commit 49262c73fa1bce0efdbe0912495efcc0f77e4093 Author: Ben Miller Date: Thu Oct 22 09:05:51 2026 -0500 inital commit 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