Three blind reviews over the first-person arc, every finding verified against the source. History-narrating comments made timeless or cut; the stacked BUDDY comment collapsed to one voice. Dead code out: the orphaned FACE copy, the DIR_ANGLE duplicate, the dead loop-counter poke, the impossible-state sentinel. The never-produced "warp" hit kind resolved the right way — warp mouths now hang a translucent veil of the painted warp texture, so art that never rendered finally does. Types tightened (SlabStrike named once, ShareData rides CatchUpStep, botTier loses its casts), the gallery reads MATERIALS instead of a hand-copied list, the chronicle resets through one helper, a superseded share mint rejects instead of stranding, and the conjured safe gains the growth key its siblings had. Tests lose a triple assignment, a tautology, and two self-swallowing regex alternatives. The sprite spec — which still told the artist to paint for additive compositing the renderer no longer uses — now describes the renderer that exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
253 lines
11 KiB
Svelte
253 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");
|
|
// ?players=2..6 sizes the table (5-player boards carry the corner
|
|
// warps whose traversals rotate the world a quarter-turn).
|
|
const count = Math.min(6, Math.max(2, Number(q.get("players") ?? 3)));
|
|
const { state: dealt } = createGame({
|
|
playerIds: ["Wanderer", "Rival", "Stranger", "Pilgrim", "Vagabond", "Drifter"].slice(0, count),
|
|
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;
|
|
}
|
|
// ?carry=Rival hands the first treasure to that wizard, for viewing
|
|
// what a laden thief looks like.
|
|
const carrier = q.get("carry");
|
|
if (carrier && view.treasures[0]) {
|
|
view.treasures[0] = { ...view.treasures[0], carriedBy: carrier, position: null };
|
|
}
|
|
// ?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.
|
|
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(SIDE_ANGLE[q.get("dir") as keyof typeof SIDE_ANGLE] ?? 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>
|