doom 0.4.0 → 0.6.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.
@@ -53,7 +53,7 @@ module Doom
53
53
  class Renderer
54
54
  attr_reader :framebuffer
55
55
 
56
- def initialize(wad, map, textures, palette, colormap, flats, sprites = nil)
56
+ def initialize(wad, map, textures, palette, colormap, flats, sprites = nil, animations = nil)
57
57
  @wad = wad
58
58
  @map = map
59
59
  @textures = textures
@@ -61,6 +61,11 @@ module Doom
61
61
  @colormap = colormap
62
62
  @flats = flats.to_h { |f| [f.name, f] }
63
63
  @sprites = sprites
64
+ @animations = animations
65
+ @hidden_things = nil
66
+ @combat = nil
67
+ @monster_ai = nil
68
+ @leveltime = 0
64
69
 
65
70
  @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
66
71
 
@@ -100,7 +105,96 @@ module Doom
100
105
  @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
101
106
  end
102
107
 
103
- attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle
108
+ attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle, :framebuffer
109
+ attr_writer :hidden_things, :combat, :monster_ai, :leveltime
110
+
111
+ # Diagnostic: returns info about all sprites and why they are/aren't visible
112
+ def sprite_diagnostics
113
+ return [] unless @sprites
114
+
115
+ results = []
116
+ @map.things.each do |thing|
117
+ prefix = @sprites.prefix_for(thing.type)
118
+ next unless prefix
119
+
120
+ info = { type: thing.type, x: thing.x, y: thing.y, prefix: prefix }
121
+
122
+ view_x, view_y = transform_point(thing.x, thing.y)
123
+ info[:view_x] = view_x.round(1)
124
+ info[:view_y] = view_y.round(1)
125
+
126
+ if view_y <= 0
127
+ info[:status] = "behind_player"
128
+ results << info
129
+ next
130
+ end
131
+
132
+ dist = view_y
133
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
134
+ info[:screen_x] = screen_x.round(1)
135
+ info[:dist] = dist.round(1)
136
+
137
+ dx = thing.x - @player_x
138
+ dy = thing.y - @player_y
139
+ angle_to_thing = Math.atan2(dy, dx)
140
+ sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
141
+ unless sprite
142
+ info[:status] = "no_sprite_frame"
143
+ results << info
144
+ next
145
+ end
146
+
147
+ sprite_scale = @projection / dist
148
+ sprite_half_width = (sprite.width * @projection / dist / 2).to_i
149
+ info[:sprite_scale] = sprite_scale.round(3)
150
+
151
+ if screen_x + sprite_half_width < 0
152
+ info[:status] = "off_screen_left"
153
+ elsif screen_x - sprite_half_width >= SCREEN_WIDTH
154
+ info[:status] = "off_screen_right"
155
+ else
156
+ # Check drawseg clipping
157
+ sprite_left = (screen_x - sprite.left_offset * sprite_scale).to_i
158
+ sprite_right = sprite_left + (sprite.width * sprite_scale).to_i - 1
159
+ x1 = [sprite_left, 0].max
160
+ x2 = [sprite_right, SCREEN_WIDTH - 1].min
161
+
162
+ sector = @map.sector_at(thing.x, thing.y)
163
+ thing_floor = sector ? sector.floor_height : 0
164
+ sprite_gz = thing_floor
165
+ sprite_gzt = thing_floor + sprite.top_offset
166
+
167
+ clipping_segs = []
168
+ @drawsegs.reverse_each do |ds|
169
+ next if ds.x1 > x2 || ds.x2 < x1
170
+ next if ds.silhouette == SIL_NONE
171
+
172
+ lowscale = [ds.scale1, ds.scale2].min
173
+ highscale = [ds.scale1, ds.scale2].max
174
+
175
+ if highscale < sprite_scale
176
+ next
177
+ elsif lowscale < sprite_scale
178
+ next unless point_on_seg_side(thing.x, thing.y, ds.curline)
179
+ end
180
+
181
+ clipping_segs << {
182
+ x1: ds.x1, x2: ds.x2,
183
+ scale: "#{ds.scale1.round(3)}..#{ds.scale2.round(3)}",
184
+ sil: ds.silhouette
185
+ }
186
+ end
187
+
188
+ info[:screen_range] = "#{x1}..#{x2}"
189
+ info[:clipping_segs] = clipping_segs.size
190
+ info[:clipping_detail] = clipping_segs
191
+ info[:status] = "visible"
192
+ end
193
+
194
+ results << info
195
+ end
196
+ results
197
+ end
104
198
 
