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:
co-authored by
Claude Fable 5
parent
64709f6c9c
commit
126f003156
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user