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
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 # teleport to boss room for debug purposes
if $bus.get(:settings, :debug) && Gosu.button_down?(Gosu::KB_T) if $bus.get(:settings, :debug) && Gosu.button_down?(Gosu::KB_T)
room_coords = $bus.get(:boss_room_coords) room_coords = $bus.get(:boss_room_coords)
+2 -2
View File
@@ -15,7 +15,7 @@ class EnemyAI
@time_since_attack = ATTACK_COOLDOWN @time_since_attack = ATTACK_COOLDOWN
end 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) distance = Math.sqrt((player_x - @enemy.x)**2 + (player_y - @enemy.y)**2)
return unless distance < AGGRO_RADIUS return unless distance < AGGRO_RADIUS
@@ -76,7 +76,7 @@ class EnemyAI
end end
end end
if @waypoints.empty? || @last_player_tile != player_tile if force || @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(:prop_at, enemy_tile[0], enemy_tile[1]).nil? if $bus.get(:prop_at, enemy_tile[0], enemy_tile[1]).nil?
+33 -1
View File
@@ -26,6 +26,8 @@ class Enemy
@w = 25 @w = 25
@h = 30 @h = 30
@health = health @health = health
@stun = 0
@last_lock = :none
@lorentz = $bus.get(:lorentz_field?) || false @lorentz = $bus.get(:lorentz_field?) || false
$bus.on(:lorentz_field!) do |lorentz| $bus.on(:lorentz_field!) do |lorentz|
@@ -37,6 +39,20 @@ class Enemy
$bus.on(:collides?) do |rect| $bus.on(:collides?) do |rect|
next collides?(rect) ? :enemy : nil next collides?(rect) ? :enemy : nil
end 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 end
def take_damage(amount) def take_damage(amount)
@@ -56,8 +72,24 @@ class Enemy
end end
def update(dt) def update(dt)
if @stun > 0
@stun -= 1
return
end
player_pos = $bus.get(:player_position) 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 end
def draw def draw
+11
View File
@@ -4,6 +4,17 @@ class HealthBar
def initialize(max_health) def initialize(max_health)
@max_health = max_health @max_health = max_health
@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 end
def take_damage(amount) def take_damage(amount)
+16
View File
@@ -3,6 +3,7 @@ require_relative 'hud/layer'
require_relative 'props/handler' require_relative 'props/handler'
require_relative 'enemy/handler' require_relative 'enemy/handler'
require_relative 'weapons/handler' require_relative 'weapons/handler'
require_relative 'traps/handler'
require_relative 'character' require_relative 'character'
class Game < Scene class Game < Scene
@@ -11,11 +12,14 @@ class Game < Scene
@font = Gosu::Font.new(24) @font = Gosu::Font.new(24)
@floor_image = Gosu::Image.new("assets/images/floor.png", retro: true) @floor_image = Gosu::Image.new("assets/images/floor.png", retro: true)
@blasted = 0
@maze = Maze.new @maze = Maze.new
@character = Character.new @character = Character.new
@enemies = EnemyHandler.new @enemies = EnemyHandler.new
@props = PropsHandler.new @props = PropsHandler.new
@weapons = WeaponHandler.new @weapons = WeaponHandler.new
@traps = TrapHandler.new
@hud = HUDLayer.new @hud = HUDLayer.new
@@ -28,14 +32,25 @@ class Game < Scene
$bus.on(:camera_pos) do $bus.on(:camera_pos) do
next @camera next @camera
end end
$bus.on(:blast) do |_|
@blasted = 10 # blast for 10 frames
end
end end
def draw 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 draw_floor
@maze.draw @maze.draw
@character.draw @character.draw
@enemies.draw @enemies.draw
@props.draw @props.draw
@traps.draw
$bus.emit(:shade) if $bus.get(:settings, :fog) $bus.emit(:shade) if $bus.get(:settings, :fog)
@hud.draw @hud.draw
@weapons.draw @weapons.draw
@@ -71,6 +86,7 @@ class Game < Scene
@character.update(dt) @character.update(dt)
@enemies.update(dt) @enemies.update(dt)
@props.update(dt) @props.update(dt)
@traps.update(dt)
@weapons.update(dt) @weapons.update(dt)
end 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) weapon.attack(direction)
elsif Gosu.button_down?(Gosu::MS_LEFT) elsif Gosu.button_down?(Gosu::MS_LEFT)
player_x, player_y = $bus.get(:player_position) 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) direction = Math.atan2(mouse_y - player_y, mouse_x - player_x)
weapon.attack(direction) weapon.attack(direction)
end end
+6 -7
View File
@@ -1,17 +1,16 @@
class LorentzField class LorentzField
BASE_TIME = 25 # Base time for the field to be active in seconds BASE_TIME = 25 # Base time for the field to be active in seconds
def initialize(bus) def initialize
@bus = bus
@time = 0 @time = 0
@active = false @active = false
@font = Gosu::Font.new(24, name: "assets/fonts/tn.ttf") @font = Gosu::Font.new(24, name: "assets/fonts/tn.ttf")
@bus.on(:lorentz_field?) do $bus.on(:lorentz_field?) do
next @active next @active
end end
@bus.on(:lorentz_field) do $bus.on(:lorentz_field) do
if @active if @active
t = @time t = @time
remaining = BASE_TIME - t remaining = BASE_TIME - t
@@ -34,10 +33,10 @@ class LorentzField
def attack(_direction) def attack(_direction)
return if @active return if @active
@bus.emit(:consume, :lorentz_field, 1) $bus.emit(:consume, :lorentz_field, 1)
@active = true @active = true
@time = 0 @time = 0
@bus.emit(:lorentz_field!, true) $bus.emit(:lorentz_field!, true)
end end
def update(dt) def update(dt)
@@ -47,7 +46,7 @@ class LorentzField
if @time >= BASE_TIME if @time >= BASE_TIME
@time = 0 @time = 0
@active = false @active = false
@bus.emit(:lorentz_field!, false) $bus.emit(:lorentz_field!, false)
end end
end end
+2 -2
View File
@@ -27,7 +27,7 @@ class GameOver < Scene
@bg_image.draw(0, 0, 1) @bg_image.draw(0, 0, 1)
OPTIONS.each_with_index do |(text, _), index| OPTIONS.each_with_index do |(text, _), index|
y = 260 + index * 50 y = 360 + index * 50
if index == @selected_index if index == @selected_index
@font.draw_text("[ #{text.ljust(10)} ]", 50, y, 1, 1, 1, Gosu::Color::YELLOW) @font.draw_text("[ #{text.ljust(10)} ]", 50, y, 1, 1, 1, Gosu::Color::YELLOW)
else else
@@ -56,7 +56,7 @@ class GameOver < Scene
def item_at(pos) def item_at(pos)
OPTIONS.each_with_index do |(_, action), index| 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)) if pos[1].between?(y, y + 32) && pos[0].between?(50, 50 + @font.text_width(" " * 14))
return [index, action] return [index, action]
end 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