Illusionary Attack, Deja-Vu, Card Erasure, and the other name-a-card spells asked for exact spell names from memory. The field is now a native datalist: type a few letters and matching card names appear, tuned per card — Illusionary Attack offers only attack spells (it can only pretend to be one), Drop Object offers only the movable objects plus Treasure, and the rest offer the whole playable pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1868 lines
70 KiB
Svelte
1868 lines
70 KiB
Svelte
<script lang="ts">
|
||
import { attentionLabel, net } from "./net.svelte";
|
||
import Board from "./Board.svelte";
|
||
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);
|
||
/** 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);
|
||
let helpTab = $state<"play" | "rules" | "cards" | "about">("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 ?? "punch"}:${view.stack.counters.length}`
|
||
: null,
|
||
);
|
||
const youMustDiscard = $derived(view != null && view.pendingDiscard === view.you);
|
||
|
||
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 edgeSelectMode = $derived(selectedCard != null && EDGE_CARDS.has(selectedCard.cardId));
|
||
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() {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/** Map a typed card name to its id (case-insensitive). */
|
||
/** 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;
|
||
});
|
||
|
||
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) {
|
||
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",
|
||
};
|
||
|
||
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 (!selectedCard || !edgeSelectMode) return;
|
||
dispatch({
|
||
type: "cast",
|
||
instanceId: selectedCard.instanceId,
|
||
target: { kind: "edge", cell, side },
|
||
});
|
||
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),
|
||
);
|
||
const FAVICON_IDLE =
|
||
"data:image/svg+xml," + encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text></svg>`.replaceAll("%23", "#"),
|
||
);
|
||
const FAVICON_TURN =
|
||
"data:image/svg+xml," + encodeURIComponent(
|
||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="%23171a20"/><text x="16" y="23" font-family="Georgia" font-size="19" font-weight="bold" fill="%23e9e1cb" text-anchor="middle">W</text><circle cx="25" cy="7" r="6" fill="%232e7d32"/></svg>`.replaceAll("%23", "#"),
|
||
);
|
||
$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`;
|
||
}
|
||
|
||
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
|
||
function playerColor(id: string): string {
|
||
const p = view?.players.find((p) => p.id === id);
|
||
const idx = p?.colorIndex ?? (view?.players.findIndex((q) => q.id === id) ?? 0);
|
||
return PLAYER_COLORS[idx % PLAYER_COLORS.length]!;
|
||
}
|
||
</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 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} onclose={() => (showHelp = false)} />
|
||
{/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}
|
||
<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 && 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}
|
||
|
||
{#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={() => {
|
||
peekCard = { instanceId: `peek-${e.id}`, cardId: e.cardId };
|
||
peekCreatureId = null;
|
||
}}>
|
||
✦ {cardDef(e.cardId).name} · {e.remainingTurns}
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
{/each}
|
||
<div class="deck-line">draw pile {view.deckCount} · discards {view.discardCount}</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
|
||
{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" && !edgeSelectMode && !cellSelectMode}
|
||
<span>— click a target{attachedNumber ? ` (powered by a ${numberTotal})` : " (tap a number card to power it)"}</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}
|
||
<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}
|
||
|
||
<div class="hand" aria-label="your hand">
|
||
{#each 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}
|
||
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; }
|
||
|
||
.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;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
@media (max-width: 900px) {
|
||
.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; }
|
||
|
||
.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;
|
||
}
|
||
|
||
.inspector { right: 0.5rem; bottom: 0.5rem; }
|
||
.inspector-card { transform: scale(1.3); }
|
||
}
|
||
|
||
.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; }
|
||
.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; }
|
||
.hand { min-height: auto; }
|
||
}
|
||
</style>
|