doom 0.8.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 +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_window.rb +604 -832
  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 +5 -3
  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 +466 -440
  43. data/lib/doom/render/renderer_factory.rb +50 -0
  44. data/lib/doom/render/screen_melt.rb +7 -7
  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 +9 -10
  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 +12 -15
  57. data/lib/doom/wad/sound.rb +14 -6
  58. data/lib/doom/wad/sprite.rb +36 -35
  59. data/lib/doom/wad/texture.rb +5 -5
  60. data/lib/doom/wad_downloader.rb +38 -56
  61. data/lib/doom.rb +197 -16
  62. metadata +94 -7
@@ -1,113 +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
- # 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)
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)
83
35
  fullscreen = ARGV.include?('--fullscreen') || ARGV.include?('-f')
84
36
  super(Render::SCREEN_WIDTH * SCALE, Render::SCREEN_HEIGHT * SCALE, fullscreen)
85
37
  self.caption = 'Doom Ruby'
86
- 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)
87
39
  SDLKeyboardGrab.setup
88
40
 
89
41
  @renderer = renderer
90
42
  @palette = palette
91
- @map = map
92
- @player_state = player_state
93
43
  @status_bar = status_bar
94
44
  @weapon_renderer = weapon_renderer
95
- @sector_actions = sector_actions
96
45
  @animations = animations
97
- @sector_effects = sector_effects
98
- @item_pickup = item_pickup
99
- @combat = combat
100
- @monster_ai = monster_ai
101
46
  @menu = menu
102
47
  @doom_font = menu&.font
103
48
  @sound = sound_engine
104
- @damage_multiplier = 1.0
105
49
  @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
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
111
60
  @tic_accumulator = 0.0
112
61
  @screen_image = nil
113
62
  @mouse_captured = false
@@ -124,12 +73,22 @@ module Doom
124
73
  @fps_time = Time.now
125
74
  @fps_display = 0.0
126
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
+
127
86
  # Precompute sector colors for automap
128
87
  @sector_colors = build_sector_colors
129
88
 
130
89
  # Pre-build palette RGBA lookups for all 14 palettes (0=normal, 1-8=pain red)
131
90
  @all_palette_rgba = []
132
- wad = renderer.instance_variable_get(:@wad)
91
+ wad = renderer.wad
133
92
  14.times do |pal_idx|
134
93
  pal = Wad::Palette.load(wad, pal_idx)
135
94
  @all_palette_rgba << pal.colors.map { |r, g, b| [r, g, b, 255].pack('CCCC') }
@@ -137,15 +96,146 @@ module Doom
137
96
  @palette_rgba = @all_palette_rgba[0]
138
97
  end
139
98
 
99
+ def advance_local
100
+ while @tic_accumulator >= 1.0
101
+ @tic_accumulator -= 1.0
102
+ @leveltime = @world.run_tic(@player.id => build_ticcmd)
103
+ @item_pickup.update_flash
104
+ end
105
+ end
106
+
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
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
140
+
141
+ def advance_networked
142
+ @session.poll
143
+
144
+ while @tic_accumulator >= 1.0
145
+ @tic_accumulator -= 1.0
146
+ @session.submit(build_ticcmd)
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?
151
+
152
+ ran = @session.run(@world, limit: 10)
153
+ ran.times { @item_pickup.update_flash }
154
+ @leveltime = @world.leveltime
155
+ end
156
+
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?
162
+
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]
168
+ end
169
+ @client_input_window = @client_input_window.last(CLIENT_INPUT_REDUNDANCY)
170
+ @client.send_input(@client_input_window)
171
+ end
172
+
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
179
+
180
+ @last_net_send_at = now
181
+ true
182
+ end
183
+
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
206
+
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
215
+
140
216
  def update
141
217
  # Calculate delta time for smooth animations
142
218
  now = Time.now
143
219
  delta_time = now - @last_update_time
144
220
  @last_update_time = now
145
221
 
146
- # Menu is active -- only update menu animation, skip game logic
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.
147
230
  if @menu&.active?
148
231
  @menu.update
232
+
233
+ if @session
234
+ @tic_accumulator += delta_time * 35.0
235
+ advance_networked
236
+ elsif @client
237
+ advance_server_client
238
+ end
149
239
  return
