Eaglev0.1.0

Coin Rush (3D)

Roll a ball around a lit, shadowed arena collecting coins before the timer runs out.

WASD move · Space jump · right-drag orbits the camera

Source: examples/coinrush3d/main.cr

examples/coinrush3d/main.cr

require "../../src/eagle"
include Eagle

# Coin Rush (3D game): roll the ball with WASD / left stick, jump with space,
# collect all coins before time runs out. Third-person follow camera, shadows, UI.
class CoinRush < App
  ARENA = 22_f32
  @cam = Camera3D.new(position: v3(0, 8, 12))
  @ball = MeshInstance3D.new(Mesh.sphere(0.5, 24, 16), Material.new(Color.hex("#ff6b6b"), shininess: 64, specular: 0.6), position: v3(0, 0.5, 0))
  @vel = Vec3::ZERO
  @coins = [] of MeshInstance3D
  @obstacles = [] of AABB
  @score = 0
  @time_left = 45_f32
  @state = :playing
  @hud = Label.new("")
  @big = Label.new("", align: TextAlign::Center)
  @snd_coin : Sound? = nil
  @snd_jump : Sound? = nil
  @rng = Random.new(3)
  @yaw = 0_f32

  def load
    Input.map "left", Key::A, Key::Left, Input.axis(GamepadAxis::LeftX, -1)
    Input.map "right", Key::D, Key::Right, Input.axis(GamepadAxis::LeftX, 1)
    Input.map "up", Key::W, Key::Up, Input.axis(GamepadAxis::LeftY, -1)
    Input.map "down", Key::S, Key::Down, Input.axis(GamepadAxis::LeftY, 1)
    Input.map "jump", Key::Space, GamepadButton::A
    @snd_coin = Sound.tone(1300, 0.1, Sound::Wave::Sine, 0.3)
    @snd_jump = Sound.tone(400, 0.12, Sound::Wave::Triangle, 0.25)
    root = SceneTree.root
    env = Scene3D.environment
    env.fog(25, 70, Color.hex("#c9d8ee"))
    env.sky_colors(Color.hex("#6fa8ff"), Color.hex("#dbe8ff"), Color.hex("#4d5a70"))
    tex = Texture.new(Image.checkerboard(32, 32, 16, Color.hex("#8bc34a"), Color.hex("#7cb342")), wrap: GPU::Wrap::Repeat)
    root.add(MeshInstance3D.new(Mesh.plane(ARENA * 2, ARENA * 2, 1, uv_scale: 11), Material.new(texture: tex, specular: 0.05)))
    # walls
    [{v3(0, 1, -ARENA), v3(ARENA * 2, 2, 1)}, {v3(0, 1, ARENA), v3(ARENA * 2, 2, 1)}, {v3(-ARENA, 1, 0), v3(1, 2, ARENA * 2)}, {v3(ARENA, 1, 0), v3(1, 2, ARENA * 2)}].each do |(pos, size)|
      root.add(MeshInstance3D.new(Mesh.box(size.x, size.y, size.z), Material.new(Color.hex("#556270")), position: pos))
      @obstacles << AABB.from_center(pos, size / 2)
    end
    # obstacles
    14.times do
      size = v3(1 + @rng.rand * 3, 1 + @rng.rand * 2, 1 + @rng.rand * 3)
      pos = v3((@rng.rand - 0.5) * ARENA * 1.6, size.y / 2, (@rng.rand - 0.5) * ARENA * 1.6)
      next if pos.xz.length < 4
      root.add(MeshInstance3D.new(Mesh.box(size.x, size.y, size.z), Material.new(Color.hsv(@rng.rand * 360, 0.35, 0.9)), position: pos))
      @obstacles << AABB.from_center(pos, size / 2)
    end
    # coins
    20.times do
      pos = v3((@rng.rand - 0.5) * ARENA * 1.7, 0.8, (@rng.rand - 0.5) * ARENA * 1.7)
      next if @obstacles.any? { |o| o.contains?(pos) }
      coin = MeshInstance3D.new(Mesh.cylinder(0.45, 0.12, 20), Material.new(Color.hex("#ffd700"), shininess: 96, specular: 0.9, emissive: Color.new(0.15, 0.1, 0)), position: pos)
      coin.rotate_x(Math::PI / 2)
      root.add(coin)
      @coins << coin
    end
    root.add(@ball)
    root.add(DirectionalLight3D.new(v3(-0.4, -1, -0.5), Color.hex("#fff4e0"), 1.1))
    root.add(PointLight3D.new(v3(0, 4, 0), Color.hex("#ffe0a0"), 1.5, 14))
    root.add(@cam)
    hud = CanvasLayer.new
    @hud.position = v2(12, 10)
    @hud.shadow = Color::BLACK
    @hud.shadow_offset = v2(2, 2)
    @big.anchor = Anchor::Center
    @big.font_scale = 2
    @big.shadow = Color::BLACK
    @big.shadow_offset = v2(3, 3)
    hud.add(@hud, @big)
    root.add(hud)
  end

  def update(dt : Float32)
    if @state == :playing
      @time_left -= dt
      move(dt)
      @coins.each { |c| c.rotate(Vec3::UP, dt * 2) }
      @coins.reject! do |c|
        if c.global_position.distance(@ball.position) < 1.0
          c.free
          @score += 1
          @snd_coin.try(&.play(pitch: 1 + @score * 0.03))
          true
        end
      end
      if @coins.empty?
        @state = :won
      elsif @time_left <= 0
        @state = :lost
      end
    elsif Input.pressed?(Key::R)
      SceneTree.reset
      @coins.clear; @obstacles.clear; @score = 0; @time_left = 45_f32; @state = :playing
      @ball = MeshInstance3D.new(Mesh.sphere(0.5, 24, 16), Material.new(Color.hex("#ff6b6b"), shininess: 64, specular: 0.6), position: v3(0, 0.5, 0))
      @vel = Vec3::ZERO
      @cam = Camera3D.new(position: v3(0, 8, 12))
      load
    end
    # camera: orbit with right drag, follow the ball
    @yaw -= Input.mouse_delta.x * 0.005 if Input.mouse_down?(MouseButton::Right)
    if g = Input.gamepad
      @yaw -= g.right_stick.x * dt * 2
    end
    offset = Quat.from_axis_angle(Vec3::UP, @yaw) * v3(0, 6, 10)
    @cam.position = Mathf.damp(@cam.position, @ball.position + offset, 6, dt)
    @cam.look_at(@ball.position + v3(0, 0.5, 0))
    @hud.text = "coins #{@score}/#{@score + @coins.size}   time #{@time_left.clamp(0, 999).ceil.to_i}   fps #{Clock.fps.round.to_i}"
    @big.text = case @state
                when :won then "YOU WIN!\nR to play again"
                when :lost then "TIME'S UP\nR to play again"
                else ""
                end
    Eagle.quit if Input.pressed?(Key::Escape)
  end

  private def move(dt : Float32)
    # input relative to the camera yaw
    inp = Input.vector("left", "right", "up", "down")
    dir = Quat.from_axis_angle(Vec3::UP, @yaw) * v3(inp.x, 0, inp.y)
    @vel += dir * 40 * dt
    @vel = v3(@vel.x * (1 / (1 + dt * 2.5)), @vel.y - 25 * dt, @vel.z * (1 / (1 + dt * 2.5)))
    on_ground = @ball.position.y <= 0.501
    if on_ground && Input.pressed?("jump")
      @vel = v3(@vel.x, 9, @vel.z)
      @snd_jump.try(&.play)
    end
    pos = @ball.position + @vel * dt
    if pos.y < 0.5
      pos = v3(pos.x, 0.5, pos.z); @vel = v3(@vel.x, 0, @vel.z)
    end
    # sphere vs boxes: push out along the smallest axis
    @obstacles.each do |box|
      closest = pos.max(box.min).min(box.max)
      d = pos - closest
      dist = d.length
      if dist < 0.5
        n = dist > 1e-4 ? d / dist : Vec3::UP
        pos = closest + n * 0.5
        vn = @vel.dot(n)
        @vel -= n * vn * 1.3 if vn < 0
      end
    end
    # roll the ball visually
    horiz = v3(@vel.x, 0, @vel.z)
    if horiz.length > 0.01
      axis = Vec3::UP.cross(horiz).normalized
      @ball.rotate(axis, horiz.length * dt / 0.5)
    end
    @ball.position = pos
  end
end

Eagle.run(CoinRush, title: "Eagle Coin Rush", width: 1024, height: 640, msaa: 4)