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