150
240
  end
151
241
 
@@ -157,104 +247,41 @@ module Doom
157
247
 
158
248
  handle_input(delta_time)
159
249
 
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)
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.
167
253
  @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
254
+ if @session
255
+ advance_networked
256
+ elsif @client
257
+ advance_server_client
258
+ else
259
+ advance_local
204
260
  end
205
- @animations&.update(@leveltime)
206
261
 
207
- # Update HUD animations
262
+ @animations&.update(@leveltime)
208
263
  @status_bar&.update
264
+ @renderer.hidden_things = @world.hidden_things
209
265
 
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
266
+ trigger_level_exit(@world.exit_triggered) if @world.exit_triggered && !@intermission
214
267
 
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
268
+ # Other players are drawn as sprites; the local one never is.
269
+ @renderer.players = @world.players
270
+ @renderer.view_player = @player
242
271
 
243
272
  # Pass combat state to renderer for death frame rendering
244
273
  @renderer.combat = @combat
245
274
  @renderer.monster_ai = @monster_ai
246
275
  @renderer.leveltime = @leveltime
247
276
 
248
- # Render the 3D world
277
+ # Render the 3D world. This is the generated-frame rate: it runs every
278
+ # loop iteration, whether or not the result gets presented.
249
279
  @renderer.render_frame
280
+ track_frame_rates
250
281
 
251
282
  # 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
283
+ @weapon_renderer.render(@renderer.framebuffer) if @weapon_renderer && !@player_state&.dead
284
+ @status_bar.render(@renderer.framebuffer) if @status_bar
258
285
 
259
286
  # Pickup message (drawn into framebuffer with DOOM font, 4 seconds like Chocolate Doom)
260
287
  if @doom_font && @item_pickup&.pickup_message && @item_pickup.message_tics > 0
@@ -262,210 +289,64 @@ module Doom
262
289
  end
263
290
 
264
291
  # Red tint when dead
265
- if @player_state&.dead
266
- apply_death_tint(@renderer.framebuffer)
267
- end
292
+ return unless @player_state&.dead
293
+
294
+ hold_pain_palette
268
295
  end
269
296
 
270
- def handle_input(delta_time)
271
- # Handle respawn when dead
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).
272
305
  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
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
278
309
  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)
310
+ return # No other input while dead
291
311
  end
292
312
 
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
313
+ handle_mouse_look # accumulates into @pending_turn
355
314
  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
315
  end
371
316
 
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
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
405
342
  end
343
+ buttons |= Game::Ticcmd::BTN_USE if Gosu.button_down?(Gosu::KB_SPACE) || Gosu.button_down?(Gosu::KB_E)
406
344
 
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
345
+ Game::Ticcmd.new(forward, side, turn, buttons)
467
346
  end
468
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.
469
350
  def handle_weapon_switch
470
351
  if Gosu.button_down?(Gosu::KB_1)
471
352
  @player_state.switch_weapon(Game::PlayerState::WEAPON_FIST)
@@ -484,257 +365,21 @@ module Doom
484
365
  end
485
366
  end
486
367
 
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]
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
538
371
 
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)
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
646
379
  end
380
+ return nil if max_x == min_x || max_y == min_y
647
381
 
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
382
+ @map_bounds = { min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y }
738
383
  end
739
384
 
740
385
  def handle_mouse_look
@@ -743,7 +388,9 @@ module Doom
743
388
  current_x = mouse_x
744
389
  if @last_mouse_x
745
390
  delta_x = current_x - @last_mouse_x
746
- @renderer.turn(-delta_x * MOUSE_SENSITIVITY) if delta_x != 0
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
747
394
  end
748
395
 
749
396
  # Keep mouse centered
@@ -756,17 +403,47 @@ module Doom
756
403
  end
757
404
  end
758
405
 
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
411
+
412
+ WindowLogic.present_due?(Gosu.milliseconds, @last_present_ms,
413
+ @present_interval_ms, PRESENT_INTERVAL_SLACK)
414
+ end
415
+
416
+ attr_reader :refresh_hz
417
+
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
425
+
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
431
+ end
432
+
759
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
+
760
442
  # Intermission screen
761
443
  if @intermission
762
444
  fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
763
445
  @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)
446
+ present(fb)
770
447
  return
771
448
  end
772
449
 
@@ -774,18 +451,19 @@ module Doom
774
451
  if @screen_melt && !@screen_melt.done?
775
452
  fb = Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, 0)
776
453
  @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)
454
+ present(fb)
783
455
  @screen_melt = nil if @screen_melt.done?
784
456
  return
785
457
  end
786
458
 
787
459
  if @menu&.active?
788
- if @menu.needs_background?
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?
789
467
  # Render game view + HUD as background, then overlay menu on top
790
468
  @renderer.render_frame
791
469
  @weapon_renderer&.render(@renderer.framebuffer) unless @player_state&.dead
@@ -800,64 +478,180 @@ module Doom
800
478
  # Capture current menu frame for melt transitions
801
479
  @last_menu_fb = fb.dup
802
480
 
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)
481
+ present(fb)
809
482
  elsif @show_map
810
483
  draw_automap
811
484
  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)
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
828
492
 
829
493
  draw_debug_overlay if @show_debug
494
+ draw_net_status if @session
495
+ draw_match_status if @world.deathmatch?
830
496
  end
831
497
  end
832
498
 
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
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)
510
+ end
511
+
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
525
+
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)
536
+ end
537
+
538
+ def new_hud_overlay
539
+ Array.new(Render::SCREEN_WIDTH * Render::SCREEN_HEIGHT, -1)
540
+ end
541
+
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
554
+
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)
560
+
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
564
+
565
+ def hud_tint_byte(value)
566
+ (value * 255.0).clamp(0.0, 255.0).to_i
567
+ end
568
+
569
+ def sync_renderer_visual_options
570
+ return unless @menu
571
+
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
576
+
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)
584
+ else
585
+ 0
586
+ end
587
+ end
588
+
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)
594
+
595
+ winner = @world.match_winner
596
+ return unless winner
597
+
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
602
+
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
841
623
  end
624
+ end
842
625
 
626
+ def draw_debug_overlay
843
627
  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)
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
845
637
 
846
638
  lines = if @menu&.options&.[](:rubykaigi_mode)
847
639
  [
848
- "#{@fps_display} FPS",
640
+ "#{@fps_display} FPS (#{shown})",
849
641
  "YJIT: #{yjit_status} (Y to toggle)",
850
642
  "Ruby #{RUBY_VERSION}",
643
+ "Renderer: #{renderer_name}",
851
644
  "Map: #{@current_map}",
852
- "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
853
- "Ang: #{ang}",
645
+ "Pos: #{@player.x.round}, #{@player.y.round}",
646
+ "Ang: #{ang}"
854
647
  ]
855
648
  else
856
649
  [
857
- "FPS: #{@fps_display}",
650
+ "FPS: #{@fps_display} (#{shown})",
858
651
  "YJIT: #{yjit_status}",
859
- "Pos: #{@renderer.player_x.round}, #{@renderer.player_y.round}",
860
- "Ang: #{ang}",
652
+ "Renderer: #{renderer_name}",
653
+ "Pos: #{@player.x.round}, #{@player.y.round}",
654
+ "Ang: #{ang}"
861
655
  ]
862
656
  end
863
657
 
@@ -910,13 +704,21 @@ module Doom
910
704
 
911
705
  # Trigger melt when transitioning from title to main menu
912
706
  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)
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
920
722
  end
921
723
 
922
724
  # Play confirmation sound on select
@@ -924,7 +726,9 @@ module Doom
924
726
 
925
727
  case result
926
728
  when :start_game
927
- apply_difficulty(@menu.selected_skill)
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
928
732
  when :resume
929
733
  @mouse_captured = true
930
734
  SDLKeyboardGrab.grab!
@@ -952,22 +756,21 @@ module Doom
952
756
  end
953
757
  when Gosu::KB_Z
954
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
955
763
  when Gosu::KB_Y
956
764
  if defined?(RubyVM::YJIT)
957
765
  setup_yjit_toggle
958
766
  if RubyVM::YJIT.enabled?
959
767
  RubyVM::YJIT.disable
960
- puts "YJIT disabled!"
961
768
  else
962
769
  RubyVM::YJIT.enable
963
- puts "YJIT enabled!"
964
770
  end
965
771
  end
966
772
  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
