doom 0.3.0 → 0.5.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.
@@ -18,7 +18,12 @@ module Doom
18
18
  # Matches Chocolate Doom's drawseg_t structure
19
19
  Drawseg = Struct.new(:x1, :x2, :scale1, :scale2,
20
20
  :silhouette, :bsilheight, :tsilheight,
21
- :sprtopclip, :sprbottomclip)
21
+ :sprtopclip, :sprbottomclip,
22
+ :curline) # seg for point-on-side test
23
+
24
+ # VisibleSprite stores sprite data for sorting and rendering
25
+ # Struct is faster than Hash for fixed-field data
26
+ VisibleSprite = Struct.new(:thing, :sprite, :view_x, :view_y, :dist, :screen_x)
22
27
 
23
28
  # Visplane stores floor/ceiling rendering info for a sector
24
29
  # Matches Chocolate Doom's visplane_t structure from r_plane.c
@@ -48,7 +53,7 @@ module Doom
48
53
  class Renderer
49
54
  attr_reader :framebuffer
50
55
 
51
- def initialize(wad, map, textures, palette, colormap, flats, sprites = nil)
56
+ def initialize(wad, map, textures, palette, colormap, flats, sprites = nil, animations = nil)
52
57
  @wad = wad
53
58
  @map = map
54
59
  @textures = textures
@@ -56,6 +61,9 @@ module Doom
56
61
  @colormap = colormap
57
62
  @flats = flats.to_h { |f| [f.name, f] }
58
63
  @sprites = sprites
64
+ @animations = animations
65
+ @hidden_things = nil
66
+ @combat = nil
59
67
 
60
68
  @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
61
69
 
@@ -67,6 +75,17 @@ module Doom
67
75
  # Projection constant - distance to projection plane
68
76
  @projection = HALF_WIDTH / Math.tan(FOV * Math::PI / 360.0)
69
77
 
78
+ # Precomputed column data (cached based on player angle)
79
+ @column_cos = Array.new(SCREEN_WIDTH)
80
+ @column_sin = Array.new(SCREEN_WIDTH)
81
+ @cached_player_angle = nil
82
+
83
+ # Column distance scale is constant (doesn't depend on player angle)
84
+ @column_distscale = Array.new(SCREEN_WIDTH) do |x|
85
+ dx = x - HALF_WIDTH
86
+ Math.sqrt(dx * dx + @projection * @projection) / @projection
87
+ end
88
+
70
89
  # Clipping arrays
71
90
  @ceiling_clip = Array.new(SCREEN_WIDTH, -1)
72
91
  @floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
@@ -77,9 +96,103 @@ module Doom
77
96
 
78
97
  # Wall depth array - tracks distance to nearest wall at each column
79
98
  @wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
99
+ @sprite_wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
100
+
101
+ # Preallocated y_slope arrays for floor/ceiling rendering (avoids per-frame allocation)
102
+ @y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
103
+ @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
80
104
  end
81
105
 
82
- attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle
106
+ attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle, :framebuffer
107
+ attr_writer :hidden_things, :combat
108
+
109
+ # Diagnostic: returns info about all sprites and why they are/aren't visible
110
+ def sprite_diagnostics
111
+ return [] unless @sprites
112
+
113
+ results = []
114
+ @map.things.each do |thing|
115
+ prefix = @sprites.prefix_for(thing.type)
116
+ next unless prefix
117
+
118
+ info = { type: thing.type, x: thing.x, y: thing.y, prefix: prefix }
119
+
120
+ view_x, view_y = transform_point(thing.x, thing.y)
121
+ info[:view_x] = view_x.round(1)
122
+ info[:view_y] = view_y.round(1)
123
+
124
+ if view_y <= 0
125
+ info[:status] = "behind_player"
126
+ results << info
127
+ next
128
+ end
129
+
130
+ dist = view_y
131
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
132
+ info[:screen_x] = screen_x.round(1)
133
+ info[:dist] = dist.round(1)
134
+
135
+ dx = thing.x - @player_x
136
+ dy = thing.y - @player_y
137
+ angle_to_thing = Math.atan2(dy, dx)
138
+ sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
139
+ unless sprite
140
+ info[:status] = "no_sprite_frame"
141
+ results << info
142
+ next
143
+ end
144
+
145
+ sprite_scale = @projection / dist
146
+ sprite_half_width = (sprite.width * @projection / dist / 2).to_i
147
+ info[:sprite_scale] = sprite_scale.round(3)
148
+
149
+ if screen_x + sprite_half_width < 0
150
+ info[:status] = "off_screen_left"
151
+ elsif screen_x - sprite_half_width >= SCREEN_WIDTH
152
+ info[:status] = "off_screen_right"
153
+ else
154
+ # Check drawseg clipping
155
+ sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
156
+ sprite_right = sprite_left + (sprite.width * sprite_scale).to_i - 1
157
+ x1 = [sprite_left, 0].max
158
+ x2 = [sprite_right, SCREEN_WIDTH - 1].min
159
+
160
+ sector = @map.sector_at(thing.x, thing.y)
161
+ thing_floor = sector ? sector.floor_height : 0
162
+ sprite_gz = thing_floor
163
+ sprite_gzt = thing_floor + sprite.top_offset
164
+
165
+ clipping_segs = []
166
+ @drawsegs.reverse_each do |ds|
167
+ next if ds.x1 > x2 || ds.x2 < x1
168
+ next if ds.silhouette == SIL_NONE
169
+
170
+ lowscale = [ds.scale1, ds.scale2].min
171
+ highscale = [ds.scale1, ds.scale2].max
172
+
173
+ if highscale < sprite_scale
174
+ next
175
+ elsif lowscale < sprite_scale
176
+ next unless point_on_seg_side(thing.x, thing.y, ds.curline)
177
+ end
178
+
179
+ clipping_segs << {
180
+ x1: ds.x1, x2: ds.x2,
181
+ scale: "#{ds.scale1.round(3)}..#{ds.scale2.round(3)}",
182
+ sil: ds.silhouette
183
+ }
184
+ end
185
+
186
+ info[:screen_range] = "#{x1}..#{x2}"
187
+ info[:clipping_segs] = clipping_segs.size
188
+ info[:clipping_detail] = clipping_segs
189
+ info[:status] = "visible"
190
+ end
191
+
192
+ results << info
193
+ end
194
+ results
195
+ end
83
196
 
84
197
  def set_player(x, y, z, angle)
85
198
  @player_x = x.to_f
@@ -111,11 +224,15 @@ module Doom
111
224
  # Precompute column angles for floor/ceiling rendering
112
225
  precompute_column_data
113
226
 
114
- # Draw floor/ceiling background first (will be partially overwritten by walls)
227
+ # Draw player's sector floor/ceiling as background fallback.
228
+ # Visplanes (with correct per-sector lighting) overwrite this for sectors
229
+ # with different properties. This only remains visible at gaps between
230
+ # same-property sectors where the light level matches anyway.
115
231
  draw_floor_ceiling_background
116
232
 
117
233
  # Initialize visplanes for tracking visible floor/ceiling spans
118
234
  @visplanes = []
235
+ @visplane_hash = {} # Hash for O(1) lookup by (height, texture, light_level, is_ceiling)
119
236
 
120
237
  # Initialize drawsegs for sprite clipping
121
238
  @drawsegs = []
@@ -126,27 +243,26 @@ module Doom
126
243
  # Draw visplanes for sectors different from background
127
244
  draw_all_visplanes
128
245
 
129
- # Save wall clip arrays for sprite clipping
130
- @sprite_ceiling_clip = @ceiling_clip.dup
131
- @sprite_floor_clip = @floor_clip.dup
132
- @sprite_wall_depth = @wall_depth.dup
246
+ # Save wall clip arrays for sprite clipping (reuse preallocated arrays)
247
+ @sprite_ceiling_clip.replace(@ceiling_clip)
248
+ @sprite_floor_clip.replace(@floor_clip)
249
+ @sprite_wall_depth.replace(@wall_depth)
133
250
 
134
251
  # Render sprites
135
252
  render_sprites if @sprites
136
253
  end
137
254
 
138
255
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
256
+ # Cached: only recomputes sin/cos when player angle changes
139
257
  def precompute_column_data
140
- @column_cos ||= Array.new(SCREEN_WIDTH)
141
- @column_sin ||= Array.new(SCREEN_WIDTH)
142
- @column_distscale ||= Array.new(SCREEN_WIDTH)
258
+ return if @cached_player_angle == @player_angle
259
+
260
+ @cached_player_angle = @player_angle
143
261
 
144
262
  SCREEN_WIDTH.times do |x|
145
- dx = x - HALF_WIDTH
146
- column_angle = @player_angle - Math.atan2(dx, @projection)
263
+ column_angle = @player_angle - Math.atan2(x - HALF_WIDTH, @projection)
147
264
  @column_cos[x] = Math.cos(column_angle)
148
265
  @column_sin[x] = Math.sin(column_angle)
149
- @column_distscale[x] = Math.sqrt(dx * dx + @projection * @projection) / @projection
150
266
  end
151
267
  end
152
268
 
@@ -223,7 +339,7 @@ module Doom
223
339
  def draw_span(plane, y, x1, x2)
224
340
  return if x1.nil? || x1 > x2 || y < 0 || y >= SCREEN_HEIGHT
225
341
 
226
- flat = @flats[plane.texture]
342
+ flat = @flats[anim_flat(plane.texture)]
227
343
  return unless flat
228
344
 
229
345
  # Distance from horizon (y=100 for 200-high screen)
@@ -249,6 +365,7 @@ module Doom
249
365
  player_x = @player_x
250
366
  neg_player_y = -@player_y
251
367
  row_offset = y * SCREEN_WIDTH
368
+ flat_pixels = flat.pixels
252
369
 
253
370
  # Clamp to screen bounds
254
371
  x1 = 0 if x1 < 0
@@ -260,13 +377,22 @@ module Doom
260
377
  ray_dist = perp_dist * column_distscale[x]
261
378
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
262
379
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
263
- color = flat[tex_x, tex_y] || 0
380
+ color = flat_pixels[tex_y * 64 + tex_x]
264
381
  framebuffer[row_offset + x] = cmap[color]
265
382
  x += 1
266
383
  end
267
384
  end
268
385
 
269
386
  # Render sky ceiling as columns (column-based like walls, not spans)
387
+ # Sky texture mid-point: texel row at screen center.
388
+ # Chocolate Doom: skytexturemid = SCREENHEIGHT/2 = 100 (for 200px screen).
389
+ # Scale factor: our 240px screen maps to DOOM's 200px sky coordinates.
390
+ SKY_TEXTUREMID = 100.0
391
+ SKY_YSCALE = 200.0 / SCREEN_HEIGHT # 0.833 - maps our pixels to DOOM's 200px space
392
+ # Chocolate Doom: ANGLETOSKYSHIFT=22 gives 4 sky repetitions per 360 degrees.
393
+ # Full circle (2pi) * 512/pi = 1024 columns, masked to 256 = 4 repetitions.
394
+ SKY_XSCALE = 512.0 / Math::PI
395
+
270
396
  def draw_sky_plane(plane)
271
397
  sky_texture = @textures['SKY1']
272
398
  return unless sky_texture
@@ -290,31 +416,31 @@ module Doom
290
416
  y1 = 0 if y1 < 0
291
417
  y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
292
418
 
293
- # Sky X based on view angle (wraps around 256 degrees)
419
+ # Sky X: 4 repetitions per 360 degrees (matching ANGLETOSKYSHIFT=22)
294
420
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
295
- sky_x = ((column_angle * 256 / Math::PI).to_i & 255) % sky_width
421
+ sky_x = (column_angle * SKY_XSCALE).to_i % sky_width
296
422
  column = sky_texture.column_pixels(sky_x)
297
423
  next unless column
298
424
 
425
+ # Sky Y: texel = skytexturemid + (y - centery) * scale
426
+ # Maps texel 0 to screen top, texel 100 to horizon (1:1 for DOOM's 200px)
299
427
  (y1..y2).each do |y|
300
- color = column[y % sky_height] || 0
428
+ tex_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_height
429
+ color = column[tex_y]
301
430
  framebuffer[y * SCREEN_WIDTH + x] = color
302
431
  end
303
432
  end
304
433
  end
305
434
 
306
435
  def find_or_create_visplane(sector, height, texture, light_level, is_ceiling)
307
- # Find existing visplane with matching properties
308
- plane = @visplanes.find do |vp|
309
- vp.height == height &&
310
- vp.texture == texture &&
311
- vp.light_level == light_level &&
312
- vp.is_ceiling == is_ceiling
313
- end
436
+ # O(1) hash lookup instead of O(n) linear search
437
+ key = [height, texture, light_level, is_ceiling]
438
+ plane = @visplane_hash[key]
314
439
 
315
440
  unless plane
316
441
  plane = Visplane.new(sector, height, texture, light_level, is_ceiling)
317
442
  @visplanes << plane
443
+ @visplane_hash[key] = plane
318
444
  end
319
445
 
320
446
  plane
@@ -371,6 +497,11 @@ module Doom
371
497
  new_plane.minx = start_x
372
498
  new_plane.maxx = stop_x
373
499
  @visplanes << new_plane
500
+
501
+ # Update hash to point to the new plane (for subsequent lookups)
502
+ key = [plane.height, plane.texture, plane.light_level, plane.is_ceiling]
503
+ @visplane_hash[key] = new_plane
504
+
374
505
  new_plane
375
506
  end
376
507
 
@@ -387,16 +518,18 @@ module Doom
387
518
 
388
519
  ceil_height = (default_sector.ceiling_height - @player_z).abs
389
520
  floor_height = (default_sector.floor_height - @player_z).abs
390
- ceil_flat = @flats[default_sector.ceiling_texture]
391
- floor_flat = @flats[default_sector.floor_texture]
521
+ ceil_flat = @flats[anim_flat(default_sector.ceiling_texture)]
522
+ floor_flat = @flats[anim_flat(default_sector.floor_texture)]
523
+ ceil_pixels = ceil_flat&.pixels
524
+ floor_pixels = floor_flat&.pixels
392
525
  is_sky = default_sector.ceiling_texture == 'F_SKY1'
393
526
  sky_texture = is_sky ? @textures['SKY1'] : nil
394
527
  light_level = default_sector.light_level
395
528
  colormap_maps = @colormap.maps
396
529
 
397
- # Precompute y_slope for each row (perpendicular distance)
398
- y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
399
- y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
530
+ # Compute y_slope for each row (perpendicular distance) - reuse preallocated arrays
531
+ y_slope_ceil = @y_slope_ceil
532
+ y_slope_floor = @y_slope_floor
400
533
  (1..HALF_HEIGHT).each do |dy|
401
534
  y_slope_ceil[dy] = ceil_height * projection / dy.to_f
402
535
  y_slope_floor[dy] = floor_height * projection / dy.to_f
@@ -414,14 +547,14 @@ module Doom
414
547
  row_offset = y * SCREEN_WIDTH
415
548
 
416
549
  if is_sky && sky_texture
417
- sky_height = sky_texture.height
418
- sky_width = sky_texture.width
419
- sky_y = y % sky_height
550
+ sky_w = sky_texture.width
551
+ sky_h = sky_texture.height
552
+ sky_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_h
420
553
  x = 0
421
554
  while x < SCREEN_WIDTH
422
555
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
423
- sky_x = ((column_angle * 256 / Math::PI).to_i & 255) % sky_width
424
- color = sky_texture.column_pixels(sky_x)[sky_y] || 0
556
+ sky_x = (column_angle * SKY_XSCALE).to_i % sky_w
557
+ color = sky_texture.column_pixels(sky_x)[sky_y]
425
558
  framebuffer[row_offset + x] = color
426
559
  x += 1
427
560
  end
@@ -431,7 +564,7 @@ module Doom
431
564
  ray_dist = perp_dist * column_distscale[x]
432
565
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
433
566
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
434
- color = ceil_flat[tex_x, tex_y] || 0
567
+ color = ceil_pixels[tex_y * 64 + tex_x]
435
568
  framebuffer[row_offset + x] = cmap[color]
436
569
  x += 1
437
570
  end
@@ -458,7 +591,7 @@ module Doom
458
591
  ray_dist = perp_dist * column_distscale[x]
459
592
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
460
593
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
461
- color = floor_flat[tex_x, tex_y] || 0
594
+ color = floor_pixels[tex_y * 64 + tex_x]
462
595
  framebuffer[row_offset + x] = cmap[color]
463
596
  x += 1
464
597
  end
@@ -471,6 +604,15 @@ module Doom
471
604
 
472
605
  private
473
606
 
607
+ # Translate flat/texture names through animation system
608
+ def anim_flat(name)
609
+ @animations ? @animations.translate_flat(name) : name
610
+ end
611
+
612
+ def anim_texture(name)
613
+ @animations ? @animations.translate_texture(name) : name
614
+ end
615
+
474
616
  def clear_framebuffer
475
617
  @framebuffer.fill(0)
476
618
  end
@@ -492,13 +634,58 @@ module Doom
492
634
 
493
635
  if side == 0
494
636
  render_bsp_node(node.child_right)
495
- render_bsp_node(node.child_left)
637
+ back_bbox = node.bbox_left
638
+ render_bsp_node(node.child_left) if check_bbox(back_bbox)
496
639
  else
497
640
  render_bsp_node(node.child_left)
498
- render_bsp_node(node.child_right)
641
+ back_bbox = node.bbox_right
642
+ render_bsp_node(node.child_right) if check_bbox(back_bbox)
499
643
  end
500
644
  end
501
645
 
646
+ # R_CheckBBox - check if a bounding box is potentially visible.
647
+ # Projects the bbox corners to screen columns and checks if any
648
+ # column in that range is not fully occluded.
649
+ def check_bbox(bbox)
650
+ # Transform all 4 corners to view space
651
+ near = 1.0
652
+ all_in_front = true
653
+ min_sx = SCREEN_WIDTH
654
+ max_sx = -1
655
+
656
+ [[bbox.left, bbox.bottom], [bbox.right, bbox.bottom],
657
+ [bbox.left, bbox.top], [bbox.right, bbox.top]].each do |wx, wy|
658
+ vx, vy = transform_point(wx, wy)
659
+
660
+ if vy < near
661
+ # Any corner behind the near plane - bbox is too close to cull safely
662
+ all_in_front = false
663
+ else
664
+ sx = HALF_WIDTH + (vx * @projection / vy)
665
+ sx_i = sx.to_i
666
+ min_sx = sx_i if sx_i < min_sx
667
+ max_sx = sx_i if sx_i > max_sx
668
+ end
669
+ end
670
+
671
+ # If any corner is behind the near plane, conservatively assume visible.
672
+ # Only cull when all 4 corners are cleanly in front and we can check occlusion.
673
+ return true unless all_in_front
674
+
675
+ min_sx = 0 if min_sx < 0
676
+ max_sx = SCREEN_WIDTH - 1 if max_sx >= SCREEN_WIDTH
677
+ return false if min_sx > max_sx
678
+
679
+ # Check if any column in the range is not fully occluded
680
+ x = min_sx
681
+ while x <= max_sx
682
+ return true if @ceiling_clip[x] < @floor_clip[x] - 1
683
+ x += 1
684
+ end
685
+
686
+ false
687
+ end
688
+
502
689
  def point_on_side(x, y, node)
503
690
  dx = x - node.x
504
691
  dy = y - node.y
@@ -641,11 +828,67 @@ module Doom
641
828
  [x, y]
642
829
  end
643
830
 
831
+ # Determine which side of a seg a point is on (R_PointOnSegSide from Chocolate Doom)
832
+ # Returns true if point is on back side, false if on front side
833
+ def point_on_seg_side(x, y, seg)
834
+ v1 = @map.vertices[seg.v1]
835
+ v2 = @map.vertices[seg.v2]
836
+
837
+ lx = v1.x
838
+ ly = v1.y
839
+ ldx = v2.x - lx
840
+ ldy = v2.y - ly
841
+
842
+ # Handle axis-aligned lines
843
+ if ldx == 0
844
+ return x <= lx ? ldy > 0 : ldy < 0
845
+ end
846
+ if ldy == 0
847
+ return y <= ly ? ldx < 0 : ldx > 0
848
+ end
849
+
850
+ dx = x - lx
851
+ dy = y - ly
852
+
853
+ # Cross product to determine side
854
+ left = ldy * dx
855
+ right = dy * ldx
856
+
857
+ right >= left
858
+ end
859
+
644
860
  def draw_seg_range(x1, x2, sx1, sx2, dist1, dist2, sector, back_sector, sidedef, linedef, seg, seg_length)
645
- # Get seg vertices in world space for texture coordinate calculation
646
861
  seg_v1 = @map.vertices[seg.v1]
647
862
  seg_v2 = @map.vertices[seg.v2]
648
863
 
864
+ # Precompute ray-seg intersection coefficients for per-column texture mapping.
865
+ # For screen column x, the view ray direction in world space is:
866
+ # ray = (sin_a * dx_col + cos_a * proj, -cos_a * dx_col + sin_a * proj)
867
+ # where dx_col = x - HALF_WIDTH.
868
+ # The ray-seg intersection parameter s (position along seg) is:
869
+ # s = (E * x + F) / (A * x + B)
870
+ # where A, B, E, F are precomputed per-seg constants.
871
+ seg_dx = (seg_v2.x - seg_v1.x).to_f
872
+ seg_dy = (seg_v2.y - seg_v1.y).to_f
873
+ px = @player_x - seg_v1.x.to_f
874
+ py = @player_y - seg_v1.y.to_f
875
+ sin_a = @sin_angle
876
+ cos_a = @cos_angle
877
+ proj = @projection
878
+
879
+ c1 = cos_a * proj - sin_a * HALF_WIDTH
880
+ c2 = sin_a * proj + cos_a * HALF_WIDTH
881
+
882
+ # denom(x) = seg_dy * ray_dx - seg_dx * ray_dy = A * x + B
883
+ tex_a = seg_dy * sin_a + seg_dx * cos_a
884
+ tex_b = seg_dy * c1 - seg_dx * c2
885
+
886
+ # numer(x) = py * ray_dx - px * ray_dy = E * x + F
887
+ tex_e = py * sin_a + px * cos_a
888
+ tex_f = py * c1 - px * c2
889
+
890
+ tex_offset = seg.offset + sidedef.x_offset
891
+
649
892
  # Calculate scales for drawseg (scale = projection / distance)
650
893
  scale1 = dist1 > 0 ? @projection / dist1 : Float::INFINITY
651
894
  scale2 = dist2 > 0 ? @projection / dist2 : Float::INFINITY
@@ -691,11 +934,10 @@ module Doom
691
934
  (x1..x2).each do |x|
692
935
  next if @ceiling_clip[x] >= @floor_clip[x] - 1
693
936
 
694
- # Screen-space interpolation factor for distance only
937
+ # Screen-space interpolation for distance
695
938
  t = sx2 != sx1 ? (x - sx1) / (sx2 - sx1) : 0
696
939
  t = t.clamp(0.0, 1.0)
697
940
 
698
- # Perspective-correct interpolation for distance
699
941
  if dist1 > 0 && dist2 > 0
700
942
  inv_dist = (1.0 - t) / dist1 + t / dist2
701
943
  dist = 1.0 / inv_dist
@@ -703,18 +945,15 @@ module Doom
703
945
  dist = dist1 > 0 ? dist1 : dist2
704
946
  end
705
947
 
706
- # Texture column using perspective-correct interpolation
707
- # tex_col_1 is texture coordinate at seg v1, tex_col_2 at v2
708
- tex_col_1 = seg.offset + sidedef.x_offset
709
- tex_col_2 = seg.offset + seg_length + sidedef.x_offset
710
-
711
- # Perspective-correct interpolation: interpolate tex/z, then multiply by z
712
- if dist1 > 0 && dist2 > 0
713
- tex_col = ((tex_col_1 / dist1) * (1.0 - t) + (tex_col_2 / dist2) * t) / inv_dist
948
+ # Ray-seg intersection for texture column
949
+ # s = (E * x + F) / (A * x + B) gives position along seg in world units
950
+ denom = tex_a * x + tex_b
951
+ if denom.abs < 0.001
952
+ s = 0.0
714
953
  else
715
- tex_col = tex_col_1 * (1.0 - t) + tex_col_2 * t
954
+ s = (tex_e * x + tex_f) / denom
716
955
  end
717
- tex_col = tex_col.to_i
956
+ tex_col = (tex_offset + s * seg_length).to_i
718
957
 
719
958
  # Skip if too close
720
959
  next if dist < 1
@@ -727,7 +966,10 @@ module Doom
727
966
  front_ceil = sector.ceiling_height - @player_z
728
967
 
729
968
  # Project to screen Y (Y increases downward on screen)
730
- front_ceil_y = (HALF_HEIGHT - front_ceil * scale).to_i
969
+ # Chocolate Doom rounds ceiling UP and floor DOWN to avoid 1-pixel gaps:
970
+ # yl = (topfrac+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for front ceiling)
971
+ # yh = bottomfrac>>HEIGHTBITS (floor for front floor)
972
+ front_ceil_y = (HALF_HEIGHT - front_ceil * scale).ceil
731
973
  front_floor_y = (HALF_HEIGHT - front_floor * scale).to_i
732
974
 
733
975
  # Clamp to current clip bounds
@@ -739,8 +981,11 @@ module Doom
739
981
  back_floor = back_sector.floor_height - @player_z
740
982
  back_ceil = back_sector.ceiling_height - @player_z
741
983
 
984
+ # Chocolate Doom rounding:
985
+ # pixhigh>>HEIGHTBITS (truncate for back ceiling / upper wall end)
986
+ # (pixlow+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for back floor / lower wall start)
742
987
  back_ceil_y = (HALF_HEIGHT - back_ceil * scale).to_i
743
- back_floor_y = (HALF_HEIGHT - back_floor * scale).to_i
988
+ back_floor_y = (HALF_HEIGHT - back_floor * scale).ceil
744
989
 
745
990
  # Determine visible ceiling/floor boundaries (the opening between sectors)
746
991
  # high_ceil = top of the opening on screen (max Y = lower world ceiling)
@@ -748,12 +993,35 @@ module Doom
748
993
  high_ceil = [ceil_y, back_ceil_y].max
749
994
  low_floor = [floor_y, back_floor_y].min
750
995
 
751
- # Mark ceiling visplane - mark front sector's ceiling for two-sided lines
752
- # Matches Chocolate Doom: mark from ceilingclip+1 to yl-1 (front ceiling)
753
- # (yl is clamped to ceilingclip+1, so we use ceil_y which is already clamped)
754
- should_mark_ceiling = sector.ceiling_height != back_sector.ceiling_height ||
755
- sector.ceiling_texture != back_sector.ceiling_texture ||
996
+ # Check for closed door (no opening between sectors)
997
+ # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
998
+ # || backsector->floorheight >= frontsector->ceilingheight
999
+ closed_door = back_sector.ceiling_height <= sector.floor_height ||
1000
+ back_sector.floor_height >= sector.ceiling_height
1001
+
1002
+ both_sky = sector.ceiling_texture == 'F_SKY1' && back_sector.ceiling_texture == 'F_SKY1'
1003
+
1004
+ # Determine whether to mark ceiling/floor visplanes.
1005
+ # Match Chocolate Doom: only mark when properties differ, plus force
1006
+ # for closed doors. The background fill covers same-property gaps.
1007
+ # Chocolate Doom sky hack: "worldtop = worldhigh" makes the front
1008
+ # ceiling equal to the back ceiling when both are sky. This prevents
1009
+ # the low sky ceiling from clipping walls in adjacent sectors.
1010
+ effective_front_ceil = both_sky ? back_sector.ceiling_height : sector.ceiling_height
1011
+
1012
+ if closed_door
1013
+ should_mark_ceiling = true
1014
+ should_mark_floor = true
1015
+ else
1016
+ should_mark_ceiling = effective_front_ceil != back_sector.ceiling_height ||
1017
+ sector.ceiling_texture != back_sector.ceiling_texture ||
1018
+ sector.light_level != back_sector.light_level
1019
+ should_mark_floor = sector.floor_height != back_sector.floor_height ||
1020
+ sector.floor_texture != back_sector.floor_texture ||
756
1021
  sector.light_level != back_sector.light_level
1022
+ end
1023
+
1024
+ # Mark ceiling visplane
757
1025
  if @current_ceiling_plane && should_mark_ceiling
758
1026
  mark_top = @ceiling_clip[x] + 1
759
1027
  mark_bottom = ceil_y - 1
@@ -763,12 +1031,7 @@ module Doom
763
1031
  end
764
1032
  end
765
1033
 
766
- # Mark floor visplane - mark front sector's floor for two-sided lines
767
- # Matches Chocolate Doom: mark from yh+1 (front floor) to floorclip-1
768
- # (yh is clamped to floorclip-1, so we use floor_y which is already clamped)
769
- should_mark_floor = sector.floor_height != back_sector.floor_height ||
770
- sector.floor_texture != back_sector.floor_texture ||
771
- sector.light_level != back_sector.light_level
1034
+ # Mark floor visplane
772
1035
  if @current_floor_plane && should_mark_floor
773
1036
  mark_top = floor_y + 1
774
1037
  mark_bottom = @floor_clip[x] - 1
@@ -778,11 +1041,8 @@ module Doom
778
1041
  end
779
1042
  end
780
1043
 
781
- # Upper wall (ceiling step down)
782
- if sector.ceiling_height > back_sector.ceiling_height
783
- # Upper texture Y offset depends on DONTPEGTOP flag
784
- # With DONTPEGTOP: texture top aligns with front ceiling
785
- # Without: texture bottom aligns with back ceiling (for doors opening)
1044
+ # Upper wall (ceiling step down) - skip if both sectors have sky
1045
+ if !both_sky && sector.ceiling_height > back_sector.ceiling_height
786
1046
  if linedef.upper_unpegged?
787
1047
  upper_tex_y = sidedef.y_offset
788
1048
  else
@@ -790,63 +1050,68 @@ module Doom
790
1050
  tex_height = texture ? texture.height : 128
791
1051
  upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
792
1052
  end
793
- draw_wall_column_ex(x, ceil_y, back_ceil_y - 1, sidedef.upper_texture, dist,
1053
+ draw_wall_column_ex(x, ceil_y, back_ceil_y, sidedef.upper_texture, dist,
794
1054
  sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height, back_sector.ceiling_height)
795
- # Note: Upper walls don't fully occlude - sprites can be visible through openings
796
1055
  end
797
1056
 
798
1057
  # Lower wall (floor step up)
799
1058
  if sector.floor_height < back_sector.floor_height
800
- # Lower texture Y offset depends on DONTPEGBOTTOM flag
801
- # With DONTPEGBOTTOM: texture bottom aligns with lower floor
802
- # Without: texture top aligns with higher floor
803
1059
  if linedef.lower_unpegged?
804
1060
  lower_tex_y = sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
805
1061
  else
806
1062
  lower_tex_y = sidedef.y_offset
807
1063
  end
808
- draw_wall_column_ex(x, back_floor_y + 1, floor_y, sidedef.lower_texture, dist,
1064
+ draw_wall_column_ex(x, back_floor_y, floor_y, sidedef.lower_texture, dist,
809
1065
  sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
810
- # Note: Lower walls don't fully occlude - sprites can be visible through openings
811
1066
  end
812
1067
 
813
- # Update clip bounds after marking
814
- # Ceiling clip increases (moves down) as ceiling is marked
815
- if sector.ceiling_height > back_sector.ceiling_height
816
- # Upper wall drawn - clip ceiling to back ceiling
817
- @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
818
- elsif sector.ceiling_height < back_sector.ceiling_height
819
- # Ceiling step up - clip to front ceiling
820
- @ceiling_clip[x] = [ceil_y, @ceiling_clip[x]].max
821
- elsif should_mark_ceiling
822
- # Same height but different texture/light - still update clip
823
- # Matches Chocolate Doom: if (markceiling) ceilingclip[rw_x] = yl-1;
824
- @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
825
- end
1068
+ # Update clip bounds
1069
+ if closed_door
1070
+ @wall_depth[x] = [@wall_depth[x], dist].min
1071
+ @ceiling_clip[x] = SCREEN_HEIGHT
1072
+ @floor_clip[x] = -1
1073
+ else
1074
+ # Ceiling clip (uses effective_front_ceil for sky hack)
1075
+ if effective_front_ceil > back_sector.ceiling_height
1076
+ @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
1077
+ elsif effective_front_ceil < back_sector.ceiling_height
1078
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1079
+ elsif sector.ceiling_texture != back_sector.ceiling_texture ||
1080
+ sector.light_level != back_sector.light_level
1081
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
1082
+ end
826
1083
 
827
- # Floor clip decreases (moves up) as floor is marked
828
- if sector.floor_height < back_sector.floor_height
829
- # Lower wall drawn - clip floor to back floor
830
- @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
831
- elsif sector.floor_height > back_sector.floor_height
832
- # Floor step down - clip to front floor to allow back sector to mark later
833
- @floor_clip[x] = [floor_y, @floor_clip[x]].min
834
- elsif should_mark_floor
835
- # Same height but different texture/light - still update clip
836
- # Matches Chocolate Doom: if (markfloor) floorclip[rw_x] = yh+1;
837
- @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1084
+ # Floor clip
1085
+ if sector.floor_height < back_sector.floor_height
1086
+ @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
1087
+ elsif sector.floor_height > back_sector.floor_height
1088
+ # Chocolate Doom: else if (markfloor) floorclip = yh + 1
1089
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1090
+ elsif sector.floor_texture != back_sector.floor_texture ||
1091
+ sector.light_level != back_sector.light_level
1092
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
1093
+ end
838
1094
  end
839
1095
  else
840
1096
  # One-sided (solid) wall
841
1097
  # Mark ceiling visplane (from previous clip to wall's ceiling)
842
- if @current_ceiling_plane && @ceiling_clip[x] + 1 <= ceil_y - 1
843
- @current_ceiling_plane.mark(x, @ceiling_clip[x] + 1, ceil_y - 1)
1098
+ # Clamp to floor_clip to prevent ceiling bleeding through portal openings
1099
+ if @current_ceiling_plane
1100
+ mark_top = @ceiling_clip[x] + 1
1101
+ mark_bottom = [ceil_y - 1, @floor_clip[x] - 1].min
1102
+ if mark_top <= mark_bottom
1103
+ @current_ceiling_plane.mark(x, mark_top, mark_bottom)
1104
+ end
844
1105
  end
845
1106
 
846
1107
  # Mark floor visplane (from wall's floor to previous floor clip)
847
- # Floor is visible BELOW the wall (from floor_y+1 to floor_clip-1)
848
- if @current_floor_plane && floor_y + 1 <= @floor_clip[x] - 1
849
- @current_floor_plane.mark(x, floor_y + 1, @floor_clip[x] - 1)
1108
+ # Clamp to ceiling_clip to prevent floor bleeding through portal openings
1109
+ if @current_floor_plane
1110
+ mark_top = [floor_y + 1, @ceiling_clip[x] + 1].max
1111
+ mark_bottom = @floor_clip[x] - 1
1112
+ if mark_top <= mark_bottom
1113
+ @current_floor_plane.mark(x, mark_top, mark_bottom)
1114
+ end
850
1115
  end
851
1116
 
852
1117
  # Draw wall (from clipped ceiling to clipped floor)
@@ -883,7 +1148,8 @@ module Doom
883
1148
  scale1, scale2,
884
1149
  silhouette,
885
1150
  bsilheight, tsilheight,
886
- sprtopclip, sprbottomclip
1151
+ sprtopclip, sprbottomclip,
1152
+ seg
887
1153
  )
888
1154
  @drawsegs << drawseg
889
1155
  end
@@ -905,7 +1171,7 @@ module Doom
905
1171
  y2 = [y2, clip_bottom].min
906
1172
  return if y1 > y2
907
1173
 
908
- texture = @textures[texture_name]
1174
+ texture = @textures[anim_texture(texture_name)]
909
1175
  return unless texture
910
1176
 
911
1177
  light = calculate_light(light_level, dist)
@@ -916,7 +1182,6 @@ module Doom
916
1182
 
917
1183
  # Texture X coordinate (wrap around texture width)
918
1184
  tex_x = tex_col.to_i % tex_width
919
- tex_x += tex_width if tex_x < 0
920
1185
 
921
1186
  # Get the column of pixels
922
1187
  column = texture.column_pixels(tex_x)
@@ -940,9 +1205,8 @@ module Doom
940
1205
  while y <= y2
941
1206
  screen_offset = y - y1
942
1207
  tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
943
- tex_y += tex_height if tex_y < 0
944
1208
 
945
- color = column[tex_y] || 0
1209
+ color = column[tex_y]
946
1210
  framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
947
1211
  y += 1
948
1212
  end
@@ -1017,7 +1281,10 @@ module Doom
1017
1281
  # Collect visible sprites with their distances
1018
1282
  visible_sprites = []
1019
1283
 
1020
- @map.things.each do |thing|
1284
+ @map.things.each_with_index do |thing, thing_idx|
1285
+ # Skip picked-up items
1286
+ next if @hidden_things && @hidden_things[thing_idx]
1287
+
1021
1288
  # Check if we have a sprite for this thing type
1022
1289
  next unless @sprites.prefix_for(thing.type)
1023
1290
 
@@ -1035,8 +1302,12 @@ module Doom
1035
1302
  dy = thing.y - @player_y
1036
1303
  angle_to_thing = Math.atan2(dy, dx)
1037
1304
 
1038
- # Get the correct rotated sprite
1039
- sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1305
+ # Get sprite - use death frame if monster is dead
1306
+ if @combat && @combat.dead?(thing_idx)
1307
+ sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1308
+ else
1309
+ sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1310
+ end
1040
1311
  next unless sprite
1041
1312
 
1042
1313
  # Project to screen X
@@ -1047,18 +1318,11 @@ module Doom
1047
1318
  next if screen_x + sprite_half_width < 0
1048
1319
  next if screen_x - sprite_half_width >= SCREEN_WIDTH
1049
1320
 
1050
- visible_sprites << {
1051
- thing: thing,
1052
- sprite: sprite,
1053
- view_x: view_x,
1054
- view_y: view_y,
1055
- dist: dist,
1056
- screen_x: screen_x
1057
- }
1321
+ visible_sprites << VisibleSprite.new(thing, sprite, view_x, view_y, dist, screen_x)
1058
1322
  end
1059
1323
 
1060
1324
  # Sort by distance (back to front for proper overdraw)
1061
- visible_sprites.sort_by! { |s| -s[:dist] }
1325
+ visible_sprites.sort_by! { |s| -s.dist }
1062
1326
 
1063
1327
  # Draw each sprite
1064
1328
  visible_sprites.each do |vs|
@@ -1067,10 +1331,10 @@ module Doom
1067
1331
  end
1068
1332
 
1069
1333
  def draw_sprite(vs)
1070
- sprite = vs[:sprite]
1071
- dist = vs[:dist]
1072
- screen_x = vs[:screen_x]
1073
- thing = vs[:thing]
1334
+ sprite = vs.sprite
1335
+ dist = vs.dist
1336
+ screen_x = vs.screen_x
1337
+ thing = vs.thing
1074
1338
 
1075
1339
  # Calculate scale (inverse of distance, used for depth comparison)
1076
1340
  sprite_scale = @projection / dist
@@ -1120,12 +1384,14 @@ module Doom
1120
1384
  lowscale = [ds.scale1, ds.scale2].min
1121
1385
  highscale = [ds.scale1, ds.scale2].max
1122
1386
 
1123
- # If drawseg is behind sprite, skip (seg must be closer to clip)
1387
+ # Chocolate Doom logic: skip if seg is behind sprite
1388
+ # If highscale < sprite_scale: wall entirely behind sprite
1389
+ # OR if lowscale < sprite_scale AND sprite is on front side of wall
1124
1390
  if highscale < sprite_scale
1125
1391
  next
1126
1392
  elsif lowscale < sprite_scale
1127
- # Seg partially overlaps in depth - would need point-on-seg test
1128
- # For simplicity, we'll clip if any part of seg is closer
1393
+ # Partial overlap - check if sprite is in front of the seg
1394
+ next unless point_on_seg_side(thing.x, thing.y, ds.curline)
1129
1395
  end
1130
1396
 
1131
1397
  # Determine which silhouettes apply based on sprite Z
@@ -1208,11 +1474,6 @@ module Doom
1208
1474
  end
1209
1475
  end
1210
1476
  end
1211
-
1212
- def set_pixel(x, y, color)
1213
- return if x < 0 || x >= SCREEN_WIDTH || y < 0 || y >= SCREEN_HEIGHT
1214
- @framebuffer[y * SCREEN_WIDTH + x] = color
1215
- end
1216
1477
  end
1217
1478
  end
1218
1479
  end