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.