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
@@ -6,8 +6,14 @@ module Doom
6
6
  # Matches Chocolate Doom's A_Look / A_Chase / P_NewChaseDir from p_enemy.c.
7
7
  class MonsterAI
8
8
  # 8 movement directions + no direction
9
- DI_EAST = 0; DI_NORTHEAST = 1; DI_NORTH = 2; DI_NORTHWEST = 3
10
- DI_WEST = 4; DI_SOUTHWEST = 5; DI_SOUTH = 6; DI_SOUTHEAST = 7
9
+ DI_EAST = 0
10
+ DI_NORTHEAST = 1
11
+ DI_NORTH = 2
12
+ DI_NORTHWEST = 3
13
+ DI_WEST = 4
14
+ DI_SOUTHWEST = 5
15
+ DI_SOUTH = 6
16
+ DI_SOUTHEAST = 7
11
17
  DI_NODIR = 8
12
18
 
13
19
  # Movement deltas per direction (map units, 1.0 = FRACUNIT)
@@ -21,38 +27,36 @@ module Doom
21
27
  MONSTER_SPEED = {
22
28
  3004 => 8, 9 => 8, 3001 => 8, 3002 => 10, 58 => 10,
23
29
  3003 => 8, 69 => 8, 3005 => 8, 3006 => 8, 16 => 16,
24
- 7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8,
30
+ 7 => 12, 65 => 8, 64 => 15, 71 => 8, 84 => 8
25
31
  }.freeze
26
32
 
27
33
  CHASE_TICS = 4 # Steps between A_Chase calls
28
34
  SIGHT_RANGE = 768.0 # Max distance for sight check
29
35
  MELEE_RANGE = 64.0
30
36
  MISSILE_RANGE = 768.0
31
- KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
32
-
33
- # Direction to angle (for sprite facing)
34
- DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
37
+ KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
35
38
 
36
39
  # Monster attack definitions (from mobjinfo / A_Chase)
37
40
  # Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4)
38
41
  # In DOOM, monsters only attempt attacks when movecount reaches 0,
39
42
  # then play full attack animation before returning to chase.
40
43
  MONSTER_ATTACK = {
41
- 3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Zombieman
42
- 9 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Shotgun Guy
43
- 3001 => { type: :projectile, cooldown: 52 }, # Imp: fireball
44
- 3002 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Demon
45
- 58 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Spectre
46
- 3003 => { type: :projectile, cooldown: 54 }, # Baron: fireball
47
- 69 => { type: :projectile, cooldown: 54 }, # Hell Knight
48
- 3005 => { type: :projectile, cooldown: 56 }, # Cacodemon
49
- 65 => { type: :hitscan, damage: [3, 15], cooldown: 40 }, # Heavy Weapon Dude
44
+ 3004 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Zombieman
45
+ 9 => { type: :hitscan, damage: [3, 15], cooldown: 56 }, # Shotgun Guy
46
+ 3001 => { type: :projectile, cooldown: 52 }, # Imp: fireball
47
+ 3002 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Demon
48
+ 58 => { type: :melee, damage: [4, 40], cooldown: 42 }, # Spectre
49
+ 3003 => { type: :projectile, cooldown: 54 }, # Baron: fireball
50
+ 69 => { type: :projectile, cooldown: 54 }, # Hell Knight
51
+ 3005 => { type: :projectile, cooldown: 56 }, # Cacodemon
52
+ 65 => { type: :hitscan, damage: [3, 15], cooldown: 40 } # Heavy Weapon Dude
50
53
  }.freeze
51
54
 
52
- REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
55
+ REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
53
56
 
54
57
  # Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread)
55
- # Close = ~85%, mid = ~60%, far = ~35%
58
+ # The falloff is HITSCAN_ACCURACY * (1 - dist / (MISSILE_RANGE * 2)):
59
+ # close (dist 0) = ~85%, mid (dist 384) = ~64%, far (dist 768) = ~42%.
56
60
  HITSCAN_ACCURACY = 0.85
57
61
 
58
62
  # Attack animation frames per sprite prefix (E, F, G typically)
@@ -64,10 +68,10 @@ module Doom
64
68
  'HEAD' => %w[E F], # Cacodemon
65
69
  'BOSS' => %w[E F G], # Baron
66
70
  'BOS2' => %w[E F G], # Hell Knight
67
- 'CPOS' => %w[E F], # Heavy Weapon Dude
71
+ 'CPOS' => %w[E F] # Heavy Weapon Dude
68
72
  }.freeze
69
73
 
70
- ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
74
+ ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
71
75
 
72
76
  # Which frame index the actual attack happens on (matching Chocolate Doom)
73
77
  # Zombieman: A_PosAttack on frame F (index 1)
@@ -81,34 +85,36 @@ module Doom
81
85
  'HEAD' => 1, # Cacodemon: E=charge, F=fire
82
86
  'BOSS' => 1, # Baron: E=raise, F=throw, G=recover
83
87
  'BOS2' => 1, # Hell Knight
84
- 'CPOS' => 1, # Heavy Weapon Dude
88
+ 'CPOS' => 1 # Heavy Weapon Dude
85
89
  }.freeze
86
90
 
87
91
  MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
88
92
  :active, :chase_timer, :type, :attack_cooldown,
89
- :reactiontime, :last_saw_player,
90
- :attacking, :attack_frame_tic, :fired)
93
+ :reactiontime, :last_saw_target,
94
+ :attacking, :attack_frame_tic, :fired, :target)
91
95
 
92
- def initialize(map, combat, player_state, sprites_mgr = nil, hidden_things = {}, sound_engine = nil)
96
+ def initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil,
97
+ random: Random.new)
93
98
  @map = map
94
99
  @combat = combat
95
- @player = player_state
100
+ @random = random
96
101
  @sprites_mgr = sprites_mgr
97
102
  @monsters = []
98
- @aggression = true # Monsters fight back (toggle with C)
103
+ @aggression = true # Monsters fight back (toggle with C)
99
104
  @damage_multiplier = 1.0
100
105
  @tic_counter = 0
101
106
  @sound = sound_engine
102
107
  @monster_by_thing_idx = {}
103
108
 
104
109
  map.things.each_with_index do |thing, idx|
105
- next if hidden_things[idx] # Filtered by difficulty
110
+ next if hidden_things[idx] # Filtered by difficulty
106
111
  next unless Combat::MONSTER_HP[thing.type]
107
112
  next if thing.type == Combat::BARREL_TYPE
113
+
108
114
  mon = MonsterState.new(
109
115
  idx, thing.x.to_f, thing.y.to_f,
110
116
  DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
111
- false, 0, false
117
+ false, 0, false, nil
112
118
  )
113
119
  @monsters << mon
114
120
  @monster_by_thing_idx[idx] = mon
@@ -118,10 +124,16 @@ module Doom
118
124
  attr_reader :monsters, :monster_by_thing_idx
119
125
  attr_accessor :aggression, :damage_multiplier
120
126
 
121
- # Called each game tic
122
- def update(player_x, player_y)
127
+ # Called each game tic with every live player. Each monster keeps its own
128
+ # target so a second player walking past does not yank a monster off the
129
+ # one it is already fighting; it re-picks only when its target dies or
130
+ # leaves.
131
+ def update(players)
132
+ players = Array(players)
123
133
  @tic_counter += 1
124
134
  @monsters.each do |mon|
135
+ target = select_target(mon, players)
136
+ next unless target
125
137
  next if @combat.dead?(mon.thing_idx)
126
138
 
127
139
  # Pain state: monster is stunned, skip movement and attacks
@@ -139,7 +151,7 @@ module Doom
139
151
  fire_idx = FIRE_FRAME_INDEX[prefix] || 1
140
152
  fire_tic = fire_idx * ATTACK_FRAME_TICS
141
153
  if !mon.fired && mon.attack_frame_tic >= fire_tic
142
- execute_attack(mon, player_x, player_y)
154
+ execute_attack(mon, target)
143
155
  mon.fired = true
144
156
  end
145
157
 
@@ -154,54 +166,71 @@ module Doom
154
166
  mon.chase_timer -= 1
155
167
  if mon.chase_timer <= 0
156
168
  mon.chase_timer = CHASE_TICS
157
- chase(mon, player_x, player_y)
169
+ chase(mon, target)
158
170
  end
159
171
  else
160
- look(mon, player_x, player_y)
172
+ look(mon, target)
161
173
  end
162
174
  end
163
175
  end
164
176
 
165
177
  private
166
178
 
167
- def look(mon, player_x, player_y)
168
- dx = player_x - mon.x
169
- dy = player_y - mon.y
170
- dist = Math.sqrt(dx * dx + dy * dy)
179
+ # Keep fighting the current target while it is alive and still present;
180
+ # otherwise take the nearest living player. Monsters that have not woken
181
+ # up yet always look at the nearest one.
182
+ def select_target(mon, players)
183
+ current = mon.target
184
+ return current if current && !current.state.dead && players.include?(current)
185
+
186
+ living = players.reject { |p| p.state.dead }
187
+ return nil if living.empty?
188
+
189
+ mon.target = living.min_by { |p| ((p.x - mon.x)**2) + ((p.y - mon.y)**2) }
190
+ end
191
+
192
+ def look(mon, target)
193
+ target_x = target.x
194
+ target_y = target.y
195
+ dx = target_x - mon.x
196
+ dy = target_y - mon.y
197
+ dist = Math.sqrt((dx * dx) + (dy * dy))
171
198
  return if dist > SIGHT_RANGE
172
199
 
173
200
  # DOOM A_Look: monster only sees in ~180-degree forward arc
174
- # unless player is very close (melee range)
201
+ # unless the target is very close (melee range)
175
202
  if dist > MELEE_RANGE
176
203
  thing = @map.things[mon.thing_idx]
177
204
  face_angle = thing.angle * Math::PI / 180.0
178
- to_player = Math.atan2(dy, dx)
179
- angle_diff = ((to_player - face_angle + Math::PI) % (2 * Math::PI) - Math::PI).abs
180
- return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
205
+ to_target = Math.atan2(dy, dx)
206
+ angle_diff = (((to_target - face_angle + Math::PI) % (2 * Math::PI)) - Math::PI).abs
207
+ return if angle_diff > Math::PI / 2 # 90 degrees each side = 180 arc
181
208
  end
182
209
 
183
- if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
184
- mon.active = true
185
- mon.chase_timer = CHASE_TICS
186
- @sound&.monster_see(mon.type)
187
- end
210
+ return unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
211
+
212
+ mon.active = true
213
+ mon.chase_timer = CHASE_TICS
214
+ @sound&.monster_see(mon.type)
188
215
  end
189
216
 
190
- def chase(mon, player_x, player_y)
217
+ def chase(mon, target)
218
+ target_x = target.x
219
+ target_y = target.y
191
220
  speed = MONSTER_SPEED[mon.type] || 8
192
221
 
193
222
  # Tick down attack cooldown
194
223
  mon.attack_cooldown -= CHASE_TICS if mon.attack_cooldown > 0
195
224
 
196
- dx = player_x - mon.x
197
- dy = player_y - mon.y
198
- dist = Math.sqrt(dx * dx + dy * dy)
225
+ dx = target_x - mon.x
226
+ dy = target_y - mon.y
227
+ dist = Math.sqrt((dx * dx) + (dy * dy))
199
228
 
200
- # Track if monster can see the player
201
- can_see = dist < SIGHT_RANGE && has_line_of_sight?(mon.x, mon.y, player_x, player_y)
229
+ # Track if monster can see the target
230
+ can_see = dist < SIGHT_RANGE && has_line_of_sight?(mon.x, mon.y, target_x, target_y)
202
231
  if can_see
203
- mon.last_saw_player = @tic_counter
204
- elsif @tic_counter - (mon.last_saw_player || 0) > 105 # ~3 seconds without LOS
232
+ mon.last_saw_target = @tic_counter
233
+ elsif @tic_counter - (mon.last_saw_target || 0) > 105 # ~3 seconds without LOS
205
234
  # Monster gives up and goes idle (like DOOM's A_Chase returning to spawnstate)
206
235
  mon.active = false
207
236
  mon.reactiontime = REACTIONTIME
@@ -209,15 +238,15 @@ module Doom
209
238
  end
210
239
 
211
240
  # Only attempt attacks when: movecount == 0, has LOS, and in range
212
- if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !@player.dead
213
- attacked = try_attack(mon, player_x, player_y, dist)
241
+ if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !target.state.dead
242
+ attacked = try_attack(mon, target, dist)
214
243
  end
215
244
 
216
245
  # Move -- but ranged monsters stop advancing when they have LOS and are close enough
217
246
  # In DOOM, A_Chase skips movement when P_CheckMissileRange succeeds
218
247
  unless attacked
219
248
  atk = MONSTER_ATTACK[mon.type]
220
- ranged = atk && (atk[:type] == :hitscan || atk[:type] == :projectile)
249
+ ranged = atk && %i[hitscan projectile].include?(atk[:type])
221
250
  skip_move = ranged && can_see && dist < KEEP_DISTANCE
222
251
 
223
252
  if skip_move
@@ -225,9 +254,7 @@ module Doom
225
254
  mon.movecount -= 1 if mon.movecount > 0
226
255
  else
227
256
  mon.movecount -= 1
228
- if mon.movecount < 0 || !try_move(mon, speed)
229
- new_chase_dir(mon, player_x, player_y)
230
- end
257
+ new_chase_dir(mon, target) if mon.movecount < 0 || !try_move(mon, speed)
231
258
  end
232
259
  end
233
260
 
@@ -236,14 +263,16 @@ module Doom
236
263
  thing.x = mon.x.to_i
237
264
  thing.y = mon.y.to_i
238
265
 
239
- # Face toward the player
240
- target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
241
- thing.angle = target_angle.round.to_i
266
+ # Face toward the target
267
+ facing_angle = Math.atan2(target_y - mon.y, target_x - mon.x) * 180.0 / Math::PI
268
+ thing.angle = facing_angle.round.to_i
242
269
  end
243
270
 
244
271
  # Decide whether to start an attack (does NOT apply damage yet)
245
272
  # Matches Chocolate Doom's P_CheckMissileRange from p_enemy.c
246
- def try_attack(mon, player_x, player_y, dist)
273
+ def try_attack(mon, target, dist)
274
+ target_x = target.x
275
+ target_y = target.y
247
276
  if mon.reactiontime > 0
248
277
  mon.reactiontime -= 1
249
278
  return false
@@ -257,14 +286,14 @@ module Doom
257
286
  return false if dist > MELEE_RANGE + (Combat::MONSTER_RADIUS[mon.type] || 20)
258
287
  when :hitscan, :projectile
259
288
  return false if dist > MISSILE_RANGE
260
- return false unless has_line_of_sight?(mon.x, mon.y, player_x, player_y)
289
+ return false unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
261
290
 
262
291
  # P_CheckMissileRange: subtract grace distance, cap at 200
263
- check_dist = dist - 64 # 64 unit grace distance
264
- check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
292
+ check_dist = dist - 64 # 64 unit grace distance
293
+ check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
265
294
  check_dist = [check_dist, 0].max
266
- check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
267
- return false if rand(256) < check_dist
295
+ check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
296
+ return false if @random.rand(256) < check_dist
268
297
  end
269
298
 
270
299
  # Start attack animation (damage applied later on fire frame)
@@ -276,44 +305,53 @@ module Doom
276
305
  end
277
306
 
278
307
  # Called on the fire frame of the attack animation
279
- def execute_attack(mon, player_x, player_y)
308
+ def execute_attack(mon, target)
309
+ target_x = target.x
310
+ target_y = target.y
280
311
  atk = MONSTER_ATTACK[mon.type]
281
312
  return unless atk
282
313
 
283
314
  @sound&.monster_attack(mon.type)
284
315
 
