feat: Create optimized pixel sand simulation with chunking system
This commit is contained in:
commit
7c7d9b97fd
32
index.html
Normal file
32
index.html
Normal 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
363
script.js
Normal 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
61
styles.css
Normal 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user