You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
434 lines
14 KiB
434 lines
14 KiB
// Simple Conway's Game of Life - frontend only
|
|
const canvas = document.getElementById('lifeCanvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const startBtn = document.getElementById('startBtn');
|
|
const stepBtn = document.getElementById('stepBtn');
|
|
const randomBtn = document.getElementById('randomBtn');
|
|
const clearBtn = document.getElementById('clearBtn');
|
|
const speedRange = document.getElementById('speedRange');
|
|
const cellSizeRange = document.getElementById('cellSizeRange');
|
|
const generationEl = document.getElementById('generation');
|
|
const presetSelect = document.getElementById('presetSelect');
|
|
const wrapCheckbox = document.getElementById('wrapCheckbox');
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
const loadBtn = document.getElementById('loadBtn');
|
|
const baseFrequencyRange = document.getElementById('baseFrequencyRange');
|
|
const volumeRange = document.getElementById('volumeRange');
|
|
const musicModeSelect = document.getElementById('musicModeSelect');
|
|
|
|
const { startRecording, stopRecording, recordingInputNode } = window.recording;
|
|
|
|
let cellSize = parseInt(cellSizeRange.value, 10);
|
|
let cols, rows;
|
|
let grid;
|
|
let running = false;
|
|
let timer = null;
|
|
let generation = 0;
|
|
let isMouseDown = false;
|
|
let drawMode = 'toggle'; // 'on' 'off' 'toggle'
|
|
let wrap = false;
|
|
let offsetX = 0;
|
|
let offsetY = 0;
|
|
let centerCellX = 0; // Track the logical center cell for stable zooming
|
|
let centerCellY = 0;
|
|
|
|
let currentCell = null;
|
|
let previousCell = null;
|
|
|
|
function resizeCanvas() {
|
|
// fit the canvas to its displayed size
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = Math.floor(rect.width);
|
|
canvas.height = Math.floor(rect.height);
|
|
|
|
cols = Math.floor(canvas.width / cellSize);
|
|
rows = Math.floor(canvas.height / cellSize);
|
|
|
|
// Calculate offsets to center the grid
|
|
const gridWidth = cols * cellSize;
|
|
const gridHeight = rows * cellSize;
|
|
offsetX = (canvas.width - gridWidth) / 2;
|
|
offsetY = (canvas.height - gridHeight) / 2;
|
|
}
|
|
|
|
function makeGrid(empty = true) {
|
|
const g = new Array(rows);
|
|
for (let y = 0; y < rows; y++) {
|
|
g[y] = new Array(cols).fill(0);
|
|
}
|
|
if (!empty) {
|
|
for (let y = 0; y < rows; y++)
|
|
for (let x = 0; x < cols; x++)
|
|
g[y][x] = Math.random() < 0.2 ? 1 : 0;
|
|
}
|
|
return g;
|
|
}
|
|
|
|
function drawGrid() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#062033';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.fillStyle = '#7ee0ff';
|
|
|
|
for (let y = 0; y < rows; y++) {
|
|
for (let x = 0; x < cols; x++) {
|
|
if (grid[y][x]) {
|
|
ctx.fillRect(offsetX + x * cellSize, offsetY + y * cellSize, cellSize - 1, cellSize - 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function countNeighbors(x, y) {
|
|
let count = 0;
|
|
for (let oy = -1; oy <= 1; oy++) {
|
|
for (let ox = -1; ox <= 1; ox++) {
|
|
if (ox === 0 && oy === 0) continue;
|
|
let nx = x + ox;
|
|
let ny = y + oy;
|
|
if (wrap) {
|
|
nx = ((nx % cols) + cols) % cols;
|
|
ny = ((ny % rows) + rows) % rows;
|
|
count += grid[ny][nx];
|
|
} else {
|
|
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
|
|
count += grid[ny][nx];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function step() {
|
|
const next = makeGrid(true);
|
|
for (let y = 0; y < rows; y++) {
|
|
for (let x = 0; x < cols; x++) {
|
|
let count = countNeighbors(x, y);
|
|
if (grid[y][x]) {
|
|
next[y][x] = (count === 2 || count === 3) ? 1 : 0;
|
|
} else {
|
|
next[y][x] = (count === 3) ? 1 : 0;
|
|
}
|
|
}
|
|
}
|
|
grid = next;
|
|
generation++;
|
|
generationEl.textContent = 'Generation: ' + generation;
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
drawGrid();
|
|
}
|
|
|
|
function runInterval() {
|
|
if (!running) return;
|
|
// If a timer is already scheduled, don't clear/reset it here — let the pending tick happen,
|
|
// the loop reads the current speed each time so new speed will take effect on the next interval
|
|
if (timer) return;
|
|
|
|
const loop = () => {
|
|
if (!running) return;
|
|
step();
|
|
const v = parseInt(speedRange.value, 10);
|
|
const ms = Math.max(10, Math.round(1550 - v));
|
|
timer = setTimeout(loop, ms);
|
|
};
|
|
|
|
const v = parseInt(speedRange.value, 10);
|
|
const ms = Math.max(10, Math.round(1550 - v));
|
|
timer = setTimeout(loop, ms);
|
|
}
|
|
|
|
async function start() {
|
|
setSynthesisMode(musicModeSelect.value);
|
|
|
|
if (!running) {
|
|
running = true;
|
|
startBtn.textContent = 'Stop';
|
|
runInterval();
|
|
|
|
if (!lifeMusic.initialized) {
|
|
await lifeMusic.init();
|
|
}
|
|
|
|
// Required by browser autoplay policy
|
|
//await lifeMusic.audioContext.resume();
|
|
|
|
lifeMusic.play();
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
|
|
} else {
|
|
running = false;
|
|
startBtn.textContent = 'Start';
|
|
|
|
if (timer) clearInterval(timer);
|
|
timer = null;
|
|
|
|
lifeMusic.stop();
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
}
|
|
|
|
// GUI
|
|
updateRunState();
|
|
}
|
|
|
|
|
|
function clearGrid() {
|
|
grid = makeGrid(true);
|
|
generation = 0;
|
|
generationEl.textContent = 'Generation: 0';
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
drawGrid();
|
|
}
|
|
|
|
function randomize() {
|
|
grid = makeGrid(false);
|
|
generation = 0;
|
|
generationEl.textContent = 'Generation: 0';
|
|
drawGrid();
|
|
}
|
|
|
|
function canvasToCell(clientX, clientY) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const cx = clientX - rect.left;
|
|
const cy = clientY - rect.top;
|
|
const cellX = Math.floor((cx - offsetX) / cellSize);
|
|
const cellY = Math.floor((cy - offsetY) / cellSize);
|
|
return { cellX, cellY };
|
|
}
|
|
|
|
function cellChanged(cellX, cellY) {
|
|
if (currentCell === null || currentCell.x !== cellX || currentCell.y !== cellY) {
|
|
previousCell = currentCell;
|
|
currentCell = { x: cellX, y: cellY };
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
isMouseDown = true;
|
|
const { cellX, cellY } = canvasToCell(e.clientX, e.clientY);
|
|
if (cellX < 0 || cellX >= cols || cellY < 0 || cellY >= rows) return;
|
|
|
|
// Only toggle on left click (button 0), right click (button 2) will erase on drag
|
|
if (e.button === 0) {
|
|
grid[cellY][cellX] = grid[cellY][cellX] ? 0 : 1;
|
|
drawGrid();
|
|
} else if (e.button === 2) {
|
|
grid[cellY][cellX] = 0;
|
|
drawGrid();
|
|
}
|
|
});
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
const { cellX, cellY } = canvasToCell(e.clientX, e.clientY);
|
|
if (cellX < 0 || cellX >= cols || cellY < 0 || cellY >= rows) return;
|
|
|
|
let isDifferentCell = cellChanged(cellX, cellY);
|
|
|
|
if (!isMouseDown || !isDifferentCell) return;
|
|
|
|
// paint with left button, erase with right button; prevent default on right-button drag
|
|
if (e.buttons === 1) {
|
|
grid[cellY][cellX] = 1;
|
|
} else if (e.buttons === 2) {
|
|
grid[cellY][cellX] = 0;
|
|
}
|
|
drawGrid();
|
|
});
|
|
|
|
canvas.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
canvas.addEventListener('wheel', (e) => {
|
|
e.preventDefault();
|
|
|
|
const minSize = parseInt(cellSizeRange.min, 10);
|
|
const maxSize = parseInt(cellSizeRange.max, 10);
|
|
|
|
// Calculate new cell size based on scroll direction
|
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Scroll down = zoom out, scroll up = zoom in
|
|
const newCellSize = Math.max(minSize, Math.min(maxSize, Math.round(cellSize * zoomFactor)));
|
|
|
|
if (newCellSize === cellSize) return; // No change
|
|
|
|
cellSize = newCellSize;
|
|
cellSizeRange.value = cellSize;
|
|
cellSizeRange.dispatchEvent(new Event('input')); // Trigger the input event to handle zoom
|
|
}, { passive: false });
|
|
|
|
window.addEventListener('mouseup', () => { isMouseDown = false; });
|
|
|
|
startBtn.addEventListener('click', start);
|
|
stepBtn.addEventListener('click', () => { step(); });
|
|
clearBtn.addEventListener('click', clearGrid);
|
|
randomBtn.addEventListener('click', randomize);
|
|
speedRange.addEventListener('input', () => { if (running) runInterval(); });
|
|
cellSizeRange.addEventListener('input', () => {
|
|
const oldCols = cols;
|
|
const oldRows = rows;
|
|
const oldGrid = grid;
|
|
|
|
cellSize = parseInt(cellSizeRange.value, 10);
|
|
resizeCanvas(); // This recalculates cols, rows, and offsets based on new cellSize
|
|
|
|
// Create new grid with potentially different dimensions
|
|
const newGrid = makeGrid(true);
|
|
|
|
// Calculate offset to keep the same logical center point
|
|
// Use floor consistently to prevent drift
|
|
const offsetX = Math.floor((cols - oldCols) / 2);
|
|
const offsetY = Math.floor((rows - oldRows) / 2);
|
|
|
|
// Copy old grid cells to new grid, centered
|
|
for (let y = 0; y < oldRows; y++) {
|
|
for (let x = 0; x < oldCols; x++) {
|
|
const newX = x + offsetX;
|
|
const newY = y + offsetY;
|
|
// Only copy if the new position is within bounds
|
|
if (newX >= 0 && newX < cols && newY >= 0 && newY < rows) {
|
|
newGrid[newY][newX] = oldGrid[y][x];
|
|
}
|
|
}
|
|
}
|
|
|
|
grid = newGrid;
|
|
drawGrid();
|
|
});
|
|
|
|
wrapCheckbox.addEventListener('change', (e) => { wrap = e.target.checked; });
|
|
|
|
/*const musicPlayBtn = document.getElementById('musicPlayBtn');
|
|
const musicStopBtn = document.getElementById('musicStopBtn');
|
|
|
|
musicPlayBtn.addEventListener('click', () => {
|
|
lifeMusic.play();
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
});
|
|
|
|
musicStopBtn.addEventListener('click', () => {
|
|
lifeMusic.stop();
|
|
lifeMusic.generateFromGrid(grid, cols, rows);
|
|
});*/
|
|
|
|
volumeRange.addEventListener('input', (e) => {
|
|
const minGain = 0.001; // avoid log(0)
|
|
const maxGain = 1; // maximum master gain
|
|
const position = e.target.value / 100; // 0..1
|
|
|
|
// logarithmic mapping
|
|
//const gain = minGain * Math.pow(maxGain / minGain, position);
|
|
const gain = position;
|
|
|
|
lifeMusic.masterGain.gain.setValueAtTime(gain, lifeMusic.audioContext.currentTime);
|
|
});
|
|
|
|
|
|
musicModeSelect.addEventListener('change', async (e) => {
|
|
setSynthesisMode(e.target.value);
|
|
//await start();
|
|
});
|
|
|
|
presetSelect.addEventListener('change', (e) => {
|
|
const v = e.target.value;
|
|
if (!v) return;
|
|
applyPreset(v);
|
|
e.target.value = '';
|
|
});
|
|
|
|
saveBtn.addEventListener('click', () => {
|
|
try {
|
|
const payload = { cols, rows, grid, cellSize, generation };
|
|
localStorage.setItem('life-save', JSON.stringify(payload));
|
|
alert('Saved to local storage.');
|
|
} catch (err) { alert('Save failed: ' + err); }
|
|
});
|
|
|
|
//window.addEventListener("DOMContentLoaded", () => {
|
|
// window.recording.init(); // no context yet
|
|
//});
|
|
|
|
|
|
loadBtn.addEventListener('click', () => {
|
|
try {
|
|
const data = localStorage.getItem('life-save');
|
|
if (!data) { alert('No saved pattern in local storage.'); return; }
|
|
const obj = JSON.parse(data);
|
|
// if dimensions differ, create new grid and center loaded pattern
|
|
if (obj.cols === cols && obj.rows === rows) {
|
|
grid = obj.grid;
|
|
} else {
|
|
const newGrid = makeGrid(true);
|
|
const offY = Math.floor((rows - obj.rows) / 2);
|
|
const offX = Math.floor((cols - obj.cols) / 2);
|
|
for (let y = 0; y < Math.min(rows, obj.rows); y++)
|
|
for (let x = 0; x < Math.min(cols, obj.cols); x++)
|
|
newGrid[y + Math.max(0, offY)][x + Math.max(0, offX)] = obj.grid[y][x];
|
|
grid = newGrid;
|
|
}
|
|
generation = obj.generation || 0;
|
|
generationEl.textContent = 'Generation: ' + generation;
|
|
drawGrid();
|
|
} catch (err) { alert('Load failed: ' + err); }
|
|
});
|
|
|
|
baseFrequencyRange.addEventListener('input', (e) => {
|
|
const newBaseFrequency = parseInt(e.target.value, 10);
|
|
lifeMusic.setBaseFrequency(newBaseFrequency);
|
|
});
|
|
|
|
function applyPreset(name) {
|
|
clearGrid();
|
|
const midY = Math.floor(rows / 2);
|
|
const midX = Math.floor(cols / 2);
|
|
if (name === 'glider') {
|
|
const p = [[0, 1, 0], [0, 0, 1], [1, 1, 1]]; // glider
|
|
for (let y = 0; y < p.length; y++) for (let x = 0; x < p[y].length; x++) grid[midY + y][midX + x] = p[y][x];
|
|
} else if (name === 'lwss') {
|
|
const p = [
|
|
[0, 1, 1, 1, 1],
|
|
[1, 0, 0, 0, 1],
|
|
[0, 0, 0, 0, 1],
|
|
[1, 0, 0, 1, 0]
|
|
];
|
|
for (let y = 0; y < p.length; y++) for (let x = 0; x < p[y].length; x++) grid[midY + y][midX + x] = p[y][x];
|
|
} else if (name === 'gosper') {
|
|
// Gosper glider gun pattern hard-coded relative points
|
|
const pts = [
|
|
[0, 4], [0, 5], [1, 4], [1, 5], [10, 4], [10, 5], [10, 6], [11, 3], [11, 7], [12, 2], [12, 8], [13, 2], [13, 8], [14, 5], [15, 3], [15, 7], [16, 4], [16, 5], [16, 6], [17, 5], [20, 2], [20, 3], [20, 4], [21, 2], [21, 3], [21, 4], [22, 1], [22, 5], [24, 0], [24, 1], [24, 5], [24, 6], [34, 2], [34, 3], [35, 2], [35, 3]
|
|
];
|
|
for (const [dx, dy] of pts) {
|
|
const x = midX + dx - 10;
|
|
const y = midY + dy - 5;
|
|
if (y >= 0 && y < rows && x >= 0 && x < cols) grid[y][x] = 1;
|
|
}
|
|
}
|
|
drawGrid();
|
|
}
|
|
|
|
function init() {
|
|
resizeCanvas();
|
|
grid = makeGrid(true);
|
|
centerCellX = cols / 2; // Track the logical center
|
|
centerCellY = rows / 2;
|
|
generation = 0;
|
|
generationEl.textContent = 'Generation: 0';
|
|
drawGrid();
|
|
}
|
|
|
|
window.addEventListener('resize', () => {
|
|
// preserve approximate pattern when resizing by copying to a new grid
|
|
const oldCols = cols, oldRows = rows, old = grid;
|
|
resizeCanvas();
|
|
const newGrid = makeGrid(true);
|
|
for (let y = 0; y < Math.min(oldRows, rows); y++)
|
|
for (let x = 0; x < Math.min(oldCols, cols); x++)
|
|
newGrid[y][x] = old[y][x];
|
|
grid = newGrid;
|
|
drawGrid();
|
|
});
|
|
|
|
wrap = wrapCheckbox.checked;
|
|
init();
|