doom 0.2.0 → 0.4.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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Renders the classic DOOM status bar at the bottom of the screen
6
+ class StatusBar
7
+ STATUS_BAR_HEIGHT = 32
8
+ STATUS_BAR_Y = SCREEN_HEIGHT - STATUS_BAR_HEIGHT
9
+
10
+ # DOOM status bar layout (from st_stuff.c)
11
+ # X positions are RIGHT EDGE of each number area (numbers are right-aligned)
12
+ # Y positions relative to status bar top
13
+
14
+ # Right edge X positions for numbers
15
+ AMMO_RIGHT_X = 44 # ST_AMMOX - right edge of 3-digit ammo
16
+ HEALTH_RIGHT_X = 90 # ST_HEALTHX - right edge of 3-digit health
17
+ ARMOR_RIGHT_X = 221 # ST_ARMORX - right edge of 3-digit armor
18
+
19
+ FACE_X = 149 # Adjusted for proper centering in face background
20
+ KEYS_X = 239 # ST_KEY0X
21
+
22
+ NUM_WIDTH = 14 # Width of each digit
23
+
24
+ def initialize(hud_graphics, player_state)
25
+ @gfx = hud_graphics
26
+ @player = player_state
27
+ @face_timer = 0
28
+ @face_index = 0
29
+ end
30
+
31
+ def render(framebuffer)
32
+ # Draw status bar background
33
+ draw_sprite(framebuffer, @gfx.status_bar, 0, STATUS_BAR_Y) if @gfx.status_bar
34
+
35
+ # Y position for numbers (3 pixels from top of status bar)
36
+ num_y = STATUS_BAR_Y + 3
37
+
38
+ # Draw ammo count (right-aligned ending at AMMO_RIGHT_X)
39
+ draw_number_right(framebuffer, @player.current_ammo, AMMO_RIGHT_X, num_y) if @player.current_ammo
40
+
41
+ # Draw health with percent (right-aligned ending at HEALTH_RIGHT_X)
42
+ draw_number_right(framebuffer, @player.health, HEALTH_RIGHT_X, num_y)
43
+ draw_percent(framebuffer, HEALTH_RIGHT_X, num_y)
44
+
45
+ # Draw face
46
+ draw_face(framebuffer)
47
+
48
+ # Draw armor with percent (right-aligned ending at ARMOR_RIGHT_X)
49
+ draw_number_right(framebuffer, @player.armor, ARMOR_RIGHT_X, num_y)
50
+ draw_percent(framebuffer, ARMOR_RIGHT_X, num_y)
51
+
52
+ # Draw keys
53
+ draw_keys(framebuffer)
54
+ end
55
+
56
+ def update
57
+ # Cycle face animation
58
+ @face_timer += 1
59
+ if @face_timer > 15 # Change face every ~0.5 seconds
60
+ @face_timer = 0
61
+ @face_index = (@face_index + 1) % 3
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def draw_sprite(framebuffer, sprite, x, y)
68
+ return unless sprite
69
+
70
+ sprite.width.times do |sx|
71
+ column = sprite.column_pixels(sx)
72
+ next unless column
73
+
74
+ draw_x = x + sx
75
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
76
+
77
+ column.each_with_index do |color, sy|
78
+ next unless color
79
+
80
+ draw_y = y + sy
81
+ next if draw_y < 0 || draw_y >= SCREEN_HEIGHT
82
+
83
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
84
+ end
85
+ end
86
+ end
87
+
88
+ # Draw number right-aligned with right edge at right_x
89
+ def draw_number_right(framebuffer, value, right_x, y)
90
+ return unless value
91
+
92
+ value = value.to_i.clamp(-999, 999)
93
+ str = value.to_s
94
+
95
+ # Draw from right to left, starting from right edge
96
+ current_x = right_x
97
+ str.reverse.each_char do |char|
98
+ digit_sprite = if char == '-'
99
+ @gfx.numbers['-']
100
+ else
101
+ @gfx.numbers[char.to_i]
102
+ end
103
+
104
+ if digit_sprite
105
+ current_x -= NUM_WIDTH
106
+ draw_sprite(framebuffer, digit_sprite, current_x, y)
107
+ end
108
+ end
109
+ end
110
+
111
+ def draw_percent(framebuffer, x, y)
112
+ percent = @gfx.numbers['%']
113
+ draw_sprite(framebuffer, percent, x, y) if percent
114
+ end
115
+
116
+ def draw_face(framebuffer)
117
+ # In DOOM, face sprite health levels are inverted: 0 = full health, 4 = dying
118
+ sprite_health = 4 - @player.health_level
119
+ faces = @gfx.faces[sprite_health]
120
+ return unless faces
121
+
122
+ # Get current face sprite
123
+ face = if @player.health <= 0
124
+ @gfx.faces[:dead]
125
+ elsif faces[:straight] && faces[:straight][@face_index]
126
+ faces[:straight][@face_index]
127
+ else
128
+ faces[:straight]&.first
129
+ end
130
+
131
+ return unless face
132
+
133
+ # Position face in the background area
134
+ face_x = FACE_X
135
+ face_y = STATUS_BAR_Y + 2 # Slightly below top of status bar
136
+ draw_sprite(framebuffer, face, face_x, face_y)
137
+ end
138
+
139
+ def draw_keys(framebuffer)
140
+ key_x = KEYS_X
141
+ key_spacing = 10
142
+
143
+ # Blue keys (top row)
144
+ if @player.keys[:blue_card]
145
+ draw_sprite(framebuffer, @gfx.keys[:blue_card], key_x, STATUS_BAR_Y + 3)
146
+ elsif @player.keys[:blue_skull]
147
+ draw_sprite(framebuffer, @gfx.keys[:blue_skull], key_x, STATUS_BAR_Y + 3)
148
+ end
149
+
150
+ # Yellow keys (middle row)
151
+ if @player.keys[:yellow_card]
152
+ draw_sprite(framebuffer, @gfx.keys[:yellow_card], key_x, STATUS_BAR_Y + 13)
153
+ elsif @player.keys[:yellow_skull]
154
+ draw_sprite(framebuffer, @gfx.keys[:yellow_skull], key_x, STATUS_BAR_Y + 13)
155
+ end
156
+
157
+ # Red keys (bottom row)
158
+ if @player.keys[:red_card]
159
+ draw_sprite(framebuffer, @gfx.keys[:red_card], key_x, STATUS_BAR_Y + 23)
160
+ elsif @player.keys[:red_skull]
161
+ draw_sprite(framebuffer, @gfx.keys[:red_skull], key_x, STATUS_BAR_Y + 23)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Render
5
+ # Renders the first-person weapon view
6
+ class WeaponRenderer
7
+ # Weapon is rendered above the status bar
8
+ WEAPON_AREA_HEIGHT = SCREEN_HEIGHT - StatusBar::STATUS_BAR_HEIGHT
9
+
10
+ def initialize(hud_graphics, player_state)
11
+ @gfx = hud_graphics
12
+ @player = player_state
13
+ end
14
+
15
+ def render(framebuffer)
16
+ weapon_name = @player.weapon_name
17
+ weapon_data = @gfx.weapons[weapon_name]
18
+ return unless weapon_data
19
+
20
+ # Get the appropriate frame
21
+ sprite = if @player.attacking && weapon_data[:fire]
22
+ frame = @player.attack_frame.clamp(0, weapon_data[:fire].length - 1)
23
+ weapon_data[:fire][frame]
24
+ else
25
+ weapon_data[:idle]
26
+ end
27
+
28
+ return unless sprite
29
+
30
+ # Calculate position with bob offset
31
+ bob_x = @player.weapon_bob_x.to_i
32
+ bob_y = @player.weapon_bob_y.to_i
33
+
34
+ # Center weapon horizontally (sprite width / 2 from center)
35
+ # Position weapon at bottom of view area
36
+ x = (SCREEN_WIDTH / 2) - (sprite.width / 2) + bob_x
37
+ # Only allow downward bob (positive values) to keep weapon bottom at status bar
38
+ clamped_bob_y = [bob_y, 0].max
39
+ y = WEAPON_AREA_HEIGHT - sprite.height + clamped_bob_y
40
+
41
+ # Add some vertical offset during attack (recoil effect)
42
+ if @player.attacking
43
+ recoil = case @player.attack_frame
44
+ when 0 then -6
45
+ when 1 then -3
46
+ when 2 then 3
47
+ else 0
48
+ end
49
+ y += recoil
50
+ end
51
+
52
+ draw_weapon_sprite(framebuffer, sprite, x, y)
53
+
54
+ # Draw muzzle flash for pistol
55
+ if @player.attacking && @player.attack_frame < 2
56
+ draw_muzzle_flash(framebuffer, weapon_name, x, y)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def draw_weapon_sprite(framebuffer, sprite, base_x, base_y)
63
+ return unless sprite
64
+
65
+ # Clip to screen bounds (don't draw over status bar)
66
+ max_y = WEAPON_AREA_HEIGHT - 1
67
+
68
+ sprite.width.times do |sx|
69
+ column = sprite.column_pixels(sx)
70
+ next unless column
71
+
72
+ draw_x = base_x + sx
73
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
74
+
75
+ column.each_with_index do |color, sy|
76
+ next unless color # Skip transparent pixels
77
+
78
+ draw_y = base_y + sy
79
+ next if draw_y < 0 || draw_y > max_y
80
+
81
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
82
+ end
83
+ end
84
+ end
85
+
86
+ def draw_muzzle_flash(framebuffer, weapon_name, weapon_x, weapon_y)
87
+ weapon_data = @gfx.weapons[weapon_name]
88
+ return unless weapon_data && weapon_data[:flash]
89
+
90
+ flash_frame = @player.attack_frame.clamp(0, weapon_data[:flash].length - 1)
91
+ flash_sprite = weapon_data[:flash][flash_frame]
92
+ return unless flash_sprite
93
+
94
+ # Flash is drawn at weapon position (sprite handles offset)
95
+ flash_x = (SCREEN_WIDTH / 2) - flash_sprite.left_offset
96
+ flash_y = WEAPON_AREA_HEIGHT - flash_sprite.top_offset
97
+
98
+ draw_weapon_sprite(framebuffer, flash_sprite, flash_x, flash_y)
99
+ end
100
+ end
101
+ end
102
+ 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.2.0"
4
+ VERSION = '0.4.0'
5
5
  end
