View controls above the board, decisions below it, the account beside it
The board's controls had three homes: zoom in the corner, the eyes toggle floating above it, and the turn's actions wrapping into rows beneath. Now a toolbar hugs the board's top — First-person at the left; − · level · + · Fit at the right — and a dock sits beneath it in three groups: what the square offers, the tools (punch a wall, hints, discard) set quieter, and draw beside End turn pinned at the right. While a card is aimed the dock leads with the decision in a line — "Choose a destination · lit squares are within 4 · ends your movement" — and a cancel, so the lit squares always say what they mean. The panel beside the board keeps the card and the full account, now with the instruction first and discarding demoted to a quiet last resort. A phone keeps the offers and End turn in view and its tools behind More. With the hand at the left, the columns grow with the cards so they no longer overlap. 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
648f6ab8cd
commit
7a6aa3d2a1
+135
-42
@@ -113,6 +113,13 @@
|
||||
* dimensions behind when the first-person pane closes. */
|
||||
let boardViewportEl = $state<HTMLDivElement | null>(null);
|
||||
let boardZoom = $state(1);
|
||||
/** Back to the whole maze at once. */
|
||||
function resetZoom() {
|
||||
boardZoom = 1;
|
||||
if (boardViewportEl) requestAnimationFrame(() => { boardViewportEl!.scrollLeft = 0; boardViewportEl!.scrollTop = 0; });
|
||||
}
|
||||
/** The phone's dock keeps its secondary tools behind "More". */
|
||||
let moreOpen = $state(false);
|
||||
function zoomBoard(dir: number) {
|
||||
const el = boardViewportEl;
|
||||
const prev = boardZoom;
|
||||
@@ -132,6 +139,8 @@
|
||||
if (!vb || !vb.width || !vb.height) return;
|
||||
const fitW = Math.min(el.clientWidth, (el.clientHeight * vb.width) / vb.height);
|
||||
el.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
|
||||
// The toolbar above the board hugs the board's drawn width, not the column's.
|
||||
el.closest<HTMLElement>(".board-zone")?.style.setProperty("--fit-w", `${Math.max(0, fitW)}px`);
|
||||
}
|
||||
$effect(() => {
|
||||
const el = boardViewportEl;
|
||||
@@ -600,6 +609,25 @@
|
||||
const cellSelectMode = $derived(
|
||||
(selectedCard != null && CELL_CARDS.has(selectedCard.cardId)) || pendingCellFor !== null,
|
||||
);
|
||||
/** The dock's leading line while a card is being aimed: the decision in
|
||||
* a few words, beside the board where the tap happens. The panel beside
|
||||
* the board keeps the card and the full account. */
|
||||
const dockLead = $derived.by(() => {
|
||||
if (!selectedCard || !selectedDef || youMustRespond) return null;
|
||||
const id = selectedCard.cardId;
|
||||
if (id === "teleport") return pendingTeleport
|
||||
? "Tap the marked square again to jump · or pick another"
|
||||
: "Choose a destination · lit squares are within 4 · ends your movement";
|
||||
if (id === "mental-force") return "Choose the wizard, then where they go · within 3 walked squares";
|
||||
if (EDGE_CARDS.has(id)) return "Tap a wall line · lit lines are legal";
|
||||
if (cellSelectMode) return "Choose a square · lit squares are legal targets";
|
||||
if (selectedDef.cardType === "attack") return selectedDef.adjacent
|
||||
? "Choose a target · in your square or the one beside you"
|
||||
: "Choose a target · lit squares are in your sight";
|
||||
if (selectedDef.cardType === "number") return "A NUMBER · play it for movement, or tap a spell to power it";
|
||||
if (selectedDef.los) return "Choose a target · lit squares are in your sight";
|
||||
return `${selectedDef.name} · its panel offers the choices`;
|
||||
});
|
||||
const numberTotal = $derived(attachedNumber ? cardDef(attachedNumber.cardId).value! : 1);
|
||||
|
||||
function dispatch(command: Parameters<typeof net.command>[0]) {
|
||||
@@ -2844,10 +2872,10 @@
|
||||
}}>Drop it here</button>
|
||||
{/if}
|
||||
{#if selectedCard && isYourTurn && !youMustRespond}
|
||||
<button class="stamp tiny discard-one" onclick={() => {
|
||||
<button class="hint-cancel discard-one" title="throw this card away instead of playing it" onclick={() => {
|
||||
if (selectedCard) dispatch({ type: "discard", instanceIds: [selectedCard.instanceId] });
|
||||
clearSelection();
|
||||
}}>Discard it</button>
|
||||
}}>discard it instead</button>
|
||||
{/if}
|
||||
{#if selectedCard?.cardId === "add"}
|
||||
<span>— ADD rides a second NUMBER: play one number for movement,
|
||||
@@ -2870,14 +2898,25 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
<section class="board-zone">
|
||||
<!-- View controls above the board, in one row: the way of looking
|
||||
at the left, the zoom at the right. -->
|
||||
<div class="board-tools">
|
||||
<div class="tools-left">
|
||||
{#if view.you && !net.spectating}
|
||||
<div class="view-toggle">
|
||||
<button class="stamp tiny" class:lit={prefs.liveFp} onclick={() => setPref("liveFp", !prefs.liveFp)}
|
||||
<button class="tool" class:on={prefs.liveFp} aria-pressed={prefs.liveFp}
|
||||
onclick={() => setPref("liveFp", !prefs.liveFp)}
|
||||
title="a first-person pane above the board, through your wizard's eyes">
|
||||
{prefs.liveFp ? "▦ the board only" : "👁 through your eyes"}
|
||||
👁 First-person
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="tools-right" role="group" aria-label="board zoom">
|
||||
<button class="tool" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}>−</button>
|
||||
<span class="zoom-level" aria-live="polite">{Math.round(boardZoom * 100)}%</span>
|
||||
<button class="tool" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
|
||||
<button class="tool" title="the whole maze at once" onclick={resetZoom} disabled={boardZoom === 1}>Fit</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if stepCaption && me}
|
||||
<div class="step-caption">
|
||||
<span class="step-chip" style:background={playerColor(me.id)}></span>
|
||||
@@ -2928,10 +2967,6 @@
|
||||
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
|
||||
/>
|
||||
</div>
|
||||
<div class="zoom-controls" role="group" aria-label="board zoom">
|
||||
<button class="zoom-btn" title="zoom in" onclick={() => zoomBoard(1)} disabled={boardZoom >= 4}>+</button>
|
||||
<button class="zoom-btn" title="zoom out" onclick={() => zoomBoard(-1)} disabled={boardZoom <= 1}>−</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -3209,6 +3244,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else if selectedCard}
|
||||
{@render tableGuidance()}
|
||||
<div class="cast-panel-card"><Card card={selectedCard} onfaq={(id) => (faqCardId = id)} /></div>
|
||||
{#if attachedNumber || attachedMods.length > 0}
|
||||
<div class="cast-panel-sub">riding along</div>
|
||||
@@ -3225,7 +3261,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{@render tableGuidance()}
|
||||
{#if youMustRespond}{@render tableGuidance()}{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="chronicle" class:tucked={castPanelOn} aria-label="game log" bind:this={chronicleEl}
|
||||
@@ -3304,11 +3340,25 @@
|
||||
{@render tableGuidance()}
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
{#if isYourTurn && onWarpToken}
|
||||
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
|
||||
<!-- The dock: decisions below the board. What the square offers at
|
||||
the left, the tools in the middle, the turn's end at the right —
|
||||
and, while a card is aimed, the decision in one line on top. -->
|
||||
<div class="dock" class:targeting={dockLead != null}>
|
||||
{#if dockLead && isYourTurn}
|
||||
<div class="dock-lead">
|
||||
<span>{dockLead}</span>
|
||||
{#if selectedCard?.cardId === "teleport" && pendingTeleport}
|
||||
<button class="stamp tiny primary" onclick={confirmTeleport}>teleport!</button>
|
||||
{/if}
|
||||
<button class="hint-cancel dock-cancel" onclick={clearSelection}>cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isYourTurn}
|
||||
<div class="dock-row">
|
||||
<div class="dock-context">
|
||||
{#if onWarpToken}
|
||||
<button class="stamp" onclick={() => dispatch({ type: "warpStep" })}>Step through the warp</button>
|
||||
{/if}
|
||||
<button class="stamp" disabled={!treasureHere || carryingTreasure || view.turn.actionsEnded}
|
||||
onclick={pickUp}>Pick up treasure</button>
|
||||
{#if pickChoice}
|
||||
@@ -3331,11 +3381,17 @@
|
||||
onclick={() => dispatch({ type: "tearTreasure", targetId: t.id })}>
|
||||
Tear the treasure from {t.id}'s arms</button>
|
||||
{/each}
|
||||
<button class="stamp" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||||
</div>
|
||||
<button class="stamp tiny more-btn" onclick={() => (moreOpen = !moreOpen)} aria-expanded={moreOpen}>
|
||||
{moreOpen ? "Less" : "More…"}</button>
|
||||
<div class="dock-tools" class:open={moreOpen}>
|
||||
<button class="stamp quiet" class:primary={punchWallMode} disabled={view.turn.attackUsed && !punchWallMode}
|
||||
onclick={() => (punchWallMode = !punchWallMode)}>
|
||||
{punchWallMode ? "Click the wall to punch — or cancel" : "Punch a wall…"}</button>
|
||||
<button class="stamp" onclick={askCouncil}>🎓 Ask the automatons</button>
|
||||
<button class="stamp" onclick={startDiscardMode}>Discard cards…</button>
|
||||
<button class="stamp quiet" onclick={askCouncil}>🎓 Hints</button>
|
||||
<button class="stamp quiet" onclick={startDiscardMode}>Discard cards…</button>
|
||||
</div>
|
||||
<div class="dock-turn">
|
||||
<label class="inline draw-pick">
|
||||
draw
|
||||
<select bind:value={drawCount}>
|
||||
@@ -3350,6 +3406,8 @@
|
||||
<span class="sending ok" aria-live="polite">✓ {net.confirmed} confirmed</span>
|
||||
{/if}
|
||||
<button class="stamp primary" onclick={endTurn} disabled={offline}>End turn</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3752,7 +3810,35 @@
|
||||
margin: 0.2rem 0 0.1rem;
|
||||
}
|
||||
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
|
||||
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
|
||||
.board-tools {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 auto 0.35rem;
|
||||
min-height: 1.9rem;
|
||||
width: 100%;
|
||||
max-width: max(var(--fit-w, 100%), 24rem);
|
||||
}
|
||||
.tools-left, .tools-right { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.tool {
|
||||
height: 1.7rem;
|
||||
min-width: 1.7rem;
|
||||
padding: 0 0.55rem;
|
||||
border: 1px solid #8a7a5e;
|
||||
border-radius: 4px;
|
||||
background: rgba(233, 225, 203, 0.9);
|
||||
color: #43331f;
|
||||
font-family: "Oswald", sans-serif;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tool:disabled { opacity: 0.35; cursor: default; }
|
||||
.tool.on { background: #43331f; color: #e9e1cb; border-color: #43331f; }
|
||||
.zoom-level { font-family: "Courier Prime", monospace; font-size: 0.75rem; color: #c9bd9f; min-width: 2.6rem; text-align: center; }
|
||||
.step-caption {
|
||||
align-self: flex-start;
|
||||
max-width: calc(100% - 11rem);
|
||||
@@ -4047,7 +4133,6 @@
|
||||
.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.discard-one { background: #8a7a5e; }
|
||||
.prefs-slip { text-align: left; }
|
||||
.pref-row {
|
||||
display: flex;
|
||||
@@ -4165,7 +4250,7 @@
|
||||
* most six, and the hand must span them all to hold the column. */
|
||||
grid-row: 1 / span 6;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 8.2rem);
|
||||
grid-template-columns: repeat(2, calc(8.2rem * var(--card-zoom, 1)));
|
||||
justify-content: center;
|
||||
align-content: start;
|
||||
align-self: start;
|
||||
@@ -4213,26 +4298,6 @@
|
||||
height: auto;
|
||||
margin: auto;
|
||||
}
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
right: 0.4rem;
|
||||
bottom: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.zoom-btn {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
border: 1px solid #8a7a5e;
|
||||
border-radius: 4px;
|
||||
background: rgba(233, 225, 203, 0.9);
|
||||
color: #43331f;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.zoom-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.paper-rail { display: flex; flex-direction: column; gap: 0.75rem; min-width: 0; }
|
||||
|
||||
.slip {
|
||||
@@ -4263,7 +4328,7 @@
|
||||
align-items: baseline;
|
||||
}
|
||||
.offline-note { font-style: italic; color: #6b3a2f; }
|
||||
.game.offline .hand, .game.offline .board-zone, .game.offline .actions .stamp { pointer-events: none; opacity: 0.55; }
|
||||
.game.offline .hand, .game.offline .board-zone, .game.offline .dock .stamp { pointer-events: none; opacity: 0.55; }
|
||||
.sending {
|
||||
font-family: "Courier Prime", monospace;
|
||||
font-size: 0.78rem;
|
||||
@@ -4461,7 +4526,30 @@
|
||||
color: #43331f;
|
||||
}
|
||||
|
||||
.actions { position: relative; display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; min-height: 2rem; }
|
||||
/* The dock: one row, three homes. The square's offers at the left, the
|
||||
* tools in the middle, the turn's end pinned at the right whatever wraps. */
|
||||
.dock { position: relative; display: flex; flex-direction: column; gap: 0.45rem; min-height: 2.4rem; }
|
||||
.dock-row { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; }
|
||||
.dock-context, .dock-tools { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; }
|
||||
.dock-tools { padding-left: 0.55rem; border-left: 1px solid rgba(233, 225, 203, 0.25); }
|
||||
.dock-turn { position: relative; margin-left: auto; display: flex; align-items: center; gap: 0.55rem; }
|
||||
.stamp.quiet { background: rgba(233, 225, 203, 0.14); color: #e9e1cb; border-color: rgba(233, 225, 203, 0.55); box-shadow: none; }
|
||||
.stamp.quiet:hover:not(:disabled) { background: rgba(233, 225, 203, 0.26); }
|
||||
.more-btn { display: none; }
|
||||
.dock-lead {
|
||||
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.4rem 0.7rem;
|
||||
font-size: 0.92rem;
|
||||
color: #e9e1cb;
|
||||
}
|
||||
.dock-cancel { margin-left: auto; }
|
||||
.discard-one { margin-left: auto; opacity: 0.8; }
|
||||
.draw-pick { color: #a49c86; font-size: 0.9rem; }
|
||||
|
||||
.attack-scrim {
|
||||
@@ -4787,6 +4875,11 @@
|
||||
.inspector-card { transform: scale(1.3); }
|
||||
.board-zone { order: 1; }
|
||||
.table-edge { order: 2; margin-top: 0.6rem; }
|
||||
/* The phone's dock stays compact: the offers and the turn's end in
|
||||
view, the tools behind More. */
|
||||
.more-btn { display: inline-block; }
|
||||
.dock-tools { display: none; border-left: 0; padding-left: 0; flex-basis: 100%; }
|
||||
.dock-tools.open { display: flex; }
|
||||
.paper-rail { order: 3; margin-top: 0.9rem; }
|
||||
.board-viewport {
|
||||
height: 56dvh;
|
||||
|
||||
Reference in New Issue
Block a user