doom 0.6.0 → 0.11.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +17 -0
  3. data/README.md +65 -4
  4. data/bin/doom +138 -25
  5. data/lib/doom/benchmark.rb +282 -0
  6. data/lib/doom/game/animations.rb +9 -2
  7. data/lib/doom/game/combat.rb +377 -105
  8. data/lib/doom/game/framebuffer_blitter.rb +38 -0
  9. data/lib/doom/game/geometry.rb +68 -0
  10. data/lib/doom/game/intermission.rb +231 -0
  11. data/lib/doom/game/item_pickup.rb +108 -60
  12. data/lib/doom/game/menu.rb +347 -0
  13. data/lib/doom/game/monster_ai.rb +304 -91
  14. data/lib/doom/game/player.rb +105 -0
  15. data/lib/doom/game/player_physics.rb +384 -0
  16. data/lib/doom/game/player_state.rb +47 -64
  17. data/lib/doom/game/random.rb +82 -0
  18. data/lib/doom/game/sector_actions.rb +466 -29
  19. data/lib/doom/game/sector_effects.rb +25 -18
  20. data/lib/doom/game/snapshot.rb +584 -0
  21. data/lib/doom/game/sound_engine.rb +176 -0
  22. data/lib/doom/game/state_hash.rb +125 -0
  23. data/lib/doom/game/ticcmd.rb +61 -0
  24. data/lib/doom/game/world.rb +420 -0
  25. data/lib/doom/map/data.rb +95 -0
  26. data/lib/doom/net/client.rb +204 -0
  27. data/lib/doom/net/desync_monitor.rb +101 -0
  28. data/lib/doom/net/game_server.rb +232 -0
  29. data/lib/doom/net/lockstep.rb +170 -0
  30. data/lib/doom/net/protocol.rb +350 -0
  31. data/lib/doom/net/session.rb +282 -0
  32. data/lib/doom/net/transport.rb +110 -0
  33. data/lib/doom/platform/gosu_window.rb +820 -640
  34. data/lib/doom/platform/sdl.rb +74 -0
  35. data/lib/doom/platform/window_logic.rb +61 -0
  36. data/lib/doom/render/font.rb +80 -0
  37. data/lib/doom/render/hardware_renderer.rb +547 -0
  38. data/lib/doom/render/ray_tracing/bvh.rb +103 -0
  39. data/lib/doom/render/ray_tracing/material_state.rb +66 -0
  40. data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
  41. data/lib/doom/render/ray_tracing_renderer.rb +940 -0
  42. data/lib/doom/render/renderer.rb +684 -330
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +71 -0
  45. data/lib/doom/render/spinel_native/kernel.rb +780 -0
  46. data/lib/doom/render/spinel_native_renderer.rb +162 -0
  47. data/lib/doom/render/status_bar.rb +14 -10
  48. data/lib/doom/render/weapon_renderer.rb +15 -17
  49. data/lib/doom/render/world_mesh.rb +270 -0
  50. data/lib/doom/render/zbuffer_renderer.rb +151 -0
  51. data/lib/doom/version.rb +1 -1
  52. data/lib/doom/wad/flat.rb +1 -1
  53. data/lib/doom/wad/hud_graphics.rb +7 -3
  54. data/lib/doom/wad/palette.rb +3 -3
  55. data/lib/doom/wad/patch.rb +1 -1
  56. data/lib/doom/wad/reader.rb +59 -10
  57. data/lib/doom/wad/sound.rb +93 -0
  58. data/lib/doom/wad/sprite.rb +60 -34
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +235 -12
  62. metadata +100 -7
@@ -1,75 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'gosu'
4
+ require_relative 'sdl'
5
+ require_relative 'window_logic'
4
6
 
5
7
  module Doom
6
8
  module Platform
7
9
  class GosuWindow < Gosu::Window
8
10
  SCALE = 3
9
11
 
10
- # Movement constants (matching Chocolate Doom P_Thrust / P_XYMovement)
11
- # DOOM: terminal walk speed = 7.55 units/tic = 264 units/sec
12
- # Continuous-time: v_terminal = thrust_rate / decay_rate
13
- # decay_rate = -ln(0.90625) * 35 = 3.44/sec
14
- # thrust_rate = 264 * 3.44 = 908 units/sec^2
15
- MOVE_THRUST_RATE = 264.0 * 3.44 # Thrust rate (units/sec^2)
16
- FRICTION_DECAY_RATE = 3.44 # Friction decay (1/sec)
17
- STOPSPEED = 0.5 # Snap-to-zero threshold (units/sec)
18
- TURN_SPEED = 3.0 # Degrees per frame
19
- MOUSE_SENSITIVITY = 0.15 # Mouse look sensitivity
20
- PLAYER_RADIUS = 16.0 # Collision radius
21
-
22
- USE_DISTANCE = 64.0 # Max distance to use a linedef
23
-
24
- # Solid thing types with their collision radii (from mobjinfo[] MF_SOLID)
25
- # Monsters, barrels, pillars, lamps, torches, trees block player movement
26
- SOLID_THING_RADIUS = {
27
- 9 => 20, 65 => 20, 66 => 20, 67 => 20, 68 => 20, # Shotgun Guy variants
28
- 3004 => 20, 84 => 20, # Zombieman
29
- 3001 => 20, # Imp
30
- 3002 => 30, 58 => 30, # Demon, Spectre
31
- 3003 => 24, 69 => 24, # Baron, Hell Knight
32
- 3006 => 16, # Lost Soul
33
- 3005 => 31, # Cacodemon
34
- 16 => 40, # Cyberdemon
35
- 7 => 128, # Spider Mastermind
36
- 64 => 20, # Archvile
37
- 71 => 31, # Pain Elemental
38
- 2035 => 10, # Barrel
39
- 2028 => 16, # Tall lamp
40
- 48 => 16, 30 => 16, 32 => 16, # Tech column, green/red pillars
41
- 31 => 16, 33 => 16, 36 => 16, # Short pillars
42
- 41 => 16, 43 => 16, # Evil eye, burnt tree
43
- 54 => 32, # Brown tree
44
- 44 => 16, 45 => 16, 46 => 16, # Tall torches
45
- 55 => 16, 56 => 16, 57 => 16, # Short torches
46
- 47 => 16, 70 => 16, # Stubs
47
- 85 => 16, 86 => 16, # Tall tech lamps
48
- 2046 => 16, # Burning barrel
49
- }.freeze
50
-
51
- def initialize(renderer, palette, map, player_state = nil, status_bar = nil, weapon_renderer = nil, sector_actions = nil, animations = nil, sector_effects = nil, item_pickup = nil, combat = nil, monster_ai = nil)
12
+ # Movement now lives in Game::PlayerPhysics and runs per tic, so the old
13
+ # continuous-time thrust/friction constants moved there with it.
14
+ TURN_SPEED = 3.0 # Degrees per tic
15
+
16
+ # Frame generation is decoupled from presentation. Gosu's main loop is
17
+ # limited by the buffer swap, which blocks on vblank -- so drawing every
18
+ # iteration pins the whole engine to the monitor's refresh rate. Instead
19
+ # we render every iteration (in update) but only ask Gosu to present when
20
+ # a refresh interval has elapsed; needs_redraw? => false skips both the
21
+ # blit and the swap, letting the loop spin freely in between.
22
+ #
23
+ # Gosu 1.4 exposes no refresh-rate API, so we ask SDL (see
24
+ # SDLDisplayMode) and fall back to 60 Hz if it will not say.
25
+ DEFAULT_REFRESH_HZ = 60.0
26
+ PRESENT_INTERVAL_SLACK = 0.85 # Aim slightly early so we don't miss a vblank
27
+ MOUSE_SENSITIVITY = 0.15 # Mouse look sensitivity
28
+
29
+ # The simulation now lives in Game::World; this class is input and
30
+ # output only. The world's subsystems are cached in ivars because the
31
+ # automap, debug overlay and HUD read them constantly -- bind_world keeps
32
+ # those caches honest whenever the world is rebuilt.
33
+ def initialize(renderer, palette, world, status_bar = nil, weapon_renderer = nil,
34
+ animations = nil, menu = nil, sound_engine = nil, session: nil, client: nil)
52
35
  fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
53
36
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
54
37
  self.caption = 'Doom Ruby'
55
- self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
38
+ self.update_interval = 0 # Uncap framerate (default 16.67ms = 60 FPS cap)
39
+ SDLKeyboardGrab.setup
56
40
 
57
41
  @renderer = renderer
58
42
  @palette = palette
59
- @map = map
60
- @player_state = player_state
61
43
  @status_bar = status_bar
62
44
  @weapon_renderer = weapon_renderer
63
- @sector_actions = sector_actions
64
45
  @animations = animations
65
- @sector_effects = sector_effects
66
- @item_pickup = item_pickup
67
- @combat = combat
68
- @monster_ai = monster_ai
69
- @last_floor_height = nil
70
- @move_momx = 0.0
71
- @move_momy = 0.0
72
- @leveltime = 0
46
+ @menu = menu
47
+ @doom_font = menu&.font
48
+ @sound = sound_engine
49
+ @skill = Game::Menu::SKILL_MEDIUM
50
+ @session = session
51
+ @client = client # authoritative-server client, if we joined one
52
+ @local_player_id = session&.local_id || client&.local_id || 0
53
+ @last_tic_at = Time.now # for interpolating between server frames
54
+ @last_net_send_at = nil # throttles outbound packets to the tic rate
55
+ @client_input_window = [] # recent [tic, ticcmd] resent against loss
56
+ menu.netgame = true if menu && (session || client)
57
+
58
+ bind_world(world)
59
+ @pending_turn = 0.0 # Mouse turn accumulated between tics
73
60
  @tic_accumulator = 0.0
74
61
  @screen_image = nil
75
62
  @mouse_captured = false
@@ -78,17 +65,30 @@ module Doom
78
65
  @use_pressed = false
79
66
  @show_debug = false
80
67
  @show_map = false
81
- @debug_font = Gosu::Font.new(16)
68
+ @screen_melt = nil
69
+ @intermission = nil
70
+ @current_map = 'E1M1'
71
+ @debug_font = Gosu::Font.new(24)
82
72
  @fps_frames = 0
83
73
  @fps_time = Time.now
84
74
  @fps_display = 0.0
85
75
 
76
+ # Uncapped frame generation. Presented frames are counted separately so
77
+ # the overlay can show both the render rate and what the display got.
78
+ @uncapped_fps = !ARGV.include?('--vsync')
79
+ menu.options[:uncapped_fps] = @uncapped_fps if menu
80
+ @present_frames = 0
81
+ @present_fps_display = 0.0
82
+ @last_present_ms = 0
83
+ @refresh_hz = (SDLDisplayMode.refresh_rate || DEFAULT_REFRESH_HZ).to_f
84
+ @present_interval_ms = 1000.0 / @refresh_hz
85
+
86
86
  # Precompute sector colors for automap
87
87
  @sector_colors = build_sector_colors
88
88
 
89
89
  # Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
90
90
  @all_palette_rgba = []
91
- wad = renderer.instance_variable_get(:@wad)
91
+ wad = renderer.wad
92
92
  14.times do |pal_idx|
93
93
  pal = Wad::Palette.load(wad, pal_idx)
94
94
  @all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
@@ -96,277 +96,257 @@ module Doom
96
96
  @palette_rgba = @all_palette_rgba[0]
97
97
  end
98
98
 
99
- def update
100
- # Calculate delta time for smooth animations
101
- now = Time.now
102
- delta_time = now - @last_update_time
103
- @last_update_time = now
104
-
105
- handle_input(delta_time)
106
-
107
- # Update player state (per-frame for smooth bob)
108
- if @player_state
109
- @player_state.update_bob(delta_time)
110
- @player_state.update_view_bob(delta_time)
111
- end
112
-
113
- # Advance game tics at 35/sec (DOOM's tic rate)
114
- @tic_accumulator += delta_time * 35.0
99
+ def advance_local
115
100
  while @tic_accumulator >= 1.0
116
- @leveltime += 1
117
101
  @tic_accumulator -= 1.0
118
- @sector_effects&.update
119
- @player_state&.update_viewheight
120
- @player_state&.update_attack # Attack timing at 35fps like DOOM
121
- @combat&.update
122
- @monster_ai&.update(@renderer.player_x, @renderer.player_y)
123
-
124
- @player_state&.update_damage_count
125
-
126
- # Sector damage (nukage, lava, etc.) every 32 tics
127
- if @player_state && !@player_state.dead && (@leveltime % 32 == 0)
128
- check_sector_damage
129
- end
130
-
131
- # Track death tic for death animation
132
- if @player_state&.dead
133
- @player_state.death_tic += 1
134
- end
135
- end
136
- @animations&.update(@leveltime)
137
-
138
- # Update HUD animations
139
- @status_bar&.update
140
-
141
- # Update sector actions (doors, lifts, etc.)
142
- if @sector_actions
143
- @sector_actions.update_player_position(@renderer.player_x, @renderer.player_y)
144
- @sector_actions.update
102
+ @leveltime = @world.run_tic(@player.id => build_ticcmd)
103
+ @item_pickup.update_flash
145
104
  end
105
+ end
146
106
 
147
- # Check item pickups
148
- if @item_pickup
149
- @item_pickup.update(@renderer.player_x, @renderer.player_y)
150
- @renderer.hidden_things = @item_pickup.picked_up
107
+ # Networked: input is sampled on the local clock but tics only run once
108
+ # every player's command for them has arrived. A stall is normal -- it
109
+ # means someone else's packet is late -- so we keep drawing and say who
110
+ # we are waiting for rather than freezing silently.
111
+ # Authoritative-server client: no local simulation at all. Poll the
112
+ # server's frame stream (Net::Client applies it), send this frame's input
113
+ # tagged a few tics ahead so it lands before that tic is finalized, and
114
+ # follow the world the client holds -- which it rebuilds from a fresh
115
+ # snapshot on a resync, so re-point at it when the object changes.
116
+ CLIENT_INPUT_LEAD = 3
117
+ # Outbound packets are paced to the tic rate, not the uncapped render loop:
118
+ # drawing runs at hundreds of FPS, and sending one packet per frame would
119
+ # flood the server (and, in P2P, every peer) from every phone in the room.
120
+ NET_SEND_INTERVAL = 1.0 / 35.0
121
+ # Each input packet repeats the last few tics of commands, so pacing the
122
+ # send rate down does not make a single dropped packet lose that input.
123
+ CLIENT_INPUT_REDUNDANCY = 4
124
+
125
+ def advance_server_client
126
+ @client.poll
127
+ bind_world(@client.world) unless @world.equal?(@client.world)
128
+
129
+ send_client_input
130
+
131
+ if @client.synced_tic != @leveltime
132
+ @leveltime = @client.synced_tic
133
+ @last_tic_at = Time.now
134
+ @item_pickup.update_flash
151
135
  end
136
+ # Fraction into the current tic, so rendering stays smooth above the
137
+ # server's 35 Hz frame rate.
138
+ @tic_accumulator = [(Time.now - @last_tic_at) * 35.0, 1.0].min
139
+ end
152
140
 
153
- # Pass combat state to renderer for death frame rendering
154
- @renderer.combat = @combat
155
- @renderer.monster_ai = @monster_ai
156
- @renderer.leveltime = @leveltime
157
-
158
- # Render the 3D world
159
- @renderer.render_frame
141
+ def advance_networked
142
+ @session.poll
160
143
 
161
- # Render HUD on top
162
- if @weapon_renderer && !@player_state&.dead
163
- @weapon_renderer.render(@renderer.framebuffer)
164
- end
165
- if @status_bar
166
- @status_bar.render(@renderer.framebuffer)
144
+ while @tic_accumulator >= 1.0
145
+ @tic_accumulator -= 1.0
146
+ @session.submit(build_ticcmd)
167
147
  end
148
+ # Lockstep already resends a redundancy window from each peer's ack, so
149
+ # pacing transmit to the tic rate loses nothing but the flood.
150
+ @session.transmit if net_send_due?
168
151
 
169
- # Red tint when dead
170
- if @player_state&.dead
171
- apply_death_tint(@renderer.framebuffer)
172
- end
152
+ ran = @session.run(@world, limit: 10)
153
+ ran.times { @item_pickup.update_flash }
154
+ @leveltime = @world.leveltime
173
155
  end
174
156
 
175
- def handle_input(delta_time)
176
- # Handle respawn when dead
177
- if @player_state&.dead
178
- if @player_state.death_tic > 35 # 1 second delay before respawn allowed
179
- if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
180
- Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT)
181
- respawn_player
182
- end
183
- end
184
- return # No other input while dead
185
- end
186
-
187
- # Mouse look
188
- handle_mouse_look
157
+ # Sample this frame's input for a near-future tic and send it, paced to the
158
+ # tic rate, with a short redundancy window against packet loss. Only the
159
+ # newest sample for a given target tic is kept.
160
+ def send_client_input
161
+ return unless net_send_due?
189
162
 
190
- # Keyboard turning
191
- if Gosu.button_down?(Gosu::KB_LEFT)
192
- @renderer.turn(TURN_SPEED)
193
- end
194
- if Gosu.button_down?(Gosu::KB_RIGHT)
195
- @renderer.turn(-TURN_SPEED)
163
+ tic = @client.synced_tic + CLIENT_INPUT_LEAD
164
+ if @client_input_window.last&.first == tic
165
+ @client_input_window[-1] = [tic, build_ticcmd]
166
+ else
167
+ @client_input_window << [tic, build_ticcmd]
196
168
  end
169
+ @client_input_window = @client_input_window.last(CLIENT_INPUT_REDUNDANCY)
170
+ @client.send_input(@client_input_window)
171
+ end
197
172
 
198
- # Apply thrust from input (P_Thrust: additive, scaled by delta_time)
199
- thrust = MOVE_THRUST_RATE * delta_time
200
- has_input = false
173
+ # True at most once per tic interval, so outbound packets track the 35 Hz
174
+ # simulation rather than the uncapped frame rate. Advances the clock as a
175
+ # side effect when it fires.
176
+ def net_send_due?
177
+ now = Time.now
178
+ return false if @last_net_send_at && (now - @last_net_send_at) < NET_SEND_INTERVAL
201
179
 
202
- if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
203
- @move_momx += @renderer.cos_angle * thrust
204
- @move_momy += @renderer.sin_angle * thrust
205
- has_input = true
206
- end
207
- if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
208
- @move_momx -= @renderer.cos_angle * thrust
209
- @move_momy -= @renderer.sin_angle * thrust
210
- has_input = true
211
- end
212
- if Gosu.button_down?(Gosu::KB_A)
213
- @move_momx -= @renderer.sin_angle * thrust
214
- @move_momy += @renderer.cos_angle * thrust
215
- has_input = true
216
- end
217
- if Gosu.button_down?(Gosu::KB_D)
218
- @move_momx += @renderer.sin_angle * thrust
219
- @move_momy -= @renderer.cos_angle * thrust
220
- has_input = true
221
- end
180
+ @last_net_send_at = now
181
+ true
182
+ end
222
183
 
223
- # Apply friction (continuous-time equivalent of *= 0.90625 per tic)
224
- decay = Math.exp(-FRICTION_DECAY_RATE * delta_time)
225
- if !has_input && @move_momx.abs < STOPSPEED && @move_momy.abs < STOPSPEED
226
- @move_momx = 0.0
227
- @move_momy = 0.0
228
- else
229
- @move_momx *= decay
230
- @move_momy *= decay
231
- end
184
+ # Point this window at a world, refreshing every cached reference. Called
185
+ # on construction and whenever the world is rebuilt (new map, respawn).
186
+ def bind_world(world)
187
+ @world = world
188
+ @map = world.map
189
+ @random = world.random
190
+ @player = world.player(@local_player_id) || world.add_player(id: @local_player_id)
191
+ apply_debug_pose if ENV['DOOM_DEBUG_POSE'] && !@debug_pose_applied
192
+ @player_state = @player.state
193
+ # The HUD holds the player state directly; re-point it, or a map change
194
+ # or a netgame resync (which builds a fresh world) leaves it on a stale
195
+ # player showing frozen health and ammo.
196
+ @status_bar.player = @player_state if @status_bar
197
+ @weapon_renderer.player = @player_state if @weapon_renderer
198
+ @physics = world.physics_for(@player)
199
+ @combat = world.combat
200
+ @monster_ai = world.monster_ai
201
+ @item_pickup = world.item_pickup
202
+ @sector_actions = world.sector_actions
203
+ @damage_multiplier = world.damage_multiplier
204
+ @leveltime = world.leveltime
205
+ end
232
206
 
