Implement enemy AI and handler; add placeable radar object; enhance maze functionality

This commit is contained in:
2026-03-25 22:02:50 +00:00
parent 84004a9f67
commit 83d250f1e9
13 changed files with 478 additions and 24 deletions
+25 -10
View File
@@ -1,13 +1,18 @@
class Character
attr_reader :world_x, :world_y
SPEED = 2.5
SPEED = 3
SIZE = [25, 30]
def initialize
@world_x = 60.0
@world_y = 60.0
start_room_coords = $bus.get(:start_room_coords)
if start_room_coords
@world_x, @world_y = [(start_room_coords[0] + 2) * 60 + 30, (start_room_coords[1] + 2) * 60 + 30]
end
# Load the full sheet as tiles
sheet = Gosu::Image.load_tiles("assets/images/player.png", 20, 40, retro: true)
@@ -31,6 +36,10 @@ class Character
@current_animation = :idle_front
@facing = :front
$bus.on_retrievable(:player_position) do
next [@world_x, @world_y]
end
end
def rect
@@ -79,16 +88,22 @@ class Character
dx *= SPEED
dy *= SPEED
# Horizontal
@world_x += dx
if $bus.get(:collides?, rect)&.include?(:wall)
@world_x -= dx
end
steps = SPEED.ceil
step_dx = dx / steps.to_f
step_dy = dy / steps.to_f
# Vertical
@world_y += dy
if $bus.get(:collides?, rect)&.include?(:wall)
@world_y -= dy
steps.times do
# X axis
@world_x += step_dx
if ($bus.get(:collides?, rect) & [:wall, :object])&.any?
@world_x -= step_dx
end
# Y axis
@world_y += step_dy
if ($bus.get(:collides?, rect) & [:wall, :object])&.any?
@world_y -= step_dy
end
end
# teleport to boss room for debug purposes
+166
View File
@@ -0,0 +1,166 @@
class EnemyAI
AGGRO_RADIUS = 2000
SPEED = 2
CORNER_OFFSET = 35 # how far from tile center to place corner waypoint
def initialize(enemy, type)
@enemy = enemy
@type = type
@tile_path = [] # raw tile coords from solver
@waypoints = [] # actual world positions to move through
@last_player_tile = nil
@avoiding_obstacle = false
end
def update(player_x, player_y)
distance = Gosu.distance(@enemy.x, @enemy.y, player_x, player_y)
return unless distance < AGGRO_RADIUS
player_tile = [(player_x - 90) / 120, (player_y - 90) / 120].map(&:round)
enemy_tile = [(@enemy.x - 90) / 120, (@enemy.y - 90) / 120].map(&:round)
return if $bus.get(:collides?, @enemy.rect)&.include?(:character)
object_in_tile = $bus.get(:object_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)
)
if los_blocked
if !@avoiding_obstacle
center_x = enemy_tile[0] * 120 + 90
center_y = enemy_tile[1] * 120 + 90
corners = [
[center_x - CORNER_OFFSET, center_y - CORNER_OFFSET],
[center_x + CORNER_OFFSET, center_y - CORNER_OFFSET],
[center_x + CORNER_OFFSET, center_y + CORNER_OFFSET],
[center_x - CORNER_OFFSET, center_y + CORNER_OFFSET]
]
closest_idx = corners.each_with_index.min_by { |c, _| Gosu.distance(c[0], c[1], @enemy.x, @enemy.y) }[1]
@waypoints = corners.rotate(closest_idx)
@avoiding_obstacle = true
end
else
move_towards(player_x, player_y)
return
end
end
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?
@tile_path.shift
end
@waypoints = build_waypoints(@tile_path)
end
return if @waypoints.empty?
target_x, target_y = @waypoints.first
if intersects?(@enemy.rect, [target_x - 5, target_y - 5, 10, 10])
@waypoints.shift
@avoiding_obstacle = false if @waypoints.empty?
return
end
move_towards(target_x, target_y)
end
def draw
return unless DEBUG
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
@waypoints.each do |wx, wy|
Gosu.draw_rect(wx - cam_x - 5, wy - cam_y - 5, 10, 10, Gosu::Color.new(0x88ffff00), Float::INFINITY)
end
end
private
def build_waypoints(tile_path)
waypoints = []
tile_path.each_with_index do |(tx, ty), i|
center_x = tx * 120 + 90
center_y = ty * 120 + 90
unless $bus.get(:object_at, tx, ty).nil?
next_tile = tile_path[i + 1]
prev_tile = i > 0 ? tile_path[i - 1] : nil
next_cx = next_tile ? next_tile[0] * 120 + 90 : center_x
next_cy = next_tile ? next_tile[1] * 120 + 90 : center_y
prev_cx = prev_tile ? prev_tile[0] * 120 + 90 : @enemy.x
prev_cy = prev_tile ? prev_tile[1] * 120 + 90 : @enemy.y
corners = [
[center_x - CORNER_OFFSET, center_y - CORNER_OFFSET],
[center_x + CORNER_OFFSET, center_y - CORNER_OFFSET],
[center_x - CORNER_OFFSET, center_y + CORNER_OFFSET],
[center_x + CORNER_OFFSET, center_y + CORNER_OFFSET]
]
# first waypoint: corner closest to where we're coming from
first = corners.min_by { |cx, cy| Gosu.distance(cx, cy, prev_cx, prev_cy) }
# second waypoint: closest to next tile but not opposite to first
opposite = [center_x - (first[0] - center_x), center_y - (first[1] - center_y)]
candidates = corners.reject { |c| c == first || c == opposite }
second = candidates.min_by { |cx, cy| Gosu.distance(cx, cy, next_cx, next_cy) }
waypoints << first
waypoints << second
else
waypoints << [center_x, center_y]
end
end
waypoints
end
def move_towards(target_x, target_y)
angle = Gosu.angle(@enemy.x, @enemy.y, target_x, target_y)
dx = Gosu.offset_x(angle, SPEED)
dy = Gosu.offset_y(angle, SPEED)
steps = SPEED.ceil
step_dx = dx / steps.to_f
step_dy = dy / steps.to_f
steps.times do
moved_x = false
moved_y = false
# Try X
@enemy.x += step_dx
if ($bus.get(:collides?, @enemy.rect) & [:wall, :object])&.any?
@enemy.x -= step_dx
else
moved_x = true
end
# Try Y (independent — keeps full intent)
@enemy.y += step_dy
if ($bus.get(:collides?, @enemy.rect) & [:wall, :object])&.any?
@enemy.y -= step_dy
else
moved_y = true
end
# If both failed, stop early
break unless moved_x || moved_y
end
if $bus.get(:collides?, @enemy.rect)&.include?(:character)
# Attack player
end
end
end
+46
View File
@@ -0,0 +1,46 @@
require_relative 'ai'
class Enemy
attr_accessor :x, :y, :w, :h
def initialize(x, y)
@x = x
@y = y
@w = 25
@h = 30
@ai = EnemyAI.new(self, :chase)
end
def rect
[@x - @w / 2, @y - @h / 2, @w, @h]
end
def collides?(rect)
return true if intersects?(self.rect, rect)
false
end
def update
player_pos = $bus.get(:player_position)
@ai.update(*player_pos) if player_pos
end
def draw
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
screen_x = @x - cam_x
screen_y = @y - cam_y
Gosu.draw_rect(
screen_x - @w / 2,
screen_y - @h / 2,
@w,
@h,
Gosu::Color::RED,
screen_y - @h / 2
)
@ai.draw
end
end
+28
View File
@@ -0,0 +1,28 @@
require_relative 'base'
class EnemyHandler
def initialize
@enemies = []
end
def update
spawn!
@enemies.each(&:update)
end
def spawn!
# For now, just spawn a single enemy at a fixed location
start_room_coords = $bus.get(:start_room_coords)
if @enemies.empty?
@enemies << Enemy.new((start_room_coords[0] + 4) * 60 + 30, (start_room_coords[1] + 4) * 60 + 30)
end
end
def draw
@enemies.each(&:draw)
end
def collides?(rect)
@enemies.any? { |enemy| enemy.collides?(rect) }
end
end
+6 -2
View File
@@ -5,6 +5,10 @@ class Maze
@maze = MazeData.new(80, 80)
@spritesheet = Gosu::Image.load_tiles("assets/images/walls.png", 20, 40, retro: true)
puts @spritesheet.size
$bus.on_retrievable(:maze_solve) do |x1, y1, x2, y2|
next @maze.solve(x1, y1, x2, y2)
end
end
def collides?(rect)
@@ -64,8 +68,8 @@ class Maze
shapes
end
def draw(camera)
cam_x, cam_y = camera
def draw
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
tile_size = 60
scale = 3
screen_width, screen_height = SCREEN_SIZE
+84
View File
@@ -0,0 +1,84 @@
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[0], SIZE[1]]
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[0], SIZE[1], 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 ObjectHandler
attr_reader :objects
def initialize
start_room_coords = $bus.get(:start_room_coords)
@objects = [Radar.new(start_room_coords[0] / 2 - 1, start_room_coords[1] / 2 - 1)]
$bus.on_retrievable(:object_at) do |x, y|
next @objects.find { |obj| obj.x == x && obj.y == y }
end
end
def add(object)
@objects << object
end
def collides?(rect)
@objects.any? { |obj| obj.collides?(rect) }
end
def update
@objects.each(&:update)
end
def draw
@objects.each(&:draw)
end
end
View File
+21 -6
View File
@@ -1,5 +1,7 @@
require_relative 'state'
require_relative 'maze'
require_relative 'placeables/base'
require_relative 'enemy/handler'
require_relative 'character'
require 'perlin'
@@ -8,11 +10,13 @@ class Game < Scene
def initialize
super
@font = Gosu::Font.new(24)
@noise = Perlin::Generator.new(rand(1000), 1.0, 1)
@noise = Perlin::Generator.new(rand(1...1000), 1.0, 1)
@floor_image = Gosu::Image.new("assets/images/floor.png", retro: true)
@maze = Maze.new
@character = Character.new
@enemies = EnemyHandler.new
@objects = ObjectHandler.new
@camera = [0, 0]
@@ -20,19 +24,27 @@ class Game < Scene
@camera = pos
end
$bus.on_retrievable(:camera_pos) do
next @camera
end
$bus.on_retrievable(:collides?) do |rect|
collisions = []
collisions << :wall if @maze.collides?(rect)
collisions << :character if @character.collides?(rect)
collisions << :enemy if @enemies.collides?(rect)
collisions << :object if @objects.collides?(rect)
next collisions
end
end
def draw
draw_floor
@maze.draw(@camera)
@maze.draw
@character.draw
draw_fog(@camera)
@enemies.draw
@objects.draw
draw_fog!
draw_debug! if DEBUG
end
@@ -51,11 +63,12 @@ class Game < Scene
end
@font.draw_text("FPS: #{@fps}", 5, 5, Float::INFINITY, 1, 1, Gosu::Color::YELLOW)
@font.draw_text("Player: [#{@character.world_x.round}, #{@character.world_y.round}]", 5, 30, Float::INFINITY, 1, 1, Gosu::Color::YELLOW)
world_x, world_y = $bus.get(:player_position)&.map(&:round) || [0, 0]
@font.draw_text("Player: [#{world_x}, #{world_y}]", 5, 30, Float::INFINITY, 1, 1, Gosu::Color::YELLOW)
end
def draw_fog(camera)
cam_x, cam_y = camera
def draw_fog!
cam_x, cam_y = @camera
cell = 10
i_radius = 90.0
o_radius = 400.0
@@ -114,6 +127,8 @@ class Game < Scene
def update
@character.update
@enemies.update
@objects.update
end
def button_down(id, _pos)