Move JavaScript files into src folder

This commit is contained in:
Daddy32
2025-12-13 22:44:35 +01:00
parent 20c6806598
commit 22c717a33e
12 changed files with 17 additions and 17 deletions

5
src/decomp-setup.js Normal file
View File

@@ -0,0 +1,5 @@
(() => {
if (typeof Matter !== "undefined" && Matter.Common && window.decomp) {
Matter.Common.setDecomp(window.decomp);
}
})();

1059
src/main.js Normal file

File diff suppressed because it is too large Load Diff

28
src/scenes/index.js Normal file
View File

@@ -0,0 +1,28 @@
(() => {
const scenes = window.PhysilinksSceneDefs || [];
const desiredOrder = [
"scene-grid",
"low-g-terraces",
"fast-drop-maze",
"balanced",
"scene-lava",
"relax",
];
const orderedScenes = desiredOrder
.map((id) => scenes.find((s) => s.id === id))
.filter(Boolean);
const unordered = scenes.filter(
(s) => !orderedScenes.find((o) => o.id === s.id),
);
const finalScenes = [...orderedScenes, ...unordered];
const defaultSceneId =
desiredOrder.find((id) => finalScenes.some((s) => s.id === id)) ||
finalScenes[0]?.id ||
"scene-grid";
window.PhysilinksScenes = {
scenes: finalScenes,
defaultSceneId,
order: desiredOrder,
};
})();

View File

@@ -0,0 +1,90 @@
(() => {
const { Bodies } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
scenes.push({
id: "balanced",
name: "Balanced",
config: {
gravity: 0.88,
spawnIntervalMs: 520,
minChain: 3,
palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"],
ballRadius: 38,
winCondition: {
type: "score",
target: 10000,
onWin: { shoveBalls: true },
},
link: {
stiffness: 0.85,
lengthScale: 1.05,
damping: 0.08,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 3.2,
},
},
createBodies: (w, h) => {
const floorHeight = Math.max(60, h * 0.12);
const wallThickness = Math.max(32, w * 0.05);
const wallHeight = h * 1.6;
const platformHeight = Math.max(14, h * 0.025);
const smallPlatformWidth = Math.max(140, w * 0.18);
const largePlatformWidth = Math.max(180, w * 0.22);
return [
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w + wallThickness * 2,
floorHeight,
{
isStatic: true,
restitution: 0.8,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
},
),
Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
isStatic: true,
render: { fillStyle: "#f97316", strokeStyle: "#f97316" },
}),
Bodies.rectangle(
w + wallThickness / 2,
h / 2,
wallThickness,
wallHeight,
{
isStatic: true,
render: { fillStyle: "#f97316", strokeStyle: "#f97316" },
},
),
Bodies.rectangle(
w * 0.25,
h * 0.55,
smallPlatformWidth,
platformHeight,
{
isStatic: true,
angle: -0.3,
render: { fillStyle: "#22c55e", strokeStyle: "#22c55e" },
plugin: { rotSpeed: 0.1 },
},
),
Bodies.rectangle(
w * 0.7,
h * 0.4,
largePlatformWidth,
platformHeight * 1.2,
{
isStatic: true,
angle: 0.26,
render: { fillStyle: "#a855f7", strokeStyle: "#a855f7" },
plugin: { rotSpeed: -0.08 },
},
),
];
},
});
})();

View File

