doom 0.8.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 +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  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 +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  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 +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  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 +33 -58
  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 +604 -832
  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 +5 -3
  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 +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  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 +9 -10
  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 +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -7
@@ -2,41 +2,42 @@
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
25
- 2035 => 20, # Explosive barrel
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
26
29
  }.freeze
27
30
 
28
31
  MONSTER_RADIUS = {
29
32
  3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
30
33
  3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
31
34
  7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
32
- 2035 => 10, # Barrel
35
+ 2035 => 10 # Barrel
33
36
  }.freeze
34
37
 
35
- # Barrel (explosive, not a monster but damageable)
38
+ # Barrel (explosive, not a monster but damageable). Its HP comes from
39
+ # MONSTER_HP[BARREL_TYPE]; its explosion reuses the shared SPLASH_* values.
36
40
  BARREL_TYPE = 2035
37
- BARREL_HP = 20
38
- BARREL_SPLASH_RADIUS = 128.0
39
- BARREL_SPLASH_DAMAGE = 128
40
41
 
41
42
  # Normal death frame sequences per sprite prefix (rotation 0 only)
42
43
  # Identified by sprite heights: frames go from standing height to flat on ground
@@ -54,18 +55,18 @@ module Doom
54
55
  'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
55
56
  'PAIN' => %w[H I J K L M], # Pain Elemental
56
57
  'SSWV' => %w[I J K L M], # Wolfenstein SS
57
- 'BEXP' => %w[A B C D E], # Barrel explosion
58
+ 'BEXP' => %w[A B C D E] # Barrel explosion
58
59
  }.freeze
59
60
 
60
- DEATH_ANIM_TICS = 6 # Tics per death frame
61
+ DEATH_ANIM_TICS = 6 # Tics per death frame
61
62
 
62
63
  # Pain chance per monster (out of 256, from mobjinfo)
63
64
  PAIN_CHANCE = {
64
65
  3004 => 200, 9 => 170, 3001 => 200, 3002 => 180, 58 => 180,
65
66
  3003 => 50, 69 => 50, 3005 => 128, 3006 => 256, 16 => 40,
66
- 7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170,
67
+ 7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170
67
68
  }.freeze
68
- PAIN_DURATION = 6 # Tics monster is stunned when in pain
69
+ PAIN_DURATION = 6 # Tics monster is stunned when in pain
69
70
 
70
71
  # Projectile constants
71
72
  ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
@@ -74,31 +75,43 @@ module Doom
74
75
  SPLASH_RADIUS = 128.0 # Splash damage radius
75
76
  SPLASH_DAMAGE = 128 # Max splash damage at center
76
77
 
77
- # Monster projectile definitions
78
+ # Monster projectile definitions. :radius is the projectile's own collision
79
+ # radius, added to the player radius when testing a hit.
78
80
  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 },
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 }
82
84
  }.freeze
83
85
 
84
86
  # Map monster type to projectile type
85
87
  MONSTER_PROJECTILE_TYPE = {
86
88
  3001 => :imp, # Imp
87
89
  3003 => :baron, # Baron
88
- 69 => :baron, # Hell Knight
89
- 3005 => :caco, # Cacodemon
90
+ 69 => :baron, # Hell Knight
91
+ 3005 => :caco # Cacodemon
90
92
  }.freeze
91
93
 
92
- Projectile = Struct.new(:x, :y, :z, :dx, :dy, :dz, :type, :spawn_tic, :sprite_prefix, :target)
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)
93
105
 
94
106
  # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
95
107
  # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
96
108
  # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
97
109
  # Fist/chainsaw: 1*2..3*2 = 2-10
98
110
 
99
- def initialize(map, player_state, sprites, hidden_things = {}, sound_engine = nil)
111
+ def initialize(map, sprites = nil, hidden_things = {}, sound_engine = nil,
112
+ random: Random.new)
100
113
  @map = map
101
- @player = player_state
114
+ @random = random
102
115
  @sprites = sprites
103
116
  @hidden_things = hidden_things
104
117
  @sound = sound_engine
@@ -108,30 +121,26 @@ module Doom
108
121
  @projectiles = [] # Active projectiles in flight
109
122
  @explosions = [] # Active explosions (for rendering)
110
123
  @puffs = [] # Bullet puff effects
111
- @player_x = 0.0
112
- @player_y = 0.0
113
- @player_z = 0.0
124
+ @players = []
114
125
  @tic = 0
115
126
  end
116
127
 
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
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
122
131
 
123
132
  # Spawn a monster projectile (fireball, etc.)
124
133
  # 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)
134
+ def spawn_monster_projectile(monster_x, monster_y, monster_z, monster_type, damage_multiplier, target)
126
135
  proj_type = MONSTER_PROJECTILE_TYPE[monster_type]
