Relocate Sector gets ground to land on
The card asked for a destination click that did not exist: a sector relocates onto an EMPTY 5x5 slot, and empty slots have no floor to click — the board only rendered cells that are part of the maze. Now lifting a sector outlines the whole 5x5 (not one square) and dashed green landings appear beyond the maze edge, one per legal slot; the svg canvas grows to hold them. Clicking a landing completes the cast. The landings mirror the engine's own relocateSector law — on the 5-grid, non-negative, vacant, every sector still adjacent to at least one other — verified slot-for-slot against engine ground truth across 2p and 4p boards. Clicking the maze again re-picks the sector, and a sector with nowhere legal to go says so in the hint bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
18d3142f95
commit
53c84ec6cb
+61
-11
@@ -119,6 +119,55 @@
|
|||||||
? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
|
? (view.creatures.find((c) => c.id === selectedCreature) ?? null)
|
||||||
: 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<string>();
|
||||||
|
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);
|
const holdingWard = $derived(view?.yourHand.some((c) => c.cardId === "ward") ?? false);
|
||||||
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
/** A live moment to interrupt: another wizard acts, no attack is pending. */
|
||||||
const canInterruptNow = $derived(
|
const canInterruptNow = $derived(
|
||||||
@@ -364,15 +413,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.cardId === "relocate-sector") {
|
if (selectedCard?.cardId === "relocate-sector") {
|
||||||
if (!pendingSectorFrom) {
|
// Any board click (re-)picks the sector; the landing is chosen from the
|
||||||
pendingSectorFrom = cell; // first click: the sector to move
|
// dashed ghost slots beyond the maze, since every on-board slot is taken.
|
||||||
return;
|
pendingSectorFrom = cell;
|
||||||
}
|
|
||||||
dispatch({
|
|
||||||
type: "cast", instanceId: selectedCard.instanceId,
|
|
||||||
target: { kind: "cell", cell }, params: { cell: pendingSectorFrom },
|
|
||||||
});
|
|
||||||
clearSelection();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedCard?.cardId === "boobytrap") {
|
if (selectedCard?.cardId === "boobytrap") {
|
||||||
@@ -1115,7 +1158,10 @@
|
|||||||
onPlayerClick={clickPlayer}
|
onPlayerClick={clickPlayer}
|
||||||
onCreatureClick={clickCreature}
|
onCreatureClick={clickCreature}
|
||||||
onWarpClick={clickWarp}
|
onWarpClick={clickWarp}
|
||||||
markedCell={tradeFrom ?? pendingSectorFrom}
|
markedCell={tradeFrom}
|
||||||
|
markedSector={pendingSectorOrigin}
|
||||||
|
ghostSlots={relocateGhosts}
|
||||||
|
onGhostClick={clickGhostSlot}
|
||||||
onCellPeek={(c) => peekAt(c)}
|
onCellPeek={(c) => peekAt(c)}
|
||||||
{litCells}
|
{litCells}
|
||||||
/>
|
/>
|
||||||
@@ -1345,7 +1391,11 @@
|
|||||||
<span>— click the sector</span>
|
<span>— click the sector</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard?.cardId === "relocate-sector"}
|
{#if selectedCard?.cardId === "relocate-sector"}
|
||||||
<span>{pendingSectorFrom ? "— now the destination area" : "— click the sector to move"}</span>
|
<span>{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"}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
{#if selectedCard && NAMED_CARDS.has(selectedCard.cardId)}
|
||||||
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
|
<label class="inline">name <input class="text-input" list="card-name-options" bind:value={nameInput} placeholder="e.g. Fireball" /></label>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
onCellPeek,
|
onCellPeek,
|
||||||
markedCell = null,
|
markedCell = null,
|
||||||
litCells = null,
|
litCells = null,
|
||||||
|
markedSector = null,
|
||||||
|
ghostSlots = null,
|
||||||
|
onGhostClick,
|
||||||
}: {
|
}: {
|
||||||
view: GameView;
|
view: GameView;
|
||||||
edgeSelectMode?: boolean;
|
edgeSelectMode?: boolean;
|
||||||
@@ -33,8 +36,23 @@
|
|||||||
markedCell?: { x: number; y: number } | null;
|
markedCell?: { x: number; y: number } | null;
|
||||||
/** When set, squares NOT in this set dim — the targeting aid. */
|
/** When set, squares NOT in this set dim — the targeting aid. */
|
||||||
litCells?: Set<string> | null;
|
litCells?: Set<string> | 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();
|
} = $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.
|
// Press-and-hold peeks the square's card; a fired hold swallows the click.
|
||||||
let peekTimer: ReturnType<typeof setTimeout> | null = null;
|
let peekTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -153,11 +171,23 @@
|
|||||||
derive one from the viewBox alone and collapses the board to 0x0 inside
|
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. -->
|
a max-height flex column. Doubled so CSS max-* caps still govern. -->
|
||||||
<svg
|
<svg
|
||||||
viewBox={`-8 -8 ${view.board.width * CELL + 16} ${view.board.height * CELL + 16}`}
|
viewBox={`-8 -8 ${boundsW} ${boundsH}`}
|
||||||
width={(view.board.width * CELL + 16) * 2}
|
width={boundsW * 2}
|
||||||
height={(view.board.height * CELL + 16) * 2}
|
height={boundsH * 2}
|
||||||
class="board"
|
class="board"
|
||||||
>
|
>
|
||||||
|
<!-- open ground a relocated sector may claim -->
|
||||||
|
{#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
|
||||||
|
<rect
|
||||||
|
x={g.x * CELL + 2} y={g.y * CELL + 2}
|
||||||
|
width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
||||||
|
class="ghost-slot"
|
||||||
|
role="button" tabindex="-1"
|
||||||
|
onclick={() => onGhostClick?.(g)}
|
||||||
|
onkeydown={() => {}}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- floor -->
|
<!-- floor -->
|
||||||
{#each cells as c (`${c.x},${c.y}`)}
|
{#each cells as c (`${c.x},${c.y}`)}
|
||||||
<rect
|
<rect
|
||||||
@@ -388,6 +418,15 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- the sector picked up, awaiting its new ground -->
|
||||||
|
{#if markedSector}
|
||||||
|
<rect
|
||||||
|
x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
|
||||||
|
width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
|
||||||
|
rx="8" class="marked-sector"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- wizards -->
|
<!-- wizards -->
|
||||||
{#each [...wizardsByCell.entries()] as [key, group] (key)}
|
{#each [...wizardsByCell.entries()] as [key, group] (key)}
|
||||||
{#each group as p, i (p.id)}
|
{#each group as p, i (p.id)}
|
||||||
@@ -607,6 +646,23 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
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 {
|
.warp-dest {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: #2e7d32;
|
stroke: #2e7d32;
|
||||||
@@ -620,7 +676,7 @@
|
|||||||
50% { opacity: 0.35; }
|
50% { opacity: 0.35; }
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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 { cursor: pointer; }
|
||||||
.wizard-label {
|
.wizard-label {
|
||||||
|
|||||||
Reference in New Issue
Block a user