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.
@@ -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,9 +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)
52
- super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, false)
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)
83
+ fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
84
+ super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
53
85
  self.caption = 'Doom Ruby'
86
+ self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
87
+ SDLKeyboardGrab.setup
54
88
 
55
89
  @renderer = renderer
56
90
  @palette = palette
@@ -63,6 +97,13 @@ module Doom
63
97
  @sector_effects = sector_effects
64
98
  @item_pickup = item_pickup
65
99
  @combat = combat
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
66
107
  @last_floor_height = nil
67
108
  @move_momx = 0.0
68
109
  @move_momy = 0.0
@@ -75,13 +116,25 @@ module Doom
75
116
  @use_pressed = false
76
117
  @show_debug = false
77
118
  @show_map = false
78
- @debug_font = Gosu::Font.new(16)
119
+ @screen_melt = nil
120
+ @intermission = nil
121
+ @current_map = 'E1M1'
122
+ @debug_font = Gosu::Font.new(24)
123
+ @fps_frames = 0
124
+ @fps_time = Time.now
125
+ @fps_display = 0.0
79
126
 
80
127
  # Precompute sector colors for automap
81
128
  @sector_colors = build_sector_colors
82
129
 
83
- # Pre-build palette lookup for speed
84
- @palette_rgba = palette.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
130
+ # Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
131
+ @all_palette_rgba = []
132
+ wad = renderer.instance_variable_get(:@wad)
133
+ 14.times do |pal_idx|
134
+ pal = Wad::Palette.load(wad, pal_idx)
135
+ @all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
136
+ end
137
+ @palette_rgba = @all_palette_rgba[0]
85
138
  end
86
139
 
87
140
  def update
@@ -90,6 +143,18 @@ module Doom
90
143
  delta_time = now - @last_update_time
91
144
  @last_update_time = now
92
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
+
93
158
  handle_input(delta_time)
94
159
 
95
160
  # Update player state (per-frame for smooth bob)
@@ -106,7 +171,36 @@ module Doom
106
171
  @sector_effects&.update
107
172
  @player_state&.update_viewheight
108
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)
109
177
  @combat&.update
178
+ @monster_ai&.update(@renderer.player_x, @renderer.player_y)
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
+
192
+ @player_state&.update_damage_count
193
+ @item_pickup&.update_flash
194
+
195
+ # Sector damage (nukage, lava, etc.) every 32 tics
196
+ if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
197
+ check_sector_damage
198
+ end
199
+
200
+ # Track death tic for death animation
201
+ if @player_state&.dead
202
+ @player_state.death_tic += 1
203
+ end
110
204
  end
111
205
  @animations&.update(@leveltime)
112
206
 
@@ -117,30 +211,74 @@ module Doom
117
211
  if @sector_actions
118
212
  @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
119
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
120
225
  end
121
226
 
122
227
  # Check item pickups
123
228
  if @item_pickup
229
+ picked_before = @item_pickup.picked_up.size
124
230
  @item_pickup.update(@renderer.player_x, @renderer.player_y)
125
- @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
126
241
  end
127
242
 
128
243
  # Pass combat state to renderer for death frame rendering
129
244
  @renderer.combat = @combat
245
+ @renderer.monster_ai = @monster_ai
246
+ @renderer.leveltime = @leveltime
130
247
 
131
248
  # Render the 3D world
132
249
  @renderer.render_frame
133
250
 
134
251
  # Render HUD on top
135
- if @weapon_renderer
252
+ if @weapon_renderer && !@player_state&.dead
136
253
  @weapon_renderer.render(@renderer.framebuffer)
137
254
  end
138
255
  if @status_bar
139
256
  @status_bar.render(@renderer.framebuffer)
140
257
  end
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
+
264
+ # Red tint when dead
265
+ if @player_state&.dead
266
+ apply_death_tint(@renderer.framebuffer)
267
+ end
141
268
  end
142
269
 
143
270
  def handle_input(delta_time)
271
+ # Handle respawn when dead
272
+ if @player_state&.dead
273
+ if @player_state.death_tic > 35 # 1 second delay before respawn allowed
274
+ if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
275
+ Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT)
276
+ respawn_player
277
+ end
278
+ end
279
+ return # No other input while dead
280
+ end
281
+
144
282
  # Mouse look
145
283
  handle_mouse_look
146
284
 
@@ -198,8 +336,9 @@ module Doom
198
336
  try_move(@move_momx * delta_time, @move_momy * delta_time)
199
337
  end
200
338
 
201
- # Handle firing (left click, Z, or Shift - Ctrl conflicts with macOS spaces)
339
+ # Handle firing (left click, Ctrl, X, or Shift)
202
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) ||
203
342
  Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
204
343
  Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
205
344
  was_attacking = @player_state.attacking
@@ -208,6 +347,7 @@ module Doom
208
347
  if @player_state.attacking && !was_attacking && @combat
209
348
  @combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
210
349
  @renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
350
+ @sound&.weapon_fire(@player_state.weapon)
211
351
  end
212
352
  end
213
353
 
@@ -296,7 +436,7 @@ module Doom
296
436
  end
297
437
 
298
438
  def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
299
- # Calculate linedef normal (perpendicular to line, pointing to front side)
439
+ # Calculate linedef normal (perpendicular to line)
300
440
  line_dx = v2.x - v1.x
301
441
  line_dy = v2.y - v1.y
302
442
 
@@ -304,24 +444,26 @@ module Doom
304
444
  normal_x = -line_dy
305
445
  normal_y = line_dx
306
446
 
307
- # Normalize
308
447
  len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
309
448
  return false if len == 0
310
449
 
311
450
  normal_x /= len
312
451
  normal_y /= len
313
452
 
314
- # Check if player is on the front side (normal side) of the line
453
+ # Determine which side the player is on
315
454
  to_player_x = px - v1.x
316
455
  to_player_y = py - v1.y
317
- dot_player = to_player_x * normal_x + to_player_y * normal_y
456
+ side = to_player_x * normal_x + to_player_y * normal_y
318
457
 
319
- # Player must be on front side
320
- 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
321
463
 
322
- # Check if player is facing toward the line
464
+ # Check if player is facing toward the line (relaxed angle check)
323
465
  dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
324
- dot_facing > 0.5 # Must be roughly facing the line
466
+ dot_facing > 0.2 # ~78 degree cone, matching DOOM's generous use check
325
467
  end
326
468
 
327
469
  def handle_weapon_switch
@@ -471,6 +613,7 @@ module Doom
471
613
  combined_radius = PLAYER_RADIUS
472
614
  picked = @item_pickup&.picked_up
473
615
  @map.things.each_with_index do |thing, idx|
616
+ next if @skill_hidden[idx]
474
617
  next if picked && picked[idx]
475
618
  next if @combat && @combat.dead?(idx)
476
619
  thing_radius = SOLID_THING_RADIUS[thing.type]
@@ -497,7 +640,7 @@ module Doom
497
640
  return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
498
641
  end
499
642
 
500
- # BLOCKING flag blocks crossing even on two-sided linedefs
643
+ # ML_BLOCKING (0x0001) blocks crossing for everything including player
501
644
  if (linedef.flags & 0x0001) != 0
502
645
  return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
503
646
  end
@@ -547,8 +690,9 @@ module Doom
547
690
  # One-sided linedef (wall) always blocks
548
691
  return true if linedef.sidedef_left == 0xFFFF
549
692
 
550
- # BLOCKING flag (0x0001) blocks even on two-sided linedefs (e.g., windows)
551
- 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
552
696
 
553
697
  # Two-sided: check if impassable (high step OR low ceiling)
554
698
  front_side = @map.sidedefs[linedef.sidedef_right]
@@ -613,18 +757,73 @@ module Doom
613
757
  end
614
758
 
615
759
  def draw
616
- 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
617
810
  draw_automap
618
811
  else
619
- # Fast RGBA conversion using pre-built palette
620
- rgba = @renderer.framebuffer.map { |idx| @palette_rgba[idx] }.join
812
+ # Select palette: red tint when taking damage (palettes 1-8)
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
821
+ active_pal = @all_palette_rgba[pal_idx]
822
+ rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join
621
823
 
622
824
  @screen_image = Gosu::Image.from_blob(
623
- Render::SCREEN_WIDTH,
624
- Render::SCREEN_HEIGHT,
625
- rgba
825
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
626
826
  )
627
-
628
827
  @screen_image.draw(0, 0, 0, SCALE, SCALE)
629
828
 
630
829
  draw_debug_overlay if @show_debug
@@ -632,46 +831,143 @@ module Doom
632
831
  end
633
832
 
634
833
  def draw_debug_overlay
635
- sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
636
- return unless sector
637
-
638
- # Find sector index
639
- sector_idx = @map.sectors.index(sector)
834
+ @fps_frames += 1
835
+ now = Time.now
836
+ elapsed = now - @fps_time
837
+ if elapsed >= 0.5
838
+ @fps_display = (@fps_frames / elapsed).round(1)
839
+ @fps_frames = 0
840
+ @fps_time = now
841
+ end
640
842
 
