Credibility pass: the seams get sanded
Scoped to everything since the last pass (3308850). Three blind
reviews, every finding verified before touching anything.
Confirmed and fixed: two identical comment-splitting insertions left
doc comments orphaned from their fields (net and local alike); a
51-line CSS fossil of the pre-extraction inline effects survived in
Board.svelte; the rev-13 miss-roll test asserted tautologies while
its comment claimed a check the code never made — it now proves the
die was consumed, and the skeleton is no longer returned as trollId;
the sprite registry's `as never` silently disabled the completeness
its annotation advertised (now a mapped type, one cast at the
dispatch seam); a dead ternary guarded a union that doesn't exist;
Bolt carried a duplicate .fork rule from a color iteration; fxTtl
contradicted three sprites' real animation lengths; the permanence
sentinel was reinvented as a magic 9000 (the engine now exports
isPermanentDuration); CELL was declared thrice (fx.ts now imports
it); App and Replay ran two divergent fx schedulers (one scheduleFx
now, cancellable — stale flourishes can no longer fire after leaving
a game); TokenArt retried missing files forever; the anti-anti
escape guards merge with the gate asymmetry explained; wall-of-fire's
rev-12 carve-out is marked; overLimit ignored a displayed BRAINSTONE
(bots over-discarded by two); botRemark's header mis-stated its own
branches; deliverGold fired on any drop, not a home-base delivery;
escape and win banter never fired from the steps that carry them.
Rejected: "as a human would" (house voice); FxGallery's dev-harness
framing (trimmed one plea, kept the facts).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4528c66057
commit
d2b40ec6f8
@@ -232,18 +232,22 @@ function thiefOfMine(view: GameView) {
|
|||||||
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
|
return livingEnemies(view).find((p) => carriers.has(p.id)) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function overLimit(view: GameView): number {
|
/** The hand limit as this seat experiences it (a displayed BRAINSTONE
|
||||||
return Math.max(0, view.yourHand.length - 7);
|
* raises it by two — mirrors the engine's handLimit). */
|
||||||
|
function handLimitOf(view: GameView): number {
|
||||||
|
return me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function overLimit(view: GameView): number {
|
||||||
|
return Math.max(0, view.yourHand.length - handLimitOf(view));
|
||||||
|
}
|
||||||
|
|
||||||
/** End the turn drawing fully — shedding dead weight first if the hand is
|
/** End the turn drawing fully — shedding dead weight first if the hand is
|
||||||
* too full to take the draw. doEndTurn caps the draw by the room left, so a
|
* too full to take the draw. doEndTurn caps the draw by the room left, so a
|
||||||
* clogged hand never refreshes; only cards the brain has no play for are
|
* clogged hand never refreshes; only cards the brain has no play for are
|
||||||
* shed (good counters and attacks are hoarded, as a human would). */
|
* shed (good counters and attacks are hoarded, as a human would). */
|
||||||
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
|
function endTurnDrawing(view: GameView, tier: TierTraits): Command {
|
||||||
const limit = me(view).displayed.some((c) => c.cardId === "brainstone") ? 9 : 7;
|
const deficit = tier.draw - (handLimitOf(view) - view.yourHand.length);
|
||||||
const deficit = tier.draw - (limit - view.yourHand.length);
|
|
||||||
if (tier.sheds && deficit > 0) {
|
if (tier.sheds && deficit > 0) {
|
||||||
const shed = [...view.yourHand]
|
const shed = [...view.yourHand]
|
||||||
.filter((c) => discardValue(c) <= 3)
|
.filter((c) => discardValue(c) <= 3)
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ const WAND_CARD_IDS = ["blaster-wand", "shift-wand", "sticky-wand", "warp-wand"]
|
|||||||
|
|
||||||
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
|
/** Duration meaning "for the rest of the game" ("This card is permanent."). */
|
||||||
const PERMANENT_TURNS = 1_000_000_000;
|
const PERMANENT_TURNS = 1_000_000_000;
|
||||||
|
/** Whether a sustained duration means "until used/removed", not a count. */
|
||||||
|
export function isPermanentDuration(turns: number): boolean {
|
||||||
|
return turns >= PERMANENT_TURNS / 2;
|
||||||
|
}
|
||||||
|
|
||||||
/** Which square contents block line of sight. */
|
/** Which square contents block line of sight. */
|
||||||
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
|
export const LOS_BLOCKING_CONTENT: Record<SquareContent["kind"], boolean> = {
|
||||||
@@ -4760,11 +4764,11 @@ function doCounteract(
|
|||||||
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
|
if (card.cardId !== "anti-anti") return err("only ANTI-ANTI or ABSORB SPELL can answer a counteraction");
|
||||||
if (!targetCounter) return err("no counteraction to nullify");
|
if (!targetCounter) return err("no counteraction to nullify");
|
||||||
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
|
// "Does not work against escape, such as SHRINK, TELEPORT, or INVISIBLE"
|
||||||
// (the card face; rules rev 6 — earlier games replay their old chains).
|
// (the card face). Teleport is rev-6-gated — earlier games replay their
|
||||||
if ((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport") {
|
// old chains; invisible needs no gate, since no earlier engine ever
|
||||||
return err("ANTI-ANTI does not work against escape");
|
// accepted it as a counteraction for a ledger to contain.
|
||||||
}
|
if (targetCounter.card.cardId === "invisible" ||
|
||||||
if (targetCounter.card.cardId === "invisible") {
|
((state.config.deckRev ?? 1) >= 6 && targetCounter.card.cardId === "teleport")) {
|
||||||
return err("ANTI-ANTI does not work against escape");
|
return err("ANTI-ANTI does not work against escape");
|
||||||
}
|
}
|
||||||
takeFromHand(player, instanceId);
|
takeFromHand(player, instanceId);
|
||||||
@@ -4801,6 +4805,8 @@ function doPass(prev: GameState, playerId: PlayerId): CommandResult {
|
|||||||
stack.counters.some((c) =>
|
stack.counters.some((c) =>
|
||||||
!c.nullified &&
|
!c.nullified &&
|
||||||
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
(TOTAL_STOP_COUNTERS.has(c.card.cardId) ||
|
||||||
|
// Wall-of-fire counters pre-date the total-stop rule; their stored
|
||||||
|
// exchanges bounced, and replay so (rules rev 12).
|
||||||
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
|
(rev >= 12 && c.card.cardId === "wall-of-fire")) &&
|
||||||
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
(stack.kind === "spell" || c.card.cardId === "teleport"));
|
||||||
if (!totalStop) {
|
if (!totalStop) {
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("automaton vs automaton", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("the clockwork does not waste counters on pointless targets", () => {
|
describe("the clockwork does not waste counters on pointless targets", () => {
|
||||||
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: any, defender: string) => void) {
|
function underAttack(attackId: string, defenderHand: { instanceId: string; cardId: string }[], rig?: (s: GameState, defender: string) => void) {
|
||||||
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
let { state } = createGame({ playerIds: ["human", "bot"], seed: 42, sets: ["basic", "expansion1"], deckRev: 13 });
|
||||||
// burn round 1
|
// burn round 1
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
@@ -131,8 +131,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
|||||||
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
if (!r.ok) throw new Error(`setup: ${r.error}`);
|
||||||
state = r.state;
|
state = r.state;
|
||||||
}
|
}
|
||||||
const human = state.players.find((p: { id: string }) => p.id === "human")!;
|
const human = state.players.find((p) => p.id === "human")!;
|
||||||
const bot = state.players.find((p: { id: string }) => p.id === "bot")!;
|
const bot = state.players.find((p) => p.id === "bot")!;
|
||||||
bot.position = { ...human.position };
|
bot.position = { ...human.position };
|
||||||
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
defenderHand.forEach((c, i) => { bot.hand[i] = c; });
|
||||||
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
human.hand[0] = { instanceId: `${attackId}#A`, cardId: attackId };
|
||||||
@@ -160,8 +160,8 @@ describe("the clockwork does not waste counters on pointless targets", () => {
|
|||||||
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
{ instanceId: "full-shield#T", cardId: "full-shield" },
|
||||||
{ instanceId: "dagger#T", cardId: "dagger" },
|
{ instanceId: "dagger#T", cardId: "dagger" },
|
||||||
], (s, defender) => {
|
], (s, defender) => {
|
||||||
const d = s.players.find((p: { id: string }) => p.id === defender)!;
|
const d = s.players.find((p) => p.id === defender)!;
|
||||||
const t = s.treasures.find((t: { owner: string }) => t.owner !== defender)!;
|
const t = s.treasures.find((t) => t.owner !== defender)!;
|
||||||
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
t.position = null; t.carriedBy = defender; d.carriedTreasureId = t.id;
|
||||||
});
|
});
|
||||||
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage");
|
||||||
|
|||||||
@@ -466,28 +466,23 @@ describe("monsters roll to hit the hidden (rules rev 13)", () => {
|
|||||||
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
id: "fx1", cardId: "invisible", casterId: victim.id, targetId: victim.id,
|
||||||
remainingTurns: 3, data: {},
|
remainingTurns: 3, data: {},
|
||||||
});
|
});
|
||||||
return { state, me, victim, trollId: bones.id };
|
return { state, me, victim, skeletonId: bones.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
it("rev 13: the skeleton's blow takes the 1-in-4 roll — 'any attack' means any", () => {
|
||||||
// Deterministic seed: this particular swing misses and the blow dissipates.
|
let { state, me, victim, skeletonId } = creatureVsInvisible(13);
|
||||||
let { state, me, victim, trollId } = creatureVsInvisible(13);
|
const rngBefore = JSON.stringify(state.rng);
|
||||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
|
||||||
state = must(state, victim.id, { type: "pass" });
|
state = must(state, victim.id, { type: "pass" });
|
||||||
const rolled = state.rng;
|
// The die was consumed, whichever way it landed.
|
||||||
expect(rolled).toBeDefined();
|
expect(JSON.stringify(state.rng)).not.toBe(rngBefore);
|
||||||
const after = state.players.find((p) => p.id === victim.id)!;
|
|
||||||
const missed = after.life === lifeBefore;
|
|
||||||
// Whichever way seed 42's die lands, the roll HAPPENED: a die event is in the chronicle.
|
|
||||||
expect(missed || after.life < lifeBefore).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
it("earlier revisions keep the old certainty: no roll, the blow just lands", () => {
|
||||||
let { state, me, victim, trollId } = creatureVsInvisible(12);
|
let { state, me, victim, skeletonId } = creatureVsInvisible(12);
|
||||||
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
const lifeBefore = state.players.find((p) => p.id === victim.id)!.life;
|
||||||
const rngBefore = JSON.stringify(state.rng);
|
const rngBefore = JSON.stringify(state.rng);
|
||||||
state = must(state, me, { type: "creatureAttack", creatureId: trollId, targetId: victim.id });
|
state = must(state, me, { type: "creatureAttack", creatureId: skeletonId, targetId: victim.id });
|
||||||
state = must(state, victim.id, { type: "pass" });
|
state = must(state, victim.id, { type: "pass" });
|
||||||
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
expect(state.players.find((p) => p.id === victim.id)!.life).toBeLessThan(lifeBefore);
|
||||||
// No die was consumed on the way.
|
// No die was consumed on the way.
|
||||||
|
|||||||
@@ -193,22 +193,25 @@ function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): vo
|
|||||||
*/
|
*/
|
||||||
const BOT_STEP_MS = 1000;
|
const BOT_STEP_MS = 1000;
|
||||||
|
|
||||||
/** What a step's event means to this bot — its own deed when it acted,
|
/** What a step's event means to this bot — its own deeds when it acted,
|
||||||
* its own suffering when somebody else did. Null: nothing worth a word. */
|
* its own suffering whoever caused it. Null: nothing worth a word. */
|
||||||
function banterTrigger(
|
function banterTrigger(
|
||||||
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
e: { type: string; [k: string]: unknown }, seat: string, actor: string,
|
||||||
): BanterTrigger | null {
|
): BanterTrigger | null {
|
||||||
if (actor === seat) {
|
if (actor === seat) {
|
||||||
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
if (e.type === "treasurePickedUp" && e.player === seat) return "grabGold";
|
||||||
if (e.type === "treasureDropped" && e.player === seat) return "deliverGold";
|
if (e.type === "treasureDropped" && e.player === seat && e.onHomeOf != null) return "deliverGold";
|
||||||
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
if (e.type === "damaged" && e.player !== seat) return "dealPain";
|
||||||
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
if (e.type === "died" && e.killedBy === seat && e.player !== seat) return "kill";
|
||||||
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
if (e.type === "creatureCreated" && e.controller === seat) return "summon";
|
||||||
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
if (e.type === "wallCreated" && e.caster === seat) return "buildWall";
|
||||||
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
|
||||||
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
if (e.type === "trapSprung" && e.player === seat) return "springTrap";
|
||||||
if (e.type === "gameWon" && e.player === seat) return "win";
|
|
||||||
}
|
}
|
||||||
|
// A counter-teleport escape resolves during the ATTACKER's step, and a
|
||||||
|
// last-standing win can land on the victim's turn: self-referential
|
||||||
|
// triggers hold whoever acted.
|
||||||
|
if (e.type === "teleported" && e.player === seat && e.by === seat) return "escape";
|
||||||
|
if (e.type === "gameWon" && e.player === seat) return "win";
|
||||||
if (e.type === "damaged" && e.player === seat) return "takePain";
|
if (e.type === "damaged" && e.player === seat) return "takePain";
|
||||||
if (e.type === "died" && e.player === seat) return "die";
|
if (e.type === "died" && e.player === seat) return "die";
|
||||||
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
if (e.type === "attackMissed" && e.defender === seat) return "dodge";
|
||||||
|
|||||||
+18
-12
@@ -7,10 +7,10 @@
|
|||||||
import Help from "./Help.svelte";
|
import Help from "./Help.svelte";
|
||||||
import Replay from "./Replay.svelte";
|
import Replay from "./Replay.svelte";
|
||||||
import FxGallery from "./FxGallery.svelte";
|
import FxGallery from "./FxGallery.svelte";
|
||||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
import { local } from "./local.svelte";
|
import { local } from "./local.svelte";
|
||||||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView, eligibleCellsFor } from "@wizwar/engine";
|
||||||
import type { CardInstance, Side } from "@wizwar/engine";
|
import type { CardInstance, Side } from "@wizwar/engine";
|
||||||
|
|
||||||
net.connect();
|
net.connect();
|
||||||
@@ -37,22 +37,28 @@
|
|||||||
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
|
localStorage.setItem("wizwar-no-fanfare", announce ? "0" : "1");
|
||||||
}
|
}
|
||||||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||||||
|
const fxWorkshop = new URLSearchParams(location.search).has("fx");
|
||||||
let attackNoticeSeen = $state<string | null>(null);
|
let attackNoticeSeen = $state<string | null>(null);
|
||||||
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
/** Live spell flourishes on the board (cosmetic, self-expiring). */
|
||||||
let boardFx = $state<BoardFx[]>([]);
|
let boardFx = $state<BoardFx[]>([]);
|
||||||
function playFx(events: Parameters<typeof fxForEvents>[0]) {
|
let fxCancels: (() => void)[] = [];
|
||||||
|
function playFx(events: Parameters<typeof scheduleFx>[0]) {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
for (const { fx, delay } of fxForEvents(events, view)) {
|
fxCancels.push(scheduleFx(
|
||||||
setTimeout(() => {
|
events, view,
|
||||||
boardFx = [...boardFx, fx];
|
(fx) => (boardFx = [...boardFx, fx]),
|
||||||
setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind));
|
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||||
}, delay);
|
));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
net.onFx = playFx;
|
net.onFx = playFx;
|
||||||
local.onFx = playFx;
|
local.onFx = playFx;
|
||||||
return () => { net.onFx = null; local.onFx = null; };
|
return () => {
|
||||||
|
net.onFx = null;
|
||||||
|
local.onFx = null;
|
||||||
|
fxCancels.forEach((c) => c());
|
||||||
|
fxCancels = [];
|
||||||
|
};
|
||||||
});
|
});
|
||||||
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
const openingRolls = $derived(net.openingRolls ?? local.openingRolls);
|
||||||
function dismissRolls() {
|
function dismissRolls() {
|
||||||
@@ -906,7 +912,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if new URLSearchParams(location.search).has("fx")}
|
{#if fxWorkshop}
|
||||||
<FxGallery />
|
<FxGallery />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="table">
|
<div class="table">
|
||||||
@@ -1413,7 +1419,7 @@
|
|||||||
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
||||||
peekCreatureId = null;
|
peekCreatureId = null;
|
||||||
}}>
|
}}>
|
||||||
✦ {spellName(e.cardId)}{e.remainingTurns < 9000 ? ` · ${e.remainingTurns}` : ""}
|
✦ {spellName(e.cardId)}{isPermanentDuration(e.remainingTurns) ? "" : ` · ${e.remainingTurns}`}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Component } from "svelte";
|
||||||
import type { GameView } from "@wizwar/engine";
|
import type { GameView } from "@wizwar/engine";
|
||||||
import type { Side } from "@wizwar/engine";
|
import type { Side } from "@wizwar/engine";
|
||||||
|
import type { BoardFx } from "./fx";
|
||||||
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
||||||
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
|
||||||
import TokenArt from "./TokenArt.svelte";
|
import TokenArt from "./TokenArt.svelte";
|
||||||
@@ -51,11 +53,10 @@
|
|||||||
ghostSlots?: { x: number; y: number }[] | null;
|
ghostSlots?: { x: number; y: number }[] | null;
|
||||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||||
/** Short-lived spell flourishes; purely cosmetic. */
|
/** Short-lived spell flourishes; purely cosmetic. */
|
||||||
effects?: import("./fx").BoardFx[] | null;
|
effects?: BoardFx[] | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const SECTOR = 5;
|
const SECTOR = 5;
|
||||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||||
* negative coordinates (the maze renormalizes after the landing). The
|
* negative coordinates (the maze renormalizes after the landing). The
|
||||||
@@ -587,8 +588,9 @@
|
|||||||
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
<!-- spell flourishes: one sprite component per effect (src/fx-sprites/) -->
|
||||||
<g class="fx-layer" aria-hidden="true">
|
<g class="fx-layer" aria-hidden="true">
|
||||||
{#each effects ?? [] as fx (fx.id)}
|
{#each effects ?? [] as fx (fx.id)}
|
||||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
<!-- One cast at the dispatch seam; the registry stays exhaustive. -->
|
||||||
<Sprite fx={fx as never} />
|
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||||
|
<Sprite {fx} />
|
||||||
{/each}
|
{/each}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -749,57 +751,6 @@
|
|||||||
stroke-width: 1.6;
|
stroke-width: 1.6;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
|
||||||
.fx-dust.late { animation-delay: 0.09s; }
|
|
||||||
.fx-dust.later { animation-delay: 0.17s; }
|
|
||||||
|
|
||||||
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
|
||||||
@keyframes fx-flicker {
|
|
||||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
|
||||||
60% { opacity: 1; } 100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes fx-ring {
|
|
||||||
0% { opacity: 0.95; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(5); }
|
|
||||||
}
|
|
||||||
@keyframes fx-ring-small {
|
|
||||||
0% { opacity: 0.9; transform: scale(1); }
|
|
||||||
100% { opacity: 0; transform: scale(2.6); }
|
|
||||||
}
|
|
||||||
@keyframes fx-shield-pulse {
|
|
||||||
0% { opacity: 0; transform: scale(0.6); }
|
|
||||||
30% { opacity: 1; transform: scale(1.1); }
|
|
||||||
60% { transform: scale(0.95); }
|
|
||||||
100% { opacity: 0; transform: scale(1.15); }
|
|
||||||
}
|
|
||||||
@keyframes fx-drop {
|
|
||||||
0% { opacity: 0.9; transform: translateY(0); }
|
|
||||||
100% { opacity: 0; transform: translateY(-14px); }
|
|
||||||
}
|
|
||||||
@keyframes fx-drift {
|
|
||||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
|
||||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
|
||||||
}
|
|
||||||
@keyframes fx-pow-hit {
|
|
||||||
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
|
||||||
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
|
||||||
55% { transform: scale(1) rotate(0deg); }
|
|
||||||
100% { opacity: 0; transform: scale(1.05); }
|
|
||||||
}
|
|
||||||
@keyframes fx-claw-rake {
|
|
||||||
0% { opacity: 0; transform: translateY(-6px); }
|
|
||||||
20% { opacity: 1; }
|
|
||||||
100% { opacity: 0; transform: translateY(6px); }
|
|
||||||
}
|
|
||||||
@keyframes fx-swallow {
|
|
||||||
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
|
||||||
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
|
||||||
}
|
|
||||||
@keyframes fx-spin {
|
|
||||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
|
||||||
30% { opacity: 1; }
|
|
||||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
|
||||||
}
|
|
||||||
.warp-dest {
|
.warp-dest {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #2e7d32;
|
stroke: #2e7d32;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// The flourish workshop: every effect sprite on a loop, labeled, against a
|
// The flourish workshop: every effect sprite on a loop, labeled, against a
|
||||||
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
|
// mini-maze. Open with /?fx while `npm run dev` runs — edits to any sprite
|
||||||
// file hot-reload here mid-animation. Not linked from anywhere; harmless.
|
// file hot-reload here mid-animation.
|
||||||
|
import type { Component } from "svelte";
|
||||||
import { FX_SPRITES } from "./fx-sprites";
|
import { FX_SPRITES } from "./fx-sprites";
|
||||||
import type { BoardFx } from "./fx";
|
import type { BoardFx, FxShape } from "./fx";
|
||||||
|
|
||||||
let tick = $state(0);
|
let tick = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
{ kind: "edge-dust", cell: mid, side: "S" },
|
{ kind: "edge-dust", cell: mid, side: "S" },
|
||||||
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
|
{ kind: "sector-spin", origin: { x: 0, y: 0 }, clockwise: true },
|
||||||
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
|
{ kind: "sector-slide", from: { x: 0, y: 0 }, to: { x: 1, y: 0 } },
|
||||||
] as unknown as BoardFx[]).map((f, i) => ({ ...f, id: i + 1 }));
|
] as FxShape[]).map((f, i): BoardFx => ({ ...f, id: i + 1 }));
|
||||||
|
|
||||||
const isSector = (k: string) => k.startsWith("sector-");
|
const isSector = (k: string) => k.startsWith("sector-");
|
||||||
</script>
|
</script>
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{#each SAMPLES as fx (fx.kind)}
|
{#each SAMPLES as fx (fx.kind)}
|
||||||
{@const Sprite = FX_SPRITES[fx.kind]}
|
{@const Sprite = FX_SPRITES[fx.kind] as Component<{ fx: BoardFx }>}
|
||||||
<figure>
|
<figure>
|
||||||
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
|
<svg viewBox={isSector(fx.kind) ? "-4 -4 296 296" : "-4 -4 152 152"}>
|
||||||
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
|
{#each Array.from({ length: isSector(fx.kind) ? 6 : 3 }, (_, cy) => cy) as cy (cy)}
|
||||||
@@ -64,7 +65,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
{#key tick}
|
{#key tick}
|
||||||
<g class="fx-layer"><Sprite fx={fx as never} /></g>
|
<g class="fx-layer"><Sprite {fx} /></g>
|
||||||
{/key}
|
{/key}
|
||||||
</svg>
|
</svg>
|
||||||
<figcaption>{fx.kind}</figcaption>
|
<figcaption>{fx.kind}</figcaption>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Board from "./Board.svelte";
|
import Board from "./Board.svelte";
|
||||||
import { humanize } from "./net.svelte";
|
import { humanize } from "./net.svelte";
|
||||||
import { fxForEvents, fxTtl, type BoardFx } from "./fx";
|
import { scheduleFx, type BoardFx } from "./fx";
|
||||||
import type { GameEvent, GameView } from "@wizwar/engine";
|
import type { GameEvent, GameView } from "@wizwar/engine";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -26,14 +26,12 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const step = steps[Math.min(idx, steps.length - 1)];
|
const step = steps[Math.min(idx, steps.length - 1)];
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
const cancel = scheduleFx(
|
||||||
for (const { fx, delay } of fxForEvents(step.events, step.view)) {
|
step.events, step.view,
|
||||||
timers.push(setTimeout(() => {
|
(fx) => (boardFx = [...boardFx, fx]),
|
||||||
boardFx = [...boardFx, fx];
|
(id) => (boardFx = boardFx.filter((f) => f.id !== id)),
|
||||||
timers.push(setTimeout(() => (boardFx = boardFx.filter((f) => f.id !== fx.id)), fxTtl(fx.kind)));
|
);
|
||||||
}, delay));
|
return () => { cancel(); boardFx = []; };
|
||||||
}
|
|
||||||
return () => { timers.forEach(clearTimeout); boardFx = []; };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// are cached per URL, with their ids namespaced so 45 tokens' worth of
|
// are cached per URL, with their ids namespaced so 45 tokens' worth of
|
||||||
// filter/pattern defs cannot collide in one document.
|
// filter/pattern defs cannot collide in one document.
|
||||||
type Entry = { vb: string; inner: string };
|
type Entry = { vb: string; inner: string };
|
||||||
const cache = new SvelteMap<string, Entry | "pending">();
|
const cache = new SvelteMap<string, Entry | "pending" | "failed">();
|
||||||
|
|
||||||
function load(href: string): void {
|
function load(href: string): void {
|
||||||
if (cache.has(href)) return;
|
if (cache.has(href)) return;
|
||||||
@@ -24,7 +24,9 @@
|
|||||||
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
|
const vb = m ? (/viewBox="([^"]*)"/.exec(m[1]!)?.[1] ?? "0 0 144 150") : "0 0 144 150";
|
||||||
cache.set(href, { vb, inner: m?.[2] ?? "" });
|
cache.set(href, { vb, inner: m?.[2] ?? "" });
|
||||||
})
|
})
|
||||||
.catch(() => cache.delete(href));
|
// A failed fetch stays failed: deleting would retrigger the effect
|
||||||
|
// and hammer a missing file forever.
|
||||||
|
.catch(() => cache.set(href, "failed"));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isSvg}
|
{#if isSvg}
|
||||||
{#if entry && entry !== "pending"}
|
{#if entry && entry !== "pending" && entry !== "failed"}
|
||||||
<svg {x} {y} {width} {height} viewBox={entry.vb}
|
<svg {x} {y} {width} {height} viewBox={entry.vb}
|
||||||
preserveAspectRatio="xMidYMid slice" class={cls}>
|
preserveAspectRatio="xMidYMid slice" class={cls}>
|
||||||
{#if title}<title>{title}</title>{/if}
|
{#if title}<title>{title}</title>{/if}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
.bolt .fork {
|
.bolt .fork {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #fff6b0;
|
stroke: #f5d54a;
|
||||||
stroke-width: 1.6;
|
stroke-width: 1.6;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
.bolt .fork { stroke: #f5d54a; }
|
|
||||||
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
|
.bolt { animation: bolt-flicker 0.5s steps(2, jump-none) forwards; }
|
||||||
@keyframes bolt-flicker {
|
@keyframes bolt-flicker {
|
||||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
<g style={`--ax: ${away.x}px; --ay: ${away.y}px; --bx: ${-away.x}px; --by: ${away.y}px`}>
|
||||||
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff there" />
|
<circle cx={c.x - 6} cy={c.y} r="7" fill={`url(#dust-soft-${uid})`} class="puff" />
|
||||||
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
|
<circle cx={c.x + 5} cy={c.y - 2} r="5" fill={`url(#dust-soft-${uid})`} class="puff back late" />
|
||||||
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff there later" />
|
<circle cx={c.x} cy={c.y + 3} r="6" fill={`url(#dust-soft-${uid})`} class="puff later" />
|
||||||
<g class="grit">
|
<g class="grit">
|
||||||
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
|
<circle cx={c.x - 4} cy={c.y - 5} r="1.3" />
|
||||||
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
|
<circle cx={c.x + 6} cy={c.y + 2} r="1.6" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
|
// file; to add one, create the component, extend BoardFx in ../fx.ts, and
|
||||||
// register it here.
|
// register it here.
|
||||||
import type { Component } from "svelte";
|
import type { Component } from "svelte";
|
||||||
import type { BoardFx } from "../fx";
|
import type { BoardFx, FxOf } from "../fx";
|
||||||
import Absorb from "./Absorb.svelte";
|
import Absorb from "./Absorb.svelte";
|
||||||
import Bolt from "./Bolt.svelte";
|
import Bolt from "./Bolt.svelte";
|
||||||
import Burst from "./Burst.svelte";
|
import Burst from "./Burst.svelte";
|
||||||
@@ -29,7 +29,7 @@ import ThornSnap from "./ThornSnap.svelte";
|
|||||||
import Waterbolt from "./Waterbolt.svelte";
|
import Waterbolt from "./Waterbolt.svelte";
|
||||||
import Whiff from "./Whiff.svelte";
|
import Whiff from "./Whiff.svelte";
|
||||||
|
|
||||||
export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
export const FX_SPRITES: { [K in BoardFx["kind"]]: Component<{ fx: FxOf<K> }> } = {
|
||||||
fireball: Fireball,
|
fireball: Fireball,
|
||||||
waterbolt: Waterbolt,
|
waterbolt: Waterbolt,
|
||||||
bolt: Bolt,
|
bolt: Bolt,
|
||||||
@@ -58,4 +58,4 @@ export const FX_SPRITES: Record<BoardFx["kind"], Component<{ fx: never }>> = {
|
|||||||
"edge-dust": DustPuff,
|
"edge-dust": DustPuff,
|
||||||
"sector-spin": SectorGrind,
|
"sector-spin": SectorGrind,
|
||||||
"sector-slide": SectorGrind,
|
"sector-slide": SectorGrind,
|
||||||
} as never;
|
};
|
||||||
|
|||||||
+24
-3
@@ -9,12 +9,12 @@ type Cell = { x: number; y: number };
|
|||||||
type Pt = { x: number; y: number };
|
type Pt = { x: number; y: number };
|
||||||
type Side = "N" | "E" | "S" | "W";
|
type Side = "N" | "E" | "S" | "W";
|
||||||
|
|
||||||
const CELL = 48;
|
import { CELL } from "./fx-sprites/geom";
|
||||||
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
|
const cellMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
|
||||||
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
|
/** Where a wizard token's center sits in a cell (mirrors Board.svelte). */
|
||||||
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
|
const wizMid = (c: Cell): Pt => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL * 0.36 });
|
||||||
|
|
||||||
type FxShape =
|
export type FxShape =
|
||||||
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
|
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; a: Pt; b: Pt }
|
||||||
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
|
||||||
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
| "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
|
||||||
@@ -134,7 +134,7 @@ export function fxForEvents(
|
|||||||
break;
|
break;
|
||||||
case "creatureAttacked": {
|
case "creatureAttacked": {
|
||||||
// The target may be a wizard or a fellow creature.
|
// The target may be a wizard or a fellow creature.
|
||||||
const at = posOf(typeof e.target === "string" ? e.target : null) ??
|
const at = posOf(e.target) ??
|
||||||
(() => {
|
(() => {
|
||||||
const c = view.creatures.find((c) => c.id === e.target);
|
const c = view.creatures.find((c) => c.id === e.target);
|
||||||
return c ? { ...c.position } : null;
|
return c ? { ...c.position } : null;
|
||||||
@@ -306,3 +306,24 @@ export function fxForEvents(
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schedule a batch's effects into `add`, expiring each after its run.
|
||||||
|
* Returns a cancel that stops pending starts and sweeps what began. */
|
||||||
|
export function scheduleFx(
|
||||||
|
events: GameEvent[], view: GameView,
|
||||||
|
add: (fx: BoardFx) => void, remove: (id: number) => void,
|
||||||
|
): () => void {
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const started: number[] = [];
|
||||||
|
for (const { fx, delay } of fxForEvents(events, view)) {
|
||||||
|
timers.push(setTimeout(() => {
|
||||||
|
started.push(fx.id);
|
||||||
|
add(fx);
|
||||||
|
timers.push(setTimeout(() => remove(fx.id), fxTtl(fx.kind)));
|
||||||
|
}, delay));
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
timers.forEach(clearTimeout);
|
||||||
|
started.forEach(remove);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ class LocalGame {
|
|||||||
/** Set while the device should be handed to the named player. */
|
/** Set while the device should be handed to the named player. */
|
||||||
handoffTo = $state<PlayerId | null>(null);
|
handoffTo = $state<PlayerId | null>(null);
|
||||||
log = $state<string[]>([]);
|
log = $state<string[]>([]);
|
||||||
/** The finished game as a reel, each step from its actor's own seat. */
|
|
||||||
/** The opening roll-off, shown once as the boards flip. */
|
/** The opening roll-off, shown once as the boards flip. */
|
||||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||||
/** Board flourishes: the app hooks in to animate command results. */
|
/** Board flourishes: the app hooks in to animate command results. */
|
||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
onFx: ((events: GameEvent[]) => void) | null = null;
|
||||||
|
/** The finished game as a reel, each step from its actor's own seat. */
|
||||||
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
replaySteps = $state<{ seq: number; actor: PlayerId; events: GameEvent[]; view: GameView }[] | null>(null);
|
||||||
view = $derived(
|
view = $derived(
|
||||||
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
this.gameState && this.viewerId ? viewFor(this.gameState, this.viewerId) : null,
|
||||||
|
|||||||
@@ -250,11 +250,11 @@ class Net {
|
|||||||
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
|
transferCode = $state<{ code: string; expiresAt: number } | null>(null);
|
||||||
/** Moves you haven't watched yet in the current room. */
|
/** Moves you haven't watched yet in the current room. */
|
||||||
missedMoves = $state(0);
|
missedMoves = $state(0);
|
||||||
/** A catch-up reel delivered by the server. */
|
|
||||||
/** The opening roll-off, shown once as the boards flip. */
|
/** The opening roll-off, shown once as the boards flip. */
|
||||||
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
openingRolls = $state<{ rolls: Record<string, number[]>; first: string; players: string[] } | null>(null);
|
||||||
/** Board flourishes: the app hooks in to animate live event batches. */
|
/** Board flourishes: the app hooks in to animate live event batches. */
|
||||||
onFx: ((events: GameEvent[]) => void) | null = null;
|
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);
|
catchUp = $state<{ seq: number; actor: string; events: GameEvent[]; view: GameView; chat?: { player: string; text: string }[] }[] | null>(null);
|
||||||
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
|
||||||
@@ -326,8 +326,8 @@ class Net {
|
|||||||
// Only the FIRST state after arriving carries a gap worth
|
// Only the FIRST state after arriving carries a gap worth
|
||||||
// announcing. Later states were watched live: a caught-up
|
// announcing. Later states were watched live: a caught-up
|
||||||
// watcher stays caught up, and an announced gap stays FROZEN
|
// watcher stays caught up, and an announced gap stays FROZEN
|
||||||
// (not grown, not wiped) until watched or skipped. A hidden
|
// until watched or skipped. A hidden tab accumulates its gap
|
||||||
// tab accumulates its gap honestly.
|
// honestly.
|
||||||
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
if (this.watching === this.roomId && document.visibilityState === "visible") {
|
||||||
if (this.missedMoves === 0) this.markSeen();
|
if (this.missedMoves === 0) this.markSeen();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user