doom 0.6.0 → 0.11.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -2,34 +2,43 @@
2
2
 
3
3
  module Doom
4
4
  module Game
5
- # Hitscan weapon firing and monster state tracking.
6
- # Matches Chocolate Doom's P_LineAttack / P_AimLineAttack from p_map.c.
5
+ # Combat resolution for every weapon and every damageable thing: hitscan
6
+ # (pistol/shotgun/chaingun), melee (fist/chainsaw), rockets and their splash,
7
+ # monster fireballs, barrel chain reactions, player-vs-player damage and frag
8
+ # scoring. Hitscan/aim matches Chocolate Doom's P_LineAttack / P_AimLineAttack
9
+ # (p_map.c); splash matches P_RadiusAttack (p_inter.c).
7
10
  class Combat
8
11
  # Monster starting HP (from mobjinfo[] in info.c)
9
12
  MONSTER_HP = {
10
- 3004 => 20, # Zombieman
11
- 9 => 30, # Shotgun Guy
13
+ 3004 => 20, # Zombieman
14
+ 9 => 30, # Shotgun Guy
12
15
  3001 => 60, # Imp
13
16
  3002 => 150, # Demon
14
- 58 => 150, # Spectre
17
+ 58 => 150, # Spectre
15
18
  3003 => 1000, # Baron of Hell
16
- 69 => 500, # Hell Knight
19
+ 69 => 500, # Hell Knight
17
20
  3005 => 400, # Cacodemon
18
21
  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
22
+ 16 => 4000, # Cyberdemon
23
+ 7 => 3000, # Spider Mastermind
24
+ 65 => 70, # Heavy Weapon Dude
25
+ 64 => 700, # Archvile
26
+ 71 => 400, # Pain Elemental
27
+ 84 => 20, # Wolfenstein SS
28
+ 2035 => 20 # Explosive barrel
25
29
  }.freeze
26
30
 
27
31
  MONSTER_RADIUS = {
28
32
  3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
29
33
  3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
30
34
  7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
35
+ 2035 => 10 # Barrel
31
36
  }.freeze
32
37
 
38
+ # Barrel (explosive, not a monster but damageable). Its HP comes from
39
+ # MONSTER_HP[BARREL_TYPE]; its explosion reuses the shared SPLASH_* values.
40
+ BARREL_TYPE = 2035
41
+
33
42
  # Normal death frame sequences per sprite prefix (rotation 0 only)
34
43
  # Identified by sprite heights: frames go from standing height to flat on ground
35
44
  DEATH_FRAMES = {
@@ -46,9 +55,18 @@ module Doom
46
55
  'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
47
56
  'PAIN' => %w[H I J K L M], # Pain Elemental
48
57
  'SSWV' => %w[I J K L M], # Wolfenstein SS
58
+ 'BEXP' => %w[A B C D E] # Barrel explosion
49
59
  }.freeze
50
60
 
51
- DEATH_ANIM_TICS = 6 # Tics per death frame
61
+ DEATH_ANIM_TICS = 6 # Tics per death frame
62
+
63
+ # Pain chance per monster (out of 256, from mobjinfo)
64
+ PAIN_CHANCE = {
65
+ 3004 => 200, 9 => 170, 3001 => 200, 3002 => 180, 58 => 180,
66
+ 3003 => 50, 69 => 50, 3005 => 128, 3006 => 256, 16 => 40,
67
+ 7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170
68
+ }.freeze
69
+ PAIN_DURATION = 6 # Tics monster is stunned when in pain
52
70
 
53
71
  # Projectile constants
54
72
  ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
@@ -57,43 +75,127 @@ module Doom
57
75
  SPLASH_RADIUS = 128.0 # Splash damage radius
58
76
  SPLASH_DAMAGE = 128 # Max splash damage at center
59
77
 
