Created
April 21, 2025 08:20
-
-
Save shricodev/8d18bb9c48b0433fbac0abfb3265b009 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Interactive Randomized Galton Board</title> | |
| <style> | |
| body { | |
| display: flex; | |
| flex-direction: column; /* Stack controls and canvas vertically */ | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| margin: 0; | |
| background-color: #f0f0f0; /* Light background for the page */ | |
| font-family: sans-serif; | |
| overflow: hidden; /* Prevent scrollbars */ | |
| } | |
| canvas { | |
| border: 1px solid black; | |
| /* Background color is set in Matter.js render options */ | |
| margin-top: 10px; /* Add some space below controls */ | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 10px; /* Space below controls */ | |
| background-color: #ddd; | |
| padding: 8px 15px; | |
| border-radius: 5px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| .controls label { | |
| margin-right: 10px; | |
| font-weight: bold; | |
| } | |
| .controls input[type="range"] { | |
| width: 150px; | |
| margin-right: 10px; | |
| cursor: pointer; | |
| } | |
| .controls span { | |
| min-width: 30px; /* Ensure space for value */ | |
| text-align: right; | |
| font-family: monospace; | |
| font-weight: bold; | |
| background-color: #eee; | |
| padding: 2px 5px; | |
| border-radius: 3px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- UI Controls --> | |
| <div class="controls"> | |
| <label for="gravitySlider">Gravity:</label> | |
| <input | |
| type="range" | |
| id="gravitySlider" | |
| min="0" | |
| max="2" | |
| step="0.1" | |
| value="1" | |
| /> | |
| <span id="gravityValue">1.0</span> | |
| </div> | |
| <!-- Simulation Canvas --> | |
| <canvas id="simulationCanvas"></canvas> | |
| <!-- Get Matter.js via CDN --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script> | |
| <script> | |
| document.addEventListener("DOMContentLoaded", () => { | |
| // --- Configuration --- | |
| const canvasWidth = 600; | |
| const canvasHeight = 800; | |
| // Ball properties - ranges for randomization | |
| const minBallRadius = 3; | |
| const maxBallRadius = 7; | |
| const minRestitution = 0.3; | |
| const maxRestitution = 0.7; | |
| const minDensity = 0.003; | |
| const maxDensity = 0.009; | |
| const pegRadius = 5; | |
| const pegRows = 16; | |
| const pegSpacingX = 40; | |
| const pegSpacingY = 35; | |
| const binCount = 13; | |
| const binHeight = 120; | |
| const wallThickness = 20; | |
| const dividerThickness = 8; | |
| const ballDropInterval = 40; // *DECREASED* Drop balls faster (milliseconds) | |
| const ballDropXVariance = 15; | |
| const spinnerSpeed = 0.03; | |
| const windTunnelForceMagnitude = 0.0004; | |
| const fanForceMagnitude = 0.004; // Side fan/drag | |
| // --- Colors --- | |
| const colorWall = "#CCCC00"; // Darker Yellow | |
| const colorPeg = "#FF0000"; // Red | |
| const colorSpinner1 = "#FFA500"; // Orange | |
| const colorSpinner2 = "#008080"; // Teal | |
| const colorBall = "#404040"; // Dark Gray | |
| const colorBackground = "#FFFFFF"; // White background | |
| // --- Matter.js Modules --- | |
| const { | |
| Engine, | |
| Render, | |
| Runner, | |
| Bodies, | |
| Composite, | |
| Events, | |
| Body, | |
| Vector, | |
| } = Matter; | |
| // --- Engine Setup --- | |
| const engine = Engine.create(); | |
| const world = engine.world; | |
| // Initial gravity set from slider default | |
| engine.world.gravity.y = parseFloat( | |
| document.getElementById("gravitySlider").value, | |
| ); // Read initial value | |
| engine.world.gravity.x = fanForceMagnitude; | |
| // --- Renderer Setup --- | |
| const canvas = document.getElementById("simulationCanvas"); | |
| canvas.width = canvasWidth; | |
| canvas.height = canvasHeight; | |
| const render = Render.create({ | |
| canvas: canvas, | |
| engine: engine, | |
| options: { | |
| width: canvasWidth, | |
| height: canvasHeight, | |
| wireframes: false, | |
| background: colorBackground, | |
| }, | |
| }); | |
| // --- UI Controls Setup --- | |
| const gravitySlider = document.getElementById("gravitySlider"); | |
| const gravityValueSpan = document.getElementById("gravityValue"); | |
| // Set initial display value | |
| gravityValueSpan.textContent = parseFloat(gravitySlider.value).toFixed( | |
| 1, | |
| ); | |
| // Add event listener for slider changes | |
| gravitySlider.addEventListener("input", (event) => { | |
| const newGravity = parseFloat(event.target.value); | |
| engine.world.gravity.y = newGravity; // Update engine gravity | |
| gravityValueSpan.textContent = newGravity.toFixed(1); // Update displayed value | |
| }); | |
| // --- World Boundaries --- | |
| const groundY = canvasHeight - wallThickness / 2; | |
| const ground = Bodies.rectangle( | |
| canvasWidth / 2, | |
| groundY, | |
| canvasWidth + wallThickness, | |
| wallThickness, | |
| { | |
| isStatic: true, | |
| label: "ground", | |
| render: { fillStyle: colorWall }, | |
| }, | |
| ); | |
| const leftWall = Bodies.rectangle( | |
| wallThickness / 2, | |
| canvasHeight / 2, | |
| wallThickness, | |
| canvasHeight, | |
| { | |
| isStatic: true, | |
| label: "wall", | |
| render: { fillStyle: colorWall }, | |
| }, | |
| ); | |
| const rightWall = Bodies.rectangle( | |
| canvasWidth - wallThickness / 2, | |
| canvasHeight / 2, | |
| wallThickness, | |
| canvasHeight, | |
| { | |
| isStatic: true, | |
| label: "wall", | |
| render: { fillStyle: colorWall }, | |
| }, | |
| ); | |
| Composite.add(world, [ground, leftWall, rightWall]); | |
| // --- Pegs Setup --- | |
| const pegs = []; | |
| const startY = 100; | |
| const pegsTotalWidth = canvasWidth - 2 * wallThickness; | |
| for (let row = 0; row < pegRows; row++) { | |
| const y = startY + row * pegSpacingY; | |
| const numPegsInRow = Math.floor(pegsTotalWidth / pegSpacingX); | |
| const rowStartX = | |
| (canvasWidth - (numPegsInRow - 1) * pegSpacingX) / 2; | |
| const offsetX = row % 2 === 0 ? 0 : pegSpacingX / 2; | |
| for (let i = 0; i < numPegsInRow; i++) { | |
| const x = rowStartX + i * pegSpacingX + offsetX; | |
| if ( | |
| x > wallThickness + pegRadius && | |
| x < canvasWidth - wallThickness - pegRadius | |
| ) { | |
| const peg = Bodies.circle(x, y, pegRadius, { | |
| isStatic: true, | |
| label: "peg", | |
| restitution: 0.5, | |
| friction: 0.1, | |
| render: { fillStyle: colorPeg }, | |
| }); | |
| pegs.push(peg); | |
| } | |
| } | |
| } | |
| Composite.add(world, pegs); | |
| // --- Dynamic Obstacles: Spinners --- | |
| const spinners = []; | |
| const spinnerPositions = [ | |
| { | |
| x: canvasWidth * 0.3, | |
| y: startY + pegSpacingY * 4.5, | |
| color: colorSpinner1, | |
| }, | |
| { | |
| x: canvasWidth * 0.7, | |
| y: startY + pegSpacingY * 8.5, | |
| color: colorSpinner2, | |
| }, | |
| { | |
| x: canvasWidth * 0.5, | |
| y: startY + pegSpacingY * 12.5, | |
| color: colorSpinner1, | |
| }, | |
| ]; | |
| spinnerPositions.forEach((pos) => { | |
| const spinner = Bodies.rectangle(pos.x, pos.y, 90, 12, { | |
| isStatic: true, | |
| label: "spinner", | |
| angle: Math.random() * Math.PI * 2, | |
| render: { fillStyle: pos.color }, | |
| }); | |
| spinners.push(spinner); | |
| }); | |
| Composite.add(world, spinners); | |
| Events.on(engine, "beforeUpdate", (event) => { | |
| spinners.forEach((spinner) => { | |
| Body.rotate(spinner, spinnerSpeed); | |
| }); | |
| }); | |
| // --- Dynamic Obstacles: Wind Tunnel Zones --- | |
| const windZones = [ | |
| { | |
| x: 0, | |
| y: startY + pegSpacingY * 6, | |
| width: canvasWidth * 0.4, | |
| height: pegSpacingY * 4, | |
| force: Vector.create(windTunnelForceMagnitude, 0), | |
| label: "wind-right", | |
| }, | |
| { | |
| x: canvasWidth * 0.6, | |
| y: startY + pegSpacingY * 11, | |
| width: canvasWidth * 0.4, | |
| height: pegSpacingY * 4, | |
| force: Vector.create(-windTunnelForceMagnitude, 0), | |
| label: "wind-left", | |
| }, | |
| ]; | |
| Events.on(engine, "beforeUpdate", (event) => { | |
| const bodies = Composite.allBodies(world); | |
| bodies.forEach((body) => { | |
| if (!body.isStatic && !body.isSensor && body.label === "ball") { | |
| windZones.forEach((zone) => { | |
| if ( | |
| body.position.x >= zone.x && | |
| body.position.x <= zone.x + zone.width && | |
| body.position.y >= zone.y && | |
| body.position.y <= zone.y + zone.height | |
| ) { | |
| Body.applyForce(body, body.position, zone.force); | |
| } | |
| }); | |
| } | |
| }); | |
| }); | |
| // --- Bins Setup --- | |
| const binDividers = []; | |
| const binBottomY = canvasHeight - wallThickness - binHeight / 2; | |
| const dividerTopY = canvasHeight - wallThickness - binHeight; | |
| const totalDividerWidth = (binCount + 1) * dividerThickness; | |
| const totalBinSpace = | |
| canvasWidth - 2 * wallThickness - totalDividerWidth; | |
| const binWidth = totalBinSpace / binCount; | |
| for (let i = 0; i <= binCount; i++) { | |
| const x = | |
| wallThickness + | |
| i * dividerThickness + | |
| i * binWidth + | |
| dividerThickness / 2; | |
| const divider = Bodies.rectangle( | |
| x, | |
| binBottomY, | |
| dividerThickness, | |
| binHeight, | |
| { | |
| isStatic: true, | |
| label: "divider", | |
| render: { fillStyle: colorWall }, | |
| }, | |
| ); | |
| binDividers.push(divider); | |
| } | |
| Composite.add(world, binDividers); | |
| // --- Ball Drop --- | |
| let ballCreationIntervalId = null; // Store interval ID | |
| const dropBall = () => { | |
| if ( | |
| Composite.allBodies(world).filter((b) => b.label === "ball") | |
| .length > 300 | |
| ) { | |
| // Optional: Limit total balls to prevent extreme lag, adjust limit as needed | |
| // console.log("Ball limit reached, pausing drop momentarily"); | |
| return; | |
| } | |
| const x = canvasWidth / 2 + (Math.random() - 0.5) * ballDropXVariance; | |
| const y = 30; | |
| // Randomized properties | |
| const radius = | |
| minBallRadius + Math.random() * (maxBallRadius - minBallRadius); | |
| const restitution = | |
| minRestitution + Math.random() * (maxRestitution - minRestitution); | |
| const density = | |
| minDensity + Math.random() * (maxDensity - minDensity); | |
| const ball = Bodies.circle(x, y, radius, { | |
| label: "ball", | |
| restitution: restitution, | |
| friction: 0.1, | |
| frictionAir: 0.015, | |
| density: density, | |
| render: { fillStyle: colorBall }, | |
| }); | |
| Composite.add(world, ball); | |
| // Simplified Ball Cleanup (Remove if settled low) | |
| setTimeout(() => { | |
| // Check if the body still exists in the world before accessing properties | |
| if ( | |
| world.bodies.includes(ball) && | |
| ball.position.y > dividerTopY + 20 && | |
| Vector.magnitude(ball.velocity) < 0.1 | |
| ) { | |
| Composite.remove(world, ball); | |
| } | |
| }, 25000); // Check after 25 seconds | |
| }; | |
| // --- Start Dropping Balls --- | |
| const startDropping = () => { | |
| if (ballCreationIntervalId === null) { | |
| // Prevent multiple intervals | |
| ballCreationIntervalId = setInterval(dropBall, ballDropInterval); | |
| } | |
| }; | |
| // Start dropping immediately on load | |
| startDropping(); | |
| // --- Run Simulation --- | |
| const runner = Runner.create(); | |
| Runner.run(runner, engine); | |
| Render.run(render); | |
| // --- Optional: Stop simulation if page visibility changes --- | |
| // document.addEventListener("visibilitychange", () => { | |
| // if (document.hidden) { | |
| // Runner.stop(runner); | |
| // Render.stop(render); | |
| // if(ballCreationIntervalId !== null) { | |
| // clearInterval(ballCreationIntervalId); | |
| // ballCreationIntervalId = null; | |
| // } | |
| // } else { | |
| // // Resume carefully if needed | |
| // // Runner.start(runner, engine); // Use start, not run | |
| // // Render.run(render); // Already running? Check Matter.js docs | |
| // // startDropping(); | |
| // } | |
| // }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment