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
@@ -1,64 +1,251 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'set'
4
+
3
5
  module Doom
4
6
  module Game
5
7
  # Manages animated sector actions (doors, lifts, etc.)
6
8
  class SectorActions
7
- # Door states
8
- 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).
9
11
  DOOR_OPENING = 1
10
12
  DOOR_OPEN = 2
11
13
  DOOR_CLOSING = 3
12
14
 
13
15
  # Door speeds (units per tic, 35 tics/sec)
14
16
  DOOR_SPEED = 2
15
- DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
16
- PLAYER_HEIGHT = 56 # Player height for door collision
17
+ DOOR_WAIT = 150 # Tics to wait when open (~4 seconds)
18
+
19
+ # Lift constants
20
+ LIFT_SPEED = 4
21
+ LIFT_WAIT = 105 # ~3 seconds
22
+
23
+ attr_reader :exit_triggered, :secrets_found
24
+
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
33
+ end
17
34
 
18
- def initialize(map)
35
+ def initialize(map, sound_engine = nil)
19
36
  @map = map
20
- @active_doors = {} # sector_index => door_state
37
+ @sound = sound_engine
38
+ @active_doors = {} # sector_index => door_state
39
+ @active_lifts = {} # sector_index => lift_state
21
40
  @player_x = 0
22
41
  @player_y = 0
42
+ @player_positions = [] # [[player_id, x, y], ...] in id order
43
+ @exit_triggered = nil
44
+ @secrets_found = {} # sector_index => true
45
+ @crossed_linedefs = {}
46
+ @near_linedefs = {} # player_id => { linedef_idx => side }
47
+ @teleport_dests = {} # player_id => { x:, y:, angle: }
23
48
  end
24
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.
25
52
  def update_player_position(x, y)
26
53
  @player_x = x
27
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]
28
68
  end
29
69
 
30
70
  def update
31
71
  update_doors
72
+ update_lifts
73
+ check_walk_triggers
74
+ check_secrets
32
75
  end
33
76
 
34
- # Try to use a linedef (called when player presses use key)
35
- 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 = {})
36
81
  return false if linedef.special == 0
37
82
 
38
83
  case linedef.special
39
- when 1 # DR Door Open Wait Close
84
+ # --- Doors ---
85
+ when 1 # DR Door Open Wait Close
40
86
  activate_door(linedef)
41
- true
42
- when 31 # D1 Door Open Stay
87
+ when 26 # DR Blue Door
88
+ activate_door(linedef, key: :blue_card, keys: keys)
89
+ when 27 # DR Yellow Door
90
+ activate_door(linedef, key: :yellow_card, keys: keys)
91
+ when 28 # DR Red Door
92
+ activate_door(linedef, key: :red_card, keys: keys)
93
+ when 31 # D1 Door Open Stay
43
94
  activate_door(linedef, stay_open: true)
44
- true
45
- when 26 # DR Blue Door
46
- activate_door(linedef, key: :blue_card)
47
- true
48
- when 27 # DR Yellow Door
49
- activate_door(linedef, key: :yellow_card)
50
- true
51
- when 28 # DR Red Door
52
- activate_door(linedef, key: :red_card)
53
- true
95
+ when 32 # D1 Blue Door Open Stay
96
+ activate_door(linedef, key: :blue_card, stay_open: true, keys: keys)
97
+ when 33 # D1 Red Door Open Stay
98
+ activate_door(linedef, key: :red_card, stay_open: true, keys: keys)
99
+ when 34 # D1 Yellow Door Open Stay
100
+ activate_door(linedef, key: :yellow_card, stay_open: true, keys: keys)
101
+ when 103 # S1 Door Open Wait Close (tagged)
102
+ activate_tagged_door(linedef)
103
+
104
+ # --- Lifts ---
105
+ when 62 # SR Lift Lower Wait Raise (repeatable)
106
+ activate_lift(linedef)
107
+
108
+ # --- Floor changes ---
109
+ when 18 # S1 Raise Floor to Next Higher
110
+ raise_floor_to_next(linedef)
111
+ when 20 # S1 Raise Floor to Next Higher (platform)
112
+ raise_floor_to_next(linedef)
113
+ when 22 # W1 Raise Floor to Next Higher
114
+ raise_floor_to_next(linedef)
115
+ when 23 # S1 Lower Floor to Lowest
116
+ lower_floor_to_lowest(linedef)
117
+ when 36 # S1 Lower Floor to Highest Adjacent - 8
118
+ lower_floor_to_highest(linedef)
119
+ when 70 # SR Lower Floor to Highest Adjacent - 8
120
+ lower_floor_to_highest(linedef)
121
+
122
+ # --- Exits ---
123
+ when 11 # S1 Exit
124
+ @exit_triggered = :normal
125
+ when 51 # S1 Secret Exit
126
+ @exit_triggered = :secret
127
+
54
128
  else