105
199
  def set_player(x, y, z, angle)
106
200
  @player_x = x.to_f
@@ -132,7 +226,10 @@ module Doom
132
226
  # Precompute column angles for floor/ceiling rendering
133
227
  precompute_column_data
134
228
 
135
- # Draw floor/ceiling background first (will be partially overwritten by walls)
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.
136
233
  draw_floor_ceiling_background
137
234
 
138
235
  # Initialize visplanes for tracking visible floor/ceiling spans
@@ -244,7 +341,7 @@ module Doom
244
341
  def draw_span(plane, y, x1, x2)
245
342
  return if x1.nil? || x1 > x2 || y < 0 || y >= SCREEN_HEIGHT
246
343
 
247
- flat = @flats[plane.texture]
344
+ flat = @flats[anim_flat(plane.texture)]
248
345
  return unless flat
249
346
 
250
347
  # Distance from horizon (y=100 for 200-high screen)
@@ -270,6 +367,7 @@ module Doom
270
367
  player_x = @player_x
271
368
  neg_player_y = -@player_y
272
369
  row_offset = y * SCREEN_WIDTH
370
+ flat_pixels = flat.pixels
273
371
 
274
372
  # Clamp to screen bounds
275
373
  x1 = 0 if x1 < 0
@@ -281,13 +379,22 @@ module Doom
281
379
  ray_dist = perp_dist * column_distscale[x]
282
380
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
283
381
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
284
- color = flat[tex_x, tex_y]
382
+ color = flat_pixels[tex_y * 64 + tex_x]
285
383
  framebuffer[row_offset + x] = cmap[color]
286
384
  x += 1
287
385
  end
288
386
  end
289
387
 
290
388
  # Render sky ceiling as columns (column-based like walls, not spans)
389
+ # Sky texture mid-point: texel row at screen center.
390
+ # Chocolate Doom: skytexturemid = SCREENHEIGHT/2 = 100 (for 200px screen).
391
+ # Scale factor: our 240px screen maps to DOOM's 200px sky coordinates.
392
+ SKY_TEXTUREMID = 100.0
393
+ SKY_YSCALE = 200.0 / SCREEN_HEIGHT # 0.833 - maps our pixels to DOOM's 200px space
394
+ # Chocolate Doom: ANGLETOSKYSHIFT=22 gives 4 sky repetitions per 360 degrees.
395
+ # Full circle (2pi) * 512/pi = 1024 columns, masked to 256 = 4 repetitions.
396
+ SKY_XSCALE = 512.0 / Math::PI
397
+
291
398
  def draw_sky_plane(plane)
292
399
  sky_texture = @textures['SKY1']
293
400
  return unless sky_texture
@@ -295,8 +402,8 @@ module Doom
295
402
  framebuffer = @framebuffer
296
403
  player_angle = @player_angle
297
404
  projection = @projection
298
- sky_width_mask = sky_texture.width - 1 # Power-of-2 textures: & is faster than %
299
- sky_height_mask = sky_texture.height - 1
405
+ sky_width = sky_texture.width
406
+ sky_height = sky_texture.height
300
407
 
301
408
  # Clamp to screen bounds
302
409
  minx = [plane.minx, 0].max
@@ -311,14 +418,17 @@ module Doom
311
418
  y1 = 0 if y1 < 0
312
419
  y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
313
420
 
314
- # Sky X based on view angle (wraps around 256 degrees)
421
+ # Sky X: 4 repetitions per 360 degrees (matching ANGLETOSKYSHIFT=22)
315
422
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
316
- sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
423
+ sky_x = (column_angle * SKY_XSCALE).to_i % sky_width
317
424
  column = sky_texture.column_pixels(sky_x)
318
425
  next unless column
319
426
 
427
+ # Sky Y: texel = skytexturemid + (y - centery) * scale
428
+ # Maps texel 0 to screen top, texel 100 to horizon (1:1 for DOOM's 200px)
320
429
  (y1..y2).each do |y|
321
- color = column[y & sky_height_mask]
430
+ tex_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_height
431
+ color = column[tex_y]
322
432
  framebuffer[y * SCREEN_WIDTH + x] = color
323
433
  end
324
434
  end
