doom 0.3.0 → 0.4.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
@@ -67,6 +72,17 @@ module Doom
67
72
  # Projection constant - distance to projection plane
68
73
  @projection = HALF_WIDTH / Math.tan(FOV * Math::PI / 360.0)
69
74
 
75
+ # Precomputed column data (cached based on player angle)
76
+ @column_cos = Array.new(SCREEN_WIDTH)
77
+ @column_sin = Array.new(SCREEN_WIDTH)
78
+ @cached_player_angle = nil
79
+
80
+ # Column distance scale is constant (doesn't depend on player angle)
81
+ @column_distscale = Array.new(SCREEN_WIDTH) do |x|
82
+ dx = x - HALF_WIDTH
83
+ Math.sqrt(dx * dx + @projection * @projection) / @projection
84
+ end
85
+
70
86
  # Clipping arrays
71
87
  @ceiling_clip = Array.new(SCREEN_WIDTH, -1)
72
88
  @floor_clip = Array.new(SCREEN_WIDTH, SCREEN_HEIGHT)
@@ -77,6 +93,11 @@ module Doom
77
93
 
78
94
  # Wall depth array - tracks distance to nearest wall at each column
79
95
  @wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
96
+ @sprite_wall_depth = Array.new(SCREEN_WIDTH, Float::INFINITY)
97
+
98
+ # Preallocated y_slope arrays for floor/ceiling rendering (avoids per-frame allocation)
99
+ @y_slope_ceil = Array.new(HALF_HEIGHT + 1, 0.0)
100
+ @y_slope_floor = Array.new(HALF_HEIGHT + 1, 0.0)
80
101
  end
81
102
 
82
103
  attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle
@@ -116,6 +137,7 @@ module Doom
116
137
 
117
138
  # Initialize visplanes for tracking visible floor/ceiling spans
118
139
  @visplanes = []
140
+ @visplane_hash = {} # Hash for O(1) lookup by (height, texture, light_level, is_ceiling)
119
141
 
120
142
  # Initialize drawsegs for sprite clipping
121
143
  @drawsegs = []
@@ -126,27 +148,26 @@ module Doom
126
148
  # Draw visplanes for sectors different from background
127
149
  draw_all_visplanes
128
150
 
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
151
+ # Save wall clip arrays for sprite clipping (reuse preallocated arrays)
152
+ @sprite_ceiling_clip.replace(@ceiling_clip)
153
+ @sprite_floor_clip.replace(@floor_clip)
154
+ @sprite_wall_depth.replace(@wall_depth)
133
155
 
134
156
  # Render sprites
135
157
  render_sprites if @sprites
136
158
  end
137
159
 
138
160
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
161
+ # Cached: only recomputes sin/cos when player angle changes
139
162
  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)
163
+ return if @cached_player_angle == @player_angle
164
+
165
+ @cached_player_angle = @player_angle
143
166
 
144
167
  SCREEN_WIDTH.times do |x|
145
- dx = x - HALF_WIDTH
146
- column_angle = @player_angle - Math.atan2(dx, @projection)
168
+ column_angle = @player_angle - Math.atan2(x - HALF_WIDTH, @projection)
147
169
  @column_cos[x] = Math.cos(column_angle)
148
170
  @column_sin[x] = Math.sin(column_angle)
149
- @column_distscale[x] = Math.sqrt(dx * dx + @projection * @projection) / @projection
150
171
  end
151
172
  end
152
173
 
@@ -260,7 +281,7 @@ module Doom
260
281
  ray_dist = perp_dist * column_distscale[x]
261
282
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
262
283
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
263
- color = flat[tex_x, tex_y] || 0
284
+ color = flat[tex_x, tex_y]
264
285
  framebuffer[row_offset + x] = cmap[color]
265
286
  x += 1
266
287
  end
@@ -274,8 +295,8 @@ module Doom
274
295
  framebuffer = @framebuffer
275
296
  player_angle = @player_angle
276
297
  projection = @projection
277
- sky_width = sky_texture.width
278
- sky_height = sky_texture.height
298
+ sky_width_mask = sky_texture.width - 1 # Power-of-2 textures: & is faster than %
299
+ sky_height_mask = sky_texture.height - 1
279
300
 
280
301
  # Clamp to screen bounds
281
302
  minx = [plane.minx, 0].max
@@ -292,29 +313,26 @@ module Doom
292
313
 
293
314
  # Sky X based on view angle (wraps around 256 degrees)
294
315
  column_angle = player_angle - Math.atan2(x - HALF_WIDTH, projection)
295
- sky_x = ((column_angle * 256 / Math::PI).to_i & 255) % sky_width
316
+ sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
296
317
  column = sky_texture.column_pixels(sky_x)
297
318
  next unless column
298
319
 
299
320
  (y1..y2).each do |y|
300
- color = column[y % sky_height] || 0
321
+ color = column[y & sky_height_mask]
301
322
  framebuffer[y * SCREEN_WIDTH + x] = color
302
323
  end
303
324
  end
304
325
  end
305
326
 
306
327
  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
328
+ # O(1) hash lookup instead of O(n) linear search
329
+ key = [height, texture, light_level, is_ceiling]
330
+ plane = @visplane_hash[key]
314
331
 
315
332
  unless plane
316
333
  plane = Visplane.new(sector, height, texture, light_level, is_ceiling)
317
334
  @visplanes << plane
335
+ @visplane_hash[key] = plane
318
336
  end
319
337
 
320
338
  plane
@@ -371,6 +389,11 @@ module Doom
371
389
  new_plane.minx = start_x
372
390
  new_plane.maxx = stop_x
373
391
  @visplanes << new_plane
392
+
393
+ # Update hash to point to the new plane (for subsequent lookups)
394
+ key = [plane.height, plane.texture, plane.light_level, plane.is_ceiling]
395
+ @visplane_hash[key] = new_plane
396
+
374
397
  new_plane
375
398
  end
376
399
 
@@ -394,9 +417,9 @@ module Doom
394
417
  light_level = default_sector.light_level
395
418
  colormap_maps = @colormap.maps
396
419
 
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)
420
+ # Compute y_slope for each row (perpendicular distance) - reuse preallocated arrays
421
+ y_slope_ceil = @y_slope_ceil
422
+ y_slope_floor = @y_slope_floor
400
423
  (1..HALF_HEIGHT).each do |dy|
401
424
  y_slope_ceil[dy] = ceil_height * projection / dy.to_f
402
425
  y_slope_floor[dy] = floor_height * projection / dy.to_f
@@ -414,14 +437,14 @@ module Doom
414
437
  row_offset = y * SCREEN_WIDTH
415
438
 
416
439
  if is_sky && sky_texture
417
- sky_height = sky_texture.height
418
- sky_width = sky_texture.width
419
- sky_y = y % sky_height
440
+ sky_height_mask = sky_texture.height - 1
441
+ sky_width_mask = sky_texture.width - 1
442
+ sky_y = y & sky_height_mask
420
443
  x = 0
421
444
  while x < SCREEN_WIDTH
422
445
  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
446
+ sky_x = (column_angle * 256 / Math::PI).to_i & sky_width_mask
447
+ color = sky_texture.column_pixels(sky_x)[sky_y]
425
448
  framebuffer[row_offset + x] = color
426
449
  x += 1
427
450
  end
@@ -431,7 +454,7 @@ module Doom
431
454
  ray_dist = perp_dist * column_distscale[x]
432
455
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
433
456
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
434
- color = ceil_flat[tex_x, tex_y] || 0
457
+ color = ceil_flat[tex_x, tex_y]
435
458
  framebuffer[row_offset + x] = cmap[color]
436
459
  x += 1
437
460
  end
@@ -458,7 +481,7 @@ module Doom
458
481
  ray_dist = perp_dist * column_distscale[x]
459
482
  tex_x = (player_x + ray_dist * column_cos[x]).to_i & 63
460
483
  tex_y = (neg_player_y - ray_dist * column_sin[x]).to_i & 63
461
- color = floor_flat[tex_x, tex_y] || 0
484
+ color = floor_flat[tex_x, tex_y]
462
485
  framebuffer[row_offset + x] = cmap[color]
463
486
  x += 1
464
487
  end
@@ -641,6 +664,35 @@ module Doom
641
664
  [x, y]
642
665
  end
643
666
 
