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.
@@ -7,6 +7,37 @@ module Doom
7
7
  class GosuWindow < Gosu::Window
8
8
  SCALE = 3
9
9
 
10
+ # SDL2 keyboard grab via Gosu's bundled SDL -- prevents OS key interception
11
+ module SDLKeyboardGrab
12
+ def self.setup
13
+ require "fiddle"
14
+ gosu_spec = Gem.loaded_specs["gosu"]
15
+ lib_ext = RbConfig::CONFIG["DLEXT"] || "so"
16
+ bundle = File.join(gosu_spec.full_gem_path, "lib", "gosu.#{lib_ext}")
17
+ @lib = Fiddle.dlopen(bundle)
18
+ @shared_window = Fiddle::Function.new(
19
+ @lib["_ZN4Gosu13shared_windowEv"], [], Fiddle::TYPE_VOIDP
20
+ )
21
+ @set_kb_grab = Fiddle::Function.new(
22
+ @lib["SDL_SetWindowKeyboardGrab"],
23
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_VOID
24
+ )
25
+ @ready = true
26
+ rescue
27
+ @ready = false
28
+ end
29
+
30
+ def self.grab!
31
+ return unless @ready
32
+ @set_kb_grab.call(@shared_window.call, 1)
33
+ end
34
+
35
+ def self.release!
36
+ return unless @ready
37
+ @set_kb_grab.call(@shared_window.call, 0)
38
+ end
39
+ end
40
+
10
41
  # Movement constants (matching Chocolate Doom P_Thrust / P_XYMovement)
11
42
  # DOOM: terminal walk speed = 7.55 units/tic = 264 units/sec
12
43
  # Continuous-time: v_terminal = thrust_rate / decay_rate
@@ -48,11 +79,12 @@ module Doom
48
79
  2046 => 16, # Burning barrel
49
80
  }.freeze
50
81
 
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)
82
+ 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, menu = nil, sound_engine = nil)
52
83
  fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
53
84
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
54
85
  self.caption = 'Doom Ruby'
55
86
  self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
87
+ SDLKeyboardGrab.setup
56
88
 
57
89
  @renderer = renderer
58
90
  @palette = palette
@@ -66,6 +98,12 @@ module Doom
66
98
  @item_pickup = item_pickup
67
99
  @combat = combat
68
100
  @monster_ai = monster_ai
101
+ @menu = menu
102
+ @doom_font = menu&.font
103
+ @sound = sound_engine
104
+ @damage_multiplier = 1.0
105
+ @skill = Game::Menu::SKILL_MEDIUM
106
+ @skill_hidden = {} # Thing indices hidden by difficulty
69
107
  @last_floor_height = nil
70
108
  @move_momx = 0.0
71
109
  @move_momy = 0.0
@@ -78,7 +116,10 @@ module Doom
78
116
  @use_pressed = false
79
117
  @show_debug = false
80
118
  @show_map = false
81
- @debug_font = Gosu::Font.new(16)
119
+ @screen_melt = nil
120
+ @intermission = nil
121
+ @current_map = 'E1M1'
122
+ @debug_font = Gosu::Font.new(24)
82
123
  @fps_frames = 0
83
124
  @fps_time = Time.now
84
125
  @fps_display = 0.0
@@ -102,6 +143,18 @@ module Doom
102
143
  delta_time = now - @last_update_time
103
144
  @last_update_time = now
104
145
 
146
+ # Menu is active -- only update menu animation, skip game logic
147
+ if @menu&.active?
148
+ @menu.update
149
+ return
150
+ end
151
+
152
+ # Intermission screen active
153
+ if @intermission
154
+ @intermission.update
155
+ return
156
+ end
157
+
105
158
  handle_input(delta_time)
106
159
 
107
160
  # Update player state (per-frame for smooth bob)
@@ -118,10 +171,26 @@ module Doom
118
171
  @sector_effects&.update
119
172
  @player_state&.update_viewheight
120
173
  @player_state&.update_attack # Attack timing at 35fps like DOOM
