doom 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4672b03d393f17c1cd9ece97869d7239a7af3e91d3ffa47b911b0ba3058f673
4
- data.tar.gz: 56772f44ced59386c92f5319c0d25dc8612e96263d68ae7e9a240d26508571cf
3
+ metadata.gz: e59cb32b1487d61b1c310ee5eae7ec30a702c64129c7fe5e18e1c06204d08208
4
+ data.tar.gz: f8f5450c3c37d661618234cf0abe80a5f0cd796aaa13694ee35d786ef25c5728
5
5
  SHA512:
6
- metadata.gz: b11b52ed631217b002aae05a4f87b7ff5fcb208485b75dd9077ebcc222c3823c6ca74167e9bc6b7c3605c0481472abbc818e6445c8fa130c744fe8c54da53099
7
- data.tar.gz: 808176138162ef4e21796dd995a6bd794eee89e4c631032134349746afd468d446b610a0939daf19c26a2706abac22635306d6e3fb1c1c93e9a70282a2114f7d
6
+ metadata.gz: 4a65c31832aff4838f924eb1263bda1f16ef6e820da9d776fa759656fdb9a58d83b2146ea30d5e0c236d1fc9e39206ad14312191f3bf5db70597cec815aef113
7
+ data.tar.gz: f6215f28a05e9924b9864f2f51d36cd694c4abfe7c0ad1618116486f62c72d26b0d7f609e945725b2218ee4b3e77e13eb3316153396977bf773673185d93ba40
data/README.md CHANGED
@@ -1,17 +1,19 @@
1
1
  # DOOM Ruby
2
2
 
3
- A faithful ruby port of the DOOM (1993) rendering engine to Ruby.
3
+ A faithful port of the DOOM (1993) engine to pure Ruby. Renders the original WAD files with near pixel-perfect BSP rendering, full HUD, item pickups, and hitscan/projectile combat.
4
4
 
5
5
  ![DOOM Ruby](demo.gif)
6
6
 
7
7
  ## Features
8
8
 
9
- - Pure Ruby implementation of DOOM's BSP rendering engine
10
- - Accurate wall, floor, and ceiling rendering with proper texture mapping
11
- - Sprite rendering with depth-correct clipping
12
- - Original DOOM lighting and colormap support
13
- - Mouse look and WASD movement controls
14
- - Supports original WAD files (shareware and registered)
9
+ - **Rendering**: BSP traversal, visplanes, drawsegs, sprite clipping, sky rendering matching Chocolate Doom
10
+ - **Combat**: Hitscan weapons (pistol, shotgun, chaingun), melee (fist, chainsaw), projectile rockets with splash damage
11
+ - **Items**: Weapons, ammo, health, armor, keys -- all pickupable with correct DOOM behavior
12
+ - **Movement**: Momentum-based physics with friction, smooth step transitions, wall sliding, view bob
13
+ - **HUD**: Full status bar with ammo, health, armor, face, weapon selector, key cards, small ammo counts
14
+ - **Effects**: Animated textures (NUKAGE, SLADRIP), sector light effects (flickering, glowing, strobing), scrolling walls
15
+ - **Monsters**: Death animations, solid collision, HP tracking
16
+ - **Compatibility**: Supports original WAD files (shareware and registered), YJIT-optimized
15
17
 
16
18
  ## Installation
17
19
 
@@ -21,7 +23,7 @@ gem install doom
21
23
 
22
24
  ## Quick Start
23
25
 
24
- Just run `doom` - it will offer to download the free shareware version:
26
+ Just run `doom` -- it will offer to download the free shareware version:
25
27
 
26
28
  ```bash
27
29
  doom
@@ -44,11 +46,16 @@ doom /path/to/doom.wad
44
46
  | Left Arrow | Turn left |
45
47
  | Right Arrow | Turn right |
46
48
  | Mouse | Look around (click to capture) |
49
+ | Left Click / X / Shift | Fire weapon |
50
+ | Space / E | Use (open doors) |
51
+ | 1-7 | Switch weapons |
52
+ | M | Toggle automap |
53
+ | Z | Toggle debug overlay |
47
54
  | Escape | Release mouse / Quit |
48
55
 
49
56
  ## Requirements
50
57
 
51
- - Ruby 3.1 or higher
58
+ - Ruby 3.1+ (Ruby 4.0 with YJIT recommended for best performance)
52
59
  - Gosu gem (for window/graphics)
53
60
  - SDL2 (native library required by Gosu)
54
61
 
@@ -61,7 +68,7 @@ brew install sdl2
61
68
 
62
69
  **Ubuntu/Debian:**
63
70
  ```bash
64
- sudo apt-get install build-essential libsdl2-dev libgl1-mesa-dev libfontconfig1-dev
71
+ sudo apt-get install build-essential libsdl2-dev libgl1-mesa-dev libopenal-dev libsndfile1-dev libmpg123-dev libfontconfig1-dev
65
72
  ```
66
73
 
67
74
  **Fedora:**
@@ -75,15 +82,15 @@ sudo pacman -S sdl2 mesa
75
82
  ```
76
83
 
77
84
  **Windows:**
78
- No additional setup needed - the gem includes SDL2.
85
+ No additional setup needed -- the gem includes SDL2.
79
86
 
80
87
  ## Development
81
88
 
82
89
  ```bash
83
- git clone https://github.com/khasinski/doom-rb.git
84
- cd doom-rb
90
+ git clone https://github.com/khasinski/doom.git
91
+ cd doom
85
92
  bundle install
86
- ruby bin/doom doom1.wad
93
+ ruby bin/doom
87
94
  ```
88
95
 
89
96
  Run specs:
@@ -92,15 +99,30 @@ Run specs:
92
99
  bundle exec rspec
