Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
177 lines
6.0 KiB
JavaScript
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);
|
|
}
|