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>
139 lines
4.3 KiB
JavaScript
139 lines
4.3 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|