diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index b61cc71..72b5993 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -2368,6 +2368,7 @@ 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 })} diff --git a/packages/web/src/LiveFirstPerson.svelte b/packages/web/src/LiveFirstPerson.svelte index 0138086..c3c5a8f 100644 --- a/packages/web/src/LiveFirstPerson.svelte +++ b/packages/web/src/LiveFirstPerson.svelte @@ -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 | 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 @@ + ontarget={handleTarget} {litCells} {aimBeings} {edgeSelect} />
diff --git a/packages/web/src/fpv/FirstPerson.svelte b/packages/web/src/fpv/FirstPerson.svelte index 4721ddd..08b046b 100644 --- a/packages/web/src/fpv/FirstPerson.svelte +++ b/packages/web/src/fpv/FirstPerson.svelte @@ -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 | 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(); diff --git a/packages/web/src/fpv/FpvWorkshop.svelte b/packages/web/src/fpv/FpvWorkshop.svelte index 56e7c0a..5ef781f 100644 --- a/packages/web/src/fpv/FpvWorkshop.svelte +++ b/packages/web/src/fpv/FpvWorkshop.svelte @@ -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 ?fpv&seed=N, stand anywhere with &x=&y=&dir=, or watch the clockwork's reel with &demo=1 (toggle 👁). + &aim=1 arms a pretend cast — sighted ground lights, marked + beings wear the ember ring — and &rival=x,y poses the Rival.

- + {} : undefined} /> {#each cells as c (cellKey({ x: c.cx, y: c.cy }))}