doom 0.4.0 → 0.6.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: cb48bcb7558c2e4f4ab959b16dbd857d2a1b6a3b9a9cc76c19a08ec91bdeb93e
4
- data.tar.gz: 610f42cf954c60048a0c4030d96a8e8ceb2872aba90e90a141b2179329867c05
3
+ metadata.gz: c3cb562e8b88b905638cfa8792337bc1b7f818753fa90587bda474784513a6bd
4
+ data.tar.gz: 1f421713b797c02dfc10e2f9bdd3ad0981a631f88bc94df161aa5a792b539094
5
5
  SHA512:
6
- metadata.gz: 1ff09fd05c29a5c49b7d41285424d58b8b46dbf995e1f18106d571f18866762e8b20ecb47d472dc2cb2be8f2fc8cdea5eb0b3a2dedae55d5b1881bbcb796088b
7
- data.tar.gz: 41d4fcb793f3b25fb57f2d50c02f5eee1a56cb40a40baf2a9f7e0831634f43aa4777abf80968130c44593055dbaea23bd3257e7d4b509a068af2c92e1e16698d
6
+ metadata.gz: b06fd18ebfa1c5755e27c11b49e9e568fc4e2acfc7bc2b9760e7b7dda500a438afe9a6ecf0e33aa4f17c2271a6c28205e6bbb793e3b9c618c4df24b8bf74679a
7
+ data.tar.gz: ed5387b2c42963956cfe42c1cfe790a8f7f0e89a15c5a38934780b5ceaca34e3e64456756280d9c8f2d280763ef0fff89434dd8e826dc2e9e0184e25c4f94508
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
- ![DOOM Ruby Screenshot](screenshot.png)
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,9 +1,11 @@
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
 
