diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 0d5be81..b7c250b 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -119,6 +119,55 @@ ? (view.creatures.find((c) => c.id === selectedCreature) ?? null) : null, ); + + /** The 5x5 sector a picked-up square belongs to (its origin), or null. */ + const pendingSectorOrigin = $derived.by(() => { + if (!view || !pendingSectorFrom) return null; + const p = view.board.placements.find( + (p) => + pendingSectorFrom!.x >= p.origin.x && pendingSectorFrom!.x < p.origin.x + 5 && + pendingSectorFrom!.y >= p.origin.y && pendingSectorFrom!.y < p.origin.y + 5, + ); + return p ? p.origin : null; + }); + + /** Legal empty slots the lifted sector may land on — mirroring the + * engine's relocateSector checks: on the 5-grid, non-negative, vacant, + * and leaving every sector adjacent to at least one other. */ + const relocateGhosts = $derived.by(() => { + if (!view || selectedCard?.cardId !== "relocate-sector" || !pendingSectorOrigin) return null; + const origins = view.board.placements.map((p) => p.origin); + const idx = origins.findIndex( + (o) => o.x === pendingSectorOrigin!.x && o.y === pendingSectorOrigin!.y, + ); + const adjacent = (a: { x: number; y: number }, b: { x: number; y: number }) => + (Math.abs(a.x - b.x) === 5 && a.y === b.y) || (Math.abs(a.y - b.y) === 5 && a.x === b.x); + const seen = new Set(); + const out: { x: number; y: number }[] = []; + for (let i = 0; i < origins.length; i++) { + if (i === idx) continue; + for (const [dx, dy] of [[5, 0], [-5, 0], [0, 5], [0, -5]] as const) { + const c = { x: origins[i]!.x + dx, y: origins[i]!.y + dy }; + const k = `${c.x},${c.y}`; + if (seen.has(k)) continue; + seen.add(k); + if (c.x < 0 || c.y < 0) continue; + if (origins.some((o) => o.x === c.x && o.y === c.y)) continue; + const next = origins.map((o, j) => (j === idx ? c : o)); + if (next.every((o, j) => next.some((p, m) => m !== j && adjacent(o, p)))) out.push(c); + } + } + return out; + }); + + function clickGhostSlot(origin: { x: number; y: number }) { + if (!selectedCard || !pendingSectorFrom) return; + dispatch({ + type: "cast", instanceId: selectedCard.instanceId, + target: { kind: "cell", cell: origin }, params: { cell: pendingSectorFrom }, + }); + clearSelection(); + } const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false); /** A live moment to interrupt: another wizard acts, no attack is pending. */ const canInterruptNow = $derived( @@ -364,15 +413,9 @@ return; } if (selectedCard?.cardId === "relocate-sector") { - if (!pendingSectorFrom) { - pendingSectorFrom = cell; // first click: the sector to move - return; - } - dispatch({ - type: "cast", instanceId: selectedCard.instanceId, - target: { kind: "cell", cell }, params: { cell: pendingSectorFrom }, - }); - clearSelection(); + // Any board click (re-)picks the sector; the landing is chosen from the + // dashed ghost slots beyond the maze, since every on-board slot is taken. + pendingSectorFrom = cell; return; } if (selectedCard?.cardId === "boobytrap") { @@ -1115,7 +1158,10 @@ onPlayerClick={clickPlayer} onCreatureClick={clickCreature} onWarpClick={clickWarp} - markedCell={tradeFrom ?? pendingSectorFrom} + markedCell={tradeFrom} + markedSector={pendingSectorOrigin} + ghostSlots={relocateGhosts} + onGhostClick={clickGhostSlot} onCellPeek={(c) => peekAt(c)} {litCells} /> @@ -1345,7 +1391,11 @@ — click the sector {/if} {#if selectedCard?.cardId === "relocate-sector"} - {pendingSectorFrom ? "— now the destination area" : "— click the sector to move"} + {pendingSectorFrom + ? (relocateGhosts?.length + ? "— now tap a dashed landing beside the maze" + : "— that sector has nowhere legal to go; pick another") + : "— click the sector to move"} {/if} {#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)} diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index b161dfe..9db4357 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -18,6 +18,9 @@ onCellPeek, markedCell = null, litCells = null, + markedSector = null, + ghostSlots = null, + onGhostClick, }: { view: GameView; edgeSelectMode?: boolean; @@ -33,8 +36,23 @@ markedCell?: { x: number; y: number } | null; /** When set, squares NOT in this set dim — the targeting aid. */ litCells?: Set | null; + /** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */ + markedSector?: { x: number; y: number } | null; + /** Empty 5x5 slot origins a sector may land on — drawn as dashed ground + * beyond the maze, since an empty slot has no floor of its own to click. */ + ghostSlots?: { x: number; y: number }[] | null; + onGhostClick?: (origin: { x: number; y: number }) => void; } = $props(); + const SECTOR = 5; + /** Ghost slots can lie beyond the assembled maze; the canvas grows to hold them. */ + const boundsW = $derived( + Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 16, + ); + const boundsH = $derived( + Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 16, + ); + // Press-and-hold peeks the square's card; a fired hold swallows the click. let peekTimer: ReturnType | null = null; @@ -153,11 +171,23 @@ derive one from the viewBox alone and collapses the board to 0x0 inside a max-height flex column. Doubled so CSS max-* caps still govern. --> + + {#each ghostSlots ?? [] as g (`${g.x},${g.y}`)} + onGhostClick?.(g)} + onkeydown={() => {}} + /> + {/each} + {#each cells as c (`${c.x},${c.y}`)} {/if} + + {#if markedSector} + + {/if} + {#each [...wizardsByCell.entries()] as [key, group] (key)} {#each group as p, i (p.id)} @@ -607,6 +646,23 @@ pointer-events: none; animation: warp-pulse 1.6s ease-in-out infinite; } + .marked-sector { + fill: rgba(211, 133, 43, 0.12); + stroke: #d3852b; + stroke-width: 3; + stroke-dasharray: 10 6; + pointer-events: none; + animation: warp-pulse 1.6s ease-in-out infinite; + } + .ghost-slot { + fill: rgba(122, 162, 122, 0.10); + stroke: #7aa27a; + stroke-width: 2.5; + stroke-dasharray: 10 6; + cursor: pointer; + animation: warp-pulse 1.6s ease-in-out infinite; + } + .ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); } .warp-dest { fill: none; stroke: #2e7d32; @@ -620,7 +676,7 @@ 50% { opacity: 0.35; } } @media (prefers-reduced-motion: reduce) { - .marked-cell, .warp-dest { animation: none; } + .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; } } .wizard { cursor: pointer; } .wizard-label {