60
- Projectile = Struct.new(:x, :y, :z, :dx, :dy, :type, :spawn_tic)
78
+ # Monster projectile definitions. :radius is the projectile's own collision
79
+ # radius, added to the player radius when testing a hit.
80
+ MONSTER_PROJECTILES = {
81
+ imp: { sprite: 'BAL1', speed: 10.0, damage: [3, 24], radius: 6 },
82
+ baron: { sprite: 'BAL7', speed: 15.0, damage: [8, 64], radius: 6 },
83
+ caco: { sprite: 'BAL2', speed: 10.0, damage: [5, 40], radius: 6 }
84
+ }.freeze
85
+
86
+ # Map monster type to projectile type
87
+ MONSTER_PROJECTILE_TYPE = {
88
+ 3001 => :imp, # Imp
89
+ 3003 => :baron, # Baron
90
+ 69 => :baron, # Hell Knight
91
+ 3005 => :caco # Cacodemon
92
+ }.freeze
93
+
94
+ # Radius used when a shot or a rocket is tested against a player. Matches
95
+ # the 16 the monster-projectile collision already assumed.
96
+ PLAYER_RADIUS = 16
97
+
98
+ # `owner` is the Player who fired, so a kill can be credited. Monster
99
+ # projectiles leave it nil: DOOM gives no frag for a death by monster.
100
+ # `damage_multiplier` scales the damage a monster fireball deals on impact,
101
+ # so a skill's damage factor reaches the fireball it spawned. Player
102
+ # projectiles (rockets) carry 1.0.
103
+ Projectile = Struct.new(:x, :y, :z, :dx, :dy, :dz, :type, :spawn_tic, :sprite_prefix,
104
+ :target, :owner, :damage_multiplier)
61
105
 
62
106
  # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
63
107
  # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
64
108
  # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
65
109
  # Fist/chainsaw: 1*2..3*2 = 2-10
66
110
 
67
- def initialize(map, player_state, sprites)
111
+ def initialize(map, sprites = nil, hidden_things = {}, sound_engine = nil,
112
+ random: Random.new)
68
113
  @map = map
69
- @player = player_state
114
+ @random = random
70
115
  @sprites = sprites
116
+ @hidden_things = hidden_things
117
+ @sound = sound_engine
71
118
  @monster_hp = {} # thing_idx => current HP
72
119
  @dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
120
+ @pain_until = {} # thing_idx => tic when pain ends
73
121
  @projectiles = [] # Active projectiles in flight
74
122
  @explosions = [] # Active explosions (for rendering)
123
+ @puffs = [] # Bullet puff effects
124
+ @players = []
75
125
  @tic = 0
76
126
  end
77
127
 
78
- attr_reader :dead_things, :projectiles, :explosions
128
+ # Every player the world knows about. Projectile collision, splash damage
129
+ # and monster aim all consider all of them, not one bound player.
130
+ attr_accessor :players
131
+
132
+ # Spawn a monster projectile (fireball, etc.)
133
+ # Matches Chocolate Doom's P_SpawnMissile: calculates momz for vertical aim
134
+ def spawn_monster_projectile(monster_x, monster_y, monster_z, monster_type, damage_multiplier, target)
135
+ proj_type = MONSTER_PROJECTILE_TYPE[monster_type]
136
+ return unless proj_type
137
+
138
+ info = MONSTER_PROJECTILES[proj_type]
139
+ return unless info
140
+
141
+ dx = target.x - monster_x
142
+ dy = target.y - monster_y
143
+ dist = Math.sqrt((dx * dx) + (dy * dy))
144
+ return if dist < 1
145
+
146
+ # Normalize direction and apply speed
147
+ speed = info[:speed]
148
+ ndx = dx / dist * speed
149
+ ndy = dy / dist * speed
150
+
151
+ # P_SpawnMissile: momz = (target.z - source.z) / (dist / speed)
152
+ # This makes the projectile arc toward the target's height
153
+ target_z = target.z - 16 # Aim at player center (z + height/2, roughly)
154
+ travel_tics = dist / speed
155
+ travel_tics = 1.0 if travel_tics < 1.0
156
+ ndz = (target_z - monster_z) / travel_tics
157
+
158
+ @projectiles << Projectile.new(
159
+ monster_x + (ndx * 2), monster_y + (ndy * 2), monster_z,
160
+ ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player, nil, damage_multiplier
161
+ )
162
+ end
163
+
164
+ attr_reader :dead_things, :projectiles, :explosions, :puffs, :sprites
165
+
166
+ def in_pain?(thing_idx)
167
+ @pain_until[thing_idx] && @tic < @pain_until[thing_idx]
168
+ end
79
169
 
