doom 0.3.0 → 0.5.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,218 @@
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
+ # Positions from Chocolate Doom st_stuff.c (relative to status bar top)
12
+ AMMO_RIGHT_X = 44 # ST_AMMOX - right edge of 3-digit ammo
13
+ HEALTH_RIGHT_X = 90 # ST_HEALTHX
14
+ ARMOR_RIGHT_X = 221 # ST_ARMORX
15
+
16
+ ARMS_BG_X = 104 # ST_ARMSBGX
17
+ ARMS_BG_Y = 0 # ST_ARMSBGY (relative to status bar)
18
+ ARMS_X = 111 # ST_ARMSX
19
+ ARMS_Y = 4 # ST_ARMSY (relative to status bar)
20
+ ARMS_XSPACE = 12
21
+ ARMS_YSPACE = 10
22
+
23
+ FACE_X = 149 # Centered in face background area
24
+ FACE_Y = 2 # Vertically centered in status bar
25
+
26
+ KEYS_X = 239 # ST_KEY0X
27
+
28
+ # Small ammo counts (right side of status bar)
29
+ SMALL_AMMO_X = 288 # Current ammo X
30
+ SMALL_MAX_X = 314 # Max ammo X
31
+ SMALL_AMMO_Y = [5, 11, 23, 17] # Bullets, Shells, Cells, Rockets (relative to bar)
32
+
33
+ NUM_WIDTH = 14 # Width of large digit
34
+ SMALL_NUM_WIDTH = 4 # Width of small digit
35
+
36
+ def initialize(hud_graphics, player_state)
37
+ @gfx = hud_graphics
38
+ @player = player_state
39
+ @face_timer = 0
40
+ @face_index = 0
41
+ end
42
+
43
+ def render(framebuffer)
44
+ # Draw status bar background
45
+ draw_sprite(framebuffer, @gfx.status_bar, 0, STATUS_BAR_Y) if @gfx.status_bar
46
+
47
+ # Draw arms background (single-player only, replaces FRAG area)
48
+ draw_sprite(framebuffer, @gfx.arms_background, ARMS_BG_X, STATUS_BAR_Y + ARMS_BG_Y) if @gfx.arms_background
49
+
50
+ # Y position for numbers (3 pixels from top of status bar)
51
+ num_y = STATUS_BAR_Y + 3
52
+
53
+ # Draw ammo count (right-aligned ending at AMMO_RIGHT_X)
54
+ draw_number_right(framebuffer, @player.current_ammo, AMMO_RIGHT_X, num_y) if @player.current_ammo
55
+
56
+ # Draw health with percent
57
+ draw_number_right(framebuffer, @player.health, HEALTH_RIGHT_X, num_y)
58
+ draw_percent(framebuffer, HEALTH_RIGHT_X, num_y)
59
+
60
+ # Draw weapon selector (2-7)
61
+ draw_arms(framebuffer)
62
+
63
+ # Draw face
64
+ draw_face(framebuffer)
65
+
66
+ # Draw armor with percent
67
+ draw_number_right(framebuffer, @player.armor, ARMOR_RIGHT_X, num_y)
68
+ draw_percent(framebuffer, ARMOR_RIGHT_X, num_y)
69
+
70
+ # Draw keys
71
+ draw_keys(framebuffer)
72
+
73
+ # Draw small ammo counts (right side)
74
+ draw_ammo_counts(framebuffer)
75
+ end
76
+
77
+ def update
78
+ # Cycle face animation
79
+ @face_timer += 1
80
+ if @face_timer > 15 # Change face every ~0.5 seconds
81
+ @face_timer = 0
82
+ @face_index = (@face_index + 1) % 3
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def draw_sprite(framebuffer, sprite, x, y)
89
+ return unless sprite
90
+
91
+ sprite.width.times do |sx|
92
+ column = sprite.column_pixels(sx)
93
+ next unless column
94
+
95
+ draw_x = x + sx
96
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
97
+
98
+ column.each_with_index do |color, sy|
99
+ next unless color
100
+
101
+ draw_y = y + sy
102
+ next if draw_y < 0 || draw_y >= SCREEN_HEIGHT
103
+
104
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
105
+ end
106
+ end
107
+ end
108
+
109
+ # Draw number right-aligned with right edge at right_x
110
+ def draw_number_right(framebuffer, value, right_x, y)
111
+ return unless value
112
+
113
+ value = value.to_i.clamp(-999, 999)
114
+ str = value.to_s
115
+
116
+ # Draw from right to left, starting from right edge
117
+ current_x = right_x
118
+ str.reverse.each_char do |char|
119
+ digit_sprite = if char == '-'
120
+ @gfx.numbers['-']
121
+ else
122
+ @gfx.numbers[char.to_i]
123
+ end
124
+
125
+ if digit_sprite
126
+ current_x -= NUM_WIDTH
127
+ draw_sprite(framebuffer, digit_sprite, current_x, y)
128
+ end
129
+ end
130
+ end
131
+
132
+ def draw_percent(framebuffer, x, y)
133
+ percent = @gfx.numbers['%']
134
+ draw_sprite(framebuffer, percent, x, y) if percent
135
+ end
136
+
137
+ def draw_arms(framebuffer)
138
+ # Weapon numbers 2-7 in a 3x2 grid
139
+ 6.times do |i|
140
+ weapon_num = i + 2 # weapons 2-7
141
+ owned = @player.has_weapons[weapon_num]
142
+ digit = owned ? @gfx.yellow_numbers[weapon_num] : @gfx.grey_numbers[weapon_num]
143
+ next unless digit
144
+
145
+ x = ARMS_X + (i % 3) * ARMS_XSPACE
146
+ y = STATUS_BAR_Y + ARMS_Y + (i / 3) * ARMS_YSPACE
147
+ draw_sprite(framebuffer, digit, x, y)
148
+ end
149
+ end
150
+
151
+ def draw_face(framebuffer)
152
+ # Pain level: 0 = healthy, 4 = near death
153
+ health = @player.health.clamp(0, 100)
154
+ pain_level = ((100 - health) * 5) / 101
155
+
156
+ face = if @player.health <= 0
157
+ @gfx.faces[:dead]
158
+ else
159
+ faces = @gfx.faces[pain_level]
160
+ faces[:straight][@face_index] if faces && faces[:straight]
161
+ end
162
+
163
+ return unless face
164
+ draw_sprite(framebuffer, face, FACE_X, STATUS_BAR_Y + FACE_Y)
165
+ end
166
+
167
+ def draw_ammo_counts(framebuffer)
168
+ ammo_current = [@player.ammo_bullets, @player.ammo_shells, @player.ammo_cells, @player.ammo_rockets]
169
+ ammo_max = [@player.max_bullets, @player.max_shells, @player.max_cells, @player.max_rockets]
170
+
171
+ 4.times do |i|
172
+ y = STATUS_BAR_Y + SMALL_AMMO_Y[i]
173
+ draw_small_number_right(framebuffer, ammo_current[i], SMALL_AMMO_X, y)
174
+ draw_small_number_right(framebuffer, ammo_max[i], SMALL_MAX_X, y)
175
+ end
176
+ end
177
+
178
+ def draw_small_number_right(framebuffer, value, right_x, y)
179
+ return unless value
180
+ str = value.to_i.to_s
181
+ current_x = right_x
182
+ str.reverse.each_char do |char|
183
+ digit = @gfx.yellow_numbers[char.to_i]
184
+ if digit
185
+ current_x -= SMALL_NUM_WIDTH
186
+ draw_sprite(framebuffer, digit, current_x, y)
187
+ end
188
+ end
189
+ end
190
+
191
+ def draw_keys(framebuffer)
192
+ key_x = KEYS_X
193
+ key_spacing = 10
194
+
195
+ # Blue keys (top row)
196
+ if @player.keys[:blue_card]
197
+ draw_sprite(framebuffer, @gfx.keys[:blue_card], key_x, STATUS_BAR_Y + 3)
198
+ elsif @player.keys[:blue_skull]
199
+ draw_sprite(framebuffer, @gfx.keys[:blue_skull], key_x, STATUS_BAR_Y + 3)
200
+ end
201
+
202
+ # Yellow keys (middle row)
203
+ if @player.keys[:yellow_card]
204
+ draw_sprite(framebuffer, @gfx.keys[:yellow_card], key_x, STATUS_BAR_Y + 13)
205
+ elsif @player.keys[:yellow_skull]
206
+ draw_sprite(framebuffer, @gfx.keys[:yellow_skull], key_x, STATUS_BAR_Y + 13)
207
+ end
208
+
209
+ # Red keys (bottom row)
210
+ if @player.keys[:red_card]
211
+ draw_sprite(framebuffer, @gfx.keys[:red_card], key_x, STATUS_BAR_Y + 23)
212
+ elsif @player.keys[:red_skull]
213
+ draw_sprite(framebuffer, @gfx.keys[:red_skull], key_x, STATUS_BAR_Y + 23)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,99 @@
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
+ # DOOM positions weapon sprites using their built-in offsets:
11
+ # x = SCREENWIDTH/2 - sprite.left_offset
12
+ # y = WEAPONTOP + SCREENHEIGHT - 200 - sprite.top_offset
13
+ # WEAPONTOP = 32 in fixed-point = 32 pixels above the default position
14
+ # We scale for our 240px screen vs DOOM's 200px.
15
+ WEAPONTOP = 32
16
+ SCREEN_Y_OFFSET = SCREEN_HEIGHT - 200 # 40px offset for 240px screen
17
+
18
+ def initialize(hud_graphics, player_state)
19
+ @gfx = hud_graphics
20
+ @player = player_state
21
+ end
22
+
23
+ def render(framebuffer)
24
+ weapon_name = @player.weapon_name
25
+ weapon_data = @gfx.weapons[weapon_name]
26
+ return unless weapon_data
27
+
28
+ # Get the appropriate frame
29
+ sprite = if @player.attacking && weapon_data[:fire]
30
+ frame = @player.attack_frame.clamp(0, weapon_data[:fire].length - 1)
31
+ weapon_data[:fire][frame]
32
+ else
33
+ weapon_data[:idle]
34
+ end
35
+
36
+ return unless sprite
37
+
38
+ # Bob offset (frozen during attack to keep weapon steady)
39
+ bob_x = @player.attacking ? 0 : @player.weapon_bob_x.to_i
40
+ bob_y = @player.attacking ? 0 : @player.weapon_bob_y.to_i
41
+
42
+ # DOOM's R_DrawPSprite: x1 = centerx + (psp->sx - centerx - spriteoffset)
43
+ # With psp->sx = 1 (default): x = 1 - left_offset
44
+ # Uses sprite's built-in offsets for both weapon and flash alignment
45
+ x = 1 - sprite.left_offset + bob_x
46
+ y = 1 - sprite.top_offset + SCREEN_Y_OFFSET + bob_y
47
+
48
+ draw_weapon_sprite(framebuffer, sprite, x, y)
49
+
50
+ # Draw muzzle flash only on the first fire frame (the actual shot)
51
+ if @player.attacking && @player.attack_frame == 0
52
+ draw_muzzle_flash(framebuffer, weapon_name)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def draw_weapon_sprite(framebuffer, sprite, base_x, base_y)
59
+ return unless sprite
60
+
61
+ # Clip to screen bounds (don't draw over status bar)
62
+ max_y = WEAPON_AREA_HEIGHT - 1
63
+
64
+ sprite.width.times do |sx|
65
+ column = sprite.column_pixels(sx)
66
+ next unless column
67
+
68
+ draw_x = base_x + sx
69
+ next if draw_x < 0 || draw_x >= SCREEN_WIDTH
70
+
71
+ column.each_with_index do |color, sy|
72
+ next unless color # Skip transparent pixels
73
+
74
+ draw_y = base_y + sy
75
+ next if draw_y < 0 || draw_y > max_y
76
+
77
+ framebuffer[draw_y * SCREEN_WIDTH + draw_x] = color
78
+ end
79
+ end
80
+ end
81
+
82
+ def draw_muzzle_flash(framebuffer, weapon_name)
83
+ weapon_data = @gfx.weapons[weapon_name]
84
+ return unless weapon_data && weapon_data[:flash]
85
+
86
+ flash_frame = @player.attack_frame.clamp(0, weapon_data[:flash].length - 1)
87
+ flash_sprite = weapon_data[:flash][flash_frame]
88
+ return unless flash_sprite
89
+
90
+ # Flash uses same positioning as weapon sprite (built-in offsets)
91
+ # Same positioning formula as weapon sprite
92
+ flash_x = 1 - flash_sprite.left_offset
93
+ flash_y = 1 - flash_sprite.top_offset + SCREEN_Y_OFFSET
94
+
95
+ draw_weapon_sprite(framebuffer, flash_sprite, flash_x, flash_y)
96
+ end
97
+ end
98
+ end
99
+ 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.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -16,12 +16,6 @@ module Doom
16
16
  @maps[map_index]
