# main.lib3d.rb — Three.js を包む薄い 3D ゲームライブラリ
#
# 使い方:（2D 版 main.lib.rb と同じ流儀）
#   window = Window.new
#   world  = World.new(window)
#   window.world = world
#   world.push_scene(PlayScene)
#   window.run
#
# 規約:
#   - 座標系は three.js ネイティブ（Y 上向き・右手系・単位はモデルスケール）
#   - 向きはヘディング rotation_y（度）。glTF モデルは +Z 向きが正面
#   - 色は 0xRRGGBB の整数で受ける（2D 版の [r,g,b] とは異なる）
#   - 毎フレーム draw を呼んだものだけが表示される
#   - vanish は印付けのみ。実除去はフレームの決まった一点でまとめて行われる
#   - ゲームロジックは per-frame 値、AnimationMixer だけ実時間 dt（秒）で進む

# 3D モデルアセット。複数の ModelActor から共有して使われる想定
class Model
  attr_reader :clips

  def self.load(path) = new.load_gltf(path)

  def initialize
    @template = nil
    @clips = []
    @loaded = false
  end

  def loaded? = @loaded

  def load_gltf(path)
    js_gltf_load(js_gltf_loader, path) do |gltf|
      @template = js_gltf_scene(gltf)
      animations = js_gltf_animations(gltf)
      @clips = Array.new(js_array_length(animations)) { |index| js_array_at(animations, index) }
      @loaded = true
    end
    self
  end

  # skinned mesh は素の clone が効かないため SkeletonUtils 経由で複製する。
  # geometry / material は複製間で参照共有のまま（解放はアセット単位 = dispose）
  def instantiate = js_skeleton_clone(@template)

  # アセット単位の解放。このモデルを使う Actor が全て退場してから呼ぶ
  def dispose
    return unless @template
    js_node_traverse(@template) do |node|
      next unless js_mesh?(node)
      js_geometry_dispose(node[:geometry])
      materials = node[:material]
      if js_array?(materials)
        js_array_length(materials).times { |index| dispose_material(js_array_at(materials, index)) }
      else
        dispose_material(materials)
      end
    end
    @template = nil
    @clips = []
    @loaded = false
  end

  private

  # スロット名を決め打ちせず、マテリアルが握る isTexture なプロパティを全て解放する
  # （KHR 拡張で増えるスロットも漏らさない）
  def dispose_material(material)
    textures = js_material_textures(material)
    js_array_length(textures).times { |index| js_texture_dispose(js_array_at(textures, index)) }
    js_material_dispose(material)
  end
end

# Scene が所有する描画体の基底。THREE ノード 1 つを抱える
class Actor
  def self.update(*lists) = lists.flatten.each { |actor| actor.update unless actor.vanished? }
  def self.draw(*lists)   = lists.flatten.each { |actor| actor.draw unless actor.vanished? }
  def self.clean(*lists)  = lists.each { |list| list.reject!(&:vanished?) }

  attr_reader :scene
  attr_accessor :x, :y, :z, :rotation_y, :scale, :visible

  def initialize(scene)
    @scene = scene
    @window = scene.window
    @x = 0.0
    @y = 0.0
    @z = 0.0
    @rotation_y = 0.0
    @scale = 1.0
    @visible = true
    @vanished = false
    @node = nil
    @node_position = nil
    @node_rotation = nil
    @node_scale = nil
  end

  # 生成パラメータの受け口。サブクラスで上書きする
  def setup(**); end

  def update; end
  def draw; end

  # 実時間の進行（AnimationMixer 等）。World#step が update を通したシーンにだけ流す
  def tick(dt); end

  def input = @scene.input

  # 実除去と資源解放は Scene#sweep
  def vanish
    @vanished = true
    hide
  end

  def vanished? = @vanished

  def hide
    @node[:visible] = false if @node
  end

  # 個体所有の資源はサブクラスの dispose_resources が解放する。
  # アセット側の共有資源（Model の geometry / material）には触れない
  def dispose
    return unless @node
    dispose_resources
    js_scene_remove(@window.stage, @node)
    @node = nil
    @node_position = nil
    @node_rotation = nil
    @node_scale = nil
  end

  private

  # サブクラスは THREE ノードを生成して返す
  def build_node = raise(NotImplementedError, "#{self.class}#build_node")

  # 個体所有の GPU 資源の明示解放。サブクラスで上書きする
  def dispose_resources; end

  def ensure_node
    return if @node
    @node = build_node
    @node_position = @node[:position]
    @node_rotation = @node[:rotation]
    @node_scale = @node[:scale]
    js_scene_add(@window.stage, @node)
  end

  def place
    @node_position.set(@x, @y, @z)
    @node_rotation[:y] = @rotation_y * Math::PI / 180.0
    @node_scale.set(@scale, @scale, @scale)
    @node[:visible] = true
  end
end