233
- # Track movement state for weapon/view bob
234
- if @player_state
235
- @player_state.is_moving = has_input
236
- @player_state.set_movement_momentum(@move_momx, @move_momy)
237
- end
207
+ def apply_debug_pose
208
+ x, y, angle = ENV.fetch('DOOM_DEBUG_POSE').split(',').map { |value| Float(value) }
209
+ sector = @map.sector_at(x, y)
210
+ @player.place(x, y, (sector&.floor_height || 0) + Game::PlayerState::VIEWHEIGHT, angle)
211
+ @debug_pose_applied = true
212
+ rescue ArgumentError
213
+ warn 'DOOM_DEBUG_POSE must be x,y,angle_degrees'
214
+ end
238
215
 
239
- # Apply momentum with collision detection (scale by delta_time for frame-rate independence)
240
- if @move_momx.abs > STOPSPEED || @move_momy.abs > STOPSPEED
241
- try_move(@move_momx * delta_time, @move_momy * delta_time)
242
- end
216
+ def update
217
+ # Calculate delta time for smooth animations
218
+ now = Time.now
219
+ delta_time = now - @last_update_time
220
+ @last_update_time = now
243
221
 
244
- # Handle firing (left click, Z, or Shift - Ctrl conflicts with macOS spaces)
245
- if @player_state && ((@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
246
- Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
247
- Gosu.button_down?(Gosu::KB_RIGHT_SHIFT))
248
- was_attacking = @player_state.attacking
249
- @player_state.start_attack
250
- # Fire hitscan on the first frame of the attack
251
- if @player_state.attacking && !was_attacking && @combat
252
- @combat.fire(@renderer.player_x, @renderer.player_y, @renderer.player_z,
253
- @renderer.cos_angle, @renderer.sin_angle, @player_state.weapon)
222
+ # Menu is active -- only update menu animation, skip game logic.
223
+ #
224
+ # Except in a networked game, which must never stop simulating. Peers
225
+ # are waiting on our ticcmds and lockstep cannot skip a tic, so a
226
+ # paused peer stops the whole session; if the pause outlasts the
227
+ # commands the others still hold, it wedges permanently. DOOM does not
228
+ # pause netgames either. Input is neutral while the menu has focus, so
229
+ # navigating it does not also drive the player.
230
+ if @menu&.active?
231
+ @menu.update
232
+
233
+ if @session
234
+ @tic_accumulator += delta_time * 35.0
235
+ advance_networked
236
+ elsif @client
237
+ advance_server_client
254
238
  end
239
+ return
255
240
  end
256
241
 
257
- # Handle weapon switching with number keys
258
- handle_weapon_switch if @player_state
259
-
260
- # Handle use key (spacebar or E)
261
- handle_use_key if @sector_actions
262
- end
263
-
264
- def handle_use_key
265
- use_down = Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)
266
-
267
- if use_down && !@use_pressed
268
- @use_pressed = true
269
- try_use_linedef
270
- elsif !use_down
271
- @use_pressed = false
242
+ # Intermission screen active
243
+ if @intermission
244
+ @intermission.update
245
+ return
272
246
  end
273
- end
274
247
 
275
- def try_use_linedef
276
- # Cast a ray forward to find a usable linedef
277
- player_x = @renderer.player_x
278
- player_y = @renderer.player_y
279
- cos_angle = @renderer.cos_angle
280
- sin_angle = @renderer.sin_angle
248
+ handle_input(delta_time)
281
249
 
282
- # Check point in front of player
283
- use_x = player_x + cos_angle * USE_DISTANCE
284
- use_y = player_y + sin_angle * USE_DISTANCE
250
+ # Advance the simulation at 35/sec (DOOM's tic rate). Everything that
251
+ # decides what happens now lives in Game::World; this loop only decides
252
+ # how many tics are owed and hands over the input for each.
253
+ @tic_accumulator += delta_time * 35.0
254
+ if @session
255
+ advance_networked
256
+ elsif @client
257
+ advance_server_client
258
+ else
259
+ advance_local
260
+ end
285
261
 
286
- # Find the closest linedef the player is facing
287
- best_linedef = nil
288
- best_idx = nil
289
- best_dist = Float::INFINITY
262
+ @animations&.update(@leveltime)
263
+ @status_bar&.update
264
+ @renderer.hidden_things = @world.hidden_things
290
265
 
291
- @map.linedefs.each_with_index do |linedef, idx|
292
- next if linedef.special == 0 # Skip non-special linedefs
266
+ trigger_level_exit(@world.exit_triggered) if @world.exit_triggered && !@intermission
293
267
 
294
- v1 = @map.vertices[linedef.v1]
295
- v2 = @map.vertices[linedef.v2]
268
+ # Other players are drawn as sprites; the local one never is.
269
+ @renderer.players = @world.players
270
+ @renderer.view_player = @player
296
271
 
297
- # Check if player is close enough to the linedef
298
- dist = point_to_line_distance(player_x, player_y, v1.x, v1.y, v2.x, v2.y)
299
- next if dist > USE_DISTANCE
300
- next if dist >= best_dist
272
+ # Pass combat state to renderer for death frame rendering
273
+ @renderer.combat = @combat
274
+ @renderer.monster_ai = @monster_ai
275
+ @renderer.leveltime = @leveltime
301
276
 
302
- # Check if player is facing the linedef (on the front side)
303
- next unless facing_linedef?(player_x, player_y, cos_angle, sin_angle, v1, v2)
277
+ # Render the 3D world. This is the generated-frame rate: it runs every
278
+ # loop iteration, whether or not the result gets presented.
279
+ @renderer.render_frame
280
+ track_frame_rates
304
281
 
305
- best_linedef = linedef
306
- best_idx = idx
307
- best_dist = dist
308
- end
282
+ # Render HUD on top
283
+ @weapon_renderer.render(@renderer.framebuffer) if @weapon_renderer && !@player_state&.dead
284
+ @status_bar.render(@renderer.framebuffer) if @status_bar
309
285
 
310
- if best_linedef
311
- @sector_actions.use_linedef(best_linedef, best_idx)
286
+ # Pickup message (drawn into framebuffer with DOOM font, 4 seconds like Chocolate Doom)
287
+ if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics > 0
288
+ @doom_font.draw_text(@renderer.framebuffer, @item_pickup.pickup_message, 2, 2)
312
289
  end
313
- end
314
-
315
- def point_to_line_distance(px, py, x1, y1, x2, y2)
316
- # Vector from line start to point
317
- dx = px - x1
318
- dy = py - y1
319
-
320
- # Line direction vector
321
- line_dx = x2 - x1
322
- line_dy = y2 - y1
323
- line_len_sq = line_dx * line_dx + line_dy * line_dy
324
-
325
- return Math.sqrt(dx * dx + dy * dy) if line_len_sq == 0
326
-
327
- # Project point onto line, clamped to segment
328
- t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
329
- t = [[t, 0.0].max, 1.0].min
330
290
 
331
- # Closest point on line segment
332
- closest_x = x1 + t * line_dx
333
- closest_y = y1 + t * line_dy
291
+ # Red tint when dead
292
+ return unless @player_state&.dead
334
293
 
335
- # Distance from point to closest point on segment
336
- dist_x = px - closest_x
337
- dist_y = py - closest_y
338
- Math.sqrt(dist_x * dist_x + dist_y * dist_y)
294
+ hold_pain_palette
339
295
  end
340
296
 
341
- def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
342
- # Calculate linedef normal (perpendicular to line, pointing to front side)
343
- line_dx = v2.x - v1.x
344
- line_dy = v2.y - v1.y
345
-
346
- # Normal points to the right of the line direction
347
- normal_x = -line_dy
348
- normal_y = line_dx
349
-
350
- # Normalize
351
- len = Math.sqrt(normal_x * normal_x + normal_y * normal_y)
352
- return false if len == 0
353
-
354
- normal_x /= len
355
- normal_y /= len
297
+ # Per-frame input sampling. Produces nothing but intent: the simulation
298
+ # itself runs per-tic in Game::World (fed the ticcmd from build_ticcmd).
299
+ # Mouse motion is accumulated here because frames are more frequent than
300
+ # tics and dropping the surplus would lose part of every flick.
301
+ def handle_input(_delta_time)
302
+ # Handle respawn when dead. Local play only: respawn rebuilds the level,
303
+ # which in a networked game would desync us from everyone else, so there
304
+ # it is the server's job (not yet wired -- dead players stay down).
305
+ if @player_state&.dead
306
+ if !@session && !@client && @player_state.death_tic > 35 && (Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_X) ||
307
+ Gosu.button_down?(Gosu::MS_LEFT) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT))
308
+ respawn_player
309
+ end
310
+ return # No other input while dead
311
+ end
356
312
 
357
- # Check if player is on the front side (normal side) of the line
358
- to_player_x = px - v1.x
359
- to_player_y = py - v1.y
360
- dot_player = to_player_x * normal_x + to_player_y * normal_y
313
+ handle_mouse_look # accumulates into @pending_turn
314
+ handle_weapon_switch if @player_state
315
+ end
361
316
 
