Flourishes: the spells become visible (branch only — not for the droplet yet)
A cosmetic effects layer on the board svg, fed by the same event stream that writes the log. Fireballs streak and burst; waterbolts arc and splash; lightning jags and flickers; teleports shimmer at both ends; walls rise and fall in dust; stopped attacks flash a golden shield; hits ring red; misses whiff; other attack spells sparkle at the caster. Successive visuals from one command stagger by a beat, everything self-expires, reduced-motion hides the layer whole, and nothing here touches game state — the effects are mapped from events in fx.ts and drawn in Board.svelte, online and hotseat alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b01e27c644
commit
bca34ae9e4
@@ -22,6 +22,7 @@
|
||||
markedSector = null,
|
||||
ghostSlots = null,
|
||||
onGhostClick,
|
||||
effects = null,
|
||||
}: {
|
||||
view: GameView;
|
||||
edgeSelectMode?: boolean;
|
||||
@@ -46,8 +47,33 @@
|
||||
* beyond the maze, since an empty slot has no floor of its own to click. */
|
||||
ghostSlots?: { x: number; y: number }[] | null;
|
||||
onGhostClick?: (origin: { x: number; y: number }) => void;
|
||||
/** Short-lived spell flourishes; purely cosmetic. */
|
||||
effects?: import("./fx").BoardFx[] | null;
|
||||
} = $props();
|
||||
|
||||
const center = (c: { x: number; y: number }) => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 });
|
||||
/** A lightning bolt's jagged path between two squares. */
|
||||
function boltPoints(from: { x: number; y: number }, to: { x: number; y: number }): string {
|
||||
const a = center(from), b = center(to);
|
||||
const segs = 6;
|
||||
const pts: string[] = [`${a.x},${a.y}`];
|
||||
for (let i = 1; i < segs; i++) {
|
||||
const t = i / segs;
|
||||
const nx = a.x + (b.x - a.x) * t + (Math.random() - 0.5) * 16;
|
||||
const ny = a.y + (b.y - a.y) * t + (Math.random() - 0.5) * 16;
|
||||
pts.push(`${nx.toFixed(1)},${ny.toFixed(1)}`);
|
||||
}
|
||||
pts.push(`${b.x},${b.y}`);
|
||||
return pts.join(" ");
|
||||
}
|
||||
function edgeMid(cell: { x: number; y: number }, side: string): { x: number; y: number } {
|
||||
const c = center(cell);
|
||||
if (side === "N") return { x: c.x, y: cell.y * CELL };
|
||||
if (side === "S") return { x: c.x, y: (cell.y + 1) * CELL };
|
||||
if (side === "W") return { x: cell.x * CELL, y: c.y };
|
||||
return { x: (cell.x + 1) * CELL, y: c.y };
|
||||
}
|
||||
|
||||
const SECTOR = 5;
|
||||
/** Ghost slots can lie beyond the assembled maze — on any side, including
|
||||
* negative coordinates (the maze renormalizes after the landing). The
|
||||
@@ -566,6 +592,53 @@
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- spell flourishes: short-lived, purely cosmetic -->
|
||||
<g class="fx-layer" aria-hidden="true">
|
||||
{#each effects ?? [] as fx (fx.id)}
|
||||
{#if fx.kind === "fireball" || fx.kind === "waterbolt"}
|
||||
{@const a = center(fx.from)}
|
||||
{@const b = center(fx.to)}
|
||||
<circle r={fx.kind === "fireball" ? 7 : 6} class={`fx-${fx.kind}`} cx="0" cy="0">
|
||||
<animateMotion dur="0.42s" fill="freeze" path={`M ${a.x} ${a.y} L ${b.x} ${b.y}`} />
|
||||
</circle>
|
||||
{:else if fx.kind === "bolt"}
|
||||
<polyline points={boltPoints(fx.from, fx.to)} class="fx-bolt" />
|
||||
{:else if fx.kind === "burst"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="4" class="fx-burst" />
|
||||
<circle cx={c.x} cy={c.y} r="4" class="fx-burst late" />
|
||||
{:else if fx.kind === "splash"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="4" class="fx-splash" />
|
||||
<circle cx={c.x - 10} cy={c.y - 6} r="2.5" class="fx-droplet" />
|
||||
<circle cx={c.x + 9} cy={c.y - 8} r="2" class="fx-droplet late" />
|
||||
<circle cx={c.x + 3} cy={c.y - 12} r="1.8" class="fx-droplet later" />
|
||||
{:else if fx.kind === "shimmer"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer" />
|
||||
<circle cx={c.x} cy={c.y} r="6" class="fx-shimmer late" />
|
||||
{:else if fx.kind === "shield"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="16" class="fx-shield" />
|
||||
{:else if fx.kind === "sparkle"}
|
||||
{@const c = center(fx.at)}
|
||||
<g class="fx-sparkle" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
||||
<path d={`M ${c.x} ${c.y - 12} l 3 9 9 3 -9 3 -3 9 -3 -9 -9 -3 9 -3 z`} />
|
||||
</g>
|
||||
{:else if fx.kind === "whiff"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="8" class="fx-whiff" />
|
||||
{:else if fx.kind === "hit"}
|
||||
{@const c = center(fx.at)}
|
||||
<circle cx={c.x} cy={c.y} r="10" class="fx-hit" />
|
||||
{:else if fx.kind === "edge-dust"}
|
||||
{@const m = edgeMid(fx.cell, fx.side)}
|
||||
<circle cx={m.x - 6} cy={m.y} r="4" class="fx-dust" />
|
||||
<circle cx={m.x + 5} cy={m.y - 3} r="3" class="fx-dust late" />
|
||||
<circle cx={m.x} cy={m.y + 4} r="3.5" class="fx-dust later" />
|
||||
{/if}
|
||||
{/each}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
@@ -705,6 +778,127 @@
|
||||
animation: warp-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.ghost-slot:hover { fill: rgba(122, 162, 122, 0.22); }
|
||||
|
||||
/* --- spell flourishes ---------------------------------------------- */
|
||||
.fx-layer { pointer-events: none; }
|
||||
.fx-fireball {
|
||||
fill: #ff8c1a;
|
||||
stroke: #ffd27a;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 6px rgba(255, 120, 20, 0.9));
|
||||
animation: fx-fade 0.55s ease-in forwards;
|
||||
}
|
||||
.fx-waterbolt {
|
||||
fill: #3b8dd6;
|
||||
stroke: #b9e2ff;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 0 5px rgba(80, 160, 230, 0.9));
|
||||
animation: fx-fade 0.55s ease-in forwards;
|
||||
}
|
||||
.fx-bolt {
|
||||
fill: none;
|
||||
stroke: #ffe94d;
|
||||
stroke-width: 3;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 7px rgba(255, 233, 77, 0.95));
|
||||
animation: fx-flicker 0.5s steps(2, jump-none) forwards;
|
||||
}
|
||||
.fx-burst {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #ff8c1a;
|
||||
stroke-width: 4;
|
||||
animation: fx-ring 0.5s ease-out 0.05s forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.fx-burst.late { stroke: #ffd27a; animation-delay: 0.16s; }
|
||||
.fx-splash {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #4fa3e3;
|
||||
stroke-width: 3.5;
|
||||
animation: fx-ring 0.55s ease-out forwards;
|
||||
}
|
||||
.fx-droplet { fill: #7cc0ee; animation: fx-drop 0.6s ease-out forwards; }
|
||||
.fx-droplet.late { animation-delay: 0.08s; }
|
||||
.fx-droplet.later { animation-delay: 0.15s; }
|
||||
.fx-shimmer {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #b48ae0;
|
||||
stroke-width: 2.5;
|
||||
animation: fx-ring 0.6s ease-out forwards;
|
||||
}
|
||||
.fx-shimmer.late { stroke: #e2c8ff; animation-delay: 0.18s; opacity: 0; }
|
||||
.fx-shield {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 3.5;
|
||||
filter: drop-shadow(0 0 6px rgba(201, 167, 42, 0.8));
|
||||
animation: fx-shield-pulse 0.7s ease-out forwards;
|
||||
}
|
||||
.fx-sparkle path {
|
||||
fill: #e8dfc6;
|
||||
stroke: #c9a72a;
|
||||
stroke-width: 1;
|
||||
animation: fx-fade 0.8s ease-in forwards;
|
||||
}
|
||||
.fx-sparkle { animation: fx-spin 0.8s linear forwards; }
|
||||
.fx-whiff {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: rgba(180, 180, 180, 0.4);
|
||||
animation: fx-drift 0.7s ease-out forwards;
|
||||
}
|
||||
.fx-hit {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
fill: none;
|
||||
stroke: #c0392b;
|
||||
stroke-width: 3.5;
|
||||
animation: fx-ring-small 0.45s ease-out forwards;
|
||||
}
|
||||
.fx-dust { fill: rgba(160, 150, 130, 0.75); animation: fx-drift 0.7s ease-out forwards; }
|
||||
.fx-dust.late { animation-delay: 0.09s; }
|
||||
.fx-dust.later { animation-delay: 0.17s; }
|
||||
|
||||
@keyframes fx-fade { 70% { opacity: 1; } 100% { opacity: 0; } }
|
||||
@keyframes fx-flicker {
|
||||
0% { opacity: 0; } 15% { opacity: 1; } 40% { opacity: 0.3; }
|
||||
60% { opacity: 1; } 100% { opacity: 0; }
|
||||
}
|
||||
@keyframes fx-ring {
|
||||
0% { opacity: 0.95; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(5); }
|
||||
}
|
||||
@keyframes fx-ring-small {
|
||||
0% { opacity: 0.9; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(2.6); }
|
||||
}
|
||||
@keyframes fx-shield-pulse {
|
||||
0% { opacity: 0; transform: scale(0.6); }
|
||||
30% { opacity: 1; transform: scale(1.1); }
|
||||
60% { transform: scale(0.95); }
|
||||
100% { opacity: 0; transform: scale(1.15); }
|
||||
}
|
||||
@keyframes fx-drop {
|
||||
0% { opacity: 0.9; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-14px); }
|
||||
}
|
||||
@keyframes fx-drift {
|
||||
0% { opacity: 0.8; transform: translateY(0) scale(1); }
|
||||
100% { opacity: 0; transform: translateY(-10px) scale(1.8); }
|
||||
}
|
||||
@keyframes fx-spin {
|
||||
0% { opacity: 0; transform: rotate(0deg) scale(0.5); }
|
||||
30% { opacity: 1; }
|
||||
100% { opacity: 0; transform: rotate(90deg) scale(1.1); }
|
||||
}
|
||||
.warp-dest {
|
||||
fill: none;
|
||||
stroke: #2e7d32;
|
||||
@@ -719,6 +913,7 @@
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
||||
.fx-layer { display: none; }
|
||||
}
|
||||
.wizard { cursor: pointer; }
|
||||
.wizard-label {
|
||||
|
||||
Reference in New Issue
Block a user