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
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

+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
+2 -2
View File
@@ -27,7 +27,7 @@ class GameOver < Scene
@bg_image.draw(0, 0, 1)
OPTIONS.each_with_index do |(text, _), index|
y = 260 + index * 50
y = 360 + index * 50
if index == @selected_index
@font.draw_text("[ #{text.ljust(10)} ]", 50, y, 1, 1, 1, Gosu::Color::YELLOW)
else
@@ -56,7 +56,7 @@ class GameOver < Scene
def item_at(pos)
OPTIONS.each_with_index do |(_, action), index|
y = 260 + index * 50
y = 360 + index * 50
if pos[1].between?(y, y + 32) && pos[0].between?(50, 50 + @font.text_width(" " * 14))
return [index, action]
end
+16
View File
@@ -0,0 +1,16 @@
So,
#landmines and event horizon should be easy - done
make enemy spawn in the boss rooms with randomness + enemy count
make the lizard kind of enemy with very high health + dps but slow
make blade work as planned
make bow just normal
make minimap for the torches revealing teh maze
click on minimap makes game pause to allow pausing for safety
make radars find exit if even one placed, and make it expensive
then start writing every mistake and time terribility