feat: add LifeSimulator ecosystem simulation with plants, animals, and weather systems

This commit is contained in:
Ben Miller 2026-10-20 11:51:26 -05:00
commit 32c3d9c5bc
15 changed files with 4696 additions and 0 deletions

95
README.md Normal file
View file

@ -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`**.

811
css/style.css Normal file
View file

@ -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);
}

406
index.html Normal file
View file

@ -0,0 +1,406 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ecosystem Life Simulator — Wolves, Sheep & Flora</title>
<meta name="description" content="A pixelated life simulator ecosystem featuring sheep grazing, wolf predation, carcass decomposition, floral blooms, and weather cycles.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="app-layout">
<!-- Top Navigation & Global Header -->
<header class="top-nav">
<div class="brand">
<div class="logo-icon">🌿</div>
<div>
<h1>Life Simulator</h1>
<span class="tagline">Ecological Flora & Fauna Simulation</span>
</div>
</div>
<!-- Live Global Metrics Badges -->
<div class="stats-ribbon">
<div class="stat-badge badge-sheep" title="Active Sheep Population">
<span class="badge-icon">🐑</span>
<span class="badge-val" id="hudSheep">0</span>
<span class="badge-label">Sheep</span>
</div>
<div class="stat-badge badge-wolf" title="Active Wolf Population">
<span class="badge-icon">🐺</span>
<span class="badge-val" id="hudWolves">0</span>
<span class="badge-label">Wolves</span>
</div>
<div class="stat-badge badge-plant" title="Active Plants & Flower Tufts">
<span class="badge-icon">🌸</span>
<span class="badge-val" id="hudPlants">0</span>
<span class="badge-label">Plants</span>
</div>
<div class="stat-badge badge-weather" title="Current Atmosphere">
<span class="badge-val" id="hudWeather">☀️ Sunny</span>
</div>
<div class="stat-badge badge-time" title="Simulated Elapsed Time">
<span class="badge-icon">⏱️</span>
<span class="badge-val" id="hudTime">00:00</span>
</div>
</div>
<!-- Quick Action Controls -->
<div class="header-actions">
<button id="btnPlayPause" class="btn btn-primary" aria-label="Toggle Play/Pause">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<span>Play</span>
</button>
<button id="btnRestart" class="btn btn-secondary" title="Restart Simulation">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
<span>Restart</span>
</button>
<button id="btnFullscreen" class="btn btn-secondary" title="Fullscreen Screen Saver Mode">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
<span>Fullscreen</span>
</button>
</div>
</header>
<!-- Floating Fullscreen Exit Button (Screen Saver Mode) -->
<button id="btnExitFullscreen" class="btn-fullscreen-exit hidden" title="Exit Fullscreen & Show Controls">
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-14v3h3v2h-5V5h2z"/></svg>
<span>Show Controls</span>
</button>
<!-- Main Workspace Area -->
<div class="main-workspace">
<!-- Left Sidebar: Sliders & Controls -->
<aside class="sidebar-panel">
<div class="sidebar-scroll">
<!-- Simulation Speed -->
<div class="control-group">
<label class="group-title">Simulation Speed</label>
<div class="speed-selector">
<button class="speed-btn active" data-speed="1">1x</button>
<button class="speed-btn" data-speed="2">2x</button>
<button class="speed-btn" data-speed="4">4x</button>
</div>
</div>
<!-- Defaults & Persistence Action Bar -->
<div class="control-group config-actions-group">
<div class="group-title-row">
<span class="group-title">Preset Config</span>
</div>
<div class="preset-buttons-row">
<button id="btnSetDefaults" class="btn btn-sm btn-accent" title="Save current slider values as your custom defaults">💾 Save As Defaults</button>
<button id="btnDefaults" class="btn btn-sm btn-secondary" title="Reset sliders to defaults">↺ Reset</button>
<button id="btnCopyJson" class="btn btn-sm btn-secondary" title="Copy configuration as JSON">📋 Copy JSON</button>
</div>
<div id="toastMsg" class="toast-text hidden">Settings Saved!</div>
</div>
<!-- Continuous Screen Saver Mode -->
<div class="control-group continuous-group">
<div class="group-title-row">
<span class="group-title">📺 Continuous Mode</span>
<label class="toggle-switch" title="Endless screen saver: continuously spawns animals from screen edges">
<input type="checkbox" id="checkContinuous">
<span class="toggle-slider"></span>
</label>
</div>
<div class="group-subtitle">Spawns wolves & sheep from screen edges on random intervals for endless background play.</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🐑 Sheep Influx Interval</span>
<span class="slider-val" id="valContinuousSheep">14s</span>
</div>
<input type="range" id="sliderContinuousSheep" min="4" max="45" step="1" value="14">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🐺 Wolf Influx Interval</span>
<span class="slider-val" id="valContinuousWolf">32s</span>
</div>
<input type="range" id="sliderContinuousWolf" min="10" max="90" step="1" value="32">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🐾 Influx Wave Size</span>
<span class="slider-val" id="valContinuousGroup">3</span>
</div>
<input type="range" id="sliderContinuousGroup" min="1" max="5" step="1" value="3">
</div>
</div>
<!-- Section 1: Populations & Caps -->
<div class="control-group">
<div class="group-title-row">
<span class="group-title">Populations & Limits</span>
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🐑 Initial Sheep</span>
<span class="slider-val" id="valSheep">25</span>
</div>
<input type="range" id="sliderSheep" min="2" max="80" step="1" value="25">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🐺 Initial Wolves</span>
<span class="slider-val" id="valWolves">3</span>
</div>
<input type="range" id="sliderWolves" min="1" max="20" step="1" value="3">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🌿 Initial Plants</span>
<span class="slider-val" id="valPlants">35</span>
</div>
<input type="range" id="sliderPlants" min="5" max="100" step="1" value="35">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🛡️ Max Plant Cap (CPU Guard)</span>
<span class="slider-val" id="valMaxPlantCap">90</span>
</div>
<input type="range" id="sliderMaxPlantCap" min="20" max="300" step="5" value="90">
</div>
</div>
<!-- Section 2: Sheep Dynamics -->
<div class="control-group">
<div class="group-title-row">
<span class="group-title">🐑 Sheep Grazing & Herd</span>
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🍽️ Hunger Depletion Rate</span>
<span class="slider-val" id="valSheepHunger">1.2/s</span>
</div>
<input type="range" id="sliderSheepHunger" min="0.2" max="3.0" step="0.1" value="1.2">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">📏 Grazing Reach</span>
<span class="slider-val" id="valSheepReach">18px</span>
</div>
<input type="range" id="sliderSheepReach" min="10" max="35" step="1" value="18">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">⏱️ Mating Cooldown</span>
<span class="slider-val" id="valSheepMating">22s</span>
</div>
<input type="range" id="sliderSheepMating" min="10" max="60" step="2" value="22">
</div>
</div>
<!-- Section 3: Wolf Dynamics & Combat Exertion -->
<div class="control-group">
<div class="group-title-row">
<span class="group-title">🐺 Wolf Predation & Exertion</span>
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">⚡ Lunge Health Cost (Exertion)</span>
<span class="slider-val" id="valWolfLunge">7 HP</span>
</div>
<input type="range" id="sliderWolfLunge" min="0" max="20" step="1" value="7">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🥩 Wolf Hunger Rate</span>
<span class="slider-val" id="valWolfHunger">1.3/s</span>
</div>
<input type="range" id="sliderWolfHunger" min="0.5" max="3.5" step="0.1" value="1.3">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🩸 Attack Bite Damage</span>
<span class="slider-val" id="valWolfDamage">38</span>
</div>
<input type="range" id="sliderWolfDamage" min="15" max="80" step="5" value="38">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">⏱️ Wolf Mating Cooldown</span>
<span class="slider-val" id="valWolfMating">35s</span>
</div>
<input type="range" id="sliderWolfMating" min="15" max="80" step="5" value="35">
</div>
</div>
<!-- Section 4: Flora & Atmosphere -->
<div class="control-group">
<div class="group-title-row">
<span class="group-title">🌸 Flora & Environment</span>
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🌾 Seed Spread Chance</span>
<span class="slider-val" id="valSeedRate">8%</span>
</div>
<input type="range" id="sliderSeedRate" min="0" max="40" step="1" value="8">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">☀️ Plant Withering Speed</span>
<span class="slider-val" id="valDryRate">2.2/s</span>
</div>
<input type="range" id="sliderDryRate" min="0.5" max="6.0" step="0.5" value="2.2">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">🌧️ Rain Frequency</span>
<span class="slider-val" id="valRain">45%</span>
</div>
<input type="range" id="sliderRain" min="0" max="100" step="5" value="45">
</div>
<div class="slider-item">
<div class="slider-header">
<span class="slider-title">💀 Carcass Decay Speed</span>
<span class="slider-val" id="valDecay">1.0x</span>
</div>
<input type="range" id="sliderDecay" min="0.2" max="3.0" step="0.2" value="1.0">
</div>
<button id="btnRain" class="btn btn-action-weather" title="Summon an immediate rain shower">
<span>🌧️ Make It Rain Now!</span>
</button>
</div>
<!-- Interaction / Click Spawn Tools -->
<div class="control-group">
<label class="group-title">Field Click Tool</label>
<div class="tools-grid">
<button class="tool-btn active" data-tool="inspect" title="Click any entity on field to inspect detailed biology">
🔍 Inspect
</button>
<button class="tool-btn" data-tool="sheep" title="Click meadow to spawn a Sheep">
🐑 Sheep
</button>
<button class="tool-btn" data-tool="wolf" title="Click meadow to spawn a Wolf">
🐺 Wolf
</button>
<button class="tool-btn" data-tool="plant" title="Click meadow to plant Grass">
🌿 Grass
</button>
<button class="tool-btn" data-tool="flower" title="Click meadow to plant a Flower Tuft">
🌸 Flower
</button>
</div>
</div>
<!-- Live Population Dynamics Mini-Chart -->
<div class="control-group">
<div class="chart-header">
<span class="group-title">Population Curve</span>
<div class="chart-legend-mini">
<span class="leg-dot sheep-dot"></span> Sheep
<span class="leg-dot wolf-dot"></span> Wolves
<span class="leg-dot plant-dot"></span> Plants
</div>
</div>
<div class="mini-chart-wrapper">
<canvas id="miniChart" width="280" height="90"></canvas>
</div>
</div>
</div>
</aside>
<!-- Center: Simulation Canvas Arena -->
<main class="canvas-viewport">
<div class="canvas-container">
<canvas id="simCanvas"></canvas>
<!-- Floating Entity Inspector Card -->
<div id="inspectorPanel" class="inspector-card hidden">
<button id="btnCloseInspector" class="insp-close">&times;</button>
<div id="inspectorDetails"></div>
</div>
</div>
</main>
</div>
<!-- Extinction Event Modal Overlay -->
<div id="extinctionModal" class="modal-backdrop hidden">
<div class="modal-card">
<div class="modal-header">
<div class="modal-badge-skull">💀</div>
<h2>Extinction Event</h2>
<p class="modal-subtitle">All active animal species have perished. The meadow has fallen quiet.</p>
</div>
<!-- History Chart -->
<div class="modal-chart-section">
<h3>Ecosystem Population Timeline</h3>
<div class="extinction-chart-wrapper">
<canvas id="extinctionChart" width="620" height="220"></canvas>
</div>
</div>
<!-- Metric Summary Cards -->
<div class="stats-grid">
<div class="stat-card">
<span class="sc-label">Time Survived</span>
<span class="sc-val" id="extTimeSurv">0m 00s</span>
</div>
<div class="stat-card">
<span class="sc-label">Peak Populations</span>
<div class="sc-subvals">
<span>🐑 <strong id="extPeakSheep">0</strong></span>
<span>🐺 <strong id="extPeakWolves">0</strong></span>
<span>🌿 <strong id="extPeakPlants">0</strong></span>
</div>
</div>
<div class="stat-card">
<span class="sc-label">Total Born</span>
<span class="sc-val" id="extTotalBorn">0</span>
</div>
<div class="stat-card">
<span class="sc-label">Predation & Mortality</span>
<span class="sc-val" id="extWolfKills">0</span>
</div>
<div class="stat-card">
<span class="sc-label">Decay Fertilizer Blooms</span>
<span class="sc-val" id="extDecayBlooms">0</span>
</div>
<div class="stat-card">
<span class="sc-label">Airborne Seeds Sprouted</span>
<span class="sc-val" id="extSeeds">0</span>
</div>
</div>
<!-- Modal Actions -->
<div class="modal-actions">
<button id="btnModalRestart" class="btn btn-primary btn-lg">Restart Simulation</button>
<button id="btnModalReview" class="btn btn-secondary btn-lg">Review Meadow Field</button>
</div>
</div>
</div>
</div>
<!-- Application Entry Script (ES Modules) -->
<script type="module" src="js/main.js"></script>
</body>
</html>

127
js/config.js Normal file
View file

@ -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();

218
js/entities/animal.js Normal file
View file

@ -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;
}
}

181
js/entities/plant.js Normal file
View file

@ -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();
}
}

248
js/entities/sheep.js Normal file
View file

@ -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();
}
}

307
js/entities/wolf.js Normal file
View file

@ -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();
}
}

68
js/main.js Normal file
View file

@ -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);
});

253
js/particles.js Normal file
View file

@ -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,
});
}
}

620
js/simulation.js Normal file
View file

@ -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();
}
}
}

107
js/state.js Normal file
View file

@ -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++;
}
}

286
js/terrain.js Normal file
View file

@ -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);
}
}
}

575
js/ui.js Normal file
View file

@ -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 = `
<div class="insp-title">${icon} ${name}</div>
<div class="insp-row"><span>Health:</span> <span>${Math.round(e.health)}/100</span></div>
<div class="insp-row"><span>Hunger:</span> <span>${Math.round(e.hunger)}/100</span></div>
<div class="insp-row"><span>State:</span> <span class="capitalize">${e.actionState}</span></div>
<div class="insp-row"><span>Age:</span> <span>${Math.round(e.age)}s</span></div>
${e.isBaby ? `<div class="insp-row"><span>Food for Adult:</span> <span>${e.foodEatenAsBaby}/3 meals</span></div>` : ''}
`;
} else {
const isFlower = e.type === 'flower';
html = `
<div class="insp-title">${isFlower ? '🌸 Dense Flower Tuft' : '🌿 Grass Tuft'}</div>
<div class="insp-row"><span>Moisture:</span> <span>${Math.round(e.moisture)}%</span></div>
<div class="insp-row"><span>Growth:</span> <span>${Math.round(e.growth * 100)}%</span></div>
<div class="insp-row"><span>Age:</span> <span>${Math.round(e.age)}s</span></div>
`;
}
this.inspectorDetails.innerHTML = html;
}
updatePlayPauseButton() {
if (this.sim.isRunning) {
this.btnPlayPause.innerHTML = `
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<span>Pause</span>
`;
this.btnPlayPause.classList.add('btn-active');
} else {
this.btnPlayPause.innerHTML = `
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<span>Play</span>
`;
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);
}
}
}

394
js/weather.js Normal file
View file

@ -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();
}
}