doom 0.6.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +138 -25
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +377 -105
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +231 -0
- data/lib/doom/game/item_pickup.rb +108 -60
- data/lib/doom/game/menu.rb +347 -0
- data/lib/doom/game/monster_ai.rb +304 -91
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +47 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +466 -29
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +176 -0
- data/lib/doom/game/state_hash.rb +125 -0
- data/lib/doom/game/ticcmd.rb +61 -0
- data/lib/doom/game/world.rb +420 -0
- data/lib/doom/map/data.rb +95 -0
- data/lib/doom/net/client.rb +204 -0
- data/lib/doom/net/desync_monitor.rb +101 -0
- data/lib/doom/net/game_server.rb +232 -0
- data/lib/doom/net/lockstep.rb +170 -0
- data/lib/doom/net/protocol.rb +350 -0
- data/lib/doom/net/session.rb +282 -0
- data/lib/doom/net/transport.rb +110 -0
- data/lib/doom/platform/gosu_window.rb +820 -640
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +80 -0
- data/lib/doom/render/hardware_renderer.rb +547 -0
- data/lib/doom/render/ray_tracing/bvh.rb +103 -0
- data/lib/doom/render/ray_tracing/material_state.rb +66 -0
- data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
- data/lib/doom/render/ray_tracing_renderer.rb +940 -0
- data/lib/doom/render/renderer.rb +684 -330
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +71 -0
- data/lib/doom/render/spinel_native/kernel.rb +780 -0
- data/lib/doom/render/spinel_native_renderer.rb +162 -0
- data/lib/doom/render/status_bar.rb +14 -10
- data/lib/doom/render/weapon_renderer.rb +15 -17
- data/lib/doom/render/world_mesh.rb +270 -0
- data/lib/doom/render/zbuffer_renderer.rb +151 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/flat.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +7 -3
- data/lib/doom/wad/palette.rb +3 -3
- data/lib/doom/wad/patch.rb +1 -1
- data/lib/doom/wad/reader.rb +59 -10
- data/lib/doom/wad/sound.rb +93 -0
- data/lib/doom/wad/sprite.rb +60 -34
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +235 -12
- metadata +100 -7
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# The simulation, with no dependency on Gosu or any window.
|
|
6
|
+
#
|
|
7
|
+
# Everything that decides what happens in the game lives here and advances
|
|
8
|
+
# only through run_tic. The platform layer is reduced to two jobs: turn
|
|
9
|
+
# input into ticcmds, and draw the result. That split is what makes
|
|
10
|
+
# lockstep multiplayer possible -- the netcode drives run_tic directly,
|
|
11
|
+
# with no display attached, and two peers feeding equal ticcmds reach equal
|
|
12
|
+
# worlds.
|
|
13
|
+
#
|
|
14
|
+
# Sound is emitted but never read back, so it cannot influence state.
|
|
15
|
+
class World
|
|
16
|
+
TIC_SECONDS = 1.0 / 35.0
|
|
17
|
+
SECTOR_DAMAGE_INTERVAL = 32 # Tics between nukage/lava ticks
|
|
18
|
+
USE_DISTANCE = 64.0 # Max distance to use a linedef
|
|
19
|
+
|
|
20
|
+
# Sector specials that hurt the player, damage per interval.
|
|
21
|
+
SECTOR_DAMAGE = {
|
|
22
|
+
4 => 20, 5 => 10, 7 => 5, 16 => 20, 11 => 20
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
MODES = %i[coop deathmatch].freeze
|
|
26
|
+
|
|
27
|
+
attr_reader :map, :players, :random, :leveltime, :mode, :combat, :monster_ai, :item_pickup, :sector_actions,
|
|
28
|
+
:sector_effects, :physics_by_id
|
|
29
|
+
# Frags needed to win, or nil for an open-ended match.
|
|
30
|
+
attr_reader :frag_limit
|
|
31
|
+
attr_accessor :damage_multiplier, :skill_hidden
|
|
32
|
+
|
|
33
|
+
def initialize(map, sprites:, sound: nil, random: Random.new, skill_hidden: {}, mode: :coop,
|
|
34
|
+
frag_limit: nil)
|
|
35
|
+
raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
|
|
36
|
+
|
|
37
|
+
@map = map
|
|
38
|
+
@sprites = sprites
|
|
39
|
+
@sound = sound
|
|
40
|
+
@random = random
|
|
41
|
+
@mode = mode
|
|
42
|
+
@frag_limit = frag_limit
|
|
43
|
+
@skill_hidden = skill_hidden
|
|
44
|
+
@damage_multiplier = 1.0
|
|
45
|
+
@leveltime = 0
|
|
46
|
+
|
|
47
|
+
@players = []
|
|
48
|
+
@physics_by_id = {}
|
|
49
|
+
|
|
50
|
+
@sector_actions = SectorActions.new(map, sound)
|
|
51
|
+
@sector_effects = SectorEffects.new(map, random: random)
|
|
52
|
+
@item_pickup = ItemPickup.new(map, skill_hidden)
|
|
53
|
+
@combat = Combat.new(map, sprites, skill_hidden, sound, random: random)
|
|
54
|
+
@monster_ai = MonsterAI.new(map, @combat, sprites, skill_hidden, sound, random: random)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def deathmatch?
|
|
58
|
+
@mode == :deathmatch
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Spawn a player. With no explicit start, the world picks one for this
|
|
62
|
+
# player id: its own co-op start in co-op, a random one in deathmatch.
|
|
63
|
+
def add_player(start_thing = nil, id: @players.size)
|
|
64
|
+
start_thing ||= spawn_point_for(id)
|
|
65
|
+
raise ArgumentError, 'map has no player start' unless start_thing
|
|
66
|
+
|
|
67
|
+
player = Player.new(id: id)
|
|
68
|
+
player.place(start_thing.x, start_thing.y, PlayerState::VIEWHEIGHT, start_thing.angle)
|
|
69
|
+
|
|
70
|
+
physics = PlayerPhysics.new(@map, player.state)
|
|
71
|
+
physics.skill_hidden = @skill_hidden
|
|
72
|
+
physics.item_pickup = @item_pickup
|
|
73
|
+
physics.combat = @combat
|
|
74
|
+
physics.settle(player, player.x, player.y)
|
|
75
|
+
|
|
76
|
+
@players << player
|
|
77
|
+
@physics_by_id[player.id] = physics
|
|
78
|
+
@combat.players = @players
|
|
79
|
+
player
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def player(id = 0)
|
|
83
|
+
@players.find { |p| p.id == id }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Remove a player mid-match: someone left, or the server timed them out.
|
|
87
|
+
# Pure bookkeeping, no RNG, so it stays deterministic across peers as long
|
|
88
|
+
# as they all remove the same id on the same tic. Monsters targeting the
|
|
89
|
+
# departed player re-pick next tic via MonsterAI's own dead/absent check.
|
|
90
|
+
def remove_player(id)
|
|
91
|
+
player = @players.find { |p| p.id == id }
|
|
92
|
+
return unless player
|
|
93
|
+
|
|
94
|
+
@players.delete(player)
|
|
95
|
+
@physics_by_id.delete(id)
|
|
96
|
+
@combat.players = @players
|
|
97
|
+
@monster_ai.monsters.each { |m| m.target = nil if m.target&.id == id }
|
|
98
|
+
player
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Where player `id` enters the map. Deathmatch draws from the map's DM
|
|
102
|
+
# spawns through the shared RNG, so every peer picks the same one.
|
|
103
|
+
def spawn_point_for(id)
|
|
104
|
+
if @mode == :deathmatch
|
|
105
|
+
starts = @map.deathmatch_starts
|
|
106
|
+
return starts[@random.rand(starts.size)] unless starts.empty?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@map.player_start_for(id)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Bring one player back to life at its spawn point, leaving the rest of
|
|
113
|
+
# the world alone. This is what multiplayer death does: the others are
|
|
114
|
+
# still playing, so monsters and items must not reset under them.
|
|
115
|
+
def respawn(player = primary)
|
|
116
|
+
return unless player
|
|
117
|
+
|
|
118
|
+
player.state.reset
|
|
119
|
+
start = spawn_point_for(player.id)
|
|
120
|
+
return unless start
|
|
121
|
+
|
|
122
|
+
physics = physics_for(player)
|
|
123
|
+
physics.reset
|
|
124
|
+
player.place(start.x, start.y, PlayerState::VIEWHEIGHT, start.angle)
|
|
125
|
+
physics.settle(player, player.x, player.y)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Single-player death: wipe monster/item/projectile state and start the
|
|
129
|
+
# level over. Monsters are rebuilt rather than revived because their HP,
|
|
130
|
+
# death and pain state live in Combat keyed by thing index.
|
|
131
|
+
def restart_level(player = primary)
|
|
132
|
+
return unless player
|
|
133
|
+
|
|
134
|
+
rebuild_actor_subsystems
|
|
135
|
+
physics = physics_for(player)
|
|
136
|
+
physics.item_pickup = @item_pickup
|
|
137
|
+
physics.combat = @combat
|
|
138
|
+
respawn(player)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def physics_for(player)
|
|
142
|
+
@physics_by_id[player.id]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Deathmatch score, player id => frags.
|
|
146
|
+
def frags
|
|
147
|
+
@players.to_h { |p| [p.id, p.frags] }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Who is winning. Ties break on the lowest player id rather than on
|
|
151
|
+
# @players order, so every peer names the same leader.
|
|
152
|
+
def frag_leader
|
|
153
|
+
@players.min_by { |p| [-p.frags, p.id] }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def frag_limit_reached?
|
|
157
|
+
return false unless @frag_limit
|
|
158
|
+
|
|
159
|
+
@players.any? { |p| p.frags >= @frag_limit }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The player who won, or nil while the match is still on.
|
|
163
|
+
#
|
|
164
|
+
# Note what this deliberately does not do: end run_tic. The frag limit is
|
|
165
|
+
# local configuration and, unlike the mode and the seed, it does not
|
|
166
|
+
# travel in the handshake, so a peer started with a different --frags
|
|
167
|
+
# would stop simulating at a different tic and desync every other peer.
|
|
168
|
+
# The world answers the question; the presentation layer says so on
|
|
169
|
+
# screen.
|
|
170
|
+
def match_winner
|
|
171
|
+
frag_limit_reached? ? frag_leader : nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def exit_triggered
|
|
175
|
+
@sector_actions.exit_triggered
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Fingerprint of the whole simulation, for lockstep desync detection.
|
|
179
|
+
def state_hash
|
|
180
|
+
StateHash.of(self)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Same fingerprint broken down by section, so a desync can say which part
|
|
184
|
+
# of the world drifted rather than only that something did.
|
|
185
|
+
def state_hash_sections
|
|
186
|
+
StateHash.sections(self)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Advance the whole simulation by one tic.
|
|
190
|
+
#
|
|
191
|
+
# `cmds` maps player id => Ticcmd; a player with no command coasts on a
|
|
192
|
+
# neutral one rather than repeating its last input, which would make a
|
|
193
|
+
# dropped packet look like a stuck key.
|
|
194
|
+
def run_tic(cmds = {})
|
|
195
|
+
@leveltime += 1
|
|
196
|
+
|
|
197
|
+
@players.each(&:snapshot!)
|
|
198
|
+
@sector_effects.update
|
|
199
|
+
|
|
200
|
+
@players.each do |p|
|
|
201
|
+
run_player_tic(p, cmds[p.id] || Ticcmd.none)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@combat.update
|
|
205
|
+
@monster_ai.update(@players)
|
|
206
|
+
|
|
207
|
+
@players.each { |p| post_tic_player(p) }
|
|
208
|
+
|
|
209
|
+
update_sector_actions
|
|
210
|
+
update_item_pickups
|
|
211
|
+
@leveltime
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def hidden_things
|
|
215
|
+
@skill_hidden.merge(@item_pickup.picked_up)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def rebuild_actor_subsystems
|
|
221
|
+
@item_pickup = ItemPickup.new(@map, @skill_hidden)
|
|
222
|
+
@combat = Combat.new(@map, @sprites, @skill_hidden, @sound, random: @random)
|
|
223
|
+
@monster_ai = MonsterAI.new(@map, @combat, @sprites, @skill_hidden, @sound, random: @random)
|
|
224
|
+
@monster_ai.damage_multiplier = @damage_multiplier
|
|
225
|
+
|
|
226
|
+
@players.each do |p|
|
|
227
|
+
physics = physics_for(p)
|
|
228
|
+
physics.item_pickup = @item_pickup
|
|
229
|
+
physics.combat = @combat
|
|
230
|
+
end
|
|
231
|
+
@combat.players = @players
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def primary
|
|
235
|
+
@players.first
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def run_player_tic(player, cmd)
|
|
239
|
+
state = player.state
|
|
240
|
+
physics = physics_for(player)
|
|
241
|
+
|
|
242
|
+
# Move first, so the bob reflects this tic's momentum. run_tic updates
|
|
243
|
+
# the player's velocity (thrust and friction); feed it to the state so
|
|
244
|
+
# the weapon and view bob come alive -- both read the state's momentum
|
|
245
|
+
# and is_moving, which the World-extraction refactor stopped supplying,
|
|
246
|
+
# freezing the bob. is_moving tracks input (as in DOOM), not coasting.
|
|
247
|
+
physics.run_tic(player, cmd)
|
|
248
|
+
state.set_movement_momentum(player.momx, player.momy)
|
|
249
|
+
state.is_moving = cmd.moving?
|
|
250
|
+
|
|
251
|
+
# View bob feeds eye height, which feeds firing height and monster aim,
|
|
252
|
+
# so it is simulation and runs on the tic clock; update_viewheight is the
|
|
253
|
+
# step-up/down recovery. step_physics then settles vertical position and
|
|
254
|
+
# writes player.z from the eye height, bob included.
|
|
255
|
+
state.update_bob(TIC_SECONDS)
|
|
256
|
+
state.update_view_bob(TIC_SECONDS)
|
|
257
|
+
state.update_viewheight
|
|
258
|
+
step_physics(player, physics)
|
|
259
|
+
state.update_attack
|
|
260
|
+
|
|
261
|
+
fire(player, cmd)
|
|
262
|
+
use(player, cmd)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def step_physics(player, physics)
|
|
266
|
+
return unless physics.floor_z
|
|
267
|
+
|
|
268
|
+
physics.step(player.x, player.y)
|
|
269
|
+
z = physics.eye_z
|
|
270
|
+
player.z = z if z
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def fire(player, cmd)
|
|
274
|
+
return unless cmd.fire?
|
|
275
|
+
|
|
276
|
+
state = player.state
|
|
277
|
+
was_attacking = state.attacking
|
|
278
|
+
state.start_attack
|
|
279
|
+
return unless state.attacking && !was_attacking
|
|
280
|
+
|
|
281
|
+
@combat.fire(player.x, player.y, player.z,
|
|
282
|
+
player.cos_angle, player.sin_angle, state.weapon, player)
|
|
283
|
+
@sound&.weapon_fire(state.weapon)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Use is edge-triggered per player, so holding the key cannot spam doors.
|
|
287
|
+
def use(player, cmd)
|
|
288
|
+
if cmd.use?
|
|
289
|
+
unless player.use_held
|
|
290
|
+
@sector_actions.update_player_position(player.x, player.y)
|
|
291
|
+
try_use(player)
|
|
292
|
+
end
|
|
293
|
+
player.use_held = true
|
|
294
|
+
else
|
|
295
|
+
player.use_held = false
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Cast forward for a usable linedef. Moved off the window because which
|
|
300
|
+
# door a player opens is simulation, and with several players each needs
|
|
301
|
+
# its own ray from its own position.
|
|
302
|
+
def try_use(player)
|
|
303
|
+
px = player.x
|
|
304
|
+
py = player.y
|
|
305
|
+
cos_angle = player.cos_angle
|
|
306
|
+
sin_angle = player.sin_angle
|
|
307
|
+
|
|
308
|
+
best_linedef = nil
|
|
309
|
+
best_idx = nil
|
|
310
|
+
best_dist = Float::INFINITY
|
|
311
|
+
|
|
312
|
+
@map.linedefs.each_with_index do |linedef, idx|
|
|
313
|
+
next if linedef.special == 0 # Skip non-special linedefs
|
|
314
|
+
|
|
315
|
+
v1 = @map.vertices[linedef.v1]
|
|
316
|
+
v2 = @map.vertices[linedef.v2]
|
|
317
|
+
|
|
318
|
+
dist = Geometry.point_to_segment_distance(px, py, v1.x, v1.y, v2.x, v2.y)
|
|
319
|
+
next if dist > USE_DISTANCE
|
|
320
|
+
next if dist >= best_dist
|
|
321
|
+
next unless facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
|
|
322
|
+
|
|
323
|
+
best_linedef = linedef
|
|
324
|
+
best_idx = idx
|
|
325
|
+
best_dist = dist
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
@sector_actions.use_linedef(best_linedef, best_idx, player.state.keys) if best_linedef
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def facing_linedef?(px, py, cos_angle, sin_angle, v1, v2)
|
|
332
|
+
line_dx = v2.x - v1.x
|
|
333
|
+
line_dy = v2.y - v1.y
|
|
334
|
+
|
|
335
|
+
# Normal points to the right of the line direction
|
|
336
|
+
normal_x = -line_dy
|
|
337
|
+
normal_y = line_dx
|
|
338
|
+
|
|
339
|
+
len = Math.sqrt((normal_x * normal_x) + (normal_y * normal_y))
|
|
340
|
+
return false if len == 0
|
|
341
|
+
|
|
342
|
+
normal_x /= len
|
|
343
|
+
normal_y /= len
|
|
344
|
+
|
|
345
|
+
# Flip the normal if the player is on the back side, so we always check
|
|
346
|
+
# facing toward the line.
|
|
347
|
+
side = ((px - v1.x) * normal_x) + ((py - v1.y) * normal_y)
|
|
348
|
+
if side < 0
|
|
349
|
+
normal_x = -normal_x
|
|
350
|
+
normal_y = -normal_y
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
dot_facing = (cos_angle * -normal_x) + (sin_angle * -normal_y)
|
|
354
|
+
dot_facing > 0.2 # ~78 degree cone, matching DOOM's generous use check
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def post_tic_player(player)
|
|
358
|
+
state = player.state
|
|
359
|
+
health_before = @last_health ||= {}
|
|
360
|
+
before = health_before[player.id] || state.health
|
|
361
|
+
|
|
362
|
+
if state.health < before
|
|
363
|
+
state.dead ? @sound&.player_death : @sound&.player_pain
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
state.update_damage_count
|
|
367
|
+
state.death_tic += 1 if state.dead
|
|
368
|
+
|
|
369
|
+
apply_sector_damage(player) if !state.dead && (@leveltime % SECTOR_DAMAGE_INTERVAL).zero?
|
|
370
|
+
|
|
371
|
+
# Record the health baseline last, after sector damage, so this tic's
|
|
372
|
+
# nukage/lava hit -- which apply_sector_damage already sounded -- is not
|
|
373
|
+
# re-detected as a fresh drop next tic and sounded a second time.
|
|
374
|
+
health_before[player.id] = state.health
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def apply_sector_damage(player)
|
|
378
|
+
sector = @map.sector_at(player.x, player.y)
|
|
379
|
+
return unless sector
|
|
380
|
+
|
|
381
|
+
damage = SECTOR_DAMAGE[sector.special]
|
|
382
|
+
return unless damage
|
|
383
|
+
|
|
384
|
+
player.state.take_damage((damage * @damage_multiplier).to_i)
|
|
385
|
+
@sound&.player_pain
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def update_sector_actions
|
|
389
|
+
# Feed every player's position, in a fixed id order, so walk triggers
|
|
390
|
+
# fire for each player and the tic stays reproducible across peers.
|
|
391
|
+
ordered = @players.sort_by(&:id)
|
|
392
|
+
@sector_actions.update_players(ordered.map { |p| [p.id, p.x, p.y] })
|
|
393
|
+
@sector_actions.update
|
|
394
|
+
|
|
395
|
+
# Each player teleports to its own destination, in id order.
|
|
396
|
+
@sector_actions.pop_teleports.sort_by { |id, _| id }.each do |id, dest|
|
|
397
|
+
p = player(id)
|
|
398
|
+
next unless p
|
|
399
|
+
|
|
400
|
+
physics = physics_for(p)
|
|
401
|
+
p.place(dest[:x], dest[:y], p.z, dest[:angle])
|
|
402
|
+
physics.reset
|
|
403
|
+
physics.settle(p, dest[:x], dest[:y])
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def update_item_pickups
|
|
408
|
+
return if @players.empty?
|
|
409
|
+
|
|
410
|
+
picked_before = @item_pickup.picked_up.size
|
|
411
|
+
@players.each { |p| @item_pickup.update(p) unless p.state.dead }
|
|
412
|
+
return unless @sound && @item_pickup.picked_up.size > picked_before
|
|
413
|
+
|
|
414
|
+
msg = @item_pickup.pickup_message
|
|
415
|
+
# Weapon pickup messages end with '!' in DOOM's string table.
|
|
416
|
+
msg&.include?('!') ? @sound.weapon_pickup : @sound.item_pickup
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
data/lib/doom/map/data.rb
CHANGED
|
@@ -105,6 +105,10 @@ module Doom
|
|
|
105
105
|
map.load_nodes(wad.read_lump_at(wad.directory[lump_idx + 7]))
|
|
106
106
|
map.load_sectors(wad.read_lump_at(wad.directory[lump_idx + 8]))
|
|
107
107
|
|
|
108
|
+
# BLOCKMAP is at lump +10 (REJECT is +9). Optional -- parse defensively.
|
|
109
|
+
blockmap_entry = wad.directory[lump_idx + 10]
|
|
110
|
+
map.load_blockmap(wad.read_lump_at(blockmap_entry)) if blockmap_entry && blockmap_entry.name == 'BLOCKMAP'
|
|
111
|
+
|
|
108
112
|
map
|
|
109
113
|
end
|
|
110
114
|
|
|
@@ -235,10 +239,101 @@ module Doom
|
|
|
235
239
|
end
|
|
236
240
|
end
|
|
237
241
|
|
|
242
|
+
# Thing types 1-4 are the co-op starts for players 1-4; type 11 is a
|
|
243
|
+
# deathmatch spawn point. Every stock WAD carries all of them -- until
|
|
244
|
+
# now only type 1 was ever read.
|
|
245
|
+
COOP_START_TYPES = (1..4).to_a.freeze
|
|
246
|
+
DEATHMATCH_START_TYPE = 11
|
|
247
|
+
|
|
238
248
|
def player_start
|
|
239
249
|
@things.find { |t| t.type == 1 }
|
|
240
250
|
end
|
|
241
251
|
|
|
252
|
+
# Co-op starts indexed by player number, so player N spawns at start N.
|
|
253
|
+
# Sparse on maps that only define some: index 2 may be nil while 0 is not.
|
|
254
|
+
def player_starts
|
|
255
|
+
COOP_START_TYPES.map { |type| @things.find { |t| t.type == type } }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Start for player `id` (0-based), falling back to player 1's start so a
|
|
259
|
+
# map without enough co-op starts is still playable.
|
|
260
|
+
def player_start_for(id)
|
|
261
|
+
player_starts[id] || player_start
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def deathmatch_starts
|
|
265
|
+
@things.select { |t| t.type == DEATHMATCH_START_TYPE }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# BLOCKMAP: 128-unit grid index into linedefs. Each block lists the
|
|
269
|
+
# linedefs that touch it. Used for fast collision lookup.
|
|
270
|
+
BLOCKMAP_BLOCK_SIZE = 128
|
|
271
|
+
|
|
272
|
+
def load_blockmap(data)
|
|
273
|
+
return if data.nil? || data.size < 8
|
|
274
|
+
|
|
275
|
+
@blockmap_origin_x = data[0, 2].unpack1('s<')
|
|
276
|
+
@blockmap_origin_y = data[2, 2].unpack1('s<')
|
|
277
|
+
@blockmap_cols = data[4, 2].unpack1('s<')
|
|
278
|
+
@blockmap_rows = data[6, 2].unpack1('s<')
|
|
279
|
+
return if @blockmap_cols <= 0 || @blockmap_rows <= 0
|
|
280
|
+
|
|
281
|
+
block_count = @blockmap_cols * @blockmap_rows
|
|
282
|
+
@blockmap_blocks = Array.new(block_count)
|
|
283
|
+
|
|
284
|
+
block_count.times do |i|
|
|
285
|
+
offset_words = data[8 + (i * 2), 2].unpack1('v')
|
|
286
|
+
byte_offset = offset_words * 2
|
|
287
|
+
linedefs_in_block = []
|
|
288
|
+
ptr = byte_offset
|
|
289
|
+
# Skip the leading 0x0000 sentinel that some blockmaps include.
|
|
290
|
+
ptr += 2 if ptr + 2 <= data.size && data[ptr, 2].unpack1('s<') == 0
|
|
291
|
+
while ptr + 2 <= data.size
|
|
292
|
+
idx = data[ptr, 2].unpack1('s<')
|
|
293
|
+
break if idx == -1 # 0xFFFF terminator
|
|
294
|
+
|
|
295
|
+
linedefs_in_block << idx
|
|
296
|
+
ptr += 2
|
|
297
|
+
end
|
|
298
|
+
@blockmap_blocks[i] = linedefs_in_block
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Yield each linedef whose block overlaps the bounding box (min_x, min_y,
|
|
303
|
+
# max_x, max_y). Yields each linedef at most once per call. Falls back
|
|
304
|
+
# to iterating all linedefs if no blockmap is loaded.
|
|
305
|
+
def each_linedef_near(min_x, min_y, max_x, max_y, &)
|
|
306
|
+
unless @blockmap_blocks
|
|
307
|
+
@linedefs.each(&)
|
|
308
|
+
return
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
bx0 = ((min_x - @blockmap_origin_x) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_cols - 1)
|
|
312
|
+
bx1 = ((max_x - @blockmap_origin_x) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_cols - 1)
|
|
313
|
+
by0 = ((min_y - @blockmap_origin_y) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_rows - 1)
|
|
314
|
+
by1 = ((max_y - @blockmap_origin_y) / BLOCKMAP_BLOCK_SIZE).floor.clamp(0, @blockmap_rows - 1)
|
|
315
|
+
|
|
316
|
+
seen = {}
|
|
317
|
+
by0.upto(by1) do |by|
|
|
318
|
+
row_base = by * @blockmap_cols
|
|
319
|
+
bx0.upto(bx1) do |bx|
|
|
320
|
+
indices = @blockmap_blocks[row_base + bx]
|
|
321
|
+
next unless indices
|
|
322
|
+
|
|
323
|
+
indices.each do |idx|
|
|
324
|
+
next if seen[idx]
|
|
325
|
+
|
|
326
|
+
seen[idx] = true
|
|
327
|
+
yield @linedefs[idx]
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def blockmap_loaded?
|
|
334
|
+
!@blockmap_blocks.nil?
|
|
335
|
+
end
|
|
336
|
+
|
|
242
337
|
# Find the sector at a given position by traversing the BSP tree
|
|
243
338
|
def sector_at(x, y)
|
|
244
339
|
subsector = subsector_at(x, y)
|