362
- # Player must be on front side
363
- return false if dot_player < 0
317
+ # Collect this tic's intent. Everything that moves the player must come
318
+ # through here, so that replacing it with a ticcmd off the network is the
319
+ # only change multiplayer needs.
320
+ def build_ticcmd
321
+ return Game::Ticcmd.none if @menu&.active?
322
+ return Game::Ticcmd.none if @player_state&.dead
323
+
324
+ forward = 0.0
325
+ side = 0.0
326
+ forward += 1.0 if Gosu.button_down?(Gosu::KB_UP) || Gosu.button_down?(Gosu::KB_W)
327
+ forward -= 1.0 if Gosu.button_down?(Gosu::KB_DOWN) || Gosu.button_down?(Gosu::KB_S)
328
+ side += 1.0 if Gosu.button_down?(Gosu::KB_D)
329
+ side -= 1.0 if Gosu.button_down?(Gosu::KB_A)
330
+
331
+ turn = @pending_turn
332
+ @pending_turn = 0.0
333
+ turn += TURN_SPEED if Gosu.button_down?(Gosu::KB_LEFT)
334
+ turn -= TURN_SPEED if Gosu.button_down?(Gosu::KB_RIGHT)
335
+
336
+ buttons = 0
337
+ if (@mouse_captured && Gosu.button_down?(Gosu::MS_LEFT)) ||
338
+ Gosu.button_down?(Gosu::KB_LEFT_CONTROL) || Gosu.button_down?(Gosu::KB_RIGHT_CONTROL) ||
339
+ Gosu.button_down?(Gosu::KB_X) || Gosu.button_down?(Gosu::KB_LEFT_SHIFT) ||
340
+ Gosu.button_down?(Gosu::KB_RIGHT_SHIFT)
341
+ buttons |= Game::Ticcmd::BTN_FIRE
342
+ end
343
+ buttons |= Game::Ticcmd::BTN_USE if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)
364
344
 
365
- # Check if player is facing toward the line
366
- dot_facing = cos_angle * (-normal_x) + sin_angle * (-normal_y)
367
- dot_facing > 0.5 # Must be roughly facing the line
345
+ Game::Ticcmd.new(forward, side, turn, buttons)
368
346
  end
369
347
 
348
+ # Weapon selection from the number keys. Read per-frame like the rest of
349
+ # input; the switch itself only changes which weapon the HUD/firing use.
370
350
  def handle_weapon_switch
371
351
  if Gosu.button_down?(Gosu::KB_1)
372
352
  @player_state.switch_weapon(Game::PlayerState::WEAPON_FIST)
@@ -385,361 +365,412 @@ module Doom
385
365
  end
386
366
  end
387
367
 
388
- def try_move(dx, dy)
389
- old_x = @renderer.player_x
390
- old_y = @renderer.player_y
391
- new_x = old_x + dx
392
- new_y = old_y + dy
368
+ # Lazily computed and cached on first access; cleared by load_next_map.
369
+ def map_bounds
370
+ return @map_bounds if defined?(@map_bounds) && @map_bounds
393
371
 
394
- # Check if new position is valid and path doesn't cross blocking linedefs
395
- if valid_move?(old_x, old_y, new_x, new_y)
396
- @renderer.move_to(new_x, new_y)
397
- update_player_height(new_x, new_y)
398
- else
399
- # Wall sliding: project movement along the blocking wall
400
- slide_x, slide_y = compute_slide(old_x, old_y, dx, dy)
401
- if slide_x && (slide_x != 0.0 || slide_y != 0.0)
402
- sx = old_x + slide_x
403
- sy = old_y + slide_y
404
- if valid_move?(old_x, old_y, sx, sy)
405
- @renderer.move_to(sx, sy)
406
- update_player_height(sx, sy)
407
- # Redirect momentum along the wall
408
- @move_momx = slide_x / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momx.abs
409
- @move_momy = slide_y / ([dx.abs, dy.abs].max.nonzero? || 1) * @move_momy.abs
410
- return
411
- end
412
- end
413
-
414
- # Fallback: try axis-aligned sliding
415
- if dx != 0.0 && valid_move?(old_x, old_y, new_x, old_y)
416
- @renderer.move_to(new_x, old_y)
417
- update_player_height(new_x, old_y)
418
- @move_momy *= 0.0
419
- elsif dy != 0.0 && valid_move?(old_x, old_y, old_x, new_y)
420
- @renderer.move_to(old_x, new_y)
421
- update_player_height(old_x, new_y)
422
- @move_momx *= 0.0
423
- else
424
- # Fully blocked - kill momentum
425
- @move_momx = 0.0
426
- @move_momy = 0.0
427
- end
428
- end
429
- end
430
-
431
- # Find the blocking linedef and project movement along it
432
- def compute_slide(px, py, dx, dy)
433
- best_wall = nil
434
- best_dist = Float::INFINITY
435
-
436
- @map.linedefs.each do |linedef|
437
- v1 = @map.vertices[linedef.v1]
438
- v2 = @map.vertices[linedef.v2]
439
-
440
- # Only check linedefs near the player
441
- next unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, px + dx, py + dy, PLAYER_RADIUS)
442
-
443
- # Check if this linedef actually blocks
444
- next unless linedef_blocks?(linedef, px + dx, py + dy) ||
445
- crosses_blocking_linedef?(px, py, px + dx, py + dy, linedef)
446
-
447
- # Distance from player to this linedef
448
- dist = point_to_line_distance(px, py, v1.x, v1.y, v2.x, v2.y)
449
- if dist < best_dist
450
- best_dist = dist
451
- best_wall = linedef
452
- end
372
+ min_x = min_y = Float::INFINITY
373
+ max_x = max_y = -Float::INFINITY
374
+ @map.vertices.each do |v|
375
+ min_x = v.x if v.x < min_x
376
+ max_x = v.x if v.x > max_x
377
+ min_y = v.y if v.y < min_y
378
+ max_y = v.y if v.y > max_y
453
379
  end
380
+ return nil if max_x == min_x || max_y == min_y
454
381
 
455
- return nil unless best_wall
456
-
457
- # Get wall direction vector
458
- v1 = @map.vertices[best_wall.v1]
459
- v2 = @map.vertices[best_wall.v2]
460
- wall_dx = (v2.x - v1.x).to_f
461
- wall_dy = (v2.y - v1.y).to_f
462
- wall_len = Math.sqrt(wall_dx * wall_dx + wall_dy * wall_dy)
463
- return nil if wall_len == 0
464
-
465
- wall_dx /= wall_len
466
- wall_dy /= wall_len
467
-
468
- # Project movement onto wall direction
469
- dot = dx * wall_dx + dy * wall_dy
470
- [dot * wall_dx, dot * wall_dy]
382
+ @map_bounds = { min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y }
471
383
  end
472
384
 
473
- def update_player_height(x, y)
474
- sector = @map.sector_at(x, y)
475
- return unless sector
476
-
477
- new_floor = sector.floor_height
385
+ def handle_mouse_look
386
+ return unless @mouse_captured
478
387
 
479
- if @player_state
480
- # Detect step: floor height changed since last move
481
- if @last_floor_height && @last_floor_height != new_floor
482
- step = new_floor - @last_floor_height
483
- @player_state.notify_step(step) if step.abs <= 24
484
- end
485
- @last_floor_height = new_floor
388
+ current_x = mouse_x
389
+ if @last_mouse_x
390
+ delta_x = current_x - @last_mouse_x
391
+ # Accumulate rather than turn: the turn is applied by the next ticcmd,
392
+ # so fast mouse motion between tics is summed instead of dropped.
393
+ @pending_turn -= delta_x * MOUSE_SENSITIVITY if delta_x != 0
394
+ end
486
395
 
487
- view_bob = @player_state.view_bob_offset
488
- @renderer.set_z(new_floor + @player_state.viewheight + view_bob)
396
+ # Keep mouse centered
397
+ center_x = width / 2
398
+ if (current_x - center_x).abs > 50
399
+ self.mouse_x = center_x
400
+ @last_mouse_x = center_x
489
401
  else
490
- @renderer.set_z(new_floor + 41)
402
+ @last_mouse_x = current_x
491
403
  end
492
404
  end
493
405
 
494
- def valid_move?(old_x, old_y, new_x, new_y)
495
- # Check if destination is inside a valid sector
496
- sector = @map.sector_at(new_x, new_y)
497
- return false unless sector
406
+ # Gosu calls this before draw; false skips both the blit and the buffer
407
+ # swap, so the main loop keeps generating frames without waiting on the
408
+ # display. Returning true unconditionally restores plain vsync behaviour.
409
+ def needs_redraw?
410
+ return true unless @uncapped_fps
498
411
 
499
- # Check floor height - can't step up too high
500
- floor_height = sector.floor_height
501
- return false if floor_height > @renderer.player_z + 24 # Max step height
412
+ WindowLogic.present_due?(Gosu.milliseconds, @last_present_ms,
413
+ @present_interval_ms, PRESENT_INTERVAL_SLACK)
414
+ end
502
415
 
503
- # Check against blocking linedefs: both circle intersection and path crossing
504
- @map.linedefs.each do |linedef|
505
- if linedef_blocks?(linedef, new_x, new_y)
506
- return false
507
- end
508
- if crosses_blocking_linedef?(old_x, old_y, new_x, new_y, linedef)
509
- return false
510
- end
511
- end
416
+ attr_reader :refresh_hz
512
417
 
513
- # Check against solid things (monsters, barrels, pillars, etc.)
514
- combined_radius = PLAYER_RADIUS
515
- picked = @item_pickup&.picked_up
516
- @map.things.each_with_index do |thing, idx|
517
- next if picked && picked[idx]
518
- next if @combat && @combat.dead?(idx)
519
- thing_radius = SOLID_THING_RADIUS[thing.type]
520
- next unless thing_radius
521
-
522
- dx = new_x - thing.x
523
- dy = new_y - thing.y
524
- min_dist = combined_radius + thing_radius
525
- if dx * dx + dy * dy < min_dist * min_dist
526
- return false
527
- end
528
- end
418
+ # Frames generated vs frames actually shown. With uncapped rendering the
419
+ # first number can run well above the second, which is the whole point.
420
+ def track_frame_rates
421
+ @fps_frames += 1
422
+ now = Time.now
423
+ elapsed = now - @fps_time
424
+ return if elapsed < 0.5
529
425
 
530
- true
426
+ @fps_display = (@fps_frames / elapsed).round(1)
427
+ @present_fps_display = (@present_frames / elapsed).round(1)
428
+ @fps_frames = 0
429
+ @present_frames = 0
430
+ @fps_time = now
531
431
  end
532
432
 
533
- # Check if movement from (x1,y1) to (x2,y2) crosses a blocking linedef
534
- def crosses_blocking_linedef?(x1, y1, x2, y2, linedef)
535
- v1 = @map.vertices[linedef.v1]
536
- v2 = @map.vertices[linedef.v2]
537
-
538
- # One-sided linedef always blocks crossing
539
- if linedef.sidedef_left == 0xFFFF
540
- return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
433
+ def draw
434
+ @present_frames += 1
435
+ @last_present_ms = Gosu.milliseconds
436
+
437
+ # Aim the camera at the local player. The simulation runs at 35 Hz but
438
+ # we draw as fast as we can, so interpolate between the previous tic's
439
+ # pose and the current one by however far into the tic we are.
440
+ @renderer.apply_view(*@player.view_pose(@tic_accumulator))
441
+
442
+ # Intermission screen
443
+ if @intermission
444
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
445
+ @intermission.render(fb)
446
+ present(fb)
447
+ return
541
448
  end
542
449
 
543
- # BLOCKING flag blocks crossing even on two-sided linedefs
544
- if (linedef.flags & 0x0001) != 0
545
- return segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
450
+ # Screen melt effect in progress
451
+ if @screen_melt && !@screen_melt.done?
452
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
453
+ @screen_melt.update(fb)
454
+ present(fb)
455
+ @screen_melt = nil if @screen_melt.done?
456
+ return
546
457
  end
547
458
 
548
- # Two-sided: check if impassable (high step OR low ceiling)
549
- front_side = @map.sidedefs[linedef.sidedef_right]
550
- back_side = @map.sidedefs[linedef.sidedef_left]
551
- front_sector = @map.sectors[front_side.sector]
552
- back_sector = @map.sectors[back_side.sector]
459
+ if @menu&.active?
460
+ hardware = @renderer.respond_to?(:hardware?) && @renderer.hardware?
461
+ if hardware && @menu.needs_background?
462
+ sync_renderer_visual_options
463
+ @renderer.draw_hardware(width, height)
464
+ draw_hardware_hud(menu: true)
465
+ return
466
+ elsif @menu.needs_background?
467
+ # Render game view + HUD as background, then overlay menu on top
468
+ @renderer.render_frame
469
+ @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
470
+ @status_bar&.render(@renderer.framebuffer)
471
+ fb = @renderer.framebuffer.dup
472
+ else
473
+ # Title screen: black background
474
+ fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
475
+ end
476
+ @menu.render(fb, nil)
553
477
 
554
- step = (back_sector.floor_height - front_sector.floor_height).abs
555
- min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
556
- max_floor = [front_sector.floor_height, back_sector.floor_height].max
478
+ # Capture current menu frame for melt transitions
479
+ @last_menu_fb = fb.dup
557
480
 
558
- # Passable if step is small AND enough headroom
559
- return false if step <= 24 && (min_ceiling - max_floor) >= 56
481
+ present(fb)
482
+ elsif @show_map
483
+ draw_automap
484
+ else
485
+ if @renderer.respond_to?(:hardware?) && @renderer.hardware?
486
+ sync_renderer_visual_options
487
+ @renderer.draw_hardware(width, height)
488
+ draw_hardware_hud
489
+ else
490
+ present(@renderer.framebuffer, active_palette_index)
491
+ end
560
492
 
561
- segments_intersect?(x1, y1, x2, y2, v1.x, v1.y, v2.x, v2.y)
493
+ draw_debug_overlay if @show_debug
494
+ draw_net_status if @session
495
+ draw_match_status if @world.deathmatch?
496
+ end
562
497
  end
563
498
 
564
- # Test if line segment (ax1,ay1)-(ax2,ay2) intersects (bx1,by1)-(bx2,by2)
565
- def segments_intersect?(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2)
566
- d1x = ax2 - ax1
567
- d1y = ay2 - ay1
568
- d2x = bx2 - bx1
569
- d2y = by2 - by1
570
-
571
- denom = d1x * d2y - d1y * d2x
572
- return false if denom.abs < 0.001 # Parallel
573
-
574
- dx = bx1 - ax1
575
- dy = by1 - ay1
576
-
577
- t = (dx * d2y - dy * d2x).to_f / denom
578
- u = (dx * d1y - dy * d1x).to_f / denom
579
-
580
- t > 0.0 && t < 1.0 && u >= 0.0 && u <= 1.0
499
+ # Blit one palette-indexed framebuffer to the window. pal_idx selects one
500
+ # of the 14 prebuilt RGBA palettes (0 = normal, 1-8 = pain red, 9 = pickup
501
+ # yellow). All four draw paths funnel through here so the blob->image->draw
502
+ # sequence lives in exactly one place.
503
+ def present(framebuffer, pal_idx = 0)
504
+ active_pal = @all_palette_rgba[pal_idx]
505
+ rgba = framebuffer.map { |idx| active_pal[idx] }.join
506
+ @screen_image = Gosu::Image.from_blob(
507
+ Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba
508
+ )
509
+ @screen_image.draw(0, 0, 0, SCALE, SCALE)
581
510
  end
582
511
 
583
- def linedef_blocks?(linedef, x, y)
584
- v1 = @map.vertices[linedef.v1]
585
- v2 = @map.vertices[linedef.v2]
586
-
587
- # Check if player circle intersects this line
588
- return false unless line_circle_intersect?(v1.x, v1.y, v2.x, v2.y, x, y, PLAYER_RADIUS)
589
-
590
- # One-sided linedef (wall) always blocks
591
- return true if linedef.sidedef_left == 0xFFFF
592
-
593
- # BLOCKING flag (0x0001) blocks even on two-sided linedefs (e.g., windows)
594
- return true if (linedef.flags & 0x0001) != 0
595
-
596
- # Two-sided: check if impassable (high step OR low ceiling)
597
- front_side = @map.sidedefs[linedef.sidedef_right]
598
- back_side = @map.sidedefs[linedef.sidedef_left]
599
-
600
- front_sector = @map.sectors[front_side.sector]
601
- back_sector = @map.sectors[back_side.sector]
602
-
603
- step = (back_sector.floor_height - front_sector.floor_height).abs
604
- min_ceiling = [front_sector.ceiling_height, back_sector.ceiling_height].min
605
- max_floor = [front_sector.floor_height, back_sector.floor_height].max
512
+ # Hardware world rendering bypasses the indexed framebuffer. Build a
513
+ # transparent indexed overlay for the existing weapon/status renderers,
514
+ # preserving the gameplay UI while the world is drawn by OpenGL.
515
+ def draw_hardware_hud(menu: false)
516
+ # The weapon hand is a held object, so the world's light colours it: tint
517
+ # it by the light where the player stands (lamps, the flashlight). Drawn
518
+ # on its own so only it is lit -- never while a menu is up or the player
519
+ # is dead.
520
+ unless menu || @player_state&.dead
521
+ weapon = new_hud_overlay
522
+ @weapon_renderer&.render(weapon)
523
+ blit_hud_overlay(weapon, active_palette_index, weapon_light_tint)
524
+ end
606
525
 
607
- # Block if step too high OR not enough headroom
608
- step > 24 || (min_ceiling - max_floor) < 56
526
+ # HUD, pickup message and menu: readable UI, never lit by the world. The
527
+ # menu also drops the pain/pickup tint (normal palette), as the classic
528
+ # present(fb) path does.
529
+ hud = new_hud_overlay
530
+ @status_bar&.render(hud)
531
+ if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics.positive?
532
+ @doom_font.draw_text(hud, @item_pickup.pickup_message, 2, 2)
533
+ end
534
+ @menu.render(hud, nil) if menu
535
+ blit_hud_overlay(hud, menu ? 0 : active_palette_index, nil)
609
536
  end
610
537
 
611
- def line_circle_intersect?(x1, y1, x2, y2, cx, cy, radius)
612
- # Vector from line start to circle center
613
- dx = cx - x1
614
- dy = cy - y1
615
-
616
- # Line direction vector
617
- line_dx = x2 - x1
618
- line_dy = y2 - y1
619
- line_len_sq = line_dx * line_dx + line_dy * line_dy
620
-
621
- return false if line_len_sq == 0
538
+ def new_hud_overlay
539
+ Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, -1)
540
+ end
622
541
 
623
- # Project circle center onto line, clamped to segment
624
- t = ((dx * line_dx) + (dy * line_dy)) / line_len_sq
625
- t = [[t, 0.0].max, 1.0].min
542
+ # Blit an indexed overlay (transparent = -1) over the GL world, coloured by
543
+ # palette `pal_idx`; `tint` multiplies the whole image (nil = untinted).
544
+ def blit_hud_overlay(overlay, pal_idx, tint)
545
+ palette = @all_palette_rgba[pal_idx]
546
+ rgba = overlay.map { |index| index == -1 ? "\0\0\0\0" : palette[index] }.join
547
+ image = Gosu::Image.from_blob(Render::SCREEN_WIDTH, Render::SCREEN_HEIGHT, rgba)
548
+ if tint
549
+ image.draw(0, 0, 10, SCALE, SCALE, tint)
550
+ else
551
+ image.draw(0, 0, 10, SCALE, SCALE)
552
+ end
553
+ end
626
554
 
627
- # Closest point on line segment
628
- closest_x = x1 + t * line_dx
629
- closest_y = y1 + t * line_dy
555
+ # The world light reaching the player, as a Gosu colour to multiply the
556
+ # weapon by. Only the hardware/ray-traced renderer computes this; the
557
+ # rasterizer leaves the weapon at full brightness.
558
+ def weapon_light_tint
559
+ return nil unless @renderer.respond_to?(:light_color_at)
630
560
 
631
- # Distance from circle center to closest point
632
- dist_x = cx - closest_x
633
- dist_y = cy - closest_y
634
- dist_sq = dist_x * dist_x + dist_y * dist_y
561
+ r, g, b = @renderer.light_color_at(@player.x, @player.y, @player.z)
562
+ Gosu::Color.rgba(hud_tint_byte(r), hud_tint_byte(g), hud_tint_byte(b), 255)
563
+ end
635
564
 
636
- dist_sq < radius * radius
565
+ def hud_tint_byte(value)
566
+ (value * 255.0).clamp(0.0, 255.0).to_i
637
567
  end
638
568
 
639
- def handle_mouse_look
640
- return unless @mouse_captured
569
+ def sync_renderer_visual_options
570
+ return unless @menu
641
571
 
642
- current_x = mouse_x
643
- if @last_mouse_x
644
- delta_x = current_x - @last_mouse_x
645
- @renderer.turn(-delta_x * MOUSE_SENSITIVITY) if delta_x != 0
646
- end
572
+ @renderer.fog_enabled = @menu.options[:fog] if @renderer.respond_to?(:fog_enabled=)
573
+ @renderer.flashlight_enabled = @menu.options[:flashlight] if @renderer.respond_to?(:flashlight_enabled=)
574
+ @renderer.bounces_enabled = @menu.options[:rt_bounces] if @renderer.respond_to?(:bounces_enabled=)
575
+ end
647
576
 
648
- # Keep mouse centered
649
- center_x = width / 2
650
- if (current_x - center_x).abs > 50
651
- self.mouse_x = center_x
652
- @last_mouse_x = center_x
577
+ # Palette for the live game view: red pain flash while taking damage
578
+ # (1-8), yellow flash on item pickup (9), otherwise the normal palette.
579
+ def active_palette_index
580
+ if @item_pickup && @item_pickup.pickup_flash > 0
581
+ 9
582
+ elsif @player_state
583
+ @player_state.damage_count.clamp(0, 8)
653
584
  else
654
- @last_mouse_x = current_x
585
+ 0
655
586
  end
656
587
  end
657
588
 
658
- def draw
659
- if @show_map
660
- draw_automap
661
- else
662
- # Select palette: red tint when taking damage (palettes 1-8)
663
- pal_idx = @player_state ? @player_state.damage_count.clamp(0, 8) : 0
664
- active_pal = @all_palette_rgba[pal_idx]
665
- rgba = @renderer.framebuffer.map { |idx| active_pal[idx] }.join
589
+ # Deathmatch scoreboard. One line of frags, and the result once someone
590
+ # has hit the limit -- the world decides who won, this only reports it.
591
+ def draw_match_status
592
+ score = @world.frags.map { |id, frags| "P#{id + 1} #{frags}" }.join(' ')
593
+ @debug_font.draw_text(score, 20, 20, 2, 1, 1, Gosu::Color::YELLOW)
666
594
 
667
- @screen_image = Gosu::Image.from_blob(
668
- Render::SCREEN_WIDTH,
669
- Render::SCREEN_HEIGHT,
670
- rgba
671
- )
595
+ winner = @world.match_winner
596
+ return unless winner
672
597
 
673
- @screen_image.draw(0, 0, 0, SCALE, SCALE)
598
+ line = "PLAYER #{winner.id + 1} WINS -- #{winner.frags} FRAGS"
599
+ @debug_font.draw_text(line, 22, (height / 3) + 2, 2, 1, 1, Gosu::Color::BLACK)
600
+ @debug_font.draw_text(line, 20, height / 3, 2, 1, 1, Gosu::Color::YELLOW)
601
+ end
674
602
 
675
- draw_debug_overlay if @show_debug
603
+ # A lockstep stall looks exactly like a freeze unless we say otherwise,
604
+ # and a desync means everything on screen is already wrong -- both are
605
+ # worth interrupting the player for.
606
+ def draw_net_status
607
+ lines = WindowLogic.net_status_lines(
608
+ started: @session.started?,
609
+ host: @session.host?,
610
+ waiting: @session.waiting_on,
611
+ stalled_seconds: @session.stalled_seconds,
612
+ stall_threshold: Net::Session::STALL_WARNING_SECONDS,
613
+ desync: @session.desyncs.first
614
+ )
615
+
616
+ return if lines.empty?
617
+
618
+ y = height / 3
619
+ lines.each do |line|
620
+ @debug_font.draw_text(line, 22, y + 2, 2, 1, 1, Gosu::Color::BLACK)
621
+ @debug_font.draw_text(line, 20, y, 2, 1, 1, Gosu::Color::YELLOW)
622
+ y += 30
676
623
  end
677
624
  end
678
625
 
679
626
  def draw_debug_overlay
680
- # Update FPS counter (refresh every 0.5 seconds)
681
- @fps_frames += 1
682
- now = Time.now
683
- elapsed = now - @fps_time
684
- if elapsed >= 0.5
685
- @fps_display = (@fps_frames / elapsed).round(1)
686
- @fps_frames = 0
687
- @fps_time = now
688
- end
689
-
690
- sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
691
- return unless sector
692
-
693
- # Find sector index
694
- sector_idx = @map.sectors.index(sector)
695
-
696
- lines = [
697
- "FPS: #{@fps_display}",
698
- "Sector #{sector_idx}",
699
- "Floor: #{sector.floor_height} (#{sector.floor_texture})",
700
- "Ceil: #{sector.ceiling_height} (#{sector.ceiling_texture})",
701
- "Light: #{sector.light_level}",
702
- "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
703
- "Heading: #{(Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI).round(1)}",
704
- "YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'} (Y to toggle)",
705
- ]
627
+ yjit_status = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'ON' : 'OFF'
628
+ renderer_name = Render::RendererFactory.type_of(@renderer).to_s
629
+ ang = (Math.atan2(@player.sin_angle, @player.cos_angle) * 180.0 / Math::PI).round(1)
630
+
631
+ # Shown vs generated: only worth spelling out when they differ.
632
+ shown = if @uncapped_fps
633
+ "shown #{@present_fps_display} / #{@refresh_hz.round} Hz"
634
+ else
635
+ 'vsync'
636
+ end
637
+
638
+ lines = if @menu&.options&.[](:rubykaigi_mode)
639
+ [
640
+ "#{@fps_display} FPS (#{shown})",
641
+ "YJIT: #{yjit_status} (Y to toggle)",
642
+ "Ruby #{RUBY_VERSION}",
643
+ "Renderer: #{renderer_name}",
644
+ "Map: #{@current_map}",
645
+ "Pos: #{@player.x.round}, #{@player.y.round}",
646
+ "Ang: #{ang}"
647
+ ]
648
+ else
649
+ [
650
+ "FPS: #{@fps_display} (#{shown})",
651
+ "YJIT: #{yjit_status}",
652
+ "Renderer: #{renderer_name}",
653
+ "Pos: #{@player.x.round}, #{@player.y.round}",
654
+ "Ang: #{ang}"
655
+ ]
656
+ end
706
657
 
707
658
  y = 4
708
659
  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
660
+ @debug_font.draw_text(line, 8, y + 2, 1, 1, 1, Gosu::Color::BLACK)
661
+ @debug_font.draw_text(line, 6, y, 1, 1, 1, Gosu::Color::WHITE)
662
+ y += 26
712
663
  end
713
664
  end
714
665
 
715
666
  def button_down(id)
667
+ # Intermission handles input
668
+ if @intermission
669
+ @intermission.handle_key
670
+ if @intermission.finished
671
+ next_map = @intermission.next_map
672
+ @intermission = nil
673
+ if next_map
674
+ load_next_map(next_map)
675
+ else
676
+ # Episode complete - return to menu
677
+ @menu&.show
678
+ end
679
+ end
680
+ return
681
+ end
682
+
683
+ # Menu handles input when active
684
+ if @menu&.active?
685
+ key = case id
686
+ when Gosu::KB_UP then :up
687
+ when Gosu::KB_DOWN then :down
688
+ when Gosu::KB_RETURN, Gosu::KB_SPACE then :enter
689
+ when Gosu::KB_ESCAPE then :escape
690
+ end
691
+ if key
692
+ # Play menu navigation sounds
693
+ case key
694
+ when :up, :down
695
+ @sound&.menu_move
696
+ when :escape
697
+ @sound&.menu_back
698
+ end
699
+
700
+ # Capture old screen before menu state change (for melt effect)
701
+ old_state = @menu.state
702
+ result = @menu.handle_key(key)
703
+ new_state = @menu.state
704
+
705
+ # Trigger melt when transitioning from title to main menu
706
+ if old_state == Game::Menu::STATE_TITLE && new_state == Game::Menu::STATE_MAIN && @last_menu_fb
707
+ if @renderer.respond_to?(:hardware?) && @renderer.hardware?
708
+ # The OpenGL scene has no palette-indexed snapshot for
709
+ # ScreenMelt. Draw it live on the next frame instead of
710
+ # melting toward HardwareRenderer's intentionally black
711
+ # compatibility framebuffer.
712
+ @screen_melt = nil
713
+ else
714
+ # Build the new screen (main menu with game background)
715
+ @renderer.render_frame
716
+ @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
717
+ @status_bar&.render(@renderer.framebuffer)
718
+ new_fb = @renderer.framebuffer.dup
719
+ @menu.render(new_fb, nil)
720
+ @screen_melt = Render::ScreenMelt.new(@last_menu_fb, new_fb)
721
+ end
722
+ end
723
+
724
+ # Play confirmation sound on select
725
+ @sound&.menu_select if key == :enter
726
+
727
+ case result
728
+ when :start_game
729
+ # Restarting the level is local, so it would desync a netgame --
730
+ # for a lockstep peer or an authoritative-server client alike.
731
+ apply_difficulty(@menu.selected_skill) unless @session || @client
732
+ when :resume
733
+ @mouse_captured = true
734
+ SDLKeyboardGrab.grab!
735
+ when :quit
736
+ close
737
+ when Hash
738
+ handle_option_toggle(result[:option], result[:value]) if result[:action] == :toggle_option
739
+ end
740
+ end
741
+ return
742
+ end
743
+
716
744
  case id
717
745
  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
746
+ SDLKeyboardGrab.release!
747
+ @mouse_captured = false
748
+ self.mouse_x = width / 2
749
+ self.mouse_y = height / 2
750
+ @menu&.show
725
751
  when Gosu::MS_LEFT, Gosu::KB_TAB
726
752
  unless @mouse_captured
727
753
  @mouse_captured = true
728
754
  @last_mouse_x = mouse_x
755
+ SDLKeyboardGrab.grab!
729
756
  end
730
757
  when Gosu::KB_Z
731
758
  @show_debug = !@show_debug
759
+ when Gosu::KB_R
760
+ switch_renderer
761
+ when Gosu::KB_B
762
+ @renderer.skip_background_fill = !@renderer.skip_background_fill
732
763
  when Gosu::KB_Y
733
764
  if defined?(RubyVM::YJIT)
734
765
  setup_yjit_toggle
735
766
  if RubyVM::YJIT.enabled?
736
767
  RubyVM::YJIT.disable
737
- puts "YJIT disabled!"
738
768
  else
739
769
  RubyVM::YJIT.enable
740
- puts "YJIT enabled!"
741
770
  end
742
771
  end
772
+ when Gosu::KB_C
773
+ @monster_ai.aggression = !@monster_ai.aggression if @monster_ai
743
774
  when Gosu::KB_M
744
775
  @show_map = !@show_map
745
776
  when Gosu::KB_F12
@@ -747,68 +778,223 @@ module Doom
747
778
  end
748
779
  end
749
780
 
750
- # Sector damage types from DOOM (p_spec.c)
751
- # Type 5: 10 damage, Type 7: 5 damage, Type 4/16: 20 damage
752
- SECTOR_DAMAGE = { 5 => 10, 7 => 5, 4 => 20, 16 => 20, 11 => 20 }.freeze
753
-
754
- def check_sector_damage
755
- sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
756
- return unless sector
757
-
758
- damage = SECTOR_DAMAGE[sector.special]
759
- @player_state.take_damage(damage) if damage
781
+ def switch_renderer
782
+ Render::RendererFactory.type_of(@renderer)
783
+ target = Render::RendererFactory.next_type(@renderer)
784
+ replacement = Render::RendererFactory.build_like(@renderer, target)
785
+ replacement.apply_view(@renderer.player_x, @renderer.player_y,
786
+ @renderer.player_z, @renderer.player_angle)
787
+ replacement.skip_background_fill = @renderer.skip_background_fill
788
+ @renderer = replacement
789
+ puts "Renderer: #{target}"
760
790
  end
761
791
 
762
- def apply_death_tint(framebuffer)
763
- # Death keeps damage_count at max so the pain palette stays red
792
+ # Death keeps damage_count pinned at max so the draw loop selects the red
793
+ # pain palette every frame while dead. The tint itself is applied by
794
+ # palette selection in #draw, not here.
795
+ def hold_pain_palette
764
796
  @player_state.damage_count = 8 if @player_state&.dead
765
797
  end
766
798
 
767
799
  def respawn_player
768
- @player_state.reset
769
- @last_floor_height = nil
770
- @move_momx = 0.0
771
- @move_momy = 0.0
800
+ if @world.deathmatch?
801
+ # Deathmatch death is personal: everyone else is still playing, so
802
+ # only this player comes back. restart_level would revive the monsters
803
+ # and restore the items under them.
804
+ @world.respawn(@player)
805
+ else
806
+ # Single player: death restarts the level, so the world rebuilds its
807
+ # actor subsystems and the cached references must be refreshed.
808
+ @world.restart_level(@player)
809
+ end
810
+ bind_world(@world)
811
+
812
+ # Re-apply active cheats from menu options
813
+ return unless @menu
814
+
815
+ opts = @menu.options
816
+ @player_state.god_mode = opts[:god_mode]
817
+ @player_state.infinite_ammo = opts[:infinite_ammo]
818
+ handle_option_toggle(:all_weapons, true) if opts[:all_weapons]
819
+ apply_rubykaigi_mode if opts[:rubykaigi_mode]
820
+ end
821
+
822
+ def handle_option_toggle(option, value)
823
+ # Belt and braces: the menu already hides these in a netgame, but a
824
+ # cheat applied on one machine only is a real desync, so refuse it
825
+ # here as well rather than trusting the menu to have filtered.
826
+ return if (@session || @client) && Game::Menu::NETGAME_UNSAFE_OPTIONS.include?(option)
827
+
828
+ case option
829
+ when :god_mode
830
+ @player_state.god_mode = value
831
+ @player_state.health = 100 if value
832
+ when :infinite_ammo
833
+ @player_state.infinite_ammo = value
834
+ when :all_weapons
835
+ if value
836
+ # Give all weapons that have sprites loaded
837
+ @gfx_weapons ||= @weapon_renderer&.gfx&.weapons || {}
838
+ (0..7).each do |w|
839
+ name = Game::PlayerState::WEAPON_NAMES[w]
840
+ @player_state.has_weapons[w] = true if @gfx_weapons[name]&.dig(:idle)
841
+ end
842
+ @player_state.ammo_bullets = @player_state.max_bullets
843
+ @player_state.ammo_shells = @player_state.max_shells
844
+ @player_state.ammo_rockets = @player_state.max_rockets
845
+ @player_state.ammo_cells = @player_state.max_cells
846
+ end
847
+ when :uncapped_fps
848
+ @uncapped_fps = value
849
+ # Re-read on re-enable: the window may have moved to a display with a
850
+ # different refresh rate.
851
+ if value
852
+ @refresh_hz = (SDLDisplayMode.refresh_rate || DEFAULT_REFRESH_HZ).to_f
853
+ @present_interval_ms = 1000.0 / @refresh_hz
854
+ end
855
+ when :fullscreen
856
+ self.fullscreen = value if respond_to?(:fullscreen=)
857
+ when :rubykaigi_mode
858
+ apply_rubykaigi_mode if value
859
+ end
860
+ end
861
+
862
+ def apply_rubykaigi_mode
863
+ return unless @menu&.options&.[](:rubykaigi_mode)
772
864
 
773
- # Reset item pickup, combat, and monster AI state
774
- sprites = @combat&.instance_variable_get(:@sprites)
775
- @item_pickup = Game::ItemPickup.new(@map, @player_state) if @item_pickup
776
- @combat = Game::Combat.new(@map, @player_state, sprites) if @combat && sprites
777
- @monster_ai = Game::MonsterAI.new(@map, @combat) if @monster_ai && @combat
865
+ # God mode: invincible for stress-free demos
866
+ @player_state.god_mode = true
867
+ @player_state.health = 100
778
868
 
779
- # Move player to start position
780
- ps = @map.player_start
781
- if ps
782
- @renderer.set_player(ps.x, ps.y, 41, ps.angle)
783
- update_player_height(ps.x, ps.y)
869
+ # All weapons + full ammo
870
+ handle_option_toggle(:all_weapons, true)
871
+ @player_state.infinite_ammo = true
872
+
873
+ # Monsters don't attack (peaceful exploration)
874
+ @monster_ai.aggression = false if @monster_ai
875
+
876
+ # Force debug overlay on (shows FPS + YJIT status)
877
+ @show_debug = true
878
+ end
879
+
880
+ def compute_skill_hidden(skill)
881
+ WindowLogic.skill_hidden(skill, @map.things)
882
+ end
883
+
884
+ def trigger_level_exit(exit_type)
885
+ # Gather stats
886
+ total_monsters = @monster_ai ? @monster_ai.monsters.size : 0
887
+ killed = @combat ? @combat.dead_things.size : 0
888
+
889
+ total_items = Game::ItemPickup::ITEMS.keys.count do |t|
890
+ @map.things.any? { |th| th.type == t }
784
891
  end
892
+ picked = @item_pickup ? @item_pickup.picked_up.size : 0
893
+
894
+ # Secret sectors (type 9) tracked by SectorActions
895
+ total_secrets = @map.sectors.count { |s| s.special == 9 }
896
+ found_secrets = @sector_actions ? @sector_actions.secrets_found.size : 0
897
+
898
+ stats = {
899
+ map: @current_map,
900
+ kills: killed, total_kills: total_monsters,
901
+ items: picked, total_items: total_items,
902
+ secrets: found_secrets, total_secrets: total_secrets,
903
+ time_tics: @leveltime,
904
+ exit_type: exit_type
905
+ }
906
+
907
+ @intermission = Game::Intermission.new(@renderer.wad, @status_bar.gfx, stats)
908
+ end
909
+
910
+ def load_next_map(map_name)
911
+ return unless map_name
912
+
913
+ wad = @renderer.wad
914
+ @current_map = map_name
915
+
916
+ # Load new map data
917
+ map = Map::MapData.load(wad, map_name)
918
+ @map = map
919
+ @map_bounds = nil # Recompute on next automap draw
920
+ @sector_colors = build_sector_colors
921
+
922
+ # Rebuild all systems for new map
923
+ @renderer = Render::RendererFactory.build(
924
+ Render::RendererFactory.type_of(@renderer), wad, map, @renderer.textures,
925
+ @palette, @renderer.colormap, @renderer.flats.values, @renderer.sprites, @animations
926
+ )
927
+ # A new map means a new world; the RNG carries over so the run stays
928
+ # one continuous deterministic sequence across level changes.
929
+ skill_hidden = compute_skill_hidden(@skill || Game::Menu::SKILL_MEDIUM)
930
+ world = Game::World.new(map, sprites: @renderer.sprites, sound: @sound,
931
+ random: @random, skill_hidden: skill_hidden)
932
+ world.damage_multiplier = @damage_multiplier
933
+ world.item_pickup.ammo_multiplier = @skill == Game::Menu::SKILL_BABY ? 2 : 1
934
+ world.monster_ai.aggression = true
935
+ world.monster_ai.damage_multiplier = @damage_multiplier
936
+ world.add_player
937
+
938
+ bind_world(world)
939
+ end
940
+
941
+ def apply_difficulty(skill)
942
+ @skill = skill
943
+ @damage_multiplier = case skill
944
+ when Game::Menu::SKILL_BABY then 0.5
945
+ when Game::Menu::SKILL_EASY then 0.75
946
+ when Game::Menu::SKILL_MEDIUM then 1.0
947
+ when Game::Menu::SKILL_HARD then 1.0
948
+ when Game::Menu::SKILL_NIGHTMARE then 1.5
949
+ else 1.0
950
+ end
951
+
952
+ # Push difficulty into the world before respawning: respawn rebuilds the
953
+ # actor subsystems from the world's settings, so setting them here only
954
+ # would be discarded.
955
+ @world.skill_hidden = compute_skill_hidden(skill)
956
+ @world.damage_multiplier = @damage_multiplier
957
+ @physics.skill_hidden = @world.skill_hidden
958
+
959
+ # Baby mode: start with some armor
960
+ @player_state.armor = 50 if skill == Game::Menu::SKILL_BABY
961
+
962
+ respawn_player
963
+
964
+ @monster_ai.aggression = true
965
+ @monster_ai.damage_multiplier = @damage_multiplier
966
+ # Baby: double ammo from pickups (matching DOOM skill 1)
967
+ @item_pickup.ammo_multiplier = skill == Game::Menu::SKILL_BABY ? 2 : 1
785
968
  end