127
136
  return unless proj_type
128
137
 
129
138
  info = MONSTER_PROJECTILES[proj_type]
130
139
  return unless info
131
140
 
132
- dx = @player_x - monster_x
133
- dy = @player_y - monster_y
134
- dist = Math.sqrt(dx * dx + dy * dy)
141
+ dx = target.x - monster_x
142
+ dy = target.y - monster_y
143
+ dist = Math.sqrt((dx * dx) + (dy * dy))
135
144
  return if dist < 1
136
145
 
137
146
  # Normalize direction and apply speed
@@ -141,18 +150,18 @@ module Doom
141
150
 
142
151
  # P_SpawnMissile: momz = (target.z - source.z) / (dist / speed)
143
152
  # This makes the projectile arc toward the target's height
144
- target_z = @player_z - 16 # Aim at player center (z + height/2, roughly)
153
+ target_z = target.z - 16 # Aim at player center (z + height/2, roughly)
145
154
  travel_tics = dist / speed
146
155
  travel_tics = 1.0 if travel_tics < 1.0
147
156
  ndz = (target_z - monster_z) / travel_tics
148
157
 
149
158
  @projectiles << Projectile.new(
150
- monster_x + ndx * 2, monster_y + ndy * 2, monster_z,
151
- ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player
159
+ monster_x + (ndx * 2), monster_y + (ndy * 2), monster_z,
160
+ ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player, nil, damage_multiplier
152
161
  )
153
162
  end
154
163
 
155
- attr_reader :dead_things, :projectiles, :explosions, :puffs
164
+ attr_reader :dead_things, :projectiles, :explosions, :puffs, :sprites
156
165
 
157
166
  def in_pain?(thing_idx)
158
167
  @pain_until[thing_idx] && @tic < @pain_until[thing_idx]
@@ -175,19 +184,17 @@ module Doom
175
184
  frame_idx = elapsed / DEATH_ANIM_TICS
176
185
 
177
186
  # 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
187
+ return nil if thing_type == BARREL_TYPE && frame_idx >= frames.size
181
188
 
182
189
  frame_idx = frame_idx.clamp(0, frames.size - 1)
183
190
  frame_letter = frames[frame_idx]
184
191
 
185
192
  # Use prefix directly if it differs from the thing's sprite (e.g. BEXP for barrels)
186
193
  thing_prefix = @sprites.prefix_for(thing_type)
187
- if prefix != thing_prefix
188
- @sprites.get_frame_by_prefix(prefix, frame_letter)
189
- else
194
+ if prefix == thing_prefix
190
195
  @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
196
+ else
197
+ @sprites.get_frame_by_prefix(prefix, frame_letter)
191
198
  end
192
199
  end
193
200
 
@@ -199,32 +206,71 @@ module Doom
199
206
  @puffs.reject! { |p| @tic - p[:tic] > 12 }
200
207
  end
201
208
 
202
- # Fire the current weapon
203
- 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)
204
213
  case weapon
205
214
  when PlayerState::WEAPON_PISTOL, PlayerState::WEAPON_CHAINGUN
206
- 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)
207
216
  when PlayerState::WEAPON_SHOTGUN
208
- 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)
209
218
  when PlayerState::WEAPON_ROCKET
210
- spawn_rocket(px, py, pz, cos_a, sin_a)
219
+ spawn_rocket(px, py, pz, cos_a, sin_a, shooter)
211
220
  when PlayerState::WEAPON_FIST
212
- melee(px, py, cos_a, sin_a, 2, 64)
221
+ melee(px, py, cos_a, sin_a, 2, 64, shooter)
213
222
  when PlayerState::WEAPON_CHAINSAW
214
- melee(px, py, cos_a, sin_a, 2, 64)
223
+ melee(px, py, cos_a, sin_a, 2, 64, shooter)
215
224
  end
216
225
  end
217
226
 
218
227
  private
219
228
 
220
- def spawn_rocket(px, py, pz, cos_a, sin_a)
229
+ def spawn_rocket(px, py, pz, cos_a, sin_a, shooter = nil)
221
230
  @projectiles << Projectile.new(
222
- px + cos_a * 20, py + sin_a * 20, pz,
231
+ px + (cos_a * 20), py + (sin_a * 20), pz,
223
232
  cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED, 0.0,
224
- :rocket, @tic, 'MISL', :monsters
233
+ :rocket, @tic, 'MISL', :monsters, shooter, 1.0
225
234
  )
226
235
  end
227
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
+
228
274
  def update_projectiles
229
275
  @projectiles.reject! do |proj|
230
276
  new_x = proj.x + proj.dx
