Add 3D loose-leaf chronicle of human civilisation
A Three.js site presenting 20 world history milestones as double-sided loose-leaf pages, with three views: a leather binder with page-flip animation, a golden timeline, and a dotted globe with great-circle arcs. All textures (paper, leather, registration cards, and 18 hand-drawn sketches) are generated procedurally via Canvas 2D, so no external image assets are needed beyond the Earth map. Includes dev helper scripts for screenshots, ASCII previews, and headless verification.
This commit is contained in:
+344
@@ -0,0 +1,344 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
import { ENTRIES } from '../data/entries.js';
|
||||
import { Binder } from '../scene/binder.js';
|
||||
import { Timeline, TIMELINE_SPREAD } from '../scene/timeline.js';
|
||||
import { Globe } from '../scene/globe.js';
|
||||
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: 3.4 },
|
||||
globe: { pos: new THREE.Vector3(0, 0.35, 3.7), target: new THREE.Vector3(0, 0, 0), max: 8, min: 2.2 },
|
||||
};
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
this.view = 'binder';
|
||||
this.selected = 0;
|
||||
this.timelineOffset = 0;
|
||||
|
||||
this._initRenderer();
|
||||
this._initLights();
|
||||
this._initScene();
|
||||
|
||||
this.overlay = new Overlay({
|
||||
onView: (v) => this.switchView(v),
|
||||
onPrev: () => this._step(-1),
|
||||
onNext: () => this._step(1),
|
||||
onFlip: () => this._step(1),
|
||||
});
|
||||
this.overlay.setTotal(ENTRIES.length);
|
||||
|
||||
this._initEvents();
|
||||
this._select(0, { syncBinder: false });
|
||||
this.binder.syncTo(0);
|
||||
|
||||
this.clock = new THREE.Clock();
|
||||
this.cameraTween = null;
|
||||
this._animate = this._animate.bind(this);
|
||||
requestAnimationFrame(this._animate);
|
||||
}
|
||||
|
||||
_initRenderer() {
|
||||
const canvas = document.getElementById('scene');
|
||||
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
this.renderer.toneMappingExposure = 1.1;
|
||||
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x16110c);
|
||||
this.scene.fog = new THREE.Fog(0x16110c, 6.5, 18);
|
||||
|
||||
this.camera = new THREE.PerspectiveCamera(46, window.innerWidth / window.innerHeight, 0.1, 60);
|
||||
this.camera.position.copy(CAMERA_POSES.binder.pos);
|
||||
|
||||
this.controls = new OrbitControls(this.camera, canvas);
|
||||
this.controls.target.copy(CAMERA_POSES.binder.target);
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.06;
|
||||
this.controls.minDistance = CAMERA_POSES.binder.min;
|
||||
this.controls.maxDistance = CAMERA_POSES.binder.max;
|
||||
this.controls.maxPolarAngle = Math.PI * 0.82;
|
||||
this.controls.update();
|
||||
}
|
||||
|
||||
_initLights() {
|
||||
const hemi = new THREE.HemisphereLight(0xfff1d8, 0x221811, 0.55);
|
||||
this.scene.add(hemi);
|
||||
|
||||
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.camera.near = 0.5;
|
||||
key.shadow.camera.far = 30;
|
||||
key.shadow.camera.left = -5;
|
||||
key.shadow.camera.right = 5;
|
||||
key.shadow.camera.top = 6;
|
||||
key.shadow.camera.bottom = -6;
|
||||
key.shadow.bias = -0.0002;
|
||||
this.scene.add(key);
|
||||
this.keyLight = key;
|
||||
|
||||
const rim = new THREE.DirectionalLight(0x8fa0d0, 0.7);
|
||||
rim.position.set(-2.5, 1.2, -3.5);
|
||||
this.scene.add(rim);
|
||||
}
|
||||
|
||||
_initScene() {
|
||||
this.binder = new Binder();
|
||||
this.binder.onChange = (i) => this._select(i, { syncBinder: false });
|
||||
|
||||
this.timeline = new Timeline();
|
||||
this.globe = new Globe({ renderer: this.renderer });
|
||||
|
||||
this.scene.add(this.binder.group);
|
||||
this.scene.add(this.timeline.group);
|
||||
this.scene.add(this.globe.group);
|
||||
|
||||
this._buildGround();
|
||||
|
||||
this.timeline.group.visible = false;
|
||||
this.globe.group.visible = false;
|
||||
|
||||
this._collectInteractives();
|
||||
}
|
||||
|
||||
_buildGround() {
|
||||
// 柔和地面,承接阴影,让本子有落地的重量感
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 512;
|
||||
const g = c.getContext('2d');
|
||||
const rg = g.createRadialGradient(256, 256, 0, 256, 256, 256);
|
||||
rg.addColorStop(0, 'rgba(201,162,39,0.15)');
|
||||
rg.addColorStop(0.45, 'rgba(70,45,22,0.10)');
|
||||
rg.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = rg;
|
||||
g.fillRect(0, 0, 512, 512);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
const geo = new THREE.PlaneGeometry(16, 16);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
map: tex, color: 0x241a12, roughness: 0.95, metalness: 0.0, transparent: true,
|
||||
});
|
||||
this.ground = new THREE.Mesh(geo, mat);
|
||||
this.ground.rotation.x = -Math.PI / 2;
|
||||
this.ground.position.y = -1.3;
|
||||
this.ground.receiveShadow = true;
|
||||
this.scene.add(this.ground);
|
||||
}
|
||||
|
||||
_collectInteractives() {
|
||||
this.interactives = {
|
||||
binder: [],
|
||||
timeline: [],
|
||||
globe: [],
|
||||
};
|
||||
this.binder.pages.forEach((p) => {
|
||||
p.group.children.forEach((m) => {
|
||||
m.userData = { view: 'binder', index: p.group.userData.index };
|
||||
this.interactives.binder.push(m);
|
||||
});
|
||||
});
|
||||
this.timeline.cards.forEach((c) => this.interactives.timeline.push(c.card));
|
||||
this.globe.markers.forEach((m) => this.interactives.globe.push(m.marker));
|
||||
}
|
||||
|
||||
_initEvents() {
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.pointer = new THREE.Vector2();
|
||||
this._drag = null;
|
||||
const canvas = this.renderer.domElement;
|
||||
|
||||
const setPointer = (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
this.pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
this.pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
};
|
||||
|
||||
canvas.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
setPointer(e);
|
||||
if (this.view === 'timeline') {
|
||||
// 时间轴:按下开始拖拽左右滑动;未移动的抬起才视为点击选中
|
||||
this._drag = { active: true, startX: e.clientX, startY: e.clientY, moved: false, offset: this.timelineOffset };
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
} else {
|
||||
this._pick();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointermove', (e) => {
|
||||
setPointer(e);
|
||||
if (this._drag && this._drag.active) {
|
||||
const dx = e.clientX - this._drag.startX;
|
||||
const dy = e.clientY - this._drag.startY;
|
||||
if (!this._drag.moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
|
||||
this._drag.moved = true;
|
||||
}
|
||||
if (this._drag.moved) {
|
||||
const limit = TIMELINE_SPREAD + 0.6;
|
||||
this.timelineOffset = clamp(this._drag.offset + dx * this._worldPerPixel(), -limit, limit);
|
||||
this.timeline.group.position.x = this.timelineOffset;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const hit = this._hitTest();
|
||||
canvas.style.cursor = hit ? 'pointer' : 'grab';
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointerup', (e) => {
|
||||
if (this._drag && this._drag.active) {
|
||||
const wasClick = !this._drag.moved;
|
||||
this._drag.active = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
if (wasClick) this._pick();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('pointercancel', () => {
|
||||
if (this._drag) this._drag.active = false;
|
||||
canvas.style.cursor = 'grab';
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => this._onResize());
|
||||
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowRight') this._step(1);
|
||||
else if (e.key === 'ArrowLeft') this._step(-1);
|
||||
else if (e.key === '1') this.switchView('binder');
|
||||
else if (e.key === '2') this.switchView('timeline');
|
||||
else if (e.key === '3') this.switchView('globe');
|
||||
});
|
||||
}
|
||||
|
||||
// 当前相机视野下,1 像素对应的世界宽度(拖拽跟手)
|
||||
_worldPerPixel() {
|
||||
const canvas = this.renderer.domElement;
|
||||
const dist = this.camera.position.distanceTo(this.controls.target);
|
||||
const halfFov = THREE.MathUtils.degToRad(this.camera.fov / 2);
|
||||
const aspect = canvas.clientWidth / canvas.clientHeight;
|
||||
return (2 * Math.tan(halfFov) * dist * aspect) / canvas.clientWidth;
|
||||
}
|
||||
|
||||
_hitTest() {
|
||||
const list = this.interactives[this.view];
|
||||
if (!list || !list.length) return null;
|
||||
this.raycaster.setFromCamera(this.pointer, this.camera);
|
||||
const hits = this.raycaster.intersectObjects(list, false);
|
||||
return hits.length ? hits[0] : null;
|
||||
}
|
||||
|
||||
_pick() {
|
||||
const hit = this._hitTest();
|
||||
if (!hit) return;
|
||||
const { view, index } = hit.object.userData;
|
||||
if (view === 'binder') {
|
||||
this._select(index, { syncBinder: true });
|
||||
} else {
|
||||
this._select(index, { syncBinder: false });
|
||||
// 点击时间轴卡片 / 地理标记时,显示右下角登记卡详情
|
||||
document.getElementById('detail-card').classList.remove('hidden');
|
||||
this.binder.syncTo(index);
|
||||
}
|
||||
}
|
||||
|
||||
_step(dir) {
|
||||
if (dir > 0) this.binder.flipForward();
|
||||
else this.binder.flipBackward();
|
||||
}
|
||||
|
||||
_select(index, { syncBinder = false } = {}) {
|
||||
this.selected = clamp(index, 0, ENTRIES.length - 1);
|
||||
const entry = ENTRIES[this.selected];
|
||||
this.overlay.setEntry(entry, this.selected, ENTRIES.length);
|
||||
this.timeline.setSelected(this.selected);
|
||||
this.globe.setSelected(this.selected);
|
||||
if (syncBinder) this.binder.syncTo(this.selected);
|
||||
}
|
||||
|
||||
switchView(view) {
|
||||
if (view === this.view || !CAMERA_POSES[view]) return;
|
||||
const iris = document.getElementById('iris');
|
||||
iris.style.opacity = '1';
|
||||
const doSwap = () => {
|
||||
this._setView(view);
|
||||
setTimeout(() => { iris.style.opacity = '0'; }, 180);
|
||||
};
|
||||
setTimeout(doSwap, 240);
|
||||
}
|
||||
|
||||
_setView(view) {
|
||||
this.view = view;
|
||||
this.overlay.setView(view);
|
||||
|
||||
this.binder.group.visible = view === 'binder';
|
||||
this.timeline.group.visible = view === 'timeline';
|
||||
this.globe.group.visible = view === 'globe';
|
||||
|
||||
// 登记卡详情仅在活页本视图展示,避免遮挡时间轴/地理内容
|
||||
document.getElementById('detail-card').classList.toggle('hidden', view !== 'binder');
|
||||
|
||||
const pose = CAMERA_POSES[view];
|
||||
this.controls.minDistance = pose.min;
|
||||
this.controls.maxDistance = pose.max;
|
||||
// 时间轴:锁定旋转/平移;滚轮缩放;拖拽左右滑动
|
||||
const locked = view === 'timeline';
|
||||
this.controls.enableRotate = !locked;
|
||||
this.controls.enablePan = !locked;
|
||||
this.controls.enableZoom = true;
|
||||
this.controls.autoRotate = view === 'globe';
|
||||
this.controls.autoRotateSpeed = 0.5;
|
||||
|
||||
// 进入时间轴时复位横向偏移
|
||||
if (view === 'timeline') {
|
||||
this.timelineOffset = 0;
|
||||
this.timeline.group.position.x = 0;
|
||||
}
|
||||
|
||||
this._tweenCamera(pose.pos, pose.target);
|
||||
}
|
||||
|
||||
_tweenCamera(toPos, toTarget) {
|
||||
const fromPos = this.camera.position.clone();
|
||||
const fromTarget = this.controls.target.clone();
|
||||
const dur = 1.1;
|
||||
this.cameraTween = {
|
||||
t: 0, dur, fromPos, toPos: toPos.clone(), fromTarget, toTarget: toTarget.clone(),
|
||||
};
|
||||
}
|
||||
|
||||
_onResize() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
|
||||
_animate() {
|
||||
requestAnimationFrame(this._animate);
|
||||
const dt = Math.min(this.clock.getDelta(), 0.05);
|
||||
|
||||
this.binder.update(dt);
|
||||
|
||||
if (this.cameraTween) {
|
||||
const t = this.cameraTween;
|
||||
t.t += dt / t.dur;
|
||||
const k = clamp(t.t, 0, 1);
|
||||
const e = easeInOutCubic(k);
|
||||
this.camera.position.lerpVectors(t.fromPos, t.toPos, e);
|
||||
this.controls.target.lerpVectors(t.fromTarget, t.toTarget, e);
|
||||
if (k >= 1) this.cameraTween = null;
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user