diff --git a/index.html b/index.html
index 6ce76ce..cd3583d 100644
--- a/index.html
+++ b/index.html
@@ -100,6 +100,7 @@
+
diff --git a/src/main.js b/src/main.js
index f8972d9..a17f888 100644
--- a/src/main.js
+++ b/src/main.js
@@ -64,6 +64,8 @@
const BALL_BASELINE = 680; // reference height used for relative ball sizing
const engine = Engine.create();
+ const defaultGravityScale = engine.gravity.scale;
+ const defaultTimeScale = engine.timing.timeScale || 1;
engine.gravity.y = config.gravity;
const world = engine.world;
@@ -125,6 +127,7 @@
const balls = [];
const blobConstraints = new Map();
let spawnTimer = null;
+ let spawnCount = 0;
let score = 0;
let highScore = 0;
let clearedCount = 0;
@@ -165,7 +168,16 @@
const prevRadius = config.ballRadius;
Object.assign(config, next.config);
config.link = { ...next.config.link };
+ engine.gravity.scale =
+ typeof next.config.gravityScale === "number"
+ ? next.config.gravityScale
+ : defaultGravityScale;
+ engine.timing.timeScale =
+ typeof next.config.timeScale === "number"
+ ? next.config.timeScale
+ : defaultTimeScale;
updateBallRadius(prevRadius);
+ engine.gravity.x = 0;
engine.gravity.y = config.gravity;
clearedCount = 0;
levelWon = false;
@@ -221,16 +233,39 @@
const spawnBall = () => {
if (gameOver) return;
+ const spawnLimit = currentScene?.config?.spawnLimit;
+ if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) {
+ stopSpawner();
+ return;
+ }
const color =
config.palette[Math.floor(Math.random() * config.palette.length)];
- const x = Math.max(
- config.ballRadius + 10,
- Math.min(width - config.ballRadius - 10, Math.random() * width),
- );
+ const spawnOrigin = currentScene?.config?.spawnOrigin || "edge";
+ const spawnJitter =
+ currentScene?.config?.spawnJitter ?? config.ballRadius * 3;
+ const centerSpawn =
+ spawnOrigin === "center"
+ ? {
+ x:
+ width / 2 +
+ (Math.random() - 0.5) * Math.max(spawnJitter, config.ballRadius),
+ y:
+ height / 2 +
+ (Math.random() - 0.5) * Math.max(spawnJitter, config.ballRadius),
+ }
+ : null;
+ const x =
+ centerSpawn?.x ??
+ Math.max(
+ config.ballRadius + 10,
+ Math.min(width - config.ballRadius - 10, Math.random() * width),
+ );
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
- const y = spawnFromBottom
- ? height + config.ballRadius * 2
- : -config.ballRadius * 2;
+ const y =
+ centerSpawn?.y ??
+ (spawnFromBottom
+ ? height + config.ballRadius * 2
+ : -config.ballRadius * 2);
const batchMin = currentScene?.config?.spawnBatchMin ?? 1;
const batchMax = currentScene?.config?.spawnBatchMax ?? 1;
const batchCount =
@@ -276,6 +311,7 @@
World.add(world, blob.constraints);
}
}
+ spawnCount += batchCount;
};
const startSpawner = () => {
@@ -292,6 +328,14 @@
}
};
+ const spawnInitialBurst = () => {
+ const initialCount = currentScene?.config?.initialSpawnCount || 0;
+ if (!initialCount || initialCount <= 0) return;
+ for (let i = 0; i < initialCount; i += 1) {
+ spawnBall();
+ }
+ };
+
const cleanupBall = (ball) => {
if (ball.plugin && ball.plugin.entryCheckId) {
clearTimeout(ball.plugin.entryCheckId);
@@ -371,6 +415,7 @@
gameOver = false;
isPaused = false;
levelWon = false;
+ spawnCount = 0;
score = 0;
clearedCount = 0;
clearedByColor = {};
@@ -393,6 +438,7 @@
ui.hideGameOver();
ui.hideWin();
ui.setPauseState(false);
+ engine.gravity.x = 0;
engine.gravity.y = config.gravity;
engine.timing.timeScale = 1;
startRunner();
@@ -400,6 +446,7 @@
if (isGridScene()) {
spawnGridBalls();
} else {
+ spawnInitialBurst();
startSpawner();
}
};
@@ -534,7 +581,20 @@
const finishChain = (releasePoint) => {
if (!chain.active || gameOver || isPaused) return;
if (chain.bodies.length >= config.minChain) {
- const gain = 10 * Math.pow(chain.bodies.length, 2);
+ const baseGain = 10 * Math.pow(chain.bodies.length, 2);
+ const negativeColors = (
+ currentScene?.config?.negativeScoreColors || []
+ ).map(normalizeColor);
+ const negativeProgressColors = (
+ currentScene?.config?.negativeProgressColors || []
+ ).map(normalizeColor);
+ const isNegative =
+ chain.color &&
+ negativeColors.includes(normalizeColor(chain.color || ""));
+ const isNegativeProgress =
+ chain.color &&
+ negativeProgressColors.includes(normalizeColor(chain.color || ""));
+ const gain = isNegative ? -baseGain : baseGain;
score += gain;
clearedCount += chain.bodies.length;
if (score > highScore) {
@@ -576,6 +636,16 @@
balls.splice(i, 1);
}
}
+ if (isNegativeProgress) {
+ const winCond = currentScene?.config?.winCondition;
+ if (winCond?.type === "colorClear" && Array.isArray(winCond.targets)) {
+ winCond.targets.forEach((target) => {
+ const key = normalizeColor(target.color);
+ const current = clearedByColor[key] || 0;
+ clearedByColor[key] = Math.max(0, current - chain.bodies.length);
+ });
+ }
+ }
} else {
chain.constraints.forEach((c) => World.remove(world, c));
chain.bodies.forEach((b) => setHighlight(b, false));
@@ -886,10 +956,11 @@
const totalTarget = targets.reduce((sum, t) => sum + t.count, 0);
let totalAchieved = 0;
const parts = targets.map((t) => {
- const got = Math.min(
- t.count,
+ const achieved = Math.max(
+ 0,
clearedByColor[normalizeColor(t.color)] || 0,
);
+ const got = Math.min(t.count, achieved);
totalAchieved += got;
const remaining = Math.max(0, t.count - got);
return `${got}/${t.count} (${remaining} left)`;
@@ -972,8 +1043,16 @@
ball.plugin.hasEntered = true;
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
Matter.Body.setPosition(ball, {
- x: Math.random() * width,
- y: spawnFromBottom ? height + 40 : -40,
+ x:
+ currentScene?.config?.spawnOrigin === "center"
+ ? width / 2
+ : Math.random() * width,
+ y:
+ currentScene?.config?.spawnOrigin === "center"
+ ? height / 2
+ : spawnFromBottom
+ ? height + 40
+ : -40,
});
Matter.Body.setVelocity(ball, { x: 0, y: 0 });
}
@@ -982,6 +1061,9 @@
Events.on(engine, "beforeUpdate", () => {
// Rope-like constraint handling: allow shortening without push-back, tension when stretched.
+ if (typeof currentScene?.config?.onBeforeUpdate === "function") {
+ currentScene.config.onBeforeUpdate({ engine, width, height });
+ }
chain.constraints.forEach((c) => {
if (!c.plugin || !c.plugin.rope) return;
const current = Vector.magnitude(
diff --git a/src/scenes/index.js b/src/scenes/index.js
index b9132b2..aba2356 100644
--- a/src/scenes/index.js
+++ b/src/scenes/index.js
@@ -6,6 +6,7 @@
"fast-drop-maze",
"balanced",
"scene-lava",
+ "swirl-arena",
"relax",
];
const orderedScenes = desiredOrder
diff --git a/src/scenes/scene-swirl-arena.js b/src/scenes/scene-swirl-arena.js
new file mode 100644
index 0000000..73e78b1
--- /dev/null
+++ b/src/scenes/scene-swirl-arena.js
@@ -0,0 +1,88 @@
+(() => {
+ const { Bodies } = Matter;
+ const scenes = (window.PhysilinksSceneDefs =
+ window.PhysilinksSceneDefs || []);
+
+ scenes.push({
+ id: "swirl-arena",
+ name: "Swirl Arena",
+ config: {
+ gravity: 0,
+ gravityScale: 0.0007,
+ timeScale: 0.9,
+ spawnIntervalMs: 520,
+ initialSpawnCount: 90,
+ spawnLimit: 500,
+ autoSpawn: true,
+ minChain: 3,
+ palette: ["#f472b6", "#22c55e"],
+ ballRadius: 20,
+ spawnOrigin: "center",
+ spawnJitter: 120,
+ blobBalls: false,
+ noGameOver: true,
+ winCondition: {
+ type: "colorClear",
+ targets: [{ color: "#22c55e", count: 100 }],
+ },
+ negativeScoreColors: ["#f472b6"],
+ negativeProgressColors: ["#f472b6"],
+ link: {
+ stiffness: 0.82,
+ lengthScale: 1.08,
+ damping: 0.06,
+ lineWidth: 3,
+ rope: true,
+ renderType: "line",
+ maxLengthMultiplier: 3.6,
+ },
+ onBeforeUpdate: ({ engine }) => {
+ const t = (engine.timing?.timestamp || 0) * 0.0005;
+ engine.gravity.x = Math.cos(t);
+ engine.gravity.y = Math.sin(t);
+ },
+ },
+ createBodies: (w, h) => {
+ const wallThickness = Math.max(36, Math.min(w, h) * 0.05);
+ const innerW = w - wallThickness * 2;
+ const innerH = h - wallThickness * 2;
+ const cx = w / 2;
+ const cy = h / 2;
+ const wallOptions = {
+ isStatic: true,
+ restitution: 0.3,
+ render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
+ };
+ return [
+ Bodies.rectangle(
+ cx,
+ cy - innerH / 2 - wallThickness / 2,
+ innerW + wallThickness * 2,
+ wallThickness,
+ wallOptions,
+ ),
+ Bodies.rectangle(
+ cx,
+ cy + innerH / 2 + wallThickness / 2,
+ innerW + wallThickness * 2,
+ wallThickness,
+ wallOptions,
+ ),
+ Bodies.rectangle(
+ cx - innerW / 2 - wallThickness / 2,
+ cy,
+ wallThickness,
+ innerH + wallThickness * 2,
+ wallOptions,
+ ),
+ Bodies.rectangle(
+ cx + innerW / 2 + wallThickness / 2,
+ cy,
+ wallThickness,
+ innerH + wallThickness * 2,
+ wallOptions,
+ ),
+ ];
+ },
+ });
+})();
diff --git a/src/ui.js b/src/ui.js
index e4cb4f4..5de8824 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -148,7 +148,8 @@
if (!point || !sceneEl) return;
const el = document.createElement("div");
el.className = "floating-score";
- el.textContent = `+${amount}`;
+ const sign = amount > 0 ? "+" : "";
+ el.textContent = `${sign}${amount}`;
el.style.left = `${point.x}px`;
el.style.top = `${point.y}px`;
el.style.color = color || "#e0f2fe";