@@ -235,9 +281,7 @@ module Doom
235
281
 
236
282
  # Check if projectile hit the floor or ceiling
237
283
  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
284
+ hit_wall = true if sector && (new_z <= sector.floor_height || new_z >= sector.ceiling_height)
241
285
  hit = false
242
286
 
243
287
  if proj.target == :monsters
@@ -246,32 +290,52 @@ module Doom
246
290
  @map.things.each_with_index do |thing, idx|
247
291
  next unless MONSTER_HP[thing.type]
248
292
  next if @dead_things[idx]
293
+
249
294
  radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
250
295
  dx = new_x - thing.x
251
296
  dy = new_y - thing.y
252
- if dx * dx + dy * dy < radius * radius
297
+ if (dx * dx) + (dy * dy) < radius * radius
253
298
  hit_monster = idx
254
299
  break
255
300
  end
256
301
  end
257
302
 
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
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)
261
318
  hit = true
262
319
  end
263
320
  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
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)
275
339
  end
276
340
  # Spawn fireball explosion
277
341
  @explosions << { x: new_x, y: new_y, z: proj.z, tic: @tic, sprite: proj.sprite_prefix }
@@ -290,31 +354,53 @@ module Doom
290
354
  end
291
355
  end
292
356
 
293
- def explode(x, y, direct_hit_idx)
357
+ def explode(x, y, direct_hit_idx, owner = nil)
294
358
  # Direct hit damage
295
359
  if direct_hit_idx
296
- damage = (rand(8) + 1) * ROCKET_DAMAGE
360
+ damage = (@random.rand(8) + 1) * ROCKET_DAMAGE
297
361
  apply_damage(direct_hit_idx, damage)
298
362
  end
299
363
 
300
- # 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)
301
375
  @map.things.each_with_index do |thing, idx|
302
376
  next unless MONSTER_HP[thing.type]
303
377
  next if @dead_things[idx]
304
- next if idx == direct_hit_idx # Already took direct hit
378
+ next if idx == exclude_idx
305
379
 
306
380
  dx = x - thing.x
307
381
  dy = y - thing.y
308
- dist = Math.sqrt(dx * dx + dy * dy)
309
- next if dist >= SPLASH_RADIUS
382
+ dist = Math.sqrt((dx * dx) + (dy * dy))
383
+ next if dist >= radius
310
384
 
311
- # Damage falls off linearly with distance
312
- damage = ((SPLASH_DAMAGE * (1.0 - dist / SPLASH_RADIUS))).to_i
385
+ damage = (max_damage * (1.0 - (dist / radius))).to_i
313
386
  apply_damage(idx, damage) if damage > 0
314
387
  end
388
+ end
315
389
 
316
- # Spawn explosion visual
317
- @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
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
318
404
  end
319
405
 
320
406
  def update_explosions
@@ -336,7 +422,7 @@ module Doom
336
422
  bs = @map.sectors[back.sector]
337
423
  max_floor = [fs.floor_height, bs.floor_height].max
338
424
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
339
- blocks = (min_ceil - max_floor) < 1 # Closed door/wall
425
+ blocks = (min_ceil - max_floor) < 1 # Closed door/wall
340
426
  else
341
427
  next
342
428
  end
@@ -344,34 +430,21 @@ module Doom
344
430
 
345
431
  v1 = @map.vertices[ld.v1]
346
432
  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
433
+ return true if Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
350
434
  end
351
435
  false
352
436
  end
353
437
 
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
-
365
- 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)
366
439
  pellets.times do
367
440
  # Add random spread
368
441
  if spread > 0
369
- 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)
370
443
  ca = Math.cos(angle)
371
444
  sa = Math.sin(angle)
372
445
  else
373
446
  # Slight pistol/chaingun spread
374
- 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)
375
448
  ca = Math.cos(angle)
376
449
  sa = Math.sin(angle)
377
450
  end
@@ -379,6 +452,7 @@ module Doom
379
452
  wall_dist = trace_wall(px, py, ca, sa)
380
453
 
381
454
  best_idx = nil
455
+ best_player = nil
382
456
  best_dist = wall_dist
383
457
 
384
458
  @map.things.each_with_index do |thing, idx|
@@ -394,21 +468,36 @@ module Doom
394
468
  end
395
469
  end
396
470
 
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
+
397
483
  # 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
484
+ puff_x = px + (ca * best_dist)
485
+ puff_y = py + (sa * best_dist)
486
+ puff_z = pz
401
487
  @puffs << { x: puff_x, y: puff_y, z: puff_z, tic: @tic }
402
488
 
403
- if best_idx
404
- damage = (rand(3) + 1) * multiplier
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
405
493
  apply_damage(best_idx, damage)
