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