doom 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/LICENSE +17 -0
- data/README.md +65 -4
- data/bin/doom +134 -24
- data/lib/doom/benchmark.rb +282 -0
- data/lib/doom/game/animations.rb +9 -2
- data/lib/doom/game/combat.rb +239 -153
- data/lib/doom/game/framebuffer_blitter.rb +38 -0
- data/lib/doom/game/geometry.rb +68 -0
- data/lib/doom/game/intermission.rb +22 -39
- data/lib/doom/game/item_pickup.rb +87 -75
- data/lib/doom/game/menu.rb +90 -85
- data/lib/doom/game/monster_ai.rb +150 -136
- data/lib/doom/game/player.rb +105 -0
- data/lib/doom/game/player_physics.rb +384 -0
- data/lib/doom/game/player_state.rb +38 -64
- data/lib/doom/game/random.rb +82 -0
- data/lib/doom/game/sector_actions.rb +169 -93
- data/lib/doom/game/sector_effects.rb +25 -18
- data/lib/doom/game/snapshot.rb +584 -0
- data/lib/doom/game/sound_engine.rb +33 -58
- data/lib/doom/game/state_hash.rb +125 -0
- data/lib/doom/game/ticcmd.rb +61 -0
- data/lib/doom/game/world.rb +420 -0
- data/lib/doom/map/data.rb +95 -0
- data/lib/doom/net/client.rb +204 -0
- data/lib/doom/net/desync_monitor.rb +101 -0
- data/lib/doom/net/game_server.rb +232 -0
- data/lib/doom/net/lockstep.rb +170 -0
- data/lib/doom/net/protocol.rb +350 -0
- data/lib/doom/net/session.rb +282 -0
- data/lib/doom/net/transport.rb +110 -0
- data/lib/doom/platform/gosu_window.rb +604 -832
- data/lib/doom/platform/sdl.rb +74 -0
- data/lib/doom/platform/window_logic.rb +61 -0
- data/lib/doom/render/font.rb +5 -3
- data/lib/doom/render/hardware_renderer.rb +547 -0
- data/lib/doom/render/ray_tracing/bvh.rb +103 -0
- data/lib/doom/render/ray_tracing/material_state.rb +66 -0
- data/lib/doom/render/ray_tracing/texture_atlas.rb +78 -0
- data/lib/doom/render/ray_tracing_renderer.rb +940 -0
- data/lib/doom/render/renderer.rb +466 -440
- data/lib/doom/render/renderer_factory.rb +50 -0
- data/lib/doom/render/screen_melt.rb +7 -7
- data/lib/doom/render/spinel_native/kernel.rb +780 -0
- data/lib/doom/render/spinel_native_renderer.rb +162 -0
- data/lib/doom/render/status_bar.rb +14 -10
- data/lib/doom/render/weapon_renderer.rb +9 -10
- data/lib/doom/render/world_mesh.rb +270 -0
- data/lib/doom/render/zbuffer_renderer.rb +151 -0
- data/lib/doom/version.rb +1 -1
- data/lib/doom/wad/flat.rb +1 -1
- data/lib/doom/wad/hud_graphics.rb +7 -3
- data/lib/doom/wad/palette.rb +3 -3
- data/lib/doom/wad/patch.rb +1 -1
- data/lib/doom/wad/reader.rb +12 -15
- data/lib/doom/wad/sound.rb +14 -6
- data/lib/doom/wad/sprite.rb +36 -35
- data/lib/doom/wad/texture.rb +5 -5
- data/lib/doom/wad_downloader.rb +38 -56
- data/lib/doom.rb +197 -16
- metadata +94 -7
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Net
|
|
5
|
+
# The client half of the authoritative-server game. Talks only to the
|
|
6
|
+
# server (outbound UDP, so NAT is a non-issue), joins by restoring a
|
|
7
|
+
# snapshot of the live world, then follows the server's finalized frame
|
|
8
|
+
# stream -- applying joins, leaves and commands in tic order -- so its world
|
|
9
|
+
# stays byte-identical to the server's. It runs no simulation of its own and
|
|
10
|
+
# never predicts: the local player sees their own input after the round trip
|
|
11
|
+
# to the server and back, which is the price of a model where a dead phone
|
|
12
|
+
# can't stall anyone.
|
|
13
|
+
#
|
|
14
|
+
# Everything is polled, never blocking, so the game keeps rendering while a
|
|
15
|
+
# join is still in flight or a resync is underway.
|
|
16
|
+
#
|
|
17
|
+
# A join is a small state machine: HELLO -> WELCOME (who you are, which map)
|
|
18
|
+
# -> SNAPSHOT chunks (the world) -> playing. HELLO is resent until WELCOME
|
|
19
|
+
# arrives; if the snapshot never completes, or the frame stream leaves a gap
|
|
20
|
+
# too old for the server's redundancy to fill, the client asks again and the
|
|
21
|
+
# server sends a fresh snapshot at the current tic.
|
|
22
|
+
class Client
|
|
23
|
+
HELLO_RESEND_SECONDS = 0.25
|
|
24
|
+
SYNC_TIMEOUT_SECONDS = 1.5 # No completed snapshot this long -> ask again
|
|
25
|
+
# If the newest frame we've heard of is this far past the one we still
|
|
26
|
+
# need, the missing one has aged out of the server's resend window and is
|
|
27
|
+
# gone for good -- only a fresh snapshot recovers us.
|
|
28
|
+
RESYNC_GAP_TICS = 12
|
|
29
|
+
|
|
30
|
+
attr_reader :world, :local_id, :synced_tic
|
|
31
|
+
|
|
32
|
+
# `load_map` is a callable name -> Map::MapData; the client learns which
|
|
33
|
+
# map from WELCOME and has no other way to reach the WAD.
|
|
34
|
+
def initialize(host:, port:, sprites:, load_map:, sound: nil,
|
|
35
|
+
clock: -> { Time.now }, transport: nil)
|
|
36
|
+
@host = host
|
|
37
|
+
@port = port
|
|
38
|
+
@sprites = sprites
|
|
39
|
+
@load_map = load_map
|
|
40
|
+
@sound = sound
|
|
41
|
+
@clock = clock
|
|
42
|
+
@transport = transport || Transport.new
|
|
43
|
+
reset_join
|
|
44
|
+
@last_hello_at = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def local_port = @transport.local_port
|
|
48
|
+
def connected? = !@local_id.nil? # WELCOME received
|
|
49
|
+
def playing? = !@world.nil? # snapshot restored, following frames
|
|
50
|
+
|
|
51
|
+
# Pump the network: chase the join if not yet playing, then drain and
|
|
52
|
+
# apply whatever has arrived. Safe every frame.
|
|
53
|
+
def poll
|
|
54
|
+
send_hello_if_due
|
|
55
|
+
@transport.poll.each { |msg, _host, _port| handle(msg) }
|
|
56
|
+
resync_if_stuck
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Send this frame's input for future tics. `pairs` is [[tic, Ticcmd], ...];
|
|
60
|
+
# recent inputs are repeated by the caller so a lost packet is covered.
|
|
61
|
+
def send_input(pairs)
|
|
62
|
+
return unless @local_id
|
|
63
|
+
|
|
64
|
+
send_packet(Protocol.encode_input(@local_id, pairs))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quit
|
|
68
|
+
send_packet(Protocol.encode_quit(@local_id)) if @local_id && !@transport.closed?
|
|
69
|
+
@transport.close
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def reset_join
|
|
75
|
+
@local_id = nil
|
|
76
|
+
@map_name = nil
|
|
77
|
+
@snapshot_tic = nil
|
|
78
|
+
@chunks = {}
|
|
79
|
+
@chunk_total = nil
|
|
80
|
+
@world = nil
|
|
81
|
+
@synced_tic = nil
|
|
82
|
+
@pending_frames = {}
|
|
83
|
+
@synced_at = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def send_packet(bytes) = @transport.send_to_addr(@host, @port, bytes)
|
|
87
|
+
|
|
88
|
+
def send_hello_if_due
|
|
89
|
+
return if playing?
|
|
90
|
+
|
|
91
|
+
now = @clock.call
|
|
92
|
+
return if @last_hello_at && (now - @last_hello_at) < HELLO_RESEND_SECONDS
|
|
93
|
+
|
|
94
|
+
@last_hello_at = now
|
|
95
|
+
send_packet(Protocol.encode_hello)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def handle(msg)
|
|
99
|
+
case msg[:type]
|
|
100
|
+
when Protocol::WELCOME then on_welcome(msg)
|
|
101
|
+
when Protocol::SNAPSHOT then on_chunk(msg)
|
|
102
|
+
when Protocol::FRAME then on_frame(msg)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# WELCOME may be the first one (join) or a fresh one answering a resync
|
|
107
|
+
# request; either way we start reassembling the snapshot it names and drop
|
|
108
|
+
# any stale in-progress reassembly for an older snapshot tic.
|
|
109
|
+
def on_welcome(msg)
|
|
110
|
+
return if @snapshot_tic == msg[:snapshot_tic] && @map_name
|
|
111
|
+
|
|
112
|
+
@local_id = msg[:player_id]
|
|
113
|
+
@map_name = msg[:map]
|
|
114
|
+
@snapshot_tic = msg[:snapshot_tic]
|
|
115
|
+
@map = @load_map.call(@map_name)
|
|
116
|
+
@chunks = {}
|
|
117
|
+
@chunk_total = nil
|
|
118
|
+
@last_hello_at = @clock.call # a WELCOME resets the hello clock
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def on_chunk(msg)
|
|
122
|
+
return unless @snapshot_tic == msg[:snapshot_tic] && @map
|
|
123
|
+
|
|
124
|
+
total = msg[:total]
|
|
125
|
+
index = msg[:index]
|
|
126
|
+
# A stray or hostile chunk (bad total, or an index outside the range)
|
|
127
|
+
# must not corrupt reassembly: without this an out-of-range index could
|
|
128
|
+
# push @chunks.size up to @chunk_total while a real index stays missing,
|
|
129
|
+
# so the join below would splice a gap and Snapshot.load would raise on
|
|
130
|
+
# the network path. Drop it instead.
|
|
131
|
+
return if total.nil? || total.zero? || index.nil? || index >= total
|
|
132
|
+
|
|
133
|
+
@chunk_total = total
|
|
134
|
+
@chunks[index] = msg[:chunk]
|
|
135
|
+
return unless @chunks.size == @chunk_total && (0...@chunk_total).all? { |i| @chunks.key?(i) }
|
|
136
|
+
|
|
137
|
+
bytes = (0...@chunk_total).map { |i| @chunks[i] }.join
|
|
138
|
+
begin
|
|
139
|
+
world = Game::Snapshot.load(bytes, map: @map, sprites: @sprites, sound: @sound)
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
# A truncated or corrupt snapshot is recoverable: ask for a fresh one
|
|
142
|
+
# rather than letting the exception kill the client mid-game.
|
|
143
|
+
warn "snapshot load failed (#{e.class}: #{e.message}); requesting resync"
|
|
144
|
+
request_resync(@clock.call)
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
@world = world
|
|
149
|
+
@synced_tic = @snapshot_tic
|
|
150
|
+
@synced_at = @clock.call
|
|
151
|
+
# Frames at or before the snapshot are already baked into it.
|
|
152
|
+
@pending_frames.reject! { |tic, _| tic <= @synced_tic }
|
|
153
|
+
drain_pending
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def on_frame(msg)
|
|
157
|
+
msg[:frames].each { |f| @pending_frames[f[:tic]] ||= f }
|
|
158
|
+
drain_pending
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Apply every consecutive frame we have, in tic order. A missing tic stops
|
|
162
|
+
# the drain -- lockstep can't skip -- and we wait for it to arrive (or for
|
|
163
|
+
# resync_if_stuck to give up on it).
|
|
164
|
+
def drain_pending
|
|
165
|
+
return unless @synced_tic
|
|
166
|
+
|
|
167
|
+
while (f = @pending_frames.delete(@synced_tic + 1))
|
|
168
|
+
f[:joins].each { |id| @world.add_player(id: id) }
|
|
169
|
+
f[:leaves].each { |id| @world.remove_player(id) }
|
|
170
|
+
@world.run_tic(f[:cmds].to_h)
|
|
171
|
+
@synced_tic += 1
|
|
172
|
+
@synced_at = @clock.call
|
|
173
|
+
end
|
|
174
|
+
prune_pending
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def prune_pending
|
|
178
|
+
@pending_frames.reject! { |tic, _| tic <= @synced_tic }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Ask for a fresh snapshot when the join stalls or an unfillable frame gap
|
|
182
|
+
# opens. Re-sending HELLO is the request; the server answers an existing
|
|
183
|
+
# client with a new WELCOME plus a snapshot at the current tic.
|
|
184
|
+
def resync_if_stuck
|
|
185
|
+
now = @clock.call
|
|
186
|
+
|
|
187
|
+
if playing?
|
|
188
|
+
newest = @pending_frames.keys.max
|
|
189
|
+
gap = newest && (newest - @synced_tic) > RESYNC_GAP_TICS
|
|
190
|
+
request_resync(now) if gap
|
|
191
|
+
elsif connected? && @synced_at.nil?
|
|
192
|
+
# WELCOME arrived but the snapshot never finished reassembling.
|
|
193
|
+
request_resync(now) if @last_hello_at && (now - @last_hello_at) >= SYNC_TIMEOUT_SECONDS
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def request_resync(now)
|
|
198
|
+
reset_join
|
|
199
|
+
@last_hello_at = now
|
|
200
|
+
send_packet(Protocol.encode_hello)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Net
|
|
5
|
+
# Watches for lockstep divergence.
|
|
6
|
+
#
|
|
7
|
+
# Each peer fingerprints its world every CHECK_INTERVAL tics and broadcasts
|
|
8
|
+
# the result. When two peers report different fingerprints for the same
|
|
9
|
+
# tic, their simulations have already diverged and every tic after it is
|
|
10
|
+
# meaningless. The job here is to say so at the tic it happened, with
|
|
11
|
+
# enough detail to diagnose it, instead of letting the games drift apart
|
|
12
|
+
# while both players wonder why the other is shooting at nothing.
|
|
13
|
+
#
|
|
14
|
+
# Peers exchange per-section hashes, not just the folded one: five extra
|
|
15
|
+
# numbers a second is nothing on the wire, and it turns "something
|
|
16
|
+
# diverged" into "the monsters diverged", which is the difference between
|
|
17
|
+
# an alarming report and a useful one.
|
|
18
|
+
#
|
|
19
|
+
# Reports can arrive before or after the local check for the same tic, so
|
|
20
|
+
# both directions are buffered and compared whenever a pair completes.
|
|
21
|
+
class DesyncMonitor
|
|
22
|
+
CHECK_INTERVAL = 35 # One second at DOOM's tic rate
|
|
23
|
+
HISTORY_TICS = 350 # Keep ~10s; enough to pair up late reports
|
|
24
|
+
|
|
25
|
+
Desync = Struct.new(:tic, :peer_id, :local, :remote, :sections, keyword_init: true) do
|
|
26
|
+
def message
|
|
27
|
+
where = sections.nil? || sections.empty? ? 'unknown section' : sections.join(', ')
|
|
28
|
+
"desync at tic #{tic} with peer #{peer_id}: " \
|
|
29
|
+
"local #{format('%08x', local)} != remote #{format('%08x', remote)} (#{where})"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :desyncs
|
|
34
|
+
|
|
35
|
+
# `on_desync` is called with a Desync the moment one is detected.
|
|
36
|
+
def initialize(interval: CHECK_INTERVAL, &on_desync)
|
|
37
|
+
@interval = interval
|
|
38
|
+
@on_desync = on_desync
|
|
39
|
+
@local = {} # tic => section hashes
|
|
40
|
+
@remote = {} # tic => { peer_id => section hashes }
|
|
41
|
+
@desyncs = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def check_due?(tic)
|
|
45
|
+
(tic % @interval).zero?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fingerprint the world if this tic is a checkpoint. Returns the section
|
|
49
|
+
# hashes to broadcast, or nil on a tic that is not checked.
|
|
50
|
+
def record(tic, world)
|
|
51
|
+
return nil unless check_due?(tic)
|
|
52
|
+
|
|
53
|
+
sections = world.state_hash_sections
|
|
54
|
+
@local[tic] = sections
|
|
55
|
+
compare(tic)
|
|
56
|
+
prune(tic)
|
|
57
|
+
sections
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Section hashes received from another peer.
|
|
61
|
+
def remote_report(tic, peer_id, sections)
|
|
62
|
+
(@remote[tic] ||= {})[peer_id] = sections
|
|
63
|
+
compare(tic)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def desynced?
|
|
67
|
+
!@desyncs.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def compare(tic)
|
|
73
|
+
local = @local[tic]
|
|
74
|
+
peers = @remote[tic]
|
|
75
|
+
return unless local && peers
|
|
76
|
+
|
|
77
|
+
peers.each do |peer_id, remote|
|
|
78
|
+
local_hash = Game::StateHash.fold(local)
|
|
79
|
+
remote_hash = Game::StateHash.fold(remote)
|
|
80
|
+
next if local_hash == remote_hash
|
|
81
|
+
next if @desyncs.any? { |d| d.tic == tic && d.peer_id == peer_id }
|
|
82
|
+
|
|
83
|
+
differing = Game::StateHash::SECTIONS.reject { |s| local[s] == remote[s] }
|
|
84
|
+
desync = Desync.new(tic: tic, peer_id: peer_id,
|
|
85
|
+
local: local_hash, remote: remote_hash, sections: differing)
|
|
86
|
+
@desyncs << desync
|
|
87
|
+
@on_desync&.call(desync)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Bound memory: a long session would otherwise keep every checkpoint.
|
|
92
|
+
def prune(tic)
|
|
93
|
+
cutoff = tic - HISTORY_TICS
|
|
94
|
+
return if cutoff <= 0
|
|
95
|
+
|
|
96
|
+
@local.delete_if { |t, _| t < cutoff }
|
|
97
|
+
@remote.delete_if { |t, _| t < cutoff }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Doom
|
|
4
|
+
module Net
|
|
5
|
+
# The authoritative game server for large, chaotic sessions.
|
|
6
|
+
#
|
|
7
|
+
# Peer-to-peer lockstep does not survive a conference: with mobile clients
|
|
8
|
+
# behind CGNAT they cannot reach each other, and lockstep makes everyone
|
|
9
|
+
# wait for the slowest, so the first person to close their laptop freezes
|
|
10
|
+
# the match. This server fixes both. Clients speak only to it (outbound
|
|
11
|
+
# UDP, so NAT is a non-issue), and it never waits: on every tic it takes
|
|
12
|
+
# whatever input has arrived, fills the gaps with neutral commands, runs
|
|
13
|
+
# the tic, and broadcasts the finalized result. A player whose phone dies
|
|
14
|
+
# simply stops acting -- nobody else is held up.
|
|
15
|
+
#
|
|
16
|
+
# It is still deterministic underneath: it holds the one canonical world,
|
|
17
|
+
# every client runs the identical command stream, and joins and leaves are
|
|
18
|
+
# tic events (a deathmatch spawn draws the shared RNG, so it must happen on
|
|
19
|
+
# the same tic everywhere). Because it holds the canonical world, it can
|
|
20
|
+
# hand a late joiner a snapshot of the exact current state instead of
|
|
21
|
+
# making them replay the match from the start.
|
|
22
|
+
#
|
|
23
|
+
# Nothing here blocks. `poll` drains the socket, `advance` runs whatever
|
|
24
|
+
# tics are due against an injected clock; a host loops them.
|
|
25
|
+
class GameServer
|
|
26
|
+
TICRATE = 35
|
|
27
|
+
TIC_SECONDS = 1.0 / TICRATE
|
|
28
|
+
FRAME_REDUNDANCY = 6 # Recent frames repeated per packet against loss
|
|
29
|
+
CLIENT_TIMEOUT = 5.0 # Drop a client silent this long
|
|
30
|
+
|
|
31
|
+
# A connected client's bookkeeping (named Member so it is not confused
|
|
32
|
+
# with Net::Client, the actual client program in the same module).
|
|
33
|
+
Member = Struct.new(:player_id, :host, :port, :last_seen, :joined) do
|
|
34
|
+
def key = "#{host}:#{port}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :world, :transport, :tic, :clients
|
|
38
|
+
|
|
39
|
+
def initialize(world:, transport:, max_players: 16, clock: -> { Time.now },
|
|
40
|
+
client_timeout: CLIENT_TIMEOUT)
|
|
41
|
+
@world = world
|
|
42
|
+
@transport = transport
|
|
43
|
+
@max_players = max_players
|
|
44
|
+
@clock = clock
|
|
45
|
+
@client_timeout = client_timeout
|
|
46
|
+
@tic = world.leveltime
|
|
47
|
+
@start_tic = world.leveltime
|
|
48
|
+
@start_time = @clock.call
|
|
49
|
+
|
|
50
|
+
@clients = {} # addr key => Client
|
|
51
|
+
@by_id = {} # player_id => Client
|
|
52
|
+
@inputs = Hash.new { |h, k| h[k] = {} } # tic => { player_id => Ticcmd }
|
|
53
|
+
@pending_joins = [] # player ids to add on the next tic
|
|
54
|
+
@pending_leaves = [] # player ids to remove on the next tic
|
|
55
|
+
@frame_log = [] # recent finalized frames, for redundancy
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Drain and dispatch whatever has arrived. Safe every loop iteration.
|
|
59
|
+
def poll
|
|
60
|
+
@transport.poll.each { |msg, host, port| handle(msg, host, port) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Drop clients that have gone silent, so their stale bodies stop being
|
|
64
|
+
# simulated and stop holding a player slot. Called from the host loop.
|
|
65
|
+
# A leave is a tic event like a join, applied on the next tic.
|
|
66
|
+
def reap(now = @clock.call)
|
|
67
|
+
@clients.values.each do |c|
|
|
68
|
+
next unless c.joined
|
|
69
|
+
next if now - c.last_seen < @client_timeout
|
|
70
|
+
|
|
71
|
+
@clients.delete(c.key)
|
|
72
|
+
@by_id.delete(c.player_id)
|
|
73
|
+
@pending_leaves << c.player_id
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Run every tic whose deadline has passed, capping the catch-up so one
|
|
78
|
+
# long stall cannot make a single call run thousands of tics. Returns how
|
|
79
|
+
# many ran.
|
|
80
|
+
def advance(now = @clock.call, limit: 8)
|
|
81
|
+
target = @start_tic + ((now - @start_time) * TICRATE).floor
|
|
82
|
+
ran = 0
|
|
83
|
+
while @tic < target && ran < limit
|
|
84
|
+
step_one_tic
|
|
85
|
+
ran += 1
|
|
86
|
+
end
|
|
87
|
+
ran
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def player_count = @world.players.size
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Advance exactly one tic: apply membership, gather input, simulate,
|
|
95
|
+
# then tell everyone what happened.
|
|
96
|
+
def step_one_tic
|
|
97
|
+
this_tic = @tic + 1
|
|
98
|
+
|
|
99
|
+
joins = @pending_joins.dup
|
|
100
|
+
leaves = @pending_leaves.dup
|
|
101
|
+
@pending_joins.clear
|
|
102
|
+
@pending_leaves.clear
|
|
103
|
+
|
|
104
|
+
apply_joins(joins)
|
|
105
|
+
apply_leaves(leaves)
|
|
106
|
+
|
|
107
|
+
cmds = finalize_cmds(this_tic)
|
|
108
|
+
@world.run_tic(cmds)
|
|
109
|
+
@tic = this_tic
|
|
110
|
+
@inputs.delete(this_tic)
|
|
111
|
+
|
|
112
|
+
frame = { tic: this_tic, joins: joins, leaves: leaves,
|
|
113
|
+
cmds: cmds.map { |id, c| [id, c] } }
|
|
114
|
+
record_and_broadcast(frame)
|
|
115
|
+
send_welcomes(joins, this_tic)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Missing players coast on a neutral command rather than stalling the tic.
|
|
119
|
+
# That is the whole point of the deadline: nobody waits.
|
|
120
|
+
def finalize_cmds(tic)
|
|
121
|
+
have = @inputs[tic]
|
|
122
|
+
@world.players.each_with_object({}) do |p, h|
|
|
123
|
+
h[p.id] = have[p.id] || Game::Ticcmd.none
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def apply_joins(ids)
|
|
128
|
+
ids.each do |id|
|
|
129
|
+
@world.add_player(id: id)
|
|
130
|
+
client = @by_id[id]
|
|
131
|
+
client.joined = true if client
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def apply_leaves(ids)
|
|
136
|
+
ids.each { |id| @world.remove_player(id) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def record_and_broadcast(frame)
|
|
140
|
+
@frame_log << frame
|
|
141
|
+
@frame_log.shift while @frame_log.size > FRAME_REDUNDANCY
|
|
142
|
+
# Cap the packet under a safe MTU: at high player counts the full
|
|
143
|
+
# redundancy would fragment or be truncated. Redundancy degrades
|
|
144
|
+
# gracefully, the newest frame always ships.
|
|
145
|
+
packet = Protocol.encode_frame_capped(@frame_log)
|
|
146
|
+
@clients.each_value { |c| @transport.send_to_addr(c.host, c.port, packet) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def handle(msg, host, port)
|
|
150
|
+
case msg[:type]
|
|
151
|
+
when Protocol::HELLO then on_hello(host, port)
|
|
152
|
+
when Protocol::INPUT then on_input(msg, host, port)
|
|
153
|
+
when Protocol::QUIT then on_quit(host, port)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def on_hello(host, port)
|
|
158
|
+
existing = @clients["#{host}:#{port}"]
|
|
159
|
+
if existing
|
|
160
|
+
# A repeated hello is a resync request: the welcome was lost, or the
|
|
161
|
+
# client fell too far behind the frame stream to recover. Answer with
|
|
162
|
+
# a fresh snapshot at the current tic, not just the welcome, so it can
|
|
163
|
+
# restart from live state.
|
|
164
|
+
send_snapshot_to(existing) if existing.joined
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
return if @world.players.size + @pending_joins.size >= @max_players
|
|
168
|
+
|
|
169
|
+
id = next_free_id
|
|
170
|
+
return unless id
|
|
171
|
+
|
|
172
|
+
client = Member.new(id, host, port, @clock.call, false)
|
|
173
|
+
@clients[client.key] = client
|
|
174
|
+
@by_id[id] = client
|
|
175
|
+
@pending_joins << id
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def on_input(msg, host, port)
|
|
179
|
+
client = @clients["#{host}:#{port}"]
|
|
180
|
+
return unless client
|
|
181
|
+
|
|
182
|
+
client.last_seen = @clock.call
|
|
183
|
+
# A client may only speak for the id it was assigned. The check does not
|
|
184
|
+
# vary per command, so reject the whole packet up front rather than
|
|
185
|
+
# re-testing it inside the loop.
|
|
186
|
+
return unless msg[:player_id] == client.player_id
|
|
187
|
+
|
|
188
|
+
msg[:cmds].each do |tic, cmd|
|
|
189
|
+
next if tic <= @tic # already simulated; too late to matter
|
|
190
|
+
|
|
191
|
+
@inputs[tic][client.player_id] = cmd
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def on_quit(host, port)
|
|
196
|
+
client = @clients.delete("#{host}:#{port}")
|
|
197
|
+
return unless client
|
|
198
|
+
|
|
199
|
+
@by_id.delete(client.player_id)
|
|
200
|
+
@pending_leaves << client.player_id if client.joined
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def next_free_id
|
|
204
|
+
taken = @by_id.keys + @pending_joins
|
|
205
|
+
(0...@max_players).find { |id| !taken.include?(id) }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Hand each new joiner a snapshot of the world at the tic they joined,
|
|
209
|
+
# split into datagram-sized chunks. They restore it and follow the frame
|
|
210
|
+
# stream from the next tic.
|
|
211
|
+
def send_welcomes(join_ids, _tic)
|
|
212
|
+
join_ids.each do |id|
|
|
213
|
+
client = @by_id[id]
|
|
214
|
+
send_snapshot_to(client) if client
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# WELCOME plus the world snapshot at the current tic, chunked. One dump
|
|
219
|
+
# per recipient is fine -- joins and resyncs are rare next to the tic loop.
|
|
220
|
+
def send_snapshot_to(client)
|
|
221
|
+
@transport.send_to_addr(client.host, client.port, Protocol.encode_welcome(
|
|
222
|
+
player_id: client.player_id, num_players: @world.players.size,
|
|
223
|
+
mode: @world.mode, skill: Session::DEFAULT_SKILL,
|
|
224
|
+
snapshot_tic: @tic, map: @world.map.name
|
|
225
|
+
))
|
|
226
|
+
Protocol.snapshot_chunks(@tic, Game::Snapshot.dump(@world)).each do |chunk|
|
|
227
|
+
@transport.send_to_addr(client.host, client.port, chunk)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|