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
@@ -19,11 +19,11 @@ module Doom
19
19
  Drawseg = Struct.new(:x1, :x2, :scale1, :scale2, :scalestep,
20
20
  :silhouette, :bsilheight, :tsilheight,
21
21
  :sprtopclip, :sprbottomclip,
22
- :curline, # seg for point-on-side test
23
- :maskedtexturecol, # per-column texture X for masked mid textures
24
- :frontsector, :backsector, # sectors for masked rendering
25
- :sidedef, # sidedef for texture lookup
26
- :dist1, :dist2, :sx1, :sx2) # for per-column distance recomputation
22
+ :curline, # seg for point-on-side test
23
+ :maskedtexturecol, # per-column texture X for masked mid textures
24
+ :frontsector, :backsector, # sectors for masked rendering
25
+ :sidedef, # sidedef for texture lookup
26
+ :dist1, :dist2, :sx1, :sx2) # for per-column distance recomputation
27
27
 
28
28
  # VisibleSprite stores sprite data for sorting and rendering
29
29
  # Struct is faster than Hash for fixed-field data
@@ -43,6 +43,7 @@ module Doom
43
43
 
44
44
  def mark(x, y1, y2)
45
45
  return if y1 > y2
46
+
46
47
  top[x] = [top[x], y1].min
47
48
  bottom[x] = [bottom[x], y2].max
48
49
  self.minx = [minx, x].min
@@ -55,21 +56,33 @@ module Doom
55
56
  end
56
57
 
57
58
  class Renderer
58
- attr_reader :framebuffer
59
-
60
59
  def initialize(wad, map, textures, palette, colormap, flats, sprites = nil, animations = nil)
61
60
  @wad = wad
62
61
  @map = map
63
62
  @textures = textures
64
63
  @palette = palette
65
64
  @colormap = colormap
65
+ @flats_array = flats
66
66
  @flats = flats.to_h { |f| [f.name, f] }
67
+ # Opt-in: route the wall/floor/ceiling rasterisation through the
68
+ # spinel_native-compiled port of this renderer's hot path. Everything
69
+ # else (sprites, weapon, HUD, physics, sound) stays the real game.
70
+ @native_kernel = !ENV["DOOM_NATIVE"].to_s.empty? && ENV["DOOM_NATIVE"] != "0"
71
+ if @native_kernel
72
+ warn "[doom] compiling spinel_native render kernel (first run may take a few seconds)..."
73
+ @sn = build_native_renderer
74
+ @native_kernel = !@sn.nil?
75
+ warn "[doom] spinel_native renderer ready" if @sn
76
+ end
67
77
  @sprites = sprites
68
78
  @animations = animations
69
79
  @hidden_things = nil
70
80
  @combat = nil
71
81
  @monster_ai = nil
82
+ @players = [] # Every player in the world, for drawing the others
83
+ @view_player = nil # The one we are looking through, never drawn
72
84
  @leveltime = 0
85
+ @skip_background_fill = false
73
86
 
74
87
  @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
75
88
 
@@ -77,6 +90,8 @@ module Doom
77
90
  @player_y = 0.0
78
91
  @player_z = 41.0
79
92
  @player_angle = 0.0
93
+ @sin_angle = 0.0
94
+ @cos_angle = 1.0
80
95
 
81
96
  # Projection constant - distance to projection plane
82
97
  @projection = HALF_WIDTH / Math.tan(FOV * Math::PI / 360.0)
@@ -89,13 +104,17 @@ module Doom
89
104
  # Column distance scale is constant (doesn't depend on player angle)
90
105
  @column_distscale = Array.new(SCREEN_WIDTH) do |x|
91
106
  dx = x - HALF_WIDTH
92
- Math.sqrt(dx * dx + @projection * @projection) / @projection
107
+ Math.sqrt((dx * dx) + (@projection * @projection)) / @projection
93
108
  end
94
109
 
95
110
  # Clipping arrays
96
111
  @ceiling_clip = Array.new(SCREEN_WIDTH, -1)
97
112
  @floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
98
113
 
114
+ # Per-sprite clip buffers, reset (not reallocated) for each draw_sprite call
115
+ @sprite_clipbot = Array.new(SCREEN_WIDTH, -2)
116
+ @sprite_cliptop = Array.new(SCREEN_WIDTH, -2)
117
+
99
118
  # Sprite clip arrays (copy of wall clips for sprite clipping)
100
119
  @sprite_ceiling_clip = Array.new(SCREEN_WIDTH, -1)
101
120
  @sprite_floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
@@ -108,9 +127,10 @@ module Doom
108
127
  @y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
109
128
  @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
110
129
  end
111
-
112
- attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle, :framebuffer
130
+ attr_reader :framebuffer, :player_x, :player_y, :player_z, :player_angle, :sin_angle, :cos_angle, :wad, :map,
131
+ :textures, :palette, :colormap, :flats, :sprites, :animations
113
132
  attr_writer :hidden_things, :combat, :monster_ai, :leveltime
133
+ attr_accessor :players, :view_player, :skip_background_fill
114
134
 
115
135
  # Diagnostic: returns info about all sprites and why they are/aren't visible
116
136
  def sprite_diagnostics
@@ -128,7 +148,7 @@ module Doom
128
148
  info[:view_y] = view_y.round(1)
129
149
 
130
150
  if view_y <= 0
131
- info[:status] = "behind_player"
151
+ info[:status] = 'behind_player'
132
152
  results << info
133
153
  next
134
154
  end
@@ -143,7 +163,7 @@ module Doom
143
163
  angle_to_thing = Math.atan2(dy, dx)
144
164
  sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
145
165
  unless sprite
146
- info[:status] = "no_sprite_frame"
166
+ info[:status] = 'no_sprite_frame'
147
167
  results << info
148
168
  next
149
169
  end
@@ -153,20 +173,19 @@ module Doom
153
173
  info[:sprite_scale] = sprite_scale.round(3)
154
174
 
155
175
  if screen_x + sprite_half_width < 0
156
- info[:status] = "off_screen_left"
176
+ info[:status] = 'off_screen_left'
157
177
  elsif screen_x - sprite_half_width >= SCREEN_WIDTH
158
- info[:status] = "off_screen_right"
178
+ info[:status] = 'off_screen_right'
159
179
  else
160
180
  # Check drawseg clipping
161
- sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
181
+ sprite_left = (screen_x - (sprite.left_offset * sprite_scale)).to_i
162
182
  sprite_right = sprite_left + (sprite.width * sprite_scale).to_i - 1
163
183
  x1 = [sprite_left, 0].max
164
184
  x2 = [sprite_right, SCREEN_WIDTH - 1].min
165
185
 
166
186
  sector = @map.sector_at(thing.x, thing.y)
167
187
  thing_floor = sector ? sector.floor_height : 0
168
- sprite_gz = thing_floor
169
- sprite_gzt = thing_floor + sprite.top_offset
188
+ thing_floor + sprite.top_offset
170
189
 
171
190
  clipping_segs = []
172
191
  @drawsegs.reverse_each do |ds|
@@ -192,7 +211,7 @@ module Doom
192
211
  info[:screen_range] = "#{x1}..#{x2}"
193
212
  info[:clipping_segs] = clipping_segs.size
194
213
  info[:clipping_detail] = clipping_segs
195
- info[:status] = "visible"
214
+ info[:status] = 'visible'
196
215
  end
197
216
 
198
217
  results << info
@@ -200,54 +219,61 @@ module Doom
200
219
  results
201
220
  end
202
221
 
203
- def set_player(x, y, z, angle)
204
- @player_x = x.to_f
205
- @player_y = y.to_f
206
- @player_z = z.to_f
207
- @player_angle = angle * Math::PI / 180.0
222
+ # Point the camera at a pose. This is the renderer's real input now that
223
+ # Game::Player owns the player: game logic mutates the player, and the
224
+ # camera is aimed once per frame at an interpolated pose. Angle is in
225
+ # radians here, unlike the older degree-based setters below.
226
+ def apply_view(x, y, z, angle_radians)
227
+ @player_x = x
228
+ @player_y = y
229
+ @player_z = z
230
+ @player_angle = angle_radians
231
+ @sin_angle = Math.sin(angle_radians)
232
+ @cos_angle = Math.cos(angle_radians)
208
233
  end
209
234
 
210
- def move_to(x, y)
235
+ def set_player(x, y, z, angle)
211
236
  @player_x = x.to_f
212
237
  @player_y = y.to_f
213
- end
214
-
215
- def set_z(z)
216
238
  @player_z = z.to_f
217
- end
218
-
219
- def turn(degrees)
220
- @player_angle += degrees * Math::PI / 180.0
239
+ @player_angle = angle * Math::PI / 180.0
240
+ @sin_angle = Math.sin(@player_angle)
241
+ @cos_angle = Math.cos(@player_angle)
221
242
  end
222
243
 
223
244
  def render_frame
224
- clear_framebuffer
225
- reset_clipping
245
+ if @native_kernel
246
+ native_wall_pass
247
+ @drawsegs = []
248
+ else
249
+ clear_framebuffer
250
+ reset_clipping
226
251
 
227
- @sin_angle = Math.sin(@player_angle)
228
- @cos_angle = Math.cos(@player_angle)
252
+ @sin_angle = Math.sin(@player_angle)
253
+ @cos_angle = Math.cos(@player_angle)
229
254
 
230
- # Precompute column angles for floor/ceiling rendering
231
- precompute_column_data
255
+ # Precompute column angles for floor/ceiling rendering
256
+ precompute_column_data
232
257
 
233
- # Draw player's sector floor/ceiling as background fallback.
234
- # Visplanes (with correct per-sector lighting) overwrite this for sectors
235
- # with different properties. This only remains visible at gaps between
236
- # same-property sectors where the light level matches anyway.
237
- draw_floor_ceiling_background
258
+ # Draw player's sector floor/ceiling as background fallback.
259
+ # Visplanes (with correct per-sector lighting) overwrite this for sectors
260
+ # with different properties. This only remains visible at gaps between
261
+ # same-property sectors where the light level matches anyway.
262
+ draw_floor_ceiling_background
238
263
 
239
- # Initialize visplanes for tracking visible floor/ceiling spans
240
- @visplanes = []
241
- @visplane_hash = {} # Hash for O(1) lookup by (height, texture, light_level, is_ceiling)
264
+ # Initialize visplanes for tracking visible floor/ceiling spans
265
+ @visplanes = []
266
+ @visplane_hash = {} # Hash for O(1) lookup by (height, texture, light_level, is_ceiling)
242
267
 
243
- # Initialize drawsegs for sprite clipping
244
- @drawsegs = []
268
+ # Initialize drawsegs for sprite clipping
269
+ @drawsegs = []
245
270
 
246
- # Render walls via BSP traversal
247
- render_bsp_node(@map.nodes.size - 1)
271
+ # Render walls via BSP traversal
272
+ render_bsp_node(@map.nodes.size - 1)
248
273
 
249
- # Draw visplanes for sectors different from background
250
- draw_all_visplanes
274
+ # Draw visplanes for sectors different from background
275
+ draw_all_visplanes
276
+ end
251
277
 
252
278
  # Save wall clip arrays for sprite clipping (reuse preallocated arrays)
253
279
  @sprite_ceiling_clip.replace(@ceiling_clip)
@@ -258,6 +284,38 @@ module Doom
258
284
  draw_masked if @sprites
259
285
  end
260
286
 
287
+ # Wall/floor/ceiling/sky rasterisation through the spinel_native-compiled
288
+ # port of this renderer's hot path. Fills @framebuffer plus the clip rows
289
+ # the Ruby sprite pass reads. Falls back to the Ruby path if the kernel
290
+ # cannot be built (e.g. no spinel compiler available).
291
+ def native_wall_pass
292
+ @sn ||= build_native_renderer
293
+ unless @sn
294
+ @native_kernel = false
295
+ return render_frame
296
+ end
297
+ # The sprite pass (kept in Ruby) transforms things with @sin_angle /
298
+ # @cos_angle, so they must be set for the current frame even though the
299
+ # native kernel computes its own trig internally.
300
+ @sin_angle = Math.sin(@player_angle)
301
+ @cos_angle = Math.cos(@player_angle)
302
+ fb = @sn.render_at(@player_x, @player_y, @player_z, @player_angle)
303
+ @framebuffer.replace(fb)
304
+ @ceiling_clip.replace(@sn.ceiling_clip)
305
+ @floor_clip.replace(@sn.floor_clip)
306
+ @wall_depth.replace(@sn.wall_depth)
307
+ end
308
+
309
+ def build_native_renderer
310
+ # Dynamic form so the spinel AOT compiler ignores this require (it pulls
311
+ # in the spinel_native gem, irrelevant to an AOT build); CRuby is unaffected.
312
+ send(:require_relative, "spinel_native_renderer")
313
+ SpinelNativeRenderer.new(@map, @palette, @colormap, @textures, @flats_array)
314
+ rescue StandardError, LoadError => e
315
+ warn "[doom] spinel_native renderer unavailable, using Ruby renderer (#{e.message.lines.first.to_s.strip})"
316
+ nil
317
+ end
318
+
261
319
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
262
320
  # Cached: only recomputes sin/cos when player angle changes
263
321
  def precompute_column_data
@@ -273,6 +331,8 @@ module Doom
273
331
  end
274
332
 
275
333
  def draw_floor_ceiling_background
334
+ return if @skip_background_fill
335
+
276
336
  player_sector = @map.sector_at(@player_x, @player_y)
277
337
  return unless player_sector
278
338
 
@@ -297,7 +357,7 @@ module Doom
297
357
  def render_visplane_spans(plane)
298
358
  return if plane.minx > plane.maxx
299
359
 
300
- spanstart = Array.new(SCREEN_HEIGHT) # Track where each row's span started
360
+ spanstart = Array.new(SCREEN_HEIGHT) # Track where each row's span started
301
361
 
302
362
  # Process columns left to right
303
363
  ((plane.minx)..(plane.maxx + 1)).each do |x|
@@ -305,9 +365,10 @@ module Doom
305
365
  if x <= plane.maxx
306
366
  t2 = plane.top[x]
307
367
  b2 = plane.bottom[x]
308
- t2 = SCREEN_HEIGHT if t2 > b2 # Invalid = empty
368
+ t2 = SCREEN_HEIGHT if t2 > b2 # Invalid = empty
309
369
  else
310
- t2, b2 = SCREEN_HEIGHT, -1 # Sentinel for final column
370
+ t2 = SCREEN_HEIGHT
371
+ b2 = -1 # Sentinel for final column
311
372
  end
312
373
 
313
374
  # Get previous column bounds
@@ -316,7 +377,8 @@ module Doom
316
377
  b1 = plane.bottom[x - 1]
317
378
  t1 = SCREEN_HEIGHT if t1 > b1
318
379
  else
319
- t1, b1 = SCREEN_HEIGHT, -1
380
+ t1 = SCREEN_HEIGHT
381
+ b1 = -1
320
382
  end
321
383
 
322
384
  # Close spans that ended (visible in prev column, not in current)
@@ -333,10 +395,10 @@ module Doom
333
395
  end
334
396
 
335
397
  # Open new spans (visible in current, not started yet)
336
- if t2 < SCREEN_HEIGHT
337
- (t2..b2).each do |y|
338
- spanstart[y] ||= x
339
- end
398
+ next unless t2 < SCREEN_HEIGHT
399
+
400
+ (t2..b2).each do |y|
401
+ spanstart[y] ||= x
340
402
  end
341
403
  end
342
404
  end
@@ -381,9 +443,9 @@ module Doom
381
443
  x = x1
382
444
  while x <= x2
383
445
  ray_dist = perp_dist * column_distscale[x]
384
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
385
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
386
- color = flat_pixels[tex_y * 64 + tex_x]
446
+ tex_x = (player_x + (ray_dist * column_cos[x])).to_i & 63
447
+ tex_y = (neg_player_y - (ray_dist * column_sin[x])).to_i & 63
448
+ color = flat_pixels[(tex_y * 64) + tex_x]
387
449
  framebuffer[row_offset + x] = cmap[color]
388
450
  x += 1
389
451
  end
@@ -394,7 +456,7 @@ module Doom
394
456
  # Chocolate Doom: skytexturemid = SCREENHEIGHT/2 = 100 (for 200px screen).
395
457
  # Scale factor: our 240px screen maps to DOOM's 200px sky coordinates.
396
458
  SKY_TEXTUREMID = 100.0
397
- SKY_YSCALE = 200.0 / SCREEN_HEIGHT # 0.833 - maps our pixels to DOOM's 200px space
459
+ SKY_YSCALE = 200.0 / SCREEN_HEIGHT # 0.833 - maps our pixels to DOOM's 200px space
398
460
  # Chocolate Doom: ANGLETOSKYSHIFT=22 gives 4 sky repetitions per 360 degrees.
399
461
  # Full circle (2pi) * 512/pi = 1024 columns, masked to 256 = 4 repetitions.
400
462
  SKY_XSCALE = 512.0 / Math::PI
@@ -431,9 +493,9 @@ module Doom
431
493
  # Sky Y: texel = skytexturemid + (y - centery) * scale
432
494
  # Maps texel 0 to screen top, texel 100 to horizon (1:1 for DOOM's 200px)
433
495
  (y1..y2).each do |y|
434
- tex_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_height
496
+ tex_y = (SKY_TEXTUREMID + ((y - HALF_HEIGHT) * SKY_YSCALE)).to_i % sky_height
435
497
  color = column[tex_y]
436
- framebuffer[y * SCREEN_WIDTH + x] = color
498
+ framebuffer[(y * SCREEN_WIDTH) + x] = color
437
499
  end
438
500
  end
439
501
  end
@@ -479,13 +541,14 @@ module Doom
479
541
  overlap = false
480
542
  (intrl..intrh).each do |x|
481
543
  next if x < 0 || x >= SCREEN_WIDTH
544
+
482
545
  if plane.top[x] <= plane.bottom[x]
483
546
  overlap = true
484
547
  break
485
548
  end
486
549
  end
487
550
 
488
- if !overlap
551
+ unless overlap
489
552
  # No overlap - reuse same visplane with expanded range
490
553
  plane.minx = unionl if unionl < plane.minx
491
554
  plane.maxx = unionh if unionh > plane.maxx
@@ -555,7 +618,7 @@ module Doom
555
618
  if is_sky && sky_texture
556
619
  sky_w = sky_texture.width
557
620
  sky_h = sky_texture.height
558
- sky_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_h
621
+ sky_y = (SKY_TEXTUREMID + ((y - HALF_HEIGHT) * SKY_YSCALE)).to_i % sky_h
559
622
  x = 0
560
623
  while x < SCREEN_WIDTH
561
624
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
@@ -568,9 +631,9 @@ module Doom
568
631
  x = 0
569
632
  while x < SCREEN_WIDTH
570
633
  ray_dist = perp_dist * column_distscale[x]
571
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
572
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
573
- color = ceil_pixels[tex_y * 64 + tex_x]
634
+ tex_x = (player_x + (ray_dist * column_cos[x])).to_i & 63
635
+ tex_y = (neg_player_y - (ray_dist * column_sin[x])).to_i & 63
636
+ color = ceil_pixels[(tex_y * 64) + tex_x]
574
637
  framebuffer[row_offset + x] = cmap[color]
575
638
  x += 1
576
639
  end
@@ -595,9 +658,9 @@ module Doom
595
658
  x = 0
596
659
  while x < SCREEN_WIDTH
597
660
  ray_dist = perp_dist * column_distscale[x]
598
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
599
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
600
- color = floor_pixels[tex_y * 64 + tex_x]
661
+ tex_x = (player_x + (ray_dist * column_cos[x])).to_i & 63
662
+ tex_y = (neg_player_y - (ray_dist * column_sin[x])).to_i & 63
663
+ color = floor_pixels[(tex_y * 64) + tex_x]
601
664
  framebuffer[row_offset + x] = cmap[color]
602
665
  x += 1
603
666
  end
@@ -686,6 +749,7 @@ module Doom
686
749
  x = min_sx
687
750
  while x <= max_sx
688
751
  return true if @ceiling_clip[x] < @floor_clip[x] - 1
752
+
689
753
  x += 1
690
754
  end
691
755
 
@@ -715,32 +779,28 @@ module Doom
715
779
 
716
780
  # Create floor visplane if floor is visible (below eye level)
717
781
  # Matches Chocolate Doom: if (frontsector->floorheight < viewz)
718
- if @current_sector.floor_height < @player_z
719
- @current_floor_plane = find_or_create_visplane(
720
- @current_sector,
721
- @current_sector.floor_height,
722
- @current_sector.floor_texture,
723
- @current_sector.light_level,
724
- false
725
- )
726
- else
727
- @current_floor_plane = nil
728
- end
782
+ @current_floor_plane = if @current_sector.floor_height < @player_z
783
+ find_or_create_visplane(
784
+ @current_sector,
785
+ @current_sector.floor_height,
786
+ @current_sector.floor_texture,
787
+ @current_sector.light_level,
788
+ false
789
+ )
790
+ end
729
791
 
730
792
  # Create ceiling visplane if ceiling is visible (above eye level or sky)
731
793
  # Matches Chocolate Doom: if (frontsector->ceilingheight > viewz || frontsector->ceilingpic == skyflatnum)
732
794
  is_sky = @current_sector.ceiling_texture == 'F_SKY1'
733
- if @current_sector.ceiling_height > @player_z || is_sky
734
- @current_ceiling_plane = find_or_create_visplane(
735
- @current_sector,
736
- @current_sector.ceiling_height,
737
- @current_sector.ceiling_texture,
738
- @current_sector.light_level,
739
- true
740
- )
741
- else
742
- @current_ceiling_plane = nil
743
- end
795
+ @current_ceiling_plane = if @current_sector.ceiling_height > @player_z || is_sky
796
+ find_or_create_visplane(
797
+ @current_sector,
798
+ @current_sector.ceiling_height,
799
+ @current_sector.ceiling_texture,
800
+ @current_sector.light_level,
801
+ true
802
+ )
803
+ end
744
804
 
745
805
  # Process all segs in this subsector
746
806
  subsector.seg_count.times do |i|
@@ -768,11 +828,11 @@ module Doom
768
828
  return
769
829
  elsif y1 < near
770
830
  t = (near - y1) / (y2 - y1)
771
- x1 = x1 + t * (x2 - x1)
831
+ x1 += (t * (x2 - x1))
772
832
  y1 = near
773
833
  elsif y2 < near
774
834
  t = (near - y1) / (y2 - y1)
775
- x2 = x1 + t * (x2 - x1)
835
+ x2 = x1 + (t * (x2 - x1))
776
836
  y2 = near
777
837
  end
778
838
  end
@@ -816,7 +876,7 @@ module Doom
816
876
  # Calculate seg length for texture mapping
817
877
  seg_v1 = @map.vertices[seg.v1]
818
878
  seg_v2 = @map.vertices[seg.v2]
819
- seg_length = Math.sqrt((seg_v2.x - seg_v1.x)**2 + (seg_v2.y - seg_v1.y)**2)
879
+ seg_length = Math.sqrt(((seg_v2.x - seg_v1.x)**2) + ((seg_v2.y - seg_v1.y)**2))
820
880
 
821
881
  draw_seg_range(x1i, x2i, sx1, sx2, y1, y2, sector, back_sector, sidedef, linedef, seg, seg_length)
822
882
  end
@@ -828,8 +888,8 @@ module Doom
828
888
 
829
889
  # Rotate - transform world to view space
830
890
  # View space: +Y forward (in direction of angle), +X is right
831
- x = dx * @sin_angle - dy * @cos_angle
832
- y = dx * @cos_angle + dy * @sin_angle
891
+ x = (dx * @sin_angle) - (dy * @cos_angle)
892
+ y = (dx * @cos_angle) + (dy * @sin_angle)
833
893
 
834
894
  [x, y]
835
895
  end
@@ -882,16 +942,16 @@ module Doom
882
942
  cos_a = @cos_angle
883
943
  proj = @projection
884
944
 
885
- c1 = cos_a * proj - sin_a * HALF_WIDTH
886
- c2 = sin_a * proj + cos_a * HALF_WIDTH
945
+ c1 = (cos_a * proj) - (sin_a * HALF_WIDTH)
946
+ c2 = (sin_a * proj) + (cos_a * HALF_WIDTH)
887
947
 
888
948
  # denom(x) = seg_dy * ray_dx - seg_dx * ray_dy = A * x + B
889
- tex_a = seg_dy * sin_a + seg_dx * cos_a
890
- tex_b = seg_dy * c1 - seg_dx * c2
949
+ tex_a = (seg_dy * sin_a) + (seg_dx * cos_a)
950
+ tex_b = (seg_dy * c1) - (seg_dx * c2)
891
951
 
892
952
  # numer(x) = py * ray_dx - px * ray_dy = E * x + F
893
- tex_e = py * sin_a + px * cos_a
894
- tex_f = py * c1 - px * c2
953
+ tex_e = (py * sin_a) + (px * cos_a)
954
+ tex_f = (py * c1) - (px * c2)
895
955
 
896
956
  tex_offset = seg.offset + sidedef.x_offset
897
957
 
@@ -949,22 +1009,18 @@ module Doom
949
1009
  end
950
1010
 
951
1011
  # Check planes for this seg range (R_CheckPlane equivalent)
952
- if @current_floor_plane
953
- @current_floor_plane = check_plane(@current_floor_plane, x1, x2)
954
- end
955
- if @current_ceiling_plane
956
- @current_ceiling_plane = check_plane(@current_ceiling_plane, x1, x2)
957
- end
1012
+ @current_floor_plane = check_plane(@current_floor_plane, x1, x2) if @current_floor_plane
1013
+ @current_ceiling_plane = check_plane(@current_ceiling_plane, x1, x2) if @current_ceiling_plane
958
1014
 
959
1015
  (x1..x2).each do |x|
960
1016
  next if @ceiling_clip[x] >= @floor_clip[x] - 1
961
1017
 
962
1018
  # Screen-space interpolation for distance
963
- t = sx2 != sx1 ? (x - sx1) / (sx2 - sx1) : 0
1019
+ t = sx2 == sx1 ? 0 : (x - sx1) / (sx2 - sx1)
964
1020
  t = t.clamp(0.0, 1.0)
965
1021
 
966
1022
  if dist1 > 0 && dist2 > 0
967
- inv_dist = (1.0 - t) / dist1 + t / dist2
1023
+ inv_dist = ((1.0 - t) / dist1) + (t / dist2)
968
1024
  dist = 1.0 / inv_dist
969
1025
  else
970
1026
  dist = dist1 > 0 ? dist1 : dist2
@@ -972,13 +1028,13 @@ module Doom
972
1028
 
973
1029
  # Ray-seg intersection for texture column
974
1030
  # s = (E * x + F) / (A * x + B) gives position along seg in world units
975
- denom = tex_a * x + tex_b
976
- if denom.abs < 0.001
977
- s = 0.0
978
- else
979
- s = (tex_e * x + tex_f) / denom
980
- end
981
- tex_col = (tex_offset + s * seg_length).to_i
1031
+ denom = (tex_a * x) + tex_b
1032
+ s = if denom.abs < 0.001
1033
+ 0.0
1034
+ else
1035
+ ((tex_e * x) + tex_f) / denom
1036
+ end
1037
+ tex_col = (tex_offset + (s * seg_length)).to_i
982
1038
 
983
1039
  # Skip if too close
984
1040
  next if dist < 1
@@ -994,176 +1050,19 @@ module Doom
994
1050
  # Chocolate Doom rounds ceiling UP and floor DOWN to avoid 1-pixel gaps:
995
1051
  # yl = (topfrac+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for front ceiling)
996
1052
  # yh = bottomfrac>>HEIGHTBITS (floor for front floor)
997
- front_ceil_y = (HALF_HEIGHT - front_ceil * scale).ceil
998
- front_floor_y = (HALF_HEIGHT - front_floor * scale).to_i
1053
+ front_ceil_y = (HALF_HEIGHT - (front_ceil * scale)).ceil
1054
+ front_floor_y = (HALF_HEIGHT - (front_floor * scale)).to_i
999
1055
 
1000
1056
  # Clamp to current clip bounds
1001
1057
  ceil_y = [front_ceil_y, @ceiling_clip[x] + 1].max
1002
1058
  floor_y = [front_floor_y, @floor_clip[x] - 1].min
1003
1059
 
1004
1060
  if back_sector
1005
- # Two-sided line
1006
- back_floor = back_sector.floor_height - @player_z
1007
- back_ceil = back_sector.ceiling_height - @player_z
1008
-
1009
- # Chocolate Doom rounding:
1010
- # pixhigh>>HEIGHTBITS (truncate for back ceiling / upper wall end)
1011
- # (pixlow+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for back floor / lower wall start)
1012
- back_ceil_y = (HALF_HEIGHT - back_ceil * scale).to_i
1013
- back_floor_y = (HALF_HEIGHT - back_floor * scale).ceil
1014
-
1015
- # Determine visible ceiling/floor boundaries (the opening between sectors)
1016
- # high_ceil = top of the opening on screen (max Y = lower world ceiling)
1017
- # low_floor = bottom of the opening on screen (min Y = higher world floor)
1018
- high_ceil = [ceil_y, back_ceil_y].max
1019
- low_floor = [floor_y, back_floor_y].min
1020
-
1021
- # Check for closed door (no opening between sectors)
1022
- # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
1023
- # || backsector->floorheight >= frontsector->ceilingheight
1024
- closed_door = back_sector.ceiling_height <= sector.floor_height ||
1025
- back_sector.floor_height >= sector.ceiling_height
1026
-
1027
- both_sky = sector.ceiling_texture == 'F_SKY1' && back_sector.ceiling_texture == 'F_SKY1'
1028
-
1029
- # Determine whether to mark ceiling/floor visplanes.
1030
- # Match Chocolate Doom: only mark when properties differ, plus force
1031
- # for closed doors. The background fill covers same-property gaps.
1032
- # Chocolate Doom sky hack: "worldtop = worldhigh" makes the front
1033
- # ceiling equal to the back ceiling when both are sky. This prevents
1034
- # the low sky ceiling from clipping walls in adjacent sectors.
1035
- effective_front_ceil = both_sky ? back_sector.ceiling_height : sector.ceiling_height
1036
-
1037
- if closed_door
1038
- should_mark_ceiling = true
1039
- should_mark_floor = true
1040
- else
1041
- should_mark_ceiling = effective_front_ceil != back_sector.ceiling_height ||
1042
- sector.ceiling_texture != back_sector.ceiling_texture ||
1043
- sector.light_level != back_sector.light_level
1044
- should_mark_floor = sector.floor_height != back_sector.floor_height ||
1045
- sector.floor_texture != back_sector.floor_texture ||
1046
- sector.light_level != back_sector.light_level
1047
- end
1048
-
1049
- # Mark ceiling visplane
1050
- if @current_ceiling_plane && should_mark_ceiling
1051
- mark_top = @ceiling_clip[x] + 1
1052
- mark_bottom = ceil_y - 1
1053
- mark_bottom = [@floor_clip[x] - 1, mark_bottom].min
1054
- if mark_top <= mark_bottom
1055
- @current_ceiling_plane.mark(x, mark_top, mark_bottom)
1056
- end
1057
- end
1058
-
1059
- # Mark floor visplane
1060
- if @current_floor_plane && should_mark_floor
1061
- mark_top = floor_y + 1
1062
- mark_bottom = @floor_clip[x] - 1
1063
- mark_top = [@ceiling_clip[x] + 1, mark_top].max
1064
- if mark_top <= mark_bottom
1065
- @current_floor_plane.mark(x, mark_top, mark_bottom)
1066
- end
1067
- end
1068
-
1069
- # Upper wall (ceiling step down) - skip if both sectors have sky
1070
- if !both_sky && sector.ceiling_height > back_sector.ceiling_height
1071
- if linedef.upper_unpegged?
1072
- upper_tex_y = sidedef.y_offset
1073
- else
1074
- texture = @textures[sidedef.upper_texture]
1075
- tex_height = texture ? texture.height : 128
1076
- upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
1077
- end
1078
- draw_wall_column_ex(x, ceil_y, back_ceil_y, sidedef.upper_texture, dist,
1079
- sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height, back_sector.ceiling_height)
1080
- end
1081
-
1082
- # Lower wall (floor step up)
1083
- if sector.floor_height < back_sector.floor_height
1084
- if linedef.lower_unpegged?
1085
- lower_tex_y = sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
1086
- else
1087
- lower_tex_y = sidedef.y_offset
1088
- end
1089
- draw_wall_column_ex(x, back_floor_y, floor_y, sidedef.lower_texture, dist,
1090
- sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
1091
- end
1092
-
1093
- # Store masked texture column for deferred rendering (grates, bars)
1094
- if @current_masked_cols
1095
- @current_masked_cols[x - x1] = tex_col
1096
- end
1097
-
1098
- # Update clip bounds
1099
- if closed_door
1100
- @wall_depth[x] = [@wall_depth[x], dist].min
1101
- @ceiling_clip[x] = SCREEN_HEIGHT
1102
- @floor_clip[x] = -1
1103
- else
1104
- # Ceiling clip (uses effective_front_ceil for sky hack)
1105
- if effective_front_ceil > back_sector.ceiling_height
1106
- @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
1107
- elsif effective_front_ceil < back_sector.ceiling_height
1108
- @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1109
- elsif sector.ceiling_texture != back_sector.ceiling_texture ||
1110
- sector.light_level != back_sector.light_level
1111
- @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1112
- end
1113
-
1114
- # Floor clip
1115
- if sector.floor_height < back_sector.floor_height
1116
- @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
1117
- elsif sector.floor_height > back_sector.floor_height
1118
- # Chocolate Doom: else if (markfloor) floorclip = yh + 1
1119
- @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1120
- elsif sector.floor_texture != back_sector.floor_texture ||
1121
- sector.light_level != back_sector.light_level
1122
- @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1123
- end
1124
- end
1061
+ draw_two_sided_seg_column(x, x1, ceil_y, floor_y, scale, dist, tex_col,
1062
+ sector, back_sector, sidedef, linedef)
1125
1063
  else
1126
- # One-sided (solid) wall
1127
- # Mark ceiling visplane (from previous clip to wall's ceiling)
1128
- # Clamp to floor_clip to prevent ceiling bleeding through portal openings
1129
- if @current_ceiling_plane
1130
- mark_top = @ceiling_clip[x] + 1
1131
- mark_bottom = [ceil_y - 1, @floor_clip[x] - 1].min
1132
- if mark_top <= mark_bottom
1133
- @current_ceiling_plane.mark(x, mark_top, mark_bottom)
1134
- end
1135
- end
1136
-
1137
- # Mark floor visplane (from wall's floor to previous floor clip)
1138
- # Clamp to ceiling_clip to prevent floor bleeding through portal openings
1139
- if @current_floor_plane
1140
- mark_top = [floor_y + 1, @ceiling_clip[x] + 1].max
1141
- mark_bottom = @floor_clip[x] - 1
1142
- if mark_top <= mark_bottom
1143
- @current_floor_plane.mark(x, mark_top, mark_bottom)
1144
- end
1145
- end
1146
-
1147
- # Draw wall (from clipped ceiling to clipped floor)
1148
- # Middle texture Y offset depends on DONTPEGBOTTOM flag
1149
- # With DONTPEGBOTTOM: texture bottom aligns with floor
1150
- # Without: texture top aligns with ceiling
1151
- if linedef.lower_unpegged?
1152
- texture = @textures[sidedef.middle_texture]
1153
- tex_height = texture ? texture.height : 128
1154
- mid_tex_y = sidedef.y_offset + tex_height - (sector.ceiling_height - sector.floor_height)
1155
- else
1156
- mid_tex_y = sidedef.y_offset
1157
- end
1158
- draw_wall_column_ex(x, ceil_y, floor_y, sidedef.middle_texture, dist,
1159
- sector.light_level, tex_col, mid_tex_y, scale, sector.ceiling_height, sector.floor_height)
1160
-
1161
- # Track wall depth for sprite clipping (solid wall occludes this column)
1162
- @wall_depth[x] = [@wall_depth[x], dist].min
1163
-
1164
- # Fully occluded
1165
- @ceiling_clip[x] = SCREEN_HEIGHT
1166
- @floor_clip[x] = -1
1064
+ draw_one_sided_seg_column(x, ceil_y, floor_y, scale, dist, tex_col,
1065
+ sector, sidedef, linedef)
1167
1066
  end
1168
1067
  end
1169
1068
 
@@ -1171,7 +1070,7 @@ module Doom
1171
1070
  if silhouette != SIL_NONE || has_masked
1172
1071
  sprtopclip = @ceiling_clip[x1..x2].dup
1173
1072
  sprbottomclip = @floor_clip[x1..x2].dup
1174
- scalestep = (x2 > x1) ? (scale2 - scale1) / (x2 - x1) : 0
1073
+ scalestep = x2 > x1 ? (scale2 - scale1) / (x2 - x1) : 0
1175
1074
 
1176
1075
  drawseg = Drawseg.new(
1177
1076
  x1, x2,
@@ -1189,127 +1088,173 @@ module Doom
1189
1088
  @current_masked_cols = nil
1190
1089
  end
1191
1090
 
1192
- # Wall column drawing with proper texture mapping
1193
- # tex_col: texture column (X coordinate in texture)
1194
- # tex_y_start: starting Y coordinate in texture (accounts for pegging)
1195
- # scale: projection scale for this column (projection / distance)
1196
- # world_top, world_bottom: world heights of this wall section
1197
- def draw_wall_column_ex(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top, world_bottom)
1198
- return if y1 > y2
1199
- return if texture_name.nil? || texture_name.empty? || texture_name == '-'
1200
-
1201
- # Clip to visible range (ceiling_clip/floor_clip)
1202
- clip_top = @ceiling_clip[x] + 1
1203
- clip_bottom = @floor_clip[x] - 1
1204
- y1 = [y1, clip_top].max
1205
- y2 = [y2, clip_bottom].min
1206
- return if y1 > y2
1207
-
1208
- texture = @textures[anim_texture(texture_name)]
1209
- return unless texture
1210
-
1211
- light = calculate_light(light_level, dist)
1212
- cmap = @colormap.maps[light]
1213
- framebuffer = @framebuffer
1214
- tex_width = texture.width
1215
- tex_height = texture.height
1216
-
1217
- # Texture X coordinate (wrap around texture width)
1218
- tex_x = tex_col.to_i % tex_width
1091
+ # One screen column of a two-sided seg: mark the ceiling/floor visplanes
1092
+ # of the opening between the two sectors, draw the upper and lower wall
1093
+ # steps, remember the masked-middle column, and tighten the clip bounds.
1094
+ def draw_two_sided_seg_column(x, x1, ceil_y, floor_y, scale, dist, tex_col,
1095
+ sector, back_sector, sidedef, linedef)
1096
+ back_floor = back_sector.floor_height - @player_z
1097
+ back_ceil = back_sector.ceiling_height - @player_z
1098
+
1099
+ # Chocolate Doom rounding:
1100
+ # pixhigh>>HEIGHTBITS (truncate for back ceiling / upper wall end)
1101
+ # (pixlow+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for back floor / lower wall start)
1102
+ back_ceil_y = (HALF_HEIGHT - (back_ceil * scale)).to_i
1103
+ back_floor_y = (HALF_HEIGHT - (back_floor * scale)).ceil
1104
+
1105
+ # Check for closed door (no opening between sectors)
1106
+ # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
1107
+ # || backsector->floorheight >= frontsector->ceilingheight
1108
+ closed_door = back_sector.ceiling_height <= sector.floor_height ||
1109
+ back_sector.floor_height >= sector.ceiling_height
1110
+
1111
+ both_sky = sector.ceiling_texture == 'F_SKY1' && back_sector.ceiling_texture == 'F_SKY1'
1112
+
1113
+ # Determine whether to mark ceiling/floor visplanes.
1114
+ # Match Chocolate Doom: only mark when properties differ, plus force
1115
+ # for closed doors. The background fill covers same-property gaps.
1116
+ # Chocolate Doom sky hack: "worldtop = worldhigh" makes the front
1117
+ # ceiling equal to the back ceiling when both are sky. This prevents
1118
+ # the low sky ceiling from clipping walls in adjacent sectors.
1119
+ effective_front_ceil = both_sky ? back_sector.ceiling_height : sector.ceiling_height
1120
+
1121
+ if closed_door
1122
+ should_mark_ceiling = true
1123
+ should_mark_floor = true
1124
+ else
1125
+ should_mark_ceiling = effective_front_ceil != back_sector.ceiling_height ||
1126
+ sector.ceiling_texture != back_sector.ceiling_texture ||
1127
+ sector.light_level != back_sector.light_level
1128
+ should_mark_floor = sector.floor_height != back_sector.floor_height ||
1129
+ sector.floor_texture != back_sector.floor_texture ||
1130
+ sector.light_level != back_sector.light_level
1131
+ end
1219
1132
 
1220
- # Get the column of pixels
1221
- column = texture.column_pixels(tex_x)
1222
- return unless column
1133
+ # Mark ceiling visplane
1134
+ if @current_ceiling_plane && should_mark_ceiling
1135
+ mark_top = @ceiling_clip[x] + 1
1136
+ mark_bottom = ceil_y - 1
1137
+ mark_bottom = [@floor_clip[x] - 1, mark_bottom].min
1138
+ @current_ceiling_plane.mark(x, mark_top, mark_bottom) if mark_top <= mark_bottom
1139
+ end
1223
1140
 
1224
- # Texture step per screen pixel
1225
- tex_step = 1.0 / scale
1141
+ # Mark floor visplane
1142
+ if @current_floor_plane && should_mark_floor
1143
+ mark_top = floor_y + 1
1144
+ mark_bottom = @floor_clip[x] - 1
1145
+ mark_top = [@ceiling_clip[x] + 1, mark_top].max
1146
+ @current_floor_plane.mark(x, mark_top, mark_bottom) if mark_top <= mark_bottom
1147
+ end
1226
1148
 
1227
- # Calculate where the unclipped wall top would be on screen
1228
- unclipped_y1 = HALF_HEIGHT - (world_top - @player_z) * scale
1149
+ # Upper wall (ceiling step down) - skip if both sectors have sky
1150
+ if !both_sky && sector.ceiling_height > back_sector.ceiling_height
1151
+ if linedef.upper_unpegged?
1152
+ upper_tex_y = sidedef.y_offset
1153
+ else
1154
+ texture = @textures[sidedef.upper_texture]
1155
+ tex_height = texture ? texture.height : 128
1156
+ upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
1157
+ end
1158
+ draw_wall_column_ex(x, ceil_y, back_ceil_y, sidedef.upper_texture, dist,
1159
+ sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height)
1160
+ end
1229
1161
 
1230
- # Adjust tex_y_start for any clipping at the top
1231
- tex_y_at_y1 = tex_y_start + (y1 - unclipped_y1) * tex_step
1162
+ # Lower wall (floor step up)
1163
+ if sector.floor_height < back_sector.floor_height
1164
+ lower_tex_y = if linedef.lower_unpegged?
1165
+ sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
1166
+ else
1167
+ sidedef.y_offset
1168
+ end
1169
+ draw_wall_column_ex(x, back_floor_y, floor_y, sidedef.lower_texture, dist,
1170
+ sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height)
1171
+ end
1232
1172
 
1233
- # Clamp to screen bounds
1234
- y1 = 0 if y1 < 0
1235
- y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1173
+ # Store masked texture column for deferred rendering (grates, bars)
1174
+ @current_masked_cols[x - x1] = tex_col if @current_masked_cols
1236
1175
 
1237
- # Draw wall column using while loop
1238
- y = y1
1239
- while y <= y2
1240
- screen_offset = y - y1
1241
- tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1176
+ # Update clip bounds
1177
+ if closed_door
1178
+ @wall_depth[x] = [@wall_depth[x], dist].min
1179
+ @ceiling_clip[x] = SCREEN_HEIGHT
1180
+ @floor_clip[x] = -1
1181
+ else
1182
+ # Ceiling clip (uses effective_front_ceil for sky hack)
1183
+ if effective_front_ceil > back_sector.ceiling_height
1184
+ @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
1185
+ elsif effective_front_ceil < back_sector.ceiling_height
1186
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1187
+ elsif sector.ceiling_texture != back_sector.ceiling_texture ||
1188
+ sector.light_level != back_sector.light_level
1189
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1190
+ end
1242
1191
 
1243
- color = column[tex_y]
1244
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color] if color
1245
- y += 1
1192
+ # Floor clip
1193
+ if sector.floor_height < back_sector.floor_height
1194
+ @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
1195
+ elsif sector.floor_height > back_sector.floor_height
1196
+ # Chocolate Doom: else if (markfloor) floorclip = yh + 1
1197
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1198
+ elsif sector.floor_texture != back_sector.floor_texture ||
1199
+ sector.light_level != back_sector.light_level
1200
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1201
+ end
1246
1202
  end
1247
1203
  end
1248
1204
 
1249
- # Cache which textures have transparent pixels
1250
- def texture_has_transparency?(texture)
1251
- @transparency_cache ||= {}
1252
- name = texture.name
1253
- return @transparency_cache[name] if @transparency_cache.key?(name)
1254
- @transparency_cache[name] = texture.width.times.any? do |x|
1255
- col = texture.column_pixels(x)
1256
- col&.any?(&:nil?)
1205
+ # One screen column of a one-sided (solid) wall: mark the ceiling and
1206
+ # floor visplanes down to the wall, draw the full-height middle texture,
1207
+ # and fully occlude the column for anything behind it.
1208
+ def draw_one_sided_seg_column(x, ceil_y, floor_y, scale, dist, tex_col,
1209
+ sector, sidedef, linedef)
1210
+ # Mark ceiling visplane (from previous clip to wall's ceiling)
1211
+ # Clamp to floor_clip to prevent ceiling bleeding through portal openings
1212
+ if @current_ceiling_plane
1213
+ mark_top = @ceiling_clip[x] + 1
1214
+ mark_bottom = [ceil_y - 1, @floor_clip[x] - 1].min
1215
+ @current_ceiling_plane.mark(x, mark_top, mark_bottom) if mark_top <= mark_bottom
1257
1216
  end
1258
- end
1259
1217
 
1260
- # Draw a deferred masked column using per-drawseg clip values saved at BSP time
1261
- def draw_wall_column_masked_deferred(md)
1262
- x = md[:x]
1263
- tex_name = md[:tex]
1264
- return if tex_name.nil? || tex_name.empty? || tex_name == '-'
1265
-
1266
- # Use saved clips (from BSP time, before far walls closed them)
1267
- # AND final sprite clips (to prevent far grates drawing over near walls)
1268
- # Take the tightest combination of both
1269
- y1 = [md[:clip_top] + 1, @sprite_ceiling_clip[x] + 1, md[:y1]].max
1270
- y2 = [md[:clip_bottom] - 1, @sprite_floor_clip[x] - 1, md[:y2]].min
1271
- return if y1 > y2
1272
-
1273
- # Depth test: far grate behind a near solid wall
1274
- return if md[:dist] > @sprite_wall_depth[x]
1275
-
1276
- texture = @textures[anim_texture(tex_name)]
1277
- return unless texture
1278
-
1279
- light = calculate_light(md[:light], md[:dist])
1280
- cmap = @colormap.maps[light]
1281
- framebuffer = @framebuffer
1282
- tex_width = texture.width
1283
- tex_height = texture.height
1284
-
1285
- tex_x = md[:tex_col].to_i % tex_width
1286
- column = texture.column_pixels(tex_x)
1287
- return unless column
1218
+ # Mark floor visplane (from wall's floor to previous floor clip)
1219
+ # Clamp to ceiling_clip to prevent floor bleeding through portal openings
1220
+ if @current_floor_plane
1221
+ mark_top = [floor_y + 1, @ceiling_clip[x] + 1].max
1222
+ mark_bottom = @floor_clip[x] - 1
1223
+ @current_floor_plane.mark(x, mark_top, mark_bottom) if mark_top <= mark_bottom
1224
+ end
1288
1225
 
1289
- scale = md[:scale]
1290
- tex_step = 1.0 / scale
1291
- unclipped_y1 = HALF_HEIGHT - (md[:world_top] - @player_z) * scale
1292
- tex_y_at_y1 = md[:tex_y] + (y1 - unclipped_y1) * tex_step
1226
+ # Draw wall (from clipped ceiling to clipped floor)
1227
+ # Middle texture Y offset depends on DONTPEGBOTTOM flag
1228
+ # With DONTPEGBOTTOM: texture bottom aligns with floor
1229
+ # Without: texture top aligns with ceiling
1230
+ if linedef.lower_unpegged?
1231
+ texture = @textures[sidedef.middle_texture]
1232
+ tex_height = texture ? texture.height : 128
1233
+ mid_tex_y = sidedef.y_offset + tex_height - (sector.ceiling_height - sector.floor_height)
1234
+ else
1235
+ mid_tex_y = sidedef.y_offset
1236
+ end
1237
+ draw_wall_column_ex(x, ceil_y, floor_y, sidedef.middle_texture, dist,
1238
+ sector.light_level, tex_col, mid_tex_y, scale, sector.ceiling_height)
1293
1239
 
1294
- y1 = 0 if y1 < 0
1295
- y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1240
+ # Track wall depth for sprite clipping (solid wall occludes this column)
1241
+ @wall_depth[x] = [@wall_depth[x], dist].min
1296
1242
 
1297
- y = y1
1298
- while y <= y2
1299
- screen_offset = y - y1
1300
- tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1301
- color = column[tex_y]
1302
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color] if color
1303
- y += 1
1304
- end
1243
+ # Fully occluded
1244
+ @ceiling_clip[x] = SCREEN_HEIGHT
1245
+ @floor_clip[x] = -1
1305
1246
  end