93
100
  ```
94
101
 
102
+ ### Benchmarking
103
+
104
+ ```bash
105
+ ruby bench/benchmark.rb # without YJIT
106
+ ruby --yjit bench/benchmark.rb # with YJIT
107
+ ruby bench/benchmark.rb --compare # side-by-side
108
+ ruby bench/benchmark.rb --profile # CPU profile with StackProf
109
+ ```
110
+
95
111
  ## Technical Details
96
112
 
97
- This implementation includes:
113
+ - **BSP Traversal**: Front-to-back rendering using the map's BSP tree with R_CheckBBox culling
114
+ - **Visplanes**: Floor/ceiling rendering with R_CheckPlane splitting and span-based drawing
115
+ - **Drawsegs**: Wall segment tracking for proper sprite clipping (silhouette system)
116
+ - **Texture Mapping**: Perspective-correct ray-seg intersection with non-power-of-2 support
117
+ - **Lighting**: Distance-based light diminishing with wall and flat colormaps
118
+ - **Sky Rendering**: Chocolate Doom sky hack (worldtop = worldhigh) with correct placement
119
+ - **Movement Physics**: Continuous-time momentum/friction matching Chocolate Doom's P_XYMovement
120
+ - **Hitscan**: Ray tracing against walls and monster bounding circles
121
+ - **Projectiles**: Physical rockets with wall/monster collision and splash damage
122
+
123
+ ## Performance
98
124
 
99
- - **BSP Traversal**: Front-to-back rendering using the map's BSP tree
100
- - **Visplanes**: Floor/ceiling rendering with R_CheckPlane splitting
101
- - **Drawsegs**: Wall segment tracking for proper sprite clipping
102
- - **Texture Mapping**: Perspective-correct texture coordinates
103
- - **Lighting**: Distance-based light diminishing with colormaps
125
+ With Ruby 4.0 and YJIT enabled, the renderer achieves 80-130 FPS on E1M1 (Apple Silicon). See [docs/performance-profiling.md](docs/performance-profiling.md) and [docs/yjit-vs-zjit.md](docs/yjit-vs-zjit.md) for detailed analysis.
104
126
 
105
127
  ## Legal
106
128
 
@@ -112,7 +134,7 @@ please purchase DOOM from [Steam](https://store.steampowered.com/app/2280/Ultima
112
134
 
113
135
  ## License
114
136
 
115
- GPL-2.0 - Same license as the original DOOM source code.
137
+ GPL-2.0 -- Same license as the original DOOM source code.
116
138
 
117
139
  ## Author
118
140
 
data/bin/doom CHANGED
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Enable YJIT if available (Ruby 3.1+) for better performance
5
- if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
6
- RubyVM::YJIT.enable
4
+ # Enable YJIT unless --no-yjit flag is passed (for live toggle demo)
5
+ unless ARGV.delete('--no-yjit')
6
+ if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable)
7
+ RubyVM::YJIT.enable
8
+ end
7
9
  end
8
10
 
11
+ # Parse flags
12
+ rubykaigi_mode = ARGV.delete('--rubykaigi') || ARGV.delete('--kaigi')
13
+
9
14
  # Parse arguments before loading heavy dependencies
10
- wad_path = ARGV[0]
15
+ wad_path = ARGV.find { |a| !a.start_with?('-') }
11
16
 
12
17
  # Show help (before loading anything)
13
18
  if wad_path == '-h' || wad_path == '--help'
@@ -49,7 +54,7 @@ require_relative '../lib/doom'
49
54
  begin
50
55
  # Find or download WAD
51
56
  wad_path = Doom::WadDownloader.ensure_wad_available(wad_path)
52
- Doom.run(wad_path)
57
+ Doom.run(wad_path, rubykaigi: rubykaigi_mode)
53
58
  rescue Doom::WadDownloader::DownloadError => e
54
59
  puts "Error: #{e.message}"
55
60
  exit 1
@@ -22,14 +22,22 @@ module Doom
22
22
  64 => 700, # Archvile
23
23
  71 => 400, # Pain Elemental
24
24
  84 => 20, # Wolfenstein SS
25
+ 2035 => 20, # Explosive barrel
25
26
  }.freeze
26
27
 
27
28
  MONSTER_RADIUS = {
28
29
  3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
29
30
  3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
30
31
  7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
32
+ 2035 => 10, # Barrel
31
33
  }.freeze
32
34
 
35
+ # Barrel (explosive, not a monster but damageable)
36
+ BARREL_TYPE = 2035
37
+ BARREL_HP = 20
38
+ BARREL_SPLASH_RADIUS = 128.0
39
+ BARREL_SPLASH_DAMAGE = 128
40
+
33
41
  # Normal death frame sequences per sprite prefix (rotation 0 only)
34
42
  # Identified by sprite heights: frames go from standing height to flat on ground
35
43
  DEATH_FRAMES = {
@@ -46,48 +54,149 @@ module Doom
46
54
  'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
47
55
  'PAIN' => %w[H I J K L M], # Pain Elemental
48
56
  'SSWV' => %w[I J K L M], # Wolfenstein SS
57
+ 'BEXP' => %w[A B C D E], # Barrel explosion
49
58
  }.freeze
50
59
 
51
60
  DEATH_ANIM_TICS = 6 # Tics per death frame
52
61
 
62
+ # Pain chance per monster (out of 256, from mobjinfo)
63
+ PAIN_CHANCE = {
64
+ 3004 => 200, 9 => 170, 3001 => 200, 3002 => 180, 58 => 180,
65
+ 3003 => 50, 69 => 50, 3005 => 128, 3006 => 256, 16 => 40,
66
+ 7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170,
67
+ }.freeze
68
+ PAIN_DURATION = 6 # Tics monster is stunned when in pain
69
+
70
+ # Projectile constants
71
+ ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
72
+ ROCKET_DAMAGE = 20 # Direct hit base (DOOM: 1d8 * 20)
73
+ ROCKET_RADIUS = 11 # Collision radius
74
+ SPLASH_RADIUS = 128.0 # Splash damage radius
75
+ SPLASH_DAMAGE = 128 # Max splash damage at center
76
+
77
+ # Monster projectile definitions
78
+ MONSTER_PROJECTILES = {
79
+ imp: { sprite: 'BAL1', speed: 10.0, damage: [3, 24], radius: 6, splash: false },
80
+ baron: { sprite: 'BAL7', speed: 15.0, damage: [8, 64], radius: 6, splash: false },
81
+ caco: { sprite: 'BAL2', speed: 10.0, damage: [5, 40], radius: 6, splash: false },
82
+ }.freeze
83
+
84
+ # Map monster type to projectile type
85
+ MONSTER_PROJECTILE_TYPE = {
86
+ 3001 => :imp, # Imp
87
+ 3003 => :baron, # Baron
88
+ 69 => :baron, # Hell Knight
89
+ 3005 => :caco, # Cacodemon
90
+ }.freeze
91
+
92
+ Projectile = Struct.new(:x, :y, :z, :dx, :dy, :dz, :type, :spawn_tic, :sprite_prefix, :target)
93
+
53
94
  # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
54
95
  # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
55
96
  # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
56
97
  # Fist/chainsaw: 1*2..3*2 = 2-10
57
98
 
58
- def initialize(map, player_state, sprites)
99
+ def initialize(map, player_state, sprites, hidden_things = {}, sound_engine = nil)
59
100
  @map = map
60
101
  @player = player_state
61
102
  @sprites = sprites
103
+ @hidden_things = hidden_things
104
+ @sound = sound_engine
62
105
  @monster_hp = {} # thing_idx => current HP
63
106
  @dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
107
+ @pain_until = {} # thing_idx => tic when pain ends
108
+ @projectiles = [] # Active projectiles in flight
109
+ @explosions = [] # Active explosions (for rendering)
110
+ @puffs = [] # Bullet puff effects
111
+ @player_x = 0.0
112
+ @player_y = 0.0
113
+ @player_z = 0.0
64
114
  @tic = 0
65
115
  end
66
116
 
67
- attr_reader :dead_things
117
+ def update_player_pos(x, y, z = nil)
118
+ @player_x = x
119
+ @player_y = y
120
+ @player_z = z if z
121
+ end
122
+
123
+ # Spawn a monster projectile (fireball, etc.)
124
+ # Matches Chocolate Doom's P_SpawnMissile: calculates momz for vertical aim
125
+ def spawn_monster_projectile(monster_x, monster_y, monster_z, monster_type, damage_multiplier)
126
+ proj_type = MONSTER_PROJECTILE_TYPE[monster_type]
127
+ return unless proj_type
128
+
129
+ info = MONSTER_PROJECTILES[proj_type]
130
+ return unless info
131
+
132
+ dx = @player_x - monster_x
133
+ dy = @player_y - monster_y
134
+ dist = Math.sqrt(dx * dx + dy * dy)
135
+ return if dist < 1
136
+
137
+ # Normalize direction and apply speed
138
+ speed = info[:speed]
139
+ ndx = dx / dist * speed
140
+ ndy = dy / dist * speed
141
+
142
+ # P_SpawnMissile: momz = (target.z - source.z) / (dist / speed)
143
+ # This makes the projectile arc toward the target's height
144
+ target_z = @player_z - 16 # Aim at player center (z + height/2, roughly)
145
+ travel_tics = dist / speed
146
+ travel_tics = 1.0 if travel_tics < 1.0
147
+ ndz = (target_z - monster_z) / travel_tics
148
+
149
+ @projectiles << Projectile.new(
150
+ monster_x + ndx * 2, monster_y + ndy * 2, monster_z,
151
+ ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player
152
+ )
153
+ end
154
+
155
+ attr_reader :dead_things, :projectiles, :explosions, :puffs
156
+
157
+ def in_pain?(thing_idx)
158
+ @pain_until[thing_idx] && @tic < @pain_until[thing_idx]
159
+ end
68
160
 
69
161
  def dead?(thing_idx)
70
162
  @dead_things.key?(thing_idx)
71
163
  end
72
164
 
73
- # Get the current death frame sprite for a dead monster
165
+ # Get the current death frame sprite for a dead monster/barrel
74
166
  def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
75
167
  info = @dead_things[thing_idx]
76
168
  return nil unless info
77
169
 
78
- frames = DEATH_FRAMES[info[:prefix]]
170
+ prefix = info[:prefix]
171
+ frames = DEATH_FRAMES[prefix]
79
172
  return nil unless frames
80
173
 
81
174
  elapsed = @tic - info[:tic]
82
- frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
175
+ frame_idx = elapsed / DEATH_ANIM_TICS
176
+
177
+ # Barrels disappear after explosion animation (S_NULL in Chocolate Doom)
178
+ if thing_type == BARREL_TYPE && frame_idx >= frames.size
179
+ return nil
180
+ end
181
+
182
+ frame_idx = frame_idx.clamp(0, frames.size - 1)
83
183
  frame_letter = frames[frame_idx]
84
184
 
85
- @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
185
+ # Use prefix directly if it differs from the thing's sprite (e.g. BEXP for barrels)
186
+ thing_prefix = @sprites.prefix_for(thing_type)
187
+ if prefix != thing_prefix
188
+ @sprites.get_frame_by_prefix(prefix, frame_letter)
189
+ else
190
+ @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
191
+ end
86
192
  end
87
193
 
88
194
  # Called each game tic
89
195
  def update
90
196
  @tic += 1
197
+ update_projectiles
198
+ update_explosions
199
+ @puffs.reject! { |p| @tic - p[:tic] > 12 }
91
200
  end
92
201
 
93
202
  # Fire the current weapon
@@ -97,6 +206,8 @@ module Doom
97
206
  hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
98
207
  when PlayerState::WEAPON_SHOTGUN
99
208
  hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
209
+ when PlayerState::WEAPON_ROCKET
210
+ spawn_rocket(px, py, pz, cos_a, sin_a)
100
211
  when PlayerState::WEAPON_FIST
101
212
  melee(px, py, cos_a, sin_a, 2, 64)
102
213
  when PlayerState::WEAPON_CHAINSAW
@@ -106,6 +217,151 @@ module Doom
106
217
 
107
218
  private
108
219
 
220
+ def spawn_rocket(px, py, pz, cos_a, sin_a)
221
+ @projectiles << Projectile.new(
222
+ px + cos_a * 20, py + sin_a * 20, pz,
223
+ cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED, 0.0,
224
+ :rocket, @tic, 'MISL', :monsters
225
+ )
226
+ end
227
+
228
+ def update_projectiles
229
+ @projectiles.reject! do |proj|
230
+ new_x = proj.x + proj.dx
231
+ new_y = proj.y + proj.dy
232
+ new_z = proj.z + (proj.dz || 0)
233
+
234
+ hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
235
+
236
+ # Check if projectile hit the floor or ceiling
237
+ sector = @map.sector_at(new_x, new_y)
238
+ if sector
239
+ hit_wall = true if new_z <= sector.floor_height || new_z >= sector.ceiling_height
240
+ end
241
+ hit = false
242
+
243
+ if proj.target == :monsters
244
+ # Player projectile: check monster collision
245
+ hit_monster = nil
246
+ @map.things.each_with_index do |thing, idx|
247
+ next unless MONSTER_HP[thing.type]
248
+ next if @dead_things[idx]
249
+ radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
250
+ dx = new_x - thing.x
251
+ dy = new_y - thing.y
252
+ if dx * dx + dy * dy < radius * radius
253
+ hit_monster = idx
254
+ break
255
+ end
256
+ end
257
+
258
+ if hit_wall || hit_monster
259
+ explode(new_x, new_y, hit_monster) if proj.type == :rocket
260
+ hit_monster ? apply_damage(hit_monster, (rand(8) + 1) * 5) : nil unless proj.type == :rocket
261
+ hit = true
262
+ end
263
+ elsif proj.target == :player
264
+ # Monster projectile: check player collision
265
+ player_radius = 16
266
+ dx = new_x - @player_x
267
+ dy = new_y - @player_y
268
+ if hit_wall || (dx * dx + dy * dy < (player_radius + 6) ** 2)
269
+ unless hit_wall
270
+ info = MONSTER_PROJECTILES[proj.type]
271
+ if info
272
+ min_d, max_d = info[:damage]
273
+ @player.take_damage(rand(min_d..max_d))
274
+ end
275
+ end
276
+ # Spawn fireball explosion
277
+ @explosions << { x: new_x, y: new_y, z: proj.z, tic: @tic, sprite: proj.sprite_prefix }
278
+ hit = true
279
+ end
280
+ end
281
+
282
+ if hit
283
+ true
284
+ else
285
+ proj.x = new_x
286
+ proj.y = new_y
287
+ proj.z = new_z
288
+ false
289
+ end
290
+ end
291
+ end
292
+
293
+ def explode(x, y, direct_hit_idx)
294
+ # Direct hit damage
295
+ if direct_hit_idx
296
+ damage = (rand(8) + 1) * ROCKET_DAMAGE
297
+ apply_damage(direct_hit_idx, damage)
298
+ end
299
+
300
+ # Splash damage to all monsters in radius
301
+ @map.things.each_with_index do |thing, idx|
302
+ next unless MONSTER_HP[thing.type]
303
+ next if @dead_things[idx]
304
+ next if idx == direct_hit_idx # Already took direct hit
305
+
306
+ dx = x - thing.x
307
+ dy = y - thing.y
308
+ dist = Math.sqrt(dx * dx + dy * dy)
309
+ next if dist >= SPLASH_RADIUS
310
+
311
+ # Damage falls off linearly with distance
312
+ damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
313
+ apply_damage(idx, damage) if damage > 0
314
+ end
315
+
316
+ # Spawn explosion visual
317
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
318
+ end
319
+
320
+ def update_explosions
321
+ # Explosions last 20 tics then disappear
322
+ @explosions.reject! { |e| @tic - e[:tic] > 20 }
323
+ end
324
+
325
+ def hits_wall?(x1, y1, x2, y2)
326
+ @map.linedefs.each do |ld|
327
+ # One-sided walls always block projectiles
328
+ if ld.sidedef_left == 0xFFFF
329
+ blocks = true
330
+ elsif ld.sidedef_left < 0xFFFF
331
+ # Two-sided: only block if opening is too small for a projectile
332
+ # BLOCKING flag (0x0001) stops players/monsters but NOT projectiles
333
+ front = @map.sidedefs[ld.sidedef_right]
334
+ back = @map.sidedefs[ld.sidedef_left]
335
+ fs = @map.sectors[front.sector]
336
+ bs = @map.sectors[back.sector]
337
+ max_floor = [fs.floor_height, bs.floor_height].max
338
+ min_ceil = [fs.ceiling_height, bs.ceiling_height].min
339
+ blocks = (min_ceil - max_floor) < 1 # Closed door/wall
340
+ else
341
+ next
342
+ end
343
+ next unless blocks
344
+
345
+ v1 = @map.vertices[ld.v1]
346
+ v2 = @map.vertices[ld.v2]
347
+ if segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
348
+ return true
349
+ end
350
+ end
351
+ false
352
+ end
353
+
354
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
355
+ d1x = ax2 - ax1; d1y = ay2 - ay1
356
+ d2x = bx2 - bx1; d2y = by2 - by1
357
+ denom = d1x * d2y - d1y * d2x
358
+ return false if denom.abs < 0.001
359
+ dx = bx1 - ax1; dy = by1 - ay1
360
+ t = (dx * d2y - dy * d2x).to_f / denom
361
+ u = (dx * d1y - dy * d1x).to_f / denom
362
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
363
+ end
364
+
109
365
  def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
110
366
  pellets.times do
111
367
  # Add random spread
@@ -126,6 +382,7 @@ module Doom
126
382
  best_dist = wall_dist
127
383
 
128
384
  @map.things.each_with_index do |thing, idx|
385
+ next if @hidden_things[idx]
129
386
  next unless MONSTER_HP[thing.type]
130
387
  next if @dead_things[idx]
131
388
 
@@ -137,6 +394,12 @@ module Doom
137
394
  end
138
395
  end
139
396
 
397
+ # Spawn bullet puff at hit location
398
+ puff_x = px + ca * best_dist
399
+ puff_y = py + sa * best_dist
400
+ puff_z = @player_z
401
+ @puffs << { x: puff_x, y: puff_y, z: puff_z, tic: @tic }
402
+
140
403
  if best_idx
141
404
  damage = (rand(3) + 1) * multiplier
142
405
  apply_damage(best_idx, damage)
@@ -180,8 +443,54 @@ module Doom
180
443
  @monster_hp[thing_idx] -= damage
181
444
 
182
445
  if @monster_hp[thing_idx] <= 0
183
- prefix = @sprites.prefix_for(thing.type)
184
- @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
446
+ return if @dead_things[thing_idx] # Already dead
447
+
448
+ if thing.type == BARREL_TYPE
449
+ @dead_things[thing_idx] = { tic: @tic, prefix: 'BEXP' }
450
+ @sound&.explosion
451
+ barrel_explode(thing.x, thing.y, thing_idx)
452
+ else
453
+ prefix = @sprites.prefix_for(thing.type)
454
+ @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
455
+ @sound&.monster_death(thing.type)
456
+ end
457
+ else
458
+ # Pain state: monster flinches (not barrels)
459
+ if thing.type != BARREL_TYPE
460
+ pain_chance = PAIN_CHANCE[thing.type] || 128
461
+ if rand(256) < pain_chance
462
+ @pain_until[thing_idx] = @tic + PAIN_DURATION
463
+ @sound&.monster_pain(thing.type)
464
+ end
465
+ end
466
+ end
467
+ end
468
+
469
+ def barrel_explode(x, y, barrel_idx)
470
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
471
+
472
+ # Splash damage to monsters and other barrels (chain reactions!)
473
+ @map.things.each_with_index do |thing, idx|
474
+ next unless MONSTER_HP[thing.type]
475
+ next if @dead_things[idx]
476
+ next if idx == barrel_idx
477
+
478
+ dx = x - thing.x
479
+ dy = y - thing.y
480
+ dist = Math.sqrt(dx * dx + dy * dy)
481
+ next if dist >= BARREL_SPLASH_RADIUS
482
+
483
+ damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
484
+ apply_damage(idx, damage) if damage > 0
485
+ end
486
+
487
+ # Splash damage to player
488
+ dx = x - @player_x
489
+ dy = y - @player_y
490
+ dist = Math.sqrt(dx * dx + dy * dy)
491
+ if dist < BARREL_SPLASH_RADIUS
492
+ damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
493
+ @player.take_damage(damage) if damage > 0
185
494
  end
186
495
  end
187
496
 
@@ -192,18 +501,21 @@ module Doom
192
501
  v1 = @map.vertices[ld.v1]
193
502
  v2 = @map.vertices[ld.v2]
194
503
 
195
- # One-sided always blocks; two-sided only if impassable
196
- blocks = (ld.sidedef_left == 0xFFFF) || (ld.flags & 0x0001 != 0)
197
- unless blocks
198
- next unless ld.sidedef_left < 0xFFFF
504
+ # One-sided always blocks hitscan
505
+ if ld.sidedef_left == 0xFFFF
506
+ blocks = true
507
+ elsif ld.sidedef_left < 0xFFFF
508
+ # Two-sided: only blocks if opening is too small
509
+ # BLOCKING flag does NOT stop hitscan (only affects movement)
199
510
  front = @map.sidedefs[ld.sidedef_right]
200
511
  back = @map.sidedefs[ld.sidedef_left]
201
512
  fs = @map.sectors[front.sector]
202
513
  bs = @map.sectors[back.sector]
203
- # Blocks if opening is too small (step or low ceiling)
204
514
  max_floor = [fs.floor_height, bs.floor_height].max
205
515
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
206
516
  blocks = (min_ceil - max_floor) < 56
517
+ else
518
+ next
207
519
  end
208
520
  next unless blocks
209
521