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
+50 -26
View File
@@ -180,6 +180,27 @@
return letters; 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. // Group players by cell so co-located wizards fan out.
const wizardsByCell = $derived.by(() => { const wizardsByCell = $derived.by(() => {
const map = new Map<string, typeof view.players>(); const map = new Map<string, typeof view.players>();
@@ -468,11 +489,13 @@
/> />
{/if} {/if}
<!-- wizards --> <!-- wizards: identity-keyed movers, so steps glide square to square -->
{#each [...wizardsByCell.entries()] as [key, group] (key)} {#each view.players.filter((p) => p.alive) as p (p.id)}
{#each group as p, i (p.id)} {@const group = wizardsByCell.get(`${p.position.x},${p.position.y}`) ?? [p]}
{@const cx = p.position.x * CELL + CELL / 2 + (group.length > 1 ? (i - (group.length - 1) / 2) * 14 : 0)} {@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} {@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 <g
role="button" tabindex="-1" role="button" tabindex="-1"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }} onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
@@ -486,7 +509,7 @@
{@const half = CELL * 0.3 * wizardScale(p.id)} {@const half = CELL * 0.3 * wizardScale(p.id)}
<image <image
href={wizardArt(p.id)} href={wizardArt(p.id)}
x={cx - half} y={cy - half} x={-half} y={-half}
width={half * 2} height={half * 2} width={half * 2} height={half * 2}
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
class="token-art hit" class="token-art hit"
@@ -494,37 +517,35 @@
class:stone-gazed={hasSpell(p.id, "medusa")} class:stone-gazed={hasSpell(p.id, "medusa")}
/> />
<rect <rect
x={cx - half} y={cy - half} x={-half} y={-half}
width={half * 2} height={half * 2} width={half * 2} height={half * 2}
class="wizard-ring" stroke={playerColor(p.id)} class="wizard-ring" stroke={playerColor(p.id)}
/> />
{#if hasSpell(p.id, "sticky-web")} {#if hasSpell(p.id, "sticky-web")}
<g class="webbing"> <g class="webbing">
<line x1={cx - half} y1={cy - half * 0.4} x2={cx + half} y2={cy + half * 0.5} /> <line x1={-half} y1={-half * 0.4} x2={half} y2={half * 0.5} />
<line x1={cx - half * 0.6} y1={cy + half} x2={cx + half * 0.7} y2={cy - half} /> <line x1={-half * 0.6} y1={half} x2={half * 0.7} y2={-half} />
<line x1={cx - half} y1={cy + half * 0.7} x2={cx + half} y2={cy - half * 0.2} /> <line x1={-half} y1={half * 0.7} x2={half} y2={-half * 0.2} />
</g> </g>
{/if} {/if}
{:else} {:else}
<circle {cx} {cy} r={12 * wizardScale(p.id)} fill={playerColor(p.id)} stroke="#2b2218" stroke-width="2" /> <circle cx="0" cy="0" 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> <text x="0" y="4" class="wizard-label">{p.id[0]?.toUpperCase()}</text>
{/if} {/if}
{#if p.carriedTreasureId} {#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} {/if}
</g> </g>
{/each} </g>
{/each} {/each}
<!-- creatures (fanned when several share a square) --> <!-- creatures: identity-keyed movers, fanned when several share a square -->
{#each [...view.creatures.reduce((m, c) => { {#each view.creatures as c (c.id)}
const k = `${c.position.x},${c.position.y}`; {@const cGroup = creaturesByCell.get(`${c.position.x},${c.position.y}`) ?? [c]}
m.set(k, [...(m.get(k) ?? []), c]); {@const ci = cGroup.findIndex((q) => q.id === c.id)}
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 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} {@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 <g
role="button" tabindex="-1" class="creature" role="button" tabindex="-1" class="creature"
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }} onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onCreatureClick?.(c.id); }}
@@ -536,7 +557,7 @@
{#if USE_TOKEN_ART && CREATURE_ART[c.kind]} {#if USE_TOKEN_ART && CREATURE_ART[c.kind]}
<image <image
href={`/tokens/${CREATURE_ART[c.kind]}.png`} href={`/tokens/${CREATURE_ART[c.kind]}.png`}
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26} x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52} width={CELL * 0.52} height={CELL * 0.52}
preserveAspectRatio="xMidYMid slice" preserveAspectRatio="xMidYMid slice"
class="token-art hit" class="token-art hit"
@@ -545,7 +566,7 @@
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title> <title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</image> </image>
<rect <rect
x={ccx - CELL * 0.26} y={ccy - CELL * 0.26} x={-CELL * 0.26} y={-CELL * 0.26}
width={CELL * 0.52} height={CELL * 0.52} width={CELL * 0.52} height={CELL * 0.52}
class="creature-ring" class="creature-ring"
class:selected={c.id === selectedCreatureId} class:selected={c.id === selectedCreatureId}
@@ -553,18 +574,18 @@
/> />
{:else} {:else}
<rect <rect
x={ccx - 10} y={ccy - 10} width={20} height={20} rx="3" x={-10} y={-10} width={20} height={20} rx="3"
transform={`rotate(45 ${ccx} ${ccy})`} transform="rotate(45 0 0)"
class="creature-body" class="creature-body"
class:selected={c.id === selectedCreatureId} class:selected={c.id === selectedCreatureId}
stroke={playerColor(c.controllerId)} stroke={playerColor(c.controllerId)}
> >
<title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title> <title>{c.kind} ({c.controllerId}) {c.damage}/{Number.isFinite(c.maxDamage) ? c.maxDamage : "∞"} dmg</title>
</rect> </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> <text x="0" y="4" class="creature-label">{c.kind === "fire-imp" ? "I" : c.kind === "democratic-monster" ? "D" : c.kind[0]?.toUpperCase()}</text>
{/if} {/if}
</g> </g>
{/each} </g>
{/each} {/each}
<!-- edge selection hitboxes --> <!-- edge selection hitboxes -->
@@ -740,6 +761,8 @@
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); } .ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
.fx-layer { pointer-events: none; } .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 ------------ */ /* --- persistent ambiance: ongoing spells wear their look ------------ */
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; } .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; } .marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
.fx-layer { display: none; } .fx-layer { display: none; }
.firewall, .token-art.ghosted { animation: none; } .firewall, .token-art.ghosted { animation: none; }
.mover { transition: none; }
} }
.wizard { cursor: pointer; } .wizard { cursor: pointer; }
.wizard-label { .wizard-label {