doom 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3cb562e8b88b905638cfa8792337bc1b7f818753fa90587bda474784513a6bd
4
- data.tar.gz: 1f421713b797c02dfc10e2f9bdd3ad0981a631f88bc94df161aa5a792b539094
3
+ metadata.gz: e59cb32b1487d61b1c310ee5eae7ec30a702c64129c7fe5e18e1c06204d08208
4
+ data.tar.gz: f8f5450c3c37d661618234cf0abe80a5f0cd796aaa13694ee35d786ef25c5728
5
5
  SHA512:
6
- metadata.gz: b06fd18ebfa1c5755e27c11b49e9e568fc4e2acfc7bc2b9760e7b7dda500a438afe9a6ecf0e33aa4f17c2271a6c28205e6bbb793e3b9c618c4df24b8bf74679a
7
- data.tar.gz: ed5387b2c42963956cfe42c1cfe790a8f7f0e89a15c5a38934780b5ceaca34e3e64456756280d9c8f2d280763ef0fff89434dd8e826dc2e9e0184e25c4f94508
6
+ metadata.gz: 4a65c31832aff4838f924eb1263bda1f16ef6e820da9d776fa759656fdb9a58d83b2146ea30d5e0c236d1fc9e39206ad14312191f3bf5db70597cec815aef113
7
+ data.tar.gz: f6215f28a05e9924b9864f2f51d36cd694c4abfe7c0ad1618116486f62c72d26b0d7f609e945725b2218ee4b3e77e13eb3316153396977bf773673185d93ba40
data/bin/doom CHANGED
@@ -8,8 +8,11 @@ unless ARGV.delete('--no-yjit')
8
8
  end
9
9
  end
10
10
 
11
+ # Parse flags
12
+ rubykaigi_mode = ARGV.delete('--rubykaigi') || ARGV.delete('--kaigi')
13
+
11
14
  # Parse arguments before loading heavy dependencies
12
- wad_path = ARGV[0]
15
+ wad_path = ARGV.find { |a| !a.start_with?('-') }
13
16
 
14
17
  # Show help (before loading anything)
15
18
  if wad_path == '-h' || wad_path == '--help'
@@ -51,7 +54,7 @@ require_relative '../lib/doom'
51
54
  begin
52
55
  # Find or download WAD
53
56
  wad_path = Doom::WadDownloader.ensure_wad_available(wad_path)
54
- Doom.run(wad_path)
57
+ Doom.run(wad_path, rubykaigi: rubykaigi_mode)
55
58
  rescue Doom::WadDownloader::DownloadError => e
56
59
  puts "Error: #{e.message}"
57
60
  exit 1
@@ -22,14 +22,22 @@ module Doom
22
22
  64 => 700, # Archvile
23
23
  71 => 400, # Pain Elemental
24
24
  84 => 20, # Wolfenstein SS
25
+ 2035 => 20, # Explosive barrel
25
26
  }.freeze
26
27
 
27
28
  MONSTER_RADIUS = {
28
29
  3004 => 20, 9 => 20, 3001 => 20, 3002 => 30, 58 => 30,
29
30
  3003 => 24, 69 => 24, 3005 => 31, 3006 => 16, 16 => 40,
30
31
  7 => 128, 65 => 20, 64 => 20, 71 => 31, 84 => 20,
32
+ 2035 => 10, # Barrel
31
33
  }.freeze
32
34
 
35
+ # Barrel (explosive, not a monster but damageable)
36
+ BARREL_TYPE = 2035
37
+ BARREL_HP = 20
38
+ BARREL_SPLASH_RADIUS = 128.0
39
+ BARREL_SPLASH_DAMAGE = 128
40
+
33
41
  # Normal death frame sequences per sprite prefix (rotation 0 only)
34
42
  # Identified by sprite heights: frames go from standing height to flat on ground
35
43
  DEATH_FRAMES = {
@@ -46,10 +54,19 @@ module Doom
46
54
  'CPOS' => %w[H I J K L M N], # Heavy Weapon Dude
47
55
  'PAIN' => %w[H I J K L M], # Pain Elemental
48
56
  'SSWV' => %w[I J K L M], # Wolfenstein SS
57
+ 'BEXP' => %w[A B C D E], # Barrel explosion
49
58
  }.freeze
50
59
 
51
60
  DEATH_ANIM_TICS = 6 # Tics per death frame
52
61
 
62
+ # Pain chance per monster (out of 256, from mobjinfo)
63
+ PAIN_CHANCE = {
64
+ 3004 => 200, 9 => 170, 3001 => 200, 3002 => 180, 58 => 180,
65
+ 3003 => 50, 69 => 50, 3005 => 128, 3006 => 256, 16 => 40,
66
+ 7 => 40, 65 => 170, 64 => 10, 71 => 128, 84 => 170,
67
+ }.freeze
68
+ PAIN_DURATION = 6 # Tics monster is stunned when in pain
69
+
53
70
  # Projectile constants
54
71
  ROCKET_SPEED = 20.0 # Map units per tic (matches DOOM's mobjinfo MISSILESPEED)
55
72
  ROCKET_DAMAGE = 20 # Direct hit base (DOOM: 1d8 * 20)
@@ -57,43 +74,121 @@ module Doom
57
74
  SPLASH_RADIUS = 128.0 # Splash damage radius
58
75
  SPLASH_DAMAGE = 128 # Max splash damage at center
59
76
 
60
- Projectile = Struct.new(:x, :y, :z, :dx, :dy, :type, :spawn_tic)
77
+ # Monster projectile definitions
78
+ MONSTER_PROJECTILES = {
79
+ imp: { sprite: 'BAL1', speed: 10.0, damage: [3, 24], radius: 6, splash: false },
80
+ baron: { sprite: 'BAL7', speed: 15.0, damage: [8, 64], radius: 6, splash: false },
81
+ caco: { sprite: 'BAL2', speed: 10.0, damage: [5, 40], radius: 6, splash: false },
82
+ }.freeze
83
+
84
+ # Map monster type to projectile type
85
+ MONSTER_PROJECTILE_TYPE = {
86
+ 3001 => :imp, # Imp
87
+ 3003 => :baron, # Baron
88
+ 69 => :baron, # Hell Knight
89
+ 3005 => :caco, # Cacodemon
90
+ }.freeze
91
+
92
+ Projectile = Struct.new(:x, :y, :z, :dx, :dy, :dz, :type, :spawn_tic, :sprite_prefix, :target)
61
93
 
62
94
  # Weapon damage: DOOM does (P_Random()%3 + 1) * multiplier
63
95
  # Pistol/chaingun: 1*5..3*5 = 5-15 per bullet
64
96
  # Shotgun: 7 pellets, each 1*5..3*5 = 5-15
65
97
  # Fist/chainsaw: 1*2..3*2 = 2-10
66
98
 
67
- def initialize(map, player_state, sprites)
99
+ def initialize(map, player_state, sprites, hidden_things = {}, sound_engine = nil)
68
100
  @map = map
69
101
  @player = player_state
70
102
  @sprites = sprites
103
+ @hidden_things = hidden_things
104
+ @sound = sound_engine
71
105
  @monster_hp = {} # thing_idx => current HP
72
106
  @dead_things = {} # thing_idx => { tic: death_start_tic, prefix: sprite_prefix }
107
+ @pain_until = {} # thing_idx => tic when pain ends
73
108
  @projectiles = [] # Active projectiles in flight
74
109
  @explosions = [] # Active explosions (for rendering)
110
+ @puffs = [] # Bullet puff effects
111
+ @player_x = 0.0
112
+ @player_y = 0.0
113
+ @player_z = 0.0
75
114
  @tic = 0
76
115
  end
77
116
 
78
- attr_reader :dead_things, :projectiles, :explosions
117
+ def update_player_pos(x, y, z = nil)
118
+ @player_x = x
119
+ @player_y = y
120
+ @player_z = z if z
121
+ end
122
+
123
+ # Spawn a monster projectile (fireball, etc.)
124
+ # Matches Chocolate Doom's P_SpawnMissile: calculates momz for vertical aim
125
+ def spawn_monster_projectile(monster_x, monster_y, monster_z, monster_type, damage_multiplier)
126
+ proj_type = MONSTER_PROJECTILE_TYPE[monster_type]
127
+ return unless proj_type
128
+
129
+ info = MONSTER_PROJECTILES[proj_type]
130
+ return unless info
131
+
132
+ dx = @player_x - monster_x
133
+ dy = @player_y - monster_y
134
+ dist = Math.sqrt(dx * dx + dy * dy)
135
+ return if dist < 1
136
+
137
+ # Normalize direction and apply speed
138
+ speed = info[:speed]
139
+ ndx = dx / dist * speed
140
+ ndy = dy / dist * speed
141
+
142
+ # P_SpawnMissile: momz = (target.z - source.z) / (dist / speed)
143
+ # This makes the projectile arc toward the target's height
144
+ target_z = @player_z - 16 # Aim at player center (z + height/2, roughly)
145
+ travel_tics = dist / speed
146
+ travel_tics = 1.0 if travel_tics < 1.0
147
+ ndz = (target_z - monster_z) / travel_tics
148
+
149
+ @projectiles << Projectile.new(
150
+ monster_x + ndx * 2, monster_y + ndy * 2, monster_z,
151
+ ndx, ndy, ndz, proj_type, @tic, info[:sprite], :player
152
+ )
153
+ end
154
+
155
+ attr_reader :dead_things, :projectiles, :explosions, :puffs
156
+
157
+ def in_pain?(thing_idx)
158
+ @pain_until[thing_idx] && @tic < @pain_until[thing_idx]
159
+ end
79
160
 
80
161
  def dead?(thing_idx)
81
162
  @dead_things.key?(thing_idx)
82
163
  end
83
164
 
84
- # Get the current death frame sprite for a dead monster
165
+ # Get the current death frame sprite for a dead monster/barrel
85
166
  def death_sprite(thing_idx, thing_type, viewer_angle, thing_angle)
86
167
  info = @dead_things[thing_idx]
87
168
  return nil unless info
88
169
 
89
- frames = DEATH_FRAMES[info[:prefix]]
170
+ prefix = info[:prefix]
171
+ frames = DEATH_FRAMES[prefix]
90
172
  return nil unless frames
91
173
 
92
174
  elapsed = @tic - info[:tic]
93
- frame_idx = (elapsed / DEATH_ANIM_TICS).clamp(0, frames.size - 1)
175
+ frame_idx = elapsed / DEATH_ANIM_TICS
176
+
177
+ # Barrels disappear after explosion animation (S_NULL in Chocolate Doom)
178
+ if thing_type == BARREL_TYPE && frame_idx >= frames.size
179
+ return nil
180
+ end
181
+
182
+ frame_idx = frame_idx.clamp(0, frames.size - 1)
94
183
  frame_letter = frames[frame_idx]
95
184
 
96
- @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
185
+ # Use prefix directly if it differs from the thing's sprite (e.g. BEXP for barrels)
186
+ thing_prefix = @sprites.prefix_for(thing_type)
187
+ if prefix != thing_prefix
188
+ @sprites.get_frame_by_prefix(prefix, frame_letter)
189
+ else
190
+ @sprites.get_frame(thing_type, frame_letter, viewer_angle, thing_angle)
191
+ end
97
192
  end
98
193
 
99
194
  # Called each game tic
@@ -101,6 +196,7 @@ module Doom
101
196
  @tic += 1
102
197
  update_projectiles
103
198
  update_explosions
199
+ @puffs.reject! { |p| @tic - p[:tic] > 12 }
104
200
  end
105
201
 
106
202
  # Fire the current weapon
@@ -122,44 +218,74 @@ module Doom
122
218
  private
123
219
 
124
220
  def spawn_rocket(px, py, pz, cos_a, sin_a)
125
- # Spawn slightly ahead of the player
126
221
  @projectiles << Projectile.new(
127
222
  px + cos_a * 20, py + sin_a * 20, pz,
128
- cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED,
129
- :rocket, @tic
223
+ cos_a * ROCKET_SPEED, sin_a * ROCKET_SPEED, 0.0,
224
+ :rocket, @tic, 'MISL', :monsters
130
225
  )
131
226
  end
132
227
 
133
228
  def update_projectiles
134
229
  @projectiles.reject! do |proj|
135
- # Move projectile
136
230
  new_x = proj.x + proj.dx
137
231
  new_y = proj.y + proj.dy
232
+ new_z = proj.z + (proj.dz || 0)
138
233
 
139
- # Check wall collision
140
234
  hit_wall = hits_wall?(proj.x, proj.y, new_x, new_y)
141
235
 
142
- # Check monster collision
143
- hit_monster = nil
144
- @map.things.each_with_index do |thing, idx|
145
- next unless MONSTER_HP[thing.type]
146
- next if @dead_things[idx]
147
- radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
148
- dx = new_x - thing.x
149
- dy = new_y - thing.y
150
- if dx * dx + dy * dy < radius * radius
151
- hit_monster = idx
152
- break
236
+ # Check if projectile hit the floor or ceiling
237
+ sector = @map.sector_at(new_x, new_y)
238
+ if sector
239
+ hit_wall = true if new_z <= sector.floor_height || new_z >= sector.ceiling_height
240
+ end
241
+ hit = false
242
+
243
+ if proj.target == :monsters
244
+ # Player projectile: check monster collision
245
+ hit_monster = nil
246
+ @map.things.each_with_index do |thing, idx|
247
+ next unless MONSTER_HP[thing.type]
248
+ next if @dead_things[idx]
249
+ radius = (MONSTER_RADIUS[thing.type] || 20) + ROCKET_RADIUS
250
+ dx = new_x - thing.x
251
+ dy = new_y - thing.y
252
+ if dx * dx + dy * dy < radius * radius
253
+ hit_monster = idx
254
+ break
255
+ end
256
+ end
257
+
258
+ if hit_wall || hit_monster
259
+ explode(new_x, new_y, hit_monster) if proj.type == :rocket
260
+ hit_monster ? apply_damage(hit_monster, (rand(8) + 1) * 5) : nil unless proj.type == :rocket
261
+ hit = true
262
+ end
263
+ elsif proj.target == :player
264
+ # Monster projectile: check player collision
265
+ player_radius = 16
266
+ dx = new_x - @player_x
267
+ dy = new_y - @player_y
268
+ if hit_wall || (dx * dx + dy * dy < (player_radius + 6) ** 2)
269
+ unless hit_wall
270
+ info = MONSTER_PROJECTILES[proj.type]
271
+ if info
272
+ min_d, max_d = info[:damage]
273
+ @player.take_damage(rand(min_d..max_d))
274
+ end
275
+ end
276
+ # Spawn fireball explosion
277
+ @explosions << { x: new_x, y: new_y, z: proj.z, tic: @tic, sprite: proj.sprite_prefix }
278
+ hit = true
153
279
  end
154
280
  end
155
281
 
156
- if hit_wall || hit_monster
157
- explode(new_x, new_y, hit_monster)
158
- true # Remove projectile
282
+ if hit
283
+ true
159
284
  else
160
285
  proj.x = new_x
161
286
  proj.y = new_y
162
- false # Keep projectile
287
+ proj.z = new_z
288
+ false
163
289
  end
164
290
  end
165
291
  end
@@ -188,7 +314,7 @@ module Doom
188
314
  end
189
315
 
190
316
  # Spawn explosion visual
191
- @explosions << { x: x, y: y, tic: @tic }
317
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
192
318
  end
193
319
 
194
320
  def update_explosions
@@ -198,17 +324,21 @@ module Doom
198
324
 
199
325
  def hits_wall?(x1, y1, x2, y2)
200
326
  @map.linedefs.each do |ld|
201
- # One-sided walls always block
202
- blocks = ld.sidedef_left == 0xFFFF || (ld.flags & 0x0001 != 0)
203
- unless blocks
204
- next unless ld.sidedef_left < 0xFFFF
327
+ # One-sided walls always block projectiles
328
+ if ld.sidedef_left == 0xFFFF
329
+ blocks = true
330
+ elsif ld.sidedef_left < 0xFFFF
331
+ # Two-sided: only block if opening is too small for a projectile
332
+ # BLOCKING flag (0x0001) stops players/monsters but NOT projectiles
205
333
  front = @map.sidedefs[ld.sidedef_right]
206
334
  back = @map.sidedefs[ld.sidedef_left]
207
335
  fs = @map.sectors[front.sector]
208
336
  bs = @map.sectors[back.sector]
209
337
  max_floor = [fs.floor_height, bs.floor_height].max
210
338
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
211
- blocks = (min_ceil - max_floor) < 56
339
+ blocks = (min_ceil - max_floor) < 1 # Closed door/wall
340
+ else
341
+ next
212
342
  end
213
343
  next unless blocks
214
344
 
@@ -252,6 +382,7 @@ module Doom
252
382
  best_dist = wall_dist
253
383
 
254
384
  @map.things.each_with_index do |thing, idx|
385
+ next if @hidden_things[idx]
255
386
  next unless MONSTER_HP[thing.type]
256
387
  next if @dead_things[idx]
257
388
 
@@ -263,6 +394,12 @@ module Doom
263
394
  end
264
395
  end
265
396
 
397
+ # Spawn bullet puff at hit location
398
+ puff_x = px + ca * best_dist
399
+ puff_y = py + sa * best_dist
400
+ puff_z = @player_z
401
+ @puffs << { x: puff_x, y: puff_y, z: puff_z, tic: @tic }
402
+
266
403
  if best_idx
267
404
  damage = (rand(3) + 1) * multiplier
268
405
  apply_damage(best_idx, damage)
@@ -306,8 +443,54 @@ module Doom
306
443
  @monster_hp[thing_idx] -= damage
307
444
 
308
445
  if @monster_hp[thing_idx] <= 0
309
- prefix = @sprites.prefix_for(thing.type)
310
- @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
446
+ return if @dead_things[thing_idx] # Already dead
447
+
448
+ if thing.type == BARREL_TYPE
449
+ @dead_things[thing_idx] = { tic: @tic, prefix: 'BEXP' }
450
+ @sound&.explosion
451
+ barrel_explode(thing.x, thing.y, thing_idx)
452
+ else
453
+ prefix = @sprites.prefix_for(thing.type)
454
+ @dead_things[thing_idx] = { tic: @tic, prefix: prefix } if prefix
455
+ @sound&.monster_death(thing.type)
456
+ end
457
+ else
458
+ # Pain state: monster flinches (not barrels)
459
+ if thing.type != BARREL_TYPE
460
+ pain_chance = PAIN_CHANCE[thing.type] || 128
461
+ if rand(256) < pain_chance
462
+ @pain_until[thing_idx] = @tic + PAIN_DURATION
463
+ @sound&.monster_pain(thing.type)
464
+ end
465
+ end
466
+ end
467
+ end
468
+
469
+ def barrel_explode(x, y, barrel_idx)
470
+ @explosions << { x: x, y: y, tic: @tic, sprite: 'MISL' }
471
+
472
+ # Splash damage to monsters and other barrels (chain reactions!)
473
+ @map.things.each_with_index do |thing, idx|
474
+ next unless MONSTER_HP[thing.type]
475
+ next if @dead_things[idx]
476
+ next if idx == barrel_idx
477
+
478
+ dx = x - thing.x
479
+ dy = y - thing.y
480
+ dist = Math.sqrt(dx * dx + dy * dy)
481
+ next if dist >= BARREL_SPLASH_RADIUS
482
+
483
+ damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
484
+ apply_damage(idx, damage) if damage > 0
485
+ end
486
+
487
+ # Splash damage to player
488
+ dx = x - @player_x
489
+ dy = y - @player_y
490
+ dist = Math.sqrt(dx * dx + dy * dy)
491
+ if dist < BARREL_SPLASH_RADIUS
492
+ damage = ((BARREL_SPLASH_DAMAGE * (1.0 - dist / BARREL_SPLASH_RADIUS))).to_i
493
+ @player.take_damage(damage) if damage > 0
311
494
  end
312
495
  end
313
496
 
@@ -318,18 +501,21 @@ module Doom
318
501
  v1 = @map.vertices[ld.v1]
319
502
  v2 = @map.vertices[ld.v2]
320
503
 
321
- # One-sided always blocks; two-sided only if impassable
322
- blocks = (ld.sidedef_left == 0xFFFF) || (ld.flags & 0x0001 != 0)
323
- unless blocks
324
- next unless ld.sidedef_left < 0xFFFF
504
+ # One-sided always blocks hitscan
505
+ if ld.sidedef_left == 0xFFFF
506
+ blocks = true
507
+ elsif ld.sidedef_left < 0xFFFF
508
+ # Two-sided: only blocks if opening is too small
509
+ # BLOCKING flag does NOT stop hitscan (only affects movement)
325
510
  front = @map.sidedefs[ld.sidedef_right]
326
511
  back = @map.sidedefs[ld.sidedef_left]
327
512
  fs = @map.sectors[front.sector]
328
513
  bs = @map.sectors[back.sector]
329
- # Blocks if opening is too small (step or low ceiling)
330
514
  max_floor = [fs.floor_height, bs.floor_height].max
331
515
  min_ceil = [fs.ceiling_height, bs.ceiling_height].min
332
516
  blocks = (min_ceil - max_floor) < 56
517
+ else
518
+ next
333
519
  end
334
520
  next unless blocks
335
521
 
@@ -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