diff --git a/index.html b/index.html
index 810dc75..dc7d676 100644
--- a/index.html
+++ b/index.html
@@ -110,6 +110,7 @@
+
diff --git a/src/chain-controller.js b/src/chain-controller.js
index c28b115..afa964a 100644
--- a/src/chain-controller.js
+++ b/src/chain-controller.js
@@ -16,8 +16,17 @@
ui,
}) => {
const setHighlight = (body, on) => {
- body.render.lineWidth = on ? 4 : 2;
- body.render.strokeStyle = on ? "#f8fafc" : "#0b1222";
+ const lineWidth = on ? 4 : 2;
+ const strokeStyle = on ? "#f8fafc" : "#0b1222";
+ const applyHighlight = (target) => {
+ target.render = target.render || {};
+ target.render.lineWidth = lineWidth;
+ target.render.strokeStyle = strokeStyle;
+ };
+ applyHighlight(body);
+ if (Array.isArray(body.parts) && body.parts.length > 1) {
+ body.parts.forEach((part) => applyHighlight(part));
+ }
};
const resetChainVisuals = () => {
diff --git a/src/scenes/index.js b/src/scenes/index.js
index eb5682d..929f4df 100644
--- a/src/scenes/index.js
+++ b/src/scenes/index.js
@@ -8,6 +8,7 @@
"scene-lava",
"swirl-arena",
"relax",
+ "christmas-calm",
"stack-blocks-chaos",
"low-g-terraces",
"fast-drop-maze",
diff --git a/src/scenes/scene-christmas-calm.js b/src/scenes/scene-christmas-calm.js
new file mode 100644
index 0000000..e1566d2
--- /dev/null
+++ b/src/scenes/scene-christmas-calm.js
@@ -0,0 +1,171 @@
+(() => {
+ const { Bodies, Body, Vertices } = Matter;
+ const scenes = (window.PhysilinksSceneDefs =
+ window.PhysilinksSceneDefs || []);
+
+ scenes.push({
+ id: "christmas-calm",
+ name: "Christmas calm",
+ config: {
+ gravity: 0.5,
+ spawnIntervalMs: 720,
+ spawnBatchMin: 1,
+ spawnBatchMax: 2,
+ minChain: 3,
+ palette: ["#b91c1c", "#15803d", "#f59e0b", "#7dd3fc", "#fda4af"],
+ ballRadius: 20,
+ ballShape: "gift",
+ giftRibbonColor: "#f8fafc",
+ debugSpawn: true,
+ initialSpawnCount: 10,
+ initialSpawnArea: ({ width, height }) => ({
+ xMin: width * 0.28,
+ xMax: width * 0.72,
+ yMin: height * 0.76,
+ yMax: height * 0.88,
+ }),
+ winCondition: {
+ type: "score",
+ target: 12000,
+ onWin: { shoveBalls: true },
+ },
+ messages: {
+ text: "Cozy winter glow",
+ position: { xPercent: 50, yPercent: 12 },
+ },
+ link: {
+ stiffness: 0.7,
+ lengthScale: 1.05,
+ damping: 0.05,
+ lineWidth: 4,
+ rope: true,
+ renderType: "line",
+ maxLengthMultiplier: 5.4,
+ },
+ backdrop: {
+ colors: ["#0f172a", "#14532d", "#1f2937"],
+ opacity: 0.35,
+ blur: 26,
+ speedSec: 34,
+ },
+ },
+ createBodies: (w, h) => {
+ const wallThickness = Math.max(32, w * 0.045);
+ const wallHeight = h * 1.7;
+ const floorHeight = Math.max(70, h * 0.12);
+ const treeBaseRadius = Math.min(w, h) * 0.22;
+ const treeMidRadius = Math.min(w, h) * 0.16;
+ const treeTopRadius = Math.min(w, h) * 0.11;
+ const trunkWidth = treeBaseRadius * 0.3;
+ const trunkHeight = treeBaseRadius * 0.35;
+ const treeCenterX = w * 0.5;
+ const treeBaseY = h * 0.62;
+ const treeMidY = h * 0.52;
+ const treeTopY = h * 0.43;
+ const treeColor = "#166534";
+ const trunkColor = "#7c2d12";
+
+ const makeTriangle = (x, y, radius) => {
+ const points = [
+ { x: 0, y: -radius },
+ { x: radius, y: radius },
+ { x: -radius, y: radius },
+ ];
+ return Bodies.fromVertices(
+ x,
+ y,
+ [points],
+ {
+ isStatic: true,
+ render: { fillStyle: treeColor, strokeStyle: treeColor },
+ },
+ true,
+ );
+ };
+
+ const makeComet = (x, y, size) => {
+ const baseStar = 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 points = baseStar.map((p) => ({
+ x: (p.x - 50) * scale,
+ y: (p.y - 50) * scale,
+ }));
+ const star = Bodies.fromVertices(
+ x,
+ y,
+ [points],
+ {
+ isStatic: true,
+ render: { fillStyle: "#facc15", strokeStyle: "#facc15" },
+ },
+ true,
+ );
+ const tail = Bodies.rectangle(
+ x + size * 1.1,
+ y + size * 0.2,
+ size * 2.1,
+ size * 0.45,
+ {
+ isStatic: true,
+ angle: 0.35,
+ render: { fillStyle: "#fde68a", strokeStyle: "#fde68a" },
+ },
+ );
+ const starParts =
+ Array.isArray(star.parts) && star.parts.length > 1
+ ? star.parts
+ : [star];
+ return Body.create({
+ isStatic: true,
+ parts: [...starParts, tail],
+ render: { fillStyle: "#facc15", strokeStyle: "#facc15" },
+ plugin: { rotSpeed: 0.35 },
+ });
+ };
+
+ return [
+ Bodies.rectangle(
+ w / 2,
+ h + floorHeight / 2,
+ w + wallThickness * 2,
+ floorHeight,
+ {
+ isStatic: true,
+ restitution: 0.7,
+ render: { fillStyle: "#e2e8f0", strokeStyle: "#e2e8f0" },
+ },
+ ),
+ Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, {
+ isStatic: true,
+ render: { fillStyle: "#0f766e", strokeStyle: "#0f766e" },
+ }),
+ Bodies.rectangle(
+ w + wallThickness / 2,
+ h / 2,
+ wallThickness,
+ wallHeight,
+ {
+ isStatic: true,
+ render: { fillStyle: "#0f766e", strokeStyle: "#0f766e" },
+ },
+ ),
+ makeTriangle(treeCenterX, treeBaseY, treeBaseRadius),
+ makeTriangle(treeCenterX, treeMidY, treeMidRadius),
+ makeTriangle(treeCenterX, treeTopY, treeTopRadius),
+ Bodies.rectangle(
+ treeCenterX,
+ treeBaseY + treeBaseRadius * 0.65,
+ trunkWidth,
+ trunkHeight,
+ {
+ isStatic: true,
+ render: { fillStyle: trunkColor, strokeStyle: trunkColor },
+ },
+ ),
+ makeComet(w * 0.2, h * 0.25, Math.max(18, w * 0.025)),
+ ];
+ },
+ });
+})();
diff --git a/src/spawn.js b/src/spawn.js
index f5f0ea0..6d823d3 100644
--- a/src/spawn.js
+++ b/src/spawn.js
@@ -120,6 +120,7 @@
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,
@@ -186,6 +187,71 @@
};
return { bodies: [body], constraints: [], blobId: null };
}
+ if (scene?.config?.ballShape === "gift") {
+ 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 };
+ }
if (scene?.config?.ballShape === "rect") {
const side = config.ballRadius * 2;
const body = Bodies.rectangle(x, y, side, side, {
@@ -214,6 +280,12 @@
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) {
@@ -393,6 +465,50 @@
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();
}