doom 0.5.0 → 0.8.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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # DOOM's built-in font loaded from STCFN patches (ASCII 33-121).
6
+ # Uppercase only -- lowercase is auto-uppercased.
7
+ class Font
8
+ SPACE_WIDTH = 4
9
+
10
+ def initialize(wad, hud_graphics)
11
+ @chars = {}
12
+ (33..121).each do |ascii|
13
+ name = "STCFN%03d" % ascii
14
+ sprite = hud_graphics.send(:load_graphic, name)
15
+ @chars[ascii] = sprite if sprite
16
+ end
17
+ end
18
+
19
+ # Draw text into framebuffer at (x, y). Returns width drawn.
20
+ def draw_text(framebuffer, text, x, y, screen_width: 320, screen_height: 240)
21
+ cursor_x = x
22
+ text.upcase.each_char do |char|
23
+ if char == ' '
24
+ cursor_x += SPACE_WIDTH
25
+ next
26
+ end
27
+
28
+ sprite = @chars[char.ord]
29
+ next unless sprite
30
+
31
+ draw_char(framebuffer, sprite, cursor_x, y, screen_width, screen_height)
32
+ cursor_x += sprite.width
33
+ end
34
+ cursor_x - x
35
+ end
36
+
37
+ # Measure text width without drawing
38
+ def text_width(text)
39
+ width = 0
40
+ text.upcase.each_char do |char|
41
+ if char == ' '
42
+ width += SPACE_WIDTH
43
+ next
44
+ end
45
+ sprite = @chars[char.ord]
46
+ width += sprite.width if sprite
47
+ end
48
+ width
49
+ end
50
+
51
+ # Draw text centered horizontally
52
+ def draw_centered(framebuffer, text, y, screen_width: 320, screen_height: 240)
53
+ w = text_width(text)
54
+ x = (screen_width - w) / 2
55
+ draw_text(framebuffer, text, x, y, screen_width: screen_width, screen_height: screen_height)
56
+ end
57
+
58
+ private
59
+
60
+ def draw_char(framebuffer, sprite, x, y, screen_width, screen_height)
61
+ sprite.width.times do |col_x|
62
+ sx = x + col_x
63
+ next if sx < 0 || sx >= screen_width
64
+
65
+ col = sprite.column_pixels(col_x)
66
+ next unless col
67
+
68
+ col.each_with_index do |color, col_y|
69
+ next unless color
70
+ sy = y + col_y
71
+ next if sy < 0 || sy >= screen_height
72
+ framebuffer[sy * screen_width + sx] = color
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -16,10 +16,14 @@ module Doom
16
16
 
17
17
  # Drawseg stores wall segment info for sprite clipping
18
18
  # Matches Chocolate Doom's drawseg_t structure
19
- Drawseg = Struct.new(:x1, :x2, :scale1, :scale2,
19
+ Drawseg = Struct.new(:x1, :x2, :scale1, :scale2, :scalestep,
20
20
  :silhouette, :bsilheight, :tsilheight,
21
21
  :sprtopclip, :sprbottomclip,
22
- :curline) # seg for point-on-side test
22
+ :curline, # seg for point-on-side test
23
+ :maskedtexturecol, # per-column texture X for masked mid textures
24
+ :frontsector, :backsector, # sectors for masked rendering
25
+ :sidedef, # sidedef for texture lookup
26
+ :dist1, :dist2, :sx1, :sx2) # for per-column distance recomputation
23
27
 
24
28
  # VisibleSprite stores sprite data for sorting and rendering
25
29
  # Struct is faster than Hash for fixed-field data
@@ -64,6 +68,8 @@ module Doom
64
68
  @animations = animations
65
69
  @hidden_things = nil
66
70
  @combat = nil
71
+ @monster_ai = nil
72
+ @leveltime = 0
67
73
 
68
74
  @framebuffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, 0)
69
75
 
@@ -104,7 +110,7 @@ module Doom
104
110
  end
105
111
 
106
112
  attr_reader :player_x, :player_y, :player_z, :sin_angle, :cos_angle, :framebuffer
107
- attr_writer :hidden_things, :combat
113
+ attr_writer :hidden_things, :combat, :monster_ai, :leveltime
108
114
 
109
115
  # Diagnostic: returns info about all sprites and why they are/aren't visible
