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.
Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +41 -0
  3. data/LICENSE +21 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +160 -0
  6. data/Rakefile +12 -0
  7. data/data/audio/THIRD_PARTY_NOTICES.md +45 -0
  8. data/data/audio/beep_02.ogg +0 -0
  9. data/data/audio/button1.ogg +0 -0
  10. data/data/audio/complete.ogg +0 -0
  11. data/data/audio/manifest.json +17 -0
  12. data/data/audio/mission_bgm.wav +0 -0
  13. data/data/audio/mission_clear_se.wav +0 -0
  14. data/data/audio/on.ogg +0 -0
  15. data/data/audio/page_se.wav +0 -0
  16. data/data/audio/sector.mp3 +0 -0
  17. data/data/audio/sfx_22b.ogg +0 -0
  18. data/data/audio/shield_alarm_se.wav +0 -0
  19. data/data/audio/shield_regen_se.wav +0 -0
  20. data/data/audio/shoot_01.ogg +0 -0
  21. data/data/audio/terminal_se.wav +0 -0
  22. data/data/audio/title.mp3 +0 -0
  23. data/data/audio/title_bgm.wav +0 -0
  24. data/data/audio/victory.mp3 +0 -0
  25. data/data/events/corridor_sweep.json +27 -0
  26. data/data/events/final_push.json +40 -0
  27. data/data/events/stronghold.json +27 -0
  28. data/data/events/the_gauntlet.json +27 -0
  29. data/data/events/training_grounds.json +31 -0
  30. data/exe/termfront +6 -0
  31. data/exe/termfront-server +7 -0
  32. data/lib/termfront/audio_manager.rb +225 -0
  33. data/lib/termfront/config.rb +38 -0
  34. data/lib/termfront/demo_player.rb +181 -0
  35. data/lib/termfront/drop_item/base.rb +26 -0
  36. data/lib/termfront/drop_item/weapon.rb +38 -0
  37. data/lib/termfront/enemy/base.rb +133 -0
  38. data/lib/termfront/enemy/crawler.rb +18 -0
  39. data/lib/termfront/enemy/executor.rb +18 -0
  40. data/lib/termfront/game.rb +637 -0
  41. data/lib/termfront/input.rb +75 -0
  42. data/lib/termfront/map.rb +72 -0
  43. data/lib/termfront/mission/base.rb +81 -0
  44. data/lib/termfront/mission/corridor_sweep.rb +41 -0
  45. data/lib/termfront/mission/event_loader.rb +87 -0
  46. data/lib/termfront/mission/event_runtime.rb +37 -0
  47. data/lib/termfront/mission/final_push.rb +44 -0
  48. data/lib/termfront/mission/stronghold.rb +45 -0
  49. data/lib/termfront/mission/the_gauntlet.rb +38 -0
  50. data/lib/termfront/mission/training.rb +38 -0
  51. data/lib/termfront/mission/training_grounds.rb +37 -0
  52. data/lib/termfront/network/client.rb +865 -0
  53. data/lib/termfront/network/connection.rb +101 -0
  54. data/lib/termfront/network/server.rb +620 -0
  55. data/lib/termfront/network/wavesfight_client.rb +364 -0
  56. data/lib/termfront/opponent.rb +24 -0
  57. data/lib/termfront/player.rb +147 -0
  58. data/lib/termfront/projectile.rb +44 -0
  59. data/lib/termfront/remote_enemy.rb +21 -0
  60. data/lib/termfront/renderer.rb +707 -0
  61. data/lib/termfront/scene_player.rb +164 -0
  62. data/lib/termfront/sprite.rb +73 -0
  63. data/lib/termfront/terminal_output.rb +63 -0
  64. data/lib/termfront/title_screen.rb +299 -0
  65. data/lib/termfront/version.rb +5 -0
  66. data/lib/termfront/weapon/assault_rifle.rb +15 -0
  67. data/lib/termfront/weapon/base.rb +44 -0
  68. data/lib/termfront/weapon/pistol.rb +15 -0
  69. data/lib/termfront/weapon/shock_pistol.rb +15 -0
  70. data/lib/termfront/weapon/shock_rifle.rb +15 -0
  71. data/lib/termfront.rb +51 -0
  72. data/sig/termfront.rbs +4 -0
  73. 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