@@ -410,8 +520,10 @@ module Doom
410
520
 
411
521
  ceil_height = (default_sector.ceiling_height - @player_z).abs
412
522
  floor_height = (default_sector.floor_height - @player_z).abs
413
- ceil_flat = @flats[default_sector.ceiling_texture]
414
- floor_flat = @flats[default_sector.floor_texture]
523
+ ceil_flat = @flats[anim_flat(default_sector.ceiling_texture)]
524
+ floor_flat = @flats[anim_flat(default_sector.floor_texture)]
525
+ ceil_pixels = ceil_flat&.pixels
526
+ floor_pixels = floor_flat&.pixels
415
527
  is_sky = default_sector.ceiling_texture == 'F_SKY1'
416
528
  sky_texture = is_sky ? @textures['SKY1'] : nil
417
529
  light_level = default_sector.light_level
@@ -437,13 +549,13 @@ module Doom
437
549
  row_offset = y * SCREEN_WIDTH
438
550
 
439
551
  if is_sky && sky_texture
440
- sky_height_mask = sky_texture.height - 1
441
- sky_width_mask = sky_texture.width - 1
442
- sky_y = y & sky_height_mask
552
+ sky_w = sky_texture.width
553
+ sky_h = sky_texture.height
554
+ sky_y = (SKY_TEXTUREMID + (y - HALF_HEIGHT) * SKY_YSCALE).to_i % sky_h
443
555
  x = 0
444
556
  while x < SCREEN_WIDTH
445
557
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
446
- sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
558
+ sky_x = (column_angle * SKY_XSCALE).to_i % sky_w
447
559
  color = sky_texture.column_pixels(sky_x)[sky_y]
448
560
  framebuffer[row_offset + x] = color
449
561
  x += 1
@@ -454,7 +566,7 @@ module Doom
454
566
  ray_dist = perp_dist * column_distscale[x]
455
567
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
456
568
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
457
- color = ceil_flat[tex_x, tex_y]
569
+ color = ceil_pixels[tex_y * 64 + tex_x]
458
570
  framebuffer[row_offset + x] = cmap[color]
459
571
  x += 1
460
572
  end
@@ -481,7 +593,7 @@ module Doom
481
593
  ray_dist = perp_dist * column_distscale[x]
482
594
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
483
595
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
484
- color = floor_flat[tex_x, tex_y]
596
+ color = floor_pixels[tex_y * 64 + tex_x]
485
597
  framebuffer[row_offset + x] = cmap[color]
486
598
  x += 1
487
599
  end
@@ -494,6 +606,15 @@ module Doom
494
606
 
495
607
  private
496
608
 
609
+ # Translate flat/texture names through animation system
610
+ def anim_flat(name)
611
+ @animations ? @animations.translate_flat(name) : name
612
+ end
613
+
614
+ def anim_texture(name)
615
+ @animations ? @animations.translate_texture(name) : name
616
+ end
617
+
497
618
  def clear_framebuffer
498
619
  @framebuffer.fill(0)
499
620
  end
@@ -515,11 +636,56 @@ module Doom
515
636
 
516
637
  if side == 0
517
638
  render_bsp_node(node.child_right)
518
- render_bsp_node(node.child_left)
639
+ back_bbox = node.bbox_left
640
+ render_bsp_node(node.child_left) if check_bbox(back_bbox)
519
641
  else
520
642
  render_bsp_node(node.child_left)
521
- render_bsp_node(node.child_right)
643
+ back_bbox = node.bbox_right
644
+ render_bsp_node(node.child_right) if check_bbox(back_bbox)
645
+ end
646
+ end
647
+
648
+ # R_CheckBBox - check if a bounding box is potentially visible.
649
+ # Projects the bbox corners to screen columns and checks if any
650
+ # column in that range is not fully occluded.
651
+ def check_bbox(bbox)
652
+ # Transform all 4 corners to view space
653
+ near = 1.0
654
+ all_in_front = true
655
+ min_sx = SCREEN_WIDTH
656
+ max_sx = -1
657
+
658
+ [[bbox.left, bbox.bottom], [bbox.right, bbox.bottom],
659
+ [bbox.left, bbox.top], [bbox.right, bbox.top]].each do |wx, wy|
660
+ vx, vy = transform_point(wx, wy)
661
+
662
+ if vy < near
663
+ # Any corner behind the near plane - bbox is too close to cull safely
664
+ all_in_front = false
665
+ else
666
+ sx = HALF_WIDTH + (vx * @projection / vy)
667
+ sx_i = sx.to_i
668
+ min_sx = sx_i if sx_i < min_sx
669
+ max_sx = sx_i if sx_i > max_sx
670
+ end
671
+ end
672
+
673
+ # If any corner is behind the near plane, conservatively assume visible.
674
+ # Only cull when all 4 corners are cleanly in front and we can check occlusion.
675
+ return true unless all_in_front
676
+
677
+ min_sx = 0 if min_sx < 0
678
+ max_sx = SCREEN_WIDTH - 1 if max_sx >= SCREEN_WIDTH
679
+ return false if min_sx > max_sx
680
+
681
+ # Check if any column in the range is not fully occluded
682
+ x = min_sx
683
+ while x <= max_sx
684
+ return true if @ceiling_clip[x] < @floor_clip[x] - 1
685
+ x += 1
522
686
  end
687
+
688
+ false
523
689
  end
524
690
 
525
691
  def point_on_side(x, y, node)
@@ -694,10 +860,37 @@ module Doom
694
860
  end
695
861
 
696
862
  def draw_seg_range(x1, x2, sx1, sx2, dist1, dist2, sector, back_sector, sidedef, linedef, seg, seg_length)
697
- # Get seg vertices in world space for texture coordinate calculation
698
863
  seg_v1 = @map.vertices[seg.v1]
699
864
  seg_v2 = @map.vertices[seg.v2]
700
865
 
866
+ # Precompute ray-seg intersection coefficients for per-column texture mapping.
867
+ # For screen column x, the view ray direction in world space is:
868
+ # ray = (sin_a * dx_col + cos_a * proj, -cos_a * dx_col + sin_a * proj)
869
+ # where dx_col = x - HALF_WIDTH.
870
+ # The ray-seg intersection parameter s (position along seg) is:
871
+ # s = (E * x + F) / (A * x + B)
872
+ # where A, B, E, F are precomputed per-seg constants.
873
+ seg_dx = (seg_v2.x - seg_v1.x).to_f
874
+ seg_dy = (seg_v2.y - seg_v1.y).to_f
875
+ px = @player_x - seg_v1.x.to_f
876
+ py = @player_y - seg_v1.y.to_f
877
+ sin_a = @sin_angle
878
+ cos_a = @cos_angle
879
+ proj = @projection
880
+
881
+ c1 = cos_a * proj - sin_a * HALF_WIDTH
882
+ c2 = sin_a * proj + cos_a * HALF_WIDTH
883
+
884
+ # 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
887
+
888
+ # 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
891
+
892
+ tex_offset = seg.offset + sidedef.x_offset
893
+
701
894
  # Calculate scales for drawseg (scale = projection / distance)
702
895
  scale1 = dist1 > 0 ? @projection / dist1 : Float::INFINITY
703
896
  scale2 = dist2 > 0 ? @projection / dist2 : Float::INFINITY
@@ -743,11 +936,10 @@ module Doom
743
936
  (x1..x2).each do |x|
744
937
  next if @ceiling_clip[x] >= @floor_clip[x] - 1
745
938
 
746
- # Screen-space interpolation factor for distance only
939
+ # Screen-space interpolation for distance
747
940
  t = sx2 != sx1 ? (x - sx1) / (sx2 - sx1) : 0
748
941
  t = t.clamp(0.0, 1.0)
749
942
 
750
- # Perspective-correct interpolation for distance
751
943
  if dist1 > 0 && dist2 > 0
752
944
  inv_dist = (1.0 - t) / dist1 + t / dist2
753
945
  dist = 1.0 / inv_dist
@@ -755,18 +947,15 @@ module Doom
755
947
  dist = dist1 > 0 ? dist1 : dist2
756
948
  end
757
949
 
758
- # Texture column using perspective-correct interpolation
759
- # tex_col_1 is texture coordinate at seg v1, tex_col_2 at v2
760
- tex_col_1 = seg.offset + sidedef.x_offset
761
- tex_col_2 = seg.offset + seg_length + sidedef.x_offset
762
-
763
- # Perspective-correct interpolation: interpolate tex/z, then multiply by z
764
- if dist1 > 0 && dist2 > 0
765
- tex_col = ((tex_col_1 / dist1) * (1.0 - t) + (tex_col_2 / dist2) * t) / inv_dist
950
+ # Ray-seg intersection for texture column
951
+ # 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
766
955
  else
767
- tex_col = tex_col_1 * (1.0 - t) + tex_col_2 * t
956
+ s = (tex_e * x + tex_f) / denom
768
957
  end
769
- tex_col = tex_col.to_i
958
+ tex_col = (tex_offset + s * seg_length).to_i
770
959
 
771
960
  # Skip if too close
772
961
  next if dist < 1
@@ -779,7 +968,10 @@ module Doom
779
968
  front_ceil = sector.ceiling_height - @player_z
780
969
 
781
970
  # Project to screen Y (Y increases downward on screen)
782
- front_ceil_y = (HALF_HEIGHT - front_ceil * scale).to_i
971
+ # Chocolate Doom rounds ceiling UP and floor DOWN to avoid 1-pixel gaps:
972
+ # yl = (topfrac+HEIGHTUNIT-1)>>HEIGHTBITS (ceil for front ceiling)
973
+ # yh = bottomfrac>>HEIGHTBITS (floor for front floor)
974
+ front_ceil_y = (HALF_HEIGHT - front_ceil * scale).ceil
783
975
  front_floor_y = (HALF_HEIGHT - front_floor * scale).to_i
784
976
 
785
977
  # Clamp to current clip bounds
@@ -791,8 +983,11 @@ module Doom
791
983
  back_floor = back_sector.floor_height - @player_z
792
984
  back_ceil = back_sector.ceiling_height - @player_z
793
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)
794
989
  back_ceil_y = (HALF_HEIGHT - back_ceil * scale).to_i
795
- back_floor_y = (HALF_HEIGHT - back_floor * scale).to_i
990
+ back_floor_y = (HALF_HEIGHT - back_floor * scale).ceil
796
991
 
797
992
  # Determine visible ceiling/floor boundaries (the opening between sectors)
798
993
  # high_ceil = top of the opening on screen (max Y = lower world ceiling)
@@ -806,12 +1001,29 @@ module Doom
806
1001
  closed_door = back_sector.ceiling_height <= sector.floor_height ||
807
1002
  back_sector.floor_height >= sector.ceiling_height
808
1003
 
809
- # Mark ceiling visplane - mark front sector's ceiling for two-sided lines
810
- # Matches Chocolate Doom: mark from ceilingclip+1 to yl-1 (front ceiling)
811
- # (yl is clamped to ceilingclip+1, so we use ceil_y which is already clamped)
812
- should_mark_ceiling = sector.ceiling_height != back_sector.ceiling_height ||
813
- sector.ceiling_texture != back_sector.ceiling_texture ||
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 ||
814
1023
  sector.light_level != back_sector.light_level
1024
+ end
1025
+
1026
+ # Mark ceiling visplane
815
1027
  if @current_ceiling_plane && should_mark_ceiling
816
1028
  mark_top = @ceiling_clip[x] + 1
817
1029
  mark_bottom = ceil_y - 1
@@ -821,12 +1033,7 @@ module Doom
821
1033
  end
822
1034
  end
823
1035
 
824
- # Mark floor visplane - mark front sector's floor for two-sided lines
825
- # Matches Chocolate Doom: mark from yh+1 (front floor) to floorclip-1
826
- # (yh is clamped to floorclip-1, so we use floor_y which is already clamped)
827
- should_mark_floor = sector.floor_height != back_sector.floor_height ||
828
- sector.floor_texture != back_sector.floor_texture ||
829
- sector.light_level != back_sector.light_level
1036
+ # Mark floor visplane
830
1037
  if @current_floor_plane && should_mark_floor
831
1038
  mark_top = floor_y + 1
832
1039
  mark_bottom = @floor_clip[x] - 1
@@ -836,11 +1043,8 @@ module Doom
836
1043
  end
837
1044
  end
838
1045
 
839
- # Upper wall (ceiling step down)
840
- if sector.ceiling_height > back_sector.ceiling_height
841
- # Upper texture Y offset depends on DONTPEGTOP flag
842
- # With DONTPEGTOP: texture top aligns with front ceiling
843
- # Without: texture bottom aligns with back ceiling (for doors opening)
1046
+ # Upper wall (ceiling step down) - skip if both sectors have sky
1047
+ if !both_sky && sector.ceiling_height > back_sector.ceiling_height
844
1048
  if linedef.upper_unpegged?
