feat: Create optimized pixel sand simulation with chunking system

This commit is contained in:
Kacper Kostka (aider) 2025-04-04 10:59:44 +02:00
commit 7c7d9b97fd
3 changed files with 456 additions and 0 deletions

32
index.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixel Sand Simulation</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<div class="controls">
<div class="tools">
<button id="sand-btn" class="active">Sand</button>
<button id="water-btn">Water</button>
<button id="eraser-btn">Eraser</button>
</div>
<div class="navigation">
<button id="move-left"></button>
<button id="move-right"></button>
<button id="move-up"></button>
<button id="move-down"></button>
</div>
<div class="info">
<span id="coords">Chunk: 0,0</span>
<span id="fps">FPS: 0</span>
</div>
</div>
<canvas id="simulation-canvas"></canvas>
</div>
<script src="script.js"></script>
</body>
</html>

363
script.js Normal file
View File

@ -0,0 +1,363 @@
// Constants
const CHUNK_SIZE = 200;
const PIXEL_SIZE = 4;
const GRAVITY = 0.5;
const WATER_SPREAD = 3;
const SAND_COLOR = '#e6c588';
const WATER_COLOR = '#4a80f5';
// Element types
const EMPTY = 0;
const SAND = 1;
const WATER = 2;
// Global variables
let canvas, ctx;
let currentTool = SAND;
let isDrawing = false;
let lastFrameTime = 0;
let fps = 0;
let worldOffsetX = 0;
let worldOffsetY = 0;
let chunks = new Map(); // Map to store chunks with key "x,y"
// Initialize the simulation
window.onload = function() {
canvas = document.getElementById('simulation-canvas');
ctx = canvas.getContext('2d');
// Set canvas size to fill the screen
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
// Tool selection
document.getElementById('sand-btn').addEventListener('click', () => setTool(SAND));
document.getElementById('water-btn').addEventListener('click', () => setTool(WATER));
document.getElementById('eraser-btn').addEventListener('click', () => setTool(EMPTY));
// Navigation controls
document.getElementById('move-left').addEventListener('click', () => moveWorld(-CHUNK_SIZE/2, 0));
document.getElementById('move-right').addEventListener('click', () => moveWorld(CHUNK_SIZE/2, 0));
document.getElementById('move-up').addEventListener('click', () => moveWorld(0, -CHUNK_SIZE/2));
document.getElementById('move-down').addEventListener('click', () => moveWorld(0, CHUNK_SIZE/2));
// Drawing events
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseleave', stopDrawing);
// Touch events for mobile
canvas.addEventListener('touchstart', handleTouchStart);
canvas.addEventListener('touchmove', handleTouchMove);
canvas.addEventListener('touchend', stopDrawing);
// Initialize the first chunk
getOrCreateChunk(0, 0);
// Start the simulation loop
requestAnimationFrame(simulationLoop);
};
function resizeCanvas() {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight - document.querySelector('.controls').offsetHeight;
}
function setTool(tool) {
currentTool = tool;
document.querySelectorAll('.tools button').forEach(btn => btn.classList.remove('active'));
if (tool === SAND) {
document.getElementById('sand-btn').classList.add('active');
} else if (tool === WATER) {
document.getElementById('water-btn').classList.add('active');
} else if (tool === EMPTY) {
document.getElementById('eraser-btn').classList.add('active');
}
}
function startDrawing(e) {
isDrawing = true;
draw(e);
}
function stopDrawing() {
isDrawing = false;
}
function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
let x, y;
if (e.type.startsWith('touch')) {
x = e.touches[0].clientX - rect.left;
y = e.touches[0].clientY - rect.top;
} else {
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
// Convert to world coordinates
const worldX = Math.floor(x / PIXEL_SIZE) + worldOffsetX;
const worldY = Math.floor(y / PIXEL_SIZE) + worldOffsetY;
// Draw a small brush (3x3)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
setPixel(worldX + dx, worldY + dy, currentTool);
}
}
}
function handleTouchStart(e) {
e.preventDefault();
startDrawing(e);
}
function handleTouchMove(e) {
e.preventDefault();
draw(e);
}
function moveWorld(dx, dy) {
worldOffsetX += dx;
worldOffsetY += dy;
updateCoordinatesDisplay();
}
function updateCoordinatesDisplay() {
const chunkX = Math.floor(worldOffsetX / CHUNK_SIZE);
const chunkY = Math.floor(worldOffsetY / CHUNK_SIZE);
document.getElementById('coords').textContent = `Chunk: ${chunkX},${chunkY}`;
}
function getChunkKey(chunkX, chunkY) {
return `${chunkX},${chunkY}`;
}
function getOrCreateChunk(chunkX, chunkY) {
const key = getChunkKey(chunkX, chunkY);
if (!chunks.has(key)) {
// Create a new chunk with empty pixels
const chunkData = new Array(CHUNK_SIZE * CHUNK_SIZE).fill(EMPTY);
chunks.set(key, chunkData);
}
return chunks.get(key);
}
function getChunkCoordinates(worldX, worldY) {
const chunkX = Math.floor(worldX / CHUNK_SIZE);
const chunkY = Math.floor(worldY / CHUNK_SIZE);
const localX = ((worldX % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
const localY = ((worldY % CHUNK_SIZE) + CHUNK_SIZE) % CHUNK_SIZE;
return { chunkX, chunkY, localX, localY };
}
function setPixel(worldX, worldY, type) {
const { chunkX, chunkY, localX, localY } = getChunkCoordinates(worldX, worldY);
const chunk = getOrCreateChunk(chunkX, chunkY);
const index = localY * CHUNK_SIZE + localX;
chunk[index] = type;
}
function getPixel(worldX, worldY) {
const { chunkX, chunkY, localX, localY } = getChunkCoordinates(worldX, worldY);
const key = getChunkKey(chunkX, chunkY);
if (!chunks.has(key)) {
return EMPTY;
}
const chunk = chunks.get(key);
const index = localY * CHUNK_SIZE + localX;
return chunk[index];
}
function simulationLoop(timestamp) {
// Calculate FPS
const deltaTime = timestamp - lastFrameTime;
lastFrameTime = timestamp;
fps = Math.round(1000 / deltaTime);
document.getElementById('fps').textContent = `FPS: ${fps}`;
// Update physics
updatePhysics();
// Render
render();
// Continue the loop
requestAnimationFrame(simulationLoop);
}
function updatePhysics() {
// Get visible chunks
const visibleChunks = getVisibleChunks();
// Process each visible chunk
for (const { chunkX, chunkY } of visibleChunks) {
const chunk = getOrCreateChunk(chunkX, chunkY);
// Process from bottom to top, right to left for correct gravity simulation
for (let y = CHUNK_SIZE - 1; y >= 0; y--) {
// Alternate direction each row for more natural flow
const startX = y % 2 === 0 ? 0 : CHUNK_SIZE - 1;
const endX = y % 2 === 0 ? CHUNK_SIZE : -1;
const step = y % 2 === 0 ? 1 : -1;
for (let x = startX; x !== endX; x += step) {
const index = y * CHUNK_SIZE + x;
const type = chunk[index];
if (type === EMPTY) continue;
const worldX = chunkX * CHUNK_SIZE + x;
const worldY = chunkY * CHUNK_SIZE + y;
if (type === SAND) {
updateSand(worldX, worldY);
} else if (type === WATER) {
updateWater(worldX, worldY);
}
}
}
}
}
function updateSand(x, y) {
// Try to move down
if (getPixel(x, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x, y + 1, SAND);
}
// Try to move down-left or down-right
else if (getPixel(x - 1, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x - 1, y + 1, SAND);
}
else if (getPixel(x + 1, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x + 1, y + 1, SAND);
}
// Sand can displace water
else if (getPixel(x, y + 1) === WATER) {
setPixel(x, y, WATER);
setPixel(x, y + 1, SAND);
}
}
function updateWater(x, y) {
// Try to move down
if (getPixel(x, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x, y + 1, WATER);
}
// Try to move down-left or down-right
else if (getPixel(x - 1, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x - 1, y + 1, WATER);
}
else if (getPixel(x + 1, y + 1) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x + 1, y + 1, WATER);
}
// Try to spread horizontally
else {
let moved = false;
// Randomly choose direction first
const goLeft = Math.random() > 0.5;
if (goLeft && getPixel(x - 1, y) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x - 1, y, WATER);
moved = true;
} else if (!goLeft && getPixel(x + 1, y) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x + 1, y, WATER);
moved = true;
}
// Try the other direction if first failed
else if (!goLeft && getPixel(x - 1, y) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x - 1, y, WATER);
moved = true;
} else if (goLeft && getPixel(x + 1, y) === EMPTY) {
setPixel(x, y, EMPTY);
setPixel(x + 1, y, WATER);
moved = true;
}
}
}
function getVisibleChunks() {
const visibleChunks = [];
// Calculate visible chunk range
const startChunkX = Math.floor(worldOffsetX / CHUNK_SIZE) - 1;
const endChunkX = Math.ceil((worldOffsetX + canvas.width / PIXEL_SIZE) / CHUNK_SIZE) + 1;
const startChunkY = Math.floor(worldOffsetY / CHUNK_SIZE) - 1;
const endChunkY = Math.ceil((worldOffsetY + canvas.height / PIXEL_SIZE) / CHUNK_SIZE) + 1;
for (let chunkY = startChunkY; chunkY < endChunkY; chunkY++) {
for (let chunkX = startChunkX; chunkX < endChunkX; chunkX++) {
visibleChunks.push({ chunkX, chunkY });
}
}
return visibleChunks;
}
function render() {
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Get visible chunks
const visibleChunks = getVisibleChunks();
// Render each visible chunk
for (const { chunkX, chunkY } of visibleChunks) {
const key = getChunkKey(chunkX, chunkY);
if (!chunks.has(key)) continue;
const chunk = chunks.get(key);
// Calculate screen position of chunk
const screenX = (chunkX * CHUNK_SIZE - worldOffsetX) * PIXEL_SIZE;
const screenY = (chunkY * CHUNK_SIZE - worldOffsetY) * PIXEL_SIZE;
// Render each pixel in the chunk
for (let y = 0; y < CHUNK_SIZE; y++) {
for (let x = 0; x < CHUNK_SIZE; x++) {
const index = y * CHUNK_SIZE + x;
const type = chunk[index];
if (type === EMPTY) continue;
// Set color based on type
if (type === SAND) {
ctx.fillStyle = SAND_COLOR;
} else if (type === WATER) {
ctx.fillStyle = WATER_COLOR;
}
// Draw the pixel
ctx.fillRect(
screenX + x * PIXEL_SIZE,
screenY + y * PIXEL_SIZE,
PIXEL_SIZE,
PIXEL_SIZE
);
}
}
}
}

61
styles.css Normal file
View File

@ -0,0 +1,61 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
background-color: #222;
color: #fff;
overflow: hidden;
height: 100vh;
}
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
.controls {
display: flex;
justify-content: space-between;
padding: 10px;
background-color: #333;
}
.tools button, .navigation button {
background-color: #555;
color: white;
border: none;
padding: 8px 12px;
margin: 0 5px;
cursor: pointer;
border-radius: 4px;
}
.tools button.active {
background-color: #ff9800;
}
.navigation {
display: flex;
}
.info {
display: flex;
flex-direction: column;
justify-content: center;
}
.info span {
margin: 2px 0;
font-size: 14px;
}
#simulation-canvas {
flex-grow: 1;
background-color: #000;
cursor: crosshair;
}