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,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Network
5
+ class WavesfightClient
6
+ def initialize(stdout)
7
+ @stdout = stdout
8
+ @conn = Connection.new
9
+ @input = Input.new
10
+ @renderer = Renderer.new(stdout)
11
+ @audio = AudioManager.new
12
+ end
13
+
14
+ def run(mission_id:, difficulty:)
15
+ @queue_mission_id = mission_id
16
+ @queue_difficulty = difficulty
17
+ addr = prompt_address
18
+ return unless addr
19
+
20
+ host, port = addr.include?(":") ? addr.split(":", 2).then { |h, p| [h, p.to_i] } : [addr, Config::PVP_PORT]
21
+ begin
22
+ @conn.connect(host, port)
23
+ @conn.send_msg({ t: "queue", mode: "wavesfight", mission_id: mission_id, difficulty: difficulty })
24
+ rescue StandardError => e
25
+ show_error("Connection failed: #{e.message}")
26
+ return
27
+ end
28
+
29
+ begin
30
+ unless wait_for_start
31
+ @conn.close
32
+ return
33
+ end
34
+ run_game_loop
35
+ rescue StandardError => e
36
+ show_error("Error: #{e.message}")
37
+ ensure
38
+ @audio.close
39
+ @conn.close
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def prompt_address
46
+ input = "localhost:#{Config::PVP_PORT}"
47
+
48
+ STDIN.raw do |stdin|
49
+ loop do
50
+ rows, cols = @stdout.winsize
51
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
52
+ lines = Array.new(rows) { " " * cols }
53
+
54
+ title = "Wavesfight Co-op - Enter Server Address"
55
+ tc = [(cols - title.size) / 2 + 1, 1].max
56
+ lines[rows / 2 - 3] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;96m#{title}\e[0m", cols)
57
+
58
+ pc = [(cols - input.size - 3) / 2 + 1, 1].max
59
+ lines[rows / 2 - 1] = TerminalOutput.fit_ansi("#{" " * (pc - 1)}\e[97m> #{input}\e[5m_\e[0m", cols)
60
+
61
+ hint = "(Enter to continue, ESC to cancel)"
62
+ hc = [(cols - hint.size) / 2 + 1, 1].max
63
+ lines[rows / 2 + 1] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
64
+
65
+ lines.each_with_index do |line, index|
66
+ buf << line
67
+ buf << "\r\n" if index < rows - 1
68
+ end
69
+ buf << TerminalOutput.end_frame
70
+ TerminalOutput.write_all(@stdout, buf)
71
+
72
+ next unless IO.select([stdin], nil, nil, Config::FRAME_DT)
73
+
74
+ begin
75
+ data = stdin.read_nonblock(64)
76
+ data.each_byte do |b|
77
+ case b
78
+ when 27 then return nil
79
+ when 13, 10 then return input.empty? ? "localhost:#{Config::PVP_PORT}" : input
80
+ when 127, 8 then input = input[0...-1] unless input.empty?
81
+ when 32..126 then input << b.chr
82
+ end
83
+ end
84
+ rescue IO::WaitReadable
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ def wait_for_start
91
+ STDIN.raw do |stdin|
92
+ loop do
93
+ rows, cols = @stdout.winsize
94
+ buf = TerminalOutput.begin_frame(home: true, clear: true)
95
+ lines = Array.new(rows) { " " * cols }
96
+ msg = "Waiting for co-op partner..."
97
+ mc = [(cols - msg.size) / 2 + 1, 1].max
98
+ lines[rows / 2 - 2] = TerminalOutput.fit_ansi("#{" " * (mc - 1)}\e[1;93m#{msg}\e[0m", cols)
99
+ detail = "#{queued_mission_name} | #{queued_difficulty_name}"
100
+ dc = [(cols - detail.size) / 2 + 1, 1].max
101
+ lines[rows / 2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}\e[38;2;170;170;190m#{detail}\e[0m", cols)
102
+ hint = "(ESC to cancel)"
103
+ hc = [(cols - hint.size) / 2 + 1, 1].max
104
+ lines[rows / 2 + 2] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
105
+
106
+ lines.each_with_index do |line, index|
107
+ buf << line
108
+ buf << "\r\n" if index < rows - 1
109
+ end
110
+ buf << TerminalOutput.end_frame
111
+ TerminalOutput.write_all(@stdout, buf)
112
+
113
+ if IO.select([stdin], nil, nil, 0)
114
+ begin
115
+ ch = stdin.read_nonblock(64)
116
+ return false if ch.bytes.include?(27)
117
+ rescue IO::WaitReadable
118
+ end
119
+ end
120
+
121
+ next unless IO.select([@conn.socket], nil, nil, 0.1)
122
+
123
+ @conn.receive.each do |msg|
124
+ next unless msg[:t] == "wavesfight_start"
125
+
126
+ load_match(msg)
127
+ return true
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def load_match(msg)
134
+ @map = Map.new(msg[:map])
135
+ @mission_name = msg[:mission]
136
+ @player_id = msg[:id]
137
+ @wave = 1
138
+ @difficulty = 1
139
+ self_info = msg[:players].find { |entry| entry[:id] == @player_id }
140
+ weapons = [Weapon::Base.build(:ar, 60), Weapon::Base.build(:pistol)]
141
+ @player = Player.new(x: self_info[:spawn][0], y: self_info[:spawn][1], angle: self_info[:spawn][2], weapons: weapons)
142
+ @player.drops = []
143
+ @remote_players = {}
144
+ msg[:players].each do |entry|
145
+ next if entry[:id] == @player_id
146
+
147
+ @remote_players[entry[:id]] = Opponent.new(x: entry[:spawn][0], y: entry[:spawn][1], angle: entry[:spawn][2])
148
+ end
149
+ @enemies = []
150
+ @projectiles = []
151
+ @match_end = nil
152
+ end
153
+
154
+ def run_game_loop
155
+ STDIN.raw do |stdin|
156
+ last_time = clock
157
+ last_ping = clock
158
+
159
+ loop do
160
+ now = clock
161
+ dt = now - last_time
162
+ last_time = now
163
+
164
+ keys = @input.process(stdin, player: @player)
165
+ return if @input.key?(:esc)
166
+
167
+ unless @player.dead
168
+ handle_player_actions(keys)
169
+ update_local(dt)
170
+ send_state
171
+ end
172
+
173
+ handle_messages
174
+ render_world
175
+
176
+ if @match_end
177
+ text = @match_end[:reason] == "defeat" ? "TEAM DOWN AT WAVE #{@match_end[:wave]}" : "MATCH CANCELED"
178
+ render_result(text, @match_end[:reason] == "defeat" ? "\e[1;91m" : "\e[1;93m")
179
+ sleep 3
180
+ return
181
+ end
182
+
183
+ if now - last_ping > 2.0
184
+ @conn.ping(now)
185
+ last_ping = now
186
+ end
187
+
188
+ cap_frame(now)
189
+ end
190
+ end
191
+ end
192
+
193
+ def handle_player_actions(keys)
194
+ @player.swap_weapon if keys.include?(:t)
195
+
196
+ return unless keys.include?(:space)
197
+
198
+ weapon = @player.current_weapon
199
+ return unless weapon.can_fire?(@player.last_fire, @player.game_time)
200
+ return unless weapon.infinite_ammo? || (weapon.ammo && weapon.ammo > 0)
201
+
202
+ @player.fire_flash = 4
203
+ weapon.consume_ammo!
204
+ @player.last_fire = @player.game_time
205
+ @audio.play_se(:shoot)
206
+ @conn.send_msg({ t: "fire" })
207
+ end
208
+
209
+ def update_local(dt)
210
+ @player.game_time += dt
211
+
212
+ @player.angle -= Config::ROT_SPEED * dt if @input.key?(:left)
213
+ @player.angle += Config::ROT_SPEED * dt if @input.key?(:right)
214
+
215
+ dx = Math.cos(@player.angle)
216
+ dy = Math.sin(@player.angle)
217
+ sx = -dy
218
+ sy = dx
219
+ mvx = 0.0
220
+ mvy = 0.0
221
+ if @input.key?(:w)
222
+ mvx += dx * Config::MOVE_SPEED * dt
223
+ mvy += dy * Config::MOVE_SPEED * dt
224
+ end
225
+ if @input.key?(:s)
226
+ mvx -= dx * Config::MOVE_SPEED * dt
227
+ mvy -= dy * Config::MOVE_SPEED * dt
228
+ end
229
+ if @input.key?(:a)
230
+ mvx -= sx * Config::MOVE_SPEED * dt
231
+ mvy -= sy * Config::MOVE_SPEED * dt
232
+ end
233
+ if @input.key?(:d)
234
+ mvx += sx * Config::MOVE_SPEED * dt
235
+ mvy += sy * Config::MOVE_SPEED * dt
236
+ end
237
+
238
+ nx = @player.x + mvx
239
+ @player.x = nx unless @map.blocked?(nx, @player.y)
240
+ ny = @player.y + mvy
241
+ @player.y = ny unless @map.blocked?(@player.x, ny)
242
+
243
+ @player.fire_flash -= 1 if @player.fire_flash > 0
244
+ end
245
+
246
+ def send_state
247
+ @conn.send_msg({
248
+ t: "state",
249
+ x: @player.x.round(3),
250
+ y: @player.y.round(3),
251
+ a: @player.angle.round(4),
252
+ w: @player.current_weapon.type_id,
253
+ am: @player.current_weapon.ammo || -1,
254
+ ff: @player.fire_flash
255
+ })
256
+ end
257
+
258
+ def handle_messages
259
+ @conn.receive.each do |msg|
260
+ case msg[:t]
261
+ when "world"
262
+ apply_world(msg)
263
+ when "hit"
264
+ @player.apply_damage(msg[:d] || Config::PVP_HIT_DMG)
265
+ @audio.play_se(:damage)
266
+ when "wave_start"
267
+ @wave = msg[:wave]
268
+ @difficulty = msg[:difficulty]
269
+ when "match_end"
270
+ @match_end = msg
271
+ end
272
+ end
273
+ end
274
+
275
+ def apply_world(msg)
276
+ @wave = msg[:wave]
277
+ @difficulty = msg[:difficulty]
278
+
279
+ msg[:players].each do |entry|
280
+ if entry[:id] == @player_id
281
+ @player.shield = entry[:s]
282
+ @player.health = entry[:h]
283
+ @player.dead = !entry[:alive]
284
+ else
285
+ remote = @remote_players[entry[:id]]
286
+ next unless remote
287
+
288
+ remote.x = entry[:x]
289
+ remote.y = entry[:y]
290
+ remote.angle = entry[:a]
291
+ remote.shield = entry[:s]
292
+ remote.health = entry[:h]
293
+ remote.weapon = entry[:w]&.to_sym
294
+ remote.ammo = entry[:am]
295
+ remote.fire_flash = entry[:ff] || 0
296
+ end
297
+ end
298
+
299
+ @enemies = msg[:enemies].filter_map do |enemy|
300
+ next unless enemy[:alive]
301
+
302
+ RemoteEnemy.new(
303
+ id: enemy[:id],
304
+ x: enemy[:x],
305
+ y: enemy[:y],
306
+ sprite_id: enemy[:type].to_sym,
307
+ hp: enemy[:hp],
308
+ max_hp: enemy[:max_hp],
309
+ alive: true
310
+ )
311
+ end
312
+
313
+ @projectiles = msg[:projectiles].map do |projectile|
314
+ Projectile.new(x: projectile[:x], y: projectile[:y], vx: 0.0, vy: 0.0, type: projectile[:type].to_sym)
315
+ end
316
+ end
317
+
318
+ def render_world
319
+ allies = @remote_players.values.select { |remote| remote.health.positive? }
320
+ status = " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]} CO-OP"
321
+ @renderer.render(
322
+ player: @player,
323
+ map: @map,
324
+ enemies: @enemies,
325
+ projectiles: @projectiles,
326
+ drops: [],
327
+ terminals: [],
328
+ status_line: status,
329
+ allies: allies
330
+ )
331
+ end
332
+
333
+ def render_result(text, color)
334
+ rows, cols = @stdout.winsize
335
+ @renderer.render_centered_message(rows, cols, text, color)
336
+ end
337
+
338
+ def show_error(msg)
339
+ rows, cols = @stdout.winsize
340
+ @renderer.render_centered_message(rows, cols, msg, "\e[1;91m")
341
+ STDIN.raw { |stdin| stdin.getc }
342
+ end
343
+
344
+ def queued_mission_name
345
+ mission = Mission::Base.wavesfight.find { |klass| klass.new.id == @queue_mission_id }
346
+ mission ? mission.new.name : @queue_mission_id.to_s
347
+ end
348
+
349
+ def queued_difficulty_name
350
+ Enemy::Base::DIFFICULTIES[@queue_difficulty][:name]
351
+ end
352
+
353
+ def clock
354
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
355
+ end
356
+
357
+ def cap_frame(frame_start)
358
+ spent = clock - frame_start
359
+ remain = Config::FRAME_DT - spent
360
+ sleep(remain) if remain > 0
361
+ end
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Opponent
5
+ attr_accessor :x, :y, :angle, :shield, :health, :weapon, :ammo, :fire_flash
6
+
7
+ def initialize(x:, y:, angle:, shield: Config::SHIELD_MAX, health: Config::HEALTH_MAX, weapon: :ar, ammo: 60,
8
+ fire_flash: 0)
9
+ @x = x
10
+ @y = y
11
+ @angle = angle
12
+ @shield = shield
13
+ @health = health
14
+ @weapon = weapon
15
+ @ammo = ammo
16
+ @fire_flash = fire_flash
17
+ end
18
+
19
+ def dup_state
20
+ Opponent.new(x: @x, y: @y, angle: @angle, shield: @shield, health: @health,
21
+ weapon: @weapon, ammo: @ammo, fire_flash: @fire_flash)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Player
5
+ attr_accessor :x, :y, :angle, :shield, :health, :weapons, :weapon_idx,
6
+ :last_fire, :dead, :game_time, :last_damage, :damage_flash,
7
+ :fire_flash, :beep_count, :last_beep, :regen_active,
8
+ :swap_pressed, :pickup_pressed, :drops
9
+
10
+ def initialize(x:, y:, angle:, weapons:)
11
+ @x = x
12
+ @y = y
13
+ @angle = angle
14
+ @weapons = weapons
15
+ @weapon_idx = 0
16
+ @last_fire = 0.0
17
+ @shield = Config::SHIELD_MAX
18
+ @health = Config::HEALTH_MAX
19
+ @dead = false
20
+ @game_time = 0.0
21
+ @last_damage = -Config::SHIELD_DELAY
22
+ @damage_flash = 0
23
+ @fire_flash = 0
24
+ @beep_count = 0
25
+ @last_beep = 0.0
26
+ @regen_active = false
27
+ @swap_pressed = false
28
+ @pickup_pressed = false
29
+ @drops = []
30
+ end
31
+
32
+ def current_weapon
33
+ @weapons[@weapon_idx]
34
+ end
35
+
36
+ def swap_weapon
37
+ @weapon_idx = 1 - @weapon_idx
38
+ end
39
+
40
+ def apply_damage(amount)
41
+ @last_damage = @game_time
42
+ @damage_flash = 3
43
+
44
+ if @shield > 0
45
+ overflow = amount - @shield
46
+ @shield = [(@shield - amount), 0].max
47
+ @health = [@health - [overflow, 0].max, 0].max if @shield == 0
48
+ else
49
+ @health = [@health - amount, 0].max
50
+ end
51
+
52
+ @dead = true if @health <= 0
53
+ end
54
+
55
+ def update_shield(dt, stdout, audio: nil)
56
+ regen_now = @shield < Config::SHIELD_MAX && (@game_time - @last_damage) >= Config::SHIELD_DELAY
57
+ if regen_now
58
+ unless @regen_active
59
+ @regen_active = true
60
+ if audio
61
+ audio.play_loop_se(:shield_regen)
62
+ else
63
+ stdout.syswrite("\a")
64
+ end
65
+ end
66
+ @shield = [@shield + Config::SHIELD_REGEN * dt, Config::SHIELD_MAX].min
67
+ else
68
+ audio&.stop_loop_se(:shield_regen) if @regen_active
69
+ @regen_active = false
70
+ end
71
+ @damage_flash -= 1 if @damage_flash > 0
72
+
73
+ if @shield >= Config::SHIELD_MAX && @health < Config::HEALTH_MAX
74
+ @health = [@health + Config::SHIELD_REGEN * dt, Config::HEALTH_MAX].min
75
+ end
76
+
77
+ return unless @shield == 0 && @health > 0
78
+
79
+ @beep_count = 3 if @beep_count <= 0
80
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ return unless (now - @last_beep) >= Config::BEEP_INTERVAL
82
+
83
+ if audio
84
+ audio.play_se(:shield_alarm)
85
+ else
86
+ stdout.syswrite("\a")
87
+ end
88
+ @last_beep = now
89
+ @beep_count -= 1
90
+ end
91
+
92
+ def try_pickup
93
+ nearest = nil
94
+ best_d2 = Config::PICKUP_RADIUS**2
95
+ @drops.each do |d|
96
+ d2 = (d.x - @x)**2 + (d.y - @y)**2
97
+ if d2 < best_d2
98
+ nearest = d
99
+ best_d2 = d2
100
+ end
101
+ end
102
+ return unless nearest
103
+
104
+ cur = current_weapon
105
+ if cur.type_id == nearest.type
106
+ max = cur.max_ammo
107
+ cur.ammo = [cur.ammo + nearest.ammo, max].min if max
108
+ else
109
+ @drops << DropItem::Weapon.new(x: @x, y: @y, type: cur.type_id, ammo: cur.ammo)
110
+ @weapons[@weapon_idx] = Weapon::Base.build(nearest.type, nearest.ammo)
111
+ end
112
+ @drops.delete(nearest)
113
+ end
114
+
115
+ def process_fire(enemies, map)
116
+ dx = Math.cos(@angle)
117
+ dy = Math.sin(@angle)
118
+
119
+ weapon = current_weapon
120
+
121
+ best = nil
122
+ best_d = 1e30
123
+ enemies.each do |e|
124
+ next unless e.alive
125
+
126
+ ex = e.x - @x
127
+ ey = e.y - @y
128
+ dot = ex * dx + ey * dy
129
+ next if dot < 0.1
130
+
131
+ perp = (ex * (-dy) + ey * dx).abs
132
+ next if perp > weapon.hit_width
133
+
134
+ if dot < best_d && map.line_of_sight?(@x, @y, e.x, e.y)
135
+ best = e
136
+ best_d = dot
137
+ end
138
+ end
139
+ return unless best
140
+
141
+ best.take_damage(1)
142
+ return if best.alive
143
+
144
+ @drops << DropItem::Weapon.new(x: best.x, y: best.y, type: best.drop_type, ammo: best.drop_ammo)
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class Projectile
5
+ attr_accessor :x, :y, :vx, :vy, :type
6
+
7
+ def initialize(x:, y:, vx:, vy:, type:)
8
+ @x = x
9
+ @y = y
10
+ @vx = vx
11
+ @vy = vy
12
+ @type = type
13
+ end
14
+
15
+ def update(dt)
16
+ @x += @vx * dt
17
+ @y += @vy * dt
18
+ end
19
+
20
+ def hit_wall?(map)
21
+ map.wall_at?(@x, @y)
22
+ end
23
+
24
+ def hit_player?(px, py, radius = Config::PROJ_RADIUS)
25
+ (@x - px).abs < radius && (@y - py).abs < radius
26
+ end
27
+
28
+ def self.update_all(projectiles, map, player)
29
+ projectiles.reject! do |p|
30
+ p.update(0) # dt is applied in the caller
31
+ if p.hit_wall?(map)
32
+ true
33
+ elsif p.hit_player?(player.x, player.y)
34
+ enemy_klass = Enemy::Base.registry[p.type]
35
+ dmg = enemy_klass ? enemy_klass.allocate.send(:damage) : 10
36
+ player.apply_damage(dmg)
37
+ true
38
+ else
39
+ false
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class RemoteEnemy
5
+ attr_accessor :id, :x, :y, :hp, :max_hp, :alive
6
+
7
+ def initialize(id:, x:, y:, sprite_id:, hp:, max_hp:, alive: true)
8
+ @id = id
9
+ @x = x
10
+ @y = y
11
+ @sprite_id = sprite_id
12
+ @hp = hp
13
+ @max_hp = max_hp
14
+ @alive = alive
15
+ end
16
+
17
+ def sprite_id
18
+ @sprite_id
19
+ end
20
+ end
21
+ end