667
+ # Determine which side of a seg a point is on (R_PointOnSegSide from Chocolate Doom)
668
+ # Returns true if point is on back side, false if on front side
669
+ def point_on_seg_side(x, y, seg)
670
+ v1 = @map.vertices[seg.v1]
671
+ v2 = @map.vertices[seg.v2]
672
+
673
+ lx = v1.x
674
+ ly = v1.y
675
+ ldx = v2.x - lx
676
+ ldy = v2.y - ly
677
+
678
+ # Handle axis-aligned lines
679
+ if ldx == 0
680
+ return x <= lx ? ldy > 0 : ldy < 0
681
+ end
682
+ if ldy == 0
683
+ return y <= ly ? ldx < 0 : ldx > 0
684
+ end
685
+
686
+ dx = x - lx
687
+ dy = y - ly
688
+
689
+ # Cross product to determine side
690
+ left = ldy * dx
691
+ right = dy * ldx
692
+
693
+ right >= left
694
+ end
695
+
644
696
  def draw_seg_range(x1, x2, sx1, sx2, dist1, dist2, sector, back_sector, sidedef, linedef, seg, seg_length)
645
697
  # Get seg vertices in world space for texture coordinate calculation
646
698
  seg_v1 = @map.vertices[seg.v1]
@@ -748,6 +800,12 @@ module Doom
748
800
  high_ceil = [ceil_y, back_ceil_y].max
749
801
  low_floor = [floor_y, back_floor_y].min
750
802
 
803
+ # Check for closed door (no opening between sectors)
804
+ # Matches Chocolate Doom: backsector->ceilingheight <= frontsector->floorheight
805
+ # || backsector->floorheight >= frontsector->ceilingheight
806
+ closed_door = back_sector.ceiling_height <= sector.floor_height ||
807
+ back_sector.floor_height >= sector.ceiling_height
808
+
751
809
  # Mark ceiling visplane - mark front sector's ceiling for two-sided lines
752
810
  # Matches Chocolate Doom: mark from ceilingclip+1 to yl-1 (front ceiling)
753
811
  # (yl is clamped to ceilingclip+1, so we use ceil_y which is already clamped)
@@ -811,30 +869,37 @@ module Doom
811
869
  end
812
870
 
813
871
  # 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
872
+ if closed_door
873
+ # Closed door - fully occlude this column
874
+ @wall_depth[x] = [@wall_depth[x], dist].min
875
+ @ceiling_clip[x] = SCREEN_HEIGHT
876
+ @floor_clip[x] = -1
877
+ 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
881
+ @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;
888
+ @ceiling_clip[x] = [ceil_y - 1, @ceiling_clip[x]].max
889
+ end
826
890
 
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
891
+ # Floor clip decreases (moves up) as floor is marked
892
+ if sector.floor_height < back_sector.floor_height
893
+ # Lower wall drawn - clip floor to back floor
894
+ @floor_clip[x] = [back_floor_y, @floor_clip[x]].min
895
+ 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;
901
+ @floor_clip[x] = [floor_y + 1, @floor_clip[x]].min
902
+ end
838
903
  end
839
904
  else
840
905
  # One-sided (solid) wall
@@ -883,7 +948,8 @@ module Doom
883
948
  scale1, scale2,
884
949
  silhouette,
885
950
  bsilheight, tsilheight,
886
- sprtopclip, sprbottomclip
951
+ sprtopclip, sprbottomclip,
952
+ seg
887
953
  )
888
954
  @drawsegs << drawseg
889
955
  end
@@ -911,12 +977,11 @@ module Doom
911
977
  light = calculate_light(light_level, dist)
912
978
  cmap = @colormap.maps[light]
913
979
  framebuffer = @framebuffer
914
- tex_width = texture.width
915
- tex_height = texture.height
980
+ tex_width_mask = texture.width - 1 # For power-of-2 textures: & mask is faster than %
981
+ tex_height_mask = texture.height - 1
916
982
 
917
- # Texture X coordinate (wrap around texture width)
918
- tex_x = tex_col.to_i % tex_width
919
- tex_x += tex_width if tex_x < 0
983
+ # Texture X coordinate (wrap around texture width using bitwise AND)
984
+ tex_x = tex_col.to_i & tex_width_mask
920
985
 
921
986
  # Get the column of pixels
922
987
  column = texture.column_pixels(tex_x)
@@ -939,10 +1004,9 @@ module Doom
939
1004
  y = y1
940
1005
  while y <= y2
941
1006
  screen_offset = y - y1
942
- tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
943
- tex_y += tex_height if tex_y < 0
1007
+ tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i & tex_height_mask
944
1008
 
945
- color = column[tex_y] || 0
1009
+ color = column[tex_y]
946
1010
  framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
947
1011
  y += 1
948
1012
  end
@@ -1047,18 +1111,11 @@ module Doom
1047
1111
  next if screen_x + sprite_half_width < 0
1048
1112
  next if screen_x - sprite_half_width >= SCREEN_WIDTH
1049
1113
 
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
- }
1114
+ visible_sprites << VisibleSprite.new(thing, sprite, view_x, view_y, dist, screen_x)
1058
1115
  end
1059
1116
 
1060
1117
  # Sort by distance (back to front for proper overdraw)
1061
- visible_sprites.sort_by! { |s| -s[:dist] }
1118
+ visible_sprites.sort_by! { |s| -s.dist }
1062
1119
 
1063
1120
  # Draw each sprite
1064
1121
  visible_sprites.each do |vs|
@@ -1067,10 +1124,10 @@ module Doom
1067
1124
  end
1068
1125
 
1069
1126
  def draw_sprite(vs)
1070
- sprite = vs[:sprite]
1071
- dist = vs[:dist]
1072
- screen_x = vs[:screen_x]
1073
- thing = vs[:thing]
1127
+ sprite = vs.sprite
1128
+ dist = vs.dist
1129
+ screen_x = vs.screen_x
1130
+ thing = vs.thing
1074
1131
 
1075
1132
  # Calculate scale (inverse of distance, used for depth comparison)
1076
1133
  sprite_scale = @projection / dist
@@ -1120,12 +1177,14 @@ module Doom
1120
1177
  lowscale = [ds.scale1, ds.scale2].min
1121
1178
  highscale = [ds.scale1, ds.scale2].max
1122
1179
 
1123
- # If drawseg is behind sprite, skip (seg must be closer to clip)
1180
+ # Chocolate Doom logic: skip if seg is behind sprite
1181
+ # If highscale < sprite_scale: wall entirely behind sprite
1182
+ # OR if lowscale < sprite_scale AND sprite is on front side of wall
1124
1183
  if highscale < sprite_scale
1125
1184
  next
1126
1185
  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
1186
+ # Partial overlap - check if sprite is in front of the seg
1187
+ next unless point_on_seg_side(thing.x, thing.y, ds.curline)
1129
1188
  end
1130
1189
 
1131
1190
  # Determine which silhouettes apply based on sprite Z
@@ -1208,11 +1267,6 @@ module Doom
1208
1267
  end
1209
1268
  end
1210
1269
  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
1270
  end
1217
1271
  end
1218
1272
  end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Renders the classic DOOM status bar at the bottom of the screen
