diff --git a/client/src/components/FogOverlay.module.css b/client/src/components/FogOverlay.module.css index e8f4bc0..1212bf3 100644 --- a/client/src/components/FogOverlay.module.css +++ b/client/src/components/FogOverlay.module.css @@ -4,19 +4,9 @@ z-index: 9998; pointer-events: none; overflow: hidden; - animation: fadeIn 3s ease-in; filter: var(--fog-filter); } -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - .layer1, .layer2, .layer3 { diff --git a/client/src/components/FogOverlay.tsx b/client/src/components/FogOverlay.tsx index 6d2b696..71f5607 100644 --- a/client/src/components/FogOverlay.tsx +++ b/client/src/components/FogOverlay.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import styles from "./FogOverlay.module.css"; interface FogOverlayProps { @@ -6,13 +7,23 @@ interface FogOverlayProps { } export default function FogOverlay({ active, intensity }: FogOverlayProps) { + // One-frame delay lets the browser paint opacity:0 first so the CSS transition fires. + // Without this the element mounts at full opacity with no fade-in. + const [visible, setVisible] = useState(false); + + useEffect(() => { + if (!active) { setVisible(false); return; } + const id = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(id); + }, [active]); + if (!active) return null; // Map 0–100 to 0.15–1.0 so fog is always at least faintly visible when active - const opacity = 0.15 + (intensity / 100) * 0.85; + const opacity = visible ? 0.15 + (intensity / 100) * 0.85 : 0; return ( -
+
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); }