diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 346406c..5420f67 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -113,6 +113,13 @@ * dimensions behind when the first-person pane closes. */ let boardViewportEl = $state(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(".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[0]) { @@ -2844,10 +2872,10 @@ }}>Drop it here {/if} {#if selectedCard && isYourTurn && !youMustRespond} - + }}>discard it instead {/if} {#if selectedCard?.cardId === "add"} — ADD rides a second NUMBER: play one number for movement, @@ -2870,14 +2898,25 @@ {/if} {/snippet}
- {#if view.you && !net.spectating} -
- + +
+
+ {#if view.you && !net.spectating} + + {/if}
- {/if} +
+ + {Math.round(boardZoom * 100)}% + + +
+
{#if stepCaption && me}
@@ -2928,10 +2967,6 @@ povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null} />
-
- - -
@@ -3209,6 +3244,7 @@ {/if} {:else if selectedCard} + {@render tableGuidance()}
(faqCardId = id)} />
{#if attachedNumber || attachedMods.length > 0}
riding along
@@ -3225,7 +3261,7 @@ {/if} {/if} {/if} - {@render tableGuidance()} + {#if youMustRespond}{@render tableGuidance()}{/if} {/if}
- {#if isYourTurn && onWarpToken} - + +
+ {#if dockLead && isYourTurn} +
+ {dockLead} + {#if selectedCard?.cardId === "teleport" && pendingTeleport} + + {/if} + +
{/if} {#if isYourTurn} - - {#if pickChoice} - - — whose? - {#each treasuresHere as t (t.id)} - +
+
+ {#if onWarpToken} + + {/if} + + {#if pickChoice} + + — whose? + {#each treasuresHere as t (t.id)} + + {/each} + + + {/if} + + {#each objectsHere as obj (obj.instanceId)} + {/each} - - - {/if} - - {#each objectsHere as obj (obj.instanceId)} - - {/each} - {#each tearTargets as t (t.id)} - - {/each} - - - - - {#if net.pending} - {Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}… - {:else if net.confirmed} - ✓ {net.confirmed} confirmed - {/if} - + {#each tearTargets as t (t.id)} + + {/each} +
+ +
+ + + +
+
+ + {#if net.pending} + {Date.now() - net.pending.at > 4000 ? "still sending" : "sending"} {net.pending.label}… + {:else if net.confirmed} + ✓ {net.confirmed} confirmed + {/if} + +
+
{/if}
@@ -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;