Add relax timer scene and color collection goal

This commit is contained in:
Daddy32
2025-12-13 20:31:40 +01:00
parent 37607ef148
commit 317803a4a3
5 changed files with 214 additions and 49 deletions

View File

@@ -99,6 +99,7 @@
<script src="./scenes/scene-lowg.js"></script> <script src="./scenes/scene-lowg.js"></script>
<script src="./scenes/scene-fastdrop.js"></script> <script src="./scenes/scene-fastdrop.js"></script>
<script src="./scenes/scene-lavalamp.js"></script> <script src="./scenes/scene-lavalamp.js"></script>
<script src="./scenes/scene-relax.js"></script>
<script src="./scenes/index.js"></script> <script src="./scenes/index.js"></script>
<script src="./ui.js"></script> <script src="./ui.js"></script>
<script src="./main.js"></script> <script src="./main.js"></script>

152
main.js
View File

@@ -7,6 +7,7 @@
Body, Body,
Bodies, Bodies,
Constraint, Constraint,
Composites,
Events, Events,
Query, Query,
Vector, Vector,
@@ -105,6 +106,8 @@
let gameOver = false; let gameOver = false;
let isPaused = false; let isPaused = false;
let levelWon = false; let levelWon = false;
let timerEndMs = null;
let lastTimerDisplay = null;
const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`; const makeStorageKey = (sceneId) => `physilinks-highscore-${sceneId}`;
@@ -199,23 +202,51 @@
const y = spawnFromBottom const y = spawnFromBottom
? height + config.ballRadius * 2 ? height + config.ballRadius * 2
: -config.ballRadius * 2; : -config.ballRadius * 2;
const ball = createBallBody(x, y, color); const batchMin = currentScene?.config?.spawnBatchMin ?? 1;
ball.plugin = { 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, color,
hasEntered: false, );
entryCheckId: null, if (blob.constraints.length > 0 && blob.blobId) {
squishX: 1, blobConstraints.set(blob.blobId, blob.constraints);
squishY: 1, }
}; blob.bodies.forEach((body) => {
balls.push(ball); balls.push(body);
World.add(world, ball); World.add(world, body);
ball.plugin.entryCheckId = setTimeout(() => { if (!currentScene?.config?.noGameOver) {
ball.plugin.entryCheckId = null; body.plugin.entryCheckId = setTimeout(() => {
body.plugin.entryCheckId = null;
if (gameOver) return; if (gameOver) return;
if (!ball.plugin.hasEntered && Math.abs(ball.velocity.y) < 0.2) { if (!body.plugin.hasEntered && Math.abs(body.velocity.y) < 0.2) {
triggerGameOver(); triggerGameOver();
} }
}, 1500); }, 1500);
}
});
if (blob.constraints.length > 0) {
World.add(world, blob.constraints);
}
}
}; };
const startSpawner = () => { const startSpawner = () => {
@@ -294,6 +325,7 @@
}; };
const triggerGameOver = () => { const triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return;
if (gameOver) return; if (gameOver) return;
gameOver = true; gameOver = true;
isPaused = false; isPaused = false;
@@ -313,6 +345,15 @@
score = 0; score = 0;
clearedCount = 0; clearedCount = 0;
clearedByColor = {}; 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(); resetChainVisuals();
balls.forEach((ball) => { balls.forEach((ball) => {
cleanupBall(ball); cleanupBall(ball);
@@ -654,11 +695,11 @@
config.ballRadius = nextRadius; config.ballRadius = nextRadius;
}; };
const createBallBody = (x, y, color) => { const createBallBodies = (x, y, color) => {
const commonOpts = { const commonOpts = {
restitution: 0.72, restitution: 0.72,
friction: 0.01, friction: 0.01,
frictionAir: 0.015, frictionAir: 0.012,
render: { render: {
fillStyle: color, fillStyle: color,
strokeStyle: "#0b1222", strokeStyle: "#0b1222",
@@ -666,20 +707,38 @@
}, },
}; };
if (currentScene?.config?.blobBalls) { if (currentScene?.config?.blobBalls) {
const points = []; const cols = 3;
const segments = 12; const rows = 2;
for (let i = 0; i < segments; i += 1) { const radius = Math.max(10, config.ballRadius * 0.55);
const angle = (i / segments) * Math.PI * 2; const soft = Composites.softBody(
const variance = 0.75 + Math.random() * 0.35; x - cols * radius * 1.2,
const r = config.ballRadius * variance; y - rows * radius * 1.2,
points.push({ cols,
x: x + Math.cos(angle) * r, rows,
y: y + Math.sin(angle) * r, 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.fromVertices(x, y, [points], commonOpts); const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
} return { bodies: [body], constraints: [], blobId: null };
return Bodies.circle(x, y, config.ballRadius, commonOpts);
}; };
const normalizeColor = (c) => (c || "").trim().toLowerCase(); const normalizeColor = (c) => (c || "").trim().toLowerCase();
@@ -687,6 +746,19 @@
const getGoalState = () => { const getGoalState = () => {
const winCond = currentScene?.config?.winCondition; const winCond = currentScene?.config?.winCondition;
if (!winCond) return null; 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") { if (winCond.type === "clearCount") {
const target = winCond.target ?? 0; const target = winCond.target ?? 0;
const remaining = Math.max(0, target - clearedCount); const remaining = Math.max(0, target - clearedCount);
@@ -850,23 +922,19 @@
Body.setPosition(b, target); Body.setPosition(b, target);
Body.setVelocity(b, { x: 0, y: 0 }); Body.setVelocity(b, { x: 0, y: 0 });
}); });
if (currentScene?.config?.blobBalls) { if (timerEndMs) {
balls.forEach((ball) => { const winCond = currentScene?.config?.winCondition;
if (!ball.plugin) return; const duration = winCond?.durationSec ?? 120;
const speed = Vector.magnitude(ball.velocity || { x: 0, y: 0 }); const now = Date.now();
const squeeze = Math.min(0.22, speed / 20); const remainingMs = Math.max(0, timerEndMs - now);
const targetX = 1 + squeeze; const remainingSec = Math.ceil(remainingMs / 1000);
const targetY = Math.max(0.7, 1 - squeeze); if (lastTimerDisplay !== remainingSec) {
const currX = ball.plugin.squishX || 1; lastTimerDisplay = remainingSec;
const currY = ball.plugin.squishY || 1; updateHud();
const factorX = targetX / currX; }
const factorY = targetY / currY; if (remainingMs <= 0 && !levelWon) {
if (Math.abs(factorX - 1) > 0.02 || Math.abs(factorY - 1) > 0.02) { checkWinCondition();
Body.scale(ball, factorX, factorY);
ball.plugin.squishX = targetX;
ball.plugin.squishY = targetY;
} }
});
} }
}); });

View File

@@ -6,6 +6,7 @@
"fast-drop-maze", "fast-drop-maze",
"balanced", "balanced",
"scene-lava", "scene-lava",
"relax",
]; ];
const orderedScenes = desiredOrder const orderedScenes = desiredOrder
.map((id) => scenes.find((s) => s.id === id)) .map((id) => scenes.find((s) => s.id === id))

View File

@@ -7,11 +7,16 @@
id: "fast-drop-maze", id: "fast-drop-maze",
name: "Fast drop maze", name: "Fast drop maze",
config: { config: {
gravity: 1.25, gravity: 1.2,
spawnIntervalMs: 220, spawnIntervalMs: 220,
minChain: 3, minChain: 3,
palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"], palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"],
ballRadius: 16, ballRadius: 16,
winCondition: {
type: "colorClear",
targets: [{ color: "#e879f9", count: 100 }],
onWin: { setGravity: -0.5, swirlBalls: true },
},
link: { link: {
stiffness: 1, stiffness: 1,
lengthScale: 0.85, lengthScale: 0.85,

90
scenes/scene-relax.js Normal file
View File

@@ -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" },
}),
];
},
});
})();