@@ -0,0 +1,32 @@
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 self.load(wad)
20
+ data = wad.read_lump('COLORMAP')
21
+ raise Error, 'COLORMAP lump not found' unless data
22
+
23
+ maps = MAPS.times.map do |i|
24
+ offset = i * MAP_SIZE
25
+ data[offset, MAP_SIZE].bytes
26
+ end
27
+
28
+ new(maps)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,40 @@
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 self.load_all(wad)
30
+ entries = wad.lumps_between('F_START', 'F_END')
31
+ entries.map do |entry|
32
+ next if entry.size != SIZE
33
+
34
+ data = wad.read_lump_at(entry)
35
+ new(entry.name, data.bytes)
36
+ end.compact
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Wad
5
+ # Loads HUD graphics (status bar, weapons) from WAD
6
+ class HudGraphics
7
+ attr_reader :status_bar, :numbers, :weapons, :faces, :keys
8
+
9
+ def initialize(wad)
10
+ @wad = wad
11
+ @cache = {}
12
+
13
+ load_status_bar
14
+ load_numbers
15
+ load_weapons
16
+ load_faces
17
+ load_keys
18
+ end
19
+
20
+ # Get a cached graphic by name
21
+ def [](name)
22
+ @cache[name]
23
+ end
24
+
25
+ private
26
+
27
+ def load_graphic(name)
28
+ return @cache[name] if @cache[name]
29
+
30
+ entry = @wad.find_lump(name)
31
+ return nil unless entry
32
+
33
+ data = @wad.read_lump_at(entry)
34
+ return nil unless data && data.size > 8
35
+
36
+ sprite = parse_patch(name, data)
37
+ @cache[name] = sprite
38
+ sprite
39
+ end
40
+
41
+ def parse_patch(name, data)
42
+ width = data[0, 2].unpack1('v')
43
+ height = data[2, 2].unpack1('v')
44
+ left_offset = data[4, 2].unpack1('s<')
45
+ top_offset = data[6, 2].unpack1('s<')
46
+
47
+ # Read column offsets
48
+ column_offsets = width.times.map do |i|
49
+ data[8 + i * 4, 4].unpack1('V')
50
+ end
51
+
52
+ # Build column data
53
+ columns = column_offsets.map do |offset|
54
+ read_column(data, offset, height)
55
+ end
56
+
57
+ HudSprite.new(name, width, height, left_offset, top_offset, columns)
58
+ end
59
+
60
+ def read_column(data, offset, height)
61
+ pixels = Array.new(height)
62
+ pos = offset
63
+
64
+ loop do
65
+ break if pos >= data.size
66
+ top_delta = data[pos].ord
67
+ break if top_delta == 0xFF
68
+
69
+ length = data[pos + 1].ord
70
+ # Skip padding byte, read pixels, skip end padding
71
+ pixel_data = data[pos + 3, length]
72
+ break unless pixel_data
73
+
74
+ pixel_data.bytes.each_with_index do |color, i|
75
+ y = top_delta + i
76
+ pixels[y] = color if y < height
77
+ end
78
+
79
+ pos += length + 4
80
+ end
81
+
82
+ pixels
83
+ end
84
+
85
+ def load_status_bar
86
+ @status_bar = load_graphic('STBAR')
87
+ end
88
+
89
+ def load_numbers
90
+ @numbers = {}
91
+ # Large red numbers for health/ammo
92
+ (0..9).each do |n|
93
+ @numbers[n] = load_graphic("STTNUM#{n}")
94
+ end
95
+ @numbers['-'] = load_graphic('STTMINUS')
96
+ @numbers['%'] = load_graphic('STTPRCNT')
97
+
98
+ # Small grey numbers for arms
99
+ @grey_numbers = {}
100
+ (0..9).each do |n|
101
+ @grey_numbers[n] = load_graphic("STGNUM#{n}")
102
+ end
103
+ end
104
+
105
+ def load_weapons
106
+ @weapons = {}
107
+
108
+ # Pistol frames (PISG = pistol gun)
109
+ @weapons[:pistol] = {
110
+ idle: load_graphic('PISGA0'),
111
+ fire: [
112
+ load_graphic('PISGB0'),
113
+ load_graphic('PISGC0'),
114
+ load_graphic('PISGD0'),
115
+ load_graphic('PISGE0')
116
+ ].compact,
117
+ flash: [
118
+ load_graphic('PISFA0'),
119
+ load_graphic('PISFB0')
120
+ ].compact
121
+ }
122
+
123
+ # Fist frames (PUNG = punch)
124
+ @weapons[:fist] = {
125
+ idle: load_graphic('PUNGA0'),
126
+ fire: [
127
+ load_graphic('PUNGB0'),
128
+ load_graphic('PUNGC0'),
129
+ load_graphic('PUNGD0')
130
+ ].compact
131
+ }
132
+ end
133
+
134
+ def load_faces
135
+ @faces = {}
136
+
137
+ # Straight ahead faces at different health levels
138
+ # STF = status face, ST = straight, 0-4 = health level (4=full, 0=dying)
139
+ (0..4).each do |health_level|
140
+ @faces[health_level] = {
141
+ straight: [
142
+ load_graphic("STFST#{health_level}0"),
143
+ load_graphic("STFST#{health_level}1"),
144
+ load_graphic("STFST#{health_level}2")
145
+ ].compact,
146
+ left: load_graphic("STFTL#{health_level}0"),
147
+ right: load_graphic("STFTR#{health_level}0"),
148
+ ouch: load_graphic("STFOUCH#{health_level}"),
149
+ evil: load_graphic("STFEVL#{health_level}"),
150
+ kill: load_graphic("STFKILL#{health_level}")
151
+ }
152
+ end
153
+
154
+ # Special faces
155
+ @faces[:dead] = load_graphic('STFDEAD0')
156
+ @faces[:god] = load_graphic('STFGOD0')
157
+ end
158
+
159
+ def load_keys
160
+ @keys = {
161
+ blue_card: load_graphic('STKEYS0'),
162
+ yellow_card: load_graphic('STKEYS1'),
163
+ red_card: load_graphic('STKEYS2'),
164
+ blue_skull: load_graphic('STKEYS3'),
165
+ yellow_skull: load_graphic('STKEYS4'),
166
+ red_skull: load_graphic('STKEYS5')
167
+ }
168
+ end
169
+ end
170
+
171
+ # Simple sprite container for HUD graphics
172
+ class HudSprite
173
+ attr_reader :name, :width, :height, :left_offset, :top_offset, :columns
174
+
175
+ def initialize(name, width, height, left_offset, top_offset, columns)
176
+ @name = name
177
+ @width = width
178
+ @height = height
179
+ @left_offset = left_offset
180
+ @top_offset = top_offset
181
+ @columns = columns
182
+ end
183
+
184
+ def column_pixels(x)
185
+ @columns[x % @width]
186
+ end
187
+ end
188
+ end
189
+ 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