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,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "shellwords"
5
+ require "thread"
6
+
7
+ module Termfront
8
+ class AudioManager
9
+ Player = Struct.new(:command, :supports_loop, keyword_init: true)
10
+
11
+ def initialize
12
+ @manifest = load_manifest
13
+ @bgm_player = detect_player(%w[ffplay afplay paplay aplay], prefer_loop: true)
14
+ @loop_se_player = detect_player(%w[ffplay afplay paplay aplay], prefer_loop: true)
15
+ @se_player = detect_player(%w[paplay afplay aplay ffplay], prefer_loop: false)
16
+ @mutex = Mutex.new
17
+ @bgm_pid = nil
18
+ @bgm_thread = nil
19
+ @bgm_stop = false
20
+ @loop_se_pid = nil
21
+ @loop_se_thread = nil
22
+ @loop_se_stop = false
23
+ @loop_se_name = nil
24
+ end
25
+
26
+ def play_bgm(name)
27
+ path = asset_path(:bgm, name)
28
+ return unless path
29
+
30
+ stop_bgm
31
+
32
+ @mutex.synchronize do
33
+ @bgm_stop = false
34
+ @bgm_thread = Thread.new do
35
+ if @bgm_player&.supports_loop
36
+ @bgm_pid = spawn_player(@bgm_player, path, loop_playback: true)
37
+ wait_for_channel(:bgm)
38
+ else
39
+ loop do
40
+ break if channel_stopped?(:bgm)
41
+
42
+ @bgm_pid = spawn_player(@bgm_player, path, loop_playback: false)
43
+ wait_for_channel(:bgm)
44
+ break if channel_stopped?(:bgm)
45
+ end
46
+ end
47
+ rescue StandardError
48
+ nil
49
+ ensure
50
+ @mutex.synchronize do
51
+ @bgm_pid = nil
52
+ @bgm_thread = nil
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ def stop_bgm
59
+ thread = nil
60
+ pid = nil
61
+
62
+ @mutex.synchronize do
63
+ @bgm_stop = true
64
+ thread = @bgm_thread
65
+ pid = @bgm_pid
66
+ end
67
+
68
+ terminate_process(pid) if pid
69
+ thread&.join(0.5)
70
+ end
71
+
72
+ def play_se(name)
73
+ path = asset_path(:se, name)
74
+ return unless path && @se_player
75
+
76
+ spawn_player(@se_player, path, loop_playback: false, detach: true)
77
+ rescue StandardError
78
+ nil
79
+ end
80
+
81
+ def play_loop_se(name)
82
+ path = asset_path(:loop_se, name) || asset_path(:se, name)
83
+ return unless path
84
+
85
+ @mutex.synchronize do
86
+ return if @loop_se_name == name && @loop_se_thread&.alive?
87
+ end
88
+
89
+ stop_loop_se
90
+
91
+ @mutex.synchronize do
92
+ @loop_se_stop = false
93
+ @loop_se_name = name
94
+ @loop_se_thread = Thread.new do
95
+ if @loop_se_player&.supports_loop
96
+ @loop_se_pid = spawn_player(@loop_se_player, path, loop_playback: true)
97
+ wait_for_channel(:loop_se)
98
+ else
99
+ loop do
100
+ break if channel_stopped?(:loop_se)
101
+
102
+ @loop_se_pid = spawn_player(@loop_se_player, path, loop_playback: false)
103
+ wait_for_channel(:loop_se)
104
+ break if channel_stopped?(:loop_se)
105
+ end
106
+ end
107
+ rescue StandardError
108
+ nil
109
+ ensure
110
+ @mutex.synchronize do
111
+ @loop_se_pid = nil
112
+ @loop_se_thread = nil
113
+ @loop_se_name = nil
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ def stop_loop_se(name = nil)
120
+ thread = nil
121
+ pid = nil
122
+
123
+ @mutex.synchronize do
124
+ return if name && @loop_se_name != name
125
+
126
+ @loop_se_stop = true
127
+ thread = @loop_se_thread
128
+ pid = @loop_se_pid
129
+ end
130
+
131
+ terminate_process(pid) if pid
132
+ thread&.join(0.5)
133
+ end
134
+
135
+ def close
136
+ stop_bgm
137
+ stop_loop_se
138
+ end
139
+
140
+ private
141
+
142
+ def load_manifest
143
+ path = File.expand_path("../../data/audio/manifest.json", __dir__)
144
+ JSON.parse(File.read(path))
145
+ rescue Errno::ENOENT, JSON::ParserError
146
+ {}
147
+ end
148
+
149
+ def detect_player(candidates, prefer_loop:)
150
+ found = candidates.filter_map do |command|
151
+ path = which(command)
152
+ next unless path
153
+
154
+ Player.new(command: command, supports_loop: command == "ffplay")
155
+ end
156
+
157
+ return found.find(&:supports_loop) if prefer_loop
158
+
159
+ found.first
160
+ end
161
+
162
+ def which(command)
163
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
164
+ candidate = File.join(dir, command)
165
+ return candidate if File.executable?(candidate) && !File.directory?(candidate)
166
+ end
167
+
168
+ nil
169
+ end
170
+
171
+ def asset_path(kind, name)
172
+ relative = @manifest.fetch(kind.to_s, {})[name.to_s]
173
+ return unless relative
174
+
175
+ path = File.expand_path("../../#{relative}", __dir__)
176
+ File.file?(path) ? path : nil
177
+ end
178
+
179
+ def spawn_player(player, path, loop_playback:, detach: false)
180
+ return unless player
181
+
182
+ command = command_for(player.command, path, loop_playback)
183
+ return unless command
184
+
185
+ pid = Process.spawn(*command, pgroup: true, out: File::NULL, err: File::NULL)
186
+ Process.detach(pid) if detach
187
+ pid
188
+ end
189
+
190
+ def command_for(command, path, loop_playback)
191
+ case command
192
+ when "ffplay"
193
+ ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", ("-loop" if loop_playback), ("0" if loop_playback), path].compact
194
+ when "afplay"
195
+ ["afplay", path]
196
+ when "paplay"
197
+ ["paplay", path]
198
+ when "aplay"
199
+ ["aplay", "-q", path]
200
+ end
201
+ end
202
+
203
+ def wait_for_channel(channel)
204
+ pid = nil
205
+ @mutex.synchronize do
206
+ pid = channel == :bgm ? @bgm_pid : @loop_se_pid
207
+ end
208
+ Process.wait(pid) if pid
209
+ rescue Errno::ECHILD
210
+ nil
211
+ end
212
+
213
+ def channel_stopped?(channel)
214
+ @mutex.synchronize do
215
+ channel == :bgm ? @bgm_stop : @loop_se_stop
216
+ end
217
+ end
218
+
219
+ def terminate_process(pid)
220
+ Process.kill("TERM", -pid)
221
+ rescue Errno::ESRCH
222
+ nil
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Config
5
+ FRAME_DT = 1.0 / 30.0
6
+ FOV = 66.0 * Math::PI / 180.0
7
+ PLAYER_RADIUS = 0.2
8
+ KEY_TIMEOUT = 5
9
+ ROT_SPEED = 2.8
10
+ MOVE_SPEED = 6.0
11
+
12
+ CEIL_C = 17
13
+ FLOOR_C = 234
14
+
15
+ ARROWS = ">v<^".chars
16
+
17
+ SHIELD_MAX = 100
18
+ SHIELD_REGEN = 25.0
19
+ SHIELD_DELAY = 3.0
20
+
21
+ HEALTH_MAX = 100
22
+ BEEP_INTERVAL = 0.15
23
+
24
+ PICKUP_RADIUS = 0.8
25
+ TERMINAL_USE_RADIUS = 2.25
26
+
27
+ PROJ_SPEED = 2.5
28
+ PROJ_RADIUS = 0.3
29
+
30
+ RADAR_RADIUS = 3
31
+ RADAR_RANGE = 12.0
32
+
33
+ PVP_PORT = 7777
34
+ PVP_HIT_DMG = 10
35
+
36
+ DEMO_SPEED = 0.008
37
+ end
38
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class DemoPlayer
5
+ DemoActor = Struct.new(:x, :y, :sprite_id, :alive, :hp, :max_hp, keyword_init: true)
6
+
7
+ def initialize(stdout, renderer)
8
+ @stdout = stdout
9
+ @renderer = renderer
10
+ end
11
+
12
+ def play(action, mission:, stdin: nil)
13
+ path = Array(action[:path])
14
+ return if path.empty?
15
+
16
+ duration = (action[:duration] || path.last[:t] || 0.0).to_f
17
+ return if duration <= 0
18
+
19
+ if stdin
20
+ play_loop(stdin, action, mission, path, duration)
21
+ else
22
+ STDIN.raw { |raw| play_loop(raw, action, mission, path, duration) }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def play_loop(stdin, action, mission, path, duration)
29
+ map = mission.build_map
30
+ player = build_player(mission, path.first)
31
+ terminals = mission.build_terminals
32
+ actors = build_actors(action[:actors])
33
+ fire_times = Array(action[:fire_times]).map(&:to_f)
34
+
35
+ started_at = clock
36
+ last_tick = started_at
37
+
38
+ loop do
39
+ now = clock
40
+ elapsed = now - started_at
41
+ dt = now - last_tick
42
+ last_tick = now
43
+
44
+ return if skip_requested?(stdin)
45
+
46
+ pose = interpolate_pose(path, [elapsed, duration].min)
47
+ player.x = pose[:x]
48
+ player.y = pose[:y]
49
+ player.angle = pose[:angle]
50
+ player.game_time += dt
51
+ player.fire_flash = fire_active?(fire_times, elapsed) ? 3 : 0
52
+
53
+ @renderer.render(
54
+ player: player,
55
+ map: map,
56
+ enemies: active_actors(actors, elapsed),
57
+ projectiles: [],
58
+ drops: [],
59
+ terminals: terminals
60
+ )
61
+ render_caption(action[:caption], elapsed, duration)
62
+
63
+ break if elapsed >= duration
64
+
65
+ sleep(Config::FRAME_DT)
66
+ end
67
+ end
68
+
69
+ def build_player(mission, pose)
70
+ weapons = mission.build_weapons
71
+ weapons = [Weapon::Base.build(:pistol)] if weapons.empty?
72
+ player = Player.new(
73
+ x: pose[:x],
74
+ y: pose[:y],
75
+ angle: pose[:angle] || 0.0,
76
+ weapons: weapons
77
+ )
78
+ player.drops = []
79
+ player
80
+ end
81
+
82
+ def build_actors(definitions)
83
+ Array(definitions).map do |definition|
84
+ {
85
+ entity: DemoActor.new(
86
+ x: definition[:x].to_f,
87
+ y: definition[:y].to_f,
88
+ sprite_id: definition[:sprite_id].to_sym,
89
+ alive: true,
90
+ hp: 1,
91
+ max_hp: 1
92
+ ),
93
+ from: definition.fetch(:from, 0.0).to_f,
94
+ to: definition.fetch(:to, Float::INFINITY).to_f
95
+ }
96
+ end
97
+ end
98
+
99
+ def active_actors(actors, elapsed)
100
+ actors.filter_map do |actor|
101
+ next unless elapsed >= actor[:from] && elapsed <= actor[:to]
102
+
103
+ actor[:entity]
104
+ end
105
+ end
106
+
107
+ def interpolate_pose(path, elapsed)
108
+ current = path.first
109
+ nxt = path.last
110
+
111
+ path.each_cons(2) do |left, right|
112
+ if elapsed <= right[:t].to_f
113
+ current = left
114
+ nxt = right
115
+ break
116
+ end
117
+ end
118
+
119
+ span = nxt[:t].to_f - current[:t].to_f
120
+ ratio = span <= 0 ? 1.0 : ((elapsed - current[:t].to_f) / span).clamp(0.0, 1.0)
121
+
122
+ {
123
+ x: lerp(current[:x], nxt[:x], ratio),
124
+ y: lerp(current[:y], nxt[:y], ratio),
125
+ angle: lerp_angle(current[:angle] || 0.0, nxt[:angle] || 0.0, ratio)
126
+ }
127
+ end
128
+
129
+ def render_caption(caption, elapsed, duration)
130
+ return if caption.to_s.empty?
131
+
132
+ rows, cols = @stdout.winsize
133
+ lines = caption.to_s.split("\n")
134
+ base_row = [rows - lines.size - 2, 2].max
135
+
136
+ buf = TerminalOutput.begin_frame
137
+ lines.each_with_index do |line, index|
138
+ col = [(cols - line.size) / 2 + 1, 1].max
139
+ buf << "\e[#{base_row + index};1H\e[K"
140
+ buf << "\e[#{base_row + index};#{col}H\e[1;97m#{line}\e[0m"
141
+ end
142
+
143
+ hint = "[Enter] Skip Demo"
144
+ progress = "#{elapsed.ceil}/#{duration.ceil}s"
145
+ buf << "\e[#{rows - 1};3H\e[90m#{hint}\e[0m"
146
+ buf << "\e[#{rows - 1};#{[cols - progress.size - 1, 1].max}H\e[90m#{progress}\e[0m"
147
+ buf << TerminalOutput.end_frame
148
+ TerminalOutput.write_all(@stdout, buf)
149
+ end
150
+
151
+ def fire_active?(fire_times, elapsed)
152
+ fire_times.any? { |time| (elapsed - time).abs < 0.12 }
153
+ end
154
+
155
+ def skip_requested?(stdin)
156
+ while IO.select([stdin], nil, nil, 0)
157
+ data = stdin.read_nonblock(64)
158
+ return true if data.bytes.any? { |byte| [13, 10, 27, 32, 81, 113].include?(byte) }
159
+ end
160
+
161
+ false
162
+ rescue IO::WaitReadable
163
+ false
164
+ end
165
+
166
+ def lerp(a, b, t)
167
+ a.to_f + (b.to_f - a.to_f) * t
168
+ end
169
+
170
+ def lerp_angle(a, b, t)
171
+ delta = b.to_f - a.to_f
172
+ delta -= Math::PI * 2 while delta > Math::PI
173
+ delta += Math::PI * 2 while delta < -Math::PI
174
+ a.to_f + delta * t
175
+ end
176
+
177
+ def clock
178
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module DropItem
5
+ class Base
6
+ attr_accessor :x, :y
7
+
8
+ def initialize(x:, y:)
9
+ @x = x
10
+ @y = y
11
+ end
12
+
13
+ def in_range?(px, py)
14
+ (px - @x)**2 + (py - @y)**2 < Config::PICKUP_RADIUS**2
15
+ end
16
+
17
+ def pickup!(player)
18
+ raise NotImplementedError
19
+ end
20
+
21
+ def sprite_color
22
+ raise NotImplementedError
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module DropItem
5
+ class Weapon < Base
6
+ attr_accessor :type, :ammo
7
+
8
+ def initialize(x:, y:, type:, ammo:)
9
+ super(x: x, y: y)
10
+ @type = type
11
+ @ammo = ammo
12
+ end
13
+
14
+ def pickup!(player)
15
+ cur = player.current_weapon
16
+ if cur.type_id == @type
17
+ max = cur.max_ammo
18
+ cur.ammo = [cur.ammo + @ammo, max].min if max
19
+ else
20
+ player.drops << DropItem::Weapon.new(x: player.x, y: player.y, type: cur.type_id, ammo: cur.ammo)
21
+ player.weapons[player.weapon_idx] = Weapon::Base.build(@type, @ammo)
22
+ end
23
+ end
24
+
25
+ def sprite_color
26
+ @type.to_s.start_with?("shock") ? "60;200;220" : "220;200;60"
27
+ end
28
+
29
+ def radar_color
30
+ @type.to_s.start_with?("shock") ? "\e[96m" : "\e[93m"
31
+ end
32
+
33
+ def radar_label
34
+ Weapon::Base.registry[@type].new.name[0]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Enemy
5
+ class Base
6
+ attr_accessor :x, :y, :wp_a, :wp_b, :wp_t, :wp_dir, :last_fire, :alive, :hp, :max_hp
7
+
8
+ DIFFICULTIES = [
9
+ { name: "Easy", hp_mult: 1, cooldown_mult: 1.5, extra_enemies: 0 },
10
+ { name: "Normal", hp_mult: 2, cooldown_mult: 1.0, extra_enemies: 1 },
11
+ { name: "Hard", hp_mult: 3, cooldown_mult: 0.7, extra_enemies: 3 },
12
+ { name: "Very Hard", hp_mult: 4, cooldown_mult: 0.5, extra_enemies: 5 }
13
+ ].freeze
14
+
15
+ def initialize(x:, y:, wp_a:, wp_b:, hp:)
16
+ @x = x
17
+ @y = y
18
+ @wp_a = wp_a
19
+ @wp_b = wp_b
20
+ @wp_t = 0.0
21
+ @wp_dir = 1
22
+ @last_fire = 0.0
23
+ @alive = true
24
+ @hp = hp
25
+ @max_hp = hp
26
+ end
27
+
28
+ def damage = raise(NotImplementedError)
29
+ def range = raise(NotImplementedError)
30
+ def cooldown = raise(NotImplementedError)
31
+ def speed = raise(NotImplementedError)
32
+ def drop_type = raise(NotImplementedError)
33
+ def drop_ammo = raise(NotImplementedError)
34
+ def sprite_id = raise(NotImplementedError)
35
+ def base_hp = raise(NotImplementedError)
36
+
37
+ def dead? = !@alive
38
+
39
+ def take_damage(amount)
40
+ @hp -= amount
41
+ return unless @hp <= 0
42
+
43
+ @alive = false
44
+ end
45
+
46
+ def update(dt, player, projectiles, map, game_time, difficulty:)
47
+ return unless @alive
48
+
49
+ patrol(dt)
50
+
51
+ edx = player.x - @x
52
+ edy = player.y - @y
53
+ edist = Math.sqrt(edx * edx + edy * edy)
54
+ cd = cooldown
55
+ cd *= DIFFICULTIES[difficulty][:cooldown_mult] if difficulty
56
+ return unless edist < range && (game_time - @last_fire) > cd
57
+ return unless map.line_of_sight?(@x, @y, player.x, player.y)
58
+
59
+ @last_fire = game_time
60
+ ndx = edx / edist
61
+ ndy = edy / edist
62
+ projectiles << Projectile.new(
63
+ x: @x, y: @y,
64
+ vx: ndx * Config::PROJ_SPEED,
65
+ vy: ndy * Config::PROJ_SPEED,
66
+ type: sprite_id
67
+ )
68
+ end
69
+
70
+ def patrol(dt)
71
+ seg_len = Math.sqrt(
72
+ (@wp_b[0] - @wp_a[0])**2 + (@wp_b[1] - @wp_a[1])**2 + 0.01
73
+ )
74
+ @wp_t += @wp_dir * speed * dt / seg_len
75
+ if @wp_t >= 1.0
76
+ @wp_t = 1.0
77
+ @wp_dir = -1
78
+ elsif @wp_t <= 0.0
79
+ @wp_t = 0.0
80
+ @wp_dir = 1
81
+ end
82
+ @x = @wp_a[0] + (@wp_b[0] - @wp_a[0]) * @wp_t
83
+ @y = @wp_a[1] + (@wp_b[1] - @wp_a[1]) * @wp_t
84
+ end
85
+
86
+ class << self
87
+ def registry
88
+ @registry ||= {}
89
+ end
90
+
91
+ def register(type, klass)
92
+ registry[type] = klass
93
+ end
94
+
95
+ def build(type, enemy_def, difficulty_index)
96
+ klass = registry[type] || raise(ArgumentError, "Unknown enemy type: #{type}")
97
+ sx, sy, ax, ay, _type = enemy_def
98
+ hp = compute_hp(klass, difficulty_index)
99
+ klass.new(x: sx, y: sy, wp_a: [sx, sy], wp_b: [ax, ay], hp: hp)
100
+ end
101
+
102
+ def generate_extras(base_list, count, difficulty_index)
103
+ return [] if count == 0 || base_list.empty?
104
+
105
+ extras = []
106
+ count.times do |i|
107
+ src = base_list[i % base_list.size]
108
+ sx, sy, ax, ay, type = src
109
+ offset = 0.3 + (i * 0.2)
110
+ klass = registry[type] || raise(ArgumentError, "Unknown enemy type: #{type}")
111
+ hp = compute_hp(klass, difficulty_index)
112
+ extras << klass.new(
113
+ x: sx + offset, y: sy + offset,
114
+ wp_a: [sx + offset, sy + offset],
115
+ wp_b: [ax + offset, ay + offset],
116
+ hp: hp
117
+ )
118
+ end
119
+ extras
120
+ end
121
+
122
+ private
123
+
124
+ def compute_hp(klass, difficulty_index)
125
+ return 1 unless difficulty_index
126
+
127
+ instance = klass.allocate
128
+ instance.send(:base_hp) * DIFFICULTIES[difficulty_index][:hp_mult]
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Enemy
5
+ class Crawler < Base
6
+ def damage = 10
7
+ def range = 5.0
8
+ def cooldown = 1.5
9
+ def speed = 1.8
10
+ def drop_type = :shock_pistol
11
+ def drop_ammo = 60
12
+ def sprite_id = :crawler
13
+ def base_hp = 1
14
+ end
15
+
16
+ Base.register(:crawler, Crawler)
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Enemy
5
+ class Executor < Base
6
+ def damage = 25
7
+ def range = 8.0
8
+ def cooldown = 2.5
9
+ def speed = 1.2
10
+ def drop_type = :shock_rifle
11
+ def drop_ammo = 100
12
+ def sprite_id = :executor
13
+ def base_hp = 2
14
+ end
15
+
16
+ Base.register(:executor, Executor)
17
+ end
18
+ end