SpectraGlyph — 9 Gem Wisps in 3x3 audio-reactive array with musical chairs flight

// init-spectraglyph.js — SpectraGlyph / Gem Wisps // citrine.world: ["spectraglyph"] (or ["all"] for HUD mode) // topaz.load_order: 160 (after cosmostack, uses scene + wisps) // // 9 Gem Wisps in a 3x3 array. Uses existing Wisp class, // FlightPath for musical chairs, AudioReactor for mic energy. // // Grid layout: // X/Obsidian S/Sapphire P/Pearl // O/Onyx R/Ruby A/Amethyst // T/Topaz C/Citrine E/Emerald

(function() { 'use strict';

if (typeof THREE === 'undefined') { console.warn('init-spectraglyph: THREE not loaded'); return; }

// ── Gem palette from Portal Things (ruby.palette) ── // dark = shell color, bright = emissive/core var GEMS = [ { name: 'Obsidian', abbr: 'X', col: 0, row: 0, color: 0x020202, emissive: 0x3E3E3E, thingId: 'e5e7d16d' }, { name: 'Sapphire', abbr: 'S', col: 1, row: 0, color: 0x565656, emissive: 0xA7A7A7, thingId: '481fad6d' }, { name: 'Pearl', abbr: 'P', col: 2, row: 0, color: 0x9E9E9E, emissive: 0xF5F5F5, thingId: '6ab78906' }, { name: 'Onyx', abbr: 'O', col: 0, row: 1, color: 0x020202, emissive: 0x8B8B8B, thingId: '9d4ed4b8' }, { name: 'Ruby', abbr: 'R', col: 1, row: 1, color: 0x3E3E3E, emissive: 0x8B8B8B, thingId: '87b304ab' }, { name: 'Amethyst', abbr: 'A', col: 2, row: 1, color: 0x3E3E3E, emissive: 0x8B8B8B, thingId: '0d209ce5' }, { name: 'Topaz', abbr: 'T', col: 0, row: 2, color: 0xA7A7A7, emissive: 0xC3C3C3, thingId: '6a2eafb2' }, { name: 'Citrine', abbr: 'C', col: 1, row: 2, color: 0x707070, emissive: 0xE1E1E1, thingId: '2388dbf1' }, { name: 'Emerald', abbr: 'E', col: 2, row: 2, color: 0x565656, emissive: 0xC3C3C3, thingId: '181a4268' }, ];

// ── Config ── var GRID_SPACING = 3.2; var CORE_SCALE = 0.15; var SHELL_SCALE = 0.55; var DRIFT_AMOUNT = 0.15; var PARTICLE_COUNT = 80; var SWAP_INTERVAL_MIN = 2500; // min time between micro-swaps var SWAP_INTERVAL_MAX = 5000; // max time between micro-swaps var SETTLE_DURATION = 8000; // settle period after a round of swaps var SWAPS_PER_ROUND = 4; // how many micro-swaps before settling var SHUFFLE_FLIGHT_MS = 1800; // flight duration for musical chairs

// Gimbal speeds var GIMBAL = { outer: { base: 0.08, audioMult: 0.4 }, middle: { base: 0.05, audioMult: 0.3 }, inner: { base: 0.03, audioMult: 0.25 }, };

// Platonic shells — dice shapes var PLATONIC = [ function(s) { return new THREE.TetrahedronGeometry(s, 0); }, function(s) { var d = s * 1.15; return new THREE.BoxGeometry(d, d, d); }, function(s) { return new THREE.OctahedronGeometry(s, 0); }, function(s) { return new THREE.DodecahedronGeometry(s, 0); }, function(s) { return new THREE.IcosahedronGeometry(s, 0); }, ];

// Shuffle solid assignment var shellAssign = [0,1,2,3,4,0,1,2,3]; for (var i = shellAssign.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var tmp = shellAssign[i]; shellAssign[i] = shellAssign[j]; shellAssign[j] = tmp; }

// ══════════════════════════════════════════════ // Seat system — grid positions that gems swap between // ══════════════════════════════════════════════

var seats = []; for (var r = 0; r < 3; r++) { for (var c = 0; c < 3; c++) { seats.push(new THREE.Vector3( (c - 1) * GRID_SPACING, (1 - r) * GRID_SPACING, 0 )); } }

// seatMap[gemIndex] = seatIndex — who sits where var seatMap = [0,1,2,3,4,5,6,7,8];

// ══════════════════════════════════════════════ // GemWisp // ══════════════════════════════════════════════

var gemWisps = []; var gimbalOuter, gimbalMiddle, gimbalInner; var _clock = new THREE.Clock(); var _lastShuffle = 0;

function GemWisp(gem, index) { this.gem = gem; this.index = index; this.seatIndex = index;

// Current and target positions (for flight) this.currentPos = seats[index].clone(); this.targetPos = seats[index].clone(); this.flightStart = null; this.flightStartPos = null; this.flightMidY = 0; this.flightDuration = 0; this.isFlying = false;

// Idle seeds this.phase = Math.random() * Math.PI * 2; this.idle = { floatSpeed: 0.4 + Math.random() * 0.4, floatAmount: 0.06 + Math.random() * 0.04, pulseSpeed: 0.3 + Math.random() * 0.3, pulseAmount: 0.25, }; this.spinAxis = new THREE.Vector3( Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5 ).normalize(); this.spinSpeed = 0.5 + Math.random() * 0.8;

// ── Group ── this.group = new THREE.Group(); this.group.position.copy(this.currentPos);

// ── Core (soul) ── this.coreMat = new THREE.MeshStandardMaterial({ color: gem.emissive, emissive: gem.emissive, emissiveIntensity: 1.0, metalness: 0.2, roughness: 0.3, }); this.core = new THREE.Mesh(new THREE.IcosahedronGeometry(CORE_SCALE, 2), this.coreMat); this.group.add(this.core);

// ── Shell (expression) ── // Dark gems (obsidian, onyx) stay black + glossy; others use bright emissive as base var isDark = gem.abbr === 'X' || gem.abbr === 'O'; this.shellMat = new THREE.MeshStandardMaterial({ color: isDark ? gem.color : gem.emissive, emissive: gem.emissive, emissiveIntensity: isDark ? 0.25 : 0.6, metalness: isDark ? 0.95 : 0.4, roughness: isDark ? 0.05 : 0.2, transparent: true, opacity: isDark ? 0.75 : 0.55, wireframe: false, }); this.shell = new THREE.Mesh(PLATONIC[shellAssign[index]](SHELL_SCALE), this.shellMat); this.group.add(this.shell);

// ── Wireframe overlay for crispness ── this.wireMat = new THREE.MeshBasicMaterial({ color: gem.emissive, wireframe: true, transparent: true, opacity: 0.15, }); this.wireframe = new THREE.Mesh(PLATONIC[shellAssign[index]](SHELL_SCALE * 1.01), this.wireMat); this.group.add(this.wireframe);

// ── Point light ── this.light = new THREE.PointLight(gem.emissive, 0.5, 5, 2); this.group.add(this.light);

// ── Particles ── this._initParticles(gem); }

GemWisp.prototype._initParticles = function(gem) { var positions = new Float32Array(PARTICLE_COUNT * 3); this.particleVelocities = [];

for (var i = 0; i < PARTICLE_COUNT; i++) { var theta = Math.random() * Math.PI * 2; var phi = Math.random() * Math.PI; var r = 0.3 + Math.random() * 1.2; positions[i * 3] = r * Math.sin(phi) * Math.cos(theta); positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta); positions[i * 3 + 2] = r * Math.cos(phi); this.particleVelocities.push(new THREE.Vector3( (Math.random() - 0.5) * 0.015, (Math.random() - 0.5) * 0.015, (Math.random() - 0.5) * 0.015 )); }

var geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));

this.particleMat = new THREE.PointsMaterial({ color: gem.emissive, size: 0.035, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false, });

this.particles = new THREE.Points(geo, this.particleMat); this.group.add(this.particles); };

