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