Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 51 additions & 50 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { useRef, useEffect, useState, useCallback } from 'react'
import './App.css'
import { DEFAULT_ZOOM, MAX_ZOOM } from './sim/constants'

type Material = 'sand' | 'water' | 'dirt' | 'stone' | 'plant' | 'fire' | 'gas' | 'fluff' | 'bug' | 'plasma' | 'nitro' | 'glass' | 'lightning' | 'slime' | 'ant' | 'alien' | 'quark' | 'crystal' | 'ember' | 'static' | 'bird' | 'gunpowder' | 'tap' | 'anthill' | 'bee' | 'flower' | 'hive' | 'honey' | 'nest' | 'gun' | 'cloud' | 'acid' | 'lava' | 'snow' | 'volcano' | 'mold' | 'mercury' | 'void' | 'seed' | 'rust' | 'spore' | 'algae' | 'poison' | 'dust' | 'firework' | 'bubble' | 'glitter' | 'star' | 'comet' | 'blackhole' | 'firefly' | 'worm' | 'fairy' | 'fish' | 'moth' | 'vent'
type Tool = Material | 'erase'
type Material = 'empty' | 'sand' | 'water' | 'dirt' | 'stone' | 'plant' | 'fire' | 'gas' | 'fluff' | 'bug' | 'plasma' | 'nitro' | 'glass' | 'lightning' | 'slime' | 'ant' | 'alien' | 'quark' | 'crystal' | 'ember' | 'static' | 'bird' | 'gunpowder' | 'tap' | 'anthill' | 'bee' | 'flower' | 'hive' | 'honey' | 'nest' | 'gun' | 'cloud' | 'acid' | 'lava' | 'snow' | 'volcano' | 'mold' | 'mercury' | 'void' | 'seed' | 'rust' | 'spore' | 'algae' | 'poison' | 'dust' | 'firework' | 'bubble' | 'glitter' | 'star' | 'comet' | 'blackhole' | 'firefly' | 'worm' | 'fairy' | 'fish' | 'moth' | 'vent'
type Tool = Material
Comment on lines +5 to +6

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Material is duplicated as a long union type in this file even though src/sim/constants.ts now exports type Material (including 'empty'). Duplicating this list risks the UI and worker diverging again; consider importing and reusing the shared Material type instead of redefining it here.

Copilot uses AI. Check for mistakes.

const BUTTON_COLORS: Record<Tool, string> = {
erase: '#f87171',
empty: '#f87171',
sand: '#e6c86e', water: '#4a90d9', dirt: '#8b5a2b', stone: '#666666',
plant: '#228b22', fire: '#ff6600', gas: '#a8b844', fluff: '#f5e6d3',
bug: '#ff69b4', plasma: '#c8a2c8', nitro: '#39ff14', glass: '#a8d8ea',
Expand Down Expand Up @@ -489,7 +489,7 @@ function App() {
}, [dropdownOpen])