80
170
  def dead?(thing_idx)
81
171
  @dead_things.key?(thing_idx)
82
172
  end
83
173
 
84
- # Get the current death frame sprite for a dead monster
174
+ # Get the current death frame sprite for a dead monster/barrel
85
175
  def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
86
176
  info = @dead_things[thing_idx]
87
177
  return nil unless info
88
178
 
89
- frames = DEATH_FRAMES[info[:prefix]]
179
+ prefix = info[:prefix]
180
+ frames = DEATH_FRAMES[prefix]
90
181
  return nil unless frames
91
182
 
92
183
  elapsed = @tic - info[:tic]
93
- frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
184
+ frame_idx = elapsed / DEATH_ANIM_TICS
185
+
186
+ # Barrels disappear after explosion animation (S_NULL in Chocolate Doom)
187
+ return nil if thing_type == BARREL_TYPE && frame_idx >= frames.size
188
+
189
+ frame_idx = frame_idx.clamp(0, frames.size - 1)
94
190
  frame_letter = frames[frame_idx]
95
191
 
96
- @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
192
+ # Use prefix directly if it differs from the thing's sprite (e.g. BEXP for barrels)
193
+ thing_prefix = @sprites.prefix_for(thing_type)
194
+ if prefix == thing_prefix
195
+ @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
196
+ else
197
+ @sprites.get_frame_by_prefix(prefix, frame_letter)
198
+ end
97
199
  end
98
200
 
99
201
  # Called each game tic
@@ -101,94 +203,204 @@ module Doom
101
203
  @tic += 1
102
204
  update_projectiles
103
205
  update_explosions
206
+ @puffs.reject! { |p| @tic - p[:tic] > 12 }
104
207
  end
105
208
 
106
- # Fire the current weapon
107
- def fire(px, py, pz, cos_a, sin_a, weapon)
209
+ # Fire the current weapon. `shooter` is the Player pulling the trigger,
210
+ # needed to credit a deathmatch kill and to stop a shot from hitting the
211
+ # person who fired it.
212
+ def fire(px, py, pz, cos_a, sin_a, weapon, shooter = nil)
108
213
  case weapon
109
214
  when PlayerState::WEAPON_PISTOL, PlayerState::WEAPON_CHAINGUN
110
- hitscan(px, py, cos_a, sin_a, 1, 0.0, 5)
215
+ hitscan(px, py, pz, cos_a, sin_a, 1, 0.0, 5, shooter)
111
216
  when PlayerState::WEAPON_SHOTGUN
112
- hitscan(px, py, cos_a, sin_a, 7, Math::PI / 32, 5)
217
+ hitscan(px, py, pz, cos_a, sin_a, 7, Math::PI / 32, 5, shooter)
113
218
  when PlayerState::WEAPON_ROCKET
114
- spawn_rocket(px, py, pz, cos_a, sin_a)
219
+ spawn_rocket(px, py, pz, cos_a, sin_a, shooter)
115
220
  when PlayerState::WEAPON_FIST
116
- melee(px, py, cos_a, sin_a, 2, 64)
221
+ melee(px, py, cos_a, sin_a, 2, 64, shooter)
117
222
  when PlayerState::WEAPON_CHAINSAW
