termfront 0.1.5 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68852aaca6be7127d8b644aae4670a56e72d5528b249dc081d72ba0d22c5f74a
4
- data.tar.gz: 440209c869681acd0f9d77d65fb367e6d055c2ffe3a5a57821dd530912199ecd
3
+ metadata.gz: b6d91fa7b7758b2151fc31bb9f7862dbda3ad311edea638aa9b11ca50a8b9a8d
4
+ data.tar.gz: 3a8ce081de76ddd04c45949c490ba4b513f42eb94eb39fce37fef292abdbb004
5
5
  SHA512:
6
- metadata.gz: 6efb979956fa649ee558ad0190547fd70c62fda5ef8b8ca4dc06f513c15c87c02c74151756486615a0cc21301e6b45d6f62e01e58230ae28b574ceb3570053ae
7
- data.tar.gz: 12dfe9fde65aa1517facf4e7e21a37b6db9550e5cc99663801efb33db74f1a1b88af2503c6c6c54be70d60374328009f85334422413737514845602091f70893
6
+ metadata.gz: a79ab596683eea6f8914c17cf88ad000d7a1b33453820526310f25fd43dbabb1f21e6f65f16a390fa92e491d6cd2da86e981c17bc971161316a6d4f7b431c98a
7
+ data.tar.gz: 556667b34ba430c7af848fca7d31f5937e24d328480a29a3191db88ca4286d579081b0f6e18c34622d87cb9ea401c20e19da6aaf0a6fbc2b892f15fdbb9f7b76
data/CHANGELOG.md CHANGED
@@ -6,6 +6,46 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.7] - 2026-05-26
10
+
11
+ ### Changed
12
+
13
+ - Restore the in-game and title screen render rate to 60 Hz (`Config::RENDER_DT = 1.0 / 60.0`) after testers reported choppy motion at 30 Hz; the hybrid rendering scaffolding is kept in place so the rate can be tuned later without code changes
14
+ - Cap the per-frame `dt` at `Config::MAX_DT` (50 ms / 20 FPS-equivalent) in the singleplayer, Wavesfight, and PvP game loops so a frame that runs long under a constrained host no longer translates into a single oversized physics step. Movement, weapon timers, and projectile motion now stay smooth — the simulation simply slows for that frame instead of producing a teleporting opponent or instant fire-rate
15
+ - Hoist the sprite shape function out of the per-cell loop in `Renderer#overlay_enemies_3d`: look up `Sprite::REGISTRY[sprite_id]` once per enemy and call it directly from the inner cell loop instead of going through `Sprite.for` (which re-hashed `REGISTRY` and re-checked the result for `nil` on every cell). With several enemies on-screen the per-cell hash lookup overhead disappears
16
+ - Migrate the PvP/Wavesfight client render paths to 256-color SGR sequences as well: add a `Sprite.player_enemy` variant pre-tinted toward red instead of recomputing the tint per cell, replace the per-cell `\e[38;2;…m` (and `\e[48;2;…m` for damage flash) escapes with `\e[38;5;Nm` lookups, drop the `tint_player_color` helper and its per-cell `String#split`/`Array#map` work, and reuse the renderer's shared `DAMAGE_FLASH_RAMP` for the screen-edge flash
17
+ - Hand frame output off to a dedicated writer thread (`AsyncWriter`) so the game loop's `syswrite` becomes a non-blocking `Queue#push` and the actual `IO#syswrite` runs on a background thread that releases the GVL during the PTY write. The main loop no longer pays the wall-clock cost of waiting for the terminal to drain its buffer; when a write is still in progress, the latest frame replaces the queued one so the terminal always sees the freshest frame instead of a backlog
18
+ - Drop truecolor SGR sequences from the in-game and title 3D views; convert every sprite, fallback, bar, projectile, drop, and damage-flash color to an xterm 256-color index once with `Color.rgb_to_256`, simplify `Renderer#render_view` to a pure 256-color path that drops the per-cell `is_a?(Integer)` test and the truecolor SGR caches, and switch the title screen's half-block compositor to look up its escapes in the shared `FG_256` / `BG_256` tables. The 13-byte truecolor SGR shrinks to a 9-byte 256-color SGR (or shorter on cell boundaries via `\e[K`), the renderer's hottest cell loop loses its type dispatch, and the dead `bg_only?` / `ansi_fg` / `ansi_bg` helpers go away
19
+ - Build the half-block rows in `TitleScreen#render` directly into the final per-row string with the column count already capped, skipping the per-row `TerminalOutput.fit_ansi` walk entirely; rows that come out uniformly the same color collapse to `\e[bg]\e[K\e[0m`, fully-blank rows ship as a single space run, and the byte stream the terminal sees for each title frame shrinks substantially
20
+ - Coalesce same-color cell runs in `TitleScreen#render`'s half-block compositor and only emit SGR escapes on transitions instead of wrapping every cell in `\e[…m…\e[0m`; the per-row string handed to `fit_ansi` shrinks from one bracketed SGR per cell to a handful of color changes, cutting both the title frame's byte volume and `fit_ansi`'s per-row scan cost
21
+ - Emit `\e[K` (Erase to End of Line) for rows in `Renderer#render_view` whose top/bottom cells are all the same integer background color; the ceiling-only and floor-only bands now ship as `\e[bg]\e[K\r\n` per row instead of the full per-cell glyph sequence, slashing the ANSI byte volume of those rows from `view_w` bytes plus per-cell SGR transitions down to about a dozen bytes total
22
+ - Memoize `AudioManager#which` results in a class-level cache so the per-command `PATH` walk (and the per-directory `File.executable?` stat) only runs the first time a binary is looked up; subsequent `AudioManager.new` calls during mode transitions hit the cache instead of re-scanning `PATH`, which removes a noticeable stutter on WSL hosts where Windows-mounted `PATH` entries are slow to stat
23
+ - Coalesce same-color runs of cells in `Renderer#render_view` with a single `String#*` instead of one `buf << " "` (or `buf << "█"`) per cell, and rewrite the row/column loops as `while` so the ceiling/floor bands cost one short string copy per row instead of dozens of one-byte appends
24
+ - Skip the per-column DDA in `Renderer#render` when the player position, facing, map, and viewport are unchanged from the previous frame; the cached `@dists`, `@sides`, `@wtop`, `@wbot`, and `@wcol` arrays are reused so `cast_ray` and the wall-column projection only run when something actually moved
25
+ - Bulk-fill the ceiling-only and floor-only rows of `Renderer#build_view_pixels` with `Array#fill` so each fully ceiling or fully floor row is written as a single C-level call, with the column-major wall loop kept for the mixed band in the middle
26
+
27
+ ### Fixed
28
+
29
+ - Treat `Errno::EAGAIN` from `syswrite` the same as `IO::WaitWritable` in `TerminalOutput.write_all` so the renderer keeps draining its frame buffer instead of crashing when the terminal's PTY temporarily refuses additional bytes
30
+
31
+ ## [0.1.6] - 2026-05-26
32
+
33
+ ### Changed
34
+
35
+ - Memoize the HUD shield and ammo lines so `fit_ansi` only re-runs when the shown shield value, weapon, ammo, pickup hint, or terminal width actually change
36
+ - Inline the color-mode branching and SGR lookups into `Renderer#render_view` to remove per-cell `bg_only?`, `ansi_fg`, and `ansi_bg` method calls (about 72,000 calls per second at 30 Hz on an 80-wide terminal)
37
+ - Enable YJIT on startup so the hot Ruby methods in the renderer (`build_view_pixels`, `render_view`, `cast_ray`, etc.) get JIT-compiled instead of interpreted
38
+ - Rewrite `TerminalOutput.fit_ansi` to walk the input by byte index with `getbyte` and `byteslice` instead of scanning with a regular expression, removing the per-line `Regexp#match` / `MatchData` allocations that dominated the renderer's CPU profile under YJIT
39
+ - Render the title screen at 30 Hz while still advancing its spin animation and polling input at 60 Hz, so the per-frame renderer cost halves without making the demo motion feel choppy
40
+ - Cache the demo mission instance, its tile-map, and its enemy definitions inside `TitleScreen#initialize` instead of rebuilding them every frame
41
+ - Render singleplayer and Wavesfight at 30 Hz while still running the update step, physics, and input polling at 60 Hz, halving the in-game renderer cost without slowing the simulation or input response
42
+ - Rewrite `Renderer#build_view_pixels` with column-major `while` loops that read `wtop`/`wbot`/`wcol` once per column and fill the ceiling / wall / floor segments without per-cell branching, hoisting `Config::CEIL_C` / `Config::FLOOR_C` and `@pixels` as locals so YJIT keeps the hot loop tight
43
+ - Memoize the title screen's static lines (logo, subtitle, and the five menu entries) per terminal width so `fit_ansi` runs once instead of seven times per title-screen frame
44
+ - Memoize every line of `Game#render_mission_select` keyed by terminal width, current selection, difficulty, and mission class, so the nine `fit_ansi` calls per frame collapse to cache lookups while the cursor is idle
45
+ - Reuse the radar's enemy / drop / terminal / ally cell hashes between frames and key them by integer (`sy * diam + sx`) to drop per-cell `Array` allocations, hoisting `Config::RADAR_RANGE`, `RADAR_RANGE_SQ`, `r*r`, and the player position as locals so YJIT keeps the radar projection loops tight
46
+ - Tighten the enemy half of `Renderer#overlay_enemies_3d`: hoist the FOV tangent, player position, and half-frame constants as locals, replace the `upto` loops with `while`, use integer shift instead of `to_f` / `.ceil` / `.floor` for half-block row indexing, and cache the float versions of `actual_h` / `actual_w` so the per-cell sprite division loop stops allocating new floats every iteration
47
+ - Apply the same `while`-loop and integer-math pattern to the drop and projectile branches of `Renderer#overlay_enemies_3d`, reusing the hoisted player position and half-frame constants and computing the projectile color directly instead of through an intermediate ANSI code string
48
+
9
49
  ## [0.1.5] - 2026-05-25
10
50
 
11
51
  ### Changed
data/exe/termfront CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ RubyVM::YJIT.enable if defined?(RubyVM::YJIT)
5
+
4
6
  require "termfront"
5
7
 
6
8
  Termfront.start
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ class AsyncWriter
5
+ def initialize(io)
6
+ @io = io
7
+ @queue = Queue.new
8
+ @closed = false
9
+ @thread = Thread.new do
10
+ Thread.current.report_on_exception = false
11
+ while (data = @queue.pop)
12
+ begin
13
+ TerminalOutput.write_all(@io, data)
14
+ rescue IOError, Errno::EBADF, Errno::EPIPE
15
+ break
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ def syswrite(data)
22
+ raise IOError, "writer closed" if @closed
23
+
24
+ @queue.clear if @queue.size >= 1
25
+ @queue.push(data)
26
+ data.bytesize
27
+ end
28
+
29
+ def winsize
30
+ @io.winsize
31
+ end
32
+
33
+ def raw(&block)
34
+ @io.raw(&block)
35
+ end
36
+
37
+ def close
38
+ return if @closed
39
+
40
+ @closed = true
41
+ @queue.push(nil)
42
+ @thread.join
43
+ end
44
+ end
45
+ end
@@ -8,6 +8,8 @@ module Termfront
8
8
  class AudioManager
9
9
  Player = Struct.new(:command, :supports_loop, keyword_init: true)
10
10
 
11
+ WHICH_CACHE = {}
12
+
11
13
  def initialize
12
14
  @manifest = load_manifest
13
15
  @bgm_player = detect_player(%w[ffplay afplay paplay aplay], prefer_loop: true)
@@ -160,12 +162,18 @@ module Termfront
160
162
  end
161
163
 
162
164
  def which(command)
165
+ return WHICH_CACHE[command] if WHICH_CACHE.key?(command)
166
+
167
+ resolved = nil
163
168
  ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
164
169
  candidate = File.join(dir, command)
165
- return candidate if File.executable?(candidate) && !File.directory?(candidate)
170
+ if File.executable?(candidate) && !File.directory?(candidate)
171
+ resolved = candidate
172
+ break
173
+ end
166
174
  end
167
175
 
168
- nil
176
+ WHICH_CACHE[command] = resolved
169
177
  end
170
178
 
171
179
  def asset_path(kind, name)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Termfront
4
+ module Color
5
+ module_function
6
+
7
+ def rgb_to_256(r, g, b)
8
+ if (r - g).abs < 8 && (g - b).abs < 8 && (r - b).abs < 8
9
+ avg = (r + g + b) / 3
10
+ return 16 if avg < 8
11
+ return 231 if avg > 247
12
+
13
+ 232 + (avg - 8) * 23 / 240
14
+ else
15
+ 16 + (r * 5 / 255) * 36 + (g * 5 / 255) * 6 + (b * 5 / 255)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -3,6 +3,8 @@
3
3
  module Termfront
4
4
  module Config
5
5
  FRAME_DT = 1.0 / 60.0
6
+ RENDER_DT = 1.0 / 60.0
7
+ MAX_DT = 1.0 / 20.0
6
8
  FOV = 66.0 * Math::PI / 180.0
7
9
  PLAYER_RADIUS = 0.2
8
10
  KEY_TIMEOUT = 5
@@ -22,8 +22,11 @@ module Termfront
22
22
  end
23
23
  end
24
24
 
25
+ SHOCK_COLOR = Color.rgb_to_256(60, 200, 220)
26
+ NORMAL_COLOR = Color.rgb_to_256(220, 200, 60)
27
+
25
28
  def sprite_color
26
- @type.to_s.start_with?("shock") ? "60;200;220" : "220;200;60"
29
+ @type.to_s.start_with?("shock") ? SHOCK_COLOR : NORMAL_COLOR
27
30
  end
28
31
 
29
32
  def radar_color
@@ -3,7 +3,7 @@
3
3
  module Termfront
4
4
  class Game
5
5
  def initialize
6
- @stdout = STDOUT
6
+ @stdout = AsyncWriter.new(STDOUT)
7
7
  @audio = AudioManager.new
8
8
  @renderer = Renderer.new(@stdout)
9
9
  @input = Input.new
@@ -36,6 +36,7 @@ module Termfront
36
36
  @crash = e
37
37
  ensure
38
38
  @audio.close
39
+ @stdout.close if @stdout.respond_to?(:close) && !@stdout.equal?(STDOUT)
39
40
  leave_alt_screen
40
41
  if @crash
41
42
  warn "#{@crash.class}: #{@crash.message}"
@@ -131,10 +132,12 @@ module Termfront
131
132
  def run_game_loop(show_complete_banner: true)
132
133
  STDIN.raw do |stdin|
133
134
  last_time = clock
135
+ last_render = last_time - Config::RENDER_DT
134
136
 
135
137
  loop do
136
138
  now = clock
137
139
  dt = now - last_time
140
+ dt = Config::MAX_DT if dt > Config::MAX_DT
138
141
  last_time = now
139
142
 
140
143
  keys = @input.process(stdin, player: @player)
@@ -146,11 +149,14 @@ module Termfront
146
149
  end
147
150
 
148
151
  update(dt)
149
- @renderer.render(
150
- player: @player, map: @map,
151
- enemies: @enemies, projectiles: @projectiles,
152
- drops: @player.drops, terminals: @terminals
153
- )
152
+ if now - last_render >= Config::RENDER_DT
153
+ @renderer.render(
154
+ player: @player, map: @map,
155
+ enemies: @enemies, projectiles: @projectiles,
156
+ drops: @player.drops, terminals: @terminals
157
+ )
158
+ last_render = now
159
+ end
154
160
 
155
161
  if @player.dead
156
162
  rows, cols = @stdout.winsize
@@ -176,10 +182,12 @@ module Termfront
176
182
  def run_wavesfight_loop
177
183
  STDIN.raw do |stdin|
178
184
  last_time = clock
185
+ last_render = last_time - Config::RENDER_DT
179
186
 
180
187
  loop do
181
188
  now = clock
182
189
  dt = now - last_time
190
+ dt = Config::MAX_DT if dt > Config::MAX_DT
183
191
  last_time = now
184
192
 
185
193
  keys = @input.process(stdin, player: @player)
@@ -191,12 +199,15 @@ module Termfront
191
199
  end
192
200
 
193
201
  update(dt)
194
- @renderer.render(
195
- player: @player, map: @map,
196
- enemies: @enemies, projectiles: @projectiles,
197
- drops: @player.drops, terminals: @terminals,
198
- status_line: " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]}"
199
- )
202
+ if now - last_render >= Config::RENDER_DT
203
+ @renderer.render(
204
+ player: @player, map: @map,
205
+ enemies: @enemies, projectiles: @projectiles,
206
+ drops: @player.drops, terminals: @terminals,
207
+ status_line: " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]}"
208
+ )
209
+ last_render = now
210
+ end
200
211
 
201
212
  if @player.dead
202
213
  rows, cols = @stdout.winsize
@@ -209,6 +220,7 @@ module Termfront
209
220
  show_wave_clear
210
221
  start_wavesfight_wave
211
222
  last_time = clock
223
+ last_render = clock - Config::RENDER_DT
212
224
  end
213
225
 
214
226
  cap_frame(now)
@@ -543,62 +555,78 @@ module Termfront
543
555
  end
544
556
  end
545
557
 
558
+ MISSION_SELECT_DIFF_COLORS = ["\e[92m", "\e[93m", "\e[38;2;255;165;0m", "\e[91m"].freeze
559
+ MISSION_SELECT_CTRL = "Up/Down: Select Left/Right: Difficulty Enter/1-5: Start Q: Back"
560
+
546
561
  def render_mission_select(selected, missions, title)
547
562
  rows, cols = @stdout.winsize
548
563
  buf = TerminalOutput.begin_frame(home: true)
549
564
  lines = Array.new(rows) { " " * cols }
565
+ cache = (@mission_select_cache ||= {})
550
566
 
551
- tc = [(cols - title.size) / 2 + 1, 1].max
552
- lines[1] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
567
+ lines[1] = cache[[:title, cols, title]] ||= begin
568
+ tc = [(cols - title.size) / 2 + 1, 1].max
569
+ TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
570
+ end
553
571
 
554
572
  diff = Enemy::Base::DIFFICULTIES[@difficulty]
555
- diff_colors = ["\e[92m", "\e[93m", "\e[38;2;255;165;0m", "\e[91m"]
556
- diff_label = "< #{diff[:name]} >"
557
- dc = [(cols - diff_label.size) / 2 + 1, 1].max
558
- lines[2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}#{diff_colors[@difficulty]}#{diff_label}\e[0m", cols)
573
+ lines[2] = cache[[:diff, cols, @difficulty]] ||= begin
574
+ diff_label = "< #{diff[:name]} >"
575
+ dc = [(cols - diff_label.size) / 2 + 1, 1].max
576
+ TerminalOutput.fit_ansi("#{" " * (dc - 1)}#{MISSION_SELECT_DIFF_COLORS[@difficulty]}#{diff_label}\e[0m", cols)
577
+ end
559
578
 
560
579
  missions.each_with_index do |klass, i|
561
- m = klass.new
562
580
  row = 5 + i * 2
563
- label = " #{i + 1}. #{m.name}"
564
- lc = [(cols - 40) / 2 + 1, 1].max
565
- text = if i == selected
566
- "\e[1;97;44m> #{label.strip.ljust(38)}\e[0m"
567
- else
568
- "\e[97m #{label.strip.ljust(38)}\e[0m"
569
- end
570
- lines[row - 1] = TerminalOutput.fit_ansi("#{" " * (lc - 1)}#{text}", cols)
581
+ is_selected = i == selected
582
+ lines[row - 1] = cache[[:mission, cols, i, is_selected, klass]] ||= begin
583
+ m = klass.new
584
+ label = " #{i + 1}. #{m.name}"
585
+ lc = [(cols - 40) / 2 + 1, 1].max
586
+ text = if is_selected
587
+ "\e[1;97;44m> #{label.strip.ljust(38)}\e[0m"
588
+ else
589
+ "\e[97m #{label.strip.ljust(38)}\e[0m"
590
+ end
591
+ TerminalOutput.fit_ansi("#{" " * (lc - 1)}#{text}", cols)
592
+ end
571
593
  end
572
594
 
573
595
  brief_row = 5 + missions.size * 2 + 1
574
- m = missions[selected].new
575
- briefing = m.briefing
576
- bc = [(cols - briefing.size) / 2 + 1, 1].max
577
- lines[brief_row - 1] = TerminalOutput.fit_ansi("#{" " * (bc - 1)}\e[38;2;180;180;200m#{briefing}\e[0m", cols)
596
+ lines[brief_row - 1] = cache[[:brief, cols, missions[selected]]] ||= begin
597
+ m = missions[selected].new
598
+ briefing = m.briefing
599
+ bc = [(cols - briefing.size) / 2 + 1, 1].max
600
+ TerminalOutput.fit_ansi("#{" " * (bc - 1)}\e[38;2;180;180;200m#{briefing}\e[0m", cols)
601
+ end
578
602
 
579
603
  info_row = brief_row + 2
580
- edefs = m.enemy_defs
581
- base_crawler = edefs.count { |e| e[4] == :crawler }
582
- base_executor = edefs.count { |e| e[4] == :executor }
583
- extra = diff[:extra_enemies]
584
- extra_crawler = 0
585
- extra_executor = 0
586
- extra.times do |i|
587
- src_type = edefs[i % edefs.size][4]
588
- src_type == :crawler ? (extra_crawler += 1) : (extra_executor += 1)
604
+ lines[info_row - 1] = cache[[:info, cols, @difficulty, missions[selected]]] ||= begin
605
+ m = missions[selected].new
606
+ edefs = m.enemy_defs
607
+ base_crawler = edefs.count { |e| e[4] == :crawler }
608
+ base_executor = edefs.count { |e| e[4] == :executor }
609
+ extra = diff[:extra_enemies]
610
+ extra_crawler = 0
611
+ extra_executor = 0
612
+ extra.times do |i|
613
+ src_type = edefs[i % edefs.size][4]
614
+ src_type == :crawler ? (extra_crawler += 1) : (extra_executor += 1)
615
+ end
616
+ crawler_c = base_crawler + extra_crawler
617
+ executor_c = base_executor + extra_executor
618
+ info = "Enemies: #{crawler_c} Crawler#{crawler_c != 1 ? "s" : ""}"
619
+ info += ", #{executor_c} Executor#{executor_c != 1 ? "s" : ""}" if executor_c > 0
620
+ info += " | HP x#{diff[:hp_mult]}"
621
+ ic = [(cols - info.size) / 2 + 1, 1].max
622
+ TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[38;2;140;140;160m#{info}\e[0m", cols)
589
623
  end
590
- crawler_c = base_crawler + extra_crawler
591
- executor_c = base_executor + extra_executor
592
- info = "Enemies: #{crawler_c} Crawler#{crawler_c != 1 ? "s" : ""}"
593
- info += ", #{executor_c} Executor#{executor_c != 1 ? "s" : ""}" if executor_c > 0
594
- info += " | HP x#{diff[:hp_mult]}"
595
- ic = [(cols - info.size) / 2 + 1, 1].max
596
- lines[info_row - 1] = TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[38;2;140;140;160m#{info}\e[0m", cols)
597
624
 
598
625
  ctrl_row = info_row + 2
599
- ctrl = "Up/Down: Select Left/Right: Difficulty Enter/1-5: Start Q: Back"
600
- cc = [(cols - ctrl.size) / 2 + 1, 1].max
601
- lines[ctrl_row - 1] = TerminalOutput.fit_ansi("#{" " * (cc - 1)}\e[38;2;100;100;120m#{ctrl}\e[0m", cols)
626
+ lines[ctrl_row - 1] = cache[[:ctrl, cols]] ||= begin
627
+ cc = [(cols - MISSION_SELECT_CTRL.size) / 2 + 1, 1].max
628
+ TerminalOutput.fit_ansi("#{" " * (cc - 1)}\e[38;2;100;100;120m#{MISSION_SELECT_CTRL}\e[0m", cols)
629
+ end
602
630
 
603
631
  lines.each_with_index do |line, index|
604
632
  buf << line
@@ -6,6 +6,14 @@ module Termfront
6
6
  TEAM_SIZES = [1, 2, 4].freeze
7
7
  ALLOWED_WEAPONS = %w[pistol ar].freeze
8
8
 
9
+ ALLY_PLAYER_FALLBACK = Color.rgb_to_256(70, 210, 255)
10
+ ENEMY_PLAYER_FALLBACK = Color.rgb_to_256(255, 110, 80)
11
+ ALLY_BAR_FILL = Color.rgb_to_256(0, 180, 255)
12
+ ENEMY_BAR_FILL = Color.rgb_to_256(255, 80, 80)
13
+ BAR_EMPTY = Color.rgb_to_256(80, 20, 20)
14
+ PROJ_SHOCK = Color.rgb_to_256(80, 220, 255)
15
+ PROJ_NORMAL = Color.rgb_to_256(255, 210, 80)
16
+
9
17
  def initialize(stdout)
10
18
  @stdout = stdout
11
19
  @conn = Connection.new
@@ -199,6 +207,7 @@ module Termfront
199
207
  loop do
200
208
  now = clock
201
209
  dt = now - last_time
210
+ dt = Config::MAX_DT if dt > Config::MAX_DT
202
211
  last_time = now
203
212
 
204
213
  keys = @input.process(stdin, player: @player)
@@ -548,33 +557,34 @@ module Termfront
548
557
  next unless top_in || bot_in
549
558
 
550
559
  if use_shape
560
+ sprite_fn = color_mode == :ally ? Sprite.method(:player) : Sprite.method(:player_enemy)
551
561
  ny0 = top_in ? (vp0 - draw_top).to_f / actual_h : nil
552
562
  ny1 = bot_in ? (vp1 - draw_top).to_f / actual_h : nil
553
- top_color = ny0 ? tint_player_color(Sprite.player(nx, ny0), color_mode) : nil
554
- bot_color = ny1 ? tint_player_color(Sprite.player(nx, ny1), color_mode) : nil
563
+ top_color = ny0 ? sprite_fn.call(nx, ny0) : nil
564
+ bot_color = ny1 ? sprite_fn.call(nx, ny1) : nil
555
565
  next unless top_color || bot_color
556
566
 
557
567
  buf << "\e[#{3 + r};#{c + 1}H"
558
568
  buf << if top_color && bot_color
559
569
  if top_color == bot_color
560
- "\e[38;2;#{top_color}m\xE2\x96\x88\e[0m"
570
+ "\e[38;5;#{top_color}m\xE2\x96\x88\e[0m"
561
571
  else
562
- "\e[38;2;#{top_color};48;2;#{bot_color}m\xE2\x96\x80\e[0m"
572
+ "\e[38;5;#{top_color};48;5;#{bot_color}m\xE2\x96\x80\e[0m"
563
573
  end
564
574
  elsif top_color
565
- "\e[38;2;#{top_color}m\xE2\x96\x80\e[0m"
575
+ "\e[38;5;#{top_color}m\xE2\x96\x80\e[0m"
566
576
  else
567
- "\e[38;2;#{bot_color}m\xE2\x96\x84\e[0m"
577
+ "\e[38;5;#{bot_color}m\xE2\x96\x84\e[0m"
568
578
  end
569
579
  else
570
- fc = color_mode == :ally ? "70;210;255" : "255;110;80"
580
+ fc = color_mode == :ally ? ALLY_PLAYER_FALLBACK : ENEMY_PLAYER_FALLBACK
571
581
  buf << "\e[#{3 + r};#{c + 1}H"
572
582
  buf << if top_in && bot_in
573
- "\e[38;2;#{fc}m\xE2\x96\x88\e[0m"
583
+ "\e[38;5;#{fc}m\xE2\x96\x88\e[0m"
574
584
  elsif top_in
575
- "\e[38;2;#{fc}m\xE2\x96\x80\e[0m"
585
+ "\e[38;5;#{fc}m\xE2\x96\x80\e[0m"
576
586
  else
577
- "\e[38;2;#{fc}m\xE2\x96\x84\e[0m"
587
+ "\e[38;5;#{fc}m\xE2\x96\x84\e[0m"
578
588
  end
579
589
  end
580
590
  end
@@ -590,15 +600,15 @@ module Termfront
590
600
  max_total = Config::SHIELD_MAX + Config::HEALTH_MAX
591
601
  hp_pct = total.to_f / max_total
592
602
  filled = (hp_pct * (bar_ex - bar_sx + 1)).ceil
593
- fill_color = color_mode == :ally ? "0;180;255" : "255;80;80"
603
+ fill_color = color_mode == :ally ? ALLY_BAR_FILL : ENEMY_BAR_FILL
594
604
 
595
605
  bar_sx.upto(bar_ex) do |c|
596
606
  next if c < 0 || c >= view_w
597
607
  next if dists[c] < tz
598
608
 
599
609
  ci = c - bar_sx
600
- color = ci < filled ? fill_color : "80;20;20"
601
- buf << "\e[#{3 + bar_row};#{c + 1}H\e[38;2;#{color}m\xE2\x96\x88\e[0m"
610
+ color = ci < filled ? fill_color : BAR_EMPTY
611
+ buf << "\e[#{3 + bar_row};#{c + 1}H\e[38;5;#{color}m\xE2\x96\x88\e[0m"
602
612
  end
603
613
  end
604
614
 
@@ -649,28 +659,17 @@ module Termfront
649
659
  proj_color = projectile[:color]
650
660
  buf << "\e[#{3 + r};#{c + 1}H"
651
661
  buf << if top_in && bot_in
652
- "\e[38;2;#{proj_color}m\xE2\x96\x88\e[0m"
662
+ "\e[38;5;#{proj_color}m\xE2\x96\x88\e[0m"
653
663
  elsif top_in
654
- "\e[38;2;#{proj_color}m\xE2\x96\x80\e[0m"
664
+ "\e[38;5;#{proj_color}m\xE2\x96\x80\e[0m"
655
665
  else
656
- "\e[38;2;#{proj_color}m\xE2\x96\x84\e[0m"
666
+ "\e[38;5;#{proj_color}m\xE2\x96\x84\e[0m"
657
667
  end
658
668
  end
659
669
  end
660
670
  end
661
671
  end
662
672
 
663
- def tint_player_color(color, mode)
664
- return nil unless color
665
- return color if mode == :ally
666
-
667
- r, g, b = color.split(";").map(&:to_i)
668
- nr = [[r + 70, 255].min, 0].max
669
- ng = [[g - 50, 0].max, 0].max
670
- nb = [[b - 90, 0].max, 0].max
671
- "#{nr};#{ng};#{nb}"
672
- end
673
-
674
673
  def render_pvp_hud(buf, cols)
675
674
  bar_w = [cols - 30, 10].max
676
675
  pct = @player.shield / Config::SHIELD_MAX.to_f
@@ -806,13 +805,15 @@ module Termfront
806
805
  def render_damage_flash(buf, view_h, view_w)
807
806
  return unless @player.damage_flash > 0
808
807
 
809
- intensity = @player.damage_flash * 60
808
+ intensity = (@player.damage_flash * 60).to_i
809
+ intensity = 255 if intensity > 255
810
+ color = Renderer::DAMAGE_FLASH_RAMP[intensity]
810
811
  flash_w = 2
811
812
 
812
813
  view_h.times do |r|
813
- buf << "\e[#{3 + r};1H\e[48;2;#{intensity};0;0m#{" " * flash_w}\e[0m"
814
+ buf << "\e[#{3 + r};1H\e[48;5;#{color}m#{" " * flash_w}\e[0m"
814
815
  rc = [view_w - flash_w + 1, 1].max
815
- buf << "\e[#{3 + r};#{rc}H\e[48;2;#{intensity};0;0m#{" " * flash_w}\e[0m"
816
+ buf << "\e[#{3 + r};#{rc}H\e[48;5;#{color}m#{" " * flash_w}\e[0m"
816
817
  end
817
818
  end
818
819
 
@@ -849,7 +850,7 @@ module Termfront
849
850
 
850
851
  def spawn_remote_projectile_effect(msg)
851
852
  shock = msg[:w].to_s.start_with?("shock")
852
- color = shock ? "80;220;255" : "255;210;80"
853
+ color = shock ? PROJ_SHOCK : PROJ_NORMAL
853
854
  speed = shock ? 18.0 : 14.0
854
855
  angle = msg[:a]
855
856
  @projectiles << {
@@ -57,7 +57,7 @@ module Termfront
57
57
  lines[rows / 2 - 2] = TerminalOutput.fit_ansi("#{" " * (mc - 1)}\e[1;93m#{msg}\e[0m", cols)
58
58
  detail = "#{queued_mission_name} | #{queued_difficulty_name}"
59
59
  dc = [(cols - detail.size) / 2 + 1, 1].max
60
- lines[rows / 2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}\e[38;2;170;170;190m#{detail}\e[0m", cols)
60
+ lines[rows / 2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}\e[38;5;#{Color.rgb_to_256(170, 170, 190)}m#{detail}\e[0m", cols)
61
61
  hint = "(ESC to cancel)"
62
62
  hc = [(cols - hint.size) / 2 + 1, 1].max
63
63
  lines[rows / 2 + 2] = TerminalOutput.fit_ansi("#{" " * (hc - 1)}\e[90m#{hint}\e[0m", cols)
@@ -121,6 +121,7 @@ module Termfront
121
121
  loop do
122
122
  now = clock
123
123
  dt = now - last_time
124
+ dt = Config::MAX_DT if dt > Config::MAX_DT
124
125
  last_time = now
125
126
 
126
127
  keys = @input.process(stdin, player: @player)