| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import type { Game } from '../game/engine'; |
| 3 | import { barge, knock } from '../game/sound'; |
| 4 | import { SPRITE_COL, SPRITE_COLS } from '../game/tiles'; |
| 5 | import { faceOn, zScale } from '../table/physics'; |
| 6 | import type { TablePool } from '../table/pool'; |
| 7 | import type { SeatId } from '../game/types'; |
| 8 | import { EDGE_SEAT } from './rotation'; |
| 9 | |
| 10 | /** |
| 11 | * The tiles in the middle of the table, drawn. |
| 12 | * |
| 13 | * A canvas rather than elements, and it spans the whole table rather than just |
| 14 | * the centre. The span is the point: `.slot` and `.seat` both clip their own |
| 15 | * contents, which is exactly why a tile cannot be animated out of a strip as a |
| 16 | * div — on the canvas there is nothing to clip it, so a tile lifted out of a |
| 17 | * hand and thrown crosses the table in one piece. |
| 18 | * |
| 19 | * The loop stops as soon as the pile has settled, and starts again when the |
| 20 | * state changes, a tile is picked up, or the window resizes, so a table nobody |
| 21 | * is touching costs nothing. |
| 22 | */ |
| 23 | export function Pool({ game, pool }: { game: Game; pool: TablePool }) { |
| 24 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| 25 | const reachRef = useRef<HTMLDivElement>(null); |
| 26 | const art = useSheet(); |
| 27 | const wash = useWash(pool); |
| 28 | |
| 29 | // What the loop reads. Refreshed on every render, read on every frame. |
| 30 | const live = useRef({ state: game.state, art }); |
| 31 | live.current = { state: game.state, art }; |
| 32 | |
| 33 | // Poked by the effect below, and by the gesture through pool.wake. |
| 34 | const wake = useRef<() => void>(() => {}); |
| 35 | |
| 36 | useEffect(() => { |
| 37 | const canvas = canvasRef.current; |
| 38 | const table = canvas?.parentElement; |
| 39 | const ctx = canvas?.getContext('2d'); |
| 40 | if (!canvas || !table || !ctx) return; |
| 41 | |
| 42 | pool.attach(table); |
| 43 | |
| 44 | let frame = 0; |
| 45 | let previous = 0; |
| 46 | |
| 47 | const resize = () => { |
| 48 | const dpr = window.devicePixelRatio || 1; |
| 49 | const w = Math.round(table.clientWidth * dpr); |
| 50 | const h = Math.round(table.clientHeight * dpr); |
| 51 | if (canvas.width !== w || canvas.height !== h) { |
| 52 | canvas.width = w; |
| 53 | canvas.height = h; |
| 54 | } |
| 55 | // The square moved, so the colliders and the launch points have too. |
| 56 | pool.invalidate(); |
| 57 | run(); |
| 58 | }; |
| 59 | |
| 60 | const tick = (now: number) => { |
| 61 | frame = 0; |
| 62 | // First frame after waking has no previous time to measure against. |
| 63 | const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0; |
| 64 | previous = now; |
| 65 | |
| 66 | pool.sync(live.current.state); |
| 67 | placeReach(reachRef.current, pool); |
| 68 | // The loudest contact this frame is the one you would actually hear; the |
| 69 | // rest of a scatter is the same event. |
| 70 | const impacts = pool.advance(dt); |
| 71 | let loudest = 0; |
| 72 | for (const i of impacts) { |
| 73 | // A contact quiet enough not to be worth hearing still counts as a tile |
| 74 | // arriving in somebody's lap, which is why the two thresholds differ. |
| 75 | if (i.strength > AUDIBLE) loudest = Math.max(loudest, i.strength); |
| 76 | if (i.edge) shove(pool, table, EDGE_SEAT[i.edge], i.seat, i.strength); |
| 77 | } |
| 78 | if (loudest > 0) knock(loudest); |
| 79 | paint(ctx, canvas, pool, live.current.art); |
| 80 | |
| 81 | // Keep going while anything is moving; otherwise wait to be poked. |
| 82 | if (!pool.settled) frame = requestAnimationFrame(tick); |
| 83 | else previous = 0; |
| 84 | }; |
| 85 | |
| 86 | const run = () => { |
| 87 | if (!frame) frame = requestAnimationFrame(tick); |
| 88 | }; |
| 89 | wake.current = run; |
| 90 | pool.wake = run; |
| 91 | |
| 92 | const observer = new ResizeObserver(resize); |
| 93 | observer.observe(table); |
| 94 | resize(); |
| 95 | |
| 96 | return () => { |
| 97 | observer.disconnect(); |
| 98 | if (frame) cancelAnimationFrame(frame); |
| 99 | wake.current = () => {}; |
| 100 | pool.wake = () => {}; |
| 101 | pool.detach(); |
| 102 | }; |
| 103 | }, [pool]); |
| 104 | |
| 105 | // Any change to the table — a discard, a claim, an undo, a new hand — is a |
| 106 | // reason to look again. |
| 107 | useEffect(() => { |
| 108 | wake.current(); |
| 109 | }); |
| 110 | |
| 111 | return ( |
| 112 | <> |
| 113 | <canvas className="tile-pool" ref={canvasRef} aria-hidden /> |
| 114 | {/* The patch of table you can reach into. Sized to the pool from the |
| 115 | measurement rather than from CSS, and only that patch, so it cannot |
| 116 | swallow clicks meant for the strips around it. */} |
| 117 | <div className="pool-reach" ref={reachRef} onPointerDown={wash.down} onPointerMove={wash.move} onPointerUp={wash.up} onPointerCancel={wash.up} /> |
| 118 | </> |
| 119 | ); |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Washing the tiles about with a finger. |
| 124 | * |
| 125 | * A discard's position is not part of the game — the engine neither knows nor |
| 126 | * cares where in the middle a tile is lying — so this is free to be pure fidget, |
| 127 | * the thing everybody does to the pool while waiting for their turn. |
| 128 | */ |
| 129 | function useWash(pool: TablePool) { |
| 130 | const last = useRef<{ x: number; y: number; t: number } | null>(null); |
| 131 | |
| 132 | const at = (e: React.PointerEvent) => { |
| 133 | const table = (e.currentTarget as HTMLElement).parentElement; |
| 134 | const r = table?.getBoundingClientRect(); |
| 135 | return { x: e.clientX - (r?.left ?? 0), y: e.clientY - (r?.top ?? 0), t: e.timeStamp }; |
| 136 | }; |
| 137 | |
| 138 | return { |
| 139 | down: (e: React.PointerEvent) => { |
| 140 | try { |
| 141 | (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); |
| 142 | } catch { |
| 143 | // Uncaptured still works while the pointer stays over the pool. |
| 144 | } |
| 145 | last.current = at(e); |
| 146 | }, |
| 147 | move: (e: React.PointerEvent) => { |
| 148 | const prev = last.current; |
| 149 | if (!prev) return; |
| 150 | const now = at(e); |
| 151 | const dt = (now.t - prev.t) / 1000; |
| 152 | last.current = now; |
| 153 | if (dt <= 0) return; |
| 154 | pool.stir(now.x, now.y, (now.x - prev.x) / dt, (now.y - prev.y) / dt); |
| 155 | pool.wake(); |
| 156 | }, |
| 157 | up: () => { |
| 158 | last.current = null; |
| 159 | }, |
| 160 | }; |
| 161 | } |
| 162 | |
| 163 | interface Art { |
| 164 | faces: HTMLImageElement | null; |
| 165 | back: HTMLImageElement | null; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * The tile art, as something canvas can draw: the face sheet, and the green |
| 170 | * back a tile shows while it is turning over. Both are the same files the CSS |
| 171 | * uses for the tiles in the hands and the wall, so the pool cannot drift from |
| 172 | * the rest of the table. |
| 173 | */ |
| 174 | function useSheet(): Art { |
| 175 | const [art, setArt] = useState<Art>({ faces: null, back: null }); |
| 176 | useEffect(() => { |
| 177 | let alive = true; |
| 178 | const load = (src: string) => { |
| 179 | const el = new Image(); |
| 180 | el.src = src; |
| 181 | // Not fatal: the pool falls back to blank tiles rather than emptying out. |
| 182 | return el.decode().then( |
| 183 | () => el, |
| 184 | () => null, |
| 185 | ); |
| 186 | }; |
| 187 | void Promise.all([load('/tiles/tiles.svg'), load('/tiles/back.png')]).then( |
| 188 | ([faces, back]) => alive && setArt({ faces, back }), |
| 189 | ); |
| 190 | return () => { |
| 191 | alive = false; |
| 192 | }; |
| 193 | }, []); |
| 194 | return art; |
| 195 | } |
| 196 | |
| 197 | /** How high a tile in somebody's fingers is held above the felt. */ |
| 198 | const HELD_Z = 52; |
| 199 | |
| 200 | /** Quieter than this and the contact is not worth a clack of its own. */ |
| 201 | const AUDIBLE = 0.12; |
| 202 | |
| 203 | /** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS, |
| 204 | * which is the other half of it: the table waits at least this long. */ |
| 205 | const SHAKE = 0.34; |
| 206 | |
| 207 | /** |
| 208 | * A tile has ended up in this seat's tiles — knocked into them, or simply come |
| 209 | * to a stop off the table on their side. Either way it is theirs to complain |
| 210 | * about, and how hard it arrived only decides how loudly. |
| 211 | * |
| 212 | * Their row takes the knock — straight on the element rather than through |
| 213 | * React, because this happens inside the physics loop and re-rendering four |
| 214 | * strips to shake one of them would be absurd — and the table says something |
| 215 | * about it. Not for the thrower's own edge: you cannot be barged by your own |
| 216 | * discard, and everybody's tile passes over their own row on the way out. |
| 217 | */ |
| 218 | function shove( |
| 219 | pool: TablePool, |
| 220 | table: HTMLElement, |
| 221 | seat: SeatId, |
| 222 | thrower: SeatId | undefined, |
| 223 | strength: number, |
| 224 | ) { |
| 225 | if (seat === thrower) return; |
| 226 | // Hold the table while it is being said and while the row is still rocking. |
| 227 | // The computer plays on a timer of its own, and a 碰 called over the top of |
| 228 | // somebody objecting to the tile lands the objection on nobody. |
| 229 | pool.heckle(Math.max(SHAKE, barge(strength))); |
| 230 | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); |
| 231 | if (!hand || hand.classList.contains('shoved')) return; |
| 232 | hand.classList.add('shoved'); |
| 233 | hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true }); |
| 234 | } |
| 235 | |
| 236 | /** |
| 237 | * Park the reachable patch over the pool. Driven off the measurement rather |
| 238 | * than restated in CSS, for the same reason the colliders are: the wall square's |
| 239 | * size is worked out in one place and this follows it. |
| 240 | */ |
| 241 | function placeReach(el: HTMLDivElement | null, pool: TablePool) { |
| 242 | if (!el) return; |
| 243 | const r = pool.poolRect; |
| 244 | if (!r) { |
| 245 | el.style.display = 'none'; |
| 246 | el.dataset.box = ''; |
| 247 | return; |
| 248 | } |
| 249 | const box = [r.x, r.y, r.w, r.h].map(Math.round); |
| 250 | const stamp = box.join(','); |
| 251 | if (el.dataset.box === stamp) return; |
| 252 | el.dataset.box = stamp; |
| 253 | el.style.display = ''; |
| 254 | el.style.left = `${box[0]}px`; |
| 255 | el.style.top = `${box[1]}px`; |
| 256 | el.style.width = `${box[2]}px`; |
| 257 | el.style.height = `${box[3]}px`; |
| 258 | } |
| 259 | |
| 260 | function paint( |
| 261 | ctx: CanvasRenderingContext2D, |
| 262 | canvas: HTMLCanvasElement, |
| 263 | pool: TablePool, |
| 264 | art: Art, |
| 265 | ) { |
| 266 | const dpr = window.devicePixelRatio || 1; |
| 267 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 268 | ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); |
| 269 | |
| 270 | const bodies = pool.bodies; |
| 271 | const held = pool.held; |
| 272 | const departing = pool.departing; |
| 273 | |
| 274 | // Shadows in one pass of their own, offset before rotation, so the light comes |
| 275 | // from one direction across the whole pool rather than turning with each tile. |
| 276 | for (const b of bodies) { |
| 277 | shadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis); |
| 278 | } |
| 279 | if (held) shadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0); |
| 280 | |
| 281 | const newest = pool.newest; |
| 282 | for (const b of bodies) { |
| 283 | tile(ctx, art, { |
| 284 | x: b.x, |
| 285 | y: b.y, |
| 286 | z: b.z, |
| 287 | angle: b.angle, |
| 288 | w: b.w * b.scale, |
| 289 | h: b.h * b.scale, |
| 290 | facing: faceOn(b), |
| 291 | axis: b.flipAxis, |
| 292 | code: b.tile, |
| 293 | lit: b === newest, |
| 294 | }); |
| 295 | } |
| 296 | |
| 297 | // On its way to whoever called it — lifted, turning, and fading as it goes. |
| 298 | for (const { body: b, fade } of departing) { |
| 299 | ctx.save(); |
| 300 | ctx.globalAlpha = fade; |
| 301 | tile(ctx, art, { |
| 302 | x: b.x, |
| 303 | y: b.y, |
| 304 | z: b.z, |
| 305 | angle: b.angle, |
| 306 | w: b.w * b.scale, |
| 307 | h: b.h * b.scale, |
| 308 | facing: faceOn(b), |
| 309 | axis: b.flipAxis, |
| 310 | code: b.tile, |
| 311 | lit: true, |
| 312 | }); |
| 313 | ctx.restore(); |
| 314 | } |
| 315 | |
| 316 | // Whatever is in your fingers is above everything else on the table. |
| 317 | if (held) { |
| 318 | tile(ctx, art, { |
| 319 | x: held.x, |
| 320 | y: held.y, |
| 321 | z: HELD_Z, |
| 322 | angle: held.angle, |
| 323 | w: held.w, |
| 324 | h: held.h, |
| 325 | facing: 1, |
| 326 | axis: 0, |
| 327 | code: held.tile, |
| 328 | lit: true, |
| 329 | }); |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | /** |
| 334 | * How thick a tile is, as a fraction of its width. A real one is about |
| 335 | * three-quarters as thick as it is wide; this is the part of that you can see |
| 336 | * from almost overhead, which is a good deal less. |
| 337 | */ |
| 338 | const THICKNESS = 0.3; |
| 339 | /** The ivory of the tile's own body, seen along its sides. */ |
| 340 | const EDGE_LIGHT = '#e6dcc2'; |
| 341 | const EDGE_DARK = '#b9ad8d'; |
| 342 | |
| 343 | interface Drawn { |
| 344 | x: number; |
| 345 | y: number; |
| 346 | z: number; |
| 347 | angle: number; |
| 348 | w: number; |
| 349 | h: number; |
| 350 | /** cos of the turn: 1 flat on its back, 0 edge on, negative face down. */ |
| 351 | facing: number; |
| 352 | axis: number; |
| 353 | code: number; |
| 354 | lit: boolean; |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * One tile, with the thickness of one. |
| 359 | * |
| 360 | * Drawn as two passes: the body of the tile, offset down the screen towards the |
| 361 | * viewer, and then its face on top of that. The sliver of body left showing |
| 362 | * along the bottom edge is what stops a tile reading as a printed card. |
| 363 | * |
| 364 | * Turning over makes that matter more, not less. Face on you see the face and a |
| 365 | * hair of the side; edge on there is no face left to see and the tile is a bar |
| 366 | * of ivory as thick as it really is — which is why the squash uses the |
| 367 | * rectangle's true projected width, `w·|cos| + thickness·|sin|`, rather than |
| 368 | * pinching to nothing and back. |
| 369 | */ |
| 370 | function tile(ctx: CanvasRenderingContext2D, art: Art, d: Drawn) { |
| 371 | const s = zScale(d.z); |
| 372 | const w = d.w * s; |
| 373 | const h = d.h * s; |
| 374 | const thick = w * THICKNESS; |
| 375 | const c = Math.abs(d.facing); |
| 376 | const sin = Math.sqrt(Math.max(0, 1 - d.facing * d.facing)); |
| 377 | // What the whole slab covers across the axis it is turning about. |
| 378 | const projected = c + (thick / w) * sin; |
| 379 | // How much of the body shows past the face: none of it face on beyond its own |
| 380 | // thickness, all of it edge on. Higher tiles show a touch more of their side. |
| 381 | const lift = thick * (0.45 + 0.55 * sin) + d.z * 0.05; |
| 382 | |
| 383 | // --- the body of the tile, seen from just off square |
| 384 | ctx.save(); |
| 385 | ctx.translate(d.x, d.y + lift); |
| 386 | squash(ctx, projected, d.axis); |
| 387 | ctx.rotate(d.angle); |
| 388 | const grad = ctx.createLinearGradient(0, -h / 2, 0, h / 2); |
| 389 | grad.addColorStop(0, EDGE_DARK); |
| 390 | grad.addColorStop(1, EDGE_LIGHT); |
| 391 | ctx.fillStyle = grad; |
| 392 | tilePath(ctx, w, h); |
| 393 | ctx.fill(); |
| 394 | ctx.restore(); |
| 395 | |
| 396 | // --- and its face on top |
| 397 | ctx.save(); |
| 398 | ctx.translate(d.x, d.y); |
| 399 | squash(ctx, Math.max(0.015, c), d.axis); |
| 400 | ctx.rotate(d.angle); |
| 401 | face(ctx, d.code, w, h, art, d.lit, d.facing < 0); |
| 402 | ctx.restore(); |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * A soft silhouette under a tile. |
| 407 | * |
| 408 | * The blur is baked into a sprite once per tile size rather than applied per |
| 409 | * draw. `ctx.filter` is the obvious way to write this and the wrong one: every |
| 410 | * filtered fill makes the browser allocate and blur its own layer, so sixty |
| 411 | * tiles times sixty frames is sixty times sixty blurs a second, which is enough |
| 412 | * to make the tab stop answering. One blur, then `drawImage`. |
| 413 | * |
| 414 | * Height is sold by scaling that sprite up — a shadow that spreads and fades as |
| 415 | * the tile rises — which a fixed blur radius could not have done anyway. |
| 416 | */ |
| 417 | const shadows = new Map<string, HTMLCanvasElement>(); |
| 418 | |
| 419 | function shadowSprite(w: number, h: number): HTMLCanvasElement { |
| 420 | const key = `${Math.round(w)}x${Math.round(h)}`; |
| 421 | const had = shadows.get(key); |
| 422 | if (had) return had; |
| 423 | // Only ever a handful — one per tile size on screen — but a window being |
| 424 | // dragged to resize walks through a lot of them. |
| 425 | if (shadows.size > 8) shadows.clear(); |
| 426 | |
| 427 | const pad = Math.ceil(Math.max(8, w * 0.5)); |
| 428 | const c = document.createElement('canvas'); |
| 429 | c.width = Math.ceil(w) + pad * 2; |
| 430 | c.height = Math.ceil(h) + pad * 2; |
| 431 | const g = c.getContext('2d'); |
| 432 | if (g) { |
| 433 | g.filter = `blur(${Math.max(1.5, w * 0.11).toFixed(1)}px)`; |
| 434 | g.fillStyle = '#000'; |
| 435 | g.translate(c.width / 2, c.height / 2); |
| 436 | g.beginPath(); |
| 437 | g.roundRect(-w / 2, -h / 2, w, h, w * 0.1); |
| 438 | g.fill(); |
| 439 | } |
| 440 | shadows.set(key, c); |
| 441 | return c; |
| 442 | } |
| 443 | |
| 444 | /** |
| 445 | * Foreshortening along the axis a tile is turning about. |
| 446 | * |
| 447 | * There is no third dimension here, so an out-of-plane turn is drawn as a |
| 448 | * squash. Applied in the *table's* frame, before the tile's own flat rotation, |
| 449 | * because the axis it turns about is fixed by the direction it was thrown, not |
| 450 | * by which way the tile happens to be facing. |
| 451 | */ |
| 452 | function squash(ctx: CanvasRenderingContext2D, across: number, axis: number) { |
| 453 | if (across > 0.999) return; |
| 454 | ctx.rotate(axis); |
| 455 | ctx.scale(across, 1); |
| 456 | ctx.rotate(-axis); |
| 457 | } |
| 458 | |
| 459 | function shadow( |
| 460 | ctx: CanvasRenderingContext2D, |
| 461 | x: number, |
| 462 | y: number, |
| 463 | z: number, |
| 464 | angle: number, |
| 465 | w: number, |
| 466 | h: number, |
| 467 | scale: number, |
| 468 | facing: number, |
| 469 | axis: number, |
| 470 | ) { |
| 471 | // Keyed on the tile's own size, never on the eased one — a scale that changes |
| 472 | // every frame would mean a fresh blur every frame, which is the thing this |
| 473 | // exists to avoid. |
| 474 | const sprite = shadowSprite(w, h); |
| 475 | const spread = zScale(z) * scale * (1 + z * 0.004); |
| 476 | ctx.save(); |
| 477 | ctx.globalAlpha = 0.36 - Math.min(0.18, z * 0.0016); |
| 478 | ctx.translate(x + z * 0.12, y + 3 + z * 0.34); |
| 479 | squash(ctx, Math.abs(facing) + THICKNESS * Math.sqrt(Math.max(0, 1 - facing * facing)), axis); |
| 480 | ctx.rotate(angle); |
| 481 | ctx.drawImage( |
| 482 | sprite, |
| 483 | (-sprite.width / 2) * spread, |
| 484 | (-sprite.height / 2) * spread, |
| 485 | sprite.width * spread, |
| 486 | sprite.height * spread, |
| 487 | ); |
| 488 | ctx.restore(); |
| 489 | } |
| 490 | |
| 491 | function face( |
| 492 | ctx: CanvasRenderingContext2D, |
| 493 | tile: number, |
| 494 | w: number, |
| 495 | h: number, |
| 496 | art: Art, |
| 497 | lit: boolean, |
| 498 | backwards: boolean, |
| 499 | ) { |
| 500 | // Part-way through a turn you are looking at the back of it: the same green |
| 501 | // tile back the wall is built from. A tile always comes to rest face up, so |
| 502 | // this is only ever seen in the air. |
| 503 | if (backwards) { |
| 504 | if (art.back) ctx.drawImage(art.back, -w / 2, -h / 2, w, h); |
| 505 | else { |
| 506 | tilePath(ctx, w, h); |
| 507 | ctx.fillStyle = '#1c7a4b'; |
| 508 | ctx.fill(); |
| 509 | } |
| 510 | return; |
| 511 | } |
| 512 | |
| 513 | if (art.faces) { |
| 514 | // 43 columns, two rows; the second is the sheet's own highlighted variant, |
| 515 | // which is what a lifted tile in a hand uses. The tile in your fingers and |
| 516 | // the one still to be claimed get it here for the same reason. |
| 517 | const cw = art.faces.naturalWidth / SPRITE_COLS; |
| 518 | const ch = art.faces.naturalHeight / 2; |
| 519 | ctx.drawImage(art.faces, SPRITE_COL[tile] * cw, lit ? ch : 0, cw, ch, -w / 2, -h / 2, w, h); |
| 520 | } else { |
| 521 | tilePath(ctx, w, h); |
| 522 | ctx.fillStyle = '#f6f0e2'; |
| 523 | ctx.fill(); |
| 524 | } |
| 525 | if (lit) { |
| 526 | // A pool of sixty tiles needs more than a lighter face to pick one out. |
| 527 | tilePath(ctx, w, h); |
| 528 | ctx.strokeStyle = '#d9b45c'; |
| 529 | ctx.lineWidth = 2; |
| 530 | ctx.stroke(); |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | function tilePath(ctx: CanvasRenderingContext2D, w: number, h: number) { |
| 535 | ctx.beginPath(); |
| 536 | ctx.roundRect(-w / 2, -h / 2, w, h, w * 0.1); |
| 537 | } |