Add relax timer scene and color collection goal
This commit is contained in:
@@ -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
152
main.js
@@ -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;
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
90
scenes/scene-relax.js
Normal 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" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user