[claude] Weather system tied to real weather at Lempster NH (#270) (#332)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled

This commit was merged in pull request #332.
This commit is contained in:
2026-03-24 05:03:12 +00:00
parent 548a59c5a6
commit 1780011c8b
3 changed files with 224 additions and 0 deletions

183
app.js
View File

@@ -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}&current=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,

View File

@@ -61,6 +61,12 @@
<span class="zoom-hint">[Esc] or double-click to exit</span>
</div>
<div id="weather-hud">
<span id="weather-icon"></span>
<span id="weather-temp">--°F</span>
<span id="weather-desc">Lempster NH</span>
</div>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});

View File

@@ -201,6 +201,41 @@ body.photo-mode #overview-indicator {
font-size: 10px;
}
/* === WEATHER HUD === */
#weather-hud {
position: fixed;
bottom: 14px;
left: 14px;
display: flex;
align-items: center;
gap: 6px;
background: rgba(0, 6, 20, 0.72);
border: 1px solid rgba(68, 136, 255, 0.35);
border-radius: 6px;
padding: 5px 10px;
font-family: var(--font-body);
font-size: 12px;
color: var(--color-text);
z-index: 10;
pointer-events: none;
transition: opacity 0.5s ease;
}
#weather-icon {
font-size: 16px;
}
#weather-temp {
color: var(--color-primary);
font-weight: bold;
min-width: 40px;
}
#weather-desc {
color: var(--color-text-muted);
font-size: 11px;
}
/* === SOVEREIGNTY EASTER EGG === */
#sovereignty-msg {
display: none;