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
@@ -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,83 +27,235 @@ 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
- SIGHT_RANGE = 768.0 # Max distance for sight check (DOOM uses sector sound propagation, we approximate)
34
+ SIGHT_RANGE = 768.0 # Max distance for sight check
29
35
  MELEE_RANGE = 64.0
36
+ MISSILE_RANGE = 768.0
37
+ KEEP_DISTANCE = 196.0 # Ranged monsters prefer to stay this far from player
38
+
39
+ # Monster attack definitions (from mobjinfo / A_Chase)
40
+ # Cooldown = attack_anim_tics + avg_movecount(7.5) * chase_tics(4)
41
+ # In DOOM, monsters only attempt attacks when movecount reaches 0,
42
+ # then play full attack animation before returning to chase.
43
+ MONSTER_ATTACK = {
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
53
+ }.freeze
54
+
55
+ REACTIONTIME = 8 # Tics before first attack after activation (from mobjinfo)
56
+
57
+ # Hitscan hit probability by distance (DOOM's P_AimLineAttack has bullet spread)
58
+ # The falloff is HITSCAN_ACCURACY * (1 - dist / (MISSILE_RANGE * 2)):
59
+ # close (dist 0) = ~85%, mid (dist 384) = ~64%, far (dist 768) = ~42%.
60
+ HITSCAN_ACCURACY = 0.85
61
+
62
+ # Attack animation frames per sprite prefix (E, F, G typically)
63
+ ATTACK_FRAMES = {
64
+ 'POSS' => %w[E F], # Zombieman: raise, fire
65
+ 'SPOS' => %w[E F], # Shotgun Guy
66
+ 'TROO' => %w[E F G H], # Imp: raise, fireball, throw, recover
67
+ 'SARG' => %w[E F G], # Demon: bite
68
+ 'HEAD' => %w[E F], # Cacodemon
69
+ 'BOSS' => %w[E F G], # Baron
70
+ 'BOS2' => %w[E F G], # Hell Knight
71
+ 'CPOS' => %w[E F] # Heavy Weapon Dude
72
+ }.freeze
30
73
 
31
- # Direction to angle (for sprite facing)
32
- DIR_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315].freeze
74
+ ATTACK_FRAME_TICS = 8 # Tics per attack animation frame
75
+
76
+ # Which frame index the actual attack happens on (matching Chocolate Doom)
77
+ # Zombieman: A_PosAttack on frame F (index 1)
78
+ # Imp: A_TroopAttack on frame G (index 2)
79
+ # Demon: A_SargAttack on frame F (index 1)
80
+ FIRE_FRAME_INDEX = {
81
+ 'POSS' => 1, # Zombieman: E=raise, F=fire
82
+ 'SPOS' => 1, # Shotgun Guy: E=raise, F=fire
83
+ 'TROO' => 2, # Imp: E=raise, F=aim, G=throw, H=recover
84
+ 'SARG' => 1, # Demon: E=open, F=bite, G=close
85
+ 'HEAD' => 1, # Cacodemon: E=charge, F=fire
86
+ 'BOSS' => 1, # Baron: E=raise, F=throw, G=recover
87
+ 'BOS2' => 1, # Hell Knight
88
+ 'CPOS' => 1 # Heavy Weapon Dude
89
+ }.freeze
33
90
 
34
91
  MonsterState = Struct.new(:thing_idx, :x, :y, :movedir, :movecount,
35
- :active, :chase_timer, :type)
92
+ :active, :chase_timer, :type, :attack_cooldown,
93
+ :reactiontime, :last_saw_target,
94
+ :attacking, :attack_frame_tic, :fired, :target)
36
95
 
37
- def initialize(map, combat)
96
+ def initialize(map, combat, sprites_mgr = nil, hidden_things = {}, sound_engine = nil,
97
+ random: Random.new)
38
98
  @map = map
39
99
  @combat = combat
100
+ @random = random
101
+ @sprites_mgr = sprites_mgr
40
102
  @monsters = []
103
+ @aggression = true # Monsters fight back (toggle with C)
104
+ @damage_multiplier = 1.0
105
+ @tic_counter = 0
106
+ @sound = sound_engine
107
+ @monster_by_thing_idx = {}
41
108
 
42
109
  map.things.each_with_index do |thing, idx|
110
+ next if hidden_things[idx] # Filtered by difficulty
43
111
  next unless Combat::MONSTER_HP[thing.type]
