The cockpit's webs and dread ring learn to sit right

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RCWSTnb1KYTPyL4GmhGnF
This commit is contained in:
Eric Wagoner
2026-09-01 12:17:55 -04:00
co-authored by Claude Fable 5
parent 19888f9e84
commit c8f719f467
+69 -59
View File
@@ -10,7 +10,7 @@
import { tokenArt } from "../art"; import { tokenArt } from "../art";
import { PLAYER_COLORS } from "../colors"; import { PLAYER_COLORS } from "../colors";
import { fearCells } from "@wizwar/engine"; import { fearCells } from "@wizwar/engine";
import type { GameView } from "@wizwar/engine"; import type { GameView } from "@wizwar/engine";
let { let {
view, view,
@@ -488,31 +488,44 @@ import type { GameView } from "@wizwar/engine";
: null); : null);
const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0); const iw = img instanceof HTMLImageElement ? img.naturalWidth : (img?.width ?? 0);
const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0); const ih = img instanceof HTMLImageElement ? img.naturalHeight : (img?.height ?? 0);
// A being standing on castable ground wears an ember ring while a // Rings live on the floor, behind the body, so the art stays clean:
// targeted card is armed: the cockpit's answer to the board's lit // FEAR's dread ring smoulders at the feet, and a being standing on
// squares, drawn behind the body so the art stays clean. Only a // castable ground wears the ember ring while a targeted card is
// sprite the walls actually show earns its ring. // armed — the cockpit's answer to the board's lit squares. Only a
if (ontarget && aimBeings && litCells && s.hit && s.cell && // 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}`) && litCells.has(`${s.cell.x},${s.cell.y}`) &&
!(s.hit.kind === "player" && s.hit.id === view.you)) { !(s.hit.kind === "player" && s.hit.id === view.you)) {
let seen = false; const w = s.right - s.left;
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { const cx = (s.left + s.right) / 2;
if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } const pulse = 0.55 + 0.25 * Math.sin(time / 220);
} ctx.save();
if (seen) { ctx.strokeStyle = `rgba(232,160,60,${pulse})`;
const w = s.right - s.left; ctx.fillStyle = "rgba(232,160,60,0.16)";
const cx = (s.left + s.right) / 2; ctx.lineWidth = 2;
const pulse = 0.55 + 0.25 * Math.sin(time / 220); ctx.beginPath();
ctx.save(); ctx.ellipse(cx, ringY, Math.max(7, w * 0.42), Math.max(3, w * 0.13), 0, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(232,160,60,${pulse})`; ctx.fill();
ctx.fillStyle = "rgba(232,160,60,0.16)"; ctx.stroke();
ctx.lineWidth = 2; ctx.restore();
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();
}
} }
// Painted art composites normally (inks stay true); light glows // Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies. // additively. Either way translucency applies.
@@ -546,43 +559,40 @@ import type { GameView } from "@wizwar/engine";
if (s.gaze) ctx.filter = "none"; if (s.gaze) ctx.filter = "none";
if (s.glow) ctx.globalCompositeOperation = "source-over"; if (s.glow) ctx.globalCompositeOperation = "source-over";
if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1; if (s.alpha !== undefined || s.glow) ctx.globalAlpha = 1;
// STICKY WAND's webs: strands crisscross the caught body, and FEAR's // STICKY WAND's webs cling over the caught body: spokes from a hub
// dread ring smoulders at the feet — both only when the walls show // and two sagging rings, each strand shadowed so the silk reads
// any of the sprite at all. // against busy token art. Only when the walls show the sprite.
if ((s.webbed || s.dread) && s.hit) { if (s.webbed && s.hit && seen) {
let seen = false; const w = s.right - s.left, h = s.bottom - s.top;
for (let col = Math.max(0, s.left | 0); col < Math.min(W, s.right); col++) { const hubX = s.left + w * 0.5, hubY = s.top + h * 0.45;
if (spriteVisibleInCol(s, col, zbuf, warpIdCol, warpDistCol)) { seen = true; break; } const rim: [number, number][] = [
} [0.02, 0.06], [0.5, 0.0], [0.98, 0.08], [1.0, 0.5],
if (seen) { [0.96, 0.94], [0.5, 1.0], [0.04, 0.92], [0.0, 0.5],
const w = s.right - s.left, h = s.bottom - s.top; ].map(([fx, fy]) => [s.left + fx * w, s.top + fy * h]);
ctx.save(); const web = () => {
if (s.webbed) { ctx.beginPath();
ctx.strokeStyle = "rgba(240,240,235,0.55)"; for (const [rx, ry] of rim) {
ctx.lineWidth = 1; ctx.moveTo(hubX, hubY);
ctx.beginPath(); ctx.lineTo(rx, ry);
for (const [x1, y1, x2, y2] of [ }
[0.05, 0.2, 0.95, 0.75], [0.1, 0.7, 0.9, 0.15], for (const t of [0.45, 0.75]) {
[0.0, 0.45, 1.0, 0.5], [0.35, 0.05, 0.55, 0.95], [0.75, 0.1, 0.6, 0.9], for (let i = 0; i <= rim.length; i++) {
] as const) { const [rx, ry] = rim[i % rim.length]!;
ctx.moveTo(s.left + x1 * w, s.top + y1 * h); const px = hubX + (rx - hubX) * t, py = hubY + (ry - hubY) * t + h * 0.02;
ctx.lineTo(s.left + x2 * w, s.top + y2 * h); if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
} }
ctx.stroke();
} }
if (s.dread) { ctx.stroke();
const pulse = 0.45 + 0.25 * Math.sin(time / 300); };
ctx.strokeStyle = `rgba(160,30,30,${pulse})`; ctx.save();
ctx.fillStyle = "rgba(160,30,30,0.12)"; ctx.strokeStyle = "rgba(25,25,30,0.45)";
ctx.lineWidth = 2; ctx.lineWidth = 2.5;
ctx.beginPath(); web();
ctx.ellipse((s.left + s.right) / 2, Math.min(H - 2, s.bottom), ctx.strokeStyle = "rgba(245,245,240,0.8)";
Math.max(9, w * 0.55), Math.max(4, w * 0.17), 0, 0, Math.PI * 2); ctx.lineWidth = 1;
ctx.fill(); web();
ctx.stroke(); ctx.restore();
}
ctx.restore();
}
} }
} }