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,27 +6,30 @@ module Doom
6
6
  module Game
7
7
  # Manages animated sector actions (doors, lifts, etc.)
8
8
  class SectorActions
9
- # Door states
10
- DOOR_CLOSED = 0
9
+ # Door states (doors start at DOOR_OPENING and are deleted once fully
10
+ # closed, so there is no resting DOOR_CLOSED state to model).
11
11
  DOOR_OPENING = 1
12
12
  DOOR_OPEN = 2
13
13
  DOOR_CLOSING = 3
14
14
 
15
15
  # Door speeds (units per tic, 35 tics/sec)
16
16
  DOOR_SPEED = 2
17
- DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
18
- PLAYER_HEIGHT = 56
17
+ DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
19
18
 
20
19
  # Lift constants
21
20
  LIFT_SPEED = 4
22
- LIFT_WAIT = 105 # ~3 seconds
21
+ LIFT_WAIT = 105 # ~3 seconds
23
22
 
24
23
  attr_reader :exit_triggered, :secrets_found
25
24
 
26
- def pop_teleport
27
- dest = @teleport_dest
28
- @teleport_dest = nil
29
- dest
25
+ # Pending teleports, drained by the world once per tic: a map of
26
+ # player_id => destination. Each player teleports to its own destination,
27
+ # so a single shared slot (which only ever moved player 0) is not enough
28
+ # in a multiplayer world.
29
+ def pop_teleports
30
+ dests = @teleport_dests
31
+ @teleport_dests = {}
32
+ dests
30
33
  end
31
34
 
32
35
  def initialize(map, sound_engine = nil)
@@ -36,14 +39,32 @@ module Doom
36
39
  @active_lifts = {} # sector_index => lift_state
37
40
  @player_x = 0
38
41
  @player_y = 0
42
+ @player_positions = [] # [[player_id, x, y], ...] in id order
39
43
  @exit_triggered = nil
40
44
  @secrets_found = {} # sector_index => true
41
45
  @crossed_linedefs = {}
46
+ @near_linedefs = {} # player_id => { linedef_idx => side }
47
+ @teleport_dests = {} # player_id => { x:, y:, angle: }
42
48
  end
43
49
 
50
+ # Single-player convenience used by the unit specs and by the world's
51
+ # use-line ray cast: treat the given point as player 0's position.
44
52
  def update_player_position(x, y)
45
53
  @player_x = x
46
54
  @player_y = y
55
+ @player_positions = [[0, x, y]]
56
+ end
57
+
58
+ # Feed every player's position (ordered by id) so walk triggers fire for
59
+ # each player independently and reproducibly. The primary (first) position
60
+ # also drives the single-player-only checks (secrets, door reopen).
61
+ def update_players(positions)
62
+ @player_positions = positions
63
+ first = positions.first
64
+ return unless first
65
+
66
+ @player_x = first[1]
67
+ @player_y = first[2]
47
68
  end
48
69
 
49
70
  def update
@@ -53,8 +74,10 @@ module Doom
53
74
  check_secrets
54
75
  end
55
76
 
56
- # Try to use a linedef (called when player presses use key)
57
- def use_linedef(linedef, linedef_idx)
77
+ # Try to use a linedef (called when player presses use key). `keys` is the
78
+ # acting player's key ring (a hash like {blue_card: true}); locked doors
79
+ # consult it and refuse to open without the matching card.
80
+ def use_linedef(linedef, _linedef_idx, keys = {})
58
81
  return false if linedef.special == 0
59
82
 
60
83
  case linedef.special
@@ -62,24 +85,24 @@ module Doom
62
85
  when 1 # DR Door Open Wait Close
63
86
  activate_door(linedef)
64
87
  when 26 # DR Blue Door
65
- activate_door(linedef, key: :blue_card)
88
+ activate_door(linedef, key: :blue_card, keys: keys)
66
89
  when 27 # DR Yellow Door
67
- activate_door(linedef, key: :yellow_card)
90
+ activate_door(linedef, key: :yellow_card, keys: keys)
68
91
  when 28 # DR Red Door
69
- activate_door(linedef, key: :red_card)
92
+ activate_door(linedef, key: :red_card, keys: keys)
70
93
  when 31 # D1 Door Open Stay
71
94
  activate_door(linedef, stay_open: true)
72
95
  when 32 # D1 Blue Door Open Stay
73
- activate_door(linedef, key: :blue_card, stay_open: true)
96
+ activate_door(linedef, key: :blue_card, stay_open: true, keys: keys)
74
97
  when 33 # D1 Red Door Open Stay
75
- activate_door(linedef, key: :red_card, stay_open: true)
98
+ activate_door(linedef, key: :red_card, stay_open: true, keys: keys)
76
99
  when 34 # D1 Yellow Door Open Stay
77
- activate_door(linedef, key: :yellow_card, stay_open: true)
100
+ activate_door(linedef, key: :yellow_card, stay_open: true, keys: keys)
78
101
  when 103 # S1 Door Open Wait Close (tagged)
79
102
  activate_tagged_door(linedef)
80
103
 
81
104
  # --- Lifts ---
82
- when 62 # SR Lift Lower Wait Raise (repeatable)
105
+ when 62 # SR Lift Lower Wait Raise (repeatable)
83
106
  activate_lift(linedef)
84
107
 
85
108
  # --- Floor changes ---
@@ -112,11 +135,11 @@ module Doom
112
135
 
113
136
  # Walk-over trigger types:
114
137
  # W1 = once, WR = repeatable
138
+ # Note: stair builders (specials 7 and 8) are not modeled, so they are
139
+ # intentionally absent here.
115
140
  WALK_TRIGGERS = {
116
- 2 => :door_open_stay, # W1 Door Open Stay
117
- 5 => :raise_floor, # W1 Raise Floor to Lowest Ceiling
118
- 7 => :stairs, # S1 Build Stairs
119
- 8 => :stairs, # W1 Build Stairs
141
+ 2 => :door_open_stay, # W1 Door Open Stay
142
+ 5 => :raise_floor, # W1 Raise Floor to Lowest Ceiling
120
143
  52 => :exit, # W1 Exit
121
144
  82 => :lower_floor, # WR Lower Floor to Lowest
122
145
  86 => :door_open_stay, # WR Door Open Stay
@@ -125,90 +148,104 @@ module Doom
125
148
  91 => :raise_floor, # WR Raise Floor to Lowest Ceiling
126
149
  97 => :teleport, # WR Teleport
127
150
  98 => :lower_floor, # WR Lower Floor to Highest - 8
128
- 124 => :secret_exit, # W1 Secret Exit
151
+ 124 => :secret_exit # W1 Secret Exit
129
152
  }.freeze
130
153
 
131
154
  # W1 types that only trigger once
132
- W1_TYPES = [2, 5, 7, 8, 52, 124].freeze
155
+ W1_TYPES = [2, 5, 52, 124].freeze
133
156
 
157
+ # Walk triggers fire per player: every player must be able to cross a line
158
+ # and set it off. Players are processed in a fixed id order so the tic is
159
+ # reproducible, and each player's side memory is tracked separately (a
160
+ # shared record would let one player's crossing state clobber another's).
134
161
  def check_walk_triggers
135
162
  @near_linedefs ||= {}
136
163
 
164
+ @player_positions.each do |(pid, px, py)|
165
+ sides = (@near_linedefs[pid] ||= {})
166
+ check_walk_triggers_for(pid, px, py, sides)
167
+ end
168
+ end
169
+
170
+ def check_walk_triggers_for(pid, px, py, sides)
137
171
  @map.linedefs.each_with_index do |ld, idx|
138
172
  next if ld.special == 0
173
+
139
174
  action = WALK_TRIGGERS[ld.special]
140
175
  next unless action
141
176
 
