commit 7c7d9b97fd07fe948a7f18bf01a4d91554cd74e9 Author: Kacper Kostka (aider) Date: Fri Apr 4 10:59:44 2025 +0200 feat: Create optimized pixel sand simulation with chunking system diff --git a/index.html b/index.html new file mode 100644 index 0000000..fefa5a7 --- /dev/null +++ b/index.html @@ -0,0 +1,32 @@ + + + + + + Pixel Sand Simulation + + + +
+
+
+ + + +
+ +
+ Chunk: 0,0 + FPS: 0 +
+
+ +
+ + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..d6032a3 --- /dev/null +++ b/script.js @@ -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 + ); + } + } + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..92472ff --- /dev/null +++ b/styles.css @@ -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; +}