termfront 0.1.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 +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/Rakefile +12 -0
- data/data/audio/THIRD_PARTY_NOTICES.md +45 -0
- data/data/audio/beep_02.ogg +0 -0
- data/data/audio/button1.ogg +0 -0
- data/data/audio/complete.ogg +0 -0
- data/data/audio/manifest.json +17 -0
- data/data/audio/mission_bgm.wav +0 -0
- data/data/audio/mission_clear_se.wav +0 -0
- data/data/audio/on.ogg +0 -0
- data/data/audio/page_se.wav +0 -0
- data/data/audio/sector.mp3 +0 -0
- data/data/audio/sfx_22b.ogg +0 -0
- data/data/audio/shield_alarm_se.wav +0 -0
- data/data/audio/shield_regen_se.wav +0 -0
- data/data/audio/shoot_01.ogg +0 -0
- data/data/audio/terminal_se.wav +0 -0
- data/data/audio/title.mp3 +0 -0
- data/data/audio/title_bgm.wav +0 -0
- data/data/audio/victory.mp3 +0 -0
- data/data/events/corridor_sweep.json +27 -0
- data/data/events/final_push.json +40 -0
- data/data/events/stronghold.json +27 -0
- data/data/events/the_gauntlet.json +27 -0
- data/data/events/training_grounds.json +31 -0
- data/exe/termfront +6 -0
- data/exe/termfront-server +7 -0
- data/lib/termfront/audio_manager.rb +225 -0
- data/lib/termfront/config.rb +38 -0
- data/lib/termfront/demo_player.rb +181 -0
- data/lib/termfront/drop_item/base.rb +26 -0
- data/lib/termfront/drop_item/weapon.rb +38 -0
- data/lib/termfront/enemy/base.rb +133 -0
- data/lib/termfront/enemy/crawler.rb +18 -0
- data/lib/termfront/enemy/executor.rb +18 -0
- data/lib/termfront/game.rb +637 -0
- data/lib/termfront/input.rb +75 -0
- data/lib/termfront/map.rb +72 -0
- data/lib/termfront/mission/base.rb +81 -0
- data/lib/termfront/mission/corridor_sweep.rb +41 -0
- data/lib/termfront/mission/event_loader.rb +87 -0
- data/lib/termfront/mission/event_runtime.rb +37 -0
- data/lib/termfront/mission/final_push.rb +44 -0
- data/lib/termfront/mission/stronghold.rb +45 -0
- data/lib/termfront/mission/the_gauntlet.rb +38 -0
- data/lib/termfront/mission/training.rb +38 -0
- data/lib/termfront/mission/training_grounds.rb +37 -0
- data/lib/termfront/network/client.rb +865 -0
- data/lib/termfront/network/connection.rb +101 -0
- data/lib/termfront/network/server.rb +620 -0
- data/lib/termfront/network/wavesfight_client.rb +364 -0
- data/lib/termfront/opponent.rb +24 -0
- data/lib/termfront/player.rb +147 -0
- data/lib/termfront/projectile.rb +44 -0
- data/lib/termfront/remote_enemy.rb +21 -0
- data/lib/termfront/renderer.rb +707 -0
- data/lib/termfront/scene_player.rb +164 -0
- data/lib/termfront/sprite.rb +73 -0
- data/lib/termfront/terminal_output.rb +63 -0
- data/lib/termfront/title_screen.rb +299 -0
- data/lib/termfront/version.rb +5 -0
- data/lib/termfront/weapon/assault_rifle.rb +15 -0
- data/lib/termfront/weapon/base.rb +44 -0
- data/lib/termfront/weapon/pistol.rb +15 -0
- data/lib/termfront/weapon/shock_pistol.rb +15 -0
- data/lib/termfront/weapon/shock_rifle.rb +15 -0
- data/lib/termfront.rb +51 -0
- data/sig/termfront.rbs +4 -0
- metadata +119 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Termfront
|
|
8
|
+
module Network
|
|
9
|
+
class Connection
|
|
10
|
+
attr_reader :rtt
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@sock = nil
|
|
14
|
+
@buf = +""
|
|
15
|
+
@ping_ts = 0
|
|
16
|
+
@rtt = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def connect(host, port)
|
|
20
|
+
tcp = TCPSocket.new(host, port)
|
|
21
|
+
tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
|
|
22
|
+
|
|
23
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
24
|
+
ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
|
25
|
+
@sock = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
26
|
+
@sock.hostname = host if @sock.respond_to?(:hostname=)
|
|
27
|
+
@sock.sync = true
|
|
28
|
+
@sock.connect
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def send_msg(hash)
|
|
32
|
+
return unless @sock
|
|
33
|
+
|
|
34
|
+
@sock.write(JSON.generate(hash) + "\n")
|
|
35
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, OpenSSL::SSL::SSLError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def receive
|
|
40
|
+
return [] unless @sock
|
|
41
|
+
|
|
42
|
+
messages = []
|
|
43
|
+
|
|
44
|
+
while IO.select([@sock], nil, nil, 0)
|
|
45
|
+
begin
|
|
46
|
+
data = @sock.read_nonblock(4096)
|
|
47
|
+
@buf << data
|
|
48
|
+
|
|
49
|
+
while (nl = @buf.index("\n"))
|
|
50
|
+
line = @buf.slice!(0, nl + 1)
|
|
51
|
+
begin
|
|
52
|
+
msg = JSON.parse(line, symbolize_names: true)
|
|
53
|
+
if msg[:t] == "pong"
|
|
54
|
+
@rtt = ((clock - @ping_ts) * 1000).to_i if @ping_ts > 0
|
|
55
|
+
else
|
|
56
|
+
messages << msg
|
|
57
|
+
end
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
rescue IO::WaitReadable
|
|
63
|
+
break
|
|
64
|
+
rescue EOFError, Errno::ECONNRESET, IOError, OpenSSL::SSL::SSLError
|
|
65
|
+
break
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
messages
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def close
|
|
73
|
+
begin
|
|
74
|
+
@sock&.close
|
|
75
|
+
rescue StandardError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
@sock = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def connected?
|
|
82
|
+
!@sock.nil?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ping(now)
|
|
86
|
+
@ping_ts = now
|
|
87
|
+
send_msg({ t: "ping", ts: (now * 1000).to_i })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def socket
|
|
91
|
+
@sock
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def clock
|
|
97
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Termfront
|
|
8
|
+
module Network
|
|
9
|
+
class Server
|
|
10
|
+
TEAM_SIZES = [1, 2, 4].freeze
|
|
11
|
+
PVP_MAP = [
|
|
12
|
+
"####################",
|
|
13
|
+
"#........##........#",
|
|
14
|
+
"#........##........#",
|
|
15
|
+
"#..................#",
|
|
16
|
+
"#..##........##....#",
|
|
17
|
+
"#..##........##....#",
|
|
18
|
+
"#..................#",
|
|
19
|
+
"#..................#",
|
|
20
|
+
"#....##........##..#",
|
|
21
|
+
"#....##........##..#",
|
|
22
|
+
"#..................#",
|
|
23
|
+
"#........##........#",
|
|
24
|
+
"#........##........#",
|
|
25
|
+
"####################"
|
|
26
|
+
].freeze
|
|
27
|
+
PVP_SPAWN_CANDIDATES = [
|
|
28
|
+
[2.5, 2.5, 0.0],
|
|
29
|
+
[2.5, 11.5, 0.0],
|
|
30
|
+
[5.5, 5.5, 0.0],
|
|
31
|
+
[4.5, 9.5, 0.0],
|
|
32
|
+
[17.5, 11.5, Math::PI],
|
|
33
|
+
[17.5, 2.5, Math::PI],
|
|
34
|
+
[14.5, 8.5, Math::PI],
|
|
35
|
+
[15.5, 4.5, Math::PI]
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
def initialize(port: Config::PVP_PORT)
|
|
39
|
+
@port = port
|
|
40
|
+
@queue_mutex = Mutex.new
|
|
41
|
+
@queues = TEAM_SIZES.to_h { |team_size| [team_size, []] }
|
|
42
|
+
@wavesfight_queues = Hash.new { |hash, key| hash[key] = [] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run
|
|
46
|
+
cert, key = load_or_create_cert
|
|
47
|
+
|
|
48
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
49
|
+
ctx.cert = cert
|
|
50
|
+
ctx.key = key
|
|
51
|
+
|
|
52
|
+
tcp_server = TCPServer.new("0.0.0.0", @port)
|
|
53
|
+
ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, ctx)
|
|
54
|
+
ssl_server.start_immediately = true
|
|
55
|
+
|
|
56
|
+
puts "Termfront PvP server listening on 0.0.0.0:#{@port}"
|
|
57
|
+
|
|
58
|
+
loop do
|
|
59
|
+
begin
|
|
60
|
+
client = ssl_server.accept
|
|
61
|
+
configure_client(client)
|
|
62
|
+
enqueue_player(client)
|
|
63
|
+
rescue OpenSSL::SSL::SSLError => e
|
|
64
|
+
puts "SSL handshake failed: #{e.message}"
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
puts "Accept error: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def configure_client(client)
|
|
74
|
+
client.sync = true
|
|
75
|
+
return unless client.respond_to?(:to_io)
|
|
76
|
+
|
|
77
|
+
io = client.to_io
|
|
78
|
+
io.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if io.respond_to?(:setsockopt)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def enqueue_player(client)
|
|
82
|
+
request = read_queue_request(client)
|
|
83
|
+
unless request
|
|
84
|
+
client.close
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
peer = begin
|
|
88
|
+
client.peeraddr[2]
|
|
89
|
+
rescue StandardError
|
|
90
|
+
"unknown"
|
|
91
|
+
end
|
|
92
|
+
if request[:mode] == :wavesfight
|
|
93
|
+
enqueue_wavesfight_player(client, peer, request)
|
|
94
|
+
else
|
|
95
|
+
enqueue_pvp_player(client, peer, request[:team_size])
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def enqueue_pvp_player(client, peer, team_size)
|
|
100
|
+
puts "Player connected from #{peer}, queued for #{team_size}v#{team_size}"
|
|
101
|
+
|
|
102
|
+
match_players = nil
|
|
103
|
+
@queue_mutex.synchronize do
|
|
104
|
+
@queues[team_size] << { socket: client, peer: peer }
|
|
105
|
+
required = team_size * 2
|
|
106
|
+
if @queues[team_size].size >= required
|
|
107
|
+
match_players = @queues[team_size].shift(required)
|
|
108
|
+
else
|
|
109
|
+
waiting = @queues[team_size].size
|
|
110
|
+
puts "Queue #{team_size}v#{team_size}: #{waiting}/#{required}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
return unless match_players
|
|
114
|
+
|
|
115
|
+
Thread.new { run_match(team_size, match_players) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def enqueue_wavesfight_player(client, peer, request)
|
|
119
|
+
mission_id = request[:mission_id]
|
|
120
|
+
difficulty = request[:difficulty]
|
|
121
|
+
key = [mission_id, difficulty]
|
|
122
|
+
puts "Player connected from #{peer}, queued for wavesfight #{mission_id} diff=#{difficulty}"
|
|
123
|
+
|
|
124
|
+
match_players = nil
|
|
125
|
+
@queue_mutex.synchronize do
|
|
126
|
+
@wavesfight_queues[key] << { socket: client, peer: peer }
|
|
127
|
+
if @wavesfight_queues[key].size >= 2
|
|
128
|
+
match_players = @wavesfight_queues[key].shift(2)
|
|
129
|
+
else
|
|
130
|
+
waiting = @wavesfight_queues[key].size
|
|
131
|
+
puts "Queue wavesfight #{mission_id}: #{waiting}/2"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
return unless match_players
|
|
135
|
+
|
|
136
|
+
Thread.new { run_wavesfight_match(mission_id, difficulty, match_players) }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def read_queue_request(client)
|
|
140
|
+
buf = +""
|
|
141
|
+
deadline = Time.now + 15
|
|
142
|
+
|
|
143
|
+
while Time.now < deadline
|
|
144
|
+
readable, = IO.select([client], nil, nil, 0.5)
|
|
145
|
+
next unless readable
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
buf << client.read_nonblock(4096)
|
|
149
|
+
rescue IO::WaitReadable
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
while (nl = buf.index("\n"))
|
|
154
|
+
line = buf.slice!(0, nl + 1)
|
|
155
|
+
begin
|
|
156
|
+
msg = JSON.parse(line, symbolize_names: true)
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
next
|
|
159
|
+
end
|
|
160
|
+
next unless msg[:t] == "queue"
|
|
161
|
+
|
|
162
|
+
if msg[:mode].to_s == "wavesfight"
|
|
163
|
+
return {
|
|
164
|
+
mode: :wavesfight,
|
|
165
|
+
mission_id: msg[:mission_id].to_s,
|
|
166
|
+
difficulty: [[msg[:difficulty].to_i, 0].max, Enemy::Base::DIFFICULTIES.size - 1].min
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
team_size = msg[:team_size].to_i
|
|
171
|
+
return { mode: :pvp, team_size: TEAM_SIZES.include?(team_size) ? team_size : 1 }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
{ mode: :pvp, team_size: 1 }
|
|
176
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def run_match(team_size, players)
|
|
181
|
+
total_players = team_size * 2
|
|
182
|
+
puts "Match starting: #{team_size}v#{team_size} (#{total_players} players)"
|
|
183
|
+
|
|
184
|
+
roster = players.each_with_index.map do |entry, idx|
|
|
185
|
+
team = idx < team_size ? 0 : 1
|
|
186
|
+
{
|
|
187
|
+
id: idx,
|
|
188
|
+
team: team,
|
|
189
|
+
socket: entry[:socket],
|
|
190
|
+
peer: entry[:peer],
|
|
191
|
+
spawn: pvp_spawns[idx],
|
|
192
|
+
buf: +"",
|
|
193
|
+
alive: true
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
roster.each do |player|
|
|
198
|
+
send_json(player[:socket], {
|
|
199
|
+
t: "start",
|
|
200
|
+
id: player[:id],
|
|
201
|
+
team: player[:team],
|
|
202
|
+
team_size: team_size,
|
|
203
|
+
map: PVP_MAP,
|
|
204
|
+
players: roster.map { |p| { id: p[:id], team: p[:team], spawn: p[:spawn] } }
|
|
205
|
+
})
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
loop do
|
|
209
|
+
sockets = roster.filter_map do |player|
|
|
210
|
+
sock = player[:socket]
|
|
211
|
+
sock unless sock.closed?
|
|
212
|
+
rescue IOError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
break if sockets.empty?
|
|
216
|
+
|
|
217
|
+
readable, = IO.select(sockets, nil, nil, 0.5)
|
|
218
|
+
next unless readable
|
|
219
|
+
|
|
220
|
+
readable.each do |sock|
|
|
221
|
+
player = roster.find { |entry| entry[:socket] == sock }
|
|
222
|
+
next unless player
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
player[:buf] << sock.read_nonblock(4096)
|
|
226
|
+
consume_messages(roster, player)
|
|
227
|
+
rescue IO::WaitReadable
|
|
228
|
+
next
|
|
229
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
230
|
+
puts "Player #{player[:id]} disconnected from #{player[:peer]}"
|
|
231
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
232
|
+
close_players(roster)
|
|
233
|
+
puts "Match aborted."
|
|
234
|
+
return
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
winner = winning_team(roster)
|
|
239
|
+
next if winner.nil?
|
|
240
|
+
|
|
241
|
+
broadcast(roster, { t: "match_end", reason: "team_eliminated", winner: winner })
|
|
242
|
+
close_players(roster)
|
|
243
|
+
puts "Match ended. Team #{winner} won."
|
|
244
|
+
return
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def consume_messages(roster, player)
|
|
249
|
+
while (nl = player[:buf].index("\n"))
|
|
250
|
+
line = player[:buf].slice!(0, nl + 1)
|
|
251
|
+
begin
|
|
252
|
+
msg = JSON.parse(line, symbolize_names: true)
|
|
253
|
+
rescue JSON::ParserError
|
|
254
|
+
next
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
case msg[:t]
|
|
258
|
+
when "ping"
|
|
259
|
+
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
260
|
+
when "state"
|
|
261
|
+
broadcast(roster, msg.merge(from: player[:id]), except: player[:id])
|
|
262
|
+
when "hit"
|
|
263
|
+
route_hit(roster, player, msg)
|
|
264
|
+
when "dead"
|
|
265
|
+
player[:alive] = false
|
|
266
|
+
broadcast(roster, { t: "dead", from: player[:id] }, except: player[:id])
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def route_hit(roster, attacker, msg)
|
|
272
|
+
target_id = msg[:target].to_i
|
|
273
|
+
target = roster.find { |player| player[:id] == target_id }
|
|
274
|
+
return unless target
|
|
275
|
+
return unless target[:alive] && attacker[:alive]
|
|
276
|
+
return if target[:team] == attacker[:team]
|
|
277
|
+
|
|
278
|
+
send_json(target[:socket], { t: "hit", from: attacker[:id], d: msg[:d] || Config::PVP_HIT_DMG })
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def winning_team(roster)
|
|
282
|
+
alive_teams = roster.select { |player| player[:alive] }.map { |player| player[:team] }.uniq
|
|
283
|
+
return nil unless alive_teams.size == 1
|
|
284
|
+
|
|
285
|
+
alive_teams.first
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def broadcast(roster, msg, except: nil)
|
|
289
|
+
roster.each do |player|
|
|
290
|
+
next if player[:id] == except
|
|
291
|
+
|
|
292
|
+
send_json(player[:socket], msg)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def send_json(socket, msg)
|
|
297
|
+
socket.write(JSON.generate(msg) + "\n")
|
|
298
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError, OpenSSL::SSL::SSLError
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def close_players(roster)
|
|
303
|
+
roster.each do |player|
|
|
304
|
+
player[:socket].close
|
|
305
|
+
rescue StandardError
|
|
306
|
+
nil
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def run_wavesfight_match(mission_id, difficulty, players)
|
|
311
|
+
mission_klass = Mission::Base.wavesfight.find { |klass| klass.new.id == mission_id }
|
|
312
|
+
unless mission_klass
|
|
313
|
+
players.each { |player| player[:socket].close rescue nil }
|
|
314
|
+
return
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
mission = mission_klass.new
|
|
318
|
+
map = mission.build_map
|
|
319
|
+
spawns = wavesfight_spawns(map, mission.spawn)
|
|
320
|
+
roster = players.each_with_index.map do |entry, idx|
|
|
321
|
+
spawn = spawns[idx]
|
|
322
|
+
{
|
|
323
|
+
id: idx,
|
|
324
|
+
socket: entry[:socket],
|
|
325
|
+
peer: entry[:peer],
|
|
326
|
+
buf: +"",
|
|
327
|
+
x: spawn[0],
|
|
328
|
+
y: spawn[1],
|
|
329
|
+
angle: spawn[2],
|
|
330
|
+
shield: Config::SHIELD_MAX,
|
|
331
|
+
health: Config::HEALTH_MAX,
|
|
332
|
+
weapon: :ar,
|
|
333
|
+
ammo: 60,
|
|
334
|
+
fire_flash: 0,
|
|
335
|
+
alive: true
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
session = {
|
|
340
|
+
mission: mission,
|
|
341
|
+
map: map,
|
|
342
|
+
difficulty: difficulty,
|
|
343
|
+
wave: 0,
|
|
344
|
+
enemies: [],
|
|
345
|
+
projectiles: [],
|
|
346
|
+
clock: Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
347
|
+
}
|
|
348
|
+
start_wavesfight_wave(session)
|
|
349
|
+
|
|
350
|
+
roster.each do |player|
|
|
351
|
+
send_json(player[:socket], {
|
|
352
|
+
t: "wavesfight_start",
|
|
353
|
+
id: player[:id],
|
|
354
|
+
map: mission.map_data,
|
|
355
|
+
mission: mission.name,
|
|
356
|
+
players: roster.map { |entry| { id: entry[:id], spawn: [entry[:x], entry[:y], entry[:angle]] } }
|
|
357
|
+
})
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
last_broadcast = session[:clock]
|
|
361
|
+
loop do
|
|
362
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
363
|
+
dt = now - session[:clock]
|
|
364
|
+
session[:clock] = now
|
|
365
|
+
|
|
366
|
+
sockets = roster.filter_map do |player|
|
|
367
|
+
sock = player[:socket]
|
|
368
|
+
sock unless sock.closed?
|
|
369
|
+
rescue IOError
|
|
370
|
+
nil
|
|
371
|
+
end
|
|
372
|
+
break if sockets.empty?
|
|
373
|
+
|
|
374
|
+
readable, = IO.select(sockets, nil, nil, 0.01)
|
|
375
|
+
if readable
|
|
376
|
+
readable.each do |sock|
|
|
377
|
+
player = roster.find { |entry| entry[:socket] == sock }
|
|
378
|
+
next unless player
|
|
379
|
+
|
|
380
|
+
begin
|
|
381
|
+
player[:buf] << sock.read_nonblock(4096)
|
|
382
|
+
consume_wavesfight_messages(roster, session, player)
|
|
383
|
+
rescue IO::WaitReadable
|
|
384
|
+
next
|
|
385
|
+
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
|
|
386
|
+
broadcast(roster, { t: "match_end", reason: "disconnect", player_id: player[:id] }, except: player[:id])
|
|
387
|
+
close_players(roster)
|
|
388
|
+
return
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
update_wavesfight_session(roster, session, dt)
|
|
394
|
+
if all_wavesfight_players_dead?(roster)
|
|
395
|
+
broadcast(roster, { t: "match_end", reason: "defeat", wave: session[:wave] })
|
|
396
|
+
close_players(roster)
|
|
397
|
+
return
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
if now - last_broadcast >= 1.0 / 15.0
|
|
401
|
+
broadcast_wavesfight_world(roster, session)
|
|
402
|
+
last_broadcast = now
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def consume_wavesfight_messages(roster, session, player)
|
|
408
|
+
while (nl = player[:buf].index("\n"))
|
|
409
|
+
line = player[:buf].slice!(0, nl + 1)
|
|
410
|
+
begin
|
|
411
|
+
msg = JSON.parse(line, symbolize_names: true)
|
|
412
|
+
rescue JSON::ParserError
|
|
413
|
+
next
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
case msg[:t]
|
|
417
|
+
when "ping"
|
|
418
|
+
send_json(player[:socket], { t: "pong", ts: msg[:ts] })
|
|
419
|
+
when "state"
|
|
420
|
+
player[:x] = msg[:x]
|
|
421
|
+
player[:y] = msg[:y]
|
|
422
|
+
player[:angle] = msg[:a]
|
|
423
|
+
player[:weapon] = msg[:w]&.to_sym || player[:weapon]
|
|
424
|
+
player[:ammo] = msg[:am] if msg.key?(:am)
|
|
425
|
+
player[:fire_flash] = msg[:ff] || 0
|
|
426
|
+
when "fire"
|
|
427
|
+
player[:fire_flash] = 4
|
|
428
|
+
process_wavesfight_fire(session, player)
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def update_wavesfight_session(roster, session, dt)
|
|
434
|
+
roster.each do |player|
|
|
435
|
+
player[:fire_flash] -= 1 if player[:fire_flash].to_i > 0
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
session[:enemies].each do |enemy|
|
|
439
|
+
next unless enemy.alive
|
|
440
|
+
|
|
441
|
+
target = roster.select { |player| player[:alive] }
|
|
442
|
+
.min_by { |player| (player[:x] - enemy.x)**2 + (player[:y] - enemy.y)**2 }
|
|
443
|
+
next unless target
|
|
444
|
+
|
|
445
|
+
enemy.update(dt, Struct.new(:x, :y).new(target[:x], target[:y]), session[:projectiles], session[:map],
|
|
446
|
+
session[:clock], difficulty: session[:difficulty])
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
session[:projectiles].reject! do |projectile|
|
|
450
|
+
projectile.update(dt)
|
|
451
|
+
if projectile.hit_wall?(session[:map])
|
|
452
|
+
true
|
|
453
|
+
else
|
|
454
|
+
target = roster.find { |player| player[:alive] && projectile.hit_player?(player[:x], player[:y]) }
|
|
455
|
+
if target
|
|
456
|
+
dmg = enemy_damage(projectile.type)
|
|
457
|
+
apply_wavesfight_damage(target, dmg)
|
|
458
|
+
send_json(target[:socket], { t: "hit", d: dmg })
|
|
459
|
+
true
|
|
460
|
+
else
|
|
461
|
+
false
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
if session[:enemies].all? { |enemy| !enemy.alive }
|
|
467
|
+
start_wavesfight_wave(session)
|
|
468
|
+
broadcast(roster, { t: "wave_start", wave: session[:wave], difficulty: session[:difficulty] })
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def process_wavesfight_fire(session, player)
|
|
473
|
+
weapon = Weapon::Base.build(player[:weapon] || :ar, player[:ammo])
|
|
474
|
+
dx = Math.cos(player[:angle])
|
|
475
|
+
dy = Math.sin(player[:angle])
|
|
476
|
+
best = nil
|
|
477
|
+
best_dot = Float::INFINITY
|
|
478
|
+
|
|
479
|
+
session[:enemies].each do |enemy|
|
|
480
|
+
next unless enemy.alive
|
|
481
|
+
|
|
482
|
+
ox = enemy.x - player[:x]
|
|
483
|
+
oy = enemy.y - player[:y]
|
|
484
|
+
dot = ox * dx + oy * dy
|
|
485
|
+
next if dot < 0.1
|
|
486
|
+
|
|
487
|
+
perp = (ox * (-dy) + oy * dx).abs
|
|
488
|
+
next if perp > weapon.hit_width
|
|
489
|
+
next unless session[:map].line_of_sight?(player[:x], player[:y], enemy.x, enemy.y)
|
|
490
|
+
next unless dot < best_dot
|
|
491
|
+
|
|
492
|
+
best = enemy
|
|
493
|
+
best_dot = dot
|
|
494
|
+
end
|
|
495
|
+
return unless best
|
|
496
|
+
|
|
497
|
+
best.take_damage(1)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def enemy_damage(type)
|
|
501
|
+
enemy_klass = Enemy::Base.registry[type]
|
|
502
|
+
enemy_klass ? enemy_klass.allocate.send(:damage) : 10
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def apply_wavesfight_damage(player, amount)
|
|
506
|
+
if player[:shield] > 0
|
|
507
|
+
overflow = amount - player[:shield]
|
|
508
|
+
player[:shield] = [player[:shield] - amount, 0].max
|
|
509
|
+
player[:health] = [player[:health] - [overflow, 0].max, 0].max if player[:shield] == 0
|
|
510
|
+
else
|
|
511
|
+
player[:health] = [player[:health] - amount, 0].max
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
player[:alive] = false if player[:health] <= 0
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def all_wavesfight_players_dead?(roster)
|
|
518
|
+
roster.none? { |player| player[:alive] }
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def broadcast_wavesfight_world(roster, session)
|
|
522
|
+
msg = {
|
|
523
|
+
t: "world",
|
|
524
|
+
wave: session[:wave],
|
|
525
|
+
difficulty: session[:difficulty],
|
|
526
|
+
players: roster.map do |player|
|
|
527
|
+
{
|
|
528
|
+
id: player[:id], x: player[:x], y: player[:y], a: player[:angle],
|
|
529
|
+
s: player[:shield], h: player[:health], w: player[:weapon], am: player[:ammo],
|
|
530
|
+
ff: player[:fire_flash], alive: player[:alive]
|
|
531
|
+
}
|
|
532
|
+
end,
|
|
533
|
+
enemies: session[:enemies].map do |enemy|
|
|
534
|
+
{
|
|
535
|
+
id: enemy.object_id, x: enemy.x, y: enemy.y, type: enemy.sprite_id,
|
|
536
|
+
hp: enemy.hp, max_hp: enemy.max_hp, alive: enemy.alive
|
|
537
|
+
}
|
|
538
|
+
end,
|
|
539
|
+
projectiles: session[:projectiles].map { |projectile| { x: projectile.x, y: projectile.y, type: projectile.type } },
|
|
540
|
+
drops: []
|
|
541
|
+
}
|
|
542
|
+
broadcast(roster, msg)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def start_wavesfight_wave(session)
|
|
546
|
+
session[:wave] += 1
|
|
547
|
+
session[:difficulty] = [session[:difficulty], 1 + ((session[:wave] - 1) / 3)].max
|
|
548
|
+
session[:difficulty] = [session[:difficulty], Enemy::Base::DIFFICULTIES.size - 1].min
|
|
549
|
+
session[:enemies] = build_wavesfight_enemies(session[:mission], session[:wave], session[:difficulty])
|
|
550
|
+
session[:projectiles].clear
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def build_wavesfight_enemies(mission, wave, difficulty_index)
|
|
554
|
+
enemies = mission.build_enemies(difficulty_index)
|
|
555
|
+
bonus_count = (wave - 1) * 2
|
|
556
|
+
enemies + Enemy::Base.generate_extras(mission.enemy_defs, bonus_count, difficulty_index)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def wavesfight_spawns(map, spawn)
|
|
560
|
+
x, y, angle = spawn
|
|
561
|
+
spawns = [[x, y, angle]]
|
|
562
|
+
offsets = [
|
|
563
|
+
[1.0, 0.0], [-1.0, 0.0], [0.0, 1.0], [0.0, -1.0],
|
|
564
|
+
[1.0, 1.0], [1.0, -1.0], [-1.0, 1.0], [-1.0, -1.0]
|
|
565
|
+
]
|
|
566
|
+
offsets.each do |dx, dy|
|
|
567
|
+
nx = x + dx
|
|
568
|
+
ny = y + dy
|
|
569
|
+
next if map.blocked?(nx, ny)
|
|
570
|
+
|
|
571
|
+
spawns << [nx, ny, angle]
|
|
572
|
+
break
|
|
573
|
+
end
|
|
574
|
+
spawns
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def generate_self_signed_cert
|
|
578
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
579
|
+
cert = OpenSSL::X509::Certificate.new
|
|
580
|
+
cert.version = 2
|
|
581
|
+
cert.serial = rand(1 << 64)
|
|
582
|
+
cert.subject = OpenSSL::X509::Name.parse("/CN=termfront-pvp")
|
|
583
|
+
cert.issuer = cert.subject
|
|
584
|
+
cert.public_key = key.public_key
|
|
585
|
+
cert.not_before = Time.now
|
|
586
|
+
cert.not_after = Time.now + 365 * 24 * 60 * 60
|
|
587
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
588
|
+
[cert, key]
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def load_or_create_cert
|
|
592
|
+
cert_file = "termfront_server.crt"
|
|
593
|
+
key_file = "termfront_server.key"
|
|
594
|
+
|
|
595
|
+
if File.exist?(cert_file) && File.exist?(key_file)
|
|
596
|
+
cert = OpenSSL::X509::Certificate.new(File.read(cert_file))
|
|
597
|
+
key = OpenSSL::PKey::RSA.new(File.read(key_file))
|
|
598
|
+
puts "Loaded existing certificate."
|
|
599
|
+
else
|
|
600
|
+
cert, key = generate_self_signed_cert
|
|
601
|
+
File.write(cert_file, cert.to_pem)
|
|
602
|
+
File.write(key_file, key.to_pem)
|
|
603
|
+
puts "Generated new self-signed certificate."
|
|
604
|
+
end
|
|
605
|
+
[cert, key]
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
def pvp_spawns
|
|
609
|
+
@pvp_spawns ||= begin
|
|
610
|
+
map = Map.new(PVP_MAP)
|
|
611
|
+
PVP_SPAWN_CANDIDATES.each do |spawn|
|
|
612
|
+
x, y, = spawn
|
|
613
|
+
raise "Invalid PvP spawn #{spawn.inspect}" if map.blocked?(x, y)
|
|
614
|
+
end
|
|
615
|
+
PVP_SPAWN_CANDIDATES
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
end
|