118
- melee(px, py, cos_a, sin_a, 2, 64)
223
+ melee(px, py, cos_a, sin_a, 2, 64, shooter)
119
224
  end
120
225
  end
121
226
 
122
227
  private
123
228
 
124
- def spawn_rocket(px, py, pz, cos_a, sin_a)
125
- # Spawn slightly ahead of the player
229
+ def spawn_rocket(px, py, pz, cos_a, sin_a, shooter = nil)
126
230
  @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
231
+ px + (cos_a * 20), py + (sin_a * 20), pz,
232
+ cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED, 0.0,
233
+ :rocket, @tic, 'MISL', :monsters, shooter, 1.0
130
234
  )
131
235
  end
132
236
 
237
+ # Players a shot fired by `shooter` may hit: everyone alive except the
238
+ # shooter, who cannot hit themselves with a bullet or a fist.
239
+ #
240
+ # Not gated on the mode. Vanilla DOOM has no friendly-fire switch --
241
+ # P_DamageMobj checks nothing about who is shooting whom -- so co-op
242
+ # players hurt each other exactly as deathmatch ones do. In single player
243
+ # this is empty anyway, the only player being the shooter.
244
+ def player_targets(shooter)
245
+ @players.reject { |pl| pl.state.dead || pl.equal?(shooter) }
246
+ end
247
+
248
+ # Damage a player and score the kill in the same place, so no damage path
249
+ # can quietly forget to. `attacker` is the Player responsible, or nil when
250
+ # a monster or the level itself did it.
251
+ def damage_player(victim, damage, attacker)
252
+ return if damage <= 0
253
+ return if victim.state.dead
254
+
255
+ victim.state.take_damage(damage)
256
+ return unless victim.state.dead
257
+
258
+ record_frag(victim, attacker)
259
+ end
260
+
261
+ # DOOM's scoring: killing someone else is worth a frag, killing yourself
262
+ # costs one, and being killed by a monster or the environment is worth
263
+ # nothing either way.
264
+ def record_frag(victim, attacker)
265
+ return unless attacker
266
+
267
+ if attacker.equal?(victim)
268
+ victim.frags -= 1
269
+ else
270
+ attacker.frags += 1
271
+ end
272
+ end
273
+
133
274
  def update_projectiles
134
275
  @projectiles.reject! do |proj|
135
- # Move projectile
136
276
  new_x = proj.x + proj.dx
137
277
  new_y = proj.y + proj.dy
278
+ new_z = proj.z + (proj.dz || 0)
138
279
 
139
- # Check wall collision
140
280
  hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
