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.
- checksums.yaml +4 -4
- data/README.md +43 -21
- data/bin/doom +10 -5
- data/lib/doom/game/combat.rb +325 -13
- 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 +494 -0
- data/lib/doom/game/player_state.rb +53 -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 +589 -49
- data/lib/doom/render/font.rb +78 -0
- data/lib/doom/render/renderer.rb +395 -18
- 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 +34 -8
- data/lib/doom/wad/texture.rb +1 -1
- data/lib/doom/wad_downloader.rb +10 -1
- data/lib/doom.rb +48 -4
- metadata +10 -3
|
@@ -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
|
-
|
|
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
|
-
@
|
|
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
|
|
84
|
-
@
|
|
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,
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
456
|
+
side = to_player_x * normal_x + to_player_y * normal_y
|
|
318
457
|
|
|
319
|
-
#
|
|
320
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
620
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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,
|
|
653
|
-
@debug_font.draw_text(line,
|
|
654
|
-
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
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|