diff --git a/app.js b/app.js index 540375d..d30e157 100644 --- a/app.js +++ b/app.js @@ -1166,6 +1166,34 @@ function animate() { rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2; } + // === WEATHER PARTICLE ANIMATION === + if (rainParticles.visible) { + const rpos = rainGeo.attributes.position.array; + for (let i = 0; i < PRECIP_COUNT; i++) { + rpos[i * 3 + 1] -= rainVelocities[i]; + if (rpos[i * 3 + 1] < PRECIP_FLOOR) { + rpos[i * 3 + 1] = PRECIP_HEIGHT; + rpos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rpos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + } + } + rainGeo.attributes.position.needsUpdate = true; + } + + if (snowParticles.visible) { + const spos = snowGeo.attributes.position.array; + for (let i = 0; i < PRECIP_COUNT; i++) { + spos[i * 3 + 1] -= 0.025 + Math.sin(snowDrift[i]) * 0.005; + spos[i * 3] += Math.sin(elapsed * 0.4 + snowDrift[i]) * 0.008; + if (spos[i * 3 + 1] < PRECIP_FLOOR) { + spos[i * 3 + 1] = PRECIP_HEIGHT; + spos[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + spos[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + } + } + snowGeo.attributes.position.needsUpdate = true; + } + // Portal collision detection forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); raycaster.set(camera.position, forwardVector); @@ -2423,6 +2451,161 @@ loadLoRAStatus(); // Refresh every 60 s so live updates propagate setInterval(loadLoRAStatus, 60000); +// === WEATHER SYSTEM — Lempster NH === +// Fetches real current weather from Open-Meteo (no API key required). +// Lempster, NH coordinates: 43.2897° N, 72.1479° W +// Drives particle rain/snow effects and ambient mood tinting. + +const WEATHER_LAT = 43.2897; +const WEATHER_LON = -72.1479; +const WEATHER_REFRESH_MS = 15 * 60 * 1000; // 15 minutes + +/** @type {{ code: number, temp: number, wind: number, condition: string, icon: string }|null} */ +let weatherState = null; + +// Particle constants +const PRECIP_COUNT = 1200; +const PRECIP_AREA = 18; // half-width of spawn box (scene units) +const PRECIP_HEIGHT = 20; // top spawn Y +const PRECIP_FLOOR = -5; // bottom Y before reset + +// Rain geometry & material +const rainGeo = new THREE.BufferGeometry(); +const rainPositions = new Float32Array(PRECIP_COUNT * 3); +const rainVelocities = new Float32Array(PRECIP_COUNT); // per-particle fall speed + +for (let i = 0; i < PRECIP_COUNT; i++) { + rainPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rainPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; + rainPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + rainVelocities[i] = 0.18 + Math.random() * 0.12; +} +rainGeo.setAttribute('position', new THREE.BufferAttribute(rainPositions, 3)); + +const rainMat = new THREE.PointsMaterial({ + color: 0x88aaff, + size: 0.05, + sizeAttenuation: true, + transparent: true, + opacity: 0.55, +}); + +const rainParticles = new THREE.Points(rainGeo, rainMat); +rainParticles.visible = false; +scene.add(rainParticles); + +// Snow geometry & material +const snowGeo = new THREE.BufferGeometry(); +const snowPositions = new Float32Array(PRECIP_COUNT * 3); +const snowDrift = new Float32Array(PRECIP_COUNT); // horizontal drift phase + +for (let i = 0; i < PRECIP_COUNT; i++) { + snowPositions[i * 3] = (Math.random() - 0.5) * PRECIP_AREA * 2; + snowPositions[i * 3 + 1] = Math.random() * (PRECIP_HEIGHT - PRECIP_FLOOR) + PRECIP_FLOOR; + snowPositions[i * 3 + 2] = (Math.random() - 0.5) * PRECIP_AREA * 2; + snowDrift[i] = Math.random() * Math.PI * 2; +} +snowGeo.setAttribute('position', new THREE.BufferAttribute(snowPositions, 3)); + +const snowMat = new THREE.PointsMaterial({ + color: 0xddeeff, + size: 0.12, + sizeAttenuation: true, + transparent: true, + opacity: 0.75, +}); + +const snowParticles = new THREE.Points(snowGeo, snowMat); +snowParticles.visible = false; +scene.add(snowParticles); + +/** + * Maps a WMO weather code to a human-readable condition label and icon. + * @param {number} code + * @returns {{ condition: string, icon: string }} + */ +function weatherCodeToLabel(code) { + if (code === 0) return { condition: 'Clear', icon: '☀️' }; + if (code <= 2) return { condition: 'Partly Cloudy', icon: '⛅' }; + if (code === 3) return { condition: 'Overcast', icon: '☁️' }; + if (code >= 45 && code <= 48) return { condition: 'Fog', icon: '🌫️' }; + if (code >= 51 && code <= 57) return { condition: 'Drizzle', icon: '🌦️' }; + if (code >= 61 && code <= 67) return { condition: 'Rain', icon: '🌧️' }; + if (code >= 71 && code <= 77) return { condition: 'Snow', icon: '❄️' }; + if (code >= 80 && code <= 82) return { condition: 'Showers', icon: '🌦️' }; + if (code >= 85 && code <= 86) return { condition: 'Snow Showers', icon: '🌨️' }; + if (code >= 95 && code <= 99) return { condition: 'Thunderstorm', icon: '⛈️' }; + return { condition: 'Unknown', icon: '🌀' }; +} + +/** + * Applies weather state to scene — particle visibility and ambient tint. + * @param {{ code: number, temp: number, wind: number, condition: string, icon: string }} wx + */ +function applyWeatherToScene(wx) { + const code = wx.code; + + // Precipitation + const isRain = (code >= 51 && code <= 67) || (code >= 80 && code <= 82) || (code >= 95 && code <= 99); + const isSnow = (code >= 71 && code <= 77) || (code >= 85 && code <= 86); + + rainParticles.visible = isRain; + snowParticles.visible = isSnow; + + // Ambient mood tint + if (isSnow) { + ambientLight.color.setHex(0x1a2a40); // cold blue-white + ambientLight.intensity = 1.8; + } else if (isRain) { + ambientLight.color.setHex(0x0a1428); // darker, bluer + ambientLight.intensity = 1.2; + } else if (code === 3 || (code >= 45 && code <= 48)) { + ambientLight.color.setHex(0x0c1220); // overcast grey-blue + ambientLight.intensity = 1.1; + } else { + ambientLight.color.setHex(0x0a1428); // default clear + ambientLight.intensity = 1.4; + } +} + +/** + * Updates the weather HUD elements in the DOM. + * @param {{ temp: number, condition: string, icon: string }} wx + */ +function updateWeatherHUD(wx) { + const iconEl = document.getElementById('weather-icon'); + const tempEl = document.getElementById('weather-temp'); + const descEl = document.getElementById('weather-desc'); + if (iconEl) iconEl.textContent = wx.icon; + if (tempEl) tempEl.textContent = `${Math.round(wx.temp)}°F`; + if (descEl) descEl.textContent = wx.condition; +} + +/** + * Fetches current weather for Lempster NH from Open-Meteo and applies it. + */ +async function fetchWeather() { + try { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`; + const res = await fetch(url); + if (!res.ok) throw new Error('weather fetch failed'); + const data = await res.json(); + const cur = data.current; + const code = cur.weather_code; + const { condition, icon } = weatherCodeToLabel(code); + weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon }; + applyWeatherToScene(weatherState); + updateWeatherHUD(weatherState); + } catch { + // Silently use defaults — no weather data available + const descEl = document.getElementById('weather-desc'); + if (descEl) descEl.textContent = 'Lempster NH'; + } +} + +fetchWeather(); +setInterval(fetchWeather, WEATHER_REFRESH_MS); + // === TIMMY SPEECH BUBBLE === // When Timmy sends a chat message, a glowing floating text sprite appears near // his avatar position above the platform. Fades in quickly, holds for 5 s total, diff --git a/index.html b/index.html index a6db193..7625932 100644 --- a/index.html +++ b/index.html @@ -61,6 +61,12 @@ [Esc] or double-click to exit +
+ + --°F + Lempster NH +
+