142
- # W1 types only trigger once
143
- if W1_TYPES.include?(ld.special)
144
- next if @crossed_linedefs[idx]
145
- end
177
+ # W1 types only trigger once (level-global: once any player crosses,
178
+ # it is spent), so the crossed record stays shared across players.
179
+ next if W1_TYPES.include?(ld.special) && @crossed_linedefs[idx]
146
180
 
147
181
  v1 = @map.vertices[ld.v1]
148
182
  v2 = @map.vertices[ld.v2]
149
183
 
150
184
  # Determine which side of the linedef the player is on
151
185
  # DOOM's P_CrossSpecialLine fires when the player transitions sides
152
- side = line_side(@player_x, @player_y, v1.x, v1.y, v2.x, v2.y)
153
- dist = point_line_dist(@player_x, @player_y, v1.x, v1.y, v2.x, v2.y)
186
+ side = line_side(px, py, v1.x, v1.y, v2.x, v2.y)
187
+ dist = Geometry.point_to_segment_distance(px, py, v1.x, v1.y, v2.x, v2.y)
154
188
 
155
- near = dist < 32 # Detection range
156
- prev_side = @near_linedefs[idx]
189
+ near = dist < 32 # Detection range
190
+ prev_side = sides[idx]
157
191
 
158
192
  if near && prev_side && prev_side != side
159
193
  # Player crossed the line - trigger!
160
- @near_linedefs[idx] = side
194
+ sides[idx] = side
161
195
  elsif near && prev_side.nil?
162
196
  # First time near - record side but don't trigger yet
163
- @near_linedefs[idx] = side
197
+ sides[idx] = side
164
198
  next
165
199
  elsif !near
166
- @near_linedefs[idx] = nil
200
+ sides[idx] = nil
167
201
  next
168
202
  else
169
- next # Same side, no crossing
203
+ next # Same side, no crossing
170
204
  end
171
205
 
172
206
  @crossed_linedefs[idx] = true
207
+ fire_walk_action(action, ld, pid)
208
+ end
209
+ end
173
210
 
174
- case action
175
- when :exit
176
- @exit_triggered = :normal
177
- when :secret_exit
178
- @exit_triggered = :secret
179
- when :door_open_stay
180
- activate_tagged_door(ld, stay_open: true)
181
- when :door
182
- activate_tagged_door(ld)
183
- when :lift
184
- activate_lift(ld)
185
- when :raise_floor
186
- raise_floor_to_next(ld)
187
- when :lower_floor
188
- lower_floor_to_highest(ld)
189
- when :teleport
190
- teleport_player(ld)
191
- end
211
+ def fire_walk_action(action, ld, pid)
212
+ case action
213
+ when :exit
214
+ @exit_triggered = :normal
215
+ when :secret_exit
216
+ @exit_triggered = :secret
217
+ when :door_open_stay
218
+ activate_tagged_door(ld, stay_open: true)
219
+ when :door
220
+ activate_tagged_door(ld)
221
+ when :lift
222
+ activate_lift(ld)
223
+ when :raise_floor
224
+ raise_floor_to_lowest_ceiling(ld)
225
+ when :lower_floor
226
+ lower_floor_to_highest(ld)
227
+ when :teleport
228
+ teleport_player(ld, pid)
192
229
  end
193
230
  end
194
231
 
195
- # Returns which side of a line a point is on (:front or :back)
232
+ # Returns which side of a line a point is on, as an integer (1 = front,
233
+ # 0 = back). An integer rather than a symbol so the value serializes
234
+ # cleanly into the snapshot's per-player side memory.
196
235
  def line_side(px, py, x1, y1, x2, y2)
197
- cross = (x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)
198
- cross >= 0 ? :front : :back
236
+ cross = ((x2 - x1) * (py - y1)) - ((y2 - y1) * (px - x1))
237
+ cross >= 0 ? 1 : 0
199
238
  end
200
239
 
