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,637 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Game
5
+ def initialize
6
+ @stdout = STDOUT
7
+ @audio = AudioManager.new
8
+ @renderer = Renderer.new(@stdout)
9
+ @input = Input.new
10
+ @scene_player = ScenePlayer.new(@stdout, audio: @audio)
11
+ @demo_player = DemoPlayer.new(@stdout, @renderer)
12
+ @difficulty = nil
13
+ end
14
+
15
+ def start
16
+ enter_alt_screen
17
+ loop do
18
+ reset_title_screen_state
19
+ @input.clear
20
+ title = TitleScreen.new(@stdout)
21
+ @audio.play_bgm(:title)
22
+ choice = title.show
23
+ @audio.stop_bgm
24
+ case choice
25
+ when :singleplayer then run_singleplayer
26
+ when :wavesfight then run_wavesfight
27
+ when :campaign then run_campaign
28
+ when :pvp then Network::Client.new(@stdout).run
29
+ when :quit then break
30
+ end
31
+ end
32
+ rescue Interrupt
33
+ # normal exit
34
+ rescue StandardError => e
35
+ @crash = e
36
+ ensure
37
+ @audio.close
38
+ leave_alt_screen
39
+ if @crash
40
+ warn "#{@crash.class}: #{@crash.message}"
41
+ @crash.backtrace.first(10).each { |l| warn " #{l}" }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def run_singleplayer
48
+ mission = Mission::Training.new
49
+ load_mission(mission, nil)
50
+ @audio.play_bgm(:mission)
51
+ run_game_loop
52
+ ensure
53
+ @audio.stop_bgm
54
+ end
55
+
56
+ def run_campaign
57
+ @difficulty = 1
58
+ loop do
59
+ choice = show_mission_select(missions: Mission::Base.campaign, title: "SELECT MISSION")
60
+ if choice == :back
61
+ clear_screen
62
+ return
63
+ end
64
+
65
+ mission = Mission::Base.campaign[choice].new
66
+ load_mission(mission, @difficulty)
67
+ @audio.play_bgm(:mission)
68
+ play_events(:mission_start, stdin: nil, title: mission.name)
69
+ result = run_game_loop(show_complete_banner: false)
70
+ return if result == :quit
71
+ if result == :mission_complete
72
+ play_events(:mission_complete, stdin: nil, title: mission.name)
73
+ rows, cols = @stdout.winsize
74
+ @audio.play_se(:mission_clear)
75
+ @renderer.render_mission_complete(rows, cols)
76
+ sleep 2
77
+ end
78
+ @audio.stop_bgm
79
+ end
80
+ ensure
81
+ @audio.stop_bgm
82
+ @difficulty = nil
83
+ end
84
+
85
+ def run_wavesfight
86
+ @difficulty = 1
87
+ missions = Mission::Base.wavesfight
88
+ choice = show_mission_select(missions: missions, title: "SELECT WAVESFIGHT")
89
+ if choice == :back
90
+ clear_screen
91
+ return
92
+ end
93
+
94
+ mission = missions[choice].new
95
+ mode = show_wavesfight_mode_select
96
+ if mode == :back
97
+ clear_screen
98
+ return
99
+ end
100
+
101
+ if mode == :coop
102
+ Network::WavesfightClient.new(@stdout).run(mission_id: mission.id, difficulty: @difficulty)
103
+ return
104
+ end
105
+
106
+ load_mission(mission, @difficulty)
107
+ @audio.play_bgm(:mission)
108
+ @wave = 0
109
+ start_wavesfight_wave
110
+ run_wavesfight_loop
111
+ ensure
112
+ @audio.stop_bgm
113
+ @difficulty = nil
114
+ @wave = nil
115
+ end
116
+
117
+ def load_mission(mission, difficulty_index)
118
+ @mission = mission
119
+ @map = mission.build_map
120
+ weapons = mission.build_weapons
121
+ x, y, angle = mission.spawn
122
+ @player = Player.new(x: x, y: y, angle: angle, weapons: weapons)
123
+ @enemies = mission.build_enemies(difficulty_index)
124
+ @projectiles = []
125
+ @player.drops = []
126
+ @terminals = mission.build_terminals
127
+ @event_runtime = Mission::EventRuntime.new(mission.event_definitions)
128
+ end
129
+
130
+ def run_game_loop(show_complete_banner: true)
131
+ STDIN.raw do |stdin|
132
+ last_time = clock
133
+
134
+ loop do
135
+ now = clock
136
+ dt = now - last_time
137
+ last_time = now
138
+
139
+ keys = @input.process(stdin, player: @player)
140
+ return :quit if @input.key?(:q) || @input.key?(:esc)
141
+
142
+ if handle_player_actions(keys, stdin)
143
+ last_time = clock
144
+ next
145
+ end
146
+
147
+ update(dt)
148
+ @renderer.render(
149
+ player: @player, map: @map,
150
+ enemies: @enemies, projectiles: @projectiles,
151
+ drops: @player.drops, terminals: @terminals
152
+ )
153
+
154
+ if @player.dead
155
+ rows, cols = @stdout.winsize
156
+ @renderer.render_game_over(rows, cols)
157
+ sleep 2
158
+ return :dead
159
+ end
160
+
161
+ if @enemies.all? { |e| !e.alive }
162
+ if show_complete_banner
163
+ rows, cols = @stdout.winsize
164
+ @renderer.render_mission_complete(rows, cols)
165
+ sleep 2
166
+ end
167
+ return :mission_complete
168
+ end
169
+
170
+ cap_frame(now)
171
+ end
172
+ end
173
+ end
174
+
175
+ def run_wavesfight_loop
176
+ STDIN.raw do |stdin|
177
+ last_time = clock
178
+
179
+ loop do
180
+ now = clock
181
+ dt = now - last_time
182
+ last_time = now
183
+
184
+ keys = @input.process(stdin, player: @player)
185
+ return :quit if @input.key?(:q) || @input.key?(:esc)
186
+
187
+ if handle_player_actions(keys, stdin)
188
+ last_time = clock
189
+ next
190
+ end
191
+
192
+ update(dt)
193
+ @renderer.render(
194
+ player: @player, map: @map,
195
+ enemies: @enemies, projectiles: @projectiles,
196
+ drops: @player.drops, terminals: @terminals,
197
+ status_line: " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]}"
198
+ )
199
+
200
+ if @player.dead
201
+ rows, cols = @stdout.winsize
202
+ @renderer.render_centered_message(rows, cols, "DOWN AT WAVE #{@wave}", "\e[1;91m")
203
+ sleep 2
204
+ return :dead
205
+ end
206
+
207
+ if @enemies.all? { |e| !e.alive }
208
+ show_wave_clear
209
+ start_wavesfight_wave
210
+ last_time = clock
211
+ end
212
+
213
+ cap_frame(now)
214
+ end
215
+ end
216
+ end
217
+
218
+ def handle_player_actions(keys, stdin)
219
+ if keys.include?(:t)
220
+ @player.swap_weapon
221
+ end
222
+
223
+ if keys.include?(:e)
224
+ terminal = nearest_terminal
225
+ if terminal
226
+ play_terminal_event(terminal, stdin)
227
+ return true
228
+ end
229
+
230
+ @player.try_pickup
231
+ end
232
+
233
+ return false unless keys.include?(:space)
234
+
235
+ weapon = @player.current_weapon
236
+ return false unless weapon.can_fire?(@player.last_fire, @player.game_time)
237
+ return false unless weapon.infinite_ammo? || (weapon.ammo && weapon.ammo > 0)
238
+
239
+ @player.fire_flash = 4
240
+ weapon.consume_ammo!
241
+ @player.last_fire = @player.game_time
242
+ @audio.play_se(:shoot)
243
+ false
244
+ end
245
+
246
+ def nearest_terminal
247
+ @terminals
248
+ .map { |terminal| [terminal, (terminal[:x] - @player.x)**2 + (terminal[:y] - @player.y)**2] }
249
+ .select { |_, distance_sq| distance_sq < Config::TERMINAL_USE_RADIUS**2 }
250
+ .min_by { |_, distance_sq| distance_sq }
251
+ &.first
252
+ end
253
+
254
+ def play_terminal_event(terminal, stdin)
255
+ events = @event_runtime.trigger(:terminal_used, terminal_id: terminal[:id])
256
+ actions = if events.empty?
257
+ [{ type: :text, text: "TERMINAL OFFLINE\nNo readable data remains." }]
258
+ else
259
+ events.flat_map { |event| event[:actions] }
260
+ end
261
+ @input.clear
262
+ @audio.play_se(:terminal)
263
+ play_actions(actions, title: "Terminal", stdin: stdin)
264
+ @input.clear
265
+ end
266
+
267
+ def play_events(type, stdin:, title:)
268
+ events = @event_runtime.trigger(type)
269
+ return if events.empty?
270
+
271
+ @input.clear
272
+ events.each do |event|
273
+ play_actions(event[:actions], title: title, stdin: stdin)
274
+ end
275
+ @input.clear
276
+ end
277
+
278
+ def play_actions(actions, title:, stdin:)
279
+ buffer = []
280
+
281
+ actions.each do |action|
282
+ if action[:type] == :demo
283
+ unless buffer.empty?
284
+ @scene_player.play(buffer, title: title, stdin: stdin)
285
+ buffer.clear
286
+ end
287
+ @demo_player.play(action, mission: @mission, stdin: stdin)
288
+ else
289
+ buffer << action
290
+ end
291
+ end
292
+
293
+ return if buffer.empty?
294
+
295
+ @scene_player.play(buffer, title: title, stdin: stdin)
296
+ end
297
+
298
+ def update(dt)
299
+ @player.game_time += dt
300
+
301
+ @player.angle -= Config::ROT_SPEED * dt if @input.key?(:left)
302
+ @player.angle += Config::ROT_SPEED * dt if @input.key?(:right)
303
+
304
+ dx = Math.cos(@player.angle)
305
+ dy = Math.sin(@player.angle)
306
+ sx = -dy
307
+ sy = dx
308
+
309
+ mvx = 0.0
310
+ mvy = 0.0
311
+ if @input.key?(:w)
312
+ mvx += dx * Config::MOVE_SPEED * dt
313
+ mvy += dy * Config::MOVE_SPEED * dt
314
+ end
315
+ if @input.key?(:s)
316
+ mvx -= dx * Config::MOVE_SPEED * dt
317
+ mvy -= dy * Config::MOVE_SPEED * dt
318
+ end
319
+ if @input.key?(:a)
320
+ mvx -= sx * Config::MOVE_SPEED * dt
321
+ mvy -= sy * Config::MOVE_SPEED * dt
322
+ end
323
+ if @input.key?(:d)
324
+ mvx += sx * Config::MOVE_SPEED * dt
325
+ mvy += sy * Config::MOVE_SPEED * dt
326
+ end
327
+
328
+ nx = @player.x + mvx
329
+ @player.x = nx unless @map.blocked?(nx, @player.y)
330
+ ny = @player.y + mvy
331
+ @player.y = ny unless @map.blocked?(@player.x, ny)
332
+
333
+ @player.process_fire(@enemies, @map) if @player.fire_flash == 4
334
+ @player.fire_flash -= 1 if @player.fire_flash > 0
335
+
336
+ @enemies.each do |e|
337
+ e.update(dt, @player, @projectiles, @map, @player.game_time, difficulty: @difficulty)
338
+ end
339
+
340
+ @projectiles.reject! do |p|
341
+ p.update(dt)
342
+ if p.hit_wall?(@map)
343
+ true
344
+ elsif p.hit_player?(@player.x, @player.y)
345
+ enemy_klass = Enemy::Base.registry[p.type]
346
+ dmg = enemy_klass ? enemy_klass.allocate.send(:damage) : 10
347
+ @player.apply_damage(dmg)
348
+ @audio.play_se(:damage)
349
+ true
350
+ else
351
+ false
352
+ end
353
+ end
354
+
355
+ relocate_drops_off_terminals
356
+
357
+ @player.update_shield(dt, @stdout, audio: @audio)
358
+ end
359
+
360
+ def relocate_drops_off_terminals
361
+ return if @terminals.empty? || @player.drops.empty?
362
+
363
+ @player.drops.each do |drop|
364
+ terminal = @terminals.find do |candidate|
365
+ same_map_cell?(drop.x, drop.y, candidate[:x], candidate[:y])
366
+ end
367
+ next unless terminal
368
+
369
+ fallback = find_drop_slot_near(terminal[:x], terminal[:y])
370
+ next unless fallback
371
+
372
+ drop.x = fallback[0]
373
+ drop.y = fallback[1]
374
+ end
375
+ end
376
+
377
+ def find_drop_slot_near(x, y)
378
+ [
379
+ [0.0, -0.9], [0.9, 0.0], [0.0, 0.9], [-0.9, 0.0],
380
+ [0.9, -0.9], [0.9, 0.9], [-0.9, 0.9], [-0.9, -0.9]
381
+ ].each do |dx, dy|
382
+ nx = x + dx
383
+ ny = y + dy
384
+ next if @map.blocked?(nx, ny, 0.15)
385
+ next if @terminals.any? { |terminal| same_map_cell?(nx, ny, terminal[:x], terminal[:y]) }
386
+
387
+ return [nx, ny]
388
+ end
389
+
390
+ nil
391
+ end
392
+
393
+ def same_map_cell?(ax, ay, bx, by)
394
+ ax.floor == bx.floor && ay.floor == by.floor
395
+ end
396
+
397
+ def start_wavesfight_wave
398
+ @wave += 1
399
+ @difficulty = [1 + ((@wave - 1) / 3), Enemy::Base::DIFFICULTIES.size - 1].min
400
+ @enemies = build_wavesfight_enemies(@wave, @difficulty)
401
+ replenish_wavesfight_loadout
402
+ @projectiles.clear
403
+
404
+ rows, cols = @stdout.winsize
405
+ @renderer.render_centered_message(rows, cols, "WAVE #{@wave}", "\e[1;93m")
406
+ sleep 1
407
+ end
408
+
409
+ def build_wavesfight_enemies(wave, difficulty_index)
410
+ enemies = @mission.build_enemies(difficulty_index)
411
+ bonus_count = (wave - 1) * 2
412
+ enemies + Enemy::Base.generate_extras(@mission.enemy_defs, bonus_count, difficulty_index)
413
+ end
414
+
415
+ def replenish_wavesfight_loadout
416
+ @player.shield = [@player.shield + 35.0, Config::SHIELD_MAX].min
417
+ @player.health = [@player.health + 20.0, Config::HEALTH_MAX].min
418
+ @player.last_damage = -Config::SHIELD_DELAY
419
+ @player.dead = false
420
+
421
+ @player.weapons.each do |weapon|
422
+ next unless weapon.max_ammo
423
+
424
+ refill = [weapon.max_ammo / 2, 1].max
425
+ weapon.ammo = [weapon.ammo + refill, weapon.max_ammo].min
426
+ end
427
+ end
428
+
429
+ def show_wave_clear
430
+ rows, cols = @stdout.winsize
431
+ @renderer.render_centered_message(rows, cols, "WAVE #{@wave} CLEAR", "\e[1;92m")
432
+ sleep 1
433
+ end
434
+
435
+ def show_wavesfight_mode_select
436
+ selected = 0
437
+ options = [
438
+ ["SOLO", "Play local wavesfight"],
439
+ ["CO-OP", "Queue for 2-player online co-op"]
440
+ ]
441
+
442
+ STDIN.raw do |stdin|
443
+ loop do
444
+ rows, cols = @stdout.winsize
445
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
446
+ lines = Array.new(rows) { " " * cols }
447
+ title = "WAVESFIGHT MODE"
448
+ tc = [(cols - title.size) / 2 + 1, 1].max
449
+ lines[2] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
450
+
451
+ options.each_with_index do |(label, desc), idx|
452
+ row = 6 + idx * 3
453
+ text = idx == selected ? "\e[1;97;44m #{label.ljust(10)} \e[0m" : "\e[97m #{label.ljust(10)} \e[0m"
454
+ tc = [(cols - 14) / 2 + 1, 1].max
455
+ dc = [(cols - desc.size) / 2 + 1, 1].max
456
+ lines[row - 1] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}#{text}", cols)
457
+ lines[row] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}\e[38;2;160;160;180m#{desc}\e[0m", cols)
458
+ end
459
+
460
+ hint = "Up/Down: Select Enter: Confirm Q/Esc: Back"
461
+ hc = [(cols - hint.size) / 2 + 1, 1].max
462
+ lines[rows - 3] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[38;2;100;100;120m#{hint}\e[0m", cols)
463
+
464
+ lines.each_with_index do |line, index|
465
+ buf << line
466
+ buf << "\r\n" if index < rows - 1
467
+ end
468
+ buf << TerminalOutput.end_frame
469
+ TerminalOutput.write_all(@stdout, buf)
470
+
471
+ next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
472
+
473
+ begin
474
+ ch = stdin.read_nonblock(64)
475
+ i = 0
476
+ while i < ch.bytesize
477
+ b = ch.getbyte(i)
478
+ if b == 27 && ch.getbyte(i + 1) == 91
479
+ code = ch.getbyte(i + 2)
480
+ case code
481
+ when 65 then selected = (selected - 1) % options.size
482
+ when 66 then selected = (selected + 1) % options.size
483
+ end
484
+ i += 3
485
+ elsif [13, 10].include?(b)
486
+ return selected.zero? ? :solo : :coop
487
+ elsif [113, 81, 27].include?(b)
488
+ return :back
489
+ else
490
+ i += 1
491
+ end
492
+ end
493
+ rescue IO::WaitReadable
494
+ end
495
+ end
496
+ end
497
+ end
498
+
499
+ def show_mission_select(missions:, title:)
500
+ selected = 0
501
+ TerminalOutput.write_all(@stdout, TerminalOutput.begin_frame(home: true, clear: true) + TerminalOutput.end_frame)
502
+
503
+ STDIN.raw do |stdin|
504
+ loop do
505
+ now = clock
506
+ render_mission_select(selected, missions, title)
507
+
508
+ while IO.select([stdin], nil, nil, 0)
509
+ begin
510
+ ch = stdin.read_nonblock(64)
511
+ i = 0
512
+ while i < ch.bytesize
513
+ b = ch.getbyte(i)
514
+ if b == 27 && ch.getbyte(i + 1) == 91
515
+ code = ch.getbyte(i + 2)
516
+ case code
517
+ when 65 then selected = (selected - 1) % missions.size
518
+ when 66 then selected = (selected + 1) % missions.size
519
+ when 68 then @difficulty = (@difficulty - 1) % Enemy::Base::DIFFICULTIES.size
520
+ when 67 then @difficulty = (@difficulty + 1) % Enemy::Base::DIFFICULTIES.size
521
+ end
522
+ i += 3
523
+ elsif [13, 10].include?(b)
524
+ return selected
525
+ elsif b >= 49 && b <= 49 + missions.size - 1
526
+ return b - 49
527
+ elsif [113, 81, 27].include?(b)
528
+ return :back
529
+ else
530
+ i += 1
531
+ end
532
+ end
533
+ rescue IO::WaitReadable
534
+ break
535
+ end
536
+ end
537
+
538
+ spent = clock - now
539
+ remain = Config::FRAME_DT - spent
540
+ sleep(remain) if remain > 0
541
+ end
542
+ end
543
+ end
544
+
545
+ def render_mission_select(selected, missions, title)
546
+ rows, cols = @stdout.winsize
547
+ buf = TerminalOutput.begin_frame(home: true)
548
+ lines = Array.new(rows) { " " * cols }
549
+
550
+ tc = [(cols - title.size) / 2 + 1, 1].max
551
+ lines[1] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
552
+
553
+ diff = Enemy::Base::DIFFICULTIES[@difficulty]
554
+ diff_colors = ["\e[92m", "\e[93m", "\e[38;2;255;165;0m", "\e[91m"]
555
+ diff_label = "< #{diff[:name]} >"
556
+ dc = [(cols - diff_label.size) / 2 + 1, 1].max
557
+ lines[2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}#{diff_colors[@difficulty]}#{diff_label}\e[0m", cols)
558
+
559
+ missions.each_with_index do |klass, i|
560
+ m = klass.new
561
+ row = 5 + i * 2
562
+ label = " #{i + 1}. #{m.name}"
563
+ lc = [(cols - 40) / 2 + 1, 1].max
564
+ text = if i == selected
565
+ "\e[1;97;44m> #{label.strip.ljust(38)}\e[0m"
566
+ else
567
+ "\e[97m #{label.strip.ljust(38)}\e[0m"
568
+ end
569
+ lines[row - 1] = TerminalOutput.fit_ansi("#{" " * (lc - 1)}#{text}", cols)
570
+ end
571
+
572
+ brief_row = 5 + missions.size * 2 + 1
573
+ m = missions[selected].new
574
+ briefing = m.briefing
575
+ bc = [(cols - briefing.size) / 2 + 1, 1].max
576
+ lines[brief_row - 1] = TerminalOutput.fit_ansi("#{" " * (bc - 1)}\e[38;2;180;180;200m#{briefing}\e[0m", cols)
577
+
578
+ info_row = brief_row + 2
579
+ edefs = m.enemy_defs
580
+ base_crawler = edefs.count { |e| e[4] == :crawler }
581
+ base_executor = edefs.count { |e| e[4] == :executor }
582
+ extra = diff[:extra_enemies]
583
+ extra_crawler = 0
584
+ extra_executor = 0
585
+ extra.times do |i|
586
+ src_type = edefs[i % edefs.size][4]
587
+ src_type == :crawler ? (extra_crawler += 1) : (extra_executor += 1)
588
+ end
589
+ crawler_c = base_crawler + extra_crawler
590
+ executor_c = base_executor + extra_executor
591
+ info = "Enemies: #{crawler_c} Crawler#{crawler_c != 1 ? "s" : ""}"
592
+ info += ", #{executor_c} Executor#{executor_c != 1 ? "s" : ""}" if executor_c > 0
593
+ info += " | HP x#{diff[:hp_mult]}"
594
+ ic = [(cols - info.size) / 2 + 1, 1].max
595
+ lines[info_row - 1] = TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[38;2;140;140;160m#{info}\e[0m", cols)
596
+
597
+ ctrl_row = info_row + 2
598
+ ctrl = "Up/Down: Select Left/Right: Difficulty Enter/1-5: Start Q: Back"
599
+ cc = [(cols - ctrl.size) / 2 + 1, 1].max
600
+ lines[ctrl_row - 1] = TerminalOutput.fit_ansi("#{" " * (cc - 1)}\e[38;2;100;100;120m#{ctrl}\e[0m", cols)
601
+
602
+ lines.each_with_index do |line, index|
603
+ buf << line
604
+ buf << "\r\n" if index < rows - 1
605
+ end
606
+
607
+ buf << TerminalOutput.end_frame
608
+ TerminalOutput.write_all(@stdout, buf)
609
+ end
610
+
611
+ def enter_alt_screen
612
+ print "\e[?1049h\e[?25l"
613
+ end
614
+
615
+ def leave_alt_screen
616
+ print "\e[?25h\e[?1049l"
617
+ end
618
+
619
+ def clear_screen
620
+ TerminalOutput.write_all(@stdout, TerminalOutput.begin_frame(home: true, clear: true) + TerminalOutput.end_frame)
621
+ end
622
+
623
+ def reset_title_screen_state
624
+ TerminalOutput.write_all(@stdout, "\e[?25h\e[?1049l\e[?1049h\e[?25l\e[H\e[2J")
625
+ end
626
+
627
+ def clock
628
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
629
+ end
630
+
631
+ def cap_frame(frame_start)
632
+ spent = clock - frame_start
633
+ remain = Config::FRAME_DT - spent
634
+ sleep(remain) if remain > 0
635
+ end
636
+ end
637
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Input
5
+ def initialize
6
+ @key_state = {}
7
+ @pending = +""
8
+ end
9
+
10
+ def read_keys(stdin)
11
+ keys = []
12
+
13
+ while IO.select([stdin], nil, nil, 0)
14
+ begin
15
+ @pending << stdin.read_nonblock(64)
16
+ rescue IO::WaitReadable
17
+ break
18
+ end
19
+ end
20
+
21
+ i = 0
22
+ while i < @pending.bytesize
23
+ ch = @pending.getbyte(i)
24
+ if ch == 27
25
+ if @pending.getbyte(i + 1) == 91 && (code = @pending.getbyte(i + 2))
26
+ case code
27
+ when 65 then keys << :up
28
+ when 66 then keys << :down
29
+ when 67 then keys << :right
30
+ when 68 then keys << :left
31
+ end
32
+ i += 3
33
+ else
34
+ keys << :esc
35
+ i += 1
36
+ end
37
+ else
38
+ case ch
39
+ when 119 then keys << :w
40
+ when 97 then keys << :a
41
+ when 115 then keys << :s
42
+ when 100 then keys << :d
43
+ when 32 then keys << :space
44
+ when 113 then keys << :q
45
+ when 116 then keys << :t
46
+ when 101 then keys << :e
47
+ end
48
+ i += 1
49
+ end
50
+ end
51
+
52
+ @pending.clear
53
+ keys
54
+ end
55
+
56
+ def process(stdin, player: nil)
57
+ keys = read_keys(stdin)
58
+
59
+ @key_state.each_key { |k| @key_state[k] -= 1 }
60
+ @key_state.delete_if { |_, v| v <= 0 }
61
+ keys.each { |k| @key_state[k] = Config::KEY_TIMEOUT }
62
+
63
+ keys
64
+ end
65
+
66
+ def key?(sym)
67
+ @key_state[sym]
68
+ end
69
+
70
+ def clear
71
+ @key_state.clear
72
+ @pending.clear
73
+ end
74
+ end
75
+ end