773
+ @monster_ai.aggression = !@monster_ai.aggression if @monster_ai
971
774
  when Gosu::KB_M
972
775
  @show_map = !@show_map
973
776
  when Gosu::KB_F12
@@ -975,57 +778,53 @@ module Doom
975
778
  end
976
779
  end
977
780
 
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
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}"
991
790
  end
992
791
 
993
- def apply_death_tint(framebuffer)
994
- # 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
995
796
  @player_state.damage_count = 8 if @player_state&.dead
996
797
  end
997
798
 
998
799
  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
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)
1010
811
 
1011
812
  # 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
813
+ return unless @menu
1019
814
 
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
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]
1026
820
  end
1027
821
 
1028
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
+
1029
828
  case option
1030
829
  when :god_mode
1031
830
  @player_state.god_mode = value
@@ -1035,7 +834,7 @@ module Doom
1035
834
  when :all_weapons
1036
835
  if value
1037
836
  # Give all weapons that have sprites loaded
1038
- @gfx_weapons ||= @weapon_renderer&.instance_variable_get(:@gfx)&.weapons || {}
837
+ @gfx_weapons ||= @weapon_renderer&.gfx&.weapons || {}
1039
838
  (0..7).each do |w|
1040
839
  name = Game::PlayerState::WEAPON_NAMES[w]
1041
840
  @player_state.has_weapons[w] = true if @gfx_weapons[name]&.dig(:idle)
@@ -1045,6 +844,14 @@ module Doom
1045
844
  @player_state.ammo_rockets = @player_state.max_rockets
1046
845
  @player_state.ammo_cells = @player_state.max_cells
1047
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
1048
855
  when :fullscreen
1049
856
  self.fullscreen = value if respond_to?(:fullscreen=)
1050
857
  when :rubykaigi_mode
@@ -1070,22 +877,8 @@ module Doom
1070
877
  @show_debug = true
1071
878
  end
1072
879
 
1073
- # DOOM thing flags: bit 0 = skill 1-2, bit 1 = skill 3, bit 2 = skill 4-5
1074
880
  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
881
+ WindowLogic.skill_hidden(skill, @map.things)
1089
882
  end
1090
883
 
1091
884
  def trigger_level_exit(exit_type)
@@ -1093,9 +886,9 @@ module Doom
1093
886
  total_monsters = @monster_ai ? @monster_ai.monsters.size : 0
1094
887
  killed = @combat ? @combat.dead_things.size : 0
1095
888
 
1096
- total_items = Game::ItemPickup::ITEMS.keys.count { |t|
889
+ total_items = Game::ItemPickup::ITEMS.keys.count do |t|
1097
890
  @map.things.any? { |th| th.type == t }
1098
- }
891
+ end
1099
892
  picked = @item_pickup ? @item_pickup.picked_up.size : 0
1100
893
 
1101
894
  # Secret sectors (type 9) tracked by SectorActions
@@ -1108,56 +901,41 @@ module Doom
1108
901
  items: picked, total_items: total_items,
1109
902
  secrets: found_secrets, total_secrets: total_secrets,
1110
903
  time_tics: @leveltime,
1111
- exit_type: exit_type,
904
+ exit_type: exit_type
1112
905
  }
1113
906
 
1114
- wad = @renderer.instance_variable_get(:@wad)
1115
- @intermission = Game::Intermission.new(wad, @status_bar.instance_variable_get(:@gfx), stats)
907
+ @intermission = Game::Intermission.new(@renderer.wad, @status_bar.gfx, stats)
1116
908
  end
1117
909
 
1118
910
  def load_next_map(map_name)
1119
911
  return unless map_name
1120
912
 
1121
- wad = @renderer.instance_variable_get(:@wad)
913
+ wad = @renderer.wad
1122
914
  @current_map = map_name
1123
915
 
1124
916
  # Load new map data
1125
917
  map = Map::MapData.load(wad, map_name)
1126
918
  @map = map
919
+ @map_bounds = nil # Recompute on next automap draw
920
+ @sector_colors = build_sector_colors
1127
921
 
1128
922
  # 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)
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)
1161
939
  end
1162
940
 
1163
941
  def apply_difficulty(skill)
@@ -1171,52 +949,52 @@ module Doom
1171
949
  else 1.0
1172
950
  end
1173
951
 
1174
- # Compute which things are hidden by this skill level
1175
- @skill_hidden = compute_skill_hidden(skill)
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
1176
958
 
1177
959
  # Baby mode: start with some armor
1178
- if skill == Game::Menu::SKILL_BABY
1179
- @player_state.armor = 50
1180
- end
960
+ @player_state.armor = 50 if skill == Game::Menu::SKILL_BABY
1181
961
 
1182
- if @monster_ai
1183
- @monster_ai.aggression = true
1184
- @monster_ai.damage_multiplier = @damage_multiplier
1185
- end
962
+ respawn_player
1186
963
 
964
+ @monster_ai.aggression = true
965
+ @monster_ai.damage_multiplier = @damage_multiplier
1187
966
  # 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
967
+ @item_pickup.ammo_multiplier = skill == Game::Menu::SKILL_BABY ? 2 : 1
1193
968
  end
1194
969
 
1195
970
  def setup_yjit_toggle
1196
971
  return if @yjit_toggle_ready || !defined?(RubyVM::YJIT)
1197
- require "fiddle"
1198
972
 
1199
- address = Fiddle::Handle::DEFAULT["rb_yjit_enabled_p"]
973
+ require 'fiddle'
974
+
975
+ address = Fiddle::Handle::DEFAULT['rb_yjit_enabled_p']
1200
976
  enabled_ptr = Fiddle::Pointer.new(address, Fiddle::SIZEOF_CHAR)
1201
977
 
1202
978
  RubyVM::YJIT.singleton_class.prepend(Module.new do
1203
979
  define_method(:enable) do |**kwargs|
1204
980
  return false if enabled?
1205
- return super(**kwargs) unless RUBY_DESCRIPTION.include?("+YJIT")
981
+ return super(**kwargs) unless RUBY_DESCRIPTION.include?('+YJIT')
982
+
1206
983
  enabled_ptr[0] = 1
1207
984
  true
1208
985
  end
1209
986
 
1210
987
  define_method(:disable) do
1211
988
  return false unless enabled?
989
+
1212
990
  enabled_ptr[0] = 0
1213
991
  true
1214
992
  end
1215
993
  end)
1216
994
 
1217
995
  @yjit_toggle_ready = true
1218
- rescue => e
1219
- puts "YJIT toggle setup failed: #{e.message}"
996
+ rescue StandardError => e
997
+ warn "YJIT toggle setup failed: #{e.class}: #{e.message}"
1220
998
  end
1221
999
 
1222
1000
  def needs_cursor?
@@ -1249,9 +1027,9 @@ module Doom
1249
1027
  img.save("#{prefix}.png")
1250
1028
 
1251
1029
  # Save player state and sector info
1252
- sector = @map.sector_at(@renderer.player_x, @renderer.player_y)
1030
+ sector = @map.sector_at(@player.x, @player.y)
1253
1031
  sector_idx = sector ? @map.sectors.index(sector) : nil
1254
- 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
1255
1033
 
1256
1034
  # Sprite diagnostics
1257
1035
  sprites_info = @renderer.sprite_diagnostics
@@ -1260,14 +1038,18 @@ module Doom
1260
1038
 
1261
1039
  sprite_lines = nearby.map do |s|
1262
1040
  " #{s[:prefix]} type=#{s[:type]} pos=(#{s[:x]},#{s[:y]}) dist=#{s[:dist]} " \
1263
- "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
1041
+ "screen_x=#{s[:screen_x]} scale=#{s[:sprite_scale]} " \
1264
1042
  "range=#{s[:screen_range]} status=#{s[:status]} " \
1265
1043
  "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(', ')}" : ''}"
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}"
1267
1049
  end
1268
1050
 
1269
1051
  File.write("#{prefix}.txt", <<~INFO)
1270
- 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)}
1271
1053
  angle: #{angle_deg.round(1)}
1272
1054
  sector: #{sector_idx}
1273
1055
  floor: #{sector&.floor_height} (#{sector&.floor_texture})