@@ -0,0 +1,97 @@
(() => {
const { Bodies } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
scenes.push({
id: "fast-drop-maze",
name: "Fast drop maze",
config: {
gravity: 1.2,
spawnIntervalMs: 220,
minChain: 3,
palette: ["#e879f9", "#38bdf8", "#f97316", "#22c55e"],
ballRadius: 16,
winCondition: {
type: "colorClear",
targets: [{ color: "#e879f9", count: 100 }],
onWin: { setGravity: -0.5, swirlBalls: true },
},
link: {
stiffness: 1,
lengthScale: 0.85,
damping: 0.15,
lineWidth: 3,
rope: false,
renderType: "line",
maxLengthMultiplier: 23.8,
},
},
createBodies: (w, h) => {
const floorHeight = Math.max(60, h * 0.1);
const wallThickness = Math.max(28, w * 0.045);
const wallHeight = h * 1.5;
const pegRadius = Math.max(10, Math.min(22, Math.min(w, h) * 0.03));
const platformHeight = Math.max(12, h * 0.02);
const leftPlatformWidth = Math.max(120, w * 0.16);
const rightPlatformWidth = Math.max(130, w * 0.18);
const bodies = [
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w + wallThickness * 2,
floorHeight,
{
isStatic: true,
restitution: 0.75,
render: { fillStyle: "#14b8a6", strokeStyle: "#14b8a6" },
},
),
Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
isStatic: true,
render: { fillStyle: "#f472b6", strokeStyle: "#f472b6" },
}),
Bodies.rectangle(
w + wallThickness / 2,
h / 2,
wallThickness,
wallHeight,
{
isStatic: true,
render: { fillStyle: "#f472b6", strokeStyle: "#f472b6" },
},
),
];
for (let i = 0; i < 5; i += 1) {
const x = (w * (i + 1)) / 6;
const y = h * 0.35 + (i % 2 === 0 ? h * 0.06 : -h * 0.05);
bodies.push(
Bodies.circle(x, y, pegRadius, {
isStatic: true,
restitution: 0.9,
render: { fillStyle: "#facc15", strokeStyle: "#facc15" },
}),
);
}
bodies.push(
Bodies.rectangle(w * 0.3, h * 0.55, leftPlatformWidth, platformHeight, {
isStatic: true,
angle: -0.3,
render: { fillStyle: "#8b5cf6", strokeStyle: "#8b5cf6" },
}),
Bodies.rectangle(
w * 0.7,
h * 0.58,
rightPlatformWidth,
platformHeight * 1.05,
{
isStatic: true,
angle: 0.28,
render: { fillStyle: "#10b981", strokeStyle: "#10b981" },
},
),
);
return bodies;
},
});
})();

129
src/scenes/scene-grid.js Normal file
View File

@@ -0,0 +1,129 @@
(() => {
const { Bodies } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
scenes.push({
id: "scene-grid",
name: "Zero-G Grid (default)",
config: {
gravity: 0,
spawnIntervalMs: 0,
autoSpawn: false,
minChain: 3,
palette: ["#38bdf8", "#f472b6", "#facc15", "#34d399", "#a78bfa"],
ballRadius: 24,
gridPadding: 0.08, // percent of viewport padding applied to both axes
gridBallScale: 0.38, // percent of cell size used as radius
gridLegend: {
A: "#38bdf8",
B: "#f472b6",
C: "#facc15",
D: "#34d399",
E: "#a78bfa",
},
winCondition: {
type: "clearCount",
target: 25,
nextSceneId: "scene1",
onWin: { setGravity: 0.88 },
},
gridLayouts: [
[
"AABBBBAA",
"ACCCBCCA",
"ACDDDDCA",
"ACEEEECA",
"ACEEEECA",
"ACDDDDCA",
"ACCCBCCA",
"AABBBBAA",
],
[
"AAAABBBA",
"ABBBCCCA",
"ABCDDDCA",
"ABCEEECA",
"ABCEEECA",
"ABCDDDCA",
"ABBBCCCA",
"AAAABBBA",
],
[
"AABBCCDD",
"ABBCCDEE",
"ABCCDEEA",
"ACCDDEEA",
"ACCDDEEA",
"ABCCDEEA",
"ABBCCDEE",
"AABBCCDD",
],
],
link: {
stiffness: 0.82,
lengthScale: 1.05,
damping: 0.06,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 3.8,
},
},
createBodies: (w, h) => {
const pad = 0.08;
const usableW = w * (1 - pad * 2);
const usableH = h * (1 - pad * 2);
const gridSize = Math.min(usableW, usableH);
const gridX = (w - gridSize) / 2;
const gridY = (h - gridSize) / 2;
const wallThickness = Math.max(18, gridSize * 0.045);
const innerW = gridSize;
const innerH = gridSize;
const cx = gridX + innerW / 2;
const cy = gridY + innerH / 2;
return [
Bodies.rectangle(
cx,
gridY - wallThickness / 2,
innerW + wallThickness * 2,
wallThickness,
{
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
},
),
Bodies.rectangle(
cx,
gridY + innerH + wallThickness / 2,
innerW + wallThickness * 2,
wallThickness,
{
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
},
),
Bodies.rectangle(
gridX - wallThickness / 2,
cy,
wallThickness,
innerH + wallThickness * 2,
{
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
},
),
Bodies.rectangle(
gridX + innerW + wallThickness / 2,
cy,
wallThickness,
innerH + wallThickness * 2,
{
isStatic: true,
render: { fillStyle: "#0b1222", strokeStyle: "#0b1222" },
},
),
];
},
});
})();

View File

