Refactor character and enemy AI movement logic; implement delta time for smoother updates and replace object (a reserved keyword) with prop.

This commit is contained in:
2026-03-28 16:43:37 +00:00
parent 95f2be311d
commit e42b31ec24
13 changed files with 219 additions and 205 deletions
+4
View File
@@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "gosu"
gem "perlin"
+12 -9
View File
@@ -1,7 +1,7 @@
class Character class Character
attr_reader :world_x, :world_y attr_reader :world_x, :world_y
SPEED = 3 SPEED = 180.0
SIZE = [25, 30] SIZE = [25, 30]
def initialize def initialize
@@ -56,7 +56,7 @@ class Character
false false
end end
def update def update(dt)
dx = 0.0 dx = 0.0
dy = 0.0 dy = 0.0
@@ -89,23 +89,26 @@ class Character
end end
# Apply speed # Apply speed
dx *= SPEED dx *= SPEED * dt
dy *= SPEED dy *= SPEED * dt
steps = SPEED.ceil distance = Math.sqrt(dx * dx + dy * dy)
step_dx = dx / steps.to_f steps = (distance / 3.0).ceil
step_dy = dy / steps.to_f steps = 1 if steps < 1
step_dx = dx / steps
step_dy = dy / steps
steps.times do steps.times do
# X axis # X axis
@world_x += step_dx @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 @world_x -= step_dx
end end
# Y axis # Y axis
@world_y += step_dy @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 @world_y -= step_dy
end end
end end
+15 -15
View File
@@ -1,6 +1,6 @@
class EnemyAI class EnemyAI
AGGRO_RADIUS = 2000 AGGRO_RADIUS = 2000
SPEED = 2 SPEED = 120.0
CORNER_OFFSET = 35 # how far from tile center to place corner waypoint CORNER_OFFSET = 35 # how far from tile center to place corner waypoint
def initialize(enemy, type) def initialize(enemy, type)
@@ -12,7 +12,7 @@ class EnemyAI
@avoiding_obstacle = false @avoiding_obstacle = false
end 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) distance = Gosu.distance(@enemy.x, @enemy.y, player_x, player_y)
return unless distance < AGGRO_RADIUS return unless distance < AGGRO_RADIUS
@@ -21,14 +21,14 @@ class EnemyAI
return if $bus.get_all(:collides?, @enemy.rect)&.include?(:character) 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 if player_tile == enemy_tile
los_blocked = !object_in_tile.nil? && !( los_blocked = !prop_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, prop_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, prop_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, prop_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, prop_in_tile.rect)
) )
if los_blocked if los_blocked
@@ -48,7 +48,7 @@ class EnemyAI
@avoiding_obstacle = true @avoiding_obstacle = true
end end
else else
move_towards(player_x, player_y) move_towards(player_x, player_y, dt)
return return
end end
end end
@@ -56,7 +56,7 @@ class EnemyAI
if @waypoints.empty? || @last_player_tile != player_tile if @waypoints.empty? || @last_player_tile != player_tile
@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]) || [] @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 @tile_path.shift
end end
@waypoints = build_waypoints(@tile_path) @waypoints = build_waypoints(@tile_path)
@@ -72,7 +72,7 @@ class EnemyAI
return return
end end
move_towards(target_x, target_y) move_towards(target_x, target_y, dt)
end end
def draw def draw
@@ -92,7 +92,7 @@ class EnemyAI
center_x = tx * 120 + 90 center_x = tx * 120 + 90
center_y = ty * 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] next_tile = tile_path[i + 1]
prev_tile = i > 0 ? tile_path[i - 1] : nil prev_tile = i > 0 ? tile_path[i - 1] : nil
@@ -126,10 +126,10 @@ class EnemyAI
waypoints waypoints
end 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) angle = Gosu.angle(@enemy.x, @enemy.y, target_x, target_y)
dx = Gosu.offset_x(angle, SPEED) dx = Gosu.offset_x(angle, SPEED * dt)
dy = Gosu.offset_y(angle, SPEED) dy = Gosu.offset_y(angle, SPEED * dt)
@enemy.x += dx @enemy.x += dx
@enemy.y += dy @enemy.y += dy
+2 -2
View File
@@ -25,9 +25,9 @@ class Enemy
false false
end end
def update def update(dt)
player_pos = $bus.get(:player_position) player_pos = $bus.get(:player_position)
@ai.update(*player_pos) if player_pos @ai.update(*player_pos, dt) if player_pos
end end
def draw def draw
+2 -2
View File
@@ -5,9 +5,9 @@ class EnemyHandler
@enemies = [] @enemies = []
end end
def update def update(dt)
spawn! spawn!
@enemies.each(&:update) @enemies.each { |enemy| enemy.update(dt) }
end end
def spawn! def spawn!
-159
View File
@@ -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
View File
+74
View File
@@ -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
+86
View File
@@ -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
+16 -15
View File
@@ -1,7 +1,7 @@
require_relative 'state' require_relative 'state'
require_relative 'maze' require_relative 'maze'
require_relative 'hud' require_relative 'hud'
require_relative 'placeables/base' require_relative 'props/handler'
require_relative 'enemy/handler' require_relative 'enemy/handler'
require_relative 'character' require_relative 'character'
@@ -17,7 +17,7 @@ class Game < Scene
@maze = Maze.new @maze = Maze.new
@character = Character.new @character = Character.new
@enemies = EnemyHandler.new @enemies = EnemyHandler.new
@objects = ObjectHandler.new @props = PropsHandler.new
@hud = HUD.new @hud = HUD.new
@@ -37,7 +37,7 @@ class Game < Scene
@maze.draw @maze.draw
@character.draw @character.draw
@enemies.draw @enemies.draw
@objects.draw @props.draw
@hud.draw @hud.draw
draw_fog! draw_fog!
draw_debug! if DEBUG draw_debug! if DEBUG
@@ -50,11 +50,6 @@ class Game < Scene
end end
def draw_fog! 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 cam_x, cam_y = @camera
cell = 10 cell = 10
p_i_radius = 80.0 p_i_radius = 80.0
@@ -65,16 +60,22 @@ class Game < Scene
# Player screen position # Player screen position
px = SCREEN_SIZE[0] / 2.0 px = SCREEN_SIZE[0] / 2.0
py = SCREEN_SIZE[1] / 2.0 py = SCREEN_SIZE[1] / 2.0
# Collect torch positions relative to camera # Collect torch positions relative to camera
torches = $bus.get(:nearby_torches, 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] } torch_lights = torches.map { |t| [t[0] - cam_x, t[1] - cam_y] }
tiles_x = (SCREEN_SIZE[0] / cell) + 2 tiles_x = (SCREEN_SIZE[0] / cell) + 2
tiles_y = (SCREEN_SIZE[1] / 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_y.times do |j|
tiles_x.times do |i| tiles_x.times do |i|
sx = i * cell sx = i * cell
@@ -143,10 +144,10 @@ class Game < Scene
end end
end end
def update def update(dt)
@character.update @character.update(dt)
@enemies.update @enemies.update(dt)
@objects.update @props.update
end end
def button_down(id, _pos) def button_down(id, _pos)
+6 -1
View File
@@ -17,6 +17,7 @@ class Window < Gosu::Window
self.caption = "Mist" self.caption = "Mist"
@scene = Menu.new @scene = Menu.new
@last_time = Gosu.milliseconds
$bus.on(:quit_game) { close! } $bus.on(:quit_game) { close! }
@@ -35,7 +36,11 @@ class Window < Gosu::Window
end end
def update def update
@scene.update now = Gosu.milliseconds
dt = (now - @last_time) / 1000.0
@last_time = now
@scene.update(dt)
end end
def compute_transform def compute_transform
+1 -1
View File
@@ -13,7 +13,7 @@ class Menu < Scene
@last_mouse_pos = nil @last_mouse_pos = nil
end end
def update def update(_dt)
pos = $bus.get(:mouse_pos) pos = $bus.get(:mouse_pos)
return unless pos return unless pos
+1 -1
View File
@@ -4,7 +4,7 @@ class Scene
def initialize def initialize
end end
def update def update(_dt)
end end
def draw def draw