# アニメ再生機。専用の AnimationMixer とクリップ名→アクション表を持ち、
# play（ループへクロスフェード）と emote（1 回再生→直前ループへ自動復帰）の
# 2 動詞を提供する。任意の Object3D ツリーに対して単体でも使える
class Animator
  attr_reader :state

  def initialize(root, clips)
    @mixer = js_three_animation_mixer(root)
    @actions = {}
    clips.each { |clip| @actions[js_clip_name(clip)] = js_mixer_clip_action(@mixer, clip) }
    @state = nil      # 復帰先のループアニメ名
    @speed = 1.0
    @current = nil    # 実際に再生中のクリップ名
    @emoting = false
    # emote（LoopOnce）の完了で state へ復帰。リスナーは生成時に 1 回だけ登録する
    js_mixer_on_finished(@mixer) { |event| finish_emote(js_event_action(event)) }
  end

  # ループアニメへクロスフェード。同名なら速度だけ更新するので毎フレーム呼んでよい
  def play(name, fade: 0.3, speed: 1.0)
    @state = name
    @speed = speed
    return if @emoting
    if @current == name
      action = @actions[name]
      js_action_set_time_scale(action, speed) if action
    else
      crossfade(name, fade, speed)
    end
  end

  # 1 回再生して直前のループアニメへ戻る。
  # 仕込んだ LoopOnce はアクションに残るため、play 用とはクリップを分けること
  def emote(name, fade: 0.2)
    action = @actions[name]
    return js_console_error("Animator#emote: 未知のクリップ #{name}") unless action
    js_action_loop_once(action)
    @emoting = true
    crossfade(name, fade, 1.0)
  end

  def tick(dt)
    js_mixer_update(@mixer, dt) if @mixer
  end

  def dispose
    js_mixer_stop_all(@mixer) if @mixer
    @mixer = nil
    @actions = {}
  end

  private

  # フェードアウト済みの旧 emote も finished を発火するため、現行アクションだけ拾う
  def finish_emote(action)
    return unless @emoting
    current_action = @actions[@current]
    return unless current_action && js_same?(action, current_action)
    @emoting = false
    crossfade(@state, 0.2, @speed) if @state
  end

  def crossfade(name, fade, speed)
    action = @actions[name]
    return js_console_error("Animator: 未知のクリップ #{name}") unless action
    previous_action = @actions[@current]
    js_action_fade_out(previous_action, fade) if previous_action && !previous_action.equal?(action)
    js_action_reset(action)
    js_action_set_time_scale(action, speed)
    js_action_set_weight(action, 1)
    js_action_fade_in(action, fade)
    js_action_play(action)
    @current = name
  end
end

