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.
- checksums.yaml +4 -4
- data/README.md +43 -21
- data/bin/doom +10 -5
- data/lib/doom/game/combat.rb +325 -13
- data/lib/doom/game/intermission.rb +248 -0
- data/lib/doom/game/item_pickup.rb +40 -4
- data/lib/doom/game/menu.rb +342 -0
- data/lib/doom/game/monster_ai.rb +494 -0
- data/lib/doom/game/player_state.rb +53 -1
- data/lib/doom/game/sector_actions.rb +376 -15
- data/lib/doom/game/sound_engine.rb +201 -0
- data/lib/doom/platform/gosu_window.rb +589 -49
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +395 -18
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/weapon_renderer.rb +11 -12
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/reader.rb +52 -0
- data/lib/doom/wad/sound.rb +85 -0
- data/lib/doom/wad/sprite.rb +34 -8
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +48 -4
- metadata +10 -3
|
@@ -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
|
data/lib/doom/render/renderer.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
252
|
-
|
|
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
|
|
1141
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1365
|
-
|
|
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
|