The cockpit learns to aim: marked beings, verdict-bearing crosshair

While a targeted card is armed, wizards and creatures standing on
castable ground wear a pulsing ember ring at their feet — drawn behind
the body, only when the walls actually show it, and only when the card
could land on a being (creations still light floor alone). The hover
cue now carries a verdict: gold outline and bright name for what the
cast can reach, cold slate for what stands beyond it. The workshop
poses it all: ?aim=1 arms a pretend cast and ?rival=x,y stands the
Rival wherever the shot needs them.

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 11:41:27 -04:00
co-authored by Claude Fable 5
parent afa0e17483
commit b996c2bc93
4 changed files with 64 additions and 7 deletions
+44 -3
View File
@@ -24,6 +24,7 @@
rubble = [],
ontarget,
litCells = null,
aimBeings = false,
edgeSelect = false,
}: {
view: GameView;
@@ -46,6 +47,9 @@
/** Squares a selected cell-target card may aim at: the pane dims the
* ineligible ground exactly as the board dims its squares. */
litCells?: Set<string> | null;
/** The armed card could land on a wizard or creature: beings on
* castable ground wear the ember ring. Creations light floor only. */
aimBeings?: boolean;
/** An edge-target card is raised: every wall and door face glows
* faintly, the pane's answer to the board's edge handles. */
edgeSelect?: boolean;
@@ -454,6 +458,32 @@
: 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 &&
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();
}
}
// Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies.
if (s.glow) ctx.globalCompositeOperation = "lighter";
@@ -594,9 +624,20 @@
if (!f || !mouse) return;
const found = resolveHover(mouse.x, mouse.y);
if (!found) return;
// The cue carries a verdict when a targeted card is armed: gold for
// ground the cast can reach, cold slate for what stands beyond it.
const t = found.target;
const tCell = t.kind === "cell" || t.kind === "edge"
? t.cell
: t.kind === "player"
? view.players.find((p) => p.id === t.id)?.position
: view.creatures.find((c) => c.id === t.id)?.position;
const eligible = litCells == null ||
(t.kind === "edge" && edgeSelect) ||
(tCell != null && litCells.has(`${tCell.x},${tCell.y}`));
ctx.save();
ctx.strokeStyle = "#c9a72a";
ctx.fillStyle = "rgba(201,167,42,0.14)";
ctx.strokeStyle = eligible ? "#c9a72a" : "rgba(150,150,155,0.55)";
ctx.fillStyle = eligible ? "rgba(201,167,42,0.14)" : "rgba(120,120,125,0.10)";
ctx.lineWidth = 1.5;
let label = "";
if (found.shape.kind === "rect") {
@@ -643,7 +684,7 @@
const ly = Math.max(16, mouse.y - 8);
ctx.fillStyle = "rgba(13,12,18,0.85)";
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
ctx.fillStyle = "#e9e1cb";
ctx.fillStyle = eligible ? "#e9e1cb" : "#8a8a90";
ctx.fillText(label, lx, ly);
}
ctx.restore();