142 "moves" was really 142 logged commands — steps, spells, passes, end-turns alike. The line now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
11 KiB
Svelte
305 lines
11 KiB
Svelte
<script lang="ts">
|
||
import { allCardDefs } from "@wizwar/engine";
|
||
import Card from "./Card.svelte";
|
||
import { HOW_TO_PLAY, RULES_SECTIONS } from "./rules";
|
||
|
||
let {
|
||
onclose,
|
||
initialTab = "play",
|
||
stats = null,
|
||
onstats,
|
||
}: {
|
||
onclose: () => void;
|
||
initialTab?: "play" | "rules" | "cards" | "about" | "tally";
|
||
stats?: Record<string, number | string | null> | null;
|
||
onstats?: () => void;
|
||
} = $props();
|
||
|
||
// svelte-ignore state_referenced_locally -- the initial tab is intentionally a one-time value
|
||
let tab = $state<"play" | "rules" | "cards" | "about" | "tally">(initialTab);
|
||
|
||
function openTally() {
|
||
tab = "tally";
|
||
onstats?.();
|
||
}
|
||
function hours(mins: number): string {
|
||
return mins < 90 ? `${mins} minutes` : `${Math.round(mins / 6) / 10} hours`;
|
||
}
|
||
let search = $state("");
|
||
|
||
// The playable pool: every card actually in the 6e game (base + Exp1).
|
||
const pool = allCardDefs().filter(
|
||
(d) => (d.set === "basic" || d.set === "expansion1") && (d.quantity ?? 0) > 0,
|
||
);
|
||
const filtered = $derived.by(() => {
|
||
const q = search.trim().toLowerCase();
|
||
const list = q
|
||
? pool.filter(
|
||
(d) => d.name.toLowerCase().includes(q) || (d.text ?? "").toLowerCase().includes(q),
|
||
)
|
||
: pool;
|
||
return [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||
});
|
||
|
||
function onkeydown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") onclose();
|
||
}
|
||
</script>
|
||
|
||
<svelte:window {onkeydown} />
|
||
|
||
<div class="scrim" role="button" tabindex="-1" onclick={onclose} onkeydown={() => {}}>
|
||
<div
|
||
class="booklet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="help"
|
||
tabindex="-1"
|
||
onclick={(e) => e.stopPropagation()}
|
||
onkeydown={() => {}}
|
||
>
|
||
<header class="booklet-head">
|
||
<span class="booklet-title">Wiz-War</span>
|
||
<nav class="tabs">
|
||
<button class:current={tab === "play"} onclick={() => (tab = "play")}>How to play</button>
|
||
<button class:current={tab === "rules"} onclick={() => (tab = "rules")}>The rules</button>
|
||
<button class:current={tab === "cards"} onclick={() => (tab = "cards")}>Card library</button>
|
||
<button class:current={tab === "about"} onclick={() => (tab = "about")}>About</button>
|
||
<button class:current={tab === "tally"} onclick={openTally}>The tally</button>
|
||
</nav>
|
||
<button class="close" onclick={onclose} aria-label="close help">×</button>
|
||
</header>
|
||
|
||
<div class="booklet-body">
|
||
{#if tab === "tally"}
|
||
<div class="tally">
|
||
<h3>How much love the maze is getting</h3>
|
||
{#if stats}
|
||
<dl class="tally-list">
|
||
<dt>{stats.gamesCreated}</dt><dd>games chronicled — {stats.gamesStarted} begun, {stats.gamesFinished} fought to a finish</dd>
|
||
<dt>{stats.wizardsSeated}</dt><dd>distinct wizards have taken a seat</dd>
|
||
<dt>{stats.commandsPlayed}</dt><dd>spells, steps, and punches recorded in the ledgers</dd>
|
||
<dt>{hours(Number(stats.minutesAtTable))}</dt><dd>spent at the table, by the clock between moves</dd>
|
||
<dt>{stats.winsByTreasure} / {stats.winsByLastStanding}</dt><dd>victories by treasure-theft / by last wizard standing</dd>
|
||
<dt>{stats.longestGameCommands}</dt><dd>actions in the longest game yet played — every step, spell, and pass in its ledger</dd>
|
||
<dt>{stats.fullestTable}</dt><dd>wizards at the fullest table</dd>
|
||
<dt>{stats.hotseatGames}</dt><dd>of the games were hotseat tables, reporting in anonymously</dd>
|
||
</dl>
|
||
{#if stats.firstGameAt}
|
||
<p class="colophon">The first game was dealt {new Date(String(stats.firstGameAt)).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })}. Hotseat tables send only counts — names and moves stay on the device.</p>
|
||
{/if}
|
||
{:else}
|
||
<p>Counting the ledgers…</p>
|
||
{/if}
|
||
</div>
|
||
{:else if tab === "about"}
|
||
<div class="about">
|
||
<h3>What this is</h3>
|
||
<p>
|
||
Wiz-War is a game of magical combat in a stone labyrinth: two to six
|
||
wizards prowl a maze, hurling fireballs, walking through walls,
|
||
summoning trolls, and stealing each other's treasure. It was created
|
||
by <strong>Tom Jolly</strong> in 1983 and first published under his
|
||
own Jolly Games label; the edition reproduced here is the
|
||
<strong>sixth edition</strong>, published by Chessex in 1993, together
|
||
with its one expansion — monsters and magic wands included.
|
||
</p>
|
||
<h3>Why it exists</h3>
|
||
<p>
|
||
In the early nineties, a group of friends played this exact edition
|
||
for countless hours — the slammed doors, the ambushes around
|
||
corners, the goat charges, the laughter. That box is long out of
|
||
print, and its cardboard is worn soft at the edges. This digital
|
||
table was built from that very copy: every card was photographed and
|
||
transcribed word for word, every board layout verified wall by wall,
|
||
and the tokens on this screen are the actual hand-inked art from
|
||
those counter sheets. It is a faithful reproduction of the game as
|
||
it was played at that table, made so the same friends — and their
|
||
friends — can keep playing it.
|
||
</p>
|
||
<h3>Whose game it is</h3>
|
||
<p>
|
||
Wiz-War is Tom Jolly's design, and the rights to it now rest with
|
||
<strong>Steve Jackson Games</strong>, who publish the current
|
||
edition. This project is an unofficial, non-commercial fan work,
|
||
made with love and admiration for the original — nothing here is
|
||
endorsed by or affiliated with Tom Jolly, Chessex, or Steve Jackson
|
||
Games. If this game delights you, honor it the right way:
|
||
<a href="https://www.sjgames.com/wiz-war/" target="_blank" rel="noreferrer">buy
|
||
the edition currently in print</a>, and deal seven cards to
|
||
somebody at a real table.
|
||
</p>
|
||
<h3>Send word</h3>
|
||
<p>
|
||
Feedback, bug reports, and faint praise all welcome — ravens fly to
|
||
<a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>,
|
||
<a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky,
|
||
or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon.
|
||
The keeper of this table roosts at
|
||
<a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.
|
||
</p>
|
||
<p class="colophon">
|
||
Wiz-War © Tom Jolly. Sixth edition published by Chessex, 1993.
|
||
Built by Eric and a very enthusiastic AI, 2026.
|
||
</p>
|
||
</div>
|
||
{:else if tab === "play"}
|
||
{#each HOW_TO_PLAY as section (section.title)}
|
||
<h3>{section.title}</h3>
|
||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||
{/each}
|
||
{:else if tab === "rules"}
|
||
<p class="colophon">
|
||
Sixth edition rules, from the original rulebook. © 1985 Jolly Games —
|
||
this digital adaptation is a fan project.
|
||
</p>
|
||
{#each RULES_SECTIONS as section (section.title)}
|
||
<h3>{section.title}</h3>
|
||
{#each section.body as p, i (i)}<p>{p}</p>{/each}
|
||
{/each}
|
||
{:else}
|
||
<div class="search-row">
|
||
<input
|
||
bind:value={search}
|
||
placeholder="search {pool.length} cards…"
|
||
aria-label="search cards"
|
||
/>
|
||
</div>
|
||
<div class="card-grid">
|
||
{#each filtered as def (def.id)}
|
||
<div class="card-slot">
|
||
<Card card={{ instanceId: `lib-${def.id}`, cardId: def.id }} />
|
||
<span class="card-meta">
|
||
×{def.quantity}{def.alsoIn?.length ? ` (+${def.alsoIn[0]!.quantity} exp)` : ""}
|
||
· {def.set === "basic" ? "base" : "exp 1"}
|
||
</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
.scrim {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(10, 12, 16, 0.72);
|
||
display: grid;
|
||
place-items: center;
|
||
z-index: 40;
|
||
padding: 2rem 1rem;
|
||
}
|
||
.booklet {
|
||
background: #efe8d4;
|
||
color: #43331f;
|
||
width: min(58rem, 100%);
|
||
max-height: calc(100vh - 4rem);
|
||
border-radius: 6px;
|
||
border: 1px solid #b3a687;
|
||
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.6);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.booklet-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 0.7rem 1rem;
|
||
border-bottom: 2px solid #43331f;
|
||
flex-wrap: wrap;
|
||
}
|
||
.booklet-title {
|
||
font-family: "Oswald", sans-serif;
|
||
font-weight: 700;
|
||
font-size: 1.15rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.tabs { display: flex; gap: 0.3rem; }
|
||
.tabs button {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
background: none;
|
||
border: 1.5px solid transparent;
|
||
border-radius: 3px;
|
||
color: #6b5a41;
|
||
padding: 0.3rem 0.6rem;
|
||
cursor: pointer;
|
||
}
|
||
.tabs button.current {
|
||
border-color: #43331f;
|
||
color: #43331f;
|
||
background: #e4dbc2;
|
||
}
|
||
.close {
|
||
margin-left: auto;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.4rem;
|
||
color: #6b5a41;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
}
|
||
.close:hover { color: #b3372b; }
|
||
|
||
.booklet-body {
|
||
overflow-y: auto;
|
||
padding: 1rem 1.4rem 1.4rem;
|
||
font-family: "Archivo Narrow", sans-serif;
|
||
font-size: 0.98rem;
|
||
line-height: 1.5;
|
||
}
|
||
.booklet-body h3 {
|
||
font-family: "Oswald", sans-serif;
|
||
font-size: 0.85rem;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid #b3a687;
|
||
padding-bottom: 0.15rem;
|
||
margin: 1.2rem 0 0.4rem;
|
||
}
|
||
.booklet-body h3:first-child { margin-top: 0; }
|
||
.booklet-body p { margin: 0.35rem 0; }
|
||
.colophon { font-style: italic; color: #6b5a41; font-size: 0.85rem; }
|
||
.booklet-body a { color: #8a4a1f; text-decoration-style: dotted; }
|
||
.booklet-body a:hover { color: #43331f; }
|
||
.tally-list { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.8rem; margin: 0.6rem 0 1rem; }
|
||
.tally-list dt {
|
||
font-family: "Oswald", sans-serif; font-weight: 700; font-size: 1.05rem;
|
||
color: #b3372b; text-align: right; white-space: nowrap;
|
||
}
|
||
.tally-list dd { margin: 0; align-self: center; }
|
||
|
||
.search-row { margin-bottom: 0.8rem; }
|
||
.search-row input {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
background: #f6f0df;
|
||
border: 1px solid #b3a687;
|
||
border-radius: 4px;
|
||
padding: 0.5rem 0.7rem;
|
||
font-family: inherit;
|
||
font-size: 0.95rem;
|
||
color: #43331f;
|
||
}
|
||
.card-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(8.4rem, 1fr));
|
||
gap: 0.8rem;
|
||
justify-items: center;
|
||
}
|
||
.card-slot { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
|
||
.card-slot :global(.card) { cursor: default; }
|
||
.card-slot :global(.card:hover) { transform: none; box-shadow: 0 3px 8px rgba(10, 8, 4, 0.45); }
|
||
.card-meta {
|
||
font-family: "Courier Prime", monospace;
|
||
font-size: 0.68rem;
|
||
color: #6b5a41;
|
||
}
|
||
</style>
|