diff --git a/packages/web/src/Board.svelte b/packages/web/src/Board.svelte
index fe210f2..b263580 100644
--- a/packages/web/src/Board.svelte
+++ b/packages/web/src/Board.svelte
@@ -662,10 +662,16 @@
{@const c = center(fx.at)}
- {:else if fx.kind === "warp-glow"}
- {@const c = center(fx.at)}
-
-
+ {: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}
+
+
+ {:else if fx.kind === "portal-cell"}
+
{:else if fx.kind === "streak"}
{@const a = center(fx.from)}
{@const b = center(fx.to)}
@@ -989,16 +995,40 @@
stroke-width: 1.5;
animation: fx-swallow 0.65s ease-in forwards;
}
- .fx-warpglow {
- transform-box: fill-box;
- transform-origin: center;
- fill: none;
- stroke: #2e7d32;
+ .fx-portal {
+ stroke: #9be3e0;
stroke-width: 3;
- filter: drop-shadow(0 0 5px rgba(46, 125, 50, 0.8));
- animation: fx-ring 0.55s ease-out forwards;
+ 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-warpglow.late { stroke: #7ac47e; animation-delay: 0.15s; opacity: 0; }
.fx-streak {
stroke: rgba(233, 225, 203, 0.85);
stroke-width: 5;
diff --git a/packages/web/src/fx.ts b/packages/web/src/fx.ts
index 2c09ae5..8190075 100644
--- a/packages/web/src/fx.ts
+++ b/packages/web/src/fx.ts
@@ -10,8 +10,9 @@ type Side = "N" | "E" | "S" | "W";
type FxShape =
| { kind: "fireball" | "bolt" | "waterbolt" | "streak"; from: Cell; to: Cell }
| { kind: "burst" | "splash" | "shimmer" | "shield" | "sparkle" | "whiff" | "hit"
- | "pow" | "claw" | "absorb" | "warp-glow" | "soul" | "fireworks" | "chaos-swirl"
+ | "pow" | "claw" | "absorb" | "portal-cell" | "soul" | "fireworks" | "chaos-swirl"
| "pit-fall" | "ooze-slip" | "tacks-ow" | "thorn-snap" | "slime-stuck" | "dust-puff"; at: Cell }
+ | { kind: "portal"; cell: Cell; side: Side }
| { kind: "sector-spin"; origin: Cell; clockwise: boolean }
| { kind: "sector-slide"; from: Cell; to: Cell }
| { kind: "edge-dust"; cell: Cell; side: Side };
@@ -24,6 +25,7 @@ export function fxTtl(kind: BoardFx["kind"]): number {
switch (kind) {
case "fireball": case "waterbolt": return 700;
case "bolt": case "streak": return 600;
+ case "portal": case "portal-cell":
case "soul": case "fireworks": case "chaos-swirl":
case "sector-spin": case "sector-slide": return 1500;
default: return 900;
@@ -131,14 +133,23 @@ export function fxForEvents(
break;
case "moved":
if (e.via === "warp") {
- push({ kind: "warp-glow", at: e.from });
- push({ kind: "warp-glow", at: e.to }, 150);
+ // The mouths are edges: shimmer the opening lines on both boards.
+ const w = view.board.warps.find((w) =>
+ w.from.cell.x === e.from.x && w.from.cell.y === e.from.y && w.from.side === e.direction);
+ if (w) {
+ push({ kind: "portal", cell: w.from.cell, side: w.from.side });
+ push({ kind: "portal", cell: w.to.cell, side: w.to.side }, 150);
+ } else {
+ push({ kind: "portal-cell", at: e.from });
+ push({ kind: "portal-cell", at: e.to }, 150);
+ }
beat++;
}
break;
case "warpStepped":
- push({ kind: "warp-glow", at: e.from });
- push({ kind: "warp-glow", at: e.to }, 150);
+ // Dimensional warp tokens fill their squares; the veil wraps them.
+ push({ kind: "portal-cell", at: e.from });
+ push({ kind: "portal-cell", at: e.to }, 150);
beat++;
break;
case "wallCreated":