Extract spawn module
This commit is contained in:
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