# モーショングラフ再生機。状態（クリップ / 1D ブレンドツリー / once）と遷移を
# データとして宣言しておき、ゲームコードは set（パラメータ）と trigger（イベント）を
# 書くだけ。どの状態に居るべきか・どうブレンドするかはグラフが毎フレーム判断する。
# Animator（2 動詞・命令駆動）の差し替え先で、ModelActor へは graph: で渡す。
#
# グラフ宣言の形:
#   {
#     initial: :idle,                       # 省略時は最初の状態
#     params:  { speed: 0.0 },              # パラメータ初期値
#     states: {
#       idle: { clip: "Idle" },
#       move: { blend: { 0.0 => "Walk", 1.0 => "Run" },  # しきい値 => クリップ
#               param: :speed,              # ブレンド位置を読むパラメータ
#               speed: :direction,          # 再生速度（Symbol / Proc / 数値、省略 1.0）
#               sync:  ["Walk", "Run"] },   # 正規化位相を揃えるクリップ群
#       jump: { clip: "Jump", once: true, trigger: :jump, fade: 0.2,
#               lock: true },               # 退出ゲート: true / 秒 / Proc（下記）
#     },
#     transitions: [
#       { from: :idle, to: :move, if: ->(params) { params[:speed] > 0 }, fade: 0.3 },
#     ],
#   }
#
# 意味論:
#   - 全 action を play したまま、重みだけを毎フレーム自前で補間する。フェード途中に
#     さらに遷移しても今の混ざり具合から続けられ、重み合計 1（素のポーズの混入なし）を保つ
#   - 遷移は現在状態の出辺（from: :any 含む）だけを評価。トリガー遷移が条件遷移より
#     優先で、各々宣言順・最初の成立 1 本のみ採用（1 フレーム 1 遷移）
#   - trigger は 1 フレーム限り。遷移に使われなければフレーム末で破棄される
#   - once 状態はフェードがクリップ末尾と重なるよう、実時間の残りが fade 分を切ったら
#     復帰先へ自動退出する。once 中は条件遷移を受け付けず、トリガー遷移だけが割り込める
#   - once 状態の trigger: 宣言は { from: :any, to: 自分, trigger:, return: true } の略記
#   - 再入時、once は常に頭から。ループ状態は重みが残っていれば続きから（跳ね防止）
#   - lock: は状態側の退出ゲート。真の間はトリガー / 条件遷移とも遮断する。
#     once の自動復帰は「完了」なので遮断しない。
#       true … 完了までキャンセル不可（ループ状態には書けない）
#       数値 … 進入からの実時間 n 秒はキャンセル不可
#       Proc … 真を返す間キャンセル不可
#   - if: / lock: / speed: の callable（lambda / proc / Method）は arity で呼び分ける。
#       -> { }                … 引数なし
#       ->(params) { }        … パラメータ表
#       ->(params, state) { } … パラメータ表と現在状態ビュー
#                               （state: name / elapsed / remaining / phase）
#     speed: のみ 0 / 1 引数
#   - グラフはインスタンスメソッドの中で組んでよい。Proc が self（ゲームオブジェクト）を
#     閉じ込めて直接読める。条件は 1 tick に複数回評価されうるため読み取り専用に保つこと
class GraphAnimator
  ANY = :any
  DEFAULT_FADE = 0.3
  ONCE_FADE = 0.2
  EPSILON = 1e-3

  # if: / lock: の Proc に渡す現在状態の読み取りビュー
  StateView = Data.define(:name, :elapsed, :remaining, :phase)

  attr_reader :state

  def initialize(root, clips, graph)
    @mixer = js_three_animation_mixer(root)
    @clips = {}
    clips.each { |clip| @clips[js_clip_name(clip)] = clip }
    @params = (graph[:params] || {}).dup
    @damping = {}  # name => [目標値, 時定数]
    @triggers = []
    build(graph)
    @state = graph[:initial] || @states.keys.first
    raise ArgumentError, "GraphAnimator: initial 状態 #{@state} が states にない" unless @states[@state]
    @return_to = @states[@state][:once] ? @states.keys.find { |name| !@states[name][:once] } : @state
    @states[@state][:weight] = 1.0
    @states[@state][:target] = 1.0
    # 構築フレームの描画でレストポーズが 1 フレーム見えないよう、初期姿勢を焼き込む
    apply_weights
    js_mixer_update(@mixer, 0.0)
  end

  # パラメータ書き込み。damp: 秒を渡すと目標値へ指数減衰で近づく。
  # if: ラムダ内だけで使う名前も params: に初期値を宣言しておくこと（未宣言はタイポとして警告）
  def set(name, value, damp: nil)
    warn_once("GraphAnimator#set: 未知のパラメータ #{name}（params: に宣言を）") unless @known_params.include?(name)
    if damp&.positive? && value.is_a?(Numeric) && @params[name].is_a?(Numeric)
      @damping[name] = [value.to_f, damp.to_f]
    else
      @damping.delete(name)
      @params[name] = value
    end
  end

  def [](name) = @params[name]

  # 1 フレーム限りのイベント。次の tick の遷移評価で消費される
  def trigger(name)
    warn_once("GraphAnimator#trigger: 未知のトリガー #{name}") unless @known_triggers.include?(name)
    @triggers << name
  end

  def tick(dt)
    return unless @mixer
    damp_params(dt)
    @states[@state][:age] += dt
    evaluate
    @triggers.clear
    advance_weights(dt)
    apply_weights
    js_mixer_update(@mixer, dt)
  end

  def dispose
    js_mixer_stop_all(@mixer) if @mixer
    @mixer = nil
    @states = {}
  end

  private

  # === グラフ構築 ===

  def build(graph)
    @states = {}
    used_clips = {}
    (graph[:states] || {}).each do |name, spec|
      if spec[:once] && spec[:speed].is_a?(Numeric) && spec[:speed] <= 0
        raise ArgumentError, "GraphAnimator: once 状態 #{name} の speed: は正の数（自動復帰できない）"
      end
      if spec[:lock] == true && !spec[:once]
        raise ArgumentError, "GraphAnimator: ループ状態 #{name} に lock: true は書けない（永久に出られない）"
      end
      unless spec[:lock].nil? || spec[:lock] == true || spec[:lock] == false ||
             spec[:lock].is_a?(Numeric) || spec[:lock].respond_to?(:call)
        raise ArgumentError, "GraphAnimator: #{name} の lock: は true / 秒 / Proc（#{spec[:lock].class} は不可）"
      end
      if spec[:speed].respond_to?(:call) && !(0..1).cover?(spec[:speed].arity)
        raise ArgumentError, "GraphAnimator: #{name} の speed: の Proc は引数 0 か 1（状態ビューは受けられない）"
      end
      @states[name] = {
        name: name,
        once: spec[:once] ? true : false,
        fade: spec[:fade] || ONCE_FADE,
        speed: spec[:speed],
        param: spec[:param],
        lock: spec[:lock],
        members: build_members(name, spec, used_clips),
        weight: 0.0,
        target: 0.0,
        rate: nil,
        age: 0.0, # 進入からの実時間秒。lock: の数値窓の判定に使う
      }
    end
    raise ArgumentError, "GraphAnimator: states が空" if @states.empty?
    if @states.each_value.all? { |state| state[:once] }
      raise ArgumentError, "GraphAnimator: once でない状態が 1 つもない（自動復帰先が決まらない）"
    end
    @transitions = (graph[:transitions] || []).map { |spec| compile_transition(spec) }
    # once 状態の trigger: 宣言は :any からのトリガー遷移の略記
    (graph[:states] || {}).each do |name, spec|
      next unless spec[:trigger]
      @transitions << { from: ANY, to: name, trigger: spec[:trigger],
                        fade: spec[:fade] || ONCE_FADE, return: true }
    end
    # set / trigger のタイポ検出用（実行時は警告のみで処理は続行する）
    @known_triggers = @transitions.filter_map { |transition| transition[:trigger] }
    @known_params = (graph[:params] || {}).keys
    @states.each_value do |state|
      @known_params << state[:param] if state[:param]
      @known_params << state[:speed] if state[:speed].is_a?(Symbol)
    end
    @warned = {}
  end

  def build_members(state_name, spec, used_clips)
    entries =
      if spec[:blend]
        raise ArgumentError, "GraphAnimator: #{state_name} の blend に param: がない" unless spec[:param]
        spec[:blend].sort_by { |threshold, _| threshold.to_f }.map { |threshold, clip| [clip, threshold.to_f] }
      elsif spec[:clip]
        [[spec[:clip], nil]]
      else
        raise ArgumentError, "GraphAnimator: #{state_name} に clip: も blend: もない"
      end
    sync = spec[:sync] || []
    entries.map do |clip_name, threshold|
      clip = @clips[clip_name]
      raise ArgumentError, "GraphAnimator: 未知のクリップ #{clip_name}（#{state_name}）" unless clip
      # 同じクリップを複数の状態が使う場合は複製する（clipAction は clip+root でメモ化され、
      # LoopOnce 等の設定が共有されてしまうため）
      clip = js_clip_clone(clip) if used_clips[clip_name]
      used_clips[clip_name] = true
      action = js_mixer_clip_action(@mixer, clip)
      js_action_loop_once(action) if spec[:once]
      js_action_set_weight(action, 0)
      js_action_play(action)
      { name: clip_name, action: action, duration: js_clip_duration(clip),
        threshold: threshold, sync: sync.include?(clip_name) }
    end
  end

  def compile_transition(spec)
    [spec[:from], spec[:to]].each do |state_name|
      next if state_name == ANY
      raise ArgumentError, "GraphAnimator: 遷移の状態 #{state_name} が states にない" unless @states[state_name]
    end
    raise ArgumentError, "GraphAnimator: 遷移 #{spec[:from]}→#{spec[:to]} に if: も trigger: もない" unless spec[:if] || spec[:trigger]
    { from: spec[:from], to: spec[:to], cond: spec[:if], trigger: spec[:trigger],
      fade: spec[:fade] || DEFAULT_FADE, return: spec[:return] }
  end

  # 同じ警告はインスタンスごとに 1 回だけ（毎フレーム呼ばれる API のため）
  def warn_once(message)
    return if @warned[message]
    @warned[message] = true
    js_console_error(message)
  end

  # === 毎フレーム評価 ===

  def damp_params(dt)
    done = []
    @damping.each do |name, (target_value, smoothing)|
      current = @params[name].to_f
      current += (target_value - current) * (1.0 - Math.exp(-dt / smoothing))
      if (target_value - current).abs < 1e-4
        current = target_value
        done << name
      end
      @params[name] = current
    end
    done.each { |name| @damping.delete(name) }
  end

  def evaluate
    current = @states[@state]
    locked = locked?(current) # lock 中はトリガー / 条件遷移とも不可。完了（自動復帰）だけは通す
    if !locked && (transition = pick_transition(trigger_pass: true))
      enter(transition[:to], transition[:fade], remember_return: transition[:return])
    elsif current[:once]
      # once 中は条件遷移を受けない。フェードがクリップ末尾と重なるよう、
      # 実時間の残りがフェード分を切ったら復帰先へ自動退出（speed 0 以下では自動復帰しない）
      speed = state_speed(current).abs
      enter(resolve(@return_to), current[:fade]) if speed > EPSILON && remaining(current) / speed <= current[:fade]
    elsif !locked && (transition = pick_transition(trigger_pass: false))
      enter(transition[:to], transition[:fade], remember_return: transition[:return])
    end
  end

  # 状態側の退出ゲート。窓内に来たトリガーは捨てられる（バッファしない）
  def locked?(state)
    case state[:lock]
    when nil, false then false
    when true       then true
    when Numeric    then state[:age] < state[:lock]
    else invoke(state[:lock], state) ? true : false
    end
  end

  def state_view(state)
    # ブレンド状態は重み最大のメンバー基準で remaining / phase を出す
    members = state[:members]
    member =
      if members.size == 1
        members.first
      else
        members.each_with_index.max_by { |_, index| member_weights(state)[index] }.first
      end
    speed = state_speed(state).abs
    time = js_action_time(member[:action])
    StateView.new(name: state[:name], elapsed: state[:age],
                  remaining: speed > EPSILON ? (member[:duration] - time) / speed : Float::INFINITY,
                  phase: (time / member[:duration]) % 1.0)
  end

  # トリガー遷移（trigger_pass: true）と条件遷移を分けて、各々宣言順で最初の成立を返す。
  # trigger: と if: の併記は AND（トリガーが発火し、かつ条件が真のときだけ成立）
  def pick_transition(trigger_pass:)
    current = @states[@state]
    @transitions.each do |transition|
      next unless transition[:from] == @state || transition[:from] == ANY
      if trigger_pass
        return transition if transition[:trigger] && @triggers.include?(transition[:trigger]) &&
                             (transition[:cond].nil? || invoke(transition[:cond], current))
      else
        next if transition[:trigger] || transition[:to] == @state # 条件による自己遷移は常時リセットになるため無視
        return transition if invoke(transition[:cond], current)
      end
    end
    nil
  end

  # 復帰先から条件遷移を辿って今のパラメータに合う状態まで先回りする（二重フェード防止）
  def resolve(name)
    4.times do
      transition = @transitions.find do |candidate|
        !candidate[:trigger] && candidate[:from] == name && candidate[:cond] && invoke(candidate[:cond], @states[name])
      end
      break unless transition && !@states[transition[:to]][:once] && transition[:to] != name
      name = transition[:to]
    end
    name
  end

  # callable を arity で呼び分ける（0: 引数なし、1: パラメータ表、それ以外: パラメータ表と状態ビュー）。
  # 状態ビューは必要なときだけ作る
  def invoke(callable, state)
    case callable.arity
    when 0 then callable.call
    when 1 then callable.call(@params)
    else callable.call(@params, state_view(state))
    end
  end

  def enter(to_name, fade, remember_return: false)
    to = @states[to_name]
    @return_to = @state if (remember_return || to[:once]) && !@states[@state][:once]
    rate = fade.to_f.positive? ? 1.0 / fade : nil
    @states.each_value do |state|
      state[:target] = 0.0
      state[:rate] = rate
    end
    to[:target] = 1.0
    # once は常に頭から。ループ状態は重みが消えている時だけ頭から（残っていれば続きから）
    to[:members].each { |member| js_action_reset(member[:action]) } if to[:once] || to[:weight] < EPSILON
    to[:age] = 0.0
    @state = to_name
  end

  def remaining(state)
    member = state[:members].first
    member[:duration] - js_action_time(member[:action])
  end

  # === 重みの駆動 ===

  def advance_weights(dt)
    @states.each_value do |state|
      if state[:rate]
        step = state[:rate] * dt
        difference = state[:target] - state[:weight]
        state[:weight] =
          if difference.abs <= step
            state[:target]
          else
            state[:weight] + (difference.positive? ? step : -step)
          end
      else
        state[:weight] = state[:target]
      end
    end
  end

  def apply_weights
    sum = @states.each_value.sum { |state| state[:weight] }
    if sum <= 0
      @states[@state][:weight] = 1.0
      sum = 1.0
    end
    @states.each_value do |state|
      state_weight = state[:weight] / sum
      speed = state_speed(state)
      weights = member_weights(state)
      state[:members].each_with_index do |member, index|
        js_action_set_enabled(member[:action], true)
        js_action_set_time_scale(member[:action], speed)
        js_action_set_weight(member[:action], state_weight * weights[index])
      end
      sync_members(state, weights, speed)
    end
  end

  def state_speed(state)
    source = state[:speed]
    case source
    when Symbol  then (@params[source] || 1.0).to_f
    when Numeric then source.to_f
    else source.respond_to?(:call) ? invoke(source, state).to_f : 1.0
    end
  end

  # 1D ブレンドツリー: しきい値で挟む隣接 2 クリップの線形補間（範囲外は端に張り付く）
  def member_weights(state)
    members = state[:members]
    return [1.0] if members.size == 1
    position = (@params[state[:param]] || 0.0).to_f
    weights = Array.new(members.size, 0.0)
    if position <= members.first[:threshold]
      weights[0] = 1.0
    elsif position >= members.last[:threshold]
      weights[-1] = 1.0
    else
      upper = members.index { |member| member[:threshold] >= position }
      lower = upper - 1
      ratio = (position - members[lower][:threshold]) / (members[upper][:threshold] - members[lower][:threshold])
      weights[lower] = 1.0 - ratio
      weights[upper] = ratio
    end
    weights
  end

  # sync 指定クリップ群の正規化位相を揃える。重み最大のものが leader（足滑り防止）。
  # follower は timeScale を尺の比で補正して位相速度を一致させ、ずれが累積したら吸着する
  def sync_members(state, weights, speed)
    leader = nil
    leader_weight = 0.0
    state[:members].each_with_index do |member, index|
      next unless member[:sync]
      if weights[index] > leader_weight
        leader = member
        leader_weight = weights[index]
      end
    end
    return unless leader && leader_weight > EPSILON
    leader_phase = (js_action_time(leader[:action]) / leader[:duration]) % 1.0
    state[:members].each do |member|
      next if !member[:sync] || member.equal?(leader)
      js_action_set_time_scale(member[:action], speed * member[:duration] / leader[:duration])
      drift = ((js_action_time(member[:action]) / member[:duration]) % 1.0 - leader_phase).abs
      drift = 1.0 - drift if drift > 0.5
      js_action_set_time(member[:action], leader_phase * member[:duration]) if drift > 0.05
    end
  end