44
- @monsters << MonsterState.new(
112
+ next if thing.type == Combat::BARREL_TYPE
113
+
114
+ mon = MonsterState.new(
45
115
  idx, thing.x.to_f, thing.y.to_f,
46
- DI_NODIR, 0, false, 0, thing.type
116
+ DI_NODIR, 0, false, 0, thing.type, 0, REACTIONTIME, 0,
117
+ false, 0, false, nil
47
118
  )
119
+ @monsters << mon
120
+ @monster_by_thing_idx[idx] = mon
48
121
  end
49
122
  end
50
123
 
51
- attr_reader :monsters
124
+ attr_reader :monsters, :monster_by_thing_idx
125
+ attr_accessor :aggression, :damage_multiplier
52
126
 
53
- # Called each game tic
54
- 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)
133
+ @tic_counter += 1
55
134
  @monsters.each do |mon|
135
+ target = select_target(mon, players)
136
+ next unless target
56
137
  next if @combat.dead?(mon.thing_idx)
57
138
 
139
+ # Pain state: monster is stunned, skip movement and attacks
140
+ next if @combat.in_pain?(mon.thing_idx)
141
+
58
142
  if mon.active
143
+ # Attack animation in progress: freeze movement, tick animation
144
+ if mon.attacking
145
+ mon.attack_frame_tic += 1
146
+ prefix = @sprites_mgr&.prefix_for(mon.type)
147
+ frames = ATTACK_FRAMES[prefix]
148
+ total_tics = (frames&.size || 2) * ATTACK_FRAME_TICS
149
+
150
+ # Fire on the correct frame (matching Chocolate Doom)
151
+ fire_idx = FIRE_FRAME_INDEX[prefix] || 1
152
+ fire_tic = fire_idx * ATTACK_FRAME_TICS
153
+ if !mon.fired && mon.attack_frame_tic >= fire_tic
154
+ execute_attack(mon, target)
155
+ mon.fired = true
156
+ end
157
+
158
+ if mon.attack_frame_tic >= total_tics
159
+ mon.attacking = false
160
+ mon.attack_frame_tic = 0
161
+ mon.fired = false
162
+ end
163
+ next
164
+ end
165
+
59
166
  mon.chase_timer -= 1
60
167
  if mon.chase_timer <= 0
61
168
  mon.chase_timer = CHASE_TICS
62
- chase(mon, player_x, player_y)
169
+ chase(mon, target)
63
170
  end
64
171
  else
65
- look(mon, player_x, player_y)
172
+ look(mon, target)
66
173
  end
67
174
  end
68
175
  end
69
176
 
70
177
  private
71
178
 
72
- def look(mon, player_x, player_y)
73
- dx = player_x - mon.x
74
- dy = player_y - mon.y
75
- 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))
76
198
  return if dist > SIGHT_RANGE
77
199
 
78
200
  # DOOM A_Look: monster only sees in ~180-degree forward arc
79
- # unless player is very close (melee range)
201
+ # unless the target is very close (melee range)
80
202
  if dist > MELEE_RANGE
81
203
  thing = @map.things[mon.thing_idx]
82
204
  face_angle = thing.angle * Math::PI / 180.0
83
- to_player = Math.atan2(dy, dx)
84
- angle_diff = ((to_player - face_angle + Math::PI) % (2 * Math::PI) - Math::PI).abs
85
- 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
86
208
  end
87
209
 
88
- if has_line_of_sight?(mon.x, mon.y, player_x, player_y)
89
- mon.active = true
90
- mon.chase_timer = CHASE_TICS
91
- 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)
92
215
  end
93
216
 
94
- def chase(mon, player_x, player_y)
217
+ def chase(mon, target)
218
+ target_x = target.x
219
+ target_y = target.y
95
220
  speed = MONSTER_SPEED[mon.type] || 8
96
221
 
97
- # Decrement movecount; pick new direction when expired or blocked
98
- mon.movecount -= 1
99
- if mon.movecount < 0 || !try_move(mon, speed)
100
- new_chase_dir(mon, player_x, player_y)
222
+ # Tick down attack cooldown
223
+ mon.attack_cooldown -= CHASE_TICS if mon.attack_cooldown > 0
224
+
225
+ dx = target_x - mon.x
226
+ dy = target_y - mon.y
227
+ dist = Math.sqrt((dx * dx) + (dy * dy))
228
+
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)
231
+ if can_see
232
+ mon.last_saw_target = @tic_counter
233
+ elsif @tic_counter - (mon.last_saw_target || 0) > 105 # ~3 seconds without LOS
234
+ # Monster gives up and goes idle (like DOOM's A_Chase returning to spawnstate)
235
+ mon.active = false
236
+ mon.reactiontime = REACTIONTIME
237
+ return
238
+ end
239
+
240
+ # Only attempt attacks when: movecount == 0, has LOS, and in range
241
+ if @aggression && mon.attack_cooldown <= 0 && mon.movecount <= 0 && can_see && !target.state.dead
242
+ attacked = try_attack(mon, target, dist)
243
+ end
244
+
245
+ # Move -- but ranged monsters stop advancing when they have LOS and are close enough
246
+ # In DOOM, A_Chase skips movement when P_CheckMissileRange succeeds
247
+ unless attacked
248
+ atk = MONSTER_ATTACK[mon.type]
249
+ ranged = atk && %i[hitscan projectile].include?(atk[:type])
250
+ skip_move = ranged && can_see && dist < KEEP_DISTANCE
251
+
252
+ if skip_move
253
+ # Still tick movecount down so attack condition can trigger
254
+ mon.movecount -= 1 if mon.movecount > 0
255
+ else
256
+ mon.movecount -= 1
257
+ new_chase_dir(mon, target) if mon.movecount < 0 || !try_move(mon, speed)
258
+ end
101
259
  end
