diff --git a/packages/engine/src/game.ts b/packages/engine/src/game.ts index 16e1b87..ffd8600 100644 --- a/packages/engine/src/game.ts +++ b/packages/engine/src/game.ts @@ -212,6 +212,9 @@ export interface CastStack { reflectedBase?: { damage: number; duration: number }; /** The wraith's touch also steals a random card if damage lands. */ creatureTouch?: "wraith" | "claw"; + /** Where the spell left from when not the attacker's own square: an + * ALTER EGO's. Knockbacks and routs push away from here. */ + origin?: Cell; /** A spell freed from slime: counteractions cannot touch its caster. */ trapped?: boolean; /** STRENGTH's tear: "this would be an attack" — the grab rides the stack @@ -836,6 +839,10 @@ export type Command = powerAttackPoints?: number; target?: CastTarget; params?: CastParams; + /** ALTER EGO: the double's creature id, when the spell leaves from + * its square — "it may use any of the spells in your hand, and you + * need not be in its L.O.S. to do so." */ + via?: string; } | { type: "setAmbush"; instanceId: string; trigger: AmbushTrigger; spellInstanceId: string; numberInstanceIds?: string[]; cell?: Cell } | { type: "cancelAmbush"; ambushId: string } @@ -903,6 +910,8 @@ interface Magnitude { } interface ResolutionContext { + /** The square the blow came from: the attacker's, or their double's. */ + origin: Cell; state: GameState; events: GameEvent[]; attacker: PlayerState; @@ -1069,7 +1078,7 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { const knock = ctx.stack.params?.knockback ?? 0; if (knock <= 0 || ctx.fullyStopped || !ctx.defender.alive) return; - knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock); + knockBack(ctx.state, ctx.events, ctx.attacker, ctx.defender, knock, ctx.origin); }, }, "sudden-death": { kind: "attack", requiresLos: true, baseDamage: () => 10 }, @@ -1114,7 +1123,7 @@ const CARD_EFFECTS: Record onResolved: (ctx) => { if (ctx.fullyStopped || !ctx.defender.alive) return; const n = ctx.stack.numberValue ?? 1; - goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n); + goAwayRout(ctx.state, ctx.events, ctx.attacker, ctx.defender, n, ctx.origin); ctx.defender.lostTurns++; ctx.events.push({ type: "stunned", player: ctx.defender.id, turnsLost: 1 }); }, @@ -5243,6 +5252,19 @@ function doCast(prev: GameState, cmd: Extract): Comma const inHand = caster.hand.find((c) => c.instanceId === cmd.instanceId); if (!inHand) return err("card not in hand"); const def = cardDef(inHand.cardId); + // ALTER EGO: "It may use any of the spells in your hand, and you need + // not be in its L.O.S. to do so." The double's square is where the + // spell leaves from — sight, reach, and the blow's direction are its — + // while the hand, the attack, and any answer to it stay the caster's. + // Spells that move the caster's own body do not travel through it. + let origin: Cell = caster.position; + if (cmd.via) { + const ego = state.creatures.find((c) => c.id === cmd.via); + if (!ego || ego.kind !== "alter-ego" || ego.controllerId !== caster.id) return err("that is not your double"); + if (!cmd.target) return err("your double casts only at a target"); + if (inHand.cardId === "butt-head" || inHand.cardId === "swap") return err(`${def.name} moves your own body — not your double's to cast`); + origin = ego.position; + } const effect = CARD_EFFECTS[inHand.cardId]; if (!effect) return err(`${def.name} is not implemented yet`); if (effect.kind === "counter") { @@ -5328,7 +5350,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const events: GameEvent[] = [{ type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: null, targetCell: null, + from: origin, target: null, targetCell: null, }]; attachSustained(state, events, "disease", caster.id, caster.id, mods.magnitude.duration); return { ok: true, state, events }; @@ -5342,12 +5364,12 @@ function doCast(prev: GameState, cmd: Extract): Comma if (cmd.target?.kind === "creature") { const creature = state.creatures.find((c) => c.id === (cmd.target as { creatureId: string }).creatureId); if (!creature) return err("no such creature"); - if (effect.sameSquare && cellKey(creature.position) !== cellKey(caster.position)) { + if (effect.sameSquare && cellKey(creature.position) !== cellKey(origin)) { return err("you must be in the same square"); } if (effect.requiresLos && !(mods.aroundCorner - ? bentLos(state, caster, caster.position, creature.position) - : casterLos(state, caster, caster.position, creature.position))) { + ? bentLos(state, caster, origin, creature.position) + : casterLos(state, caster, origin, creature.position))) { return err("no line of sight to the creature"); } const wandEvents: GameEvent[] = []; @@ -5362,7 +5384,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const events: GameEvent[] = [...wandEvents, { type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: null, targetCell: creature.position, + from: origin, target: null, targetCell: creature.position, }]; // "Any WATERBOLT or WATERWALL will destroy it" (FIRE IMP). if (creature.kind === "fire-imp" && inHand.cardId === "waterbolt") { @@ -5383,7 +5405,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (cmd.target?.kind === "cell" && state.squareContents[cellKey(cmd.target.cell)]?.kind === "safe") { const cell = cmd.target.cell; - if (effect.sameSquare && cellKey(cell) !== cellKey(caster.position)) { + if (effect.sameSquare && cellKey(cell) !== cellKey(origin)) { return err("you must be in the same square"); } // castSight carries the whole sight law: straight lines, the @@ -5405,7 +5427,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const events: GameEvent[] = [...wandEvents, { type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: null, targetCell: { ...cell }, + from: origin, target: null, targetCell: { ...cell }, }]; const box = state.squareContents[cellKey(cell)]!; box.damage += dmg; @@ -5424,8 +5446,8 @@ function doCast(prev: GameState, cmd: Extract): Comma state.squareContents[cellKey(cmd.target.cell)]?.kind === "slime") { const cell = cmd.target.cell; if (effect.requiresLos && !(mods.aroundCorner - ? bentLos(state, caster, caster.position, cell) - : casterLos(state, caster, caster.position, cell))) { + ? bentLos(state, caster, origin, cell) + : casterLos(state, caster, origin, cell))) { return err("no line of sight to the slime"); } const wandEvents: GameEvent[] = []; @@ -5469,7 +5491,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (current !== "wall" && current !== "door") { return err("there is no wall or door on that edge to attack"); } - if (effect.sameSquare && !touchesEdge(caster.position, cell, side)) { + if (effect.sameSquare && !touchesEdge(origin, cell, side)) { return err("you must stand beside the wall"); } if (effect.requiresLos && !castSightEdge(state, caster, cmd, view, cell, side)) { @@ -5495,7 +5517,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const events2: GameEvent[] = [...wandEvents, { type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: null, targetCell: cell, + from: origin, target: null, targetCell: cell, }]; const problem = damageWall(state, events2, caster, cell, side, dmg, inHand.cardId); if (problem) return err(problem); @@ -5524,7 +5546,7 @@ function doCast(prev: GameState, cmd: Extract): Comma const events: GameEvent[] = [{ type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: null, targetCell: null, + from: origin, target: null, targetCell: null, }]; const queue = turnOrderFrom(state, caster.id).filter((id) => id !== caster.id && state.players.find((p) => p.id === id)!.alive); @@ -5536,7 +5558,7 @@ function doCast(prev: GameState, cmd: Extract): Comma if (cmd.target.playerId === caster.id) return err("you cannot attack yourself"); const target = state.players.find((p) => p.id === (cmd.target as { playerId: PlayerId }).playerId); if (!target || !target.alive) return err("no such living player"); - if (effect.sameSquare && cellKey(target.position) !== cellKey(caster.position)) { + if (effect.sameSquare && cellKey(target.position) !== cellKey(origin)) { return err("you must be in the same square"); } const statusBlock = attackBlockedByStatus(state, caster, target); @@ -5544,8 +5566,8 @@ function doCast(prev: GameState, cmd: Extract): Comma const preEvents: GameEvent[] = []; if (effect.requiresLos) { const sighted = mods.aroundCorner - ? bentLos(state, caster, caster.position, target.position) - : casterLos(state, caster, caster.position, target.position); + ? bentLos(state, caster, origin, target.position) + : casterLos(state, caster, origin, target.position); if (!sighted) return err("no line of sight to the target"); } // Attacking someone breaks any BUDDY pact you swore to them. @@ -5579,9 +5601,9 @@ function doCast(prev: GameState, cmd: Extract): Comma // go intended distance" — if the die disagrees with the true direction, // the spell hits whoever lies that way, or dissipates. if (isBlinded(state, caster) && - cellKey(target.position) !== cellKey(caster.position)) { - const dx = target.position.x - caster.position.x; - const dy = target.position.y - caster.position.y; + cellKey(target.position) !== cellKey(origin)) { + const dx = target.position.x - origin.x; + const dy = target.position.y - origin.y; const intended: Side = Math.abs(dx) >= Math.abs(dy) && dx !== 0 ? (dx > 0 ? "E" : "W") : dy > 0 ? "S" : "N"; const [roll, rngNext] = rollDie(state.rng); @@ -5590,11 +5612,11 @@ function doCast(prev: GameState, cmd: Extract): Comma if (rolled !== intended) { const along = state.players.find((p) => { if (!p.alive || p.id === caster.id) return false; - const px = p.position.x - caster.position.x; - const py = p.position.y - caster.position.y; + const px = p.position.x - origin.x; + const py = p.position.y - origin.y; const dirOf: Side | null = Math.abs(px) >= Math.abs(py) && px !== 0 ? (px > 0 ? "E" : "W") : py !== 0 ? (py > 0 ? "S" : "N") : null; - return dirOf === rolled && casterLos(state, caster, caster.position, p.position); + return dirOf === rolled && casterLos(state, caster, origin, p.position); }); consumeCast(state, caster, inHand, mods, effect.keepInHand ?? false); state.turn.attackUsed = true; @@ -5616,11 +5638,12 @@ function doCast(prev: GameState, cmd: Extract): Comma kind: effect.physical ? "physical" : "spell", counters: [], waitingOn: along.id, + ...(cmd.via ? { origin } : {}), }; missEvents.push({ type: "spellCast", caster: caster.id, card: inHand, cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, target: along.id, targetCell: along.position, + from: origin, target: along.id, targetCell: along.position, }); return { ok: true, state, events: missEvents }; } @@ -5642,6 +5665,7 @@ function doCast(prev: GameState, cmd: Extract): Comma counters: [], waitingOn: target.id, ...(mods.aroundCorner ? { bentCorner: true as const } : {}), + ...(cmd.via ? { origin } : {}), }; state.lastSpellUsed[caster.id] = inHand.cardId; const events: GameEvent[] = [...wandEvents, ...preEvents]; @@ -5654,7 +5678,7 @@ function doCast(prev: GameState, cmd: Extract): Comma numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, amplifies: mods.amplifies.length, - from: caster.position, + from: origin, target: target.id, targetCell: target.position, }); @@ -5674,7 +5698,7 @@ function doCast(prev: GameState, cmd: Extract): Comma cardId: inHand.cardId, numberCards: mods.numbers, numberValue: mods.magnitude.numberValue, - from: caster.position, + from: origin, target: cmd.target?.kind === "player" ? cmd.target.playerId : null, targetCell: cmd.target?.kind === "edge" || cmd.target?.kind === "cell" ? cmd.target.cell : null, }); @@ -6291,6 +6315,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { effect.onResolved({ state, events, attacker: defender, defender: attacker, + origin: defender.position, damageDealt: 0, fullyStopped: false, reversed: false, duration: 0, stack: { ...stack, params: { ...stack.params, cardId: chosen } }, }); @@ -6434,6 +6459,7 @@ function resolveStack(state: GameState, events: GameEvent[]): void { events, attacker, defender, + origin: stack.origin ?? attacker.position, damageDealt, fullyStopped: pipe.fullyStopped, reversed: pipe.reversed, @@ -6472,11 +6498,12 @@ function goAwayRout( attacker: PlayerState, defender: PlayerState, squares: number, + origin: Cell = attacker.position, ): void { if (isLockedInPlace(state, defender.id)) return; const from = defender.position; const dist = (c: Cell) => - Math.abs(attacker.position.x - c.x) + Math.abs(attacker.position.y - c.y); + Math.abs(origin.x - c.x) + Math.abs(origin.y - c.y); let heading: Side | null = null; let moved = 0; for (let i = 0; i < squares; i++) { @@ -6527,9 +6554,10 @@ function knockBack( attacker: PlayerState, defender: PlayerState, squares: number, + origin: Cell = attacker.position, ): void { - const dx = defender.position.x - attacker.position.x; - const dy = defender.position.y - attacker.position.y; + const dx = defender.position.x - origin.x; + const dy = defender.position.y - origin.y; let dir: Side | null = null; if (Math.abs(dx) >= Math.abs(dy) && dx !== 0) dir = dx > 0 ? "E" : "W"; else if (dy !== 0) dir = dy > 0 ? "S" : "N"; diff --git a/packages/engine/src/view.ts b/packages/engine/src/view.ts index 6e6d9d4..fe7928b 100644 --- a/packages/engine/src/view.ts +++ b/packages/engine/src/view.ts @@ -220,20 +220,23 @@ export function fearCells(view: GameView): Set { * reflects what this player knows (illusion walls they believe in block it). * The basis for the client's "dim the ineligible squares" targeting aid. */ -export function sightedCellsFor(view: GameView): Set { +export function sightedCellsFor(view: GameView, from?: Cell): Set { const out = new Set(); const me = view.players.find((p) => p.id === view.you); if (!me) return out; + // Sight is taken from the viewer's square unless a cast leaves from + // elsewhere — an ALTER EGO's — in which case it is the double's. + const eye = from ?? me.position; const { board, blockers } = sightBasis(view); // In a dust cloud a wizard sees only their own square (rev 19). - if (dustAtEnd(view, me.position, me.position, true)) { - out.add(`${me.position.x},${me.position.y}`); + if (dustAtEnd(view, eye, eye, true)) { + out.add(`${eye.x},${eye.y}`); return out; } for (const key of Object.keys(board.cells)) { const [x, y] = key.split(",").map(Number) as [number, number]; - if (dustAtEnd(view, me.position, { x, y })) continue; - if (sightBetween(board, me.position, { x, y }, blockers)) out.add(key); + if (dustAtEnd(view, eye, { x, y })) continue; + if (sightBetween(board, eye, { x, y }, blockers)) out.add(key); } // VISIONSTONE lets its bearer see through exactly one wall or door — // any one — so a square is also sighted if removing a single edge @@ -248,7 +251,7 @@ export function sightedCellsFor(view: GameView): Set { const opened = { ...board, edges }; for (let i = unseen.length - 1; i >= 0; i--) { const [x, y] = unseen[i]!.split(",").map(Number) as [number, number]; - if (sightBetween(opened, me.position, { x, y }, blockers)) { + if (sightBetween(opened, eye, { x, y }, blockers)) { out.add(unseen[i]!); unseen.splice(i, 1); } @@ -263,8 +266,8 @@ export function sightedCellsFor(view: GameView): Set { * plus every square visible from one of those — the caster looks to a middle * cell and the spell turns there, mirroring the engine's bentLos. */ -export function bentSightedCellsFor(view: GameView): Set { - const out = sightedCellsFor(view); +export function bentSightedCellsFor(view: GameView, from?: Cell): Set { + const out = sightedCellsFor(view, from); const me = view.players.find((p) => p.id === view.you); if (!me) return out; const mids = [...out].map((k) => { @@ -431,12 +434,12 @@ const SUMMON_CARD_IDS = new Set([ * aid — mirroring the engine's own validation from the viewer's knowledge. * Null = this card's eligibility is not modeled; light everything. */ -export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false): Set | null { +export function eligibleCellsFor(view: GameView, cardId: string, bentCorner = false, from?: Cell): Set | null { const me = view.players.find((p) => p.id === view.you); if (!me) return null; const cells = Object.keys(view.board.cells); const key = (x: number, y: number) => `${x},${y}`; - const sighted = bentCorner ? bentSightedCellsFor(view) : sightedCellsFor(view); + const sighted = bentCorner ? bentSightedCellsFor(view, from) : sightedCellsFor(view, from); if (CREATION_CARD_IDS.has(cardId)) { // emptySquareTarget: on the board, unoccupied by content, home, wizard, diff --git a/packages/engine/test/creatures.test.ts b/packages/engine/test/creatures.test.ts index fbd44c4..c21dc76 100644 --- a/packages/engine/test/creatures.test.ts +++ b/packages/engine/test/creatures.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { - type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame } from "../src/game"; + type CreatureState, applyCommand, activePlayer, boardView, creatureAt, type GameState, type PlayerId, createGame, gameLos, +} from "../src/game"; import { cellKey, edgeKey, neighbor, opposite, SIDES, stepTarget, type Cell, type Side } from "../src/board"; import type { CardInstance } from "../src/cards"; -import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained } from "./helpers"; +import { newExpansionGame as newGame, must, drain, giveCard, toRound2, emptyNeighborCell, pushSustained, faceOff } from "./helpers"; /** Summon a creature next to its creator (round 2+, consumes the attack). */ function summon(state: GameState, playerId: PlayerId, kind: string, tag = "S") { @@ -961,3 +962,38 @@ describe("the imp's fire is a spell (rev 8)", () => { expect(after.alive).toBe(true); }); }); + +describe("the alter ego casts from its own square", () => { + it("a fireball leaves from the double, by the double's sight, and answers to the caster", () => { + let { state } = newGame(42); + state = toRound2(state); + const { attacker, defender } = faceOff(state); + const a = state.players.find((p) => p.id === attacker)!; + const d = state.players.find((p) => p.id === defender)!; + const ego = giveCard(state, attacker, "alter-ego"); + state = must(state, attacker, { type: "cast", instanceId: ego.instanceId }); + const double = state.creatures.find((c) => c.kind === "alter-ego" && c.controllerId === attacker)!; + expect(cellKey(double.position)).toBe(cellKey(a.position)); + // The caster steps out of the defender's sight; the double keeps it. + const view = boardView(state); + const hidden = Object.keys(view.cells).map((k) => { const [x, y] = k.split(",").map(Number) as [number, number]; return { x, y }; }) + .find((c) => !gameLos(state, c, d.position) && !state.squareContents[cellKey(c)] && !view.homes.some((h) => cellKey(h) === cellKey(c))); + expect(hidden).toBeDefined(); + const me = state.players.find((p) => p.id === attacker)!; + me.position = { ...hidden! }; + const fb = giveCard(state, attacker, "fireball"); + const blind = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender } }); + expect(blind.ok).toBe(false); + const wrong = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: "no-such-double" }); + expect(wrong.ok).toBe(false); + const viaEgo = applyCommand(state, attacker, { type: "cast", instanceId: fb.instanceId, target: { kind: "player", playerId: defender }, via: double.id }); + expect(viaEgo.ok).toBe(true); + if (!viaEgo.ok) return; + expect(viaEgo.state.stack?.attackerId).toBe(attacker); + expect(cellKey(viaEgo.state.stack!.origin!)).toBe(cellKey(double.position)); + const cast = viaEgo.events.find((e) => e.type === "spellCast"); + expect(cast && cast.type === "spellCast" ? cellKey(cast.from) : null).toBe(cellKey(double.position)); + const done = must(viaEgo.state, defender, { type: "pass" }); + expect(done.players.find((p) => p.id === defender)!.life).toBe(10); + }); +}); diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 94bd688..614170a 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -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[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} - Commanding {cardDef(sc.kind).name} — {creatureStats(sc)}. - Tap a square beside it to march, a target in its square to attack. + {#if sc.kind === "alter-ego"} + Your double stands ready — select a card and it casts from the double's square, by the double's sight. + {:else} + Commanding {cardDef(sc.kind).name} — {creatureStats(sc)}. + Tap a square beside it to march, a target in its square to attack. + {/if} {#if dmWarnCell} ⚠ it will claw YOU the moment it enters your square — tap again if you mean it {/if} @@ -2673,6 +2681,12 @@ {/if} + {#if stepCaption && me} +
+ + This is you — tap a lit square beside your wizard to walk +
+ {/if}
{#if prefs.liveFp && view.you && !net.spectating} 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; diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index e5d8f88..002d143 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -35,7 +35,6 @@ markedCells = null, litCells = null, spotCells = [], - spotCaption = null, markedSector = null, ghostSlots = null, onGhostClick, @@ -70,8 +69,6 @@ litCells?: Set | 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])} {/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} - - - {spotCaption.text} - - {/if} {#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;