Player preferences: art set, auto-grab, flourishes

A ⚙ preferences slip in the masthead, saved to this device:
- Token art, all or nothing: the photographed cardboard or the
  hand-drawn vectors (the workshop's per-category query params stay as
  the undocumented editing override).
- Ending a turn on a lone grabbable treasure picks it up on the way
  out — never on your own home, never when two share the square.
- Spell flourishes on or off, honored by the live board and the
  replay reel alike (trap notices still fire; they are information).
Preferences change what you see or what routine commands the client
sends for you — never what is legal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138A8CjeQRpvzKxuMfz1Bqc
This commit is contained in:
Eric Wagoner
2026-08-19 09:45:12 -04:00
co-authored by Claude Fable 5
parent 5310fbdca9
commit 6aae40ad5f
4 changed files with 110 additions and 11 deletions
+57
View File
@@ -9,6 +9,7 @@
import FxGallery from "./FxGallery.svelte";
import TokenGallery from "./TokenGallery.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs, savePrefs } from "./prefs.svelte";
import { CREATURE_ART, objectArt, TERRAIN_ART, tokenArt } from "./art";
import { local } from "./local.svelte";
import { allCardDefs, cardDef, isNumberCard, isPermanentDuration, SIDES, stepTarget, cellKey, edgeKey, isMovableObject, sightedCellsFor, stackSightTrace, type GameView, eligibleCellsFor } from "@wizwar/engine";
@@ -56,6 +57,7 @@
for (const e of events) {
if (e.type === "trapSprung" && e.player === view.you && e.cardId) trapNotice = e.cardId;
}
if (!prefs.flourishes) return;
fxCancels.push(scheduleFx(
events, view,
(fx) => (boardFx = [...boardFx, fx]),
@@ -850,6 +852,13 @@
function endTurn() {
clearSelection();
// Preference: a lone grabbable treasure underfoot is picked up on the
// way out (pickup ends actions; ending the turn is still legal after).
if (prefs.autoGrab && view && me && !me.carriedTreasureId &&
!view.turn.actionsEnded && treasuresHere.length === 1 &&
cellKey(me.position) !== cellKey(me.home)) {
dispatch({ type: "pickUpTreasure" });
}
dispatch({ type: "endTurn", draw: drawCount });
}
@@ -942,6 +951,13 @@
// Press-and-hold on a wall/door/fire segment (touch has no hover).
let peekEdge = $state<string | null>(null);
/** The preferences slip (client-side taste, saved on this device). */
let prefsOpen = $state(false);
function setPref<K extends keyof typeof prefs>(k: K, v: (typeof prefs)[K]) {
prefs[k] = v;
savePrefs();
}
// A shimmering illusion wall, tapped or bumped: offer the belief test.
let illusionPrompt = $state<{ cell: { x: number; y: number }; side: Side } | null>(null);
function illusionEdgeParts(key: string): { cell: { x: number; y: number }; side: Side } {
@@ -1097,6 +1113,7 @@
<header class="masthead">
<span class="mast-title">Wiz-War</span>
<span class="mast-sub">sixth edition</span>
<button class="mast-leave" title="preferences" onclick={() => (prefsOpen = !prefsOpen)}> preferences</button>
{#if local.active}
<span class="mast-room">hotseat</span>
<button class="mast-leave" onclick={() => local.leave()}>set the game aside</button>
@@ -1180,6 +1197,37 @@
</div>
{/if}
{#if prefsOpen}
<div class="scrim attack-scrim" role="button" tabindex="-1"
onclick={() => (prefsOpen = false)} onkeydown={() => {}}>
<div class="attack-notice prefs-slip" role="dialog" aria-label="preferences" tabindex="-1"
onclick={(ev) => ev.stopPropagation()} onkeydown={() => {}}>
<div class="attack-title">Preferences</div>
<div class="pref-row">
<span>Token art</span>
<button class="stamp tiny" class:lit={prefs.art === "photo"}
onclick={() => setPref("art", "photo")}>cardboard</button>
<button class="stamp tiny" class:lit={prefs.art === "drawn"}
onclick={() => setPref("art", "drawn")}>hand-drawn</button>
</div>
<label class="pref-row">
<input type="checkbox" checked={prefs.autoGrab}
onchange={(e) => setPref("autoGrab", e.currentTarget.checked)} />
<span>Ending my turn on a lone treasure picks it up (never on my own home)</span>
</label>
<label class="pref-row">
<input type="checkbox" checked={prefs.flourishes}
onchange={(e) => setPref("flourishes", e.currentTarget.checked)} />
<span>Spell flourishes (the animated effects)</span>
</label>
<div class="pref-note">Saved on this device. Rules are never affected.</div>
<div class="attack-actions">
<button class="hint-cancel" onclick={() => (prefsOpen = false)}>done</button>
</div>
</div>
</div>
{/if}
{#if faqCardId}
<Faq cardId={faqCardId} onclose={() => (faqCardId = null)} />
{/if}
@@ -2394,6 +2442,15 @@
.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.discard-one { background: #8a7a5e; }
.prefs-slip { text-align: left; }
.pref-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.55rem 0;
font-size: 0.85rem;
}
.pref-note { font-size: 0.72rem; color: #8a7a5e; margin-top: 0.6rem; }
.slip.council { background: #ece4f1; border-color: #7a6f9a; }
.council-head {
font-weight: 700;
+2 -1
View File
@@ -2,6 +2,7 @@
import Board from "./Board.svelte";
import { humanize } from "./net.svelte";
import { scheduleFx, type BoardFx } from "./fx";
import { prefs } from "./prefs.svelte";
import { stackSightTrace } from "@wizwar/engine";
import type { GameEvent, GameView } from "@wizwar/engine";
@@ -30,7 +31,7 @@
let boardFx = $state<BoardFx[]>([]);
$effect(() => {
const step = steps[Math.min(idx, steps.length - 1)];
if (!step) return;
if (!step || !prefs.flourishes) return;
const cancel = scheduleFx(
step.events, step.view,
(fx) => (boardFx = [...boardFx, fx]),
+10 -10
View File
@@ -1,19 +1,19 @@
// The art workshop's side door (deliberately undocumented): query params
// like ?svgPlayers=true&svgTerrain=true swap token categories to the
// hand-drawn SVG set in public/tokens-svg. Under `npm run dev`, editing
// those files shows on the next refresh.
// Which art set a token draws from: the player's preference, with the
// workshop's per-category query params (?svgPlayers=true, ...) as a
// deliberate override for editing sessions under `npm run dev`.
import { prefs } from "./prefs.svelte";
export type ArtCategory = "players" | "creatures" | "terrain" | "objects";
const q = new URLSearchParams(typeof location === "undefined" ? "" : location.search);
const SVG_ART: Record<ArtCategory, boolean> = {
players: q.get("svgPlayers") === "true",
creatures: q.get("svgCreatures") === "true",
terrain: q.get("svgTerrain") === "true",
objects: q.get("svgObjects") === "true",
const PARAM: Record<ArtCategory, string> = {
players: "svgPlayers", creatures: "svgCreatures", terrain: "svgTerrain", objects: "svgObjects",
};
export function tokenArt(file: string, cat: ArtCategory): string {
return SVG_ART[cat] ? `/tokens-svg/${file}.svg` : `/tokens/${file}.png`;
const override = q.get(PARAM[cat]);
const drawn = override !== null ? override === "true" : prefs.art === "drawn";
return drawn ? `/tokens-svg/${file}.svg` : `/tokens/${file}.png`;
}
// Which token file depicts each board thing (shared by the board renderer
+41
View File
@@ -0,0 +1,41 @@
// Player preferences: purely client-side taste, saved on this device.
// Nothing here touches the rules — a preference may change what you SEE
// or what routine commands the client sends on your behalf, never what
// is legal.
export interface Prefs {
/** Token art: the photographed cardboard, or the hand-drawn vectors. */
art: "photo" | "drawn";
/** Standing on lone grabbable gold at end of turn: pick it up first. */
autoGrab: boolean;
/** Spell flourishes (the animated effects layer). */
flourishes: boolean;
}
const KEY = "wizwar-prefs";
function load(): Prefs {
const fallback: Prefs = { art: "photo", autoGrab: false, flourishes: true };
try {
const raw = localStorage.getItem(KEY);
if (!raw) return fallback;
const p = JSON.parse(raw) as Partial<Prefs>;
return {
art: p.art === "drawn" ? "drawn" : "photo",
autoGrab: p.autoGrab === true,
flourishes: p.flourishes !== false,
};
} catch {
return fallback;
}
}
export const prefs = $state<Prefs>(load());
export function savePrefs(): void {
try {
localStorage.setItem(KEY, JSON.stringify(prefs));
} catch {
// A full or blocked localStorage loses persistence, not the session.
}
}