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 +4 -4
- data/bin/doom +5 -2
- data/lib/doom/game/combat.rb +226 -40
- data/lib/doom/game/intermission.rb +248 -0
- data/lib/doom/game/item_pickup.rb +40 -4
- data/lib/doom/game/menu.rb +342 -0
- data/lib/doom/game/monster_ai.rb +210 -11
- data/lib/doom/game/player_state.rb +10 -1
- data/lib/doom/game/sector_actions.rb +376 -15
- data/lib/doom/game/sound_engine.rb +201 -0
- data/lib/doom/platform/gosu_window.rb +460 -52
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +361 -33
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/weapon_renderer.rb +11 -12
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/reader.rb +52 -0
- data/lib/doom/wad/sound.rb +85 -0
- data/lib/doom/wad/sprite.rb +29 -4
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom.rb +47 -5
- metadata +7 -1
|
@@ -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
|
-
@
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
456
|
+
side = to_player_x * normal_x + to_player_y * normal_y
|
|
361
457
|
|
|
362
|
-
#
|
|
363
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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,
|
|
710
|
-
@debug_font.draw_text(line,
|
|
711
|
-
y +=
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|