The instant replay: a chronicle line's eye relives the turn, firsthand
The dream feature lands, behind a preference that ships off for now. The chronicle's lines grow structure — each knows its turn, counted by the same boundary events on both ends of the wire (the server counts the deal's own events too, or every number would sit one turn behind). The first notable line of a turn — combat, spectacle, a targeted cast — wears a small eye; clicking it asks the server for that one turn's steps, rebuilt and redacted like any reel, and the replay opens STRAIGHT into first person through the eyes of the wizard whose turn it was: their position, the viewer's knowledge, nothing private leaked. It plays that turn and stops. Save-video and the board toggle come along for free. Two camera bugs die with it: the replay tween effect tracked its own writes, restarting the ease every frame until walks decayed into the slow wall-piercing drift Eric saw — untracked reads run one tween per step now. And the reel camera follows whichever eyes the reel wears, not always your own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
68be32c43e
commit
f52d0d88a9
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Board from "./Board.svelte";
|
||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||
import { untrack } from "svelte";
|
||||
import { humanize } from "./net.svelte";
|
||||
import { scheduleFx, type BoardFx } from "./fx";
|
||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||
@@ -12,16 +13,34 @@
|
||||
let {
|
||||
steps,
|
||||
onclose,
|
||||
moment = false,
|
||||
}: {
|
||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||
onclose: () => void;
|
||||
/** An instant replay of one turn: open straight into first person,
|
||||
* through the eyes of the wizard whose turn it is. */
|
||||
moment?: boolean;
|
||||
} = $props();
|
||||
|
||||
let idx = $state(0);
|
||||
let playing = $state(true);
|
||||
let speed = $state(1);
|
||||
/** Watch the board from above, or relive it through your own eyes. */
|
||||
let fp = $state(false);
|
||||
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. */
|
||||
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;
|
||||
}
|
||||
return steps[0]!.actor;
|
||||
});
|
||||
const step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||
const lines = $derived(
|
||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||
@@ -53,7 +72,7 @@
|
||||
if (!fp || !st || !prefs.flourishes) return;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
const started: number[] = [];
|
||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, st.view.you)) {
|
||||
for (const { fx, delay } of fpFxForEvents(st.events, st.view, povId)) {
|
||||
timers.push(setTimeout(() => {
|
||||
started.push(fx.id);
|
||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||
@@ -102,7 +121,7 @@
|
||||
}
|
||||
};
|
||||
for (const p of v.players) {
|
||||
if (!p.alive || p.id === v.you) continue;
|
||||
if (!p.alive || p.id === povId) continue;
|
||||
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||
gather(p.id, p.position, was?.position);
|
||||
}
|
||||
@@ -143,12 +162,19 @@
|
||||
$effect(() => {
|
||||
if (!fp) { camReady = false; return; }
|
||||
const v = step.view;
|
||||
const me = v.players.find((p) => p.id === v.you);
|
||||
const me = v.players.find((p) => p.id === povId);
|
||||
if (!me) return;
|
||||
const tx = me.position.x + 0.5;
|
||||
const ty = me.position.y + 0.5;
|
||||
const dx = tx - cam.x;
|
||||
const dy = ty - cam.y;
|
||||
// Read the camera WITHOUT tracking it: this effect's own tween writes
|
||||
// cam every frame, and a tracked read would re-trigger the effect per
|
||||
// frame — each restart resetting the ease to zero, so the walk decays
|
||||
// into a slow drift. Untracked, one step runs one tween.
|
||||
const camX = untrack(() => cam.x);
|
||||
const camY = untrack(() => cam.y);
|
||||
const camF = untrack(() => cam.facing);
|
||||
const dx = tx - camX;
|
||||
const dy = ty - camY;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
// Where should the eye end up pointing? Along its own stride; at the
|
||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
||||
@@ -156,11 +182,11 @@
|
||||
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||
const hurled = step.events.some((e) =>
|
||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
||||
e.type === "retreatedInHorror") && e.player === v.you);
|
||||
e.type === "retreatedInHorror") && e.player === povId);
|
||||
const actor = v.players.find((p) => p.id === step.actor);
|
||||
let targetFacing = cam.facing;
|
||||
let targetFacing = camF;
|
||||
if (dist > 0.05 && !hurled) targetFacing = Math.atan2(dy, dx);
|
||||
else if (actor && actor.id !== v.you &&
|
||||
else if (actor && actor.id !== povId &&
|
||||
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||
// Turn toward the actor — but only if these eyes could actually see
|
||||
// them: an unbroken straight sight line, no warps bending it.
|
||||
@@ -175,7 +201,7 @@
|
||||
camReady = true;
|
||||
return;
|
||||
}
|
||||
const fromX = cam.x, fromY = cam.y, fromF = cam.facing;
|
||||
const fromX = camX, fromY = camY, fromF = camF;
|
||||
const arc = shortestArc(fromF, targetFacing);
|
||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||
@@ -280,7 +306,7 @@
|
||||
<div class="replay-scrim">
|
||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||
<header class="replay-head">
|
||||
<span class="replay-title">{steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-title">{moment ? `Instant replay — ${povId}'s turn` : steps[0]?.seq === 0 ? "The whole tale, from the deal" : "While you were away"}</span>
|
||||
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||
@@ -292,7 +318,7 @@
|
||||
</header>
|
||||
<div class="replay-board" bind:this={stageEl}>
|
||||
{#if fp}
|
||||
<FirstPerson view={step.view} povId={step.view.you}
|
||||
<FirstPerson view={step.view} {povId}
|
||||
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||
fx={fpFx} posOverride={actorPos} />
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user