diff --git a/app.js b/app.js index b396094..473a00c 100644 --- a/app.js +++ b/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(); }