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 {
|
||||
catchUpSteps,
|
||||
momentSteps,
|
||||
claimTransferCode,
|
||||
createRoom,
|
||||
pickColor,
|
||||
@@ -507,6 +508,20 @@ wss.on("connection", (socket) => {
|
||||
send(socket, { type: "catchUp", steps });
|
||||
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": {
|
||||
const room = session.roomId ? getRoom(session.roomId) : undefined;
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
// 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)} />
|
||||
<span>Spell flourishes (the animated effects)</span>
|
||||
</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">
|
||||
<input type="checkbox" checked={prefs.cautions}
|
||||
onchange={(e) => setPref("cautions", e.currentTarget.checked)} />
|
||||
@@ -1606,7 +1611,9 @@
|
||||
</div>
|
||||
{/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()} />
|
||||
{:else if local.replaySteps && local.replaySteps.length > 0}
|
||||
<Replay steps={local.replaySteps} onclose={() => (local.replaySteps = null)} />
|
||||
@@ -2038,9 +2045,21 @@
|
||||
</div>
|
||||
|
||||
<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}
|
||||
{#each local.log.slice(-60) as line, i (i)}
|
||||
<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>
|
||||
{#if local.active && local.viewerId}
|
||||
<div class="say-box">
|
||||
@@ -2892,6 +2911,15 @@
|
||||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.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 {
|
||||
margin-top: 0.9rem;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 SEATS_KEY = "wizwar-seats";
|
||||
const CHAT_SEEN_KEY = "wizwar-chat-seen";
|
||||
@@ -265,7 +291,7 @@ class Net {
|
||||
/** How many watch from the gallery (0 hides the count). */
|
||||
audience = $state(0);
|
||||
view = $state<GameView | null>(null);
|
||||
log = $state<string[]>([]);
|
||||
log = $state<LogLine[]>([]);
|
||||
error = $state<string | null>(null);
|
||||
/** Every seat this browser holds, across rooms. */
|
||||
seats = $state<Seat[]>(loadSeats());
|
||||
@@ -288,6 +314,13 @@ 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);
|
||||
/** 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();
|
||||
/** Room whose live stream this connection has already shown once: states
|
||||
* after the first mark themselves seen while the tab is visible. */
|
||||
@@ -387,6 +420,9 @@ class Net {
|
||||
case "catchUp":
|
||||
this.catchUp = msg.steps;
|
||||
break;
|
||||
case "moment":
|
||||
this.moment = msg.steps;
|
||||
break;
|
||||
case "events": {
|
||||
let talk = 0;
|
||||
if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
|
||||
@@ -395,8 +431,13 @@ class Net {
|
||||
if (e.type === "gameStarted" && !msg.replayed) {
|
||||
this.openingRolls = { rolls: e.dieRolls, first: e.firstPlayer, players: e.players };
|
||||
}
|
||||
if (TURN_BOUNDARY.has(e.type)) this.turnCounter++;
|
||||
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) {
|
||||
this.chatCount += talk;
|
||||
@@ -415,7 +456,7 @@ class Net {
|
||||
break;
|
||||
}
|
||||
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;
|
||||
if (this.roomId) this.markChatSeen();
|
||||
break;
|
||||
@@ -454,7 +495,7 @@ class Net {
|
||||
}
|
||||
this.error = msg.message;
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
@@ -475,6 +516,8 @@ class Net {
|
||||
watch(roomId: string): void {
|
||||
this.you = null;
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.send({ type: "watch", roomId: roomId.toUpperCase() });
|
||||
}
|
||||
|
||||
@@ -495,6 +538,8 @@ class Net {
|
||||
this.token = seat.token;
|
||||
this.roomIdPending = seat.roomId;
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.send({ type: "join", roomId: seat.roomId, name: seat.name, token: seat.token });
|
||||
}
|
||||
|
||||
@@ -602,6 +647,8 @@ class Net {
|
||||
this.started = false;
|
||||
this.players = [];
|
||||
this.log = [];
|
||||
this.turnCounter = -1;
|
||||
this.eyeTurn = -1;
|
||||
this.token = null;
|
||||
this.spectating = false;
|
||||
this.audience = 0;
|
||||
@@ -639,6 +686,15 @@ class Net {
|
||||
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 {
|
||||
this.send({ type: "command", command });
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Prefs {
|
||||
cautions: boolean;
|
||||
/** Default skill for automatons seated from the lobby. */
|
||||
botTier: "apprentice" | "adept" | "archmage";
|
||||
/** Instant replay: notable chronicle lines offer a first-person reel. */
|
||||
instantReplay: boolean;
|
||||
}
|
||||
|
||||
const KEY = "wizwar-prefs";
|
||||
@@ -25,7 +27,7 @@ const KEY = "wizwar-prefs";
|
||||
function load(): Prefs {
|
||||
const fallback: Prefs = {
|
||||
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
|
||||
cautions: true, botTier: "adept",
|
||||
cautions: true, botTier: "adept", instantReplay: false,
|
||||
};
|
||||
try {
|
||||
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,
|
||||
cautions: p.cautions !== false,
|
||||
botTier: p.botTier === "apprentice" || p.botTier === "archmage" ? p.botTier : "adept",
|
||||
instantReplay: p.instantReplay === true,
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
|
||||
Reference in New Issue
Block a user