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,865 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Network
5
+ class Client
6
+ TEAM_SIZES = [1, 2, 4].freeze
7
+
8
+ def initialize(stdout)
9
+ @stdout = stdout
10
+ @conn = Connection.new
11
+ @input = Input.new
12
+ @renderer = Renderer.new(stdout)
13
+ @audio = AudioManager.new
14
+ end
15
+
16
+ def run
17
+ addr = prompt_address
18
+ return unless addr
19
+
20
+ team_size = prompt_team_size
21
+ return unless team_size
22
+
23
+ host, port = addr.include?(":") ? addr.split(":", 2).then { |h, p| [h, p.to_i] } : [addr, Config::PVP_PORT]
24
+ begin
25
+ @conn.connect(host, port)
26
+ @conn.send_msg({ t: "queue", team_size: team_size })
27
+ rescue StandardError => e
28
+ show_error("Connection failed: #{e.message}")
29
+ return
30
+ end
31
+
32
+ begin
33
+ unless wait_for_start(team_size)
34
+ @conn.close
35
+ return
36
+ end
37
+ run_game_loop
38
+ rescue StandardError => e
39
+ show_error("Error: #{e.message}")
40
+ ensure
41
+ @audio.close
42
+ @conn.close
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def prompt_address
49
+ input = "localhost:#{Config::PVP_PORT}"
50
+
51
+ STDIN.raw do |stdin|
52
+ loop do
53
+ rows, cols = @stdout.winsize
54
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
55
+ lines = Array.new(rows) { " " * cols }
56
+
57
+ title = "PvP - Enter Server Address"
58
+ tc = [(cols - title.size) / 2 + 1, 1].max
59
+ lines[rows / 2 - 3] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;96m#{title}\e[0m", cols)
60
+
61
+ prompt = "> #{input}_"
62
+ pc = [(cols - prompt.size) / 2 + 1, 1].max
63
+ lines[rows / 2 - 1] = TerminalOutput.fit_ansi("#{" " * (pc - 1)}\e[97m> #{input}\e[5m_\e[0m", cols)
64
+
65
+ hint = "(Enter to continue, ESC to cancel)"
66
+ hc = [(cols - hint.size) / 2 + 1, 1].max
67
+ lines[rows / 2 + 1] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
68
+
69
+ lines.each_with_index do |line, index|
70
+ buf << line
71
+ buf << "\r\n" if index < rows - 1
72
+ end
73
+ buf << TerminalOutput.end_frame
74
+ TerminalOutput.write_all(@stdout, buf)
75
+
76
+ next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
77
+
78
+ begin
79
+ data = stdin.read_nonblock(64)
80
+ data.each_byte do |b|
81
+ case b
82
+ when 27 then return nil
83
+ when 13, 10 then return input.empty? ? "localhost:#{Config::PVP_PORT}" : input
84
+ when 127, 8 then input = input[0...-1] unless input.empty?
85
+ when 32..126 then input << b.chr
86
+ end
87
+ end
88
+ rescue IO::WaitReadable
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def prompt_team_size
95
+ selected = 0
96
+
97
+ STDIN.raw do |stdin|
98
+ loop do
99
+ rows, cols = @stdout.winsize
100
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
101
+ lines = Array.new(rows) { " " * cols }
102
+
103
+ title = "PvP - Select Match Size"
104
+ tc = [(cols - title.size) / 2 + 1, 1].max
105
+ lines[rows / 2 - 4] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;96m#{title}\e[0m", cols)
106
+
107
+ TEAM_SIZES.each_with_index do |team_size, idx|
108
+ label = "#{team_size}v#{team_size}"
109
+ text = idx == selected ? "\e[30;103m #{label} \e[0m" : "\e[97m #{label} \e[0m"
110
+ c = [(cols - label.size - 2) / 2 + 1, 1].max
111
+ lines[rows / 2 - 1 + idx] = TerminalOutput.fit_ansi("#{" " * (c - 1)}#{text}", cols)
112
+ end
113
+
114
+ hint = "(Up/Down, J/K, or 1/2/4 to change, Enter to queue, ESC to cancel)"
115
+ hc = [(cols - hint.size) / 2 + 1, 1].max
116
+ lines[rows / 2 + 4] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
117
+
118
+ lines.each_with_index do |line, index|
119
+ buf << line
120
+ buf << "\r\n" if index < rows - 1
121
+ end
122
+ buf << TerminalOutput.end_frame
123
+ TerminalOutput.write_all(@stdout, buf)
124
+
125
+ next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
126
+
127
+ begin
128
+ data = stdin.read_nonblock(64)
129
+ bytes = data.bytes
130
+ idx = 0
131
+ while idx < bytes.size
132
+ b = bytes[idx]
133
+ case b
134
+ when 27
135
+ if bytes[idx + 1] == 91 && bytes[idx + 2] == 65
136
+ selected = (selected - 1) % TEAM_SIZES.size
137
+ idx += 3
138
+ next
139
+ elsif bytes[idx + 1] == 91 && bytes[idx + 2] == 66
140
+ selected = (selected + 1) % TEAM_SIZES.size
141
+ idx += 3
142
+ next
143
+ else
144
+ return nil
145
+ end
146
+ when 13, 10 then return TEAM_SIZES[selected]
147
+ when 65, 107 then selected = (selected - 1) % TEAM_SIZES.size
148
+ when 66, 106 then selected = (selected + 1) % TEAM_SIZES.size
149
+ when 49 then return 1
150
+ when 50 then return 2
151
+ when 52 then return 4
152
+ end
153
+ idx += 1
154
+ end
155
+ rescue IO::WaitReadable
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ def wait_for_start(team_size)
162
+ STDIN.raw do |stdin|
163
+ loop do
164
+ rows, cols = @stdout.winsize
165
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
166
+ lines = Array.new(rows) { " " * cols }
167
+ msg = "Waiting for #{team_size}v#{team_size} match..."
168
+ mc = [(cols - msg.size) / 2 + 1, 1].max
169
+ lines[rows / 2 - 1] = TerminalOutput.fit_ansi("#{" " * (mc - 1)}\e[1;93m#{msg}\e[0m", cols)
170
+ hint = "(ESC to cancel)"
171
+ hc = [(cols - hint.size) / 2 + 1, 1].max
172
+ lines[rows / 2 + 1] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
173
+ lines.each_with_index do |line, index|
174
+ buf << line
175
+ buf << "\r\n" if index < rows - 1
176
+ end
177
+ buf << TerminalOutput.end_frame
178
+ TerminalOutput.write_all(@stdout, buf)
179
+
180
+ if IO.select([stdin], nil, nil, 0)
181
+ begin
182
+ ch = stdin.read_nonblock(64)
183
+ return false if ch.bytes.include?(27)
184
+ rescue IO::WaitReadable
185
+ end
186
+ end
187
+
188
+ next unless IO.select([@conn.socket], nil, nil, 0.1)
189
+
190
+ @conn.receive.each do |msg|
191
+ next unless msg[:t] == "start"
192
+
193
+ load_pvp_match(msg[:map], msg[:players], msg[:id], msg[:team], msg[:team_size])
194
+ return true
195
+ end
196
+ end
197
+ end
198
+ end
199
+
200
+ def load_pvp_match(map_data, players, player_id, team, team_size)
201
+ @map = Map.new(map_data)
202
+ weapons = [Weapon::Base.build(:ar, 60), Weapon::Base.build(:pistol)]
203
+ self_info = players.find { |entry| entry[:id] == player_id }
204
+ spawn = self_info[:spawn]
205
+
206
+ @player = Player.new(x: spawn[0], y: spawn[1], angle: spawn[2], weapons: weapons)
207
+ @player.drops = []
208
+ @player_id = player_id
209
+ @team = team
210
+ @team_size = team_size
211
+ @match_end = nil
212
+ @sent_dead = false
213
+ @projectiles = []
214
+
215
+ @remotes = {}
216
+ players.each do |entry|
217
+ next if entry[:id] == player_id
218
+
219
+ @remotes[entry[:id]] = build_remote_player(entry)
220
+ end
221
+ end
222
+
223
+ def build_remote_player(entry)
224
+ spawn = entry[:spawn]
225
+ current = Opponent.new(x: spawn[0], y: spawn[1], angle: spawn[2])
226
+ prev = current.dup_state
227
+ render = current.dup_state
228
+
229
+ {
230
+ id: entry[:id],
231
+ team: entry[:team],
232
+ current: current,
233
+ prev: prev,
234
+ render: render,
235
+ lerp_t: 1.0
236
+ }
237
+ end
238
+
239
+ def run_game_loop
240
+ STDIN.raw do |stdin|
241
+ last_time = clock
242
+ last_ping = clock
243
+
244
+ loop do
245
+ now = clock
246
+ dt = now - last_time
247
+ last_time = now
248
+
249
+ keys = @input.process(stdin, player: @player)
250
+ return if @input.key?(:esc)
251
+
252
+ unless @player.dead
253
+ handle_player_actions(keys)
254
+ update(dt)
255
+ send_state
256
+ end
257
+
258
+ handle_messages
259
+ interpolate_opponents(dt)
260
+ render_pvp
261
+
262
+ if victory?
263
+ render_pvp_result("VICTORY", "\e[1;92m")
264
+ sleep 3
265
+ return
266
+ end
267
+
268
+ if defeated?
269
+ render_pvp_result("DEFEATED", "\e[1;91m")
270
+ sleep 3
271
+ return
272
+ end
273
+
274
+ if @match_end
275
+ text = @match_end[:reason] == "disconnect" ? "MATCH CANCELED" : "MATCH ENDED"
276
+ render_pvp_result(text, "\e[1;93m")
277
+ sleep 3
278
+ return
279
+ end
280
+
281
+ if now - last_ping > 2.0
282
+ @conn.ping(now)
283
+ last_ping = now
284
+ end
285
+
286
+ cap_frame(now)
287
+ end
288
+ end
289
+ end
290
+
291
+ def handle_player_actions(keys)
292
+ @player.swap_weapon if keys.include?(:t)
293
+ @player.try_pickup if keys.include?(:e)
294
+
295
+ return unless keys.include?(:space)
296
+
297
+ weapon = @player.current_weapon
298
+ return unless weapon.can_fire?(@player.last_fire, @player.game_time)
299
+ return unless weapon.infinite_ammo? || (weapon.ammo && weapon.ammo > 0)
300
+
301
+ @player.fire_flash = 4
302
+ weapon.consume_ammo!
303
+ @player.last_fire = @player.game_time
304
+ @audio.play_se(:shoot)
305
+ end
306
+
307
+ def handle_messages
308
+ @conn.receive.each do |msg|
309
+ case msg[:t]
310
+ when "state"
311
+ update_remote_state(msg)
312
+ when "hit"
313
+ @player.apply_damage(msg[:d] || Config::PVP_HIT_DMG)
314
+ @audio.play_se(:damage)
315
+ notify_death_if_needed
316
+ when "dead"
317
+ @remotes.delete(msg[:from])
318
+ when "match_end"
319
+ @match_end = msg
320
+ end
321
+ end
322
+ end
323
+
324
+ def update_remote_state(msg)
325
+ remote = @remotes[msg[:from]]
326
+ return unless remote
327
+
328
+ remote[:prev].x = remote[:render].x
329
+ remote[:prev].y = remote[:render].y
330
+ remote[:prev].angle = remote[:render].angle
331
+
332
+ remote[:current].x = msg[:x]
333
+ remote[:current].y = msg[:y]
334
+ remote[:current].angle = msg[:a]
335
+ remote[:current].shield = msg[:s]
336
+ remote[:current].health = msg[:h]
337
+ remote[:current].weapon = msg[:w]&.to_sym
338
+ remote[:current].ammo = msg[:am]
339
+ remote[:current].fire_flash = msg[:ff] || 0
340
+ remote[:lerp_t] = 0.0
341
+ end
342
+
343
+ def update(dt)
344
+ @player.game_time += dt
345
+
346
+ @player.angle -= Config::ROT_SPEED * dt if @input.key?(:left)
347
+ @player.angle += Config::ROT_SPEED * dt if @input.key?(:right)
348
+
349
+ dx = Math.cos(@player.angle)
350
+ dy = Math.sin(@player.angle)
351
+ sx = -dy
352
+ sy = dx
353
+
354
+ mvx = 0.0
355
+ mvy = 0.0
356
+ if @input.key?(:w)
357
+ mvx += dx * Config::MOVE_SPEED * dt
358
+ mvy += dy * Config::MOVE_SPEED * dt
359
+ end
360
+ if @input.key?(:s)
361
+ mvx -= dx * Config::MOVE_SPEED * dt
362
+ mvy -= dy * Config::MOVE_SPEED * dt
363
+ end
364
+ if @input.key?(:a)
365
+ mvx -= sx * Config::MOVE_SPEED * dt
366
+ mvy -= sy * Config::MOVE_SPEED * dt
367
+ end
368
+ if @input.key?(:d)
369
+ mvx += sx * Config::MOVE_SPEED * dt
370
+ mvy += sy * Config::MOVE_SPEED * dt
371
+ end
372
+
373
+ nx = @player.x + mvx
374
+ @player.x = nx unless @map.blocked?(nx, @player.y)
375
+ ny = @player.y + mvy
376
+ @player.y = ny unless @map.blocked?(@player.x, ny)
377
+
378
+ process_fire_pvp if @player.fire_flash == 4
379
+ @player.fire_flash -= 1 if @player.fire_flash > 0
380
+
381
+ @player.update_shield(dt, @stdout, audio: @audio)
382
+
383
+ notify_death_if_needed
384
+ end
385
+
386
+ def send_state
387
+ @conn.send_msg({
388
+ t: "state",
389
+ x: @player.x.round(3),
390
+ y: @player.y.round(3),
391
+ a: @player.angle.round(4),
392
+ s: @player.shield.round(1),
393
+ h: @player.health.round(1),
394
+ w: @player.current_weapon.type_id,
395
+ am: @player.current_weapon.ammo || -1,
396
+ ff: @player.fire_flash
397
+ })
398
+ end
399
+
400
+ def process_fire_pvp
401
+ dx = Math.cos(@player.angle)
402
+ dy = Math.sin(@player.angle)
403
+ weapon = @player.current_weapon
404
+
405
+ best_target = nil
406
+ best_dot = Float::INFINITY
407
+
408
+ enemy_players.each do |remote|
409
+ ox = remote[:render].x - @player.x
410
+ oy = remote[:render].y - @player.y
411
+ dot = ox * dx + oy * dy
412
+ next if dot < 0.1
413
+
414
+ perp = (ox * (-dy) + oy * dx).abs
415
+ next if perp > weapon.hit_width
416
+ next unless @map.line_of_sight?(@player.x, @player.y, remote[:render].x, remote[:render].y)
417
+ next unless dot < best_dot
418
+
419
+ best_target = remote
420
+ best_dot = dot
421
+ end
422
+ return unless best_target
423
+
424
+ @conn.send_msg({ t: "hit", target: best_target[:id], d: Config::PVP_HIT_DMG })
425
+ end
426
+
427
+ def interpolate_opponents(dt)
428
+ @remotes.each_value do |remote|
429
+ remote[:lerp_t] = [remote[:lerp_t] + dt * 15.0, 1.0].min
430
+ t = remote[:lerp_t]
431
+ remote[:render].x = remote[:prev].x + (remote[:current].x - remote[:prev].x) * t
432
+ remote[:render].y = remote[:prev].y + (remote[:current].y - remote[:prev].y) * t
433
+
434
+ da = remote[:current].angle - remote[:prev].angle
435
+ da -= 2 * Math::PI while da > Math::PI
436
+ da += 2 * Math::PI while da < -Math::PI
437
+ remote[:render].angle = remote[:prev].angle + da * t
438
+ end
439
+ end
440
+
441
+ def render_pvp
442
+ rows, cols = @stdout.winsize
443
+ rows = [rows, 6].max
444
+ cols = [cols, 20].max
445
+
446
+ radar_h = Config::RADAR_RADIUS * 2 + 1
447
+ view_h = [rows - 3 - radar_h, 4].max
448
+ view_w = cols
449
+ virt_h = view_h * 2
450
+
451
+ dx = Math.cos(@player.angle)
452
+ dy = Math.sin(@player.angle)
453
+ plane_x = -dy * Math.tan(Config::FOV / 2.0)
454
+ plane_y = dx * Math.tan(Config::FOV / 2.0)
455
+
456
+ dists = Array.new(view_w)
457
+ sides = Array.new(view_w)
458
+ view_w.times do |c|
459
+ cam = 2.0 * c / view_w - 1.0
460
+ dists[c], sides[c] = @renderer.cast_ray(@map, @player.x, @player.y, dx + plane_x * cam, dy + plane_y * cam)
461
+ end
462
+
463
+ vmid = virt_h / 2.0
464
+ wtop = Array.new(view_w)
465
+ wbot = Array.new(view_w)
466
+ wcol = Array.new(view_w)
467
+ view_w.times do |c|
468
+ d = dists[c]
469
+ lh = d > 0.01 ? (virt_h / d).to_i : virt_h
470
+ wtop[c] = [(vmid - lh / 2.0).to_i, 0].max
471
+ wbot[c] = [(vmid + lh / 2.0).to_i, virt_h].min
472
+ wcol[c] = Sprite.wall_brightness(d, sides[c])
473
+ end
474
+
475
+ buf = TerminalOutput.begin_frame(home: true)
476
+ render_pvp_hud(buf, cols)
477
+ render_view(buf, view_h, view_w, wtop, wbot, wcol)
478
+ render_remote_players_3d(buf, view_h, view_w, virt_h, dists)
479
+ buf << "\e[#{3 + view_h};1H"
480
+ render_pvp_radar(buf, cols, radar_h)
481
+ render_crosshair(buf, view_h, view_w, cols)
482
+ render_damage_flash(buf, view_h, view_w)
483
+ render_status_overlay(buf, view_h, cols)
484
+ buf << TerminalOutput.end_frame
485
+ TerminalOutput.write_all(@stdout, buf)
486
+ end
487
+
488
+ def render_view(buf, view_h, view_w, wtop, wbot, wcol)
489
+ view_h.times do |r|
490
+ vp0 = r * 2
491
+ vp1 = r * 2 + 1
492
+ pfg = -1
493
+ pbg = -1
494
+
495
+ view_w.times do |c|
496
+ tc = if vp0 < wtop[c]
497
+ Config::CEIL_C
498
+ else
499
+ (vp0 < wbot[c] ? wcol[c] : Config::FLOOR_C)
500
+ end
501
+ bc = if vp1 < wtop[c]
502
+ Config::CEIL_C
503
+ else
504
+ (vp1 < wbot[c] ? wcol[c] : Config::FLOOR_C)
505
+ end
506
+
507
+ if tc == bc
508
+ if bc != pbg
509
+ buf << "\e[48;5;#{bc}m"
510
+ pbg = bc
511
+ end
512
+ buf << " "
513
+ else
514
+ if tc != pfg && bc != pbg
515
+ buf << "\e[38;5;#{tc};48;5;#{bc}m"
516
+ elsif tc != pfg
517
+ buf << "\e[38;5;#{tc}m"
518
+ elsif bc != pbg
519
+ buf << "\e[48;5;#{bc}m"
520
+ end
521
+ pfg = tc
522
+ pbg = bc
523
+ buf << "\xE2\x96\x80"
524
+ end
525
+ end
526
+ buf << "\e[0m\r\n"
527
+ end
528
+ end
529
+
530
+ def render_remote_players_3d(buf, view_h, view_w, virt_h, dists)
531
+ sorted_remotes = @remotes.values.sort_by do |remote|
532
+ -distance_sq(remote[:render].x, remote[:render].y, @player.x, @player.y)
533
+ end
534
+
535
+ sorted_remotes.each do |remote|
536
+ render_remote_player_3d(buf, view_h, view_w, virt_h, dists, remote)
537
+ end
538
+ end
539
+
540
+ def render_remote_player_3d(buf, view_h, view_w, virt_h, dists, remote)
541
+ dx = Math.cos(@player.angle)
542
+ dy = Math.sin(@player.angle)
543
+ px = -dy * Math.tan(Config::FOV / 2.0)
544
+ py = dx * Math.tan(Config::FOV / 2.0)
545
+ inv = 1.0 / (px * dy - py * dx)
546
+
547
+ ex = remote[:render].x - @player.x
548
+ ey = remote[:render].y - @player.y
549
+ tx = inv * (dy * ex - dx * ey)
550
+ tz = inv * (-py * ex + px * ey)
551
+ return if tz < 0.2
552
+
553
+ sx = ((view_w / 2.0) * (1 + tx / tz)).to_i
554
+ sprite_h = (virt_h / tz).to_i
555
+ draw_top = [(virt_h / 2 - sprite_h / 2), 0].max
556
+ draw_bot = [(virt_h / 2 + sprite_h / 2), virt_h].min
557
+ sprite_w = (sprite_h / 2.0).to_i
558
+ start_x = [sx - sprite_w / 2, 0].max
559
+ end_x = [sx + sprite_w / 2, view_w - 1].min
560
+
561
+ actual_h = draw_bot - draw_top
562
+ actual_w = end_x - start_x + 1
563
+ return if actual_h < 1 || actual_w < 1
564
+
565
+ color_mode = remote[:team] == @team ? :ally : :enemy
566
+ use_shape = actual_h >= 6
567
+
568
+ start_x.upto(end_x) do |c|
569
+ next if c < 0 || c >= view_w
570
+ next if dists[c] < tz
571
+
572
+ nx = (c - start_x).to_f / actual_w
573
+ r_top = (draw_top / 2.0).ceil
574
+ r_bot = (draw_bot / 2.0).floor
575
+
576
+ r_top.upto(r_bot - 1) do |r|
577
+ vp0 = r * 2
578
+ vp1 = r * 2 + 1
579
+ top_in = vp0 >= draw_top && vp0 < draw_bot
580
+ bot_in = vp1 >= draw_top && vp1 < draw_bot
581
+ next unless top_in || bot_in
582
+
583
+ if use_shape
584
+ ny0 = top_in ? (vp0 - draw_top).to_f / actual_h : nil
585
+ ny1 = bot_in ? (vp1 - draw_top).to_f / actual_h : nil
586
+ top_color = ny0 ? tint_player_color(Sprite.player(nx, ny0), color_mode) : nil
587
+ bot_color = ny1 ? tint_player_color(Sprite.player(nx, ny1), color_mode) : nil
588
+ next unless top_color || bot_color
589
+
590
+ buf << "\e[#{3 + r};#{c + 1}H"
591
+ buf << if top_color && bot_color
592
+ if top_color == bot_color
593
+ "\e[38;2;#{top_color}m\xE2\x96\x88\e[0m"
594
+ else
595
+ "\e[38;2;#{top_color};48;2;#{bot_color}m\xE2\x96\x80\e[0m"
596
+ end
597
+ elsif top_color
598
+ "\e[38;2;#{top_color}m\xE2\x96\x80\e[0m"
599
+ else
600
+ "\e[38;2;#{bot_color}m\xE2\x96\x84\e[0m"
601
+ end
602
+ else
603
+ fc = color_mode == :ally ? "70;210;255" : "255;110;80"
604
+ buf << "\e[#{3 + r};#{c + 1}H"
605
+ buf << if top_in && bot_in
606
+ "\e[38;2;#{fc}m\xE2\x96\x88\e[0m"
607
+ elsif top_in
608
+ "\e[38;2;#{fc}m\xE2\x96\x80\e[0m"
609
+ else
610
+ "\e[38;2;#{fc}m\xE2\x96\x84\e[0m"
611
+ end
612
+ end
613
+ end
614
+ end
615
+
616
+ bar_row = (draw_top / 2.0).ceil - 1
617
+ return unless bar_row >= 0 && bar_row < view_h
618
+
619
+ bar_w = [actual_w, 2].max
620
+ bar_sx = [sx - bar_w / 2, 0].max
621
+ bar_ex = [bar_sx + bar_w - 1, view_w - 1].min
622
+ total = remote[:current].shield + remote[:current].health
623
+ max_total = Config::SHIELD_MAX + Config::HEALTH_MAX
624
+ hp_pct = total.to_f / max_total
625
+ filled = (hp_pct * (bar_ex - bar_sx + 1)).ceil
626
+ fill_color = color_mode == :ally ? "0;180;255" : "255;80;80"
627
+
628
+ bar_sx.upto(bar_ex) do |c|
629
+ next if c < 0 || c >= view_w
630
+ next if dists[c] < tz
631
+
632
+ ci = c - bar_sx
633
+ color = ci < filled ? fill_color : "80;20;20"
634
+ buf << "\e[#{3 + bar_row};#{c + 1}H\e[38;2;#{color}m\xE2\x96\x88\e[0m"
635
+ end
636
+ end
637
+
638
+ def tint_player_color(color, mode)
639
+ return nil unless color
640
+ return color if mode == :ally
641
+
642
+ r, g, b = color.split(";").map(&:to_i)
643
+ nr = [[r + 70, 255].min, 0].max
644
+ ng = [[g - 50, 0].max, 0].max
645
+ nb = [[b - 90, 0].max, 0].max
646
+ "#{nr};#{ng};#{nb}"
647
+ end
648
+
649
+ def render_pvp_hud(buf, cols)
650
+ bar_w = [cols - 30, 10].max
651
+ pct = @player.shield / Config::SHIELD_MAX.to_f
652
+ filled = (pct * bar_w).to_i
653
+ empty = bar_w - filled
654
+ color = if pct >= 0.5
655
+ "\e[96m"
656
+ elsif pct >= 0.25
657
+ "\e[93m"
658
+ else
659
+ "\e[91m"
660
+ end
661
+ pct_s = "#{(pct * 100).to_i}%"
662
+ shield_str = "SHIELD #{color}#{"█" * filled}#{"░" * empty}\e[0m #{pct_s}"
663
+
664
+ enemies_left = enemy_players.count
665
+ allies_left = alive_allies_count
666
+ match_str = " #{allies_left}A/#{enemies_left}E #{@team_size}v#{@team_size}"
667
+ ping_str = " #{@conn.rtt}ms"
668
+
669
+ pad = [(cols - bar_w - 20) / 2, 0].max
670
+ buf << TerminalOutput.fit_ansi("#{" " * pad}#{shield_str}\e[97m#{match_str}\e[90m#{ping_str}\e[0m", cols) << "\r\n"
671
+
672
+ weapon = @player.current_weapon
673
+ wcolor = weapon.type_id.to_s.start_with?("shock") ? "\e[96m" : "\e[97m"
674
+
675
+ ammo_str = if weapon.max_ammo
676
+ ammo_bar_w = 12
677
+ ammo_pct = weapon.ammo.to_f / weapon.max_ammo
678
+ ammo_filled = (ammo_pct * ammo_bar_w).to_i
679
+ ammo_empty = ammo_bar_w - ammo_filled
680
+ "#{wcolor}#{weapon.name}\e[0m [#{"█" * ammo_filled}#{"░" * ammo_empty}] #{weapon.ammo}/#{weapon.max_ammo}"
681
+ else
682
+ "#{wcolor}#{weapon.name}\e[0m [\xE2\x88\x9E]"
683
+ end
684
+
685
+ status = @player.dead ? " \e[91mELIMINATED\e[0m" : ""
686
+ line = "#{ammo_str} T:swap Space:fire#{status}"
687
+ buf << TerminalOutput.fit_ansi(line, cols) << "\r\n"
688
+ end
689
+
690
+ def render_pvp_radar(buf, cols, radar_h)
691
+ buf << ("\xE2\x94\x80" * cols)[0, cols * 3] << "\r\n"
692
+
693
+ r = Config::RADAR_RADIUS
694
+ diam = r * 2 + 1
695
+ grid = Array.new(diam) { Array.new(diam, " ") }
696
+ diam.times do |ry|
697
+ diam.times do |rx|
698
+ ddx = rx - r
699
+ ddy = ry - r
700
+ d2 = ddx * ddx + ddy * ddy
701
+ if d2 <= r * r
702
+ grid[ry][rx] = "."
703
+ elsif d2 <= (r + 1) * (r + 1)
704
+ grid[ry][rx] = "#"
705
+ end
706
+ end
707
+ end
708
+ grid[r][r] = "^"
709
+
710
+ markers = radar_markers(r, diam)
711
+ info_lines = [
712
+ "Allies: #{alive_allies_count}/#{@team_size}",
713
+ "Enemies: #{enemy_players.count}/#{@team_size}",
714
+ "Ping: #{@conn.rtt}ms"
715
+ ]
716
+
717
+ radar_h.times do |row|
718
+ line = +""
719
+ if row < diam
720
+ line << " "
721
+ diam.times do |cx|
722
+ marker = markers[[row, cx]]
723
+ line << if marker == :ally
724
+ "\e[96m+\e[0m"
725
+ elsif marker == :enemy
726
+ "\e[91m*\e[0m"
727
+ elsif row == r && cx == r
728
+ "\e[92m^\e[0m"
729
+ elsif grid[row][cx] == "#"
730
+ "\e[90m#\e[0m"
731
+ else
732
+ grid[row][cx]
733
+ end
734
+ end
735
+ line << (row < info_lines.size ? " #{info_lines[row]}" : "")
736
+ end
737
+ buf << TerminalOutput.fit_ansi(line, cols)
738
+ buf << "\r\n" if row < radar_h - 1
739
+ end
740
+ end
741
+
742
+ def radar_markers(radius, diam)
743
+ cos_a = Math.cos(-@player.angle + Math::PI / 2)
744
+ sin_a = Math.sin(-@player.angle + Math::PI / 2)
745
+ markers = {}
746
+
747
+ @remotes.each_value do |remote|
748
+ ex = remote[:render].x - @player.x
749
+ ey = remote[:render].y - @player.y
750
+ dist = Math.sqrt(ex * ex + ey * ey)
751
+ next if dist > Config::RADAR_RANGE
752
+
753
+ rx = -(ex * cos_a - ey * sin_a)
754
+ ry = -(ex * sin_a + ey * cos_a)
755
+ osx = radius + (rx / Config::RADAR_RANGE * radius).round
756
+ osy = radius + (ry / Config::RADAR_RANGE * radius).round
757
+ next unless osx.between?(0, diam - 1) && osy.between?(0, diam - 1)
758
+
759
+ d2 = (osx - radius)**2 + (osy - radius)**2
760
+ next unless d2 <= radius * radius
761
+
762
+ markers[[osy, osx]] = remote[:team] == @team ? :ally : :enemy
763
+ end
764
+
765
+ markers
766
+ end
767
+
768
+ def render_crosshair(buf, view_h, view_w, cols)
769
+ cr = 3 + (view_h / 2)
770
+ cc = view_w / 2 + 1
771
+ buf << "\e[#{cr};#{cc}H\e[97m+\e[0m"
772
+
773
+ return unless @player.fire_flash > 0
774
+
775
+ hw = [@player.fire_flash * 4, view_w / 4].min
776
+ fs = [cc - hw, 1].max
777
+ fe = [cc + hw, cols].min
778
+ buf << "\e[#{cr};#{fs}H\e[93m#{"*" * (fe - fs + 1)}\e[0m"
779
+ end
780
+
781
+ def render_damage_flash(buf, view_h, view_w)
782
+ return unless @player.damage_flash > 0
783
+
784
+ intensity = @player.damage_flash * 60
785
+ flash_w = 2
786
+
787
+ view_h.times do |r|
788
+ buf << "\e[#{3 + r};1H\e[48;2;#{intensity};0;0m#{" " * flash_w}\e[0m"
789
+ rc = [view_w - flash_w + 1, 1].max
790
+ buf << "\e[#{3 + r};#{rc}H\e[48;2;#{intensity};0;0m#{" " * flash_w}\e[0m"
791
+ end
792
+ end
793
+
794
+ def render_status_overlay(buf, view_h, cols)
795
+ return unless @player.dead && !defeated? && !victory?
796
+
797
+ text = "SPECTATING TEAM"
798
+ c = [(cols - text.size) / 2 + 1, 1].max
799
+ buf << "\e[#{2 + [view_h / 4, 1].max};#{c}H\e[1;93m#{text}\e[0m"
800
+ end
801
+
802
+ def render_pvp_result(text, color_code)
803
+ rows, cols = @stdout.winsize
804
+ buf = +"\e[H"
805
+ rows.times { buf << "#{" " * cols}\r\n" }
806
+ r = rows / 2
807
+ c = [(cols - text.size) / 2 + 1, 1].max
808
+ buf << "\e[#{r};#{c}H#{color_code}#{text}\e[0m"
809
+ TerminalOutput.write_all(@stdout, buf)
810
+ end
811
+
812
+ def show_error(msg)
813
+ rows, cols = @stdout.winsize
814
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
815
+ mc = [(cols - msg.size) / 2, 1].max
816
+ buf << "\e[#{rows / 2};#{mc}H\e[1;91m#{msg}\e[0m"
817
+ hint = "(Press any key)"
818
+ hc = [(cols - hint.size) / 2, 1].max
819
+ buf << "\e[#{rows / 2 + 2};#{hc}H\e[90m#{hint}\e[0m"
820
+ buf << TerminalOutput.end_frame
821
+ TerminalOutput.write_all(@stdout, buf)
822
+ STDIN.raw { |s| s.getc }
823
+ end
824
+
825
+ def notify_death_if_needed
826
+ return unless @player.dead
827
+ return if @sent_dead
828
+
829
+ @conn.send_msg({ t: "dead" })
830
+ @sent_dead = true
831
+ end
832
+
833
+ def enemy_players
834
+ @remotes.values.select { |remote| remote[:team] != @team }
835
+ end
836
+
837
+ def alive_allies_count
838
+ remote_allies = @remotes.values.count { |remote| remote[:team] == @team }
839
+ remote_allies + (@player.dead ? 0 : 1)
840
+ end
841
+
842
+ def victory?
843
+ enemy_players.empty?
844
+ end
845
+
846
+ def defeated?
847
+ @player.dead && alive_allies_count.zero?
848
+ end
849
+
850
+ def distance_sq(x1, y1, x2, y2)
851
+ (x1 - x2)**2 + (y1 - y2)**2
852
+ end
853
+
854
+ def clock
855
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
856
+ end
857
+
858
+ def cap_frame(frame_start)
859
+ spent = clock - frame_start
860
+ remain = Config::FRAME_DT - spent
861
+ sleep(remain) if remain > 0
862
+ end
863
+ end
864
+ end
865
+ end