feat: animated soul constellation traces SOUL.md keywords in the stars
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:
199
app.js
199
app.js
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user