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
@@ -35,6 +35,7 @@ import { WebSocketServer, WebSocket } from "ws";
|
|||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import {
|
import {
|
||||||
catchUpSteps,
|
catchUpSteps,
|
||||||
|
momentSteps,
|
||||||
claimTransferCode,
|
claimTransferCode,
|
||||||
createRoom,
|
createRoom,
|
||||||
pickColor,
|
pickColor,
|
||||||
@@ -507,6 +508,20 @@ wss.on("connection", (socket) => {
|
|||||||
send(socket, { type: "catchUp", steps });
|
send(socket, { type: "catchUp", steps });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "moment": {
|
||||||
|
// A chronicle line's instant-replay eye: one turn's reel.
|
||||||
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - session.lastCatchUpAt < CATCHUP_COOLDOWN_MS) {
|
||||||
|
return send(socket, { type: "error", message: "catching up already — one moment" });
|
||||||
|
}
|
||||||
|
session.lastCatchUpAt = now;
|
||||||
|
const steps = momentSteps(room, session.playerId, Number(msg.turn ?? -1));
|
||||||
|
if ("error" in steps) return send(socket, { type: "error", message: steps.error });
|
||||||
|
send(socket, { type: "moment", steps });
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "pickColor": {
|
case "pickColor": {
|
||||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||||
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
|
||||||
|
|||||||
@@ -416,6 +416,53 @@ export function redactFor(events: GameEvent[], playerId: PlayerId): GameEvent[]
|
|||||||
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
|
return events.map((e) => redactEvent(e, playerId)).filter((e): e is GameEvent => e !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The events that begin a turn — the client's chronicle counts these
|
||||||
|
* identically, so a turn number names the same stretch on both ends. */
|
||||||
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One turn's reel: every command whose events touch turn `turnIndex`
|
||||||
|
* (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
|
||||||
|
* began it and closes on the handover.
|
||||||
|
*/
|
||||||
|
export function momentSteps(room: Room, playerId: PlayerId, turnIndex: number): CatchUpStep[] | { error: string } {
|
||||||
|
if (!room.state) return { error: "game not started" };
|
||||||
|
if (!room.players.includes(playerId)) return { error: "you hold no seat in this room" };
|
||||||
|
const MAX_STEPS = 80;
|
||||||
|
const { state: fresh, events: dealt } = createGame(room.state.config);
|
||||||
|
let current = fresh;
|
||||||
|
// The deal's own events open the first turn — count them, or every
|
||||||
|
// turn number would sit one behind the chronicle's.
|
||||||
|
let counter = -1;
|
||||||
|
for (const e of dealt) if (TURN_BOUNDARY.has(e.type)) counter++;
|
||||||
|
const steps: CatchUpStep[] = [];
|
||||||
|
for (const entry of room.log) {
|
||||||
|
const result = applyCommand(current, entry.playerId, entry.command);
|
||||||
|
if (!result.ok) return { error: `replay diverged at seq ${entry.seq}` };
|
||||||
|
current = result.state;
|
||||||
|
const before = counter;
|
||||||
|
for (const e of result.events) if (TURN_BOUNDARY.has(e.type)) counter++;
|
||||||
|
if (before > turnIndex) break;
|
||||||
|
if (before <= turnIndex && turnIndex <= counter) {
|
||||||
|
const prevAt = entry.seq > 0 ? room.log[entry.seq - 1]!.at : "";
|
||||||
|
const said = room.chat
|
||||||
|
.filter((c) => c.at > prevAt && c.at <= entry.at)
|
||||||
|
.map((c) => ({ player: c.player, text: c.text }));
|
||||||
|
steps.push({
|
||||||
|
seq: entry.seq,
|
||||||
|
actor: entry.playerId,
|
||||||
|
events: redactFor(result.events, playerId),
|
||||||
|
view: viewFor(current, playerId),
|
||||||
|
...(said.length > 0 ? { chat: said } : {}),
|
||||||
|
});
|
||||||
|
if (steps.length >= MAX_STEPS) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (steps.length === 0) return { error: "no such turn yet" };
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Seat transfers: a spoken-word one-time code hands a seat to another device.
|
// Seat transfers: a spoken-word one-time code hands a seat to another device.
|
||||||
// Ephemeral by design — a server restart voids pending codes, never seats.
|
// Ephemeral by design — a server restart voids pending codes, never seats.
|
||||||
|
|||||||
@@ -1375,6 +1375,11 @@
|
|||||||
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
|
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
|
||||||
<span>Spell flourishes (the animated effects)</span>
|
<span>Spell flourishes (the animated effects)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="pref-row">
|
||||||
|
<input type="checkbox" checked={prefs.instantReplay}
|
||||||
|
onchange={(e) => setPref("instantReplay", e.currentTarget.checked)} />
|
||||||
|
<span>Instant replay: notable chronicle lines offer a 👁 that relives the turn</span>
|
||||||
|
</label>
|
||||||
<label class="pref-row">
|
<label class="pref-row">
|
||||||
<input type="checkbox" checked={prefs.cautions}
|
<input type="checkbox" checked={prefs.cautions}
|
||||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||||
@@ -1606,7 +1611,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if net.catchUp && net.catchUp.length > 0}
|
{#if net.moment && net.moment.length > 0}
|
||||||
|
<Replay steps={net.moment} moment onclose={() => net.closeMoment()} />
|
||||||
|
{: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}
|
||||||
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
||||||
@@ -2038,9 +2045,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
||||||
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
|
{#if local.active}
|
||||||
<div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
|
{#each local.log.slice(-60) as line, i (i)}
|
||||||
{/each}
|
<div class:table-talk={line.startsWith("\u{1F4AC}")}>{line}</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each net.log.slice(-60) as line, i (i)}
|
||||||
|
<div class:table-talk={line.text.startsWith("\u{1F4AC}")}>
|
||||||
|
{line.text}
|
||||||
|
{#if line.notable && line.turn !== null && prefs.instantReplay && !net.spectating}
|
||||||
|
<button class="moment-eye" title="relive this turn"
|
||||||
|
onclick={() => net.requestMoment(line.turn!)}>👁</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if local.active && local.viewerId}
|
{#if local.active && local.viewerId}
|
||||||
<div class="say-box">
|
<div class="say-box">
|
||||||
@@ -2892,6 +2911,15 @@
|
|||||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
.chronicle div + div { margin-top: 0.05rem; }
|
.chronicle div + div { margin-top: 0.05rem; }
|
||||||
|
.moment-eye {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.moment-eye:hover { opacity: 1; }
|
||||||
|
|
||||||
.table-edge {
|
.table-edge {
|
||||||
margin-top: 0.9rem;
|
margin-top: 0.9rem;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
import FirstPerson from "./fpv/FirstPerson.svelte";
|
import FirstPerson from "./fpv/FirstPerson.svelte";
|
||||||
|
import { untrack } from "svelte";
|
||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
import { scheduleFx, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
import { fpFxForEvents, type FpFx } from "./fpv/fx3d";
|
||||||
@@ -12,16 +13,34 @@
|
|||||||
let {
|
let {
|
||||||
steps,
|
steps,
|
||||||
onclose,
|
onclose,
|
||||||
|
moment = false,
|
||||||
}: {
|
}: {
|
||||||
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
steps: { seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[];
|
||||||
onclose: () => void;
|
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();
|
} = $props();
|
||||||
|
|
||||||
let idx = $state(0);
|
let idx = $state(0);
|
||||||
let playing = $state(true);
|
let playing = $state(true);
|
||||||
let speed = $state(1);
|
let speed = $state(1);
|
||||||
/** 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(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 step = $derived(steps[Math.min(idx, steps.length - 1)]!);
|
||||||
const lines = $derived(
|
const lines = $derived(
|
||||||
step.events.map(humanize).filter((l): l is string => l !== null),
|
step.events.map(humanize).filter((l): l is string => l !== null),
|
||||||
@@ -53,7 +72,7 @@
|
|||||||
if (!fp || !st || !prefs.flourishes) return;
|
if (!fp || !st || !prefs.flourishes) return;
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
const started: number[] = [];
|
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(() => {
|
timers.push(setTimeout(() => {
|
||||||
started.push(fx.id);
|
started.push(fx.id);
|
||||||
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
fpFx = [...fpFx, { ...fx, t0: performance.now() }];
|
||||||
@@ -102,7 +121,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const p of v.players) {
|
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);
|
const was = before.players.find((q) => q.id === p.id && q.alive);
|
||||||
gather(p.id, p.position, was?.position);
|
gather(p.id, p.position, was?.position);
|
||||||
}
|
}
|
||||||
@@ -143,12 +162,19 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!fp) { camReady = false; return; }
|
if (!fp) { camReady = false; return; }
|
||||||
const v = step.view;
|
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;
|
if (!me) return;
|
||||||
const tx = me.position.x + 0.5;
|
const tx = me.position.x + 0.5;
|
||||||
const ty = me.position.y + 0.5;
|
const ty = me.position.y + 0.5;
|
||||||
const dx = tx - cam.x;
|
// Read the camera WITHOUT tracking it: this effect's own tween writes
|
||||||
const dy = ty - cam.y;
|
// 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);
|
const dist = Math.hypot(dx, dy);
|
||||||
// Where should the eye end up pointing? Along its own stride; at the
|
// Where should the eye end up pointing? Along its own stride; at the
|
||||||
// actor, when someone else moved the world; wherever it was, otherwise.
|
// 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.
|
// eyes still where they were; only unexplained leaps (teleports) cut.
|
||||||
const hurled = step.events.some((e) =>
|
const hurled = step.events.some((e) =>
|
||||||
(e.type === "knockedBack" || e.type === "shoved" || e.type === "washedBack" ||
|
(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);
|
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);
|
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)) {
|
(actor.position.x !== me.position.x || actor.position.y !== me.position.y)) {
|
||||||
// Turn toward the actor — but only if these eyes could actually see
|
// Turn toward the actor — but only if these eyes could actually see
|
||||||
// them: an unbroken straight sight line, no warps bending it.
|
// them: an unbroken straight sight line, no warps bending it.
|
||||||
@@ -175,7 +201,7 @@
|
|||||||
camReady = true;
|
camReady = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fromX = cam.x, fromY = cam.y, fromF = cam.facing;
|
const fromX = camX, fromY = camY, fromF = camF;
|
||||||
const arc = shortestArc(fromF, targetFacing);
|
const arc = shortestArc(fromF, targetFacing);
|
||||||
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
const turnMs = (hurled ? 0 : Math.min(260, Math.abs(arc) * 180)) / speed;
|
||||||
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
const walkMs = (dist > 0.05 ? (hurled ? 260 : 420) : 0) / speed;
|
||||||
@@ -280,7 +306,7 @@
|
|||||||
<div class="replay-scrim">
|
<div class="replay-scrim">
|
||||||
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
<div class="replay" role="dialog" aria-modal="true" aria-label="what happened while you were away">
|
||||||
<header class="replay-head">
|
<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>
|
<span class="replay-count">move {idx + 1} of {steps.length}</span>
|
||||||
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
<button class="replay-eyes" class:lit={fp} onclick={() => (fp = !fp)}>
|
||||||
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
{fp ? "⬒ the board" : "👁 your eyes"}</button>
|
||||||
@@ -292,7 +318,7 @@
|
|||||||
</header>
|
</header>
|
||||||
<div class="replay-board" bind:this={stageEl}>
|
<div class="replay-board" bind:this={stageEl}>
|
||||||
{#if fp}
|
{#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}
|
x={cam.x} y={cam.y} facing={cam.facing} width={640} height={360}
|
||||||
fx={fpFx} posOverride={actorPos} />
|
fx={fpFx} posOverride={actorPos} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -202,6 +202,32 @@ export function humanize(e: GameEvent): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A chronicle line: its words, the turn it belongs to (counted by the
|
||||||
|
* same boundary events the server counts), and whether it carries the
|
||||||
|
* turn's instant-replay eye. */
|
||||||
|
export interface LogLine {
|
||||||
|
text: string;
|
||||||
|
turn: number | null;
|
||||||
|
notable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The boundaries the turn count walks — mirrored by the server's
|
||||||
|
* momentSteps, so a chronicle turn number addresses the same commands. */
|
||||||
|
const TURN_BOUNDARY = new Set(["turnStarted", "extraTurnStarted", "turnSkipped"]);
|
||||||
|
|
||||||
|
/** Does this event make a turn worth reliving? Combat and spectacle. */
|
||||||
|
export function isNotableEvent(e: GameEvent): boolean {
|
||||||
|
if (e.type === "spellCast") return e.target !== null || e.targetCell !== null;
|
||||||
|
return NOTABLE_EVENTS.has(e.type);
|
||||||
|
}
|
||||||
|
const NOTABLE_EVENTS = new Set([
|
||||||
|
"punched", "attackResolved", "damaged", "died", "knockedBack", "shoved",
|
||||||
|
"washedBack", "retreatedInHorror", "creatureAttacked", "boobytrapSprung",
|
||||||
|
"firewallBurned", "objectThrown", "gameWon", "positionsSwapped",
|
||||||
|
"teleported", "stonesDestroyed", "thumbOfGod", "stoneTurnedToWater",
|
||||||
|
"waterwallCrashes", "sectorRotated", "sectorRelocated", "homeBasesSwapped",
|
||||||
|
]);
|
||||||
|
|
||||||
const SEAT_KEY = "wizwar-seat";
|
const SEAT_KEY = "wizwar-seat";
|
||||||
const SEATS_KEY = "wizwar-seats";
|
const SEATS_KEY = "wizwar-seats";
|
||||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||||
@@ -265,7 +291,7 @@ class Net {
|
|||||||
/** How many watch from the gallery (0 hides the count). */
|
/** How many watch from the gallery (0 hides the count). */
|
||||||
audience = $state(0);
|
audience = $state(0);
|
||||||
view = $state<GameView | null>(null);
|
view = $state<GameView | null>(null);
|
||||||
log = $state<string[]>([]);
|
log = $state<LogLine[]>([]);
|
||||||
error = $state<string | null>(null);
|
error = $state<string | null>(null);
|
||||||
/** Every seat this browser holds, across rooms. */
|
/** Every seat this browser holds, across rooms. */
|
||||||
seats = $state<Seat[]>(loadSeats());
|
seats = $state<Seat[]>(loadSeats());
|
||||||
@@ -288,6 +314,13 @@ 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. */
|
||||||
|
moment = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: 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;
|
||||||
|
/** The turn that already carries an instant-replay eye (one per turn). */
|
||||||
|
private eyeTurn = -1;
|
||||||
private seen: Record<string, number> = loadSeen();
|
private seen: Record<string, number> = loadSeen();
|
||||||
/** Room whose live stream this connection has already shown once: states
|
/** Room whose live stream this connection has already shown once: states
|
||||||
* after the first mark themselves seen while the tab is visible. */
|
* after the first mark themselves seen while the tab is visible. */
|
||||||
@@ -387,6 +420,9 @@ class Net {
|
|||||||
case "catchUp":
|
case "catchUp":
|
||||||
this.catchUp = msg.steps;
|
this.catchUp = msg.steps;
|
||||||
break;
|
break;
|
||||||
|
case "moment":
|
||||||
|
this.moment = msg.steps;
|
||||||
|
break;
|
||||||
case "events": {
|
case "events": {
|
||||||
let talk = 0;
|
let talk = 0;
|
||||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||||
@@ -395,8 +431,13 @@ class Net {
|
|||||||
if (e.type === "gameStarted" && !msg.replayed) {
|
if (e.type === "gameStarted" && !msg.replayed) {
|
||||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||||
}
|
}
|
||||||
|
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||||
const line = humanize(e);
|
const line = humanize(e);
|
||||||
if (line) this.log = [...this.log, line];
|
if (line) {
|
||||||
|
const notable = this.turnCounter >= 0 && this.turnCounter !== this.eyeTurn && isNotableEvent(e);
|
||||||
|
if (notable) this.eyeTurn = this.turnCounter;
|
||||||
|
this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (talk > 0) {
|
if (talk > 0) {
|
||||||
this.chatCount += talk;
|
this.chatCount += talk;
|
||||||
@@ -415,7 +456,7 @@ class Net {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chat": {
|
case "chat": {
|
||||||
this.log = [...this.log, `\u{1F4AC} ${msg.player}: ${msg.text}`];
|
this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
|
||||||
this.chatCount += 1;
|
this.chatCount += 1;
|
||||||
if (this.roomId) this.markChatSeen();
|
if (this.roomId) this.markChatSeen();
|
||||||
break;
|
break;
|
||||||
@@ -454,7 +495,7 @@ class Net {
|
|||||||
}
|
}
|
||||||
this.error = msg.message;
|
this.error = msg.message;
|
||||||
// The toast fades; the chronicle remembers why nothing happened.
|
// The toast fades; the chronicle remembers why nothing happened.
|
||||||
this.log = [...this.log, `— ${msg.message} —`];
|
this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
|
||||||
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -475,6 +516,8 @@ class Net {
|
|||||||
watch(roomId: string): void {
|
watch(roomId: string): void {
|
||||||
this.you = null;
|
this.you = null;
|
||||||
this.log = [];
|
this.log = [];
|
||||||
|
this.turnCounter = -1;
|
||||||
|
this.eyeTurn = -1;
|
||||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +538,8 @@ class Net {
|
|||||||
this.token = seat.token;
|
this.token = seat.token;
|
||||||
this.roomIdPending = seat.roomId;
|
this.roomIdPending = seat.roomId;
|
||||||
this.log = [];
|
this.log = [];
|
||||||
|
this.turnCounter = -1;
|
||||||
|
this.eyeTurn = -1;
|
||||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,6 +647,8 @@ class Net {
|
|||||||
this.started = false;
|
this.started = false;
|
||||||
this.players = [];
|
this.players = [];
|
||||||
this.log = [];
|
this.log = [];
|
||||||
|
this.turnCounter = -1;
|
||||||
|
this.eyeTurn = -1;
|
||||||
this.token = null;
|
this.token = null;
|
||||||
this.spectating = false;
|
this.spectating = false;
|
||||||
this.audience = 0;
|
this.audience = 0;
|
||||||
@@ -639,6 +686,15 @@ class Net {
|
|||||||
this.markSeen();
|
this.markSeen();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Summon one turn's reel by its chronicle turn number. */
|
||||||
|
requestMoment(turn: number): void {
|
||||||
|
this.send({ type: "moment", turn });
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMoment(): void {
|
||||||
|
this.moment = null;
|
||||||
|
}
|
||||||
|
|
||||||
command(command: Command): void {
|
command(command: Command): void {
|
||||||
this.send({ type: "command", command });
|
this.send({ type: "command", command });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface Prefs {
|
|||||||
cautions: boolean;
|
cautions: boolean;
|
||||||
/** Default skill for automatons seated from the lobby. */
|
/** Default skill for automatons seated from the lobby. */
|
||||||
botTier: "apprentice" | "adept" | "archmage";
|
botTier: "apprentice" | "adept" | "archmage";
|
||||||
|
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
||||||
|
instantReplay: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KEY = "wizwar-prefs";
|
const KEY = "wizwar-prefs";
|
||||||
@@ -25,7 +27,7 @@ const KEY = "wizwar-prefs";
|
|||||||
function load(): Prefs {
|
function load(): Prefs {
|
||||||
const fallback: Prefs = {
|
const fallback: Prefs = {
|
||||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||||
cautions: true, botTier: "adept",
|
cautions: true, botTier: "adept", instantReplay: false,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(KEY);
|
const raw = localStorage.getItem(KEY);
|
||||||
@@ -39,6 +41,7 @@ function load(): Prefs {
|
|||||||
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
color: typeof p.color === "number" && p.color >= 0 && p.color <= 5 ? p.color : null,
|
||||||
cautions: p.cautions !== false,
|
cautions: p.cautions !== false,
|
||||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||||
|
instantReplay: p.instantReplay === true,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
Reference in New Issue
Block a user