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,584 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Game
|
|
5
|
+
# A complete, restorable copy of a World, as bytes.
|
|
6
|
+
#
|
|
7
|
+
# This is deliberately not the same thing as StateHash. The hash is a
|
|
8
|
+
# fingerprint: enough to notice two worlds have diverged. A snapshot has to
|
|
9
|
+
# do the harder thing -- carry enough that a fresh world restored from it
|
|
10
|
+
# goes on to simulate byte-for-byte identically. So it captures state the
|
|
11
|
+
# hash skips because it does not change the current tic's picture: monster
|
|
12
|
+
# attack timers, sector-effect countdowns, the full projectile list, door
|
|
13
|
+
# and lift animations in progress.
|
|
14
|
+
#
|
|
15
|
+
# Why it exists: a player joining a running match cannot replay the round
|
|
16
|
+
# from the start -- at 30 players that is nearly a minute of catch-up and
|
|
17
|
+
# it grows with the match. Handed a snapshot, they resume in one step.
|
|
18
|
+
#
|
|
19
|
+
# This module reaches directly into subsystem internals. That coupling is
|
|
20
|
+
# intentional and concentrated here, in one place, so the wire format lives
|
|
21
|
+
# in one file rather than smeared across eight. The round-trip spec is what
|
|
22
|
+
# keeps it honest: it restores a snapshot and runs both worlds forward,
|
|
23
|
+
# failing the moment a field was missed.
|
|
24
|
+
module Snapshot
|
|
25
|
+
VERSION = 1
|
|
26
|
+
|
|
27
|
+
# A cursor-based binary writer, so record offsets are never hand-managed.
|
|
28
|
+
class Writer
|
|
29
|
+
def initialize
|
|
30
|
+
@buf = +''.b
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def u8(v) = append([v & 0xff].pack('C'))
|
|
34
|
+
def u16(v) = append([v & 0xffff].pack('S<'))
|
|
35
|
+
def u32(v) = append([v & 0xffffffff].pack('L<'))
|
|
36
|
+
def i64(v) = append([v].pack('q<'))
|
|
37
|
+
def f64(v) = append([v].pack('E'))
|
|
38
|
+
def bool(v) = u8(v ? 1 : 0)
|
|
39
|
+
|
|
40
|
+
def str(s)
|
|
41
|
+
bytes = s.to_s.b
|
|
42
|
+
u16(bytes.bytesize)
|
|
43
|
+
append(bytes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# length-prefixed homogeneous array
|
|
47
|
+
def list(items, &)
|
|
48
|
+
u32(items.size)
|
|
49
|
+
items.each(&)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def to_s = @buf.dup
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def append(bytes)
|
|
57
|
+
@buf << bytes
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
class Reader
|
|
63
|
+
def initialize(bytes)
|
|
64
|
+
@buf = bytes.b
|
|
65
|
+
@pos = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def u8 = take(1).unpack1('C')
|
|
69
|
+
def u16 = take(2).unpack1('S<')
|
|
70
|
+
def u32 = take(4).unpack1('L<')
|
|
71
|
+
def i64 = take(8).unpack1('q<')
|
|
72
|
+
def f64 = take(8).unpack1('E')
|
|
73
|
+
def bool = u8 == 1
|
|
74
|
+
|
|
75
|
+
def str
|
|
76
|
+
take(u16)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def list(&)
|
|
80
|
+
Array.new(u32, &)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def take(n)
|
|
86
|
+
raise 'snapshot truncated' if @pos + n > @buf.bytesize
|
|
87
|
+
|
|
88
|
+
slice = @buf.byteslice(@pos, n)
|
|
89
|
+
@pos += n
|
|
90
|
+
slice
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
NO_ID = 0xff # sentinel for "no player" (target/owner)
|
|
95
|
+
PROJ_TARGETS = { monsters: 0, player: 1 }.freeze
|
|
96
|
+
PROJ_TARGET_NAMES = PROJ_TARGETS.invert.freeze
|
|
97
|
+
|
|
98
|
+
module_function
|
|
99
|
+
|
|
100
|
+
# Serialize a whole world to bytes.
|
|
101
|
+
def dump(world)
|
|
102
|
+
w = Writer.new
|
|
103
|
+
w.u8(VERSION)
|
|
104
|
+
dump_header(w, world)
|
|
105
|
+
dump_map_state(w, world.map)
|
|
106
|
+
dump_players(w, world)
|
|
107
|
+
dump_combat(w, world.combat)
|
|
108
|
+
dump_monsters(w, world.monster_ai)
|
|
109
|
+
dump_item_pickup(w, world.item_pickup)
|
|
110
|
+
dump_sector_effects(w, world.sector_effects)
|
|
111
|
+
dump_sector_actions(w, world.sector_actions, world.map)
|
|
112
|
+
w.to_s
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Rebuild a world from bytes. The caller supplies the environment the
|
|
116
|
+
# joiner already has -- a freshly loaded map for the same level, the
|
|
117
|
+
# sprite set, and a sound engine (or nil headless). Everything that
|
|
118
|
+
# varies during play comes out of the bytes.
|
|
119
|
+
def load(bytes, map:, sprites:, sound: nil)
|
|
120
|
+
r = Reader.new(bytes)
|
|
121
|
+
version = r.u8
|
|
122
|
+
raise "unknown snapshot version #{version}" unless version == VERSION
|
|
123
|
+
|
|
124
|
+
header = load_header(r)
|
|
125
|
+
world = World.new(map, sprites: sprites, sound: sound,
|
|
126
|
+
random: Random.new(0), mode: header[:mode],
|
|
127
|
+
frag_limit: header[:frag_limit])
|
|
128
|
+
world.instance_variable_set(:@leveltime, header[:leveltime])
|
|
129
|
+
world.instance_variable_set(:@damage_multiplier, header[:damage_multiplier])
|
|
130
|
+
# World.rebuild_actor_subsystems mirrors the skill's damage factor onto
|
|
131
|
+
# the monster AI; a fresh world built here for a load skips that path, so
|
|
132
|
+
# set it explicitly or restored monsters would deal 1.0x damage.
|
|
133
|
+
world.monster_ai.damage_multiplier = header[:damage_multiplier]
|
|
134
|
+
world.random.index = header[:random_index]
|
|
135
|
+
|
|
136
|
+
load_map_state(r, map)
|
|
137
|
+
load_players(r, world, map)
|
|
138
|
+
load_combat(r, world.combat, header[:combat_tic])
|
|
139
|
+
relink_projectile_owners(world)
|
|
140
|
+
load_monsters(r, world, header[:monster_tic], header[:aggression])
|
|
141
|
+
load_item_pickup(r, world.item_pickup)
|
|
142
|
+
load_sector_effects(r, world.sector_effects)
|
|
143
|
+
load_sector_actions(r, world.sector_actions, map)
|
|
144
|
+
world
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# --- header -------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def dump_header(w, world)
|
|
150
|
+
w.u32(world.leveltime)
|
|
151
|
+
w.u8(World::MODES.index(world.mode))
|
|
152
|
+
w.u32(world.frag_limit || 0)
|
|
153
|
+
w.f64(world.damage_multiplier)
|
|
154
|
+
w.u8(world.random.index)
|
|
155
|
+
w.u32(world.combat.instance_variable_get(:@tic))
|
|
156
|
+
w.u32(world.monster_ai.instance_variable_get(:@tic_counter))
|
|
157
|
+
w.bool(world.monster_ai.aggression)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def load_header(r)
|
|
161
|
+
{
|
|
162
|
+
leveltime: r.u32,
|
|
163
|
+
mode: World::MODES[r.u8],
|
|
164
|
+
frag_limit: (fl = r.u32).zero? ? nil : fl,
|
|
165
|
+
damage_multiplier: r.f64,
|
|
166
|
+
random_index: r.u8,
|
|
167
|
+
combat_tic: r.u32,
|
|
168
|
+
monster_tic: r.u32,
|
|
169
|
+
aggression: r.bool
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# --- map: the parts doors, lifts and light effects mutate ---------------
|
|
174
|
+
|
|
175
|
+
def dump_map_state(w, map)
|
|
176
|
+
w.list(map.sectors) do |sec|
|
|
177
|
+
w.i64(sec.light_level)
|
|
178
|
+
w.i64(sec.floor_height)
|
|
179
|
+
w.i64(sec.ceiling_height)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def load_map_state(r, map)
|
|
184
|
+
count = r.u32
|
|
185
|
+
count.times do |i|
|
|
186
|
+
light = r.i64
|
|
187
|
+
floor = r.i64
|
|
188
|
+
ceil = r.i64
|
|
189
|
+
sec = map.sectors[i]
|
|
190
|
+
next unless sec
|
|
191
|
+
|
|
192
|
+
sec.light_level = light
|
|
193
|
+
sec.floor_height = floor
|
|
194
|
+
sec.ceiling_height = ceil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- players ------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def dump_players(w, world)
|
|
201
|
+
w.list(world.players) do |p|
|
|
202
|
+
w.u8(p.id)
|
|
203
|
+
w.i64(p.frags)
|
|
204
|
+
w.bool(p.use_held)
|
|
205
|
+
w.f64(p.x)
|
|
206
|
+
w.f64(p.y)
|
|
207
|
+
w.f64(p.z)
|
|
208
|
+
w.f64(p.angle)
|
|
209
|
+
w.f64(p.momx)
|
|
210
|
+
w.f64(p.momy)
|
|
211
|
+
|
|
212
|
+
phys = world.physics_for(p)
|
|
213
|
+
w.f64(phys.floor_z || 0.0)
|
|
214
|
+
w.bool(!phys.floor_z.nil?)
|
|
215
|
+
w.f64(phys.momz)
|
|
216
|
+
|
|
217
|
+
dump_player_state(w, p.state)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def load_players(r, world, map)
|
|
222
|
+
count = r.u32
|
|
223
|
+
count.times do
|
|
224
|
+
id = r.u8
|
|
225
|
+
player = world.add_player(map.player_start_for(id) || map.player_start, id: id)
|
|
226
|
+
player.frags = r.i64
|
|
227
|
+
player.use_held = r.bool
|
|
228
|
+
player.x = r.f64
|
|
229
|
+
player.y = r.f64
|
|
230
|
+
player.z = r.f64
|
|
231
|
+
player.angle = r.f64
|
|
232
|
+
player.momx = r.f64
|
|
233
|
+
player.momy = r.f64
|
|
234
|
+
player.snapshot! # collapse interpolation onto the restored pose
|
|
235
|
+
|
|
236
|
+
phys = world.physics_for(player)
|
|
237
|
+
floor_z = r.f64
|
|
238
|
+
has_floor = r.bool
|
|
239
|
+
phys.instance_variable_set(:@floor_z, has_floor ? floor_z : nil)
|
|
240
|
+
phys.instance_variable_set(:@momz, r.f64)
|
|
241
|
+
|
|
242
|
+
load_player_state(r, player.state)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
PLAYER_STATE_INTS = %i[
|
|
247
|
+
health armor max_health max_armor
|
|
248
|
+
ammo_bullets ammo_shells ammo_rockets ammo_cells
|
|
249
|
+
max_bullets max_shells max_rockets max_cells
|
|
250
|
+
weapon attack_frame attack_tics death_tic damage_count
|
|
251
|
+
].freeze
|
|
252
|
+
|
|
253
|
+
PLAYER_STATE_BOOLS = %i[attacking dead god_mode infinite_ammo is_moving].freeze
|
|
254
|
+
|
|
255
|
+
PLAYER_STATE_FLOATS = %i[
|
|
256
|
+
bob_angle bob_amount viewheight deltaviewheight view_bob_offset
|
|
257
|
+
momx momy thrust_x thrust_y view_bob_angle
|
|
258
|
+
].freeze
|
|
259
|
+
|
|
260
|
+
def dump_player_state(w, s)
|
|
261
|
+
PLAYER_STATE_INTS.each { |f| w.i64(s.instance_variable_get(:"@#{f}")) }
|
|
262
|
+
PLAYER_STATE_BOOLS.each { |f| w.bool(s.instance_variable_get(:"@#{f}")) }
|
|
263
|
+
PLAYER_STATE_FLOATS.each { |f| w.f64(s.instance_variable_get(:"@#{f}")) }
|
|
264
|
+
|
|
265
|
+
weapons = s.has_weapons
|
|
266
|
+
w.u16(weapons.each_index.sum { |i| weapons[i] ? (1 << i) : 0 })
|
|
267
|
+
|
|
268
|
+
keys = s.keys
|
|
269
|
+
w.list(keys.keys) do |k|
|
|
270
|
+
w.str(k.to_s)
|
|
271
|
+
w.bool(keys[k])
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def load_player_state(r, s)
|
|
276
|
+
PLAYER_STATE_INTS.each { |f| s.instance_variable_set(:"@#{f}", r.i64) }
|
|
277
|
+
PLAYER_STATE_BOOLS.each { |f| s.instance_variable_set(:"@#{f}", r.bool) }
|
|
278
|
+
PLAYER_STATE_FLOATS.each { |f| s.instance_variable_set(:"@#{f}", r.f64) }
|
|
279
|
+
|
|
280
|
+
mask = r.u16
|
|
281
|
+
weapons = s.has_weapons
|
|
282
|
+
weapons.each_index { |i| weapons[i] = mask[i] == 1 }
|
|
283
|
+
|
|
284
|
+
keys = {}
|
|
285
|
+
r.list do
|
|
286
|
+
name = r.str
|
|
287
|
+
keys[name.to_sym] = r.bool
|
|
288
|
+
end
|
|
289
|
+
s.keys.replace(keys)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# --- combat -------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def dump_combat(w, combat)
|
|
295
|
+
w.list(combat.instance_variable_get(:@monster_hp).to_a) do |idx, hp|
|
|
296
|
+
w.u32(idx)
|
|
297
|
+
w.i64(hp)
|
|
298
|
+
end
|
|
299
|
+
w.list(combat.dead_things.to_a) do |idx, info|
|
|
300
|
+
w.u32(idx)
|
|
301
|
+
w.u32(info[:tic])
|
|
302
|
+
w.str(info[:prefix].to_s)
|
|
303
|
+
end
|
|
304
|
+
w.list(combat.instance_variable_get(:@pain_until).to_a) do |idx, tic|
|
|
305
|
+
w.u32(idx)
|
|
306
|
+
w.u32(tic)
|
|
307
|
+
end
|
|
308
|
+
w.list(combat.projectiles) { |p| dump_projectile(w, p) }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def load_combat(r, combat, tic)
|
|
312
|
+
combat.instance_variable_set(:@tic, tic)
|
|
313
|
+
|
|
314
|
+
hp = {}
|
|
315
|
+
r.list do
|
|
316
|
+
idx = r.u32
|
|
317
|
+
hp[idx] = r.i64
|
|
318
|
+
end
|
|
319
|
+
combat.instance_variable_set(:@monster_hp, hp)
|
|
320
|
+
|
|
321
|
+
dead = {}
|
|
322
|
+
r.list do
|
|
323
|
+
idx = r.u32
|
|
324
|
+
dead[idx] = { tic: r.u32, prefix: r.str }
|
|
325
|
+
end
|
|
326
|
+
combat.instance_variable_set(:@dead_things, dead)
|
|
327
|
+
|
|
328
|
+
pain = {}
|
|
329
|
+
r.list do
|
|
330
|
+
idx = r.u32
|
|
331
|
+
pain[idx] = r.u32
|
|
332
|
+
end
|
|
333
|
+
combat.instance_variable_set(:@pain_until, pain)
|
|
334
|
+
|
|
335
|
+
projectiles = r.list { load_projectile(r) }
|
|
336
|
+
combat.instance_variable_set(:@projectiles, projectiles)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def dump_projectile(w, p)
|
|
340
|
+
w.f64(p.x)
|
|
341
|
+
w.f64(p.y)
|
|
342
|
+
w.f64(p.z)
|
|
343
|
+
w.f64(p.dx)
|
|
344
|
+
w.f64(p.dy)
|
|
345
|
+
w.f64(p.dz || 0.0)
|
|
346
|
+
w.str(p.type.to_s)
|
|
347
|
+
w.u32(p.spawn_tic)
|
|
348
|
+
w.str(p.sprite_prefix.to_s)
|
|
349
|
+
w.u8(PROJ_TARGETS.fetch(p.target, 0))
|
|
350
|
+
w.u8(p.owner&.id || NO_ID)
|
|
351
|
+
w.f64(p.damage_multiplier || 1.0)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def load_projectile(r)
|
|
355
|
+
Combat::Projectile.new(
|
|
356
|
+
r.f64, r.f64, r.f64, r.f64, r.f64, r.f64,
|
|
357
|
+
r.str.to_sym, r.u32, r.str, PROJ_TARGET_NAMES.fetch(r.u8, :monsters),
|
|
358
|
+
r.u8, # owner id; re-linked to a Player by relink_projectile_owners
|
|
359
|
+
r.f64 # damage_multiplier
|
|
360
|
+
)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Projectiles carry their owner as an id on the wire; swap it for the
|
|
364
|
+
# Player object once players exist, or nil for an owner who has left.
|
|
365
|
+
def relink_projectile_owners(world)
|
|
366
|
+
by_id = world.players.to_h { |p| [p.id, p] }
|
|
367
|
+
world.combat.projectiles.each do |proj|
|
|
368
|
+
proj.owner = proj.owner == NO_ID ? nil : by_id[proj.owner]
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# --- monsters -----------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
MONSTER_INT_FIELDS = %i[thing_idx movedir movecount chase_timer type
|
|
375
|
+
attack_cooldown reactiontime last_saw_target attack_frame_tic].freeze
|
|
376
|
+
MONSTER_BOOL_FIELDS = %i[active attacking fired].freeze
|
|
377
|
+
|
|
378
|
+
def dump_monsters(w, ai)
|
|
379
|
+
things = ai.instance_variable_get(:@map).things
|
|
380
|
+
w.list(ai.monsters) do |m|
|
|
381
|
+
MONSTER_INT_FIELDS.each { |f| w.i64(m[f]) }
|
|
382
|
+
MONSTER_BOOL_FIELDS.each { |f| w.bool(m[f]) }
|
|
383
|
+
w.f64(m.x)
|
|
384
|
+
w.f64(m.y)
|
|
385
|
+
w.u8(m.target&.id || NO_ID)
|
|
386
|
+
|
|
387
|
+
# The map thing carries an integer-truncated copy of the monster's
|
|
388
|
+
# position, synced only when the monster moves (monster_ai.rb writes
|
|
389
|
+
# thing.x = mon.x.to_i). Hitscan traces against THIS, not against
|
|
390
|
+
# mon.x/mon.y, so it must be restored explicitly: a stale thing
|
|
391
|
+
# position makes bullets hit where the monster no longer is. It also
|
|
392
|
+
# lags mon.x.to_i for a monster that has paused, so it is stored
|
|
393
|
+
# directly rather than recomputed. This was the field whose absence
|
|
394
|
+
# broke the round-trip test once a lift was in play.
|
|
395
|
+
thing = things[m.thing_idx]
|
|
396
|
+
w.i64(thing.x)
|
|
397
|
+
w.i64(thing.y)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def load_monsters(r, world, tic, aggression)
|
|
402
|
+
ai = world.monster_ai
|
|
403
|
+
ai.instance_variable_set(:@tic_counter, tic)
|
|
404
|
+
ai.aggression = aggression
|
|
405
|
+
by_id = world.players.to_h { |p| [p.id, p] }
|
|
406
|
+
|
|
407
|
+
saved = r.list do
|
|
408
|
+
fields = {}
|
|
409
|
+
MONSTER_INT_FIELDS.each { |f| fields[f] = r.i64 }
|
|
410
|
+
MONSTER_BOOL_FIELDS.each { |f| fields[f] = r.bool }
|
|
411
|
+
fields[:x] = r.f64
|
|
412
|
+
fields[:y] = r.f64
|
|
413
|
+
fields[:target_id] = r.u8
|
|
414
|
+
fields[:thing_x] = r.i64
|
|
415
|
+
fields[:thing_y] = r.i64
|
|
416
|
+
fields
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# The monster list is rebuilt from the map in a fixed order by the
|
|
420
|
+
# constructor, so match saved records to live monsters by thing index
|
|
421
|
+
# rather than trusting position.
|
|
422
|
+
things = world.map.things
|
|
423
|
+
live = ai.monster_by_thing_idx
|
|
424
|
+
saved.each do |f|
|
|
425
|
+
mon = live[f[:thing_idx]]
|
|
426
|
+
next unless mon
|
|
427
|
+
|
|
428
|
+
MONSTER_INT_FIELDS.each { |k| mon[k] = f[k] }
|
|
429
|
+
MONSTER_BOOL_FIELDS.each { |k| mon[k] = f[k] }
|
|
430
|
+
mon.x = f[:x]
|
|
431
|
+
mon.y = f[:y]
|
|
432
|
+
mon.target = f[:target_id] == NO_ID ? nil : by_id[f[:target_id]]
|
|
433
|
+
|
|
434
|
+
thing = things[f[:thing_idx]]
|
|
435
|
+
thing.x = f[:thing_x]
|
|
436
|
+
thing.y = f[:thing_y]
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# --- item pickup --------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def dump_item_pickup(w, ip)
|
|
443
|
+
w.u32(ip.ammo_multiplier)
|
|
444
|
+
w.list(ip.picked_up.keys) { |idx| w.u32(idx) }
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def load_item_pickup(r, ip)
|
|
448
|
+
ip.ammo_multiplier = r.u32
|
|
449
|
+
picked = {}
|
|
450
|
+
r.list { picked[r.u32] = true }
|
|
451
|
+
ip.picked_up.replace(picked)
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# --- sector effects: rebuilt in map order, full state restored ----------
|
|
455
|
+
#
|
|
456
|
+
# The effect list rebuilds in a fixed order from the map, so records line
|
|
457
|
+
# up by index. But NOT only the countdown gets restored: a rebuilt effect
|
|
458
|
+
# reads its max and min light from the sector's CURRENT level, and at a
|
|
459
|
+
# snapshot mid-fade that level has already moved, so reconstruction alone
|
|
460
|
+
# gives a wrong max/min and the light drifts a few tics later. Every
|
|
461
|
+
# effect field is carried instead.
|
|
462
|
+
EFFECT_FIELDS = %i[@maxlight @minlight @count @direction @darktime @brighttime].freeze
|
|
463
|
+
|
|
464
|
+
def dump_sector_effects(w, effects)
|
|
465
|
+
list = effects.instance_variable_get(:@effects)
|
|
466
|
+
w.list(list) do |e|
|
|
467
|
+
EFFECT_FIELDS.each { |iv| w.i64(e.instance_variable_defined?(iv) ? e.instance_variable_get(iv) : 0) }
|
|
468
|
+
end
|
|
469
|
+
scrolls = effects.instance_variable_get(:@scroll_sides)
|
|
470
|
+
w.list(scrolls) { |side| w.i64(side.x_offset) }
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def load_sector_effects(r, effects)
|
|
474
|
+
list = effects.instance_variable_get(:@effects)
|
|
475
|
+
records = r.list { EFFECT_FIELDS.map { r.i64 } }
|
|
476
|
+
records.each_with_index do |values, i|
|
|
477
|
+
e = list[i]
|
|
478
|
+
next unless e
|
|
479
|
+
|
|
480
|
+
EFFECT_FIELDS.each_with_index do |iv, j|
|
|
481
|
+
e.instance_variable_set(iv, values[j]) if e.instance_variable_defined?(iv)
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
scrolls = effects.instance_variable_get(:@scroll_sides)
|
|
486
|
+
offsets = r.list { r.i64 }
|
|
487
|
+
offsets.each_with_index { |off, i| scrolls[i]&.x_offset = off }
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# --- sector actions: doors, lifts and trigger-once bookkeeping ----------
|
|
491
|
+
|
|
492
|
+
def dump_sector_actions(w, actions, _map)
|
|
493
|
+
w.list(actions.instance_variable_get(:@active_doors).to_a) do |idx, d|
|
|
494
|
+
w.u32(idx)
|
|
495
|
+
w.i64(d[:state]) # DOOR_OPENING/OPEN/CLOSING are integers, not symbols
|
|
496
|
+
w.i64(d[:target_height])
|
|
497
|
+
w.i64(d[:original_height])
|
|
498
|
+
w.u32(d[:wait_tics])
|
|
499
|
+
w.bool(d[:stay_open])
|
|
500
|
+
end
|
|
501
|
+
w.list(actions.instance_variable_get(:@active_lifts).to_a) do |idx, l|
|
|
502
|
+
w.u32(idx)
|
|
503
|
+
w.str(l[:state].to_s)
|
|
504
|
+
w.i64(l[:target_low])
|
|
505
|
+
w.i64(l[:original_height])
|
|
506
|
+
w.u32(l[:wait_tics])
|
|
507
|
+
end
|
|
508
|
+
w.list(actions.instance_variable_get(:@crossed_linedefs).keys) { |idx| w.u32(idx) }
|
|
509
|
+
w.list(actions.instance_variable_get(:@secrets_found).keys) { |idx| w.u32(idx) }
|
|
510
|
+
|
|
511
|
+
# Which side of each nearby line each player was last on. Walk triggers
|
|
512
|
+
# fire on a side change, so without this a restored world detects a
|
|
513
|
+
# crossing that never happened (or misses one), which silently
|
|
514
|
+
# activates a different set of lines and diverges tics later. This was
|
|
515
|
+
# the field that broke the round-trip test.
|
|
516
|
+
#
|
|
517
|
+
# It is nested per player id: every player crosses walk lines on their
|
|
518
|
+
# own, so each keeps its own side memory. Players are written in id
|
|
519
|
+
# order and lines in index order so the bytes are reproducible across
|
|
520
|
+
# peers. A nil side means "not yet seen", which is the same as the key
|
|
521
|
+
# being absent, so those entries are dropped rather than encoded.
|
|
522
|
+
near = actions.instance_variable_get(:@near_linedefs) || {}
|
|
523
|
+
w.list(near.sort_by { |pid, _| pid }) do |pid, sides|
|
|
524
|
+
w.u8(pid)
|
|
525
|
+
live = (sides || {}).reject { |_, side| side.nil? }.sort_by { |idx, _| idx }
|
|
526
|
+
w.list(live) do |(idx, side)|
|
|
527
|
+
w.u32(idx)
|
|
528
|
+
w.i64(side)
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
w.bool(!actions.exit_triggered.nil?)
|
|
533
|
+
w.str(actions.exit_triggered.to_s)
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def load_sector_actions(r, actions, map)
|
|
537
|
+
doors = {}
|
|
538
|
+
r.list do
|
|
539
|
+
idx = r.u32
|
|
540
|
+
doors[idx] = {
|
|
541
|
+
sector: map.sectors[idx], state: r.i64,
|
|
542
|
+
target_height: r.i64, original_height: r.i64,
|
|
543
|
+
wait_tics: r.u32, stay_open: r.bool
|
|
544
|
+
}
|
|
545
|
+
end
|
|
546
|
+
actions.instance_variable_set(:@active_doors, doors)
|
|
547
|
+
|
|
548
|
+
lifts = {}
|
|
549
|
+
r.list do
|
|
550
|
+
idx = r.u32
|
|
551
|
+
lifts[idx] = {
|
|
552
|
+
sector: map.sectors[idx], state: r.str.to_sym,
|
|
553
|
+
target_low: r.i64, original_height: r.i64, wait_tics: r.u32
|
|
554
|
+
}
|
|
555
|
+
end
|
|
556
|
+
actions.instance_variable_set(:@active_lifts, lifts)
|
|
557
|
+
|
|
558
|
+
crossed = {}
|
|
559
|
+
r.list { crossed[r.u32] = true }
|
|
560
|
+
actions.instance_variable_set(:@crossed_linedefs, crossed)
|
|
561
|
+
|
|
562
|
+
secrets = {}
|
|
563
|
+
r.list { secrets[r.u32] = true }
|
|
564
|
+
actions.instance_variable_set(:@secrets_found, secrets)
|
|
565
|
+
|
|
566
|
+
near = {}
|
|
567
|
+
r.list do
|
|
568
|
+
pid = r.u8
|
|
569
|
+
sides = {}
|
|
570
|
+
r.list do
|
|
571
|
+
idx = r.u32
|
|
572
|
+
sides[idx] = r.i64
|
|
573
|
+
end
|
|
574
|
+
near[pid] = sides
|
|
575
|
+
end
|
|
576
|
+
actions.instance_variable_set(:@near_linedefs, near)
|
|
577
|
+
|
|
578
|
+
has_exit = r.bool
|
|
579
|
+
exit_val = r.str
|
|
580
|
+
actions.instance_variable_set(:@exit_triggered, has_exit ? exit_val.to_sym : nil)
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|