import * as THREE from 'three' import { cardSceneDimensions, createCardGeometry } from './cardGeometry' import { createFoilWrapper } from './foilWrapper' import { applyEdgeMaterialControls, applyMaterialControls, createCardMaterial, type FinishName, type SubstrateName, } from './cardMaterial' export type PackFixture = 'David' | 'Timothy' export type PackState = 'sealed' | 'opening' | 'stackReady' | 'lifting' | 'lifted' | 'revealing' | 'inspecting' | 'advancing' | 'complete' export const packContents: readonly { fixture: PackFixture finish: FinishName substrate: SubstrateName }[] = [ { fixture: 'David', finish: 'Printed ink', substrate: 'Linen' }, { fixture: 'Timothy', finish: 'Foil', substrate: 'Paper' }, { fixture: 'David', finish: 'Holographic', substrate: 'Metal' }, ] // Leaves ~0.039 scene units between the surfaces of adjacent 0.88-scale cards. const stackDepthSpacing = 0.055 const cardSweepRadius = Math.hypot( cardSceneDimensions.width, cardSceneDimensions.height, cardSceneDimensions.thickness, ) / 2 // Clearance is based on the full corner sweep, not just the card's thickness or front pose. const inspectionDepth = Math.max(3, cardSweepRadius + 0.5) const inspectionCameraRetreat = inspectionDepth - 0.85 interface PackOptions { canvas: HTMLCanvasElement textures: Record backMaterial: THREE.Material normalMap: THREE.Texture environment: THREE.CubeTexture lightPosition: THREE.Vector3 onChange: () => void } interface Pose { position: THREE.Vector3 quaternion: THREE.Quaternion scale: THREE.Vector3 } interface Track { object: THREE.Object3D from: Pose to: Pose } interface Motion { tracks: Track[] startedAt: number elapsed: number duration: number finish: () => void progress?: (value: number) => void } function pose(x: number, y: number, z: number, rx = 0, ry = 0, rz = 0, scale = 1): Pose { return { position: new THREE.Vector3(x, y, z), quaternion: new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), scale: new THREE.Vector3(scale, scale, scale), } } function setPose(object: THREE.Object3D, value: Pose) { object.position.copy(value.position) object.quaternion.copy(value.quaternion) object.scale.copy(value.scale) } function track(object: THREE.Object3D, to: Pose): Track { return { object, from: { position: object.position.clone(), quaternion: object.quaternion.clone(), scale: object.scale.clone(), }, to, } } export class PackOpening { readonly root = new THREE.Group() readonly camera = new THREE.PerspectiveCamera(34, 1, 0.1, 100) state: PackState = 'sealed' paused = false private readonly options: PackOptions private readonly wrapper = createFoilWrapper() private readonly cards private readonly reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') private motion: Motion | undefined private active = false private index = 0 private readonly revealed = new Set() private completed = false private zoom = 1 private fitDistance = 9.6 private cameraRetreat = 0 private cameraTargetY = 0 private openingProgress = 0 private seamDrag: { id: number; x: number; progress: number; travel: number } | undefined private readonly touch = new THREE.Vector3() private touchTarget = 0 private lastTick: number | undefined private wrapperDirty = false private wrapperStripExit = 4.8 private readonly pointers = new Map() private tap: { id: number; x: number; y: number; at: number } | undefined private pinchDistance: number | undefined constructor(options: PackOptions) { this.options = options this.root.name = 'PACK_PROTOTYPE' this.root.position.y = 0.22 this.root.visible = false this.root.add(this.wrapper.root) this.cards = packContents.map((spec) => { const textures = options.textures[spec.fixture] const material = createCardMaterial( textures.artwork, textures.mask, options.normalMap, options.environment, options.lightPosition, ) applyMaterialControls(material, { ...spec, finishStrength: 0.6, roughness: 0.23, normalStrength: 0.14, environmentIntensity: 0.7, condition: 1, imperfectionSeed: 81251, }) const edge = new THREE.MeshPhysicalMaterial() const object = createCardGeometry(material, options.backMaterial, edge) const front = object.getObjectByName('CARD_FRONT')! this.root.add(object) return { object, material, edge, front } }) this.restart() } get view() { const number = `${this.index + 1} / ${this.cards.length}` const spec = packContents[this.index] const views: Record = { sealed: { action: 'Tear open', status: 'Sealed · three cards', hint: 'Drag the gold top seam to the right · or use Tear open' }, opening: { action: this.motion && !this.paused ? 'Opening…' : 'Finish opening', status: this.openingProgress < 0.38 ? `Tearing seal · ${Math.round(this.tearProgress * 100)}%` : this.openingProgress < 0.52 ? 'Strip curling away' : this.openingProgress < 0.62 ? 'Spreading the foil mouth' : this.openingProgress < 0.87 ? 'Sliding out three cards' : 'Settling the face-down stack', hint: this.openingProgress < 0.38 ? 'Pull right along the seam · release to hold · Finish opening resumes' : 'Touch or wheel pauses motion · Skip finishes this step', }, stackReady: { action: 'Lift card', status: `Card ${number} · face-down stack`, hint: 'Tap or Lift card · identities stay hidden until the flip' }, lifting: { action: 'Lifting…', status: `Card ${number} · lifting face down`, hint: 'Touch or wheel pauses motion · Skip finishes this step' }, lifted: { action: 'Reveal card', status: `Card ${number} · ready to turn`, hint: 'Tap or Reveal card to flip the front into view' }, revealing: { action: 'Revealing…', status: `Card ${number} · turning over`, hint: 'Touch or wheel pauses motion · Skip finishes this step' }, inspecting: { action: this.index === this.cards.length - 1 ? 'Finish pack' : 'Next card', status: `${number} · ${spec.fixture} · ${spec.substrate} · ${spec.finish}`, hint: 'Drag to rotate · pinch / wheel to zoom · tap to continue', }, advancing: { action: 'Advancing…', status: 'Setting the revealed card aside', hint: 'Touch or wheel pauses motion · Skip finishes this step' }, complete: { action: 'Pack complete', status: 'Complete · all three cards inspected', hint: 'Restart to replay the same authored order' }, } return this.paused && this.motion ? { ...views[this.state], action: 'Continue', hint: 'Motion paused in place · Continue resumes · Skip finishes this step' } : views[this.state] } get tearProgress() { return Math.min(1, this.openingProgress / 0.38) } syncLighting(source: THREE.ShaderMaterial) { for (const { material } of this.cards) { for (const name of ['studioEnvironment', 'environmentIntensity', 'lightMode', 'lightIntensity']) { material.uniforms[name].value = source.uniforms[name].value } material.uniforms.lightColor.value.copy(source.uniforms.lightColor.value) } } async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) { if (this.active) throw new Error('Prepare pack GPU resources before activating the pack') const textures = new Set() if (scene.environment) textures.add(scene.environment) this.root.traverse((object) => { if (!(object instanceof THREE.Mesh)) return const materials = Array.isArray(object.material) ? object.material : [object.material] for (const material of materials) { for (const value of Object.values(material)) { if (value instanceof THREE.Texture) textures.add(value) } if (material instanceof THREE.ShaderMaterial) { for (const uniform of Object.values(material.uniforms)) { if (uniform.value instanceof THREE.Texture) textures.add(uniform.value) } } } }) for (const texture of textures) renderer.initTexture(texture) // compileAsync visits hidden fronts without drawing them or changing reveal state. await renderer.compileAsync(this.root, this.camera, scene) try { this.cards.forEach((card, index) => { applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 }) }) await renderer.compileAsync(this.root, this.camera, scene) } finally { for (const card of this.cards) applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 }) } } dispose() { this.clearPointers() this.active = false this.root.visible = false this.root.removeFromParent() this.wrapper.dispose() const geometries = new Set() for (const card of this.cards) { card.object.traverse((object) => { if (object instanceof THREE.Mesh) geometries.add(object.geometry) }) card.material.dispose() card.edge.dispose() } for (const geometry of geometries) geometry.dispose() } setActive(active: boolean) { if (!active) this.pause(performance.now()) this.clearPointers() this.active = active this.root.visible = active this.options.onChange() } resize(width: number, height: number) { if (width <= 0 || height <= 0) return this.camera.aspect = width / height this.camera.updateProjectionMatrix() const tangent = Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) this.fitDistance = Math.max(9.6, 3.95 / (2 * tangent * this.camera.aspect)) this.updateCamera() } private updateCamera() { this.camera.position.set(0, this.cameraTargetY, this.fitDistance * this.zoom + this.cameraRetreat) this.camera.lookAt(0, this.cameraTargetY, 0) } private stackPose(offset: number) { return pose(offset * 0.045, -offset * 0.045, -0.08 - offset * stackDepthSpacing, 0, Math.PI, 0, 0.88) } private reservePose(offset: number) { const target = this.stackPose(offset) target.position.x += 1.05 target.position.y -= 0.4 return target } restart() { this.clearPointers() this.motion = undefined this.paused = false this.index = 0 this.revealed.clear() this.completed = false this.zoom = 1 this.cameraRetreat = 0 this.cameraTargetY = 0 this.openingProgress = 0 this.touch.set(0, 0, 0) this.touchTarget = 0 this.lastTick = undefined this.updateCamera() this.wrapper.root.visible = true setPose(this.wrapper.root, pose(0, 0, 0)) this.invalidateWrapper() this.cards.forEach((card, index) => { card.object.visible = true card.front.visible = false applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 }) setPose(card.object, this.stackPose(index)) }) this.setState('sealed') } private invalidateWrapper() { this.wrapperDirty = true this.wrapperStripExit = (this.camera.position.z + 4) * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect + 2 } private setOpeningProgress(value: number) { this.openingProgress = value this.invalidateWrapper() const smooth = THREE.MathUtils.smoothstep const settling = smooth(value, 0.87, 1) const framing = smooth(value, 0.52, 0.76) * (1 - settling) this.cameraRetreat = 8 * framing this.cameraTargetY = 1.6 * framing this.updateCamera() this.cards.forEach((card, index) => { const target = this.stackPose(index) // All cards clear the mouth before the pouch moves aside. Depth spacing never changes. target.position.y += 4.4 * smooth(value, 0.62 + index * 0.018, 0.79 + index * 0.018) * (1 - settling) setPose(card.object, target) }) const aside = smooth(value, 0.83, 0.87) const halfViewWidth = this.camera.position.z * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect this.wrapper.root.position.x = -(halfViewWidth + 2) * aside this.wrapper.root.visible = value < 0.87 this.options.onChange() } private open() { const start = this.openingProgress this.begin('opening', [], 3200 * (1 - start), () => { this.wrapper.root.visible = false this.setState('stackReady') }, (value) => this.setOpeningProgress(THREE.MathUtils.lerp(start, 1, value))) } private setState(state: PackState) { this.state = state const revealPhase = state === 'revealing' || state === 'inspecting' || state === 'advancing' this.cards.forEach((card, index) => { card.object.visible = state !== 'complete' && index >= this.index card.front.visible = revealPhase && index === this.index }) if (state === 'inspecting' && !this.revealed.has(this.index)) { this.revealed.add(this.index) applyEdgeMaterialControls(this.cards[this.index].edge, { ...packContents[this.index], condition: 1 }) this.options.canvas.dispatchEvent(new CustomEvent('packreveal', { detail: { index: this.index + 1, ...packContents[this.index] }, })) } if (state === 'complete' && !this.completed) { this.completed = true this.options.canvas.dispatchEvent(new CustomEvent('packcomplete', { detail: { count: this.revealed.size } })) } this.options.onChange() } private begin( state: PackState, tracks: Track[], duration: number, finish: () => void, progress?: (value: number) => void, ) { this.paused = false this.motion = { tracks, duration: this.reducedMotion.matches ? 70 : duration, startedAt: performance.now(), elapsed: 0, finish, progress, } this.setState(state) } primary() { if (!this.active) return this.clearPointers() if (this.motion) { if (this.paused) { this.paused = false this.motion.startedAt = performance.now() this.options.onChange() } return } const card = this.cards[this.index] switch (this.state) { case 'sealed': case 'opening': this.open() break case 'stackReady': { // This is the stack card itself, not a second lift actor or a cloned placeholder. const tracks = [track(card.object, pose(0, 0.14, inspectionDepth, 0, Math.PI))] for (let next = this.index + 1; next < this.cards.length; next++) { tracks.push(track(this.cards[next].object, this.reservePose(next - this.index - 1))) } const startRetreat = this.cameraRetreat this.begin('lifting', tracks, 620, () => this.setState('lifted'), (value) => { // Camera framing follows the lift; updateCamera remains the sole camera transform writer. this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, inspectionCameraRetreat, value) this.updateCamera() }) break } case 'lifted': this.begin('revealing', [track(card.object, pose(0, 0.14, inspectionDepth, -0.06, Math.PI * 2 - 0.12))], 820, () => this.setState('inspecting')) break case 'inspecting': { const halfViewWidth = (this.camera.position.z - inspectionDepth) * Math.tan(THREE.MathUtils.degToRad(this.camera.fov / 2)) * this.camera.aspect const exitX = -Math.max(4.8, halfViewWidth + cardSweepRadius + 0.3) // Stay on the safe plane even when advancing from an arbitrary manually rotated pose. const tracks = [track(card.object, pose(exitX, 0.3, inspectionDepth, 0.1, -0.5, -0.18, 0.85))] for (let next = this.index + 1; next < this.cards.length; next++) { tracks.push(track(this.cards[next].object, this.stackPose(next - this.index - 1))) } const startRetreat = this.cameraRetreat this.begin('advancing', tracks, 650, () => { if (this.index === this.cards.length - 1) { this.setState('complete') } else { this.index++ this.setState('stackReady') } }, (value) => { this.cameraRetreat = THREE.MathUtils.lerp(startRetreat, 0, value) this.updateCamera() }) break } default: break } } skip() { if (!this.active) return this.clearPointers() if (!this.motion) this.primary() if (this.motion) this.finishMotion() } private finishMotion() { const motion = this.motion if (!motion) return for (const item of motion.tracks) setPose(item.object, item.to) motion.progress?.(1) this.motion = undefined this.paused = false motion.finish() } tick(now: number) { this.updateMotion(now) if (this.active && this.wrapperDirty && this.wrapper.root.visible) { this.wrapper.deform(this.tearProgress, THREE.MathUtils.smoothstep(this.openingProgress, 0.38, 0.52), THREE.MathUtils.smoothstep(this.openingProgress, 0.5, 0.62), this.touch, this.wrapperStripExit) this.wrapperDirty = false } } private updateMotion(now: number) { const delta = Math.min(0.05, Math.max(0, (now - (this.lastTick ?? now)) / 1000)) this.lastTick = now if (!this.active) return if (Math.abs(this.touch.z - this.touchTarget) > 0.001) { this.touch.z = this.reducedMotion.matches ? this.touchTarget : THREE.MathUtils.lerp(this.touch.z, this.touchTarget, 1 - Math.exp(-delta * 18)) this.invalidateWrapper() } const motion = this.motion if (!motion || this.paused) return const progress = THREE.MathUtils.clamp((motion.elapsed + now - motion.startedAt) / motion.duration, 0, 1) if (progress >= 1) { this.finishMotion() return } const eased = progress * progress * (3 - 2 * progress) for (const { object, from, to } of motion.tracks) { object.position.lerpVectors(from.position, to.position, eased) object.quaternion.slerpQuaternions(from.quaternion, to.quaternion, eased) object.scale.lerpVectors(from.scale, to.scale, eased) } motion.progress?.(eased) } private pause(now: number) { const interrupted = this.motion !== undefined this.updateMotion(now) if (this.motion && !this.paused) { this.motion.elapsed += now - this.motion.startedAt this.paused = true this.options.onChange() } return interrupted } pointerDown(event: PointerEvent) { if (!this.active || event.button !== 0) return const interrupted = this.pause(performance.now()) this.pointers.set(event.pointerId, new THREE.Vector2(event.clientX, event.clientY)) this.options.canvas.setPointerCapture(event.pointerId) this.tap = this.pointers.size === 1 && !interrupted ? { id: event.pointerId, x: event.clientX, y: event.clientY, at: performance.now() } : undefined this.pinchDistance = this.getPinchDistance() if (this.pointers.size > 1) { this.endSeamDrag() this.touchTarget = 0 return } if (this.state === 'sealed' || (this.state === 'opening' && this.openingProgress < 0.38)) { const local = this.wrapperPoint(event.clientX, event.clientY) if (local && Math.abs(local.x) < 1.8 && local.y > -2.24 && local.y < 2.5) { this.touch.set(local.x, Math.min(local.y, 1.7), this.touch.z) this.touchTarget = 1 } const seam = this.seamScreenBounds() if (event.clientX >= seam.left - 18 && event.clientX <= seam.right + 18 && Math.abs(event.clientY - seam.y) <= Math.max(22, seam.height)) { this.motion = undefined this.seamDrag = { id: event.pointerId, x: event.clientX, progress: this.openingProgress, travel: Math.max(80, (seam.right - seam.left) * 0.75) } this.paused = false } } } pointerMove(event: PointerEvent) { const pointer = this.pointers.get(event.pointerId) if (!pointer) return const dx = event.clientX - pointer.x const dy = event.clientY - pointer.y pointer.set(event.clientX, event.clientY) if (this.tap && Math.hypot(event.clientX - this.tap.x, event.clientY - this.tap.y) > 6) { this.tap = undefined } if (this.pointers.size >= 2) { const distance = this.getPinchDistance() if (distance && this.pinchDistance) this.setZoom(this.zoom * this.pinchDistance / distance) this.pinchDistance = distance } else if (this.seamDrag?.id === event.pointerId) { const drag = this.seamDrag const progress = THREE.MathUtils.clamp(drag.progress + Math.max(0, event.clientX - drag.x) / drag.travel * 0.38, this.openingProgress, 0.38) if (progress > this.openingProgress && !this.tap) { if (this.state === 'sealed') this.setState('opening') this.setOpeningProgress(progress) } } else if (this.state === 'inspecting') { const object = this.cards[this.index].object object.rotation.y += dx * 0.008 object.rotation.x = THREE.MathUtils.clamp(object.rotation.x + dy * 0.008, -1.25, 1.25) } else if (this.touchTarget) { const local = this.wrapperPoint(event.clientX, event.clientY) if (local) { this.touch.x = THREE.MathUtils.clamp(local.x, -1.68, 1.68) this.touch.y = THREE.MathUtils.clamp(local.y, -2.24, 1.7) this.invalidateWrapper() } } } pointerUp(event: PointerEvent, cancelled = false) { if (!this.pointers.has(event.pointerId)) return const tearing = this.seamDrag?.id === event.pointerId if (tearing) this.endSeamDrag() this.touchTarget = 0 const tap = this.tap this.tap = undefined this.pointers.delete(event.pointerId) this.pinchDistance = this.getPinchDistance() if (this.options.canvas.hasPointerCapture(event.pointerId)) { this.options.canvas.releasePointerCapture(event.pointerId) } if (tearing && !cancelled && this.tearProgress >= 1) { this.primary() return } if (!cancelled && tap?.id === event.pointerId && performance.now() - tap.at < 600 && Math.hypot(event.clientX - tap.x, event.clientY - tap.y) <= 6) this.primary() } wheel(event: WheelEvent) { event.preventDefault() this.pause(performance.now()) this.clearPointers() const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 240 : 1) this.setZoom(this.zoom * Math.exp(pixels * 0.001)) } private setZoom(zoom: number) { this.zoom = THREE.MathUtils.clamp(zoom, 0.62, 1.45) this.updateCamera() } private getPinchDistance() { const pointers = [...this.pointers.values()] return pointers.length < 2 ? undefined : pointers[0].distanceTo(pointers[1]) } private clearPointers() { this.endSeamDrag() this.touchTarget = 0 this.tap = undefined const ids = [...this.pointers.keys()] this.pointers.clear() for (const id of ids) { if (this.options.canvas.hasPointerCapture(id)) this.options.canvas.releasePointerCapture(id) } this.pinchDistance = undefined } private endSeamDrag() { if (!this.seamDrag) return this.seamDrag = undefined if (this.state === 'opening' && !this.motion) this.paused = true this.options.onChange() } private seamScreenBounds() { const rect = this.options.canvas.getBoundingClientRect() this.root.updateMatrixWorld(true) this.camera.updateMatrixWorld(true) const screen = (x: number, y: number) => { const point = this.wrapper.root.localToWorld(new THREE.Vector3(x, y, -0.09)).project(this.camera) return { x: rect.left + (point.x + 1) * rect.width / 2, y: rect.top + (1 - point.y) * rect.height / 2 } } const left = screen(-1.68, 2.12) const right = screen(1.68, 2.12) return { left: left.x, right: right.x, y: left.y, height: Math.abs(screen(0, 1.97).y - screen(0, 2.28).y) } } private wrapperPoint(x: number, y: number) { const rect = this.options.canvas.getBoundingClientRect() this.camera.updateMatrixWorld(true) this.root.updateMatrixWorld(true) const ray = new THREE.Raycaster() ray.setFromCamera(new THREE.Vector2((x - rect.left) / rect.width * 2 - 1, 1 - (y - rect.top) / rect.height * 2), this.camera) const point = ray.ray.intersectPlane(new THREE.Plane(new THREE.Vector3(0, 0, 1), -0.28), new THREE.Vector3()) return point ? this.wrapper.root.worldToLocal(point) : undefined } }