From 126f003156a00ec55f2c63140fb0844d5bdffcf5 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Mon, 17 Aug 2026 10:56:41 -0400 Subject: [PATCH] Wizards walk; portals stay instantaneous MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokens jumped square to square, which made the portal shimmer look late — the wizard had already re-appeared before the threshold lit. Wizards and creatures are now identity-keyed movers positioned by a transform, so the DOM node survives a move and a single step glides (260ms). Anything farther than a step — teleports, warps, drags, sector moves — snaps as before, because sliding a wizard across the whole maze would tell a false story; the warp's instant jump beside its threshold shimmer is now the point, not the bug. Replays glide the same way, and reduced motion stills it all. Co-Authored-By: Claude Fable 5 --- packages/web/src/Board.svelte | 150 ++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 63 deletions(-) diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 819a332..a683c98 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -180,6 +180,27 @@ return letters; }); + // Tokens glide between adjacent squares; anything farther — teleports, + // warps, drags, sector moves — snaps, because sliding a wizard across the + // whole maze would tell a false story. Keyed by identity so the DOM node + // survives the move. + const lastAt = new Map(); + function snapsTo(id: string, x: number, y: number): boolean { + const prev = lastAt.get(id); + lastAt.set(id, { x, y }); + if (!prev) return true; + return Math.abs(prev.x - x) + Math.abs(prev.y - y) > CELL * 1.6; + } + + const creaturesByCell = $derived.by(() => { + const map = new Map(); + for (const c of view.creatures) { + const k = `${c.position.x},${c.position.y}`; + map.set(k, [...(map.get(k) ?? []), c]); + } + return map; + }); + // Group players by cell so co-located wizards fan out. const wizardsByCell = $derived.by(() => { const map = new Map(); @@ -468,11 +489,13 @@ /> {/if} - - {#each [...wizardsByCell.entries()] as [key, group] (key)} - {#each group as p, i (p.id)} - {@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)} - {@const cy = p.position.y * CELL + CELL * 0.36} + + {#each view.players.filter((p) => p.alive) as p (p.id)} + {@const group = wizardsByCell.get(`${p.position.x},${p.position.y}`) ?? [p]} + {@const gi = group.findIndex((q) => q.id === p.id)} + {@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (gi - (group.length - 1) / 2) * 14 : 0)} + {@const cy = p.position.y * CELL + CELL * 0.36} + { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }} @@ -486,7 +509,7 @@ {@const half = CELL * 0.3 * wizardScale(p.id)} {#if hasSpell(p.id, "sticky-web")} - - - + + + {/if} {:else} - - {p.id[0]?.toUpperCase()} + + {p.id[0]?.toUpperCase()} {/if} {#if p.carriedTreasureId} - + {/if} - {/each} - {/each} - - - {#each [...view.creatures.reduce((m, c) => { - const k = `${c.position.x},${c.position.y}`; - m.set(k, [...(m.get(k) ?? []), c]); - return m; - }, new Map()).entries()] as [cKey, cGroup] (cKey)} - {#each cGroup as c, ci (c.id)} - {@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)} - {@const ccy = c.position.y * CELL + CELL * 0.7} - { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }} - onpointerdown={() => pressCell(c.position)} - onpointerup={releasePress} - onpointerleave={releasePress} - onkeydown={() => {}} - > - {#if USE_TOKEN_ART && CREATURE_ART[c.kind]} - - {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg - - - {:else} - - {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg - - {c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()} - {/if} {/each} + + + {#each view.creatures as c (c.id)} + {@const cGroup = creaturesByCell.get(`${c.position.x},${c.position.y}`) ?? [c]} + {@const ci = cGroup.findIndex((q) => q.id === c.id)} + {@const ccx = c.position.x * CELL + CELL * 0.72 - (cGroup.length > 1 ? ci * CELL * 0.26 : 0)} + {@const ccy = c.position.y * CELL + CELL * 0.7} + + { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }} + onpointerdown={() => pressCell(c.position)} + onpointerup={releasePress} + onpointerleave={releasePress} + onkeydown={() => {}} + > + {#if USE_TOKEN_ART && CREATURE_ART[c.kind]} + + {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg + + + {:else} + + {c.kind} ({c.controllerId}) — {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg + + {c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()} + {/if} + + {/each} @@ -740,6 +761,8 @@ .ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); } .fx-layer { pointer-events: none; } + .mover { transition: transform 260ms cubic-bezier(0.25, 0.8, 0.35, 1); } + .mover.snap { transition: none; } /* --- persistent ambiance: ongoing spells wear their look ------------ */ .token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; } @@ -817,6 +840,7 @@ .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; } .fx-layer { display: none; } .firewall, .token-art.ghosted { animation: none; } + .mover { transition: none; } } .wizard { cursor: pointer; } .wizard-label {