[claude] Weather system tied to real weather at Lempster NH (#270) #332
183
app.js
183
app.js
@@ -1043,6 +1043,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);
|
||||
@@ -2300,6 +2328,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,
|
||||
|
||||
@@ -56,6 +56,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(() => {});
|
||||
|
||||
35
style.css
35
style.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user