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
+1
View File
@@ -2368,6 +2368,7 @@
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)} <LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
onstride={(side) => tryMove(side)} canStride={yourMoment} onstride={(side) => tryMove(side)} canStride={yourMoment}
ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells} ontarget={fpvTarget} onfacing={(s) => (fpvFacing = s)} {litCells}
aimBeings={selectedCard != null && !CELL_CARDS.has(selectedCard.cardId) && !EDGE_CARDS.has(selectedCard.cardId)}
onpickup={(w) => dispatch(w.kind === "treasure" onpickup={(w) => dispatch(w.kind === "treasure"
? { type: "pickUpTreasure", treasureId: w.id } ? { type: "pickUpTreasure", treasureId: w.id }
: { type: "pickUpObject", instanceId: w.id })} : { type: "pickUpObject", instanceId: w.id })}
+3 -2
View File
@@ -17,7 +17,7 @@
import { onDestroy, untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
import type { GameEvent, GameView, Side } from "@wizwar/engine"; import type { GameEvent, GameView, Side } from "@wizwar/engine";
let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: { let { view, batch, onhide, onstride = null, canStride = false, ontarget = null, onfacing = null, litCells = null, aimBeings = false, edgeSelect = false, onpickup = null, onCreatureMove = null, onCreatureAttack = null, onbody = null, onWarpStep = null }: {
view: GameView; view: GameView;
/** The latest live event batch, numbered so each plays once, with /** The latest live event batch, numbered so each plays once, with
* the view the server sent alongside it. */ * the view the server sent alongside it. */
@@ -34,6 +34,7 @@
onfacing?: ((side: Side) => void) | null; onfacing?: ((side: Side) => void) | null;
/** Cell-card eligibility, shared with the board's dimming aid. */ /** Cell-card eligibility, shared with the board's dimming aid. */
litCells?: Set<string> | null; litCells?: Set<string> | null;
aimBeings?: boolean;
/** An edge-target card is raised: wall faces offer themselves. */ /** An edge-target card is raised: wall faces offer themselves. */
edgeSelect?: boolean; edgeSelect?: boolean;
/** The at-your-feet tray's grab: the pane cannot look down, so what /** The at-your-feet tray's grab: the pane cannot look down, so what
@@ -470,7 +471,7 @@
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)} <FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400} x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
fx={fpFx} posOverride={actorPos} fx={fpFx} posOverride={actorPos}
ontarget={handleTarget} {litCells} {edgeSelect} /> ontarget={handleTarget} {litCells} {aimBeings} {edgeSelect} />
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button> <button class="drive" onclick={() => manualTurn(1)} aria-label="turn right"></button>
</div> </div>
<div class="bezel-bottom"> <div class="bezel-bottom">
+44 -3
View File
@@ -24,6 +24,7 @@
rubble = [], rubble = [],
ontarget, ontarget,
litCells = null, litCells = null,
aimBeings = false,
edgeSelect = false, edgeSelect = false,
}: { }: {
view: GameView; view: GameView;
@@ -46,6 +47,9 @@
/** Squares a selected cell-target card may aim at: the pane dims the /** Squares a selected cell-target card may aim at: the pane dims the
* ineligible ground exactly as the board dims its squares. */ * ineligible ground exactly as the board dims its squares. */
litCells?: Set<string> | null; 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 /** An edge-target card is raised: every wall and door face glows
* faintly, the pane's answer to the board's edge handles. */ * faintly, the pane's answer to the board's edge handles. */
edgeSelect?: boolean; edgeSelect?: boolean;
@@ -454,6 +458,32 @@
: 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
// 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 // Painted art composites normally (inks stay true); light glows
// additively. Either way translucency applies. // additively. Either way translucency applies.
if (s.glow) ctx.globalCompositeOperation = "lighter"; if (s.glow) ctx.globalCompositeOperation = "lighter";
@@ -594,9 +624,20 @@
if (!f || !mouse) return; if (!f || !mouse) return;
const found = resolveHover(mouse.x, mouse.y); const found = resolveHover(mouse.x, mouse.y);
if (!found) return; 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.save();
ctx.strokeStyle = "#c9a72a"; ctx.strokeStyle = eligible ? "#c9a72a" : "rgba(150,150,155,0.55)";
ctx.fillStyle = "rgba(201,167,42,0.14)"; ctx.fillStyle = eligible ? "rgba(201,167,42,0.14)" : "rgba(120,120,125,0.10)";
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
let label = ""; let label = "";
if (found.shape.kind === "rect") { if (found.shape.kind === "rect") {
@@ -643,7 +684,7 @@
const ly = Math.max(16, mouse.y - 8); const ly = Math.max(16, mouse.y - 8);
ctx.fillStyle = "rgba(13,12,18,0.85)"; ctx.fillStyle = "rgba(13,12,18,0.85)";
ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16); ctx.fillRect(lx - 4, ly - 12, wTxt + 8, 16);
ctx.fillStyle = "#e9e1cb"; ctx.fillStyle = eligible ? "#e9e1cb" : "#8a8a90";
ctx.fillText(label, lx, ly); ctx.fillText(label, lx, ly);
} }
ctx.restore(); ctx.restore();
+16 -2
View File
@@ -3,7 +3,7 @@
// eyes, free-fly controls. No rules run here — the camera walks where // eyes, free-fly controls. No rules run here — the camera walks where
// walls allow and the maze is exactly what viewFor would send that // walls allow and the maze is exactly what viewFor would send that
// player at the table, beliefs and all. // player at the table, beliefs and all.
import { createGame, viewFor, cellKey, applyCommand, automatonCommand, automatonFallback, redactEvent } from "@wizwar/engine"; import { createGame, viewFor, cellKey, applyCommand, automatonCommand, automatonFallback, redactEvent, sightedCellsFor } from "@wizwar/engine";
import type { GameEvent, GameState, GameView } from "@wizwar/engine"; import type { GameEvent, GameState, GameView } from "@wizwar/engine";
import FirstPerson from "./FirstPerson.svelte"; import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte"; import Replay from "../Replay.svelte";
@@ -53,6 +53,17 @@
if (kind === "dimwarp") view.dimWarps.push({ a: { x: tx!, y: ty! }, b: { x: tx!, y: ty! } }); if (kind === "dimwarp") view.dimWarps.push({ a: { x: tx!, y: ty! }, b: { x: tx!, y: ty! } });
else view.squareContents[`${tx},${ty}`] = { kind } as (typeof view.squareContents)[string]; else view.squareContents[`${tx},${ty}`] = { kind } as (typeof view.squareContents)[string];
} }
// ?rival=x,y stands the Rival at a chosen square; ?aim=1 arms a pretend
// targeted cast — sighted ground lights, beings on castable ground wear
// the ember ring, and the hover cue carries its castable/beyond verdict.
const rivalAt = q.get("rival");
if (rivalAt) {
const [rx, ry] = rivalAt.split(",").map(Number);
const rival = view.players.find((p) => p.id === "Rival");
if (rival && Number.isFinite(rx) && Number.isFinite(ry)) rival.position = { x: rx!, y: ry! };
}
const aimCells = q.has("aim") ? sightedCellsFor(view) : null;
const start = view.players.find((p) => p.id === povId)!.position; const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere. // ?x=&y=&dir= override the spawn — for standing the camera anywhere.
@@ -211,9 +222,12 @@
stranger things do not — this workshop has eyes, not rules. Seed with stranger things do not — this workshop has eyes, not rules. Seed with
<code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>, <code>?fpv&seed=N</code>, stand anywhere with <code>&x=&y=&dir=</code>,
or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁). or watch the clockwork's reel with <code>&demo=1</code> (toggle 👁).
<code>&aim=1</code> arms a pretend cast — sighted ground lights, marked
beings wear the ember ring — and <code>&rival=x,y</code> poses the Rival.
</p> </p>
<div class="stage"> <div class="stage">
<FirstPerson {view} {povId} {x} {y} {facing} /> <FirstPerson {view} {povId} {x} {y} {facing} litCells={aimCells} aimBeings={!!aimCells}
ontarget={aimCells ? () => {} : undefined} />
<svg class="minimap" viewBox="0 0 {view.board.width * MM} {view.board.height * MM}"> <svg class="minimap" viewBox="0 0 {view.board.width * MM} {view.board.height * MM}">
{#each cells as c (cellKey({ x: c.cx, y: c.cy }))} {#each cells as c (cellKey({ x: c.cx, y: c.cy }))}
<rect x={c.cx * MM} y={c.cy * MM} width={MM} height={MM} class="mm-cell" /> <rect x={c.cx * MM} y={c.cy * MM} width={MM} height={MM} class="mm-cell" />