# frozen_string_literal: true
require "js"

module Kingyo
  FISH_TYPES = [
    { name: "小赤", color: "#f06455", light: "#ffb06a", dark: "#bd3b42", points: 1, radius: 15, weight: 48 },
    { name: "琉金", color: "#ff9b48", light: "#ffd078", dark: "#e25f2e", points: 3, radius: 17, weight: 27 },
    { name: "更紗金魚", color: "#fff0df", light: "#ffffff", dark: "#e76b63", points: 5, radius: 18, weight: 16 },
    { name: "黒出目金", color: "#343747", light: "#6a7086", dark: "#121522", points: 10, radius: 20, weight: 8 },
    { name: "金魚王", color: "#f8ca55", light: "#fff2a1", dark: "#d98d24", points: 30, radius: 21, weight: 1, rare: true }
  ].freeze

  POND_W = 640
  POND_H = 400
  GAME_SECONDS = 30.0
  FISH_COUNT = 15
  TICK_MS = 33
  COMBO_WINDOW = 3.0

  class Game
    def initialize
      @doc = JS.global[:document]
      @win = JS.global
    end

    def setup
      cache_elements
      bind_events
      load_ranking(@ranking_list_start)
    end

    def cache_elements
      @start_screen = el("start-screen"); @start_btn = el("start-btn"); @view_rank_btn = el("view-ranking-btn")
      @ranking_modal = el("ranking-modal"); @ranking_list_start = el("ranking-modal-list"); @close_rank_btn = el("close-ranking-btn")
      @game_screen = el("game-screen"); @canvas = el("pond"); @ctx = @canvas.getContext("2d")
      @score_value = el("score-value"); @timer_value = el("timer-value"); @combo_value = el("combo-value")
      @poi_bar_inner = el("poi-bar-inner"); @flash_msg = el("flash-message"); @event_msg = el("event-message")
      @result_screen = el("result-screen"); @result_reason = el("result-reason"); @final_score = el("final-score")
      @max_combo_el = el("max-combo"); @rare_count_el = el("rare-count"); @name_input = el("player-name")
      @register_btn = el("register-btn"); @register_status = el("register-status"); @ranking_list_result = el("result-ranking-list")
      @again_btn = el("play-again-btn"); @share_btn = el("share-x-btn")
    end

    def el(id) = @doc.getElementById(id)

    def bind_events
      @start_btn.addEventListener("click") { start_game }
      @view_rank_btn.addEventListener("click") { show(@ranking_modal); load_ranking(@ranking_list_start) }
      @close_rank_btn.addEventListener("click") { hide(@ranking_modal) }
      @register_btn.addEventListener("click") { register_score }
      @again_btn.addEventListener("click") { hide(@result_screen); show(@start_screen) }
      @share_btn.addEventListener("click") { share_to_x }
      bind_pond_events
    end

    def reset_state
      @score = 0; @time_left = GAME_SECONDS; @poi_durability = 100.0
      @poi_x = POND_W / 2.0; @poi_y = POND_H / 2.0
      @fish = Array.new(FISH_COUNT) { spawn_fish }
      @obstacles = [spawn_turtle, spawn_crab]
      @particles = []; @bubbles = []; @ripples = []
      @combo = 0; @max_combo = 0; @combo_timer = 0.0; @rare_count = 0
      @slow_timer = 0.0; @bubble_timer = 0.0; @current_timer = 0.0
      @next_event_at = 6.0 + rand * 3.0; @elapsed = 0.0
      @running = false
    end

    def start_game
      reset_state; init_audio
      hide(@start_screen); hide(@ranking_modal); show(@game_screen)
      update_hud; update_poi_bar; flash("START!", 700); play_tone(520, 0.08, "square")
      @running = true
      @interval_id = @win.setInterval(proc { tick }, TICK_MS)
    end

    def weighted_type_index
      total = FISH_TYPES.sum { |f| f[:weight] }; pick = rand(total); sum = 0
      FISH_TYPES.each_with_index { |f, i| sum += f[:weight]; return i if pick < sum }
      0
    end

    def spawn_fish
      i = weighted_type_index; type = FISH_TYPES[i]; margin = type[:radius] + 12; angle = rand * Math::PI * 2
      speed = type[:rare] ? 55 + rand * 20 : 25 + rand * 38
      { type_index: i, x: margin + rand * (POND_W - margin * 2), y: margin + rand * (POND_H - margin * 2),
        vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, phase: rand * Math::PI * 2, caught: false }
    end

    def spawn_turtle
      dir = rand < 0.5 ? 1 : -1
      { kind: :turtle, x: dir == 1 ? -45.0 : POND_W + 45.0, y: 70 + rand * 260, vx: dir * (22 + rand * 10), radius: 27, phase: 0.0 }
    end

    def spawn_crab
      dir = rand < 0.5 ? 1 : -1
      { kind: :crab, x: dir == 1 ? -35.0 : POND_W + 35.0, y: POND_H - 34 - rand * 35, vx: dir * (65 + rand * 25), radius: 22, phase: 0.0 }
    end

    def tick
      return unless @running
      dt = TICK_MS / 1000.0; @elapsed += dt
      update_timer(dt); update_combo(dt); update_effects(dt); update_fish(dt); update_obstacles(dt); maybe_trigger_event
      draw
    end

    def update_timer(dt)
      @time_left -= dt
      return end_game(:time_up) if @time_left <= 0
      @timer_value[:innerText] = @time_left.ceil.to_s
    end

    def update_combo(dt)
      return if @combo <= 0
      @combo_timer -= dt
      if @combo_timer <= 0
        @combo = 0; @combo_value[:innerText] = "×1"
      end
    end

    def update_effects(dt)
      @slow_timer = [@slow_timer - dt, 0].max
      @bubble_timer = [@bubble_timer - dt, 0].max
      @current_timer = [@current_timer - dt, 0].max
      @bubbles.each { |b| b[:y] -= b[:speed] * dt; b[:phase] += dt }
      @bubbles.reject! { |b| b[:y] < -15 }
      @particles.each { |p| p[:x] += p[:vx] * dt; p[:y] += p[:vy] * dt; p[:life] -= dt }
      @particles.reject! { |p| p[:life] <= 0 }
      @ripples.each { |r| r[:radius] += 50 * dt; r[:life] -= dt }
      @ripples.reject! { |r| r[:life] <= 0 }
    end

    def update_fish(dt)
      current_push = @current_timer > 0 ? 95.0 : 0.0
      @fish.each do |f|
        next if f[:caught]
        type = FISH_TYPES[f[:type_index]]; margin = type[:radius] + 10
        f[:x] += (f[:vx] + current_push) * dt; f[:y] += f[:vy] * dt; f[:phase] += dt * 4
        f[:vx] *= 0.999
        if f[:x] < margin then f[:x] = margin; f[:vx] = f[:vx].abs end
        if f[:x] > POND_W - margin then f[:x] = POND_W - margin; f[:vx] = -f[:vx].abs end
        if f[:y] < margin then f[:y] = margin; f[:vy] = f[:vy].abs end
        if f[:y] > POND_H - margin then f[:y] = POND_H - margin; f[:vy] = -f[:vy].abs end
      end
    end

    def update_obstacles(dt)
      @obstacles.each do |o|
        o[:x] += o[:vx] * dt; o[:phase] += dt * 4
        if o[:x] < -70 || o[:x] > POND_W + 70
          fresh = o[:kind] == :turtle ? spawn_turtle : spawn_crab
          o.replace(fresh)
        end
      end
    end

    def maybe_trigger_event
      return if @elapsed < @next_event_at
      if rand < 0.5 then trigger_current else trigger_bubbles end
      @next_event_at = @elapsed + 7 + rand * 4
    end

    def trigger_current
      @current_timer = 2.7; event_flash("🌊 水流発生！", 1300); play_sweep
      @fish.each { |f| f[:vx] += 80 + rand * 60 }
    end

    def trigger_bubbles
      @bubble_timer = 4.2; event_flash("🫧 泡で見えない！", 1300); play_tone(180, 0.12, "sine")
      65.times { @bubbles << { x: rand * POND_W, y: POND_H + rand * 160, radius: 3 + rand * 9, speed: 30 + rand * 70, phase: rand * 6 } }
    end

    def draw
      draw_water; @ripples.each { |r| draw_ripple(r) }
      @fish.each { |f| draw_fish(f) unless f[:caught] }
      @obstacles.each { |o| o[:kind] == :turtle ? draw_turtle(o) : draw_crab(o) }
      draw_particles; draw_bubbles; draw_poi
    end

    def draw_water
      g = @ctx.createLinearGradient(0, 0, 0, POND_H); g.addColorStop(0, "#23859a"); g.addColorStop(1, "#0e506b")
      @ctx[:fillStyle] = g; @ctx.fillRect(0, 0, POND_W, POND_H)
      @ctx[:strokeStyle] = "rgba(255,255,255,0.08)"; @ctx[:lineWidth] = 2
      5.times do |i|
        y = 45 + i * 75 + Math.sin(@elapsed * 1.3 + i) * 8
        @ctx.beginPath; @ctx.moveTo(0, y)
        8.times { |j| @ctx.quadraticCurveTo(j * 80 + 40, y + (j.even? ? 9 : -9), j * 80 + 80, y) }
        @ctx.stroke
      end
      @ctx[:fillStyle] = "rgba(245,195,91,0.09)"; draw_circle(90, 70, 55); draw_circle(530, 315, 70)
    end

    def draw_fish(f)
      t = FISH_TYPES[f[:type_index]]; bob = Math.sin(f[:phase]) * 3; r = t[:radius]; facing = f[:vx] >= 0 ? 1 : -1
      @ctx.save; @ctx.translate(f[:x], f[:y] + bob); @ctx.scale(facing, 1)
      tail_wave = Math.sin(f[:phase] * 1.7) * 0.18
      @ctx.save; @ctx.rotate(tail_wave); @ctx[:fillStyle] = t[:dark]; @ctx.beginPath
      @ctx.moveTo(-r * 0.82, 0); @ctx.quadraticCurveTo(-r * 1.65, -r * 0.95, -r * 1.45, 0); @ctx.quadraticCurveTo(-r * 1.65, r * 0.95, -r * 0.82, 0); @ctx.fill; @ctx.restore
      grad = @ctx.createRadialGradient(r * 0.2, -r * 0.25, 2, 0, 0, r * 1.2); grad.addColorStop(0, t[:light]); grad.addColorStop(1, t[:color])
      @ctx[:fillStyle] = grad; @ctx.beginPath; @ctx.ellipse(0, 0, r * 1.05, r * 0.78, 0, 0, Math::PI * 2); @ctx.fill
      @ctx[:fillStyle] = "rgba(255,255,255,0.45)"; @ctx.beginPath; @ctx.ellipse(-r * 0.15, -r * 0.32, r * 0.32, r * 0.12, -0.3, 0, Math::PI * 2); @ctx.fill
      @ctx[:fillStyle] = t[:dark]; @ctx.beginPath; @ctx.moveTo(-r * 0.2, -r * 0.55); @ctx.quadraticCurveTo(0, -r * 1.05, r * 0.25, -r * 0.56); @ctx.fill
      eye_x = r * 0.48; @ctx[:fillStyle] = "white"; draw_circle(eye_x, -r * 0.13, r * 0.18); @ctx[:fillStyle] = "#101520"; draw_circle(eye_x + r * 0.04, -r * 0.12, r * 0.1)
      @ctx[:strokeStyle] = "rgba(90,25,30,0.55)"; @ctx[:lineWidth] = 1.5; @ctx.beginPath; @ctx.arc(r * 0.68, r * 0.12, r * 0.15, 0.2, 1.45); @ctx.stroke
      if t[:rare]
        @ctx[:strokeStyle] = "rgba(255,244,164,0.8)"; @ctx[:lineWidth] = 3; @ctx.beginPath; @ctx.arc(0, 0, r * 1.25, 0, Math::PI * 2); @ctx.stroke
      end
      @ctx.restore
    end

    def draw_turtle(o)
      @ctx.save; @ctx.translate(o[:x], o[:y] + Math.sin(o[:phase]) * 3); facing = o[:vx] > 0 ? 1 : -1; @ctx.scale(facing, 1)
      @ctx[:fillStyle] = "#8ac06b"; draw_circle(25, 0, 11); draw_circle(-20, -15, 6); draw_circle(-20, 15, 6)
      @ctx[:fillStyle] = "#4d8550"; @ctx.beginPath; @ctx.ellipse(0, 0, 27, 21, 0, 0, Math::PI * 2); @ctx.fill
      @ctx[:strokeStyle] = "#b6d77a"; @ctx[:lineWidth] = 2; @ctx.beginPath; @ctx.arc(0, 0, 13, 0, Math::PI * 2); @ctx.stroke
      @ctx[:fillStyle] = "#13231c"; draw_circle(29, -3, 2); @ctx.restore
    end

    def draw_crab(o)
      @ctx.save; @ctx.translate(o[:x], o[:y] + Math.sin(o[:phase] * 2) * 2)
      @ctx[:strokeStyle] = "#e85148"; @ctx[:lineWidth] = 4
      [-1,1].each do |s|; 3.times do |i|; @ctx.beginPath; @ctx.moveTo(s * 10, -6 + i * 7); @ctx.lineTo(s * (27 + i * 3), -15 + i * 14); @ctx.stroke; end; end
      @ctx[:fillStyle] = "#ef6255"; @ctx.beginPath; @ctx.ellipse(0, 0, 20, 14, 0, 0, Math::PI * 2); @ctx.fill
      @ctx[:fillStyle] = "white"; draw_circle(-7, -11, 5); draw_circle(7, -11, 5); @ctx[:fillStyle] = "#111827"; draw_circle(-7, -12, 2); draw_circle(7, -12, 2)
      @ctx.restore
    end

    def draw_bubbles
      @bubbles.each do |b|
        alpha = @bubble_timer > 0 ? 0.55 : 0.22; @ctx[:strokeStyle] = "rgba(255,255,255,#{alpha})"; @ctx[:lineWidth] = 1.5
        @ctx.beginPath; @ctx.arc(b[:x] + Math.sin(b[:phase]) * 5, b[:y], b[:radius], 0, Math::PI * 2); @ctx.stroke
      end
    end

    def draw_particles
      @particles.each do |p|
        @ctx[:fillStyle] = p[:color]; draw_circle(p[:x], p[:y], p[:size] * [p[:life], 1].min)
      end
    end

    def draw_ripple(r)
      @ctx[:strokeStyle] = "rgba(255,255,255,#{[r[:life],0].max * 0.35})"; @ctx[:lineWidth] = 2
      @ctx.beginPath; @ctx.arc(r[:x], r[:y], r[:radius], 0, Math::PI * 2); @ctx.stroke
    end

    def draw_poi
      ratio = @poi_durability / 100.0
      @ctx[:strokeStyle] = "#6a4328"; @ctx[:lineWidth] = 7; @ctx.beginPath; @ctx.moveTo(@poi_x + 67, @poi_y + 67); @ctx.lineTo(@poi_x + 24, @poi_y + 24); @ctx.stroke
      @ctx[:strokeStyle] = "#dfb87b"; @ctx[:lineWidth] = 5; @ctx.beginPath; @ctx.arc(@poi_x, @poi_y, poi_radius, 0, Math::PI * 2); @ctx.stroke
      @ctx[:fillStyle] = "rgba(255,248,226,#{0.12 + ratio * 0.38})"; @ctx.beginPath; @ctx.arc(@poi_x, @poi_y, poi_radius - 4, 0, Math::PI * 2); @ctx.fill
    end

    def draw_circle(x, y, r)
      @ctx.beginPath; @ctx.arc(x, y, r, 0, Math::PI * 2); @ctx.fill
    end

    def poi_radius = 39

    def bind_pond_events
      @canvas.addEventListener("mousemove") { |e| move_poi(e[:offsetX].to_f, e[:offsetY].to_f) }
      @canvas.addEventListener("mousedown") { scoop! }
      @canvas.addEventListener("touchmove") { |e| e.preventDefault; p = touch_pos(e); move_poi(p[0], p[1]) if p }
      @canvas.addEventListener("touchstart") { |e| e.preventDefault; p = touch_pos(e); move_poi(p[0], p[1]) if p; scoop! }
    end

    def touch_pos(e)
      return nil if e[:touches][:length].to_i < 1
      t = e[:touches][0]; r = @canvas.getBoundingClientRect
      [t[:clientX].to_f - r[:left].to_f, t[:clientY].to_f - r[:top].to_f]
    end

    def move_poi(x, y)
      scale_x = POND_W / @canvas[:clientWidth].to_f; scale_y = POND_H / @canvas[:clientHeight].to_f
      factor = @slow_timer > 0 ? 0.38 : 1.0
      target_x = (x * scale_x).clamp(0, POND_W); target_y = (y * scale_y).clamp(0, POND_H)
      @poi_x += (target_x - @poi_x) * factor; @poi_y += (target_y - @poi_y) * factor
    end

    def scoop!
      return unless @running && @poi_durability > 0
      @ripples << { x: @poi_x, y: @poi_y, radius: 12.0, life: 0.7 }; play_tone(240, 0.045, "sine")
      obstacle = @obstacles.find { |o| distance(o[:x], o[:y], @poi_x, @poi_y) <= poi_radius + o[:radius] }
      if obstacle
        hit_obstacle(obstacle); damage_poi(obstacle[:kind] == :turtle ? 15 : 10); return
      end
      candidates = @fish.reject { |f| f[:caught] }.select do |f|
        t = FISH_TYPES[f[:type_index]]; distance(f[:x], f[:y], @poi_x, @poi_y) <= poi_radius + t[:radius] * 0.48
      end
      caught = false
      candidates.sort_by { |f| distance(f[:x], f[:y], @poi_x, @poi_y) }.each do |f|
        t = FISH_TYPES[f[:type_index]]; chance = [[0.88 - t[:points] * 0.012 + @poi_durability / 500.0, 0.25].max, 0.96].min
        next unless rand < chance
        f[:caught] = true; register_catch(t, f[:x], f[:y]); caught = true
        @win.setTimeout(proc { @fish << spawn_fish }, 300); break
      end
      damage_poi(caught ? 5 + rand(5) : (candidates.empty? ? 1 + rand(3) : 4 + rand(4)))
    end

    def register_catch(type, x, y)
      @combo = @combo_timer > 0 ? @combo + 1 : 1; @combo_timer = COMBO_WINDOW; @max_combo = [@max_combo, @combo].max
      multiplier = @combo >= 8 ? 2.0 : (@combo >= 5 ? 1.5 : (@combo >= 3 ? 1.2 : 1.0))
      gained = (type[:points] * multiplier).round; @score += gained; @rare_count += 1 if type[:rare]
      @score_value[:innerText] = @score.to_s; @combo_value[:innerText] = "×#{@combo}"
      text = type[:rare] ? "✨ 金魚王 +#{gained}pt" : "#{type[:name]} +#{gained}pt"
      flash(text, 650); event_flash("COMBO ×#{@combo}", 650) if @combo >= 3
      12.times { @particles << { x: x, y: y, vx: -55 + rand * 110, vy: -60 + rand * 85, life: 0.7 + rand * 0.5, size: 2 + rand * 4, color: type[:light] } }
      type[:rare] ? play_rare_sound : play_tone(470 + [@combo, 8].min * 35, 0.08, "square")
    end

    def hit_obstacle(o)
      if o[:kind] == :turtle
        flash("🐢 亀！ ポイ耐久 -15", 850); play_tone(110, 0.16, "sawtooth")
      else
        @slow_timer = 2.0; flash("🦀 カニ！ 2秒間スロー", 850); play_tone(90, 0.18, "square")
      end
    end

    def damage_poi(amount)
      @poi_durability = [@poi_durability - amount, 0].max; update_poi_bar
      end_game(:poi_broken) if @poi_durability <= 0
    end

    def update_hud
      @score_value[:innerText] = "0"; @timer_value[:innerText] = GAME_SECONDS.to_i.to_s; @combo_value[:innerText] = "×1"
    end

    def update_poi_bar
      pct = @poi_durability.round; @poi_bar_inner[:style][:width] = "#{pct}%"
      @poi_bar_inner[:style][:backgroundColor] = pct > 60 ? "#61d196" : (pct > 25 ? "#f3bd50" : "#ef5b55")
    end

    def end_game(reason)
      return unless @running
      @running = false; @win.clearInterval(@interval_id) if @interval_id
      @result_reason[:innerText] = reason == :time_up ? "タイムアップ！" : "ポイが破れた！"
      @final_score[:innerText] = "#{@score}pt"; @max_combo_el[:innerText] = "×#{[@max_combo,1].max}"; @rare_count_el[:innerText] = "#{@rare_count}匹"
      @name_input[:value] = ""; @register_status[:innerText] = ""; @register_btn[:disabled] = false
      hide(@game_screen); show(@result_screen); load_ranking(@ranking_list_result); play_end_sound
    end

    def share_to_x
      page_url = @win[:location][:href].to_s.split("?").first
      text = "Tech縁日 金魚すくいで #{@score}pt 獲得しました！🐟🏮\n最高コンボ ×#{[@max_combo,1].max}\n\n#Tech縁日\n#CodeCast\n#金魚すくい\n\n#{page_url}"
      encoded = @win[:encodeURIComponent].call(text)
      @win.open("https://twitter.com/intent/tweet?text=#{encoded}", "_blank", "noopener,noreferrer")
    end

    def register_score
      name = @name_input[:value].to_s.strip; name = "名無しさん" if name.empty?; name = name[0,20]
      @register_btn[:disabled] = true; @register_status[:innerText] = "送信中..."
      opts = { "method" => "POST", "headers" => { "Content-Type" => "application/json" }.to_js, "body" => build_score_json(name, @score) }.to_js
      @win.fetch("/api/scores", opts).call(:then, ->(r) { r.call(:json).call(:then, ->(d) { @register_status[:innerText] = "ランキングに登録しました！"; render_ranking(@ranking_list_result, d); nil }); nil }, ->(_e) { @register_status[:innerText] = "送信に失敗しました"; @register_btn[:disabled] = false; nil })
    end

    def load_ranking(target)
      target[:innerHTML] = '<li class="rank-loading">読み込み中...</li>'
      @win.fetch("/api/scores").call(:then, ->(r) { r.call(:json).call(:then, ->(d) { render_ranking(target, d); nil }, ->(_e) { target[:innerHTML] = '<li class="rank-empty">取得できませんでした</li>'; nil }); nil }, ->(_e) { target[:innerHTML] = '<li class="rank-empty">取得できませんでした</li>'; nil })
    end

    def render_ranking(target, data)
      items = data.to_a
      if items.empty? then target[:innerHTML] = '<li class="rank-empty">まだランキングはありません</li>'; return end
      target[:innerHTML] = items.each_with_index.map { |item,i| "<li><span class=\"rank-no\">#{i+1}</span><span class=\"rank-name\">#{escape_html(item[:name].to_s)}</span><span class=\"rank-score\">#{item[:score].to_i}pt</span></li>" }.join
    end

    def build_score_json(name, score)
      escaped = name.gsub("\\", "\\\\").gsub('"', '\\"'); %({"name":"#{escaped}","score":#{score.to_i}})
    end

    def escape_html(s) = s.gsub("&","&amp;").gsub("<","&lt;").gsub(">","&gt;").gsub('"',"&quot;")
    def distance(x1,y1,x2,y2) = Math.sqrt((x1-x2)**2 + (y1-y2)**2)

    def init_audio
      return if @audio
      ctor = @win[:AudioContext] || @win[:webkitAudioContext]
      @audio = ctor.new if ctor
    rescue StandardError
      @audio = nil
    end

    def play_tone(freq, duration, wave)
      return unless @audio
      osc = @audio.createOscillator; gain = @audio.createGain; now = @audio[:currentTime].to_f
      osc[:type] = wave; osc[:frequency][:value] = freq; gain[:gain].setValueAtTime(0.055, now); gain[:gain].exponentialRampToValueAtTime(0.001, now + duration)
      osc.connect(gain); gain.connect(@audio[:destination]); osc.start(now); osc.stop(now + duration)
    rescue StandardError
      nil
    end

    def play_rare_sound
      [660,880,1100].each_with_index { |f,i| @win.setTimeout(proc { play_tone(f,0.12,"sine") }, i*80) }
    end
    def play_end_sound
      [440,330,220].each_with_index { |f,i| @win.setTimeout(proc { play_tone(f,0.14,"triangle") }, i*100) }
    end
    def play_sweep
      [180,240,320].each_with_index { |f,i| @win.setTimeout(proc { play_tone(f,0.12,"sine") }, i*70) }
    end

    def flash(text, ms)
      @flash_msg[:innerText] = text; @flash_msg[:style][:opacity] = "1"; @win.setTimeout(proc { @flash_msg[:style][:opacity] = "0" }, ms)
    end
    def event_flash(text, ms)
      @event_msg[:innerText] = text; @event_msg[:style][:opacity] = "1"; @win.setTimeout(proc { @event_msg[:style][:opacity] = "0" }, ms)
    end
    def show(e) = e[:style][:display] = "flex"
    def hide(e) = e[:style][:display] = "none"
  end
end

$kingyo_game = Kingyo::Game.new
$kingyo_game.setup