110
116
  def sprite_diagnostics
@@ -248,8 +254,8 @@ module Doom
248
254
  @sprite_floor_clip.replace(@floor_clip)
249
255
  @sprite_wall_depth.replace(@wall_depth)
250
256
 
251
- # Render sprites
252
- render_sprites if @sprites
257
+ # R_DrawMasked: render sprites and masked middle textures interleaved
258
+ draw_masked if @sprites
253
259
  end
254
260
 
255
261
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
@@ -922,8 +928,27 @@ module Doom
922
928
  tsilheight = -Float::INFINITY
923
929
  end
924
930
 
931
+ # Detect masked middle texture (grates, bars, fences)
932
+ has_masked = false
933
+ @current_masked_cols = nil
934
+ if back_sector && sidedef
935
+ mid_tex = sidedef.middle_texture
936
+ if mid_tex && mid_tex != '-' && !mid_tex.empty?
937
+ has_masked = true
938
+ @current_masked_cols = Array.new(x2 - x1 + 1)
939
+ # Force silhouette so sprites get clipped against this seg
940
+ if (silhouette & SIL_TOP) == 0
941
+ silhouette |= SIL_TOP
942
+ tsilheight = -Float::INFINITY
943
+ end
944
+ if (silhouette & SIL_BOTTOM) == 0
945
+ silhouette |= SIL_BOTTOM
946
+ bsilheight = Float::INFINITY
947
+ end
948
+ end
949
+ end
950
+
925
951
  # Check planes for this seg range (R_CheckPlane equivalent)
926
- # This may create new visplanes if column ranges would overlap
927
952
  if @current_floor_plane
928
953
  @current_floor_plane = check_plane(@current_floor_plane, x1, x2)
929
954
  end
@@ -1065,6 +1090,11 @@ module Doom
1065
1090
  sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
1066
1091
  end
1067
1092
 
1093
+ # Store masked texture column for deferred rendering (grates, bars)
1094
+ if @current_masked_cols
1095
+ @current_masked_cols[x - x1] = tex_col
1096
+ end
1097
+
1068
1098
  # Update clip bounds
1069
1099
  if closed_door
1070
1100
  @wall_depth[x] = [@wall_depth[x], dist].min
@@ -1137,22 +1167,26 @@ module Doom
1137
1167
  end
1138
1168
  end
1139
1169
 
1140
- # Save drawseg for sprite clipping (after columns are rendered)
1141
- # Copy the clip arrays for the segment's screen range
1142
- if silhouette != SIL_NONE
1170
+ # Save drawseg for sprite clipping and masked rendering
1171
+ if silhouette != SIL_NONE || has_masked
1143
1172
  sprtopclip = @ceiling_clip[x1..x2].dup
1144
1173
  sprbottomclip = @floor_clip[x1..x2].dup
1174
+ scalestep = (x2 > x1) ? (scale2 - scale1) / (x2 - x1) : 0
1145
1175
 
1146
1176
  drawseg = Drawseg.new(
1147
1177
  x1, x2,
1148
- scale1, scale2,
1178
+ scale1, scale2, scalestep,
1149
1179
  silhouette,
1150
1180
  bsilheight, tsilheight,
1151
1181
  sprtopclip, sprbottomclip,
1152
- seg
1182
+ seg,
1183
+ has_masked ? @current_masked_cols : nil,
1184
+ sector, back_sector, sidedef,
1185
+ dist1, dist2, sx1.to_f, sx2.to_f
1153
1186
  )
1154
1187
  @drawsegs << drawseg
1155
1188
  end
1189
+ @current_masked_cols = nil
1156
1190
  end
1157
1191
 
1158
1192
  # Wall column drawing with proper texture mapping
@@ -1207,7 +1241,110 @@ module Doom
1207
1241
  tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1208
1242
 
1209
1243
  color = column[tex_y]
1210
- framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1244
+ framebuffer[y * SCREEN_WIDTH + x] = cmap[color] if color
1245
+ y += 1
1246
+ end
1247
+ end
1248
+
1249
+ # Cache which textures have transparent pixels
1250
+ def texture_has_transparency?(texture)
1251
+ @transparency_cache ||= {}
1252
+ name = texture.name
1253
+ return @transparency_cache[name] if @transparency_cache.key?(name)
1254
+ @transparency_cache[name] = texture.width.times.any? do |x|
1255
+ col = texture.column_pixels(x)
1256
+ col&.any?(&:nil?)
1257
+ end
1258
+ end
1259
+
1260
+ # Draw a deferred masked column using per-drawseg clip values saved at BSP time
1261
+ def draw_wall_column_masked_deferred(md)
1262
+ x = md[:x]
1263
+ tex_name = md[:tex]
1264
+ return if tex_name.nil? || tex_name.empty? || tex_name == '-'
1265
+
1266
+ # Use saved clips (from BSP time, before far walls closed them)
1267
+ # AND final sprite clips (to prevent far grates drawing over near walls)
1268
+ # Take the tightest combination of both
1269
+ y1 = [md[:clip_top] + 1, @sprite_ceiling_clip[x] + 1, md[:y1]].max
1270
+ y2 = [md[:clip_bottom] - 1, @sprite_floor_clip[x] - 1, md[:y2]].min
1271
+ return if y1 > y2
1272
+
1273
+ # Depth test: far grate behind a near solid wall
1274
+ return if md[:dist] > @sprite_wall_depth[x]
1275
+
1276
+ texture = @textures[anim_texture(tex_name)]
1277
+ return unless texture
1278
+
1279
+ light = calculate_light(md[:light], md[:dist])
1280
+ cmap = @colormap.maps[light]
1281
+ framebuffer = @framebuffer
1282
+ tex_width = texture.width
1283
+ tex_height = texture.height
1284
+
1285
+ tex_x = md[:tex_col].to_i % tex_width
1286
+ column = texture.column_pixels(tex_x)
1287
+ return unless column
1288
+
1289
+ scale = md[:scale]
1290
+ tex_step = 1.0 / scale
1291
+ unclipped_y1 = HALF_HEIGHT - (md[:world_top] - @player_z) * scale
1292
+ tex_y_at_y1 = md[:tex_y] + (y1 - unclipped_y1) * tex_step
1293
+
1294
+ y1 = 0 if y1 < 0
1295
+ y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1296
+
1297
+ y = y1
1298
+ while y <= y2
1299
+ screen_offset = y - y1
1300
+ tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1301
+ color = column[tex_y]
1302
+ framebuffer[y * SCREEN_WIDTH + x] = cmap[color] if color
1303
+ y += 1
1304
+ end
1305
+ end
1306
+
1307
+ # Draw a masked (transparent) wall column for middle textures on two-sided linedefs.
1308
+ # Unlike draw_wall_column_ex, skips nil pixels (transparent areas).
1309
+ def draw_wall_column_masked(x, y1, y2, texture_name, dist, light_level, tex_col, tex_y_start, scale, world_top)
1310
+ return if y1 > y2
1311
+ return if texture_name.nil? || texture_name.empty? || texture_name == '-'
1312
+
1313
+ clip_top = @ceiling_clip[x] + 1
1314
+ clip_bottom = @floor_clip[x] - 1
1315
+ y1 = [y1, clip_top].max
1316
+ y2 = [y2, clip_bottom].min
1317
+ return if y1 > y2
1318
+
1319
+ texture = @textures[anim_texture(texture_name)]
1320
+ return unless texture
1321
+
1322
+ light = calculate_light(light_level, dist)
1323
+ cmap = @colormap.maps[light]
1324
+ framebuffer = @framebuffer
1325
+ tex_width = texture.width
1326
+ tex_height = texture.height
1327
+
1328
+ tex_x = tex_col.to_i % tex_width
1329
+ column = texture.column_pixels(tex_x)
1330
+ return unless column
1331
+
1332
+ tex_step = 1.0 / scale
1333
+ unclipped_y1 = HALF_HEIGHT - (world_top - @player_z) * scale
1334
+ tex_y_at_y1 = tex_y_start + (y1 - unclipped_y1) * tex_step
1335
+
1336
+ y1 = 0 if y1 < 0
1337
+ y2 = SCREEN_HEIGHT - 1 if y2 >= SCREEN_HEIGHT
1338
+
1339
+ y = y1
1340
+ while y <= y2
1341
+ screen_offset = y - y1
1342
+ tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1343
+ color = column[tex_y]
1344
+ # Skip transparent pixels (nil in patch data)
1345
+ if color
1346
+ framebuffer[y * SCREEN_WIDTH + x] = cmap[color]
1347
+ end
1211
1348
  y += 1
1212
1349
  end
1213
1350
  end
@@ -1302,9 +1439,28 @@ module Doom
1302
1439
  dy = thing.y - @player_y
1303
1440
  angle_to_thing = Math.atan2(dy, dx)
1304
1441
 
1305
- # Get sprite - use death frame if monster is dead
1442
+ # Get sprite: death frame > walking frame > idle frame
1306
1443
  if @combat && @combat.dead?(thing_idx)
1307
1444
  sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1445
+ next unless sprite # Barrel disappeared after explosion
1446
+ elsif @monster_ai
1447
+ mon = @monster_ai.monster_by_thing_idx[thing_idx]
1448
+ if mon && mon.active
1449
+ if mon.attacking
1450
+ # Attack animation: show attack frames (E, F, G...)
1451
+ prefix = @sprites.prefix_for(thing.type)
1452
+ atk_frames = Game::MonsterAI::ATTACK_FRAMES[prefix]
1453
+ if atk_frames
1454
+ frame_idx = (mon.attack_frame_tic / Game::MonsterAI::ATTACK_FRAME_TICS).clamp(0, atk_frames.size - 1)
1455
+ sprite = @sprites.get_frame(thing.type, atk_frames[frame_idx], angle_to_thing, thing.angle)
1456
+ end
1457
+ else
1458
+ # Walking animation: cycle through frames A-D
1459
+ walk_frame = %w[A B C D][@leveltime / 4 % 4]
1460
+ sprite = @sprites.get_frame(thing.type, walk_frame, angle_to_thing, thing.angle)
1461
+ end
1462
+ end
1463
+ sprite ||= @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1308
1464
  else
1309
1465
  sprite = @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1310
1466
  end
@@ -1324,9 +1480,223 @@ module Doom
1324
1480
  # Sort by distance (back to front for proper overdraw)
1325
1481
  visible_sprites.sort_by! { |s| -s.dist }
1326
1482
 
1327
- # Draw each sprite
1483
+ # Draw each sprite with drawseg interleaving
1328
1484
  visible_sprites.each do |vs|
1329
- draw_sprite(vs)
1485
+ draw_sprite_with_masking(vs)
1486
+ end
1487
+
1488
+ # Draw remaining masked segs not triggered by sprites
1489
+ @drawsegs.reverse_each do |ds|
1490
+ next unless ds.maskedtexturecol
1491
+ render_masked_seg_range(ds, ds.x1, ds.x2)
1492
+ end
1493
+
1494
+ # Draw projectiles, explosions, and bullet puffs
1495
+ if @combat
1496
+ render_projectiles
1497
+ render_puffs
1498
+ end
1499
+ end
1500
+
1501
+ # Chocolate Doom R_DrawMasked: interleave sprites with masked drawsegs
1502
+ def draw_masked
1503
+ render_sprites
1504
+ end
1505
+
1506
+ # Draw sprite, rendering masked drawsegs behind it first (R_DrawSprite)
1507
+ def draw_sprite_with_masking(spr)
1508
+ sprite_scale = @projection / spr.dist
1509
+
1510
+ # Scan drawsegs newest to oldest (nearest to farthest)
1511
+ @drawsegs.reverse_each do |ds|
1512
+ next if ds.x1 > spr.screen_x.to_i + spr.sprite.width || ds.x2 < spr.screen_x.to_i - spr.sprite.width
1513
+ next if ds.silhouette == SIL_NONE && ds.maskedtexturecol.nil?
1514
+
1515
+ ds_max_scale = [ds.scale1, ds.scale2].max
1516
+ ds_min_scale = [ds.scale1, ds.scale2].min
1517
+
1518
+ # Is the drawseg behind the sprite?
1519
+ if ds_max_scale < sprite_scale
1520
+ # Draw masked texture behind the sprite
1521
+ if ds.maskedtexturecol
1522
+ r1 = [ds.x1, spr.screen_x.to_i - spr.sprite.width].max
1523
+ r2 = [ds.x2, spr.screen_x.to_i + spr.sprite.width].min
1524
+ render_masked_seg_range(ds, r1, r2)
1525
+ end
1526
+ next # Don't clip against things behind
1527
+ end
1528
+ end
1529
+
1530
+ draw_sprite(spr)
1531
+ end
1532
+
1533
+ # Chocolate Doom R_RenderMaskedSegRange
1534
+ def render_masked_seg_range(ds, rx1, rx2)
1535
+ return unless ds.maskedtexturecol
1536
+ return unless ds.sidedef
1537
+
1538
+ mid_tex_name = ds.sidedef.middle_texture
1539
+ return if mid_tex_name.nil? || mid_tex_name == '-' || mid_tex_name.empty?
1540
+
1541
+ texture = @textures[anim_texture(mid_tex_name)]
1542
+ return unless texture
1543
+
1544
+ front = ds.frontsector
1545
+ back = ds.backsector
1546
+ return unless front && back
1547
+
1548
+ # Texture anchoring (matching Chocolate Doom)
1549
+ if ds.curline && @map.linedefs[ds.curline.linedef].lower_unpegged?
1550
+ higher_floor = [front.floor_height, back.floor_height].max
1551
+ dc_texturemid = higher_floor + texture.height - @player_z
1552
+ else
1553
+ lower_ceiling = [front.ceiling_height, back.ceiling_height].min
1554
+ dc_texturemid = lower_ceiling - @player_z
1555
+ end
1556
+ dc_texturemid += ds.sidedef.y_offset
1557
+
1558
+ light_level = front.light_level
1559
+
1560
+ # Iterate columns
1561
+ rx1 = [rx1, ds.x1].max
1562
+ rx2 = [rx2, ds.x2].min
1563
+
1564
+ (rx1..rx2).each do |x|
1565
+ col_idx = x - ds.x1
1566
+ next if col_idx < 0 || col_idx >= ds.maskedtexturecol.size
1567
+
1568
+ tex_col = ds.maskedtexturecol[col_idx]
1569
+ next unless tex_col # nil = already drawn or empty
1570
+
1571
+ # Per-column distance using same 1/z interpolation as wall renderer
1572
+ if ds.dist1 && ds.dist2 && ds.dist1 > 0 && ds.dist2 > 0 && ds.sx2 != ds.sx1
1573
+ t = ((x - ds.sx1) / (ds.sx2 - ds.sx1)).clamp(0.0, 1.0)
1574
+ inv_dist = (1.0 - t) / ds.dist1 + t / ds.dist2
1575
+ col_dist = 1.0 / inv_dist
1576
+ else
1577
+ col_dist = ds.dist1 || 1.0
1578
+ end
1579
+ next if col_dist < 1
1580
+ spryscale = @projection / col_dist
1581
+ spryscale = [spryscale, 64.0].min
1582
+
1583
+ # Screen Y of texture top
1584
+ sprtopscreen = HALF_HEIGHT - dc_texturemid * spryscale
1585
+
1586
+ # Get clip bounds from drawseg
1587
+ clip_idx = x - ds.x1
1588
+ mceilingclip = ds.sprtopclip[clip_idx]
1589
+ mfloorclip = ds.sprbottomclip[clip_idx]
1590
+
1591
+ # Calculate column draw range
1592
+ tex_x = tex_col.to_i % texture.width
1593
+ column = texture.column_pixels(tex_x)
1594
+ next unless column
1595
+
1596
+ iscale = 1.0 / spryscale
1597
+
1598
+ light = calculate_light(light_level, col_dist)
1599
+ cmap = @colormap.maps[light]
1600
+
1601
+ # Draw each non-nil run of pixels (transparency)
1602
+ tex_height = texture.height
1603
+ y_start = nil
1604
+ (0..tex_height).each do |ty|
1605
+ color = ty < tex_height ? column[ty] : nil
1606
+ if color
1607
+ y_start = ty unless y_start
1608
+ elsif y_start
1609
+ # Draw run from y_start to ty-1
1610
+ top_y = (sprtopscreen + y_start * spryscale).to_i
1611
+ bot_y = (sprtopscreen + ty * spryscale).to_i - 1
1612
+ top_y = [top_y, mceilingclip + 1].max
1613
+ bot_y = [bot_y, mfloorclip - 1].min
1614
+
1615
+ if top_y <= bot_y
1616
+ top_y = [top_y, 0].max
1617
+ bot_y = [bot_y, SCREEN_HEIGHT - 1].min
1618
+ tex_frac = y_start + (top_y - (sprtopscreen + y_start * spryscale)) * iscale
1619
+ y = top_y
1620
+ while y <= bot_y
1621
+ t = ((tex_frac).to_i % tex_height)
1622
+ c = column[t]
1623
+ @framebuffer[y * SCREEN_WIDTH + x] = cmap[c] if c
1624
+ tex_frac += iscale
1625
+ y += 1
1626
+ end
1627
+ end
1628
+ y_start = nil
1629
+ end
1630
+ end
1631
+
1632
+ # Mark column as drawn
1633
+ ds.maskedtexturecol[col_idx] = nil
1634
+ end
1635
+ end
1636
+
1637
+ # Stub for projectiles/explosions that carries a z height
1638
+ ProjectileStub = Struct.new(:x, :y, :angle, :type, :flags, :z)
1639
+
1640
+ def render_projectiles
1641
+ # Render all projectiles in flight
1642
+ @combat.projectiles.each do |proj|
1643
+ view_x, view_y = transform_point(proj.x, proj.y)
1644
+ next if view_y <= 0
1645
+
1646
+ prefix = proj.sprite_prefix || 'MISL'
1647
+
1648
+ # Try rotation-based sprite first (rockets, baron fireballs)
1649
+ rocket_angle = Math.atan2(proj.dy, proj.dx)
1650
+ viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
1651
+ angle_diff = (viewer_angle - rocket_angle) % (2 * Math::PI)
1652
+ rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
1653
+
1654
+ # Animate flight: cycle A/B frames
1655
+ flight_frame = %w[A B][@leveltime / 4 % 2]
1656
+ proj_sprite = @sprites.send(:load_sprite_frame, prefix, flight_frame, rotation)
1657
+ proj_sprite ||= @sprites.send(:load_sprite_frame, prefix, flight_frame, 0)
1658
+ proj_sprite ||= @sprites.send(:load_sprite_frame, prefix, 'A', 0)
1659
+ next unless proj_sprite
1660
+
1661
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1662
+ stub = ProjectileStub.new(proj.x, proj.y, 0, 0, 0, proj.z)
1663
+ visible = VisibleSprite.new(stub, proj_sprite, view_x, view_y, view_y, screen_x)
1664
+ draw_sprite(visible)
1665
+ end
1666
+
1667
+ # Render explosions at projectile height
1668
+ @combat.explosions.each do |expl|
1669
+ view_x, view_y = transform_point(expl[:x], expl[:y])
1670
+ next if view_y <= 0
1671
+ elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
1672
+ frame_idx = (elapsed / 4).clamp(0, 2)
1673
+
1674
+ prefix = expl[:sprite] || 'MISL'
1675
+ frame_letter = (prefix == 'MISL') ? %w[B C D][frame_idx] : %w[C D E][frame_idx]
1676
+
1677
+ expl_sprite = @sprites.send(:load_sprite_frame, prefix, frame_letter, 0)
1678
+ next unless expl_sprite
1679
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1680
+ expl_z = expl[:z] || @player_z
1681
+ stub = ProjectileStub.new(expl[:x], expl[:y], 0, 0, 0, expl_z)
1682
+ visible = VisibleSprite.new(stub, expl_sprite, view_x, view_y, view_y, screen_x)
1683
+ draw_sprite(visible)
1684
+ end
1685
+ end
1686
+
1687
+ def render_puffs
1688
+ @combat.puffs.each do |puff|
1689
+ view_x, view_y = transform_point(puff[:x], puff[:y])
1690
+ next if view_y <= 0
1691
+ elapsed = @combat.instance_variable_get(:@tic) - puff[:tic]
1692
+ frame_idx = (elapsed / 3).clamp(0, 3)
1693
+ frame_letter = %w[A B C D][frame_idx]
1694
+ puff_sprite = @sprites.send(:load_sprite_frame, 'PUFF', frame_letter, 0)
1695
+ next unless puff_sprite
1696
+ screen_x = HALF_WIDTH + (view_x * @projection / view_y)
1697
+ stub = ProjectileStub.new(puff[:x], puff[:y], 0, 0, 0, puff[:z])
1698
+ visible = VisibleSprite.new(stub, puff_sprite, view_x, view_y, view_y, screen_x)
1699
+ draw_sprite(visible)
1330
1700
  end
