Implement traps system: add Trap, Landmine, and EventHorizon classes with collision detection and interaction logic; integrate trap handling in the game scene and enemy AI.

This commit is contained in:
2026-04-02 22:46:24 +01:00
parent db7c2aa7a2
commit 7c35419026
20 changed files with 294 additions and 13 deletions
+4
View File
@@ -122,6 +122,10 @@ class Character
end
end
if $bus.get_all(:collides?, rect).include?(:trap)
$bus.emit(:trap_stepped_on, rect, :character)
end
# teleport to boss room for debug purposes
if $bus.get(:settings, :debug) && Gosu.button_down?(Gosu::KB_T)
room_coords = $bus.get(:boss_room_coords)
+2 -2
View File
@@ -15,7 +15,7 @@ class EnemyAI
@time_since_attack = ATTACK_COOLDOWN
end
def update(player_x, player_y, dt)
def update(player_x, player_y, dt, force = false)
distance = Math.sqrt((player_x - @enemy.x)**2 + (player_y - @enemy.y)**2)
return unless distance < AGGRO_RADIUS
@@ -76,7 +76,7 @@ class EnemyAI
end
end
if @waypoints.empty? || @last_player_tile != player_tile
if force || @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(:prop_at, enemy_tile[0], enemy_tile[1]).nil?
+33 -1
View File
@@ -26,6 +26,8 @@ class Enemy
@w = 25
@h = 30
@health = health
@stun = 0
@last_lock = :none
@lorentz = $bus.get(:lorentz_field?) || false
$bus.on(:lorentz_field!) do |lorentz|
@@ -37,6 +39,20 @@ class Enemy
$bus.on(:collides?) do |rect|
next collides?(rect) ? :enemy : nil
end
$bus.on(:blast) do |x, y, radius, damage, _|
dist = Math.hypot(@x - x, @y - y)
if dist <= radius + 20
take_damage(damage * 2)
end
end
$bus.on(:stun) do |x, y|
dist = Math.hypot(@x - x, @y - y)
if dist <= 100
@stun = 60 * 1 # stun for 1 second
end
end
end
def take_damage(amount)
@@ -56,8 +72,24 @@ class Enemy
end
def update(dt)
if @stun > 0
@stun -= 1
return
end
player_pos = $bus.get(:player_position)
@ai.update(*player_pos, dt) if player_pos
closest_event_horizon = $bus.get(:closest_event_horizon, @x, @y, 200)
if closest_event_horizon
@ai.update(*closest_event_horizon, dt, @last_lock != :event_horizon)
@last_lock = :event_horizon
else
@ai.update(*player_pos, dt, @last_lock != :player) if player_pos
@last_lock = :player
end
if $bus.get_all(:collides?, rect).include?(:trap)
$bus.emit(:trap_stepped_on, rect, :enemy)
end
end
def draw
+11
View File
@@ -4,6 +4,17 @@ class HealthBar
def initialize(max_health)
@max_health = max_health
@health = max_health
$bus.on(:blast) do |x, y, radius, damage, safety|
next if safety == :safe
player_x, player_y = $bus.get(:player_position)
dist = Math.hypot(player_x - x, player_y - y)
if dist <= radius + 20
take_damage(damage)
end
end
end
def take_damage(amount)
+16
View File
@@ -3,6 +3,7 @@ require_relative 'hud/layer'
require_relative 'props/handler'
require_relative 'enemy/handler'
require_relative 'weapons/handler'
require_relative 'traps/handler'
require_relative 'character'
class Game < Scene
@@ -11,11 +12,14 @@ class Game < Scene
@font = Gosu::Font.new(24)
@floor_image = Gosu::Image.new("assets/images/floor.png", retro: true)
@blasted = 0
@maze = Maze.new
@character = Character.new
@enemies = EnemyHandler.new
@props = PropsHandler.new
@weapons = WeaponHandler.new
@traps = TrapHandler.new
@hud = HUDLayer.new
@@ -28,14 +32,25 @@ class Game < Scene
$bus.on(:camera_pos) do
next @camera
end
$bus.on(:blast) do |_|
@blasted = 10 # blast for 10 frames
end
end
def draw
if @blasted != 0
# this is a terrible animation for blasting but that would require me to work on artwork and I don't have time for that, so let's just flash the screen white and fade it out
Gosu.draw_rect(0, 0, *SCREEN_SIZE, Gosu::Color.new(@blasted * 255 / 10, 255, 255, 255), Float::INFINITY)
@blasted -= 1
return
end
draw_floor
@maze.draw
@character.draw
@enemies.draw
@props.draw
@traps.draw
$bus.emit(:shade) if $bus.get(:settings, :fog)
@hud.draw
@weapons.draw
@@ -71,6 +86,7 @@ class Game < Scene
@character.update(dt)
@enemies.update(dt)
@props.update(dt)
@traps.update(dt)
@weapons.update(dt)
end
+30
View File
@@ -0,0 +1,30 @@
class Trap
SIZE = [40, 40].freeze
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
@sprite = nil
end
def collides?(rect)
return intersects?(collision_rect, rect)
end
def collision_rect
[@x - SIZE[0] / 2, @y - SIZE[1] / 2, *SIZE]
end
def update(player)
# the collision is checked by the player or enemy
end
def draw
return unless @sprite
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
@sprite.draw(@x - SIZE[0] / 2 - cam_x, @y - SIZE[1] / 2 - cam_y, 0, 2, 2)
end
end
+15
View File
@@ -0,0 +1,15 @@
require_relative 'base'
class EventHorizon < Trap
def initialize(x, y)
super(x, y)
@sprite = Gosu::Image.new("assets/images/event_horizon.png", retro: true)
end
def stepped_on(entity)
# this is not a weapon, so nothing
# it is instead destroyed after after being stepped on by enemy
$bus.emit(:stun, @x, @y)
entity == :enemy
end
end
+129
View File
@@ -0,0 +1,129 @@
require_relative 'landmine'
require_relative 'shrodingers_mine'
require_relative 'event_horizon'
class TrapHandler
def initialize
# traps are stored in a grid for efficient collision checking
# can write in report about trying to use an array and how it was too slow to check every trap for collisions, so we switched to a grid where each cell is 120x120, blah blah
@grid = {}
$bus.on(:collides?) do |rect|
nearby_traps(rect).any? { |t| t.collides?(rect) } ? :trap : nil
end
$bus.on(:trap_stepped_on) do |rect, entity|
nearby_traps(rect).each do |t|
t.collides?(rect) && t.stepped_on(entity) && @grid[[(t.x / 120).floor, (t.y / 120).floor]].delete(t) # remove the trap if stepped on
end
end
$bus.on(:closest_event_horizon) do |x, y, range|
# this abomination is to find the closest event horizon within range, we can optimize this later if needed but for now it works
nearby_traps([x - range, y - range, range * 2, range * 2])
.select { |t| t.is_a?(EventHorizon) }
.map { |t| [t, Math.hypot(t.x - x, t.y - y)] }
.select { |_, d| d <= range }
.min_by { |_, d| d }
&.first&.then { |e| [e.x, e.y] }
end
end
def add(trap)
tx = (trap.x / 120).floor
ty = (trap.y / 120).floor
@grid[[tx, ty]] ||= []
@grid[[tx, ty]] << trap
end
def update dt
# allow placing traps by the player
if Gosu.button_down?(Gosu::MS_LEFT)
selected_item = $bus.get(:selected_item)
if selected_item == :landmine
mouse_x, mouse_y = $bus.get(:mouse_pos) || [0, 0]
player_x, player_y = $bus.get(:player_position) || [0, 0]
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
world_x = mouse_x + cam_x
world_y = mouse_y + cam_y
landmine = Landmine.new(world_x, world_y)
return if Math.hypot(landmine.x - player_x, landmine.y - player_y) > 200 # can't place if too far from player
return if ($bus.get_all(:collides?, landmine.collision_rect) & [:character, :prop, :trap, :wall]).any?
return unless $bus.get(:consume, :landmine, 1)
add(landmine)
elsif selected_item == :shrodingers_mine
mouse_x, mouse_y = $bus.get(:mouse_pos) || [0, 0]
player_x, player_y = $bus.get(:player_position) || [0, 0]
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
world_x = mouse_x + cam_x
world_y = mouse_y + cam_y
mine = ShrodingersMine.new(world_x, world_y)
return if Math.hypot(mine.x - player_x, mine.y - player_y) > 200
return if ($bus.get_all(:collides?, mine.collision_rect) & [:character, :prop, :trap, :wall]).any?
return unless $bus.get(:consume, :shrodingers_mine, 1)
add(mine)
elsif selected_item == :event_horizon
mouse_x, mouse_y = $bus.get(:mouse_pos) || [0, 0]
player_x, player_y = $bus.get(:player_position) || [0, 0]
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
world_x = mouse_x + cam_x
world_y = mouse_y + cam_y
event_horizon = EventHorizon.new(world_x, world_y)
return if Math.hypot(world_x - player_x, world_y - player_y) > 200
return if ($bus.get_all(:collides?, event_horizon.collision_rect) & [:character, :prop, :trap, :wall]).any?
return unless $bus.get(:consume, :event_horizon, 1)
add(event_horizon)
end
end
end
def nearby_traps(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|
t = @grid[[tx, ty]]
results.concat(t) if t
end
end
results
end
def draw
cam = $bus.get(:camera_pos) || [0, 0]
nearby_traps([*cam, *SCREEN_SIZE]).each(&:draw)
draw_ghost_traps!
end
def draw_ghost_traps!
selected_item = $bus.get(:selected_item)
return unless [:landmine, :shrodingers_mine, :event_horizon].include?(selected_item) # only draw if selected item is a placeable trap
return unless $bus.get(:count, selected_item) > 0 # only draw if player has at least 1 landmine
mouse_x, mouse_y = $bus.get(:mouse_pos) || [0, 0]
cam_x, cam_y = $bus.get(:camera_pos) || [0, 0]
world_x = mouse_x + cam_x
world_y = mouse_y + cam_y
color = Gosu::Color.new(128, 255, 255, 255)
player_x, player_y = $bus.get(:player_position) || [0, 0]
if Math.hypot(world_x - player_x, world_y - player_y) > 100 ||
($bus.get_all(:collides?, [world_x - 20, world_y - 20, 40, 40]) & [:character, :prop, :trap, :wall]).any?
color = Gosu::Color.new(128, 255, 0, 0) # red if too far
elsif nearby_traps([world_x - 20, world_y - 20, 40, 40]).any? { |t| t.collides?([world_x - 20, world_y - 20, 40, 40]) }
color = Gosu::Color.new(128, 255, 255, 0) # yellow if colliding with another trap
end
ghost_sprite = Gosu::Image.new("assets/images/#{selected_item}.png", retro: true)
ghost_sprite.draw(world_x - 20 - cam_x, world_y - 20 - cam_y, 0, 2, 2, color)
end
end
+13
View File
@@ -0,0 +1,13 @@
require_relative 'base'
class Landmine < Trap
def initialize(x, y)
super(x, y)
@sprite = Gosu::Image.new("assets/images/landmine.png", retro: true)
end
def stepped_on(_)
$bus.emit(:blast, @x, @y, 30, 30, :unsafe) # blast radius of 30 and damage of 30, and mark it as unsafe for the player
true # to destroy the trap
end
end
+16
View File
@@ -0,0 +1,16 @@
require_relative 'base'
class ShrodingersMine < Trap
def initialize(x, y)
super(x, y)
@sprite = Gosu::Image.new("assets/images/shrodingers_mine.png", retro: true)
end
def stepped_on(entity)
return false if entity == :character # shrodingers mine is safe for the player, so don't destroy it or cause a blast if the player steps on it
if rand < 0.7 # 70% chance to be a landmine, 30% chance to be a dud
$bus.emit(:blast, @x, @y, 50, 50, :safe) # blast radius of 50 and damage of 50, and always safe for the player
end
true # to destroy the trap
end
end
+1 -1
View File
@@ -26,7 +26,7 @@ class WeaponHandler
weapon.attack(direction)
elsif Gosu.button_down?(Gosu::MS_LEFT)
player_x, player_y = $bus.get(:player_position)
mouse_x, mouse_y = $bus.get(:mouse_position)
mouse_x, mouse_y = $bus.get(:mouse_pos)
direction = Math.atan2(mouse_y - player_y, mouse_x - player_x)
weapon.attack(direction)
end
+6 -7
View File
@@ -1,17 +1,16 @@
class LorentzField
BASE_TIME = 25 # Base time for the field to be active in seconds
def initialize(bus)
@bus = bus
def initialize
@time = 0
@active = false
@font = Gosu::Font.new(24, name: "assets/fonts/tn.ttf")
@bus.on(:lorentz_field?) do
$bus.on(:lorentz_field?) do
next @active
end
@bus.on(:lorentz_field) do
$bus.on(:lorentz_field) do
if @active
t = @time
remaining = BASE_TIME - t
@@ -34,10 +33,10 @@ class LorentzField
def attack(_direction)
return if @active
@bus.emit(:consume, :lorentz_field, 1)
$bus.emit(:consume, :lorentz_field, 1)
@active = true
@time = 0
@bus.emit(:lorentz_field!, true)
$bus.emit(:lorentz_field!, true)
end
def update(dt)
@@ -47,7 +46,7 @@ class LorentzField
if @time >= BASE_TIME
@time = 0
@active = false
@bus.emit(:lorentz_field!, false)
$bus.emit(:lorentz_field!, false)
end
end