102
260
 
103
261
  # Update the thing's position and facing angle in the map for rendering
@@ -105,16 +263,95 @@ module Doom
105
263
  thing.x = mon.x.to_i
106
264
  thing.y = mon.y.to_i
107
265
 
108
- # Face toward the player (smooth turning)
109
- target_angle = Math.atan2(player_y - mon.y, player_x - mon.x) * 180.0 / Math::PI
110
- 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
269
+ end
270
+
271
+ # Decide whether to start an attack (does NOT apply damage yet)
272
+ # Matches Chocolate Doom's P_CheckMissileRange from p_enemy.c
273
+ def try_attack(mon, target, dist)
274
+ target_x = target.x
275
+ target_y = target.y
276
+ if mon.reactiontime > 0
277
+ mon.reactiontime -= 1
278
+ return false
279
+ end
280
+
281
+ atk = MONSTER_ATTACK[mon.type]
282
+ return false unless atk
283
+
284
+ case atk[:type]
285
+ when :melee
286
+ return false if dist > MELEE_RANGE + (Combat::MONSTER_RADIUS[mon.type] || 20)
287
+ when :hitscan, :projectile
288
+ return false if dist > MISSILE_RANGE
289
+ return false unless has_line_of_sight?(mon.x, mon.y, target_x, target_y)
290
+
291
+ # P_CheckMissileRange: subtract grace distance, cap at 200
292
+ check_dist = dist - 64 # 64 unit grace distance
293
+ check_dist -= 128 if atk[:type] == :projectile # Pure ranged fire more
294
+ check_dist = [check_dist, 0].max
295
+ check_dist = [check_dist, 200].min # Cap: always >= 22% chance to fire
296
+ return false if @random.rand(256) < check_dist
297
+ end
298
+
299
+ # Start attack animation (damage applied later on fire frame)
300
+ mon.attacking = true
301
+ mon.attack_frame_tic = 0
302
+ mon.fired = false
303
+ mon.attack_cooldown = atk[:cooldown]
304
+ true
305
+ end
306
+
307
+ # Called on the fire frame of the attack animation
308
+ def execute_attack(mon, target)
309
+ target_x = target.x
310
+ target_y = target.y
311
+ atk = MONSTER_ATTACK[mon.type]
312
+ return unless atk
313
+
314
+ @sound&.monster_attack(mon.type)
315
+
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)
326
+
327
+ case atk[:type]
328
+ when :melee
329
+ min_dmg, max_dmg = atk[:damage]
330
+ damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
331
+ target.state.take_damage(damage) if damage > 0
332
+
333
+ when :hitscan
334
+ hit_chance = HITSCAN_ACCURACY * (1.0 - (dist / (MISSILE_RANGE * 2)))
335
+ hit_chance = [hit_chance, 0.15].max
336
+ if @random.rand < hit_chance
337
+ min_dmg, max_dmg = atk[:damage]
338
+ damage = (@random.rand(min_dmg..max_dmg) * @damage_multiplier).to_i
339
+ target.state.take_damage(damage) if damage > 0
340
+ end
341
+
342
+ when :projectile
343
+ # P_SpawnMissile: z = source->z + 32 (chest height)
344
+ sector = @map.sector_at(mon.x, mon.y)
345
+ spawn_z = (sector ? sector.floor_height : 0) + 32
346
+ @combat.spawn_monster_projectile(mon.x, mon.y, spawn_z, mon.type, @damage_multiplier, target)
347
+ end
111
348
  end
112
349
 
113
350
  def try_move(mon, speed)
114
351
  return false if mon.movedir == DI_NODIR
115
352
 
116
- new_x = mon.x + speed * XSPEED[mon.movedir]
117
- 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])
118
355
 
119
356
  # Check if the position is valid (inside a sector, not blocked by walls)
120
357
  sector = @map.sector_at(new_x, new_y)
@@ -128,7 +365,7 @@ module Doom
128
365
 
129
366
  # Simple line-circle intersection
