"You may add two NUMBER cards together for any single action" — movement included. The engine accepts a second movement number when an ADD accompanies it (once per turn); the client spends your Add automatically and says so on the button. 120 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
937 lines
35 KiB
Svelte
937 lines
35 KiB
Svelte
<script lang="ts">
|
|
import { net } from "./net.svelte";
|
|
import Board from "./Board.svelte";
|
|
import Card from "./Card.svelte";
|
|
import { allCardDefs, cardDef, isNumberCard, SIDES, stepTarget, cellKey } from "@wizwar/engine";
|
|
import type { CardInstance, Side } from "@wizwar/engine";
|
|
|
|
net.connect();
|
|
|
|
let name = $state("");
|
|
let joinCode = $state("");
|
|
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(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 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", "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 clearSelection() {
|
|
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). */
|
|
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;
|
|
if (youMustRespond) {
|
|
net.command({ 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) 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;
|
|
}
|
|
net.command(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];
|
|
net.command(cmd);
|
|
clearSelection();
|
|
}
|
|
|
|
function castPowerRun() {
|
|
if (!selectedCard) return;
|
|
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { points: runPoints } });
|
|
clearSelection();
|
|
}
|
|
|
|
function clickCell(cell: { x: number; y: number }) {
|
|
if (!view || !isYourTurn) return;
|
|
if (pendingCellFor && selectedCard) {
|
|
// Stage 2 of teleport-opponent: destination chosen.
|
|
net.command({
|
|
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;
|
|
}
|
|
net.command({
|
|
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) {
|
|
net.command({ type: "cast", instanceId: selectedCard.instanceId, params: { cells: trapCells } });
|
|
clearSelection();
|
|
}
|
|
return;
|
|
}
|
|
if (selectedCard && TWO_CELL_CARDS.has(selectedCard.cardId)) {
|
|
if (!tradeFrom) { tradeFrom = cell; return; }
|
|
net.command({
|
|
type: "cast", instanceId: selectedCard.instanceId,
|
|
target: { kind: "cell", cell }, params: { cell: tradeFrom },
|
|
});
|
|
clearSelection();
|
|
return;
|
|
}
|
|
if (selectedCard?.cardId === "rotate-sector") {
|
|
net.command({
|
|
type: "cast", instanceId: selectedCard.instanceId,
|
|
target: { kind: "cell", cell }, params: { clockwise: rotateCW },
|
|
});
|
|
clearSelection();
|
|
return;
|
|
}
|
|
if (selectedCard && CELL_CARDS.has(selectedCard.cardId)) {
|
|
net.command({
|
|
type: "cast", instanceId: selectedCard.instanceId,
|
|
target: { kind: "cell", cell },
|
|
});
|
|
clearSelection();
|
|
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)) {
|
|
net.command({ type: "moveCreature", creatureId: selectedCreature, direction: 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)) {
|
|
net.command({ 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)) {
|
|
net.command({ type: "move", direction: side });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
function clickWarp(cell: { x: number; y: number }, side: Side) {
|
|
if (!view || !isYourTurn || !me) return;
|
|
// Standing on the opening: step through. Otherwise treat it as a cell
|
|
// click on the opening square (e.g. to walk toward it).
|
|
if (me.position.x === cell.x && me.position.y === cell.y) {
|
|
net.command({ type: "move", direction: side });
|
|
} else {
|
|
clickCell(cell);
|
|
}
|
|
}
|
|
|
|
function clickEdge(cell: { x: number; y: number }, side: Side) {
|
|
if (!selectedCard || !edgeSelectMode) return;
|
|
net.command({
|
|
type: "cast",
|
|
instanceId: selectedCard.instanceId,
|
|
target: { kind: "edge", cell, side },
|
|
});
|
|
clearSelection();
|
|
}
|
|
|
|
function clickCreature(creatureId: string) {
|
|
if (!view || !isYourTurn) return;
|
|
const creature = view.creatures.find((c) => c.id === creatureId);
|
|
if (!creature) return;
|
|
if (selectedCard && (CREATURE_TARGET_CARDS.has(selectedCard.cardId) || cardDef(selectedCard.cardId).cardType === "attack")) {
|
|
net.command({
|
|
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.
|
|
net.command({ type: "creatureAttack", creatureId: selectedCreature, targetId: creatureId });
|
|
selectedCreature = null;
|
|
return;
|
|
}
|
|
const mine = creature.controllerId === view.you || creature.kind === "democratic-monster";
|
|
if (mine) {
|
|
selectedCreature = selectedCreature === creatureId ? null : creatureId;
|
|
}
|
|
}
|
|
|
|
function clickPlayer(playerId: string) {
|
|
if (!view || !isYourTurn) return;
|
|
if (selectedCreature) {
|
|
net.command({ 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)) {
|
|
net.command({ 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 };
|
|
}
|
|
net.command(cmd);
|
|
clearSelection();
|
|
}
|
|
|
|
function doDiscard() {
|
|
net.command({ type: "discard", instanceIds: [...discardSelection] });
|
|
discardSelection = new Set();
|
|
discardMode = false;
|
|
}
|
|
|
|
function endTurn() {
|
|
clearSelection();
|
|
net.command({ type: "endTurn", draw: drawCount });
|
|
}
|
|
|
|
function pickUp() { net.command({ type: "pickUpTreasure" }); }
|
|
function drop() { net.command({ type: "dropTreasure" }); }
|
|
function pass() { net.command({ 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),
|
|
);
|
|
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;
|
|
if (chronicleEl) chronicleEl.scrollTop = chronicleEl.scrollHeight;
|
|
});
|
|
|
|
const PLAYER_COLORS = ["#1a9c46", "#d3352b", "#c9308f", "#3a3ac0", "#2ab0c9", "#c9a72a"];
|
|
function playerColor(id: string): string {
|
|
const idx = view?.players.findIndex((p) => p.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 net.roomId}
|
|
<span class="mast-room">room <b>{net.roomId}</b></span>
|
|
{/if}
|
|
<span class="mast-status" class:offline={net.status !== "connected"}>
|
|
{net.status === "connected" ? "" : "reconnecting…"}
|
|
</span>
|
|
</header>
|
|
|
|
{#if net.error}
|
|
<div class="toast" role="alert">{net.error}</div>
|
|
{/if}
|
|
|
|
{#if !net.roomId}
|
|
<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>
|
|
<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>
|
|
</section>
|
|
{:else if !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 or four wizards enter the maze.</div>
|
|
<ul class="roster">
|
|
{#each net.players as p (p)}
|
|
<li><span class="dot" style:background={playerColor(p)}></span>{p}{p === net.hostId ? " — host" : ""}</li>
|
|
{/each}
|
|
</ul>
|
|
{#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 !== 4}
|
|
onclick={() => net.start(withExpansion)}
|
|
>
|
|
Flip the boards ({net.players.length} of 2 or 4)
|
|
</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}
|
|
/>
|
|
</section>
|
|
|
|
<aside class="paper-rail">
|
|
{#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}
|
|
<strong>{view.stack.attackerId}</strong> attacks you
|
|
{#if view.stack.attackCard}with <strong>{cardDef(view.stack.attackCard.cardId).name}</strong>{:else}with a punch{/if}!
|
|
Play a counteraction, or
|
|
{:else}
|
|
Respond to the counteraction, or
|
|
{/if}
|
|
<button class="stamp tiny" onclick={pass}>let it resolve</button>
|
|
</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}
|
|
|
|
<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>
|
|
{/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 net.log.slice(-60) as line, i (i)}
|
|
<div>{line}</div>
|
|
{/each}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="table-edge">
|
|
{#if selectedDef || youMustDiscard || discardMode || discardSelection.size > 0}
|
|
<div class="hint-strip">
|
|
{#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" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
|
|
{#if selectedCard.cardId === "deja-vu"}
|
|
<button class="stamp tiny" onclick={() => {
|
|
const id = nameToCardId(nameInput);
|
|
if (id && selectedCard) {
|
|
net.command({ 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 && 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={() => net.command({ 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>
|
|
<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>
|
|
|
|
<div class="hand" aria-label="your hand">
|
|
{#each view.yourHand as card (card.instanceId)}
|
|
<Card
|
|
{card}
|
|
selected={selectedCard?.instanceId === card.instanceId}
|
|
attached={attachedNumber?.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;
|
|
}
|
|
.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; }
|
|
|
|
.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: 26rem;
|
|
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 1.4rem;
|
|
}
|
|
.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; }
|
|
.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; }
|
|
|
|
.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(100vh - 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.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;
|
|
}
|
|
.score-row.active .score-name { font-weight: 700; }
|
|
.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; }
|
|
|
|
.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) {
|
|
.game { grid-template-columns: 1fr; }
|
|
.paper-rail { order: 2; }
|
|
.chronicle { max-height: 22vh; }
|
|
}
|
|
</style>
|