// ── Flight: arc path with height ── GemWisp.prototype.flyTo = function(targetPos, duration) { this.flightStartPos = this.currentPos.clone(); this.targetPos.copy(targetPos); this.flightStart = Date.now(); this.flightDuration = duration || SHUFFLE_FLIGHT_MS; this.isFlying = true;

// Arc height: proportional to distance + random jitter + Z displacement for 3D feel var dist = this.flightStartPos.distanceTo(targetPos); this.flightMidY = dist * 0.4 + (Math.random() - 0.3) * 2.0; this.flightMidZ = (Math.random() - 0.5) * dist * 0.6; };

// Sacred golden easing — peaks at phi function sacredGolden(t) { var phi = 0.618; if (t < phi) { return 0.5 * Math.pow(t / phi, 2); } else { return 0.5 + 0.5 * (1 - Math.pow((1 - t) / (1 - phi), 2)); } }

GemWisp.prototype.update = function(t, dt, energy, wave) { // ── Flight ── if (this.isFlying) { var elapsed = Date.now() - this.flightStart; var rawT = Math.min(1, elapsed / this.flightDuration); var eased = sacredGolden(rawT);

// Quadratic bezier arc: start → mid (lifted) → end var oneMinusT = 1 - eased; var mid = new THREE.Vector3( (this.flightStartPos.x + this.targetPos.x) * 0.5, (this.flightStartPos.y + this.targetPos.y) * 0.5 + this.flightMidY, (this.flightStartPos.z + this.targetPos.z) * 0.5 + this.flightMidZ );

this.currentPos.set( oneMinusT * oneMinusT * this.flightStartPos.x + 2 * oneMinusT * eased * mid.x + eased * eased * this.targetPos.x, oneMinusT * oneMinusT * this.flightStartPos.y + 2 * oneMinusT * eased * mid.y + eased * eased * this.targetPos.y, oneMinusT * oneMinusT * this.flightStartPos.z + 2 * oneMinusT * eased * mid.z + eased * eased * this.targetPos.z );

// Extra spin during flight this.shell.rotateOnAxis(this.spinAxis, dt * 6); this.core.rotation.y -= dt * 4;

if (rawT >= 1) { this.isFlying = false; this.currentPos.copy(this.targetPos); }

this.group.position.copy(this.currentPos); } else { // ── Idle: smooth lissajous mini-flight paths ── // Slow figure-8 style drift — each gem has unique phase offsets var p = this.phase; var r = DRIFT_AMOUNT + energy * 0.3; // tighter radius var sx = 0.15 + (p * 0.01 % 0.08); // unique slow speeds per gem var sy = 0.12 + (p * 0.013 % 0.06); var sz = 0.09 + (p * 0.017 % 0.05); this.group.position.x = this.currentPos.x + Math.sin(t * sx + p) * r; this.group.position.y = this.currentPos.y + Math.sin(t * sy + p * 1.3) * r + Math.sin(t * this.idle.floatSpeed * 0.5 + p) * this.idle.floatAmount; this.group.position.z = this.currentPos.z + Math.sin(t * sz + p * 0.7) * r * 0.6 + wave * 0.2;

// ── Shell spin ── var spinRate = this.spinSpeed * (1 + energy * 5) * dt; this.shell.rotateOnAxis(this.spinAxis, spinRate); this.wireframe.rotation.copy(this.shell.rotation);

// ── Core counter-rotate ── this.core.rotation.y -= spinRate * 0.6; this.core.rotation.x += spinRate * 0.3; }

// ── Shell scale pulse ── var shellTarget = 1 + energy * 1.5; var shellCurrent = this.shell.scale.x; var newScale = shellCurrent + (shellTarget - shellCurrent) * 0.12; this.shell.scale.setScalar(newScale); this.wireframe.scale.setScalar(newScale * 1.01);

// ── Core pulse — capped so it stays colored, not white ── var basePulse = 0.5 + Math.sin(t * this.idle.pulseSpeed + this.phase) * this.idle.pulseAmount; this.coreMat.emissiveIntensity = Math.min(basePulse + energy * 0.8, 1.2);

// ── Shell emissive — brighter color, not white wash ── this.shellMat.emissiveIntensity = Math.min(0.4 + energy * 0.6, 0.9); this.shellMat.opacity = 0.5 + energy * 0.35; this.wireMat.opacity = 0.12 + energy * 0.25;

// ── Light — gem-colored halo ── this.light.intensity = 0.4 + energy * 1.2;

// ── Particles ── this._updateParticles(energy, t); };

GemWisp.prototype._updateParticles = function(energy, t) { var posAttr = this.particles.geometry.attributes.position;

for (var p = 0; p < PARTICLE_COUNT; p++) { var px = posAttr.getX(p); var py = posAttr.getY(p); var pz = posAttr.getZ(p);

var dist = Math.sqrt(px*px + py*py + pz*pz);

// Idle orbit — particles always drift in a slow orbit around the gem var vel = this.particleVelocities[p]; var orbitSpeed = 0.008; // Cross product with position gives tangential orbit direction var ox = vel.y * pz - vel.z * py; var oy = vel.z * px - vel.x * pz; var oz = vel.x * py - vel.y * px; var omag = Math.sqrt(ox*ox + oy*oy + oz*oz) || 1; px += (ox / omag) * orbitSpeed; py += (oy / omag) * orbitSpeed; pz += (oz / omag) * orbitSpeed;

// Audio: expand outward with energy if (energy > 0.01) { var expand = energy * 0.04; var force = dist > 0.01 ? expand / dist : 0; px += px * force; py += py * force; pz += pz * force; }

// Hold orbit radius — gently pull to ideal distance (0.6 - 1.2) var idealDist = 0.6 + energy * 1.5; var maxDist = 1.2 + energy * 2.5; if (dist > maxDist) { var pull = 0.03; px *= (1 - pull); py *= (1 - pull); pz *= (1 - pull); } else if (dist < 0.3) { // Push out if collapsed too close var push = 0.02; if (dist > 0.01) { px += (px / dist) * push; py += (py / dist) * push; pz += (pz / dist) * push; } else { px += (Math.random() - 0.5) * 0.1; py += (Math.random() - 0.5) * 0.1; pz += (Math.random() - 0.5) * 0.1; } }

posAttr.setXYZ(p, px, py, pz); } posAttr.needsUpdate = true;

// Always visible at base level, brighter with audio this.particleMat.opacity = 0.2 + energy * 0.6; this.particleMat.size = 0.02 + energy * 0.05; };

// ══════════════════════════════════════════════ // Musical Chairs — staggered micro-swaps // 2-3 gems swap at a time, then settle // ══════════════════════════════════════════════

var _swapCount = 0; var _nextSwapTime = 0; var _settling = false;

// Swap 2-3 random gems (micro-swap) function microSwap() { var count = 2 + Math.floor(Math.random() * 2); // 2 or 3 // Pick random indices to swap var indices = []; while (indices.length < count) { var idx = Math.floor(Math.random() * 9); if (indices.indexOf(idx) === -1) indices.push(idx); }

// Rotate their seats: A→B, B→C, C→A var firstSeat = seatMap[indices[0]]; for (var i = 0; i < indices.length - 1; i++) { seatMap[indices[i]] = seatMap[indices[i + 1]]; } seatMap[indices[indices.length - 1]] = firstSeat;

// Fly only the affected gems for (var j = 0; j < indices.length; j++) { var gi = indices[j]; var wisp = gemWisps[gi]; wisp.seatIndex = seatMap[gi]; var delay = j * 150; // stagger within the micro-swap (function(w, target, d) { setTimeout(function() { w.flyTo(target, SHUFFLE_FLIGHT_MS + Math.random() * 400); }, d); })(wisp, seats[seatMap[gi]], delay); }

_swapCount++; }

// Full shuffle — all 9 rearrange (for /spectraglyph shuffle and smash burst) function shuffleSeats() { var newMap = seatMap.slice(); for (var i = newMap.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var tmp = newMap[i]; newMap[i] = newMap[j]; newMap[j] = tmp; } var changed = 0; for (var k = 0; k < 9; k++) { if (newMap[k] !== seatMap[k]) changed++; } if (changed < 2) return shuffleSeats();

seatMap = newMap; for (var g = 0; g < gemWisps.length; g++) { var wisp = gemWisps[g]; wisp.seatIndex = seatMap[g]; var delay = Math.random() * 300; (function(w, target, d) { setTimeout(function() { w.flyTo(target, SHUFFLE_FLIGHT_MS + Math.random() * 400); }, d); })(wisp, seats[seatMap[g]], delay); } _swapCount = 0; _settling = true; _nextSwapTime = Date.now() + SETTLE_DURATION; }