9
11
  # Parse arguments before loading heavy dependencies
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Animated texture/flat cycling, matching Chocolate Doom's P_InitPicAnims
6
+ # and P_UpdateSpecials from p_spec.c.
7
+ #
8
+ # All animations run at 8 tics per frame (8/35 sec ≈ 0.23s).
9
+ # Frames must be consecutive entries in the WAD; the engine uses
10
+ # start/end names to find the range.
11
+ class Animations
12
+ TICS_PER_FRAME = 8
13
+
14
+ # [is_texture, start_name, end_name]
15
+ # From Chocolate Doom animdefs[] in p_spec.c
16
+ ANIMDEFS = [
17
+ # Animated flats
18
+ [false, 'NUKAGE1', 'NUKAGE3'],
19
+ [false, 'FWATER1', 'FWATER4'],
20
+ [false, 'SWATER1', 'SWATER4'],
21
+ [false, 'LAVA1', 'LAVA4'],
22
+ [false, 'BLOOD1', 'BLOOD3'],
23
+ [false, 'RROCK05', 'RROCK08'],
24
+ [false, 'SLIME01', 'SLIME04'],
25
+ [false, 'SLIME05', 'SLIME08'],
26
+ [false, 'SLIME09', 'SLIME12'],
27
+ # Animated wall textures
28
+ [true, 'BLODGR1', 'BLODGR4'],
29
+ [true, 'SLADRIP1', 'SLADRIP3'],
30
+ [true, 'BLODRIP1', 'BLODRIP4'],
31
+ [true, 'FIREWALA', 'FIREWALL'],
32
+ [true, 'GSTFONT1', 'GSTFONT3'],
33
+ [true, 'FIRELAV3', 'FIRELAVA'],
34
+ [true, 'FIREMAG1', 'FIREMAG3'],
35
+ [true, 'FIREBLU1', 'FIREBLU2'],
36
+ [true, 'ROCKRED1', 'ROCKRED3'],
37
+ [true, 'BFALL1', 'BFALL4'],
38
+ [true, 'SFALL1', 'SFALL4'],
39
+ [true, 'WFALL1', 'WFALL4'],
40
+ [true, 'DBRAIN1', 'DBRAIN4'],
41
+ ].freeze
42
+
43
+ attr_reader :flat_translation, :texture_translation
44
+
45
+ def initialize(texture_names, flat_names)
46
+ @flat_translation = {} # flat_name -> current_frame_name
47
+ @texture_translation = {} # texture_name -> current_frame_name
48
+ @anims = []
49
+
50
+ ANIMDEFS.each do |is_texture, start_name, end_name|
51
+ names = is_texture ? texture_names : flat_names
52
+
53
+ start_idx = names.index(start_name)
54
+ end_idx = names.index(end_name)
55
+ next unless start_idx && end_idx
56
+ next if end_idx <= start_idx
57
+
58
+ frames = names[start_idx..end_idx]
59
+ next if frames.size < 2
60
+
61
+ @anims << {
62
+ is_texture: is_texture,
63
+ frames: frames,
64
+ speed: TICS_PER_FRAME,
65
+ }
66
+ end
67
+ end
68
+
69
+ # Call every game tic (or approximate with leveltime).
70
+ # Matches Chocolate Doom P_UpdateSpecials:
71
+ # pic = basepic + ((leveltime / speed + i) % numpics)
72
+ def update(leveltime)
73
+ @anims.each do |anim|
74
+ frames = anim[:frames]
75
+ numpics = frames.size
76
+ phase = leveltime / anim[:speed]
77
+ translation = anim[:is_texture] ? @texture_translation : @flat_translation
78
+
79
+ numpics.times do |i|
80
+ current_frame = frames[(phase + i) % numpics]
81
+ translation[frames[i]] = current_frame
82
+ end
83
+ end
84
+ end
85
+
86
+ # Translate a flat name to its current animation frame
87
+ def translate_flat(name)
88
+ @flat_translation[name] || name
89
+ end
90
+
91
+ # Translate a texture name to its current animation frame
92
+ def translate_texture(name)
93
+ @texture_translation[name] || name
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,370 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Hitscan weapon firing and monster state tracking.
6
+ # Matches Chocolate Doom's P_LineAttack / P_AimLineAttack from p_map.c.
7
+ class Combat
8
+ # Monster starting HP (from mobjinfo[] in info.c)
9
+ MONSTER_HP = {
10
+ 3004 => 20, # Zombieman
11
+ 9 => 30, # Shotgun Guy
12
+ 3001 => 60, # Imp
13
+ 3002 => 150, # Demon
14
+ 58 => 150, # Spectre
15
+ 3003 => 1000, # Baron of Hell
16
+ 69 => 500, # Hell Knight
17
+ 3005 => 400, # Cacodemon
18
+ 3006 => 100, # Lost Soul
19
+ 16 => 4000, # Cyberdemon
20
+ 7 => 3000, # Spider Mastermind
21
+ 65 => 70, # Heavy Weapon Dude
22
+ 64 => 700, # Archvile
23
+ 71 => 400, # Pain Elemental
24
+ 84 => 20, # Wolfenstein SS
25
+ }.freeze
26
+
27
+ MONSTER_RADIUS = {
28
+ 3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
29
+ 3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
30
+ 7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
31
+ }.freeze
32
+
33
+ # Normal death frame sequences per sprite prefix (rotation 0 only)
34
+ # Identified by sprite heights: frames go from standing height to flat on ground
35
+ DEATH_FRAMES = {
36
+ 'POSS' => %w[H I J K L], # Zombieman: 55→46→34→27→17
37
+ 'SPOS' => %w[H I J K L], # Shotgun Guy: 60→50→35→27→17
38
+ 'TROO' => %w[I J K L M], # Imp: 62→59→54→46→22
39
+ 'SARG' => %w[I J K L M N], # Demon/Spectre: 56→56→53→57→46→32
40
+ 'BOSS' => %w[H I J K L M N], # Baron
41
+ 'BOS2' => %w[H I J K L M N], # Hell Knight
42
+ 'HEAD' => %w[G H I J K L], # Cacodemon
43
+ 'SKUL' => %w[G H I J K], # Lost Soul
44
+ 'CYBR' => %w[I J], # Cyberdemon
45
+ 'SPID' => %w[I J K], # Spider Mastermind
46
+ 'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
47
+ 'PAIN' => %w[H I J K L M], # Pain Elemental
48
+ 'SSWV' => %w[I J K L M], # Wolfenstein SS
49
+ }.freeze
50
+
51
+ DEATH_ANIM_TICS = 6 # Tics per death frame
52
+
53
+ # Projectile constants
54
+ ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
55
+ ROCKET_DAMAGE = 20 # Direct hit base (DOOM: 1d8 * 20)
56
+ ROCKET_RADIUS = 11 # Collision radius
57
+ SPLASH_RADIUS = 128.0 # Splash damage radius
58
+ SPLASH_DAMAGE = 128 # Max splash damage at center
59
+
60
+ Projectile = Struct.new(:x, :y, :z, :dx, :dy, :type, :spawn_tic)
61
+
62
+ # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
63
+ # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
64
+ # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
65
+ # Fist/chainsaw: 1*2..3*2 = 2-10
66
+
67
+ def initialize(map, player_state, sprites)
68
+ @map = map
69
+ @player = player_state
70
+ @sprites = sprites
71
+ @monster_hp = {} # thing_idx => current HP
72
+ @dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
73
+ @projectiles = [] # Active projectiles in flight
74
+ @explosions = [] # Active explosions (for rendering)
75
+ @tic = 0
76
+ end
77
+
78
+ attr_reader :dead_things, :projectiles, :explosions
79
+
80
+ def dead?(thing_idx)
81
+ @dead_things.key?(thing_idx)
82
+ end
83
+
84
+ # Get the current death frame sprite for a dead monster
85
+ def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
86
+ info = @dead_things[thing_idx]
87
+ return nil unless info
88
+
89
+ frames = DEATH_FRAMES[info[:prefix]]
90
+ return nil unless frames
91
+
92
+ elapsed = @tic - info[:tic]
93
+ frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
94
+ frame_letter = frames[frame_idx]
95
+
96
+ @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
97
+ end
98
+
99
+ # Called each game tic
100
+ def update
101
+ @tic += 1
102
+ update_projectiles
103
+ update_explosions
104
+ end
105
+
106
+ # Fire the current weapon
107
+ def fire(px, py, pz, cos_a, sin_a, weapon)
108
+ case weapon
109
+ when PlayerState::WEAPON_PISTOL, PlayerState::WEAPON_CHAINGUN
110
+ hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
111
+ when PlayerState::WEAPON_SHOTGUN
112
+ hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
113
+ when PlayerState::WEAPON_ROCKET
114
+ spawn_rocket(px, py, pz, cos_a, sin_a)
115
+ when PlayerState::WEAPON_FIST
116
+ melee(px, py, cos_a, sin_a, 2, 64)
117
+ when PlayerState::WEAPON_CHAINSAW
118
+ melee(px, py, cos_a, sin_a, 2, 64)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def spawn_rocket(px, py, pz, cos_a, sin_a)
125
+ # Spawn slightly ahead of the player
126
+ @projectiles << Projectile.new(
127
+ px + cos_a * 20, py + sin_a * 20, pz,
128
+ cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED,
129
+ :rocket, @tic
130
+ )
131
+ end
132
+
133
+ def update_projectiles
134
+ @projectiles.reject! do |proj|
135
+ # Move projectile
136
+ new_x = proj.x + proj.dx
137
+ new_y = proj.y + proj.dy
138
+
139
+ # Check wall collision
140
+ hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
141
+
142
+ # Check monster collision
143
+ hit_monster = nil
144
+ @map.things.each_with_index do |thing, idx|
145
+ next unless MONSTER_HP[thing.type]
146
+ next if @dead_things[idx]
147
+ radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
148
+ dx = new_x - thing.x
149
+ dy = new_y - thing.y
150
+ if dx * dx + dy * dy < radius * radius
151
+ hit_monster = idx
152
+ break
153
+ end
154
+ end
155
+
156
+ if hit_wall || hit_monster
157
+ explode(new_x, new_y, hit_monster)
158
+ true # Remove projectile
159
+ else
160
+ proj.x = new_x
161
+ proj.y = new_y
162
+ false # Keep projectile
163
+ end
164
+ end
165
+ end
166
+
167
+ def explode(x, y, direct_hit_idx)
168
+ # Direct hit damage
169
+ if direct_hit_idx
170
+ damage = (rand(8) + 1) * ROCKET_DAMAGE
171
+ apply_damage(direct_hit_idx, damage)
172
+ end
173
+
174
+ # Splash damage to all monsters in radius
175
+ @map.things.each_with_index do |thing, idx|
176
+ next unless MONSTER_HP[thing.type]
177
+ next if @dead_things[idx]
178
+ next if idx == direct_hit_idx # Already took direct hit
179
+
180
+ dx = x - thing.x
181
+ dy = y - thing.y
182
+ dist = Math.sqrt(dx * dx + dy * dy)
183
+ next if dist >= SPLASH_RADIUS
184
+
185
+ # Damage falls off linearly with distance
186
+ damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
187
+ apply_damage(idx, damage) if damage > 0
188
+ end
189
+
190
+ # Spawn explosion visual
191
+ @explosions << { x: x, y: y, tic: @tic }
192
+ end
193
+
194
+ def update_explosions
195
+ # Explosions last 20 tics then disappear
196
+ @explosions.reject! { |e| @tic - e[:tic] > 20 }
197
+ end
198
+
199
+ def hits_wall?(x1, y1, x2, y2)
200
+ @map.linedefs.each do |ld|
201
+ # One-sided walls always block
202
+ blocks = ld.sidedef_left == 0xFFFF || (ld.flags & 0x0001 != 0)
203
+ unless blocks
204
+ next unless ld.sidedef_left < 0xFFFF
205
+ front = @map.sidedefs[ld.sidedef_right]
206
+ back = @map.sidedefs[ld.sidedef_left]
207
+ fs = @map.sectors[front.sector]
208
+ bs = @map.sectors[back.sector]
209
+ max_floor = [fs.floor_height, bs.floor_height].max
210
+ min_ceil = [fs.ceiling_height, bs.ceiling_height].min
211
+ blocks = (min_ceil - max_floor) < 56
212
+ end
213
+ next unless blocks
214
+
215
+ v1 = @map.vertices[ld.v1]
216
+ v2 = @map.vertices[ld.v2]
217
+ if segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
218
+ return true
219
+ end
220
+ end
221
+ false
222
+ end
223
+
224
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
225
+ d1x = ax2 - ax1; d1y = ay2 - ay1
226
+ d2x = bx2 - bx1; d2y = by2 - by1
227
+ denom = d1x * d2y - d1y * d2x
228
+ return false if denom.abs < 0.001
229
+ dx = bx1 - ax1; dy = by1 - ay1
230
+ t = (dx * d2y - dy * d2x).to_f / denom
231
+ u = (dx * d1y - dy * d1x).to_f / denom
232
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
233
+ end
234
+
235
+ def hitscan(px, py, cos_a, sin_a, pellets, spread, multiplier)
236
+ pellets.times do
237
+ # Add random spread
238
+ if spread > 0
239
+ angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * spread * 2
240
+ ca = Math.cos(angle)
241
+ sa = Math.sin(angle)
242
+ else
243
+ # Slight pistol/chaingun spread
244
+ angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * 0.04
245
+ ca = Math.cos(angle)
246
+ sa = Math.sin(angle)
247
+ end
248
+
249
+ wall_dist = trace_wall(px, py, ca, sa)
250
+
251
+ best_idx = nil
252
+ best_dist = wall_dist
253
+
254
+ @map.things.each_with_index do |thing, idx|
255
+ next unless MONSTER_HP[thing.type]
256
+ next if @dead_things[idx]
257
+
258
+ radius = MONSTER_RADIUS[thing.type] || 20
259
+ hit_dist = ray_circle_hit(px, py, ca, sa, thing.x, thing.y, radius)
260
+ if hit_dist && hit_dist > 0 && hit_dist < best_dist
261
+ best_dist = hit_dist
262
+ best_idx = idx
263
+ end
264
+ end
265
+
266
+ if best_idx
267
+ damage = (rand(3) + 1) * multiplier
268
+ apply_damage(best_idx, damage)
269
+ end
270
+ end
271
+ end
272
+
273
+ def melee(px, py, cos_a, sin_a, multiplier, range)
274
+ best_idx = nil
275
+ best_dist = range.to_f
276
+
277
+ @map.things.each_with_index do |thing, idx|
278
+ next unless MONSTER_HP[thing.type]
279
+ next if @dead_things[idx]
280
+
281
+ dx = thing.x - px
282
+ dy = thing.y - py
283
+ dist = Math.sqrt(dx * dx + dy * dy)
284
+ next if dist > range + (MONSTER_RADIUS[thing.type] || 20)
285
+
286
+ # Check if roughly facing the monster
287
+ dot = dx * cos_a + dy * sin_a
288
+ next if dot < 0
289
+
290
+ if dist < best_dist
291
+ best_dist = dist
292
+ best_idx = idx
293
+ end
294
+ end
295
+
296
+ if best_idx
297
+ damage = (rand(3) + 1) * multiplier
298
+ apply_damage(best_idx, damage)
299
+ end
300
+ end
301
+
302
+ def apply_damage(thing_idx, damage)
303
+ thing = @map.things[thing_idx]
304
+ @monster_hp[thing_idx] ||= MONSTER_HP[thing.type] || 20
305
+
306
+ @monster_hp[thing_idx] -= damage
307
+
308
+ if @monster_hp[thing_idx] <= 0
309
+ prefix = @sprites.prefix_for(thing.type)
310
+ @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
311
+ end
312
+ end
313
+
314
+ def trace_wall(px, py, cos_a, sin_a)
315
+ best_t = 4096.0 # Max hitscan range
316
+
317
+ @map.linedefs.each do |ld|
318
+ v1 = @map.vertices[ld.v1]
319
+ v2 = @map.vertices[ld.v2]
320
+
321
+ # One-sided always blocks; two-sided only if impassable
322
+ blocks = (ld.sidedef_left == 0xFFFF) || (ld.flags & 0x0001 != 0)
323
+ unless blocks
324
+ next unless ld.sidedef_left < 0xFFFF
325
+ front = @map.sidedefs[ld.sidedef_right]
326
+ back = @map.sidedefs[ld.sidedef_left]
327
+ fs = @map.sectors[front.sector]
328
+ bs = @map.sectors[back.sector]
329
+ # Blocks if opening is too small (step or low ceiling)
330
+ max_floor = [fs.floor_height, bs.floor_height].max
331
+ min_ceil = [fs.ceiling_height, bs.ceiling_height].min
332
+ blocks = (min_ceil - max_floor) < 56
333
+ end
334
+ next unless blocks
335
+
336
+ t = ray_segment_intersect(px, py, cos_a, sin_a,
337
+ v1.x, v1.y, v2.x, v2.y)
338
+ best_t = t if t && t > 0 && t < best_t
339
+ end
340
+
341
+ best_t
342
+ end
343
+
344
+ def ray_segment_intersect(px, py, dx, dy, x1, y1, x2, y2)
345
+ sx = x2 - x1
346
+ sy = y2 - y1
347
+ denom = dx * sy - dy * sx
348
+ return nil if denom.abs < 0.001
349
+
350
+ t = ((x1 - px) * sy - (y1 - py) * sx) / denom
351
+ u = ((x1 - px) * dy - (y1 - py) * dx) / denom
352
+
353
+ (t > 0 && u >= 0.0 && u <= 1.0) ? t : nil
354
+ end
355
+
356
+ def ray_circle_hit(px, py, cos_a, sin_a, cx, cy, radius)
357
+ dx = cx - px
358
+ dy = cy - py
359
+ proj = dx * cos_a + dy * sin_a
360
+ return nil if proj < 0
361
+
362
+ perp_sq = dx * dx + dy * dy - proj * proj
363
+ return nil if perp_sq > radius * radius
364
+
365
+ chord_half = Math.sqrt([radius * radius - perp_sq, 0].max)
366
+ proj - chord_half
367
+ end
368
+ end
369
+ end
370
+ end