diff --git a/app.js b/app.js index 7765897..228cef7 100644 --- a/app.js +++ b/app.js @@ -1394,6 +1394,27 @@ function animate() { snowGeo.attributes.position.needsUpdate = true; } + // === GRAVITY ANOMALY ANIMATION === + for (const gz of gravityZoneObjects) { + const pos = gz.geo.attributes.position.array; + const count = gz.zone.particleCount; + for (let i = 0; i < count; i++) { + pos[i * 3 + 1] += gz.velocities[i]; + pos[i * 3] += Math.sin(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + pos[i * 3 + 2] += Math.cos(elapsed * 0.5 + gz.driftPhases[i]) * 0.003; + if (pos[i * 3 + 1] > GRAVITY_ANOMALY_CEIL) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * gz.zone.radius; + pos[i * 3] = gz.zone.x + Math.cos(angle) * r; + pos[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * 2.0; + pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r; + } + } + gz.geo.attributes.position.needsUpdate = true; + gz.ringMat.opacity = 0.3 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.15; + gz.discMat.opacity = 0.02 + Math.sin(elapsed * 1.5 + gz.zone.x) * 0.02; + } + // Portal collision detection forwardVector.set(0, 0, -1).applyQuaternion(camera.quaternion); raycaster.set(camera.position, forwardVector); @@ -3073,6 +3094,82 @@ async function fetchWeather() { fetchWeather(); setInterval(fetchWeather, WEATHER_REFRESH_MS); +// === GRAVITY ANOMALY ZONES === +// Areas where particles defy gravity and float upward. +// Each zone has a glowing floor ring and a rising particle stream. + +const GRAVITY_ANOMALY_FLOOR = 0.2; // Y where particles respawn (ground level) +const GRAVITY_ANOMALY_CEIL = 16.0; // Y where particles wrap back to floor + +const GRAVITY_ZONES = [ + { x: -8, z: -6, radius: 3.5, color: 0x00ffcc, particleCount: 180 }, + { x: 10, z: 4, radius: 3.0, color: 0xaa44ff, particleCount: 160 }, + { x: -3, z: 9, radius: 2.5, color: 0xff8844, particleCount: 140 }, +]; + +const gravityZoneObjects = GRAVITY_ZONES.map((zone) => { + // Glowing floor ring + const ringGeo = new THREE.RingGeometry(zone.radius - 0.15, zone.radius + 0.15, 64); + const ringMat = new THREE.MeshBasicMaterial({ + color: zone.color, + transparent: true, + opacity: 0.4, + side: THREE.DoubleSide, + depthWrite: false, + }); + const ring = new THREE.Mesh(ringGeo, ringMat); + ring.rotation.x = -Math.PI / 2; + ring.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.05, zone.z); + scene.add(ring); + + // Faint inner disc + const discGeo = new THREE.CircleGeometry(zone.radius - 0.15, 64); + const discMat = new THREE.MeshBasicMaterial({ + color: zone.color, + transparent: true, + opacity: 0.04, + side: THREE.DoubleSide, + depthWrite: false, + }); + const disc = new THREE.Mesh(discGeo, discMat); + disc.rotation.x = -Math.PI / 2; + disc.position.set(zone.x, GRAVITY_ANOMALY_FLOOR + 0.04, zone.z); + scene.add(disc); + + // Rising particle stream + const count = zone.particleCount; + const positions = new Float32Array(count * 3); + const driftPhases = new Float32Array(count); + const velocities = new Float32Array(count); + + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const r = Math.sqrt(Math.random()) * zone.radius; + positions[i * 3] = zone.x + Math.cos(angle) * r; + positions[i * 3 + 1] = GRAVITY_ANOMALY_FLOOR + Math.random() * (GRAVITY_ANOMALY_CEIL - GRAVITY_ANOMALY_FLOOR); + positions[i * 3 + 2] = zone.z + Math.sin(angle) * r; + driftPhases[i] = Math.random() * Math.PI * 2; + velocities[i] = 0.03 + Math.random() * 0.04; + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const mat = new THREE.PointsMaterial({ + color: zone.color, + size: 0.10, + sizeAttenuation: true, + transparent: true, + opacity: 0.7, + depthWrite: false, + }); + + const points = new THREE.Points(geo, mat); + scene.add(points); + + return { zone, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities }; +}); + // === 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,