845
1049
  upper_tex_y = sidedef.y_offset
846
1050
  else
@@ -848,70 +1052,68 @@ module Doom
848
1052
  tex_height = texture ? texture.height : 128
849
1053
  upper_tex_y = sidedef.y_offset + back_sector.ceiling_height - sector.ceiling_height + tex_height
850
1054
  end
851
- draw_wall_column_ex(x, ceil_y, back_ceil_y - 1, sidedef.upper_texture, dist,
1055
+ draw_wall_column_ex(x, ceil_y, back_ceil_y, sidedef.upper_texture, dist,
852
1056
  sector.light_level, tex_col, upper_tex_y, scale, sector.ceiling_height, back_sector.ceiling_height)
853
- # Note: Upper walls don't fully occlude - sprites can be visible through openings
854
1057
  end
855
1058
 
856
1059
  # Lower wall (floor step up)
857
1060
  if sector.floor_height < back_sector.floor_height
858
- # Lower texture Y offset depends on DONTPEGBOTTOM flag
859
- # With DONTPEGBOTTOM: texture bottom aligns with lower floor
860
- # Without: texture top aligns with higher floor
861
1061
  if linedef.lower_unpegged?
862
1062
  lower_tex_y = sidedef.y_offset + sector.ceiling_height - back_sector.floor_height
863
1063
  else
864
1064
  lower_tex_y = sidedef.y_offset
865
1065
  end
866
- draw_wall_column_ex(x, back_floor_y + 1, floor_y, sidedef.lower_texture, dist,
1066
+ draw_wall_column_ex(x, back_floor_y, floor_y, sidedef.lower_texture, dist,
867
1067
  sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
868
- # Note: Lower walls don't fully occlude - sprites can be visible through openings
869
1068
  end
870
1069
 
871
- # Update clip bounds after marking
1070
+ # Update clip bounds
872
1071
  if closed_door
873
- # Closed door - fully occlude this column
874
1072
  @wall_depth[x] = [@wall_depth[x], dist].min
875
1073
  @ceiling_clip[x] = SCREEN_HEIGHT
876
1074
  @floor_clip[x] = -1
877
1075
  else
878
- # Ceiling clip increases (moves down) as ceiling is marked
879
- if sector.ceiling_height > back_sector.ceiling_height
880
- # Upper wall drawn - clip ceiling to back ceiling
1076
+ # Ceiling clip (uses effective_front_ceil for sky hack)
1077
+ if effective_front_ceil > back_sector.ceiling_height
881
1078
  @ceiling_clip[x] = [back_ceil_y, @ceiling_clip[x]].max
882
- elsif sector.ceiling_height < back_sector.ceiling_height
883
- # Ceiling step up - clip to front ceiling
884
- @ceiling_clip[x] = [ceil_y, @ceiling_clip[x]].max
885
- elsif should_mark_ceiling
886
- # Same height but different texture/light - still update clip
887
- # Matches Chocolate Doom: if (markceiling) ceilingclip[rw_x] = yl-1;
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
888
1083
  @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
889
1084
  end
890
1085
 
891
- # Floor clip decreases (moves up) as floor is marked
1086
+ # Floor clip
892
1087
  if sector.floor_height < back_sector.floor_height
893
- # Lower wall drawn - clip floor to back floor
894
1088
  @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
895
1089
  elsif sector.floor_height > back_sector.floor_height
896
- # Floor step down - clip to front floor to allow back sector to mark later
897
- @floor_clip[x] = [floor_y, @floor_clip[x]].min
898
- elsif should_mark_floor
899
- # Same height but different texture/light - still update clip
900
- # Matches Chocolate Doom: if (markfloor) floorclip[rw_x] = yh+1;
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
901
1094
  @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
902
1095
  end
903
1096
  end
904
1097
  else
905
1098
  # One-sided (solid) wall
906
1099
  # Mark ceiling visplane (from previous clip to wall's ceiling)
907
- if @current_ceiling_plane && @ceiling_clip[x] + 1 <= ceil_y - 1
908
- @current_ceiling_plane.mark(x, @ceiling_clip[x] + 1, ceil_y - 1)
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
909
1107
  end
910
1108
 
911
1109
  # Mark floor visplane (from wall's floor to previous floor clip)
912
- # Floor is visible BELOW the wall (from floor_y+1 to floor_clip-1)
913
- if @current_floor_plane && floor_y + 1 <= @floor_clip[x] - 1
914
- @current_floor_plane.mark(x, floor_y + 1, @floor_clip[x] - 1)
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
915
1117
  end
916
1118
 
917
1119
  # Draw wall (from clipped ceiling to clipped floor)
@@ -971,17 +1173,17 @@ module Doom
971
1173
  y2 = [y2, clip_bottom].min
972
1174
  return if y1 > y2
973
1175
 
974
- texture = @textures[texture_name]
1176
+ texture = @textures[anim_texture(texture_name)]
975
1177
  return unless texture
976
1178
 
977
1179
  light = calculate_light(light_level, dist)
978
1180
  cmap = @colormap.maps[light]
979
1181
  framebuffer = @framebuffer
980
- tex_width_mask = texture.width - 1 # For power-of-2 textures: & mask is faster than %
981
- tex_height_mask = texture.height - 1
1182
+ tex_width = texture.width
1183
+ tex_height = texture.height
982
1184
 
983
- # Texture X coordinate (wrap around texture width using bitwise AND)
984
- tex_x = tex_col.to_i & tex_width_mask
1185
+ # Texture X coordinate (wrap around texture width)
1186
+ tex_x = tex_col.to_i % tex_width
985
1187
 
986
1188
  # Get the column of pixels
987
1189
  column = texture.column_pixels(tex_x)
@@ -1004,7 +1206,7 @@ module Doom
1004
1206
  y = y1
1005
1207
  while y <= y2
1006
1208
  screen_offset = y - y1
1007
- tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i & tex_height_mask
1209
+ tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1008
1210
 
1009
1211
  color = column[tex_y]
1010
1212
  framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
@@ -1081,7 +1283,10 @@ module Doom
1081
1283
  # Collect visible sprites with their distances
1082
1284
  visible_sprites = []
1083
1285
 
1084
- @map.things.each do |thing|
1286
+ @map.things.each_with_index do |thing, thing_idx|
1287
+ # Skip picked-up items
1288
+ next if @hidden_things && @hidden_things[thing_idx]
1289
+
1085
1290
  # Check if we have a sprite for this thing type
1086
1291
  next unless @sprites.prefix_for(thing.type)
1087
1292
 
@@ -1099,8 +1304,20 @@ module Doom
1099
1304
  dy = thing.y - @player_y
1100
1305
  angle_to_thing = Math.atan2(dy, dx)
1101
1306
 
1102
- # Get the correct rotated sprite
1103
- sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1307
+ # Get sprite: death frame > walking frame > idle frame
1308
+ if @combat && @combat.dead?(thing_idx)
1309
+ sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1310
+ elsif @monster_ai
1311
+ mon = @monster_ai.monsters.find { |m| m.thing_idx == thing_idx }
1312
+ 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)
1316
+ end
1317
+ sprite ||= @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1318
+ else
1319
+ sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1320
+ end
1104
1321
  next unless sprite
1105
1322
 
1106
1323
  # Project to screen X
@@ -1121,6 +1338,45 @@ module Doom
1121
1338
  visible_sprites.each do |vs|
1122
1339
  draw_sprite(vs)
1123
1340
  end
1341
+
1342
+ # Draw projectiles and explosions
1343
+ render_projectiles if @combat
1344
+ end
1345
+
1346
+ def render_projectiles
1347
+ # Render rockets in flight
1348
+ @combat.projectiles.each do |proj|
1349
+ view_x, view_y = transform_point(proj.x, proj.y)
1350
+ next if view_y <= 0
1351
+
1352
+ # Rocket has rotations 1-8 based on travel direction relative to viewer
1353
+ rocket_angle = Math.atan2(proj.dy, proj.dx)
1354
+ viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
1355
+ 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
1359
+
1360
+ 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)
1363
+ draw_sprite(visible)
1364
+ end
1365
+
1366
+ # Render explosions (frames B, C, D - all rotation 0)
1367
+ @combat.explosions.each do |expl|
1368
+ view_x, view_y = transform_point(expl[:x], expl[:y])
1369
+ next if view_y <= 0
1370
+ elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
1371
+ 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)
1374
+ next unless expl_sprite
1375
+ 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)
1378
+ draw_sprite(visible)
1379
+ end
1124
1380
  end
1125
1381
 
1126
1382
  def draw_sprite(vs)