141
281
 
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
282
+ # Check if projectile hit the floor or ceiling
283
+ sector = @map.sector_at(new_x, new_y)
284
+ hit_wall = true if sector && (new_z <= sector.floor_height || new_z >= sector.ceiling_height)
285
+ hit = false
286
+
287
+ if proj.target == :monsters
288
+ # Player projectile: check monster collision
289
+ hit_monster = nil
290
+ @map.things.each_with_index do |thing, idx|
291
+ next unless MONSTER_HP[thing.type]
292
+ next if @dead_things[idx]
293
+
294
+ radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
295
+ dx = new_x - thing.x
296
+ dy = new_y - thing.y
297
+ if (dx * dx) + (dy * dy) < radius * radius
298
+ hit_monster = idx
299
+ break
300
+ end
301
+ end
302
+
303
+ # In deathmatch a rocket also detonates on a player. There is no
304
+ # separate direct-hit damage: exploding on top of someone already
305
+ # deals full splash, which is close enough to DOOM's direct hit and
306
+ # keeps the RNG draws the same as any other detonation.
307
+ hit_player = player_targets(proj.owner).find do |pl|
308
+ radius = PLAYER_RADIUS + ROCKET_RADIUS
309
+ dx = new_x - pl.x
310
+ dy = new_y - pl.y
311
+ (dx * dx) + (dy * dy) < radius * radius
312
+ end
313
+
314
+ if hit_wall || hit_monster || hit_player
315
+ # Only rockets target :monsters, so the detonation is always a
316
+ # rocket explosion (direct hit plus splash).
317
+ explode(new_x, new_y, hit_monster, proj.owner)
318
+ hit = true
319
+ end
320
+ elsif proj.target == :player
321
+ # Monster projectile: hits whichever player it reaches first, not
322
+ # only the one it was aimed at -- a fireball meant for someone else
323
+ # still hurts if you walk into it.
324
+ info = MONSTER_PROJECTILES[proj.type]
325
+ hit_radius = PLAYER_RADIUS + (info ? info[:radius] : 6)
326
+ struck = @players.find do |pl|
327
+ next false if pl.state.dead
328
+
329
+ dx = new_x - pl.x
330
+ dy = new_y - pl.y
331
+ (dx * dx) + (dy * dy) < hit_radius**2
332
+ end
333
+
334
+ if hit_wall || struck
335
+ if !hit_wall && struck && info
336
+ min_d, max_d = info[:damage]
337
+ damage = (@random.rand(min_d..max_d) * (proj.damage_multiplier || 1.0)).to_i
338
+ damage_player(struck, damage, nil)
339
+ end
340
+ # Spawn fireball explosion
341
+ @explosions << { x: new_x, y: new_y, z: proj.z, tic: @tic, sprite: proj.sprite_prefix }
342
+ hit = true
153
343
  end
154
344
  end
155
345
 
156
- if hit_wall || hit_monster
157
- explode(new_x, new_y, hit_monster)
158
- true # Remove projectile
346
+ if hit
347
+ true
159
348
  else
160
349
  proj.x = new_x
161
350
  proj.y = new_y
162
- false # Keep projectile
351
+ proj.z = new_z
352
+ false
163
353
  end
164
354
  end
165
355
  end
166
356
 
167
- def explode(x, y, direct_hit_idx)
357
+ def explode(x, y, direct_hit_idx, owner = nil)
168
358
  # Direct hit damage
169
359
  if direct_hit_idx
170
- damage = (rand(8) + 1) * ROCKET_DAMAGE
360
+ damage = (@random.rand(8) + 1) * ROCKET_DAMAGE
171
361
  apply_damage(direct_hit_idx, damage)
172
362
  end
173
363
 
174
- # Splash damage to all monsters in radius
364
+ splash_monsters(x, y, SPLASH_RADIUS, SPLASH_DAMAGE, direct_hit_idx)
365
+ splash_players(x, y, SPLASH_RADIUS, SPLASH_DAMAGE, owner)
366
+
367
+ # Spawn explosion visual
368
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
369
+ end
370
+
371
+ # Splash damage to every monster (and barrel, for chain reactions) in
372
+ # radius, falling off linearly, skipping any dead thing and the excluded
373
+ # index (a rocket's direct-hit target or the barrel that just went off).
374
+ def splash_monsters(x, y, radius, max_damage, exclude_idx)
175
375
  @map.things.each_with_index do |thing, idx|
176
376
  next unless MONSTER_HP[thing.type]
177
377
  next if @dead_things[idx]
178
- next if idx == direct_hit_idx # Already took direct hit
378
+ next if idx == exclude_idx
179
379
 
180
380
  dx = x - thing.x
181
381
  dy = y - thing.y
182
- dist = Math.sqrt(dx * dx + dy * dy)
183
- next if dist >= SPLASH_RADIUS
382
+ dist = Math.sqrt((dx * dx) + (dy * dy))
383
+ next if dist >= radius
184
384
 
185
- # Damage falls off linearly with distance
186
- damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
385
+ damage = (max_damage * (1.0 - (dist / radius))).to_i
187
386
  apply_damage(idx, damage) if damage > 0
188
387
  end
388
+ end
189
389
 
190
- # Spawn explosion visual
191
- @explosions << { x: x, y: y, tic: @tic }
390
+ # Splash reaches the shooter too, which is how a rocket suicide happens.
391
+ #
392
+ # In every mode, single player included. PIT_RadiusAttack damages every
393
+ # shootable thing in range and never excludes the bomb source, which is
394
+ # why firing a rocket at your own feet has always been fatal in DOOM.
395
+ def splash_players(x, y, radius, max_damage, attacker)
396
+ @players.each do |pl|
397
+ next if pl.state.dead
398
+
399
+ dist = Math.hypot(x - pl.x, y - pl.y)
400
+ next unless dist < radius
401
+
402
+ damage_player(pl, (max_damage * (1.0 - (dist / radius))).to_i, attacker)
403
+ end
192
404
  end
193
405
 
194
406
  def update_explosions
@@ -198,50 +410,41 @@ module Doom
198
410
 
199
411
  def hits_wall?(x1, y1, x2, y2)
200
412
  @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
413
+ # One-sided walls always block projectiles
414
+ if ld.sidedef_left == 0xFFFF
415
+ blocks = true
416
+ elsif ld.sidedef_left < 0xFFFF
417
+ # Two-sided: only block if opening is too small for a projectile
418
+ # BLOCKING flag (0x0001) stops players/monsters but NOT projectiles
205
419
  front = @map.sidedefs[ld.sidedef_right]
206
420
  back = @map.sidedefs[ld.sidedef_left]
207
421
  fs = @map.sectors[front.sector]
208
422
  bs = @map.sectors[back.sector]
209
423
  max_floor = [fs.floor_height, bs.floor_height].max
210
424
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
211
- blocks = (min_ceil - max_floor) < 56
425
+ blocks = (min_ceil - max_floor) < 1 # Closed door/wall
426
+ else
427
+ next
212
428
  end
213
429
  next unless blocks
214
430
 
215
431
  v1 = @map.vertices[ld.v1]
216
432
  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
433
+ return true if Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
220
434
  end
221
435
  false
222
436
  end
223
437
 
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)
438
+ def hitscan(px, py, pz, cos_a, sin_a, pellets, spread, multiplier, shooter = nil)
236
439
  pellets.times do
237
440
  # Add random spread
238
441
  if spread > 0
239
- angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * spread * 2
442
+ angle = Math.atan2(sin_a, cos_a) + ((@random.rand - 0.5) * spread * 2)
240
443
  ca = Math.cos(angle)
241
444
  sa = Math.sin(angle)
242
445
  else
243
446
  # Slight pistol/chaingun spread
244
- angle = Math.atan2(sin_a, cos_a) + (rand - 0.5) * 0.04
447
+ angle = Math.atan2(sin_a, cos_a) + ((@random.rand - 0.5) * 0.04)
245
448
  ca = Math.cos(angle)
246
449
  sa = Math.sin(angle)
247
450
  end
@@ -249,9 +452,11 @@ module Doom
249
452
  wall_dist = trace_wall(px, py, ca, sa)
250
453
 
251
454
  best_idx = nil
455
+ best_player = nil
252
456
  best_dist = wall_dist
253
457
 
254
458
  @map.things.each_with_index do |thing, idx|
459
+ next if @hidden_things[idx]
255
460
  next unless MONSTER_HP[thing.type]
256
461
  next if @dead_things[idx]
257
462
 
@@ -263,15 +468,36 @@ module Doom
263
468
  end
264
469
  end
265
470
 
