diff --git a/app.js b/app.js index 9ac2064..5bd4e92 100644 --- a/app.js +++ b/app.js @@ -1002,6 +1002,12 @@ function animate() { loraPanelSprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; } + // Animate floating bookshelves — gentle slow bob + for (const shelf of bookshelfGroups) { + const ud = shelf.userData; + shelf.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.18; + } + // Animate Timmy speech bubble — fade in, hold, fade out if (timmySpeechState) { const age = elapsed - timmySpeechState.startTime; @@ -1552,6 +1558,9 @@ window.addEventListener('beforeunload', () => { // === COMMIT BANNERS === const commitBanners = []; +/** @type {THREE.Group[]} */ +const bookshelfGroups = []; + @@ -1761,6 +1770,217 @@ async function initCommitBanners() { initCommitBanners(); loadPortals(); +// === FLOATING BOOKSHELVES === +// Floating bookshelves display merged PR history as books with spine labels. +// Each book spine shows a PR number and truncated title rendered via canvas texture. + +/** + * Creates a canvas texture for a book spine. + * @param {number} prNum + * @param {string} title + * @param {string} bgColor CSS color string for book cover + * @returns {THREE.CanvasTexture} + */ +function createSpineTexture(prNum, title, bgColor) { + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 512; + const ctx = canvas.getContext('2d'); + + // Background — book cover color + ctx.fillStyle = bgColor; + ctx.fillRect(0, 0, 128, 512); + + // Accent border + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 3; + ctx.strokeRect(3, 3, 122, 506); + + // PR number — accent blue, near top + ctx.font = 'bold 32px "Courier New", monospace'; + ctx.fillStyle = '#4488ff'; + ctx.textAlign = 'center'; + ctx.fillText(`#${prNum}`, 64, 58); + + // Divider line + ctx.strokeStyle = '#4488ff'; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(12, 78); + ctx.lineTo(116, 78); + ctx.stroke(); + ctx.globalAlpha = 1.0; + + // Title — rotated 90° to read bottom-to-top (spine convention) + ctx.save(); + ctx.translate(64, 300); + ctx.rotate(-Math.PI / 2); + const displayTitle = title.length > 30 ? title.slice(0, 30) + '\u2026' : title; + ctx.font = '21px "Courier New", monospace'; + ctx.fillStyle = '#ccd6f6'; + ctx.textAlign = 'center'; + ctx.fillText(displayTitle, 0, 0); + ctx.restore(); + + return new THREE.CanvasTexture(canvas); +} + +/** + * Builds a single floating bookshelf group and adds it to the scene. + * @param {Array<{prNum: number, title: string}>} books + * @param {THREE.Vector3} position + * @param {number} rotationY + */ +function buildBookshelf(books, position, rotationY) { + const group = new THREE.Group(); + group.position.copy(position); + group.rotation.y = rotationY; + + const SHELF_W = books.length * 0.52 + 0.6; + const SHELF_THICKNESS = 0.12; + const SHELF_DEPTH = 0.72; + const ENDPANEL_H = 2.0; + + // Dark metallic shelf material + const shelfMat = new THREE.MeshStandardMaterial({ + color: 0x0d1520, + metalness: 0.6, + roughness: 0.5, + emissive: new THREE.Color(NEXUS.colors.accent).multiplyScalar(0.02), + }); + + // Shelf plank (horizontal) + const plank = new THREE.Mesh(new THREE.BoxGeometry(SHELF_W, SHELF_THICKNESS, SHELF_DEPTH), shelfMat); + group.add(plank); + + // End panels + const endGeo = new THREE.BoxGeometry(0.1, ENDPANEL_H, SHELF_DEPTH); + const leftEnd = new THREE.Mesh(endGeo, shelfMat); + leftEnd.position.set(-SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(leftEnd); + + const rightEnd = new THREE.Mesh(endGeo.clone(), shelfMat); + rightEnd.position.set(SHELF_W / 2, ENDPANEL_H / 2 - SHELF_THICKNESS / 2, 0); + group.add(rightEnd); + + // Accent glow strip along front edge of shelf + const glowStrip = new THREE.Mesh( + new THREE.BoxGeometry(SHELF_W, 0.035, 0.035), + new THREE.MeshBasicMaterial({ color: NEXUS.colors.accent, transparent: true, opacity: 0.55 }) + ); + glowStrip.position.set(0, SHELF_THICKNESS / 2 + 0.017, SHELF_DEPTH / 2); + group.add(glowStrip); + + // Book cover colors — dark tones with slight variation + const BOOK_COLORS = [ + '#0f0818', '#080f18', '#0f1108', '#07120e', + '#130c06', '#060b12', '#120608', '#080812', + ]; + + // Spine thickness (X), book height (Y), cover depth (Z) + // +Z face (index 4) = spine visible to viewer + const bookStartX = -(SHELF_W / 2) + 0.36; + books.forEach((book, i) => { + const spineW = 0.34 + (i % 3) * 0.05; // slight width variation + const bookH = 1.35 + (i % 4) * 0.13; // slight height variation + const coverD = 0.58; + + const bgColor = BOOK_COLORS[i % BOOK_COLORS.length]; + const spineTexture = createSpineTexture(book.prNum, book.title, bgColor); + + const plainMat = new THREE.MeshStandardMaterial({ + color: new THREE.Color(bgColor), + roughness: 0.85, + metalness: 0.05, + }); + const spineMat = new THREE.MeshBasicMaterial({ map: spineTexture }); + + // Material array: +X, -X, +Y, -Y, +Z (spine), -Z + const bookMats = [plainMat, plainMat, plainMat, plainMat, spineMat, plainMat]; + + const bookGeo = new THREE.BoxGeometry(spineW, bookH, coverD); + const bookMesh = new THREE.Mesh(bookGeo, bookMats); + bookMesh.position.set( + bookStartX + i * 0.5, + SHELF_THICKNESS / 2 + bookH / 2, + 0 + ); + bookMesh.userData.zoomLabel = `PR #${book.prNum}: ${book.title.slice(0, 40)}`; + group.add(bookMesh); + }); + + // Soft point light beneath shelf for ambient glow + const shelfLight = new THREE.PointLight(NEXUS.colors.accent, 0.25, 5); + shelfLight.position.set(0, -0.4, 0); + group.add(shelfLight); + + group.userData.zoomLabel = 'PR Archive — Merged Contributions'; + group.userData.baseY = position.y; + group.userData.floatPhase = bookshelfGroups.length * Math.PI; + group.userData.floatSpeed = 0.17 + bookshelfGroups.length * 0.06; + + scene.add(group); + bookshelfGroups.push(group); +} + +/** + * Fetches merged PRs and spawns floating bookshelves in the scene. + */ +async function initBookshelves() { + let prs = []; + try { + const res = await fetch( + 'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20', + { headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } } + ); + if (!res.ok) throw new Error('fetch failed'); + const data = await res.json(); + prs = data + .filter(/** @type {(p: any) => boolean} */ p => p.merged) + .map(/** @type {(p: any) => {prNum: number, title: string}} */ p => ({ + prNum: p.number, + // Strip "[claude]" prefix and trailing "(#N)" suffix for cleaner spine labels + title: p.title + .replace(/^\[[\w\s]+\]\s*/i, '') + .replace(/\s*\(#\d+\)\s*$/, ''), + })); + } catch { + // Fallback if API unreachable + prs = [ + { prNum: 324, title: 'Model training status — LoRA adapters' }, + { prNum: 323, title: 'The Oath — interactive SOUL.md reading' }, + { prNum: 320, title: 'Hermes session save/load' }, + { prNum: 304, title: 'Session export as markdown' }, + { prNum: 303, title: 'Procedural Web Audio ambient soundtrack' }, + { prNum: 301, title: 'Warp tunnel effect for portals' }, + { prNum: 296, title: 'Procedural terrain for floating island' }, + { prNum: 294, title: 'Northern lights flash on PR merge' }, + ]; + } + + if (prs.length === 0) return; + + // Split PRs across two shelves — left and right of the scene background + const mid = Math.ceil(prs.length / 2); + + buildBookshelf( + prs.slice(0, mid), + new THREE.Vector3(-8.5, 1.5, -4.5), + Math.PI * 0.1, // slight angle toward scene center + ); + + if (prs.slice(mid).length > 0) { + buildBookshelf( + prs.slice(mid), + new THREE.Vector3(8.5, 1.5, -4.5), + -Math.PI * 0.1, + ); + } +} + +initBookshelves(); + // === THE OATH === // Interactive reading of SOUL.md with dramatic lighting. // Trigger: press 'O' or double-click the tome object in the scene.