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(); } }