201
- def point_line_dist(px, py, x1, y1, x2, y2)
202
- dx = x2 - x1; dy = y2 - y1
203
- len_sq = dx * dx + dy * dy
204
- return Math.sqrt((px - x1) ** 2 + (py - y1) ** 2) if len_sq == 0
205
- t = ((px - x1) * dx + (py - y1) * dy).to_f / len_sq
206
- t = [[t, 0.0].max, 1.0].min
207
- cx = x1 + t * dx; cy = y1 + t * dy
208
- Math.sqrt((px - cx) ** 2 + (py - cy) ** 2)
209
- end
240
+ def activate_door(linedef, stay_open: false, key: nil, keys: {})
241
+ # Locked doors refuse to open without the matching key. Refusal changes
242
+ # no state (deterministic), and plays the "no way" grunt if a sound
243
+ # engine is attached.
244
+ if key && !keys[key]
245
+ @sound&.noway
246
+ return
247
+ end
210
248
 
211
- def activate_door(linedef, stay_open: false, key: nil)
212
249
  # Find the sector on the back side of the linedef
213
250
  return unless linedef.two_sided?
214
251
 
@@ -224,9 +261,7 @@ module Doom
224
261
  if @active_doors[sector_idx]
225
262
  door = @active_doors[sector_idx]
226
263
  # If closing, reverse direction
227
- if door[:state] == DOOR_CLOSING
228
- door[:state] = DOOR_OPENING
229
- end
264
+ door[:state] = DOOR_OPENING if door[:state] == DOOR_CLOSING
230
265
  return
231
266
  end
232
267
 
@@ -302,9 +337,7 @@ module Doom
302
337
  adjacent_sector = @map.sectors[right_sidedef.sector]
303
338
  end
304
339
 
305
- if adjacent_sector
306
- lowest = [lowest, adjacent_sector.ceiling_height].min
307
- end
340
+ lowest = [lowest, adjacent_sector.ceiling_height].min if adjacent_sector
308
341
  end
309
342
 
310
343
  lowest == Float::INFINITY ? 128 : lowest
@@ -326,7 +359,7 @@ module Doom
326
359
  target_height: target,
327
360
  original_height: sector.ceiling_height,
328
361
  wait_tics: 0,
329
- stay_open: stay_open,
362
+ stay_open: stay_open
330
363
  }
331
364
  end
332
365
  @sound&.door_open
@@ -340,7 +373,7 @@ module Doom
340
373
  activated = false
341
374
  @map.sectors.each_with_index do |sector, idx|
342
375
  next unless sector_has_tag?(idx, tag)
343
- next if @active_lifts[idx] # Already moving
376
+ next if @active_lifts[idx] # Already moving
344
377
 
345
378
  lowest = find_lowest_floor_around(idx)
346
379
  @active_lifts[idx] = {
@@ -348,7 +381,7 @@ module Doom
348
381
  state: :lowering,
349
382
  target_low: lowest,
350
383
  original_height: sector.floor_height,
351
- wait_tics: 0,
384
+ wait_tics: 0
352
385
  }
353
386
  activated = true
354
387
  end
@@ -379,6 +412,13 @@ module Doom
379
412
  @active_lifts.delete(idx)
380
413
  @sound&.platform_stop
381
414
  end
415
+ when :floor_lowering
416
+ # One-way floor lower (e.g. special 23): lower to target and stay.
417
+ lift[:sector].floor_height -= LIFT_SPEED
418
+ if lift[:sector].floor_height <= lift[:target_low]
419
+ lift[:sector].floor_height = lift[:target_low]
420
+ @active_lifts.delete(idx)
421
+ end
382
422
  end
383
423
  end
384
424
  end
@@ -389,6 +429,7 @@ module Doom
389
429
 
390
430
  @map.sectors.each_with_index do |sector, idx|
391
431
  next unless sector_has_tag?(idx, tag)
432
+
392
433
  target = find_next_higher_floor(idx)
393
434
  next if target <= sector.floor_height
394
435
 