55
- false
129
+ return false
56
130
  end
131
+ true
57
132
  end
58
133
 
59
134
  private
60
135
 
61
- def activate_door(linedef, stay_open: false, key: nil)
136
+ # Walk-over trigger types:
137
+ # W1 = once, WR = repeatable
138
+ # Note: stair builders (specials 7 and 8) are not modeled, so they are
139
+ # intentionally absent here.
140
+ WALK_TRIGGERS = {
141
+ 2 => :door_open_stay, # W1 Door Open Stay
142
+ 5 => :raise_floor, # W1 Raise Floor to Lowest Ceiling
143
+ 52 => :exit, # W1 Exit
144
+ 82 => :lower_floor, # WR Lower Floor to Lowest
145
+ 86 => :door_open_stay, # WR Door Open Stay
146
+ 88 => :lift, # WR Lift Lower Wait Raise
147
+ 90 => :door, # WR Door Open Wait Close
148
+ 91 => :raise_floor, # WR Raise Floor to Lowest Ceiling
149
+ 97 => :teleport, # WR Teleport
150
+ 98 => :lower_floor, # WR Lower Floor to Highest - 8
151
+ 124 => :secret_exit # W1 Secret Exit
152
+ }.freeze
153
+
154
+ # W1 types that only trigger once
155
+ W1_TYPES = [2, 5, 52, 124].freeze
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).
161
+ def check_walk_triggers
162
+ @near_linedefs ||= {}
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)
171
+ @map.linedefs.each_with_index do |ld, idx|
172
+ next if ld.special == 0
173
+
174
+ action = WALK_TRIGGERS[ld.special]
175
+ next unless action
176
+
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]
180
+
181
+ v1 = @map.vertices[ld.v1]
182
+ v2 = @map.vertices[ld.v2]
183
+
184
+ # Determine which side of the linedef the player is on
185
+ # DOOM's P_CrossSpecialLine fires when the player transitions sides
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)
188
+
189
+ near = dist < 32 # Detection range
190
+ prev_side = sides[idx]
191
+
192
+ if near && prev_side && prev_side != side
193
+ # Player crossed the line - trigger!
194
+ sides[idx] = side
195
+ elsif near && prev_side.nil?
196
+ # First time near - record side but don't trigger yet
197
+ sides[idx] = side
198
+ next
199
+ elsif !near
200
+ sides[idx] = nil
201
+ next
202
+ else
203
+ next # Same side, no crossing
204
+ end
205
+
206
+ @crossed_linedefs[idx] = true
207
+ fire_walk_action(action, ld, pid)
208
+ end
209
+ end
210
+
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)
229
+ end
230
+ end
231
+
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.
235
+ def line_side(px, py, x1, y1, x2, y2)
236
+ cross = ((x2 - x1) * (py - y1)) - ((y2 - y1) * (px - x1))
237
+ cross >= 0 ? 1 : 0
238
+ end
239
+
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
248
+
62
249
  # Find the sector on the back side of the linedef
63
250
  return unless linedef.two_sided?
64
251
 
@@ -74,9 +261,7 @@ module Doom
74
261
  if @active_doors[sector_idx]
75
262
  door = @active_doors[sector_idx]
76
263
  # If closing, reverse direction
77
- if door[:state] == DOOR_CLOSING
78
- door[:state] = DOOR_OPENING
79
- end
264
+ door[:state] = DOOR_OPENING if door[:state] == DOOR_CLOSING
80
265
  return
81
266
  end
82
267
 
@@ -92,6 +277,7 @@ module Doom
92
277
  wait_tics: 0,
93
278
  stay_open: stay_open
94
279
  }
