# main.lib.rb — Three.js を包む薄い 2D ゲームライブラリ
#
# 使い方:
#   window = Window.new
#   world  = World.new(window)
#   window.world = world
#   world.push_scene(TitleScene)
#   window.run
#
# 規約:
#   - 座標は左上原点・Y 下向き
#   - 生成は world.push_scene / scene.spawn から。パラメータは setup のキーワード引数で受ける
#   - 毎フレーム draw を呼んだものだけが表示される
#   - vanish は印付けのみ。実除去はフレームの決まった一点でまとめて行われる

# 画像。複数の Sprite から共有して使われる想定
class Image
  attr_reader :width, :height, :texture

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

  def initialize
    @texture = nil
    @width = 0
    @height = 0
    @loaded = false
  end

  def loaded? = @loaded

  def load_texture(path)
    loader = js_three_texture_loader
    @texture = js_load_texture(loader, path) do |texture|
      @width, @height = js_texture_size(texture)
      @loaded = true
    end
    js_texture_nearest(@texture)
    self
  end
end

# 画像（事前ロード版）。Image と同じく複数の Sprite から共有して使われる想定。
# 取得は Http、保持は Resource、消費は blob → createImageBitmap → CanvasTexture。
# createImageBitmap は await を伴うため、起動時の取得文脈から構築する。
# loaded? は常に真（await 完了後に構築するため）。
#
# テクスチャ規約：Three.js は既定で flipY=true（生成時に上下反転）を前提とするが、
# ImageBitmap をソースにすると WebGL の UNPACK_FLIP_Y が効かない。そこで
# createImageBitmap 側で生成時に反転しておき（imageOrientation: flipY）、
# texture[:flipY]=false（js_texture_no_flip）と対で使って二重反転を打ち消し、
# スプライトの座標系に合わせる。
class Picture
  attr_reader :width, :height, :texture

  # path | Resource を受ける。文字列はパスとして Http.get で Resource 化する。
  # filter: :nearest（ドット絵）か :linear（sRGB、文字や写真）でテクスチャ補間を選ぶ。
  def self.load(source, filter: :nearest)
    resource = source.is_a?(Resource) ? source : Http.get(source)
    bitmap = js_create_image_bitmap(resource.blob)
    texture = js_three_canvas_texture(bitmap) # CanvasTexture は ImageBitmap を受ける
    js_texture_no_flip(texture)               # 生成時に反転済みなので二重反転を防ぐ
    case filter
    when :linear then js_texture_linear_srgb(texture)
    else              js_texture_nearest(texture)
    end
    width, height = js_image_bitmap_size(bitmap)
    new(texture, width, height)
  end

  def loaded? = true # 事前ロード済み（await 完了後に構築するため常に真）

  private

  def initialize(texture, width, height)
    @texture = texture
    @width = width
    @height = height
  end
end

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

  attr_reader :scene
  attr_accessor :x, :y, :z, :alpha, :visible

  def initialize(scene)
    @scene = scene
    @window = scene.window
    @x = 0
    @y = 0
    @z = 0
    @alpha = 255
    @visible = true
    @vanished = false
    @node = nil
    @material = nil
    @position = nil
    @scale = nil
    @applied = nil
  end

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

  def update; end
  def draw; end

  def input = @scene.input

  def width = 0
  def height = 0
  def center_x = @x + width / 2.0
  def center_y = @y + height / 2.0

  def hit?(other)
    @x < other.x + other.width && @x + width > other.x &&
      @y < other.y + other.height && @y + height > other.y
  end

  def offscreen?(margin = 40)
    @x + width < -margin || @x > @window.width + margin ||
      @y + height < -margin || @y > @window.height + margin
  end

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

  def vanished? = @vanished

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

  # 共有テクスチャは解放しない
  def dispose
    return unless @node
    js_scene_remove(@window.stage, @node)
    js_material_dispose(@material)
    @node = nil
    @material = nil
    @position = nil
    @scale = nil
    @applied = nil
  end

  private

  def ensure_node
    return if @node
    @material = js_three_sprite_material
    @material[:transparent] = true
    @node = js_three_sprite(@material)
    @node[:center].set(0.5, 0.5)
    @position = @node[:position]
    @scale = @node[:scale]
    js_scene_add(@window.stage, @node)
  end

  def apply_map(texture)
    return if @applied.equal?(texture)
    js_material_set_map(@material, texture)
    @applied = texture
  end

  def place(x, y, width, height, z)
    @position.set(x + width / 2.0, @window.height - y - height / 2.0, z)
    @scale.set(width, height, 1)
    @node[:visible] = true
  end

  def opacity = (@alpha || 255) / 255.0

  def rgb_hex(color) = (color[0].to_i << 16) | (color[1].to_i << 8) | color[2].to_i
end

# 画像スプライト。ゲームオブジェクトの基本形
class Sprite < SpriteHandle
  attr_accessor :angle, :scale_x, :scale_y, :image

  def initialize(scene)
    super
    @angle = 0
    @scale_x = 1.0
    @scale_y = 1.0
    @image = nil
  end

  def setup(x: 0, y: 0, image: nil, z: 0)
    @x = x
    @y = y
    @image = image
    @z = z
  end

  def width  = @image ? @image.width * @scale_x : 0
  def height = @image ? @image.height * @scale_y : 0

  def draw
    return if vanished? || !@visible || @image.nil? || !@image.loaded?
    ensure_node
    apply_map(@image.texture)
    render_width = @image.width * @scale_x
    render_height = @image.height * @scale_y
    render_width = 1 if render_width <= 0
    render_height = 1 if render_height <= 0
    @material[:rotation] = @angle * Math::PI / 180.0
    @material[:opacity]  = opacity
    place(@x, @y, render_width, render_height, @z)
  end
