750 lines
24 KiB
JavaScript
750 lines
24 KiB
JavaScript
(() => {
|
|
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 createSoftBlob = (x, y, color, commonOpts) => {
|
|
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 };
|
|
};
|
|
|
|
const createJaggedBall = (x, y, color, commonOpts) => {
|
|
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 };
|
|
};
|
|
|
|
const createGiftBall = (x, y, color, commonOpts, scene, debugSpawn) => {
|
|
const size = config.ballRadius * 2;
|
|
const ribbonSize = Math.max(4, config.ballRadius * 0.35);
|
|
const ribbonColor = scene?.config?.giftRibbonColor || "#f8fafc";
|
|
if (debugSpawn) {
|
|
console.log("Spawn gift", {
|
|
sceneId: scene?.id,
|
|
x,
|
|
y,
|
|
color,
|
|
size,
|
|
});
|
|
}
|
|
const base = Bodies.rectangle(x, y, size, size, {
|
|
...commonOpts,
|
|
chamfer: { radius: Math.max(2, config.ballRadius * 0.18) },
|
|
});
|
|
const ribbonRender = {
|
|
fillStyle: ribbonColor,
|
|
strokeStyle: "#0b1222",
|
|
lineWidth: 2,
|
|
};
|
|
const verticalRibbon = Bodies.rectangle(x, y, ribbonSize, size * 1.02, {
|
|
render: ribbonRender,
|
|
});
|
|
const horizontalRibbon = Bodies.rectangle(
|
|
x,
|
|
y,
|
|
size * 1.02,
|
|
ribbonSize,
|
|
{
|
|
render: ribbonRender,
|
|
},
|
|
);
|
|
const bow = Bodies.rectangle(
|
|
x,
|
|
y - size * 0.34,
|
|
ribbonSize * 1.4,
|
|
ribbonSize * 0.8,
|
|
{
|
|
render: ribbonRender,
|
|
},
|
|
);
|
|
const body = Body.create({
|
|
parts: [base, verticalRibbon, horizontalRibbon, bow],
|
|
});
|
|
Body.setPosition(body, { x, y });
|
|
body.restitution = commonOpts.restitution;
|
|
body.friction = commonOpts.friction;
|
|
body.frictionAir = commonOpts.frictionAir;
|
|
body.frictionStatic = commonOpts.frictionStatic;
|
|
body.density = commonOpts.density;
|
|
body.render = {
|
|
...body.render,
|
|
...commonOpts.render,
|
|
visible: true,
|
|
};
|
|
body.plugin = {
|
|
color,
|
|
hasEntered: false,
|
|
entryCheckId: null,
|
|
shape: "gift",
|
|
};
|
|
return { bodies: [body], constraints: [], blobId: null };
|
|
};
|
|
|
|
const createRectBall = (x, y, color, commonOpts) => {
|
|
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 createCircleBall = (x, y, color, commonOpts) => {
|
|
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 createBallBodies = (x, y, color) => {
|
|
const scene = getCurrentScene();
|
|
const ballPhysics = scene?.config?.ballPhysics || {};
|
|
const debugSpawn = !!scene?.config?.debugSpawn;
|
|
const commonOpts = {
|
|
restitution: ballPhysics.restitution ?? 0.72,
|
|
friction: ballPhysics.friction ?? 0.01,
|
|
frictionAir: ballPhysics.frictionAir ?? 0.012,
|
|
frictionStatic: ballPhysics.frictionStatic ?? 0,
|
|
density: ballPhysics.density ?? 0.001,
|
|
render: {
|
|
fillStyle: color,
|
|
strokeStyle: "#0b1222",
|
|
lineWidth: 2,
|
|
},
|
|
};
|
|
if (scene?.config?.blobBalls === "soft") {
|
|
return createSoftBlob(x, y, color, commonOpts);
|
|
}
|
|
if (scene?.config?.blobBalls === "jagged") {
|
|
return createJaggedBall(x, y, color, commonOpts);
|
|
}
|
|
if (scene?.config?.ballShape === "gift") {
|
|
return createGiftBall(x, y, color, commonOpts, scene, debugSpawn);
|
|
}
|
|
if (scene?.config?.ballShape === "rect") {
|
|
return createRectBall(x, y, color, commonOpts);
|
|
}
|
|
return createCircleBall(x, y, color, commonOpts);
|
|
};
|
|
|
|
const spawnBall = () => {
|
|
if (isGameOver()) return;
|
|
const scene = getCurrentScene();
|
|
const sceneConfig = scene?.config || {};
|
|
if (sceneConfig.debugSpawn) {
|
|
console.log("Spawn tick", {
|
|
sceneId: scene?.id,
|
|
ballShape: sceneConfig.ballShape,
|
|
});
|
|
}
|
|
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 spawnAtPosition = ({
|
|
x,
|
|
y,
|
|
color,
|
|
markEntered = false,
|
|
enforceSpawnLimit = true,
|
|
} = {}) => {
|
|
if (isGameOver()) return 0;
|
|
const scene = getCurrentScene();
|
|
const sceneConfig = scene?.config || {};
|
|
const spawnLimit = sceneConfig.spawnLimit;
|
|
if (
|
|
enforceSpawnLimit &&
|
|
Number.isFinite(spawnLimit) &&
|
|
spawnCount >= spawnLimit
|
|
) {
|
|
return 0;
|
|
}
|
|
const spawnColor =
|
|
color ??
|
|
config.palette[Math.floor(Math.random() * config.palette.length)];
|
|
const blob = createBallBodies(x, y, spawnColor);
|
|
if (blob.constraints.length > 0 && blob.blobId) {
|
|
blobConstraints.set(blob.blobId, blob.constraints);
|
|
}
|
|
blob.bodies.forEach((body) => {
|
|
body.plugin = body.plugin || {};
|
|
body.plugin.hasEntered = !!markEntered;
|
|
if (body.plugin.entryCheckId) {
|
|
clearTimeout(body.plugin.entryCheckId);
|
|
body.plugin.entryCheckId = null;
|
|
}
|
|
balls.push(body);
|
|
World.add(world, body);
|
|
if (!sceneConfig.noGameOver && !markEntered) {
|
|
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 += blob.bodies.length;
|
|
return blob.bodies.length;
|
|
};
|
|
|
|
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;
|
|
if (scene?.config?.debugSpawn) {
|
|
console.log("Initial burst", {
|
|
sceneId: scene?.id,
|
|
initialCount,
|
|
});
|
|
}
|
|
const areaSource = scene?.config?.initialSpawnArea;
|
|
let area = null;
|
|
if (typeof areaSource === "function") {
|
|
try {
|
|
area = areaSource({ ...getDimensions(), world });
|
|
} catch (err) {
|
|
console.error("initialSpawnArea function failed", err);
|
|
}
|
|
}
|
|
if (scene?.config?.debugSpawn) {
|
|
console.log("Initial spawn area", {
|
|
sceneId: scene?.id,
|
|
area,
|
|
});
|
|
}
|
|
if (area && Number.isFinite(area.xMin) && Number.isFinite(area.xMax)) {
|
|
const { width, height } = getDimensions();
|
|
const pad = config.ballRadius + 4;
|
|
const minX = Math.max(pad, Math.min(area.xMin, area.xMax));
|
|
const maxX = Math.min(width - pad, Math.max(area.xMin, area.xMax));
|
|
const minY = Math.max(pad, Math.min(area.yMin, area.yMax));
|
|
const maxY = Math.min(height - pad, Math.max(area.yMin, area.yMax));
|
|
if (scene?.config?.debugSpawn) {
|
|
console.log("Initial spawn bounds", {
|
|
sceneId: scene?.id,
|
|
minX,
|
|
maxX,
|
|
minY,
|
|
maxY,
|
|
});
|
|
}
|
|
for (let i = 0; i < initialCount; i += 1) {
|
|
const x = minX + Math.random() * Math.max(0, maxX - minX);
|
|
const y = minY + Math.random() * Math.max(0, maxY - minY);
|
|
spawnAtPosition({ x, y, markEntered: true });
|
|
}
|
|
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 removeBlob = (blobId) => {
|
|
if (!blobId) return;
|
|
const constraints = blobConstraints.get(blobId) || [];
|
|
constraints.forEach((c) => World.remove(world, c));
|
|
blobConstraints.delete(blobId);
|
|
for (let i = balls.length - 1; i >= 0; i -= 1) {
|
|
const ball = balls[i];
|
|
if (ball.plugin?.blobId === blobId) {
|
|
cleanupBall(ball);
|
|
World.remove(world, ball);
|
|
balls.splice(i, 1);
|
|
}
|
|
}
|
|
};
|
|
|
|
const resetSpawnState = () => {
|
|
spawnCount = 0;
|
|
};
|
|
|
|
const spawnRowAtY = ({
|
|
y,
|
|
columns: columnsOverride,
|
|
jitter = 0,
|
|
markEntered = false,
|
|
} = {}) => {
|
|
const scene = getCurrentScene();
|
|
const sceneConfig = scene?.config || {};
|
|
const columns =
|
|
Number.isFinite(columnsOverride) && columnsOverride > 0
|
|
? columnsOverride
|
|
: sceneConfig.spawnColumns;
|
|
if (!Number.isFinite(columns) || columns <= 0) return 0;
|
|
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 minX =
|
|
(squareArea ? squareArea.left : 0) +
|
|
insets.left +
|
|
config.ballRadius +
|
|
2;
|
|
const maxX =
|
|
(squareArea ? squareArea.left + squareArea.size : width) -
|
|
insets.right -
|
|
config.ballRadius -
|
|
2;
|
|
const baseY =
|
|
typeof y === "number"
|
|
? y
|
|
: (squareArea ? squareArea.top + squareArea.size : height) -
|
|
config.ballRadius * 1.1;
|
|
for (let c = 0; c < columns; c += 1) {
|
|
const jitterX = (Math.random() - 0.5) * jitter;
|
|
const x =
|
|
(squareArea ? squareArea.left : 0) +
|
|
insets.left +
|
|
columnWidth * (c + 0.5) +
|
|
jitterX;
|
|
const safeX = Math.max(minX, Math.min(maxX, x));
|
|
const color =
|
|
config.palette[Math.floor(Math.random() * config.palette.length)];
|
|
const blob = createBallBodies(safeX, baseY, color);
|
|
blob.bodies.forEach((body) => {
|
|
body.plugin = body.plugin || {};
|
|
body.plugin.hasEntered = !!markEntered;
|
|
if (body.plugin.entryCheckId) {
|
|
clearTimeout(body.plugin.entryCheckId);
|
|
body.plugin.entryCheckId = null;
|
|
}
|
|
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 += columns;
|
|
return columns;
|
|
};
|
|
|
|
return {
|
|
startSpawner,
|
|
stopSpawner,
|
|
spawnInitialBurst,
|
|
spawnInitialColumns,
|
|
spawnGridBalls,
|
|
updateBallRadius,
|
|
computeBallRadius,
|
|
cleanupBall,
|
|
removeBlob,
|
|
resetSpawnState,
|
|
spawnRowAtY,
|
|
spawnAtPosition,
|
|
};
|
|
};
|
|
|
|
window.PhysilinksSpawn = { create };
|
|
})();
|