diff --git a/client/src/components/ThreeFireOverlay.tsx b/client/src/components/ThreeFireOverlay.tsx
index 9890866..8aac6d9 100644
--- a/client/src/components/ThreeFireOverlay.tsx
+++ b/client/src/components/ThreeFireOverlay.tsx
@@ -43,14 +43,14 @@ const FRAG = /* glsl */ `
return v / 0.9375;
}
- // Fire colour ramp: black → dark red → orange → yellow → pale yellow
+ // Fire colour ramp: black → deep red tips → orange → amber → warm yellow core
vec3 fireRamp(float t) {
t = clamp(t, 0.0, 1.0);
- vec3 c0 = vec3(0.00, 0.00, 0.00);
- vec3 c1 = vec3(0.65, 0.04, 0.00);
- vec3 c2 = vec3(1.00, 0.28, 0.00);
- vec3 c3 = vec3(1.00, 0.76, 0.05);
- vec3 c4 = vec3(1.00, 1.00, 0.55);
+ vec3 c0 = vec3(0.00, 0.00, 0.00); // black / transparent
+ vec3 c1 = vec3(0.68, 0.05, 0.00); // deep red (cool tips)
+ vec3 c2 = vec3(1.00, 0.32, 0.00); // rich orange (mid flame)
+ vec3 c3 = vec3(1.00, 0.56, 0.02); // amber-orange (inner flame)
+ vec3 c4 = vec3(1.00, 0.84, 0.28); // warm yellow (dense core, not pure white)
if (t < 0.25) return mix(c0, c1, t * 4.0);
if (t < 0.50) return mix(c1, c2, (t - 0.25) * 4.0);
if (t < 0.75) return mix(c2, c3, (t - 0.50) * 4.0);
@@ -58,19 +58,25 @@ const FRAG = /* glsl */ `
}
void main() {
- // How tall the fire reaches (fraction of screen height)
- float fireH = 0.28 + uIntensity * 0.24; // 0.28–0.52
-
- // Normalised height inside fire zone (0=ground, 1=top of flames)
- float yN = vUv.y / fireH;
-
- // Discard pixels comfortably above the fire zone
- if (yN > 1.6) { gl_FragColor = vec4(0.0); return; }
-
float t = uTime * 0.45;
- // Noise coords: aspect-corrected X, upward-scrolling Y
- vec2 p = vec2(vUv.x * uAspect * 1.6, vUv.y / fireH * 1.4 - t);
+ // Base fire height (fraction of screen height) — taller overall
+ float fireH = 0.42 + uIntensity * 0.28; // 0.42–0.70
+
+ // Per-column height variation: slow wide FBM humps create distinct tall/short columns.
+ // Varying colFireH changes where yN=1 lands on screen, so the early discard below
+ // produces a naturally jagged silhouette rather than a flat top.
+ float heightVar = 0.52 * fbm(vec2(vUv.x * uAspect * 1.1 + t * 0.07, t * 0.05));
+ float colFireH = fireH * (1.0 + heightVar); // per-column effective height
+
+ // Normalised height inside this column's fire zone (0=ground, 1=column top)
+ float yN = vUv.y / colFireH;
+
+ // Discard pixels above the fire zone — jagged because colFireH varies per column
+ if (yN > 1.6) { gl_FragColor = vec4(0.0); return; }
+
+ // Noise coords: aspect-corrected X, upward-scrolling Y scaled to column height
+ vec2 p = vec2(vUv.x * uAspect * 1.6, vUv.y / colFireH * 1.4 - t);
// Layered FBM — coarse structure + fine flicker
float n = fbm(p * 1.8);
@@ -78,12 +84,12 @@ const FRAG = /* glsl */ `
n += 0.20 * fbm(p * 7.0 - vec2(0.0, t * 0.6));
n /= 1.75;
- // Vary flame height horizontally for ragged top edge
- float crest = 0.25 * noise(vec2(vUv.x * uAspect * 2.0 + t * 0.18, 0.3));
+ // Fine ragged tips on top of the broad column silhouette
+ float crest = 0.16 * noise(vec2(vUv.x * uAspect * 4.0 + t * 0.22, 1.1));
float yMod = yN + crest;
- // Core fire value
- float fire = (1.0 - yMod) * 1.1 + n * 0.75 - 0.05;
+ // Core fire value — 0.88 lets noise breathe at ground level
+ float fire = (1.0 - yMod) * 0.88 + n * 0.92 - 0.05;
fire *= 1.0 + uIntensity * 0.35;
fire = clamp(fire, 0.0, 1.0);
@@ -92,7 +98,10 @@ const FRAG = /* glsl */ `
fire *= 0.72 + edge * 0.28;
vec3 col = fireRamp(fire);
- float alpha = fire * fire; // quadratic → softer halo falloff
+ // Deep red/ember tint near the base — coals and heat where flame meets ground
+ float emberBase = smoothstep(0.30, 0.0, yN);
+ col = mix(col, vec3(0.70, 0.08, 0.00), emberBase * 0.45);
+ float alpha = pow(fire, 1.6);
gl_FragColor = vec4(col * alpha, alpha);
}
diff --git a/client/src/lib/particleConfigs.ts b/client/src/lib/particleConfigs.ts
index fa85718..ea47e9a 100644
--- a/client/src/lib/particleConfigs.ts
+++ b/client/src/lib/particleConfigs.ts
@@ -1,25 +1,26 @@
import type { ISourceOptions } from "@tsparticles/engine";
/**
- * Rain — intensity 0–100 → count 200–600, speed 15–30
- * Fast-falling blue-white circles. At this speed the motion itself
- * reads as rain streaks — no elongated shape needed.
+ * Rain — intensity 0–100 → count 200–600, speed 15–22.5
+ * More translucent, half speed range, direction tilts from 90° (vertical)
+ * to 70° (wind-driven) as intensity rises. In tsparticles degrees: 90=down, 70=bottom-right lean.
*/
export function getRainConfig(intensity: number): ISourceOptions {
const count = Math.round(200 + (intensity / 100) * 400);
- const speed = 15 + (intensity / 100) * 15;
+ const speed = 15 + (intensity / 100) * 7.5; // half the previous speed range
+ const direction = 90 - (intensity / 100) * 20; // 90° straight down → 70° angled
return {
fullScreen: { enable: true, zIndex: 9997 },
particles: {
number: { value: count, density: { enable: false } },
color: { value: "#aad4f5" },
shape: { type: "circle" },
- opacity: { value: { min: 0.4, max: 0.75 } },
+ opacity: { value: { min: 0.15, max: 0.40 } },
size: { value: { min: 1, max: 2.5 } },
move: {
enable: true,
speed: speed,
- direction: "bottom",
+ direction: direction as never,
straight: true,
outModes: { default: "out" },
},
@@ -29,12 +30,11 @@ export function getRainConfig(intensity: number): ISourceOptions {
}
/**
- * Embers — intensity 0–100 → count 20–80, speed 0.5–2
- * Slow-drifting warm amber dots with flickering opacity.
- * Clearly slower and dimmer than fire — different mood entirely.
+ * Embers — intensity 0–100 → count 50–150, speed 0.5–2
+ * Smaller, more numerous slow-drifting sparks. Flickering opacity.
*/
export function getEmbersConfig(intensity: number): ISourceOptions {
- const count = Math.round(20 + (intensity / 100) * 60);
+ const count = Math.round(50 + (intensity / 100) * 100);
const speed = 0.5 + (intensity / 100) * 1.5;
return {
fullScreen: { enable: true, zIndex: 9997 },
@@ -46,7 +46,7 @@ export function getEmbersConfig(intensity: number): ISourceOptions {
value: { min: 0.15, max: 0.9 },
animation: { enable: true, speed: 0.6, sync: false, startValue: "random" },
},
- size: { value: { min: 1.5, max: 4 } },
+ size: { value: { min: 0.8, max: 2.2 } },
move: {
enable: true,
speed: speed,
diff --git a/site/assets/screenshots/atmosphere.png b/site/assets/screenshots/atmosphere.png
index c663fb7..3ec5936 100644
Binary files a/site/assets/screenshots/atmosphere.png and b/site/assets/screenshots/atmosphere.png differ
diff --git a/site/screenshots.js b/site/screenshots.js
index 8ae9292..cd928e9 100644
--- a/site/screenshots.js
+++ b/site/screenshots.js
@@ -198,13 +198,25 @@ async function captureAtmosphere(page) {
await page.click('button[title="Atmosphere effects"]');
await page.waitForTimeout(300);
- // Toggle Fog on — the toggle button has aria-label="Toggle Fog"
+ // Toggle Fog on
await page.click('button[aria-label="Toggle Fog"]');
- await page.waitForTimeout(800); // wait for fog to render
- // Screenshot captures fog effect over the campaign view
+ await page.waitForTimeout(400);
- // Close the atmosphere panel after so it doesn't clutter other captures
- await page.keyboard.press('Escape');
+ // Toggle Fire on and set intensity to 25%
+ // Effects order: fog(0), fire(1), rain(2), embers(3) — fire is 2nd slider
+ await page.click('button[aria-label="Toggle Fire"]');
+ await page.waitForTimeout(300);
+ await page.evaluate(() => {
+ const sliders = document.querySelectorAll('input[type="range"]');
+ const fireSlider = sliders[1];
+ fireSlider.value = '25';
+ fireSlider.dispatchEvent(new Event('input', { bubbles: true }));
+ fireSlider.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ });
+ await page.waitForTimeout(1200); // wait for both effects to render
+
+ // Close the atmosphere panel so it doesn't clutter the screenshot
+ await page.click('button[title="Atmosphere effects"]');
await page.waitForTimeout(200);
}