266
- if best_idx
267
- damage = (rand(3) + 1) * multiplier
471
+ # Players compete with monsters for the same ray: whoever is nearest
472
+ # takes the bullet, so you cannot shoot through someone to hit the
473
+ # imp behind them.
474
+ player_targets(shooter).each do |pl|
475
+ hit_dist = ray_circle_hit(px, py, ca, sa, pl.x, pl.y, PLAYER_RADIUS)
476
+ next unless hit_dist && hit_dist > 0 && hit_dist < best_dist
477
+
478
+ best_dist = hit_dist
479
+ best_player = pl
480
+ best_idx = nil
481
+ end
482
+
483
+ # Spawn bullet puff at hit location
484
+ puff_x = px + (ca * best_dist)
485
+ puff_y = py + (sa * best_dist)
486
+ puff_z = pz
487
+ @puffs << { x: puff_x, y: puff_y, z: puff_z, tic: @tic }
488
+
489
+ if best_player
490
+ damage_player(best_player, (@random.rand(3) + 1) * multiplier, shooter)
491
+ elsif best_idx
492
+ damage = (@random.rand(3) + 1) * multiplier
268
493
  apply_damage(best_idx, damage)
269
494
  end
270
495
  end
271
496
  end
272
497
 
273
- def melee(px, py, cos_a, sin_a, multiplier, range)
498
+ def melee(px, py, cos_a, sin_a, multiplier, range, shooter = nil)
274
499
  best_idx = nil
500
+ best_player = nil
275
501
  best_dist = range.to_f
276
502
 
277
503
  @map.things.each_with_index do |thing, idx|
@@ -280,11 +506,11 @@ module Doom
280
506
 
281
507
  dx = thing.x - px
282
508
  dy = thing.y - py
283
- dist = Math.sqrt(dx * dx + dy * dy)
509
+ dist = Math.sqrt((dx * dx) + (dy * dy))
284
510
  next if dist > range + (MONSTER_RADIUS[thing.type] || 20)
285
511
 
286
512
  # Check if roughly facing the monster
287
- dot = dx * cos_a + dy * sin_a
513
+ dot = (dx * cos_a) + (dy * sin_a)
288
514
  next if dot < 0
289
515
 
290
516
  if dist < best_dist
@@ -293,8 +519,23 @@ module Doom
293
519
  end
294
520
  end
295
521
 
296
- if best_idx
297
- damage = (rand(3) + 1) * multiplier
522
+ player_targets(shooter).each do |pl|
523
+ dx = pl.x - px
524
+ dy = pl.y - py
525
+ dist = Math.hypot(dx, dy)
526
+ next if dist > range + PLAYER_RADIUS
527
+ next if (dx * cos_a) + (dy * sin_a) < 0 # behind the swing
528
+ next unless dist < best_dist
529
+
530
+ best_dist = dist
531
+ best_player = pl
532
+ best_idx = nil
533
+ end
534
+
535
+ if best_player
536
+ damage_player(best_player, (@random.rand(3) + 1) * multiplier, shooter)
537
+ elsif best_idx
538
+ damage = (@random.rand(3) + 1) * multiplier
298
539
  apply_damage(best_idx, damage)
299
540
  end
300
541
  end
@@ -306,35 +547,66 @@ module Doom
306
547
  @monster_hp[thing_idx] -= damage
307
548
 
308
549
  if @monster_hp[thing_idx] <= 0
309
- prefix = @sprites.prefix_for(thing.type)
310
- @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
550
+ return if @dead_things[thing_idx] # Already dead
551
+
552
+ if thing.type == BARREL_TYPE
553
+ @dead_things[thing_idx] = { tic: @tic, prefix: 'BEXP' }
554
+ @sound&.explosion
555
+ barrel_explode(thing.x, thing.y, thing_idx)
556
+ else
557
+ prefix = @sprites.prefix_for(thing.type)
558
+ @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
559
+ @sound&.monster_death(thing.type)
560
+ end
561
+ else
562
+ # Pain state: monster flinches (not barrels)
563
+ if thing.type != BARREL_TYPE
564
+ pain_chance = PAIN_CHANCE[thing.type] || 128
565
+ if @random.rand(256) < pain_chance
566
+ @pain_until[thing_idx] = @tic + PAIN_DURATION
567
+ @sound&.monster_pain(thing.type)
568
+ end
569
+ end
311
570
  end
