Eaglev0.1.0

Roguelike

Procedural dungeons, shadowcasting field of view, monsters that chase, items, five levels.

Arrows/WASD/hjkl move · Space wait · Enter descends on > · R restart

Source: examples/roguelike/main.cr

examples/roguelike/main.cr

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

# Dungeon explorer: arrows / WASD / hjkl move & attack, space waits, > descends,
# R restarts. Fog of war, shadowcasting FOV, monsters, items, 5 levels.
class RoguelikeApp < App
  CELL = 20
  @game = Rogue::Game.new((Random.rand * 10000).to_i)
  @font : Font? = nil
  @flash = 0_f32
  @snd_hit : Sound? = nil
  @snd_pick : Sound? = nil

  def load
    ttf = ["/System/Library/Fonts/Menlo.ttc", "/System/Library/Fonts/Monaco.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"].find { |p| File.exists?(p) }
    @font = Font.load(ttf, 18) if ttf && !ttf.ends_with?(".ttc")
    @snd_hit = Sound.tone(150, 0.08, Sound::Wave::Saw, 0.25)
    @snd_pick = Sound.tone(900, 0.06, Sound::Wave::Sine, 0.25)
    Input.map "left", Key::Left, Key::A, Key::H, Input.axis(GamepadAxis::LeftX, -1)
    Input.map "right", Key::Right, Key::D, Key::L, Input.axis(GamepadAxis::LeftX, 1)
    Input.map "up", Key::Up, Key::W, Key::K, Input.axis(GamepadAxis::LeftY, -1)
    Input.map "down", Key::Down, Key::S, Key::J, Input.axis(GamepadAxis::LeftY, 1)
    Input.map "wait", Key::Space, Key::Period, GamepadButton::B
    Input.map "descend", Key::Period, Key::Enter, GamepadButton::A
    if ENV["EAGLE_DEMO"]? == "1"
      # explore a bit for screenshots: walk in the direction with most open floor
      @game = Rogue::Game.new(4)
      40.times { |i| Script.at(0.05 * i) { auto_step } }
    end
  end

  private def auto_step
    d = @game.dungeon; p = @game.player
    best = {1, 0}; best_score = -1
    [{1, 0}, {-1, 0}, {0, 1}, {0, -1}].each do |(dx, dy)|
      score = 0
      (1..6).each { |k| break unless d.walkable?(p.x + dx * k, p.y + dy * k); score += d.explored?(p.x + dx * k, p.y + dy * k) ? 1 : 3 }
      if score > best_score
        best_score = score; best = {dx, dy}
      end
    end
    @game.move_player(best[0], best[1]) || @game.wait_turn
  end

  def update(dt : Float32)
    @flash = Math.max(0_f32, @flash - dt)
    before = @game.messages.size
    moved = false
    moved = @game.move_player(-1, 0) if Input.pressed?("left")
    moved = @game.move_player(1, 0) if Input.pressed?("right")
    moved = @game.move_player(0, -1) if Input.pressed?("up")
    moved = @game.move_player(0, 1) if Input.pressed?("down")
    @game.wait_turn if Input.pressed?("wait") && !@game.game_over?
    if Input.pressed?("descend") && Input.shift? || Input.pressed?(Key::Enter)
      @game.descend
    end
    if @game.messages.size > before
      msg = @game.messages.last
      if msg.includes?("hits you")
        @flash = 0.2_f32; @snd_hit.try(&.play)
      elsif msg.includes?("pick up") || msg.includes?("found") || msg.includes?("potion")
        @snd_pick.try(&.play)
      end
    end
    @game = Rogue::Game.new((Random.rand * 10000).to_i) if Input.pressed?(Key::R)
    Eagle.quit if Input.pressed?(Key::Escape)
  end

  def draw(g : Graphics)
    d = @game.dungeon
    ox = 10; oy = 40
    f = @font || g.font
    d.height.times do |y|
      d.width.times do |x|
        next unless d.explored?(x, y)
        vis = d.visible?(x, y)
        t = d[x, y]
        color = case t
                in Rogue::Tile::Wall then Color.hex("#5b6b8a")
                in Rogue::Tile::Floor then Color.hex("#2e3448")
                in Rogue::Tile::Door then Color.hex("#a0703c")
                in Rogue::Tile::StairsDown then Color.hex("#e0c060")
                end
        color = color.darken(0.55) unless vis
        px = ox + x * CELL; py = oy + y * CELL
        case t
        in Rogue::Tile::Wall then g.rect(px, py, CELL, CELL, color: color)
        in Rogue::Tile::Floor then g.rect(px, py, CELL, CELL, color: color); g.rect(px + CELL / 2 - 1, py + CELL / 2 - 1, 2, 2, color: color.lighten(0.2))
        in Rogue::Tile::Door then g.rect(px, py, CELL, CELL, color: Color.hex("#2e3448")); g.rect(px + 4, py + 2, CELL - 8, CELL - 4, color: color)
        in Rogue::Tile::StairsDown then g.rect(px, py, CELL, CELL, color: Color.hex("#2e3448")); g.print(">", px + CELL / 2, py + 2, color, f, align: TextAlign::Center)
        end
      end
    end
    @game.items.each do |it|
      next unless d.visible?(it.x, it.y)
      c = it.kind.gold? ? Color::YELLOW : it.kind.potion? ? Color.hex("#ff6b9d") : Color.hex("#9ad0ff")
      g.print(it.glyph.to_s, ox + it.x * CELL + CELL / 2, oy + it.y * CELL + 2, c, f, align: TextAlign::Center)
    end
    @game.monsters.each do |m|
      next unless d.visible?(m.x, m.y)
      c = {'r' => Color.hex("#c9a27e"), 'g' => Color.hex("#7bd88f"), 'o' => Color.hex("#e07a5f"), 'T' => Color.hex("#c77dff")}[m.glyph]? || Color::WHITE
      g.print(m.glyph.to_s, ox + m.x * CELL + CELL / 2, oy + m.y * CELL + 2, c, f, align: TextAlign::Center)
      g.rect(ox + m.x * CELL + 2, oy + m.y * CELL + CELL - 3, (CELL - 4) * m.hp / m.max_hp, 2, color: Color::RED)
    end
    p = @game.player
    g.print("@", ox + p.x * CELL + CELL / 2, oy + p.y * CELL + 2, Color::WHITE, f, align: TextAlign::Center)
    # HUD
    g.rect(0, 0, Window.width, 32, color: Color.hex("#12141c"))
    g.print("HP #{p.hp}/#{p.max_hp}  ATK #{p.attack}  DEF #{p.defense}  Lv #{@game.char_level} (xp #{@game.xp})  Gold #{@game.gold}  Dungeon #{@game.level}/#{Rogue::Game::MAX_LEVEL}  Turn #{@game.turns}", 10, 8, Color::WHITE)
    g.rect(Window.width - 210, 8, 200, 16, color: Color.gray(0.2))
    g.rect(Window.width - 210, 8, 200 * p.hp / p.max_hp, 16, color: p.hp > p.max_hp / 3 ? Color::GREEN : Color::RED)
    # messages
    my = oy + d.height * CELL + 6
    @game.messages.last(5).each_with_index do |m, i|
      g.print(m, 10, my + i * 18, Color.gray(0.55 + i * 0.1))
    end
    g.print("arrows/WASD/hjkl move - space wait - Enter descends on > - R restart", Window.width - 10, Window.height - 20, Color.gray(0.4), align: TextAlign::Right)
    g.rect(0, 0, Window.width, Window.height, color: Color::RED.with_alpha(@flash * 1.5)) if @flash > 0
    if @game.game_over?
      g.rect(0, 0, Window.width, Window.height, color: Color.new(0, 0, 0, 0.6))
      g.print(@game.won? ? "YOU ESCAPED!" : "YOU DIED", Window.width / 2, Window.height / 2 - 30, @game.won? ? Color::YELLOW : Color::RED, scale: 2, align: TextAlign::Center)
      g.print("press R for a new dungeon", Window.width / 2, Window.height / 2 + 20, Color::WHITE, align: TextAlign::Center)
    end
  end