end

# GLTF モデルの描画体。複製ツリーとアニメ再生機を持つ。
# graph: を渡すと GraphAnimator（set / trigger）、渡さなければ Animator（play / emote）。
# 使える動詞はモードで排他。各動詞は再生機への薄い委譲（直接 animator を触ってもよい）
class ModelActor < Actor
  attr_reader :animator

  def setup(model:, x: 0, y: 0, z: 0, rotation_y: 0, scale: 1.0, state: nil, graph: nil)
    raise ArgumentError, "ModelActor: state: と graph: は併用できない（初期状態は graph の initial: で）" if state && graph
    @model = model
    @x = x
    @y = y
    @z = z
    @rotation_y = rotation_y
    @scale = scale
    @animator = nil
    @graph = graph
    @pending = state ? [state, 1.0] : nil # build（初回 draw）前に指定された state
    @pending_sets = {}                    # build 前のパラメータ書き込み
    @pending_triggers = []
  end

  def play(name, fade: 0.3, speed: 1.0)
    return js_console_error("ModelActor#play: graph: 指定時は set / trigger を使う") if @graph
    return @pending = [name, speed] unless @animator
    @animator.play(name, fade: fade, speed: speed)
  end

  def emote(name, fade: 0.2)
    return js_console_error("ModelActor#emote: graph: 指定時は set / trigger を使う") if @graph
    @animator&.emote(name, fade: fade)
  end

  def set(name, value, damp: nil)
    return js_console_error("ModelActor#set: graph: 指定時のみ使える（play / emote を使う）") unless @graph
    return @pending_sets[name] = [value, damp] unless @animator
    @animator.set(name, value, damp: damp)
  end

  def trigger(name)
    return js_console_error("ModelActor#trigger: graph: 指定時のみ使える（play / emote を使う）") unless @graph
    return @pending_triggers << name unless @animator
    @animator.trigger(name)
  end

  def tick(dt)
    @animator&.tick(dt)
  end

  def draw
    return if vanished? || !@visible || !@model.loaded?
    ensure_node
    place
  end

  private

  def build_node
    node = @model.instantiate
    if @graph
      @animator = GraphAnimator.new(node, @model.clips, @graph)
      @pending_sets.each { |name, (value, damp)| @animator.set(name, value, damp: damp) }
      @pending_sets = {}
      @pending_triggers.each { |name| @animator.trigger(name) }
      @pending_triggers = []
    else
      @animator = Animator.new(node, @model.clips)
      if @pending
        name, speed = @pending
        @animator.play(name, fade: 0.0, speed: speed)
        @pending = nil
      end
    end
    node
  end

  # 複製ツリーの skeleton（boneTexture）は個体生成物なので明示解放する。
  # geometry / material は Model と共有のため触れない
  def dispose_resources
    @animator&.dispose
    @animator = nil
    js_node_traverse(@node) do |child|
      js_skeleton_dispose(child) if js_skinned_mesh?(child)
    end
  end
