The camera always wears the turn-taker's eyes, and knows whose turn zero is

Eric's rule, applied everywhere: every reel — moment, catch-up, whole
tale — follows the wizard whose turn it is, the pov switching at each
handover as boundaries scroll past. And the J8LL mystery dies with it:
turn zero's opening boundary lives in the DEAL, before any command, so
scanning a moment's steps found the NEXT turn's start instead and put
the camera in the wrong wizard's head. momentSteps now names the owner
from the very boundary that opened the turn — counted through the deal
— and carries it to the client and the share page, where it also fixes
the same latent mis-titling.

Also aboard: the cutaway shot. When the eyes aim at something they
cannot see — a wraith summoned across the maze, a spell through walls —
the camera cuts to the target's cell for that beat, standing back down
its deepest corridor, and cuts home on the next step. The wraith no
longer materializes off-screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-23 18:56:02 -04:00
co-authored by Claude Fable 5
parent 6788de2f6e
commit a28d27eedc
6 changed files with 79 additions and 41 deletions
+3 -2
View File
@@ -1619,8 +1619,9 @@
</div>
{/if}
{#if net.moment && net.moment.length > 0}
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{#if net.moment && net.moment.steps.length > 0}
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
{:else if net.catchUp && net.catchUp.length > 0}
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
{:else if local.replaySteps && local.replaySteps.length > 0}
+39 -11
View File
@@ -14,6 +14,7 @@
steps,
onclose,
moment = false,
pov = null,
onshare = null,
endLabel = null,
}: {
@@ -22,6 +23,9 @@
/** An instant replay of one turn: open straight into first person,
* through the eyes of the wizard whose turn it is. */
moment?: boolean;
/** The turn-owner, when the server already knows it (moment reels):
* the fallback when no boundary has scrolled past yet. */
pov?: string | null;
/** Mint a public link to this turn; resolves to the URL. */
onshare?: (() => Promise<string>) | null;
/** What the leave button says once the reel has run out. */
@@ -49,18 +53,22 @@
/** Watch the board from above, or relive it through your own eyes. */
let fp = $state(moment);
/** Whose eyes the first-person camera wears: normally your own; in a
* moment reel, the turn-owner's — their position seen with YOUR
* knowledge of the maze, so nothing private leaks. */
/** Whose eyes the first-person camera wears: the wizard whose turn it
* is, ALWAYS — their position seen with the viewer's knowledge of the
* maze, so nothing private leaks. The owner is whoever the last turn
* boundary at or before the current step named; before any boundary
* scrolls past, the server-known owner (moment reels) or the viewer. */
const povId = $derived.by(() => {
if (!moment) return steps[0]!.view.you;
for (const s of steps) {
const started = s.events.find(
(e) => e.type === "turnStarted" || e.type === "extraTurnStarted",
);
if (started && "player" in started) return started.player as string;
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
const evs = steps[i]!.events;
for (let j = evs.length - 1; j >= 0; j--) {
const e = evs[j]!;
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
return e.player as string;
}
}
}
return steps[0]!.actor;
return pov ?? steps[0]!.view.you;
});
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
const lines = $derived(
@@ -246,7 +254,27 @@
// that phantom distance must not pick the opening shot's direction.
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
else if (aim) {
targetFacing = Math.atan2(aim.y + 0.5 - ty, aim.x + 0.5 - tx);
const ax = aim.x + 0.5, ay = aim.y + 0.5;
const toAim = Math.hypot(ax - tx, ay - ty);
const sight = castRay(v, tx, ty, Math.atan2(ay - ty, ax - tx));
if (sight.warped || sight.dist < toAim - 0.4) {
// The deed lands beyond these eyes — a summon across the maze, a
// spell through walls. Cut away to the spot like a broadcast
// camera: stand back down its deepest corridor, facing it, for
// this one beat; the next step cuts home to the wizard.
let best = { d: -1, a: 0 };
for (const a of [0, Math.PI / 2, Math.PI, -Math.PI / 2]) {
const h = castRay(v, ax, ay, a);
if (h.dist > best.d) best = { d: h.dist, a };
}
const back = Math.min(1.6, Math.max(0.35, best.d - 0.4));
cam.x = ax + Math.cos(best.a) * back;
cam.y = ay + Math.sin(best.a) * back;
cam.facing = best.a + Math.PI;
camReady = true;
return;
}
targetFacing = Math.atan2(ay - ty, ax - tx);
aimed = true;
} else if (actor && actor.id !== povId &&
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
+1 -1
View File
@@ -44,7 +44,7 @@
{:else}
<div class="share-stage">
{#key reelKey}
<Replay steps={data.steps} moment endLabel="⟲ watch it again"
<Replay steps={data.steps} moment pov={data.actor} endLabel="⟲ watch it again"
onclose={() => (reelKey += 1)} />
{/key}
</div>
+4 -3
View File
@@ -314,8 +314,9 @@ class Net {
onFx: ((events: GameEvent[]) => void) | null = null;
/** A catch-up reel delivered by the server. */
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** One turn's reel, summoned from a chronicle line's instant-replay eye. */
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
/** One turn's reel, summoned from a chronicle line's instant-replay
* eye steps plus the wizard whose eyes the camera wears. */
moment = $state<{ steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[]; owner: string } | null>(null);
/** Turns witnessed so far: counts the same boundary events the server
* counts, so a chronicle line can name the turn it belongs to. */
private turnCounter = -1;
@@ -424,7 +425,7 @@ class Net {
this.catchUp = msg.steps;
break;
case "moment":
this.moment = msg.steps;
this.moment = { steps: msg.steps, owner: msg.owner };
break;
case "share":
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);