285
- dx = player_x - mon.x
286
- dy = player_y - mon.y
287
- dist = Math.sqrt(dx * dx + dy * dy)
316
+ dx = target_x - mon.x
317
+ dy = target_y - mon.y
318
+ dist = Math.sqrt((dx * dx) + (dy * dy))
319
+
320
+ # Re-check line of sight at fire time. The player may have moved
321
+ # behind cover or a door may have closed during the attack animation
322
+ # (which spans several tics between try_attack and the fire frame).
323
+ # Melee always lands on contact; ranged skips silently if LOS broke.
324
+ ranged = %i[hitscan projectile].include?(atk[:type])
325
+ return if ranged && !has_line_of_sight?(mon.x, mon.y, target_x, target_y)
288
326
 
289
327
  case atk[:type]
290
328
  when :melee
291
329
  min_dmg, max_dmg = atk[:damage]
292
- damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
293
- @player.take_damage(damage) if damage > 0
330
+ damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
331
+ target.state.take_damage(damage) if damage > 0
294
332
 
295
333
  when :hitscan
296
- hit_chance = HITSCAN_ACCURACY * (1.0 - dist / (MISSILE_RANGE * 2))
334
+ hit_chance = HITSCAN_ACCURACY * (1.0 - (dist / (MISSILE_RANGE * 2)))
297
335
  hit_chance = [hit_chance, 0.15].max
298
- if rand < hit_chance
336
+ if @random.rand < hit_chance
299
337
  min_dmg, max_dmg = atk[:damage]
300
- damage = (rand(min_dmg..max_dmg) * @damage_multiplier).to_i
301
- @player.take_damage(damage) if damage > 0
338
+ damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
339
+ target.state.take_damage(damage) if damage > 0
302
340
  end
303
341
 
304
342
  when :projectile
305
343
  # P_SpawnMissile: z = source->z + 32 (chest height)
306
344
  sector = @map.sector_at(mon.x, mon.y)
307
345
  spawn_z = (sector ? sector.floor_height : 0) + 32
308
- @combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier)
346
+ @combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier, target)
309
347
  end
310
348
  end
311
349
 
312
350
  def try_move(mon, speed)
313
351
  return false if mon.movedir == DI_NODIR
314
352
 
315
- new_x = mon.x + speed * XSPEED[mon.movedir]
316
- new_y = mon.y + speed * YSPEED[mon.movedir]
353
+ new_x = mon.x + (speed * XSPEED[mon.movedir])
354
+ new_y = mon.y + (speed * YSPEED[mon.movedir])
317
355
 
318
356
  # Check if the position is valid (inside a sector, not blocked by walls)
319
357
  sector = @map.sector_at(new_x, new_y)
@@ -327,7 +365,7 @@ module Doom
327
365
 
328
366
  # Simple line-circle intersection
329
367
  radius = Combat::MONSTER_RADIUS[mon.type] || 20
330
- next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
368
+ next unless Geometry.line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, new_x, new_y, radius)
331
369
 
332
370
  # One-sided walls always block
333
371
  if ld.sidedef_left == 0xFFFF
@@ -336,16 +374,16 @@ module Doom
336
374
  end
337
375
 
338
376
  # Two-sided: check step height and headroom
339
- if ld.sidedef_left < 0xFFFF
340
- front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
341
- back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
342
- step = (back.floor_height - front.floor_height).abs
343
- min_ceil = [front.ceiling_height, back.ceiling_height].min
344
- max_floor = [front.floor_height, back.floor_height].max
345
- if step > 24 || (min_ceil - max_floor) < 56
346
- blocked = true
347
- break
348
- end
377
+ next unless ld.sidedef_left < 0xFFFF
378
+
379
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
380
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
381
+ step = (back.floor_height - front.floor_height).abs
382
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
383
+ max_floor = [front.floor_height, back.floor_height].max
384
+ if step > 24 || (min_ceil - max_floor) < 56
385
+ blocked = true
386
+ break
349
387
  end
350
388
  end
351
389
  return false if blocked
@@ -355,9 +393,11 @@ module Doom
355
393
  true
