diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte index 858c3a3..fe210f2 100644 --- a/packages/web/src/Board.svelte +++ b/packages/web/src/Board.svelte @@ -666,6 +666,74 @@ {@const c = center(fx.at)} + {:else if fx.kind === "streak"} + {@const a = center(fx.from)} + {@const b = center(fx.to)} + + {:else if fx.kind === "soul"} + {@const c = center(fx.at)} + + + + + + {:else if fx.kind === "fireworks"} + {@const c = center(fx.at)} + + {#each [0, 45, 90, 135, 180, 225, 270, 315] as deg (deg)} + + {/each} + + {:else if fx.kind === "chaos-swirl"} + {@const c = center(fx.at)} + + + + + + {:else if fx.kind === "pit-fall"} + {@const c = center(fx.at)} + + + + {:else if fx.kind === "ooze-slip"} + {@const c = center(fx.at)} + + {:else if fx.kind === "tacks-ow"} + {@const c = center(fx.at)} + + + + + + {:else if fx.kind === "thorn-snap"} + {@const c = center(fx.at)} + + {:else if fx.kind === "slime-stuck"} + {@const c = center(fx.at)} + + {:else if fx.kind === "dust-puff"} + {@const c = center(fx.at)} + + + + {:else if fx.kind === "sector-spin"} + {@const ox = fx.origin.x * CELL} + {@const oy = fx.origin.y * CELL} + + {:else if fx.kind === "sector-slide"} + {@const fxp = fx.from.x * CELL} + {@const fyp = fx.from.y * CELL} + {:else if fx.kind === "edge-dust"} {@const m = edgeMid(fx.cell, fx.side)} @@ -931,6 +999,133 @@ animation: fx-ring 0.55s ease-out forwards; } .fx-warpglow.late { stroke: #7ac47e; animation-delay: 0.15s; 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; } } diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts index 69863d8..2c09ae5 100644 --- a/packages/web/src/fx.ts +++ b/packages/web/src/fx.ts @@ -8,9 +8,12 @@ type Cell = { x: number; y: number }; type Side = "N" | "E" | "S" | "W"; type FxShape = - | { kind: "fireball" | "bolt" | "waterbolt"; from: Cell; to: Cell } + | { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell } | { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit" - | "pow" | "claw" | "absorb" | "warp-glow"; at: Cell } + | "pow" | "claw" | "absorb" | "warp-glow" | "soul" | "fireworks" | "chaos-swirl" + | "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell } + | { kind: "sector-spin"; origin: Cell; clockwise: boolean } + | { kind: "sector-slide"; from: Cell; to: Cell } | { kind: "edge-dust"; cell: Cell; side: Side }; export type BoardFx = FxShape & { id: number }; @@ -20,7 +23,9 @@ let nextId = 1; export function fxTtl(kind: BoardFx["kind"]): number { switch (kind) { case "fireball": case "waterbolt": return 700; - case "bolt": return 600; + case "bolt": case "streak": return 600; + case "soul": case "fireworks": case "chaos-swirl": + case "sector-spin": case "sector-slide": return 1500; default: return 900; } } @@ -52,6 +57,11 @@ export function fxForEvents( for (const e of events) { switch (e.type) { case "spellCast": { + if (e.cardId === "chaos") { + push({ kind: "chaos-swirl", at: { x: (view.board.width - 1) / 2, y: (view.board.height - 1) / 2 } }); + beat++; + break; + } const to = e.targetCell ?? posOf(e.target); const projectile = PROJECTILES[e.cardId]; if (projectile && to) { @@ -136,10 +146,77 @@ export function fxForEvents( push({ kind: "edge-dust", cell: e.edge.cell, side: e.edge.side }); beat++; break; - case "sectorRotated": - case "sectorRelocated": - // The whole maze moved; any effect would point at stale ground. + case "died": { + const at = posOf(e.player); + if (at) { push({ kind: "soul", at }, 250); beat++; } + break; + } + case "gameWon": { + const home = view.players.find((p) => p.id === e.player)?.home; + if (home) { + push({ kind: "fireworks", at: home }); + push({ kind: "fireworks", at: home }, 350); + push({ kind: "fireworks", at: home }, 700); + beat++; + } + break; + } + case "knockedBack": + case "shoved": + push({ kind: "streak", from: e.from, to: e.to }); + beat++; + break; + case "objectDragged": + push({ kind: "streak", from: e.from, to: e.to }); + beat++; + break; + case "jumpedPit": + push({ kind: "streak", from: e.from, to: e.to }); + beat++; + break; + case "fellInPit": + push({ kind: "pit-fall", at: e.at }); + beat++; + break; + case "climbedFromPit": { + if (e.success) { + const at = posOf(e.player); + if (at) { push({ kind: "dust-puff", at }); beat++; } + } + break; + } + case "slippedInOoze": + push({ kind: "ooze-slip", at: e.at }); + beat++; + break; + case "steppedOnTacks": + push({ kind: "tacks-ow", at: e.at }); + beat++; + break; + case "enteredThornbush": + push({ kind: "thorn-snap", at: e.at }); + beat++; + break; + case "stuckInSlime": + push({ kind: "slime-stuck", at: e.at }); + beat++; + break; + case "sectorRotated": { + const origin = view.board.placements[e.sectorIndex]?.origin; + if (origin) push({ kind: "sector-spin", origin: { ...origin }, clockwise: e.clockwise }); + // Everything after the grind points at pre-move ground; stop here. return out; + } + case "sectorRelocated": { + // The event records pre-normalization origins; the view holds the + // truth. Shift the recorded start by the same correction. + const trueTo = view.board.placements[e.sectorIndex]?.origin; + if (trueTo) { + const dx = trueTo.x - e.to.x, dy = trueTo.y - e.to.y; + push({ kind: "sector-slide", from: { x: e.from.x + dx, y: e.from.y + dy }, to: { ...trueTo } }); + } + return out; + } default: break; }