The Alter Ego casts from its own square; the first-stride cue leaves the maze

Randy raised his double, selected it, and could cast nothing through
it: the card's whole point — "it may use any of the spells in your
hand, and you need not be in its L.O.S." — had never been wired. A
cast may now name the double, and the double's square becomes the
spell's origin: sight, reach, the launch point the reel films, and
the direction a knockback or rout pushes. The hand, the attack, and
every answer to it stay the caster's. Spells that move the caster's
own body — a goat's charge, a swap — do not travel through it. On the
board, the double's sight lights the eligible squares; selecting the
double and then a card aims from where it stands. A test hides the
caster and fires through the double.

The first-stride caption sat on the maze and hid the walls beside the
wizard; it now stands above the board with a color chip for "you."

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-15 23:38:58 -04:00
co-authored by Claude Fable 5.1
parent 5f58a0955a
commit 10d5c5815d
5 changed files with 158 additions and 78 deletions
+50 -14
View File
@@ -590,8 +590,15 @@
/** Attachment plumbing for a cast command: the chosen mods and the
* attached NUMBER ride along (an explicit numberInstanceIds wins). */
/** Your ALTER EGO, when it is the selected token: casts leave from its square. */
const castingDouble = $derived(
selectedCreature && view
? view.creatures.find((c) => c.id === selectedCreature && c.kind === "alter-ego" && c.controllerId === view!.you) ?? null
: null,
);
function withMods<T extends Parameters<typeof net.command>[0] & { type: "cast" }>(cmd: T): T {
applyMods(cmd);
if (castingDouble && cmd.target) cmd.via = castingDouble.id;
if (attachedNumber && !cmd.numberInstanceIds) {
cmd.numberInstanceIds = [attachedNumber.instanceId];
}
@@ -1150,7 +1157,7 @@
function clickPlayer(playerId: string) {
if (!view || !yourMoment) return;
if (selectedCreature) {
if (selectedCreature && !(castingDouble && selectedCard)) {
dispatch({ type: "creatureAttack", creatureId: selectedCreature, targetId: playerId });
selectedCreature = null;
return;
@@ -1540,12 +1547,9 @@
.filter((t) => t.kind !== "blocked")
.map((t) => cellKey(t.to));
});
/** The first stride of a first game gets one line on the board itself. */
const stepCaption = $derived(
stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0
? { cell: me.position, text: "This is you — tap a lit square to walk" }
: null,
);
/** The first stride of a first game gets one line above the board
* off the maze, so the walls around the wizard stay in plain sight. */
const stepCaption = $derived(stepLight && view && me && view.turn.round === 1 && view.turn.movementUsed === 0);
const litCells = $derived.by(() => {
if (view && me && !selectedDef && stepLight) return new Set([cellKey(me.position), ...stepCells]);
if (!view || !selectedDef || !yourMoment) return null;
@@ -1553,10 +1557,10 @@
if (!me) return null;
const bent = attachedMods.some((m) => m.cardId === "around-the-corner");
if (CELL_CARDS.has(selectedCard!.cardId)) {
return eligibleCellsFor(view, selectedCard!.cardId, bent);
return eligibleCellsFor(view, selectedCard!.cardId, bent, castingDouble?.position);
}
if (selectedDef.adjacent === true) {
const { x, y } = me.position;
const { x, y } = castingDouble?.position ?? me.position;
return new Set(
[[x, y], [x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]]
.map(([cx, cy]) => `${cx},${cy}`)
@@ -1564,7 +1568,7 @@
);
}
if (selectedDef.los !== true) return null;
return bent ? bentSightedCellsFor(view) : sightedCellsFor(view);
return bent ? bentSightedCellsFor(view, castingDouble?.position) : sightedCellsFor(view, castingDouble?.position);
});
/** Creatures awaiting your orders this turn (yours + any democratic monster). */
@@ -2479,8 +2483,12 @@
{#if selectedCreature && !selectedDef}
{@const sc = view.creatures.find((c) => c.id === selectedCreature)}
{#if sc}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
{#if sc.kind === "alter-ego"}
<span>Your <strong>double</strong> stands ready — select a card and it casts from the double's square, by the double's sight.</span>
{:else}
<span>Commanding <strong>{cardDef(sc.kind).name}</strong> — {creatureStats(sc)}.
Tap a square beside it to march, a target in its square to attack.</span>
{/if}
{#if dmWarnCell}
<span class="hint-alert">⚠ it will claw YOU the moment it enters your square — tap again if you mean it</span>
{/if}
@@ -2673,6 +2681,12 @@
</button>
</div>
{/if}
{#if stepCaption && me}
<div class="step-caption">
<span class="step-chip" style:background={playerColor(me.id)}></span>
This is you — tap a lit square beside your wizard to walk
</div>
{/if}
<div class="table-stack" class:fpv-primary={prefs.liveFp && !!view.you && !net.spectating}>
{#if prefs.liveFp && view.you && !net.spectating}
<LiveFirstPerson {view} batch={fpBatch} onhide={() => setPref("liveFp", false)}
@@ -2712,7 +2726,6 @@
onEdgePeek={(tip) => (peekEdge = tip)}
{litCells}
spotCells={stepCells}
spotCaption={stepCaption}
{sightTrace}
povFacing={prefs.liveFp && view.you && !net.spectating ? fpvFacing : null}
povCreatureId={prefs.liveFp && !net.spectating ? fpvBody : null}
@@ -3487,8 +3500,31 @@
margin: 0.2rem 0 0.1rem;
}
/* The eyes toggle floats over the board's corner so it costs the maze no height. */
.board-zone { position: relative; }
.board-zone { position: relative; flex-direction: column; align-items: stretch; }
.view-toggle { position: absolute; top: 0.25rem; right: 0.25rem; z-index: 3; }
.step-caption {
align-self: flex-start;
max-width: calc(100% - 11rem);
margin: 0 0 0.4rem 0.4rem;
padding: 0.3rem 0.8rem;
border-radius: 999px;
background: rgba(28, 22, 12, 0.92);
border: 1.5px solid #f2cf5b;
color: #f7ecc8;
font-family: "Oswald", sans-serif;
font-size: 0.85rem;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 0.45rem;
}
.step-chip {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 2px;
border: 1.5px solid #f7ecc8;
}
.boxlid-tag {
font-size: 0.95rem;
color: #6b5a41;
-23
View File
@@ -35,7 +35,6 @@
markedCells = null,
litCells = null,
spotCells = [],
spotCaption = null,
markedSector = null,
ghostSlots = null,
onGhostClick,
@@ -70,8 +69,6 @@
litCells?: Set<string> | null;
/** The step light's squares: a pulsing rim on each. */
spotCells?: string[];
/** One line hung above a square — the first stride's cue. */
spotCaption?: { cell: { x: number; y: number }; text: 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
@@ -697,17 +694,6 @@
{@const sy = Number(key.split(",")[1])}
<rect x={sx * CELL + 3} y={sy * CELL + 3} width={CELL - 6} height={CELL - 6} class="spot-cell" rx="5" />
{/each}
{#if spotCaption}
{@const cols = Math.max(...Object.keys(view.board.cells).map((k) => Number(k.split(",")[0]))) + 1}
{@const cw = spotCaption.text.length * 6.6 + 20}
{@const cx = Math.min(Math.max(spotCaption.cell.x * CELL + CELL / 2, cw / 2 + 4), cols * CELL - cw / 2 - 4)}
{@const above = spotCaption.cell.y > 0}
{@const cy = above ? spotCaption.cell.y * CELL - 14 : (spotCaption.cell.y + 1) * CELL + 14}
<g class="spot-caption" transform={`translate(${cx}, ${cy})`}>
<rect x={-cw / 2} y={-12} width={cw} height={24} rx="12" />
<text x="0" y="4">{spotCaption.text}</text>
</g>
{/if}
<!-- FEAR's bubble: the three-space diamond, through walls, wrapping the rim -->
{#each fearAura as key (key)}
{@const bx = Number(key.split(",")[0])}
@@ -865,15 +851,6 @@
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.spot-caption { pointer-events: none; }
.spot-caption rect { fill: rgba(28, 22, 12, 0.92); stroke: #f2cf5b; stroke-width: 1.5; }
.spot-caption text {
fill: #f7ecc8;
font-size: 12px;
font-family: "Oswald", "Helvetica Neue", Arial, sans-serif;
letter-spacing: 0.02em;
text-anchor: middle;
}
.marked-cell {
fill: rgba(211, 133, 43, 0.18);
stroke: #d3852b;