130
367
  radius = Combat::MONSTER_RADIUS[mon.type] || 20
131
- 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)
132
369
 
133
370
  # One-sided walls always block
134
371
  if ld.sidedef_left == 0xFFFF
@@ -137,16 +374,16 @@ module Doom
137
374
  end
138
375
 
139
376
  # Two-sided: check step height and headroom
140
- if ld.sidedef_left < 0xFFFF
141
- front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
142
- back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
143
- step = (back.floor_height - front.floor_height).abs
144
- min_ceil = [front.ceiling_height, back.ceiling_height].min
145
- max_floor = [front.floor_height, back.floor_height].max
146
- if step > 24 || (min_ceil - max_floor) < 56
147
- blocked = true
148
- break
149
- 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
150
387
  end
151
388
  end
152
389
  return false if blocked
@@ -156,9 +393,11 @@ module Doom
156
393
  true
157
394
  end
158
395
 
159
- def new_chase_dir(mon, player_x, player_y)
160
- deltax = player_x - mon.x
161
- 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
162
401
  old_dir = mon.movedir
163
402
 
164
403
  # Determine preferred directions
@@ -177,16 +416,12 @@ module Doom
177
416
  diag = diagonal_dir(dir_x, dir_y)
178
417
  if diag != OPPOSITE[old_dir]
179
418
  mon.movedir = diag
180
- if try_walk(mon)
181
- return
182
- end
419
+ return if try_walk(mon)
183
420
  end
184
421
  end
185
422
 
186
423
  # Randomly swap X/Y priority
187
- if rand > 0.22 || deltay.abs > deltax.abs
188
- dir_x, dir_y = dir_y, dir_x
189
- end
424
+ dir_x, dir_y = dir_y, dir_x if @random.rand > 0.22 || deltay.abs > deltax.abs
190
425
 
191
426
  # Try primary direction
192
427
  if dir_x != DI_NODIR && dir_x != OPPOSITE[old_dir]
@@ -207,10 +442,11 @@ module Doom
207
442
  end
208
443
 
209
444
  # Try all other directions
210
- start = rand(8)
445
+ start = @random.rand(8)
211
446
  8.times do |i|
212
447
  d = (start + i) % 8
213
448
  next if d == OPPOSITE[old_dir]
449
+
214
450
  mon.movedir = d
215
451
  return if try_walk(mon)
216
452
  end
@@ -227,7 +463,7 @@ module Doom
227
463
  def try_walk(mon)
228
464
  speed = MONSTER_SPEED[mon.type] || 8
229
465
  if try_move(mon, speed)
230
- mon.movecount = rand(16)
466
+ mon.movecount = @random.rand(16)
231
467
  true
232
468
  else
233
469
  false
@@ -250,46 +486,23 @@ module Doom
250
486
  v1 = @map.vertices[ld.v1]
251
487
  v2 = @map.vertices[ld.v2]
252
488
 
253
- 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)
254
490
 
255
491
  # One-sided walls always block
256
492
  return false if ld.sidedef_left == 0xFFFF
257
493
 
258
494
  # Two-sided: check if opening is big enough to see through
259
- if ld.sidedef_left < 0xFFFF
260
- front = @map.sectors[@map.sidedefs[ld.sidedef_right].sector]
261
- back = @map.sectors[@map.sidedefs[ld.sidedef_left].sector]
262
- max_floor = [front.floor_height, back.floor_height].max
263
- min_ceil = [front.ceiling_height, back.ceiling_height].min
264
- # Block sight if the opening is too small
265
- return false if (min_ceil - max_floor) < 1
266
- 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
267
503
  end
268
504
  true
269
505
  end
270
-
271
- def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
272
- d1x = ax2 - ax1; d1y = ay2 - ay1
273
- d2x = bx2 - bx1; d2y = by2 - by1
274
- denom = d1x * d2y - d1y * d2x
275
- return false if denom.abs < 0.001
276
- dx = bx1 - ax1; dy = by1 - ay1
277
- t = (dx * d2y - dy * d2x).to_f / denom
278
- u = (dx * d1y - dy * d1x).to_f / denom
279
- t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
280
- end
281
-
282
- def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
283
- dx = cx - x1; dy = cy - y1
284
- line_dx = x2 - x1; line_dy = y2 - y1
285
- line_len_sq = line_dx * line_dx + line_dy * line_dy
286
- return false if line_len_sq == 0
287
- t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
288
- t = [[t, 0.0].max, 1.0].min
289
- closest_x = x1 + t * line_dx; closest_y = y1 + t * line_dy
290
- dist_sq = (cx - closest_x) ** 2 + (cy - closest_y) ** 2
291
- dist_sq < radius * radius
292
- end
293
506
  end
294
507
  end
295
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