1331
1701
  end
1332
1702
 
@@ -1359,10 +1729,17 @@ module Doom
1359
1729
  x2 = [sprite_right, SCREEN_WIDTH - 1].min
1360
1730
  return if x1 > x2
1361
1731
 
1362
- # Calculate sprite world Z positions
1732
+ # Calculate sprite world Z positions (matching Chocolate Doom R_ProjectSprite)
1733
+ # gzt = mobj->z + spritetopoffset (top of sprite)
1734
+ # gz = gzt - spriteheight (bottom of sprite, 1:1 pixel:unit)
1363
1735
  thing_floor = sector ? sector.floor_height : 0
1364
- sprite_gz = thing_floor # bottom of sprite in world Z
1365
- sprite_gzt = thing_floor + sprite.top_offset # top of sprite in world Z
1736
+ if thing.is_a?(ProjectileStub) && thing.z
1737
+ base_z = thing.z
1738
+ else
1739
+ base_z = thing_floor
1740
+ end
1741
+ sprite_gzt = base_z + sprite.top_offset
1742
+ sprite_gz = sprite_gzt - sprite.height
1366
1743
 
1367
1744
  # Initialize per-column clip arrays (-2 = not yet clipped)
1368
1745
  clipbot = Array.new(SCREEN_WIDTH, -2)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # DOOM's screen melt/wipe effect from wipe.c.
