diff --git a/index.html b/index.html
index 884c8c4..4986279 100644
--- a/index.html
+++ b/index.html
@@ -99,6 +99,7 @@
+
diff --git a/main.js b/main.js
index ff8f7fd..de3e0ea 100644
--- a/main.js
+++ b/main.js
@@ -7,6 +7,7 @@
Body,
Bodies,
Constraint,
+ Composites,
Events,
Query,
Vector,
@@ -105,6 +106,8 @@
let gameOver = false;
let isPaused = false;
let levelWon = false;
+ let timerEndMs = null;
+ let lastTimerDisplay = null;
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
@@ -199,23 +202,51 @@
const y = spawnFromBottom
? height + config.ballRadius * 2
: -config.ballRadius * 2;
- const ball = createBallBody(x, y, color);
- ball.plugin = {
- color,
- hasEntered: false,
- entryCheckId: null,
- squishX: 1,
- squishY: 1,
- };
- balls.push(ball);
- World.add(world, ball);
- ball.plugin.entryCheckId = setTimeout(() => {
- ball.plugin.entryCheckId = null;
- if (gameOver) return;
- if (!ball.plugin.hasEntered && Math.abs(ball.velocity.y) < 0.2) {
- triggerGameOver();
+ const batchMin = currentScene?.config?.spawnBatchMin ?? 1;
+ const batchMax = currentScene?.config?.spawnBatchMax ?? 1;
+ const batchCount =
+ batchMin === batchMax
+ ? batchMin
+ : Math.max(
+ batchMin,
+ Math.floor(Math.random() * (batchMax - batchMin + 1)) + batchMin,
+ );
+ for (let i = 0; i < batchCount; i += 1) {
+ const blob = createBallBodies(
+ Math.min(
+ Math.max(
+ config.ballRadius + 10,
+ x + (i - batchCount / 2) * config.ballRadius * 1.5,
+ ),
+ width - config.ballRadius - 10,
+ ),
+ y +
+ i *
+ (spawnFromBottom
+ ? -config.ballRadius * 0.5
+ : config.ballRadius * 0.5),
+ color,
+ );
+ if (blob.constraints.length > 0 && blob.blobId) {
+ blobConstraints.set(blob.blobId, blob.constraints);
}
- }, 1500);
+ blob.bodies.forEach((body) => {
+ balls.push(body);
+ World.add(world, body);
+ if (!currentScene?.config?.noGameOver) {
+ body.plugin.entryCheckId = setTimeout(() => {
+ body.plugin.entryCheckId = null;
+ if (gameOver) return;
+ if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) {
+ triggerGameOver();
+ }
+ }, 1500);
+ }
+ });
+ if (blob.constraints.length > 0) {
+ World.add(world, blob.constraints);
+ }
+ }
};
const startSpawner = () => {
@@ -294,6 +325,7 @@
};
const triggerGameOver = () => {
+ if (currentScene?.config?.noGameOver) return;
if (gameOver) return;
gameOver = true;
isPaused = false;
@@ -313,6 +345,15 @@
score = 0;
clearedCount = 0;
clearedByColor = {};
+ const winCond = currentScene?.config?.winCondition;
+ if (winCond?.type === "timer") {
+ const duration = winCond.durationSec ?? 120;
+ timerEndMs = Date.now() + duration * 1000;
+ lastTimerDisplay = null;
+ } else {
+ timerEndMs = null;
+ lastTimerDisplay = null;
+ }
resetChainVisuals();
balls.forEach((ball) => {
cleanupBall(ball);
@@ -654,11 +695,11 @@
config.ballRadius = nextRadius;
};
- const createBallBody = (x, y, color) => {
+ const createBallBodies = (x, y, color) => {
const commonOpts = {
restitution: 0.72,
friction: 0.01,
- frictionAir: 0.015,
+ frictionAir: 0.012,
render: {
fillStyle: color,
strokeStyle: "#0b1222",
@@ -666,20 +707,38 @@
},
};
if (currentScene?.config?.blobBalls) {
- const points = [];
- const segments = 12;
- for (let i = 0; i < segments; i += 1) {
- const angle = (i / segments) * Math.PI * 2;
- const variance = 0.75 + Math.random() * 0.35;
- const r = config.ballRadius * variance;
- points.push({
- x: x + Math.cos(angle) * r,
- y: y + Math.sin(angle) * r,
- });
- }
- return Bodies.fromVertices(x, y, [points], commonOpts);
+ const cols = 3;
+ const rows = 2;
+ const radius = Math.max(10, config.ballRadius * 0.55);
+ const soft = Composites.softBody(
+ x - cols * radius * 1.2,
+ y - rows * radius * 1.2,
+ cols,
+ rows,
+ 0,
+ 0,
+ true,
+ radius,
+ commonOpts,
+ );
+ const blobId = `blob-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ soft.bodies.forEach((b) => {
+ b.plugin = {
+ color,
+ hasEntered: false,
+ entryCheckId: null,
+ blobId,
+ };
+ });
+ soft.constraints.forEach((c) => {
+ c.plugin = { blobId, blobConstraint: true };
+ c.render = c.render || {};
+ c.render.type = "line";
+ });
+ return { bodies: soft.bodies, constraints: soft.constraints, blobId };
}
- return Bodies.circle(x, y, config.ballRadius, commonOpts);
+ const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
+ return { bodies: [body], constraints: [], blobId: null };
};
const normalizeColor = (c) => (c || "").trim().toLowerCase();
@@ -687,6 +746,19 @@
const getGoalState = () => {
const winCond = currentScene?.config?.winCondition;
if (!winCond) return null;
+ if (winCond.type === "timer") {
+ const duration = winCond.durationSec ?? 120;
+ const now = Date.now();
+ const end = timerEndMs || now + duration * 1000;
+ const remainingMs = Math.max(0, end - now);
+ const remainingSec = Math.ceil(remainingMs / 1000);
+ const elapsed = Math.max(0, duration - remainingSec);
+ return {
+ label: `${String(Math.floor(remainingSec / 60)).padStart(2, "0")}:${String(remainingSec % 60).padStart(2, "0")}`,
+ progress: duration > 0 ? (100 * elapsed) / duration : 0,
+ met: remainingMs <= 0,
+ };
+ }
if (winCond.type === "clearCount") {
const target = winCond.target ?? 0;
const remaining = Math.max(0, target - clearedCount);
@@ -850,23 +922,19 @@
Body.setPosition(b, target);
Body.setVelocity(b, { x: 0, y: 0 });
});
- if (currentScene?.config?.blobBalls) {
- balls.forEach((ball) => {
- if (!ball.plugin) return;
- const speed = Vector.magnitude(ball.velocity || { x: 0, y: 0 });
- const squeeze = Math.min(0.22, speed / 20);
- const targetX = 1 + squeeze;
- const targetY = Math.max(0.7, 1 - squeeze);
- const currX = ball.plugin.squishX || 1;
- const currY = ball.plugin.squishY || 1;
- const factorX = targetX / currX;
- const factorY = targetY / currY;
- if (Math.abs(factorX - 1) > 0.02 || Math.abs(factorY - 1) > 0.02) {
- Body.scale(ball, factorX, factorY);
- ball.plugin.squishX = targetX;
- ball.plugin.squishY = targetY;
- }
- });
+ if (timerEndMs) {
+ const winCond = currentScene?.config?.winCondition;
+ const duration = winCond?.durationSec ?? 120;
+ const now = Date.now();
+ const remainingMs = Math.max(0, timerEndMs - now);
+ const remainingSec = Math.ceil(remainingMs / 1000);
+ if (lastTimerDisplay !== remainingSec) {
+ lastTimerDisplay = remainingSec;
+ updateHud();
+ }
+ if (remainingMs <= 0 && !levelWon) {
+ checkWinCondition();
+ }
}
});
diff --git a/scenes/index.js b/scenes/index.js
index 2bf22cf..b9132b2 100644
--- a/scenes/index.js
+++ b/scenes/index.js
@@ -6,6 +6,7 @@
"fast-drop-maze",
"balanced",
"scene-lava",
+ "relax",
];
const orderedScenes = desiredOrder
.map((id) => scenes.find((s) => s.id === id))
diff --git a/scenes/scene-fastdrop.js b/scenes/scene-fastdrop.js
index 106b893..a9862c7 100644
--- a/scenes/scene-fastdrop.js
+++ b/scenes/scene-fastdrop.js
@@ -7,11 +7,16 @@
id: "fast-drop-maze",
name: "Fast drop maze",
config: {
- gravity: 1.25,
+ gravity: 1.2,
spawnIntervalMs: 220,
minChain: 3,
palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"],
ballRadius: 16,
+ winCondition: {
+ type: "colorClear",
+ targets: [{ color: "#e879f9", count: 100 }],
+ onWin: { setGravity: -0.5, swirlBalls: true },
+ },
link: {
stiffness: 1,
lengthScale: 0.85,
diff --git a/scenes/scene-relax.js b/scenes/scene-relax.js
new file mode 100644
index 0000000..01476b0
--- /dev/null
+++ b/scenes/scene-relax.js
@@ -0,0 +1,90 @@
+(() => {
+ const { Bodies } = Matter;
+ const scenes = (window.PhysilinksSceneDefs =
+ window.PhysilinksSceneDefs || []);
+
+ scenes.push({
+ id: "relax",
+ name: "Relax drift",
+ config: {
+ gravity: 0.08,
+ spawnIntervalMs: 850,
+ spawnBatchMin: 3,
+ spawnBatchMax: 5,
+ spawnFrom: "bottom",
+ autoSpawn: true,
+ minChain: 2,
+ palette: ["#38bdf8", "#f472b6", "#fbbf24", "#22c55e", "#a855f7"],
+ ballRadius: 20,
+ blobBalls: true,
+ noGameOver: true,
+ winCondition: {
+ type: "timer",
+ durationSec: 120,
+ onWin: { setGravity: -0.4, swirlBalls: true },
+ },
+ link: {
+ stiffness: 0.6,
+ lengthScale: 1.1,
+ damping: 0.1,
+ lineWidth: 3,
+ rope: true,
+ renderType: "line",
+ maxLengthMultiplier: 3.8,
+ },
+ },
+ createBodies: (w, h) => {
+ const wallThickness = Math.max(30, w * 0.04);
+ const wallHeight = h + wallThickness * 2;
+ const floorHeight = Math.max(40, h * 0.08);
+ const bumperRadius = Math.max(30, Math.min(w, h) * 0.04);
+ return [
+ Bodies.rectangle(
+ w / 2,
+ h + floorHeight / 2,
+ w + wallThickness * 2,
+ floorHeight,
+ {
+ isStatic: true,
+ restitution: 0.8,
+ render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
+ },
+ ),
+ Bodies.rectangle(w / 2, -wallThickness / 2, w + wallThickness * 2, wallThickness, {
+ isStatic: true,
+ render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
+ }),
+ Bodies.rectangle(
+ -wallThickness / 2,
+ h / 2,
+ wallThickness,
+ wallHeight,
+ {
+ isStatic: true,
+ render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
+ },
+ ),
+ Bodies.rectangle(
+ w + wallThickness / 2,
+ h / 2,
+ wallThickness,
+ wallHeight,
+ {
+ isStatic: true,
+ render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
+ },
+ ),
+ Bodies.circle(w * 0.35, h * 0.35, bumperRadius, {
+ isStatic: true,
+ restitution: 1.05,
+ render: { fillStyle: "#fbbf24", strokeStyle: "#fbbf24" },
+ }),
+ Bodies.circle(w * 0.65, h * 0.55, bumperRadius * 0.9, {
+ isStatic: true,
+ restitution: 1.05,
+ render: { fillStyle: "#22c55e", strokeStyle: "#22c55e" },
+ }),
+ ];
+ },
+ });
+})();