@@ -1277,8 +1059,6 @@ module Doom
1277
1059
  nearby sprites (#{nearby.size}):
1278
1060
  #{sprite_lines.join("\n")}
1279
1061
  INFO
1280
-
1281
- puts "Snapshot saved: #{prefix}.png + .txt"
1282
1062
  end
1283
1063
 
1284
1064
  # --- Automap ---
@@ -1300,7 +1080,7 @@ module Doom
1300
1080
 
1301
1081
  def hsv_to_gosu(h, s, v)
1302
1082
  c = v * s
1303
- x = c * (1 - ((h / 60.0) % 2 - 1).abs)
1083
+ x = c * (1 - (((h / 60.0) % 2) - 1).abs)
1304
1084
  m = v - c
1305
1085
 
1306
1086
  r, g, b = case (h / 60).to_i % 6
@@ -1319,36 +1099,32 @@ module Doom
1319
1099
  # Black background
1320
1100
  Gosu.draw_rect(0, 0, width, height, Gosu::Color::BLACK, 0)
1321
1101
 
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
1102
+ bounds = map_bounds
1103
+ return unless bounds
1332
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]
1333
1110
  map_w = max_x - min_x
1334
1111
  map_h = max_y - min_y
1335
- return if map_w == 0 || map_h == 0
1336
1112
 
1337
1113
  # Scale to fit screen with margin
1338
- draw_w = width - MAP_MARGIN * 2
1339
- draw_h = height - MAP_MARGIN * 2
1114
+ draw_w = width - (MAP_MARGIN * 2)
1115
+ draw_h = height - (MAP_MARGIN * 2)
1340
1116
  scale = [draw_w.to_f / map_w, draw_h.to_f / map_h].min
1341
1117
 
1342
1118
  # 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
1119
+ offset_x = MAP_MARGIN + ((draw_w - (map_w * scale)) / 2.0)
1120
+ offset_y = MAP_MARGIN + ((draw_h - (map_h * scale)) / 2.0)
1345
1121
 
1346
1122
  # 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 }
1123
+ to_sx = ->(wx) { offset_x + ((wx - min_x) * scale) }
1124
+ to_sy = ->(wy) { offset_y + ((max_y - wy) * scale) }
1349
1125
 
1350
1126
  # Draw linedefs colored by front sector
1351
- two_sided_color = Gosu::Color.new(100, 80, 80, 80)
1127
+ Gosu::Color.new(100, 80, 80, 80)
1352
1128
 
1353
1129
  @map.linedefs.each do |linedef|
1354
1130
  v1 = verts[linedef.v1]
@@ -1373,27 +1149,27 @@ module Doom
1373
1149
  end
1374
1150
 
1375
1151
  # Draw player
1376
- px = to_sx.call(@renderer.player_x)
1377
- py = to_sy.call(@renderer.player_y)
1152
+ px = to_sx.call(@player.x)
1153
+ py = to_sy.call(@player.y)
1378
1154
 
1379
- cos_a = @renderer.cos_angle
1380
- sin_a = @renderer.sin_angle
1155
+ cos_a = @player.cos_angle
1156
+ sin_a = @player.sin_angle
1381
1157
 
1382
1158
  # FOV cone
1383
1159
  fov_len = 40.0
1384
1160
  half_fov = Math::PI / 4.0 # 45 deg half = 90 deg total
1385
1161
 
1386
1162
  # 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
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)
1391
1167
 
1392
1168
  # 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
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)
1397
1173
 
1398
1174
  cone_color = Gosu::Color.new(60, 0, 255, 0)
1399
1175
  Gosu.draw_triangle(px, py, cone_color, lx, ly, cone_color, rx, ry, cone_color, 2)
@@ -1409,16 +1185,12 @@ module Doom
1409
1185
 
1410
1186
  # Direction line
1411
1187
  dir_len = 12.0
1412
- dx = px + cos_a * dir_len
1413
- dy = py - sin_a * dir_len
1188
+ dx = px + (cos_a * dir_len)
1189
+ dy = py - (sin_a * dir_len)
1414
1190
  Gosu.draw_line(px, py, Gosu::Color::WHITE, dx, dy, Gosu::Color::WHITE, 3)
1415
1191
  end
1416
1192
 
1417
1193
  # --- End Automap ---
1418
-
1419
- def needs_cursor?
1420
- !@mouse_captured
1421
- end
1422
1194
  end
1423
1195
  end
1424
1196
  end