6
+ # Each column slides down at a slightly different speed,
7
+ # revealing the new screen underneath.
8
+ class ScreenMelt
9
+ WIDTH = SCREEN_WIDTH
10
+ HEIGHT = SCREEN_HEIGHT
11
+ MELT_SPEED = HEIGHT / 30 # ~8 pixels per frame (Chocolate Doom does HEIGHT/15 at 35fps)
12
+
13
+ def initialize(old_screen, new_screen)
14
+ # Snapshot both screens (arrays of palette indices, 320x240)
15
+ @old = old_screen.dup
16
+ @new = new_screen.dup
17
+ @done = false
18
+
19
+ # Initialize column offsets (from wipe_initMelt in wipe.c)
20
+ # Column 0 gets random negative offset, each subsequent column
21
+ # varies by -1/0/+1 from previous, creating a jagged melt line
22
+ @y = Array.new(WIDTH)
23
+ @y[0] = -(rand(16))
24
+ (1...WIDTH).each do |i|
25
+ @y[i] = @y[i - 1] + (rand(3) - 1)
26
+ @y[i] = -15 if @y[i] < -15
27
+ @y[i] = 0 if @y[i] > 0
28
+ end
29
+ end
30
+
31
+ def done?
32
+ @done
33
+ end
34
+
35
+ # Advance one tic. Returns the composited framebuffer.
36
+ def update(framebuffer)
37
+ all_done = true
38
+
39
+ WIDTH.times do |x|
40
+ if @y[x] < 0
41
+ @y[x] += 1
42
+ all_done = false
43
+ # Column hasn't started melting yet - show old screen
44
+ HEIGHT.times { |row| framebuffer[row * WIDTH + x] = @old[row * WIDTH + x] }
45
+ elsif @y[x] < HEIGHT
46
+ all_done = false
47
+ dy = @y[x]
48
+
49
+ # Top part: new screen revealed
50
+ dy.times do |row|
51
+ framebuffer[row * WIDTH + x] = @new[row * WIDTH + x]
52
+ end
53
+
54
+ # Bottom part: old screen shifted down
55
+ (dy...HEIGHT).each do |row|
56
+ src_row = row - dy
57
+ framebuffer[row * WIDTH + x] = @old[src_row * WIDTH + x]
58
+ end
59
+
60
+ @y[x] += MELT_SPEED
61
+ else
62
+ # Column fully melted - show new screen
63
+ HEIGHT.times { |row| framebuffer[row * WIDTH + x] = @new[row * WIDTH + x] }
64
+ end
65
+ end
66
+
67
+ @done = all_done
68
+ end
69
+ end
70
+ end
71
+ end