end

# 文字
class Label < SpriteHandle
  attr_accessor :text, :size, :color, :bold, :name, :align

  def setup(x: 0, y: 0, text: "", size: 18, color: [230, 230, 230], z: 0, align: :left, bold: false, name: "sans-serif")
    @x = x
    @y = y
    @text = text
    @size = size
    @color = color
    @z = z
    @align = align
    @bold = bold
    @name = name
  end

  def width  = @text.to_s.empty? ? 0 : entry[:width] - Window::TEXT_PADDING * 2
  def height = @text.to_s.empty? ? 0 : entry[:height] - Window::TEXT_PADDING * 2

  def draw
    return if vanished? || !@visible || @text.to_s.empty?
    text_entry = entry
    ensure_node
    apply_map(text_entry[:texture])
    @material[:opacity] = opacity
    content_width = text_entry[:width] - Window::TEXT_PADDING * 2
    offset_x = @align == :center ? content_width / 2.0 : @align == :right ? content_width : 0.0
    place(@x - offset_x - Window::TEXT_PADDING, @y - Window::TEXT_PADDING, text_entry[:width], text_entry[:height], @z)
  end

  private

  def entry = @window.text_texture(@text.to_s, size: @size, color: @color, bold: @bold, name: @name)
end

# 単色矩形
class Box < SpriteHandle
  attr_accessor :width, :height, :color

  def setup(x: 0, y: 0, width: 0, height: 0, color: [255, 255, 255], z: 0, alpha: 255)
    @x = x
    @y = y
    @width = width
    @height = height
    @color = color
    @z = z
    @alpha = alpha
  end

  def draw
    return if vanished? || !@visible || @width <= 0 || @height <= 0
    ensure_node
    @material[:color].set(rgb_hex(@color))
    @material[:opacity] = opacity
    place(@x, @y, @width, @height, @z)
  end
end

# キーボード / マウス入力
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
    @sprites = []
  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 = Sprite, **keywords)
    sprite = klass.new(self)
    sprite.setup(**keywords)
    @sprites << sprite
    sprite
  end

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

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

  def hide_sprites = @sprites.each(&:hide)

  def teardown
    @sprites.each(&:dispose)
    @sprites.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] }
      scenes.each(&:sweep)
      scenes.each(&:hide_sprites)
      scenes.each_index { |index| scenes[index].draw if run_draw[index] }
    end
    @retired.each(&:teardown)
    @retired.clear
  end

  private

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

# デバイスホスト。renderer・カメラ・文字テクスチャ・フレームループを担う
class Window
  CANVAS_ID = "screen"
  LINE_HEIGHT_RATIO = 1.3
  TEXT_PADDING = 2.0
  TEXT_CACHE_LIMIT = 128
  MAX_DT = 0.1

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

  def initialize
    @width = 640
    @height = 480
    @background_color = [0, 0, 0]
    @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 = nil
    @canvas = js_get_element_by_id(CANVAS_ID)
    @input = Input.new(@canvas, self)
    @text_cache = {}
    @measure_context = nil
  end

  def bgcolor=(color)
    @background_color = color
    js_rt_set_clear_color(rgb_hex(color), 1.0)
  end

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

  # Label が使う。texture と余白込みの width / height を返す
  def text_texture(string, size:, color:, bold:, name:)
    key = "#{size}|#{name}|#{color.join(',')}|#{bold ? 1 : 0}|#{string}"
    hit = @text_cache[key]
    return hit if hit
    if @text_cache.size >= TEXT_CACHE_LIMIT
      oldest = @text_cache.keys.first
      js_texture_dispose(@text_cache.delete(oldest)[:texture])
    end
    @text_cache[key] = render_text(string, size, color, bold, name)
  end

  private

  def start
    return if @started
    @started = true
    js_rt_set_size(@width, @height)
    js_rt_set_clear_color(rgb_hex(@background_color), 1.0)
    @camera = js_three_ortho_camera(0.0, @width.to_f, @height.to_f, 0.0, -1000.0, 1000.0)
  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

  # 実フレーム時間（秒）。タブ復帰などの大穴はクランプする
  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

  def rgb_hex(color) = (color[0].to_i << 16) | (color[1].to_i << 8) | color[2].to_i

  def font_css(size, bold, name) = "#{bold ? 'bold ' : ''}#{size}px #{name}"

  def measure_context
    @measure_context ||= js_canvas_context_2d(js_create_canvas)
  end

  def measure_width(string, css)
    context = measure_context
    context[:font] = css
    width = context.measureText(string)[:width].to_f
    width < 1.0 ? 1.0 : width
  end

  def render_text(string, size, color, bold, name)
    css = font_css(size, bold, name)
    pixel_ratio = js_device_pixel_ratio
    text_width = measure_width(string, css)
    text_height = size * LINE_HEIGHT_RATIO
    padded_width = text_width + TEXT_PADDING * 2
    padded_height = text_height + TEXT_PADDING * 2
    canvas = js_create_canvas
    canvas[:width]  = (padded_width * pixel_ratio).ceil
    canvas[:height] = (padded_height * pixel_ratio).ceil
    context = js_canvas_context_2d(canvas)
    context.scale(pixel_ratio, pixel_ratio)
    context[:font] = css
    context[:textBaseline] = "top"
    context[:textAlign] = "left"
    red, green, blue = color
    context[:fillStyle] = "rgb(#{red.to_i},#{green.to_i},#{blue.to_i})"
    context.fillText(string, TEXT_PADDING, TEXT_PADDING)
    texture = js_three_canvas_texture(canvas)
    js_texture_linear_srgb(texture)
    { texture: texture, width: padded_width, height: padded_height }
  end
end
