Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- DESIGN_REFERENCE.md +709 -0
- demo.html +1414 -0
- evals_hf.ipynb +114 -20
- frontend/README.md +93 -73
- frontend/dist/assets/index-8TVqpTW-.css +1 -0
- frontend/dist/assets/index-CWSK8ZuR.js +0 -0
- frontend/dist/index.html +17 -14
- frontend/index.html +16 -13
- frontend/src/App.css +581 -245
- frontend/src/App.tsx +265 -193
- frontend/src/components/APIReport.tsx +7 -8
- frontend/src/components/ControlPanel.tsx +62 -40
- frontend/src/components/HUD.tsx +127 -33
- frontend/src/components/Map2D.tsx +429 -485
- frontend_new_desgin/index.html +449 -0
- frontend_new_desgin/js/app.js +535 -0
- frontend_new_desgin/js/renderer.js +707 -0
- frontend_new_desgin/js/sim.js +521 -0
- openenv_pyre_env.egg-info/PKG-INFO +12 -0
- openenv_pyre_env.egg-info/SOURCES.txt +2 -0
- openenv_pyre_env.egg-info/requires.txt +13 -0
- pyproject.toml +13 -0
- server/static/assets/index-8TVqpTW-.css +1 -0
- server/static/assets/index-CWSK8ZuR.js +0 -0
- server/static/index.html +17 -14
- train_grpo_openenv.py +75 -94
- uv.lock +0 -0
DESIGN_REFERENCE.md
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pyre — Design Reference for React Implementation
|
| 2 |
+
|
| 3 |
+
> Extracted from `frontend_new_desgin/index.html`, `js/renderer.js`, `js/sim.js`, and `js/app.js`.
|
| 4 |
+
> Goal: 16 × 16 grid with per-agent **footprint trail** and **real-time health arc**.
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Design Tokens (CSS Custom Properties → JS constants)
|
| 9 |
+
|
| 10 |
+
```ts
|
| 11 |
+
// Surfaces
|
| 12 |
+
const SURFACE = {
|
| 13 |
+
bg: '#f5f2ed', // page background — warm off-white
|
| 14 |
+
panel: '#ffffff', // card / side panel
|
| 15 |
+
inset: '#faf9f6', // subtle inset (chart bg)
|
| 16 |
+
hover: '#f0ede8',
|
| 17 |
+
border: '#e4dfd7',
|
| 18 |
+
borderStrong: '#cdc7be',
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
// Text
|
| 22 |
+
const TEXT = {
|
| 23 |
+
primary: '#1c1712',
|
| 24 |
+
secondary: '#6b6460',
|
| 25 |
+
muted: '#a8a29e',
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// Semantic
|
| 29 |
+
const SEMANTIC = {
|
| 30 |
+
fire: '#c2410c',
|
| 31 |
+
fireBg: 'rgba(194,65,12,0.08)',
|
| 32 |
+
blue: '#1d4ed8',
|
| 33 |
+
blueBg: 'rgba(29,78,216,0.08)',
|
| 34 |
+
green: '#166534',
|
| 35 |
+
greenBg: 'rgba(22,101,52,0.08)',
|
| 36 |
+
amber: '#92400e',
|
| 37 |
+
amberBg: 'rgba(146,64,14,0.08)',
|
| 38 |
+
red: '#b91c1c',
|
| 39 |
+
redBg: 'rgba(185,28,28,0.08)',
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Canvas dark-world palette (inside the grid canvas)
|
| 43 |
+
const CANVAS_COLORS = {
|
| 44 |
+
bg: '#1e242c', // dark slate background
|
| 45 |
+
wallBase: '#4f443b',
|
| 46 |
+
floorVis: '#302620',
|
| 47 |
+
obstBase: '#140d0a',
|
| 48 |
+
exitBase: '#062210',
|
| 49 |
+
exitGlow: '#22c55e',
|
| 50 |
+
agentCore: '#0ea5e9', // sky-blue (healthy)
|
| 51 |
+
agentGlow: '#38bdf8',
|
| 52 |
+
agentBright:'#e0f2fe',
|
| 53 |
+
fogSeen: 'rgba(30,36,44,0.30)', // explored but out of view
|
| 54 |
+
fogUnseen: 'rgba(20,24,30,0.95)', // never seen
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
// Typography
|
| 58 |
+
const FONTS = {
|
| 59 |
+
mono: "'DM Mono', 'JetBrains Mono', monospace",
|
| 60 |
+
sans: "'DM Sans', 'Inter', system-ui, sans-serif",
|
| 61 |
+
};
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## 2. Overall Layout
|
| 67 |
+
|
| 68 |
+
```
|
| 69 |
+
┌──────────────────────── 44px topbar ─────────────────────────┐
|
| 70 |
+
│ 🔥 Pyre | Demo/Live | Easy/Medium/Hard [Live chip] │
|
| 71 |
+
├─────────────────────────────────┬────────────────────────────┤
|
| 72 |
+
│ │ 320px side panel │
|
| 73 |
+
│ canvas-zone (centered) │ ─ Episode stats │
|
| 74 |
+
│ aspect-ratio: 1 │ ─ Reward chart │
|
| 75 |
+
│ max-size fills available │ ─ Fire size chart │
|
| 76 |
+
│ space after header+footer │ ─ Action histogram │
|
| 77 |
+
│ │ ─ Event log │
|
| 78 |
+
├──────────────────────── 48px botbar ─────────────────────────┤
|
| 79 |
+
│ ⏮ ▶ ⏭ ↺ | ½× 1× 2× 4× 8× | Seed [42] kbd hints │
|
| 80 |
+
└──────────────────────────────────────────────────────────────┘
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Key layout CSS
|
| 84 |
+
```css
|
| 85 |
+
.shell {
|
| 86 |
+
display: grid;
|
| 87 |
+
grid-template-rows: 44px 1fr 48px;
|
| 88 |
+
height: 100vh;
|
| 89 |
+
min-width: 1280px;
|
| 90 |
+
}
|
| 91 |
+
.content {
|
| 92 |
+
display: grid;
|
| 93 |
+
grid-template-columns: 1fr 320px;
|
| 94 |
+
}
|
| 95 |
+
/* Canvas aspect-ratio 1:1, fills available area */
|
| 96 |
+
.canvas-frame {
|
| 97 |
+
aspect-ratio: 1;
|
| 98 |
+
max-width: min(calc(100vh - 140px), calc(100vw - 360px));
|
| 99 |
+
border-radius: 8px;
|
| 100 |
+
box-shadow: 0 0 0 1px #cdc7be, 0 20px 60px rgba(0,0,0,.18), 0 4px 12px rgba(0,0,0,.10);
|
| 101 |
+
}
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## 3. The 16 × 16 Grid
|
| 107 |
+
|
| 108 |
+
### Cell Types (integer constants)
|
| 109 |
+
| Value | Name | Description |
|
| 110 |
+
|-------|-------------|-------------|
|
| 111 |
+
| 0 | `FLOOR` | Walkable tile |
|
| 112 |
+
| 1 | `WALL` | Impassable solid |
|
| 113 |
+
| 2 | `DOOR_OPEN` | Open doorway |
|
| 114 |
+
| 3 | `DOOR_CLOSED`| Closed door (blocks movement & slows fire) |
|
| 115 |
+
| 4 | `EXIT` | Evacuation point |
|
| 116 |
+
| 5 | `OBSTACLE` | Charred / impassable ruin |
|
| 117 |
+
|
| 118 |
+
### Grid storage
|
| 119 |
+
```ts
|
| 120 |
+
// Flat array, row-major: index = y * W + x
|
| 121 |
+
const cellGrid = new Array(16 * 16); // cell types
|
| 122 |
+
const fireGrid = new Float32Array(256); // 0–1 fire intensity
|
| 123 |
+
const smokeGrid = new Float32Array(256);// 0–1 smoke density
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### Cell pixel size
|
| 127 |
+
```ts
|
| 128 |
+
const CANVAS_SIZE = 576; // px (the <canvas> width & height)
|
| 129 |
+
const GRID = 16;
|
| 130 |
+
const cellSize = Math.floor(CANVAS_SIZE / GRID); // = 36px per cell
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## 4. Per-Cell Visual Design
|
| 136 |
+
|
| 137 |
+
### FLOOR tile
|
| 138 |
+
```
|
| 139 |
+
Background: #302620 (dark warm brown)
|
| 140 |
+
Checkered accent (even x+y): rgba(255,248,230, 0.035)
|
| 141 |
+
Grout lines (top edge + left edge of each cell):
|
| 142 |
+
color: rgba(0,0,0,0.3), 1px wide
|
| 143 |
+
Inner corner detail:
|
| 144 |
+
2px inset shadow strip rgba(0,0,0,0.06) at top-left
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### WALL tile *(pseudo-3D isometric look)*
|
| 148 |
+
```
|
| 149 |
+
3D extrusion height: 8px upward
|
| 150 |
+
Front shadow face (bottom): rgba(0,0,0,0.60), height 8px
|
| 151 |
+
Right-side drop shadow: rgba(0,0,0,0.30), width 3px
|
| 152 |
+
Top face: #4f443b
|
| 153 |
+
Top catch-light (top edge 3px): rgba(255,248,232, 0.25)
|
| 154 |
+
Left highlight (2px strip): rgba(255,248,232, 0.15)
|
| 155 |
+
Mortar texture lines (3 horizontal + vertical splits):
|
| 156 |
+
color: rgba(0,0,0,0.20), 1px
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### OBSTACLE tile *(charred ruin)*
|
| 160 |
+
```
|
| 161 |
+
3D elevation: 4px upward
|
| 162 |
+
Front face shadow: #0a0705
|
| 163 |
+
Top face: #140d0a
|
| 164 |
+
Diagonal char texture: 45° stripes rgba(0,0,0,0.6) every 5px
|
| 165 |
+
Ember edge glow (1px border): rgba(255,80,0, 0.35)
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
### EXIT tile
|
| 169 |
+
```
|
| 170 |
+
Base fill: #062210
|
| 171 |
+
Subtle green tint: rgba(34,197,94, 0.07) inset 2px
|
| 172 |
+
Animated outer glow ring:
|
| 173 |
+
pulse = 0.62 + 0.38 * sin(time * 2.8)
|
| 174 |
+
shadowBlur: cellSize * 1.1 * pulse
|
| 175 |
+
strokeStyle: #22c55e (blocked: #ef4444)
|
| 176 |
+
Center symbol: ⇥ (blocked: ✕) font bold 50% of cellSize
|
| 177 |
+
Corner bracket accents: rgba(74,222,128, 0.75)
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
### DOOR_CLOSED tile
|
| 181 |
+
```
|
| 182 |
+
Frame: #291508 (2px rails top/bottom, 2px stiles left/right)
|
| 183 |
+
Door body wood: #5c3621
|
| 184 |
+
Three horizontal panel lines: rgba(0,0,0,0.35) at 14%, 50%, 82% height
|
| 185 |
+
Panel inset shadow: rgba(0,0,0,0.13)
|
| 186 |
+
Doorknob: gold circle rgba(210,168,75,0.88), radius 2.5px at right center
|
| 187 |
+
```
|
| 188 |
+
|
| 189 |
+
### DOOR_OPEN tile
|
| 190 |
+
```
|
| 191 |
+
Dark passage: rgba(0,0,0,0.38)
|
| 192 |
+
Door panel pressed to left: #5c3621, width 4px
|
| 193 |
+
Top frame: #291508, height 2px
|
| 194 |
+
Faint blue passage glow: rgba(125,211,252,0.10)
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
---
|
| 198 |
+
|
| 199 |
+
## 5. Fire Rendering (layered radial gradients)
|
| 200 |
+
|
| 201 |
+
### Fire color ramp (`fireColor(t, alpha)`)
|
| 202 |
+
```
|
| 203 |
+
t=0.00→0.15: black → deep red #620600 → #ba0600
|
| 204 |
+
t=0.15→0.40: deep red → orange → #f63600
|
| 205 |
+
t=0.40→0.65: orange → yellow → #f6b200
|
| 206 |
+
t=0.65→0.85: yellow → bright → #ffcd29
|
| 207 |
+
t=0.85→1.00: bright → white-hot → #ffffd3
|
| 208 |
+
```
|
| 209 |
+
|
| 210 |
+
### Per-cell fire: 4 volumetric gradient layers
|
| 211 |
+
```
|
| 212 |
+
Each frame: flicker factor f1 = 0.82 + 0.18*sin(time*9.5 + x*2.7 + y*3.14)
|
| 213 |
+
Wind offsets: windDx = windVec[0] * cellSize * 0.35 * intensity
|
| 214 |
+
|
| 215 |
+
Layer 1 (bright core): radius=cs*0.35, alpha=0.98, offset=(dx*0.2, dy*0.2 - cs*0.1)
|
| 216 |
+
Layer 2 (inner flame): radius=cs*0.65, alpha=0.75, offset=(dx*0.5, dy*0.5)
|
| 217 |
+
Layer 3 (flame body): radius=cs*0.95, alpha=0.45, offset=(dx, dy)
|
| 218 |
+
Layer 4 (heat glow): radius=cs*1.40, alpha=0.18, offset=(dx*1.5, dy*1.5)
|
| 219 |
+
|
| 220 |
+
Blend mode: 'screen' (Canvas2D globalCompositeOperation)
|
| 221 |
+
|
| 222 |
+
White-hot core when intensity > 0.48:
|
| 223 |
+
radius=cs*0.17, rgba(255,255,230, (t-0.48)*1.7) → transparent
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### Ambient fire glow (normal blend, under everything)
|
| 227 |
+
```
|
| 228 |
+
Applied when fire ≥ 0.28
|
| 229 |
+
Large radial gradient on floor:
|
| 230 |
+
radius = cellSize * (2.8 + fire * 2.0)
|
| 231 |
+
center: rgba(255,100,10, fire*0.65)
|
| 232 |
+
mid: rgba(200,60,0, fire*0.325)
|
| 233 |
+
edge: transparent
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## 6. Smoke Rendering
|
| 239 |
+
|
| 240 |
+
```
|
| 241 |
+
Smoke color: rgb(200, 210, 220) — bright thick white/grey
|
| 242 |
+
Animated offset:
|
| 243 |
+
phase[x,y] = random seed per cell (0..2π)
|
| 244 |
+
dx = sin(time*0.35 + phase) * 1.8
|
| 245 |
+
dy = cos(time*0.28 + phase + 1.2) * 1.8
|
| 246 |
+
|
| 247 |
+
Base puff:
|
| 248 |
+
radialGradient at (center + offset), radius = cellSize * 0.85
|
| 249 |
+
alpha = min(0.85, smoke * 0.95) → 0 at edge
|
| 250 |
+
|
| 251 |
+
Secondary density puff (when smoke > 0.3):
|
| 252 |
+
radius = cellSize * 0.60
|
| 253 |
+
offset orthogonal to primary: (dy*1.5, dx*1.5)
|
| 254 |
+
lighter tint rgb(215,225,240), alpha*0.5
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## 7. Fog of War
|
| 260 |
+
|
| 261 |
+
```
|
| 262 |
+
Visibility radius (BFS from agent position):
|
| 263 |
+
smoke ≤ 0.2 → radius 5 cells
|
| 264 |
+
smoke 0.2–0.5 → radius 3 cells
|
| 265 |
+
smoke > 0.5 → radius 2 cells
|
| 266 |
+
|
| 267 |
+
Rendering (drawn over all cells outside visible set):
|
| 268 |
+
Explored but not currently visible: rgba(30,36,44, 0.30) [semi-transparent]
|
| 269 |
+
Never explored: rgba(20,24,30, 0.95) [nearly opaque]
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
## 8. Agent Rendering — Body
|
| 275 |
+
|
| 276 |
+
```tsx
|
| 277 |
+
// All coordinates: agentPixelX = agentX * cellSize + cellSize/2
|
| 278 |
+
|
| 279 |
+
const pulse = 0.82 + 0.18 * sin(time * 3.6) // slow body pulse
|
| 280 |
+
const pulse2 = 0.70 + 0.30 * sin(time * 2.1 + 1.0) // aura pulse
|
| 281 |
+
const r = cellSize * 0.38 * pulse // body radius
|
| 282 |
+
|
| 283 |
+
// Color by health ratio (health/100):
|
| 284 |
+
health > 60% → #0ea5e9 (sky blue)
|
| 285 |
+
health > 30% → #f59e0b (amber)
|
| 286 |
+
health ≤ 30% → #ef4444 (red)
|
| 287 |
+
evacuated → #4ade80 (green)
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
### Drawing order (bottom to top):
|
| 291 |
+
1. **Outer aura** — faint radial gradient, radius `cellSize*0.74*pulse2`
|
| 292 |
+
- center→edge: transparent → `rgba(56,189,248,0.09)` → transparent
|
| 293 |
+
2. **Drop shadow** — dark ellipse offset (+2, +3), width `r*0.9`, height `r*0.48`
|
| 294 |
+
3. **Glow** — `ctx.shadowBlur = cellSize*0.90`, `shadowColor = agentColor`
|
| 295 |
+
4. **Body sphere** — radial gradient from highlight offset `(-r*0.28, -r*0.28)`:
|
| 296 |
+
```
|
| 297 |
+
stop 0.00: #ffffff (specular highlight)
|
| 298 |
+
stop 0.32: #e0f2fe (bright rim)
|
| 299 |
+
stop 0.70: agentColor (body color)
|
| 300 |
+
stop 1.00: rgba(0,80,160,0.55)
|
| 301 |
+
```
|
| 302 |
+
5. **Outline** — `rgba(255,255,255,0.88)`, lineWidth 1.5
|
| 303 |
+
|
| 304 |
+
---
|
| 305 |
+
|
| 306 |
+
## 9. Agent Health Arc Ring ← **Key feature**
|
| 307 |
+
|
| 308 |
+
Drawn around the agent body every frame:
|
| 309 |
+
|
| 310 |
+
```tsx
|
| 311 |
+
const arcRadius = bodyRadius * 1.65;
|
| 312 |
+
|
| 313 |
+
// Track (always visible, faint):
|
| 314 |
+
ctx.arc(px, py, arcRadius, 0, Math.PI*2);
|
| 315 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
| 316 |
+
ctx.lineWidth = 2.5;
|
| 317 |
+
|
| 318 |
+
// Filled arc (health portion, clockwise from top):
|
| 319 |
+
const startAngle = -Math.PI / 2; // 12 o'clock
|
| 320 |
+
const endAngle = startAngle + (health/100) * Math.PI * 2;
|
| 321 |
+
ctx.arc(px, py, arcRadius, startAngle, endAngle);
|
| 322 |
+
ctx.lineCap = 'round';
|
| 323 |
+
ctx.lineWidth = 3.0;
|
| 324 |
+
|
| 325 |
+
// Arc color by health:
|
| 326 |
+
health > 60%: '#4ade80' (green)
|
| 327 |
+
health > 30%: '#fbbf24' (amber)
|
| 328 |
+
health ≤ 30%: '#f87171' (red)
|
| 329 |
+
|
| 330 |
+
// Arc glow (drawn twice):
|
| 331 |
+
ctx.shadowBlur = 8;
|
| 332 |
+
ctx.shadowColor = arcColor;
|
| 333 |
+
ctx.stroke(); // called twice for glow effect
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
**In React**, implement as an SVG circle or Canvas arc that updates every animation frame with `requestAnimationFrame`.
|
| 337 |
+
|
| 338 |
+
---
|
| 339 |
+
|
| 340 |
+
## 10. Agent Footprint Trail ← **Key feature**
|
| 341 |
+
|
| 342 |
+
The trail records the last **14 positions** of the agent.
|
| 343 |
+
|
| 344 |
+
```tsx
|
| 345 |
+
// agentTrail: Array<{px: number, py: number, t: number}>
|
| 346 |
+
// Updated every frame: push new position to front if moved > 1px
|
| 347 |
+
|
| 348 |
+
// Drawing (older = more transparent, smaller):
|
| 349 |
+
for (let i = 1; i < trail.length; i++) {
|
| 350 |
+
const alpha = (1 - i / trail.length) * 0.42; // fades out
|
| 351 |
+
const radius = Math.max(1.5, (1 - i / trail.length) * cellSize * 0.22);
|
| 352 |
+
|
| 353 |
+
ctx.beginPath();
|
| 354 |
+
ctx.arc(trail[i].px, trail[i].py, radius, 0, Math.PI * 2);
|
| 355 |
+
ctx.fillStyle = `rgba(14, 165, 233, ${alpha})`; // sky-blue trail dots
|
| 356 |
+
ctx.fill();
|
| 357 |
+
}
|
| 358 |
+
```
|
| 359 |
+
|
| 360 |
+
**Visual result**: A dotted comet-tail of fading sky-blue circles showing the agent's recent path. Each dot: 42% max opacity, fading to ~3% at the tail. Radius shrinks from `cellSize*0.22` to 1.5px.
|
| 361 |
+
|
| 362 |
+
**In React**: Maintain a `trailPositions` state array (length ≤ 14). Push current `[x,y]` each step. Render as absolutely-positioned `div` circles or SVG `<circle>` elements on top of the grid.
|
| 363 |
+
|
| 364 |
+
---
|
| 365 |
+
|
| 366 |
+
## 11. HUD Overlays (float above canvas)
|
| 367 |
+
|
| 368 |
+
### Top-Left HUD Card — Health
|
| 369 |
+
```
|
| 370 |
+
Background: rgba(14,10,8, 0.82)
|
| 371 |
+
Border: 1px rgba(255,255,255,0.10)
|
| 372 |
+
Border-radius: 6px
|
| 373 |
+
Padding: 8px 11px
|
| 374 |
+
Backdrop-filter: blur(8px)
|
| 375 |
+
Text color: #e8e0d8
|
| 376 |
+
|
| 377 |
+
Layout:
|
| 378 |
+
Row 1: [HP label] [progress bar 80px] [numeric value]
|
| 379 |
+
Row 2: [status text] [smoke level text right-aligned]
|
| 380 |
+
|
| 381 |
+
HP bar:
|
| 382 |
+
bg: rgba(255,255,255,0.08), height 4px, radius 2px
|
| 383 |
+
fill class "g" → background: #4ade80
|
| 384 |
+
fill class "m" → background: #fbbf24
|
| 385 |
+
fill class "c" → background: #f87171 + animation: blink 0.5s infinite alternate
|
| 386 |
+
@keyframes blink { to { opacity: 0.3 } }
|
| 387 |
+
|
| 388 |
+
Status text colors:
|
| 389 |
+
"Good" → #4ade80
|
| 390 |
+
"Moderate" → #fbbf24
|
| 391 |
+
"Low" → #fb923c
|
| 392 |
+
"Critical" → #f87171
|
| 393 |
+
|
| 394 |
+
Health thresholds:
|
| 395 |
+
> 75 → "Good"
|
| 396 |
+
> 50 → "Moderate"
|
| 397 |
+
> 25 → "Low"
|
| 398 |
+
≤ 25 → "Critical"
|
| 399 |
+
```
|
| 400 |
+
|
| 401 |
+
### Top-Right HUD Card — Step Counter
|
| 402 |
+
```
|
| 403 |
+
Same card style, text right-aligned
|
| 404 |
+
|
| 405 |
+
Step display: "42 / 150" color #93c5fd (light blue)
|
| 406 |
+
Progress bar: 90px wide, 3px high, background #60a5fa
|
| 407 |
+
Metadata line: "medium · small_office" color rgba(168,162,158,0.6)
|
| 408 |
+
```
|
| 409 |
+
|
| 410 |
+
### Bottom-Center Episode Banner
|
| 411 |
+
```
|
| 412 |
+
Position: absolute, bottom 14px, horizontally centered
|
| 413 |
+
Font: DM Mono 13px, font-weight 500
|
| 414 |
+
|
| 415 |
+
EVACUATED: bg rgba(21,128,61,0.80), border rgba(74,222,128,.3), text #dcfce7 → "✓ EVACUATED"
|
| 416 |
+
INCAPACITATED: bg rgba(153,27,27,0.80), border rgba(248,113,113,.3), text #fee2e2 → "✗ INCAPACITATED"
|
| 417 |
+
TIMEOUT: bg rgba(120,53,15,0.80), border rgba(251,191,36,.3), text #fef3c7 → "⏱ TIMEOUT"
|
| 418 |
+
```
|
| 419 |
+
|
| 420 |
+
### Bottom-Right Wind Rose
|
| 421 |
+
```
|
| 422 |
+
Position: absolute, bottom 10px, right 10px
|
| 423 |
+
Size: 68×68px canvas, circular shape
|
| 424 |
+
Background: rgba(14,10,8,0.80)
|
| 425 |
+
Border: 1px rgba(255,255,255,0.10)
|
| 426 |
+
border-radius: 50%
|
| 427 |
+
|
| 428 |
+
Compass ring: rgba(120,140,220,0.28), lineWidth 1.5
|
| 429 |
+
Cardinal labels N/E/S/W: rgba(160,172,232,0.55), 10.9px monospace
|
| 430 |
+
Arrow: rgba(251,191,36,0.95), lineWidth 2.5, arrowhead filled
|
| 431 |
+
Center label: current wind direction (e.g. "NE"), bold 12px, rgba(255,210,80,0.95)
|
| 432 |
+
CALM: "CALM" label, rgba(160,182,232,0.75)
|
| 433 |
+
```
|
| 434 |
+
|
| 435 |
+
---
|
| 436 |
+
|
| 437 |
+
## 12. Side Panel
|
| 438 |
+
|
| 439 |
+
```
|
| 440 |
+
Width: 320px
|
| 441 |
+
Background: #ffffff
|
| 442 |
+
Border-left: 1px #e4dfd7
|
| 443 |
+
Scrollbar: 3px wide, thumb #e4dfd7
|
| 444 |
+
```
|
| 445 |
+
|
| 446 |
+
### Section Header style
|
| 447 |
+
```css
|
| 448 |
+
font-family: DM Mono;
|
| 449 |
+
font-size: 9px;
|
| 450 |
+
font-weight: 500;
|
| 451 |
+
letter-spacing: 0.12em;
|
| 452 |
+
text-transform: uppercase;
|
| 453 |
+
color: #a8a29e;
|
| 454 |
+
```
|
| 455 |
+
|
| 456 |
+
### Stat Cards (2-column grid)
|
| 457 |
+
```
|
| 458 |
+
2 columns, gap 6px
|
| 459 |
+
Each card: bg #faf9f6, border 1px #e4dfd7, border-radius 6px, padding 8px 10px
|
| 460 |
+
Label: 10px, color #a8a29e
|
| 461 |
+
Value: DM Mono 14px 500, color varies:
|
| 462 |
+
fire → #c2410c
|
| 463 |
+
blue → #1d4ed8
|
| 464 |
+
amber → #92400e
|
| 465 |
+
```
|
| 466 |
+
|
| 467 |
+
### Stat values displayed
|
| 468 |
+
```
|
| 469 |
+
Reward: totalReward.toFixed(2) class=blue
|
| 470 |
+
Burned: percentBurned + "%" class=fire
|
| 471 |
+
Wind: windDir (e.g. "NE") class=amber
|
| 472 |
+
Humidity: humidity*100 + "%"
|
| 473 |
+
Spread ρ: pSpread.toFixed(2) color=fire
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
### Mini Line Charts
|
| 477 |
+
```
|
| 478 |
+
bg: #faf9f6
|
| 479 |
+
Grid lines: rgba(0,0,0,0.06), 0.5px, 4 horizontal rows
|
| 480 |
+
Line width: 1.8px
|
| 481 |
+
Endpoint dot: 3px radius circle, same color as line
|
| 482 |
+
Value label: 10px monospace, right-aligned above dot
|
| 483 |
+
|
| 484 |
+
Reward chart: color #1d4ed8, fill rgba(29,78,216,0.08)
|
| 485 |
+
Fire chart: color #c2410c, fill rgba(194,65,12,0.08)
|
| 486 |
+
```
|
| 487 |
+
|
| 488 |
+
### Action Distribution Histogram
|
| 489 |
+
```
|
| 490 |
+
Horizontal bars, labels on left (54px), bars from 60px
|
| 491 |
+
Bar height: floor((height-8) / numActions) - 2
|
| 492 |
+
Bar bg: rgba(0,0,0,0.05)
|
| 493 |
+
|
| 494 |
+
Colors:
|
| 495 |
+
move → #1d4ed8
|
| 496 |
+
door → #c2410c
|
| 497 |
+
wait → #7c3aed
|
| 498 |
+
look → #166534
|
| 499 |
+
|
| 500 |
+
Count label: right of bar, same color
|
| 501 |
+
```
|
| 502 |
+
|
| 503 |
+
### Event Log
|
| 504 |
+
```
|
| 505 |
+
Each row: flex, gap 7px, DM Mono 10px
|
| 506 |
+
Step number: 9px, min-width 22px, color #a8a29e
|
| 507 |
+
Text: 10px, color #6b6460, flex:1, line-height 1.4
|
| 508 |
+
Reward delta: 9px, min-width 32px, right-aligned
|
| 509 |
+
positive → #166534
|
| 510 |
+
negative → #b91c1c
|
| 511 |
+
|
| 512 |
+
Alarm rows (burnout events):
|
| 513 |
+
border-left: 2px #c2410c
|
| 514 |
+
background: rgba(194,65,12,0.08)
|
| 515 |
+
|
| 516 |
+
Row animation: @keyframes ein { from { opacity:0; translateY(-2px) } }
|
| 517 |
+
Hover: background #f0ede8
|
| 518 |
+
```
|
| 519 |
+
|
| 520 |
+
---
|
| 521 |
+
|
| 522 |
+
## 13. Ember Particle System
|
| 523 |
+
|
| 524 |
+
```
|
| 525 |
+
Max particles: 220
|
| 526 |
+
Spawned per frame from cells with fire ≥ 0.3 (9% chance per cell)
|
| 527 |
+
|
| 528 |
+
Each particle:
|
| 529 |
+
position: (cellCenter ± random*3)
|
| 530 |
+
velocity: upward cone (-π/2 ± 0.75rad), speed 0.4-1.4 + windInfluence*0.7
|
| 531 |
+
vertical acceleration: -0.012 (rises)
|
| 532 |
+
horizontal drag: *0.97 per frame
|
| 533 |
+
life: 1.0 → 0 at decay rate 0.010–0.025/frame
|
| 534 |
+
|
| 535 |
+
Types (60% ember / 40% spark):
|
| 536 |
+
ember: rgba(255, 80+100*life, 0, life*0.92)
|
| 537 |
+
spark: rgba(255, 210+45*life, 60*life, life*0.92)
|
| 538 |
+
|
| 539 |
+
Radius = particle.size (1.3–3.7) * life
|
| 540 |
+
```
|
| 541 |
+
|
| 542 |
+
---
|
| 543 |
+
|
| 544 |
+
## 14. React Implementation Guide
|
| 545 |
+
|
| 546 |
+
### Component Tree (suggested)
|
| 547 |
+
```
|
| 548 |
+
<PyreGrid> ← 16×16 canvas/div container
|
| 549 |
+
<GridCanvas> ← main Canvas2D renderer
|
| 550 |
+
<AgentLayer> ← SVG or div overlay for agent + trail
|
| 551 |
+
<FootprintTrail /> ← last 14 positions as fading dots
|
| 552 |
+
<AgentBody /> ← pulsing sphere
|
| 553 |
+
<HealthArcRing /> ← SVG arc ring around agent
|
| 554 |
+
<HUDOverlay position="tl"> ← health bar HUD card
|
| 555 |
+
<HUDOverlay position="tr"> ← step counter HUD card
|
| 556 |
+
<WindRose /> ← mini canvas, bottom-right
|
| 557 |
+
<EpisodeBanner /> ← bottom-center banner
|
| 558 |
+
```
|
| 559 |
+
|
| 560 |
+
### State shape
|
| 561 |
+
```ts
|
| 562 |
+
interface AgentState {
|
| 563 |
+
x: number; // grid column [0..15]
|
| 564 |
+
y: number; // grid row [0..15]
|
| 565 |
+
health: number; // 0–100
|
| 566 |
+
alive: boolean;
|
| 567 |
+
evacuated: boolean;
|
| 568 |
+
trail: { x: number; y: number }[]; // last 14 positions, [0]=newest
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
interface GridState {
|
| 572 |
+
cellGrid: number[]; // 256 ints — cell types
|
| 573 |
+
fireGrid: Float32Array; // 256 floats — 0..1
|
| 574 |
+
smokeGrid: Float32Array; // 256 floats — 0..1
|
| 575 |
+
visibleCells: Set<string>; // "x,y" keys
|
| 576 |
+
exploreSet: Set<string>; // "x,y" keys
|
| 577 |
+
}
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
### Health arc in React (SVG approach)
|
| 581 |
+
```tsx
|
| 582 |
+
const CELL = 36; // px
|
| 583 |
+
|
| 584 |
+
function HealthArcRing({ x, y, health }: { x: number; y: number; health: number }) {
|
| 585 |
+
const cx = x * CELL + CELL / 2;
|
| 586 |
+
const cy = y * CELL + CELL / 2;
|
| 587 |
+
const bodyR = CELL * 0.38;
|
| 588 |
+
const arcR = bodyR * 1.65;
|
| 589 |
+
const circumference = 2 * Math.PI * arcR;
|
| 590 |
+
const filled = (health / 100) * circumference;
|
| 591 |
+
|
| 592 |
+
const color = health > 60 ? '#4ade80' : health > 30 ? '#fbbf24' : '#f87171';
|
| 593 |
+
|
| 594 |
+
return (
|
| 595 |
+
<svg style={{ position:'absolute', top:0, left:0, pointerEvents:'none' }}
|
| 596 |
+
width={16*CELL} height={16*CELL}>
|
| 597 |
+
{/* Track */}
|
| 598 |
+
<circle cx={cx} cy={cy} r={arcR}
|
| 599 |
+
fill="none" stroke="rgba(255,255,255,0.07)" strokeWidth={2.5} />
|
| 600 |
+
{/* Arc — starts at 12 o'clock, clockwise */}
|
| 601 |
+
<circle cx={cx} cy={cy} r={arcR}
|
| 602 |
+
fill="none"
|
| 603 |
+
stroke={color} strokeWidth={3}
|
| 604 |
+
strokeLinecap="round"
|
| 605 |
+
strokeDasharray={`${filled} ${circumference}`}
|
| 606 |
+
transform={`rotate(-90, ${cx}, ${cy})`}
|
| 607 |
+
style={{ filter: `drop-shadow(0 0 4px ${color})`, transition: 'stroke-dasharray 0.3s' }}
|
| 608 |
+
/>
|
| 609 |
+
</svg>
|
| 610 |
+
);
|
| 611 |
+
}
|
| 612 |
+
```
|
| 613 |
+
|
| 614 |
+
### Footprint trail in React (SVG approach)
|
| 615 |
+
```tsx
|
| 616 |
+
function FootprintTrail({ trail }: { trail: { x: number; y: number }[] }) {
|
| 617 |
+
const CELL = 36;
|
| 618 |
+
return (
|
| 619 |
+
<svg style={{ position:'absolute', top:0, left:0, pointerEvents:'none' }}
|
| 620 |
+
width={16*CELL} height={16*CELL}>
|
| 621 |
+
{trail.slice(1).map((pos, i) => {
|
| 622 |
+
const total = trail.length;
|
| 623 |
+
const alpha = ((total - 1 - i) / total) * 0.42;
|
| 624 |
+
const radius = Math.max(1.5, ((total - 1 - i) / total) * CELL * 0.22);
|
| 625 |
+
const cx = pos.x * CELL + CELL / 2;
|
| 626 |
+
const cy = pos.y * CELL + CELL / 2;
|
| 627 |
+
return (
|
| 628 |
+
<circle key={i} cx={cx} cy={cy} r={radius}
|
| 629 |
+
fill={`rgba(14,165,233,${alpha.toFixed(3)})`} />
|
| 630 |
+
);
|
| 631 |
+
})}
|
| 632 |
+
</svg>
|
| 633 |
+
);
|
| 634 |
+
}
|
| 635 |
+
```
|
| 636 |
+
|
| 637 |
+
### Trail update logic
|
| 638 |
+
```ts
|
| 639 |
+
const MAX_TRAIL = 14;
|
| 640 |
+
|
| 641 |
+
function updateTrail(
|
| 642 |
+
trail: { x: number; y: number }[],
|
| 643 |
+
newX: number,
|
| 644 |
+
newY: number
|
| 645 |
+
): { x: number; y: number }[] {
|
| 646 |
+
const last = trail[0];
|
| 647 |
+
if (last && last.x === newX && last.y === newY) return trail;
|
| 648 |
+
return [{ x: newX, y: newY }, ...trail].slice(0, MAX_TRAIL);
|
| 649 |
+
}
|
| 650 |
+
```
|
| 651 |
+
|
| 652 |
+
### Animation loop pattern
|
| 653 |
+
```tsx
|
| 654 |
+
useEffect(() => {
|
| 655 |
+
let raf: number;
|
| 656 |
+
const loop = () => {
|
| 657 |
+
setTime(t => t + 0.016); // drives fire flicker, smoke drift, pulse
|
| 658 |
+
raf = requestAnimationFrame(loop);
|
| 659 |
+
};
|
| 660 |
+
raf = requestAnimationFrame(loop);
|
| 661 |
+
return () => cancelAnimationFrame(raf);
|
| 662 |
+
}, []);
|
| 663 |
+
```
|
| 664 |
+
|
| 665 |
+
---
|
| 666 |
+
|
| 667 |
+
## 15. Fire & Smoke Constants
|
| 668 |
+
|
| 669 |
+
```ts
|
| 670 |
+
const FIRE_IGNITION = 0.1; // newly ignited
|
| 671 |
+
const FIRE_BURNING = 0.3; // actively spreading
|
| 672 |
+
const FIRE_GAIN = 0.15; // per-step intensity increase
|
| 673 |
+
const BURNOUT_TICKS = 5; // steps at max before becoming obstacle
|
| 674 |
+
const SMOKE_SPREAD = 0.20; // fraction diffused per step
|
| 675 |
+
const SMOKE_DOOR_FACT = 0.40; // doors slow smoke
|
| 676 |
+
const SMOKE_DECAY = 0.02; // per-step natural decay
|
| 677 |
+
const EXIT_BLOCK_FIRE = 0.50; // fire level that blocks an exit
|
| 678 |
+
```
|
| 679 |
+
|
| 680 |
+
---
|
| 681 |
+
|
| 682 |
+
## 16. Tile Grid Overlay
|
| 683 |
+
|
| 684 |
+
A very subtle grid drawn on top of all layers:
|
| 685 |
+
```
|
| 686 |
+
strokeStyle: rgba(255,255,255,0.04)
|
| 687 |
+
lineWidth: 1
|
| 688 |
+
Pattern: full vertical + horizontal lines every cellSize px
|
| 689 |
+
```
|
| 690 |
+
|
| 691 |
+
---
|
| 692 |
+
|
| 693 |
+
## 17. Summary — What to Build in React
|
| 694 |
+
|
| 695 |
+
| Feature | Implementation |
|
| 696 |
+
|---------|---------------|
|
| 697 |
+
| 16×16 grid | CSS grid or Canvas, 36px cells (576px total) |
|
| 698 |
+
| Cell textures | Canvas2D `fillRect` with per-type colors |
|
| 699 |
+
| Fire effect | Radial gradients, 'screen' blend mode, animated `t` |
|
| 700 |
+
| Smoke drift | Radial gradient circles with animated offsets |
|
| 701 |
+
| Fog of war | Semi-transparent overlays driven by BFS visibility set |
|
| 702 |
+
| **Agent body** | Pulsing SVG circle with radial gradient |
|
| 703 |
+
| **Health arc ring** | SVG `<circle>` with `strokeDasharray` trick, color-coded |
|
| 704 |
+
| **Footprint trail** | SVG `<circle>` array, fade + shrink by index |
|
| 705 |
+
| HUD cards | `position:absolute` divs with `backdrop-filter: blur` |
|
| 706 |
+
| HP bar | `width` transition, color class switch (green/amber/red/blink) |
|
| 707 |
+
| Wind rose | Small Canvas2D or SVG compass |
|
| 708 |
+
| Mini charts | Canvas2D line chart with fill area |
|
| 709 |
+
| Event log | Scrolling list, alarm rows with fire border |
|
demo.html
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
| 6 |
+
<title>Pyre — Crisis Navigation Demo</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
--s0:#f5f2ed; --s1:#ffffff; --s2:#faf9f6; --s3:#f0ede8;
|
| 14 |
+
--bd:#e4dfd7; --bd2:#cdc7be;
|
| 15 |
+
--t1:#1c1712; --t2:#6b6460; --t3:#a8a29e;
|
| 16 |
+
--fire:#c2410c; --fire2:rgba(194,65,12,0.08);
|
| 17 |
+
--blue:#1d4ed8; --blue2:rgba(29,78,216,0.08);
|
| 18 |
+
--green:#166534; --green2:rgba(22,101,52,0.08);
|
| 19 |
+
--amber:#92400e; --amber2:rgba(146,64,14,0.08);
|
| 20 |
+
--red:#b91c1c; --red2:rgba(185,28,28,0.08);
|
| 21 |
+
--mono:'DM Mono','JetBrains Mono',monospace;
|
| 22 |
+
--sans:'DM Sans','Inter',system-ui,sans-serif;
|
| 23 |
+
--r:6px;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
html, body { height:100%; background:var(--s0); color:var(--t1); font-family:var(--sans); overflow:hidden; font-size:13px; }
|
| 27 |
+
|
| 28 |
+
/* ── Shell ── */
|
| 29 |
+
.shell { display:grid; grid-template-rows:44px 1fr 48px; height:100vh; min-width:1100px; }
|
| 30 |
+
|
| 31 |
+
/* ── Topbar ── */
|
| 32 |
+
.topbar {
|
| 33 |
+
background:var(--s1); border-bottom:1px solid var(--bd);
|
| 34 |
+
display:flex; align-items:center; padding:0 20px; gap:16px;
|
| 35 |
+
}
|
| 36 |
+
.brand { display:flex; align-items:center; gap:9px; }
|
| 37 |
+
.brand-icon {
|
| 38 |
+
width:28px; height:28px; border-radius:6px;
|
| 39 |
+
background:var(--fire); display:flex; align-items:center; justify-content:center;
|
| 40 |
+
font-size:14px; flex-shrink:0;
|
| 41 |
+
}
|
| 42 |
+
.brand-name { font-family:var(--mono); font-size:14px; font-weight:500; letter-spacing:.05em; }
|
| 43 |
+
.brand-sep { width:1px; height:18px; background:var(--bd); }
|
| 44 |
+
.brand-sub { font-size:11px; color:var(--t3); }
|
| 45 |
+
.topbar-sep { width:1px; height:20px; background:var(--bd); }
|
| 46 |
+
.topbar-right { margin-left:auto; display:flex; align-items:center; gap:8px; }
|
| 47 |
+
|
| 48 |
+
.seg { display:flex; border:1px solid var(--bd); border-radius:6px; overflow:hidden; background:var(--s2); }
|
| 49 |
+
.seg button {
|
| 50 |
+
background:none; border:none; padding:5px 11px;
|
| 51 |
+
font-family:var(--mono); font-size:11px; font-weight:500; color:var(--t2);
|
| 52 |
+
cursor:pointer; letter-spacing:.03em; transition:background .1s,color .1s;
|
| 53 |
+
}
|
| 54 |
+
.seg button:hover { background:var(--s3); color:var(--t1); }
|
| 55 |
+
.seg button.on { background:var(--s1); color:var(--t1); box-shadow:inset 0 1px 0 rgba(255,255,255,.8); }
|
| 56 |
+
.seg button.on.fire { color:var(--fire); }
|
| 57 |
+
.seg button + button { border-left:1px solid var(--bd); }
|
| 58 |
+
|
| 59 |
+
.live-chip {
|
| 60 |
+
font-family:var(--mono); font-size:10px; font-weight:500;
|
| 61 |
+
padding:3px 10px; border-radius:12px; border:1px solid; letter-spacing:.05em;
|
| 62 |
+
}
|
| 63 |
+
.live-chip.demo { border-color:var(--bd2); color:var(--t2); }
|
| 64 |
+
.live-chip.online { border-color:rgba(22,101,52,.4); color:var(--green); background:var(--green2); }
|
| 65 |
+
|
| 66 |
+
/* ── Content ── */
|
| 67 |
+
.content { display:grid; grid-template-columns:1fr 300px; overflow:hidden; min-height:0; }
|
| 68 |
+
|
| 69 |
+
/* ── Canvas zone ── */
|
| 70 |
+
.canvas-zone {
|
| 71 |
+
display:flex; align-items:center; justify-content:center;
|
| 72 |
+
background:var(--s0); padding:20px; position:relative; overflow:hidden;
|
| 73 |
+
}
|
| 74 |
+
.canvas-frame {
|
| 75 |
+
position:relative; border-radius:10px; overflow:hidden;
|
| 76 |
+
box-shadow:0 0 0 1px var(--bd2),0 24px 64px rgba(0,0,0,.20),0 6px 16px rgba(0,0,0,.12);
|
| 77 |
+
max-width:min(calc(100vh - 140px), calc(100vw - 340px));
|
| 78 |
+
max-height:calc(100vh - 140px);
|
| 79 |
+
aspect-ratio:1;
|
| 80 |
+
}
|
| 81 |
+
#mainCanvas { display:block; width:100%; height:100%; }
|
| 82 |
+
|
| 83 |
+
/* ── HUD overlays ── */
|
| 84 |
+
.hud-tl, .hud-tr { position:absolute; top:12px; pointer-events:none; z-index:2; }
|
| 85 |
+
.hud-tl { left:12px; }
|
| 86 |
+
.hud-tr { right:12px; }
|
| 87 |
+
.hud-card {
|
| 88 |
+
background:rgba(12,9,7,0.84);
|
| 89 |
+
border:1px solid rgba(255,255,255,0.10);
|
| 90 |
+
border-radius:8px; padding:9px 12px;
|
| 91 |
+
backdrop-filter:blur(10px); color:#e8e0d8;
|
| 92 |
+
min-width:140px;
|
| 93 |
+
}
|
| 94 |
+
.hud-row { display:flex; align-items:center; gap:8px; margin-bottom:5px; }
|
| 95 |
+
.hud-row:last-child { margin-bottom:0; }
|
| 96 |
+
.hlbl { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.8); letter-spacing:.10em; }
|
| 97 |
+
.hval { font-family:var(--mono); font-size:13px; font-weight:500; color:#f0e8e0; }
|
| 98 |
+
|
| 99 |
+
.hbar-bg { width:84px; height:5px; background:rgba(255,255,255,.08); border-radius:3px; overflow:hidden; flex:1; }
|
| 100 |
+
.hbar-fill { height:100%; border-radius:3px; transition:width .35s,background .35s; }
|
| 101 |
+
.hbar-fill.g { background:#4ade80; }
|
| 102 |
+
.hbar-fill.m { background:#fbbf24; }
|
| 103 |
+
.hbar-fill.c { background:#f87171; animation:hblink .5s infinite alternate; }
|
| 104 |
+
@keyframes hblink { to { opacity:.25 } }
|
| 105 |
+
|
| 106 |
+
.hstatus { font-family:var(--mono); font-size:9px; }
|
| 107 |
+
.hstatus.good { color:#4ade80; }
|
| 108 |
+
.hstatus.moderate { color:#fbbf24; }
|
| 109 |
+
.hstatus.low { color:#fb923c; }
|
| 110 |
+
.hstatus.critical { color:#f87171; }
|
| 111 |
+
|
| 112 |
+
.step-val { font-family:var(--mono); font-size:13px; font-weight:500; color:#93c5fd; }
|
| 113 |
+
.sbar-bg { width:100%; height:3px; background:rgba(255,255,255,.07); border-radius:2px; overflow:hidden; margin-top:5px; }
|
| 114 |
+
.sbar-fill { height:100%; border-radius:2px; background:#60a5fa; transition:width .35s; }
|
| 115 |
+
.step-meta { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.55); margin-top:3px; }
|
| 116 |
+
|
| 117 |
+
#epBanner {
|
| 118 |
+
display:none; position:absolute;
|
| 119 |
+
bottom:14px; left:50%; transform:translateX(-50%);
|
| 120 |
+
font-family:var(--mono); font-size:13px; font-weight:500;
|
| 121 |
+
letter-spacing:.04em; padding:10px 22px;
|
| 122 |
+
border-radius:8px; backdrop-filter:blur(10px);
|
| 123 |
+
pointer-events:none; white-space:nowrap; z-index:3;
|
| 124 |
+
}
|
| 125 |
+
#epBanner.ok { background:rgba(21,128,61,.82); border:1px solid rgba(74,222,128,.3); color:#dcfce7; }
|
| 126 |
+
#epBanner.bad { background:rgba(153,27,27,.82); border:1px solid rgba(248,113,113,.3); color:#fee2e2; }
|
| 127 |
+
#epBanner.tmo { background:rgba(120,53,15,.82); border:1px solid rgba(251,191,36,.3); color:#fef3c7; }
|
| 128 |
+
|
| 129 |
+
/* ── Legend badge ── */
|
| 130 |
+
.legend {
|
| 131 |
+
position:absolute; bottom:12px; left:12px; z-index:2; pointer-events:none;
|
| 132 |
+
display:flex; flex-direction:column; gap:4px;
|
| 133 |
+
}
|
| 134 |
+
.leg-row { display:flex; align-items:center; gap:5px; font-family:var(--mono); font-size:9px; color:rgba(220,210,200,.7); }
|
| 135 |
+
.leg-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
| 136 |
+
|
| 137 |
+
/* ── Side panel ── */
|
| 138 |
+
.side {
|
| 139 |
+
background:var(--s1); border-left:1px solid var(--bd);
|
| 140 |
+
overflow-y:auto; overflow-x:hidden;
|
| 141 |
+
scrollbar-width:thin; scrollbar-color:var(--bd) transparent;
|
| 142 |
+
display:flex; flex-direction:column;
|
| 143 |
+
}
|
| 144 |
+
::-webkit-scrollbar { width:3px; }
|
| 145 |
+
::-webkit-scrollbar-thumb { background:var(--bd); border-radius:2px; }
|
| 146 |
+
|
| 147 |
+
.side-sec { padding:14px 16px; border-bottom:1px solid var(--bd); }
|
| 148 |
+
.side-sec:last-child { border-bottom:none; flex:1; min-height:0; display:flex; flex-direction:column; }
|
| 149 |
+
.sec-hd {
|
| 150 |
+
font-family:var(--mono); font-size:9px; font-weight:500;
|
| 151 |
+
letter-spacing:.12em; text-transform:uppercase; color:var(--t3);
|
| 152 |
+
margin-bottom:10px; display:flex; align-items:center; justify-content:space-between;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.sg { display:grid; grid-template-columns:1fr 1fr; gap:6px; }
|
| 156 |
+
.sc { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); padding:8px 10px; }
|
| 157 |
+
.sc-l { font-size:10px; color:var(--t3); margin-bottom:2px; }
|
| 158 |
+
.sc-v { font-family:var(--mono); font-size:14px; font-weight:500; color:var(--t1); }
|
| 159 |
+
.sc-v.fire { color:var(--fire); }
|
| 160 |
+
.sc-v.blue { color:var(--blue); }
|
| 161 |
+
.sc-v.amber { color:var(--amber); }
|
| 162 |
+
|
| 163 |
+
.meta-row {
|
| 164 |
+
display:flex; justify-content:space-between; align-items:center;
|
| 165 |
+
margin-top:8px; padding-top:8px; border-top:1px solid var(--bd); font-size:11px;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* Health legend bar */
|
| 169 |
+
.health-legend { display:flex; gap:3px; margin-top:8px; }
|
| 170 |
+
.hl-seg { height:4px; border-radius:2px; flex:1; }
|
| 171 |
+
.hl-seg.g { background:#4ade80; }
|
| 172 |
+
.hl-seg.m { background:#fbbf24; }
|
| 173 |
+
.hl-seg.l { background:#fb923c; }
|
| 174 |
+
.hl-seg.c { background:#f87171; }
|
| 175 |
+
|
| 176 |
+
/* Sparkline chart */
|
| 177 |
+
.chart-wrap { background:var(--s2); border:1px solid var(--bd); border-radius:var(--r); overflow:hidden; margin-top:6px; }
|
| 178 |
+
.chart-wrap canvas { display:block; width:100% !important; }
|
| 179 |
+
|
| 180 |
+
/* Event log */
|
| 181 |
+
.elog { display:flex; flex-direction:column; gap:1px; overflow-y:auto; flex:1; }
|
| 182 |
+
.erow {
|
| 183 |
+
display:flex; align-items:baseline; gap:7px;
|
| 184 |
+
padding:3px 6px; border-radius:4px;
|
| 185 |
+
font-family:var(--mono); font-size:10px;
|
| 186 |
+
border-left:2px solid transparent;
|
| 187 |
+
animation:ein .15s ease;
|
| 188 |
+
}
|
| 189 |
+
@keyframes ein { from { opacity:0; transform:translateY(-2px) } }
|
| 190 |
+
.erow:hover { background:var(--s3); }
|
| 191 |
+
.erow.alarm { border-left-color:var(--fire); background:var(--fire2); }
|
| 192 |
+
.estep { color:var(--t3); min-width:22px; font-size:9px; }
|
| 193 |
+
.etext { color:var(--t2); flex:1; line-height:1.4; }
|
| 194 |
+
.erwd { font-size:9px; min-width:32px; text-align:right; }
|
| 195 |
+
.erwd.p { color:var(--green); }
|
| 196 |
+
.erwd.n { color:var(--red); }
|
| 197 |
+
|
| 198 |
+
/* ── Bottom bar ── */
|
| 199 |
+
.botbar {
|
| 200 |
+
background:var(--s1); border-top:1px solid var(--bd);
|
| 201 |
+
display:flex; align-items:center; padding:0 16px; gap:6px;
|
| 202 |
+
}
|
| 203 |
+
.bsep { width:1px; height:20px; background:var(--bd); margin:0 4px; }
|
| 204 |
+
.icn-btn {
|
| 205 |
+
width:30px; height:30px; display:flex; align-items:center; justify-content:center;
|
| 206 |
+
background:var(--s2); border:1px solid var(--bd); border-radius:var(--r);
|
| 207 |
+
color:var(--t2); font-size:12px; cursor:pointer;
|
| 208 |
+
transition:background .1s,color .1s,border-color .1s;
|
| 209 |
+
}
|
| 210 |
+
.icn-btn:hover { background:var(--s3); color:var(--t1); border-color:var(--bd2); }
|
| 211 |
+
.icn-btn:active { transform:scale(.94); }
|
| 212 |
+
.icn-btn.play { background:var(--fire2); border-color:rgba(194,65,12,.3); color:var(--fire); }
|
| 213 |
+
.icn-btn.play:hover { background:rgba(194,65,12,.14); }
|
| 214 |
+
|
| 215 |
+
.seed-wrap { display:flex; align-items:center; gap:6px; }
|
| 216 |
+
.seed-lbl { font-size:11px; color:var(--t3); }
|
| 217 |
+
#seedInput {
|
| 218 |
+
width:60px; background:var(--s2); border:1px solid var(--bd);
|
| 219 |
+
color:var(--t1); font-family:var(--mono); font-size:11px;
|
| 220 |
+
padding:4px 8px; border-radius:var(--r); outline:none;
|
| 221 |
+
}
|
| 222 |
+
#seedInput:focus { border-color:var(--bd2); }
|
| 223 |
+
.keys { margin-left:auto; display:flex; gap:12px; align-items:center; font-size:10px; color:var(--t3); }
|
| 224 |
+
.keys kbd {
|
| 225 |
+
background:var(--s2); border:1px solid var(--bd2); border-radius:3px;
|
| 226 |
+
padding:1px 5px; font-family:var(--mono); font-size:10px; color:var(--t2);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
/* Toast */
|
| 230 |
+
#toast {
|
| 231 |
+
position:fixed; bottom:58px; left:50%;
|
| 232 |
+
transform:translateX(-50%) translateY(6px);
|
| 233 |
+
background:var(--t1); color:var(--s0);
|
| 234 |
+
font-family:var(--mono); font-size:11px;
|
| 235 |
+
padding:7px 16px; border-radius:20px;
|
| 236 |
+
opacity:0; pointer-events:none; z-index:99;
|
| 237 |
+
transition:opacity .22s,transform .22s;
|
| 238 |
+
}
|
| 239 |
+
#toast.up { opacity:1; transform:translateX(-50%) translateY(0); }
|
| 240 |
+
</style>
|
| 241 |
+
</head>
|
| 242 |
+
<body>
|
| 243 |
+
<div class="shell">
|
| 244 |
+
|
| 245 |
+
<!-- ── Topbar ── -->
|
| 246 |
+
<header class="topbar">
|
| 247 |
+
<div class="brand">
|
| 248 |
+
<div class="brand-icon">🔥</div>
|
| 249 |
+
<span class="brand-name">Pyre</span>
|
| 250 |
+
<div class="brand-sep"></div>
|
| 251 |
+
<span class="brand-sub">Crisis Navigation Environment</span>
|
| 252 |
+
</div>
|
| 253 |
+
<div class="topbar-sep"></div>
|
| 254 |
+
<div class="seg">
|
| 255 |
+
<button class="diff-btn on fire" data-diff="easy">Easy</button>
|
| 256 |
+
<button class="diff-btn" data-diff="medium">Medium</button>
|
| 257 |
+
<button class="diff-btn" data-diff="hard">Hard</button>
|
| 258 |
+
</div>
|
| 259 |
+
<div class="topbar-right">
|
| 260 |
+
<span id="liveChip" class="live-chip demo">Demo Mode</span>
|
| 261 |
+
</div>
|
| 262 |
+
</header>
|
| 263 |
+
|
| 264 |
+
<!-- ── Content ── -->
|
| 265 |
+
<div class="content">
|
| 266 |
+
|
| 267 |
+
<!-- Canvas zone -->
|
| 268 |
+
<div class="canvas-zone">
|
| 269 |
+
<div class="canvas-frame">
|
| 270 |
+
<canvas id="mainCanvas" width="576" height="576"></canvas>
|
| 271 |
+
|
| 272 |
+
<!-- Top-left HUD: Health -->
|
| 273 |
+
<div class="hud-tl">
|
| 274 |
+
<div class="hud-card">
|
| 275 |
+
<div class="hud-row">
|
| 276 |
+
<span class="hlbl">HP</span>
|
| 277 |
+
<div class="hbar-bg"><div id="healthBar" class="hbar-fill g" style="width:100%"></div></div>
|
| 278 |
+
<span id="healthVal" class="hval">100</span>
|
| 279 |
+
</div>
|
| 280 |
+
<div class="hud-row" style="gap:10px">
|
| 281 |
+
<span id="healthStatus" class="hstatus good">Good</span>
|
| 282 |
+
<span id="smokeTag" style="font-family:var(--mono);font-size:9px;color:rgba(168,162,158,.6);margin-left:auto">smoke: clear</span>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
<!-- Top-right HUD: Steps -->
|
| 288 |
+
<div class="hud-tr">
|
| 289 |
+
<div class="hud-card" style="text-align:right">
|
| 290 |
+
<div class="hud-row" style="justify-content:flex-end">
|
| 291 |
+
<span class="hlbl">STEP</span>
|
| 292 |
+
<span id="stepCount" class="step-val">0 / 200</span>
|
| 293 |
+
</div>
|
| 294 |
+
<div class="sbar-bg"><div id="stepBar" class="sbar-fill" style="width:0%"></div></div>
|
| 295 |
+
<div id="diffTag" class="step-meta">easy · small_office</div>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<!-- Episode end banner -->
|
| 300 |
+
<div id="epBanner"></div>
|
| 301 |
+
|
| 302 |
+
<!-- Legend -->
|
| 303 |
+
<div class="legend">
|
| 304 |
+
<div class="leg-row"><div class="leg-dot" style="background:#0ea5e9"></div>Agent (human)</div>
|
| 305 |
+
<div class="leg-row"><div class="leg-dot" style="background:rgba(2,132,199,.45)"></div>Footprint trail</div>
|
| 306 |
+
<div class="leg-row"><div class="leg-dot" style="background:#c2410c"></div>Fire</div>
|
| 307 |
+
<div class="leg-row"><div class="leg-dot" style="background:rgba(200,210,220,.7)"></div>Smoke</div>
|
| 308 |
+
<div class="leg-row"><div class="leg-dot" style="background:#22c55e"></div>Exit</div>
|
| 309 |
+
</div>
|
| 310 |
+
</div>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<!-- Side panel -->
|
| 314 |
+
<aside class="side">
|
| 315 |
+
|
| 316 |
+
<div class="side-sec">
|
| 317 |
+
<div class="sec-hd">Agent Status</div>
|
| 318 |
+
<div class="sg">
|
| 319 |
+
<div class="sc"><div class="sc-l">Health</div><div id="sHealthVal" class="sc-v blue">100</div></div>
|
| 320 |
+
<div class="sc"><div class="sc-l">Status</div><div id="sStatusVal" class="sc-v">Good</div></div>
|
| 321 |
+
<div class="sc"><div class="sc-l">Smoke</div><div id="sSmokeVal" class="sc-v">clear</div></div>
|
| 322 |
+
<div class="sc"><div class="sc-l">Reward</div><div id="sRewardVal" class="sc-v blue">0.00</div></div>
|
| 323 |
+
</div>
|
| 324 |
+
<div class="health-legend">
|
| 325 |
+
<div class="hl-seg g" title="Good (>75)"></div>
|
| 326 |
+
<div class="hl-seg m" title="Moderate (>50)"></div>
|
| 327 |
+
<div class="hl-seg l" title="Low (>25)"></div>
|
| 328 |
+
<div class="hl-seg c" title="Critical (≤25)"></div>
|
| 329 |
+
</div>
|
| 330 |
+
</div>
|
| 331 |
+
|
| 332 |
+
<div class="side-sec">
|
| 333 |
+
<div class="sec-hd">
|
| 334 |
+
Episode
|
| 335 |
+
<span id="sBurnVal" style="color:var(--fire);font-family:var(--mono);font-size:10px">0% burned</span>
|
| 336 |
+
</div>
|
| 337 |
+
<div class="sg">
|
| 338 |
+
<div class="sc"><div class="sc-l">Fire cells</div><div id="sFireVal" class="sc-v fire">0</div></div>
|
| 339 |
+
<div class="sc"><div class="sc-l">Step</div><div id="sStepVal" class="sc-v">0</div></div>
|
| 340 |
+
<div class="sc"><div class="sc-l">Wind</div><div id="sWindVal" class="sc-v amber">CALM</div></div>
|
| 341 |
+
<div class="sc"><div class="sc-l">Spread ρ</div><div id="sSpreadVal" class="sc-v fire">0.10</div></div>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="meta-row">
|
| 344 |
+
<span style="color:var(--t3);font-size:11px">Exits blocked</span>
|
| 345 |
+
<span id="sExitBlocked" style="font-family:var(--mono);font-size:11px;color:var(--green)">0 / 2</span>
|
| 346 |
+
</div>
|
| 347 |
+
</div>
|
| 348 |
+
|
| 349 |
+
<div class="side-sec">
|
| 350 |
+
<div class="sec-hd">
|
| 351 |
+
Cumulative Reward
|
| 352 |
+
<span id="sRewardLast" style="color:var(--t2);font-size:10px">—</span>
|
| 353 |
+
</div>
|
| 354 |
+
<div class="chart-wrap" style="height:50px">
|
| 355 |
+
<canvas id="rewardChart" height="50"></canvas>
|
| 356 |
+
</div>
|
| 357 |
+
</div>
|
| 358 |
+
|
| 359 |
+
<div class="side-sec">
|
| 360 |
+
<div class="sec-hd">
|
| 361 |
+
Active Fire
|
| 362 |
+
<span id="sFireLast" style="color:var(--t2);font-size:10px">0 cells</span>
|
| 363 |
+
</div>
|
| 364 |
+
<div class="chart-wrap" style="height:44px">
|
| 365 |
+
<canvas id="fireChart" height="44"></canvas>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
|
| 369 |
+
<div class="side-sec">
|
| 370 |
+
<div class="sec-hd">Event Log</div>
|
| 371 |
+
<div id="eventLog" class="elog">
|
| 372 |
+
<div class="erow">
|
| 373 |
+
<span class="estep">000</span>
|
| 374 |
+
<span class="etext">Episode started. Assess surroundings.</span>
|
| 375 |
+
</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
|
| 379 |
+
</aside>
|
| 380 |
+
</div>
|
| 381 |
+
|
| 382 |
+
<!-- ── Bottom bar ── -->
|
| 383 |
+
<footer class="botbar">
|
| 384 |
+
<button id="resetBtn" class="icn-btn" title="Reset (R)">↺</button>
|
| 385 |
+
<button id="playBtn" class="icn-btn play" title="Play / Pause (Space)">▶</button>
|
| 386 |
+
<button id="stepBtn" class="icn-btn" title="Step once (→)">⏭</button>
|
| 387 |
+
<div class="bsep"></div>
|
| 388 |
+
<div class="seg">
|
| 389 |
+
<button class="spd-btn" data-s="0.5">½×</button>
|
| 390 |
+
<button class="spd-btn on" data-s="1">1×</button>
|
| 391 |
+
<button class="spd-btn" data-s="2">2×</button>
|
| 392 |
+
<button class="spd-btn" data-s="4">4×</button>
|
| 393 |
+
</div>
|
| 394 |
+
<div class="bsep"></div>
|
| 395 |
+
<div class="seed-wrap">
|
| 396 |
+
<span class="seed-lbl">Seed</span>
|
| 397 |
+
<input id="seedInput" type="number" value="42" min="0" max="9999">
|
| 398 |
+
</div>
|
| 399 |
+
<div class="keys">
|
| 400 |
+
<span><kbd>Space</kbd> play</span>
|
| 401 |
+
<span><kbd>→</kbd> step</span>
|
| 402 |
+
<span><kbd>R</kbd> reset</span>
|
| 403 |
+
</div>
|
| 404 |
+
</footer>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<div id="toast"></div>
|
| 408 |
+
|
| 409 |
+
<script>
|
| 410 |
+
'use strict';
|
| 411 |
+
|
| 412 |
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
| 413 |
+
const FLOOR=0, WALL=1, DOOR_OPEN=2, DOOR_CLOSED=3, EXIT=4, OBSTACLE=5;
|
| 414 |
+
const FIRE_IGNITION=0.1, FIRE_BURNING=0.3, FIRE_GAIN=0.15, BURNOUT_TICKS=5;
|
| 415 |
+
const SMOKE_SPREAD=0.20, SMOKE_DECAY=0.025, SMOKE_DOOR=0.4;
|
| 416 |
+
const EXIT_BLOCK=0.50;
|
| 417 |
+
const CARDINAL=[[0,-1],[0,1],[-1,0],[1,0]];
|
| 418 |
+
const WIND_DIRS={N:[0,-1],NE:[1,-1],E:[1,0],SE:[1,1],S:[0,1],SW:[-1,1],W:[-1,0],NW:[-1,-1],CALM:[0,0]};
|
| 419 |
+
|
| 420 |
+
const DIFFICULTY = {
|
| 421 |
+
easy: { nSrc:[1,1], spread:[.10,.20], humid:[.30,.50], winds:['CALM'], maxSteps:200 },
|
| 422 |
+
medium: { nSrc:[2,3], spread:[.15,.35], humid:[.15,.40], winds:Object.keys(WIND_DIRS), maxSteps:150 },
|
| 423 |
+
hard: { nSrc:[3,4], spread:[.30,.50], humid:[.05,.20], winds:['N','NE','E','SE','S','SW','W','NW'], maxSteps:100 },
|
| 424 |
+
};
|
| 425 |
+
|
| 426 |
+
// ── Seeded PRNG (mulberry32) ──────────────────────────────────────────────────
|
| 427 |
+
function makePRNG(seed) {
|
| 428 |
+
let s = seed >>> 0;
|
| 429 |
+
return () => {
|
| 430 |
+
s += 0x6D2B79F5;
|
| 431 |
+
let t = s;
|
| 432 |
+
t = Math.imul(t ^ t>>>15, t|1);
|
| 433 |
+
t ^= t + Math.imul(t^t>>>7, t|61);
|
| 434 |
+
return ((t^t>>>14)>>>0) / 4294967296;
|
| 435 |
+
};
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
// ── Fire color ramp ───────────────────────────────────────────────────────────
|
| 439 |
+
function fireColor(t, a=1) {
|
| 440 |
+
t = Math.max(0, Math.min(1, t));
|
| 441 |
+
let r,g,b;
|
| 442 |
+
if (t<.15){ const s=t/.15; r=~~(98+88*s); g=~~(6*s); b=0; }
|
| 443 |
+
else if (t<.40){ const s=(t-.15)/.25; r=~~(186+60*s); g=~~(6+60*s); b=0; }
|
| 444 |
+
else if (t<.65){ const s=(t-.40)/.25; r=~~(246+9*s); g=~~(66+112*s); b=~~(9*s); }
|
| 445 |
+
else if (t<.85){ const s=(t-.65)/.20; r=255; g=~~(178+57*s); b=~~(9+32*s); }
|
| 446 |
+
else { const s=(t-.85)/.15; r=255; g=~~(235+20*s); b=~~(41+214*s); }
|
| 447 |
+
return `rgba(${r},${g},${b},${a})`;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// ── Floor plan: small office 16×16 ───────────────────────────────────────────
|
| 451 |
+
const LAYOUT = [
|
| 452 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 453 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 454 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 455 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 456 |
+
[1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
|
| 457 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 458 |
+
[4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 459 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 460 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
|
| 461 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 462 |
+
[1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
|
| 463 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 464 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 465 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 466 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 467 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 468 |
+
].flat();
|
| 469 |
+
const W=16, H=16;
|
| 470 |
+
const EXITS=[[0,6],[15,8]];
|
| 471 |
+
const DOORS=[[2,4],[6,4],[10,4],[14,4],[2,10],[6,10],[10,10],[14,10]];
|
| 472 |
+
|
| 473 |
+
// ── BFS utilities ─────────────────────────────────────────────────────────────
|
| 474 |
+
function bfsDist(ax, ay, exits, cellGrid) {
|
| 475 |
+
const targets = exits.filter(([ex,ey])=>cellGrid[ey*W+ex]!==OBSTACLE);
|
| 476 |
+
if (!targets.length) return 999;
|
| 477 |
+
const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]];
|
| 478 |
+
while (q.length) {
|
| 479 |
+
const [x,y,d]=q.shift();
|
| 480 |
+
for (const [dx,dy] of CARDINAL) {
|
| 481 |
+
const nx=x+dx, ny=y+dy;
|
| 482 |
+
if (nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 483 |
+
const k=`${nx},${ny}`;
|
| 484 |
+
if (vis.has(k)) continue;
|
| 485 |
+
const ct=cellGrid[ny*W+nx];
|
| 486 |
+
if (ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
|
| 487 |
+
if (targets.some(([ex,ey])=>ex===nx&&ey===ny)) return d+1;
|
| 488 |
+
vis.add(k); q.push([nx,ny,d+1]);
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
return 999;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
function computeVisible(ax, ay, cellGrid, smokeGrid) {
|
| 495 |
+
const smoke=smokeGrid[ay*W+ax];
|
| 496 |
+
const radius=smoke>.5?2:smoke>.2?3:5;
|
| 497 |
+
const vis=new Set([`${ax},${ay}`]), q=[[ax,ay,0]], seen=new Set([`${ax},${ay}`]);
|
| 498 |
+
while (q.length) {
|
| 499 |
+
const [x,y,d]=q.shift();
|
| 500 |
+
if (d>=radius) continue;
|
| 501 |
+
for (const [dx,dy] of CARDINAL) {
|
| 502 |
+
const nx=x+dx, ny=y+dy;
|
| 503 |
+
if (nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 504 |
+
const k=`${nx},${ny}`;
|
| 505 |
+
if (seen.has(k)) continue;
|
| 506 |
+
seen.add(k);
|
| 507 |
+
const ct=cellGrid[ny*W+nx];
|
| 508 |
+
if (ct===WALL) continue;
|
| 509 |
+
vis.add(k); q.push([nx,ny,d+1]);
|
| 510 |
+
}
|
| 511 |
+
}
|
| 512 |
+
return vis;
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// ── Simulation state ──────────────────────────────────────────────────────────
|
| 516 |
+
let state = null;
|
| 517 |
+
|
| 518 |
+
function buildState(difficulty='easy', seed=42) {
|
| 519 |
+
const p = DIFFICULTY[difficulty];
|
| 520 |
+
const rng = makePRNG(seed);
|
| 521 |
+
|
| 522 |
+
const cellGrid = LAYOUT.slice();
|
| 523 |
+
// Randomly close some doors
|
| 524 |
+
const doorReg = {};
|
| 525 |
+
DOORS.forEach((d,i) => {
|
| 526 |
+
doorReg[`door_${i+1}`] = d;
|
| 527 |
+
if (rng()<0.3) cellGrid[d[1]*W+d[0]] = DOOR_CLOSED;
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
const nSrc = Math.round(p.nSrc[0] + rng()*(p.nSrc[1]-p.nSrc[0]));
|
| 531 |
+
const pSpread = p.spread[0] + rng()*(p.spread[1]-p.spread[0]);
|
| 532 |
+
const humidity = p.humid[0] + rng()*(p.humid[1]-p.humid[0]);
|
| 533 |
+
const windDir = p.winds[Math.floor(rng()*p.winds.length)];
|
| 534 |
+
const wv = WIND_DIRS[windDir]||[0,0];
|
| 535 |
+
|
| 536 |
+
const fireGrid = new Float32Array(W*H);
|
| 537 |
+
const smokeGrid = new Float32Array(W*H);
|
| 538 |
+
const burnTimers= new Int32Array(W*H);
|
| 539 |
+
|
| 540 |
+
// Spawn agent in corridor
|
| 541 |
+
const spawns=[];
|
| 542 |
+
for(let y=5;y<=9;y++) for(let x=1;x<=14;x++) if(cellGrid[y*W+x]===FLOOR) spawns.push([x,y]);
|
| 543 |
+
const sp = spawns[Math.floor(rng()*spawns.length)];
|
| 544 |
+
|
| 545 |
+
// Place fire far from agent and exits
|
| 546 |
+
const candidates=[];
|
| 547 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 548 |
+
if(cellGrid[y*W+x]===FLOOR) {
|
| 549 |
+
const farAgent=Math.abs(x-sp[0])+Math.abs(y-sp[1])>=5;
|
| 550 |
+
const farExits=EXITS.every(([ex,ey])=>Math.abs(x-ex)+Math.abs(y-ey)>=4);
|
| 551 |
+
if(farAgent&&farExits) candidates.push([x,y]);
|
| 552 |
+
}
|
| 553 |
+
}
|
| 554 |
+
candidates.sort(()=>rng()-.5);
|
| 555 |
+
for(let i=0;i<Math.min(nSrc,candidates.length);i++) {
|
| 556 |
+
const [fx,fy]=candidates[i];
|
| 557 |
+
fireGrid[fy*W+fx]=FIRE_IGNITION;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
const visible = computeVisible(sp[0],sp[1],cellGrid,smokeGrid);
|
| 561 |
+
const explore = new Set(visible);
|
| 562 |
+
|
| 563 |
+
return {
|
| 564 |
+
cellGrid, fireGrid, smokeGrid, burnTimers, doorReg,
|
| 565 |
+
pSpread, humidity, windDir, wx:wv[0], wy:wv[1],
|
| 566 |
+
effectiveSpread: pSpread * Math.max(0, 1-humidity),
|
| 567 |
+
maxSteps: p.maxSteps, stepCount:0,
|
| 568 |
+
agentX:sp[0], agentY:sp[1],
|
| 569 |
+
agentHealth:100, agentAlive:true, agentEvacuated:false,
|
| 570 |
+
totalReward:0, lastReward:0,
|
| 571 |
+
rewardHistory:[], fireSizeHistory:[],
|
| 572 |
+
eventLog:[{step:0,text:'Episode started. Assess your surroundings.',reward:0}],
|
| 573 |
+
visibleCells:visible, exploreSet:explore,
|
| 574 |
+
difficulty, seed,
|
| 575 |
+
rng2: makePRNG(seed+1),
|
| 576 |
+
};
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
// ── Fire simulation step ──────────────────────────────────────────────────────
|
| 580 |
+
function simStep(s) {
|
| 581 |
+
const {cellGrid,fireGrid,smokeGrid,burnTimers,pSpread,effectiveSpread,wx,wy,rng2} = s;
|
| 582 |
+
const ignite = new Uint8Array(W*H);
|
| 583 |
+
const burned = [];
|
| 584 |
+
|
| 585 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 586 |
+
const i=y*W+x;
|
| 587 |
+
if(fireGrid[i]<FIRE_BURNING) continue;
|
| 588 |
+
for(const [dx,dy] of CARDINAL) {
|
| 589 |
+
const nx=x+dx, ny=y+dy;
|
| 590 |
+
if(nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 591 |
+
const ni=ny*W+nx, nct=cellGrid[ni];
|
| 592 |
+
if(nct===WALL||nct===OBSTACLE||fireGrid[ni]>0) continue;
|
| 593 |
+
let p = nct===DOOR_CLOSED ? effectiveSpread*.15 : effectiveSpread;
|
| 594 |
+
const dot=dx*wx+dy*wy;
|
| 595 |
+
p *= (dot>0?2.0:dot<0?0.5:1.0);
|
| 596 |
+
if(rng2()<Math.min(1,p)) ignite[ni]=1;
|
| 597 |
+
}
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
const nf=fireGrid.slice(), nt=burnTimers.slice();
|
| 601 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 602 |
+
const i=y*W+x, ct=cellGrid[i];
|
| 603 |
+
if(ct===WALL||ct===OBSTACLE) continue;
|
| 604 |
+
if(fireGrid[i]>0) {
|
| 605 |
+
nf[i]=Math.min(1,fireGrid[i]+FIRE_GAIN);
|
| 606 |
+
if(fireGrid[i]>=FIRE_BURNING) nt[i]++;
|
| 607 |
+
if(nt[i]>=BURNOUT_TICKS&&nf[i]>=1) {
|
| 608 |
+
cellGrid[i]=OBSTACLE; nf[i]=0; nt[i]=0; burned.push([x,y]);
|
| 609 |
+
}
|
| 610 |
+
} else if(ignite[i]) { nf[i]=FIRE_IGNITION; nt[i]=0; }
|
| 611 |
+
}
|
| 612 |
+
for(let i=0;i<W*H;i++) { fireGrid[i]=nf[i]; burnTimers[i]=nt[i]; }
|
| 613 |
+
|
| 614 |
+
// Smoke
|
| 615 |
+
const ns=smokeGrid.slice();
|
| 616 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 617 |
+
const i=y*W+x, ct=cellGrid[i];
|
| 618 |
+
if(ct===WALL||ct===OBSTACLE) continue;
|
| 619 |
+
if(fireGrid[i]>=FIRE_BURNING) ns[i]=Math.min(1,smokeGrid[i]+0.3);
|
| 620 |
+
for(const [dx,dy] of CARDINAL) {
|
| 621 |
+
const nx=x+dx, ny=y+dy;
|
| 622 |
+
if(nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 623 |
+
const ni=ny*W+nx, nct=cellGrid[ni];
|
| 624 |
+
if(nct===WALL||nct===OBSTACLE) continue;
|
| 625 |
+
const diff=smokeGrid[i]-smokeGrid[ni];
|
| 626 |
+
if(diff<=0) continue;
|
| 627 |
+
let rate=SMOKE_SPREAD;
|
| 628 |
+
if(nct===DOOR_CLOSED) rate*=SMOKE_DOOR;
|
| 629 |
+
ns[ni]=Math.min(1,ns[ni]+Math.min(diff*rate,diff*.5));
|
| 630 |
+
}
|
| 631 |
+
ns[i]=Math.max(0,ns[i]-SMOKE_DECAY);
|
| 632 |
+
}
|
| 633 |
+
for(let i=0;i<W*H;i++) smokeGrid[i]=ns[i];
|
| 634 |
+
return burned;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
// ── Demo agent (BFS toward exit) ──────────────────────────────────────────────
|
| 638 |
+
function agentAct(s) {
|
| 639 |
+
const {agentX:ax,agentY:ay,cellGrid,fireGrid,doorReg} = s;
|
| 640 |
+
const dirs=['north','south','east','west'];
|
| 641 |
+
const deltas={north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
|
| 642 |
+
|
| 643 |
+
// Check if adjacent to closed door
|
| 644 |
+
for(const dir of dirs) {
|
| 645 |
+
const [dx,dy]=deltas[dir];
|
| 646 |
+
const nx=ax+dx, ny=ay+dy;
|
| 647 |
+
if(nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 648 |
+
if(cellGrid[ny*W+nx]===DOOR_CLOSED) {
|
| 649 |
+
for(const [id,[ddx,ddy]] of Object.entries(doorReg)) {
|
| 650 |
+
if(ddx===nx&&ddy===ny) return {action:'door',target_id:id,door_state:'open'};
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
let bestDir=null, bestDist=Infinity;
|
| 656 |
+
for(const dir of dirs) {
|
| 657 |
+
const [dx,dy]=deltas[dir];
|
| 658 |
+
const nx=ax+dx, ny=ay+dy;
|
| 659 |
+
if(nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 660 |
+
const ct=cellGrid[ny*W+nx];
|
| 661 |
+
if(ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
|
| 662 |
+
if(fireGrid[ny*W+nx]>0.35) continue;
|
| 663 |
+
const d=bfsDist(nx,ny,EXITS,cellGrid);
|
| 664 |
+
if(d<bestDist) { bestDist=d; bestDir=dir; }
|
| 665 |
+
}
|
| 666 |
+
if(!bestDir||Math.random()<0.12) {
|
| 667 |
+
const shuffled=[...dirs].sort(()=>Math.random()-.5);
|
| 668 |
+
for(const dir of shuffled) {
|
| 669 |
+
const [dx,dy]=deltas[dir];
|
| 670 |
+
const nx=ax+dx, ny=ay+dy;
|
| 671 |
+
if(nx<0||nx>=W||ny<0||ny>=H) continue;
|
| 672 |
+
const ct=cellGrid[ny*W+nx];
|
| 673 |
+
if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED&&fireGrid[ny*W+nx]<0.4) return {action:'move',direction:dir};
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
return bestDir ? {action:'move',direction:bestDir} : {action:'wait'};
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function doStep(s) {
|
| 680 |
+
if(!s.agentAlive||s.agentEvacuated||s.stepCount>=s.maxSteps) return;
|
| 681 |
+
const action = agentAct(s);
|
| 682 |
+
const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
|
| 683 |
+
const prevX=s.agentX, prevY=s.agentY;
|
| 684 |
+
let feedback='';
|
| 685 |
+
|
| 686 |
+
if(action.action==='move') {
|
| 687 |
+
const [dx,dy]=deltas[action.direction]||[0,0];
|
| 688 |
+
const nx=s.agentX+dx, ny=s.agentY+dy;
|
| 689 |
+
if(nx>=0&&nx<W&&ny>=0&&ny<H) {
|
| 690 |
+
const ct=s.cellGrid[ny*W+nx];
|
| 691 |
+
if(ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED) {
|
| 692 |
+
s.agentX=nx; s.agentY=ny; feedback=`Moved ${action.direction}`;
|
| 693 |
+
} else feedback='Path blocked';
|
| 694 |
+
}
|
| 695 |
+
} else if(action.action==='door') {
|
| 696 |
+
const pos=s.doorReg[action.target_id];
|
| 697 |
+
if(pos) {
|
| 698 |
+
const [dx,dy]=pos;
|
| 699 |
+
if(Math.abs(dx-s.agentX)+Math.abs(dy-s.agentY)<=2) {
|
| 700 |
+
if(s.cellGrid[dy*W+dx]===DOOR_CLOSED) {
|
| 701 |
+
s.cellGrid[dy*W+dx]=DOOR_OPEN;
|
| 702 |
+
feedback=`Opened ${action.target_id}`;
|
| 703 |
+
}
|
| 704 |
+
}
|
| 705 |
+
}
|
| 706 |
+
} else if(action.action==='wait') {
|
| 707 |
+
feedback='Waiting...';
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
// Check evacuation
|
| 711 |
+
if(s.cellGrid[s.agentY*W+s.agentX]===EXIT && s.fireGrid[s.agentY*W+s.agentX]<EXIT_BLOCK) {
|
| 712 |
+
s.agentEvacuated=true; feedback='EVACUATED — reached safety!';
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
// Fire step
|
| 716 |
+
const burned = simStep(s);
|
| 717 |
+
|
| 718 |
+
// Damage
|
| 719 |
+
const ai=s.agentY*W+s.agentX;
|
| 720 |
+
const smoke=s.smokeGrid[ai], fire=s.fireGrid[ai];
|
| 721 |
+
let dmg=0;
|
| 722 |
+
if(smoke>=.8) dmg+=5; else if(smoke>=.5) dmg+=2; else if(smoke>=.2) dmg+=.5;
|
| 723 |
+
if(fire>=FIRE_BURNING) dmg+=10;
|
| 724 |
+
s.agentHealth=Math.max(0,s.agentHealth-dmg);
|
| 725 |
+
if(s.agentHealth<=0) { s.agentAlive=false; feedback='Incapacitated!'; }
|
| 726 |
+
|
| 727 |
+
s.stepCount++;
|
| 728 |
+
s.visibleCells=computeVisible(s.agentX,s.agentY,s.cellGrid,s.smokeGrid);
|
| 729 |
+
for(const k of s.visibleCells) s.exploreSet.add(k);
|
| 730 |
+
|
| 731 |
+
// Reward
|
| 732 |
+
const prevDist=bfsDist(prevX,prevY,EXITS,s.cellGrid);
|
| 733 |
+
const curDist=bfsDist(s.agentX,s.agentY,EXITS,s.cellGrid);
|
| 734 |
+
let reward=-0.01;
|
| 735 |
+
if(curDist<prevDist) reward+=0.1;
|
| 736 |
+
if(smoke>=.5||fire>=FIRE_BURNING) reward-=0.5;
|
| 737 |
+
reward-=0.02*dmg;
|
| 738 |
+
if(s.agentEvacuated) reward+=5+(0.05*Math.max(0,s.maxSteps-s.stepCount));
|
| 739 |
+
if(!s.agentAlive) reward-=10;
|
| 740 |
+
if(action.action==='door') reward+=0.5;
|
| 741 |
+
|
| 742 |
+
s.lastReward=Math.round(reward*1000)/1000;
|
| 743 |
+
s.totalReward=Math.round((s.totalReward+reward)*1000)/1000;
|
| 744 |
+
s.rewardHistory.push(s.totalReward);
|
| 745 |
+
s.fireSizeHistory.push(Array.from(s.fireGrid).filter(f=>f>=FIRE_BURNING).length);
|
| 746 |
+
|
| 747 |
+
if(feedback) s.eventLog.unshift({step:s.stepCount,text:feedback,reward:s.lastReward});
|
| 748 |
+
if(burned.length) s.eventLog.unshift({step:s.stepCount,text:`${burned.length} cell(s) burned out`,reward:0,isAlert:true});
|
| 749 |
+
if(s.eventLog.length>50) s.eventLog.length=50;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
// ── Renderer ──────────────────────────────────────────────────────────────────
|
| 753 |
+
const canvas = document.getElementById('mainCanvas');
|
| 754 |
+
const ctx = canvas.getContext('2d');
|
| 755 |
+
const CS = 36; // cell size px
|
| 756 |
+
let time = 0;
|
| 757 |
+
let agentTrail = []; // [{px,py}] max 14
|
| 758 |
+
let smokePhases; // per-cell random phase
|
| 759 |
+
|
| 760 |
+
function initRender() {
|
| 761 |
+
smokePhases = new Float32Array(W*H).map(()=>Math.random()*Math.PI*2);
|
| 762 |
+
agentTrail = [];
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
function renderFrame(s) {
|
| 766 |
+
time += 0.016;
|
| 767 |
+
const {cellGrid,fireGrid,smokeGrid,agentX,agentY,visibleCells,exploreSet} = s;
|
| 768 |
+
|
| 769 |
+
// ── Background ──
|
| 770 |
+
ctx.fillStyle='#c8b890';
|
| 771 |
+
ctx.fillRect(0,0,576,576);
|
| 772 |
+
|
| 773 |
+
// ── Cell base ──
|
| 774 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) drawCellBase(ctx,x,y,cellGrid[y*W+x],CS);
|
| 775 |
+
|
| 776 |
+
// ── Fire ambient (multiply — warms light floor tiles naturally) ──
|
| 777 |
+
ctx.save();
|
| 778 |
+
ctx.globalCompositeOperation='multiply';
|
| 779 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 780 |
+
const f=fireGrid[y*W+x];
|
| 781 |
+
if(f<0.28) continue;
|
| 782 |
+
drawFireAmbient(ctx,x,y,f,CS);
|
| 783 |
+
}
|
| 784 |
+
ctx.restore();
|
| 785 |
+
|
| 786 |
+
// ── Fire cell (multiply — burns vivid deep orange-red into light tiles) ──
|
| 787 |
+
ctx.save();
|
| 788 |
+
ctx.globalCompositeOperation='multiply';
|
| 789 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 790 |
+
const f=fireGrid[y*W+x];
|
| 791 |
+
if(f<0.05) continue;
|
| 792 |
+
drawFireCell(ctx,x,y,f,CS);
|
| 793 |
+
}
|
| 794 |
+
ctx.restore();
|
| 795 |
+
|
| 796 |
+
// ── Smoke ──
|
| 797 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 798 |
+
const sm=smokeGrid[y*W+x];
|
| 799 |
+
if(sm<0.08) continue;
|
| 800 |
+
drawSmoke(ctx,x,y,sm,CS,smokePhases[y*W+x]);
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
// ── Exits & doors ──
|
| 804 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 805 |
+
const ct=cellGrid[y*W+x];
|
| 806 |
+
if(ct===EXIT) drawExit(ctx,x,y,CS,fireGrid[y*W+x]);
|
| 807 |
+
else if(ct===DOOR_OPEN||ct===DOOR_CLOSED) drawDoor(ctx,x,y,ct,CS);
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
// ── Visibility shading — all cells visible, dim by knowledge tier ──
|
| 811 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 812 |
+
const k=`${x},${y}`;
|
| 813 |
+
if(visibleCells.has(k)) continue; // fully lit — no overlay
|
| 814 |
+
// Explored but out of sight: light desaturating veil
|
| 815 |
+
// Unexplored: heavier veil — but never fully opaque, structure always readable
|
| 816 |
+
ctx.fillStyle = exploreSet.has(k)
|
| 817 |
+
? 'rgba(180,174,165,0.30)' // explored — slight dim
|
| 818 |
+
: 'rgba(140,134,126,0.55)'; // unseen — moderate dim, structure visible
|
| 819 |
+
ctx.fillRect(x*CS,y*CS,CS,CS);
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
// ── Agent vision lantern — warm radial highlight on visible area ──
|
| 823 |
+
const agPx=agentX*CS+CS/2, agPy=agentY*CS+CS/2;
|
| 824 |
+
const vR=5.5*CS;
|
| 825 |
+
const vGr=ctx.createRadialGradient(agPx,agPy,0,agPx,agPy,vR);
|
| 826 |
+
vGr.addColorStop(0, 'rgba(255,248,215,0.28)');
|
| 827 |
+
vGr.addColorStop(0.45,'rgba(255,244,200,0.14)');
|
| 828 |
+
vGr.addColorStop(0.80,'rgba(255,240,180,0.04)');
|
| 829 |
+
vGr.addColorStop(1, 'rgba(255,240,180,0)');
|
| 830 |
+
ctx.fillStyle=vGr;
|
| 831 |
+
ctx.beginPath(); ctx.arc(agPx,agPy,vR,0,Math.PI*2); ctx.fill();
|
| 832 |
+
|
| 833 |
+
// ── Trail ──
|
| 834 |
+
updateTrail(agentX,agentY);
|
| 835 |
+
drawTrail(ctx);
|
| 836 |
+
|
| 837 |
+
// ── Agent ──
|
| 838 |
+
drawAgent(ctx,agentX,agentY,s.agentHealth,s.agentAlive,s.agentEvacuated,CS);
|
| 839 |
+
|
| 840 |
+
// ── Grid overlay ──
|
| 841 |
+
ctx.strokeStyle='rgba(0,0,0,0.07)';
|
| 842 |
+
ctx.lineWidth=0.5;
|
| 843 |
+
for(let x=0;x<=W;x++){ctx.beginPath();ctx.moveTo(x*CS,0);ctx.lineTo(x*CS,H*CS);ctx.stroke();}
|
| 844 |
+
for(let y=0;y<=H;y++){ctx.beginPath();ctx.moveTo(0,y*CS);ctx.lineTo(W*CS,y*CS);ctx.stroke();}
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
// ── Cell base drawing ─────────────────────────────────────────────────────────
|
| 848 |
+
function drawCellBase(ctx,x,y,ct,cs) {
|
| 849 |
+
const px=x*cs, py=y*cs;
|
| 850 |
+
switch(ct) {
|
| 851 |
+
case WALL: {
|
| 852 |
+
const wh=9;
|
| 853 |
+
// Front shadow face (very dark — makes wall pop from floor)
|
| 854 |
+
ctx.fillStyle='rgba(0,0,0,0.55)'; ctx.fillRect(px,py+cs-wh,cs,wh);
|
| 855 |
+
// Right drop shadow
|
| 856 |
+
ctx.fillStyle='rgba(0,0,0,0.30)'; ctx.fillRect(px+cs,py-wh+2,4,cs+2);
|
| 857 |
+
// Top face — dark concrete gray
|
| 858 |
+
ctx.fillStyle='#5e5850'; ctx.fillRect(px,py-wh,cs,cs);
|
| 859 |
+
// Top edge catch-light (bright white strip)
|
| 860 |
+
ctx.fillStyle='rgba(255,255,255,0.80)'; ctx.fillRect(px,py-wh,cs,3);
|
| 861 |
+
// Left highlight strip
|
| 862 |
+
ctx.fillStyle='rgba(255,255,255,0.35)'; ctx.fillRect(px,py-wh+3,2,cs-3);
|
| 863 |
+
// Bottom-right inner shadow for depth
|
| 864 |
+
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+cs-3,py-wh+3,3,cs-3);
|
| 865 |
+
ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.fillRect(px+3,py-wh+cs-3,cs-6,3);
|
| 866 |
+
// Mortar lines
|
| 867 |
+
ctx.fillStyle='rgba(0,0,0,0.18)';
|
| 868 |
+
ctx.fillRect(px+3,py-wh+~~(cs*.34),cs-6,1);
|
| 869 |
+
ctx.fillRect(px+3,py-wh+~~(cs*.67),cs-6,1);
|
| 870 |
+
break;
|
| 871 |
+
}
|
| 872 |
+
case OBSTACLE: {
|
| 873 |
+
const oh=5;
|
| 874 |
+
// Deep shadow underface
|
| 875 |
+
ctx.fillStyle='rgba(0,0,0,0.45)'; ctx.fillRect(px,py+cs-oh,cs,oh);
|
| 876 |
+
ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.fillRect(px+cs,py-oh+2,4,cs);
|
| 877 |
+
// Dark charred surface
|
| 878 |
+
ctx.fillStyle='#3a3530'; ctx.fillRect(px,py-oh,cs,cs);
|
| 879 |
+
// Diagonal char texture
|
| 880 |
+
ctx.strokeStyle='rgba(0,0,0,0.30)'; ctx.lineWidth=0.9;
|
| 881 |
+
for(let d=0;d<cs+cs;d+=5){
|
| 882 |
+
ctx.beginPath();
|
| 883 |
+
ctx.moveTo(px+Math.max(0,d-cs),py-oh+Math.min(d,cs));
|
| 884 |
+
ctx.lineTo(px+Math.min(d,cs),py-oh+Math.max(0,d-cs));
|
| 885 |
+
ctx.stroke();
|
| 886 |
+
}
|
| 887 |
+
// Vivid ember edge glow
|
| 888 |
+
ctx.fillStyle='rgba(230,80,0,0.80)';
|
| 889 |
+
ctx.fillRect(px,py-oh,cs,1); ctx.fillRect(px,py-oh+cs-1,cs,1);
|
| 890 |
+
ctx.fillRect(px,py-oh+1,1,cs-2); ctx.fillRect(px+cs-1,py-oh+1,1,cs-2);
|
| 891 |
+
break;
|
| 892 |
+
}
|
| 893 |
+
case EXIT: {
|
| 894 |
+
ctx.fillStyle='#e6f4ec'; ctx.fillRect(px,py,cs,cs);
|
| 895 |
+
ctx.fillStyle='rgba(34,197,94,0.18)'; ctx.fillRect(px+2,py+2,cs-4,cs-4);
|
| 896 |
+
break;
|
| 897 |
+
}
|
| 898 |
+
default: { // FLOOR, DOOR — Minecraft-style strong checkerboard
|
| 899 |
+
// Alternating tiles: warm sand-tan vs golden-tan (like reference image 2)
|
| 900 |
+
ctx.fillStyle = (x+y)%2===0 ? '#e8d8b8' : '#d0be98';
|
| 901 |
+
ctx.fillRect(px, py, cs, cs);
|
| 902 |
+
// Subtle inner bevel (top-left lighter, bottom-right darker)
|
| 903 |
+
ctx.fillStyle = 'rgba(255,255,255,0.20)';
|
| 904 |
+
ctx.fillRect(px, py, cs, 2); ctx.fillRect(px, py+2, 2, cs-2);
|
| 905 |
+
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
| 906 |
+
ctx.fillRect(px, py+cs-2, cs, 2); ctx.fillRect(px+cs-2, py, 2, cs-2);
|
| 907 |
+
}
|
| 908 |
+
}
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
// ── Fire rendering ────────────────────────────────────────────────────────────
|
| 912 |
+
function drawFireAmbient(ctx,x,y,fire,cs) {
|
| 913 |
+
// multiply: deeply warms floor around fire — reduce G+B channels
|
| 914 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 915 |
+
const r=cs*(3.0+fire*2.2);
|
| 916 |
+
const a=0.72+fire*0.25; // high alpha for vivid multiply effect
|
| 917 |
+
const gr=ctx.createRadialGradient(px,py,0,px,py,r);
|
| 918 |
+
gr.addColorStop(0, `rgba(255,50,0,${a})`); // deep red-orange core
|
| 919 |
+
gr.addColorStop(0.30, `rgba(255,90,0,${a*0.80})`); // orange mid
|
| 920 |
+
gr.addColorStop(0.60, `rgba(255,150,20,${a*0.45})`);
|
| 921 |
+
gr.addColorStop(0.85, `rgba(255,200,60,${a*0.15})`);
|
| 922 |
+
gr.addColorStop(1, 'rgba(255,255,255,0)');
|
| 923 |
+
ctx.fillStyle=gr;
|
| 924 |
+
ctx.fillRect(px-r,py-r,r*2,r*2);
|
| 925 |
+
}
|
| 926 |
+
|
| 927 |
+
function drawFireCell(ctx,x,y,fire,cs) {
|
| 928 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 929 |
+
const f1=0.82+0.18*Math.sin(time*9.5+x*2.7+y*3.14);
|
| 930 |
+
const eff=fire*f1;
|
| 931 |
+
// multiply blend: alpha values are high so colors deeply saturate the light floor
|
| 932 |
+
const layers=[
|
| 933 |
+
{r:cs*.35, a:.99, ox:0, oy:-cs*.10}, // white-hot core
|
| 934 |
+
{r:cs*.68, a:.95, ox:0, oy:0}, // inner flame
|
| 935 |
+
{r:cs*1.0, a:.82, ox:0, oy:0}, // flame body
|
| 936 |
+
{r:cs*1.55,a:.60, ox:0, oy:0}, // outer heat glow
|
| 937 |
+
];
|
| 938 |
+
for(const {r,a,ox,oy} of layers) {
|
| 939 |
+
const cx=px+ox, cy=py+oy;
|
| 940 |
+
const gr=ctx.createRadialGradient(cx,cy,0,cx,cy,r);
|
| 941 |
+
gr.addColorStop(0, fireColor(eff, a));
|
| 942 |
+
gr.addColorStop(.35, fireColor(eff*.80, a*.75));
|
| 943 |
+
gr.addColorStop(.70, fireColor(eff*.40, a*.40));
|
| 944 |
+
gr.addColorStop(1, fireColor(eff*.10, 0));
|
| 945 |
+
ctx.fillStyle=gr;
|
| 946 |
+
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.fill();
|
| 947 |
+
}
|
| 948 |
+
// White-hot core punch-through (visible even in multiply)
|
| 949 |
+
if(eff>.45) {
|
| 950 |
+
const gr2=ctx.createRadialGradient(px,py-cs*.12,0,px,py-cs*.12,cs*.20);
|
| 951 |
+
gr2.addColorStop(0,`rgba(255,200,50,${Math.min(1,(eff-.45)*2.2)})`);
|
| 952 |
+
gr2.addColorStop(1,'rgba(255,140,0,0)');
|
| 953 |
+
ctx.fillStyle=gr2;
|
| 954 |
+
ctx.beginPath(); ctx.arc(px,py-cs*.12,cs*.20,0,Math.PI*2); ctx.fill();
|
| 955 |
+
}
|
| 956 |
+
}
|
| 957 |
+
|
| 958 |
+
// ── Smoke ─────────────────────────────────────────────────────────────────────
|
| 959 |
+
function drawSmoke(ctx,x,y,smoke,cs,phase) {
|
| 960 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 961 |
+
const dx=Math.sin(time*.35+phase)*1.8, dy=Math.cos(time*.28+phase+1.2)*1.8;
|
| 962 |
+
const alpha=Math.min(0.85,smoke*.95);
|
| 963 |
+
const r=cs*.85;
|
| 964 |
+
ctx.save();
|
| 965 |
+
ctx.translate(px+dx,py+dy);
|
| 966 |
+
const gr=ctx.createRadialGradient(0,0,0,0,0,r);
|
| 967 |
+
gr.addColorStop(0,`rgba(72,82,96,${alpha})`);
|
| 968 |
+
gr.addColorStop(.5,`rgba(72,82,96,${alpha*.75})`);
|
| 969 |
+
gr.addColorStop(1,'rgba(72,82,96,0)');
|
| 970 |
+
ctx.fillStyle=gr;
|
| 971 |
+
ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.fill();
|
| 972 |
+
if(smoke>.3) {
|
| 973 |
+
const gr2=ctx.createRadialGradient(dy*1.5,dx*1.5,0,dy*1.5,dx*1.5,cs*.6);
|
| 974 |
+
gr2.addColorStop(0,`rgba(88,98,114,${alpha*.5})`);
|
| 975 |
+
gr2.addColorStop(1,'rgba(88,98,114,0)');
|
| 976 |
+
ctx.fillStyle=gr2;
|
| 977 |
+
ctx.beginPath(); ctx.arc(dy*1.5,dx*1.5,cs*.6,0,Math.PI*2); ctx.fill();
|
| 978 |
+
}
|
| 979 |
+
ctx.restore();
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
// ── Exit ──────────────────────────────────────────────────────────────────────
|
| 983 |
+
function drawExit(ctx,x,y,cs,fire) {
|
| 984 |
+
const px=x*cs, py=y*cs;
|
| 985 |
+
const pulse=0.62+0.38*Math.sin(time*2.8+x+y);
|
| 986 |
+
const blocked=fire>=EXIT_BLOCK;
|
| 987 |
+
const col=blocked?'#ef4444':'#22c55e';
|
| 988 |
+
ctx.save();
|
| 989 |
+
ctx.shadowBlur=cs*1.1*pulse; ctx.shadowColor=col;
|
| 990 |
+
ctx.strokeStyle=col; ctx.lineWidth=2;
|
| 991 |
+
ctx.strokeRect(px+2,py+2,cs-4,cs-4);
|
| 992 |
+
ctx.fillStyle=blocked?'rgba(220,38,38,.11)':'rgba(34,197,94,.11)';
|
| 993 |
+
ctx.fillRect(px+3,py+3,cs-6,cs-6);
|
| 994 |
+
ctx.shadowBlur=0;
|
| 995 |
+
const sa=0.55+0.35*pulse;
|
| 996 |
+
ctx.fillStyle=blocked?`rgba(220,38,38,${sa})`:`rgba(22,163,74,${sa})`;
|
| 997 |
+
ctx.font=`bold ${~~(cs*.50)}px sans-serif`;
|
| 998 |
+
ctx.textAlign='center'; ctx.textBaseline='middle';
|
| 999 |
+
ctx.fillText(blocked?'✕':'⇥',px+cs/2,py+cs/2+1);
|
| 1000 |
+
// Corner brackets
|
| 1001 |
+
ctx.fillStyle=blocked?'rgba(220,38,38,.85)':'rgba(22,163,74,.85)';
|
| 1002 |
+
const ca=5;
|
| 1003 |
+
ctx.fillRect(px,py,ca,1); ctx.fillRect(px,py,1,ca);
|
| 1004 |
+
ctx.fillRect(px+cs-ca,py,ca,1); ctx.fillRect(px+cs-1,py,1,ca);
|
| 1005 |
+
ctx.fillRect(px,py+cs-1,ca,1); ctx.fillRect(px,py+cs-ca,1,ca);
|
| 1006 |
+
ctx.fillRect(px+cs-ca,py+cs-1,ca,1); ctx.fillRect(px+cs-1,py+cs-ca,1,ca);
|
| 1007 |
+
ctx.restore();
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
// ── Door ──────────────────────────────────────────────────────────────────────
|
| 1011 |
+
function drawDoor(ctx,x,y,ct,cs) {
|
| 1012 |
+
const px=x*cs, py=y*cs;
|
| 1013 |
+
if(ct===DOOR_OPEN) {
|
| 1014 |
+
ctx.fillStyle='rgba(0,0,0,0.38)'; ctx.fillRect(px+4,py,cs-6,cs);
|
| 1015 |
+
ctx.fillStyle='#5c3621'; ctx.fillRect(px,py,4,cs);
|
| 1016 |
+
ctx.fillStyle='#291508'; ctx.fillRect(px,py,cs,2);
|
| 1017 |
+
ctx.fillStyle='rgba(125,211,252,0.10)'; ctx.fillRect(px+5,py+2,cs-7,cs-4);
|
| 1018 |
+
} else {
|
| 1019 |
+
ctx.fillStyle='#5c3621'; ctx.fillRect(px+2,py+1,cs-4,cs-2);
|
| 1020 |
+
ctx.fillStyle='#291508';
|
| 1021 |
+
ctx.fillRect(px,py,cs,2); ctx.fillRect(px,py+cs-2,cs,2);
|
| 1022 |
+
ctx.fillRect(px,py+2,2,cs-4); ctx.fillRect(px+cs-2,py+2,2,cs-4);
|
| 1023 |
+
ctx.fillStyle='rgba(0,0,0,0.35)';
|
| 1024 |
+
ctx.fillRect(px+4,py+~~(cs*.14),cs-8,1);
|
| 1025 |
+
ctx.fillRect(px+4,py+~~(cs*.50),cs-8,1);
|
| 1026 |
+
ctx.fillRect(px+4,py+~~(cs*.82),cs-8,1);
|
| 1027 |
+
ctx.fillStyle='rgba(0,0,0,0.13)';
|
| 1028 |
+
ctx.fillRect(px+4,py+~~(cs*.14)+2,cs-8,~~(cs*.34));
|
| 1029 |
+
ctx.fillRect(px+4,py+~~(cs*.50)+2,cs-8,~~(cs*.30));
|
| 1030 |
+
ctx.beginPath(); ctx.arc(px+cs-8,py+~~(cs/2),2.5,0,Math.PI*2);
|
| 1031 |
+
ctx.fillStyle='rgba(210,168,75,.88)'; ctx.fill();
|
| 1032 |
+
}
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
// ── Agent trail ───────────────────────────────────────────────────────────────
|
| 1036 |
+
function updateTrail(ax,ay) {
|
| 1037 |
+
const px=ax*CS+CS/2, py=ay*CS+CS/2;
|
| 1038 |
+
const last=agentTrail[0];
|
| 1039 |
+
if(!last||Math.abs(last.px-px)>1||Math.abs(last.py-py)>1) {
|
| 1040 |
+
agentTrail.unshift({px,py});
|
| 1041 |
+
}
|
| 1042 |
+
while(agentTrail.length>14) agentTrail.pop();
|
| 1043 |
+
}
|
| 1044 |
+
|
| 1045 |
+
function drawTrail(ctx) {
|
| 1046 |
+
for(let i=1;i<agentTrail.length;i++) {
|
| 1047 |
+
const ratio = 1-i/agentTrail.length;
|
| 1048 |
+
const alpha = ratio * 0.70;
|
| 1049 |
+
const r = Math.max(1.5, ratio * CS * 0.24);
|
| 1050 |
+
ctx.beginPath();
|
| 1051 |
+
ctx.arc(agentTrail[i].px,agentTrail[i].py,r,0,Math.PI*2);
|
| 1052 |
+
ctx.fillStyle=`rgba(2,132,199,${alpha.toFixed(3)})`;
|
| 1053 |
+
ctx.fill();
|
| 1054 |
+
}
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
// ── Agent color themes (body = shirt/arms, matches reference image) ───────────
|
| 1058 |
+
const AGENT_COLORS = {
|
| 1059 |
+
healthy: { body:'#3b82f6', dark:'#1d4ed8', ring:'#fbbf24', ringGlow:'#f59e0b' },
|
| 1060 |
+
moderate: { body:'#f97316', dark:'#c2410c', ring:'#fb923c', ringGlow:'#ea580c' },
|
| 1061 |
+
low: { body:'#dc2626', dark:'#991b1b', ring:'#f87171', ringGlow:'#dc2626' },
|
| 1062 |
+
evacuated:{ body:'#16a34a', dark:'#14532d', ring:'#4ade80', ringGlow:'#22c55e' },
|
| 1063 |
+
};
|
| 1064 |
+
|
| 1065 |
+
// ── Minecraft-style blocky pixel character ────────────────────────────────────
|
| 1066 |
+
// Drawn entirely with fillRect — crisp pixel art, no anti-aliasing curves.
|
| 1067 |
+
// Layout (in "units" u = cs/18 ≈ 2px):
|
| 1068 |
+
// Rows 0-1 : Helmet (10u wide incl brim, 2u tall)
|
| 1069 |
+
// Rows 2-5 : Head (8u wide, 4u tall, skin + eyes)
|
| 1070 |
+
// Rows 6-10: Body+Arms (body 6u, arms 2u each side → 10u total, 5u tall)
|
| 1071 |
+
// Rows 11-15: Legs (3u each, 5u tall)
|
| 1072 |
+
// Total: 16u tall × 10u wide → 32px × 20px at cs=36
|
| 1073 |
+
function drawMinecraftAgent(ctx, px, py, cs, theme) {
|
| 1074 |
+
const u = cs / 18; // ~2px at cs=36
|
| 1075 |
+
const ow = 10 * u; // total character width
|
| 1076 |
+
const oh = 16 * u; // total character height
|
| 1077 |
+
const ox = Math.round(px - ow / 2); // top-left X
|
| 1078 |
+
const oy = Math.round(py - oh / 2); // top-left Y (centered on cell)
|
| 1079 |
+
|
| 1080 |
+
// ── Color palette ──
|
| 1081 |
+
const skin = '#f4c08a'; // face / skin
|
| 1082 |
+
const helmTop = '#b8c8d8'; // hard-hat top — steel blue-gray
|
| 1083 |
+
const helmBrm = '#7a98ae'; // brim — darker
|
| 1084 |
+
const helmVis = '#d4e8f8'; // visor stripe — pale blue
|
| 1085 |
+
const body = theme.body; // health-coded shirt
|
| 1086 |
+
const bodyShd = theme.dark; // shirt shadow / dark side
|
| 1087 |
+
const pants = '#1e3a6e'; // navy trousers
|
| 1088 |
+
const pantsShd= '#142856';
|
| 1089 |
+
const eye = '#1a1818';
|
| 1090 |
+
const skin2 = '#e8a870'; // skin shadow / cheek
|
| 1091 |
+
|
| 1092 |
+
// helper: fill a rectangle in character-local units
|
| 1093 |
+
const r = (col, dx, dy, dw, dh) => {
|
| 1094 |
+
ctx.fillStyle = col;
|
| 1095 |
+
ctx.fillRect(ox + dx*u, oy + dy*u, dw*u, dh*u);
|
| 1096 |
+
};
|
| 1097 |
+
|
| 1098 |
+
// ─── HELMET ────────────────────────────────────────────────────────────────
|
| 1099 |
+
r(helmBrm, 0, 0, 10, 1); // brim (full width)
|
| 1100 |
+
r(helmTop, 1, 1, 8, 1); // dome top
|
| 1101 |
+
r(helmVis, 2, 1, 6, 0.5); // visor line on brim
|
| 1102 |
+
|
| 1103 |
+
// ─── HEAD ──────────────────────────────────────────────────────────────────
|
| 1104 |
+
r(skin, 1, 2, 8, 4); // face (skin)
|
| 1105 |
+
r(skin2, 1, 5, 8, 1); // chin / lower shadow
|
| 1106 |
+
r(eye, 2, 3, 1, 1); // left eye
|
| 1107 |
+
r(eye, 5, 3, 1, 1); // right eye
|
| 1108 |
+
r('rgba(160,80,40,0.35)', 3, 5, 2, 0.5); // mouth hint
|
| 1109 |
+
|
| 1110 |
+
// ─── BODY ──────────────────────────────────────────────────────────────────
|
| 1111 |
+
r(body, 2, 6, 6, 5); // torso
|
| 1112 |
+
r(bodyShd, 2, 6, 1, 5); // left-edge shadow (depth)
|
| 1113 |
+
r(bodyShd, 2,10, 6, 1); // bottom shadow
|
| 1114 |
+
|
| 1115 |
+
// ─── ARMS ──────────────────────────────────────────────────────────────────
|
| 1116 |
+
r(body, 0, 6, 2, 5); // left arm
|
| 1117 |
+
r(bodyShd, 0, 6, 1, 5); // left arm shadow
|
| 1118 |
+
r(body, 8, 6, 2, 5); // right arm
|
| 1119 |
+
r(bodyShd, 9, 6, 1, 5); // right arm right-edge shadow
|
| 1120 |
+
|
| 1121 |
+
// ─── LEGS ──────────────────────────────────────────────────────────────────
|
| 1122 |
+
r(pants, 2, 11, 3, 5); // left leg
|
| 1123 |
+
r(pantsShd, 2, 11, 1, 5); // left leg shadow
|
| 1124 |
+
r(pants, 5, 11, 3, 5); // right leg
|
| 1125 |
+
r(pantsShd, 5, 11, 1, 5); // right leg shadow
|
| 1126 |
+
r('rgba(0,0,0,0.18)', 4, 11, 1, 5); // centre gap between legs
|
| 1127 |
+
}
|
| 1128 |
+
|
| 1129 |
+
// ── Agent: golden aura + Minecraft character + health arc ────────────────────
|
| 1130 |
+
function drawAgent(ctx, ax, ay, health, alive, evacuated, cs) {
|
| 1131 |
+
if (!alive && !evacuated) return;
|
| 1132 |
+
const px = ax*cs + cs/2, py = ay*cs + cs/2;
|
| 1133 |
+
// Aura pulse — two independent oscillators (matching original frontend)
|
| 1134 |
+
const pulse2 = 0.68 + 0.32*Math.sin(time*2.1 + 1.0);
|
| 1135 |
+
const pulse3 = 0.80 + 0.20*Math.sin(time*4.8); // faster inner shimmer
|
| 1136 |
+
|
| 1137 |
+
const hRatio = Math.max(0, Math.min(1, health/100));
|
| 1138 |
+
const theme = evacuated ? AGENT_COLORS.evacuated
|
| 1139 |
+
: hRatio > 0.6 ? AGENT_COLORS.healthy
|
| 1140 |
+
: hRatio > 0.3 ? AGENT_COLORS.moderate
|
| 1141 |
+
: AGENT_COLORS.low;
|
| 1142 |
+
|
| 1143 |
+
const arcBaseR = cs * 0.42; // fixed radius (no size-pulse — keeps pixel char stable)
|
| 1144 |
+
|
| 1145 |
+
// ── 1. GOLDEN OUTER GLOW (large halo — like reference image) ──────────────
|
| 1146 |
+
ctx.save();
|
| 1147 |
+
const auraR = cs * 0.85 * pulse2;
|
| 1148 |
+
const aGr = ctx.createRadialGradient(px, py, arcBaseR*0.55, px, py, auraR);
|
| 1149 |
+
aGr.addColorStop(0, 'rgba(0,0,0,0)');
|
| 1150 |
+
aGr.addColorStop(0.40, `rgba(251,191,36,${0.28 * pulse3})`); // gold inner
|
| 1151 |
+
aGr.addColorStop(0.72, `rgba(245,158,11,${0.14 * pulse3})`); // amber mid
|
| 1152 |
+
aGr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 1153 |
+
ctx.fillStyle = aGr;
|
| 1154 |
+
ctx.beginPath(); ctx.arc(px, py, auraR, 0, Math.PI*2); ctx.fill();
|
| 1155 |
+
ctx.restore();
|
| 1156 |
+
|
| 1157 |
+
// ── 2. GROUND SHADOW (ellipse under feet) ─────────────────────────────────
|
| 1158 |
+
ctx.save();
|
| 1159 |
+
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
| 1160 |
+
ctx.beginPath();
|
| 1161 |
+
ctx.ellipse(px + 1, py + cs*0.46, cs*0.24, cs*0.055, 0, 0, Math.PI*2);
|
| 1162 |
+
ctx.fill();
|
| 1163 |
+
ctx.restore();
|
| 1164 |
+
|
| 1165 |
+
// ── 3. CHARACTER SHADOW GLOW (subtle colored shadow under sprite) ──────────
|
| 1166 |
+
ctx.save();
|
| 1167 |
+
ctx.shadowBlur = cs * 0.55;
|
| 1168 |
+
ctx.shadowColor = theme.ring;
|
| 1169 |
+
// Draw an invisible rect at the character centre just to emit the shadow
|
| 1170 |
+
ctx.fillStyle = 'rgba(0,0,0,0)';
|
| 1171 |
+
ctx.fillRect(px - 1, py - 1, 2, 2);
|
| 1172 |
+
ctx.restore();
|
| 1173 |
+
|
| 1174 |
+
// ── 4. MINECRAFT CHARACTER (crisp pixel art — no transforms) ──────────────
|
| 1175 |
+
drawMinecraftAgent(ctx, px, py, cs, theme);
|
| 1176 |
+
|
| 1177 |
+
// ── 5. HEALTH ARC RING (gold/orange/red — clockwise from 12 o'clock) ──────
|
| 1178 |
+
ctx.save();
|
| 1179 |
+
const arcR = arcBaseR * 1.68;
|
| 1180 |
+
|
| 1181 |
+
// Track ring (always full circle, subtle)
|
| 1182 |
+
ctx.beginPath(); ctx.arc(px, py, arcR, 0, Math.PI*2);
|
| 1183 |
+
ctx.strokeStyle = 'rgba(0,0,0,0.12)'; ctx.lineWidth = 4; ctx.stroke();
|
| 1184 |
+
|
| 1185 |
+
// Health fill arc
|
| 1186 |
+
if (hRatio > 0) {
|
| 1187 |
+
const arcCol = theme.ring;
|
| 1188 |
+
const arcGlow = theme.ringGlow;
|
| 1189 |
+
ctx.beginPath();
|
| 1190 |
+
ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + hRatio*Math.PI*2);
|
| 1191 |
+
ctx.strokeStyle = arcCol;
|
| 1192 |
+
ctx.lineWidth = 4.5;
|
| 1193 |
+
ctx.lineCap = 'round';
|
| 1194 |
+
// Double-stroke for bright glow (same technique as original frontend)
|
| 1195 |
+
ctx.shadowBlur = 14;
|
| 1196 |
+
ctx.shadowColor = arcGlow;
|
| 1197 |
+
ctx.stroke();
|
| 1198 |
+
ctx.shadowBlur = 6;
|
| 1199 |
+
ctx.stroke();
|
| 1200 |
+
}
|
| 1201 |
+
ctx.restore();
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
+
// ── Mini sparkline chart ──────────────────────────────────────────────────────
|
| 1205 |
+
function drawSparkline(canvasEl, data, color, fill) {
|
| 1206 |
+
if(!canvasEl) return;
|
| 1207 |
+
const c=canvasEl.getContext('2d');
|
| 1208 |
+
const w=canvasEl.offsetWidth||canvasEl.parentElement.clientWidth||240;
|
| 1209 |
+
const h=canvasEl.height;
|
| 1210 |
+
canvasEl.width=w;
|
| 1211 |
+
c.fillStyle='#faf9f6'; c.fillRect(0,0,w,h);
|
| 1212 |
+
if(data.length<2) return;
|
| 1213 |
+
const yMin=Math.min(...data), yMax=Math.max(...data);
|
| 1214 |
+
const yRange=(yMax-yMin)||1;
|
| 1215 |
+
const pad=4;
|
| 1216 |
+
// Grid lines
|
| 1217 |
+
c.strokeStyle='rgba(0,0,0,0.06)'; c.lineWidth=.5;
|
| 1218 |
+
for(let i=0;i<=2;i++){
|
| 1219 |
+
const gy=pad+(h-2*pad)*(i/2);
|
| 1220 |
+
c.beginPath(); c.moveTo(0,gy); c.lineTo(w,gy); c.stroke();
|
| 1221 |
+
}
|
| 1222 |
+
const toX=i=>(i/(data.length-1))*w;
|
| 1223 |
+
const toY=v=>h-pad-((v-yMin)/yRange)*(h-2*pad);
|
| 1224 |
+
// Fill area
|
| 1225 |
+
c.beginPath(); c.moveTo(toX(0),h);
|
| 1226 |
+
for(let i=0;i<data.length;i++) c.lineTo(toX(i),toY(data[i]));
|
| 1227 |
+
c.lineTo(toX(data.length-1),h); c.closePath();
|
| 1228 |
+
c.fillStyle=fill; c.fill();
|
| 1229 |
+
// Line
|
| 1230 |
+
c.beginPath(); c.moveTo(toX(0),toY(data[0]));
|
| 1231 |
+
for(let i=1;i<data.length;i++) c.lineTo(toX(i),toY(data[i]));
|
| 1232 |
+
c.strokeStyle=color; c.lineWidth=1.8; c.stroke();
|
| 1233 |
+
// Dot
|
| 1234 |
+
const lx=toX(data.length-1), ly=toY(data[data.length-1]);
|
| 1235 |
+
c.beginPath(); c.arc(lx,ly,3,0,Math.PI*2);
|
| 1236 |
+
c.fillStyle=color; c.fill();
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
// ── HUD & UI updates ──────────────────────────────────────────────────────────
|
| 1240 |
+
function updateHUD(s) {
|
| 1241 |
+
// Health bar
|
| 1242 |
+
const hPct=s.agentHealth/100;
|
| 1243 |
+
const bar=document.getElementById('healthBar');
|
| 1244 |
+
if(bar){ bar.style.width=`${s.agentHealth}%`; bar.className='hbar-fill '+(hPct>.6?'g':hPct>.3?'m':'c'); }
|
| 1245 |
+
setText('healthVal', Math.round(s.agentHealth));
|
| 1246 |
+
|
| 1247 |
+
const status = s.agentHealth>75?'Good':s.agentHealth>50?'Moderate':s.agentHealth>25?'Low':'Critical';
|
| 1248 |
+
const hStatus=document.getElementById('healthStatus');
|
| 1249 |
+
if(hStatus){ hStatus.textContent=status; hStatus.className='hstatus '+status.toLowerCase(); }
|
| 1250 |
+
|
| 1251 |
+
// Smoke
|
| 1252 |
+
const smoke=s.smokeGrid[s.agentY*W+s.agentX];
|
| 1253 |
+
const smokeLabel=smoke>.8?'heavy':smoke>.5?'moderate':smoke>.2?'light':'clear';
|
| 1254 |
+
setText('smokeTag',`smoke: ${smokeLabel}`);
|
| 1255 |
+
|
| 1256 |
+
// Steps
|
| 1257 |
+
setText('stepCount',`${s.stepCount} / ${s.maxSteps}`);
|
| 1258 |
+
const sb=document.getElementById('stepBar');
|
| 1259 |
+
if(sb) sb.style.width=`${100*s.stepCount/s.maxSteps}%`;
|
| 1260 |
+
setText('diffTag',`${s.difficulty} · small_office · ${s.windDir}`);
|
| 1261 |
+
|
| 1262 |
+
// Episode banner
|
| 1263 |
+
const banner=document.getElementById('epBanner');
|
| 1264 |
+
if(banner) {
|
| 1265 |
+
if(s.agentEvacuated){ banner.textContent='✓ EVACUATED — reached safety'; banner.className='ok'; banner.style.display='block'; }
|
| 1266 |
+
else if(!s.agentAlive){ banner.textContent='✗ INCAPACITATED'; banner.className='bad'; banner.style.display='block'; }
|
| 1267 |
+
else if(s.stepCount>=s.maxSteps){ banner.textContent='⏱ TIMEOUT'; banner.className='tmo'; banner.style.display='block'; }
|
| 1268 |
+
else banner.style.display='none';
|
| 1269 |
+
}
|
| 1270 |
+
|
| 1271 |
+
// Side panel
|
| 1272 |
+
setText('sHealthVal', Math.round(s.agentHealth));
|
| 1273 |
+
setText('sStatusVal', status);
|
| 1274 |
+
setText('sSmokeVal', smokeLabel);
|
| 1275 |
+
setText('sRewardVal', s.totalReward.toFixed(2));
|
| 1276 |
+
const fireCells=s.fireSizeHistory[s.fireSizeHistory.length-1]||0;
|
| 1277 |
+
setText('sFireVal', fireCells);
|
| 1278 |
+
setText('sStepVal', s.stepCount);
|
| 1279 |
+
setText('sWindVal', s.windDir);
|
| 1280 |
+
setText('sSpreadVal',s.pSpread.toFixed(2));
|
| 1281 |
+
const burnedPct=Math.round(100*Array.from(s.fireGrid).filter(f=>f>0).length/(W*H));
|
| 1282 |
+
setText('sBurnVal',`${burnedPct}% burned`);
|
| 1283 |
+
const exitBlock=EXITS.filter(([ex,ey])=>s.fireGrid[ey*W+ex]>=EXIT_BLOCK).length;
|
| 1284 |
+
setText('sExitBlocked',`${exitBlock} / ${EXITS.length}`);
|
| 1285 |
+
document.getElementById('sExitBlocked').style.color = exitBlock>0?'var(--fire)':'var(--green)';
|
| 1286 |
+
|
| 1287 |
+
// Reward delta
|
| 1288 |
+
if(s.lastReward!==undefined) {
|
| 1289 |
+
const lr=s.lastReward;
|
| 1290 |
+
const el=document.getElementById('sRewardLast');
|
| 1291 |
+
if(el){ el.textContent=(lr>0?'+':'')+lr.toFixed(2); el.style.color=lr>0?'var(--green)':'var(--red)'; }
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
// Charts
|
| 1295 |
+
drawSparkline(document.getElementById('rewardChart'), s.rewardHistory,'#1d4ed8','rgba(29,78,216,0.08)');
|
| 1296 |
+
drawSparkline(document.getElementById('fireChart'), s.fireSizeHistory,'#c2410c','rgba(194,65,12,0.08)');
|
| 1297 |
+
const fl=document.getElementById('sFireLast');
|
| 1298 |
+
if(fl) fl.textContent=`${fireCells} cells`;
|
| 1299 |
+
|
| 1300 |
+
// Event log
|
| 1301 |
+
const log=document.getElementById('eventLog');
|
| 1302 |
+
if(log) {
|
| 1303 |
+
log.innerHTML=s.eventLog.slice(0,18).map(e=>`
|
| 1304 |
+
<div class="erow${e.isAlert?' alarm':''}">
|
| 1305 |
+
<span class="estep">${String(e.step).padStart(3,'0')}</span>
|
| 1306 |
+
<span class="etext">${e.text}</span>
|
| 1307 |
+
${e.reward?`<span class="erwd ${e.reward>0?'p':'n'}">${e.reward>0?'+':''}${e.reward.toFixed(2)}</span>`:''}
|
| 1308 |
+
</div>`).join('');
|
| 1309 |
+
}
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
function setText(id,val) {
|
| 1313 |
+
const el=document.getElementById(id);
|
| 1314 |
+
if(el) el.textContent=val;
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
function showToast(msg) {
|
| 1318 |
+
const t=document.getElementById('toast');
|
| 1319 |
+
if(!t) return;
|
| 1320 |
+
t.textContent=msg; t.classList.add('up');
|
| 1321 |
+
setTimeout(()=>t.classList.remove('up'),3000);
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
// ── App controller ────────────────────────────────────────────────────────────
|
| 1325 |
+
let playing=false, speed=1, stepTimer=null, rafId=null;
|
| 1326 |
+
let difficulty='easy';
|
| 1327 |
+
|
| 1328 |
+
function getSeed() { return parseInt(document.getElementById('seedInput')?.value)||42; }
|
| 1329 |
+
|
| 1330 |
+
function newEpisode() {
|
| 1331 |
+
clearTimer();
|
| 1332 |
+
state = buildState(difficulty, getSeed());
|
| 1333 |
+
initRender();
|
| 1334 |
+
updateHUD(state);
|
| 1335 |
+
setText('liveChip','Demo Mode');
|
| 1336 |
+
document.getElementById('epBanner').style.display='none';
|
| 1337 |
+
playing=false; updatePlayBtn();
|
| 1338 |
+
setTimeout(()=>{ playing=true; updatePlayBtn(); startTimer(); },350);
|
| 1339 |
+
}
|
| 1340 |
+
|
| 1341 |
+
function tick() {
|
| 1342 |
+
if(!state) return;
|
| 1343 |
+
doStep(state);
|
| 1344 |
+
updateHUD(state);
|
| 1345 |
+
if(state.agentEvacuated||!state.agentAlive||state.stepCount>=state.maxSteps) {
|
| 1346 |
+
playing=false; clearTimer(); updatePlayBtn();
|
| 1347 |
+
setTimeout(()=>newEpisode(), 2800);
|
| 1348 |
+
}
|
| 1349 |
+
}
|
| 1350 |
+
|
| 1351 |
+
function startTimer() {
|
| 1352 |
+
clearTimer();
|
| 1353 |
+
stepTimer=setInterval(tick, Math.max(50,1000/speed));
|
| 1354 |
+
}
|
| 1355 |
+
function clearTimer() { if(stepTimer){ clearInterval(stepTimer); stepTimer=null; } }
|
| 1356 |
+
function togglePlay() {
|
| 1357 |
+
playing=!playing;
|
| 1358 |
+
if(playing) startTimer(); else clearTimer();
|
| 1359 |
+
updatePlayBtn();
|
| 1360 |
+
}
|
| 1361 |
+
function updatePlayBtn() {
|
| 1362 |
+
const b=document.getElementById('playBtn');
|
| 1363 |
+
if(b){ b.textContent=playing?'⏸':'▶'; b.classList.toggle('play',!playing); }
|
| 1364 |
+
}
|
| 1365 |
+
|
| 1366 |
+
// Render loop (independent of sim tick rate)
|
| 1367 |
+
function startRender() {
|
| 1368 |
+
const loop=()=>{
|
| 1369 |
+
if(state) renderFrame(state);
|
| 1370 |
+
rafId=requestAnimationFrame(loop);
|
| 1371 |
+
};
|
| 1372 |
+
rafId=requestAnimationFrame(loop);
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
// ── Controls ──────────────────────────────────────────────────────────────────
|
| 1376 |
+
document.getElementById('playBtn')?.addEventListener('click',togglePlay);
|
| 1377 |
+
document.getElementById('stepBtn')?.addEventListener('click',()=>{ if(state&&!state.agentEvacuated&&state.agentAlive) { doStep(state); updateHUD(state); } });
|
| 1378 |
+
document.getElementById('resetBtn')?.addEventListener('click',newEpisode);
|
| 1379 |
+
|
| 1380 |
+
document.querySelectorAll('.diff-btn').forEach(b=>{
|
| 1381 |
+
b.addEventListener('click',()=>{
|
| 1382 |
+
document.querySelectorAll('.diff-btn').forEach(x=>{x.classList.remove('on','fire');});
|
| 1383 |
+
b.classList.add('on','fire');
|
| 1384 |
+
difficulty=b.dataset.diff;
|
| 1385 |
+
newEpisode();
|
| 1386 |
+
});
|
| 1387 |
+
});
|
| 1388 |
+
|
| 1389 |
+
document.querySelectorAll('.spd-btn').forEach(b=>{
|
| 1390 |
+
b.addEventListener('click',()=>{
|
| 1391 |
+
document.querySelectorAll('.spd-btn').forEach(x=>x.classList.remove('on'));
|
| 1392 |
+
b.classList.add('on'); speed=parseFloat(b.dataset.s);
|
| 1393 |
+
if(playing){ clearTimer(); startTimer(); }
|
| 1394 |
+
});
|
| 1395 |
+
});
|
| 1396 |
+
|
| 1397 |
+
document.getElementById('seedInput')?.addEventListener('change',newEpisode);
|
| 1398 |
+
|
| 1399 |
+
window.addEventListener('keydown',e=>{
|
| 1400 |
+
if(e.target.tagName==='INPUT') return;
|
| 1401 |
+
if(e.code==='Space'){ e.preventDefault(); togglePlay(); }
|
| 1402 |
+
else if(e.code==='ArrowRight'&&state&&!state.agentEvacuated&&state.agentAlive){ doStep(state); updateHUD(state); }
|
| 1403 |
+
else if(e.code==='KeyR') newEpisode();
|
| 1404 |
+
});
|
| 1405 |
+
|
| 1406 |
+
// ── Boot ──────────────────────────────────────────────────────────────────────
|
| 1407 |
+
window.addEventListener('DOMContentLoaded',()=>{
|
| 1408 |
+
newEpisode();
|
| 1409 |
+
startRender();
|
| 1410 |
+
showToast('Press Space to pause · → to step · R to reset');
|
| 1411 |
+
});
|
| 1412 |
+
</script>
|
| 1413 |
+
</body>
|
| 1414 |
+
</html>
|
evals_hf.ipynb
CHANGED
|
@@ -26,10 +26,18 @@
|
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"cell_type": "code",
|
| 29 |
-
"execution_count":
|
| 30 |
"id": "c3d4e5f6",
|
| 31 |
"metadata": {},
|
| 32 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
"source": [
|
| 34 |
"import csv\n",
|
| 35 |
"import json\n",
|
|
@@ -65,7 +73,7 @@
|
|
| 65 |
},
|
| 66 |
{
|
| 67 |
"cell_type": "markdown",
|
| 68 |
-
"id": "
|
| 69 |
"metadata": {},
|
| 70 |
"source": [
|
| 71 |
"## Cell 2 — Config ✏️ Edit this cell to change eval settings"
|
|
@@ -73,10 +81,23 @@
|
|
| 73 |
},
|
| 74 |
{
|
| 75 |
"cell_type": "code",
|
| 76 |
-
"execution_count":
|
| 77 |
"id": "e5f6a7b8",
|
| 78 |
"metadata": {},
|
| 79 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
"source": [
|
| 81 |
"# ── Model ─────────────────────────────────────────────────────────────────────\n",
|
| 82 |
"MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
|
|
@@ -123,10 +144,19 @@
|
|
| 123 |
},
|
| 124 |
{
|
| 125 |
"cell_type": "code",
|
| 126 |
-
"execution_count":
|
| 127 |
"id": "a7b8c9d0",
|
| 128 |
"metadata": {},
|
| 129 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
"source": [
|
| 131 |
"ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
|
| 132 |
" {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
|
|
@@ -195,10 +225,18 @@
|
|
| 195 |
},
|
| 196 |
{
|
| 197 |
"cell_type": "code",
|
| 198 |
-
"execution_count":
|
| 199 |
"id": "c9d0e1f2",
|
| 200 |
"metadata": {},
|
| 201 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
"source": [
|
| 203 |
"class HFChatModel(SimpleChatModel):\n",
|
| 204 |
" \"\"\"\n",
|
|
@@ -308,10 +346,18 @@
|
|
| 308 |
},
|
| 309 |
{
|
| 310 |
"cell_type": "code",
|
| 311 |
-
"execution_count":
|
| 312 |
"id": "e1f2a3b4",
|
| 313 |
"metadata": {},
|
| 314 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
"source": [
|
| 316 |
"def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
|
| 317 |
" \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
|
|
@@ -466,10 +512,18 @@
|
|
| 466 |
},
|
| 467 |
{
|
| 468 |
"cell_type": "code",
|
| 469 |
-
"execution_count":
|
| 470 |
"id": "a3b4c5d6",
|
| 471 |
"metadata": {},
|
| 472 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
"source": [
|
| 474 |
"def run_episode(\n",
|
| 475 |
" llm: HFChatModel,\n",
|
|
@@ -637,10 +691,54 @@
|
|
| 637 |
},
|
| 638 |
{
|
| 639 |
"cell_type": "code",
|
| 640 |
-
"execution_count":
|
| 641 |
"id": "c5d6e7f8",
|
| 642 |
"metadata": {},
|
| 643 |
-
"outputs": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 644 |
"source": [
|
| 645 |
"# Health check first — fail fast before waiting for the large model to load\n",
|
| 646 |
"try:\n",
|
|
@@ -855,13 +953,9 @@
|
|
| 855 |
],
|
| 856 |
"metadata": {
|
| 857 |
"kernelspec": {
|
| 858 |
-
"display_name": "Python 3",
|
| 859 |
"language": "python",
|
| 860 |
"name": "python3"
|
| 861 |
-
},
|
| 862 |
-
"language_info": {
|
| 863 |
-
"name": "python",
|
| 864 |
-
"version": "3.10.0"
|
| 865 |
}
|
| 866 |
},
|
| 867 |
"nbformat": 4,
|
|
|
|
| 26 |
},
|
| 27 |
{
|
| 28 |
"cell_type": "code",
|
| 29 |
+
"execution_count": 1,
|
| 30 |
"id": "c3d4e5f6",
|
| 31 |
"metadata": {},
|
| 32 |
+
"outputs": [
|
| 33 |
+
{
|
| 34 |
+
"name": "stdout",
|
| 35 |
+
"output_type": "stream",
|
| 36 |
+
"text": [
|
| 37 |
+
"Imports OK\n"
|
| 38 |
+
]
|
| 39 |
+
}
|
| 40 |
+
],
|
| 41 |
"source": [
|
| 42 |
"import csv\n",
|
| 43 |
"import json\n",
|
|
|
|
| 73 |
},
|
| 74 |
{
|
| 75 |
"cell_type": "markdown",
|
| 76 |
+
"id": "1f1d9271",
|
| 77 |
"metadata": {},
|
| 78 |
"source": [
|
| 79 |
"## Cell 2 — Config ✏️ Edit this cell to change eval settings"
|
|
|
|
| 81 |
},
|
| 82 |
{
|
| 83 |
"cell_type": "code",
|
| 84 |
+
"execution_count": 2,
|
| 85 |
"id": "e5f6a7b8",
|
| 86 |
"metadata": {},
|
| 87 |
+
"outputs": [
|
| 88 |
+
{
|
| 89 |
+
"name": "stdout",
|
| 90 |
+
"output_type": "stream",
|
| 91 |
+
"text": [
|
| 92 |
+
"Model : Qwen/Qwen3-1.7B\n",
|
| 93 |
+
"4-bit : False\n",
|
| 94 |
+
"Temperature: 0.3\n",
|
| 95 |
+
"Levels : all 3 (easy, medium, hard)\n",
|
| 96 |
+
"Seeds : [1, 2, 3]\n",
|
| 97 |
+
"Output dir : ./outputs/hf_evals\n"
|
| 98 |
+
]
|
| 99 |
+
}
|
| 100 |
+
],
|
| 101 |
"source": [
|
| 102 |
"# ── Model ─────────────────────────────────────────────────────────────────────\n",
|
| 103 |
"MODEL_ID = \"Qwen/Qwen3-1.7B\" # HF model ID or local path / adapter dir\n",
|
|
|
|
| 144 |
},
|
| 145 |
{
|
| 146 |
"cell_type": "code",
|
| 147 |
+
"execution_count": 3,
|
| 148 |
"id": "a7b8c9d0",
|
| 149 |
"metadata": {},
|
| 150 |
+
"outputs": [
|
| 151 |
+
{
|
| 152 |
+
"name": "stdout",
|
| 153 |
+
"output_type": "stream",
|
| 154 |
+
"text": [
|
| 155 |
+
"Difficulties to evaluate : ['easy', 'medium', 'hard']\n",
|
| 156 |
+
"Total episodes : 9\n"
|
| 157 |
+
]
|
| 158 |
+
}
|
| 159 |
+
],
|
| 160 |
"source": [
|
| 161 |
"ALL_DIFFICULTIES: List[Dict[str, Any]] = [\n",
|
| 162 |
" {\"difficulty\": \"easy\", \"max_steps\": 200, \"description\": \"1 fire source · slow spread · calm wind · high humidity\"},\n",
|
|
|
|
| 225 |
},
|
| 226 |
{
|
| 227 |
"cell_type": "code",
|
| 228 |
+
"execution_count": 4,
|
| 229 |
"id": "c9d0e1f2",
|
| 230 |
"metadata": {},
|
| 231 |
+
"outputs": [
|
| 232 |
+
{
|
| 233 |
+
"name": "stdout",
|
| 234 |
+
"output_type": "stream",
|
| 235 |
+
"text": [
|
| 236 |
+
"HFChatModel class defined.\n"
|
| 237 |
+
]
|
| 238 |
+
}
|
| 239 |
+
],
|
| 240 |
"source": [
|
| 241 |
"class HFChatModel(SimpleChatModel):\n",
|
| 242 |
" \"\"\"\n",
|
|
|
|
| 346 |
},
|
| 347 |
{
|
| 348 |
"cell_type": "code",
|
| 349 |
+
"execution_count": 5,
|
| 350 |
"id": "e1f2a3b4",
|
| 351 |
"metadata": {},
|
| 352 |
+
"outputs": [
|
| 353 |
+
{
|
| 354 |
+
"name": "stdout",
|
| 355 |
+
"output_type": "stream",
|
| 356 |
+
"text": [
|
| 357 |
+
"Prompt builder and action parser defined.\n"
|
| 358 |
+
]
|
| 359 |
+
}
|
| 360 |
+
],
|
| 361 |
"source": [
|
| 362 |
"def _build_user_message(obs: Dict[str, Any], history: List[str]) -> str:\n",
|
| 363 |
" \"\"\"Convert a raw observation dict + history into the LLM user message.\"\"\"\n",
|
|
|
|
| 512 |
},
|
| 513 |
{
|
| 514 |
"cell_type": "code",
|
| 515 |
+
"execution_count": 6,
|
| 516 |
"id": "a3b4c5d6",
|
| 517 |
"metadata": {},
|
| 518 |
+
"outputs": [
|
| 519 |
+
{
|
| 520 |
+
"name": "stdout",
|
| 521 |
+
"output_type": "stream",
|
| 522 |
+
"text": [
|
| 523 |
+
"Episode runner defined.\n"
|
| 524 |
+
]
|
| 525 |
+
}
|
| 526 |
+
],
|
| 527 |
"source": [
|
| 528 |
"def run_episode(\n",
|
| 529 |
" llm: HFChatModel,\n",
|
|
|
|
| 691 |
},
|
| 692 |
{
|
| 693 |
"cell_type": "code",
|
| 694 |
+
"execution_count": 7,
|
| 695 |
"id": "c5d6e7f8",
|
| 696 |
"metadata": {},
|
| 697 |
+
"outputs": [
|
| 698 |
+
{
|
| 699 |
+
"ename": "RuntimeError",
|
| 700 |
+
"evalue": "Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
|
| 701 |
+
"output_type": "error",
|
| 702 |
+
"traceback": [
|
| 703 |
+
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
| 704 |
+
"\u001b[31mConnectionRefusedError\u001b[39m Traceback (most recent call last)",
|
| 705 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:204\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 203\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m204\u001b[39m sock = \u001b[30;43mconnection\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mcreate_connection\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 205\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_dns_host\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mport\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 206\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 207\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msource_address\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 208\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msocket_options\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 209\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 210\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m socket.gaierror \u001b[38;5;28;01mas\u001b[39;00m e:\n",
|
| 706 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:85\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 84\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m---> \u001b[39m\u001b[32m85\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m err\n\u001b[32m 86\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n\u001b[32m 87\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
|
| 707 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/connection.py:73\u001b[39m, in \u001b[36mcreate_connection\u001b[39m\u001b[34m(address, timeout, source_address, socket_options)\u001b[39m\n\u001b[32m 72\u001b[39m sock.bind(source_address)\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[30;43msock\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43msa\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 74\u001b[39m \u001b[38;5;66;03m# Break explicitly a reference cycle\u001b[39;00m\n",
|
| 708 |
+
"\u001b[31mConnectionRefusedError\u001b[39m: [Errno 111] Connection refused",
|
| 709 |
+
"\nThe above exception was the direct cause of the following exception:\n",
|
| 710 |
+
"\u001b[31mNewConnectionError\u001b[39m Traceback (most recent call last)",
|
| 711 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:787\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 786\u001b[39m \u001b[38;5;66;03m# Make the request on the HTTPConnection object\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m787\u001b[39m response = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_make_request\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 788\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mconn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 789\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 790\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 791\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout_obj\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 792\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 793\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 794\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 795\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 796\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mresponse_conn\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 797\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 798\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 799\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mresponse_kw\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 800\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 802\u001b[39m \u001b[38;5;66;03m# Everything went great!\u001b[39;00m\n",
|
| 712 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:493\u001b[39m, in \u001b[36mHTTPConnectionPool._make_request\u001b[39m\u001b[34m(self, conn, method, url, body, headers, retries, timeout, chunked, response_conn, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 492\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m493\u001b[39m \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 494\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 495\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 496\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 497\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 498\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 499\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 500\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 501\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43menforce_content_length\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 504\u001b[39m \u001b[38;5;66;03m# We are swallowing BrokenPipeError (errno.EPIPE) since the server is\u001b[39;00m\n\u001b[32m 505\u001b[39m \u001b[38;5;66;03m# legitimately able to close the connection after sending a valid response.\u001b[39;00m\n\u001b[32m 506\u001b[39m \u001b[38;5;66;03m# With this behaviour, the received response is still readable.\u001b[39;00m\n",
|
| 713 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:500\u001b[39m, in \u001b[36mHTTPConnection.request\u001b[39m\u001b[34m(self, method, url, body, headers, chunked, preload_content, decode_content, enforce_content_length)\u001b[39m\n\u001b[32m 499\u001b[39m \u001b[38;5;28mself\u001b[39m.putheader(header, value)\n\u001b[32m--> \u001b[39m\u001b[32m500\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mendheaders\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 502\u001b[39m \u001b[38;5;66;03m# If we're given a body we start sending that in chunks.\u001b[39;00m\n",
|
| 714 |
+
"\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1333\u001b[39m, in \u001b[36mHTTPConnection.endheaders\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1332\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m CannotSendHeader()\n\u001b[32m-> \u001b[39m\u001b[32m1333\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_send_output\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmessage_body\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mencode_chunked\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
|
| 715 |
+
"\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1093\u001b[39m, in \u001b[36mHTTPConnection._send_output\u001b[39m\u001b[34m(self, message_body, encode_chunked)\u001b[39m\n\u001b[32m 1092\u001b[39m \u001b[38;5;28;01mdel\u001b[39;00m \u001b[38;5;28mself\u001b[39m._buffer[:]\n\u001b[32m-> \u001b[39m\u001b[32m1093\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmsg\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1095\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m message_body \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m 1096\u001b[39m \n\u001b[32m 1097\u001b[39m \u001b[38;5;66;03m# create a consistent interface to message_body\u001b[39;00m\n",
|
| 716 |
+
"\u001b[36mFile \u001b[39m\u001b[32m~/.local/share/uv/python/cpython-3.12.12-linux-x86_64-gnu/lib/python3.12/http/client.py:1037\u001b[39m, in \u001b[36mHTTPConnection.send\u001b[39m\u001b[34m(self, data)\u001b[39m\n\u001b[32m 1036\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m.auto_open:\n\u001b[32m-> \u001b[39m\u001b[32m1037\u001b[39m \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mconnect\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 1038\u001b[39m \u001b[38;5;28;01melse\u001b[39;00m:\n",
|
| 717 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:331\u001b[39m, in \u001b[36mHTTPConnection.connect\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 330\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34mconnect\u001b[39m(\u001b[38;5;28mself\u001b[39m) -> \u001b[38;5;28;01mNone\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m331\u001b[39m \u001b[38;5;28mself\u001b[39m.sock = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43m_new_conn\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 332\u001b[39m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mself\u001b[39m._tunnel_host:\n\u001b[32m 333\u001b[39m \u001b[38;5;66;03m# If we're tunneling it means we're connected to our proxy.\u001b[39;00m\n",
|
| 718 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connection.py:219\u001b[39m, in \u001b[36mHTTPConnection._new_conn\u001b[39m\u001b[34m(self)\u001b[39m\n\u001b[32m 218\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mOSError\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[32m--> \u001b[39m\u001b[32m219\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m NewConnectionError(\n\u001b[32m 220\u001b[39m \u001b[38;5;28mself\u001b[39m, \u001b[33mf\u001b[39m\u001b[33m\"\u001b[39m\u001b[33mFailed to establish a new connection: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00me\u001b[38;5;132;01m}\u001b[39;00m\u001b[33m\"\u001b[39m\n\u001b[32m 221\u001b[39m ) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01me\u001b[39;00m\n\u001b[32m 223\u001b[39m sys.audit(\u001b[33m\"\u001b[39m\u001b[33mhttp.client.connect\u001b[39m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m, \u001b[38;5;28mself\u001b[39m.host, \u001b[38;5;28mself\u001b[39m.port)\n",
|
| 719 |
+
"\u001b[31mNewConnectionError\u001b[39m: HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused",
|
| 720 |
+
"\nThe above exception was the direct cause of the following exception:\n",
|
| 721 |
+
"\u001b[31mMaxRetryError\u001b[39m Traceback (most recent call last)",
|
| 722 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:645\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 644\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m645\u001b[39m resp = \u001b[30;43mconn\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43murlopen\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 646\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 647\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 648\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mbody\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 649\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mheaders\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 650\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mredirect\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 651\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43massert_same_host\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 652\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mpreload_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 653\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mdecode_content\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43;01mFalse\u001b[39;49;00m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 654\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mretries\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mmax_retries\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 655\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mtimeout\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 656\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mchunked\u001b[39;49m\u001b[30;43m,\u001b[39;49m\n\u001b[32m 657\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 659\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m (ProtocolError, \u001b[38;5;167;01mOSError\u001b[39;00m) \u001b[38;5;28;01mas\u001b[39;00m err:\n",
|
| 723 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/connectionpool.py:841\u001b[39m, in \u001b[36mHTTPConnectionPool.urlopen\u001b[39m\u001b[34m(self, method, url, body, headers, retries, redirect, assert_same_host, timeout, pool_timeout, release_conn, chunked, body_pos, preload_content, decode_content, **response_kw)\u001b[39m\n\u001b[32m 839\u001b[39m new_e = ProtocolError(\u001b[33m\"\u001b[39m\u001b[33mConnection aborted.\u001b[39m\u001b[33m\"\u001b[39m, new_e)\n\u001b[32m--> \u001b[39m\u001b[32m841\u001b[39m retries = \u001b[30;43mretries\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mincrement\u001b[39;49m\u001b[30;43m(\u001b[39;49m\n\u001b[32m 842\u001b[39m \u001b[30;43m \u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43merror\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mnew_e\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_pool\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mself\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m_stacktrace\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43msys\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mexc_info\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m)\u001b[39;49m\u001b[30;43m[\u001b[39;49m\u001b[30;43m2\u001b[39;49m\u001b[30;43m]\u001b[39;49m\n\u001b[32m 843\u001b[39m \u001b[30;43m\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 844\u001b[39m retries.sleep()\n",
|
| 724 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/urllib3/util/retry.py:535\u001b[39m, in \u001b[36mRetry.increment\u001b[39m\u001b[34m(self, method, url, response, error, _pool, _stacktrace)\u001b[39m\n\u001b[32m 534\u001b[39m reason = error \u001b[38;5;129;01mor\u001b[39;00m ResponseError(cause)\n\u001b[32m--> \u001b[39m\u001b[32m535\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m MaxRetryError(_pool, url, reason) \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mreason\u001b[39;00m \u001b[38;5;66;03m# type: ignore[arg-type]\u001b[39;00m\n\u001b[32m 537\u001b[39m log.debug(\u001b[33m\"\u001b[39m\u001b[33mIncremented Retry for (url=\u001b[39m\u001b[33m'\u001b[39m\u001b[38;5;132;01m%s\u001b[39;00m\u001b[33m'\u001b[39m\u001b[33m): \u001b[39m\u001b[38;5;132;01m%r\u001b[39;00m\u001b[33m\"\u001b[39m, url, new_retry)\n",
|
| 725 |
+
"\u001b[31mMaxRetryError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
|
| 726 |
+
"\nDuring handling of the above exception, another exception occurred:\n",
|
| 727 |
+
"\u001b[31mConnectionError\u001b[39m Traceback (most recent call last)",
|
| 728 |
+
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n",
|
| 729 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:73\u001b[39m, in \u001b[36mget\u001b[39m\u001b[34m(url, params, **kwargs)\u001b[39m\n\u001b[32m 63\u001b[39m \u001b[38;5;250m\u001b[39m\u001b[33mr\u001b[39m\u001b[33;03m\"\"\"Sends a GET request.\u001b[39;00m\n\u001b[32m 64\u001b[39m \n\u001b[32m 65\u001b[39m \u001b[33;03m:param url: URL for the new :class:`Request` object.\u001b[39;00m\n\u001b[32m (...)\u001b[39m\u001b[32m 70\u001b[39m \u001b[33;03m:rtype: requests.Response\u001b[39;00m\n\u001b[32m 71\u001b[39m \u001b[33;03m\"\"\"\u001b[39;00m\n\u001b[32m---> \u001b[39m\u001b[32m73\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43mget\u001b[39;49m\u001b[30;43m\"\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mparams\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
|
| 730 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/api.py:59\u001b[39m, in \u001b[36mrequest\u001b[39m\u001b[34m(method, url, **kwargs)\u001b[39m\n\u001b[32m 58\u001b[39m \u001b[38;5;28;01mwith\u001b[39;00m sessions.Session() \u001b[38;5;28;01mas\u001b[39;00m session:\n\u001b[32m---> \u001b[39m\u001b[32m59\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[30;43msession\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43mmethod\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m=\u001b[39;49m\u001b[30;43murl\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n",
|
| 731 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:592\u001b[39m, in \u001b[36mSession.request\u001b[39m\u001b[34m(self, method, url, params, data, headers, cookies, files, auth, timeout, allow_redirects, proxies, hooks, stream, verify, cert, json)\u001b[39m\n\u001b[32m 591\u001b[39m send_kwargs.update(settings)\n\u001b[32m--> \u001b[39m\u001b[32m592\u001b[39m resp = \u001b[30;43mself\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mprep\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43msend_kwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 594\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m resp\n",
|
| 732 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/sessions.py:706\u001b[39m, in \u001b[36mSession.send\u001b[39m\u001b[34m(self, request, **kwargs)\u001b[39m\n\u001b[32m 705\u001b[39m \u001b[38;5;66;03m# Send the request\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m706\u001b[39m r = \u001b[30;43madapter\u001b[39;49m\u001b[30;43m.\u001b[39;49m\u001b[30;43msend\u001b[39;49m\u001b[30;43m(\u001b[39;49m\u001b[30;43mrequest\u001b[39;49m\u001b[30;43m,\u001b[39;49m\u001b[30;43m \u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43m*\u001b[39;49m\u001b[30;43mkwargs\u001b[39;49m\u001b[30;43m)\u001b[39;49m\n\u001b[32m 708\u001b[39m \u001b[38;5;66;03m# Total elapsed time of the request (approximately)\u001b[39;00m\n",
|
| 733 |
+
"\u001b[36mFile \u001b[39m\u001b[32m/dccstor/kirushikesh/personal-projects/openenv-pyre/.venv/lib/python3.12/site-packages/requests/adapters.py:678\u001b[39m, in \u001b[36mHTTPAdapter.send\u001b[39m\u001b[34m(self, request, stream, timeout, verify, cert, proxies)\u001b[39m\n\u001b[32m 676\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m SSLError(e, request=request)\n\u001b[32m--> \u001b[39m\u001b[32m678\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mConnectionError\u001b[39;00m(e, request=request)\n\u001b[32m 680\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m ClosedPoolError \u001b[38;5;28;01mas\u001b[39;00m e:\n",
|
| 734 |
+
"\u001b[31mConnectionError\u001b[39m: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))",
|
| 735 |
+
"\nDuring handling of the above exception, another exception occurred:\n",
|
| 736 |
+
"\u001b[31mRuntimeError\u001b[39m Traceback (most recent call last)",
|
| 737 |
+
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[7]\u001b[39m\u001b[32m, line 7\u001b[39m\n\u001b[32m 3\u001b[39m health = requests.get(f\"{ENV_URL}/health\", timeout=\u001b[32m5\u001b[39m)\n\u001b[32m 4\u001b[39m health.raise_for_status()\n\u001b[32m 5\u001b[39m print(f\"Server health check PASSED ({ENV_URL})\")\n\u001b[32m 6\u001b[39m \u001b[38;5;28;01mexcept\u001b[39;00m Exception \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[32m----> \u001b[39m\u001b[32m7\u001b[39m \u001b[38;5;28;01mraise\u001b[39;00m RuntimeError(f\"Server not reachable at {ENV_URL}: {exc}\")\n\u001b[32m 8\u001b[39m \n\u001b[32m 9\u001b[39m \u001b[38;5;66;03m# Build and load the HF model — this is the expensive step\u001b[39;00m\n\u001b[32m 10\u001b[39m llm = HFChatModel(\n",
|
| 738 |
+
"\u001b[31mRuntimeError\u001b[39m: Server not reachable at http://localhost:8000: HTTPConnectionPool(host='localhost', port=8000): Max retries exceeded with url: /health (Caused by NewConnectionError(\"HTTPConnection(host='localhost', port=8000): Failed to establish a new connection: [Errno 111] Connection refused\"))"
|
| 739 |
+
]
|
| 740 |
+
}
|
| 741 |
+
],
|
| 742 |
"source": [
|
| 743 |
"# Health check first — fail fast before waiting for the large model to load\n",
|
| 744 |
"try:\n",
|
|
|
|
| 953 |
],
|
| 954 |
"metadata": {
|
| 955 |
"kernelspec": {
|
| 956 |
+
"display_name": "Python 3 (ipykernel)",
|
| 957 |
"language": "python",
|
| 958 |
"name": "python3"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
}
|
| 960 |
},
|
| 961 |
"nbformat": 4,
|
frontend/README.md
CHANGED
|
@@ -1,73 +1,93 @@
|
|
| 1 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
The
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
```
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pyre — Frontend Visualization
|
| 2 |
+
|
| 3 |
+
A cinematic real-time visualization for the **Pyre Crisis Navigation Environment** — a reinforcement learning environment where an LLM agent navigates a burning building.
|
| 4 |
+
|
| 5 |
+
## Quick start
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# Open directly in a browser — no build step needed
|
| 9 |
+
open frontend/index.html
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
The app runs entirely in-browser. **Demo mode** simulates the fire physics in JavaScript (no server required). **Live mode** connects to the deployed environment.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Demo mode vs Live mode
|
| 17 |
+
|
| 18 |
+
| | Demo | Live |
|
| 19 |
+
|---|---|---|
|
| 20 |
+
| Server needed | ✗ | ✓ |
|
| 21 |
+
| Fire physics | JS port (exact match) | Python server |
|
| 22 |
+
| Full reward rubric | Simplified | Complete |
|
| 23 |
+
| Toggle | Default | Click "Live" in topbar |
|
| 24 |
+
|
| 25 |
+
**Live server:** `https://krooz-pyre-env.hf.space`
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Controls
|
| 30 |
+
|
| 31 |
+
| Key | Action |
|
| 32 |
+
|---|---|
|
| 33 |
+
| `Space` | Play / pause |
|
| 34 |
+
| `→` | Single step |
|
| 35 |
+
| `R` | New episode |
|
| 36 |
+
| `1`–`5` | Speed ½× / 1× / 2× / 4× / 8× |
|
| 37 |
+
|
| 38 |
+
Bottom bar: difficulty selector, seed input, speed control, reset.
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Recording episodes (Python)
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
pip install requests # only stdlib used, no install needed
|
| 46 |
+
|
| 47 |
+
python bridge/recorder.py \
|
| 48 |
+
--url https://krooz-pyre-env.hf.space/web \
|
| 49 |
+
--episodes 10 \
|
| 50 |
+
--difficulty medium \
|
| 51 |
+
--out episodes/
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Episodes are saved as JSON files to `episodes/`. Each file contains full frame-by-frame grid data (cell, fire, smoke grids + agent position + visible cells).
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## File structure
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
frontend/
|
| 62 |
+
├── index.html Main app — open this
|
| 63 |
+
└── js/
|
| 64 |
+
├── sim.js JS port of pyre_env fire simulation + floor plans
|
| 65 |
+
├── renderer.js Canvas2D rendering (fire particles, fog-of-war, agent trail)
|
| 66 |
+
└── app.js App controller, charts, HUD, live/demo modes
|
| 67 |
+
|
| 68 |
+
bridge/
|
| 69 |
+
└── recorder.py Record live episodes to JSON for replay
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## Architecture notes
|
| 75 |
+
|
| 76 |
+
**Rendering:** HTML5 Canvas 2D — sufficient at 60fps for 16×16 grids; additive blending (`globalCompositeOperation: lighter`) for fire glow; ember particle pool (200 max); fog-of-war via per-cell alpha overlay.
|
| 77 |
+
|
| 78 |
+
**Demo agent:** BFS toward nearest unblocked exit, 15% random exploration, avoids fire cells > 0.4 intensity.
|
| 79 |
+
|
| 80 |
+
**Live bridge:** Polls `/web/scene` every 800ms; applies grid state to the same rendering pipeline.
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Demo script (30-second stage walkthrough)
|
| 85 |
+
|
| 86 |
+
1. **Open** `frontend/index.html` — fire simulation starts automatically at 1×
|
| 87 |
+
2. **Point out** the dark floor plan canvas with glowing fire cells, fog-of-war, and cyan agent dot
|
| 88 |
+
3. **Slow to ½×** to show per-step fire propagation and smoke spread
|
| 89 |
+
4. **Speed to 4×** — show agent navigating toward exits (green glow), closing doors (blue bars) to slow fire
|
| 90 |
+
5. **Highlight** the side panel: cumulative reward curve dipping on smoke exposure, fire cell count climbing, action histogram
|
| 91 |
+
6. **Describe partial observability** — the dark unexplored cells vs. visible corridor
|
| 92 |
+
7. **Reset (R)** with a different seed to show episode variety
|
| 93 |
+
8. If server is available: click **Live** — "Connected" chip turns green, real Python environment takes over
|
frontend/dist/assets/index-8TVqpTW-.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#f5f2ed;--s1:#fff;--s2:#faf9f6;--s3:#f0ede8;--bd:#e4dfd7;--bd2:#cdc7be;--t1:#1c1712;--t2:#6b6460;--t3:#a8a29e;--fire:#c2410c;--fire2:#c2410c14;--blue:#1d4ed8;--blue2:#1d4ed814;--green:#166534;--green2:#16653414;--amber:#92400e;--amber2:#92400e14;--red:#b91c1c;--red2:#b91c1c14;--gold:#d97706;--gold2:#d977061f;--mono:"DM Mono", "JetBrains Mono", monospace;--sans:"DM Sans", "Inter", system-ui, sans-serif;--r:8px}body{background:var(--bg);color:var(--t1);font-family:var(--sans);font-size:13px;line-height:1.5;overflow:hidden}.shell{grid-template-rows:48px 1fr;height:100vh;display:grid;overflow:hidden}.topbar{background:var(--s1);border-bottom:1px solid var(--bd);z-index:20;align-items:center;gap:16px;padding:0 20px;display:flex;box-shadow:0 1px 3px #0000000a}.brand{align-items:center;gap:10px;display:flex}.brand-icon{background:linear-gradient(135deg,#c2410c,#f97316);border-radius:6px;justify-content:center;align-items:center;width:28px;height:28px;font-size:14px;display:flex}.brand-name{font-family:var(--mono);color:var(--t1);letter-spacing:.06em;text-transform:uppercase;font-size:15px;font-weight:500}.brand-sep{color:var(--bd2);margin:0 2px}.brand-sub{color:var(--t3);letter-spacing:.08em;font-size:11px;font-family:var(--mono)}.topbar-right{align-items:center;gap:12px;margin-left:auto;display:flex}.live-chip{font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--bd);color:var(--t3);background:var(--s2);border-radius:20px;align-items:center;gap:5px;padding:4px 10px;font-size:10px;font-weight:500;display:flex}.live-chip:before{content:"";background:var(--bd2);border-radius:50%;width:6px;height:6px}.live-chip.online{color:var(--green);background:var(--green2);border-color:#16653433}.live-chip.online:before{background:#22c55e;animation:1.2s infinite blink}.live-chip.offline{color:var(--t3);background:var(--s2)}.live-chip.error{color:var(--red);background:var(--red2);border-color:#b91c1c33}.live-chip.error:before{background:var(--red)}@keyframes blink{0%,to{opacity:1}50%{opacity:.3}}.topbar-ep{font-family:var(--mono);color:var(--t3);align-items:center;gap:6px;font-size:10px;display:flex}.topbar-ep .ep-id{color:var(--blue);font-weight:500}.content{grid-template-columns:1fr 300px;height:100%;display:grid;overflow:hidden}.canvas-zone{background:var(--bg);flex-direction:column;justify-content:flex-start;align-items:center;gap:16px;padding:20px;display:flex;overflow-y:auto}.canvas-frame{box-shadow:0 0 0 1px var(--bd2), 0 4px 24px #0000001a, 0 1px 4px #0000000f;background:#d4c9a8;border-radius:10px;flex-shrink:0;line-height:0;position:relative;overflow:hidden}#map-canvas{max-width:100%;max-height:calc(100vh - 168px);image-rendering:pixelated;display:block}.hud-overlay{pointer-events:none;z-index:10;justify-content:space-between;gap:8px;padding:10px;display:flex;position:absolute;top:0;left:0;right:0}.hud-card{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#ede6dc;background:#0c0805d1;border:1px solid #ffffff1a;border-radius:6px;flex-direction:column;gap:3px;min-width:130px;padding:7px 10px;display:flex}.hud-card-center{border-color:#fbbf242e;min-width:160px}.hud-r{align-items:center;gap:6px;display:flex}.hlbl{font-family:var(--mono);color:#a8a29eb3;letter-spacing:.1em;text-transform:uppercase;flex-shrink:0;font-size:9px}.hval{font-family:var(--mono);color:#f0e8e0;text-align:right;min-width:26px;margin-left:auto;font-size:13px;font-weight:500}.hbar-bg{background:#ffffff1a;border-radius:3px;flex:1;height:5px;overflow:hidden}.hbar-fill{border-radius:3px;height:100%;transition:width .35s}.hbar-fill.g{background:linear-gradient(90deg,#22c55e,#4ade80)}.hbar-fill.m{background:linear-gradient(90deg,#f59e0b,#fbbf24)}.hbar-fill.c{background:linear-gradient(90deg,#ef4444,#f87171)}.hstatus{font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;border-radius:3px;padding:2px 6px;font-size:9px;font-weight:500}.hstatus.good{color:#4ade80;background:#4ade801f}.hstatus.moderate{color:#fbbf24;background:#fbbf241f}.hstatus.low{color:#fb923c;background:#fb923c1f}.hstatus.critical{color:#f87171;background:#f871711f;animation:1s infinite pulse-red}@keyframes pulse-red{0%,to{opacity:1}50%{opacity:.5}}.step-val{font-family:var(--mono);color:#f0e8e0;font-size:12px;font-weight:500}.sbar-bg{background:#ffffff1a;border-radius:2px;width:100%;height:3px;margin-top:1px;overflow:hidden}.sbar-fill{background:linear-gradient(90deg, var(--blue), #60a5fa);border-radius:2px;height:100%;transition:width .35s}.step-meta{font-family:var(--mono);color:#a8a29e8c;letter-spacing:.06em;text-transform:uppercase;margin-top:1px;font-size:8px}.legend{background:var(--s1);border:1px solid var(--bd);border-radius:6px;flex-wrap:wrap;align-items:center;gap:14px;width:100%;max-width:640px;padding:8px 12px;display:flex}.legend-item{font-family:var(--mono);color:var(--t2);letter-spacing:.05em;text-transform:uppercase;align-items:center;gap:5px;font-size:9px;display:flex}.leg-swatch{border-radius:2px;flex-shrink:0;width:12px;height:12px}.dialog{background:var(--s1);border:1px solid var(--bd);border-left:3px solid var(--fire);border-radius:var(--r);font-family:var(--mono);color:var(--t2);width:100%;max-width:640px;padding:10px 14px;font-size:12px;line-height:1.5}.dialog-who{color:var(--t3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:3px;font-size:9px;display:block}.side{background:var(--s1);border-left:1px solid var(--bd);flex-direction:column;display:flex;overflow:hidden auto}.side-sec{border-bottom:1px solid var(--bd);padding:14px 16px}.sec-hd{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.12em;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:9px;font-weight:600;display:flex}.sg{grid-template-columns:1fr 1fr;gap:6px;display:grid}.sc{background:var(--s2);border:1px solid var(--bd);border-radius:6px;padding:8px 10px}.sc-l{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:2px;font-size:9px}.sc-v{font-family:var(--mono);color:var(--t1);font-size:14px;font-weight:500}.sc-v.fire{color:var(--fire)}.sc-v.blue{color:var(--blue)}.sc-v.green{color:var(--green)}.sc-v.amber{color:var(--amber)}.sc-v.gold{color:var(--gold)}.srow{border-bottom:1px solid #00000008;justify-content:space-between;align-items:center;padding:5px 0;font-size:12px;display:flex}.srow:last-child{border-bottom:none}.sv{font-family:var(--mono);font-weight:500}.sv.blue{color:var(--blue)}.sv.green{color:var(--green)}.sv.hot{color:var(--fire)}.sv.danger{color:var(--red)}.sv.warn{color:var(--amber)}.sv.gold{color:var(--gold)}.bar-w{margin-top:10px}.bar-lbl{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.06em;justify-content:space-between;margin-bottom:5px;font-size:9px;display:flex}.bar-bg{background:var(--s2);border:1px solid var(--bd);border-radius:4px;height:6px;overflow:hidden}.bar-fill{border-radius:4px;height:100%;transition:width .4s cubic-bezier(.4,0,.2,1)}.elog{flex-direction:column;gap:2px;max-height:160px;display:flex;overflow-y:auto}.erow{background:0 0;border-radius:4px;align-items:flex-start;gap:6px;padding:4px 6px;font-size:11px;display:flex}.erow.alarm{background:var(--red2);border-left:2px solid var(--red)}.erow.warn-row{background:var(--amber2);border-left:2px solid var(--amber)}.estep{font-family:var(--mono);color:var(--t3);flex-shrink:0;min-width:26px;padding-top:1px;font-size:9px}.etext{color:var(--t2);flex:1;font-size:11px;line-height:1.4}.erwd{font-family:var(--mono);flex-shrink:0;padding-top:1px;font-size:9px;font-weight:500}.erwd.p{color:var(--green)}.erwd.n{color:var(--red)}.dpad{grid-template-columns:repeat(3,1fr);gap:5px;max-width:140px;margin:0 auto;display:grid}.dpad-center{justify-content:center;align-items:center;display:flex}.ctrl-grid{grid-template-columns:repeat(3,1fr);gap:5px;display:grid}.ctrl-row{gap:5px;margin-top:8px;display:flex}.ctrl-btn{background:var(--s2);color:var(--t2);border:1px solid var(--bd);font-family:var(--mono);letter-spacing:.04em;cursor:pointer;text-align:center;border-radius:5px;flex:1;padding:7px 6px;font-size:10px;font-weight:500;transition:all .1s}.ctrl-btn:hover:not(:disabled){background:var(--s3);color:var(--t1);border-color:var(--bd2)}.ctrl-btn:disabled{opacity:.4;cursor:not-allowed}.ctrl-btn.accent{background:var(--fire2);color:var(--fire);border-color:#c2410c40}.ctrl-btn.accent:hover{color:var(--fire);background:#c2410c26}.ctrl-btn.active{background:var(--blue2);color:var(--blue);border-color:#1d4ed840}.ctrl-btn.active:hover{background:#1d4ed826}.ctrl-btn.play{color:#fff;background:linear-gradient(135deg,#c2410c,#f97316);border:none;font-size:11px;box-shadow:0 2px 8px #c2410c4d}.ctrl-btn.play:hover{filter:brightness(1.05);box-shadow:0 3px 12px #c2410c73}.ctrl-status{font-family:var(--mono);color:var(--t3);background:var(--s2);border:1px solid var(--bd);border-left:3px solid var(--bd2);border-radius:5px;margin-top:10px;padding:7px 10px;font-size:10px}.ctrl-status.error{color:var(--red);background:var(--red2);border-left-color:var(--red)}.door-grid{grid-template-columns:1fr 1fr;gap:4px;display:grid}.door-btn{background:var(--s2);border:1px solid var(--bd);font-family:var(--mono);cursor:pointer;color:var(--t2);text-align:center;border-radius:5px;padding:5px 4px;font-size:9px;transition:all .1s}.door-btn:hover{background:var(--s3);color:var(--t1)}.door-btn.open{color:var(--green);background:var(--green2);border-color:#16653440}.door-btn.closed{color:var(--amber);background:var(--amber2);border-color:#92400e40}.door-btn.failed{color:var(--red);background:var(--red2);border-color:#b91c1c40}.report-box{background:var(--s2);border:1px solid var(--bd);max-height:160px;font-family:var(--mono);color:var(--t2);white-space:pre-wrap;border-radius:5px;padding:10px;font-size:9px;line-height:1.5;overflow-y:auto}::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
|
frontend/dist/assets/index-CWSK8ZuR.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/dist/index.html
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>
|
| 8 |
-
<
|
| 9 |
-
<link rel="
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
</
|
| 14 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Pyre — Crisis Navigation</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-CWSK8ZuR.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-8TVqpTW-.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
frontend/index.html
CHANGED
|
@@ -1,13 +1,16 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
<
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Pyre — Crisis Navigation</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
frontend/src/App.css
CHANGED
|
@@ -1,367 +1,703 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 4 |
|
|
|
|
| 5 |
:root {
|
| 6 |
-
--bg
|
| 7 |
-
--
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
body {
|
| 21 |
-
background: var(--bg
|
| 22 |
-
color: var(--
|
| 23 |
-
font-family: var(--
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
display: flex;
|
| 35 |
-
flex-direction: column;
|
| 36 |
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
-
.
|
| 40 |
-
width: 100%;
|
| 41 |
-
max-width: 1400px;
|
| 42 |
display: flex;
|
| 43 |
-
justify-content: space-between;
|
| 44 |
align-items: center;
|
| 45 |
-
|
| 46 |
-
border-bottom: 2px solid var(--border-color);
|
| 47 |
-
margin-bottom: 24px;
|
| 48 |
-
position: relative;
|
| 49 |
}
|
| 50 |
|
| 51 |
-
.
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
.
|
| 63 |
-
font-
|
| 64 |
-
font-
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
text-transform: uppercase;
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
display: flex;
|
| 69 |
align-items: center;
|
| 70 |
gap: 12px;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
.
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
display: grid;
|
| 79 |
-
grid-template-columns: 1fr
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
max-width: 1400px;
|
| 83 |
}
|
| 84 |
|
| 85 |
-
/* ──
|
| 86 |
-
.
|
| 87 |
display: flex;
|
| 88 |
flex-direction: column;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
gap: 16px;
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
-
.
|
| 93 |
-
background: var(--panel-bg);
|
| 94 |
-
border: 1px solid var(--glass-border);
|
| 95 |
-
border-radius: 8px;
|
| 96 |
-
padding: 16px;
|
| 97 |
-
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
| 98 |
position: relative;
|
|
|
|
| 99 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
}
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
}
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
}
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
grid-template-columns: repeat(4, 1fr);
|
| 129 |
-
gap: 12px;
|
| 130 |
-
margin-top: 16px;
|
| 131 |
}
|
| 132 |
|
| 133 |
-
.hud-
|
| 134 |
-
background: rgba(255, 255, 255, 0.03);
|
| 135 |
-
border: 1px solid var(--glass-border);
|
| 136 |
-
border-radius: 4px;
|
| 137 |
-
padding: 12px;
|
| 138 |
display: flex;
|
| 139 |
-
|
| 140 |
-
gap:
|
| 141 |
}
|
| 142 |
|
| 143 |
-
.
|
| 144 |
-
font-
|
| 145 |
-
|
|
|
|
|
|
|
| 146 |
text-transform: uppercase;
|
| 147 |
-
|
| 148 |
}
|
| 149 |
|
| 150 |
-
.
|
| 151 |
-
font-family: var(--
|
| 152 |
-
font-size:
|
| 153 |
-
font-weight:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
}
|
| 155 |
|
| 156 |
-
.
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 160 |
|
| 161 |
-
|
| 162 |
-
.
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
font-
|
| 168 |
-
font-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
display: flex;
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
font-size: 12px;
|
| 179 |
-
color: var(--
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
text-transform: uppercase;
|
|
|
|
|
|
|
|
|
|
| 182 |
}
|
| 183 |
|
| 184 |
-
/* ──
|
| 185 |
-
.
|
|
|
|
|
|
|
| 186 |
display: flex;
|
| 187 |
flex-direction: column;
|
| 188 |
-
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
-
.
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
border-radius: 8px;
|
| 195 |
-
padding: 16px;
|
| 196 |
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
| 197 |
}
|
| 198 |
|
| 199 |
-
.
|
| 200 |
-
font-
|
| 201 |
-
font-
|
| 202 |
-
|
|
|
|
| 203 |
text-transform: uppercase;
|
| 204 |
-
letter-spacing:
|
| 205 |
-
margin-bottom:
|
| 206 |
-
padding-bottom: 8px;
|
| 207 |
-
border-bottom: 1px solid var(--border-color);
|
| 208 |
display: flex;
|
|
|
|
| 209 |
justify-content: space-between;
|
| 210 |
}
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
.srow {
|
| 213 |
display: flex;
|
| 214 |
justify-content: space-between;
|
| 215 |
-
|
| 216 |
-
padding:
|
| 217 |
-
|
|
|
|
| 218 |
}
|
| 219 |
|
| 220 |
.srow:last-child { border-bottom: none; }
|
| 221 |
|
| 222 |
-
.sv { font-family: var(--
|
| 223 |
-
.sv.blue
|
| 224 |
-
.sv.
|
| 225 |
-
.sv.hot
|
| 226 |
-
.sv.danger
|
| 227 |
-
.sv.
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
|
|
|
| 237 |
.bar-bg {
|
| 238 |
-
background:
|
| 239 |
-
border
|
| 240 |
-
|
|
|
|
| 241 |
overflow: hidden;
|
| 242 |
-
border: 1px solid var(--border-color);
|
| 243 |
}
|
|
|
|
| 244 |
.bar-fill {
|
| 245 |
height: 100%;
|
| 246 |
-
background: linear-gradient(90deg, var(--accent-red), var(--accent-green));
|
| 247 |
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
-
/* ──
|
| 252 |
.ctrl-grid {
|
| 253 |
display: grid;
|
| 254 |
grid-template-columns: repeat(3, 1fr);
|
| 255 |
-
gap:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
}
|
| 257 |
|
| 258 |
.ctrl-btn {
|
| 259 |
-
background:
|
| 260 |
-
color: var(--
|
| 261 |
-
border: 1px solid var(--
|
| 262 |
-
border-radius:
|
| 263 |
-
padding:
|
| 264 |
-
font-family: var(--
|
| 265 |
-
font-size:
|
| 266 |
-
font-weight:
|
|
|
|
| 267 |
cursor: pointer;
|
| 268 |
-
transition: all 0.
|
| 269 |
-
text-
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
.ctrl-btn:hover:not(:disabled) {
|
| 273 |
-
background:
|
| 274 |
-
|
| 275 |
-
color: var(--
|
| 276 |
-
box-shadow: 0 0 10px rgba(0, 242, 255, 0.2);
|
| 277 |
}
|
| 278 |
|
| 279 |
-
.ctrl-btn:
|
| 280 |
-
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
-
.ctrl-btn.wide { grid-column: span 3; }
|
| 284 |
.ctrl-btn.accent {
|
| 285 |
-
background: var(--
|
| 286 |
-
color:
|
| 287 |
-
border:
|
| 288 |
}
|
|
|
|
| 289 |
.ctrl-btn.accent:hover {
|
| 290 |
-
background:
|
|
|
|
| 291 |
}
|
| 292 |
|
| 293 |
-
.ctrl-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
color:
|
| 297 |
-
border: 1px solid var(--border-color);
|
| 298 |
-
border-radius: 4px;
|
| 299 |
-
padding: 10px;
|
| 300 |
-
font-family: var(--font-mono);
|
| 301 |
-
font-size: 13px;
|
| 302 |
}
|
| 303 |
|
| 304 |
-
.ctrl-
|
| 305 |
-
|
| 306 |
-
font-family: var(--font-mono);
|
| 307 |
-
font-size: 12px;
|
| 308 |
-
padding: 8px;
|
| 309 |
-
background: rgba(0,0,0,0.2);
|
| 310 |
-
border-radius: 4px;
|
| 311 |
}
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
border:
|
| 317 |
-
border-radius: 4px;
|
| 318 |
-
padding: 12px;
|
| 319 |
-
max-height: 250px;
|
| 320 |
-
overflow-y: auto;
|
| 321 |
-
font-family: var(--font-mono);
|
| 322 |
font-size: 11px;
|
| 323 |
-
|
| 324 |
-
white-space: pre-wrap;
|
| 325 |
-
margin-bottom: 12px;
|
| 326 |
}
|
| 327 |
|
| 328 |
-
.
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
margin-bottom: 8px;
|
| 332 |
-
font-family: var(--font-mono);
|
| 333 |
}
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
background: rgba(0, 242, 255, 0.1);
|
| 346 |
-
opacity: 0.1;
|
| 347 |
-
pointer-events: none;
|
| 348 |
-
animation: scan 8s linear infinite;
|
| 349 |
}
|
| 350 |
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
|
|
|
|
|
|
| 354 |
}
|
| 355 |
|
| 356 |
-
.door-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
padding:
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
}
|
| 363 |
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
| 1 |
+
/* ── Reset ── */
|
|
|
|
| 2 |
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 3 |
|
| 4 |
+
/* ── Design Tokens ── */
|
| 5 |
:root {
|
| 6 |
+
--bg: #f5f2ed;
|
| 7 |
+
--s1: #ffffff;
|
| 8 |
+
--s2: #faf9f6;
|
| 9 |
+
--s3: #f0ede8;
|
| 10 |
+
--bd: #e4dfd7;
|
| 11 |
+
--bd2: #cdc7be;
|
| 12 |
+
--t1: #1c1712;
|
| 13 |
+
--t2: #6b6460;
|
| 14 |
+
--t3: #a8a29e;
|
| 15 |
+
--fire: #c2410c;
|
| 16 |
+
--fire2: rgba(194,65,12,0.08);
|
| 17 |
+
--blue: #1d4ed8;
|
| 18 |
+
--blue2: rgba(29,78,216,0.08);
|
| 19 |
+
--green: #166534;
|
| 20 |
+
--green2:rgba(22,101,52,0.08);
|
| 21 |
+
--amber: #92400e;
|
| 22 |
+
--amber2:rgba(146,64,14,0.08);
|
| 23 |
+
--red: #b91c1c;
|
| 24 |
+
--red2: rgba(185,28,28,0.08);
|
| 25 |
+
--gold: #d97706;
|
| 26 |
+
--gold2: rgba(217,119,6,0.12);
|
| 27 |
+
--mono: 'DM Mono', 'JetBrains Mono', monospace;
|
| 28 |
+
--sans: 'DM Sans', 'Inter', system-ui, sans-serif;
|
| 29 |
+
--r: 8px;
|
| 30 |
}
|
| 31 |
|
| 32 |
body {
|
| 33 |
+
background: var(--bg);
|
| 34 |
+
color: var(--t1);
|
| 35 |
+
font-family: var(--sans);
|
| 36 |
+
font-size: 13px;
|
| 37 |
+
line-height: 1.5;
|
| 38 |
+
overflow: hidden;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/* ── Shell ── */
|
| 42 |
+
.shell {
|
| 43 |
+
display: grid;
|
| 44 |
+
grid-template-rows: 48px 1fr;
|
| 45 |
+
height: 100vh;
|
| 46 |
+
overflow: hidden;
|
| 47 |
}
|
| 48 |
|
| 49 |
+
/* ── Topbar ── */
|
| 50 |
+
.topbar {
|
| 51 |
+
background: var(--s1);
|
| 52 |
+
border-bottom: 1px solid var(--bd);
|
| 53 |
display: flex;
|
|
|
|
| 54 |
align-items: center;
|
| 55 |
+
padding: 0 20px;
|
| 56 |
+
gap: 16px;
|
| 57 |
+
z-index: 20;
|
| 58 |
+
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
| 59 |
}
|
| 60 |
|
| 61 |
+
.brand {
|
|
|
|
|
|
|
| 62 |
display: flex;
|
|
|
|
| 63 |
align-items: center;
|
| 64 |
+
gap: 10px;
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
+
.brand-icon {
|
| 68 |
+
width: 28px;
|
| 69 |
+
height: 28px;
|
| 70 |
+
background: linear-gradient(135deg, #c2410c, #f97316);
|
| 71 |
+
border-radius: 6px;
|
| 72 |
+
display: flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
font-size: 14px;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.brand-name {
|
| 79 |
+
font-family: var(--mono);
|
| 80 |
+
font-size: 15px;
|
| 81 |
+
font-weight: 500;
|
| 82 |
+
color: var(--t1);
|
| 83 |
+
letter-spacing: 0.06em;
|
| 84 |
text-transform: uppercase;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.brand-sep {
|
| 88 |
+
color: var(--bd2);
|
| 89 |
+
margin: 0 2px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.brand-sub {
|
| 93 |
+
font-size: 11px;
|
| 94 |
+
color: var(--t3);
|
| 95 |
+
letter-spacing: 0.08em;
|
| 96 |
+
font-family: var(--mono);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.topbar-right {
|
| 100 |
+
margin-left: auto;
|
| 101 |
display: flex;
|
| 102 |
align-items: center;
|
| 103 |
gap: 12px;
|
| 104 |
}
|
| 105 |
|
| 106 |
+
.live-chip {
|
| 107 |
+
display: flex;
|
| 108 |
+
align-items: center;
|
| 109 |
+
gap: 5px;
|
| 110 |
+
font-family: var(--mono);
|
| 111 |
+
font-size: 10px;
|
| 112 |
+
font-weight: 500;
|
| 113 |
+
letter-spacing: 0.08em;
|
| 114 |
+
text-transform: uppercase;
|
| 115 |
+
padding: 4px 10px;
|
| 116 |
+
border-radius: 20px;
|
| 117 |
+
border: 1px solid var(--bd);
|
| 118 |
+
color: var(--t3);
|
| 119 |
+
background: var(--s2);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.live-chip::before {
|
| 123 |
+
content: '';
|
| 124 |
+
width: 6px;
|
| 125 |
+
height: 6px;
|
| 126 |
+
border-radius: 50%;
|
| 127 |
+
background: var(--bd2);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.live-chip.online {
|
| 131 |
+
color: var(--green);
|
| 132 |
+
background: var(--green2);
|
| 133 |
+
border-color: rgba(22,101,52,0.2);
|
| 134 |
}
|
| 135 |
|
| 136 |
+
.live-chip.online::before { background: #22c55e; animation: blink 1.2s infinite; }
|
| 137 |
+
|
| 138 |
+
.live-chip.offline {
|
| 139 |
+
color: var(--t3);
|
| 140 |
+
background: var(--s2);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.live-chip.error {
|
| 144 |
+
color: var(--red);
|
| 145 |
+
background: var(--red2);
|
| 146 |
+
border-color: rgba(185,28,28,0.2);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.live-chip.error::before { background: var(--red); }
|
| 150 |
+
|
| 151 |
+
@keyframes blink { 0%,100% { opacity:1 } 50% { opacity:0.3 } }
|
| 152 |
+
|
| 153 |
+
.topbar-ep {
|
| 154 |
+
font-family: var(--mono);
|
| 155 |
+
font-size: 10px;
|
| 156 |
+
color: var(--t3);
|
| 157 |
+
display: flex;
|
| 158 |
+
align-items: center;
|
| 159 |
+
gap: 6px;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.topbar-ep .ep-id {
|
| 163 |
+
color: var(--blue);
|
| 164 |
+
font-weight: 500;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* ── Content Layout ── */
|
| 168 |
+
.content {
|
| 169 |
display: grid;
|
| 170 |
+
grid-template-columns: 1fr 300px;
|
| 171 |
+
overflow: hidden;
|
| 172 |
+
height: 100%;
|
|
|
|
| 173 |
}
|
| 174 |
|
| 175 |
+
/* ── Canvas Zone (left) ── */
|
| 176 |
+
.canvas-zone {
|
| 177 |
display: flex;
|
| 178 |
flex-direction: column;
|
| 179 |
+
align-items: center;
|
| 180 |
+
justify-content: flex-start;
|
| 181 |
+
padding: 20px;
|
| 182 |
+
overflow-y: auto;
|
| 183 |
gap: 16px;
|
| 184 |
+
background: var(--bg);
|
| 185 |
}
|
| 186 |
|
| 187 |
+
.canvas-frame {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
position: relative;
|
| 189 |
+
border-radius: 10px;
|
| 190 |
overflow: hidden;
|
| 191 |
+
box-shadow:
|
| 192 |
+
0 0 0 1px var(--bd2),
|
| 193 |
+
0 4px 24px rgba(0,0,0,0.10),
|
| 194 |
+
0 1px 4px rgba(0,0,0,0.06);
|
| 195 |
+
background: #d4c9a8;
|
| 196 |
+
line-height: 0;
|
| 197 |
+
flex-shrink: 0;
|
| 198 |
}
|
| 199 |
|
| 200 |
+
#map-canvas {
|
| 201 |
+
display: block;
|
| 202 |
+
max-width: 100%;
|
| 203 |
+
max-height: calc(100vh - 48px - 120px);
|
| 204 |
+
image-rendering: pixelated;
|
| 205 |
}
|
| 206 |
|
| 207 |
+
/* ── HUD Overlays on Canvas ── */
|
| 208 |
+
.hud-overlay {
|
| 209 |
+
position: absolute;
|
| 210 |
+
top: 0; left: 0; right: 0;
|
| 211 |
+
display: flex;
|
| 212 |
+
justify-content: space-between;
|
| 213 |
+
pointer-events: none;
|
| 214 |
+
z-index: 10;
|
| 215 |
+
padding: 10px;
|
| 216 |
+
gap: 8px;
|
| 217 |
}
|
| 218 |
|
| 219 |
+
.hud-card {
|
| 220 |
+
background: rgba(12, 8, 5, 0.82);
|
| 221 |
+
backdrop-filter: blur(10px);
|
| 222 |
+
border: 1px solid rgba(255,255,255,0.10);
|
| 223 |
+
border-radius: 6px;
|
| 224 |
+
padding: 7px 10px;
|
| 225 |
+
color: #ede6dc;
|
| 226 |
+
display: flex;
|
| 227 |
+
flex-direction: column;
|
| 228 |
+
gap: 3px;
|
| 229 |
+
min-width: 130px;
|
| 230 |
}
|
| 231 |
|
| 232 |
+
.hud-card-center {
|
| 233 |
+
min-width: 160px;
|
| 234 |
+
border-color: rgba(251,191,36,0.18);
|
|
|
|
|
|
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
+
.hud-r {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
display: flex;
|
| 239 |
+
align-items: center;
|
| 240 |
+
gap: 6px;
|
| 241 |
}
|
| 242 |
|
| 243 |
+
.hlbl {
|
| 244 |
+
font-family: var(--mono);
|
| 245 |
+
font-size: 9px;
|
| 246 |
+
color: rgba(168,162,158,0.7);
|
| 247 |
+
letter-spacing: 0.10em;
|
| 248 |
text-transform: uppercase;
|
| 249 |
+
flex-shrink: 0;
|
| 250 |
}
|
| 251 |
|
| 252 |
+
.hval {
|
| 253 |
+
font-family: var(--mono);
|
| 254 |
+
font-size: 13px;
|
| 255 |
+
font-weight: 500;
|
| 256 |
+
color: #f0e8e0;
|
| 257 |
+
margin-left: auto;
|
| 258 |
+
min-width: 26px;
|
| 259 |
+
text-align: right;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.hbar-bg {
|
| 263 |
+
flex: 1;
|
| 264 |
+
height: 5px;
|
| 265 |
+
background: rgba(255,255,255,0.10);
|
| 266 |
+
border-radius: 3px;
|
| 267 |
+
overflow: hidden;
|
| 268 |
}
|
| 269 |
|
| 270 |
+
.hbar-fill {
|
| 271 |
+
height: 100%;
|
| 272 |
+
border-radius: 3px;
|
| 273 |
+
transition: width 0.35s ease;
|
| 274 |
+
}
|
| 275 |
|
| 276 |
+
.hbar-fill.g { background: linear-gradient(90deg, #22c55e, #4ade80); }
|
| 277 |
+
.hbar-fill.m { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
|
| 278 |
+
.hbar-fill.c { background: linear-gradient(90deg, #ef4444, #f87171); }
|
| 279 |
+
|
| 280 |
+
.hstatus {
|
| 281 |
+
font-family: var(--mono);
|
| 282 |
+
font-size: 9px;
|
| 283 |
+
font-weight: 500;
|
| 284 |
+
letter-spacing: 0.06em;
|
| 285 |
+
text-transform: uppercase;
|
| 286 |
+
padding: 2px 6px;
|
| 287 |
+
border-radius: 3px;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.hstatus.good { color: #4ade80; background: rgba(74,222,128,0.12); }
|
| 291 |
+
.hstatus.moderate { color: #fbbf24; background: rgba(251,191,36,0.12); }
|
| 292 |
+
.hstatus.low { color: #fb923c; background: rgba(251,146,60,0.12); }
|
| 293 |
+
.hstatus.critical { color: #f87171; background: rgba(248,113,113,0.12); animation: pulse-red 1s infinite; }
|
| 294 |
+
|
| 295 |
+
@keyframes pulse-red { 0%,100%{opacity:1} 50%{opacity:0.5} }
|
| 296 |
+
|
| 297 |
+
.step-val {
|
| 298 |
+
font-family: var(--mono);
|
| 299 |
+
font-size: 12px;
|
| 300 |
+
font-weight: 500;
|
| 301 |
+
color: #f0e8e0;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
.sbar-bg {
|
| 305 |
+
height: 3px;
|
| 306 |
+
background: rgba(255,255,255,0.10);
|
| 307 |
+
border-radius: 2px;
|
| 308 |
+
overflow: hidden;
|
| 309 |
+
width: 100%;
|
| 310 |
+
margin-top: 1px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.sbar-fill {
|
| 314 |
+
height: 100%;
|
| 315 |
+
background: linear-gradient(90deg, var(--blue), #60a5fa);
|
| 316 |
+
border-radius: 2px;
|
| 317 |
+
transition: width 0.35s ease;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.step-meta {
|
| 321 |
+
font-family: var(--mono);
|
| 322 |
+
font-size: 8px;
|
| 323 |
+
color: rgba(168,162,158,0.55);
|
| 324 |
+
letter-spacing: 0.06em;
|
| 325 |
+
text-transform: uppercase;
|
| 326 |
+
margin-top: 1px;
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
/* ── Legend below canvas ── */
|
| 330 |
+
.legend {
|
| 331 |
display: flex;
|
| 332 |
+
gap: 14px;
|
| 333 |
+
flex-wrap: wrap;
|
| 334 |
+
align-items: center;
|
| 335 |
+
padding: 8px 12px;
|
| 336 |
+
background: var(--s1);
|
| 337 |
+
border: 1px solid var(--bd);
|
| 338 |
+
border-radius: 6px;
|
| 339 |
+
width: 100%;
|
| 340 |
+
max-width: 640px;
|
| 341 |
}
|
| 342 |
|
| 343 |
+
.legend-item {
|
| 344 |
+
display: flex;
|
| 345 |
+
align-items: center;
|
| 346 |
+
gap: 5px;
|
| 347 |
+
font-family: var(--mono);
|
| 348 |
+
font-size: 9px;
|
| 349 |
+
color: var(--t2);
|
| 350 |
+
letter-spacing: 0.05em;
|
| 351 |
+
text-transform: uppercase;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.leg-swatch {
|
| 355 |
+
width: 12px;
|
| 356 |
+
height: 12px;
|
| 357 |
+
border-radius: 2px;
|
| 358 |
+
flex-shrink: 0;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/* ── Dialog / Field Report ── */
|
| 362 |
+
.dialog {
|
| 363 |
+
background: var(--s1);
|
| 364 |
+
border: 1px solid var(--bd);
|
| 365 |
+
border-left: 3px solid var(--fire);
|
| 366 |
+
border-radius: var(--r);
|
| 367 |
+
padding: 10px 14px;
|
| 368 |
+
font-family: var(--mono);
|
| 369 |
font-size: 12px;
|
| 370 |
+
color: var(--t2);
|
| 371 |
+
width: 100%;
|
| 372 |
+
max-width: 640px;
|
| 373 |
+
line-height: 1.5;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.dialog-who {
|
| 377 |
+
font-size: 9px;
|
| 378 |
+
color: var(--t3);
|
| 379 |
text-transform: uppercase;
|
| 380 |
+
letter-spacing: 0.10em;
|
| 381 |
+
display: block;
|
| 382 |
+
margin-bottom: 3px;
|
| 383 |
}
|
| 384 |
|
| 385 |
+
/* ── Side Panel (right) ── */
|
| 386 |
+
.side {
|
| 387 |
+
background: var(--s1);
|
| 388 |
+
border-left: 1px solid var(--bd);
|
| 389 |
display: flex;
|
| 390 |
flex-direction: column;
|
| 391 |
+
overflow-y: auto;
|
| 392 |
+
overflow-x: hidden;
|
| 393 |
}
|
| 394 |
|
| 395 |
+
.side-sec {
|
| 396 |
+
border-bottom: 1px solid var(--bd);
|
| 397 |
+
padding: 14px 16px;
|
|
|
|
|
|
|
|
|
|
| 398 |
}
|
| 399 |
|
| 400 |
+
.sec-hd {
|
| 401 |
+
font-family: var(--mono);
|
| 402 |
+
font-size: 9px;
|
| 403 |
+
font-weight: 600;
|
| 404 |
+
color: var(--t3);
|
| 405 |
text-transform: uppercase;
|
| 406 |
+
letter-spacing: 0.12em;
|
| 407 |
+
margin-bottom: 10px;
|
|
|
|
|
|
|
| 408 |
display: flex;
|
| 409 |
+
align-items: center;
|
| 410 |
justify-content: space-between;
|
| 411 |
}
|
| 412 |
|
| 413 |
+
/* ── Stat Grid (2×N) ── */
|
| 414 |
+
.sg {
|
| 415 |
+
display: grid;
|
| 416 |
+
grid-template-columns: 1fr 1fr;
|
| 417 |
+
gap: 6px;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.sc {
|
| 421 |
+
background: var(--s2);
|
| 422 |
+
border: 1px solid var(--bd);
|
| 423 |
+
border-radius: 6px;
|
| 424 |
+
padding: 8px 10px;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.sc-l {
|
| 428 |
+
font-family: var(--mono);
|
| 429 |
+
font-size: 9px;
|
| 430 |
+
color: var(--t3);
|
| 431 |
+
text-transform: uppercase;
|
| 432 |
+
letter-spacing: 0.08em;
|
| 433 |
+
margin-bottom: 2px;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.sc-v {
|
| 437 |
+
font-family: var(--mono);
|
| 438 |
+
font-size: 14px;
|
| 439 |
+
font-weight: 500;
|
| 440 |
+
color: var(--t1);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.sc-v.fire { color: var(--fire); }
|
| 444 |
+
.sc-v.blue { color: var(--blue); }
|
| 445 |
+
.sc-v.green { color: var(--green); }
|
| 446 |
+
.sc-v.amber { color: var(--amber); }
|
| 447 |
+
.sc-v.gold { color: var(--gold); }
|
| 448 |
+
|
| 449 |
+
/* ── Status Rows ── */
|
| 450 |
.srow {
|
| 451 |
display: flex;
|
| 452 |
justify-content: space-between;
|
| 453 |
+
align-items: center;
|
| 454 |
+
padding: 5px 0;
|
| 455 |
+
font-size: 12px;
|
| 456 |
+
border-bottom: 1px solid rgba(0,0,0,0.03);
|
| 457 |
}
|
| 458 |
|
| 459 |
.srow:last-child { border-bottom: none; }
|
| 460 |
|
| 461 |
+
.sv { font-family: var(--mono); font-weight: 500; }
|
| 462 |
+
.sv.blue { color: var(--blue); }
|
| 463 |
+
.sv.green { color: var(--green); }
|
| 464 |
+
.sv.hot { color: var(--fire); }
|
| 465 |
+
.sv.danger{ color: var(--red); }
|
| 466 |
+
.sv.warn { color: var(--amber); }
|
| 467 |
+
.sv.gold { color: var(--gold); }
|
| 468 |
+
|
| 469 |
+
/* ── Health bar in side panel ── */
|
| 470 |
+
.bar-w { margin-top: 10px; }
|
| 471 |
+
|
| 472 |
+
.bar-lbl {
|
| 473 |
+
font-family: var(--mono);
|
| 474 |
+
font-size: 9px;
|
| 475 |
+
color: var(--t3);
|
| 476 |
+
display: flex;
|
| 477 |
+
justify-content: space-between;
|
| 478 |
+
margin-bottom: 5px;
|
| 479 |
+
text-transform: uppercase;
|
| 480 |
+
letter-spacing: 0.06em;
|
| 481 |
}
|
| 482 |
+
|
| 483 |
.bar-bg {
|
| 484 |
+
background: var(--s2);
|
| 485 |
+
border: 1px solid var(--bd);
|
| 486 |
+
border-radius: 4px;
|
| 487 |
+
height: 6px;
|
| 488 |
overflow: hidden;
|
|
|
|
| 489 |
}
|
| 490 |
+
|
| 491 |
.bar-fill {
|
| 492 |
height: 100%;
|
|
|
|
| 493 |
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
| 494 |
+
border-radius: 4px;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* ── Event Log ── */
|
| 498 |
+
.elog {
|
| 499 |
+
display: flex;
|
| 500 |
+
flex-direction: column;
|
| 501 |
+
gap: 2px;
|
| 502 |
+
max-height: 160px;
|
| 503 |
+
overflow-y: auto;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.erow {
|
| 507 |
+
display: flex;
|
| 508 |
+
align-items: flex-start;
|
| 509 |
+
gap: 6px;
|
| 510 |
+
padding: 4px 6px;
|
| 511 |
+
border-radius: 4px;
|
| 512 |
+
font-size: 11px;
|
| 513 |
+
background: transparent;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.erow.alarm {
|
| 517 |
+
background: var(--red2);
|
| 518 |
+
border-left: 2px solid var(--red);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
.erow.warn-row {
|
| 522 |
+
background: var(--amber2);
|
| 523 |
+
border-left: 2px solid var(--amber);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
.estep {
|
| 527 |
+
font-family: var(--mono);
|
| 528 |
+
font-size: 9px;
|
| 529 |
+
color: var(--t3);
|
| 530 |
+
min-width: 26px;
|
| 531 |
+
padding-top: 1px;
|
| 532 |
+
flex-shrink: 0;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
.etext {
|
| 536 |
+
color: var(--t2);
|
| 537 |
+
line-height: 1.4;
|
| 538 |
+
flex: 1;
|
| 539 |
+
font-size: 11px;
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
.erwd {
|
| 543 |
+
font-family: var(--mono);
|
| 544 |
+
font-size: 9px;
|
| 545 |
+
font-weight: 500;
|
| 546 |
+
flex-shrink: 0;
|
| 547 |
+
padding-top: 1px;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
.erwd.p { color: var(--green); }
|
| 551 |
+
.erwd.n { color: var(--red); }
|
| 552 |
+
|
| 553 |
+
/* ── Direction Pad ── */
|
| 554 |
+
.dpad {
|
| 555 |
+
display: grid;
|
| 556 |
+
grid-template-columns: repeat(3, 1fr);
|
| 557 |
+
gap: 5px;
|
| 558 |
+
max-width: 140px;
|
| 559 |
+
margin: 0 auto;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.dpad-center {
|
| 563 |
+
display: flex;
|
| 564 |
+
align-items: center;
|
| 565 |
+
justify-content: center;
|
| 566 |
}
|
| 567 |
|
| 568 |
+
/* ── Control Buttons ── */
|
| 569 |
.ctrl-grid {
|
| 570 |
display: grid;
|
| 571 |
grid-template-columns: repeat(3, 1fr);
|
| 572 |
+
gap: 5px;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
.ctrl-row {
|
| 576 |
+
display: flex;
|
| 577 |
+
gap: 5px;
|
| 578 |
+
margin-top: 8px;
|
| 579 |
}
|
| 580 |
|
| 581 |
.ctrl-btn {
|
| 582 |
+
background: var(--s2);
|
| 583 |
+
color: var(--t2);
|
| 584 |
+
border: 1px solid var(--bd);
|
| 585 |
+
border-radius: 5px;
|
| 586 |
+
padding: 7px 6px;
|
| 587 |
+
font-family: var(--mono);
|
| 588 |
+
font-size: 10px;
|
| 589 |
+
font-weight: 500;
|
| 590 |
+
letter-spacing: 0.04em;
|
| 591 |
cursor: pointer;
|
| 592 |
+
transition: all 0.1s;
|
| 593 |
+
text-align: center;
|
| 594 |
+
flex: 1;
|
| 595 |
}
|
| 596 |
|
| 597 |
.ctrl-btn:hover:not(:disabled) {
|
| 598 |
+
background: var(--s3);
|
| 599 |
+
color: var(--t1);
|
| 600 |
+
border-color: var(--bd2);
|
|
|
|
| 601 |
}
|
| 602 |
|
| 603 |
+
.ctrl-btn:disabled {
|
| 604 |
+
opacity: 0.4;
|
| 605 |
+
cursor: not-allowed;
|
| 606 |
}
|
| 607 |
|
|
|
|
| 608 |
.ctrl-btn.accent {
|
| 609 |
+
background: var(--fire2);
|
| 610 |
+
color: var(--fire);
|
| 611 |
+
border-color: rgba(194,65,12,0.25);
|
| 612 |
}
|
| 613 |
+
|
| 614 |
.ctrl-btn.accent:hover {
|
| 615 |
+
background: rgba(194,65,12,0.15);
|
| 616 |
+
color: var(--fire);
|
| 617 |
}
|
| 618 |
|
| 619 |
+
.ctrl-btn.active {
|
| 620 |
+
background: var(--blue2);
|
| 621 |
+
color: var(--blue);
|
| 622 |
+
border-color: rgba(29,78,216,0.25);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
}
|
| 624 |
|
| 625 |
+
.ctrl-btn.active:hover {
|
| 626 |
+
background: rgba(29,78,216,0.15);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
}
|
| 628 |
|
| 629 |
+
.ctrl-btn.play {
|
| 630 |
+
background: linear-gradient(135deg, #c2410c, #f97316);
|
| 631 |
+
color: white;
|
| 632 |
+
border: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
font-size: 11px;
|
| 634 |
+
box-shadow: 0 2px 8px rgba(194,65,12,0.30);
|
|
|
|
|
|
|
| 635 |
}
|
| 636 |
|
| 637 |
+
.ctrl-btn.play:hover {
|
| 638 |
+
box-shadow: 0 3px 12px rgba(194,65,12,0.45);
|
| 639 |
+
filter: brightness(1.05);
|
|
|
|
|
|
|
| 640 |
}
|
| 641 |
|
| 642 |
+
.ctrl-status {
|
| 643 |
+
margin-top: 10px;
|
| 644 |
+
font-family: var(--mono);
|
| 645 |
+
font-size: 10px;
|
| 646 |
+
color: var(--t3);
|
| 647 |
+
padding: 7px 10px;
|
| 648 |
+
background: var(--s2);
|
| 649 |
+
border-radius: 5px;
|
| 650 |
+
border: 1px solid var(--bd);
|
| 651 |
+
border-left: 3px solid var(--bd2);
|
| 652 |
+
}
|
| 653 |
|
| 654 |
+
.ctrl-status.error {
|
| 655 |
+
color: var(--red);
|
| 656 |
+
background: var(--red2);
|
| 657 |
+
border-left-color: var(--red);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
}
|
| 659 |
|
| 660 |
+
/* ── Door List ── */
|
| 661 |
+
.door-grid {
|
| 662 |
+
display: grid;
|
| 663 |
+
grid-template-columns: 1fr 1fr;
|
| 664 |
+
gap: 4px;
|
| 665 |
}
|
| 666 |
|
| 667 |
+
.door-btn {
|
| 668 |
+
background: var(--s2);
|
| 669 |
+
border: 1px solid var(--bd);
|
| 670 |
+
border-radius: 5px;
|
| 671 |
+
padding: 5px 4px;
|
| 672 |
+
font-family: var(--mono);
|
| 673 |
+
font-size: 9px;
|
| 674 |
+
cursor: pointer;
|
| 675 |
+
color: var(--t2);
|
| 676 |
+
text-align: center;
|
| 677 |
+
transition: all 0.1s;
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.door-btn:hover { background: var(--s3); color: var(--t1); }
|
| 681 |
+
.door-btn.open { color: var(--green); border-color: rgba(22,101,52,0.25); background: var(--green2); }
|
| 682 |
+
.door-btn.closed { color: var(--amber); border-color: rgba(146,64,14,0.25); background: var(--amber2); }
|
| 683 |
+
.door-btn.failed { color: var(--red); border-color: rgba(185,28,28,0.25); background: var(--red2); }
|
| 684 |
+
|
| 685 |
+
/* ── API Report ── */
|
| 686 |
+
.report-box {
|
| 687 |
+
background: var(--s2);
|
| 688 |
+
border: 1px solid var(--bd);
|
| 689 |
+
border-radius: 5px;
|
| 690 |
+
padding: 10px;
|
| 691 |
+
max-height: 160px;
|
| 692 |
+
overflow-y: auto;
|
| 693 |
+
font-family: var(--mono);
|
| 694 |
+
font-size: 9px;
|
| 695 |
+
color: var(--t2);
|
| 696 |
+
white-space: pre-wrap;
|
| 697 |
+
line-height: 1.5;
|
| 698 |
}
|
| 699 |
|
| 700 |
+
/* ── Scrollbar ── */
|
| 701 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 702 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 703 |
+
::-webkit-scrollbar-thumb { background: var(--bd2); border-radius: 2px; }
|
frontend/src/App.tsx
CHANGED
|
@@ -4,37 +4,54 @@ import type { Observation, Door, ApiReport, SceneResponse } from './types';
|
|
| 4 |
import Map2D from './components/Map2D';
|
| 5 |
import HUD from './components/HUD';
|
| 6 |
import ControlPanel from './components/ControlPanel';
|
| 7 |
-
import { StatusCard, StatusRow } from './components/StatusCard';
|
| 8 |
import APIReport from './components/APIReport';
|
| 9 |
|
| 10 |
const DOOR_CLOSED = 3;
|
| 11 |
-
const OBSTACLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
function App() {
|
| 14 |
-
const [observation,
|
| 15 |
-
const [sceneData,
|
| 16 |
-
const [isAutoWait,
|
| 17 |
-
const [isPolling,
|
| 18 |
-
const [status,
|
| 19 |
-
const [isError,
|
| 20 |
-
const [apiReport,
|
| 21 |
const [agentMoveCount, setAgentMoveCount] = useState(0);
|
| 22 |
const [agentMoveFlash, setAgentMoveFlash] = useState(0);
|
| 23 |
-
|
| 24 |
-
|
|
|
|
| 25 |
const autoWaitTimer = useRef<number | null>(null);
|
| 26 |
-
const pollTimer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
const setStatusMsg = (msg: string, error = false) => {
|
| 29 |
setStatus(msg);
|
| 30 |
setIsError(error);
|
| 31 |
};
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const applyObservation = useCallback((obs: Observation) => {
|
| 34 |
const newX = obs.map_state.agent_x;
|
| 35 |
const newY = obs.map_state.agent_y;
|
| 36 |
-
|
| 37 |
-
|
| 38 |
setAgentMoveFlash(18);
|
| 39 |
setAgentMoveCount(c => c + 1);
|
| 40 |
}
|
|
@@ -42,23 +59,21 @@ function App() {
|
|
| 42 |
setObservation(obs);
|
| 43 |
}, []);
|
| 44 |
|
| 45 |
-
const updateReport = (kind: string, request:
|
| 46 |
const mapState = response?.observation?.map_state || response?.map_state || response?.graph;
|
| 47 |
const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown';
|
| 48 |
-
const step
|
| 49 |
-
const
|
| 50 |
-
const
|
| 51 |
-
const done = Boolean(response?.done);
|
| 52 |
-
|
| 53 |
setApiReport({
|
| 54 |
call_type: kind,
|
| 55 |
request,
|
| 56 |
response,
|
| 57 |
-
meta: `${kind.toUpperCase()} | template=${template} | step=${step} |
|
| 58 |
});
|
| 59 |
};
|
| 60 |
|
| 61 |
-
const apiCall = async (path: string, payload:
|
| 62 |
const res = await fetch(path, {
|
| 63 |
method: 'POST',
|
| 64 |
headers: { 'Content-Type': 'application/json' },
|
|
@@ -73,22 +88,22 @@ function App() {
|
|
| 73 |
|
| 74 |
const resetLive = async (difficulty = 'medium') => {
|
| 75 |
try {
|
| 76 |
-
setStatusMsg('Initiating
|
| 77 |
const payload = { difficulty };
|
| 78 |
const data = await apiCall('/reset', payload);
|
| 79 |
const obs: Observation = data.observation;
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
};
|
| 88 |
}
|
| 89 |
applyObservation(obs);
|
| 90 |
updateReport('reset', payload, data);
|
| 91 |
-
setStatusMsg(`
|
|
|
|
| 92 |
} catch (err: any) {
|
| 93 |
setStatusMsg(`Reset Failed: ${err.message}`, true);
|
| 94 |
}
|
|
@@ -96,45 +111,52 @@ function App() {
|
|
| 96 |
|
| 97 |
const resetUntilDoors = async () => {
|
| 98 |
try {
|
| 99 |
-
setStatusMsg('Searching for
|
| 100 |
for (let i = 1; i <= 8; i++) {
|
| 101 |
const payload = { difficulty: 'medium' };
|
| 102 |
-
const data
|
| 103 |
const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
|
| 104 |
if (doorCount > 0) {
|
| 105 |
const obs: Observation = data.observation;
|
| 106 |
obs.metadata = {
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
};
|
| 112 |
applyObservation(obs);
|
| 113 |
updateReport('reset', payload, data);
|
| 114 |
-
setStatusMsg(`System Ready
|
|
|
|
| 115 |
return;
|
| 116 |
}
|
| 117 |
}
|
| 118 |
-
setStatusMsg('Optimal
|
| 119 |
} catch (err: any) {
|
| 120 |
setStatusMsg(`Search Failed: ${err.message}`, true);
|
| 121 |
}
|
| 122 |
};
|
| 123 |
|
| 124 |
-
const runAction = async (actionObj:
|
| 125 |
try {
|
| 126 |
-
setStatusMsg(`
|
| 127 |
const payload = { action: actionObj };
|
| 128 |
-
const data
|
| 129 |
const obs: Observation = data.observation;
|
| 130 |
-
|
| 131 |
-
obs.metadata = observation?.metadata;
|
| 132 |
applyObservation(obs);
|
| 133 |
updateReport('step', payload, data);
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
if (data.done) setIsAutoWait(false);
|
| 136 |
} catch (err: any) {
|
| 137 |
-
setStatusMsg(`
|
| 138 |
}
|
| 139 |
};
|
| 140 |
|
|
@@ -146,16 +168,13 @@ function App() {
|
|
| 146 |
setSceneData(scene);
|
| 147 |
updateReport('scene', {}, scene);
|
| 148 |
|
| 149 |
-
// Map SceneResponse to Observation for the Map2D component
|
| 150 |
const { labels, graph } = scene;
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
const cell_grid: number[] = [];
|
| 154 |
-
const fire_grid: number[] = [];
|
| 155 |
const smoke_grid: number[] = [];
|
| 156 |
-
|
| 157 |
-
for(let y=0; y<graph.height; y++) {
|
| 158 |
-
for(let x=0; x<graph.width; x++) {
|
| 159 |
const [type, fire, smoke] = graph.grid[y][x];
|
| 160 |
cell_grid.push(type);
|
| 161 |
fire_grid.push(fire);
|
|
@@ -164,49 +183,40 @@ function App() {
|
|
| 164 |
}
|
| 165 |
|
| 166 |
const visible_cells: [number, number][] = [];
|
| 167 |
-
for(let y=0; y<graph.height; y++) {
|
| 168 |
-
for(let x=0; x<graph.width; x++) {
|
| 169 |
-
if (graph.grid[y][x][4] === 1.0)
|
| 170 |
-
visible_cells.push([x, y]);
|
| 171 |
-
}
|
| 172 |
}
|
| 173 |
}
|
| 174 |
|
| 175 |
-
const totalFire = fire_grid.reduce((a, b) => a + b, 0);
|
| 176 |
-
if (totalFire > 0) {
|
| 177 |
-
console.log(`[DEBUG] Scene received with fire: total_intensity=${totalFire.toFixed(2)}, cells=${fire_grid.filter(v => v > 0).length}`);
|
| 178 |
-
}
|
| 179 |
-
|
| 180 |
const pseudoObs: Observation = {
|
| 181 |
map_state: {
|
| 182 |
-
cell_grid,
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
agent_x: labels.agent.x,
|
| 186 |
-
agent_y: labels.agent.y,
|
| 187 |
visible_cells,
|
| 188 |
-
door_registry:
|
| 189 |
exit_positions: labels.map.exit_positions,
|
| 190 |
-
step_count:
|
| 191 |
-
max_steps:
|
| 192 |
-
grid_w:
|
| 193 |
-
grid_h:
|
| 194 |
-
template_name:
|
| 195 |
},
|
| 196 |
-
agent_health:
|
| 197 |
-
location_label:
|
| 198 |
-
smoke_level:
|
| 199 |
-
wind_dir:
|
| 200 |
-
fire_visible:
|
| 201 |
-
fire_direction:
|
| 202 |
last_action_feedback: labels.agent.last_action_feedback,
|
| 203 |
narrative: '',
|
| 204 |
metadata: {
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
}
|
| 210 |
};
|
| 211 |
applyObservation(pseudoObs);
|
| 212 |
} catch (err: any) {
|
|
@@ -214,7 +224,6 @@ function App() {
|
|
| 214 |
}
|
| 215 |
}, [applyObservation, observation?.metadata]);
|
| 216 |
|
| 217 |
-
// Auto Wait Effect
|
| 218 |
useEffect(() => {
|
| 219 |
if (isAutoWait) {
|
| 220 |
autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
|
|
@@ -224,7 +233,6 @@ function App() {
|
|
| 224 |
return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); };
|
| 225 |
}, [isAutoWait]);
|
| 226 |
|
| 227 |
-
// Polling Effect (Scene - 2 times per second)
|
| 228 |
useEffect(() => {
|
| 229 |
if (isPolling) {
|
| 230 |
fetchAndApplyScene();
|
|
@@ -235,102 +243,107 @@ function App() {
|
|
| 235 |
return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
|
| 236 |
}, [isPolling, fetchAndApplyScene]);
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
const setup = async () => {
|
| 239 |
setIsPolling(false);
|
| 240 |
setAgentMoveCount(0);
|
| 241 |
setAgentMoveFlash(0);
|
|
|
|
| 242 |
prevAgentPos.current = { x: -1, y: -1 };
|
| 243 |
await resetLive();
|
| 244 |
setIsPolling(true);
|
| 245 |
};
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
const
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
const
|
| 267 |
-
const
|
| 268 |
-
const smokeCells = observation ? observation.map_state.smoke_grid.filter(v => v > 0.05).length : 0;
|
| 269 |
-
const exploredPct = observation ? Math.round((new Set(observation.map_state.visible_cells.map(([x,y]) => `${x},${y}`)).size / observation.map_state.cell_grid.length) * 100) : 0;
|
| 270 |
|
| 271 |
return (
|
| 272 |
-
<div className="
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
</div>
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
<span className="
|
| 281 |
-
</div>
|
| 282 |
-
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
| 283 |
-
<span className="hud-label">Difficulty</span>
|
| 284 |
-
<span className="sv green" style={{ fontSize: '12px', textTransform: 'uppercase' }}>{sceneData?.labels.episode.difficulty || 'MEDIUM'}</span>
|
| 285 |
-
</div>
|
| 286 |
-
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
| 287 |
-
<span className="hud-label">Status</span>
|
| 288 |
-
<span className={`sv ${isError ? 'danger' : 'green'}`} style={{ fontSize: '12px' }}>{isError ? 'ERROR' : 'SYNCED'}</span>
|
| 289 |
</div>
|
|
|
|
|
|
|
|
|
|
| 290 |
</div>
|
| 291 |
</header>
|
| 292 |
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
observation={observation}
|
| 301 |
-
agentMoveFlash={agentMoveFlash}
|
| 302 |
-
/>
|
| 303 |
-
</div>
|
| 304 |
<HUD observation={observation} agentMoveCount={agentMoveCount} />
|
| 305 |
</div>
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
<div className="dialog">
|
| 308 |
<span className="dialog-who">Field Report</span>
|
| 309 |
-
|
| 310 |
</div>
|
| 311 |
-
|
| 312 |
-
<StatusCard title="Surroundings & Analysis">
|
| 313 |
-
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
|
| 314 |
-
<div>
|
| 315 |
-
<StatusRow label="Visible Objects" value={sceneData?.labels.surroundings.visible_objects.length || 0} />
|
| 316 |
-
<StatusRow label="Blocked Exits" value={sceneData?.labels.surroundings.blocked_exit_ids.length || 0} className="danger" />
|
| 317 |
-
</div>
|
| 318 |
-
<div>
|
| 319 |
-
<StatusRow label="Audible Signals" value={sceneData?.labels.surroundings.audible_signals.length || 0} className="warn" />
|
| 320 |
-
<StatusRow label="Available Actions" value={sceneData?.labels.surroundings.available_actions.length || 0} className="green" />
|
| 321 |
-
</div>
|
| 322 |
-
</div>
|
| 323 |
-
{sceneData?.labels.surroundings.visible_objects.length ? (
|
| 324 |
-
<div style={{ marginTop: '10px', fontSize: '12px', opacity: 0.8 }}>
|
| 325 |
-
DETECTION: {sceneData.labels.surroundings.visible_objects.map(o => `${o.type} (${o.id})`).join(', ')}
|
| 326 |
-
</div>
|
| 327 |
-
) : null}
|
| 328 |
-
</StatusCard>
|
| 329 |
</div>
|
| 330 |
|
| 331 |
-
{/*
|
| 332 |
-
<
|
| 333 |
-
|
|
|
|
|
|
|
| 334 |
onAction={runAction}
|
| 335 |
onReset={resetLive}
|
| 336 |
onResetDoors={resetUntilDoors}
|
|
@@ -344,42 +357,101 @@ function App() {
|
|
| 344 |
isError={isError}
|
| 345 |
/>
|
| 346 |
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
<
|
| 350 |
-
<
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
<div className="bar-w">
|
| 361 |
-
<div className="bar-lbl">
|
|
|
|
|
|
|
|
|
|
| 362 |
<div className="bar-bg">
|
| 363 |
-
<div className="bar-fill" style={{ width: `${
|
| 364 |
</div>
|
| 365 |
</div>
|
| 366 |
-
</
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
<
|
| 371 |
-
<
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
);
|
|
|
|
| 4 |
import Map2D from './components/Map2D';
|
| 5 |
import HUD from './components/HUD';
|
| 6 |
import ControlPanel from './components/ControlPanel';
|
|
|
|
| 7 |
import APIReport from './components/APIReport';
|
| 8 |
|
| 9 |
const DOOR_CLOSED = 3;
|
| 10 |
+
const OBSTACLE = 5;
|
| 11 |
+
|
| 12 |
+
interface EventEntry {
|
| 13 |
+
step: number;
|
| 14 |
+
text: string;
|
| 15 |
+
reward: number;
|
| 16 |
+
isAlarm?: boolean;
|
| 17 |
+
}
|
| 18 |
|
| 19 |
function App() {
|
| 20 |
+
const [observation, setObservation] = useState<Observation | null>(null);
|
| 21 |
+
const [sceneData, setSceneData] = useState<SceneResponse | null>(null);
|
| 22 |
+
const [isAutoWait, setIsAutoWait] = useState(false);
|
| 23 |
+
const [isPolling, setIsPolling] = useState(true);
|
| 24 |
+
const [status, setStatus] = useState('Idle — waiting for connection');
|
| 25 |
+
const [isError, setIsError] = useState(false);
|
| 26 |
+
const [apiReport, setApiReport] = useState<ApiReport | null>(null);
|
| 27 |
const [agentMoveCount, setAgentMoveCount] = useState(0);
|
| 28 |
const [agentMoveFlash, setAgentMoveFlash] = useState(0);
|
| 29 |
+
const [eventLog, setEventLog] = useState<EventEntry[]>([]);
|
| 30 |
+
|
| 31 |
+
const prevAgentPos = useRef({ x: -1, y: -1 });
|
| 32 |
const autoWaitTimer = useRef<number | null>(null);
|
| 33 |
+
const pollTimer = useRef<number | null>(null);
|
| 34 |
+
const logEndRef = useRef<HTMLDivElement | null>(null);
|
| 35 |
+
|
| 36 |
+
/* scroll event log to bottom */
|
| 37 |
+
useEffect(() => {
|
| 38 |
+
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 39 |
+
}, [eventLog]);
|
| 40 |
|
| 41 |
const setStatusMsg = (msg: string, error = false) => {
|
| 42 |
setStatus(msg);
|
| 43 |
setIsError(error);
|
| 44 |
};
|
| 45 |
|
| 46 |
+
const pushLog = (text: string, step: number, reward: number, isAlarm = false) => {
|
| 47 |
+
setEventLog(prev => [...prev.slice(-49), { step, text, reward, isAlarm }]);
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
const applyObservation = useCallback((obs: Observation) => {
|
| 51 |
const newX = obs.map_state.agent_x;
|
| 52 |
const newY = obs.map_state.agent_y;
|
| 53 |
+
if (prevAgentPos.current.x !== -1 &&
|
| 54 |
+
(newX !== prevAgentPos.current.x || newY !== prevAgentPos.current.y)) {
|
| 55 |
setAgentMoveFlash(18);
|
| 56 |
setAgentMoveCount(c => c + 1);
|
| 57 |
}
|
|
|
|
| 59 |
setObservation(obs);
|
| 60 |
}, []);
|
| 61 |
|
| 62 |
+
const updateReport = (kind: string, request: unknown, response: any) => {
|
| 63 |
const mapState = response?.observation?.map_state || response?.map_state || response?.graph;
|
| 64 |
const template = mapState?.template_name || response?.labels?.episode?.template || 'unknown';
|
| 65 |
+
const step = mapState?.step_count ?? response?.observation?.elapsed_steps ?? response?.labels?.episode?.step ?? '-';
|
| 66 |
+
const reward = Number(response?.reward ?? 0).toFixed(3);
|
| 67 |
+
const done = Boolean(response?.done);
|
|
|
|
|
|
|
| 68 |
setApiReport({
|
| 69 |
call_type: kind,
|
| 70 |
request,
|
| 71 |
response,
|
| 72 |
+
meta: `${kind.toUpperCase()} | template=${template} | step=${step} | reward=${reward} | done=${done}`,
|
| 73 |
});
|
| 74 |
};
|
| 75 |
|
| 76 |
+
const apiCall = async (path: string, payload: unknown) => {
|
| 77 |
const res = await fetch(path, {
|
| 78 |
method: 'POST',
|
| 79 |
headers: { 'Content-Type': 'application/json' },
|
|
|
|
| 88 |
|
| 89 |
const resetLive = async (difficulty = 'medium') => {
|
| 90 |
try {
|
| 91 |
+
setStatusMsg('Initiating Reset...');
|
| 92 |
const payload = { difficulty };
|
| 93 |
const data = await apiCall('/reset', payload);
|
| 94 |
const obs: Observation = data.observation;
|
| 95 |
+
if (data.observation?.map_state) {
|
| 96 |
+
obs.metadata = {
|
| 97 |
+
fire_sources: data.observation.fire_sources_count ?? 0,
|
| 98 |
+
fire_spread_rate:data.observation.fire_spread_rate ?? 0,
|
| 99 |
+
humidity: data.observation.humidity ?? 0,
|
| 100 |
+
difficulty,
|
| 101 |
+
};
|
|
|
|
| 102 |
}
|
| 103 |
applyObservation(obs);
|
| 104 |
updateReport('reset', payload, data);
|
| 105 |
+
setStatusMsg(`Ready. Reward: ${Number(data.reward || 0).toFixed(2)}`);
|
| 106 |
+
pushLog('Episode reset. Assess surroundings.', obs.map_state.step_count, data.reward ?? 0);
|
| 107 |
} catch (err: any) {
|
| 108 |
setStatusMsg(`Reset Failed: ${err.message}`, true);
|
| 109 |
}
|
|
|
|
| 111 |
|
| 112 |
const resetUntilDoors = async () => {
|
| 113 |
try {
|
| 114 |
+
setStatusMsg('Searching for layout with doors...');
|
| 115 |
for (let i = 1; i <= 8; i++) {
|
| 116 |
const payload = { difficulty: 'medium' };
|
| 117 |
+
const data = await apiCall('/reset', payload);
|
| 118 |
const doorCount = Object.keys(data?.observation?.map_state?.door_registry || {}).length;
|
| 119 |
if (doorCount > 0) {
|
| 120 |
const obs: Observation = data.observation;
|
| 121 |
obs.metadata = {
|
| 122 |
+
fire_sources: data.observation.fire_sources_count ?? 0,
|
| 123 |
+
fire_spread_rate:data.observation.fire_spread_rate ?? 0,
|
| 124 |
+
humidity: data.observation.humidity ?? 0,
|
| 125 |
+
difficulty: 'medium',
|
| 126 |
};
|
| 127 |
applyObservation(obs);
|
| 128 |
updateReport('reset', payload, data);
|
| 129 |
+
setStatusMsg(`System Ready — ${doorCount} door(s) detected.`);
|
| 130 |
+
pushLog(`Layout found (${doorCount} doors). Doors detected.`, obs.map_state.step_count, 0);
|
| 131 |
return;
|
| 132 |
}
|
| 133 |
}
|
| 134 |
+
setStatusMsg('Optimal layout not found after 8 attempts.', true);
|
| 135 |
} catch (err: any) {
|
| 136 |
setStatusMsg(`Search Failed: ${err.message}`, true);
|
| 137 |
}
|
| 138 |
};
|
| 139 |
|
| 140 |
+
const runAction = async (actionObj: unknown, label: string) => {
|
| 141 |
try {
|
| 142 |
+
setStatusMsg(`Action: ${label}`);
|
| 143 |
const payload = { action: actionObj };
|
| 144 |
+
const data = await apiCall('/step', payload);
|
| 145 |
const obs: Observation = data.observation;
|
| 146 |
+
obs.metadata = observation?.metadata;
|
|
|
|
| 147 |
applyObservation(obs);
|
| 148 |
updateReport('step', payload, data);
|
| 149 |
+
const rwd = Number(data.reward || 0);
|
| 150 |
+
setStatusMsg(`Executed. Reward: ${rwd.toFixed(2)}`);
|
| 151 |
+
pushLog(
|
| 152 |
+
obs.last_action_feedback || label,
|
| 153 |
+
obs.map_state.step_count,
|
| 154 |
+
rwd,
|
| 155 |
+
rwd < -0.5,
|
| 156 |
+
);
|
| 157 |
if (data.done) setIsAutoWait(false);
|
| 158 |
} catch (err: any) {
|
| 159 |
+
setStatusMsg(`Error: ${err.message}`, true);
|
| 160 |
}
|
| 161 |
};
|
| 162 |
|
|
|
|
| 168 |
setSceneData(scene);
|
| 169 |
updateReport('scene', {}, scene);
|
| 170 |
|
|
|
|
| 171 |
const { labels, graph } = scene;
|
| 172 |
+
const cell_grid: number[] = [];
|
| 173 |
+
const fire_grid: number[] = [];
|
|
|
|
|
|
|
| 174 |
const smoke_grid: number[] = [];
|
| 175 |
+
|
| 176 |
+
for (let y = 0; y < graph.height; y++) {
|
| 177 |
+
for (let x = 0; x < graph.width; x++) {
|
| 178 |
const [type, fire, smoke] = graph.grid[y][x];
|
| 179 |
cell_grid.push(type);
|
| 180 |
fire_grid.push(fire);
|
|
|
|
| 183 |
}
|
| 184 |
|
| 185 |
const visible_cells: [number, number][] = [];
|
| 186 |
+
for (let y = 0; y < graph.height; y++) {
|
| 187 |
+
for (let x = 0; x < graph.width; x++) {
|
| 188 |
+
if (graph.grid[y][x][4] === 1.0) visible_cells.push([x, y]);
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
const pseudoObs: Observation = {
|
| 193 |
map_state: {
|
| 194 |
+
cell_grid, fire_grid, smoke_grid,
|
| 195 |
+
agent_x: labels.agent.x,
|
| 196 |
+
agent_y: labels.agent.y,
|
|
|
|
|
|
|
| 197 |
visible_cells,
|
| 198 |
+
door_registry: labels.map.door_registry,
|
| 199 |
exit_positions: labels.map.exit_positions,
|
| 200 |
+
step_count: labels.episode.step,
|
| 201 |
+
max_steps: labels.episode.max_steps,
|
| 202 |
+
grid_w: graph.width,
|
| 203 |
+
grid_h: graph.height,
|
| 204 |
+
template_name: labels.episode.template,
|
| 205 |
},
|
| 206 |
+
agent_health: labels.agent.health,
|
| 207 |
+
location_label: labels.agent.location,
|
| 208 |
+
smoke_level: labels.agent.smoke_level,
|
| 209 |
+
wind_dir: labels.episode.wind_dir,
|
| 210 |
+
fire_visible: labels.agent.fire_visible,
|
| 211 |
+
fire_direction: labels.agent.fire_direction,
|
| 212 |
last_action_feedback: labels.agent.last_action_feedback,
|
| 213 |
narrative: '',
|
| 214 |
metadata: {
|
| 215 |
+
fire_sources: labels.episode.fire_sources,
|
| 216 |
+
fire_spread_rate:labels.episode.fire_spread_rate,
|
| 217 |
+
humidity: labels.episode.humidity,
|
| 218 |
+
difficulty: labels.episode.difficulty,
|
| 219 |
+
},
|
| 220 |
};
|
| 221 |
applyObservation(pseudoObs);
|
| 222 |
} catch (err: any) {
|
|
|
|
| 224 |
}
|
| 225 |
}, [applyObservation, observation?.metadata]);
|
| 226 |
|
|
|
|
| 227 |
useEffect(() => {
|
| 228 |
if (isAutoWait) {
|
| 229 |
autoWaitTimer.current = window.setInterval(() => runAction({ action: 'wait' }, 'AUTO WAIT'), 900);
|
|
|
|
| 233 |
return () => { if (autoWaitTimer.current) clearInterval(autoWaitTimer.current); };
|
| 234 |
}, [isAutoWait]);
|
| 235 |
|
|
|
|
| 236 |
useEffect(() => {
|
| 237 |
if (isPolling) {
|
| 238 |
fetchAndApplyScene();
|
|
|
|
| 243 |
return () => { if (pollTimer.current) clearInterval(pollTimer.current); };
|
| 244 |
}, [isPolling, fetchAndApplyScene]);
|
| 245 |
|
| 246 |
+
useEffect(() => {
|
| 247 |
+
if (agentMoveFlash > 0) {
|
| 248 |
+
const timer = setTimeout(() => setAgentMoveFlash(f => f - 1), 50);
|
| 249 |
+
return () => clearTimeout(timer);
|
| 250 |
+
}
|
| 251 |
+
}, [agentMoveFlash]);
|
| 252 |
+
|
| 253 |
const setup = async () => {
|
| 254 |
setIsPolling(false);
|
| 255 |
setAgentMoveCount(0);
|
| 256 |
setAgentMoveFlash(0);
|
| 257 |
+
setEventLog([]);
|
| 258 |
prevAgentPos.current = { x: -1, y: -1 };
|
| 259 |
await resetLive();
|
| 260 |
setIsPolling(true);
|
| 261 |
};
|
| 262 |
|
| 263 |
+
/* Derived state */
|
| 264 |
+
const doors: Door[] = Object.entries(observation?.map_state.door_registry || {})
|
| 265 |
+
.map(([id, [x, y]]) => {
|
| 266 |
+
const ct = observation?.map_state.cell_grid[y * (observation?.map_state.grid_w ?? 16) + x];
|
| 267 |
+
let state: 'open' | 'closed' | 'failed' = 'open';
|
| 268 |
+
if (ct === DOOR_CLOSED) state = 'closed';
|
| 269 |
+
if (ct === OBSTACLE) state = 'failed';
|
| 270 |
+
return { id, x, y, state };
|
| 271 |
+
})
|
| 272 |
+
.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }));
|
| 273 |
+
|
| 274 |
+
const fireCells = observation?.map_state.fire_grid.filter(v => v > 0.05).length ?? 0;
|
| 275 |
+
const exploredPct = observation
|
| 276 |
+
? Math.round((new Set(observation.map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`)).size
|
| 277 |
+
/ observation.map_state.cell_grid.length) * 100)
|
| 278 |
+
: 0;
|
| 279 |
+
|
| 280 |
+
const hp = Math.round(observation?.agent_health ?? 0);
|
| 281 |
+
const hpColor = hp >= 60 ? 'var(--green)' : hp >= 30 ? 'var(--amber)' : 'var(--red)';
|
| 282 |
+
const isOnline = isPolling && !isError;
|
| 283 |
+
const epId = sceneData?.labels.episode.id?.slice(0, 8) ?? '—';
|
|
|
|
|
|
|
| 284 |
|
| 285 |
return (
|
| 286 |
+
<div className="shell">
|
| 287 |
+
{/* ── Topbar ── */}
|
| 288 |
+
<header className="topbar">
|
| 289 |
+
<div className="brand">
|
| 290 |
+
<div className="brand-icon">🔥</div>
|
| 291 |
+
<span className="brand-name">Pyre</span>
|
| 292 |
+
<span className="brand-sep">/</span>
|
| 293 |
+
<span className="brand-sub">Crisis Navigation</span>
|
| 294 |
</div>
|
| 295 |
+
|
| 296 |
+
<div className="topbar-right">
|
| 297 |
+
<div className="topbar-ep">
|
| 298 |
+
Episode <span className="ep-id">{epId}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
| 300 |
+
<span className={`live-chip ${isError ? 'error' : isOnline ? 'online' : 'offline'}`}>
|
| 301 |
+
{isError ? 'Error' : isOnline ? 'Live' : 'Idle'}
|
| 302 |
+
</span>
|
| 303 |
</div>
|
| 304 |
</header>
|
| 305 |
|
| 306 |
+
{/* ── Body ── */}
|
| 307 |
+
<div className="content">
|
| 308 |
+
|
| 309 |
+
{/* ── Left: Canvas Zone ── */}
|
| 310 |
+
<div className="canvas-zone">
|
| 311 |
+
<div className="canvas-frame">
|
| 312 |
+
<Map2D observation={observation} agentMoveFlash={agentMoveFlash} />
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
<HUD observation={observation} agentMoveCount={agentMoveCount} />
|
| 314 |
</div>
|
| 315 |
|
| 316 |
+
{/* Legend */}
|
| 317 |
+
<div className="legend">
|
| 318 |
+
{[
|
| 319 |
+
{ color:'#5e5850', label:'Wall' },
|
| 320 |
+
{ color:'#3a3530', label:'Obstacle' },
|
| 321 |
+
{ color:'#e6f4ec', label:'Exit' },
|
| 322 |
+
{ color:'#7c5c3c', label:'Door' },
|
| 323 |
+
{ color:'#f97316', label:'Fire' },
|
| 324 |
+
{ color:'rgba(72,82,96,0.7)', label:'Smoke' },
|
| 325 |
+
{ color:'#3b82f6', label:'Agent' },
|
| 326 |
+
{ color:'rgba(2,132,199,0.6)', label:'Trail'},
|
| 327 |
+
].map(({ color, label }) => (
|
| 328 |
+
<div key={label} className="legend-item">
|
| 329 |
+
<div className="leg-swatch" style={{ background: color, border:'1px solid rgba(0,0,0,0.1)' }} />
|
| 330 |
+
{label}
|
| 331 |
+
</div>
|
| 332 |
+
))}
|
| 333 |
+
</div>
|
| 334 |
+
|
| 335 |
+
{/* Field Report */}
|
| 336 |
<div className="dialog">
|
| 337 |
<span className="dialog-who">Field Report</span>
|
| 338 |
+
{observation?.last_action_feedback || 'Establishing link to field systems...'}
|
| 339 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
</div>
|
| 341 |
|
| 342 |
+
{/* ── Right: Side Panel ── */}
|
| 343 |
+
<aside className="side">
|
| 344 |
+
|
| 345 |
+
{/* Controls */}
|
| 346 |
+
<ControlPanel
|
| 347 |
onAction={runAction}
|
| 348 |
onReset={resetLive}
|
| 349 |
onResetDoors={resetUntilDoors}
|
|
|
|
| 357 |
isError={isError}
|
| 358 |
/>
|
| 359 |
|
| 360 |
+
{/* Agent Biometrics */}
|
| 361 |
+
<div className="side-sec">
|
| 362 |
+
<div className="sec-hd">Agent Biometrics</div>
|
| 363 |
+
<div className="sg">
|
| 364 |
+
<div className="sc">
|
| 365 |
+
<div className="sc-l">Health</div>
|
| 366 |
+
<div className="sc-v" style={{ color: hpColor }}>{hp}%</div>
|
| 367 |
+
</div>
|
| 368 |
+
<div className="sc">
|
| 369 |
+
<div className="sc-l">Status</div>
|
| 370 |
+
<div className="sc-v" style={{ color: hpColor }}>
|
| 371 |
+
{sceneData?.labels.agent.health_status ?? 'NOMINAL'}
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
<div className="sc">
|
| 375 |
+
<div className="sc-l">Position</div>
|
| 376 |
+
<div className="sc-v blue">
|
| 377 |
+
({observation?.map_state.agent_x ?? '—'},{observation?.map_state.agent_y ?? '—'})
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
<div className="sc">
|
| 381 |
+
<div className="sc-l">Sector</div>
|
| 382 |
+
<div className="sc-v">{observation?.location_label ?? 'Unknown'}</div>
|
| 383 |
+
</div>
|
| 384 |
+
</div>
|
| 385 |
<div className="bar-w">
|
| 386 |
+
<div className="bar-lbl">
|
| 387 |
+
<span>System Integrity</span>
|
| 388 |
+
<span>{hp}%</span>
|
| 389 |
+
</div>
|
| 390 |
<div className="bar-bg">
|
| 391 |
+
<div className="bar-fill" style={{ width: `${hp}%`, background: hpColor }} />
|
| 392 |
</div>
|
| 393 |
</div>
|
| 394 |
+
</div>
|
| 395 |
+
|
| 396 |
+
{/* Environment */}
|
| 397 |
+
<div className="side-sec">
|
| 398 |
+
<div className="sec-hd">Environment</div>
|
| 399 |
+
<div className="sg">
|
| 400 |
+
<div className="sc">
|
| 401 |
+
<div className="sc-l">Hazard Cells</div>
|
| 402 |
+
<div className="sc-v fire">{fireCells}</div>
|
| 403 |
+
</div>
|
| 404 |
+
<div className="sc">
|
| 405 |
+
<div className="sc-l">Explored</div>
|
| 406 |
+
<div className="sc-v blue">{exploredPct}%</div>
|
| 407 |
+
</div>
|
| 408 |
+
<div className="sc">
|
| 409 |
+
<div className="sc-l">Wind</div>
|
| 410 |
+
<div className="sc-v">{observation?.wind_dir ?? 'CALM'}</div>
|
| 411 |
+
</div>
|
| 412 |
+
<div className="sc">
|
| 413 |
+
<div className="sc-l">Humidity</div>
|
| 414 |
+
<div className="sc-v amber">
|
| 415 |
+
{Math.round((observation?.metadata?.humidity ?? 0) * 100)}%
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
</div>
|
| 419 |
+
</div>
|
| 420 |
+
|
| 421 |
+
{/* Event Log */}
|
| 422 |
+
<div className="side-sec" style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
| 423 |
+
<div className="sec-hd">
|
| 424 |
+
Event Log
|
| 425 |
+
<span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'var(--t3)' }}>
|
| 426 |
+
{eventLog.length} events
|
| 427 |
+
</span>
|
| 428 |
+
</div>
|
| 429 |
+
<div className="elog">
|
| 430 |
+
{eventLog.length === 0 && (
|
| 431 |
+
<div style={{ color: 'var(--t3)', fontFamily: 'var(--mono)', fontSize: '10px', padding: '4px' }}>
|
| 432 |
+
No events yet…
|
| 433 |
+
</div>
|
| 434 |
+
)}
|
| 435 |
+
{eventLog.map((e, i) => (
|
| 436 |
+
<div key={i} className={`erow ${e.isAlarm ? 'alarm' : ''}`}>
|
| 437 |
+
<span className="estep">T{e.step}</span>
|
| 438 |
+
<span className="etext">{e.text}</span>
|
| 439 |
+
<span className={`erwd ${e.reward >= 0 ? 'p' : 'n'}`}>
|
| 440 |
+
{e.reward >= 0 ? '+' : ''}{e.reward.toFixed(2)}
|
| 441 |
+
</span>
|
| 442 |
+
</div>
|
| 443 |
+
))}
|
| 444 |
+
<div ref={logEndRef} />
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
|
| 448 |
+
{/* API Report */}
|
| 449 |
+
<div className="side-sec">
|
| 450 |
+
<div className="sec-hd">Network Activity</div>
|
| 451 |
+
<APIReport report={apiReport} onCopyReset={() => {}} onCopyStep={() => {}} onCopyScene={() => {}} />
|
| 452 |
+
</div>
|
| 453 |
+
|
| 454 |
+
</aside>
|
| 455 |
</div>
|
| 456 |
</div>
|
| 457 |
);
|
frontend/src/components/APIReport.tsx
CHANGED
|
@@ -10,10 +10,9 @@ interface APIReportProps {
|
|
| 10 |
|
| 11 |
const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep, onCopyScene }) => {
|
| 12 |
return (
|
| 13 |
-
<div
|
| 14 |
-
<div className="
|
| 15 |
-
|
| 16 |
-
{report ? report.meta : 'Awaiting data packets...'}
|
| 17 |
</div>
|
| 18 |
<div className="report-box">
|
| 19 |
{report ? JSON.stringify({
|
|
@@ -22,10 +21,10 @@ const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep,
|
|
| 22 |
response: report.response
|
| 23 |
}, null, 2) : '{}'}
|
| 24 |
</div>
|
| 25 |
-
<div className="
|
| 26 |
-
<button className="ctrl-btn" onClick={onCopyScene}>
|
| 27 |
-
<button className="ctrl-btn" onClick={onCopyReset}>
|
| 28 |
-
<button className="ctrl-btn" onClick={onCopyStep}>
|
| 29 |
</div>
|
| 30 |
</div>
|
| 31 |
);
|
|
|
|
| 10 |
|
| 11 |
const APIReport: React.FC<APIReportProps> = ({ report, onCopyReset, onCopyStep, onCopyScene }) => {
|
| 12 |
return (
|
| 13 |
+
<div>
|
| 14 |
+
<div className="report-meta" style={{ marginBottom: '8px' }}>
|
| 15 |
+
{report ? report.meta : 'Awaiting telemetry...'}
|
|
|
|
| 16 |
</div>
|
| 17 |
<div className="report-box">
|
| 18 |
{report ? JSON.stringify({
|
|
|
|
| 21 |
response: report.response
|
| 22 |
}, null, 2) : '{}'}
|
| 23 |
</div>
|
| 24 |
+
<div className="ctrl-grid" style={{ marginTop: '12px' }}>
|
| 25 |
+
<button className="ctrl-btn" onClick={onCopyScene}>Scene</button>
|
| 26 |
+
<button className="ctrl-btn" onClick={onCopyReset}>Reset</button>
|
| 27 |
+
<button className="ctrl-btn" onClick={onCopyStep}>Step</button>
|
| 28 |
</div>
|
| 29 |
</div>
|
| 30 |
);
|
frontend/src/components/ControlPanel.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
import React
|
| 2 |
import type { Door } from '../types';
|
| 3 |
|
| 4 |
interface ControlPanelProps {
|
| 5 |
-
onAction: (action:
|
| 6 |
onReset: (difficulty: string) => void;
|
| 7 |
onResetDoors: () => void;
|
| 8 |
onSetup: () => void;
|
|
@@ -16,51 +16,73 @@ interface ControlPanelProps {
|
|
| 16 |
}
|
| 17 |
|
| 18 |
const ControlPanel: React.FC<ControlPanelProps> = ({
|
| 19 |
-
onAction,
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
}) => {
|
| 23 |
-
|
| 24 |
-
const [doorId, setDoorId] = useState(doors[0]?.id || 'door_1');
|
| 25 |
|
| 26 |
return (
|
| 27 |
-
<div className="
|
| 28 |
-
<div className="
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
<
|
| 34 |
-
<button className="ctrl-btn" onClick={() =>
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
<button className="ctrl-btn" onClick={() =>
|
| 38 |
-
<button className="ctrl-btn" onClick={() => onAction({ action: 'wait' }, 'WAIT')}
|
| 39 |
-
<button className="ctrl-btn" onClick={() =>
|
| 40 |
-
|
| 41 |
-
<
|
| 42 |
-
<button className="ctrl-btn" onClick={() =>
|
| 43 |
-
<
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
<button className="ctrl-btn" onClick={() => onAction({ action: 'door', target_id: doorId, door_state: 'open' }, `OPEN ${doorId}`)}>Open</button>
|
| 52 |
-
<button className="ctrl-btn" onClick={() => onAction({ action: 'door', target_id: doorId, door_state: 'close' }, `CLOSE ${doorId}`)}>Close</button>
|
| 53 |
-
|
| 54 |
-
<button className="ctrl-btn" onClick={toggleAutoWait}>
|
| 55 |
-
Auto: {isAutoWait ? 'ON' : 'OFF'}
|
| 56 |
</button>
|
| 57 |
-
<button className=
|
| 58 |
-
|
| 59 |
</button>
|
| 60 |
</div>
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
</div>
|
| 65 |
);
|
| 66 |
};
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
import type { Door } from '../types';
|
| 3 |
|
| 4 |
interface ControlPanelProps {
|
| 5 |
+
onAction: (action: unknown, label: string) => void;
|
| 6 |
onReset: (difficulty: string) => void;
|
| 7 |
onResetDoors: () => void;
|
| 8 |
onSetup: () => void;
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
const ControlPanel: React.FC<ControlPanelProps> = ({
|
| 19 |
+
onAction, onSetup, doors,
|
| 20 |
+
isAutoWait, toggleAutoWait,
|
| 21 |
+
isPolling, togglePolling,
|
| 22 |
+
status, isError,
|
| 23 |
}) => {
|
| 24 |
+
const mv = (dir: string) => onAction({ action: 'move', direction: dir }, `MOVE ${dir.toUpperCase()}`);
|
|
|
|
| 25 |
|
| 26 |
return (
|
| 27 |
+
<div className="side-sec">
|
| 28 |
+
<div className="sec-hd">Tactical Controls</div>
|
| 29 |
+
|
| 30 |
+
{/* D-Pad */}
|
| 31 |
+
<div className="ctrl-grid" style={{ maxWidth: 160, margin: '0 auto', marginBottom: 8 }}>
|
| 32 |
+
{/* Row 1 */}
|
| 33 |
+
<div />
|
| 34 |
+
<button className="ctrl-btn" onClick={() => mv('north')}>▲</button>
|
| 35 |
+
<div />
|
| 36 |
+
{/* Row 2 */}
|
| 37 |
+
<button className="ctrl-btn" onClick={() => mv('west')}>◀</button>
|
| 38 |
+
<button className="ctrl-btn accent" onClick={() => onAction({ action: 'wait' }, 'WAIT')} title="Wait">●</button>
|
| 39 |
+
<button className="ctrl-btn" onClick={() => mv('east')}>▶</button>
|
| 40 |
+
{/* Row 3 */}
|
| 41 |
+
<div />
|
| 42 |
+
<button className="ctrl-btn" onClick={() => mv('south')}>▼</button>
|
| 43 |
+
<div />
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
{/* Secondary actions */}
|
| 47 |
+
<div className="ctrl-row">
|
| 48 |
+
<button className="ctrl-btn" onClick={() => onAction({ action: 'explore' }, 'EXPLORE')}>SCAN</button>
|
| 49 |
+
<button className={`ctrl-btn ${isAutoWait ? 'active' : ''}`} onClick={toggleAutoWait}>
|
| 50 |
+
{isAutoWait ? 'STOP AUTO' : 'AUTO'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
</button>
|
| 52 |
+
<button className={`ctrl-btn ${isPolling ? 'active' : ''}`} onClick={togglePolling}>
|
| 53 |
+
{isPolling ? 'LIVE ●' : 'LIVE ○'}
|
| 54 |
</button>
|
| 55 |
</div>
|
| 56 |
+
|
| 57 |
+
{/* Reboot */}
|
| 58 |
+
<div className="ctrl-row" style={{ marginTop: 6 }}>
|
| 59 |
+
<button className="ctrl-btn play" onClick={onSetup} style={{ flex: 'none', width: '100%' }}>
|
| 60 |
+
↺ REBOOT EPISODE
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
{/* Status line */}
|
| 65 |
+
<div className={`ctrl-status ${isError ? 'error' : ''}`}>
|
| 66 |
+
{status}
|
| 67 |
</div>
|
| 68 |
+
|
| 69 |
+
{/* Doors */}
|
| 70 |
+
{doors.length > 0 && (
|
| 71 |
+
<div style={{ marginTop: 12 }}>
|
| 72 |
+
<div className="sec-hd" style={{ marginBottom: 6 }}>Proximity Doors</div>
|
| 73 |
+
<div className="door-grid">
|
| 74 |
+
{doors.map(d => (
|
| 75 |
+
<button
|
| 76 |
+
key={d.id}
|
| 77 |
+
className={`door-btn ${d.state}`}
|
| 78 |
+
onClick={() => onAction({ action: 'interact', door_id: d.id }, `INTERACT ${d.id}`)}
|
| 79 |
+
>
|
| 80 |
+
{d.id} [{d.state}]
|
| 81 |
+
</button>
|
| 82 |
+
))}
|
| 83 |
+
</div>
|
| 84 |
+
</div>
|
| 85 |
+
)}
|
| 86 |
</div>
|
| 87 |
);
|
| 88 |
};
|
frontend/src/components/HUD.tsx
CHANGED
|
@@ -3,46 +3,140 @@ import type { Observation } from '../types';
|
|
| 3 |
|
| 4 |
interface HUDProps {
|
| 5 |
observation: Observation | null;
|
| 6 |
-
agentMoveCount: number;
|
| 7 |
}
|
| 8 |
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
return (
|
| 22 |
-
<div className="hud">
|
|
|
|
| 23 |
<div className="hud-card">
|
| 24 |
-
<
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
<
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
);
|
|
|
|
| 3 |
|
| 4 |
interface HUDProps {
|
| 5 |
observation: Observation | null;
|
| 6 |
+
agentMoveCount?: number;
|
| 7 |
}
|
| 8 |
|
| 9 |
+
/* Unicode compass arrows for each wind direction */
|
| 10 |
+
const WIND_ARROW: Record<string, string> = {
|
| 11 |
+
N: '↑', S: '↓', E: '→', W: '←',
|
| 12 |
+
NE: '↗', NW: '↖', SE: '↘', SW: '↙',
|
| 13 |
+
CALM: '·',
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
/* Rotation degrees so the arrow visually points in the right direction */
|
| 17 |
+
const WIND_DEG: Record<string, number> = {
|
| 18 |
+
N: 0, NE: 45, E: 90, SE: 135,
|
| 19 |
+
S: 180, SW: 225, W: 270, NW: 315, CALM: 0,
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const HUD: React.FC<HUDProps> = ({ observation, agentMoveCount = 0 }) => {
|
| 23 |
+
if (!observation) return null;
|
| 24 |
+
|
| 25 |
+
const { map_state, agent_health, smoke_level, wind_dir, fire_visible, metadata } = observation;
|
| 26 |
+
const hPct = Math.round(agent_health);
|
| 27 |
+
const sPct = Math.round((map_state.step_count / map_state.max_steps) * 100);
|
| 28 |
+
|
| 29 |
+
let hBarClass = 'g';
|
| 30 |
+
let hStatusLabel = 'Nominal';
|
| 31 |
+
let hStatusClass = 'good';
|
| 32 |
+
|
| 33 |
+
if (hPct < 30) { hBarClass = 'c'; hStatusLabel = 'Critical'; hStatusClass = 'critical'; }
|
| 34 |
+
else if (hPct < 60) { hBarClass = 'm'; hStatusLabel = 'Moderate'; hStatusClass = 'moderate'; }
|
| 35 |
+
|
| 36 |
+
const windDir = wind_dir || 'CALM';
|
| 37 |
+
const windArrow = WIND_ARROW[windDir] ?? '?';
|
| 38 |
+
const windDeg = WIND_DEG[windDir] ?? 0;
|
| 39 |
+
const spreadRate = metadata?.fire_spread_rate ?? 0;
|
| 40 |
+
const humidity = metadata?.humidity ?? 0;
|
| 41 |
|
| 42 |
return (
|
| 43 |
+
<div className="hud-overlay">
|
| 44 |
+
{/* ── Left: Health ── */}
|
| 45 |
<div className="hud-card">
|
| 46 |
+
<div className="hud-r">
|
| 47 |
+
<span className="hlbl">HP</span>
|
| 48 |
+
<div className="hbar-bg">
|
| 49 |
+
<div className={`hbar-fill ${hBarClass}`} style={{ width: `${hPct}%` }} />
|
| 50 |
+
</div>
|
| 51 |
+
<span className="hval">{hPct}</span>
|
| 52 |
+
</div>
|
| 53 |
+
<div className="hud-r" style={{ gap: '8px', marginTop: '2px' }}>
|
| 54 |
+
<span className={`hstatus ${hStatusClass}`}>{hStatusLabel}</span>
|
| 55 |
+
<span style={{ fontFamily: 'var(--mono)', fontSize: '9px', color: 'rgba(168,162,158,.55)', marginLeft: 'auto' }}>
|
| 56 |
+
💨 {smoke_level || 'clear'}
|
| 57 |
+
</span>
|
| 58 |
+
</div>
|
| 59 |
+
{agentMoveCount > 0 && (
|
| 60 |
+
<div style={{ fontFamily: 'var(--mono)', fontSize: '8px', color: 'rgba(168,162,158,.4)', marginTop: '2px' }}>
|
| 61 |
+
moves: {agentMoveCount}
|
| 62 |
+
</div>
|
| 63 |
+
)}
|
| 64 |
</div>
|
| 65 |
+
|
| 66 |
+
{/* ── Center: Wind & Hazard ── */}
|
| 67 |
+
<div className="hud-card hud-card-center">
|
| 68 |
+
{/* Compass rose */}
|
| 69 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
| 70 |
+
<div style={{ position: 'relative', width: 32, height: 32, flexShrink: 0 }}>
|
| 71 |
+
{/* compass ring */}
|
| 72 |
+
<svg width="32" height="32" viewBox="0 0 32 32" style={{ position: 'absolute', top: 0, left: 0 }}>
|
| 73 |
+
<circle cx="16" cy="16" r="14" fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" />
|
| 74 |
+
<text x="16" y="6" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">N</text>
|
| 75 |
+
<text x="16" y="28" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">S</text>
|
| 76 |
+
<text x="4" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">W</text>
|
| 77 |
+
<text x="28" y="17" textAnchor="middle" fontSize="5" fill="rgba(255,255,255,0.35)" dominantBaseline="middle">E</text>
|
| 78 |
+
</svg>
|
| 79 |
+
{/* direction arrow */}
|
| 80 |
+
<div style={{
|
| 81 |
+
position: 'absolute', top: '50%', left: '50%',
|
| 82 |
+
transform: `translate(-50%,-50%) rotate(${windDeg}deg)`,
|
| 83 |
+
fontSize: windDir === 'CALM' ? '10px' : '14px',
|
| 84 |
+
color: windDir === 'CALM' ? 'rgba(168,162,158,0.6)' : '#fbbf24',
|
| 85 |
+
lineHeight: 1,
|
| 86 |
+
transition: 'transform 0.6s ease',
|
| 87 |
+
}}>
|
| 88 |
+
{windArrow}
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
<div>
|
| 93 |
+
<div className="hlbl">Wind</div>
|
| 94 |
+
<div style={{ fontFamily: 'var(--mono)', fontSize: '13px', fontWeight: 500, color: '#f0e8e0', lineHeight: 1.2 }}>
|
| 95 |
+
{windDir}
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div style={{ display: 'flex', gap: '10px', marginTop: '4px' }}>
|
| 101 |
+
<div>
|
| 102 |
+
<div className="hlbl">Spread</div>
|
| 103 |
+
<div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: spreadRate > 0.5 ? '#f87171' : '#fbbf24' }}>
|
| 104 |
+
{(spreadRate * 100).toFixed(0)}%
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
<div>
|
| 108 |
+
<div className="hlbl">Humidity</div>
|
| 109 |
+
<div style={{ fontFamily: 'var(--mono)', fontSize: '11px', color: humidity > 0.6 ? '#60a5fa' : '#a8a29e' }}>
|
| 110 |
+
{(humidity * 100).toFixed(0)}%
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
{fire_visible && (
|
| 114 |
+
<div style={{ alignSelf: 'center' }}>
|
| 115 |
+
<span style={{
|
| 116 |
+
fontFamily: 'var(--mono)', fontSize: '9px', fontWeight: 600,
|
| 117 |
+
color: '#f87171', background: 'rgba(239,68,68,0.15)',
|
| 118 |
+
border: '1px solid rgba(239,68,68,0.3)',
|
| 119 |
+
padding: '2px 5px', borderRadius: '3px', letterSpacing: '0.06em',
|
| 120 |
+
animation: 'pulse-red 1s infinite',
|
| 121 |
+
}}>
|
| 122 |
+
🔥 FIRE
|
| 123 |
+
</span>
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
</div>
|
| 127 |
</div>
|
| 128 |
+
|
| 129 |
+
{/* ── Right: Steps ── */}
|
| 130 |
+
<div className="hud-card" style={{ textAlign: 'right', minWidth: 'auto' }}>
|
| 131 |
+
<div className="hud-r" style={{ justifyContent: 'flex-end' }}>
|
| 132 |
+
<span className="step-val">{map_state.step_count} / {map_state.max_steps}</span>
|
| 133 |
+
</div>
|
| 134 |
+
<div className="sbar-bg">
|
| 135 |
+
<div className="sbar-fill" style={{ width: `${sPct}%` }} />
|
| 136 |
+
</div>
|
| 137 |
+
<div className="step-meta">
|
| 138 |
+
{metadata?.difficulty ?? 'medium'} · {map_state.template_name}
|
| 139 |
+
</div>
|
| 140 |
</div>
|
| 141 |
</div>
|
| 142 |
);
|
frontend/src/components/Map2D.tsx
CHANGED
|
@@ -6,523 +6,467 @@ interface Map2DProps {
|
|
| 6 |
agentMoveFlash: number;
|
| 7 |
}
|
| 8 |
|
| 9 |
-
|
| 10 |
-
const
|
| 11 |
-
const
|
| 12 |
-
const DOOR_OPEN = 2;
|
| 13 |
const DOOR_CLOSED = 3;
|
| 14 |
-
const EXIT
|
| 15 |
-
const OBSTACLE
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
};
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
};
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => {
|
| 27 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
|
|
|
| 31 |
const canvas = canvasRef.current;
|
| 32 |
-
if (!canvas) return;
|
| 33 |
const ctx = canvas.getContext('2d');
|
| 34 |
if (!ctx) return;
|
| 35 |
|
| 36 |
-
|
| 37 |
-
const
|
| 38 |
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
const idx = (x: number, y: number) => y * W + x;
|
| 41 |
-
const
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}
|
| 61 |
}
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
const drawFloorRoom = (px: number, py: number) => {
|
| 81 |
-
const s = CELL;
|
| 82 |
-
ctx.fillStyle = '#E2D8C4'; ctx.fillRect(px, py, s, s);
|
| 83 |
-
ctx.fillStyle = '#ECE4D0'; ctx.fillRect(px + 2, py + 2, s - 4, s - 4);
|
| 84 |
-
ctx.strokeStyle = '#CCC2AE'; ctx.lineWidth = 0.7;
|
| 85 |
-
ctx.beginPath(); ctx.moveTo(px + s / 2, py + 2); ctx.lineTo(px + s / 2, py + s - 2); ctx.stroke();
|
| 86 |
-
ctx.beginPath(); ctx.moveTo(px + 2, py + s / 2); ctx.lineTo(px + s - 2, py + s / 2); ctx.stroke();
|
| 87 |
-
ctx.strokeStyle = '#BCB2A0'; ctx.lineWidth = 0.5;
|
| 88 |
-
ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 89 |
-
};
|
| 90 |
-
|
| 91 |
-
const drawWallOuter = (px: number, py: number) => {
|
| 92 |
-
const s = CELL;
|
| 93 |
-
ctx.fillStyle = '#5C6470'; ctx.fillRect(px, py, s, s);
|
| 94 |
-
ctx.fillStyle = '#80889A'; ctx.fillRect(px, py, s, 6);
|
| 95 |
-
ctx.fillStyle = '#70788A'; ctx.fillRect(px, py + 6, s, 3);
|
| 96 |
-
ctx.fillStyle = '#404850'; ctx.fillRect(px, py + s - 3, s, 3);
|
| 97 |
-
ctx.strokeStyle = '#4A5260'; ctx.lineWidth = 0.7;
|
| 98 |
-
[13, 23, 30].forEach(ly => { ctx.beginPath(); ctx.moveTo(px, py + ly); ctx.lineTo(px + s, py + ly); ctx.stroke(); });
|
| 99 |
-
ctx.lineWidth = 0.4;
|
| 100 |
-
[[0, 14, 10], [14, 10, 18], [24, 9, 9]].forEach(([ry, h, off]) => {
|
| 101 |
-
for (let vx = off; vx < s; vx += 18) { if (vx > 0 && vx < s) { ctx.beginPath(); ctx.moveTo(px + vx, py + ry + 8); ctx.lineTo(px + vx, py + ry + 8 + h); ctx.stroke(); } }
|
| 102 |
-
});
|
| 103 |
-
ctx.strokeStyle = '#282E38'; ctx.lineWidth = 1;
|
| 104 |
-
ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 105 |
-
};
|
| 106 |
-
|
| 107 |
-
const drawWallInner = (px: number, py: number) => {
|
| 108 |
-
const s = CELL;
|
| 109 |
-
ctx.fillStyle = '#726860'; ctx.fillRect(px, py, s, s);
|
| 110 |
-
ctx.fillStyle = '#928880'; ctx.fillRect(px, py, s, 5);
|
| 111 |
-
ctx.fillStyle = '#827870'; ctx.fillRect(px, py + 5, s, 2);
|
| 112 |
-
ctx.fillStyle = '#524840'; ctx.fillRect(px, py + s - 2, s, 2);
|
| 113 |
-
ctx.strokeStyle = '#605850'; ctx.lineWidth = 0.6;
|
| 114 |
-
for (let i = 1; i <= 4; i++) { const ly = py + Math.round(i * s / 5); ctx.beginPath(); ctx.moveTo(px + 2, ly); ctx.lineTo(px + s - 2, ly); ctx.stroke(); }
|
| 115 |
-
ctx.strokeStyle = '#403830'; ctx.lineWidth = 1;
|
| 116 |
-
ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 117 |
-
};
|
| 118 |
-
|
| 119 |
-
const drawWallWithItem = (px: number, py: number, item: string) => {
|
| 120 |
-
const x = px / CELL | 0, y = py / CELL | 0;
|
| 121 |
-
if (isOuter(x, y)) drawWallOuter(px, py); else drawWallInner(px, py);
|
| 122 |
-
const s = CELL, cx = px + s / 2;
|
| 123 |
-
if (item === 'alarm_wall') {
|
| 124 |
-
ctx.fillStyle = '#AA0000'; ctx.fillRect(px + 8, py + 10, 20, 16);
|
| 125 |
-
ctx.fillStyle = '#CC1010'; ctx.fillRect(px + 8, py + 10, 20, 3);
|
| 126 |
-
ctx.fillStyle = '#FFD040';
|
| 127 |
-
ctx.beginPath(); ctx.arc(cx, py + 19, 4, 0, Math.PI * 2); ctx.fill();
|
| 128 |
-
ctx.fillStyle = '#AA0000';
|
| 129 |
-
ctx.beginPath(); ctx.arc(cx, py + 19, 2.5, 0, Math.PI * 2); ctx.fill();
|
| 130 |
-
ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
|
| 131 |
-
ctx.fillStyle = '#FF4040'; ctx.fillText('FIRE', cx, py + 30);
|
| 132 |
-
ctx.fillStyle = 'rgba(255,0,0,0.10)'; ctx.fillRect(px, py, s, s);
|
| 133 |
-
}
|
| 134 |
-
if (item === 'exit_sign') {
|
| 135 |
-
ctx.fillStyle = '#00A030'; ctx.fillRect(px + 5, py + 12, 26, 12);
|
| 136 |
-
ctx.fillStyle = '#00C840'; ctx.fillRect(px + 5, py + 12, 26, 3);
|
| 137 |
-
ctx.fillStyle = '#FFFFFF';
|
| 138 |
-
ctx.font = 'bold 5px monospace'; ctx.textAlign = 'center';
|
| 139 |
-
ctx.fillText('EXIT', cx, py + 22);
|
| 140 |
-
ctx.fillStyle = '#80FF80'; ctx.beginPath();
|
| 141 |
-
ctx.moveTo(cx - 2, py + 9); ctx.lineTo(cx + 4, py + 12); ctx.lineTo(cx - 2, py + 12); ctx.closePath(); ctx.fill();
|
| 142 |
-
}
|
| 143 |
-
};
|
| 144 |
-
|
| 145 |
-
const drawDoorOpen = (px: number, py: number) => {
|
| 146 |
-
const s = CELL, fw = 5;
|
| 147 |
-
drawFloorCorridor(px, py);
|
| 148 |
-
ctx.fillStyle = '#5C2408'; ctx.fillRect(px, py, fw, s); ctx.fillRect(px + s - fw, py, fw, s); ctx.fillRect(px, py, s, fw);
|
| 149 |
-
ctx.fillStyle = '#8A3A18'; ctx.fillRect(px + 1, py + fw, fw - 2, s - fw - 1); ctx.fillRect(px + s - fw + 1, py + fw, fw - 2, s - fw - 1);
|
| 150 |
-
ctx.fillStyle = 'rgba(200,230,255,0.05)'; ctx.fillRect(px + fw, py + fw, s - fw * 2, s - fw);
|
| 151 |
-
ctx.fillStyle = '#28A040'; ctx.fillRect(px + fw, py + fw, s - fw * 2, 3);
|
| 152 |
-
ctx.fillStyle = '#40C060'; ctx.fillRect(px + fw, py + fw, s - fw * 2, 1);
|
| 153 |
-
};
|
| 154 |
-
|
| 155 |
-
const drawDoorClosed = (px: number, py: number) => {
|
| 156 |
-
const s = CELL;
|
| 157 |
-
ctx.fillStyle = '#240E05'; ctx.fillRect(px, py, s, s);
|
| 158 |
-
ctx.fillStyle = '#7A3E16'; ctx.fillRect(px + 3, py + 2, s - 6, s - 4);
|
| 159 |
-
ctx.fillStyle = '#9A5E34'; ctx.fillRect(px + 3, py + 2, s - 6, 3); ctx.fillRect(px + 3, py + 2, 3, s - 4);
|
| 160 |
-
ctx.fillStyle = '#5A2608'; ctx.fillRect(px + 3, py + s - 4, s - 6, 2); ctx.fillRect(px + s - 6, py + 2, 3, s - 4);
|
| 161 |
-
ctx.fillStyle = '#6A2E10'; ctx.fillRect(px + 8, py + 7, s - 16, s - 16);
|
| 162 |
-
ctx.fillStyle = '#8A4E2E'; ctx.fillRect(px + 8, py + 7, s - 16, 2); ctx.fillRect(px + 8, py + 7, 2, s - 16);
|
| 163 |
-
ctx.fillStyle = '#4A1E08'; ctx.fillRect(px + 8, py + s - 11, s - 16, 2); ctx.fillRect(px + s - 10, py + 7, 2, s - 16);
|
| 164 |
-
ctx.fillStyle = '#C89020'; ctx.beginPath(); ctx.arc(px + s - 10, py + s / 2, 3, 0, Math.PI * 2); ctx.fill();
|
| 165 |
-
ctx.fillStyle = '#F0C040'; ctx.beginPath(); ctx.arc(px + s - 11, py + s / 2 - 1, 1.5, 0, Math.PI * 2); ctx.fill();
|
| 166 |
-
ctx.strokeStyle = '#140800'; ctx.lineWidth = 1; ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 167 |
-
};
|
| 168 |
-
|
| 169 |
-
const drawDoorFailed = (px: number, py: number) => {
|
| 170 |
-
const s = CELL;
|
| 171 |
-
ctx.fillStyle = '#180C04'; ctx.fillRect(px, py, s, s);
|
| 172 |
-
ctx.fillStyle = '#D0A858'; ctx.fillRect(px + 5, py + 5, s - 10, s - 10);
|
| 173 |
-
ctx.fillStyle = '#B08840'; ctx.fillRect(px + 5, py + 5, s - 10, s - 10);
|
| 174 |
-
ctx.fillStyle = '#281408'; ctx.fillRect(px + 7, py + 7, 8, 6); ctx.fillRect(px + 18, py + 18, 8, 5);
|
| 175 |
-
ctx.fillStyle = '#380E04'; ctx.fillRect(px + 12, py + 12, 8, 8);
|
| 176 |
-
ctx.fillStyle = '#300A02';
|
| 177 |
-
ctx.fillRect(px, py, 4, s); ctx.fillRect(px + s - 4, py, 4, s); ctx.fillRect(px, py, s, 4);
|
| 178 |
-
ctx.fillStyle = '#A03010'; ctx.fillRect(px + 2, py + 8, 2, 2); ctx.fillRect(px + s - 4, py + 14, 2, 2);
|
| 179 |
-
ctx.fillStyle = '#D05020'; ctx.fillRect(px + 2, py + 9, 1, 1); ctx.fillRect(px + s - 4, py + 15, 1, 1);
|
| 180 |
-
ctx.strokeStyle = 'rgba(200,30,10,0.7)'; ctx.lineWidth = 1.5;
|
| 181 |
-
ctx.beginPath(); ctx.moveTo(px + 6, py + 6); ctx.lineTo(px + s - 6, py + s - 6); ctx.stroke();
|
| 182 |
-
ctx.beginPath(); ctx.moveTo(px + s - 6, py + 6); ctx.lineTo(px + 6, py + s - 6); ctx.stroke();
|
| 183 |
-
ctx.strokeStyle = '#100600'; ctx.lineWidth = 1;
|
| 184 |
-
ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 185 |
-
};
|
| 186 |
-
|
| 187 |
-
const drawExit = (px: number, py: number) => {
|
| 188 |
-
const s = CELL;
|
| 189 |
-
ctx.fillStyle = '#0C7224'; ctx.fillRect(px, py, s, s);
|
| 190 |
-
ctx.fillStyle = '#1A9038'; ctx.fillRect(px + 2, py + 2, s - 4, s - 4);
|
| 191 |
-
ctx.fillStyle = '#30A850'; ctx.fillRect(px + 4, py + 4, s - 8, s - 8);
|
| 192 |
-
ctx.fillStyle = '#FFFFFF';
|
| 193 |
-
const cx = px + s / 2, ay = py + 6;
|
| 194 |
-
ctx.beginPath(); ctx.moveTo(cx, ay); ctx.lineTo(cx + 7, ay + 8); ctx.lineTo(cx + 4, ay + 8);
|
| 195 |
-
ctx.lineTo(cx + 4, ay + 19); ctx.lineTo(cx - 4, ay + 19); ctx.lineTo(cx - 4, ay + 8); ctx.lineTo(cx - 7, ay + 8);
|
| 196 |
-
ctx.closePath(); ctx.fill();
|
| 197 |
-
ctx.font = 'bold 5px monospace'; ctx.textAlign = 'center';
|
| 198 |
-
ctx.fillStyle = '#FFFFFF'; ctx.fillText('EXIT', cx, py + s - 4);
|
| 199 |
-
ctx.strokeStyle = '#085018'; ctx.lineWidth = 2; ctx.strokeRect(px + 1, py + 1, s - 2, s - 2);
|
| 200 |
-
ctx.strokeStyle = '#48C860'; ctx.lineWidth = 0.5; ctx.strokeRect(px + 3.5, py + 3.5, s - 7, s - 7);
|
| 201 |
-
};
|
| 202 |
-
|
| 203 |
-
const drawDesk = (px: number, py: number) => {
|
| 204 |
-
const s = CELL;
|
| 205 |
-
ctx.fillStyle = '#7B5A30'; ctx.fillRect(px + 2, py + 14, s - 4, s - 20);
|
| 206 |
-
ctx.fillStyle = '#9B7A50'; ctx.fillRect(px + 2, py + 14, s - 4, 3); ctx.fillRect(px + 2, py + 14, 3, s - 20);
|
| 207 |
-
ctx.fillStyle = '#5A3A18'; ctx.fillRect(px + 3, py + s - 8, 3, 6); ctx.fillRect(px + s - 6, py + s - 8, 3, 6);
|
| 208 |
-
ctx.fillStyle = '#F0E8D0'; ctx.fillRect(px + 6, py + 17, 9, 4);
|
| 209 |
-
ctx.fillStyle = '#C8C0B0'; ctx.fillRect(px + 17, py + 16, 7, 3);
|
| 210 |
-
};
|
| 211 |
-
|
| 212 |
-
const drawMonitor = (px: number, py: number) => {
|
| 213 |
-
const s = CELL;
|
| 214 |
-
ctx.fillStyle = '#1A2030'; ctx.fillRect(px + 6, py + 4, s - 12, 15);
|
| 215 |
-
ctx.fillStyle = '#283050'; ctx.fillRect(px + 7, py + 5, s - 14, 13);
|
| 216 |
-
ctx.fillStyle = '#102848'; ctx.fillRect(px + 8, py + 6, s - 16, 11);
|
| 217 |
-
ctx.fillStyle = '#204880'; ctx.fillRect(px + 9, py + 7, s - 18, 4);
|
| 218 |
-
ctx.fillStyle = '#38A0D8'; ctx.fillRect(px + 9, py + 7, s - 18, 1);
|
| 219 |
-
ctx.fillStyle = '#286080'; ctx.fillRect(px + 9, py + 10, s - 18, 1);
|
| 220 |
-
ctx.fillStyle = '#204060'; ctx.fillRect(px + 9, py + 12, 8, 1);
|
| 221 |
-
ctx.fillStyle = '#00E060'; ctx.fillRect(px + s / 2 + 5, py + 19, 2, 2);
|
| 222 |
-
ctx.fillStyle = '#202028'; ctx.fillRect(px + s / 2 - 2, py + 20, 5, 4); ctx.fillRect(px + s / 2 - 4, py + 23, 9, 2);
|
| 223 |
-
};
|
| 224 |
-
|
| 225 |
-
const drawChair = (px: number, py: number) => {
|
| 226 |
-
const s = CELL;
|
| 227 |
-
ctx.fillStyle = '#2A3050'; ctx.fillRect(px + 7, py + 16, s - 14, 10);
|
| 228 |
-
ctx.fillStyle = '#3A4068'; ctx.fillRect(px + 7, py + 16, s - 14, 3);
|
| 229 |
-
ctx.fillStyle = '#2A3050'; ctx.fillRect(px + 9, py + 8, s - 18, 9);
|
| 230 |
-
ctx.fillStyle = '#3A4068'; ctx.fillRect(px + 9, py + 8, s - 18, 2);
|
| 231 |
-
ctx.fillStyle = '#303844'; ctx.fillRect(px + 5, py + 16, 4, 7); ctx.fillRect(px + s - 9, py + 16, 4, 7);
|
| 232 |
-
ctx.strokeStyle = '#484E58'; ctx.lineWidth = 1.2;
|
| 233 |
-
const base = [0, -8, 6, 5, -6, 5, -9, -2, 9, -2];
|
| 234 |
-
for (let i = 0; i < 5; i++) {
|
| 235 |
-
ctx.beginPath(); ctx.moveTo(px + s / 2, py + s - 4); ctx.lineTo(px + s / 2 + base[i * 2], py + s - 4 + base[i * 2 + 1]); ctx.stroke();
|
| 236 |
-
}
|
| 237 |
-
};
|
| 238 |
-
|
| 239 |
-
const drawPlant = (px: number, py: number) => {
|
| 240 |
-
const s = CELL, cx = px + s / 2;
|
| 241 |
-
ctx.fillStyle = '#C06030'; ctx.fillRect(cx - 6, py + 24, 12, 8);
|
| 242 |
-
ctx.fillStyle = '#E08050'; ctx.fillRect(cx - 6, py + 24, 12, 2);
|
| 243 |
-
ctx.fillStyle = '#4A2A10'; ctx.fillRect(cx - 5, py + 26, 10, 4);
|
| 244 |
-
ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx, py + 15, 7, 0, Math.PI * 2); ctx.fill();
|
| 245 |
-
ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx - 6, py + 19, 5, 0, Math.PI * 2); ctx.fill();
|
| 246 |
-
ctx.fillStyle = '#208038'; ctx.beginPath(); ctx.arc(cx + 6, py + 19, 5, 0, Math.PI * 2); ctx.fill();
|
| 247 |
-
ctx.fillStyle = '#30C050'; ctx.beginPath(); ctx.arc(cx - 1, py + 13, 4, 0, Math.PI * 2); ctx.fill();
|
| 248 |
-
ctx.fillStyle = '#48D860'; ctx.beginPath(); ctx.arc(cx - 2, py + 12, 2, 0, Math.PI * 2); ctx.fill();
|
| 249 |
-
};
|
| 250 |
-
|
| 251 |
-
const drawFiling = (px: number, py: number) => {
|
| 252 |
-
const s = CELL;
|
| 253 |
-
ctx.fillStyle = '#788090'; ctx.fillRect(px + 6, py + 3, s - 12, s - 8);
|
| 254 |
-
ctx.fillStyle = '#90A0B0'; ctx.fillRect(px + 6, py + 3, s - 12, 2);
|
| 255 |
-
ctx.fillStyle = '#586070'; ctx.fillRect(px + 6, py + s - 8, s - 12, 3);
|
| 256 |
-
ctx.strokeStyle = '#485060'; ctx.lineWidth = 0.8;
|
| 257 |
-
[11, 19, 27].forEach(y => { ctx.beginPath(); ctx.moveTo(px + 6, py + y); ctx.lineTo(px + s - 6, py + y); ctx.stroke(); });
|
| 258 |
-
ctx.fillStyle = '#B8C8D8';
|
| 259 |
-
[8, 16, 24].forEach(dy => { ctx.fillRect(px + s / 2 - 4, py + dy + 1, 8, 2); });
|
| 260 |
-
};
|
| 261 |
-
|
| 262 |
-
const drawExtinguisher = (px: number, py: number) => {
|
| 263 |
-
const s = CELL, cx = px + s / 2;
|
| 264 |
-
ctx.fillStyle = '#BE0808'; ctx.fillRect(cx - 5, py + 8, 10, 20);
|
| 265 |
-
ctx.fillStyle = '#E01010'; ctx.fillRect(cx - 5, py + 8, 10, 3);
|
| 266 |
-
ctx.fillStyle = '#DD2828'; ctx.fillRect(cx - 4, py + 11, 3, 14);
|
| 267 |
-
ctx.fillStyle = '#282828'; ctx.fillRect(cx - 1, py + 4, 3, 5);
|
| 268 |
-
ctx.fillRect(cx + 1, py + 4, 5, 2);
|
| 269 |
-
ctx.fillStyle = '#FFFFFF'; ctx.fillRect(cx - 5, py + 16, 10, 3);
|
| 270 |
-
ctx.fillStyle = '#FFFFFF'; ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
|
| 271 |
-
ctx.fillText('EXT', cx, py + 23);
|
| 272 |
-
ctx.fillStyle = '#700000'; ctx.fillRect(cx - 5, py + 26, 10, 2);
|
| 273 |
-
ctx.fillStyle = 'rgba(220,30,10,0.12)'; ctx.fillRect(px, py, s, s);
|
| 274 |
-
};
|
| 275 |
-
|
| 276 |
-
const drawHazardMarker = (px: number, py: number) => {
|
| 277 |
-
const s = CELL, cx = px + s / 2, cy = py + s / 2;
|
| 278 |
-
ctx.fillStyle = 'rgba(255,190,0,0.18)'; ctx.fillRect(px, py, s, s);
|
| 279 |
-
ctx.fillStyle = '#E8C000';
|
| 280 |
-
ctx.beginPath(); ctx.moveTo(cx, py + 4); ctx.lineTo(px + s - 4, cy); ctx.lineTo(cx, py + s - 4); ctx.lineTo(px + 4, cy); ctx.closePath(); ctx.fill();
|
| 281 |
-
ctx.fillStyle = '#8A7200';
|
| 282 |
-
ctx.beginPath(); ctx.moveTo(cx, py + 9); ctx.lineTo(px + s - 9, cy); ctx.lineTo(cx, py + s - 9); ctx.lineTo(px + 9, cy); ctx.closePath(); ctx.fill();
|
| 283 |
-
ctx.fillStyle = '#FFE030'; ctx.font = 'bold 11px monospace';
|
| 284 |
-
ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
|
| 285 |
-
ctx.fillText('!', cx, cy + 1); (ctx as any).textBaseline = 'alphabetic';
|
| 286 |
-
ctx.strokeStyle = '#A08000'; ctx.lineWidth = 1;
|
| 287 |
-
ctx.strokeRect(px + .5, py + .5, s - 1, s - 1);
|
| 288 |
-
};
|
| 289 |
-
|
| 290 |
-
const drawSprinkler = (px: number, py: number) => {
|
| 291 |
-
const s = CELL, cx = px + s / 2;
|
| 292 |
-
ctx.fillStyle = 'rgba(50,100,200,0.14)'; ctx.fillRect(px, py, s, s);
|
| 293 |
-
ctx.fillStyle = '#909098'; ctx.fillRect(cx - 2, py + 1, 4, 4);
|
| 294 |
-
ctx.fillStyle = '#A8A8B0'; ctx.fillRect(cx - 5, py + 4, 10, 2);
|
| 295 |
-
ctx.fillStyle = '#58A0E8';
|
| 296 |
-
[[s * 0.25, s * 0.32], [s * 0.5, s * 0.38], [s * 0.75, s * 0.32],
|
| 297 |
-
[s * 0.18, s * 0.52], [s * 0.5, s * 0.58], [s * 0.82, s * 0.52],
|
| 298 |
-
[s * 0.3, s * 0.72], [s * 0.7, s * 0.72]].forEach(([wx, wy]) => {
|
| 299 |
-
ctx.beginPath(); ctx.ellipse(px + wx, py + wy, 1.5, 2.5, 0, 0, Math.PI * 2); ctx.fill();
|
| 300 |
-
});
|
| 301 |
-
};
|
| 302 |
-
|
| 303 |
-
const drawAlarmTriggered = (px: number, py: number) => {
|
| 304 |
-
const s = CELL, cx = px + s / 2;
|
| 305 |
-
ctx.fillStyle = 'rgba(200,0,0,0.18)'; ctx.fillRect(px, py, s, s);
|
| 306 |
-
ctx.fillStyle = '#B80000'; ctx.fillRect(px + 5, py + 6, s - 10, s - 12);
|
| 307 |
-
ctx.fillStyle = '#D81010'; ctx.fillRect(px + 5, py + 6, s - 10, 3);
|
| 308 |
-
ctx.fillStyle = '#880000'; ctx.fillRect(px + 5, py + s - 9, s - 10, 3);
|
| 309 |
-
ctx.fillStyle = '#FFD030'; ctx.beginPath(); ctx.arc(cx, py + s / 2, 6, 0, Math.PI * 2); ctx.fill();
|
| 310 |
-
ctx.fillStyle = '#CC0000'; ctx.beginPath(); ctx.arc(cx, py + s / 2, 4, 0, Math.PI * 2); ctx.fill();
|
| 311 |
-
ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
|
| 312 |
-
ctx.fillStyle = '#FF6060'; ctx.fillText('FIRE', cx, py + s - 4);
|
| 313 |
-
};
|
| 314 |
-
|
| 315 |
-
const drawSensorNode = (px: number, py: number) => {
|
| 316 |
-
const s = CELL, cx = px + s / 2;
|
| 317 |
-
ctx.fillStyle = '#C0C8D0'; ctx.beginPath(); ctx.arc(cx, py + 9, 6, 0, Math.PI * 2); ctx.fill();
|
| 318 |
-
ctx.fillStyle = '#D8E0E8'; ctx.beginPath(); ctx.arc(cx, py + 9, 5, 0, Math.PI * 2); ctx.fill();
|
| 319 |
-
ctx.fillStyle = '#00FF70'; ctx.beginPath(); ctx.arc(cx, py + 9, 2, 0, Math.PI * 2); ctx.fill();
|
| 320 |
-
ctx.strokeStyle = 'rgba(0,200,80,0.35)'; ctx.lineWidth = 0.8;
|
| 321 |
-
[9, 13, 17].forEach(r => { ctx.beginPath(); ctx.arc(cx, py + 9, r, Math.PI * 1.2, Math.PI * 1.8); ctx.stroke(); });
|
| 322 |
-
ctx.font = 'bold 4px monospace'; ctx.textAlign = 'center';
|
| 323 |
-
ctx.fillStyle = '#80A8C8'; ctx.fillText('SENS', cx, py + 22);
|
| 324 |
-
ctx.fillStyle = 'rgba(0,180,100,0.12)'; ctx.fillRect(px, py, s, s);
|
| 325 |
-
};
|
| 326 |
-
|
| 327 |
-
const drawItem = (item: string, px: number, py: number) => {
|
| 328 |
-
ctx.save();
|
| 329 |
-
switch (item) {
|
| 330 |
-
case 'desk': drawDesk(px, py); break;
|
| 331 |
-
case 'monitor': drawMonitor(px, py); break;
|
| 332 |
-
case 'chair': drawChair(px, py); break;
|
| 333 |
-
case 'plant': drawPlant(px, py); break;
|
| 334 |
-
case 'filing': drawFiling(px, py); break;
|
| 335 |
-
case 'extinguisher': drawExtinguisher(px, py); break;
|
| 336 |
-
case 'hazard': drawHazardMarker(px, py); break;
|
| 337 |
-
case 'sprinkler': drawSprinkler(px, py); break;
|
| 338 |
-
case 'alarm': drawAlarmTriggered(px, py); break;
|
| 339 |
-
case 'sensor': drawSensorNode(px, py); break;
|
| 340 |
-
}
|
| 341 |
-
ctx.restore();
|
| 342 |
-
};
|
| 343 |
-
|
| 344 |
-
const drawTemperature = (px: number, py: number, x: number, y: number) => {
|
| 345 |
-
const heat = getHeat(x, y);
|
| 346 |
-
if (heat < 0.08) return;
|
| 347 |
-
const alpha = Math.min(0.52, heat * 0.58);
|
| 348 |
-
if (heat > 0.7) {
|
| 349 |
-
ctx.fillStyle = `rgba(255,50,0,${alpha})`; ctx.fillRect(px, py, CELL, CELL);
|
| 350 |
-
} else if (heat > 0.45) {
|
| 351 |
-
ctx.fillStyle = `rgba(255,120,20,${alpha})`; ctx.fillRect(px, py, CELL, CELL);
|
| 352 |
-
} else {
|
| 353 |
-
ctx.fillStyle = `rgba(255,190,40,${alpha * 0.7})`; ctx.fillRect(px, py, CELL, CELL);
|
| 354 |
-
}
|
| 355 |
-
};
|
| 356 |
-
|
| 357 |
-
const drawFog = (px: number, py: number, x: number, y: number) => {
|
| 358 |
-
const explored = new Set(map_state.visible_cells.map(([cx, cy]) => `${cx},${cy}`));
|
| 359 |
-
if (explored.has(`${x},${y}`)) return;
|
| 360 |
-
ctx.fillStyle = 'rgba(5,5,12,0.72)'; ctx.fillRect(px, py, CELL, CELL);
|
| 361 |
-
ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
|
| 362 |
-
ctx.fillStyle = 'rgba(80,80,120,0.5)';
|
| 363 |
-
ctx.fillText('?', px + CELL / 2, py + CELL / 2); (ctx as any).textBaseline = 'alphabetic';
|
| 364 |
-
};
|
| 365 |
-
|
| 366 |
-
const drawFire = (px: number, py: number, intensity: number) => {
|
| 367 |
-
if (intensity < 0.03) return;
|
| 368 |
-
const s = CELL;
|
| 369 |
-
// High base alpha so even smoldering (0.1) is visible
|
| 370 |
-
const alpha = Math.min(1.0, 0.5 + intensity * 0.5);
|
| 371 |
-
const g = ctx.createRadialGradient(px + s / 2, py + s / 2, 0, px + s / 2, py + s / 2, s * 0.72);
|
| 372 |
-
g.addColorStop(0, `rgba(255, 255, 180, ${alpha})`);
|
| 373 |
-
g.addColorStop(0.3, `rgba(255, 180, 0, ${alpha * 0.9})`);
|
| 374 |
-
g.addColorStop(0.7, `rgba(255, 40, 0, ${alpha * 0.7})`);
|
| 375 |
-
g.addColorStop(1, 'rgba(150, 0, 0, 0)');
|
| 376 |
-
ctx.fillStyle = g; ctx.fillRect(px, py, s, s);
|
| 377 |
-
|
| 378 |
-
if (intensity > 0.3) {
|
| 379 |
-
ctx.fillStyle = `rgba(255, 255, 255, ${intensity * 0.6})`;
|
| 380 |
-
[[s / 2 - 2, 4, s / 2 + 2, 4, s / 2, -2], [s * .28, 7, s * .48, 7, s * .38, 1], [s * .58, 6, s * .78, 6, s * .68, 0]].forEach(([x1, y1, x2, y2, x3, y3]) => {
|
| 381 |
-
ctx.beginPath(); ctx.moveTo(px + x1, py + y1); ctx.lineTo(px + x2, py + y2); ctx.lineTo(px + x3, py + y3); ctx.closePath(); ctx.fill();
|
| 382 |
-
});
|
| 383 |
-
}
|
| 384 |
-
if (intensity > 0.5) {
|
| 385 |
-
ctx.font = `bold ${s * .65}px serif`; ctx.textAlign = 'center'; (ctx as any).textBaseline = 'middle';
|
| 386 |
-
ctx.globalAlpha = Math.min(1, intensity * 1.5);
|
| 387 |
-
ctx.fillText('🔥', px + s / 2, py + s / 2 + 2); ctx.globalAlpha = 1; (ctx as any).textBaseline = 'alphabetic';
|
| 388 |
-
}
|
| 389 |
-
};
|
| 390 |
-
|
| 391 |
-
const drawSmoke = (px: number, py: number, density: number) => {
|
| 392 |
-
if (density < 0.03) return;
|
| 393 |
-
const s = CELL;
|
| 394 |
-
const alpha = Math.min(0.8, 0.2 + density * 0.6);
|
| 395 |
-
ctx.fillStyle = `rgba(80, 80, 100, ${alpha})`; ctx.fillRect(px, py, s, s);
|
| 396 |
-
const g = ctx.createRadialGradient(px + s * .38, py + s * .28, 0, px + s / 2, py + s / 2, s * 0.62);
|
| 397 |
-
g.addColorStop(0, `rgba(120, 120, 140, ${alpha * 0.3})`); g.addColorStop(1, 'rgba(50, 50, 70, 0)');
|
| 398 |
-
ctx.fillStyle = g; ctx.fillRect(px, py, s, s);
|
| 399 |
-
};
|
| 400 |
-
|
| 401 |
-
const drawAgent = () => {
|
| 402 |
-
const { agent_x: ax, agent_y: ay } = map_state;
|
| 403 |
-
const px = ax * CELL, py = ay * CELL, s = CELL;
|
| 404 |
-
const cx = px + s / 2, cy = py + s / 2;
|
| 405 |
-
|
| 406 |
-
if (agentMoveFlash > 0) {
|
| 407 |
-
const alpha = agentMoveFlash / 18;
|
| 408 |
-
const radius = s * (0.6 + (1 - alpha) * 0.6);
|
| 409 |
-
ctx.save();
|
| 410 |
-
ctx.strokeStyle = `rgba(255, 230, 60, ${alpha})`;
|
| 411 |
-
ctx.lineWidth = 3 + alpha * 4;
|
| 412 |
-
ctx.shadowColor = `rgba(255, 200, 0, ${alpha})`;
|
| 413 |
-
ctx.shadowBlur = 14;
|
| 414 |
-
ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke();
|
| 415 |
-
ctx.restore();
|
| 416 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
ctx.fillStyle = '#1A60D8'; ctx.fillRect(cx - 7, cy - 9, 14, 11);
|
| 429 |
-
ctx.fillStyle = '#3080F8'; ctx.fillRect(cx - 7, cy - 9, 14, 3); ctx.fillRect(cx - 7, cy - 9, 3, 11);
|
| 430 |
-
ctx.fillStyle = '#1A60D8'; ctx.fillRect(cx - 10, cy - 8, 4, 9); ctx.fillRect(cx + 6, cy - 8, 4, 9);
|
| 431 |
-
ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 10, cy + 0, 4, 3); ctx.fillRect(cx + 6, cy + 0, 4, 3);
|
| 432 |
-
ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 2, cy - 11, 4, 3);
|
| 433 |
-
ctx.fillStyle = '#F0C070'; ctx.fillRect(cx - 5, cy - 19, 10, 9);
|
| 434 |
-
ctx.fillStyle = '#F8D890'; ctx.fillRect(cx - 5, cy - 19, 10, 3); ctx.fillRect(cx - 5, cy - 19, 3, 9);
|
| 435 |
-
ctx.fillStyle = '#4A2810'; ctx.fillRect(cx - 5, cy - 19, 10, 4);
|
| 436 |
-
ctx.fillRect(cx - 6, cy - 17, 3, 3); ctx.fillRect(cx + 3, cy - 17, 3, 3);
|
| 437 |
-
ctx.fillStyle = '#180C00'; ctx.fillRect(cx - 3, cy - 13, 2, 2); ctx.fillRect(cx + 1, cy - 13, 2, 2);
|
| 438 |
-
ctx.fillStyle = '#FFFFFF'; ctx.fillRect(cx - 3, cy - 14, 1, 1); ctx.fillRect(cx + 2, cy - 14, 1, 1);
|
| 439 |
-
ctx.font = 'bold 6px monospace'; ctx.textAlign = 'center';
|
| 440 |
-
ctx.fillStyle = '#FFFFFF'; ctx.fillText('A', cx, py + 2);
|
| 441 |
-
};
|
| 442 |
-
|
| 443 |
-
const drawZoneLabels = () => {
|
| 444 |
-
ctx.save();
|
| 445 |
-
ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center';
|
| 446 |
-
ctx.fillStyle = 'rgba(195,180,150,0.13)';
|
| 447 |
-
ctx.fillText('NORTH OFFICES', CELL * 8, CELL * 2.6);
|
| 448 |
-
ctx.fillText('MAIN CORRIDOR', CELL * 8, CELL * 7.4);
|
| 449 |
-
ctx.fillText('SOUTH OFFICES', CELL * 8, CELL * 12.9);
|
| 450 |
-
ctx.restore();
|
| 451 |
-
};
|
| 452 |
-
|
| 453 |
-
// Main Render Loop
|
| 454 |
-
const render = () => {
|
| 455 |
-
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 456 |
-
|
| 457 |
-
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
|
| 458 |
-
const ct = map_state.cell_grid[idx(x, y)], px = x * CELL, py = y * CELL;
|
| 459 |
-
if (ct === WALL && WALL_ITEMS[`${x},${y}`]) { drawWallWithItem(px, py, WALL_ITEMS[`${x},${y}`]); continue; }
|
| 460 |
-
switch (ct) {
|
| 461 |
-
case FLOOR: getZone(x, y) === 'corridor' ? drawFloorCorridor(px, py) : drawFloorRoom(px, py); break;
|
| 462 |
-
case WALL: isOuter(x, y) ? drawWallOuter(px, py) : drawWallInner(px, py); break;
|
| 463 |
-
case DOOR_OPEN: drawDoorOpen(px, py); break;
|
| 464 |
-
case DOOR_CLOSED: drawDoorClosed(px, py); break;
|
| 465 |
-
case OBSTACLE: drawDoorFailed(px, py); break;
|
| 466 |
-
case EXIT: drawExit(px, py); break;
|
| 467 |
-
case DOOR_FAILED: drawDoorFailed(px, py); break;
|
| 468 |
}
|
| 469 |
-
}
|
| 470 |
|
| 471 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 472 |
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
// Smoke only visible if explored/in-sight (it's hard to see in the dark)
|
| 489 |
-
if (visible) {
|
| 490 |
-
drawSmoke(px, py, smoke);
|
| 491 |
}
|
| 492 |
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
drawFire(px, py, fire);
|
| 496 |
-
// Stronger heat glow for distant fires
|
| 497 |
-
if (!visible) {
|
| 498 |
-
ctx.fillStyle = `rgba(255, 100, 0, ${Math.min(0.2, fire * 0.5)})`;
|
| 499 |
-
ctx.fillRect(px, py, CELL, CELL);
|
| 500 |
-
}
|
| 501 |
}
|
| 502 |
-
|
| 503 |
-
// Temperature/Heat signature
|
| 504 |
-
drawTemperature(px, py, x, y);
|
| 505 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
ctx.fillStyle = vig; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 512 |
-
};
|
| 513 |
-
|
| 514 |
-
render();
|
| 515 |
}, [observation, agentMoveFlash]);
|
| 516 |
|
| 517 |
-
const W = observation?.map_state.grid_w
|
| 518 |
-
const H = observation?.map_state.grid_h
|
| 519 |
|
| 520 |
return (
|
| 521 |
-
<canvas
|
| 522 |
-
ref={canvasRef}
|
| 523 |
-
id="map-canvas"
|
| 524 |
-
width={W *
|
| 525 |
-
height={H *
|
| 526 |
/>
|
| 527 |
);
|
| 528 |
};
|
|
|
|
| 6 |
agentMoveFlash: number;
|
| 7 |
}
|
| 8 |
|
| 9 |
+
/* ── Cell type constants ── */
|
| 10 |
+
const WALL = 1;
|
| 11 |
+
const DOOR_OPEN = 2;
|
|
|
|
| 12 |
const DOOR_CLOSED = 3;
|
| 13 |
+
const EXIT = 4;
|
| 14 |
+
const OBSTACLE = 5;
|
| 15 |
+
|
| 16 |
+
/* ── Wind direction vectors ── */
|
| 17 |
+
const WIND_DIRS: Record<string, [number, number]> = {
|
| 18 |
+
N: [0, -1], S: [0, 1], E: [1, 0], W: [-1, 0],
|
| 19 |
+
NW: [-0.7, -0.7], NE: [0.7, -0.7], SW: [-0.7, 0.7], SE: [0.7, 0.7],
|
| 20 |
+
CALM: [0, 0],
|
| 21 |
+
};
|
| 22 |
|
| 23 |
+
/* ── Agent appearance per health tier ── */
|
| 24 |
+
const AGENT_THEMES = {
|
| 25 |
+
healthy: { body: '#3b82f6', dark: '#1d4ed8', arm: '#2563eb', ring: '#fbbf24', ringGlow: 'rgba(251,191,36,0.5)' },
|
| 26 |
+
moderate: { body: '#f97316', dark: '#c2410c', arm: '#ea580c', ring: '#fb923c', ringGlow: 'rgba(251,146,60,0.5)' },
|
| 27 |
+
low: { body: '#dc2626', dark: '#991b1b', arm: '#b91c1c', ring: '#f87171', ringGlow: 'rgba(248,113,113,0.5)' },
|
| 28 |
+
critical: { body: '#7c3aed', dark: '#5b21b6', arm: '#6d28d9', ring: '#c4b5fd', ringGlow: 'rgba(196,181,253,0.5)' },
|
| 29 |
};
|
| 30 |
|
| 31 |
+
/* ── Ember particle ── */
|
| 32 |
+
class Ember {
|
| 33 |
+
x: number; y: number; vx: number; vy: number;
|
| 34 |
+
life: number; decay: number; size: number;
|
| 35 |
+
type: 'ember' | 'spark';
|
| 36 |
+
|
| 37 |
+
constructor(x: number, y: number, windX: number) {
|
| 38 |
+
const speed = 0.4 + Math.random() * 1.0;
|
| 39 |
+
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 1.6;
|
| 40 |
+
this.x = x + (Math.random() - 0.5) * 3;
|
| 41 |
+
this.y = y + (Math.random() - 0.5) * 3;
|
| 42 |
+
this.vx = Math.cos(angle) * speed + windX * 0.7;
|
| 43 |
+
this.vy = Math.sin(angle) * speed - 0.22;
|
| 44 |
+
this.life = 1.0;
|
| 45 |
+
this.decay = 0.012 + Math.random() * 0.015;
|
| 46 |
+
this.size = 1.2 + Math.random() * 2.2;
|
| 47 |
+
this.type = Math.random() > 0.4 ? 'ember' : 'spark';
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
update() {
|
| 51 |
+
this.x += this.vx;
|
| 52 |
+
this.y += this.vy;
|
| 53 |
+
this.vy -= 0.012;
|
| 54 |
+
this.vx *= 0.97;
|
| 55 |
+
this.life -= this.decay;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
|
|
|
|
| 59 |
|
| 60 |
+
/* ── Minecraft pixel-art character ── */
|
| 61 |
+
function drawMinecraftAgent(
|
| 62 |
+
ctx: CanvasRenderingContext2D,
|
| 63 |
+
cx: number, cy: number, cs: number,
|
| 64 |
+
theme: typeof AGENT_THEMES.healthy
|
| 65 |
+
) {
|
| 66 |
+
const u = cs / 18;
|
| 67 |
+
const left = cx - 5 * u;
|
| 68 |
+
const top = cy - 8.5 * u;
|
| 69 |
+
|
| 70 |
+
const px = (rx: number, ry: number, rw: number, rh: number, color: string) => {
|
| 71 |
+
ctx.fillStyle = color;
|
| 72 |
+
ctx.fillRect(left + rx * u, top + ry * u, rw * u, rh * u);
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
/* Helmet */
|
| 76 |
+
px(2, 0, 6, 1, '#5c4a3d');
|
| 77 |
+
/* Head */
|
| 78 |
+
px(2, 1, 6, 5, '#f5d5a0');
|
| 79 |
+
/* Face features */
|
| 80 |
+
px(3, 3, 1, 1, '#3d2b1a'); /* left eye */
|
| 81 |
+
px(6, 3, 1, 1, '#3d2b1a'); /* right eye */
|
| 82 |
+
px(4, 5, 2, 1, '#c8937a'); /* mouth */
|
| 83 |
+
/* Hair accent */
|
| 84 |
+
px(2, 1, 6, 1, '#7a5c3e');
|
| 85 |
+
|
| 86 |
+
/* Body */
|
| 87 |
+
px(3, 6, 4, 4, theme.body);
|
| 88 |
+
px(3, 6, 4, 1, theme.dark);
|
| 89 |
+
|
| 90 |
+
/* Arms */
|
| 91 |
+
px(1, 6, 2, 4, theme.arm);
|
| 92 |
+
px(7, 6, 2, 4, theme.arm);
|
| 93 |
+
|
| 94 |
+
/* Legs */
|
| 95 |
+
px(3, 10, 2, 4, '#1e40af');
|
| 96 |
+
px(5, 10, 2, 4, '#1e3a8a');
|
| 97 |
+
|
| 98 |
+
/* Boots */
|
| 99 |
+
px(3, 14, 2, 2, '#3a2e26');
|
| 100 |
+
px(5, 14, 2, 2, '#2e2420');
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/* ── Main canvas component ── */
|
| 104 |
const Map2D: React.FC<Map2DProps> = ({ observation, agentMoveFlash }) => {
|
| 105 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 106 |
+
const embersRef = useRef<Ember[]>([]);
|
| 107 |
+
const trailRef = useRef<{ x: number; y: number; t: number }[]>([]);
|
| 108 |
+
const timeRef = useRef(0);
|
| 109 |
+
const rafRef = useRef(0);
|
| 110 |
|
| 111 |
+
const CS = 40;
|
| 112 |
+
|
| 113 |
+
const animate = () => {
|
| 114 |
const canvas = canvasRef.current;
|
| 115 |
+
if (!canvas || !observation) { rafRef.current = requestAnimationFrame(animate); return; }
|
| 116 |
const ctx = canvas.getContext('2d');
|
| 117 |
if (!ctx) return;
|
| 118 |
|
| 119 |
+
timeRef.current += 0.016;
|
| 120 |
+
const t = timeRef.current;
|
| 121 |
|
| 122 |
+
const { map_state, agent_health, wind_dir } = observation;
|
| 123 |
+
const { grid_w: W, grid_h: H, cell_grid, fire_grid, smoke_grid, agent_x, agent_y } = map_state;
|
| 124 |
+
const cs = CS;
|
| 125 |
+
const wv = WIND_DIRS[wind_dir] ?? [0, 0];
|
| 126 |
const idx = (x: number, y: number) => y * W + x;
|
| 127 |
+
const visible = new Set(map_state.visible_cells.map(([vx, vy]) => `${vx},${vy}`));
|
| 128 |
+
|
| 129 |
+
/* ── Canvas bg ── */
|
| 130 |
+
ctx.fillStyle = '#c8b890';
|
| 131 |
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 132 |
+
|
| 133 |
+
/* ── Base layer ── */
|
| 134 |
+
for (let y = 0; y < H; y++) {
|
| 135 |
+
for (let x = 0; x < W; x++) {
|
| 136 |
+
const ct = cell_grid[idx(x, y)];
|
| 137 |
+
const px = x * cs, py = y * cs;
|
| 138 |
+
|
| 139 |
+
switch (ct) {
|
| 140 |
+
case WALL: {
|
| 141 |
+
ctx.fillStyle = '#5e5850';
|
| 142 |
+
ctx.fillRect(px, py, cs, cs);
|
| 143 |
+
ctx.fillStyle = 'rgba(255,255,255,0.28)';
|
| 144 |
+
ctx.fillRect(px, py, cs, 2);
|
| 145 |
+
ctx.fillRect(px, py + 2, 2, cs - 2);
|
| 146 |
+
ctx.fillStyle = 'rgba(0,0,0,0.45)';
|
| 147 |
+
ctx.fillRect(px, py + cs - 2, cs, 2);
|
| 148 |
+
ctx.fillRect(px + cs - 2, py, 2, cs - 2);
|
| 149 |
+
/* center mortar cross */
|
| 150 |
+
ctx.fillStyle = 'rgba(0,0,0,0.12)';
|
| 151 |
+
ctx.fillRect(px + cs / 2 - 1, py, 2, cs);
|
| 152 |
+
ctx.fillRect(px, py + cs / 2 - 1, cs, 2);
|
| 153 |
+
break;
|
| 154 |
+
}
|
| 155 |
+
case OBSTACLE: {
|
| 156 |
+
ctx.fillStyle = '#3a3530';
|
| 157 |
+
ctx.fillRect(px, py, cs, cs);
|
| 158 |
+
ctx.fillStyle = 'rgba(255,80,0,0.55)';
|
| 159 |
+
ctx.fillRect(px, py, cs, 3);
|
| 160 |
+
ctx.fillRect(px, py, 3, cs);
|
| 161 |
+
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
| 162 |
+
ctx.fillRect(px, py + cs - 3, cs, 3);
|
| 163 |
+
ctx.fillRect(px + cs - 3, py, 3, cs);
|
| 164 |
+
break;
|
| 165 |
+
}
|
| 166 |
+
default: {
|
| 167 |
+
/* Minecraft checkerboard floor */
|
| 168 |
+
ctx.fillStyle = (x + y) % 2 === 0 ? '#e8d8b8' : '#d0be98';
|
| 169 |
+
ctx.fillRect(px, py, cs, cs);
|
| 170 |
+
/* tile bevel */
|
| 171 |
+
ctx.fillStyle = 'rgba(255,255,255,0.20)';
|
| 172 |
+
ctx.fillRect(px, py, cs, 2);
|
| 173 |
+
ctx.fillRect(px, py + 2, 2, cs - 2);
|
| 174 |
+
ctx.fillStyle = 'rgba(0,0,0,0.18)';
|
| 175 |
+
ctx.fillRect(px, py + cs - 2, cs, 2);
|
| 176 |
+
ctx.fillRect(px + cs - 2, py, 2, cs - 2);
|
| 177 |
+
}
|
| 178 |
}
|
| 179 |
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* ── Fire ambient: multiply scorches the floor tiles ── */
|
| 183 |
+
ctx.save();
|
| 184 |
+
ctx.globalCompositeOperation = 'multiply';
|
| 185 |
+
for (let y = 0; y < H; y++) {
|
| 186 |
+
for (let x = 0; x < W; x++) {
|
| 187 |
+
const fire = fire_grid[idx(x, y)];
|
| 188 |
+
if (fire < 0.1) continue;
|
| 189 |
+
const px = x * cs + cs / 2, py = y * cs + cs / 2;
|
| 190 |
+
const radius = cs * (1.2 + fire * 1.8);
|
| 191 |
+
const a = Math.min(0.85, fire * 0.9);
|
| 192 |
+
const gr = ctx.createRadialGradient(px, py, 0, px, py, radius);
|
| 193 |
+
gr.addColorStop(0, `rgba(255,80,0,${a})`);
|
| 194 |
+
gr.addColorStop(0.4, `rgba(220,40,0,${a * 0.5})`);
|
| 195 |
+
gr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 196 |
+
ctx.fillStyle = gr;
|
| 197 |
+
ctx.fillRect(px - radius, py - radius, radius * 2, radius * 2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
}
|
| 199 |
+
}
|
| 200 |
+
ctx.restore();
|
| 201 |
+
|
| 202 |
+
/* ── Fire volumetric: vivid source-over layers (real-looking flames) ── */
|
| 203 |
+
for (let y = 0; y < H; y++) {
|
| 204 |
+
for (let x = 0; x < W; x++) {
|
| 205 |
+
const fire = fire_grid[idx(x, y)];
|
| 206 |
+
if (fire < 0.05) continue;
|
| 207 |
+
const px = x * cs + cs / 2, py = y * cs + cs / 2;
|
| 208 |
+
const flicker = 0.80 + 0.20 * Math.sin(t * 11.0 + x * 3.1 + y * 2.7);
|
| 209 |
+
const eff = Math.min(1, fire * flicker);
|
| 210 |
+
|
| 211 |
+
const windDx = wv[0] * cs * 0.25 * eff;
|
| 212 |
+
const windDy = wv[1] * cs * 0.25 * eff - cs * 0.06;
|
| 213 |
+
|
| 214 |
+
/* Outer dark-red base: anchors fire to cell */
|
| 215 |
+
{
|
| 216 |
+
const r = cs * 0.70 * (0.7 + eff * 0.3);
|
| 217 |
+
const cx2 = px + windDx * 0.5, cy2 = py + windDy * 0.5;
|
| 218 |
+
const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
|
| 219 |
+
gr.addColorStop(0, `rgba(200,20,0,${eff * 0.65})`);
|
| 220 |
+
gr.addColorStop(0.55,`rgba(170,10,0,${eff * 0.35})`);
|
| 221 |
+
gr.addColorStop(1, 'rgba(80,0,0,0)');
|
| 222 |
+
ctx.fillStyle = gr;
|
| 223 |
+
ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
|
| 224 |
+
}
|
| 225 |
|
| 226 |
+
/* Mid vivid-orange body */
|
| 227 |
+
{
|
| 228 |
+
const r = cs * 0.46 * (0.8 + eff * 0.2);
|
| 229 |
+
const cx2 = px + windDx * 0.35, cy2 = py + windDy * 0.35 - cs * 0.04 * eff;
|
| 230 |
+
const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
|
| 231 |
+
gr.addColorStop(0, `rgba(255,110,0,${eff * 0.90})`);
|
| 232 |
+
gr.addColorStop(0.45,`rgba(255,60,0,${eff * 0.60})`);
|
| 233 |
+
gr.addColorStop(1, 'rgba(220,20,0,0)');
|
| 234 |
+
ctx.fillStyle = gr;
|
| 235 |
+
ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
|
|
|
| 237 |
|
| 238 |
+
/* Inner bright-yellow core */
|
| 239 |
+
{
|
| 240 |
+
const r = cs * 0.26 * eff;
|
| 241 |
+
const cx2 = px + windDx * 0.15, cy2 = py + windDy * 0.15 - cs * 0.10 * eff;
|
| 242 |
+
const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
|
| 243 |
+
gr.addColorStop(0, `rgba(255,230,80,${eff * 0.95})`);
|
| 244 |
+
gr.addColorStop(0.35,`rgba(255,170,20,${eff * 0.75})`);
|
| 245 |
+
gr.addColorStop(1, 'rgba(255,80,0,0)');
|
| 246 |
+
ctx.fillStyle = gr;
|
| 247 |
+
ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
|
| 248 |
+
}
|
| 249 |
|
| 250 |
+
/* White-hot tip (only for intense fire) */
|
| 251 |
+
if (eff > 0.55) {
|
| 252 |
+
const r = cs * 0.13 * eff;
|
| 253 |
+
const cx2 = px + windDx * 0.1, cy2 = py + windDy * 0.1 - cs * 0.18 * eff;
|
| 254 |
+
const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
|
| 255 |
+
gr.addColorStop(0, `rgba(255,255,220,${eff * 0.9})`);
|
| 256 |
+
gr.addColorStop(1, 'rgba(255,220,60,0)');
|
| 257 |
+
ctx.fillStyle = gr;
|
| 258 |
+
ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
|
| 259 |
+
}
|
| 260 |
|
| 261 |
+
/* Wind-carried plume tip */
|
| 262 |
+
if (fire > 0.35) {
|
| 263 |
+
const r = cs * 0.28 * eff;
|
| 264 |
+
const cx2 = px + windDx, cy2 = py + windDy - cs * 0.22 * eff;
|
| 265 |
+
const gr = ctx.createRadialGradient(cx2, cy2, 0, cx2, cy2, r);
|
| 266 |
+
gr.addColorStop(0, `rgba(255,160,0,${eff * 0.65})`);
|
| 267 |
+
gr.addColorStop(1, 'rgba(255,60,0,0)');
|
| 268 |
+
ctx.fillStyle = gr;
|
| 269 |
+
ctx.beginPath(); ctx.arc(cx2, cy2, r, 0, Math.PI * 2); ctx.fill();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
}
|
| 271 |
|
| 272 |
+
if (fire > 0.45 && Math.random() < 0.09 && embersRef.current.length < 120) {
|
| 273 |
+
embersRef.current.push(new Ember(px, py, wv[0]));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
}
|
|
|
|
|
|
|
|
|
|
| 275 |
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/* ── Smoke (dark on light bg) ── */
|
| 279 |
+
for (let y = 0; y < H; y++) {
|
| 280 |
+
for (let x = 0; x < W; x++) {
|
| 281 |
+
const smoke = smoke_grid[idx(x, y)];
|
| 282 |
+
if (smoke < 0.1) continue;
|
| 283 |
+
const px = x * cs + cs / 2, py = y * cs + cs / 2;
|
| 284 |
+
const offX = Math.sin(t * 0.5 + x) * 2;
|
| 285 |
+
const offY = Math.cos(t * 0.4 + y) * 2;
|
| 286 |
+
const alpha = Math.min(0.68, smoke * 0.8);
|
| 287 |
+
const gr = ctx.createRadialGradient(px + offX, py + offY, 0, px + offX, py + offY, cs * 0.82);
|
| 288 |
+
gr.addColorStop(0, `rgba(72,82,96,${alpha})`);
|
| 289 |
+
gr.addColorStop(1, 'rgba(72,82,96,0)');
|
| 290 |
+
ctx.fillStyle = gr;
|
| 291 |
+
ctx.beginPath(); ctx.arc(px + offX, py + offY, cs * 0.82, 0, Math.PI * 2); ctx.fill();
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
/* ── Exits & Doors ── */
|
| 296 |
+
for (let y = 0; y < H; y++) {
|
| 297 |
+
for (let x = 0; x < W; x++) {
|
| 298 |
+
const ct = cell_grid[idx(x, y)];
|
| 299 |
+
const px = x * cs, py = y * cs;
|
| 300 |
+
const pulse = 0.7 + 0.3 * Math.sin(t * 3);
|
| 301 |
+
|
| 302 |
+
if (ct === EXIT) {
|
| 303 |
+
ctx.fillStyle = '#e6f4ec';
|
| 304 |
+
ctx.fillRect(px + 2, py + 2, cs - 4, cs - 4);
|
| 305 |
+
ctx.strokeStyle = `rgba(22,163,74,${0.7 + 0.3 * pulse})`;
|
| 306 |
+
ctx.lineWidth = 2 * pulse;
|
| 307 |
+
ctx.strokeRect(px + 5, py + 5, cs - 10, cs - 10);
|
| 308 |
+
/* EXIT symbol */
|
| 309 |
+
ctx.fillStyle = `rgba(22,163,74,${0.85 + 0.15 * pulse})`;
|
| 310 |
+
ctx.font = `bold ${cs * 0.26}px var(--mono, monospace)`;
|
| 311 |
+
ctx.textAlign = 'center';
|
| 312 |
+
ctx.textBaseline = 'middle';
|
| 313 |
+
ctx.fillText('EXIT', px + cs / 2, py + cs / 2);
|
| 314 |
+
} else if (ct === DOOR_CLOSED) {
|
| 315 |
+
ctx.fillStyle = '#7c5c3c';
|
| 316 |
+
ctx.fillRect(px + 4, py + 2, cs - 8, cs - 4);
|
| 317 |
+
ctx.fillStyle = '#4a3020';
|
| 318 |
+
ctx.fillRect(px + 2, py, cs - 4, 2);
|
| 319 |
+
ctx.fillRect(px + 2, py + cs - 2, cs - 4, 2);
|
| 320 |
+
/* handle */
|
| 321 |
+
ctx.fillStyle = '#f0b030';
|
| 322 |
+
ctx.beginPath();
|
| 323 |
+
ctx.arc(px + cs - 10, py + cs / 2, 2.5, 0, Math.PI * 2);
|
| 324 |
+
ctx.fill();
|
| 325 |
+
} else if (ct === DOOR_OPEN) {
|
| 326 |
+
ctx.fillStyle = '#4a3020';
|
| 327 |
+
ctx.fillRect(px + 2, py, 4, cs);
|
| 328 |
+
ctx.fillRect(px + cs - 6, py, 4, cs);
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
/* ── Fog of War (dim only — all map items still readable) ── */
|
| 334 |
+
for (let y = 0; y < H; y++) {
|
| 335 |
+
for (let x = 0; x < W; x++) {
|
| 336 |
+
const key = `${x},${y}`;
|
| 337 |
+
if (!visible.has(key)) {
|
| 338 |
+
ctx.fillStyle = 'rgba(140,134,126,0.55)';
|
| 339 |
+
ctx.fillRect(x * cs, y * cs, cs, cs);
|
| 340 |
+
}
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
/* ── Vision lantern glow around agent ── */
|
| 345 |
+
const apx = agent_x * cs + cs / 2, apy = agent_y * cs + cs / 2;
|
| 346 |
+
const lanternR = cs * 3.5;
|
| 347 |
+
const lanternGr = ctx.createRadialGradient(apx, apy, 0, apx, apy, lanternR);
|
| 348 |
+
lanternGr.addColorStop(0, 'rgba(255,240,190,0.18)');
|
| 349 |
+
lanternGr.addColorStop(0.5, 'rgba(255,220,140,0.08)');
|
| 350 |
+
lanternGr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 351 |
+
ctx.fillStyle = lanternGr;
|
| 352 |
+
ctx.fillRect(apx - lanternR, apy - lanternR, lanternR * 2, lanternR * 2);
|
| 353 |
+
|
| 354 |
+
/* ── Agent trail ── */
|
| 355 |
+
const now = timeRef.current;
|
| 356 |
+
if (
|
| 357 |
+
trailRef.current.length === 0 ||
|
| 358 |
+
Math.abs(trailRef.current[0].x - apx) > 1 ||
|
| 359 |
+
Math.abs(trailRef.current[0].y - apy) > 1
|
| 360 |
+
) {
|
| 361 |
+
trailRef.current.unshift({ x: apx, y: apy, t: now });
|
| 362 |
+
}
|
| 363 |
+
if (trailRef.current.length > 20) trailRef.current.pop();
|
| 364 |
+
|
| 365 |
+
trailRef.current.forEach((p, i) => {
|
| 366 |
+
const alpha = (1 - i / trailRef.current.length) * 0.70;
|
| 367 |
+
ctx.fillStyle = `rgba(2,132,199,${alpha})`;
|
| 368 |
+
ctx.beginPath();
|
| 369 |
+
ctx.arc(p.x, p.y, cs * 0.12 * (1 - i / 22), 0, Math.PI * 2);
|
| 370 |
+
ctx.fill();
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
/* ── Agent rendering ── */
|
| 374 |
+
const theme =
|
| 375 |
+
agent_health >= 60 ? AGENT_THEMES.healthy :
|
| 376 |
+
agent_health >= 30 ? AGENT_THEMES.moderate :
|
| 377 |
+
agent_health > 0 ? AGENT_THEMES.low :
|
| 378 |
+
AGENT_THEMES.critical;
|
| 379 |
+
|
| 380 |
+
const pulse = 0.85 + 0.15 * Math.sin(t * 4);
|
| 381 |
+
const ringR = cs * 0.48;
|
| 382 |
+
|
| 383 |
+
/* pulsing gold aura */
|
| 384 |
+
const auraR = ringR * (1.5 + 0.2 * pulse);
|
| 385 |
+
const auraGr = ctx.createRadialGradient(apx, apy, ringR * 0.7, apx, apy, auraR);
|
| 386 |
+
auraGr.addColorStop(0, `rgba(251,191,36,${0.28 * pulse})`);
|
| 387 |
+
auraGr.addColorStop(1, 'rgba(251,191,36,0)');
|
| 388 |
+
ctx.fillStyle = auraGr;
|
| 389 |
+
ctx.beginPath(); ctx.arc(apx, apy, auraR, 0, Math.PI * 2); ctx.fill();
|
| 390 |
+
|
| 391 |
+
/* ground shadow */
|
| 392 |
+
ctx.save();
|
| 393 |
+
ctx.globalAlpha = 0.22;
|
| 394 |
+
const shadowGr = ctx.createRadialGradient(apx, apy + cs * 0.32, 0, apx, apy + cs * 0.32, cs * 0.38);
|
| 395 |
+
shadowGr.addColorStop(0, 'rgba(0,0,0,0.6)');
|
| 396 |
+
shadowGr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 397 |
+
ctx.fillStyle = shadowGr;
|
| 398 |
+
ctx.beginPath(); ctx.ellipse(apx, apy + cs * 0.32, cs * 0.38, cs * 0.14, 0, 0, Math.PI * 2); ctx.fill();
|
| 399 |
+
ctx.restore();
|
| 400 |
+
|
| 401 |
+
/* Minecraft character */
|
| 402 |
+
drawMinecraftAgent(ctx, apx, apy, cs, theme);
|
| 403 |
+
|
| 404 |
+
/* health arc ring — gold ring + colored fill */
|
| 405 |
+
const hRatio = Math.max(0, Math.min(1, agent_health / 100));
|
| 406 |
+
/* ring track */
|
| 407 |
+
ctx.beginPath();
|
| 408 |
+
ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
|
| 409 |
+
ctx.strokeStyle = 'rgba(0,0,0,0.12)';
|
| 410 |
+
ctx.lineWidth = 4;
|
| 411 |
+
ctx.lineCap = 'round';
|
| 412 |
+
ctx.stroke();
|
| 413 |
+
/* gold base ring */
|
| 414 |
+
ctx.beginPath();
|
| 415 |
+
ctx.arc(apx, apy, ringR, 0, Math.PI * 2);
|
| 416 |
+
ctx.strokeStyle = 'rgba(251,191,36,0.25)';
|
| 417 |
+
ctx.lineWidth = 3.5;
|
| 418 |
+
ctx.stroke();
|
| 419 |
+
/* health fill */
|
| 420 |
+
ctx.beginPath();
|
| 421 |
+
ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
|
| 422 |
+
ctx.strokeStyle = theme.ring;
|
| 423 |
+
ctx.lineWidth = 3.5;
|
| 424 |
+
ctx.lineCap = 'round';
|
| 425 |
+
ctx.stroke();
|
| 426 |
+
/* ring glow */
|
| 427 |
+
ctx.beginPath();
|
| 428 |
+
ctx.arc(apx, apy, ringR, -Math.PI / 2, -Math.PI / 2 + hRatio * Math.PI * 2);
|
| 429 |
+
ctx.strokeStyle = theme.ringGlow;
|
| 430 |
+
ctx.lineWidth = 6;
|
| 431 |
+
ctx.stroke();
|
| 432 |
+
|
| 433 |
+
/* move flash */
|
| 434 |
+
if (agentMoveFlash > 0) {
|
| 435 |
+
const fa = agentMoveFlash / 18;
|
| 436 |
+
ctx.strokeStyle = `rgba(255,255,255,${fa * 0.8})`;
|
| 437 |
+
ctx.lineWidth = 3;
|
| 438 |
+
ctx.beginPath();
|
| 439 |
+
ctx.arc(apx, apy, ringR * (1.8 + (1 - fa) * 0.6), 0, Math.PI * 2);
|
| 440 |
+
ctx.stroke();
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/* ── Embers ── */
|
| 444 |
+
for (let i = embersRef.current.length - 1; i >= 0; i--) {
|
| 445 |
+
const e = embersRef.current[i];
|
| 446 |
+
e.update();
|
| 447 |
+
if (e.life <= 0) { embersRef.current.splice(i, 1); continue; }
|
| 448 |
+
ctx.fillStyle = `rgba(255,${Math.floor(80 + 175 * e.life)},0,${e.life})`;
|
| 449 |
+
ctx.beginPath(); ctx.arc(e.x, e.y, e.size * e.life, 0, Math.PI * 2); ctx.fill();
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
rafRef.current = requestAnimationFrame(animate);
|
| 453 |
+
};
|
| 454 |
|
| 455 |
+
useEffect(() => {
|
| 456 |
+
rafRef.current = requestAnimationFrame(animate);
|
| 457 |
+
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
| 458 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
}, [observation, agentMoveFlash]);
|
| 460 |
|
| 461 |
+
const W = observation?.map_state.grid_w ?? 16;
|
| 462 |
+
const H = observation?.map_state.grid_h ?? 16;
|
| 463 |
|
| 464 |
return (
|
| 465 |
+
<canvas
|
| 466 |
+
ref={canvasRef}
|
| 467 |
+
id="map-canvas"
|
| 468 |
+
width={W * CS}
|
| 469 |
+
height={H * CS}
|
| 470 |
/>
|
| 471 |
);
|
| 472 |
};
|
frontend_new_desgin/index.html
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
| 6 |
+
<title>Pyre — Crisis Navigation Environment</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* Surface */
|
| 14 |
+
--s0: #f5f2ed; /* page bg — warm off-white */
|
| 15 |
+
--s1: #ffffff; /* panel / card */
|
| 16 |
+
--s2: #faf9f6; /* subtle inset */
|
| 17 |
+
--s3: #f0ede8; /* hover */
|
| 18 |
+
--bd: #e4dfd7; /* border */
|
| 19 |
+
--bd2:#cdc7be; /* strong border */
|
| 20 |
+
|
| 21 |
+
/* Text */
|
| 22 |
+
--t1: #1c1712; /* primary */
|
| 23 |
+
--t2: #6b6460; /* secondary */
|
| 24 |
+
--t3: #a8a29e; /* muted */
|
| 25 |
+
|
| 26 |
+
/* Semantic */
|
| 27 |
+
--fire: #c2410c; /* deep ember */
|
| 28 |
+
--fire2: rgba(194,65,12,0.08);
|
| 29 |
+
--blue: #1d4ed8;
|
| 30 |
+
--blue2: rgba(29,78,216,0.08);
|
| 31 |
+
--green: #166534;
|
| 32 |
+
--green2:rgba(22,101,52,0.08);
|
| 33 |
+
--amber: #92400e;
|
| 34 |
+
--amber2:rgba(146,64,14,0.08);
|
| 35 |
+
--red: #b91c1c;
|
| 36 |
+
--red2: rgba(185,28,28,0.08);
|
| 37 |
+
|
| 38 |
+
--mono: 'DM Mono', 'JetBrains Mono', monospace;
|
| 39 |
+
--sans: 'DM Sans', 'Inter', system-ui, sans-serif;
|
| 40 |
+
--r: 6px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
html, body { height: 100%; background: var(--s0); color: var(--t1); font-family: var(--sans); overflow: hidden; font-size: 13px; }
|
| 44 |
+
|
| 45 |
+
/* ── Shell ── */
|
| 46 |
+
.shell {
|
| 47 |
+
display: grid;
|
| 48 |
+
grid-template-rows: 44px 1fr 48px;
|
| 49 |
+
height: 100vh;
|
| 50 |
+
min-width: 1280px;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* ── Topbar ── */
|
| 54 |
+
.topbar {
|
| 55 |
+
background: var(--s1);
|
| 56 |
+
border-bottom: 1px solid var(--bd);
|
| 57 |
+
display: flex; align-items: center;
|
| 58 |
+
padding: 0 20px; gap: 16px;
|
| 59 |
+
}
|
| 60 |
+
.brand { display: flex; align-items: center; gap: 9px; }
|
| 61 |
+
.brand-icon {
|
| 62 |
+
width: 28px; height: 28px; border-radius: 6px;
|
| 63 |
+
background: var(--fire); display: flex; align-items: center; justify-content: center;
|
| 64 |
+
font-size: 14px; flex-shrink: 0;
|
| 65 |
+
}
|
| 66 |
+
.brand-name { font-family: var(--mono); font-size: 14px; font-weight: 500; color: var(--t1); letter-spacing: 0.05em; }
|
| 67 |
+
.brand-sep { width: 1px; height: 18px; background: var(--bd); }
|
| 68 |
+
.brand-sub { font-size: 11px; color: var(--t3); }
|
| 69 |
+
|
| 70 |
+
.topbar-divider { width: 1px; height: 20px; background: var(--bd); }
|
| 71 |
+
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
| 72 |
+
|
| 73 |
+
/* Segment controls */
|
| 74 |
+
.seg {
|
| 75 |
+
display: flex; border: 1px solid var(--bd); border-radius: 6px; overflow: hidden;
|
| 76 |
+
background: var(--s2);
|
| 77 |
+
}
|
| 78 |
+
.seg button {
|
| 79 |
+
background: none; border: none; padding: 5px 11px;
|
| 80 |
+
font-family: var(--mono); font-size: 11px; font-weight: 500;
|
| 81 |
+
color: var(--t2); cursor: pointer; letter-spacing: 0.03em;
|
| 82 |
+
transition: background .1s, color .1s;
|
| 83 |
+
}
|
| 84 |
+
.seg button:hover { background: var(--s3); color: var(--t1); }
|
| 85 |
+
.seg button.on { background: var(--s1); color: var(--t1); box-shadow: inset 0 1px 0 rgba(255,255,255,.8); }
|
| 86 |
+
.seg button.on.fire { color: var(--fire); }
|
| 87 |
+
.seg button + button { border-left: 1px solid var(--bd); }
|
| 88 |
+
|
| 89 |
+
.live-chip {
|
| 90 |
+
font-family: var(--mono); font-size: 10px; font-weight: 500;
|
| 91 |
+
padding: 3px 10px; border-radius: 12px; border: 1px solid;
|
| 92 |
+
letter-spacing: 0.05em;
|
| 93 |
+
}
|
| 94 |
+
.live-chip.demo { border-color: var(--bd2); color: var(--t2); }
|
| 95 |
+
.live-chip.online { border-color: rgba(22,101,52,.4); color: var(--green); background: var(--green2); }
|
| 96 |
+
.live-chip.offline{ border-color: rgba(185,28,28,.4); color: var(--red); }
|
| 97 |
+
|
| 98 |
+
/* ── Content ── */
|
| 99 |
+
.content { display: grid; grid-template-columns: 1fr 320px; overflow: hidden; min-height: 0; }
|
| 100 |
+
|
| 101 |
+
/* ── Canvas zone ── */
|
| 102 |
+
.canvas-zone {
|
| 103 |
+
display: flex; align-items: center; justify-content: center;
|
| 104 |
+
background: var(--s0); padding: 20px; position: relative;
|
| 105 |
+
overflow: hidden;
|
| 106 |
+
}
|
| 107 |
+
.canvas-frame {
|
| 108 |
+
position: relative;
|
| 109 |
+
border-radius: 8px; overflow: hidden;
|
| 110 |
+
box-shadow: 0 0 0 1px var(--bd2), 0 20px 60px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.10);
|
| 111 |
+
/* Scale to fill available space while keeping aspect ratio */
|
| 112 |
+
max-width: min(calc(100vh - 92px - 48px - 40px), calc(100vw - 340px - 40px));
|
| 113 |
+
max-height: calc(100vh - 92px);
|
| 114 |
+
aspect-ratio: 1;
|
| 115 |
+
}
|
| 116 |
+
#mainCanvas {
|
| 117 |
+
display: block;
|
| 118 |
+
width: 100%;
|
| 119 |
+
height: 100%;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* HUD overlays — float above canvas */
|
| 123 |
+
.hud-tl, .hud-tr {
|
| 124 |
+
position: absolute; top: 12px;
|
| 125 |
+
pointer-events: none; z-index: 2;
|
| 126 |
+
}
|
| 127 |
+
.hud-tl { left: 12px; }
|
| 128 |
+
.hud-tr { right: 12px; }
|
| 129 |
+
|
| 130 |
+
.hud-card {
|
| 131 |
+
background: rgba(14,10,8,0.82);
|
| 132 |
+
border: 1px solid rgba(255,255,255,0.10);
|
| 133 |
+
border-radius: 6px; padding: 8px 11px;
|
| 134 |
+
backdrop-filter: blur(8px);
|
| 135 |
+
color: #e8e0d8;
|
| 136 |
+
}
|
| 137 |
+
.hud-r { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
|
| 138 |
+
.hud-r:last-child { margin-bottom: 0; }
|
| 139 |
+
.hlbl { font-family: var(--mono); font-size: 9px; color: rgba(168,162,158,0.8); letter-spacing: 0.1em; }
|
| 140 |
+
.hval { font-family: var(--mono); font-size: 12px; font-weight: 500; color: #f0e8e0; }
|
| 141 |
+
.hbar-bg { width: 80px; height: 4px; background: rgba(255,255,255,0.08); border-radius: 2px; overflow: hidden; }
|
| 142 |
+
.hbar-fill { height: 100%; border-radius: 2px; transition: width .4s, background .4s; }
|
| 143 |
+
.hbar-fill.g { background: #4ade80; }
|
| 144 |
+
.hbar-fill.m { background: #fbbf24; }
|
| 145 |
+
.hbar-fill.c { background: #f87171; animation: hblink .5s infinite alternate; }
|
| 146 |
+
@keyframes hblink { to { opacity: .3 } }
|
| 147 |
+
.hstatus { font-family: var(--mono); font-size: 9px; }
|
| 148 |
+
.hstatus.good { color: #4ade80; }
|
| 149 |
+
.hstatus.moderate { color: #fbbf24; }
|
| 150 |
+
.hstatus.low { color: #fb923c; }
|
| 151 |
+
.hstatus.critical { color: #f87171; }
|
| 152 |
+
|
| 153 |
+
.step-val { font-family: var(--mono); font-size: 12px; font-weight: 500; color: #93c5fd; }
|
| 154 |
+
.sbar-bg { width: 90px; height: 3px; background: rgba(255,255,255,0.07); border-radius:2px; overflow:hidden; margin-top:5px; }
|
| 155 |
+
.sbar-fill { height:100%; border-radius:2px; background:#60a5fa; transition:width .4s; }
|
| 156 |
+
.step-meta { font-family:var(--mono); font-size:9px; color:rgba(168,162,158,.6); margin-top:3px; }
|
| 157 |
+
|
| 158 |
+
/* Episode end */
|
| 159 |
+
#epBanner {
|
| 160 |
+
display: none; position: absolute;
|
| 161 |
+
bottom: 14px; left: 50%; transform: translateX(-50%);
|
| 162 |
+
font-family: var(--mono); font-size: 13px; font-weight: 500;
|
| 163 |
+
letter-spacing: 0.04em; padding: 10px 20px;
|
| 164 |
+
border-radius: 8px; backdrop-filter: blur(10px);
|
| 165 |
+
pointer-events: none; white-space: nowrap;
|
| 166 |
+
}
|
| 167 |
+
#epBanner.ok { background:rgba(21,128,61,0.80); border:1px solid rgba(74,222,128,.3); color:#dcfce7; }
|
| 168 |
+
#epBanner.bad { background:rgba(153,27,27,0.80); border:1px solid rgba(248,113,113,.3); color:#fee2e2; }
|
| 169 |
+
#epBanner.tmo { background:rgba(120,53,15,0.80); border:1px solid rgba(251,191,36,.3); color:#fef3c7; }
|
| 170 |
+
|
| 171 |
+
/* Wind */
|
| 172 |
+
.wind-wrap {
|
| 173 |
+
position: absolute; bottom: 10px; right: 10px;
|
| 174 |
+
background: rgba(14,10,8,0.80); border: 1px solid rgba(255,255,255,0.10);
|
| 175 |
+
border-radius: 50%; backdrop-filter: blur(6px);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
/* ── Side panel ── */
|
| 179 |
+
.side {
|
| 180 |
+
background: var(--s1);
|
| 181 |
+
border-left: 1px solid var(--bd);
|
| 182 |
+
overflow-y: auto; overflow-x: hidden;
|
| 183 |
+
scrollbar-width: thin; scrollbar-color: var(--bd) transparent;
|
| 184 |
+
display: flex; flex-direction: column;
|
| 185 |
+
}
|
| 186 |
+
::-webkit-scrollbar { width: 3px; }
|
| 187 |
+
::-webkit-scrollbar-thumb { background: var(--bd); border-radius: 2px; }
|
| 188 |
+
|
| 189 |
+
.side-sec { padding: 14px 16px; border-bottom: 1px solid var(--bd); }
|
| 190 |
+
.side-sec:last-child { border-bottom: none; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
| 191 |
+
.sec-hd {
|
| 192 |
+
font-family: var(--mono); font-size: 9px; font-weight: 500;
|
| 193 |
+
letter-spacing: 0.12em; text-transform: uppercase; color: var(--t3);
|
| 194 |
+
margin-bottom: 10px; display: flex; align-items: center; justify-content: space-between;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/* Stats */
|
| 198 |
+
.sg { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
| 199 |
+
.sc {
|
| 200 |
+
background: var(--s2); border: 1px solid var(--bd);
|
| 201 |
+
border-radius: var(--r); padding: 8px 10px;
|
| 202 |
+
}
|
| 203 |
+
.sc-l { font-size: 10px; color: var(--t3); margin-bottom: 2px; }
|
| 204 |
+
.sc-v { font-family: var(--mono); font-size: 14px; font-weight: 500; color: var(--t1); }
|
| 205 |
+
.sc-v.fire { color: var(--fire); }
|
| 206 |
+
.sc-v.blue { color: var(--blue); }
|
| 207 |
+
.sc-v.amber { color: var(--amber); }
|
| 208 |
+
|
| 209 |
+
.meta-row {
|
| 210 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 211 |
+
margin-top: 8px; padding-top: 9px; border-top: 1px solid var(--bd);
|
| 212 |
+
font-size: 11px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
/* Charts */
|
| 216 |
+
.chart-bg {
|
| 217 |
+
background: var(--s2); border: 1px solid var(--bd);
|
| 218 |
+
border-radius: var(--r); overflow: hidden; margin-top: 6px;
|
| 219 |
+
}
|
| 220 |
+
.chart-bg canvas { display: block; width: 100% !important; }
|
| 221 |
+
|
| 222 |
+
/* Event log */
|
| 223 |
+
.elog { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; flex: 1; }
|
| 224 |
+
.erow {
|
| 225 |
+
display: flex; align-items: baseline; gap: 7px;
|
| 226 |
+
padding: 3px 6px; border-radius: 4px;
|
| 227 |
+
font-family: var(--mono); font-size: 10px;
|
| 228 |
+
border-left: 2px solid transparent;
|
| 229 |
+
animation: ein .15s ease;
|
| 230 |
+
}
|
| 231 |
+
@keyframes ein { from { opacity:0; transform:translateY(-2px) } }
|
| 232 |
+
.erow:hover { background: var(--s3); }
|
| 233 |
+
.erow.alarm { border-left-color: var(--fire); background: var(--fire2); }
|
| 234 |
+
.estep { color: var(--t3); min-width: 22px; font-size: 9px; }
|
| 235 |
+
.etext { color: var(--t2); flex: 1; line-height: 1.4; }
|
| 236 |
+
.erwd { font-size: 9px; min-width: 32px; text-align: right; }
|
| 237 |
+
.erwd.p { color: var(--green); }
|
| 238 |
+
.erwd.n { color: var(--red); }
|
| 239 |
+
|
| 240 |
+
/* ── Bottom bar ── */
|
| 241 |
+
.botbar {
|
| 242 |
+
background: var(--s1); border-top: 1px solid var(--bd);
|
| 243 |
+
display: flex; align-items: center; padding: 0 16px; gap: 6px;
|
| 244 |
+
}
|
| 245 |
+
.bsep { width: 1px; height: 20px; background: var(--bd); margin: 0 4px; }
|
| 246 |
+
|
| 247 |
+
.icn-btn {
|
| 248 |
+
width: 30px; height: 30px;
|
| 249 |
+
display: flex; align-items: center; justify-content: center;
|
| 250 |
+
background: var(--s2); border: 1px solid var(--bd);
|
| 251 |
+
border-radius: var(--r); color: var(--t2);
|
| 252 |
+
font-size: 12px; cursor: pointer;
|
| 253 |
+
transition: background .1s, color .1s, border-color .1s;
|
| 254 |
+
}
|
| 255 |
+
.icn-btn:hover { background: var(--s3); color: var(--t1); border-color: var(--bd2); }
|
| 256 |
+
.icn-btn:active { transform: scale(0.94); }
|
| 257 |
+
.icn-btn.play {
|
| 258 |
+
background: var(--fire2); border-color: rgba(194,65,12,0.3); color: var(--fire);
|
| 259 |
+
}
|
| 260 |
+
.icn-btn.play:hover { background: rgba(194,65,12,0.14); }
|
| 261 |
+
|
| 262 |
+
.seed-wrap { display: flex; align-items: center; gap: 6px; }
|
| 263 |
+
.seed-lbl { font-size: 11px; color: var(--t3); }
|
| 264 |
+
#seedInput {
|
| 265 |
+
width: 64px; background: var(--s2); border: 1px solid var(--bd);
|
| 266 |
+
color: var(--t1); font-family: var(--mono); font-size: 11px;
|
| 267 |
+
padding: 4px 8px; border-radius: var(--r); outline: none;
|
| 268 |
+
}
|
| 269 |
+
#seedInput:focus { border-color: var(--bd2); }
|
| 270 |
+
|
| 271 |
+
.keys {
|
| 272 |
+
margin-left: auto; display: flex; gap: 12px; align-items: center;
|
| 273 |
+
font-size: 10px; color: var(--t3);
|
| 274 |
+
}
|
| 275 |
+
.keys kbd {
|
| 276 |
+
background: var(--s2); border: 1px solid var(--bd2);
|
| 277 |
+
border-radius: 3px; padding: 1px 5px;
|
| 278 |
+
font-family: var(--mono); font-size: 10px; color: var(--t2);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/* Toast */
|
| 282 |
+
#toast {
|
| 283 |
+
position: fixed; bottom: 56px; left: 50%;
|
| 284 |
+
transform: translateX(-50%) translateY(6px);
|
| 285 |
+
background: var(--t1); color: var(--s0);
|
| 286 |
+
font-family: var(--mono); font-size: 11px;
|
| 287 |
+
padding: 7px 16px; border-radius: 20px;
|
| 288 |
+
opacity: 0; pointer-events: none; z-index: 99;
|
| 289 |
+
transition: opacity .22s, transform .22s;
|
| 290 |
+
}
|
| 291 |
+
#toast.up { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 292 |
+
</style>
|
| 293 |
+
</head>
|
| 294 |
+
<body>
|
| 295 |
+
<div class="shell">
|
| 296 |
+
|
| 297 |
+
<!-- Topbar -->
|
| 298 |
+
<header class="topbar">
|
| 299 |
+
<div class="brand">
|
| 300 |
+
<div class="brand-icon">🔥</div>
|
| 301 |
+
<span class="brand-name">Pyre</span>
|
| 302 |
+
<div class="brand-sep"></div>
|
| 303 |
+
<span class="brand-sub">Crisis Navigation Environment</span>
|
| 304 |
+
</div>
|
| 305 |
+
|
| 306 |
+
<div class="topbar-divider"></div>
|
| 307 |
+
|
| 308 |
+
<div class="seg">
|
| 309 |
+
<button class="mode-btn on fire" data-mode="demo">Demo</button>
|
| 310 |
+
<button class="mode-btn" data-mode="live">Live</button>
|
| 311 |
+
</div>
|
| 312 |
+
|
| 313 |
+
<div class="seg">
|
| 314 |
+
<button class="diff-btn" data-diff="easy">Easy</button>
|
| 315 |
+
<button class="diff-btn on" data-diff="medium">Medium</button>
|
| 316 |
+
<button class="diff-btn" data-diff="hard">Hard</button>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<div class="topbar-right">
|
| 320 |
+
<span id="liveIndicator" class="live-chip demo">Demo</span>
|
| 321 |
+
</div>
|
| 322 |
+
</header>
|
| 323 |
+
|
| 324 |
+
<!-- Content -->
|
| 325 |
+
<div class="content">
|
| 326 |
+
|
| 327 |
+
<!-- Canvas zone -->
|
| 328 |
+
<div class="canvas-zone">
|
| 329 |
+
<div class="canvas-frame">
|
| 330 |
+
<canvas id="mainCanvas" width="576" height="576"></canvas>
|
| 331 |
+
|
| 332 |
+
<div class="hud-tl">
|
| 333 |
+
<div class="hud-card">
|
| 334 |
+
<div class="hud-r">
|
| 335 |
+
<span class="hlbl">HP</span>
|
| 336 |
+
<div class="hbar-bg"><div id="healthBar" class="hbar-fill g" style="width:100%"></div></div>
|
| 337 |
+
<span id="healthVal" class="hval">100</span>
|
| 338 |
+
</div>
|
| 339 |
+
<div class="hud-r" style="gap:10px">
|
| 340 |
+
<span id="healthStatus" class="hstatus good">Good</span>
|
| 341 |
+
<span id="smokeTag" style="font-family:var(--mono);font-size:9px;color:rgba(168,162,158,.6);margin-left:auto">smoke: clear</span>
|
| 342 |
+
</div>
|
| 343 |
+
</div>
|
| 344 |
+
</div>
|
| 345 |
+
|
| 346 |
+
<div class="hud-tr">
|
| 347 |
+
<div class="hud-card" style="text-align:right">
|
| 348 |
+
<div class="hud-r" style="justify-content:flex-end">
|
| 349 |
+
<span class="step-val" id="stepCount">0 / 150</span>
|
| 350 |
+
</div>
|
| 351 |
+
<div class="sbar-bg" style="margin-left:auto">
|
| 352 |
+
<div id="stepBar" class="sbar-fill" style="width:0%"></div>
|
| 353 |
+
</div>
|
| 354 |
+
<div class="step-meta" id="diffTag">medium · small_office</div>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
<div id="epBanner"></div>
|
| 359 |
+
|
| 360 |
+
<div class="wind-wrap">
|
| 361 |
+
<canvas id="windCanvas" width="68" height="68"></canvas>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
|
| 366 |
+
<!-- Side -->
|
| 367 |
+
<aside class="side">
|
| 368 |
+
|
| 369 |
+
<div class="side-sec">
|
| 370 |
+
<div class="sec-hd">Episode</div>
|
| 371 |
+
<div class="sg">
|
| 372 |
+
<div class="sc"><div class="sc-l">Reward</div><div id="rewardVal" class="sc-v blue">0.00</div></div>
|
| 373 |
+
<div class="sc"><div class="sc-l">Burned</div><div id="burnPct" class="sc-v fire">0%</div></div>
|
| 374 |
+
<div class="sc"><div class="sc-l">Wind</div><div id="windVal" class="sc-v amber">—</div></div>
|
| 375 |
+
<div class="sc"><div class="sc-l">Humidity</div><div id="humidVal" class="sc-v">—%</div></div>
|
| 376 |
+
</div>
|
| 377 |
+
<div class="meta-row">
|
| 378 |
+
<span style="color:var(--t3)">spread ρ</span>
|
| 379 |
+
<span id="spreadVal" style="color:var(--fire);font-family:var(--mono)">0.25</span>
|
| 380 |
+
</div>
|
| 381 |
+
</div>
|
| 382 |
+
|
| 383 |
+
<div class="side-sec">
|
| 384 |
+
<div class="sec-hd">
|
| 385 |
+
Cumulative Reward
|
| 386 |
+
<span id="rewardLast" style="color:var(--t2);font-weight:400;letter-spacing:0;font-size:10px">—</span>
|
| 387 |
+
</div>
|
| 388 |
+
<div class="chart-bg" style="height:52px"><canvas id="rewardChart" height="52"></canvas></div>
|
| 389 |
+
</div>
|
| 390 |
+
|
| 391 |
+
<div class="side-sec">
|
| 392 |
+
<div class="sec-hd">
|
| 393 |
+
Active Fire Cells
|
| 394 |
+
<span id="fireLast" style="color:var(--t2);font-weight:400;letter-spacing:0;font-size:10px">0</span>
|
| 395 |
+
</div>
|
| 396 |
+
<div class="chart-bg" style="height:46px"><canvas id="fireSizeChart" height="46"></canvas></div>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
<div class="side-sec">
|
| 400 |
+
<div class="sec-hd">Action Distribution</div>
|
| 401 |
+
<div class="chart-bg" style="height:68px"><canvas id="actionChart" height="68"></canvas></div>
|
| 402 |
+
</div>
|
| 403 |
+
|
| 404 |
+
<div class="side-sec">
|
| 405 |
+
<div class="sec-hd">Event Log</div>
|
| 406 |
+
<div id="eventLog" class="elog">
|
| 407 |
+
<div class="erow">
|
| 408 |
+
<span class="estep">000</span>
|
| 409 |
+
<span class="etext">Episode started. Assess your surroundings.</span>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
</div>
|
| 413 |
+
|
| 414 |
+
</aside>
|
| 415 |
+
</div>
|
| 416 |
+
|
| 417 |
+
<!-- Bottom bar -->
|
| 418 |
+
<footer class="botbar">
|
| 419 |
+
<button id="prevBtn" class="icn-btn" title="Prev template">⏮</button>
|
| 420 |
+
<button id="playBtn" class="icn-btn play" title="Play (Space)">▶</button>
|
| 421 |
+
<button id="stepBtn" class="icn-btn" title="Step (→)">⏭</button>
|
| 422 |
+
<button id="resetBtn" class="icn-btn" title="Reset (R)">↺</button>
|
| 423 |
+
<div class="bsep"></div>
|
| 424 |
+
<div class="seg">
|
| 425 |
+
<button class="speed-btn" data-speed="0.5">½×</button>
|
| 426 |
+
<button class="speed-btn on" data-speed="1">1×</button>
|
| 427 |
+
<button class="speed-btn" data-speed="2">2×</button>
|
| 428 |
+
<button class="speed-btn" data-speed="4">4×</button>
|
| 429 |
+
<button class="speed-btn" data-speed="8">8×</button>
|
| 430 |
+
</div>
|
| 431 |
+
<div class="bsep"></div>
|
| 432 |
+
<div class="seed-wrap">
|
| 433 |
+
<span class="seed-lbl">Seed</span>
|
| 434 |
+
<input id="seedInput" type="number" value="42" min="0" max="9999">
|
| 435 |
+
</div>
|
| 436 |
+
<div class="keys">
|
| 437 |
+
<span><kbd>Space</kbd> play</span>
|
| 438 |
+
<span><kbd>→</kbd> step</span>
|
| 439 |
+
<span><kbd>R</kbd> reset</span>
|
| 440 |
+
</div>
|
| 441 |
+
</footer>
|
| 442 |
+
</div>
|
| 443 |
+
|
| 444 |
+
<div id="toast"></div>
|
| 445 |
+
<script src="js/sim.js?v=3"></script>
|
| 446 |
+
<script src="js/renderer.js?v=4"></script>
|
| 447 |
+
<script src="js/app.js?v=6"></script>
|
| 448 |
+
</body>
|
| 449 |
+
</html>
|
frontend_new_desgin/js/app.js
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* app.js — Pyre visualization controller
|
| 3 |
+
* Handles episode lifecycle, HUD updates, charts, live/demo modes.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// ─── Mini chart renderer ─────────────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
class MiniChart {
|
| 9 |
+
constructor(canvas, opts={}) {
|
| 10 |
+
this.canvas = canvas;
|
| 11 |
+
this.ctx = canvas.getContext('2d');
|
| 12 |
+
this.color = opts.color || '#00e5ff';
|
| 13 |
+
this.fill = opts.fill || 'rgba(0,229,255,0.12)';
|
| 14 |
+
this.label = opts.label || '';
|
| 15 |
+
this.maxPoints = opts.maxPoints || 200;
|
| 16 |
+
this.yMin = opts.yMin !== undefined ? opts.yMin : null;
|
| 17 |
+
this.yMax = opts.yMax !== undefined ? opts.yMax : null;
|
| 18 |
+
this.data = [];
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
push(v) {
|
| 22 |
+
this.data.push(v);
|
| 23 |
+
if (this.data.length > this.maxPoints) this.data.shift();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
draw() {
|
| 27 |
+
const {canvas, ctx, data, color, fill} = this;
|
| 28 |
+
const w=canvas.width, h=canvas.height;
|
| 29 |
+
ctx.fillStyle='#faf9f6'; ctx.fillRect(0,0,w,h);
|
| 30 |
+
if (data.length < 2) return;
|
| 31 |
+
|
| 32 |
+
const yMin = this.yMin !== null ? this.yMin : Math.min(...data);
|
| 33 |
+
const yMax = this.yMax !== null ? this.yMax : Math.max(...data);
|
| 34 |
+
const yRange = (yMax-yMin) || 1;
|
| 35 |
+
const pad = 4;
|
| 36 |
+
|
| 37 |
+
// Grid lines
|
| 38 |
+
ctx.strokeStyle='rgba(0,0,0,0.06)';
|
| 39 |
+
ctx.lineWidth=0.5;
|
| 40 |
+
for (let i=0;i<=3;i++) {
|
| 41 |
+
const gy=pad+(h-2*pad)*(i/3);
|
| 42 |
+
ctx.beginPath(); ctx.moveTo(0,gy); ctx.lineTo(w,gy); ctx.stroke();
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const toX = i => (i/(data.length-1))*w;
|
| 46 |
+
const toY = v => h-pad - ((v-yMin)/yRange)*(h-2*pad);
|
| 47 |
+
|
| 48 |
+
// Fill area
|
| 49 |
+
ctx.beginPath();
|
| 50 |
+
ctx.moveTo(toX(0), h);
|
| 51 |
+
for (let i=0;i<data.length;i++) ctx.lineTo(toX(i), toY(data[i]));
|
| 52 |
+
ctx.lineTo(toX(data.length-1), h);
|
| 53 |
+
ctx.closePath();
|
| 54 |
+
ctx.fillStyle=fill;
|
| 55 |
+
ctx.fill();
|
| 56 |
+
|
| 57 |
+
// Line
|
| 58 |
+
ctx.beginPath();
|
| 59 |
+
ctx.moveTo(toX(0), toY(data[0]));
|
| 60 |
+
for (let i=1;i<data.length;i++) ctx.lineTo(toX(i), toY(data[i]));
|
| 61 |
+
ctx.strokeStyle=color;
|
| 62 |
+
ctx.lineWidth=1.8;
|
| 63 |
+
ctx.stroke();
|
| 64 |
+
|
| 65 |
+
// Current value dot
|
| 66 |
+
const lastX=toX(data.length-1), lastY=toY(data[data.length-1]);
|
| 67 |
+
ctx.beginPath();
|
| 68 |
+
ctx.arc(lastX, lastY, 3, 0, Math.PI*2);
|
| 69 |
+
ctx.fillStyle=color;
|
| 70 |
+
ctx.fill();
|
| 71 |
+
|
| 72 |
+
// Current value label
|
| 73 |
+
ctx.fillStyle=color;
|
| 74 |
+
ctx.font='10px monospace';
|
| 75 |
+
ctx.textAlign='right';
|
| 76 |
+
ctx.fillText(data[data.length-1].toFixed(1), w-2, lastY-4);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ─── Action histogram ────────────────────────────────────────────────────────
|
| 81 |
+
|
| 82 |
+
function drawActionHistogram(canvas, counts) {
|
| 83 |
+
const ctx = canvas.getContext('2d');
|
| 84 |
+
const w=canvas.width, h=canvas.height;
|
| 85 |
+
ctx.fillStyle='#faf9f6'; ctx.fillRect(0,0,w,h);
|
| 86 |
+
|
| 87 |
+
const entries = Object.entries(counts);
|
| 88 |
+
const total = entries.reduce((s,[,v])=>s+v,0)||1;
|
| 89 |
+
const colors = {move:'#1d4ed8', door:'#c2410c', wait:'#7c3aed', look:'#166534'};
|
| 90 |
+
const barH = Math.floor((h-8)/entries.length)-2;
|
| 91 |
+
|
| 92 |
+
entries.forEach(([k,v],i) => {
|
| 93 |
+
const ratio = v/total;
|
| 94 |
+
const bw = Math.max(2, ratio*(w-60));
|
| 95 |
+
const by = 4+i*(barH+2);
|
| 96 |
+
// Bar bg
|
| 97 |
+
ctx.fillStyle='rgba(0,0,0,0.05)';
|
| 98 |
+
ctx.fillRect(60,by,w-62,barH);
|
| 99 |
+
// Bar fill
|
| 100 |
+
ctx.fillStyle=colors[k]||'#888';
|
| 101 |
+
ctx.fillRect(60,by,bw,barH);
|
| 102 |
+
// Label
|
| 103 |
+
ctx.fillStyle='rgba(80,70,65,0.85)';
|
| 104 |
+
ctx.font=`${Math.min(11,barH-1)}px monospace`;
|
| 105 |
+
ctx.textAlign='right';
|
| 106 |
+
ctx.fillText(k, 54, by+barH-2);
|
| 107 |
+
// Count
|
| 108 |
+
ctx.fillStyle=colors[k]||'#888';
|
| 109 |
+
ctx.textAlign='left';
|
| 110 |
+
ctx.fillText(v, 64+bw, by+barH-2);
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// ─── Live mode client ────────────────────────────────────────────────────────
|
| 115 |
+
|
| 116 |
+
class LiveClient {
|
| 117 |
+
constructor(baseUrl='https://krooz-pyre-env.hf.space') {
|
| 118 |
+
this.baseUrl=baseUrl;
|
| 119 |
+
this.connected=false;
|
| 120 |
+
}
|
| 121 |
+
async checkHealth() {
|
| 122 |
+
try {
|
| 123 |
+
const r=await fetch(`${this.baseUrl}/web/health`,{signal:AbortSignal.timeout(3000)});
|
| 124 |
+
this.connected=r.ok;
|
| 125 |
+
} catch { this.connected=false; }
|
| 126 |
+
return this.connected;
|
| 127 |
+
}
|
| 128 |
+
async reset(difficulty='medium', seed=null) {
|
| 129 |
+
const body={difficulty};
|
| 130 |
+
if(seed!==null) body.seed=parseInt(seed)||null;
|
| 131 |
+
const r=await fetch(`${this.baseUrl}/web/reset`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
| 132 |
+
return r.json();
|
| 133 |
+
}
|
| 134 |
+
async step(action) {
|
| 135 |
+
const r=await fetch(`${this.baseUrl}/web/step`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(action)});
|
| 136 |
+
return r.json();
|
| 137 |
+
}
|
| 138 |
+
async getScene() {
|
| 139 |
+
const r=await fetch(`${this.baseUrl}/web/scene`);
|
| 140 |
+
return r.json();
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// ─── App controller ───────────────────────────────────────────────────────────
|
| 145 |
+
|
| 146 |
+
class PyreApp {
|
| 147 |
+
constructor() {
|
| 148 |
+
this.canvas = document.getElementById('mainCanvas');
|
| 149 |
+
this.windCanvas = document.getElementById('windCanvas');
|
| 150 |
+
this.renderer = new PyreRenderer(this.canvas);
|
| 151 |
+
this.episode = null;
|
| 152 |
+
this.agent = new DemoAgent();
|
| 153 |
+
this.mode = 'demo'; // 'demo' | 'live'
|
| 154 |
+
this.playing = false;
|
| 155 |
+
this.speed = 1; // steps per second
|
| 156 |
+
this.stepInterval = null;
|
| 157 |
+
this.animFrame = null;
|
| 158 |
+
this.templateIdx = 0;
|
| 159 |
+
this.difficulty = 'medium';
|
| 160 |
+
this.seed = 42;
|
| 161 |
+
this.liveClient = new LiveClient();
|
| 162 |
+
this.liveScene = null;
|
| 163 |
+
|
| 164 |
+
// Charts
|
| 165 |
+
this.rewardChart = new MiniChart(document.getElementById('rewardChart'),{
|
| 166 |
+
color:'#1d4ed8', fill:'rgba(29,78,216,0.08)', yMin:null, yMax:null
|
| 167 |
+
});
|
| 168 |
+
this.fireSizeChart = new MiniChart(document.getElementById('fireSizeChart'),{
|
| 169 |
+
color:'#c2410c', fill:'rgba(194,65,12,0.08)', yMin:0
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
this._bindControls();
|
| 173 |
+
this.newEpisode();
|
| 174 |
+
this._startRenderLoop();
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
newEpisode() {
|
| 178 |
+
this.playing = false;
|
| 179 |
+
this._clearStepInterval();
|
| 180 |
+
const seed = parseInt(document.getElementById('seedInput')?.value) || this.seed;
|
| 181 |
+
this.seed = seed;
|
| 182 |
+
this.episode = new PyreEpisode(this.templateIdx, seed, this.difficulty);
|
| 183 |
+
this.renderer.init(this.episode);
|
| 184 |
+
this.agent = new DemoAgent();
|
| 185 |
+
this._updateHUD();
|
| 186 |
+
this._clearCharts();
|
| 187 |
+
this._updateEventLog();
|
| 188 |
+
this._updatePlayBtn();
|
| 189 |
+
// Auto-play
|
| 190 |
+
setTimeout(()=>{this.playing=true;this._startStepInterval();this._updatePlayBtn();},300);
|
| 191 |
+
this.templateIdx = (this.templateIdx+1) % 3; // cycle templates
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
_clearCharts() {
|
| 195 |
+
this.rewardChart.data = [];
|
| 196 |
+
this.fireSizeChart.data = [];
|
| 197 |
+
this.rewardChart.draw();
|
| 198 |
+
this.fireSizeChart.draw();
|
| 199 |
+
const ac=document.getElementById('actionChart');
|
| 200 |
+
if(ac){const c=ac.getContext('2d');c.clearRect(0,0,ac.width,ac.height);}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
step() {
|
| 204 |
+
if (!this.episode || this.episode.done) { this.playing=false; this._clearStepInterval(); this._updatePlayBtn(); return; }
|
| 205 |
+
const action = this.agent.chooseAction({
|
| 206 |
+
agentX:this.episode.agentX, agentY:this.episode.agentY,
|
| 207 |
+
cellGrid:this.episode.cellGrid, fireGrid:this.episode.fireGrid,
|
| 208 |
+
smokeGrid:this.episode.smokeGrid, exits:this.episode.exits,
|
| 209 |
+
w:this.episode.w, h:this.episode.h, doorRegistry:this.episode.doorRegistry
|
| 210 |
+
});
|
| 211 |
+
this.episode.step(action);
|
| 212 |
+
this._updateHUD();
|
| 213 |
+
this._updateCharts();
|
| 214 |
+
this._updateEventLog();
|
| 215 |
+
|
| 216 |
+
if (this.episode.done) {
|
| 217 |
+
this.playing=false;
|
| 218 |
+
this._clearStepInterval();
|
| 219 |
+
this._updatePlayBtn();
|
| 220 |
+
this._showEpisodeEnd();
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
togglePlay() {
|
| 225 |
+
this.playing = !this.playing;
|
| 226 |
+
if (this.playing) this._startStepInterval();
|
| 227 |
+
else this._clearStepInterval();
|
| 228 |
+
this._updatePlayBtn();
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
setSpeed(s) {
|
| 232 |
+
this.speed = s;
|
| 233 |
+
document.querySelectorAll('.speed-btn').forEach(b=>{
|
| 234 |
+
b.classList.toggle('on', parseFloat(b.dataset.speed)===s);
|
| 235 |
+
});
|
| 236 |
+
if (this.playing) { this._clearStepInterval(); this._startStepInterval(); }
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
_startStepInterval() {
|
| 240 |
+
this._clearStepInterval();
|
| 241 |
+
const ms = Math.max(50, 1000/this.speed);
|
| 242 |
+
this.stepInterval = setInterval(()=>this.step(), ms);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
_clearStepInterval() {
|
| 246 |
+
if (this.stepInterval) { clearInterval(this.stepInterval); this.stepInterval=null; }
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ── HUD updates ────────────────────────────────────────────────────────────
|
| 250 |
+
|
| 251 |
+
_updateHUD() {
|
| 252 |
+
const ep = this.episode;
|
| 253 |
+
if (!ep) return;
|
| 254 |
+
|
| 255 |
+
// Health bar
|
| 256 |
+
const hPct = ep.agentHealth/100;
|
| 257 |
+
const hBar = document.getElementById('healthBar');
|
| 258 |
+
if (hBar) {
|
| 259 |
+
hBar.style.width=`${ep.agentHealth}%`;
|
| 260 |
+
hBar.className='hbar-fill ' + (hPct>0.6?'g':hPct>0.3?'m':'c');
|
| 261 |
+
}
|
| 262 |
+
const hVal = document.getElementById('healthVal');
|
| 263 |
+
if(hVal) hVal.textContent=`${Math.round(ep.agentHealth)}`;
|
| 264 |
+
const hStatus = document.getElementById('healthStatus');
|
| 265 |
+
if(hStatus) { hStatus.textContent=ep.healthStatus; hStatus.className='hstatus '+ep.healthStatus.toLowerCase(); }
|
| 266 |
+
|
| 267 |
+
// Step counter
|
| 268 |
+
const stepEl=document.getElementById('stepCount');
|
| 269 |
+
if(stepEl) stepEl.textContent=`${ep.stepCount} / ${ep.maxSteps}`;
|
| 270 |
+
const progBar=document.getElementById('stepBar');
|
| 271 |
+
if(progBar) progBar.style.width=`${100*ep.stepCount/ep.maxSteps}%`;
|
| 272 |
+
|
| 273 |
+
// Stats
|
| 274 |
+
setText('rewardVal', ep.totalReward.toFixed(2));
|
| 275 |
+
setText('burnPct', `${ep.percentBurned}%`);
|
| 276 |
+
setText('smokeVal', ep.smokeLevel);
|
| 277 |
+
const smokeTag=document.getElementById('smokeTag');
|
| 278 |
+
if(smokeTag) smokeTag.textContent=`smoke: ${ep.smokeLevel}`;
|
| 279 |
+
setText('windVal', ep.windDir);
|
| 280 |
+
setText('humidVal', `${Math.round(ep.humidity*100)}%`);
|
| 281 |
+
setText('spreadVal', ep.pSpread.toFixed(2));
|
| 282 |
+
const diffTag=document.getElementById('diffTag');
|
| 283 |
+
if(diffTag) {
|
| 284 |
+
let dt=`${ep.difficulty} · ${ep.templateName}`;
|
| 285 |
+
if(ep._fireDirection) dt+=` · fire ${ep._fireDirection}`;
|
| 286 |
+
diffTag.textContent=dt;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Episode status banner
|
| 290 |
+
const banner=document.getElementById('epBanner');
|
| 291 |
+
if(banner){
|
| 292 |
+
if(ep.agentEvacuated){ banner.textContent='✓ EVACUATED'; banner.className='ok'; banner.style.display='flex'; }
|
| 293 |
+
else if(!ep.agentAlive){ banner.textContent='✗ INCAPACITATED'; banner.className='bad'; banner.style.display='flex'; }
|
| 294 |
+
else if(ep.stepCount>=ep.maxSteps){ banner.textContent='⏱ TIMEOUT'; banner.className='tmo'; banner.style.display='flex'; }
|
| 295 |
+
else banner.style.display='none';
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Smoke level indicator
|
| 299 |
+
const smokeInd=document.getElementById('smokeIndicator');
|
| 300 |
+
if(smokeInd){ smokeInd.className='smoke-indicator '+ep.smokeLevel; smokeInd.title=`Smoke: ${ep.smokeLevel}`; }
|
| 301 |
+
|
| 302 |
+
// Wind rose
|
| 303 |
+
if(this.windCanvas) this.renderer.drawWindRose(this.windCanvas, ep.windDir);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
_updateCharts() {
|
| 307 |
+
const ep=this.episode;
|
| 308 |
+
if(!ep) return;
|
| 309 |
+
this.rewardChart.push(ep.totalReward);
|
| 310 |
+
setText('rewardLast', ep.lastReward>0?'+'+ep.lastReward.toFixed(2):ep.lastReward.toFixed(2));
|
| 311 |
+
this.rewardChart.draw();
|
| 312 |
+
const fs=ep.fireSizeHistory[ep.fireSizeHistory.length-1]||0;
|
| 313 |
+
this.fireSizeChart.push(fs);
|
| 314 |
+
setText('fireLast', fs);
|
| 315 |
+
this.fireSizeChart.draw();
|
| 316 |
+
const ac=document.getElementById('actionChart');
|
| 317 |
+
if(ac) drawActionHistogram(ac, ep.actionCounts);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
_updateEventLog() {
|
| 321 |
+
const logEl=document.getElementById('eventLog');
|
| 322 |
+
if(!logEl||!this.episode) return;
|
| 323 |
+
const entries=this.episode.eventLog.slice(0,18);
|
| 324 |
+
logEl.innerHTML=entries.map(e=>`
|
| 325 |
+
<div class="erow${e.isAlert?' alarm':''}">
|
| 326 |
+
<span class="estep">${String(e.step).padStart(3,'0')}</span>
|
| 327 |
+
<span class="etext">${e.text}</span>
|
| 328 |
+
${e.reward?`<span class="erwd ${e.reward>0?'p':'n'}">${e.reward>0?'+':''}${e.reward.toFixed(2)}</span>`:''}
|
| 329 |
+
</div>`).join('');
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
_showEpisodeEnd() {
|
| 333 |
+
const ep=this.episode;
|
| 334 |
+
const banner=document.getElementById('statusBanner');
|
| 335 |
+
if(!banner) return;
|
| 336 |
+
if(ep.agentEvacuated) {
|
| 337 |
+
banner.innerHTML=`✓ EVACUATED — ${ep.totalReward.toFixed(1)} pts in ${ep.stepCount} steps`;
|
| 338 |
+
banner.className='ok';
|
| 339 |
+
} else if(!ep.agentAlive) {
|
| 340 |
+
banner.innerHTML=`✗ INCAPACITATED — health depleted`;
|
| 341 |
+
banner.className='bad';
|
| 342 |
+
} else {
|
| 343 |
+
banner.innerHTML=`⏱ TIMEOUT — ${ep.stepCount} steps`;
|
| 344 |
+
banner.className='tmo';
|
| 345 |
+
}
|
| 346 |
+
banner.style.display='flex';
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
_updatePlayBtn() {
|
| 350 |
+
const btn=document.getElementById('playBtn');
|
| 351 |
+
if(btn){btn.textContent=this.playing?'⏸':'▶';btn.classList.toggle('play',!this.playing);}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
// ── Render loop ────────────────────────────────────────────────────────────
|
| 355 |
+
|
| 356 |
+
_startRenderLoop() {
|
| 357 |
+
const loop=()=>{ this._render(); this.animFrame=requestAnimationFrame(loop); };
|
| 358 |
+
this.animFrame=requestAnimationFrame(loop);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
_render() {
|
| 362 |
+
if (!this.episode) return;
|
| 363 |
+
this.renderer.frame(this.episode, 1);
|
| 364 |
+
// Periodic chart updates when paused (to keep time display fresh)
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// ── Mode switching ─────────────────────────────────────────────────────────
|
| 368 |
+
|
| 369 |
+
async switchToLive() {
|
| 370 |
+
const ok=await this.liveClient.checkHealth();
|
| 371 |
+
const indicator=document.getElementById('liveIndicator');
|
| 372 |
+
if(!ok) {
|
| 373 |
+
if(indicator) { indicator.textContent='⚠ Server offline'; indicator.className='live-indicator offline'; }
|
| 374 |
+
showToast('Cannot reach server at localhost:8000 — staying in Demo mode');
|
| 375 |
+
return;
|
| 376 |
+
}
|
| 377 |
+
this.mode='live';
|
| 378 |
+
if(indicator){ indicator.textContent='● LIVE'; indicator.className='live-indicator online'; }
|
| 379 |
+
showToast('Connected to live server');
|
| 380 |
+
await this._resetLive();
|
| 381 |
+
this._startLiveLoop();
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
switchToDemo() {
|
| 385 |
+
this.mode='demo';
|
| 386 |
+
this._clearStepInterval();
|
| 387 |
+
const indicator=document.getElementById('liveIndicator');
|
| 388 |
+
if(indicator){ indicator.textContent='◉ DEMO'; indicator.className='live-indicator demo'; }
|
| 389 |
+
this.newEpisode();
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async _resetLive() {
|
| 393 |
+
try {
|
| 394 |
+
const seed=parseInt(document.getElementById('seedInput')?.value)||null;
|
| 395 |
+
await this.liveClient.reset(this.difficulty, seed);
|
| 396 |
+
} catch(e){ console.warn('Live reset failed',e); }
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
async _startLiveLoop() {
|
| 400 |
+
const poll=async()=>{
|
| 401 |
+
if(this.mode!=='live') return;
|
| 402 |
+
try {
|
| 403 |
+
const scene=await this.liveClient.getScene();
|
| 404 |
+
this._applyLiveScene(scene);
|
| 405 |
+
} catch(e){ console.warn('Live poll failed',e); }
|
| 406 |
+
if(this.mode==='live') setTimeout(poll, 800);
|
| 407 |
+
};
|
| 408 |
+
poll();
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
_applyLiveScene(scene) {
|
| 412 |
+
if(!scene||!scene.graph) return;
|
| 413 |
+
const g=scene.graph, lb=scene.labels;
|
| 414 |
+
const w=g.width, h=g.height;
|
| 415 |
+
const n=w*h;
|
| 416 |
+
const cellGrid=new Array(n), fireGrid=new Float32Array(n), smokeGrid=new Float32Array(n);
|
| 417 |
+
const visibleCells=new Set();
|
| 418 |
+
for(let y=0;y<h;y++) for(let x=0;x<w;x++){
|
| 419 |
+
const cell=g.grid[y][x];
|
| 420 |
+
const i=y*w+x;
|
| 421 |
+
cellGrid[i]=cell[0]; fireGrid[i]=cell[1]; smokeGrid[i]=cell[2];
|
| 422 |
+
if(cell[4]>0.5) visibleCells.add(`${x},${y}`);
|
| 423 |
+
}
|
| 424 |
+
if(!this.episode||this.episode.w!==w){
|
| 425 |
+
this.episode=new PyreEpisode(0,42,'medium');
|
| 426 |
+
this.renderer.init(this.episode);
|
| 427 |
+
}
|
| 428 |
+
this.episode.cellGrid=cellGrid;
|
| 429 |
+
this.episode.fireGrid=fireGrid;
|
| 430 |
+
this.episode.smokeGrid=smokeGrid;
|
| 431 |
+
this.episode.visibleCells=visibleCells;
|
| 432 |
+
// Agent state
|
| 433 |
+
this.episode.agentX=lb.agent.x; this.episode.agentY=lb.agent.y;
|
| 434 |
+
this.episode.agentHealth=lb.agent.health;
|
| 435 |
+
this.episode.agentAlive=lb.agent.alive;
|
| 436 |
+
this.episode.agentEvacuated=lb.agent.evacuated;
|
| 437 |
+
// Episode metadata
|
| 438 |
+
this.episode.stepCount=lb.episode.step;
|
| 439 |
+
this.episode.maxSteps=lb.episode.max_steps;
|
| 440 |
+
this.episode.windDir=lb.episode.wind_dir;
|
| 441 |
+
this.episode.pSpread=lb.episode.fire_spread_rate;
|
| 442 |
+
this.episode.humidity=lb.episode.humidity;
|
| 443 |
+
this.episode.difficulty=lb.episode.difficulty||'medium';
|
| 444 |
+
this.episode.templateName=lb.episode.template||'—';
|
| 445 |
+
// Map
|
| 446 |
+
this.episode.exits=lb.map.exit_positions||this.episode.exits;
|
| 447 |
+
this.episode.doorRegistry=lb.map.door_registry||{};
|
| 448 |
+
// Narrative fields (agent perception)
|
| 449 |
+
this.episode._smokeLevel=lb.agent.smoke_level||'clear';
|
| 450 |
+
this.episode._fireDirection=lb.agent.fire_direction||null;
|
| 451 |
+
this.episode._locationLabel=lb.agent.location||'';
|
| 452 |
+
this.episode._healthStatus=lb.agent.health_status||'Good';
|
| 453 |
+
// Surroundings → inject into event log
|
| 454 |
+
if(lb.surroundings) {
|
| 455 |
+
const sur=lb.surroundings;
|
| 456 |
+
if(sur.audible_signals&&sur.audible_signals.length) {
|
| 457 |
+
const step=lb.episode.step;
|
| 458 |
+
const prev=this.episode.eventLog[0];
|
| 459 |
+
if(!prev||prev.step!==step||!prev._fromSurroundings) {
|
| 460 |
+
for(const sig of sur.audible_signals.slice(0,2)) {
|
| 461 |
+
this.episode.eventLog.unshift({step,text:`🔊 ${sig}`,reward:0,isAlert:true,_fromSurroundings:true});
|
| 462 |
+
}
|
| 463 |
+
if(this.episode.eventLog.length>50) this.episode.eventLog.length=50;
|
| 464 |
+
}
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
this.episode.w=w; this.episode.h=h;
|
| 468 |
+
this._updateHUD();
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
// ── Control binding ────────────────────────────────────────────────────────
|
| 472 |
+
|
| 473 |
+
_bindControls() {
|
| 474 |
+
document.getElementById('playBtn')?.addEventListener('click',()=>this.togglePlay());
|
| 475 |
+
document.getElementById('stepBtn')?.addEventListener('click',()=>this.step());
|
| 476 |
+
document.getElementById('resetBtn')?.addEventListener('click',()=>this.newEpisode());
|
| 477 |
+
document.getElementById('prevBtn')?.addEventListener('click',()=>{
|
| 478 |
+
this.templateIdx=((this.templateIdx-2)+3)%3;
|
| 479 |
+
this.newEpisode();
|
| 480 |
+
});
|
| 481 |
+
|
| 482 |
+
document.querySelectorAll('.speed-btn').forEach(b=>{
|
| 483 |
+
b.addEventListener('click',()=>this.setSpeed(parseFloat(b.dataset.speed)));
|
| 484 |
+
});
|
| 485 |
+
|
| 486 |
+
document.querySelectorAll('.diff-btn').forEach(b=>{
|
| 487 |
+
b.addEventListener('click',()=>{
|
| 488 |
+
this.difficulty=b.dataset.diff;
|
| 489 |
+
document.querySelectorAll('.diff-btn').forEach(x=>x.classList.toggle('active',x===b));
|
| 490 |
+
this.newEpisode();
|
| 491 |
+
});
|
| 492 |
+
});
|
| 493 |
+
|
| 494 |
+
document.querySelectorAll('.mode-btn').forEach(b=>{
|
| 495 |
+
b.addEventListener('click',()=>{
|
| 496 |
+
document.querySelectorAll('.mode-btn').forEach(x=>x.classList.remove('active','fire'));
|
| 497 |
+
b.classList.add('active');
|
| 498 |
+
if(b.dataset.mode==='live') this.switchToLive();
|
| 499 |
+
else this.switchToDemo();
|
| 500 |
+
});
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
document.getElementById('seedInput')?.addEventListener('change',()=>this.newEpisode());
|
| 504 |
+
|
| 505 |
+
// Keyboard shortcuts
|
| 506 |
+
window.addEventListener('keydown',e=>{
|
| 507 |
+
if(e.target.tagName==='INPUT') return;
|
| 508 |
+
if(e.code==='Space'){e.preventDefault();this.togglePlay();}
|
| 509 |
+
else if(e.code==='ArrowRight') this.step();
|
| 510 |
+
else if(e.code==='KeyR') this.newEpisode();
|
| 511 |
+
else if(e.code==='Digit1') this.setSpeed(0.5);
|
| 512 |
+
else if(e.code==='Digit2') this.setSpeed(1);
|
| 513 |
+
else if(e.code==='Digit3') this.setSpeed(2);
|
| 514 |
+
else if(e.code==='Digit4') this.setSpeed(4);
|
| 515 |
+
else if(e.code==='Digit5') this.setSpeed(8);
|
| 516 |
+
});
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
// ── Utilities ──────────────────────────────────────���───────────────────────
|
| 521 |
+
|
| 522 |
+
function setText(id, val) {
|
| 523 |
+
const el=document.getElementById(id);
|
| 524 |
+
if(el) el.textContent=val;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
function showToast(msg) {
|
| 528 |
+
const t=document.getElementById('toast');
|
| 529 |
+
if(!t) return;
|
| 530 |
+
t.textContent=msg; t.classList.add('up');
|
| 531 |
+
setTimeout(()=>t.classList.remove('up'),3000);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
// Boot
|
| 535 |
+
window.addEventListener('DOMContentLoaded',()=>{ window.app=new PyreApp(); });
|
frontend_new_desgin/js/renderer.js
ADDED
|
@@ -0,0 +1,707 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* renderer.js — Canvas2D rendering engine for Pyre visualization
|
| 3 |
+
* Overhauled: natural building materials, vibrant fire, rich atmospheric depth.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// ── Palette ──────────────────────────────────────────────────────────────────
|
| 7 |
+
|
| 8 |
+
const COLORS = {
|
| 9 |
+
bg: '#1e242c', // A dark slate/navy instead of pitch black
|
| 10 |
+
// Walls — warm concrete / painted interior
|
| 11 |
+
wallBase: '#4f443b',
|
| 12 |
+
wallHT: 'rgba(255,248,232,0.25)', // top catch-light
|
| 13 |
+
wallHL: 'rgba(255,248,232,0.15)', // left highlight
|
| 14 |
+
wallSB: 'rgba(0,0,0,0.60)', // bottom shadow
|
| 15 |
+
wallSR: 'rgba(0,0,0,0.45)', // right shadow
|
| 16 |
+
wallMortar: 'rgba(0,0,0,0.20)', // mortar / texture lines
|
| 17 |
+
// Floor — worn office tile
|
| 18 |
+
floorBase: '#261f1a',
|
| 19 |
+
floorVis: '#302620',
|
| 20 |
+
floorGrout: 'rgba(0,0,0,0.3)',
|
| 21 |
+
// Obstacle — charred ruin
|
| 22 |
+
obstBase: '#140d0a',
|
| 23 |
+
obstEmber: 'rgba(255,80,0,0.35)',
|
| 24 |
+
// Exit
|
| 25 |
+
exitBase: '#062210',
|
| 26 |
+
exitGlow: '#22c55e',
|
| 27 |
+
exitGlow2: '#4ade80',
|
| 28 |
+
// Door
|
| 29 |
+
doorWood: '#5c3621',
|
| 30 |
+
doorDark: '#331c0d',
|
| 31 |
+
doorFrame: '#291508',
|
| 32 |
+
doorPanel: 'rgba(0,0,0,0.35)',
|
| 33 |
+
doorGlow: '#7dd3fc',
|
| 34 |
+
// Agent
|
| 35 |
+
agentCore: '#0ea5e9',
|
| 36 |
+
agentGlow: '#38bdf8',
|
| 37 |
+
agentBright: '#e0f2fe',
|
| 38 |
+
// Fog
|
| 39 |
+
fogSeen: 'rgba(30,36,44,0.3)', // increased visibility
|
| 40 |
+
fogUnseen: 'rgba(20,24,30,0.95)', // darker, more contrast
|
| 41 |
+
// Smoke channels
|
| 42 |
+
smokeR: 200, smokeG: 210, smokeB: 220, // Bright, thick white/grey smoke
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// Fire color ramp: black → deep red → orange → yellow → white
|
| 46 |
+
function fireColor(t, alpha=1) {
|
| 47 |
+
t = Math.min(1, Math.max(0, t));
|
| 48 |
+
let r,g,b;
|
| 49 |
+
if (t < 0.15) { const s=t/0.15; r=~~(98+88*s); g=~~(6*s); b=0; }
|
| 50 |
+
else if (t < 0.40) { const s=(t-0.15)/0.25; r=~~(186+60*s); g=~~(6+60*s); b=0; }
|
| 51 |
+
else if (t < 0.65) { const s=(t-0.40)/0.25; r=~~(246+9*s); g=~~(66+112*s); b=~~(9*s); }
|
| 52 |
+
else if (t < 0.85) { const s=(t-0.65)/0.20; r=255; g=~~(178+57*s); b=~~(9+32*s); }
|
| 53 |
+
else { const s=(t-0.85)/0.15; r=255; g=~~(235+20*s); b=~~(41+214*s); }
|
| 54 |
+
return `rgba(${r},${g},${b},${alpha})`;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function lerpColor(c1, c2, t) {
|
| 58 |
+
return [~~(c1[0]+(c2[0]-c1[0])*t), ~~(c1[1]+(c2[1]-c1[1])*t), ~~(c1[2]+(c2[2]-c1[2])*t)];
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// ── Ember particle system ─────────────────────────────────────────────────────
|
| 62 |
+
|
| 63 |
+
class EmberSystem {
|
| 64 |
+
constructor(maxCount=220) {
|
| 65 |
+
this.particles = [];
|
| 66 |
+
this.maxCount = maxCount;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
spawn(x, y, windX, windY, fireIntensity) {
|
| 70 |
+
if (this.particles.length >= this.maxCount) return;
|
| 71 |
+
if (Math.random() > fireIntensity * 0.48) return;
|
| 72 |
+
const speed = 0.4 + Math.random() * 1.0;
|
| 73 |
+
const angle = -Math.PI/2 + (Math.random()-0.5)*1.5;
|
| 74 |
+
this.particles.push({
|
| 75 |
+
x: x + (Math.random()-0.5)*3,
|
| 76 |
+
y: y + (Math.random()-0.5)*3,
|
| 77 |
+
vx: Math.cos(angle)*speed + windX*0.7,
|
| 78 |
+
vy: Math.sin(angle)*speed - 0.22,
|
| 79 |
+
life: 1.0,
|
| 80 |
+
decay: 0.010 + Math.random()*0.015,
|
| 81 |
+
size: 1.3 + Math.random()*2.4,
|
| 82 |
+
type: Math.random() > 0.4 ? 'ember' : 'spark',
|
| 83 |
+
});
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
update() {
|
| 87 |
+
for (let i=this.particles.length-1;i>=0;i--) {
|
| 88 |
+
const p=this.particles[i];
|
| 89 |
+
p.x += p.vx; p.y += p.vy;
|
| 90 |
+
p.vy -= 0.012;
|
| 91 |
+
p.vx *= 0.97;
|
| 92 |
+
p.life -= p.decay;
|
| 93 |
+
if (p.life <= 0) this.particles.splice(i,1);
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
draw(ctx) {
|
| 98 |
+
for (const p of this.particles) {
|
| 99 |
+
const a = p.life * 0.92;
|
| 100 |
+
const r = p.size * p.life;
|
| 101 |
+
ctx.beginPath();
|
| 102 |
+
ctx.arc(p.x, p.y, r, 0, Math.PI*2);
|
| 103 |
+
if (p.type === 'ember')
|
| 104 |
+
ctx.fillStyle=`rgba(255,${~~(80+100*p.life)},0,${a})`;
|
| 105 |
+
else
|
| 106 |
+
ctx.fillStyle=`rgba(255,${~~(210+45*p.life)},${~~(60*p.life)},${a})`;
|
| 107 |
+
ctx.fill();
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ── Smoke drift layer ─────────────────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
class SmokeLayer {
|
| 115 |
+
constructor(w, h) {
|
| 116 |
+
this.w=w; this.h=h;
|
| 117 |
+
this.phases = new Float32Array(w*h).map(()=>Math.random()*Math.PI*2);
|
| 118 |
+
}
|
| 119 |
+
getOffset(x, y, time) {
|
| 120 |
+
const ph = this.phases[y*this.w+x];
|
| 121 |
+
return { dx: Math.sin(time*0.35+ph)*1.8, dy: Math.cos(time*0.28+ph+1.2)*1.8 };
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// ── Main Renderer ─────────────────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
class PyreRenderer {
|
| 128 |
+
constructor(canvas) {
|
| 129 |
+
this.canvas = canvas;
|
| 130 |
+
this.ctx = canvas.getContext('2d');
|
| 131 |
+
this.time = 0;
|
| 132 |
+
this.embers = new EmberSystem(220);
|
| 133 |
+
this.agentTrail = [];
|
| 134 |
+
this.prevInterp = null;
|
| 135 |
+
this.smokeLayer = null;
|
| 136 |
+
this.exploreCanvas = null;
|
| 137 |
+
this.exploreCtx = null;
|
| 138 |
+
this._lastExploreSet = null;
|
| 139 |
+
this.cellSize = 0;
|
| 140 |
+
this.windVec = [0,0];
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
init(episode) {
|
| 144 |
+
const cs = Math.floor(this.canvas.width / episode.w);
|
| 145 |
+
this.cellSize = cs;
|
| 146 |
+
this.smokeLayer = new SmokeLayer(episode.w, episode.h);
|
| 147 |
+
this.exploreCanvas = document.createElement('canvas');
|
| 148 |
+
this.exploreCanvas.width = this.canvas.width;
|
| 149 |
+
this.exploreCanvas.height = this.canvas.height;
|
| 150 |
+
this.exploreCtx = this.exploreCanvas.getContext('2d');
|
| 151 |
+
this.exploreCtx.fillStyle = 'black';
|
| 152 |
+
this.exploreCtx.fillRect(0,0,this.exploreCanvas.width,this.exploreCanvas.height);
|
| 153 |
+
this.agentTrail = [];
|
| 154 |
+
this._lastExploreSet = new Set();
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
frame(episode, lerpT=1) {
|
| 158 |
+
this.time += 0.016;
|
| 159 |
+
const {w,h,cellGrid,fireGrid,smokeGrid,agentX,agentY,visibleCells,exploreSet} = episode;
|
| 160 |
+
const cs = this.cellSize;
|
| 161 |
+
this._w = w;
|
| 162 |
+
const ctx = this.ctx;
|
| 163 |
+
const wv = WIND_DIRS[episode.windDir]||[0,0];
|
| 164 |
+
this.windVec = wv;
|
| 165 |
+
|
| 166 |
+
// ── Update explore canvas ─────────────────────────────────────────────
|
| 167 |
+
if (exploreSet && exploreSet !== this._lastExploreSet) {
|
| 168 |
+
this._lastExploreSet = exploreSet;
|
| 169 |
+
for (const key of exploreSet) {
|
| 170 |
+
const [ex,ey] = key.split(',').map(Number);
|
| 171 |
+
this.exploreCtx.clearRect(ex*cs, ey*cs, cs, cs);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// ── Background ───────────────────────────────────────────────────────
|
| 176 |
+
ctx.fillStyle = COLORS.bg;
|
| 177 |
+
ctx.fillRect(0,0,this.canvas.width,this.canvas.height);
|
| 178 |
+
|
| 179 |
+
// ── Cell base layer ───────────────────────────────────────────────────
|
| 180 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 181 |
+
this._drawCellBase(ctx, x, y, cellGrid, cs);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// ── Fire ambient — warm glow radiating onto floor (normal blend) ──────
|
| 185 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 186 |
+
const i=y*w+x;
|
| 187 |
+
if (fireGrid[i] < 0.28) continue;
|
| 188 |
+
this._drawFireAmbient(ctx, x, y, fireGrid[i], cs);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
// ── Fire glow (screen blend for natural layering) ─────────────────────
|
| 192 |
+
ctx.save();
|
| 193 |
+
ctx.globalCompositeOperation = 'screen';
|
| 194 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 195 |
+
const i=y*w+x;
|
| 196 |
+
const fire=fireGrid[i];
|
| 197 |
+
if (fire < 0.05) continue;
|
| 198 |
+
this._drawFireCell(ctx, x, y, fire, cs);
|
| 199 |
+
if (fire >= FIRE_BURNING && Math.random()<0.09) {
|
| 200 |
+
this.embers.spawn(x*cs+cs/2, y*cs+cs/2, wv[0], wv[1], fire);
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
ctx.restore();
|
| 204 |
+
|
| 205 |
+
// ── Smoke overlay ─────────────────────────────────────────────────────
|
| 206 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 207 |
+
const i=y*w+x;
|
| 208 |
+
const smoke=smokeGrid[i];
|
| 209 |
+
if (smoke < 0.08) continue;
|
| 210 |
+
this._drawSmoke(ctx, x, y, smoke, cs);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// ── Doors & exits ─────────────────────────────────────────────────────
|
| 214 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 215 |
+
const ct=cellGrid[y*w+x];
|
| 216 |
+
if (ct===EXIT) this._drawExit(ctx,x,y,cs,fireGrid[y*w+x]);
|
| 217 |
+
else if (ct===DOOR_OPEN||ct===DOOR_CLOSED) this._drawDoor(ctx,x,y,ct,cs);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
// ── Fog of war ────────────────────────────────────────────────────────
|
| 221 |
+
if (visibleCells && episode.exploreSet) {
|
| 222 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 223 |
+
const key=`${x},${y}`;
|
| 224 |
+
if (visibleCells.has(key)) continue;
|
| 225 |
+
const explored = episode.exploreSet.has(key);
|
| 226 |
+
ctx.fillStyle = explored ? COLORS.fogSeen : COLORS.fogUnseen;
|
| 227 |
+
ctx.fillRect(x*cs, y*cs, cs, cs);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// ── Agent trail ───────────────────────────────────────────────────────
|
| 232 |
+
this._updateTrail(agentX, agentY, cs);
|
| 233 |
+
this._drawTrail(ctx, cs);
|
| 234 |
+
|
| 235 |
+
// ── Agent ─────────────────────────────────────────────────────────────
|
| 236 |
+
this._drawAgent(ctx, agentX, agentY, episode.agentHealth, episode.agentAlive, episode.agentEvacuated, cs);
|
| 237 |
+
|
| 238 |
+
// ── Embers (drawn above fog for dramatic effect) ──────────────────────
|
| 239 |
+
this.embers.update();
|
| 240 |
+
this.embers.draw(ctx);
|
| 241 |
+
|
| 242 |
+
// ── Tile grid overlay (very subtle) ───────────────────────────────────
|
| 243 |
+
ctx.strokeStyle='rgba(255,255,255,0.04)';
|
| 244 |
+
ctx.lineWidth=1;
|
| 245 |
+
for (let x=0;x<=w;x++) { ctx.beginPath(); ctx.moveTo(x*cs,0); ctx.lineTo(x*cs,h*cs); ctx.stroke(); }
|
| 246 |
+
for (let y=0;y<=h;y++) { ctx.beginPath(); ctx.moveTo(0,y*cs); ctx.lineTo(w*cs,y*cs); ctx.stroke(); }
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ── Cell base rendering ───────────────────────────────────────────────────
|
| 250 |
+
|
| 251 |
+
_drawCellBase(ctx, x, y, cellGrid, cs) {
|
| 252 |
+
const ct = cellGrid[y * (this._w||16) + x];
|
| 253 |
+
const px=x*cs, py=y*cs;
|
| 254 |
+
|
| 255 |
+
switch(ct) {
|
| 256 |
+
case WALL: {
|
| 257 |
+
const wallH = 8; // Extrude wall upward for 3D depth
|
| 258 |
+
|
| 259 |
+
// Front shadow face (gives depth)
|
| 260 |
+
ctx.fillStyle = COLORS.wallSB;
|
| 261 |
+
ctx.fillRect(px, py+cs-wallH, cs, wallH);
|
| 262 |
+
|
| 263 |
+
// Right side shadow face if applicable, but for simplicity, just a slight drop shadow
|
| 264 |
+
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
| 265 |
+
ctx.fillRect(px + cs, py - wallH + 4, 3, cs);
|
| 266 |
+
|
| 267 |
+
// Top face (shifted up by wallH)
|
| 268 |
+
ctx.fillStyle = COLORS.wallBase;
|
| 269 |
+
ctx.fillRect(px, py - wallH, cs, cs);
|
| 270 |
+
|
| 271 |
+
// Top face catch-light
|
| 272 |
+
ctx.fillStyle = COLORS.wallHT;
|
| 273 |
+
ctx.fillRect(px, py - wallH, cs, 3);
|
| 274 |
+
|
| 275 |
+
// Left highlight
|
| 276 |
+
ctx.fillStyle = COLORS.wallHL;
|
| 277 |
+
ctx.fillRect(px, py - wallH + 3, 2, cs - 3);
|
| 278 |
+
|
| 279 |
+
// Brick texture on the top face
|
| 280 |
+
ctx.fillStyle = COLORS.wallMortar;
|
| 281 |
+
ctx.fillRect(px+3, py - wallH + ~~(cs*0.34), cs-6, 1);
|
| 282 |
+
ctx.fillRect(px+3, py - wallH + ~~(cs*0.67), cs-6, 1);
|
| 283 |
+
ctx.fillRect(px+~~(cs*0.3), py - wallH + 3, 1, ~~(cs*0.34)-3);
|
| 284 |
+
ctx.fillRect(px+~~(cs*0.7), py - wallH + ~~(cs*0.34)+1, 1, ~~(cs*0.33));
|
| 285 |
+
ctx.fillRect(px+~~(cs*0.5), py - wallH + ~~(cs*0.67)+1, 1, ~~(cs*0.33)-3);
|
| 286 |
+
break;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
case OBSTACLE: {
|
| 290 |
+
const obsH = 4; // Charred ruin slight 3D elevation
|
| 291 |
+
|
| 292 |
+
// Front shadow face
|
| 293 |
+
ctx.fillStyle = '#0a0705';
|
| 294 |
+
ctx.fillRect(px, py+cs-obsH, cs, obsH);
|
| 295 |
+
|
| 296 |
+
// Top face
|
| 297 |
+
ctx.fillStyle = COLORS.obstBase;
|
| 298 |
+
ctx.fillRect(px, py - obsH, cs, cs);
|
| 299 |
+
|
| 300 |
+
// Diagonal char texture
|
| 301 |
+
ctx.strokeStyle = 'rgba(0,0,0,0.6)';
|
| 302 |
+
ctx.lineWidth = 0.8;
|
| 303 |
+
for (let d=0; d<cs+cs; d+=5) {
|
| 304 |
+
ctx.beginPath();
|
| 305 |
+
ctx.moveTo(px+Math.max(0,d-cs), py - obsH + Math.min(d,cs));
|
| 306 |
+
ctx.lineTo(px+Math.min(d,cs), py - obsH + Math.max(0,d-cs));
|
| 307 |
+
ctx.stroke();
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// Faint ember glow at edges
|
| 311 |
+
ctx.fillStyle = COLORS.obstEmber;
|
| 312 |
+
ctx.fillRect(px, py - obsH, cs, 1);
|
| 313 |
+
ctx.fillRect(px, py - obsH +cs-1, cs, 1);
|
| 314 |
+
ctx.fillRect(px, py - obsH +1, 1, cs-2);
|
| 315 |
+
ctx.fillRect(px+cs-1, py - obsH +1, 1, cs-2);
|
| 316 |
+
break;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
case EXIT: {
|
| 320 |
+
ctx.fillStyle = COLORS.exitBase;
|
| 321 |
+
ctx.fillRect(px, py, cs, cs);
|
| 322 |
+
// Subtle green tint
|
| 323 |
+
ctx.fillStyle = 'rgba(34,197,94,0.07)';
|
| 324 |
+
ctx.fillRect(px+2, py+2, cs-4, cs-4);
|
| 325 |
+
break;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
default: { // FLOOR, DOOR_OPEN, DOOR_CLOSED
|
| 329 |
+
ctx.fillStyle = COLORS.floorVis;
|
| 330 |
+
ctx.fillRect(px, py, cs, cs);
|
| 331 |
+
|
| 332 |
+
// Checkered tile effect
|
| 333 |
+
if ((x + y) % 2 === 0) {
|
| 334 |
+
ctx.fillStyle = 'rgba(255,248,230,0.035)';
|
| 335 |
+
ctx.fillRect(px, py, cs, cs);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
// Tile grout lines (top + left edge per cell → creates tile grid)
|
| 339 |
+
ctx.fillStyle = COLORS.floorGrout;
|
| 340 |
+
ctx.fillRect(px, py, cs, 1);
|
| 341 |
+
ctx.fillRect(px, py+1, 1, cs-1);
|
| 342 |
+
|
| 343 |
+
// Inner tile details for realism
|
| 344 |
+
ctx.fillStyle = 'rgba(0,0,0,0.06)';
|
| 345 |
+
ctx.fillRect(px+2, py+2, cs-4, 2);
|
| 346 |
+
ctx.fillRect(px+2, py+2, 2, cs-4);
|
| 347 |
+
break;
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// ── Fire rendering ────────────────────────────────────────────────────────
|
| 353 |
+
|
| 354 |
+
_drawFireAmbient(ctx, x, y, fire, cs) {
|
| 355 |
+
// Warm orange-red ambient glow radiating onto surrounding cells
|
| 356 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 357 |
+
const radius = cs * (2.8 + fire * 2.0); // much larger glow
|
| 358 |
+
const alpha = fire * 0.65; // much brighter glow
|
| 359 |
+
const gr = ctx.createRadialGradient(px,py,0,px,py,radius);
|
| 360 |
+
gr.addColorStop(0, `rgba(255,100,10,${alpha})`);
|
| 361 |
+
gr.addColorStop(0.45,`rgba(200,60,0,${alpha*0.5})`);
|
| 362 |
+
gr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 363 |
+
ctx.fillStyle = gr;
|
| 364 |
+
ctx.fillRect(px-radius, py-radius, radius*2, radius*2);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
_drawFireCell(ctx, x, y, fire, cs) {
|
| 368 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 369 |
+
const f1 = 0.82 + 0.18*Math.sin(this.time*9.5+x*2.7+y*3.14);
|
| 370 |
+
const eff = fire * f1;
|
| 371 |
+
|
| 372 |
+
// Wind offsets for volumetric flame leaning
|
| 373 |
+
const windDx = this.windVec ? this.windVec[0] * cs * 0.35 * eff : 0;
|
| 374 |
+
const windDy = this.windVec ? this.windVec[1] * cs * 0.35 * eff : -cs * 0.15; // default lean up
|
| 375 |
+
|
| 376 |
+
// Four layered volumetric gradients
|
| 377 |
+
const layers = [
|
| 378 |
+
{r:cs*0.35, aMax:0.98, ox: windDx*0.2, oy: windDy*0.2 - cs*0.1}, // bright core
|
| 379 |
+
{r:cs*0.65, aMax:0.75, ox: windDx*0.5, oy: windDy*0.5}, // inner flame
|
| 380 |
+
{r:cs*0.95, aMax:0.45, ox: windDx, oy: windDy}, // flame body
|
| 381 |
+
{r:cs*1.40, aMax:0.18, ox: windDx*1.5, oy: windDy*1.5}, // outer heat glow
|
| 382 |
+
];
|
| 383 |
+
for (const {r,aMax,ox,oy} of layers) {
|
| 384 |
+
const cx = px + ox;
|
| 385 |
+
const cy = py + oy;
|
| 386 |
+
const gr = ctx.createRadialGradient(cx,cy,0,cx,cy,r);
|
| 387 |
+
gr.addColorStop(0, fireColor(eff, aMax));
|
| 388 |
+
gr.addColorStop(0.4, fireColor(eff*0.75, aMax*0.55));
|
| 389 |
+
gr.addColorStop(0.8, fireColor(eff*0.32, aMax*0.18));
|
| 390 |
+
gr.addColorStop(1, fireColor(eff*0.08, 0));
|
| 391 |
+
ctx.fillStyle = gr;
|
| 392 |
+
ctx.beginPath();
|
| 393 |
+
ctx.arc(cx, cy, r, 0, Math.PI*2);
|
| 394 |
+
ctx.fill();
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// White-hot core for high intensity fire
|
| 398 |
+
if (eff > 0.48) {
|
| 399 |
+
const cx = px + windDx*0.1;
|
| 400 |
+
const cy = py + windDy*0.1 - cs*0.1;
|
| 401 |
+
const gr2 = ctx.createRadialGradient(cx,cy,0,cx,cy,cs*0.17);
|
| 402 |
+
gr2.addColorStop(0, `rgba(255,255,230,${(eff-0.48)*1.7})`);
|
| 403 |
+
gr2.addColorStop(1, 'rgba(255,210,50,0)');
|
| 404 |
+
ctx.fillStyle = gr2;
|
| 405 |
+
ctx.beginPath();
|
| 406 |
+
ctx.arc(cx, cy, cs*0.17, 0, Math.PI*2);
|
| 407 |
+
ctx.fill();
|
| 408 |
+
}
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
_drawSmoke(ctx, x, y, smoke, cs) {
|
| 412 |
+
const px=x*cs+cs/2, py=y*cs+cs/2;
|
| 413 |
+
const off = this.smokeLayer.getOffset(x,y,this.time);
|
| 414 |
+
const alpha = Math.min(0.85, smoke * 0.95);
|
| 415 |
+
const {smokeR:sr, smokeG:sg, smokeB:sb} = COLORS;
|
| 416 |
+
|
| 417 |
+
// Draw multiple overlapping soft circles to form a volumetric cloud
|
| 418 |
+
const cloudRadius = cs * 0.85; // overlaps neighboring cells
|
| 419 |
+
|
| 420 |
+
ctx.save();
|
| 421 |
+
// Shift slightly based on smoke layer offset for animation
|
| 422 |
+
ctx.translate(px + off.dx, py + off.dy);
|
| 423 |
+
|
| 424 |
+
// Base fluffy cloud layer
|
| 425 |
+
const gr = ctx.createRadialGradient(0, 0, 0, 0, 0, cloudRadius);
|
| 426 |
+
gr.addColorStop(0, `rgba(${sr},${sg},${sb},${alpha})`);
|
| 427 |
+
gr.addColorStop(0.5, `rgba(${sr},${sg},${sb},${alpha*0.75})`);
|
| 428 |
+
gr.addColorStop(1, `rgba(${sr},${sg},${sb},0)`);
|
| 429 |
+
|
| 430 |
+
ctx.fillStyle = gr;
|
| 431 |
+
ctx.beginPath();
|
| 432 |
+
ctx.arc(0, 0, cloudRadius, 0, Math.PI*2);
|
| 433 |
+
ctx.fill();
|
| 434 |
+
|
| 435 |
+
// Secondary offset puff for 3D depth and density
|
| 436 |
+
if (smoke > 0.3) {
|
| 437 |
+
const puffRadius = cs * 0.6;
|
| 438 |
+
const puffOffX = off.dy * 1.5; // orthogonal offset
|
| 439 |
+
const puffOffY = off.dx * 1.5;
|
| 440 |
+
const gr2 = ctx.createRadialGradient(puffOffX, puffOffY, 0, puffOffX, puffOffY, puffRadius);
|
| 441 |
+
gr2.addColorStop(0, `rgba(${sr+15},${sg+15},${sb+20},${alpha*0.5})`);
|
| 442 |
+
gr2.addColorStop(1, `rgba(${sr+15},${sg+15},${sb+20},0)`);
|
| 443 |
+
|
| 444 |
+
ctx.fillStyle = gr2;
|
| 445 |
+
ctx.beginPath();
|
| 446 |
+
ctx.arc(puffOffX, puffOffY, puffRadius, 0, Math.PI*2);
|
| 447 |
+
ctx.fill();
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
ctx.restore();
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
_drawExit(ctx, x, y, cs, fire) {
|
| 454 |
+
const px=x*cs, py=y*cs;
|
| 455 |
+
const pulse = 0.62 + 0.38*Math.sin(this.time*2.8 + x + y);
|
| 456 |
+
const blocked = fire >= EXIT_BLOCKED_FIRE_THRESHOLD;
|
| 457 |
+
const col1 = blocked ? '#ef4444' : COLORS.exitGlow;
|
| 458 |
+
const col2 = blocked ? '#dc2626' : COLORS.exitGlow2;
|
| 459 |
+
|
| 460 |
+
ctx.save();
|
| 461 |
+
|
| 462 |
+
// Outer glow ring
|
| 463 |
+
ctx.shadowBlur = cs * 1.1 * pulse;
|
| 464 |
+
ctx.shadowColor = col1;
|
| 465 |
+
ctx.strokeStyle = col1;
|
| 466 |
+
ctx.lineWidth = 2;
|
| 467 |
+
ctx.strokeRect(px+2, py+2, cs-4, cs-4);
|
| 468 |
+
|
| 469 |
+
// Interior tint
|
| 470 |
+
ctx.fillStyle = blocked ? 'rgba(220,38,38,0.11)' : 'rgba(34,197,94,0.11)';
|
| 471 |
+
ctx.fillRect(px+3, py+3, cs-6, cs-6);
|
| 472 |
+
|
| 473 |
+
// Center symbol
|
| 474 |
+
ctx.shadowBlur = 0;
|
| 475 |
+
const sa = 0.55 + 0.35*pulse;
|
| 476 |
+
ctx.fillStyle = blocked
|
| 477 |
+
? `rgba(255,90,90,${sa})`
|
| 478 |
+
: `rgba(74,222,128,${sa})`;
|
| 479 |
+
ctx.font = `bold ${~~(cs*0.50)}px sans-serif`;
|
| 480 |
+
ctx.textAlign='center'; ctx.textBaseline='middle';
|
| 481 |
+
ctx.fillText(blocked ? '✕' : '⇥', px+cs/2, py+cs/2+1);
|
| 482 |
+
|
| 483 |
+
// Corner bracket accents
|
| 484 |
+
ctx.fillStyle = blocked ? 'rgba(248,113,113,0.75)' : 'rgba(74,222,128,0.75)';
|
| 485 |
+
const ca=5;
|
| 486 |
+
ctx.fillRect(px, py, ca, 1); ctx.fillRect(px, py, 1, ca);
|
| 487 |
+
ctx.fillRect(px+cs-ca,py, ca, 1); ctx.fillRect(px+cs-1,py,1,ca);
|
| 488 |
+
ctx.fillRect(px, py+cs-1, ca, 1); ctx.fillRect(px, py+cs-ca, 1, ca);
|
| 489 |
+
ctx.fillRect(px+cs-ca,py+cs-1, ca, 1); ctx.fillRect(px+cs-1,py+cs-ca,1,ca);
|
| 490 |
+
|
| 491 |
+
ctx.restore();
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
_drawDoor(ctx, x, y, ct, cs) {
|
| 495 |
+
const px=x*cs, py=y*cs;
|
| 496 |
+
|
| 497 |
+
if (ct === DOOR_OPEN) {
|
| 498 |
+
// Open doorway — dark passage with door pressed to side
|
| 499 |
+
ctx.fillStyle = 'rgba(0,0,0,0.38)';
|
| 500 |
+
ctx.fillRect(px+4, py, cs-6, cs);
|
| 501 |
+
|
| 502 |
+
// Door panel pushed to left wall
|
| 503 |
+
ctx.fillStyle = COLORS.doorWood;
|
| 504 |
+
ctx.fillRect(px, py, 4, cs);
|
| 505 |
+
|
| 506 |
+
// Door frame along top
|
| 507 |
+
ctx.fillStyle = COLORS.doorFrame;
|
| 508 |
+
ctx.fillRect(px, py, cs, 2);
|
| 509 |
+
|
| 510 |
+
// Faint blue passage glow
|
| 511 |
+
ctx.fillStyle = 'rgba(125,211,252,0.10)';
|
| 512 |
+
ctx.fillRect(px+5, py+2, cs-7, cs-4);
|
| 513 |
+
} else {
|
| 514 |
+
// Closed door — wood panel with visible detail
|
| 515 |
+
// Door body
|
| 516 |
+
ctx.fillStyle = COLORS.doorWood;
|
| 517 |
+
ctx.fillRect(px+2, py+1, cs-4, cs-2);
|
| 518 |
+
|
| 519 |
+
// Door frame
|
| 520 |
+
ctx.fillStyle = COLORS.doorFrame;
|
| 521 |
+
ctx.fillRect(px, py, cs, 2); // top rail
|
| 522 |
+
ctx.fillRect(px, py+cs-2, cs, 2); // bottom rail
|
| 523 |
+
ctx.fillRect(px, py+2, 2, cs-4); // left stile
|
| 524 |
+
ctx.fillRect(px+cs-2, py+2, 2, cs-4); // right stile
|
| 525 |
+
|
| 526 |
+
// Panel lines (3 horizontal panels)
|
| 527 |
+
ctx.fillStyle = COLORS.doorPanel;
|
| 528 |
+
ctx.fillRect(px+4, py+~~(cs*0.14), cs-8, 1);
|
| 529 |
+
ctx.fillRect(px+4, py+~~(cs*0.50), cs-8, 1);
|
| 530 |
+
ctx.fillRect(px+4, py+~~(cs*0.82), cs-8, 1);
|
| 531 |
+
|
| 532 |
+
// Panel inset shadow
|
| 533 |
+
ctx.fillStyle = 'rgba(0,0,0,0.13)';
|
| 534 |
+
ctx.fillRect(px+4, py+~~(cs*0.14)+2, cs-8, ~~(cs*0.34));
|
| 535 |
+
ctx.fillRect(px+4, py+~~(cs*0.50)+2, cs-8, ~~(cs*0.30));
|
| 536 |
+
|
| 537 |
+
// Doorknob — gold circle
|
| 538 |
+
ctx.beginPath();
|
| 539 |
+
ctx.arc(px+cs-8, py+~~(cs/2), 2.5, 0, Math.PI*2);
|
| 540 |
+
ctx.fillStyle = 'rgba(210,168,75,0.88)';
|
| 541 |
+
ctx.fill();
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
// ── Agent ─────────────────────────────────────────────────────────────────
|
| 546 |
+
|
| 547 |
+
_updateTrail(ax, ay, cs) {
|
| 548 |
+
const px=ax*cs+cs/2, py=ay*cs+cs/2;
|
| 549 |
+
if (!this.agentTrail.length
|
| 550 |
+
|| Math.abs(this.agentTrail[0].px-px)>1
|
| 551 |
+
|| Math.abs(this.agentTrail[0].py-py)>1) {
|
| 552 |
+
this.agentTrail.unshift({px,py,t:this.time});
|
| 553 |
+
}
|
| 554 |
+
while (this.agentTrail.length > 14) this.agentTrail.pop();
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
_drawTrail(ctx, cs) {
|
| 558 |
+
if (this.agentTrail.length < 2) return;
|
| 559 |
+
for (let i=1; i<this.agentTrail.length; i++) {
|
| 560 |
+
const alpha = (1-i/this.agentTrail.length)*0.42;
|
| 561 |
+
const r = Math.max(1.5, (1-i/this.agentTrail.length)*cs*0.22);
|
| 562 |
+
ctx.beginPath();
|
| 563 |
+
ctx.arc(this.agentTrail[i].px, this.agentTrail[i].py, r, 0, Math.PI*2);
|
| 564 |
+
ctx.fillStyle = `rgba(14,165,233,${alpha})`;
|
| 565 |
+
ctx.fill();
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
_drawAgent(ctx, ax, ay, health, alive, evacuated, cs) {
|
| 570 |
+
if (!alive && !evacuated) return;
|
| 571 |
+
const px=ax*cs+cs/2, py=ay*cs+cs/2;
|
| 572 |
+
const pulse = 0.82 + 0.18*Math.sin(this.time*3.6);
|
| 573 |
+
const pulse2 = 0.70 + 0.30*Math.sin(this.time*2.1+1.0);
|
| 574 |
+
const r = cs * 0.38 * pulse; // increased size for better visibility
|
| 575 |
+
|
| 576 |
+
const hRatio = Math.max(0, Math.min(1, health/100));
|
| 577 |
+
const agentColor = evacuated ? '#4ade80'
|
| 578 |
+
: hRatio > 0.6 ? COLORS.agentCore
|
| 579 |
+
: hRatio > 0.3 ? '#f59e0b'
|
| 580 |
+
: '#ef4444';
|
| 581 |
+
|
| 582 |
+
ctx.save();
|
| 583 |
+
|
| 584 |
+
// Outer aura pulse (very faint)
|
| 585 |
+
const auraR = cs * 0.74 * pulse2;
|
| 586 |
+
const auraGr = ctx.createRadialGradient(px,py,r*0.9,px,py,auraR);
|
| 587 |
+
auraGr.addColorStop(0, 'rgba(0,0,0,0)');
|
| 588 |
+
auraGr.addColorStop(0.55, evacuated ? 'rgba(74,222,128,0.09)' : 'rgba(56,189,248,0.09)');
|
| 589 |
+
auraGr.addColorStop(1, 'rgba(0,0,0,0)');
|
| 590 |
+
ctx.fillStyle = auraGr;
|
| 591 |
+
ctx.beginPath();
|
| 592 |
+
ctx.arc(px, py, auraR, 0, Math.PI*2);
|
| 593 |
+
ctx.fill();
|
| 594 |
+
|
| 595 |
+
// Drop shadow
|
| 596 |
+
ctx.fillStyle = 'rgba(0,0,0,0.32)';
|
| 597 |
+
ctx.beginPath();
|
| 598 |
+
ctx.ellipse(px+2, py+3, r*0.9, r*0.48, 0, 0, Math.PI*2);
|
| 599 |
+
ctx.fill();
|
| 600 |
+
|
| 601 |
+
// Glow
|
| 602 |
+
ctx.shadowBlur = cs * 0.90;
|
| 603 |
+
ctx.shadowColor = evacuated ? '#22c55e' : agentColor;
|
| 604 |
+
|
| 605 |
+
// Body — radial gradient for sphere illusion
|
| 606 |
+
ctx.beginPath();
|
| 607 |
+
ctx.arc(px, py, r, 0, Math.PI*2);
|
| 608 |
+
const bodyGr = ctx.createRadialGradient(px-r*0.28, py-r*0.28, 0, px, py, r);
|
| 609 |
+
bodyGr.addColorStop(0, '#ffffff');
|
| 610 |
+
bodyGr.addColorStop(0.32, COLORS.agentBright);
|
| 611 |
+
bodyGr.addColorStop(0.70, agentColor);
|
| 612 |
+
bodyGr.addColorStop(1, 'rgba(0,80,160,0.55)');
|
| 613 |
+
ctx.fillStyle = bodyGr;
|
| 614 |
+
ctx.fill();
|
| 615 |
+
|
| 616 |
+
// Outline
|
| 617 |
+
ctx.shadowBlur = 0;
|
| 618 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.88)';
|
| 619 |
+
ctx.lineWidth = 1.5;
|
| 620 |
+
ctx.stroke();
|
| 621 |
+
ctx.restore();
|
| 622 |
+
|
| 623 |
+
// Health arc ring
|
| 624 |
+
ctx.save();
|
| 625 |
+
const arcR = r * 1.65;
|
| 626 |
+
// Track
|
| 627 |
+
ctx.beginPath();
|
| 628 |
+
ctx.arc(px, py, arcR, 0, Math.PI*2);
|
| 629 |
+
ctx.strokeStyle = 'rgba(255,255,255,0.07)';
|
| 630 |
+
ctx.lineWidth = 2.5;
|
| 631 |
+
ctx.stroke();
|
| 632 |
+
// Fill arc
|
| 633 |
+
if (hRatio > 0) {
|
| 634 |
+
ctx.beginPath();
|
| 635 |
+
ctx.arc(px, py, arcR, -Math.PI/2, -Math.PI/2 + hRatio*Math.PI*2);
|
| 636 |
+
ctx.strokeStyle = hRatio>0.6?'#4ade80':hRatio>0.3?'#fbbf24':'#f87171'; // More vibrant health colors
|
| 637 |
+
ctx.lineWidth = 3.0; // Slightly thicker
|
| 638 |
+
ctx.lineCap = 'round';
|
| 639 |
+
ctx.stroke();
|
| 640 |
+
|
| 641 |
+
// Arc glow
|
| 642 |
+
ctx.shadowBlur = 8;
|
| 643 |
+
ctx.shadowColor = ctx.strokeStyle;
|
| 644 |
+
ctx.stroke();
|
| 645 |
+
}
|
| 646 |
+
ctx.restore();
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
// ── Wind rose ─────────────────────────────────────────────────────────────
|
| 650 |
+
|
| 651 |
+
drawWindRose(canvas2, windDir, speed=1) {
|
| 652 |
+
if (!canvas2) return;
|
| 653 |
+
const ctx=canvas2.getContext('2d');
|
| 654 |
+
const w=canvas2.width, h=canvas2.height, cx=w/2, cy=h/2;
|
| 655 |
+
ctx.clearRect(0,0,w,h);
|
| 656 |
+
|
| 657 |
+
const wv=WIND_DIRS[windDir]||[0,0];
|
| 658 |
+
const angle=Math.atan2(wv[1],wv[0]);
|
| 659 |
+
const isCalm=windDir==='CALM';
|
| 660 |
+
|
| 661 |
+
// Compass ring
|
| 662 |
+
ctx.beginPath();
|
| 663 |
+
ctx.arc(cx,cy,cx-4,0,Math.PI*2);
|
| 664 |
+
ctx.strokeStyle='rgba(120,140,220,0.28)';
|
| 665 |
+
ctx.lineWidth=1.5;
|
| 666 |
+
ctx.stroke();
|
| 667 |
+
|
| 668 |
+
// Cardinal labels
|
| 669 |
+
const labels=[['N',0,-1],['E',1,0],['S',0,1],['W',-1,0]];
|
| 670 |
+
ctx.font=`${w*0.16}px monospace`;
|
| 671 |
+
ctx.textAlign='center'; ctx.textBaseline='middle';
|
| 672 |
+
for (const [l,dx,dy] of labels) {
|
| 673 |
+
ctx.fillStyle='rgba(160,172,232,0.55)';
|
| 674 |
+
ctx.fillText(l, cx+dx*(cx-10), cy+dy*(cy-10));
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
if (!isCalm) {
|
| 678 |
+
const arrowLen=cx*0.60;
|
| 679 |
+
const ax2=cx+Math.cos(angle)*arrowLen, ay2=cy+Math.sin(angle)*arrowLen;
|
| 680 |
+
ctx.beginPath();
|
| 681 |
+
ctx.moveTo(cx,cy);
|
| 682 |
+
ctx.lineTo(ax2,ay2);
|
| 683 |
+
ctx.strokeStyle='rgba(251,191,36,0.95)';
|
| 684 |
+
ctx.lineWidth=2.5;
|
| 685 |
+
ctx.stroke();
|
| 686 |
+
|
| 687 |
+
const headLen=8, ha=0.40;
|
| 688 |
+
ctx.beginPath();
|
| 689 |
+
ctx.moveTo(ax2,ay2);
|
| 690 |
+
ctx.lineTo(ax2-headLen*Math.cos(angle-ha), ay2-headLen*Math.sin(angle-ha));
|
| 691 |
+
ctx.lineTo(ax2-headLen*Math.cos(angle+ha), ay2-headLen*Math.sin(angle+ha));
|
| 692 |
+
ctx.closePath();
|
| 693 |
+
ctx.fillStyle='rgba(251,191,36,0.95)';
|
| 694 |
+
ctx.fill();
|
| 695 |
+
|
| 696 |
+
ctx.font=`bold ${w*0.18}px monospace`;
|
| 697 |
+
ctx.fillStyle='rgba(255,210,80,0.95)';
|
| 698 |
+
ctx.textAlign='center'; ctx.textBaseline='middle';
|
| 699 |
+
ctx.fillText(windDir, cx, cy);
|
| 700 |
+
} else {
|
| 701 |
+
ctx.font=`${w*0.18}px monospace`;
|
| 702 |
+
ctx.fillStyle='rgba(160,182,232,0.75)';
|
| 703 |
+
ctx.textAlign='center'; ctx.textBaseline='middle';
|
| 704 |
+
ctx.fillText('CALM', cx, cy);
|
| 705 |
+
}
|
| 706 |
+
}
|
| 707 |
+
}
|
frontend_new_desgin/js/sim.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* sim.js — JavaScript port of pyre_env fire simulation
|
| 3 |
+
* Mirrors server/fire_sim.py and server/floor_plan.py exactly.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
// Cell types
|
| 7 |
+
const FLOOR = 0, WALL = 1, DOOR_OPEN = 2, DOOR_CLOSED = 3, EXIT = 4, OBSTACLE = 5;
|
| 8 |
+
|
| 9 |
+
const FIRE_IGNITION = 0.1;
|
| 10 |
+
const FIRE_BURNING = 0.3;
|
| 11 |
+
const FIRE_INTENSITY_GAIN = 0.15;
|
| 12 |
+
const BURNOUT_TICKS = 5;
|
| 13 |
+
const DOOR_CLOSED_FIRE_FACTOR = 0.15;
|
| 14 |
+
const SMOKE_SPREAD_RATE = 0.20;
|
| 15 |
+
const SMOKE_DOOR_FACTOR = 0.4;
|
| 16 |
+
const SMOKE_DECAY = 0.02;
|
| 17 |
+
const EXIT_BLOCKED_FIRE_THRESHOLD = 0.5;
|
| 18 |
+
|
| 19 |
+
const WIND_DIRS = {
|
| 20 |
+
N:[0,-1], NE:[1,-1], E:[1,0], SE:[1,1],
|
| 21 |
+
S:[0,1], SW:[-1,1], W:[-1,0], NW:[-1,-1], CALM:[0,0]
|
| 22 |
+
};
|
| 23 |
+
const CARDINAL = [[0,-1],[0,1],[-1,0],[1,0]];
|
| 24 |
+
|
| 25 |
+
function windMult(dx, dy, wx, wy) {
|
| 26 |
+
if (wx===0&&wy===0) return 1.0;
|
| 27 |
+
const dot = dx*wx + dy*wy;
|
| 28 |
+
return dot>0 ? 2.0 : dot<0 ? 0.5 : 1.0;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Seeded PRNG (mulberry32)
|
| 32 |
+
function makePRNG(seed) {
|
| 33 |
+
let s = seed >>> 0;
|
| 34 |
+
return function() {
|
| 35 |
+
s += 0x6D2B79F5;
|
| 36 |
+
let t = s;
|
| 37 |
+
t = Math.imul(t ^ t>>>15, t|1);
|
| 38 |
+
t ^= t + Math.imul(t^t>>>7, t|61);
|
| 39 |
+
return ((t^t>>>14)>>>0) / 4294967296;
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
class FireSim {
|
| 44 |
+
constructor(w, h, rng, pSpread=0.25, windDir='CALM', humidity=0.25, fuelMap=null, ventMap=null) {
|
| 45 |
+
this.w=w; this.h=h; this.rng=rng;
|
| 46 |
+
const wv = WIND_DIRS[windDir]||[0,0];
|
| 47 |
+
this.wx=wv[0]; this.wy=wv[1];
|
| 48 |
+
this.effectiveSpread = pSpread * Math.max(0, 1-humidity);
|
| 49 |
+
this.fuelMap=fuelMap; this.ventMap=ventMap;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
step(cellGrid, fireGrid, smokeGrid, burnTimers) {
|
| 53 |
+
const {w, h} = this;
|
| 54 |
+
const burned = [];
|
| 55 |
+
const ignite = new Uint8Array(w*h);
|
| 56 |
+
|
| 57 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 58 |
+
const i=y*w+x;
|
| 59 |
+
if (fireGrid[i] < FIRE_BURNING) continue;
|
| 60 |
+
for (const [dx,dy] of CARDINAL) {
|
| 61 |
+
const nx=x+dx, ny=y+dy;
|
| 62 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 63 |
+
const ni=ny*w+nx, nct=cellGrid[ni];
|
| 64 |
+
if (nct===WALL||nct===OBSTACLE||fireGrid[ni]>0) continue;
|
| 65 |
+
let p = nct===DOOR_CLOSED ? this.effectiveSpread*DOOR_CLOSED_FIRE_FACTOR : this.effectiveSpread;
|
| 66 |
+
p *= windMult(dx,dy,this.wx,this.wy);
|
| 67 |
+
if (this.fuelMap) p *= this.fuelMap[ni];
|
| 68 |
+
if (this.rng() < Math.min(1,p)) ignite[ni]=1;
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const newFire = fireGrid.slice();
|
| 73 |
+
const newTimers = burnTimers.slice();
|
| 74 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 75 |
+
const i=y*w+x, ct=cellGrid[i];
|
| 76 |
+
if (ct===WALL||ct===OBSTACLE) continue;
|
| 77 |
+
if (fireGrid[i]>0) {
|
| 78 |
+
let gain = FIRE_INTENSITY_GAIN;
|
| 79 |
+
if (this.fuelMap) gain*=this.fuelMap[i];
|
| 80 |
+
newFire[i] = Math.min(1, fireGrid[i]+gain);
|
| 81 |
+
if (fireGrid[i]>=FIRE_BURNING) newTimers[i]++;
|
| 82 |
+
if (newTimers[i]>=BURNOUT_TICKS && newFire[i]>=1.0) {
|
| 83 |
+
cellGrid[i]=OBSTACLE; newFire[i]=0; newTimers[i]=0;
|
| 84 |
+
burned.push([x,y]);
|
| 85 |
+
}
|
| 86 |
+
} else if (ignite[i]) {
|
| 87 |
+
newFire[i]=FIRE_IGNITION; newTimers[i]=0;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
for (let i=0;i<fireGrid.length;i++){fireGrid[i]=newFire[i];burnTimers[i]=newTimers[i];}
|
| 91 |
+
this._spreadSmoke(cellGrid, fireGrid, smokeGrid);
|
| 92 |
+
return burned;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
_spreadSmoke(cellGrid, fireGrid, smokeGrid) {
|
| 96 |
+
const {w,h}=this;
|
| 97 |
+
const ns=smokeGrid.slice();
|
| 98 |
+
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
|
| 99 |
+
const i=y*w+x, ct=cellGrid[i];
|
| 100 |
+
if (ct===WALL||ct===OBSTACLE) continue;
|
| 101 |
+
if (fireGrid[i]>=FIRE_BURNING) ns[i]=Math.min(1, smokeGrid[i]+0.3);
|
| 102 |
+
for (const [dx,dy] of CARDINAL) {
|
| 103 |
+
const nx=x+dx, ny=y+dy;
|
| 104 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 105 |
+
const ni=ny*w+nx, nct=cellGrid[ni];
|
| 106 |
+
if (nct===WALL||nct===OBSTACLE) continue;
|
| 107 |
+
if (smokeGrid[i]>smokeGrid[ni]) {
|
| 108 |
+
const diff=smokeGrid[i]-smokeGrid[ni];
|
| 109 |
+
let rate=SMOKE_SPREAD_RATE;
|
| 110 |
+
if (nct===DOOR_CLOSED) rate*=SMOKE_DOOR_FACTOR;
|
| 111 |
+
ns[ni]=Math.min(1, ns[ni]+Math.min(diff*rate, diff*0.5));
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
const decay=this.ventMap?this.ventMap[i]:SMOKE_DECAY;
|
| 115 |
+
ns[i]=Math.max(0, ns[i]-decay);
|
| 116 |
+
}
|
| 117 |
+
for (let i=0;i<smokeGrid.length;i++) smokeGrid[i]=ns[i];
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// ─── Floor plan templates (mirrors floor_plan.py) ───────────────────────────
|
| 122 |
+
|
| 123 |
+
function makeSmallOffice() {
|
| 124 |
+
const W=16, H=16;
|
| 125 |
+
const rows=[
|
| 126 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 127 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 128 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 129 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 130 |
+
[1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
|
| 131 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 132 |
+
[4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 133 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 134 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
|
| 135 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 136 |
+
[1,1,2,1,1,1,2,1,1,1,2,1,1,1,2,1],
|
| 137 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 138 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 139 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 140 |
+
[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,1],
|
| 141 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 142 |
+
];
|
| 143 |
+
const grid=rows.flat();
|
| 144 |
+
return {
|
| 145 |
+
name:'small_office', w:W, h:H, grid,
|
| 146 |
+
exits:[[0,6],[15,8]],
|
| 147 |
+
doors:[[2,4],[6,4],[10,4],[14,4],[2,10],[6,10],[10,10],[14,10]],
|
| 148 |
+
agentSpawns: (() => {
|
| 149 |
+
const s=[];
|
| 150 |
+
for(let y=5;y<=9;y++) for(let x=1;x<=14;x++) if(grid[y*W+x]===0) s.push([x,y]);
|
| 151 |
+
return s;
|
| 152 |
+
})(),
|
| 153 |
+
zoneMap: buildZoneMap_SO(grid,W,H)
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
function buildZoneMap_SO(grid,W,H) {
|
| 158 |
+
const zm={};
|
| 159 |
+
for(let y=0;y<H;y++) for(let x=0;x<W;x++) {
|
| 160 |
+
const ct=grid[y*W+x];
|
| 161 |
+
if(ct===0) zm[`${x},${y}`]=y<=4?'north_offices':y>=5&&y<=9?'main_corridor':'south_offices';
|
| 162 |
+
else if(ct===4) zm[`${x},${y}`]='exit';
|
| 163 |
+
}
|
| 164 |
+
return zm;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function makeOpenPlan() {
|
| 168 |
+
const W=16, H=16;
|
| 169 |
+
const rows=[
|
| 170 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 171 |
+
[4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 172 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 173 |
+
[1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
|
| 174 |
+
[1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
|
| 175 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 176 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 177 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 178 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 179 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 180 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 181 |
+
[1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
|
| 182 |
+
[1,0,0,5,5,0,0,0,0,0,5,5,0,0,0,1],
|
| 183 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 184 |
+
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
| 185 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,4],
|
| 186 |
+
];
|
| 187 |
+
const grid=rows.flat();
|
| 188 |
+
const agentSpawns=[];
|
| 189 |
+
for(let y=1;y<H-1;y++) for(let x=1;x<W-1;x++) if(grid[y*W+x]===FLOOR) agentSpawns.push([x,y]);
|
| 190 |
+
return {name:'open_plan',w:W,h:H,grid,exits:[[0,1],[15,15]],doors:[],agentSpawns};
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function makeTCorridor() {
|
| 194 |
+
const W=16, H=16;
|
| 195 |
+
const rows=[
|
| 196 |
+
[1,1,1,1,1,1,1,4,1,1,1,1,1,1,1,1],
|
| 197 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 198 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 199 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 200 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 201 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 202 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 203 |
+
[4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4],
|
| 204 |
+
[1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
|
| 205 |
+
[1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
|
| 206 |
+
[1,1,2,1,1,2,1,0,1,2,1,1,1,1,2,1],
|
| 207 |
+
[1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
|
| 208 |
+
[1,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1],
|
| 209 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 210 |
+
[1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1],
|
| 211 |
+
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
| 212 |
+
];
|
| 213 |
+
const grid=rows.flat();
|
| 214 |
+
const agentSpawns=[];
|
| 215 |
+
for(let x=1;x<W-1;x++) if(grid[7*W+x]===FLOOR) agentSpawns.push([x,7]);
|
| 216 |
+
for(let y=4;y<8;y++) if(grid[y*W+7]===FLOOR) agentSpawns.push([7,y]);
|
| 217 |
+
return {name:'t_corridor',w:W,h:H,grid,exits:[[7,0],[0,7],[15,7]],doors:[[2,10],[5,10],[9,10],[14,10]],agentSpawns};
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const TEMPLATES = [makeSmallOffice(), makeOpenPlan(), makeTCorridor()];
|
| 221 |
+
|
| 222 |
+
// ─── BFS exit distance ──────────────────────────────────────────────────────
|
| 223 |
+
|
| 224 |
+
function bfsExitDist(ax, ay, exits, cellGrid, w, h) {
|
| 225 |
+
const blocked = exits.filter(([ex,ey])=>cellGrid[ey*w+ex]===OBSTACLE);
|
| 226 |
+
const targets = exits.filter(([ex,ey])=>cellGrid[ey*w+ex]!==OBSTACLE);
|
| 227 |
+
if (!targets.length) return 999;
|
| 228 |
+
const visited = new Set([`${ax},${ay}`]);
|
| 229 |
+
const queue = [[ax,ay,0]];
|
| 230 |
+
while (queue.length) {
|
| 231 |
+
const [x,y,d]=queue.shift();
|
| 232 |
+
for (const [dx,dy] of CARDINAL) {
|
| 233 |
+
const nx=x+dx, ny=y+dy;
|
| 234 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 235 |
+
const key=`${nx},${ny}`;
|
| 236 |
+
if (visited.has(key)) continue;
|
| 237 |
+
const ct=cellGrid[ny*w+nx];
|
| 238 |
+
if (ct===WALL||ct===OBSTACLE||ct===DOOR_CLOSED) continue;
|
| 239 |
+
if (targets.some(([ex,ey])=>ex===nx&&ey===ny)) return d+1;
|
| 240 |
+
visited.add(key);
|
| 241 |
+
queue.push([nx,ny,d+1]);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
return 999;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// ─── Fog of war (BFS, visibility radius shrinks under smoke) ────────────────
|
| 248 |
+
|
| 249 |
+
function computeVisible(ax, ay, cellGrid, smokeGrid, w, h) {
|
| 250 |
+
const smokeDensity = smokeGrid[ay*w+ax];
|
| 251 |
+
const radius = smokeDensity>0.5 ? 2 : smokeDensity>0.2 ? 3 : 5;
|
| 252 |
+
const visible = new Set([`${ax},${ay}`]);
|
| 253 |
+
const queue = [[ax,ay,0]];
|
| 254 |
+
const visited = new Set([`${ax},${ay}`]);
|
| 255 |
+
while (queue.length) {
|
| 256 |
+
const [x,y,d]=queue.shift();
|
| 257 |
+
if (d>=radius) continue;
|
| 258 |
+
for (const [dx,dy] of CARDINAL) {
|
| 259 |
+
const nx=x+dx, ny=y+dy;
|
| 260 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 261 |
+
const key=`${nx},${ny}`;
|
| 262 |
+
if (visited.has(key)) continue;
|
| 263 |
+
visited.add(key);
|
| 264 |
+
const ct=cellGrid[ny*w+nx];
|
| 265 |
+
if (ct===WALL) continue;
|
| 266 |
+
visible.add(key);
|
| 267 |
+
queue.push([nx,ny,d+1]);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
return visible;
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
// ─── Simple BFS demo agent ──────────────────────────────────────────────────
|
| 274 |
+
|
| 275 |
+
class DemoAgent {
|
| 276 |
+
constructor() { this.lastDir = null; this.stuckCount = 0; }
|
| 277 |
+
|
| 278 |
+
chooseAction(state) {
|
| 279 |
+
const { agentX:ax, agentY:ay, cellGrid, fireGrid, smokeGrid, exits, w, h } = state;
|
| 280 |
+
const dirs = ['north','south','east','west'];
|
| 281 |
+
const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
|
| 282 |
+
|
| 283 |
+
// Find best direction toward nearest exit
|
| 284 |
+
let bestDir = null, bestDist = Infinity;
|
| 285 |
+
for (const dir of dirs) {
|
| 286 |
+
const [dx,dy]=deltas[dir];
|
| 287 |
+
const nx=ax+dx, ny=ay+dy;
|
| 288 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 289 |
+
const ct=cellGrid[ny*w+nx];
|
| 290 |
+
if (ct===WALL||ct===OBSTACLE) continue;
|
| 291 |
+
if (ct===DOOR_CLOSED) {
|
| 292 |
+
// Try to open door if fire is not adjacent
|
| 293 |
+
return {action:'door', target_id:this._findDoor(state,nx,ny), door_state:'open'};
|
| 294 |
+
}
|
| 295 |
+
const fire=fireGrid[ny*w+nx];
|
| 296 |
+
if (fire>0.4) continue; // avoid fire
|
| 297 |
+
const d = bfsExitDist(nx,ny,exits,cellGrid,w,h);
|
| 298 |
+
if (d<bestDist) { bestDist=d; bestDir=dir; }
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
if (!bestDir || Math.random()<0.15) {
|
| 302 |
+
// Random valid move
|
| 303 |
+
const shuffled=[...dirs].sort(()=>Math.random()-0.5);
|
| 304 |
+
for (const dir of shuffled) {
|
| 305 |
+
const [dx,dy]=deltas[dir];
|
| 306 |
+
const nx=ax+dx, ny=ay+dy;
|
| 307 |
+
if (nx<0||nx>=w||ny<0||ny>=h) continue;
|
| 308 |
+
const ct=cellGrid[ny*w+nx];
|
| 309 |
+
if (ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED&&fireGrid[ny*w+nx]<0.4) {
|
| 310 |
+
return {action:'move',direction:dir};
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
return bestDir ? {action:'move',direction:bestDir} : {action:'wait'};
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
_findDoor(state, dx, dy) {
|
| 318 |
+
for (const [id,[x,y]] of Object.entries(state.doorRegistry||{})) {
|
| 319 |
+
if (x===dx&&y===dy) return id;
|
| 320 |
+
}
|
| 321 |
+
return `door_1`;
|
| 322 |
+
}
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// ─── Episode state machine ───────────────────────────────────────────────────
|
| 326 |
+
|
| 327 |
+
class PyreEpisode {
|
| 328 |
+
constructor(templateIdx=0, seed=42, difficulty='medium') {
|
| 329 |
+
const presets = {
|
| 330 |
+
easy: {nSources:[1,1], pSpread:[0.10,0.20], humidity:[0.30,0.50], winds:['CALM'], maxSteps:200},
|
| 331 |
+
medium: {nSources:[2,4], pSpread:[0.15,0.40], humidity:[0.10,0.45], winds:Object.keys(WIND_DIRS), maxSteps:150},
|
| 332 |
+
hard: {nSources:[3,5], pSpread:[0.30,0.55], humidity:[0.05,0.20], winds:Object.keys(WIND_DIRS).filter(d=>d!=='CALM'), maxSteps:100},
|
| 333 |
+
};
|
| 334 |
+
const p = presets[difficulty]||presets.medium;
|
| 335 |
+
const rng = makePRNG(seed);
|
| 336 |
+
const tpl = TEMPLATES[templateIdx % TEMPLATES.length];
|
| 337 |
+
|
| 338 |
+
// Deep-copy grid
|
| 339 |
+
this.cellGrid = tpl.grid.slice();
|
| 340 |
+
this.w = tpl.w; this.h = tpl.h;
|
| 341 |
+
this.exits = tpl.exits.map(e=>[...e]);
|
| 342 |
+
this.doors = (tpl.doors||[]).map(d=>[...d]);
|
| 343 |
+
this.doorRegistry = {};
|
| 344 |
+
for (let j=0;j<this.doors.length;j++) {
|
| 345 |
+
this.doorRegistry[`door_${j+1}`]=this.doors[j];
|
| 346 |
+
if (rng()<0.3) this.cellGrid[this.doors[j][1]*this.w+this.doors[j][0]]=DOOR_CLOSED;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
// Fire params
|
| 350 |
+
const [ns0,ns1]=p.nSources, [sp0,sp1]=p.pSpread, [hm0,hm1]=p.humidity;
|
| 351 |
+
const nSrc = Math.round(ns0 + rng()*(ns1-ns0));
|
| 352 |
+
const pSpread = sp0 + rng()*(sp1-sp0);
|
| 353 |
+
const humidity = hm0 + rng()*(hm1-hm0);
|
| 354 |
+
const windDir = p.winds[Math.floor(rng()*p.winds.length)];
|
| 355 |
+
|
| 356 |
+
this.pSpread = pSpread;
|
| 357 |
+
this.humidity = humidity;
|
| 358 |
+
this.windDir = windDir;
|
| 359 |
+
this.maxSteps = p.maxSteps;
|
| 360 |
+
this.templateName = tpl.name;
|
| 361 |
+
this.difficulty = difficulty;
|
| 362 |
+
this.seed = seed;
|
| 363 |
+
|
| 364 |
+
// Fire grids
|
| 365 |
+
this.fireGrid = new Float32Array(this.w*this.h);
|
| 366 |
+
this.smokeGrid = new Float32Array(this.w*this.h);
|
| 367 |
+
this.burnTimers = new Int32Array(this.w*this.h);
|
| 368 |
+
|
| 369 |
+
// Place fire sources
|
| 370 |
+
const floorCells = [];
|
| 371 |
+
for (let y=0;y<this.h;y++) for (let x=0;x<this.w;x++) {
|
| 372 |
+
if (this.cellGrid[y*this.w+x]===FLOOR) {
|
| 373 |
+
const farFromExit = this.exits.every(([ex,ey])=>Math.abs(x-ex)+Math.abs(y-ey)>=5);
|
| 374 |
+
if (farFromExit) floorCells.push([x,y]);
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
floorCells.sort(()=>rng()-0.5);
|
| 378 |
+
|
| 379 |
+
// Agent spawn
|
| 380 |
+
const spawn = tpl.agentSpawns[Math.floor(rng()*tpl.agentSpawns.length)];
|
| 381 |
+
this.agentX = spawn[0]; this.agentY = spawn[1];
|
| 382 |
+
this.agentHealth = 100.0;
|
| 383 |
+
this.agentAlive = true;
|
| 384 |
+
this.agentEvacuated = false;
|
| 385 |
+
this.stepCount = 0;
|
| 386 |
+
|
| 387 |
+
// Place fires far from agent
|
| 388 |
+
const fireSources = floorCells.filter(([fx,fy])=>Math.abs(fx-this.agentX)+Math.abs(fy-this.agentY)>=4).slice(0,nSrc);
|
| 389 |
+
for (const [fx,fy] of fireSources) this.fireGrid[fy*this.w+fx]=FIRE_IGNITION;
|
| 390 |
+
|
| 391 |
+
// Fire sim
|
| 392 |
+
this.sim = new FireSim(this.w, this.h, makePRNG(seed+1), pSpread, windDir, humidity);
|
| 393 |
+
|
| 394 |
+
// Tracking
|
| 395 |
+
this.visibleCells = new Set();
|
| 396 |
+
this.exploreSet = new Set();
|
| 397 |
+
this._updateVisibility();
|
| 398 |
+
|
| 399 |
+
// Reward tracking
|
| 400 |
+
this.totalReward = 0;
|
| 401 |
+
this.rewardHistory = [];
|
| 402 |
+
this.fireSizeHistory = [];
|
| 403 |
+
this.actionCounts = {move:0, door:0, look:0, wait:0};
|
| 404 |
+
this.eventLog = [];
|
| 405 |
+
this.lastAction = null;
|
| 406 |
+
this.lastReward = 0;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
_updateVisibility() {
|
| 410 |
+
this.visibleCells = computeVisible(this.agentX, this.agentY, this.cellGrid, this.smokeGrid, this.w, this.h);
|
| 411 |
+
if (!this.exploreSet) this.exploreSet = new Set();
|
| 412 |
+
for (const k of this.visibleCells) this.exploreSet.add(k);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
_fireSize() {
|
| 416 |
+
let count=0;
|
| 417 |
+
for (let i=0;i<this.fireGrid.length;i++) if(this.fireGrid[i]>=FIRE_BURNING) count++;
|
| 418 |
+
return count;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
step(action) {
|
| 422 |
+
if (!this.agentAlive || this.agentEvacuated || this.stepCount>=this.maxSteps) return false;
|
| 423 |
+
|
| 424 |
+
const prevX=this.agentX, prevY=this.agentY;
|
| 425 |
+
let feedback = '';
|
| 426 |
+
|
| 427 |
+
// Execute action
|
| 428 |
+
const deltas = {north:[0,-1],south:[0,1],east:[1,0],west:[-1,0]};
|
| 429 |
+
if (action.action==='move') {
|
| 430 |
+
const [dx,dy]=deltas[action.direction]||[0,0];
|
| 431 |
+
const nx=this.agentX+dx, ny=this.agentY+dy;
|
| 432 |
+
if (nx>=0&&nx<this.w&&ny>=0&&ny<this.h) {
|
| 433 |
+
const ct=this.cellGrid[ny*this.w+nx];
|
| 434 |
+
if (ct!==WALL&&ct!==OBSTACLE&&ct!==DOOR_CLOSED) {
|
| 435 |
+
this.agentX=nx; this.agentY=ny;
|
| 436 |
+
feedback=`Moved ${action.direction}`;
|
| 437 |
+
} else feedback=`Blocked: ${ct===DOOR_CLOSED?'door closed':'wall'}`;
|
| 438 |
+
}
|
| 439 |
+
this.actionCounts.move++;
|
| 440 |
+
} else if (action.action==='door') {
|
| 441 |
+
const doorPos = this.doorRegistry[action.target_id];
|
| 442 |
+
if (doorPos) {
|
| 443 |
+
const [dx,dy]=doorPos;
|
| 444 |
+
if (Math.abs(dx-this.agentX)+Math.abs(dy-this.agentY)<=2) {
|
| 445 |
+
const cur=this.cellGrid[dy*this.w+dx];
|
| 446 |
+
if (action.door_state==='open'&&cur===DOOR_CLOSED) {
|
| 447 |
+
this.cellGrid[dy*this.w+dx]=DOOR_OPEN;
|
| 448 |
+
feedback=`Opened ${action.target_id}`;
|
| 449 |
+
} else if (action.door_state==='close'&&cur===DOOR_OPEN) {
|
| 450 |
+
this.cellGrid[dy*this.w+dx]=DOOR_CLOSED;
|
| 451 |
+
feedback=`Closed ${action.target_id} — slowing fire`;
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
this.actionCounts.door++;
|
| 456 |
+
} else if (action.action==='wait') {
|
| 457 |
+
feedback='Waiting...'; this.actionCounts.wait++;
|
| 458 |
+
} else if (action.action==='look') {
|
| 459 |
+
feedback=`Looking ${action.direction||''}`; this.actionCounts.look++;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
// Check evacuation
|
| 463 |
+
const agentCell=this.cellGrid[this.agentY*this.w+this.agentX];
|
| 464 |
+
if (agentCell===EXIT && this.fireGrid[this.agentY*this.w+this.agentX]<EXIT_BLOCKED_FIRE_THRESHOLD) {
|
| 465 |
+
this.agentEvacuated=true;
|
| 466 |
+
feedback='EVACUATED — reached safety!';
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
// Advance fire
|
| 470 |
+
const burned = this.sim.step(this.cellGrid, this.fireGrid, this.smokeGrid, this.burnTimers);
|
| 471 |
+
|
| 472 |
+
// Health damage
|
| 473 |
+
const ai=this.agentY*this.w+this.agentX;
|
| 474 |
+
const smoke=this.smokeGrid[ai], fire=this.fireGrid[ai];
|
| 475 |
+
let dmg=0;
|
| 476 |
+
if (smoke>=0.8) dmg+=5; else if (smoke>=0.5) dmg+=2; else if (smoke>=0.2) dmg+=0.5;
|
| 477 |
+
if (fire>=FIRE_BURNING) dmg+=10;
|
| 478 |
+
this.agentHealth=Math.max(0, this.agentHealth-dmg);
|
| 479 |
+
if (this.agentHealth<=0) { this.agentAlive=false; feedback='Incapacitated by fire and smoke!'; }
|
| 480 |
+
|
| 481 |
+
this.stepCount++;
|
| 482 |
+
this._updateVisibility();
|
| 483 |
+
|
| 484 |
+
// Reward (simplified rubric)
|
| 485 |
+
const prevDist=bfsExitDist(prevX,prevY,this.exits,this.cellGrid,this.w,this.h);
|
| 486 |
+
const curDist=bfsExitDist(this.agentX,this.agentY,this.exits,this.cellGrid,this.w,this.h);
|
| 487 |
+
let reward=-0.01;
|
| 488 |
+
if (curDist<prevDist) reward+=0.1;
|
| 489 |
+
if (smoke>=0.5||fire>=FIRE_BURNING) reward-=0.5;
|
| 490 |
+
reward-=0.02*dmg;
|
| 491 |
+
if (this.agentEvacuated) reward+=5.0+(0.05*Math.max(0,this.maxSteps-this.stepCount));
|
| 492 |
+
if (!this.agentAlive) reward-=10.0;
|
| 493 |
+
if (action.action==='door'&&action.door_state==='close') reward+=0.5;
|
| 494 |
+
|
| 495 |
+
this.lastReward=Math.round(reward*1000)/1000;
|
| 496 |
+
this.totalReward=Math.round((this.totalReward+reward)*1000)/1000;
|
| 497 |
+
this.rewardHistory.push(this.totalReward);
|
| 498 |
+
this.fireSizeHistory.push(this._fireSize());
|
| 499 |
+
|
| 500 |
+
if (feedback) this.eventLog.unshift({step:this.stepCount, text:feedback, reward:this.lastReward});
|
| 501 |
+
if (this.eventLog.length>50) this.eventLog.pop();
|
| 502 |
+
if (burned.length) this.eventLog.unshift({step:this.stepCount, text:`${burned.length} cell(s) burned out`, reward:0, isAlert:true});
|
| 503 |
+
|
| 504 |
+
this.lastAction = action;
|
| 505 |
+
return !this.agentEvacuated && this.agentAlive && this.stepCount<this.maxSteps;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
get done() { return !this.agentAlive||this.agentEvacuated||this.stepCount>=this.maxSteps; }
|
| 509 |
+
get percentBurned() { return Math.round(100*Array.from(this.fireGrid).filter(f=>f>0).length/(this.w*this.h)); }
|
| 510 |
+
get exitBlocked() { return this.exits.filter(([ex,ey])=>this.fireGrid[ey*this.w+ex]>=EXIT_BLOCKED_FIRE_THRESHOLD); }
|
| 511 |
+
get smokeLevel() {
|
| 512 |
+
const s=this.smokeGrid[this.agentY*this.w+this.agentX];
|
| 513 |
+
return s>=0.8?'heavy':s>=0.5?'moderate':s>=0.2?'light':'clear';
|
| 514 |
+
}
|
| 515 |
+
get healthStatus() {
|
| 516 |
+
if(this.agentHealth>75) return 'Good';
|
| 517 |
+
if(this.agentHealth>50) return 'Moderate';
|
| 518 |
+
if(this.agentHealth>25) return 'Low';
|
| 519 |
+
return 'Critical';
|
| 520 |
+
}
|
| 521 |
+
}
|
openenv_pyre_env.egg-info/PKG-INFO
CHANGED
|
@@ -13,3 +13,15 @@ Requires-Dist: langchain-openai>=1.2.1
|
|
| 13 |
Provides-Extra: dev
|
| 14 |
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 15 |
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
Provides-Extra: dev
|
| 14 |
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
| 15 |
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
| 16 |
+
Provides-Extra: train
|
| 17 |
+
Requires-Dist: datasets>=4.8.4; extra == "train"
|
| 18 |
+
Requires-Dist: peft>=0.15.2; extra == "train"
|
| 19 |
+
Requires-Dist: tensorboard>=2.20.0; extra == "train"
|
| 20 |
+
Requires-Dist: torch>=2.9.0; extra == "train"
|
| 21 |
+
Requires-Dist: transformers>=4.57.6; extra == "train"
|
| 22 |
+
Requires-Dist: trl>=1.2.0; extra == "train"
|
| 23 |
+
Requires-Dist: tornado>=6.5.5; extra == "train"
|
| 24 |
+
Requires-Dist: vllm<=0.18.0,>=0.11.0; extra == "train"
|
| 25 |
+
Requires-Dist: flashinfer-python==0.6.3; extra == "train"
|
| 26 |
+
Requires-Dist: flashinfer-cubin==0.6.3; extra == "train"
|
| 27 |
+
Requires-Dist: jupyter>=1.1.1; extra == "train"
|
openenv_pyre_env.egg-info/SOURCES.txt
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
README.md
|
| 2 |
__init__.py
|
| 3 |
client.py
|
|
|
|
| 4 |
models.py
|
| 5 |
pyproject.toml
|
|
|
|
| 6 |
./__init__.py
|
| 7 |
./client.py
|
| 8 |
./evals.py
|
|
|
|
| 1 |
README.md
|
| 2 |
__init__.py
|
| 3 |
client.py
|
| 4 |
+
evals.py
|
| 5 |
models.py
|
| 6 |
pyproject.toml
|
| 7 |
+
train_grpo_openenv.py
|
| 8 |
./__init__.py
|
| 9 |
./client.py
|
| 10 |
./evals.py
|
openenv_pyre_env.egg-info/requires.txt
CHANGED
|
@@ -9,3 +9,16 @@ langchain-openai>=1.2.1
|
|
| 9 |
[dev]
|
| 10 |
pytest>=8.0.0
|
| 11 |
pytest-cov>=4.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
[dev]
|
| 10 |
pytest>=8.0.0
|
| 11 |
pytest-cov>=4.0.0
|
| 12 |
+
|
| 13 |
+
[train]
|
| 14 |
+
datasets>=4.8.4
|
| 15 |
+
peft>=0.15.2
|
| 16 |
+
tensorboard>=2.20.0
|
| 17 |
+
torch>=2.9.0
|
| 18 |
+
transformers>=4.57.6
|
| 19 |
+
trl>=1.2.0
|
| 20 |
+
tornado>=6.5.5
|
| 21 |
+
vllm<=0.18.0,>=0.11.0
|
| 22 |
+
flashinfer-python==0.6.3
|
| 23 |
+
flashinfer-cubin==0.6.3
|
| 24 |
+
jupyter>=1.1.1
|
pyproject.toml
CHANGED
|
@@ -28,6 +28,19 @@ dev = [
|
|
| 28 |
"pytest>=8.0.0",
|
| 29 |
"pytest-cov>=4.0.0",
|
| 30 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
[project.scripts]
|
| 33 |
# Server entry point - enables running via: uv run --project . server
|
|
|
|
| 28 |
"pytest>=8.0.0",
|
| 29 |
"pytest-cov>=4.0.0",
|
| 30 |
]
|
| 31 |
+
train = [
|
| 32 |
+
"datasets>=4.8.4",
|
| 33 |
+
"peft>=0.15.2",
|
| 34 |
+
"tensorboard>=2.20.0",
|
| 35 |
+
"torch>=2.9.0",
|
| 36 |
+
"transformers>=4.57.6",
|
| 37 |
+
"trl>=1.2.0",
|
| 38 |
+
"tornado>=6.5.5",
|
| 39 |
+
"vllm>=0.11.0,<=0.18.0",
|
| 40 |
+
"flashinfer-python==0.6.3",
|
| 41 |
+
"flashinfer-cubin==0.6.3",
|
| 42 |
+
"jupyter>=1.1.1",
|
| 43 |
+
]
|
| 44 |
|
| 45 |
[project.scripts]
|
| 46 |
# Server entry point - enables running via: uv run --project . server
|
server/static/assets/index-8TVqpTW-.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg:#f5f2ed;--s1:#fff;--s2:#faf9f6;--s3:#f0ede8;--bd:#e4dfd7;--bd2:#cdc7be;--t1:#1c1712;--t2:#6b6460;--t3:#a8a29e;--fire:#c2410c;--fire2:#c2410c14;--blue:#1d4ed8;--blue2:#1d4ed814;--green:#166534;--green2:#16653414;--amber:#92400e;--amber2:#92400e14;--red:#b91c1c;--red2:#b91c1c14;--gold:#d97706;--gold2:#d977061f;--mono:"DM Mono", "JetBrains Mono", monospace;--sans:"DM Sans", "Inter", system-ui, sans-serif;--r:8px}body{background:var(--bg);color:var(--t1);font-family:var(--sans);font-size:13px;line-height:1.5;overflow:hidden}.shell{grid-template-rows:48px 1fr;height:100vh;display:grid;overflow:hidden}.topbar{background:var(--s1);border-bottom:1px solid var(--bd);z-index:20;align-items:center;gap:16px;padding:0 20px;display:flex;box-shadow:0 1px 3px #0000000a}.brand{align-items:center;gap:10px;display:flex}.brand-icon{background:linear-gradient(135deg,#c2410c,#f97316);border-radius:6px;justify-content:center;align-items:center;width:28px;height:28px;font-size:14px;display:flex}.brand-name{font-family:var(--mono);color:var(--t1);letter-spacing:.06em;text-transform:uppercase;font-size:15px;font-weight:500}.brand-sep{color:var(--bd2);margin:0 2px}.brand-sub{color:var(--t3);letter-spacing:.08em;font-size:11px;font-family:var(--mono)}.topbar-right{align-items:center;gap:12px;margin-left:auto;display:flex}.live-chip{font-family:var(--mono);letter-spacing:.08em;text-transform:uppercase;border:1px solid var(--bd);color:var(--t3);background:var(--s2);border-radius:20px;align-items:center;gap:5px;padding:4px 10px;font-size:10px;font-weight:500;display:flex}.live-chip:before{content:"";background:var(--bd2);border-radius:50%;width:6px;height:6px}.live-chip.online{color:var(--green);background:var(--green2);border-color:#16653433}.live-chip.online:before{background:#22c55e;animation:1.2s infinite blink}.live-chip.offline{color:var(--t3);background:var(--s2)}.live-chip.error{color:var(--red);background:var(--red2);border-color:#b91c1c33}.live-chip.error:before{background:var(--red)}@keyframes blink{0%,to{opacity:1}50%{opacity:.3}}.topbar-ep{font-family:var(--mono);color:var(--t3);align-items:center;gap:6px;font-size:10px;display:flex}.topbar-ep .ep-id{color:var(--blue);font-weight:500}.content{grid-template-columns:1fr 300px;height:100%;display:grid;overflow:hidden}.canvas-zone{background:var(--bg);flex-direction:column;justify-content:flex-start;align-items:center;gap:16px;padding:20px;display:flex;overflow-y:auto}.canvas-frame{box-shadow:0 0 0 1px var(--bd2), 0 4px 24px #0000001a, 0 1px 4px #0000000f;background:#d4c9a8;border-radius:10px;flex-shrink:0;line-height:0;position:relative;overflow:hidden}#map-canvas{max-width:100%;max-height:calc(100vh - 168px);image-rendering:pixelated;display:block}.hud-overlay{pointer-events:none;z-index:10;justify-content:space-between;gap:8px;padding:10px;display:flex;position:absolute;top:0;left:0;right:0}.hud-card{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#ede6dc;background:#0c0805d1;border:1px solid #ffffff1a;border-radius:6px;flex-direction:column;gap:3px;min-width:130px;padding:7px 10px;display:flex}.hud-card-center{border-color:#fbbf242e;min-width:160px}.hud-r{align-items:center;gap:6px;display:flex}.hlbl{font-family:var(--mono);color:#a8a29eb3;letter-spacing:.1em;text-transform:uppercase;flex-shrink:0;font-size:9px}.hval{font-family:var(--mono);color:#f0e8e0;text-align:right;min-width:26px;margin-left:auto;font-size:13px;font-weight:500}.hbar-bg{background:#ffffff1a;border-radius:3px;flex:1;height:5px;overflow:hidden}.hbar-fill{border-radius:3px;height:100%;transition:width .35s}.hbar-fill.g{background:linear-gradient(90deg,#22c55e,#4ade80)}.hbar-fill.m{background:linear-gradient(90deg,#f59e0b,#fbbf24)}.hbar-fill.c{background:linear-gradient(90deg,#ef4444,#f87171)}.hstatus{font-family:var(--mono);letter-spacing:.06em;text-transform:uppercase;border-radius:3px;padding:2px 6px;font-size:9px;font-weight:500}.hstatus.good{color:#4ade80;background:#4ade801f}.hstatus.moderate{color:#fbbf24;background:#fbbf241f}.hstatus.low{color:#fb923c;background:#fb923c1f}.hstatus.critical{color:#f87171;background:#f871711f;animation:1s infinite pulse-red}@keyframes pulse-red{0%,to{opacity:1}50%{opacity:.5}}.step-val{font-family:var(--mono);color:#f0e8e0;font-size:12px;font-weight:500}.sbar-bg{background:#ffffff1a;border-radius:2px;width:100%;height:3px;margin-top:1px;overflow:hidden}.sbar-fill{background:linear-gradient(90deg, var(--blue), #60a5fa);border-radius:2px;height:100%;transition:width .35s}.step-meta{font-family:var(--mono);color:#a8a29e8c;letter-spacing:.06em;text-transform:uppercase;margin-top:1px;font-size:8px}.legend{background:var(--s1);border:1px solid var(--bd);border-radius:6px;flex-wrap:wrap;align-items:center;gap:14px;width:100%;max-width:640px;padding:8px 12px;display:flex}.legend-item{font-family:var(--mono);color:var(--t2);letter-spacing:.05em;text-transform:uppercase;align-items:center;gap:5px;font-size:9px;display:flex}.leg-swatch{border-radius:2px;flex-shrink:0;width:12px;height:12px}.dialog{background:var(--s1);border:1px solid var(--bd);border-left:3px solid var(--fire);border-radius:var(--r);font-family:var(--mono);color:var(--t2);width:100%;max-width:640px;padding:10px 14px;font-size:12px;line-height:1.5}.dialog-who{color:var(--t3);text-transform:uppercase;letter-spacing:.1em;margin-bottom:3px;font-size:9px;display:block}.side{background:var(--s1);border-left:1px solid var(--bd);flex-direction:column;display:flex;overflow:hidden auto}.side-sec{border-bottom:1px solid var(--bd);padding:14px 16px}.sec-hd{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.12em;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:9px;font-weight:600;display:flex}.sg{grid-template-columns:1fr 1fr;gap:6px;display:grid}.sc{background:var(--s2);border:1px solid var(--bd);border-radius:6px;padding:8px 10px}.sc-l{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.08em;margin-bottom:2px;font-size:9px}.sc-v{font-family:var(--mono);color:var(--t1);font-size:14px;font-weight:500}.sc-v.fire{color:var(--fire)}.sc-v.blue{color:var(--blue)}.sc-v.green{color:var(--green)}.sc-v.amber{color:var(--amber)}.sc-v.gold{color:var(--gold)}.srow{border-bottom:1px solid #00000008;justify-content:space-between;align-items:center;padding:5px 0;font-size:12px;display:flex}.srow:last-child{border-bottom:none}.sv{font-family:var(--mono);font-weight:500}.sv.blue{color:var(--blue)}.sv.green{color:var(--green)}.sv.hot{color:var(--fire)}.sv.danger{color:var(--red)}.sv.warn{color:var(--amber)}.sv.gold{color:var(--gold)}.bar-w{margin-top:10px}.bar-lbl{font-family:var(--mono);color:var(--t3);text-transform:uppercase;letter-spacing:.06em;justify-content:space-between;margin-bottom:5px;font-size:9px;display:flex}.bar-bg{background:var(--s2);border:1px solid var(--bd);border-radius:4px;height:6px;overflow:hidden}.bar-fill{border-radius:4px;height:100%;transition:width .4s cubic-bezier(.4,0,.2,1)}.elog{flex-direction:column;gap:2px;max-height:160px;display:flex;overflow-y:auto}.erow{background:0 0;border-radius:4px;align-items:flex-start;gap:6px;padding:4px 6px;font-size:11px;display:flex}.erow.alarm{background:var(--red2);border-left:2px solid var(--red)}.erow.warn-row{background:var(--amber2);border-left:2px solid var(--amber)}.estep{font-family:var(--mono);color:var(--t3);flex-shrink:0;min-width:26px;padding-top:1px;font-size:9px}.etext{color:var(--t2);flex:1;font-size:11px;line-height:1.4}.erwd{font-family:var(--mono);flex-shrink:0;padding-top:1px;font-size:9px;font-weight:500}.erwd.p{color:var(--green)}.erwd.n{color:var(--red)}.dpad{grid-template-columns:repeat(3,1fr);gap:5px;max-width:140px;margin:0 auto;display:grid}.dpad-center{justify-content:center;align-items:center;display:flex}.ctrl-grid{grid-template-columns:repeat(3,1fr);gap:5px;display:grid}.ctrl-row{gap:5px;margin-top:8px;display:flex}.ctrl-btn{background:var(--s2);color:var(--t2);border:1px solid var(--bd);font-family:var(--mono);letter-spacing:.04em;cursor:pointer;text-align:center;border-radius:5px;flex:1;padding:7px 6px;font-size:10px;font-weight:500;transition:all .1s}.ctrl-btn:hover:not(:disabled){background:var(--s3);color:var(--t1);border-color:var(--bd2)}.ctrl-btn:disabled{opacity:.4;cursor:not-allowed}.ctrl-btn.accent{background:var(--fire2);color:var(--fire);border-color:#c2410c40}.ctrl-btn.accent:hover{color:var(--fire);background:#c2410c26}.ctrl-btn.active{background:var(--blue2);color:var(--blue);border-color:#1d4ed840}.ctrl-btn.active:hover{background:#1d4ed826}.ctrl-btn.play{color:#fff;background:linear-gradient(135deg,#c2410c,#f97316);border:none;font-size:11px;box-shadow:0 2px 8px #c2410c4d}.ctrl-btn.play:hover{filter:brightness(1.05);box-shadow:0 3px 12px #c2410c73}.ctrl-status{font-family:var(--mono);color:var(--t3);background:var(--s2);border:1px solid var(--bd);border-left:3px solid var(--bd2);border-radius:5px;margin-top:10px;padding:7px 10px;font-size:10px}.ctrl-status.error{color:var(--red);background:var(--red2);border-left-color:var(--red)}.door-grid{grid-template-columns:1fr 1fr;gap:4px;display:grid}.door-btn{background:var(--s2);border:1px solid var(--bd);font-family:var(--mono);cursor:pointer;color:var(--t2);text-align:center;border-radius:5px;padding:5px 4px;font-size:9px;transition:all .1s}.door-btn:hover{background:var(--s3);color:var(--t1)}.door-btn.open{color:var(--green);background:var(--green2);border-color:#16653440}.door-btn.closed{color:var(--amber);background:var(--amber2);border-color:#92400e40}.door-btn.failed{color:var(--red);background:var(--red2);border-color:#b91c1c40}.report-box{background:var(--s2);border:1px solid var(--bd);max-height:160px;font-family:var(--mono);color:var(--t2);white-space:pre-wrap;border-radius:5px;padding:10px;font-size:9px;line-height:1.5;overflow-y:auto}::-webkit-scrollbar{width:4px;height:4px}::-webkit-scrollbar-track{background:0 0}::-webkit-scrollbar-thumb{background:var(--bd2);border-radius:2px}
|
server/static/assets/index-CWSK8ZuR.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
server/static/index.html
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>
|
| 8 |
-
<
|
| 9 |
-
<link rel="
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
<
|
| 13 |
-
</
|
| 14 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Pyre — Crisis Navigation</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
| 11 |
+
<script type="module" crossorigin src="/assets/index-CWSK8ZuR.js"></script>
|
| 12 |
+
<link rel="stylesheet" crossorigin href="/assets/index-8TVqpTW-.css">
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="root"></div>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
train_grpo_openenv.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
train_grpo_openenv.py — GRPO + LoRA training for Pyre
|
| 3 |
-
(
|
| 4 |
|
| 5 |
Mirrors train_grpo_openenv.py from the reference as closely as possible.
|
| 6 |
Differences vs. the reference are intentional and limited to:
|
| 7 |
|
| 8 |
-
1. Environment is
|
| 9 |
-
The reference calls WorldState in-process; Pyre
|
| 10 |
-
|
| 11 |
|
| 12 |
2. Pyre is a single-environment, difficulty-parametrised task. For this initial
|
| 13 |
training run we fix difficulty to "easy" only (1 fire source, slow spread,
|
|
@@ -31,10 +31,6 @@ Differences vs. the reference are intentional and limited to:
|
|
| 31 |
|
| 32 |
Usage
|
| 33 |
─────
|
| 34 |
-
# 1. Start the Pyre server (from pyre_env/):
|
| 35 |
-
# uv run server
|
| 36 |
-
#
|
| 37 |
-
# 2. Run training (from pyre_env/):
|
| 38 |
python train_grpo_openenv.py
|
| 39 |
python train_grpo_openenv.py --model-id Qwen/Qwen3-1.7B --dataset-size 1000
|
| 40 |
|
|
@@ -77,7 +73,8 @@ _PROJECT_ROOT = Path(__file__).resolve().parent
|
|
| 77 |
if str(_PROJECT_ROOT) not in sys.path:
|
| 78 |
sys.path.insert(0, str(_PROJECT_ROOT))
|
| 79 |
|
| 80 |
-
from pyre_env import
|
|
|
|
| 81 |
|
| 82 |
logging.basicConfig(
|
| 83 |
level=logging.INFO,
|
|
@@ -86,9 +83,6 @@ logging.basicConfig(
|
|
| 86 |
)
|
| 87 |
log = logging.getLogger("grpo_openenv")
|
| 88 |
|
| 89 |
-
# Server URL — set PYRE_SERVER_URL in pyre_env/.env to override
|
| 90 |
-
ENV_URL = os.getenv("PYRE_SERVER_URL", "http://localhost:8000")
|
| 91 |
-
|
| 92 |
|
| 93 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 94 |
# 1. Difficulty registry + stratified mix (notebook "cell-1" analogue)
|
|
@@ -322,81 +316,79 @@ def rollout_once(
|
|
| 322 |
steps_taken: int = 0
|
| 323 |
think_steps: int = 0
|
| 324 |
|
| 325 |
-
MAX_TOK_ACCUM =
|
| 326 |
|
| 327 |
try:
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
enable_thinking=True,
|
| 350 |
-
)
|
| 351 |
-
except TypeError:
|
| 352 |
-
prompt_text = tokenizer.apply_chat_template(
|
| 353 |
-
messages,
|
| 354 |
-
add_generation_prompt=True,
|
| 355 |
-
tokenize=False,
|
| 356 |
-
)
|
| 357 |
-
|
| 358 |
-
log.debug(
|
| 359 |
-
"\n[difficulty=%s seed=%d step=%d] ── PROMPT ──────────────────────────\n%s\n────────────────────────────────────",
|
| 360 |
-
difficulty, seed, _turn + 1, prompt_text,
|
| 361 |
)
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
completion_text = rollout_outputs.get("text") or tokenizer.decode(
|
| 368 |
-
rollout_outputs["completion_ids"], skip_special_tokens=False
|
| 369 |
)
|
| 370 |
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
| 382 |
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
done = result.done
|
| 387 |
|
| 388 |
-
|
|
|
|
| 389 |
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
f"Step {_turn + 1}: {json.dumps(action_dict)}"
|
| 394 |
-
+ (f"\n → {feedback}" if feedback else "")
|
| 395 |
-
+ f"\n health: {obs.agent_health:.1f}"
|
| 396 |
-
)
|
| 397 |
|
| 398 |
-
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
except Exception as exc:
|
| 402 |
log.error("Episode failed (difficulty=%s seed=%d): %s", difficulty, seed, exc)
|
|
@@ -568,17 +560,6 @@ if __name__ == "__main__":
|
|
| 568 |
logging.getLogger().setLevel(logging.DEBUG)
|
| 569 |
log.debug("Debug logging enabled — full prompt/completion will be printed each step.")
|
| 570 |
|
| 571 |
-
# ── Server health check ─────────────────────────────────────────────────
|
| 572 |
-
import requests as _requests
|
| 573 |
-
try:
|
| 574 |
-
_requests.get(f"{ENV_URL}/health", timeout=5).raise_for_status()
|
| 575 |
-
log.info("Pyre server: healthy ✓ (%s)", ENV_URL)
|
| 576 |
-
except Exception as exc:
|
| 577 |
-
raise SystemExit(
|
| 578 |
-
f"Pyre server not reachable at {ENV_URL}: {exc}\n"
|
| 579 |
-
"Start it with: uv run server"
|
| 580 |
-
)
|
| 581 |
-
|
| 582 |
# ── Init tokenizer ──────────────────────────────────────────────────────
|
| 583 |
tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=True)
|
| 584 |
if tokenizer.pad_token is None:
|
|
@@ -595,22 +576,26 @@ if __name__ == "__main__":
|
|
| 595 |
#
|
| 596 |
# Matching the reference notebook, with one Pyre-specific override:
|
| 597 |
#
|
| 598 |
-
# max_completion_length
|
| 599 |
#
|
| 600 |
# Everything else — lr, grad_accum, num_generations, vLLM colocate,
|
| 601 |
# gradient checkpointing, push_to_hub — is identical to the reference.
|
| 602 |
grpo_config = GRPOConfig(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
num_train_epochs=1,
|
| 604 |
learning_rate=5e-6,
|
| 605 |
gradient_accumulation_steps=64,
|
| 606 |
per_device_train_batch_size=1,
|
| 607 |
warmup_steps=20,
|
| 608 |
num_generations=2,
|
| 609 |
-
max_completion_length=
|
| 610 |
use_vllm=True,
|
| 611 |
vllm_mode="colocate",
|
| 612 |
vllm_gpu_memory_utilization=0.3,
|
| 613 |
-
|
| 614 |
output_dir=args.output_dir,
|
| 615 |
report_to=args.report_to,
|
| 616 |
trackio_space_id=args.output_dir if args.report_to == "trackio" else None,
|
|
@@ -625,10 +610,6 @@ if __name__ == "__main__":
|
|
| 625 |
# ── Create trainer ──────────────────────────────────────────────────────
|
| 626 |
trainer = GRPOTrainer(
|
| 627 |
model=args.model_id,
|
| 628 |
-
model_init_kwargs={
|
| 629 |
-
"torch_dtype": torch.bfloat16,
|
| 630 |
-
"attn_implementation": "eager",
|
| 631 |
-
},
|
| 632 |
processing_class=tokenizer,
|
| 633 |
reward_funcs=[
|
| 634 |
reward_evacuated,
|
|
|
|
| 1 |
"""
|
| 2 |
train_grpo_openenv.py — GRPO + LoRA training for Pyre
|
| 3 |
+
(PyreEnvironment in-process, OpenEnv notebook-style pipeline)
|
| 4 |
|
| 5 |
Mirrors train_grpo_openenv.py from the reference as closely as possible.
|
| 6 |
Differences vs. the reference are intentional and limited to:
|
| 7 |
|
| 8 |
+
1. Environment is PyreEnvironment, called in-process (no server needed).
|
| 9 |
+
The reference calls WorldState in-process; Pyre uses the same approach —
|
| 10 |
+
PyreEnvironment is instantiated directly, so no `uv run server` is required.
|
| 11 |
|
| 12 |
2. Pyre is a single-environment, difficulty-parametrised task. For this initial
|
| 13 |
training run we fix difficulty to "easy" only (1 fire source, slow spread,
|
|
|
|
| 31 |
|
| 32 |
Usage
|
| 33 |
─────
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
python train_grpo_openenv.py
|
| 35 |
python train_grpo_openenv.py --model-id Qwen/Qwen3-1.7B --dataset-size 1000
|
| 36 |
|
|
|
|
| 73 |
if str(_PROJECT_ROOT) not in sys.path:
|
| 74 |
sys.path.insert(0, str(_PROJECT_ROOT))
|
| 75 |
|
| 76 |
+
from pyre_env import PyreAction # noqa: E402
|
| 77 |
+
from pyre_env.server.pyre_env_environment import PyreEnvironment # noqa: E402
|
| 78 |
|
| 79 |
logging.basicConfig(
|
| 80 |
level=logging.INFO,
|
|
|
|
| 83 |
)
|
| 84 |
log = logging.getLogger("grpo_openenv")
|
| 85 |
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
# ─────────────────────────────────────────────────────────────────────────────
|
| 88 |
# 1. Difficulty registry + stratified mix (notebook "cell-1" analogue)
|
|
|
|
| 316 |
steps_taken: int = 0
|
| 317 |
think_steps: int = 0
|
| 318 |
|
| 319 |
+
MAX_TOK_ACCUM = 8192
|
| 320 |
|
| 321 |
try:
|
| 322 |
+
env = PyreEnvironment()
|
| 323 |
+
obs = env.reset(difficulty=difficulty, seed=seed)
|
| 324 |
+
done = obs.done
|
| 325 |
+
|
| 326 |
+
for _turn in range(max_turns):
|
| 327 |
+
if done or len(completion_ids) >= MAX_TOK_ACCUM:
|
| 328 |
+
break
|
| 329 |
+
|
| 330 |
+
obs_dict = obs.model_dump()
|
| 331 |
+
user_prompt = make_user_prompt(obs_dict, history)
|
| 332 |
+
messages = [
|
| 333 |
+
{"role": "system", "content": system_prompt},
|
| 334 |
+
{"role": "user", "content": user_prompt},
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
try:
|
| 338 |
+
prompt_text = tokenizer.apply_chat_template(
|
| 339 |
+
messages,
|
| 340 |
+
add_generation_prompt=True,
|
| 341 |
+
tokenize=False,
|
| 342 |
+
enable_thinking=True,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
)
|
| 344 |
+
except TypeError:
|
| 345 |
+
prompt_text = tokenizer.apply_chat_template(
|
| 346 |
+
messages,
|
| 347 |
+
add_generation_prompt=True,
|
| 348 |
+
tokenize=False,
|
|
|
|
|
|
|
| 349 |
)
|
| 350 |
|
| 351 |
+
log.debug(
|
| 352 |
+
"\n[difficulty=%s seed=%d step=%d] ── PROMPT ──────────────────────────\n%s\n────────────────────────────────────",
|
| 353 |
+
difficulty, seed, _turn + 1, prompt_text,
|
| 354 |
+
)
|
| 355 |
|
| 356 |
+
rollout_outputs = generate_rollout_completions(trainer, [prompt_text])[0]
|
| 357 |
+
prompt_ids.extend(rollout_outputs["prompt_ids"])
|
| 358 |
+
completion_ids.extend(rollout_outputs["completion_ids"])
|
| 359 |
+
logprobs.extend(rollout_outputs.get("logprobs") or [])
|
| 360 |
+
completion_text = rollout_outputs.get("text") or tokenizer.decode(
|
| 361 |
+
rollout_outputs["completion_ids"], skip_special_tokens=False
|
| 362 |
+
)
|
| 363 |
|
| 364 |
+
log.debug(
|
| 365 |
+
"\n[difficulty=%s seed=%d step=%d] ── COMPLETION ──────────────────────\n%s\n────────────────────────────────────",
|
| 366 |
+
difficulty, seed, _turn + 1, completion_text,
|
| 367 |
+
)
|
| 368 |
|
| 369 |
+
if "<think>" in completion_text and "</think>" in completion_text:
|
| 370 |
+
think_steps += 1
|
| 371 |
+
steps_taken += 1
|
|
|
|
| 372 |
|
| 373 |
+
action_dict, fmt_score = parse_action(completion_text)
|
| 374 |
+
fmt_penalty = (1.0 - fmt_score) * -0.10
|
| 375 |
|
| 376 |
+
obs = env.step(PyreAction(**action_dict))
|
| 377 |
+
step_reward = float(obs.reward or 0.0)
|
| 378 |
+
done = obs.done
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
+
step_rewards.append(step_reward + fmt_penalty)
|
| 381 |
+
|
| 382 |
+
feedback = obs.last_action_feedback or ""
|
| 383 |
+
# Reward is intentionally excluded from the history entry.
|
| 384 |
+
history.append(
|
| 385 |
+
f"Step {_turn + 1}: {json.dumps(action_dict)}"
|
| 386 |
+
+ (f"\n → {feedback}" if feedback else "")
|
| 387 |
+
+ f"\n health: {obs.agent_health:.1f}"
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
agent_evacuated = obs.agent_evacuated
|
| 391 |
+
final_health = float(obs.agent_health)
|
| 392 |
|
| 393 |
except Exception as exc:
|
| 394 |
log.error("Episode failed (difficulty=%s seed=%d): %s", difficulty, seed, exc)
|
|
|
|
| 560 |
logging.getLogger().setLevel(logging.DEBUG)
|
| 561 |
log.debug("Debug logging enabled — full prompt/completion will be printed each step.")
|
| 562 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
# ── Init tokenizer ──────────────────────────────────────────────────────
|
| 564 |
tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=True)
|
| 565 |
if tokenizer.pad_token is None:
|
|
|
|
| 576 |
#
|
| 577 |
# Matching the reference notebook, with one Pyre-specific override:
|
| 578 |
#
|
| 579 |
+
# max_completion_length 512 → 1024 (safe margin for Qwen3 think + JSON action)
|
| 580 |
#
|
| 581 |
# Everything else — lr, grad_accum, num_generations, vLLM colocate,
|
| 582 |
# gradient checkpointing, push_to_hub — is identical to the reference.
|
| 583 |
grpo_config = GRPOConfig(
|
| 584 |
+
model_init_kwargs={
|
| 585 |
+
"torch_dtype": "bfloat16",
|
| 586 |
+
"attn_implementation": "eager",
|
| 587 |
+
},
|
| 588 |
num_train_epochs=1,
|
| 589 |
learning_rate=5e-6,
|
| 590 |
gradient_accumulation_steps=64,
|
| 591 |
per_device_train_batch_size=1,
|
| 592 |
warmup_steps=20,
|
| 593 |
num_generations=2,
|
| 594 |
+
max_completion_length=1024,
|
| 595 |
use_vllm=True,
|
| 596 |
vllm_mode="colocate",
|
| 597 |
vllm_gpu_memory_utilization=0.3,
|
| 598 |
+
vllm_max_model_length=8192,
|
| 599 |
output_dir=args.output_dir,
|
| 600 |
report_to=args.report_to,
|
| 601 |
trackio_space_id=args.output_dir if args.report_to == "trackio" else None,
|
|
|
|
| 610 |
# ── Create trainer ──────────────────────────────────────────────────────
|
| 611 |
trainer = GRPOTrainer(
|
| 612 |
model=args.model_id,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
processing_class=tokenizer,
|
| 614 |
reward_funcs=[
|
| 615 |
reward_evacuated,
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|