doom 0.5.0 → 0.8.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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # Intermission screen shown between levels.
6
+ # Displays kill%, item%, secret%, time, and par time.
7
+ class Intermission
8
+ # Episode 1 par times in seconds (from Chocolate Doom)
9
+ PAR_TIMES = {
10
+ 'E1M1' => 30, 'E1M2' => 75, 'E1M3' => 120, 'E1M4' => 90,
11
+ 'E1M5' => 165, 'E1M6' => 180, 'E1M7' => 180, 'E1M8' => 30, 'E1M9' => 165,
12
+ }.freeze
13
+
14
+ # Next map progression
15
+ NEXT_MAP = {
16
+ 'E1M1' => 'E1M2', 'E1M2' => 'E1M3', 'E1M3' => 'E1M4', 'E1M4' => 'E1M5',
17
+ 'E1M5' => 'E1M6', 'E1M6' => 'E1M7', 'E1M7' => 'E1M8', 'E1M8' => nil,
18
+ 'E1M9' => 'E1M4',
19
+ }.freeze
20
+
21
+ # Counter animation speed (percentage points per tic)
22
+ COUNT_SPEED = 2
23
+ TICS_PER_COUNT = 1
24
+
25
+ attr_reader :finished, :next_map
26
+
27
+ def initialize(wad, hud_graphics, stats)
28
+ @wad = wad
29
+ @gfx = hud_graphics
30
+ @stats = stats # { map:, kills:, total_kills:, items:, total_items:, secrets:, total_secrets:, time_tics: }
31
+ @finished = false
32
+ @next_map = NEXT_MAP[stats[:map]]
33
+ @tic = 0
34
+
35
+ # Animated counters (count up from 0 to actual value)
36
+ @kill_count = 0
37
+ @item_count = 0
38
+ @secret_count = 0
39
+ @time_count = 0
40
+ @counting_done = false
41
+
42
+ # Target percentages
43
+ @kill_pct = @stats[:total_kills] > 0 ? (@stats[:kills] * 100 / @stats[:total_kills]) : 100
44
+ @item_pct = @stats[:total_items] > 0 ? (@stats[:items] * 100 / @stats[:total_items]) : 100
45
+ @secret_pct = @stats[:total_secrets] > 0 ? (@stats[:secrets] * 100 / @stats[:total_secrets]) : 100
46
+ @time_secs = @stats[:time_tics] / 35
47
+
48
+ @par_time = PAR_TIMES[stats[:map]] || 0
49
+
50
+ load_graphics
51
+ end
52
+
53
+ def update
54
+ @tic += 1
55
+ return if @counting_done
56
+
57
+ # Animate counters
58
+ if @kill_count < @kill_pct
59
+ @kill_count = [@kill_count + COUNT_SPEED, @kill_pct].min
60
+ elsif @item_count < @item_pct
61
+ @item_count = [@item_count + COUNT_SPEED, @item_pct].min
62
+ elsif @secret_count < @secret_pct
63
+ @secret_count = [@secret_count + COUNT_SPEED, @secret_pct].min
64
+ elsif @time_count < @time_secs
65
+ @time_count = [@time_count + 3, @time_secs].min
66
+ else
67
+ @counting_done = true
68
+ end
69
+ end
70
+
71
+ def render(framebuffer)
72
+ # Background
73
+ draw_background(framebuffer)
74
+
75
+ # "Finished" text + level name
76
+ draw_sprite(framebuffer, @wifinish, 64, 4) if @wifinish
77
+ level_idx = map_to_level_index(@stats[:map])
78
+ lv = @level_names[level_idx]
79
+ draw_sprite(framebuffer, lv, (320 - (lv&.width || 0)) / 2, 24) if lv
80
+
81
+ # Kill, Item, Secret percentages
82
+ y = 60
83
+ draw_sprite(framebuffer, @wiostk, 50, y) if @wiostk
84
+ draw_percent(framebuffer, 260, y, @kill_count)
85
+
86
+ y += 24
87
+ draw_sprite(framebuffer, @wiosti, 50, y) if @wiosti
88
+ draw_percent(framebuffer, 260, y, @item_count)
89
+
90
+ y += 24
91
+ draw_sprite(framebuffer, @wiosts, 50, y) if @wiosts
92
+ draw_percent(framebuffer, 260, y, @secret_count)
93
+
94
+ # Time
95
+ y += 30
96
+ draw_sprite(framebuffer, @witime, 16, y) if @witime
97
+ draw_time(framebuffer, 160, y, @time_count)
98
+
99
+ # Par time
100
+ draw_sprite(framebuffer, @wipar, 176, y) if @wipar
101
+ draw_time(framebuffer, 292, y, @par_time)
102
+
103
+ # "Entering" next level (after counting done)
104
+ if @counting_done && @next_map
105
+ y += 30
106
+ draw_sprite(framebuffer, @wienter, 64, y) if @wienter
107
+ next_idx = map_to_level_index(@next_map)
108
+ nlv = @level_names[next_idx]
109
+ draw_sprite(framebuffer, nlv, (320 - (nlv&.width || 0)) / 2, y + 18) if nlv
110
+ end
111
+
112
+ # "Press any key" hint after counting
113
+ if @counting_done && (@tic / 17) % 2 == 0
114
+ # Blink hint via skull
115
+ skull = @skulls[@tic / 8 % 2]
116
+ draw_sprite(framebuffer, skull, 144, 210) if skull
117
+ end
118
+ end
119
+
120
+ def handle_key
121
+ if @counting_done
122
+ @finished = true
123
+ else
124
+ # Skip counting animation
125
+ @kill_count = @kill_pct
126
+ @item_count = @item_pct
127
+ @secret_count = @secret_pct
128
+ @time_count = @time_secs
129
+ @counting_done = true
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def map_to_level_index(map_name)
136
+ return 0 unless map_name
137
+ map_name[3].to_i - 1 # E1M1 -> 0, E1M2 -> 1, etc.
138
+ end
139
+
140
+ def load_graphics
141
+ # Intermission number digits
142
+ @nums = (0..9).map { |n| load_patch("WINUM#{n}") }
143
+ @percent = load_patch('WIPCNT')
144
+ @colon = load_patch('WICOLON')
145
+ @minus = load_patch('WIMINUS')
146
+
147
+ # Labels
148
+ @wiostk = load_patch('WIOSTK') # "Kills"
149
+ @wiosti = load_patch('WIOSTI') # "Items"
150
+ @wiosts = load_patch('WIOSTS') # "Scrt" (Secrets)
151
+ @witime = load_patch('WITIME') # "Time"
152
+ @wipar = load_patch('WIPAR') # "Par"
153
+ @wifinish = load_patch('WIF') # "Finished"
154
+ @wienter = load_patch('WIENTER') # "Entering"
155
+
156
+ # Map background
157
+ @wimap = load_patch('WIMAP0')
158
+
159
+ # Level names (WILV00-WILV08)
160
+ @level_names = (0..8).map { |n| load_patch("WILV0#{n}") }
161
+
162
+ # Skull cursor
163
+ @skulls = [load_patch('M_SKULL1'), load_patch('M_SKULL2')]
164
+ end
165
+
166
+ def load_patch(name)
167
+ @gfx.send(:load_graphic, name)
168
+ end
169
+
170
+ def draw_background(framebuffer)
171
+ return unless @wimap
172
+ draw_fullscreen(framebuffer, @wimap)
173
+ end
174
+
175
+ def draw_fullscreen(framebuffer, sprite)
176
+ return unless sprite
177
+ y_offset = (240 - sprite.height) / 2
178
+ y_offset = [y_offset, 0].max
179
+ sprite.width.times do |x|
180
+ next if x >= 320
181
+ col = sprite.column_pixels(x)
182
+ next unless col
183
+ col.each_with_index do |color, y|
184
+ next unless color
185
+ sy = y + y_offset
186
+ next if sy < 0 || sy >= 240
187
+ framebuffer[sy * 320 + x] = color
188
+ end
189
+ end
190
+ end
191
+
192
+ def draw_percent(framebuffer, right_x, y, value)
193
+ # Draw percent sign
194
+ draw_sprite(framebuffer, @percent, right_x, y) if @percent
195
+
196
+ # Draw number right-aligned before percent
197
+ draw_num_right(framebuffer, right_x - 2, y, value)
198
+ end
199
+
200
+ def draw_time(framebuffer, right_x, y, seconds)
201
+ mins = seconds / 60
202
+ secs = seconds % 60
203
+
204
+ # Draw seconds (2 digits, zero-padded)
205
+ draw_num_right(framebuffer, right_x, y, secs, pad: 2)
206
+
207
+ # Colon
208
+ colon_x = right_x - num_width * 2 - 4
209
+ draw_sprite(framebuffer, @colon, colon_x, y) if @colon
210
+
211
+ # Minutes
212
+ draw_num_right(framebuffer, colon_x - 2, y, mins)
213
+ end
214
+
215
+ def num_width
216
+ @nums[0]&.width || 14
217
+ end
218
+
219
+ def draw_num_right(framebuffer, right_x, y, value, pad: 0)
220
+ w = num_width
221
+ str = value.to_i.to_s
222
+ str = str.rjust(pad, '0') if pad > 0
223
+ x = right_x
224
+ str.reverse.each_char do |ch|
225
+ x -= w
226
+ digit = @nums[ch.to_i]
227
+ draw_sprite(framebuffer, digit, x, y) if digit
228
+ end
229
+ end
230
+
231
+ def draw_sprite(framebuffer, sprite, x, y)
232
+ return unless sprite
233
+ sprite.width.times do |col_x|
234
+ sx = x + col_x
235
+ next if sx < 0 || sx >= 320
236
+ col = sprite.column_pixels(col_x)
237
+ next unless col
238
+ col.each_with_index do |color, col_y|
239
+ next unless color
240
+ sy = y + col_y
241
+ next if sy < 0 || sy >= 240
242
+ framebuffer[sy * 320 + sx] = color
243
+ end
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
@@ -50,31 +50,66 @@ module Doom
50
50
  38 => { cat: :key, key: :red_skull },
51
51
  }.freeze
52
52
 
53
- attr_reader :picked_up
53
+ MESSAGETICS = 140 # 4 * TICRATE (4 seconds, matching Chocolate Doom)
54
+ FLASH_TICS = 8 # Yellow palette flash duration
54
55
 
55
- def initialize(map, player_state)
56
+ attr_reader :picked_up, :pickup_message, :pickup_flash, :message_tics
57
+ attr_accessor :ammo_multiplier, :hidden_things
58
+
59
+ def initialize(map, player_state, hidden_things = {})
56
60
  @map = map
57
61
  @player = player_state
58
- @picked_up = {} # thing index => true (to avoid re-picking)
62
+ @picked_up = {}
63
+ @hidden_things = hidden_things
64
+ @ammo_multiplier = 1
65
+ @pickup_message = nil
66
+ @pickup_flash = 0 # Yellow screen flash (short)
67
+ @message_tics = 0 # Message display timer (long)
68
+ end
69
+
70
+ # Decay timers each tic
71
+ def update_flash
72
+ @pickup_flash -= 1 if @pickup_flash > 0
73
+ @message_tics -= 1 if @message_tics > 0
59
74
  end
60
75
 
61
76
  def update(player_x, player_y)
62
77
  @map.things.each_with_index do |thing, idx|
78
+ next if @hidden_things[idx]
63
79
  next if @picked_up[idx]
64
80
  item = ITEMS[thing.type]
65
81
  next unless item
66
82
 
67
- # DOOM uses bounding box overlap: abs(dx) < sum_of_radii
68
83
  dx = (player_x - thing.x).abs
69
84
  dy = (player_y - thing.y).abs
70
85
  next if dx >= PICKUP_DIST || dy >= PICKUP_DIST
71
86
 
72
87
  if try_pickup(item)
73
88
  @picked_up[idx] = true
89
+ @pickup_message = PICKUP_MESSAGES[thing.type]
90
+ @pickup_flash = FLASH_TICS
91
+ @message_tics = MESSAGETICS
74
92
  end
75
93
  end
76
94
  end
77
95
 
96
+ PICKUP_MESSAGES = {
97
+ 2001 => "A SHOTGUN!", 2002 => "A CHAINGUN!", 2003 => "A ROCKET LAUNCHER!",
98
+ 2004 => "A PLASMA RIFLE!", 2005 => "A CHAINSAW!", 2006 => "A BFG9000!",
99
+ 2007 => "PICKED UP A CLIP.", 2048 => "PICKED UP A BOX OF BULLETS.",
100
+ 2008 => "PICKED UP 4 SHOTGUN SHELLS.", 2049 => "PICKED UP A BOX OF SHELLS.",
101
+ 2010 => "PICKED UP A ROCKET.", 2046 => "PICKED UP A BOX OF ROCKETS.",
102
+ 17 => "PICKED UP AN ENERGY CELL.", 2047 => "PICKED UP AN ENERGY CELL PACK.",
103
+ 8 => "PICKED UP A BACKPACK FULL OF AMMO!",
104
+ 2011 => "PICKED UP A STIMPACK.", 2012 => "PICKED UP A MEDIKIT.",
105
+ 2014 => "PICKED UP A HEALTH BONUS.", 2015 => "PICKED UP AN ARMOR BONUS.",
106
+ 2018 => "PICKED UP THE ARMOR.", 2019 => "PICKED UP THE MEGAARMOR!",
107
+ 2013 => "SUPERCHARGE!",
108
+ 5 => "PICKED UP A BLUE KEYCARD.", 6 => "PICKED UP A YELLOW KEYCARD.",
109
+ 13 => "PICKED UP A RED KEYCARD.", 40 => "PICKED UP A BLUE SKULL KEY.",
110
+ 39 => "PICKED UP A YELLOW SKULL KEY.", 38 => "PICKED UP A RED SKULL KEY.",
111
+ }.freeze
112
+
78
113
  private
79
114
 
80
115
  def try_pickup(item)
@@ -112,6 +147,7 @@ module Doom
112
147
  end
113
148
 
114
149
  def give_ammo(type, amount)
150
+ amount = amount * @ammo_multiplier
115
151
  case type
116
152
  when :bullets
117
153
  return false if @player.ammo_bullets >= @player.max_bullets
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doom
4
+ module Game
5
+ # DOOM main menu system with title screen, new game, and difficulty selection.
6
+ class Menu
7
+ SKULL_ANIM_TICS = 8 # Skull cursor blink rate
8
+
9
+ # Difficulty levels matching DOOM's skill levels
10
+ SKILL_BABY = 0 # I'm too young to die
11
+ SKILL_EASY = 1 # Hey, not too rough
12
+ SKILL_MEDIUM = 2 # Hurt me plenty
13
+ SKILL_HARD = 3 # Ultra-Violence
14
+ SKILL_NIGHTMARE = 4 # Nightmare!
15
+
16
+ # Menu states
17
+ STATE_TITLE = :title
18
+ STATE_MAIN = :main
19
+ STATE_SKILL = :skill
20
+ STATE_OPTIONS = :options
21
+ STATE_NONE = :none # In-game, no menu
22
+
23
+ # Main menu items
24
+ MAIN_ITEMS = %i[new_game options quit].freeze
25
+
26
+ # Options menu items
27
+ OPTIONS_ITEMS = %i[god_mode infinite_ammo all_weapons fullscreen rubykaigi_mode].freeze
28
+ OPTIONS_LABELS = {
29
+ god_mode: "GOD MODE",
30
+ infinite_ammo: "INFINITE AMMO",
31
+ all_weapons: "ALL WEAPONS",
32
+ fullscreen: "FULLSCREEN",
33
+ rubykaigi_mode: "RUBYKAIGI MODE",
34
+ }.freeze
35
+
36
+ OPTIONS_X = 48
37
+ OPTIONS_Y = 50
38
+ OPTIONS_SPACING = 18
39
+
40
+ # Skill menu items
41
+ SKILL_ITEMS = [SKILL_BABY, SKILL_EASY, SKILL_MEDIUM, SKILL_HARD, SKILL_NIGHTMARE].freeze
42
+
43
+ # Menu item Y positions (from Chocolate Doom m_menu.c)
44
+ MAIN_X = 97
45
+ MAIN_Y = 64
46
+ MAIN_SPACING = 16
47
+
48
+ SKILL_X = 48
49
+ SKILL_Y = 63
50
+ SKILL_SPACING = 16
51
+
52
+ attr_reader :state, :selected_skill, :options, :font
53
+
54
+ def initialize(wad, hud_graphics, font = nil)
55
+ @wad = wad
56
+ @gfx = hud_graphics
57
+ @font = font
58
+ @state = STATE_TITLE
59
+ @cursor = 0
60
+ @skull_frame = 0
61
+ @skull_tic = 0
62
+ @selected_skill = SKILL_MEDIUM # Default difficulty
63
+ @game_started = false
64
+
65
+ # Options toggles
66
+ @options = {
67
+ god_mode: false,
68
+ infinite_ammo: false,
69
+ all_weapons: false,
70
+ fullscreen: false,
71
+ rubykaigi_mode: false,
72
+ }
73
+
74
+ load_graphics
75
+ end
76
+
77
+ def active?
78
+ @state != STATE_NONE
79
+ end
80
+
81
+ def needs_background?
82
+ @state != STATE_TITLE
83
+ end
84
+
85
+ def update
86
+ @skull_tic += 1
87
+ if @skull_tic >= SKULL_ANIM_TICS
88
+ @skull_tic = 0
89
+ @skull_frame = 1 - @skull_frame
90
+ end
91
+ end
92
+
93
+ def render(framebuffer, palette_colors)
94
+ case @state
95
+ when STATE_TITLE
96
+ render_title(framebuffer)
97
+ when STATE_MAIN
98
+ render_main_menu(framebuffer)
99
+ when STATE_SKILL
100
+ render_skill_menu(framebuffer)
101
+ when STATE_OPTIONS
102
+ render_options_menu(framebuffer)
103
+ end
104
+ end
105
+
106
+ # Returns :start_game, :resume, :quit, or option action symbols
107
+ def handle_key(key)
108
+ case @state
109
+ when STATE_TITLE
110
+ @state = STATE_MAIN
111
+ @cursor = 0
112
+ when STATE_MAIN
113
+ handle_main_key(key)
114
+ when STATE_SKILL
115
+ handle_skill_key(key)
116
+ when STATE_OPTIONS
117
+ handle_options_key(key)
118
+ end
119
+ end
120
+
121
+ def dismiss
122
+ @state = STATE_NONE
123
+ @game_started = true
124
+ end
125
+
126
+ def show
127
+ @state = STATE_MAIN
128
+ @cursor = 0
129
+ end
130
+
131
+ private
132
+
133
+ def load_graphics
134
+ # Title screen
135
+ @title = load_patch('TITLEPIC')
136
+
137
+ # RubyKaigi title screen (pre-rendered palette indices)
138
+ kaigi_path = File.join(File.expand_path('../../..', __dir__), 'assets', 'kaigi_title.dat')
139
+ @kaigi_title = File.exist?(kaigi_path) ? Marshal.load(File.binread(kaigi_path)) : nil
140
+
141
+ # Main menu
142
+ @m_doom = load_patch('M_DOOM')
143
+ @m_newg = load_patch('M_NGAME')
144
+ @m_option = load_patch('M_OPTION')
145
+ @m_quitg = load_patch('M_QUITG')
146
+
147
+ # Skill menu
148
+ @m_skill = load_patch('M_SKILL')
149
+ @m_jkill = load_patch('M_JKILL')
150
+ @m_hurt = load_patch('M_HURT')
151
+ @m_rough = load_patch('M_ROUGH') # Not used, but loaded
152
+ @m_ultra = load_patch('M_ULTRA')
153
+ @m_nmare = load_patch('M_NMARE')
154
+
155
+ # Episode (shareware only has 1)
156
+ @m_episod = load_patch('M_EPISOD')
157
+ @m_epi1 = load_patch('M_EPI1')
158
+
159
+ # Skull cursor
160
+ @skulls = [load_patch('M_SKULL1'), load_patch('M_SKULL2')]
161
+ end
162
+
163
+ def load_patch(name)
164
+ @gfx.send(:load_graphic, name)
165
+ end
166
+
167
+ def render_title(framebuffer)
168
+ if @options[:rubykaigi_mode] && @kaigi_title
169
+ # Draw kaigi title (raw palette indices, 320x200, offset 20px down)
170
+ @kaigi_title.each_with_index do |color, i|
171
+ x = i % 320
172
+ y = (i / 320) + 20
173
+ framebuffer[y * 320 + x] = color if y < 240
174
+ end
175
+ elsif @title
176
+ draw_fullscreen(framebuffer, @title)
177
+ end
178
+ end
179
+
180
+ def render_main_menu(framebuffer)
181
+ # Draw title logo
182
+ draw_sprite(framebuffer, @m_doom, 94, 2) if @m_doom
183
+
184
+ # Draw menu items
185
+ items = [@m_newg, @m_option, @m_quitg]
186
+ items.each_with_index do |item, i|
187
+ next unless item
188
+ draw_sprite(framebuffer, item, MAIN_X, MAIN_Y + i * MAIN_SPACING)
189
+ end
190
+
191
+ # Draw skull cursor
192
+ skull = @skulls[@skull_frame]
193
+ if skull
194
+ skull_x = MAIN_X - 32
195
+ skull_y = MAIN_Y + @cursor * MAIN_SPACING - 5
196
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
197
+ end
198
+ end
199
+
200
+ def render_skill_menu(framebuffer)
201
+ # Draw skill title
202
+ draw_sprite(framebuffer, @m_skill, 38, 15) if @m_skill
203
+
204
+ # Draw skill items: baby, easy, medium, hard, nightmare
205
+ skill_items = [@m_jkill, @m_hurt, @m_rough, @m_ultra, @m_nmare]
206
+ skill_items.each_with_index do |item, i|
207
+ next unless item
208
+ draw_sprite(framebuffer, item, SKILL_X, SKILL_Y + i * SKILL_SPACING)
209
+ end
210
+
211
+ # Draw skull cursor
212
+ skull = @skulls[@skull_frame]
213
+ if skull
214
+ skull_x = SKILL_X - 32
215
+ skull_y = SKILL_Y + @cursor * SKILL_SPACING - 5
216
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
217
+ end
218
+ end
219
+
220
+ def handle_main_key(key)
221
+ case key
222
+ when :up
223
+ @cursor = (@cursor - 1) % MAIN_ITEMS.size
224
+ when :down
225
+ @cursor = (@cursor + 1) % MAIN_ITEMS.size
226
+ when :enter
227
+ case MAIN_ITEMS[@cursor]
228
+ when :new_game
229
+ @state = STATE_SKILL
230
+ @cursor = SKILL_MEDIUM # Default to "Hurt me plenty"
231
+ when :options
232
+ @state = STATE_OPTIONS
233
+ @cursor = 0
234
+ when :quit
235
+ return :quit
236
+ end
237
+ when :escape
238
+ if @game_started
239
+ # Resume game
240
+ @state = STATE_NONE
241
+ return :resume
242
+ else
243
+ @state = STATE_TITLE
244
+ end
245
+ end
246
+ nil
247
+ end
248
+
249
+ def handle_skill_key(key)
250
+ case key
251
+ when :up
252
+ @cursor = (@cursor - 1) % SKILL_ITEMS.size
253
+ when :down
254
+ @cursor = (@cursor + 1) % SKILL_ITEMS.size
255
+ when :enter
256
+ @selected_skill = SKILL_ITEMS[@cursor]
257
+ @state = STATE_NONE
258
+ @game_started = true
259
+ return :start_game
260
+ when :escape
261
+ @state = STATE_MAIN
262
+ @cursor = 0
263
+ end
264
+ nil
265
+ end
266
+
267
+ def render_options_menu(framebuffer)
268
+ # Draw title using font
269
+ @font&.draw_centered(framebuffer, "OPTIONS", 20)
270
+
271
+ # Draw each option with ON/OFF status
272
+ OPTIONS_ITEMS.each_with_index do |item, i|
273
+ y = OPTIONS_Y + i * OPTIONS_SPACING
274
+ label = OPTIONS_LABELS[item]
275
+ value = @options[item] ? "ON" : "OFF"
276
+ @font&.draw_text(framebuffer, label, OPTIONS_X, y)
277
+ @font&.draw_text(framebuffer, value, 260, y)
278
+ end
279
+
280
+ # Draw skull cursor
281
+ skull = @skulls[@skull_frame]
282
+ if skull
283
+ skull_x = OPTIONS_X - 32
284
+ skull_y = OPTIONS_Y + @cursor * OPTIONS_SPACING - 5
285
+ draw_sprite(framebuffer, skull, skull_x, skull_y)
286
+ end
287
+ end
288
+
289
+ def handle_options_key(key)
290
+ case key
291
+ when :up
292
+ @cursor = (@cursor - 1) % OPTIONS_ITEMS.size
293
+ when :down
294
+ @cursor = (@cursor + 1) % OPTIONS_ITEMS.size
295
+ when :enter
296
+ item = OPTIONS_ITEMS[@cursor]
297
+ @options[item] = !@options[item]
298
+ return { action: :toggle_option, option: item, value: @options[item] }
299
+ when :escape
300
+ @state = STATE_MAIN
301
+ @cursor = 1 # Options is the second main menu item
302
+ end
303
+ nil
304
+ end
305
+
306
+ def draw_fullscreen(framebuffer, sprite)
307
+ return unless sprite
308
+ # TITLEPIC is 320x200, our screen is 320x240
309
+ # Draw it centered vertically (offset by 20 pixels)
310
+ y_offset = 20
311
+ sprite.width.times do |x|
312
+ col = sprite.column_pixels(x)
313
+ next unless col
314
+ col.each_with_index do |color, y|
315
+ next unless color
316
+ screen_y = y + y_offset
317
+ next if screen_y < 0 || screen_y >= 240
318
+ framebuffer[screen_y * 320 + x] = color
319
+ end
320
+ end
321
+ end
322
+
323
+ def draw_sprite(framebuffer, sprite, x, y)
324
+ return unless sprite
325
+ sprite.width.times do |col_x|
326
+ screen_x = x + col_x
327
+ next if screen_x < 0 || screen_x >= 320
328
+
329
+ col = sprite.column_pixels(col_x)
330
+ next unless col
331
+
332
+ col.each_with_index do |color, col_y|
333
+ next unless color
334
+ screen_y = y + col_y
335
+ next if screen_y < 0 || screen_y >= 240
336
+ framebuffer[screen_y * 320 + screen_x] = color
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
342
+ end