import * as THREE from 'three'; import { ENTRIES } from '../data/entries.js'; import { acquirePageTextures, releasePageTextures, getPlaceholderTextures, makeTwoSidedMaterial } from '../textures/pages.js'; import { makeTextSprite } from '../utils/labels.js'; import { clamp, easeInOutCubic } from '../utils/easing.js'; // 活页之间固定间距(世界单位),横向可滚动 const CARD_GAP = 0.65; export const TIMELINE_SPREAD = ((ENTRIES.length - 1) * CARD_GAP) / 2; const CARD_FLOAT = 0.14; // 选中时上浮高度 const NAME_Y = 0.42; // 主题标签基准高度(活页上方) const AXIS_Y = -0.40; // 时间轴基线高度 const TIME_Y = -0.56; // 时间标签高度(轴下方) function cardX(i, n) { return (i - (n - 1) / 2) * CARD_GAP; } export class Timeline { constructor() { this.group = new THREE.Group(); this.cards = []; this.selected = -1; this.placeholder = getPlaceholderTextures(); this._textured = new Set(); this._visRange = null; this._ready = false; this._pending = []; this._dirty = false; this._buildLine(); this._buildCards(); } _buildLine() { const n = ENTRIES.length; // 轴线向两侧延伸足够远(并关闭雾化),滚动到边缘也不见端点,视觉上无限长 const extent = TIMELINE_SPREAD + 5; const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7, fog: false }); const lineGeo = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(-extent, AXIS_Y, 0), new THREE.Vector3(extent, AXIS_Y, 0), ]); this.group.add(new THREE.Line(lineGeo, lineMat)); // 刻度:向上指向卡片、向下指向时间标签 const tickMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.5 }); ENTRIES.forEach((e, i) => { const x = cardX(i, n); const g = new THREE.BufferGeometry().setFromPoints([ new THREE.Vector3(x, AXIS_Y - 0.14, 0), new THREE.Vector3(x, AXIS_Y + 0.12, 0), ]); this.group.add(new THREE.Line(g, tickMat)); }); } _buildCards() { const n = ENTRIES.length; ENTRIES.forEach((entry, i) => { const x = cardX(i, n); // 每张卡片独立几何(带分段,便于翻面时弯曲出纸感) const cardGeo = new THREE.PlaneGeometry(0.4, 0.565, 24, 6); const base = Float32Array.from(cardGeo.attributes.position.array); // 翻转容器:单 mesh + 双面材质(翻面时 gl_FrontFacing 自动切换正/背) const flipGroup = new THREE.Group(); flipGroup.position.set(x, 0, 0); const mat = makeTwoSidedMaterial(this.placeholder.front, this.placeholder.back, { roughness: 0.86 }); const cardMesh = new THREE.Mesh(cardGeo, mat); cardMesh.userData = { view: 'timeline', index: i }; cardMesh.castShadow = true; flipGroup.add(cardMesh); // 活页上方:主题(常显,随选中上浮,超宽居中换行) const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.12, maxWidth: 0.9 }); name.position.set(x, NAME_Y, 0); // 轴下方:时间(常显,固定不动) const time = makeTextSprite(entry.time, { fontSize: 13, color: '#c9a227', scale: 0.11 }); time.position.set(x, TIME_Y, 0); this.group.add(flipGroup, name, time); this.cards.push({ card: flipGroup, mesh: cardMesh, material: mat, geo: cardGeo, base, name, nameBaseY: NAME_Y, index: i, flipped: false, flipAnim: null, isPlaceholder: true }); }); } setSelected(index) { this.selected = index; this.cards.forEach((c) => { const on = c.index === index; c.card.position.y = on ? CARD_FLOAT : 0; c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0); }); } // 首次进入时间轴视图时由 App 调用,此后由 App 每帧按可视区域驱动窗口 refreshTextures() { this._ready = true; } // App 每帧根据可视区域调用:minI/maxI 为可见卡片索引范围(可为小数) setVisibleRange(minI, maxI) { const len = this.cards.length; const PAD = 4; // 缓冲:可视范围外多生成几页,拖拽时提前就绪 const lo = Math.max(0, Math.floor(minI) - PAD); const hi = Math.min(len - 1, Math.ceil(maxI) + PAD); if (this._visRange && this._visRange.min === lo && this._visRange.max === hi) return; this._visRange = { min: lo, max: hi }; this._refreshTextures(); } // 世界坐标可视范围 → 索引范围(内部减去 group 偏移;卡片组内坐标 = cardX(i)) setVisibleWorldRange(minX, maxX) { const n = this.cards.length; const offset = this.group.position.x; const lo = (minX - offset) / CARD_GAP + (n - 1) / 2; const hi = (maxX - offset) / CARD_GAP + (n - 1) / 2; this.setVisibleRange(lo, hi); } _window() { const win = new Set(); if (!this._visRange) return win; for (let i = this._visRange.min; i <= this._visRange.max; i++) win.add(i); return win; } _refreshTextures() { if (!this._ready) return; const win = this._window(); // 离开窗口:释放真实纹理,换回占位 for (const i of [...this._textured]) { if (win.has(i)) continue; const c = this.cards[i]; releasePageTextures(ENTRIES[i]); c.material.setPageMaps(this.placeholder.front, this.placeholder.back); c.isPlaceholder = true; this._textured.delete(i); this._dirty = true; } // 进入窗口:分帧异步生成,避免一次性生成 25 页阻塞主线程 const next = [...win].filter((i) => !this._textured.has(i) && this.cards[i].isPlaceholder); if (next.length) this._dirty = true; this._pending = next; this.drainPending(); } // 每帧由 App._animate 驱动,逐批生成窗口内纹理(避免阻塞) drainPending() { if (!this._pending || !this._pending.length) return false; const budget = 2; // 每帧最多生成 2 页 let did = false; for (let k = 0; k < budget && this._pending.length; k++) { const i = this._pending.shift(); const c = this.cards[i]; if (!c || this._textured.has(i)) continue; const { front, back } = acquirePageTextures(ENTRIES[i]); c.material.setPageMaps(front, back); c.isPlaceholder = false; this._textured.add(i); this._dirty = true; did = true; } return did; } hasActiveFlip() { return this.cards.some((c) => c.flipAnim); } // App 每帧消费“纹理窗口变化”标志,用于按需渲染 consumeDirty() { const d = this._dirty; this._dirty = false; return d; } // 返回某张卡片在时间轴组内的横向坐标(用于点击后居中) getCardX(index) { return cardX(index, ENTRIES.length); } // 射线检测用:只返回可视窗口内的卡片 mesh(减少求交对象) pickableMeshes() { if (!this._visRange) return this.cards.map((c) => c.mesh); const list = []; for (let i = this._visRange.min; i <= this._visRange.max; i++) { if (this.cards[i]) list.push(this.cards[i].mesh); } return list; } // 点击翻转:每次点击切换正/反面;选中另一张时,其它翻到背面的卡片先翻回正面 flip(index) { this.cards.forEach((o) => { if (o.index !== index && o.flipped) { o.flipped = false; o.flipAnim = { from: o.card.rotation.y, to: 0, t: 0, dur: 0.6 }; } }); const c = this.cards[index]; if (!c) return; c.flipped = !c.flipped; c.flipAnim = { from: c.card.rotation.y, to: c.flipped ? Math.PI : 0, t: 0, dur: 0.6 }; } // 翻面动画(由 App 每帧调用) update(dt) { this.cards.forEach((c) => { if (!c.flipAnim) return; const a = c.flipAnim; a.t += dt / a.dur; const k = clamp(a.t, 0, 1); c.card.rotation.y = a.from + (a.to - a.from) * easeInOutCubic(k); // 纸张弯曲:翻面中间段边缘向后微弯,带柔软感 const bend = Math.sin(k * Math.PI) * 0.06; const pos = c.geo.attributes.position; const arr = pos.array; const base = c.base; for (let j = 0; j < arr.length; j += 3) { const xn = base[j] / 0.4; // -0.5 .. 0.5 arr[j + 2] = base[j + 2] - bend * (xn * 2) * (xn * 2); } pos.needsUpdate = true; if (k >= 1) c.flipAnim = null; }); } }