end

# 単一ノードの静物。draw で置くだけ
class Prop < Actor
  def draw
    return if vanished? || !@visible
    ensure_node
    place
  end
end

# 地面。PlaneGeometry を X 軸 -90 度で寝かせた単色マット
class Floor < Prop
  def setup(size: 2000, color: 0xcbcbcb, y: 0)
    @size = size
    @color = color
    @y = y
  end

  private

  def build_node
    @geometry = js_three_plane_geometry(@size, @size)
    @material = js_three_mesh_phong_material
    js_color_set(@material, @color)
    @material[:depthWrite] = false
    node = js_three_mesh(@geometry, @material)
    node[:rotation][:x] = -Math::PI / 2
    node
  end

  # 自前生成した geometry / material は個体の寿命で解放する
  def dispose_resources
    js_geometry_dispose(@geometry)
    js_material_dispose(@material)
    @geometry = nil
    @material = nil
  end
end

# 床に重ねる目盛り線。動きの手掛かり用
class Grid < Prop
  def setup(size: 200, divisions: 40, color: 0x000000, opacity: 0.2, y: 0)
    @size = size
    @divisions = divisions
    @color = color
    @opacity = opacity
    @y = y
  end

  private

  def build_node
    node = js_three_grid_helper(@size, @divisions, @color, @color)
    material = node[:material]
    material[:opacity] = @opacity
    material[:transparent] = true
    node
  end

  # GridHelper は自前の geometry / material を dispose() で解放できる
  def dispose_resources = js_node_dispose(@node)