312
571
  end
313
572
 
573
+ def barrel_explode(x, y, barrel_idx)
574
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
575
+
576
+ # Splash damage to monsters and other barrels (chain reactions!), then to
577
+ # every player in radius including the one who set it off. Nobody is
578
+ # credited: the barrel is not a player, and we do not track who shot it.
579
+ splash_monsters(x, y, SPLASH_RADIUS, SPLASH_DAMAGE, barrel_idx)
580
+ splash_players(x, y, SPLASH_RADIUS, SPLASH_DAMAGE, nil)
581
+ end
582
+
314
583
  def trace_wall(px, py, cos_a, sin_a)
315
- best_t = 4096.0 # Max hitscan range
584
+ best_t = 4096.0 # Max hitscan range
316
585
 
317
586
  @map.linedefs.each do |ld|
318
587
  v1 = @map.vertices[ld.v1]
319
588
  v2 = @map.vertices[ld.v2]
320
589
 
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
590
+ # One-sided always blocks hitscan
591
+ if ld.sidedef_left == 0xFFFF
592
+ blocks = true
593
+ elsif ld.sidedef_left < 0xFFFF
594
+ # Two-sided: only blocks if opening is too small
595
+ # BLOCKING flag does NOT stop hitscan (only affects movement)
325
596
  front = @map.sidedefs[ld.sidedef_right]
326
597
  back = @map.sidedefs[ld.sidedef_left]
327
598
  fs = @map.sectors[front.sector]
328
599
  bs = @map.sectors[back.sector]
329
- # Blocks if opening is too small (step or low ceiling)
330
600
  max_floor = [fs.floor_height, bs.floor_height].max
331
601
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
332
602
  blocks = (min_ceil - max_floor) < 56
603
+ else
604
+ next
333
605
  end
334
606
  next unless blocks
335
607
 
336
608
  t = ray_segment_intersect(px, py, cos_a, sin_a,
337
- v1.x, v1.y, v2.x, v2.y)
609
+ v1.x, v1.y, v2.x, v2.y)
338
610
  best_t = t if t && t > 0 && t < best_t
339
611
  end
340
612
 
@@ -344,25 +616,25 @@ module Doom
344
616
  def ray_segment_intersect(px, py, dx, dy, x1, y1, x2, y2)
345
617
  sx = x2 - x1
346
618
  sy = y2 - y1
347
- denom = dx * sy - dy * sx
619
+ denom = (dx * sy) - (dy * sx)
348
620
  return nil if denom.abs < 0.001
349
621
 
350
- t = ((x1 - px) * sy - (y1 - py) * sx) / denom
351
- u = ((x1 - px) * dy - (y1 - py) * dx) / denom
622
+ t = (((x1 - px) * sy) - ((y1 - py) * sx)) / denom
623
+ u = (((x1 - px) * dy) - ((y1 - py) * dx)) / denom
352
624
 
353
- (t > 0 && u >= 0.0 && u <= 1.0) ? t : nil
625
+ t > 0 && u >= 0.0 && u <= 1.0 ? t : nil
354
626
  end
355
627
 
356
628
  def ray_circle_hit(px, py, cos_a, sin_a, cx, cy, radius)
357
629
  dx = cx - px
358
630
  dy = cy - py
359
- proj = dx * cos_a + dy * sin_a
631
+ proj = (dx * cos_a) + (dy * sin_a)
360
632
  return nil if proj < 0
361
633
 
362
- perp_sq = dx * dx + dy * dy - proj * proj
634
+ perp_sq = (dx * dx) + (dy * dy) - (proj * proj)
363
635
  return nil if perp_sq > radius * radius
364
636
 
365
- chord_half = Math.sqrt([radius * radius - perp_sq, 0].max)
637
+ chord_half = Math.sqrt([(radius * radius) - perp_sq, 0].max)
366
638
  proj - chord_half
367
639
  end
368
640
  end