382 lines
12 KiB
JavaScript
382 lines
12 KiB
JavaScript
// 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';
|
|
const WALL_COLOR = '#888888';
|
|
|
|
// Element types
|
|
const EMPTY = 0;
|
|
const SAND = 1;
|
|
const WATER = 2;
|
|
const WALL = 3;
|
|
|
|
// 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);
|
|
|
|
// Add floor at the bottom of the world (y = 0)
|
|
if (chunkY === 0) {
|
|
// Fill the bottom row with walls
|
|
for (let x = 0; x < CHUNK_SIZE; x++) {
|
|
chunkData[0 * CHUNK_SIZE + x] = WALL;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Special case: floor at the bottom of the world
|
|
if (worldY === 0) {
|
|
return WALL;
|
|
}
|
|
|
|
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;
|
|
} else if (type === WALL) {
|
|
ctx.fillStyle = WALL_COLOR;
|
|
}
|
|
|
|
// Draw the pixel
|
|
ctx.fillRect(
|
|
screenX + x * PIXEL_SIZE,
|
|
screenY + y * PIXEL_SIZE,
|
|
PIXEL_SIZE,
|
|
PIXEL_SIZE
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|