doom 0.8.0 → 0.11.1

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