Files
the-nexus/modules/effects/rune-ring.js
Alexander Whitestone 0408ceb5bc
All checks were successful
CI / validate (pull_request) Successful in 6s
CI / auto-merge (pull_request) Successful in 10s
feat: add effects modules — matrix rain, lightning, beam, runes, gravity, shockwave
Phase 4 of app.js modularization. Extracts all visual effects into self-contained
ES modules under modules/effects/ following the init(scene,state,theme)/update(elapsed,delta)
contract defined in CLAUDE.md.

Modules created:
- matrix-rain.js  — commit-density-driven 2D canvas rain (DATA-TETHERED AESTHETIC)
- lightning.js    — floating crystals + lightning arcs (DATA-TETHERED AESTHETIC)
- energy-beam.js  — Batcave terminal beam (DATA-TETHERED AESTHETIC)
- rune-ring.js    — portal-tethered orbiting rune sprites (DATA-TETHERED AESTHETIC)
- gravity-zones.js — portal-position rising particle zones (DATA-TETHERED AESTHETIC)
- shockwave.js    — shockwave ripple, fireworks, merge flash (DATA-TETHERED AESTHETIC)

All modules read data tethers from the state bus (state.zoneIntensity,
state.portals, state.activeAgentCount, state.commitHashes). No mocked data.
app.js unchanged — final wiring happens in Phase 5 slim-down.

Refs #423

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:18:42 -04:00

139 lines
4.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* rune-ring.js — Orbiting Elder Futhark rune sprites
*
* Category: DATA-TETHERED AESTHETIC
* Data source: state.portals (count, colors, and online status from portals.json)
*
* Rune sprites orbit the scene in a ring. Count matches the portal count,
* colors come from portal colors, and brightness reflects portal online status.
* A faint torus marks the orbit track.
*/
import * as THREE from 'three';
const RUNE_RING_RADIUS = 7.0;
const RUNE_RING_Y = 1.5;
const RUNE_ORBIT_SPEED = 0.08; // radians per second
const DEFAULT_RUNE_COUNT = 12;
const ELDER_FUTHARK = ['ᚠ','ᚢ','ᚦ','ᚨ','ᚱ','','','ᚹ','ᚺ','ᚾ','','ᛃ'];
const FALLBACK_COLORS = ['#00ffcc', '#ff44ff'];
let _scene = null;
let _state = null;
/** @type {Array<{sprite: THREE.Sprite, baseAngle: number, floatPhase: number, portalOnline: boolean}>} */
const runeSprites = [];
let _orbitRingMesh = null;
let _builtForPortalCount = -1;
function _createRuneTexture(glyph, color) {
const W = 128, H = 128;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
ctx.shadowColor = color;
ctx.shadowBlur = 28;
ctx.font = 'bold 78px serif';
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(glyph, W / 2, H / 2);
return new THREE.CanvasTexture(canvas);
}
function _clearSprites() {
for (const rune of runeSprites) {
_scene.remove(rune.sprite);
if (rune.sprite.material.map) rune.sprite.material.map.dispose();
rune.sprite.material.dispose();
}
runeSprites.length = 0;
}
function _build(portals) {
_clearSprites();
const count = portals ? portals.length : DEFAULT_RUNE_COUNT;
_builtForPortalCount = count;
for (let i = 0; i < count; i++) {
const glyph = ELDER_FUTHARK[i % ELDER_FUTHARK.length];
const color = portals ? portals[i].color : FALLBACK_COLORS[i % FALLBACK_COLORS.length];
const isOnline = portals ? portals[i].status === 'online' : true;
const texture = _createRuneTexture(glyph, color);
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: isOnline ? 1.0 : 0.15,
depthWrite: false,
blending: THREE.AdditiveBlending,
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(1.3, 1.3, 1);
const baseAngle = (i / count) * Math.PI * 2;
sprite.position.set(
Math.cos(baseAngle) * RUNE_RING_RADIUS,
RUNE_RING_Y,
Math.sin(baseAngle) * RUNE_RING_RADIUS
);
_scene.add(sprite);
runeSprites.push({ sprite, baseAngle, floatPhase: (i / count) * Math.PI * 2, portalOnline: isOnline });
}
}
/**
* @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;
// Faint orbit track torus
const ringGeo = new THREE.TorusGeometry(RUNE_RING_RADIUS, 0.03, 6, 64);
const ringMat = new THREE.MeshBasicMaterial({ color: 0x224466, transparent: true, opacity: 0.22 });
_orbitRingMesh = new THREE.Mesh(ringGeo, ringMat);
_orbitRingMesh.rotation.x = Math.PI / 2;
_orbitRingMesh.position.y = RUNE_RING_Y;
scene.add(_orbitRingMesh);
// Initial build with defaults — will be rebuilt when portals load
_build(null);
}
export function update(elapsed, _delta) {
// Rebuild rune sprites when portal data changes
const portals = _state?.portals ?? [];
if (portals.length > 0 && portals.length !== _builtForPortalCount) {
_build(portals);
}
// Orbit and float
for (const rune of runeSprites) {
const angle = rune.baseAngle + elapsed * RUNE_ORBIT_SPEED;
rune.sprite.position.x = Math.cos(angle) * RUNE_RING_RADIUS;
rune.sprite.position.z = Math.sin(angle) * RUNE_RING_RADIUS;
rune.sprite.position.y = RUNE_RING_Y + Math.sin(elapsed * 0.7 + rune.floatPhase) * 0.4;
const baseOpacity = rune.portalOnline ? 0.85 : 0.12;
const pulseRange = rune.portalOnline ? 0.15 : 0.03;
rune.sprite.material.opacity = baseOpacity + Math.sin(elapsed * 1.2 + rune.floatPhase) * pulseRange;
}
}
/**
* Force a rebuild from current portal data.
* Called externally after portal health checks update statuses.
*/
export function rebuild() {
const portals = _state?.portals ?? [];
_build(portals.length > 0 ? portals : null);
}