174
+ health_before = @player_state&.health || 100
175
+
176
+ @combat&.update_player_pos(@renderer.player_x, @renderer.player_y, @renderer.player_z)
121
177
  @combat&.update
122
178
  @monster_ai&.update(@renderer.player_x, @renderer.player_y)
123
179
 
180
+ # Sound effects for player damage/death
181
+ if @sound && @player_state
182
+ health_now = @player_state.health
183
+ if health_now < health_before
184
+ if @player_state.dead
185
+ @sound.player_death
186
+ else
187
+ @sound.player_pain
188
+ end
189
+ end
190
+ end
191
+
124
192
  @player_state&.update_damage_count
193
+ @item_pickup&.update_flash
125
194
 
126
195
  # Sector damage (nukage, lava, etc.) every 32 tics
127
196
  if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
@@ -142,12 +211,33 @@ module Doom
142
211
  if @sector_actions
143
212
  @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
144
213
  @sector_actions.update
214
+
215
+ # Check for level exit
216
+ if @sector_actions.exit_triggered && !@intermission
217
+ trigger_level_exit(@sector_actions.exit_triggered)
218
+ end
219
+
220
+ # Check for teleport
221
+ if (dest = @sector_actions.pop_teleport)
222
+ @renderer.set_player(dest[:x], dest[:y], @renderer.player_z, dest[:angle])
223
+ update_player_height(dest[:x], dest[:y])
224
+ end
145
225
  end
146
226
 
147
227
  # Check item pickups
148
228
  if @item_pickup
229
+ picked_before = @item_pickup.picked_up.size
149
230
  @item_pickup.update(@renderer.player_x, @renderer.player_y)
150
- @renderer.hidden_things = @item_pickup.picked_up
231
+ @renderer.hidden_things = @skill_hidden.merge(@item_pickup.picked_up)
232
+ if @sound && @item_pickup.picked_up.size > picked_before
233
+ # Check if it was a weapon pickup (has :weapon key in ITEMS)
234
+ msg = @item_pickup.pickup_message
235
+ if msg && msg.include?('!') # Weapon pickups end with !
236
+ @sound.weapon_pickup
237
+ else
238
+ @sound.item_pickup
239
+ end
240
+ end
151
241
  end
152
242
 
153
243
  # Pass combat state to renderer for death frame rendering
@@ -166,6 +256,11 @@ module Doom
166
256
  @status_bar.render(@renderer.framebuffer)
167
257
  end
168
258
 
259
+ # Pickup message (drawn into framebuffer with DOOM font, 4 seconds like Chocolate Doom)
260
+ if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics > 0
261
+ @doom_font.draw_text(@renderer.framebuffer, @item_pickup.pickup_message, 2, 2)
262
+ end
263
+
169
264
  # Red tint when dead
170
265
  if @player_state&.dead
171
266
  apply_death_tint(@renderer.framebuffer)
@@ -241,8 +336,9 @@ module Doom
241
336
  try_move(@move_momx * delta_time, @move_momy * delta_time)
242
337
  end
243
338
 
244
- # Handle firing (left click, Z, or Shift - Ctrl conflicts with macOS spaces)
339
+ # Handle firing (left click, Ctrl, X, or Shift)
245
340
  if @player_state && ((@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
341
+ Gosu.button_down?(Gosu::KB_LEFT_CONTROL) || Gosu.button_down?(Gosu::KB_RIGHT_CONTROL) ||
246
342
  Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
247
343
  Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
248
344
  was_attacking = @player_state.attacking
@@ -251,6 +347,7 @@ module Doom
251
347
  if @player_state.attacking && !was_attacking && @combat
252
348
  @combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
253
349
  @renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
350
+ @sound&.weapon_fire(@player_state.weapon)
254
351
  end
255
352
  end
256
353
 
@@ -339,7 +436,7 @@ module Doom
339
436
  end
340
437
 
341
438
  def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
342
- # Calculate linedef normal (perpendicular to line, pointing to front side)
439
+ # Calculate linedef normal (perpendicular to line)
343
440
  line_dx = v2.x - v1.x
344
441
  line_dy = v2.y - v1.y
345
442
 
@@ -347,24 +444,26 @@ module Doom
347
444
  normal_x = -line_dy
348
445
  normal_y = line_dx
349
446
 
350
- # Normalize
351
447
  len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
352
448
  return false if len == 0
353
449
 
354
450
  normal_x /= len
355
451
  normal_y /= len
356
452
 
357
- # Check if player is on the front side (normal side) of the line
453
+ # Determine which side the player is on
358
454
  to_player_x = px - v1.x
359
455
  to_player_y = py - v1.y
360
- dot_player = to_player_x * normal_x + to_player_y * normal_y
456
+ side = to_player_x * normal_x + to_player_y * normal_y
361
457
 
362
- # Player must be on front side
363
- return false if dot_player < 0
458
+ # Flip normal if player is on the back side (so we check facing toward the line)
459
+ if side < 0
460
+ normal_x = -normal_x
461
+ normal_y = -normal_y
462
+ end
364
463
 
365
- # Check if player is facing toward the line
464
+ # Check if player is facing toward the line (relaxed angle check)
366
465
  dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
367
- dot_facing > 0.5 # Must be roughly facing the line
466
+ dot_facing > 0.2 # ~78 degree cone, matching DOOM's generous use check
368
467
  end
369
468
 
370
469
  def handle_weapon_switch
@@ -514,6 +613,7 @@ module Doom
514
613
  combined_radius = PLAYER_RADIUS
515
614
  picked = @item_pickup&.picked_up
516
615
  @map.things.each_with_index do |thing, idx|
616
+ next if @skill_hidden[idx]
517
617
  next if picked && picked[idx]
518
618
  next if @combat && @combat.dead?(idx)
519
619
  thing_radius = SOLID_THING_RADIUS[thing.type]
@@ -540,7 +640,7 @@ module Doom
540
640
  return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
541
641
  end
542
642
 
543
- # BLOCKING flag blocks crossing even on two-sided linedefs
643
+ # ML_BLOCKING (0x0001) blocks crossing for everything including player
544
644
  if (linedef.flags & 0x0001) != 0
545
645
  return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
546
646
  end
@@ -590,8 +690,9 @@ module Doom
590
690
  # One-sided linedef (wall) always blocks
591
691
  return true if linedef.sidedef_left == 0xFFFF
592
692
 
593
- # BLOCKING flag (0x0001) blocks even on two-sided linedefs (e.g., windows)
594
- return true if (linedef.flags & 0x0001) != 0
693
+ # ML_BLOCKING on two-sided: handled by crosses_blocking_linedef? (crossing check)
694
+ # Don't check here -- linedef_blocks? is a proximity check and would
695
+ # block the player when standing near the line, not just crossing it
595
696
 
596
697
  # Two-sided: check if impassable (high step OR low ceiling)
597
698
  front_side = @map.sidedefs[linedef.sidedef_right]
@@ -656,20 +757,73 @@ module Doom
656
757
  end
657
758
 
658
759
  def draw
659
- if @show_map
760
+ # Intermission screen
761
+ if @intermission
762
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
763
+ @intermission.render(fb)
764
+ active_pal = @all_palette_rgba[0]
765
+ rgba = fb.map { |idx| active_pal[idx] }.join
766
+ @screen_image = Gosu::Image.from_blob(
767
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
768
+ )
769
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
770
+ return
771
+ end
772
+
773
+ # Screen melt effect in progress
774
+ if @screen_melt && !@screen_melt.done?
775
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
776
+ @screen_melt.update(fb)
777
+ active_pal = @all_palette_rgba[0]
778
+ rgba = fb.map { |idx| active_pal[idx] }.join
779
+ @screen_image = Gosu::Image.from_blob(
780
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
781
+ )
782
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
783
+ @screen_melt = nil if @screen_melt.done?
784
+ return
785
+ end
786
+
787
+ if @menu&.active?
788
+ if @menu.needs_background?
789
+ # Render game view + HUD as background, then overlay menu on top
790
+ @renderer.render_frame
791
+ @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
792
+ @status_bar&.render(@renderer.framebuffer)
793
+ fb = @renderer.framebuffer.dup
794
+ else
795
+ # Title screen: black background
796
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
797
+ end
798
+ @menu.render(fb, nil)
799
+
800
+ # Capture current menu frame for melt transitions
801
+ @last_menu_fb = fb.dup
802
+
803
+ active_pal = @all_palette_rgba[0]
804
+ rgba = fb.map { |idx| active_pal[idx] }.join
805
+ @screen_image = Gosu::Image.from_blob(
806
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
807
+ )
808
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
809
+ elsif @show_map
660
810
  draw_automap
661
811
  else
662
812
  # Select palette: red tint when taking damage (palettes 1-8)
663
- pal_idx = @player_state ? @player_state.damage_count.clamp(0, 8) : 0
813
+ # Pain palette (1-8 red), pickup palette (9 yellow)
814
+ pal_idx = if @item_pickup && @item_pickup.pickup_flash > 0
815
+ 9 # Yellow flash for item pickup
816
+ elsif @player_state
817
+ @player_state.damage_count.clamp(0, 8)
818
+ else
819
+ 0
820
+ end
664
821
  active_pal = @all_palette_rgba[pal_idx]
665
822
  rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join
666
823
 
667
824
  @screen_image = Gosu::Image.from_blob(
668
- Render::SCREEN_WIDTH,
669
- Render::SCREEN_HEIGHT,
670
- rgba
825
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
671
826
  )
672
-
673
827
  @screen_image.draw(0, 0, 0, SCALE, SCALE)
674
828
 
675
829
  draw_debug_overlay if @show_debug
@@ -677,7 +831,6 @@ module Doom
677
831
  end
678
832
 
679
833
  def draw_debug_overlay
680
- # Update FPS counter (refresh every 0.5 seconds)
681
834
  @fps_frames += 1
682
835
  now = Time.now
683
836
  elapsed = now - @fps_time
@@ -687,45 +840,115 @@ module Doom
687
840
  @fps_time = now
688
841
  end
689
842
 
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
- ]
843
+ yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'
844
+ ang = (Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)
845
+
846
+ lines = if @menu&.options&.[](:rubykaigi_mode)
847
+ [
848
+ "#{@fps_display} FPS",
849
+ "YJIT: #{yjit_status} (Y to toggle)",
850
+ "Ruby #{RUBY_VERSION}",
851
+ "Map: #{@current_map}",
852
+ "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
853
+ "Ang: #{ang}",
854
+ ]
855
+ else
856
+ [
857
+ "FPS: #{@fps_display}",
858
+ "YJIT: #{yjit_status}",
859
+ "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
860
+ "Ang: #{ang}",
861
+ ]
862
+ end
706
863
 
707
864
  y = 4
708
865
  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
866
+ @debug_font.draw_text(line, 8, y + 2, 1, 1, 1, Gosu::Color::BLACK)
867
+ @debug_font.draw_text(line, 6, y, 1, 1, 1, Gosu::Color::WHITE)
868
+ y += 26
712
869
  end
713
870
  end
714
871
 
715
872
  def button_down(id)
873
+ # Intermission handles input
874
+ if @intermission
875
+ @intermission.handle_key
876
+ if @intermission.finished
877
+ next_map = @intermission.next_map
878
+ @intermission = nil
879
+ if next_map
880
+ load_next_map(next_map)
881
+ else
882
+ # Episode complete - return to menu
883
+ @menu&.show
884
+ end
885
+ end
886
+ return
887
+ end
888
+
889
+ # Menu handles input when active
890
+ if @menu&.active?
891
+ key = case id
892
+ when Gosu::KB_UP then :up
893
+ when Gosu::KB_DOWN then :down
894
+ when Gosu::KB_RETURN, Gosu::KB_SPACE then :enter
895
+ when Gosu::KB_ESCAPE then :escape
896
+ end
897
+ if key
898
+ # Play menu navigation sounds
899
+ case key
900
+ when :up, :down
901
+ @sound&.menu_move
902
+ when :escape
903
+ @sound&.menu_back
904
+ end
905
+
906
+ # Capture old screen before menu state change (for melt effect)
907
+ old_state = @menu.state
908
+ result = @menu.handle_key(key)
909
+ new_state = @menu.state
910
+
911
+ # Trigger melt when transitioning from title to main menu
912
+ if old_state == Game::Menu::STATE_TITLE && new_state == Game::Menu::STATE_MAIN && @last_menu_fb
913
+ # Build the new screen (main menu with game background)
914
+ @renderer.render_frame
915
+ @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
916
+ @status_bar&.render(@renderer.framebuffer)
917
+ new_fb = @renderer.framebuffer.dup
918
+ @menu.render(new_fb, nil)
919
+ @screen_melt = Render::ScreenMelt.new(@last_menu_fb, new_fb)
920
+ end
921
+
922
+ # Play confirmation sound on select
923
+ @sound&.menu_select if key == :enter
924
+
925
+ case result
926
+ when :start_game
927
+ apply_difficulty(@menu.selected_skill)
928
+ when :resume
929
+ @mouse_captured = true
930
+ SDLKeyboardGrab.grab!
931
+ when :quit
932
+ close
933
+ when Hash
934
+ handle_option_toggle(result[:option], result[:value]) if result[:action] == :toggle_option
935
+ end
936
+ end
937
+ return
938
+ end
939
+
716
940
  case id
717
941
  when Gosu::KB_ESCAPE
718
- if @mouse_captured
719
- @mouse_captured = false
720
- self.mouse_x = width / 2
721
- self.mouse_y = height / 2
722
- else
723
- close
724
- end
942
+ SDLKeyboardGrab.release!
943
+ @mouse_captured = false
944
+ self.mouse_x = width / 2
945
+ self.mouse_y = height / 2
946
+ @menu&.show
725
947
  when Gosu::MS_LEFT, Gosu::KB_TAB
726
948
  unless @mouse_captured
727
949
  @mouse_captured = true
728
950
  @last_mouse_x = mouse_x
951
+ SDLKeyboardGrab.grab!
729
952
  end
730
953
  when Gosu::KB_Z
731
954
  @show_debug = !@show_debug
@@ -740,6 +963,11 @@ module Doom
740
963
  puts "YJIT enabled!"
741
964
  end
742
965
  end
966
+ when Gosu::KB_C
967
+ if @monster_ai
968
+ @monster_ai.aggression = !@monster_ai.aggression
969
+ puts "Monster aggression: #{@monster_ai.aggression ? 'ON' : 'OFF'}"
970
+ end
743
971
  when Gosu::KB_M
744
972
  @show_map = !@show_map
745
973
  when Gosu::KB_F12
@@ -756,7 +984,10 @@ module Doom
756
984
  return unless sector
757
985
 
758
986
  damage = SECTOR_DAMAGE[sector.special]
759
- @player_state.take_damage(damage) if damage
987
+ if damage
988
+ @player_state.take_damage((damage * @damage_multiplier).to_i)
989
+ @sound&.player_pain
990
+ end
760
991
  end
761
992
 
762
993
  def apply_death_tint(framebuffer)
@@ -772,9 +1003,19 @@ module Doom
772
1003
 
773
1004
  # Reset item pickup, combat, and monster AI state
774
1005
  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
1006
+ @item_pickup = Game::ItemPickup.new(@map, @player_state, @skill_hidden) if @item_pickup
1007
+ @combat = Game::Combat.new(@map, @player_state, sprites, @skill_hidden, @sound) if @combat && sprites
1008
+ sprites_mgr = @combat&.instance_variable_get(:@sprites)
1009
+ @monster_ai = Game::MonsterAI.new(@map, @combat, @player_state, sprites_mgr, @skill_hidden, @sound) if @monster_ai && @combat
1010
+
1011
+ # Re-apply active cheats from menu options
1012
+ if @menu
1013
+ opts = @menu.options
1014
+ @player_state.god_mode = opts[:god_mode]
1015
+ @player_state.infinite_ammo = opts[:infinite_ammo]
1016
+ handle_option_toggle(:all_weapons, true) if opts[:all_weapons]
1017
+ apply_rubykaigi_mode if opts[:rubykaigi_mode]
1018
+ end
778
1019
 
779
1020
  # Move player to start position
780
1021
  ps = @map.player_start
@@ -784,6 +1025,173 @@ module Doom
784
1025
  end
785
1026
  end
786
1027
 
1028
+ def handle_option_toggle(option, value)
1029
+ case option
1030
+ when :god_mode
1031
+ @player_state.god_mode = value
1032
+ @player_state.health = 100 if value
1033
+ when :infinite_ammo
1034
+ @player_state.infinite_ammo = value
1035
+ when :all_weapons
1036
+ if value
1037
+ # Give all weapons that have sprites loaded
1038
+ @gfx_weapons ||= @weapon_renderer&.instance_variable_get(:@gfx)&.weapons || {}
1039
+ (0..7).each do |w|
1040
+ name = Game::PlayerState::WEAPON_NAMES[w]
1041
+ @player_state.has_weapons[w] = true if @gfx_weapons[name]&.dig(:idle)
1042
+ end
1043
+ @player_state.ammo_bullets = @player_state.max_bullets
1044
+ @player_state.ammo_shells = @player_state.max_shells
1045
+ @player_state.ammo_rockets = @player_state.max_rockets
1046
+ @player_state.ammo_cells = @player_state.max_cells
1047
+ end
1048
+ when :fullscreen
1049
+ self.fullscreen = value if respond_to?(:fullscreen=)
1050
+ when :rubykaigi_mode
1051
+ apply_rubykaigi_mode if value
1052
+ end
1053
+ end
1054
+
1055
+ def apply_rubykaigi_mode
1056
+ return unless @menu&.options&.[](:rubykaigi_mode)
1057
+
1058
+ # God mode: invincible for stress-free demos
1059
+ @player_state.god_mode = true
1060
+ @player_state.health = 100
1061
+
1062
+ # All weapons + full ammo
1063
+ handle_option_toggle(:all_weapons, true)
1064
+ @player_state.infinite_ammo = true
1065
+
1066
+ # Monsters don't attack (peaceful exploration)
1067
+ @monster_ai.aggression = false if @monster_ai
1068
+
1069
+ # Force debug overlay on (shows FPS + YJIT status)
1070
+ @show_debug = true
1071
+ end
1072
+
1073
+ # DOOM thing flags: bit 0 = skill 1-2, bit 1 = skill 3, bit 2 = skill 4-5
1074
+ def compute_skill_hidden(skill)
1075
+ flag_bit = case skill
1076
+ when Game::Menu::SKILL_BABY, Game::Menu::SKILL_EASY then 0x0001
1077
+ when Game::Menu::SKILL_MEDIUM then 0x0002
1078
+ when Game::Menu::SKILL_HARD, Game::Menu::SKILL_NIGHTMARE then 0x0004
1079
+ else 0x0007
1080
+ end
1081
+ hidden = {}
1082
+ @map.things.each_with_index do |thing, idx|
1083
+ # Multiplayer-only things (bit 4) are hidden in single player
1084
+ if (thing.flags & 0x0010) != 0 || (thing.flags & flag_bit) == 0
1085
+ hidden[idx] = true
1086
+ end
1087
+ end
1088
+ hidden
1089
+ end
1090
+
1091
+ def trigger_level_exit(exit_type)
1092
+ # Gather stats
1093
+ total_monsters = @monster_ai ? @monster_ai.monsters.size : 0
1094
+ killed = @combat ? @combat.dead_things.size : 0
1095
+
1096
+ total_items = Game::ItemPickup::ITEMS.keys.count { |t|
1097
+ @map.things.any? { |th| th.type == t }
1098
+ }
1099
+ picked = @item_pickup ? @item_pickup.picked_up.size : 0
1100
+
1101
+ # Secret sectors (type 9) tracked by SectorActions
1102
+ total_secrets = @map.sectors.count { |s| s.special == 9 }
1103
+ found_secrets = @sector_actions ? @sector_actions.secrets_found.size : 0
1104
+
1105
+ stats = {
1106
+ map: @current_map,
1107
+ kills: killed, total_kills: total_monsters,
1108
+ items: picked, total_items: total_items,
1109
+ secrets: found_secrets, total_secrets: total_secrets,
1110
+ time_tics: @leveltime,
1111
+ exit_type: exit_type,
1112
+ }
1113
+
1114
+ wad = @renderer.instance_variable_get(:@wad)
1115
+ @intermission = Game::Intermission.new(wad, @status_bar.instance_variable_get(:@gfx), stats)
1116
+ end
1117
+
1118
+ def load_next_map(map_name)
1119
+ return unless map_name
1120
+
1121
+ wad = @renderer.instance_variable_get(:@wad)
1122
+ @current_map = map_name
1123
+
1124
+ # Load new map data
1125
+ map = Map::MapData.load(wad, map_name)
1126
+ @map = map
1127
+
1128
+ # Rebuild all systems for new map
1129
+ palette = @palette
1130
+ colormap = @renderer.instance_variable_get(:@colormap)
1131
+ textures = @renderer.instance_variable_get(:@textures)
1132
+ flats = @renderer.instance_variable_get(:@flats)
1133
+ sprites = @renderer.instance_variable_get(:@sprites)
1134
+ animations = @animations
1135
+
1136
+ @renderer = Render::Renderer.new(wad, map, textures, palette, colormap,
1137
+ flats.values, sprites, animations)
1138
+ ps = map.player_start
1139
+ @renderer.set_player(ps.x, ps.y, 41, ps.angle)
1140
+
1141
+ @player_state.reset
1142
+ @sector_actions = Game::SectorActions.new(map, @sound)
1143
+ @sector_effects = Game::SectorEffects.new(map)
1144
+
1145
+ @skill_hidden = compute_skill_hidden(@skill || Game::Menu::SKILL_MEDIUM)
1146
+ @item_pickup = Game::ItemPickup.new(map, @player_state, @skill_hidden)
1147
+ @item_pickup.ammo_multiplier = (@skill == Game::Menu::SKILL_BABY) ? 2 : 1
1148
+
1149
+ combat_sprites = sprites
1150
+ @combat = Game::Combat.new(map, @player_state, combat_sprites, @skill_hidden, @sound)
1151
+ @monster_ai = Game::MonsterAI.new(map, @combat, @player_state, combat_sprites, @skill_hidden, @sound)
1152
+ @monster_ai.aggression = true
1153
+ @monster_ai.damage_multiplier = @damage_multiplier
1154
+
1155
+ @last_floor_height = nil
1156
+ @move_momx = 0.0
1157
+ @move_momy = 0.0
1158
+ @leveltime = 0
1159
+
1160
+ update_player_height(ps.x, ps.y)
1161
+ end
1162
+
1163
+ def apply_difficulty(skill)
1164
+ @skill = skill
1165
+ @damage_multiplier = case skill
1166
+ when Game::Menu::SKILL_BABY then 0.5
1167
+ when Game::Menu::SKILL_EASY then 0.75
1168
+ when Game::Menu::SKILL_MEDIUM then 1.0
1169
+ when Game::Menu::SKILL_HARD then 1.0
1170
+ when Game::Menu::SKILL_NIGHTMARE then 1.5
1171
+ else 1.0
1172
+ end
1173
+
1174
+ # Compute which things are hidden by this skill level
1175
+ @skill_hidden = compute_skill_hidden(skill)
1176
+
1177
+ # Baby mode: start with some armor
1178
+ if skill == Game::Menu::SKILL_BABY
1179
+ @player_state.armor = 50
1180
+ end
1181
+
1182
+ if @monster_ai
1183
+ @monster_ai.aggression = true
1184
+ @monster_ai.damage_multiplier = @damage_multiplier
1185
+ end
1186
+
1187
+ # Baby: double ammo from pickups (matching DOOM skill 1)
1188
+ if @item_pickup
1189
+ @item_pickup.ammo_multiplier = (skill == Game::Menu::SKILL_BABY) ? 2 : 1
1190
+ end
1191
+
1192
+ respawn_player
1193
+ end
1194
+
787
1195
  def setup_yjit_toggle
788
1196
  return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
789
1197
  require "fiddle"