406
494
  end
407
495
  end
408
496
  end
409
497
 
410
- def melee(px, py, cos_a, sin_a, multiplier, range)
498
+ def melee(px, py, cos_a, sin_a, multiplier, range, shooter = nil)
411
499
  best_idx = nil
500
+ best_player = nil
412
501
  best_dist = range.to_f
413
502
 
414
503
  @map.things.each_with_index do |thing, idx|
@@ -417,11 +506,11 @@ module Doom
417
506
 
418
507
  dx = thing.x - px
419
508
  dy = thing.y - py
420
- dist = Math.sqrt(dx * dx + dy * dy)
509
+ dist = Math.sqrt((dx * dx) + (dy * dy))
421
510
  next if dist > range + (MONSTER_RADIUS[thing.type] || 20)
422
511
 
423
512
  # Check if roughly facing the monster
424
- dot = dx * cos_a + dy * sin_a
513
+ dot = (dx * cos_a) + (dy * sin_a)
425
514
  next if dot < 0
426
515
 
427
516
  if dist < best_dist
@@ -430,8 +519,23 @@ module Doom
430
519
  end
431
520
  end
432
521
 
433
- if best_idx
434
- 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
435
539
  apply_damage(best_idx, damage)
436
540
  end
437
541
  end
@@ -443,7 +547,7 @@ module Doom
443
547
  @monster_hp[thing_idx] -= damage
444
548
 
445
549
  if @monster_hp[thing_idx] <= 0
446
- return if @dead_things[thing_idx] # Already dead
550
+ return if @dead_things[thing_idx] # Already dead
447
551
 
448
552
  if thing.type == BARREL_TYPE
449
553
  @dead_things[thing_idx] = { tic: @tic, prefix: 'BEXP' }
@@ -458,7 +562,7 @@ module Doom
458
562
  # Pain state: monster flinches (not barrels)
459
563
  if thing.type != BARREL_TYPE
460
564
  pain_chance = PAIN_CHANCE[thing.type] || 128
461
- if rand(256) < pain_chance
565
+ if @random.rand(256) < pain_chance
462
566
  @pain_until[thing_idx] = @tic + PAIN_DURATION
463
567
  @sound&.monster_pain(thing.type)
464
568
  end
@@ -469,33 +573,15 @@ module Doom
469
573
  def barrel_explode(x, y, barrel_idx)
470
574
  @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
471
575
 
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
494
- end
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)
495
581
  end
496
582
 
497
583
  def trace_wall(px, py, cos_a, sin_a)
498
- best_t = 4096.0 # Max hitscan range
584
+ best_t = 4096.0 # Max hitscan range
499
585
 
500
586
  @map.linedefs.each do |ld|
501
587
  v1 = @map.vertices[ld.v1]
@@ -520,7 +606,7 @@ module Doom
520
606
  next unless blocks
521
607
 
522
608
  t = ray_segment_intersect(px, py, cos_a, sin_a,
523
- v1.x, v1.y, v2.x, v2.y)
609
+ v1.x, v1.y, v2.x, v2.y)
524
610
  best_t = t if t && t > 0 && t < best_t
525
611
  end
526
612
 
@@ -530,25 +616,25 @@ module Doom
530
616
  def ray_segment_intersect(px, py, dx, dy, x1, y1, x2, y2)
531
617
  sx = x2 - x1
532
618
  sy = y2 - y1
533
- denom = dx * sy - dy * sx
619
+ denom = (dx * sy) - (dy * sx)
534
620
  return nil if denom.abs < 0.001
535
621
 
536
- t = ((x1 - px) * sy - (y1 - py) * sx) / denom
537
- 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
538
624
 
539
- (t > 0 && u >= 0.0 && u <= 1.0) ? t : nil
625
+ t > 0 && u >= 0.0 && u <= 1.0 ? t : nil
540
626
  end
541
627
 
542
628
  def ray_circle_hit(px, py, cos_a, sin_a, cx, cy, radius)
543
629
  dx = cx - px
544
630
  dy = cy - py
545
- proj = dx * cos_a + dy * sin_a
631
+ proj = (dx * cos_a) + (dy * sin_a)
546
632
  return nil if proj < 0
547
633
 
548
- perp_sq = dx * dx + dy * dy - proj * proj
634
+ perp_sq = (dx * dx) + (dy * dy) - (proj * proj)
549
635
  return nil if perp_sq > radius * radius
550
636
 
551
- chord_half = Math.sqrt([radius * radius - perp_sq, 0].max)
637
+ chord_half = Math.sqrt([(radius * radius) - perp_sq, 0].max)
552
638
  proj - chord_half
553
639
  end
554
640
  end