From e42b31ec247737dc70c2d6d64f50d1602331ebfb Mon Sep 17 00:00:00 2001 From: Daanish Date: Sat, 28 Mar 2026 16:43:37 +0000 Subject: [PATCH] Refactor character and enemy AI movement logic; implement delta time for smoother updates and replace object (a reserved keyword) with prop. --- Gemfile | 4 + game/character.rb | 21 +++--- game/enemy/ai.rb | 30 ++++---- game/enemy/base.rb | 4 +- game/enemy/handler.rb | 4 +- game/placeables/base.rb | 159 --------------------------------------- game/placeables/traps.rb | 0 game/props/base.rb | 74 ++++++++++++++++++ game/props/handler.rb | 86 +++++++++++++++++++++ game/scene.rb | 31 ++++---- main.rb | 7 +- menu/scene.rb | 2 +- utils/scene.rb | 2 +- 13 files changed, 219 insertions(+), 205 deletions(-) create mode 100644 Gemfile delete mode 100644 game/placeables/base.rb delete mode 100644 game/placeables/traps.rb create mode 100644 game/props/base.rb create mode 100644 game/props/handler.rb diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..351479b --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source "https://rubygems.org" + +gem "gosu" +gem "perlin" \ No newline at end of file diff --git a/game/character.rb b/game/character.rb index 5c5bf33..fa45a87 100644 --- a/game/character.rb +++ b/game/character.rb @@ -1,7 +1,7 @@ class Character attr_reader :world_x, :world_y - SPEED = 3 + SPEED = 180.0 SIZE = [25, 30] def initialize @@ -56,7 +56,7 @@ class Character false end - def update + def update(dt) dx = 0.0 dy = 0.0 @@ -89,23 +89,26 @@ class Character end # Apply speed - dx *= SPEED - dy *= SPEED + dx *= SPEED * dt + dy *= SPEED * dt - steps = SPEED.ceil - step_dx = dx / steps.to_f - step_dy = dy / steps.to_f + distance = Math.sqrt(dx * dx + dy * dy) + steps = (distance / 3.0).ceil + steps = 1 if steps < 1 + + step_dx = dx / steps + step_dy = dy / steps steps.times do # X axis @world_x += step_dx - if ($bus.get_all(:collides?, rect) & [:wall, :object])&.any? + if ($bus.get_all(:collides?, rect) & [:wall, :prop])&.any? @world_x -= step_dx end # Y axis @world_y += step_dy - if ($bus.get_all(:collides?, rect) & [:wall, :object])&.any? + if ($bus.get_all(:collides?, rect) & [:wall, :prop])&.any? @world_y -= step_dy end end diff --git a/game/enemy/ai.rb b/game/enemy/ai.rb index 0bcb48e..9f0cc52 100644 --- a/game/enemy/ai.rb +++ b/game/enemy/ai.rb @@ -1,6 +1,6 @@ class EnemyAI AGGRO_RADIUS = 2000 - SPEED = 2 + SPEED = 120.0 CORNER_OFFSET = 35 # how far from tile center to place corner waypoint def initialize(enemy, type) @@ -12,7 +12,7 @@ class EnemyAI @avoiding_obstacle = false end - def update(player_x, player_y) + def update(player_x, player_y, dt) distance = Gosu.distance(@enemy.x, @enemy.y, player_x, player_y) return unless distance < AGGRO_RADIUS @@ -21,14 +21,14 @@ class EnemyAI return if $bus.get_all(:collides?, @enemy.rect)&.include?(:character) - object_in_tile = $bus.get(:object_at, enemy_tile[0], enemy_tile[1]) + prop_in_tile = $bus.get(:prop_at, enemy_tile[0], enemy_tile[1]) if player_tile == enemy_tile - los_blocked = !object_in_tile.nil? && !( - line_of_sight?(@enemy.x - @enemy.w / 2, @enemy.y - @enemy.h / 2, player_x, player_y, object_in_tile.rect) && - line_of_sight?(@enemy.x + @enemy.w / 2, @enemy.y + @enemy.h / 2, player_x, player_y, object_in_tile.rect) && - line_of_sight?(@enemy.x - @enemy.w / 2, @enemy.y + @enemy.h / 2, player_x, player_y, object_in_tile.rect) && - line_of_sight?(@enemy.x + @enemy.w / 2, @enemy.y - @enemy.h / 2, player_x, player_y, object_in_tile.rect) + los_blocked = !prop_in_tile.nil? && !( + line_of_sight?(@enemy.x - @enemy.w / 2, @enemy.y - @enemy.h / 2, player_x, player_y, prop_in_tile.rect) && + line_of_sight?(@enemy.x + @enemy.w / 2, @enemy.y + @enemy.h / 2, player_x, player_y, prop_in_tile.rect) && + line_of_sight?(@enemy.x - @enemy.w / 2, @enemy.y + @enemy.h / 2, player_x, player_y, prop_in_tile.rect) && + line_of_sight?(@enemy.x + @enemy.w / 2, @enemy.y - @enemy.h / 2, player_x, player_y, prop_in_tile.rect) ) if los_blocked @@ -48,7 +48,7 @@ class EnemyAI @avoiding_obstacle = true end else - move_towards(player_x, player_y) + move_towards(player_x, player_y, dt) return end end @@ -56,7 +56,7 @@ class EnemyAI if @waypoints.empty? || @last_player_tile != player_tile @last_player_tile = player_tile @tile_path = $bus.get(:maze_solve, enemy_tile[0], enemy_tile[1], player_tile[0], player_tile[1]) || [] - if $bus.get(:object_at, enemy_tile[0], enemy_tile[1]).nil? + if $bus.get(:prop_at, enemy_tile[0], enemy_tile[1]).nil? @tile_path.shift end @waypoints = build_waypoints(@tile_path) @@ -72,7 +72,7 @@ class EnemyAI return end - move_towards(target_x, target_y) + move_towards(target_x, target_y, dt) end def draw @@ -92,7 +92,7 @@ class EnemyAI center_x = tx * 120 + 90 center_y = ty * 120 + 90 - unless $bus.get(:object_at, tx, ty).nil? + unless $bus.get(:prop_at, tx, ty).nil? next_tile = tile_path[i + 1] prev_tile = i > 0 ? tile_path[i - 1] : nil @@ -126,10 +126,10 @@ class EnemyAI waypoints end - def move_towards(target_x, target_y) + def move_towards(target_x, target_y, dt) angle = Gosu.angle(@enemy.x, @enemy.y, target_x, target_y) - dx = Gosu.offset_x(angle, SPEED) - dy = Gosu.offset_y(angle, SPEED) + dx = Gosu.offset_x(angle, SPEED * dt) + dy = Gosu.offset_y(angle, SPEED * dt) @enemy.x += dx @enemy.y += dy diff --git a/game/enemy/base.rb b/game/enemy/base.rb index d235359..6e85c23 100644 --- a/game/enemy/base.rb +++ b/game/enemy/base.rb @@ -25,9 +25,9 @@ class Enemy false end - def update + def update(dt) player_pos = $bus.get(:player_position) - @ai.update(*player_pos) if player_pos + @ai.update(*player_pos, dt) if player_pos end def draw diff --git a/game/enemy/handler.rb b/game/enemy/handler.rb index de7ddd2..277f0a6 100644 --- a/game/enemy/handler.rb +++ b/game/enemy/handler.rb @@ -5,9 +5,9 @@ class EnemyHandler @enemies = [] end - def update + def update(dt) spawn! - @enemies.each(&:update) + @enemies.each { |enemy| enemy.update(dt) } end def spawn! diff --git a/game/placeables/base.rb b/game/placeables/base.rb deleted file mode 100644 index 3e4f4d0..0000000 --- a/game/placeables/base.rb +++ /dev/null @@ -1,159 +0,0 @@ -class Placeable - SIZE = [25, 15] - - # The base class for all placeables in the game. Placeables are things that can be interacted with, but do not move (unlike characters). - # They cannot be walked through, but can be interacted with (e.g. a chest can be opened, a torch can be lit, a radar can be used). - # They are placed at the center of a tile, and their position is represented by the tile coordinate - # An object is one that is placed at the center of a tile (radar, torch & chest) - attr_reader :x, :y - - def initialize(x, y) - @x = x - @y = y - @frames_count = 0 - @spritesheet = nil - end - - def collides?(rect) - return true if intersects?(self.rect, rect) - false - end - - def rect - [@x * 120 + 90 - SIZE[0] / 2, @y * 120 + 90 - SIZE[1], *SIZE] - end - - def update - # For now, placeables don't have any behavior - end - - def draw - return unless @spritesheet - - cam_x, cam_y = $bus.get(:camera_pos) || [0, 0] - screen_x = @x * 120 + 90 - cam_x - screen_y = @y * 120 + 90 - cam_y - - elapsed = Gosu.milliseconds / 1000.0 - frame_index = ((elapsed * @frames_count).floor) % @frames_count - - @spritesheet[frame_index].draw(screen_x - 20, screen_y - 20, screen_y, 2, 2) - - return unless DEBUG - - # collision box centered on same point - Gosu.draw_rect(screen_x - SIZE[0] / 2, screen_y - SIZE[1], *SIZE, Gosu::Color.new(0x88ff0000), Float::INFINITY) - end -end - -class Radar < Placeable - def initialize(x, y) - super(x, y) - @frames_count = 4 - @spritesheet = Gosu::Image.load_tiles("assets/images/radar.png", 20, 20, retro: true) - end -end - -class Torch < Placeable - def initialize(x, y) - super(x, y) - @frames_count = 4 - @spritesheet = Gosu::Image.load_tiles("assets/images/torch.png", 20, 20, retro: true) - end -end - -class Chest < Placeable - def initialize(x, y) - super(x, y) - @wood = rand(0..64) - @metal = rand(0..16) - @science = rand(0..4) - @frames_count = 1 - @spritesheet = Gosu::Image.load_tiles("assets/images/chest.png", 20, 20, retro: true) - end -end - -class ObjectHandler - attr_reader :objects - - def initialize - @objects = [] - @grid = {} - - spawn_chests! - - $bus.on(:collides?) do |rect| - nearby_objects(rect).any? { |obj| obj.collides?(rect) } ? :object : nil - end - - $bus.on(:object_at) do |x, y| - @grid[[x, y]] - end - - $bus.on(:nearby_torches) do |rect| - nearby_objects(rect).select { |obj| obj.is_a?(Torch) }.map { |t| t.rect } - end - end - - def spawn_chests! - world_size = $bus.get(:maze_size) || [0, 0] - - cell_size = 10 - - (0...world_size[0]).step(cell_size) do |cx| - (0...world_size[1]).step(cell_size) do |cy| - - # Random position inside this cell - x = cx + rand(cell_size) - y = cy + rand(cell_size) - - next if x >= world_size[0] || y >= world_size[1] - - # Keep your spawn exclusion - next if (x - 2).abs <= 1 && (y - 2).abs <= 1 - - next if $bus.get(:room?, x, y) - - chest = Chest.new(x, y) - @objects << chest - @grid[[x, y]] = chest - end - end - end - - def nearby_objects(rect) - x, y, w, h = rect - - min_tx = ((x) / 120).floor - max_tx = ((x + w) / 120).floor - min_ty = ((y) / 120).floor - max_ty = ((y + h) / 120).floor - - results = [] - - (min_tx..max_tx).each do |tx| - (min_ty..max_ty).each do |ty| - obj = @grid[[tx, ty]] - results << obj if obj - end - end - - results - end - - def add(object) - @objects << object - - key = [object.x, object.y] - @grid[key] = object - end - - def update - @objects.each(&:update) - end - - def draw - cam = $bus.get(:camera_pos) || [0, 0] - nearby_objects([*cam, *SCREEN_SIZE]).each(&:draw) - end -end \ No newline at end of file diff --git a/game/placeables/traps.rb b/game/placeables/traps.rb deleted file mode 100644 index e69de29..0000000 diff --git a/game/props/base.rb b/game/props/base.rb new file mode 100644 index 0000000..cf1f9cb --- /dev/null +++ b/game/props/base.rb @@ -0,0 +1,74 @@ +class Prop + SIZE = [25, 15] + + # The base class for all props in the game. Props are things that can be interacted with, but do not move (unlike characters). + # They cannot be walked through, but can be interacted with (e.g. a chest can be opened, a torch can be lit, a radar can be used). + # They are placed at the center of a tile, and their position is represented by the tile coordinate + # A prop is one that is placed at the center of a tile (radar, torch & chest) + attr_reader :x, :y + + def initialize(x, y) + @x = x + @y = y + @frames_count = 0 + @spritesheet = nil + end + + def collides?(rect) + return true if intersects?(self.rect, rect) + false + end + + def rect + [@x * 120 + 90 - SIZE[0] / 2, @y * 120 + 90 - SIZE[1], *SIZE] + end + + def update + # For now, placeables don't have any behavior + end + + def draw + return unless @spritesheet + + cam_x, cam_y = $bus.get(:camera_pos) || [0, 0] + screen_x = @x * 120 + 90 - cam_x + screen_y = @y * 120 + 90 - cam_y + + elapsed = Gosu.milliseconds / 1000.0 + frame_index = ((elapsed * @frames_count).floor) % @frames_count + + @spritesheet[frame_index].draw(screen_x - 20, screen_y - 20, screen_y, 2, 2) + + return unless DEBUG + + # collision box centered on same point + Gosu.draw_rect(screen_x - SIZE[0] / 2, screen_y - SIZE[1], *SIZE, Gosu::Color.new(0x88ff0000), Float::INFINITY) + end +end + +class Radar < Prop + def initialize(x, y) + super(x, y) + @frames_count = 4 + @spritesheet = Gosu::Image.load_tiles("assets/images/radar.png", 20, 20, retro: true) + end +end + +class Torch < Prop + def initialize(x, y) + super(x, y) + @frames_count = 4 + @spritesheet = Gosu::Image.load_tiles("assets/images/torch.png", 20, 20, retro: true) + end +end + +class Chest < Prop + def initialize(x, y) + super(x, y) + @wood = rand(0..64) + @metal = rand(0..16) + @science = rand(0..4) + @frames_count = 1 + @spritesheet = Gosu::Image.load_tiles("assets/images/chest.png", 20, 20, retro: true) + end +end \ No newline at end of file diff --git a/game/props/handler.rb b/game/props/handler.rb new file mode 100644 index 0000000..918426d --- /dev/null +++ b/game/props/handler.rb @@ -0,0 +1,86 @@ +require_relative "base" + +class PropsHandler + attr_reader :props + + def initialize + @props = [] + @grid = {} + + spawn_chests! + + $bus.on(:collides?) do |rect| + nearby_props(rect).any? { |p| p.collides?(rect) } ? :prop : nil + end + + $bus.on(:prop_at) do |x, y| + @grid[[x, y]] + end + + $bus.on(:nearby_torches) do |rect| + nearby_props(rect).select { |p| p.is_a?(Torch) }.map { |t| t.rect } + end + end + + def spawn_chests! + world_size = $bus.get(:maze_size) || [0, 0] + + cell_size = 10 + + (0...world_size[0]).step(cell_size) do |cx| + (0...world_size[1]).step(cell_size) do |cy| + + # Random position inside this cell + x = cx + rand(cell_size) + y = cy + rand(cell_size) + + next if x >= world_size[0] || y >= world_size[1] + + # Keep your spawn exclusion + next if (x - 2).abs <= 1 && (y - 2).abs <= 1 + + next if $bus.get(:room?, x, y) + + chest = Chest.new(x, y) + @props << chest + @grid[[x, y]] = chest + end + end + end + + def nearby_props(rect) + x, y, w, h = rect + + min_tx = ((x) / 120).floor + max_tx = ((x + w) / 120).floor + min_ty = ((y) / 120).floor + max_ty = ((y + h) / 120).floor + + results = [] + + (min_tx..max_tx).each do |tx| + (min_ty..max_ty).each do |ty| + p = @grid[[tx, ty]] + results << p if p + end + end + + results + end + + def add(prop) + @props << prop + + key = [prop.x, prop.y] + @grid[key] = prop + end + + def update + @props.each(&:update) + end + + def draw + cam = $bus.get(:camera_pos) || [0, 0] + nearby_props([*cam, *SCREEN_SIZE]).each(&:draw) + end +end \ No newline at end of file diff --git a/game/scene.rb b/game/scene.rb index 4e5448c..1f56ae4 100644 --- a/game/scene.rb +++ b/game/scene.rb @@ -1,7 +1,7 @@ require_relative 'state' require_relative 'maze' require_relative 'hud' -require_relative 'placeables/base' +require_relative 'props/handler' require_relative 'enemy/handler' require_relative 'character' @@ -17,7 +17,7 @@ class Game < Scene @maze = Maze.new @character = Character.new @enemies = EnemyHandler.new - @objects = ObjectHandler.new + @props = PropsHandler.new @hud = HUD.new @@ -37,7 +37,7 @@ class Game < Scene @maze.draw @character.draw @enemies.draw - @objects.draw + @props.draw @hud.draw draw_fog! draw_debug! if DEBUG @@ -50,11 +50,6 @@ class Game < Scene end def draw_fog! - # This part is a shader but due to time constraints, I'm doing it on the CPU. - # If done on the GPU, we could do a single pass and have much better performance. - # and also make it more fine-grained and smoother gradients. - # doing it on the GPU would require better knowledge of the ruby openGL bindings and shader programming, which I don't have right now. - cam_x, cam_y = @camera cell = 10 p_i_radius = 80.0 @@ -65,16 +60,22 @@ class Game < Scene # Player screen position px = SCREEN_SIZE[0] / 2.0 py = SCREEN_SIZE[1] / 2.0 - + # Collect torch positions relative to camera torches = $bus.get(:nearby_torches, - [cam_x - SCREEN_SIZE[0] / 2, cam_y - SCREEN_SIZE[1] / 2, SCREEN_SIZE[0] * 2, SCREEN_SIZE[1] * 2] + [cam_x - SCREEN_SIZE[0] / 2, cam_y - SCREEN_SIZE[1] / 2, SCREEN_SIZE[0] * 2, SCREEN_SIZE[1] * 2] ) || [] torch_lights = torches.map { |t| [t[0] - cam_x, t[1] - cam_y] } - + tiles_x = (SCREEN_SIZE[0] / cell) + 2 tiles_y = (SCREEN_SIZE[1] / cell) + 2 + # This part is a shader but due to time constraints, I'm doing it on the CPU. + # If done on the GPU, we could have much better performance. + # and also make it more fine-grained and smoother gradients. + # doing it on the GPU would require better knowledge of the ruby openGL bindings and shader programming, + # which I don't have right now. + tiles_y.times do |j| tiles_x.times do |i| sx = i * cell @@ -143,10 +144,10 @@ class Game < Scene end end - def update - @character.update - @enemies.update - @objects.update + def update(dt) + @character.update(dt) + @enemies.update(dt) + @props.update end def button_down(id, _pos) diff --git a/main.rb b/main.rb index 0804305..fcdb018 100644 --- a/main.rb +++ b/main.rb @@ -17,6 +17,7 @@ class Window < Gosu::Window self.caption = "Mist" @scene = Menu.new + @last_time = Gosu.milliseconds $bus.on(:quit_game) { close! } @@ -35,7 +36,11 @@ class Window < Gosu::Window end def update - @scene.update + now = Gosu.milliseconds + dt = (now - @last_time) / 1000.0 + @last_time = now + + @scene.update(dt) end def compute_transform diff --git a/menu/scene.rb b/menu/scene.rb index 7b9164c..e60ffa3 100644 --- a/menu/scene.rb +++ b/menu/scene.rb @@ -13,7 +13,7 @@ class Menu < Scene @last_mouse_pos = nil end - def update + def update(_dt) pos = $bus.get(:mouse_pos) return unless pos diff --git a/utils/scene.rb b/utils/scene.rb index 44ce2ff..7b1cb63 100644 --- a/utils/scene.rb +++ b/utils/scene.rb @@ -4,7 +4,7 @@ class Scene def initialize end - def update + def update(_dt) end def draw