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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/doom/game/player_state.rb +221 -0
- data/lib/doom/game/sector_actions.rb +162 -0
- data/lib/doom/platform/gosu_window.rb +179 -2
- data/lib/doom/render/renderer.rb +140 -86
- data/lib/doom/render/status_bar.rb +166 -0
- data/lib/doom/render/weapon_renderer.rb +102 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/colormap.rb +0 -6
- data/lib/doom/wad/flat.rb +0 -21
- data/lib/doom/wad/hud_graphics.rb +189 -0
- data/lib/doom/wad/texture.rb +0 -10
- data/lib/doom/wad_downloader.rb +2 -2
- data/lib/doom.rb +15 -1
- metadata +7 -2
data/lib/doom/render/renderer.rb
CHANGED
|
@@ -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
|
|
131
|
-
@sprite_floor_clip
|
|
132
|
-
@sprite_wall_depth
|
|
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
|
-
@
|
|
141
|
-
|
|
142
|
-
@
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
278
|
-
|
|
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 = (
|
|
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
|
|
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
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
#
|
|
398
|
-
y_slope_ceil =
|
|
399
|
-
y_slope_floor =
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
sky_y = y
|
|
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 = (
|
|
424
|
-
color = sky_texture.column_pixels(sky_x)[sky_y]
|
|
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]
|
|
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]
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
@ceiling_clip[x] =
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
1071
|
-
dist = vs
|
|
1072
|
-
screen_x = vs
|
|
1073
|
-
thing = vs
|
|
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
|
-
#
|
|
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
|
-
#
|
|
1128
|
-
|
|
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
|