camera.js 6.75 KB
// 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<string, (t:number) => 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,
    }
  }
}