Last active
January 16, 2026 04:37
-
-
Save tiebingzhang/a1e993381278632d068dce4f6f2c438c 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>Tusi Motion Simulator</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| height: 100vh; | |
| background-color: #1a1a1a; | |
| color: white; | |
| font-family: sans-serif; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| background: #222; | |
| border-radius: 50%; | |
| box-shadow: 0 0 50px rgba(0,0,0,0.5); | |
| cursor: crosshair; | |
| } | |
| .controls { | |
| margin-top: 20px; | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 15px; | |
| background: #333; | |
| padding: 20px; | |
| border-radius: 10px; | |
| } | |
| .hint { | |
| margin-bottom: 10px; | |
| color: #00ffcc; | |
| font-weight: bold; | |
| text-align: center; | |
| } | |
| button { | |
| padding: 10px; | |
| cursor: pointer; | |
| background: #444; | |
| color: white; | |
| border: 1px solid #666; | |
| border-radius: 5px; | |
| } | |
| button:hover { background: #555; } | |
| label { font-size: 0.9rem; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="hint" id="hint">Tip: Click when the green circle is full!</div> | |
| <canvas id="canvas"></canvas> | |
| <div class="controls"> | |
| <div> | |
| <label>Speed: </label> | |
| <input type="range" id="speedRange" min="0.01" max="0.1" step="0.01" value="0.04"> | |
| </div> | |
| <div> | |
| <label>Tracks: </label> | |
| <input type="checkbox" id="showTracks" checked> | |
| </div> | |
| <div> | |
| <label>Show Lines: </label> | |
| <input type="checkbox" id="showLines"> | |
| </div> | |
| <div> | |
| <label>Max Balls: </label> | |
| <input type="number" id="maxBalls" min="1" max="32" value="16"> | |
| </div> | |
| <button onclick="resetBalls()">Reset All</button> | |
| <button onclick="autoPlace()">Auto Place Circle</button> | |
| </div> | |
| <script> | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const speedInput = document.getElementById('speedRange'); | |
| const showTracksBtn = document.getElementById('showTracks'); | |
| const showLinesBtn = document.getElementById('showLines'); | |
| const maxBallsInput = document.getElementById('maxBalls'); | |
| let width, height, radius; | |
| let balls = []; | |
| let time = 0; | |
| let autoPlacing = false; | |
| let autoPlaceIndex = 0; | |
| let autoPlaceTimer = 0; | |
| function getNumTracks() { | |
| return parseInt(maxBallsInput.value); | |
| } | |
| function resize() { | |
| const size = Math.min(window.innerWidth * 0.8, window.innerHeight * 0.6); | |
| canvas.width = size; | |
| canvas.height = size; | |
| width = canvas.width; | |
| height = canvas.height; | |
| radius = (width / 2) * 0.8; | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| // Handle dropping a ball | |
| canvas.addEventListener('mousedown', () => { | |
| if (balls.length < getNumTracks()) { | |
| balls.push({ | |
| trackIndex: balls.length, | |
| phaseOffset: time | |
| }); | |
| } | |
| }); | |
| function resetBalls() { | |
| balls = []; | |
| autoPlacing = false; | |
| } | |
| function autoPlace() { | |
| resetBalls(); | |
| autoPlacing = true; | |
| autoPlaceIndex = 0; | |
| autoPlaceTimer = 0; | |
| } | |
| function draw() { | |
| ctx.clearRect(0, 0, width, height); | |
| const centerX = width / 2; | |
| const centerY = height / 2; | |
| // 1. Draw the Plate/Tracks | |
| if (showTracksBtn.checked) { | |
| ctx.strokeStyle = '#333'; | |
| ctx.lineWidth = 8; | |
| for (let i = 0; i < getNumTracks(); i++) { | |
| const angle = (i * Math.PI) / getNumTracks(); | |
| ctx.beginPath(); | |
| ctx.moveTo(centerX + Math.cos(angle) * radius, centerY + Math.sin(angle) * radius); | |
| ctx.lineTo(centerX - Math.cos(angle) * radius, centerY - Math.sin(angle) * radius); | |
| ctx.stroke(); | |
| } | |
| } | |
| // 2. Draw the Timing Indicator | |
| // Show where the next ball should be placed with a filling circle | |
| if (balls.length < getNumTracks()) { | |
| const nextTrackAngle = (balls.length * Math.PI) / getNumTracks(); | |
| const indicatorX = centerX + Math.cos(nextTrackAngle) * radius * 0.9; | |
| const indicatorY = centerY + Math.sin(nextTrackAngle) * radius * 0.9; | |
| // Background circle | |
| ctx.beginPath(); | |
| ctx.arc(indicatorX, indicatorY, 12, 0, Math.PI * 2); | |
| ctx.strokeStyle = '#00ffcc'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Filling circle based on ball timing - sync with the oscillation | |
| const fillAmount = (Math.cos(time) + 1) / 2; // 0 to 1, matches ball motion | |
| ctx.beginPath(); | |
| ctx.arc(indicatorX, indicatorY, 12, -Math.PI/2, -Math.PI/2 + fillAmount * Math.PI * 2); | |
| ctx.fillStyle = '#00ffcc'; | |
| ctx.fill(); | |
| } | |
| // Auto-place balls | |
| if (autoPlacing && autoPlaceIndex < getNumTracks()) { | |
| autoPlaceTimer += parseFloat(speedInput.value); | |
| if (autoPlaceTimer >= Math.PI / getNumTracks()) { | |
| balls.push({ | |
| trackIndex: autoPlaceIndex, | |
| phaseOffset: time | |
| }); | |
| autoPlaceIndex++; | |
| autoPlaceTimer = 0; | |
| if (autoPlaceIndex >= getNumTracks()) { | |
| autoPlacing = false; | |
| } | |
| } | |
| } | |
| // 3. Draw the Balls | |
| balls.forEach((ball, index) => { | |
| const angle = (ball.trackIndex * Math.PI) / getNumTracks(); | |
| // Linear motion formula: x = r * cos(theta) | |
| // We use the time when it was dropped to determine its position | |
| const displacement = Math.cos(time - ball.phaseOffset) * radius; | |
| const x = centerX + Math.cos(angle) * displacement; | |
| const y = centerY + Math.sin(angle) * displacement; | |
| // Optional Lines between balls to show the "circle" | |
| if (showLinesBtn.checked && balls.length > 1) { | |
| const nextIndex = (index + 1) % balls.length; | |
| const nextAngle = (balls[nextIndex].trackIndex * Math.PI) / getNumTracks(); | |
| const nextDisp = Math.cos(time - balls[nextIndex].phaseOffset) * radius; | |
| const nx = centerX + Math.cos(nextAngle) * nextDisp; | |
| const ny = centerY + Math.sin(nextAngle) * nextDisp; | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| ctx.lineTo(nx, ny); | |
| ctx.stroke(); | |
| } | |
| // The Ball | |
| ctx.beginPath(); | |
| ctx.arc(x, y, 10, 0, Math.PI * 2); | |
| ctx.fillStyle = '#eee'; | |
| ctx.fill(); | |
| ctx.shadowBlur = 10; | |
| ctx.shadowColor = "white"; | |
| }); | |
| ctx.shadowBlur = 0; // reset shadow | |
| time += parseFloat(speedInput.value); | |
| requestAnimationFrame(draw); | |
| } | |
| draw(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment