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
@@ -16,10 +16,14 @@ module Doom
16
16
 
17
17
  # Drawseg stores wall segment info for sprite clipping
18
18
  # Matches Chocolate Doom's drawseg_t structure
19
- Drawseg = Struct.new(:x1, :x2, :scale1, :scale2,
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
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
23
27
 
24
28
  # VisibleSprite stores sprite data for sorting and rendering
25
29
  # Struct is faster than Hash for fixed-field data
@@ -39,6 +43,7 @@ module Doom
39
43
 
40
44
  def mark(x, y1, y2)
41
45
  return if y1 > y2
46
+
42
47
  top[x] = [top[x], y1].min
43
48
  bottom[x] = [bottom[x], y2].max
44
49
  self.minx = [minx, x].min
@@ -51,21 +56,33 @@ module Doom
51
56
  end
52
57
 
53
58
  class Renderer
54
- attr_reader :framebuffer
55
-
56
59
  def initialize(wad, map, textures, palette, colormap, flats, sprites = nil, animations = nil)
57
60
  @wad = wad
58
61
  @map = map
59
62
  @textures = textures
60
63
  @palette = palette
61
64
  @colormap = colormap
65
+ @flats_array = flats
62
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
63
77
  @sprites = sprites
64
78
  @animations = animations
65
79
  @hidden_things = nil
66
80
  @combat = nil
67
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
68
84
  @leveltime = 0
85
+ @skip_background_fill = false
69
86
 
70
87
  @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
71
88
 
@@ -73,6 +90,8 @@ module Doom
73
90
  @player_y = 0.0
74
91
  @player_z = 41.0
75
92
  @player_angle = 0.0
93
+ @sin_angle = 0.0
94
+ @cos_angle = 1.0
76
95
 
77
96
  # Projection constant - distance to projection plane
78
97
  @projection = HALF_WIDTH / Math.tan(FOV * Math::PI / 360.0)
@@ -85,13 +104,17 @@ module Doom
85
104
  # Column distance scale is constant (doesn't depend on player angle)
86
105
  @column_distscale = Array.new(SCREEN_WIDTH) do |x|
87
106
  dx = x - HALF_WIDTH
88
- Math.sqrt(dx * dx + @projection * @projection) / @projection
107
+ Math.sqrt((dx * dx) + (@projection * @projection)) / @projection
89
108
  end
90
109
 
91
110
  # Clipping arrays
92
111
  @ceiling_clip = Array.new(SCREEN_WIDTH, -1)
93
112
  @floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
94
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
+
95
118
  # Sprite clip arrays (copy of wall clips for sprite clipping)
96
119
  @sprite_ceiling_clip = Array.new(SCREEN_WIDTH, -1)
97
120
  @sprite_floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
@@ -104,9 +127,10 @@ module Doom
104
127
  @y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
105
128
  @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
106
129
  end
107
-
108
- 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
109
132
  attr_writer :hidden_things, :combat, :monster_ai, :leveltime
133
+ attr_accessor :players, :view_player, :skip_background_fill
110
134
 
111
135
  # Diagnostic: returns info about all sprites and why they are/aren't visible
112
136
  def sprite_diagnostics
@@ -124,7 +148,7 @@ module Doom
124
148
  info[:view_y] = view_y.round(1)
125
149
 
126
150
  if view_y <= 0
127
- info[:status] = "behind_player"
151
+ info[:status] = 'behind_player'
128
152
  results << info
129
153
  next
130
154
  end
@@ -139,7 +163,7 @@ module Doom
139
163
  angle_to_thing = Math.atan2(dy, dx)
140
164
  sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
141
165
  unless sprite
142
- info[:status] = "no_sprite_frame"
166
+ info[:status] = 'no_sprite_frame'
143
167
  results << info
144
168
  next
145
169
  end
@@ -149,20 +173,19 @@ module Doom
149
173
  info[:sprite_scale] = sprite_scale.round(3)
150
174
 
151
175
  if screen_x + sprite_half_width < 0
152
- info[:status] = "off_screen_left"
176
+ info[:status] = 'off_screen_left'
153
177
  elsif screen_x - sprite_half_width >= SCREEN_WIDTH
154
- info[:status] = "off_screen_right"
178
+ info[:status] = 'off_screen_right'
155
179
  else
156
180
  # Check drawseg clipping
157
- sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
181
+ sprite_left = (screen_x - (sprite.left_offset * sprite_scale)).to_i
158
182
  sprite_right = sprite_left + (sprite.width * sprite_scale).to_i - 1
159
183
  x1 = [sprite_left, 0].max
160
184
  x2 = [sprite_right, SCREEN_WIDTH - 1].min
161
185
 
162
186
  sector = @map.sector_at(thing.x, thing.y)
163
187
  thing_floor = sector ? sector.floor_height : 0
164
- sprite_gz = thing_floor
165
- sprite_gzt = thing_floor + sprite.top_offset
188
+ thing_floor + sprite.top_offset
166
189
 
167
190
  clipping_segs = []
168
191
  @drawsegs.reverse_each do |ds|
@@ -188,7 +211,7 @@ module Doom
188
211
  info[:screen_range] = "#{x1}..#{x2}"
189
212
  info[:clipping_segs] = clipping_segs.size
190
213
  info[:clipping_detail] = clipping_segs
191
- info[:status] = "visible"
214
+ info[:status] = 'visible'
192
215
  end
193
216
 
194
217
  results << info
@@ -196,62 +219,101 @@ module Doom
196
219
  results
197
220
  end
198
221
 
199
- def set_player(x, y, z, angle)
200
- @player_x = x.to_f
201
- @player_y = y.to_f
202
- @player_z = z.to_f
203
- @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)
204
233
  end
205
234
 
206
- def move_to(x, y)
235
+ def set_player(x, y, z, angle)
207
236
  @player_x = x.to_f
208
237
  @player_y = y.to_f
209
- end
210
-
211
- def set_z(z)
212
238
  @player_z = z.to_f
213
- end
214
-
215
- def turn(degrees)
216
- @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)
217
242
  end
218
243
 
219
244
  def render_frame
220
- clear_framebuffer
221
- reset_clipping
245
+ if @native_kernel
246
+ native_wall_pass
247
+ @drawsegs = []
248
+ else
249
+ clear_framebuffer
250
+ reset_clipping
222
251
 
223
- @sin_angle = Math.sin(@player_angle)
224
- @cos_angle = Math.cos(@player_angle)
252
+ @sin_angle = Math.sin(@player_angle)
253
+ @cos_angle = Math.cos(@player_angle)
225
254
 
226
- # Precompute column angles for floor/ceiling rendering
227
- precompute_column_data
255
+ # Precompute column angles for floor/ceiling rendering
256
+ precompute_column_data
228
257
 
229
- # Draw player's sector floor/ceiling as background fallback.
230
- # Visplanes (with correct per-sector lighting) overwrite this for sectors
231
- # with different properties. This only remains visible at gaps between
232
- # same-property sectors where the light level matches anyway.
233
- 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
234
263
 
235
- # Initialize visplanes for tracking visible floor/ceiling spans
236
- @visplanes = []
237
- @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)
238
267
 
239
- # Initialize drawsegs for sprite clipping
240
- @drawsegs = []
268
+ # Initialize drawsegs for sprite clipping
269
+ @drawsegs = []
241
270
 
242
- # Render walls via BSP traversal
243
- render_bsp_node(@map.nodes.size - 1)
271
+ # Render walls via BSP traversal
272
+ render_bsp_node(@map.nodes.size - 1)
244
273
 
245
- # Draw visplanes for sectors different from background
246
- draw_all_visplanes
274
+ # Draw visplanes for sectors different from background
275
+ draw_all_visplanes
276
+ end
247
277
 
248
278
  # Save wall clip arrays for sprite clipping (reuse preallocated arrays)
249
279
  @sprite_ceiling_clip.replace(@ceiling_clip)
250
280
  @sprite_floor_clip.replace(@floor_clip)
251
281
  @sprite_wall_depth.replace(@wall_depth)
252
282
 
253
- # Render sprites
254
- render_sprites if @sprites
283
+ # R_DrawMasked: render sprites and masked middle textures interleaved
284
+ draw_masked if @sprites
285
+ end
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
255
317
  end
256
318
 
257
319
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
@@ -269,6 +331,8 @@ module Doom
269
331
  end
270
332
 
271
333
  def draw_floor_ceiling_background
334
+ return if @skip_background_fill
335
+
272
336
  player_sector = @map.sector_at(@player_x, @player_y)
273
337
  return unless player_sector
274
338
 
@@ -293,7 +357,7 @@ module Doom
293
357
  def render_visplane_spans(plane)
294
358
  return if plane.minx > plane.maxx
295
359
 
296
- 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
297
361
 
298
362
  # Process columns left to right
299
363
  ((plane.minx)..(plane.maxx + 1)).each do |x|
@@ -301,9 +365,10 @@ module Doom
301
365
  if x <= plane.maxx
302
366
  t2 = plane.top[x]
303
367
  b2 = plane.bottom[x]
304
- t2 = SCREEN_HEIGHT if t2 > b2 # Invalid = empty
368
+ t2 = SCREEN_HEIGHT if t2 > b2 # Invalid = empty
305
369
  else
306
- t2, b2 = SCREEN_HEIGHT, -1 # Sentinel for final column
370
+ t2 = SCREEN_HEIGHT
371
+ b2 = -1 # Sentinel for final column
307
372
  end
308
373
 
309
374
  # Get previous column bounds
@@ -312,7 +377,8 @@ module Doom
312
377
  b1 = plane.bottom[x - 1]
313
378
  t1 = SCREEN_HEIGHT if t1 > b1
314
379
  else
315
- t1, b1 = SCREEN_HEIGHT, -1
380
+ t1 = SCREEN_HEIGHT
381
+ b1 = -1
316
382
  end
317
383
 
318
384
  # Close spans that ended (visible in prev column, not in current)
@@ -329,10 +395,10 @@ module Doom
329
395
  end
330
396
 
331
397
  # Open new spans (visible in current, not started yet)
332
- if t2 < SCREEN_HEIGHT
333
- (t2..b2).each do |y|
334
- spanstart[y] ||= x
335
- end
398
+ next unless t2 < SCREEN_HEIGHT
399
+
400
+ (t2..b2).each do |y|
401
+ spanstart[y] ||= x
336
402
  end
337
403
  end
338
404
  end
@@ -377,9 +443,9 @@ module Doom
377
443
  x = x1
378
444
  while x <= x2
379
445
  ray_dist = perp_dist * column_distscale[x]
380
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
381
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
382
- 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]
383
449
  framebuffer[row_offset + x] = cmap[color]
384
450
  x += 1
385
451
  end
@@ -390,7 +456,7 @@ module Doom
390
456
  # Chocolate Doom: skytexturemid = SCREENHEIGHT/2 = 100 (for 200px screen).
391
457
  # Scale factor: our 240px screen maps to DOOM's 200px sky coordinates.
392
458
  SKY_TEXTUREMID = 100.0
393
- 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
394
460
  # Chocolate Doom: ANGLETOSKYSHIFT=22 gives 4 sky repetitions per 360 degrees.
395
461
  # Full circle (2pi) * 512/pi = 1024 columns, masked to 256 = 4 repetitions.
396
462
  SKY_XSCALE = 512.0 / Math::PI
@@ -427,9 +493,9 @@ module Doom
427
493
  # Sky Y: texel = skytexturemid + (y - centery) * scale
428
494
  # Maps texel 0 to screen top, texel 100 to horizon (1:1 for DOOM's 200px)
429
495
  (y1..y2).each do |y|
430
- 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
431
497
  color = column[tex_y]
432
- framebuffer[y * SCREEN_WIDTH + x] = color
498
+ framebuffer[(y * SCREEN_WIDTH) + x] = color
433
499
  end
434
500
  end
435
501
  end
@@ -475,13 +541,14 @@ module Doom
475
541
  overlap = false
476
542
  (intrl..intrh).each do |x|
477
543
  next if x < 0 || x >= SCREEN_WIDTH
544
+
478
545
  if plane.top[x] <= plane.bottom[x]
479
546
  overlap = true
480
547
  break
481
548
  end
482
549
  end
483
550
 
484
- if !overlap
551
+ unless overlap
485
552
  # No overlap - reuse same visplane with expanded range
486
553
  plane.minx = unionl if unionl < plane.minx
487
554
  plane.maxx = unionh if unionh > plane.maxx
@@ -551,7 +618,7 @@ module Doom
551
618
  if is_sky && sky_texture
552
619
  sky_w = sky_texture.width
553
620
  sky_h = sky_texture.height
554
- 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
555
622
  x = 0
556
623
  while x < SCREEN_WIDTH
557
624
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
@@ -564,9 +631,9 @@ module Doom
564
631
  x = 0
565
632
  while x < SCREEN_WIDTH
566
633
  ray_dist = perp_dist * column_distscale[x]
567
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
568
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
569
- 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]
570
637
  framebuffer[row_offset + x] = cmap[color]
571
638
  x += 1
572
639
  end
@@ -591,9 +658,9 @@ module Doom
591
658
  x = 0
592
659
  while x < SCREEN_WIDTH
593
660
  ray_dist = perp_dist * column_distscale[x]
594
- tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
595
- tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
596
- 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]
597
664
  framebuffer[row_offset + x] = cmap[color]
598
665
  x += 1
599
666
  end
@@ -682,6 +749,7 @@ module Doom
682
749
  x = min_sx
683
750
  while x <= max_sx
684
751
  return true if @ceiling_clip[x] < @floor_clip[x] - 1
752
+
685
753
  x += 1
686
754
  end
687
755
 
@@ -711,32 +779,28 @@ module Doom
711
779
 
712
780
  # Create floor visplane if floor is visible (below eye level)
713
781
  # Matches Chocolate Doom: if (frontsector->floorheight < viewz)
714
- if @current_sector.floor_height < @player_z
715
- @current_floor_plane = find_or_create_visplane(
716
- @current_sector,
717
- @current_sector.floor_height,
718
- @current_sector.floor_texture,
719
- @current_sector.light_level,
720
- false
721
- )
722
- else
723
- @current_floor_plane = nil
724
- 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
725
791
 
726
792
  # Create ceiling visplane if ceiling is visible (above eye level or sky)
727
793
  # Matches Chocolate Doom: if (frontsector->ceilingheight > viewz || frontsector->ceilingpic == skyflatnum)
728
794
  is_sky = @current_sector.ceiling_texture == 'F_SKY1'
729
- if @current_sector.ceiling_height > @player_z || is_sky
730
- @current_ceiling_plane = find_or_create_visplane(
731
- @current_sector,
732
- @current_sector.ceiling_height,
733
- @current_sector.ceiling_texture,
734
- @current_sector.light_level,
735
- true
736
- )
737
- else
738
- @current_ceiling_plane = nil
739
- 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
740
804
 
741
805
  # Process all segs in this subsector
742
806
  subsector.seg_count.times do |i|
@@ -764,11 +828,11 @@ module Doom
764
828
  return
765
829
  elsif y1 < near
766
830
  t = (near - y1) / (y2 - y1)
767
- x1 = x1 + t * (x2 - x1)
831
+ x1 += (t * (x2 - x1))
768
832
  y1 = near
769
833
  elsif y2 < near
770
834
  t = (near - y1) / (y2 - y1)
771
- x2 = x1 + t * (x2 - x1)
835
+ x2 = x1 + (t * (x2 - x1))
772
836
  y2 = near
773
837
  end
774
838
  end
@@ -812,7 +876,7 @@ module Doom
812
876
  # Calculate seg length for texture mapping
813
877
  seg_v1 = @map.vertices[seg.v1]
814
878
  seg_v2 = @map.vertices[seg.v2]
815
- 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))
816
880
 
817
881
  draw_seg_range(x1i, x2i, sx1, sx2, y1, y2, sector, back_sector, sidedef, linedef, seg, seg_length)
818
882
  end
@@ -824,8 +888,8 @@ module Doom
824
888
 
825
889
  # Rotate - transform world to view space
826
890
  # View space: +Y forward (in direction of angle), +X is right
827
- x = dx * @sin_angle - dy * @cos_angle
828
- y = dx * @cos_angle + dy * @sin_angle
891
+ x = (dx * @sin_angle) - (dy * @cos_angle)
892
+ y = (dx * @cos_angle) + (dy * @sin_angle)
829
893
 
830
894
  [x, y]
831
895
  end
@@ -878,16 +942,16 @@ module Doom
878
942
  cos_a = @cos_angle
879
943
  proj = @projection
880
944
 
881
- c1 = cos_a * proj - sin_a * HALF_WIDTH
882
- 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)
883
947
 
884
948
  # denom(x) = seg_dy * ray_dx - seg_dx * ray_dy = A * x + B
885
- tex_a = seg_dy * sin_a + seg_dx * cos_a
886
- 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)
887
951
 
888
952
  # numer(x) = py * ray_dx - px * ray_dy = E * x + F
889
- tex_e = py * sin_a + px * cos_a
890
- tex_f = py * c1 - px * c2
953
+ tex_e = (py * sin_a) + (px * cos_a)
954
+ tex_f = (py * c1) - (px * c2)
891
955
 
892
956
  tex_offset = seg.offset + sidedef.x_offset
893
957
 
@@ -924,24 +988,39 @@ module Doom
924
988
  tsilheight = -Float::INFINITY
925
989
  end
926
990
 
927
- # Check planes for this seg range (R_CheckPlane equivalent)
928
- # This may create new visplanes if column ranges would overlap
929
- if @current_floor_plane
930
- @current_floor_plane = check_plane(@current_floor_plane, x1, x2)
931
- end
932
- if @current_ceiling_plane
933
- @current_ceiling_plane = check_plane(@current_ceiling_plane, x1, x2)
991
+ # Detect masked middle texture (grates, bars, fences)
992
+ has_masked = false
993
+ @current_masked_cols = nil
994
+ if back_sector && sidedef
995
+ mid_tex = sidedef.middle_texture
996
+ if mid_tex && mid_tex != '-' && !mid_tex.empty?
997
+ has_masked = true
998
+ @current_masked_cols = Array.new(x2 - x1 + 1)
999
+ # Force silhouette so sprites get clipped against this seg
1000
+ if (silhouette & SIL_TOP) == 0
1001
+ silhouette |= SIL_TOP
1002
+ tsilheight = -Float::INFINITY
1003
+ end
1004
+ if (silhouette & SIL_BOTTOM) == 0
1005
+ silhouette |= SIL_BOTTOM
1006
+ bsilheight = Float::INFINITY
1007
+ end
1008
+ end
934
1009
  end
935
1010
 
1011
+ # Check planes for this seg range (R_CheckPlane equivalent)
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
1014
+
936
1015
  (x1..x2).each do |x|
937
1016
  next if @ceiling_clip[x] >= @floor_clip[x] - 1
938
1017
 
939
1018
  # Screen-space interpolation for distance
940
- t = sx2 != sx1 ? (x - sx1) / (sx2 - sx1) : 0
1019
+ t = sx2 == sx1 ? 0 : (x - sx1) / (sx2 - sx1)
941
1020
  t = t.clamp(0.0, 1.0)
942
1021
 
943
1022
  if dist1 > 0 && dist2 > 0
944
- inv_dist = (1.0 - t) / dist1 + t / dist2
1023
+ inv_dist = ((1.0 - t) / dist1) + (t / dist2)
945
1024
  dist = 1.0 / inv_dist
946
1025
  else
947
1026
  dist = dist1 > 0 ? dist1 : dist2
@@ -949,13 +1028,13 @@ module Doom
949
1028
 
950
1029
  # Ray-seg intersection for texture column
951
1030
  # s = (E * x + F) / (A * x + B) gives position along seg in world units
952
- denom = tex_a * x + tex_b
953
- if denom.abs < 0.001
954
- s = 0.0
955
- else
956
- s = (tex_e * x + tex_f) / denom
957
- end
958
- 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
959
1038
 
960
1039
  # Skip if too close
961
1040
  next if dist < 1
@@ -971,198 +1050,207 @@ module Doom
971
1050
  # Chocolate Doom rounds ceiling UP and floor DOWN to avoid 1-pixel gaps:
972
1051
  # yl = (topfrac+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for front ceiling)
973
1052
  # yh = bottomfrac>>HEIGHTBITS (floor for front floor)
974
- front_ceil_y = (HALF_HEIGHT - front_ceil * scale).ceil
975
- 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
976
1055
 
977
1056
  # Clamp to current clip bounds
978
1057
  ceil_y = [front_ceil_y, @ceiling_clip[x] + 1].max
979
1058
  floor_y = [front_floor_y, @floor_clip[x] - 1].min
980
1059
 
981
1060
  if back_sector
982
- # Two-sided line
983
- back_floor = back_sector.floor_height - @player_z
984
- back_ceil = back_sector.ceiling_height - @player_z
985
-
986
- # Chocolate Doom rounding:
987
- # pixhigh>>HEIGHTBITS (truncate for back ceiling / upper wall end)
988
- # (pixlow+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for back floor / lower wall start)
989
- back_ceil_y = (HALF_HEIGHT - back_ceil * scale).to_i
990
- back_floor_y = (HALF_HEIGHT - back_floor * scale).ceil
991
-
992
- # Determine visible ceiling/floor boundaries (the opening between sectors)
993
- # high_ceil = top of the opening on screen (max Y = lower world ceiling)
994
- # low_floor = bottom of the opening on screen (min Y = higher world floor)
995
- high_ceil = [ceil_y, back_ceil_y].max
996
- low_floor = [floor_y, back_floor_y].min
997
-
998
- # Check for closed door (no opening between sectors)
999
- # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
1000
- # || backsector->floorheight >= frontsector->ceilingheight
1001
- closed_door = back_sector.ceiling_height <= sector.floor_height ||
1002
- back_sector.floor_height >= sector.ceiling_height
1003
-
1004
- both_sky = sector.ceiling_texture == 'F_SKY1' && back_sector.ceiling_texture == 'F_SKY1'
1005
-
1006
- # Determine whether to mark ceiling/floor visplanes.
1007
- # Match Chocolate Doom: only mark when properties differ, plus force
1008
- # for closed doors. The background fill covers same-property gaps.
1009
- # Chocolate Doom sky hack: "worldtop = worldhigh" makes the front
1010
- # ceiling equal to the back ceiling when both are sky. This prevents
1011
- # the low sky ceiling from clipping walls in adjacent sectors.
1012
- effective_front_ceil = both_sky ? back_sector.ceiling_height : sector.ceiling_height
1013
-
1014
- if closed_door
1015
- should_mark_ceiling = true
1016
- should_mark_floor = true
1017
- else
1018
- should_mark_ceiling = effective_front_ceil != back_sector.ceiling_height ||
1019
- sector.ceiling_texture != back_sector.ceiling_texture ||
1020
- sector.light_level != back_sector.light_level
1021
- should_mark_floor = sector.floor_height != back_sector.floor_height ||
1022
- sector.floor_texture != back_sector.floor_texture ||
1023
- sector.light_level != back_sector.light_level
1024
- end
1061
+ draw_two_sided_seg_column(x, x1, ceil_y, floor_y, scale, dist, tex_col,
1062
+ sector, back_sector, sidedef, linedef)
1063
+ else
1064
+ draw_one_sided_seg_column(x, ceil_y, floor_y, scale, dist, tex_col,
1065
+ sector, sidedef, linedef)
1066
+ end
1067
+ end
1025
1068
 
1026
- # Mark ceiling visplane
1027
- if @current_ceiling_plane && should_mark_ceiling
1028
- mark_top = @ceiling_clip[x] + 1
1029
- mark_bottom = ceil_y - 1
1030
- mark_bottom = [@floor_clip[x] - 1, mark_bottom].min
1031
- if mark_top <= mark_bottom
1032
- @current_ceiling_plane.mark(x, mark_top, mark_bottom)
1033
- end
1034
- end
1069
+ # Save drawseg for sprite clipping and masked rendering
1070
+ if silhouette != SIL_NONE || has_masked
1071
+ sprtopclip = @ceiling_clip[x1..x2].dup
1072
+ sprbottomclip = @floor_clip[x1..x2].dup
1073
+ scalestep = x2 > x1 ? (scale2 - scale1) / (x2 - x1) : 0
1035
1074
 
1036
- # Mark floor visplane
1037
- if @current_floor_plane && should_mark_floor
1038
- mark_top = floor_y + 1
1039
- mark_bottom = @floor_clip[x] - 1
1040
- mark_top = [@ceiling_clip[x] + 1, mark_top].max
1041
- if mark_top <= mark_bottom
1042
- @current_floor_plane.mark(x, mark_top, mark_bottom)
1043
- end
1044
- end
1075
+ drawseg = Drawseg.new(
1076
+ x1, x2,
1077
+ scale1, scale2, scalestep,
1078
+ silhouette,
1079
+ bsilheight, tsilheight,
1080
+ sprtopclip, sprbottomclip,
1081
+ seg,
1082
+ has_masked ? @current_masked_cols : nil,
1083
+ sector, back_sector, sidedef,
1084
+ dist1, dist2, sx1.to_f, sx2.to_f
1085
+ )
1086
+ @drawsegs << drawseg
1087
+ end
1088
+ @current_masked_cols = nil
1089
+ end
1045
1090
 
1046
- # Upper wall (ceiling step down) - skip if both sectors have sky
1047
- if !both_sky && sector.ceiling_height > back_sector.ceiling_height
1048
- if linedef.upper_unpegged?
1049
- upper_tex_y = sidedef.y_offset
1050
- else
1051
- texture = @textures[sidedef.upper_texture]
1052
- tex_height = texture ? texture.height : 128
1053
- upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
1054
- end
1055
- draw_wall_column_ex(x, ceil_y, back_ceil_y, sidedef.upper_texture, dist,
1056
- sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height, back_sector.ceiling_height)
1057
- end
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
1058
1132
 
1059
- # Lower wall (floor step up)
1060
- if sector.floor_height < back_sector.floor_height
1061
- if linedef.lower_unpegged?
1062
- lower_tex_y = sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
1063
- else
1064
- lower_tex_y = sidedef.y_offset
1065
- end
1066
- draw_wall_column_ex(x, back_floor_y, floor_y, sidedef.lower_texture, dist,
1067
- sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
1068
- end
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
1069
1140
 
1070
- # Update clip bounds
1071
- if closed_door
1072
- @wall_depth[x] = [@wall_depth[x], dist].min
1073
- @ceiling_clip[x] = SCREEN_HEIGHT
1074
- @floor_clip[x] = -1
1075
- else
1076
- # Ceiling clip (uses effective_front_ceil for sky hack)
1077
- if effective_front_ceil > back_sector.ceiling_height
1078
- @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
1079
- elsif effective_front_ceil < back_sector.ceiling_height
1080
- @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1081
- elsif sector.ceiling_texture != back_sector.ceiling_texture ||
1082
- sector.light_level != back_sector.light_level
1083
- @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1084
- end
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
1085
1148
 
1086
- # Floor clip
1087
- if sector.floor_height < back_sector.floor_height
1088
- @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
1089
- elsif sector.floor_height > back_sector.floor_height
1090
- # Chocolate Doom: else if (markfloor) floorclip = yh + 1
1091
- @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1092
- elsif sector.floor_texture != back_sector.floor_texture ||
1093
- sector.light_level != back_sector.light_level
1094
- @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1095
- end
1096
- end
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
1097
1153
  else
1098
- # One-sided (solid) wall
1099
- # Mark ceiling visplane (from previous clip to wall's ceiling)
1100
- # Clamp to floor_clip to prevent ceiling bleeding through portal openings
1101
- if @current_ceiling_plane
1102
- mark_top = @ceiling_clip[x] + 1
1103
- mark_bottom = [ceil_y - 1, @floor_clip[x] - 1].min
1104
- if mark_top <= mark_bottom
1105
- @current_ceiling_plane.mark(x, mark_top, mark_bottom)
1106
- end
1107
- end
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
1108
1161
 
1109
- # Mark floor visplane (from wall's floor to previous floor clip)
1110
- # Clamp to ceiling_clip to prevent floor bleeding through portal openings
1111
- if @current_floor_plane
1112
- mark_top = [floor_y + 1, @ceiling_clip[x] + 1].max
1113
- mark_bottom = @floor_clip[x] - 1
1114
- if mark_top <= mark_bottom
1115
- @current_floor_plane.mark(x, mark_top, mark_bottom)
1116
- end
1117
- end
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
1118
1172
 
1119
- # Draw wall (from clipped ceiling to clipped floor)
1120
- # Middle texture Y offset depends on DONTPEGBOTTOM flag
1121
- # With DONTPEGBOTTOM: texture bottom aligns with floor
1122
- # Without: texture top aligns with ceiling
1123
- if linedef.lower_unpegged?
1124
- texture = @textures[sidedef.middle_texture]
1125
- tex_height = texture ? texture.height : 128
1126
- mid_tex_y = sidedef.y_offset + tex_height - (sector.ceiling_height - sector.floor_height)
1127
- else
1128
- mid_tex_y = sidedef.y_offset
1129
- end
1130
- draw_wall_column_ex(x, ceil_y, floor_y, sidedef.middle_texture, dist,
1131
- sector.light_level, tex_col, mid_tex_y, scale, sector.ceiling_height, sector.floor_height)
1173
+ # Store masked texture column for deferred rendering (grates, bars)
1174
+ @current_masked_cols[x - x1] = tex_col if @current_masked_cols
1132
1175
 
1133
- # Track wall depth for sprite clipping (solid wall occludes this column)
1134
- @wall_depth[x] = [@wall_depth[x], dist].min
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
1135
1191
 
1136
- # Fully occluded
1137
- @ceiling_clip[x] = SCREEN_HEIGHT
1138
- @floor_clip[x] = -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
1139
1201
  end
1140
1202
  end
1203
+ end
1141
1204
 
1142
- # Save drawseg for sprite clipping (after columns are rendered)
1143
- # Copy the clip arrays for the segment's screen range
1144
- if silhouette != SIL_NONE
1145
- sprtopclip = @ceiling_clip[x1..x2].dup
1146
- sprbottomclip = @floor_clip[x1..x2].dup
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
1216
+ end
1147
1217
 
1148
- drawseg = Drawseg.new(
1149
- x1, x2,
1150
- scale1, scale2,
1151
- silhouette,
1152
- bsilheight, tsilheight,
1153
- sprtopclip, sprbottomclip,
1154
- seg
1155
- )
1156
- @drawsegs << drawseg
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
1157
1224
  end
1225
+
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)
1239
+
1240
+ # Track wall depth for sprite clipping (solid wall occludes this column)
1241
+ @wall_depth[x] = [@wall_depth[x], dist].min
1242
+
1243
+ # Fully occluded
1244
+ @ceiling_clip[x] = SCREEN_HEIGHT
1245
+ @floor_clip[x] = -1
1158
1246
  end
1159
1247
 
1160
1248
  # Wall column drawing with proper texture mapping
1161
1249
  # tex_col: texture column (X coordinate in texture)
1162
1250
  # tex_y_start: starting Y coordinate in texture (accounts for pegging)
1163
1251
  # scale: projection scale for this column (projection / distance)
1164
- # world_top, world_bottom: world heights of this wall section
1165
- def draw_wall_column_ex(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top, world_bottom)
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)
1166
1254
  return if y1 > y2
1167
1255
  return if texture_name.nil? || texture_name.empty? || texture_name == '-'
1168
1256
 
@@ -1193,10 +1281,10 @@ module Doom
1193
1281
  tex_step = 1.0 / scale
1194
1282
 
1195
1283
  # Calculate where the unclipped wall top would be on screen
1196
- unclipped_y1 = HALF_HEIGHT - (world_top - @player_z) * scale
1284
+ unclipped_y1 = HALF_HEIGHT - ((world_top - @player_z) * scale)
1197
1285
 
1198
1286
  # Adjust tex_y_start for any clipping at the top
1199
- tex_y_at_y1 = tex_y_start + (y1 - unclipped_y1) * tex_step
1287
+ tex_y_at_y1 = tex_y_start + ((y1 - unclipped_y1) * tex_step)
1200
1288
 
1201
1289
  # Clamp to screen bounds
1202
1290
  y1 = 0 if y1 < 0
@@ -1206,10 +1294,10 @@ module Doom
1206
1294
  y = y1
1207
1295
  while y <= y2
1208
1296
  screen_offset = y - y1
1209
- 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
1210
1298
 
1211
1299
  color = column[tex_y]
1212
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1300
+ framebuffer[(y * SCREEN_WIDTH) + x] = cmap[color] if color
1213
1301
  y += 1
1214
1302
  end
1215
1303
  end
@@ -1307,12 +1395,23 @@ module Doom
1307
1395
  # Get sprite: death frame > walking frame > idle frame
1308
1396
  if @combat && @combat.dead?(thing_idx)
1309
1397
  sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1398
+ next unless sprite # Barrel disappeared after explosion
1310
1399
  elsif @monster_ai
1311
- mon = @monster_ai.monsters.find { |m| m.thing_idx == thing_idx }
1400
+ mon = @monster_ai.monster_by_thing_idx[thing_idx]
1312
1401
  if mon && mon.active
1313
- # Walking animation: cycle through frames A-D based on leveltime
1314
- walk_frame = %w[A B C D][@leveltime / 4 % 4]
1315
- sprite = @sprites.get_frame(thing.type, walk_frame, angle_to_thing, thing.angle)
1402
+ if mon.attacking
1403
+ # Attack animation: show attack frames (E, F, G...)
1404
+ prefix = @sprites.prefix_for(thing.type)
1405
+ atk_frames = Game::MonsterAI::ATTACK_FRAMES[prefix]
1406
+ if atk_frames
1407
+ frame_idx = (mon.attack_frame_tic / Game::MonsterAI::ATTACK_FRAME_TICS).clamp(0, atk_frames.size - 1)
1408
+ sprite = @sprites.get_frame(thing.type, atk_frames[frame_idx], angle_to_thing, thing.angle)
1409
+ end
1410
+ else
1411
+ # Walking animation: cycle through frames A-D
1412
+ walk_frame = %w[A B C D][@leveltime / 4 % 4]
1413
+ sprite = @sprites.get_frame(thing.type, walk_frame, angle_to_thing, thing.angle)
1414
+ end
1316
1415
  end
1317
1416
  sprite ||= @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1318
1417
  else
@@ -1331,50 +1430,287 @@ module Doom
1331
1430
  visible_sprites << VisibleSprite.new(thing, sprite, view_x, view_y, dist, screen_x)
1332
1431
  end
1333
1432
 
1433
+ collect_player_sprites(visible_sprites)
1434
+
1334
1435
  # Sort by distance (back to front for proper overdraw)
1335
1436
  visible_sprites.sort_by! { |s| -s.dist }
1336
1437
 
1337
- # Draw each sprite
1438
+ # Draw each sprite with drawseg interleaving
1338
1439
  visible_sprites.each do |vs|
1339
- draw_sprite(vs)
1440
+ draw_sprite_with_masking(vs)
1441
+ end
1442
+
1443
+ # Draw remaining masked segs not triggered by sprites
1444
+ @drawsegs.reverse_each do |ds|
1445
+ next unless ds.maskedtexturecol
1446
+
1447
+ render_masked_seg_range(ds, ds.x1, ds.x2)
1340
1448
  end
1341
1449
 
1342
- # Draw projectiles and explosions
1343
- render_projectiles if @combat
1450
+ # Draw projectiles, explosions, and bullet puffs
1451
+ return unless @combat
1452
+
1453
+ render_projectiles
1454
+ render_puffs
1455
+ end
1456
+
1457
+ # Chocolate Doom R_DrawMasked: interleave sprites with masked drawsegs
1458
+ def draw_masked
1459
+ render_sprites
1344
1460
  end
1345
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
+
1516
+ # Draw sprite, rendering masked drawsegs behind it first (R_DrawSprite)
1517
+ def draw_sprite_with_masking(spr)
1518
+ sprite_scale = @projection / spr.dist
1519
+
1520
+ # Scan drawsegs newest to oldest (nearest to farthest)
1521
+ @drawsegs.reverse_each do |ds|
1522
+ next if ds.x1 > spr.screen_x.to_i + spr.sprite.width || ds.x2 < spr.screen_x.to_i - spr.sprite.width
1523
+ next if ds.silhouette == SIL_NONE && ds.maskedtexturecol.nil?
1524
+
1525
+ ds_max_scale = [ds.scale1, ds.scale2].max
1526
+ [ds.scale1, ds.scale2].min
1527
+
1528
+ # Is the drawseg behind the sprite?
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)
1536
+ end
1537
+ next # Don't clip against things behind
1538
+ end
1539
+
1540
+ draw_sprite(spr)
1541
+ end
1542
+
1543
+ # Chocolate Doom R_RenderMaskedSegRange
1544
+ def render_masked_seg_range(ds, rx1, rx2)
1545
+ return unless ds.maskedtexturecol
1546
+ return unless ds.sidedef
1547
+
1548
+ mid_tex_name = ds.sidedef.middle_texture
1549
+ return if mid_tex_name.nil? || mid_tex_name == '-' || mid_tex_name.empty?
1550
+
1551
+ texture = @textures[anim_texture(mid_tex_name)]
1552
+ return unless texture
1553
+
1554
+ front = ds.frontsector
1555
+ back = ds.backsector
1556
+ return unless front && back
1557
+
1558
+ # Texture anchoring (matching Chocolate Doom)
1559
+ if ds.curline && @map.linedefs[ds.curline.linedef].lower_unpegged?
1560
+ higher_floor = [front.floor_height, back.floor_height].max
1561
+ dc_texturemid = higher_floor + texture.height - @player_z
1562
+ else
1563
+ lower_ceiling = [front.ceiling_height, back.ceiling_height].min
1564
+ dc_texturemid = lower_ceiling - @player_z
1565
+ end
1566
+ dc_texturemid += ds.sidedef.y_offset
1567
+
1568
+ light_level = front.light_level
1569
+
1570
+ # Iterate columns
1571
+ rx1 = [rx1, ds.x1].max
1572
+ rx2 = [rx2, ds.x2].min
1573
+
1574
+ (rx1..rx2).each do |x|
1575
+ col_idx = x - ds.x1
1576
+ next if col_idx < 0 || col_idx >= ds.maskedtexturecol.size
1577
+
1578
+ tex_col = ds.maskedtexturecol[col_idx]
1579
+ next unless tex_col # nil = already drawn or empty
1580
+
1581
+ # Per-column distance using same 1/z interpolation as wall renderer
1582
+ if ds.dist1 && ds.dist2 && ds.dist1 > 0 && ds.dist2 > 0 && ds.sx2 != ds.sx1
1583
+ t = ((x - ds.sx1) / (ds.sx2 - ds.sx1)).clamp(0.0, 1.0)
1584
+ inv_dist = ((1.0 - t) / ds.dist1) + (t / ds.dist2)
1585
+ col_dist = 1.0 / inv_dist
1586
+ else
1587
+ col_dist = ds.dist1 || 1.0
1588
+ end
1589
+ next if col_dist < 1
1590
+
1591
+ spryscale = @projection / col_dist
1592
+ spryscale = [spryscale, 64.0].min
1593
+
1594
+ # Screen Y of texture top
1595
+ sprtopscreen = HALF_HEIGHT - (dc_texturemid * spryscale)
1596
+
1597
+ # Get clip bounds from drawseg
1598
+ clip_idx = x - ds.x1
1599
+ mceilingclip = ds.sprtopclip[clip_idx]
1600
+ mfloorclip = ds.sprbottomclip[clip_idx]
1601
+
1602
+ # Calculate column draw range
1603
+ tex_x = tex_col.to_i % texture.width
1604
+ column = texture.column_pixels(tex_x)
1605
+ next unless column
1606
+
1607
+ iscale = 1.0 / spryscale
1608
+
1609
+ light = calculate_light(light_level, col_dist)
1610
+ cmap = @colormap.maps[light]
1611
+
1612
+ # Draw each non-nil run of pixels (transparency)
1613
+ tex_height = texture.height
1614
+ y_start = nil
1615
+ (0..tex_height).each do |ty|
1616
+ color = ty < tex_height ? column[ty] : nil
1617
+ if color
1618
+ y_start ||= ty
1619
+ elsif y_start
1620
+ # Draw run from y_start to ty-1
1621
+ top_y = (sprtopscreen + (y_start * spryscale)).to_i
1622
+ bot_y = (sprtopscreen + (ty * spryscale)).to_i - 1
1623
+ top_y = [top_y, mceilingclip + 1].max
1624
+ bot_y = [bot_y, mfloorclip - 1].min
1625
+
1626
+ if top_y <= bot_y
1627
+ top_y = [top_y, 0].max
1628
+ bot_y = [bot_y, SCREEN_HEIGHT - 1].min
1629
+ tex_frac = y_start + ((top_y - (sprtopscreen + (y_start * spryscale))) * iscale)
1630
+ y = top_y
1631
+ while y <= bot_y
1632
+ t = (tex_frac.to_i % tex_height)
1633
+ c = column[t]
1634
+ @framebuffer[(y * SCREEN_WIDTH) + x] = cmap[c] if c
1635
+ tex_frac += iscale
1636
+ y += 1
1637
+ end
1638
+ end
1639
+ y_start = nil
1640
+ end
1641
+ end
1642
+
1643
+ # Mark column as drawn
1644
+ ds.maskedtexturecol[col_idx] = nil
1645
+ end
1646
+ end
1647
+
1648
+ # Stub for projectiles/explosions that carries a z height
1649
+ ProjectileStub = Struct.new(:x, :y, :angle, :type, :flags, :z)
1650
+
1346
1651
  def render_projectiles
1347
- # Render rockets in flight
1652
+ # Render all projectiles in flight
1348
1653
  @combat.projectiles.each do |proj|
1349
1654
  view_x, view_y = transform_point(proj.x, proj.y)
1350
1655
  next if view_y <= 0
1351
1656
 
1352
- # Rocket has rotations 1-8 based on travel direction relative to viewer
1657
+ prefix = proj.sprite_prefix || 'MISL'
1658
+
1659
+ # Try rotation-based sprite first (rockets, baron fireballs)
1353
1660
  rocket_angle = Math.atan2(proj.dy, proj.dx)
1354
1661
  viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
1355
1662
  angle_diff = (viewer_angle - rocket_angle) % (2 * Math::PI)
1356
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
1357
- rocket_sprite = @sprites.send(:load_sprite_frame, 'MISL', 'A', rotation)
1358
- next unless rocket_sprite
1663
+ rotation = (((angle_diff + (Math::PI / 8)) / (Math::PI / 4)).to_i % 8) + 1
1664
+
1665
+ # Animate flight: cycle A/B frames
1666
+ flight_frame = %w[A B][@leveltime / 4 % 2]
1667
+ proj_sprite = @sprites.send(:load_sprite_frame, prefix, flight_frame, rotation)
1668
+ proj_sprite ||= @sprites.send(:load_sprite_frame, prefix, flight_frame, 0)
1669
+ proj_sprite ||= @sprites.send(:load_sprite_frame, prefix, 'A', 0)
1670
+ next unless proj_sprite
1359
1671
 
1360
1672
  screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1361
- thing_stub = Map::Thing.new(proj.x, proj.y, 0, 0, 0)
1362
- visible = VisibleSprite.new(thing_stub, rocket_sprite, view_x, view_y, view_y, screen_x)
1673
+ stub = ProjectileStub.new(proj.x, proj.y, 0, 0, 0, proj.z)
1674
+ visible = VisibleSprite.new(stub, proj_sprite, view_x, view_y, view_y, screen_x)
1363
1675
  draw_sprite(visible)
1364
1676
  end
1365
1677
 
1366
- # Render explosions (frames B, C, D - all rotation 0)
1678
+ # Render explosions at projectile height
1367
1679
  @combat.explosions.each do |expl|
1368
1680
  view_x, view_y = transform_point(expl[:x], expl[:y])
1369
1681
  next if view_y <= 0
1682
+
1370
1683
  elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
1371
1684
  frame_idx = (elapsed / 4).clamp(0, 2)
1372
- frame_letter = %w[B C D][frame_idx]
1373
- expl_sprite = @sprites.send(:load_sprite_frame, 'MISL', frame_letter, 0)
1685
+
1686
+ prefix = expl[:sprite] || 'MISL'
1687
+ frame_letter = prefix == 'MISL' ? %w[B C D][frame_idx] : %w[C D E][frame_idx]
1688
+
1689
+ expl_sprite = @sprites.send(:load_sprite_frame, prefix, frame_letter, 0)
1374
1690
  next unless expl_sprite
1691
+
1375
1692
  screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1376
- thing_stub = Map::Thing.new(expl[:x], expl[:y], 0, 0, 0)
1377
- visible = VisibleSprite.new(thing_stub, expl_sprite, view_x, view_y, view_y, screen_x)
1693
+ expl_z = expl[:z] || @player_z
1694
+ stub = ProjectileStub.new(expl[:x], expl[:y], 0, 0, 0, expl_z)
1695
+ visible = VisibleSprite.new(stub, expl_sprite, view_x, view_y, view_y, screen_x)
1696
+ draw_sprite(visible)
1697
+ end
1698
+ end
1699
+
1700
+ def render_puffs
1701
+ @combat.puffs.each do |puff|
1702
+ view_x, view_y = transform_point(puff[:x], puff[:y])
1703
+ next if view_y <= 0
1704
+
1705
+ elapsed = @combat.instance_variable_get(:@tic) - puff[:tic]
1706
+ frame_idx = (elapsed / 3).clamp(0, 3)
1707
+ frame_letter = %w[A B C D][frame_idx]
1708
+ puff_sprite = @sprites.send(:load_sprite_frame, 'PUFF', frame_letter, 0)
1709
+ next unless puff_sprite
1710
+
1711
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1712
+ stub = ProjectileStub.new(puff[:x], puff[:y], 0, 0, 0, puff[:z])
1713
+ visible = VisibleSprite.new(stub, puff_sprite, view_x, view_y, view_y, screen_x)
1378
1714
  draw_sprite(visible)
1379
1715
  end
1380
1716
  end
@@ -1400,7 +1736,7 @@ module Doom
1400
1736
  light = calculate_light(light_level, dist)
1401
1737
 
1402
1738
  # Sprite screen bounds
1403
- sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
1739
+ sprite_left = (screen_x - (sprite.left_offset * sprite_scale)).to_i
1404
1740
  sprite_right = sprite_left + sprite_screen_width - 1
1405
1741
 
1406
1742
  # Clamp to screen
@@ -1408,14 +1744,26 @@ module Doom
1408
1744
  x2 = [sprite_right, SCREEN_WIDTH - 1].min
1409
1745
  return if x1 > x2
1410
1746
 
1411
- # Calculate sprite world Z positions
1747
+ # Calculate sprite world Z positions (matching Chocolate Doom R_ProjectSprite)
1748
+ # gzt = mobj->z + spritetopoffset (top of sprite)
1749
+ # gz = gzt - spriteheight (bottom of sprite, 1:1 pixel:unit)
1412
1750
  thing_floor = sector ? sector.floor_height : 0
1413
- sprite_gz = thing_floor # bottom of sprite in world Z
1414
- sprite_gzt = thing_floor + sprite.top_offset # top of sprite in world Z
1415
-
1416
- # Initialize per-column clip arrays (-2 = not yet clipped)
1417
- clipbot = Array.new(SCREEN_WIDTH, -2)
1418
- cliptop = Array.new(SCREEN_WIDTH, -2)
1751
+ base_z = if thing.is_a?(ProjectileStub) && thing.z
1752
+ thing.z
1753
+ else
1754
+ thing_floor
1755
+ end
1756
+ sprite_gzt = base_z + sprite.top_offset
1757
+ sprite_gz = sprite_gzt - sprite.height
1758
+
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
1419
1767
 
1420
1768
  # Scan drawsegs from back to front for obscuring segs
1421
1769
  @drawsegs.reverse_each do |ds|
@@ -1447,26 +1795,22 @@ module Doom
1447
1795
  silhouette = ds.silhouette
1448
1796
 
1449
1797
  # If sprite bottom is at or above the bottom silhouette height, don't clip bottom
1450
- if sprite_gz >= ds.bsilheight
1451
- silhouette &= ~SIL_BOTTOM
1452
- end
1798
+ silhouette &= ~SIL_BOTTOM if sprite_gz >= ds.bsilheight
1453
1799
 
1454
1800
  # If sprite top is at or below the top silhouette height, don't clip top
1455
- if sprite_gzt <= ds.tsilheight
1456
- silhouette &= ~SIL_TOP
1457
- end
1801
+ silhouette &= ~SIL_TOP if sprite_gzt <= ds.tsilheight
1458
1802
 
1459
1803
  # Apply clipping for each column in the overlap
1460
1804
  (r1..r2).each do |x|
1461
1805
  # Index into drawseg's clip arrays (0-based from ds.x1)
1462
1806
  ds_idx = x - ds.x1
1463
1807
 
1464
- if (silhouette & SIL_BOTTOM) != 0 && clipbot[x] == -2
1465
- 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]
1466
1810
  end
1467
1811
 
1468
- if (silhouette & SIL_TOP) != 0 && cliptop[x] == -2
1469
- 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]
1470
1814
  end
1471
1815
  end
1472
1816
  end
@@ -1477,11 +1821,21 @@ module Doom
1477
1821
  cliptop[x] = -1 if cliptop[x] == -2
1478
1822
  end
1479
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
+
1480
1834
  # Calculate sprite screen Y positions
1481
1835
  sprite_top_world = sprite_gzt - @player_z
1482
1836
  sprite_bottom_world = sprite_gz - @player_z
1483
- sprite_top_screen = (HALF_HEIGHT - sprite_top_world * sprite_scale).to_i
1484
- 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
1485
1839
 
1486
1840
  # Cache for inner loop
1487
1841
  framebuffer = @framebuffer
@@ -1519,7 +1873,7 @@ module Doom
1519
1873
  next unless color
1520
1874
 
1521
1875
  # Apply lighting and write directly to framebuffer
1522
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1876
+ framebuffer[(y * SCREEN_WIDTH) + x] = cmap[color]
1523
1877
  end
1524
1878
  end
1525
1879
  end