inital commit

This commit is contained in:
Ben Miller 2026-10-22 09:05:51 -05:00
commit 49262c73fa
18 changed files with 1340 additions and 0 deletions

1
README.md Normal file
View file

@ -0,0 +1 @@
# sandgame

66
acid.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}