feat: animated soul constellation traces SOUL.md keywords in the stars
Some checks failed
CI / validate (pull_request) Failing after 11s
CI / auto-merge (pull_request) Has been skipped

Adds a cycling animated constellation system to the Nexus star field.
Six SOUL.md keywords (SOVEREIGNTY, BITCOIN, SOUL, IDENTITY, TRUST, PRESENCE)
each get a dedicated star cluster deep in the star field. A glowing teal
trace line progressively draws between the stars following a predefined path,
with the keyword label fading in as the trace completes. After a hold period
the constellation fades out and the next keyword begins. The soulGroup shares
the star-field rotation so constellations track with the sky.

Fixes #266

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexander Whitestone
2026-03-24 00:50:38 -04:00
parent 39e0eecb9e
commit ca284e07a8

199
app.js
View File

@@ -656,6 +656,199 @@ for (let i = 0; i < RUNE_COUNT; i++) {
runeSprites.push({ sprite, baseAngle, floatPhase: (i / RUNE_COUNT) * Math.PI * 2 });
}
// === SOUL CONSTELLATION ===
// Animated star constellations that cycle through SOUL.md keywords,
// tracing connecting lines between stars while the keyword label fades in.
const SOUL_KEYWORDS = ['SOVEREIGNTY', 'BITCOIN', 'SOUL', 'IDENTITY', 'TRUST', 'PRESENCE'];
const SOUL_DIST = 100; // distance from scene origin — places stars deep in the field
const SOUL_COLOR = 0x44ffcc;
// Local-space star offsets for each keyword constellation
const SOUL_PATTERNS = [
// SOVEREIGNTY — 8 stars, crown shape
[[0,0,0],[-8,5,2],[8,5,-1],[0,10,3],[-12,8,0],[12,7,2],[5,-5,1],[-5,-5,-2]],
// BITCOIN — 7 stars
[[0,0,0],[0,8,0],[0,-8,2],[6,3,1],[6,-3,-1],[-5,4,0],[-5,-4,2]],
// SOUL — 5 stars, flame
[[0,0,0],[0,10,2],[-5,4,-1],[5,4,1],[0,-7,0]],
// IDENTITY — 7 stars
[[0,0,0],[0,9,2],[0,-9,1],[8,3,-1],[-8,3,2],[5,-5,0],[-5,-5,1]],
// TRUST — 6 stars
[[0,0,0],[0,9,1],[-7,6,2],[7,6,-1],[5,-4,0],[-5,-4,2]],
// PRESENCE — 6 stars, radial burst
[[0,0,0],[10,0,2],[-10,0,-1],[0,10,1],[0,-10,2],[7,7,0]],
];
// Ordered star indices visited during the trace animation
const SOUL_PATHS = [
[4,1,3,0,2,5,2,0,6,7], // SOVEREIGNTY
[5,1,3,0,4,6,2,0], // BITCOIN
[3,0,2,0,4,0,1], // SOUL
[5,0,3,1,0,2,4,6], // IDENTITY
[4,0,2,1,3,0,5], // TRUST
[1,0,2,0,3,0,4,0,5], // PRESENCE
];
// World-space center direction for each constellation (normalized × SOUL_DIST)
const SOUL_CENTERS = [
[0.30, 0.75, -0.50],
[-0.60, 0.55, -0.40],
[0.70, 0.55, 0.35],
[-0.45, 0.72, 0.50],
[0.50, 0.45, 0.72],
[-0.65, 0.58, -0.25],
].map(([x, y, z]) => new THREE.Vector3(x, y, z).normalize().multiplyScalar(SOUL_DIST));
/**
* Creates a glowing canvas label texture for a SOUL keyword.
* @param {string} keyword
* @returns {THREE.CanvasTexture}
*/
function createSoulLabelTexture(keyword) {
const W = 256, H = 48;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, W, H);
ctx.shadowColor = '#44ffcc';
ctx.shadowBlur = 18;
ctx.font = 'bold 18px "Courier New", monospace';
ctx.fillStyle = '#88ffee';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(keyword, W / 2, H / 2);
return new THREE.CanvasTexture(canvas);
}
/** Group that shares the star-field rotation so constellations move with the sky. */
const soulGroup = new THREE.Group();
scene.add(soulGroup);
/**
* @type {Array<{
* stars: THREE.Points,
* traceLine: THREE.Line,
* label: THREE.Sprite,
* pathLen: number,
* }>}
*/
const soulConstellations = SOUL_KEYWORDS.map((keyword, ci) => {
const center = SOUL_CENTERS[ci];
const pattern = SOUL_PATTERNS[ci];
const path = SOUL_PATHS[ci];
const positions = pattern.map(([dx, dy, dz]) =>
new THREE.Vector3(center.x + dx, center.y + dy, center.z + dz)
);
// Soul stars — brighter teal points
const posArr = new Float32Array(positions.length * 3);
positions.forEach((p, i) => { posArr[i*3]=p.x; posArr[i*3+1]=p.y; posArr[i*3+2]=p.z; });
const sGeo = new THREE.BufferGeometry();
sGeo.setAttribute('position', new THREE.BufferAttribute(posArr, 3));
const sMat = new THREE.PointsMaterial({
color: SOUL_COLOR, size: 3.5, sizeAttenuation: true,
transparent: true, opacity: 0, blending: THREE.AdditiveBlending,
});
const soulStars = new THREE.Points(sGeo, sMat);
soulGroup.add(soulStars);
// Trace line following the path order (revealed progressively via drawRange)
const tPosArr = new Float32Array(path.length * 3);
path.forEach((si, i) => {
const p = positions[si];
tPosArr[i*3]=p.x; tPosArr[i*3+1]=p.y; tPosArr[i*3+2]=p.z;
});
const tGeo = new THREE.BufferGeometry();
tGeo.setAttribute('position', new THREE.BufferAttribute(tPosArr, 3));
tGeo.setDrawRange(0, 0);
const tMat = new THREE.LineBasicMaterial({
color: SOUL_COLOR, transparent: true, opacity: 0, blending: THREE.AdditiveBlending,
});
const traceLine = new THREE.Line(tGeo, tMat);
soulGroup.add(traceLine);
// Keyword label floating above the constellation center
const labelMat = new THREE.SpriteMaterial({
map: createSoulLabelTexture(keyword),
transparent: true, opacity: 0,
depthWrite: false, blending: THREE.AdditiveBlending,
});
const label = new THREE.Sprite(labelMat);
label.scale.set(7, 1.3, 1);
label.position.set(center.x, center.y + 14, center.z);
soulGroup.add(label);
return { stars: soulStars, traceLine, label, pathLen: path.length };
});
// Soul constellation timing constants
const SOUL_TRACE_SPEED = 3.0; // trace points per second
const SOUL_HOLD_SECS = 3.0;
const SOUL_FADE_IN_SECS = 0.8;
const SOUL_FADE_OUT_SECS = 1.5;
const SOUL_PAUSE_SECS = 0.8;
let soulCurrentIdx = 0;
let soulPhase = /** @type {'fadein'|'trace'|'hold'|'fadeout'|'pause'} */ ('fadein');
let soulPhaseTime = 0;
let soulTraceProgress = 0;
let soulLastElapsed = 0;
/**
* Advances the soul constellation animation by one frame.
* @param {number} elapsed - total scene time in seconds
* @param {number} delta - time since last frame in seconds
*/
function updateSoulConstellations(elapsed, delta) {
const c = soulConstellations[soulCurrentIdx];
soulPhaseTime += delta;
if (soulPhase === 'fadein') {
const t = Math.min(soulPhaseTime / SOUL_FADE_IN_SECS, 1);
c.stars.material.opacity = t * 0.88;
if (t >= 1) { soulPhase = 'trace'; soulPhaseTime = 0; soulTraceProgress = 0; }
} else if (soulPhase === 'trace') {
soulTraceProgress = Math.min(soulPhaseTime * SOUL_TRACE_SPEED, c.pathLen);
c.traceLine.geometry.setDrawRange(0, Math.ceil(soulTraceProgress));
c.traceLine.material.opacity = 0.85;
c.stars.material.opacity = 0.75 + Math.sin(elapsed * 5) * 0.13;
c.label.material.opacity = (soulTraceProgress / c.pathLen) * 0.88;
if (soulTraceProgress >= c.pathLen) { soulPhase = 'hold'; soulPhaseTime = 0; }
} else if (soulPhase === 'hold') {
c.stars.material.opacity = 0.80 + Math.sin(elapsed * 2.2) * 0.10;
c.traceLine.material.opacity = 0.75 + Math.sin(elapsed * 1.6) * 0.12;
c.label.material.opacity = 0.88;
if (soulPhaseTime >= SOUL_HOLD_SECS) { soulPhase = 'fadeout'; soulPhaseTime = 0; }
} else if (soulPhase === 'fadeout') {
const t = Math.min(soulPhaseTime / SOUL_FADE_OUT_SECS, 1);
const inv = 1 - t;
c.stars.material.opacity = inv * 0.88;
c.traceLine.material.opacity = inv * 0.85;
c.label.material.opacity = inv * 0.88;
if (t >= 1) {
c.stars.material.opacity = 0;
c.traceLine.material.opacity = 0;
c.label.material.opacity = 0;
c.traceLine.geometry.setDrawRange(0, 0);
soulPhase = 'pause';
soulPhaseTime = 0;
}
} else { // pause
if (soulPhaseTime >= SOUL_PAUSE_SECS) {
soulCurrentIdx = (soulCurrentIdx + 1) % soulConstellations.length;
soulPhase = 'fadein';
soulPhaseTime = 0;
}
}
}
// === ANIMATION LOOP ===
const clock = new THREE.Clock();
@@ -684,6 +877,8 @@ function animate() {
constellationLines.rotation.x = stars.rotation.x;
constellationLines.rotation.y = stars.rotation.y;
soulGroup.rotation.x = stars.rotation.x;
soulGroup.rotation.y = stars.rotation.y;
// Subtle pulse on constellation opacity
constellationLines.material.opacity = 0.12 + Math.sin(elapsed * 0.5) * 0.06;
@@ -770,6 +965,10 @@ function animate() {
rune.sprite.material.opacity = 0.65 + Math.sin(elapsed * 1.2 + rune.floatPhase) * 0.2;
}
// Animate soul keyword constellations
updateSoulConstellations(elapsed, elapsed - soulLastElapsed);
soulLastElapsed = elapsed;
composer.render();
}