// Called each frame to manage swap timing function updateMusicalChairs() { var now = Date.now(); if (now < _nextSwapTime) return;

if (_settling) { // Done settling — start new round _settling = false; _swapCount = 0; }

if (_swapCount >= SWAPS_PER_ROUND) { // Round complete — settle _settling = true; _nextSwapTime = now + SETTLE_DURATION; _swapCount = 0; return; }

// Do a micro-swap microSwap(); _nextSwapTime = now + SWAP_INTERVAL_MIN + Math.random() * (SWAP_INTERVAL_MAX - SWAP_INTERVAL_MIN); }

// Connection lines removed — gems are independent wisps, not a grid

// ══════════════════════════════════════════════ // Init — called when scene is available // ══════════════════════════════════════════════

var _scene = null; var _initialized = false;

function initSpectraGlyph(scene) { if (_initialized) return; _scene = scene;

// Gimbal rig — positioned above the cosmostack workspace gimbalOuter = new THREE.Group(); gimbalMiddle = new THREE.Group(); gimbalInner = new THREE.Group(); gimbalOuter.add(gimbalMiddle); gimbalMiddle.add(gimbalInner); gimbalOuter.position.set(0, 8, 0); // float above the pieces scene.add(gimbalOuter);

// Create Gem Wisps for (var i = 0; i < GEMS.length; i++) { var wisp = new GemWisp(GEMS[i], i); gimbalInner.add(wisp.group); gemWisps.push(wisp); }

// Spacebar = shuffle window.addEventListener('keydown', function(e) { if (e.code === 'Space' && _initialized && !e.repeat) { // Don't hijack space if terminal is focused if (document.activeElement && document.activeElement.tagName === 'TEXTAREA') return; shuffleSeats(); } });

_initialized = true; _nextSwapTime = Date.now() + 3000; // first swap after 3s console.log('✦ SpectraGlyph initialized — 9 Gem Wisps in array'); }

// ══════════════════════════════════════════════ // Update — call each frame // ══════════════════════════════════════════════

// ── Logarithmic band mapping ── // Voice is mostly 80-3000Hz. Linear bins pack all voice into gems 0-2. // Log mapping spreads voice energy across all 9 gems. // Pre-compute bin ranges per gem. var _bandRanges = null; function computeBandRanges(totalBins) { _bandRanges = []; // Log-spaced boundaries from bin 1 to totalBins for (var g = 0; g < 9; g++) { var lo = Math.round(Math.pow(totalBins, g / 9)); var hi = Math.round(Math.pow(totalBins, (g + 1) / 9)); if (lo < 1) lo = 1; if (hi > totalBins) hi = totalBins; if (hi <= lo) hi = lo + 1; _bandRanges.push([lo, hi]); } }

function getGemEnergy(gemIndex) { if (!_bandRanges || !window.AudioReactor || !AudioReactor.active) return 0; var raw = AudioReactor.getRawFrequency(); if (!raw) return 0; var range = _bandRanges[gemIndex]; var sum = 0; var count = range[1] - range[0]; for (var i = range[0]; i < range[1] && i < raw.length; i++) { sum += raw[i]; } return count > 0 ? (sum / count) / 255 : 0; }

function updateSpectraGlyph() { if (!_initialized) return;

var dt = _clock.getDelta(); var t = _clock.getElapsedTime();

// Sample audio if (window.AudioReactor && AudioReactor.active) { AudioReactor.sample(); // Init log bands on first sample if (!_bandRanges) { var raw = AudioReactor.getRawFrequency(); if (raw) computeBandRanges(raw.length); } }

var avgEnergy = (window.AudioReactor && AudioReactor.active) ? AudioReactor.getTotalEnergy() : 0;

// Musical chairs — staggered micro-swaps updateMusicalChairs();

// Update wisps for (var i = 0; i < gemWisps.length; i++) { var energy = getGemEnergy(i); var wave = (window.AudioReactor && AudioReactor.active) ? AudioReactor.getWaveform(i, 9) : 0; gemWisps[i].update(t, dt, energy, wave); }

// Gimbal — skip gyroscope when in camera mode (stay facing viewer) if (!_cameraMode) { gimbalOuter.rotation.y += (GIMBAL.outer.base + avgEnergy * GIMBAL.outer.audioMult) * dt; gimbalMiddle.rotation.x += (GIMBAL.middle.base + avgEnergy * GIMBAL.middle.audioMult) * dt; gimbalInner.rotation.z += (GIMBAL.inner.base + avgEnergy * GIMBAL.inner.audioMult) * dt; }

}

// ══════════════════════════════════════════════ // Align — center the gems, fill the view // ══════════════════════════════════════════════

var _aligned = false; var _savedGimbalPos = null;

function align() { if (!_initialized) return; if (_aligned) { // Unalign — return to floating position gimbalOuter.position.copy(_savedGimbalPos); _aligned = false; console.log('✦ Gem Wisps returned to floating position'); return; } // Save current position and move to origin (center of view) _savedGimbalPos = gimbalOuter.position.clone(); gimbalOuter.position.set(0, 0, 0); _aligned = true; // Trigger a full shuffle for dramatic effect shuffleSeats(); console.log('✦ Gem Wisps aligned — center stage'); }

// ══════════════════════════════════════════════ // Camera mode — gems follow the camera (HUD) // ══════════════════════════════════════════════

var _cameraMode = false; var _savedParent = null; var _savedRotations = null; var _camOffset = new THREE.Vector3(0, 0, -11); // relative to hudGroup (which is already at Z=-3)

function cameraFollow(camera) { if (!_initialized || !camera) return; // Find hudGroup — lives on the camera, has the parallax tilt var hudGroup = null; for (var ci = 0; ci < camera.children.length; ci++) { var child = camera.children[ci]; if (child.isGroup && child.position.z === -3) { hudGroup = child; break; } } var attachTo = hudGroup || camera; // fallback to camera if hudGroup not found

if (_cameraMode) { // Detach from HUD, return to scene attachTo.remove(gimbalOuter); _savedParent.add(gimbalOuter); gimbalOuter.position.set(0, 8, 0); // Restore gimbal rotations gimbalOuter.rotation.set(_savedRotations.ox, _savedRotations.oy, _savedRotations.oz); gimbalMiddle.rotation.set(_savedRotations.mx, _savedRotations.my, _savedRotations.mz); gimbalInner.rotation.set(_savedRotations.ix, _savedRotations.iy, _savedRotations.iz); _cameraMode = false; console.log('✦ Gem Wisps released from camera'); return; } // Save current rotations _savedRotations = { ox: gimbalOuter.rotation.x, oy: gimbalOuter.rotation.y, oz: gimbalOuter.rotation.z, mx: gimbalMiddle.rotation.x, my: gimbalMiddle.rotation.y, mz: gimbalMiddle.rotation.z, ix: gimbalInner.rotation.x, iy: gimbalInner.rotation.y, iz: gimbalInner.rotation.z, }; // Reset rotations so grid faces camera gimbalOuter.rotation.set(0, 0, 0); gimbalMiddle.rotation.set(0, 0, 0); gimbalInner.rotation.set(0, 0, 0); // Reparent gimbal into hudGroup (gets parallax tilt for free) _savedParent = gimbalOuter.parent; _savedParent.remove(gimbalOuter); attachTo.add(gimbalOuter); gimbalOuter.position.copy(_camOffset); _cameraMode = true;

// Fly wisps in — scatter them far behind/around camera, then fly to seats for (var i = 0; i < gemWisps.length; i++) { var w = gemWisps[i]; // Start position: scattered wide, far behind camera-local Z w.currentPos.set( (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 30, 20 + Math.random() * 15 // behind the camera in camera-local space ); w.group.position.copy(w.currentPos); // Stagger the arrival var delay = i * 120 + Math.random() * 200; (function(wisp, seat, d) { setTimeout(function() { wisp.flyTo(seat, 1400 + Math.random() * 600); }, d); })(w, seats[seatMap[i]], delay); } console.log('✦ Gem Wisps flying to camera'); }

// ══════════════════════════════════════════════ // Smash / Enter — mic activation + visual burst // ══════════════════════════════════════════════

var _smashed = false;

function smash() { if (!window.AudioReactor) { console.warn('✦ AudioReactor not loaded'); return; } if (_smashed) { console.log('✦ Already smashed — mic is live'); return; }

AudioReactor.start().then(function(ok) { if (ok) { _smashed = true; // Visual burst — all wisps scatter outward then regroup for (var i = 0; i < gemWisps.length; i++) { var w = gemWisps[i]; // Fling to a random point in 3D var burstTarget = new THREE.Vector3( (Math.random() - 0.5) * GRID_SPACING * 4, (Math.random() - 0.5) * GRID_SPACING * 4, (Math.random() - 0.5) * GRID_SPACING * 3 ); w.flyTo(burstTarget, 600); } // After burst, regroup to seats setTimeout(function() { for (var j = 0; j < gemWisps.length; j++) { gemWisps[j].flyTo(seats[seatMap[j]], 1200 + Math.random() * 600); } }, 800); console.log('✦ SMASH — Gem Wisps awakened by sound'); } }); }

function enter() { // TODO: camera fly-in to gimbal center (inside the die) // For now, just focus camera if controls exist console.log('✦ Enter SpectraGlyph — camera fly-in (pending world-loading arch)'); }

// ══════════════════════════════════════════════ // Expose API // ══════════════════════════════════════════════

window.SpectraGlyph = { init: initSpectraGlyph, update: updateSpectraGlyph, shuffle: shuffleSeats, smash: smash, enter: enter, align: align, camera: cameraFollow, get active() { return _smashed; }, getWisps: function() { return gemWisps; }, getGimbal: function() { return { outer: gimbalOuter, middle: gimbalMiddle, inner: gimbalInner }; }, GEMS: GEMS, };

console.log('✦ SpectraGlyph chip loaded — /smash to awaken, /enter to dive in');

})();