const categories: Array<{ label: string; items: Tool[] }> = [
{ label: 'basic', items: ['erase', 'sand', 'water', 'dirt', 'stone', 'glass', 'snow', 'dust', 'fluff'] },
{ label: 'basic', items: ['empty', 'sand', 'water', 'dirt', 'stone', 'glass', 'snow', 'dust', 'fluff'] },
{ label: 'fluid', items: ['slime', 'acid', 'lava', 'mercury', 'honey', 'poison', 'gas', 'bubble'] },
{ label: 'energy', items: ['fire', 'ember', 'plasma', 'lightning', 'static', 'nitro', 'gunpowder', 'firework', 'quark', 'comet'] },
{ label: 'nature', items: ['plant', 'seed', 'flower', 'algae', 'mold', 'spore', 'rust', 'crystal', 'void', 'glitter'] },
Expand Down Expand Up @@ -542,6 +542,7 @@ function App() {
className="material-dropdown-trigger"
style={{ '--material-color': BUTTON_COLORS[tool] } as React.CSSProperties}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
Comment on lines +545 to 546

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling e.preventDefault() on onPointerDown for a <button> prevents the button from receiving focus via mouse/touch interaction, which can hurt keyboard navigation and assistive tech workflows. If the goal is only to suppress drag/scroll side effects, consider avoiding preventDefault here (or applying it only once a drag is detected) so the control can still be focused normally.

Copilot uses AI. Check for mistakes.
brushDragRef.current = { startX: e.clientX, startSize: brushSizeRef.current, moved: false }
}}
Expand Down Expand Up @@ -587,7 +588,7 @@ function App() {
<button
key={m}
className={`material-dropdown-item ${tool === m ? 'active' : ''}`}
onClick={() => { if (m !== 'erase') lastMaterialRef.current = m as Material; setTool(m); setDropdownOpen(false) }}
onClick={() => { if (m !== 'empty') lastMaterialRef.current = m as Material; setTool(m); setDropdownOpen(false) }}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lastMaterialRef.current is written when selecting a non-'empty' tool, but it's never read anywhere in the file. This makes the intent unclear and adds dead state; either remove it or reintroduce the behavior that consumes it (e.g., toggling back to the last non-empty material).

Suggested change
onClick={() => { if (m !== 'empty') lastMaterialRef.current = m as Material; setTool(m); setDropdownOpen(false) }}
onClick={() => { setTool(m); setDropdownOpen(false) }}

Copilot uses AI. Check for mistakes.
>
<span className="material-dot" style={{ background: BUTTON_COLORS[m] }} />
<span>{m}</span>
Expand All @@ -600,53 +601,53 @@ function App() {
</div>
)}
{
settingsOpen && (
<div className="settings-overlay" onClick={() => setSettingsOpen(false)}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-section">
<div className="settings-title">Map Size</div>
<div className="settings-subtitle">
Current: {gridDims.cols} x {gridDims.rows}
</div>
<div className="settings-options">
{PRESET_SIZES.map((size) => (
<button
key={size.label}
className="settings-option"
onClick={() => selectMapSize(size)}
>
<span className="settings-option-label">{size.label}</span>
<span className="settings-option-dims">{size.cols} x {size.rows}</span>
</button>
))}
<button
className="settings-option"
onClick={() => selectMapSize(getScreenSize())}
>
<span className="settings-option-label">Screen</span>
<span className="settings-option-dims">{Math.floor(window.innerWidth / 2)} x {Math.floor(window.innerHeight / 2)}</span>
</button>
</div>
<div className="settings-warn">Changing size resets the simulation</div>
</div>
<div className="settings-divider" />
<div className="settings-section">
<div className="settings-title">World</div>
<div className="settings-options">
<button className="settings-option" onClick={() => { save(); setSettingsOpen(false) }}>
<span className="settings-option-label">Save World</span>
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" className="settings-option-icon"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" /></svg>
</button>
<button className="settings-option" onClick={() => { load(); setSettingsOpen(false) }}>
<span className="settings-option-label">Load World</span>
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" className="settings-option-icon"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" /></svg>
</button>
settingsOpen && (
<div className="settings-overlay" onClick={() => setSettingsOpen(false)}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-section">
<div className="settings-title">Map Size</div>
<div className="settings-subtitle">
Current: {gridDims.cols} x {gridDims.rows}
</div>
<div className="settings-options">
{PRESET_SIZES.map((size) => (
<button
key={size.label}
className="settings-option"
onClick={() => selectMapSize(size)}
>
<span className="settings-option-label">{size.label}</span>
<span className="settings-option-dims">{size.cols} x {size.rows}</span>
</button>
))}
<button
className="settings-option"
onClick={() => selectMapSize(getScreenSize())}
>
<span className="settings-option-label">Screen</span>
<span className="settings-option-dims">{Math.floor(window.innerWidth / 2)} x {Math.floor(window.innerHeight / 2)}</span>
</button>
</div>
<div className="settings-warn">Changing size resets the simulation</div>
</div>
<div className="settings-divider" />
<div className="settings-section">
<div className="settings-title">World</div>
<div className="settings-options">
<button className="settings-option" onClick={() => { save(); setSettingsOpen(false) }}>
<span className="settings-option-label">Save World</span>
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" className="settings-option-icon"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" /></svg>
</button>
<button className="settings-option" onClick={() => { load(); setSettingsOpen(false) }}>
<span className="settings-option-label">Load World</span>
<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18" className="settings-option-icon"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" /></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
)
}
</div >
)
}
Expand Down
10 changes: 5 additions & 5 deletions src/physics.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Runs physics simulation and rendering off the main thread
// Uses two-canvas pipeline: world buffer (1px/cell) → GPU-scaled display canvas

