| 1 | import { useEffect, useRef, useState } from 'react'; |
| 2 | import type { TableCtl } from '../game/ctl'; |
| 3 | import { barge, knock, type Place } from '../game/sound'; |
| 4 | import { aabbOf, centreLimits, faceOn, type Impact, type Rect } from '../table/physics'; |
| 5 | import type { TablePool } from '../table/pool'; |
| 6 | import type { SeatId } from '../game/types'; |
| 7 | import { |
| 8 | exposePool, |
| 9 | holdOn, |
| 10 | isHeld, |
| 11 | keyHeld, |
| 12 | noteImpacts, |
| 13 | onCollidersChange, |
| 14 | owedTime, |
| 15 | pausingOnHit, |
| 16 | recentImpacts, |
| 17 | showingColliders, |
| 18 | } from './colliders'; |
| 19 | import { flickOf, type Sample } from './flick'; |
| 20 | import { EDGE_SEAT } from './rotation'; |
| 21 | import { drawShadow, drawTile, loadArt, qrFace, type Art } from './tileArt'; |
| 22 | |
| 23 | /** |
| 24 | * The tiles in the middle of the table, drawn. |
| 25 | * |
| 26 | * A canvas rather than elements, and it spans the whole table rather than just |
| 27 | * the centre. The span is the point: `.slot` and `.seat` both clip their own |
| 28 | * contents, which is exactly why a tile cannot be animated out of a strip as a |
| 29 | * div — on the canvas there is nothing to clip it, so a tile lifted out of a |
| 30 | * hand and thrown crosses the table in one piece. |
| 31 | * |
| 32 | * The loop stops as soon as the pile has settled, and starts again when the |
| 33 | * state changes, a tile is picked up, or the window resizes, so a table nobody |
| 34 | * is touching costs nothing. |
| 35 | */ |
| 36 | export function Pool({ |
| 37 | game, |
| 38 | pool, |
| 39 | viewSeat = 0, |
| 40 | joinUrl, |
| 41 | }: { |
| 42 | game: TableCtl; |
| 43 | pool: TablePool; |
| 44 | /** The seat drawn at the bottom of this screen — see App. An edge the physics |
| 45 | * reports belongs to whoever is drawn there, not to seat 0 by birthright. */ |
| 46 | viewSeat?: SeatId; |
| 47 | /** |
| 48 | * Where a phone should point itself to join this table, if there is anywhere. |
| 49 | * Undefined puts no tile on the felt at all — no room, no server, nothing to |
| 50 | * scan, and nothing pretending otherwise. |
| 51 | */ |
| 52 | joinUrl?: string; |
| 53 | }) { |
| 54 | const canvasRef = useRef<HTMLCanvasElement>(null); |
| 55 | const art = useSheet(); |
| 56 | |
| 57 | // What the loop reads. Refreshed on every render, read on every frame. |
| 58 | const live = useRef({ state: game.state, art, viewSeat, joinUrl }); |
| 59 | live.current = { state: game.state, art, viewSeat, joinUrl }; |
| 60 | |
| 61 | // Poked by the effect below, and by the gesture through pool.wake. |
| 62 | const wake = useRef<() => void>(() => {}); |
| 63 | |
| 64 | useEffect(() => { |
| 65 | const canvas = canvasRef.current; |
| 66 | const table = canvas?.parentElement; |
| 67 | const ctx = canvas?.getContext('2d'); |
| 68 | if (!canvas || !table || !ctx) return; |
| 69 | |
| 70 | pool.attach(table); |
| 71 | exposePool(pool); |
| 72 | const stopDragging = dragging(table as HTMLElement, pool, () => wake.current()); |
| 73 | // Somebody has pushed a wall along. Where a thrown tile bounces has moved |
| 74 | // with it, and the pool re-measures only when it is given a reason to. |
| 75 | const wallMoved = () => { |
| 76 | pool.invalidate(); |
| 77 | wake.current(); |
| 78 | }; |
| 79 | table.addEventListener('wallmoved', wallMoved); |
| 80 | |
| 81 | let frame = 0; |
| 82 | let previous = 0; |
| 83 | |
| 84 | const resize = () => { |
| 85 | const dpr = window.devicePixelRatio || 1; |
| 86 | const w = Math.round(table.clientWidth * dpr); |
| 87 | const h = Math.round(table.clientHeight * dpr); |
| 88 | if (canvas.width !== w || canvas.height !== h) { |
| 89 | canvas.width = w; |
| 90 | canvas.height = h; |
| 91 | } |
| 92 | // The square moved, so the colliders and the launch points have too. |
| 93 | pool.invalidate(); |
| 94 | run(); |
| 95 | }; |
| 96 | |
| 97 | const tick = (now: number) => { |
| 98 | frame = 0; |
| 99 | // First frame after waking has no previous time to measure against. |
| 100 | const dt = previous ? Math.min(0.05, (now - previous) / 1000) : 0; |
| 101 | previous = now; |
| 102 | |
| 103 | pool.sync(live.current.state); |
| 104 | // The QR comes and goes with the room, and the tile with it. Asked every |
| 105 | // frame rather than watched, because the answer is one boolean and the |
| 106 | // pool takes it idempotently. |
| 107 | pool.setJoinTile(!!live.current.joinUrl); |
| 108 | // A tile in the fingers goes over the table's own buttons — seat 0 throws |
| 109 | // from the *right-hand end* of its row, which is directly under the undo |
| 110 | // button — so while one is being carried they stop taking the pointer. |
| 111 | table.classList.toggle('carrying', !!pool.held); |
| 112 | // The loudest contact this frame is the one you would actually hear; the |
| 113 | // rest of a scatter is the same event. |
| 114 | // Frozen on a contact: the world only moves when it is asked to, a fixed |
| 115 | // step at a time, so the moment can be walked through rather than watched. |
| 116 | const impacts = isHeld() ? pool.advance(owedTime()) : pool.advance(dt, pausingOnHit()); |
| 117 | noteImpacts(impacts); |
| 118 | if (pausingOnHit() && !isHeld() && impacts.length) holdOn(impacts); |
| 119 | let loudest = 0; |
| 120 | let loudAt: Impact | null = null; |
| 121 | for (const i of impacts) { |
| 122 | // A contact quiet enough not to be worth hearing still counts as a tile |
| 123 | // arriving in somebody's lap, which is why the two thresholds differ. |
| 124 | if (i.strength > AUDIBLE && i.strength > loudest) { |
| 125 | loudest = i.strength; |
| 126 | loudAt = i; |
| 127 | } |
| 128 | // Straight into somebody's tiles — the wall square is nobody's, so this |
| 129 | // is a hand or a set that has been laid down. |
| 130 | if (i.into !== undefined) { |
| 131 | shove(pool, table, i.into, i.seat, i.strength, posOf(i.into, live.current.viewSeat)); |
| 132 | } else if (i.edge) { |
| 133 | // An edge is already stated in this screen's own terms, which is what |
| 134 | // the sound wants; the seat sitting at it is the round trip back. |
| 135 | const pos = EDGE_SEAT[i.edge] as SeatId; |
| 136 | const at = ((pos + live.current.viewSeat) % 4) as SeatId; |
| 137 | shove(pool, table, at, i.seat, i.strength, pos); |
| 138 | } |
| 139 | } |
| 140 | // Out in the middle a sound has a real position rather than an edge it |
| 141 | // belongs to, and the physics knows it to the pixel. |
| 142 | if (loudest > 0) knock(loudest, spotOf(pool, loudAt!)); |
| 143 | paint(ctx, canvas, pool, live.current.art, live.current.joinUrl); |
| 144 | |
| 145 | // Keep going while anything is moving; otherwise wait to be poked. A held |
| 146 | // world is never running: it is stepped by hand and repainted each time. |
| 147 | if (!isHeld() && !pool.settled) frame = requestAnimationFrame(tick); |
| 148 | else previous = 0; |
| 149 | }; |
| 150 | |
| 151 | const run = () => { |
| 152 | if (!frame) frame = requestAnimationFrame(tick); |
| 153 | }; |
| 154 | wake.current = run; |
| 155 | pool.wake = run; |
| 156 | |
| 157 | const observer = new ResizeObserver(resize); |
| 158 | observer.observe(table); |
| 159 | const stopColliders = onCollidersChange(run); |
| 160 | const onKey = (e: KeyboardEvent) => { |
| 161 | if (keyHeld(e)) e.preventDefault(); |
| 162 | }; |
| 163 | window.addEventListener('keydown', onKey); |
| 164 | resize(); |
| 165 | |
| 166 | return () => { |
| 167 | table.removeEventListener('wallmoved', wallMoved); |
| 168 | stopDragging(); |
| 169 | window.removeEventListener('keydown', onKey); |
| 170 | stopColliders(); |
| 171 | observer.disconnect(); |
| 172 | if (frame) cancelAnimationFrame(frame); |
| 173 | wake.current = () => {}; |
| 174 | pool.wake = () => {}; |
| 175 | pool.detach(); |
| 176 | }; |
| 177 | }, [pool]); |
| 178 | |
| 179 | // Any change to the table — a discard, a claim, an undo, a new hand — is a |
| 180 | // reason to look again. |
| 181 | useEffect(() => { |
| 182 | wake.current(); |
| 183 | }); |
| 184 | |
| 185 | return <canvas className="tile-pool" ref={canvasRef} aria-hidden />; |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Picking a discard up and putting it somewhere else. |
| 190 | * |
| 191 | * A discard's position is not part of the game — the engine neither knows nor |
| 192 | * cares where in the middle a tile is lying — so the pile is free to be handled |
| 193 | * the way a real one is: take hold of a tile, carry it over the top of the |
| 194 | * others, and either set it down or send it skidding off with a flick. |
| 195 | * |
| 196 | * The listener is on the table and it *captures*, because the tiles are drawn |
| 197 | * on a canvas that has no elements to hit. It hit-tests the pile first and only |
| 198 | * takes the gesture when a tile is actually under the finger; anything else is |
| 199 | * let straight through to whatever it was really aimed at — a tile in a hand, |
| 200 | * the settings button, the wall. |
| 201 | */ |
| 202 | function dragging(table: HTMLElement, pool: TablePool, wake: () => void) { |
| 203 | let samples: Sample[] = []; |
| 204 | |
| 205 | const at = (e: PointerEvent) => { |
| 206 | const r = table.getBoundingClientRect(); |
| 207 | return { x: e.clientX - r.left, y: e.clientY - r.top, t: e.timeStamp }; |
| 208 | }; |
| 209 | |
| 210 | const down = (e: PointerEvent) => { |
| 211 | // Only the primary button, and only where there is a tile to take hold of. |
| 212 | if (e.button !== 0) return; |
| 213 | const p = at(e); |
| 214 | if (!pool.grabAt(p.x, p.y)) return; |
| 215 | e.stopPropagation(); |
| 216 | e.preventDefault(); |
| 217 | samples = [p]; |
| 218 | try { |
| 219 | table.setPointerCapture?.(e.pointerId); |
| 220 | } catch { |
| 221 | // Uncaptured still works while the pointer stays over the table. |
| 222 | } |
| 223 | wake(); |
| 224 | }; |
| 225 | |
| 226 | const move = (e: PointerEvent) => { |
| 227 | if (!pool.dragging) return; |
| 228 | e.stopPropagation(); |
| 229 | const p = at(e); |
| 230 | // Only the tail matters, and only the recent tail: a flick is the last few |
| 231 | // milliseconds of a drag, not an average over the whole of it. |
| 232 | samples.push(p); |
| 233 | if (samples.length > 6) samples.shift(); |
| 234 | pool.dragTo(p.x, p.y); |
| 235 | wake(); |
| 236 | }; |
| 237 | |
| 238 | const up = (e: PointerEvent) => { |
| 239 | if (!pool.dragging) return; |
| 240 | e.stopPropagation(); |
| 241 | samples.push(at(e)); |
| 242 | // Read exactly as a throw out of a hand is: same gesture, same reading, |
| 243 | // curve on it and all. See ui/flick. |
| 244 | const v = flickOf(samples); |
| 245 | pool.releaseGrab(v.vx, v.vy, v.spin); |
| 246 | samples = []; |
| 247 | wake(); |
| 248 | }; |
| 249 | |
| 250 | table.addEventListener('pointerdown', down, true); |
| 251 | table.addEventListener('pointermove', move, true); |
| 252 | table.addEventListener('pointerup', up, true); |
| 253 | table.addEventListener('pointercancel', up, true); |
| 254 | return () => { |
| 255 | table.removeEventListener('pointerdown', down, true); |
| 256 | table.removeEventListener('pointermove', move, true); |
| 257 | table.removeEventListener('pointerup', up, true); |
| 258 | table.removeEventListener('pointercancel', up, true); |
| 259 | }; |
| 260 | } |
| 261 | |
| 262 | /** The sheet and the green back, once, shared by every tile drawn. */ |
| 263 | function useSheet(): Art { |
| 264 | const [art, setArt] = useState<Art>({ faces: null, back: null }); |
| 265 | useEffect(() => { |
| 266 | let alive = true; |
| 267 | void loadArt().then((a) => alive && setArt(a)); |
| 268 | return () => { |
| 269 | alive = false; |
| 270 | }; |
| 271 | }, []); |
| 272 | return art; |
| 273 | } |
| 274 | |
| 275 | /** How high a tile in somebody's fingers is held above the felt. */ |
| 276 | const HELD_Z = 52; |
| 277 | |
| 278 | /** Quieter than this and the contact is not worth a clack of its own. */ |
| 279 | const AUDIBLE = 0.12; |
| 280 | |
| 281 | /** How long the knocked row rocks for, in seconds. Matches `shoved` in the CSS, |
| 282 | * which is the other half of it: the table waits at least this long. */ |
| 283 | const SHAKE = 0.34; |
| 284 | |
| 285 | /** A seat, as an edge of the table this screen is drawing. */ |
| 286 | const posOf = (seat: SeatId, viewSeat: SeatId): SeatId => (((seat - viewSeat + 4) % 4) as SeatId); |
| 287 | |
| 288 | /** |
| 289 | * Where on the felt a contact happened, in the terms the sound wants: how far |
| 290 | * left or right of the middle, and how far up the table — the far half of the |
| 291 | * pool is over by 對家 and sounds like it. The felt is drawn in screen |
| 292 | * coordinates, so this needs no turning: it is already the way you are facing. |
| 293 | */ |
| 294 | function spotOf(pool: TablePool, at: Impact): Place { |
| 295 | const b = pool.world?.bounds; |
| 296 | if (!b) return {}; |
| 297 | const pan = Math.max(-1, Math.min(1, (at.x - (b.x + b.w / 2)) / (b.w / 2))); |
| 298 | // Only the half of the table away from you counts as distance; the near half |
| 299 | // is all within reach, and dulling it would just sound like a blanket. |
| 300 | const far = Math.max(0, Math.min(1, (b.y + b.h / 2 - at.y) / (b.h / 2))); |
| 301 | return { pan: pan * 0.8, far: far * 0.55 }; |
| 302 | } |
| 303 | |
| 304 | /** |
| 305 | * A tile has ended up in this seat's tiles — knocked into them, or simply come |
| 306 | * to a stop off the table on their side. Either way it is theirs to complain |
| 307 | * about, and how hard it arrived only decides how loudly. |
| 308 | * |
| 309 | * Their row takes the knock — straight on the element rather than through |
| 310 | * React, because this happens inside the physics loop and re-rendering four |
| 311 | * strips to shake one of them would be absurd — and the table says something |
| 312 | * about it. Not for the thrower's own edge: you cannot be barged by your own |
| 313 | * discard, and everybody's tile passes over their own row on the way out. |
| 314 | */ |
| 315 | function shove( |
| 316 | pool: TablePool, |
| 317 | table: HTMLElement, |
| 318 | seat: SeatId, |
| 319 | thrower: SeatId | undefined, |
| 320 | strength: number, |
| 321 | pos: SeatId, |
| 322 | ) { |
| 323 | if (seat === thrower) return; |
| 324 | // Hold the table while it is being said and while the row is still rocking. |
| 325 | // The computer plays on a timer of its own, and a 碰 called over the top of |
| 326 | // somebody objecting to the tile lands the objection on nobody. |
| 327 | // It comes from their edge: they are the one whose tiles were scattered, and |
| 328 | // it is their voice complaining about it. |
| 329 | pool.heckle(Math.max(SHAKE, barge(strength, { pos }))); |
| 330 | const hand = table.querySelector<HTMLElement>(`[data-seat="${seat}"] .hand`); |
| 331 | if (!hand || hand.classList.contains('shoved')) return; |
| 332 | hand.classList.add('shoved'); |
| 333 | hand.addEventListener('animationend', () => hand.classList.remove('shoved'), { once: true }); |
| 334 | } |
| 335 | |
| 336 | function paint( |
| 337 | ctx: CanvasRenderingContext2D, |
| 338 | canvas: HTMLCanvasElement, |
| 339 | pool: TablePool, |
| 340 | art: Art, |
| 341 | joinUrl?: string, |
| 342 | ) { |
| 343 | const dpr = window.devicePixelRatio || 1; |
| 344 | ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 345 | ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); |
| 346 | |
| 347 | const bodies = pool.bodies; |
| 348 | const held = pool.held; |
| 349 | const departing = pool.departing; |
| 350 | |
| 351 | // Shadows in one pass of their own, offset before rotation, so the light comes |
| 352 | // from one direction across the whole pool rather than turning with each tile. |
| 353 | for (const b of bodies) { |
| 354 | drawShadow(ctx, b.x, b.y, b.z, b.angle, b.w, b.h, b.scale, faceOn(b), b.flipAxis); |
| 355 | } |
| 356 | if (held) drawShadow(ctx, held.x, held.y, HELD_Z, held.angle, held.w, held.h, 1, 1, 0); |
| 357 | |
| 358 | const newest = pool.newest; |
| 359 | // The one tile whose face is painted rather than cut out of the sheet. Asked |
| 360 | // for at the size it is actually being drawn, so the code is never resampled |
| 361 | // and a phone never has to work at it. |
| 362 | const join = pool.joinTile; |
| 363 | const joinArt = |
| 364 | join && joinUrl ? qrFace(joinUrl, join.w * join.scale * (window.devicePixelRatio || 1)) : null; |
| 365 | for (const b of bodies) { |
| 366 | drawTile(ctx, art, { |
| 367 | x: b.x, |
| 368 | y: b.y, |
| 369 | z: b.z, |
| 370 | angle: b.angle, |
| 371 | w: b.w * b.scale, |
| 372 | h: b.h * b.scale, |
| 373 | facing: faceOn(b), |
| 374 | axis: b.flipAxis, |
| 375 | code: b.tile, |
| 376 | lit: b === newest, |
| 377 | image: b === join ? joinArt ?? undefined : undefined, |
| 378 | }); |
| 379 | } |
| 380 | |
| 381 | // On its way to whoever called it — lifted, turning, and fading as it goes. |
| 382 | for (const { body: b, fade } of departing) { |
| 383 | ctx.save(); |
| 384 | ctx.globalAlpha = fade; |
| 385 | drawTile(ctx, art, { |
| 386 | x: b.x, |
| 387 | y: b.y, |
| 388 | z: b.z, |
| 389 | angle: b.angle, |
| 390 | w: b.w * b.scale, |
| 391 | h: b.h * b.scale, |
| 392 | facing: faceOn(b), |
| 393 | axis: b.flipAxis, |
| 394 | code: b.tile, |
| 395 | lit: true, |
| 396 | }); |
| 397 | ctx.restore(); |
| 398 | } |
| 399 | |
| 400 | // Whatever is in your fingers is above everything else on the table. |
| 401 | if (held) { |
| 402 | drawTile(ctx, art, { |
| 403 | x: held.x, |
| 404 | y: held.y, |
| 405 | z: HELD_Z, |
| 406 | angle: held.angle, |
| 407 | w: held.w, |
| 408 | h: held.h, |
| 409 | facing: 1, |
| 410 | axis: 0, |
| 411 | code: held.tile, |
| 412 | lit: true, |
| 413 | }); |
| 414 | } |
| 415 | |
| 416 | // Over the top of all of it, or there would be no point to it. |
| 417 | if (showingColliders()) colliders(ctx, pool); |
| 418 | } |
| 419 | |
| 420 | /** |
| 421 | * Every boundary the physics uses, drawn where it actually is. |
| 422 | * |
| 423 | * Solid the felt — the fence that keeps a tile on the table, measured |
| 424 | * out to where each seat's tiles begin (table/geometry.ts). |
| 425 | * Solid each standing stack of two. Half stacks are not drawn because |
| 426 | * they are not colliders: a tile goes over them. |
| 427 | * Dashed the wall square's opening. Only where a throw is *aimed*; it |
| 428 | * stops nothing, which is why the pile spills out of it. |
| 429 | * Dotted, in where the moving tile's own centre is allowed to be — the felt |
| 430 | * less that tile's half width. This is the line a bounce happens |
| 431 | * at, and it is half a tile inside the solid one, which is what |
| 432 | * makes a bounce look early when it is not. |
| 433 | * Per tile the rotated rectangle it really collides with, and, fainter, the |
| 434 | * upright box the felt's straight edges use instead. The two come |
| 435 | * apart as a tile turns, and that difference is worth seeing. |
| 436 | */ |
| 437 | function colliders(ctx: CanvasRenderingContext2D, pool: TablePool) { |
| 438 | const world = pool.world; |
| 439 | if (!world) return; |
| 440 | |
| 441 | const line = (r: Rect) => ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1); |
| 442 | |
| 443 | ctx.save(); |
| 444 | ctx.lineWidth = 1; |
| 445 | |
| 446 | // The table's own edge, and every tile standing on it. |
| 447 | ctx.strokeStyle = '#ff2d20'; |
| 448 | line(world.bounds); |
| 449 | for (const w of world.walls) line(w.rect); |
| 450 | |
| 451 | // Where a throw is aimed. Not a fence — see TableGeometry.pool. |
| 452 | const aim = pool.poolRect; |
| 453 | if (aim) { |
| 454 | ctx.setLineDash([5, 5]); |
| 455 | ctx.strokeStyle = 'rgba(255, 45, 32, 0.45)'; |
| 456 | line(aim); |
| 457 | ctx.setLineDash([]); |
| 458 | } |
| 459 | |
| 460 | // Whatever is still moving: what it collides with, and what it is held by. |
| 461 | const live = world.bodies.find((b) => !b.resting) ?? pool.newest; |
| 462 | |
| 463 | for (const b of world.bodies) { |
| 464 | // A tile in the fingers is held over everything and collides with nothing, |
| 465 | // so it gets no box: the boxes are what is solid. |
| 466 | if (b.carried) continue; |
| 467 | ctx.strokeStyle = b === live ? 'rgba(255, 45, 32, 0.9)' : 'rgba(255, 45, 32, 0.35)'; |
| 468 | ctx.save(); |
| 469 | ctx.translate(b.x, b.y); |
| 470 | ctx.rotate(b.angle); |
| 471 | ctx.strokeRect(-b.w / 2, -b.h / 2, b.w, b.h); |
| 472 | ctx.restore(); |
| 473 | |
| 474 | // The upright box the felt's edges are worked out from. Only for the one in |
| 475 | // flight: sixty of them at once is a red mess, not a picture. |
| 476 | if (b === live && b.angle % Math.PI !== 0) { |
| 477 | ctx.setLineDash([2, 3]); |
| 478 | ctx.strokeStyle = 'rgba(255, 45, 32, 0.5)'; |
| 479 | line(aabbOf(b)); |
| 480 | ctx.setLineDash([]); |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | if (live) { |
| 485 | // The box that tile's centre is held inside, and where the centre is now. |
| 486 | ctx.setLineDash([2, 3]); |
| 487 | ctx.strokeStyle = 'rgba(255, 45, 32, 0.75)'; |
| 488 | line(centreLimits(world.bounds, live)); |
| 489 | ctx.setLineDash([]); |
| 490 | ctx.fillStyle = '#ff2d20'; |
| 491 | ctx.fillRect(live.x - 1.5, live.y - 1.5, 3, 3); |
| 492 | } |
| 493 | |
| 494 | // How the last few throws actually left. Faint line: the flick, as it was |
| 495 | // let go of. Bright line: where the tile was actually sent. When those two |
| 496 | // disagree the tile leaves on a line nobody threw, which is the other way a |
| 497 | // bounce can be a bounce off nothing — see TablePool.throws. |
| 498 | ctx.font = '9px ui-monospace, monospace'; |
| 499 | ctx.textBaseline = 'bottom'; |
| 500 | for (const t of pool.throws) { |
| 501 | const ray = (dx: number, dy: number, len: number) => { |
| 502 | const d = Math.hypot(dx, dy) || 1; |
| 503 | ctx.beginPath(); |
| 504 | ctx.moveTo(t.x, t.y); |
| 505 | ctx.lineTo(t.x + (dx / d) * len, t.y + (dy / d) * len); |
| 506 | ctx.stroke(); |
| 507 | }; |
| 508 | ctx.strokeStyle = 'rgba(90, 200, 250, 0.35)'; |
| 509 | ray(t.fx, t.fy, 90); |
| 510 | ctx.strokeStyle = 'rgba(90, 200, 250, 0.9)'; |
| 511 | ray(t.vx, t.vy, 130); |
| 512 | ctx.fillStyle = 'rgba(90, 200, 250, 0.9)'; |
| 513 | ctx.fillText(t.how, t.x + 6, t.y - 4); |
| 514 | ctx.fillRect(t.x - 2, t.y - 2, 4, 4); |
| 515 | } |
| 516 | |
| 517 | // Where contact was actually made, fading out. One of these with no box under |
| 518 | // it is the interesting case: a bounce off something that is not drawn is a |
| 519 | // bounce off something that is not a collider. |
| 520 | ctx.font = '9px ui-monospace, monospace'; |
| 521 | ctx.textBaseline = 'bottom'; |
| 522 | for (const m of recentImpacts(!isHeld())) { |
| 523 | const fade = 1 - m.age / 90; |
| 524 | ctx.strokeStyle = `rgba(255, 214, 10, ${fade})`; |
| 525 | ctx.fillStyle = `rgba(255, 214, 10, ${fade})`; |
| 526 | ctx.beginPath(); |
| 527 | ctx.moveTo(m.x - 5, m.y); |
| 528 | ctx.lineTo(m.x + 5, m.y); |
| 529 | ctx.moveTo(m.x, m.y - 5); |
| 530 | ctx.lineTo(m.x, m.y + 5); |
| 531 | ctx.stroke(); |
| 532 | if (m.kind) ctx.fillText(m.kind, m.x + 6, m.y - 2); |
| 533 | } |
| 534 | |
| 535 | ctx.restore(); |
| 536 | } |
| 537 | |