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:
co-authored by
Claude Fable 5
parent
6788de2f6e
commit
a28d27eedc
@@ -117,20 +117,9 @@ function shareData(id: string): ShareData | null {
|
|||||||
const share = getShare(id);
|
const share = getShare(id);
|
||||||
const room = share ? getRoom(share.roomId) : undefined;
|
const room = share ? getRoom(share.roomId) : undefined;
|
||||||
if (share && room?.state) {
|
if (share && room?.state) {
|
||||||
const steps = momentSteps(room, SPECTATOR, share.turn);
|
const reel = momentSteps(room, SPECTATOR, share.turn);
|
||||||
if (!("error" in steps) && steps.length > 0) {
|
if (!("error" in reel) && reel.steps.length > 0) {
|
||||||
let actor: string = steps[steps.length - 1]!.actor;
|
data = { steps: reel.steps as unknown as ShareData["steps"], actor: reel.owner, round: reel.round };
|
||||||
let round = 0;
|
|
||||||
outer: for (const st of steps) {
|
|
||||||
for (const e of st.events) {
|
|
||||||
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
|
||||||
actor = e.player;
|
|
||||||
if (e.type === "turnStarted") round = e.round;
|
|
||||||
break outer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data = { steps: steps as unknown as ShareData["steps"], actor, round };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (shareCache.size > 50) shareCache.clear();
|
if (shareCache.size > 50) shareCache.clear();
|
||||||
@@ -624,8 +613,8 @@ wss.on("connection", (socket) => {
|
|||||||
return send(socket, { type: "error", message: "one moment" });
|
return send(socket, { type: "error", message: "one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
const steps = momentSteps(room, session.playerId, turn);
|
const reel = momentSteps(room, session.playerId, turn);
|
||||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
|
||||||
const share = mintShare(room.id, turn);
|
const share = mintShare(room.id, turn);
|
||||||
send(socket, { type: "share", id: share.id, turn });
|
send(socket, { type: "share", id: share.id, turn });
|
||||||
break;
|
break;
|
||||||
@@ -639,9 +628,9 @@ wss.on("connection", (socket) => {
|
|||||||
return send(socket, { type: "error", message: "catching up already — one moment" });
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||||
}
|
}
|
||||||
session.lastCatchUpAt = now;
|
session.lastCatchUpAt = now;
|
||||||
const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
const reel = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
||||||
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
if ("error" in reel) return send(socket, { type: "error", message: reel.error });
|
||||||
send(socket, { type: "moment", steps });
|
send(socket, { type: "moment", steps: reel.steps, owner: reel.owner });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "pickColor": {
|
case "pickColor": {
|
||||||
|
|||||||
@@ -420,13 +420,22 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
|
|||||||
* identically, so a turn number names the same stretch on both ends. */
|
* identically, so a turn number names the same stretch on both ends. */
|
||||||
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
|
export interface MomentReel {
|
||||||
|
steps: CatchUpStep[];
|
||||||
|
/** The wizard whose turn this is — the reel's eyes. */
|
||||||
|
owner: PlayerId;
|
||||||
|
round: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One turn's reel: every command whose events touch turn `turnIndex`
|
* One turn's reel: every command whose events touch turn `turnIndex`
|
||||||
* (0-counted across boundary events). A command that ends one turn and
|
* (0-counted across boundary events). A command that ends one turn and
|
||||||
* starts the next belongs to both, so a reel opens with the blow that
|
* starts the next belongs to both, so a reel opens with the blow that
|
||||||
* began it and closes on the handover.
|
* began it and closes on the handover. The owner comes from the very
|
||||||
|
* boundary that opened the turn — which for turn 0 lives in the DEAL,
|
||||||
|
* before any command, so no scan of the steps could find it.
|
||||||
*/
|
*/
|
||||||
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
|
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): MomentReel | { error: string } {
|
||||||
if (!room.state) return { error: "game not started" };
|
if (!room.state) return { error: "game not started" };
|
||||||
// The SPECTATOR builds share pages: public knowledge only, no seat.
|
// The SPECTATOR builds share pages: public knowledge only, no seat.
|
||||||
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
if (playerId !== SPECTATOR && !room.players.includes(playerId)) {
|
||||||
@@ -438,14 +447,24 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
// The deal's own events open the first turn — count them, or every
|
// The deal's own events open the first turn — count them, or every
|
||||||
// turn number would sit one behind the chronicle's.
|
// turn number would sit one behind the chronicle's.
|
||||||
let counter = -1;
|
let counter = -1;
|
||||||
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
|
let owner: PlayerId | null = null;
|
||||||
|
let round = 0;
|
||||||
|
const bump = (e: GameEvent) => {
|
||||||
|
if (!TURN_BOUNDARY.has(e.type)) return;
|
||||||
|
counter++;
|
||||||
|
if (counter === turnIndex && owner === null && "player" in e) {
|
||||||
|
owner = (e as { player: PlayerId }).player;
|
||||||
|
if ("round" in e) round = (e as { round: number }).round;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const e of dealt) bump(e);
|
||||||
const steps: CatchUpStep[] = [];
|
const steps: CatchUpStep[] = [];
|
||||||
for (const entry of room.log) {
|
for (const entry of room.log) {
|
||||||
const result = applyCommand(current, entry.playerId, entry.command);
|
const result = applyCommand(current, entry.playerId, entry.command);
|
||||||
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||||
current = result.state;
|
current = result.state;
|
||||||
const before = counter;
|
const before = counter;
|
||||||
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
|
for (const e of result.events) bump(e);
|
||||||
if (before > turnIndex) break;
|
if (before > turnIndex) break;
|
||||||
if (before <= turnIndex && turnIndex <= counter) {
|
if (before <= turnIndex && turnIndex <= counter) {
|
||||||
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||||
@@ -463,7 +482,7 @@ export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (steps.length === 0) return { error: "no such turn yet" };
|
if (steps.length === 0) return { error: "no such turn yet" };
|
||||||
return steps;
|
return { steps, owner: owner ?? steps[steps.length - 1]!.actor, round };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1619,8 +1619,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if net.moment && net.moment.length > 0}
|
{#if net.moment && net.moment.steps.length > 0}
|
||||||
<Replay steps={net.moment} moment onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
<Replay steps={net.moment.steps} moment pov={net.moment.owner}
|
||||||
|
onshare={() => net.requestShare()} onclose={() => net.closeMoment()} />
|
||||||
{:else if net.catchUp && net.catchUp.length > 0}
|
{:else if net.catchUp && net.catchUp.length > 0}
|
||||||
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
|
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
|
||||||
{:else if local.replaySteps && local.replaySteps.length > 0}
|
{:else if local.replaySteps && local.replaySteps.length > 0}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
steps,
|
steps,
|
||||||
onclose,
|
onclose,
|
||||||
moment = false,
|
moment = false,
|
||||||
|
pov = null,
|
||||||
onshare = null,
|
onshare = null,
|
||||||
endLabel = null,
|
endLabel = null,
|
||||||
}: {
|
}: {
|
||||||
@@ -22,6 +23,9 @@
|
|||||||
/** An instant replay of one turn: open straight into first person,
|
/** An instant replay of one turn: open straight into first person,
|
||||||
* through the eyes of the wizard whose turn it is. */
|
* through the eyes of the wizard whose turn it is. */
|
||||||
moment?: boolean;
|
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. */
|
/** Mint a public link to this turn; resolves to the URL. */
|
||||||
onshare?: (() => Promise<string>) | null;
|
onshare?: (() => Promise<string>) | null;
|
||||||
/** What the leave button says once the reel has run out. */
|
/** 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. */
|
/** Watch the board from above, or relive it through your own eyes. */
|
||||||
let fp = $state(moment);
|
let fp = $state(moment);
|
||||||
|
|
||||||
/** Whose eyes the first-person camera wears: normally your own; in a
|
/** Whose eyes the first-person camera wears: the wizard whose turn it
|
||||||
* moment reel, the turn-owner's — their position seen with YOUR
|
* is, ALWAYS — their position seen with the viewer's knowledge of the
|
||||||
* knowledge of the maze, so nothing private leaks. */
|
* 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(() => {
|
const povId = $derived.by(() => {
|
||||||
if (!moment) return steps[0]!.view.you;
|
for (let i = Math.min(idx, steps.length - 1); i >= 0; i--) {
|
||||||
for (const s of steps) {
|
const evs = steps[i]!.events;
|
||||||
const started = s.events.find(
|
for (let j = evs.length - 1; j >= 0; j--) {
|
||||||
(e) => e.type === "turnStarted" || e.type === "extraTurnStarted",
|
const e = evs[j]!;
|
||||||
);
|
if (e.type === "turnStarted" || e.type === "extraTurnStarted") {
|
||||||
if (started && "player" in started) return started.player as string;
|
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 step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||||
const lines = $derived(
|
const lines = $derived(
|
||||||
@@ -246,7 +254,27 @@
|
|||||||
// that phantom distance must not pick the opening shot's direction.
|
// that phantom distance must not pick the opening shot's direction.
|
||||||
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
if (camReady && dist > 0.05 && !hurled) { targetFacing = Math.atan2(dy, dx); aimed = true; }
|
||||||
else if (aim) {
|
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;
|
aimed = true;
|
||||||
} else if (actor && actor.id !== povId &&
|
} else if (actor && actor.id !== povId &&
|
||||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="share-stage">
|
<div class="share-stage">
|
||||||
{#key reelKey}
|
{#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)} />
|
onclose={() => (reelKey += 1)} />
|
||||||
{/key}
|
{/key}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -314,8 +314,9 @@ class Net {
|
|||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
/** A catch-up reel delivered by the server. */
|
/** 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);
|
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. */
|
/** One turn's reel, summoned from a chronicle line's instant-replay
|
||||||
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
* 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
|
/** Turns witnessed so far: counts the same boundary events the server
|
||||||
* counts, so a chronicle line can name the turn it belongs to. */
|
* counts, so a chronicle line can name the turn it belongs to. */
|
||||||
private turnCounter = -1;
|
private turnCounter = -1;
|
||||||
@@ -424,7 +425,7 @@ class Net {
|
|||||||
this.catchUp = msg.steps;
|
this.catchUp = msg.steps;
|
||||||
break;
|
break;
|
||||||
case "moment":
|
case "moment":
|
||||||
this.moment = msg.steps;
|
this.moment = { steps: msg.steps, owner: msg.owner };
|
||||||
break;
|
break;
|
||||||
case "share":
|
case "share":
|
||||||
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user