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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +160 -0
- data/Rakefile +12 -0
- data/data/audio/THIRD_PARTY_NOTICES.md +45 -0
- data/data/audio/beep_02.ogg +0 -0
- data/data/audio/button1.ogg +0 -0
- data/data/audio/complete.ogg +0 -0
- data/data/audio/manifest.json +17 -0
- data/data/audio/mission_bgm.wav +0 -0
- data/data/audio/mission_clear_se.wav +0 -0
- data/data/audio/on.ogg +0 -0
- data/data/audio/page_se.wav +0 -0
- data/data/audio/sector.mp3 +0 -0
- data/data/audio/sfx_22b.ogg +0 -0
- data/data/audio/shield_alarm_se.wav +0 -0
- data/data/audio/shield_regen_se.wav +0 -0
- data/data/audio/shoot_01.ogg +0 -0
- data/data/audio/terminal_se.wav +0 -0
- data/data/audio/title.mp3 +0 -0
- data/data/audio/title_bgm.wav +0 -0
- data/data/audio/victory.mp3 +0 -0
- data/data/events/corridor_sweep.json +27 -0
- data/data/events/final_push.json +40 -0
- data/data/events/stronghold.json +27 -0
- data/data/events/the_gauntlet.json +27 -0
- data/data/events/training_grounds.json +31 -0
- data/exe/termfront +6 -0
- data/exe/termfront-server +7 -0
- data/lib/termfront/audio_manager.rb +225 -0
- data/lib/termfront/config.rb +38 -0
- data/lib/termfront/demo_player.rb +181 -0
- data/lib/termfront/drop_item/base.rb +26 -0
- data/lib/termfront/drop_item/weapon.rb +38 -0
- data/lib/termfront/enemy/base.rb +133 -0
- data/lib/termfront/enemy/crawler.rb +18 -0
- data/lib/termfront/enemy/executor.rb +18 -0
- data/lib/termfront/game.rb +637 -0
- data/lib/termfront/input.rb +75 -0
- data/lib/termfront/map.rb +72 -0
- data/lib/termfront/mission/base.rb +81 -0
- data/lib/termfront/mission/corridor_sweep.rb +41 -0
- data/lib/termfront/mission/event_loader.rb +87 -0
- data/lib/termfront/mission/event_runtime.rb +37 -0
- data/lib/termfront/mission/final_push.rb +44 -0
- data/lib/termfront/mission/stronghold.rb +45 -0
- data/lib/termfront/mission/the_gauntlet.rb +38 -0
- data/lib/termfront/mission/training.rb +38 -0
- data/lib/termfront/mission/training_grounds.rb +37 -0
- data/lib/termfront/network/client.rb +865 -0
- data/lib/termfront/network/connection.rb +101 -0
- data/lib/termfront/network/server.rb +620 -0
- data/lib/termfront/network/wavesfight_client.rb +364 -0
- data/lib/termfront/opponent.rb +24 -0
- data/lib/termfront/player.rb +147 -0
- data/lib/termfront/projectile.rb +44 -0
- data/lib/termfront/remote_enemy.rb +21 -0
- data/lib/termfront/renderer.rb +707 -0
- data/lib/termfront/scene_player.rb +164 -0
- data/lib/termfront/sprite.rb +73 -0
- data/lib/termfront/terminal_output.rb +63 -0
- data/lib/termfront/title_screen.rb +299 -0
- data/lib/termfront/version.rb +5 -0
- data/lib/termfront/weapon/assault_rifle.rb +15 -0
- data/lib/termfront/weapon/base.rb +44 -0
- data/lib/termfront/weapon/pistol.rb +15 -0
- data/lib/termfront/weapon/shock_pistol.rb +15 -0
- data/lib/termfront/weapon/shock_rifle.rb +15 -0
- data/lib/termfront.rb +51 -0
- data/sig/termfront.rbs +4 -0
- 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
|