end

# 半球ライト。空色と地面色で全体を満たす
class HemisphereLight < Prop
  def setup(sky: 0xffffff, ground: 0x8d8d8d, intensity: 3.0, x: 0, y: 20, z: 0)
    @sky = sky
    @ground = ground
    @intensity = intensity
    @x = x
    @y = y
    @z = z
  end

  private

  def build_node = js_three_hemisphere_light(@sky, @ground, @intensity)

  def dispose_resources = js_node_dispose(@node)
end

# 平行光。位置から原点方向を照らす
class DirectionalLight < Prop
  def setup(color: 0xffffff, intensity: 3.0, x: 0, y: 20, z: 10)
    @color = color
    @intensity = intensity
    @x = x
    @y = y
    @z = z
  end

  private

  def build_node = js_three_directional_light(@color, @intensity)

  def dispose_resources = js_node_dispose(@node)
end

# キーボード / マウス入力（2D 版と同一）
class Input
  K_LEFT  = "ArrowLeft"; K_RIGHT = "ArrowRight"; K_UP = "ArrowUp"; K_DOWN = "ArrowDown"
  K_SPACE = "Space"; K_RETURN = "Enter"; K_ESCAPE = "Escape"
  K_A = "KeyA"; K_B = "KeyB"; K_C = "KeyC"; K_D = "KeyD"; K_E = "KeyE"; K_F = "KeyF"
  K_G = "KeyG"; K_H = "KeyH"; K_I = "KeyI"; K_J = "KeyJ"; K_K = "KeyK"; K_L = "KeyL"
  K_M = "KeyM"; K_N = "KeyN"; K_O = "KeyO"; K_P = "KeyP"; K_Q = "KeyQ"; K_R = "KeyR"
  K_S = "KeyS"; K_T = "KeyT"; K_U = "KeyU"; K_V = "KeyV"; K_W = "KeyW"; K_X = "KeyX"
  K_Y = "KeyY"; K_Z = "KeyZ"
  K_0 = "Digit0"; K_1 = "Digit1"; K_2 = "Digit2"; K_3 = "Digit3"; K_4 = "Digit4"
  K_5 = "Digit5"; K_6 = "Digit6"; K_7 = "Digit7"; K_8 = "Digit8"; K_9 = "Digit9"
  M_LBUTTON = 1; M_RBUTTON = 2; M_MBUTTON = 4

  def initialize(canvas, viewport)
    @canvas = canvas
    @viewport = viewport
    @key_live = {}
    @key_now = {}
    @key_previous = {}
    @mouse_live = {}
    @mouse_now = {}
    @mouse_previous = {}
    @mouse_x = 0
    @mouse_y = 0
    wire_keys
    wire_mouse
  end

  attr_reader :mouse_x, :mouse_y

  def advance
    @key_previous = @key_now
    @key_now = @key_live.dup
    @mouse_previous = @mouse_now
    @mouse_now = @mouse_live.dup
  end

  def key_down?(code)    = @key_now[code] == true
  def key_push?(code)    = @key_now[code] == true && @key_previous[code] != true
  def key_release?(code) = @key_now[code] != true && @key_previous[code] == true

  # 矢印キーの水平方向。-1 / 0 / +1
  def x
    result = 0
    result += 1 if key_down?(K_RIGHT)
    result -= 1 if key_down?(K_LEFT)
    result
  end

  # 矢印キーの垂直方向。-1 / 0 / +1
  def y
    result = 0
    result += 1 if key_down?(K_DOWN)
    result -= 1 if key_down?(K_UP)
    result
  end

  def mouse_down?(button = M_LBUTTON)    = @mouse_now[button] == true
  def mouse_push?(button = M_LBUTTON)    = @mouse_now[button] == true && @mouse_previous[button] != true
  def mouse_release?(button = M_LBUTTON) = @mouse_now[button] != true && @mouse_previous[button] == true

  private

  def wire_keys
    js_add_window_listener("keydown") do |event|
      code = js_event_code(event)
      @key_live[code] = true
      js_event_prevent_default(event) if game_key?(code)
    end
    js_add_window_listener("keyup") do |event|
      code = js_event_code(event)
      @key_live[code] = false
      js_event_prevent_default(event) if game_key?(code)
    end
  end

  def wire_mouse
    if js_element_missing?(@canvas)
      js_console_error("Input: canvas not found, mouse input disabled")
      return
    end
    js_add_element_listener(@canvas, "pointermove") { |event| track_mouse(event) }
    js_add_element_listener(@canvas, "pointerdown") do |event|
      track_mouse(event)
      @mouse_live[button_code(js_event_button(event))] = true
      js_event_prevent_default(event)
    end
    js_add_window_listener("pointerup") do |event|
      @mouse_live[button_code(js_event_button(event))] = false
    end
  end

  def track_mouse(event)
    left, top = js_element_left_top(@canvas)
    client_x, client_y = js_event_client_xy(event)
    @mouse_x = (client_x - left).clamp(0, @viewport.width - 1).to_i
    @mouse_y = (client_y - top).clamp(0, @viewport.height - 1).to_i
  end

  def button_code(button)
    case button
    when 0 then M_LBUTTON
    when 1 then M_MBUTTON
    when 2 then M_RBUTTON
    else 0
    end
  end

  # ブラウザの既定動作を抑止するキー
  def game_key?(code) = [K_LEFT, K_RIGHT, K_UP, K_DOWN, K_SPACE].include?(code)