@@ -397,7 +438,30 @@ module Doom
397
438
  state: :raising,
398
439
  target_low: sector.floor_height,
399
440
  original_height: target,
400
- wait_tics: 0,
441
+ wait_tics: 0
442
+ }
443
+ end
444
+ end
445
+
446
+ # Specials 5 (W1) and 91 (WR): DOOM's raiseFloor mover raises the floor
447
+ # to the lowest ceiling among surrounding sectors. Both specials use the
448
+ # same mover in vanilla, so they are not split here.
449
+ def raise_floor_to_lowest_ceiling(linedef)
450
+ tag = linedef.tag
451
+ return if tag == 0
452
+
453
+ @map.sectors.each_with_index do |sector, idx|
454
+ next unless sector_has_tag?(idx, tag)
455
+
456
+ target = find_lowest_ceiling_around(idx)
457
+ next if target <= sector.floor_height
458
+
459
+ @active_lifts[idx] = {
460
+ sector: sector,
461
+ state: :raising,
462
+ target_low: sector.floor_height,
463
+ original_height: target,
464
+ wait_tics: 0
401
465
  }
402
466
  end
403
467
  end
@@ -408,8 +472,19 @@ module Doom
408
472
 
409
473
  @map.sectors.each_with_index do |sector, idx|
410
474
  next unless sector_has_tag?(idx, tag)
475
+ next if @active_lifts[idx] # Already moving
476
+
411
477
  target = find_lowest_floor_around(idx)
412
- sector.floor_height = target
478
+ next if target >= sector.floor_height
479
+
480
+ # Animate the lower (matching the raise movers) instead of snapping.
481
+ @active_lifts[idx] = {
482
+ sector: sector,
483
+ state: :floor_lowering,
484
+ target_low: target,
485
+ original_height: sector.floor_height,
486
+ wait_tics: 0
487
+ }
413
488
  end
414
489
  end
415
490
 
@@ -419,29 +494,31 @@ module Doom
419
494
 
420
495
  @map.sectors.each_with_index do |sector, idx|
421
496
  next unless sector_has_tag?(idx, tag)
497
+
422
498
  target = find_highest_floor_around(idx) - 8
423
499
  sector.floor_height = target if target < sector.floor_height
424
500
  end
425
501
  end
426
502
 
427
- def teleport_player(linedef)
503
+ def teleport_player(linedef, player_id)
428
504
  tag = linedef.tag
429
505
  return if tag == 0
430
506
 
431
507
  # Find teleport destination thing (type 14) in tagged sector
432
508
  @map.things.each do |thing|
433
- next unless thing.type == 14 # Teleport destination
509
+ next unless thing.type == 14 # Teleport destination
510
+
434
511
  sector = @map.sector_at(thing.x, thing.y)
435
512
  next unless sector
513
+
436
514
  sector_idx = @map.sectors.index(sector)
437
515
  next unless sector_has_tag?(sector_idx, tag)
438
516
 
439
- @teleport_dest = { x: thing.x, y: thing.y, angle: thing.angle }
517
+ @teleport_dests[player_id] = { x: thing.x, y: thing.y, angle: thing.angle }
440
518
  return
441
519
  end
442
520
  end
443
521
 
444
-
445
522
  def check_secrets
446
523
  # Build set of secret sector indices on first call
