From 83d250f1e9e972cb5366465d814614a5b98a5528 Mon Sep 17 00:00:00 2001 From: Daanish Date: Wed, 25 Mar 2026 22:02:50 +0000 Subject: [PATCH] Implement enemy AI and handler; add placeable radar object; enhance maze functionality --- assets/images/radar.aseprite | Bin 0 -> 655 bytes assets/images/radar.png | Bin 0 -> 574 bytes game/character.rb | 35 ++++-- game/enemy/ai.rb | 166 ++++++++++++++++++++++++++ game/enemy/base.rb | 46 +++++++ game/enemy/handler.rb | 28 +++++ game/maze.rb | 8 +- game/placeables/base.rb | 84 +++++++++++++ {objects => game/placeables}/traps.rb | 0 game/scene.rb | 27 ++++- objects/items.rb | 0 utils/maze.rb | 78 +++++++++++- utils/utils.rb | 30 +++++ 13 files changed, 478 insertions(+), 24 deletions(-) create mode 100644 assets/images/radar.aseprite create mode 100644 assets/images/radar.png create mode 100644 game/enemy/handler.rb create mode 100644 game/placeables/base.rb rename {objects => game/placeables}/traps.rb (100%) delete mode 100644 objects/items.rb diff --git a/assets/images/radar.aseprite b/assets/images/radar.aseprite new file mode 100644 index 0000000000000000000000000000000000000000..824f0ab92f56427b2000801c7edd67482f490248 GIT binary patch literal 655 zcmeBYVqkc%l#wBTL4-kpfsuiMA%y`MC@?UB1Q`Sv1dxTP#^z^YVEFZs1#Bw|0|SE? z0|NuQ0@z9!?S*WGMo7{AZwq2 z`IJ2GO#L8l6(bu(#sZr+anf8_0ufQeZ~1`#;D<3_gjKsYMEg35*O3tRP1)FfsgRf~oRj5MZd7 z^K`l+Uz34=Yc-4cF=Yuew{W#JcXnKQz!)Rx*)#9p-u4fA^Ukoyrp%g|=;oDh)$FI{ zsrR3)x3JE+e7nSG^}mx6Y1=$1B`?bDUH&>Y)M1-QMctlb1+H;BjsAa)XHD^LJudO$ zV^3RgwQjazch%IGxi`U9Qzok;w@IUzBmYf} zZH3u#0iMABpEtjn-W_vzW96+XiBqpnJhv>--Tc+>pZZq@_ek!QAAHU|{FHGw`h)dL z{*$7T{!bg^pDnqPv~}hAuV+OS`wI*HwspMY4_nt!nKQpdE~{tnob48Twz0qSj@W!V z!}I(ibMk|IOnS!8o^>UEcYVHw?%zB5{^T(|@PaJS*- literal 0 HcmV?d00001 diff --git a/assets/images/radar.png b/assets/images/radar.png new file mode 100644 index 0000000000000000000000000000000000000000..a7a21f321b8d944e63273b5b1696c2c55ae597c1 GIT binary patch literal 574 zcmeAS@N?(olHy`uVBq!ia0y~yUJoIHBa zS5vk$c+<^|79w+8H2yiT7H00c$@lVd%%tw9rL6k#94Ghg{=T#P{W~GO2HoiYn(XTj zDgWHJMkc+R^Ln_7oaAAt=o896UolKL_rxc$H>xst!l4f`QV|@ld<-_63SisAuq1k(Es^sp+cVP_8chj5|Ffy7#xXJHrBC1XFXo@l*{lPHTt8zFN?^RlWbu_NSJKOIPYuUjBNC zjp6yz#Y=Zft`8|$86LX#{b^;jjHfaawx6w-$ucjVW#*|Ig`H9xuBAQQ+`0Vv!U_|` z_9wZ*_c^EYCG0EN+|ZC^U0lb&Fl{}5*gK~WJ2>a)-wj~P4=DL|GJ_-L?i-OChM##j zW%hh$WYG6_U3YoM{n)KrZz(B#n8Ct#@Ar!>(I40kOMKt`f&FmDF(I~u#bIss5^l@y zGEFeL(tRY^PKia?HJNkHJyQmT)SaE1?xf7Q9#GtQz&Ix{YTc6a&rKK 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 \ No newline at end of file diff --git a/game/enemy/base.rb b/game/enemy/base.rb index e69de29..eb4e045 100644 --- a/game/enemy/base.rb +++ b/game/enemy/base.rb @@ -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 \ No newline at end of file diff --git a/game/enemy/handler.rb b/game/enemy/handler.rb new file mode 100644 index 0000000..de7ddd2 --- /dev/null +++ b/game/enemy/handler.rb @@ -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 diff --git a/game/maze.rb b/game/maze.rb index 28d2e08..c24b975 100644 --- a/game/maze.rb +++ b/game/maze.rb @@ -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 diff --git a/game/placeables/base.rb b/game/placeables/base.rb new file mode 100644 index 0000000..5e037c7 --- /dev/null +++ b/game/placeables/base.rb @@ -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 \ No newline at end of file diff --git a/objects/traps.rb b/game/placeables/traps.rb similarity index 100% rename from objects/traps.rb rename to game/placeables/traps.rb diff --git a/game/scene.rb b/game/scene.rb index 43917bc..fa2b76b 100644 --- a/game/scene.rb +++ b/game/scene.rb @@ -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) diff --git a/objects/items.rb b/objects/items.rb deleted file mode 100644 index e69de29..0000000 diff --git a/utils/maze.rb b/utils/maze.rb index e272ec1..e99360d 100644 --- a/utils/maze.rb +++ b/utils/maze.rb @@ -1,16 +1,23 @@ class MazeData - def initialize(width, height) + def initialize(width, height, standalone: false) @width = width @height = height @grid = Array.new(height) { Array.new(width, 0) } @boss_rooms = [] + @start_room = nil crate_rooms! carve_passages_from(0, 0) fix_room_entrances! + return if standalone + + $bus.on_retrievable(:start_room_coords) do + next @start_room ? [@start_room[0] * 2 + 1, @start_room[1] * 2 + 1] : nil + end + return unless DEBUG print_debug @@ -19,6 +26,60 @@ class MazeData end end + def solve(x1, y1, x2, y2) + # explain why dijkstra's is fine here: + # A* hueristics make it possible to chose a longer path through rooms instead of a shorter path through corridors, which is not what we want for enemy pathfinding + # Dijkstra's is also simpler to implement since we don't need to worry about the heuristic function, and the maze is not large enough for performance to be a concern + + distances = Array.new(@height) { Array.new(@width, Float::INFINITY) } + visited = Array.new(@height) { Array.new(@width, false) } + previous = Array.new(@height) { Array.new(@width, nil) } + distances[y1][x1] = 0 + + queue = [[y1, x1]] + + while !queue.empty? + cy, cx = queue.shift + + next if visited[cy][cx] + visited[cy][cx] = true + + return build_path(previous, x1, y1, x2, y2) if cx == x2 && cy == y2 + + # can check NSEW walls here to determine which neighbors to add to the queue (no need to check teh neighbors) + [N, S, E, W].each do |direction| + next if (@grid[cy][cx] & direction) == 0 # wall in this direction + + nx, ny = cx + DX[direction], cy + DY[direction] + + next unless ny.between?(0, @height - 1) && nx.between?(0, @width - 1) + + alt = distances[cy][cx] + 1 + if alt < distances[ny][nx] + distances[ny][nx] = alt + previous[ny][nx] = [cx, cy] + queue << [ny, nx] + end + end + end + + return nil + end + + def build_path(previous, x1, y1, x2, y2) + path = [] + cx, cy = x2, y2 + + while cx != x1 || cy != y1 + return nil unless previous[cy][cx] + path << [cx, cy] + cx, cy = previous[cy][cx] + end + + path << [x1, y1] + path.reverse! + end + def width @width * 2 + 1 end @@ -114,7 +175,7 @@ class MazeData end def fix_room_entrances! - @boss_rooms.each do |x, y| + (@boss_rooms + [@start_room]).each do |x, y| @grid[y][x + 1] |= N @grid[y - 1][x + 1] |= S end @@ -123,10 +184,11 @@ class MazeData def crate_rooms! base = Math.sqrt(@width * @height / 200.0).round num_rooms = rand((base - 1)..(base)) - num_rooms = 1 if num_rooms < 1 + num_rooms = (num_rooms < 1 ? 1 : num_rooms) + 1 # for start room attempts = 0 + rooms = [] - while @boss_rooms.size < num_rooms && attempts < 10 + while rooms.size < num_rooms && attempts < 10 attempts += 1 x = rand(2..(@width - BOSS_ROOM_SIZE - 2)) @@ -148,8 +210,11 @@ class MazeData end end - @boss_rooms << [x, y] + rooms << [x, y] end + + @start_room = rooms.shift + @boss_rooms = rooms end def carve_passages_from(cx, cy) @@ -168,5 +233,6 @@ class MazeData end if __FILE__ == $0 - + maze = MazeData.new(80, 25, standalone: true) + maze.print_debug end \ No newline at end of file diff --git a/utils/utils.rb b/utils/utils.rb index d8118eb..57e2b04 100644 --- a/utils/utils.rb +++ b/utils/utils.rb @@ -9,4 +9,34 @@ def intersects?(rect1, rect2) return false if y1 >= y2 + h2 # rect1 is below rect2 true # overlap exists +end + +def line_of_sight?(x1, y1, x2, y2, rect) + rx, ry, rw, rh = rect + rx, ry, rw, rh = rx - 2, ry - 2, rw + 4, rh + 4 + + # The 4 edges of the rect as line segments + edges = [ + [rx, ry, rx + rw, ry ], # top + [rx, ry + rh, rx + rw, ry + rh], # bottom + [rx, ry, rx, ry + rh], # left + [rx + rw, ry, rx + rw, ry + rh], # right + ] + + !(edges.any? { |ex1, ey1, ex2, ey2| segments_intersect?(x1, y1, x2, y2, ex1, ey1, ex2, ey2) }) +end + +def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2) + dx1 = ax2 - ax1 + dy1 = ay2 - ay1 + dx2 = bx2 - bx1 + dy2 = by2 - by1 + + denom = dx1 * dy2 - dy1 * dx2 + return false if denom == 0 # parallel + + t = ((bx1 - ax1) * dy2 - (by1 - ay1) * dx2).to_f / denom + u = ((bx1 - ax1) * dy1 - (by1 - ay1) * dx1).to_f / denom + + t.between?(0, 1) && u.between?(0, 1) end \ No newline at end of file