end

# シーンの基底。描画体の一覧を所有する
class Scene
  attr_reader :world

  def initialize(world)
    @world = world
    @actors = []
  end

  # 生成パラメータの受け口。サブクラスで上書きする
  def setup(**); end

  def update; end
  def draw; end

  # 下層シーンへ update / draw を通すか。false / true / Hash で指定する
  def transparent = false

  def window = @world.window
  def input  = window.input
  def width  = window.width
  def height = window.height

  def spawn(klass = ModelActor, **keywords)
    actor = klass.new(self)
    actor.setup(**keywords)
    @actors << actor
    actor
  end

  # 即時除去。冪等
  def remove(actor)
    return unless @actors.delete(actor)
    actor.vanish
    actor.dispose
  end

  # World#step が update を通したシーンにだけ呼ぶ。AnimationMixer の時間進行
  def tick(dt) = @actors.each { |actor| actor.tick(dt) unless actor.vanished? }

  # World#step が update 後に呼ぶ
  def sweep
    vanished = @actors.select(&:vanished?)
    return if vanished.empty?
    vanished.each(&:dispose)
    @actors -= vanished
  end

  def hide_actors = @actors.each(&:hide)

  def teardown
    @actors.each(&:dispose)
    @actors.clear
  end
end

# シーンスタックの管理と 1 フレームの進行
class World
  attr_reader :window

  def initialize(window)
    @window = window
    @stack = []
    @retired = []
  end

  def push_scene(klass, **keywords)
    scene = klass.new(self)
    scene.setup(**keywords)
    @stack.push(scene)
    scene
  end

  def pop_scene
    scene = @stack.pop
    @retired << scene if scene
    scene
  end

  # 旧シーンの破棄はフレーム末尾
  def set_scene(klass, **keywords)
    @retired.concat(@stack)
    @stack = []
    push_scene(klass, **keywords)
  end

  def current = @stack.last

  def step
    # update 中の push / set は翌フレームから効く
    scenes = @stack.dup
    unless scenes.empty?
      run_update = []
      run_draw = []
      cascade_update = true
      cascade_draw = true
      (scenes.size - 1).downto(0) do |index|
        run_update[index] = cascade_update
        run_draw[index] = cascade_draw
        transparency = normalize(scenes[index].transparent)
        cascade_update &&= transparency[:update]
        cascade_draw &&= transparency[:draw]
      end
      scenes.each_index { |index| scenes[index].update if run_update[index] }
      # update を止めたシーンはアニメ時間も止める（ポーズの意味論を 2D と揃える）
      scenes.each_index { |index| scenes[index].tick(@window.dt) if run_update[index] }
      scenes.each(&:sweep)
      scenes.each(&:hide_actors)
      scenes.each_index { |index| scenes[index].draw if run_draw[index] }
    end
    @retired.each(&:teardown)
    @retired.clear
  end

  private

  def normalize(transparency)
    case transparency
    when true       then { update: true,  draw: true }
    when false, nil then { update: false, draw: false }
    when Hash       then { update: transparency.fetch(:update, true), draw: transparency.fetch(:draw, true) }
    else raise ArgumentError, "transparent must be true / false / Hash, got #{transparency.inspect}"
    end
  end