447
524
  @secret_sectors ||= Set.new(
@@ -465,12 +542,12 @@ module Doom
465
542
  sector_idx = @map.sidedefs[sd_idx].sector
466
543
  return if @secrets_found[sector_idx]
467
544
 
468
- if @secret_sectors.include?(sector_idx)
469
- @secrets_found[sector_idx] = true
470
- # Clear the special so it doesn't retrigger (matching Chocolate Doom)
471
- @map.sectors[sector_idx].special = 0
472
- @secret_sectors.delete(sector_idx)
473
- end
545
+ return unless @secret_sectors.include?(sector_idx)
546
+
547
+ @secrets_found[sector_idx] = true
548
+ # Clear the special so it doesn't retrigger (matching Chocolate Doom)
549
+ @map.sectors[sector_idx].special = 0
550
+ @secret_sectors.delete(sector_idx)
474
551
  end
475
552
 
476
553
  def sector_has_tag?(sector_idx, tag)
@@ -486,20 +563,18 @@ module Doom
486
563
  end
487
564
 
488
565
  def find_highest_floor_around(sector_idx)
489
- highest = -32768
566
+ highest = -32_768
490
567
  each_adjacent_sector(sector_idx) do |adj|
491
568
  highest = adj.floor_height if adj.floor_height > highest
492
569
  end
493
- highest == -32768 ? @map.sectors[sector_idx].floor_height : highest
570
+ highest == -32_768 ? @map.sectors[sector_idx].floor_height : highest
494
571
  end
495
572
 
496
573
  def find_next_higher_floor(sector_idx)
497
574
  current = @map.sectors[sector_idx].floor_height
498
575
  best = Float::INFINITY
499
576
  each_adjacent_sector(sector_idx) do |adj|
500
- if adj.floor_height > current && adj.floor_height < best
501
- best = adj.floor_height
502
- end
577
+ best = adj.floor_height if adj.floor_height > current && adj.floor_height < best
503
578
  end
504
579
  best == Float::INFINITY ? current : best
505
580
  end
@@ -507,6 +582,7 @@ module Doom
507
582
  def each_adjacent_sector(sector_idx)
508
583
  @map.linedefs.each do |ld|
509
584
  next unless ld.two_sided?
585
+
510
586
  right = @map.sidedefs[ld.sidedef_right]
511
587
  left = @map.sidedefs[ld.sidedef_left] if ld.sidedef_left != 0xFFFF
512
588
  next unless left
@@ -10,8 +10,9 @@ module Doom
10
10
  FASTDARK = 15 # Dark duration for fast strobe (tics)
11
11
  SLOWDARK = 35 # Dark duration for slow strobe (tics)
12
12
 
13
- def initialize(map)
13
+ def initialize(map, random: Random.new)
14
14
  @map = map
15
+ @random = random
15
16
  @effects = []
16
17
  @scroll_sides = []
17
18
  spawn_specials
@@ -29,27 +30,28 @@ module Doom
29
30
  @map.sectors.each do |sector|
30
31
  case sector.special
31
32
  when 1 # Flickering lights
32
- @effects << LightFlash.new(sector, find_min_light(sector))
33
+ @effects << LightFlash.new(sector, find_min_light(sector), @random)
33
34
  when 2 # Fast strobe
34
- @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, false)
35
+ @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, false, @random)
35
36
  when 3 # Slow strobe
36
- @effects << StrobeFlash.new(sector, find_min_light(sector), SLOWDARK, false)
37
+ @effects << StrobeFlash.new(sector, find_min_light(sector), SLOWDARK, false, @random)
37
38
  when 4 # Fast strobe + 20% damage
38
- @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, false)
39
+ @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, false, @random)
39
40
  when 8 # Glowing light
40
41
  @effects << Glow.new(sector, find_min_light(sector))
41
42
  when 12 # Sync strobe slow
42
- @effects << StrobeFlash.new(sector, find_min_light(sector), SLOWDARK, true)
43
+ @effects << StrobeFlash.new(sector, find_min_light(sector), SLOWDARK, true, @random)
43
44
  when 13 # Sync strobe fast
44
- @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, true)
45
+ @effects << StrobeFlash.new(sector, find_min_light(sector), FASTDARK, true, @random)
45
46
  when 17 # Fire flicker
46
- @effects << FireFlicker.new(sector, find_min_light(sector))
47
+ @effects << FireFlicker.new(sector, find_min_light(sector), @random)
47
48
  end
48
49
  end
49
50
 
50
51
  # Linedef type 48: scrolling wall (front side scrolls +1 unit/tic)