import { MATERIAL_TO_ID, type Material, EMPTY, STONE, TAP, GUN, BLACK_HOLE,
import { MATERIAL_TO_ID, type Material, STONE, TAP, GUN, BLACK_HOLE,
DEFAULT_ZOOM, BG_COLOR } from './sim/constants'
import { ARCHETYPES } from './sim/archetypes'
import { renderSystem } from './sim/systems/render'
Expand All @@ -22,7 +22,7 @@ let sim: Simulation | null = null
let isPaused = false
let pauseAtStep: number | null = null
let debugChunks = false
let pendingInputs: Array<{ x: number; y: number; prevX: number; prevY: number; tool: Material | 'erase'; brushSize: number }> = []
let pendingInputs: Array<{ x: number; y: number; prevX: number; prevY: number; tool: Material; brushSize: number }> = []

// Camera state
let camX = 0 // top-left world cell (float)
Expand All @@ -47,10 +47,10 @@ function initGrid(displayWidth: number, displayHeight: number, cols?: number, ro
camY = 0
}

function addParticles(cellX: number, cellY: number, tool: Material | 'erase', brushSize: number) {
function addParticles(cellX: number, cellY: number, tool: Material, brushSize: number) {
if (!sim) return
const { grid, cols, rows, rand, chunkMap } = sim
const matId = tool === 'erase' ? EMPTY : MATERIAL_TO_ID[tool as Material]
const matId = MATERIAL_TO_ID[tool]

if (matId === GUN) {
if (cellX >= 0 && cellX < cols && cellY >= 0 && cellY < rows) {
Expand All @@ -70,7 +70,7 @@ function addParticles(cellX: number, cellY: number, tool: Material | 'erase', br
if (nx >= 0 && nx < cols && ny >= 0 && ny < rows) {
const idx = ny * cols + nx
const spawnRate = ARCHETYPES[matId]?.spawnRate ?? 0.45
if ((tool === 'erase' || (rand() < spawnRate && grid[idx] === EMPTY))) {
if (rand() < spawnRate) {
grid[idx] = matId
}
Comment on lines 50 to 75

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Brush painting logic changed: with tool 'empty' the code now uses spawnRate (default 0.45 because ARCHETYPES[EMPTY] is null) and only clears a cell when rand() < spawnRate. Also, for all tools it now overwrites any existing particle because the prior grid[idx] === EMPTY guard was removed. If the intent is to make 'empty' act like erase and keep paint-from-empty behavior, handle EMPTY as a special case (always set to EMPTY), and for other materials keep the grid[idx] === EMPTY check (or otherwise prevent unintended overwrites).

Copilot uses AI. Check for mistakes.
}
Expand Down
4 changes: 2 additions & 2 deletions src/sim/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const WORM = 62, FAIRY = 63
export const FISH = 64, MOTH = 65, VENT = 66
export const LIT_GUNPOWDER = 67

export type Material = 'sand' | 'water' | 'dirt' | 'stone' | 'plant' | 'fire' | 'gas' | 'fluff' | 'bug' | 'plasma' | 'nitro' | 'glass' | 'lightning' | 'slime' | 'ant' | 'alien' | 'quark' | 'crystal' | 'ember' | 'static' | 'bird' | 'gunpowder' | 'tap' | 'anthill' | 'bee' | 'flower' | 'hive' | 'honey' | 'nest' | 'gun' | 'cloud' | 'acid' | 'lava' | 'snow' | 'volcano' | 'mold' | 'mercury' | 'void' | 'seed' | 'rust' | 'spore' | 'algae' | 'poison' | 'dust' | 'firework' | 'bubble' | 'glitter' | 'star' | 'comet' | 'blackhole' | 'firefly' | 'worm' | 'fairy' | 'fish' | 'moth' | 'vent'
export type Material = 'empty' | 'sand' | 'water' | 'dirt' | 'stone' | 'plant' | 'fire' | 'gas' | 'fluff' | 'bug' | 'plasma' | 'nitro' | 'glass' | 'lightning' | 'slime' | 'ant' | 'alien' | 'quark' | 'crystal' | 'ember' | 'static' | 'bird' | 'gunpowder' | 'tap' | 'anthill' | 'bee' | 'flower' | 'hive' | 'honey' | 'nest' | 'gun' | 'cloud' | 'acid' | 'lava' | 'snow' | 'volcano' | 'mold' | 'mercury' | 'void' | 'seed' | 'rust' | 'spore' | 'algae' | 'poison' | 'dust' | 'firework' | 'bubble' | 'glitter' | 'star' | 'comet' | 'blackhole' | 'firefly' | 'worm' | 'fairy' | 'fish' | 'moth' | 'vent'

export const MATERIAL_TO_ID: Record<Material, number> = {
sand: SAND, water: WATER, dirt: DIRT, stone: STONE, plant: PLANT,
empty: EMPTY, sand: SAND, water: WATER, dirt: DIRT, stone: STONE, plant: PLANT,
fire: FIRE, gas: GAS, fluff: FLUFF, bug: BUG, plasma: PLASMA,
nitro: NITRO, glass: GLASS, lightning: LIGHTNING, slime: SLIME, ant: ANT, alien: ALIEN, quark: QUARK,
crystal: CRYSTAL, ember: EMBER, static: STATIC, bird: BIRD, gunpowder: GUNPOWDER, tap: TAP, anthill: ANTHILL,
Expand Down
3 changes: 2 additions & 1 deletion tests/controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ test.describe('Control Buttons', () => {

test('material dropdown opens and shows items', async ({ page }) => {
const trigger = page.locator('.material-dropdown-trigger')
await trigger.click()
const box = await trigger.boundingBox()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
Comment on lines +24 to +25

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trigger.boundingBox() can return null; using box! can cause a runtime failure and flakiness. Add await expect(trigger).toBeVisible() and assert the bounding box is non-null before clicking (or prefer await trigger.click() if possible).

Suggested change
const box = await trigger.boundingBox()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await expect(trigger).toBeVisible()
await trigger.click()

Copilot uses AI. Check for mistakes.
const sandBtn = page.locator('.material-dropdown-item', { hasText: 'sand' })
await expect(sandBtn).toBeVisible()
await sandBtn.click()
Expand Down
3 changes: 2 additions & 1 deletion tests/particles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ test.describe('Particle Interactions', () => {

test('selecting materials via dropdown works', async ({ page }) => {
const trigger = page.locator('.material-dropdown-trigger')
await trigger.click()
const box = await trigger.boundingBox()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
Comment on lines +40 to +41

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trigger.boundingBox() can return null (e.g., if the element isn't visible yet). The non-null assertion (box!) will make this test flaky/fail with a runtime error; add an explicit visibility assertion and a null check (or use await trigger.click() after ensuring visibility).

Suggested change
const box = await trigger.boundingBox()
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2)
await expect(trigger).toBeVisible()
await trigger.click()

Copilot uses AI. Check for mistakes.

const waterBtn = page.locator('.material-dropdown-item', { hasText: 'water' })
await expect(waterBtn).toBeVisible()
Expand Down
Loading