Extract spawn module

This commit is contained in:
Daddy32
2025-12-15 17:55:35 +01:00
parent 0bc834416f
commit 466bed56dd
3 changed files with 504 additions and 432 deletions

View File

@@ -107,6 +107,7 @@
<script src="./src/scenes/index.js"></script> <script src="./src/scenes/index.js"></script>
<script src="./src/ui.js"></script> <script src="./src/ui.js"></script>
<script src="./src/storage.js"></script> <script src="./src/storage.js"></script>
<script src="./src/spawn.js"></script>
<script src="./src/main.js"></script> <script src="./src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -5,9 +5,7 @@
Runner, Runner,
World, World,
Body, Body,
Bodies,
Constraint, Constraint,
Composites,
Events, Events,
Query, Query,
Vector, Vector,
@@ -135,8 +133,6 @@
const balls = []; const balls = [];
const blobConstraints = new Map(); const blobConstraints = new Map();
let spawnTimer = null;
let spawnCount = 0;
let score = 0; let score = 0;
let highScore = 0; let highScore = 0;
let longestChainRecord = 0; let longestChainRecord = 0;
@@ -150,6 +146,7 @@
let timerEndMs = null; let timerEndMs = null;
let lastTimerDisplay = null; let lastTimerDisplay = null;
let dragConstraint = null; let dragConstraint = null;
let spawnSystem = null;
const { const {
loadHighScore = () => 0, loadHighScore = () => 0,
@@ -206,7 +203,7 @@
typeof next.config.timeScale === "number" typeof next.config.timeScale === "number"
? next.config.timeScale ? next.config.timeScale
: defaultTimeScale; : defaultTimeScale;
updateBallRadius(prevRadius); spawnSystem.updateBallRadius(prevRadius);
engine.gravity.x = 0; engine.gravity.x = 0;
engine.gravity.y = config.gravity; engine.gravity.y = config.gravity;
clearedCount = 0; clearedCount = 0;
@@ -262,291 +259,38 @@
const isGridScene = () => const isGridScene = () =>
currentScene && currentScene.config && currentScene.config.gridLayouts; 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 = () => { const triggerGameOver = () => {
if (currentScene?.config?.noGameOver) return; if (currentScene?.config?.noGameOver) return;
if (gameOver) return; if (gameOver) return;
gameOver = true; gameOver = true;
isPaused = false; isPaused = false;
resetChainVisuals(); resetChainVisuals();
stopSpawner(); spawnSystem?.stopSpawner();
stopRunner(); stopRunner();
engine.timing.timeScale = 0; engine.timing.timeScale = 0;
ui.setPauseState(false); ui.setPauseState(false);
ui.showGameOver(score); 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 = () => { const restartGame = () => {
stopSpawner(); spawnSystem.stopSpawner();
gameOver = false; gameOver = false;
isPaused = false; isPaused = false;
levelWon = false; levelWon = false;
spawnCount = 0; spawnSystem.resetSpawnState();
score = 0; score = 0;
clearedCount = 0; clearedCount = 0;
clearedByColor = {}; clearedByColor = {};
@@ -563,7 +307,7 @@
} }
resetChainVisuals(); resetChainVisuals();
balls.forEach((ball) => { balls.forEach((ball) => {
cleanupBall(ball); spawnSystem.cleanupBall(ball);
World.remove(world, ball); World.remove(world, ball);
}); });
balls.length = 0; balls.length = 0;
@@ -580,13 +324,13 @@
startRunner(); startRunner();
updateHud(); updateHud();
if (isGridScene()) { if (isGridScene()) {
spawnGridBalls(); spawnSystem.spawnGridBalls();
} else { } else {
const spawnedGrid = spawnInitialColumns(); const spawnedGrid = spawnSystem.spawnInitialColumns();
if (!spawnedGrid) { if (!spawnedGrid) {
spawnInitialBurst(); spawnSystem.spawnInitialBurst();
} }
startSpawner(); spawnSystem.startSpawner();
} }
showGoalIntro(); showGoalIntro();
}; };
@@ -603,12 +347,12 @@
ui.setPauseState(isPaused); ui.setPauseState(isPaused);
if (isPaused) { if (isPaused) {
resetChainVisuals(); resetChainVisuals();
stopSpawner(); spawnSystem.stopSpawner();
stopRunner(); stopRunner();
engine.timing.timeScale = 0; engine.timing.timeScale = 0;
} else { } else {
startRunner(); startRunner();
startSpawner(); spawnSystem.startSpawner();
engine.timing.timeScale = 1; engine.timing.timeScale = 1;
} }
}; };
@@ -677,7 +421,7 @@
if (!goal || !goal.met) return; if (!goal || !goal.met) return;
applyWinEffects(); applyWinEffects();
levelWon = true; levelWon = true;
stopSpawner(); spawnSystem.stopSpawner();
engine.timing.timeScale = 1; engine.timing.timeScale = 1;
startRunner(); startRunner();
ui.setPauseState(false); ui.setPauseState(false);
@@ -772,7 +516,7 @@
const key = normalizeColor(body.plugin.color); const key = normalizeColor(body.plugin.color);
clearedByColor[key] = (clearedByColor[key] || 0) + 1; clearedByColor[key] = (clearedByColor[key] || 0) + 1;
} }
cleanupBall(body); spawnSystem.cleanupBall(body);
World.remove(world, body); World.remove(world, body);
} }
}); });
@@ -974,152 +718,6 @@
ui.buildLegend(config.palette); 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 normalizeColor = (c) => (c || "").trim().toLowerCase();
const getGoalState = () => { const getGoalState = () => {
@@ -1288,7 +886,7 @@
render.bounds.max.x = width; render.bounds.max.x = width;
render.bounds.max.y = height; render.bounds.max.y = height;
Render.lookAt(render, render.bounds); Render.lookAt(render, render.bounds);
updateBallRadius(prevRadius); spawnSystem.updateBallRadius(prevRadius);
clampBodiesIntoView(prevWidth, prevHeight); clampBodiesIntoView(prevWidth, prevHeight);
rebuildSceneBodies(); rebuildSceneBodies();
}; };
@@ -1309,7 +907,7 @@
? ball.position.y < -500 ? ball.position.y < -500
: ball.position.y > height + 500) : ball.position.y > height + 500)
) { ) {
cleanupBall(ball); spawnSystem.cleanupBall(ball);
ball.plugin.hasEntered = true; ball.plugin.hasEntered = true;
const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom";
Matter.Body.setPosition(ball, { Matter.Body.setPosition(ball, {
@@ -1437,6 +1035,6 @@
scenes, scenes,
(currentScene && currentScene.id) || defaultSceneId, (currentScene && currentScene.id) || defaultSceneId,
); );
updateBallRadius(); spawnSystem.updateBallRadius();
applyScene((currentScene && currentScene.id) || defaultSceneId); applyScene((currentScene && currentScene.id) || defaultSceneId);
})(); })();