@@ -0,0 +1,96 @@
(() => {
const { Bodies, Body } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
const makeCurveSegments = (cx, h, amp, thickness, segments) => {
const segs = [];
const stepY = h / segments;
let prevX = cx;
let prevY = 0;
for (let i = 0; i < segments; i += 1) {
const y = stepY * (i + 1);
const t = y / h;
const x = cx + Math.sin(t * Math.PI * 1.5) * amp;
const dx = x - prevX;
const dy = y - prevY;
const len = Math.max(1, Math.sqrt(dx * dx + dy * dy));
const angle = Math.atan2(dy, dx);
segs.push(
Bodies.rectangle((prevX + x) / 2, (prevY + y) / 2, thickness, len, {
isStatic: true,
angle,
render: { fillStyle: "#14213a", strokeStyle: "#14213a" },
plugin: { curve: true },
}),
);
prevX = x;
prevY = y;
}
return segs;
};
scenes.push({
id: "scene-lava",
name: "Lava drift",
config: {
gravity: -0.1,
spawnIntervalMs: 180,
spawnFrom: "bottom",
autoSpawn: true,
minChain: 3,
palette: ["#f472b6", "#38bdf8", "#fbbf24", "#a855f7", "#22c55e"],
ballRadius: 26,
blobBalls: "jagged",
winCondition: {
type: "score",
target: 25000,
onWin: { setGravity: -0.55, removeCurves: true },
},
link: {
stiffness: 0.7,
lengthScale: 1.05,
damping: 0.12,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 5.8,
},
},
createBodies: (w, h) => {
const wallThickness = Math.max(24, w * 0.05);
const amp = Math.max(40, w * 0.08);
const segments = 12;
const curves = [
...makeCurveSegments(w * 0.33, h, amp, wallThickness, segments),
...makeCurveSegments(w * 0.67, h, amp * 0.95, wallThickness, segments),
];
// Gentle paddles that sway slightly
/*
const paddleWidth = Math.max(120, w * 0.18);
const paddleHeight = Math.max(12, h * 0.018);
const paddles = [
Bodies.rectangle(w * 0.45, h * 0.65, paddleWidth, paddleHeight, {
isStatic: true,
angle: -0.08,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
plugin: {
oscillate: { axis: "x", amplitude: w * 0.05, speed: 0.7 },
},
}),
Bodies.rectangle(w * 0.58, h * 0.4, paddleWidth * 0.85, paddleHeight, {
isStatic: true,
angle: 0.12,
render: { fillStyle: "#f59e0b", strokeStyle: "#f59e0b" },
plugin: {
oscillate: { axis: "x", amplitude: w * 0.04, speed: 1.0 },
},
}),
];
*/
return [...curves];
},
});
})();

194
src/scenes/scene-lowg.js Normal file
View File