end

Eagle.run(RoguelikeApp, title: "Eagle Roguelike", width: 60 * RoguelikeApp::CELL + 20, height: 34 * RoguelikeApp::CELL + 160)

examples/roguelike/dungeon.cr

# Roguelike core: procedural dungeon, shadowcasting FOV, turn-based combat.
# Independent of rendering so it can be unit tested.
module Rogue
  enum Tile : UInt8
    Wall
    Floor
    Door
    StairsDown
    def walkable? : Bool; !wall?; end
    def opaque? : Bool; wall? || door?; end
  end

  record Room, x : Int32, y : Int32, w : Int32, h : Int32 do
    def center : {Int32, Int32}; {x + w // 2, y + h // 2}; end
    def intersects?(o : Room, pad = 1) : Bool
      x - pad < o.x + o.w && x + w + pad > o.x && y - pad < o.y + o.h && y + h + pad > o.y
    end
  end

  class Entity
    property name : String
    property x : Int32
    property y : Int32
    property hp : Int32
    property max_hp : Int32
    property attack : Int32
    property defense : Int32
    property glyph : Char
    property? player : Bool
    property xp_value : Int32 = 0
    property sight : Int32 = 6

    def initialize(@name, @x, @y, @hp, @attack, @defense, @glyph, @player = false, @xp_value = 0)
      @max_hp = @hp
    end

    def alive? : Bool; @hp > 0; end
    def pos : {Int32, Int32}; {@x, @y}; end
  end

  class Item
    enum Kind
      Potion
      Gold
      Sword
      Shield
    end
    property kind : Kind
    property x : Int32
    property y : Int32
    property value : Int32
    def initialize(@kind, @x, @y, @value = 1); end
    def glyph : Char
      case @kind
      in Kind::Potion then '!'
      in Kind::Gold then '$'
      in Kind::Sword then '/'
      in Kind::Shield then ']'
      end
    end
  end

  class Dungeon
    getter width : Int32
    getter height : Int32
    getter tiles : Array(Tile)
    getter rooms = [] of Room
    getter explored : Array(Bool)
    getter visible : Array(Bool)
    getter rng : Random

    def initialize(@width, @height, seed : Int32 = 1, room_attempts = 40)
      @rng = Random.new(seed)
      @tiles = Array(Tile).new(@width * @height, Tile::Wall)
      @explored = Array(Bool).new(@width * @height, false)
      @visible = Array(Bool).new(@width * @height, false)
      generate(room_attempts)
    end

    def [](x : Int32, y : Int32) : Tile
      in_bounds?(x, y) ? @tiles[y * @width + x] : Tile::Wall
    end

    def []=(x : Int32, y : Int32, t : Tile); @tiles[y * @width + x] = t; end
    def in_bounds?(x, y) : Bool; x >= 0 && y >= 0 && x < @width && y < @height; end
    def walkable?(x, y) : Bool; self[x, y].walkable?; end
    def visible?(x, y) : Bool; in_bounds?(x, y) && @visible[y * @width + x]; end
    def explored?(x, y) : Bool; in_bounds?(x, y) && @explored[y * @width + x]; end

    private def generate(attempts)
      attempts.times do
        w = @rng.rand(4..10); h = @rng.rand(3..7)
        x = @rng.rand(1..(@width - w - 2)); y = @rng.rand(1..(@height - h - 2))
        room = Room.new(x, y, w, h)
        next if @rooms.any? { |r| r.intersects?(room) }
        carve(room)
        if last = @rooms.last?
          corridor(last.center, room.center)
        end
        @rooms << room
      end
      # doors where corridors meet room walls, stairs in the last room
      cx, cy = @rooms.last.center
      self[cx, cy] = Tile::StairsDown
      place_doors
    end

    private def carve(r : Room)
      (r.y...r.y + r.h).each { |yy| (r.x...r.x + r.w).each { |xx| self[xx, yy] = Tile::Floor } }
    end

    private def corridor(a, b)
      x, y = a
      tx, ty = b
      if @rng.rand < 0.5
        until x == tx; self[x, y] = Tile::Floor if self[x, y].wall?; x += x < tx ? 1 : -1; end
        until y == ty; self[x, y] = Tile::Floor if self[x, y].wall?; y += y < ty ? 1 : -1; end
      else
        until y == ty; self[x, y] = Tile::Floor if self[x, y].wall?; y += y < ty ? 1 : -1; end
        until x == tx; self[x, y] = Tile::Floor if self[x, y].wall?; x += x < tx ? 1 : -1; end
      end
      self[x, y] = Tile::Floor if self[x, y].wall?
    end

    private def place_doors
      @rooms.each do |r|
        # scan the room's outline for corridor openings
        (r.x - 1..r.x + r.w).each do |xx|
          [r.y - 1, r.y + r.h].each do |yy|
            self[xx, yy] = Tile::Door if self[xx, yy].floor? && corridor_cell?(xx, yy) && @rng.rand < 0.6
          end
        end
        (r.y - 1..r.y + r.h).each do |yy|
          [r.x - 1, r.x + r.w].each do |xx|
            self[xx, yy] = Tile::Door if self[xx, yy].floor? && corridor_cell?(xx, yy) && @rng.rand < 0.6
          end
        end
      end
    end

    private def corridor_cell?(x, y) : Bool
      h = self[x - 1, y].wall? && self[x + 1, y].wall?
      v = self[x, y - 1].wall? && self[x, y + 1].wall?
      h || v
    end

    # All walkable tiles reachable from (x, y) via 4-neighbour moves.
    def reachable_from(x, y) : Set({Int32, Int32})
      seen = Set({Int32, Int32}).new
      stack = [{x, y}]
      while p = stack.pop?
        next if seen.includes?(p)
        seen << p
        px, py = p
        [{1, 0}, {-1, 0}, {0, 1}, {0, -1}].each do |(dx, dy)|
          nx = px + dx; ny = py + dy
          stack << {nx, ny} if walkable?(nx, ny) && !seen.includes?({nx, ny})
        end
      end
      seen
    end

    def walkable_count : Int32; @tiles.count(&.walkable?); end

    # Symmetric shadowcasting FOV (recursive, 8 octants).
    def compute_fov(ox, oy, radius) : Nil
      @visible.fill(false)
      mark_visible(ox, oy)
      8.times { |oct| cast_light(ox, oy, radius, 1, 1.0, 0.0, oct) }
    end

    private def mark_visible(x, y)
      return unless in_bounds?(x, y)
      @visible[y * @width + x] = true
      @explored[y * @width + x] = true
    end

    MULT = [[1, 0, 0, -1, -1, 0, 0, 1], [0, 1, -1, 0, 0, -1, 1, 0], [0, 1, 1, 0, 0, -1, -1, 0], [1, 0, 0, 1, -1, 0, 0, -1]]

    private def cast_light(cx, cy, radius, row, start : Float64, finish : Float64, oct)
      return if start < finish
      xx = MULT[0][oct]; xy = MULT[1][oct]; yx = MULT[2][oct]; yy = MULT[3][oct]
      r2 = radius * radius
      (row..radius).each do |j|
        dx = -j - 1; dy = -j
        blocked = false
        new_start = start
        while dx <= 0
          dx += 1
          x = cx + dx * xx + dy * xy
          y = cy + dx * yx + dy * yy
          l_slope = (dx - 0.5) / (dy + 0.5)
          r_slope = (dx + 0.5) / (dy - 0.5)
          if start < r_slope
            next
          elsif finish > l_slope
            break
          end
          mark_visible(x, y) if dx * dx + dy * dy < r2
          if blocked
            if self[x, y].opaque?
              new_start = r_slope
              next
            else
              blocked = false
              start = new_start
            end
          elsif self[x, y].opaque? && j < radius
            blocked = true
            cast_light(cx, cy, radius, j + 1, start, l_slope, oct)
            new_start = r_slope
          end
        end
        break if blocked
      end
    end

    # Simple BFS path (4-neighbour) avoiding `blocked` cells; returns the next step or nil.
    def next_step(from : {Int32, Int32}, to : {Int32, Int32}, blocked : Set({Int32, Int32}) = Set({Int32, Int32}).new) : {Int32, Int32}?
      return nil if from == to
      prev = {} of {Int32, Int32} => {Int32, Int32}
      queue = Deque{from}
      seen = Set{from}
      found = false
      while (cur = queue.shift?)
        if cur == to
          found = true
          break
        end
        cx, cy = cur
        [{1, 0}, {-1, 0}, {0, 1}, {0, -1}].each do |(dx, dy)|
          n = {cx + dx, cy + dy}
          next if seen.includes?(n) || !walkable?(n[0], n[1]) || (blocked.includes?(n) && n != to)
          seen << n
          prev[n] = cur
          queue << n
        end
        break if seen.size > 4000
      end
      return nil unless found
      step = to
      while prev[step]? != from
        step = prev[step]? || return nil
      end
      step
    end
  end

  class Game
    getter dungeon : Dungeon
    getter player : Entity
    getter monsters = [] of Entity
    getter items = [] of Item
    getter messages = [] of String
    getter level = 1
    getter turns = 0
    getter gold = 0
    getter xp = 0
    getter char_level = 1
    getter? game_over = false
    getter? won = false
    getter rng : Random
    property seed : Int32

    MAX_LEVEL = 5

    def initialize(@seed : Int32 = 1, width = 60, height = 34)
      @rng = Random.new(@seed)
      @width = width; @height = height
      @dungeon = Dungeon.new(width, height, @seed)
      sx, sy = @dungeon.rooms.first.center
      @player = Entity.new("You", sx, sy, 20, 4, 1, '@', true)
      populate
      log("Welcome to the dungeon. Find the stairs (>) on level #{MAX_LEVEL}.")
      @dungeon.compute_fov(@player.x, @player.y, @player.sight)
    end

    def log(msg : String)
      @messages << msg
      @messages.shift if @messages.size > 50
    end

    private def populate
      @monsters.clear; @items.clear
      @dungeon.rooms.each_with_index do |room, i|
        next if i == 0
        count = @rng.rand(0..(1 + @level // 2))
        count.times do
          x = @rng.rand(room.x...room.x + room.w); y = @rng.rand(room.y...room.y + room.h)
          next if @monsters.any? { |m| m.x == x && m.y == y }
          kind = @rng.rand(0..2) + (@level >= 3 ? 1 : 0)
          m = case kind
              when 0 then Entity.new("rat", x, y, 4, 2, 0, 'r', xp_value: 3)
              when 1 then Entity.new("goblin", x, y, 7, 3, 1, 'g', xp_value: 6)
              when 2 then Entity.new("orc", x, y, 12, 5, 1, 'o', xp_value: 12)
              else Entity.new("troll", x, y, 20, 7, 2, 'T', xp_value: 25)
              end
          @monsters << m
        end
        if @rng.rand < 0.7
          x = @rng.rand(room.x...room.x + room.w); y = @rng.rand(room.y...room.y + room.h)
          r = @rng.rand
          @items << (r < 0.45 ? Item.new(Item::Kind::Gold, x, y, @rng.rand(5..25)) : r < 0.85 ? Item.new(Item::Kind::Potion, x, y, 8) : r < 0.93 ? Item.new(Item::Kind::Sword, x, y, 1) : Item.new(Item::Kind::Shield, x, y, 1))
        end
      end
    end

    def monster_at(x, y) : Entity?; @monsters.find { |m| m.alive? && m.x == x && m.y == y }; end
    def item_at(x, y) : Item?; @items.find { |i| i.x == x && i.y == y }; end

    # Player action: move/attack in a direction. Returns true if a turn passed.
    def move_player(dx : Int32, dy : Int32) : Bool
      return false if @game_over
      nx = @player.x + dx; ny = @player.y + dy
      if m = monster_at(nx, ny)
        attack(@player, m)
      elsif @dungeon.walkable?(nx, ny)
        @player.x = nx; @player.y = ny
        pick_up
      else
        return false
      end
      end_turn
      true
    end

    def wait_turn : Nil
      end_turn
    end

    def descend : Bool
      return false unless @dungeon[@player.x, @player.y].stairs_down?
      @level += 1
      if @level > MAX_LEVEL
        @won = true; @game_over = true
        log("You escape the dungeon with #{@gold} gold. You win!")
        return true
      end
      @dungeon = Dungeon.new(@width, @height, @seed * 31 + @level)
      sx, sy = @dungeon.rooms.first.center
      @player.x = sx; @player.y = sy
      populate
      log("You descend to level #{@level}.")
      @dungeon.compute_fov(@player.x, @player.y, @player.sight)
      true
    end

    private def pick_up
      if item = item_at(@player.x, @player.y)
        @items.delete(item)
        case item.kind
        in Item::Kind::Gold then @gold += item.value; log("You pick up #{item.value} gold.")
        in Item::Kind::Potion
          heal = Math.min(item.value, @player.max_hp - @player.hp)
          @player.hp += heal
          log("You drink a potion and heal #{heal}.")
        in Item::Kind::Sword then @player.attack += 2; log("You found a sword! Attack +2.")
        in Item::Kind::Shield then @player.defense += 1; log("You found a shield! Defense +1.")
        end
      end
    end

    def attack(a : Entity, d : Entity) : Nil
      dmg = Math.max(0, a.attack + @rng.rand(0..2) - d.defense)
      d.hp -= dmg
      if a.player?
        log(dmg > 0 ? "You hit the #{d.name} for #{dmg}." : "You miss the #{d.name}.")
        if !d.alive?
          log("The #{d.name} dies.")
          gain_xp(d.xp_value)
        end
      else
        log(dmg > 0 ? "The #{a.name} hits you for #{dmg}." : "The #{a.name} misses.")
        if !d.alive?
          @game_over = true
          log("You die on level #{@level} after #{@turns} turns.")
        end
      end
    end

    private def gain_xp(n)
      @xp += n
      while @xp >= @char_level * 10
        @xp -= @char_level * 10
        @char_level += 1
        @player.max_hp += 5; @player.hp = @player.max_hp; @player.attack += 1
        log("You reach level #{@char_level}!")
      end
    end

    private def end_turn
      @turns += 1
      @monsters.reject! { |m| !m.alive? }
      @dungeon.compute_fov(@player.x, @player.y, @player.sight)
      occupied = @monsters.map(&.pos).to_set
      @monsters.each do |m|
        next if @game_over
        dist = (m.x - @player.x).abs + (m.y - @player.y).abs
        if dist == 1
          attack(m, @player)
        elsif @dungeon.visible?(m.x, m.y) && dist <= 8
          occupied.delete(m.pos)
          if step = @dungeon.next_step(m.pos, @player.pos, occupied)
            if step != @player.pos
              m.x, m.y = step
            end
          end
          occupied << m.pos
        elsif @rng.rand < 0.3
          dx, dy = [{1, 0}, {-1, 0}, {0, 1}, {0, -1}][@rng.rand(4)]
          nx = m.x + dx; ny = m.y + dy
          if @dungeon.walkable?(nx, ny) && !occupied.includes?({nx, ny}) && {nx, ny} != @player.pos
            occupied.delete(m.pos); m.x = nx; m.y = ny; occupied << m.pos
          end
        end
      end
      # regenerate slowly
      @player.hp = Math.min(@player.max_hp, @player.hp + 1) if @turns % 10 == 0 && !@game_over
    end
  end
end