Files
the-nexus/modules/effects/gravity-zones.js
Claude (Opus 4.6) 35dd6c5f17
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
[claude] Phase 4: Effects modules — matrix rain, lightning, beam, runes, gravity, shockwave (#423) (#444)
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-24 18:19:26 +00:00

177 lines
6.0 KiB
JavaScript

/**
* gravity-zones.js — Rising particle gravity anomaly zones
*
* Category: DATA-TETHERED AESTHETIC
* Data source: state.portals (positions and online status)
*
* Each gravity zone is a glowing floor ring with rising particle streams.
* Zones are initially placed at hardcoded positions, then realigned to portal
* positions when portal data loads. Online portals have brighter/faster anomalies;
* offline portals have dim, slow anomalies.
*/
import * as THREE from 'three';
const ANOMALY_FLOOR = 0.2;
const ANOMALY_CEIL = 16.0;
const DEFAULT_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 },
];
let _state = null;
let _scene = null;
let _portalsApplied = false;
/**
* @typedef {{
* zone: object,
* ring: THREE.Mesh, ringMat: THREE.MeshBasicMaterial,
* disc: THREE.Mesh, discMat: THREE.MeshBasicMaterial,
* points: THREE.Points, geo: THREE.BufferGeometry,
* driftPhases: Float32Array, velocities: Float32Array
* }} GravityZoneObject
*/
/** @type {GravityZoneObject[]} */
const gravityZoneObjects = [];
function _buildZone(zone) {
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, ANOMALY_FLOOR + 0.05, zone.z);
_scene.add(ring);
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, ANOMALY_FLOOR + 0.04, zone.z);
_scene.add(disc);
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] = ANOMALY_FLOOR + Math.random() * (ANOMALY_CEIL - 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: { ...zone }, ring, ringMat, disc, discMat, points, geo, driftPhases, velocities };
}
/**
* @param {THREE.Scene} scene
* @param {object} state Shared state bus (reads state.portals)
* @param {object} _theme
*/
export function init(scene, state, _theme) {
_scene = scene;
_state = state;
for (const zone of DEFAULT_ZONES) {
gravityZoneObjects.push(_buildZone(zone));
}
}
function _applyPortals(portals) {
_portalsApplied = true;
for (let i = 0; i < Math.min(portals.length, gravityZoneObjects.length); i++) {
const portal = portals[i];
const gz = gravityZoneObjects[i];
const isOnline = portal.status === 'online';
const c = new THREE.Color(portal.color);
gz.ring.position.set(portal.position.x, ANOMALY_FLOOR + 0.05, portal.position.z);
gz.disc.position.set(portal.position.x, ANOMALY_FLOOR + 0.04, portal.position.z);
gz.zone.x = portal.position.x;
gz.zone.z = portal.position.z;
gz.zone.color = c.getHex();
gz.ringMat.color.copy(c);
gz.discMat.color.copy(c);
gz.points.material.color.copy(c);
gz.ringMat.opacity = isOnline ? 0.4 : 0.08;
gz.discMat.opacity = isOnline ? 0.04 : 0.01;
gz.points.material.opacity = isOnline ? 0.7 : 0.15;
// Reposition particles around portal
const pos = gz.geo.attributes.position.array;
for (let j = 0; j < gz.zone.particleCount; j++) {
const angle = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * gz.zone.radius;
pos[j * 3] = gz.zone.x + Math.cos(angle) * r;
pos[j * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
}
gz.geo.attributes.position.needsUpdate = true;
}
}
export function update(elapsed, _delta) {
// Align to portal data once it loads
if (!_portalsApplied) {
const portals = _state?.portals ?? [];
if (portals.length > 0) _applyPortals(portals);
}
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] > 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] = ANOMALY_FLOOR + Math.random() * 2.0;
pos[i * 3 + 2] = gz.zone.z + Math.sin(angle) * r;
}
}
gz.geo.attributes.position.needsUpdate = true;
// Breathing glow pulse on ring/disc
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;
}
}
/**
* Re-align zones to current portal data.
* Call after portal health check updates portal statuses.
*/
export function rebuildFromPortals() {
const portals = _state?.portals ?? [];
if (portals.length > 0) _applyPortals(portals);
}