inital commit
This commit is contained in:
commit
49262c73fa
18 changed files with 1340 additions and 0 deletions
1
README.md
Normal file
1
README.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# sandgame
|
||||||
66
acid.js
Normal file
66
acid.js
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { move, swap } from './actions.js';
|
||||||
|
import { explode } from './explosive.js';
|
||||||
|
|
||||||
|
export function updateAcid(x, y, cell) {
|
||||||
|
// Dissolve neighbors
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny]) {
|
||||||
|
const t = grid[nx][ny].type;
|
||||||
|
if (t === 'rock' || t === 'wood' || t === 'dirt' || t === 'plant') {
|
||||||
|
if (config.acid.dissolve[t] && Math.random() < config.acid.dissolveChance) {
|
||||||
|
grid[nx][ny] = { type: 'steam', color: '#E0FFFF', updated: true }; // Turn dissolved block into steam
|
||||||
|
grid[x][y] = null; // Consume acid
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (t === 'explosive') {
|
||||||
|
if (config.acid.explodeTNT) {
|
||||||
|
explode(nx, ny);
|
||||||
|
grid[x][y] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (t === 'water') {
|
||||||
|
if (config.acid.dissolve.water && Math.random() < 0.01) {
|
||||||
|
grid[nx][ny].type = 'acid'; // Convert water
|
||||||
|
grid[nx][ny].color = '#32CD32';
|
||||||
|
grid[nx][ny].updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Movement (same as water)
|
||||||
|
const below = y + 1;
|
||||||
|
if (below >= rows) return;
|
||||||
|
|
||||||
|
const belowCell = grid[x][below];
|
||||||
|
if (!belowCell) {
|
||||||
|
move(x, y, x, below);
|
||||||
|
} else if (belowCell.type === 'water' && !config.acid.dissolve.water) {
|
||||||
|
// Sink through water if not infecting (Acid is denser)
|
||||||
|
if (Math.random() < config.acid.sinkChance) {
|
||||||
|
swap(x, y, x, below);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Slide sideways (can displace water if not dissolving it)
|
||||||
|
const canMoveLeft = x > 0 && (!grid[x - 1][y] || (grid[x - 1][y].type === 'water' && !config.acid.dissolve.water));
|
||||||
|
const canMoveRight = x < cols - 1 && (!grid[x + 1][y] || (grid[x + 1][y].type === 'water' && !config.acid.dissolve.water));
|
||||||
|
|
||||||
|
if (canMoveLeft && canMoveRight) {
|
||||||
|
const dir = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
const target = grid[x + dir][y];
|
||||||
|
if (target && target.type === 'water') swap(x, y, x + dir, y);
|
||||||
|
else move(x, y, x + dir, y);
|
||||||
|
} else if (canMoveLeft) {
|
||||||
|
const target = grid[x - 1][y];
|
||||||
|
if (target && target.type === 'water') swap(x, y, x - 1, y);
|
||||||
|
else move(x, y, x - 1, y);
|
||||||
|
} else if (canMoveRight) {
|
||||||
|
const target = grid[x + 1][y];
|
||||||
|
if (target && target.type === 'water') swap(x, y, x + 1, y);
|
||||||
|
else move(x, y, x + 1, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
actions.js
Normal file
15
actions.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { grid } from './state.js';
|
||||||
|
|
||||||
|
export function move(x1, y1, x2, y2) {
|
||||||
|
grid[x2][y2] = grid[x1][y1];
|
||||||
|
grid[x1][y1] = null;
|
||||||
|
grid[x2][y2].updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function swap(x1, y1, x2, y2) {
|
||||||
|
const temp = grid[x1][y1];
|
||||||
|
grid[x1][y1] = grid[x2][y2];
|
||||||
|
grid[x2][y2] = temp;
|
||||||
|
if (grid[x1][y1]) grid[x1][y1].updated = true;
|
||||||
|
if (grid[x2][y2]) grid[x2][y2].updated = true;
|
||||||
|
}
|
||||||
48
config.js
Normal file
48
config.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// c:\Users\benji\OneDrive\Documents\Source Code\sandgame\config.js
|
||||||
|
export const defaultConfig = {
|
||||||
|
global: { gravity: 0.5, drag: 0.9 },
|
||||||
|
time: { paused: false, speed: 1 },
|
||||||
|
explosive: {
|
||||||
|
radius: 15,
|
||||||
|
force: 2.0,
|
||||||
|
destroy: { rock: true, wood: true, dirt: true, plant: true, water: false, acid: true }
|
||||||
|
},
|
||||||
|
acid: {
|
||||||
|
dissolveChance: 0.05,
|
||||||
|
sinkChance: 0.2,
|
||||||
|
explodeTNT: false,
|
||||||
|
dissolve: { rock: false, wood: true, dirt: true, plant: true, water: false }
|
||||||
|
},
|
||||||
|
fire: { burnChance: 0.01 },
|
||||||
|
water: { flowChance: 1.0 },
|
||||||
|
steam: { condenseChance: 0.01 },
|
||||||
|
plant: { growChance: 0.02, woodificationChance: 0.01, burnChance: 0.4 },
|
||||||
|
wood: { burnChance: 0.05 },
|
||||||
|
visual: { style: 'blocky' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export let config = JSON.parse(JSON.stringify(defaultConfig));
|
||||||
|
|
||||||
|
export function setConfig(newConfig) {
|
||||||
|
config = newConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings() {
|
||||||
|
localStorage.setItem('sandGameSettings', JSON.stringify(config));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSettings() {
|
||||||
|
if (localStorage.getItem('sandGameSettings')) {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(localStorage.getItem('sandGameSettings'));
|
||||||
|
// Merge saved settings into config
|
||||||
|
for (const key in saved) {
|
||||||
|
if (config[key]) {
|
||||||
|
Object.assign(config[key], saved[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
dirt.js
Normal file
28
dirt.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { move } from './actions.js';
|
||||||
|
|
||||||
|
export function updateDirt(x, y, cell) {
|
||||||
|
const below = y + 1;
|
||||||
|
if (below >= rows) return;
|
||||||
|
|
||||||
|
const belowCell = grid[x][below];
|
||||||
|
if (!belowCell) {
|
||||||
|
move(x, y, x, below);
|
||||||
|
} else if (belowCell.type === 'water' || belowCell.type === 'acid') {
|
||||||
|
// Fall through water or acid (slower)
|
||||||
|
if (Math.random() < 0.2) {
|
||||||
|
grid[x][below] = cell;
|
||||||
|
grid[x][y] = belowCell;
|
||||||
|
cell.updated = true;
|
||||||
|
belowCell.updated = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try sliding left or right
|
||||||
|
const left = x > 0 && !grid[x - 1][below];
|
||||||
|
const right = x < cols - 1 && !grid[x + 1][below];
|
||||||
|
|
||||||
|
if (left && right) move(x, y, Math.random() < 0.5 ? x - 1 : x + 1, below);
|
||||||
|
else if (left) move(x, y, x - 1, below);
|
||||||
|
else if (right) move(x, y, x + 1, below);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
explosive.js
Normal file
125
explosive.js
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export function updateExplosive(x, y, cell) {
|
||||||
|
// Check neighbors for fire
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny]) {
|
||||||
|
if (grid[nx][ny].type === 'fire') {
|
||||||
|
explode(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function explode(startX, startY) {
|
||||||
|
// BFS to find connected explosives (Cluster Logic)
|
||||||
|
let queue = [{x: startX, y: startY}];
|
||||||
|
let visited = new Set([`${startX},${startY}`]);
|
||||||
|
let cluster = [];
|
||||||
|
let perimeter = []; // "Skin" of the explosive
|
||||||
|
|
||||||
|
// Ensure start is explosive
|
||||||
|
if (!grid[startX][startY] || grid[startX][startY].type !== 'explosive') return;
|
||||||
|
|
||||||
|
let head = 0;
|
||||||
|
while(head < queue.length) {
|
||||||
|
let curr = queue[head++];
|
||||||
|
cluster.push(curr);
|
||||||
|
|
||||||
|
let isPerimeter = false;
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = curr.x + n[0];
|
||||||
|
const ny = curr.y + n[1];
|
||||||
|
const key = `${nx},${ny}`;
|
||||||
|
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
|
||||||
|
const neighbor = grid[nx][ny];
|
||||||
|
if (neighbor && neighbor.type === 'explosive') {
|
||||||
|
if (!visited.has(key)) {
|
||||||
|
visited.add(key);
|
||||||
|
queue.push({x: nx, y: ny});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Neighbor is not explosive (or is empty), so this is an edge
|
||||||
|
isPerimeter = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Edge of screen
|
||||||
|
isPerimeter = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPerimeter) {
|
||||||
|
perimeter.push(curr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Force based on Mass
|
||||||
|
const totalMass = cluster.length;
|
||||||
|
const forceMultiplier = config.explosive.force + Math.log(totalMass) * 0.1;
|
||||||
|
const reach = config.explosive.radius;
|
||||||
|
|
||||||
|
// Remove Cluster
|
||||||
|
for (let p of cluster) {
|
||||||
|
grid[p.x][p.y] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply Blast from Perimeter (Skin)
|
||||||
|
for (let p of perimeter) {
|
||||||
|
for (let i = -reach; i <= reach; i++) {
|
||||||
|
for (let j = -reach; j <= reach; j++) {
|
||||||
|
const nx = p.x + i;
|
||||||
|
const ny = p.y + j;
|
||||||
|
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
|
||||||
|
const dist = Math.sqrt(i*i + j*j);
|
||||||
|
if (dist > 0 && dist <= reach) {
|
||||||
|
const target = grid[nx][ny];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
if (target.type === 'explosive') {
|
||||||
|
explode(nx, ny); // Trigger separate cluster
|
||||||
|
} else if (target.type === 'wood' && config.explosive.destroy.wood) {
|
||||||
|
grid[nx][ny] = { type: 'fire', color: '#FF5500', updated: true };
|
||||||
|
} else if (target.type === 'water' || target.type === 'acid' || target.type === 'steam') {
|
||||||
|
if (target.type === 'water' && config.explosive.destroy.water) {
|
||||||
|
grid[nx][ny] = null;
|
||||||
|
} else if (target.type === 'acid' && config.explosive.destroy.acid) {
|
||||||
|
grid[nx][ny] = null;
|
||||||
|
} else {
|
||||||
|
if (target.type === 'water' && Math.random() < 0.2) {
|
||||||
|
target.type = 'steam';
|
||||||
|
target.color = '#E0FFFF';
|
||||||
|
}
|
||||||
|
const angle = Math.atan2(j, i);
|
||||||
|
const force = (reach - dist + 1) * forceMultiplier * 0.5;
|
||||||
|
target.vx = (target.vx || 0) + Math.cos(angle) * force;
|
||||||
|
target.vy = (target.vy || 0) + Math.sin(angle) * force;
|
||||||
|
target.updated = true;
|
||||||
|
}
|
||||||
|
} else if (target.type === 'dirt' && config.explosive.destroy.dirt) {
|
||||||
|
const angle = Math.atan2(j, i);
|
||||||
|
const force = (reach - dist + 1) * (forceMultiplier * 0.3);
|
||||||
|
target.vx = (target.vx || 0) + Math.cos(angle) * force;
|
||||||
|
target.vy = (target.vy || 0) + Math.sin(angle) * force;
|
||||||
|
target.updated = true;
|
||||||
|
} else if (target.type !== 'fire' && config.explosive.destroy[target.type]) {
|
||||||
|
grid[nx][ny] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.random() < 0.05 && !grid[nx][ny]) {
|
||||||
|
grid[nx][ny] = { type: 'fire', color: '#FF5500', updated: true, lifespan: Math.random() * 10 + 5 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
fire.js
Normal file
63
fire.js
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { move } from './actions.js';
|
||||||
|
|
||||||
|
export function updateFire(x, y, cell) {
|
||||||
|
// Check lifespan
|
||||||
|
if (cell.lifespan !== undefined) {
|
||||||
|
cell.lifespan--;
|
||||||
|
if (cell.lifespan <= 0) {
|
||||||
|
grid[x][y] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (Math.random() < config.fire.burnChance) {
|
||||||
|
grid[x][y] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extinguish if trapped
|
||||||
|
let trapped = true;
|
||||||
|
const checkNeighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
for (let n of checkNeighbors) {
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
|
||||||
|
if (!grid[nx][ny] || grid[nx][ny].type === 'wood' || grid[nx][ny].type === 'plant' || grid[nx][ny].type === 'explosive') {
|
||||||
|
trapped = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trapped) {
|
||||||
|
grid[x][y] = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interact with neighbors
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny]) {
|
||||||
|
if (grid[nx][ny].type === 'water') {
|
||||||
|
grid[x][y] = null; // Extinguished
|
||||||
|
grid[nx][ny] = { type: 'steam', color: '#E0FFFF', updated: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (grid[nx][ny].type === 'plant') {
|
||||||
|
if (Math.random() < config.plant.burnChance) {
|
||||||
|
grid[nx][ny] = { type: 'fire', color: '#FF5500', updated: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (grid[nx][ny].type === 'wood') {
|
||||||
|
if (Math.random() < config.wood.burnChance) {
|
||||||
|
grid[nx][ny] = { type: 'fire', color: '#FF5500', updated: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move randomly (rise)
|
||||||
|
const dx = Math.floor(Math.random() * 3) - 1;
|
||||||
|
const dy = Math.floor(Math.random() * 2) - 1;
|
||||||
|
const nx = x + dx, ny = y + dy;
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && !grid[nx][ny]) move(x, y, nx, ny);
|
||||||
|
}
|
||||||
31
grass.js
Normal file
31
grass.js
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export function updateGrass(x, y, cell) {
|
||||||
|
// Static, but infects dirt
|
||||||
|
if (Math.random() < config.grass.growChance * 0.1) {
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
const n = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny] && grid[nx][ny].type === 'dirt') {
|
||||||
|
grid[nx][ny].type = 'grass';
|
||||||
|
grid[nx][ny].color = '#00FF00';
|
||||||
|
grid[nx][ny].updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organic Growth (Feeler branches)
|
||||||
|
if (Math.random() < config.grass.growChance) {
|
||||||
|
const growOptions = [
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: -1, dy: -1 }, // Up-Left
|
||||||
|
{ dx: 1, dy: -1 } // Up-Right
|
||||||
|
];
|
||||||
|
const opt = growOptions[Math.floor(Math.random() * growOptions.length)];
|
||||||
|
const nx = x + opt.dx, ny = y + opt.dy;
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && !grid[nx][ny]) {
|
||||||
|
grid[nx][ny] = { type: 'grass', color: '#00FF00', updated: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
207
index.css
Normal file
207
index.css
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #222;
|
||||||
|
color: #eee;
|
||||||
|
font-family: sans-serif;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
border: none;
|
||||||
|
background-color: #111;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#elementDrawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 140px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
transition: left 0.3s;
|
||||||
|
color: white;
|
||||||
|
border-right: 2px solid #444;
|
||||||
|
font-family: monospace;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
#elementDrawer.closed {
|
||||||
|
left: -140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggleElements {
|
||||||
|
position: absolute;
|
||||||
|
left: 100%;
|
||||||
|
top: 10px;
|
||||||
|
width: auto;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0 5px 5px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#elementContent {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-btn {
|
||||||
|
padding: 10px;
|
||||||
|
border: 4px solid;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4) rgba(0, 0, 0, 0.4) rgba(0, 0, 0, 0.4) rgba(255, 255, 255, 0.4);
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
color: white;
|
||||||
|
background-image: linear-gradient(to bottom, rgba(255,255,255,0.2), rgba(0,0,0,0.2));
|
||||||
|
box-shadow: 4px 4px 0 rgba(0,0,0,0.5);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-btn:active {
|
||||||
|
transform: translate(2px, 2px);
|
||||||
|
box-shadow: 2px 2px 0 rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-btn.selected {
|
||||||
|
border-color: white;
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 4px 4px 0 rgba(0,0,0,0.5), 0 0 0 2px black;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsDrawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: -300px;
|
||||||
|
width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
transition: right 0.3s;
|
||||||
|
color: white;
|
||||||
|
border-left: 2px solid #444;
|
||||||
|
font-family: monospace;
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsDrawer.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toggleDrawer {
|
||||||
|
position: absolute;
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
top: 10px;
|
||||||
|
width: auto;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 15px;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
font-size: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#togglePause {
|
||||||
|
position: absolute;
|
||||||
|
left: auto;
|
||||||
|
right: 100%;
|
||||||
|
top: 60px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
padding: 0;
|
||||||
|
background: #333;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 5px 0 0 5px;
|
||||||
|
font-size: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #444;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-group h3, .setting-group fieldset {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsContent {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 2fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control input[type=range] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: calc(50% - 5px);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#elementDrawer {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
#elementDrawer.closed {
|
||||||
|
left: -120px;
|
||||||
|
}
|
||||||
|
#settingsDrawer {
|
||||||
|
width: 250px;
|
||||||
|
right: -250px;
|
||||||
|
}
|
||||||
|
#settingsDrawer.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.setting-checkbox {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
index.html
Normal file
26
index.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sand Falling Game</title>
|
||||||
|
<link rel="stylesheet" href="index.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<canvas id="gameCanvas"></canvas>
|
||||||
|
<div id="elementDrawer">
|
||||||
|
<button id="toggleElements">Elements 🧱</button>
|
||||||
|
<div id="elementContent"></div>
|
||||||
|
</div>
|
||||||
|
<div id="settingsDrawer">
|
||||||
|
<button id="togglePause">⏸️</button>
|
||||||
|
<button id="toggleDrawer">Settings ⚙️</button>
|
||||||
|
<div id="settingsContent"></div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="index.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
57
index.js
Normal file
57
index.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { canvas, resize } from './state.js';
|
||||||
|
import { setupUI, handleInput } from './ui.js';
|
||||||
|
import { update } from './physics.js';
|
||||||
|
import { draw } from './renderer.js';
|
||||||
|
import { loadSettings, config } from './config.js';
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadSettings();
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
setupUI();
|
||||||
|
|
||||||
|
// Input Handling
|
||||||
|
let isMouseDown = false;
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
isMouseDown = true;
|
||||||
|
handleInput(e);
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mouseup', () => isMouseDown = false);
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (isMouseDown) handleInput(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchstart', (e) => {
|
||||||
|
isMouseDown = true;
|
||||||
|
handleInput(e);
|
||||||
|
e.preventDefault();
|
||||||
|
}, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', (e) => {
|
||||||
|
if (e.touches.length === 0) isMouseDown = false;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('touchmove', (e) => {
|
||||||
|
if (isMouseDown) handleInput(e);
|
||||||
|
e.preventDefault();
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
gameLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateAccumulator = 0;
|
||||||
|
function gameLoop() {
|
||||||
|
if (!config.time.paused) {
|
||||||
|
updateAccumulator += config.time.speed;
|
||||||
|
while (updateAccumulator >= 1) {
|
||||||
|
update();
|
||||||
|
updateAccumulator -= 1;
|
||||||
|
if (updateAccumulator > 50) {
|
||||||
|
updateAccumulator = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
requestAnimationFrame(gameLoop);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
158
physics.js
Normal file
158
physics.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { move, swap } from './actions.js';
|
||||||
|
import { updateDirt } from './dirt.js';
|
||||||
|
import { updateWater } from './water.js';
|
||||||
|
import { updateAcid } from './acid.js';
|
||||||
|
import { updateSteam } from './steam.js';
|
||||||
|
import { updatePlant } from './plant.js';
|
||||||
|
import { updateFire } from './fire.js';
|
||||||
|
import { updateExplosive, explode } from './explosive.js';
|
||||||
|
|
||||||
|
export function spawnCell(x, y, type) {
|
||||||
|
if (x >= 0 && x < cols && y >= 0 && y < rows) {
|
||||||
|
const target = grid[x][y];
|
||||||
|
|
||||||
|
// Manual Detonation
|
||||||
|
if (target && target.type === 'explosive' && type === 'fire') {
|
||||||
|
if (!config.time.paused) {
|
||||||
|
explode(x, y);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if target is a fluid/gas that can be displaced
|
||||||
|
const isDisplaceable = target && (target.type === 'water' || target.type === 'acid' || target.type === 'steam');
|
||||||
|
|
||||||
|
if (!target || (isDisplaceable && type !== target.type)) {
|
||||||
|
// If it's displaceable, try to push it aside
|
||||||
|
if (isDisplaceable) {
|
||||||
|
const neighbors = [[-1,0], [1,0], [0,-1], [0,1]];
|
||||||
|
neighbors.sort(() => Math.random() - 0.5);
|
||||||
|
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = x + n[0];
|
||||||
|
const ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && !grid[nx][ny]) {
|
||||||
|
grid[nx][ny] = target; // Move fluid here
|
||||||
|
target.updated = true;
|
||||||
|
// Add Ripple Velocity
|
||||||
|
target.vx = (target.vx || 0) + n[0] * 2;
|
||||||
|
target.vy = (target.vy || 0) + n[1] * 2 - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
grid[x][y] = createCell(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCell(type) {
|
||||||
|
const colors = {
|
||||||
|
dirt: '#ffaa00',
|
||||||
|
plant: '#228B22',
|
||||||
|
water: '#0000FF',
|
||||||
|
fire: '#FF5500',
|
||||||
|
rock: '#808080',
|
||||||
|
wood: '#8B4513',
|
||||||
|
explosive: '#8B0000',
|
||||||
|
acid: '#7FFF00',
|
||||||
|
steam: '#E0FFFF'
|
||||||
|
};
|
||||||
|
return { type, color: colors[type] || '#FFF', updated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function update() {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
for (let y = 0; y < rows; y++) {
|
||||||
|
if (grid[x][y]) grid[x][y].updated = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let y = rows - 1; y >= 0; y--) {
|
||||||
|
for (let x = 0; x < cols; x++) {
|
||||||
|
if (grid[x][y] && !grid[x][y].updated) {
|
||||||
|
updateCell(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCell(x, y) {
|
||||||
|
const cell = grid[x][y];
|
||||||
|
cell.updated = true;
|
||||||
|
|
||||||
|
// Physics for tossed particles (Explosions/Velocity)
|
||||||
|
if (cell.vx || cell.vy) {
|
||||||
|
applyVelocity(x, y, cell);
|
||||||
|
// If velocity moved it, we might stop here or continue depending on logic.
|
||||||
|
// For now, if it moved, we consider it updated for this frame.
|
||||||
|
if (!grid[x][y] || grid[x][y] !== cell) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = cell.type;
|
||||||
|
|
||||||
|
if (type === 'dirt') {
|
||||||
|
updateDirt(x, y, cell);
|
||||||
|
} else if (type === 'water') {
|
||||||
|
updateWater(x, y, cell);
|
||||||
|
} else if (type === 'fire') {
|
||||||
|
updateFire(x, y, cell);
|
||||||
|
} else if (type === 'steam') {
|
||||||
|
updateSteam(x, y, cell);
|
||||||
|
} else if (type === 'acid') {
|
||||||
|
updateAcid(x, y, cell);
|
||||||
|
} else if (type === 'plant') {
|
||||||
|
updatePlant(x, y, cell);
|
||||||
|
} else if (type === 'explosive') {
|
||||||
|
updateExplosive(x, y, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVelocity(x, y, cell) {
|
||||||
|
cell.vx = cell.vx || 0;
|
||||||
|
cell.vy = cell.vy || 0;
|
||||||
|
|
||||||
|
if (cell.type === 'steam') {
|
||||||
|
cell.vy -= 0.1; // Steam rises
|
||||||
|
} else {
|
||||||
|
cell.vy += config.global.gravity;
|
||||||
|
}
|
||||||
|
cell.vx *= config.global.drag;
|
||||||
|
cell.vy *= config.global.drag;
|
||||||
|
|
||||||
|
let nextX = Math.round(x + cell.vx);
|
||||||
|
let nextY = Math.round(y + cell.vy);
|
||||||
|
|
||||||
|
// Bounce off walls
|
||||||
|
if (nextX < 0 || nextX >= cols) {
|
||||||
|
cell.vx *= -0.5;
|
||||||
|
nextX = x;
|
||||||
|
}
|
||||||
|
if (nextY < 0 || nextY >= rows) {
|
||||||
|
cell.vy *= -0.3;
|
||||||
|
nextY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextX >= 0 && nextX < cols && nextY >= 0 && nextY < rows) {
|
||||||
|
const neighbor = grid[nextX][nextY];
|
||||||
|
if (!neighbor) {
|
||||||
|
move(x, y, nextX, nextY);
|
||||||
|
} else if (neighbor.type === 'steam') {
|
||||||
|
swap(x, y, nextX, nextY);
|
||||||
|
} else if (neighbor.type === 'water' || neighbor.type === 'dirt' || neighbor.type === 'acid') {
|
||||||
|
// Collision with movable particles
|
||||||
|
neighbor.vx = (neighbor.vx || 0) + cell.vx * 0.8;
|
||||||
|
neighbor.vy = (neighbor.vy || 0) + cell.vy * 0.8;
|
||||||
|
neighbor.vx += (Math.random() - 0.5); // Scatter
|
||||||
|
neighbor.updated = true;
|
||||||
|
cell.vx *= -0.4;
|
||||||
|
cell.vy *= -0.4;
|
||||||
|
} else {
|
||||||
|
cell.vx *= -0.3;
|
||||||
|
cell.vy *= -0.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
plant.js
Normal file
57
plant.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export function updatePlant(x, y, cell) {
|
||||||
|
// Woodification (Stalk formation)
|
||||||
|
// Only turn to wood if supporting a column of plants (weight)
|
||||||
|
let weight = 0;
|
||||||
|
const requiredWeight = 5;
|
||||||
|
for (let i = 1; i <= requiredWeight; i++) {
|
||||||
|
if (y - i < 0) break;
|
||||||
|
const above = grid[x][y - i];
|
||||||
|
if (above && (above.type === 'plant' || above.type === 'wood')) {
|
||||||
|
weight++;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weight >= requiredWeight) {
|
||||||
|
if (Math.random() < config.plant.woodificationChance) {
|
||||||
|
cell.type = 'wood';
|
||||||
|
cell.color = '#8B4513';
|
||||||
|
cell.updated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roots (Infects dirt)
|
||||||
|
if (Math.random() < config.plant.growChance * 0.1) {
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
const n = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny] && grid[nx][ny].type === 'dirt') {
|
||||||
|
grid[nx][ny].type = 'plant';
|
||||||
|
grid[nx][ny].color = '#228B22';
|
||||||
|
grid[nx][ny].updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organic Growth (Towards Sun)
|
||||||
|
if (Math.random() < config.plant.growChance) {
|
||||||
|
const growOptions = [
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: 0, dy: -1 }, // Up
|
||||||
|
{ dx: -1, dy: -1 }, // Up-Left
|
||||||
|
{ dx: 1, dy: -1 } // Up-Right
|
||||||
|
];
|
||||||
|
const opt = growOptions[Math.floor(Math.random() * growOptions.length)];
|
||||||
|
const nx = x + opt.dx, ny = y + opt.dy;
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && !grid[nx][ny]) {
|
||||||
|
grid[nx][ny] = { type: 'plant', color: '#228B22', updated: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
renderer.js
Normal file
38
renderer.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// c:\Users\benji\OneDrive\Documents\Source Code\sandgame\renderer.js
|
||||||
|
import { ctx, canvas, grid, cols, rows, cellSize } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
|
||||||
|
export function draw() {
|
||||||
|
ctx.fillStyle = '#111';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const style = config.visual.style;
|
||||||
|
const radius = cellSize / 2;
|
||||||
|
|
||||||
|
if (style === 'fuzzy') {
|
||||||
|
ctx.filter = 'blur(2px)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Grid
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
for (let j = 0; j < rows; j++) {
|
||||||
|
if (grid[i][j]) {
|
||||||
|
ctx.fillStyle = grid[i][j].color;
|
||||||
|
const x = i * cellSize;
|
||||||
|
const y = j * cellSize;
|
||||||
|
|
||||||
|
if (style === 'bloby') {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x + radius, y + radius, radius, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
} else { // blocky
|
||||||
|
ctx.fillRect(x, y, cellSize, cellSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'fuzzy') {
|
||||||
|
ctx.filter = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
state.js
Normal file
28
state.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// c:\Users\benji\OneDrive\Documents\Source Code\sandgame\state.js
|
||||||
|
export const canvas = document.getElementById('gameCanvas');
|
||||||
|
export const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
export const cellSize = 5;
|
||||||
|
export let cols = 0;
|
||||||
|
export let rows = 0;
|
||||||
|
export let grid = [];
|
||||||
|
export let selectedType = 'dirt';
|
||||||
|
|
||||||
|
export function setSelectedType(type) {
|
||||||
|
selectedType = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGrid(newGrid) {
|
||||||
|
grid = newGrid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resize() {
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
|
||||||
|
cols = Math.floor(canvas.width / cellSize);
|
||||||
|
rows = Math.floor(canvas.height / cellSize);
|
||||||
|
|
||||||
|
// Initialize grid with nulls
|
||||||
|
grid = new Array(cols).fill(null).map(() => new Array(rows).fill(null));
|
||||||
|
}
|
||||||
84
steam.js
Normal file
84
steam.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { move, swap } from './actions.js';
|
||||||
|
|
||||||
|
export function updateSteam(x, y, cell) {
|
||||||
|
const above = y - 1;
|
||||||
|
|
||||||
|
// Check if we can move up
|
||||||
|
let canMoveUp = false;
|
||||||
|
if (above >= 0) {
|
||||||
|
const cellAbove = grid[x][above];
|
||||||
|
if (!cellAbove) {
|
||||||
|
canMoveUp = true;
|
||||||
|
} else if (cellAbove.type === 'water' || cellAbove.type === 'acid' || cellAbove.type === 'dirt') {
|
||||||
|
swap(x, y, x, above);
|
||||||
|
// Add turbulence to the liquid falling through
|
||||||
|
cellAbove.vx = (cellAbove.vx || 0) + (Math.random() - 0.5);
|
||||||
|
cellAbove.vy = (cellAbove.vy || 0) + 0.5;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canMoveUp) {
|
||||||
|
move(x, y, x, above);
|
||||||
|
// Random jitter for organic look while rising
|
||||||
|
if (Math.random() < 0.5) {
|
||||||
|
const dir = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
const nx = x + dir;
|
||||||
|
if (nx >= 0 && nx < cols) {
|
||||||
|
const neighbor = grid[nx][above];
|
||||||
|
if (!neighbor) {
|
||||||
|
move(x, above, nx, above);
|
||||||
|
} else if (neighbor.type === 'water' || neighbor.type === 'acid' || neighbor.type === 'dirt') {
|
||||||
|
swap(x, above, nx, above);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If blocked up, try sideways
|
||||||
|
const leftIdx = x - 1;
|
||||||
|
const rightIdx = x + 1;
|
||||||
|
|
||||||
|
let leftFree = false;
|
||||||
|
let rightFree = false;
|
||||||
|
|
||||||
|
if (leftIdx >= 0) {
|
||||||
|
const c = grid[leftIdx][y];
|
||||||
|
if (!c || c.type === 'water' || c.type === 'acid' || c.type === 'dirt') leftFree = true;
|
||||||
|
}
|
||||||
|
if (rightIdx < cols) {
|
||||||
|
const c = grid[rightIdx][y];
|
||||||
|
if (!c || c.type === 'water' || c.type === 'acid' || c.type === 'dirt') rightFree = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let movedSideways = false;
|
||||||
|
if (leftFree && rightFree) {
|
||||||
|
const dir = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
const nx = x + dir;
|
||||||
|
const target = grid[nx][y];
|
||||||
|
if (target) swap(x, y, nx, y);
|
||||||
|
else move(x, y, nx, y);
|
||||||
|
movedSideways = true;
|
||||||
|
} else if (leftFree) {
|
||||||
|
const target = grid[leftIdx][y];
|
||||||
|
if (target) swap(x, y, leftIdx, y);
|
||||||
|
else move(x, y, leftIdx, y);
|
||||||
|
movedSideways = true;
|
||||||
|
} else if (rightFree) {
|
||||||
|
const target = grid[rightIdx][y];
|
||||||
|
if (target) swap(x, y, rightIdx, y);
|
||||||
|
else move(x, y, rightIdx, y);
|
||||||
|
movedSideways = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condense logic
|
||||||
|
if ((above < 0 || (!movedSideways && grid[x][above])) && Math.random() < config.steam.condenseChance) {
|
||||||
|
cell.type = 'water';
|
||||||
|
cell.color = '#0000FF';
|
||||||
|
} else if (above < 0) {
|
||||||
|
grid[x][y] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
274
ui.js
Normal file
274
ui.js
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
import { config, saveSettings, defaultConfig, setConfig } from './config.js';
|
||||||
|
import { grid, cols, rows, selectedType, setSelectedType, setGrid, canvas, cellSize } from './state.js';
|
||||||
|
import { spawnCell } from './physics.js';
|
||||||
|
|
||||||
|
export const buttons = [
|
||||||
|
{ type: 'dirt', color: '#ffaa00', label: 'Dirt' },
|
||||||
|
{ type: 'plant', color: '#228B22', label: 'Plant' },
|
||||||
|
{ type: 'water', color: '#0000FF', label: 'Water' },
|
||||||
|
{ type: 'fire', color: '#FF5500', label: 'Fire' },
|
||||||
|
{ type: 'rock', color: '#808080', label: 'Rock' },
|
||||||
|
{ type: 'wood', color: '#8B4513', label: 'Wood' },
|
||||||
|
{ type: 'explosive', color: '#8B0000', label: 'TNT' },
|
||||||
|
{ type: 'acid', color: '#7FFF00', label: 'Acid' },
|
||||||
|
{ type: 'steam', color: '#E0FFFF', label: 'Steam' },
|
||||||
|
{ type: 'clear', color: '#FFFFFF', label: 'Clear' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function setupUI() {
|
||||||
|
// Settings Drawer
|
||||||
|
const drawer = document.getElementById('settingsContent');
|
||||||
|
const toggle = document.getElementById('toggleDrawer');
|
||||||
|
const drawerContainer = document.getElementById('settingsDrawer');
|
||||||
|
const pauseBtn = document.getElementById('togglePause');
|
||||||
|
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
drawerContainer.classList.toggle('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function updatePauseBtn() {
|
||||||
|
pauseBtn.textContent = config.time.paused ? '▶️' : '⏸️';
|
||||||
|
}
|
||||||
|
pauseBtn.addEventListener('click', () => {
|
||||||
|
config.time.paused = !config.time.paused;
|
||||||
|
saveSettings();
|
||||||
|
updatePauseBtn();
|
||||||
|
});
|
||||||
|
updatePauseBtn();
|
||||||
|
|
||||||
|
renderSettings(drawer);
|
||||||
|
|
||||||
|
// Elements Drawer
|
||||||
|
const elDrawer = document.getElementById('elementContent');
|
||||||
|
const elToggle = document.getElementById('toggleElements');
|
||||||
|
const elContainer = document.getElementById('elementDrawer');
|
||||||
|
|
||||||
|
elToggle.addEventListener('click', () => {
|
||||||
|
elContainer.classList.toggle('closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
renderElements(elDrawer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderElements(container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = 'element-btn';
|
||||||
|
if (btn.type === selectedType) button.classList.add('selected');
|
||||||
|
button.style.backgroundColor = btn.color;
|
||||||
|
button.textContent = btn.label;
|
||||||
|
if (btn.type === 'clear' || btn.type === 'steam' || btn.type === 'acid') button.style.color = '#000';
|
||||||
|
if (btn.type === 'clear') {
|
||||||
|
button.style.marginTop = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
button.onclick = () => {
|
||||||
|
if (btn.type === 'clear') {
|
||||||
|
setGrid(new Array(cols).fill(null).map(() => new Array(rows).fill(null)));
|
||||||
|
} else {
|
||||||
|
setSelectedType(btn.type);
|
||||||
|
document.querySelectorAll('.element-btn').forEach(b => b.classList.remove('selected'));
|
||||||
|
button.classList.add('selected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
container.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSettings(container) {
|
||||||
|
container.innerHTML = ''; // Clear existing
|
||||||
|
|
||||||
|
function addControl(group, key, subkey, def) {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
|
||||||
|
const labelEl = document.createElement('label');
|
||||||
|
let val = subkey ? config[group][key][subkey] : config[group][key];
|
||||||
|
|
||||||
|
labelEl.textContent = def.label;
|
||||||
|
|
||||||
|
labelEl.style.display = 'inline-block';
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = def.type || 'range';
|
||||||
|
|
||||||
|
if (def.type === 'checkbox') {
|
||||||
|
container.className = 'setting-checkbox';
|
||||||
|
input.checked = val;
|
||||||
|
input.style.marginRight = '10px';
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
if (subkey) config[group][key][subkey] = e.target.checked;
|
||||||
|
else config[group][key] = e.target.checked;
|
||||||
|
saveSettings();
|
||||||
|
});
|
||||||
|
container.append(input, labelEl);
|
||||||
|
} else if (def.type === 'radio') {
|
||||||
|
const fieldset = document.createElement('fieldset');
|
||||||
|
const legend = document.createElement('legend');
|
||||||
|
legend.textContent = def.label;
|
||||||
|
fieldset.appendChild(legend);
|
||||||
|
|
||||||
|
def.options.forEach(option => {
|
||||||
|
const radioId = `radio-${group}--`;
|
||||||
|
const radioContainer = document.createElement('div');
|
||||||
|
radioContainer.style.display = 'inline-block';
|
||||||
|
radioContainer.style.marginRight = '15px';
|
||||||
|
|
||||||
|
const radioInput = document.createElement('input');
|
||||||
|
radioInput.type = 'radio';
|
||||||
|
radioInput.name = `radio--`;
|
||||||
|
radioInput.value = option;
|
||||||
|
radioInput.id = radioId;
|
||||||
|
if (val === option) radioInput.checked = true;
|
||||||
|
|
||||||
|
radioInput.addEventListener('change', (e) => {
|
||||||
|
config[group][key] = e.target.value;
|
||||||
|
saveSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
const radioLabel = document.createElement('label');
|
||||||
|
radioLabel.textContent = option.charAt(0).toUpperCase() + option.slice(1);
|
||||||
|
radioLabel.setAttribute('for', radioId);
|
||||||
|
radioLabel.style.marginLeft = '5px';
|
||||||
|
|
||||||
|
radioContainer.append(radioInput, radioLabel);
|
||||||
|
fieldset.appendChild(radioContainer);
|
||||||
|
});
|
||||||
|
return fieldset;
|
||||||
|
} else {
|
||||||
|
container.className = 'setting-control';
|
||||||
|
input.min = def.min;
|
||||||
|
input.max = def.max;
|
||||||
|
input.step = def.step;
|
||||||
|
input.value = val;
|
||||||
|
const valueSpan = document.createElement('span');
|
||||||
|
valueSpan.textContent = val;
|
||||||
|
|
||||||
|
input.addEventListener('input', (e) => {
|
||||||
|
const newVal = parseFloat(e.target.value);
|
||||||
|
if (subkey) config[group][key][subkey] = newVal;
|
||||||
|
else config[group][key] = newVal;
|
||||||
|
saveSettings();
|
||||||
|
valueSpan.textContent = newVal.toFixed(Math.max(def.step.toString().split('.')[1]?.length || 0, 2));
|
||||||
|
});
|
||||||
|
container.append(labelEl, input, valueSpan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = {
|
||||||
|
time: [
|
||||||
|
{ key: 'speed', label: 'Speed', min: 0, max: 20, step: 1 }
|
||||||
|
],
|
||||||
|
global: [
|
||||||
|
{ key: 'gravity', label: 'Gravity', min: -1.0, max: 2.0, step: 0.1 },
|
||||||
|
{ key: 'drag', label: 'Drag', min: 0.1, max: 1.0, step: 0.05 }
|
||||||
|
],
|
||||||
|
explosive: [
|
||||||
|
{ key: 'force', label: 'Blast Force', min: 0, max: 20, step: 1 },
|
||||||
|
{ key: 'radius', label: 'Blast Reach', min: 1, max: 50, step: 1 },
|
||||||
|
{ key: 'destroy', subkey: 'rock', type: 'checkbox', label: 'Destroy Rock' },
|
||||||
|
{ key: 'destroy', subkey: 'wood', type: 'checkbox', label: 'Destroy Wood' },
|
||||||
|
{ key: 'destroy', subkey: 'dirt', type: 'checkbox', label: 'Destroy Dirt' },
|
||||||
|
{ key: 'destroy', subkey: 'plant', type: 'checkbox', label: 'Destroy Plant' },
|
||||||
|
{ key: 'destroy', subkey: 'water', type: 'checkbox', label: 'Destroy Water' },
|
||||||
|
{ key: 'destroy', subkey: 'acid', type: 'checkbox', label: 'Destroy Acid' }
|
||||||
|
],
|
||||||
|
acid: [
|
||||||
|
{ key: 'dissolveChance', label: 'Dissolve Rate', min: 0, max: 1.0, step: 0.01 },
|
||||||
|
{ key: 'sinkChance', label: 'Sink Rate', min: 0, max: 1.0, step: 0.05 },
|
||||||
|
{ key: 'explodeTNT', type: 'checkbox', label: 'Explode TNT' },
|
||||||
|
{ key: 'dissolve', subkey: 'rock', type: 'checkbox', label: 'Dissolve Rock' },
|
||||||
|
{ key: 'dissolve', subkey: 'wood', type: 'checkbox', label: 'Dissolve Wood' },
|
||||||
|
{ key: 'dissolve', subkey: 'dirt', type: 'checkbox', label: 'Dissolve Dirt' },
|
||||||
|
{ key: 'dissolve', subkey: 'plant', type: 'checkbox', label: 'Dissolve Plant' },
|
||||||
|
{ key: 'dissolve', subkey: 'water', type: 'checkbox', label: 'Infect Water' }
|
||||||
|
],
|
||||||
|
fire: [
|
||||||
|
{ key: 'burnChance', label: 'Fire Decay Rate', min: 0, max: 1.0, step: 0.01 }
|
||||||
|
],
|
||||||
|
water: [
|
||||||
|
{ key: 'flowChance', label: 'Flow Rate', min: 0, max: 1.0, step: 0.05 }
|
||||||
|
],
|
||||||
|
steam: [
|
||||||
|
{ key: 'condenseChance', label: 'Condense Rate', min: 0, max: 1.0, step: 0.01 }
|
||||||
|
],
|
||||||
|
plant: [
|
||||||
|
{ key: 'growChance', label: 'Growth Rate', min: 0, max: 1.0, step: 0.01 },
|
||||||
|
{ key: 'woodificationChance', label: 'Wood Rate', min: 0, max: 1.0, step: 0.01 },
|
||||||
|
{ key: 'burnChance', label: 'Burn Rate', min: 0, max: 1.0, step: 0.05 }
|
||||||
|
],
|
||||||
|
wood: [
|
||||||
|
{ key: 'burnChance', label: 'Burn Rate', min: 0, max: 1.0, step: 0.01 }
|
||||||
|
],
|
||||||
|
visual: [
|
||||||
|
{ key: 'style', type: 'radio', label: 'Style', options: ['blocky', 'bloby', 'fuzzy'] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [groupName, settings] of Object.entries(groups)) {
|
||||||
|
const groupDiv = document.createElement('div');
|
||||||
|
groupDiv.className = 'setting-group';
|
||||||
|
const title = document.createElement('h3');
|
||||||
|
title.textContent = groupName.charAt(0).toUpperCase() + groupName.slice(1);
|
||||||
|
groupDiv.appendChild(title);
|
||||||
|
|
||||||
|
settings.forEach(s => {
|
||||||
|
groupDiv.appendChild(addControl(groupName, s.key, s.subkey, s));
|
||||||
|
});
|
||||||
|
container.appendChild(groupDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults Button
|
||||||
|
const resetBtn = document.createElement('button');
|
||||||
|
resetBtn.textContent = 'Reset to Defaults';
|
||||||
|
resetBtn.style.width = '100%';
|
||||||
|
resetBtn.style.padding = '10px';
|
||||||
|
resetBtn.style.marginTop = '20px';
|
||||||
|
resetBtn.style.cursor = 'pointer';
|
||||||
|
resetBtn.onclick = () => {
|
||||||
|
setConfig(JSON.parse(JSON.stringify(defaultConfig)));
|
||||||
|
saveSettings();
|
||||||
|
renderSettings(container);
|
||||||
|
};
|
||||||
|
container.appendChild(resetBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleInput(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
|
function getPos(clientX, clientY) {
|
||||||
|
return {
|
||||||
|
x: clientX - rect.left,
|
||||||
|
y: clientY - rect.top
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Drawing
|
||||||
|
const drawAt = (clientX, clientY) => {
|
||||||
|
const {x, y} = getPos(clientX, clientY);
|
||||||
|
|
||||||
|
// No need to check isOverButton because DOM elements handle their own clicks
|
||||||
|
|
||||||
|
const col = Math.floor(x / cellSize);
|
||||||
|
const row = Math.floor(y / cellSize);
|
||||||
|
|
||||||
|
for (let i = -1; i <= 1; i++) {
|
||||||
|
for (let j = -1; j <= 1; j++) {
|
||||||
|
if (Math.random() > 0.5) {
|
||||||
|
spawnCell(col + i, row + j, selectedType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (e.type.startsWith('mouse')) {
|
||||||
|
drawAt(e.clientX, e.clientY);
|
||||||
|
} else if (e.type.startsWith('touch')) {
|
||||||
|
for (let i = 0; i < e.touches.length; i++) {
|
||||||
|
const t = e.touches[i];
|
||||||
|
drawAt(t.clientX, t.clientY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
water.js
Normal file
34
water.js
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { grid, cols, rows } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { move } from './actions.js';
|
||||||
|
|
||||||
|
export function updateWater(x, y, cell) {
|
||||||
|
// Check for grass interaction (Growth)
|
||||||
|
const neighbors = [[0, 1], [0, -1], [1, 0], [-1, 0]];
|
||||||
|
for (let n of neighbors) {
|
||||||
|
const nx = x + n[0], ny = y + n[1];
|
||||||
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows && grid[nx][ny] && grid[nx][ny].type === 'plant') {
|
||||||
|
if (Math.random() < 0.005) { // Slower, but faster than dirt
|
||||||
|
cell.type = 'plant';
|
||||||
|
cell.color = '#228B22';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const below = y + 1;
|
||||||
|
if (below >= rows) return;
|
||||||
|
|
||||||
|
const belowCell = grid[x][below];
|
||||||
|
if (!belowCell) {
|
||||||
|
move(x, y, x, below);
|
||||||
|
} else if (Math.random() < config.water.flowChance) {
|
||||||
|
// Slide or flow sideways
|
||||||
|
const left = x > 0 && !grid[x - 1][y];
|
||||||
|
const right = x < cols - 1 && !grid[x + 1][y];
|
||||||
|
|
||||||
|
if (left && right) move(x, y, Math.random() < 0.5 ? x - 1 : x + 1, y);
|
||||||
|
else if (left) move(x, y, x - 1, y);
|
||||||
|
else if (right) move(x, y, x + 1, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue