doom 0.4.0 → 0.6.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.
@@ -7,17 +7,52 @@ module Doom
7
7
  class GosuWindow < Gosu::Window
8
8
  SCALE = 3
9
9
 
10
- # Movement constants
11
- MOVE_SPEED = 8.0 # Units per frame
10
+ # Movement constants (matching Chocolate Doom P_Thrust / P_XYMovement)
11
+ # DOOM: terminal walk speed = 7.55 units/tic = 264 units/sec
12
+ # Continuous-time: v_terminal = thrust_rate / decay_rate
13
+ # decay_rate = -ln(0.90625) * 35 = 3.44/sec
14
+ # thrust_rate = 264 * 3.44 = 908 units/sec^2
15
+ MOVE_THRUST_RATE = 264.0 * 3.44 # Thrust rate (units/sec^2)
16
+ FRICTION_DECAY_RATE = 3.44 # Friction decay (1/sec)
17
+ STOPSPEED = 0.5 # Snap-to-zero threshold (units/sec)
12
18
  TURN_SPEED = 3.0 # Degrees per frame
13
19
  MOUSE_SENSITIVITY = 0.15 # Mouse look sensitivity
14
20
  PLAYER_RADIUS = 16.0 # Collision radius
15
21
 
16
22
  USE_DISTANCE = 64.0 # Max distance to use a linedef
17
23
 
18
- def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil)
19
- super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, false)
24
+ # Solid thing types with their collision radii (from mobjinfo[] MF_SOLID)
25
+ # Monsters, barrels, pillars, lamps, torches, trees block player movement
26
+ SOLID_THING_RADIUS = {
27
+ 9 => 20, 65 => 20, 66 => 20, 67 => 20, 68 => 20, # Shotgun Guy variants
28
+ 3004 => 20, 84 => 20, # Zombieman
29
+ 3001 => 20, # Imp
30
+ 3002 => 30, 58 => 30, # Demon, Spectre
31
+ 3003 => 24, 69 => 24, # Baron, Hell Knight
32
+ 3006 => 16, # Lost Soul
33
+ 3005 => 31, # Cacodemon
34
+ 16 => 40, # Cyberdemon
35
+ 7 => 128, # Spider Mastermind
36
+ 64 => 20, # Archvile
37
+ 71 => 31, # Pain Elemental
38
+ 2035 => 10, # Barrel
39
+ 2028 => 16, # Tall lamp
40
+ 48 => 16, 30 => 16, 32 => 16, # Tech column, green/red pillars
41
+ 31 => 16, 33 => 16, 36 => 16, # Short pillars
42
+ 41 => 16, 43 => 16, # Evil eye, burnt tree
43
+ 54 => 32, # Brown tree
44
+ 44 => 16, 45 => 16, 46 => 16, # Tall torches
45
+ 55 => 16, 56 => 16, 57 => 16, # Short torches
46
+ 47 => 16, 70 => 16, # Stubs
47
+ 85 => 16, 86 => 16, # Tall tech lamps
48
+ 2046 => 16, # Burning barrel
49
+ }.freeze
50
+
51
+ def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil, animations = nil, sector_effects = nil, item_pickup = nil, combat = nil, monster_ai = nil)
52
+ fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
53
+ super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
20
54
  self.caption = 'Doom Ruby'
55
+ self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
21
56
 
22
57
  @renderer = renderer
23
58
  @palette = palette
@@ -26,14 +61,39 @@ module Doom
26
61
  @status_bar = status_bar
27
62
  @weapon_renderer = weapon_renderer
28
63
  @sector_actions = sector_actions
64
+ @animations = animations
65
+ @sector_effects = sector_effects
66
+ @item_pickup = item_pickup
67
+ @combat = combat
68
+ @monster_ai = monster_ai
69
+ @last_floor_height = nil
70
+ @move_momx = 0.0
71
+ @move_momy = 0.0
72
+ @leveltime = 0
73
+ @tic_accumulator = 0.0
29
74
  @screen_image = nil
30
75
  @mouse_captured = false
31
76
  @last_mouse_x = nil
32
77
  @last_update_time = Time.now
33
78
  @use_pressed = false
34
-
35
- # Pre-build palette lookup for speed
36
- @palette_rgba = palette.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
79
+ @show_debug = false
80
+ @show_map = false
81
+ @debug_font = Gosu::Font.new(16)
82
+ @fps_frames = 0
83
+ @fps_time = Time.now
84
+ @fps_display = 0.0
85
+
86
+ # Precompute sector colors for automap
87
+ @sector_colors = build_sector_colors
88
+
89
+ # Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
90
+ @all_palette_rgba = []
91
+ wad = renderer.instance_variable_get(:@wad)
92
+ 14.times do |pal_idx|
93
+ pal = Wad::Palette.load(wad, pal_idx)
94
+ @all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
95
+ end
96
+ @palette_rgba = @all_palette_rgba[0]
37
97
  end
38
98
 
39
99
  def update
@@ -42,14 +102,39 @@ module Doom
42
102
  delta_time = now - @last_update_time
43
103
  @last_update_time = now
44
104
 
45
- handle_input
105
+ handle_input(delta_time)
46
106
 
47
- # Update player state
107
+ # Update player state (per-frame for smooth bob)
48
108
  if @player_state
49
- @player_state.update_attack
50
109
  @player_state.update_bob(delta_time)
110
+ @player_state.update_view_bob(delta_time)
51
111
  end
52
112
 
113
+ # Advance game tics at 35/sec (DOOM's tic rate)
114
+ @tic_accumulator += delta_time * 35.0
115
+ while @tic_accumulator >= 1.0
116
+ @leveltime += 1
117
+ @tic_accumulator -= 1.0
118
+ @sector_effects&.update
119
+ @player_state&.update_viewheight
120
+ @player_state&.update_attack # Attack timing at 35fps like DOOM
121
+ @combat&.update
122
+ @monster_ai&.update(@renderer.player_x, @renderer.player_y)
123
+
124
+ @player_state&.update_damage_count
125
+
126
+ # Sector damage (nukage, lava, etc.) every 32 tics
127
+ if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
128
+ check_sector_damage
129
+ end
130
+
131
+ # Track death tic for death animation
132
+ if @player_state&.dead
133
+ @player_state.death_tic += 1
134
+ end
135
+ end
136
+ @animations&.update(@leveltime)
137
+
53
138
  # Update HUD animations
54
139
  @status_bar&.update
55
140
 
@@ -59,19 +144,46 @@ module Doom
59
144
  @sector_actions.update
60
145
  end
61
146
 
147
+ # Check item pickups
148
+ if @item_pickup
149
+ @item_pickup.update(@renderer.player_x, @renderer.player_y)
150
+ @renderer.hidden_things = @item_pickup.picked_up
151
+ end
152
+
153
+ # Pass combat state to renderer for death frame rendering
154
+ @renderer.combat = @combat
155
+ @renderer.monster_ai = @monster_ai
156
+ @renderer.leveltime = @leveltime
157
+
62
158
  # Render the 3D world
63
159
  @renderer.render_frame
64
160
 
65
161
  # Render HUD on top
66
- if @weapon_renderer
162
+ if @weapon_renderer && !@player_state&.dead
67
163
  @weapon_renderer.render(@renderer.framebuffer)
68
164
  end
69
165
  if @status_bar
70
166
  @status_bar.render(@renderer.framebuffer)
71
167
  end
168
+
169
+ # Red tint when dead
170
+ if @player_state&.dead
171
+ apply_death_tint(@renderer.framebuffer)
172
+ end
72
173
  end
73
174
 
74
- def handle_input
175
+ def handle_input(delta_time)
176
+ # Handle respawn when dead
177
+ if @player_state&.dead
178
+ if @player_state.death_tic > 35 # 1 second delay before respawn allowed
179
+ if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
180
+ Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT)
181
+ respawn_player
182
+ end
183
+ end
184
+ return # No other input while dead
185
+ end
186
+
75
187
  # Mouse look
76
188
  handle_mouse_look
77
189
 
@@ -83,41 +195,63 @@ module Doom
83
195
  @renderer.turn(-TURN_SPEED)
84
196
  end
85
197
 
86
- # Forward/backward movement
87
- move_x = 0.0
88
- move_y = 0.0
198
+ # Apply thrust from input (P_Thrust: additive, scaled by delta_time)
199
+ thrust = MOVE_THRUST_RATE * delta_time
200
+ has_input = false
89
201
 
90
202
  if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
91
- move_x += @renderer.cos_angle * MOVE_SPEED
92
- move_y += @renderer.sin_angle * MOVE_SPEED
203
+ @move_momx += @renderer.cos_angle * thrust
204
+ @move_momy += @renderer.sin_angle * thrust
205
+ has_input = true
93
206
  end
94
207
  if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
95
- move_x -= @renderer.cos_angle * MOVE_SPEED
96
- move_y -= @renderer.sin_angle * MOVE_SPEED
208
+ @move_momx -= @renderer.cos_angle * thrust
209
+ @move_momy -= @renderer.sin_angle * thrust
210
+ has_input = true
97
211
  end
98
-
99
- # Strafe
100
212
  if Gosu.button_down?(Gosu::KB_A)
101
- move_x += @renderer.sin_angle * MOVE_SPEED
102
- move_y -= @renderer.cos_angle * MOVE_SPEED
213
+ @move_momx -= @renderer.sin_angle * thrust
214
+ @move_momy += @renderer.cos_angle * thrust
215
+ has_input = true
103
216
  end
104
217
  if Gosu.button_down?(Gosu::KB_D)
105
- move_x -= @renderer.sin_angle * MOVE_SPEED
106
- move_y += @renderer.cos_angle * MOVE_SPEED
218
+ @move_momx += @renderer.sin_angle * thrust
219
+ @move_momy -= @renderer.cos_angle * thrust
220
+ has_input = true
221
+ end
222
+
223
+ # Apply friction (continuous-time equivalent of *= 0.90625 per tic)
224
+ decay = Math.exp(-FRICTION_DECAY_RATE * delta_time)
225
+ if !has_input && @move_momx.abs < STOPSPEED && @move_momy.abs < STOPSPEED
226
+ @move_momx = 0.0
227
+ @move_momy = 0.0
228
+ else
229
+ @move_momx *= decay
230
+ @move_momy *= decay
107
231
  end
108
232
 
109
- # Track if player is moving (for weapon bob)
110
- is_moving = move_x != 0.0 || move_y != 0.0
111
- @player_state.is_moving = is_moving if @player_state
233
+ # Track movement state for weapon/view bob
234
+ if @player_state
235
+ @player_state.is_moving = has_input
236
+ @player_state.set_movement_momentum(@move_momx, @move_momy)
237
+ end
112
238
 
113
- # Apply movement with collision detection
114
- if is_moving
115
- try_move(move_x, move_y)
239
+ # Apply momentum with collision detection (scale by delta_time for frame-rate independence)
240
+ if @move_momx.abs > STOPSPEED || @move_momy.abs > STOPSPEED
241
+ try_move(@move_momx * delta_time, @move_momy * delta_time)
116
242
  end
117
243
 
118
- # Handle firing
119
- if @player_state && @mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)
244
+ # Handle firing (left click, Z, or Shift - Ctrl conflicts with macOS spaces)
245
+ if @player_state && ((@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
246
+ Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
247
+ Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
248
+ was_attacking = @player_state.attacking
120
249
  @player_state.start_attack
250
+ # Fire hitscan on the first frame of the attack
251
+ if @player_state.attacking && !was_attacking && @combat
252
+ @combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
253
+ @renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
254
+ end
121
255
  end
122
256
 
123
257
  # Handle weapon switching with number keys
@@ -252,53 +386,200 @@ module Doom
252
386
  end
253
387
 
254
388
  def try_move(dx, dy)
255
- new_x = @renderer.player_x + dx
256
- new_y = @renderer.player_y + dy
389
+ old_x = @renderer.player_x
390
+ old_y = @renderer.player_y
391
+ new_x = old_x + dx
392
+ new_y = old_y + dy
257
393
 
258
- # Check if new position is valid (simple collision detection)
259
- if valid_position?(new_x, new_y)
394
+ # Check if new position is valid and path doesn't cross blocking linedefs
395
+ if valid_move?(old_x, old_y, new_x, new_y)
260
396
  @renderer.move_to(new_x, new_y)
261
397
  update_player_height(new_x, new_y)
262
398
  else
263
- # Try sliding along walls - try X movement only
264
- if valid_position?(new_x, @renderer.player_y)
265
- @renderer.move_to(new_x, @renderer.player_y)
266
- update_player_height(new_x, @renderer.player_y)
267
- # Try Y movement only
268
- elsif valid_position?(@renderer.player_x, new_y)
269
- @renderer.move_to(@renderer.player_x, new_y)
270
- update_player_height(@renderer.player_x, new_y)
399
+ # Wall sliding: project movement along the blocking wall
400
+ slide_x, slide_y = compute_slide(old_x, old_y, dx, dy)
401
+ if slide_x && (slide_x != 0.0 || slide_y != 0.0)
402
+ sx = old_x + slide_x
403
+ sy = old_y + slide_y
404
+ if valid_move?(old_x, old_y, sx, sy)
405
+ @renderer.move_to(sx, sy)
406
+ update_player_height(sx, sy)
407
+ # Redirect momentum along the wall
408
+ @move_momx = slide_x / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momx.abs
409
+ @move_momy = slide_y / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momy.abs
410
+ return
411
+ end
412
+ end
413
+
414
+ # Fallback: try axis-aligned sliding
415
+ if dx != 0.0 && valid_move?(old_x, old_y, new_x, old_y)
416
+ @renderer.move_to(new_x, old_y)
417
+ update_player_height(new_x, old_y)
418
+ @move_momy *= 0.0
419
+ elsif dy != 0.0 && valid_move?(old_x, old_y, old_x, new_y)
420
+ @renderer.move_to(old_x, new_y)
421
+ update_player_height(old_x, new_y)
422
+ @move_momx *= 0.0
423
+ else
424
+ # Fully blocked - kill momentum
425
+ @move_momx = 0.0
426
+ @move_momy = 0.0
427
+ end
428
+ end
429
+ end
430
+
431
+ # Find the blocking linedef and project movement along it
432
+ def compute_slide(px, py, dx, dy)
433
+ best_wall = nil
434
+ best_dist = Float::INFINITY
435
+
436
+ @map.linedefs.each do |linedef|
437
+ v1 = @map.vertices[linedef.v1]
438
+ v2 = @map.vertices[linedef.v2]
439
+
440
+ # Only check linedefs near the player
441
+ next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, px + dx, py + dy, PLAYER_RADIUS)
442
+
443
+ # Check if this linedef actually blocks
444
+ next unless linedef_blocks?(linedef, px + dx, py + dy) ||
445
+ crosses_blocking_linedef?(px, py, px + dx, py + dy, linedef)
446
+
447
+ # Distance from player to this linedef
448
+ dist = point_to_line_distance(px, py, v1.x, v1.y, v2.x, v2.y)
449
+ if dist < best_dist
450
+ best_dist = dist
451
+ best_wall = linedef
271
452
  end
272
453
  end
454
+
455
+ return nil unless best_wall
456
+
457
+ # Get wall direction vector
458
+ v1 = @map.vertices[best_wall.v1]
459
+ v2 = @map.vertices[best_wall.v2]
460
+ wall_dx = (v2.x - v1.x).to_f
461
+ wall_dy = (v2.y - v1.y).to_f
462
+ wall_len = Math.sqrt(wall_dx * wall_dx + wall_dy * wall_dy)
463
+ return nil if wall_len == 0
464
+
465
+ wall_dx /= wall_len
466
+ wall_dy /= wall_len
467
+
468
+ # Project movement onto wall direction
469
+ dot = dx * wall_dx + dy * wall_dy
470
+ [dot * wall_dx, dot * wall_dy]
273
471
  end
274
472
 
275
473
  def update_player_height(x, y)
276
474
  sector = @map.sector_at(x, y)
277
475
  return unless sector
278
476
 
279
- # Player view height is 41 units above floor
280
- target_z = sector.floor_height + 41
281
- @renderer.set_z(target_z)
477
+ new_floor = sector.floor_height
478
+
479
+ if @player_state
480
+ # Detect step: floor height changed since last move
481
+ if @last_floor_height && @last_floor_height != new_floor
482
+ step = new_floor - @last_floor_height
483
+ @player_state.notify_step(step) if step.abs <= 24
484
+ end
485
+ @last_floor_height = new_floor
486
+
487
+ view_bob = @player_state.view_bob_offset
488
+ @renderer.set_z(new_floor + @player_state.viewheight + view_bob)
489
+ else
490
+ @renderer.set_z(new_floor + 41)
491
+ end
282
492
  end
283
493
 
284
- def valid_position?(x, y)
285
- # Check if position is inside a valid sector
286
- sector = @map.sector_at(x, y)
494
+ def valid_move?(old_x, old_y, new_x, new_y)
495
+ # Check if destination is inside a valid sector
496
+ sector = @map.sector_at(new_x, new_y)
287
497
  return false unless sector
288
498
 
289
- # Check floor height - can't walk into walls
499
+ # Check floor height - can't step up too high
290
500
  floor_height = sector.floor_height
291
501
  return false if floor_height > @renderer.player_z + 24 # Max step height
292
502
 
293
- # Check against blocking linedefs
503
+ # Check against blocking linedefs: both circle intersection and path crossing
294
504
  @map.linedefs.each do |linedef|
295
- next unless linedef_blocks?(linedef, x, y)
296
- return false
505
+ if linedef_blocks?(linedef, new_x, new_y)
506
+ return false
507
+ end
508
+ if crosses_blocking_linedef?(old_x, old_y, new_x, new_y, linedef)
509
+ return false
510
+ end
511
+ end
512
+
513
+ # Check against solid things (monsters, barrels, pillars, etc.)
514
+ combined_radius = PLAYER_RADIUS
515
+ picked = @item_pickup&.picked_up
516
+ @map.things.each_with_index do |thing, idx|
517
+ next if picked && picked[idx]
518
+ next if @combat && @combat.dead?(idx)
519
+ thing_radius = SOLID_THING_RADIUS[thing.type]
520
+ next unless thing_radius
521
+
522
+ dx = new_x - thing.x
523
+ dy = new_y - thing.y
524
+ min_dist = combined_radius + thing_radius
525
+ if dx * dx + dy * dy < min_dist * min_dist
526
+ return false
527
+ end
297
528
  end
298
529
 
299
530
  true
300
531
  end
301
532
 
533
+ # Check if movement from (x1,y1) to (x2,y2) crosses a blocking linedef
534
+ def crosses_blocking_linedef?(x1, y1, x2, y2, linedef)
535
+ v1 = @map.vertices[linedef.v1]
536
+ v2 = @map.vertices[linedef.v2]
537
+
538
+ # One-sided linedef always blocks crossing
539
+ if linedef.sidedef_left == 0xFFFF
540
+ return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
541
+ end
542
+
543
+ # BLOCKING flag blocks crossing even on two-sided linedefs
544
+ if (linedef.flags & 0x0001) != 0
545
+ return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
546
+ end
547
+
548
+ # Two-sided: check if impassable (high step OR low ceiling)
549
+ front_side = @map.sidedefs[linedef.sidedef_right]
550
+ back_side = @map.sidedefs[linedef.sidedef_left]
551
+ front_sector = @map.sectors[front_side.sector]
552
+ back_sector = @map.sectors[back_side.sector]
553
+
554
+ step = (back_sector.floor_height - front_sector.floor_height).abs
555
+ min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
556
+ max_floor = [front_sector.floor_height, back_sector.floor_height].max
557
+
558
+ # Passable if step is small AND enough headroom
559
+ return false if step <= 24 && (min_ceiling - max_floor) >= 56
560
+
561
+ segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
562
+ end
563
+
564
+ # Test if line segment (ax1,ay1)-(ax2,ay2) intersects (bx1,by1)-(bx2,by2)
565
+ def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
566
+ d1x = ax2 - ax1
567
+ d1y = ay2 - ay1
568
+ d2x = bx2 - bx1
569
+ d2y = by2 - by1
570
+
571
+ denom = d1x * d2y - d1y * d2x
572
+ return false if denom.abs < 0.001 # Parallel
573
+
574
+ dx = bx1 - ax1
575
+ dy = by1 - ay1
576
+
577
+ t = (dx * d2y - dy * d2x).to_f / denom
578
+ u = (dx * d1y - dy * d1x).to_f / denom
579
+
580
+ t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
581
+ end
582
+
302
583
  def linedef_blocks?(linedef, x, y)
303
584
  v1 = @map.vertices[linedef.v1]
304
585
  v2 = @map.vertices[linedef.v2]
@@ -309,23 +590,22 @@ module Doom
309
590
  # One-sided linedef (wall) always blocks
310
591
  return true if linedef.sidedef_left == 0xFFFF
311
592
 
312
- # Two-sided: check if passable
593
+ # BLOCKING flag (0x0001) blocks even on two-sided linedefs (e.g., windows)
594
+ return true if (linedef.flags & 0x0001) != 0
595
+
596
+ # Two-sided: check if impassable (high step OR low ceiling)
313
597
  front_side = @map.sidedefs[linedef.sidedef_right]
314
598
  back_side = @map.sidedefs[linedef.sidedef_left]
315
599
 
316
600
  front_sector = @map.sectors[front_side.sector]
317
601
  back_sector = @map.sectors[back_side.sector]
318
602
 
319
- # Check step height
320
- step = back_sector.floor_height - front_sector.floor_height
321
- return true if step.abs > 24
322
-
323
- # Check ceiling clearance
603
+ step = (back_sector.floor_height - front_sector.floor_height).abs
324
604
  min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
325
605
  max_floor = [front_sector.floor_height, back_sector.floor_height].max
326
- return true if min_ceiling - max_floor < 56 # Player height
327
606
 
328
- false
607
+ # Block if step too high OR not enough headroom
608
+ step > 24 || (min_ceiling - max_floor) < 56
329
609
  end
330
610
 
331
611
  def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
@@ -376,16 +656,60 @@ module Doom
376
656
  end
377
657
 
378
658
  def draw
379
- # Fast RGBA conversion using pre-built palette
380
- rgba = @renderer.framebuffer.map { |idx| @palette_rgba[idx] }.join
659
+ if @show_map
660
+ draw_automap
661
+ else
662
+ # Select palette: red tint when taking damage (palettes 1-8)
663
+ pal_idx = @player_state ? @player_state.damage_count.clamp(0, 8) : 0
664
+ active_pal = @all_palette_rgba[pal_idx]
665
+ rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join
666
+
667
+ @screen_image = Gosu::Image.from_blob(
668
+ Render::SCREEN_WIDTH,
669
+ Render::SCREEN_HEIGHT,
670
+ rgba
671
+ )
381
672
 
382
- @screen_image = Gosu::Image.from_blob(
383
- Render::SCREEN_WIDTH,
384
- Render::SCREEN_HEIGHT,
385
- rgba
386
- )
673
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
387
674
 
388
- @screen_image.draw(0, 0, 0, SCALE, SCALE)
675
+ draw_debug_overlay if @show_debug
676
+ end
677
+ end
678
+
679
+ def draw_debug_overlay
680
+ # Update FPS counter (refresh every 0.5 seconds)
681
+ @fps_frames += 1
682
+ now = Time.now
683
+ elapsed = now - @fps_time
684
+ if elapsed >= 0.5
685
+ @fps_display = (@fps_frames / elapsed).round(1)
686
+ @fps_frames = 0
687
+ @fps_time = now
688
+ end
689
+
690
+ sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
691
+ return unless sector
692
+
693
+ # Find sector index
694
+ sector_idx = @map.sectors.index(sector)
695
+
696
+ lines = [
697
+ "FPS: #{@fps_display}",
698
+ "Sector #{sector_idx}",
699
+ "Floor: #{sector.floor_height} (#{sector.floor_texture})",
700
+ "Ceil: #{sector.ceiling_height} (#{sector.ceiling_texture})",
701
+ "Light: #{sector.light_level}",
702
+ "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
703
+ "Heading: #{(Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)}",
704
+ "YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'} (Y to toggle)",
705
+ ]
706
+
707
+ y = 4
708
+ lines.each do |line|
709
+ @debug_font.draw_text(line, 6, y + 1, 1, 1, 1, Gosu::Color::BLACK)
710
+ @debug_font.draw_text(line, 5, y, 1, 1, 1, Gosu::Color::WHITE)
711
+ y += 18
712
+ end
389
713
  end
390
714
 
391
715
  def button_down(id)
@@ -403,9 +727,287 @@ module Doom
403
727
  @mouse_captured = true
404
728
  @last_mouse_x = mouse_x
405
729
  end
730
+ when Gosu::KB_Z
731
+ @show_debug = !@show_debug
732
+ when Gosu::KB_Y
733
+ if defined?(RubyVM::YJIT)
734
+ setup_yjit_toggle
735
+ if RubyVM::YJIT.enabled?
736
+ RubyVM::YJIT.disable
737
+ puts "YJIT disabled!"
738
+ else
739
+ RubyVM::YJIT.enable
740
+ puts "YJIT enabled!"
741
+ end
742
+ end
743
+ when Gosu::KB_M
744
+ @show_map = !@show_map
745
+ when Gosu::KB_F12
746
+ capture_debug_snapshot
747
+ end
748
+ end
749
+
750
+ # Sector damage types from DOOM (p_spec.c)
751
+ # Type 5: 10 damage, Type 7: 5 damage, Type 4/16: 20 damage
752
+ SECTOR_DAMAGE = { 5 => 10, 7 => 5, 4 => 20, 16 => 20, 11 => 20 }.freeze
753
+
754
+ def check_sector_damage
755
+ sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
756
+ return unless sector
757
+
758
+ damage = SECTOR_DAMAGE[sector.special]
759
+ @player_state.take_damage(damage) if damage
760
+ end
761
+
762
+ def apply_death_tint(framebuffer)
763
+ # Death keeps damage_count at max so the pain palette stays red
764
+ @player_state.damage_count = 8 if @player_state&.dead
765
+ end
766
+
767
+ def respawn_player
768
+ @player_state.reset
769
+ @last_floor_height = nil
770
+ @move_momx = 0.0
771
+ @move_momy = 0.0
772
+
773
+ # Reset item pickup, combat, and monster AI state
774
+ sprites = @combat&.instance_variable_get(:@sprites)
775
+ @item_pickup = Game::ItemPickup.new(@map, @player_state) if @item_pickup
776
+ @combat = Game::Combat.new(@map, @player_state, sprites) if @combat && sprites
777
+ @monster_ai = Game::MonsterAI.new(@map, @combat) if @monster_ai && @combat
778
+
779
+ # Move player to start position
780
+ ps = @map.player_start
781
+ if ps
782
+ @renderer.set_player(ps.x, ps.y, 41, ps.angle)
783
+ update_player_height(ps.x, ps.y)
784
+ end
785
+ end
786
+
787
+ def setup_yjit_toggle
788
+ return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
789
+ require "fiddle"
790
+
791
+ address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
792
+ enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)
793
+
794
+ RubyVM::YJIT.singleton_class.prepend(Module.new do
795
+ define_method(:enable) do |**kwargs|
796
+ return false if enabled?
797
+ return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
798
+ enabled_ptr[0] = 1
799
+ true
800
+ end
801
+
802
+ define_method(:disable) do
803
+ return false unless enabled?
804
+ enabled_ptr[0] = 0
805
+ true
806
+ end
807
+ end)
808
+
809
+ @yjit_toggle_ready = true
810
+ rescue => e
811
+ puts "YJIT toggle setup failed: #{e.message}"
812
+ end
813
+
814
+ def needs_cursor?
815
+ !@mouse_captured
816
+ end
817
+
818
+ # --- Debug Snapshot ---
819
+
820
+ def capture_debug_snapshot
821
+ dir = File.join(File.expand_path('../..', __dir__), '..', 'screenshots')
822
+ FileUtils.mkdir_p(dir)
823
+
824
+ ts = Time.now.strftime('%Y%m%d_%H%M%S_%L')
825
+ prefix = File.join(dir, ts)
826
+
827
+ # Save framebuffer as PNG
828
+ require 'chunky_png' unless defined?(ChunkyPNG)
829
+ w = Render::SCREEN_WIDTH
830
+ h = Render::SCREEN_HEIGHT
831
+ img = ChunkyPNG::Image.new(w, h)
832
+ fb = @renderer.framebuffer
833
+ colors = @palette.colors
834
+ h.times do |y|
835
+ row = y * w
836
+ w.times do |x|
837
+ r, g, b = colors[fb[row + x]]
838
+ img[x, y] = ChunkyPNG::Color.rgb(r, g, b)
839
+ end
840
+ end
841
+ img.save("#{prefix}.png")
842
+
843
+ # Save player state and sector info
844
+ sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
845
+ sector_idx = sector ? @map.sectors.index(sector) : nil
846
+ angle_deg = Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI
847
+
848
+ # Sprite diagnostics
849
+ sprites_info = @renderer.sprite_diagnostics
850
+ nearby = sprites_info.select { |s| s[:dist] && s[:dist] < 1500 }
851
+ .sort_by { |s| s[:dist] }
852
+
853
+ sprite_lines = nearby.map do |s|
854
+ " #{s[:prefix]} type=#{s[:type]} pos=(#{s[:x]},#{s[:y]}) dist=#{s[:dist]} " \
855
+ "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
856
+ "range=#{s[:screen_range]} status=#{s[:status]} " \
857
+ "clip_segs=#{s[:clipping_segs]}" \
858
+ "#{s[:clipping_detail]&.any? ? "\n clips: #{s[:clipping_detail].map { |c| "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}" }.join(', ')}" : ''}"
859
+ end
860
+
861
+ File.write("#{prefix}.txt", <<~INFO)
862
+ pos: #{@renderer.player_x.round(1)}, #{@renderer.player_y.round(1)}, #{@renderer.player_z.round(1)}
863
+ angle: #{angle_deg.round(1)}
864
+ sector: #{sector_idx}
865
+ floor: #{sector&.floor_height} (#{sector&.floor_texture})
866
+ ceil: #{sector&.ceiling_height} (#{sector&.ceiling_texture})
867
+ light: #{sector&.light_level}
868
+
869
+ nearby sprites (#{nearby.size}):
870
+ #{sprite_lines.join("\n")}
871
+ INFO
872
+
873
+ puts "Snapshot saved: #{prefix}.png + .txt"
874
+ end
875
+
876
+ # --- Automap ---
877
+
878
+ MAP_MARGIN = 20
879
+
880
+ def build_sector_colors
881
+ # Generate distinct colors for each sector using golden ratio hue spacing
882
+ num_sectors = @map.sectors.size
883
+ colors = Array.new(num_sectors)
884
+ phi = (1 + Math.sqrt(5)) / 2.0
885
+
886
+ num_sectors.times do |i|
887
+ hue = (i * phi * 360) % 360
888
+ colors[i] = hsv_to_gosu(hue, 0.6, 0.85)
889
+ end
890
+ colors
891
+ end
892
+
893
+ def hsv_to_gosu(h, s, v)
894
+ c = v * s
895
+ x = c * (1 - ((h / 60.0) % 2 - 1).abs)
896
+ m = v - c
897
+
898
+ r, g, b = case (h / 60).to_i % 6
899
+ when 0 then [c, x, 0]
900
+ when 1 then [x, c, 0]
901
+ when 2 then [0, c, x]
902
+ when 3 then [0, x, c]
903
+ when 4 then [x, 0, c]
904
+ when 5 then [c, 0, x]
905
+ end
906
+
907
+ Gosu::Color.new(255, ((r + m) * 255).to_i, ((g + m) * 255).to_i, ((b + m) * 255).to_i)
908
+ end
909
+
910
+ def draw_automap
911
+ # Black background
912
+ Gosu.draw_rect(0, 0, width, height, Gosu::Color::BLACK, 0)
913
+
914
+ # Compute map bounds
915
+ verts = @map.vertices
916
+ min_x = min_y = Float::INFINITY
917
+ max_x = max_y = -Float::INFINITY
918
+ verts.each do |v|
919
+ min_x = v.x if v.x < min_x
920
+ max_x = v.x if v.x > max_x
921
+ min_y = v.y if v.y < min_y
922
+ max_y = v.y if v.y > max_y
923
+ end
924
+
925
+ map_w = max_x - min_x
926
+ map_h = max_y - min_y
927
+ return if map_w == 0 || map_h == 0
928
+
929
+ # Scale to fit screen with margin
930
+ draw_w = width - MAP_MARGIN * 2
931
+ draw_h = height - MAP_MARGIN * 2
932
+ scale = [draw_w.to_f / map_w, draw_h.to_f / map_h].min
933
+
934
+ # Center the map
935
+ offset_x = MAP_MARGIN + (draw_w - map_w * scale) / 2.0
936
+ offset_y = MAP_MARGIN + (draw_h - map_h * scale) / 2.0
937
+
938
+ # World to screen coordinate transform (Y flipped: world Y+ is up, screen Y+ is down)
939
+ to_sx = ->(wx) { offset_x + (wx - min_x) * scale }
940
+ to_sy = ->(wy) { offset_y + (max_y - wy) * scale }
941
+
942
+ # Draw linedefs colored by front sector
943
+ two_sided_color = Gosu::Color.new(100, 80, 80, 80)
944
+
945
+ @map.linedefs.each do |linedef|
946
+ v1 = verts[linedef.v1]
947
+ v2 = verts[linedef.v2]
948
+ sx1 = to_sx.call(v1.x)
949
+ sy1 = to_sy.call(v1.y)
950
+ sx2 = to_sx.call(v2.x)
951
+ sy2 = to_sy.call(v2.y)
952
+
953
+ if linedef.two_sided?
954
+ # Two-sided: dim line, colored by front sector
955
+ front_sd = @map.sidedefs[linedef.sidedef_right]
956
+ color = @sector_colors[front_sd.sector]
957
+ dim = Gosu::Color.new(100, color.red, color.green, color.blue)
958
+ Gosu.draw_line(sx1, sy1, dim, sx2, sy2, dim, 1)
959
+ else
960
+ # One-sided: solid wall, bright sector color
961
+ front_sd = @map.sidedefs[linedef.sidedef_right]
962
+ color = @sector_colors[front_sd.sector]
963
+ Gosu.draw_line(sx1, sy1, color, sx2, sy2, color, 1)
964
+ end
406
965
  end
966
+
967
+ # Draw player
968
+ px = to_sx.call(@renderer.player_x)
969
+ py = to_sy.call(@renderer.player_y)
970
+
971
+ cos_a = @renderer.cos_angle
972
+ sin_a = @renderer.sin_angle
973
+
974
+ # FOV cone
975
+ fov_len = 40.0
976
+ half_fov = Math::PI / 4.0 # 45 deg half = 90 deg total
977
+
978
+ # Cone edges (in world space, Y+ is up; on screen Y is flipped via to_sy)
979
+ left_dx = Math.cos(half_fov) * cos_a - Math.sin(half_fov) * sin_a
980
+ left_dy = Math.cos(half_fov) * sin_a + Math.sin(half_fov) * cos_a
981
+ right_dx = Math.cos(-half_fov) * cos_a - Math.sin(-half_fov) * sin_a
982
+ right_dy = Math.cos(-half_fov) * sin_a + Math.sin(-half_fov) * cos_a
983
+
984
+ # Screen positions for cone tips
985
+ lx = px + left_dx * fov_len
986
+ ly = py - left_dy * fov_len # negate because screen Y is flipped
987
+ rx = px + right_dx * fov_len
988
+ ry = py - right_dy * fov_len
989
+
990
+ cone_color = Gosu::Color.new(60, 0, 255, 0)
991
+ Gosu.draw_triangle(px, py, cone_color, lx, ly, cone_color, rx, ry, cone_color, 2)
992
+
993
+ # Cone edge lines
994
+ edge_color = Gosu::Color.new(180, 0, 255, 0)
995
+ Gosu.draw_line(px, py, edge_color, lx, ly, edge_color, 3)
996
+ Gosu.draw_line(px, py, edge_color, rx, ry, edge_color, 3)
997
+
998
+ # Player dot
999
+ dot_size = 4
1000
+ Gosu.draw_rect(px - dot_size, py - dot_size, dot_size * 2, dot_size * 2, Gosu::Color::GREEN, 3)
1001
+
1002
+ # Direction line
1003
+ dir_len = 12.0
1004
+ dx = px + cos_a * dir_len
1005
+ dy = py - sin_a * dir_len
1006
+ Gosu.draw_line(px, py, Gosu::Color::WHITE, dx, dy, Gosu::Color::WHITE, 3)
407
1007
  end
408
1008
 
1009
+ # --- End Automap ---
1010
+
409
1011
  def needs_cursor?
410
1012
  !@mouse_captured
411
1013
  end