641
- lines = [
642
- "Sector #{sector_idx}",
643
- "Floor: #{sector.floor_height} (#{sector.floor_texture})",
644
- "Ceil: #{sector.ceiling_height} (#{sector.ceiling_texture})",
645
- "Light: #{sector.light_level}",
646
- "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
647
- "Heading: #{(Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)}",
648
- ]
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
649
863
 
650
864
  y = 4
651
865
  lines.each do |line|
652
- @debug_font.draw_text(line, 6, y + 1, 1, 1, 1, Gosu::Color::BLACK)
653
- @debug_font.draw_text(line, 5, y, 1, 1, 1, Gosu::Color::WHITE)
654
- 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
655
869
  end
656
870
  end
657
871
 
658
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
+
659
940
  case id
660
941
  when Gosu::KB_ESCAPE
661
- if @mouse_captured
662
- @mouse_captured = false
663
- self.mouse_x = width / 2
664
- self.mouse_y = height / 2
665
- else
666
- close
667
- end
942
+ SDLKeyboardGrab.release!
943
+ @mouse_captured = false
944
+ self.mouse_x = width / 2
945
+ self.mouse_y = height / 2
946
+ @menu&.show
668
947
  when Gosu::MS_LEFT, Gosu::KB_TAB
669
948
  unless @mouse_captured
670
949
  @mouse_captured = true
671
950
  @last_mouse_x = mouse_x
951
+ SDLKeyboardGrab.grab!
672
952
  end
673
953
  when Gosu::KB_Z
674
954
  @show_debug = !@show_debug
955
+ when Gosu::KB_Y
956
+ if defined?(RubyVM::YJIT)
957
+ setup_yjit_toggle
958
+ if RubyVM::YJIT.enabled?
959
+ RubyVM::YJIT.disable
960
+ puts "YJIT disabled!"
961
+ else
962
+ RubyVM::YJIT.enable
963
+ puts "YJIT enabled!"
964
+ end
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
675
971
  when Gosu::KB_M
676
972
  @show_map = !@show_map
677
973
  when Gosu::KB_F12
@@ -679,6 +975,250 @@ module Doom
679
975
  end
680
976
  end
681
977
 
978
+ # Sector damage types from DOOM (p_spec.c)
979
+ # Type 5: 10 damage, Type 7: 5 damage, Type 4/16: 20 damage
980
+ SECTOR_DAMAGE = { 5 => 10, 7 => 5, 4 => 20, 16 => 20, 11 => 20 }.freeze
981
+
982
+ def check_sector_damage
983
+ sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
984
+ return unless sector
985
+
986
+ damage = SECTOR_DAMAGE[sector.special]
987
+ if damage
988
+ @player_state.take_damage((damage * @damage_multiplier).to_i)
989
+ @sound&.player_pain
990
+ end
991
+ end
992
+
993
+ def apply_death_tint(framebuffer)
994
+ # Death keeps damage_count at max so the pain palette stays red
995
+ @player_state.damage_count = 8 if @player_state&.dead
996
+ end
997
+
998
+ def respawn_player
999
+ @player_state.reset
1000
+ @last_floor_height = nil
1001
+ @move_momx = 0.0
1002
+ @move_momy = 0.0
1003
+
1004
+ # Reset item pickup, combat, and monster AI state
1005
+ sprites = @combat&.instance_variable_get(:@sprites)
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
1019
+
1020
+ # Move player to start position
1021
+ ps = @map.player_start
1022
+ if ps
1023
+ @renderer.set_player(ps.x, ps.y, 41, ps.angle)
1024
+ update_player_height(ps.x, ps.y)
1025
+ end
1026
+ end
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
+
1195
+ def setup_yjit_toggle
1196
+ return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
1197
+ require "fiddle"
1198
+
1199
+ address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
1200
+ enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)
1201
+
1202
+ RubyVM::YJIT.singleton_class.prepend(Module.new do
1203
+ define_method(:enable) do |**kwargs|
1204
+ return false if enabled?
1205
+ return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
1206
+ enabled_ptr[0] = 1
1207
+ true
1208
+ end
1209
+
1210
+ define_method(:disable) do
1211
+ return false unless enabled?
1212
+ enabled_ptr[0] = 0
1213
+ true
1214
+ end
1215
+ end)
1216
+
1217
+ @yjit_toggle_ready = true
1218
+ rescue => e
1219
+ puts "YJIT toggle setup failed: #{e.message}"
1220
+ end
1221
+
682
1222
  def needs_cursor?
683
1223
  !@mouse_captured
684
1224
  end