Your turn — round {view.turn.round}.
+ {#if prefs.stepLight && view.turn.round <= 2}
+
Win by carrying two treasures that aren't yours to your home, or by being the last wizard standing.
+ {/if}
{#if view.turn.actionsEnded}
{#if me?.carriedTreasureId}
picking up ended your actions for this turn, and the treasure comes with you —
@@ -3751,6 +3782,7 @@
transform: rotate(-0.4deg);
}
.slip.yours { border-left: 4px solid #2e7d32; }
+ .slip .goal { margin-top: 0.3rem; font-size: 0.86rem; opacity: 0.85; }
.slip.coach { border-left: 4px solid #c9a72a; }
.slip.coach .stamp { margin-left: 0.5rem; }
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte
index edf0c08..df6107d 100644
--- a/packages/web/src/Board.svelte
+++ b/packages/web/src/Board.svelte
@@ -33,6 +33,8 @@
markedCell = null,
markedCells = null,
litCells = null,
+ spotCells = [],
+ spotCaption = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
@@ -65,6 +67,10 @@
markedCells?: { x: number; y: number }[] | null;
/** When set, squares NOT in this set dim — the targeting aid. */
litCells?: Set
| null;
+ /** The step light's squares: a pulsing rim on each. */
+ spotCells?: string[];
+ /** One line hung above a square — the first stride's cue. */
+ spotCaption?: { cell: { x: number; y: number }; text: string } | null;
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
markedSector?: { x: number; y: number } | null;
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
@@ -680,6 +686,23 @@
{/if}
{/each}
{/if}
+
+ {#each spotCells as key (key)}
+ {@const sx = Number(key.split(",")[0])}
+ {@const sy = Number(key.split(",")[1])}
+
+ {/each}
+ {#if spotCaption}
+ {@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
+ {@const cw = spotCaption.text.length * 6.6 + 20}
+ {@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
+ {@const above = spotCaption.cell.y > 0}
+ {@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
+
+
+ {spotCaption.text}
+
+ {/if}
{#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])}
@@ -823,6 +846,26 @@
fill: rgba(12, 9, 5, 0.55);
pointer-events: none;
}
+ .spot-cell {
+ fill: none;
+ stroke: #f2cf5b;
+ stroke-width: 3;
+ pointer-events: none;
+ animation: spot-pulse 1.3s ease-in-out infinite;
+ }
+ @keyframes spot-pulse {
+ 0%, 100% { opacity: 0.35; }
+ 50% { opacity: 1; }
+ }
+ .spot-caption { pointer-events: none; }
+ .spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
+ .spot-caption text {
+ fill: #f7ecc8;
+ font-size: 12px;
+ font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
+ letter-spacing: 0.02em;
+ text-anchor: middle;
+ }
.marked-cell {
fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b;
diff --git a/packages/web/src/net.svelte.ts b/packages/web/src/net.svelte.ts
index 1d66542..187049a 100644
--- a/packages/web/src/net.svelte.ts
+++ b/packages/web/src/net.svelte.ts
@@ -408,174 +408,201 @@ class Net {
ws.onmessage = (raw) => {
const msg = JSON.parse(raw.data as string);
this.lastHeard = Date.now();
- switch (msg.type) {
- case "pong":
- break;
- case "seat": {
- this.token = msg.token;
- const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
- if (seatRoom) {
- const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
- localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
- this.rememberSeat(seat);
- }
- break;
- }
- case "watching":
- this.spectating = true;
- this.roomId = msg.roomId;
- break;
- case "audience":
- this.audience = msg.count ?? 0;
- break;
- case "room":
- this.roomId = msg.roomId;
- this.roomColors = msg.colors ?? {};
- this.roomBots = msg.bots ?? {};
- this.audience = msg.audience ?? 0;
- if (this.you && this.token) {
- const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
- localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
- this.rememberSeat(seat);
- }
- this.players = msg.players;
- this.hostId = msg.hostId;
- this.started = msg.started;
- break;
- case "state": {
- this.view = msg.view;
- if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
- this.currentSeq = msg.seq;
- // Only the FIRST state after arriving carries a gap worth
- // announcing. Later states were watched live: a caught-up
- // watcher stays caught up, and an announced gap stays FROZEN
- // until watched or skipped. A hidden tab accumulates its gap
- // honestly.
- if (this.watching === this.roomId && document.visibilityState === "visible") {
- if (this.missedMoves === 0) this.markSeen();
- } else {
- const last = this.seen[this.roomId] ?? 0;
- this.missedMoves = Math.max(0, msg.seq - last);
- if (this.missedMoves === 0) this.markSeen();
- }
- if (document.visibilityState === "visible") this.watching = this.roomId;
- }
- break;
- }
- case "catchUp":
- this.catchUp = msg.steps;
- break;
- case "moment":
- this.moment = { steps: msg.steps, owner: msg.owner };
- break;
- case "share":
- this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
- this.shareResolve = null;
- this.shareReject = null;
- break;
- case "events": {
- let talk = 0;
- if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
- for (const e of msg.events as GameEvent[]) {
- if (e.type === "tableTalk") talk++;
- 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) {
- // Every turn wears its eye, on the header line that opens
- // it — the players judge what is share-worthy. The game-
- // winning line carries its own besides.
- const notable = this.turnCounter >= 0 &&
- (e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
- this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
- }
- }
- if (talk > 0) {
- this.chatCount += talk;
- if (this.roomId) this.markChatSeen();
- }
- break;
- }
- case "kicked":
- this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
- this.leaveLocal();
- break;
- case "roomAbandoned":
- this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
- this.leaveLocal();
- break;
- case "transferCode":
- this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
- break;
- case "transferClaimed": {
- const seat = msg.seat as Seat & { roomId: string };
- this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
- this.refreshGames();
- this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
- break;
- }
- case "chat": {
- this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
- this.chatCount += 1;
- if (this.roomId) this.markChatSeen();
- break;
- }
- case "stats": {
- this.stats = msg.stats;
- break;
- }
- case "feedbackList":
- this.feedbackReports = msg.reports ?? [];
- break;
- case "feedbackReceived":
- this.refreshFeedback();
- break;
- case "games": {
- this.games = msg.games;
- for (const g of msg.games as GameSummary[]) {
- const key = `${g.roomId}:${g.name}`;
- const was = this.lastYourTurn.get(key) ?? false;
- if (g.yourTurn && !was) this.notifyTurn(g);
- this.lastYourTurn.set(key, g.yourTurn);
- }
- // Prune ONLY seats the server proved dead: the room exists and
- // refused this exact token. A seat merely ABSENT from the reply
- // stays — a restarting server, a stale restore, or the wrong
- // backend all answer with ignorance, and ignorance is not
- // deletion. The wallet is capped by age instead of by trust.
- const voided = new Set((msg.voided as string[] | undefined) ?? []);
- let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
- if (kept.length > 50) kept = kept.slice(kept.length - 50);
- if (kept.length !== this.seats.length) {
- this.seats = kept;
- saveSeats(this.seats);
- }
- break;
- }
- case "error":
- // "Name is taken" means THIS token was tried and refused: the
- // credential itself is dead, and holding it helps nobody. But
- // "no such room" proves nothing about the seat — a restarting
- // server, a stale restore, or the wrong backend all say it.
- // The seat stays; the error shows; a later reconnect against
- // the right server walks back in.
- if (this.roomIdPending && /name is taken/.test(msg.message)) {
- localStorage.removeItem(SEAT_KEY);
- }
- if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
- this.roomIdPending = null;
- }
- this.error = msg.message;
- // The toast fades; the chronicle remembers why nothing happened.
- this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
- setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
- break;
+ // While the opening roll-off card is up, the game holds still: the
+ // clockwork's first turn would otherwise play out behind the card
+ // in a blur nobody watched. Its moves wait, then arrive at a pace.
+ if ((this.openingRolls || this.draining) && this.view) {
+ if (msg.type === "state" || msg.type === "events") { this.held.push(msg); return; }
}
+ this.handle(msg);
};
}
+ private held: { type: string }[] = [];
+ private draining = false;
+
+ /** Put the roll-off card away and let the held moves play, a beat apart. */
+ dismissRolls(): void {
+ this.openingRolls = null;
+ if (this.draining) return;
+ this.draining = true;
+ const step = () => {
+ const m = this.held.shift();
+ if (!m || !this.roomId) { this.held = []; this.draining = false; return; }
+ this.handle(m);
+ setTimeout(step, m.type === "events" ? 900 : 0);
+ };
+ step();
+ }
+
+ private handle(msg: any): void {
+ switch (msg.type) {
+ case "pong":
+ break;
+ case "seat": {
+ this.token = msg.token;
+ const seatRoom = (this.roomIdPending ?? this.roomId ?? "").toUpperCase();
+ if (seatRoom) {
+ const seat: Seat = { name: msg.playerId, roomId: seatRoom, token: msg.token };
+ localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
+ this.rememberSeat(seat);
+ }
+ break;
+ }
+ case "watching":
+ this.spectating = true;
+ this.roomId = msg.roomId;
+ break;
+ case "audience":
+ this.audience = msg.count ?? 0;
+ break;
+ case "room":
+ this.roomId = msg.roomId;
+ this.roomColors = msg.colors ?? {};
+ this.roomBots = msg.bots ?? {};
+ this.audience = msg.audience ?? 0;
+ if (this.you && this.token) {
+ const seat: Seat = { name: this.you, roomId: msg.roomId, token: this.token };
+ localStorage.setItem(SEAT_KEY, JSON.stringify(seat));
+ this.rememberSeat(seat);
+ }
+ this.players = msg.players;
+ this.hostId = msg.hostId;
+ this.started = msg.started;
+ break;
+ case "state": {
+ this.view = msg.view;
+ if (typeof msg.seq === "number" && this.roomId && !this.spectating) {
+ this.currentSeq = msg.seq;
+ // Only the FIRST state after arriving carries a gap worth
+ // announcing. Later states were watched live: a caught-up
+ // watcher stays caught up, and an announced gap stays FROZEN
+ // until watched or skipped. A hidden tab accumulates its gap
+ // honestly.
+ if (this.watching === this.roomId && document.visibilityState === "visible") {
+ if (this.missedMoves === 0) this.markSeen();
+ } else {
+ const last = this.seen[this.roomId] ?? 0;
+ this.missedMoves = Math.max(0, msg.seq - last);
+ if (this.missedMoves === 0) this.markSeen();
+ }
+ if (document.visibilityState === "visible") this.watching = this.roomId;
+ }
+ break;
+ }
+ case "catchUp":
+ this.catchUp = msg.steps;
+ break;
+ case "moment":
+ this.moment = { steps: msg.steps, owner: msg.owner };
+ break;
+ case "share":
+ this.shareResolve?.(`${location.origin}/watch/${msg.id}`);
+ this.shareResolve = null;
+ this.shareReject = null;
+ break;
+ case "events": {
+ let talk = 0;
+ if (!msg.replayed) this.onFx?.(msg.events as GameEvent[]);
+ for (const e of msg.events as GameEvent[]) {
+ if (e.type === "tableTalk") talk++;
+ 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) {
+ // Every turn wears its eye, on the header line that opens
+ // it — the players judge what is share-worthy. The game-
+ // winning line carries its own besides.
+ const notable = this.turnCounter >= 0 &&
+ (e.type === "gameWon" || TURN_BOUNDARY.has(e.type));
+ this.log = [...this.log, { text: line, turn: this.turnCounter >= 0 ? this.turnCounter : null, notable }];
+ }
+ }
+ if (talk > 0) {
+ this.chatCount += talk;
+ if (this.roomId) this.markChatSeen();
+ }
+ break;
+ }
+ case "kicked":
+ this.log = [...this.log, { text: `— the host cleared your seat in ${msg.roomId} —`, turn: null, notable: false }];
+ this.leaveLocal();
+ break;
+ case "roomAbandoned":
+ this.log = [...this.log, { text: `— the host closed room ${msg.roomId} —`, turn: null, notable: false }];
+ this.leaveLocal();
+ break;
+ case "transferCode":
+ this.transferCode = { code: msg.code, expiresAt: msg.expiresAt };
+ break;
+ case "transferClaimed": {
+ const seat = msg.seat as Seat & { roomId: string };
+ this.rememberSeat({ name: seat.name, roomId: seat.roomId, token: seat.token });
+ this.refreshGames();
+ this.resume({ name: seat.name, roomId: seat.roomId, token: seat.token });
+ break;
+ }
+ case "chat": {
+ this.log = [...this.log, { text: `\u{1F4AC} ${msg.player}: ${msg.text}`, turn: null, notable: false }];
+ this.chatCount += 1;
+ if (this.roomId) this.markChatSeen();
+ break;
+ }
+ case "stats": {
+ this.stats = msg.stats;
+ break;
+ }
+ case "feedbackList":
+ this.feedbackReports = msg.reports ?? [];
+ break;
+ case "feedbackReceived":
+ this.refreshFeedback();
+ break;
+ case "games": {
+ this.games = msg.games;
+ for (const g of msg.games as GameSummary[]) {
+ const key = `${g.roomId}:${g.name}`;
+ const was = this.lastYourTurn.get(key) ?? false;
+ if (g.yourTurn && !was) this.notifyTurn(g);
+ this.lastYourTurn.set(key, g.yourTurn);
+ }
+ // Prune ONLY seats the server proved dead: the room exists and
+ // refused this exact token. A seat merely ABSENT from the reply
+ // stays — a restarting server, a stale restore, or the wrong
+ // backend all answer with ignorance, and ignorance is not
+ // deletion. The wallet is capped by age instead of by trust.
+ const voided = new Set((msg.voided as string[] | undefined) ?? []);
+ let kept = this.seats.filter((s) => !voided.has(`${s.roomId}:${s.name}`));
+ if (kept.length > 50) kept = kept.slice(kept.length - 50);
+ if (kept.length !== this.seats.length) {
+ this.seats = kept;
+ saveSeats(this.seats);
+ }
+ break;
+ }
+ case "error":
+ // "Name is taken" means THIS token was tried and refused: the
+ // credential itself is dead, and holding it helps nobody. But
+ // "no such room" proves nothing about the seat — a restarting
+ // server, a stale restore, or the wrong backend all say it.
+ // The seat stays; the error shows; a later reconnect against
+ // the right server walks back in.
+ if (this.roomIdPending && /name is taken/.test(msg.message)) {
+ localStorage.removeItem(SEAT_KEY);
+ }
+ if (this.roomIdPending && /no such room|name is taken/.test(msg.message)) {
+ this.roomIdPending = null;
+ }
+ this.error = msg.message;
+ // The toast fades; the chronicle remembers why nothing happened.
+ this.log = [...this.log, { text: `— ${msg.message} —`, turn: null, notable: false }];
+ setTimeout(() => { if (this.error === msg.message) this.error = null; }, 5000);
+ break;
+ }
+}
+
private send(message: unknown): void {
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
}
@@ -733,6 +760,8 @@ class Net {
this.roomId = null;
this.roomIdPending = null;
this.view = null;
+ this.held = [];
+ this.draining = false;
this.started = false;
this.players = [];
this.token = null;
diff --git a/packages/web/src/prefs.svelte.ts b/packages/web/src/prefs.svelte.ts
index c255256..1c70522 100644
--- a/packages/web/src/prefs.svelte.ts
+++ b/packages/web/src/prefs.svelte.ts
@@ -31,6 +31,10 @@ export interface Prefs {
tierChosen: boolean;
/** Games this browser has seen through to a winner. */
finishedGames: number;
+ /** The step light: on your turn, the squares a stride can reach glow
+ * and the rest of the maze falls into shadow. On for a browser's first
+ * game, off once that game is won, and yours to switch on again. */
+ stepLight: boolean;
}
const KEY = "wizwar-prefs";
@@ -39,7 +43,7 @@ function load(): Prefs {
const fallback: Prefs = {
art: "photo", autoGrab: false, flourishes: true, wizardName: "", color: null,
cautions: true, botTier: "apprentice", instantReplay: true, liveFp: false, handLeft: false,
- coached: false, tierChosen: false, finishedGames: 0,
+ coached: false, tierChosen: false, finishedGames: 0, stepLight: true,
};
try {
const raw = localStorage.getItem(KEY);
@@ -61,6 +65,9 @@ function load(): Prefs {
coached: p.coached === true,
tierChosen: p.tierChosen === true,
finishedGames: typeof p.finishedGames === "number" ? p.finishedGames : 0,
+ // A browser that has already won a game meets the step light switched
+ // off; a fresh one has it on for its first.
+ stepLight: typeof p.stepLight === "boolean" ? p.stepLight : !(typeof p.finishedGames === "number" && p.finishedGames > 0),
};
} catch {
return fallback;