Skip to content

Instantly share code, notes, and snippets.

@tiebingzhang
Last active January 16, 2026 04:37
Show Gist options
  • Select an option

  • Save tiebingzhang/a1e993381278632d068dce4f6f2c438c to your computer and use it in GitHub Desktop.

Select an option

Save tiebingzhang/a1e993381278632d068dce4f6f2c438c to your computer and use it in GitHub Desktop.
<!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