doom 0.6.0 → 0.11.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Experimental renderer path. It deliberately reuses the proven BSP
6
+ # visibility and texture sampling code while replacing the visibility
7
+ # target with a full, per-pixel depth buffer. This makes it possible to add
8
+ # mesh rasterisation and ray traced passes without changing game logic.
9
+ class ZBufferRenderer < Renderer
10
+ attr_reader :depth_buffer
11
+
12
+ MAX_DYNAMIC_LIGHTS = 8
13
+ LIGHT_LEVELS = 16
14
+ Light = Struct.new(:x, :y, :z, :radius, :energy)
15
+
16
+ def initialize(...)
17
+ super
18
+ @depth_buffer = Array.new(SCREEN_WIDTH * SCREEN_HEIGHT, Float::INFINITY)
19
+ @light_lut = build_light_lut
20
+ end
21
+
22
+ def render_frame
23
+ @depth_buffer.fill(Float::INFINITY)
24
+ @dynamic_lights = collect_dynamic_lights
25
+ super
26
+ end
27
+
28
+ # Opaque walls are the first geometry producer for the new depth target.
29
+ # BSP clipping remains the visibility algorithm for this initial stage;
30
+ # later raster passes can consume the per-pixel depth values written here.
31
+ def draw_wall_column_ex(x, y1, y2, texture_name, dist, light_level,
32
+ tex_col, tex_y_start, scale, world_top)
33
+ clip_top = @ceiling_clip[x] + 1
34
+ clip_bottom = @floor_clip[x] - 1
35
+ first = [[y1, clip_top, 0].max, SCREEN_HEIGHT].min
36
+ last = [[y2, clip_bottom, SCREEN_HEIGHT - 1].min, -1].max
37
+
38
+ super
39
+
40
+ return if first > last || texture_name.nil? || texture_name.empty? || texture_name == '-'
41
+ return unless @textures[anim_texture(texture_name)]
42
+
43
+ # Reconstruct the world-space wall hit. Each dynamic source casts a
44
+ # visibility ray to it; one-sided linedefs produce hard shadows.
45
+ ray_distance = dist * @column_distscale[x]
46
+ hit_x = @player_x + (ray_distance * @column_cos[x])
47
+ hit_y = @player_y - (ray_distance * @column_sin[x])
48
+ visible_lights = @dynamic_lights.select do |light|
49
+ dx = light.x - hit_x
50
+ dy = light.y - hit_y
51
+ (dx * dx) + (dy * dy) < light.radius * light.radius && light_visible?(hit_x, hit_y, light)
52
+ end
53
+
54
+ y = first
55
+ while y <= last
56
+ offset = (y * SCREEN_WIDTH) + x
57
+ @depth_buffer[offset] = dist if dist < @depth_buffer[offset]
58
+ unless visible_lights.empty?
59
+ world_z = world_top - ((y - (HALF_HEIGHT - ((world_top - @player_z) * scale))) / scale)
60
+ intensity = visible_lights.sum do |light|
61
+ distance = Math.sqrt(((light.x - hit_x)**2) + ((light.y - hit_y)**2) + ((light.z - world_z)**2))
62
+ distance < light.radius ? light.energy * ((1.0 - (distance / light.radius))**2) : 0.0
63
+ end
64
+ level = (intensity * (LIGHT_LEVELS - 1)).to_i.clamp(0, LIGHT_LEVELS - 1)
65
+ @framebuffer[offset] = @light_lut[level][@framebuffer[offset]] if level.positive?
66
+ end
67
+ y += 1
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def collect_dynamic_lights
74
+ return [] unless @combat
75
+
76
+ lights = @combat.projectiles.map do |projectile|
77
+ Light.new(projectile.x, projectile.y, projectile.z, 224.0, 1.0)
78
+ end
79
+ # Hitscan weapons have no projectile object. Their first attack frames
80
+ # still produce a real, short-lived muzzle light at the camera.
81
+ if @view_player&.state&.attacking && @view_player.state.attack_frame <= 1
82
+ lights << Light.new(
83
+ @view_player.x + (@view_player.cos_angle * 24.0),
84
+ @view_player.y + (@view_player.sin_angle * 24.0),
85
+ @view_player.z,
86
+ 448.0,
87
+ 1.8
88
+ )
89
+ end
90
+ @combat.puffs.each do |puff|
91
+ age = @leveltime - puff[:tic]
92
+ energy = (1.0 - (age / 12.0)).clamp(0.0, 1.0)
93
+ lights << Light.new(puff[:x], puff[:y], puff[:z], 192.0, energy * 0.8)
94
+ end
95
+ @combat.explosions.each do |explosion|
96
+ age = @leveltime - explosion[:tic]
97
+ energy = (1.0 - (age / 20.0)).clamp(0.0, 1.0)
98
+ lights << Light.new(explosion[:x], explosion[:y], explosion[:z] || @player_z,
99
+ 384.0, energy * 1.5)
100
+ end
101
+ lights.sort_by { |light| ((light.x - @player_x)**2) + ((light.y - @player_y)**2) }
102
+ .first(MAX_DYNAMIC_LIGHTS)
103
+ end
104
+
105
+ # A 2D ray query matches Doom's extruded sector geometry. Portal-aware
106
+ # height tests can later replace this conservative one-sided-wall test.
107
+ def light_visible?(from_x, from_y, light)
108
+ @map.linedefs.none? do |line|
109
+ next false if line.two_sided?
110
+
111
+ a = @map.vertices[line.v1]
112
+ b = @map.vertices[line.v2]
113
+ segments_intersect?(from_x, from_y, light.x, light.y, a.x, a.y, b.x, b.y)
114
+ end
115
+ end
116
+
117
+ def segments_intersect?(ax, ay, bx, by, cx, cy, dx, dy)
118
+ abx = bx - ax
119
+ aby = by - ay
120
+ cdx = dx - cx
121
+ cdy = dy - cy
122
+ denominator = (abx * cdy) - (aby * cdx)
123
+ return false if denominator.abs < 1e-9
124
+
125
+ acx = cx - ax
126
+ acy = cy - ay
127
+ t = ((acx * cdy) - (acy * cdx)) / denominator.to_f
128
+ u = ((acx * aby) - (acy * abx)) / denominator.to_f
129
+ t > 1e-4 && t < 0.9999 && u >= 0.0 && u <= 1.0
130
+ end
131
+
132
+ # Doom's target is palette-indexed. Precompute nearest palette entries
133
+ # for a warm additive light so the geometry pass remains deterministic.
134
+ def build_light_lut
135
+ colors = @palette.colors
136
+ LIGHT_LEVELS.times.map do |level|
137
+ amount = level.to_f / (LIGHT_LEVELS - 1)
138
+ colors.map do |red, green, blue|
139
+ target = [red + (255 * amount), green + (176 * amount), blue + (72 * amount)]
140
+ .map { |channel| channel.clamp(0, 255) }
141
+ colors.each_index.min_by do |index|
142
+ candidate = colors[index]
143
+ ((candidate[0] - target[0])**2) + ((candidate[1] - target[1])**2) +
144
+ ((candidate[2] - target[2])**2)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
data/lib/doom/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Doom
4
- VERSION = '0.6.0'
4
+ VERSION = '0.11.0'
5
5
  end
data/lib/doom/wad/flat.rb CHANGED
@@ -23,7 +23,7 @@ module Doom
23
23
  end
24
24
 
25
25
  def [](x, y)
26
- @pixels[(y & 63) * WIDTH + (x & 63)]
26
+ @pixels[((y & 63) * WIDTH) + (x & 63)]
27
27
  end
28
28
 
29
29
  def self.load_all(wad)
@@ -22,8 +22,9 @@ module Doom
22
22
  @cache[name]
23
23
  end
24
24
 
25
- private
26
-
25
+ # Load (and cache) a single named patch/graphic lump. Returns a HudSprite
26
+ # or nil if the lump is missing or too small. Public so the menu,
27
+ # intermission and font renderers can pull graphics by name.
27
28
  def load_graphic(name)
28
29
  return @cache[name] if @cache[name]
29
30
 
@@ -38,6 +39,8 @@ module Doom
38
39
  sprite
39
40
  end
40
41
 
42
+ private
43
+
41
44
  def parse_patch(name, data)
42
45
  width = data[0, 2].unpack1('v')
43
46
  height = data[2, 2].unpack1('v')
@@ -46,7 +49,7 @@ module Doom
46
49
 
47
50
  # Read column offsets
48
51
  column_offsets = width.times.map do |i|
49
- data[8 + i * 4, 4].unpack1('V')
52
+ data[8 + (i * 4), 4].unpack1('V')
50
53
  end
51
54
 
52
55
  # Build column data
@@ -63,6 +66,7 @@ module Doom
63
66
 
64
67
  loop do
65
68
  break if pos >= data.size
69
+
66
70
  top_delta = data[pos].ord
67
71
  break if top_delta == 0xFF
68
72
 
@@ -24,9 +24,9 @@ module Doom
24
24
  offset = palette_index * PALETTE_SIZE
25
25
  colors = COLORS.times.map do |i|
26
26
  [
27
- data[offset + i * 3].ord,
28
- data[offset + i * 3 + 1].ord,
29
- data[offset + i * 3 + 2].ord
27
+ data[offset + (i * 3)].ord,
28
+ data[offset + (i * 3) + 1].ord,
29
+ data[offset + (i * 3) + 2].ord
30
30
  ]
31
31
  end
32
32
 
@@ -30,7 +30,7 @@ module Doom
30
30
  top_offset = data[6, 2].unpack1('s<')
31
31
 
32
32
  column_offsets = width.times.map do |i|
33
- data[8 + i * 4, 4].unpack1('V')
33
+ data[8 + (i * 4), 4].unpack1('V')
34
34
  end
35
35
 
36
36
  columns = column_offsets.map do |offset|
@@ -26,17 +26,15 @@ module Doom
26
26
  entry = find_lump(name)
27
27
  return nil unless entry
28
28
 
29
- @file.seek(entry.offset)
30
- data = @file.read(entry.size)
29
+ # Route through read_lump_at, which reads from the file that actually
30
+ # owns the entry. After merge_pwad a lump the PWAD overrode (PLAYPAL,
31
+ # COLORMAP, TEXTUREx, PNAMES, a patch) lives in the PWAD file, so seeking
32
+ # this reader's own IWAD handle at the entry's offset would read garbage.
33
+ data = read_lump_at(entry)
31
34
  @lump_cache[name] = data
32
35
  data
33
36
  end
34
37
 
35
- def read_lump_at(entry)
36
- @file.seek(entry.offset)
37
- @file.read(entry.size)
38
- end
39
-
40
38
  def lumps_between(start_marker, end_marker)
41
39
  start_idx = @directory.index { |e| e.name == start_marker }
42
40
  end_idx = @directory.index { |e| e.name == end_marker }
@@ -45,8 +43,59 @@ module Doom
45
43
  @directory[start_idx + 1...end_idx]
46
44
  end
47
45
 
46
+ def pwad?
47
+ @type == PWAD
48
+ end
49
+
50
+ def iwad?
51
+ @type == IWAD
52
+ end
53
+
54
+ # Merge a PWAD on top of this IWAD.
55
+ # PWAD lumps override IWAD lumps with the same name.
56
+ # Map lumps (between map markers) are replaced as a group.
57
+ def merge_pwad(pwad)
58
+ raise Error, 'Can only merge a PWAD' unless pwad.pwad?
59
+
60
+ @lump_cache.clear
61
+
62
+ pwad.directory.each do |pwad_entry|
63
+ # Check if this lump already exists in the IWAD
64
+ existing_idx = @directory.index { |e| e.name == pwad_entry.name }
65
+ if existing_idx
66
+ # Replace it -- but we need to read from the PWAD file
67
+ @directory[existing_idx] = pwad_entry
68
+ else
69
+ # Append new lump
70
+ @directory << pwad_entry
71
+ end
72
+ end
73
+
74
+ # Keep the PWAD file open for reading its lumps
75
+ @pwad_files ||= []
76
+ @pwad_files << pwad
77
+ @num_lumps = @directory.size
78
+ end
79
+
80
+ # Read a lump by directory entry, checking if it belongs to a PWAD file
81
+ def read_lump_at(entry)
82
+ # Try PWAD files first (they may own this entry)
83
+ (@pwad_files || []).each do |pwad|
84
+ return pwad.read_lump_own(entry) if pwad.directory.include?(entry)
85
+ end
86
+ @file.seek(entry.offset)
87
+ @file.read(entry.size)
88
+ end
89
+
90
+ # Read a lump that belongs to this WAD's own file
91
+ def read_lump_own(entry)
92
+ @file.seek(entry.offset)
93
+ @file.read(entry.size)
94
+ end
95
+
48
96
  def close
49
97
  @file.close
98
+ (@pwad_files || []).each(&:close)
50
99
  end
51
100
 
52
101
  private
@@ -58,9 +107,9 @@ module Doom
58
107
  @directory_offset = data[8, 4].unpack1('V')
59
108
  @lump_cache = {}
60
109
 
61
- unless [@type == IWAD, @type == PWAD].any?
62
- raise Error, "Invalid WAD type: #{@type}"
63
- end
110
+ return if [@type == IWAD, @type == PWAD].any?
111
+
112
+ raise Error, "Invalid WAD type: #{@type}"
64
113
  end
65
114
 
66
115
  def read_directory
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ module Doom
7
+ module Wad
8
+ # Loads DOOM sound effects from WAD and converts to WAV for Gosu playback.
9
+ # DOOM sound format: 8-byte header (2 format, 2 sample_rate, 4 num_samples)
10
+ # followed by unsigned 8-bit PCM samples.
11
+ class SoundManager
12
+ def initialize(wad)
13
+ @wad = wad
14
+ @cache = {} # name => Gosu::Sample
15
+ @temp_dir = File.join(Dir.tmpdir, "doom_rb_sounds_#{Process.pid}")
16
+ Dir.mkdir(@temp_dir) unless Dir.exist?(@temp_dir)
17
+
18
+ # Sounds are decoded into a per-process temp dir; clean it up when the
19
+ # process exits so repeated runs do not leak WAV files.
20
+ at_exit { cleanup }
21
+ end
22
+
23
+ # Get or load a sound effect. Returns a Gosu::Sample or nil.
24
+ def [](name)
25
+ return @cache[name] if @cache.key?(name)
26
+
27
+ lump_name = name.start_with?('DS') ? name : "DS#{name}"
28
+ entry = @wad.find_lump(lump_name)
29
+ return @cache[name] = nil unless entry
30
+
31
+ data = @wad.read_lump_at(entry)
32
+ return @cache[name] = nil if data.size < 8
33
+
34
+ # Parse DOOM sound header
35
+ _format = data[0, 2].unpack1('v')
36
+ sample_rate = data[2, 2].unpack1('v')
37
+ num_samples = data[4, 4].unpack1('V')
38
+
39
+ # PCM data starts at offset 8, skip 16 padding bytes at start and end
40
+ pcm_start = 8 + 16
41
+ pcm_end = 8 + num_samples - 16
42
+ pcm_data = data[pcm_start...pcm_end]
43
+ return @cache[name] = nil unless pcm_data && pcm_data.size > 0
44
+
45
+ # Convert to WAV file for Gosu
46
+ wav_path = File.join(@temp_dir, "#{lump_name}.wav")
47
+ write_wav(wav_path, pcm_data, sample_rate) unless File.exist?(wav_path)
48
+
49
+ @cache[name] = Gosu::Sample.new(wav_path)
50
+ rescue StandardError => e
51
+ # A bad or unplayable lump should silence that one sound, not crash the
52
+ # game -- but say so rather than swallowing the error entirely.
53
+ warn "SoundManager: could not load '#{name}': #{e.class}: #{e.message}"
54
+ @cache[name] = nil
55
+ end
56
+
57
+ def cleanup
58
+ FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
59
+ end
60
+
61
+ private
62
+
63
+ # Write unsigned 8-bit PCM data as a WAV file
64
+ def write_wav(path, pcm_data, sample_rate)
65
+ num_samples = pcm_data.size
66
+ data_size = num_samples
67
+ file_size = 36 + data_size
68
+
69
+ File.open(path, 'wb') do |f|
70
+ # RIFF header
71
+ f.write('RIFF')
72
+ f.write([file_size].pack('V'))
73
+ f.write('WAVE')
74
+
75
+ # fmt chunk
76
+ f.write('fmt ')
77
+ f.write([16].pack('V')) # chunk size
78
+ f.write([1].pack('v')) # PCM format
79
+ f.write([1].pack('v')) # mono
80
+ f.write([sample_rate].pack('V')) # sample rate
81
+ f.write([sample_rate].pack('V')) # byte rate (sample_rate * 1 * 1)
82
+ f.write([1].pack('v')) # block align
83
+ f.write([8].pack('v')) # bits per sample
84
+
85
+ # data chunk
86
+ f.write('data')
87
+ f.write([data_size].pack('V'))
88
+ f.write(pcm_data)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -3,7 +3,7 @@
3
3
  module Doom
4
4
  module Wad
5
5
  class Sprite
6
- attr_reader :name, :width, :height, :left_offset, :top_offset
6
+ attr_reader :name, :width, :height, :left_offset, :top_offset, :columns
7
7
 
8
8
  def initialize(name, width, height, left_offset, top_offset, columns)
9
9
  @name = name
@@ -34,20 +34,21 @@ module Doom
34
34
  # Read column offsets
35
35
  column_offsets = []
36
36
  width.times do |i|
37
- column_offsets << data[8 + i * 4, 4].unpack1('V')
37
+ column_offsets << data[8 + (i * 4), 4].unpack1('V')
38
38
  end
39
39
 
40
40
  # Read columns
41
41
  columns = []
42
42
  width.times do |x|
43
- column = Array.new(height, nil) # nil = transparent
43
+ column = Array.new(height, nil) # nil = transparent
44
44
  offset = column_offsets[x]
45
45
 
46
46
  # Read posts for this column
47
47
  loop do
48
48
  break if offset >= data.size
49
+
49
50
  row_start = data[offset].ord
50
- break if row_start == 255 # End of column marker
51
+ break if row_start == 255 # End of column marker
51
52
 
52
53
  post_height = data[offset + 1].ord
53
54
  break if post_height == 0 || offset + 3 + post_height > data.size
@@ -55,12 +56,10 @@ module Doom
55
56
  # Skip padding byte, read pixels, skip trailing padding
56
57
  post_height.times do |i|
57
58
  y = row_start + i
58
- if y < height
59
- column[y] = data[offset + 3 + i].ord
60
- end
59
+ column[y] = data[offset + 3 + i].ord if y < height
61
60
  end
62
61
 
63
- offset += post_height + 4 # row_start + count + padding + pixels + padding
62
+ offset += post_height + 4 # row_start + count + padding + pixels + padding
64
63
  end
65
64
 
66
65
  columns << column
@@ -120,6 +119,12 @@ module Doom
120
119
  48 => 'ELEC', # Tall tech column
121
120
  35 => 'CBRA', # Candelabra
122
121
 
122
+ # Dead bodies / gore decorations
123
+ 10 => 'PLAY', # Bloody mess
124
+ 12 => 'PLAY', # Bloody mess 2
125
+ 15 => 'PLAY', # Dead player
126
+ 24 => 'POL5', # Pool of blood and flesh
127
+
123
128
  # Barrels
124
129
  2035 => 'BAR1', # Exploding barrel
125
130
 
@@ -133,7 +138,18 @@ module Doom
133
138
  3005 => 'HEAD', # Cacodemon