356
394
  end
357
395
 
358
- def new_chase_dir(mon, player_x, player_y)
359
- deltax = player_x - mon.x
360
- deltay = player_y - mon.y
396
+ def new_chase_dir(mon, target)
397
+ target_x = target.x
398
+ target_y = target.y
399
+ deltax = target_x - mon.x
400
+ deltay = target_y - mon.y
361
401
  old_dir = mon.movedir
362
402
 
363
403
  # Determine preferred directions
@@ -376,16 +416,12 @@ module Doom
376
416
  diag = diagonal_dir(dir_x, dir_y)
377
417
  if diag != OPPOSITE[old_dir]
378
418
  mon.movedir = diag
379
- if try_walk(mon)
380
- return
381
- end
419
+ return if try_walk(mon)
382
420
  end
383
421
  end
384
422
 
385
423
  # Randomly swap X/Y priority
386
- if rand > 0.22 || deltay.abs > deltax.abs
387
- dir_x, dir_y = dir_y, dir_x
388
- end
424
+ dir_x, dir_y = dir_y, dir_x if @random.rand > 0.22 || deltay.abs > deltax.abs
389
425
 
390
426
  # Try primary direction
391
427
  if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
@@ -406,10 +442,11 @@ module Doom
406
442
  end
407
443
 
408
444
  # Try all other directions
409
- start = rand(8)
445
+ start = @random.rand(8)
410
446
  8.times do |i|
411
447
  d = (start + i) % 8
412
448
  next if d == OPPOSITE[old_dir]
449
+
413
450
  mon.movedir = d
414
451
  return if try_walk(mon)
415
452
  end
@@ -426,7 +463,7 @@ module Doom
426
463
  def try_walk(mon)
427
464
  speed = MONSTER_SPEED[mon.type] || 8
428
465
  if try_move(mon, speed)
429
- mon.movecount = rand(16)
466
+ mon.movecount = @random.rand(16)
430
467
  true
431
468
  else
432
469
  false
@@ -449,46 +486,23 @@ module Doom
449
486
  v1 = @map.vertices[ld.v1]
450
487
  v2 = @map.vertices[ld.v2]
451
488
 
452
- next unless segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
489
+ next unless Geometry.segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
453
490
 
454
491
  # One-sided walls always block
455
492
  return false if ld.sidedef_left == 0xFFFF
456
493
 
457
494
  # Two-sided: check if opening is big enough to see through
458
- if ld.sidedef_left < 0xFFFF
459
- front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
460
- back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
461
- max_floor = [front.floor_height, back.floor_height].max
462
- min_ceil = [front.ceiling_height, back.ceiling_height].min
463
- # Block sight if the opening is too small
464
- return false if (min_ceil - max_floor) < 1
465
- end
495
+ next unless ld.sidedef_left < 0xFFFF
496
+
497
+ front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
498
+ back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
499
+ max_floor = [front.floor_height, back.floor_height].max
500
+ min_ceil = [front.ceiling_height, back.ceiling_height].min
501
+ # Block sight if the opening is too small
502
+ return false if (min_ceil - max_floor) < 1
466
503
  end
467
504
  true
468
505
  end
469
-
470
- def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
471
- d1x = ax2 - ax1; d1y = ay2 - ay1
472
- d2x = bx2 - bx1; d2y = by2 - by1
473
- denom = d1x * d2y - d1y * d2x
474
- return false if denom.abs < 0.001
475
- dx = bx1 - ax1; dy = by1 - ay1
476
- t = (dx * d2y - dy * d2x).to_f / denom
477
- u = (dx * d1y - dy * d1x).to_f / denom
478
- t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
479
- end
480
-
481
- def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
482
- dx = cx - x1; dy = cy - y1
483
- line_dx = x2 - x1; line_dy = y2 - y1
484
- line_len_sq = line_dx * line_dx + line_dy * line_dy
485
- return false if line_len_sq == 0
486
- t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
487
- t = [[t, 0.0].max, 1.0].min
488
- closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
489
- dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
490
- dist_sq < radius * radius
491
- end
492
506
  end
