doom 0.2.0 → 0.3.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.
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.2.0"
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Colormap
6
+ MAPS = 34
7
+ MAP_SIZE = 256
8
+
9
+ attr_reader :maps
10
+
11
+ def initialize(maps)
12
+ @maps = maps
13
+ end
14
+
15
+ def [](map_index)
16
+ @maps[map_index]
17
+ end
18
+
19
+ def map_color(color_index, light_level)
20
+ map_index = 31 - (light_level >> 3)
21
+ map_index = map_index.clamp(0, 31)
22
+ @maps[map_index][color_index]
23
+ end
24
+
25
+ def self.load(wad)
26
+ data = wad.read_lump('COLORMAP')
27
+ raise Error, 'COLORMAP lump not found' unless data
28
+
29
+ maps = MAPS.times.map do |i|
30
+ offset = i * MAP_SIZE
31
+ data[offset, MAP_SIZE].bytes
32
+ end
33
+
34
+ new(maps)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Flat
6
+ WIDTH = 64
7
+ HEIGHT = 64
8
+ SIZE = WIDTH * HEIGHT
9
+
10
+ attr_reader :name, :pixels
11
+
12
+ def width
13
+ WIDTH
14
+ end
15
+
16
+ def height
17
+ HEIGHT
18
+ end
19
+
20
+ def initialize(name, pixels)
21
+ @name = name
22
+ @pixels = pixels
23
+ end
24
+
25
+ def [](x, y)
26
+ @pixels[(y & 63) * WIDTH + (x & 63)]
27
+ end
28
+
29
+ def to_png(palette, filename)
30
+ require 'chunky_png'
31
+
32
+ img = ChunkyPNG::Image.new(WIDTH, HEIGHT)
33
+ @pixels.each_with_index do |color_index, i|
34
+ x = i % WIDTH
35
+ y = i / WIDTH
36
+ r, g, b = palette[color_index]
37
+ img[x, y] = ChunkyPNG::Color.rgb(r, g, b)
38
+ end
39
+ img.save(filename)
40
+ end
41
+
42
+ def self.load(wad, name)
43
+ data = wad.read_lump(name)
44
+ return nil unless data
45
+ raise Error, "Invalid flat size: #{data.size}" unless data.size == SIZE
46
+
47
+ new(name, data.bytes)
48
+ end
49
+
50
+ def self.load_all(wad)
51
+ entries = wad.lumps_between('F_START', 'F_END')
52
+ entries.map do |entry|
53
+ next if entry.size != SIZE
54
+
55
+ data = wad.read_lump_at(entry)
56
+ new(entry.name, data.bytes)
57
+ end.compact
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Palette
6
+ COLORS = 256
7
+ PALETTES = 14
8
+ PALETTE_SIZE = COLORS * 3
9
+
10
+ attr_reader :colors
11
+
12
+ def initialize(colors)
13
+ @colors = colors
14
+ end
15
+
16
+ def [](index)
17
+ @colors[index]
18
+ end
19
+
20
+ def self.load(wad, palette_index = 0)
21
+ data = wad.read_lump('PLAYPAL')
22
+ raise Error, 'PLAYPAL lump not found' unless data
23
+
24
+ offset = palette_index * PALETTE_SIZE
25
+ colors = COLORS.times.map do |i|
26
+ [
27
+ data[offset + i * 3].ord,
28
+ data[offset + i * 3 + 1].ord,
29
+ data[offset + i * 3 + 2].ord
30
+ ]
31
+ end
32
+
33
+ new(colors)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Patch
6
+ Post = Struct.new(:top_delta, :pixels)
7
+
8
+ attr_reader :name, :width, :height, :left_offset, :top_offset, :columns
9
+
10
+ def initialize(name, width, height, left_offset, top_offset, columns)
11
+ @name = name
12
+ @width = width
13
+ @height = height
14
+ @left_offset = left_offset
15
+ @top_offset = top_offset
16
+ @columns = columns
17
+ end
18
+
19
+ def self.load(wad, name)
20
+ data = wad.read_lump(name)
21
+ return nil unless data
22
+
23
+ parse(name, data)
24
+ end
25
+
26
+ def self.parse(name, data)
27
+ width = data[0, 2].unpack1('v')
28
+ height = data[2, 2].unpack1('v')
29
+ left_offset = data[4, 2].unpack1('s<')
30
+ top_offset = data[6, 2].unpack1('s<')
31
+
32
+ column_offsets = width.times.map do |i|
33
+ data[8 + i * 4, 4].unpack1('V')
34
+ end
35
+
36
+ columns = column_offsets.map do |offset|
37
+ read_column(data, offset)
38
+ end
39
+
40
+ new(name, width, height, left_offset, top_offset, columns)
41
+ end
42
+
43
+ def self.read_column(data, offset)
44
+ posts = []
45
+ pos = offset
46
+
47
+ loop do
48
+ top_delta = data[pos].ord
49
+ break if top_delta == 0xFF
50
+
51
+ length = data[pos + 1].ord
52
+ pixels = data[pos + 3, length].bytes
53
+ posts << Post.new(top_delta, pixels)
54
+ pos += length + 4
55
+ end
56
+
57
+ posts
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Reader
6
+ IWAD = 'IWAD'
7
+ PWAD = 'PWAD'
8
+
9
+ DirectoryEntry = Struct.new(:offset, :size, :name)
10
+
11
+ attr_reader :type, :num_lumps, :directory
12
+
13
+ def initialize(path)
14
+ @file = File.open(path, 'rb')
15
+ read_header
16
+ read_directory
17
+ end
18
+
19
+ def find_lump(name)
20
+ @directory.find { |entry| entry.name == name.upcase }
21
+ end
22
+
23
+ def read_lump(name)
24
+ return @lump_cache[name] if @lump_cache.key?(name)
25
+
26
+ entry = find_lump(name)
27
+ return nil unless entry
28
+
29
+ @file.seek(entry.offset)
30
+ data = @file.read(entry.size)
31
+ @lump_cache[name] = data
32
+ data
33
+ end
34
+
35
+ def read_lump_at(entry)
36
+ @file.seek(entry.offset)
37
+ @file.read(entry.size)
38
+ end
39
+
40
+ def lumps_between(start_marker, end_marker)
41
+ start_idx = @directory.index { |e| e.name == start_marker }
42
+ end_idx = @directory.index { |e| e.name == end_marker }
43
+ return [] unless start_idx && end_idx
44
+
45
+ @directory[start_idx + 1...end_idx]
46
+ end
47
+
48
+ def close
49
+ @file.close
50
+ end
51
+
52
+ private
53
+
54
+ def read_header
55
+ data = @file.read(12)
56
+ @type = data[0, 4]
57
+ @num_lumps = data[4, 4].unpack1('V')
58
+ @directory_offset = data[8, 4].unpack1('V')
59
+ @lump_cache = {}
60
+
61
+ unless [@type == IWAD, @type == PWAD].any?
62
+ raise Error, "Invalid WAD type: #{@type}"
63
+ end
64
+ end
65
+
66
+ def read_directory
67
+ @file.seek(@directory_offset)
68
+ @directory = @num_lumps.times.map do
69
+ data = @file.read(16)
70
+ DirectoryEntry.new(
71
+ data[0, 4].unpack1('V'),
72
+ data[4, 4].unpack1('V'),
73
+ data[8, 8].delete("\x00").upcase
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Sprite
6
+ attr_reader :name, :width, :height, :left_offset, :top_offset
7
+
8
+ def initialize(name, width, height, left_offset, top_offset, columns)
9
+ @name = name
10
+ @width = width
11
+ @height = height
12
+ @left_offset = left_offset
13
+ @top_offset = top_offset
14
+ @columns = columns
15
+ end
16
+
17
+ def column_pixels(x)
18
+ @columns[x] || []
19
+ end
20
+
21
+ # Load a sprite from a WAD patch lump
22
+ def self.load(wad, lump_name)
23
+ entry = wad.directory.find { |e| e.name == lump_name }
24
+ return nil unless entry
25
+
26
+ data = wad.read_lump_at(entry)
27
+ return nil if data.size < 8
28
+
29
+ width = data[0, 2].unpack1('v')
30
+ height = data[2, 2].unpack1('v')
31
+ left_offset = data[4, 2].unpack1('s<')
32
+ top_offset = data[6, 2].unpack1('s<')
33
+
34
+ # Read column offsets
35
+ column_offsets = []
36
+ width.times do |i|
37
+ column_offsets << data[8 + i * 4, 4].unpack1('V')
38
+ end
39
+
40
+ # Read columns
41
+ columns = []
42
+ width.times do |x|
43
+ column = Array.new(height, nil) # nil = transparent
44
+ offset = column_offsets[x]
45
+
46
+ # Read posts for this column
47
+ loop do
48
+ break if offset >= data.size
49
+ row_start = data[offset].ord
50
+ break if row_start == 255 # End of column marker
51
+
52
+ post_height = data[offset + 1].ord
53
+ break if post_height == 0 || offset + 3 + post_height > data.size
54
+
55
+ # Skip padding byte, read pixels, skip trailing padding
56
+ post_height.times do |i|
57
+ y = row_start + i
58
+ if y < height
59
+ column[y] = data[offset + 3 + i].ord
60
+ end
61
+ end
62
+
63
+ offset += post_height + 4 # row_start + count + padding + pixels + padding
64
+ end
65
+
66
+ columns << column
67
+ end
68
+
69
+ new(lump_name, width, height, left_offset, top_offset, columns)
70
+ end
71
+ end
72
+
73
+ class SpriteManager
74
+ # Map thing types to sprite prefixes
75
+ THING_SPRITES = {
76
+ # Ammo
77
+ 2007 => 'CLIP', # Clip
78
+ 2048 => 'AMMO', # Box of ammo
79
+ 2008 => 'SHEL', # Shells
80
+ 2049 => 'SBOX', # Box of shells
81
+ 2010 => 'ROCK', # Rocket
82
+ 2046 => 'BROK', # Box of rockets
83
+ 2047 => 'CELL', # Cell charge
84
+ 17 => 'CELP', # Cell pack
85
+
86
+ # Weapons
87
+ 2001 => 'SHOT', # Shotgun
88
+ 2002 => 'MGUN', # Chaingun
89
+ 2003 => 'LAUN', # Rocket launcher
90
+ 2004 => 'PLAS', # Plasma rifle
91
+ 2006 => 'BFUG', # BFG 9000
92
+ 2005 => 'CSAW', # Chainsaw
93
+
94
+ # Health/Armor
95
+ 2011 => 'STIM', # Stimpack
96
+ 2012 => 'MEDI', # Medikit
97
+ 2014 => 'BON1', # Health bonus
98
+ 2015 => 'BON2', # Armor bonus
99
+ 2018 => 'ARM1', # Green armor
100
+ 2019 => 'ARM2', # Blue armor
101
+
102
+ # Keys
103
+ 5 => 'BKEY', # Blue keycard
104
+ 6 => 'YKEY', # Yellow keycard
105
+ 13 => 'RKEY', # Red keycard
106
+ 40 => 'BSKU', # Blue skull
107
+ 39 => 'YSKU', # Yellow skull
108
+ 38 => 'RSKU', # Red skull
109
+
110
+ # Decorations
111
+ 2028 => 'COLU', # Light column
112
+ 30 => 'COL1', # Tall green pillar
113
+ 31 => 'COL2', # Short green pillar
114
+ 32 => 'COL3', # Tall red pillar
115
+ 33 => 'COL4', # Short red pillar
116
+ 34 => 'CAND', # Candle
117
+ 44 => 'TBLU', # Tall blue torch
118
+ 45 => 'TGRN', # Tall green torch
119
+ 46 => 'TRED', # Tall red torch
120
+ 48 => 'ELEC', # Tall tech column
121
+ 35 => 'CBRA', # Candelabra
122
+
123
+ # Barrels
124
+ 2035 => 'BAR1', # Exploding barrel
125
+
126
+ # Monsters
127
+ 3004 => 'POSS', # Zombieman
128
+ 9 => 'SPOS', # Shotgun guy
129
+ 3001 => 'TROO', # Imp
130
+ 3002 => 'SARG', # Demon
131
+ 58 => 'SARG', # Spectre (same as Demon)
132
+ 3003 => 'BOSS', # Baron of Hell
133
+ 3005 => 'HEAD', # Cacodemon
134
+ 3006 => 'SKUL', # Lost soul
135
+ 7 => 'SPID', # Spider Mastermind
136
+ 16 => 'CYBR', # Cyberdemon
137
+ }.freeze
138
+
139
+ def initialize(wad)
140
+ @wad = wad
141
+ @cache = {}
142
+ @rotation_cache = {}
143
+ end
144
+
145
+ # Get default sprite (rotation 0 or 1)
146
+ def [](thing_type)
147
+ return @cache[thing_type] if @cache.key?(thing_type)
148
+
149
+ prefix = THING_SPRITES[thing_type]
150
+ return nil unless prefix
151
+
152
+ # Try to find sprite with A0 (all angles) or A1 (front facing)
153
+ sprite = Sprite.load(@wad, "#{prefix}A0") ||
154
+ Sprite.load(@wad, "#{prefix}A1")
155
+
156
+ @cache[thing_type] = sprite
157
+ sprite
158
+ end
159
+
160
+ # Get sprite prefix for a thing type
161
+ def prefix_for(thing_type)
162
+ THING_SPRITES[thing_type]
163
+ end
164
+
165
+ # Get sprite for specific rotation (1-8, or 0 for all angles)
166
+ # viewer_angle: angle from viewer to sprite in radians
167
+ # thing_angle: thing's facing angle in degrees
168
+ def get_rotated(thing_type, viewer_angle, thing_angle)
169
+ prefix = THING_SPRITES[thing_type]
170
+ return nil unless prefix
171
+
172
+ # Check cache for rotation 0 (all angles) sprite
173
+ cache_key = "#{prefix}A0"
174
+ if @rotation_cache.key?(cache_key)
175
+ return @rotation_cache[cache_key] if @rotation_cache[cache_key]
176
+ else
177
+ sprite = Sprite.load(@wad, cache_key)
178
+ @rotation_cache[cache_key] = sprite
179
+ return sprite if sprite
180
+ end
181
+
182
+ # Calculate rotation frame (1-8)
183
+ # Doom rotations: 1=front, 2=front-right, 3=right, etc. (clockwise)
184
+ # The angle we need is: viewer's angle to sprite - sprite's facing angle
185
+ angle_diff = viewer_angle - (thing_angle * Math::PI / 180.0)
186
+
187
+ # Normalize to 0-2π
188
+ angle_diff = angle_diff % (2 * Math::PI)
189
+ angle_diff += 2 * Math::PI if angle_diff < 0
190
+
191
+ # Convert to rotation frame (1-8)
192
+ # Each rotation covers 45 degrees (π/4 radians)
193
+ # Add π/8 to center the ranges
194
+ rotation = ((angle_diff + Math::PI / 8) / (Math::PI / 4)).to_i % 8 + 1
195
+
196
+ cache_key = "#{prefix}A#{rotation}"
197
+ unless @rotation_cache.key?(cache_key)
198
+ @rotation_cache[cache_key] = Sprite.load(@wad, cache_key)
199
+ end
200
+
201
+ @rotation_cache[cache_key] || @cache[thing_type]
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ class Texture
6
+ PatchRef = Struct.new(:x_offset, :y_offset, :patch_index)
7
+
8
+ attr_reader :name, :width, :height, :patch_refs
9
+
10
+ def initialize(name, width, height, patch_refs)
11
+ @name = name
12
+ @width = width
13
+ @height = height
14
+ @patch_refs = patch_refs
15
+ @columns = nil
16
+ end
17
+
18
+ def columns
19
+ @columns ||= build_columns
20
+ end
21
+
22
+ def build_columns
23
+ # Will be built when patches are composited
24
+ nil
25
+ end
26
+
27
+ def self.load_all(wad)
28
+ pnames = load_pnames(wad)
29
+ textures = {}
30
+
31
+ %w[TEXTURE1 TEXTURE2].each do |lump_name|
32
+ data = wad.read_lump(lump_name)
33
+ next unless data
34
+
35
+ parse_texture_lump(data).each do |tex|
36
+ textures[tex.name] = tex
37
+ end
38
+ end
39
+
40
+ { textures: textures, pnames: pnames }
41
+ end
42
+
43
+ def self.load_pnames(wad)
44
+ data = wad.read_lump('PNAMES')
45
+ return [] unless data
46
+
47
+ count = data[0, 4].unpack1('V')
48
+ count.times.map do |i|
49
+ data[4 + i * 8, 8].delete("\x00").upcase
50
+ end
51
+ end
52
+
53
+ def self.parse_texture_lump(data)
54
+ num_textures = data[0, 4].unpack1('V')
55
+ offsets = num_textures.times.map do |i|
56
+ data[4 + i * 4, 4].unpack1('V')
57
+ end
58
+
59
+ offsets.map do |offset|
60
+ parse_texture(data, offset)
61
+ end
62
+ end
63
+
64
+ def self.parse_texture(data, offset)
65
+ name = data[offset, 8].delete("\x00").upcase
66
+ width = data[offset + 12, 2].unpack1('v')
67
+ height = data[offset + 14, 2].unpack1('v')
68
+ patch_count = data[offset + 20, 2].unpack1('v')
69
+
70
+ patch_refs = patch_count.times.map do |i|
71
+ po = offset + 22 + i * 10
72
+ PatchRef.new(
73
+ data[po, 2].unpack1('s<'),
74
+ data[po + 2, 2].unpack1('s<'),
75
+ data[po + 4, 2].unpack1('v')
76
+ )
77
+ end
78
+
79
+ new(name, width, height, patch_refs)
80
+ end
81
+ end
82
+
83
+ class TextureManager
84
+ attr_reader :textures, :pnames, :patches
85
+
86
+ def initialize(wad)
87
+ @wad = wad
88
+ result = Texture.load_all(wad)
89
+ @textures = result[:textures]
90
+ @pnames = result[:pnames]
91
+ @patches = {}
92
+ @composite_cache = {}
93
+ end
94
+
95
+ def [](name)
96
+ return nil if name.nil? || name.empty? || name == '-'
97
+
98
+ @composite_cache[name] ||= build_composite(name.upcase)
99
+ end
100
+
101
+ def get_patch(index)
102
+ name = @pnames[index]
103
+ return nil unless name
104
+
105
+ @patches[name] ||= Patch.load(@wad, name)
106
+ end
107
+
108
+ private
109
+
110
+ def build_composite(name)
111
+ texture = @textures[name]
112
+ return nil unless texture
113
+
114
+ columns = Array.new(texture.width) { [] }
115
+
116
+ texture.patch_refs.each do |pref|
117
+ patch = get_patch(pref.patch_index)
118
+ next unless patch
119
+
120
+ patch.columns.each_with_index do |posts, px|
121
+ tx = pref.x_offset + px
122
+ next if tx < 0 || tx >= texture.width
123
+
124
+ posts.each do |post|
125
+ columns[tx] << Patch::Post.new(
126
+ pref.y_offset + post.top_delta,
127
+ post.pixels
128
+ )
129
+ end
130
+ end
131
+ end
132
+
133
+ CompositeTexture.new(name, texture.width, texture.height, columns)
134
+ end
135
+ end
136
+
137
+ class CompositeTexture
138
+ attr_reader :name, :width, :height, :columns
139
+
140
+ def initialize(name, width, height, columns)
141
+ @name = name
142
+ @width = width
143
+ @height = height
144
+ @columns = columns
145
+ end
146
+
147
+ def column_pixels(x, height_needed = nil)
148
+ x = x % @width
149
+ posts = @columns[x]
150
+ height_needed ||= @height
151
+
152
+ pixels = Array.new(height_needed, 0)
153
+ posts.each do |post|
154
+ post.pixels.each_with_index do |color, i|
155
+ y = (post.top_delta + i) % @height
156
+ pixels[y] = color if y < height_needed
157
+ end
158
+ end
159
+ pixels
160
+ end
161
+ end
162
+ end
163
+ end