@@ -0,0 +1,194 @@
(() => {
const { Bodies, Body, Vertices } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
scenes.push({
id: "low-g-terraces",
name: "Low-G terraces",
config: {
gravity: 0.65,
spawnIntervalMs: 600,
minChain: 3,
palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"],
ballRadius: 22,
winCondition: {
type: "score",
target: 15000,
onWin: { shoveBalls: true },
},
link: {
stiffness: 0.6,
lengthScale: 1,
damping: 0.01,
lineWidth: 4,
rope: false,
renderType: "spring",
maxLengthMultiplier: 6,
},
},
createBodies: (w, h) => {
const floorHeight = Math.max(70, h * 0.12);
const wallThickness = Math.max(32, w * 0.05);
const wallHeight = h * 1.8;
const cogRadius = Math.max(40, Math.min(w, h) * 0.085);
const cogRadiusSmall = Math.max(30, Math.min(w, h) * 0.065);
const bumperRadius = Math.max(20, Math.min(w, h) * 0.05);
const stickyWidth = Math.max(90, w * 0.12);
const stickyHeight = Math.max(14, h * 0.02);
const stickyAmplitude = w * 0.06;
const bumperAmplitude = h * 0.08;
const makeGear = (cx, cy, outerRadius, teeth, color, rotSpeed) => {
const coreRadius = outerRadius * 1.5;
const toothLength = outerRadius * 0.5;
const toothWidth = Math.max(outerRadius * 0.14, 10);
const parts = [
Bodies.circle(cx, cy, coreRadius, {
isStatic: true,
render: { fillStyle: color, strokeStyle: color },
}),
];
const step = (Math.PI * 2) / teeth;
for (let i = 0; i < teeth; i += 1) {
const angle = step * i;
const tx = cx + Math.cos(angle) * (coreRadius + toothLength / 2);
const ty = cy + Math.sin(angle) * (coreRadius + toothLength / 2);
parts.push(
Bodies.rectangle(tx, ty, toothWidth, toothLength, {
isStatic: true,
angle,
render: { fillStyle: color, strokeStyle: color },
}),
);
}
const gear = Body.create({
isStatic: true,
parts,
plugin: { rotSpeed },
});
return gear;
};
const makeStar = (x, y, size, color, angle, rotSpeed) => {
const base = Vertices.fromPath(
"50 0 63 38 100 38 69 59 82 100 50 75 18 100 31 59 0 38 37 38",
);
const scale = (size || 50) / 50;
const pts = base.map((p) => ({
x: (p.x - 50) * scale,
y: (p.y - 50) * scale,
}));
return Bodies.fromVertices(
x,
y,
[pts],
{
isStatic: true,
angle,
render: { fillStyle: color, strokeStyle: color },
plugin: { rotSpeed },
},
true,
);
};
return [
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w + wallThickness * 2,
floorHeight,
{
isStatic: true,
restitution: 0.9,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
},
),
Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
isStatic: true,
render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
}),
Bodies.rectangle(
w + wallThickness / 2,
h / 2,
wallThickness,
wallHeight,
{
isStatic: true,
render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
},
),
// Rotating cogs with teeth
makeGear(w * 0.32, h * 0.38, cogRadius, 10, "#f97316", 1.2),
makeGear(w * 0.64, h * 0.5, cogRadiusSmall, 12, "#a855f7", -1.6),
makeGear(w * 0.5, h * 0.32, cogRadius * 0.85, 14, "#fb7185", 1.9),
// Oscillating bumpers
Bodies.circle(w * 0.2, h * 0.46, bumperRadius, {
isStatic: true,
restitution: 1.08,
friction: 0.01,
render: { fillStyle: "#14b8a6", strokeStyle: "#14b8a6" },
plugin: {
oscillate: { axis: "y", amplitude: bumperAmplitude, speed: 1.4 },
},
}),
Bodies.circle(w * 0.5, h * 0.62, bumperRadius * 0.9, {
isStatic: true,
restitution: 1.08,
friction: 0.01,
render: { fillStyle: "#fbbf24", strokeStyle: "#fbbf24" },
plugin: {
oscillate: { axis: "x", amplitude: stickyAmplitude, speed: 1.1 },
},
}),
Bodies.circle(w * 0.8, h * 0.38, bumperRadius * 1.05, {
isStatic: true,
restitution: 1.08,
friction: 0.01,
render: { fillStyle: "#38bdf8", strokeStyle: "#38bdf8" },
plugin: {
oscillate: {
axis: "y",
amplitude: bumperAmplitude * 0.7,
speed: 1.8,
},
},
}),
// Sticky moving pads
Bodies.rectangle(w * 0.32, h * 0.72, stickyWidth, stickyHeight, {
isStatic: true,
angle: -0.08,
friction: 1.3,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
plugin: {
oscillate: { axis: "x", amplitude: stickyAmplitude, speed: 0.9 },
},
}),
Bodies.rectangle(w * 0.72, h * 0.7, stickyWidth * 0.9, stickyHeight, {
isStatic: true,
angle: 0.06,
friction: 1.3,
render: { fillStyle: "#f472b6", strokeStyle: "#f472b6" },
plugin: {
oscillate: {
axis: "x",
amplitude: stickyAmplitude * 0.8,
speed: 1.3,
},
},
}),
// Star obstacles
makeStar(w * 0.18, h * 0.28, bumperRadius * 1.1, "#22c55e", 0.2, -0.6),
makeStar(
w * 0.86,
h * 0.62,
bumperRadius * 1.15,
"#c084fc",
-0.25,
0.9,
),
];
},
});
})();

128
src/scenes/scene-relax.js Normal file
View File