786
969
 
787
970
  def setup_yjit_toggle
788
971
  return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
789
- require "fiddle"
790
972
 
791
- address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
973
+ require 'fiddle'
974
+
975
+ address = Fiddle::Handle::DEFAULT['rb_yjit_enabled_p']
792
976
  enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)
793
977
 
794
978
  RubyVM::YJIT.singleton_class.prepend(Module.new do
795
979
  define_method(:enable) do |**kwargs|
796
980
  return false if enabled?
797
- return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
981
+ return super(**kwargs) unless RUBY_DESCRIPTION.include?('+YJIT')
982
+
798
983
  enabled_ptr[0] = 1
799
984
  true
800
985
  end
801
986
 
802
987
  define_method(:disable) do
803
988
  return false unless enabled?
989
+
804
990
  enabled_ptr[0] = 0
805
991
  true
806
992
  end
807
993
  end)
808
994
 
809
995
  @yjit_toggle_ready = true
810
- rescue => e
811
- puts "YJIT toggle setup failed: #{e.message}"
996
+ rescue StandardError => e
997
+ warn "YJIT toggle setup failed: #{e.class}: #{e.message}"
812
998
  end
813
999
 
814
1000
  def needs_cursor?
@@ -841,9 +1027,9 @@ module Doom
841
1027
  img.save("#{prefix}.png")
842
1028
 
843
1029
  # Save player state and sector info
844
- sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
1030
+ sector = @map.sector_at(@player.x, @player.y)
845
1031
  sector_idx = sector ? @map.sectors.index(sector) : nil
846
- angle_deg = Math.atan2(@renderer.sin_angle, @renderer.cos_angle) * 180.0 / Math::PI
1032
+ angle_deg = Math.atan2(@player.sin_angle, @player.cos_angle) * 180.0 / Math::PI
847
1033
 
848
1034
  # Sprite diagnostics
849
1035
  sprites_info = @renderer.sprite_diagnostics
@@ -852,14 +1038,18 @@ module Doom
852
1038
 
853
1039
  sprite_lines = nearby.map do |s|
854
1040
  " #{s[:prefix]} type=#{s[:type]} pos=(#{s[:x]},#{s[:y]}) dist=#{s[:dist]} " \
855
- "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
1041
+ "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
856
1042
  "range=#{s[:screen_range]} status=#{s[:status]} " \
857
1043
  "clip_segs=#{s[:clipping_segs]}" \
858
- "#{s[:clipping_detail]&.any? ? "\n clips: #{s[:clipping_detail].map { |c| "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}" }.join(', ')}" : ''}"
1044
+ "#{if s[:clipping_detail]&.any?
1045
+ "\n clips: #{s[:clipping_detail].map do |c|
1046
+ "ds[#{c[:x1]}..#{c[:x2]}] scale=#{c[:scale]} sil=#{c[:sil]}"
1047
+ end.join(', ')}"
1048
+ end}"
859
1049
  end
860
1050
 
861
1051
  File.write("#{prefix}.txt", <<~INFO)
862
- pos: #{@renderer.player_x.round(1)}, #{@renderer.player_y.round(1)}, #{@renderer.player_z.round(1)}
1052
+ pos: #{@player.x.round(1)}, #{@player.y.round(1)}, #{@player.z.round(1)}
863
1053
  angle: #{angle_deg.round(1)}
864
1054
  sector: #{sector_idx}
865
1055
  floor: #{sector&.floor_height} (#{sector&.floor_texture})
@@ -869,8 +1059,6 @@ module Doom
869
1059
  nearby sprites (#{nearby.size}):
870
1060
  #{sprite_lines.join("\n")}
871
1061
  INFO
872
-
873
- puts "Snapshot saved: #{prefix}.png + .txt"
874
1062
  end
875
1063
 
876
1064
  # --- Automap ---
@@ -892,7 +1080,7 @@ module Doom
892
1080
 
893
1081
  def hsv_to_gosu(h, s, v)
894
1082
  c = v * s
895
- x = c * (1 - ((h / 60.0) % 2 - 1).abs)
1083
+ x = c * (1 - (((h / 60.0) % 2) - 1).abs)
896
1084
  m = v - c
897
1085
 
898
1086
  r, g, b = case (h / 60).to_i % 6
@@ -911,36 +1099,32 @@ module Doom
911
1099
  # Black background
912
1100
  Gosu.draw_rect(0, 0, width, height, Gosu::Color::BLACK, 0)
913
1101
 
914
- # Compute map bounds
915
- verts = @map.vertices
916
- min_x = min_y = Float::INFINITY
917
- max_x = max_y = -Float::INFINITY
918
- verts.each do |v|
919
- min_x = v.x if v.x < min_x
920
- max_x = v.x if v.x > max_x
921
- min_y = v.y if v.y < min_y
922
- max_y = v.y if v.y > max_y
923
- end
1102
+ bounds = map_bounds
1103
+ return unless bounds
924
1104
 
1105
+ verts = @map.vertices
1106
+ min_x = bounds[:min_x]
1107
+ max_x = bounds[:max_x]
1108
+ min_y = bounds[:min_y]
1109
+ max_y = bounds[:max_y]
925
1110
  map_w = max_x - min_x
926
1111
  map_h = max_y - min_y
927
- return if map_w == 0 || map_h == 0
928
1112
 
929
1113
  # Scale to fit screen with margin
930
- draw_w = width - MAP_MARGIN * 2
931
- draw_h = height - MAP_MARGIN * 2
1114
+ draw_w = width - (MAP_MARGIN * 2)
1115
+ draw_h = height - (MAP_MARGIN * 2)
932
1116
  scale = [draw_w.to_f / map_w, draw_h.to_f / map_h].min
933
1117
 
934
1118
  # Center the map
935
- offset_x = MAP_MARGIN + (draw_w - map_w * scale) / 2.0
936
- offset_y = MAP_MARGIN + (draw_h - map_h * scale) / 2.0
1119
+ offset_x = MAP_MARGIN + ((draw_w - (map_w * scale)) / 2.0)
1120
+ offset_y = MAP_MARGIN + ((draw_h - (map_h * scale)) / 2.0)
937
1121
 
938
1122
  # World to screen coordinate transform (Y flipped: world Y+ is up, screen Y+ is down)
939
- to_sx = ->(wx) { offset_x + (wx - min_x) * scale }
940
- to_sy = ->(wy) { offset_y + (max_y - wy) * scale }
1123
+ to_sx = ->(wx) { offset_x + ((wx - min_x) * scale) }
1124
+ to_sy = ->(wy) { offset_y + ((max_y - wy) * scale) }
941
1125
 
942
1126
  # Draw linedefs colored by front sector
943
- two_sided_color = Gosu::Color.new(100, 80, 80, 80)
1127
+ Gosu::Color.new(100, 80, 80, 80)
944
1128
 
945
1129
  @map.linedefs.each do |linedef|
946
1130
  v1 = verts[linedef.v1]
@@ -965,27 +1149,27 @@ module Doom
965
1149
  end
966
1150
 
967
1151
  # Draw player
968
- px = to_sx.call(@renderer.player_x)
969
- py = to_sy.call(@renderer.player_y)
1152
+ px = to_sx.call(@player.x)
1153
+ py = to_sy.call(@player.y)
970
1154
 
971
- cos_a = @renderer.cos_angle
972
- sin_a = @renderer.sin_angle
1155
+ cos_a = @player.cos_angle
1156
+ sin_a = @player.sin_angle
973
1157
 
974
1158
  # FOV cone
975
1159
  fov_len = 40.0
976
1160
  half_fov = Math::PI / 4.0 # 45 deg half = 90 deg total
977
1161
 
978
1162
  # Cone edges (in world space, Y+ is up; on screen Y is flipped via to_sy)
979
- left_dx = Math.cos(half_fov) * cos_a - Math.sin(half_fov) * sin_a
980
- left_dy = Math.cos(half_fov) * sin_a + Math.sin(half_fov) * cos_a
981
- right_dx = Math.cos(-half_fov) * cos_a - Math.sin(-half_fov) * sin_a
982
- right_dy = Math.cos(-half_fov) * sin_a + Math.sin(-half_fov) * cos_a
1163
+ left_dx = (Math.cos(half_fov) * cos_a) - (Math.sin(half_fov) * sin_a)
1164
+ left_dy = (Math.cos(half_fov) * sin_a) + (Math.sin(half_fov) * cos_a)
1165
+ right_dx = (Math.cos(-half_fov) * cos_a) - (Math.sin(-half_fov) * sin_a)
1166
+ right_dy = (Math.cos(-half_fov) * sin_a) + (Math.sin(-half_fov) * cos_a)
983
1167
 
984
1168
  # Screen positions for cone tips
985
- lx = px + left_dx * fov_len
986
- ly = py - left_dy * fov_len # negate because screen Y is flipped
987
- rx = px + right_dx * fov_len
988
- ry = py - right_dy * fov_len
1169
+ lx = px + (left_dx * fov_len)
1170
+ ly = py - (left_dy * fov_len) # negate because screen Y is flipped
1171
+ rx = px + (right_dx * fov_len)
1172
+ ry = py - (right_dy * fov_len)
989
1173
 
990
1174
  cone_color = Gosu::Color.new(60, 0, 255, 0)
991
1175
  Gosu.draw_triangle(px, py, cone_color, lx, ly, cone_color, rx, ry, cone_color, 2)
@@ -1001,16 +1185,12 @@ module Doom
1001
1185
 
1002
1186
  # Direction line
1003
1187
  dir_len = 12.0
1004
- dx = px + cos_a * dir_len
1005
- dy = py - sin_a * dir_len
1188
+ dx = px + (cos_a * dir_len)
1189
+ dy = py - (sin_a * dir_len)
1006
1190
  Gosu.draw_line(px, py, Gosu::Color::WHITE, dx, dy, Gosu::Color::WHITE, 3)
1007
1191
  end
1008
1192
 
1009
1193
  # --- End Automap ---
1010
-
1011
- def needs_cursor?
1012
- !@mouse_captured
1013
- end
1014
1194
  end
1015
1195
  end
1016
1196
  end