Wizards walk; portals stay instantaneous

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 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-17 10:56:41 -04:00
co-authored by Claude Fable 5
parent 64709f6c9c
commit 126f003156
+87 -63
View File
@@ -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<string, { x: number; y: number }>();
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<string, typeof view.creatures>();
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<string, typeof view.players>();
@@ -468,11 +489,13 @@
/>
{/if}
<!-- wizards -->
{#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}
<!-- wizards: identity-keyed movers, so steps glide square to square -->
{#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}
<g class="mover" class:snap={snapsTo(`w:${p.id}`, cx, cy)} style={`transform: translate(${cx}px, ${cy}px)`}>
<g
role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
@@ -486,7 +509,7 @@
{@const half = CELL * 0.3 * wizardScale(p.id)}
<image
href={wizardArt(p.id)}
x={cx - half} y={cy - half}
x={-half} y={-half}
width={half * 2} height={half * 2}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
@@ -494,77 +517,75 @@
class:stone-gazed={hasSpell(p.id, "medusa")}
/>
<rect
x={cx - half} y={cy - half}
x={-half} y={-half}
width={half * 2} height={half * 2}
class="wizard-ring" stroke={playerColor(p.id)}
/>
{#if hasSpell(p.id, "sticky-web")}
<g class="webbing">
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} />
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} />
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} />
<line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
<line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
<line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
</g>
{/if}
{:else}
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x={cx} y={cy + 4} class="wizard-label">{p.id[0]?.toUpperCase()}</text>
<circle cx="0" cy="0" r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" />
<text x="0" y="4" class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{/if}
{#if p.carriedTreasureId}
<circle cx={cx + CELL * 0.26} cy={cy + CELL * 0.26} r={5} class="carried" />
<circle cx={CELL * 0.26} cy={CELL * 0.26} r={5} class="carried" />
{/if}
</g>
{/each}
{/each}
<!-- creatures (fanned when several share a square) -->
{#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}
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { 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]}
<image
href={`/tokens/${CREATURE_ART[c.kind]}.png`}
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
class:selected-art={c.id === selectedCreatureId}
>
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</image>
<rect
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
/>
{:else}
<rect
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`}
class="creature-body"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x={ccx} y={ccy + 4} class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
{/each}
<!-- creatures: identity-keyed movers, fanned when several share a square -->
{#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}
<g class="mover" class:snap={snapsTo(`c:${c.id}`, ccx, ccy)} style={`transform: translate(${ccx}px, ${ccy}px)`}>
<g
role="button" tabindex="-1" class="creature"
onclick={(ev) => { 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]}
<image
href={`/tokens/${CREATURE_ART[c.kind]}.png`}
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice"
class="token-art hit"
class:selected-art={c.id === selectedCreatureId}
>
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</image>
<rect
x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
/>
{:else}
<rect
x={-10} y={-10} width={20} height={20} rx="3"
transform="rotate(45 0 0)"
class="creature-body"
class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)}
>
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect>
<text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if}
</g>
</g>
{/each}
<!-- edge selection hitboxes -->
@@ -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 {