From c8f719f46730f9304defc00cc53b04951815ae2a Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 1 Sep 2026 12:17:55 -0400 Subject: [PATCH] The cockpit's webs and dread ring learn to sit right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sticky-web silk becomes a real web — hub, spokes, two sagging rings — each strand shadowed so it reads against busy token art. The dread ring moves behind the body onto the floor plane, sharing the ember ring's flattened perspective instead of smouldering over the sprite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF --- packages/web/src/fpv/FirstPerson.svelte | 128 +++++++++++++----------- 1 file changed, 69 insertions(+), 59 deletions(-) diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 1c5afcf..2449b3f 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -10,7 +10,7 @@ import { tokenArt } from "../art"; import { PLAYER_COLORS } from "../colors"; import { fearCells } from "@wizwar/engine"; -import type { GameView } from "@wizwar/engine"; + import type { GameView } from "@wizwar/engine"; let { view, @@ -488,31 +488,44 @@ import type { GameView } from "@wizwar/engine"; : null); const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0); const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0); - // A being standing on castable ground wears an ember ring while a - // targeted card is armed: the cockpit's answer to the board's lit - // squares, drawn behind the body so the art stays clean. Only a - // sprite the walls actually show earns its ring. - if (ontarget && aimBeings && litCells && s.hit && s.cell && + // Rings live on the floor, behind the body, so the art stays clean: + // FEAR's dread ring smoulders at the feet, and a being standing on + // castable ground wears the ember ring while a targeted card is + // armed — the cockpit's answer to the board's lit squares. Only a + // sprite the walls actually show earns its marks. + let seen = false; + for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { + if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } + } + const ringY = Math.min(H - 2, s.bottom); + if (seen && s.dread && s.hit) { + const w = s.right - s.left; + const pulse = 0.45 + 0.25 * Math.sin(time / 300); + ctx.save(); + ctx.strokeStyle = `rgba(160,30,30,${pulse})`; + ctx.fillStyle = "rgba(160,30,30,0.12)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse((s.left + s.right) / 2, ringY, Math.max(9, w * 0.5), Math.max(3, w * 0.155), 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + if (seen && ontarget && aimBeings && litCells && s.hit && s.cell && litCells.has(`${s.cell.x},${s.cell.y}`) && !(s.hit.kind === "player" && s.hit.id === view.you)) { - let seen = false; - for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { - if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } - } - if (seen) { - const w = s.right - s.left; - const cx = (s.left + s.right) / 2; - const pulse = 0.55 + 0.25 * Math.sin(time / 220); - ctx.save(); - ctx.strokeStyle = `rgba(232,160,60,${pulse})`; - ctx.fillStyle = "rgba(232,160,60,0.16)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(cx, Math.min(H - 2, s.bottom), Math.max(7, w * 0.42), Math.max(3, w * 0.13), 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - ctx.restore(); - } + const w = s.right - s.left; + const cx = (s.left + s.right) / 2; + const pulse = 0.55 + 0.25 * Math.sin(time / 220); + ctx.save(); + ctx.strokeStyle = `rgba(232,160,60,${pulse})`; + ctx.fillStyle = "rgba(232,160,60,0.16)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(cx, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), 0, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.restore(); } // Painted art composites normally (inks stay true); light glows // additively. Either way translucency applies. @@ -546,43 +559,40 @@ import type { GameView } from "@wizwar/engine"; if (s.gaze) ctx.filter = "none"; if (s.glow) ctx.globalCompositeOperation = "source-over"; if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1; - // STICKY WAND's webs: strands crisscross the caught body, and FEAR's - // dread ring smoulders at the feet — both only when the walls show - // any of the sprite at all. - if ((s.webbed || s.dread) && s.hit) { - let seen = false; - for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { - if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } - } - if (seen) { - const w = s.right - s.left, h = s.bottom - s.top; - ctx.save(); - if (s.webbed) { - ctx.strokeStyle = "rgba(240,240,235,0.55)"; - ctx.lineWidth = 1; - ctx.beginPath(); - for (const [x1, y1, x2, y2] of [ - [0.05, 0.2, 0.95, 0.75], [0.1, 0.7, 0.9, 0.15], - [0.0, 0.45, 1.0, 0.5], [0.35, 0.05, 0.55, 0.95], [0.75, 0.1, 0.6, 0.9], - ] as const) { - ctx.moveTo(s.left + x1 * w, s.top + y1 * h); - ctx.lineTo(s.left + x2 * w, s.top + y2 * h); + // STICKY WAND's webs cling over the caught body: spokes from a hub + // and two sagging rings, each strand shadowed so the silk reads + // against busy token art. Only when the walls show the sprite. + if (s.webbed && s.hit && seen) { + const w = s.right - s.left, h = s.bottom - s.top; + const hubX = s.left + w * 0.5, hubY = s.top + h * 0.45; + const rim: [number, number][] = [ + [0.02, 0.06], [0.5, 0.0], [0.98, 0.08], [1.0, 0.5], + [0.96, 0.94], [0.5, 1.0], [0.04, 0.92], [0.0, 0.5], + ].map(([fx, fy]) => [s.left + fx * w, s.top + fy * h]); + const web = () => { + ctx.beginPath(); + for (const [rx, ry] of rim) { + ctx.moveTo(hubX, hubY); + ctx.lineTo(rx, ry); + } + for (const t of [0.45, 0.75]) { + for (let i = 0; i <= rim.length; i++) { + const [rx, ry] = rim[i % rim.length]!; + const px = hubX + (rx - hubX) * t, py = hubY + (ry - hubY) * t + h * 0.02; + if (i === 0) ctx.moveTo(px, py); + else ctx.lineTo(px, py); } - ctx.stroke(); } - if (s.dread) { - const pulse = 0.45 + 0.25 * Math.sin(time / 300); - ctx.strokeStyle = `rgba(160,30,30,${pulse})`; - ctx.fillStyle = "rgba(160,30,30,0.12)"; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse((s.left + s.right) / 2, Math.min(H - 2, s.bottom), - Math.max(9, w * 0.55), Math.max(4, w * 0.17), 0, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - } - ctx.restore(); - } + ctx.stroke(); + }; + ctx.save(); + ctx.strokeStyle = "rgba(25,25,30,0.45)"; + ctx.lineWidth = 2.5; + web(); + ctx.strokeStyle = "rgba(245,245,240,0.8)"; + ctx.lineWidth = 1; + web(); + ctx.restore(); } }