### PARTIAL OUTPUT (time limit hit; the model was still generating) ###
=== thinking so far ===
I'm building a 2D volcano physics simulation in a single self-contained HTML file with canvas rendering, no libraries. Let me plan this carefully.
**Core architecture:**
- Canvas with a fixed logical size, scaled to fit the window.
- A terrain heightfield: a volcano cone shape (rising slopes with a crater dip at the top), plus flat ground on the sides. I'll build this as an array of heights per x-column so I can do fast collision checks and also render the terrain as a filled polygon.
- Particle pool with typed-array-style management (or just plain arrays with swap-remove). Two particle systems: lava particles (physics, cooling, collision with terrain) and smoke/ash particles (buoyancy, wind drift, fading).
- Pressure system: builds over time at some rate; when it hits 100, eruption triggers. Eruption spawns a burst of lava particles over a short window (with the "particle amount" slider controlling count per eruption and maybe sustained fountain rate).
- FPS counter using exponential moving average of frame time.
- Particle counter.
**Physics details:**
Lava particles:
- Position (x, y), velocity (vx, vy).
- Gravity: ~250 px/s² or so, tuned to canvas size.
- Eruption: particles launched from crater with angle mostly upward, spread around vertical ± some angle, speed scaled by "power" slider. Add slight noise for realism. Maybe a few "bombs" — larger, slower particles.
- Collision: when particle's y exceeds terrain height at its x (or goes below ground), resolve. On slope contact:
- Compute terrain slope at x (difference of heights).
- If impact velocity into the surface is high and surface is steep-ish, bounce slightly with damping, or deflect.
- Then slide: project velocity onto slope direction, apply friction, and a downhill acceleration component (gravity along slope). Actually simplest robust approach: when grounded, move particle along terrain — set x += sign(slope) * slidespeed, with slidespeed building from gravity along slope and decaying by friction. Particles pile? In a simple heightfield model without particle-particle collision, particles will overlap; that's fine visually if we render them as small glowing dots with additive blending — they'll look like lava flows.
- Cooling: each particle has "heat" 1 → 0 over lifetime (shorter when grounded, longer in air? Or just time-based). Heat maps color: white-yellow (heat ~1) → orange → red → dark brown/black (heat ~0). When heat hits 0, particle fades out (alpha over remaining life) and is removed.
- Also: particles that come to rest while still hot keep glowing and cooling slowly — creating a glow field on the slopes. Nice.
- When a hot particle lands, it could slightly heat neighbors — skip, keep it simple but visually rich via additive rendering.
For performance with 1000+ particles: use a single array of lava particle objects, swap-remove on death. Rendering with `globalCompositeOperation = 'lighter'` and small filled rects or arcs. Arcs are slow for 1000+; better to pre-render a small radial-gradient sprite on an offscreen canvas once per heat level... Actually per-particle tinting: I can pre-render e.g. 32 sprites spanning the heat gradient (yellow→orange→red→dark), each an offscreen canvas with radial gradient, then drawImage with per-particle size scaling. drawImage of tiny sprites is very fast — thousands per frame is fine. That gives glow for free (radial gradient sprite) and consistent color mapping. For cooled (dark) particles, switch to normal composite so dark rock dots appear over the sky... hmm, dark particles with 'lighter' composite would be invisible. Solution: render in two passes — first pass 'source-over' for cool/dark particles, second 'lighter' for hot ones. Or simpler: hot particles use 'lighter' sprite; as heat drops below threshold, draw the dark rock sprite with source-over. I'll sort by heat implicitly via two loops (iterate once, draw hot; iterate again, draw cold) — or just set composite per particle... changing composite per draw is cheap-ish but grouping is cleaner. I'll do two passes over the same array.
Smoke particles:
- Spawn continuously from crater (rate tied to pressure and recent eruption — bigger plume after eruption, small wisps when idle).
- Physics: vy with buoyancy (negative accel), vx = wind * factor + turbulence (sin-based wander or random walk). Drag. Growth in size over life, alpha fades.
- Render: pre-rendered soft smoke sprite (radial gradient gray), drawn with source-over, low alpha, tinting by life (darker gray early = ash, lighter later = steam). Maybe two sprite variants: dark ash, light smoke, cross-fade by age. Keep one gray sprite, vary globalAlpha and size; color shift via two sprites (dark/light) blended by age ratio — I'll draw dark sprite with alpha*(1-age) and light sprite with alpha*age. That's 2 drawImages per smoke particle; smoke count maybe capped ~300–600 so fine.
Wind slider: -100..100 mapped to horizontal acceleration/velocity for smoke and also slight effect on lava arc (air drag horizontal component). Also could animate a faint wind streak indicator... maybe subtle background dust streaks showing wind direction — nice ambient touch, cheap. I'll add faint wind streak lines in the sky that move with wind, subtle.
Pressure:
- pressure 0..100, builds at base rate; maybe random tremor wobbles near high pressure. Displayed as a vertical gauge next to the volcano or a horizontal bar at top — I'll do a vertical gauge on the left with tick marks, needle-ish fill, and a label. Fill color: from amber to red as it rises? Avoid gradient-decoration... a fill bar with color shift by value is functional, not decorative — fine, but I'll keep it restrained: single warm color with intensity change, plus tick marks. Actually a subtle color change from dull to hot as pressure rises is genuinely informative. Keep it.
- When pressure ≥ 100: trigger eruption: pressure discharges over a short eruption duration (e.g. 1.2–2s) while spawning lava fountain; pressure drops to 0, then rebuilds. Eruption intensity = power slider. Also camera shake! Small screen shake during eruption — great feedback, "alive" feeling. Implement as translate offset decaying.
- Pre-eruption rumble: when pressure > 80, slight shake + increased smoke — foreshadowing.
Sliders (three):
1. Eruption power (0.5–2.0 multiplier on particle speed & fountain rate).
2. Wind strength & direction (-100..100, center 0).
3. Particle amount (particles per eruption / spawn multiplier, e.g. 200–3000 per eruption, spread across eruption duration).
Styling the controls: custom-styled range inputs, monospace-ish technical look. I want an authored aesthetic: something like a geological observatory console — dark warm charcoal background, amber/ember accent color, a technical/mono typeface feel. Fonts: I can't load external fonts? CDN allowed — I could pull a font from Google Fonts via link. That's an external CDN reference which is allowed. But "No external libraries: everything inline" from the user — fonts aren't libraries, but to be safe and honor "everything inline," I'll stick to system monospace stack styled well ("ui-monospace, 'Cascadia Mono', 'JetBrains Mono', Menlo, Consolas, monospace") — a mono terminal aesthetic fits an observatory console and avoids the generic Inter look. Good.
Layout: full-viewport canvas; UI overlaid as a slim HUD: top-left title block ("MONITORING STATION" style), pressure gauge, sliders in a bottom-left panel, stats (particles, FPS) top-right. All with restrained borders, no gradients, warm dark palette (#141110 background sky? Wait — the sky is drawn on canvas). Canvas sky: dusk-ish? Let me pick a scene palette: deep dusk sky (dark slate blue-gray to... no gradients as decoration — but a sky gradient is *environmental*, representing actual sky, which is representational rather than decorative. Hmm, I said no decorative gradients; a sky gradient in a landscape scene is representational and legitimate. I'll use a subtle vertical sky gradient (deep indigo-gray to warm dusk horizon) — it reads as sky, not as UI ornament. Actually, to be safe and tasteful: a very subtle two-tone sky (linearGradient with close, muted colors) plus stars? Night scene with stars would make lava glow pop beautifully. Yes: night volcano — dark starry sky, silhouetted terrain, glowing lava. Signature look. Stars: static random points, twinkle subtly.
Terrain rendering: filled dark silhouette (#0d0b0f-ish), with a rim highlight near crater? Maybe faint warm glow inside crater from magma (a glowing pool at crater bottom visible when pressure high — glow intensity scales with pressure! Nice feedback). Magma glow in crater: radial gradient sprite drawn with 'lighter', alpha ∝ pressure. That's a great "pressure" visual on the scene itself.
Snow caps? No — it's a volcano; maybe distant mountain silhouettes for depth (darker parallax layers). A second distant ridge adds composition. Cheap: draw another heightfield polygon in a slightly lighter dark tone. Yes, adds depth.
Also a moon? A thin crescent moon in sky — nice compositional anchor, easy (two circles). Maybe skip to avoid clutter; a moon helps balance the composition top-right opposite the plume drift... I'll add a small dim moon low near horizon. Actually keep it minimal: stars + subtle horizon glow near the volcano. Hmm, I'll include a small pale moon — it gives the eye a resting point. Fine.
**Terrain generation:**
Canvas logical size: W=1280, H=720 (scale via CSS to fit, maintain aspect with letterbox or just cover). I'll use fixed 1280×720 with devicePixelRatio-aware? To keep perf with thousands of particles, I might render at CSS resolution capped — simpler: canvas width=1280 height=720, CSS scales it. With DPR scaling the drawImage sprites still fine. I'll keep internal resolution at 1280×720 for predictable perf; on high-DPI it slightly softens but glow hides it. Actually let me support DPR up to 1.5 maybe... Simplicity: fixed 1280×720. Fine.
Terrain function: build array `ground[x]` for x in [0,W]:
- Base ground level yG = H - 90.
- Volcano centered at cx=W*0.5, base half-width bw=340, peak at yP = H - 470ish. Height profile: cone with concave-up slopes (steeper near top) — classic stratovolcano: use a curve like h = peakH * (1 - (d/bw)^1.6). Crater: near |d| < 35, dip down by ~55px (crater bowl).
- Add small noise for ruggedness (smoothed random). Keep noise small so sliding works predictably.
- Distant ridge: separate simpler heightfield, offset.
Collision: particle y > ground[clamp(x)] → contact. Ground slope s = (ground[x+2]-ground[x-2])/4. In-air integrate; on contact:
- If vy > threshold and slope moderate: bounce: vy = -vy * 0.25; vx *= 0.7; small random. Also if moving into slope horizontally.
- Then grounded mode: particle slides: compute along-slope gravity g_t = g * sin(θ)·cos(θ)-ish; simpler: vx += slope * g * dt_factor (moving toward downhill: if ground decreases with +x (s<0 means downhill to right?) — ground[] is y-coordinate; y grows downward. Terrain surface height in world terms: higher terrain = smaller y. slope of terrain in math coords: dy_world/dx = (ground[x-1]-ground[x+1])/2 (world y up). Let me define h(x) = groundY[x] measured as elevation above base... To avoid sign confusion: define elev[x] = yG - ground[x] (elevation, positive up). Downhill direction: elevation decreases. accel_x = -g_slope: a = g * (elev gradient) but capped: a_x = -G * clamp(slope_elev, -1.5, 1.5) where slope_elev = (elev[x+1]-elev[x-1])/2. If particle at rest on slope, accelerates downhill; friction: vx *= (1 - friction*dt) and if |vx| tiny and slope tiny, it stops (heat cools faster when stopped? whatever).
- Grounded particles also need gravity clamp so they don't sink: keep y = ground[x] - small offset, i.e., snap.
- Leaving ground: if slide fast over a convex crest, become airborne (if next ground is much lower than current pos, set airborne). Simple: each frame if grounded: y = ground[x]-1; vx += ax*dt; vx *= friction; if vy... also add downward if terrain drops fast — just snap handles downhills (particles "stick" — acceptable and looks like flowing lava hugging slope). Stickiness is good for lava flows.
- Pooling at bottom: grounded particles reaching flat ground slide to rest, cool, die.
Cooling: heat -= dt * (grounded? k1 : k2) * (1 + speedFactor). Lifetime mapping: hot phase ~3–6s in air, cooling to rock then rock persists ~4s fading. Total per-particle life = heatLife + fadeLife. Render: if heat > 0.06 draw glow sprite (source from heatSprites) with 'lighter'; else draw rock sprite 'source-over' with alpha = fade.
heatSprites: build 24 sprites: for i, t=i/23, color lerp along stops:
- t=0: rgb(255,255,230) bright white-yellow core
- t=0.25: #ffd23e
- t=0.55: #ff7a1a
- t=0.8: #d93a0c deep red
- t=1: #4a1206 near-dark
Each sprite: radial gradient from that color (alpha 1 center → 0 edge), radius ~16px. Draw scaled by particle size.
Rock sprite: dark gray-brown #241a16 radial, drawn source-over with fading alpha.
Smoke sprites: soft gray puffs. dark: rgba(60,55,58), light: rgba(150,145,148)? On dark sky, smoke should be lit slightly by lava below? Keep neutral gray, alpha low. Eruption smoke: dark ash column (fast rising, dark) then transitions to lighter drifting cloud. Buoyancy stronger initially then wind dominates as it ages. size grows from ~10 to ~90.
Spawn smoke: continuous base rate (a few/sec) + during eruption big burst rate. Also smoke rate ∝ pressure (fumarole activity). Cap smoke array at ~700; if exceeded, skip spawns.
Wind affects smoke vx (vx approaches wind*factor), and lava in-air slight drag: vx += wind * 0.02 * dt... modest so arcs stay arcs.
**Eruption logic:**
State: pressure (0..1). Build rate: 0.028/s base * power? Build rate maybe affected by nothing (or slightly by power slider? keep constant ~0.03 → ~33s cycle... maybe 0.045 → ~22s idle cycle; plus small random surges: pressure += (0.03 + noise)*dt). Add slight stochasticity: occasional tremor event adds pressure faster. Keep simple: rate = 0.035 + 0.02*sin(t*0.5).
Eruption trigger at pressure≥1: eruptionT = duration = 2.0s (scaled slightly by power). During eruption, spawn lava at rate = amountSlider / duration particles, with speed = base * power * rand. Also spawn heavy smoke burst. Pressure drains linearly to 0 over eruption duration + maybe lingers: I'll drain over ~2.5s with easing. Screen shake magnitude ∝ power during eruption, decays.
Initial spawn velocity: angle = -90° ± spread(28°), speed = (170..320) * powerMult px/s... For a 720-high canvas with peak around y=210, need to reach above crater by 150–350px: v = sqrt(2*g*h): with g=340, to rise 300px: v≈ sqrt(2*340*300) ≈ 450. So speeds ~200–520 * power. Launch from crater floor: crater center (cx, craterFloorY). vx small ±60 plus angle-based.
Two populations: "fountain" small fast many particles, and occasional "bomb": bigger sprite, slower, more spin? no rotation needed (round glow). Bombs: 2% chance, scale 2.5, speed high, they arc out and land with thud bounce.
**Stats:** count = lava.length + smoke.length, display both maybe: "LAVA 1204 · ASH 320". FPS: ema. Update DOM text every ~250ms to avoid layout thrash.
**Controls panel:** bottom-left: three sliders with labels and live values. Custom range styling: thin track, ember-colored thumb (square? small circle). Webkit + moz styles. Also a pause? Not requested; maybe a "trigger eruption" button? Not requested — the auto-cycle is required; a manual "VENT" button could be a nice extra... The requirements say pressure maxes and erupts automatically. I'll add a small "TRIGGER ERUPTION" button? It adds interactivity beyond sliders; user asked for three sliders specifically; extra button is harmless and fun. Hmm — "the simulation must stay smooth with at least 1000 active particles" — fine. I'll add the button subtly ("vent pressure") — actually let me not add unrequested UI clutter... A tiny secondary action is genuinely useful for demos. I'll include a small text button "force eruption" in the panel. Keep it minimal.
**HUD design:**
- Top-left: "VOLCANO OBSERVATORY — MONTE ROJO / SIM 02" small mono caps, letter-spaced, dim.
- Top-right: stats line: FPS, particles, in mono, right-aligned, maybe with tiny bars.
- Left edge: vertical pressure gauge: a tall thin bar with ticks and a label "PRESSURE", value % readout. Position: left center. Drawn in DOM or canvas? Canvas is easy and integrates with scene — I'll draw the gauge on canvas at fixed position (x=70), so it sits "inside" the world like an instrument... Or DOM overlay for crispness. Canvas drawing avoids DOM sync; I'll draw gauge on canvas (rects + ticks + text). Text on canvas each frame is fine.
- Bottom-left panel: DOM with sliders (DOM sliders give free interaction & accessibility).
Colors: background page #0a090b; sky handled in canvas; panel: rgba(12,10,12,0.72) with 1px border rgba(255,235,220,0.14); text #e8ddd2 warm off-white; accent ember #ff7a29 / #ffb35c. No gradients in UI.
**Perf plan:**
- Pre-rendered sprites via makeSprite(color stops, size).
- Two passes (rock then glow) with composite changes once per pass.
- Swap-remove arrays.
- Terrain heightfield arrays precomputed.
- Stars precomputed array; drawn as tiny rects each frame (cheap, ~120 stars) — or pre-render stars onto a static offscreen canvas including terrain base? Terrain silhouette changes only via particles overlay — terrain static! So: pre-render static background layer (sky gradient, stars, moon, distant ridge, main terrain, ground) into offscreen canvas once. Then each frame: drawImage(bg), then magma glow, particles, smoke, gauge, shake via translate. That's a big win. Terrain drawn once.
Wait — magma glow at crater must render behind particles but above terrain: draw order: bg → glow (lighter) → rock particles (source-over) → hot particles (lighter) → smoke (source-over) → vignette? → gauge → HUD text. Smoke above particles ok. Also wind streaks above bg below smoke.
- Cap DPR at 1 for perf? I'll set canvas at 1280×720 and CSS scale to fit while preserving aspect: compute scale in JS on resize (letterbox with page background). Use `image-rendering` default. Good enough; sprite glow hides softness. Actually I can bump internal res by DPR once (cap 2) — static bg scales, sprites scale — drawImage cost scales with dest pixels... thousands of particles at 2x could hurt on weak machines. I'll do DPR-aware only up to 1.5? Keep it simple: internal 1280×720 fixed. Smooth at 1000+ particles is the priority.
**Screen shake:** shakeMag decays *= exp(-3dt); offset = (rand-0.5)*shakeMag. Applied via ctx.translate before world drawing; gauge drawn outside shake (translate back) so instrument stays steady — nice touch: world shakes, HUD steady.
**Crater magma glow:** radial sprite drawn at crater floor with 'lighter', alpha = 0.15 + pressure*0.85, pulsing slightly (sin). Plus a wider dim halo. Also glow reflected on... skip.
Also lava landing "splash" spawn? When hot particle lands with speed > threshold, spawn 1–2 tiny short-lived sparks occasionally — adds sparkle, watch particle budget. Only if lava.length < cap. Cheap and pretty. Cap sparks.
**Sound?** No, keep silent (no assets). Fine.
**Wind indicator:** thin drifting streaks in sky: maintain ~40 streak particles with x velocity = wind*3, wrap around; draw as 1px horizontal lines alpha 0.05 — subtle motion cue. Only visible when |wind|>5.
Let me also make slider "particle amount" affect ongoing fountain: per eruption count = 300..2600. Emission during eruption duration with jitter; also initial burst 35% instantly.
Also ambient: occasionally small pre-eruption spitting when pressure>0.75 (tiny puffs of sparks) — foreshadow.
**Numbers tuning (I'll write then mentally simulate):**
- W=1280,H=720. Ground y: yG=640. Volcano: cx=640, baseW=380 each side, peak elevation 400 → peak y=240; crater: halfwidth 46, depth 60 → floor y≈300? Let's construct: elev(d) = peakElev * cone(d) where cone = max(0, 1 - (|d|/baseW)^1.55). Then crater: for |d|<48, subtract depth * smoothstep... elev -= craterDepth * (1 - (|d|/48)^2) clamped ≥ -craterDepth... Let me do: craterFactor = cos-like: for |d|<50: elev -= 70 * (1 - (|d|/50)^2). At d=0 subtract 70 → crater floor elevation = peakElev - 70. peakElev: 395 → peak y = 640-395=245, crater floor y=315. Good.
- Slope steepness near top: derivative of 1-(d/380)^1.55 → steeper at small d. Near d=50: slope = 395*1.55*(50/380)^0.55/380 ≈ 395*1.55*0.34/380*... let me not over-verify; visually stratovolcano shape, sliding works via heightfield regardless.
- Noise: elev += smoothNoise small ±6px. Ensure crater rim slightly uneven naturally. I'll add noise via summed sines: n(x)=Σ A_i sin(x*f_i + p_i). Multiply down near crater? Noise small (±5) fine everywhere.
Distant ridge: second heightfield: baseline y=560, peaks up to y≈470, drawn in #17131a (slightly lighter than main silhouette #0e0c10?) Actually main terrain should be darkest (closest) — near-black #0b0a0d; ridge #14111a; sky gradient from #0b0d18 (top) to #1c1620 near horizon with faint warm band #2a1a16 right above ground? Subtle. Stars in upper 60%.
Moon: small circle #d8d2c8 alpha .8 at (1050,130), r=22, with dark cut circle for crescent. Slight glow? no.
**Grounded sliding details:**
grounded particle each frame:
```
const gi = clamp(x,0,W-1)|0;
snapY = ground[gi]-2;
slope = (elev[gi+2]-elev[gi-2]) ...
```
I'll store groundY[] directly (screen coords). worldUp slope s = (groundY[gi-2]-groundY[gi+2])/(4) — positive when ground rises to the right? groundY smaller = higher. s>0 → terrain higher to left... Let's define d = (groundY[gi+2] - groundY[gi-2])/4: if d>0, ground y increases to right = terrain descends to right → particle slides right (downhill). accel_x = d * slideG (slideG ~ 600?) with cap |d|<=3 → accel up to 1800? Too much; use accel = clamp(d,-2.5,2.5) * 260. friction: vx *= Math.pow(0.6, dt)? use vx *= 1/(1+dt*fr) with fr≈3 when slow, less when fast? Simpler: vx += a*dt; vx *= (1 - 2.2*dt). Terminal slide speed = a/2.2 ≈ 260*2.5/2.2 ≈ 295 px/s max — fast lava flow, ok, maybe friction 3.5.
Set y = groundY[gi] - 2 (snap). If terrain ahead drops > 40px within few px (cliff/rim), could become airborne — check: if groundY[x+vx*dt] - groundY[x] > 26 and vx≠0 → airborne with vy = small. Adds natural rim spill. I'll include mild version.
Bounce on landing: if vy>140: vy=-vy*0.22, vx*=0.6, stay airborne; else grounded. Also lateral: if moving into uphill slope (vx*downhill sign wrong and steep), damp vx.
Air: vx += wind*30*dt (wind slider -100..100 → up to 30 px/s² — small arc influence; smoke gets much more). vy += g*dt. Drag slight: vx*=1-dt*0.12.
Heat: air cooling slow (heat -= dt*0.10), grounded cooling faster (dt*0.16), so glow persists during flight ~ up to 6–9s then fades over 3s. When grounded and slow, cooling *1.6 (rock crust). Sparks: heat -= dt*0.5, life 0.5s.
Also landing hot particles: if heat>0.5 and landed hard, tiny chance spawn spark burst (2–4).
Rock fade: after heat<=0, fade -= dt/3; draw rock sprite alpha=fade*0.9.
Cap lava array at e.g. 4000. Slider amount caps there. Perf: 2600 lava + glow sprites at 1280×720 should run 60fps easily.
**Smoke physics:**
p = {x,y,vx,vy,life,age,size0,spin?}. Spawn at crater floor + jitter, vy = -(40..90), vx random ±20 + wind*0.3 initial. Update: vy -= buoy * dt where buoy decays with age (buoy = 60 * max(0,1-age/2.5)); vx += (wind*4.5 - vx)*dt*1.2 (relax toward wind velocity: wind slider -100..100 → target vx up to ±450? that's fast; use wind*3.5 → ±350 px/s drift, plume bends dramatically at full wind — dramatic and shows slider clearly). turbulence: vx += sin(age*2+seed)*15*dt; vy also wobble. size = size0 + age*grow (grow ~26/s up to max 90). alpha profile: a = min(age*3,1) * (1 - age/life); life ~ 5–8s. Draw: two sprites dark/light weighted by min(age/1.8,1). During eruption spawn with vy more negative (-120..-220) and darker & bigger.
Smoke color on dark sky: use rgba grays with alpha ~0.16 per puff layered → builds nice clouds. Light sprite rgba(190,185,180) gives moonlit edges. I'll construct smoke sprite with soft radial falloff, draw with globalAlpha per particle.
Ash "darkness": during eruption, also slight overall tint? skip.
**Gauge drawing (canvas, steady):**
x=84 area: outer rect 14×220 at (40, H/2-110)... ticks every 10%, labels 0/50/100. Fill from bottom height*pressure, color lerp #b8b0a4 → #ff7a29 → #ff3b2f as pressure→1. Warning: when >0.85, blinking "WARNING" text? Small blinking dot. Value text under gauge.
Also show pressure % top-left near title? The gauge suffices.
**DOM panel:** bottom-left, sliders:
- POWER: 50–200 (%), default 100 → powerMult 0.5–2.
- WIND: -100–100, default 15.
- PARTICLES: 100–3000, default 1400 (per eruption).
Each row: label left, value right, slider below. Button "FORCE ERUPTION" small outlined.
Stats top-right: two lines: "FPS 60" big-ish, "PARTICLES 1 432" and "LAVA 1102 / ASH 330". Mono. Update every 200ms.
Title top-left: "VULCANOBS · SIM-07" + subline "MONTE CENIZA OBSERVATORY — LIVE FEED" dim. Blinking red dot "REC"? A small pulsing ember dot next to status "ACTIVE". Cute, minimal.
**Fit scaling:** body flex center, canvas max-width 100vw max-height 100vh with aspect-ratio via width/height auto: use CSS `canvas{width:min(100vw, calc(100vh*1280/720)); height:auto}` — clean, no JS resize needed. Panel overlays absolutely positioned relative to a wrapper that matches canvas box. I'll wrap canvas + HUD in a div sized like the canvas... Simpler: HUD elements positioned fixed relative to viewport; canvas centered. If letterboxed, HUD floats over page bg — acceptable if page bg matches sky top (#0a0a10). I'll make page bg #08070a and let HUD anchor to viewport corners; fine. Actually cleaner: position HUD inside a container with same aspect via JS? Overkill. Corner-anchored HUD over full viewport is standard and fine.
Hmm, but sliders bottom-left could overlap canvas or page — fine either way.
**Wind streaks:** array of 36 {x,y,len,spd} moving vx=wind*4; wrap; draw lines alpha 0.05+|wind|/1000. Only spawn/update when |wind|>4.
**Star twinkle:** stars have phase; alpha = base + 0.3*sin(t*sp+ph). But stars are pre-rendered into bg... then no twinkle. Option: draw stars dynamically (140 fillRect 1px — trivial cost). I'll draw stars dynamically for twinkle; keep bg for terrain only, sky gradient could also be in bg. bg = sky gradient + horizon glow + moon + distant ridge + terrain. Stars dynamic. OK.
Moon in bg (static).
**Eruption cycle math:** buildRate: reach 1.0 in ~26s at rate 0.038 with variation. Eruption duration 2.2s + power*0.6? Drain rate during eruption = 1/duration. Spawn count total = amountSlider; per second = amount/duration; accumulate spawnAcc += rate*dt; while ≥1 spawn.
During eruption lava spawn: speed = (260 + 320*rnd) * powerMult? Max rise height with v=580*2(power=2)=1160 → h=v²/2g=1160²/680≈1980px — way off-screen. Power 2 with max speed too much; scale: speed = (210 + 250*rnd^1.4) * (0.55+0.45*powerMult)? Let me set: base speeds such that power=1 gives plume up ~180–380px above crater (crater floor y≈315, so apex y between ~135 and -65 — some off top, that's fine/dramatic at high power). g=380. v for h=300: sqrt(2*380*300)=477. So speed range 240–540 * powerMult^0.9? With power=0.5: 120–270 → rises 19–96px — weak sputter, good contrast. power=2: 480–1080 → h up to 1530 — most exits top; acceptable at "extreme" setting? Maybe cap speed 900. I'll use speed = (230+300*rnd)*pm, cap 920. Angle: -PI/2 ± (0.15+0.5*rnd^2)? Spread ±~30°: ang = -PI/2 + (rnd-0.5)*1.0 rad(±29°) weighted center. vx = sin*speed... angle from vertical: vx = sin(a)*sp, vy = -cos(a)*sp.
Also during eruption, spawn smoke heavily: rate = 90/s + amount/200 per s.
Tremor/shake: during eruption shakeMag = 6*powerMult; also small shake when pressure>0.8: 1.2*(p-0.8)/0.2.
**Force button:** sets pressure=1 → triggers naturally next frame (and pressure display snaps).
**Counter formatting:** thin space groups.
Edge: particles offscreen x<−50 or >W+50 → kill. y>H+50 kill (shouldn't happen).
Water at bottom? no.
Let me also add a subtle vignette drawn once into bg? Vignette = radial dark edges — is that a "cheap glow"? A very subtle vignette aids focus; it's a photographic device, fine, but maybe skip to keep code lean. Skip.
Heat shimmer? skip.
**Code structure:**
```html
Volcano Simulator
```
Stage: position relative; canvas display block with the aspect CSS; hud absolutely inset 0 pointer-events none; panel pointer-events auto.
Actually simpler: canvas sized by CSS min(); hud absolute over stage where stage is inline-block shrink-wrapping canvas. `#stage{position:relative; margin:auto}` and canvas `max-width:100vw; max-height:100vh; width:auto`... To keep canvas fully visible: canvas{display:block; max-width:100vw; max-height:100vh} with width/height attrs — browser scales preserving aspect if only one constraint... If both max constraints, aspect preserved? canvas element with width attr and CSS max-width:100vw;max-height:100vh; height:auto? Setting height:auto with max-height conflict: use `canvas{max-width:100vw; max-height:100vh; width:auto; height:auto}` — width:auto resolves to attr width 1280 capped by max-width, then height auto follows ratio? For replaced elements with intrinsic ratio, if width is capped by max-width, height auto scales proportionally; but then height might exceed max-height... In practice browsers do constrain: with both max-width and max-height on an img/canvas with auto dims, they preserve ratio (like object-fit). Yes — replaced elements honor both constraints preserving intrinsic ratio. Good: `canvas{display:block;max-width:100vw;max-height:100vh}` and body centered flex.
Stage wraps canvas via position:relative; display:inline-block? If canvas scales, stage shrink-wraps (display:flex? just make stage width fit-content). Use `#stage{position:relative}` containing canvas; hud absolutely positioned inset:0. Since canvas is block child, stage width = canvas width. Good.
JS modules:
1. setup canvas, ctx.
2. makeSprite(size, stops) → offscreen canvas with radial gradient.
3. sprites: HEAT_SPRITES[25], ROCK, SMOKE_D, SMOKE_L, GLOW (magma halo).
4. terrain build: groundY = Float32Array(W). Also draw bg offscreen.
5. stars array.
6. state: pressure, eruptT, shake, sliders object, time.
7. particles arrays (plain objects; swap-remove).
8. spawn functions.
9. update(dt), render().
10. loop with rAF, dt clamp 1/30.
11. FPS ema; DOM stat updates throttled.
12. UI wiring.
Terrain detail with code:
```js
const W=1280,H=720,GY=636,CX=640;
const groundY=new Float32Array(W);
const elevRaw=(d)=>{ const a=Math.abs(d); let e=402*Math.pow(Math.max(0,1-Math.pow(a/392,1.55)),1); ... }
```
Hmm: peak elevation 402 → peak y = 636-402=234. For |d|≥392, e=0.
Crater: e -= 72*Math.exp(-((d/34)**2))? Gaussian dip centered, plus rim slight raise at edges: rim lift +8*exp(-((|d|-52)/18)^2)? Keep: e -= 74*Math.exp(-(d*d)/(2*34*34)) → at d=0: -74; at ±70 negligible. Crater floor y ≈ 234+74=308. Crater half-width where dip=half: d=34*1.177≈40. Rim width ~80. Good; crater floor width where dip>10: d≤~55? exp(-d²/2312)=0.135→d²=4370→d=66?? recompute: dip=74*exp(-d²/2312). dip=10 → exp=0.135 → d²=2312*2.0=4624 → d=68. Hmm too wide: at d=68 elevation of cone: 402*(1-(68/392)^1.55)=402*(1-0.078)=370 → y=266; crater adds -10 → 256. The crater bowl spans where dip meaningful. Fine visually.
Noise: n = 4*sin(x*0.021+1.7)+3*sin(x*0.043+0.6)+2.2*sin(x*0.09+4.1) → ±9; apply e += n * ramp (fade noise inside crater? keep everywhere, small). Apply to e then groundY[x]=GY - e (clamp ≥ 40? no). Also outer terrain: flat GY beyond cone.
Distant ridge for bg: heights h2(x)=GY-2 - (120+ 90*sin(x*0.004+2)+40*sin(x*0.013) )*something... make it a function of x with |ridge| across whole width behind volcano: y2(x) = GY - 30 - max(0, 150*sin(...))... simpler: y2 = GY - (60 + 130*Math.pow(Math.max(0,Math.sin(x*0.0042+2.3)),1.2) + 26*Math.sin(x*0.021)). Draw filled to bottom with color #131018. It'll be mostly hidden behind main volcano near center — visible on sides. Good depth.
bg drawing: sky gradient: '#0a0b14' top → '#171322' 55% → '#241a19' 78% → '#2b1d16' 100% (warm horizon band, subtle). Stars only where y < 420 drawn dynamically. Moon at (1060,120) r=20: fill #cfc8ba alpha 0.9; crescent: overlay circle offset (7,-3) with sky color? Sky color at that y varies... use composite 'destination-out'? On offscreen bg: draw moon circle then cut with globalCompositeOperation='destination-out' circle offset → crescent hole shows sky gradient below (bg is the offscreen). Works since bg offscreen. Add faint halo? skip halo (glow-bloat).
Ground fill: path from (0,H) up along groundY to (W,H). Fill '#0d0b10'. Then a subtle rim-light on crater edge? A 2px stroke along terrain top with #1d1820? Maybe faint warm inner-crater glow drawn dynamically anyway.
Crater magma pool: draw ellipse-ish glow at (CX, craterFloorY+6): sprite GLOW drawn 'lighter', alpha = (0.25+0.75*pressure)*(0.85+0.15*sin(t*7)). Also smaller hot core. craterFloorY ≈ 308. Compute exactly: floorE = 402 - 74 + noise(640) → groundY[CX]. I'll just read groundY[640] at runtime.
Gauge drawing:
```js
function drawGauge(){
const gx=46, gy=H/2-120, gw=16, gh=240;
// frame
ctx.strokeStyle='rgba(232,221,210,0.28)'; strokeRect(gx-3,gy-3,gw+6,gh+6)
ticks each 10%: line width 6 at left, longer at 0/50/100 + labels
fill: h=gh*p; color = p<0.6 ? mix('#8f867c','#ff7a29',p/0.6) : mix('#ff7a29','#ff3226',(p-0.6)/0.4)
ctx.fillStyle=color; fillRect(gx, gy+gh-h, gw, h)
text: Math.round(p*100)+'%' below; label 'PRESSURE' rotated? vertical text via single chars stacked.
blink: if p>0.85 draw pulsing dot + 'ERUPTION IMMINENT'? small text 'VENT'
}
```
Vertical label: draw 'PRESSURE' letters stacked, font 9px, dim.
Stats DOM:
```html
```
Style: right aligned, fps big.
Panel:
```html
```
Update loop details:
```js
function frame(ts){
dt = min((ts-last)/1000, 1/30); last=ts; t+=dt;
updatePressure(dt); updateLava(dt); updateSmoke(dt); updateStreaks(dt);
render();
fps ema: f = 1/dt; ema = ema*0.92 + f*0.08;
if(t-lastStat>0.25){ update DOM }
rAF
}
```
Lava update pseudocode:
```js
for(i=lava.length-1;i>=0;i--){
p=lava[i];
p.age+=dt;
// heat
const grounded=p.g;
p.heat -= dt*(grounded? (p.slow?0.22:0.13) : 0.075) * p.cool; // p.cool variance 0.8-1.3
if(p.heat<=0){ p.fade-=dt/2.6; if(p.fade<=0){kill} }
if grounded:
gi = clamp
gY=groundY[gi];
d=(groundY[min(gi+2,W-1)]-groundY[max(gi-2,0)])/4;
p.vx += clamp(d,-3,3)*300*dt;
p.vx *= 1/(1+3.0*dt);
p.x += p.vx*dt; p.y = groundY[clamp(round? use gi from new x...)]
// snap
p.y = groundY[cl(p.x)]-1.5;
// drop-off: if groundY at new x much lower than old? snap handles.
// small hop on steep drop: if groundY ahead - current >30: p.g=false; p.vy=... keep simple: skip, snap slides down cliffs instantly = looks like flow. ok.
p.slow = |vx|<14;
else air:
p.vy += 380*dt; p.vx += windX*26*dt; p.vx*=1/(1+0.1*dt);
p.x+=vx*dt; p.y+=vy*dt;
const gy2=groundY[cl(p.x)];
if(p.y>=gy2-1){
if(p.vy>150 && rnd... ) { p.vy*=-0.18; p.vx*=0.55; p.y=gy2-1; maybe spark }
else { p.g=true; p.y=gy2-1.5; p.vx*=0.5; landing fx if heat>0.4&&vy>200 }
// if on nearly flat & hot, fine
}
bounds: if x<-40||x>W+40||y>H+40 kill (also fade)
}
```
Note heat cooling 0.075/s air → 13s in air; too long; air cooling 0.16 → ~6s hot in air. Grounded 0.13–0.22 → 5–8s glow, then fade 2.6s. With eruption every ~26s+2s, active lava between eruptions persists ~10s, fine. Spark: cool fast 1.6/s, life 0.6, fade quick.
Actually also cap: lava array cap 4200; if exceeded, remove oldest (shift is O(n); use swap with random index or just skip spawns — skip spawns beyond cap and also kill oldest via index swap: lava[lava.length-1] pattern... I'll just skip spawning when at cap and set slider max accordingly; plus emergency trim.
Rendering lava:
pass 0: rock (source-over): for p where heat<=0: alpha=p.fade; size=p.s*(1+ (1-p.fade)*0) drawImage(ROCK,...)
pass 1 (lighter): for p with heat>0: idx = min(24, heat*24|0); s=p.s*(1.6+heat*0.8)? size scale: base sprite 48px wide for glow falloff; draw at dsize = p.s * (2 + 2*heat) maybe. Particle sizes: normal s=2.2–3.6 (radius-ish), bombs s=5–7, sparks s=1.2–2.
Draw: ctx.globalAlpha = min(1, p.heat*1.4+0.15) * p.fade? While fading pre-rock? heat>0.06 → glow only. Use globalAlpha=Math.min(1,p.heat*1.6). drawImage(spr, x-d/2, y-d/2, d, d).
Set ctx.globalCompositeOperation per pass; reset after; also globalAlpha resets.
Count passes: iterate array twice — fine.
Smoke draw: source-over, for each: a = alphaProfile; dark then light? draw dark with a*(1-k), light with a*k where k=min(1,age/2). Two drawImages each — maybe only draw light when k>0.05, dark when k<0.95. size grows.
Smoke update:
```
p.age+=dt; if age>life kill.
buoy = 55*(1-p.age/p.life*0.9)... use (1 - age/life)
p.vy += (-85*(1-age/life) - 0) *dt??
```
Better: vy relax toward target vy_t = -(90*(1.2-age/life))-ish... Let me do forces: vy += (-70*(1 - age/life) )*dt (buoyancy fading); vy *= 1/(1+0.55*dt) (drag → terminal rise speed); wind: vx += (wind*3.2 - vx)*dt*0.9; turbulence: vx += Math.sin(p.age*1.7+p.sd)*22*dt; vy += Math.sin(p.age*2.3+p.sd*2)*10*dt.
x+=vx*dt;y+=vy*dt. size = p.s0 + p.age*p.gs (gs ~ 14–26). life 4.5–7.5s.
Eruption smoke: s0 bigger 14–30, initial vy -(120..240), gs 30, life 6–9, color darker (k offset slower).
Ambient smoke: rate = 1.5 + pressure*8 per second (wisps). Also when pressure>0.7 occasional stronger puff.
Cap smoke 750.
Spawn smoke position: (CX + rnd*30-15, craterY - 4).
Streaks: 40 items; if |wind|<5 alpha 0. skip else move x += wind*4.5*dt (+ their own factor), wrap 0..W; y fixed random in sky area (0..H*0.55); draw line length 30+|wind|*0.3, alpha 0.04+|wind|*0.0006. color 'rgba(200,200,220,α)'. Update positions only when wind active; draw always if alpha>0.
Shake: shake decays: shake *= Math.exp(-2.6*dt). During eruption: shake = max(shake, 7*pm). Pre-eruption: if p>0.8: shake=max(shake,(p-0.8)*8). offset ox=(Math.random()*2-1)*shake, oy likewise *0.6.
Render:
```
ctx.setTransform(1,0,0,1,0,0); ctx.clearRect? drawImage bg covers all → no clear needed except alpha? bg opaque → fine, skip clear.
ctx.translate(ox,oy);
drawImage(bg)
stars (before bg?? stars must be behind terrain — but bg contains terrain! So stars must be drawn onto... problem: bg includes terrain silhouette; dynamic stars would need drawing between sky and terrain. Options: draw stars onto bg offscreen (no twinkle) OR split: bgSky (gradient+moon) then dynamic stars then bgTerrain (transparent offscreen with ridges+ground). Two offscreens — easy. Do that: bgSky, bgTer.
Then magmaGlow (lighter), streaks, rockPass, glowPass, smoke, then reset transform → gauge, done.
```
Wait stars twinkle above terrain? Stars drawn after bgSky but before bgTer — correct occlusion.
Also faint horizon warm glow near volcano base in bgSky: gradient already has warm band; fine.
Star colors slight variation: white/#cfd6ff/#ffe9c8.
DOM stat: total = lava.length + smoke.length (+streaks? no, particles = lava+smoke). Show "LAVA n / ASH m" maybe in one line under big numbers.
Pressure build: rate = 0.032 + 0.016*Math.sin(t*0.31+1) + occasional surge: use second sine for irregularity: +0.012*Math.sin(t*0.13*7.7)... let's: rate=0.030+0.014*Math.sin(t*0.37)+0.010*Math.sin(t*1.61). Min ~0.006 → slow moments, max 0.054. Cycle ~ 20–40s. During eruption, pressure -= dt/dur (dur=2.3+0.8*pm? keep 2.4). After eruption residual small: set pressure to 0 at end exactly (drain computed to hit 0: track eruptLeft; pressure = eruptLeft/dur). Spawn window tied to eruptLeft.
Eruption spawn: while erupting: emitAcc += (amount/dur)*dt*(power curve?) just amount/dur; also first 0.3s extra: multiply rate by (1+1.5*max(0,(eruptLeft-dur+0.4)/0.4))? Simpler: rate factor = 1 + 1.2*Math.exp(-(dur-eruptLeft)*3) (strong start). ok.
Bomb chance 1.5%: s 5.5+2*rnd, cool slower 0.75, speed*0.85.
Also spawn sparks at crater continuously during eruption (rate 60/s): tiny fast particles heat bright — they add sparkle inside fountain; they're just lava particles with small s and high cool. Budget: sparks short-lived.
Muzzle flash: at eruption start, draw bright glow sprite at crater big for 0.5s: glowFlash=1 at trigger, decay 3/s; render GLOW sprite size 260*pm alpha flash*0.8 'lighter'.
Sound none.
Fonts: define stack: `ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,monospace`.
UI styles:
```
body{background:#08070a;color:#e6dccf;font-family:mono;overflow:hidden}
#panel{position:absolute;left:18px;bottom:18px;width:250px;padding:14px 16px;background:rgba(13,11,14,.78);border:1px solid rgba(230,215,200,.13); backdrop-filter:blur(4px)? maybe}
row label{font-size:10px;letter-spacing:.14em;color:#9a8f83}
value{color:#ffb35c}
input[type=range] custom: -webkit-appearance:none;height:2px;background:#3a332e; thumb: 11px square? circle #ff7a29 border.
button: transparent bg, 1px border #ff7a29-ish, color #ffb35c, hover fill rgba.
```
Square thumbs feel instrument-like; circle is fine. I'll use a small circle with border.
Title block:
```
VULCANOBS / SIM-07
MONTE CENIZA — LIVE TELEMETRY
```
dot: 7px, background #ff5a2a, animation pulse 1.6s. h1 12px letter-spacing .18em; p 9px dim.
Stats:
```
#stats{position:absolute;right:18px;top:14px;text-align:right}
#fpsV{font-size:26px;color:#e6dccf}
unit small; #parV lines.
```
Gauge drawn on canvas at left — panel is bottom-left; gauge at left middle might collide with panel if panel tall — panel height ~190, gauge centered at H/2 y from 240..480 area x=46.. panel starts around H-220. At 720 canvas scaled, overlaps possible on small screens; move gauge to x=54, panel width 250 at bottom-left... gauge bottom y=H/2+120=480, panel top ≈ 720-230=490. OK no overlap within canvas coordinates. Fine.
Warning blink text near gauge: 'CHARGE' state text below gauge: if p>0.85 draw blinking triangle? Just color fill red + blinking outline. ok.
Let me now also double-check slider "particle amount" live: user changes while running → next eruption uses value (and if eruption active, rate recalcs each frame — read live, so it responds immediately). Good, sliders live.
Power slider live affects windX etc. Wind live affects smoke instantly — good demo feel.
windX = parseFloat; in air lava: vx += windX*0.28*dt*... let me define windAcc = windX*0.35 (max 35 px/s²) — over 2s flight Δvx≈70. Slight bend. Smoke target vx = windX*3.4 (±340). Streak speed windX*4.5.
Number formatting: n.toLocaleString? Use custom groups with thin space: String replace regex.
FPS: also show ms? Just FPS.
Edge case: dt clamp; tab switch large dt handled by clamp.
Sprite creation:
```js
function sprite(d, stops){ // stops: [ [off, 'rgba(...)'], ...] radial center
const c=document.createElement('canvas');c.width=c.height=d;const g=c.getContext('2d');
const gr=g.createRadialGradient(d/2,d/2,0,d/2,d/2,d/2);
stops.forEach(s=>gr.addColorStop(s[0],s[1]));
g.fillStyle=gr;g.fillRect(0,0,d,d);return c;
}
```
Heat ramp stops (24 sprites):
```js
const ramp=[[1.0,[255,240,200]],[0.82,[255,196,80]],[0.55,[255,110,26]],[0.3,[200,50,14]],[0.0,[70,26,18]]];
```
Interp between stops; sprite i (i/23 heat from 1→0): color = sample(t). Alpha falloff: gradient stops: 0:'rgba(r,g,b,1)', 0.55: rgba(...,0.55), 1: rgba(...,0). Build per sprite.
Simpler per-sprite: compute rgb via piecewise lerp on heat t (1 hot→0 cool), then radial gradient with stops [0,a=1],[0.4,0.7],[1,0].
ROCK sprite: radial rgba(30,22,20,0.95)→transparent, d=20.
SMOKE_D: rgba(52,47,52): stops 0:0.42,0.6:0.2,1:0. Hmm per-particle globalAlpha multiplies.
SMOKE_L: rgba(168,160,158) same.
GLOW (magma/flash): rgba(255,150,50) hot: 0:1,1:0, d=256.
Interp function:
```js
function rampColor(t){ // t=1 hot
for(let j=0;j=ramp[j+1][0]){ const k=(ramp[j][0]-t)/(ramp[j][0]-ramp[j+1][0]); ... lerp } } }
```
Careful bounds.
Terrain bg draw:
```js
bgTer: g.fillStyle='#0d0b10'; path: moveTo(0,H); for x..: lineTo(x, groundY[x]); lineTo(W,H); fill.
Also distant ridge first: '#151119': y2(x) path.
Optionally faint edge line: stroke path along terrain 'rgba(255,140,60,0.06)'? subtle warm rim from magma light... cute: stroke ridge top with rgba(120,70,40,0.35) 1px? Might look like cheap outline; skip or very subtle #191319. I'll add 1px stroke '#181119'.
```
Ground flat areas beyond cone — with e=0, groundY=GY everywhere flat. Ridge function ensures side interest.
Stars: 130 stars {x,y<380,r(0.5–1.4),ph,sp,a(0.3–0.9)}; draw fillStyle white-ish alpha a*(0.6+0.4 sin). A few brighter with tiny cross? skip.
Now stats: "active particle count" — show combined big number + breakdown.
Let me also ensure "particles land on slopes and slide/settle downhill" — yes grounded logic. "Ash cloud rises from crater and drifts with wind" — yes. "pressure resets and builds again" — yes automatic. Counters — yes. Sliders — 3. Smooth ≥1000 particles — yes (arrays, sprites, single canvas). Single HTML, no libs.
One more perf consideration: drawing 3000 glow sprites 30–60px each with 'lighter' — fill rate heavy: 3000 * 40² px = 4.8M px overdraw per frame — GPUs handle; canvas2d is GPU-accelerated in modern browsers; fine. Cap sizes: hot glow d = s*(4..7): s≈3 → 12–21px. Small. OK.
Rock particles: many dark dots on slopes accumulate between eruptions (~ up to few thousand with fade 2.6s after cooling) — visually a scattered dark speckle field, reads as ejected rock. Good.
Let me write the final code carefully, complete and consistent. I'll be meticulous about variable definitions.
Key constants:
```js
const W=1280,H=720,GY=634,CX=640,GRAV=380;
```
Terrain build:
```js
const groundY=new Float32Array(W+4);
for(let x=0;xgroundY[Math.max(0,Math.min(W+3,Math.round(x)))];
const craterY=gY(CX);
```
Wait noise adds to e including inside crater (small) fine. At |d|≥392 e≈0+noise → ground wiggles ±9 on flat plains — slight undulation ok, actually pleasant (rolling ground). But pow of negative? Math.max(0,...) guards.
Also clamp groundY min? e max ~ 402+9-... peak y≈634-411=223 fine.
Check crater: at d=0: e=402-74+noise(≈4*sin(1.7)+3*sin(0.6)+2*sin(4.2)= 4*0.99+3*0.56+2*(-0.87)=3.96+1.69-1.74=3.9) → e≈331 → craterY≈303. Good.
bgSky gradient warm near horizon: horizon line at GY-ish. Colors:
0.0 '#07070d', 0.45 '#0d0d17', 0.75 '#181420', 1.0 '#221a18' over full H. Moon at (1055,118).
Ridge (in bgTer before main):
```js
g.fillStyle='#141018';
g.beginPath();g.moveTo(0,H);
for(x=0..W) y2 = GY-34 - Math.max(0,Math.sin(x*0.0038+2.6))*150 - 20*Math.sin(x*0.017+1);
lineTo; close at (W,H).
```
sin gives smooth hills; max(0,·) creates flat-zero plains — one broad mountain region. Fine.
Main fill '#0c0a0f', stroke top 'rgba(255,255,255,0.04)'? subtle: use '#171219' 1px.
Now the lava particle object:
```js
function spawnLava(x,y,vx,vy,s,cool){ if(lava.length>=CAP_L) return; lava.push({x,y,vx,vy,s,g:false,heat:1,cool:cool,age:0,fade:1,slow:false,sd:Math.random()}) }
```
Eruption:
```js
let pressure=0.34, erupting=false, eruptLeft=0, eruptDur=1, emitAcc=0, flash=0, shake=0;
function triggerEruption(){ if(erupting) return... allow retrigger: eruptDur=2.2+0.6*powerMult(); eruptLeft=eruptDur; erupting=true; flash=1; }
```
pressure behavior:
```js
if(erupting){
eruptLeft-=dt;
pressure=Math.max(0,eruptLeft/eruptDur);
emit lava & smoke & sparks;
if(eruptLeft<=0){erupting=false;pressure=0;}
} else {
pressure+=dt*(0.030+0.014*Math.sin(t*0.37)+0.010*Math.sin(t*1.61));
if(pressure>=1) triggerEruption();
}
```
During eruption emit:
```js
const amt=params.amount;
const boost=1+1.3*Math.exp(-(eruptDur-eruptLeft)*2.5);
emitAcc+=amt/eruptDur*boost*dt;
while(emitAcc>=1){emitAcc--; spawnEruptionParticle();}
```
spawnEruptionParticle:
```js
const pm=powerMult();
const a=-Math.PI/2+(Math.random()-0.5)*(0.55+Math.random()*0.55);
let sp=(230+300*Math.pow(Math.random(),1.3))*pm; if(sp>920)sp=920;
if(Math.random()<0.016){ sp*=0.9; spawnLava(CX+r(-8),craterY-2, Math.sin(a)*sp, -Math.cos(a)*sp, 5+2.5*Math.random(),0.8);}
else spawnLava(...,s=2+1.7*Math.random(),cool=0.8+0.5*Math.random());
```
Wait pm up to 2 → sp up to 920 capped; pm 0.5 → 115–265. ok.
Smoke during eruption: smokeAcc similar rate = 55*boost*pm? add: emitSmokeAcc += (40+amt*0.02)*boost*dt; spawn smoke.
Ambient smoke (always, incl. idle): acc += (1.2+pressure*7)*dt; spawn small.
Sparks at crater during eruption: handled by s small & cool high? Add 25% of fountain spawns as sparks: s=1.3+1.0*rnd, cool=2.2 (fast cool), speed*1.15. They glint.
Landing spark: on impact if vy>240 && heat>0.35 && Math.random()<0.5 → spawn 2 sparks with vy=-rnd*120, vx=rnd*±160, cool 2.5, s 1.2+1.1r. Only if lava.length=0;i--){
const p=lava[i];
if(p.heat>0){
p.heat-=dt*(p.g?0.14:0.16)*p.cool*(p.g&&p.slow?1.5:1);
if(p.heat<=0){p.heat=0;}
} else {
p.fade-=dt/2.6;
if(p.fade<=0){ lava.splice? swap-remove: lava[i]=lava[lava.length-1];lava.pop();continue;}
}
if(p.g){
const gi=Math.max(1,Math.min(W+2,Math.round(p.x)));
const d=(groundY[gi+2]-groundY[gi-2])/4;
p.vx+=Math.max(-3,Math.min(3,d))*300*dt;
p.vx*=1/(1+3.2*dt);
p.x+=p.vx*dt;
p.y=gY(p.x)-1.5;
p.slow=Math.abs(p.vx)<15;
} else {
p.vy+=GRAV*dt;
p.vx+=wind*0.35*dt;
p.vx*=1/(1+0.10*dt);
p.x+=p.vx*dt;p.y+=p.vy*dt;
const gy=gY(p.x);
if(p.y>=gy-1){
if(p.vy>170){
// bounce or splash
if(p.vy>260){ if(Math.random()<0.4&&p.heat>0.3) spawnSparks(p.x,gy-2,2); }
p.vy=-p.vy*0.16;p.vx*=0.5;p.y=gy-1;
if(p.vy<40)p.g=true,p.y=gy-1.5,p.vx*=0.5; // settle when slow
} else {
p.g=true;p.y=gy-1.5;p.vx*=0.55;
if(p.heat>0.4&&Math.abs(p.vy)>?...) skip
}
}
}
if(p.x<-30||p.x>W+30){remove}
}
}
```
Hmm bounce: vy after -vy*0.16 with vy 400 → 64 up: rises 64²/760≈5px, tiny hop then falls, vy at land small → grounds. fine.
Note: grounded heat cooling *slow factor — settle crust.
Also grounded particles near crater rim on flat crater floor: d≈0, vx decays → rest, cool, fade. Good — crater floor fills with cooling rock glow.
Sparks cool fast (2.2–2.5 → heat gone in ~0.4s ground) then fade 2.6s as rock specks — meh, sparks fading as rock dust is fine, or give sparks shorter fade: add p.fq (fade rate) = sparks 1.5/s? Add field f0? Keep uniform; fine.
updateSmoke:
```js
for i from end:
p.age+=dt; if(p.age>=p.life) remove;
const u=p.age/p.life;
p.vy+=(-75*(1-u))*dt;
p.vx+=(params.wind*3.4-p.vx)*dt*0.9;
p.vx+=Math.sin(p.age*1.7+p.sd)*24*dt;
p.vy+=Math.sin(p.age*2.3+p.sd*2.7)*12*dt;
p.vy*=1/(1+0.5*dt);
p.x+=p.vx*dt;p.y+=p.vy*dt;
```
spawnSmoke(big):
```js
smoke.push({x:CX+(Math.random()-0.5)*34, y:craterY-6-Math.random()*10,
vx:(Math.random()-0.5)*30+params.wind*0.6, vy:-(40+Math.random()*(big?200:60)),
age:0,life:4.5+Math.random()*3+(big?1.5:0), s0:big?12+8*Math.random():4+4*Math.random(),
gs:big?26+10*Math.random():10+8*Math.random(), sd:Math.random()*10})
```
cap 750.
drawSmoke: for each: u=age/life; a=Math.min(u*4,1)*(1-u); k=Math.min(1,age/1.6); d=s0+age*gs;
dark: alpha=a*(1-k)*0.28; light: a*k*0.30; drawImage accordingly.
Wind sign: positive wind → vx positive → drift right. Slider "WIND ← →". Label shows m/s-ish value.
Render order code:
```js
function render(){
const ox=(Math.random()*2-1)*shake, oy=(Math.random()*2-1)*shake*0.6;
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(bgSky,0,0);
// stars
...
ctx.setTransform(1,0,0,1,ox,oy);
ctx.drawImage(bgTer,0,0);
// magma glow
ctx.globalCompositeOperation='lighter';
const ga=(0.22+0.78*pressure)*(0.8+0.2*Math.sin(t*6.3));
ctx.globalAlpha=ga; d=150+90*pressure; drawImage(GLOW,CX-d/2,craterY-d/2+10,d,d);
if(flash>0){ flash-=? do in update; draw d2=340*pm*flash? alpha flash*0.9 }
ctx.globalCompositeOperation='source-over';
// streaks
// rock pass
for p of lava if heat<=0: ctx.globalAlpha=p.fade*0.9; d=p.s*4; drawImage(ROCK,...)
// hot pass lighter
ctx.globalCompositeOperation='lighter';
for p heat>0: const hh=p.heat; spr=HSPR[Math.min(23,(hh*23)|0)]; d=p.s*(3.2+3.4*hh); alpha=min(1,hh*1.7);
// smoke source-over
...
reset transform, alpha=1, composite source-over
drawGauge();
}
```
Star draw between bgSky and bgTer with identity transform (before shake translate — stars shaking? better stars shake with world? sky shaking with world is fine/normal if whole world shakes; but stars are "infinite sky" — shaking everything looks fine. I'll draw stars in the shaken pass right after bgTer? They'd overlay terrain... no, bgTer terrain would be under stars then. Must draw stars before terrain. So: shaken: bgSky, stars, bgTer, ... all shaken. Simplest: apply shake translate to everything including sky — fine.
Actually setTransform(1,0,0,1,ox,oy) then drawImage(bgSky,0,0) leaves edges unshaken showing clear canvas (transparent black) at borders — bg page is #08070a; canvas default transparent → body bg shows through. Slight dark edges during shake — acceptable, actually adds impact. Or overdraw bgSky 4px larger... skip; shake ≤7px, barely noticeable against dark.
Gauge code:
```js
function drawGauge(){
const gx=52,gy=H/2-118,gw=15,gh=236;
ctx.font='9px ...';
ctx.fillStyle='rgba(232,221,210,0.25)';
ctx.fillRect(gx-4,gy-4,gw+8,gh+8) as frame? use strokeRect thin.
ticks: for k=0..10: yy=gy+gh-gh*k/10; ctx.fillRect(gx-4-?,...) draw tick left side len (k%5?4:7)
labels 0,50,100: fillText at left? space tight; put labels right of bar small.
fill: p=pressure; col = p<0.55 ? lerpColor([143,134,124],[255,122,41],p/0.55) : lerpColor([255,122,41],[255,48,38],(p-0.55)/0.45);
ctx.fillStyle=col; ctx.fillRect(gx,gy+gh*(1-p),gw,gh*p);
// pulsing overfill line when >0.85
if(p>0.85){ctx.globalAlpha=0.4+0.4*Math.sin(t*10);ctx.fillStyle='#ff3226';ctx.fillRect(gx-6,gy-1,gw+12,2);ctx.globalAlpha=1;}
// value text
ctx.fillStyle='rgba(230,220,208,0.85)';ctx.font='11px mono';ctx.textAlign='center';
ctx.fillText(Math.round(p*100)+'%', gx+gw/2, gy+gh+18);
// vertical label
ctx.fillStyle='rgba(154,143,131,0.7)';ctx.font='9px';
'PRESSURE' chars at x=gx+gw/2? below value? Place label above bar: fillText('PRESSURE',gx+gw/2,gy-12) letterSpacing? canvas letterSpacing support ok modern; just draw text.
textAlign reset to 'left'.
}
```
Colors sampled: fine.
Panel HTML/CSS — write carefully.
Also add hint text bottom-right? "Sliders update the simulation live." small dim line inside panel footer. ok.
Button styling + JS: onclick → pressure=1 handled? trigger directly: if(!erupting){triggerEruption()} else extend? just call trigger; if erupting, ignore (or re-trigger). I'll make button always: eruptLeft=eruptDur; erupting=true; flash=1 — restart eruption mid-way is fine and fun (extends). Actually if erupting with eruptLeft 0.3, resetting to full dur keeps pressure high. fine.
params read:
```js
const params={power:1,wind:18,amount:1400};
pow.oninput=e=>{params.power=+e.target.value/100; vPow.text=params.power.toFixed(2)+'×'}
wind: params.wind=+val; display (val>0?'→ ':'← ')+abs+' kt'? show sign: (val>0?'+':'')+val
amount: display value.
```
powerMult=()=>params.power.
FPS: fpsEMA; display Math.round.
Stats DOM update every 0.25s: fpsV, countV = lava.length+smoke.length formatted, splitV = `LAVA ${lava.length} · ASH ${smoke.length}`.
Flash update: flash=Math.max(0,flash-dt*1.8).
Shake update:
```js
const targetShake = erupting? 6.5*params.power : (pressure>0.8? (pressure-0.8)*22 : 0);
shake=Math.max(targetShake, shake*Math.exp(-2.8*dt));
```
Hmm during eruption want steady shake: max(target, decayed) → holds at target. good.
Streaks:
```js
const streaks=[]; for 42: {x:Math.random()*W,y:Math.random()*400+20,f:0.6+Math.random()*0.8}
update: if(|wind|>4){s.x+=wind*4.5*s.f*dt; wrap x<0→+W, >W→−W; s.on=true}
draw: if |wind|>4: alpha=0.03+|wind|*0.0007; len=(26+|wind|*0.35)*s.f; strokeStyle='rgba(190,190,205,'+a+')'; line (x,y)-(x-len*sign,y).
```
Batch strokes: one path per alpha? alpha varies per streak via f... just individual strokes (42 fine) or fixed alpha per frame and single path with varying length via segments — 42 strokes ok.
Now — total code length manageable. Write final HTML.
CSS custom range:
```css
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:14px;background:transparent;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{height:2px;background:#3a332d}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:11px;height:11px;border-radius:50%;background:#0d0b0e;border:2px solid #ff7a29;margin-top:-4.5px}
input[type=range]::-moz-range-track{height:2px;background:#3a332d}
input[type=range]::-moz-range-thumb{width:9px;height:9px;border-radius:50%;background:#0d0b0e;border:2px solid #ff7a29}
```
Button:
```css
button{width:100%;margin-top:12px;padding:8px;background:transparent;border:1px solid rgba(255,122,41,.5);color:#ffb35c;font:600 10px mono;letter-spacing:.16em;cursor:pointer}
button:hover{background:rgba(255,122,41,.12)}
button:active{transform:translateY(1px)}
```
Title dot pulse keyframes.
Panel row layout:
```css
.row{margin-bottom:10px}
.row .lr{display:flex;justify-content:space-between;font-size:9.5px;letter-spacing:.14em;color:#9a8f83;margin-bottom:4px}
.row .lr b{color:#ffb35c;font-weight:600}
```
HUD pointer-events: #hud{position:absolute;inset:0;pointer-events:none} #panel{pointer-events:auto}.
Stage centering: body{display:flex;align-items:center;justify-content:center;height:100vh;margin:0} #stage{position:relative} canvas{display:block;max-width:100vw;max-height:100vh}.
One more check: `wind` label shows negative fine.
Sparks helper:
```js
function spawnSparks(x,y,n){ for(...){ if(lava.length>=CAP_L)return; lava.push({x,y,vx:(Math.random()-0.5)*260,vy:-Math.random()*160-30,s:1.1+Math.random()*1.1,g:false,heat:1,cool:2.4,age:0,fade:1,slow:false,sd:0}) } }
```
CAP_L=4200, CAP_S=750.
Formatting numbers:
```js
const fmt=n=>(''+n).replace(/\B(?=(\d{3})+(?!\d))/g,' ');
```
Also initial pressure 0.3 so first eruption comes ~20s in, smoke visible early. And maybe trigger a small early "cough"? At t=3s, a mini-eruption? Could set initial pressure 0.75 → first eruption ~6s — good for immediate payoff. Set 0.7.
Wait: buildRate min ~0.006 → from 0.7 could take 50s worst case? rate range 0.030±0.024 → min 0.006 only momentarily; average ~0.030 → ~10s to full. Good.
Also during idle high pressure (>0.8) small spits: every so: if(!erupting&&pressure>0.82&&Math.random()0): alpha=flash, d=180+320*flash, at crater. 'lighter' within same block.
I think design is solid. Let me also handle textBaseline for gauge labels.
Write it out now. I'll aim for clean, commented code (comments in English since user request is English).
One subtle bug risk: groundY array indexed W+4; gY clamp uses W+3. gi max W+2 then gi+2 → W+4 out of bounds! groundY has length W+4 → indices 0..W+3. gi max should be W+1: clamp(Math.round(p.x),1,W+1), gi+2 → W+3 ok, gi-2 → -1 min when gi=1 → guard with Math.max? use clamp gi in [2, W+1]. Let me define: const gi=Math.max(2,Math.min(W+1,Math.round(p.x))). gi-2≥0, gi+2≤W+3. Good. And gY(x) clamp round to [0,W+3].
For bgTer path use x loop 0..W+? use 0..W with gY(x) fine (indices within range).
pow display default '1.00×'.
Also title mentions... fine.
Check eruption spawn x jitter ±10 and y craterY-4; particles spawn inside crater below rim; with upward speed they clear rim since crater depth 74 and they go up — but angled ones (±30°) hitting crater walls: they collide with groundY slope inside crater and slide back down into crater — realistic rumble fill. Good but many may pool in crater; crater floor width ~80px, pool of glowing rocks accumulating then fading — actually looks awesome (molten crater).
vy sign: a=-PI/2 ± δ; vx=Math.sin(a)*sp → at a=-PI/2, sin=0... sin(-PI/2)=−1! Careful: a=-PI/2 gives sin=-1, cos=0 → vx=-sp, vy=-cos(a)*sp=0. Wrong. Use: dir from vertical: ang=(r-0.5)*spread; vx=Math.sin(ang)*sp; vy=-Math.cos(ang)*sp. ang=0 → straight up. Yes use that.
In spawnSparks vy=-rand*160-30 (upward) ok.
Bounce threshold check uses p.vy>170 before position correction; implemented as described.
Grounded→airborne on crest: skip (snap handles; fine).
Slow factor on cooling: (p.g && p.slow) multiplier 1.5 speeds crust formation.
Alright, also throttle: when tab hidden rAF pauses; dt clamp handles resume.
Smoke drift target wind*3.4: wind=100 → 340 px/s; streaks 450*f. Plume bends hard right — dramatic.
Draw smoke sizes: d up to s0+life*gs = 22+8*36 ≈ 300px big puffs — fill-rate ok (≤750).
Let me now write the file. Also include ``.
Explanation text after code, concise. Let me write final answer.
=== answer so far ===
Here's my take on it — I went with a night-time observatory theme so the lava glow really pops against a starlit sky, with a molten crater that visibly "charges up" with the pressure gauge:
```html
VULCANOBS / SIM-07
VULCANOBS / SIM-07
MONTE CENIZA — LIVE TELEMETRY
60
FPS
0
ACTIVE PARTICLES
LAVA 0 · ASH 0