diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..2ad2f0e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: Deploy + +# Publishes https://micro-cosmos.web.app: +# / the game on `main` +# /prototype/ the original prototype on the `prototype` branch +on: + push: + branches: + - main + - prototype + workflow_dispatch: + +concurrency: + group: deploy-hosting + cancel-in-progress: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + # Always build the site from main; a push to `prototype` just refreshes /prototype. + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build site (game + /prototype) + run: bash scripts/build-site.sh + + - name: Deploy to Firebase Hosting + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + repoToken: ${{ secrets.GITHUB_TOKEN }} + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_MICRO_COSMOS }} + projectId: micro-cosmos + channelId: live diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c266c4e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,11 @@ +# Generated and vendored files +.github/ +node_modules/ +dist/ +build/ +media/ +src-tauri/target/ +src-tauri/gen/ +bun.lock +*.webp +*.png diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..81632d3 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "printWidth": 140, + "trailingComma": "all" +} diff --git a/README.md b/README.md index e15a43d..0d1ca0c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ### Engulf. Endosymbiose. Divide. Become multicellular. -A real-time cellular survival game for desktop and browser: part agar.io, part Factorio-inside-a-cell, part *Journey to the Microcosmos*, drawn in a Kurzgesagt-style flat palette. +A real-time cellular survival game for desktop and browser: part agar.io, part Factorio-inside-a-cell, part _Journey to the Microcosmos_, drawn in a Kurzgesagt-style flat palette. ![Version](https://img.shields.io/badge/version-0.2.0-8eea54?style=flat-square&labelColor=0b2632) ![Tauri](https://img.shields.io/badge/Tauri-2-24c8db?style=flat-square&labelColor=0b2632) @@ -35,25 +35,25 @@ The opening is deliberately quiet: a close-up of a lone cell, a few drifting glu Thirty-three species, each with its own behavior and a Field Journal entry grounded in real biology. They hunt, flee, graze and infect each other without you. -| | Organisms | -| --- | --- | -| **Food** | Glucose, cell debris, DNA fragments, lipid droplets, cocci, bacilli, spirilla, diatoms, Euglena, gastrotrichs | -| **Endosymbionts** | α-proteobacteria (→ mitochondria), cyanobacteria (→ chloroplasts) | -| **Hunters** | *Didinium* charges, *Lacrymaria olor* coils its neck and strikes, *Amoeba proteus* engulfs anything smaller, *Paramecium* fires trichocysts | -| **Traps** | Hydra tentacles with nematocysts, the near-invisible *Collotheca* funnel, Stentor and rotifer feeding vortices | -| **Armor and terrain** | *Arcella* (only its aperture is vulnerable), indestructible tardigrades (brush them for Dsup protection), pollen grains | -| **Mini-boss** | Neoplasms: glucose-hoarding tumor masses that split when wounded | +| | Organisms | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Food** | Glucose, cell debris, DNA fragments, lipid droplets, cocci, bacilli, spirilla, diatoms, Euglena, gastrotrichs | +| **Endosymbionts** | α-proteobacteria (→ mitochondria), cyanobacteria (→ chloroplasts) | +| **Hunters** | _Didinium_ charges, _Lacrymaria olor_ coils its neck and strikes, _Amoeba proteus_ engulfs anything smaller, _Paramecium_ fires trichocysts | +| **Traps** | Hydra tentacles with nematocysts, the near-invisible _Collotheca_ funnel, Stentor and rotifer feeding vortices | +| **Armor and terrain** | _Arcella_ (only its aperture is vulnerable), indestructible tardigrades (brush them for Dsup protection), pollen grains | +| **Mini-boss** | Neoplasms: glucose-hoarding tumor masses that split when wounded | Infection follows the design bible's **aliveness spectrum**: -| Agent | Mechanic | -| --- | --- | -| Prions | Misfold an organelle; the misfolding spreads to neighbors. A lysosome burst performs autophagy. | -| Viroids | Swarm photosynthetic cells and siphon ATP. Shake them off. | -| Satellite RNAs | Inert alone; amplify the next virus that infects you. | -| Viruses | Adenovirus (lytic), retrovirus (integrates as a dormant provirus that stress can reawaken), TMV (plant cells only), bacteriophage (bacteria only), Mimivirus (amoebae, including you). Docked virions can be shaken off with a dash before they inject. | -| Virophages | Allies. Absorb them and release the swarm against giant viruses. | -| Intracellular bacteria | Hitchhike inside infected prey and colonize your cytoplasm. | +| Agent | Mechanic | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prions | Misfold an organelle; the misfolding spreads to neighbors. A lysosome burst performs autophagy. | +| Viroids | Swarm photosynthetic cells and siphon ATP. Shake them off. | +| Satellite RNAs | Inert alone; amplify the next virus that infects you. | +| Viruses | Adenovirus (lytic), retrovirus (integrates as a dormant provirus that stress can reawaken), TMV (plant cells only), bacteriophage (bacteria only), Mimivirus (amoebae, including you). Docked virions can be shaken off with a dash before they inject. | +| Virophages | Allies. Absorb them and release the swarm against giant viruses. | +| Intracellular bacteria | Hitchhike inside infected prey and colonize your cytoplasm. | Five biomes (Sunlit Shallows, Biofilm Reef, Lysis Bloom, Abyssal Sediment, Neoplastic Rift) set light for photosynthesis, glucose density, currents and spawn tables. An ecosystem director escalates pressure with time, distance and generation: viral storms, prion fog, phage bursts, glucose blooms, current surges and neoplasm hunts. @@ -61,9 +61,9 @@ Five biomes (Sunlit Shallows, Biofilm Reef, Lysis Bloom, Abyssal Sediment, Neopl Press Q to switch illumination. Each light shows different things. -| Darkfield | Fluorescence | -| --- | --- | -| ![Darkfield](docs/screenshots/darkfield.webp) | ![Fluorescence](docs/screenshots/fluorescence.webp) | +| Darkfield | Fluorescence | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ![Darkfield](docs/screenshots/darkfield.webp) | ![Fluorescence](docs/screenshots/fluorescence.webp) | | Scattered light outlines every edge on a black field. Reveals transparent agents (prions, viroids, satellites) and the bristles of traps. | Real probe colors: DAPI-blue nuclei, MitoTracker-green mitochondria, red chlorophyll, magenta lysosomes, orange membrane dye. Reveals infected prey and proviruses, but excitation light costs ATP (phototoxicity). | Brightfield is the vivid illustrated default. Zooming into the **Cell Architect** shifts the grade toward an electron micrograph. @@ -83,17 +83,17 @@ Your cell's size follows from what it contains: every organelle is real biomass ## Controls -| Input | Action | -| --- | --- | -| WASD / arrows, or hold the mouse | Swim | -| Space | Pseudopod dash: shakes off docked viruses, breaks grips | -| 1–5, right click | Lysosome burst, toxicyst volley, RNA interference, encyst, release virophages | -| Q | Cycle brightfield, darkfield and fluorescence | -| Tab | Cell Architect | -| R | Divide | -| Hold E | Identify specimens | -| Mouse wheel, [ ] | Magnification | -| Esc | Pause (also automatic when the window loses focus) | +| Input | Action | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| WASD / arrows, or hold the mouse | Swim | +| Space | Pseudopod dash: shakes off docked viruses, breaks grips | +| 1–5, right click | Lysosome burst, toxicyst volley, RNA interference, encyst, release virophages | +| Q | Cycle brightfield, darkfield and fluorescence | +| Tab | Cell Architect | +| R | Divide | +| Hold E | Identify specimens | +| Mouse wheel, [ ] | Magnification | +| Esc | Pause (also automatic when the window loses focus) | Dash, the Cell Architect and microscope light unlock during the first objectives. diff --git a/docs/readme-previews/playable/README.md b/docs/readme-previews/playable/README.md index 6176bdd..424ec50 100644 --- a/docs/readme-previews/playable/README.md +++ b/docs/readme-previews/playable/README.md @@ -5,7 +5,7 @@ ### Engulf. Endosymbiose. Divide. Become multicellular. -A real-time cellular survival game for desktop and browser: part agar.io, part Factorio-inside-a-cell, part *Journey to the Microcosmos*, drawn in a Kurzgesagt-style flat palette. +A real-time cellular survival game for desktop and browser: part agar.io, part Factorio-inside-a-cell, part _Journey to the Microcosmos_, drawn in a Kurzgesagt-style flat palette. ![Version](https://img.shields.io/badge/version-0.2.0-8eea54?style=flat-square&labelColor=0b2632) ![Tauri](https://img.shields.io/badge/Tauri-2-24c8db?style=flat-square&labelColor=0b2632) @@ -37,25 +37,25 @@ You begin as a single amoeboid cell in an infinite, procedurally streamed drop o Thirty-three species, each with its own behavior and a Field Journal entry grounded in real biology. They hunt, flee, graze and infect each other without you. -| | Organisms | -| --- | --- | -| **Food** | Glucose, cell debris, DNA fragments, lipid droplets, cocci, bacilli, spirilla, diatoms, Euglena, gastrotrichs | -| **Endosymbionts** | α-proteobacteria (→ mitochondria), cyanobacteria (→ chloroplasts) | -| **Hunters** | *Didinium* charges, *Lacrymaria olor* coils its neck and strikes, *Amoeba proteus* engulfs anything smaller, *Paramecium* fires trichocysts | -| **Traps** | Hydra tentacles with nematocysts, the near-invisible *Collotheca* funnel, Stentor and rotifer feeding vortices | -| **Armor and terrain** | *Arcella* (only its aperture is vulnerable), indestructible tardigrades (brush them for Dsup protection), pollen grains | -| **Mini-boss** | Neoplasms: glucose-hoarding tumor masses that split when wounded | +| | Organisms | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| **Food** | Glucose, cell debris, DNA fragments, lipid droplets, cocci, bacilli, spirilla, diatoms, Euglena, gastrotrichs | +| **Endosymbionts** | α-proteobacteria (→ mitochondria), cyanobacteria (→ chloroplasts) | +| **Hunters** | _Didinium_ charges, _Lacrymaria olor_ coils its neck and strikes, _Amoeba proteus_ engulfs anything smaller, _Paramecium_ fires trichocysts | +| **Traps** | Hydra tentacles with nematocysts, the near-invisible _Collotheca_ funnel, Stentor and rotifer feeding vortices | +| **Armor and terrain** | _Arcella_ (only its aperture is vulnerable), indestructible tardigrades (brush them for Dsup protection), pollen grains | +| **Mini-boss** | Neoplasms: glucose-hoarding tumor masses that split when wounded | Infection follows the design bible's **aliveness spectrum**: -| Agent | Mechanic | -| --- | --- | -| Prions | Misfold an organelle; the misfolding spreads to neighbors. A lysosome burst performs autophagy. | -| Viroids | Swarm photosynthetic cells and siphon ATP. Shake them off. | -| Satellite RNAs | Inert alone; amplify the next virus that infects you. | -| Viruses | Adenovirus (lytic), retrovirus (integrates as a dormant provirus that stress can reawaken), TMV (plant cells only), bacteriophage (bacteria only), Mimivirus (amoebae, including you). Docked virions can be shaken off with a dash before they inject. | -| Virophages | Allies. Absorb them and release the swarm against giant viruses. | -| Intracellular bacteria | Hitchhike inside infected prey and colonize your cytoplasm. | +| Agent | Mechanic | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prions | Misfold an organelle; the misfolding spreads to neighbors. A lysosome burst performs autophagy. | +| Viroids | Swarm photosynthetic cells and siphon ATP. Shake them off. | +| Satellite RNAs | Inert alone; amplify the next virus that infects you. | +| Viruses | Adenovirus (lytic), retrovirus (integrates as a dormant provirus that stress can reawaken), TMV (plant cells only), bacteriophage (bacteria only), Mimivirus (amoebae, including you). Docked virions can be shaken off with a dash before they inject. | +| Virophages | Allies. Absorb them and release the swarm against giant viruses. | +| Intracellular bacteria | Hitchhike inside infected prey and colonize your cytoplasm. | Five biomes (Sunlit Shallows, Biofilm Reef, Lysis Bloom, Abyssal Sediment, Neoplastic Rift) set light for photosynthesis, glucose density, currents and spawn tables. An ecosystem director escalates pressure with time, distance and generation: viral storms, prion fog, phage bursts, glucose blooms, current surges and neoplasm hunts. @@ -63,9 +63,9 @@ Five biomes (Sunlit Shallows, Biofilm Reef, Lysis Bloom, Abyssal Sediment, Neopl Press Q to switch illumination. Each light shows different things. -| Darkfield | Fluorescence | -| --- | --- | -| ![Darkfield](screenshots/darkfield.webp) | ![Fluorescence](screenshots/fluorescence.webp) | +| Darkfield | Fluorescence | +| ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ![Darkfield](screenshots/darkfield.webp) | ![Fluorescence](screenshots/fluorescence.webp) | | Scattered light outlines every edge on a black field. Reveals transparent agents (prions, viroids, satellites) and the bristles of traps. | Real probe colors: DAPI-blue nuclei, MitoTracker-green mitochondria, red chlorophyll, magenta lysosomes, orange membrane dye. Reveals infected prey and proviruses, but excitation light costs ATP (phototoxicity). | Brightfield is the vivid illustrated default. Zooming into the **Cell Architect** shifts the grade toward an electron micrograph. @@ -85,17 +85,17 @@ Your cell's size follows from what it contains: every organelle is real biomass ## Controls -| Input | Action | -| --- | --- | -| WASD / arrows, or hold the mouse | Swim | -| Space | Pseudopod dash: shakes off docked viruses, breaks grips | -| 1–5, right click | Lysosome burst, toxicyst volley, RNA interference, encyst, release virophages | -| Q | Cycle brightfield, darkfield and fluorescence | -| Tab | Cell Architect | -| R | Divide | -| Hold E | Identify specimens | -| Mouse wheel, [ ] | Magnification | -| Esc | Pause (also automatic when the window loses focus) | +| Input | Action | +| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| WASD / arrows, or hold the mouse | Swim | +| Space | Pseudopod dash: shakes off docked viruses, breaks grips | +| 1–5, right click | Lysosome burst, toxicyst volley, RNA interference, encyst, release virophages | +| Q | Cycle brightfield, darkfield and fluorescence | +| Tab | Cell Architect | +| R | Divide | +| Hold E | Identify specimens | +| Mouse wheel, [ ] | Magnification | +| Esc | Pause (also automatic when the window loses focus) | ## Under the hood diff --git a/docs/readme-previews/prototype/README.md b/docs/readme-previews/prototype/README.md index 77211cf..298646b 100644 --- a/docs/readme-previews/prototype/README.md +++ b/docs/readme-previews/prototype/README.md @@ -21,12 +21,12 @@ An experimental real-time cellular survival strategy game for desktop and browse MicroCosm runs two interdependent game loops at once. Every decision inside the cell changes how well it survives outside, while every resource gathered outside determines what can be built within. -| Ecosystem survival | Cellular strategy | -| --- | --- | -| Swim through a borderless, procedurally streamed micro-world | Build a functional internal architecture around the genome | -| Hunt green glucose crystals and engulf useful organelles | Place and rearrange mitochondria, nuclei, and Golgi systems | -| Evade predators, armored organisms, fungal barriers, and viruses | Balance ATP generation, uptake, repair, biomass, and viral resistance | -| Read currents, threats, scale, and spatial opportunities in real time | Switch metabolic stances and fold regulatory RNA programs | +| Ecosystem survival | Cellular strategy | +| --------------------------------------------------------------------- | --------------------------------------------------------------------- | +| Swim through a borderless, procedurally streamed micro-world | Build a functional internal architecture around the genome | +| Hunt green glucose crystals and engulf useful organelles | Place and rearrange mitochondria, nuclei, and Golgi systems | +| Evade predators, armored organisms, fungal barriers, and viruses | Balance ATP generation, uptake, repair, biomass, and viral resistance | +| Read currents, threats, scale, and spatial opportunities in real time | Switch metabolic stances and fold regulatory RNA programs | The world does not pause while the cell is being redesigned. Survival and architecture remain part of the same continuous simulation. @@ -64,16 +64,16 @@ The microscope is more than a camera zoom. At ecosystem scale the game uses a vi ## Controls -| Input | Action | -| --- | --- | -| W A S D or arrow keys | Swim | -| Drag or tap the world | Steer toward the pointer | -| [ / ] | Zoom the microscope out / in | -| Microscope slider | Move between ecosystem and ultrastructure | -| Tab | Enter or leave Cell Architect when available | -| Hold E | Identify nearby biological structures | -| 1 2 3 | Trigger organelle abilities | -| Mouse or touch drag | Reposition organelles in Cell Architect | +| Input | Action | +| ----------------------------------------------------------------- | -------------------------------------------- | +| W A S D or arrow keys | Swim | +| Drag or tap the world | Steer toward the pointer | +| [ / ] | Zoom the microscope out / in | +| Microscope slider | Move between ecosystem and ultrastructure | +| Tab | Enter or leave Cell Architect when available | +| Hold E | Identify nearby biological structures | +| 1 2 3 | Trigger organelle abilities | +| Mouse or touch drag | Reposition organelles in Cell Architect | ## Core systems @@ -130,15 +130,15 @@ bun run build ## Technology -| Layer | Stack | -| --- | --- | -| Simulation and interface | React 18 + TypeScript | -| Build system | Vite 7 + Bun | -| Native shell | Tauri 2 + Rust | -| Styling | Tailwind CSS + project-specific CSS | -| Spatial generation | D3 Delaunay + deterministic chunk generation | -| Interface primitives | Radix UI + Lucide | -| Persistence and services | Fully local, no backend required | +| Layer | Stack | +| ------------------------ | -------------------------------------------- | +| Simulation and interface | React 18 + TypeScript | +| Build system | Vite 7 + Bun | +| Native shell | Tauri 2 + Rust | +| Styling | Tailwind CSS + project-specific CSS | +| Spatial generation | D3 Delaunay + deterministic chunk generation | +| Interface primitives | Radix UI + Lucide | +| Persistence and services | Fully local, no backend required | ## Project structure diff --git a/eslint.config.js b/eslint.config.js index b7fb843..2cedb4c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,10 +7,7 @@ import globals from 'globals'; export default tseslint.config( { ignores: ['dist', 'out', 'node_modules', 'src-tauri', '.next', '.firebase', 'next-env.d.ts'] }, { - extends: [ - js.configs.recommended, - ...tseslint.configs.recommended, - ], + extends: [js.configs.recommended, ...tseslint.configs.recommended], files: ['**/*.{ts,tsx}'], languageOptions: { ecmaVersion: 2020, @@ -26,10 +23,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'warn', }, diff --git a/firebase.json b/firebase.json index ad7face..99d1662 100644 --- a/firebase.json +++ b/firebase.json @@ -1,12 +1,12 @@ { "hosting": { "public": "dist", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [ + { + "source": "/prototype{,/**}", + "destination": "/prototype/index.html" + }, { "source": "**", "destination": "/index.html" @@ -21,7 +21,16 @@ "value": "public,max-age=31536000,immutable" } ] + }, + { + "source": "/prototype/assets/**", + "headers": [ + { + "key": "Cache-Control", + "value": "public,max-age=31536000,immutable" + } + ] } ] } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 6e64764..7bc7197 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "preview": "vite preview --host 0.0.0.0", "test": "bun test", "typecheck": "tsc --noEmit", - "deploy": "bun run build && firebase deploy --only hosting", + "build:site": "bash scripts/build-site.sh", + "deploy": "bun run build:site && firebase deploy --only hosting", "tauri": "tauri", "lint": "eslint .", "lint:fix": "eslint . --fix" diff --git a/scripts/build-site.sh b/scripts/build-site.sh new file mode 100755 index 0000000..0448605 --- /dev/null +++ b/scripts/build-site.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Builds the Firebase Hosting site: +# / the current game (this checkout) +# /prototype/ the original prototype (the `prototype` branch) +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +ref="${PROTOTYPE_REF:-origin/prototype}" +work="$(mktemp -d)" +trap 'git -C "$root" worktree remove --force "$work/prototype" >/dev/null 2>&1 || true; rm -rf "$work"' EXIT + +cd "$root" +bun run build + +git fetch --quiet origin prototype || true +git worktree add --detach "$work/prototype" "$ref" >/dev/null +( + cd "$work/prototype" + bun install --frozen-lockfile + bunx vite build --base=/prototype/ --outDir "$root/dist/prototype" --emptyOutDir +) + +# The prototype references a few images by absolute URL from component code, +# which Vite does not rebase. Serve those at the root paths it expects. +mkdir -p dist/assets +cp "$work/prototype/public/assets/"*.webp dist/assets/ 2>/dev/null || true + +echo "Site ready in dist/ (prototype under dist/prototype/)" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index c135d7f..8e906f7 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,10 +2,6 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": [ - "main" - ], - "permissions": [ - "core:default" - ] + "windows": ["main"], + "permissions": ["core:default"] } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7b4a64f..8f1a798 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -30,13 +30,7 @@ "bundle": { "active": true, "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "android": { "debugApplicationIdSuffix": ".debug" } diff --git a/src/game/audio.ts b/src/game/audio.ts index 4044e67..b189c5b 100644 --- a/src/game/audio.ts +++ b/src/game/audio.ts @@ -20,7 +20,15 @@ const SCALES: Record = { // First-order Markov transitions over scale-degree intervals: mostly steps, // sometimes leaps, gravitating back toward the tonic. -const INTERVAL_WEIGHTS: Array<[number, number]> = [[-2, 0.12], [-1, 0.3], [0, 0.06], [1, 0.3], [2, 0.12], [3, 0.05], [-3, 0.05]]; +const INTERVAL_WEIGHTS: Array<[number, number]> = [ + [-2, 0.12], + [-1, 0.3], + [0, 0.06], + [1, 0.3], + [2, 0.12], + [3, 0.05], + [-3, 0.05], +]; export type SfxBus = 'sfx' | 'ui'; @@ -122,7 +130,9 @@ export class Soundscape { const gd = this.glass.getChannelData(0); for (let i = 0; i < glen; i++) { const t = i / ctx.sampleRate; - gd[i] = (Math.sin(t * 2 * Math.PI * 880) * 0.5 + Math.sin(t * 2 * Math.PI * 1318.5) * 0.3 + Math.sin(t * 2 * Math.PI * 2093) * 0.2) * Math.exp(-t * 4); + gd[i] = + (Math.sin(t * 2 * Math.PI * 880) * 0.5 + Math.sin(t * 2 * Math.PI * 1318.5) * 0.3 + Math.sin(t * 2 * Math.PI * 2093) * 0.2) * + Math.exp(-t * 4); } // Ambient wash. @@ -345,7 +355,10 @@ export class Soundscape { private heartbeat(t: number, k: number) { const ctx = this.ctx!; - for (const [dt, g] of [[0, 1], [0.18, 0.7]] as const) { + for (const [dt, g] of [ + [0, 1], + [0.18, 0.7], + ] as const) { const o = ctx.createOscillator(); const env = ctx.createGain(); o.type = 'sine'; @@ -364,7 +377,15 @@ export class Soundscape { // --- One-shot effects ----------------------------------------------------- private tone(opts: { - type?: OscillatorType; from: number; to?: number; dur: number; gain: number; attack?: number; pan?: number; delay?: number; reverb?: number; + type?: OscillatorType; + from: number; + to?: number; + dur: number; + gain: number; + attack?: number; + pan?: number; + delay?: number; + reverb?: number; }) { const ctx = this.ctx; if (!ctx || this.muted) return; @@ -392,7 +413,16 @@ export class Soundscape { o.stop(t + opts.dur + 0.05); } - private noiseHit(opts: { freq: number; to?: number; q?: number; dur: number; gain: number; type?: BiquadFilterType; pan?: number; delay?: number }) { + private noiseHit(opts: { + freq: number; + to?: number; + q?: number; + dur: number; + gain: number; + type?: BiquadFilterType; + pan?: number; + delay?: number; + }) { const ctx = this.ctx; if (!ctx || this.muted || !this.noise) return; const t = ctx.currentTime + (opts.delay ?? 0); diff --git a/src/game/engine.ts b/src/game/engine.ts index 3f8612f..e723de7 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -1,6 +1,13 @@ import { Soundscape } from './audio'; import { - DEFAULT_RECORDS, loadJournal, loadRecords, loadSettings, recordDiscovery, saveRecords, saveSettings, type Records, + DEFAULT_RECORDS, + loadJournal, + loadRecords, + loadSettings, + recordDiscovery, + saveRecords, + saveSettings, + type Records, type Settings, } from './persistence'; import { Camera } from './render/camera'; @@ -12,39 +19,99 @@ import { OBJECTIVES, objectiveNavTarget } from './sim/director'; import { membraneImpulse } from './sim/membrane'; import { approach, clamp } from './sim/math'; import { - ABILITY_INFO, BUILD_ORDER, buildCost, canBuild, countOrganelles, FATES, MUTATIONS, ORGANELLES, SLOTS, slotAccepts, + ABILITY_INFO, + BUILD_ORDER, + buildCost, + canBuild, + countOrganelles, + FATES, + MUTATIONS, + ORGANELLES, + SLOTS, + slotAccepts, } from './sim/organelles'; import { - buildOrganelle, canDivide, chooseDivision, divisionDnaCost, divisionThreshold, livingUnits, moveOrganelle, - recycleOrganelle, rerollDivision, spendableBiomass, startDivision, + buildOrganelle, + canDivide, + chooseDivision, + divisionDnaCost, + divisionThreshold, + livingUnits, + moveOrganelle, + recycleOrganelle, + rerollDivision, + spendableBiomass, + startDivision, } from './sim/player'; import { createGame, stepGame } from './sim/sim'; import { SPECIES, SPECIES_LIST } from './sim/species'; import { NO_INPUT, spawnEntity, type GameState, type SimInput } from './sim/state'; import { openness, spawnGroup } from './sim/world'; import type { - AbilityId, BiomeId, CellFate, DeathCause, LightMode, Mutation, MutationId, OrganelleType, SimEvent, SpeciesId, Stats, - Traits, UnlockId, + AbilityId, + BiomeId, + CellFate, + DeathCause, + LightMode, + Mutation, + MutationId, + OrganelleType, + SimEvent, + SpeciesId, + Stats, + Traits, + UnlockId, } from './sim/types'; export type Screen = 'title' | 'playing' | 'paused' | 'division' | 'dead' | 'victory'; export type ToastKind = 'discover' | 'objective' | 'event' | 'warn' | 'good' | 'info' | 'unlock'; export type Toast = { - id: number; kind: ToastKind; title: string; text: string; color: string; species?: SpeciesId; key?: string; born: number; ttl: number; + id: number; + kind: ToastKind; + title: string; + text: string; + color: string; + species?: SpeciesId; + key?: string; + born: number; + ttl: number; }; // A large, quiet caption for arriving somewhere (biomes, the opening shot). export type Banner = { id: number; title: string; sub: string }; -export type AbilityView = { id: AbilityId; name: string; key: string; atp: number; cooldown: number; remaining: number; affordable: boolean; description: string }; +export type AbilityView = { + id: AbilityId; + name: string; + key: string; + atp: number; + cooldown: number; + remaining: number; + affordable: boolean; + description: string; +}; export type BuildOption = { - type: OrganelleType; name: string; role: string; science: string; ok: boolean; reason: string; biomass: number; dna: number; owned: number; max: number; + type: OrganelleType; + name: string; + role: string; + science: string; + ok: boolean; + reason: string; + biomass: number; + dna: number; + owned: number; + max: number; }; export type SlotView = { - id: number; x: number; y: number; r: number; ring: 'core' | 'inner' | 'outer'; locked: boolean; + id: number; + x: number; + y: number; + r: number; + ring: 'core' | 'inner' | 'outer'; + locked: boolean; occupant: { id: number; type: OrganelleType; misfolded: boolean } | null; }; @@ -80,7 +147,15 @@ export type HudSnapshot = { objectiveAge: number; completed: { title: string; reward: number } | null; objective: { index: number; total: number; title: string; text: string; progress: string } | null; - infection: { viralLoad: number; prophages: number; colonies: number; misfolded: number; attached: number; viroids: number; satellite: number }; + infection: { + viralLoad: number; + prophages: number; + colonies: number; + misfolded: number; + attached: number; + viroids: number; + satellite: number; + }; starving: boolean; captured: { species: SpeciesId; struggle: number } | null; cyst: boolean; @@ -110,19 +185,68 @@ const LIGHT_ORDER: LightMode[] = ['bright', 'dark', 'fluor']; const EVENT_TEXT: Record = { glucoseBloom: { title: 'Glucose bloom', text: 'Photosynthesis upstream released a sugar cloud nearby.', color: '#d6ff5c', kind: 'good' }, - viralStorm: { title: 'Viral storm', text: 'Virions converging on your membrane. Dash to shake them off, then use RNAi.', color: '#ff3b5c', kind: 'warn' }, - prionFog: { title: 'Prion fog', text: 'Misfolded proteins drifting ahead. Switch to darkfield (Q) to see them.', color: '#d7c4ff', kind: 'warn' }, - phageBurst: { title: 'Phage storm', text: 'Bacteriophages are bursting a bacterial colony. Harmless to you, and full of DNA.', color: '#b9c0ff', kind: 'event' }, - virophageSwarm: { title: 'Virophage swarm', text: 'Sputnik virophages hunting giant viruses. Absorb them as allies.', color: '#7dffb0', kind: 'good' }, - currentSurge: { title: 'Current surge', text: 'A strong current sweeps the plankton. Swim hard to hold position.', color: '#7de8ff', kind: 'event' }, - neoplasm: { title: 'Neoplasm detected', text: 'A mutant cell mass is hunting. It splits when wounded. Burn it down for DNA.', color: '#ff2e4d', kind: 'warn' }, + viralStorm: { + title: 'Viral storm', + text: 'Virions converging on your membrane. Dash to shake them off, then use RNAi.', + color: '#ff3b5c', + kind: 'warn', + }, + prionFog: { + title: 'Prion fog', + text: 'Misfolded proteins drifting ahead. Switch to darkfield (Q) to see them.', + color: '#d7c4ff', + kind: 'warn', + }, + phageBurst: { + title: 'Phage storm', + text: 'Bacteriophages are bursting a bacterial colony. Harmless to you, and full of DNA.', + color: '#b9c0ff', + kind: 'event', + }, + virophageSwarm: { + title: 'Virophage swarm', + text: 'Sputnik virophages hunting giant viruses. Absorb them as allies.', + color: '#7dffb0', + kind: 'good', + }, + currentSurge: { + title: 'Current surge', + text: 'A strong current sweeps the plankton. Swim hard to hold position.', + color: '#7de8ff', + kind: 'event', + }, + neoplasm: { + title: 'Neoplasm detected', + text: 'A mutant cell mass is hunting. It splits when wounded. Burn it down for DNA.', + color: '#ff2e4d', + kind: 'warn', + }, }; const UNLOCK_TEXT: Partial> = { - radar: { title: 'Chemotaxis', text: 'Your cell now senses chemical gradients. Green is food, blue is prey, red is danger.', color: '#7be0c0' }, - dash: { title: 'Pseudopod dash', text: 'A burst of speed that also shakes off anything clinging to you.', key: 'SPACE', color: '#7be0c0' }, - architect: { title: 'Cell Architect', text: 'Look inside your cell to grow and arrange organelles. Time slows while you build.', key: 'TAB', color: '#c4f53a' }, - microscope: { title: 'Microscope light', text: 'Switch illumination. Darkfield and fluorescence reveal what brightfield hides.', key: 'Q', color: '#9fe8ff' }, + radar: { + title: 'Chemotaxis', + text: 'Your cell now senses chemical gradients. Green is food, blue is prey, red is danger.', + color: '#7be0c0', + }, + dash: { + title: 'Pseudopod dash', + text: 'A burst of speed that also shakes off anything clinging to you.', + key: 'SPACE', + color: '#7be0c0', + }, + architect: { + title: 'Cell Architect', + text: 'Look inside your cell to grow and arrange organelles. Time slows while you build.', + key: 'TAB', + color: '#c4f53a', + }, + microscope: { + title: 'Microscope light', + text: 'Switch illumination. Darkfield and fluorescence reveal what brightfield hides.', + key: 'Q', + color: '#9fe8ff', + }, }; const DEATH_TEXT: Record = { @@ -220,7 +344,18 @@ export class GameEngine { s.player.invulnUntil = 1e9; // The title screen shows the ecosystem in full, not the quiet opening. s.objective.index = 5; - const fauna: SpeciesId[] = ['euglena', 'paramecium', 'diatom', 'proteo', 'cyano', 'bacillus', 'spirillum', 'cocci', 'euglena', 'diatom']; + const fauna: SpeciesId[] = [ + 'euglena', + 'paramecium', + 'diatom', + 'proteo', + 'cyano', + 'bacillus', + 'spirillum', + 'cocci', + 'euglena', + 'diatom', + ]; fauna.forEach((id, i) => { const a = (i / fauna.length) * Math.PI * 2; spawnGroup(s, id, Math.cos(a) * 420, Math.sin(a) * 300, s.rng); @@ -303,11 +438,21 @@ export class GameEngine { this.audio.modeSwitch(mode); if (mode === 'fluor' && !this.warned.has('fluor')) { this.warned.add('fluor'); - this.toast('info', 'Fluorescence', 'Tags reveal infected prey, proviruses and organelle health. Excitation light costs ATP (phototoxicity).', '#4dff6a'); + this.toast( + 'info', + 'Fluorescence', + 'Tags reveal infected prey, proviruses and organelle health. Excitation light costs ATP (phototoxicity).', + '#4dff6a', + ); } if (mode === 'dark' && !this.warned.has('dark')) { this.warned.add('dark'); - this.toast('info', 'Darkfield', 'Scattered light outlines transparent agents: prions, viroids, satellites and trap bristles.', '#dfefff'); + this.toast( + 'info', + 'Darkfield', + 'Scattered light outlines transparent agents: prions, viroids, satellites and trap bristles.', + '#dfefff', + ); } } this.publish(); @@ -523,7 +668,17 @@ export class GameEngine { const t = this.clock * 0.05; this.camera.update(dtReal, Math.cos(t) * 320, Math.sin(t * 1.3) * 220, 0, 0, 60, 30, 0, this.settings.reducedMotion); } else if (this.architect || this.architectBlend > 0.02) { - this.camera.update(dtReal, prime.x, prime.y, prime.vx, prime.vy, colonyR, prime.radius, this.architectBlend, this.settings.reducedMotion); + this.camera.update( + dtReal, + prime.x, + prime.y, + prime.vx, + prime.vy, + colonyR, + prime.radius, + this.architectBlend, + this.settings.reducedMotion, + ); } else if (this.debugFocus) { this.camera.update(dtReal, this.debugFocus.x, this.debugFocus.y, 0, 0, colonyR, prime.radius, 0, this.settings.reducedMotion); } else { @@ -539,27 +694,34 @@ export class GameEngine { this.ca = Math.max(0, this.ca - dtReal * 2.5); if (p.dead && this.screen !== 'title') this.desat = Math.min(0.85, this.desat + dtReal * 0.5); - const hurt = this.screen === 'title' || p.dead ? 0 : clamp(1 - prime.integrity / prime.maxIntegrity - 0.45, 0, 0.55) * 1.6 + (p.capture ? 0.35 : 0); + const hurt = + this.screen === 'title' || p.dead + ? 0 + : clamp(1 - prime.integrity / prime.maxIntegrity - 0.45, 0, 0.55) * 1.6 + (p.capture ? 0.35 : 0); const toxicyst = p.traits.abilities.includes('toxicyst'); const pointerWorld = this.camera.screenToWorld(this.input.pointer.x, this.input.pointer.y); - this.renderer.render(s, { - time: this.clock, - camera: this.camera, - dark: this.dark, - fluor: this.fluor, - electron: this.architectBlend * 0.62, - architect: this.architectBlend, - highlightSlot: this.highlightSlot, - fx: { ca: this.ca, flash: this.flash, hurt, desat: this.desat }, - overlay: { - labels: this.labels, - aim: this.input.pointer.active && !this.input.pointer.touch ? pointerWorld : null, - aimActive: toxicyst && this.screen === 'playing', - nav: this.screen === 'playing' && this.introT > 0.9 ? objectiveNavTarget(s) : null, + this.renderer.render( + s, + { + time: this.clock, + camera: this.camera, + dark: this.dark, + fluor: this.fluor, + electron: this.architectBlend * 0.62, + architect: this.architectBlend, + highlightSlot: this.highlightSlot, + fx: { ca: this.ca, flash: this.flash, hurt, desat: this.desat }, + overlay: { + labels: this.labels, + aim: this.input.pointer.active && !this.input.pointer.touch ? pointerWorld : null, + aimActive: toxicyst && this.screen === 'playing', + nav: this.screen === 'playing' && this.introT > 0.9 ? objectiveNavTarget(s) : null, + }, + showPlayer: this.screen !== 'title', + glowBoost: 0, }, - showPlayer: this.screen !== 'title', - glowBoost: 0, - }, dtReal); + dtReal, + ); // Audio intensity from nearby danger. if (this.screen !== 'title') { @@ -668,7 +830,12 @@ export class GameEngine { aimX = w.x; aimY = w.y; } else { - const target = s.grid.nearest(prime.x, prime.y, 520, (e) => SPECIES[e.species].threat >= 1 && SPECIES[e.species].group !== 'resource'); + const target = s.grid.nearest( + prime.x, + prime.y, + 520, + (e) => SPECIES[e.species].threat >= 1 && SPECIES[e.species].group !== 'resource', + ); if (target) { aimX = target.x; aimY = target.y; @@ -748,7 +915,14 @@ export class GameEngine { const u = this.nearestUnit(ev.x, ev.y); membraneImpulse(u.membrane, Math.atan2(ev.y - u.y, ev.x - u.x), u.radius * 3, 0.6); this.audio.gulp(SPECIES[ev.species].radius[1], this.pan(ev.x)); - R.particles.burst(ev.x, ev.y, { count: 6, kind: 'bubble', color: 'rgba(230,255,245,0.9)', speed: [20, 60], size: [1.5, 4], life: [0.4, 0.8] }); + R.particles.burst(ev.x, ev.y, { + count: 6, + kind: 'bubble', + color: 'rgba(230,255,245,0.9)', + speed: [20, 60], + size: [1.5, 4], + life: [0.4, 0.8], + }); break; } case 'digested': @@ -767,7 +941,14 @@ export class GameEngine { const def = SPECIES[ev.species]; this.audio.kill(def.radius[1], this.pan(ev.x)); const color = def.group === 'agent' ? '#c9b6ff' : '#ff9a6b'; - R.particles.burst(ev.x, ev.y, { count: def.group === 'agent' ? 6 : 14, kind: 'shard', color, speed: [60, 220], size: [1.5, 4], life: [0.4, 0.8] }); + R.particles.burst(ev.x, ev.y, { + count: def.group === 'agent' ? 6 : 14, + kind: 'shard', + color, + speed: [60, 220], + size: [1.5, 4], + life: [0.4, 0.8], + }); R.particles.ring(ev.x, ev.y, def.radius[1] * 1.8, color, 0.45, 3); if (def.radius[1] > 30) { this.hitstop = 0.06; @@ -779,12 +960,23 @@ export class GameEngine { this.audio.endosymbiosis(); this.flash = 0.45; this.hitstop = 0.12; - R.particles.burst(ev.x, ev.y, { count: 36, color: ev.organelle === 'mitochondrion' ? '#ffb43d' : '#7be04a', speed: [80, 260], size: [2, 4], life: [0.6, 1.2] }); + R.particles.burst(ev.x, ev.y, { + count: 36, + color: ev.organelle === 'mitochondrion' ? '#ffb43d' : '#7be04a', + speed: [80, 260], + size: [2, 4], + life: [0.6, 1.2], + }); R.particles.ring(ev.x, ev.y, prime.radius * 3.2, '#fff2a8', 0.8, 4); const name = ORGANELLES[ev.organelle].name; - this.toast('good', `Endosymbiosis: ${name}`, ev.organelle === 'mitochondrion' - ? 'You engulfed a bacterium and kept it. ATP output just jumped. This is how mitochondria began, ~2 billion years ago.' - : 'A captured cyanobacterium now photosynthesizes for you. This is how every chloroplast began.', ev.organelle === 'mitochondrion' ? '#ffb43d' : '#7be04a'); + this.toast( + 'good', + `Endosymbiosis: ${name}`, + ev.organelle === 'mitochondrion' + ? 'You engulfed a bacterium and kept it. ATP output just jumped. This is how mitochondria began, ~2 billion years ago.' + : 'A captured cyanobacterium now photosynthesizes for you. This is how every chloroplast began.', + ev.organelle === 'mitochondrion' ? '#ffb43d' : '#7be04a', + ); break; } case 'attach': { @@ -792,7 +984,12 @@ export class GameEngine { R.particles.ring(ev.x, ev.y, 18, '#ffe98a', 0.4, 2); if (!this.warned.has('satellite')) { this.warned.add('satellite'); - this.toast('warn', 'Satellite RNA absorbed', 'Harmless alone, but the next virus that infects you will replicate faster.', '#ffe98a'); + this.toast( + 'warn', + 'Satellite RNA absorbed', + 'Harmless alone, but the next virus that infects you will replicate faster.', + '#ffe98a', + ); } break; } @@ -800,7 +997,12 @@ export class GameEngine { R.particles.ring(ev.x, ev.y, 22, '#ff3b5c', 0.4, 2); if (!this.warned.has('attach')) { this.warned.add('attach'); - this.toast('warn', 'Virus docking', 'A virion is drilling into your membrane. DASH (SPACE) to shake it off before it injects.', '#ff3b5c'); + this.toast( + 'warn', + 'Virus docking', + 'A virion is drilling into your membrane. DASH (SPACE) to shake it off before it injects.', + '#ff3b5c', + ); } break; } @@ -812,8 +1014,20 @@ export class GameEngine { case 'infection': this.audio.infection(); this.ca = 0.8; - if (ev.style === 'lysogenic') this.toast('warn', 'Provirus integrated', 'A retrovirus spliced itself into your genome. It sleeps until stress wakes it. RNAi (3) can excise it.', '#ff6b84'); - else this.toast('warn', 'Genome hijacked', `${SPECIES[ev.species].name} DNA is replicating inside you. Use RNA interference (3) before the cell bursts.`, '#ff3b5c'); + if (ev.style === 'lysogenic') + this.toast( + 'warn', + 'Provirus integrated', + 'A retrovirus spliced itself into your genome. It sleeps until stress wakes it. RNAi (3) can excise it.', + '#ff6b84', + ); + else + this.toast( + 'warn', + 'Genome hijacked', + `${SPECIES[ev.species].name} DNA is replicating inside you. Use RNA interference (3) before the cell bursts.`, + '#ff3b5c', + ); break; case 'lysisBurst': this.audio.burst(); @@ -829,23 +1043,45 @@ export class GameEngine { break; case 'misfold': this.audio.misfold(); - this.toast('warn', 'Prion misfolding', `Your ${ORGANELLES[ev.organelle].name.toLowerCase()} misfolded and stopped working. It will spread. Lysosome burst (1) recycles it.`, '#d7c4ff'); + this.toast( + 'warn', + 'Prion misfolding', + `Your ${ORGANELLES[ev.organelle].name.toLowerCase()} misfolded and stopped working. It will spread. Lysosome burst (1) recycles it.`, + '#d7c4ff', + ); break; case 'colonized': if (!this.warned.has('colonized')) { this.warned.add('colonized'); - this.toast('warn', 'Hitchhikers', 'That prey carried intracellular bacteria. They now steal your glucose. Lysosome bursts clear colonies.', '#9dff3a'); + this.toast( + 'warn', + 'Hitchhikers', + 'That prey carried intracellular bacteria. They now steal your glucose. Lysosome bursts clear colonies.', + '#9dff3a', + ); } break; case 'cured': this.audio.cure(); - R.particles.burst(prime.x, prime.y, { count: 12, color: '#7dffb0', speed: [60, 160], size: [1.5, 3], life: [0.4, 0.7], spread: prime.radius }); + R.particles.burst(prime.x, prime.y, { + count: 12, + color: '#7dffb0', + speed: [60, 160], + size: [1.5, 3], + life: [0.4, 0.7], + spread: prime.radius, + }); break; case 'captured': this.audio.captured(); this.camera.addTrauma(0.5); this.hitstop = 0.08; - this.toast('warn', `Caught by ${SPECIES[ev.species].name}`, 'Thrash with WASD and SPACE to break free, or encyst (4).', '#ff3b5c'); + this.toast( + 'warn', + `Caught by ${SPECIES[ev.species].name}`, + 'Thrash with WASD and SPACE to break free, or encyst (4).', + '#ff3b5c', + ); break; case 'escaped': this.audio.escaped(); @@ -856,7 +1092,14 @@ export class GameEngine { break; case 'build': this.audio.build(); - R.particles.burst(prime.x, prime.y, { count: 14, color: '#ffffff', speed: [40, 140], size: [1.5, 3], life: [0.4, 0.7], spread: prime.radius * 0.6 }); + R.particles.burst(prime.x, prime.y, { + count: 14, + color: '#ffffff', + speed: [40, 140], + size: [1.5, 3], + life: [0.4, 0.7], + spread: prime.radius * 0.6, + }); membraneImpulse(prime.membrane, Math.random() * Math.PI * 2, prime.radius * 3, 1.2); break; case 'divisionReady': @@ -914,7 +1157,14 @@ export class GameEngine { case 'npcBurst': if (this.onScreen(ev.x, ev.y)) { this.audio.kill(8, this.pan(ev.x)); - R.particles.burst(ev.x, ev.y, { count: 12, kind: 'shard', color: '#c4c9ff', speed: [60, 180], size: [1.5, 3], life: [0.4, 0.8] }); + R.particles.burst(ev.x, ev.y, { + count: 12, + kind: 'shard', + color: '#c4c9ff', + speed: [60, 180], + size: [1.5, 3], + life: [0.4, 0.8], + }); } break; case 'stun': @@ -922,7 +1172,12 @@ export class GameEngine { break; case 'tardigrade': this.audio.tardigrade(); - this.toast('good', 'Dsup borrowed', 'Brushing the tardigrade coated you in damage-suppressor protein: -45% damage for 10 s.', '#c9b6ff'); + this.toast( + 'good', + 'Dsup borrowed', + 'Brushing the tardigrade coated you in damage-suppressor protein: -45% damage for 10 s.', + '#c9b6ff', + ); break; case 'victory': this.audio.victory(); @@ -948,13 +1203,30 @@ export class GameEngine { switch (id) { case 'dash': this.audio.dash(); - R.particles.burst(x, y, { count: 12, kind: 'bubble', color: 'rgba(220,255,250,0.8)', speed: [20, 80], size: [2, 5], life: [0.4, 0.8], angle: Math.atan2(-p.dashDir.y, -p.dashDir.x), cone: 0.7, spread: prime.radius * 0.6 }); + R.particles.burst(x, y, { + count: 12, + kind: 'bubble', + color: 'rgba(220,255,250,0.8)', + speed: [20, 80], + size: [2, 5], + life: [0.4, 0.8], + angle: Math.atan2(-p.dashDir.y, -p.dashDir.x), + cone: 0.7, + spread: prime.radius * 0.6, + }); this.state.shockwaves.push({ x, y, t: 0, duration: 0.4, radius: prime.radius * 2.2, strength: 0.35, color: '#c8fff0' }); break; case 'lysosome': this.audio.lysosome(); this.camera.addTrauma(0.35); - R.particles.burst(x, y, { count: 40, kind: 'bubble', color: 'rgba(200,150,255,0.9)', speed: [120, 380], size: [2, 6], life: [0.4, 0.9] }); + R.particles.burst(x, y, { + count: 40, + kind: 'bubble', + color: 'rgba(200,150,255,0.9)', + speed: [120, 380], + size: [2, 6], + life: [0.4, 0.9], + }); R.particles.burst(x, y, { count: 24, color: '#ffd23f', speed: [100, 300], size: [1.5, 3], life: [0.4, 0.8] }); break; case 'toxicyst': @@ -966,7 +1238,15 @@ export class GameEngine { break; case 'encyst': this.audio.encyst(); - R.particles.burst(x, y, { count: 16, kind: 'shard', color: '#ffcf7a', speed: [40, 120], size: [1.5, 3], life: [0.4, 0.8], spread: prime.radius }); + R.particles.burst(x, y, { + count: 16, + kind: 'shard', + color: '#ffcf7a', + speed: [40, 120], + size: [1.5, 3], + life: [0.4, 0.8], + spread: prime.radius, + }); break; case 'virophage': this.audio.cure(); @@ -1085,11 +1365,21 @@ export class GameEngine { maxIntegrity: prime.maxIntegrity, generation: p.generation, cells: livingUnits(p).length, - division: { threshold: divisionThreshold(p.generation), dna: divisionDnaCost(p.generation), ok: div.ok, reasons: div.reasons, progress: p.dividing }, + division: { + threshold: divisionThreshold(p.generation), + dna: divisionDnaCost(p.generation), + ok: div.ok, + reasons: div.reasons, + progress: p.dividing, + }, abilities: t.abilities.map((id) => { const info = ABILITY_INFO[id]; return { - id, name: info.name, key: info.key, atp: info.atp, description: info.description, + id, + name: info.name, + key: info.key, + atp: info.atp, + description: info.description, cooldown: id === 'dash' ? t.dashCooldown : info.cooldown, remaining: Math.max(0, p.cooldowns[id] - s.time), affordable: p.atp >= info.atp, @@ -1108,7 +1398,15 @@ export class GameEngine { intro: this.introT, objectiveAge: this.clock - this.objectiveAt, completed: this.clock - this.objectiveAt < 2.8 ? this.completed : null, - objective: objective ? { index: s.objective.index, total: OBJECTIVES.length, title: objective.title, text: objective.text, progress: s.objective.progressText } : null, + objective: objective + ? { + index: s.objective.index, + total: OBJECTIVES.length, + title: objective.title, + text: objective.text, + progress: s.objective.progressText, + } + : null, infection: { viralLoad: p.infection.viralLoad, prophages: p.infection.prophages, @@ -1130,8 +1428,16 @@ export class GameEngine { const check = canBuild(p, type, spendable); const cost = buildCost(p, type); return { - type, name: def.name, role: def.role, science: def.science, ok: check.ok, reason: check.reason, - biomass: cost.biomass, dna: cost.dna, owned: countOrganelles(p, type, true), max: def.max, + type, + name: def.name, + role: def.role, + science: def.science, + ok: check.ok, + reason: check.reason, + biomass: cost.biomass, + dna: cost.dna, + owned: countOrganelles(p, type, true), + max: def.max, }; }), choices: s.divisionChoices ? { fates: s.divisionChoices.fates, mutations: s.divisionChoices.mutations } : null, diff --git a/src/game/persistence.ts b/src/game/persistence.ts index 91faa8e..8048584 100644 --- a/src/game/persistence.ts +++ b/src/game/persistence.ts @@ -71,8 +71,7 @@ export function saveRecords(r: Records) { write(KEYS.records, r); } -const prefersReduced = () => - typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +const prefersReduced = () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; export function loadSettings(): Settings { return read(KEYS.settings, { diff --git a/src/game/render/camera.ts b/src/game/render/camera.ts index ebfcb0f..351e956 100644 --- a/src/game/render/camera.ts +++ b/src/game/render/camera.ts @@ -22,8 +22,16 @@ export class Camera { } update( - dt: number, focusX: number, focusY: number, vx: number, vy: number, colonyR: number, primeR: number, - architect: number, reducedMotion: boolean, intro = 1, + dt: number, + focusX: number, + focusY: number, + vx: number, + vy: number, + colonyR: number, + primeR: number, + architect: number, + reducedMotion: boolean, + intro = 1, ) { this.t += dt; const minDim = Math.min(this.viewW, this.viewH); diff --git a/src/game/render/draw/agents.ts b/src/game/render/draw/agents.ts index a1e0f6b..dc5786b 100644 --- a/src/game/render/draw/agents.ts +++ b/src/game/render/draw/agents.ts @@ -268,16 +268,26 @@ function satellite(ctx: CanvasRenderingContext2D, r: number, t: number) { export function drawAgentShape(ctx: CanvasRenderingContext2D, species: SpeciesId, r: number, t: number, seed: number, detail = true) { switch (species) { - case 'adenovirus': return adenovirus(ctx, r, t, detail); - case 'retrovirus': return retrovirus(ctx, r, t, detail); - case 'tmv': return tmv(ctx, r, detail); - case 'phage': return phage(ctx, r, t, detail); - case 'mimivirus': return mimivirus(ctx, r, t, detail); - case 'virophage': return virophage(ctx, r); - case 'prion': return prion(ctx, r, t, seed); - case 'viroid': return viroid(ctx, r, t); - case 'satellite': return satellite(ctx, r, t); - default: break; + case 'adenovirus': + return adenovirus(ctx, r, t, detail); + case 'retrovirus': + return retrovirus(ctx, r, t, detail); + case 'tmv': + return tmv(ctx, r, detail); + case 'phage': + return phage(ctx, r, t, detail); + case 'mimivirus': + return mimivirus(ctx, r, t, detail); + case 'virophage': + return virophage(ctx, r); + case 'prion': + return prion(ctx, r, t, seed); + case 'viroid': + return viroid(ctx, r, t); + case 'satellite': + return satellite(ctx, r, t); + default: + break; } } @@ -292,7 +302,9 @@ export function drawAgent(dc: DrawCtx, e: Entity, look: EntityLook) { const detail = look.lod > 7; // Glow: hidden agents scatter light in darkfield; viruses are tagged in fluorescence. - const glow = hidden ? Math.max(dc.dark, dc.fluor) * 0.9 + (dc.hiddenAlpha - 0.08) * 0.4 : dc.fluor * 0.9 + (e.species === 'virophage' ? 0.5 : 0.08); + const glow = hidden + ? Math.max(dc.dark, dc.fluor) * 0.9 + (dc.hiddenAlpha - 0.08) * 0.4 + : dc.fluor * 0.9 + (e.species === 'virophage' ? 0.5 : 0.08); if (glow > 0.02) { place(ectx, dc.exf, e.x, e.y, 0); ectx.globalAlpha = Math.min(1, glow) * look.alpha; diff --git a/src/game/render/draw/animals.ts b/src/game/render/draw/animals.ts index f5de0fe..24ae7ec 100644 --- a/src/game/render/draw/animals.ts +++ b/src/game/render/draw/animals.ts @@ -32,7 +32,10 @@ export function drawGastrotrich(dc: DrawCtx, e: Entity, look: EntityLook) { ctx.lineWidth = r * 0.025; ctx.beginPath(); for (let i = 1; i < n - 2; i++) { - for (const [xs, ys, sign] of [[LX, LY, -1], [RX, RY, 1]] as const) { + for (const [xs, ys, sign] of [ + [LX, LY, -1], + [RX, RY, 1], + ] as const) { ctx.moveTo(xs[i], ys[i]); ctx.lineTo(xs[i] - r * 0.3, ys[i] + sign * r * 0.22); } diff --git a/src/game/render/draw/index.ts b/src/game/render/draw/index.ts index 68c8610..1fc1c16 100644 --- a/src/game/render/draw/index.ts +++ b/src/game/render/draw/index.ts @@ -2,12 +2,8 @@ import type { Entity, SpeciesId } from '../../sim/types'; import { drawAgent } from './agents'; import { drawCollotheca, drawGastrotrich, drawHydra, drawRotifer, drawTardigrade } from './animals'; import { drawNeoplasm, drawPollen } from './large'; -import { - drawBacillus, drawCocci, drawCyano, drawDebris, drawDna, drawGlucose, drawLipid, drawProteo, drawSpirillum, -} from './microbes'; -import { - drawAmoeba, drawDidinium, drawDiatom, drawEuglena, drawLacrymaria, drawParamecium, drawStentor, drawTestate, -} from './protists'; +import { drawBacillus, drawCocci, drawCyano, drawDebris, drawDna, drawGlucose, drawLipid, drawProteo, drawSpirillum } from './microbes'; +import { drawAmoeba, drawDidinium, drawDiatom, drawEuglena, drawLacrymaria, drawParamecium, drawStentor, drawTestate } from './protists'; import type { DrawCtx, EntityLook } from './util'; export type Drawer = (dc: DrawCtx, e: Entity, look: EntityLook) => void; @@ -50,9 +46,37 @@ export const DRAWERS: Record = { // Draw order: big sessile/background organisms first, small and agents last. export const LAYER: Record = { - pollen: 0, hydra: 1, stentor: 1, collotheca: 1, amoeba: 2, neoplasm: 2, tardigrade: 2, rotifer: 2, - testate: 3, gastrotrich: 3, lacrymaria: 3, paramecium: 3, didinium: 3, euglena: 3, diatom: 3, - cyano: 4, proteo: 4, bacillus: 4, spirillum: 4, cocci: 4, - debris: 5, lipid: 5, glucose: 5, dna: 5, - phage: 6, adenovirus: 6, retrovirus: 6, tmv: 6, mimivirus: 6, virophage: 6, viroid: 7, prion: 7, satellite: 7, + pollen: 0, + hydra: 1, + stentor: 1, + collotheca: 1, + amoeba: 2, + neoplasm: 2, + tardigrade: 2, + rotifer: 2, + testate: 3, + gastrotrich: 3, + lacrymaria: 3, + paramecium: 3, + didinium: 3, + euglena: 3, + diatom: 3, + cyano: 4, + proteo: 4, + bacillus: 4, + spirillum: 4, + cocci: 4, + debris: 5, + lipid: 5, + glucose: 5, + dna: 5, + phage: 6, + adenovirus: 6, + retrovirus: 6, + tmv: 6, + mimivirus: 6, + virophage: 6, + viroid: 7, + prion: 7, + satellite: 7, }; diff --git a/src/game/render/draw/microbes.ts b/src/game/render/draw/microbes.ts index 6019002..b19174a 100644 --- a/src/game/render/draw/microbes.ts +++ b/src/game/render/draw/microbes.ts @@ -161,7 +161,10 @@ export function drawDna(dc: DrawCtx, e: Entity, look: EntityLook) { ctx.stroke(); } ctx.lineWidth = r * 0.26; - for (const [sign, color] of [[1, PAL.dna.a], [-1, PAL.dna.b]] as const) { + for (const [sign, color] of [ + [1, PAL.dna.a], + [-1, PAL.dna.b], + ] as const) { ctx.strokeStyle = color; ctx.beginPath(); for (let i = 0; i <= 18; i++) { @@ -204,7 +207,16 @@ export function drawLipid(dc: DrawCtx, e: Entity, look: EntityLook) { // --- Bacteria -------------------------------------------------------------- -function flagellumTail(ctx: CanvasRenderingContext2D, x: number, y: number, angle: number, len: number, t: number, w: number, color: string) { +function flagellumTail( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + angle: number, + len: number, + t: number, + w: number, + color: string, +) { ctx.strokeStyle = color; ctx.lineWidth = w; ctx.lineCap = 'round'; @@ -228,7 +240,12 @@ export function drawCocci(dc: DrawCtx, e: Entity, look: EntityLook) { place(ctx, dc.xf, e.x, e.y, e.angle); ctx.globalAlpha = look.alpha; const pair = hash(e.seed) > 0.4; - const spheres: Array<[number, number]> = pair ? [[-r * 0.55, 0], [r * 0.55, 0]] : [[0, 0]]; + const spheres: Array<[number, number]> = pair + ? [ + [-r * 0.55, 0], + [r * 0.55, 0], + ] + : [[0, 0]]; for (const [x, y] of spheres) { ctx.beginPath(); circle(ctx, x, y, pair ? r * 0.72 : r); @@ -399,4 +416,3 @@ export function bacteriumDot(dc: DrawCtx, e: Entity, look: EntityLook, color: st circle(ctx, 0, 0, e.radius); ctx.fill(); } - diff --git a/src/game/render/draw/organelles.ts b/src/game/render/draw/organelles.ts index ab68afe..1517ace 100644 --- a/src/game/render/draw/organelles.ts +++ b/src/game/render/draw/organelles.ts @@ -6,22 +6,32 @@ import { capsule, circle, ellipse, flatShade, rgba } from './util'; // world space). `face` is the direction toward the cell center so stacked // membranes (ER, Golgi) curve around the nucleus like real endomembranes. -export function drawOrganelle( - ctx: CanvasRenderingContext2D, type: OrganelleType, r: number, t: number, seed: number, face = 0, extra = 0, -) { +export function drawOrganelle(ctx: CanvasRenderingContext2D, type: OrganelleType, r: number, t: number, seed: number, face = 0, extra = 0) { switch (type) { - case 'nucleus': return nucleus(ctx, r, t, seed, extra); - case 'mitochondrion': return mitochondrion(ctx, r, t, seed); - case 'chloroplast': return chloroplast(ctx, r, t, seed); - case 'er': return er(ctx, r, t, face); - case 'golgi': return golgi(ctx, r, t, face); - case 'lysosome': return lysosome(ctx, r, t, seed); - case 'vacuole': return vacuole(ctx, r, t); - case 'cytoskeleton': return centrosome(ctx, r, t); - case 'flagellum': return basalBody(ctx, r, PAL.flagellum); - case 'cilia': return basalBody(ctx, r, PAL.cilia, 5); - case 'extrusome': return extrusomes(ctx, r, face); - case 'eyespot': return eyespot(ctx, r); + case 'nucleus': + return nucleus(ctx, r, t, seed, extra); + case 'mitochondrion': + return mitochondrion(ctx, r, t, seed); + case 'chloroplast': + return chloroplast(ctx, r, t, seed); + case 'er': + return er(ctx, r, t, face); + case 'golgi': + return golgi(ctx, r, t, face); + case 'lysosome': + return lysosome(ctx, r, t, seed); + case 'vacuole': + return vacuole(ctx, r, t); + case 'cytoskeleton': + return centrosome(ctx, r, t); + case 'flagellum': + return basalBody(ctx, r, PAL.flagellum); + case 'cilia': + return basalBody(ctx, r, PAL.cilia, 5); + case 'extrusome': + return extrusomes(ctx, r, face); + case 'eyespot': + return eyespot(ctx, r); } } @@ -58,7 +68,12 @@ function nucleus(ctx: CanvasRenderingContext2D, r: number, t: number, seed: numb const a = seed + i * 1.7; const rr = r * (0.3 + 0.12 * i); ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr); - ctx.quadraticCurveTo(Math.cos(a + 1.2 + Math.sin(t * 0.6 + i) * 0.2) * r * 0.75, Math.sin(a + 1.2) * r * 0.75, Math.cos(a + 2.2) * rr, Math.sin(a + 2.2) * rr); + ctx.quadraticCurveTo( + Math.cos(a + 1.2 + Math.sin(t * 0.6 + i) * 0.2) * r * 0.75, + Math.sin(a + 1.2) * r * 0.75, + Math.cos(a + 2.2) * rr, + Math.sin(a + 2.2) * rr, + ); } ctx.stroke(); // Nucleolus. diff --git a/src/game/render/draw/player.ts b/src/game/render/draw/player.ts index 0dc0b78..bd22234 100644 --- a/src/game/render/draw/player.ts +++ b/src/game/render/draw/player.ts @@ -12,10 +12,24 @@ const XS = new Float32Array(96); const YS = new Float32Array(96); const PREY_COLOR: Partial> = { - cocci: '#26d9b8', bacillus: '#2fc3e8', spirillum: '#a874ff', proteo: '#b05cff', cyano: '#3ec95e', diatom: '#d29a35', - euglena: '#54d65e', paramecium: '#96c8ff', didinium: '#ff8f3d', lacrymaria: '#ff8fc6', amoeba: '#9ab8dc', - testate: '#b8742c', stentor: '#3f7bff', gastrotrich: '#dff0b2', rotifer: '#e8dfc4', collotheca: '#d7e8fa', - neoplasm: '#d81b3c', phage: '#c4c9ff', + cocci: '#26d9b8', + bacillus: '#2fc3e8', + spirillum: '#a874ff', + proteo: '#b05cff', + cyano: '#3ec95e', + diatom: '#d29a35', + euglena: '#54d65e', + paramecium: '#96c8ff', + didinium: '#ff8f3d', + lacrymaria: '#ff8fc6', + amoeba: '#9ab8dc', + testate: '#b8742c', + stentor: '#3f7bff', + gastrotrich: '#dff0b2', + rotifer: '#e8dfc4', + collotheca: '#d7e8fa', + neoplasm: '#d81b3c', + phage: '#c4c9ff', }; const FATE_TINT: Record = { @@ -208,7 +222,13 @@ function drawFateInternals(dc: DrawCtx, u: CellUnit, t: number) { const items: Array<[OrganelleType, number, number, number]> = []; switch (u.fate) { case 'photocyte': - items.push(['nucleus', 0, 0, 0.26], ['chloroplast', 0.45, 0.2, 0.16], ['chloroplast', -0.4, 0.3, 0.16], ['chloroplast', 0.05, -0.5, 0.16], ['chloroplast', -0.35, -0.3, 0.14]); + items.push( + ['nucleus', 0, 0, 0.26], + ['chloroplast', 0.45, 0.2, 0.16], + ['chloroplast', -0.4, 0.3, 0.16], + ['chloroplast', 0.05, -0.5, 0.16], + ['chloroplast', -0.35, -0.3, 0.14], + ); break; case 'ciliocyte': items.push(['nucleus', 0, 0, 0.27], ['mitochondrion', 0.42, -0.25, 0.13], ['mitochondrion', -0.42, 0.25, 0.13]); @@ -420,7 +440,7 @@ export function drawCell(dc: DrawCtx, state: GameState, u: CellUnit, alpha: numb const x = (o.px * c - o.py * s) * R; const y = (o.px * s + o.py * c) * R; const pop = easeOutBack(Math.min(1, (state.time - o.born) / 0.55)); - const spin = o.type === 'nucleus' ? 0 : (o.id * 1.37) % 6.28 + Math.sin(t * 0.4 + o.id) * 0.15; + const spin = o.type === 'nucleus' ? 0 : ((o.id * 1.37) % 6.28) + Math.sin(t * 0.4 + o.id) * 0.15; place(ctx, dc.xf, u.x + x, u.y + y, u.heading + spin, Math.max(0.01, pop)); ctx.globalAlpha = alpha * (o.misfolded ? 0.6 : 1); const face = Math.atan2(-y, -x) - u.heading - spin; diff --git a/src/game/render/draw/protists.ts b/src/game/render/draw/protists.ts index 012bcb8..1296d1a 100644 --- a/src/game/render/draw/protists.ts +++ b/src/game/render/draw/protists.ts @@ -6,7 +6,17 @@ import { circle, ellipse, flatShade, hash, place, setPatternScale, smoothClosed, const XS = new Float32Array(64); const YS = new Float32Array(64); -function cilia(ctx: CanvasRenderingContext2D, xs: Float32Array, ys: Float32Array, n: number, len: number, t: number, color: string, width: number, step = 1) { +function cilia( + ctx: CanvasRenderingContext2D, + xs: Float32Array, + ys: Float32Array, + n: number, + len: number, + t: number, + color: string, + width: number, + step = 1, +) { ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; @@ -127,7 +137,7 @@ export function drawEuglena(dc: DrawCtx, e: Entity, look: EntityLook) { const body = () => { ctx.beginPath(); const L = r * 1.6; - const W = r * 0.52 / squish; + const W = (r * 0.52) / squish; ctx.moveTo(L * 0.85, 0); ctx.bezierCurveTo(L * 0.85, -W * 1.2, -L * 0.2, -W * 1.25, -L, 0); ctx.bezierCurveTo(-L * 0.2, W * 1.25, L * 0.85, W * 1.2, L * 0.85, 0); diff --git a/src/game/render/draw/util.ts b/src/game/render/draw/util.ts index e908444..4c96712 100644 --- a/src/game/render/draw/util.ts +++ b/src/game/render/draw/util.ts @@ -71,14 +71,10 @@ const BX = new Float32Array(64); const BY = new Float32Array(64); // Organic blob: a smooth closed outline with seeded lobes and slow breathing. -export function blob( - ctx: CanvasRenderingContext2D, r: number, seed: number, t: number, wobble = 0.12, lobes = 5, n = 22, sx = 1, sy = 1, -) { +export function blob(ctx: CanvasRenderingContext2D, r: number, seed: number, t: number, wobble = 0.12, lobes = 5, n = 22, sx = 1, sy = 1) { for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; - const w = 1 - + wobble * Math.sin(a * lobes + seed + t * 0.9) - + wobble * 0.5 * Math.sin(a * (lobes + 2) - seed * 1.7 - t * 1.3); + const w = 1 + wobble * Math.sin(a * lobes + seed + t * 0.9) + wobble * 0.5 * Math.sin(a * (lobes + 2) - seed * 1.7 - t * 1.3); BX[i] = Math.cos(a) * r * w * sx; BY[i] = Math.sin(a) * r * w * sy; } diff --git a/src/game/render/gl/post.ts b/src/game/render/gl/post.ts index a143eb1..dd34597 100644 --- a/src/game/render/gl/post.ts +++ b/src/game/render/gl/post.ts @@ -82,7 +82,13 @@ export class PostFX { private emitSize = [0, 0]; constructor(canvas: HTMLCanvasElement) { - const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: false, preserveDrawingBuffer: false, powerPreference: 'high-performance' }); + const gl = canvas.getContext('webgl2', { + antialias: false, + alpha: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + powerPreference: 'high-performance', + }); if (!gl) throw new Error('WebGL2 unavailable'); this.gl = gl; this.floatOK = !!gl.getExtension('EXT_color_buffer_float'); @@ -95,15 +101,44 @@ export class PostFX { gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); this.compose = program(gl, COMPOSE_FRAG, [ - 'u_scene', 'u_biome', 'u_resolution', 'u_time', 'u_cam', 'u_ppu', 'u_biomeRect', 'u_pal', 'u_dark', 'u_fluor', - 'u_electron', 'u_playerPx', 'u_playerVel', 'u_playerR', 'u_quality', 'u_light', 'u_surge', + 'u_scene', + 'u_biome', + 'u_resolution', + 'u_time', + 'u_cam', + 'u_ppu', + 'u_biomeRect', + 'u_pal', + 'u_dark', + 'u_fluor', + 'u_electron', + 'u_playerPx', + 'u_playerVel', + 'u_playerR', + 'u_quality', + 'u_light', + 'u_surge', ]); this.prefilter = program(gl, PREFILTER_FRAG, ['u_src', 'u_emit', 'u_texel', 'u_threshold', 'u_emitGain']); this.down = program(gl, DOWN_FRAG, ['u_src', 'u_texel']); this.up = program(gl, UP_FRAG, ['u_src', 'u_texel', 'u_scatter']); this.final = program(gl, FINAL_FRAG, [ - 'u_compose', 'u_bloom', 'u_emit', 'u_resolution', 'u_time', 'u_waves', 'u_waveCount', 'u_ca', 'u_bloomStrength', - 'u_emitSharp', 'u_vignette', 'u_hurt', 'u_flash', 'u_desat', 'u_grain', 'u_warp', + 'u_compose', + 'u_bloom', + 'u_emit', + 'u_resolution', + 'u_time', + 'u_waves', + 'u_waveCount', + 'u_ca', + 'u_bloomStrength', + 'u_emitSharp', + 'u_vignette', + 'u_hurt', + 'u_flash', + 'u_desat', + 'u_grain', + 'u_warp', ]); this.sceneTex = this.makeTex(gl.LINEAR); diff --git a/src/game/render/overlay.ts b/src/game/render/overlay.ts index d9b82a4..fdc86fc 100644 --- a/src/game/render/overlay.ts +++ b/src/game/render/overlay.ts @@ -17,7 +17,14 @@ export type OverlayInputs = { const FONT = '"Sora", "Nunito Sans", system-ui, sans-serif'; -export function drawOverlay(ctx: CanvasRenderingContext2D, dpr: number, cam: Camera, state: GameState, floaters: Floater[], o: OverlayInputs) { +export function drawOverlay( + ctx: CanvasRenderingContext2D, + dpr: number, + cam: Camera, + state: GameState, + floaters: Floater[], + o: OverlayInputs, +) { const W = cam.viewW; const H = cam.viewH; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); diff --git a/src/game/render/palette.ts b/src/game/render/palette.ts index 5f84e3f..dc98d4e 100644 --- a/src/game/render/palette.ts +++ b/src/game/render/palette.ts @@ -71,6 +71,14 @@ export const BIOME_PALETTES: Record = export function hexToRgb(hex: string): [number, number, number] { const h = hex.replace('#', ''); - const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16); + const n = parseInt( + h.length === 3 + ? h + .split('') + .map((c) => c + c) + .join('') + : h, + 16, + ); return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]; } diff --git a/src/game/render/patterns.ts b/src/game/render/patterns.ts index d97af24..40dcf19 100644 --- a/src/game/render/patterns.ts +++ b/src/game/render/patterns.ts @@ -93,9 +93,53 @@ export function createPatterns(ctx: CanvasRenderingContext2D): Patterns { return tile ? ctx.createPattern(tile, 'repeat') : null; }; return { - cyto: make({ size: 128, cells: 16, edge: [0.86, 1, 0.95], edgeAlpha: 0.5, edgeWidth: 2.4, fill: [0.7, 1, 0.9], fillAlpha: 0.08, shade: 0.7, rim: 2, seed: 7 }), - shell: make({ size: 96, cells: 14, edge: [0.32, 0.18, 0.08], edgeAlpha: 0.85, edgeWidth: 3.2, fill: [0.95, 0.66, 0.32], fillAlpha: 0.32, shade: 0.5, rim: 2.5, seed: 21 }), - tumor: make({ size: 128, cells: 18, edge: [0.35, 0.02, 0.1], edgeAlpha: 0.75, edgeWidth: 3, fill: [1, 0.35, 0.42], fillAlpha: 0.35, shade: 0.9, rim: 3, seed: 33 }), - pollen: make({ size: 112, cells: 20, edge: [1, 0.93, 0.62], edgeAlpha: 0.9, edgeWidth: 3.6, fill: [0.55, 0.38, 0.1], fillAlpha: 0.5, shade: 0.6, rim: 0, seed: 44 }), + cyto: make({ + size: 128, + cells: 16, + edge: [0.86, 1, 0.95], + edgeAlpha: 0.5, + edgeWidth: 2.4, + fill: [0.7, 1, 0.9], + fillAlpha: 0.08, + shade: 0.7, + rim: 2, + seed: 7, + }), + shell: make({ + size: 96, + cells: 14, + edge: [0.32, 0.18, 0.08], + edgeAlpha: 0.85, + edgeWidth: 3.2, + fill: [0.95, 0.66, 0.32], + fillAlpha: 0.32, + shade: 0.5, + rim: 2.5, + seed: 21, + }), + tumor: make({ + size: 128, + cells: 18, + edge: [0.35, 0.02, 0.1], + edgeAlpha: 0.75, + edgeWidth: 3, + fill: [1, 0.35, 0.42], + fillAlpha: 0.35, + shade: 0.9, + rim: 3, + seed: 33, + }), + pollen: make({ + size: 112, + cells: 20, + edge: [1, 0.93, 0.62], + edgeAlpha: 0.9, + edgeWidth: 3.6, + fill: [0.55, 0.38, 0.1], + fillAlpha: 0.5, + shade: 0.6, + rim: 0, + seed: 44, + }), }; } diff --git a/src/game/render/renderer.ts b/src/game/render/renderer.ts index fdd521d..e18f0fc 100644 --- a/src/game/render/renderer.ts +++ b/src/game/render/renderer.ts @@ -147,7 +147,7 @@ export class Renderer { } private updateBiomeMap(state: GameState, cam: Camera) { - const span = Math.max(cam.viewW, cam.viewH) / cam.zoom * 1.5; + const span = (Math.max(cam.viewW, cam.viewH) / cam.zoom) * 1.5; const x0 = cam.x - span / 2; const y0 = cam.y - span / 2; this.biomeRect = [x0, y0, span, span]; @@ -183,8 +183,17 @@ export class Renderer { const hiddenAlpha = Math.min(1, 0.07 + inp.dark * 0.95 + inp.fluor * 0.95 + inp.glowBoost); const dc: DrawCtx = { - ctx: sctx, ectx, xf, exf, t: inp.time, ppu: cam.zoom, fluor: inp.fluor, dark: inp.dark, electron: inp.electron, - hiddenAlpha, patterns: this.patterns, + ctx: sctx, + ectx, + xf, + exf, + t: inp.time, + ppu: cam.zoom, + fluor: inp.fluor, + dark: inp.dark, + electron: inp.electron, + hiddenAlpha, + patterns: this.patterns, }; // Cull and bucket entities by layer. @@ -198,7 +207,8 @@ export class Renderer { const threats: Array<{ x: number; y: number }> = []; for (const e of state.entities) { if (e.dead) continue; - const reach = e.species === 'hydra' ? 4.6 : e.species === 'lacrymaria' ? 8 : e.species === 'collotheca' ? 2.6 : e.species === 'stentor' ? 3 : 1.6; + const reach = + e.species === 'hydra' ? 4.6 : e.species === 'lacrymaria' ? 8 : e.species === 'collotheca' ? 2.6 : e.species === 'stentor' ? 3 : 1.6; const m = e.radius * reach + 20; const dx = Math.abs(e.x - cam.x); const dy = Math.abs(e.y - cam.y); diff --git a/src/game/sim/ai.ts b/src/game/sim/ai.ts index 931485f..61381e9 100644 --- a/src/game/sim/ai.ts +++ b/src/game/sim/ai.ts @@ -87,9 +87,27 @@ function smallPrey(o: Entity, maxRadius: number, self: Entity) { return PREY_GROUPS.has(d.group) && !d.invulnerable && o.radius < maxRadius && o.species !== self.species; } -export function fireProjectile(state: GameState, x: number, y: number, angle: number, speed: number, damage: number, life: number, owner: 'player' | 'enemy', stun = 0) { +export function fireProjectile( + state: GameState, + x: number, + y: number, + angle: number, + speed: number, + damage: number, + life: number, + owner: 'player' | 'enemy', + stun = 0, +) { state.projectiles.push({ - id: state.nextId++, x, y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life, damage, stun, owner, + id: state.nextId++, + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life, + damage, + stun, + owner, }); } @@ -188,7 +206,12 @@ const behaviors: Record void> = { if (unit) { e.targetId = -unit.id; } else { - const prey = state.grid.nearest(e.x, e.y, def.sense * 0.7, (o) => o.species === 'paramecium' || (smallPrey(o, e.radius * 1.6, e) && SPECIES[o.species].group === 'protist')); + const prey = state.grid.nearest( + e.x, + e.y, + def.sense * 0.7, + (o) => o.species === 'paramecium' || (smallPrey(o, e.radius * 1.6, e) && SPECIES[o.species].group === 'protist'), + ); e.targetId = prey ? prey.id : 0; } if (e.targetId) { @@ -606,8 +629,12 @@ const behaviors: Record void> = { } } if (!e.targetId) { - const host = state.grid.nearest(e.x, e.y, def.sense, (o) => - !o.infectedBy && !o.attachedTo && o.id !== e.id && hostClassesOf(o.species).some((c) => infects.includes(c))); + const host = state.grid.nearest( + e.x, + e.y, + def.sense, + (o) => !o.infectedBy && !o.attachedTo && o.id !== e.id && hostClassesOf(o.species).some((c) => infects.includes(c)), + ); if (host) e.targetId = host.id; } } diff --git a/src/game/sim/combat.ts b/src/game/sim/combat.ts index 9cab99d..948d8d2 100644 --- a/src/game/sim/combat.ts +++ b/src/game/sim/combat.ts @@ -3,9 +3,7 @@ import { damageEntity, killEntity, yieldsOf } from './entities'; import { attachAgent, clearAttachments, shakeAttachments } from './infection'; import { angleDiff } from './math'; import { ABILITY_INFO, countOrganelles } from './organelles'; -import { - canUnitEat, damageUnit, engulf, isUntouchable, livingUnits, playerHostClasses, releaseCapture, vacuoleSlotsFor, -} from './player'; +import { canUnitEat, damageUnit, engulf, isUntouchable, livingUnits, playerHostClasses, releaseCapture, vacuoleSlotsFor } from './player'; import { SPECIES } from './species'; import { emit, removeEntity, spawnEntity, type GameState, type SimInput } from './state'; import type { AbilityId, CellUnit, Entity } from './types'; @@ -246,7 +244,17 @@ export function castAbility(state: GameState, id: AbilityId, input: SimInput): b for (let i = 0; i < n; i++) { const spread = n === 1 ? 0 : (i / (n - 1) - 0.5) * 0.36; const a = angle + spread; - fireProjectile(state, prime.x + Math.cos(a) * prime.radius, prime.y + Math.sin(a) * prime.radius, a, 700, p.traits.dartDamage, 0.6, 'player', p.traits.dartStun); + fireProjectile( + state, + prime.x + Math.cos(a) * prime.radius, + prime.y + Math.sin(a) * prime.radius, + a, + 700, + p.traits.dartDamage, + 0.6, + 'player', + p.traits.dartStun, + ); } break; } @@ -275,7 +283,8 @@ export function castAbility(state: GameState, id: AbilityId, input: SimInput): b for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; spawnEntity(state, 'virophage', prime.x + Math.cos(a) * prime.radius, prime.y + Math.sin(a) * prime.radius, { - vx: Math.cos(a) * 220, vy: Math.sin(a) * 220, + vx: Math.cos(a) * 220, + vy: Math.sin(a) * 220, }); } p.storedVirophages = 0; diff --git a/src/game/sim/director.ts b/src/game/sim/director.ts index 7b3fa5a..514d050 100644 --- a/src/game/sim/director.ts +++ b/src/game/sim/director.ts @@ -24,40 +24,53 @@ const has = (s: GameState, type: Parameters[1]) => count export const OBJECTIVES: ObjectiveDef[] = [ { - id: 'feed', title: 'Ignite metabolism', reward: 0, + id: 'feed', + title: 'Ignite metabolism', + reward: 0, text: 'Swim into glucose crystals. Glycolysis turns each one into ATP.', check: (s) => s.stats.glucose >= 10, progress: (s) => `${Math.min(10, s.stats.glucose)}/10 glucose`, nav: ['glucose'], }, { - id: 'mito', title: 'Endosymbiosis', reward: 1, + id: 'mito', + title: 'Endosymbiosis', + reward: 1, text: 'Engulf a purple α-proteobacterium. Instead of digesting it, you keep it as your first mitochondrion.', check: (s) => has(s, 'mitochondrion'), nav: ['proteo'], intro: ['proteo', 'proteo', 'cyano'], }, { - id: 'hunt', title: 'Gather biomass', reward: 1, + id: 'hunt', + title: 'Gather biomass', + reward: 1, text: 'Engulf microbes smaller than you (dashed green ring). Digested prey becomes spare biomass for building.', check: (s) => s.stats.eaten - s.objective.startEaten >= 6 || has(s, 'er'), - progress: (s) => `${Math.min(6, s.stats.eaten - s.objective.startEaten)}/6 engulfed · ${Math.floor(Math.max(0, spendableBiomass(s.player)))} spare`, + progress: (s) => + `${Math.min(6, s.stats.eaten - s.objective.startEaten)}/6 engulfed · ${Math.floor(Math.max(0, spendableBiomass(s.player)))} spare`, nav: ['bacillus', 'cocci', 'spirillum', 'euglena', 'diatom'], intro: ['cocci', 'bacillus'], }, { - id: 'er', title: 'Endomembrane system', reward: 2, + id: 'er', + title: 'Endomembrane system', + reward: 2, text: 'Press TAB to zoom into your ultrastructure and grow an endoplasmic reticulum.', check: (s) => has(s, 'er'), }, { - id: 'arm', title: 'Arm the cell', reward: 1, + id: 'arm', + title: 'Arm the cell', + reward: 1, text: 'Grow a Golgi apparatus, then a lysosome. Lysosome burst is on key 1.', check: (s) => has(s, 'lysosome'), progress: (s) => `${(has(s, 'golgi') ? 1 : 0) + (has(s, 'lysosome') ? 1 : 0)}/2 built`, }, { - id: 'divide', title: 'Cytokinesis', reward: 2, + id: 'divide', + title: 'Cytokinesis', + reward: 2, text: 'Reach critical mass and gather DNA for genome replication, then divide with R.', check: (s) => s.player.generation >= 2, progress: (s) => { @@ -67,19 +80,25 @@ export const OBJECTIVES: ObjectiveDef[] = [ nav: (s) => (s.player.dna < divisionDnaCost(s.player.generation) ? ['dna', 'phage'] : null), }, { - id: 'colony', title: 'A colony forms', reward: 2, + id: 'colony', + title: 'A colony forms', + reward: 2, text: 'Divide again. Your daughters stay attached and specialize.', check: (s) => livingUnits(s.player).length >= 3, progress: (s) => `${livingUnits(s.player).length}/3 cells`, }, { - id: 'journal', title: 'Field journal', reward: 3, + id: 'journal', + title: 'Field journal', + reward: 3, text: 'Catalogue 16 species. Switch microscope light (Q) to reveal hidden agents.', check: (s) => s.discovered.size >= 16, progress: (s) => `${s.discovered.size}/16 species`, }, { - id: 'multicellular', title: 'Multicellularity', reward: 5, + id: 'multicellular', + title: 'Multicellularity', + reward: 5, text: 'Grow a colony of 6 specialized cells and cross the threshold to complex life.', check: (s) => livingUnits(s.player).length >= 6, progress: (s) => `${livingUnits(s.player).length}/6 cells`, @@ -159,15 +178,17 @@ function runEvent(state: GameState, kind: DirectorEventKind) { const ring = Math.max(view.halfW, view.halfH) + 140; const plant = countOrganelles(p, 'chloroplast', true) > 0; for (let i = 0; i < n; i++) { - const species = weightedPick(rng, { - adenovirus: 3, - retrovirus: pressure > 1 ? 1.2 : 0, - mimivirus: pressure > 1.3 ? 0.5 : 0, - tmv: plant ? 1 : 0, - }) ?? 'adenovirus'; + const species = + weightedPick(rng, { + adenovirus: 3, + retrovirus: pressure > 1 ? 1.2 : 0, + mimivirus: pressure > 1.3 ? 0.5 : 0, + tmv: plant ? 1 : 0, + }) ?? 'adenovirus'; const a = (i / n) * Math.PI * 2 + rng() * 0.3; const e = spawnEntity(state, species, prime.x + Math.cos(a) * ring, prime.y + Math.sin(a) * ring, { - vx: -Math.cos(a) * 60, vy: -Math.sin(a) * 60, + vx: -Math.cos(a) * 60, + vy: -Math.sin(a) * 60, }); if (rng() < 0.3) e.satellites = randInt(rng, 2, 3); } @@ -193,7 +214,12 @@ function runEvent(state: GameState, kind: DirectorEventKind) { if (infected < 3) { at = ahead(state, 480); for (let i = 0; i < 8; i++) { - const b = spawnEntity(state, pick(rng, ['cocci', 'bacillus', 'cocci'] as const), at.x + rand(rng, -90, 90), at.y + rand(rng, -90, 90)); + const b = spawnEntity( + state, + pick(rng, ['cocci', 'bacillus', 'cocci'] as const), + at.x + rand(rng, -90, 90), + at.y + rand(rng, -90, 90), + ); b.infectedBy = 'phage'; b.infection = rng() * 4; } @@ -284,8 +310,7 @@ const UNLOCK_CHECKS: Record boolean> = { radar: (s) => s.objective.index >= 1, biomass: (s) => s.stats.eaten >= 1 || s.objective.index >= 2, // Dash arrives early, or immediately if something grabs you first. - dash: (s) => s.stats.eaten >= 2 || s.objective.index >= 2 || !!s.player.capture - || s.player.units.some((u) => u.attached.length > 0), + dash: (s) => s.stats.eaten >= 2 || s.objective.index >= 2 || !!s.player.capture || s.player.units.some((u) => u.attached.length > 0), architect: (s) => s.objective.index >= 2, dna: (s) => s.player.dna >= 0.5 || s.objective.index >= 4, microscope: (s) => s.objective.index >= 3 || s.time > 540, diff --git a/src/game/sim/infection.ts b/src/game/sim/infection.ts index 3e2ceed..5bb3d48 100644 --- a/src/game/sim/infection.ts +++ b/src/game/sim/infection.ts @@ -120,7 +120,8 @@ export function updateInfection(state: GameState, dt: number) { for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2 + state.rng(); spawnEntity(state, virus, prime.x + Math.cos(a) * prime.radius, prime.y + Math.sin(a) * prime.radius, { - vx: Math.cos(a) * 160, vy: Math.sin(a) * 160, + vx: Math.cos(a) * 160, + vy: Math.sin(a) * 160, }).stun = 2; } damageUnit(state, prime, prime.maxIntegrity * 0.34, prime.x, prime.y, 'burst', { bypassCooldown: true, cause: 'lysis' }); @@ -191,7 +192,8 @@ export function updateInfection(state: GameState, dt: number) { inf.colonyTimer = 0; inf.colonies = Math.min(8, inf.colonies + 1); } - if (inf.colonies > 4) damageUnit(state, prime, (inf.colonies - 4) * 1.1 * dt, prime.x, prime.y, 'misc', { bypassCooldown: true, cause: 'rupture' }); + if (inf.colonies > 4) + damageUnit(state, prime, (inf.colonies - 4) * 1.1 * dt, prime.x, prime.y, 'misc', { bypassCooldown: true, cause: 'rupture' }); } } diff --git a/src/game/sim/membrane.ts b/src/game/sim/membrane.ts index 4e9c5c4..ff53730 100644 --- a/src/game/sim/membrane.ts +++ b/src/game/sim/membrane.ts @@ -64,8 +64,10 @@ export function stepMembrane(m: MembraneState, input: MembraneInput) { if (rigid) { target = R * 0.94; } else { - const wobble = 0.075 * Math.sin(3 * a + time * 0.55 + seed) + 0.045 * Math.sin(5 * a - time * 0.8 + seed * 2.1) - + 0.02 * Math.sin(7 * a + time * 1.3 + seed * 0.7); + const wobble = + 0.075 * Math.sin(3 * a + time * 0.55 + seed) + + 0.045 * Math.sin(5 * a - time * 0.8 + seed * 2.1) + + 0.02 * Math.sin(7 * a + time * 1.3 + seed * 0.7); target = R * (1 + wobble * input.liveliness); const c = Math.cos(a - heading); // Elongate along the swim axis and slim the flanks. @@ -164,7 +166,7 @@ export function membraneImpulse(m: MembraneState, angle: number, strength: numbe // Interpolated membrane radius at an arbitrary angle. export function membraneRadiusAt(m: MembraneState, angle: number): number { const n = m.r.length; - let f = ((angle % TAU) + TAU) % TAU / TAU * n; + let f = ((((angle % TAU) + TAU) % TAU) / TAU) * n; const i0 = Math.floor(f) % n; const i1 = (i0 + 1) % n; f -= Math.floor(f); diff --git a/src/game/sim/organelles.ts b/src/game/sim/organelles.ts index 0bbaada..99e6872 100644 --- a/src/game/sim/organelles.ts +++ b/src/game/sim/organelles.ts @@ -19,69 +19,169 @@ export type OrganelleDef = { export const ORGANELLES: Record = { nucleus: { - type: 'nucleus', name: 'Nucleus', short: 'NUC', role: 'Genome command center', + type: 'nucleus', + name: 'Nucleus', + short: 'NUC', + role: 'Genome command center', science: 'Holds the genome behind a double membrane studded with pores. All other systems run on its instructions.', - cost: { biomass: 0, dna: 0 }, requires: [], ring: 'core', max: 1, size: 0.27, + cost: { biomass: 0, dna: 0 }, + requires: [], + ring: 'core', + max: 1, + size: 0.27, }, mitochondrion: { - type: 'mitochondrion', name: 'Mitochondrion', short: 'MITO', role: 'ATP power plant', + type: 'mitochondrion', + name: 'Mitochondrion', + short: 'MITO', + role: 'ATP power plant', science: 'Pumps protons across folded cristae; ATP synthase, a spinning molecular turbine, turns that gradient into ATP.', - cost: { biomass: 16, dna: 0 }, requires: ['mitochondrion'], endosymbiont: 'proteo', ring: 'any', max: 8, size: 0.17, + cost: { biomass: 16, dna: 0 }, + requires: ['mitochondrion'], + endosymbiont: 'proteo', + ring: 'any', + max: 8, + size: 0.17, }, chloroplast: { - type: 'chloroplast', name: 'Chloroplast', short: 'CHLR', role: 'Light into glucose', + type: 'chloroplast', + name: 'Chloroplast', + short: 'CHLR', + role: 'Light into glucose', science: 'Stacks of thylakoid membranes capture photons and fix CO₂ into sugar. Output scales with local light.', - cost: { biomass: 16, dna: 0 }, requires: ['chloroplast'], endosymbiont: 'cyano', ring: 'any', max: 6, size: 0.17, + cost: { biomass: 16, dna: 0 }, + requires: ['chloroplast'], + endosymbiont: 'cyano', + ring: 'any', + max: 6, + size: 0.17, }, er: { - type: 'er', name: 'Endoplasmic reticulum', short: 'ER', role: 'Protein factory', + type: 'er', + name: 'Endoplasmic reticulum', + short: 'ER', + role: 'Protein factory', science: 'A folded membrane maze studded with ribosomes. Builds the proteins for repair, export and RNA interference.', - cost: { biomass: 18, dna: 0 }, requires: [], ring: 'any', max: 2, ability: 'rnai', size: 0.2, + cost: { biomass: 18, dna: 0 }, + requires: [], + ring: 'any', + max: 2, + ability: 'rnai', + size: 0.2, }, golgi: { - type: 'golgi', name: 'Golgi apparatus', short: 'GOLGI', role: 'Packaging and export', + type: 'golgi', + name: 'Golgi apparatus', + short: 'GOLGI', + role: 'Packaging and export', science: 'Stacked cisternae that tag, sort and ship proteins in vesicles. It assembles lysosomes and extrusomes.', - cost: { biomass: 22, dna: 1 }, requires: ['er'], ring: 'any', max: 2, size: 0.18, + cost: { biomass: 22, dna: 1 }, + requires: ['er'], + ring: 'any', + max: 2, + size: 0.18, }, lysosome: { - type: 'lysosome', name: 'Lysosome', short: 'LYSO', role: 'Acid digestion, burst attack', + type: 'lysosome', + name: 'Lysosome', + short: 'LYSO', + role: 'Acid digestion, burst attack', science: 'Acidic bubbles of digestive enzymes. They break down prey, recycle misfolded proteins (autophagy) and kill invaders.', - cost: { biomass: 12, dna: 1 }, requires: ['golgi'], ring: 'any', max: 4, ability: 'lysosome', size: 0.1, + cost: { biomass: 12, dna: 1 }, + requires: ['golgi'], + ring: 'any', + max: 4, + ability: 'lysosome', + size: 0.1, }, vacuole: { - type: 'vacuole', name: 'Vacuole', short: 'VAC', role: 'Storage', + type: 'vacuole', + name: 'Vacuole', + short: 'VAC', + role: 'Storage', science: 'A membrane-bound reservoir. Stores glucose and water and buffers the cell against starvation.', - cost: { biomass: 14, dna: 0 }, requires: [], ring: 'any', max: 3, size: 0.2, + cost: { biomass: 14, dna: 0 }, + requires: [], + ring: 'any', + max: 3, + size: 0.2, }, cytoskeleton: { - type: 'cytoskeleton', name: 'Cytoskeleton hub', short: 'CYTO', role: 'Speed, toughness, encystment', + type: 'cytoskeleton', + name: 'Cytoskeleton hub', + short: 'CYTO', + role: 'Speed, toughness, encystment', science: 'A centrosome organizing microtubules and actin. Scaffolds the membrane and powers crawling and cyst formation.', - cost: { biomass: 14, dna: 0 }, requires: [], ring: 'any', max: 2, ability: 'encyst', size: 0.14, + cost: { biomass: 14, dna: 0 }, + requires: [], + ring: 'any', + max: 2, + ability: 'encyst', + size: 0.14, }, flagellum: { - type: 'flagellum', name: 'Flagellum', short: 'FLAG', role: 'Propulsion', + type: 'flagellum', + name: 'Flagellum', + short: 'FLAG', + role: 'Propulsion', science: 'A whip of nine microtubule doublets around two singlets (the 9+2 axoneme), beaten by dynein motors.', - cost: { biomass: 20, dna: 2 }, requires: ['cytoskeleton'], ring: 'membrane', max: 3, size: 0.12, + cost: { biomass: 20, dna: 2 }, + requires: ['cytoskeleton'], + ring: 'membrane', + max: 3, + size: 0.12, }, cilia: { - type: 'cilia', name: 'Cilia field', short: 'CILIA', role: 'Agility and feeding currents', + type: 'cilia', + name: 'Cilia field', + short: 'CILIA', + role: 'Agility and feeding currents', science: 'Thousands of short hairs beating in metachronal waves. They row the cell and sweep food toward it.', - cost: { biomass: 18, dna: 1 }, requires: ['cytoskeleton'], ring: 'membrane', max: 4, size: 0.12, + cost: { biomass: 18, dna: 1 }, + requires: ['cytoskeleton'], + ring: 'membrane', + max: 4, + size: 0.12, }, extrusome: { - type: 'extrusome', name: 'Extrusome battery', short: 'EXTR', role: 'Toxicyst darts', + type: 'extrusome', + name: 'Extrusome battery', + short: 'EXTR', + role: 'Toxicyst darts', science: 'Docked capsules that fire on command, like the toxicysts Didinium uses to harpoon Paramecium.', - cost: { biomass: 16, dna: 2 }, requires: ['golgi'], ring: 'membrane', max: 3, ability: 'toxicyst', size: 0.12, + cost: { biomass: 16, dna: 2 }, + requires: ['golgi'], + ring: 'membrane', + max: 3, + ability: 'toxicyst', + size: 0.12, }, eyespot: { - type: 'eyespot', name: 'Eyespot', short: 'EYE', role: 'Sensing and phototaxis', + type: 'eyespot', + name: 'Eyespot', + short: 'EYE', + role: 'Sensing and phototaxis', science: 'A carotenoid-pigmented stigma shading a light sensor, so the cell can steer toward light.', - cost: { biomass: 10, dna: 1 }, requires: ['chloroplast'], ring: 'membrane', max: 1, size: 0.1, + cost: { biomass: 10, dna: 1 }, + requires: ['chloroplast'], + ring: 'membrane', + max: 1, + size: 0.1, }, }; export const BUILD_ORDER: OrganelleType[] = [ - 'mitochondrion', 'chloroplast', 'er', 'golgi', 'lysosome', 'vacuole', 'cytoskeleton', 'flagellum', 'cilia', 'extrusome', 'eyespot', + 'mitochondrion', + 'chloroplast', + 'er', + 'golgi', + 'lysosome', + 'vacuole', + 'cytoskeleton', + 'flagellum', + 'cilia', + 'extrusome', + 'eyespot', ]; export type Slot = { id: number; x: number; y: number; ring: 'core' | 'inner' | 'outer' }; @@ -164,7 +264,11 @@ export function canBuild(player: Player, type: OrganelleType, storedBiomass: num for (const req of def.requires) { if (countOrganelles(player, req) === 0) { if (def.endosymbiont && req === type) { - return { ok: false, reason: def.endosymbiont === 'proteo' ? 'Engulf an α-proteobacterium first' : 'Engulf a cyanobacterium first', cost }; + return { + ok: false, + reason: def.endosymbiont === 'proteo' ? 'Engulf an α-proteobacterium first' : 'Engulf a cyanobacterium first', + cost, + }; } return { ok: false, reason: `Needs ${ORGANELLES[req].name}`, cost }; } @@ -179,45 +283,219 @@ export function canBuild(player: Player, type: OrganelleType, storedBiomass: num export type FateDef = { id: CellFate; name: string; role: string; science: string; color: string }; export const FATES: Record, FateDef> = { - photocyte: { id: 'photocyte', name: 'Photocyte', role: '+Glucose from light', science: 'A daughter packed with chloroplasts, like the green cells of Volvox.', color: '#7be04a' }, - ciliocyte: { id: 'ciliocyte', name: 'Ciliocyte', role: '+Speed and agility', science: 'A motor cell fringed with cilia that rows the colony along.', color: '#4fd8ff' }, - phagocyte: { id: 'phagocyte', name: 'Phagocyte', role: 'Engulfs larger prey, digests faster', science: 'A hunter-gatherer cell specialized for phagocytosis, the ancestor of macrophages.', color: '#ffb43d' }, - cnidocyte: { id: 'cnidocyte', name: 'Cnidocyte', role: 'Stings attackers, +burst damage', science: 'A stinging cell loaded with nematocysts, borrowed from the Hydra playbook.', color: '#ff4f8b' }, - germ: { id: 'germ', name: 'Germ cell', role: '+DNA over time, cheaper builds', science: 'A reproductive cell that keeps the lineage\'s genome pristine.', color: '#b07bff' }, + photocyte: { + id: 'photocyte', + name: 'Photocyte', + role: '+Glucose from light', + science: 'A daughter packed with chloroplasts, like the green cells of Volvox.', + color: '#7be04a', + }, + ciliocyte: { + id: 'ciliocyte', + name: 'Ciliocyte', + role: '+Speed and agility', + science: 'A motor cell fringed with cilia that rows the colony along.', + color: '#4fd8ff', + }, + phagocyte: { + id: 'phagocyte', + name: 'Phagocyte', + role: 'Engulfs larger prey, digests faster', + science: 'A hunter-gatherer cell specialized for phagocytosis, the ancestor of macrophages.', + color: '#ffb43d', + }, + cnidocyte: { + id: 'cnidocyte', + name: 'Cnidocyte', + role: 'Stings attackers, +burst damage', + science: 'A stinging cell loaded with nematocysts, borrowed from the Hydra playbook.', + color: '#ff4f8b', + }, + germ: { + id: 'germ', + name: 'Germ cell', + role: '+DNA over time, cheaper builds', + science: "A reproductive cell that keeps the lineage's genome pristine.", + color: '#b07bff', + }, }; export type MutationDef = { id: MutationId; name: string; effect: string; science: string; maxStacks: number }; export const MUTATIONS: Record = { - pellicle: { id: 'pellicle', name: 'Pellicle', effect: '+25% max integrity, -6% damage', science: 'A protein lattice under the membrane, like a ciliate\'s armor.', maxStacks: 3 }, - contractileVacuole: { id: 'contractileVacuole', name: 'Contractile vacuole', effect: '+0.8 integrity regen/s', science: 'A pump that bails out excess water so the cell never bursts.', maxStacks: 2 }, - cytostome: { id: 'cytostome', name: 'Cytostome', effect: 'Engulf 10% larger prey', science: 'A permanent "cell mouth" that widens phagocytosis.', maxStacks: 2 }, - cristae: { id: 'cristae', name: 'Deep cristae', effect: '+35% ATP per mitochondrion', science: 'More inner-membrane folds mean more ATP synthase turbines.', maxStacks: 2 }, - phototaxis: { id: 'phototaxis', name: 'Light-harvesting antennae', effect: '+45% photosynthesis', science: 'Extra pigment complexes funnel more photons to the reaction centers.', maxStacks: 2 }, - interferon: { id: 'interferon', name: 'Antiviral cascade', effect: 'Viruses replicate 30% slower', science: 'Innate sensors detect viral RNA and throttle the hijacked machinery.', maxStacks: 2 }, - chaperonin: { id: 'chaperonin', name: 'Chaperonins', effect: 'Prions misfold half as often; organelles refold slowly', science: 'HSP60 barrels give misfolded proteins a private chamber to refold.', maxStacks: 1 }, - dsup: { id: 'dsup', name: 'Dsup gene', effect: '-12% damage taken', science: 'The tardigrade damage-suppressor protein that shields DNA.', maxStacks: 2 }, - glycogen: { id: 'glycogen', name: 'Glycogen granules', effect: '+50% glucose storage, +15 ATP cap', science: 'Branched glucose polymers packed for lean times.', maxStacks: 2 }, - hydrolase: { id: 'hydrolase', name: 'Hydrolases', effect: '+40% digestion speed', science: 'Sharper digestive enzymes inside every food vacuole.', maxStacks: 2 }, - flagellarMotor: { id: 'flagellarMotor', name: 'Turbo dynein', effect: '+12% speed', science: 'Faster motor proteins along every microtubule.', maxStacks: 2 }, - cadherin: { id: 'cadherin', name: 'Cadherin junctions', effect: 'Colony cells take 25% less damage', science: 'Calcium-dependent adhesion proteins, the glue of animal tissue.', maxStacks: 1 }, - bioluminescence: { id: 'bioluminescence', name: 'Bioluminescence', effect: 'Glow reveals hidden agents nearby', science: 'Luciferase oxidizes luciferin to emit cold light, as dinoflagellates do.', maxStacks: 1 }, - venom: { id: 'venom', name: 'Toxicyst venom', effect: 'Darts +30% damage, +0.8s stun', science: 'Paralytic toxins packed into each extrusome capsule.', maxStacks: 2 }, - endosymbiontPact: { id: 'endosymbiontPact', name: 'Endosymbiont pact', effect: 'Intracellular bacteria make ATP instead of stealing', science: 'Parasite becomes partner, the same deal that made mitochondria.', maxStacks: 1 }, - pseudopodSurge: { id: 'pseudopodSurge', name: 'Pseudopod surge', effect: 'Dash recharges 30% faster', science: 'Explosive actin polymerization at the leading edge.', maxStacks: 2 }, - hgt: { id: 'hgt', name: 'Horizontal gene transfer', effect: 'Copy a random trait, +3 DNA', science: 'Genes jump between unrelated organisms. Bacteria do it constantly.', maxStacks: 3 }, - quorum: { id: 'quorum', name: 'Quorum sensing', effect: '+DNA trickle, +5% build discount', science: 'Chemical chatter that lets cells act together.', maxStacks: 2 }, + pellicle: { + id: 'pellicle', + name: 'Pellicle', + effect: '+25% max integrity, -6% damage', + science: "A protein lattice under the membrane, like a ciliate's armor.", + maxStacks: 3, + }, + contractileVacuole: { + id: 'contractileVacuole', + name: 'Contractile vacuole', + effect: '+0.8 integrity regen/s', + science: 'A pump that bails out excess water so the cell never bursts.', + maxStacks: 2, + }, + cytostome: { + id: 'cytostome', + name: 'Cytostome', + effect: 'Engulf 10% larger prey', + science: 'A permanent "cell mouth" that widens phagocytosis.', + maxStacks: 2, + }, + cristae: { + id: 'cristae', + name: 'Deep cristae', + effect: '+35% ATP per mitochondrion', + science: 'More inner-membrane folds mean more ATP synthase turbines.', + maxStacks: 2, + }, + phototaxis: { + id: 'phototaxis', + name: 'Light-harvesting antennae', + effect: '+45% photosynthesis', + science: 'Extra pigment complexes funnel more photons to the reaction centers.', + maxStacks: 2, + }, + interferon: { + id: 'interferon', + name: 'Antiviral cascade', + effect: 'Viruses replicate 30% slower', + science: 'Innate sensors detect viral RNA and throttle the hijacked machinery.', + maxStacks: 2, + }, + chaperonin: { + id: 'chaperonin', + name: 'Chaperonins', + effect: 'Prions misfold half as often; organelles refold slowly', + science: 'HSP60 barrels give misfolded proteins a private chamber to refold.', + maxStacks: 1, + }, + dsup: { + id: 'dsup', + name: 'Dsup gene', + effect: '-12% damage taken', + science: 'The tardigrade damage-suppressor protein that shields DNA.', + maxStacks: 2, + }, + glycogen: { + id: 'glycogen', + name: 'Glycogen granules', + effect: '+50% glucose storage, +15 ATP cap', + science: 'Branched glucose polymers packed for lean times.', + maxStacks: 2, + }, + hydrolase: { + id: 'hydrolase', + name: 'Hydrolases', + effect: '+40% digestion speed', + science: 'Sharper digestive enzymes inside every food vacuole.', + maxStacks: 2, + }, + flagellarMotor: { + id: 'flagellarMotor', + name: 'Turbo dynein', + effect: '+12% speed', + science: 'Faster motor proteins along every microtubule.', + maxStacks: 2, + }, + cadherin: { + id: 'cadherin', + name: 'Cadherin junctions', + effect: 'Colony cells take 25% less damage', + science: 'Calcium-dependent adhesion proteins, the glue of animal tissue.', + maxStacks: 1, + }, + bioluminescence: { + id: 'bioluminescence', + name: 'Bioluminescence', + effect: 'Glow reveals hidden agents nearby', + science: 'Luciferase oxidizes luciferin to emit cold light, as dinoflagellates do.', + maxStacks: 1, + }, + venom: { + id: 'venom', + name: 'Toxicyst venom', + effect: 'Darts +30% damage, +0.8s stun', + science: 'Paralytic toxins packed into each extrusome capsule.', + maxStacks: 2, + }, + endosymbiontPact: { + id: 'endosymbiontPact', + name: 'Endosymbiont pact', + effect: 'Intracellular bacteria make ATP instead of stealing', + science: 'Parasite becomes partner, the same deal that made mitochondria.', + maxStacks: 1, + }, + pseudopodSurge: { + id: 'pseudopodSurge', + name: 'Pseudopod surge', + effect: 'Dash recharges 30% faster', + science: 'Explosive actin polymerization at the leading edge.', + maxStacks: 2, + }, + hgt: { + id: 'hgt', + name: 'Horizontal gene transfer', + effect: 'Copy a random trait, +3 DNA', + science: 'Genes jump between unrelated organisms. Bacteria do it constantly.', + maxStacks: 3, + }, + quorum: { + id: 'quorum', + name: 'Quorum sensing', + effect: '+DNA trickle, +5% build discount', + science: 'Chemical chatter that lets cells act together.', + maxStacks: 2, + }, }; export const MUTATION_POOL = Object.keys(MUTATIONS) as MutationId[]; export const ABILITY_INFO: Record = { - dash: { name: 'Pseudopod dash', atp: 9, cooldown: 2.4, key: 'SPACE', description: 'Burst of speed. Shakes off attached viruses and breaks grips.' }, - lysosome: { name: 'Lysosome burst', atp: 24, cooldown: 7, key: '1', description: 'Acid shockwave: damages hostiles, clears agents, recycles misfolded organelles.' }, - toxicyst: { name: 'Toxicyst volley', atp: 12, cooldown: 2.6, key: '2', description: 'Fires harpoon darts toward your aim that damage and stun.' }, - rnai: { name: 'RNA interference', atp: 22, cooldown: 10, key: '3', description: 'Shreds viral RNA: cuts viral load, excises a provirus, strips viroids.' }, - encyst: { name: 'Encyst', atp: 18, cooldown: 16, key: '4', description: 'Harden into a cyst: invulnerable and immobile for 3.5s. Breaks any grip.' }, - virophage: { name: 'Release virophages', atp: 6, cooldown: 4, key: '5', description: 'Releases your stored virophage swarm against giant viruses.' }, + dash: { + name: 'Pseudopod dash', + atp: 9, + cooldown: 2.4, + key: 'SPACE', + description: 'Burst of speed. Shakes off attached viruses and breaks grips.', + }, + lysosome: { + name: 'Lysosome burst', + atp: 24, + cooldown: 7, + key: '1', + description: 'Acid shockwave: damages hostiles, clears agents, recycles misfolded organelles.', + }, + toxicyst: { + name: 'Toxicyst volley', + atp: 12, + cooldown: 2.6, + key: '2', + description: 'Fires harpoon darts toward your aim that damage and stun.', + }, + rnai: { + name: 'RNA interference', + atp: 22, + cooldown: 10, + key: '3', + description: 'Shreds viral RNA: cuts viral load, excises a provirus, strips viroids.', + }, + encyst: { + name: 'Encyst', + atp: 18, + cooldown: 16, + key: '4', + description: 'Harden into a cyst: invulnerable and immobile for 3.5s. Breaks any grip.', + }, + virophage: { + name: 'Release virophages', + atp: 6, + cooldown: 4, + key: '5', + description: 'Releases your stored virophage swarm against giant viruses.', + }, }; export function baseTraits(): Traits { @@ -338,12 +616,26 @@ export function computeTraits(player: Player): Traits { for (const unit of player.units.slice(1)) { if (unit.dead) continue; switch (unit.fate) { - case 'photocyte': t.photoRate += 0.45; break; - case 'ciliocyte': speedMult += 0.09; t.accel += 1.5; break; - case 'phagocyte': t.digestion += 0.3; t.vacuoleSlots += 1; break; - case 'cnidocyte': t.lysoDamage += 6; break; - case 'germ': t.dnaRate += 0.04; t.buildDiscount += 0.04; break; - default: break; + case 'photocyte': + t.photoRate += 0.45; + break; + case 'ciliocyte': + speedMult += 0.09; + t.accel += 1.5; + break; + case 'phagocyte': + t.digestion += 0.3; + t.vacuoleSlots += 1; + break; + case 'cnidocyte': + t.lysoDamage += 6; + break; + case 'germ': + t.dnaRate += 0.04; + t.buildDiscount += 0.04; + break; + default: + break; } } diff --git a/src/game/sim/player.ts b/src/game/sim/player.ts index 1db4da4..a3fd42d 100644 --- a/src/game/sim/player.ts +++ b/src/game/sim/player.ts @@ -2,15 +2,22 @@ import { yieldsOf } from './entities'; import { approach, clamp, TAU, turnToward } from './math'; import { membraneImpulse, stepMembrane, type MembraneBody } from './membrane'; import { - canBuild, computeTraits, countOrganelles, FATES, firstFreeSlot, MUTATION_POOL, MUTATIONS, mutationStacks, ORGANELLES, - SLOTS, slotAccepts, + canBuild, + computeTraits, + countOrganelles, + FATES, + firstFreeSlot, + MUTATION_POOL, + MUTATIONS, + mutationStacks, + ORGANELLES, + SLOTS, + slotAccepts, } from './organelles'; import { curlFlow } from './rng'; import { SPECIES } from './species'; import { BODY_BIOMASS, createUnit, emit, MIN_CYTOPLASM, radiusForBiomass, type GameState, type SimInput } from './state'; -import type { - CellFate, CellUnit, DeathCause, Entity, HostClass, MutationId, OrganelleType, Player, SpeciesId, -} from './types'; +import type { CellFate, CellUnit, DeathCause, Entity, HostClass, MutationId, OrganelleType, Player, SpeciesId } from './types'; // Dividing costs spare biomass (it becomes the daughter) and nucleotides for // genome replication, so every division competes with building organelles. @@ -27,8 +34,7 @@ export function playerHostClasses(player: Player): HostClass[] { return classes; } -export const unitEngulfRatio = (player: Player, unit: CellUnit) => - player.traits.engulfRatio + (unit.fate === 'phagocyte' ? 0.12 : 0); +export const unitEngulfRatio = (player: Player, unit: CellUnit) => player.traits.engulfRatio + (unit.fate === 'phagocyte' ? 0.12 : 0); export function canUnitEat(player: Player, unit: CellUnit, e: Entity): boolean { const def = SPECIES[e.species]; @@ -66,8 +72,13 @@ export type DamageOpts = { cause?: DeathCause; bypassCooldown?: boolean }; // Applies damage to a cell of the colony. Returns damage dealt. export function damageUnit( - state: GameState, unit: CellUnit, amount: number, fromX: number, fromY: number, - source: SpeciesId | 'burst' | 'misc', opts: DamageOpts = {}, + state: GameState, + unit: CellUnit, + amount: number, + fromX: number, + fromY: number, + source: SpeciesId | 'burst' | 'misc', + opts: DamageOpts = {}, ): number { const p = state.player; if (p.dead || unit.dead || amount <= 0) return 0; @@ -86,7 +97,14 @@ export function damageUnit( unit.hitFlash = 1; unit.hitAngle = angle; membraneImpulse(unit.membrane, angle, -unit.radius * Math.min(7, 2 + dmg * 0.35), 0.55); - emit(state, { type: 'damage', amount: dmg, x: unit.x + Math.cos(angle) * unit.radius, y: unit.y + Math.sin(angle) * unit.radius, unit: unit.id, source }); + emit(state, { + type: 'damage', + amount: dmg, + x: unit.x + Math.cos(angle) * unit.radius, + y: unit.y + Math.sin(angle) * unit.radius, + unit: unit.id, + source, + }); } if (unit.integrity <= 0) { unit.integrity = 0; @@ -429,10 +447,13 @@ export function updateMetabolism(state: GameState, dt: number) { } } for (const u of units) { - u.maxIntegrity = (u.fate === 'prime' ? t.maxIntegrity : t.maxIntegrity * 0.7); + u.maxIntegrity = u.fate === 'prime' ? t.maxIntegrity : t.maxIntegrity * 0.7; u.integrity = Math.min(u.integrity, u.maxIntegrity); } - state.stats.peakBiomass = Math.max(state.stats.peakBiomass, units.reduce((s, u) => s + u.biomass, 0)); + state.stats.peakBiomass = Math.max( + state.stats.peakBiomass, + units.reduce((s, u) => s + u.biomass, 0), + ); } // Digestion inside food vacuoles, plus endosymbiotic capture. @@ -451,8 +472,14 @@ export function updateDigestion(state: GameState, dt: number) { const slot = firstFreeSlot(p, def.endosymbiont); if (slot !== null) { p.organelles.push({ - id: state.nextOrganelleId++, type: def.endosymbiont, slot, misfolded: false, born: state.time, mass: 4, - px: v.lx, py: v.ly, + id: state.nextOrganelleId++, + type: def.endosymbiont, + slot, + misfolded: false, + born: state.time, + mass: 4, + px: v.lx, + py: v.ly, }); prime.biomass += 4; p.traitsDirty = true; @@ -525,7 +552,8 @@ export function canDivide(state: GameState) { const units = livingUnits(p); const reasons: string[] = []; const spare = spendableBiomass(p); - if (spare < divisionThreshold(p.generation)) reasons.push(`Spare biomass ${Math.floor(Math.max(0, spare))}/${divisionThreshold(p.generation)}`); + if (spare < divisionThreshold(p.generation)) + reasons.push(`Spare biomass ${Math.floor(Math.max(0, spare))}/${divisionThreshold(p.generation)}`); if (p.dna < divisionDnaCost(p.generation)) reasons.push(`DNA ${Math.floor(p.dna)}/${divisionDnaCost(p.generation)}`); if (p.infection.viralLoad > 40) reasons.push('Genome infected'); if (units.length >= 12) reasons.push('Colony at maximum size'); @@ -566,7 +594,13 @@ export function updateDivision(state: GameState, dt: number) { const share = Math.max(14, Math.min(spendableBiomass(p), divisionThreshold(p.generation))); prime.biomass -= share; const back = prime.heading + Math.PI; - const daughter = createUnit('phagocyte', prime.x + Math.cos(back) * prime.radius, prime.y + Math.sin(back) * prime.radius, share, state.time); + const daughter = createUnit( + 'phagocyte', + prime.x + Math.cos(back) * prime.radius, + prime.y + Math.sin(back) * prime.radius, + share, + state.time, + ); daughter.heading = prime.heading; daughter.integrity = 100; p.units.push(daughter); @@ -628,8 +662,14 @@ export function buildOrganelle(state: GameState, type: OrganelleType): boolean { if (slot === null) return false; const origin = SLOTS[slot]; p.organelles.push({ - id: state.nextOrganelleId++, type, slot, misfolded: false, born: state.time, mass: check.cost.biomass, - px: origin.x * 0.2, py: origin.y * 0.2, + id: state.nextOrganelleId++, + type, + slot, + misfolded: false, + born: state.time, + mass: check.cost.biomass, + px: origin.x * 0.2, + py: origin.y * 0.2, }); p.dna -= check.cost.dna; p.traitsDirty = true; diff --git a/src/game/sim/sim.ts b/src/game/sim/sim.ts index bcb9945..a676181 100644 --- a/src/game/sim/sim.ts +++ b/src/game/sim/sim.ts @@ -1,11 +1,15 @@ import { updateEntities } from './ai'; import { updateInteractions, updateProjectiles, castAbility } from './combat'; -import { - checkDivisionReady, updateDirector, updateDiscovery, updateEnvironment, updateObjectives, updateUnlocks, -} from './director'; +import { checkDivisionReady, updateDirector, updateDiscovery, updateEnvironment, updateObjectives, updateUnlocks } from './director'; import { updateInfection } from './infection'; import { - refreshTraits, startDivision, updateDigestion, updateDivision, updateMetabolism, updatePlayerMovement, updateUnitBodies, + refreshTraits, + startDivision, + updateDigestion, + updateDivision, + updateMetabolism, + updatePlayerMovement, + updateUnitBodies, } from './player'; import { mulberry32, rand } from './rng'; import { createState, spawnEntity, type GameState, type SimInput, type ViewRect } from './state'; diff --git a/src/game/sim/species.ts b/src/game/sim/species.ts index f0842bc..ad04401 100644 --- a/src/game/sim/species.ts +++ b/src/game/sim/species.ts @@ -41,10 +41,24 @@ const S = (def: SpeciesDef) => def; export const SPECIES: Record = { glucose: S({ - id: 'glucose', name: 'Glucose', latin: 'C₆H₁₂O₆', group: 'resource', aliveness: -1, - radius: [3.4, 6], speed: 0, turn: 0, hpPerRadius: 1, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 0.25, glucose: 5, dna: 0, lipid: 0 }, sense: 0, - biomes: {}, group_size: [4, 10], minPressure: 0, threat: 0, + id: 'glucose', + name: 'Glucose', + latin: 'C₆H₁₂O₆', + group: 'resource', + aliveness: -1, + radius: [3.4, 6], + speed: 0, + turn: 0, + hpPerRadius: 1, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 0.25, glucose: 5, dna: 0, lipid: 0 }, + sense: 0, + biomes: {}, + group_size: [4, 10], + minPressure: 0, + threat: 0, codex: { size: '~1 nm molecule (shown as crystals)', fact: 'A six-carbon ring sugar. Glycolysis splits one for 2 ATP; a mitochondrion squeezes out roughly 30 more.', @@ -52,10 +66,24 @@ export const SPECIES: Record = { }, }), debris: S({ - id: 'debris', name: 'Cell debris', latin: 'lysate', group: 'resource', aliveness: -1, - radius: [3.5, 7.5], speed: 0, turn: 0, hpPerRadius: 1, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 3.2, glucose: 0.6, dna: 0, lipid: 0 }, sense: 0, - biomes: {}, group_size: [1, 1], minPressure: 0, threat: 0, + id: 'debris', + name: 'Cell debris', + latin: 'lysate', + group: 'resource', + aliveness: -1, + radius: [3.5, 7.5], + speed: 0, + turn: 0, + hpPerRadius: 1, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 3.2, glucose: 0.6, dna: 0, lipid: 0 }, + sense: 0, + biomes: {}, + group_size: [1, 1], + minPressure: 0, + threat: 0, codex: { size: 'fragments', fact: 'Proteins, lipids and nucleic acids spilled when a cell bursts. Nothing in the microcosmos goes to waste.', @@ -63,10 +91,24 @@ export const SPECIES: Record = { }, }), dna: S({ - id: 'dna', name: 'DNA fragment', latin: 'extracellular DNA', group: 'resource', aliveness: -1, - radius: [5, 7], speed: 0, turn: 0, hpPerRadius: 1, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 0.3, glucose: 0, dna: 0.6, lipid: 0 }, sense: 0, - biomes: { bloom: 0.6, biofilm: 0.15 }, group_size: [1, 3], minPressure: 0, threat: 0, + id: 'dna', + name: 'DNA fragment', + latin: 'extracellular DNA', + group: 'resource', + aliveness: -1, + radius: [5, 7], + speed: 0, + turn: 0, + hpPerRadius: 1, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 0.3, glucose: 0, dna: 0.6, lipid: 0 }, + sense: 0, + biomes: { bloom: 0.6, biofilm: 0.15 }, + group_size: [1, 3], + minPressure: 0, + threat: 0, codex: { size: 'nanometres wide, microns long', fact: 'Free DNA is everywhere in pond water. Many bacteria scavenge it by natural transformation, absorbing new genes.', @@ -74,10 +116,24 @@ export const SPECIES: Record = { }, }), lipid: S({ - id: 'lipid', name: 'Lipid droplet', latin: 'phospholipid vesicle', group: 'resource', aliveness: -1, - radius: [4, 7], speed: 0, turn: 0, hpPerRadius: 1, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 0.5, glucose: 1, dna: 0, lipid: 12 }, sense: 0, - biomes: { shallows: 0.3, abyss: 0.4, biofilm: 0.2 }, group_size: [1, 2], minPressure: 0, threat: 0, + id: 'lipid', + name: 'Lipid droplet', + latin: 'phospholipid vesicle', + group: 'resource', + aliveness: -1, + radius: [4, 7], + speed: 0, + turn: 0, + hpPerRadius: 1, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 0.5, glucose: 1, dna: 0, lipid: 12 }, + sense: 0, + biomes: { shallows: 0.3, abyss: 0.4, biofilm: 0.2 }, + group_size: [1, 2], + minPressure: 0, + threat: 0, codex: { size: '1–5 μm', fact: 'Phospholipids self-assemble into bilayers, the bricks of every membrane on Earth.', @@ -86,11 +142,26 @@ export const SPECIES: Record = { }), cocci: S({ - id: 'cocci', name: 'Coccus', latin: 'Micrococcus', group: 'bacteria', aliveness: 6, - radius: [4, 5.5], speed: 12, turn: 2, hpPerRadius: 0.8, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 1.6, glucose: 0.8, dna: 0, lipid: 0 }, sense: 0, - hostClass: ['bacteria'], carrierChance: 0, - biomes: { biofilm: 3, shallows: 1.4, bloom: 1, abyss: 0.5, rift: 0.6 }, group_size: [2, 5], minPressure: 0, threat: 0, + id: 'cocci', + name: 'Coccus', + latin: 'Micrococcus', + group: 'bacteria', + aliveness: 6, + radius: [4, 5.5], + speed: 12, + turn: 2, + hpPerRadius: 0.8, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 1.6, glucose: 0.8, dna: 0, lipid: 0 }, + sense: 0, + hostClass: ['bacteria'], + carrierChance: 0, + biomes: { biofilm: 3, shallows: 1.4, bloom: 1, abyss: 0.5, rift: 0.6 }, + group_size: [2, 5], + minPressure: 0, + threat: 0, codex: { size: '~1 μm', fact: 'Spherical bacteria, alone, in pairs, chains (strepto-) or grape-like clusters (staphylo-).', @@ -98,11 +169,25 @@ export const SPECIES: Record = { }, }), bacillus: S({ - id: 'bacillus', name: 'Bacillus', latin: 'Bacillus subtilis', group: 'bacteria', aliveness: 6, - radius: [6, 8], speed: 70, turn: 2.2, hpPerRadius: 0.9, behavior: 'swim', edible: true, - contactDamage: 0, yields: { biomass: 2.6, glucose: 1, dna: 0.1, lipid: 0 }, sense: 120, + id: 'bacillus', + name: 'Bacillus', + latin: 'Bacillus subtilis', + group: 'bacteria', + aliveness: 6, + radius: [6, 8], + speed: 70, + turn: 2.2, + hpPerRadius: 0.9, + behavior: 'swim', + edible: true, + contactDamage: 0, + yields: { biomass: 2.6, glucose: 1, dna: 0.1, lipid: 0 }, + sense: 120, hostClass: ['bacteria'], - biomes: { biofilm: 3, shallows: 2, bloom: 1, rift: 0.6 }, group_size: [1, 3], minPressure: 0, threat: 0, + biomes: { biofilm: 3, shallows: 2, bloom: 1, rift: 0.6 }, + group_size: [1, 3], + minPressure: 0, + threat: 0, codex: { size: '2–4 μm', fact: 'Rod-shaped and driven by flagella: rotary protein motors that spin hundreds of times per second.', @@ -110,11 +195,25 @@ export const SPECIES: Record = { }, }), spirillum: S({ - id: 'spirillum', name: 'Spirillum', latin: 'Spirillum volutans', group: 'bacteria', aliveness: 6, - radius: [7, 10], speed: 125, turn: 3, hpPerRadius: 0.8, behavior: 'corkscrew', edible: true, - contactDamage: 0, yields: { biomass: 3, glucose: 1, dna: 0.1, lipid: 0 }, sense: 150, + id: 'spirillum', + name: 'Spirillum', + latin: 'Spirillum volutans', + group: 'bacteria', + aliveness: 6, + radius: [7, 10], + speed: 125, + turn: 3, + hpPerRadius: 0.8, + behavior: 'corkscrew', + edible: true, + contactDamage: 0, + yields: { biomass: 3, glucose: 1, dna: 0.1, lipid: 0 }, + sense: 150, hostClass: ['bacteria'], - biomes: { shallows: 1, biofilm: 1.5, bloom: 1 }, group_size: [1, 2], minPressure: 0, threat: 0, + biomes: { shallows: 1, biofilm: 1.5, bloom: 1 }, + group_size: [1, 2], + minPressure: 0, + threat: 0, codex: { size: 'up to 60 μm long', fact: 'A corkscrew bacterium that drills through viscous water with tufts of flagella at both ends.', @@ -122,11 +221,26 @@ export const SPECIES: Record = { }, }), proteo: S({ - id: 'proteo', name: 'α-proteobacterium', latin: 'Rickettsiales ancestor', group: 'bacteria', aliveness: 6, - radius: [6, 8], speed: 55, turn: 2, hpPerRadius: 0.9, behavior: 'swim', edible: true, - contactDamage: 0, yields: { biomass: 3, glucose: 1.5, dna: 0.2, lipid: 0 }, sense: 110, - hostClass: ['bacteria'], endosymbiont: 'mitochondrion', - biomes: { shallows: 1.6, biofilm: 1.3, bloom: 0.4, abyss: 0.4 }, group_size: [1, 3], minPressure: 0, threat: 0, + id: 'proteo', + name: 'α-proteobacterium', + latin: 'Rickettsiales ancestor', + group: 'bacteria', + aliveness: 6, + radius: [6, 8], + speed: 55, + turn: 2, + hpPerRadius: 0.9, + behavior: 'swim', + edible: true, + contactDamage: 0, + yields: { biomass: 3, glucose: 1.5, dna: 0.2, lipid: 0 }, + sense: 110, + hostClass: ['bacteria'], + endosymbiont: 'mitochondrion', + biomes: { shallows: 1.6, biofilm: 1.3, bloom: 0.4, abyss: 0.4 }, + group_size: [1, 3], + minPressure: 0, + threat: 0, codex: { size: '~1–2 μm', fact: 'About 2 billion years ago, a cell swallowed one of these and never digested it. It became the mitochondrion.', @@ -134,11 +248,26 @@ export const SPECIES: Record = { }, }), cyano: S({ - id: 'cyano', name: 'Cyanobacterium', latin: 'Synechococcus', group: 'bacteria', aliveness: 6, - radius: [7, 10], speed: 14, turn: 1, hpPerRadius: 0.9, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 3, glucose: 3, dna: 0.2, lipid: 0 }, sense: 0, - hostClass: ['bacteria'], endosymbiont: 'chloroplast', - biomes: { shallows: 2.6, biofilm: 0.5 }, group_size: [1, 2], minPressure: 0, threat: 0, + id: 'cyano', + name: 'Cyanobacterium', + latin: 'Synechococcus', + group: 'bacteria', + aliveness: 6, + radius: [7, 10], + speed: 14, + turn: 1, + hpPerRadius: 0.9, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 3, glucose: 3, dna: 0.2, lipid: 0 }, + sense: 0, + hostClass: ['bacteria'], + endosymbiont: 'chloroplast', + biomes: { shallows: 2.6, biofilm: 0.5 }, + group_size: [1, 2], + minPressure: 0, + threat: 0, codex: { size: '1–10 μm', fact: 'Cyanobacteria invented oxygen-producing photosynthesis. An engulfed one became the chloroplast of every plant.', @@ -147,11 +276,25 @@ export const SPECIES: Record = { }), diatom: S({ - id: 'diatom', name: 'Diatom', latin: 'Navicula', group: 'protist', aliveness: 6, - radius: [12, 20], speed: 18, turn: 0.6, hpPerRadius: 2.4, behavior: 'drift', edible: true, - contactDamage: 0, yields: { biomass: 7, glucose: 6, dna: 0.3, lipid: 3 }, sense: 0, + id: 'diatom', + name: 'Diatom', + latin: 'Navicula', + group: 'protist', + aliveness: 6, + radius: [12, 20], + speed: 18, + turn: 0.6, + hpPerRadius: 2.4, + behavior: 'drift', + edible: true, + contactDamage: 0, + yields: { biomass: 7, glucose: 6, dna: 0.3, lipid: 3 }, + sense: 0, hostClass: ['eukaryote', 'plant'], - biomes: { shallows: 2.2, biofilm: 1 }, group_size: [1, 2], minPressure: 0, threat: 0, + biomes: { shallows: 2.2, biofilm: 1 }, + group_size: [1, 2], + minPressure: 0, + threat: 0, codex: { size: '2–200 μm', fact: 'Algae living in glass houses of silica called frustules. Diatoms make roughly a fifth of the oxygen you breathe.', @@ -159,11 +302,26 @@ export const SPECIES: Record = { }, }), euglena: S({ - id: 'euglena', name: 'Euglena', latin: 'Euglena gracilis', group: 'protist', aliveness: 6, - radius: [13, 18], speed: 80, turn: 2, hpPerRadius: 1.6, behavior: 'swim', edible: true, - contactDamage: 0, yields: { biomass: 9, glucose: 4, dna: 0.4, lipid: 0 }, sense: 160, - hostClass: ['eukaryote', 'plant'], carrierChance: 0.12, - biomes: { shallows: 2.4, bloom: 0.5, biofilm: 0.5 }, group_size: [1, 2], minPressure: 0, threat: 0, + id: 'euglena', + name: 'Euglena', + latin: 'Euglena gracilis', + group: 'protist', + aliveness: 6, + radius: [13, 18], + speed: 80, + turn: 2, + hpPerRadius: 1.6, + behavior: 'swim', + edible: true, + contactDamage: 0, + yields: { biomass: 9, glucose: 4, dna: 0.4, lipid: 0 }, + sense: 160, + hostClass: ['eukaryote', 'plant'], + carrierChance: 0.12, + biomes: { shallows: 2.4, bloom: 0.5, biofilm: 0.5 }, + group_size: [1, 2], + minPressure: 0, + threat: 0, codex: { size: '15–500 μm', fact: 'Part plant, part animal: it photosynthesizes but also swims and eats. A red eyespot shades its light sensor to steer toward the sun.', @@ -171,11 +329,26 @@ export const SPECIES: Record = { }, }), paramecium: S({ - id: 'paramecium', name: 'Paramecium', latin: 'Paramecium caudatum', group: 'protist', aliveness: 6, - radius: [20, 28], speed: 105, turn: 1.8, hpPerRadius: 1.5, behavior: 'swim', edible: true, - contactDamage: 0, yields: { biomass: 16, glucose: 5, dna: 0.6, lipid: 2 }, sense: 200, - hostClass: ['eukaryote'], carrierChance: 0.18, - biomes: { shallows: 1.5, biofilm: 1.5, bloom: 1 }, group_size: [1, 2], minPressure: 0.1, threat: 1, + id: 'paramecium', + name: 'Paramecium', + latin: 'Paramecium caudatum', + group: 'protist', + aliveness: 6, + radius: [20, 28], + speed: 105, + turn: 1.8, + hpPerRadius: 1.5, + behavior: 'swim', + edible: true, + contactDamage: 0, + yields: { biomass: 16, glucose: 5, dna: 0.6, lipid: 2 }, + sense: 200, + hostClass: ['eukaryote'], + carrierChance: 0.18, + biomes: { shallows: 1.5, biofilm: 1.5, bloom: 1 }, + group_size: [1, 2], + minPressure: 0.1, + threat: 1, codex: { size: '50–330 μm', fact: 'Covered in thousands of cilia beating in waves. Under attack it fires trichocysts: spring-loaded protein harpoons.', @@ -183,11 +356,25 @@ export const SPECIES: Record = { }, }), didinium: S({ - id: 'didinium', name: 'Didinium', latin: 'Didinium nasutum', group: 'protist', aliveness: 6, - radius: [14, 18], speed: 175, turn: 3, hpPerRadius: 1.6, behavior: 'hunter', edible: true, - contactDamage: 9, yields: { biomass: 10, glucose: 3, dna: 0.5, lipid: 0 }, sense: 340, + id: 'didinium', + name: 'Didinium', + latin: 'Didinium nasutum', + group: 'protist', + aliveness: 6, + radius: [14, 18], + speed: 175, + turn: 3, + hpPerRadius: 1.6, + behavior: 'hunter', + edible: true, + contactDamage: 9, + yields: { biomass: 10, glucose: 3, dna: 0.5, lipid: 0 }, + sense: 340, hostClass: ['eukaryote'], - biomes: { biofilm: 1.1, shallows: 0.7, bloom: 1 }, group_size: [1, 2], minPressure: 0.35, threat: 2, + biomes: { biofilm: 1.1, shallows: 0.7, bloom: 1 }, + group_size: [1, 2], + minPressure: 0.35, + threat: 2, codex: { size: '50–150 μm', fact: 'A barrel-shaped ciliate that hunts Paramecium, prey bigger than itself, harpooning it with toxicysts and swallowing it whole.', @@ -195,11 +382,25 @@ export const SPECIES: Record = { }, }), lacrymaria: S({ - id: 'lacrymaria', name: 'Lacrymaria', latin: 'Lacrymaria olor', group: 'protist', aliveness: 6, - radius: [18, 24], speed: 60, turn: 1.6, hpPerRadius: 1.7, behavior: 'neck', edible: true, - contactDamage: 6, yields: { biomass: 18, glucose: 4, dna: 0.8, lipid: 0 }, sense: 260, + id: 'lacrymaria', + name: 'Lacrymaria', + latin: 'Lacrymaria olor', + group: 'protist', + aliveness: 6, + radius: [18, 24], + speed: 60, + turn: 1.6, + hpPerRadius: 1.7, + behavior: 'neck', + edible: true, + contactDamage: 6, + yields: { biomass: 18, glucose: 4, dna: 0.8, lipid: 0 }, + sense: 260, hostClass: ['eukaryote'], - biomes: { abyss: 1.4, biofilm: 1, bloom: 0.8, shallows: 0.25 }, group_size: [1, 1], minPressure: 0.45, threat: 2, + biomes: { abyss: 1.4, biofilm: 1, bloom: 0.8, shallows: 0.25 }, + group_size: [1, 1], + minPressure: 0.45, + threat: 2, codex: { size: '~100 μm body', fact: '"Tear of the swan". Its neck can stretch to many times its body length, striking prey with a mouth at the tip.', @@ -207,12 +408,26 @@ export const SPECIES: Record = { }, }), amoeba: S({ - id: 'amoeba', name: 'Amoeba proteus', latin: 'Amoeba proteus', group: 'protist', aliveness: 6, - radius: [45, 110], speed: 30, turn: 0.8, hpPerRadius: 5.5, behavior: 'engulfer', edible: true, + id: 'amoeba', + name: 'Amoeba proteus', + latin: 'Amoeba proteus', + group: 'protist', + aliveness: 6, + radius: [45, 110], + speed: 30, + turn: 0.8, + hpPerRadius: 5.5, + behavior: 'engulfer', + edible: true, perRadius: true, - contactDamage: 4, yields: { biomass: 0.62, glucose: 0.2, dna: 0.02, lipid: 0.08 }, sense: 420, + contactDamage: 4, + yields: { biomass: 0.62, glucose: 0.2, dna: 0.02, lipid: 0.08 }, + sense: 420, hostClass: ['eukaryote', 'amoeboid'], - biomes: { abyss: 2, biofilm: 1, bloom: 0.5, shallows: 0.15 }, group_size: [1, 1], minPressure: 0.4, threat: 3, + biomes: { abyss: 2, biofilm: 1, bloom: 0.5, shallows: 0.15 }, + group_size: [1, 1], + minPressure: 0.4, + threat: 3, codex: { size: '250–750 μm', fact: 'Crawls on pseudopods, "false feet" of streaming cytoplasm, and engulfs prey whole by phagocytosis. Just like you.', @@ -220,11 +435,25 @@ export const SPECIES: Record = { }, }), testate: S({ - id: 'testate', name: 'Testate amoeba', latin: 'Arcella vulgaris', group: 'protist', aliveness: 6, - radius: [26, 40], speed: 24, turn: 0.9, hpPerRadius: 2.4, behavior: 'testate', edible: true, - contactDamage: 10, yields: { biomass: 14, glucose: 3, dna: 0.6, lipid: 2 }, sense: 240, + id: 'testate', + name: 'Testate amoeba', + latin: 'Arcella vulgaris', + group: 'protist', + aliveness: 6, + radius: [26, 40], + speed: 24, + turn: 0.9, + hpPerRadius: 2.4, + behavior: 'testate', + edible: true, + contactDamage: 10, + yields: { biomass: 14, glucose: 3, dna: 0.6, lipid: 2 }, + sense: 240, hostClass: ['eukaryote', 'amoeboid'], - biomes: { biofilm: 1.8, abyss: 1.1 }, group_size: [1, 1], minPressure: 0.25, threat: 1, + biomes: { biofilm: 1.8, abyss: 1.1 }, + group_size: [1, 1], + minPressure: 0.25, + threat: 1, codex: { size: '30–250 μm', fact: 'Arcella secretes a domed shell, the test, and reaches out through one opening. Its machinery stays armored inside.', @@ -232,12 +461,26 @@ export const SPECIES: Record = { }, }), stentor: S({ - id: 'stentor', name: 'Stentor', latin: 'Stentor coeruleus', group: 'protist', aliveness: 6, - radius: [60, 88], speed: 0, turn: 0.3, hpPerRadius: 4, behavior: 'vortex', edible: true, + id: 'stentor', + name: 'Stentor', + latin: 'Stentor coeruleus', + group: 'protist', + aliveness: 6, + radius: [60, 88], + speed: 0, + turn: 0.3, + hpPerRadius: 4, + behavior: 'vortex', + edible: true, perRadius: true, - contactDamage: 4, yields: { biomass: 0.6, glucose: 0.2, dna: 0.02, lipid: 0.05 }, sense: 380, + contactDamage: 4, + yields: { biomass: 0.6, glucose: 0.2, dna: 0.02, lipid: 0.05 }, + sense: 380, hostClass: ['eukaryote'], - biomes: { shallows: 0.9, biofilm: 1 }, group_size: [1, 1], minPressure: 0.2, threat: 2, + biomes: { shallows: 0.9, biofilm: 1 }, + group_size: [1, 1], + minPressure: 0.2, + threat: 2, codex: { size: 'up to 2 mm', fact: 'A trumpet-shaped giant ciliate with a beaded "moniliform" nucleus like a string of pearls. A small fragment can regrow a whole cell.', @@ -246,12 +489,26 @@ export const SPECIES: Record = { }), gastrotrich: S({ - id: 'gastrotrich', name: 'Gastrotrich', latin: 'Chaetonotus', group: 'animal', aliveness: 7, - radius: [30, 42], speed: 55, turn: 1.4, hpPerRadius: 1.6, behavior: 'grazer', edible: true, + id: 'gastrotrich', + name: 'Gastrotrich', + latin: 'Chaetonotus', + group: 'animal', + aliveness: 7, + radius: [30, 42], + speed: 55, + turn: 1.4, + hpPerRadius: 1.6, + behavior: 'grazer', + edible: true, perRadius: true, - contactDamage: 0, yields: { biomass: 0.75, glucose: 0.2, dna: 0.03, lipid: 0.1 }, sense: 180, + contactDamage: 0, + yields: { biomass: 0.75, glucose: 0.2, dna: 0.03, lipid: 0.1 }, + sense: 180, carrierChance: 0.3, - biomes: { biofilm: 2, shallows: 1, abyss: 0.4 }, group_size: [1, 2], minPressure: 0.1, threat: 0, + biomes: { biofilm: 2, shallows: 1, abyss: 0.4 }, + group_size: [1, 2], + minPressure: 0.1, + threat: 0, codex: { size: '~60 μm', fact: 'One of the smallest animals: a brain of a few dozen neurons and a coat of cat-whisker bristles. Some carry unicellular hitchhikers inside.', @@ -259,11 +516,25 @@ export const SPECIES: Record = { }, }), rotifer: S({ - id: 'rotifer', name: 'Rotifer', latin: 'Brachionus calyciflorus', group: 'animal', aliveness: 7, - radius: [34, 55], speed: 40, turn: 1, hpPerRadius: 2.4, behavior: 'vortex', edible: true, + id: 'rotifer', + name: 'Rotifer', + latin: 'Brachionus calyciflorus', + group: 'animal', + aliveness: 7, + radius: [34, 55], + speed: 40, + turn: 1, + hpPerRadius: 2.4, + behavior: 'vortex', + edible: true, perRadius: true, - contactDamage: 6, yields: { biomass: 0.85, glucose: 0.3, dna: 0.04, lipid: 0.15 }, sense: 300, - biomes: { shallows: 1.4, biofilm: 1, bloom: 0.7 }, group_size: [1, 1], minPressure: 0.2, threat: 2, + contactDamage: 6, + yields: { biomass: 0.85, glucose: 0.3, dna: 0.04, lipid: 0.15 }, + sense: 300, + biomes: { shallows: 1.4, biofilm: 1, bloom: 0.7 }, + group_size: [1, 1], + minPressure: 0.2, + threat: 2, codex: { size: '100–300 μm', fact: 'A whole animal smaller than many protists. Its ciliated corona spins a vortex that feeds a grinding jaw called the mastax.', @@ -271,11 +542,25 @@ export const SPECIES: Record = { }, }), hydra: S({ - id: 'hydra', name: 'Hydra', latin: 'Hydra viridissima', group: 'animal', aliveness: 7, - radius: [62, 88], speed: 0, turn: 0.2, hpPerRadius: 5, behavior: 'tentacles', edible: false, + id: 'hydra', + name: 'Hydra', + latin: 'Hydra viridissima', + group: 'animal', + aliveness: 7, + radius: [62, 88], + speed: 0, + turn: 0.2, + hpPerRadius: 5, + behavior: 'tentacles', + edible: false, perRadius: true, - contactDamage: 10, yields: { biomass: 0.9, glucose: 0.4, dna: 0.04, lipid: 0.2 }, sense: 330, - biomes: { biofilm: 1.1, abyss: 0.8, shallows: 0.3 }, group_size: [1, 1], minPressure: 0.6, threat: 3, + contactDamage: 10, + yields: { biomass: 0.9, glucose: 0.4, dna: 0.04, lipid: 0.2 }, + sense: 330, + biomes: { biofilm: 1.1, abyss: 0.8, shallows: 0.3 }, + group_size: [1, 1], + minPressure: 0.6, + threat: 3, codex: { size: '~1 cm', fact: 'A freshwater cnidarian that fires stinging nematocysts. Its stem cells renew it constantly, and it shows no measurable aging.', @@ -283,11 +568,25 @@ export const SPECIES: Record = { }, }), collotheca: S({ - id: 'collotheca', name: 'Collotheca', latin: 'Collotheca ornata', group: 'animal', aliveness: 7, - radius: [34, 48], speed: 0, turn: 0.2, hpPerRadius: 2, behavior: 'trap', edible: true, + id: 'collotheca', + name: 'Collotheca', + latin: 'Collotheca ornata', + group: 'animal', + aliveness: 7, + radius: [34, 48], + speed: 0, + turn: 0.2, + hpPerRadius: 2, + behavior: 'trap', + edible: true, perRadius: true, - contactDamage: 8, yields: { biomass: 0.7, glucose: 0.3, dna: 0.04, lipid: 0.1 }, sense: 220, - biomes: { abyss: 1.3, biofilm: 0.7 }, group_size: [1, 1], minPressure: 0.55, threat: 2, + contactDamage: 8, + yields: { biomass: 0.7, glucose: 0.3, dna: 0.04, lipid: 0.1 }, + sense: 220, + biomes: { abyss: 1.3, biofilm: 0.7 }, + group_size: [1, 1], + minPressure: 0.55, + threat: 2, codex: { size: '~500 μm', fact: 'A sessile rotifer whose funnel is crowned by long, near-invisible bristles. When prey swims in, the funnel snaps shut.', @@ -295,10 +594,26 @@ export const SPECIES: Record = { }, }), tardigrade: S({ - id: 'tardigrade', name: 'Tardigrade', latin: 'Hypsibius dujardini', group: 'animal', aliveness: 7, - radius: [50, 68], speed: 22, turn: 0.7, hpPerRadius: 10, behavior: 'lumber', edible: false, invulnerable: true, solid: true, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, sense: 0, - biomes: { abyss: 1.1, biofilm: 0.6, shallows: 0.25 }, group_size: [1, 1], minPressure: 0, threat: 0, + id: 'tardigrade', + name: 'Tardigrade', + latin: 'Hypsibius dujardini', + group: 'animal', + aliveness: 7, + radius: [50, 68], + speed: 22, + turn: 0.7, + hpPerRadius: 10, + behavior: 'lumber', + edible: false, + invulnerable: true, + solid: true, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, + sense: 0, + biomes: { abyss: 1.1, biofilm: 0.6, shallows: 0.25 }, + group_size: [1, 1], + minPressure: 0, + threat: 0, codex: { size: '0.3–0.5 mm', fact: 'Water bears survive freezing, drying, vacuum and intense radiation. Their Dsup protein physically shields DNA.', @@ -307,11 +622,27 @@ export const SPECIES: Record = { }), prion: S({ - id: 'prion', name: 'Prion', latin: 'PrPˢᶜ', group: 'agent', aliveness: 0, - radius: [3, 5], speed: 6, turn: 1, hpPerRadius: 1, behavior: 'drift', edible: false, hidden: true, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, sense: 0, - infects: ['eukaryote'], infection: 'prion', - biomes: { bloom: 1.6, rift: 1.4, abyss: 0.6 }, group_size: [4, 9], minPressure: 0.6, threat: 2, + id: 'prion', + name: 'Prion', + latin: 'PrPˢᶜ', + group: 'agent', + aliveness: 0, + radius: [3, 5], + speed: 6, + turn: 1, + hpPerRadius: 1, + behavior: 'drift', + edible: false, + hidden: true, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, + sense: 0, + infects: ['eukaryote'], + infection: 'prion', + biomes: { bloom: 1.6, rift: 1.4, abyss: 0.6 }, + group_size: [4, 9], + minPressure: 0.6, + threat: 2, codex: { size: '~10 nm', fact: 'Misfolded proteins that template their misfolding onto healthy copies. No genome at all: the least "alive" infectious agent.', @@ -319,11 +650,27 @@ export const SPECIES: Record = { }, }), viroid: S({ - id: 'viroid', name: 'Viroid', latin: 'PSTVd', group: 'agent', aliveness: 1, - radius: [2.4, 3.2], speed: 95, turn: 4, hpPerRadius: 1, behavior: 'swarm', edible: false, hidden: true, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.05, lipid: 0 }, sense: 260, - infects: ['plant'], infection: 'viroid', - biomes: { bloom: 1.5, shallows: 0.5, biofilm: 0.6 }, group_size: [6, 11], minPressure: 0.55, threat: 1, + id: 'viroid', + name: 'Viroid', + latin: 'PSTVd', + group: 'agent', + aliveness: 1, + radius: [2.4, 3.2], + speed: 95, + turn: 4, + hpPerRadius: 1, + behavior: 'swarm', + edible: false, + hidden: true, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.05, lipid: 0 }, + sense: 260, + infects: ['plant'], + infection: 'viroid', + biomes: { bloom: 1.5, shallows: 0.5, biofilm: 0.6 }, + group_size: [6, 11], + minPressure: 0.55, + threat: 1, codex: { size: '246–401 nucleotides', fact: 'Naked circular RNA that codes for no protein. It hijacks host enzymes to copy itself, and it infects plants.', @@ -331,10 +678,25 @@ export const SPECIES: Record = { }, }), satellite: S({ - id: 'satellite', name: 'Satellite RNA', latin: 'satRNA', group: 'agent', aliveness: 2, - radius: [1.6, 2.4], speed: 5, turn: 1, hpPerRadius: 1, behavior: 'drift', edible: false, hidden: true, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.05, lipid: 0 }, sense: 0, - biomes: { bloom: 1 }, group_size: [3, 6], minPressure: 0.6, threat: 1, + id: 'satellite', + name: 'Satellite RNA', + latin: 'satRNA', + group: 'agent', + aliveness: 2, + radius: [1.6, 2.4], + speed: 5, + turn: 1, + hpPerRadius: 1, + behavior: 'drift', + edible: false, + hidden: true, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.05, lipid: 0 }, + sense: 0, + biomes: { bloom: 1 }, + group_size: [3, 6], + minPressure: 0.6, + threat: 1, codex: { size: 'a few hundred bases', fact: 'Satellite RNAs cannot replicate alone. They ride along with a helper virus and change how severe its infection is.', @@ -342,11 +704,26 @@ export const SPECIES: Record = { }, }), adenovirus: S({ - id: 'adenovirus', name: 'Adenovirus', latin: 'Mastadenovirus', group: 'agent', aliveness: 3, - radius: [6, 8], speed: 42, turn: 2, hpPerRadius: 0.5, behavior: 'virus', edible: false, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.2, lipid: 0 }, sense: 210, - infects: ['eukaryote'], infection: 'lytic', - biomes: { bloom: 2.4, biofilm: 0.7, shallows: 0.4, rift: 0.8 }, group_size: [2, 5], minPressure: 0.4, threat: 2, + id: 'adenovirus', + name: 'Adenovirus', + latin: 'Mastadenovirus', + group: 'agent', + aliveness: 3, + radius: [6, 8], + speed: 42, + turn: 2, + hpPerRadius: 0.5, + behavior: 'virus', + edible: false, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.2, lipid: 0 }, + sense: 210, + infects: ['eukaryote'], + infection: 'lytic', + biomes: { bloom: 2.4, biofilm: 0.7, shallows: 0.4, rift: 0.8 }, + group_size: [2, 5], + minPressure: 0.4, + threat: 2, codex: { size: '~90 nm', fact: 'An icosahedral (20-faced) capsid with fiber proteins jutting from its 12 vertices. Lytic: it hijacks the cell, replicates and bursts out.', @@ -354,11 +731,26 @@ export const SPECIES: Record = { }, }), retrovirus: S({ - id: 'retrovirus', name: 'Retrovirus', latin: 'Retroviridae', group: 'agent', aliveness: 3, - radius: [6.5, 9], speed: 34, turn: 1.6, hpPerRadius: 0.6, behavior: 'virus', edible: false, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.3, lipid: 0 }, sense: 200, - infects: ['eukaryote'], infection: 'lysogenic', - biomes: { bloom: 1.4, abyss: 0.6, rift: 0.7 }, group_size: [1, 3], minPressure: 0.75, threat: 2, + id: 'retrovirus', + name: 'Retrovirus', + latin: 'Retroviridae', + group: 'agent', + aliveness: 3, + radius: [6.5, 9], + speed: 34, + turn: 1.6, + hpPerRadius: 0.6, + behavior: 'virus', + edible: false, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.3, lipid: 0 }, + sense: 200, + infects: ['eukaryote'], + infection: 'lysogenic', + biomes: { bloom: 1.4, abyss: 0.6, rift: 0.7 }, + group_size: [1, 3], + minPressure: 0.75, + threat: 2, codex: { size: '~100 nm', fact: 'An enveloped virus that reverse-transcribes its RNA into DNA and splices it into the host genome as a dormant provirus.', @@ -366,11 +758,26 @@ export const SPECIES: Record = { }, }), tmv: S({ - id: 'tmv', name: 'Tobacco mosaic virus', latin: 'Tobamovirus', group: 'agent', aliveness: 3, - radius: [4, 5], speed: 30, turn: 1.4, hpPerRadius: 0.6, behavior: 'virus', edible: false, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.2, lipid: 0 }, sense: 200, - infects: ['plant'], infection: 'lytic', - biomes: { shallows: 0.8, bloom: 0.8 }, group_size: [2, 4], minPressure: 0.5, threat: 1, + id: 'tmv', + name: 'Tobacco mosaic virus', + latin: 'Tobamovirus', + group: 'agent', + aliveness: 3, + radius: [4, 5], + speed: 30, + turn: 1.4, + hpPerRadius: 0.6, + behavior: 'virus', + edible: false, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.2, lipid: 0 }, + sense: 200, + infects: ['plant'], + infection: 'lytic', + biomes: { shallows: 0.8, bloom: 0.8 }, + group_size: [2, 4], + minPressure: 0.5, + threat: 1, codex: { size: '300 × 18 nm', fact: 'A rigid helical rod built from 2,130 identical coat proteins, the first virus ever discovered. It infects plants.', @@ -378,11 +785,26 @@ export const SPECIES: Record = { }, }), phage: S({ - id: 'phage', name: 'Bacteriophage', latin: 'Enterobacteria phage T4', group: 'agent', aliveness: 3, - radius: [5, 6], speed: 50, turn: 2.4, hpPerRadius: 0.5, behavior: 'virus', edible: true, - contactDamage: 0, yields: { biomass: 0.2, glucose: 0, dna: 0.22, lipid: 0 }, sense: 240, - infects: ['bacteria'], infection: 'lytic', - biomes: { biofilm: 2.4, bloom: 1.5, shallows: 0.5 }, group_size: [3, 7], minPressure: 0.1, threat: 0, + id: 'phage', + name: 'Bacteriophage', + latin: 'Enterobacteria phage T4', + group: 'agent', + aliveness: 3, + radius: [5, 6], + speed: 50, + turn: 2.4, + hpPerRadius: 0.5, + behavior: 'virus', + edible: true, + contactDamage: 0, + yields: { biomass: 0.2, glucose: 0, dna: 0.22, lipid: 0 }, + sense: 240, + infects: ['bacteria'], + infection: 'lytic', + biomes: { biofilm: 2.4, bloom: 1.5, shallows: 0.5 }, + group_size: [3, 7], + minPressure: 0.1, + threat: 0, codex: { size: '~200 nm', fact: 'A capsid head, contractile tail and spidery tail fibers. It infects only bacteria, and phages are probably the most abundant biological entities on Earth.', @@ -390,11 +812,26 @@ export const SPECIES: Record = { }, }), mimivirus: S({ - id: 'mimivirus', name: 'Mimivirus', latin: 'Acanthamoeba polyphaga mimivirus', group: 'agent', aliveness: 4, - radius: [14, 18], speed: 26, turn: 1, hpPerRadius: 1.2, behavior: 'virus', edible: false, - contactDamage: 0, yields: { biomass: 0.5, glucose: 0, dna: 1.2, lipid: 0 }, sense: 260, - infects: ['amoeboid'], infection: 'lytic', - biomes: { bloom: 1, abyss: 0.7 }, group_size: [1, 2], minPressure: 0.9, threat: 3, + id: 'mimivirus', + name: 'Mimivirus', + latin: 'Acanthamoeba polyphaga mimivirus', + group: 'agent', + aliveness: 4, + radius: [14, 18], + speed: 26, + turn: 1, + hpPerRadius: 1.2, + behavior: 'virus', + edible: false, + contactDamage: 0, + yields: { biomass: 0.5, glucose: 0, dna: 1.2, lipid: 0 }, + sense: 260, + infects: ['amoeboid'], + infection: 'lytic', + biomes: { bloom: 1, abyss: 0.7 }, + group_size: [1, 2], + minPressure: 0.9, + threat: 3, codex: { size: '~750 nm with fibers', fact: 'A giant virus, bigger than some bacteria, with a hairy capsid. It infects amoebae, and you are amoeboid.', @@ -402,24 +839,52 @@ export const SPECIES: Record = { }, }), virophage: S({ - id: 'virophage', name: 'Virophage', latin: 'Sputnik', group: 'agent', aliveness: 4, - radius: [3, 4], speed: 80, turn: 3, hpPerRadius: 1, behavior: 'virophage', edible: false, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0.1, lipid: 0 }, sense: 380, + id: 'virophage', + name: 'Virophage', + latin: 'Sputnik', + group: 'agent', + aliveness: 4, + radius: [3, 4], + speed: 80, + turn: 3, + hpPerRadius: 1, + behavior: 'virophage', + edible: false, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0.1, lipid: 0 }, + sense: 380, infects: ['giantVirus'], - biomes: { bloom: 0.9 }, group_size: [3, 6], minPressure: 0.5, threat: 0, + biomes: { bloom: 0.9 }, + group_size: [3, 6], + minPressure: 0.5, + threat: 0, codex: { size: '~50 nm', - fact: 'A virus that parasitizes other viruses. Sputnik hijacks a giant virus\'s replication factory and cripples it.', + fact: "A virus that parasitizes other viruses. Sputnik hijacks a giant virus's replication factory and cripples it.", role: 'An ally. Absorb them, then release the swarm on giant viruses.', }, }), neoplasm: S({ - id: 'neoplasm', name: 'Neoplasm', latin: 'transformed cell mass', group: 'boss', aliveness: 6, - radius: [70, 120], speed: 70, turn: 1.6, hpPerRadius: 9, behavior: 'neoplasm', edible: true, + id: 'neoplasm', + name: 'Neoplasm', + latin: 'transformed cell mass', + group: 'boss', + aliveness: 6, + radius: [70, 120], + speed: 70, + turn: 1.6, + hpPerRadius: 9, + behavior: 'neoplasm', + edible: true, perRadius: true, - contactDamage: 18, yields: { biomass: 0.75, glucose: 0.9, dna: 0.12, lipid: 0.1 }, sense: 520, - biomes: { rift: 0.35 }, group_size: [1, 1], minPressure: 1.2, threat: 3, + contactDamage: 18, + yields: { biomass: 0.75, glucose: 0.9, dna: 0.12, lipid: 0.1 }, + sense: 520, + biomes: { rift: 0.35 }, + group_size: [1, 1], + minPressure: 1.2, + threat: 3, codex: { size: 'unbounded', fact: 'Cells that ignore every "stop dividing" signal. Tumors gorge on glucose even when oxygen is present: the Warburg effect.', @@ -427,10 +892,26 @@ export const SPECIES: Record = { }, }), pollen: S({ - id: 'pollen', name: 'Pollen grain', latin: 'sporopollenin wall', group: 'obstacle', aliveness: -1, - radius: [40, 86], speed: 4, turn: 0.1, hpPerRadius: 100, behavior: 'drift', edible: false, invulnerable: true, solid: true, - contactDamage: 0, yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, sense: 0, - biomes: { shallows: 0.5, biofilm: 0.35 }, group_size: [1, 1], minPressure: 0, threat: 0, + id: 'pollen', + name: 'Pollen grain', + latin: 'sporopollenin wall', + group: 'obstacle', + aliveness: -1, + radius: [40, 86], + speed: 4, + turn: 0.1, + hpPerRadius: 100, + behavior: 'drift', + edible: false, + invulnerable: true, + solid: true, + contactDamage: 0, + yields: { biomass: 0, glucose: 0, dna: 0, lipid: 0 }, + sense: 0, + biomes: { shallows: 0.5, biofilm: 0.35 }, + group_size: [1, 1], + minPressure: 0, + threat: 0, codex: { size: '10–100 μm', fact: 'Wrapped in sporopollenin, one of the toughest biopolymers known, sculpted with spikes and ridges unique to each plant.', diff --git a/src/game/sim/state.ts b/src/game/sim/state.ts index 5bc025b..aa538c0 100644 --- a/src/game/sim/state.ts +++ b/src/game/sim/state.ts @@ -4,8 +4,21 @@ import { mulberry32, rand, type Rng } from './rng'; import { SpatialHash } from './spatial'; import { SPECIES } from './species'; import type { - AbilityId, BiomeId, CellFate, CellUnit, DirectorState, DivisionChoices, Entity, LightMode, Player, Projectile, - Shockwave, SimEvent, SpeciesId, Stats, UnlockId, + AbilityId, + BiomeId, + CellFate, + CellUnit, + DirectorState, + DivisionChoices, + Entity, + LightMode, + Player, + Projectile, + Shockwave, + SimEvent, + SpeciesId, + Stats, + UnlockId, } from './types'; export const START_BIOMASS = 40; @@ -120,7 +133,16 @@ function createPlayer(): Player { invulnUntil: 0, cystUntil: 0, capture: null, - infection: { viralLoad: 0, satelliteBoost: 0, prophages: 0, colonies: 0, misfoldTimer: 0, refoldTimer: 0, colonyTimer: 0, lastVirus: null }, + infection: { + viralLoad: 0, + satelliteBoost: 0, + prophages: 0, + colonies: 0, + misfoldTimer: 0, + refoldTimer: 0, + colonyTimer: 0, + lastVirus: null, + }, starving: false, dividing: 0, pendingDivision: false, @@ -158,7 +180,17 @@ export function createState(seed: number, knownSpecies: Iterable = [] unlocked: new Set(), introducedAt: -999, lightMode: 'bright', - stats: { eaten: 0, kills: 0, glucose: 0, maxDistance: 0, divisions: 0, infectionsCleared: 0, peakCells: 1, peakBiomass: START_BIOMASS, discovered: 0 }, + stats: { + eaten: 0, + kills: 0, + glucose: 0, + maxDistance: 0, + divisions: 0, + infectionsCleared: 0, + peakCells: 1, + peakBiomass: START_BIOMASS, + discovered: 0, + }, discovered: new Set(), biome: 'shallows', light: 1, diff --git a/src/game/sim/types.ts b/src/game/sim/types.ts index e027a38..a63fee2 100644 --- a/src/game/sim/types.ts +++ b/src/game/sim/types.ts @@ -10,43 +10,108 @@ export type LightMode = 'bright' | 'dark' | 'fluor'; export type SpeciesId = // resources - | 'glucose' | 'debris' | 'dna' | 'lipid' + | 'glucose' + | 'debris' + | 'dna' + | 'lipid' // bacteria - | 'cocci' | 'bacillus' | 'spirillum' | 'proteo' | 'cyano' + | 'cocci' + | 'bacillus' + | 'spirillum' + | 'proteo' + | 'cyano' // protists - | 'diatom' | 'euglena' | 'paramecium' | 'didinium' | 'lacrymaria' | 'amoeba' | 'testate' | 'stentor' + | 'diatom' + | 'euglena' + | 'paramecium' + | 'didinium' + | 'lacrymaria' + | 'amoeba' + | 'testate' + | 'stentor' // animalcules - | 'gastrotrich' | 'rotifer' | 'hydra' | 'collotheca' | 'tardigrade' + | 'gastrotrich' + | 'rotifer' + | 'hydra' + | 'collotheca' + | 'tardigrade' // infectious agents along the aliveness spectrum - | 'prion' | 'viroid' | 'satellite' | 'adenovirus' | 'retrovirus' | 'tmv' | 'phage' | 'mimivirus' | 'virophage' + | 'prion' + | 'viroid' + | 'satellite' + | 'adenovirus' + | 'retrovirus' + | 'tmv' + | 'phage' + | 'mimivirus' + | 'virophage' // other - | 'neoplasm' | 'pollen'; + | 'neoplasm' + | 'pollen'; export type SpeciesGroup = 'resource' | 'bacteria' | 'protist' | 'animal' | 'agent' | 'boss' | 'obstacle'; export type Behavior = - | 'drift' | 'swim' | 'corkscrew' | 'hunter' | 'neck' | 'engulfer' | 'testate' | 'vortex' - | 'tentacles' | 'trap' | 'lumber' | 'virus' | 'swarm' | 'virophage' | 'neoplasm' | 'grazer'; + | 'drift' + | 'swim' + | 'corkscrew' + | 'hunter' + | 'neck' + | 'engulfer' + | 'testate' + | 'vortex' + | 'tentacles' + | 'trap' + | 'lumber' + | 'virus' + | 'swarm' + | 'virophage' + | 'neoplasm' + | 'grazer'; export type HostClass = 'bacteria' | 'eukaryote' | 'plant' | 'amoeboid' | 'giantVirus'; export type InfectionStyle = 'lytic' | 'lysogenic' | 'viroid' | 'prion'; export type OrganelleType = - | 'nucleus' | 'mitochondrion' | 'chloroplast' | 'er' | 'golgi' | 'lysosome' - | 'vacuole' | 'cytoskeleton' | 'flagellum' | 'cilia' | 'extrusome' | 'eyespot'; + | 'nucleus' + | 'mitochondrion' + | 'chloroplast' + | 'er' + | 'golgi' + | 'lysosome' + | 'vacuole' + | 'cytoskeleton' + | 'flagellum' + | 'cilia' + | 'extrusome' + | 'eyespot'; export type CellFate = 'prime' | 'photocyte' | 'ciliocyte' | 'phagocyte' | 'cnidocyte' | 'germ'; export type AbilityId = 'dash' | 'lysosome' | 'toxicyst' | 'rnai' | 'encyst' | 'virophage'; export type MutationId = - | 'pellicle' | 'contractileVacuole' | 'cytostome' | 'cristae' | 'phototaxis' | 'interferon' - | 'chaperonin' | 'dsup' | 'glycogen' | 'hydrolase' | 'flagellarMotor' | 'cadherin' - | 'bioluminescence' | 'venom' | 'endosymbiontPact' | 'pseudopodSurge' | 'hgt' | 'quorum'; - -export type DeathCause = - | 'rupture' | 'starvation' | 'lysis' | 'digested' | 'proteostasis' | 'neoplasm'; + | 'pellicle' + | 'contractileVacuole' + | 'cytostome' + | 'cristae' + | 'phototaxis' + | 'interferon' + | 'chaperonin' + | 'dsup' + | 'glycogen' + | 'hydrolase' + | 'flagellarMotor' + | 'cadherin' + | 'bioluminescence' + | 'venom' + | 'endosymbiontPact' + | 'pseudopodSurge' + | 'hgt' + | 'quorum'; + +export type DeathCause = 'rupture' | 'starvation' | 'lysis' | 'digested' | 'proteostasis' | 'neoplasm'; export type Entity = { id: number; diff --git a/src/game/sim/world.ts b/src/game/sim/world.ts index d71b277..5423f28 100644 --- a/src/game/sim/world.ts +++ b/src/game/sim/world.ts @@ -175,9 +175,16 @@ export function ambientUpkeep(state: GameState, view: ViewRect, dt: number) { if (prey < 3 + 9 * open) { const at = offscreen(); const symbionts = state.objective.index >= 1; - const table = speciesWeightsAt(at.x, at.y, state.seed, state.director.pressure, (d) => - (d.group === 'bacteria' && (symbionts || !isSymbiont(d.id))) || d.id === 'euglena' || d.id === 'diatom' - || (d.id === 'paramecium' && open > 0.4), + const table = speciesWeightsAt( + at.x, + at.y, + state.seed, + state.director.pressure, + (d) => + (d.group === 'bacteria' && (symbionts || !isSymbiont(d.id))) || + d.id === 'euglena' || + d.id === 'diatom' || + (d.id === 'paramecium' && open > 0.4), ); const species = weightedPick(rng, table); if (species) spawnGroup(state, species, at.x, at.y, rng); diff --git a/src/ui/Architect.tsx b/src/ui/Architect.tsx index dbdfa05..00a83c2 100644 --- a/src/ui/Architect.tsx +++ b/src/ui/Architect.tsx @@ -49,8 +49,12 @@ export function Architect({ engine, hud }: { engine: GameEngine; hud: HudSnapsho }, [hover, engine]); const validTarget = (slot: SlotView) => - !!moving && !slot.locked && moving.type !== 'nucleus' && slotAccepts(slot.id, moving.type) - && (!slot.occupant || (slot.occupant.type !== 'nucleus' && slotAccepts(hud.organelles.find((o) => o.id === moving.id)!.slot, slot.occupant.type))); + !!moving && + !slot.locked && + moving.type !== 'nucleus' && + slotAccepts(slot.id, moving.type) && + (!slot.occupant || + (slot.occupant.type !== 'nucleus' && slotAccepts(hud.organelles.find((o) => o.id === moving.id)!.slot, slot.occupant.type))); const t = hud.traits; return ( @@ -60,7 +64,8 @@ export function Architect({ engine, hud }: { engine: GameEngine; hud: HudSnapsho const r = Math.max(16, slot.r); const valid = validTarget(slot); const cls = [ - 'slot', `slot-${slot.ring}`, + 'slot', + `slot-${slot.ring}`, slot.locked ? 'slot-locked' : '', slot.occupant ? 'slot-full' : '', slot.occupant && slot.occupant.id === selected ? 'slot-selected' : '', @@ -93,9 +98,7 @@ export function Architect({ engine, hud }: { engine: GameEngine; hud: HudSnapsho ); })} - {drag && ( - - )} + {drag && }