The reel through your own eyes: first-person replay (stage two)
Sight now runs THROUGH the warps as the rules say: a ray reaching a mouth re-enters at its pair and marches on, the far side swimming in violet haze. The floor's furniture joins the scene — bushes stand tall, safes squat, slime and tacks and pits lie low, dropped daggers glint. And the replay reel gains a 👁 toggle: relive the catch-up or the whole tale through your wizard's eyes, the camera turning before it strides, easing between cells, cutting on teleports, and turning toward whoever acted while you stood still. The /?fpv workshop grows &demo=1 — two automatons play a stretch and the real Replay component reruns it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
dae01969b0
commit
65092f50af
@@ -89,6 +89,11 @@
|
||||
const swirl = 0.75 + 0.25 * Math.sin(time / 240 + hit.u * 9);
|
||||
[cr, cg, cb] = [cr * swirl, cg * swirl, cb * (0.9 + 0.3 * swirl)];
|
||||
}
|
||||
if (hit.warped) {
|
||||
// Seen through a warp: the far side swims in violet haze.
|
||||
const haze = 0.82 + 0.08 * Math.sin(time / 300 + col * 0.05);
|
||||
[cr, cg, cb] = [cr * 0.7 * haze, cg * 0.6 * haze, Math.min(255, cb * 1.1 + 26)];
|
||||
}
|
||||
ctx.fillStyle = `rgb(${cr | 0},${cg | 0},${cb | 0})`;
|
||||
ctx.fillRect(col, half - wallH / 2, 1, wallH);
|
||||
// Doors wear a lintel and panel seam so they read as doors.
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
// 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 } from "@wizwar/engine";
|
||||
import FirstPerson from "./FirstPerson.svelte";
|
||||
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 } 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,
|
||||
@@ -64,6 +69,34 @@
|
||||
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) => {
|
||||
@@ -81,12 +114,17 @@
|
||||
|
||||
<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 walk and turn. 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>.
|
||||
<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} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { cellKey, edgeKey, type Cell, type Side } from "@wizwar/engine";
|
||||
import type { GameView } from "@wizwar/engine";
|
||||
import { objectArt, TERRAIN_ART } from "../art";
|
||||
|
||||
export interface Hit {
|
||||
/** Distance along the ray (perpendicular-corrected by the caller). */
|
||||
@@ -17,8 +18,13 @@ export interface Hit {
|
||||
u: number;
|
||||
/** Vertical faces get a different shade than horizontal ones. */
|
||||
axis: "x" | "y";
|
||||
/** The eye reached this through a warp: haze it other-worldly. */
|
||||
warped?: boolean;
|
||||
}
|
||||
|
||||
const SIDE_ANGLE: Record<Side, number> = { E: 0, S: Math.PI / 2, W: Math.PI, N: -Math.PI / 2 };
|
||||
const OPPOSITE: Record<Side, Side> = { E: "W", W: "E", N: "S", S: "N" };
|
||||
|
||||
/** Is this edge passable to the EYE (rays), and if not, what is it? */
|
||||
function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
||||
const e = view.board.edges[key] ?? "open";
|
||||
@@ -38,57 +44,76 @@ function edgeObstacle(view: GameView, key: string): Hit["kind"] | null {
|
||||
* is a warp mouth (then a shimmering opening).
|
||||
*/
|
||||
export function castRay(view: GameView, ox: number, oy: number, angle: number): Hit {
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
let cx = Math.floor(ox);
|
||||
let cy = Math.floor(oy);
|
||||
const stepX = dx > 0 ? 1 : -1;
|
||||
const stepY = dy > 0 ? 1 : -1;
|
||||
// Distance along the ray between successive x / y grid lines.
|
||||
const dDistX = Math.abs(1 / (dx || 1e-9));
|
||||
const dDistY = Math.abs(1 / (dy || 1e-9));
|
||||
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
|
||||
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
|
||||
// Sight runs THROUGH warps as the rules say it does: a ray that reaches
|
||||
// a mouth re-enters at the paired mouth and marches on, its hits hazed.
|
||||
let baseDist = 0;
|
||||
let warped = false;
|
||||
for (let traversal = 0; traversal < 3; traversal++) {
|
||||
const dx = Math.cos(angle);
|
||||
const dy = Math.sin(angle);
|
||||
let cx = Math.floor(ox);
|
||||
let cy = Math.floor(oy);
|
||||
const stepX = dx > 0 ? 1 : -1;
|
||||
const stepY = dy > 0 ? 1 : -1;
|
||||
// Distance along the ray between successive x / y grid lines.
|
||||
const dDistX = Math.abs(1 / (dx || 1e-9));
|
||||
const dDistY = Math.abs(1 / (dy || 1e-9));
|
||||
let sideDistX = (dx > 0 ? cx + 1 - ox : ox - cx) * dDistX;
|
||||
let sideDistY = (dy > 0 ? cy + 1 - oy : oy - cy) * dDistY;
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const crossingX = sideDistX < sideDistY;
|
||||
const dist = crossingX ? sideDistX : sideDistY;
|
||||
// The edge being crossed lives between the current cell and the next.
|
||||
let key: string;
|
||||
let nx = cx, ny = cy;
|
||||
if (crossingX) {
|
||||
key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E");
|
||||
nx = cx + stepX;
|
||||
sideDistX += dDistX;
|
||||
} else {
|
||||
key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S");
|
||||
ny = cy + stepY;
|
||||
sideDistY += dDistY;
|
||||
}
|
||||
const axis: Hit["axis"] = crossingX ? "x" : "y";
|
||||
const u = crossingX
|
||||
? (oy + dist * dy) % 1
|
||||
: (ox + dist * dx) % 1;
|
||||
const texU = u < 0 ? u + 1 : u;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const crossingX = sideDistX < sideDistY;
|
||||
const dist = crossingX ? sideDistX : sideDistY;
|
||||
// The edge being crossed lives between the current cell and the next.
|
||||
let key: string;
|
||||
let nx = cx, ny = cy;
|
||||
if (crossingX) {
|
||||
key = edgeKey({ x: stepX > 0 ? cx : cx - 1, y: cy }, "E");
|
||||
nx = cx + stepX;
|
||||
sideDistX += dDistX;
|
||||
} else {
|
||||
key = edgeKey({ x: cx, y: stepY > 0 ? cy : cy - 1 }, "S");
|
||||
ny = cy + stepY;
|
||||
sideDistY += dDistY;
|
||||
}
|
||||
const axis: Hit["axis"] = crossingX ? "x" : "y";
|
||||
const u = crossingX ? (oy + dist * dy) % 1 : (ox + dist * dx) % 1;
|
||||
const texU = u < 0 ? u + 1 : u;
|
||||
|
||||
const blocked = edgeObstacle(view, key);
|
||||
if (blocked) return { dist, kind: blocked, u: texU, axis };
|
||||
const blocked = edgeObstacle(view, key);
|
||||
if (blocked) return { dist: baseDist + dist, kind: blocked, u: texU, axis, warped };
|
||||
|
||||
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
||||
if (offBoard) {
|
||||
const mouth = view.board.warps.some(
|
||||
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) &&
|
||||
w.from.side === (crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N")),
|
||||
);
|
||||
return { dist, kind: mouth ? "warp" : "rim", u: texU, axis };
|
||||
const offBoard = !view.board.cells[cellKey({ x: nx, y: ny })];
|
||||
if (offBoard) {
|
||||
const side: Side = crossingX ? (stepX > 0 ? "E" : "W") : (stepY > 0 ? "S" : "N");
|
||||
const warp = view.board.warps.find(
|
||||
(w) => cellKey(w.from.cell) === cellKey({ x: cx, y: cy }) && w.from.side === side,
|
||||
);
|
||||
if (!warp) return { dist: baseDist + dist, kind: "rim", u: texU, axis, warped };
|
||||
// Step through: re-enter at the paired mouth, heading inward, the
|
||||
// offset along the edge preserved (a wraparound keeps its lane).
|
||||
const exitHeading = SIDE_ANGLE[OPPOSITE[warp.to.side]];
|
||||
angle = angle + (exitHeading - SIDE_ANGLE[warp.from.side]);
|
||||
const c = warp.to.cell;
|
||||
const along = texU;
|
||||
if (warp.to.side === "E") { ox = c.x + 1 - 1e-4; oy = c.y + along; }
|
||||
else if (warp.to.side === "W") { ox = c.x + 1e-4; oy = c.y + along; }
|
||||
else if (warp.to.side === "S") { ox = c.x + along; oy = c.y + 1 - 1e-4; }
|
||||
else { ox = c.x + along; oy = c.y + 1e-4; }
|
||||
baseDist += dist;
|
||||
warped = true;
|
||||
i = 64; // restart the DDA from the far mouth
|
||||
break;
|
||||
}
|
||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||
return { dist: baseDist + dist, kind: "stone", u: texU, axis, warped };
|
||||
}
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
}
|
||||
if (view.squareContents[cellKey({ x: nx, y: ny })]?.kind === "stone") {
|
||||
return { dist, kind: "stone", u: texU, axis };
|
||||
}
|
||||
cx = nx;
|
||||
cy = ny;
|
||||
if (!warped || traversal === 2) break;
|
||||
}
|
||||
return { dist: 64, kind: "rim", u: 0, axis: "x" };
|
||||
return { dist: baseDist + 64, kind: "rim", u: 0, axis: "x", warped };
|
||||
}
|
||||
|
||||
/** Can a wizard's body (not just their eye) cross this edge? Workshop
|
||||
@@ -149,5 +174,25 @@ export function billboards(
|
||||
scale: 0.4, rise: 0, label: "treasure",
|
||||
});
|
||||
}
|
||||
// The floor's furniture: bushes stand tall, hazards squat low. Stone is
|
||||
// a wall to the rays and needs no sprite.
|
||||
const TERRAIN_SCALE: Record<string, number> = {
|
||||
thornbush: 0.7, rosebush: 0.7, safe: 0.55, ooze: 0.35, slime: 0.3,
|
||||
tacks: 0.25, pit: 0.35, dust: 0.6,
|
||||
};
|
||||
for (const [k, content] of Object.entries(view.squareContents)) {
|
||||
if (content.kind === "stone") continue;
|
||||
const file = TERRAIN_ART[content.kind];
|
||||
const scale = TERRAIN_SCALE[content.kind];
|
||||
if (!file || !scale) continue;
|
||||
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
||||
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "terrain"), scale, rise: 0, label: content.kind });
|
||||
}
|
||||
for (const [k, cards] of Object.entries(view.groundObjects)) {
|
||||
const file = cards[0] ? objectArt(cards[0].cardId) : null;
|
||||
if (!file) continue;
|
||||
const [tx, ty] = k.split(",").map(Number) as [number, number];
|
||||
out.push({ x: tx + 0.5, y: ty + 0.5, src: art(file, "objects"), scale: 0.25, rise: 0, label: cards[0]!.cardId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user