diff --git a/README.md b/README.md index a99ce23..4cc40f4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Physilinks is a browser-based physics linking game built with Matter.js. Match a ## File structure - `index.html`: Shell layout and HUD overlays; loads Matter.js plus game scripts. - `styles.css`: Styling for canvas, HUD, overlays, and score popups. -- `scenes/`: Scene presets split per file (`scene-*.js`) plus `index.js` that registers them to `window.PhysilinksScenes`. +- `scenes/`: Scene presets split per file (`scene-*.js`) plus `index.js` that registers them to `window.PhysilinksScenes` (e.g., zero-G grid, balanced, low-G, fast drop, lava drift). - `ui.js`: DOM access, HUD updates, overlays, popups, and control/selector wiring. - `main.js`: Physics setup, state machine, chain interaction, spawning, scene application, and pause/restart logic. diff --git a/index.html b/index.html index 5cf76dc..9b89ff3 100644 --- a/index.html +++ b/index.html @@ -96,6 +96,7 @@ + diff --git a/main.js b/main.js index 63bdb18..ad2ae20 100644 --- a/main.js +++ b/main.js @@ -78,6 +78,7 @@ // Static boundaries and scene-specific obstacles. let boundaries = []; let rotators = []; + let oscillators = []; let currentScene = scenes.find((s) => s.id === defaultSceneId) || scenes[0] || null; @@ -90,6 +91,7 @@ boundaries.forEach((b) => World.remove(world, b)); boundaries = currentScene.createBodies(width, height); rotators = boundaries.filter((b) => b.plugin && b.plugin.rotSpeed); + oscillators = boundaries.filter((b) => b.plugin && b.plugin.oscillate); World.add(world, boundaries); }; @@ -176,18 +178,18 @@ config.ballRadius + 10, Math.min(width - config.ballRadius - 10, Math.random() * width), ); - const y = -config.ballRadius * 2; - const ball = Bodies.circle(x, y, config.ballRadius, { - restitution: 0.72, - friction: 0.01, - frictionAir: 0.015, - render: { - fillStyle: color, - strokeStyle: "#0b1222", - lineWidth: 2, - }, - }); - ball.plugin = { color, hasEntered: false, entryCheckId: null }; + const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; + const y = spawnFromBottom + ? height + config.ballRadius * 2 + : -config.ballRadius * 2; + const ball = createBallBody(x, y, color); + ball.plugin = { + color, + hasEntered: false, + entryCheckId: null, + squishX: 1, + squishY: 1, + }; balls.push(ball); World.add(world, ball); ball.plugin.entryCheckId = setTimeout(() => { @@ -352,6 +354,28 @@ if (typeof winCond.onWin.setGravity === "number") { engine.gravity.y = winCond.onWin.setGravity; } + if (winCond.onWin.shoveBalls) { + balls.forEach((ball) => { + const angle = Math.random() * Math.PI * 2; + const magnitude = 12 + Math.random() * 10; + const force = { + x: Math.cos(angle) * magnitude, + y: Math.sin(angle) * magnitude, + }; + Body.applyForce(ball, ball.position, force); + }); + } + if (winCond.onWin.removeCurves) { + const remaining = []; + boundaries.forEach((b) => { + if (b.plugin && b.plugin.curve) { + World.remove(world, b); + } else { + remaining.push(b); + } + }); + boundaries = remaining; + } }; const checkWinCondition = () => { @@ -578,6 +602,34 @@ config.ballRadius = nextRadius; }; + const createBallBody = (x, y, color) => { + const commonOpts = { + restitution: 0.72, + friction: 0.01, + frictionAir: 0.015, + render: { + fillStyle: color, + strokeStyle: "#0b1222", + lineWidth: 2, + }, + }; + if (currentScene?.config?.blobBalls) { + const points = []; + const segments = 12; + for (let i = 0; i < segments; i += 1) { + const angle = (i / segments) * Math.PI * 2; + const variance = 0.75 + Math.random() * 0.35; + const r = config.ballRadius * variance; + points.push({ + x: x + Math.cos(angle) * r, + y: y + Math.sin(angle) * r, + }); + } + return Bodies.fromVertices(x, y, [points], commonOpts); + } + return Bodies.circle(x, y, config.ballRadius, commonOpts); + }; + const getGoalState = () => { const winCond = currentScene?.config?.winCondition; if (!winCond) return null; @@ -669,11 +721,17 @@ if ( ball.position.x < -100 || ball.position.x > width + 100 || - ball.position.y > height + 500 + (currentScene?.config?.spawnFrom === "bottom" + ? ball.position.y < -500 + : ball.position.y > height + 500) ) { cleanupBall(ball); ball.plugin.hasEntered = true; - Matter.Body.setPosition(ball, { x: Math.random() * width, y: -40 }); + const spawnFromBottom = currentScene?.config?.spawnFrom === "bottom"; + Matter.Body.setPosition(ball, { + x: Math.random() * width, + y: spawnFromBottom ? height + 40 : -40, + }); Matter.Body.setVelocity(ball, { x: 0, y: 0 }); } }); @@ -705,6 +763,42 @@ Body.rotate(b, speed * ((dt * timeScale) / 1000)); } }); + oscillators.forEach((b) => { + const osc = b.plugin.oscillate; + if (!osc) return; + if (!osc.base) { + osc.base = { x: b.position.x, y: b.position.y }; + } + const now = (engine.timing.timestamp || 0) / 1000; + const amplitude = osc.amplitude ?? 0; + const speed = osc.speed ?? 1; + const phase = osc.phase ?? 0; + const offset = Math.sin(now * speed + phase) * amplitude; + const target = + osc.axis === "x" + ? { x: osc.base.x + offset, y: osc.base.y } + : { x: osc.base.x, y: osc.base.y + offset }; + Body.setPosition(b, target); + Body.setVelocity(b, { x: 0, y: 0 }); + }); + if (currentScene?.config?.blobBalls) { + balls.forEach((ball) => { + if (!ball.plugin) return; + const speed = Vector.magnitude(ball.velocity || { x: 0, y: 0 }); + const squeeze = Math.min(0.22, speed / 20); + const targetX = 1 + squeeze; + const targetY = Math.max(0.7, 1 - squeeze); + const currX = ball.plugin.squishX || 1; + const currY = ball.plugin.squishY || 1; + const factorX = targetX / currX; + const factorY = targetY / currY; + if (Math.abs(factorX - 1) > 0.02 || Math.abs(factorY - 1) > 0.02) { + Body.scale(ball, factorX, factorY); + ball.plugin.squishX = targetX; + ball.plugin.squishY = targetY; + } + }); + } }); Events.on(render, "afterRender", () => { diff --git a/scenes/scene-balanced.js b/scenes/scene-balanced.js index d479d8d..5d2f79e 100644 --- a/scenes/scene-balanced.js +++ b/scenes/scene-balanced.js @@ -1,7 +1,7 @@ (() => { const { Bodies } = Matter; - const scenes = - (window.PhysilinksSceneDefs = window.PhysilinksSceneDefs || []); + const scenes = (window.PhysilinksSceneDefs = + window.PhysilinksSceneDefs || []); scenes.push({ id: "scene1", @@ -12,6 +12,12 @@ minChain: 3, palette: ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93"], ballRadius: 38, + winCondition: { + type: "score", + target: 10000, + onWin: { shoveBalls: true }, + nextSceneId: "scene2", + }, link: { stiffness: 0.85, lengthScale: 1.05, @@ -41,16 +47,10 @@ render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" }, }, ), - Bodies.rectangle( - -wallThickness / 2, - h / 2, - wallThickness, - wallHeight, - { - isStatic: true, - render: { fillStyle: "#f97316", strokeStyle: "#f97316" }, - }, - ), + Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, { + isStatic: true, + render: { fillStyle: "#f97316", strokeStyle: "#f97316" }, + }), Bodies.rectangle( w + wallThickness / 2, h / 2, diff --git a/scenes/scene-lavalamp.js b/scenes/scene-lavalamp.js new file mode 100644 index 0000000..1db176a --- /dev/null +++ b/scenes/scene-lavalamp.js @@ -0,0 +1,97 @@ +(() => { + 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: true, + winCondition: { + type: "score", + target: 25000, + onWin: { setGravity: -0.55, removeCurves: true }, + nextSceneId: "scene-grid", + }, + link: { + stiffness: 0.7, + lengthScale: 1.05, + damping: 0.12, + lineWidth: 3, + rope: true, + renderType: "line", + maxLengthMultiplier: 4.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]; + }, + }); +})(); diff --git a/scenes/scene-lowg.js b/scenes/scene-lowg.js index f3a1f82..c9d3e27 100644 --- a/scenes/scene-lowg.js +++ b/scenes/scene-lowg.js @@ -1,7 +1,7 @@ (() => { - const { Bodies } = Matter; - const scenes = - (window.PhysilinksSceneDefs = window.PhysilinksSceneDefs || []); + const { Bodies, Body } = Matter; + const scenes = (window.PhysilinksSceneDefs = + window.PhysilinksSceneDefs || []); scenes.push({ id: "scene2", @@ -12,6 +12,12 @@ minChain: 3, palette: ["#fb7185", "#fbbf24", "#34d399", "#38bdf8"], ballRadius: 22, + winCondition: { + type: "score", + target: 50000, + nextSceneId: "scene3", + onWin: { shoveBalls: true }, + }, link: { stiffness: 0.6, lengthScale: 1, @@ -26,10 +32,45 @@ const floorHeight = Math.max(70, h * 0.12); const wallThickness = Math.max(32, w * 0.05); const wallHeight = h * 1.8; - const ledgeHeight = Math.max(14, h * 0.022); - const leftWidth = Math.max(160, w * 0.18); - const midWidth = Math.max(190, w * 0.26); - const rightWidth = Math.max(150, w * 0.18); + 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; + }; + return [ Bodies.rectangle( w / 2, @@ -42,16 +83,10 @@ render: { fillStyle: "#0ea5e9", strokeStyle: "#0ea5e9" }, }, ), - Bodies.rectangle( - -wallThickness / 2, - h / 2, - wallThickness, - wallHeight, - { - isStatic: true, - render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" }, - }, - ), + Bodies.rectangle(-wallThickness / 2, h / 2, wallThickness, wallHeight, { + isStatic: true, + render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" }, + }), Bodies.rectangle( w + wallThickness / 2, h / 2, @@ -62,20 +97,77 @@ render: { fillStyle: "#7c3aed", strokeStyle: "#7c3aed" }, }, ), - Bodies.rectangle(w * 0.2, h * 0.45, leftWidth, ledgeHeight, { + // 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, - angle: 0.08, - render: { fillStyle: "#f97316", strokeStyle: "#f97316" }, - }), - Bodies.rectangle(w * 0.5, h * 0.6, midWidth, ledgeHeight, { - isStatic: true, - angle: -0.04, + restitution: 1.08, + friction: 0.01, render: { fillStyle: "#14b8a6", strokeStyle: "#14b8a6" }, + plugin: { + oscillate: { axis: "y", amplitude: bumperAmplitude, speed: 1.4 }, + }, }), - Bodies.rectangle(w * 0.8, h * 0.42, rightWidth, ledgeHeight, { + Bodies.circle(w * 0.5, h * 0.62, bumperRadius * 0.9, { isStatic: true, - angle: 0.14, + 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, + }, + }, + }), + // Random polygon obstacles + Bodies.polygon(w * 0.18, h * 0.28, 5, bumperRadius * 0.9, { + isStatic: true, + angle: 0.2, + render: { fillStyle: "#22c55e", strokeStyle: "#22c55e" }, + plugin: { rotSpeed: -0.6 }, + }), + Bodies.polygon(w * 0.86, h * 0.62, 7, bumperRadius * 0.95, { + isStatic: true, + angle: -0.25, render: { fillStyle: "#c084fc", strokeStyle: "#c084fc" }, + plugin: { rotSpeed: 0.9 }, }), ]; },