Eight quality-of-life turns for strangers, newcomers, and veterans
For visitors: the landing shows a clip card of the maze before asking for a name; "Play now against a clockwork wizard" is one tap from the landing to the first turn (the room, the seat, and the flip each follow as the server confirms the last); pass-the-device and seat transfer fold behind "More ways to play". For a first game: automatons default to apprentice until this browser has seen a game through, then step up to adept unless the tier was picked by hand; the council speaks once unasked on a first turn; a finished game offers "play again" (the same bots re-seated in a fresh room) and "share the tale" (the whole replay as a link). For players who know the box: a House rulings tab in help — what the digital table does differently, every rulings revision (now exported from the engine as RULES_REVISIONS, which the deckRev doc block points at), and the 76 cards the FAQ has spoken on. And the arrow keys and WASD walk on the board as they already did in the first-person pane. The recorder's poster settle lengthens to outlast a change-of-eyes wipe; two cards had caught the title card instead of the maze. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2b5c2c4884
commit
53fba77a55
+118
-3
@@ -244,7 +244,7 @@
|
||||
let faqCardId = $state<string | null>(null);
|
||||
/** A discard-pile card enlarged above the pile. */
|
||||
let discardPeek = $state<CardInstance | null>(null);
|
||||
let helpTab = $state<"play" | "rules" | "cards" | "about" | "tally">("play");
|
||||
let helpTab = $state<"play" | "rules" | "cards" | "rulings" | "about" | "tally">("play");
|
||||
let hotseatCount = $state(2);
|
||||
let setupName = $state("");
|
||||
let setupColor = $state(0);
|
||||
@@ -1316,6 +1316,94 @@
|
||||
councilHints = tableHints(view);
|
||||
}
|
||||
|
||||
/** "Play now": one tap from the landing to a game against a clockwork
|
||||
* wizard. The room is created, a bot seated, the boards flipped, each
|
||||
* step as the server confirms the last; the same chain re-seats the
|
||||
* bots of a finished game for "play again". */
|
||||
let playNowPending = $state<{ stage: "create" | "seating" | "starting"; bots: { style?: string; tier: string }[]; seated: number } | null>(null);
|
||||
function playNow() {
|
||||
setPref("wizardName", name.trim());
|
||||
playNowPending = { stage: "create", bots: [{ tier: prefs.botTier }], seated: 0 };
|
||||
net.create(name.trim());
|
||||
}
|
||||
function playAgain() {
|
||||
// The bots of the table just finished, as the roster names them ("adept hunter").
|
||||
const bots = Object.values(net.roomBots).map((label) => {
|
||||
const [tier, style] = String(label).split(" ");
|
||||
return { tier: tier ?? prefs.botTier, style: style && style !== "mystery" ? style : undefined };
|
||||
});
|
||||
const me = net.you ?? name.trim();
|
||||
net.leave();
|
||||
playNowPending = { stage: "create", bots: bots.length ? bots : [{ tier: prefs.botTier }], seated: 0 };
|
||||
setTimeout(() => net.create(me), 300);
|
||||
}
|
||||
$effect(() => {
|
||||
const p = playNowPending;
|
||||
if (!p || !net.roomId || net.you !== net.hostId) return;
|
||||
if (net.started) { playNowPending = null; return; }
|
||||
const botsNow = Object.keys(net.roomBots).length;
|
||||
if (p.stage !== "starting" && botsNow < p.bots.length && botsNow === p.seated) {
|
||||
const b = p.bots[botsNow]!;
|
||||
net.addBot(b.style, b.tier);
|
||||
playNowPending = { ...p, stage: "seating", seated: botsNow + 1 };
|
||||
} else if (p.stage !== "starting" && botsNow >= p.bots.length && net.players.length >= 2) {
|
||||
net.start(withExpansion);
|
||||
playNowPending = { ...p, stage: "starting" };
|
||||
}
|
||||
});
|
||||
|
||||
/** The whole tale, as a link to hand around. */
|
||||
let taleState = $state<"idle" | "minting" | "copied" | "failed">("idle");
|
||||
async function shareTale() {
|
||||
if (taleState === "minting") return;
|
||||
taleState = "minting";
|
||||
try {
|
||||
const url = await net.requestShare(-1);
|
||||
await navigator.clipboard.writeText(url);
|
||||
taleState = "copied";
|
||||
} catch {
|
||||
taleState = "failed";
|
||||
}
|
||||
setTimeout(() => (taleState = "idle"), 4000);
|
||||
}
|
||||
|
||||
/** A finished game counts toward this browser's experience: after the
|
||||
* first, the automatons step up from apprentice unless the tier was
|
||||
* picked by hand. */
|
||||
let finishedRoom: string | null = null;
|
||||
$effect(() => {
|
||||
if (view?.phase !== "finished" || !view.winner || local.active) return;
|
||||
const room = net.roomId;
|
||||
if (!room || finishedRoom === room) return;
|
||||
finishedRoom = room;
|
||||
setPref("finishedGames", prefs.finishedGames + 1);
|
||||
if (!prefs.tierChosen && prefs.botTier === "apprentice") setPref("botTier", "adept");
|
||||
});
|
||||
|
||||
/** On a first turn the council speaks unasked, once — a newcomer will
|
||||
* not know to ask, and a minute's silence is how they leave. */
|
||||
let counciled = false;
|
||||
$effect(() => {
|
||||
if (!view || !isYourTurn || prefs.coached || view.turn.round !== 1 || counciled || councilHints !== null) return;
|
||||
counciled = true;
|
||||
void askCouncil();
|
||||
});
|
||||
|
||||
/** Arrow keys and WASD walk on the board, as they do in the first-person
|
||||
* pane — that pane takes the keys itself while it is up. */
|
||||
function onBoardKey(e: KeyboardEvent) {
|
||||
if (!view || !isYourTurn || prefs.liveFp || net.spectating || showHelp || caution || youMustRespond) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT" || t.isContentEditable)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
const side: Side | null =
|
||||
k === "arrowup" || k === "w" ? "N" : k === "arrowdown" || k === "s" ? "S" :
|
||||
k === "arrowleft" || k === "a" ? "W" : k === "arrowright" || k === "d" ? "E" : null;
|
||||
if (!side) return;
|
||||
e.preventDefault();
|
||||
tryMove(side);
|
||||
}
|
||||
|
||||
let pickChoice = $state(false);
|
||||
function pickUp() {
|
||||
if (treasuresHere.length > 1) { pickChoice = true; return; }
|
||||
@@ -1442,6 +1530,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onBoardKey} />
|
||||
|
||||
{#if fpvWorkshop}
|
||||
<FpvWorkshop />
|
||||
{:else if tokenWorkshop}
|
||||
@@ -1984,11 +2074,17 @@
|
||||
<button class="about-link" onclick={() => { helpTab = "about"; showHelp = true; }}>
|
||||
about this game — a labor of love
|
||||
</button>
|
||||
<a class="hero" href="/clips" aria-label="watch clips from the labyrinth">
|
||||
<img src="/hero.jpg" alt="Through a wizard's eyes: an ambush watches its corridor through a wormhole" />
|
||||
</a>
|
||||
<a class="about-link clips-link" href="/clips">watch clips from the labyrinth</a>
|
||||
<label class="field">
|
||||
<span>Your wizard's name</span>
|
||||
<input bind:value={name} maxlength="20" placeholder="e.g. Mordecai" />
|
||||
</label>
|
||||
<button class="stamp primary play-now" disabled={!name.trim() || playNowPending !== null} onclick={playNow}>
|
||||
{playNowPending ? "Flipping the boards…" : "Play now against a clockwork wizard"}
|
||||
</button>
|
||||
<div class="boxlid-actions">
|
||||
<button class="stamp" disabled={!name.trim()} onclick={() => { setPref("wizardName", name.trim()); net.create(name); }}>
|
||||
Create a game
|
||||
@@ -2005,6 +2101,8 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<details class="more-ways" open={local.hasSave()}>
|
||||
<summary>More ways to play — pass one device around, or claim a transferred seat</summary>
|
||||
<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">
|
||||
@@ -2032,6 +2130,7 @@
|
||||
Claim a transferred seat
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{#if net.seats.length > 0}
|
||||
<div class="ledger">
|
||||
@@ -2157,7 +2256,7 @@
|
||||
<span class="bot-row">
|
||||
<span class="bot-label">⚙ seat a</span>
|
||||
<select class="tier-pick" bind:value={botTier} aria-label="automaton difficulty"
|
||||
onchange={() => setPref("botTier", botTier)}>
|
||||
onchange={() => { setPref("botTier", botTier); setPref("tierChosen", true); }}>
|
||||
<option value="apprentice">apprentice</option>
|
||||
<option value="adept">adept</option>
|
||||
<option value="archmage">archmage</option>
|
||||
@@ -2449,7 +2548,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.phase === "finished"}
|
||||
<div class="slip winner">🏆 {view.winner} wins!</div>
|
||||
<div class="slip winner">🏆 {view.winner} wins!
|
||||
{#if !local.active}
|
||||
{#if net.you === net.hostId}
|
||||
<button class="stamp tiny" onclick={playAgain}>play again</button>
|
||||
{/if}
|
||||
<button class="stamp tiny" onclick={shareTale}>
|
||||
{taleState === "idle" ? "share the tale" : taleState === "minting" ? "…" : taleState === "copied" ? "link copied" : "share failed"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if youMustRespond}
|
||||
<div class="slip urgent">
|
||||
{#if reflectSwapCard}
|
||||
@@ -3175,6 +3283,13 @@
|
||||
color: #6b5a41;
|
||||
margin: 0.5rem 0 0.4rem;
|
||||
}
|
||||
.hero { display: block; margin: 0.6rem auto 0; max-width: 24rem; border-radius: 6px; overflow: hidden;
|
||||
border: 1.5px solid #43331f; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35); }
|
||||
.hero img { display: block; width: 100%; }
|
||||
.play-now { display: block; margin: 0.2rem auto 0.8rem; font-size: 0.95rem; padding: 0.6rem 1.2rem; }
|
||||
.more-ways { margin-top: 1.2rem; }
|
||||
.more-ways summary { cursor: pointer; font-family: "Caveat", cursive; font-size: 1.05rem; color: #8a6d3f; }
|
||||
.more-ways summary:hover { color: #43331f; }
|
||||
.lobby-head {
|
||||
font-family: "Oswald", sans-serif;
|
||||
text-transform: uppercase;
|
||||
|
||||
Reference in New Issue
Block a user