280
+ @sound&.door_open
95
281
  end
96
282
 
97
283
  def update_doors
@@ -113,6 +299,7 @@ module Doom
113
299
  door[:wait_tics] -= 1
114
300
  if door[:wait_tics] <= 0
115
301
  door[:state] = DOOR_CLOSING
302
+ @sound&.door_close
116
303
  end
117
304
 
118
305
  when DOOR_CLOSING
@@ -150,13 +337,263 @@ module Doom
150
337
  adjacent_sector = @map.sectors[right_sidedef.sector]
151
338
  end
152
339
 
153
- if adjacent_sector
154
- lowest = [lowest, adjacent_sector.ceiling_height].min
155
- end
340
+ lowest = [lowest, adjacent_sector.ceiling_height].min if adjacent_sector
156
341
  end
157
342
 
158
343
  lowest == Float::INFINITY ? 128 : lowest
159
344
  end
345
+
346
+ # Door activated by tag (for S1/W1/WR tagged doors)
347
+ def activate_tagged_door(linedef, stay_open: false)
348
+ tag = linedef.tag
349
+ return if tag == 0
350
+
351
+ @map.sectors.each_with_index do |sector, idx|
352
+ next unless sector_has_tag?(idx, tag)
353
+ next if @active_doors[idx]
354
+
355
+ target = find_lowest_ceiling_around(idx) - 4
356
+ @active_doors[idx] = {
357
+ sector: sector,
358
+ state: DOOR_OPENING,
359
+ target_height: target,
360
+ original_height: sector.ceiling_height,
361
+ wait_tics: 0,
362
+ stay_open: stay_open
363
+ }
364
+ end
365
+ @sound&.door_open
366
+ end
367
+
368
+ # Lift: lower floor to lowest adjacent, wait, raise back
369
+ def activate_lift(linedef)
370
+ tag = linedef.tag
371
+ return if tag == 0
372
+
373
+ activated = false
374
+ @map.sectors.each_with_index do |sector, idx|
375
+ next unless sector_has_tag?(idx, tag)
376
+ next if @active_lifts[idx] # Already moving
377
+
378
+ lowest = find_lowest_floor_around(idx)
379
+ @active_lifts[idx] = {
380
+ sector: sector,
381
+ state: :lowering,
382
+ target_low: lowest,
383
+ original_height: sector.floor_height,
384
+ wait_tics: 0
385
+ }
386
+ activated = true
387
+ end
388
+ @sound&.platform_start if activated
389
+ end
390
+
391
+ def update_lifts
392
+ @active_lifts.each do |idx, lift|
393
+ case lift[:state]
394
+ when :lowering
395
+ lift[:sector].floor_height -= LIFT_SPEED
396
+ if lift[:sector].floor_height <= lift[:target_low]
397
+ lift[:sector].floor_height = lift[:target_low]
398
+ lift[:state] = :waiting
399
+ lift[:wait_tics] = LIFT_WAIT
400
+ @sound&.platform_stop
401
+ end
402
+ when :waiting
403
+ lift[:wait_tics] -= 1
404
+ if lift[:wait_tics] <= 0
405
+ lift[:state] = :raising
406
+ @sound&.platform_start
407
+ end
408
+ when :raising
409
+ lift[:sector].floor_height += LIFT_SPEED
410
+ if lift[:sector].floor_height >= lift[:original_height]
411
+ lift[:sector].floor_height = lift[:original_height]
412
+ @active_lifts.delete(idx)
413
+ @sound&.platform_stop
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
422
+ end
423
+ end
424
+ end
425
+
426
+ def raise_floor_to_next(linedef)
427
+ tag = linedef.tag
428
+ return if tag == 0
429
+
430
+ @map.sectors.each_with_index do |sector, idx|
431
+ next unless sector_has_tag?(idx, tag)
432
+
433
+ target = find_next_higher_floor(idx)
434
+ next if target <= sector.floor_height
435
+
436
+ @active_lifts[idx] = {
437
+ sector: sector,
438
+ state: :raising,
439
+ target_low: sector.floor_height,
440
+ original_height: target,
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
465
+ }
466
+ end
467
+ end
468
+
469
+ def lower_floor_to_lowest(linedef)
470
+ tag = linedef.tag
471
+ return if tag == 0
472
+
473
+ @map.sectors.each_with_index do |sector, idx|
474
+ next unless sector_has_tag?(idx, tag)
475
+ next if @active_lifts[idx] # Already moving
476
+
477
+ target = find_lowest_floor_around(idx)
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
+ }
488
+ end
489
+ end
490
+
491
+ def lower_floor_to_highest(linedef)
492
+ tag = linedef.tag
493
+ return if tag == 0
494
+
495
+ @map.sectors.each_with_index do |sector, idx|
496
+ next unless sector_has_tag?(idx, tag)
497
+
498
+ target = find_highest_floor_around(idx) - 8
499
+ sector.floor_height = target if target < sector.floor_height
500
+ end
501
+ end
502
+
503
+ def teleport_player(linedef, player_id)
504
+ tag = linedef.tag
505
+ return if tag == 0
506
+
507
+ # Find teleport destination thing (type 14) in tagged sector
508
+ @map.things.each do |thing|
509
+ next unless thing.type == 14 # Teleport destination
510
+
511
+ sector = @map.sector_at(thing.x, thing.y)
512
+ next unless sector
513
+
514
+ sector_idx = @map.sectors.index(sector)
515
+ next unless sector_has_tag?(sector_idx, tag)
516
+
517
+ @teleport_dests[player_id] = { x: thing.x, y: thing.y, angle: thing.angle }
518
+ return
519
+ end
520
+ end
521
+
522
+ def check_secrets
523
+ # Build set of secret sector indices on first call
524
+ @secret_sectors ||= Set.new(
525
+ @map.sectors.each_with_index.filter_map { |s, i| i if s.special == 9 }
526
+ )
527
+ return if @secret_sectors.empty?
528
+
529
+ # Find which sector the player is in via BSP subsector lookup
530
+ subsector = @map.subsector_at(@player_x, @player_y)
531
+ return unless subsector
532
+
533
+ seg = @map.segs[subsector.first_seg]
534
+ return unless seg
535
+
536
+ ld = @map.linedefs[seg.linedef]
537
+ return unless ld
538
+
539
+ sd_idx = seg.direction == 0 ? ld.sidedef_right : ld.sidedef_left
540
+ return if sd_idx == 0xFFFF
541
+
542
+ sector_idx = @map.sidedefs[sd_idx].sector
543
+ return if @secrets_found[sector_idx]
544
+
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)
551
+ end
552
+
553
+ def sector_has_tag?(sector_idx, tag)
554
+ @map.sectors[sector_idx].tag == tag
555
+ end
556
+
557
+ def find_lowest_floor_around(sector_idx)
558
+ lowest = @map.sectors[sector_idx].floor_height
559
+ each_adjacent_sector(sector_idx) do |adj|
560
+ lowest = adj.floor_height if adj.floor_height < lowest
561
+ end
562
+ lowest
563
+ end
564
+
565
+ def find_highest_floor_around(sector_idx)
566
+ highest = -32_768
567
+ each_adjacent_sector(sector_idx) do |adj|
568
+ highest = adj.floor_height if adj.floor_height > highest
569
+ end
570
+ highest == -32_768 ? @map.sectors[sector_idx].floor_height : highest
571
+ end
572
+
573
+ def find_next_higher_floor(sector_idx)
574
+ current = @map.sectors[sector_idx].floor_height
575
+ best = Float::INFINITY
576
+ each_adjacent_sector(sector_idx) do |adj|
577
+ best = adj.floor_height if adj.floor_height > current && adj.floor_height < best
578
+ end
579
+ best == Float::INFINITY ? current : best
580
+ end
581
+
582
+ def each_adjacent_sector(sector_idx)
583
+ @map.linedefs.each do |ld|
584
+ next unless ld.two_sided?
585
+
586
+ right = @map.sidedefs[ld.sidedef_right]
587
+ left = @map.sidedefs[ld.sidedef_left] if ld.sidedef_left != 0xFFFF
588
+ next unless left
589
+
590
+ if right.sector == sector_idx
591
+ yield @map.sectors[left.sector]
592
+ elsif left.sector == sector_idx
593
+ yield @map.sectors[right.sector]
594
+ end
595
+ end
596
+ end
160
597
  end
161
598
  end
162
599
  end
@@ -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