parent
c49b69d9a7
commit
42d9505a6b
@ -1,21 +1,25 @@
|
||||
Conway's Game of Life - Frontend only
|
||||
# Conway's Game of Life - Frontend only
|
||||
|
||||
Files:
|
||||
|
||||
- index.html — main page
|
||||
- style.css — styles
|
||||
- script.js — game logic and UI
|
||||
|
||||
How to run:
|
||||
|
||||
1. Open `index.html` in your browser (double-click or use a simple static file server).
|
||||
2. Use the controls to Start/Stop, Step, Randomize, or Clear.
|
||||
3. Click or drag on the canvas to toggle cells. Adjust speed and cell size with sliders.
|
||||
|
||||
Notes:
|
||||
|
||||
- No backend required.
|
||||
- For best results, open the page via a local server (e.g., `python3 -m http.server`) if your browser blocks local file scripts.
|
||||
|
||||
New features:
|
||||
Features:
|
||||
|
||||
- Presets: select common patterns (Glider, Lightweight spaceship, Pulsar, Gosper glider gun) from the Presets dropdown.
|
||||
- Wrap edges: enable "Wrap edges" to use toroidal neighbor rules (cells at edges consider opposite edges as neighbors).
|
||||
- Save / Load: Save the current grid to your browser's localStorage (client-side only) and load it back later. The save is stored per-browser and per-machine.
|
||||
- Speed slider: slider direction matches natural expectation: left = slower, right = faster.
|
||||
- Speed slider: slider direction matches natural expectation: left = slower, right = faster.
|
||||
|
||||
@ -1,496 +0,0 @@
|
||||
// Music synthesis from Conway's Game of Life
|
||||
// Base class and multiple synthesis approaches
|
||||
|
||||
/**
|
||||
* Base class for all music synthesis approaches
|
||||
*/
|
||||
class MusicSynthesizer {
|
||||
constructor() {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
this.isPlaying = false;
|
||||
this.masterGain = this.audioContext.createGain();
|
||||
this.masterGain.connect(this.audioContext.destination);
|
||||
this.masterGain.gain.value = 0.3; // Prevent clipping
|
||||
|
||||
this.baseFrequency = 100; // Hz
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate music based on grid state - override in subclasses
|
||||
*/
|
||||
generateFromGrid(grid, cols, rows) {
|
||||
// To be implemented by subclasses
|
||||
}
|
||||
|
||||
play() {
|
||||
if (this.isPlaying) return;
|
||||
this.isPlaying = true;
|
||||
this.masterGain.gain.setValueAtTime(0.3, this.audioContext.currentTime);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
this.masterGain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
setBaseFrequency(frequency) {
|
||||
this.baseFrequency = frequency;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// To be implemented by subclasses
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Additive Synthesis: Each live cell generates a sine oscillator
|
||||
* Frequency based on cell position, creating harmonic textures
|
||||
*/
|
||||
class AdditiveSynthesizer extends MusicSynthesizer {
|
||||
constructor(superCellThreshold = 100, superCellMaxOscillators = 100) {
|
||||
super();
|
||||
this.oscillators = new Map(); // Map of "key" -> { osc, gain, frequency }
|
||||
this.lastUpdate = 0;
|
||||
this.liveCells = new Set(); // Tracks currently alive cells as "x,y"
|
||||
this.superCellThreshold = superCellThreshold;
|
||||
this.superCellMaxOscillators = superCellMaxOscillators;
|
||||
}
|
||||
|
||||
calculateFrequency(x, y, cols, rows) {
|
||||
const normalizedX = 1 - x / (cols - 1 || 1);
|
||||
const normalizedY = 1 - y / (rows - 1 || 1);
|
||||
|
||||
const octaveOffset = normalizedY * 3;
|
||||
const noteOffset = normalizedX * 12;
|
||||
|
||||
return this.baseFrequency * Math.pow(2, octaveOffset + noteOffset / 12);
|
||||
}
|
||||
|
||||
generateFromGrid(grid, cols, rows) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
// Throttle updates ~40Hz
|
||||
const now = performance.now();
|
||||
if (now - this.lastUpdate < 25) return;
|
||||
this.lastUpdate = now;
|
||||
|
||||
const newLiveCells = new Set();
|
||||
|
||||
// Only iterate the sparse live cells
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (grid[y][x]) newLiveCells.add(`${x},${y}`);
|
||||
}
|
||||
}
|
||||
|
||||
const activeCellCount = newLiveCells.size;
|
||||
const useSuperCells = activeCellCount > this.superCellThreshold;
|
||||
|
||||
let superCellSize = 1;
|
||||
const superCells = new Map();
|
||||
|
||||
if (useSuperCells) {
|
||||
// Determine super-cell size to limit total oscillators
|
||||
superCellSize = Math.ceil(Math.sqrt((cols * rows) / this.superCellMaxOscillators));
|
||||
|
||||
for (const cellKey of newLiveCells) {
|
||||
const [x, y] = cellKey.split(',').map(Number);
|
||||
const sx = Math.floor(x / superCellSize);
|
||||
const sy = Math.floor(y / superCellSize);
|
||||
const sKey = `${sx},${sy}`;
|
||||
superCells.set(sKey, (superCells.get(sKey) || 0) + 1);
|
||||
}
|
||||
} else {
|
||||
// In sparse mode, each live cell becomes its own "super cell"
|
||||
for (const cellKey of newLiveCells) {
|
||||
superCells.set(cellKey, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Stop oscillators for empty keys
|
||||
const toDelete = [];
|
||||
for (const [key, { osc, gain }] of this.oscillators) {
|
||||
if (!superCells.has(key)) {
|
||||
gain.gain.cancelScheduledValues(this.audioContext.currentTime);
|
||||
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
|
||||
osc.stop(this.audioContext.currentTime + 0.05);
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
for (const key of toDelete) this.oscillators.delete(key);
|
||||
|
||||
// Create/update oscillators
|
||||
for (const [key, count] of superCells) {
|
||||
const [sx, sy] = key.split(',').map(Number);
|
||||
|
||||
const cx = useSuperCells
|
||||
? sx * superCellSize + superCellSize / 2
|
||||
: sx;
|
||||
const cy = useSuperCells
|
||||
? sy * superCellSize + superCellSize / 2
|
||||
: sy;
|
||||
|
||||
const frequency = this.calculateFrequency(cx, cy, cols, rows);
|
||||
|
||||
const gainValue = useSuperCells
|
||||
? Math.min(0.3, Math.sqrt(count / (superCellSize * superCellSize)) * 0.3)
|
||||
: 0.3 / Math.sqrt(activeCellCount);
|
||||
|
||||
if (!this.oscillators.has(key)) {
|
||||
const osc = this.audioContext.createOscillator();
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = frequency;
|
||||
|
||||
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
|
||||
gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
osc.start();
|
||||
|
||||
this.oscillators.set(key, { osc, gain, frequency });
|
||||
} else {
|
||||
const { osc, gain } = this.oscillators.get(key);
|
||||
|
||||
if (Math.abs(osc.frequency.value - frequency) > 0.1) {
|
||||
osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.05);
|
||||
this.oscillators.get(key).frequency = frequency;
|
||||
}
|
||||
if (Math.abs(gain.gain.value - gainValue) > 0.01) {
|
||||
gain.gain.setTargetAtTime(gainValue, this.audioContext.currentTime, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update liveCells set
|
||||
this.liveCells = newLiveCells;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const [, { osc, gain }] of this.oscillators) {
|
||||
gain.gain.cancelScheduledValues(this.audioContext.currentTime);
|
||||
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
|
||||
osc.stop(this.audioContext.currentTime + 0.1);
|
||||
}
|
||||
this.oscillators.clear();
|
||||
this.liveCells.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Frequency Grid: Map rows to frequencies, columns to amplitude modulation
|
||||
* Creates a more melodic, wavetable-like sound
|
||||
*/
|
||||
class FrequencyGridSynthesizer extends MusicSynthesizer {
|
||||
constructor() {
|
||||
super();
|
||||
this.oscillators = new Map(); // Map of row index -> { osc, gain }
|
||||
}
|
||||
|
||||
generateFromGrid(grid, cols, rows) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
const activeCells = new Set();
|
||||
|
||||
// Find which rows have active cells
|
||||
for (let y = 0; y < rows; y++) {
|
||||
let rowHasLife = false;
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (grid[y][x]) {
|
||||
rowHasLife = true;
|
||||
activeCells.add(y);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop oscillators for rows that are now empty
|
||||
for (const [rowKey, { osc, gain }] of this.oscillators) {
|
||||
if (!activeCells.has(parseInt(rowKey))) {
|
||||
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
|
||||
setTimeout(() => {
|
||||
osc.stop();
|
||||
}, 100);
|
||||
this.oscillators.delete(rowKey);
|
||||
}
|
||||
}
|
||||
|
||||
// global grid density
|
||||
const density = activeCells.size / (rows * cols);
|
||||
|
||||
// compensation: louder when overall density is low, neutral when medium
|
||||
const globalBoost = Math.min(1 / Math.sqrt(density), 10);
|
||||
|
||||
// Create or update oscillators for rows with life
|
||||
for (const row of activeCells) {
|
||||
const frequency = this.baseFrequency * Math.pow(2, (1 - row / (rows - 1 || 1)) * 3);
|
||||
|
||||
// Count density in this row for amplitude
|
||||
let cellCount = 0;
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (grid[row][x]) cellCount++;
|
||||
}
|
||||
const amplitude = Math.min((cellCount / cols) * globalBoost, 0.1);
|
||||
|
||||
if (!this.oscillators.has(row)) {
|
||||
const osc = this.audioContext.createOscillator();
|
||||
const gain = this.audioContext.createGain();
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = frequency;
|
||||
|
||||
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
|
||||
gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
osc.start();
|
||||
|
||||
this.oscillators.set(row, { osc, gain });
|
||||
} else {
|
||||
const { osc, gain } = this.oscillators.get(row);
|
||||
osc.frequency.setTargetAtTime(frequency, this.audioContext.currentTime, 0.1);
|
||||
gain.gain.setTargetAtTime(amplitude, this.audioContext.currentTime, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const [, { osc, gain }] of this.oscillators) {
|
||||
gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.1);
|
||||
setTimeout(() => {
|
||||
osc.stop();
|
||||
}, 150);
|
||||
}
|
||||
this.oscillators.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Granular Synthesis: Each cell generates a brief burst/grain
|
||||
* Creates glitchy, textured, evolving sounds
|
||||
*/
|
||||
class GranularSynthesizer extends MusicSynthesizer {
|
||||
constructor() {
|
||||
super();
|
||||
this.liveCells = new Set(); // currently alive cells
|
||||
}
|
||||
|
||||
generateFromGrid(grid, cols, rows) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
const newLiveCells = new Set();
|
||||
const triggeredCells = [];
|
||||
|
||||
// Only loop over the entire grid to detect transitions
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const key = `${x},${y}`;
|
||||
const isAlive = grid[y][x] === 1;
|
||||
|
||||
if (isAlive) newLiveCells.add(key);
|
||||
|
||||
const wasAlive = this.liveCells.has(key);
|
||||
if (isAlive && !wasAlive) triggeredCells.push([x, y]);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate per-grain volume based on number of grains this update
|
||||
const grainCount = triggeredCells.length || 1; // avoid division by zero
|
||||
const gainPerGrain = Math.min(0.2, 0.5 / Math.sqrt(grainCount));
|
||||
|
||||
for (const [x, y] of triggeredCells) {
|
||||
this.triggerGrain(x, y, cols, rows, gainPerGrain);
|
||||
}
|
||||
|
||||
// Update live cells set
|
||||
this.liveCells = newLiveCells;
|
||||
}
|
||||
|
||||
triggerGrain(x, y, cols, rows, gainValue) {
|
||||
const frequency = this.baseFrequency * Math.pow(
|
||||
2,
|
||||
(1 - y / (rows - 1 || 1)) * 3 + (1 - x / (cols - 1 || 1))
|
||||
);
|
||||
const grainDuration = 0.1; // 100ms
|
||||
|
||||
const osc = this.audioContext.createOscillator();
|
||||
const gain = this.audioContext.createGain();
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
osc.frequency.value = frequency;
|
||||
osc.type = 'sine';
|
||||
|
||||
// Envelope: fade in, hold, fade out
|
||||
gain.gain.setValueAtTime(0, now);
|
||||
gain.gain.linearRampToValueAtTime(gainValue, now + 0.01);
|
||||
gain.gain.linearRampToValueAtTime(gainValue, now + grainDuration - 0.02);
|
||||
gain.gain.linearRampToValueAtTime(0, now + grainDuration);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + grainDuration);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.liveCells.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class PolyrhythmSynthesizer extends MusicSynthesizer {
|
||||
constructor() {
|
||||
super();
|
||||
this.oscillatorPool = [];
|
||||
this.activeOscillators = [];
|
||||
this.maxOscillators = 50;
|
||||
this._initOscillatorPool();
|
||||
|
||||
// Simple delay for echo effect
|
||||
this.delayNode = this.audioContext.createDelay();
|
||||
this.delayNode.delayTime.value = 0.2; // 200ms echo
|
||||
this.delayGain = this.audioContext.createGain();
|
||||
this.delayGain.gain.value = 0.25; // echo volume
|
||||
this.delayNode.connect(this.delayGain);
|
||||
this.delayGain.connect(this.masterGain);
|
||||
}
|
||||
|
||||
_initOscillatorPool() {
|
||||
for (let i = 0; i < this.maxOscillators; i++) {
|
||||
const osc = this.audioContext.createOscillator();
|
||||
const gain = this.audioContext.createGain();
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0, this.audioContext.currentTime);
|
||||
osc.connect(gain);
|
||||
gain.connect(this.masterGain);
|
||||
osc.start();
|
||||
this.oscillatorPool.push({ osc, gain, inUse: false });
|
||||
}
|
||||
}
|
||||
|
||||
generateFromGrid(grid, cols, rows) {
|
||||
if (!this.isPlaying) return;
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
const rowCells = [];
|
||||
for (let x = 0; x < cols; x++) if (grid[y][x]) rowCells.push(x);
|
||||
if (!rowCells.length) continue;
|
||||
|
||||
const rowLength = (rows - y) + 2;
|
||||
for (const x of rowCells) {
|
||||
const triggerProb = Math.min(1, 0.3 * rowLength / rowCells.length);
|
||||
if (Math.random() < triggerProb) this.triggerNote(y, rowCells.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
triggerNote(row, activeCellsInRow) {
|
||||
const freeOsc = this.oscillatorPool.find(o => !o.inUse);
|
||||
if (!freeOsc) return;
|
||||
freeOsc.inUse = true;
|
||||
|
||||
const now = this.audioContext.currentTime;
|
||||
const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
|
||||
freeOsc.osc.frequency.setValueAtTime(freq, now);
|
||||
|
||||
const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
|
||||
|
||||
// Calculate attack and release times based on frequency
|
||||
const noteDuration = 1 + (6 - row) * 0.2; // Longer duration for lower notes
|
||||
const attackTime = 0.05;
|
||||
const releaseTime = noteDuration - attackTime;
|
||||
|
||||
// Smoother fade-in/out with dynamic times
|
||||
freeOsc.gain.gain.cancelScheduledValues(now);
|
||||
freeOsc.gain.gain.setValueAtTime(0, now);
|
||||
freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + attackTime);
|
||||
freeOsc.gain.gain.exponentialRampToValueAtTime(0.01, now + releaseTime);
|
||||
|
||||
// Optional echo: duplicate into delay node
|
||||
const delayGain = this.audioContext.createGain();
|
||||
delayGain.gain.value = 0.2;
|
||||
freeOsc.osc.connect(delayGain);
|
||||
delayGain.connect(this.delayNode);
|
||||
|
||||
// Free oscillator after envelope finishes
|
||||
setTimeout(() => {
|
||||
freeOsc.inUse = false;
|
||||
delayGain.disconnect();
|
||||
}, noteDuration * 1000); // Adjusted to match the new decay time
|
||||
}
|
||||
/*triggerNote(row, activeCellsInRow) {
|
||||
const freeOsc = this.oscillatorPool.find(o => !o.inUse);
|
||||
if (!freeOsc) return;
|
||||
freeOsc.inUse = true;
|
||||
|
||||
const now = this.audioContext.currentTime;
|
||||
const freq = this.baseFrequency * Math.pow(2, (1 - row / 8) * 4); // wider pitch spread
|
||||
freeOsc.osc.frequency.setValueAtTime(freq, now);
|
||||
|
||||
const gainValue = Math.min(0.3, 0.5 / Math.sqrt(activeCellsInRow));
|
||||
|
||||
// Long envelope with smoother fade in/out
|
||||
freeOsc.gain.gain.cancelScheduledValues(now);
|
||||
freeOsc.gain.gain.setValueAtTime(0, now);
|
||||
freeOsc.gain.gain.linearRampToValueAtTime(gainValue, now + 0.05);
|
||||
freeOsc.gain.gain.linearRampToValueAtTime(0.01, now + 0.4); // ~400ms note duration
|
||||
|
||||
// Optional echo: duplicate into delay node
|
||||
const delayGain = this.audioContext.createGain();
|
||||
delayGain.gain.value = 0.2;
|
||||
freeOsc.osc.connect(delayGain);
|
||||
delayGain.connect(this.delayNode);
|
||||
|
||||
// Free oscillator after envelope finishes
|
||||
setTimeout(() => {
|
||||
freeOsc.inUse = false;
|
||||
delayGain.disconnect();
|
||||
}, 450);
|
||||
}*/
|
||||
|
||||
cleanup() {
|
||||
for (const oscObj of this.oscillatorPool) {
|
||||
oscObj.gain.gain.setTargetAtTime(0, this.audioContext.currentTime, 0.05);
|
||||
oscObj.inUse = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create global instance using Additive Synthesis by default
|
||||
let lifeMusic = new AdditiveSynthesizer();
|
||||
|
||||
// Function to switch synthesis approaches
|
||||
function setSynthesisMode(mode) {
|
||||
const wasPlaying = lifeMusic.isPlaying;
|
||||
|
||||
if (wasPlaying) {
|
||||
lifeMusic.stop();
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'additive':
|
||||
lifeMusic = new AdditiveSynthesizer();
|
||||
break;
|
||||
case 'frequencyGrid':
|
||||
lifeMusic = new FrequencyGridSynthesizer();
|
||||
break;
|
||||
case 'granular':
|
||||
lifeMusic = new GranularSynthesizer();
|
||||
break;
|
||||
case 'polyrhythm':
|
||||
lifeMusic = new PolyrhythmSynthesizer();
|
||||
break;
|
||||
default:
|
||||
lifeMusic = new AdditiveSynthesizer();
|
||||
}
|
||||
|
||||
if (wasPlaying) {
|
||||
lifeMusic.play();
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
@ -0,0 +1,244 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--canvas-bg: #071020;
|
||||
--muted: rgba(255, 255, 255, 0.06);
|
||||
--text: #e6eef8;
|
||||
--accent: #7cb7ff;
|
||||
--panel: rgba(255, 255, 255, 0.02);
|
||||
--radius: 12px;
|
||||
--gap: 14px;
|
||||
--max-width: 1200px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: var(--max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap)
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 20px
|
||||
}
|
||||
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 20px
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap)
|
||||
}
|
||||
|
||||
@media(min-width:980px) {
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 22px;
|
||||
height: calc(100vh - 44px);
|
||||
overflow: auto
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
#runState::before, .recording-indicator::before {
|
||||
content: "● ";
|
||||
}
|
||||
|
||||
#runState.running {
|
||||
color: #7cb7ff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel.primary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center
|
||||
}
|
||||
|
||||
.panel.secondary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--muted);
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
font-size: 13px
|
||||
}
|
||||
|
||||
label.inline {
|
||||
display: flex;
|
||||
gap: 8px
|
||||
}
|
||||
|
||||
#lifeCanvas {
|
||||
width: 100%;
|
||||
height: min(72vh, 900px);
|
||||
background: var(--canvas-bg);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
opacity: .8
|
||||
}
|
||||
|
||||
.recording-indicator {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #ff5f5f;
|
||||
animation: pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1 }
|
||||
50% { opacity: 0.4 }
|
||||
100% { opacity: 1 }
|
||||
}
|
||||
|
||||
|
||||
@media(max-width:980px) {
|
||||
.app-grid {
|
||||
grid-template-columns: 1fr
|
||||
}
|
||||
|
||||
#lifeCanvas {
|
||||
height: 60vh
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Collapsible panels ---------- */
|
||||
|
||||
.collapsible .collapse-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.collapsible .collapsible-content {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.collapsible.collapsed .collapsible-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse-toggle::before {
|
||||
content: "↑ ";
|
||||
}
|
||||
|
||||
.collapsible.collapsed .collapse-toggle::before {
|
||||
content: "↓ ";
|
||||
}
|
||||
|
||||
/* ---------- Full canvas mode ---------- */
|
||||
|
||||
.fullscreen-toggle {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
body.canvas-only .sidebar,
|
||||
body.canvas-only header,
|
||||
body.canvas-only footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.canvas-only .canvas-area {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body.canvas-only #lifeCanvas {
|
||||
height: calc(100vh - 80px);
|
||||
}
|
||||
|
||||
body.canvas-only .app-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Slider alignment */
|
||||
.slider-row {
|
||||
display: grid;
|
||||
grid-template-columns: 6em 1fr;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3ch;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Prevent output from resizing */
|
||||
.slider-control output {
|
||||
display: inline-block;
|
||||
width: 2ch;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@ -1,179 +0,0 @@
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--canvas-bg: #071020;
|
||||
--text: #e6eef8;
|
||||
--label: #cfe7ff;
|
||||
--button-bg: #0b1220;
|
||||
--footer: #9bb8d9;
|
||||
--border: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
#status {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
#runState::before {
|
||||
content: "● ";
|
||||
}
|
||||
|
||||
#runState.running {
|
||||
color: #7cb7ff;
|
||||
}
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel.primary {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.panel.secondary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: rgba(255,255,255,0.015);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
/* ---------- Inputs ---------- */
|
||||
|
||||
button {
|
||||
background: var(--button-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
select,
|
||||
input[type="range"] {
|
||||
background: var(--button-bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
label.inline {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
output {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
#modeHint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ---------- Canvas ---------- */
|
||||
|
||||
#lifeCanvas {
|
||||
width: 100%;
|
||||
height: 70vh;
|
||||
display: block;
|
||||
background: var(--canvas-bg);
|
||||
border: 1px solid var(--border);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
|
||||
footer {
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
color: var(--footer);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Recording -------- */
|
||||
.recording-indicator {
|
||||
margin-left: 8px;
|
||||
font-size: 13px;
|
||||
color: #ff5f5f;
|
||||
animation: pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1 }
|
||||
50% { opacity: 0.4 }
|
||||
100% { opacity: 1 }
|
||||
}
|
||||
Loading…
Reference in new issue