<h1>Interactive Physics Hourglass</h1>
<div class="canvas-container" id="container">
<canvas id="simCanvas"></canvas>
</div>
<div class="overlay">Click to Rotate</div>
<script>
const canvas = document.getElementById('simCanvas');
const ctx = canvas.getContext('2d', { alpha: false });
const container = document.getElementById('container');
// Simulation Resolution (Low res for performance and retro style)
const SIM_WIDTH = 120;
const SIM_HEIGHT = 180;
// Set internal canvas size
canvas.width = SIM_WIDTH;
canvas.height = SIM_HEIGHT;
// Particle Types
const EMPTY = 0;
const WALL = 1;
const SAND = 2;
// State Arrays
// grid: stores the type of particle
// colorGrid: stores a random variant for visual texture
let grid = new Int8Array(SIM_WIDTH * SIM_HEIGHT);
let colorGrid = new Float32Array(SIM_WIDTH * SIM_HEIGHT);
// Physics State
let gravity = 1; // 1 = down, -1 = up
let isRotating = false;
let rotationAngle = 0;
// Image Buffer for rendering
const imageData = ctx.createImageData(SIM_WIDTH, SIM_HEIGHT);
const buf = new Uint32Array(imageData.data.buffer);
// Colors (ABGR format for Little Endian 32-bit write)
// Background: #121212 -> FF121212
const COL_BG = 0xFF121212;
// Wall: #444444 -> FF444444
const COL_WALL = 0xFF505050;
// Sand Base Colors (We will vary these)
// Creating a palette of sand colors for variety
function makeColor(r, g, b) {
return (255 << 24) | (b << 16) | (g << 8) | r;
}
// Initialization
function init() {
// Fill grid
for (let y = 0; y < SIM_HEIGHT; y++) {
for (let x = 0; x < SIM_WIDTH; x++) {
const i = y * SIM_WIDTH + x;
// Define Hourglass Shape
// Normalize coords -1 to 1
let ny = (y - SIM_HEIGHT / 2) / (SIM_HEIGHT / 2);
let nx = (x - SIM_WIDTH / 2) / (SIM_WIDTH / 2);
// Curve function: x = width at y
// Wide at top/bottom, narrow at center
// w = 0.1 + 0.9 * ny^2
let maxW = 0.06 + 0.90 * (ny * ny);
// Add walls
if (Math.abs(nx) > maxW) {
grid[i] = WALL;
} else {
grid[i] = EMPTY;
// Fill bottom half with sand initially
// y > SIM_HEIGHT / 2 + margin
if (y > SIM_HEIGHT * 0.55 && y < SIM_HEIGHT - 5) {
// Random fill density for natural look start
if (Math.random() > 0.1) {
grid[i] = SAND;
colorGrid[i] = Math.random(); // Store random value for texture
}
}
}
// Cap top and bottom
if (y === 0 || y === SIM_HEIGHT - 1) grid[i] = WALL;
}
}
// Explicitly open the neck just in case calculation was too tight
const cy = Math.floor(SIM_HEIGHT / 2);
const cx = Math.floor(SIM_WIDTH / 2);
for(let y = cy - 2; y <= cy + 2; y++) {
for(let x = cx - 2; x <= cx + 2; x++) {
grid[y * SIM_WIDTH + x] = (x === cx - 2 || x === cx + 2) ? WALL : EMPTY;
}
}
}
function update() {
if (isRotating) return; // Pause physics during rotation animation
// We iterate either bottom-up or top-down depending on gravity to prevent
// particles from "teleporting" through the whole grid in one frame.
let startY, endY, stepY;
if (gravity === 1) {
startY = SIM_HEIGHT - 2; // Start from one row above bottom
endY = 0;
stepY = -1;
} else {
startY = 1; // Start from one row below top
endY = SIM_HEIGHT - 1;
stepY = 1;
}
// To prevent bias (always falling left or right first), we can alternate X direction
// or just use Math.random(). Alternating frames is cheaper.
const randDir = Math.random() > 0.5 ? 1 : -1;
for (let y = startY; y !== endY + stepY; y += stepY) {
for (let x = 0; x < SIM_WIDTH; x++) {
// Pick x based on even/odd row to distribute checks or just iterate linear
// Standard linear is fine if we randomize fall direction check
const i = y * SIM_WIDTH + x;
if (grid[i] === SAND) {
const down = i + (SIM_WIDTH * gravity);
// Check directly down
if (grid[down] === EMPTY) {
grid[down] = SAND;
grid[i] = EMPTY;
colorGrid[down] = colorGrid[i];
}
// Pile Up Logic (Granular Physics)
// Check diagonals
else {
const dir = Math.random() > 0.5 ? 1 : -1;
const downA = down + dir;
const downB = down - dir;
// Ensure we don't wrap around the grid (boundary check x)
const xA = x + dir;
const xB = x - dir;
let moved = false;
// Try direction A
if (xA >= 0 && xA < SIM_WIDTH && grid[downA] === EMPTY) {
grid[downA] = SAND;
grid[i] = EMPTY;
colorGrid[downA] = colorGrid[i];
moved = true;
}
// Try direction B
else if (xB >= 0 && xB < SIM_WIDTH && grid[downB] === EMPTY) {
grid[downB] = SAND;
grid[i] = EMPTY;
colorGrid[downB] = colorGrid[i];
moved = true;
}
}
}
}
}
}
function draw() {
// Clear buffer
buf.fill(COL_BG);
for (let i = 0; i < grid.length; i++) {
const type = grid[i];
if (type === WALL) {
buf[i] = COL_WALL;
// Add simple shading to wall
// check if right pixel is empty for highlight
if ((i+1) < grid.length && grid[i+1] === EMPTY) {
buf[i] = 0xFF888888;
}
} else if (type === SAND) {
// Generate sand color based on stored variant
// Base Gold: R=240, G=190, B=90
// Darker Gold: R=180, G=130, B=40
const variant = colorGrid[i];
const r = 200 + (variant * 55);
const g = 150 + (variant * 50);
const b = 50 + (variant * 60);
buf[i] = makeColor(r, g, b);
}
}
ctx.putImageData(imageData, 0, 0);
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
// Input Handling
container.addEventListener('click', () => {
if (isRotating) return;
isRotating = true;
container.classList.add('rotating');
rotationAngle += 180;
// Apply visual rotation
container.style.transform = `rotate(${rotationAngle}deg)`;
// Wait for CSS transition (0.8s) to finish before flipping physics
setTimeout(() => {
gravity *= -1;
isRotating = false;
container.classList.remove('rotating');
// Note: We do NOT flip the grid data array.
// When visual is 180deg, the top of the screen is the bottom of the grid array.
// Physics Gravity -1 means falling towards index 0 (Grid Top).
// Visually, falling towards Grid Top (index 0) when rotated 180deg means falling DOWN.
// So simply flipping the gravity scalar works perfectly.
}, 800);
});
// Start
init();
loop();
</script>
<h1>Interactive Physics Hourglass</h1>
<div class="canvas-container" id="container">
<canvas id="simCanvas"></canvas>
</div>
<div class="overlay">Click to Rotate</div>
<script>
const canvas = document.getElementById('simCanvas');
const ctx = canvas.getContext('2d', { alpha: false });
const container = document.getElementById('container');
// Simulation Resolution (Low res for performance and retro style)
const SIM_WIDTH = 120;
const SIM_HEIGHT = 180;
// Set internal canvas size
canvas.width = SIM_WIDTH;
canvas.height = SIM_HEIGHT;
// Particle Types
const EMPTY = 0;
const WALL = 1;
const SAND = 2;
// State Arrays
// grid: stores the type of particle
// colorGrid: stores a random variant for visual texture
let grid = new Int8Array(SIM_WIDTH * SIM_HEIGHT);
let colorGrid = new Float32Array(SIM_WIDTH * SIM_HEIGHT);
// Physics State
let gravity = 1; // 1 = down, -1 = up
let isRotating = false;
let rotationAngle = 0;
// Image Buffer for rendering
const imageData = ctx.createImageData(SIM_WIDTH, SIM_HEIGHT);
const buf = new Uint32Array(imageData.data.buffer);
// Colors (ABGR format for Little Endian 32-bit write)
// Background: #121212 -> FF121212
const COL_BG = 0xFF121212;
// Wall: #444444 -> FF444444
const COL_WALL = 0xFF505050;
// Sand Base Colors (We will vary these)
// Creating a palette of sand colors for variety
function makeColor(r, g, b) {
return (255 << 24) | (b << 16) | (g << 8) | r;
}
// Initialization
function init() {
// Fill grid
for (let y = 0; y < SIM_HEIGHT; y++) {
for (let x = 0; x < SIM_WIDTH; x++) {
const i = y * SIM_WIDTH + x;
// Define Hourglass Shape
// Normalize coords -1 to 1
let ny = (y - SIM_HEIGHT / 2) / (SIM_HEIGHT / 2);
let nx = (x - SIM_WIDTH / 2) / (SIM_WIDTH / 2);
// Curve function: x = width at y
// Wide at top/bottom, narrow at center
// w = 0.1 + 0.9 * ny^2
let maxW = 0.06 + 0.90 * (ny * ny);
// Add walls
if (Math.abs(nx) > maxW) {
grid[i] = WALL;
} else {
grid[i] = EMPTY;
// Fill bottom half with sand initially
// y > SIM_HEIGHT / 2 + margin
if (y > SIM_HEIGHT * 0.55 && y < SIM_HEIGHT - 5) {
// Random fill density for natural look start
if (Math.random() > 0.1) {
grid[i] = SAND;
colorGrid[i] = Math.random(); // Store random value for texture
}
}
}
// Cap top and bottom
if (y === 0 || y === SIM_HEIGHT - 1) grid[i] = WALL;
}
}
// Explicitly open the neck just in case calculation was too tight
const cy = Math.floor(SIM_HEIGHT / 2);
const cx = Math.floor(SIM_WIDTH / 2);
for(let y = cy - 2; y <= cy + 2; y++) {
for(let x = cx - 2; x <= cx + 2; x++) {
grid[y * SIM_WIDTH + x] = (x === cx - 2 || x === cx + 2) ? WALL : EMPTY;
}
}
}
function update() {
if (isRotating) return; // Pause physics during rotation animation
// We iterate either bottom-up or top-down depending on gravity to prevent
// particles from "teleporting" through the whole grid in one frame.
let startY, endY, stepY;
if (gravity === 1) {
startY = SIM_HEIGHT - 2; // Start from one row above bottom
endY = 0;
stepY = -1;
} else {
startY = 1; // Start from one row below top
endY = SIM_HEIGHT - 1;
stepY = 1;
}
// To prevent bias (always falling left or right first), we can alternate X direction
// or just use Math.random(). Alternating frames is cheaper.
const randDir = Math.random() > 0.5 ? 1 : -1;
for (let y = startY; y !== endY + stepY; y += stepY) {
for (let x = 0; x < SIM_WIDTH; x++) {
// Pick x based on even/odd row to distribute checks or just iterate linear
// Standard linear is fine if we randomize fall direction check
const i = y * SIM_WIDTH + x;
if (grid[i] === SAND) {
const down = i + (SIM_WIDTH * gravity);
// Check directly down
if (grid[down] === EMPTY) {
grid[down] = SAND;
grid[i] = EMPTY;
colorGrid[down] = colorGrid[i];
}
// Pile Up Logic (Granular Physics)
// Check diagonals
else {
const dir = Math.random() > 0.5 ? 1 : -1;
const downA = down + dir;
const downB = down - dir;
// Ensure we don't wrap around the grid (boundary check x)
const xA = x + dir;
const xB = x - dir;
let moved = false;
// Try direction A
if (xA >= 0 && xA < SIM_WIDTH && grid[downA] === EMPTY) {
grid[downA] = SAND;
grid[i] = EMPTY;
colorGrid[downA] = colorGrid[i];
moved = true;
}
// Try direction B
else if (xB >= 0 && xB < SIM_WIDTH && grid[downB] === EMPTY) {
grid[downB] = SAND;
grid[i] = EMPTY;
colorGrid[downB] = colorGrid[i];
moved = true;
}
}
}
}
}
}
function draw() {
// Clear buffer
buf.fill(COL_BG);
for (let i = 0; i < grid.length; i++) {
const type = grid[i];
if (type === WALL) {
buf[i] = COL_WALL;
// Add simple shading to wall
// check if right pixel is empty for highlight
if ((i+1) < grid.length && grid[i+1] === EMPTY) {
buf[i] = 0xFF888888;
}
} else if (type === SAND) {
// Generate sand color based on stored variant
// Base Gold: R=240, G=190, B=90
// Darker Gold: R=180, G=130, B=40
const variant = colorGrid[i];
const r = 200 + (variant * 55);
const g = 150 + (variant * 50);
const b = 50 + (variant * 60);
buf[i] = makeColor(r, g, b);
}
}
ctx.putImageData(imageData, 0, 0);
}
function loop() {
update();
draw();
requestAnimationFrame(loop);
}
// Input Handling
container.addEventListener('click', () => {
if (isRotating) return;
isRotating = true;
container.classList.add('rotating');
rotationAngle += 180;
// Apply visual rotation
container.style.transform = `rotate(${rotationAngle}deg)`;
// Wait for CSS transition (0.8s) to finish before flipping physics
setTimeout(() => {
gravity *= -1;
isRotating = false;
container.classList.remove('rotating');
// Note: We do NOT flip the grid data array.
// When visual is 180deg, the top of the screen is the bottom of the grid array.
// Physics Gravity -1 means falling towards index 0 (Grid Top).
// Visually, falling towards Grid Top (index 0) when rotated 180deg means falling DOWN.
// So simply flipping the gravity scalar works perfectly.
}, 800);
});
// Start
init();
loop();
</script>