Compare commits
2
Commits
68be32c43e
...
8a27c06b55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a27c06b55 | ||
|
|
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.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 45 KiB |
@@ -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