493
507
  end
494
508
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # A player as a first-class entity.
6
+ #
7
+ # Before this existed the "player" was scattered across three objects:
8
+ # position and angle lived in Renderer, horizontal momentum lived in
9
+ # GosuWindow, and floor height lived in PlayerPhysics -- which made more
10
+ # than one of them impossible. Everything that identifies a player now
11
+ # lives here; Renderer is reduced to a camera that gets pointed at a pose.
12
+ #
13
+ # Angles are radians internally (Renderer's old setters took degrees).
14
+ class Player
15
+ attr_accessor :id, :x, :y, :z, :angle, :momx, :momy
16
+ # Edge-trigger latch for the use key, per player: holding the key must
17
+ # not spam doors, and each player needs its own latch.
18
+ attr_accessor :use_held
19
+ # Deathmatch score. It lives on the player rather than in a side table so
20
+ # it survives respawning, which throws away the whole PlayerState.
21
+ attr_accessor :frags
22
+ attr_reader :state, :sin_angle, :cos_angle
23
+
24
+ # Previous tic's pose, kept so the renderer can interpolate between tics
25
+ # and stay smooth above 35 FPS.
26
+ attr_reader :prev_x, :prev_y, :prev_z, :prev_angle
27
+
28
+ def initialize(id: 0, state: PlayerState.new)
29
+ @id = id
30
+ @state = state
31
+ @x = 0.0
32
+ @y = 0.0
33
+ @z = 41.0 # eye height
34
+ @momx = 0.0
35
+ @momy = 0.0
36
+ @use_held = false
37
+ @frags = 0
38
+ self.angle = 0.0
39
+ snapshot!
40
+ end
41
+
42
+ # Keeps the sin/cos cache in step with the angle -- every read path
43
+ # (thrust direction, firing direction, view transform) depends on it.
44
+ def angle=(radians)
45
+ @angle = radians
46
+ @sin_angle = Math.sin(radians)
47
+ @cos_angle = Math.cos(radians)
48
+ end
49
+
50
+ def angle_degrees=(degrees)
51
+ self.angle = degrees * Math::PI / 180.0
52
+ end
53
+
54
+ def turn(degrees)
55
+ self.angle = @angle + (degrees * Math::PI / 180.0)
56
+ end
57
+
58
+ def move_to(x, y)
59
+ @x = x.to_f
60
+ @y = y.to_f
61
+ end
62
+
63
+ # Place the player outright (spawn, teleport, respawn) and collapse the
64
+ # interpolation history so the camera doesn't sweep across the map.
65
+ def place(x, y, z, angle_degrees)
66
+ @x = x.to_f
67
+ @y = y.to_f
68
+ @z = z.to_f
69
+ self.angle_degrees = angle_degrees
70
+ @momx = 0.0
71
+ @momy = 0.0
72
+ snapshot!
73
+ end
74
+
75
+ # Called at the start of each tic, before the simulation moves the player.
76
+ def snapshot!
77
+ @prev_x = @x
78
+ @prev_y = @y
79
+ @prev_z = @z
80
+ @prev_angle = @angle
81
+ end
82
+
83
+ # Pose to render at, `alpha` of the way from the previous tic to this one.
84
+ # Angle is interpolated along the shortest arc so wrapping past +/-PI
85
+ # doesn't spin the view the long way round.
86
+ def view_pose(alpha)
87
+ a = alpha.clamp(0.0, 1.0)
88
+ delta = @angle - @prev_angle
89
+ delta -= 2 * Math::PI while delta > Math::PI
90
+ delta += 2 * Math::PI while delta < -Math::PI
91
+
92
+ [
93
+ @prev_x + ((@x - @prev_x) * a),
94
+ @prev_y + ((@y - @prev_y) * a),
95
+ @prev_z + ((@z - @prev_z) * a),
96
+ @prev_angle + (delta * a)
97
+ ]
98
+ end
99
+
100
+ def dead?
101
+ @state.dead
102
+ end
103
+ end
104
+ end
105
+ end