end

# デバイスホスト。renderer・カメラ・フレームループを担う
class Window
  CANVAS_ID = "screen"
  MAX_DT = 0.1

  attr_accessor :width, :height, :fps, :world
  attr_reader :input, :stage, :real_fps, :dt

  def initialize
    @width = 960
    @height = 540
    @background_color = 0x000000
    @fps = 60
    @fps_timestamp = nil
    @fps_accumulator = 0.0
    @real_fps = 0
    @real_fps_count = 1
    @real_fps_timestamp = nil
    @dt = 0.0
    @dt_timestamp = nil
    @world = nil
    @started = false
    @stage = js_three_scene
    @camera = js_three_perspective_camera(45.0, @width.to_f / @height, 0.25, 100.0)
    @camera_position = @camera[:position]
    @canvas = js_get_element_by_id(CANVAS_ID)
    @input = Input.new(@canvas, self)
  end

  def bgcolor=(hex)
    @background_color = hex
    js_rt_set_clear_color(hex, 1.0)
  end

  def fog(hex, near, far) = js_scene_set_fog(@stage, js_three_fog(hex, near, far))

  def camera_at(x, y, z)   = @camera_position.set(x, y, z)
  def camera_look(x, y, z) = js_camera_look_at(@camera, x, y, z)

  # 開発時のリーク監視。シーン切替を跨いで geometries / textures / programs が
  # 単調増加し続けるならどこかに解放漏れがある
  def stats = js_rt_info_counts

  # world とシーンを用意してから呼ぶ
  def run
    raise "Window#run: world が未設定" unless @world
    start
    each_frame do
      @input.advance
      @world.step
      js_rt_render(@stage, @camera)
    end
  end

  private

  def start
    return if @started
    @started = true
    js_rt_set_size(@width, @height)
    js_rt_set_clear_color(@background_color, 1.0)
    js_camera_set_aspect(@camera, @width.to_f / @height)
  end

  def each_frame(&block)
    frame = nil
    frame = proc do |timestamp|
      if paced?(timestamp)
        count_real_fps(timestamp)
        advance_dt(timestamp)
        begin
          block.call
        rescue => error
          js_console_error("#{error.class}: #{error.message}")
        end
      end
      js_request_animation_frame { |next_timestamp| frame.call(next_timestamp) }
    end
    js_request_animation_frame { |timestamp| frame.call(timestamp) }
  end

  # fps 上限のフレームゲート
  def paced?(timestamp)
    return true if @fps <= 0
    frame_milliseconds = 1000.0 / @fps
    @fps_timestamp ||= timestamp
    @fps_accumulator += timestamp - @fps_timestamp
    @fps_timestamp = timestamp
    @fps_accumulator = frame_milliseconds if @fps_accumulator > frame_milliseconds * 4
    return false if @fps_accumulator < frame_milliseconds
    @fps_accumulator -= frame_milliseconds
    true
  end

  # AnimationMixer 用の実フレーム時間（秒）。タブ復帰などの大穴はクランプする
  def advance_dt(timestamp)
    @dt = @dt_timestamp ? ((timestamp - @dt_timestamp) / 1000.0).clamp(0.0, MAX_DT) : 1.0 / (@fps > 0 ? @fps : 60)
    @dt_timestamp = timestamp
  end

  def count_real_fps(timestamp)
    @real_fps_timestamp ||= timestamp
    if timestamp - @real_fps_timestamp >= 1000.0
      @real_fps = @real_fps_count
      @real_fps_count = 1
      @real_fps_timestamp = timestamp
    else
      @real_fps_count += 1
    end
  end
end
