SandGame/ui.js

462 lines
18 KiB
JavaScript
Raw Normal View History

2026-10-22 14:05:51 +00:00
import { config, saveSettings, defaultConfig, setConfig } from './config.js';
import { cols, rows, selectedType, setSelectedType, clearGrid, canvas, cellSize, brushSize, setBrushSize } from './state.js';
2026-10-22 14:05:51 +00:00
import { spawnCell } from './physics.js';
import { stepOnce } from './index.js';
2026-10-22 14:05:51 +00:00
export const elementCategories = [
{
title: 'Solids',
items: [
{ type: 'sand', color: '#e8b656', label: 'Sand' },
{ type: 'dirt', color: '#8b5a2b', label: 'Dirt' },
{ type: 'rock', color: '#7a7e85', label: 'Rock' },
{ type: 'wood', color: '#704020', label: 'Wood' },
{ type: 'ice', color: '#bce8f5', label: 'Ice', textColor: '#000' }
]
},
{
title: 'Liquids',
items: [
{ type: 'water', color: '#2582eb', label: 'Water' },
{ type: 'lava', color: '#ff5a00', label: 'Lava' },
{ type: 'oil', color: '#382f23', label: 'Oil' },
{ type: 'acid', color: '#68f518', label: 'Acid', textColor: '#000' }
]
},
{
title: 'Organics & Gas',
items: [
{ type: 'plant', color: '#228B22', label: 'Plant' },
{ type: 'grass', color: '#38b000', label: 'Grass' },
{ type: 'fire', color: '#ff6600', label: 'Fire' },
{ type: 'steam', color: '#dcf3f9', label: 'Steam', textColor: '#000' },
{ type: 'smoke', color: '#4a4e54', label: 'Smoke' }
]
},
{
title: 'Explosives & Tools',
items: [
{ type: 'explosive', color: '#b71c1c', label: 'TNT' },
{ type: 'eraser', color: '#555560', label: 'Eraser', title: 'Or right-click to erase' },
{ type: 'clear', color: '#2a2a30', label: 'Clear All' }
]
}
2026-10-22 14:05:51 +00:00
];
let lastPos = null;
export function setLastPos(pos) {
lastPos = pos;
}
let brushSizeSlider = null;
let brushSizeValLabel = null;
export function updateBrushUI() {
if (brushSizeSlider) brushSizeSlider.value = brushSize;
if (brushSizeValLabel) brushSizeValLabel.textContent = `${brushSize}px`;
}
2026-10-22 14:05:51 +00:00
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');
const stepBtn = document.getElementById('btnStep');
2026-10-22 14:05:51 +00:00
toggle.addEventListener('click', () => {
drawerContainer.classList.toggle('open');
});
function updatePauseBtn() {
pauseBtn.textContent = config.time.paused ? '▶' : '⏸';
2026-10-22 14:05:51 +00:00
}
2026-10-22 14:05:51 +00:00
pauseBtn.addEventListener('click', () => {
config.time.paused = !config.time.paused;
saveSettings();
updatePauseBtn();
});
updatePauseBtn();
if (stepBtn) {
stepBtn.addEventListener('click', () => {
if (config.time.paused) {
stepOnce();
} else {
config.time.paused = true;
saveSettings();
updatePauseBtn();
stepOnce();
}
});
}
2026-10-22 14:05:51 +00:00
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 selectElement(type) {
setSelectedType(type);
document.querySelectorAll('.element-btn').forEach(b => {
if (b.dataset.type === type) {
b.classList.add('selected');
} else {
b.classList.remove('selected');
}
});
}
2026-10-22 14:05:51 +00:00
function renderElements(container) {
container.innerHTML = '';
// Brush Size Header Control
const brushBox = document.createElement('div');
brushBox.className = 'brush-size-box';
const brushHeader = document.createElement('div');
brushHeader.className = 'brush-size-header';
brushHeader.innerHTML = `<span>Brush Size</span>`;
brushSizeValLabel = document.createElement('span');
brushSizeValLabel.className = 'brush-val';
brushSizeValLabel.textContent = `${brushSize}px`;
brushHeader.appendChild(brushSizeValLabel);
brushSizeSlider = document.createElement('input');
brushSizeSlider.type = 'range';
brushSizeSlider.min = '1';
brushSizeSlider.max = '25';
brushSizeSlider.value = brushSize;
brushSizeSlider.className = 'brush-slider';
brushSizeSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
setBrushSize(val);
config.brush.size = val;
saveSettings();
brushSizeValLabel.textContent = `${val}px`;
});
brushBox.appendChild(brushHeader);
brushBox.appendChild(brushSizeSlider);
container.appendChild(brushBox);
// Categorized Buttons (clean without any hotkey numbers)
elementCategories.forEach(category => {
const catDiv = document.createElement('div');
catDiv.className = 'element-category';
const catTitle = document.createElement('div');
catTitle.className = 'category-title';
catTitle.textContent = category.title;
catDiv.appendChild(catTitle);
category.items.forEach(btn => {
const button = document.createElement('button');
button.className = 'element-btn';
button.dataset.type = btn.type;
if (btn.type === selectedType) button.classList.add('selected');
button.style.backgroundColor = btn.color;
if (btn.textColor) button.style.color = btn.textColor;
const labelSpan = document.createElement('span');
labelSpan.textContent = btn.label;
button.appendChild(labelSpan);
button.onclick = () => {
if (btn.type === 'clear') {
if (confirm('Clear the entire canvas?')) {
clearGrid();
}
} else {
selectElement(btn.type);
}
};
catDiv.appendChild(button);
});
container.appendChild(catDiv);
2026-10-22 14:05:51 +00:00
});
}
export function renderSettings(container) {
container.innerHTML = '';
2026-10-22 14:05:51 +00:00
function addControl(group, key, subkey, def) {
const ctrlDiv = document.createElement('div');
2026-10-22 14:05:51 +00:00
const labelEl = document.createElement('label');
let val = subkey ? config[group]?.[key]?.[subkey] : config[group]?.[key];
2026-10-22 14:05:51 +00:00
labelEl.textContent = def.label;
2026-10-22 14:05:51 +00:00
const input = document.createElement('input');
input.type = def.type || 'range';
2026-10-22 14:05:51 +00:00
if (def.type === 'checkbox') {
ctrlDiv.className = 'setting-checkbox';
input.checked = !!val;
2026-10-22 14:05:51 +00:00
input.addEventListener('change', (e) => {
if (subkey) config[group][key][subkey] = e.target.checked;
else config[group][key] = e.target.checked;
saveSettings();
});
ctrlDiv.append(input, labelEl);
2026-10-22 14:05:51 +00:00
} else if (def.type === 'radio') {
const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = def.label;
fieldset.appendChild(legend);
2026-10-22 14:05:51 +00:00
def.options.forEach(option => {
const radioId = `radio-${group}-${key}-${option}`;
2026-10-22 14:05:51 +00:00
const radioContainer = document.createElement('div');
radioContainer.style.display = 'inline-block';
radioContainer.style.marginRight = '12px';
2026-10-22 14:05:51 +00:00
const radioInput = document.createElement('input');
radioInput.type = 'radio';
radioInput.name = `radio-${group}-${key}`;
2026-10-22 14:05:51 +00:00
radioInput.value = option;
radioInput.id = radioId;
if (val === option) radioInput.checked = true;
2026-10-22 14:05:51 +00:00
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 = '4px';
2026-10-22 14:05:51 +00:00
radioContainer.append(radioInput, radioLabel);
fieldset.appendChild(radioContainer);
});
return fieldset;
} else {
ctrlDiv.className = 'setting-control';
2026-10-22 14:05:51 +00:00
input.min = def.min;
input.max = def.max;
input.step = def.step;
input.value = val !== undefined ? val : def.min;
2026-10-22 14:05:51 +00:00
const valueSpan = document.createElement('span');
valueSpan.className = 'control-val';
valueSpan.textContent = typeof val === 'number' ? val.toString() : (val || '0');
2026-10-22 14:05:51 +00:00
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, 1));
2026-10-22 14:05:51 +00:00
});
ctrlDiv.append(labelEl, input, valueSpan);
2026-10-22 14:05:51 +00:00
}
return ctrlDiv;
2026-10-22 14:05:51 +00:00
}
// Complete settings matching all original groups plus all new elements
2026-10-22 14:05:51 +00:00
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: 'sand', type: 'checkbox', label: 'Destroy Sand' },
2026-10-22 14:05:51 +00:00
{ key: 'destroy', subkey: 'plant', type: 'checkbox', label: 'Destroy Plant' },
{ key: 'destroy', subkey: 'grass', type: 'checkbox', label: 'Destroy Grass' },
2026-10-22 14:05:51 +00:00
{ key: 'destroy', subkey: 'water', type: 'checkbox', label: 'Destroy Water' },
{ key: 'destroy', subkey: 'acid', type: 'checkbox', label: 'Destroy Acid' },
{ key: 'destroy', subkey: 'ice', type: 'checkbox', label: 'Destroy Ice' },
{ key: 'destroy', subkey: 'lava', type: 'checkbox', label: 'Destroy Lava' },
{ key: 'destroy', subkey: 'oil', type: 'checkbox', label: 'Destroy Oil' }
2026-10-22 14:05:51 +00:00
],
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: 'dispersion', label: 'Spread Speed', min: 1, max: 10, step: 1 },
2026-10-22 14:05:51 +00:00
{ 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: 'sand', type: 'checkbox', label: 'Dissolve Sand' },
2026-10-22 14:05:51 +00:00
{ key: 'dissolve', subkey: 'plant', type: 'checkbox', label: 'Dissolve Plant' },
{ key: 'dissolve', subkey: 'grass', type: 'checkbox', label: 'Dissolve Grass' },
{ key: 'dissolve', subkey: 'water', type: 'checkbox', label: 'Infect Water' },
{ key: 'dissolve', subkey: 'ice', type: 'checkbox', label: 'Dissolve Ice' },
{ key: 'dissolve', subkey: 'oil', type: 'checkbox', label: 'Dissolve Oil' }
2026-10-22 14:05:51 +00:00
],
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 },
{ key: 'dispersion', label: 'Spread Speed', min: 1, max: 10, step: 1 }
],
lava: [
{ key: 'flowChance', label: 'Flow Rate', min: 0, max: 1.0, step: 0.05 },
{ key: 'dispersion', label: 'Spread Speed', min: 1, max: 6, step: 1 },
{ key: 'coolingChance', label: 'Cooling Rate', min: 0, max: 1.0, step: 0.01 },
{ key: 'sinkChance', label: 'Sink in Water', min: 0, max: 1.0, step: 0.05 }
],
oil: [
{ key: 'flowChance', label: 'Flow Rate', min: 0, max: 1.0, step: 0.05 },
{ key: 'dispersion', label: 'Spread Speed', min: 1, max: 8, step: 1 },
{ key: 'burnChance', label: 'Burn Rate', min: 0, max: 1.0, step: 0.01 },
{ key: 'sinkChance', label: 'Water Sink Rate', min: 0, max: 1.0, step: 0.05 },
{ key: 'floatChance', label: 'Oil Float Rate', min: 0, max: 1.0, step: 0.05 }
],
ice: [
{ key: 'meltChance', label: 'Melt Rate', min: 0, max: 1.0, step: 0.01 },
{ key: 'freezeChance', label: 'Freeze Rate', min: 0, max: 1.0, step: 0.01 }
2026-10-22 14:05:51 +00:00
],
steam: [
{ key: 'condenseChance', label: 'Condense Rate', min: 0, max: 1.0, step: 0.01 }
],
smoke: [
{ key: 'dissipateChance', label: 'Dissipate Rate', min: 0, max: 1.0, step: 0.01 }
],
2026-10-22 14:05:51 +00:00
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 }
],
grass: [
{ key: 'growChance', label: 'Growth Rate', min: 0, max: 1.0, step: 0.01 },
{ key: 'burnChance', label: 'Burn Rate', min: 0, max: 1.0, step: 0.05 }
],
2026-10-22 14:05:51 +00:00
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'] },
{ key: 'colorNoise', type: 'checkbox', label: 'Multi-Tone Shading' }
2026-10-22 14:05:51 +00:00
]
};
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);
2026-10-22 14:05:51 +00:00
settings.forEach(s => {
groupDiv.appendChild(addControl(groupName, s.key, s.subkey, s));
});
container.appendChild(groupDiv);
}
// Reset Defaults
2026-10-22 14:05:51 +00:00
const resetBtn = document.createElement('button');
resetBtn.className = 'reset-defaults-btn';
resetBtn.textContent = '↺ Reset to Defaults';
2026-10-22 14:05:51 +00:00
resetBtn.onclick = () => {
setConfig(JSON.parse(JSON.stringify(defaultConfig)));
setBrushSize(defaultConfig.brush.size);
2026-10-22 14:05:51 +00:00
saveSettings();
updateBrushUI();
2026-10-22 14:05:51 +00:00
renderSettings(container);
};
container.appendChild(resetBtn);
}
// Bresenham's line algorithm for continuous stroke drawing without gaps
export function handleInput(e, overrideType = null) {
2026-10-22 14:05:51 +00:00
const rect = canvas.getBoundingClientRect();
const typeToSpawn = overrideType || selectedType;
2026-10-22 14:05:51 +00:00
function getGridCoords(clientX, clientY) {
const x = clientX - rect.left;
const y = clientY - rect.top;
2026-10-22 14:05:51 +00:00
return {
col: Math.floor(x / cellSize),
row: Math.floor(y / cellSize)
2026-10-22 14:05:51 +00:00
};
}
function stampCircle(centerCol, centerRow) {
const r = brushSize;
const rSq = r * r;
for (let di = -r; di <= r; di++) {
for (let dj = -r; dj <= r; dj++) {
if (di * di + dj * dj <= rSq) {
const c = centerCol + di;
const ro = centerRow + dj;
if (c >= 0 && c < cols && ro >= 0 && ro < rows) {
if (r <= 2 || Math.random() > 0.08) {
spawnCell(c, ro, typeToSpawn);
}
}
2026-10-22 14:05:51 +00:00
}
}
}
}
function drawLine(p0, p1) {
let x0 = p0.col, y0 = p0.row;
let x1 = p1.col, y1 = p1.row;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1;
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
while (true) {
stampCircle(x0, y0);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 > -dy) {
err -= dy;
x0 += sx;
}
if (e2 < dx) {
err += dx;
y0 += sy;
}
}
}
2026-10-22 14:05:51 +00:00
if (e.type.startsWith('mouse')) {
const current = getGridCoords(e.clientX, e.clientY);
if (lastPos && (lastPos.col !== current.col || lastPos.row !== current.row)) {
drawLine(lastPos, current);
} else {
stampCircle(current.col, current.row);
}
lastPos = current;
2026-10-22 14:05:51 +00:00
} else if (e.type.startsWith('touch')) {
for (let i = 0; i < e.touches.length; i++) {
const t = e.touches[i];
const current = getGridCoords(t.clientX, t.clientY);
if (lastPos) {
drawLine(lastPos, current);
} else {
stampCircle(current.col, current.row);
}
lastPos = current;
2026-10-22 14:05:51 +00:00
}
}
}