From 9a1a3685cecbafe2bfc167b521d5fa03c0914d69 Mon Sep 17 00:00:00 2001 From: fukunee Date: Fri, 18 Sep 2026 22:46:26 +0800 Subject: [PATCH] Tune camera and timeline controls, add globe fly-to - Clamp binder/timeline zoom ranges and disable OrbitControls zoom, using custom smooth zoom instead - Disable middle/right mouse buttons; reduce rotate speed - Fly camera to picked marker on globe click - Rebuild leather surfaces with a regular grid for smooth curvature - Refine suede and kraft paper textures, punch real page holes - Reposition axis, time labels, and detail card; speed up iris fade --- src/core/App.js | 129 +++++++++++++++++++++++--- src/scene/CesiumGlobe.js | 25 ++++- src/scene/binder.js | 195 +++++++++++++++++++++------------------ src/scene/timeline.js | 27 +++--- src/styles.css | 4 +- src/textures/pages.js | 54 +++++------ 6 files changed, 286 insertions(+), 148 deletions(-) diff --git a/src/core/App.js b/src/core/App.js index e9b1498..a21151b 100644 --- a/src/core/App.js +++ b/src/core/App.js @@ -8,8 +8,8 @@ import { Overlay } from '../ui/overlay.js'; import { clamp, easeInOutCubic } from '../utils/easing.js'; const CAMERA_POSES = { - binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 6, min: 1.6 }, - timeline: { pos: new THREE.Vector3(0, 1.4, 7.9), target: new THREE.Vector3(0, 0, 0), max: 13, min: 1.5 }, + binder: { pos: new THREE.Vector3(0.2, 0.75, 2.95), target: new THREE.Vector3(0, 0.02, 0), max: 4.4, min: 2.4 }, + timeline: { pos: new THREE.Vector3(0, 1.4, 6.86), target: new THREE.Vector3(0, 0, 0), max: 8, min: 6 }, globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 }, }; @@ -18,6 +18,9 @@ export class App { this.view = 'binder'; this.selected = 0; this.timelineOffset = 0; + this.timelineSlide = null; + this.zoomTarget = null; + this.cameraMemory = {}; this._initRenderer(); this._initLights(); @@ -32,6 +35,7 @@ export class App { this.overlay.setTotal(ENTRIES.length); this._initEvents(); + this._applyRotationLimits(CAMERA_POSES.binder); this._select(0, { syncBinder: false }); this.binder.syncTo(0); @@ -62,9 +66,14 @@ export class App { this.controls.target.copy(CAMERA_POSES.binder.target); this.controls.enableDamping = true; this.controls.dampingFactor = 0.06; + this.controls.enableZoom = false; // 关闭 OrbitControls 自带缩放,使用自定义平滑缩放 this.controls.minDistance = CAMERA_POSES.binder.min; this.controls.maxDistance = CAMERA_POSES.binder.max; this.controls.maxPolarAngle = Math.PI * 0.82; + // 禁用中键(缩放)与右键(平移),仅左键旋转 + 滚轮缩放 + this.controls.mouseButtons.MIDDLE = -1; + this.controls.mouseButtons.RIGHT = -1; + this.controls.rotateSpeed = 0.4; // 整体降速(无边缘减速) this.controls.update(); } @@ -75,7 +84,7 @@ export class App { const key = new THREE.DirectionalLight(0xffe6c0, 2.4); key.position.set(3.2, 4.4, 3.6); key.castShadow = true; - key.shadow.mapSize.set(2048, 2048); + key.shadow.mapSize.set(1024, 1024); key.shadow.camera.near = 0.5; key.shadow.camera.far = 30; key.shadow.camera.left = -5; @@ -176,6 +185,7 @@ export class App { setPointer(e); if (this.view === 'timeline') { // 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中 + this.timelineSlide = null; // 手动拖拽时取消居中动画 this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset }; canvas.setPointerCapture(e.pointerId); } else { @@ -226,6 +236,16 @@ export class App { else if (e.key === '2') this.switchView('timeline'); else if (e.key === '3') this.switchView('globe'); }); + + // 滚轮缩放:目标累积(连续滚动合并为一次平滑过渡) + canvas.addEventListener('wheel', (e) => { + if (this.view === 'globe') return; + e.preventDefault(); + const dist = this.camera.position.distanceTo(this.controls.target); + const base = this.zoomTarget !== null ? this.zoomTarget : dist; + const factor = Math.pow(0.9, -e.deltaY * 0.01); + this.zoomTarget = clamp(base * factor, this.controls.minDistance, this.controls.maxDistance); + }, { passive: false }); } // 当前相机视野下,1 像素对应的世界宽度(拖拽跟手) @@ -257,15 +277,31 @@ export class App { if (!hit) return; const { view, index } = hit.object.userData; if (view === 'binder') { - this._select(index, { syncBinder: true }); + const cur = this.binder.current; + if (index === cur) { + this.binder.flipForward(); // 点击当前右页:翻到下一页 + } else if (index === cur - 1) { + this.binder.flipBackward(); // 点击左页:翻回上一页 + } else { + this._select(index, { syncBinder: true }); + } } else { this._select(index, { syncBinder: false }); // 点击时间轴卡片 / 地理标记时,显示右下角登记卡详情 document.getElementById('detail-card').classList.remove('hidden'); this.binder.syncTo(index); + // 时间轴:点击后滑动使该卡片居中 + if (view === 'timeline') this._centerTimeline(index); } } + // 时间轴:点击卡片后横向滑动,使其居中 + _centerTimeline(index) { + const limit = TIMELINE_SPREAD + 0.6; + const target = clamp(-this.timeline.getCardX(index), -limit, limit); + this.timelineSlide = { from: this.timelineOffset, to: target, t: 0, dur: 0.55 }; + } + _step(dir) { if (dir > 0) this.binder.flipForward(); else this.binder.flipBackward(); @@ -282,13 +318,23 @@ export class App { switchView(view) { if (view === this.view || !CAMERA_POSES[view]) return; + // 离开当前视图前,记住它的相机位置 + if (this.view !== 'globe') { + this.cameraMemory[this.view] = { + pos: this.camera.position.clone(), + target: this.controls.target.clone(), + }; + } const iris = document.getElementById('iris'); iris.style.opacity = '1'; const doSwap = () => { this._setView(view); - setTimeout(() => { iris.style.opacity = '0'; }, 180); + // 等新视图渲染一两帧后再淡出,避免半透明时看到切换瞬间 + requestAnimationFrame(() => { + requestAnimationFrame(() => { iris.style.opacity = '0'; }); + }); }; - setTimeout(doSwap, 240); + setTimeout(doSwap, 200); // 与 0.18s 的淡入匹配,完全变黑后再切换 } _setView(view) { @@ -309,13 +355,15 @@ export class App { this.globe.setVisible(true); this.globe.setSelected(this.selected); this.globe.resize(); + // 进入地理视图时,自动对准当前导航条选中的标记 + this.globe.flyToEntry(this.selected); } else { this.globe.setVisible(false); this.renderer.setSize(window.innerWidth, window.innerHeight); } - // 登记卡详情仅在活页本视图展示,避免遮挡时间轴/地理内容 - document.getElementById('detail-card').classList.toggle('hidden', view !== 'binder'); + // 登记卡详情在视图切换时保持显示,不隐藏 + document.getElementById('detail-card').classList.remove('hidden'); const pose = CAMERA_POSES[view]; this.controls.minDistance = pose.min; @@ -324,16 +372,49 @@ export class App { const locked = view === 'timeline'; this.controls.enableRotate = !locked; this.controls.enablePan = !locked; - this.controls.enableZoom = true; + this.controls.enableZoom = false; // 滚轮缩放改为自定义渐变动画 this.controls.autoRotate = false; - // 进入时间轴时复位横向偏移 + // 左键旋转限制:以各视图初始视角为中心,各方向 ±10° + this._applyRotationLimits(pose); + + // 进入时间轴时:自动定位到当前选中的活页居中 if (view === 'timeline') { - this.timelineOffset = 0; - this.timeline.group.position.x = 0; + const limit = TIMELINE_SPREAD + 0.6; + this.timelineOffset = clamp(-this.timeline.getCardX(this.selected), -limit, limit); + this.timeline.group.position.x = this.timelineOffset; } - if (!isGlobe) this._tweenCamera(pose.pos, pose.target); + if (!isGlobe) { + // 切换视图:直接定位(有记忆则恢复上次的相机位置) + this.cameraTween = null; + this.zoomTarget = null; + const mem = this.cameraMemory[view]; + if (mem) { + this.camera.position.copy(mem.pos); + this.controls.target.copy(mem.target); + } else { + this.camera.position.copy(pose.pos); + this.controls.target.copy(pose.target); + } + } + } + + _applyRotationLimits(pose) { + const LIMIT = (20 * Math.PI) / 180; // ±20° + const dx = pose.pos.x - pose.target.x; + const dy = pose.pos.y - pose.target.y; + const dz = pose.pos.z - pose.target.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1; + const polar = Math.acos(Math.max(-1, Math.min(1, dy / dist))); + const azimuth = Math.atan2(dx, dz); + this.controls.minPolarAngle = Math.max(0, polar - LIMIT); + this.controls.maxPolarAngle = Math.min(Math.PI - 0.01, polar + LIMIT); + this.controls.minAzimuthAngle = azimuth - LIMIT; + this.controls.maxAzimuthAngle = azimuth + LIMIT; + // 保存中心与限度,供边缘减速使用 + this._rotCenter = { polar, azimuth }; + this._rotLimit = LIMIT; } _tweenCamera(toPos, toTarget) { @@ -368,6 +449,28 @@ export class App { if (k >= 1) this.cameraTween = null; } + // 滚轮缩放渐变动画:相机距离平滑趋近目标(指数平滑,更平顺) + if (this.zoomTarget !== null && !this.cameraTween) { + const offset = this.camera.position.clone().sub(this.controls.target); + const dist = offset.length(); + const k = 1 - Math.exp(-dt * 2.2); + const newDist = dist + (this.zoomTarget - dist) * k; + offset.normalize().multiplyScalar(newDist); + this.camera.position.copy(this.controls.target).add(offset); + if (Math.abs(newDist - this.zoomTarget) < 0.002) this.zoomTarget = null; + } + + // 时间轴点击居中:横向滑动动画 + if (this.timelineSlide) { + const s = this.timelineSlide; + s.t += dt / s.dur; + const k = clamp(s.t, 0, 1); + const e = easeInOutCubic(k); + this.timelineOffset = s.from + (s.to - s.from) * e; + this.timeline.group.position.x = this.timelineOffset; + if (k >= 1) this.timelineSlide = null; + } + this.controls.update(); this.renderer.render(this.scene, this.camera); } diff --git a/src/scene/CesiumGlobe.js b/src/scene/CesiumGlobe.js index 4d4a51d..67a6bbc 100644 --- a/src/scene/CesiumGlobe.js +++ b/src/scene/CesiumGlobe.js @@ -7,6 +7,9 @@ const IMAGERY_URL = const TERRAIN_URL = 'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer'; +// 点击标记后相机飞到的目标高度(米,约 1500 km 区域尺度) +const FLY_TO_HEIGHT = 1.5e6; + export class CesiumGlobe { constructor({ onPick } = {}) { this.onPick = onPick; @@ -70,6 +73,9 @@ export class CesiumGlobe { }, ]; + // 阻止右键弹出浏览器菜单 + scene.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + // 精细化渲染:质量与性能的平衡 scene.globe.maximumScreenSpaceError = 2.0; // 默认细分,最省性能 scene.globe.tileCacheSize = 600; // 更大的瓦片缓存,减少细节刷新 @@ -146,12 +152,29 @@ export class CesiumGlobe { handler.setInputAction((movement) => { const picked = this.viewer.scene.pick(movement.position); if (Cesium.defined(picked) && picked.id && picked.id.entryIndex !== undefined) { - this.onPick?.(picked.id.entryIndex); + const index = picked.id.entryIndex; + this.onPick?.(index); + this.flyToEntry(index); } }, Cesium.ScreenSpaceEventType.LEFT_CLICK); this._handler = handler; } + // 相机平滑飞至某个标记上空,并将其居中(正俯视,北朝上) + flyToEntry(index) { + const entry = ENTRIES[index]; + if (!entry || !this.viewer) return; + this.viewer.camera.flyTo({ + destination: Cesium.Cartesian3.fromDegrees(entry.lng, entry.lat, FLY_TO_HEIGHT), + orientation: { + heading: 0, + pitch: Cesium.Math.toRadians(-90), + roll: 0, + }, + duration: 1.2, + }); + } + setSelected(index) { this.selected = index; if (!this.viewer) return; diff --git a/src/scene/binder.js b/src/scene/binder.js index 363ccf2..a9494aa 100644 --- a/src/scene/binder.js +++ b/src/scene/binder.js @@ -50,7 +50,7 @@ function makeLeatherTexture({ emblem = false } = {}) { // 内侧绒面(粗糙、哑光、模糊绒毛) function makeSuedeTexture() { - const w = 512, h = 640; + const w = 1024, h = 1280; const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); @@ -60,15 +60,17 @@ function makeSuedeTexture() { g.addColorStop(1, '#866244'); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); - for (let i = 0; i < 90000; i++) { + // 密集绒毛噪点(更细更密) + for (let i = 0; i < 420000; i++) { const x = Math.random() * w, y = Math.random() * h; ctx.fillStyle = Math.random() > 0.5 ? 'rgba(40,22,10,0.10)' : 'rgba(230,200,160,0.08)'; - ctx.fillRect(x, y, Math.random() * 1.6 + 0.3, Math.random() * 1.6 + 0.3); + ctx.fillRect(x, y, Math.random() * 1.1 + 0.22, Math.random() * 1.1 + 0.22); } + // 绒毛短划痕(更细密) ctx.strokeStyle = 'rgba(30,16,8,0.10)'; - ctx.lineWidth = 0.8; - for (let i = 0; i < 16000; i++) { - const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 5 + 1; + ctx.lineWidth = 0.65; + for (let i = 0; i < 70000; i++) { + const x = Math.random() * w, y = Math.random() * h, a = Math.random() * Math.PI, l = Math.random() * 7 + 1.5; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); ctx.stroke(); } const edge = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.4, w / 2, h / 2, Math.max(w, h) * 0.72); @@ -78,7 +80,7 @@ function makeSuedeTexture() { ctx.fillRect(0, 0, w, h); const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; - tex.anisotropy = 8; + tex.anisotropy = 16; return tex; } @@ -107,46 +109,53 @@ function roundedRectShape(w, h, r, edgeSeg = 40, arcSeg = 12) { return s; } -// 随机牛皮弯曲:低频平滑起伏(更柔和、更自然),正反面/皮边共用同一函数保证一致 -function leatherWarp(x, y) { - const s = Math.sin(x * 1.3 + 0.7) * Math.cos(y * 1.1 + 0.9) - + Math.sin(x * 0.8 + y * 1.5 + 0.4) * 0.6 - + Math.sin(x * 2.1 + 1.6) * Math.sin(y * 1.7 + 0.5) * 0.3; - return s * 0.002; -} - // 软皮曲面:ShapeGeometry 三角化(圆角正确、无缺口)+ 书脊处弧度弯曲 function makeLeatherSurface(w, h, crXY, curve, flip) { - const shape = roundedRectShape(w, h, crXY); - const geo = new THREE.ShapeGeometry(shape); - const pos = geo.attributes.position; - // ShapeGeometry 的 UV 是原始坐标,需归一化到 [0,1] - const uv0 = geo.attributes.uv; - for (let i = 0; i < uv0.count; i++) { - uv0.setXY(i, (uv0.getX(i) + w / 2) / w, (uv0.getY(i) + h / 2) / h); - } - uv0.needsUpdate = true; - const hh = h / 2, sigma = 0.5; - for (let i = 0; i < pos.count; i++) { - const x = pos.getX(i), y = pos.getY(i); - const v = y / hh; - const z = -curve * Math.exp(-(x * x) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(x, y); - pos.setZ(i, z); - } - pos.needsUpdate = true; - if (flip) { - // 背面:反转索引绕向(法线朝 -z),并镜像 UV - const idx = geo.index; - for (let i = 0; i < idx.count; i += 3) { - const t = idx.getX(i + 1); - idx.setX(i + 1, idx.getX(i + 2)); - idx.setX(i + 2, t); + // 规则网格(含内部顶点,曲面平滑无对角褶痕)+ 角区收缩到圆弧 + const segX = 128, segY = 96; + const hw = w / 2, hh = h / 2; + const bx = hw - crXY, by = hh - crXY; + const sigma = 0.5; + const pos = [], uvs = [], idx = []; + const g = []; + + for (let j = 0; j <= segY; j++) { + g[j] = []; + const y = -hh + (j / segY) * h; + for (let i = 0; i <= segX; i++) { + const x = -hw + (i / segX) * w; + let px = x, py = y; + const ax = Math.abs(x), ay = Math.abs(y); + // 角区:外部点投影到圆弧上(内部点保留) + if (ax > bx && ay > by) { + const dx = ax - bx, dy = ay - by; + const dl = Math.sqrt(dx * dx + dy * dy); + if (dl > crXY) { + const k = crXY / dl; + px = Math.sign(x) * (bx + dx * k); + py = Math.sign(y) * (by + dy * k); + } + } + const v = py / hh; + const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v); + g[j].push(pos.length / 3); + pos.push(px, py, z); + uvs.push(flip ? (hw - px) / w : (px + hw) / w, (py + hh) / h); } - idx.needsUpdate = true; - const uv = geo.attributes.uv; - for (let i = 0; i < uv.count; i++) uv.setX(i, 1 - uv.getX(i)); - uv.needsUpdate = true; } + + for (let j = 0; j < segY; j++) { + for (let i = 0; i < segX; i++) { + const a = g[j][i], b = g[j][i + 1], c = g[j + 1][i], d = g[j + 1][i + 1]; + if (flip) idx.push(a, c, b, b, c, d); + else idx.push(a, b, c, b, d, c); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geo.setIndex(idx); geo.computeVertexNormals(); return geo; } @@ -160,7 +169,7 @@ function makeSideRim(w, h, crXY, thick, curve) { for (let i = 0; i < pts.length; i++) { const px = pts[i].x, py = pts[i].y; const v = py / hh; - const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v) + leatherWarp(px, py); + const z = -curve * Math.exp(-(px * px) / (2 * sigma * sigma)) * (1 - 0.25 * v * v); pos.push(px, py, z - thick / 2, px, py, z + thick / 2); if (i > 0) { const a = (i - 1) * 2, b = a + 1, c = i * 2, d = i * 2 + 1; @@ -223,7 +232,7 @@ function makeLeatherNormalMap() { // 内侧绒面法线:密集细绒毛(高频噪点) function makeSuedeNormalMap() { - const w = 512, h = 640; + const w = 1024, h = 1280; const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d', { willReadFrequently: true }); @@ -240,7 +249,7 @@ function makeSuedeNormalMap() { // 高度 → 法线 const src = ctx.getImageData(0, 0, w, h); const out = ctx.createImageData(w, h); - const S = 4.5; + const S = 3.2; const hAt = (yy, xx) => src.data[(Math.min(h - 1, Math.max(0, yy)) * w + Math.min(w - 1, Math.max(0, xx))) * 4]; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { @@ -257,7 +266,7 @@ function makeSuedeNormalMap() { ctx.putImageData(out, 0, 0); const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.NoColorSpace; - tex.anisotropy = 8; + tex.anisotropy = 16; return tex; } @@ -269,8 +278,6 @@ export class Binder { this.flip = null; // { index, from, to, t, duration } this.onChange = null; - this.coverTex = makeLeatherTexture({ emblem: true }); - this.leatherNormalTex = makeLeatherNormalMap(); this.suedeTex = makeSuedeTexture(); this.suedeNormalTex = makeSuedeNormalMap(); this._buildCovers(); @@ -286,17 +293,9 @@ export class Binder { const coverW = PAGE_W + 0.2; const coverH = PAGE_H + 0.2; const spineW = 0.12; - const thick = 0.017; // 牛皮厚度(减 1/3) + const thick = 0.017; // 牛皮厚度 - // 外侧:光滑深棕小牛皮(纯色,微光泽) - const outerMat = new THREE.MeshStandardMaterial({ - map: this.coverTex, - normalMap: this.leatherNormalTex, - normalScale: new THREE.Vector2(0.3, 0.3), - roughness: 0.25, - metalness: 0.0, - }); - // 内侧:粗糙绒面(哑光) + // 内侧:粗糙绒面(哑光)——外皮不渲染,只保留内侧 const innerMat = new THREE.MeshStandardMaterial({ map: this.suedeTex, normalMap: this.suedeNormalTex, @@ -305,24 +304,21 @@ export class Binder { metalness: 0.0, }); - // 一张牛皮:正反两张弯曲曲面 + 封闭皮边(UV 正确无拉伸) + // 一张牛皮:仅内侧曲面 + 封闭皮边 const totalW = coverW * 2 + spineW; const frontGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, false); - const backGeo = makeLeatherSurface(totalW, coverH, 0.06, 0.07, true); const rimGeo = makeSideRim(totalW, coverH, 0.06, thick, 0.07); const rimMat = new THREE.MeshStandardMaterial({ color: 0x6b4524, roughness: 0.7, metalness: 0.0, side: THREE.DoubleSide }); const front = new THREE.Mesh(frontGeo, innerMat); // +z 朝书页(绒面) front.position.z = thick / 2; - const back = new THREE.Mesh(backGeo, outerMat); // -z 朝外(光滑) - back.position.z = -thick / 2; const rim = new THREE.Mesh(rimGeo, rimMat); this.cover = new THREE.Group(); - this.cover.add(front, back, rim); + this.cover.add(front, rim); this.cover.position.set(0, 0, -0.095); - front.receiveShadow = back.receiveShadow = rim.receiveShadow = true; - front.castShadow = back.castShadow = rim.castShadow = true; + front.receiveShadow = rim.receiveShadow = true; + front.castShadow = rim.castShadow = true; this.group.add(this.cover); } @@ -332,18 +328,18 @@ export class Binder { const mat = new THREE.MeshStandardMaterial({ color: 0xd8d2c6, metalness: 0.95, roughness: 0.25, }); - // 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.122)上 - const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.85, 0.04), mat); - plate.position.set(0, 0.07, -0.135); + // 薄片(有可见厚度的金属条),贴在牛皮内面(书脊处曲面 z≈-0.157)上 + const plate = new THREE.Mesh(new THREE.BoxGeometry(0.09, 1.95, 0.04), mat); + plate.position.set(0, 0, -0.137); plate.castShadow = true; plate.receiveShadow = true; - // 铆钉:把薄片钉在牛皮上 + // 铆钉:把薄片钉在牛皮上(覆盖新的铁环范围 ±0.896) const rivetGeo = new THREE.SphereGeometry(0.013, 12, 10); const rivets = []; - for (const ry of [-0.72, -0.36, 0, 0.36, 0.72]) { + for (const ry of [-0.78, -0.39, 0, 0.39, 0.78]) { const rivet = new THREE.Mesh(rivetGeo, mat); - rivet.position.set(0, ry, -0.114); + rivet.position.set(0, ry, -0.116); rivet.scale.z = 0.5; rivet.castShadow = true; rivets.push(rivet); @@ -356,7 +352,7 @@ export class Binder { _buildRings() { const mat = new THREE.MeshStandardMaterial({ - color: 0xd8d2c6, metalness: 0.95, roughness: 0.22, + color: 0xd4d7dc, metalness: 0.95, roughness: 0.2, }); // 更平滑的活页环 const geo = new THREE.TorusGeometry(RING_R, RING_TUBE, 18, 56); @@ -393,9 +389,11 @@ export class Binder { const frontMat = new THREE.MeshStandardMaterial({ map: front, roughness: 0.88, metalness: 0.0, side: THREE.FrontSide, + transparent: true, alphaTest: 0.5, }); const backMat = new THREE.MeshStandardMaterial({ map: back, roughness: 0.88, metalness: 0.0, side: THREE.BackSide, + transparent: true, alphaTest: 0.5, }); const frontMesh = new THREE.Mesh(geo, frontMat); @@ -418,23 +416,28 @@ export class Binder { backMat, angle: 0, y: 0, + z: 0, + targetZ: 0, }); this.group.add(g); }); - this._restack(); + this._restack(true); } - // 各页休息位置:角度 + 世界 z(堆叠深度) - _restack() { + // 各页休息位置:角度 + 目标 z(堆叠深度);z 由 update 里平滑动画过渡 + _restack(instant = false) { this.pages.forEach((p, j) => { const angle = j < this.current ? -Math.PI : 0; p.angle = angle; p.group.rotation.y = angle; const depth = j < this.current ? (this.current - 1 - j) : (j - this.current); - const z = -depth * STACK_GAP; - p.group.position.z = z; + p.targetZ = -depth * STACK_GAP; + if (instant) { + p.z = p.targetZ; + p.group.position.z = p.z; + } p.group.position.y = 0; p.group.position.x = 0; // 下层页略暗 @@ -445,9 +448,10 @@ export class Binder { this._updateCurvature(); } - _updateCurvature() { + _updateCurvature(index = null) { // 翻页时整页柔软弧弯:自由边最高、中段有“腹”、书脊侧贴环;顶边略强于底边;静止时完全平整 - this.pages.forEach((p) => { + const pages = index === null ? this.pages : [this.pages[index]]; + pages.forEach((p) => { const pos = p.geometry.attributes.position; const arr = pos.array; const theta = Math.abs(p.angle); // 0..π @@ -472,23 +476,29 @@ export class Binder { syncTo(index) { this.current = clamp(index, 0, this.pages.length - 1); this.flip = null; - this._restack(); + this._restack(true); if (this.onChange) this.onChange(this.current); } - canFlipForward() { return !this.flip && this.current < this.pages.length - 1; } + canFlipForward() { return !this.flip; } canFlipBackward() { return !this.flip && this.current > 0; } flipForward() { if (!this.canFlipForward()) return; this.flip = { index: this.current, from: 0, to: -Math.PI, t: 0, duration: 0.6 }; - this.pages[this.current].group.position.z = 0.006; + const p = this.pages[this.current]; + p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006; + // 优先更新底部导航与信息卡(翻页开始即切换目标页) + if (this.onChange) this.onChange((this.current + 1) % this.pages.length); } flipBackward() { if (!this.canFlipBackward()) return; this.flip = { index: this.current - 1, from: -Math.PI, to: 0, t: 0, duration: 0.6 }; - this.pages[this.current - 1].group.position.z = 0.006; + const p = this.pages[this.current - 1]; + p.z = 0.006; p.targetZ = 0.006; p.group.position.z = 0.006; + // 优先更新底部导航与信息卡 + if (this.onChange) this.onChange(this.current - 1); } update(dt) { @@ -502,15 +512,24 @@ export class Binder { const p = this.pages[f.index]; p.angle = angle; p.group.rotation.y = angle; - this._updateCurvature(); + this._updateCurvature(f.index); if (k >= 1) { - if (f.to === -Math.PI) this.current += 1; else this.current -= 1; + if (f.to === -Math.PI) { + // 最后一页翻完后循环回第一页 + this.current = (this.current + 1) % this.pages.length; + } else this.current -= 1; this.flip = null; - this._restack(); - if (this.onChange) this.onChange(this.current); + this._restack(false); } } + + // 页面堆叠进退动画:z 平滑过渡到目标 + const lerpK = Math.min(1, dt * 14); + this.pages.forEach((p) => { + p.z += (p.targetZ - p.z) * lerpK; + p.group.position.z = p.z; + }); } dispose() { diff --git a/src/scene/timeline.js b/src/scene/timeline.js index 858e968..fcea423 100644 --- a/src/scene/timeline.js +++ b/src/scene/timeline.js @@ -7,6 +7,8 @@ export const TIMELINE_SPREAD = 6.2; // 横轴范围 ±6.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 * TIMELINE_SPREAD - TIMELINE_SPREAD; @@ -24,27 +26,21 @@ export class Timeline { _buildLine() { const n = ENTRIES.length; - const min = cardX(0, n) - 0.4; - const max = cardX(n - 1, n) + 0.4; + // 轴线向两侧延伸足够远(并关闭雾化),滚动到边缘也不见端点,视觉上无限长 + const extent = 50; - const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7 }); + const lineMat = new THREE.LineBasicMaterial({ color: 0xc9a227, transparent: true, opacity: 0.7, fog: false }); const lineGeo = new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(min, -0.85, 0), new THREE.Vector3(max, -0.85, 0), + new THREE.Vector3(-extent, AXIS_Y, 0), new THREE.Vector3(extent, AXIS_Y, 0), ]); this.group.add(new THREE.Line(lineGeo, lineMat)); - const glowMat = new THREE.LineBasicMaterial({ color: 0xe0c46a, transparent: true, opacity: 0.22 }); - const glowGeo = new THREE.BufferGeometry().setFromPoints([ - new THREE.Vector3(min, -0.855, -0.03), new THREE.Vector3(max, -0.855, -0.03), - ]); - this.group.add(new THREE.Line(glowGeo, glowMat)); - // 刻度:向上指向卡片、向下指向时间标签 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, -0.98, 0), new THREE.Vector3(x, -0.7, 0), + 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)); }); @@ -73,12 +69,12 @@ export class Timeline { card.add(outline); // 活页上方:主题(常显,随选中上浮,超宽居中换行) - const name = makeTextSprite(entry.name, { fontSize: 17, color: '#e8d9b4', scale: 0.13, maxWidth: 1.0 }); + 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, -1.06, 0); + time.position.set(x, TIME_Y, 0); this.group.add(card, name, time); this.cards.push({ card, outline, name, nameBaseY: NAME_Y, index: i }); @@ -94,4 +90,9 @@ export class Timeline { c.name.position.y = c.nameBaseY + (on ? CARD_FLOAT : 0); }); } + + // 返回某张卡片在时间轴组内的横向坐标(用于点击后居中) + getCardX(index) { + return cardX(index, ENTRIES.length); + } } diff --git a/src/styles.css b/src/styles.css index 7d1d9c9..46cd54f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -48,7 +48,7 @@ html, body { } #iris { position: absolute; inset: 0; pointer-events: none; - background: #0c0906; opacity: 0; transition: opacity 0.45s ease; + background: #0c0906; opacity: 0; transition: opacity 0.18s ease; } /* 顶部标题 */ @@ -107,7 +107,7 @@ html, body { /* 登记卡详情 */ .detail-card { - right: 24px; bottom: 96px; width: min(320px, 82vw); + right: 24px; bottom: 22px; width: min(320px, 82vw); padding: 18px 20px; border-radius: 4px; background: linear-gradient(180deg, rgba(232,216,182,0.96), rgba(214,196,156,0.96)); color: var(--ink); box-shadow: 0 18px 50px rgba(0,0,0,0.55), inset 0 0 0 1px rgba(90,61,43,0.4); diff --git a/src/textures/pages.js b/src/textures/pages.js index 48fef40..fe37ef3 100644 --- a/src/textures/pages.js +++ b/src/textures/pages.js @@ -7,9 +7,9 @@ export const PAGE_W = 1.6; // 世界单位:页宽 export const PAGE_H = 2.24; // 世界单位:页高 export const TEX_W = 768; export const TEX_H = 1075; -export const HOLE_U = [0.085, 0.24, 0.4, 0.56, 0.72, 0.855]; // 活页孔(自顶部归一化) +export const HOLE_U = [0.1, 0.26, 0.42, 0.58, 0.74, 0.9]; // 活页孔(自顶部归一化,6孔等距) export const HOLE_X = 0.065; // 孔中心相对页内缘(环前交点 0.085 − 页缘空隙 0.02) -export const HOLE_R = 0.03; // 孔半径(世界,略大于环丝,留出穿环空隙) +export const HOLE_R = 0.029; // 孔半径 ≈5.5mm 直径(真实活页本孔径) const SERIF = '"Noto Serif SC","Songti SC","STSong","SimSun",Georgia,serif'; const KAI = '"Kaiti SC","STKaiti","KaiTi","Noto Serif SC",serif'; @@ -21,34 +21,34 @@ const CONTENT_L = MARGIN.left + 18; // 内容左界 const CONTENT_R = TEX_W - MARGIN.right - 18; // 内容右界 const CONTENT_W = CONTENT_R - CONTENT_L; // 内容宽度 -// —— 基础纸张 —— +// —— 基础纸张(牛皮纸)—— export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed = 0 } = {}) { const c = document.createElement('canvas'); c.width = w; c.height = h; const ctx = c.getContext('2d'); - // 底色(暖黄纸) - const base = [232 - tone, 216 - tone, 182 - tone]; + // 底色(牛皮纸棕黄) + const base = [201 - tone, 169 - tone, 126 - tone]; const g = ctx.createLinearGradient(0, 0, 0, h); - g.addColorStop(0, `rgb(${base[0] + 6},${base[1] + 5},${base[2] + 4})`); + g.addColorStop(0, `rgb(${base[0] + 7},${base[1] + 6},${base[2] + 5})`); g.addColorStop(0.5, `rgb(${base[0]},${base[1]},${base[2]})`); - g.addColorStop(1, `rgb(${base[0] - 8},${base[1] - 7},${base[2] - 6})`); + g.addColorStop(1, `rgb(${base[0] - 9},${base[1] - 8},${base[2] - 7})`); ctx.fillStyle = g; ctx.fillRect(0, 0, w, h); - // 随机纤维与斑点 + // 纤维斑点(更粗粝) const rand = mulberry(seed + 1); - for (let i = 0; i < w * h / 90; i++) { + for (let i = 0; i < w * h / 70; i++) { const x = rand() * w, y = rand() * h; const dark = rand() > 0.5; - ctx.fillStyle = dark ? 'rgba(120,95,60,0.10)' : 'rgba(255,250,230,0.14)'; - ctx.fillRect(x, y, rand() * 2 + 0.4, rand() * 2 + 0.4); + ctx.fillStyle = dark ? 'rgba(110,80,48,0.14)' : 'rgba(240,220,185,0.13)'; + ctx.fillRect(x, y, rand() * 2.4 + 0.5, rand() * 2.4 + 0.5); } - // 纤维线条 - ctx.strokeStyle = 'rgba(140,110,70,0.07)'; - ctx.lineWidth = 0.7; - for (let i = 0; i < w * h / 2600; i++) { - const x = rand() * w, y = rand() * h, a = rand() * Math.PI, l = rand() * 22 + 6; + // 长纤维线条(牛皮纸特征) + ctx.strokeStyle = 'rgba(120,85,50,0.13)'; + ctx.lineWidth = 0.8; + for (let i = 0; i < w * h / 1400; i++) { + const x = rand() * w, y = rand() * h, a = rand() * Math.PI, l = rand() * 50 + 12; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(a) * l, y + Math.sin(a) * l); @@ -59,7 +59,7 @@ export function makePaper(w, h, { tone = 0, ruled = false, margin = false, seed const x = rand() * w, y = rand() * h, r = rand() * 140 + 40; const gr = ctx.createRadialGradient(x, y, 0, x, y, r); const warm = rand() > 0.5; - gr.addColorStop(0, warm ? 'rgba(180,150,95,0.05)' : 'rgba(120,100,70,0.05)'); + gr.addColorStop(0, warm ? 'rgba(170,135,90,0.06)' : 'rgba(110,80,50,0.06)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); ctx.fillStyle = gr; ctx.fillRect(0, 0, w, h); @@ -103,24 +103,16 @@ function mulberry(seed) { function drawHoles(ctx, w, h) { const hx = (HOLE_X / PAGE_W) * w; const hr = (HOLE_R / PAGE_W) * w; + ctx.save(); + ctx.fillStyle = '#000'; // 确保不透明,destination-out 才能完全抠掉 for (const u of HOLE_U) { const y = u * h; - // 孔内阴影(穿过去的感觉) - const g = ctx.createRadialGradient(hx, y, 0, hx, y, hr); - g.addColorStop(0, '#2b1f13'); - g.addColorStop(0.62, '#4a3826'); - g.addColorStop(0.82, '#6b5740'); - g.addColorStop(1, '#8a735a'); - ctx.fillStyle = g; + // 真实打孔:抠出透明孔洞(配合材质 alphaTest 成为真洞) + ctx.globalCompositeOperation = 'destination-out'; ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.fill(); - // 孔缘 - ctx.strokeStyle = 'rgba(70,50,32,0.85)'; - ctx.lineWidth = 2.2; - ctx.beginPath(); ctx.arc(hx, y, hr, 0, Math.PI * 2); ctx.stroke(); - ctx.strokeStyle = 'rgba(255,244,214,0.35)'; - ctx.lineWidth = 1; - ctx.beginPath(); ctx.arc(hx, y, hr - 2, Math.PI * 0.15, Math.PI * 0.85); ctx.stroke(); + ctx.globalCompositeOperation = 'source-over'; } + ctx.restore(); } // 描边 + 填充的通用文字