diff --git a/deploy/film-clips.mjs b/deploy/film-clips.mjs index 926421f..20aee59 100644 --- a/deploy/film-clips.mjs +++ b/deploy/film-clips.mjs @@ -21,7 +21,6 @@ const [outDir, ...only] = process.argv.slice(2); if (!outDir) { console.error("usage: film-clips.mjs [scene ...]"); process.exit(1); } const PORT = 8809; const FPS = 25; -const STEP_MS = 1500; // the reel's own pace at 1x const TAIL_MS = 2500; // hold on the last move const EPOCH = new Date("2026-01-01T00:00:00Z").getTime(); @@ -82,15 +81,25 @@ try { const clip = { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }; // Roll: the reel's play button is the ▶ before the step button. await page.locator("button", { hasText: "▶" }).first().click(); - const total = (count - 1) * STEP_MS + TAIL_MS; - const nFrames = Math.ceil(total / (1000 / FPS)); - const posterAt = ((sp.beats?.[0] ?? 1) * STEP_MS + 1200) / (1000 / FPS); - for (let f = 0; f < nFrames; f++) { + // Roll until the reel reaches its last move (steps a die decides + // run longer than the beat), then hold the tail. The poster is + // the first pinned beat, settled. + const posterBeat = sp.beats?.[0] ?? 1; + let f = 0, tail = -1, posterIn = -1, total = 0; + const MAX_FRAMES = FPS * 240; // a reel that never ends is a bug, not a feature + while (tail !== 0 && f < MAX_FRAMES) { const png = await page.screenshot({ clip }); writeFileSync(join(frames, `${String(f).padStart(5, "0")}.png`), png); - if (view === "fpv" && f === Math.round(posterAt)) writeFileSync(join(frames, "poster.png"), png); + const at = Number((await page.locator(".replay-count").textContent()).match(/move (\d+)/)[1]); + if (view === "fpv" && posterIn < 0 && at === posterBeat + 1) posterIn = Math.round(1200 / (1000 / FPS)); + if (posterIn === 0) writeFileSync(join(frames, "poster.png"), png); + if (posterIn >= 0) posterIn--; + if (tail < 0 && at === count) tail = Math.round(TAIL_MS / (1000 / FPS)); + if (tail > 0) tail--; await page.clock.runFor(1000 / FPS); + f++; total += 1000 / FPS; } + const nFrames = f; await ctx.close(); execFileSync("ffmpeg", ["-loglevel", "error", "-y", "-framerate", String(FPS), "-i", join(frames, "%05d.png"), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // h264 wants even sides diff --git a/deploy/scene-goldens/come-here-s10-board.png b/deploy/scene-goldens/come-here-s10-board.png index 22efeac..a657173 100644 Binary files a/deploy/scene-goldens/come-here-s10-board.png and b/deploy/scene-goldens/come-here-s10-board.png differ diff --git a/deploy/scene-goldens/come-here-s10-fpv.png b/deploy/scene-goldens/come-here-s10-fpv.png index e151107..f690777 100644 Binary files a/deploy/scene-goldens/come-here-s10-fpv.png and b/deploy/scene-goldens/come-here-s10-fpv.png differ diff --git a/deploy/scene-goldens/come-here-s13-fpv.png b/deploy/scene-goldens/come-here-s13-fpv.png index 1417d8f..1e211d2 100644 Binary files a/deploy/scene-goldens/come-here-s13-fpv.png and b/deploy/scene-goldens/come-here-s13-fpv.png differ diff --git a/deploy/scene-goldens/fire-in-the-webs-s11-board.png b/deploy/scene-goldens/fire-in-the-webs-s11-board.png index 1a59590..2eb17bb 100644 Binary files a/deploy/scene-goldens/fire-in-the-webs-s11-board.png and b/deploy/scene-goldens/fire-in-the-webs-s11-board.png differ diff --git a/deploy/scene-goldens/shield-holds-s11-board.png b/deploy/scene-goldens/shield-holds-s11-board.png index 439c31c..0893cd1 100644 Binary files a/deploy/scene-goldens/shield-holds-s11-board.png and b/deploy/scene-goldens/shield-holds-s11-board.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s5-board.png b/deploy/scene-goldens/springing-the-trap-s5-board.png index ea0c948..4213c22 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s5-board.png and b/deploy/scene-goldens/springing-the-trap-s5-board.png differ diff --git a/deploy/scene-goldens/springing-the-trap-s6-fpv.png b/deploy/scene-goldens/springing-the-trap-s6-fpv.png index fbbd61a..3db9f5a 100644 Binary files a/deploy/scene-goldens/springing-the-trap-s6-fpv.png and b/deploy/scene-goldens/springing-the-trap-s6-fpv.png differ diff --git a/deploy/scene-goldens/stone-gaze-s10-board.png b/deploy/scene-goldens/stone-gaze-s10-board.png index ced6d73..08bbc51 100644 Binary files a/deploy/scene-goldens/stone-gaze-s10-board.png and b/deploy/scene-goldens/stone-gaze-s10-board.png differ diff --git a/deploy/scene-goldens/the-leap-s8-board.png b/deploy/scene-goldens/the-leap-s8-board.png index a7dfdc8..d001ba6 100644 Binary files a/deploy/scene-goldens/the-leap-s8-board.png and b/deploy/scene-goldens/the-leap-s8-board.png differ diff --git a/deploy/scene-goldens/the-leap-s8-fpv.png b/deploy/scene-goldens/the-leap-s8-fpv.png index ce36aeb..547012f 100644 Binary files a/deploy/scene-goldens/the-leap-s8-fpv.png and b/deploy/scene-goldens/the-leap-s8-fpv.png differ diff --git a/deploy/scene-goldens/the-leap-s9-fpv.png b/deploy/scene-goldens/the-leap-s9-fpv.png index decf9de..c474386 100644 Binary files a/deploy/scene-goldens/the-leap-s9-fpv.png and b/deploy/scene-goldens/the-leap-s9-fpv.png differ diff --git a/deploy/scene-goldens/the-rout-s11-board.png b/deploy/scene-goldens/the-rout-s11-board.png index 37c7f77..adca0ec 100644 Binary files a/deploy/scene-goldens/the-rout-s11-board.png and b/deploy/scene-goldens/the-rout-s11-board.png differ diff --git a/deploy/scene-goldens/the-rout-s4-fpv.png b/deploy/scene-goldens/the-rout-s4-fpv.png index d14392c..903c117 100644 Binary files a/deploy/scene-goldens/the-rout-s4-fpv.png and b/deploy/scene-goldens/the-rout-s4-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-board.png b/deploy/scene-goldens/wall-comes-down-s6-board.png index 0f390e5..7db9b63 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s6-board.png and b/deploy/scene-goldens/wall-comes-down-s6-board.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s6-fpv.png b/deploy/scene-goldens/wall-comes-down-s6-fpv.png index e78fffc..6970dbf 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s6-fpv.png and b/deploy/scene-goldens/wall-comes-down-s6-fpv.png differ diff --git a/deploy/scene-goldens/wall-comes-down-s7-fpv.png b/deploy/scene-goldens/wall-comes-down-s7-fpv.png index d687cce..0885f7c 100644 Binary files a/deploy/scene-goldens/wall-comes-down-s7-fpv.png and b/deploy/scene-goldens/wall-comes-down-s7-fpv.png differ diff --git a/deploy/scene-goldens/warp-sniper-s16-board.png b/deploy/scene-goldens/warp-sniper-s16-board.png index 6f22f93..63c87df 100644 Binary files a/deploy/scene-goldens/warp-sniper-s16-board.png and b/deploy/scene-goldens/warp-sniper-s16-board.png differ diff --git a/deploy/scene-goldens/wormhole-heist-s5-board.png b/deploy/scene-goldens/wormhole-heist-s5-board.png index 02a031d..ac6f7ad 100644 Binary files a/deploy/scene-goldens/wormhole-heist-s5-board.png and b/deploy/scene-goldens/wormhole-heist-s5-board.png differ diff --git a/deploy/verify-scenes.mjs b/deploy/verify-scenes.mjs index b529253..483eef0 100644 --- a/deploy/verify-scenes.mjs +++ b/deploy/verify-scenes.mjs @@ -121,7 +121,10 @@ try { for (const beat of beats) { for (; at < beat; at++) { await page.locator("button", { hasText: "▶" }).last().click(); - await page.clock.runFor(at === beat - 1 ? 2600 : 600); + // A step the die decides holds the world for the roll's + // interlude before anything moves: wait it out on top. + const die = (await page.locator(".replay-caption").textContent()).includes("\u{1F3B2}"); + await page.clock.runFor((at === beat - 1 ? 2600 : 600) + (die ? 2400 : 0)); } const box = await page.locator(".replay").boundingBox(); const name = `${sp.name}-s${beat}-${view}.png`; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index e68220b..bd2f636 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -31,6 +31,8 @@ export interface PlayerPublicView { extraTurns: number; /** Banked PASS THROUGH WALL crossings — cast openly, so public knowledge. */ passWallCharges: number; + /** Down a pit, until a roll climbs them out — everyone saw the fall. */ + inPit: boolean; displayed: CardInstance[]; /** Which of the six physical wizard colors this player plays. */ colorIndex: number; @@ -132,6 +134,7 @@ export function viewFor(state: GameState, playerId: PlayerId): GameView { position: p.position, home: p.home, life: p.life, + inPit: p.inPit, alive: p.alive, handCount: p.hand.length, carriedTreasureId: p.carriedTreasureId, diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index bd253ef..edf0c08 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -570,7 +570,7 @@ href={wizardArt(p.id)} x={-half} y={-half} width={half * 2} height={half * 2} - cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}`} + cls={`token-art hit${hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body") ? " ghosted" : ""}${hasSpell(p.id, "medusa") ? " stone-gazed" : ""}${p.inPit ? " in-pit" : ""}`} /> + st?.events.find((e): e is Extract => e.type === "dieRolled"); + /** How long this step's interlude holds the world before it moves. */ + const dieMs = $derived(dieOf(step) && prefs.flourishes ? DIE_MS / speed : 0); + let die = $state<{ idx: number; player: string; roll: number; purpose: string; face: number; landed: boolean } | null>(null); + $effect(() => { + const i = Math.min(idx, steps.length - 1); + const d = dieOf(steps[i]); + if (!d || !prefs.flourishes) { die = null; return; } + const t0 = performance.now(); + const dur = DIE_MS / speed; + const who = d.player ?? "The maze"; + die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: 1, landed: false }; + let raf = 0; + const tick = (now: number) => { + const p = Math.max(0, now - t0) / dur; + if (p >= 1) { die = null; return; } + if (p < 0.48) { + // The tumble: faces flick past, slowing as the die settles. A + // fixed sequence, so the harness films the same roll every time. + const n = Math.floor(p * 26 - p * p * 18); + die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: ((n * 3 + 1) % 4) + 1, landed: false }; + } else if (!die?.landed) { + die = { idx: i, player: who, roll: d.roll, purpose: d.purpose, face: d.roll, landed: true }; + } + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => { cancelAnimationFrame(raf); die = null; }; + }); + /** While the die is in the air the board shows the world as it was. */ + const dieHeld = $derived(die && die.idx > 0 ? steps[die.idx - 1]!.view : null); /** What you drew belongs in the chronicle, not on a reel that may be * recorded and passed around — captions keep the count, not the cards. */ const CAPTION_SILENT = new Set(["cardsDrawnPrivate", "cardsDealtPrivate", "cardsStolenPrivate"]); @@ -92,6 +129,8 @@ .filter((l): l is string => l !== null), ); const atEnd = $derived(idx >= steps.length - 1); + /** What the roll brought about, in the caption's own words. */ + const dieVerdict = $derived(lines.filter((l) => !l.startsWith("\u{1F3B2}")).slice(0, 2).join(" ")); /** The acting wizard's standee, shown beside their words. */ const actorArt = $derived.by(() => { @@ -171,12 +210,15 @@ $effect(() => { const step = steps[Math.min(idx, steps.length - 1)]; if (!step || !prefs.flourishes) return; - const cancel = scheduleFx( - step.events, step.view, - (fx) => (boardFx = [...boardFx, fx]), - (id) => (boardFx = boardFx.filter((f) => f.id !== id)), - ); - return () => { cancel(); boardFx = []; }; + let cancel = () => {}; + const wait = setTimeout(() => { + cancel = scheduleFx( + step.events, step.view, + (fx) => (boardFx = [...boardFx, fx]), + (id) => (boardFx = boardFx.filter((f) => f.id !== id)), + ); + }, dieMs); + return () => { clearTimeout(wait); cancel(); boardFx = []; }; }); /** The director's slate: the camera effect writes how long this step's @@ -214,11 +256,16 @@ $effect(() => { if (!playing) return; - const t = setInterval(() => { + // Each step gets the reel's beat, plus the die's interlude when + // one decided it. Read the step here so every advance re-arms the + // timer — a derived that stays 0 across steps would not. + const from = idx; + const t = setTimeout(() => { + if (idx !== from) return; if (idx < steps.length - 1) idx += 1; else playing = false; - }, 1500 / speed); - return () => clearInterval(t); + }, 1500 / speed + dieMs); + return () => clearTimeout(t); }); /** Other bodies glide between steps instead of blinking cell to cell: @@ -235,7 +282,7 @@ if (!before || before === v) return; const moves = gatherGlides(before, v, povId); if (moves.length === 0) { actorPos = {}; return; } - const t0 = performance.now(); + const t0 = performance.now() + dieMs; const dur = 400 / speed; let raf = 0; const tick = (now: number) => { @@ -257,7 +304,10 @@ // Each step the camera settles on your position. A step away tweens — // turn first, then stride; a leap (teleport, warp) cuts. When you stood // still, the eye turns toward whoever acted. - const cam = $state({ x: 0, y: 0, facing: 0 }); + const cam = $state({ x: 0, y: 0, facing: 0, pitch: 0 }); + /** How far the eyes drop to watch their own feet: the horizon rises + * to the pane's top third and the ground underfoot fills the rest. */ + const LOOK_DOWN = 0.36; let camReady = false; $effect(() => { if (!fp) { camReady = false; return; } @@ -273,6 +323,7 @@ const camX = untrack(() => cam.x); const camY = untrack(() => cam.y); const camF = untrack(() => cam.facing); + const camP = untrack(() => cam.pitch); const dx = tx - camX; const dy = ty - camY; const dist = Math.hypot(dx, dy); @@ -304,26 +355,44 @@ const i2 = Math.min(idx, steps.length - 1); const pv = i2 > 0 ? steps[i2 - 1]!.view : null; heldView = pv; - camPlan = { idx: i2, holdMs: pv ? 420 / speed : 0 }; + camPlan = { idx: i2, holdMs: pv ? 420 / speed + dieMs : 0 }; if (pv) { - const t = setTimeout(() => (heldView = null), 420 / speed); + const t = setTimeout(() => (heldView = null), 420 / speed + dieMs); return () => clearTimeout(t); } return; }; let targetFacing = camF; let aimed = false; + // Something at your own feet — a conjuration on your square, a + // treasure taken up, the pit you just fell down — is watched by + // looking down; the eyes come back level with the next step. + const underfoot = step.events.some((e) => + (e.type === "fellInPit" || e.type === "climbedFromPit") && "player" in e && (e as { player: string }).player === povId); + let targetPitch = 0; + // Only GROUND deeds earn the look-down: a conjuration on this + // square, a treasure taken up or set down. A punch traded with + // someone sharing the square aims at nobody's feet — they stand + // right in front of the eyes, wherever those point. + const GROUNDWORK = new Set(["spellCast", "squareFilled", "creatureCreated", + "treasurePickedUp", "treasureDropped", "objectDropped"]); + const groundwork = aim?.self && step.events.some((e) => GROUNDWORK.has(e.type)); if (camReady && !willCut && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; } + else if (groundwork || underfoot) { + targetPitch = LOOK_DOWN; + aimed = true; + } else if (aim?.self) { - // Summoned at your own feet: no turn can show it — step outside - // yourself and watch it grow beside you. - return takeCutaway(me.position.x + 0.5, me.position.y + 0.5); + // Nothing to turn toward: keep the view, unless it is a wall. } else if (aim) { const ax = aim.x + 0.5, ay = aim.y + 0.5; const toAim = Math.hypot(ax - tx, ay - ty); const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); - if (sight.warped || sight.dist < toAim - 0.4) { + // Blocked: a wall before the target, or a warp mouth before it — + // a warp BEYOND the target bends nothing the eyes need. + const warpBefore = sight.warped && (sight.warpDist ?? Infinity) < toAim - 0.4; + if (warpBefore || sight.dist < toAim - 0.4) { return takeCutaway(ax, ay); } targetFacing = Math.atan2(ay - ty, ax - tx); @@ -335,7 +404,8 @@ const ax = actor.position.x + 0.5, ay = actor.position.y + 0.5; const toActor = Math.hypot(ax - tx, ay - ty); const ray = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx)); - if (!ray.warped && ray.dist > toActor - 0.2) { + const warpBefore = ray.warped && (ray.warpDist ?? Infinity) < toActor - 0.2; + if (!warpBefore && ray.dist > toActor - 0.2) { targetFacing = Math.atan2(ay - ty, ax - tx); aimed = true; } @@ -403,6 +473,7 @@ // Locals only: reading heldView back here would register it as a // dependency of this very effect, and the timer clearing it would // re-trigger us into a ping-pong. + holdMs += dieMs; const hold = holdMs > 60 && prevView ? prevView : null; heldView = hold; camPlan = { idx: stepIdx, holdMs: hold ? holdMs : 0 }; @@ -417,23 +488,35 @@ // cut still holds a beat of the before-world once the reel is // rolling; the opening frame reveals at once. const wasReady = camReady; - cam.x = tx; cam.y = ty; cam.facing = targetFacing; + const cut = () => { cam.x = tx; cam.y = ty; cam.facing = targetFacing; cam.pitch = targetPitch; }; camReady = true; + if (dieMs > 0 && wasReady) { + // The die decides where the eyes land: cut once it has. + const t = setTimeout(cut, dieMs); + const clear = setSlate(aim ? 380 / speed : 0); + return () => { clearTimeout(t); clear(); }; + } + cut(); return setSlate(wasReady && aim ? 380 / speed : 0); } - const fromX = camX, fromY = camY, fromF = camF; + const fromX = camX, fromY = camY, fromF = camF, fromP = camP; const arc = shortestArc(fromF, targetFacing); const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed; const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed; - const clearSlate = setSlate(aimed && aim ? turnMs + 120 / speed : 0); + const tiltMs = (Math.abs(targetPitch - fromP) > 0.01 ? 340 : 0) / speed; + const clearSlate = setSlate(aimed && (aim || underfoot) ? Math.max(turnMs, tiltMs) + 120 / speed : 0); const closingMs = Math.min(300, Math.abs(closingArc) * 200) / speed; - const t0 = performance.now(); + const t0 = performance.now() + dieMs; let raf = 0; const tick = (now: number) => { // rAF hands frame-start time, which can precede t0; and a // zero-length phase must never divide. Either poisons the facing // with NaN, which no later frame can wash out. const t = Math.max(0, now - t0); + if (tiltMs > 0) { + const w = Math.min(1, t / tiltMs); + cam.pitch = fromP + (targetPitch - fromP) * (w * w * (3 - 2 * w)); + } if (turnMs > 0 && t < turnMs) { cam.facing = fromF + arc * (t / turnMs); } else if (walkMs > 0 && t < turnMs + walkMs) { @@ -446,9 +529,12 @@ cam.x = tx; cam.y = ty; const w = (t - turnMs - walkMs) / closingMs; cam.facing = fromF + arc + closingArc * w * w * (3 - 2 * w); - } else { + } else if (tiltMs > 0 && t < tiltMs) { cam.facing = fromF + arc + closingArc; cam.x = tx; cam.y = ty; + } else { + cam.facing = fromF + arc + closingArc; + cam.x = tx; cam.y = ty; cam.pitch = targetPitch; return; } raf = requestAnimationFrame(tick); @@ -596,10 +682,22 @@
{#if fp} {:else} - + + {/if} + {#if die} +
+
+
{die.player} rolls the die
+
{die.purpose}
+
+ {die.face} +
+
{die.landed ? dieVerdict || "…" : "\u00a0"}
+
+
{/if}
@@ -695,7 +793,52 @@ min-height: 0; display: flex; justify-content: center; + position: relative; } + /* The die's interlude: a card over the held world. */ + .die-slate { + position: absolute; + inset: 0; + display: grid; + place-items: center; + background: rgba(10, 12, 16, 0.55); + } + .die-card { + background: #efe8d4; + color: #3a2f1f; + border: 2px solid #43331f; + border-radius: 6px; + padding: 0.7rem 1.4rem 0.8rem; + min-width: 17rem; + max-width: 80%; + text-align: center; + font-family: "Courier Prime", monospace; + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5); + } + .die-who { + font-family: "Oswald", sans-serif; + text-transform: uppercase; + letter-spacing: 0.12em; + font-size: 0.8rem; + color: #6b5a3e; + } + .die-why { font-size: 0.82rem; margin: 0.15rem 0 0.5rem; } + .die-cube { + width: 4.2rem; + height: 4.2rem; + margin: 0 auto; + display: grid; + place-items: center; + background: #e0b34a; + border: 2px solid #43331f; + border-radius: 0.6rem; + font-family: "Oswald", sans-serif; + font-size: 2.4rem; + color: #2b2218; + transition: none; + } + .die-slate.landed .die-cube { background: #f2d27a; box-shadow: 0 0 0 4px rgba(224, 179, 74, 0.35); } + .die-verdict { margin-top: 0.55rem; font-size: 0.82rem; min-height: 1.3em; } .replay-board :global(svg.board) { max-height: calc(100dvh - 15rem); width: auto; diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index db816a6..2cfe7e6 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -18,6 +18,7 @@ x, y, facing, + pitch = 0, width = 720, height = 440, fx = [], @@ -35,6 +36,11 @@ y: number; /** Radians; 0 faces east, matching the board's +x. */ facing: number; + /** Tilt, as the fraction of the pane's height the horizon rises: + * 0 looks level, ~0.36 looks down at your own feet. The raycaster + * shears rather than rotates — verticals stay vertical, and the + * ground at the pane's foot comes within half a cell of the eye. */ + pitch?: number; width?: number; height?: number; /** Live spell moments: projectiles, impacts, flashes, shakes. */ @@ -63,7 +69,8 @@ * the pane is an instrument only because the renderer remembers what * stood under every pixel. */ let hitFrame: { - W: number; H: number; ex: number; ey: number; facing: number; + W: number; + horizon: number; H: number; ex: number; ey: number; facing: number; zbuf: Float64Array; warpIdCol: Int32Array; warpDistCol: Float64Array; cols: ({ edge?: string; kind: string; top: number; h: number } | null)[]; /** Depth-sorted nearest-first, ready for hover hit-testing. */ @@ -189,7 +196,10 @@ const ctx = canvas?.getContext("2d"); if (!ctx) return; ctx.imageSmoothingEnabled = false; // crisp texels, as the old masters drew - const W = width, H = height, half = H / 2; + // `half` is the horizon row — mid-pane when level, higher when the + // eyes look down. Wall faces center on it; the eye's own height + // above the floor stays H/2 wherever the horizon sits. + const W = width, H = height, half = H / 2 - pitch * H; // Active shakes wobble the eye a hair off true, fading as they run. let ex = x, ey = y; @@ -223,12 +233,72 @@ } } + + // Walls, one ray per column; remember each column's depth for + // sprites, and whether its ray bent through a warp — a sprite's warp + // side must MATCH its column's, or bodies near a far mouth would + // ghost into real corridors the unrolled space happens to overlap. + const zbuf = new Float64Array(W); + const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null); + // Per column: which warp (if any) the ray bent through, and how far + // away that mouth stood. A REAL body paints a column only if it is + // nearer than the mouth (it stands in front of the window); a + // VIRTUAL one only through its OWN warp's columns, beyond the mouth. + const warpIdCol = new Int32Array(W).fill(-1); + const warpDistCol = new Float64Array(W).fill(Infinity); + // Each mouth a ray passed through hangs a translucent veil of the + // warp texture at its own depth, drawn with the other overlays. + const veils: { col: number; depth: number; u: number; warp: number }[] = []; + // Known illusions: the ray passes, but a translucent ghost of a wall + // stands at the crossing — drawn after the sprites so bodies show + // through it, shimmering so nobody mistakes it for stone. + const ghosts: { col: number; depth: number; u: number; worldU: number }[] = []; + // Open doorways collect their lintels, hung after the sprites; and + // conjurations still rising collect their growing columns. + const lintels: { col: number; depth: number; u: number }[] = []; + const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = []; + // Cast first, draw after: the floor pass between them needs to know + // which columns bent through a warp, so the ground beyond a mouth can + // wear the far room's decals — the pit past the wormhole included. + const rays: { hit: ReturnType; depth: number; wallH: number; top: number }[] = new Array(W); + for (let col = 0; col < W; col++) { + const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2)); + const hit = castRay(view, ex, ey, rayAngle, doors, growing); + const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye + zbuf[col] = depth; + if (hit.warpId !== undefined) { + warpIdCol[col] = hit.warpId; + warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing); + veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId }); + } + if (hit.ghost) { + ghosts.push({ + col, depth: hit.ghost.dist * Math.cos(rayAngle - facing), + u: hit.ghost.u, worldU: hit.ghost.worldU, + }); + } + if (hit.doorway) { + lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u }); + } + if (hit.rising) { + risings.push({ + col, depth: hit.rising.dist * Math.cos(rayAngle - facing), + u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g, + }); + } + const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); + const top = half - wallH / 2; + hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH }; + rays[col] = { hit, depth, wallH, top }; + } + // The ground and the vault overhead, cast per pixel: each screen row // below (or above) the horizon lies at one fixed depth, so the row is // walked with a constant world-space step and sampled from the floor // or ceiling texture — every maze cell wearing one full tile of it. const flen = (W / 2) / Math.tan(FOV / 2); const cosF = Math.cos(facing), sinF = Math.sin(facing); + const motions = view.board.warps.map(warpMotion); if (!frame || frame.width !== W || frame.height !== H) frame = ctx.createImageData(W, H); const buf = frame.data; const fl = pixelsOf(textures.floor!); @@ -278,25 +348,35 @@ if (shade <= 0.02) { buf[o] = 8; buf[o + 1] = 7; buf[o + 2] = 9; } else { - let u = wx % 1; if (u < 0) u += 1; - let v = wy % 1; if (v < 0) v += 1; + // Ground seen through a warp mouth belongs to the far room: + // carry the point through the warp's rigid motion before + // asking whose flagstone, whose decal, whose light it wears. + let gx = wx, gy = wy; + let hazed = false; + if (below && warpIdCol[col]! >= 0 && d > warpDistCol[col]!) { + const real = motions[warpIdCol[col]!]!.toReal({ x: wx, y: wy }); + gx = real.x; gy = real.y; + hazed = true; + } + let u = gx % 1; if (u < 0) u += 1; + let v = gy % 1; if (v < 0) v += 1; const ti = (((v * th) | 0) * tw + ((u * tw) | 0)) * 4; let r = tp[ti]!, g = tp[ti + 1]!, b = tp[ti + 2]!; let sh = shade; if (litGrid && below) { - const gx = (wx - u) | 0, gy = (wy - v) | 0; - if (gx < 0 || gy < 0 || gx >= litBw || gy >= litBh || !litGrid[gy * litBw + gx]) sh *= 0.3; + const cx = (gx - u) | 0, cy = (gy - v) | 0; + if (cx < 0 || cy < 0 || cx >= litBw || cy >= litBh || !litGrid[cy * litBw + cx]) sh *= 0.3; } if (dreadGrid && below) { - const gx = (wx - u) | 0, gy = (wy - v) | 0; - if (gx >= 0 && gy >= 0 && gx < litBw && gy < litBh && dreadGrid[gy * litBw + gx]) { + const cx = (gx - u) | 0, cy = (gy - v) | 0; + if (cx >= 0 && cy >= 0 && cx < litBw && cy < litBh && dreadGrid[cy * litBw + cx]) { r = r * (1 - dreadBlend) + 122 * dreadBlend; g = g * (1 - dreadBlend) + 74 * dreadBlend; b = b * (1 - dreadBlend) + 138 * dreadBlend; } } if (dec) { - const hx = (wx - u) | 0, hy = (wy - v) | 0; + const hx = (gx - u) | 0, hy = (gy - v) | 0; if (hx >= 0 && hy >= 0 && hx < dec.bw && hy < dec.bh) { const di = dec.grid[hy * dec.bw + hx]!; if (di >= 0) { @@ -315,6 +395,10 @@ } } } + if (hazed) { + // The far room's ground swims in the same violet haze as its walls. + r = r * 0.82 + 120 * 0.18; g = g * 0.82 + 70 * 0.18; b = b * 0.82 + 220 * 0.18; + } buf[o] = r * sh; buf[o + 1] = g * sh; buf[o + 2] = b * sh; @@ -326,57 +410,9 @@ } ctx.putImageData(frame, 0, 0); - // Walls, one ray per column; remember each column's depth for - // sprites, and whether its ray bent through a warp — a sprite's warp - // side must MATCH its column's, or bodies near a far mouth would - // ghost into real corridors the unrolled space happens to overlap. - const zbuf = new Float64Array(W); - const hitCols: ({ edge?: string; kind: string; top: number; h: number } | null)[] = new Array(W).fill(null); - // Per column: which warp (if any) the ray bent through, and how far - // away that mouth stood. A REAL body paints a column only if it is - // nearer than the mouth (it stands in front of the window); a - // VIRTUAL one only through its OWN warp's columns, beyond the mouth. - const warpIdCol = new Int32Array(W).fill(-1); - const warpDistCol = new Float64Array(W).fill(Infinity); - // Each mouth a ray passed through hangs a translucent veil of the - // warp texture at its own depth, drawn with the other overlays. - const veils: { col: number; depth: number; u: number; warp: number }[] = []; - // Known illusions: the ray passes, but a translucent ghost of a wall - // stands at the crossing — drawn after the sprites so bodies show - // through it, shimmering so nobody mistakes it for stone. - const ghosts: { col: number; depth: number; u: number; worldU: number }[] = []; - // Open doorways collect their lintels, hung after the sprites; and - // conjurations still rising collect their growing columns. - const lintels: { col: number; depth: number; u: number }[] = []; - const risings: { col: number; depth: number; u: number; worldU: number; kind: string; g: number }[] = []; + // Walls, drawn from the cast: one column each. for (let col = 0; col < W; col++) { - const rayAngle = facing + Math.atan((col / W - 0.5) * 2 * Math.tan(FOV / 2)); - const hit = castRay(view, ex, ey, rayAngle, doors, growing); - const depth = hit.dist * Math.cos(rayAngle - facing); // no fisheye - zbuf[col] = depth; - if (hit.warpId !== undefined) { - warpIdCol[col] = hit.warpId; - warpDistCol[col] = (hit.warpDist ?? 0) * Math.cos(rayAngle - facing); - veils.push({ col, depth: warpDistCol[col]!, u: hit.warpU ?? 0, warp: hit.warpId }); - } - if (hit.ghost) { - ghosts.push({ - col, depth: hit.ghost.dist * Math.cos(rayAngle - facing), - u: hit.ghost.u, worldU: hit.ghost.worldU, - }); - } - if (hit.doorway) { - lintels.push({ col, depth: hit.doorway.dist * Math.cos(rayAngle - facing), u: hit.doorway.u }); - } - if (hit.rising) { - risings.push({ - col, depth: hit.rising.dist * Math.cos(rayAngle - facing), - u: hit.rising.u, worldU: hit.rising.worldU, kind: hit.rising.kind, g: hit.rising.g, - }); - } - const wallH = Math.min(H * 2.5, H / Math.max(depth, 0.05)); - const top = half - wallH / 2; - hitCols[col] = { edge: hit.edge, kind: hit.frame ? "frame" : hit.kind, top, h: wallH }; + const { hit, depth, wallH, top } = rays[col]!; const tex = hit.frame ? textures.doorframe! : textures[hit.kind] ?? textures.wall!; // Sample by the texture's own size: painted files may be any scale. // Fire shifts its slice per world cell, so a blaze spanning edges @@ -466,7 +502,7 @@ } sprites.sort((a, b) => b.sort - a.sort); hitFrame = { - W, H, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, + W, H, horizon: half, ex, ey, facing, zbuf, warpIdCol, warpDistCol, cols: hitCols, sprites: [...sprites].sort((a, b) => a.depth - b.depth), }; // The nearest sprite each column carries — depth AND vertical span — @@ -542,18 +578,22 @@ if (s.depth < spriteZ[col]!) { spriteZ[col] = s.depth; sprTop[col] = s.top; - sprBot[col] = s.bottom; + sprBot[col] = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) : s.bottom; } const texX = ((col - s.left) / (s.right - s.left)); + // Down a pit, the body ends at the ground: only the rows above + // the floor line paint, and only that much of the art. + const shown = s.clipY !== undefined ? Math.min(s.bottom, s.clipY) - s.top : s.bottom - s.top; + if (shown <= 0) continue; if (img) { ctx.drawImage( img, - texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih, - col, s.top, 1, s.bottom - s.top, + texX * iw, 0, Math.max(1, iw / (s.right - s.left)), ih * (shown / (s.bottom - s.top)), + col, s.top, 1, shown, ); } else { ctx.fillStyle = "rgba(200,190,160,0.6)"; - ctx.fillRect(col, s.top, 1, s.bottom - s.top); + ctx.fillRect(col, s.top, 1, shown); } if (s.warped) { // A body seen through a warp swims in the same violet haze. @@ -709,6 +749,71 @@ ctx.globalAlpha = 1; } + // Fists. Yours lunges from the lower right into the scene, shrinking + // as it goes, and snaps back; one thrown at you swells from the + // middle of the pane until it fills it. Either way the blow lands + // with a comic starburst. + for (const f of fx) { + if (f.kind !== "fist") continue; + const p = (time - f.t0) / f.dur; + if (p < 0 || p >= 1) continue; + const ease = (w: number) => w * w * (3 - 2 * w); + const out = f.swing === "out"; + // Travel: out 0→0.42 lunge, 0.42→1 retract; in 0→0.45 approach, then hold and fade. + const a = out ? (p < 0.42 ? ease(p / 0.42) : ease(1 - (p - 0.42) / 0.58)) : Math.min(1, ease(p / 0.45)); + const sx = out ? W * 0.86 : W * 0.5, sy = out ? H * 1.15 : H * 0.55; + const tx = W * 0.56, ty = H * 0.5; + const fx0 = sx + (tx - sx) * a, fy0 = sy + (ty - sy) * a; + const size = out ? H * (0.62 - 0.30 * a) : H * (0.18 + 1.25 * a); + const glyph = out ? "\u{1F91C}" : "\u{1F44A}"; + ctx.save(); + ctx.globalAlpha = !out && p > 0.75 ? 1 - (p - 0.75) / 0.25 : 1; + ctx.translate(fx0, fy0); + ctx.rotate(out ? -0.6 : 0.15); + ctx.font = `${Math.round(size)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.shadowColor = "rgba(0,0,0,0.55)"; + ctx.shadowBlur = 12; + ctx.shadowOffsetY = 6; + ctx.fillText(glyph, 0, 0); + ctx.restore(); + // POW: pops at the landing, holds, and shrinks away. + const land = out ? 0.36 : 0.4; + if (p >= land) { + const q = (p - land) / (1 - land); + const pop = q < 0.18 ? ease(q / 0.18) * 1.15 : q < 0.7 ? 1.15 - 0.15 * ease((q - 0.18) / 0.52) : 1 - ease((q - 0.7) / 0.3); + if (pop > 0.01) { + const R = H * 0.19 * pop; + const cx = out ? tx - W * 0.02 : W * 0.5, cy = out ? ty - H * 0.16 : H * 0.34; + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-0.14); + ctx.beginPath(); + for (let i = 0; i < 24; i++) { + const r = i % 2 === 0 ? R : R * 0.68; + const t = (i / 24) * Math.PI * 2; + ctx.lineTo(Math.cos(t) * r * 1.35, Math.sin(t) * r); + } + ctx.closePath(); + ctx.fillStyle = "#f2d24a"; + ctx.strokeStyle = "#2b2218"; + ctx.lineWidth = Math.max(2, R * 0.08); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "#c03020"; + ctx.strokeStyle = "#2b2218"; + ctx.lineWidth = Math.max(1.5, R * 0.06); + ctx.font = `600 ${Math.round(R * 0.95)}px Oswald, "Archivo Narrow", sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.strokeText("POW!", 0, R * 0.04); + ctx.fillText("POW!", 0, R * 0.04); + ctx.restore(); + } + } + } + // A change of eyes: the slate wipes across, names the wizard whose // sight this now is, and wipes away to reveal it. for (const f of fx) { @@ -722,27 +827,28 @@ ctx.clip(); ctx.fillStyle = "#171a20"; ctx.fillRect(x0, 0, x1 - x0, H); + const mid = H / 2; ctx.fillStyle = "#e0b34a"; - ctx.fillRect(x0, half - 1, x1 - x0, 2); + ctx.fillRect(x0, mid - 1, x1 - x0, 2); // The card's face: standee, then the name — composed on the full // pane so it lands where it will sit once the band is complete. const face = f.art ? imageFor(f.art) : null; const size = Math.round(H * 0.24); const cx = W / 2; if (face) { - ctx.drawImage(face, cx - size / 2, half - size - 18, size, size); + ctx.drawImage(face, cx - size / 2, mid - size - 18, size, size); ctx.strokeStyle = "#43331f"; ctx.lineWidth = 2; - ctx.strokeRect(cx - size / 2, half - size - 18, size, size); + ctx.strokeRect(cx - size / 2, mid - size - 18, size, size); } ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; ctx.fillStyle = "#a49c86"; ctx.font = `600 ${Math.round(H * 0.045)}px Oswald, "Archivo Narrow", sans-serif`; - ctx.fillText("T H R O U G H T H E E Y E S O F", cx, half + Math.round(H * 0.09)); + ctx.fillText("T H R O U G H T H E E Y E S O F", cx, mid + Math.round(H * 0.09)); ctx.fillStyle = "#e9e1cb"; ctx.font = `600 ${Math.round(H * 0.13)}px Oswald, "Archivo Narrow", sans-serif`; - ctx.fillText(f.name.toUpperCase(), cx, half + Math.round(H * 0.23)); + ctx.fillText(f.name.toUpperCase(), cx, mid + Math.round(H * 0.23)); ctx.restore(); } @@ -835,6 +941,8 @@ glow?: boolean; alpha?: number; fallback?: string; + /** Rows at or below this are underground: not painted. */ + clipY?: number; clampL?: number; clampR?: number; hit?: { kind: "player" | "creature"; id: string }; @@ -854,14 +962,17 @@ clip?: { x: number; y: number }; hit?: { kind: "player" | "creature"; id: string }; cell?: { x: number; y: number }; - webbed?: boolean; gaze?: boolean; dread?: boolean }, + webbed?: boolean; gaze?: boolean; dread?: boolean; sink?: boolean }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; - const depth = relX * Math.cos(facing) + relY * Math.sin(facing); + let depth = relX * Math.cos(facing) + relY * Math.sin(facing); + let side = -relX * Math.sin(facing) + relY * Math.cos(facing); + // A being sharing the eyes' own square has no depth to draw at: + // it stands right in front of them, whichever way they look. + if (b.hit && Math.hypot(relX, relY) < 0.35) { depth = 1.0; side = 0; } if (depth < 0.15) return null; - const side = -relX * Math.sin(facing) + relY * Math.cos(facing); - const W = width, H = height, half = H / 2; + const W = width, H = height, half = H / 2 - pitch * H; const screenX = W / 2 + (side / depth) * (W / 2) / Math.tan(FOV / 2); const wallH = H / depth; const size = wallH * b.scale; @@ -873,6 +984,8 @@ ? size * b.aspect * (((W / 2) / Math.tan(FOV / 2)) / H) : size; const bottom = half + wallH / 2 - b.rise * wallH; + // A sunken body paints only above the floor line at its depth. + const clipY = b.sink ? half + wallH / 2 : undefined; // A cell-bound volume is windowed to its own square: the billboard's // camera-facing plane is intersected with the cell, and only that // lateral segment may paint, so an obliquely-viewed hedge cannot @@ -908,7 +1021,7 @@ src: b.src, depth, sort: depth - (b.bias ?? 0), warped: b.warped, warpId: b.warpId, alpha: b.alpha, glow: b.glow, fallback: b.fallback, clampL, clampR, webbed: b.webbed, gaze: b.gaze, dread: b.dread, - hit: b.hit, cell: b.cell, + hit: b.hit, cell: b.cell, clipY, left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, }; @@ -937,7 +1050,7 @@ const f = hitFrame; if (!f) return null; const col = Math.max(0, Math.min(f.W - 1, px | 0)); - const half = f.H / 2; + const half = f.horizon; // Sprites first, nearest first, honoring the draw pass's own // visibility rules for this column. diff --git a/packages/web/src/fpv/director.ts b/packages/web/src/fpv/director.ts index fe9f2cf..fcb96b6 100644 --- a/packages/web/src/fpv/director.ts +++ b/packages/web/src/fpv/director.ts @@ -98,9 +98,11 @@ export function aimOfEvents( /** Does this batch hurl the pov wizard bodily — a blow to glide with, * eyes held steady, rather than a stride or a cut? */ export function hurledIn(events: GameEvent[], povId: string): boolean { + // A leap over a pit is a glide too: two squares in one bound, fast + // and straight, eyes ahead — never a cut that reads as a teleport. return events.some((e) => (e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" || - e.type === "retreatedInHorror") && e.player === povId); + e.type === "retreatedInHorror" || e.type === "jumpedPit") && e.player === povId); } export interface Glide { @@ -156,7 +158,17 @@ export function cutawayStand(view: GameView, x: number, y: number): { facing: nu let deepest = -1, facing = 0; for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) { const h = castRay(view, x, y, a); - const d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist; + let d = h.warped && h.warpDist !== undefined ? Math.min(h.dist, h.warpDist) : h.dist; + // A body standing down this corridor is where the eyes must stop + // short of — the camera stands before it, or picks another way, + // never inside a wizard's own square with their face filling it. + for (const p of view.players) { + if (!p.alive) continue; + const px = p.position.x + 0.5 - x, py = p.position.y + 0.5 - y; + const along = px * Math.cos(a) + py * Math.sin(a); + const across = -px * Math.sin(a) + py * Math.cos(a); + if (along > 0.1 && along < d && Math.abs(across) < 0.5) d = Math.min(d, along - 0.55); + } if (d > deepest) { deepest = d; facing = a; } } return { facing, depth: deepest }; diff --git a/packages/web/src/fpv/fx3d.ts b/packages/web/src/fpv/fx3d.ts index 4fdd7ab..85e6c36 100644 --- a/packages/web/src/fpv/fx3d.ts +++ b/packages/web/src/fpv/fx3d.ts @@ -29,6 +29,10 @@ export type FpFx = * whose turn — and whose sight — this now is, so a cut between * wizards never reads as one wizard teleporting. */ | { id: number; kind: "slate"; name: string; art: string | null; t0: number; dur: number } + /** A punch, felt: your own fist drives into the scene ("out"), or + * someone's comes at the camera ("in") — with the comic POW at the + * moment it lands, so a blow never reads as just another flash. */ + | { id: number; kind: "fist"; swing: "out" | "in"; t0: number; dur: number } | { id: number; kind: "grow"; slot: "solid" | "sprite"; key: string; t0: number; dur: number }; /** How hard a surging veil glows at progress p: a snap to full as the @@ -157,6 +161,11 @@ export function fpFxForEvents( const warpIndex = (m: { cell: { x: number; y: number }; side: string }) => view.board.warps.findIndex((w) => w.from.cell.x === m.cell.x && w.from.cell.y === m.cell.y && w.from.side === m.side); for (const e of events) { + if (e.type === "punched" && "attacker" in e) { + const pe = e as { attacker: string; target: string }; + if (pe.attacker === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "out", t0: 0, dur: 620 }, delay: 0 }); + else if (pe.target === povId) out.push({ fx: { id: nextId++, kind: "fist", swing: "in", t0: 0, dur: 620 }, delay: 0 }); + } if (e.type === "warpOpened") { for (const [m, delay] of [[e.a, 0], [e.b, 200]] as const) { const warp = warpIndex(m); diff --git a/packages/web/src/fpv/raycast.ts b/packages/web/src/fpv/raycast.ts index 866ce2b..7a584bf 100644 --- a/packages/web/src/fpv/raycast.ts +++ b/packages/web/src/fpv/raycast.ts @@ -308,8 +308,11 @@ export interface Billboard { src: string; /** Fraction of wall height (a wizard stands taller than a chest). */ scale: number; - /** Lifted off the floor (0 = feet on the ground). */ + /** Lifted off the floor (0 = feet on the ground); negative sinks. */ rise: number; + /** Below the floor line the body is hidden — a wizard down a pit shows + * only what stands proud of the ground. */ + sink?: boolean; label: string; /** Width as a multiple of height (1 = square; 2 = a cell-wide hedge). */ aspect?: number; @@ -413,7 +416,8 @@ export function billboards( out.push({ x: o?.x ?? p.position.x + 0.5, y: o?.y ?? p.position.y + 0.5, src: art(`wizard-${p.colorIndex}`, "players"), - scale: big ? 1.2 : small ? 0.53 : 0.85, rise: 0, label: p.id, + scale: big ? 1.2 : small ? 0.53 : 0.85, label: p.id, + rise: p.inPit ? -0.42 : 0, sink: p.inPit || undefined, alpha: ghost ? 0.35 : undefined, webbed: wears(p.id, "sticky-web") || undefined, gaze: wears(p.id, "medusa") || undefined,