@@ -0,0 +1,128 @@
(() => {
const { Bodies, Composites } = Matter;
const scenes = (window.PhysilinksSceneDefs =
window.PhysilinksSceneDefs || []);
scenes.push({
id: "relax",
name: "Relax drift",
config: {
gravity: 0.08,
spawnIntervalMs: 850,
spawnBatchMin: 3,
spawnBatchMax: 5,
spawnFrom: "bottom",
autoSpawn: true,
minChain: 2,
palette: ["#38bdf8", "#f472b6", "#fbbf24", "#22c55e", "#a855f7"],
ballRadius: 20,
blobBalls: false,
noGameOver: true,
relaxMode: true,
winCondition: {
type: "timer",
durationSec: 120,
onWin: { setGravity: -0.4, swirlBalls: true },
},
link: {
stiffness: 0.6,
lengthScale: 1.1,
damping: 0.1,
lineWidth: 3,
rope: true,
renderType: "line",
maxLengthMultiplier: 3.8,
},
},
createBodies: (w, h) => {
const wallThickness = Math.max(30, w * 0.04);
const wallHeight = h + wallThickness * 2;
const floorHeight = Math.max(40, h * 0.08);
const bumperRadius = Math.max(30, Math.min(w, h) * 0.04);
const makeSoft = (cx, cy, cols, rows, radius, color) => {
const particleOpts = {
friction: 0.02,
frictionStatic: 0.04,
restitution: 0.02,
render: { fillStyle: color, strokeStyle: color },
plugin: { draggable: true, nonLinkable: true },
};
const constraintOpts = {
stiffness: 0.08,
damping: 0.35,
render: { visible: false, type: "line", anchors: false },
};
const comp = Composites.softBody(
cx - cols * radius * 1.1,
cy - rows * radius * 1.1,
cols,
rows,
0,
0,
true,
radius,
particleOpts,
constraintOpts,
);
comp.bodies.forEach((b) => {
b.plugin = b.plugin || {};
b.plugin.draggable = true;
b.plugin.nonLinkable = true;
});
comp.constraints.forEach((c) => {
c.plugin = { soft: true };
});
return comp;
};
const softRadius = Math.max(18, w * 0.025);
const softA = makeSoft(w * 0.32, h * 0.38, 3, 3, softRadius, "#38bdf8");
const softB = makeSoft(w * 0.65, h * 0.55, 3, 3, softRadius, "#f472b6");
const softC = makeSoft(w * 0.5, h * 0.32, 3, 3, softRadius, "#fbbf24");
return [
Bodies.rectangle(
w / 2,
h + floorHeight / 2,
w + wallThickness * 2,
floorHeight,
{
isStatic: true,
restitution: 0.8,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
},
),
Bodies.rectangle(
w / 2,
-wallThickness / 2,
w + wallThickness * 2,
wallThickness,
{
isStatic: true,
render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" },
},
),
Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
isStatic: true,
render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
}),
Bodies.rectangle(
w + wallThickness / 2,
h / 2,
wallThickness,
wallHeight,
{
isStatic: true,
render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" },
},
),
...softA.bodies,
...softA.constraints,
...softB.bodies,
...softB.constraints,
...softC.bodies,
...softC.constraints,
];
},
});
})();

216
src/ui.js Normal file
View File

