doom 0.8.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 +134 -24
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +239 -153
  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 +22 -39
  11. data/lib/doom/game/item_pickup.rb +87 -75
  12. data/lib/doom/game/menu.rb +90 -85
  13. data/lib/doom/game/monster_ai.rb +150 -136
  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 +38 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +169 -93
  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 +33 -58
  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 +604 -832
  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 +5 -3
  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 +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  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 +9 -10
  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 +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -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.8.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 }
@@ -57,7 +55,8 @@ module Doom
57
55
  # PWAD lumps override IWAD lumps with the same name.
58
56
  # Map lumps (between map markers) are replaced as a group.
59
57
  def merge_pwad(pwad)
60
- raise Error, "Can only merge a PWAD" unless pwad.pwad?
58
+ raise Error, 'Can only merge a PWAD' unless pwad.pwad?
59
+
61
60
  @lump_cache.clear
62
61
 
63
62
  pwad.directory.each do |pwad_entry|
@@ -78,13 +77,11 @@ module Doom
78
77
  @num_lumps = @directory.size
79
78
  end
80
79
 
81
- # Override read_lump_at to check if entry belongs to a PWAD file
80
+ # Read a lump by directory entry, checking if it belongs to a PWAD file
82
81
  def read_lump_at(entry)
83
82
  # Try PWAD files first (they may own this entry)
84
83
  (@pwad_files || []).each do |pwad|
85
- if pwad.directory.include?(entry)
86
- return pwad.read_lump_own(entry)
87
- end
84
+ return pwad.read_lump_own(entry) if pwad.directory.include?(entry)
88
85
  end
89
86
  @file.seek(entry.offset)
90
87
  @file.read(entry.size)
@@ -110,9 +107,9 @@ module Doom
110
107
  @directory_offset = data[8, 4].unpack1('V')
111
108
  @lump_cache = {}
112
109
 
113
- unless [@type == IWAD, @type == PWAD].any?
114
- raise Error, "Invalid WAD type: #{@type}"
115
- end
110
+ return if [@type == IWAD, @type == PWAD].any?
111
+
112
+ raise Error, "Invalid WAD type: #{@type}"
116
113
  end
117
114
 
118
115
  def read_directory
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'tmpdir'
4
+ require 'fileutils'
4
5
 
5
6
  module Doom
6
7
  module Wad
@@ -10,9 +11,13 @@ module Doom
10
11
  class SoundManager
11
12
  def initialize(wad)
12
13
  @wad = wad
13
- @cache = {} # name => Gosu::Sample
14
+ @cache = {} # name => Gosu::Sample
14
15
  @temp_dir = File.join(Dir.tmpdir, "doom_rb_sounds_#{Process.pid}")
15
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 }
16
21
  end
17
22
 
18
23
  # Get or load a sound effect. Returns a Gosu::Sample or nil.
@@ -42,7 +47,10 @@ module Doom
42
47
  write_wav(wav_path, pcm_data, sample_rate) unless File.exist?(wav_path)
43
48
 
44
49
  @cache[name] = Gosu::Sample.new(wav_path)
45
- rescue => e
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}"
46
54
  @cache[name] = nil
47
55
  end
48
56
 
@@ -60,12 +68,12 @@ module Doom
60
68
 
61
69
  File.open(path, 'wb') do |f|
62
70
  # RIFF header
63
- f.write("RIFF")
71
+ f.write('RIFF')
64
72
  f.write([file_size].pack('V'))
65
- f.write("WAVE")
73
+ f.write('WAVE')
66
74
 
67
75
  # fmt chunk
68
- f.write("fmt ")
76
+ f.write('fmt ')
69
77
  f.write([16].pack('V')) # chunk size
70
78
  f.write([1].pack('v')) # PCM format
71
79
  f.write([1].pack('v')) # mono
@@ -75,7 +83,7 @@ module Doom
75
83
  f.write([8].pack('v')) # bits per sample
76
84
 
77
85
  # data chunk
78
- f.write("data")
86
+ f.write('data')
79
87
  f.write([data_size].pack('V'))
80
88
  f.write(pcm_data)
81
89
  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
@@ -139,7 +138,7 @@ module Doom
139
138
  3005 => 'HEAD', # Cacodemon
140
139
  3006 => 'SKUL', # Lost soul
141
140
  7 => 'SPID', # Spider Mastermind
142
- 16 => 'CYBR', # Cyberdemon
141
+ 16 => 'CYBR' # Cyberdemon
143
142
  }.freeze
144
143
 
145
144
  # Things that use a specific frame instead of 'A'
@@ -150,7 +149,7 @@ module Doom
150
149
  THING_DEFAULT_FRAME = {
151
150
  10 => 'W', # Bloody mess (gibbed)
152
151
  12 => 'N', # Bloody mess 2 (dead body flat)
153
- 15 => 'N', # Dead player (dead body flat)
152
+ 15 => 'N' # Dead player (dead body flat)
154
153
  }.freeze
155
154
 
156
155
  def initialize(wad)
@@ -197,15 +196,17 @@ module Doom
197
196
  sprite = load_sprite_frame(prefix, frame, 0)
198
197
  return sprite if sprite
199
198
 
200
- # Calculate rotation frame (1-8)
201
- # DOOM: rot = (R_PointToAngle(thing) - thing->angle + ANG45/2*9) >> 29
202
- # 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)
203
206
  angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
204
- angle_diff = angle_diff % (2 * Math::PI)
207
+ angle_diff %= (2 * Math::PI)
205
208
  angle_diff += 2 * Math::PI if angle_diff < 0
206
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
207
-
208
- load_sprite_frame(prefix, frame, rotation) || @cache[thing_type]
209
+ (((angle_diff + (Math::PI / 8)) / (Math::PI / 4)).to_i % 8) + 1
209
210
  end
210
211
 
211
212
  # Get a specific frame (for death animations, etc.)
@@ -213,17 +214,7 @@ module Doom
213
214
  prefix = THING_SPRITES[thing_type]
214
215
  return nil unless prefix
215
216
 
216
- # Death frames typically use rotation 0 (same from all angles)
217
- sprite = load_sprite_frame(prefix, frame_letter, 0)
218
- return sprite if sprite
219
-
220
- # Try with calculated rotation (same formula as get_rotated)
221
- angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0) + Math::PI
222
- angle_diff = angle_diff % (2 * Math::PI)
223
- angle_diff += 2 * Math::PI if angle_diff < 0
224
- rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
225
-
226
- load_sprite_frame(prefix, frame_letter, rotation)
217
+ get_frame_rotated(prefix, frame_letter, viewer_angle, thing_angle)
227
218
  end
228
219
 
229
220
  # Get a frame by explicit prefix (for barrel explosions where prefix differs from thing type)
@@ -231,6 +222,16 @@ module Doom
231
222
  load_sprite_frame(prefix, frame_letter, 0)
232
223
  end
233
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)
229
+ sprite = load_sprite_frame(prefix, frame_letter, 0)
230
+ return sprite if sprite
231
+
232
+ load_sprite_frame(prefix, frame_letter, rotation_for(viewer_angle, thing_angle))
233
+ end
234
+
234
235
  private
235
236
 
236
237
  def build_sprite_index
@@ -247,12 +248,12 @@ module Doom
247
248
  @sprite_index[key] = [name, false]
248
249
 
249
250
  # Check for mirrored second rotation (e.g., SPOSA2A8)
250
- if name.length >= 8
251
- frame2 = name[6]
252
- rot2 = name[7].to_i
253
- key2 = "#{prefix}#{frame2}#{rot2}"
254
- @sprite_index[key2] = [name, true]
255
- 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]
256
257
  end
257
258
  end
258
259
 
@@ -290,7 +291,7 @@ module Doom
290
291
 
291
292
  def mirror_sprite(sprite)
292
293
  # Flip columns horizontally, adjust left_offset
293
- mirrored_columns = sprite.instance_variable_get(:@columns).reverse
294
+ mirrored_columns = sprite.columns.reverse
294
295
  mirrored_left = sprite.width - sprite.left_offset
295
296
  Sprite.new(
296
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) # nil = transparent
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|