// 天氣視覺特效(r86,Z-up):雨/雪粒子(THREE.Points 跟著鏡頭的粒子盒)、雲層 // billboard、霧濃度/夜航連動、樹隨風搖。狀態源 = adapter /api/state(考官拉 // 「暴風雨」情境,畫面就真的下雨)。效能原則:粒子+貼圖,不加幾何;共用材質。 (function () { var state = { wind: { speed_mps: 0, direction_deg: 0 }, weather: { rain: 0, fog: 0, snow: 0 } }; var night = false; var rain = null, snow = null, clouds = []; var skyDay = null, skyNight = null; var lastT = null; function lerp(a, b, k) { return a + (b - a) * k; } // ---- 粒子盒(跟著鏡頭;count 上限,依強度用 drawRange 顯示比例) ---- function makeParticles(count, color, size, opacity) { var geo = new THREE.BufferGeometry(); var pos = new Float32Array(count * 3); for (var i = 0; i < count; i++) { pos[i * 3] = (Math.random() - 0.5) * 120; pos[i * 3 + 1] = (Math.random() - 0.5) * 120; pos[i * 3 + 2] = Math.random() * 70; } geo.addAttribute('position', new THREE.BufferAttribute(pos, 3)); // r86 是 addAttribute var m = new THREE.Points(geo, new THREE.PointsMaterial({ color: color, size: size, transparent: true, opacity: opacity, sizeAttenuation: true, depthWrite: false })); m.frustumCulled = false; return m; } // 粒子下落 + 以鏡頭為中心 120m 盒內回繞 function stepParticles(p, dt, fall, drift, cam) { var a = p.geometry.attributes.position, arr = a.array, n = arr.length / 3; var wr = state.wind.direction_deg * Math.PI / 180; var wx = -Math.sin(wr) * drift * state.wind.speed_mps; var wy = -Math.cos(wr) * drift * state.wind.speed_mps; for (var i = 0; i < n; i++) { arr[i * 3] += wx * dt; arr[i * 3 + 1] += wy * dt; arr[i * 3 + 2] -= fall * dt; var dx = arr[i * 3] - cam.x, dy = arr[i * 3 + 1] - cam.y, dz = arr[i * 3 + 2] - cam.z; if (dz < -25) arr[i * 3 + 2] += 70; if (dz > 50) arr[i * 3 + 2] -= 70; if (dx > 60) arr[i * 3] -= 120; else if (dx < -60) arr[i * 3] += 120; if (dy > 60) arr[i * 3 + 1] -= 120; else if (dy < -60) arr[i * 3 + 1] += 120; } a.needsUpdate = true; } // ---- 天空(晝/夜漸層,夜空撒星) ---- function makeSky(nightMode) { var cv = document.createElement('canvas'); cv.width = 32; cv.height = 512; var c = cv.getContext('2d'); var g = c.createLinearGradient(0, 0, 0, 512); if (nightMode) { g.addColorStop(0, '#040711'); g.addColorStop(0.5, '#0a1224'); g.addColorStop(1, '#141d33'); c.fillStyle = g; c.fillRect(0, 0, 32, 512); c.fillStyle = '#dfe8ff'; for (var i = 0; i < 60; i++) { c.globalAlpha = 0.3 + Math.random() * 0.7; c.fillRect(Math.random() * 32, Math.random() * 300, 1, 1); } c.globalAlpha = 1; } else { g.addColorStop(0, '#3b7fc4'); g.addColorStop(0.5, '#8fbde6'); g.addColorStop(1, '#dce9f2'); c.fillStyle = g; c.fillRect(0, 0, 32, 512); } return new THREE.CanvasTexture(cv); } function makeClouds(sc) { var cv = document.createElement('canvas'); cv.width = 128; cv.height = 128; var c = cv.getContext('2d'); var g = c.createRadialGradient(64, 64, 8, 64, 64, 62); g.addColorStop(0, 'rgba(255,255,255,.9)'); g.addColorStop(0.6, 'rgba(255,255,255,.45)'); g.addColorStop(1, 'rgba(255,255,255,0)'); c.fillStyle = g; c.fillRect(0, 0, 128, 128); var tex = new THREE.CanvasTexture(cv); var mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, opacity: 0.8, depthWrite: false, side: THREE.DoubleSide }); var geo = new THREE.PlaneGeometry(1, 1); for (var i = 0; i < 8; i++) { var m = new THREE.Mesh(geo, mat); var s = 90 + Math.random() * 120; m.scale.set(s, s * (0.5 + Math.random() * 0.5), 1); m.position.set((Math.random() - 0.5) * 900, (Math.random() - 0.5) * 900, 125 + Math.random() * 60); sc.scene.add(m); clouds.push(m); } } // ---- 夜航切換(燈光/天空/霧色;窗燈路燈紅綠燈是 Basic 材質,夜裡自然發亮) ---- window.zyNight = function (on) { night = on == null ? !night : !!on; var sc = window.scene; if (!sc || !sc.scene) return night; if (!skyDay) skyDay = makeSky(false); if (!skyNight) skyNight = makeSky(true); sc.scene.__zySky = night ? skyNight : skyDay; // visual-boost 週期重套會用這張 sc.scene.background = sc.scene.__zySky; var sun = sc.scene.getObjectByName('zy-sun'); var amb = sc.scene.getObjectByName('zy-amb'); var hemi = sc.scene.getObjectByName('zy-hemi'); if (sun) sun.intensity = night ? 0.1 : 0.75; if (amb) amb.intensity = night ? 0.14 : 0.35; if (hemi) hemi.intensity = night ? 0.1 : 0.45; applyFog(); return night; }; window.zyIsNight = function () { return night; }; // 霧:考題 fog 0..1 → 能見度;夜航霧色偏暗藍。visual-boost 重套時讀 window.zyFog。 function applyFog() { var f = state.weather.fog || 0; window.zyFog = { color: night ? 0x0a1322 : 0xcfe0ee, near: lerp(night ? 100 : 120, 15, f), far: lerp(night ? 650 : 900, 90, f), }; var sc = window.scene; if (sc && sc.scene) sc.scene.fog = new THREE.Fog(window.zyFog.color, window.zyFog.near, window.zyFog.far); } async function pollState() { try { var j = await (await fetch('/api/state')).json(); if (j && j.wind) state.wind = j.wind; if (j && j.weather) state.weather = j.weather; applyFog(); } catch (e) {} } function tick(t) { var dt = lastT == null ? 0.016 : Math.min(0.1, (t - lastT) / 1000); lastT = t; var sc = window.scene; if (sc && sc.scene && sc.camera) { var cam = sc.camera.position; if (!rain) { rain = makeParticles(2600, 0xa9c4da, 0.24, 0.7); snow = makeParticles(1600, 0xffffff, 0.28, 0.85); sc.scene.add(rain); sc.scene.add(snow); makeClouds(sc); } var rl = Math.max(0, Math.min(1, state.weather.rain || 0)); var sl = Math.max(0, Math.min(1, state.weather.snow || 0)); rain.geometry.setDrawRange(0, Math.floor(2600 * rl)); snow.geometry.setDrawRange(0, Math.floor(1600 * sl)); rain.visible = rl > 0; snow.visible = sl > 0; if (rl > 0) stepParticles(rain, dt, 34, 0.9, cam); if (sl > 0) stepParticles(snow, dt, 3.5, 1.4, cam); // 雲:順風漂移,離鏡頭太遠搬回附近 var wr = state.wind.direction_deg * Math.PI / 180; var cwx = -Math.sin(wr) * (1 + state.wind.speed_mps * 0.4) * dt; var cwy = -Math.cos(wr) * (1 + state.wind.speed_mps * 0.4) * dt; clouds.forEach(function (cl) { cl.position.x += cwx; cl.position.y += cwy; if (Math.hypot(cl.position.x - cam.x, cl.position.y - cam.y) > 800) { cl.position.x = cam.x + (Math.random() - 0.5) * 700; cl.position.y = cam.y + (Math.random() - 0.5) * 700; } }); stepFlocks(dt, cam, t); // 樹隨風搖(振幅連動風速;canopy 註冊在各 chunk userData.sway) if (window.zyTerrain && window.zyTerrain.groups) { var amp = Math.min(state.wind.speed_mps, 15) / 15 * 0.09; var ph = t / 1000; var gs = window.zyTerrain.groups(); Object.keys(gs).forEach(function (k) { var sw = gs[k].userData.sway; if (!sw) return; for (var i = 0; i < sw.length; i++) { var m = sw[i]; if (m.userData.swayBase == null) m.userData.swayBase = m.rotation.x; // 針葉錐有 90° 基準 m.rotation.x = m.userData.swayBase + Math.sin(ph * 1.9 + m.position.x * 0.7) * amp; m.rotation.y = Math.cos(ph * 1.6 + m.position.y * 0.7) * amp * 0.6; } }); } } requestAnimationFrame(tick); } // ---- 鳥群(純視覺,無碰撞):兩群小黑鳥繞著鏡頭附近盤旋,拍翅上下擺 ---- var flocks = []; function makeFlocks(sc) { var mat = new THREE.MeshBasicMaterial({ color: 0x23262b }); var geo = new THREE.ConeGeometry(0.25, 1.0, 4); for (var f = 0; f < 2; f++) { var flock = { birds: [], cx: (Math.random() - 0.5) * 300, cy: (Math.random() - 0.5) * 300, alt: 20 + Math.random() * 25, dir: Math.random() * Math.PI * 2, speed: 8 + Math.random() * 5 }; for (var i = 0; i < 7; i++) { var m = new THREE.Mesh(geo, mat); m.userData.off = { x: (Math.random() - 0.5) * 10, y: (Math.random() - 0.5) * 10, z: (Math.random() - 0.5) * 3, ph: Math.random() * 6 }; sc.scene.add(m); flock.birds.push(m); } flocks.push(flock); } } function stepFlocks(dt, cam, t) { flocks.forEach(function (fl) { fl.dir += (Math.random() - 0.5) * 0.3 * dt * 10; // 緩慢轉向 fl.cx += Math.cos(fl.dir) * fl.speed * dt; fl.cy += Math.sin(fl.dir) * fl.speed * dt; if (Math.hypot(fl.cx - cam.x, fl.cy - cam.y) > 500) { // 飛遠了搬回鏡頭附近 fl.cx = cam.x + (Math.random() - 0.5) * 250; fl.cy = cam.y + (Math.random() - 0.5) * 250; } fl.birds.forEach(function (b) { var o = b.userData.off; b.position.set(fl.cx + o.x, fl.cy + o.y, fl.alt + o.z + Math.sin(t / 1000 * 6 + o.ph) * 0.8); // 拍翅起伏 b.rotation.set(Math.PI / 2, 0, -(fl.dir - Math.PI / 2)); // 錐尖朝飛行方向 }); }); } // ---- 晝夜自動循環:10 分鐘白天 → 5 分鐘夜間 ---- var cycleOn = false, cycleTimer = null; window.zyDayCycle = function (on) { cycleOn = on == null ? !cycleOn : !!on; if (cycleTimer) { clearInterval(cycleTimer); cycleTimer = null; } if (cycleOn) { cycleTimer = setInterval(function () { var t = (Date.now() / 1000) % 900; // 15 分鐘一輪 var wantNight = t > 600; // 前 10 分鐘白天,後 5 分鐘夜間 if (wantNight !== night) window.zyNight(wantNight); }, 5000); } return cycleOn; }; var wait = setInterval(function () { if (window.scene && window.scene.scene && window.THREE) { clearInterval(wait); pollState(); setInterval(pollState, 2000); makeFlocks(window.scene); requestAnimationFrame(tick); console.log('[weather-fx] active'); } }, 500); })();