@@ -0,0 +1,216 @@
(() => {
const create = () => {
const sceneEl = document.getElementById("scene-wrapper");
const activeColorEl = document.getElementById("active-color");
const chainLenEl = document.getElementById("chain-length");
const spawnRateEl = document.getElementById("spawn-rate");
const minLinkEl = document.getElementById("min-link");
const paletteLegendEl = document.getElementById("palette-legend");
const scoreEl = document.getElementById("score");
const highScoreEl = document.getElementById("high-score");
const sceneSelectEl = document.getElementById("scene-select");
const gameOverEl = document.getElementById("game-over");
const finalScoreEl = document.getElementById("final-score");
const restartBtn = document.getElementById("restart-btn");
const pauseBtn = document.getElementById("pause-btn");
const pauseOverlay = document.getElementById("pause-overlay");
const goalLabelEl = document.getElementById("goal-label");
const goalProgressEl = document.getElementById("goal-progress");
const winEl = document.getElementById("win-overlay");
const winMessageEl = document.getElementById("win-message");
const winNextBtn = document.getElementById("win-next");
const winRestartBtn = document.getElementById("win-restart");
const handlers = {
onPauseToggle: null,
onRestart: null,
onSceneChange: null,
onWinNext: null,
};
if (pauseBtn) {
pauseBtn.addEventListener("click", () => {
if (handlers.onPauseToggle) handlers.onPauseToggle();
});
}
if (restartBtn) {
restartBtn.addEventListener("click", () => {
if (handlers.onRestart) handlers.onRestart();
});
}
if (sceneSelectEl) {
sceneSelectEl.addEventListener("change", (e) => {
if (handlers.onSceneChange) handlers.onSceneChange(e.target.value);
});
}
if (winNextBtn) {
winNextBtn.addEventListener("click", () => {
if (handlers.onWinNext) handlers.onWinNext();
});
}
if (winRestartBtn) {
winRestartBtn.addEventListener("click", () => {
if (handlers.onRestart) handlers.onRestart();
});
}
window.addEventListener("keydown", (e) => {
if (e.key === "Escape" && handlers.onPauseToggle) {
handlers.onPauseToggle();
}
});
const setHandlers = (nextHandlers = {}) => {
Object.assign(handlers, nextHandlers);
};
const setSceneOptions = (scenes, activeId) => {
if (!sceneSelectEl) return;
sceneSelectEl.innerHTML = "";
scenes.forEach((scene) => {
const opt = document.createElement("option");
opt.value = scene.id;
opt.textContent = scene.name;
sceneSelectEl.appendChild(opt);
});
if (activeId) {
sceneSelectEl.value = activeId;
}
};
const setSceneSelection = (sceneId) => {
if (sceneSelectEl) {
sceneSelectEl.value = sceneId;
}
};
const setPauseState = (paused) => {
if (pauseOverlay) {
pauseOverlay.classList.toggle("visible", paused);
}
if (pauseBtn) {
pauseBtn.textContent = paused ? "Resume" : "Pause";
}
};
const setGoal = ({ label, progress, colors }) => {
if (goalLabelEl) {
goalLabelEl.innerHTML = "";
if (Array.isArray(colors) && colors.length > 0) {
colors.forEach((color) => {
const swatch = document.createElement("span");
swatch.style.background = color;
swatch.style.display = "inline-block";
swatch.style.width = "14px";
swatch.style.height = "14px";
swatch.style.borderRadius = "50%";
swatch.style.border = "1px solid rgba(255,255,255,0.2)";
swatch.style.marginRight = "6px";
goalLabelEl.appendChild(swatch);
});
const text = document.createElement("span");
text.textContent = label || "";
goalLabelEl.appendChild(text);
} else {
goalLabelEl.textContent = label || "—";
}
}
if (goalProgressEl)
goalProgressEl.style.width = `${Math.max(
0,
Math.min(100, progress ?? 0),
)}%`;
};
const showWin = (message) => {
if (winMessageEl) winMessageEl.textContent = message || "You win!";
if (winEl) winEl.classList.add("visible");
};
const hideWin = () => {
if (winEl) winEl.classList.remove("visible");
};
const showGameOver = (score) => {
if (finalScoreEl) finalScoreEl.textContent = score;
if (gameOverEl) gameOverEl.classList.add("visible");
};
const hideGameOver = () => {
if (gameOverEl) gameOverEl.classList.remove("visible");
};
const spawnScorePopup = (point, amount, color) => {
if (!point || !sceneEl) return;
const el = document.createElement("div");
el.className = "floating-score";
el.textContent = `+${amount}`;
el.style.left = `${point.x}px`;
el.style.top = `${point.y}px`;
el.style.color = color || "#e0f2fe";
sceneEl.appendChild(el);
setTimeout(() => el.remove(), 950);
};
const updateHud = ({
spawnIntervalMs,
minChain,
chainLength,
score,
highScore,
activeColor,
}) => {
if (spawnRateEl) spawnRateEl.textContent = `${spawnIntervalMs} ms`;
if (minLinkEl) minLinkEl.textContent = minChain;
if (chainLenEl) chainLenEl.textContent = chainLength;
if (scoreEl) scoreEl.textContent = score;
if (highScoreEl) highScoreEl.textContent = highScore;
if (activeColorEl) {
if (activeColor) {
activeColorEl.textContent = "";
activeColorEl.style.display = "inline-block";
activeColorEl.style.width = "14px";
activeColorEl.style.height = "14px";
activeColorEl.style.borderRadius = "50%";
activeColorEl.style.background = activeColor;
activeColorEl.style.border = "1px solid rgba(255,255,255,0.3)";
} else {
activeColorEl.removeAttribute("style");
activeColorEl.textContent = "—";
}
}
};
const buildLegend = (palette) => {
if (!paletteLegendEl) return;
paletteLegendEl.innerHTML = "";
palette.forEach((color) => {
const swatch = document.createElement("span");
swatch.style.background = color;
paletteLegendEl.appendChild(swatch);
});
};
return {
sceneEl,
updateHud,
buildLegend,
spawnScorePopup,
setPauseState,
showGameOver,
hideGameOver,
showWin,
hideWin,
setSceneOptions,
setSceneSelection,
setHandlers,
setGoal,
};
};
window.PhysilinksUI = { create };
})();