// camera.js —— createCamera:五指令补间 + 阻尼跟随 + 抖动 → getRenderState。 // 移植自原型 camera.js 的纯数学,**剥离 Pixi 容器写入**。 // // 相机状态 {x,y,zoom} 看向世界点 (x,y) 并缩放 zoom。本包只**算出**相机状态与抖动偏移, // 通过 getRenderState() 交给 Renderer.applyCamera 落地——core 不碰 worldContainer, // 这样换 Skia / 换分辨率时镜头数学一行不动(架构 §6.8)。 // // 指令 CameraCommand:cut / follow / pan / zoom / shake(见 types.js)。 /** @type {Record number>} */ const EASINGS = { linear: (t) => t, easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), } /** @param {string=} name @returns {(t:number) => number} */ function easingByName(name) { return (name && EASINGS[name]) || EASINGS.easeInOutCubic } /** * 镜头工厂。 * @param {{getViewport: () => {w:number,h:number}, random?: () => number}} deps * getViewport 给视口宽高;random 可注入(默认 Math.random),测试注入确定性序列以测 shake。 * @returns {Camera} */ export function createCamera({ getViewport, random = Math.random }) { return new Camera(getViewport, random) } export class Camera { /** * @param {() => {w:number,h:number}} getViewport * @param {() => number} random */ constructor(getViewport, random) { this.getViewport = getViewport this.random = random /** @type {{x:number,y:number,zoom:number}} 当前相机状态(只读取用)。 */ this.cam = { x: 0, y: 0, zoom: 1 } /** @type {(string|null)} 当前跟随的演员别名(焦点由 applyCommand 时的 lookup 提供)。 */ this.followAlias = null this.followDamping = 0.08 // 0..1,越大越紧跟 /** @type {(string|null)} */ this._focusLookupAlias = null /** @type {(((alias:string) => ({x:number,y:number}|null))|null)} */ this._focusLookup = null /** @type {({from:{x:number,y:number,zoom:number},to:{x:number,y:number,zoom:number},dur:number,t:number,easing:(t:number)=>number}|null)} */ this.tween = null this.shakeT = 0 this.shakeDur = 0 this.shakeMag = 0 this._shakeX = 0 this._shakeY = 0 } /** @param {{x?:number,y?:number,zoom?:number}=} p */ _cut({ x, y, zoom } = {}) { this.tween = null if (x != null) this.cam.x = x if (y != null) this.cam.y = y if (zoom != null) this.cam.zoom = zoom } /** * @param {{x?:number,y?:number,zoom?:number}} target * @param {number} duration * @param {string=} easing */ _tweenTo(target, duration, easing) { const from = { ...this.cam } const to = { ...this.cam, ...target } this.tween = { from, to, dur: Math.max(duration, 1), t: 0, easing: easingByName(easing), } } /** @param {number=} magnitude @param {number=} duration */ _shake(magnitude = 14, duration = 350) { this.shakeMag = magnitude this.shakeDur = duration this.shakeT = 0 } /** * 执行一条镜头指令。 * @param {import('./types.js').CameraCommand} cmd * @param {((alias:string) => ({x:number,y:number}|null))=} actorFocusLookup * 别名 → 演员焦点世界坐标;follow / cut(follow) 用。 */ applyCommand(cmd, actorFocusLookup) { if (!cmd) return const lookup = actorFocusLookup || (() => null) const dur = cmd.duration ?? 0 switch (cmd.type) { case 'cut': { if (cmd.follow) { this.followAlias = cmd.follow this._focusLookup = lookup const f = lookup(cmd.follow) this._cut({ x: f?.x, y: f?.y, zoom: cmd.zoom }) } else { this.followAlias = null this._focusLookup = null this._cut({ x: cmd.target?.x, y: cmd.target?.y, zoom: cmd.zoom }) } break } case 'follow': { this.followAlias = cmd.follow ?? null this._focusLookup = lookup if (cmd.zoom != null) this._tweenTo({ zoom: cmd.zoom }, dur || 600, cmd.easing) break } case 'pan': { this.followAlias = null this._focusLookup = null /** @type {{x?:number,y?:number,zoom?:number}} */ const t = {} if (cmd.target?.x != null) t.x = cmd.target.x if (cmd.target?.y != null) t.y = cmd.target.y if (cmd.zoom != null) t.zoom = cmd.zoom this._tweenTo(t, dur || 800, cmd.easing) break } case 'zoom': { this._tweenTo({ zoom: cmd.zoom ?? this.cam.zoom }, dur || 600, cmd.easing) break } case 'shake': this._shake(cmd.magnitude ?? 14, dur || 350) break } } /** @param {{x:number,y:number}=} centerWorld */ reset(centerWorld) { this.followAlias = null this._focusLookup = null this.tween = null this.shakeMag = 0 this.shakeDur = 0 this.shakeT = 0 this._shakeX = 0 this._shakeY = 0 if (centerWorld) this._cut({ ...centerWorld, zoom: 1 }) } /** * 推进补间 / 阻尼跟随 / 抖动,更新 cam 与抖动偏移。 * @param {number} dtMs */ update(dtMs) { // 跟随:tween 不活动时才接管 x/y,每帧把焦点 lerp 向目标(帧率无关阻尼) if (this.followAlias && this._focusLookup && !this.tween) { const f = this._focusLookup(this.followAlias) if (f) { const k = 1 - Math.pow(1 - this.followDamping, dtMs / 16.67) this.cam.x += (f.x - this.cam.x) * k this.cam.y += (f.y - this.cam.y) * k } } // 补间 if (this.tween) { this.tween.t += dtMs const f = Math.min(this.tween.t / this.tween.dur, 1) const e = this.tween.easing(f) this.cam.x = this.tween.from.x + (this.tween.to.x - this.tween.from.x) * e this.cam.y = this.tween.from.y + (this.tween.to.y - this.tween.from.y) * e this.cam.zoom = this.tween.from.zoom + (this.tween.to.zoom - this.tween.from.zoom) * e if (f >= 1) this.tween = null } // 抖动(随时间衰减;duration 后归零) this._shakeX = 0 this._shakeY = 0 if (this.shakeT < this.shakeDur) { this.shakeT += dtMs const decay = Math.max(0, 1 - this.shakeT / this.shakeDur) this._shakeX = (this.random() * 2 - 1) * this.shakeMag * decay this._shakeY = (this.random() * 2 - 1) * this.shakeMag * decay } } /** * 产出交给 Renderer.applyCamera 的纯状态。core 不写任何容器。 * @returns {import('./types.js').CameraRenderState} */ getRenderState() { const vp = this.getViewport() return { x: this.cam.x, y: this.cam.y, zoom: this.cam.zoom, shakeX: this._shakeX, shakeY: this._shakeY, viewW: vp.w, viewH: vp.h, } } }