From 32c3d9c5bc8be8fab47171ebb6c8d60f8ed74b23 Mon Sep 17 00:00:00 2001 From: Ben Miller Date: Tue, 20 Oct 2026 11:51:26 -0500 Subject: [PATCH] feat: add LifeSimulator ecosystem simulation with plants, animals, and weather systems --- README.md | 95 +++++ css/style.css | 811 ++++++++++++++++++++++++++++++++++++++++++ index.html | 406 +++++++++++++++++++++ js/config.js | 127 +++++++ js/entities/animal.js | 218 ++++++++++++ js/entities/plant.js | 181 ++++++++++ js/entities/sheep.js | 248 +++++++++++++ js/entities/wolf.js | 307 ++++++++++++++++ js/main.js | 68 ++++ js/particles.js | 253 +++++++++++++ js/simulation.js | 620 ++++++++++++++++++++++++++++++++ js/state.js | 107 ++++++ js/terrain.js | 286 +++++++++++++++ js/ui.js | 575 ++++++++++++++++++++++++++++++ js/weather.js | 394 ++++++++++++++++++++ 15 files changed, 4696 insertions(+) create mode 100644 README.md create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/config.js create mode 100644 js/entities/animal.js create mode 100644 js/entities/plant.js create mode 100644 js/entities/sheep.js create mode 100644 js/entities/wolf.js create mode 100644 js/main.js create mode 100644 js/particles.js create mode 100644 js/simulation.js create mode 100644 js/state.js create mode 100644 js/terrain.js create mode 100644 js/ui.js create mode 100644 js/weather.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b12ee2 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# 🌿 Ecosystem Life Simulator + +An interactive, pixelated ecosystem simulation built with pure **HTML5 Canvas, CSS3, and Vanilla JavaScript (ES Modules)**. + +Observe predator-prey dynamics, biological lifecycles, carcass decomposition, floral blooming cycles, seed dispersal, and periodic rainstorms in a living sandbox meadow. + +--- + +## 🌟 Key Features & Mechanics + +### 1. 🐑 Sheep Grazing & Herd Behavior +* **Rapid Grazing (Node Removal):** Sheep are ravenous grazers. When a sheep reaches a plant, it instantly consumes and removes the entire plant node from the field. A herd of sheep can sweep through and clear an entire field in moments. +* **Grazing Reach & Hunger:** Sheep continuously search for food whenever hunger drops below 96%, with a configurable grazing reach. +* **Mating & 3-Meal Growth:** Satiated adult sheep seek mates and collide to birth a baby lamb. Baby lambs start at 55% scale and grow larger with each meal until reaching adult size after **3 meals**. +* **Evasion:** Sheep actively detect wolves in their perception radius and flee with a panic speed boost. + +### 2. 🐺 Wolf Hunting, Combat Exertion & Population Controls +* **Combat Exertion (Lunge Health Cost):** Wolves expend health every time they deliver an attack bite. Reckless over-hunting without resting or eating inflicts stamina fatigue and damage. +* **Chase Drain:** Wolves expend hunger 50% faster while actively sprinting in hunt mode. +* **Controlled Reproduction:** Wolves require high satiety (86%+) and a long mating cooldown (35s+) to reproduce, preventing runaway wolf swarms from wiping out the sheep population unchecked. +* **Feeding:** Defeating a sheep restores hunger. Wolf pups must eat **3 meals** to mature into adult wolves. + +### 3. 💀 Carcass Decay & Soil Enrichment +* **Decay Cycle:** Dead animals leave carcasses that decompose through flesh and skeletal stages before dissolving into rich brown soil mulch. +* **Decay Speed Slider:** Adjust decomposition rate from 0.2x to 3.0x. +* **Floral Blooms:** Fully decomposed remains fertilize the ground and trigger a **lush Flower Tuft** to sprout on the enriched soil. + +### 4. 🌸 Flora Dynamics, Seed Dispersal & CPU Guard +* **Max Plant Cap (CPU Guard):** Strict upper ceiling slider (default: 90) preventing plant overpopulation and keeping framerates rock-solid. +* **Low Seed Spread Rate:** Mature, clustered plants have a rare chance (configurable, default 8%) to release an airborne dandelion seed that lands nearby. +* **Moisture & Withering:** Plants lose moisture during dry weather and wither away if unhydrated. +* **Periodic Rain:** Rain showers roll across the meadow periodically (or on demand), reviving all flora to 100% moisture. + +### 5. 🎛️ Comprehensive State Sliders & Presets +* **Populations & Caps:** + * Initial Sheep (2 - 80) + * Initial Wolves (1 - 20) + * Initial Plants (5 - 100) + * Max Plant Cap (20 - 300) +* **Sheep Dynamics:** + * Sheep Hunger Depletion Rate (0.2 - 3.0/s) + * Grazing Reach (10 - 35px) + * Sheep Mating Cooldown (10 - 60s) +* **Wolf Dynamics:** + * Wolf Lunge Health Cost (0 - 20 HP) + * Wolf Hunger Rate (0.5 - 3.5/s) + * Attack Bite Damage (15 - 80) + * Wolf Mating Cooldown (15 - 80s) +* **Flora & Atmosphere:** + * Plant Seed Spread Chance (0 - 40%) + * Plant Withering Speed (0.5 - 6.0/s) + * Rain Frequency (0 - 100%) + * Carcass Decay Speed (0.2x - 3.0x) +* **Preset Configuration Tools:** + * **💾 Save As Defaults:** Saves your customized slider configuration to `localStorage` as your new default baseline. + * **↺ Reset:** Reverts all sliders to your saved defaults. + * **📋 Copy JSON:** Copies the current configuration object to your clipboard for sharing or manual tuning. + +### 6. 📊 Extinction Event & Analytics +* If all animals perish, an extinction modal appears with an interactive population timeline chart (Sheep, Wolves, Plants) and detailed ecosystem stats. + +--- + +## 📂 Project Architecture + +``` +LifeSimulator/ +├── index.html # Layout, categorized sliders sidebar, canvas arena, and modal +├── css/ +│ └── style.css # Modern dark theme, glassmorphic cards, custom sliders +├── js/ +│ ├── config.js # Settings, bounds, factory & custom defaults, localStorage +│ ├── state.js # Population stats, birth/death counters, timeline snapshots +│ ├── particles.js # Blood spray, eating crumbs, hearts, seeds, and ripples +│ ├── terrain.js # Decals canvas buffer, blood spots, and carcass decomposition +│ ├── weather.js # Rain cycles, cloud shadows, raindrops, and hydration +│ ├── entities/ +│ │ ├── animal.js # Base animal class (steering, hunger, mating, baby growth) +│ │ ├── sheep.js # Sheep AI: rapid grazing, flee wolves, mate, baby growth +│ │ ├── wolf.js # Wolf AI: multi-bite hunting, lunge health cost, mate +│ │ └── plant.js # Grass & flower tufts, moisture depletion, regulated seeds +│ ├── ui.js # Slider bindings, presets, HUD updates, chart rendering +│ ├── simulation.js # Master simulation loop and entity manager +│ └── main.js # Entry point and animation frame loop +└── README.md # Project documentation +``` + +--- + +## 🚀 How to Run Locally + +```bash +python -m http.server 8080 +``` +Then open your browser to **`http://localhost:8080`**. diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..16576dd --- /dev/null +++ b/css/style.css @@ -0,0 +1,811 @@ +/** + * Modern Sleek Dark Design System + * Glassmorphic panels, responsive flex layout, custom styled sliders, and pixelated canvas framing. + */ + +:root { + --bg-primary: #0A0D12; + --bg-secondary: #121820; + --bg-card: rgba(22, 30, 41, 0.75); + --bg-card-hover: rgba(28, 38, 52, 0.9); + --border-subtle: rgba(255, 255, 255, 0.08); + --border-accent: rgba(56, 211, 159, 0.35); + + --text-main: #F0F4F8; + --text-muted: #8B9BB0; + --text-dim: #546478; + + --color-sheep: #4CC9F0; + --color-wolf: #FF4D6D; + --color-plant: #38D39F; + --color-gold: #FFB703; + + --accent-primary: #2ECC71; + --accent-primary-hover: #27AE60; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; + + --shadow-lg: 0 16px 36px rgba(0, 0, 0, 0.45); + --font-ui: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-primary); + color: var(--text-main); + font-family: var(--font-ui); + min-height: 100vh; + overflow-x: hidden; + user-select: none; +} + +/* --- Layout Grid --- */ +.app-layout { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* --- Top Navigation Header --- */ +.top-nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 24px; + background: rgba(18, 24, 32, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-subtle); + z-index: 10; + gap: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-icon { + font-size: 24px; + width: 38px; + height: 38px; + background: rgba(56, 211, 159, 0.12); + border: 1px solid rgba(56, 211, 159, 0.25); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; +} + +.brand h1 { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.01em; + color: #FFFFFF; +} + +.tagline { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +/* --- Stats Ribbon Badges --- */ +.stats-ribbon { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.stat-badge { + display: flex; + align-items: center; + gap: 6px; + background: var(--bg-card); + border: 1px solid var(--border-subtle); + padding: 5px 12px; + border-radius: var(--radius-sm); + font-size: 0.82rem; + font-weight: 500; + transition: all 0.2s ease; +} + +.stat-badge:hover { + background: var(--bg-card-hover); + border-color: rgba(255, 255, 255, 0.15); +} + +.badge-val { + font-family: var(--font-mono); + font-weight: 600; + font-size: 0.95rem; +} + +.badge-sheep .badge-val { color: var(--color-sheep); } +.badge-wolf .badge-val { color: var(--color-wolf); } +.badge-plant .badge-val { color: var(--color-plant); } +.badge-time .badge-val { color: var(--color-gold); } + +.badge-label { + color: var(--text-muted); + font-size: 0.74rem; +} + +/* --- Header Actions --- */ +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +/* --- Buttons System --- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.btn-primary { + background: linear-gradient(135deg, #2ECC71, #10B981); + color: #042914; + box-shadow: 0 4px 14px rgba(46, 204, 113, 0.25); +} + +.btn-primary:hover { + background: linear-gradient(135deg, #34D399, #059669); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(46, 204, 113, 0.35); +} + +.btn-primary.btn-active { + background: linear-gradient(135deg, #F59E0B, #D97706); + color: #381A02; + box-shadow: 0 4px 14px rgba(245, 158, 11, 0.25); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.08); + color: var(--text-main); + border: 1px solid var(--border-subtle); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(255, 255, 255, 0.2); +} + +.btn-text { + background: transparent; + border: none; + color: var(--color-sheep); + font-size: 0.75rem; + cursor: pointer; + text-decoration: underline; + padding: 2px 4px; +} + +.btn-text:hover { + color: #A0E9FF; +} + +.btn-sm { + padding: 5px 9px; + font-size: 0.74rem; + border-radius: 5px; +} + +.btn-accent { + background: linear-gradient(135deg, #05D9E8, #0056B3); + color: #FFFFFF; + box-shadow: 0 2px 8px rgba(5, 217, 232, 0.25); +} + +.btn-accent:hover { + background: linear-gradient(135deg, #37E7F6, #0070E0); + transform: translateY(-1px); +} + +.preset-buttons-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.preset-buttons-row .btn { + flex: 1; +} + +.toast-text { + font-size: 0.75rem; + font-weight: 600; + color: #38D39F; + background: rgba(56, 211, 159, 0.12); + border: 1px solid rgba(56, 211, 159, 0.3); + border-radius: var(--radius-sm); + padding: 4px 8px; + text-align: center; + transition: all 0.2s ease; +} + +.btn-action-weather { + width: 100%; + margin-top: 10px; + background: rgba(76, 201, 240, 0.12); + color: #8ED6FF; + border: 1px dashed rgba(76, 201, 240, 0.35); + padding: 7px; + font-size: 0.8rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-action-weather:hover { + background: rgba(76, 201, 240, 0.2); + border-color: #8ED6FF; +} + +/* --- Main Workspace Split View --- */ +.main-workspace { + display: flex; + flex: 1; + overflow: hidden; + position: relative; +} + +/* --- Left Sidebar Panel --- */ +.sidebar-panel { + width: 350px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 5; +} + +.sidebar-scroll { + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.sidebar-scroll::-webkit-scrollbar { + width: 5px; +} +.sidebar-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.12); + border-radius: 10px; +} + +.control-group { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.group-title { + font-size: 0.82rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.group-title-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +/* Speed Multiplier Segmented Buttons */ +.speed-selector { + display: flex; + gap: 6px; + background: rgba(0, 0, 0, 0.25); + padding: 3px; + border-radius: var(--radius-sm); +} + +.speed-btn { + flex: 1; + background: transparent; + border: none; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.82rem; + padding: 5px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; +} + +.speed-btn:hover { + color: #FFF; +} + +.speed-btn.active { + background: var(--accent-primary); + color: #042914; + font-weight: 700; +} + +/* Sliders */ +.slider-item { + display: flex; + flex-direction: column; + gap: 6px; +} + +.slider-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.82rem; +} + +.slider-title { + color: var(--text-main); + font-weight: 500; +} + +.slider-val { + font-family: var(--font-mono); + color: var(--color-plant); + font-weight: 600; + font-size: 0.85rem; +} + +input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--color-plant); + cursor: pointer; + box-shadow: 0 0 8px rgba(56, 211, 159, 0.6); + transition: transform 0.1s ease; +} + +input[type="range"]::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +/* Tools Grid */ +.tools-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +.tool-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border-subtle); + color: var(--text-main); + padding: 8px 6px; + border-radius: var(--radius-sm); + font-size: 0.78rem; + font-family: var(--font-ui); + cursor: pointer; + transition: all 0.2s ease; +} + +.tool-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.tool-btn.active { + background: rgba(56, 211, 159, 0.15); + border-color: var(--color-plant); + color: #FFF; + font-weight: 600; +} + +/* Mini Population Chart in Sidebar */ +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.chart-legend-mini { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.68rem; + color: var(--text-muted); +} + +.leg-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; +} +.sheep-dot { background: var(--color-sheep); } +.wolf-dot { background: var(--color-wolf); } +.plant-dot { background: var(--color-plant); } + +.mini-chart-wrapper { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 4px; + height: 98px; + display: flex; + align-items: center; + justify-content: center; +} + +#miniChart { + width: 100%; + height: 100%; +} + +/* --- Center Canvas Viewport --- */ +.canvas-viewport { + flex: 1; + display: flex; + align-items: stretch; + justify-content: stretch; + background: #06080B; + position: relative; + overflow: hidden; + padding: 0; + width: 100%; + height: 100%; +} + +.canvas-container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: #4A8330; +} + +#simCanvas { + width: 100%; + height: 100%; + display: block; + image-rendering: -moz-crisp-edges; + image-rendering: -webkit-crisp-edges; + image-rendering: pixelated; + image-rendering: crisp-edges; + cursor: crosshair; +} + +/* --- Entity Inspector Floating Card --- */ +.inspector-card { + position: absolute; + top: 14px; + right: 14px; + width: 220px; + background: rgba(14, 20, 28, 0.88); + backdrop-filter: blur(14px); + border: 1px solid rgba(255, 215, 0, 0.4); + border-radius: var(--radius-md); + padding: 12px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.5); + z-index: 20; +} + +.insp-close { + position: absolute; + top: 6px; + right: 8px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; +} +.insp-close:hover { color: #FFF; } + +.insp-title { + font-weight: 700; + font-size: 0.9rem; + margin-bottom: 8px; + color: #FFD700; + padding-right: 14px; +} + +.insp-row { + display: flex; + justify-content: space-between; + font-size: 0.78rem; + color: var(--text-muted); + padding: 3px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} +.insp-row span:last-child { + color: var(--text-main); + font-family: var(--font-mono); + font-weight: 500; +} +.capitalize { + text-transform: capitalize; +} + +/* --- Extinction Modal Overlay --- */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(4, 6, 9, 0.85); + backdrop-filter: blur(16px); + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + padding: 20px; + transition: opacity 0.3s ease; +} + +/* --- Visibility Utilities --- */ +.hidden, +.modal-backdrop.hidden, +.inspector-card.hidden, +.btn-fullscreen-exit.hidden { + display: none !important; +} + +.modal-card { + background: var(--bg-secondary); + border: 1px solid rgba(255, 77, 109, 0.35); + border-radius: var(--radius-lg); + width: 100%; + max-width: 680px; + padding: 28px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.7); + display: flex; + flex-direction: column; + gap: 20px; + animation: modalPop 0.35s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes modalPop { + 0% { transform: scale(0.95); opacity: 0; } + 100% { transform: scale(1); opacity: 1; } +} + +.modal-header { + text-align: center; +} + +.modal-badge-skull { + font-size: 32px; + margin-bottom: 6px; +} + +.modal-header h2 { + font-size: 1.6rem; + color: #FFF; + font-weight: 700; +} + +.modal-subtitle { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 4px; +} + +.modal-chart-section h3 { + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.04em; + margin-bottom: 8px; +} + +.extinction-chart-wrapper { + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + padding: 8px; + height: 230px; + display: flex; + align-items: center; + justify-content: center; +} + +#extinctionChart { + width: 100%; + height: 100%; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.stat-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.sc-label { + font-size: 0.72rem; + color: var(--text-muted); + text-transform: uppercase; + font-weight: 600; +} + +.sc-val { + font-family: var(--font-mono); + font-size: 0.95rem; + font-weight: 700; + color: #FFF; +} + +.sc-subvals { + display: flex; + gap: 8px; + font-size: 0.82rem; + color: var(--text-main); +} +.sc-subvals strong { + font-family: var(--font-mono); + color: #FFF; +} + +.modal-actions { + display: flex; + gap: 12px; + margin-top: 6px; +} + +.btn-lg { + flex: 1; + padding: 12px; + font-size: 0.95rem; +} + +/* --- Toggle Switch for Continuous Mode --- */ +.toggle-switch { + position: relative; + display: inline-block; + width: 38px; + height: 20px; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(255, 255, 255, 0.15); + transition: 0.25s cubic-bezier(0.16, 1, 0.3, 1); + border-radius: 20px; + border: 1px solid var(--border-subtle); +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 12px; + width: 12px; + left: 3px; + bottom: 3px; + background-color: #FFF; + transition: 0.25s cubic-bezier(0.16, 1, 0.3, 1); + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background: linear-gradient(135deg, #2ECC71, #10B981); + box-shadow: 0 0 10px rgba(46, 204, 113, 0.4); + border-color: #2ECC71; +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(18px); +} + +.group-subtitle { + font-size: 0.72rem; + color: var(--text-muted); + line-height: 1.35; + margin-top: -2px; + margin-bottom: 6px; +} + +/* --- Fullscreen / Screen Saver Mode --- */ +.app-layout.fullscreen-mode .top-nav, +.app-layout.fullscreen-mode .sidebar-panel { + display: none !important; +} + +.app-layout.fullscreen-mode .main-workspace { + height: 100vh !important; + width: 100vw !important; +} + +.app-layout.fullscreen-mode .canvas-viewport { + width: 100vw !important; + height: 100vh !important; + padding: 0 !important; +} + +.btn-fullscreen-exit { + position: fixed; + top: 18px; + right: 18px; + z-index: 1000; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: rgba(14, 20, 28, 0.75); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-sm); + color: var(--text-main); + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + opacity: 0.65; +} + +.btn-fullscreen-exit:hover { + opacity: 1; + background: rgba(20, 30, 42, 0.95); + border-color: var(--color-plant); + transform: translateY(-1px); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.65); +} + diff --git a/index.html b/index.html new file mode 100644 index 0000000..4f381c3 --- /dev/null +++ b/index.html @@ -0,0 +1,406 @@ + + + + + + Ecosystem Life Simulator — Wolves, Sheep & Flora + + + + + + + +
+ +
+
+
🌿
+
+

