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:
co-authored by
Claude Fable 5
parent
afa0e17483
commit
b996c2bc93
@@ -2368,6 +2368,7 @@
|
||||
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
|
||||
onstride={(side) => tryMove(side)} canStride={yourMoment}
|
||||
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"
|
||||
? { type: "pickUpTreasure", treasureId: w.id }
|
||||
: { type: "pickUpObject", instanceId: w.id })}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { onDestroy, untrack } from "svelte";
|
||||
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;
|
||||
/** The latest live event batch, numbered so each plays once, with
|
||||
* the view the server sent alongside it. */
|
||||
@@ -34,6 +34,7 @@
|
||||
onfacing?: ((side: Side) => void) | null;
|
||||
/** Cell-card eligibility, shared with the board's dimming aid. */
|
||||
litCells?: Set<string> | null;
|
||||
aimBeings?: boolean;
|
||||
/** An edge-target card is raised: wall faces offer themselves. */
|
||||
edgeSelect?: boolean;
|
||||
/** The at-your-feet tray's grab: the pane cannot look down, so what
|
||||
@@ -470,7 +471,7 @@
|
||||
<FirstPerson {view} povId={cutawayShot ? "" : (possessedId ?? povId)}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={960} height={400}
|
||||
fx={fpFx} posOverride={actorPos}
|
||||
ontarget={handleTarget} {litCells} {edgeSelect} />
|
||||
ontarget={handleTarget} {litCells} {aimBeings} {edgeSelect} />
|
||||
<button class="drive" onclick={() => manualTurn(1)} aria-label="turn right">›</button>
|
||||
</div>
|
||||
<div class="bezel-bottom">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// eyes, free-fly controls. No rules run here — the camera walks where
|
||||
// walls allow and the maze is exactly what viewFor would send that
|
||||
// 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 FirstPerson from "./FirstPerson.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! } });
|
||||
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;
|
||||
|
||||
// ?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
|
||||
<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 👁).
|
||||
<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>
|
||||
<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}">
|
||||
{#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" />
|
||||
|
||||
Reference in New Issue
Block a user