feat: add LifeSimulator ecosystem simulation with plants, animals, and weather systems
This commit is contained in:
commit
32c3d9c5bc
15 changed files with 4696 additions and 0 deletions
95
README.md
Normal file
95
README.md
Normal 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
811
css/style.css
Normal 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
406
index.html
Normal 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">×</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
127
js/config.js
Normal 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
218
js/entities/animal.js
Normal 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
181
js/entities/plant.js
Normal 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
248
js/entities/sheep.js
Normal 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
307
js/entities/wolf.js
Normal 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
68
js/main.js
Normal 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
253
js/particles.js
Normal 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
620
js/simulation.js
Normal 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
107
js/state.js
Normal 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
286
js/terrain.js
Normal 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
575
js/ui.js
Normal 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
394
js/weather.js
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue