Green rings at the traveler's squares become a portal curtain: the opening LINE itself shimmers on both boards — resolved from the warp table via the step's direction — in iridescent cyan over a soft violet breath, dashes drifting like light through a veil. Dimensional warp tokens, whose mouths are whole squares, wrap in the same veil around the square instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1253 lines
46 KiB
Svelte
1253 lines
46 KiB
Svelte
<script lang="ts">
|
|
import type { GameView } from "@wizwar/engine";
|
|
import type { Side } from "@wizwar/engine";
|
|
import { colorIndexOf as sharedColorIndex, wizardColor } from "./colors";
|
|
|
|
const CELL = 48;
|
|
const WALL = 7;
|
|
|
|
let {
|
|
view,
|
|
edgeSelectMode = false,
|
|
selectedCreatureId = null,
|
|
onCellClick,
|
|
onEdgeClick,
|
|
onPlayerClick,
|
|
onCreatureClick,
|
|
onWarpClick,
|
|
onCellPeek,
|
|
markedCell = null,
|
|
markedCells = null,
|
|
litCells = null,
|
|
markedSector = null,
|
|
ghostSlots = null,
|
|
onGhostClick,
|
|
effects = null,
|
|
}: {
|
|
view: GameView;
|
|
edgeSelectMode?: boolean;
|
|
selectedCreatureId?: string | null;
|
|
onCellClick?: (cell: { x: number; y: number }) => void;
|
|
onEdgeClick?: (cell: { x: number; y: number }, side: Side) => void;
|
|
onPlayerClick?: (playerId: string) => void;
|
|
onCreatureClick?: (creatureId: string) => void;
|
|
onWarpClick?: (cell: { x: number; y: number }, side: Side) => void;
|
|
/** Long-press on a square: read the card behind whatever occupies it. */
|
|
onCellPeek?: (cell: { x: number; y: number }) => void;
|
|
/** First square of a two-square spell: marked so the click reads as taken. */
|
|
markedCell?: { x: number; y: number } | null;
|
|
/** A multi-square placement in progress (boobytrap tokens), in click
|
|
* order — the first is the real trap, so each mark shows its number. */
|
|
markedCells?: { x: number; y: number }[] | null;
|
|
/** When set, squares NOT in this set dim — the targeting aid. */
|
|
litCells?: Set<string> | null;
|
|
/** Origin of a picked-up 5x5 sector: the whole sector outlines as taken. */
|
|
markedSector?: { x: number; y: number } | null;
|
|
/** Empty 5x5 slot origins a sector may land on — drawn as dashed ground
|
|
* 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
|
|
* canvas grows in whichever direction holds them. */
|
|
const minBx = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.x)) * CELL - 8);
|
|
const minBy = $derived(Math.min(0, ...(ghostSlots ?? []).map((g) => g.y)) * CELL - 8);
|
|
const boundsW = $derived(
|
|
Math.max(view.board.width, ...(ghostSlots ?? []).map((g) => g.x + SECTOR)) * CELL + 8 - minBx,
|
|
);
|
|
const boundsH = $derived(
|
|
Math.max(view.board.height, ...(ghostSlots ?? []).map((g) => g.y + SECTOR)) * CELL + 8 - minBy,
|
|
);
|
|
|
|
|
|
// Press-and-hold peeks the square's card; a fired hold swallows the click.
|
|
let peekTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let peekFired = false;
|
|
function pressCell(c: { x: number; y: number }) {
|
|
peekFired = false;
|
|
if (!onCellPeek) return;
|
|
peekTimer = setTimeout(() => { peekFired = true; onCellPeek(c); }, 450);
|
|
}
|
|
function releasePress() {
|
|
if (peekTimer) clearTimeout(peekTimer);
|
|
peekTimer = null;
|
|
}
|
|
function tapCell(c: { x: number; y: number }) {
|
|
if (peekFired) { peekFired = false; return; }
|
|
onCellClick?.(c);
|
|
}
|
|
|
|
// Real token art, cropped from the owner's physical set (public/tokens/).
|
|
// Anything without an entry falls back to the vector rendering below.
|
|
const USE_TOKEN_ART = true;
|
|
const TERRAIN_ART: Record<string, string> = {
|
|
stone: "solid-stone", thornbush: "thorn-bush", rosebush: "rosebush",
|
|
ooze: "killer-ooze", dust: "dustcloud", slime: "slime",
|
|
tacks: "tacks", pit: "pit", safe: "safe",
|
|
};
|
|
const CREATURE_ART: Record<string, string> = {
|
|
skeleton: "skeleton", troll: "troll", wraith: "wraith",
|
|
"fire-imp": "fire-imp", "democratic-monster": "democratic-monster",
|
|
shadow: "shadow", "alter-ego": "alter-ego",
|
|
};
|
|
function objectArt(cardId: string): string | null {
|
|
if (cardId === "dagger") return "dagger";
|
|
if (cardId === "large-rock") return "rock";
|
|
if (cardId === "master-key") return "master-key";
|
|
if (cardId.endsWith("stone")) return "magic-stone";
|
|
if (cardId.endsWith("-wand")) return "magic-wand";
|
|
return null;
|
|
}
|
|
// Ongoing spells wear their look: ghostly when unseen, webbed when caught,
|
|
// stone-gray under Medusa's gaze.
|
|
function hasSpell(id: string, cardId: string): boolean {
|
|
return view.sustained.some((e) => e.cardId === cardId && e.targetId === id);
|
|
}
|
|
|
|
// BIG MAN towers; SHRINK dwindles. The token says so at a glance.
|
|
function wizardScale(id: string): number {
|
|
if (view.sustained.some((e) => e.cardId === "big-man" && e.targetId === id)) return 1.5;
|
|
if (view.sustained.some((e) => e.cardId === "shrink" && e.targetId === id)) return 0.62;
|
|
return 1;
|
|
}
|
|
function wizardArt(id: string): string {
|
|
return `/tokens/wizard-${sharedColorIndex(view, id) % 6}.png`;
|
|
}
|
|
function treasureArt(owner: string): string {
|
|
return `/tokens/treasure-${sharedColorIndex(view, owner) % 6}.png`;
|
|
}
|
|
|
|
function playerColor(id: string): string {
|
|
return wizardColor(view, id);
|
|
}
|
|
|
|
const cells = $derived(
|
|
Object.keys(view.board.cells).map((k) => {
|
|
const [x, y] = k.split(",").map(Number);
|
|
return { x: x!, y: y! };
|
|
}),
|
|
);
|
|
|
|
const edges = $derived(
|
|
Object.entries(view.board.edges)
|
|
.filter(([, state]) => state !== "open")
|
|
.map(([key, state]) => {
|
|
const [kind, coords] = key.split(":") as [string, string];
|
|
const [x, y] = coords.split(",").map(Number) as [number, number];
|
|
// A door's lock can be gone for good, jammed shut, or picked open
|
|
// for the turn — each earns its own look.
|
|
const lock = state === "door"
|
|
? (view.doorStates[key] ?? (view.openDoorEdges.includes(key) ? "ajar" : null))
|
|
: null;
|
|
return { kind, x, y, state, lock };
|
|
}),
|
|
);
|
|
|
|
// Candidate edges for create/destroy wall clicks: every interior boundary.
|
|
const edgeHitboxes = $derived.by(() => {
|
|
if (!edgeSelectMode) return [];
|
|
const boxes: { cell: { x: number; y: number }; side: Side; x: number; y: number; w: number; h: number }[] = [];
|
|
for (const c of cells) {
|
|
if (view.board.cells[`${c.x + 1},${c.y}`]) {
|
|
boxes.push({ cell: c, side: "E", x: (c.x + 1) * CELL - 6, y: c.y * CELL + 4, w: 12, h: CELL - 8 });
|
|
}
|
|
if (view.board.cells[`${c.x},${c.y + 1}`]) {
|
|
boxes.push({ cell: c, side: "S", x: c.x * CELL + 4, y: (c.y + 1) * CELL - 6, w: CELL - 8, h: 12 });
|
|
}
|
|
}
|
|
return boxes;
|
|
});
|
|
|
|
// Warp pairs share a letter, like the printed openings on the real boards.
|
|
const warpLetters = $derived.by(() => {
|
|
const letters = new Map<string, string>();
|
|
let next = 0;
|
|
for (const w of view.board.warps) {
|
|
const a = `${w.from.cell.x},${w.from.cell.y}`;
|
|
const b = `${w.to.cell.x},${w.to.cell.y}`;
|
|
const key = [a, b].sort().join("|");
|
|
if (!letters.has(key)) letters.set(key, String.fromCharCode(65 + next++));
|
|
letters.set(a, letters.get(key)!);
|
|
}
|
|
return letters;
|
|
});
|
|
|
|
// Group players by cell so co-located wizards fan out.
|
|
const wizardsByCell = $derived.by(() => {
|
|
const map = new Map<string, typeof view.players>();
|
|
for (const p of view.players) {
|
|
if (!p.alive) continue;
|
|
const k = `${p.position.x},${p.position.y}`;
|
|
map.set(k, [...(map.get(k) ?? []), p]);
|
|
}
|
|
return map;
|
|
});
|
|
</script>
|
|
|
|
<!-- Explicit width/height give the svg an intrinsic size: Safari cannot
|
|
derive one from the viewBox alone and collapses the board to 0x0 inside
|
|
a max-height flex column. Doubled so CSS max-* caps still govern. -->
|
|
<svg
|
|
viewBox={`${minBx} ${minBy} ${boundsW} ${boundsH}`}
|
|
width={boundsW * 2}
|
|
height={boundsH * 2}
|
|
class="board"
|
|
>
|
|
<!-- open ground a relocated sector may claim -->
|
|
{#each ghostSlots ?? [] as g (`${g.x},${g.y}`)}
|
|
<rect
|
|
x={g.x * CELL + 2} y={g.y * CELL + 2}
|
|
width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
|
class="ghost-slot"
|
|
role="button" tabindex="-1"
|
|
onclick={() => onGhostClick?.(g)}
|
|
onkeydown={() => {}}
|
|
/>
|
|
{/each}
|
|
|
|
<!-- floor -->
|
|
{#each cells as c (`${c.x},${c.y}`)}
|
|
<rect
|
|
x={c.x * CELL} y={c.y * CELL} width={CELL} height={CELL}
|
|
class="floor"
|
|
role="button" tabindex="-1"
|
|
onclick={() => tapCell(c)}
|
|
onpointerdown={() => pressCell(c)}
|
|
onpointerup={releasePress}
|
|
onpointerleave={releasePress}
|
|
onkeydown={() => {}}
|
|
/>
|
|
{/each}
|
|
|
|
<!-- homes & treasure spaces -->
|
|
{#each view.players as p (p.id)}
|
|
{@const hx = p.home.x * CELL + CELL / 2}
|
|
{@const hy = p.home.y * CELL + CELL / 2}
|
|
{@const R = CELL * 0.36}
|
|
{@const r = CELL * 0.14}
|
|
<polygon
|
|
points={Array.from({ length: 16 }, (_, i) => {
|
|
const rad = (Math.PI / 8) * i - Math.PI / 2;
|
|
const len = i % 2 === 0 ? R : r;
|
|
return `${hx + Math.cos(rad) * len},${hy + Math.sin(rad) * len}`;
|
|
}).join(" ")}
|
|
class="home-star" fill={playerColor(p.id)}
|
|
/>
|
|
{/each}
|
|
{#each [...view.treasures.reduce((m, t) => {
|
|
if (!t.position) return m;
|
|
const k = `${t.position.x},${t.position.y}`;
|
|
m.set(k, [...(m.get(k) ?? []), t]);
|
|
return m;
|
|
}, new Map()).entries()] as [tKey, group] (tKey)}
|
|
{#each group as t, ti (t.id)}
|
|
{#if t.position}
|
|
{@const tx = t.position.x * CELL + CELL / 2 + (group.length > 1 ? (ti - (group.length - 1) / 2) * CELL * 0.3 : 0)}
|
|
{@const ty = t.position.y * CELL + CELL * 0.72}
|
|
{#if USE_TOKEN_ART}
|
|
<image
|
|
href={treasureArt(t.owner)}
|
|
x={tx - CELL * 0.26} y={ty - CELL * 0.26}
|
|
width={CELL * 0.52} height={CELL * 0.52}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="token-art small"
|
|
/>
|
|
{:else}
|
|
<g class="treasure-g">
|
|
<circle cx={tx} cy={ty} r={8} class="treasure" fill={playerColor(t.owner)} />
|
|
<circle cx={tx} cy={ty} r={4.5} class="treasure-swirl" />
|
|
<circle cx={tx} cy={ty} r={1.6} class="treasure-core" />
|
|
</g>
|
|
{/if}
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
|
|
<!-- terrain: solid stone and thornbushes -->
|
|
{#each Object.entries(view.squareContents) as [key, content] (key)}
|
|
{@const sx = Number(key.split(",")[0])}
|
|
{@const sy = Number(key.split(",")[1])}
|
|
{#if USE_TOKEN_ART && TERRAIN_ART[content.kind]}
|
|
<image
|
|
href={`/tokens/${TERRAIN_ART[content.kind]}.png`}
|
|
x={sx * CELL + 3} y={sy * CELL + 3}
|
|
width={CELL - 6} height={CELL - 6}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="token-art"
|
|
/>
|
|
{:else if content.kind === "stone"}
|
|
<rect x={sx * CELL + 2} y={sy * CELL + 2} width={CELL - 4} height={CELL - 4} class="stone" rx="4" />
|
|
{:else if content.kind === "thornbush" || content.kind === "rosebush"}
|
|
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.36}
|
|
class={content.kind === "rosebush" ? "rose" : "bush"} />
|
|
{:else if content.kind === "ooze" || content.kind === "slime"}
|
|
<rect x={sx * CELL + 5} y={sy * CELL + 5} width={CELL - 10} height={CELL - 10} rx="10"
|
|
class={content.kind === "ooze" ? "ooze" : "slime"} />
|
|
{:else if content.kind === "dust"}
|
|
<circle cx={sx * CELL + CELL / 2} cy={sy * CELL + CELL / 2} r={CELL * 0.4} class="dust" />
|
|
{:else if content.kind === "pit"}
|
|
<rect x={sx * CELL + 7} y={sy * CELL + 7} width={CELL - 14} height={CELL - 14} class="pit" />
|
|
{:else if content.kind === "tacks"}
|
|
<text x={sx * CELL + CELL / 2} y={sy * CELL + CELL / 2 + 4} class="tacks">✻✻</text>
|
|
{:else if content.kind === "safe"}
|
|
<rect x={sx * CELL + 10} y={sy * CELL + 12} width={CELL - 20} height={CELL - 22} rx="3" class="safe" />
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- dimensional warp tokens -->
|
|
{#each view.dimWarps as w, wi (wi)}
|
|
{#each [w.a, w.b] as tok, i (i)}
|
|
{#if USE_TOKEN_ART}
|
|
<image
|
|
href="/tokens/dimensional-warp.png"
|
|
x={tok.x * CELL + CELL * 0.04} y={tok.y * CELL + CELL * 0.04}
|
|
width={CELL * 0.44} height={CELL * 0.44}
|
|
preserveAspectRatio="xMidYMid slice" class="token-art"
|
|
/>
|
|
{:else}
|
|
<circle cx={tok.x * CELL + CELL * 0.5} cy={tok.y * CELL + CELL * 0.5} r={CELL * 0.3}
|
|
class="dimwarp" />
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
|
|
<!-- boobytrap tokens: face-down for everyone (the caster knows the real one) -->
|
|
{#each view.boobytraps as trap, ti (ti)}
|
|
{#each trap.cells as tc, i (i)}
|
|
<circle cx={tc.x * CELL + CELL * 0.82} cy={tc.y * CELL + CELL * 0.18} r="6"
|
|
class="trap-token" class:trap-real={trap.realCell != null && tc.x === trap.realCell.x && tc.y === trap.realCell.y} />
|
|
{/each}
|
|
{/each}
|
|
|
|
<!-- ground objects -->
|
|
{#each Object.entries(view.groundObjects) as [key, objects] (key)}
|
|
{@const gx = Number(key.split(",")[0])}
|
|
{@const gy = Number(key.split(",")[1])}
|
|
{#each objects as o, i (o.instanceId)}
|
|
{@const art = USE_TOKEN_ART ? objectArt(o.cardId) : null}
|
|
{#if art}
|
|
<image
|
|
href={`/tokens/${art}.png`}
|
|
x={gx * CELL + 4 + i * 9} y={gy * CELL + CELL - CELL * 0.42 - 3}
|
|
width={CELL * 0.4} height={CELL * 0.4}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="token-art small"
|
|
>
|
|
<title>{o.cardId}</title>
|
|
</image>
|
|
{:else}
|
|
<rect
|
|
x={gx * CELL + 6 + i * 8} y={gy * CELL + CELL - 16}
|
|
width={12} height={10} rx="2" class="ground-object"
|
|
>
|
|
<title>{o.cardId}</title>
|
|
</rect>
|
|
{/if}
|
|
{/each}
|
|
{/each}
|
|
|
|
<!-- walls & doors & firewalls -->
|
|
{#each edges as e (`${e.kind}:${e.x},${e.y}`)}
|
|
{@const cls = e.state === "door" ? `door${e.lock ? ` ${e.lock}` : ""}` : e.state === "firewall" ? "firewall" : "wall"}
|
|
{@const lockTitle = e.lock === "removed" ? "lock removed — swings free"
|
|
: e.lock === "jammed" ? "lock jammed — sealed for good"
|
|
: e.lock === "ajar" ? "unlocked until end of turn" : null}
|
|
{#if e.kind === "V"}
|
|
<rect
|
|
x={(e.x + 1) * CELL - WALL / 2} y={e.y * CELL - WALL / 2}
|
|
width={WALL} height={CELL + WALL}
|
|
class={cls}
|
|
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
|
{:else}
|
|
<rect
|
|
x={e.x * CELL - WALL / 2} y={(e.y + 1) * CELL - WALL / 2}
|
|
width={CELL + WALL} height={WALL}
|
|
class={cls}
|
|
>{#if lockTitle}<title>{lockTitle}</title>{/if}</rect>
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- battle damage: cracks spread as a wall or door takes attacks -->
|
|
{#each Object.entries(view.wallDamage) as [key, dmg] (key)}
|
|
{@const kind = key.split(":")[0]}
|
|
{@const wx = Number(key.split(":")[1]?.split(",")[0])}
|
|
{@const wy = Number(key.split(":")[1]?.split(",")[1])}
|
|
{@const frac = Math.min(1, dmg / 20)}
|
|
{#if kind === "V"}
|
|
<line x1={(wx + 1) * CELL} y1={wy * CELL + 3} x2={(wx + 1) * CELL} y2={(wy + 1) * CELL - 3}
|
|
class="crack" style:opacity={0.35 + frac * 0.65} />
|
|
{:else}
|
|
<line x1={wx * CELL + 3} y1={(wy + 1) * CELL} x2={(wx + 1) * CELL - 3} y2={(wy + 1) * CELL}
|
|
class="crack" style:opacity={0.35 + frac * 0.65} />
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- illusions YOU know are fake: ghostly dashed lines -->
|
|
{#each view.knownIllusionEdges as key (key)}
|
|
{@const kind = key.split(":")[0]}
|
|
{@const ix = Number(key.split(":")[1]?.split(",")[0])}
|
|
{@const iy = Number(key.split(":")[1]?.split(",")[1])}
|
|
{#if kind === "V"}
|
|
<line x1={(ix + 1) * CELL} y1={iy * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
|
|
{:else}
|
|
<line x1={ix * CELL} y1={(iy + 1) * CELL} x2={(ix + 1) * CELL} y2={(iy + 1) * CELL} class="illusion" />
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- warp openings: click the arrow (while standing on it) to step through -->
|
|
{#each view.board.warps as w, i (i)}
|
|
{@const wx = w.from.cell.x * CELL + CELL / 2 +
|
|
(w.from.side === "E" ? CELL * 0.42 : w.from.side === "W" ? -CELL * 0.42 : 0)}
|
|
{@const wy = w.from.cell.y * CELL + CELL / 2 +
|
|
(w.from.side === "S" ? CELL * 0.42 : w.from.side === "N" ? -CELL * 0.42 : 0)}
|
|
{@const standing = view.players.some((p) =>
|
|
p.id === view.you && p.position.x === w.from.cell.x && p.position.y === w.from.cell.y) ||
|
|
view.creatures.some((c) => c.id === selectedCreatureId &&
|
|
c.position.x === w.from.cell.x && c.position.y === w.from.cell.y)}
|
|
{@const letter = warpLetters.get(`${w.from.cell.x},${w.from.cell.y}`) ?? ""}
|
|
<g
|
|
class="warp-g" class:standing
|
|
role="button" tabindex="-1"
|
|
onclick={(ev) => { ev.stopPropagation(); onWarpClick?.(w.from.cell, w.from.side); }}
|
|
onkeydown={() => {}}
|
|
>
|
|
<circle cx={wx} cy={wy} r={11} class="warp-hit" />
|
|
<text x={wx} y={wy + 4.5} class="warp"
|
|
>{w.from.side === "N" ? "↑" : w.from.side === "S" ? "↓" : w.from.side === "E" ? "→" : "←"}</text>
|
|
<text
|
|
x={wx + (w.from.side === "E" ? -13 : w.from.side === "W" ? 13 : 12)}
|
|
y={wy + (w.from.side === "N" ? 13 : w.from.side === "S" ? -9 : 4)}
|
|
class="warp-letter"
|
|
>{letter}</text>
|
|
</g>
|
|
{#if standing}
|
|
<!-- show where this opening leads -->
|
|
<circle
|
|
cx={w.to.cell.x * CELL + CELL / 2} cy={w.to.cell.y * CELL + CELL / 2}
|
|
r={CELL * 0.42} class="warp-dest"
|
|
/>
|
|
{/if}
|
|
{/each}
|
|
|
|
<!-- first square of a two-square spell -->
|
|
{#if markedCell}
|
|
<rect
|
|
x={markedCell.x * CELL + 3} y={markedCell.y * CELL + 3}
|
|
width={CELL - 6} height={CELL - 6}
|
|
rx="6" class="marked-cell"
|
|
/>
|
|
{/if}
|
|
|
|
<!-- placements so far of a multi-square spell, numbered in click order -->
|
|
{#each markedCells ?? [] as m, i (`${m.x},${m.y}`)}
|
|
<rect
|
|
x={m.x * CELL + 3} y={m.y * CELL + 3}
|
|
width={CELL - 6} height={CELL - 6}
|
|
rx="6" class="marked-cell"
|
|
/>
|
|
<text
|
|
x={m.x * CELL + CELL / 2} y={m.y * CELL + CELL / 2}
|
|
class="marked-count" text-anchor="middle" dominant-baseline="central"
|
|
>{i + 1}</text>
|
|
{/each}
|
|
|
|
<!-- the sector picked up, awaiting its new ground -->
|
|
{#if markedSector}
|
|
<rect
|
|
x={markedSector.x * CELL + 3} y={markedSector.y * CELL + 3}
|
|
width={SECTOR * CELL - 6} height={SECTOR * CELL - 6}
|
|
rx="8" class="marked-sector"
|
|
/>
|
|
{/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}
|
|
<g
|
|
role="button" tabindex="-1"
|
|
onclick={(ev) => { ev.stopPropagation(); if (peekFired) { peekFired = false; return; } onPlayerClick?.(p.id); }}
|
|
onpointerdown={() => pressCell(p.position)}
|
|
onpointerup={releasePress}
|
|
onpointerleave={releasePress}
|
|
onkeydown={() => {}}
|
|
class="wizard"
|
|
>
|
|
{#if USE_TOKEN_ART}
|
|
{@const half = CELL * 0.3 * wizardScale(p.id)}
|
|
<image
|
|
href={wizardArt(p.id)}
|
|
x={cx - half} y={cy - half}
|
|
width={half * 2} height={half * 2}
|
|
preserveAspectRatio="xMidYMid slice"
|
|
class="token-art hit"
|
|
class:ghosted={hasSpell(p.id, "invisible") || hasSpell(p.id, "mist-body")}
|
|
class:stone-gazed={hasSpell(p.id, "medusa")}
|
|
/>
|
|
<rect
|
|
x={cx - half} y={cy - 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} />
|
|
</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>
|
|
{/if}
|
|
{#if p.carriedTreasureId}
|
|
<circle cx={cx + CELL * 0.26} cy={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}
|
|
{/each}
|
|
|
|
<!-- edge selection hitboxes -->
|
|
{#each edgeHitboxes as h (`${h.cell.x},${h.cell.y},${h.side}`)}
|
|
<rect
|
|
x={h.x} y={h.y} width={h.w} height={h.h}
|
|
class="edge-hit"
|
|
role="button" tabindex="-1"
|
|
onclick={(ev) => { ev.stopPropagation(); onEdgeClick?.(h.cell, h.side); }}
|
|
onkeydown={() => {}}
|
|
/>
|
|
{/each}
|
|
<!-- targeting aid: ineligible squares fall into shadow -->
|
|
{#if litCells}
|
|
{#each Object.keys(view.board.cells) as key (key)}
|
|
{#if !litCells.has(key)}
|
|
{@const dx = Number(key.split(",")[0])}
|
|
{@const dy = Number(key.split(",")[1])}
|
|
<rect x={dx * CELL} y={dy * CELL} width={CELL} height={CELL} class="dim-cell" />
|
|
{/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 === "pow"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-pow" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
|
<path d={`M ${c.x} ${c.y - 14} l 4 8 9 -4 -4 9 8 5 -9 3 2 10 -8 -6 -6 8 -1 -10 -10 1 7 -7 -7 -7 10 0 1 -9 6 8 z`} />
|
|
</g>
|
|
{:else if fx.kind === "claw"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-claw">
|
|
<line x1={c.x - 10} y1={c.y - 14} x2={c.x - 2} y2={c.y + 12} />
|
|
<line x1={c.x - 2} y1={c.y - 16} x2={c.x + 6} y2={c.y + 10} />
|
|
<line x1={c.x + 6} y1={c.y - 14} x2={c.x + 14} y2={c.y + 12} />
|
|
</g>
|
|
{:else if fx.kind === "absorb"}
|
|
{@const c = center(fx.at)}
|
|
<rect x={c.x - 9} y={c.y - 13} width="18" height="26" rx="2" class="fx-absorb"
|
|
style={`transform-origin: ${c.x}px ${c.y}px`} />
|
|
{:else if fx.kind === "portal"}
|
|
{@const x1 = fx.side === "E" ? (fx.cell.x + 1) * CELL : fx.cell.x * CELL}
|
|
{@const y1 = fx.side === "S" ? (fx.cell.y + 1) * CELL : fx.cell.y * CELL}
|
|
{@const x2 = fx.side === "W" ? fx.cell.x * CELL : (fx.cell.x + 1) * CELL}
|
|
{@const y2 = fx.side === "N" ? fx.cell.y * CELL : (fx.cell.y + 1) * CELL}
|
|
<line {x1} {y1} {x2} {y2} class="fx-portal glow" />
|
|
<line {x1} {y1} {x2} {y2} class="fx-portal" />
|
|
{:else if fx.kind === "portal-cell"}
|
|
<rect x={fx.at.x * CELL + 3} y={fx.at.y * CELL + 3}
|
|
width={CELL - 6} height={CELL - 6} rx="6" class="fx-portal-veil" />
|
|
{:else if fx.kind === "streak"}
|
|
{@const a = center(fx.from)}
|
|
{@const b = center(fx.to)}
|
|
<line x1={a.x} y1={a.y} x2={b.x} y2={b.y} class="fx-streak" />
|
|
{:else if fx.kind === "soul"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-soul">
|
|
<circle cx={c.x} cy={c.y - 4} r="8" />
|
|
<circle cx={c.x - 5} cy={c.y + 3} r="4" />
|
|
<circle cx={c.x + 5} cy={c.y + 3} r="4" />
|
|
</g>
|
|
{:else if fx.kind === "fireworks"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-fireworks" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
|
{#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)}
|
|
<circle
|
|
cx={c.x + 18 * Math.cos((deg * Math.PI) / 180)}
|
|
cy={c.y + 18 * Math.sin((deg * Math.PI) / 180)}
|
|
r="3" fill={["#e74c3c", "#f1c40f", "#3b8dd6", "#7ac47e"][(deg / 45) % 4]}
|
|
/>
|
|
{/each}
|
|
</g>
|
|
{:else if fx.kind === "chaos-swirl"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-chaos" style={`transform-origin: ${c.x}px ${c.y}px`}>
|
|
<circle cx={c.x} cy={c.y} r="30" />
|
|
<circle cx={c.x} cy={c.y} r="55" class="mid" />
|
|
<circle cx={c.x} cy={c.y} r="80" class="outer" />
|
|
</g>
|
|
{:else if fx.kind === "pit-fall"}
|
|
{@const c = center(fx.at)}
|
|
<circle cx={c.x} cy={c.y} r="10" class="fx-pitfall" />
|
|
<circle cx={c.x - 9} cy={c.y - 4} r="3" class="fx-dust" />
|
|
<circle cx={c.x + 9} cy={c.y - 4} r="3" class="fx-dust late" />
|
|
{:else if fx.kind === "ooze-slip"}
|
|
{@const c = center(fx.at)}
|
|
<ellipse cx={c.x} cy={c.y + 8} rx="13" ry="5" class="fx-ooze" />
|
|
{:else if fx.kind === "tacks-ow"}
|
|
{@const c = center(fx.at)}
|
|
<g class="fx-tacks">
|
|
<text x={c.x - 10} y={c.y - 4}>✱</text>
|
|
<text x={c.x + 4} y={c.y - 10} class="late">✱</text>
|
|
<text x={c.x - 2} y={c.y + 6} class="later">✱</text>
|
|
</g>
|
|
{:else if fx.kind === "thorn-snap"}
|
|
{@const c = center(fx.at)}
|
|
<path class="fx-thorn"
|
|
d={`M ${c.x - 12} ${c.y} l 6 -4 2 -8 4 6 8 -4 -3 8 7 5 -9 1 -2 9 -5 -7 -8 2 z`} />
|
|
{:else if fx.kind === "slime-stuck"}
|
|
{@const c = center(fx.at)}
|
|
<circle cx={c.x} cy={c.y} r="12" class="fx-slime" />
|
|
{:else if fx.kind === "dust-puff"}
|
|
{@const c = center(fx.at)}
|
|
<circle cx={c.x - 6} cy={c.y} r="4" class="fx-dust" />
|
|
<circle cx={c.x + 5} cy={c.y - 3} r="3" class="fx-dust late" />
|
|
<circle cx={c.x} cy={c.y + 4} r="3.5" class="fx-dust later" />
|
|
{:else if fx.kind === "sector-spin"}
|
|
{@const ox = fx.origin.x * CELL}
|
|
{@const oy = fx.origin.y * CELL}
|
|
<rect x={ox + 2} y={oy + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
|
class={`fx-sector-spin ${fx.clockwise ? "cw" : "ccw"}`}
|
|
style={`transform-origin: ${ox + (SECTOR * CELL) / 2}px ${oy + (SECTOR * CELL) / 2}px`} />
|
|
{:else if fx.kind === "sector-slide"}
|
|
{@const fxp = fx.from.x * CELL}
|
|
{@const fyp = fx.from.y * CELL}
|
|
<rect x={fxp + 2} y={fyp + 2} width={SECTOR * CELL - 4} height={SECTOR * CELL - 4}
|
|
class="fx-sector-slide"
|
|
style={`--dx: ${(fx.to.x - fx.from.x) * CELL}px; --dy: ${(fx.to.y - fx.from.y) * CELL}px`} />
|
|
{: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>
|
|
.board {
|
|
-webkit-touch-callout: none;
|
|
-webkit-user-select: none;
|
|
user-select: none;
|
|
touch-action: manipulation;
|
|
width: 100%;
|
|
max-width: 680px;
|
|
background: #e3dcc7;
|
|
border: 7px solid #2b2218;
|
|
border-radius: 3px;
|
|
box-shadow:
|
|
0 0 0 2px #b7ad92,
|
|
0 12px 34px rgba(0, 0, 0, 0.6);
|
|
}
|
|
.floor {
|
|
fill: #e3dcc7;
|
|
stroke: #b7ad92;
|
|
stroke-width: 1.1;
|
|
stroke-dasharray: 4 2.5;
|
|
cursor: pointer;
|
|
}
|
|
.floor:hover { fill: #efe8d2; }
|
|
.wall {
|
|
fill: #f2ecd8;
|
|
stroke: #2b2218;
|
|
stroke-width: 1.6;
|
|
}
|
|
.door {
|
|
fill: #8b5a2b;
|
|
stroke: #2b2218;
|
|
stroke-width: 1.4;
|
|
rx: 2;
|
|
}
|
|
/* Lock removed: pale, an open doorway forever. */
|
|
.door.removed { fill: #d4bd8e; }
|
|
/* Lock jammed: near-black, sealed. */
|
|
.door.jammed { fill: #3a2a18; stroke: #14100b; }
|
|
/* Picked or keyed open until end of turn. */
|
|
.door.ajar { fill: #d4bd8e; stroke-dasharray: 5 3; }
|
|
.firewall {
|
|
fill: #d0342c;
|
|
stroke: #7c1a14;
|
|
stroke-width: 1.2;
|
|
animation: firewall-lick 1.1s ease-in-out infinite alternate;
|
|
}
|
|
@keyframes firewall-lick {
|
|
from { fill: #d0342c; filter: drop-shadow(0 0 2px rgba(255, 120, 20, 0.5)); }
|
|
to { fill: #ef6c3a; filter: drop-shadow(0 0 6px rgba(255, 140, 30, 0.9)); }
|
|
}
|
|
.stone { fill: #6a6458; stroke: #3a362e; stroke-width: 2; }
|
|
.bush { fill: #2e7d32; stroke: #1b4d1e; stroke-width: 2; }
|
|
.rose { fill: #2e7d32; stroke: #b0245a; stroke-width: 3; }
|
|
.ooze { fill: #58a12b; opacity: 0.85; }
|
|
.slime { fill: #8ec52e; opacity: 0.85; }
|
|
.dust { fill: #9b9184; opacity: 0.75; }
|
|
.pit { fill: #171512; }
|
|
.tacks { font-size: 15px; text-anchor: middle; fill: #444; }
|
|
.safe { fill: #7d8894; stroke: #2f3844; stroke-width: 2; }
|
|
.trap-token { fill: #513c22; stroke: #201709; stroke-width: 1.5; }
|
|
.trap-real { stroke: #d3352b; stroke-width: 2.5; }
|
|
.dimwarp { fill: none; stroke: #5b3f9e; stroke-width: 3.5; stroke-dasharray: 4 3; }
|
|
.token-art {
|
|
filter: drop-shadow(0.5px 1.5px 1.5px rgba(10, 8, 4, 0.5));
|
|
pointer-events: none;
|
|
}
|
|
.token-art.hit { pointer-events: auto; cursor: pointer; }
|
|
.creature-ring, .wizard-ring {
|
|
fill: none;
|
|
stroke-width: 2.5;
|
|
pointer-events: none;
|
|
}
|
|
.creature-ring.selected { stroke-width: 4; stroke-dasharray: 5 3; }
|
|
.ground-object { fill: #a6812e; stroke: #4a3a10; stroke-width: 1; }
|
|
.illusion { stroke: #7a6f9a; stroke-width: 4; stroke-dasharray: 6 5; opacity: 0.7; }
|
|
.creature { cursor: pointer; }
|
|
.creature-body { fill: #3b3428; stroke-width: 2.5; }
|
|
.creature-body.selected { fill: #6b5a34; }
|
|
.creature-label { font-size: 11px; font-weight: bold; fill: #f4ead2; text-anchor: middle; pointer-events: none; }
|
|
/* Decorative shapes never eat clicks — the floor rect beneath handles them. */
|
|
.home-star, .stone, .bush, .rose, .ooze, .slime, .dust, .pit, .tacks, .safe,
|
|
.dimwarp, .treasure-g, .treasure, .treasure-swirl, .treasure-core {
|
|
pointer-events: none;
|
|
}
|
|
.home-star { stroke: #2b2218; stroke-width: 1.4; opacity: 0.92; }
|
|
.treasure { stroke: #2b2218; stroke-width: 1.3; }
|
|
.treasure-swirl { fill: none; stroke: #2b2218; stroke-width: 1.2; stroke-dasharray: 5 2.2; }
|
|
.treasure-core { fill: #2b2218; }
|
|
.warp { font-size: 14px; text-anchor: middle; fill: #2b2218; font-weight: bold; opacity: 0.65; pointer-events: none; }
|
|
.warp-g { cursor: pointer; }
|
|
.warp-hit { fill: transparent; }
|
|
.warp-g:hover .warp-hit { fill: rgba(43, 34, 24, 0.12); }
|
|
.warp-g.standing .warp-hit {
|
|
fill: rgba(46, 125, 50, 0.25);
|
|
stroke: #2e7d32;
|
|
stroke-width: 2;
|
|
}
|
|
.warp-g.standing .warp { opacity: 1; }
|
|
.warp-letter {
|
|
font-family: "Oswald", sans-serif;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-anchor: middle;
|
|
fill: #6a5c44;
|
|
pointer-events: none;
|
|
}
|
|
.dim-cell {
|
|
fill: rgba(12, 9, 5, 0.55);
|
|
pointer-events: none;
|
|
}
|
|
.marked-cell {
|
|
fill: rgba(211, 133, 43, 0.18);
|
|
stroke: #d3852b;
|
|
stroke-width: 2.5;
|
|
stroke-dasharray: 7 4;
|
|
pointer-events: none;
|
|
animation: warp-pulse 1.6s ease-in-out infinite;
|
|
}
|
|
.marked-count {
|
|
font-family: "Oswald", sans-serif;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
fill: #d3852b;
|
|
pointer-events: none;
|
|
}
|
|
.marked-sector {
|
|
fill: rgba(211, 133, 43, 0.12);
|
|
stroke: #d3852b;
|
|
stroke-width: 3;
|
|
stroke-dasharray: 10 6;
|
|
pointer-events: none;
|
|
animation: warp-pulse 1.6s ease-in-out infinite;
|
|
}
|
|
.ghost-slot {
|
|
fill: rgba(122, 162, 122, 0.10);
|
|
stroke: #7aa27a;
|
|
stroke-width: 2.5;
|
|
stroke-dasharray: 10 6;
|
|
cursor: pointer;
|
|
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-pow path {
|
|
fill: #ffe94d;
|
|
stroke: #b3372b;
|
|
stroke-width: 1.6;
|
|
}
|
|
.fx-pow { animation: fx-pow-hit 0.5s ease-out forwards; }
|
|
.fx-claw line {
|
|
stroke: #b3372b;
|
|
stroke-width: 3;
|
|
stroke-linecap: round;
|
|
}
|
|
.fx-claw { animation: fx-claw-rake 0.55s ease-out forwards; }
|
|
.fx-absorb {
|
|
fill: #f6f0df;
|
|
stroke: #43331f;
|
|
stroke-width: 1.5;
|
|
animation: fx-swallow 0.65s ease-in forwards;
|
|
}
|
|
.fx-portal {
|
|
stroke: #9be3e0;
|
|
stroke-width: 3;
|
|
stroke-dasharray: 6 5;
|
|
stroke-linecap: round;
|
|
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.9));
|
|
animation: fx-portal-shimmer 0.95s ease-in-out forwards;
|
|
}
|
|
.fx-portal.glow {
|
|
stroke: rgba(180, 138, 224, 0.5);
|
|
stroke-width: 9;
|
|
stroke-dasharray: none;
|
|
filter: blur(2px);
|
|
animation: fx-portal-breathe 0.95s ease-in-out forwards;
|
|
}
|
|
.fx-portal-veil {
|
|
fill: rgba(155, 227, 224, 0.12);
|
|
stroke: #9be3e0;
|
|
stroke-width: 2.5;
|
|
stroke-dasharray: 7 5;
|
|
filter: drop-shadow(0 0 4px rgba(155, 227, 224, 0.8));
|
|
animation: fx-portal-shimmer 0.95s ease-in-out forwards;
|
|
}
|
|
@keyframes fx-portal-shimmer {
|
|
0% { opacity: 0; stroke-dashoffset: 0; }
|
|
20% { opacity: 1; }
|
|
80% { opacity: 0.85; }
|
|
100% { opacity: 0; stroke-dashoffset: 34; }
|
|
}
|
|
@keyframes fx-portal-breathe {
|
|
0% { opacity: 0; }
|
|
30% { opacity: 0.8; }
|
|
100% { opacity: 0; }
|
|
}
|
|
.fx-streak {
|
|
stroke: rgba(233, 225, 203, 0.85);
|
|
stroke-width: 5;
|
|
stroke-linecap: round;
|
|
stroke-dasharray: 14 9;
|
|
animation: fx-streak-fade 0.55s ease-out forwards;
|
|
}
|
|
@keyframes fx-streak-fade {
|
|
0% { opacity: 0.9; stroke-dashoffset: 46; }
|
|
100% { opacity: 0; stroke-dashoffset: 0; }
|
|
}
|
|
.fx-soul circle {
|
|
fill: rgba(233, 228, 245, 0.8);
|
|
stroke: rgba(160, 150, 200, 0.6);
|
|
stroke-width: 1;
|
|
}
|
|
.fx-soul { animation: fx-ascend 1.3s ease-out forwards; }
|
|
@keyframes fx-ascend {
|
|
0% { opacity: 0; transform: translateY(0); }
|
|
25% { opacity: 0.9; }
|
|
100% { opacity: 0; transform: translateY(-34px); }
|
|
}
|
|
.fx-fireworks { animation: fx-fireworks-boom 1.1s ease-out forwards; }
|
|
@keyframes fx-fireworks-boom {
|
|
0% { opacity: 0; transform: scale(0.1); }
|
|
25% { opacity: 1; }
|
|
100% { opacity: 0; transform: scale(2.4) rotate(30deg); }
|
|
}
|
|
.fx-chaos circle {
|
|
fill: none;
|
|
stroke: #b48ae0;
|
|
stroke-width: 4;
|
|
stroke-dasharray: 30 22;
|
|
}
|
|
.fx-chaos .mid { stroke: #8a5fc0; stroke-dasharray: 44 30; }
|
|
.fx-chaos .outer { stroke: #e2c8ff; stroke-dasharray: 60 40; }
|
|
.fx-chaos { animation: fx-chaos-spin 1.4s ease-in-out forwards; }
|
|
@keyframes fx-chaos-spin {
|
|
0% { opacity: 0; transform: rotate(0deg) scale(0.6); }
|
|
20% { opacity: 1; }
|
|
100% { opacity: 0; transform: rotate(200deg) scale(1.5); }
|
|
}
|
|
.fx-pitfall {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
fill: rgba(30, 24, 16, 0.8);
|
|
animation: fx-swallow 0.7s ease-in forwards;
|
|
}
|
|
.fx-ooze {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
fill: rgba(110, 160, 60, 0.55);
|
|
animation: fx-ooze-wobble 0.7s ease-out forwards;
|
|
}
|
|
@keyframes fx-ooze-wobble {
|
|
0% { opacity: 0.9; transform: scaleX(0.6); }
|
|
35% { transform: scaleX(1.25); }
|
|
65% { transform: scaleX(0.9); }
|
|
100% { opacity: 0; transform: scaleX(1.1); }
|
|
}
|
|
.fx-tacks text {
|
|
font-size: 13px;
|
|
fill: #b3372b;
|
|
animation: fx-hop 0.6s ease-out forwards;
|
|
}
|
|
.fx-tacks .late { animation-delay: 0.1s; opacity: 0; }
|
|
.fx-tacks .later { animation-delay: 0.2s; opacity: 0; }
|
|
@keyframes fx-hop {
|
|
0% { opacity: 0; transform: translateY(2px); }
|
|
30% { opacity: 1; transform: translateY(-5px); }
|
|
60% { transform: translateY(-1px); }
|
|
100% { opacity: 0; transform: translateY(-8px); }
|
|
}
|
|
.fx-thorn {
|
|
fill: rgba(70, 120, 50, 0.7);
|
|
stroke: #2f5423;
|
|
stroke-width: 1.5;
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
animation: fx-pow-hit 0.55s ease-out forwards;
|
|
}
|
|
.fx-slime {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
fill: rgba(140, 200, 60, 0.5);
|
|
stroke: #7a9e2e;
|
|
stroke-width: 3;
|
|
animation: fx-slime-sink 0.9s ease-out forwards;
|
|
}
|
|
@keyframes fx-slime-sink {
|
|
0% { opacity: 0.9; transform: scale(1.3); }
|
|
100% { opacity: 0; transform: scale(0.7); }
|
|
}
|
|
.fx-sector-spin {
|
|
fill: rgba(233, 225, 203, 0.25);
|
|
stroke: #d3852b;
|
|
stroke-width: 3;
|
|
stroke-dasharray: 12 7;
|
|
animation: fx-grind-cw 1.2s ease-in-out forwards;
|
|
}
|
|
.fx-sector-spin.ccw { animation-name: fx-grind-ccw; }
|
|
@keyframes fx-grind-cw {
|
|
0% { opacity: 0; transform: rotate(-90deg); }
|
|
15% { opacity: 1; }
|
|
85% { opacity: 1; transform: rotate(0deg); }
|
|
100% { opacity: 0; }
|
|
}
|
|
@keyframes fx-grind-ccw {
|
|
0% { opacity: 0; transform: rotate(90deg); }
|
|
15% { opacity: 1; }
|
|
85% { opacity: 1; transform: rotate(0deg); }
|
|
100% { opacity: 0; }
|
|
}
|
|
.fx-sector-slide {
|
|
fill: rgba(233, 225, 203, 0.25);
|
|
stroke: #d3852b;
|
|
stroke-width: 3;
|
|
stroke-dasharray: 12 7;
|
|
animation: fx-slide-home 1.2s ease-in-out forwards;
|
|
}
|
|
@keyframes fx-slide-home {
|
|
0% { opacity: 0; transform: translate(0, 0); }
|
|
15% { opacity: 1; }
|
|
85% { opacity: 1; transform: translate(var(--dx), var(--dy)); }
|
|
100% { opacity: 0; transform: translate(var(--dx), var(--dy)); }
|
|
}
|
|
|
|
/* --- persistent ambiance: ongoing spells wear their look ------------ */
|
|
.token-art.ghosted { opacity: 0.4; animation: ghost-breathe 2.6s ease-in-out infinite; }
|
|
@keyframes ghost-breathe { 50% { opacity: 0.22; } }
|
|
.token-art.stone-gazed { filter: grayscale(0.9) brightness(0.8); }
|
|
.webbing line {
|
|
stroke: rgba(220, 218, 205, 0.85);
|
|
stroke-width: 1.6;
|
|
pointer-events: none;
|
|
}
|
|
.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-pow-hit {
|
|
0% { opacity: 0; transform: scale(0.3) rotate(-15deg); }
|
|
25% { opacity: 1; transform: scale(1.25) rotate(5deg); }
|
|
55% { transform: scale(1) rotate(0deg); }
|
|
100% { opacity: 0; transform: scale(1.05); }
|
|
}
|
|
@keyframes fx-claw-rake {
|
|
0% { opacity: 0; transform: translateY(-6px); }
|
|
20% { opacity: 1; }
|
|
100% { opacity: 0; transform: translateY(6px); }
|
|
}
|
|
@keyframes fx-swallow {
|
|
0% { opacity: 0.95; transform: scale(1) rotate(0deg); }
|
|
100% { opacity: 0; transform: scale(0.05) rotate(50deg); }
|
|
}
|
|
@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;
|
|
stroke-width: 2.5;
|
|
stroke-dasharray: 7 4;
|
|
pointer-events: none;
|
|
animation: warp-pulse 1.6s ease-in-out infinite;
|
|
}
|
|
@keyframes warp-pulse {
|
|
0%, 100% { opacity: 0.9; }
|
|
50% { opacity: 0.35; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.marked-cell, .marked-sector, .ghost-slot, .warp-dest { animation: none; }
|
|
.fx-layer { display: none; }
|
|
.firewall, .token-art.ghosted { animation: none; }
|
|
}
|
|
.wizard { cursor: pointer; }
|
|
.wizard-label {
|
|
font-family: "Oswald", sans-serif;
|
|
font-size: 13px; font-weight: 600; fill: #f4ead2;
|
|
text-anchor: middle; pointer-events: none;
|
|
}
|
|
.carried { fill: gold; stroke: #111; stroke-width: 1; }
|
|
.crack {
|
|
stroke: #efe8d4;
|
|
stroke-width: 2;
|
|
stroke-dasharray: 3 5;
|
|
stroke-linecap: round;
|
|
pointer-events: none;
|
|
}
|
|
.edge-hit { fill: rgba(30, 120, 240, 0.15); cursor: crosshair; }
|
|
.edge-hit:hover { fill: rgba(30, 120, 240, 0.5); }
|
|
</style>
|