51
52
  @map.linedefs.each do |linedef|
52
53
  next unless linedef.special == 48
54
+
53
55
  side = @map.sidedefs[linedef.sidedef_right]
54
56
  @scroll_sides << side if side
55
57
  end
@@ -64,8 +66,10 @@ module Doom
64
66
  @map.linedefs.each do |ld|
65
67
  right = @map.sidedefs[ld.sidedef_right]
66
68
  next unless right
69
+
67
70
  left_idx = ld.sidedef_left
68
71
  next if left_idx >= 0xFFFF
72
+
69
73
  left = @map.sidedefs[left_idx]
70
74
  next unless left
71
75
 
@@ -82,11 +86,12 @@ module Doom
82
86
 
83
87
  # T_LightFlash (type 1): mostly bright with brief random dark flickers
84
88
  class LightFlash
85
- def initialize(sector, minlight)
89
+ def initialize(sector, minlight, random)
86
90
  @sector = sector
91
+ @random = random
87
92
  @maxlight = sector.light_level
88
93
  @minlight = minlight
89
- @count = (rand(65)) + 1
94
+ @count = @random.rand(65) + 1
90
95
  end
91
96
 
92
97
  def update
@@ -95,24 +100,25 @@ module Doom
95
100
 
96
101
  if @sector.light_level == @maxlight
97
102
  @sector.light_level = @minlight
98
- @count = (rand(8)) + 1 # dark for 1-8 tics
103
+ @count = @random.rand(8) + 1 # dark for 1-8 tics
99
104
  else
100
105
  @sector.light_level = @maxlight
101
- @count = (rand(2) == 0 ? 1 : 65) # bright for 1 or 65 tics (P_Random()&64)
106
+ @count = (@random.rand(2) == 0 ? 1 : 65) # bright for 1 or 65 tics (P_Random()&64)
102
107
  end
103
108
  end
104
109
  end
105
110
 
106
111
  # T_StrobeFlash (types 2, 3, 4, 12, 13): regular strobe blink
107
112
  class StrobeFlash
108
- def initialize(sector, minlight, darktime, in_sync)
113
+ def initialize(sector, minlight, darktime, in_sync, random)
109
114
  @sector = sector
115
+ @random = random
110
116
  @maxlight = sector.light_level
111
117
  @minlight = minlight
112
118
  @minlight = 0 if @minlight == @maxlight
113
119
  @darktime = darktime
114
120
  @brighttime = STROBEBRIGHT
115
- @count = in_sync ? 1 : (rand(8)) + 1
121
+ @count = in_sync ? 1 : @random.rand(8) + 1
116
122
  end
117
123
 
118
124
  def update
@@ -135,7 +141,7 @@ module Doom
135
141
  @sector = sector
136
142
  @maxlight = sector.light_level
137
143
  @minlight = minlight
138
- @direction = -1 # start dimming
144
+ @direction = -1 # start dimming
139
145
  end
140
146
 
141
147
  def update
@@ -157,10 +163,11 @@ module Doom
157
163
 
158
164
  # T_FireFlicker (type 17): random fire-like flickering
159
165
  class FireFlicker
160
- def initialize(sector, minlight)
166
+ def initialize(sector, minlight, random)
161
167
  @sector = sector
162
168
  @maxlight = sector.light_level
163
- @minlight = minlight + 16 # fire doesn't go as dark
169
+ @minlight = minlight + 16 # fire doesn't go as dark
170
+ @random = random
164
171
  @count = 4
165
172
  end
166
173
 
@@ -168,7 +175,7 @@ module Doom
168
175
  @count -= 1
169
176
  return if @count > 0
170
177
 
171
- amount = (rand(4)) * 16 # 0, 16, 32, or 48
178
+ amount = @random.rand(4) * 16 # 0, 16, 32, or 48
172
179
  level = @maxlight - amount
173
180
  @sector.light_level = level < @minlight ? @minlight : level
174
181
  @count = 4