Extract spawn module
This commit is contained in:
462
src/main.js
462
src/main.js
@@ -5,9 +5,7 @@
|
||||
Runner,
|
||||
World,
|
||||
Body,
|
||||
Bodies,
|
||||
Constraint,
|
||||
Composites,
|
||||
Events,
|
||||
Query,
|
||||
Vector,
|
||||
@@ -135,8 +133,6 @@
|
||||
|
||||
const balls = [];
|
||||
const blobConstraints = new Map();
|
||||
let spawnTimer = null;
|
||||
let spawnCount = 0;
|
||||
let score = 0;
|
||||
let highScore = 0;
|
||||
let longestChainRecord = 0;
|
||||
@@ -150,6 +146,7 @@
|
||||
let timerEndMs = null;
|
||||
let lastTimerDisplay = null;
|
||||
let dragConstraint = null;
|
||||
let spawnSystem = null;
|
||||
|
||||
const {
|
||||
loadHighScore = () => 0,
|
||||
@@ -206,7 +203,7 @@
|
||||
typeof next.config.timeScale === "number"
|
||||
? next.config.timeScale
|
||||
: defaultTimeScale;
|
||||
updateBallRadius(prevRadius);
|
||||
spawnSystem.updateBallRadius(prevRadius);
|
||||
engine.gravity.x = 0;
|
||||
engine.gravity.y = config.gravity;
|
||||
clearedCount = 0;
|
||||
@@ -262,291 +259,38 @@
|
||||
const isGridScene = () =>
|
||||
currentScene && currentScene.config && currentScene.config.gridLayouts;
|
||||
|
||||
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 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 columnCount = currentScene?.config?.spawnColumns;
|
||||
const insets = getSpawnInsets();
|
||||
const squareArea = getSquarePlayArea();
|
||||
const playWidth = squareArea ? squareArea.size : width;
|
||||
const playLeft = squareArea ? squareArea.left : 0;
|
||||
const usableWidth = Math.max(0, playWidth - insets.left - insets.right);
|
||||
const minX = playLeft + insets.left + config.ballRadius + 2;
|
||||
const maxX = playLeft + playWidth - insets.right - config.ballRadius - 2;
|
||||
let x =
|
||||
centerSpawn?.x ??
|
||||
Math.max(
|
||||
minX,
|
||||
Math.min(maxX, playLeft + insets.left + Math.random() * usableWidth),
|
||||
);
|
||||
if (Number.isFinite(columnCount) && columnCount > 0) {
|
||||
const columnWidth = usableWidth / columnCount;
|
||||
const colIndex = Math.floor(Math.random() * columnCount);
|
||||
x = playLeft + insets.left + columnWidth * (colIndex + 0.5);
|
||||
x = Math.max(minX, Math.min(maxX, x));
|
||||
}
|
||||
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
|
||||
const defaultTop = squareArea
|
||||
? squareArea.top - config.ballRadius * 2
|
||||
: -config.ballRadius * 2;
|
||||
const y =
|
||||
centerSpawn?.y ??
|
||||
(spawnFromBottom ? height + config.ballRadius * 2 : defaultTop);
|
||||
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 targetX = Math.min(
|
||||
Math.max(
|
||||
config.ballRadius + 10,
|
||||
x + (i - batchCount / 2) * config.ballRadius * 1.5,
|
||||
),
|
||||
width - config.ballRadius - 10,
|
||||
);
|
||||
const targetY =
|
||||
y +
|
||||
i *
|
||||
(spawnFromBottom
|
||||
? -config.ballRadius * 0.5
|
||||
: config.ballRadius * 0.5);
|
||||
if (currentScene?.config?.requireClearSpawn) {
|
||||
const halfSize = config.ballRadius * 1.05;
|
||||
const region = {
|
||||
min: { x: targetX - halfSize, y: targetY - halfSize },
|
||||
max: { x: targetX + halfSize, y: targetY + halfSize },
|
||||
};
|
||||
const hits = Query.region(balls, region);
|
||||
if (hits && hits.length > 0) {
|
||||
triggerGameOver();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const blob = createBallBodies(targetX, targetY, color);
|
||||
if (blob.constraints.length > 0 && blob.blobId) {
|
||||
blobConstraints.set(blob.blobId, blob.constraints);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
spawnCount += batchCount;
|
||||
};
|
||||
|
||||
const startSpawner = () => {
|
||||
if (isGridScene()) return;
|
||||
if (currentScene?.config?.autoSpawn === false) return;
|
||||
if (spawnTimer) clearInterval(spawnTimer);
|
||||
spawnTimer = setInterval(spawnBall, config.spawnIntervalMs);
|
||||
};
|
||||
|
||||
const stopSpawner = () => {
|
||||
if (spawnTimer) {
|
||||
clearInterval(spawnTimer);
|
||||
spawnTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const spawnInitialBurst = () => {
|
||||
const initialCount = currentScene?.config?.initialSpawnCount || 0;
|
||||
if (!initialCount || initialCount <= 0) return;
|
||||
for (let i = 0; i < initialCount; i += 1) {
|
||||
spawnBall();
|
||||
}
|
||||
};
|
||||
|
||||
const getSquarePlayArea = () => {
|
||||
if (!currentScene?.config?.squarePlayArea) return null;
|
||||
const size = Math.min(width, height);
|
||||
const offset = world?.plugin?.squareOffset || 0;
|
||||
const left = (width - size) / 2 + offset;
|
||||
const top = (height - size) / 2 + offset;
|
||||
return { size, left, top };
|
||||
};
|
||||
|
||||
const getSpawnInsets = () => {
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
const insetVal = currentScene?.config?.spawnInset;
|
||||
if (Number.isFinite(insetVal)) {
|
||||
left = insetVal;
|
||||
right = insetVal;
|
||||
}
|
||||
if (typeof currentScene?.config?.spawnInsets === "function") {
|
||||
try {
|
||||
const res = currentScene.config.spawnInsets({ width, height, world });
|
||||
if (res && Number.isFinite(res.left)) left = res.left;
|
||||
if (res && Number.isFinite(res.right)) right = res.right;
|
||||
} catch (err) {
|
||||
console.error("spawnInsets function failed", err);
|
||||
}
|
||||
}
|
||||
return { left, right };
|
||||
};
|
||||
|
||||
const spawnInitialColumns = () => {
|
||||
const rows = currentScene?.config?.initialRows;
|
||||
const columns = currentScene?.config?.spawnColumns;
|
||||
if (!Number.isFinite(rows) || rows <= 0) return false;
|
||||
if (!Number.isFinite(columns) || columns <= 0) return false;
|
||||
const insets = getSpawnInsets();
|
||||
const squareArea = getSquarePlayArea();
|
||||
const usableWidth = Math.max(
|
||||
0,
|
||||
(squareArea ? squareArea.size : width) - insets.left - insets.right,
|
||||
);
|
||||
const columnWidth = usableWidth / columns;
|
||||
const side = config.ballRadius * 2;
|
||||
const rowGap = side * (currentScene?.config?.rowGapMultiplier ?? 1.05);
|
||||
const startY = squareArea
|
||||
? squareArea.top + config.ballRadius
|
||||
: config.ballRadius * 1.5;
|
||||
for (let r = 0; r < rows; r += 1) {
|
||||
const y = startY + r * rowGap;
|
||||
for (let c = 0; c < columns; c += 1) {
|
||||
const x =
|
||||
(squareArea ? squareArea.left : 0) +
|
||||
insets.left +
|
||||
columnWidth * (c + 0.5);
|
||||
const color =
|
||||
config.palette[Math.floor(Math.random() * config.palette.length)];
|
||||
const blob = createBallBodies(x, y, color);
|
||||
blob.bodies.forEach((body) => {
|
||||
body.plugin = body.plugin || {};
|
||||
body.plugin.hasEntered = true;
|
||||
balls.push(body);
|
||||
World.add(world, body);
|
||||
});
|
||||
if (blob.constraints.length > 0) {
|
||||
World.add(world, blob.constraints);
|
||||
}
|
||||
if (blob.constraints.length > 0 && blob.blobId) {
|
||||
blobConstraints.set(blob.blobId, blob.constraints);
|
||||
}
|
||||
}
|
||||
}
|
||||
spawnCount += rows * columns;
|
||||
return true;
|
||||
};
|
||||
|
||||
const cleanupBall = (ball) => {
|
||||
if (ball.plugin && ball.plugin.entryCheckId) {
|
||||
clearTimeout(ball.plugin.entryCheckId);
|
||||
ball.plugin.entryCheckId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const getGridDimensions = () => {
|
||||
const padding = currentScene?.config?.gridPadding ?? 0.08;
|
||||
const usableW = width * (1 - padding * 2);
|
||||
const usableH = height * (1 - padding * 2);
|
||||
const gridSize = Math.min(usableW, usableH);
|
||||
const cellSize = gridSize / 8;
|
||||
const startX = (width - gridSize) / 2 + cellSize / 2;
|
||||
const startY = (height - gridSize) / 2 + cellSize / 2;
|
||||
return { cellSize, startX, startY };
|
||||
};
|
||||
|
||||
const getGridColor = (letter) => {
|
||||
if (!letter || letter === "." || letter === " ") return null;
|
||||
const legend = currentScene?.config?.gridLegend || {};
|
||||
if (legend[letter]) return legend[letter];
|
||||
const paletteToUse = currentScene?.config?.palette || config.palette;
|
||||
const index =
|
||||
(letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length;
|
||||
return paletteToUse[index];
|
||||
};
|
||||
|
||||
const spawnGridBalls = () => {
|
||||
const layouts = currentScene?.config?.gridLayouts || [];
|
||||
if (!layouts.length) return;
|
||||
const layout = layouts[Math.floor(Math.random() * layouts.length)];
|
||||
if (!Array.isArray(layout)) return;
|
||||
const { cellSize, startX, startY } = getGridDimensions();
|
||||
const radius = computeBallRadius();
|
||||
config.ballRadius = radius;
|
||||
layout.forEach((row, rowIdx) => {
|
||||
if (typeof row !== "string") return;
|
||||
for (let colIdx = 0; colIdx < 8; colIdx += 1) {
|
||||
const letter = row[colIdx] || ".";
|
||||
const color = getGridColor(letter);
|
||||
if (!color) continue;
|
||||
const x = startX + colIdx * cellSize;
|
||||
const y = startY + rowIdx * cellSize;
|
||||
const ball = Bodies.circle(x, y, radius, {
|
||||
restitution: 0.12,
|
||||
friction: 0.01,
|
||||
frictionAir: 0.01,
|
||||
render: {
|
||||
fillStyle: color,
|
||||
strokeStyle: "#0b1222",
|
||||
lineWidth: 2,
|
||||
},
|
||||
});
|
||||
ball.plugin = { color, hasEntered: true, entryCheckId: null };
|
||||
balls.push(ball);
|
||||
World.add(world, ball);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const triggerGameOver = () => {
|
||||
if (currentScene?.config?.noGameOver) return;
|
||||
if (gameOver) return;
|
||||
gameOver = true;
|
||||
isPaused = false;
|
||||
resetChainVisuals();
|
||||
stopSpawner();
|
||||
spawnSystem?.stopSpawner();
|
||||
stopRunner();
|
||||
engine.timing.timeScale = 0;
|
||||
ui.setPauseState(false);
|
||||
ui.showGameOver(score);
|
||||
};
|
||||
|
||||
spawnSystem = window.PhysilinksSpawn.create({
|
||||
config,
|
||||
world,
|
||||
balls,
|
||||
blobConstraints,
|
||||
getCurrentScene: () => currentScene,
|
||||
getDimensions: () => ({ width, height }),
|
||||
isGridScene,
|
||||
triggerGameOver,
|
||||
isGameOver: () => gameOver,
|
||||
ballBaseline: BALL_BASELINE,
|
||||
});
|
||||
|
||||
const restartGame = () => {
|
||||
stopSpawner();
|
||||
spawnSystem.stopSpawner();
|
||||
gameOver = false;
|
||||
isPaused = false;
|
||||
levelWon = false;
|
||||
spawnCount = 0;
|
||||
spawnSystem.resetSpawnState();
|
||||
score = 0;
|
||||
clearedCount = 0;
|
||||
clearedByColor = {};
|
||||
@@ -563,7 +307,7 @@
|
||||
}
|
||||
resetChainVisuals();
|
||||
balls.forEach((ball) => {
|
||||
cleanupBall(ball);
|
||||
spawnSystem.cleanupBall(ball);
|
||||
World.remove(world, ball);
|
||||
});
|
||||
balls.length = 0;
|
||||
@@ -580,13 +324,13 @@
|
||||
startRunner();
|
||||
updateHud();
|
||||
if (isGridScene()) {
|
||||
spawnGridBalls();
|
||||
spawnSystem.spawnGridBalls();
|
||||
} else {
|
||||
const spawnedGrid = spawnInitialColumns();
|
||||
const spawnedGrid = spawnSystem.spawnInitialColumns();
|
||||
if (!spawnedGrid) {
|
||||
spawnInitialBurst();
|
||||
spawnSystem.spawnInitialBurst();
|
||||
}
|
||||
startSpawner();
|
||||
spawnSystem.startSpawner();
|
||||
}
|
||||
showGoalIntro();
|
||||
};
|
||||
@@ -603,12 +347,12 @@
|
||||
ui.setPauseState(isPaused);
|
||||
if (isPaused) {
|
||||
resetChainVisuals();
|
||||
stopSpawner();
|
||||
spawnSystem.stopSpawner();
|
||||
stopRunner();
|
||||
engine.timing.timeScale = 0;
|
||||
} else {
|
||||
startRunner();
|
||||
startSpawner();
|
||||
spawnSystem.startSpawner();
|
||||
engine.timing.timeScale = 1;
|
||||
}
|
||||
};
|
||||
@@ -677,7 +421,7 @@
|
||||
if (!goal || !goal.met) return;
|
||||
applyWinEffects();
|
||||
levelWon = true;
|
||||
stopSpawner();
|
||||
spawnSystem.stopSpawner();
|
||||
engine.timing.timeScale = 1;
|
||||
startRunner();
|
||||
ui.setPauseState(false);
|
||||
@@ -772,7 +516,7 @@
|
||||
const key = normalizeColor(body.plugin.color);
|
||||
clearedByColor[key] = (clearedByColor[key] || 0) + 1;
|
||||
}
|
||||
cleanupBall(body);
|
||||
spawnSystem.cleanupBall(body);
|
||||
World.remove(world, body);
|
||||
}
|
||||
});
|
||||
@@ -974,152 +718,6 @@
|
||||
ui.buildLegend(config.palette);
|
||||
};
|
||||
|
||||
const computeBallRadius = () => {
|
||||
if (isGridScene()) {
|
||||
const padding = currentScene?.config?.gridPadding ?? 0.08;
|
||||
const usableW = width * (1 - padding * 2);
|
||||
const usableH = height * (1 - padding * 2);
|
||||
const gridSize = Math.min(usableW, usableH);
|
||||
const cellSize = gridSize / 8;
|
||||
const scale = currentScene?.config?.gridBallScale ?? 0.36;
|
||||
const scaled = cellSize * scale;
|
||||
return Math.round(Math.max(10, Math.min(60, scaled)));
|
||||
}
|
||||
if (
|
||||
currentScene?.config?.sizeFromColumns &&
|
||||
Number.isFinite(currentScene?.config?.spawnColumns) &&
|
||||
currentScene.config.spawnColumns > 0
|
||||
) {
|
||||
const insets = getSpawnInsets();
|
||||
const squareArea = getSquarePlayArea();
|
||||
const usableWidth = Math.max(
|
||||
0,
|
||||
(squareArea ? squareArea.size : width) - insets.left - insets.right,
|
||||
);
|
||||
const colWidth = usableWidth / currentScene.config.spawnColumns;
|
||||
// Fit 10 per side; halve to get radius. Cap to a reasonable range.
|
||||
return Math.round(Math.max(8, Math.min(60, colWidth / 2)));
|
||||
}
|
||||
const baseRadius =
|
||||
(currentScene && currentScene.config && currentScene.config.ballRadius) ||
|
||||
config.ballRadius ||
|
||||
18;
|
||||
const dim = Math.max(1, Math.min(width, height));
|
||||
const scaled = (baseRadius * dim) / BALL_BASELINE;
|
||||
return Math.round(Math.max(12, Math.min(52, scaled)));
|
||||
};
|
||||
|
||||
const updateBallRadius = (prevRadius) => {
|
||||
const nextRadius = computeBallRadius();
|
||||
if (
|
||||
Number.isFinite(prevRadius) &&
|
||||
prevRadius > 0 &&
|
||||
nextRadius !== prevRadius
|
||||
) {
|
||||
const scale = nextRadius / prevRadius;
|
||||
balls.forEach((ball) => {
|
||||
Body.scale(ball, scale, scale);
|
||||
if (ball.plugin?.shape === "rect") return;
|
||||
if (ball.plugin?.shape === "jagged") {
|
||||
// Recompute center to keep shape consistent; vertices are scaled by Body.scale
|
||||
Body.setPosition(ball, ball.position);
|
||||
return;
|
||||
}
|
||||
if (typeof ball.circleRadius === "number") {
|
||||
ball.circleRadius = nextRadius;
|
||||
}
|
||||
});
|
||||
}
|
||||
config.ballRadius = nextRadius;
|
||||
};
|
||||
|
||||
const createBallBodies = (x, y, color) => {
|
||||
const commonOpts = {
|
||||
restitution: 0.72,
|
||||
friction: 0.01,
|
||||
frictionAir: 0.012,
|
||||
render: {
|
||||
fillStyle: color,
|
||||
strokeStyle: "#0b1222",
|
||||
lineWidth: 2,
|
||||
},
|
||||
};
|
||||
if (currentScene?.config?.blobBalls === "soft") {
|
||||
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 };
|
||||
}
|
||||
if (currentScene?.config?.blobBalls === "jagged") {
|
||||
const points = [];
|
||||
const segments = 6;
|
||||
for (let i = 0; i < segments; i += 1) {
|
||||
const angle = Math.min((i / segments) * Math.PI * 2, Math.PI * 2);
|
||||
const variance = 0.6 + Math.random() * 0.5;
|
||||
const r = config.ballRadius * variance;
|
||||
points.push({
|
||||
x: x + Math.cos(angle) * r,
|
||||
y: y + Math.sin(angle) * r,
|
||||
});
|
||||
}
|
||||
const body = Bodies.fromVertices(x, y, [points], commonOpts, true);
|
||||
body.plugin = {
|
||||
color,
|
||||
hasEntered: false,
|
||||
entryCheckId: null,
|
||||
shape: "jagged",
|
||||
};
|
||||
return { bodies: [body], constraints: [], blobId: null };
|
||||
}
|
||||
if (currentScene?.config?.ballShape === "rect") {
|
||||
const side = config.ballRadius * 2;
|
||||
const body = Bodies.rectangle(x, y, side, side, {
|
||||
...commonOpts,
|
||||
chamfer: 0,
|
||||
});
|
||||
body.plugin = {
|
||||
color,
|
||||
hasEntered: false,
|
||||
entryCheckId: null,
|
||||
shape: "rect",
|
||||
};
|
||||
return { bodies: [body], constraints: [], blobId: null };
|
||||
}
|
||||
const body = Bodies.circle(x, y, config.ballRadius, commonOpts);
|
||||
body.plugin = {
|
||||
color,
|
||||
hasEntered: false,
|
||||
entryCheckId: null,
|
||||
shape: "circle",
|
||||
};
|
||||
return { bodies: [body], constraints: [], blobId: null };
|
||||
};
|
||||
|
||||
const normalizeColor = (c) => (c || "").trim().toLowerCase();
|
||||
|
||||
const getGoalState = () => {
|
||||
@@ -1288,7 +886,7 @@
|
||||
render.bounds.max.x = width;
|
||||
render.bounds.max.y = height;
|
||||
Render.lookAt(render, render.bounds);
|
||||
updateBallRadius(prevRadius);
|
||||
spawnSystem.updateBallRadius(prevRadius);
|
||||
clampBodiesIntoView(prevWidth, prevHeight);
|
||||
rebuildSceneBodies();
|
||||
};
|
||||
@@ -1309,7 +907,7 @@
|
||||
? ball.position.y < -500
|
||||
: ball.position.y > height + 500)
|
||||
) {
|
||||
cleanupBall(ball);
|
||||
spawnSystem.cleanupBall(ball);
|
||||
ball.plugin.hasEntered = true;
|
||||
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
|
||||
Matter.Body.setPosition(ball, {
|
||||
@@ -1437,6 +1035,6 @@
|
||||
scenes,
|
||||
(currentScene && currentScene.id) || defaultSceneId,
|
||||
);
|
||||
updateBallRadius();
|
||||
spawnSystem.updateBallRadius();
|
||||
applyScene((currentScene && currentScene.id) || defaultSceneId);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user