473
src/spawn.js Normal file
View File

@@ -0,0 +1,473 @@
(() => {
const { World, Bodies, Composites, Query, Body } = Matter;
const create = ({
config,
world,
balls,
blobConstraints,
getCurrentScene,
getDimensions,
isGridScene,
triggerGameOver,
isGameOver,
ballBaseline = 680,
}) => {
let spawnTimer = null;
let spawnCount = 0;
const cleanupBall = (ball) => {
if (ball.plugin && ball.plugin.entryCheckId) {
clearTimeout(ball.plugin.entryCheckId);
ball.plugin.entryCheckId = null;
}
};
const getSquarePlayArea = () => {
const scene = getCurrentScene();
if (!scene?.config?.squarePlayArea) return null;
const { width, height } = getDimensions();
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 = () => {
const scene = getCurrentScene();
let left = 0;
let right = 0;
const insetVal = scene?.config?.spawnInset;
if (Number.isFinite(insetVal)) {
left = insetVal;
right = insetVal;
}
if (typeof scene?.config?.spawnInsets === "function") {
try {
const res = scene.config.spawnInsets({
...getDimensions(),
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 computeBallRadius = () => {
const scene = getCurrentScene();
const { width, height } = getDimensions();
if (isGridScene()) {
const padding = scene?.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 = scene?.config?.gridBallScale ?? 0.36;
const scaled = cellSize * scale;
return Math.round(Math.max(10, Math.min(60, scaled)));
}
if (
scene?.config?.sizeFromColumns &&
Number.isFinite(scene?.config?.spawnColumns) &&
scene.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 / scene.config.spawnColumns;
return Math.round(Math.max(8, Math.min(60, colWidth / 2)));
}
const baseRadius =
(scene && scene.config && scene.config.ballRadius) ||
config.ballRadius ||
18;
const dim = Math.max(1, Math.min(width, height));
const scaled = (baseRadius * dim) / ballBaseline;
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") {
Body.setPosition(ball, ball.position);
return;
}
if (typeof ball.circleRadius === "number") {
ball.circleRadius = nextRadius;
}
});
}
config.ballRadius = nextRadius;
};
const createBallBodies = (x, y, color) => {
const scene = getCurrentScene();
const commonOpts = {
restitution: 0.72,
friction: 0.01,
frictionAir: 0.012,
render: {
fillStyle: color,
strokeStyle: "#0b1222",
lineWidth: 2,
},
};
if (scene?.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 (scene?.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 (scene?.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 spawnBall = () => {
if (isGameOver()) return;
const scene = getCurrentScene();
const sceneConfig = scene?.config || {};
const { width, height } = getDimensions();
const spawnLimit = sceneConfig.spawnLimit;
if (Number.isFinite(spawnLimit) && spawnCount >= spawnLimit) {
stopSpawner();
return;
}
const color =
config.palette[Math.floor(Math.random() * config.palette.length)];
const spawnOrigin = sceneConfig.spawnOrigin || "edge";
const spawnJitter = sceneConfig.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 = sceneConfig.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 = sceneConfig.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 = sceneConfig.spawnBatchMin ?? 1;
const batchMax = sceneConfig.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 (sceneConfig.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 (!sceneConfig.noGameOver) {
body.plugin.entryCheckId = setTimeout(() => {
body.plugin.entryCheckId = null;
if (isGameOver()) 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;
const scene = getCurrentScene();
if (scene?.config?.autoSpawn === false) return;
if (spawnTimer) clearInterval(spawnTimer);
spawnTimer = setInterval(spawnBall, config.spawnIntervalMs);
};
const stopSpawner = () => {
if (spawnTimer) {
clearInterval(spawnTimer);
spawnTimer = null;
}
};
const spawnInitialBurst = () => {
const scene = getCurrentScene();
const initialCount = scene?.config?.initialSpawnCount || 0;
if (!initialCount || initialCount <= 0) return;
for (let i = 0; i < initialCount; i += 1) {
spawnBall();
}
};
const spawnInitialColumns = () => {
const scene = getCurrentScene();
const sceneConfig = scene?.config || {};
const rows = sceneConfig.initialRows;
const columns = sceneConfig.spawnColumns;
if (!Number.isFinite(rows) || rows <= 0) return false;
if (!Number.isFinite(columns) || columns <= 0) return false;
const { width, height } = getDimensions();
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 * (sceneConfig.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 getGridDimensions = () => {
const scene = getCurrentScene();
const { width, height } = getDimensions();
const padding = scene?.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) => {
const scene = getCurrentScene();
const sceneConfig = scene?.config || {};
if (!letter || letter === "." || letter === " ") return null;
const legend = sceneConfig.gridLegend || {};
if (legend[letter]) return legend[letter];
const paletteToUse = sceneConfig.palette || config.palette;
const index =
(letter.toUpperCase().charCodeAt(0) - 65) % paletteToUse.length;
return paletteToUse[index];
};
const spawnGridBalls = () => {
const scene = getCurrentScene();
const sceneConfig = scene?.config || {};
const layouts = sceneConfig.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 resetSpawnState = () => {
spawnCount = 0;
};
return {
startSpawner,
stopSpawner,
spawnInitialBurst,
spawnInitialColumns,
spawnGridBalls,
updateBallRadius,
computeBallRadius,
cleanupBall,
resetSpawnState,
};
};
window.PhysilinksSpawn = { create };
})();