6
+ class StatusBar
7
+ STATUS_BAR_HEIGHT = 32
8
+ STATUS_BAR_Y = SCREEN_HEIGHT - STATUS_BAR_HEIGHT
9
+
10
+ # DOOM status bar layout (from st_stuff.c)
11
+ # X positions are RIGHT EDGE of each number area (numbers are right-aligned)
12
+ # Y positions relative to status bar top
13
+
14
+ # Right edge X positions for numbers
15
+ AMMO_RIGHT_X = 44 # ST_AMMOX - right edge of 3-digit ammo
16
+ HEALTH_RIGHT_X = 90 # ST_HEALTHX - right edge of 3-digit health
17
+ ARMOR_RIGHT_X = 221 # ST_ARMORX - right edge of 3-digit armor
18
+
19
+ FACE_X = 149 # Adjusted for proper centering in face background
20
+ KEYS_X = 239 # ST_KEY0X
21
+
22
+ NUM_WIDTH = 14 # Width of each digit
23
+
24
+ def initialize(hud_graphics, player_state)
25
+ @gfx = hud_graphics
26
+ @player = player_state
27
+ @face_timer = 0
28
+ @face_index = 0
29
+ end
30
+
31
+ def render(framebuffer)
32
+ # Draw status bar background
33
+ draw_sprite(framebuffer, @gfx.status_bar, 0, STATUS_BAR_Y) if @gfx.status_bar
34
+
35
+ # Y position for numbers (3 pixels from top of status bar)
36
+ num_y = STATUS_BAR_Y + 3
37
+
38
+ # Draw ammo count (right-aligned ending at AMMO_RIGHT_X)
39
+ draw_number_right(framebuffer, @player.current_ammo, AMMO_RIGHT_X, num_y) if @player.current_ammo
40
+
41
+ # Draw health with percent (right-aligned ending at HEALTH_RIGHT_X)
42
+ draw_number_right(framebuffer, @player.health, HEALTH_RIGHT_X, num_y)
43
+ draw_percent(framebuffer, HEALTH_RIGHT_X, num_y)
44
+
45
+ # Draw face
46
+ draw_face(framebuffer)
47
+
48
+ # Draw armor with percent (right-aligned ending at ARMOR_RIGHT_X)
49
+ draw_number_right(framebuffer, @player.armor, ARMOR_RIGHT_X, num_y)
50
+ draw_percent(framebuffer, ARMOR_RIGHT_X, num_y)
51
+
52
+ # Draw keys
53
+ draw_keys(framebuffer)
54
+ end
55
+
56
+ def update
57
+ # Cycle face animation
58
+ @face_timer += 1
59
+ if @face_timer > 15 # Change face every ~0.5 seconds
60
+ @face_timer = 0
61
+ @face_index = (@face_index + 1) % 3
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def draw_sprite(framebuffer, sprite, x, y)
68
+ return unless sprite
69
+
70
+ sprite.width.times do |sx|
71
+ column = sprite.column_pixels(sx)
72
+ next unless column
73
+
74
+ draw_x = x + sx
75
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
76
+
77
+ column.each_with_index do |color, sy|
78
+ next unless color
79
+
80
+ draw_y = y + sy
81
+ next if draw_y < 0 || draw_y >= SCREEN_HEIGHT
82
+
83
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
84
+ end
85
+ end
86
+ end
87
+
88
+ # Draw number right-aligned with right edge at right_x
89
+ def draw_number_right(framebuffer, value, right_x, y)
90
+ return unless value
91
+
92
+ value = value.to_i.clamp(-999, 999)
93
+ str = value.to_s
94
+
95
+ # Draw from right to left, starting from right edge
96
+ current_x = right_x
97
+ str.reverse.each_char do |char|
98
+ digit_sprite = if char == '-'
99
+ @gfx.numbers['-']
100
+ else
101
+ @gfx.numbers[char.to_i]
102
+ end
103
+
104
+ if digit_sprite
105
+ current_x -= NUM_WIDTH
106
+ draw_sprite(framebuffer, digit_sprite, current_x, y)
107
+ end
108
+ end
109
+ end
110
+
111
+ def draw_percent(framebuffer, x, y)
112
+ percent = @gfx.numbers['%']
113
+ draw_sprite(framebuffer, percent, x, y) if percent
114
+ end
115
+
116
+ def draw_face(framebuffer)
117
+ # In DOOM, face sprite health levels are inverted: 0 = full health, 4 = dying
118
+ sprite_health = 4 - @player.health_level
119
+ faces = @gfx.faces[sprite_health]
120
+ return unless faces
121
+
122
+ # Get current face sprite
123
+ face = if @player.health <= 0
124
+ @gfx.faces[:dead]
125
+ elsif faces[:straight] && faces[:straight][@face_index]
126
+ faces[:straight][@face_index]
127
+ else
128
+ faces[:straight]&.first
129
+ end
130
+
131
+ return unless face
132
+
133
+ # Position face in the background area
134
+ face_x = FACE_X
135
+ face_y = STATUS_BAR_Y + 2 # Slightly below top of status bar
136
+ draw_sprite(framebuffer, face, face_x, face_y)
137
+ end
138
+
139
+ def draw_keys(framebuffer)
140
+ key_x = KEYS_X
141
+ key_spacing = 10
142
+
143
+ # Blue keys (top row)
144
+ if @player.keys[:blue_card]
145
+ draw_sprite(framebuffer, @gfx.keys[:blue_card], key_x, STATUS_BAR_Y + 3)
146
+ elsif @player.keys[:blue_skull]
147
+ draw_sprite(framebuffer, @gfx.keys[:blue_skull], key_x, STATUS_BAR_Y + 3)
148
+ end
149
+
150
+ # Yellow keys (middle row)
151
+ if @player.keys[:yellow_card]
152
+ draw_sprite(framebuffer, @gfx.keys[:yellow_card], key_x, STATUS_BAR_Y + 13)
153
+ elsif @player.keys[:yellow_skull]
154
+ draw_sprite(framebuffer, @gfx.keys[:yellow_skull], key_x, STATUS_BAR_Y + 13)
155
+ end
156
+
157
+ # Red keys (bottom row)
158
+ if @player.keys[:red_card]
159
+ draw_sprite(framebuffer, @gfx.keys[:red_card], key_x, STATUS_BAR_Y + 23)
160
+ elsif @player.keys[:red_skull]
161
+ draw_sprite(framebuffer, @gfx.keys[:red_skull], key_x, STATUS_BAR_Y + 23)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Renders the first-person weapon view
6
+ class WeaponRenderer
7
+ # Weapon is rendered above the status bar
8
+ WEAPON_AREA_HEIGHT = SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT
9
+
10
+ def initialize(hud_graphics, player_state)
11
+ @gfx = hud_graphics
12
+ @player = player_state
13
+ end
14
+
15
+ def render(framebuffer)
16
+ weapon_name = @player.weapon_name
17
+ weapon_data = @gfx.weapons[weapon_name]
18
+ return unless weapon_data
19
+
20
+ # Get the appropriate frame
21
+ sprite = if @player.attacking && weapon_data[:fire]
22
+ frame = @player.attack_frame.clamp(0, weapon_data[:fire].length - 1)
23
+ weapon_data[:fire][frame]
24
+ else
25
+ weapon_data[:idle]
26
+ end
27
+
28
+ return unless sprite
29
+
30
+ # Calculate position with bob offset
31
+ bob_x = @player.weapon_bob_x.to_i
32
+ bob_y = @player.weapon_bob_y.to_i
33
+
34
+ # Center weapon horizontally (sprite width / 2 from center)
35
+ # Position weapon at bottom of view area
36
+ x = (SCREEN_WIDTH / 2) - (sprite.width / 2) + bob_x
37
+ # Only allow downward bob (positive values) to keep weapon bottom at status bar
38
+ clamped_bob_y = [bob_y, 0].max
39
+ y = WEAPON_AREA_HEIGHT - sprite.height + clamped_bob_y
40
+
41
+ # Add some vertical offset during attack (recoil effect)
42
+ if @player.attacking
43
+ recoil = case @player.attack_frame
44
+ when 0 then -6
45
+ when 1 then -3
46
+ when 2 then 3
47
+ else 0
48
+ end
49
+ y += recoil
50
+ end
51
+
52
+ draw_weapon_sprite(framebuffer, sprite, x, y)
53
+
54
+ # Draw muzzle flash for pistol
55
+ if @player.attacking && @player.attack_frame < 2
56
+ draw_muzzle_flash(framebuffer, weapon_name, x, y)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def draw_weapon_sprite(framebuffer, sprite, base_x, base_y)
63
+ return unless sprite
64
+
65
+ # Clip to screen bounds (don't draw over status bar)
66
+ max_y = WEAPON_AREA_HEIGHT - 1
67
+
68
+ sprite.width.times do |sx|
69
+ column = sprite.column_pixels(sx)
70
+ next unless column
71
+
72
+ draw_x = base_x + sx
73
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
74
+
75
+ column.each_with_index do |color, sy|
76
+ next unless color # Skip transparent pixels
77
+
78
+ draw_y = base_y + sy
79
+ next if draw_y < 0 || draw_y > max_y
80
+
81
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
82
+ end
83
+ end
84
+ end
85
+
86
+ def draw_muzzle_flash(framebuffer, weapon_name, weapon_x, weapon_y)
87
+ weapon_data = @gfx.weapons[weapon_name]
88
+ return unless weapon_data && weapon_data[:flash]
89
+
90
+ flash_frame = @player.attack_frame.clamp(0, weapon_data[:flash].length - 1)
91
+ flash_sprite = weapon_data[:flash][flash_frame]
92
+ return unless flash_sprite
93
+
94
+ # Flash is drawn at weapon position (sprite handles offset)
95
+ flash_x = (SCREEN_WIDTH / 2) - flash_sprite.left_offset
96
+ flash_y = WEAPON_AREA_HEIGHT - flash_sprite.top_offset
97
+
98
+ draw_weapon_sprite(framebuffer, flash_sprite, flash_x, flash_y)
99
+ end
100
+ end
101
+ end
102
+ end