collin/mahjong · 5d3e6b14
A page for turning these by eye, and the slab goes
Collin Richards · 2026-08-21 19:59 UTC · 5d3e6b14b3cd19301491aadb5c58483ca3bccbb9 · parent 16cdd507 · browse files
modifiedREADME.md+16 −0
| ⋯ 438 unchanged lines | |||
| 439 | 439 | blank tile from its **smooth** theme, tinted jade, since a solitaire game has no | |
| 440 | 440 | face-down art of its own. | |
| 441 | 441 | ||
| 442 | + | How a tile is drawn on the canvas — the outline round it and the shadow under | |
| 443 | + | it — lives in `src/ui/tileArt.ts`, and `npm run dev` serves **`/tiles.html`**, a | |
| 444 | + | page of every face at every angle over the felt with a slider on each of those | |
| 445 | + | numbers and the finished block ready to paste back. None of it can be judged | |
| 446 | + | from the values: it is 246 white against a dark green at 35 pixels across, and | |
| 447 | + | the only way to know is to look at it and move something. (`tiles.html` is not | |
| 448 | + | one of the build's entry points, so it does not ship.) | |
| 449 | + | ||
| 450 | + | A tile used to be drawn as two passes — a slab of ivory offset down the screen | |
| 451 | + | with the face on top, so a sliver of the tile's own side showed along the bottom | |
| 452 | + | edge. At the size a discard is actually drawn that never read as thickness; it | |
| 453 | + | read as a second, paler shape stuck to the bottom of every tile. Shading it | |
| 454 | + | better did not help, because the problem was that it was there. What gives a | |
| 455 | + | tile its weight now is the shadow and the outline, both of which are true at | |
| 456 | + | any size. | |
| 457 | + | ||
| 442 | 458 | That art is **GPL-2.0-or-later**. Fine for playing at home; if you ever | |
| 443 | 459 | distribute this app, either honour the GPL or swap the two files for art of your | |
| 444 | 460 | own — `SPRITE_COL` in `tiles.ts` is the only mapping that would need updating. | |
| ⋯ 148 unchanged lines | |||
addedsrc/tune.css+145 −0
| 1 | + | /* The tile tuner. Its own stylesheet rather than the table's: nothing here is | |
| 2 | + | the game, and the game should not have to carry it. */ | |
| 3 | + | ||
| 4 | + | :root { | |
| 5 | + | color-scheme: dark; | |
| 6 | + | --ink: #f6f0e2; | |
| 7 | + | --gold: #d9b45c; | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | * { | |
| 11 | + | box-sizing: border-box; | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | body { | |
| 15 | + | margin: 0; | |
| 16 | + | background: #14181a; | |
| 17 | + | color: var(--ink); | |
| 18 | + | font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | .tune { | |
| 22 | + | display: flex; | |
| 23 | + | min-height: 100vh; | |
| 24 | + | align-items: flex-start; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /* The tiles get the room; the sliders get a column. */ | |
| 28 | + | .stage { | |
| 29 | + | flex: 1; | |
| 30 | + | min-width: 0; | |
| 31 | + | overflow: auto; | |
| 32 | + | padding: 16px; | |
| 33 | + | } | |
| 34 | + | .stage canvas { | |
| 35 | + | display: block; | |
| 36 | + | border-radius: 8px; | |
| 37 | + | image-rendering: pixelated; | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | .panel { | |
| 41 | + | flex: none; | |
| 42 | + | width: 340px; | |
| 43 | + | padding: 16px 16px 40px; | |
| 44 | + | border-left: 1px solid rgba(246, 240, 226, 0.12); | |
| 45 | + | position: sticky; | |
| 46 | + | top: 0; | |
| 47 | + | max-height: 100vh; | |
| 48 | + | overflow: auto; | |
| 49 | + | } | |
| 50 | + | .panel h1 { | |
| 51 | + | margin: 0 0 6px; | |
| 52 | + | font-size: 18px; | |
| 53 | + | font-weight: 500; | |
| 54 | + | color: var(--gold); | |
| 55 | + | } | |
| 56 | + | .panel .hint { | |
| 57 | + | margin: 0 0 16px; | |
| 58 | + | font-size: 12px; | |
| 59 | + | opacity: 0.6; | |
| 60 | + | } | |
| 61 | + | .panel code { | |
| 62 | + | font-family: ui-monospace, monospace; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | .knob { | |
| 66 | + | display: grid; | |
| 67 | + | grid-template-columns: 130px 1fr auto; | |
| 68 | + | align-items: center; | |
| 69 | + | gap: 8px; | |
| 70 | + | margin-bottom: 7px; | |
| 71 | + | } | |
| 72 | + | .knob > span { | |
| 73 | + | display: flex; | |
| 74 | + | flex-direction: column; | |
| 75 | + | font-size: 12px; | |
| 76 | + | } | |
| 77 | + | .knob em { | |
| 78 | + | font-style: normal; | |
| 79 | + | font-size: 10px; | |
| 80 | + | opacity: 0.5; | |
| 81 | + | } | |
| 82 | + | .knob input[type='range'] { | |
| 83 | + | width: 100%; | |
| 84 | + | accent-color: var(--gold); | |
| 85 | + | } | |
| 86 | + | .knob input[type='text'] { | |
| 87 | + | width: 100%; | |
| 88 | + | background: #0d1012; | |
| 89 | + | border: 1px solid rgba(246, 240, 226, 0.18); | |
| 90 | + | border-radius: 5px; | |
| 91 | + | color: var(--ink); | |
| 92 | + | font-family: ui-monospace, monospace; | |
| 93 | + | font-size: 11px; | |
| 94 | + | padding: 4px 6px; | |
| 95 | + | } | |
| 96 | + | .knob output { | |
| 97 | + | font-family: ui-monospace, monospace; | |
| 98 | + | font-size: 11px; | |
| 99 | + | opacity: 0.75; | |
| 100 | + | min-width: 52px; | |
| 101 | + | text-align: right; | |
| 102 | + | } | |
| 103 | + | .knob .swatch { | |
| 104 | + | width: 20px; | |
| 105 | + | height: 20px; | |
| 106 | + | border-radius: 4px; | |
| 107 | + | border: 1px solid rgba(246, 240, 226, 0.25); | |
| 108 | + | /* Checks behind it, so an alpha reads as an alpha. */ | |
| 109 | + | background-image: linear-gradient(45deg, #555 25%, transparent 25%), | |
| 110 | + | linear-gradient(-45deg, #555 25%, transparent 25%); | |
| 111 | + | background-size: 8px 8px; | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | .row { | |
| 115 | + | display: flex; | |
| 116 | + | gap: 8px; | |
| 117 | + | margin: 14px 0 10px; | |
| 118 | + | } | |
| 119 | + | .btn { | |
| 120 | + | flex: 1; | |
| 121 | + | padding: 7px 10px; | |
| 122 | + | border-radius: 6px; | |
| 123 | + | border: 1px solid rgba(246, 240, 226, 0.25); | |
| 124 | + | background: transparent; | |
| 125 | + | color: var(--ink); | |
| 126 | + | font: inherit; | |
| 127 | + | font-size: 12px; | |
| 128 | + | cursor: pointer; | |
| 129 | + | } | |
| 130 | + | .btn:hover { | |
| 131 | + | border-color: var(--gold); | |
| 132 | + | color: var(--gold); | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | .panel pre { | |
| 136 | + | margin: 0; | |
| 137 | + | padding: 10px; | |
| 138 | + | background: #0d1012; | |
| 139 | + | border-radius: 6px; | |
| 140 | + | font-family: ui-monospace, monospace; | |
| 141 | + | font-size: 11px; | |
| 142 | + | line-height: 1.5; | |
| 143 | + | white-space: pre-wrap; | |
| 144 | + | opacity: 0.85; | |
| 145 | + | } |
addedsrc/tune.tsx+223 −0
| 1 | + | import { useEffect, useRef, useState } from 'react'; | |
| 2 | + | import { createRoot } from 'react-dom/client'; | |
| 3 | + | import { LOOK, drawShadow, drawTile, loadArt, type Art, type TileLook } from './ui/tileArt'; | |
| 4 | + | import './tune.css'; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * A page for turning the numbers that decide how a tile looks, against the | |
| 8 | + | * thing itself. | |
| 9 | + | * | |
| 10 | + | * How a tile reads on cloth is not something anybody can work out from the | |
| 11 | + | * values — it is 246 white against a dark green, a slab of ivory a few pixels | |
| 12 | + | * tall, and a shadow, and the only way to know is to look at it and move | |
| 13 | + | * something. So: every tile, at every angle, at the sizes they are actually | |
| 14 | + | * drawn, over the same felt, with a slider on each number and the finished | |
| 15 | + | * values ready to paste back into `ui/tileArt.ts`. | |
| 16 | + | * | |
| 17 | + | * Dev only — `tiles.html` is not one of the build's entry points, so it is | |
| 18 | + | * served by `npm run dev` and does not ship. | |
| 19 | + | */ | |
| 20 | + | ||
| 21 | + | /** One row of the sheet, plus a couple of backs, at pool and hand size. */ | |
| 22 | + | const FACES = [0, 4, 9, 13, 18, 22, 27, 31, 34]; | |
| 23 | + | ||
| 24 | + | interface Knob { | |
| 25 | + | key: keyof TileLook; | |
| 26 | + | label: string; | |
| 27 | + | min?: number; | |
| 28 | + | max?: number; | |
| 29 | + | step?: number; | |
| 30 | + | note: string; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | const KNOBS: Knob[] = [ | |
| 34 | + | { key: 'edgeWidth', label: 'Outline width', min: 0, max: 4, step: 0.25, note: 'px' }, | |
| 35 | + | { key: 'shadowAlpha', label: 'Shadow', min: 0, max: 1, step: 0.01, note: 'darkest, on the felt' }, | |
| 36 | + | { key: 'shadowBlur', label: 'Shadow blur', min: 0, max: 0.4, step: 0.005, note: 'of the width' }, | |
| 37 | + | { key: 'shadowDown', label: 'Shadow down', min: 0, max: 16, step: 0.5, note: 'px at rest' }, | |
| 38 | + | { key: 'shadowAcross', label: 'Shadow across', min: 0, max: 0.5, step: 0.01, note: 'per unit up' }, | |
| 39 | + | { key: 'shadowDownZ', label: 'Shadow down', min: 0, max: 1.2, step: 0.01, note: 'per unit up' }, | |
| 40 | + | { key: 'shadowSpread', label: 'Shadow spread', min: 0, max: 0.02, step: 0.0005, note: 'per unit up' }, | |
| 41 | + | { key: 'shadowFade', label: 'Shadow fade', min: 0, max: 0.01, step: 0.0002, note: 'per unit up' }, | |
| 42 | + | ]; | |
| 43 | + | ||
| 44 | + | const COLOURS: { key: keyof TileLook; label: string; note: string }[] = [ | |
| 45 | + | { key: 'edgeLine', label: 'Outline', note: 'takes an alpha' }, | |
| 46 | + | ]; | |
| 47 | + | ||
| 48 | + | /** The felt, as `styles.css` paints it, so the tiles are judged against it. */ | |
| 49 | + | const FELT = '#0d3b2a'; | |
| 50 | + | const FELT_2 = '#07271b'; | |
| 51 | + | ||
| 52 | + | function Tuner() { | |
| 53 | + | const [look, setLook] = useState<TileLook>({ ...LOOK }); | |
| 54 | + | const [zoom, setZoom] = useState(3); | |
| 55 | + | const [art, setArt] = useState<Art>({ faces: null, back: null }); | |
| 56 | + | const [copied, setCopied] = useState(false); | |
| 57 | + | const canvas = useRef<HTMLCanvasElement>(null); | |
| 58 | + | ||
| 59 | + | useEffect(() => { | |
| 60 | + | let alive = true; | |
| 61 | + | void loadArt().then((a) => alive && setArt(a)); | |
| 62 | + | return () => { | |
| 63 | + | alive = false; | |
| 64 | + | }; | |
| 65 | + | }, []); | |
| 66 | + | ||
| 67 | + | useEffect(() => { | |
| 68 | + | const cv = canvas.current; | |
| 69 | + | const ctx = cv?.getContext('2d'); | |
| 70 | + | if (!cv || !ctx) return; | |
| 71 | + | ||
| 72 | + | // A tile in the middle is 35px across on an ordinary window; the hand ones | |
| 73 | + | // are the same tile, drawn where you can read them. | |
| 74 | + | const sizes = [{ w: 35, z: 0 }, { w: 35, z: 26 }, { w: 56, z: 0 }]; | |
| 75 | + | const gap = 18; | |
| 76 | + | // Room for the widest tile turned on its corner, and for its shadow. | |
| 77 | + | const rowH = Math.max(...sizes.map((s) => s.w * 1.375)) * 1.5 + 20; | |
| 78 | + | const head = 90; | |
| 79 | + | const width = 110 + FACES.length * (56 * 1.5 + gap); | |
| 80 | + | const height = head + sizes.length * (rowH + gap); | |
| 81 | + | ||
| 82 | + | const dpr = window.devicePixelRatio || 1; | |
| 83 | + | cv.width = Math.round(width * zoom * dpr); | |
| 84 | + | cv.height = Math.round(height * zoom * dpr); | |
| 85 | + | cv.style.width = `${width * zoom}px`; | |
| 86 | + | cv.style.height = `${height * zoom}px`; | |
| 87 | + | ctx.setTransform(dpr * zoom, 0, 0, dpr * zoom, 0, 0); | |
| 88 | + | ||
| 89 | + | const felt = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width * 0.7); | |
| 90 | + | felt.addColorStop(0, FELT); | |
| 91 | + | felt.addColorStop(1, FELT_2); | |
| 92 | + | ctx.fillStyle = felt; | |
| 93 | + | ctx.fillRect(0, 0, width, height); | |
| 94 | + | ||
| 95 | + | // Every angle, so a tile resting sideways is judged as well as one square | |
| 96 | + | // on — the side is offset down the screen whatever the tile is doing. | |
| 97 | + | sizes.forEach((size, row) => { | |
| 98 | + | const y = head + row * (rowH + gap) + rowH / 2; | |
| 99 | + | FACES.forEach((code, i) => { | |
| 100 | + | const x = 90 + i * (56 * 1.5 + gap); | |
| 101 | + | const angle = (i / (FACES.length - 1)) * Math.PI - Math.PI / 2; | |
| 102 | + | const w = size.w; | |
| 103 | + | const h = w * 1.375; | |
| 104 | + | drawShadow(ctx, x, y, size.z, angle, w, h, 1, 1, 0, look); | |
| 105 | + | drawTile( | |
| 106 | + | ctx, | |
| 107 | + | art, | |
| 108 | + | { x, y, z: size.z, angle, w, h, facing: 1, axis: 0, code, lit: false }, | |
| 109 | + | look, | |
| 110 | + | ); | |
| 111 | + | }); | |
| 112 | + | ctx.fillStyle = 'rgba(246,240,226,0.5)'; | |
| 113 | + | ctx.font = '11px ui-monospace, monospace'; | |
| 114 | + | ctx.textBaseline = 'middle'; | |
| 115 | + | ctx.fillText(size.z ? `${size.w}px, up ${size.z}` : `${size.w}px`, 8, y); | |
| 116 | + | }); | |
| 117 | + | ||
| 118 | + | // A back and a lit tile, since both are drawn on the table too. | |
| 119 | + | const y = 44; | |
| 120 | + | ctx.fillStyle = 'rgba(246,240,226,0.5)'; | |
| 121 | + | ctx.font = '11px ui-monospace, monospace'; | |
| 122 | + | ctx.fillText('plain · lit · back', 8, y); | |
| 123 | + | [0, 1, 2].forEach((i) => { | |
| 124 | + | const x = 130 + i * 60; | |
| 125 | + | drawShadow(ctx, x, y, 0, 0.2, 35, 48, 1, i === 2 ? -1 : 1, 0, look); | |
| 126 | + | drawTile( | |
| 127 | + | ctx, | |
| 128 | + | art, | |
| 129 | + | { x, y, z: 0, angle: 0.2, w: 35, h: 48, facing: i === 2 ? -1 : 1, axis: 0, code: 7, lit: i === 1 }, | |
| 130 | + | look, | |
| 131 | + | ); | |
| 132 | + | }); | |
| 133 | + | }, [look, zoom, art]); | |
| 134 | + | ||
| 135 | + | const set = (key: keyof TileLook, value: string | number) => | |
| 136 | + | setLook((l) => ({ ...l, [key]: value })); | |
| 137 | + | ||
| 138 | + | const source = `export const LOOK: TileLook = {\n${(Object.keys(LOOK) as (keyof TileLook)[]) | |
| 139 | + | .map((k) => ` ${k}: ${typeof look[k] === 'string' ? `'${look[k]}'` : look[k]},`) | |
| 140 | + | .join('\n')}\n};`; | |
| 141 | + | ||
| 142 | + | return ( | |
| 143 | + | <div className="tune"> | |
| 144 | + | <div className="stage"> | |
| 145 | + | <canvas ref={canvas} /> | |
| 146 | + | </div> | |
| 147 | + | <div className="panel"> | |
| 148 | + | <h1>Tile look</h1> | |
| 149 | + | <p className="hint"> | |
| 150 | + | Every face at every angle, over the felt, at the sizes they are really drawn. Turn | |
| 151 | + | something, look at it, and paste the block at the bottom into <code>ui/tileArt.ts</code>. | |
| 152 | + | </p> | |
| 153 | + | ||
| 154 | + | <label className="knob"> | |
| 155 | + | <span> | |
| 156 | + | Zoom<em>×{zoom}</em> | |
| 157 | + | </span> | |
| 158 | + | <input | |
| 159 | + | type="range" | |
| 160 | + | min={1} | |
| 161 | + | max={8} | |
| 162 | + | step={1} | |
| 163 | + | value={zoom} | |
| 164 | + | onChange={(e) => setZoom(+e.target.value)} | |
| 165 | + | /> | |
| 166 | + | </label> | |
| 167 | + | ||
| 168 | + | {COLOURS.map((c) => ( | |
| 169 | + | <label className="knob" key={c.key}> | |
| 170 | + | <span> | |
| 171 | + | {c.label} | |
| 172 | + | <em>{c.note}</em> | |
| 173 | + | </span> | |
| 174 | + | <input | |
| 175 | + | type="text" | |
| 176 | + | spellCheck={false} | |
| 177 | + | value={String(look[c.key])} | |
| 178 | + | onChange={(e) => set(c.key, e.target.value)} | |
| 179 | + | /> | |
| 180 | + | <i className="swatch" style={{ background: String(look[c.key]) }} /> | |
| 181 | + | </label> | |
| 182 | + | ))} | |
| 183 | + | ||
| 184 | + | {KNOBS.map((k) => ( | |
| 185 | + | <label className="knob" key={`${k.key}-${k.note}`}> | |
| 186 | + | <span> | |
| 187 | + | {k.label} | |
| 188 | + | <em>{k.note}</em> | |
| 189 | + | </span> | |
| 190 | + | <input | |
| 191 | + | type="range" | |
| 192 | + | min={k.min} | |
| 193 | + | max={k.max} | |
| 194 | + | step={k.step} | |
| 195 | + | value={Number(look[k.key])} | |
| 196 | + | onChange={(e) => set(k.key, +e.target.value)} | |
| 197 | + | /> | |
| 198 | + | <output>{Number(look[k.key])}</output> | |
| 199 | + | </label> | |
| 200 | + | ))} | |
| 201 | + | ||
| 202 | + | <div className="row"> | |
| 203 | + | <button className="btn" onClick={() => setLook({ ...LOOK })}> | |
| 204 | + | Back to the table's | |
| 205 | + | </button> | |
| 206 | + | <button | |
| 207 | + | className="btn" | |
| 208 | + | onClick={() => { | |
| 209 | + | void navigator.clipboard?.writeText(source); | |
| 210 | + | setCopied(true); | |
| 211 | + | setTimeout(() => setCopied(false), 1200); | |
| 212 | + | }} | |
| 213 | + | > | |
| 214 | + | {copied ? 'Copied' : 'Copy'} | |
| 215 | + | </button> | |
| 216 | + | </div> | |
| 217 | + | <pre>{source}</pre> | |
| 218 | + | </div> | |
| 219 | + | </div> | |
| 220 | + | ); | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | createRoot(document.getElementById('root')!).render(<Tuner />); |
modifiedsrc/ui/Pool.tsx+9 −282
| 1 | 1 | import { useEffect, useRef, useState } from 'react'; | |
| 2 | - | // Imported rather than named by path, so the canvas draws the very files the | |
| 3 | - | // CSS tiles use wherever the bundle ends up being served from. | |
| 4 | - | import backUrl from '../assets/back.png'; | |
| 5 | - | import tilesUrl from '../assets/tiles.svg'; | |
| 6 | 2 | import type { TableCtl } from '../game/ctl'; | |
| 7 | 3 | import { barge, knock } from '../game/sound'; | |
| 8 | - | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; | |
| 9 | - | import { aabbOf, centreLimits, faceOn, zScale, type Rect } from '../table/physics'; | |
| 4 | + | import { aabbOf, centreLimits, faceOn, type Rect } from '../table/physics'; | |
| 10 | 5 | import type { TablePool } from '../table/pool'; | |
| 11 | 6 | import type { SeatId } from '../game/types'; | |
| 12 | 7 | import { | |
| ⋯ 9 unchanged lines | |||
| 22 | 17 | showingColliders, | |
| 23 | 18 | } from './colliders'; | |
| 24 | 19 | import { EDGE_SEAT } from './rotation'; | |
| 20 | + | import { drawShadow, drawTile, loadArt, type Art } from './tileArt'; | |
| 25 | 21 | ||
| 26 | 22 | /** | |
| 27 | 23 | * The tiles in the middle of the table, drawn. | |
| ⋯ 209 unchanged lines | |||
| 237 | 233 | }; | |
| 238 | 234 | } | |
| 239 | 235 | ||
| 240 | - | interface Art { | |
| 241 | - | faces: HTMLImageElement | null; | |
| 242 | - | back: HTMLImageElement | null; | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | /** | |
| 246 | - | * The tile art, as something canvas can draw: the face sheet, and the green | |
| 247 | - | * back a tile shows while it is turning over. Both are the same files the CSS | |
| 248 | - | * uses for the tiles in the hands and the wall, so the pool cannot drift from | |
| 249 | - | * the rest of the table. | |
| 250 | - | */ | |
| 236 | + | /** The sheet and the green back, once, shared by every tile drawn. */ | |
| 251 | 237 | function useSheet(): Art { | |
| 252 | 238 | const [art, setArt] = useState<Art>({ faces: null, back: null }); | |
| 253 | 239 | useEffect(() => { | |
| 254 | 240 | let alive = true; | |
| 255 | - | const load = (src: string) => { | |
| 256 | - | const el = new Image(); | |
| 257 | - | el.src = src; | |
| 258 | - | // Not fatal: the pool falls back to blank tiles rather than emptying out. | |
| 259 | - | return el.decode().then( | |
| 260 | - | () => el, | |
| 261 | - | () => null, | |
| 262 | - | ); | |
| 263 | - | }; | |
| 264 | - | void Promise.all([load(tilesUrl), load(backUrl)]).then( | |
| 265 | - | ([faces, back]) => alive && setArt({ faces, back }), | |
| 266 | - | ); | |
| 241 | + | void loadArt().then((a) => alive && setArt(a)); | |
| 267 | 242 | return () => { | |
| 268 | 243 | alive = false; | |
| 269 | 244 | }; | |
| ⋯ 57 unchanged lines | |||
| 327 | 302 | // Shadows in one pass of their own, offset before rotation, so the light comes | |
| 328 | 303 | // from one direction across the whole pool rather than turning with each tile. | |
| 329 | 304 | for (const b of bodies) { | |
| 330 | - | shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis); | |
| 305 | + | drawShadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis); | |
| 331 | 306 | } | |
| 332 | - | if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0); | |
| 307 | + | if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0); | |
| 333 | 308 | ||
| 334 | 309 | const newest = pool.newest; | |
| 335 | 310 | for (const b of bodies) { | |
| 336 | - | tile(ctx, art, { | |
| 311 | + | drawTile(ctx, art, { | |
| 337 | 312 | x: b.x, | |
| 338 | 313 | y: b.y, | |
| 339 | 314 | z: b.z, | |
| ⋯ 11 unchanged lines | |||
| 351 | 326 | for (const { body: b, fade } of departing) { | |
| 352 | 327 | ctx.save(); | |
| 353 | 328 | ctx.globalAlpha = fade; | |
| 354 | - | tile(ctx, art, { | |
| 329 | + | drawTile(ctx, art, { | |
| 355 | 330 | x: b.x, | |
| 356 | 331 | y: b.y, | |
| 357 | 332 | z: b.z, | |
| ⋯ 10 unchanged lines | |||
| 368 | 343 | ||
| 369 | 344 | // Whatever is in your fingers is above everything else on the table. | |
| 370 | 345 | if (held) { | |
| 371 | - | tile(ctx, art, { | |
| 346 | + | drawTile(ctx, art, { | |
| 372 | 347 | x: held.x, | |
| 373 | 348 | y: held.y, | |
| 374 | 349 | z: HELD_Z, | |
| ⋯ 129 unchanged lines | |||
| 504 | 479 | ctx.restore(); | |
| 505 | 480 | } | |
| 506 | 481 | ||
| 507 | - | /** | |
| 508 | - | * How thick a tile is, as a fraction of its width. A real one is about | |
| 509 | - | * three-quarters as thick as it is wide; this is the part of that you can see | |
| 510 | - | * from almost overhead, which is a good deal less. | |
| 511 | - | */ | |
| 512 | - | const THICKNESS = 0.3; | |
| 513 | - | /** The ivory of the tile's own body, seen along its sides. */ | |
| 514 | - | const EDGE_LIGHT = '#e6dcc2'; | |
| 515 | - | const EDGE_DARK = '#b9ad8d'; | |
| 516 | - | /** | |
| 517 | - | * The line round the outside of a tile. | |
| 518 | - | * | |
| 519 | - | * The art already has one — a single black pixel all the way round each 128px | |
| 520 | - | * cell — but a tile in the middle is drawn about a quarter of that size, and | |
| 521 | - | * `drawImage` box-filters that one pixel down to a quarter of its strength. On | |
| 522 | - | * the two sides the art shades for itself, left and bottom, there is a painted | |
| 523 | - | * bevel underneath and you cannot tell. On the other two there is nothing but | |
| 524 | - | * the outline, so white met green with no edge at all and the tile read as a | |
| 525 | - | * flat shape rather than something lying on cloth. Drawn as a stroke instead of | |
| 526 | - | * relying on the bitmap, it holds at any size. | |
| 527 | - | */ | |
| 528 | - | const EDGE_LINE = 'rgba(52, 43, 29, 0.55)'; | |
| 529 | - | ||
| 530 | - | interface Drawn { | |
| 531 | - | x: number; | |
| 532 | - | y: number; | |
| 533 | - | z: number; | |
| 534 | - | angle: number; | |
| 535 | - | w: number; | |
| 536 | - | h: number; | |
| 537 | - | /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */ | |
| 538 | - | facing: number; | |
| 539 | - | axis: number; | |
| 540 | - | code: number; | |
| 541 | - | lit: boolean; | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | /** | |
| 545 | - | * One tile, with the thickness of one. | |
| 546 | - | * | |
| 547 | - | * Drawn as two passes: the body of the tile, offset down the screen towards the | |
| 548 | - | * viewer, and then its face on top of that. The sliver of body left showing | |
| 549 | - | * along the bottom edge is what stops a tile reading as a printed card. | |
| 550 | - | * | |
| 551 | - | * Turning over makes that matter more, not less. Face on you see the face and a | |
| 552 | - | * hair of the side; edge on there is no face left to see and the tile is a bar | |
| 553 | - | * of ivory as thick as it really is — which is why the squash uses the | |
| 554 | - | * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than | |
| 555 | - | * pinching to nothing and back. | |
| 556 | - | */ | |
| 557 | - | function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) { | |
| 558 | - | const s = zScale(d.z); | |
| 559 | - | const w = d.w * s; | |
| 560 | - | const h = d.h * s; | |
| 561 | - | const thick = w * THICKNESS; | |
| 562 | - | const c = Math.abs(d.facing); | |
| 563 | - | const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing)); | |
| 564 | - | // What the whole slab covers across the axis it is turning about. | |
| 565 | - | const projected = c + (thick / w) * sin; | |
| 566 | - | // How much of the body shows past the face: none of it face on beyond its own | |
| 567 | - | // thickness, all of it edge on. Higher tiles show a touch more of their side. | |
| 568 | - | const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05; | |
| 569 | - | ||
| 570 | - | // --- the body of the tile, seen from just off square | |
| 571 | - | // | |
| 572 | - | // Only the sliver past the bottom of the face is ever seen of this, and it is | |
| 573 | - | // the side of the tile: it faces the viewer rather than the light, so it is | |
| 574 | - | // the *darker* end of the ivory. It used to be the lighter one — the gradient | |
| 575 | - | // ran dark at the tile's top, which is the half the face covers — so the one | |
| 576 | - | // part on show was within a tenth of the white face and read as more face | |
| 577 | - | // rather than as thickness. | |
| 578 | - | // | |
| 579 | - | // Shaded down the screen rather than down the tile. The slab is offset | |
| 580 | - | // straight down whatever angle the tile lies at, so the sliver is always along | |
| 581 | - | // its lower edge; a gradient that turned with the tile put the shading | |
| 582 | - | // somewhere else entirely as soon as one came to rest sideways. The path is | |
| 583 | - | // laid down rotated and then filled with the rotation taken back off, which | |
| 584 | - | // leaves the shape turned and the light coming from one direction across the | |
| 585 | - | // whole pool — the same direction the shadows fall in. | |
| 586 | - | ctx.save(); | |
| 587 | - | ctx.translate(d.x, d.y + lift); | |
| 588 | - | squash(ctx, projected, d.axis); | |
| 589 | - | ctx.save(); | |
| 590 | - | ctx.rotate(d.angle); | |
| 591 | - | tilePath(ctx, w, h); | |
| 592 | - | ctx.restore(); | |
| 593 | - | const grad = ctx.createLinearGradient(0, h / 2 - lift, 0, h / 2); | |
| 594 | - | grad.addColorStop(0, EDGE_LIGHT); | |
| 595 | - | grad.addColorStop(1, EDGE_DARK); | |
| 596 | - | ctx.fillStyle = grad; | |
| 597 | - | ctx.fill(); | |
| 598 | - | ctx.strokeStyle = EDGE_LINE; | |
| 599 | - | ctx.lineWidth = 1; | |
| 600 | - | ctx.stroke(); | |
| 601 | - | ctx.restore(); | |
| 602 | - | ||
| 603 | - | // --- and its face on top | |
| 604 | - | ctx.save(); | |
| 605 | - | ctx.translate(d.x, d.y); | |
| 606 | - | squash(ctx, Math.max(0.015, c), d.axis); | |
| 607 | - | ctx.rotate(d.angle); | |
| 608 | - | face(ctx, d.code, w, h, art, d.lit, d.facing < 0); | |
| 609 | - | ctx.restore(); | |
| 610 | - | } | |
| 611 | - | ||
| 612 | - | /** | |
| 613 | - | * A soft silhouette under a tile. | |
| 614 | - | * | |
| 615 | - | * The blur is baked into a sprite once per tile size rather than applied per | |
| 616 | - | * draw. `ctx.filter` is the obvious way to write this and the wrong one: every | |
| 617 | - | * filtered fill makes the browser allocate and blur its own layer, so sixty | |
| 618 | - | * tiles times sixty frames is sixty times sixty blurs a second, which is enough | |
| 619 | - | * to make the tab stop answering. One blur, then `drawImage`. | |
| 620 | - | * | |
| 621 | - | * Height is sold by scaling that sprite up — a shadow that spreads and fades as | |
| 622 | - | * the tile rises — which a fixed blur radius could not have done anyway. | |
| 623 | - | */ | |
| 624 | - | const shadows = new Map<string, HTMLCanvasElement>(); | |
| 625 | - | ||
| 626 | - | function shadowSprite(w: number, h: number): HTMLCanvasElement { | |
| 627 | - | const key = `${Math.round(w)}x${Math.round(h)}`; | |
| 628 | - | const had = shadows.get(key); | |
| 629 | - | if (had) return had; | |
| 630 | - | // Only ever a handful — one per tile size on screen — but a window being | |
| 631 | - | // dragged to resize walks through a lot of them. | |
| 632 | - | if (shadows.size > 8) shadows.clear(); | |
| 633 | - | ||
| 634 | - | const pad = Math.ceil(Math.max(8, w * 0.5)); | |
| 635 | - | const c = document.createElement('canvas'); | |
| 636 | - | c.width = Math.ceil(w) + pad * 2; | |
| 637 | - | c.height = Math.ceil(h) + pad * 2; | |
| 638 | - | const g = c.getContext('2d'); | |
| 639 | - | if (g) { | |
| 640 | - | g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`; | |
| 641 | - | g.fillStyle = '#000'; | |
| 642 | - | g.translate(c.width / 2, c.height / 2); | |
| 643 | - | g.beginPath(); | |
| 644 | - | g.roundRect(-w / 2, -h / 2, w, h, w * 0.1); | |
| 645 | - | g.fill(); | |
| 646 | - | } | |
| 647 | - | shadows.set(key, c); | |
| 648 | - | return c; | |
| 649 | - | } | |
| 650 | - | ||
| 651 | - | /** | |
| 652 | - | * Foreshortening along the axis a tile is turning about. | |
| 653 | - | * | |
| 654 | - | * There is no third dimension here, so an out-of-plane turn is drawn as a | |
| 655 | - | * squash. Applied in the *table's* frame, before the tile's own flat rotation, | |
| 656 | - | * because the axis it turns about is fixed by the direction it was thrown, not | |
| 657 | - | * by which way the tile happens to be facing. | |
| 658 | - | */ | |
| 659 | - | function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) { | |
| 660 | - | if (across > 0.999) return; | |
| 661 | - | ctx.rotate(axis); | |
| 662 | - | ctx.scale(across, 1); | |
| 663 | - | ctx.rotate(-axis); | |
| 664 | - | } | |
| 665 | - | ||
| 666 | - | function shadow( | |
| 667 | - | ctx: CanvasRenderingContext2D, | |
| 668 | - | x: number, | |
| 669 | - | y: number, | |
| 670 | - | z: number, | |
| 671 | - | angle: number, | |
| 672 | - | w: number, | |
| 673 | - | h: number, | |
| 674 | - | scale: number, | |
| 675 | - | facing: number, | |
| 676 | - | axis: number, | |
| 677 | - | ) { | |
| 678 | - | // Keyed on the tile's own size, never on the eased one — a scale that changes | |
| 679 | - | // every frame would mean a fresh blur every frame, which is the thing this | |
| 680 | - | // exists to avoid. | |
| 681 | - | const sprite = shadowSprite(w, h); | |
| 682 | - | const spread = zScale(z) * scale * (1 + z * 0.004); | |
| 683 | - | ctx.save(); | |
| 684 | - | ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016); | |
| 685 | - | ctx.translate(x + z * 0.12, y + 3 + z * 0.34); | |
| 686 | - | squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis); | |
| 687 | - | ctx.rotate(angle); | |
| 688 | - | ctx.drawImage( | |
| 689 | - | sprite, | |
| 690 | - | (-sprite.width / 2) * spread, | |
| 691 | - | (-sprite.height / 2) * spread, | |
| 692 | - | sprite.width * spread, | |
| 693 | - | sprite.height * spread, | |
| 694 | - | ); | |
| 695 | - | ctx.restore(); | |
| 696 | - | } | |
| 697 | - | ||
| 698 | - | function face( | |
| 699 | - | ctx: CanvasRenderingContext2D, | |
| 700 | - | tile: number, | |
| 701 | - | w: number, | |
| 702 | - | h: number, | |
| 703 | - | art: Art, | |
| 704 | - | lit: boolean, | |
| 705 | - | backwards: boolean, | |
| 706 | - | ) { | |
| 707 | - | // Part-way through a turn you are looking at the back of it: the same green | |
| 708 | - | // tile back the wall is built from. A tile always comes to rest face up, so | |
| 709 | - | // this is only ever seen in the air. | |
| 710 | - | if (backwards) { | |
| 711 | - | if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h); | |
| 712 | - | else { | |
| 713 | - | tilePath(ctx, w, h); | |
| 714 | - | ctx.fillStyle = '#1c7a4b'; | |
| 715 | - | ctx.fill(); | |
| 716 | - | } | |
| 717 | - | // A green back on green cloth needs the edge more than a white face does. | |
| 718 | - | tilePath(ctx, w, h); | |
| 719 | - | ctx.strokeStyle = EDGE_LINE; | |
| 720 | - | ctx.lineWidth = 1; | |
| 721 | - | ctx.stroke(); | |
| 722 | - | return; | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | if (art.faces) { | |
| 726 | - | // 43 columns, two rows; the second is the sheet's own highlighted variant, | |
| 727 | - | // which is what a lifted tile in a hand uses. The tile in your fingers and | |
| 728 | - | // the one still to be claimed get it here for the same reason. | |
| 729 | - | const cw = art.faces.naturalWidth / SPRITE_COLS; | |
| 730 | - | const ch = art.faces.naturalHeight / 2; | |
| 731 | - | ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h); | |
| 732 | - | } else { | |
| 733 | - | tilePath(ctx, w, h); | |
| 734 | - | ctx.fillStyle = '#f6f0e2'; | |
| 735 | - | ctx.fill(); | |
| 736 | - | } | |
| 737 | - | // The edge the shrunken art no longer carries for itself. | |
| 738 | - | tilePath(ctx, w, h); | |
| 739 | - | ctx.strokeStyle = EDGE_LINE; | |
| 740 | - | ctx.lineWidth = 1; | |
| 741 | - | ctx.stroke(); | |
| 742 | - | if (lit) { | |
| 743 | - | // A pool of sixty tiles needs more than a lighter face to pick one out. | |
| 744 | - | tilePath(ctx, w, h); | |
| 745 | - | ctx.strokeStyle = '#d9b45c'; | |
| 746 | - | ctx.lineWidth = 2; | |
| 747 | - | ctx.stroke(); | |
| 748 | - | } | |
| 749 | - | } | |
| 750 | - | ||
| 751 | - | function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) { | |
| 752 | - | ctx.beginPath(); | |
| 753 | - | ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1); | |
| 754 | - | } | |
addedsrc/ui/tileArt.ts+268 −0
| 1 | + | import backUrl from '../assets/back.png'; | |
| 2 | + | import tilesUrl from '../assets/tiles.svg'; | |
| 3 | + | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; | |
| 4 | + | import { zScale } from '../table/physics'; | |
| 5 | + | ||
| 6 | + | /** | |
| 7 | + | * Drawing one tile, and the shadow under it. | |
| 8 | + | * | |
| 9 | + | * Pulled out of the renderer so the numbers in `LOOK` can be turned by hand | |
| 10 | + | * against the real thing rather than guessed at: `tiles.html` is a page of | |
| 11 | + | * these at every angle and size with a slider on each of them. Nothing in here | |
| 12 | + | * knows about the game, the pool or the table — it is a tile, a canvas, and how | |
| 13 | + | * a tile is meant to look on cloth. | |
| 14 | + | */ | |
| 15 | + | ||
| 16 | + | /** The art, as something canvas can draw. */ | |
| 17 | + | export interface Art { | |
| 18 | + | faces: HTMLImageElement | null; | |
| 19 | + | back: HTMLImageElement | null; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | /** | |
| 23 | + | * Every number that decides how a tile reads. Defaults are what the table uses; | |
| 24 | + | * the tuner hands in its own while it is being turned. | |
| 25 | + | */ | |
| 26 | + | export interface TileLook { | |
| 27 | + | /** | |
| 28 | + | * The line round the outside. | |
| 29 | + | * | |
| 30 | + | * The art already has one — a single black pixel all the way round each 128px | |
| 31 | + | * cell — but a tile in the middle is drawn about a quarter of that, and | |
| 32 | + | * `drawImage` box-filters that one pixel down to about a quarter of its | |
| 33 | + | * strength. Drawn as a stroke instead of relying on the bitmap, it holds at | |
| 34 | + | * any size. | |
| 35 | + | */ | |
| 36 | + | edgeLine: string; | |
| 37 | + | edgeWidth: number; | |
| 38 | + | /** The shadow: how dark at rest, how much height washes it out, how far it is | |
| 39 | + | * offset across and down, how wide it spreads, and how soft it is. */ | |
| 40 | + | shadowAlpha: number; | |
| 41 | + | shadowFade: number; | |
| 42 | + | shadowAcross: number; | |
| 43 | + | shadowDown: number; | |
| 44 | + | shadowDownZ: number; | |
| 45 | + | shadowSpread: number; | |
| 46 | + | shadowBlur: number; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | export const LOOK: TileLook = { | |
| 50 | + | edgeLine: 'rgba(52, 43, 29, 0.55)', | |
| 51 | + | edgeWidth: 1, | |
| 52 | + | shadowAlpha: 0.36, | |
| 53 | + | shadowFade: 0.0016, | |
| 54 | + | shadowAcross: 0.12, | |
| 55 | + | shadowDown: 3, | |
| 56 | + | shadowDownZ: 0.34, | |
| 57 | + | shadowSpread: 0.004, | |
| 58 | + | shadowBlur: 0.11, | |
| 59 | + | }; | |
| 60 | + | ||
| 61 | + | /** Load the sheet and the back — the same files the CSS tiles use, so the pool | |
| 62 | + | * cannot drift from the rest of the table. */ | |
| 63 | + | export function loadArt(): Promise<Art> { | |
| 64 | + | const load = (src: string) => { | |
| 65 | + | const el = new Image(); | |
| 66 | + | el.src = src; | |
| 67 | + | // Not fatal: the pool falls back to blank tiles rather than emptying out. | |
| 68 | + | return el.decode().then( | |
| 69 | + | () => el, | |
| 70 | + | () => null, | |
| 71 | + | ); | |
| 72 | + | }; | |
| 73 | + | return Promise.all([load(tilesUrl), load(backUrl)]).then(([faces, back]) => ({ faces, back })); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | ||
| 77 | + | export interface Drawn { | |
| 78 | + | x: number; | |
| 79 | + | y: number; | |
| 80 | + | z: number; | |
| 81 | + | angle: number; | |
| 82 | + | w: number; | |
| 83 | + | h: number; | |
| 84 | + | /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */ | |
| 85 | + | facing: number; | |
| 86 | + | axis: number; | |
| 87 | + | code: number; | |
| 88 | + | lit: boolean; | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * One tile: its face, its outline, and nothing else. | |
| 93 | + | * | |
| 94 | + | * There used to be a slab of ivory drawn under it and offset down the screen, | |
| 95 | + | * so a sliver of the tile's own side showed along the bottom edge and it read | |
| 96 | + | * as an object rather than a printed card. Seen at the size a discard is | |
| 97 | + | * actually drawn it did not read as thickness — it read as a second, paler | |
| 98 | + | * shape stuck to the bottom of every tile, and no amount of shading the thing | |
| 99 | + | * fixed that, because the problem was that it was there at all. | |
| 100 | + | * | |
| 101 | + | * What gives a tile its weight now is the shadow under it and the line round | |
| 102 | + | * it. Both of those are true at any size, which the slab never was. | |
| 103 | + | */ | |
| 104 | + | export function drawTile( | |
| 105 | + | ctx: CanvasRenderingContext2D, | |
| 106 | + | art: Art, | |
| 107 | + | d: Drawn, | |
| 108 | + | look: TileLook = LOOK, | |
| 109 | + | ) { | |
| 110 | + | const s = zScale(d.z); | |
| 111 | + | const w = d.w * s; | |
| 112 | + | const h = d.h * s; | |
| 113 | + | ||
| 114 | + | ctx.save(); | |
| 115 | + | ctx.translate(d.x, d.y); | |
| 116 | + | // Edge on there is no face left to see, so it pinches to a line rather than | |
| 117 | + | // through to the other side; a hair of width keeps the outline drawable. | |
| 118 | + | squash(ctx, Math.max(0.015, Math.abs(d.facing)), d.axis); | |
| 119 | + | ctx.rotate(d.angle); | |
| 120 | + | face(ctx, d.code, w, h, art, d.lit, d.facing < 0, look); | |
| 121 | + | ctx.restore(); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * A soft silhouette under a tile. | |
| 126 | + | * | |
| 127 | + | * The blur is baked into a sprite once per tile size rather than applied per | |
| 128 | + | * draw. `ctx.filter` is the obvious way to write this and the wrong one: every | |
| 129 | + | * filtered fill makes the browser allocate and blur its own layer, so sixty | |
| 130 | + | * tiles times sixty frames is sixty times sixty blurs a second, which is enough | |
| 131 | + | * to make the tab stop answering. One blur, then `drawImage`. | |
| 132 | + | * | |
| 133 | + | * Height is sold by scaling that sprite up — a shadow that spreads and fades as | |
| 134 | + | * the tile rises — which a fixed blur radius could not have done anyway. | |
| 135 | + | */ | |
| 136 | + | const shadows = new Map<string, HTMLCanvasElement>(); | |
| 137 | + | ||
| 138 | + | function shadowSprite(w: number, h: number, blur: number): HTMLCanvasElement { | |
| 139 | + | const key = `${Math.round(w)}x${Math.round(h)}@${blur}`; | |
| 140 | + | const had = shadows.get(key); | |
| 141 | + | if (had) return had; | |
| 142 | + | // Only ever a handful — one per tile size on screen — but a window being | |
| 143 | + | // dragged to resize walks through a lot of them. | |
| 144 | + | if (shadows.size > 8) shadows.clear(); | |
| 145 | + | ||
| 146 | + | const pad = Math.ceil(Math.max(8, w * 0.5)); | |
| 147 | + | const c = document.createElement('canvas'); | |
| 148 | + | c.width = Math.ceil(w) + pad * 2; | |
| 149 | + | c.height = Math.ceil(h) + pad * 2; | |
| 150 | + | const g = c.getContext('2d'); | |
| 151 | + | if (g) { | |
| 152 | + | g.filter = `blur(${Math.max(1.5, w * blur).toFixed(1)}px)`; | |
| 153 | + | g.fillStyle = '#000'; | |
| 154 | + | g.translate(c.width / 2, c.height / 2); | |
| 155 | + | g.beginPath(); | |
| 156 | + | g.roundRect(-w / 2, -h / 2, w, h, w * 0.1); | |
| 157 | + | g.fill(); | |
| 158 | + | } | |
| 159 | + | shadows.set(key, c); | |
| 160 | + | return c; | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | /** | |
| 164 | + | * Foreshortening along the axis a tile is turning about. | |
| 165 | + | * | |
| 166 | + | * There is no third dimension here, so an out-of-plane turn is drawn as a | |
| 167 | + | * squash. Applied in the *table's* frame, before the tile's own flat rotation, | |
| 168 | + | * because the axis it turns about is fixed by the direction it was thrown, not | |
| 169 | + | * by which way the tile happens to be facing. | |
| 170 | + | */ | |
| 171 | + | function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) { | |
| 172 | + | if (across > 0.999) return; | |
| 173 | + | ctx.rotate(axis); | |
| 174 | + | ctx.scale(across, 1); | |
| 175 | + | ctx.rotate(-axis); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | export function drawShadow( | |
| 179 | + | ctx: CanvasRenderingContext2D, | |
| 180 | + | x: number, | |
| 181 | + | y: number, | |
| 182 | + | z: number, | |
| 183 | + | angle: number, | |
| 184 | + | w: number, | |
| 185 | + | h: number, | |
| 186 | + | scale: number, | |
| 187 | + | facing: number, | |
| 188 | + | axis: number, | |
| 189 | + | look: TileLook = LOOK, | |
| 190 | + | ) { | |
| 191 | + | // Keyed on the tile's own size, never on the eased one — a scale that changes | |
| 192 | + | // every frame would mean a fresh blur every frame, which is the thing this | |
| 193 | + | // exists to avoid. | |
| 194 | + | const sprite = shadowSprite(w, h, look.shadowBlur); | |
| 195 | + | const spread = zScale(z) * scale * (1 + z * look.shadowSpread); | |
| 196 | + | ctx.save(); | |
| 197 | + | ctx.globalAlpha = look.shadowAlpha - Math.min(look.shadowAlpha / 2, z * look.shadowFade); | |
| 198 | + | ctx.translate(x + z * look.shadowAcross, y + look.shadowDown + z * look.shadowDownZ); | |
| 199 | + | squash(ctx, Math.abs(facing), axis); | |
| 200 | + | ctx.rotate(angle); | |
| 201 | + | ctx.drawImage( | |
| 202 | + | sprite, | |
| 203 | + | (-sprite.width / 2) * spread, | |
| 204 | + | (-sprite.height / 2) * spread, | |
| 205 | + | sprite.width * spread, | |
| 206 | + | sprite.height * spread, | |
| 207 | + | ); | |
| 208 | + | ctx.restore(); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function face( | |
| 212 | + | ctx: CanvasRenderingContext2D, | |
| 213 | + | tile: number, | |
| 214 | + | w: number, | |
| 215 | + | h: number, | |
| 216 | + | art: Art, | |
| 217 | + | lit: boolean, | |
| 218 | + | backwards: boolean, | |
| 219 | + | look: TileLook, | |
| 220 | + | ) { | |
| 221 | + | // Part-way through a turn you are looking at the back of it: the same green | |
| 222 | + | // tile back the wall is built from. A tile always comes to rest face up, so | |
| 223 | + | // this is only ever seen in the air. | |
| 224 | + | if (backwards) { | |
| 225 | + | if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h); | |
| 226 | + | else { | |
| 227 | + | tilePath(ctx, w, h); | |
| 228 | + | ctx.fillStyle = '#1c7a4b'; | |
| 229 | + | ctx.fill(); | |
| 230 | + | } | |
| 231 | + | // A green back on green cloth needs the edge more than a white face does. | |
| 232 | + | tilePath(ctx, w, h); | |
| 233 | + | ctx.strokeStyle = look.edgeLine; | |
| 234 | + | ctx.lineWidth = look.edgeWidth; | |
| 235 | + | ctx.stroke(); | |
| 236 | + | return; | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | if (art.faces) { | |
| 240 | + | // 43 columns, two rows; the second is the sheet's own highlighted variant, | |
| 241 | + | // which is what a lifted tile in a hand uses. The tile in your fingers and | |
| 242 | + | // the one still to be claimed get it here for the same reason. | |
| 243 | + | const cw = art.faces.naturalWidth / SPRITE_COLS; | |
| 244 | + | const ch = art.faces.naturalHeight / 2; | |
| 245 | + | ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h); | |
| 246 | + | } else { | |
| 247 | + | tilePath(ctx, w, h); | |
| 248 | + | ctx.fillStyle = '#f6f0e2'; | |
| 249 | + | ctx.fill(); | |
| 250 | + | } | |
| 251 | + | // The edge the shrunken art no longer carries for itself. | |
| 252 | + | tilePath(ctx, w, h); | |
| 253 | + | ctx.strokeStyle = look.edgeLine; | |
| 254 | + | ctx.lineWidth = look.edgeWidth; | |
| 255 | + | ctx.stroke(); | |
| 256 | + | if (lit) { | |
| 257 | + | // A pool of sixty tiles needs more than a lighter face to pick one out. | |
| 258 | + | tilePath(ctx, w, h); | |
| 259 | + | ctx.strokeStyle = '#d9b45c'; | |
| 260 | + | ctx.lineWidth = 2; | |
| 261 | + | ctx.stroke(); | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) { | |
| 266 | + | ctx.beginPath(); | |
| 267 | + | ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1); | |
| 268 | + | } |
addedtiles.html+13 −0
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en" translate="no"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8" /> | |
| 5 | + | <meta name="google" content="notranslate" /> | |
| 6 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 7 | + | <title>Tile look</title> | |
| 8 | + | </head> | |
| 9 | + | <body> | |
| 10 | + | <div id="root"></div> | |
| 11 | + | <script type="module" src="/src/tune.tsx"></script> | |
| 12 | + | </body> | |
| 13 | + | </html> |