Files
wizwar6e/packages/web/src/fpv/FpvWorkshop.svelte
T
Eric WagonerandClaude Fable 5 5fc5805354 Odds, ends, and every missing effect: doorways dressed, victory earns its fireworks
The audit Eric called for, delivered whole. Open doors are DOORWAYS
now: stone jamb posts at both ends and a lintel hung across the top
(paintable as textures/doorframe.png), the way through open to the eye.
Battle damage shows — the engine's per-edge wallDamage wears painted
cracks into any wall, heavier as harm mounts. The wall safe stands as
a strongbox volume instead of a flat token. Hedges take root: an
underbrush floor decal covers the whole square beneath them (Eric's
report — a billboard at center depth can't reach the floor trapezoid's
near edge), and the hedge itself widens past the cell. Rubble
stretches across the fallen wall's full gap, corner piles landing at
the posts, exactly as its art was painted.

The first-person effect gaps close: victory now throws SIX fireworks
climbing over the winner's home with gold washing the screen; sector
rotations flash violet and heave the whole world; the Thumb of God
lands with a burst and a shudder; walls conjured from nothing rise in
stone dust; and every hazard pratfall plays — pit falls (with a dark
drop and jolt for the faller's own eyes), ooze slips, tack yelps,
thorn snaps, slime, dust. Dying in first person fades long and dark.

And the whole tale travels: a finished game's full replay carries the
share button too — turn -1 mints a link to every turn of the game,
each through its wizard's own eyes, titled for the winner's triumph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:32:42 -04:00

245 lines
11 KiB
Svelte

<script lang="ts">
// The first-person workshop (/?fpv): a real dealt game, one wizard's
// 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 type { GameEvent, GameState, GameView } from "@wizwar/engine";
import FirstPerson from "./FirstPerson.svelte";
import Replay from "../Replay.svelte";
import { canWalk, edgeMid, SIDE_ANGLE, OPPOSITE } from "./raycast";
const q = new URLSearchParams(location.search);
const seed = Number(q.get("seed") ?? 42);
/** ?demo=1: two automatons play a stretch, then the reel replays it —
* the real Replay component, toggleable into first person. */
const demoReel = q.has("demo");
const { state: dealt } = createGame({
playerIds: ["Wanderer", "Rival", "Stranger"],
seed,
sets: ["basic", "expansion1"],
});
const povId = "Wanderer";
const view = viewFor(dealt, povId);
// ?illusion=H:2,8 (";"-separated): stand up known illusions for the
// renderer to rehearse — the edge opens, the ghost remains.
for (const k of (q.get("illusion") ?? "").split(";").filter(Boolean)) {
view.knownIllusionEdges.push(k);
delete view.board.edges[k];
}
// ?open=V:2,7 props doors open; ?crack=V:2,7@3 wears damage into walls.
for (const k of (q.get("open") ?? "").split(";").filter(Boolean)) {
view.openDoorEdges.push(k);
}
for (const spec of (q.get("crack") ?? "").split(";").filter(Boolean)) {
const [k, n] = spec.split("@") as [string, string?];
view.wallDamage[k] = Number(n ?? 1) || 1;
}
// ?terrain=pit@3,7;thornbush@4,7 — furnish squares for the renderer
// (and the artist) to rehearse. dimwarp@x,y opens a floor mouth.
for (const spec of (q.get("terrain") ?? "").split(";").filter(Boolean)) {
const [kind, at] = spec.split("@") as [string, string?];
const [tx, ty] = (at ?? "").split(",").map(Number);
if (!Number.isFinite(tx) || !Number.isFinite(ty)) continue;
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];
}
const start = view.players.find((p) => p.id === povId)!.position;
// ?x=&y=&dir= override the spawn — for standing the camera anywhere.
const DIR_ANGLE: Record<string, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
let x = $state((q.has("x") ? Number(q.get("x")) : start.x) + 0.5);
let y = $state((q.has("y") ? Number(q.get("y")) : start.y) + 0.5);
let facing = $state(DIR_ANGLE[q.get("dir") ?? ""] ?? 0);
// Dungeon-crawler locomotion: the maze is walked cell by cell, so the
// camera moves the same way — a tap glides one square, a turn swings a
// clean quarter. Held keys queue the next move as each one settles.
const held = new Set<string>();
function onKey(e: KeyboardEvent, down: boolean) {
const k = e.key.toLowerCase();
if (["arrowup", "arrowdown", "arrowleft", "arrowright", "w", "a", "s", "d"].includes(k)) {
e.preventDefault();
if (down) held.add(k); else held.delete(k);
}
}
const SIDES4 = ["E", "S", "W", "N"] as const;
type Anim =
| { kind: "glide"; fx: number; fy: number; tx: number; ty: number; t0: number; dur: number }
| { kind: "turn"; from: number; to: number; t0: number; dur: number }
| { kind: "warpglide"; fx: number; fy: number; mx: number; my: number;
nx: number; ny: number; tx: number; ty: number; dturn: number;
turned: boolean; t0: number; dur: number }
| null;
let anim: Anim = null;
const ease = (w: number) => w * w * (3 - 2 * w);
function startNext(now: number) {
const turn = (held.has("arrowleft") || held.has("a") ? -1 : 0) +
(held.has("arrowright") || held.has("d") ? 1 : 0);
if (turn !== 0) {
anim = { kind: "turn", from: facing, to: facing + turn * (Math.PI / 2), t0: now, dur: 210 };
return;
}
const fwd = (held.has("arrowup") || held.has("w") ? 1 : 0) +
(held.has("arrowdown") || held.has("s") ? -1 : 0);
if (fwd === 0) return;
// The stride goes along the nearest cardinal (backpedal reverses it).
const q = Math.round(facing / (Math.PI / 2));
const idx = ((q % 4) + 4 + (fwd < 0 ? 2 : 0)) % 4;
const side = SIDES4[idx]!;
const cell = { x: Math.floor(x), y: Math.floor(y) };
if (!canWalk(view, cell, side)) return;
const to = {
x: cell.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
y: cell.y + (side === "S" ? 1 : side === "N" ? -1 : 0),
};
// An open rim edge canWalk allowed is a warp mouth: stride into it,
// and the stride carries on out of the paired mouth, the body turned
// exactly as the rays turn.
const warp = !view.board.cells[cellKey(to)]
? view.board.warps.find((w) => cellKey(w.from.cell) === cellKey(cell) && w.from.side === side)
: undefined;
if (warp) {
const mouth = edgeMid(cell, side);
const far = edgeMid(warp.to.cell, warp.to.side);
anim = {
kind: "warpglide", fx: x, fy: y, mx: mouth.x, my: mouth.y,
nx: far.x, ny: far.y, tx: warp.to.cell.x + 0.5, ty: warp.to.cell.y + 0.5,
dturn: SIDE_ANGLE[OPPOSITE[warp.to.side]] - SIDE_ANGLE[warp.from.side],
turned: false, t0: now, dur: 320,
};
return;
}
const tx = x + (side === "E" ? 1 : side === "W" ? -1 : 0);
const ty = y + (side === "S" ? 1 : side === "N" ? -1 : 0);
anim = { kind: "glide", fx: x, fy: y, tx, ty, t0: now, dur: 280 };
}
$effect(() => {
let raf = 0;
const tick = (now: number) => {
if (!anim) startNext(now);
if (anim) {
const w = Math.min(1, (now - anim.t0) / anim.dur);
if (anim.kind === "glide") {
x = anim.fx + (anim.tx - anim.fx) * ease(w);
y = anim.fy + (anim.ty - anim.fy) * ease(w);
} else if (anim.kind === "warpglide") {
// Half the stride reaches the mouth; the moment it crosses, the
// eye stands at the far mouth, turned, finishing the stride.
const e = ease(w);
if (e < 0.5) {
x = anim.fx + (anim.mx - anim.fx) * (e * 2);
y = anim.fy + (anim.my - anim.fy) * (e * 2);
} else {
if (!anim.turned) { facing += anim.dturn; anim.turned = true; }
x = anim.nx + (anim.tx - anim.nx) * ((e - 0.5) * 2);
y = anim.ny + (anim.ty - anim.ny) * ((e - 0.5) * 2);
}
} else {
facing = anim.from + (anim.to - anim.from) * ease(w);
}
if (w >= 1) anim = null;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
});
// The demo reel: drive the clockwork for a while, keeping a step per
// command exactly as the server's catch-up would build it.
type Step = { seq: number; actor: string; events: GameEvent[]; view: GameView };
function buildDemoSteps(): Step[] {
let s: GameState = dealt;
const actingSeat = (g: GameState) =>
g.stack?.waitingOn ?? g.pendingDiscard ?? g.chaosPending?.queue[0] ??
g.outOfTurnWindow?.playerId ?? g.players[g.turn.activeIndex]!.id;
const steps: Step[] = [];
for (let i = 0; i < 80 && s.phase === "playing"; i++) {
const seat = actingSeat(s);
const cmd = automatonCommand(viewFor(s, seat), "hunter", "adept")
?? automatonFallback(viewFor(s, seat), "adept");
let r = applyCommand(s, seat, cmd);
if (!r.ok) r = applyCommand(s, seat, automatonFallback(viewFor(s, seat), "adept"));
if (!r.ok) r = applyCommand(s, seat, { type: "endTurn", draw: 0 });
if (!r.ok) break;
s = r.state;
steps.push({
seq: i, actor: seat,
events: r.events.map((e) => redactEvent(e, povId)).filter((e): e is GameEvent => e !== null),
view: viewFor(s, povId),
});
}
return steps;
}
const demoSteps = demoReel ? buildDemoSteps() : [];
// Minimap geometry (top-down, one small square per cell).
const MM = 9;
const cells = Object.keys(view.board.cells).map((k) => {
const [cx, cy] = k.split(",").map(Number) as [number, number];
return { cx, cy };
});
const mmWalls = Object.entries(view.board.edges)
.filter(([, e]) => e !== "open")
.map(([k]) => {
const [kind, coords] = k.split(":") as [string, string];
const [ex, ey] = coords.split(",").map(Number) as [number, number];
return { kind, ex, ey };
});
</script>
<svelte:window onkeydown={(e) => onKey(e, true)} onkeyup={(e) => onKey(e, false)} />
{#if demoReel}
<Replay steps={demoSteps} onclose={() => (location.search = "?fpv")} />
{/if}
<div class="fpv-shop">
<h1>The maze, through {povId}'s eyes</h1>
<p class="hint">
Arrows / WASD stride the maze square by square and swing in quarter
turns, as a wizard walks it. Walls and shut doors refuse you; fire and
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 👁).
</p>
<div class="stage">
<FirstPerson {view} {povId} {x} {y} {facing} />
<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" />
{/each}
{#each mmWalls as w (w.kind + w.ex + "," + w.ey)}
{#if w.kind === "V"}
<line x1={(w.ex + 1) * MM} y1={w.ey * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
{:else}
<line x1={w.ex * MM} y1={(w.ey + 1) * MM} x2={(w.ex + 1) * MM} y2={(w.ey + 1) * MM} class="mm-wall" />
{/if}
{/each}
<g transform={`translate(${x * MM} ${y * MM}) rotate(${(facing * 180) / Math.PI})`}>
<polygon points="6,0 -3,4 -3,-4" class="mm-eye" />
</g>
</svg>
</div>
</div>
<style>
.fpv-shop {
min-height: 100vh;
background: #171a20;
color: #d8d2c0;
font-family: "Archivo Narrow", system-ui, sans-serif;
padding: 1.2rem;
}
h1 { font-size: 1.2rem; margin: 0 0 0.3rem; }
.hint { color: #8d8672; margin: 0 0 0.8rem; }
.hint code { color: #b7ae94; }
.stage { display: flex; gap: 1rem; align-items: flex-start; flex-wrap: wrap; }
.stage > :global(.fpv-canvas) { flex: 1 1 480px; }
.minimap { width: 220px; background: #101318; border: 1px solid #3a3428; border-radius: 4px; }
.mm-cell { fill: #1d212b; stroke: #262b36; stroke-width: 0.5; }
.mm-wall { stroke: #9a927c; stroke-width: 1.6; stroke-linecap: square; }
.mm-eye { fill: #e0b34a; }
</style>