Extract spawn module
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
462
src/main.js
462
src/main.js
@@ -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
473
src/spawn.js
Normal 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 };
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user