1306
1247
 
1307
- # Draw a masked (transparent) wall column for middle textures on two-sided linedefs.
1308
- # Unlike draw_wall_column_ex, skips nil pixels (transparent areas).
1309
- def draw_wall_column_masked(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top)
1248
+ # Wall column drawing with proper texture mapping
1249
+ # tex_col: texture column (X coordinate in texture)
1250
+ # tex_y_start: starting Y coordinate in texture (accounts for pegging)
1251
+ # scale: projection scale for this column (projection / distance)
1252
+ # world_top: world height of the top of this wall section
1253
+ def draw_wall_column_ex(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top)
1310
1254
  return if y1 > y2
1311
1255
  return if texture_name.nil? || texture_name.empty? || texture_name == '-'
1312
1256
 
1257
+ # Clip to visible range (ceiling_clip/floor_clip)
1313
1258
  clip_top = @ceiling_clip[x] + 1
1314
1259
  clip_bottom = @floor_clip[x] - 1
1315
1260
  y1 = [y1, clip_top].max
@@ -1325,26 +1270,34 @@ module Doom
1325
1270
  tex_width = texture.width
1326
1271
  tex_height = texture.height
1327
1272
 
1273
+ # Texture X coordinate (wrap around texture width)
1328
1274
  tex_x = tex_col.to_i % tex_width
1275
+
1276
+ # Get the column of pixels
1329
1277
  column = texture.column_pixels(tex_x)
1330
1278
  return unless column
1331
1279
 
1280
+ # Texture step per screen pixel
1332
1281
  tex_step = 1.0 / scale
1333
- unclipped_y1 = HALF_HEIGHT - (world_top - @player_z) * scale
1334
- tex_y_at_y1 = tex_y_start + (y1 - unclipped_y1) * tex_step
1335
1282
 
1283
+ # Calculate where the unclipped wall top would be on screen
1284
+ unclipped_y1 = HALF_HEIGHT - ((world_top - @player_z) * scale)
1285
+
1286
+ # Adjust tex_y_start for any clipping at the top
1287
+ tex_y_at_y1 = tex_y_start + ((y1 - unclipped_y1) * tex_step)
1288
+
1289
+ # Clamp to screen bounds
1336
1290
  y1 = 0 if y1 < 0
1337
1291
  y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1338
1292
 
1293
+ # Draw wall column using while loop
1339
1294
  y = y1
1340
1295
  while y <= y2
1341
1296
  screen_offset = y - y1
1342
- tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1297
+ tex_y = (tex_y_at_y1 + (screen_offset * tex_step)).to_i % tex_height
1298
+
1343
1299
  color = column[tex_y]
1344
- # Skip transparent pixels (nil in patch data)
1345
- if color
1346
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1347
- end
1300
+ framebuffer[(y * SCREEN_WIDTH) + x] = cmap[color] if color
1348
1301
  y += 1
1349
1302
  end
1350
1303
  end
@@ -1442,7 +1395,7 @@ module Doom
1442
1395
  # Get sprite: death frame > walking frame > idle frame
1443
1396
  if @combat && @combat.dead?(thing_idx)
1444
1397
  sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1445
- next unless sprite # Barrel disappeared after explosion
1398
+ next unless sprite # Barrel disappeared after explosion
1446
1399
  elsif @monster_ai
1447
1400
  mon = @monster_ai.monster_by_thing_idx[thing_idx]
