From 49262c73fa1bce0efdbe0912495efcc0f77e4093 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Thu, 22 Oct 2026 09:05:51 -0500 Subject: [PATCH] inital commit --- README.md | 1 + acid.js | 66 +++++++++++++ actions.js | 15 +++ config.js | 48 +++++++++ dirt.js | 28 ++++++ explosive.js | 125 +++++++++++++++++++++++ fire.js | 63 ++++++++++++ grass.js | 31 ++++++ index.css | 207 ++++++++++++++++++++++++++++++++++++++ index.html | 26 +++++ index.js | 57 +++++++++++ physics.js | 158 +++++++++++++++++++++++++++++ plant.js | 57 +++++++++++ renderer.js | 38 +++++++ state.js | 28 ++++++ steam.js | 84 ++++++++++++++++ ui.js | 274 +++++++++++++++++++++++++++++++++++++++++++++++++++ water.js | 34 +++++++ 18 files changed, 1340 insertions(+) create mode 100644 README.md create mode 100644 acid.js create mode 100644 actions.js create mode 100644 config.js create mode 100644 dirt.js create mode 100644 explosive.js create mode 100644 fire.js create mode 100644 grass.js create mode 100644 index.css create mode 100644 index.html create mode 100644 index.js create mode 100644 physics.js create mode 100644 plant.js create mode 100644 renderer.js create mode 100644 state.js create mode 100644 steam.js create mode 100644 ui.js create mode 100644 water.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3971cb --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# sandgame \ No newline at end of file diff --git a/acid.js b/acid.js new file mode 100644 index 0000000..83db9da --- /dev/null +++ b/acid.js @@ -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); + } + } +} \ No newline at end of file diff --git a/actions.js b/actions.js new file mode 100644 index 0000000..416aacc --- /dev/null +++ b/actions.js @@ -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; +} \ No newline at end of file diff --git a/config.js b/config.js new file mode 100644 index 0000000..dd2c847 --- /dev/null +++ b/config.js @@ -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); + } + } +} diff --git a/dirt.js b/dirt.js new file mode 100644 index 0000000..8b1376f --- /dev/null +++ b/dirt.js @@ -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); + } +} \ No newline at end of file diff --git a/explosive.js b/explosive.js new file mode 100644 index 0000000..4c5ff1f --- /dev/null +++ b/explosive.js @@ -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 }; + } + } + } + } + } + } +} \ No newline at end of file diff --git a/fire.js b/fire.js new file mode 100644 index 0000000..2f9b330 --- /dev/null +++ b/fire.js @@ -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); +} \ No newline at end of file diff --git a/grass.js b/grass.js new file mode 100644 index 0000000..845a5ee --- /dev/null +++ b/grass.js @@ -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 }; + } + } +} \ No newline at end of file diff --git a/index.css b/index.css new file mode 100644 index 0000000..192896a --- /dev/null +++ b/index.css @@ -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%; + } +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..c2c476c --- /dev/null +++ b/index.html @@ -0,0 +1,26 @@ + + + + + + + + Sand Falling Game + + + + + +
+ +
+
+
+ + +
+
+ + + + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..697c992 --- /dev/null +++ b/index.js @@ -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(); \ No newline at end of file diff --git a/physics.js b/physics.js new file mode 100644 index 0000000..1e505e9 --- /dev/null +++ b/physics.js @@ -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; + } + } +} diff --git a/plant.js b/plant.js new file mode 100644 index 0000000..e4cd3d3 --- /dev/null +++ b/plant.js @@ -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 }; + } + } +} \ No newline at end of file diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..b0d3c9b --- /dev/null +++ b/renderer.js @@ -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'; + } +} diff --git a/state.js b/state.js new file mode 100644 index 0000000..e1bad5b --- /dev/null +++ b/state.js @@ -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)); +} diff --git a/steam.js b/steam.js new file mode 100644 index 0000000..71ee40e --- /dev/null +++ b/steam.js @@ -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; + } +} \ No newline at end of file diff --git a/ui.js b/ui.js new file mode 100644 index 0000000..27ab311 --- /dev/null +++ b/ui.js @@ -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); + } + } +} \ No newline at end of file diff --git a/water.js b/water.js new file mode 100644 index 0000000..e6f2074 --- /dev/null +++ b/water.js @@ -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); + } +} \ No newline at end of file