Life Simulator

+ Ecological Flora & Fauna Simulation +
+
+ + +
+
+ 🐑 + 0 + Sheep +
+
+ 🐺 + 0 + Wolves +
+
+ 🌸 + 0 + Plants +
+
+ ☀️ Sunny +
+
+ ⏱️ + 00:00 +
+
+ + +
+ + + +
+
+ + + + + +
+ + + + +
+
+ + + + +
+
+
+ + + +
+ + + + + diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..1efcc73 --- /dev/null +++ b/js/config.js @@ -0,0 +1,127 @@ +/** + * Configuration & Settings Management + * Handles default values, slider bounds, custom defaults, and localStorage persistence. + */ + +export const FACTORY_DEFAULTS = { + // Starting entity counts & limits + initialSheep: 25, + initialWolves: 3, + initialPlants: 35, + maxPlantCap: 90, // Hard cap to keep CPU lightweight and meadow balanced + + // Sheep behavioral attributes + sheepHungerDepletionRate: 1.2, // per second (sheep graze frequently) + sheepGrazingReach: 18, // radius in pixels to snap up plants + sheepMatingCooldown: 22, // seconds between sheep births + + // Wolf behavioral attributes & combat exertion + wolfLungeCost: 7, // Health expended per attack bite (fatigue/struggle) + wolfHungerDepletionRate: 1.3, // per second + wolfAttackDamage: 38, // damage per bite to sheep + wolfMatingCooldown: 35, // seconds between wolf litters + wolfMatingThreshold: 86, // minimum hunger required to seek mate + + // Plant & flora dynamics + plantSeedRate: 8, // % chance for clustered mature plants to disperse seed + plantDryRate: 2.2, // moisture loss per second in clear weather + rainFrequency: 45, // 0 - 100% + decaySpeed: 1.0, // 0.2x to 3.0x multiplier on carcass decomposition + + // Movement speeds (preset balance) + sheepMaxSpeed: 1.8, + sheepFleeSpeed: 2.8, + sheepPerceptionRadius: 160, + wolfMaxSpeed: 2.2, + wolfChaseSpeed: 4.1, + wolfPerceptionRadius: 280, + wolfAttackCooldown: 0.65, + + // World constraints + worldWidth: 1280, + worldHeight: 760, + boundaryMargin: 40, + + // Continuous Mode (Screen Saver / Endless Ecosystem) + continuousMode: false, + continuousSheepInterval: 14, + continuousWolfInterval: 32, + continuousMaxGroupSize: 3, +}; + +const STORAGE_KEY = 'LifeSimulator_Config_v2'; +const CUSTOM_DEFAULTS_KEY = 'LifeSimulator_Custom_Defaults_v2'; + +export class ConfigManager { + constructor() { + this.current = this.load(); + } + + load() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + return { ...FACTORY_DEFAULTS, ...parsed }; + } + } catch (e) { + console.warn('Failed to load settings from localStorage:', e); + } + return { ...FACTORY_DEFAULTS }; + } + + save(newConfig) { + this.current = { ...this.current, ...newConfig }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current)); + } catch (e) { + console.warn('Failed to save settings to localStorage:', e); + } + return this.current; + } + + saveAsNewDefaults(config) { + this.current = { ...this.current, ...config }; + try { + localStorage.setItem(CUSTOM_DEFAULTS_KEY, JSON.stringify(this.current)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current)); + } catch (e) { + console.warn('Failed to save custom defaults:', e); + } + return this.current; + } + + resetToDefaults() { + // Check if user set custom defaults, otherwise use factory defaults + let base = FACTORY_DEFAULTS; + try { + const custom = localStorage.getItem(CUSTOM_DEFAULTS_KEY); + if (custom) { + base = { ...FACTORY_DEFAULTS, ...JSON.parse(custom) }; + } + } catch (e) { + console.warn('Failed to read custom defaults:', e); + } + + this.current = { ...base }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current)); + } catch (e) { + console.warn('Failed to reset settings:', e); + } + return this.current; + } + + resetToFactory() { + this.current = { ...FACTORY_DEFAULTS }; + try { + localStorage.removeItem(CUSTOM_DEFAULTS_KEY); + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.current)); + } catch (e) { + console.warn('Failed to reset to factory defaults:', e); + } + return this.current; + } +} + +export const configManager = new ConfigManager(); diff --git a/js/entities/animal.js b/js/entities/animal.js new file mode 100644 index 0000000..ad6e77f --- /dev/null +++ b/js/entities/animal.js @@ -0,0 +1,218 @@ +/** + * Base Animal Entity Class + * Encapsulates movement, steering behaviors, soft boundaries, hunger, health, + * reproduction prerequisites, baby growth cycle, and walking bob animations. + */ + +export class Animal { + constructor(x, y, species, isBaby = false) { + this.x = x; + this.y = y; + this.species = species; // 'sheep' or 'wolf' + + // Movement + this.vx = (Math.random() - 0.5) * 1.5; + this.vy = (Math.random() - 0.5) * 1.5; + this.heading = Math.atan2(this.vy, this.vx); + this.wanderAngle = Math.random() * Math.PI * 2; + + // Biological stats + this.health = 100; + this.hunger = isBaby ? 80 : 75; // 0 to 100 + this.age = 0; + this.isDead = false; + this.deathCause = null; + + // Baby & Growth + this.isBaby = isBaby; + this.foodEatenAsBaby = 0; + this.growthScale = isBaby ? 0.55 : 1.0; + + // Mating + this.mateCooldown = 12 + Math.random() * 6; // seconds before ready + this.isSeekingMate = false; + + // Animation + this.walkCycle = Math.random() * Math.PI * 2; + this.bobOffset = 0; + this.actionState = 'wander'; // 'wander', 'seek_food', 'flee', 'hunt', 'mating' + this.actionTimer = 0; + } + + updateBase(dt, worldWidth, worldHeight, config) { + if (this.isDead) return; + + this.age += dt; + this.walkCycle += Math.hypot(this.vx, this.vy) * dt * 4.5; + this.bobOffset = Math.abs(Math.sin(this.walkCycle)) * 2.5; + + // Mating cooldown + if (this.mateCooldown > 0) { + this.mateCooldown -= dt; + } + + // Health decay if starving + if (this.hunger <= 0) { + this.hunger = 0; + this.health -= 8.0 * dt; // dies in 12.5s without food + if (this.health <= 0) { + this.health = 0; + this.die('starvation'); + return; + } + } + + // Soft boundary steering + this.applyBoundarySteering(worldWidth, worldHeight, config.boundaryMargin || 45); + + // Integrate position + this.x += this.vx * dt * 45; + this.y += this.vy * dt * 45; + + // Update facing heading if moving + const currentSpeed = Math.hypot(this.vx, this.vy); + if (currentSpeed > 0.05) { + this.heading = Math.atan2(this.vy, this.vx); + } + } + + applyBoundarySteering(width, height, margin = 55) { + const turnForce = 0.45; + const bodyPadding = 22; // Keeps entire head and body comfortably inside visible canvas + + // Proactive steering away from boundaries + if (this.x < margin) { + this.vx += turnForce * ((margin - this.x) / margin); + } else if (this.x > width - margin) { + this.vx -= turnForce * ((this.x - (width - margin)) / margin); + } + + if (this.y < margin) { + this.vy += turnForce * ((margin - this.y) / margin); + } else if (this.y > height - margin) { + this.vy -= turnForce * ((this.y - (height - margin)) / margin); + } + + // Physical boundary bounce: reflect velocity inward so animals never get stuck facing walls + if (this.x <= bodyPadding) { + this.x = bodyPadding; + if (this.vx < 0) { + this.vx = Math.abs(this.vx) * 0.7 + 0.3; + this.wanderAngle = 0; // face inward (right) + } + } else if (this.x >= width - bodyPadding) { + this.x = width - bodyPadding; + if (this.vx > 0) { + this.vx = -Math.abs(this.vx) * 0.7 - 0.3; + this.wanderAngle = Math.PI; // face inward (left) + } + } + + if (this.y <= bodyPadding) { + this.y = bodyPadding; + if (this.vy < 0) { + this.vy = Math.abs(this.vy) * 0.7 + 0.3; + this.wanderAngle = Math.PI * 0.5; // face inward (down) + } + } else if (this.y >= height - bodyPadding) { + this.y = height - bodyPadding; + if (this.vy > 0) { + this.vy = -Math.abs(this.vy) * 0.7 - 0.3; + this.wanderAngle = Math.PI * 1.5; // face inward (up) + } + } + } + + wander(speed = 1.2) { + this.wanderAngle += (Math.random() - 0.5) * 0.45; + const targetVx = Math.cos(this.wanderAngle) * speed; + const targetVy = Math.sin(this.wanderAngle) * speed; + this.vx += (targetVx - this.vx) * 0.1; + this.vy += (targetVy - this.vy) * 0.1; + } + + steerTowards(targetX, targetY, speed = 2.0, turnRate = 0.15) { + const dx = targetX - this.x; + const dy = targetY - this.y; + const dist = Math.hypot(dx, dy); + if (dist > 1) { + const targetVx = (dx / dist) * speed; + const targetVy = (dy / dist) * speed; + this.vx += (targetVx - this.vx) * turnRate; + this.vy += (targetVy - this.vy) * turnRate; + } + } + + steerAwayFrom(hazardX, hazardY, speed = 2.6, worldWidth = 1280, worldHeight = 760) { + let dx = this.x - hazardX; + let dy = this.y - hazardY; + + // Wall deflection: curve along perimeter into open field rather than pinning against wall + const wallBuffer = 55; + if (this.x < wallBuffer) dx += 1.8; + else if (this.x > worldWidth - wallBuffer) dx -= 1.8; + + if (this.y < wallBuffer) dy += 1.8; + else if (this.y > worldHeight - wallBuffer) dy -= 1.8; + + const dist = Math.hypot(dx, dy); + if (dist > 0.1) { + const targetVx = (dx / dist) * speed; + const targetVy = (dy / dist) * speed; + this.vx += (targetVx - this.vx) * 0.25; + this.vy += (targetVy - this.vy) * 0.25; + } + } + + recordMeal(nutritionAmount = 40, particleSystem = null) { + this.hunger = Math.min(100, this.hunger + nutritionAmount); + + if (this.isBaby) { + this.foodEatenAsBaby++; + // Baby grows progressively with each meal: + // 0 meals: 0.55 scale, 1 meal: 0.70 scale, 2 meals: 0.85 scale, 3 meals: 1.0 adult! + this.growthScale = 0.55 + (this.foodEatenAsBaby / 3) * 0.45; + + if (this.foodEatenAsBaby >= 3) { + this.isBaby = false; + this.growthScale = 1.0; + if (particleSystem) { + particleSystem.emitGrowthSparkles(this.x, this.y, 14); + } + } + } + } + + canMate() { + return !this.isBaby && this.hunger >= 82 && this.mateCooldown <= 0; + } + + consumeMatingEnergy(cooldown = 20) { + this.hunger = Math.max(25, this.hunger - 45); + this.mateCooldown = cooldown + Math.random() * 6; + } + + takeDamage(amount, fromAngle = 0, particleSystem = null) { + this.health -= amount; + + // Bite strike knockback impulse pushes prey away from predator + const knockback = 2.6; + this.vx += Math.cos(fromAngle) * knockback; + this.vy += Math.sin(fromAngle) * knockback; + + if (particleSystem) { + particleSystem.emitBloodSpray(this.x, this.y, fromAngle, 12); + } + if (this.health <= 0) { + this.health = 0; + this.die('wolf'); + return true; // died + } + return false; + } + + die(cause = 'starvation') { + this.isDead = true; + this.deathCause = cause; + } +} diff --git a/js/entities/plant.js b/js/entities/plant.js new file mode 100644 index 0000000..8e9b76f --- /dev/null +++ b/js/entities/plant.js @@ -0,0 +1,181 @@ +/** + * Plant & Flower Tuft Entity + * Supports standard grass tufts and nutrient-dense flower tufts grown from decayed remains. + * Features moisture loss, revival via rain, growth stages, and seed dispersal when clustered. + */ + +export class Plant { + constructor(x, y, type = 'grass', growth = 0.25, worldWidth = 1280, worldHeight = 760) { + // Natural soft boundary matching meadow edge + const margin = 20; + this.x = Math.max(margin, Math.min(worldWidth - margin, x)); + this.y = Math.max(margin, Math.min(worldHeight - margin, y)); + this.type = type; // 'grass' or 'flower' (dense flower tuft) + + this.growth = growth; // 0.2 to 1.0 + this.growthRate = type === 'flower' ? 0.08 : 0.05; + + this.moisture = 100; // 0 to 100 + this.dryRate = 1.6; // moisture loss per second + + this.age = 0; + this.seedCooldown = 8 + Math.random() * 8; // initial jitter + this.bitesRemaining = type === 'flower' ? 3 : 1; // dense tufts provide multiple meals + + this.isDead = false; + this.swayOffset = Math.random() * Math.PI * 2; + + // Pick flower colors for flower tufts + const flowerPalette = ['#FF2A6D', '#FFD166', '#05D9E8', '#9B5DE5', '#F15BB5']; + this.flowerColor = flowerPalette[Math.floor(Math.random() * flowerPalette.length)]; + } + + receiveRain(amount) { + this.moisture = Math.min(100, this.moisture + amount); + } + + update(dt, allPlants, onSeedDispersed, onWithered, worldWidth, worldHeight, config = null, terrain = null) { + if (this.isDead) return; + + this.age += dt; + + // As plants grow, they absorb surrounding blood/decay decals into nutrients! + if (terrain && this.age < 8) { + terrain.absorbDecalsNear(this.x, this.y, 30); + } + + // 1. Dryness / moisture loss using config dry rate + const dryRate = config && config.plantDryRate ? config.plantDryRate : 2.2; + this.moisture -= dryRate * dt; + if (this.moisture <= 0) { + this.moisture = 0; + this.isDead = true; + if (onWithered) onWithered(this); + return; + } + + // 2. Growth + if (this.growth < 1.0) { + this.growth = Math.min(1.0, this.growth + this.growthRate * dt); + } + + // 3. Proximity-based Seed Dispersal (heavily regulated to protect CPU & meadow balance) + this.seedCooldown -= dt; + if (this.seedCooldown <= 0 && this.growth >= 0.85 && this.age >= 24 && this.moisture >= 50) { + this.seedCooldown = 24 + Math.random() * 18; + + const maxCap = config && config.maxPlantCap ? config.maxPlantCap : 90; + if (allPlants.length >= maxCap) return; + + const seedChance = config && typeof config.plantSeedRate === 'number' ? config.plantSeedRate : 8; + if (Math.random() * 100 > seedChance) return; + + // Check neighbor count: must have a close neighbor, but not overcrowded (< 3 in 55px) + let neighborCount = 0; + for (let i = 0; i < allPlants.length; i++) { + const other = allPlants[i]; + if (other === this || other.isDead) continue; + const dist = Math.hypot(other.x - this.x, other.y - this.y); + if (dist <= 55) { + neighborCount++; + } + } + + if (neighborCount >= 1 && neighborCount <= 3 && onSeedDispersed) { + const angle = Math.random() * Math.PI * 2; + const dist = 35 + Math.random() * 50; + const safeMargin = 22; + const targetX = Math.max(safeMargin, Math.min(worldWidth - safeMargin, this.x + Math.cos(angle) * dist)); + const targetY = Math.max(safeMargin, Math.min(worldHeight - safeMargin, this.y + Math.sin(angle) * dist)); + + onSeedDispersed(this.x, this.y - 6, targetX, targetY); + } + } + } + + takeBite() { + // Sheep eating a plant completely consumes and removes the node + this.bitesRemaining = 0; + this.isDead = true; + return true; + } + + draw(ctx, simTime = 0) { + if (this.isDead) return; + + ctx.save(); + const px = Math.round(this.x); + const py = Math.round(this.y); + const sway = Math.sin(simTime * 2.5 + this.swayOffset) * 1.5; + + // Color gradient based on moisture + // Healthy: vibrant greens; Parched: pale yellow/olive; Withering: straw brown + const moistPct = this.moisture / 100; + let bladeColor = '#3CB043'; + let darkBladeColor = '#2B822F'; + + if (moistPct < 0.3) { + bladeColor = '#A89F58'; // withering straw + darkBladeColor = '#8A8244'; + } else if (moistPct < 0.6) { + bladeColor = '#77B847'; // slightly dry + darkBladeColor = '#5C9132'; + } + + if (this.type === 'flower') { + // --- DENSE FLOWER TUFT (High nutrition from decayed carcasses) --- + const scale = this.growth; + const w = Math.round(18 * scale); + const h = Math.round(16 * scale); + + // Dense leafy foliage mound + ctx.fillStyle = darkBladeColor; + ctx.fillRect(px - Math.round(w / 2), py - 2, w, 4); + + // Lush pixel blades swaying + ctx.fillStyle = bladeColor; + ctx.fillRect(px - 6 + sway * 0.5, py - Math.round(h * 0.7), 2, Math.round(h * 0.7)); + ctx.fillRect(px - 2 + sway * 0.8, py - h, 3, h); + ctx.fillRect(px + 3 + sway, py - Math.round(h * 0.85), 2, Math.round(h * 0.85)); + ctx.fillRect(px + 6 + sway * 0.6, py - Math.round(h * 0.6), 2, Math.round(h * 0.6)); + + // Blossoms / Flowers (if mature) + if (this.growth > 0.6) { + // Main flower + ctx.fillStyle = this.flowerColor; + const fx = Math.round(px + sway * 0.8); + const fy = Math.round(py - h - 1); + ctx.fillRect(fx - 2, fy - 2, 5, 5); + ctx.fillStyle = '#FFFFFF'; // flower center + ctx.fillRect(fx, fy, 1, 1); + + // Secondary small blossom + ctx.fillStyle = '#FFE66D'; + const fx2 = Math.round(px - 5 + sway * 0.5); + const fy2 = Math.round(py - Math.round(h * 0.75)); + ctx.fillRect(fx2 - 1, fy2 - 1, 3, 3); + } + } else { + // --- STANDARD GRASS TUFT --- + const scale = this.growth; + const h = Math.round(10 * scale); + + ctx.fillStyle = bladeColor; + ctx.fillRect(px - 3 + sway * 0.6, py - Math.round(h * 0.8), 2, Math.round(h * 0.8)); + ctx.fillRect(px + sway, py - h, 2, h); + ctx.fillRect(px + 3 + sway * 0.7, py - Math.round(h * 0.7), 2, Math.round(h * 0.7)); + + // Root base dot + ctx.fillStyle = darkBladeColor; + ctx.fillRect(px - 2, py - 1, 5, 2); + } + + // Optional subtle low-moisture alert dot if dying of thirst + if (this.moisture < 25) { + ctx.fillStyle = '#E63946'; + ctx.fillRect(px - 1, py - 16, 2, 2); + } + + ctx.restore(); + } +} diff --git a/js/entities/sheep.js b/js/entities/sheep.js new file mode 100644 index 0000000..c3249f8 --- /dev/null +++ b/js/entities/sheep.js @@ -0,0 +1,248 @@ +/** + * Sheep Entity + * Grazes on grass and dense flower tufts, flees from predators, + * seeks mates when well-fed, and gives birth to growing baby sheep. + */ + +import { Animal } from './animal.js'; + +export class Sheep extends Animal { + constructor(x, y, isBaby = false) { + super(x, y, 'sheep', isBaby); + this.grazingTimer = 0; + } + + update(dt, worldWidth, worldHeight, config, plants, wolves, allSheep, particleSystem, onBabyBorn, terrain = null) { + if (this.isDead) return; + + // Deplete hunger over time using config + this.hunger -= (config.sheepHungerDepletionRate || 1.2) * dt; + + // Base update (boundaries, health tick if starving, walk cycle) + super.updateBase(dt, worldWidth, worldHeight, config); + if (this.isDead) return; + + // Flock Separation: maintain personal space so sheep don't bunch into a single spot + for (let i = 0; i < allSheep.length; i++) { + const other = allSheep[i]; + if (other === this || other.isDead) continue; + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minFlockDist = 26 * this.growthScale; + if (dist < minFlockDist && dist > 0.05) { + const force = ((minFlockDist - dist) / minFlockDist) * 0.3; + this.vx -= (dx / dist) * force; + this.vy -= (dy / dist) * force; + } + } + + // 1. Check for nearby threats (Wolves) - TOP PRIORITY: FLEE! + let nearestWolf = null; + let minWolfDist = config.sheepPerceptionRadius || 160; + + for (let i = 0; i < wolves.length; i++) { + const w = wolves[i]; + if (w.isDead) continue; + const dist = Math.hypot(w.x - this.x, w.y - this.y); + if (dist < minWolfDist) { + minWolfDist = dist; + nearestWolf = w; + } + } + + if (nearestWolf) { + this.actionState = 'flee'; + const fleeSpeed = config.sheepFleeSpeed || 2.8; + this.steerAwayFrom(nearestWolf.x, nearestWolf.y, fleeSpeed, worldWidth, worldHeight); + return; + } + + // 2. Mating Behavior: + // If sheep is full, adult, and off cooldown -> search for another full sheep + if (this.canMate()) { + let nearestMate = null; + let minMateDist = 180; + + for (let i = 0; i < allSheep.length; i++) { + const s = allSheep[i]; + if (s === this || s.isDead || !s.canMate()) continue; + const dist = Math.hypot(s.x - this.x, s.y - this.y); + if (dist < minMateDist) { + minMateDist = dist; + nearestMate = s; + } + } + + if (nearestMate) { + this.actionState = 'mating'; + const approachSpeed = config.sheepMaxSpeed || 1.8; + this.steerTowards(nearestMate.x, nearestMate.y, approachSpeed); + + const collisionThreshold = (16 * this.growthScale) + (16 * nearestMate.growthScale); + if (minMateDist <= collisionThreshold) { + const cooldown = config.sheepMatingCooldown || 22; + this.consumeMatingEnergy(cooldown); + nearestMate.consumeMatingEnergy(cooldown); + + if (particleSystem) { + particleSystem.emitHeart(this.x, this.y); + particleSystem.emitHeart(nearestMate.x, nearestMate.y); + } + + if (onBabyBorn) { + const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12; + const babyY = (this.y + nearestMate.y) / 2 + (Math.random() - 0.5) * 12; + onBabyBorn(babyX, babyY, 'sheep'); + } + } + return; + } + } + + // 3. Foraging / Grazing: + // Sheep are active grazers — will seek and mow down plants if hunger < 96% + if (this.hunger < 96 || this.isBaby) { + let bestPlant = null; + let bestScore = -Infinity; + + for (let i = 0; i < plants.length; i++) { + const p = plants[i]; + if (p.isDead || p.growth < 0.25) continue; + const dist = Math.hypot(p.x - this.x, p.y - this.y); + if (dist > 280) continue; + + const flowerBonus = p.type === 'flower' ? 50 : 0; + const score = 320 - dist + flowerBonus; + if (score > bestScore) { + bestScore = score; + bestPlant = p; + } + } + + if (bestPlant) { + this.actionState = 'seek_food'; + const dist = Math.hypot(bestPlant.x - this.x, bestPlant.y - this.y); + const grazeReach = Math.max(18, (config.sheepGrazingReach || 18) * this.growthScale); + + if (dist <= grazeReach) { + // Rapid chomp: instantly consume & remove plant node + const nutrition = bestPlant.type === 'flower' ? 45 : 30; + bestPlant.takeBite(); // removes the node immediately + this.recordMeal(nutrition, particleSystem); + + if (terrain) { + terrain.absorbDecalsNear(bestPlant.x, bestPlant.y, 25); + } + + if (particleSystem) { + particleSystem.emitEatingCrumbs( + bestPlant.x, + bestPlant.y, + bestPlant.type === 'flower' ? bestPlant.flowerColor : '#48C738', + 5 + ); + } + } else { + this.steerTowards(bestPlant.x, bestPlant.y, config.sheepMaxSpeed || 1.8); + + // Maintain flock spacing during grazing approach + for (let i = 0; i < allSheep.length; i++) { + const other = allSheep[i]; + if (other === this || other.isDead) continue; + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minFlockDist = 26 * this.growthScale; + if (dist < minFlockDist && dist > 0.05) { + const force = ((minFlockDist - dist) / minFlockDist) * 0.35; + this.vx -= (dx / dist) * force; + this.vy -= (dy / dist) * force; + } + } + } + return; + } + } + + // 4. Default: Casual Wandering + this.actionState = 'wander'; + this.wander(1.1); + } + + draw(ctx) { + if (this.isDead) return; + + ctx.save(); + const scale = this.growthScale; + const px = Math.round(this.x); + const py = Math.round(this.y - this.bobOffset); + + // Direction flip + const facingLeft = Math.cos(this.heading) < 0; + + // Body dimensions + const bodyW = Math.round(22 * scale); + const bodyH = Math.round(16 * scale); + const halfW = Math.round(bodyW / 2); + const halfH = Math.round(bodyH / 2); + + // 1. Hooves (4 little black pixel feet bobbing) + ctx.fillStyle = '#222224'; + const legW = Math.max(2, Math.round(3 * scale)); + const legH = Math.max(3, Math.round(5 * scale)); + const legBob1 = Math.round(Math.sin(this.walkCycle) * 2); + const legBob2 = Math.round(Math.sin(this.walkCycle + Math.PI) * 2); + + // Front/Back legs + ctx.fillRect(px - halfW + 3, py + halfH - 2 + legBob1, legW, legH); + ctx.fillRect(px - halfW + 8, py + halfH - 2 + legBob2, legW, legH); + ctx.fillRect(px + halfW - 9, py + halfH - 2 + legBob2, legW, legH); + ctx.fillRect(px + halfW - 4, py + halfH - 2 + legBob1, legW, legH); + + // 2. Fluffy Wool Body (Squarish pixel block with wool puff corners) + ctx.fillStyle = '#E8ECEF'; // main wool + ctx.fillRect(px - halfW, py - halfH, bodyW, bodyH); + + // Wool shadow/shading underneath + ctx.fillStyle = '#CBD2D8'; + ctx.fillRect(px - halfW, py + halfH - Math.round(4 * scale), bodyW, Math.round(4 * scale)); + + // Wool puff details + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(px - halfW + 2, py - halfH + 2, Math.round(6 * scale), Math.round(4 * scale)); + ctx.fillRect(px + 2, py - halfH + 2, Math.round(5 * scale), Math.round(3 * scale)); + + // 3. Head & Ears + const headW = Math.round(8 * scale); + const headH = Math.round(9 * scale); + const headX = facingLeft ? px - halfW - headW + 2 : px + halfW - 2; + const headY = py - halfH + 1; + + ctx.fillStyle = '#2B2B2E'; // black sheep face + ctx.fillRect(headX, headY, headW, headH); + + // Ears twitch + const earX = facingLeft ? headX + headW - 2 : headX; + ctx.fillStyle = '#1A1A1C'; + ctx.fillRect(earX, headY - Math.round(2 * scale), Math.round(3 * scale), Math.round(3 * scale)); + + // Eye (little white pixel + pupil) + const eyeX = facingLeft ? headX + 2 : headX + headW - 3; + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(eyeX, headY + 2, Math.max(1, Math.round(2 * scale)), Math.max(1, Math.round(2 * scale))); + + // 4. Status Indicator (Fleeing panic / Mating heart / Baby icon) + if (this.actionState === 'flee') { + ctx.fillStyle = '#FF4444'; + ctx.font = 'bold 10px monospace'; + ctx.fillText('!', px - 2, py - halfH - 4); + } else if (this.actionState === 'mating') { + ctx.fillStyle = '#FF5588'; + ctx.font = '8px monospace'; + ctx.fillText('♥', px - 3, py - halfH - 4); + } + + ctx.restore(); + } +} diff --git a/js/entities/wolf.js b/js/entities/wolf.js new file mode 100644 index 0000000..c1b5390 --- /dev/null +++ b/js/entities/wolf.js @@ -0,0 +1,307 @@ +/** + * Wolf Entity + * Stalks and hunts sheep via multi-bite combat, triggers blood splatter decals, + * feeds on prey, mates when satiated, and nurtures growing wolf pups. + */ + +import { Animal } from './animal.js'; + +export class Wolf extends Animal { + constructor(x, y, isBaby = false) { + super(x, y, 'wolf', isBaby); + this.attackCooldownTimer = 0; + } + + canMate(threshold = 86) { + return !this.isBaby && this.hunger >= threshold && this.mateCooldown <= 0; + } + + update(dt, worldWidth, worldHeight, config, sheepList, allWolves, terrain, particleSystem, onBabyBorn, onSheepKilled) { + if (this.isDead) return; + + // Deplete hunger over time (gentler for growing pups) + const baseDeplete = (config.wolfHungerDepletionRate || 1.3) * (this.isBaby ? 0.8 : 1.0); + const isHunting = this.actionState === 'hunt'; + this.hunger -= (isHunting ? baseDeplete * 1.4 : baseDeplete) * dt; + + if (this.attackCooldownTimer > 0) { + this.attackCooldownTimer -= dt; + } + + // Base update (boundaries, health tick if starving, walk cycle) + super.updateBase(dt, worldWidth, worldHeight, config); + if (this.isDead) return; + + // Pack Separation: maintain personal space so wolves don't stack on top of each other + for (let i = 0; i < allWolves.length; i++) { + const other = allWolves[i]; + if (other === this || other.isDead) continue; + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minPackDist = 32 * this.growthScale; + if (dist < minPackDist && dist > 0.05) { + const force = ((minPackDist - dist) / minPackDist) * 0.35; + this.vx -= (dx / dist) * force; + this.vy -= (dy / dist) * force; + } + } + + // 1. Mating Behavior: + // Requires high satiety (wolfMatingThreshold) and long cooldown + const mateThreshold = config.wolfMatingThreshold || 86; + const mateCooldown = config.wolfMatingCooldown || 35; + + if (this.canMate(mateThreshold)) { + let nearestMate = null; + let minMateDist = 220; + + for (let i = 0; i < allWolves.length; i++) { + const w = allWolves[i]; + if (w === this || w.isDead || !w.canMate(mateThreshold)) continue; + const dist = Math.hypot(w.x - this.x, w.y - this.y); + if (dist < minMateDist) { + minMateDist = dist; + nearestMate = w; + } + } + + if (nearestMate) { + this.actionState = 'mating'; + const approachSpeed = config.wolfMaxSpeed || 2.2; + this.steerTowards(nearestMate.x, nearestMate.y, approachSpeed); + + const collisionThreshold = (18 * this.growthScale) + (18 * nearestMate.growthScale); + if (minMateDist <= collisionThreshold) { + this.consumeMatingEnergy(mateCooldown); + nearestMate.consumeMatingEnergy(mateCooldown); + + if (particleSystem) { + particleSystem.emitHeart(this.x, this.y); + particleSystem.emitHeart(nearestMate.x, nearestMate.y); + } + + if (onBabyBorn) { + const babyX = (this.x + nearestMate.x) / 2 + (Math.random() - 0.5) * 12; + const babyY = (this.y + nearestMate.y) / 2 + (Math.random() - 0.5) * 12; + onBabyBorn(babyX, babyY, 'wolf'); + } + } + return; + } + } + + // 2. Hunting & Scavenging Behavior: + if (this.hunger < 92 || this.isBaby) { + // 2a. Scavenging: Baby wolves can feast on fresh sheep carcasses left by the pack + if (this.isBaby && terrain && terrain.carcasses.length > 0 && this.hunger < 85) { + let nearestCarcass = null; + let minCarcassDist = 200; + for (let i = 0; i < terrain.carcasses.length; i++) { + const c = terrain.carcasses[i]; + if (c.species === 'sheep' && c.decayProgress < 0.75) { + const d = Math.hypot(c.x - this.x, c.y - this.y); + if (d < minCarcassDist) { + minCarcassDist = d; + nearestCarcass = c; + } + } + } + + if (nearestCarcass) { + this.actionState = 'hunt'; + if (minCarcassDist <= 24) { + // Feast on carcass + this.recordMeal(35, particleSystem); + nearestCarcass.decayProgress = Math.min(1.0, nearestCarcass.decayProgress + 0.18); + if (particleSystem) { + particleSystem.emitEatingCrumbs(nearestCarcass.x, nearestCarcass.y, '#8A1010', 4); + } + return; + } else { + this.steerTowards(nearestCarcass.x, nearestCarcass.y, 2.6, 0.2); + return; + } + } + } + + // 2b. Hunt Live Sheep: + let targetSheep = null; + let minSheepDist = config.wolfPerceptionRadius || 260; + + for (let i = 0; i < sheepList.length; i++) { + const s = sheepList[i]; + if (s.isDead) continue; + const dist = Math.hypot(s.x - this.x, s.y - this.y); + // Baby wolves prioritize baby sheep or nearby targets + const scoreDist = (this.isBaby && s.isBaby) ? dist * 0.6 : dist; + if (scoreDist < minSheepDist) { + minSheepDist = scoreDist; + targetSheep = s; + } + } + + if (targetSheep) { + this.actionState = 'hunt'; + const rawDist = Math.hypot(targetSheep.x - this.x, targetSheep.y - this.y); + + // Fast, agile chase speed (4.1+ vs sheep's 2.8) so wolves reliably catch targets without agonizing long chases + const chaseSpeed = (config.wolfChaseSpeed || 4.1) * (this.isBaby ? 1.05 : 1.0); + const turnRate = this.isBaby ? 0.25 : 0.22; + + // Attack reach ensures wolf strikes cleanly before collision bounce + const attackDist = Math.max(30, 34 * this.growthScale); + + if (rawDist <= attackDist) { + // Attack range! Multi-bite combat + if (this.attackCooldownTimer <= 0) { + this.attackCooldownTimer = config.wolfAttackCooldown || 0.65; + + // Wolves expend health on attack lunge (struggle/exertion) - reduced for babies + const baseLunge = config.wolfLungeCost !== undefined ? config.wolfLungeCost : 7; + const lungeCost = this.isBaby ? baseLunge * 0.35 : baseLunge; + this.health -= lungeCost; + if (this.health <= 0) { + const biteDamage = (config.wolfAttackDamage || 38) * (this.isBaby ? 0.75 : 1.0); + if (targetSheep.health <= biteDamage) { + // Clutch kill! Spent last burst of energy to take down prey and survive + this.health = 18; + } else { + this.health = 0; + this.die('exertion'); + return; + } + } + + const biteDamage = (config.wolfAttackDamage || 38) * (this.isBaby ? 0.75 : 1.0); + const biteAngle = Math.atan2(targetSheep.y - this.y, targetSheep.x - this.x); + const sheepDied = targetSheep.takeDamage(biteDamage, biteAngle, particleSystem); + + // Stamp blood on the ground where attack occurred + if (terrain) { + terrain.addBloodSplotch(targetSheep.x, targetSheep.y, 4 + Math.random() * 3); + } + + if (sheepDied) { + // Target killed! Wolf feasts + this.recordMeal(42, particleSystem); + if (onSheepKilled) onSheepKilled(targetSheep, this); + } else { + // Taste on bite strike (sustains pursuit and rewards successful strikes) + this.recordMeal(this.isBaby ? 16 : 10, particleSystem); + } + } + } else { + // Sprint towards sheep with high agility + this.steerTowards(targetSheep.x, targetSheep.y, chaseSpeed, turnRate); + + // Pack dispersion during chase: wolves flank around target rather than stacking in single file + for (let i = 0; i < allWolves.length; i++) { + const other = allWolves[i]; + if (other === this || other.isDead) continue; + const dx = other.x - this.x; + const dy = other.y - this.y; + const dist = Math.hypot(dx, dy); + const minPackDist = 34 * this.growthScale; + if (dist < minPackDist && dist > 0.05) { + const force = ((minPackDist - dist) / minPackDist) * 0.4; + this.vx -= (dx / dist) * force; + this.vy -= (dy / dist) * force; + } + } + } + return; + } + } + + // 3. Default: Stalking / Wandering + this.actionState = 'wander'; + this.wander(1.3); + } + + draw(ctx) { + if (this.isDead) return; + + ctx.save(); + const scale = this.growthScale; + const px = Math.round(this.x); + const py = Math.round(this.y - this.bobOffset); + + // Direction flip + const facingLeft = Math.cos(this.heading) < 0; + + // Dimensions + const bodyW = Math.round(26 * scale); + const bodyH = Math.round(14 * scale); + const halfW = Math.round(bodyW / 2); + const halfH = Math.round(bodyH / 2); + + // 1. Paws / Legs (4 sleek grey legs bobbing) + ctx.fillStyle = '#26292C'; + const legW = Math.max(2, Math.round(3 * scale)); + const legH = Math.max(4, Math.round(6 * scale)); + const legBob1 = Math.round(Math.sin(this.walkCycle) * 2.5); + const legBob2 = Math.round(Math.sin(this.walkCycle + Math.PI) * 2.5); + + ctx.fillRect(px - halfW + 3, py + halfH - 2 + legBob1, legW, legH); + ctx.fillRect(px - halfW + 9, py + halfH - 2 + legBob2, legW, legH); + ctx.fillRect(px + halfW - 10, py + halfH - 2 + legBob2, legW, legH); + ctx.fillRect(px + halfW - 4, py + halfH - 2 + legBob1, legW, legH); + + // 2. Tail + const tailX = facingLeft ? px + halfW - 2 : px - halfW - Math.round(6 * scale) + 2; + const tailBob = Math.round(Math.sin(this.walkCycle * 1.5) * 2); + ctx.fillStyle = '#3F4448'; + ctx.fillRect(tailX, py - Math.round(2 * scale) + tailBob, Math.round(6 * scale), Math.round(3 * scale)); + + // 3. Torso + ctx.fillStyle = '#44494E'; // slate grey coat + ctx.fillRect(px - halfW, py - halfH, bodyW, bodyH); + + // Belly & Back shading + ctx.fillStyle = '#2C3033'; // underbelly shadow + ctx.fillRect(px - halfW, py + halfH - Math.round(3 * scale), bodyW, Math.round(3 * scale)); + ctx.fillStyle = '#5A6066'; // back highlight + ctx.fillRect(px - halfW + 2, py - halfH, bodyW - 4, Math.round(2 * scale)); + + // 4. Head, Muzzle & Pointed Ears + const headW = Math.round(10 * scale); + const headH = Math.round(9 * scale); + const headX = facingLeft ? px - halfW - headW + 2 : px + halfW - 2; + const headY = py - halfH - 1; + + // Head base + ctx.fillStyle = '#3A3F43'; + ctx.fillRect(headX, headY, headW, headH); + + // Snout / Muzzle + const snoutW = Math.round(5 * scale); + const snoutH = Math.round(4 * scale); + const snoutX = facingLeft ? headX - snoutW : headX + headW; + ctx.fillStyle = '#2B2F32'; + ctx.fillRect(snoutX, headY + headH - snoutH - 1, snoutW, snoutH); + + // Pointed Ears + const earX = facingLeft ? headX + headW - Math.round(3 * scale) : headX; + ctx.fillStyle = '#232629'; + ctx.fillRect(earX, headY - Math.round(4 * scale), Math.round(3 * scale), Math.round(4 * scale)); + + // Predator Eye (Glowing Amber/Red) + const eyeX = facingLeft ? headX + 3 : headX + headW - 4; + ctx.fillStyle = this.actionState === 'hunt' ? '#FF2200' : '#FFB703'; + ctx.fillRect(eyeX, headY + 2, Math.max(1, Math.round(2 * scale)), Math.max(1, Math.round(2 * scale))); + + // 5. Status Indicator + if (this.actionState === 'hunt') { + ctx.fillStyle = '#FF2222'; + ctx.font = 'bold 9px monospace'; + ctx.fillText('⚡', px - 3, py - halfH - 5); + } else if (this.actionState === 'mating') { + ctx.fillStyle = '#FF5588'; + ctx.font = '8px monospace'; + ctx.fillText('♥', px - 3, py - halfH - 5); + } + + ctx.restore(); + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..0233e2a --- /dev/null +++ b/js/main.js @@ -0,0 +1,68 @@ +/** + * Main Application Entry Point + * Orchestrates canvas setup, simulation loop, frame timing, and UI binding. + */ + +import { configManager } from './config.js'; +import { Simulation } from './simulation.js'; +import { UIManager } from './ui.js'; + +window.addEventListener('DOMContentLoaded', () => { + const canvas = document.getElementById('simCanvas'); + const container = canvas.parentElement; + + // Determine initial container dimensions (100% of open play area) + const initialWidth = Math.max(400, Math.floor(container.clientWidth || window.innerWidth - 330)); + const initialHeight = Math.max(300, Math.floor(container.clientHeight || window.innerHeight - 60)); + + canvas.width = initialWidth; + canvas.height = initialHeight; + + // Initialize Simulation and UI + const sim = new Simulation(canvas, configManager.current); + const ui = new UIManager(sim, configManager); + + // Dynamic screen resize synchronization + function syncCanvasSize() { + const w = Math.max(400, Math.floor(container.clientWidth)); + const h = Math.max(300, Math.floor(container.clientHeight)); + if (w > 0 && h > 0 && (canvas.width !== w || canvas.height !== h)) { + canvas.width = w; + canvas.height = h; + sim.resize(w, h); + } + } + + window.addEventListener('resize', syncCanvasSize); + if (window.ResizeObserver) { + const ro = new ResizeObserver(() => syncCanvasSize()); + ro.observe(container); + } + + // Wire simulation callbacks + sim.onStatsChange = (stats, sheep, wolves, plants) => { + ui.updateHUD(stats, sheep, wolves, plants); + }; + + sim.onExtinction = (stats) => { + ui.showExtinctionModal(stats); + }; + + // Main Animation Loop + let lastTime = performance.now(); + + function loop(currentTime) { + const rawDt = (currentTime - lastTime) / 1000.0; + lastTime = currentTime; + + // Advance simulation step + sim.update(rawDt); + + // Render frame + sim.draw(); + + requestAnimationFrame(loop); + } + + requestAnimationFrame(loop); +}); diff --git a/js/particles.js b/js/particles.js new file mode 100644 index 0000000..4fd0b84 --- /dev/null +++ b/js/particles.js @@ -0,0 +1,253 @@ +/** + * Particle Effects System + * Manages blood spray, eating crumbs, mating hearts, floating dandelion seeds, and splash ripples. + */ + +export class ParticleSystem { + constructor() { + this.particles = []; + } + + reset() { + this.particles = []; + } + + update(dt, terrain) { + for (let i = this.particles.length - 1; i >= 0; i--) { + const p = this.particles[i]; + p.life -= dt; + + if (p.life <= 0) { + // If blood particle landed, paint small splatter on terrain + if (p.type === 'blood' && p.paintOnDeath && terrain) { + terrain.addBloodSplotch(p.x, p.y, p.radius || 2.5); + } + // If seed particle landed, signal germination callback + if (p.type === 'seed' && p.onLand) { + p.onLand(p.x, p.y); + } + this.particles.splice(i, 1); + continue; + } + + // Physics integration + p.x += p.vx * dt; + p.y += p.vy * dt; + + // Apply drag / gravity based on particle type + if (p.drag) { + p.vx *= Math.pow(p.drag, dt * 60); + p.vy *= Math.pow(p.drag, dt * 60); + } + if (p.gravity) { + p.vy += p.gravity * dt; + } + + // Custom motion + if (p.type === 'seed') { + p.sway += dt * 4; + p.x += Math.sin(p.sway) * 12 * dt; + } else if (p.type === 'heart') { + p.sway += dt * 3; + p.x += Math.sin(p.sway) * 8 * dt; + } + } + } + + draw(ctx) { + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + const alpha = Math.max(0, Math.min(1, p.life / p.maxLife)); + ctx.save(); + ctx.globalAlpha = alpha; + + switch (p.type) { + case 'blood': { + ctx.fillStyle = p.color || '#C91818'; + const size = Math.max(1, Math.round(p.size)); + ctx.fillRect(Math.round(p.x - size / 2), Math.round(p.y - size / 2), size, size); + break; + } + + case 'crumb': { + ctx.fillStyle = p.color || '#57B547'; + const size = Math.max(1, Math.round(p.size)); + ctx.fillRect(Math.round(p.x - size / 2), Math.round(p.y - size / 2), size, size); + break; + } + + case 'heart': { + // Pixelated little heart + ctx.fillStyle = '#FF3366'; + const hx = Math.round(p.x); + const hy = Math.round(p.y); + const s = p.size || 2; + // Row 0 + ctx.fillRect(hx - s * 2, hy - s, s, s); + ctx.fillRect(hx - s, hy - s * 2, s, s); + ctx.fillRect(hx + s, hy - s * 2, s, s); + ctx.fillRect(hx + s * 2, hy - s, s, s); + // Row 1 + ctx.fillRect(hx - s * 2, hy, s * 5, s); + // Row 2 + ctx.fillRect(hx - s, hy + s, s * 3, s); + // Row 3 + ctx.fillRect(hx, hy + s * 2, s, s); + break; + } + + case 'seed': { + // Dandelion seed puff + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(Math.round(p.x), Math.round(p.y), 3, 3); + ctx.fillStyle = '#C2B280'; + ctx.fillRect(Math.round(p.x + 1), Math.round(p.y + 3), 1, 3); + break; + } + + case 'sparkle': { + ctx.fillStyle = p.color || '#FFD700'; + const s = p.size || 3; + ctx.fillRect(Math.round(p.x - s / 2), Math.round(p.y), s, 1); + ctx.fillRect(Math.round(p.x), Math.round(p.y - s / 2), 1, s); + break; + } + + case 'splash': { + // Expanding ripple + const progress = 1 - (p.life / p.maxLife); + ctx.strokeStyle = '#8ED6FF'; + ctx.lineWidth = 1; + ctx.strokeRect( + Math.round(p.x - p.radius * progress), + Math.round(p.y - p.radius * progress * 0.5), + Math.round(p.radius * progress * 2), + Math.round(p.radius * progress) + ); + break; + } + } + + ctx.restore(); + } + } + + // --- Emitter Methods --- + + emitBloodSpray(x, y, angle, intensity = 14) { + const baseColors = ['#D41414', '#A80808', '#850000', '#FF3636']; + for (let i = 0; i < intensity; i++) { + const spread = (Math.random() - 0.5) * 1.2; + const speed = 70 + Math.random() * 140; + const particleAngle = angle + spread; + const life = 0.35 + Math.random() * 0.45; + this.particles.push({ + type: 'blood', + x, + y, + vx: Math.cos(particleAngle) * speed, + vy: Math.sin(particleAngle) * speed, + drag: 0.88, + gravity: 120, + size: 2 + Math.floor(Math.random() * 3), + color: baseColors[Math.floor(Math.random() * baseColors.length)], + life, + maxLife: life, + paintOnDeath: Math.random() < 0.75, + radius: 3 + Math.random() * 4, + }); + } + } + + emitEatingCrumbs(x, y, color = '#5CB85C', count = 5) { + for (let i = 0; i < count; i++) { + const speed = 20 + Math.random() * 40; + const angle = Math.random() * Math.PI * 2; + const life = 0.3 + Math.random() * 0.3; + this.particles.push({ + type: 'crumb', + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed - 15, + drag: 0.85, + gravity: 80, + size: 2, + color, + life, + maxLife: life, + }); + } + } + + emitHeart(x, y) { + const life = 1.2; + this.particles.push({ + type: 'heart', + x, + y: y - 8, + vx: (Math.random() - 0.5) * 15, + vy: -25 - Math.random() * 20, + sway: Math.random() * Math.PI, + drag: 0.96, + size: 2, + life, + maxLife: life, + }); + } + + emitSeed(x, y, targetX, targetY, onLand) { + const dx = targetX - x; + const dy = targetY - y; + const dist = Math.hypot(dx, dy); + const speed = 35 + Math.random() * 25; + const life = Math.max(1.2, dist / speed); + + this.particles.push({ + type: 'seed', + x, + y, + vx: (dx / dist) * speed, + vy: (dy / dist) * speed, + sway: Math.random() * 6, + life, + maxLife: life, + onLand, + }); + } + + emitGrowthSparkles(x, y, count = 10) { + const colors = ['#FFDF00', '#FFFA8A', '#00FFFF', '#FF99EE']; + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = 30 + Math.random() * 50; + const life = 0.5 + Math.random() * 0.4; + this.particles.push({ + type: 'sparkle', + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + drag: 0.9, + size: 3 + Math.random() * 3, + color: colors[Math.floor(Math.random() * colors.length)], + life, + maxLife: life, + }); + } + } + + emitSplash(x, y) { + const life = 0.25; + this.particles.push({ + type: 'splash', + x, + y, + vx: 0, + vy: 0, + radius: 4 + Math.random() * 4, + life, + maxLife: life, + }); + } +} diff --git a/js/simulation.js b/js/simulation.js new file mode 100644 index 0000000..f7964dc --- /dev/null +++ b/js/simulation.js @@ -0,0 +1,620 @@ +/** + * Core Simulation Engine + * Coordinates world state, entity lifecycles, predation, flora germination, + * carcass decay callbacks, weather, and statistics recording. + */ + +import { Sheep } from './entities/sheep.js'; +import { Wolf } from './entities/wolf.js'; +import { Plant } from './entities/plant.js'; +import { TerrainManager } from './terrain.js'; +import { WeatherManager } from './weather.js'; +import { ParticleSystem } from './particles.js'; +import { SimStats } from './state.js'; + +export class Simulation { + constructor(canvas, config) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.config = config; + + this.width = canvas.width; + this.height = canvas.height; + + this.isRunning = false; + this.speedMultiplier = 1; + + // Subsystems + this.terrain = new TerrainManager(this.width, this.height); + this.weather = new WeatherManager(this.width, this.height); + this.particles = new ParticleSystem(); + this.stats = new SimStats(); + + // Entity lists + this.sheep = []; + this.wolves = []; + this.plants = []; + + // Interaction mode: 'inspect', 'sheep', 'wolf', 'plant', 'flower' + this.activeTool = 'inspect'; + this.selectedEntity = null; + + // Callbacks + this.onExtinction = null; + this.onStatsChange = null; + + this.init(); + } + + isPlantLocationOccupied(x, y, minDistance = 24) { + for (let i = 0; i < this.plants.length; i++) { + const p = this.plants[i]; + if (p.isDead) continue; + if (Math.hypot(p.x - x, p.y - y) < minDistance) { + return true; + } + } + return false; + } + + findClearPlantLocation(preferredX, preferredY, minDistance = 24, maxSearchRadius = 65) { + // If target spot is already clear, use it directly! + if (!this.isPlantLocationOccupied(preferredX, preferredY, minDistance)) { + return { x: preferredX, y: preferredY }; + } + + // Try expanding rings and multiple angles to find an unoccupied spot nearby + for (let r = minDistance; r <= maxSearchRadius; r += 7) { + for (let angle = 0; angle < Math.PI * 2; angle += Math.PI / 5) { + const testX = Math.max(25, Math.min(this.width - 25, preferredX + Math.cos(angle) * r)); + const testY = Math.max(25, Math.min(this.height - 25, preferredY + Math.sin(angle) * r)); + if (!this.isPlantLocationOccupied(testX, testY, minDistance)) { + return { x: testX, y: testY }; + } + } + } + + return null; // Local area is saturated, avoid stacking + } + + init() { + this.sheep = []; + this.wolves = []; + this.plants = []; + this.terrain.reset(); + this.weather.reset(); + this.particles.reset(); + this.stats.reset(); + this.continuousSheepTimer = 0; + this.continuousWolfTimer = 0; + + // 1. Spawn Initial Plants (spaced out with no overlaps) + const plantCount = Math.max(5, this.config.initialPlants || 35); + for (let i = 0; i < plantCount; i++) { + let attempts = 0; + let loc = null; + while (!loc && attempts < 35) { + const x = 50 + Math.random() * (this.width - 100); + const y = 50 + Math.random() * (this.height - 100); + loc = this.findClearPlantLocation(x, y, 26, 30); + attempts++; + } + + if (loc) { + const type = Math.random() < 0.25 ? 'flower' : 'grass'; + const plant = new Plant(loc.x, loc.y, type, 0.4 + Math.random() * 0.6, this.width, this.height); + this.plants.push(plant); + this.stats.recordPlantSpawn(false, false); + } + } + + // 2. Spawn Initial Sheep + const sheepCount = Math.max(2, this.config.initialSheep || 20); + for (let i = 0; i < sheepCount; i++) { + const x = 70 + Math.random() * (this.width - 140); + const y = 70 + Math.random() * (this.height - 140); + const sheep = new Sheep(x, y, false); + this.sheep.push(sheep); + this.stats.recordSheepBirth(true); + } + + // 3. Spawn Initial Wolves + const wolfCount = Math.max(1, this.config.initialWolves || 4); + for (let i = 0; i < wolfCount; i++) { + const x = 70 + Math.random() * (this.width - 140); + const y = 70 + Math.random() * (this.height - 140); + const wolf = new Wolf(x, y, false); + this.wolves.push(wolf); + this.stats.recordWolfBirth(true); + } + } + + restart(newConfig = null) { + if (newConfig) { + this.config = { ...this.config, ...newConfig }; + } + this.init(); + this.isRunning = true; + } + + resize(newWidth, newHeight) { + if (newWidth <= 0 || newHeight <= 0) return; + this.width = newWidth; + this.height = newHeight; + + if (this.terrain) { + this.terrain.resize(newWidth, newHeight); + } + if (this.weather) { + this.weather.resize(newWidth, newHeight); + } + + // Keep all animals and plants safely within updated bounds + const margin = 26; + for (let i = 0; i < this.sheep.length; i++) { + const s = this.sheep[i]; + s.x = Math.max(margin, Math.min(newWidth - margin, s.x)); + s.y = Math.max(margin, Math.min(newHeight - margin, s.y)); + } + for (let i = 0; i < this.wolves.length; i++) { + const w = this.wolves[i]; + w.x = Math.max(margin, Math.min(newWidth - margin, w.x)); + w.y = Math.max(margin, Math.min(newHeight - margin, w.y)); + } + for (let i = 0; i < this.plants.length; i++) { + const p = this.plants[i]; + p.x = Math.max(margin, Math.min(newWidth - margin, p.x)); + p.y = Math.max(margin, Math.min(newHeight - margin, p.y)); + } + } + + spawnEntity(type, x, y) { + x = Math.max(25, Math.min(this.width - 25, x)); + y = Math.max(25, Math.min(this.height - 25, y)); + + switch (type) { + case 'sheep': { + const sheep = new Sheep(x, y, false); + this.sheep.push(sheep); + this.stats.recordSheepBirth(false); + this.particles.emitGrowthSparkles(x, y, 8); + break; + } + case 'wolf': { + const wolf = new Wolf(x, y, false); + this.wolves.push(wolf); + this.stats.recordWolfBirth(false); + this.particles.emitGrowthSparkles(x, y, 8); + break; + } + case 'plant': { + const loc = this.findClearPlantLocation(x, y, 24); + if (loc) { + const plant = new Plant(loc.x, loc.y, 'grass', 0.8, this.width, this.height); + this.plants.push(plant); + this.stats.recordPlantSpawn(false, false); + this.particles.emitEatingCrumbs(loc.x, loc.y, '#4AD638', 6); + } + break; + } + case 'flower': { + const loc = this.findClearPlantLocation(x, y, 24); + if (loc) { + const flower = new Plant(loc.x, loc.y, 'flower', 0.9, this.width, this.height); + this.plants.push(flower); + this.stats.recordPlantSpawn(false, false); + this.particles.emitEatingCrumbs(loc.x, loc.y, flower.flowerColor, 8); + } + break; + } + } + } + + spawnMigrantEntity(species, forcedEdge = -1) { + const edge = forcedEdge >= 0 ? forcedEdge : Math.floor(Math.random() * 4); + let x, y, angle; + const speed = species === 'wolf' ? 1.7 : 1.4; + + switch (edge) { + case 0: // Top edge entering down + x = 60 + Math.random() * (this.width - 120); + y = 26; + angle = Math.PI * 0.5 + (Math.random() - 0.5) * 0.5; + break; + case 1: // Bottom edge entering up + x = 60 + Math.random() * (this.width - 120); + y = this.height - 26; + angle = -Math.PI * 0.5 + (Math.random() - 0.5) * 0.5; + break; + case 2: // Left edge entering right + x = 26; + y = 60 + Math.random() * (this.height - 120); + angle = (Math.random() - 0.5) * 0.5; + break; + case 3: // Right edge entering left + default: + x = this.width - 26; + y = 60 + Math.random() * (this.height - 120); + angle = Math.PI + (Math.random() - 0.5) * 0.5; + break; + } + + if (species === 'sheep') { + const sheep = new Sheep(x, y, false); + sheep.vx = Math.cos(angle) * speed; + sheep.vy = Math.sin(angle) * speed; + sheep.wanderAngle = angle; + this.sheep.push(sheep); + this.stats.recordSheepBirth(false); + this.particles.emitGrowthSparkles(x, y, 6); + } else { + const wolf = new Wolf(x, y, false); + wolf.vx = Math.cos(angle) * speed; + wolf.vy = Math.sin(angle) * speed; + wolf.wanderAngle = angle; + this.wolves.push(wolf); + this.stats.recordWolfBirth(false); + this.particles.emitGrowthSparkles(x, y, 6); + } + } + + update(rawDt) { + if (!this.isRunning) return; + + // Cap delta time to prevent physics explosions on background tab + const dt = Math.min(0.1, rawDt) * this.speedMultiplier; + + // 1. Weather Update (applies rain to revitalize plants) + this.weather.update(dt, this.config.rainFrequency, this.plants, this.particles); + + // 1b. Continuous Mode Migration Waves (Screen Saver / Endless Ecosystem) + if (this.config.continuousMode) { + this.continuousSheepTimer = (this.continuousSheepTimer || 0) + dt; + this.continuousWolfTimer = (this.continuousWolfTimer || 0) + dt; + + const targetSheepInterval = Math.max(3, this.config.continuousSheepInterval || 14); + const targetWolfInterval = Math.max(6, this.config.continuousWolfInterval || 32); + const maxGroup = Math.max(1, this.config.continuousMaxGroupSize || 3); + + if (this.continuousSheepTimer >= targetSheepInterval) { + this.continuousSheepTimer = (Math.random() - 0.5) * (targetSheepInterval * 0.25); + const groupCount = 1 + Math.floor(Math.random() * maxGroup); + const edge = Math.floor(Math.random() * 4); + for (let g = 0; g < groupCount; g++) { + this.spawnMigrantEntity('sheep', edge); + } + } + + if (this.continuousWolfTimer >= targetWolfInterval) { + this.continuousWolfTimer = (Math.random() - 0.5) * (targetWolfInterval * 0.25); + const groupCount = 1 + Math.floor(Math.random() * Math.min(2, maxGroup)); + const edge = Math.floor(Math.random() * 4); + for (let g = 0; g < groupCount; g++) { + this.spawnMigrantEntity('wolf', edge); + } + } + } + + // 2. Terrain & Carcass Decomposition Update + const maxPlantCap = this.config.maxPlantCap || 90; + + this.terrain.update( + dt, + this.config.decaySpeed, + this.weather.rainIntensity, + (decayX, decayY) => { + // Enriched soil bloom if under cap + if (this.plants.length < maxPlantCap) { + const fx = Math.max(25, Math.min(this.width - 25, decayX)); + const fy = Math.max(25, Math.min(this.height - 25, decayY)); + const loc = this.findClearPlantLocation(fx, fy, 24, 70); + if (loc) { + const flower = new Plant(loc.x, loc.y, 'flower', 0.35, this.width, this.height); + this.plants.push(flower); + // As flower grows from carcass, it absorbs the decay and blood splats! + this.terrain.absorbDecalsNear(loc.x, loc.y, 35); + this.stats.recordPlantSpawn(true, false); + this.particles.emitEatingCrumbs(loc.x, loc.y, flower.flowerColor, 6); + } + } + }, + (bloodX, bloodY) => { + // Blood spot discoloration sprout if under cap + if (this.plants.length < maxPlantCap) { + const bx = Math.max(25, Math.min(this.width - 25, bloodX)); + const by = Math.max(25, Math.min(this.height - 25, bloodY)); + const loc = this.findClearPlantLocation(bx, by, 24, 70); + if (loc) { + const sprout = new Plant(loc.x, loc.y, 'flower', 0.25, this.width, this.height); + this.plants.push(sprout); + // Absorb blood splat into the new plant! + this.terrain.absorbDecalsNear(loc.x, loc.y, 32); + this.stats.recordPlantSpawn(true, false); + } + } + } + ); + + // 3. Plants Update + for (let i = this.plants.length - 1; i >= 0; i--) { + const p = this.plants[i]; + p.update( + dt, + this.plants, + (seedFromX, seedFromY, targetX, targetY) => { + if (this.plants.length < maxPlantCap) { + this.particles.emitSeed(seedFromX, seedFromY, targetX, targetY, (landX, landY) => { + if (this.plants.length < maxPlantCap) { + const loc = this.findClearPlantLocation(landX, landY, 24, 65); + if (loc) { + const newPlant = new Plant(loc.x, loc.y, Math.random() < 0.4 ? 'flower' : 'grass', 0.2, this.width, this.height); + this.plants.push(newPlant); + // Cleanse soil where seed germinates + this.terrain.absorbDecalsNear(loc.x, loc.y, 25); + this.stats.recordPlantSpawn(false, true); + } + } + }); + } + }, + (witheredPlant) => { + this.stats.recordPlantWithered(); + }, + this.width, + this.height, + this.config, + this.terrain + ); + + if (p.isDead) { + this.plants.splice(i, 1); + } + } + + // 3b. Meadow Rejuvenation & Rain Germination + // Prevents desolate wasteland: Rain rolls in and germinates fresh grass seedlings in open soil + if (this.weather.rainIntensity > 0.25) { + this.rainSproutTimer = (this.rainSproutTimer || 0) + dt; + if (this.rainSproutTimer >= 1.3 && this.plants.length < maxPlantCap) { + this.rainSproutTimer = 0; + const rx = 35 + Math.random() * (this.width - 70); + const ry = 35 + Math.random() * (this.height - 70); + const loc = this.findClearPlantLocation(rx, ry, 24, 80); + if (loc) { + const freshGrass = new Plant(loc.x, loc.y, 'grass', 0.35, this.width, this.height); + this.plants.push(freshGrass); + this.terrain.absorbDecalsNear(loc.x, loc.y, 25); + this.stats.recordPlantSpawn(false, false); + } + } + } else if (this.plants.length < 16) { + // Gentle meadow baseline so sheep always have a living ecosystem + this.soilSproutTimer = (this.soilSproutTimer || 0) + dt; + if (this.soilSproutTimer >= 3.8 && this.plants.length < maxPlantCap) { + this.soilSproutTimer = 0; + const rx = 40 + Math.random() * (this.width - 80); + const ry = 40 + Math.random() * (this.height - 80); + const loc = this.findClearPlantLocation(rx, ry, 24, 80); + if (loc) { + const freshGrass = new Plant(loc.x, loc.y, 'grass', 0.3, this.width, this.height); + this.plants.push(freshGrass); + this.stats.recordPlantSpawn(false, false); + } + } + } + + // 4. Sheep Update + for (let i = this.sheep.length - 1; i >= 0; i--) { + const s = this.sheep[i]; + s.update( + dt, + this.width, + this.height, + this.config, + this.plants, + this.wolves, + this.sheep, + this.particles, + (babyX, babyY, species) => { + const babySheep = new Sheep(babyX, babyY, true); + this.sheep.push(babySheep); + this.stats.recordSheepBirth(false); + }, + this.terrain + ); + + if (s.isDead) { + // Turn into decaying carcass on the terrain! + this.terrain.addCarcass(s.x, s.y, 'sheep', 20 * s.growthScale); + this.stats.recordSheepDeath(s.deathCause || 'starvation'); + this.sheep.splice(i, 1); + } + } + + // 5. Wolves Update + for (let i = this.wolves.length - 1; i >= 0; i--) { + const w = this.wolves[i]; + w.update( + dt, + this.width, + this.height, + this.config, + this.sheep, + this.wolves, + this.terrain, + this.particles, + (babyX, babyY, species) => { + const babyWolf = new Wolf(babyX, babyY, true); + this.wolves.push(babyWolf); + this.stats.recordWolfBirth(false); + }, + (killedSheep, killerWolf) => { + // Handled via sheep.isDead and sheep.takeDamage + } + ); + + if (w.isDead) { + this.terrain.addCarcass(w.x, w.y, 'wolf', 24 * w.growthScale); + this.stats.recordWolfDeath('starvation'); + this.wolves.splice(i, 1); + } + } + + // 5b. Physical Animal-Animal Collision & Elastic Bounce + // Prevents wolves from stacking into 1 sprite, keeps sheep flock spaced, and bounces colliding animals + this.resolveAnimalCollisions(); + + // 6. Particles Update + this.particles.update(dt, this.terrain); + + // 7. Stats Update & Extinction Check + const wasExtinct = this.stats.extinct; + this.stats.update(dt, this.sheep.length, this.wolves.length, this.plants.length); + + if (!wasExtinct && this.stats.extinct) { + if (this.onExtinction && !this.config.continuousMode) { + this.onExtinction(this.stats); + } + } + + if (this.onStatsChange) { + this.onStatsChange(this.stats, this.sheep.length, this.wolves.length, this.plants.length); + } + } + + resolveAnimalCollisions() { + const allAnimals = [...this.wolves, ...this.sheep]; + const len = allAnimals.length; + + for (let i = 0; i < len; i++) { + const a1 = allAnimals[i]; + if (a1.isDead) continue; + + const r1 = (a1.species === 'wolf' ? 14 : 12) * a1.growthScale; + + for (let j = i + 1; j < len; j++) { + const a2 = allAnimals[j]; + if (a2.isDead) continue; + + const r2 = (a2.species === 'wolf' ? 14 : 12) * a2.growthScale; + const minDist = r1 + r2; + + let dx = a2.x - a1.x; + let dy = a2.y - a1.y; + let dist = Math.hypot(dx, dy); + + if (dist < 0.001) { + // Exactly on top of each other: nudge slightly apart in random directions + const angle = Math.random() * Math.PI * 2; + dx = Math.cos(angle) * 0.1; + dy = Math.sin(angle) * 0.1; + dist = 0.1; + } + + if (dist < minDist) { + const nx = dx / dist; // Collision normal from a1 to a2 + const ny = dy / dist; + const overlap = minDist - dist; + + // 1. Positional Separation: immediately separate positions so animals do not overlap + const m1 = a1.growthScale; + const m2 = a2.growthScale; + const totalMass = m1 + m2; + const ratio1 = m2 / totalMass; + const ratio2 = m1 / totalMass; + + a1.x -= nx * overlap * ratio1; + a1.y -= ny * overlap * ratio1; + a2.x += nx * overlap * ratio2; + a2.y += ny * overlap * ratio2; + + // 2. Velocity Bounce: elastic impulse along normal + const rvx = a2.vx - a1.vx; + const rvy = a2.vy - a1.vy; + const velAlongNormal = rvx * nx + rvy * ny; + + // Only bounce if they are moving towards each other + if (velAlongNormal < 0) { + const restitution = 0.65; // Elastic bounce factor + const impulse = -(1 + restitution) * velAlongNormal; + + a1.vx -= nx * (impulse * ratio1); + a1.vy -= ny * (impulse * ratio1); + a2.vx += nx * (impulse * ratio2); + a2.vy += ny * (impulse * ratio2); + + // Subtle lateral deflection so head-on colliding animals slip past each other + const tx = -ny; + const ty = nx; + const lateralKick = (Math.random() - 0.5) * 0.35; + a1.vx += tx * lateralKick; + a1.vy += ty * lateralKick; + a2.vx -= tx * lateralKick; + a2.vy -= ty * lateralKick; + } + } + } + } + + // Keep all animals comfortably within the visible meadow bounds after bouncing + const margin = 24; + for (let i = 0; i < len; i++) { + const a = allAnimals[i]; + if (a.isDead) continue; + if (a.x < margin) { + a.x = margin; + if (a.vx < 0) a.vx = -a.vx * 0.5 + 0.3; + } else if (a.x > this.width - margin) { + a.x = this.width - margin; + if (a.vx > 0) a.vx = -a.vx * 0.5 - 0.3; + } + + if (a.y < margin) { + a.y = margin; + if (a.vy < 0) a.vy = -a.vy * 0.5 + 0.3; + } else if (a.y > this.height - margin) { + a.y = this.height - margin; + if (a.vy > 0) a.vy = -a.vy * 0.5 - 0.3; + } + } + } + + draw() { + const ctx = this.ctx; + + // 1. Draw Terrain, Soil Decals & Carcasses + this.terrain.draw(ctx); + + // 2. Draw Plants & Flowers + for (let i = 0; i < this.plants.length; i++) { + this.plants[i].draw(ctx, this.stats.elapsedSimTime); + } + + // 3. Draw Sheep + for (let i = 0; i < this.sheep.length; i++) { + this.sheep[i].draw(ctx); + } + + // 4. Draw Wolves + for (let i = 0; i < this.wolves.length; i++) { + this.wolves[i].draw(ctx); + } + + // 5. Draw Particles (Blood sprays, hearts, seeds, splashes) + this.particles.draw(ctx); + + // 6. Draw Weather Atmosphere & Rain Drops + this.weather.draw(ctx); + + // 7. Selected Entity Inspection Circle + if (this.selectedEntity && !this.selectedEntity.isDead) { + ctx.save(); + ctx.strokeStyle = '#FFD700'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(this.selectedEntity.x, this.selectedEntity.y, 22 * (this.selectedEntity.growthScale || 1), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + } +} diff --git a/js/state.js b/js/state.js new file mode 100644 index 0000000..d2a9a49 --- /dev/null +++ b/js/state.js @@ -0,0 +1,107 @@ +/** + * Simulation Statistics & History Tracking + * Keeps full account of births, deaths, consumption, and temporal population data for charting. + */ + +export class SimStats { + constructor() { + this.reset(); + } + + reset() { + this.startTime = Date.now(); + this.elapsedSimTime = 0; // simulated seconds + + // Historical population records (sampled at 1Hz sim time) + this.history = []; + this.sampleTimer = 0; + + // Entity counters + this.totalSheepBorn = 0; + this.totalWolvesBorn = 0; + this.totalPlantsGrown = 0; + this.totalPlantsFromDecay = 0; + this.totalSeedsSprouted = 0; + + // Death counters + this.sheepKilledByWolves = 0; + this.sheepStarved = 0; + this.wolvesStarved = 0; + this.plantsEaten = 0; + this.plantsWithered = 0; + + // Peaks + this.peakSheep = 0; + this.peakWolves = 0; + this.peakPlants = 0; + + // Extinction flag + this.extinct = false; + this.extinctionTime = 0; + } + + update(dt, sheepCount, wolfCount, plantCount) { + this.elapsedSimTime += dt; + + if (sheepCount > this.peakSheep) this.peakSheep = sheepCount; + if (wolfCount > this.peakWolves) this.peakWolves = wolfCount; + if (plantCount > this.peakPlants) this.peakPlants = plantCount; + + // Sample population every 1 simulated second for graphs + this.sampleTimer += dt; + if (this.sampleTimer >= 1.0) { + this.sampleTimer -= 1.0; + this.history.push({ + time: Math.round(this.elapsedSimTime), + sheep: sheepCount, + wolves: wolfCount, + plants: plantCount, + }); + + // Keep up to 600 data points (10 minutes of active history) + if (this.history.length > 600) { + this.history.shift(); + } + } + + // Check for extinction event (both animal populations reach 0 after starting) + if (!this.extinct && this.elapsedSimTime > 2.0 && sheepCount === 0 && wolfCount === 0) { + this.extinct = true; + this.extinctionTime = this.elapsedSimTime; + } + } + + recordSheepBirth(isInitial = false) { + if (!isInitial) this.totalSheepBorn++; + } + + recordWolfBirth(isInitial = false) { + if (!isInitial) this.totalWolvesBorn++; + } + + recordPlantSpawn(isFromDecay = false, isFromSeed = false) { + this.totalPlantsGrown++; + if (isFromDecay) this.totalPlantsFromDecay++; + if (isFromSeed) this.totalSeedsSprouted++; + } + + recordSheepDeath(cause = 'wolf') { + if (cause === 'wolf') { + this.sheepKilledByWolves++; + } else { + this.sheepStarved++; + } + } + + recordWolfDeath(cause = 'starvation') { + this.wolvesStarved++; + } + + recordPlantConsumed() { + this.plantsEaten++; + } + + recordPlantWithered() { + this.plantsWithered++; + } +} diff --git a/js/terrain.js b/js/terrain.js new file mode 100644 index 0000000..d482b25 --- /dev/null +++ b/js/terrain.js @@ -0,0 +1,286 @@ +/** + * Terrain, Soil Decals & Carcass Decomposition System + * Manages meadow grass, dynamic blood decals with plant nutrient absorption, + * rain cleansing, and decomposing animal carcasses that sprout flower tufts. + */ + +export class Carcass { + constructor(x, y, species, size = 18) { + this.x = x; + this.y = y; + this.species = species; // 'sheep' or 'wolf' + this.size = size; + this.decayProgress = 0; // 0.0 (fresh) to 1.0 (fully decomposed into soil) + this.meatLeft = 1.0; + this.isDecomposed = false; + } + + update(dt, decayMultiplier = 1.0) { + const baseDecayRate = 1 / 10.0; + this.decayProgress += dt * baseDecayRate * decayMultiplier; + + if (this.decayProgress >= 1.0) { + this.decayProgress = 1.0; + this.isDecomposed = true; + } + } + + draw(ctx) { + ctx.save(); + const alpha = Math.max(0.2, 1 - this.decayProgress * 0.7); + ctx.globalAlpha = alpha; + + const s = Math.max(6, Math.round(this.size * (1 - this.decayProgress * 0.35))); + const px = Math.round(this.x - s / 2); + const py = Math.round(this.y - s / 3); + + if (this.decayProgress < 0.6) { + // Flesh stage + if (this.species === 'sheep') { + ctx.fillStyle = '#C8CDD0'; // soiled wool + ctx.fillRect(px, py, s, Math.round(s * 0.65)); + ctx.fillStyle = '#8A1010'; // blood patches on wool + ctx.fillRect(px + 2, py + 2, Math.max(2, Math.round(s * 0.3)), 3); + ctx.fillStyle = '#222'; + ctx.fillRect(px - 3, py + 1, 4, 4); + } else { + // Wolf carcass + ctx.fillStyle = '#3E4246'; + ctx.fillRect(px, py, s, Math.round(s * 0.55)); + ctx.fillStyle = '#8A1010'; + ctx.fillRect(px + 4, py + 1, Math.max(2, Math.round(s * 0.25)), 2); + } + } else { + // Skeletal ribs stage + ctx.fillStyle = '#E5E1D3'; + const boneLen = Math.round(s * 0.75); + ctx.fillRect(px, py + 2, boneLen, 2); + for (let r = 2; r < boneLen - 2; r += 3) { + ctx.fillRect(px + r, py, 1, 6); + } + ctx.fillRect(px - 2, py + 1, 3, 3); + } + + ctx.restore(); + } +} + +export class TerrainManager { + constructor(width, height) { + this.width = width; + this.height = height; + this.carcasses = []; + + // Active dynamic decals (blood pools, soil mulch) that can be absorbed or washed away + this.decals = []; + + // Offscreen canvas for static meadow base grass + this.baseCanvas = document.createElement('canvas'); + this.baseCanvas.width = width; + this.baseCanvas.height = height; + this.baseCtx = this.baseCanvas.getContext('2d'); + + this.generateBaseTerrain(); + } + + generateBaseTerrain() { + const ctx = this.baseCtx; + const grad = ctx.createLinearGradient(0, 0, 0, this.height); + grad.addColorStop(0, '#559437'); + grad.addColorStop(1, '#4A8330'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, this.width, this.height); + + // Subtle grass flecks + const fleckColors = ['#5E9E3C', '#45792B', '#68A843', '#407028']; + const totalFlecks = Math.floor((this.width * this.height) / 350); + + for (let i = 0; i < totalFlecks; i++) { + const x = Math.floor(Math.random() * this.width); + const y = Math.floor(Math.random() * this.height); + const c = fleckColors[Math.floor(Math.random() * fleckColors.length)]; + ctx.fillStyle = c; + const w = 2 + Math.floor(Math.random() * 2); + const h = 2 + Math.floor(Math.random() * 3); + ctx.fillRect(x, y, w, h); + } + + // Tiny wild micro-flowers + const flowerColors = ['#FFF382', '#FFFFFF', '#FFB7C5', '#8FD3FE']; + for (let i = 0; i < 90; i++) { + const x = Math.floor(Math.random() * this.width); + const y = Math.floor(Math.random() * this.height); + const c = flowerColors[Math.floor(Math.random() * flowerColors.length)]; + ctx.fillStyle = c; + ctx.fillRect(x, y, 2, 2); + } + } + + reset() { + this.carcasses = []; + this.decals = []; + } + + resize(newWidth, newHeight) { + if (newWidth <= 0 || newHeight <= 0) return; + this.width = newWidth; + this.height = newHeight; + + this.baseCanvas.width = newWidth; + this.baseCanvas.height = newHeight; + this.generateBaseTerrain(); + + // Clamp decals and carcasses to new meadow boundaries + for (let i = 0; i < this.carcasses.length; i++) { + const c = this.carcasses[i]; + c.x = Math.max(15, Math.min(newWidth - 15, c.x)); + c.y = Math.max(15, Math.min(newHeight - 15, c.y)); + } + for (let i = 0; i < this.decals.length; i++) { + const d = this.decals[i]; + d.x = Math.max(10, Math.min(newWidth - 10, d.x)); + d.y = Math.max(10, Math.min(newHeight - 10, d.y)); + } + } + + addCarcass(x, y, species, size) { + const carcass = new Carcass(x, y, species, size); + this.carcasses.push(carcass); + this.addBloodSplotch(x, y, 7 + Math.random() * 4); + return carcass; + } + + addBloodSplotch(x, y, radius = 5) { + const r = Math.max(3, Math.round(radius)); + this.decals.push({ + x, + y, + radius: r, + type: 'blood', + color: 'rgba(145, 14, 14,', + maxLife: 28, // seconds before natural fade + life: 28, + opacity: 0.75, + transformed: false, + }); + + // Keep decal count bounded for performance + if (this.decals.length > 70) { + this.decals.shift(); + } + } + + addFertilizedSoilPatch(x, y, radius = 16) { + this.decals.push({ + x, + y, + radius, + type: 'soil', + maxLife: 35, + life: 35, + opacity: 0.75, + transformed: true, + }); + } + + /** + * Absorbs/cleanses blood and soil decals around a growing plant node. + * As plants grow, they consume the nutrients and remove the blood splats! + */ + absorbDecalsNear(x, y, absorbRadius = 30) { + for (let i = this.decals.length - 1; i >= 0; i--) { + const d = this.decals[i]; + const dist = Math.hypot(d.x - x, d.y - y); + if (dist <= absorbRadius + d.radius) { + // Fast fade or instant absorption by the plant + d.life = Math.min(d.life, 0.4); + } + } + } + + update(dt, decayMultiplier, rainIntensity, onCarcassDecomposed, onBloodEnriched) { + // 1. Update carcasses + for (let i = this.carcasses.length - 1; i >= 0; i--) { + const c = this.carcasses[i]; + c.update(dt, decayMultiplier); + + if (c.isDecomposed) { + this.addFertilizedSoilPatch(c.x, c.y, 16); + + if (onCarcassDecomposed) { + onCarcassDecomposed(c.x, c.y); + } + + this.carcasses.splice(i, 1); + } + } + + // 2. Update decals: age, rain cleansing wash, and soil enrichment + // Rain actively washes blood away! + const rainWashFactor = 1.0 + (rainIntensity || 0) * 3.5; + + for (let i = this.decals.length - 1; i >= 0; i--) { + const d = this.decals[i]; + d.life -= dt * decayMultiplier * rainWashFactor; + + // When blood spot reaches maturity, turn into enriched soil and trigger a bloom + if (d.type === 'blood' && !d.transformed && d.life <= d.maxLife * 0.4) { + d.transformed = true; + d.type = 'soil'; + d.radius += 5; + if (onBloodEnriched && Math.random() < 0.65) { + onBloodEnriched(d.x, d.y); + } + } + + // Decal has completely faded or washed away + if (d.life <= 0) { + this.decals.splice(i, 1); + } + } + } + + draw(ctx) { + // 1. Base clean meadow + ctx.drawImage(this.baseCanvas, 0, 0); + + // 2. Draw dynamic decals (blood and enriched soil) with smooth fading + for (let i = 0; i < this.decals.length; i++) { + const d = this.decals[i]; + const alphaNorm = Math.max(0, Math.min(1, d.life / d.maxLife)); + const currentAlpha = d.opacity * alphaNorm; + if (currentAlpha <= 0.02) continue; + + ctx.save(); + if (d.type === 'blood') { + // Blood pool with subtle splatter dots + ctx.fillStyle = `rgba(145, 14, 14, ${currentAlpha})`; + ctx.beginPath(); + ctx.arc(d.x, d.y, d.radius, 0, Math.PI * 2); + ctx.fill(); + + // Satellites + ctx.fillStyle = `rgba(110, 8, 8, ${currentAlpha * 0.8})`; + ctx.fillRect(Math.round(d.x + d.radius * 0.7), Math.round(d.y - d.radius * 0.5), 2, 2); + ctx.fillRect(Math.round(d.x - d.radius * 0.8), Math.round(d.y + d.radius * 0.6), 2, 2); + } else { + // Enriched mulch soil ring + const grad = ctx.createRadialGradient(d.x, d.y, 2, d.x, d.y, d.radius); + grad.addColorStop(0, `rgba(68, 45, 26, ${currentAlpha})`); + grad.addColorStop(0.7, `rgba(84, 128, 48, ${currentAlpha * 0.7})`); + grad.addColorStop(1, `rgba(84, 128, 48, 0)`); + + ctx.fillStyle = grad; + ctx.beginPath(); + ctx.arc(d.x, d.y, d.radius, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + // 3. Draw carcasses lying on the field + for (let i = 0; i < this.carcasses.length; i++) { + this.carcasses[i].draw(ctx); + } + } +} diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..3f2be61 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,575 @@ +/** + * User Interface & Interactive Controls Module + * Binds expanded sliders, preset actions, live HUD badges, population history chart, + * entity inspector, and the post-extinction analysis modal. + */ + +export class UIManager { + constructor(sim, configManager) { + this.sim = sim; + this.configManager = configManager; + + this.cacheDOMElements(); + this.bindEvents(); + this.syncSlidersFromConfig(); + } + + cacheDOMElements() { + // Primary Controls + this.btnPlayPause = document.getElementById('btnPlayPause'); + this.btnRestart = document.getElementById('btnRestart'); + this.btnFullscreen = document.getElementById('btnFullscreen'); + this.btnExitFullscreen = document.getElementById('btnExitFullscreen'); + this.btnSetDefaults = document.getElementById('btnSetDefaults'); + this.btnDefaults = document.getElementById('btnDefaults'); + this.btnCopyJson = document.getElementById('btnCopyJson'); + this.btnRain = document.getElementById('btnRain'); + this.toastMsg = document.getElementById('toastMsg'); + + // Continuous Screen Saver Mode + this.checkContinuous = document.getElementById('checkContinuous'); + + // Speed buttons + this.speedBtns = document.querySelectorAll('.speed-btn'); + + // Tool buttons + this.toolBtns = document.querySelectorAll('.tool-btn'); + + // Sliders & value displays + this.sliders = { + initialSheep: { input: document.getElementById('sliderSheep'), label: document.getElementById('valSheep'), format: (v) => v }, + initialWolves: { input: document.getElementById('sliderWolves'), label: document.getElementById('valWolves'), format: (v) => v }, + initialPlants: { input: document.getElementById('sliderPlants'), label: document.getElementById('valPlants'), format: (v) => v }, + maxPlantCap: { input: document.getElementById('sliderMaxPlantCap'), label: document.getElementById('valMaxPlantCap'), format: (v) => v }, + + sheepHungerDepletionRate: { input: document.getElementById('sliderSheepHunger'), label: document.getElementById('valSheepHunger'), format: (v) => `${Number(v).toFixed(1)}/s` }, + sheepGrazingReach: { input: document.getElementById('sliderSheepReach'), label: document.getElementById('valSheepReach'), format: (v) => `${v}px` }, + sheepMatingCooldown: { input: document.getElementById('sliderSheepMating'), label: document.getElementById('valSheepMating'), format: (v) => `${v}s` }, + + wolfLungeCost: { input: document.getElementById('sliderWolfLunge'), label: document.getElementById('valWolfLunge'), format: (v) => `${v} HP` }, + wolfHungerDepletionRate: { input: document.getElementById('sliderWolfHunger'), label: document.getElementById('valWolfHunger'), format: (v) => `${Number(v).toFixed(1)}/s` }, + wolfAttackDamage: { input: document.getElementById('sliderWolfDamage'), label: document.getElementById('valWolfDamage'), format: (v) => v }, + wolfMatingCooldown: { input: document.getElementById('sliderWolfMating'), label: document.getElementById('valWolfMating'), format: (v) => `${v}s` }, + + plantSeedRate: { input: document.getElementById('sliderSeedRate'), label: document.getElementById('valSeedRate'), format: (v) => `${v}%` }, + plantDryRate: { input: document.getElementById('sliderDryRate'), label: document.getElementById('valDryRate'), format: (v) => `${Number(v).toFixed(1)}/s` }, + rainFrequency: { input: document.getElementById('sliderRain'), label: document.getElementById('valRain'), format: (v) => `${v}%` }, + decaySpeed: { input: document.getElementById('sliderDecay'), label: document.getElementById('valDecay'), format: (v) => `${Number(v).toFixed(1)}x` }, + + // Continuous Mode Influx intervals & wave size + continuousSheepInterval: { input: document.getElementById('sliderContinuousSheep'), label: document.getElementById('valContinuousSheep'), format: (v) => `${v}s` }, + continuousWolfInterval: { input: document.getElementById('sliderContinuousWolf'), label: document.getElementById('valContinuousWolf'), format: (v) => `${v}s` }, + continuousMaxGroupSize: { input: document.getElementById('sliderContinuousGroup'), label: document.getElementById('valContinuousGroup'), format: (v) => `${v}` }, + }; + + // Live HUD badges + this.hudSheep = document.getElementById('hudSheep'); + this.hudWolves = document.getElementById('hudWolves'); + this.hudPlants = document.getElementById('hudPlants'); + this.hudTime = document.getElementById('hudTime'); + this.hudWeather = document.getElementById('hudWeather'); + + // Mini chart canvas + this.miniChartCanvas = document.getElementById('miniChart'); + if (this.miniChartCanvas) { + this.miniChartCtx = this.miniChartCanvas.getContext('2d'); + } + + // Extinction modal elements + this.extinctionModal = document.getElementById('extinctionModal'); + this.extinctionChartCanvas = document.getElementById('extinctionChart'); + if (this.extinctionChartCanvas) { + this.extinctionChartCtx = this.extinctionChartCanvas.getContext('2d'); + } + + this.extTimeSurv = document.getElementById('extTimeSurv'); + this.extPeakSheep = document.getElementById('extPeakSheep'); + this.extPeakWolves = document.getElementById('extPeakWolves'); + this.extPeakPlants = document.getElementById('extPeakPlants'); + this.extTotalBorn = document.getElementById('extTotalBorn'); + this.extWolfKills = document.getElementById('extWolfKills'); + this.extDecayBlooms = document.getElementById('extDecayBlooms'); + this.extSeeds = document.getElementById('extSeeds'); + + this.btnModalRestart = document.getElementById('btnModalRestart'); + this.btnModalReview = document.getElementById('btnModalReview'); + + // Entity Inspector + this.inspectorPanel = document.getElementById('inspectorPanel'); + this.inspectorDetails = document.getElementById('inspectorDetails'); + this.btnCloseInspector = document.getElementById('btnCloseInspector'); + } + + showToast(text) { + if (!this.toastMsg) return; + this.toastMsg.textContent = text; + this.toastMsg.classList.remove('hidden'); + clearTimeout(this._toastTimeout); + this._toastTimeout = setTimeout(() => { + this.toastMsg.classList.add('hidden'); + }, 2400); + } + + syncSlidersFromConfig() { + const cfg = this.configManager.current; + + if (this.checkContinuous && cfg.continuousMode !== undefined) { + this.checkContinuous.checked = !!cfg.continuousMode; + } + + for (const [key, item] of Object.entries(this.sliders)) { + if (item.input && cfg[key] !== undefined) { + item.input.value = cfg[key]; + if (item.label) { + item.label.textContent = item.format(cfg[key]); + } + } + } + } + + readSliders() { + const config = {}; + if (this.checkContinuous) { + config.continuousMode = this.checkContinuous.checked; + } + for (const [key, item] of Object.entries(this.sliders)) { + if (item.input) { + config[key] = parseFloat(item.input.value); + } + } + return config; + } + + bindEvents() { + // 1. Play / Pause + this.btnPlayPause.addEventListener('click', () => { + this.sim.isRunning = !this.sim.isRunning; + this.updatePlayPauseButton(); + }); + + // 2. Restart Simulation + this.btnRestart.addEventListener('click', () => { + this.extinctionModal.classList.add('hidden'); + this.sim.restart(); + this.sim.isRunning = true; + this.updatePlayPauseButton(); + }); + + // 3. Save current settings as custom defaults + if (this.btnSetDefaults) { + this.btnSetDefaults.addEventListener('click', () => { + const currentVals = this.readSliders(); + this.configManager.saveAsNewDefaults(currentVals); + this.sim.config = { ...this.configManager.current }; + this.showToast('✓ Saved As Custom Defaults!'); + }); + } + + // 4. Restore Defaults + this.btnDefaults.addEventListener('click', () => { + this.configManager.resetToDefaults(); + this.syncSlidersFromConfig(); + this.sim.config = { ...this.configManager.current }; + this.showToast('↺ Restored Defaults'); + }); + + // 5. Copy JSON Config + if (this.btnCopyJson) { + this.btnCopyJson.addEventListener('click', () => { + const jsonStr = JSON.stringify(this.sim.config, null, 2); + navigator.clipboard.writeText(jsonStr).then(() => { + this.showToast('📋 JSON Copied to Clipboard!'); + }).catch(() => { + this.showToast('Failed to copy to clipboard'); + }); + }); + } + + // 6. Summon Rain button + if (this.btnRain) { + this.btnRain.addEventListener('click', () => { + this.sim.weather.forceRain(); + }); + } + + // 7. Speed Buttons (1x, 2x, 4x) + this.speedBtns.forEach((btn) => { + btn.addEventListener('click', () => { + this.speedBtns.forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + const speed = parseFloat(btn.dataset.speed || '1'); + this.sim.speedMultiplier = speed; + }); + }); + + // 8. Spawn / Inspect Tools + this.toolBtns.forEach((btn) => { + btn.addEventListener('click', () => { + this.toolBtns.forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + this.sim.activeTool = btn.dataset.tool || 'inspect'; + }); + }); + + // 9. Real-time Slider input listeners + for (const [key, item] of Object.entries(this.sliders)) { + if (item.input) { + item.input.addEventListener('input', () => { + const val = parseFloat(item.input.value); + if (item.label) { + item.label.textContent = item.format(val); + } + + // Live update simulation config & persist + const updated = this.readSliders(); + this.configManager.save(updated); + this.sim.config = { ...this.sim.config, ...updated }; + }); + } + } + + // 10. Canvas click interactions (Spawn or Inspect) + this.sim.canvas.addEventListener('click', (e) => { + const rect = this.sim.canvas.getBoundingClientRect(); + const scaleX = this.sim.canvas.width / rect.width; + const scaleY = this.sim.canvas.height / rect.height; + const clickX = (e.clientX - rect.left) * scaleX; + const clickY = (e.clientY - rect.top) * scaleY; + + if (this.sim.activeTool === 'inspect') { + this.handleInspectClick(clickX, clickY); + } else { + this.sim.spawnEntity(this.sim.activeTool, clickX, clickY); + } + }); + + // 11. Extinction modal actions + if (this.btnModalRestart) { + this.btnModalRestart.addEventListener('click', () => { + this.extinctionModal.classList.add('hidden'); + this.sim.restart(); + this.sim.isRunning = true; + this.updatePlayPauseButton(); + }); + } + + if (this.btnModalReview) { + this.btnModalReview.addEventListener('click', () => { + this.extinctionModal.classList.add('hidden'); + }); + } + + if (this.btnCloseInspector) { + this.btnCloseInspector.addEventListener('click', () => { + this.sim.selectedEntity = null; + this.inspectorPanel.classList.add('hidden'); + }); + } + + // 12. Fullscreen Screen Saver Mode + if (this.btnFullscreen) { + this.btnFullscreen.addEventListener('click', () => { + this.enterFullscreen(); + }); + } + + if (this.btnExitFullscreen) { + this.btnExitFullscreen.addEventListener('click', () => { + this.exitFullscreen(); + }); + } + + // Keyboard ESC & browser fullscreenchange listener + document.addEventListener('fullscreenchange', () => { + if (!document.fullscreenElement && this.isFullscreen) { + this.exitFullscreen(false); + } + }); + + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && this.isFullscreen) { + this.exitFullscreen(); + } + }); + + // 13. Continuous Mode Toggle + if (this.checkContinuous) { + this.checkContinuous.addEventListener('change', () => { + const isChecked = this.checkContinuous.checked; + this.sim.config.continuousMode = isChecked; + this.configManager.save({ continuousMode: isChecked }); + if (isChecked) { + this.extinctionModal.classList.add('hidden'); + this.showToast('📺 Continuous Screen Saver Mode Active'); + } else { + this.showToast('Continuous Mode Disabled'); + } + }); + } + } + + enterFullscreen() { + this.isFullscreen = true; + const appLayout = document.querySelector('.app-layout'); + if (appLayout) { + appLayout.classList.add('fullscreen-mode'); + } + if (this.btnExitFullscreen) { + this.btnExitFullscreen.classList.remove('hidden'); + } + + if (document.documentElement.requestFullscreen && !document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => {}); + } + + setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 60); + } + + exitFullscreen(requestExit = true) { + this.isFullscreen = false; + const appLayout = document.querySelector('.app-layout'); + if (appLayout) { + appLayout.classList.remove('fullscreen-mode'); + } + if (this.btnExitFullscreen) { + this.btnExitFullscreen.classList.add('hidden'); + } + + if (requestExit && document.fullscreenElement && document.exitFullscreen) { + document.exitFullscreen().catch(() => {}); + } + + setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 60); + } + + handleInspectClick(x, y) { + let bestEntity = null; + let minD = 28; + + const checkList = (list) => { + for (let i = 0; i < list.length; i++) { + const item = list[i]; + if (item.isDead) continue; + const d = Math.hypot(item.x - x, item.y - y); + if (d < minD) { + minD = d; + bestEntity = item; + } + } + }; + + checkList(this.sim.wolves); + checkList(this.sim.sheep); + checkList(this.sim.plants); + + this.sim.selectedEntity = bestEntity; + this.updateInspectorPanel(); + } + + updateInspectorPanel() { + const e = this.sim.selectedEntity; + if (!e || e.isDead) { + this.inspectorPanel.classList.add('hidden'); + return; + } + + this.inspectorPanel.classList.remove('hidden'); + let html = ''; + + if (e.species === 'sheep' || e.species === 'wolf') { + const icon = e.species === 'sheep' ? '🐑' : '🐺'; + const name = e.isBaby ? `Baby ${e.species}` : `Adult ${e.species}`; + html = ` +
${icon} ${name}
+
Health: ${Math.round(e.health)}/100
+
Hunger: ${Math.round(e.hunger)}/100
+
State: ${e.actionState}
+
Age: ${Math.round(e.age)}s
+ ${e.isBaby ? `
Food for Adult: ${e.foodEatenAsBaby}/3 meals
` : ''} + `; + } else { + const isFlower = e.type === 'flower'; + html = ` +
${isFlower ? '🌸 Dense Flower Tuft' : '🌿 Grass Tuft'}
+
Moisture: ${Math.round(e.moisture)}%
+
Growth: ${Math.round(e.growth * 100)}%
+
Age: ${Math.round(e.age)}s
+ `; + } + + this.inspectorDetails.innerHTML = html; + } + + updatePlayPauseButton() { + if (this.sim.isRunning) { + this.btnPlayPause.innerHTML = ` + + Pause + `; + this.btnPlayPause.classList.add('btn-active'); + } else { + this.btnPlayPause.innerHTML = ` + + Play + `; + this.btnPlayPause.classList.remove('btn-active'); + } + } + + updateHUD(stats, sheepCount, wolfCount, plantCount) { + if (this.hudSheep) this.hudSheep.textContent = sheepCount; + if (this.hudWolves) this.hudWolves.textContent = wolfCount; + if (this.hudPlants) this.hudPlants.textContent = plantCount; + + if (this.hudTime) { + const totalSec = Math.floor(stats.elapsedSimTime); + const mins = Math.floor(totalSec / 60); + const secs = totalSec % 60; + this.hudTime.textContent = `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + + if (this.hudWeather) { + const wState = this.sim.weather.state; + let label = '☀️ Sunny'; + if (wState === 'raining') label = '🌧️ Raining'; + else if (wState === 'cloudy') label = '☁️ Overcast'; + else if (wState === 'clearing') label = '⛅ Clearing'; + this.hudWeather.textContent = label; + } + + // Refresh inspector if entity is active + if (this.sim.selectedEntity) { + this.updateInspectorPanel(); + } + + // Draw Mini Chart + if (this.miniChartCtx && stats.history.length > 1) { + this.drawChart(this.miniChartCtx, this.miniChartCanvas.width, this.miniChartCanvas.height, stats.history, false); + } + } + + showExtinctionModal(stats) { + this.extinctionModal.classList.remove('hidden'); + + const totalSec = Math.floor(stats.extinctionTime || stats.elapsedSimTime); + const mins = Math.floor(totalSec / 60); + const secs = totalSec % 60; + this.extTimeSurv.textContent = `${mins}m ${secs}s`; + + this.extPeakSheep.textContent = stats.peakSheep; + this.extPeakWolves.textContent = stats.peakWolves; + this.extPeakPlants.textContent = stats.peakPlants; + + this.extTotalBorn.textContent = `${stats.totalSheepBorn} sheep / ${stats.totalWolvesBorn} wolves`; + this.extWolfKills.textContent = `${stats.sheepKilledByWolves} sheep hunted (${stats.sheepStarved} starved)`; + this.extDecayBlooms.textContent = `${stats.totalPlantsFromDecay} flower tufts`; + this.extSeeds.textContent = `${stats.totalSeedsSprouted} seed puffs sprouted`; + + // Render large extinction history chart + if (this.extinctionChartCtx) { + this.drawChart( + this.extinctionChartCtx, + this.extinctionChartCanvas.width, + this.extinctionChartCanvas.height, + stats.history, + true + ); + } + } + + drawChart(ctx, width, height, history, showAxes = true) { + ctx.clearRect(0, 0, width, height); + + if (!history || history.length < 2) return; + + const padLeft = showAxes ? 45 : 4; + const padRight = showAxes ? 20 : 4; + const padTop = showAxes ? 25 : 4; + const padBottom = showAxes ? 25 : 4; + + const chartW = width - padLeft - padRight; + const chartH = height - padTop - padBottom; + + // Determine max value for Y-scaling + let maxY = 10; + for (let i = 0; i < history.length; i++) { + const h = history[i]; + if (h.sheep > maxY) maxY = h.sheep; + if (h.wolves > maxY) maxY = h.wolves; + if (h.plants > maxY) maxY = h.plants; + } + maxY = Math.ceil(maxY * 1.15); + + // Background grid + if (showAxes) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; + ctx.lineWidth = 1; + + // Horizontal grid lines + for (let g = 0; g <= 4; g++) { + const yVal = Math.round((maxY / 4) * g); + const yPos = padTop + chartH - (g / 4) * chartH; + ctx.beginPath(); + ctx.moveTo(padLeft, yPos); + ctx.lineTo(padLeft + chartW, yPos); + ctx.stroke(); + + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)'; + ctx.font = '10px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(yVal.toString(), padLeft - 6, yPos + 3); + } + } + + const mapX = (idx) => padLeft + (idx / (history.length - 1)) * chartW; + const mapY = (val) => padTop + chartH - (val / maxY) * chartH; + + // Draw Line Series helper + const drawSeries = (key, color, lineWidth = 2) => { + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + + for (let i = 0; i < history.length; i++) { + const x = mapX(i); + const y = mapY(history[i][key]); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }; + + // Draw series: Plants (green), Sheep (sky blue/white), Wolves (coral red) + drawSeries('plants', '#38D39F', showAxes ? 2.5 : 1.5); + drawSeries('sheep', '#4CC9F0', showAxes ? 2.5 : 1.5); + drawSeries('wolves', '#FF4D6D', showAxes ? 2.5 : 1.5); + + // Chart Legend on full chart + if (showAxes) { + ctx.font = '11px sans-serif'; + ctx.textAlign = 'left'; + + // Sheep + ctx.fillStyle = '#4CC9F0'; + ctx.fillRect(padLeft + 10, 10, 10, 10); + ctx.fillStyle = '#E0E0E0'; + ctx.fillText('Sheep', padLeft + 25, 19); + + // Wolves + ctx.fillStyle = '#FF4D6D'; + ctx.fillRect(padLeft + 85, 10, 10, 10); + ctx.fillStyle = '#E0E0E0'; + ctx.fillText('Wolves', padLeft + 100, 19); + + // Plants + ctx.fillStyle = '#38D39F'; + ctx.fillRect(padLeft + 165, 10, 10, 10); + ctx.fillStyle = '#E0E0E0'; + ctx.fillText('Plants / Flowers', padLeft + 180, 19); + } + } +} diff --git a/js/weather.js b/js/weather.js new file mode 100644 index 0000000..fe6b0e1 --- /dev/null +++ b/js/weather.js @@ -0,0 +1,394 @@ +/** + * Weather & Rain Simulation System + * Features procedural multi-lobed organic clouds with 3D volume shading, + * realistic drifting ground shadows, weather cycles, raindrops, and plant hydration. + */ + +const CLOUD_PRESETS = [ + // Preset 0: Majestic Cumulus (Tall center dome with billowing shoulders, flanking wisps, and flat base) + { + lobes: [ + { x: 0, y: -26, r: 50 }, // Central peak + { x: -44, y: -14, r: 42 }, // Left shoulder + { x: 46, y: -16, r: 44 }, // Right shoulder + { x: -84, y: 0, r: 32 }, // Far left puff + { x: 88, y: -2, r: 34 }, // Far right puff + { x: -116, y: 6, r: 22 }, // Left tail + { x: 120, y: 5, r: 24 }, // Right tail + { x: -22, y: -44, r: 34 }, // High crest left + { x: 24, y: -42, r: 32 }, // High crest right + { x: -52, y: 10, r: 28 }, // Base left + { x: 0, y: 12, r: 32 }, // Base center + { x: 52, y: 10, r: 28 }, // Base right + ], + width: 250, + height: 105 + }, + // Preset 1: Wide Billowing Cloud Bank (Broad rolling billows with undulating tops) + { + lobes: [ + { x: -38, y: -28, r: 48 }, + { x: 42, y: -26, r: 46 }, + { x: -92, y: -12, r: 38 }, + { x: 98, y: -14, r: 40 }, + { x: -142, y: 2, r: 28 }, + { x: 148, y: 4, r: 30 }, + { x: 0, y: -48, r: 38 }, + { x: -72, y: 10, r: 32 }, + { x: 0, y: 14, r: 35 }, + { x: 72, y: 12, r: 32 }, + { x: -118, y: 8, r: 24 }, + { x: 122, y: 8, r: 26 } + ], + width: 320, + height: 115 + }, + // Preset 2: Compact Fluffy Cotton Puff (Cute rounded cloud with soft bubbling lobes) + { + lobes: [ + { x: 0, y: -20, r: 42 }, + { x: -38, y: -10, r: 34 }, + { x: 40, y: -8, r: 36 }, + { x: -70, y: 2, r: 24 }, + { x: 72, y: 0, r: 26 }, + { x: 12, y: -36, r: 28 }, + { x: -18, y: -34, r: 26 }, + { x: -30, y: 6, r: 25 }, + { x: 30, y: 6, r: 25 }, + { x: 0, y: 8, r: 28 } + ], + width: 175, + height: 85 + }, + // Preset 3: Drifting Stratocumulus (Elongated windswept cloud bank) + { + lobes: [ + { x: -55, y: -18, r: 40 }, + { x: 12, y: -24, r: 44 }, + { x: 78, y: -16, r: 38 }, + { x: -112, y: -6, r: 30 }, + { x: 128, y: -4, r: 28 }, + { x: -162, y: 4, r: 20 }, + { x: 168, y: 6, r: 20 }, + { x: -26, y: -40, r: 30 }, + { x: 46, y: -36, r: 28 }, + { x: -72, y: 8, r: 27 }, + { x: 22, y: 10, r: 29 }, + { x: 82, y: 8, r: 27 } + ], + width: 355, + height: 100 + } +]; + +export class WeatherManager { + constructor(width, height) { + this.width = width; + this.height = height; + + this.state = 'sunny'; // 'sunny', 'cloudy', 'raining', 'clearing' + this.timer = 15; // seconds until next weather transition + this.rainIntensity = 0; // 0.0 to 1.0 (smooth fade in/out) + this.cloudDensity = 0.35; // 0.2 (clear day) to 1.0 (overcast storm) + + // Procedural cloud entities + this.clouds = []; + this.initClouds(); + + // Raindrop particles + this.raindrops = []; + this.maxDrops = 280; + this.initRaindrops(); + } + + initClouds() { + this.clouds = []; + // Number of clouds scales naturally with meadow width + const count = Math.max(5, Math.ceil(this.width / 220)); + + for (let i = 0; i < count; i++) { + const preset = CLOUD_PRESETS[i % CLOUD_PRESETS.length]; + const scale = 0.8 + Math.random() * 0.45; + const speed = 14 + Math.random() * 16; + // Stagger across the entire width plus extra margin + const x = -100 + (i / count) * (this.width + 300); + const y = 30 + Math.random() * (this.height * 0.65); + + this.clouds.push({ + x, + y, + scale, + speed, + preset, + lobes: preset.lobes, + width: preset.width * scale, + height: preset.height * scale, + shadowOffsetY: 90 + Math.random() * 60, + swayOffset: Math.random() * Math.PI * 2 + }); + } + } + + initRaindrops() { + this.raindrops = []; + const count = Math.max(220, Math.floor((this.width * this.height) / 3200)); + this.maxDrops = count; + + for (let i = 0; i < count; i++) { + this.raindrops.push({ + x: Math.random() * (this.width + 160), + y: Math.random() * this.height, + speed: 420 + Math.random() * 300, + length: 8 + Math.random() * 9, + }); + } + } + + resize(newWidth, newHeight) { + if (newWidth <= 0 || newHeight <= 0) return; + this.width = newWidth; + this.height = newHeight; + + this.initRaindrops(); + + // Ensure clouds span the new meadow width + if (this.clouds.length < Math.ceil(newWidth / 220)) { + this.initClouds(); + } else { + for (let i = 0; i < this.clouds.length; i++) { + const c = this.clouds[i]; + if (c.y > newHeight * 0.75) { + c.y = 30 + Math.random() * (newHeight * 0.65); + } + } + } + } + + reset() { + this.state = 'sunny'; + this.timer = 15; + this.rainIntensity = 0; + this.cloudDensity = 0.35; + } + + forceRain() { + this.state = 'raining'; + this.timer = 15; + this.rainIntensity = 1.0; + this.cloudDensity = 1.0; + } + + update(dt, rainSliderValue, plants, particleSystem) { + // rainSliderValue is 0 - 100 + // Higher slider = shorter sunny periods, longer rain duration + const sliderNorm = Math.max(0.05, rainSliderValue / 100); + const sunnyDuration = (1 - sliderNorm) * 35 + 8; // e.g. 10 to 43 sec + const rainDuration = sliderNorm * 18 + 7; // e.g. 8 to 25 sec + + this.timer -= dt; + + // Weather state machine + if (this.timer <= 0) { + switch (this.state) { + case 'sunny': + this.state = 'cloudy'; + this.timer = 5; + break; + case 'cloudy': + this.state = 'raining'; + this.timer = rainDuration; + break; + case 'raining': + this.state = 'clearing'; + this.timer = 5; + break; + case 'clearing': + this.state = 'sunny'; + this.timer = sunnyDuration; + break; + } + } + + // Smoothly adjust rainIntensity and cloudDensity + if (this.state === 'raining') { + this.rainIntensity = Math.min(1.0, this.rainIntensity + dt * 0.75); + this.cloudDensity = Math.min(1.0, this.cloudDensity + dt * 0.6); + } else if (this.state === 'cloudy') { + this.rainIntensity = Math.min(0.25, this.rainIntensity + dt * 0.35); + this.cloudDensity = Math.min(0.85, this.cloudDensity + dt * 0.5); + } else if (this.state === 'clearing') { + this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.45); + this.cloudDensity = Math.max(0.45, this.cloudDensity - dt * 0.35); + } else { + // Sunny + this.rainIntensity = Math.max(0.0, this.rainIntensity - dt * 0.5); + this.cloudDensity = Math.max(0.35, this.cloudDensity - dt * 0.2); + } + + // When raining, revitalize all plants! + if (this.rainIntensity > 0.1 && plants && plants.length > 0) { + const hydrationRate = 22 * this.rainIntensity; // moisture per second + for (let i = 0; i < plants.length; i++) { + plants[i].receiveRain(hydrationRate * dt); + } + } + + // Animate procedural clouds drifting across the sky + const windSpeedMult = 1.0 + this.rainIntensity * 0.75; + for (let i = 0; i < this.clouds.length; i++) { + const cloud = this.clouds[i]; + cloud.x += cloud.speed * windSpeedMult * dt; + + // Wrap around cleanly when fully offscreen to the right + if (cloud.x - cloud.width * 0.6 > this.width + 120) { + cloud.x = -cloud.width * 0.6 - 60; + cloud.y = 25 + Math.random() * (this.height * 0.65); + cloud.scale = 0.8 + Math.random() * 0.45; + cloud.speed = 14 + Math.random() * 16; + } + } + + // Animate raindrops + if (this.rainIntensity > 0.05) { + const activeDrops = Math.floor(this.maxDrops * this.rainIntensity); + for (let i = 0; i < activeDrops; i++) { + const drop = this.raindrops[i]; + drop.x -= dt * 65; // realistic wind angle + drop.y += drop.speed * dt; + + if (drop.y > this.height) { + drop.y = -drop.length; + drop.x = Math.random() * (this.width + 160); + + // Ground splash ripple on impact + if (particleSystem && Math.random() < 0.2) { + particleSystem.emitSplash(drop.x, this.height - Math.random() * 30); + } + } + } + } + } + + draw(ctx) { + ctx.save(); + + // ============================================================== + // 1. ORGANIC CLOUD SHADOWS CAST ONTO THE MEADOW GROUND + // Realistic multi-lobed cloud silhouettes drifting over the grass + // ============================================================== + const shadowAlpha = 0.07 + this.cloudDensity * 0.16 + this.rainIntensity * 0.12; + ctx.fillStyle = `rgba(10, 26, 14, ${shadowAlpha})`; + + for (let i = 0; i < this.clouds.length; i++) { + const cloud = this.clouds[i]; + // Ground shadow projection: offset down-right from light source with subtle vertical perspective squash + const sx = cloud.x + 35; + const sy = Math.min(this.height - 15, cloud.y + cloud.shadowOffsetY); + + ctx.beginPath(); + for (let j = 0; j < cloud.lobes.length; j++) { + const lobe = cloud.lobes[j]; + const lx = sx + lobe.x * cloud.scale; + const ly = sy + lobe.y * cloud.scale * 0.72; // perspective ground squash + const lr = lobe.r * cloud.scale; + ctx.moveTo(lx + lr, ly); + ctx.arc(lx, ly, lr, 0, Math.PI * 2); + } + ctx.fill(); + } + + // ============================================================== + // 2. ATMOSPHERIC AMBIENT TINT (Darkening when overcast or stormy) + // ============================================================== + if (this.rainIntensity > 0.02 || this.cloudDensity > 0.5) { + const darkFactor = this.rainIntensity * 0.32 + Math.max(0, this.cloudDensity - 0.5) * 0.15; + ctx.fillStyle = `rgba(16, 32, 44, ${darkFactor})`; + ctx.fillRect(0, 0, this.width, this.height); + } + + // ============================================================== + // 3. DRIFTING OVERHEAD CLOUDS (SKY ATMOSPHERE LAYER) + // Fluffy cumulus with 3D volumetric shading & highlighted rims + // ============================================================== + const cloudAlpha = 0.28 + this.cloudDensity * 0.55 + this.rainIntensity * 0.15; + const isStormy = this.rainIntensity > 0.35; + + for (let i = 0; i < this.clouds.length; i++) { + const cloud = this.clouds[i]; + + // A. Darker Underbelly / Base Shading (Gives the cloud volumetric depth) + ctx.fillStyle = isStormy + ? `rgba(65, 78, 92, ${cloudAlpha * 0.85})` + : `rgba(175, 190, 205, ${cloudAlpha * 0.55})`; + + ctx.beginPath(); + for (let j = 0; j < cloud.lobes.length; j++) { + const lobe = cloud.lobes[j]; + const lx = cloud.x + lobe.x * cloud.scale; + const ly = cloud.y + (lobe.y + 5) * cloud.scale; // subtle downward shadow offset + const lr = lobe.r * cloud.scale; + ctx.moveTo(lx + lr, ly); + ctx.arc(lx, ly, lr, 0, Math.PI * 2); + } + ctx.fill(); + + // B. Main Puffy Cloud Body (Soft billowing white / slate-grey storm) + ctx.fillStyle = isStormy + ? `rgba(115, 130, 146, ${cloudAlpha * 0.95})` + : `rgba(244, 248, 253, ${cloudAlpha * 0.88})`; + + ctx.beginPath(); + for (let j = 0; j < cloud.lobes.length; j++) { + const lobe = cloud.lobes[j]; + const lx = cloud.x + lobe.x * cloud.scale; + const ly = cloud.y + lobe.y * cloud.scale; + const lr = lobe.r * cloud.scale; + ctx.moveTo(lx + lr, ly); + ctx.arc(lx, ly, lr, 0, Math.PI * 2); + } + ctx.fill(); + + // C. Sunlit Rim / Top Crown Highlights on upper lobes + ctx.fillStyle = isStormy + ? `rgba(175, 192, 208, ${cloudAlpha * 0.5})` + : `rgba(255, 255, 255, ${cloudAlpha * 0.82})`; + + ctx.beginPath(); + for (let j = 0; j < cloud.lobes.length; j++) { + const lobe = cloud.lobes[j]; + if (lobe.y < 0) { // Crown of the cloud receives direct sunlight + const lx = cloud.x + lobe.x * cloud.scale; + const ly = cloud.y + (lobe.y - 3) * cloud.scale; + const lr = Math.max(2, (lobe.r - 4) * cloud.scale); + ctx.moveTo(lx + lr, ly); + ctx.arc(lx, ly, lr, 0, Math.PI * 2); + } + } + ctx.fill(); + } + + // ============================================================== + // 4. DIAGONAL TRANSLUCENT RAINDROPS + // ============================================================== + if (this.rainIntensity > 0.05) { + ctx.strokeStyle = `rgba(175, 215, 255, ${0.65 * this.rainIntensity})`; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + const activeDrops = Math.floor(this.maxDrops * this.rainIntensity); + for (let i = 0; i < activeDrops; i++) { + const drop = this.raindrops[i]; + const startX = drop.x; + const startY = drop.y; + const endX = startX - drop.length * 0.28; + const endY = startY + drop.length; + + ctx.moveTo(startX, startY); + ctx.lineTo(endX, endY); + } + ctx.stroke(); + } + + ctx.restore(); + } +}