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
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # The simulation, with no dependency on Gosu or any window.
6
+ #
7
+ # Everything that decides what happens in the game lives here and advances
8
+ # only through run_tic. The platform layer is reduced to two jobs: turn
9
+ # input into ticcmds, and draw the result. That split is what makes
10
+ # lockstep multiplayer possible -- the netcode drives run_tic directly,
11
+ # with no display attached, and two peers feeding equal ticcmds reach equal
12
+ # worlds.
13
+ #
14
+ # Sound is emitted but never read back, so it cannot influence state.
15
+ class World
16
+ TIC_SECONDS = 1.0 / 35.0
17
+ SECTOR_DAMAGE_INTERVAL = 32 # Tics between nukage/lava ticks
18
+ USE_DISTANCE = 64.0 # Max distance to use a linedef
19
+
20
+ # Sector specials that hurt the player, damage per interval.
21
+ SECTOR_DAMAGE = {
22
+ 4 => 20, 5 => 10, 7 => 5, 16 => 20, 11 => 20
23
+ }.freeze
24
+
25
+ MODES = %i[coop deathmatch].freeze
26
+
27
+ attr_reader :map, :players, :random, :leveltime, :mode, :combat, :monster_ai, :item_pickup, :sector_actions,
28
+ :sector_effects, :physics_by_id
29
+ # Frags needed to win, or nil for an open-ended match.
30
+ attr_reader :frag_limit
31
+ attr_accessor :damage_multiplier, :skill_hidden
32
+
33
+ def initialize(map, sprites:, sound: nil, random: Random.new, skill_hidden: {}, mode: :coop,
34
+ frag_limit: nil)
35
+ raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
36
+
37
+ @map = map
38
+ @sprites = sprites
39
+ @sound = sound
40
+ @random = random
41
+ @mode = mode
42
+ @frag_limit = frag_limit
43
+ @skill_hidden = skill_hidden
44
+ @damage_multiplier = 1.0
45
+ @leveltime = 0
46
+
47
+ @players = []
48
+ @physics_by_id = {}
49
+
50
+ @sector_actions = SectorActions.new(map, sound)
51
+ @sector_effects = SectorEffects.new(map, random: random)
52
+ @item_pickup = ItemPickup.new(map, skill_hidden)
53
+ @combat = Combat.new(map, sprites, skill_hidden, sound, random: random)
54
+ @monster_ai = MonsterAI.new(map, @combat, sprites, skill_hidden, sound, random: random)
55
+ end
56
+
57
+ def deathmatch?
58
+ @mode == :deathmatch
59
+ end
60
+
61
+ # Spawn a player. With no explicit start, the world picks one for this
62
+ # player id: its own co-op start in co-op, a random one in deathmatch.
63
+ def add_player(start_thing = nil, id: @players.size)
64
+ start_thing ||= spawn_point_for(id)
65
+ raise ArgumentError, 'map has no player start' unless start_thing
66
+
67
+ player = Player.new(id: id)
68
+ player.place(start_thing.x, start_thing.y, PlayerState::VIEWHEIGHT, start_thing.angle)
69
+
70
+ physics = PlayerPhysics.new(@map, player.state)
71
+ physics.skill_hidden = @skill_hidden
72
+ physics.item_pickup = @item_pickup
73
+ physics.combat = @combat
74
+ physics.settle(player, player.x, player.y)
75
+
76
+ @players << player
77
+ @physics_by_id[player.id] = physics
78
+ @combat.players = @players
79
+ player
80
+ end
81
+
82
+ def player(id = 0)
83
+ @players.find { |p| p.id == id }
84
+ end
85
+
86
+ # Remove a player mid-match: someone left, or the server timed them out.
87
+ # Pure bookkeeping, no RNG, so it stays deterministic across peers as long
88
+ # as they all remove the same id on the same tic. Monsters targeting the
89
+ # departed player re-pick next tic via MonsterAI's own dead/absent check.
90
+ def remove_player(id)
91
+ player = @players.find { |p| p.id == id }
92
+ return unless player
93
+
94
+ @players.delete(player)
95
+ @physics_by_id.delete(id)
96
+ @combat.players = @players
97
+ @monster_ai.monsters.each { |m| m.target = nil if m.target&.id == id }
98
+ player
99
+ end
100
+
101
+ # Where player `id` enters the map. Deathmatch draws from the map's DM
102
+ # spawns through the shared RNG, so every peer picks the same one.
103
+ def spawn_point_for(id)
104
+ if @mode == :deathmatch
105
+ starts = @map.deathmatch_starts
106
+ return starts[@random.rand(starts.size)] unless starts.empty?
107
+ end
108
+
109
+ @map.player_start_for(id)
110
+ end
111
+
112
+ # Bring one player back to life at its spawn point, leaving the rest of
113
+ # the world alone. This is what multiplayer death does: the others are
114
+ # still playing, so monsters and items must not reset under them.
115
+ def respawn(player = primary)
116
+ return unless player
117
+
118
+ player.state.reset
119
+ start = spawn_point_for(player.id)
120
+ return unless start
121
+
122
+ physics = physics_for(player)
123
+ physics.reset
124
+ player.place(start.x, start.y, PlayerState::VIEWHEIGHT, start.angle)
125
+ physics.settle(player, player.x, player.y)
126
+ end
127
+
128
+ # Single-player death: wipe monster/item/projectile state and start the
129
+ # level over. Monsters are rebuilt rather than revived because their HP,
130
+ # death and pain state live in Combat keyed by thing index.
131
+ def restart_level(player = primary)
132
+ return unless player
133
+
134
+ rebuild_actor_subsystems
135
+ physics = physics_for(player)
136
+ physics.item_pickup = @item_pickup
137
+ physics.combat = @combat
138
+ respawn(player)
139
+ end
140
+
141
+ def physics_for(player)
142
+ @physics_by_id[player.id]
143
+ end
144
+
145
+ # Deathmatch score, player id => frags.
146
+ def frags
147
+ @players.to_h { |p| [p.id, p.frags] }
148
+ end
149
+
150
+ # Who is winning. Ties break on the lowest player id rather than on
151
+ # @players order, so every peer names the same leader.
152
+ def frag_leader
153
+ @players.min_by { |p| [-p.frags, p.id] }
154
+ end
155
+
156
+ def frag_limit_reached?
157
+ return false unless @frag_limit
158
+
159
+ @players.any? { |p| p.frags >= @frag_limit }
160
+ end
161
+
162
+ # The player who won, or nil while the match is still on.
163
+ #
164
+ # Note what this deliberately does not do: end run_tic. The frag limit is
165
+ # local configuration and, unlike the mode and the seed, it does not
166
+ # travel in the handshake, so a peer started with a different --frags
167
+ # would stop simulating at a different tic and desync every other peer.
168
+ # The world answers the question; the presentation layer says so on
169
+ # screen.
170
+ def match_winner
171
+ frag_limit_reached? ? frag_leader : nil
172
+ end
173
+
174
+ def exit_triggered
175
+ @sector_actions.exit_triggered
176
+ end
177
+
178
+ # Fingerprint of the whole simulation, for lockstep desync detection.
179
+ def state_hash
180
+ StateHash.of(self)
181
+ end
182
+
183
+ # Same fingerprint broken down by section, so a desync can say which part
184
+ # of the world drifted rather than only that something did.
185
+ def state_hash_sections
186
+ StateHash.sections(self)
187
+ end
188
+
189
+ # Advance the whole simulation by one tic.
190
+ #
191
+ # `cmds` maps player id => Ticcmd; a player with no command coasts on a
192
+ # neutral one rather than repeating its last input, which would make a
193
+ # dropped packet look like a stuck key.
194
+ def run_tic(cmds = {})
195
+ @leveltime += 1
196
+
197
+ @players.each(&:snapshot!)
198
+ @sector_effects.update
199
+
200
+ @players.each do |p|
201
+ run_player_tic(p, cmds[p.id] || Ticcmd.none)
202
+ end
203
+
204
+ @combat.update
205
+ @monster_ai.update(@players)
206
+
207
+ @players.each { |p| post_tic_player(p) }
208
+
209
+ update_sector_actions
210
+ update_item_pickups
211
+ @leveltime
212
+ end
213
+
214
+ def hidden_things
215
+ @skill_hidden.merge(@item_pickup.picked_up)
216
+ end
217
+
218
+ private
219
+
220
+ def rebuild_actor_subsystems
221
+ @item_pickup = ItemPickup.new(@map, @skill_hidden)
222
+ @combat = Combat.new(@map, @sprites, @skill_hidden, @sound, random: @random)
223
+ @monster_ai = MonsterAI.new(@map, @combat, @sprites, @skill_hidden, @sound, random: @random)
224
+ @monster_ai.damage_multiplier = @damage_multiplier
225
+
226
+ @players.each do |p|
227
+ physics = physics_for(p)
228
+ physics.item_pickup = @item_pickup
229
+ physics.combat = @combat
230
+ end
231
+ @combat.players = @players
232
+ end
233
+
234
+ def primary
235
+ @players.first
236
+ end
237
+
238
+ def run_player_tic(player, cmd)
239
+ state = player.state
240
+ physics = physics_for(player)
241
+
242
+ # Move first, so the bob reflects this tic's momentum. run_tic updates
243
+ # the player's velocity (thrust and friction); feed it to the state so
244
+ # the weapon and view bob come alive -- both read the state's momentum
245
+ # and is_moving, which the World-extraction refactor stopped supplying,
246
+ # freezing the bob. is_moving tracks input (as in DOOM), not coasting.
247
+ physics.run_tic(player, cmd)
248
+ state.set_movement_momentum(player.momx, player.momy)
249
+ state.is_moving = cmd.moving?
250
+
251
+ # View bob feeds eye height, which feeds firing height and monster aim,
252
+ # so it is simulation and runs on the tic clock; update_viewheight is the
253
+ # step-up/down recovery. step_physics then settles vertical position and
254
+ # writes player.z from the eye height, bob included.
255
+ state.update_bob(TIC_SECONDS)
256
+ state.update_view_bob(TIC_SECONDS)
257
+ state.update_viewheight
258
+ step_physics(player, physics)
259
+ state.update_attack
260
+
261
+ fire(player, cmd)
262
+ use(player, cmd)
263
+ end
264
+
265
+ def step_physics(player, physics)
266
+ return unless physics.floor_z
267
+
268
+ physics.step(player.x, player.y)
269
+ z = physics.eye_z
270
+ player.z = z if z
271
+ end
272
+
273
+ def fire(player, cmd)
274
+ return unless cmd.fire?
275
+
276
+ state = player.state
277
+ was_attacking = state.attacking
278
+ state.start_attack
279
+ return unless state.attacking && !was_attacking
280
+
281
+ @combat.fire(player.x, player.y, player.z,
282
+ player.cos_angle, player.sin_angle, state.weapon, player)
283
+ @sound&.weapon_fire(state.weapon)
284
+ end
285
+
286
+ # Use is edge-triggered per player, so holding the key cannot spam doors.
287
+ def use(player, cmd)
288
+ if cmd.use?
289
+ unless player.use_held
290
+ @sector_actions.update_player_position(player.x, player.y)
291
+ try_use(player)
292
+ end
293
+ player.use_held = true
294
+ else
295
+ player.use_held = false
296
+ end
297
+ end
298
+
299
+ # Cast forward for a usable linedef. Moved off the window because which
300
+ # door a player opens is simulation, and with several players each needs
301
+ # its own ray from its own position.
302
+ def try_use(player)
303
+ px = player.x
304
+ py = player.y
305
+ cos_angle = player.cos_angle
306
+ sin_angle = player.sin_angle
307
+
308
+ best_linedef = nil
309
+ best_idx = nil
310
+ best_dist = Float::INFINITY
311
+
312
+ @map.linedefs.each_with_index do |linedef, idx|
313
+ next if linedef.special == 0 # Skip non-special linedefs
314
+
315
+ v1 = @map.vertices[linedef.v1]
316
+ v2 = @map.vertices[linedef.v2]
317
+
318
+ dist = Geometry.point_to_segment_distance(px, py, v1.x, v1.y, v2.x, v2.y)
319
+ next if dist > USE_DISTANCE
320
+ next if dist >= best_dist
321
+ next unless facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
322
+
323
+ best_linedef = linedef
324
+ best_idx = idx
325
+ best_dist = dist
326
+ end
327
+
328
+ @sector_actions.use_linedef(best_linedef, best_idx, player.state.keys) if best_linedef
329
+ end
330
+
331
+ def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
332
+ line_dx = v2.x - v1.x
333
+ line_dy = v2.y - v1.y
334
+
335
+ # Normal points to the right of the line direction
336
+ normal_x = -line_dy
337
+ normal_y = line_dx
338
+
339
+ len = Math.sqrt((normal_x * normal_x) + (normal_y * normal_y))
340
+ return false if len == 0
341
+
342
+ normal_x /= len
343
+ normal_y /= len
344
+
345
+ # Flip the normal if the player is on the back side, so we always check
346
+ # facing toward the line.
347
+ side = ((px - v1.x) * normal_x) + ((py - v1.y) * normal_y)
348
+ if side < 0
349
+ normal_x = -normal_x
350
+ normal_y = -normal_y
351
+ end
352
+
353
+ dot_facing = (cos_angle * -normal_x) + (sin_angle * -normal_y)
354
+ dot_facing > 0.2 # ~78 degree cone, matching DOOM's generous use check
355
+ end
356
+
357
+ def post_tic_player(player)
358
+ state = player.state
359
+ health_before = @last_health ||= {}
360
+ before = health_before[player.id] || state.health
361
+
362
+ if state.health < before
363
+ state.dead ? @sound&.player_death : @sound&.player_pain
364
+ end
365
+
366
+ state.update_damage_count
367
+ state.death_tic += 1 if state.dead
368
+
369
+ apply_sector_damage(player) if !state.dead && (@leveltime % SECTOR_DAMAGE_INTERVAL).zero?
370
+
371
+ # Record the health baseline last, after sector damage, so this tic's
372
+ # nukage/lava hit -- which apply_sector_damage already sounded -- is not
373
+ # re-detected as a fresh drop next tic and sounded a second time.
374
+ health_before[player.id] = state.health
375
+ end
376
+
377
+ def apply_sector_damage(player)
378
+ sector = @map.sector_at(player.x, player.y)
379
+ return unless sector
380
+
381
+ damage = SECTOR_DAMAGE[sector.special]
382
+ return unless damage
383
+
384
+ player.state.take_damage((damage * @damage_multiplier).to_i)
385
+ @sound&.player_pain
386
+ end
387
+
388
+ def update_sector_actions
389
+ # Feed every player's position, in a fixed id order, so walk triggers
390
+ # fire for each player and the tic stays reproducible across peers.
391
+ ordered = @players.sort_by(&:id)
392
+ @sector_actions.update_players(ordered.map { |p| [p.id, p.x, p.y] })
393
+ @sector_actions.update
394
+
395
+ # Each player teleports to its own destination, in id order.
396
+ @sector_actions.pop_teleports.sort_by { |id, _| id }.each do |id, dest|
397
+ p = player(id)
398
+ next unless p
399
+
400
+ physics = physics_for(p)
401
+ p.place(dest[:x], dest[:y], p.z, dest[:angle])
402
+ physics.reset
403
+ physics.settle(p, dest[:x], dest[:y])
404
+ end
405
+ end
406
+
407
+ def update_item_pickups
408
+ return if @players.empty?
409
+
410
+ picked_before = @item_pickup.picked_up.size
411
+ @players.each { |p| @item_pickup.update(p) unless p.state.dead }
412
+ return unless @sound && @item_pickup.picked_up.size > picked_before
413
+
414
+ msg = @item_pickup.pickup_message
415
+ # Weapon pickup messages end with '!' in DOOM's string table.
416
+ msg&.include?('!') ? @sound.weapon_pickup : @sound.item_pickup
417
+ end
418
+ end
419
+ end
420
+ end
data/lib/doom/map/data.rb CHANGED
@@ -105,6 +105,10 @@ module Doom
105
105
  map.load_nodes(wad.read_lump_at(wad.directory[lump_idx + 7]))
