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