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.
- checksums.yaml +4 -4
- data/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +134 -24
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +239 -153
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +22 -39
- data/lib/doom/game/item_pickup.rb +87 -75
- data/lib/doom/game/menu.rb +90 -85
- data/lib/doom/game/monster_ai.rb +150 -136
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +38 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +169 -93
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +33 -58
- 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 +604 -832
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +5 -3
- 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 +466 -440
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +7 -7
- 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 +9 -10
- 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 +12 -15
- data/lib/doom/wad/sound.rb +14 -6
- data/lib/doom/wad/sprite.rb +36 -35
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +197 -16
- 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
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
@
|
|
107
|
-
@
|
|
108
|
-
@
|
|
109
|
-
@
|
|
110
|
-
@
|
|
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.
|
|
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
|
-
#
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
262
|
+
@animations&.update(@leveltime)
|
|
208
263
|
@status_bar&.update
|
|
264
|
+
@renderer.hidden_things = @world.hidden_things
|
|
209
265
|
|
|
210
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
292
|
+
return unless @player_state&.dead
|
|
293
|
+
|
|
294
|
+
hold_pain_palette
|
|
268
295
|
end
|
|
269
296
|
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
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
|
-
|
|
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: #{@
|
|
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
|
-
"
|
|
860
|
-
"
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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
|
-
|
|
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
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
994
|
-
|
|
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
|
-
@
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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&.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
@
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
#
|
|
1175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?(
|
|
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
|
-
|
|
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(@
|
|
1030
|
+
sector = @map.sector_at(@player.x, @player.y)
|
|
1253
1031
|
sector_idx = sector ? @map.sectors.index(sector) : nil
|
|
1254
|
-
angle_deg = Math.atan2(@
|
|
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
|
-
|
|
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?
|
|
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: #{@
|
|
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
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
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(@
|
|
1377
|
-
py = to_sy.call(@
|
|
1152
|
+
px = to_sx.call(@player.x)
|
|
1153
|
+
py = to_sy.call(@player.y)
|
|
1378
1154
|
|
|
1379
|
-
cos_a = @
|
|
1380
|
-
sin_a = @
|
|
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
|
|
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
|