diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 08b046b..2b22f0f 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -488,6 +488,7 @@ // additively. Either way translucency applies. if (s.glow) ctx.globalCompositeOperation = "lighter"; if (s.alpha !== undefined || s.glow) ctx.globalAlpha = s.alpha ?? 1; + if (s.gaze) ctx.filter = "grayscale(0.9) brightness(0.8)"; for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { if (!spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) continue; if (s.depth < spriteZ[col]!) { @@ -512,8 +513,47 @@ ctx.fillRect(col, s.top, 1, s.bottom - s.top); } } + 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); + } + 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(); + } + } } // Warp mouths wear their veil: the warp texture at the opening, @@ -706,6 +746,9 @@ clampR?: number; hit?: { kind: "player" | "creature"; id: string }; cell?: { x: number; y: number }; + webbed?: boolean; + gaze?: boolean; + dread?: boolean; /** Sort key only: the near-bias orders sprites among THEMSELVES (a * hedge over the wizard standing in it) but must never let one * cheat past a wall — occlusion always uses the true depth. */ @@ -717,7 +760,8 @@ fallback?: string; warped?: boolean; warpId?: number; clip?: { x: number; y: number }; hit?: { kind: "player" | "creature"; id: string }; - cell?: { x: number; y: number } }, + cell?: { x: number; y: number }; + webbed?: boolean; gaze?: boolean; dread?: boolean }, ex: number, ey: number, ): Projected | null { const relX = b.x - ex, relY = b.y - ey; @@ -770,6 +814,7 @@ return { 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, left: screenX - wide / 2, right: screenX + wide / 2, top: bottom - size, bottom, diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index 5ef781f..3f68bc5 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -62,6 +62,11 @@ const rival = view.players.find((p) => p.id === "Rival"); if (rival && Number.isFinite(rx) && Number.isFinite(ry)) rival.position = { x: rx!, y: ry! }; } + // ?wear=sticky-web;fear dresses the Rival in ongoing spells, for + // rehearsing the sprite's condition looks (webs, gaze, dread, sizes). + for (const cardId of (q.get("wear") ?? "").split(";").filter(Boolean)) { + view.sustained.push({ id: `pose-${cardId}`, cardId, casterId: "Rival", targetId: "Rival", remainingTurns: 9, data: {} }); + } const aimCells = q.has("aim") ? sightedCellsFor(view) : null; const start = view.players.find((p) => p.id === povId)!.position; @@ -223,7 +228,7 @@ ?fpv&seed=N, stand anywhere with &x=&y=&dir=, or watch the clockwork's reel with &demo=1 (toggle 👁). &aim=1 arms a pretend cast — sighted ground lights, marked - beings wear the ember ring — and &rival=x,y poses the Rival. + beings wear the ember ring — and &rival=x,y poses the Rival; &wear=sticky-web;fear dresses them in ongoing spells.

, ): Billboard[] { const out: Billboard[] = []; + const wears = (id: string, cardId: string) => + view.sustained.some((e) => e.cardId === cardId && e.targetId === id); for (const p of view.players) { if (!p.alive || p.id === povId) continue; const o = posOverride?.[p.id]; + // Ongoing spells wear their look here as on the board: giants tower, + // the shrunk dwindle, the unseen go translucent, webs and stone and + // dread mark their bearers. + const big = wears(p.id, "big-man"); + const small = wears(p.id, "shrink"); + const ghost = wears(p.id, "invisible") || wears(p.id, "mist-body"); 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: 0.85, rise: 0, label: p.id, + src: art(`wizard-${p.colorIndex}`, "players"), + scale: big ? 1.2 : small ? 0.53 : 0.85, rise: 0, label: p.id, + alpha: ghost ? 0.35 : undefined, + webbed: wears(p.id, "sticky-web") || undefined, + gaze: wears(p.id, "medusa") || undefined, + dread: wears(p.id, "fear") || undefined, hit: { kind: "player", id: p.id }, cell: { ...p.position }, }); }