134
139
  3006 => 'SKUL', # Lost soul
135
140
  7 => 'SPID', # Spider Mastermind
136
- 16 => 'CYBR', # Cyberdemon
141
+ 16 => 'CYBR' # Cyberdemon
142
+ }.freeze
143
+
144
+ # Things that use a specific frame instead of 'A'
145
+ # From DOOM info.h mobjinfo spawnstate:
146
+ # MT_MISC10 (type 10) -> S_PLAY_XDIE9 = PLAY W (gibbed mess)
147
+ # MT_MISC12 (type 12) -> S_PLAY_DIE7 = PLAY N (dead body)
148
+ # MT_MISC15 (type 15) -> S_PLAY_DIE7 = PLAY N (dead body)
149
+ THING_DEFAULT_FRAME = {
150
+ 10 => 'W', # Bloody mess (gibbed)
151
+ 12 => 'N', # Bloody mess 2 (dead body flat)
152
+ 15 => 'N' # Dead player (dead body flat)
137
153
  }.freeze
138
154
 
139
155
  def initialize(wad)
@@ -154,8 +170,9 @@ module Doom
154
170
  prefix = THING_SPRITES[thing_type]
155
171
  return nil unless prefix
156
172
 
157
- sprite = load_sprite_frame(prefix, 'A', 0) ||
158
- load_sprite_frame(prefix, 'A', 1)
173
+ frame = THING_DEFAULT_FRAME[thing_type] || 'A'
174
+ sprite = load_sprite_frame(prefix, frame, 0) ||
175
+ load_sprite_frame(prefix, frame, 1)
159
176
 
160
177
  @cache[thing_type] = sprite
161
178
  sprite
@@ -173,19 +190,23 @@ module Doom
173
190
  prefix = THING_SPRITES[thing_type]
174
191
  return nil unless prefix
175
192
 
193
+ frame = THING_DEFAULT_FRAME[thing_type] || 'A'
194
+
176
195
  # Check for rotation 0 (all angles) sprite first
177
- sprite = load_sprite_frame(prefix, 'A', 0)
196
+ sprite = load_sprite_frame(prefix, frame, 0)
178
197
  return sprite if sprite
179
198
 
180
- # Calculate rotation frame (1-8)
181
- # DOOM: rot = (R_PointToAngle(thing) - thing->angle + ANG45/2*9) >> 29
182
- # Rotation 1=front (viewer faces monster's front), 5=back
199
+ load_sprite_frame(prefix, frame, rotation_for(viewer_angle, thing_angle)) || @cache[thing_type]
200
+ end
201
+
202
+ # Which of the 8 rotation frames faces the viewer.
203
+ # DOOM: rot = (R_PointToAngle(thing) - thing->angle + ANG45/2*9) >> 29
204
+ # Rotation 1 = front (viewer faces the thing's front), 5 = back.
205
+ def rotation_for(viewer_angle, thing_angle)
183
206
  angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
184
- angle_diff = angle_diff % (2 * Math::PI)
207
+ angle_diff %= (2 * Math::PI)
185
208
  angle_diff += 2 * Math::PI if angle_diff < 0
186
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
187
-
188
- load_sprite_frame(prefix, 'A', rotation) || @cache[thing_type]
209
+ (((angle_diff + (Math::PI / 8)) / (Math::PI / 4)).to_i % 8) + 1
189
210
  end
190
211
 
191
212
  # Get a specific frame (for death animations, etc.)
@@ -193,17 +214,22 @@ module Doom
193
214
  prefix = THING_SPRITES[thing_type]
194
215
  return nil unless prefix
195
216
 
196
- # Death frames typically use rotation 0 (same from all angles)
217
+ get_frame_rotated(prefix, frame_letter, viewer_angle, thing_angle)
218
+ end
219
+
220
+ # Get a frame by explicit prefix (for barrel explosions where prefix differs from thing type)
221
+ def get_frame_by_prefix(prefix, frame_letter)
222
+ load_sprite_frame(prefix, frame_letter, 0)
223
+ end
224
+
225
+ # Get a frame by explicit prefix, honouring rotations. Players are drawn
226
+ # through this: PLAY has no thing type of its own, and its walk and
227
+ # attack frames are rotated while its death frames are not.
228
+ def get_frame_rotated(prefix, frame_letter, viewer_angle, thing_angle)
197
229
  sprite = load_sprite_frame(prefix, frame_letter, 0)
198
230
  return sprite if sprite
199
231
 
200
- # Try with calculated rotation (same formula as get_rotated)
201
- angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
202
- angle_diff = angle_diff % (2 * Math::PI)
203
- angle_diff += 2 * Math::PI if angle_diff < 0
204
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
205
-
206
- load_sprite_frame(prefix, frame_letter, rotation)
232
+ load_sprite_frame(prefix, frame_letter, rotation_for(viewer_angle, thing_angle))
207
233
  end
208
234
 
209
235
  private
@@ -222,12 +248,12 @@ module Doom
222
248
  @sprite_index[key] = [name, false]
223
249
 
224
250
  # Check for mirrored second rotation (e.g., SPOSA2A8)
225
- if name.length >= 8
226
- frame2 = name[6]
227
- rot2 = name[7].to_i
228
- key2 = "#{prefix}#{frame2}#{rot2}"
229
- @sprite_index[key2] = [name, true]
230
- end
251
+ next unless name.length >= 8
252
+
253
+ frame2 = name[6]
254
+ rot2 = name[7].to_i
255
+ key2 = "#{prefix}#{frame2}#{rot2}"
256
+ @sprite_index[key2] = [name, true]
231
257
  end
232
258
  end
233
259
 
@@ -265,7 +291,7 @@ module Doom
265
291
 
266
292
  def mirror_sprite(sprite)
267
293
  # Flip columns horizontally, adjust left_offset
268
- mirrored_columns = sprite.instance_variable_get(:@columns).reverse
294
+ mirrored_columns = sprite.columns.reverse
269
295
  mirrored_left = sprite.width - sprite.left_offset
270
296
  Sprite.new(
271
297
  "#{sprite.name}_M",
@@ -38,14 +38,14 @@ module Doom
38
38
 
39
39
  count = data[0, 4].unpack1('V')
40
40
  count.times.map do |i|
41
- data[4 + i * 8, 8].delete("\x00").upcase
41
+ data[4 + (i * 8), 8].delete("\x00").upcase
42
42
  end
43
43
  end
44
44
 
45
45
  def self.parse_texture_lump(data)
46
46
  num_textures = data[0, 4].unpack1('V')
47
47
  offsets = num_textures.times.map do |i|
48
- data[4 + i * 4, 4].unpack1('V')
48
+ data[4 + (i * 4), 4].unpack1('V')
49
49
  end
50
50
 
51
51
  offsets.map do |offset|
@@ -60,7 +60,7 @@ module Doom
60
60
  patch_count = data[offset + 20, 2].unpack1('v')
61
61
 
62
62
  patch_refs = patch_count.times.map do |i|
63
- po = offset + 22 + i * 10
63
+ po = offset + 22 + (i * 10)
64
64
  PatchRef.new(
65
65
  data[po, 2].unpack1('s<'),
66
66
  data[po + 2, 2].unpack1('s<'),
@@ -139,7 +139,7 @@ module Doom
139
139
  precompute_columns
140
140
  end
141
141
 
142
- def column_pixels(x, _height_needed = nil)
142
+ def column_pixels(x)
143
143
  @column_cache[x & (@width - 1)]
144
144
  end
145
145
 
@@ -148,7 +148,7 @@ module Doom
148
148
  def precompute_columns
149
149
  @width.times do |x|
150
150
  posts = @columns[x]
151
- pixels = Array.new(@height, 0)
151
+ pixels = Array.new(@height) # nil = transparent
152
152
  posts.each do |post|
153
153
  td = post.top_delta
154
154
  post.pixels.each_with_index do |color, i|