Implement Game of Life feature with card flip UI and simulation controls
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m30s

This commit is contained in:
Cameron Redmore 2025-08-12 11:48:59 +01:00
parent 7e6195eb2c
commit 412fbc37f7
No known key found for this signature in database
4 changed files with 816 additions and 89 deletions

View file

@ -26,6 +26,8 @@
<script type="module"> <script type="module">
import '~icons/mdi/github' import '~icons/mdi/github'
import '~icons/mdi/git' import '~icons/mdi/git'
import '~icons/mdi/orbit-variant'
import '/src/gameoflife.ts'
import '/src/client.ts' import '/src/client.ts'
</script> </script>
@ -35,109 +37,150 @@
<div class="shape shape-3"></div> <div class="shape shape-3"></div>
<!-- Main container --> <!-- Main container -->
<main class="min-h-screen w-full flex items-center justify-center p-4 sm:p-6 lg:p-8"> <main class="min-h-screen w-full flex items-center justify-center p-4 sm:p-6 lg:p-8 card-container">
<!-- The Glass Card --> <!-- The Glass Card -->
<div class="glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10"> <div class="card-flipper w-full max-w-4xl">
<div class="flex flex-col md:flex-row items-center md:space-x-10"> <div class="card-front glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10">
<icon-mdi-orbit-variant
class="absolute top-0 right-0 m-3 p-1 text-3xl rounded-full cursor-pointer hover-glass"></icon-mdi-orbit-variant>
<div class="flex flex-col md:flex-row items-center md:space-x-10">
<!-- Left Column: Profile Picture and Social Links --> <!-- Left Column: Profile Picture and Social Links -->
<div class="flex-shrink-0 text-center mb-6 md:mb-0"> <div class="flex-shrink-0 text-center mb-6 md:mb-0">
<img src="/avatar.webp" alt="Cameron B. R. Redmore" <img src="/avatar.webp" alt="Cameron B. R. Redmore"
class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass" class="rounded-full w-32 h-32 md:w-40 md:h-40 mx-auto border-4 border-white/20 shadow-lg hover-glass"
loading="lazy" decoding="async"> loading="lazy" decoding="async">
<div class="mt-6 flex justify-center space-x-4"> <div class="mt-6 flex justify-center space-x-4">
<a href="https://github.com/CameronRedmore" target="_blank" <a href="https://github.com/CameronRedmore" target="_blank"
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile"> class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="GitHub Profile">
<icon-mdi-github class="text-3xl" /> <icon-mdi-github class="text-3xl" />
</a> </a>
<a href="https://git.cmzi.uk/" target="_blank" <a href="https://git.cmzi.uk/" target="_blank"
class="hover-glass hover:text-cyan-200 p-2 rounded-full" aria-label="Personal Git Hosting"> class="hover-glass hover:text-cyan-200 p-2 rounded-full"
<icon-mdi-git class="text-3xl" /> aria-label="Personal Git Hosting">
</a> <icon-mdi-git class="text-3xl" />
</a>
</div>
<!-- Last.fm Now Playing Section -->
<div class="mt-6 md:mt-8 hidden md:block text-center">
<div class="p-4 rounded-lg hover-glass">
<div id="last-song-container">
<div class="text-white-400 text-sm animate-pulse">
Loading music data...
</div>
</div>
</div>
</div>
</div> </div>
<!-- Last.fm Now Playing Section --> <!-- Right Column: Main Content -->
<div class="mt-6 md:mt-8 hidden md:block text-center"> <div class="w-full text-center md:text-left">
<div class="p-4 rounded-lg hover-glass"> <h1 class="text-4xl md:text-5xl font-bold text-white animated-gradient">Cameron B. R. Redmore
<div id="last-song-container"> </h1>
<div class="text-white-400 text-sm animate-pulse"> <p class="mt-2 text-lg text-cyan-200">Software Developer from Kingston-Upon-Hull, UK</p>
Loading music data...
<p class="mt-6 text-gray-200 leading-relaxed">
With 8 years of professional experience and over 15 years of passion-driven coding, I build
robust and efficient software solutions. My journey has taken me from recreational projects
to
developing complex professional applications.
</p>
<!-- Interests & Hobbies Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-white mb-3">Interests & Hobbies</h2>
<p class="text-gray-200 leading-relaxed">
Beyond my core work, I have a deep interest in the broader field of computing and
programming. I'm particularly passionate about the AI and Machine Learning space,
focusing
on integrating Large Language Models (LLMs) in novel ways. In my spare time, I also
enjoy
the logical challenge of puzzles like Sudoku and Nonograms.
</p>
</div>
<div class="mt-8">
<h2 class="text-xl font-semibold text-white mb-3">Programming Skills</h2>
<div class="flex flex-wrap gap-2 items-center justify-center">
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">HTML</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">CSS</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">JavaScript</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">TypeScript</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C#</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C++</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">Java</span>
</div>
</div>
<!-- Current Project Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-white mb-3">Current Hobby Project</h2>
<a href="https://weaver.cmzi.uk/" target="_blank"
class="block p-4 rounded-lg hover-glass transition-all duration-300 ease-in-out hover:bg-white/30">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-cyan-300" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1">
</path>
</svg>
</div>
<div>
<p class="font-bold text-white">Word Weaver</p>
<p class="text-sm text-cyan-200">An AI-powered web game of "Alchemy" with
infinite,
wacky combinations.</p>
</div>
</div>
</a>
</div>
<!-- Last.fm Now Playing Section - Mobile Only -->
<div class="mt-8 md:hidden">
<h2 class="text-xl font-semibold text-white mb-3">Music</h2>
<div class="p-4 rounded-lg hover-glass">
<div id="last-song-container-mobile">
<div class="text-white-400 text-sm animate-pulse">
Loading music data...
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="card-back glass-card w-full max-w-4xl rounded-2xl p-6 sm:p-8 md:p-10">
<icon-mdi-orbit-variant
class="absolute top-0 right-0 m-3 p-1 text-3xl rounded-full cursor-pointer hover-glass"></icon-mdi-orbit-variant>
<!-- Right Column: Main Content --> <!-- Game Of Life Implementation -->
<div class="w-full text-center md:text-left"> <div id="game-of-life-container" class="mt-4">
<h1 class="text-4xl md:text-5xl font-bold text-white animated-gradient">Cameron B. R. Redmore</h1> <canvas id="game-of-life-canvas" class="border border-white"></canvas>
<p class="mt-2 text-lg text-cyan-200">Software Developer from Kingston-Upon-Hull, UK</p> </div>
<p class="mt-6 text-gray-200 leading-relaxed"> <!-- Save/Load Controls -->
With 8 years of professional experience and over 15 years of passion-driven coding, I build <div class="mt-4 flex flex-col space-y-4">
robust and efficient software solutions. My journey has taken me from recreational projects to <div class="flex space-x-4">
developing complex professional applications. <button id="save-pattern"
</p> class="flex-1 px-4 py-2 bg-cyan-600/20 hover:bg-cyan-600/40 text-white rounded-lg border border-cyan-500/50 hover-glass transition-all">
Save Pattern
<!-- Interests & Hobbies Section --> </button>
<div class="mt-8"> <button id="load-pattern"
<h2 class="text-xl font-semibold text-white mb-3">Interests & Hobbies</h2> class="flex-1 px-4 py-2 bg-green-600/20 hover:bg-green-600/40 text-white rounded-lg border border-green-500/50 hover-glass transition-all">
<p class="text-gray-200 leading-relaxed"> Load Pattern
Beyond my core work, I have a deep interest in the broader field of computing and </button>
programming. I'm particularly passionate about the AI and Machine Learning space, focusing
on integrating Large Language Models (LLMs) in novel ways. In my spare time, I also enjoy
the logical challenge of puzzles like Sudoku and Nonograms.
</p>
</div> </div>
<div class="mt-8"> <!-- Simulation Speed Control -->
<h2 class="text-xl font-semibold text-white mb-3">Programming Skills</h2> <div class="mb-4 flex items-center space-x-4">
<div class="flex flex-wrap gap-2 items-center justify-center"> <label for="simulation-speed" class="text-white font-medium">Simulation Speed:</label>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">HTML</span> <input type="range" id="simulation-speed" min="1" max="30" value="10"
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">CSS</span> class="flex-1 h-2 bg-white/20 rounded-lg appearance-none cursor-pointer slider">
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">JavaScript</span> <span id="speed-display" class="text-cyan-200 font-mono min-w-[3rem]">10 Hz</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">TypeScript</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C#</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C++</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">C</span>
<span class="hover-glass text-sm font-medium px-4 py-1 rounded-full">Java</span>
</div>
</div>
<!-- Current Project Section -->
<div class="mt-8">
<h2 class="text-xl font-semibold text-white mb-3">Current Hobby Project</h2>
<a href="https://weaver.cmzi.uk/" target="_blank"
class="block p-4 rounded-lg hover-glass transition-all duration-300 ease-in-out hover:bg-white/30">
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<svg class="w-8 h-8 text-cyan-300" fill="none" stroke="currentColor"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1">
</path>
</svg>
</div>
<div>
<p class="font-bold text-white">Word Weaver</p>
<p class="text-sm text-cyan-200">An AI-powered web game of "Alchemy" with infinite,
wacky combinations.</p>
</div>
</div>
</a>
</div>
<!-- Last.fm Now Playing Section - Mobile Only -->
<div class="mt-8 md:hidden">
<h2 class="text-xl font-semibold text-white mb-3">Music</h2>
<div class="p-4 rounded-lg hover-glass">
<div id="last-song-container-mobile">
<div class="text-white-400 text-sm animate-pulse">
Loading music data...
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,5 +1,6 @@
import { treaty } from '@elysiajs/eden'; import { treaty } from '@elysiajs/eden';
import type { App } from './server'; import type { App } from './server';
import './gameoflife';
// Create Eden Treaty client using current page origin // Create Eden Treaty client using current page origin
const client = treaty<App>(window.location.origin); const client = treaty<App>(window.location.origin);
@ -251,6 +252,19 @@ async function updateLastSongDisplay(song: LastSong | null, errorMessage?: strin
// Initialize when DOM is loaded // Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Card flip logic - attach to orbit buttons
const cardContainer = document.querySelector('.card-container');
const flipButtons = document.querySelectorAll('icon-mdi-orbit-variant');
flipButtons.forEach(button => {
button.addEventListener('click', (event) => {
event.stopPropagation();
if (cardContainer) {
cardContainer.classList.toggle('is-flipped');
}
});
});
// Lazy-load: observe the desktop and mobile containers // Lazy-load: observe the desktop and mobile containers
const targets = [ const targets = [
document.getElementById('last-song-container'), document.getElementById('last-song-container'),

589
src/gameoflife.ts Normal file
View file

@ -0,0 +1,589 @@
// File to implement Game of Life using stored co-ordinates.
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('game-of-life-canvas') as HTMLCanvasElement;
const gameOfLife = new GameOfLife(canvas);
gameOfLife.start();
});
interface Cell {
x: number;
y: number;
}
class GameOfLife {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
// Define sets of cells (much more efficient for large grids than 2D array)
private activeCells: Set<string> = new Set(); // Cells that are currently alive
private activeCellsNext: Set<string> = new Set(); // Cells that will be alive in the next generation
private potentialCells: Set<string> = new Set(); // Cells that could potentially become alive in the next generation
private potentialCellsNext: Set<string> = new Set(); // Cells that will be alive in the next generation
// Viewport and interaction state
private offsetX: number = 0;
private offsetY: number = 0;
private zoomLevel: number = 1;
private isPaused: boolean = false;
private isDragging: boolean = false;
private lastMouseX: number = 0;
private lastMouseY: number = 0;
private targetFrameRate: number = 144;
private targetSimulationRate: number = 10;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
// Set canvas size
this.canvas.width = 800;
this.canvas.height = 550;
this.setupEventListeners();
this.setupSimulationSpeedSlider();
this.setupSaveLoadButtons();
this.seedInitialPattern();
}
start() {
this.update();
this.drawCells();
}
private setupEventListeners() {
// Click to add cells
this.canvas.addEventListener('click', (e) => {
if (!this.isDragging) {
this.handleCellClick(e);
}
});
// Right click to fill area with random cells
this.canvas.addEventListener('contextmenu', (e) => {
e.preventDefault(); // Prevent context menu
if (!this.isDragging) {
this.handleRightClick(e);
}
});
// Mouse events for panning
this.canvas.addEventListener('mousedown', (e) => {
this.isDragging = false;
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
});
this.canvas.addEventListener('mousemove', (e) => {
if (e.buttons === 1) { // Left mouse button pressed
const deltaX = e.clientX - this.lastMouseX;
const deltaY = e.clientY - this.lastMouseY;
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
this.isDragging = true;
this.offsetX -= deltaX;
this.offsetY -= deltaY;
}
this.lastMouseX = e.clientX;
this.lastMouseY = e.clientY;
}
});
// Zoom with mouse wheel
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Calculate world coordinates of mouse position before zoom
const worldX = (mouseX + this.offsetX) / this.zoomLevel;
const worldY = (mouseY + this.offsetY) / this.zoomLevel;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const newZoomLevel = Math.max(0.1, Math.min(5, this.zoomLevel * zoomFactor));
// Calculate new offset to keep the mouse position at the same world coordinates
this.offsetX = (worldX * newZoomLevel) - mouseX;
this.offsetY = (worldY * newZoomLevel) - mouseY;
this.zoomLevel = newZoomLevel;
});
// Keyboard controls
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
this.isPaused = !this.isPaused;
}
});
}
private setupSimulationSpeedSlider() {
const speedSlider = document.getElementById('simulation-speed') as HTMLInputElement;
const speedDisplay = document.getElementById('speed-display') as HTMLSpanElement;
if (speedSlider && speedDisplay) {
// Update display with initial value
speedDisplay.textContent = `${speedSlider.value} Hz`;
// Listen for slider changes
speedSlider.addEventListener('input', (e) => {
const target = e.target as HTMLInputElement;
const newSpeed = parseInt(target.value);
this.targetSimulationRate = newSpeed;
speedDisplay.textContent = `${newSpeed} Hz`;
});
}
}
private setupSaveLoadButtons() {
const saveButton = document.getElementById('save-pattern') as HTMLButtonElement;
const loadButton = document.getElementById('load-pattern') as HTMLButtonElement;
const fileInput = document.getElementById('pattern-file') as HTMLInputElement;
if (saveButton && loadButton) {
saveButton.addEventListener('click', async () => {
try {
const patternBlob = await this.savePattern();
// Create a download link
const url = URL.createObjectURL(patternBlob);
const a = document.createElement('a');
a.href = url;
a.download = `game-of-life-pattern-${Date.now()}.gol.zz`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Show success feedback
saveButton.textContent = 'Downloaded!';
setTimeout(() => {
saveButton.textContent = 'Save Pattern';
}, 1000);
} catch (error) {
console.error('Failed to save pattern:', error);
saveButton.textContent = 'Save Failed';
setTimeout(() => {
saveButton.textContent = 'Save Pattern';
}, 1000);
}
});
loadButton.addEventListener('click', () => {
// Trigger the file input
if (fileInput) {
fileInput.click();
} else {
// Create a temporary file input if none exists
const tempInput = document.createElement('input');
tempInput.type = 'file';
tempInput.accept = '.gol.zz,.zz';
tempInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
await this.handleFileLoad(file, loadButton);
}
});
tempInput.click();
}
});
// Set up file input if it exists
if (fileInput) {
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
await this.handleFileLoad(file, loadButton);
}
});
}
}
}
private async handleFileLoad(file: File, loadButton: HTMLButtonElement) {
try {
await this.loadPattern(file);
loadButton.textContent = 'Loaded!';
setTimeout(() => {
loadButton.textContent = 'Load Pattern';
}, 1000);
} catch (error) {
console.error('Failed to load pattern:', error);
loadButton.textContent = 'Load Failed';
setTimeout(() => {
loadButton.textContent = 'Load Pattern';
}, 1000);
}
}
private seedInitialPattern() {
// Add a simple glider pattern to start
const glider = [
{ x: 1, y: 0 },
{ x: 2, y: 1 },
{ x: 0, y: 2 },
{ x: 1, y: 2 },
{ x: 2, y: 2 }
];
for (const cell of glider) {
this.activeCells.add(this.cellToString(cell));
this.potentialCells.add(this.cellToString(cell));
}
}
private cellToString(cell: Cell): string {
return `${cell.x},${cell.y}`;
}
private stringToCell(str: string): Cell {
const [x, y] = str.split(',').map(Number);
return { x, y };
}
private async savePattern(): Promise<Blob> {
// Convert active cells to an array of coordinates
const cells = Array.from(this.activeCells).map(cellKey => {
const cell = this.stringToCell(cellKey);
return [cell.x, cell.y];
});
// Convert to JSON
const jsonData = JSON.stringify(cells);
const encoder = new TextEncoder();
const jsonBytes = encoder.encode(jsonData);
// Compress using deflate
const compressionStream = new CompressionStream('deflate-raw');
const writer = compressionStream.writable.getWriter();
const reader = compressionStream.readable.getReader();
// Write the data to the compression stream
writer.write(jsonBytes);
writer.close();
// Read the compressed data
const compressedChunks: Uint8Array[] = [];
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
compressedChunks.push(value);
}
}
// Combine all chunks into a single blob
const combinedArray = new Uint8Array(
compressedChunks.reduce((totalLength, chunk) => totalLength + chunk.length, 0)
);
let offset = 0;
for (const chunk of compressedChunks) {
combinedArray.set(chunk, offset);
offset += chunk.length;
}
return new Blob([combinedArray], { type: 'application/deflate' });
}
private async loadPattern(file: File): Promise<void> {
try {
// Read the file as array buffer
const compressedData = await file.arrayBuffer();
// Decompress using deflate
const decompressionStream = new DecompressionStream('deflate-raw');
const writer = decompressionStream.writable.getWriter();
const reader = decompressionStream.readable.getReader();
// Write the compressed data to the decompression stream
writer.write(new Uint8Array(compressedData));
writer.close();
// Read the decompressed data
const decompressedChunks: Uint8Array[] = [];
let done = false;
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
decompressedChunks.push(value);
}
}
// Combine chunks and decode to string
const totalLength = decompressedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combinedArray = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of decompressedChunks) {
combinedArray.set(chunk, offset);
offset += chunk.length;
}
const decoder = new TextDecoder();
const jsonData = decoder.decode(combinedArray);
const cells = JSON.parse(jsonData) as number[][];
// Clear current pattern
this.activeCells.clear();
this.activeCellsNext.clear();
this.potentialCells.clear();
this.potentialCellsNext.clear();
// Load new pattern
for (const [x, y] of cells) {
const cellKey = this.cellToString({ x, y });
this.activeCells.add(cellKey);
this.potentialCells.add(cellKey);
// Add neighbors to potential cells
const neighbors = this.getNeighbors({ x, y });
for (const neighbor of neighbors) {
this.potentialCells.add(this.cellToString(neighbor));
}
}
// Initialize next generation sets
this.activeCellsNext = new Set(this.activeCells);
this.potentialCellsNext = new Set(this.potentialCells);
} catch (error) {
throw new Error('Invalid pattern file or decompression failed');
}
}
private handleCellClick(e: MouseEvent) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Convert screen coordinates to grid coordinates
const cellSize = 10 * this.zoomLevel;
const gridX = Math.floor((x + this.offsetX) / cellSize);
const gridY = Math.floor((y + this.offsetY) / cellSize);
const cellKey = this.cellToString({ x: gridX, y: gridY });
// Toggle cell state
if (this.activeCells.has(cellKey)) {
this.activeCells.delete(cellKey);
this.activeCellsNext.delete(cellKey);
this.potentialCellsNext.add(cellKey);
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
for (const neighbor of neighbors) {
this.potentialCellsNext.add(this.cellToString(neighbor));
}
} else {
this.activeCells.add(cellKey);
this.activeCellsNext.add(cellKey);
this.potentialCellsNext.add(cellKey);
// Add neighbors to potential cells
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
for (const neighbor of neighbors) {
this.potentialCellsNext.add(this.cellToString(neighbor));
}
}
}
private handleRightClick(e: MouseEvent) {
const rect = this.canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Convert screen coordinates to grid coordinates
const cellSize = 10 * this.zoomLevel;
const centerX = Math.floor((x + this.offsetX) / cellSize);
const centerY = Math.floor((y + this.offsetY) / cellSize);
// Define the area size (radius around the cursor) - scale inversely with zoom level
// Base radius of 5 at zoom level 1, scales up when zoomed out
const areaRadius = Math.max(2, Math.round(5 / this.zoomLevel));
const fillDensity = 0.3; // 30% chance for each cell to be filled
// Fill random cells in the area
for (let dx = -areaRadius; dx <= areaRadius; dx++) {
for (let dy = -areaRadius; dy <= areaRadius; dy++) {
// Only fill cells within a circular area
if (dx * dx + dy * dy <= areaRadius * areaRadius) {
if (Math.random() < fillDensity) {
const gridX = centerX + dx;
const gridY = centerY + dy;
const cellKey = this.cellToString({ x: gridX, y: gridY });
// Add the cell if it's not already alive
if (!this.activeCells.has(cellKey)) {
this.activeCells.add(cellKey);
this.activeCellsNext.add(cellKey);
this.potentialCellsNext.add(cellKey);
// Add neighbors to potential cells
const neighbors = this.getNeighbors({ x: gridX, y: gridY });
for (const neighbor of neighbors) {
this.potentialCellsNext.add(this.cellToString(neighbor));
}
}
}
}
}
}
}
private update() {
if (!this.isPaused) {
this.updateCells();
}
setTimeout(() => requestAnimationFrame(this.update.bind(this)), 1000 / this.targetSimulationRate);
}
private updateCells() {
//Check if the card is currently flipped, otherwise do not update cells.
const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
if (!isFlipped) {
return;
}
this.activeCells = new Set(this.activeCellsNext);
this.activeCellsNext = new Set<string>();
this.potentialCells = new Set(this.potentialCellsNext);
// Each current cell has the potential to cause change on this update.
this.potentialCellsNext = new Set(this.activeCells);
for (const cellKey of this.potentialCells) {
const cell = this.stringToCell(cellKey);
const neighbors = this.getNeighbors(cell);
const aliveNeighbors = neighbors.filter(n => this.activeCells.has(this.cellToString(n))).length;
if (this.activeCells.has(cellKey)) {
// Cell is currently alive
if (aliveNeighbors < 2 || aliveNeighbors > 3) {
// Cell dies, so we don't add it to the next generation
// However, this means the surrounding cells could change on the next generation.
for (const neighbor of neighbors) {
// Add neighbors to potential cells for next generation
this.potentialCellsNext.add(this.cellToString(neighbor));
}
this.potentialCellsNext.add(cellKey);
continue;
} else {
// Cell stays alive
this.activeCellsNext.add(cellKey);
}
} else {
// Cell is currently dead
if (aliveNeighbors === 3) {
// Cell becomes alive
this.activeCellsNext.add(cellKey);
this.potentialCellsNext.add(cellKey);
for (const neighbor of neighbors) {
// Add neighbors to potential cells for next generation
this.potentialCellsNext.add(this.cellToString(neighbor));
}
}
}
}
}
private getNeighbors(cell: Cell): Cell[] {
const neighbors: Cell[] = [];
for (let x = -1; x <= 1; x++) {
for (let y = -1; y <= 1; y++) {
// Ignore the cell itself.
if (x === 0 && y === 0)
{
continue;
}
neighbors.push({ x: cell.x + x, y: cell.y + y });
}
}
return neighbors;
}
private drawCells() {
const isFlipped = document.querySelector('main')?.classList.contains('is-flipped');
if (!isFlipped) {
setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
return;
}
// Draw the cells, taking into account viewport offset and zoom level.
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// Find what range of cells are currently visible based on the canvas size and zoom level.
let cellSize = 10; // Default size of each cell in pixels
// Adjust size by zoom level
cellSize *= this.zoomLevel;
// Calculate the visible range of cells
const startX = Math.floor(this.offsetX / cellSize);
const startY = Math.floor(this.offsetY / cellSize);
const endX = Math.ceil((this.offsetX + this.canvas.width) / cellSize);
const endY = Math.ceil((this.offsetY + this.canvas.height) / cellSize);
// Draw grid lines for better visibility
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
this.ctx.lineWidth = 1;
for (let x = startX; x <= endX; x++) {
const screenX = (x * cellSize) - this.offsetX;
this.ctx.beginPath();
this.ctx.moveTo(screenX, 0);
this.ctx.lineTo(screenX, this.canvas.height);
this.ctx.stroke();
}
for (let y = startY; y <= endY; y++) {
const screenY = (y * cellSize) - this.offsetY;
this.ctx.beginPath();
this.ctx.moveTo(0, screenY);
this.ctx.lineTo(this.canvas.width, screenY);
this.ctx.stroke();
}
this.ctx.fillStyle = '#67e8f9'; // cyan color to match theme
// Draw active cells
for (let x = startX; x < endX; x++) {
for (let y = startY; y < endY; y++) {
const cellKey = this.cellToString({ x, y });
if (this.activeCells.has(cellKey)) {
const screenX = (x * cellSize) - this.offsetX;
const screenY = (y * cellSize) - this.offsetY;
this.ctx.fillRect(screenX, screenY, cellSize - 1, cellSize - 1);
}
}
}
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
this.ctx.font = '20px Exo 2 Variable, sans-serif';
// Draw pause indicator
if (this.isPaused) {
this.ctx.fillText('PAUSED - Press SPACE to resume', 10, 30);
}
// Draw current X, Y and zoom level along with total number of active cells in bottom left
this.ctx.fillText(`X: ${Math.floor(this.offsetX / cellSize)}, Y: ${Math.floor(this.offsetY / cellSize)}, Zoom: ${this.zoomLevel.toFixed(2)}`, 10, this.canvas.height - 10);
this.ctx.fillText(`Active Cells: ${this.activeCells.size}`, 10, this.canvas.height - 30);
setTimeout(() => requestAnimationFrame(this.drawCells.bind(this)), 1000 / this.targetFrameRate);
}
}

View file

@ -220,3 +220,84 @@ details.history ul li {
color: #cbd5e0; color: #cbd5e0;
font-size: 12px; font-size: 12px;
} }
/* Card flip styles */
.card-container {
perspective: 1500px;
}
.card-flipper {
position: relative;
width: 100%;
min-height: 720px;
transform-style: preserve-3d;
transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.card-container.is-flipped .card-flipper {
transform: rotateY(-180deg);
}
.card-front,
.card-back {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.card-front {
z-index: 2;
transform: rotateY(0deg);
}
.card-back {
transform: rotateY(-180deg);
z-index: 1;
}
/* Slider styles for simulation speed control */
.slider {
background: rgba(255, 255, 255, 0.2);
outline: none;
-webkit-appearance: none;
appearance: none;
}
.slider::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #67e8f9;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #4dd0e1;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #67e8f9;
cursor: pointer;
border: 2px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
}
.slider::-moz-range-thumb:hover {
background: #4dd0e1;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}