doom 0.6.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
@@ -250,8 +254,8 @@ module Doom
250
254
  @sprite_floor_clip.replace(@floor_clip)
251
255
  @sprite_wall_depth.replace(@wall_depth)
252
256
 
253
- # Render sprites
254
- render_sprites if @sprites
257
+ # R_DrawMasked: render sprites and masked middle textures interleaved
258
+ draw_masked if @sprites
255
259
  end
256
260
 
257
261
  # Precompute column-based data for floor/ceiling rendering (R_InitLightTables-like)
@@ -924,8 +928,27 @@ module Doom
924
928
  tsilheight = -Float::INFINITY
925
929
  end
926
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
+
927
951
  # Check planes for this seg range (R_CheckPlane equivalent)
928
- # This may create new visplanes if column ranges would overlap
929
952
  if @current_floor_plane
930
953
  @current_floor_plane = check_plane(@current_floor_plane, x1, x2)
931
954
  end
@@ -1067,6 +1090,11 @@ module Doom
1067
1090
  sector.light_level, tex_col, lower_tex_y, scale, back_sector.floor_height, sector.floor_height)
1068
1091
  end
1069
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
+
1070
1098
  # Update clip bounds
1071
1099
  if closed_door
1072
1100
  @wall_depth[x] = [@wall_depth[x], dist].min
@@ -1139,22 +1167,26 @@ module Doom
1139
1167
  end
1140
1168
  end
1141
1169
 
1142
- # Save drawseg for sprite clipping (after columns are rendered)
1143
- # Copy the clip arrays for the segment's screen range
1144
- if silhouette != SIL_NONE
1170
+ # Save drawseg for sprite clipping and masked rendering
1171
+ if silhouette != SIL_NONE || has_masked
1145
1172
  sprtopclip = @ceiling_clip[x1..x2].dup
1146
1173
  sprbottomclip = @floor_clip[x1..x2].dup
1174
+ scalestep = (x2 > x1) ? (scale2 - scale1) / (x2 - x1) : 0
1147
1175
 
1148
1176
  drawseg = Drawseg.new(
1149
1177
  x1, x2,
1150
- scale1, scale2,
1178
+ scale1, scale2, scalestep,
1151
1179
  silhouette,
1152
1180
  bsilheight, tsilheight,
1153
1181
  sprtopclip, sprbottomclip,
1154
- seg
1182
+ seg,
1183
+ has_masked ? @current_masked_cols : nil,
1184
+ sector, back_sector, sidedef,
1185
+ dist1, dist2, sx1.to_f, sx2.to_f
1155
1186
  )
1156
1187
  @drawsegs << drawseg
1157
1188
  end
1189
+ @current_masked_cols = nil
1158
1190
  end
1159
1191
 
1160
1192
  # Wall column drawing with proper texture mapping
@@ -1209,7 +1241,110 @@ module Doom
1209
1241
  tex_y = (tex_y_at_y1 + screen_offset * tex_step).to_i % tex_height
1210
1242
 
1211
1243
  color = column[tex_y]
1212
- 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
1213
1348
  y += 1
1214
1349
  end
1215
1350
  end
@@ -1307,12 +1442,23 @@ module Doom
1307
1442
  # Get sprite: death frame > walking frame > idle frame
1308
1443
  if @combat && @combat.dead?(thing_idx)
1309
1444
  sprite = @combat.death_sprite(thing_idx, thing.type, angle_to_thing, thing.angle)
1445
+ next unless sprite # Barrel disappeared after explosion
1310
1446
  elsif @monster_ai
1311
- mon = @monster_ai.monsters.find { |m| m.thing_idx == thing_idx }
1447
+ mon = @monster_ai.monster_by_thing_idx[thing_idx]
1312
1448
  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)
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
1316
1462
  end
1317
1463
  sprite ||= @sprites.get_rotated(thing.type, angle_to_thing, thing.angle)
1318
1464
  else
@@ -1334,47 +1480,222 @@ module Doom
1334
1480
  # Sort by distance (back to front for proper overdraw)
1335
1481
  visible_sprites.sort_by! { |s| -s.dist }
1336
1482
 
1337
- # Draw each sprite
1483
+ # Draw each sprite with drawseg interleaving
1338
1484
  visible_sprites.each do |vs|
1339
- 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
1340
1498
  end
1499
+ end
1341
1500
 
1342
- # Draw projectiles and explosions
1343
- render_projectiles if @combat
1501
+ # Chocolate Doom R_DrawMasked: interleave sprites with masked drawsegs
1502
+ def draw_masked
1503
+ render_sprites
1344
1504
  end
1345
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
+
1346
1640
  def render_projectiles
1347
- # Render rockets in flight
1641
+ # Render all projectiles in flight
1348
1642
  @combat.projectiles.each do |proj|
1349
1643
  view_x, view_y = transform_point(proj.x, proj.y)
1350
1644
  next if view_y <= 0
1351
1645
 
1352
- # Rocket has rotations 1-8 based on travel direction relative to viewer
1646
+ prefix = proj.sprite_prefix || 'MISL'
1647
+
1648
+ # Try rotation-based sprite first (rockets, baron fireballs)
1353
1649
  rocket_angle = Math.atan2(proj.dy, proj.dx)
1354
1650
  viewer_angle = Math.atan2(proj.y - @player_y, proj.x - @player_x)
1355
1651
  angle_diff = (viewer_angle - rocket_angle) % (2 * Math::PI)
1356
1652
  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
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
1359
1660
 
1360
1661
  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)
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)
1363
1664
  draw_sprite(visible)
1364
1665
  end
1365
1666
 
1366
- # Render explosions (frames B, C, D - all rotation 0)
1667
+ # Render explosions at projectile height
1367
1668
  @combat.explosions.each do |expl|
1368
1669
  view_x, view_y = transform_point(expl[:x], expl[:y])
1369
1670
  next if view_y <= 0
1370
1671
  elapsed = (@combat.instance_variable_get(:@tic) - expl[:tic])
1371
1672
  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)
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)
1374
1678
  next unless expl_sprite
1375
1679
  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)
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)
1378
1699
  draw_sprite(visible)
1379
1700
  end
1380
1701
  end
@@ -1408,10 +1729,17 @@ module Doom
1408
1729
  x2 = [sprite_right, SCREEN_WIDTH - 1].min
1409
1730
  return if x1 > x2
1410
1731
 
1411
- # 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)
1412
1735
  thing_floor = sector ? sector.floor_height : 0
1413
- sprite_gz = thing_floor # bottom of sprite in world Z
1414
- 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
1415
1743
 
1416
1744
  # Initialize per-column clip arrays (-2 = not yet clipped)
1417
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