1448
1401
  if mon && mon.active
@@ -1477,6 +1430,8 @@ module Doom
1477
1430
  visible_sprites << VisibleSprite.new(thing, sprite, view_x, view_y, dist, screen_x)
1478
1431
  end
1479
1432
 
1433
+ collect_player_sprites(visible_sprites)
1434
+
1480
1435
  # Sort by distance (back to front for proper overdraw)
1481
1436
  visible_sprites.sort_by! { |s| -s.dist }
1482
1437
 
@@ -1488,14 +1443,15 @@ module Doom
1488
1443
  # Draw remaining masked segs not triggered by sprites
1489
1444
  @drawsegs.reverse_each do |ds|
1490
1445
  next unless ds.maskedtexturecol
1446
+
1491
1447
  render_masked_seg_range(ds, ds.x1, ds.x2)
1492
1448
  end
1493
1449
 
1494
1450
  # Draw projectiles, explosions, and bullet puffs
1495
- if @combat
1496
- render_projectiles
1497
- render_puffs
1498
- end
1451
+ return unless @combat
1452
+
1453
+ render_projectiles
1454
+ render_puffs
1499
1455
  end
1500
1456
 
1501
1457
  # Chocolate Doom R_DrawMasked: interleave sprites with masked drawsegs
@@ -1503,6 +1459,60 @@ module Doom
1503
1459
  render_sprites
1504
1460
  end
1505
1461
 
1462
+ # Other players, drawn with the PLAY sprites. They join the same
1463
+ # distance-sorted list as map things rather than being drawn afterwards,
1464
+ # so they occlude and are occluded correctly.
1465
+ PLAYER_SPRITE_PREFIX = 'PLAY'
1466
+ PLAYER_WALK_FRAMES = %w[A B C D].freeze
1467
+ PLAYER_DEATH_FRAMES = %w[H I J K L M N].freeze
1468
+ PLAYER_DEATH_FRAME_TICS = 6
1469
+ PLAYER_ATTACK_FRAME = 'E'
1470
+ PLAYER_MOVING_SPEED = 1.0 # units/sec below which a player reads as still
1471
+
1472
+ def collect_player_sprites(visible_sprites)
1473
+ return if @players.nil? || @players.empty?
1474
+
1475
+ @players.each do |player|
1476
+ # Never draw the player whose eyes we are looking through.
1477
+ next if player.equal?(@view_player)
1478
+
1479
+ view_x, view_y = transform_point(player.x, player.y)
1480
+ next if view_y <= 0
1481
+
1482
+ angle_to_player = Math.atan2(player.y - @player_y, player.x - @player_x)
1483
+ sprite = player_sprite(player, angle_to_player)
1484
+ next unless sprite
1485
+
1486
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1487
+ sprite_half_width = (sprite.width * @projection / view_y / 2).to_i
1488
+ next if screen_x + sprite_half_width < 0
1489
+ next if screen_x - sprite_half_width >= SCREEN_WIDTH
1490
+
1491
+ # z is the player's eye height; the sprite is anchored at the feet.
1492
+ feet_z = player.z - Game::PlayerState::VIEWHEIGHT
1493
+ stub = ProjectileStub.new(player.x, player.y, 0, 0, 0, feet_z)
1494
+ visible_sprites << VisibleSprite.new(stub, sprite, view_x, view_y, view_y, screen_x)
1495
+ end
1496
+ end
1497
+
1498
+ def player_sprite(player, angle_to_player)
1499
+ state = player.state
1500
+ facing = player.angle * 180.0 / Math::PI
1501
+
1502
+ if state.dead
1503
+ idx = (state.death_tic / PLAYER_DEATH_FRAME_TICS).clamp(0, PLAYER_DEATH_FRAMES.size - 1)
1504
+ frame = PLAYER_DEATH_FRAMES[idx]
1505
+ elsif state.attacking
1506
+ frame = PLAYER_ATTACK_FRAME
1507
+ elsif Math.hypot(player.momx, player.momy) > PLAYER_MOVING_SPEED
1508
+ frame = PLAYER_WALK_FRAMES[@leveltime / 4 % PLAYER_WALK_FRAMES.size]
1509
+ else
1510
+ frame = PLAYER_WALK_FRAMES.first
1511
+ end
1512
+
1513
+ @sprites.get_frame_rotated(PLAYER_SPRITE_PREFIX, frame, angle_to_player, facing)
1514
+ end
1515
+
1506
1516
  # Draw sprite, rendering masked drawsegs behind it first (R_DrawSprite)
1507
1517
  def draw_sprite_with_masking(spr)
1508
1518
  sprite_scale = @projection / spr.dist
@@ -1513,18 +1523,18 @@ module Doom
1513
1523
  next if ds.silhouette == SIL_NONE && ds.maskedtexturecol.nil?
1514
1524
 
1515
1525
  ds_max_scale = [ds.scale1, ds.scale2].max
1516
- ds_min_scale = [ds.scale1, ds.scale2].min
1526
+ [ds.scale1, ds.scale2].min
1517
1527
 
1518
1528
  # Is the drawseg behind the sprite?
1519
- if ds_max_scale < sprite_scale
1520
- # Draw masked texture behind the sprite
1521
- if ds.maskedtexturecol
1522
- r1 = [ds.x1, spr.screen_x.to_i - spr.sprite.width].max
1523
- r2 = [ds.x2, spr.screen_x.to_i + spr.sprite.width].min
1524
- render_masked_seg_range(ds, r1, r2)
1525
- end
1526
- next # Don't clip against things behind
1529
+ next unless ds_max_scale < sprite_scale
1530
+
1531
+ # Draw masked texture behind the sprite
1532
+ if ds.maskedtexturecol
1533
+ r1 = [ds.x1, spr.screen_x.to_i - spr.sprite.width].max
1534
+ r2 = [ds.x2, spr.screen_x.to_i + spr.sprite.width].min
1535
+ render_masked_seg_range(ds, r1, r2)
1527
1536
  end
1537
+ next # Don't clip against things behind
1528
1538
  end
1529
1539
 
1530
1540
  draw_sprite(spr)
@@ -1566,22 +1576,23 @@ module Doom
1566
1576
  next if col_idx < 0 || col_idx >= ds.maskedtexturecol.size
1567
1577
 
1568
1578
  tex_col = ds.maskedtexturecol[col_idx]
1569
- next unless tex_col # nil = already drawn or empty
1579
+ next unless tex_col # nil = already drawn or empty
1570
1580
 
1571
1581
  # Per-column distance using same 1/z interpolation as wall renderer
1572
1582
  if ds.dist1 && ds.dist2 && ds.dist1 > 0 && ds.dist2 > 0 && ds.sx2 != ds.sx1
1573
1583
  t = ((x - ds.sx1) / (ds.sx2 - ds.sx1)).clamp(0.0, 1.0)
1574
- inv_dist = (1.0 - t) / ds.dist1 + t / ds.dist2
1584
+ inv_dist = ((1.0 - t) / ds.dist1) + (t / ds.dist2)
1575
1585
  col_dist = 1.0 / inv_dist
1576
1586
  else
1577
1587
  col_dist = ds.dist1 || 1.0
1578
1588
  end
1579
1589
  next if col_dist < 1
1590
+
1580
1591
  spryscale = @projection / col_dist
1581
1592
  spryscale = [spryscale, 64.0].min
1582
1593
 
1583
1594
  # Screen Y of texture top
1584
- sprtopscreen = HALF_HEIGHT - dc_texturemid * spryscale
1595
+ sprtopscreen = HALF_HEIGHT - (dc_texturemid * spryscale)
1585
1596
 
1586
1597
  # Get clip bounds from drawseg
1587
1598
  clip_idx = x - ds.x1
@@ -1604,23 +1615,23 @@ module Doom
1604
1615
  (0..tex_height).each do |ty|
1605
1616
  color = ty < tex_height ? column[ty] : nil
1606
1617
  if color
1607
- y_start = ty unless y_start
1618
+ y_start ||= ty
1608
1619
  elsif y_start
1609
1620
  # Draw run from y_start to ty-1
1610
- top_y = (sprtopscreen + y_start * spryscale).to_i
1611
- bot_y = (sprtopscreen + ty * spryscale).to_i - 1
1621
+ top_y = (sprtopscreen + (y_start * spryscale)).to_i
1622
+ bot_y = (sprtopscreen + (ty * spryscale)).to_i - 1
1612
1623
  top_y = [top_y, mceilingclip + 1].max
1613
1624
  bot_y = [bot_y, mfloorclip - 1].min
1614
1625
 
1615
1626
  if top_y <= bot_y
1616
1627
  top_y = [top_y, 0].max
1617
1628
  bot_y = [bot_y, SCREEN_HEIGHT - 1].min
1618
- tex_frac = y_start + (top_y - (sprtopscreen + y_start * spryscale)) * iscale
1629
+ tex_frac = y_start + ((top_y - (sprtopscreen + (y_start * spryscale))) * iscale)
1619
1630
  y = top_y
1620
1631
  while y <= bot_y
