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,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class ScenePlayer
5
+ def initialize(stdout, audio: nil)
6
+ @stdout = stdout
7
+ @audio = audio
8
+ end
9
+
10
+ def play(actions, title:, stdin: nil)
11
+ pages = build_pages(actions)
12
+ return if pages.empty?
13
+
14
+ if stdin
15
+ play_loop(stdin, pages, title)
16
+ else
17
+ STDIN.raw { |raw| play_loop(raw, pages, title) }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def play_loop(stdin, pages, title)
24
+ index = 0
25
+
26
+ loop do
27
+ render_page(title, pages[index], index + 1, pages.size)
28
+
29
+ input = wait_for_advance(stdin)
30
+ return if input == :skip
31
+
32
+ @audio&.play_se(:page)
33
+ index += 1
34
+ return if index >= pages.size
35
+ end
36
+ end
37
+
38
+ def wait_for_advance(stdin)
39
+ loop do
40
+ next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
41
+
42
+ data = stdin.read_nonblock(64)
43
+ data.each_byte do |byte|
44
+ case byte
45
+ when 13, 10, 32
46
+ return :next
47
+ when 27, 81, 113
48
+ return :skip
49
+ end
50
+ end
51
+ rescue IO::WaitReadable
52
+ next
53
+ end
54
+ end
55
+
56
+ def render_page(title, page, page_no, page_count)
57
+ rows, cols = @stdout.winsize
58
+ rows = [rows, 12].max
59
+ cols = [cols, 40].max
60
+
61
+ buf = TerminalOutput.begin_frame(home: true)
62
+ lines = Array.new(rows) { " " * cols }
63
+
64
+ top = " #{title.upcase} "
65
+ lines[1] = TerminalOutput.fit_ansi(" \e[1;96m#{top}\e[0m", cols)
66
+
67
+ if page[:type] == :title_card
68
+ render_title_card_page(lines, rows, cols, page)
69
+ else
70
+ render_text_page(lines, rows, cols, page)
71
+ end
72
+
73
+ footer = "[Enter] Next [Esc] Skip"
74
+ status = "#{page_no}/#{page_count}"
75
+ footer_line = +" \e[90m#{footer}\e[0m"
76
+ status_col = [cols - status.size, 1].max
77
+ footer_line << (" " * [status_col - footer_line.size, 0].max)
78
+ footer_line << "\e[90m#{status}\e[0m"
79
+ lines[rows - 2] = TerminalOutput.fit_ansi(footer_line, cols)
80
+
81
+ lines.each_with_index do |line, index|
82
+ buf << line
83
+ buf << "\r\n" if index < rows - 1
84
+ end
85
+ buf << TerminalOutput.end_frame
86
+
87
+ TerminalOutput.write_all(@stdout, buf)
88
+ end
89
+
90
+ def build_pages(actions)
91
+ rows, cols = @stdout.winsize
92
+ rows = [rows, 12].max
93
+ cols = [cols, 40].max
94
+ max_lines = rows - 8
95
+ width = cols - 4
96
+
97
+ actions.flat_map do |action|
98
+ lines = wrap_action(action, width)
99
+ lines.each_slice(max_lines).map do |slice|
100
+ {
101
+ type: action[:type],
102
+ speaker: action[:speaker],
103
+ lines: slice
104
+ }
105
+ end
106
+ end
107
+ end
108
+
109
+ def wrap_action(action, width)
110
+ text = action[:text].to_s
111
+ text.split("\n").flat_map do |line|
112
+ wrap_line(line, width)
113
+ end
114
+ end
115
+
116
+ def wrap_line(line, width)
117
+ return [""] if line.empty?
118
+
119
+ words = line.split(/\s+/)
120
+ return [line[0, width]] if words.empty?
121
+
122
+ lines = []
123
+ current = +""
124
+ words.each do |word|
125
+ candidate = current.empty? ? word : "#{current} #{word}"
126
+ if candidate.size <= width
127
+ current = candidate
128
+ else
129
+ lines << current unless current.empty?
130
+ while word.size > width
131
+ lines << word.slice!(0, width)
132
+ end
133
+ current = word
134
+ end
135
+ end
136
+ lines << current unless current.empty?
137
+ lines
138
+ end
139
+
140
+ def render_text_page(lines, rows, cols, page)
141
+ if page[:speaker]
142
+ lines[3] = TerminalOutput.fit_ansi(" \e[1;93m#{page[:speaker]}\e[0m", cols)
143
+ end
144
+
145
+ start_row = page[:speaker] ? 6 : 5
146
+ page[:lines].each_with_index do |line, index|
147
+ row = start_row + index
148
+ break if row >= rows - 2
149
+
150
+ lines[row - 1] = TerminalOutput.fit_ansi(" #{line}", cols)
151
+ end
152
+ end
153
+
154
+ def render_title_card_page(lines, rows, cols, page)
155
+ total_lines = page[:lines].size
156
+ start_row = [[(rows - total_lines) / 2, 4].max, rows - total_lines - 2].min
157
+
158
+ page[:lines].each_with_index do |line, index|
159
+ col = [(cols - line.size) / 2 + 1, 1].max
160
+ lines[start_row + index - 1] = TerminalOutput.fit_ansi("#{" " * (col - 1)}\e[1;97m#{line}\e[0m", cols)
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Sprite
5
+ module_function
6
+
7
+ def executor(nx, ny)
8
+ return "180;120;255" if ((nx - 0.43) / 0.045)**2 + ((ny - 0.11) / 0.045)**2 <= 1.0
9
+ return "180;120;255" if ((nx - 0.57) / 0.045)**2 + ((ny - 0.11) / 0.045)**2 <= 1.0
10
+ return "130;80;220" if ((nx - 0.5) / 0.18)**2 + ((ny - 0.12) / 0.12)**2 <= 1.0
11
+ return "90;50;180" if ((nx - 0.5) / 0.38)**2 + ((ny - 0.30) / 0.08)**2 <= 1.0
12
+ return "80;40;160" if ((nx - 0.5) / 0.25)**2 + ((ny - 0.50) / 0.22)**2 <= 1.0
13
+ return "80;40;160" if ((nx - 0.38) / 0.10)**2 + ((ny - 0.85) / 0.15)**2 <= 1.0
14
+ return "80;40;160" if ((nx - 0.62) / 0.10)**2 + ((ny - 0.85) / 0.15)**2 <= 1.0
15
+
16
+ nil
17
+ end
18
+
19
+ def crawler(nx, ny)
20
+ return "255;240;100" if ((nx - 0.36) / 0.063)**2 + ((ny - 0.28) / 0.063)**2 <= 1.0
21
+ return "255;240;100" if ((nx - 0.64) / 0.063)**2 + ((ny - 0.28) / 0.063)**2 <= 1.0
22
+ return "220;140;30" if ((nx - 0.5) / 0.40)**2 + ((ny - 0.40) / 0.40)**2 <= 1.0
23
+ return "160;100;20" if ((nx - 0.35) / 0.12)**2 + ((ny - 0.90) / 0.10)**2 <= 1.0
24
+ return "160;100;20" if ((nx - 0.65) / 0.12)**2 + ((ny - 0.90) / 0.10)**2 <= 1.0
25
+
26
+ nil
27
+ end
28
+
29
+ def player(nx, ny)
30
+ return "140;220;255" if ((nx - 0.43) / 0.045)**2 + ((ny - 0.11) / 0.045)**2 <= 1.0
31
+ return "140;220;255" if ((nx - 0.57) / 0.045)**2 + ((ny - 0.11) / 0.045)**2 <= 1.0
32
+ return "40;130;180" if ((nx - 0.5) / 0.18)**2 + ((ny - 0.12) / 0.12)**2 <= 1.0
33
+ return "30;100;160" if ((nx - 0.5) / 0.38)**2 + ((ny - 0.30) / 0.08)**2 <= 1.0
34
+ return "25;80;140" if ((nx - 0.5) / 0.25)**2 + ((ny - 0.50) / 0.22)**2 <= 1.0
35
+ return "25;80;140" if ((nx - 0.38) / 0.10)**2 + ((ny - 0.85) / 0.15)**2 <= 1.0
36
+ return "25;80;140" if ((nx - 0.62) / 0.10)**2 + ((ny - 0.85) / 0.15)**2 <= 1.0
37
+
38
+ nil
39
+ end
40
+
41
+ def training_dummy(nx, ny)
42
+ return "235;80;80" if ((nx - 0.5) / 0.18)**2 + ((ny - 0.18) / 0.14)**2 <= 1.0
43
+ return "210;210;210" if ((nx - 0.5) / 0.08)**2 + ((ny - 0.42) / 0.14)**2 <= 1.0
44
+ return "200;200;200" if ((nx - 0.5) / 0.22)**2 + ((ny - 0.66) / 0.12)**2 <= 1.0
45
+ return "180;180;180" if ((nx - 0.38) / 0.08)**2 + ((ny - 0.90) / 0.12)**2 <= 1.0
46
+ return "180;180;180" if ((nx - 0.62) / 0.08)**2 + ((ny - 0.90) / 0.12)**2 <= 1.0
47
+
48
+ nil
49
+ end
50
+
51
+ def wall_brightness(dist, side)
52
+ b = 255 - [[(dist * 2.5).to_i, 0].max, 19].min
53
+ b -= 3 if side == 1
54
+ b.clamp(233, 255)
55
+ end
56
+
57
+ REGISTRY = {
58
+ executor: method(:executor),
59
+ crawler: method(:crawler),
60
+ player: method(:player),
61
+ training_dummy: method(:training_dummy)
62
+ }
63
+
64
+ def self.for(sprite_id, nx, ny)
65
+ fn = REGISTRY[sprite_id]
66
+ fn ? fn.call(nx, ny) : nil
67
+ end
68
+
69
+ def self.register(sprite_id, &block)
70
+ REGISTRY[sprite_id] = block
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module TerminalOutput
5
+ module_function
6
+ ANSI_PATTERN = /\e\[[0-9;]*[A-Za-z]/.freeze
7
+
8
+ def sync_updates?
9
+ ENV.fetch("TERMFRONT_SYNC_UPDATES", "1") == "1"
10
+ end
11
+
12
+ def begin_frame(home: false, clear: false)
13
+ buf = +""
14
+ buf << "\e[?2026h" if sync_updates?
15
+ buf << "\e[H" if home
16
+ buf << "\e[2J" if clear
17
+ buf
18
+ end
19
+
20
+ def end_frame
21
+ sync_updates? ? "\e[?2026l" : ""
22
+ end
23
+
24
+ def fit_ansi(text, width)
25
+ visible = 0
26
+ out = +""
27
+ index = 0
28
+
29
+ while index < text.length && visible < width
30
+ if (match = ANSI_PATTERN.match(text, index)) && match.begin(0) == index
31
+ out << match[0]
32
+ index = match.end(0)
33
+ next
34
+ end
35
+
36
+ char = text[index]
37
+ out << char
38
+ visible += 1
39
+ index += 1
40
+ end
41
+
42
+ out << "\e[0m" if out.include?("\e[")
43
+ out << (" " * (width - visible)) if visible < width
44
+ out
45
+ end
46
+
47
+ def write_all(io, data)
48
+ total = 0
49
+ bytes = data.bytesize
50
+
51
+ while total < bytes
52
+ begin
53
+ written = io.syswrite(data.byteslice(total, bytes - total))
54
+ total += written
55
+ rescue IO::WaitWritable
56
+ IO.select(nil, [io])
57
+ end
58
+ end
59
+
60
+ total
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class TitleScreen
5
+ DEMO_WAYPOINTS = [
6
+ [2.5, 5.0], [7.0, 2.5], [11.0, 3.5], [13.0, 5.0],
7
+ [13.0, 8.0], [10.0, 8.5], [5.0, 8.5], [2.5, 5.0]
8
+ ].freeze
9
+
10
+ def initialize(stdout)
11
+ @stdout = stdout
12
+ @title_spin = 0.0
13
+ @demo_wp_idx = 0
14
+ @demo_wp_t = 0.0
15
+ @demo_fire = 0
16
+ end
17
+
18
+ def show
19
+ @title_spin = 0.0
20
+ @demo_wp_idx = 0
21
+ @demo_wp_t = 0.0
22
+ @demo_fire = 0
23
+
24
+ TerminalOutput.write_all(@stdout, TerminalOutput.begin_frame(home: true, clear: true) + TerminalOutput.end_frame)
25
+
26
+ STDIN.raw do |stdin|
27
+ loop do
28
+ now = clock
29
+ @title_spin += 0.015
30
+
31
+ render
32
+
33
+ while IO.select([stdin], nil, nil, 0)
34
+ begin
35
+ ch = stdin.read_nonblock(64)
36
+ ch.each_byte do |b|
37
+ case b
38
+ when 102, 70 then return :wavesfight
39
+ when 115, 83 then return :singleplayer
40
+ when 99, 67 then return :campaign
41
+ when 112, 80 then return :pvp
42
+ when 113, 81, 27 then return :quit
43
+ end
44
+ end
45
+ rescue IO::WaitReadable
46
+ break
47
+ end
48
+ end
49
+
50
+ spent = clock - now
51
+ remain = Config::FRAME_DT - spent
52
+ sleep(remain) if remain > 0
53
+ end
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def clock
60
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ end
62
+
63
+ def render
64
+ rows, cols = @stdout.winsize
65
+ rows = [rows, 10].max
66
+ cols = [cols, 20].max
67
+ buf = TerminalOutput.begin_frame(home: true)
68
+ lines = Array.new(rows) { " " * cols }
69
+
70
+ reserved_rows = 7
71
+ th = rows - reserved_rows
72
+ th = 3 if th < 3
73
+ th = rows - 3 if th >= rows
74
+ th = 1 if th < 1
75
+ tw = [cols, 20].max
76
+ virt_h = th * 2
77
+ color = Array.new(tw * virt_h, nil)
78
+
79
+ mission = Mission::Base.campaign.first
80
+ return unless mission
81
+
82
+ m = mission.new
83
+ demo_map = m.map_data.map { |r| r.is_a?(Array) ? r : r.chars }
84
+ dm_h = demo_map.size
85
+ dm_w = demo_map[0].size
86
+
87
+ @demo_wp_t += Config::DEMO_SPEED
88
+ if @demo_wp_t >= 1.0
89
+ @demo_wp_t -= 1.0
90
+ @demo_wp_idx = (@demo_wp_idx + 1) % DEMO_WAYPOINTS.size
91
+ end
92
+ wp_a = DEMO_WAYPOINTS[@demo_wp_idx]
93
+ wp_b = DEMO_WAYPOINTS[(@demo_wp_idx + 1) % DEMO_WAYPOINTS.size]
94
+ st = @demo_wp_t * @demo_wp_t * (3 - 2 * @demo_wp_t)
95
+ cam_x = wp_a[0] + (wp_b[0] - wp_a[0]) * st
96
+ cam_y = wp_a[1] + (wp_b[1] - wp_a[1]) * st
97
+
98
+ dx = wp_b[0] - wp_a[0]
99
+ dy = wp_b[1] - wp_a[1]
100
+ cam_a = Math.atan2(dy, dx) + 0.15 * Math.sin(@title_spin * 1.7)
101
+
102
+ bob = Math.sin(@title_spin * 4.0) * 0.4
103
+
104
+ @demo_fire -= 1 if @demo_fire > 0
105
+ @demo_fire = 4 if (@title_spin * 100).to_i % 120 == 0 && @demo_fire <= 0
106
+
107
+ fov = Config::FOV
108
+ half_fov = fov / 2.0
109
+ dists = Array.new(tw, 100.0)
110
+ horizon = (virt_h / 2 + bob).to_i
111
+
112
+ ceil_c = "0;0;95"
113
+ floor_c = "28;28;28"
114
+
115
+ tw.times do |col|
116
+ ray_a = cam_a - half_fov + fov * col.to_f / tw
117
+ rd_x = Math.cos(ray_a)
118
+ rd_y = Math.sin(ray_a)
119
+
120
+ mx = cam_x.to_i
121
+ my = cam_y.to_i
122
+ dd_x = rd_x == 0 ? 1e30 : (1.0 / rd_x.abs)
123
+ dd_y = rd_y == 0 ? 1e30 : (1.0 / rd_y.abs)
124
+ if rd_x < 0
125
+ step_x = -1
126
+ sd_x = (cam_x - mx) * dd_x
127
+ else
128
+ step_x = 1
129
+ sd_x = (mx + 1.0 - cam_x) * dd_x
130
+ end
131
+ if rd_y < 0
132
+ step_y = -1
133
+ sd_y = (cam_y - my) * dd_y
134
+ else
135
+ step_y = 1
136
+ sd_y = (my + 1.0 - cam_y) * dd_y
137
+ end
138
+ side = 0
139
+ 32.times do
140
+ if sd_x < sd_y
141
+ sd_x += dd_x
142
+ mx += step_x
143
+ side = 0
144
+ else
145
+ sd_y += dd_y
146
+ my += step_y
147
+ side = 1
148
+ end
149
+ break if mx >= 0 && mx < dm_w && my >= 0 && my < dm_h && demo_map[my][mx] == "#"
150
+ break if mx < 0 || mx >= dm_w || my < 0 || my >= dm_h
151
+ end
152
+ dist = side == 0 ? (mx - cam_x + (1 - step_x) / 2.0) / rd_x : (my - cam_y + (1 - step_y) / 2.0) / rd_y
153
+ dist = dist.abs
154
+ perp = dist * Math.cos(ray_a - cam_a)
155
+ perp = 0.1 if perp < 0.1
156
+ dists[col] = perp
157
+
158
+ wall_h = (virt_h / perp).to_i
159
+ draw_start = [horizon - wall_h / 2, 0].max
160
+ draw_end = [horizon + wall_h / 2, virt_h - 1].min
161
+
162
+ wb = 255 - [[(dist * 2.5).to_i, 0].max, 19].min
163
+ wb -= 3 if side == 1
164
+ wb = wb.clamp(233, 255)
165
+ grey = 8 + (wb - 232) * 10
166
+ if @demo_fire > 0 && dist < 4.0
167
+ flash = @demo_fire / 4.0 * (1.0 - dist / 4.0)
168
+ rr = (grey + flash * 160).to_i.clamp(0, 255)
169
+ gg = (grey + flash * 60).to_i.clamp(0, 255)
170
+ wall_c = "#{rr};#{gg};#{grey}"
171
+ else
172
+ wall_c = "#{grey};#{grey};#{grey}"
173
+ end
174
+
175
+ virt_h.times do |vr|
176
+ color[vr * tw + col] = if vr < draw_start
177
+ ceil_c
178
+ elsif vr <= draw_end
179
+ wall_c
180
+ else
181
+ floor_c
182
+ end
183
+ end
184
+ end
185
+
186
+ # Demo enemies
187
+ demo_enemies = m.enemy_defs
188
+ ddx = Math.cos(cam_a)
189
+ ddy = Math.sin(cam_a)
190
+ ppx = -ddy * Math.tan(fov / 2.0)
191
+ ppy = ddx * Math.tan(fov / 2.0)
192
+ inv_det = 1.0 / (ppx * ddy - ppy * ddx)
193
+
194
+ sprites = []
195
+ demo_enemies.each do |sx, sy, ax, ay, type|
196
+ seg_len = Math.sqrt((ax - sx)**2 + (ay - sy)**2) + 0.01
197
+ period = seg_len / 1.5
198
+ phase = (@title_spin * 0.5) % (period * 2)
199
+ et = phase < period ? phase / period : 2.0 - phase / period
200
+ ex = sx + (ax - sx) * et
201
+ ey = sy + (ay - sy) * et
202
+
203
+ rx = ex - cam_x
204
+ ry = ey - cam_y
205
+ tx = inv_det * (ddy * rx - ddx * ry)
206
+ tz = inv_det * (-ppy * rx + ppx * ry)
207
+ next if tz < 0.3
208
+
209
+ sprites << [tz, tx, type]
210
+ end
211
+ sprites.sort_by! { |s| -s[0] }
212
+
213
+ sprites.each do |tz, tx, type|
214
+ scr_x = ((tw / 2.0) * (1 + tx / tz)).to_i
215
+ sprite_h = (virt_h / tz).to_i
216
+ draw_top = [(horizon - sprite_h / 2), 0].max
217
+ draw_bot = [(horizon + sprite_h / 2), virt_h].min
218
+ sprite_w = (sprite_h / 2.0).to_i
219
+ start_x = [scr_x - sprite_w / 2, 0].max
220
+ end_x = [scr_x + sprite_w / 2, tw - 1].min
221
+
222
+ actual_h = draw_bot - draw_top
223
+ actual_w = end_x - start_x + 1
224
+ next if actual_h < 1 || actual_w < 1
225
+
226
+ use_shape = actual_h >= 6
227
+
228
+ start_x.upto(end_x) do |c|
229
+ next if c < 0 || c >= tw
230
+ next if dists[c] < tz
231
+
232
+ nx = (c - start_x).to_f / actual_w
233
+
234
+ draw_top.upto(draw_bot - 1) do |vr|
235
+ if use_shape
236
+ ny = (vr - draw_top).to_f / actual_h
237
+ sc = Sprite.for(type, nx, ny)
238
+ next unless sc
239
+ else
240
+ sc = type == :executor ? "100;60;200" : "220;140;30"
241
+ end
242
+ color[vr * tw + c] = sc
243
+ end
244
+ end
245
+ end
246
+
247
+ # Half-block rendering
248
+ th.times do |r|
249
+ vp0 = r * 2
250
+ vp1 = r * 2 + 1
251
+ line = +""
252
+ tw.times do |c|
253
+ tc = color[vp0 * tw + c]
254
+ bc = color[vp1 * tw + c]
255
+ line << if tc && bc
256
+ if tc == bc
257
+ "\e[38;2;#{tc}m\xE2\x96\x88\e[0m"
258
+ else
259
+ "\e[38;2;#{tc};48;2;#{bc}m\xE2\x96\x80\e[0m"
260
+ end
261
+ elsif tc
262
+ "\e[38;2;#{tc}m\xE2\x96\x80\e[0m"
263
+ elsif bc
264
+ "\e[38;2;#{bc}m\xE2\x96\x84\e[0m"
265
+ else
266
+ " "
267
+ end
268
+ end
269
+ lines[r] = TerminalOutput.fit_ansi(line, cols)
270
+ end
271
+
272
+ # Title text
273
+ title_row = [[th + 1, rows - 4].min, 1].max
274
+ title = "T E R M F R O N T"
275
+ sub = "Terminal FPS"
276
+ tc = [(cols - title.size) / 2 + 1, 1].max
277
+ sc = [(cols - sub.size) / 2 + 1, 1].max
278
+ lines[title_row - 1] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
279
+ lines[title_row] = TerminalOutput.fit_ansi("#{" " * (sc - 1)}\e[38;2;80;80;120m#{sub}\e[0m", cols)
280
+
281
+ # Menu items
282
+ items = ["[P] PvP", "[F] Wavesfight", "[C] Campaign", "[S] Training", "[Q] Quit"]
283
+ items_count_for_menu = items.size
284
+ menu_row = [[title_row + 2, rows - items_count_for_menu].min, 1].max
285
+ items.each_with_index do |item, i|
286
+ ic = [(cols - item.size) / 2 + 1, 1].max
287
+ lines[menu_row + i - 1] = TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[97m#{item}\e[0m", cols)
288
+ end
289
+
290
+ lines.each_with_index do |line, index|
291
+ buf << line
292
+ buf << "\r\n" if index < rows - 1
293
+ end
294
+
295
+ buf << TerminalOutput.end_frame
296
+ TerminalOutput.write_all(@stdout, buf)
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Weapon
5
+ class AssaultRifle < Base
6
+ def name = "AR"
7
+ def max_ammo = 60
8
+ def cooldown = 0.12
9
+ def hit_width = 0.3
10
+ def type_id = :ar
11
+ end
12
+
13
+ Base.register(:ar, AssaultRifle)
14
+ end
15
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Weapon
5
+ class Base
6
+ attr_accessor :ammo
7
+
8
+ def initialize(ammo: nil)
9
+ @ammo = ammo.nil? ? max_ammo : ammo
10
+ end
11
+
12
+ def name = raise(NotImplementedError)
13
+ def max_ammo = raise(NotImplementedError)
14
+ def cooldown = raise(NotImplementedError)
15
+ def hit_width = raise(NotImplementedError)
16
+ def type_id = raise(NotImplementedError)
17
+
18
+ def infinite_ammo? = max_ammo.nil?
19
+
20
+ def can_fire?(last_fire, now)
21
+ (now - last_fire) > cooldown
22
+ end
23
+
24
+ def consume_ammo!
25
+ @ammo -= 1 if @ammo
26
+ end
27
+
28
+ class << self
29
+ def registry
30
+ @registry ||= {}
31
+ end
32
+
33
+ def register(type, klass)
34
+ registry[type] = klass
35
+ end
36
+
37
+ def build(type, ammo = nil)
38
+ klass = registry[type] || raise(ArgumentError, "Unknown weapon type: #{type}")
39
+ ammo ? klass.new(ammo: ammo) : klass.new
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Weapon
5
+ class Pistol < Base
6
+ def name = "Pistol"
7
+ def max_ammo = nil
8
+ def cooldown = 0.45
9
+ def hit_width = 0.5
10
+ def type_id = :pistol
11
+ end
12
+
13
+ Base.register(:pistol, Pistol)
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Weapon
5
+ class ShockPistol < Base
6
+ def name = "S.Pistol"
7
+ def max_ammo = 60
8
+ def cooldown = 0.35
9
+ def hit_width = 0.6
10
+ def type_id = :shock_pistol
11
+ end
12
+
13
+ Base.register(:shock_pistol, ShockPistol)
14
+ end
15
+ end