diff --git a/index.html b/index.html
index 3255d03..350d6b2 100644
--- a/index.html
+++ b/index.html
@@ -102,6 +102,7 @@
+
diff --git a/src/scenes/index.js b/src/scenes/index.js
index f9c004a..9ef3988 100644
--- a/src/scenes/index.js
+++ b/src/scenes/index.js
@@ -9,6 +9,7 @@
"swirl-arena",
"relax",
"stack-blocks",
+ "storm-grid",
];
const orderedScenes = desiredOrder
.map((id) => scenes.find((s) => s.id === id))
diff --git a/src/scenes/scene-storm-grid.js b/src/scenes/scene-storm-grid.js
new file mode 100644
index 0000000..74cd12a
--- /dev/null
+++ b/src/scenes/scene-storm-grid.js
@@ -0,0 +1,119 @@
+(() => {
+ const { Bodies } = Matter;
+ const scenes = (window.PhysilinksSceneDefs =
+ window.PhysilinksSceneDefs || []);
+
+ const makeSquareBodies = (w, h, offset = 0, wallThickness = 24) => {
+ const squareSize = Math.min(w, h) * 0.82;
+ const left = (w - squareSize) / 2 + offset;
+ const top = (h - squareSize) / 2 + offset;
+ const render = {
+ fillStyle: "#0b1222",
+ strokeStyle: "#334155",
+ lineWidth: 2,
+ };
+ const floorHeight = Math.max(30, squareSize * 0.06);
+ return [
+ Bodies.rectangle(
+ left - wallThickness / 2,
+ h / 2,
+ wallThickness,
+ h + wallThickness * 2,
+ { isStatic: true, render },
+ ),
+ Bodies.rectangle(
+ left + squareSize + wallThickness / 2,
+ h / 2,
+ wallThickness,
+ h + wallThickness * 2,
+ { isStatic: true, render },
+ ),
+ Bodies.rectangle(
+ w / 2,
+ h + floorHeight / 2,
+ w + wallThickness * 2,
+ floorHeight,
+ { isStatic: true, restitution: 0.1, render },
+ ),
+ ];
+ };
+
+ scenes.push({
+ id: "storm-grid",
+ name: "Storm Grid Shift",
+ config: {
+ gravity: 0.9,
+ spawnIntervalMs: 520,
+ autoSpawn: true,
+ minChain: 3,
+ palette: ["#38bdf8", "#f97316", "#facc15", "#22c55e"],
+ ballRadius: 18,
+ ballShape: "rect",
+ spawnColumns: 10,
+ sizeFromColumns: true,
+ initialRows: 3,
+ rowGapMultiplier: 1,
+ squarePlayArea: true,
+ requireClearSpawn: true,
+ spawnInset: 0,
+ spawnIntervals: [
+ { seconds: 0, gravityX: 0, gravityY: 0.9, label: "Calm start" },
+ { seconds: 20, gravityX: 0.2, gravityY: 0.88, label: "Gust: East pull" },
+ { seconds: 40, gravityX: -0.25, gravityY: 0.85, label: "Gust: West pull" },
+ { seconds: 60, gravityX: 0, gravityY: 0.7, label: "Updraft" },
+ ],
+ winCondition: { type: "timer", durationSec: 90, onWin: { shoveBalls: true } },
+ link: {
+ stiffness: 0.82,
+ lengthScale: 1.05,
+ damping: 0.08,
+ lineWidth: 3,
+ rope: true,
+ renderType: "line",
+ maxLengthMultiplier: 3.3,
+ },
+ onBeforeUpdate: ({ engine, width, height }) => {
+ const state = engine.plugin.stormState || {};
+ const now = (engine.timing?.timestamp || 0) / 1000;
+ if (!state.startTime) {
+ state.startTime = now;
+ state.nextIdx = 0;
+ engine.plugin.stormState = state;
+ }
+ const elapsed = now - state.startTime;
+ const steps = engine.world.plugin?.stormSteps || [];
+ const upcoming = steps[state.nextIdx];
+ if (upcoming && elapsed >= upcoming.seconds) {
+ engine.gravity.x = upcoming.gravityX;
+ engine.gravity.y = upcoming.gravityY;
+ // Nudge play area offset to keep things lively.
+ const offset = ((state.nextIdx % 2 === 0 ? 1 : -1) * Math.min(width, height)) / 50;
+ engine.world.plugin.squareOffset = offset;
+ state.nextIdx += 1;
+ if (typeof window?.PhysilinksUI?.create === "function" && window.PhysilinksUI?.instance) {
+ window.PhysilinksUI.instance.showFloatingMessage(
+ { text: upcoming.label || "Gust incoming" },
+ { durationMs: 2200 },
+ );
+ }
+ }
+ },
+ spawnInsets: ({ width, height, world }) => {
+ const wallThickness = Math.max(20, Math.min(width, height) * 0.02);
+ const offset = world?.plugin?.squareOffset || 0;
+ const squareSize = Math.min(width, height) * 0.82;
+ const left = (width - squareSize) / 2 + offset;
+ return { left, right: width - (left + squareSize) };
+ },
+ },
+ createBodies: (w, h) => {
+ const offset = 0;
+ const walls = makeSquareBodies(w, h, offset, Math.max(20, Math.min(w, h) * 0.02));
+ walls.forEach((b) => {
+ b.plugin = b.plugin || {};
+ b.plugin.stormWall = true;
+ });
+ return walls;
+ },
+ });
+})();