1621
- t = ((tex_frac).to_i % tex_height)
1632
+ t = (tex_frac.to_i % tex_height)
1622
1633
  c = column[t]
1623
- @framebuffer[y * SCREEN_WIDTH + x] = cmap[c] if c
1634
+ @framebuffer[(y * SCREEN_WIDTH) + x] = cmap[c] if c
1624
1635
  tex_frac += iscale
1625
1636
  y += 1
1626
1637
  end
@@ -1649,7 +1660,7 @@ module Doom
1649
1660
  rocket_angle = Math.atan2(proj.dy, proj.dx)
1650
1661
  viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
1651
1662
  angle_diff = (viewer_angle - rocket_angle) % (2 * Math::PI)
1652
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
1663
+ rotation = (((angle_diff + (Math::PI / 8)) / (Math::PI / 4)).to_i % 8) + 1
1653
1664
 
1654
1665
  # Animate flight: cycle A/B frames
1655
1666
  flight_frame = %w[A B][@leveltime / 4 % 2]
@@ -1668,14 +1679,16 @@ module Doom
1668
1679
  @combat.explosions.each do |expl|
1669
1680
  view_x, view_y = transform_point(expl[:x], expl[:y])
1670
1681
  next if view_y <= 0
1682
+
1671
1683
  elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
1672
1684
  frame_idx = (elapsed / 4).clamp(0, 2)
1673
1685
 
1674
1686
  prefix = expl[:sprite] || 'MISL'
1675
- frame_letter = (prefix == 'MISL') ? %w[B C D][frame_idx] : %w[C D E][frame_idx]
1687
+ frame_letter = prefix == 'MISL' ? %w[B C D][frame_idx] : %w[C D E][frame_idx]
1676
1688
 
1677
1689
  expl_sprite = @sprites.send(:load_sprite_frame, prefix, frame_letter, 0)
1678
1690
  next unless expl_sprite
1691
+
1679
1692
  screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1680
1693
  expl_z = expl[:z] || @player_z
1681
1694
  stub = ProjectileStub.new(expl[:x], expl[:y], 0, 0, 0, expl_z)
@@ -1688,11 +1701,13 @@ module Doom
1688
1701
  @combat.puffs.each do |puff|
1689
1702
  view_x, view_y = transform_point(puff[:x], puff[:y])
1690
1703
  next if view_y <= 0
1704
+
1691
1705
  elapsed = @combat.instance_variable_get(:@tic) - puff[:tic]
1692
1706
  frame_idx = (elapsed / 3).clamp(0, 3)
1693
1707
  frame_letter = %w[A B C D][frame_idx]
1694
1708
  puff_sprite = @sprites.send(:load_sprite_frame, 'PUFF', frame_letter, 0)
1695
1709
  next unless puff_sprite
1710
+
1696
1711
  screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1697
1712
  stub = ProjectileStub.new(puff[:x], puff[:y], 0, 0, 0, puff[:z])
1698
1713
  visible = VisibleSprite.new(stub, puff_sprite, view_x, view_y, view_y, screen_x)
@@ -1721,7 +1736,7 @@ module Doom
1721
1736
  light = calculate_light(light_level, dist)
1722
1737
 
1723
1738
  # Sprite screen bounds
1724
- sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
1739
+ sprite_left = (screen_x - (sprite.left_offset * sprite_scale)).to_i
1725
1740
  sprite_right = sprite_left + sprite_screen_width - 1
1726
1741
 
1727
1742
  # Clamp to screen
@@ -1733,17 +1748,22 @@ module Doom
1733
1748
  # gzt = mobj->z + spritetopoffset (top of sprite)
1734
1749
  # gz = gzt - spriteheight (bottom of sprite, 1:1 pixel:unit)
1735
1750
  thing_floor = sector ? sector.floor_height : 0
1736
- if thing.is_a?(ProjectileStub) && thing.z
1737
- base_z = thing.z
1738
- else
1739
- base_z = thing_floor
1740
- end
1751
+ base_z = if thing.is_a?(ProjectileStub) && thing.z
1752
+ thing.z
1753
+ else
1754
+ thing_floor
1755
+ end
1741
1756
  sprite_gzt = base_z + sprite.top_offset
1742
1757
  sprite_gz = sprite_gzt - sprite.height
1743
1758
 
1744
- # Initialize per-column clip arrays (-2 = not yet clipped)
1745
- clipbot = Array.new(SCREEN_WIDTH, -2)
1746
- cliptop = Array.new(SCREEN_WIDTH, -2)
1759
+ # Reuse per-frame clip buffers (-2 = not yet clipped). Only reset the
1760
+ # range we'll touch ([x1, x2]); leftover values past x2 are ignored.
1761
+ clipbot = @sprite_clipbot
1762
+ cliptop = @sprite_cliptop
1763
+ x1.upto(x2) do |i|
1764
+ clipbot[i] = -2
1765
+ cliptop[i] = -2
1766
+ end
1747
1767
 
1748
1768
  # Scan drawsegs from back to front for obscuring segs
1749
1769
  @drawsegs.reverse_each do |ds|
@@ -1775,26 +1795,22 @@ module Doom
1775
1795
  silhouette = ds.silhouette
1776
1796
 
1777
1797
  # If sprite bottom is at or above the bottom silhouette height, don't clip bottom
1778
- if sprite_gz >= ds.bsilheight
1779
- silhouette &= ~SIL_BOTTOM
1780
- end
1798
+ silhouette &= ~SIL_BOTTOM if sprite_gz >= ds.bsilheight
1781
1799
 
1782
1800
  # If sprite top is at or below the top silhouette height, don't clip top
1783
- if sprite_gzt <= ds.tsilheight
1784
- silhouette &= ~SIL_TOP
1785
- end
1801
+ silhouette &= ~SIL_TOP if sprite_gzt <= ds.tsilheight
1786
1802
 
1787
1803
  # Apply clipping for each column in the overlap
1788
1804
  (r1..r2).each do |x|
1789
1805
  # Index into drawseg's clip arrays (0-based from ds.x1)
1790
1806
  ds_idx = x - ds.x1
1791
1807
 
1792
- if (silhouette & SIL_BOTTOM) != 0 && clipbot[x] == -2
1793
- clipbot[x] = ds.sprbottomclip[ds_idx] if ds.sprbottomclip && ds_idx < ds.sprbottomclip.length
1808
+ if (silhouette & SIL_BOTTOM) != 0 && clipbot[x] == -2 && ds.sprbottomclip && ds_idx < ds.sprbottomclip.length
1809
+ clipbot[x] = ds.sprbottomclip[ds_idx]
1794
1810
  end
1795
1811
 
1796
- if (silhouette & SIL_TOP) != 0 && cliptop[x] == -2
1797
- cliptop[x] = ds.sprtopclip[ds_idx] if ds.sprtopclip && ds_idx < ds.sprtopclip.length
1812
+ if (silhouette & SIL_TOP) != 0 && cliptop[x] == -2 && ds.sprtopclip && ds_idx < ds.sprtopclip.length
1813
+ cliptop[x] = ds.sprtopclip[ds_idx]
1798
1814
  end
1799
1815
  end
1800
1816
  end
@@ -1805,11 +1821,21 @@ module Doom
1805
1821
  cliptop[x] = -1 if cliptop[x] == -2
1806
1822
  end
1807
1823
 
1824
+ # The native wall pass emits no drawsegs (the silhouette bookkeeping is
1825
+ # not ported), so the drawseg loop above clipped nothing and sprites
1826
+ # would draw over the walls in front of them. Occlude, per column, any
1827
+ # sprite that sits behind the nearest solid wall recorded by the wall
1828
+ # pass -- the common case (a monster behind a wall or the far background).
1829
+ if @native_kernel
1830
+ swd = @sprite_wall_depth
1831
+ (x1..x2).each { |x| cliptop[x] = SCREEN_HEIGHT if swd[x] < dist }
1832
+ end
1833
+
1808
1834
  # Calculate sprite screen Y positions
1809
1835
  sprite_top_world = sprite_gzt - @player_z
1810
1836
  sprite_bottom_world = sprite_gz - @player_z
1811
- sprite_top_screen = (HALF_HEIGHT - sprite_top_world * sprite_scale).to_i
1812
- sprite_bottom_screen = (HALF_HEIGHT - sprite_bottom_world * sprite_scale).to_i
1837
+ sprite_top_screen = (HALF_HEIGHT - (sprite_top_world * sprite_scale)).to_i
1838
+ sprite_bottom_screen = (HALF_HEIGHT - (sprite_bottom_world * sprite_scale)).to_i
1813
1839
 
1814
1840
  # Cache for inner loop
1815
1841
  framebuffer = @framebuffer
@@ -1847,7 +1873,7 @@ module Doom
1847
1873
  next unless color
1848
1874
 
1849
1875
  # Apply lighting and write directly to framebuffer
1850
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1876
+ framebuffer[(y * SCREEN_WIDTH) + x] = cmap[color]
1851
1877
  end
1852
1878
  end
1853
1879
  end