106
106
  map.load_sectors(wad.read_lump_at(wad.directory[lump_idx + 8]))
107
107
 
108
+ # BLOCKMAP is at lump +10 (REJECT is +9). Optional -- parse defensively.
109
+ blockmap_entry = wad.directory[lump_idx + 10]
110
+ map.load_blockmap(wad.read_lump_at(blockmap_entry)) if blockmap_entry && blockmap_entry.name == 'BLOCKMAP'
111
+
108
112
  map
109
113
  end
110
114
 
@@ -235,10 +239,101 @@ module Doom
235
239
  end
236
240
  end
237
241
 
242
+ # Thing types 1-4 are the co-op starts for players 1-4; type 11 is a
243
+ # deathmatch spawn point. Every stock WAD carries all of them -- until
244
+ # now only type 1 was ever read.
245
+ COOP_START_TYPES = (1..4).to_a.freeze
246
+ DEATHMATCH_START_TYPE = 11
247
+
238
248
  def player_start
239
249
  @things.find { |t| t.type == 1 }
240
250
  end
241
251
 
252
+ # Co-op starts indexed by player number, so player N spawns at start N.
253
+ # Sparse on maps that only define some: index 2 may be nil while 0 is not.
254
+ def player_starts
255
+ COOP_START_TYPES.map { |type| @things.find { |t| t.type == type } }
256
+ end
257
+
258
+ # Start for player `id` (0-based), falling back to player 1's start so a
259
+ # map without enough co-op starts is still playable.
260
+ def player_start_for(id)
261
+ player_starts[id] || player_start
262
+ end
263
+
264
+ def deathmatch_starts
265
+ @things.select { |t| t.type == DEATHMATCH_START_TYPE }
266
+ end
267
+
268
+ # BLOCKMAP: 128-unit grid index into linedefs. Each block lists the
269
+ # linedefs that touch it. Used for fast collision lookup.
270
+ BLOCKMAP_BLOCK_SIZE = 128
271
+
272
+ def load_blockmap(data)
273
+ return if data.nil? || data.size < 8
274
+
275
+ @blockmap_origin_x = data[0, 2].unpack1('s<')
276
+ @blockmap_origin_y = data[2, 2].unpack1('s<')
277
+ @blockmap_cols = data[4, 2].unpack1('s<')
278
+ @blockmap_rows = data[6, 2].unpack1('s<')
279
+ return if @blockmap_cols <= 0 || @blockmap_rows <= 0
280
+
281
+ block_count = @blockmap_cols * @blockmap_rows
282
+ @blockmap_blocks = Array.new(block_count)
283
+
284
+ block_count.times do |i|
285
+ offset_words = data[8 + (i * 2), 2].unpack1('v')
286
+ byte_offset = offset_words * 2
287
+ linedefs_in_block = []
288
+ ptr = byte_offset
289
+ # Skip the leading 0x0000 sentinel that some blockmaps include.
290
+ ptr += 2 if ptr + 2 <= data.size && data[ptr, 2].unpack1('s<') == 0
291
+ while ptr + 2 <= data.size
292
+ idx = data[ptr, 2].unpack1('s<')
293
+ break if idx == -1 # 0xFFFF terminator
294
+
295
+ linedefs_in_block << idx
296
+ ptr += 2
297
+ end
298
+ @blockmap_blocks[i] = linedefs_in_block
299
+ end
300
+ end
301
+
302
+ # Yield each linedef whose block overlaps the bounding box (min_x, min_y,
303
+ # max_x, max_y). Yields each linedef at most once per call. Falls back
304
+ # to iterating all linedefs if no blockmap is loaded.
305
+ def each_linedef_near(min_x, min_y, max_x, max_y, &)
306
+ unless @blockmap_blocks
307
+ @linedefs.each(&)
308
+ return
309
+ end
310
+
311
+ bx0 = ((min_x - @blockmap_origin_x) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_cols - 1)
312
+ bx1 = ((max_x - @blockmap_origin_x) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_cols - 1)
313
+ by0 = ((min_y - @blockmap_origin_y) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_rows - 1)
314
+ by1 = ((max_y - @blockmap_origin_y) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_rows - 1)
315
+
316
+ seen = {}
317
+ by0.upto(by1) do |by|
318
+ row_base = by * @blockmap_cols
319
+ bx0.upto(bx1) do |bx|
320
+ indices = @blockmap_blocks[row_base + bx]
321
+ next unless indices
322
+
323
+ indices.each do |idx|
324
+ next if seen[idx]
325
+
326
+ seen[idx] = true
327
+ yield @linedefs[idx]
328
+ end
329
+ end
330
+ end
331
+ end
332
+
333
+ def blockmap_loaded?
334
+ !@blockmap_blocks.nil?
335
+ end
336
+
242
337
  # Find the sector at a given position by traversing the BSP tree
243
338
  def sector_at(x, y)
244
339
  subsector = subsector_at(x, y)