17
17
  end
18
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
19
  def self.load(wad)
26
20
  data = wad.read_lump('COLORMAP')
27
21
  raise Error, 'COLORMAP lump not found' unless data
data/lib/doom/wad/flat.rb CHANGED
@@ -26,27 +26,6 @@ module Doom
26
26
  @pixels[(y & 63) * WIDTH + (x & 63)]
27
27
  end
28
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
29
  def self.load_all(wad)
51
30
  entries = wad.lumps_between('F_START', 'F_END')
52
31
  entries.map do |entry|
@@ -0,0 +1,257 @@
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, :arms_background, :numbers, :grey_numbers, :yellow_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
+ @arms_background = load_graphic('STARMS')
88
+ end
89
+
90
+ def load_numbers
91
+ @numbers = {}
92
+ # Large red numbers for health/ammo
93
+ (0..9).each do |n|
94
+ @numbers[n] = load_graphic("STTNUM#{n}")
95
+ end
96
+ @numbers['-'] = load_graphic('STTMINUS')
97
+ @numbers['%'] = load_graphic('STTPRCNT')
98
+
99
+ # Small grey numbers for arms (weapon not owned)
100
+ @grey_numbers = {}
101
+ (0..9).each do |n|
102
+ @grey_numbers[n] = load_graphic("STGNUM#{n}")
103
+ end
104
+
105
+ # Small yellow numbers for arms (weapon owned) and ammo counts
106
+ @yellow_numbers = {}
107
+ (0..9).each do |n|
108
+ @yellow_numbers[n] = load_graphic("STYSNUM#{n}")
109
+ end
110
+ end
111
+
112
+ def load_weapons
113
+ @weapons = {}
114
+
115
+ # Pistol frames (PISG = pistol gun)
116
+ @weapons[:pistol] = {
117
+ idle: load_graphic('PISGA0'),
118
+ fire: [
119
+ load_graphic('PISGB0'),
120
+ load_graphic('PISGC0'),
121
+ load_graphic('PISGD0'),
122
+ load_graphic('PISGE0')
123
+ ].compact,
124
+ flash: [
125
+ load_graphic('PISFA0'),
126
+ load_graphic('PISFB0')
127
+ ].compact
128
+ }
129
+
130
+ # Fist frames (PUNG = punch)
131
+ @weapons[:fist] = {
132
+ idle: load_graphic('PUNGA0'),
133
+ fire: [
134
+ load_graphic('PUNGB0'),
135
+ load_graphic('PUNGC0'),
136
+ load_graphic('PUNGD0')
137
+ ].compact
138
+ }
139
+
140
+ # Shotgun (SHTG)
141
+ @weapons[:shotgun] = {
142
+ idle: load_graphic('SHTGA0'),
143
+ fire: [
144
+ load_graphic('SHTGB0'),
145
+ load_graphic('SHTGC0'),
146
+ load_graphic('SHTGD0')
147
+ ].compact,
148
+ flash: [load_graphic('SHTFA0'), load_graphic('SHTFB0')].compact
149
+ }
150
+
151
+ # Chaingun (CHGG)
152
+ @weapons[:chaingun] = {
153
+ idle: load_graphic('CHGGA0'),
154
+ fire: [
155
+ load_graphic('CHGGB0'),
156
+ load_graphic('CHGGC0')
157
+ ].compact,
158
+ flash: [load_graphic('CHGFA0'), load_graphic('CHGFB0')].compact
159
+ }
160
+
161
+ # Rocket launcher (MISG)
162
+ @weapons[:rocket] = {
163
+ idle: load_graphic('MISGA0'),
164
+ fire: [
165
+ load_graphic('MISGB0'),
166
+ load_graphic('MISGC0'),
167
+ load_graphic('MISGD0')
168
+ ].compact,
169
+ flash: [load_graphic('MISFA0'), load_graphic('MISFB0'), load_graphic('MISFC0')].compact
170
+ }
171
+
172
+ # Plasma rifle (PLSG)
173
+ @weapons[:plasma] = {
174
+ idle: load_graphic('PLSGA0'),
175
+ fire: [
176
+ load_graphic('PLSGB0')
177
+ ].compact,
178
+ flash: [load_graphic('PLSFA0'), load_graphic('PLSFB0')].compact
179
+ }
180
+
181
+ # BFG9000 (BFGG)
182
+ @weapons[:bfg] = {
183
+ idle: load_graphic('BFGGA0'),
184
+ fire: [
185
+ load_graphic('BFGGB0'),
186
+ load_graphic('BFGGC0')
187
+ ].compact,
188
+ flash: [load_graphic('BFGFA0'), load_graphic('BFGFB0')].compact
189
+ }
190
+
191
+ # Chainsaw (SAWG)
192
+ @weapons[:chainsaw] = {
193
+ idle: load_graphic('SAWGA0'),
194
+ fire: [
195
+ load_graphic('SAWGB0'),
196
+ load_graphic('SAWGC0'),
197
+ load_graphic('SAWGD0')
198
+ ].compact
199
+ }
200
+ end
201
+
202
+ def load_faces
203
+ @faces = {}
204
+
205
+ # Straight ahead faces at different health levels
206
+ # STF = status face, ST = straight, 0-4 = health level (4=full, 0=dying)
207
+ (0..4).each do |health_level|
208
+ @faces[health_level] = {
209
+ straight: [
210
+ load_graphic("STFST#{health_level}0"),
211
+ load_graphic("STFST#{health_level}1"),
212
+ load_graphic("STFST#{health_level}2")
213
+ ].compact,
214
+ left: load_graphic("STFTL#{health_level}0"),
215
+ right: load_graphic("STFTR#{health_level}0"),
216
+ ouch: load_graphic("STFOUCH#{health_level}"),
217
+ evil: load_graphic("STFEVL#{health_level}"),
218
+ kill: load_graphic("STFKILL#{health_level}")
219
+ }
220
+ end
221
+
222
+ # Special faces
223
+ @faces[:dead] = load_graphic('STFDEAD0')
224
+ @faces[:god] = load_graphic('STFGOD0')
225
+ end
226
+
227
+ def load_keys
228
+ @keys = {
229
+ blue_card: load_graphic('STKEYS0'),
230
+ yellow_card: load_graphic('STKEYS1'),
231
+ red_card: load_graphic('STKEYS2'),
232
+ blue_skull: load_graphic('STKEYS3'),
233
+ yellow_skull: load_graphic('STKEYS4'),
234
+ red_skull: load_graphic('STKEYS5')
235
+ }
236
+ end
237
+ end
238
+
239
+ # Simple sprite container for HUD graphics
240
+ class HudSprite
241
+ attr_reader :name, :width, :height, :left_offset, :top_offset, :columns
242
+
243
+ def initialize(name, width, height, left_offset, top_offset, columns)
244
+ @name = name
245
+ @width = width
246
+ @height = height
247
+ @left_offset = left_offset
248
+ @top_offset = top_offset
249
+ @columns = columns
250
+ end
251
+
252
+ def column_pixels(x)
253
+ @columns[x % @width]
254
+ end
255
+ end
256
+ end
257
+ end