At the table the discards sit face-up for anyone to leaf through. The view now carries the pile (deck order stays secret), the "discards N" count in the rail became a link, and it opens a booklet-styled reader: every cast-away card newest first, tap any to enlarge. And the chronicle stops mumbling "discards 1 card(s)" — it names names, so a wraith's theft reads "bob discards Fireball," exactly what a table would see as the card hits the pile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2029 lines
77 KiB
Svelte
2029 lines
77 KiB
Svelte
<script lang="ts">
|
||
import { attentionLabel, net, spellName } from "./net.svelte";
|
||
import Board from "./Board.svelte";
|
||
import { PLAYER_COLORS, wizardColor } from "./colors";
|
||
import Card from "./Card.svelte";
|
||
import Help from "./Help.svelte";
|
||
import Replay from "./Replay.svelte";
|
||
import { local } from "./local.svelte";
|
||
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey, isMovableObject, sightedCellsFor, type GameView } from "@wizwar/engine";
|
||
import type { CardInstance, Side } from "@wizwar/engine";
|
||
|
||
net.connect();
|
||
net.startGamePolling();
|
||
|
||
let name = $state("");
|
||
let joinCode = $state("");
|
||
let claimPhrase = $state("");
|
||
let showHelp = $state(false);
|
||
/** The finished game whose victory fanfare has been dismissed. */
|
||
let victorySeen = $state(false);
|
||
/** Which pending attack the player has already acknowledged (modal dismissed). */
|
||
let attackNoticeSeen = $state<string | null>(null);
|
||
/** A face-up card being peeked at from the scoresheet (view-only). */
|
||
let peekCard = $state<CardInstance | null>(null);
|
||
/** When the peeked card is a creature on the board, its live stats ride along. */
|
||
let peekCreatureId = $state<string | null>(null);
|
||
/** Bare-knuckle demolition: click a wall to punch it. */
|
||
let punchWallMode = $state(false);
|
||
/** Leafing through the face-up discard pile. */
|
||
let showDiscards = $state(false);
|
||
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
|
||
let hotseatCount = $state(2);
|
||
let setupName = $state("");
|
||
let setupColor = $state(0);
|
||
/** Ambush arming flow: the Interrupt/OF card, trigger, and committed attack. */
|
||
let ambushVia = $state<CardInstance | null>(null);
|
||
let ambushTrigger = $state<"los" | "near" | "treasure" | null>(null);
|
||
let ambushSpell = $state<CardInstance | null>(null);
|
||
// Default each new wizard to the first unclaimed standee.
|
||
$effect(() => {
|
||
if (local.setup && local.setup.colors.includes(setupColor)) {
|
||
setupColor = [0, 1, 2, 3, 4, 5].find((c) => !local.setup!.colors.includes(c)) ?? 0;
|
||
}
|
||
});
|
||
/** Tap-to-inspect: hide the enlarged card for the current selection. */
|
||
let inspectorHidden = $state(false);
|
||
$effect(() => {
|
||
void selectedCard?.instanceId;
|
||
inspectorHidden = false;
|
||
});
|
||
let drawCount = $state(2);
|
||
let withExpansion = $state(true);
|
||
/** Your creature selected for movement/attacks. */
|
||
let selectedCreature = $state<string | null>(null);
|
||
|
||
/** Card selected in hand, pending a target. */
|
||
let selectedCard = $state<CardInstance | null>(null);
|
||
/** Number card attached to the pending cast. */
|
||
let attachedNumber = $state<CardInstance | null>(null);
|
||
/** Waterbolt split. */
|
||
let wbDamage = $state(0);
|
||
/** teleport-opponent: player chosen, waiting for the destination cell. */
|
||
let pendingCellFor = $state<string | null>(null);
|
||
/** card-erasure / drop-object: the named card. */
|
||
let nameInput = $state("");
|
||
/** power-run points. */
|
||
let runPoints = $state(1);
|
||
/** Modifier cards attached to the pending cast. */
|
||
let attachedMods = $state<CardInstance[]>([]);
|
||
/** relocate-sector: the sector picked up, awaiting its destination. */
|
||
let pendingSectorFrom = $state<{ x: number; y: number } | null>(null);
|
||
/** boobytrap: cells picked so far (first is the real one). */
|
||
let trapCells = $state<{ x: number; y: number }[]>([]);
|
||
/** trader: first item square picked. */
|
||
let tradeFrom = $state<{ x: number; y: number } | null>(null);
|
||
/** rotate-sector direction. */
|
||
let rotateCW = $state(true);
|
||
/** Cards marked for discard. */
|
||
let discardSelection = $state<Set<string>>(new Set());
|
||
/** Voluntary discard mode: hand clicks mark cards instead of playing them. */
|
||
let discardMode = $state(false);
|
||
|
||
const view = $derived(local.active ? local.view : net.view);
|
||
const isYourTurn = $derived(view !== null && view.activePlayerId === view.you && !view.stack);
|
||
const youMustRespond = $derived(view?.stack != null && view.stack.waitingOn === view.you);
|
||
const attackNoticeKey = $derived(
|
||
youMustRespond && view?.stack
|
||
? `${view.stack.attackerId}:${view.stack.attackCard?.instanceId ?? view.stack.creatureId ?? "punch"}:${view.stack.counters.length}`
|
||
: null,
|
||
);
|
||
const attackingCreature = $derived(
|
||
view?.stack?.creatureId ? view.creatures.find((c) => c.id === view!.stack!.creatureId) ?? null : null,
|
||
);
|
||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||
const youMustShield = $derived(view?.chaosPending?.queue[0] === view?.you && view != null);
|
||
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
|
||
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
||
const canInterruptNow = $derived(
|
||
view != null && !isYourTurn && !view.stack && !view.chaosPending &&
|
||
!view.outOfTurnWindow && view.phase === "playing" && view.turn.round > 1 && !local.active,
|
||
);
|
||
const interruptCards = $derived(
|
||
canInterruptNow
|
||
? view!.yourHand.filter((c) => c.cardId === "interrupt" || c.cardId === "opportunity-fire")
|
||
: [],
|
||
);
|
||
|
||
const selectedDef = $derived(selectedCard ? cardDef(selectedCard.cardId) : null);
|
||
const EDGE_CARDS = new Set([
|
||
"create-wall", "destroy-wall", "illusion-wall", "wall-of-fire", "waterwall",
|
||
"pick-lock", "jam-lock", "remove-lock", "master-key", "dispel-creation",
|
||
"warp-wand", "create-door", "stone-to-water",
|
||
]);
|
||
const CELL_CARDS = new Set([
|
||
"teleport", "fill-square-with-stone", "thornbush", "dispel-creation", "drag",
|
||
"rotate-sector", "relocate-sector",
|
||
"troll", "skeleton", "wraith", "fire-imp", "democratic-monster", "shadow",
|
||
"killer-ooze", "rosebush", "dust-cloud", "fill-square-with-slime", "create-pit",
|
||
"handful-of-tacks", "glue", "safe", "trader", "stone-to-water", "boobytrap",
|
||
"dimensional-warp", "redirection", "thumb-of-god",
|
||
]);
|
||
const TWO_CELL_CARDS = new Set(["trader", "dimensional-warp", "redirection"]);
|
||
const CREATURE_TARGET_CARDS = new Set(["mega-monster"]);
|
||
const MODIFIER_CARDS = new Set(["amplify", "add", "extend", "around-the-corner"]);
|
||
|
||
const NAMED_CARDS = new Set(["card-erasure", "drop-object", "deja-vu", "thief", "swap-meet", "remove-curse", "swarthmores-enchantment", "illusionary-attack"]);
|
||
const attackVsWall = $derived(
|
||
selectedCard != null && cardDef(selectedCard.cardId).cardType === "attack" && !EDGE_CARDS.has(selectedCard.cardId),
|
||
);
|
||
const edgeSelectMode = $derived(
|
||
(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId)) || attackVsWall || punchWallMode,
|
||
);
|
||
const cellSelectMode = $derived(
|
||
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
||
);
|
||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||
|
||
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||
if (local.active) local.command(command);
|
||
else net.command(command);
|
||
}
|
||
|
||
function clearSelection() {
|
||
punchWallMode = false;
|
||
ambushVia = null;
|
||
ambushTrigger = null;
|
||
ambushSpell = null;
|
||
trapCells = [];
|
||
tradeFrom = null;
|
||
selectedCreature = null;
|
||
selectedCard = null;
|
||
attachedNumber = null;
|
||
attachedMods = [];
|
||
wbDamage = 0;
|
||
pendingCellFor = null;
|
||
pendingSectorFrom = null;
|
||
nameInput = "";
|
||
}
|
||
|
||
/** Fill the modifier fields of a cast command from the attachments. */
|
||
function applyMods(cmd: Parameters<typeof net.command>[0] & { type: "cast" }) {
|
||
for (const m of attachedMods) {
|
||
if (m.cardId === "amplify") (cmd.amplifyInstanceIds ??= []).push(m.instanceId);
|
||
else if (m.cardId === "add") cmd.addInstanceId = m.instanceId;
|
||
else if (m.cardId === "extend") cmd.extendInstanceId = m.instanceId;
|
||
else if (m.cardId === "around-the-corner") cmd.aroundCornerInstanceId = m.instanceId;
|
||
}
|
||
}
|
||
|
||
/** Suggest-as-you-type pool for the card-naming field, tuned per card. */
|
||
const nameSuggestions = $derived.by(() => {
|
||
if (!selectedCard) return [];
|
||
const pool = allCardDefs().filter(
|
||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||
);
|
||
let picks = pool;
|
||
if (selectedCard.cardId === "illusionary-attack") {
|
||
picks = pool.filter((d) => d.cardType === "attack");
|
||
} else if (selectedCard.cardId === "drop-object") {
|
||
picks = pool.filter((d) => isMovableObject(d.id));
|
||
}
|
||
const names = picks.map((d) => d.name).sort();
|
||
return selectedCard.cardId === "drop-object" ? ["Treasure", ...names] : names;
|
||
});
|
||
|
||
/** Map a typed card name to its id (case-insensitive). */
|
||
function nameToCardId(name: string): string | null {
|
||
const wanted = name.trim().toLowerCase();
|
||
if (!wanted) return null;
|
||
if (wanted === "treasure") return "treasure";
|
||
for (const def of allCardDefs()) {
|
||
if (def.name.toLowerCase() === wanted || def.id === wanted) return def.id;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function selectCard(card: CardInstance) {
|
||
if (!view) return;
|
||
peekCard = null;
|
||
peekCreatureId = null;
|
||
if (youMustRespond || youMustShield) {
|
||
dispatch({ type: "counteract", instanceId: card.instanceId });
|
||
return;
|
||
}
|
||
if (youMustDiscard || discardMode || discardSelection.size > 0) {
|
||
const next = new Set(discardSelection);
|
||
next.has(card.instanceId) ? next.delete(card.instanceId) : next.add(card.instanceId);
|
||
discardSelection = next;
|
||
return;
|
||
}
|
||
if (!isYourTurn) {
|
||
// Live interruption: Interrupt / Opportunity Fire may be played during
|
||
// another player's turn (when no attack is pending).
|
||
if (!view.stack && (card.cardId === "interrupt" || card.cardId === "opportunity-fire")) {
|
||
dispatch({ type: "cast", instanceId: card.instanceId });
|
||
}
|
||
return;
|
||
}
|
||
// On your own turn, Interrupt / Opportunity Fire arm an ambush instead.
|
||
if (card.cardId === "interrupt" || card.cardId === "opportunity-fire") {
|
||
clearSelection();
|
||
ambushVia = card;
|
||
return;
|
||
}
|
||
if (ambushVia) {
|
||
if (isNumberCard(card.cardId)) {
|
||
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
||
return;
|
||
}
|
||
if (cardDef(card.cardId).cardType === "attack") {
|
||
ambushSpell = ambushSpell?.instanceId === card.instanceId ? null : card;
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
if (selectedCard && isNumberCard(card.cardId) && !isNumberCard(selectedCard.cardId)) {
|
||
attachedNumber = attachedNumber?.instanceId === card.instanceId ? null : card;
|
||
wbDamage = numberTotal;
|
||
return;
|
||
}
|
||
if (selectedCard && MODIFIER_CARDS.has(card.cardId) && !MODIFIER_CARDS.has(selectedCard.cardId)) {
|
||
attachedMods = attachedMods.some((m) => m.instanceId === card.instanceId)
|
||
? attachedMods.filter((m) => m.instanceId !== card.instanceId)
|
||
: [...attachedMods, card];
|
||
return;
|
||
}
|
||
if (selectedCard?.instanceId === card.instanceId) {
|
||
clearSelection();
|
||
return;
|
||
}
|
||
selectedCard = card;
|
||
attachedNumber = null;
|
||
// Nothing casts on first click: every play is confirmed from the hint
|
||
// bar or by choosing a target. Misclicks cost nothing.
|
||
}
|
||
|
||
/** Untargeted spells confirmed from the hint bar (with optional number). */
|
||
const CONFIRM_CAST = new Set([
|
||
"speed", "pass-through-wall", "reuse-spell", "ugly", "alter-ego", "lifesaver", "mad-dash",
|
||
"gift-from-above", "chaos", "interrupt", "opportunity-fire",
|
||
"bloodstone", "brainstone", "powerstone", "shadowstone",
|
||
"shieldstone", "soulstone", "speedstone", "visionstone",
|
||
"invisible", "shrink", "mist-body", "strength", "empathy", "big-man", "fear", "adrenaline",
|
||
]);
|
||
|
||
function playNumberForMovement() {
|
||
if (!selectedCard || !view) return;
|
||
const cmd: Parameters<typeof net.command>[0] = {
|
||
type: "playNumberForMovement",
|
||
instanceId: selectedCard.instanceId,
|
||
};
|
||
// A second movement number needs an ADD — spend one from hand automatically.
|
||
if (view.turn.numberPlayedForMovement) {
|
||
const add = view.yourHand.find((c) => c.cardId === "add");
|
||
if (add) cmd.addInstanceId = add.instanceId;
|
||
}
|
||
dispatch(cmd);
|
||
clearSelection();
|
||
}
|
||
|
||
function startDiscardMode() {
|
||
clearSelection();
|
||
discardMode = true;
|
||
}
|
||
function cancelDiscardMode() {
|
||
discardMode = false;
|
||
discardSelection = new Set();
|
||
}
|
||
|
||
function castSelfWithNumber() {
|
||
if (!selectedCard) return;
|
||
const cmd: Parameters<typeof net.command>[0] = { type: "cast", instanceId: selectedCard.instanceId };
|
||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||
dispatch(cmd);
|
||
clearSelection();
|
||
}
|
||
|
||
function armAmbush() {
|
||
if (!ambushVia || !ambushTrigger || !ambushSpell) return;
|
||
dispatch({
|
||
type: "setAmbush",
|
||
instanceId: ambushVia.instanceId,
|
||
trigger: { kind: ambushTrigger },
|
||
spellInstanceId: ambushSpell.instanceId,
|
||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||
});
|
||
clearSelection();
|
||
}
|
||
|
||
function castPowerRun() {
|
||
if (!selectedCard) return;
|
||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
||
clearSelection();
|
||
}
|
||
|
||
function clickCell(cell: { x: number; y: number }) {
|
||
if (!view) return;
|
||
if (!isYourTurn) { peekAt(cell); return; }
|
||
if (pendingCellFor && selectedCard) {
|
||
// Stage 2 of teleport-opponent: destination chosen.
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "player", playerId: pendingCellFor },
|
||
params: { cell },
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
if (selectedCard?.cardId === "relocate-sector") {
|
||
if (!pendingSectorFrom) {
|
||
pendingSectorFrom = cell; // first click: the sector to move
|
||
return;
|
||
}
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
if (selectedCard?.cardId === "boobytrap") {
|
||
trapCells = [...trapCells, cell];
|
||
if (trapCells.length === 4) {
|
||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
||
clearSelection();
|
||
}
|
||
return;
|
||
}
|
||
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
||
if (!tradeFrom) { tradeFrom = cell; return; }
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
if (selectedCard?.cardId === "rotate-sector") {
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "cell", cell },
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
const meNow = view.players.find((p) => p.id === view.you);
|
||
if (!selectedCard && meNow) {
|
||
const pos = meNow.position;
|
||
const pair = view.dimWarps.find((w) =>
|
||
(w.a.x === pos.x && w.a.y === pos.y) || (w.b.x === pos.x && w.b.y === pos.y));
|
||
if (pair) {
|
||
const dest = pair.a.x === pos.x && pair.a.y === pos.y ? pair.b : pair.a;
|
||
if (dest.x === cell.x && dest.y === cell.y) {
|
||
dispatch({ type: "warpStep" });
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (selectedCreature) {
|
||
const creature = view.creatures.find((c) => c.id === selectedCreature);
|
||
if (creature) {
|
||
for (const side of SIDES) {
|
||
const n = { x: creature.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||
y: creature.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||
if (cellKey(n) === cellKey(cell)) {
|
||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: side });
|
||
return;
|
||
}
|
||
}
|
||
// Standing on a board-edge opening: clicking the far mouth warps it.
|
||
const w = view.board.warps.find((w) =>
|
||
cellKey(w.from.cell) === cellKey(creature.position) && cellKey(w.to.cell) === cellKey(cell));
|
||
if (w) {
|
||
dispatch({ type: "moveCreature", creatureId: selectedCreature, direction: w.from.side });
|
||
return;
|
||
}
|
||
}
|
||
selectedCreature = null;
|
||
return;
|
||
}
|
||
const me = view.players.find((p) => p.id === view.you)!;
|
||
// A cell click is a move if the cell is one legal step away (the server
|
||
// also lets doors/walls pass when unlocked/misted — try the direction).
|
||
for (const side of SIDES) {
|
||
const t = stepTarget(view.board, me.position, side);
|
||
if (t.kind !== "blocked" && cellKey(t.to) === cellKey(cell)) {
|
||
dispatch({ type: "move", direction: side });
|
||
return;
|
||
}
|
||
}
|
||
// Adjacent but blocked? Send the move anyway — unlocked doors, mist-body
|
||
// and pass-through-wall are resolved server-side.
|
||
for (const side of SIDES) {
|
||
const n = { x: me.position.x + (side === "E" ? 1 : side === "W" ? -1 : 0),
|
||
y: me.position.y + (side === "S" ? 1 : side === "N" ? -1 : 0) };
|
||
if (cellKey(n) === cellKey(cell)) {
|
||
dispatch({ type: "move", direction: side });
|
||
return;
|
||
}
|
||
}
|
||
// The click claimed no action — offer the square's card instead.
|
||
peekAt(cell);
|
||
}
|
||
|
||
/** Card ids behind the terrain tokens, for tap-to-read. */
|
||
const CONTENT_CARD: Record<string, string> = {
|
||
stone: "fill-square-with-stone", thornbush: "thornbush", rosebush: "rosebush",
|
||
ooze: "killer-ooze", dust: "dust-cloud", slime: "fill-square-with-slime",
|
||
tacks: "handful-of-tacks", pit: "create-pit", safe: "safe",
|
||
};
|
||
|
||
/** Sustained ids include engine constructs the card library cannot show. */
|
||
function realCard(cardId: string): boolean {
|
||
try { cardDef(cardId); return true; } catch { return false; }
|
||
}
|
||
|
||
function creatureStats(c: NonNullable<GameView["creatures"]>[number]): string {
|
||
const hp = Number.isFinite(c.maxDamage)
|
||
? `${c.maxDamage - c.damage} of ${c.maxDamage} hits left`
|
||
: "unharmed by ordinary damage";
|
||
const moves = `${c.movesPerTurn - c.movementUsed} of ${c.movesPerTurn} moves`;
|
||
const atk = c.justCreated ? "no attack the turn it appears" : c.attackUsed ? "attack spent" : "attack ready";
|
||
return `${hp} · ${moves} · ${atk}`;
|
||
}
|
||
const peekNote = $derived.by(() => {
|
||
if (!view || !peekCreatureId) return null;
|
||
const c = view.creatures.find((c) => c.id === peekCreatureId);
|
||
return c ? creatureStats(c) : null;
|
||
});
|
||
|
||
/** Show the card behind whatever occupies this square (view-only). */
|
||
function peekAt(cell: { x: number; y: number }): boolean {
|
||
if (!view) return false;
|
||
const key = `${cell.x},${cell.y}`;
|
||
const creature = view.creatures.find((c) => c.position.x === cell.x && c.position.y === cell.y);
|
||
const content = view.squareContents[key];
|
||
const objects = view.groundObjects[key] ?? [];
|
||
const cardId =
|
||
creature ? creature.kind
|
||
: content ? CONTENT_CARD[content.kind]
|
||
: objects.length > 0 ? objects[objects.length - 1]!.cardId
|
||
: view.dimWarps.some((w) => (w.a.x === cell.x && w.a.y === cell.y) || (w.b.x === cell.x && w.b.y === cell.y)) ? "dimensional-warp"
|
||
: view.boobytraps.some((t) => t.cells.some((c) => c.x === cell.x && c.y === cell.y)) ? "boobytrap"
|
||
: null;
|
||
if (!cardId) return false;
|
||
peekCard = { instanceId: `peek-${cardId}`, cardId };
|
||
peekCreatureId = creature?.id ?? null;
|
||
return true;
|
||
}
|
||
|
||
function clickWarp(cell: { x: number; y: number }, side: Side) {
|
||
if (!view || !isYourTurn || !me) return;
|
||
// Standing on the opening: step through — the selected creature first,
|
||
// your wizard otherwise. Elsewhere it is just a cell click.
|
||
const creature = selectedCreature ? view.creatures.find((c) => c.id === selectedCreature) : null;
|
||
if (creature && creature.position.x === cell.x && creature.position.y === cell.y) {
|
||
dispatch({ type: "moveCreature", creatureId: creature.id, direction: side });
|
||
} else if (me.position.x === cell.x && me.position.y === cell.y) {
|
||
dispatch({ type: "move", direction: side });
|
||
} else {
|
||
clickCell(cell);
|
||
}
|
||
}
|
||
|
||
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
||
if (punchWallMode) {
|
||
dispatch({ type: "punchWall", cell, side });
|
||
punchWallMode = false;
|
||
return;
|
||
}
|
||
if (!selectedCard || !edgeSelectMode) return;
|
||
dispatch({
|
||
type: "cast",
|
||
instanceId: selectedCard.instanceId,
|
||
target: { kind: "edge", cell, side },
|
||
...(attackVsWall && attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||
});
|
||
clearSelection();
|
||
}
|
||
|
||
function clickCreature(creatureId: string) {
|
||
if (!view) return;
|
||
const creature = view.creatures.find((c) => c.id === creatureId);
|
||
if (!creature) return;
|
||
if (!isYourTurn) { peekAt(creature.position); return; }
|
||
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
||
dispatch({
|
||
type: "cast", instanceId: selectedCard.instanceId,
|
||
target: { kind: "creature", creatureId },
|
||
...(attachedNumber ? { numberInstanceIds: [attachedNumber.instanceId] } : {}),
|
||
});
|
||
clearSelection();
|
||
return;
|
||
}
|
||
if (selectedCreature && selectedCreature !== creatureId) {
|
||
// Your selected creature attacks another creature in its square.
|
||
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
||
selectedCreature = null;
|
||
return;
|
||
}
|
||
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
|
||
if (mine) {
|
||
const selecting = selectedCreature !== creatureId;
|
||
selectedCreature = selecting ? creatureId : null;
|
||
if (selecting) {
|
||
peekCard = { instanceId: `peek-${creature.kind}`, cardId: creature.kind };
|
||
peekCreatureId = creature.id;
|
||
} else {
|
||
peekCard = null;
|
||
peekCreatureId = null;
|
||
}
|
||
} else {
|
||
peekAt(creature.position);
|
||
}
|
||
}
|
||
|
||
function clickPlayer(playerId: string) {
|
||
if (!view || !isYourTurn) return;
|
||
if (selectedCreature) {
|
||
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
|
||
selectedCreature = null;
|
||
return;
|
||
}
|
||
if (!selectedCard) {
|
||
// No card selected: same-square click = punch.
|
||
const me = view.players.find((p) => p.id === view.you)!;
|
||
const them = view.players.find((p) => p.id === playerId)!;
|
||
if (playerId !== view.you && cellKey(me.position) === cellKey(them.position)) {
|
||
dispatch({ type: "punch", targetId: playerId });
|
||
}
|
||
return;
|
||
}
|
||
if (selectedCard.cardId === "teleport-opponent" || selectedCard.cardId === "shift-wand") {
|
||
pendingCellFor = playerId;
|
||
return; // next: click the destination cell
|
||
}
|
||
const cmd: Parameters<typeof net.command>[0] = {
|
||
type: "cast",
|
||
instanceId: selectedCard.instanceId,
|
||
target: { kind: "player", playerId },
|
||
};
|
||
if (attachedNumber) cmd.numberInstanceIds = [attachedNumber.instanceId];
|
||
applyMods(cmd);
|
||
if (selectedCard.cardId === "waterbolt") {
|
||
cmd.params = { damage: wbDamage, knockback: numberTotal - wbDamage };
|
||
}
|
||
if (NAMED_CARDS.has(selectedCard.cardId)) {
|
||
const id = nameToCardId(nameInput);
|
||
if (!id) return; // needs a card name typed first
|
||
cmd.params = { cardId: id };
|
||
}
|
||
dispatch(cmd);
|
||
clearSelection();
|
||
}
|
||
|
||
function doDiscard() {
|
||
dispatch({ type: "discard", instanceIds: [...discardSelection] });
|
||
discardSelection = new Set();
|
||
discardMode = false;
|
||
}
|
||
|
||
function endTurn() {
|
||
clearSelection();
|
||
dispatch({ type: "endTurn", draw: drawCount });
|
||
}
|
||
|
||
function pickUp() { dispatch({ type: "pickUpTreasure" }); }
|
||
function drop() { dispatch({ type: "dropTreasure" }); }
|
||
function pass() { dispatch({ type: "pass" }); }
|
||
|
||
const me = $derived(view?.players.find((p) => p.id === view?.you) ?? null);
|
||
const carryingTreasure = $derived(me?.carriedTreasureId != null);
|
||
const treasureHere = $derived(
|
||
view != null && me != null &&
|
||
view.treasures.some((t) => t.position && t.position.x === me.position.x &&
|
||
t.position.y === me.position.y && !t.carriedBy),
|
||
);
|
||
/** Squares a selected L.O.S./ADJACENT card can reach; null = no dimming. */
|
||
const litCells = $derived.by(() => {
|
||
if (!view || !selectedDef || !isYourTurn) return null;
|
||
if (ambushVia || ambushSpell || ambushTrigger) return null; // ambushes aim at the future
|
||
if (!me) return null;
|
||
if (selectedDef.adjacent === true) {
|
||
const { x, y } = me.position;
|
||
return new Set(
|
||
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
|
||
.map(([cx, cy]) => `${cx},${cy}`)
|
||
.filter((k) => view.board.cells[k]),
|
||
);
|
||
}
|
||
if (selectedDef.los !== true) return null;
|
||
const sighted = sightedCellsFor(view);
|
||
if (attachedMods.some((m) => m.cardId === "around-the-corner")) {
|
||
const widened = new Set(sighted);
|
||
for (const key of Object.keys(view.board.cells)) {
|
||
if (widened.has(key)) continue;
|
||
const [cx, cy] = key.split(",").map(Number);
|
||
if ([[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dx, dy]) => sighted.has(`${cx! + dx!},${cy! + dy!}`))) {
|
||
widened.add(key);
|
||
}
|
||
}
|
||
return widened;
|
||
}
|
||
return sighted;
|
||
});
|
||
|
||
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
|
||
const idleCreatures = $derived(
|
||
view != null && isYourTurn
|
||
? view.creatures.filter((c) =>
|
||
(c.controllerId === view!.you || c.kind === "democratic-monster") &&
|
||
(c.movementUsed < c.movesPerTurn || (!c.attackUsed && !c.justCreated)))
|
||
: [],
|
||
);
|
||
|
||
const objectsHere = $derived(
|
||
view != null && me != null
|
||
? (view.groundObjects[`${me.position.x},${me.position.y}`] ?? [])
|
||
: [],
|
||
);
|
||
const onWarpToken = $derived(
|
||
view != null && me != null &&
|
||
view.dimWarps.some((w) =>
|
||
(w.a.x === me.position.x && w.a.y === me.position.y) ||
|
||
(w.b.x === me.position.x && w.b.y === me.position.y)),
|
||
);
|
||
|
||
let chronicleEl = $state<HTMLElement | null>(null);
|
||
$effect(() => {
|
||
void net.log.length;
|
||
void local.log.length;
|
||
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
||
});
|
||
|
||
// Tab title + favicon carry the turn signal even from another tab.
|
||
const anyTurnWaiting = $derived(
|
||
(view != null && view.phase === "playing" &&
|
||
(isYourTurn || youMustRespond || youMustDiscard ||
|
||
view.outOfTurnWindow?.playerId === view.you)) ||
|
||
net.games.some((g) => g.yourTurn && g.roomId !== net.roomId),
|
||
);
|
||
// The purple wizard from the physical counter sheet; a red badge marks your turn.
|
||
const FAVICON_IDLE = "/favicon.png";
|
||
const FAVICON_TURN = "/favicon-turn.png";
|
||
$effect(() => {
|
||
document.title = anyTurnWaiting ? "● Your turn — Wiz-War" : "Wiz-War";
|
||
let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
||
if (!link) {
|
||
link = document.createElement("link");
|
||
link.rel = "icon";
|
||
document.head.appendChild(link);
|
||
}
|
||
link.href = anyTurnWaiting ? FAVICON_TURN : FAVICON_IDLE;
|
||
});
|
||
|
||
function timeAgo(iso: string | null): string {
|
||
if (!iso) return "no moves yet";
|
||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||
if (s < 90) return "moments ago";
|
||
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
||
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
||
return `${Math.round(s / 86400)} d ago`;
|
||
}
|
||
|
||
function playerColor(id: string): string {
|
||
return view ? wizardColor(view, id) : PLAYER_COLORS[0]!;
|
||
}
|
||
</script>
|
||
|
||
<div class="table">
|
||
<header class="masthead">
|
||
<span class="mast-title">Wiz-War</span>
|
||
<span class="mast-sub">sixth edition</span>
|
||
{#if local.active}
|
||
<span class="mast-room">hotseat</span>
|
||
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
|
||
<button class="mast-leave" onclick={() => local.abandon()}>abandon game</button>
|
||
{:else if net.roomId}
|
||
<span class="mast-room">room <b>{net.roomId}</b></span>
|
||
<button class="mast-leave" onclick={() => net.requestTransferCode()}>transfer seat</button>
|
||
<button class="mast-leave" onclick={() => net.leave()}>leave table</button>
|
||
{/if}
|
||
<button class="mast-leave" class:mast-help-solo={!net.roomId} onclick={() => { helpTab = "play"; showHelp = true; }}>
|
||
help & rules
|
||
</button>
|
||
<span class="mast-status" class:offline={net.status !== "connected"}>
|
||
{net.status === "connected" ? "" : "reconnecting…"}
|
||
</span>
|
||
</header>
|
||
|
||
{#if showDiscards && view}
|
||
<div class="scrim attack-scrim" role="button" tabindex="-1" onclick={() => (showDiscards = false)} onkeydown={() => {}}>
|
||
<div class="discard-book" role="dialog" aria-label="the discard pile" tabindex="-1"
|
||
onclick={(e) => e.stopPropagation()} onkeydown={() => {}}>
|
||
<header class="discard-head">
|
||
<span>The discard pile — {view.discardCount} card{view.discardCount === 1 ? "" : "s"}, newest first</span>
|
||
<button class="inspector-close discard-close" onclick={() => (showDiscards = false)} aria-label="close">×</button>
|
||
</header>
|
||
<div class="discard-grid">
|
||
{#each [...view.discardPile].reverse() as card (card.instanceId)}
|
||
<Card {card} onclick={() => (peekCard = card)} />
|
||
{/each}
|
||
{#if view.discardPile.length === 0}
|
||
<span class="discard-empty">Nothing has been cast away yet.</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if peekCard}
|
||
<div class="inspector" role="img" aria-label="displayed card, enlarged">
|
||
<button class="inspector-close" onclick={() => { peekCard = null; peekCreatureId = null; }} aria-label="hide card">×</button>
|
||
<div class="inspector-card"><Card card={peekCard} /></div>
|
||
{#if peekNote}<div class="peek-note">{peekNote}</div>{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if showHelp}
|
||
<Help initialTab={helpTab} stats={net.stats} onstats={() => net.requestStats()} onclose={() => (showHelp = false)} />
|
||
{/if}
|
||
|
||
{#if view?.phase === "finished" && view.winner && !victorySeen}
|
||
<div class="scrim attack-scrim" role="alertdialog" aria-label="the game is won">
|
||
<div class="attack-notice victory-notice">
|
||
<div class="victory-trophy">🏆</div>
|
||
<div class="victory-name">{view.winner}</div>
|
||
<div class="victory-how">
|
||
{#if view.winReason === "treasures"}
|
||
carried two stolen treasures home — the maze is theirs
|
||
{:else if view.winReason === "lastStanding"}
|
||
the last wizard standing
|
||
{:else}
|
||
wins the game
|
||
{/if}
|
||
</div>
|
||
<button class="stamp big" onclick={() => (victorySeen = true)}>Behold the final board</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if view?.stack && attackNoticeKey && attackNoticeSeen !== attackNoticeKey}
|
||
<div class="scrim attack-scrim" role="alertdialog" aria-label="you are under attack">
|
||
<div class="attack-notice">
|
||
<div class="attack-headline">
|
||
{#if view.stack.defenderId === view.you && attackingCreature}
|
||
<strong>{view.stack.attackerId}</strong>'s {cardDef(attackingCreature.kind).name} attacks you!
|
||
{:else if view.stack.defenderId === view.you}
|
||
<strong>{view.stack.attackerId}</strong> attacks you!
|
||
{:else}
|
||
<strong>{view.stack.counters[view.stack.counters.length - 1]?.player}</strong> counters your spell!
|
||
{/if}
|
||
</div>
|
||
{#if view.stack.defenderId === view.you && attackingCreature}
|
||
<div class="attack-card"><Card card={{ instanceId: `atk-${attackingCreature.id}`, cardId: attackingCreature.kind }} /></div>
|
||
{:else if view.stack.defenderId === view.you && view.stack.attackCard}
|
||
<div class="attack-card"><Card card={view.stack.attackCard} /></div>
|
||
{#if view.stack.numberValue != null}
|
||
<div class="attack-power">powered by a {view.stack.numberValue}</div>
|
||
{/if}
|
||
{:else if view.stack.defenderId === view.you}
|
||
<div class="attack-fist">👊 a bare-knuckled punch</div>
|
||
{:else if view.stack.counters.length > 0}
|
||
<div class="attack-card"><Card card={view.stack.counters[view.stack.counters.length - 1]!.card} /></div>
|
||
{/if}
|
||
<button class="stamp big" onclick={() => (attackNoticeSeen = attackNoticeKey)}>To arms!</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if net.catchUp && net.catchUp.length > 0}
|
||
<Replay steps={net.catchUp} onclose={() => net.closeCatchUp()} />
|
||
{/if}
|
||
|
||
{#if net.error}
|
||
<div class="toast" role="alert">{net.error}</div>
|
||
{/if}
|
||
|
||
{#if net.transferCode}
|
||
<div class="slip transfer-slip">
|
||
Speak this phrase into your other device (good for 10 minutes, one use):
|
||
<strong class="transfer-phrase">{net.transferCode.code}</strong>
|
||
<button class="hint-cancel" onclick={() => (net.transferCode = null)}>dismiss</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if local.setup}
|
||
<div class="scrim-handoff">
|
||
<div class="handoff-card">
|
||
<div class="handoff-eyebrow">wizard {local.setup.names.length + 1} of {local.setup.count}</div>
|
||
<div class="handoff-name setup-title">What is your name?</div>
|
||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||
{@const taken = local.setup.colors.includes(c)}
|
||
<button
|
||
class="standee" class:current={setupColor === c} class:taken
|
||
disabled={taken}
|
||
style:--ring={PLAYER_COLORS[c]}
|
||
onclick={() => (setupColor = c)}
|
||
aria-label={`wizard color ${c + 1}${taken ? " (taken)" : ""}`}
|
||
>
|
||
<img src={`/tokens/wizard-${c}.png`} alt="" />
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<form class="setup-form" onsubmit={(e) => {
|
||
e.preventDefault();
|
||
const err = local.submitName(setupName, setupColor);
|
||
if (err) net.error = err;
|
||
else setupName = "";
|
||
}}>
|
||
<!-- svelte-ignore a11y_autofocus -->
|
||
<input class="setup-input" bind:value={setupName} maxlength="20"
|
||
placeholder="e.g. Mordecai" autofocus />
|
||
<button class="stamp" type="submit" disabled={!setupName.trim()}>
|
||
{local.setup.names.length + 1 === local.setup.count ? "Flip the boards" : "Pass the device on"}
|
||
</button>
|
||
</form>
|
||
{#if local.setup.names.length > 0}
|
||
<div class="setup-roster">so far: {local.setup.names.join(", ")}</div>
|
||
{/if}
|
||
<button class="hint-cancel setup-cancel" onclick={() => local.cancelSetup()}>never mind</button>
|
||
</div>
|
||
</div>
|
||
{:else if local.handoffTo}
|
||
<div class="scrim-handoff" role="button" tabindex="0"
|
||
onclick={() => local.takeSeat()} onkeydown={(e) => e.key === "Enter" && local.takeSeat()}>
|
||
<div class="handoff-card">
|
||
<div class="handoff-eyebrow">pass the device to</div>
|
||
<div class="handoff-name">{local.handoffTo}</div>
|
||
<div class="handoff-hint">tap when only they can see the screen</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if !net.roomId && !local.active}
|
||
<section class="boxlid">
|
||
<div class="boxlid-inner">
|
||
<div class="boxlid-title">Wiz-War</div>
|
||
<div class="boxlid-tag">A game of magical combat in a stone labyrinth</div>
|
||
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
|
||
about this game — a labor of love
|
||
</button>
|
||
<label class="field">
|
||
<span>Your wizard's name</span>
|
||
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
|
||
</label>
|
||
<div class="boxlid-actions">
|
||
<button class="stamp" disabled={!name.trim()} onclick={() => net.create(name)}>
|
||
Create a game
|
||
</button>
|
||
<span class="or">or join one</span>
|
||
<input class="code" bind:value={joinCode} maxlength="4" placeholder="CODE" />
|
||
<button class="stamp" disabled={!name.trim() || !joinCode.trim()} onclick={() => net.join(joinCode, name)}>
|
||
Join
|
||
</button>
|
||
</div>
|
||
|
||
<div class="hotseat-row">
|
||
<span class="hotseat-label">or play here, passing the device:</span>
|
||
<div class="count-picker" role="group" aria-label="number of wizards">
|
||
{#each [2, 3, 4, 5, 6] as n (n)}
|
||
<button class="count-btn" class:current={hotseatCount === n}
|
||
onclick={() => (hotseatCount = n)}>{n}</button>
|
||
{/each}
|
||
</div>
|
||
<button class="stamp tiny" onclick={() => { setupName = ""; local.beginSetup(hotseatCount, withExpansion); }}>
|
||
Gather {hotseatCount} wizards
|
||
</button>
|
||
{#if local.hasSave()}
|
||
<button class="stamp tiny" onclick={() => local.resume()}>Resume hotseat</button>
|
||
{/if}
|
||
</div>
|
||
<label class="check hotseat-exp">
|
||
<input type="checkbox" bind:checked={withExpansion} /> with Expansion Set #1
|
||
</label>
|
||
|
||
<div class="claim-row">
|
||
<input class="claim-input" bind:value={claimPhrase}
|
||
placeholder="ember-troll-dagger" aria-label="seat transfer phrase" />
|
||
<button class="stamp tiny" disabled={!claimPhrase.trim()}
|
||
onclick={() => { net.claimTransfer(claimPhrase); claimPhrase = ""; }}>
|
||
Claim a transferred seat
|
||
</button>
|
||
</div>
|
||
|
||
{#if net.seats.length > 0}
|
||
<div class="ledger">
|
||
<div class="ledger-head">
|
||
<span>your games</span>
|
||
{#if !net.notificationsEnabled}
|
||
<button class="hint-cancel" onclick={() => net.enableNotifications()}>
|
||
notify me on my turn
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#each net.seats as seat (seat.roomId + seat.name)}
|
||
{@const g = net.games.find((x) => x.roomId === seat.roomId && x.name === seat.name)}
|
||
<div class="ledger-row" class:your-turn={g?.yourTurn}>
|
||
<button class="ledger-resume" onclick={() => net.resume(seat)}>
|
||
<span class="ledger-code">{seat.roomId}</span>
|
||
<span class="ledger-info">
|
||
{#if !g}
|
||
as {seat.name} — unreachable
|
||
{:else if g.finished}
|
||
{g.winner === seat.name ? "you won! 🏆" : `${g.winner} won`}
|
||
{:else if !g.started}
|
||
waiting to start · {g.players.join(", ")}
|
||
{:else if g.yourTurn}
|
||
{attentionLabel(g.attention)} · round {g.round} · {timeAgo(g.lastMoveAt)}
|
||
{:else}
|
||
{g.activePlayerId}'s turn · round {g.round} · {timeAgo(g.lastMoveAt)}
|
||
{/if}
|
||
</span>
|
||
</button>
|
||
<button class="ledger-forget" title="forget this game"
|
||
onclick={() => net.forgetSeat(seat.roomId)}>×</button>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</section>
|
||
{:else if !local.active && !net.started}
|
||
<section class="boxlid">
|
||
<div class="boxlid-inner">
|
||
<div class="boxlid-title small">Room {net.roomId}</div>
|
||
<div class="boxlid-tag">Share the code. Two to six wizards enter the maze.</div>
|
||
<ul class="roster">
|
||
{#each net.players as p (p)}
|
||
{@const chosen = net.roomColors[p]}
|
||
<li>
|
||
{#if chosen !== undefined}
|
||
<img class="roster-standee" src={`/tokens/wizard-${chosen}.png`} alt="" />
|
||
{:else}
|
||
<span class="dot" style:background="#b3a687"></span>
|
||
{/if}
|
||
{p}{p === net.hostId ? " — host" : ""}{chosen === undefined ? " — choosing…" : ""}
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
<div class="standee-row" role="group" aria-label="choose your wizard">
|
||
{#each [0, 1, 2, 3, 4, 5] as c (c)}
|
||
{@const takenBy = Object.entries(net.roomColors).find(([, v]) => v === c)?.[0]}
|
||
<button
|
||
class="standee"
|
||
class:current={net.you != null && net.roomColors[net.you] === c}
|
||
class:taken={takenBy !== undefined && takenBy !== net.you}
|
||
disabled={takenBy !== undefined && takenBy !== net.you}
|
||
style:--ring={PLAYER_COLORS[c]}
|
||
onclick={() => net.pickColor(c)}
|
||
aria-label={`wizard color ${c + 1}${takenBy && takenBy !== net.you ? ` (taken by ${takenBy})` : ""}`}
|
||
>
|
||
<img src={`/tokens/wizard-${c}.png`} alt="" />
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{#if net.you === net.hostId}
|
||
<label class="check">
|
||
<input type="checkbox" bind:checked={withExpansion} />
|
||
Include Expansion Set #1 — monsters & wands
|
||
</label>
|
||
<button
|
||
class="stamp big"
|
||
disabled={net.players.length < 2 || net.players.length > 6}
|
||
onclick={() => net.start(withExpansion)}
|
||
>
|
||
Flip the boards ({net.players.length} wizard{net.players.length === 1 ? "" : "s"})
|
||
</button>
|
||
{:else}
|
||
<p class="waiting">Waiting for {net.hostId} to flip the boards…</p>
|
||
{/if}
|
||
</div>
|
||
</section>
|
||
{:else if view}
|
||
<div class="game">
|
||
<section class="board-zone">
|
||
<Board
|
||
{view}
|
||
edgeSelectMode={edgeSelectMode && isYourTurn}
|
||
selectedCreatureId={selectedCreature}
|
||
onCellClick={clickCell}
|
||
onEdgeClick={clickEdge}
|
||
onPlayerClick={clickPlayer}
|
||
onCreatureClick={clickCreature}
|
||
onWarpClick={clickWarp}
|
||
markedCell={tradeFrom ?? pendingSectorFrom}
|
||
onCellPeek={(c) => peekAt(c)}
|
||
{litCells}
|
||
/>
|
||
</section>
|
||
|
||
<aside class="paper-rail">
|
||
{#if !local.active && net.missedMoves > 0}
|
||
<div class="slip catchup">
|
||
You missed {net.missedMoves} move{net.missedMoves === 1 ? "" : "s"}.
|
||
<button class="stamp tiny" onclick={() => net.requestCatchUp()}>▶ Watch what happened</button>
|
||
<button class="hint-cancel" onclick={() => net.markSeen()}>skip</button>
|
||
</div>
|
||
{/if}
|
||
{#if view.phase === "finished"}
|
||
<div class="slip winner">🏆 {view.winner} wins!</div>
|
||
{:else if youMustRespond}
|
||
<div class="slip urgent">
|
||
{#if view.stack?.defenderId === view.you}
|
||
Under attack — respond by your hand.
|
||
{:else}
|
||
Your spell is countered — respond by your hand.
|
||
{/if}
|
||
</div>
|
||
{:else if view.stack}
|
||
<div class="slip">Waiting on {view.stack.waitingOn}…</div>
|
||
{:else if view.outOfTurnWindow?.playerId === view.you}
|
||
<div class="slip urgent">Your interruption — cast now, or <button class="stamp tiny" onclick={pass}>waste it</button></div>
|
||
{:else if isYourTurn}
|
||
<div class="slip yours">
|
||
Your turn — round {view.turn.round}.
|
||
{view.turn.movementAllowance - view.turn.movementUsed} moves left{view.turn.attackUsed ? " · attack spent" : ""}
|
||
</div>
|
||
{:else}
|
||
<div class="slip">{view.activePlayerId} is taking their turn…</div>
|
||
{/if}
|
||
{#if interruptCards.length > 0}
|
||
<div class="slip urgent">
|
||
⚡ Your {cardDef(interruptCards[0]!.cardId).name} can seize this moment — tap the glowing card.
|
||
</div>
|
||
{/if}
|
||
|
||
{#if view.yourWardArmed}
|
||
<div class="slip ambush-note">🗡 Your ward is set — a thief who grabs your treasure bleeds for 3.</div>
|
||
{/if}
|
||
{#if youMustShield}
|
||
<div class="slip urgent">
|
||
Chaos comes for your hand — tap your Full Shield to sit out, or
|
||
<button class="stamp tiny" onclick={pass}>let it take you</button>
|
||
</div>
|
||
{:else if view.chaosPending}
|
||
<div class="slip">Chaos gathers — waiting on {view.chaosPending.queue[0]}…</div>
|
||
{/if}
|
||
{#each idleCreatures as c (c.id)}
|
||
{@const moves = c.movesPerTurn - c.movementUsed}
|
||
<div class="slip creature-note">
|
||
🜲 {c.controllerId === view.you ? "Your" : "The"} {cardDef(c.kind).name} awaits orders —
|
||
{#if moves > 0}{moves} move{moves === 1 ? "" : "s"}{/if}{#if moves > 0 && !c.attackUsed && !c.justCreated} · {/if}{#if !c.attackUsed && !c.justCreated}attack ready{/if}.
|
||
Tap its token to command it.
|
||
</div>
|
||
{/each}
|
||
|
||
{#if view.yourAmbushes.length > 0}
|
||
{#each view.yourAmbushes as a (a.id)}
|
||
<div class="slip ambush-note">
|
||
🗡 {cardDef(a.spell.cardId).name} waits —
|
||
{a.trigger.kind === "los" ? "when seen" : a.trigger.kind === "near" ? "when approached" : "when treasure is grabbed"}
|
||
{#if isYourTurn}
|
||
<button class="hint-cancel" onclick={() => dispatch({ type: "cancelAmbush", ambushId: a.id })}>disarm</button>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
{/if}
|
||
|
||
<div class="scoresheet">
|
||
<div class="scoresheet-head">life-points</div>
|
||
{#each view.players as p (p.id)}
|
||
<div class="score-row" class:dead={!p.alive} class:active={p.id === view.activePlayerId}>
|
||
<span class="dot" style:background={playerColor(p.id)}></span>
|
||
<span class="score-name">{p.id}{p.id === view.you ? " (you)" : ""}</span>
|
||
<span class="score-life">{p.life}</span>
|
||
<span class="score-marks">
|
||
{p.handCount} cards{#if p.lostTurns > 0} · dazed {p.lostTurns}{/if}{#if p.carriedTreasureId} · 💰{/if}
|
||
</span>
|
||
</div>
|
||
{@const spellsOn = view.sustained.filter((e) => e.targetId === p.id)}
|
||
{#if p.displayed.length > 0 || spellsOn.length > 0}
|
||
<div class="table-cards">
|
||
{#each p.displayed as c (c.instanceId)}
|
||
<button class="table-card" onclick={() => (peekCard = peekCard?.instanceId === c.instanceId ? null : c)}>
|
||
{cardDef(c.cardId).name}{#if view.wandCharges[c.instanceId] != null} · {"●".repeat(view.wandCharges[c.instanceId]!)}{/if}
|
||
</button>
|
||
{/each}
|
||
{#each spellsOn as e (e.id)}
|
||
<button class="table-card spell-chip" onclick={() => {
|
||
if (!realCard(e.cardId)) return;
|
||
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
||
peekCreatureId = null;
|
||
}}>
|
||
✦ {spellName(e.cardId)} · {e.remainingTurns}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
<div class="deck-line">draw pile {view.deckCount} ·
|
||
<button class="discard-link" onclick={() => (showDiscards = true)}>discards {view.discardCount}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chronicle" aria-label="game log" bind:this={chronicleEl}>
|
||
{#each (local.active ? local.log : net.log).slice(-60) as line, i (i)}
|
||
<div>{line}</div>
|
||
{/each}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
<div class="table-edge">
|
||
{#if ambushVia}
|
||
<div class="hint-strip">
|
||
<strong class="hint-name">{cardDef(ambushVia.cardId).name} — set an ambush</strong>
|
||
{#if !ambushTrigger}
|
||
<span>springs when an opponent…</span>
|
||
<button class="stamp tiny" onclick={() => (ambushTrigger = "los")}>enters my sight</button>
|
||
<button class="stamp tiny" onclick={() => (ambushTrigger = "near")}>comes beside me</button>
|
||
<button class="stamp tiny" onclick={() => (ambushTrigger = "treasure")}>grabs a treasure</button>
|
||
{:else if !ambushSpell}
|
||
<span>— now tap the attack card to commit</span>
|
||
{:else}
|
||
<span>{cardDef(ambushSpell.cardId).name}{attachedNumber ? ` with a ${numberTotal}` : " (tap a number to power it)"}</span>
|
||
<button class="stamp tiny" onclick={armAmbush}>Arm the ambush</button>
|
||
{/if}
|
||
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
|
||
</div>
|
||
{/if}
|
||
{#if youMustRespond || selectedDef || selectedCreature || youMustDiscard || discardMode || discardSelection.size > 0}
|
||
<div class="hint-strip">
|
||
{#if selectedCreature && !selectedDef}
|
||
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
|
||
{#if sc}
|
||
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
|
||
Tap a square beside it to march, a target in its square to attack.</span>
|
||
<button class="hint-cancel" onclick={() => { selectedCreature = null; peekCard = null; peekCreatureId = null; }}>done</button>
|
||
{/if}
|
||
{/if}
|
||
{#if youMustRespond && view.stack}
|
||
<span class="hint-alert">
|
||
{#if view.stack.defenderId === view.you}
|
||
{view.stack.attackerId} attacks with
|
||
{attackingCreature ? `the ${cardDef(attackingCreature.kind).name}` : view.stack.attackCard ? cardDef(view.stack.attackCard.cardId).name : "a punch"} —
|
||
tap a counteraction card, or
|
||
{:else}
|
||
{view.stack.waitingOn === view.you ? "They counter your spell — counter back, or" : ""}
|
||
{/if}
|
||
</span>
|
||
<button class="stamp tiny" onclick={pass}>let it resolve</button>
|
||
{/if}
|
||
{#if youMustDiscard}
|
||
<span class="hint-alert">Hand over the limit — mark cards and discard.</span>
|
||
{:else if discardMode && discardSelection.size === 0}
|
||
<span>Mark the cards to throw away, then confirm.</span>
|
||
{/if}
|
||
{#if discardSelection.size > 0}
|
||
<button class="stamp tiny" onclick={doDiscard}>Discard {discardSelection.size} marked</button>
|
||
{/if}
|
||
{#if discardMode || (discardSelection.size > 0 && !youMustDiscard)}
|
||
<button class="hint-cancel" onclick={cancelDiscardMode}>never mind</button>
|
||
{/if}
|
||
{#if selectedDef}
|
||
<strong class="hint-name">{selectedDef.name}</strong>
|
||
{#if edgeSelectMode}<span>— click a wall line</span>{/if}
|
||
{#if selectedDef.cardType === "attack" && !cellSelectMode && !EDGE_CARDS.has(selectedCard?.cardId ?? "")}
|
||
<span>— click a target, or a wall line to batter it{attachedNumber ? ` (powered by a ${numberTotal})` : ""}</span>
|
||
{/if}
|
||
{#if attachedMods.length > 0}
|
||
<span class="hint-mods">[+ {attachedMods.map((m) => cardDef(m.cardId).name).join(", ")}]</span>
|
||
{/if}
|
||
{#if selectedCard?.cardId === "boobytrap"}
|
||
<span>— place 4 tokens ({trapCells.length}/4; the first is real)</span>
|
||
{/if}
|
||
{#if selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)}
|
||
<span>{tradeFrom ? "— now the second square" : "— click the first square"}</span>
|
||
{/if}
|
||
{#if selectedCard?.cardId === "rotate-sector"}
|
||
<label class="inline"><input type="checkbox" bind:checked={rotateCW} /> clockwise</label>
|
||
<span>— click the sector</span>
|
||
{/if}
|
||
{#if selectedCard?.cardId === "relocate-sector"}
|
||
<span>{pendingSectorFrom ? "— now the destination area" : "— click the sector to move"}</span>
|
||
{/if}
|
||
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
||
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
|
||
<datalist id="card-name-options">
|
||
{#each nameSuggestions as n (n)}<option value={n}></option>{/each}
|
||
</datalist>
|
||
{#if selectedCard.cardId === "deja-vu"}
|
||
<button class="stamp tiny" onclick={() => {
|
||
const id = nameToCardId(nameInput);
|
||
if (id && selectedCard) {
|
||
dispatch({ type: "cast", instanceId: selectedCard.instanceId, params: { cardId: id } });
|
||
clearSelection();
|
||
}
|
||
}}>Retrieve</button>
|
||
{:else}
|
||
<span>— then click the target</span>
|
||
{/if}
|
||
{/if}
|
||
{#if selectedCard?.cardId === "waterbolt"}
|
||
<label class="inline">damage <input class="num-input" type="number" min="0" max={numberTotal} bind:value={wbDamage} /></label>
|
||
<span>(knockback {numberTotal - wbDamage})</span>
|
||
{/if}
|
||
{#if selectedCard?.cardId === "power-run"}
|
||
<label class="inline">life to trade <input class="num-input" type="number" min="1" max="10" bind:value={runPoints} /></label>
|
||
<button class="stamp tiny" onclick={castPowerRun}>Run!</button>
|
||
{/if}
|
||
{#if selectedCard && CONFIRM_CAST.has(selectedCard.cardId)}
|
||
<button class="stamp tiny" onclick={castSelfWithNumber}>
|
||
Cast{attachedNumber ? ` with the ${numberTotal}` : ""}
|
||
</button>
|
||
{/if}
|
||
{#if selectedCard && isMovableObject(selectedCard.cardId) && isYourTurn}
|
||
<button class="stamp tiny" onclick={() => {
|
||
if (selectedCard) dispatch({ type: "dropObject", instanceId: selectedCard.instanceId });
|
||
clearSelection();
|
||
}}>Drop it here</button>
|
||
{/if}
|
||
{#if selectedCard && isNumberCard(selectedCard.cardId)}
|
||
<button class="stamp tiny" onclick={playNumberForMovement}>
|
||
Play for +{cardDef(selectedCard.cardId).value} movement{view.turn.numberPlayedForMovement ? " (uses your Add)" : ""}
|
||
</button>
|
||
{/if}
|
||
<button class="hint-cancel" onclick={clearSelection}>cancel</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="actions">
|
||
{#if isYourTurn && onWarpToken}
|
||
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
|
||
{/if}
|
||
{#if isYourTurn}
|
||
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
|
||
onclick={pickUp}>Pick up treasure</button>
|
||
<button class="stamp" disabled={!carryingTreasure} onclick={drop}>Drop treasure</button>
|
||
{#each objectsHere as obj (obj.instanceId)}
|
||
<button class="stamp" disabled={view.turn.actionsEnded}
|
||
onclick={() => dispatch({ type: "pickUpObject", instanceId: obj.instanceId })}>
|
||
Pick up {cardDef(obj.cardId).name}</button>
|
||
{/each}
|
||
{#if holdingWard}
|
||
<button class="stamp" onclick={() => dispatch({ type: "armWard", armed: !view.yourWardArmed })}>
|
||
{view.yourWardArmed ? "Stand down the ward" : "Set the ward"}</button>
|
||
{/if}
|
||
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||
onclick={() => (punchWallMode = !punchWallMode)}>
|
||
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
||
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
|
||
<label class="inline draw-pick">
|
||
draw
|
||
<select bind:value={drawCount}>
|
||
<option value={0}>0</option>
|
||
<option value={1}>1</option>
|
||
<option value={2}>2</option>
|
||
</select>
|
||
</label>
|
||
<button class="stamp primary" onclick={endTurn}>End turn</button>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if selectedCard && !inspectorHidden}
|
||
<div class="inspector" role="img" aria-label="selected card, enlarged">
|
||
<button class="inspector-close" onclick={() => (inspectorHidden = true)} aria-label="hide enlarged card">×</button>
|
||
<div class="inspector-card">
|
||
<Card card={selectedCard} />
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if view.phase === "finished" && view.revealedHands}
|
||
<div class="final-reveal" aria-label="all hands revealed">
|
||
<div class="reveal-head">The hands, face-up</div>
|
||
{#each view.players as p (p.id)}
|
||
<div class="reveal-row">
|
||
<span class="reveal-name" class:reveal-winner={p.id === view.winner}>
|
||
{p.id === view.winner ? "🏆 " : ""}{p.id}
|
||
{#if !p.alive}<span class="reveal-note">as they fell</span>{/if}
|
||
</span>
|
||
<div class="reveal-cards">
|
||
{#each view.revealedHands[p.id] ?? [] as card (card.instanceId)}
|
||
<Card {card} onclick={() => (peekCard = card)} />
|
||
{/each}
|
||
{#if (view.revealedHands[p.id] ?? []).length === 0}
|
||
<span class="reveal-empty">empty-handed</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="hand" aria-label="your hand">
|
||
{#each view.phase === "finished" ? [] : view.yourHand as card (card.instanceId)}
|
||
<Card
|
||
{card}
|
||
selected={selectedCard?.instanceId === card.instanceId ||
|
||
ambushVia?.instanceId === card.instanceId}
|
||
attached={attachedNumber?.instanceId === card.instanceId ||
|
||
ambushSpell?.instanceId === card.instanceId ||
|
||
attachedMods.some((m) => m.instanceId === card.instanceId)}
|
||
marked={discardSelection.has(card.instanceId)}
|
||
displayed={view.players.find((p) => p.id === view.you)?.displayed.some((c) => c.instanceId === card.instanceId) ?? false}
|
||
glowing={interruptCards.some((c) => c.instanceId === card.instanceId)}
|
||
charges={view.wandCharges[card.instanceId] ?? null}
|
||
onclick={() => selectCard(card)}
|
||
/>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<style>
|
||
:global(html, body) {
|
||
margin: 0;
|
||
background: #171a20;
|
||
}
|
||
:global(*, *::before, *::after) {
|
||
box-sizing: border-box;
|
||
}
|
||
.table {
|
||
min-height: 100vh;
|
||
background:
|
||
radial-gradient(ellipse at 50% 0%, rgba(64, 74, 92, 0.25), transparent 55%),
|
||
repeating-linear-gradient(115deg, rgba(255,255,255,0.012) 0 3px, transparent 3px 7px),
|
||
#171a20;
|
||
color: #d8d2c0;
|
||
font-family: "Archivo Narrow", system-ui, sans-serif;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0 1.1rem 1.1rem;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.masthead {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.7rem;
|
||
padding: 0.7rem 0.2rem 0.6rem;
|
||
border-bottom: 1px solid rgba(216, 210, 192, 0.15);
|
||
margin-bottom: 0.9rem;
|
||
}
|
||
.mast-title {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 700;
|
||
font-size: 1.35rem;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: #e9e1cb;
|
||
}
|
||
.mast-sub {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 400;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.24em;
|
||
text-transform: uppercase;
|
||
color: #8d8672;
|
||
}
|
||
.mast-room { margin-left: auto; font-size: 0.85rem; color: #a49c86; }
|
||
.mast-room b { color: #e9e1cb; letter-spacing: 0.12em; }
|
||
.mast-status { font-size: 0.8rem; color: #c98a2a; }
|
||
.mast-leave {
|
||
background: none;
|
||
border: none;
|
||
color: #8d8672;
|
||
font-family: "Archivo Narrow", sans-serif;
|
||
font-size: 0.8rem;
|
||
text-decoration: underline;
|
||
cursor: pointer;
|
||
}
|
||
.mast-leave:hover { color: #d8d2c0; }
|
||
.mast-help-solo { margin-left: auto; }
|
||
.hotseat-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; justify-content: center; margin-top: 1.3rem; }
|
||
.hotseat-exp { margin-top: 0.4rem; margin-bottom: 0; font-size: 0.85rem; }
|
||
|
||
.scrim-handoff {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: #14161b;
|
||
display: grid;
|
||
place-items: center;
|
||
z-index: 60;
|
||
cursor: pointer;
|
||
}
|
||
.handoff-card {
|
||
background: #e9e1cb;
|
||
color: #43331f;
|
||
border-radius: 8px;
|
||
padding: 2.4rem 3.2rem;
|
||
text-align: center;
|
||
box-shadow: 0 16px 44px rgba(0, 0, 0, 0.6);
|
||
}
|
||
.handoff-eyebrow {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.7rem;
|
||
letter-spacing: 0.22em;
|
||
text-transform: uppercase;
|
||
color: #6b5a41;
|
||
}
|
||
.handoff-name {
|
||
font-family: "Caveat", cursive;
|
||
font-size: 3rem;
|
||
line-height: 1.15;
|
||
}
|
||
.handoff-hint { font-size: 0.85rem; color: #6b5a41; margin-top: 0.4rem; }
|
||
.setup-title { font-size: 2.2rem; }
|
||
.standee-row {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
justify-content: center;
|
||
margin-top: 0.9rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.standee {
|
||
width: 3.1rem;
|
||
height: 3.1rem;
|
||
padding: 0;
|
||
border: 2.5px solid transparent;
|
||
border-radius: 6px;
|
||
background: #f4eede;
|
||
cursor: pointer;
|
||
overflow: hidden;
|
||
}
|
||
.standee img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||
.standee.current { border-color: var(--ring); box-shadow: 0 0 0 2px rgba(0,0,0,0.15); }
|
||
.standee.taken { opacity: 0.28; cursor: default; filter: grayscale(0.8); }
|
||
.setup-form { display: flex; flex-direction: column; gap: 0.7rem; margin-top: 0.8rem; }
|
||
.setup-input {
|
||
background: #f4eede;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 4px;
|
||
padding: 0.6rem 0.8rem;
|
||
font-family: "Archivo Narrow", sans-serif;
|
||
font-size: 1.1rem;
|
||
color: #43331f;
|
||
text-align: center;
|
||
}
|
||
.setup-roster { margin-top: 0.8rem; font-size: 0.85rem; color: #6b5a41; }
|
||
.setup-cancel { margin: 0.6rem auto 0; display: block; }
|
||
.hotseat-label { flex-basis: 100%; font-size: 0.85rem; color: #6b5a41; }
|
||
.count-picker { display: flex; gap: 0.3rem; }
|
||
.count-btn {
|
||
width: 2.1rem;
|
||
height: 2.1rem;
|
||
border-radius: 50%;
|
||
border: 1.5px solid #6b5a41;
|
||
background: none;
|
||
color: #43331f;
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.95rem;
|
||
cursor: pointer;
|
||
}
|
||
.count-btn.current { background: #43331f; color: #e9e1cb; }
|
||
|
||
.toast {
|
||
background: #6d2119;
|
||
color: #f2e6d8;
|
||
border: 1px solid #96382c;
|
||
padding: 0.5rem 0.85rem;
|
||
border-radius: 4px;
|
||
margin-bottom: 0.7rem;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.boxlid {
|
||
flex: 1;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 2rem 0;
|
||
}
|
||
.boxlid-inner {
|
||
background: #e9e1cb;
|
||
color: #43331f;
|
||
border-radius: 8px;
|
||
border: 1px solid #b3a687;
|
||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
|
||
padding: 2.2rem 2.6rem 2rem;
|
||
max-width: min(26rem, 100%);
|
||
width: 100%;
|
||
text-align: center;
|
||
}
|
||
.boxlid-title {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 700;
|
||
font-size: 3rem;
|
||
line-height: 1;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.boxlid-title.small { font-size: 1.7rem; }
|
||
.boxlid-tag {
|
||
font-size: 0.95rem;
|
||
color: #6b5a41;
|
||
margin: 0.5rem 0 0.4rem;
|
||
}
|
||
.about-link {
|
||
background: none;
|
||
border: none;
|
||
font-family: "Caveat", cursive;
|
||
font-size: 1.05rem;
|
||
color: #8a6d3f;
|
||
text-decoration: underline;
|
||
text-decoration-style: dotted;
|
||
cursor: pointer;
|
||
margin-bottom: 1.2rem;
|
||
}
|
||
.about-link:hover { color: #43331f; }
|
||
.field { display: block; text-align: left; margin-bottom: 1rem; }
|
||
.field span {
|
||
display: block;
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.62rem;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: #6b5a41;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
.field input, .code {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
background: #f4eede;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 4px;
|
||
padding: 0.55rem 0.7rem;
|
||
font-family: inherit;
|
||
font-size: 1rem;
|
||
color: #43331f;
|
||
}
|
||
.boxlid-actions { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; justify-content: center; }
|
||
.or { font-size: 0.85rem; color: #6b5a41; }
|
||
.code { width: 6.5rem; padding: 0.55rem 0.4rem; text-transform: uppercase; text-align: center; letter-spacing: 0.15em; }
|
||
.roster { list-style: none; padding: 0; margin: 0 0 1rem; text-align: left; }
|
||
.roster li { display: flex; align-items: center; gap: 0.5rem; padding: 0.25rem 0; font-size: 1.05rem; }
|
||
.roster-standee { width: 1.9rem; height: 1.9rem; border-radius: 4px; object-fit: cover; }
|
||
.check { display: flex; gap: 0.5rem; align-items: center; justify-content: center; font-size: 0.92rem; margin-bottom: 1rem; }
|
||
.waiting { color: #6b5a41; font-style: italic; }
|
||
|
||
.transfer-slip {
|
||
max-width: 30rem;
|
||
margin: 0 auto 0.7rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.transfer-phrase {
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 1.05rem;
|
||
letter-spacing: 0.04em;
|
||
background: rgba(67, 51, 31, 0.12);
|
||
padding: 0.1rem 0.45rem;
|
||
border-radius: 3px;
|
||
user-select: all;
|
||
}
|
||
.claim-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 1.1rem;
|
||
}
|
||
.claim-input {
|
||
background: #f4eede;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 4px;
|
||
padding: 0.4rem 0.6rem;
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.9rem;
|
||
color: #43331f;
|
||
width: 13rem;
|
||
}
|
||
|
||
/* the games ledger */
|
||
.ledger {
|
||
margin-top: 1.6rem;
|
||
border-top: 1.5px solid #6b5a41;
|
||
padding-top: 0.5rem;
|
||
text-align: left;
|
||
}
|
||
.ledger-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
font-family: "Caveat", cursive;
|
||
font-size: 1.15rem;
|
||
color: #6b5a41;
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
.ledger-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
border-radius: 4px;
|
||
padding: 0.15rem 0.3rem;
|
||
}
|
||
.ledger-row.your-turn { background: rgba(46, 125, 50, 0.14); }
|
||
.ledger-resume {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.7rem;
|
||
background: none;
|
||
border: none;
|
||
padding: 0.3rem 0.2rem;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
color: #43331f;
|
||
font-family: "Archivo Narrow", sans-serif;
|
||
font-size: 0.95rem;
|
||
}
|
||
.ledger-resume:hover .ledger-code { text-decoration: underline; }
|
||
.ledger-code {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
}
|
||
.ledger-row.your-turn .ledger-info { color: #1d5720; font-weight: 600; }
|
||
.ledger-info { color: #6b5a41; }
|
||
.ledger-forget {
|
||
background: none;
|
||
border: none;
|
||
color: #a4906c;
|
||
font-size: 1.05rem;
|
||
cursor: pointer;
|
||
padding: 0 0.3rem;
|
||
}
|
||
.ledger-forget:hover { color: #b3372b; }
|
||
|
||
.stamp {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 500;
|
||
font-size: 0.78rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
background: #e9e1cb;
|
||
color: #43331f;
|
||
border: 1.5px solid #43331f;
|
||
border-radius: 3px;
|
||
padding: 0.42rem 0.9rem;
|
||
cursor: pointer;
|
||
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
|
||
}
|
||
.stamp:hover:not(:disabled) { background: #f4eede; }
|
||
.stamp:active:not(:disabled) { transform: translate(1px, 1px); box-shadow: 1px 1px 0 rgba(0,0,0,0.35); }
|
||
.stamp:disabled { opacity: 0.45; cursor: default; }
|
||
.stamp.primary { background: #43331f; color: #e9e1cb; }
|
||
.stamp.primary:hover:not(:disabled) { background: #5a4429; }
|
||
.stamp.big { font-size: 0.95rem; padding: 0.6rem 1.3rem; }
|
||
.stamp.tiny { font-size: 0.62rem; padding: 0.22rem 0.5rem; box-shadow: 1px 1px 0 rgba(0,0,0,0.3); }
|
||
.stamp:focus-visible { outline: 2px solid #d8b23a; outline-offset: 2px; }
|
||
|
||
.game {
|
||
display: grid;
|
||
grid-template-columns: minmax(340px, 1fr) minmax(250px, 330px);
|
||
gap: 1.1rem;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.board-zone { display: flex; align-items: flex-start; justify-content: center; min-width: 0; }
|
||
.board-zone :global(svg.board) {
|
||
max-height: calc(100dvh - 21.5rem);
|
||
width: auto;
|
||
max-width: 100%;
|
||
}
|
||
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
|
||
|
||
.slip {
|
||
background: #e9e1cb;
|
||
color: #43331f;
|
||
border-radius: 3px;
|
||
padding: 0.55rem 0.8rem;
|
||
font-size: 0.95rem;
|
||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
|
||
transform: rotate(-0.4deg);
|
||
}
|
||
.slip.yours { border-left: 4px solid #2e7d32; }
|
||
.slip.urgent { border-left: 4px solid #b3372b; transform: rotate(0.4deg); }
|
||
.slip.ambush-note { border-left: 4px solid #43331f; font-size: 0.85rem; }
|
||
.slip.catchup { border-left: 4px solid #5b3f9e; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||
.slip.winner {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 1.25rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
border-left: 4px solid #c9a72a;
|
||
}
|
||
|
||
.scoresheet {
|
||
background: #f2ecdb;
|
||
color: #43331f;
|
||
border-radius: 2px;
|
||
padding: 0.6rem 0.8rem 0.5rem;
|
||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
||
background-image: repeating-linear-gradient(
|
||
transparent 0 1.55rem,
|
||
rgba(95, 74, 51, 0.18) 1.55rem calc(1.55rem + 1px)
|
||
);
|
||
}
|
||
.scoresheet-head {
|
||
font-family: "Caveat", cursive;
|
||
font-size: 1.05rem;
|
||
color: #6b5a41;
|
||
border-bottom: 1.5px solid #6b5a41;
|
||
margin-bottom: 0.2rem;
|
||
}
|
||
.score-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
height: 1.55rem;
|
||
}
|
||
.slip.creature-note { background: #e7edda; border-color: #7d8a5a; }
|
||
.score-row.active .score-name { font-weight: 700; }
|
||
.table-cards {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
margin: -0.15rem 0 0.25rem 1.1rem;
|
||
}
|
||
.table-card {
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.68rem;
|
||
color: #43331f;
|
||
background: #f6f0df;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 3px;
|
||
padding: 0.05rem 0.35rem;
|
||
cursor: pointer;
|
||
}
|
||
.table-card:hover { background: #efe6cc; }
|
||
.table-card.spell-chip { background: #ece2f2; border-color: #8a6fa0; color: #4a3260; }
|
||
.table-card.spell-chip:hover { background: #e3d5ee; }
|
||
.score-row.dead { opacity: 0.5; text-decoration: line-through; }
|
||
.score-name { font-family: "Caveat", cursive; font-size: 1.25rem; line-height: 1; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.score-life { font-family: "Caveat", cursive; font-size: 1.4rem; font-weight: 700; min-width: 1.6rem; text-align: right; }
|
||
.score-marks { font-size: 0.75rem; color: #6b5a41; }
|
||
.deck-line {
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.72rem;
|
||
color: #8a7a5e;
|
||
padding-top: 0.3rem;
|
||
}
|
||
.dot { width: 11px; height: 11px; border-radius: 50%; flex: 0 0 auto; border: 1px solid rgba(0,0,0,0.4); }
|
||
|
||
.chronicle {
|
||
flex: 1;
|
||
min-height: 8rem;
|
||
max-height: 40vh;
|
||
overflow-y: auto;
|
||
background: #efe8d4;
|
||
color: #3a2f1f;
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.74rem;
|
||
line-height: 1.5;
|
||
padding: 0.6rem 0.75rem;
|
||
border-radius: 2px;
|
||
box-shadow: 0 2px 7px rgba(0, 0, 0, 0.45);
|
||
}
|
||
.chronicle div + div { margin-top: 0.05rem; }
|
||
|
||
.table-edge {
|
||
margin-top: 0.9rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.6rem;
|
||
}
|
||
.hint-strip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
flex-wrap: wrap;
|
||
background: rgba(233, 225, 203, 0.12);
|
||
border: 1px dashed rgba(233, 225, 203, 0.35);
|
||
border-radius: 4px;
|
||
padding: 0.45rem 0.7rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
.hint-name { font-family: "Oswald", sans-serif; text-transform: uppercase; letter-spacing: 0.06em; color: #e9e1cb; }
|
||
.hint-mods { color: #9fca6a; }
|
||
.hint-alert { color: #e8a03c; }
|
||
.hint-cancel {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
color: #a49c86;
|
||
text-decoration: underline;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
.inline { display: inline-flex; align-items: center; gap: 0.35rem; }
|
||
.num-input { width: 3.2rem; }
|
||
.text-input { width: 9rem; }
|
||
.num-input, .text-input, .draw-pick select {
|
||
background: #e9e1cb;
|
||
border: 1px solid #43331f;
|
||
border-radius: 3px;
|
||
padding: 0.2rem 0.35rem;
|
||
font-family: inherit;
|
||
color: #43331f;
|
||
}
|
||
|
||
.actions { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; }
|
||
.draw-pick { color: #a49c86; font-size: 0.9rem; }
|
||
|
||
.attack-scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(10, 12, 16, 0.72);
|
||
display: grid;
|
||
place-items: center;
|
||
z-index: 45;
|
||
padding: 1rem;
|
||
}
|
||
.attack-notice {
|
||
background: #efe8d4;
|
||
border: 2px solid #43331f;
|
||
border-radius: 8px;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
|
||
padding: 1.2rem 1.6rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.8rem;
|
||
max-width: min(22rem, 92vw);
|
||
text-align: center;
|
||
}
|
||
.attack-headline {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 1.15rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: #b3372b;
|
||
}
|
||
.attack-card :global(.card) { transform: scale(1.35); margin: 1.2rem 0; }
|
||
.attack-card :global(.card:hover) { transform: scale(1.35); }
|
||
.attack-power { font-family: "Courier Prime", monospace; color: #6b5a41; font-size: 0.9rem; }
|
||
.attack-fist { font-size: 1.1rem; color: #43331f; }
|
||
|
||
.discard-link {
|
||
background: none;
|
||
border: none;
|
||
font: inherit;
|
||
color: inherit;
|
||
text-decoration: underline;
|
||
text-decoration-style: dotted;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.discard-link:hover { color: #43331f; }
|
||
.discard-book {
|
||
background: #efe8d4;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 6px;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
|
||
width: min(52rem, 94vw);
|
||
max-height: calc(100vh - 4rem);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.discard-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.6rem 1rem;
|
||
border-bottom: 2px solid #43331f;
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: #43331f;
|
||
}
|
||
.discard-close { position: static; }
|
||
.discard-grid {
|
||
overflow-y: auto;
|
||
padding: 0.9rem;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
|
||
gap: 0.7rem;
|
||
justify-items: center;
|
||
}
|
||
.discard-empty { font-family: "Caveat", cursive; color: #8a7a5e; font-size: 1.1rem; }
|
||
|
||
.peek-note {
|
||
margin-top: 0.4rem;
|
||
background: #2b2218;
|
||
color: #e8dfc6;
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.72rem;
|
||
padding: 0.25rem 0.5rem;
|
||
border-radius: 3px;
|
||
text-align: center;
|
||
max-width: 11rem;
|
||
}
|
||
|
||
.final-reveal {
|
||
background: #efe8d4;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 6px;
|
||
padding: 0.7rem 0.9rem;
|
||
margin-bottom: 0.6rem;
|
||
}
|
||
.reveal-head {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.8rem;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
color: #43331f;
|
||
border-bottom: 1px solid #b3a687;
|
||
padding-bottom: 0.2rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.reveal-row { display: flex; align-items: flex-start; gap: 0.6rem; margin-bottom: 0.5rem; }
|
||
.reveal-name {
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.8rem;
|
||
color: #43331f;
|
||
min-width: 6.5rem;
|
||
padding-top: 0.4rem;
|
||
}
|
||
.reveal-name.reveal-winner { font-weight: 700; }
|
||
.reveal-note { display: block; font-family: "Caveat", cursive; font-size: 0.85rem; color: #8a7a5e; }
|
||
.reveal-cards { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||
.reveal-cards :global(.card) { transform: scale(0.82); transform-origin: top left; margin: -0.35rem -0.8rem -1.1rem 0; }
|
||
.reveal-empty { font-family: "Caveat", cursive; color: #8a7a5e; padding-top: 0.5rem; }
|
||
|
||
.victory-notice { border-color: #a5842c; box-shadow: 0 0 0 4px rgba(201, 167, 42, 0.35), 0 18px 50px rgba(0, 0, 0, 0.6); }
|
||
.victory-trophy { font-size: 3rem; line-height: 1; }
|
||
.victory-name {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 1.7rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: #43331f;
|
||
}
|
||
.victory-how { font-family: "Caveat", cursive; font-size: 1.25rem; color: #6b5a41; }
|
||
|
||
.inspector {
|
||
position: fixed;
|
||
right: 0.9rem;
|
||
bottom: 0.9rem;
|
||
z-index: 35;
|
||
}
|
||
.inspector-card {
|
||
transform: scale(1.55);
|
||
transform-origin: bottom right;
|
||
filter: drop-shadow(0 10px 26px rgba(0, 0, 0, 0.65));
|
||
}
|
||
.inspector-card :global(.card) {
|
||
cursor: default;
|
||
}
|
||
.inspector-card :global(.card:hover) {
|
||
transform: none;
|
||
}
|
||
.inspector-close {
|
||
position: absolute;
|
||
top: -2.05rem;
|
||
right: -0.4rem;
|
||
z-index: 2;
|
||
background: #2b2218;
|
||
color: #e9e1cb;
|
||
border: 1px solid #6b5a41;
|
||
border-radius: 50%;
|
||
width: 1.7rem;
|
||
height: 1.7rem;
|
||
font-size: 1rem;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.hand {
|
||
display: flex;
|
||
gap: 0.45rem;
|
||
padding: 0.9rem 0.3rem 0.35rem;
|
||
overflow-x: auto;
|
||
overflow-y: visible;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
/* One-column phone flow: board, then your controls and hand, THEN the
|
||
paperwork — cards must never hide below the chronicle. */
|
||
.game { display: contents; }
|
||
.inspector { right: 0.5rem; bottom: 0.5rem; }
|
||
.inspector-card { transform: scale(1.3); }
|
||
.board-zone { order: 1; }
|
||
.table-edge { order: 2; margin-top: 0.6rem; }
|
||
.paper-rail { order: 3; margin-top: 0.9rem; }
|
||
.board-zone :global(svg.board) {
|
||
max-height: 56dvh;
|
||
}
|
||
.chronicle { max-height: 30dvh; flex: initial; }
|
||
.masthead { gap: 0.45rem; padding-top: 0.5rem; }
|
||
.boxlid { padding: 1rem 0; }
|
||
.boxlid-inner { padding: 1.5rem 1.2rem 1.4rem; }
|
||
.boxlid-title { font-size: 2.3rem; }
|
||
.claim-input { width: 100%; flex: 1 1 100%; }
|
||
.hotseat-row .stamp, .claim-row .stamp { flex: 0 0 auto; }
|
||
.mast-title { font-size: 1.05rem; white-space: nowrap; }
|
||
.mast-sub { display: none; }
|
||
.mast-leave { font-size: 0.72rem; }
|
||
}
|
||
</style>
|