termfront 0.1.4 → 0.1.6

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: 2169347d66dd50b7734b9de8afc2398a0b979649cd98cb36aa6cc6dcdc9373f5
4
- data.tar.gz: afd578bf2aabf93476c81d96fdcceeb5d55289d6137dade78429b75bdd84753a
3
+ metadata.gz: d84ea40d760b9d62c0037b83a0b83576725a12a16c3aa2937e16025e6a798015
4
+ data.tar.gz: e33b7fc4bde17b2924ea6a1f30bb11f6f8d30fbba75bd9f9130c773b1b1cf33a
5
5
  SHA512:
6
- metadata.gz: 4bf5e4ee9f0c736626f7c73f05ce04a5a2c128f1c94193d9f74ae6c7788297561f24244d7ae65d03f5b6955ba10d772bfe9ba3a017de4d2bac18b8a527020a8b
7
- data.tar.gz: d317a9c0ac3ba86c633c3fb1f8b5af951bda951bcc2418213457535ec32d3e0d2471fca9c3e34588f5f134800c46a495a68cbfa58f86f9349739e1dc206ffe7d
6
+ metadata.gz: fc5856f2bb1eea72307f2ba41af96e5b3412e7ef893fe8a3609f0841ab88d693653ed797c072eff8a4092d01511c810edd2fc848c12dcd17a07b8803181cc551
7
+ data.tar.gz: 461847cef45b100959a044dcb22cca9c537a806331168834d9f936fd6055c4c668581f376058003ad2eb28abdd805cc3f2d82d6f52a36644b7f7d53a13635f9f
data/CHANGELOG.md CHANGED
@@ -6,6 +6,49 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.6] - 2026-05-26
10
+
11
+ ### Changed
12
+
13
+ - 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
14
+ - 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)
15
+ - 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
16
+ - 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
17
+ - 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
18
+ - Cache the demo mission instance, its tile-map, and its enemy definitions inside `TitleScreen#initialize` instead of rebuilding them every frame
19
+ - 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
20
+ - 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
21
+ - 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
22
+ - 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
23
+ - 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
24
+ - 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
25
+ - 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
26
+
27
+ ## [0.1.5] - 2026-05-25
28
+
29
+ ### Changed
30
+
31
+ - Radar distance culling now compares squared distances against `Config::RADAR_RANGE_SQ` instead of taking a square root per entity per frame
32
+ - Renderer reuses the per-column raycast buffers and the virtual pixel grid across frames instead of allocating fresh arrays each tick, only re-allocating when the terminal is resized
33
+ - Build the radar background grid, horizontal rule, and ANSI-styled radar glyphs once and reuse them across frames
34
+ - Look up 256-color SGR escape sequences from a precomputed table and memoize truecolor SGR sequences to avoid rebuilding the same ANSI strings every cell
35
+ - Reuse renderer sprite collection arrays and the radar line buffer across frames, and sort sprites with a comparator block instead of `sort_by`
36
+ - Cache the terminal `winsize` inside the renderer and invalidate the cache from a `SIGWINCH` handler instead of issuing an ioctl every frame
37
+ - PvP hit raycast now culls candidates beyond `MAX_PVP_RANGE` and skips the line-of-sight check when the candidate is already farther than the current best
38
+ - Multiplayer server `broadcast` now serializes each outgoing message once and writes the same JSON line to every recipient
39
+ - PvP server now aggregates outgoing player state on a 30 Hz server tick instead of relaying each incoming state message immediately, reducing TLS write bursts on small VMs
40
+ - PvP match loop `IO.select` timeout shortened from 500 ms to ~16 ms so the new 30 Hz broadcast tick is not delayed by idle reads
41
+ - PvP client opponent interpolation now converges within ~40 ms (lerp factor raised from 15 to 25) so remote players track the new 30 Hz broadcast tick within a single frame
42
+ - Run the title demo, campaign missions, Wavesfight, and PvP at 60 FPS
43
+
44
+ ### Removed
45
+
46
+ - Stopped emitting DEC 2026 synchronized update escape sequences around each frame and removed the `TERMFRONT_SYNC_UPDATES` environment switch; non-supporting terminals (some SSH paths, older xterm) were paying parsing cost for shapes they ignore, while supporting terminals show no visible regression
47
+
48
+ ### Fixed
49
+
50
+ - Wavesfight wave advance now fully restores each surviving and revived player's shield to `Config::SHIELD_MAX`; previously only a +35 partial recovery was applied, which left revived players at 35%
51
+
9
52
  ## [0.1.34] - 2026-05-25
10
53
 
11
54
  ### Fixed
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
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Termfront
4
4
  module Config
5
- FRAME_DT = 1.0 / 30.0
5
+ FRAME_DT = 1.0 / 60.0
6
+ RENDER_DT = 1.0 / 30.0
6
7
  FOV = 66.0 * Math::PI / 180.0
7
8
  PLAYER_RADIUS = 0.2
8
9
  KEY_TIMEOUT = 5
@@ -29,6 +30,7 @@ module Termfront
29
30
 
30
31
  RADAR_RADIUS = 3
31
32
  RADAR_RANGE = 12.0
33
+ RADAR_RANGE_SQ = RADAR_RANGE * RADAR_RANGE
32
34
 
33
35
  PVP_PORT = 7777
34
36
  PVP_DEFAULT_ADDRESS = "termfront.gamelinks007.net:443"
@@ -10,6 +10,7 @@ module Termfront
10
10
  @scene_player = ScenePlayer.new(@stdout, audio: @audio)
11
11
  @demo_player = DemoPlayer.new(@stdout, @renderer)
12
12
  @difficulty = nil
13
+ Signal.trap("WINCH") { @renderer.invalidate_size_cache! }
13
14
  end
14
15
 
15
16
  def start
@@ -130,6 +131,7 @@ module Termfront
130
131
  def run_game_loop(show_complete_banner: true)
131
132
  STDIN.raw do |stdin|
132
133
  last_time = clock
134
+ last_render = last_time - Config::RENDER_DT
133
135
 
134
136
  loop do
135
137
  now = clock
@@ -145,11 +147,14 @@ module Termfront
145
147
  end
146
148
 
147
149
  update(dt)
148
- @renderer.render(
149
- player: @player, map: @map,
150
- enemies: @enemies, projectiles: @projectiles,
151
- drops: @player.drops, terminals: @terminals
152
- )
150
+ if now - last_render >= Config::RENDER_DT
151
+ @renderer.render(
152
+ player: @player, map: @map,
153
+ enemies: @enemies, projectiles: @projectiles,
154
+ drops: @player.drops, terminals: @terminals
155
+ )
156
+ last_render = now
157
+ end
153
158
 
154
159
  if @player.dead
155
160
  rows, cols = @stdout.winsize
@@ -175,6 +180,7 @@ module Termfront
175
180
  def run_wavesfight_loop
176
181
  STDIN.raw do |stdin|
177
182
  last_time = clock
183
+ last_render = last_time - Config::RENDER_DT
178
184
 
179
185
  loop do
180
186
  now = clock
@@ -190,12 +196,15 @@ module Termfront
190
196
  end
191
197
 
192
198
  update(dt)
193
- @renderer.render(
194
- player: @player, map: @map,
195
- enemies: @enemies, projectiles: @projectiles,
196
- drops: @player.drops, terminals: @terminals,
197
- status_line: " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]}"
198
- )
199
+ if now - last_render >= Config::RENDER_DT
200
+ @renderer.render(
201
+ player: @player, map: @map,
202
+ enemies: @enemies, projectiles: @projectiles,
203
+ drops: @player.drops, terminals: @terminals,
204
+ status_line: " WAVE #{@wave} #{Enemy::Base::DIFFICULTIES[@difficulty][:name]}"
205
+ )
206
+ last_render = now
207
+ end
199
208
 
200
209
  if @player.dead
201
210
  rows, cols = @stdout.winsize
@@ -208,6 +217,7 @@ module Termfront
208
217
  show_wave_clear
209
218
  start_wavesfight_wave
210
219
  last_time = clock
220
+ last_render = clock - Config::RENDER_DT
211
221
  end
212
222
 
213
223
  cap_frame(now)
@@ -413,7 +423,7 @@ module Termfront
413
423
  end
414
424
 
415
425
  def replenish_wavesfight_loadout
416
- @player.shield = [@player.shield + 35.0, Config::SHIELD_MAX].min
426
+ @player.shield = Config::SHIELD_MAX
417
427
  @player.health = [@player.health + 20.0, Config::HEALTH_MAX].min
418
428
  @player.last_damage = -Config::SHIELD_DELAY
419
429
  @player.dead = false
@@ -542,62 +552,78 @@ module Termfront
542
552
  end
543
553
  end
544
554
 
555
+ MISSION_SELECT_DIFF_COLORS = ["\e[92m", "\e[93m", "\e[38;2;255;165;0m", "\e[91m"].freeze
556
+ MISSION_SELECT_CTRL = "Up/Down: Select Left/Right: Difficulty Enter/1-5: Start Q: Back"
557
+
545
558
  def render_mission_select(selected, missions, title)
546
559
  rows, cols = @stdout.winsize
547
560
  buf = TerminalOutput.begin_frame(home: true)
548
561
  lines = Array.new(rows) { " " * cols }
562
+ cache = (@mission_select_cache ||= {})
549
563
 
550
- tc = [(cols - title.size) / 2 + 1, 1].max
551
- lines[1] = TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
564
+ lines[1] = cache[[:title, cols, title]] ||= begin
565
+ tc = [(cols - title.size) / 2 + 1, 1].max
566
+ TerminalOutput.fit_ansi("#{" " * (tc - 1)}\e[1;38;2;120;140;255m#{title}\e[0m", cols)
567
+ end
552
568
 
553
569
  diff = Enemy::Base::DIFFICULTIES[@difficulty]
554
- diff_colors = ["\e[92m", "\e[93m", "\e[38;2;255;165;0m", "\e[91m"]
555
- diff_label = "< #{diff[:name]} >"
556
- dc = [(cols - diff_label.size) / 2 + 1, 1].max
557
- lines[2] = TerminalOutput.fit_ansi("#{" " * (dc - 1)}#{diff_colors[@difficulty]}#{diff_label}\e[0m", cols)
570
+ lines[2] = cache[[:diff, cols, @difficulty]] ||= begin
571
+ diff_label = "< #{diff[:name]} >"
572
+ dc = [(cols - diff_label.size) / 2 + 1, 1].max
573
+ TerminalOutput.fit_ansi("#{" " * (dc - 1)}#{MISSION_SELECT_DIFF_COLORS[@difficulty]}#{diff_label}\e[0m", cols)
574
+ end
558
575
 
559
576
  missions.each_with_index do |klass, i|
560
- m = klass.new
561
577
  row = 5 + i * 2
562
- label = " #{i + 1}. #{m.name}"
563
- lc = [(cols - 40) / 2 + 1, 1].max
564
- text = if i == selected
565
- "\e[1;97;44m> #{label.strip.ljust(38)}\e[0m"
566
- else
567
- "\e[97m #{label.strip.ljust(38)}\e[0m"
568
- end
569
- lines[row - 1] = TerminalOutput.fit_ansi("#{" " * (lc - 1)}#{text}", cols)
578
+ is_selected = i == selected
579
+ lines[row - 1] = cache[[:mission, cols, i, is_selected, klass]] ||= begin
580
+ m = klass.new
581
+ label = " #{i + 1}. #{m.name}"
582
+ lc = [(cols - 40) / 2 + 1, 1].max
583
+ text = if is_selected
584
+ "\e[1;97;44m> #{label.strip.ljust(38)}\e[0m"
585
+ else
586
+ "\e[97m #{label.strip.ljust(38)}\e[0m"
587
+ end
588
+ TerminalOutput.fit_ansi("#{" " * (lc - 1)}#{text}", cols)
589
+ end
570
590
  end
571
591
 
572
592
  brief_row = 5 + missions.size * 2 + 1
573
- m = missions[selected].new
574
- briefing = m.briefing
575
- bc = [(cols - briefing.size) / 2 + 1, 1].max
576
- lines[brief_row - 1] = TerminalOutput.fit_ansi("#{" " * (bc - 1)}\e[38;2;180;180;200m#{briefing}\e[0m", cols)
593
+ lines[brief_row - 1] = cache[[:brief, cols, missions[selected]]] ||= begin
594
+ m = missions[selected].new
595
+ briefing = m.briefing
596
+ bc = [(cols - briefing.size) / 2 + 1, 1].max
597
+ TerminalOutput.fit_ansi("#{" " * (bc - 1)}\e[38;2;180;180;200m#{briefing}\e[0m", cols)
598
+ end
577
599
 
578
600
  info_row = brief_row + 2
579
- edefs = m.enemy_defs
580
- base_crawler = edefs.count { |e| e[4] == :crawler }
581
- base_executor = edefs.count { |e| e[4] == :executor }
582
- extra = diff[:extra_enemies]
583
- extra_crawler = 0
584
- extra_executor = 0
585
- extra.times do |i|
586
- src_type = edefs[i % edefs.size][4]
587
- src_type == :crawler ? (extra_crawler += 1) : (extra_executor += 1)
601
+ lines[info_row - 1] = cache[[:info, cols, @difficulty, missions[selected]]] ||= begin
602
+ m = missions[selected].new
603
+ edefs = m.enemy_defs
604
+ base_crawler = edefs.count { |e| e[4] == :crawler }
605
+ base_executor = edefs.count { |e| e[4] == :executor }
606
+ extra = diff[:extra_enemies]
607
+ extra_crawler = 0
608
+ extra_executor = 0
609
+ extra.times do |i|
610
+ src_type = edefs[i % edefs.size][4]
611
+ src_type == :crawler ? (extra_crawler += 1) : (extra_executor += 1)
612
+ end
613
+ crawler_c = base_crawler + extra_crawler
614
+ executor_c = base_executor + extra_executor
615
+ info = "Enemies: #{crawler_c} Crawler#{crawler_c != 1 ? "s" : ""}"
616
+ info += ", #{executor_c} Executor#{executor_c != 1 ? "s" : ""}" if executor_c > 0
617
+ info += " | HP x#{diff[:hp_mult]}"
618
+ ic = [(cols - info.size) / 2 + 1, 1].max
619
+ TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[38;2;140;140;160m#{info}\e[0m", cols)
588
620
  end
589
- crawler_c = base_crawler + extra_crawler
590
- executor_c = base_executor + extra_executor
591
- info = "Enemies: #{crawler_c} Crawler#{crawler_c != 1 ? "s" : ""}"
592
- info += ", #{executor_c} Executor#{executor_c != 1 ? "s" : ""}" if executor_c > 0
593
- info += " | HP x#{diff[:hp_mult]}"
594
- ic = [(cols - info.size) / 2 + 1, 1].max
595
- lines[info_row - 1] = TerminalOutput.fit_ansi("#{" " * (ic - 1)}\e[38;2;140;140;160m#{info}\e[0m", cols)
596
621
 
597
622
  ctrl_row = info_row + 2
598
- ctrl = "Up/Down: Select Left/Right: Difficulty Enter/1-5: Start Q: Back"
599
- cc = [(cols - ctrl.size) / 2 + 1, 1].max
600
- lines[ctrl_row - 1] = TerminalOutput.fit_ansi("#{" " * (cc - 1)}\e[38;2;100;100;120m#{ctrl}\e[0m", cols)
623
+ lines[ctrl_row - 1] = cache[[:ctrl, cols]] ||= begin
624
+ cc = [(cols - MISSION_SELECT_CTRL.size) / 2 + 1, 1].max
625
+ TerminalOutput.fit_ansi("#{" " * (cc - 1)}\e[38;2;100;100;120m#{MISSION_SELECT_CTRL}\e[0m", cols)
626
+ end
601
627
 
602
628
  lines.each_with_index do |line, index|
603
629
  buf << line
@@ -392,7 +392,7 @@ module Termfront
392
392
 
393
393
  def interpolate_opponents(dt)
394
394
  @remotes.each_value do |remote|
395
- remote[:lerp_t] = [remote[:lerp_t] + dt * 15.0, 1.0].min
395
+ remote[:lerp_t] = [remote[:lerp_t] + dt * 25.0, 1.0].min
396
396
  t = remote[:lerp_t]
397
397
  remote[:render].x = remote[:prev].x + (remote[:current].x - remote[:prev].x) * t
398
398
  remote[:render].y = remote[:prev].y + (remote[:current].y - remote[:prev].y) * t
@@ -28,6 +28,10 @@ module Termfront
28
28
  }.freeze
29
29
  DEFAULT_RATE_LIMIT = 10
30
30
  MAX_DROPPED_MSGS = 200
31
+ MAX_PVP_RANGE = 30.0
32
+ STATE_BROADCAST_HZ = 30
33
+ STATE_BROADCAST_DT = 1.0 / STATE_BROADCAST_HZ
34
+ SELECT_TIMEOUT = 1.0 / 60.0
31
35
  PVP_MAP = [
32
36
  "####################",
33
37
  "#........##........#",
@@ -271,6 +275,7 @@ module Termfront
271
275
  match_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
272
276
  last_activity = match_start
273
277
  last_tick_at = match_start
278
+ last_state_flush_at = match_start
274
279
 
275
280
  loop do
276
281
  sockets = roster.filter_map do |player|
@@ -281,7 +286,7 @@ module Termfront
281
286
  end
282
287
  break if sockets.empty?
283
288
 
284
- readable, = IO.select(sockets, nil, nil, 0.5)
289
+ readable, = IO.select(sockets, nil, nil, SELECT_TIMEOUT)
285
290
 
286
291
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
287
292
  if (reason = match_timeout_reason(now, match_start, last_activity))
@@ -295,6 +300,11 @@ module Termfront
295
300
  roster.each { |player| regen_player(player, dt, now) }
296
301
  last_tick_at = now
297
302
 
303
+ if now - last_state_flush_at >= STATE_BROADCAST_DT
304
+ flush_pending_states(roster)
305
+ last_state_flush_at = now
306
+ end
307
+
298
308
  next unless readable
299
309
 
300
310
  last_activity = now
@@ -388,7 +398,7 @@ module Termfront
388
398
  msg = msg.merge(ff: ff || 0)
389
399
  end
390
400
  msg = msg.merge(s: player[:shield].round(1), h: player[:health].round(1))
391
- broadcast(roster, msg.merge(from: player[:id]), except: player[:id])
401
+ player[:pending_state] = msg.merge(from: player[:id])
392
402
  when "hit"
393
403
  route_hit(roster, player, msg, Process.clock_gettime(Process::CLOCK_MONOTONIC))
394
404
  when "dead"
@@ -430,11 +440,12 @@ module Termfront
430
440
  oy = other[:y] - attacker[:y]
431
441
  dot = ox * dx + oy * dy
432
442
  next if dot < 0.1
443
+ next if dot > MAX_PVP_RANGE
444
+ next unless dot < best_dot
433
445
 
434
446
  perp = (ox * (-dy) + oy * dx).abs
435
447
  next if perp > weapon.hit_width
436
448
  next unless pvp_map.line_of_sight?(attacker[:x], attacker[:y], other[:x], other[:y])
437
- next unless dot < best_dot
438
449
 
439
450
  best = other
440
451
  best_dot = dot
@@ -450,15 +461,30 @@ module Termfront
450
461
  end
451
462
 
452
463
  def broadcast(roster, msg, except: nil)
464
+ line = JSON.generate(msg) + "\n"
453
465
  roster.each do |player|
454
466
  next if player[:id] == except
455
467
 
456
- send_json(player[:socket], msg)
468
+ write_line(player[:socket], line)
469
+ end
470
+ end
471
+
472
+ def flush_pending_states(roster)
473
+ roster.each do |player|
474
+ state = player[:pending_state]
475
+ next unless state
476
+
477
+ broadcast(roster, state, except: player[:id])
478
+ player[:pending_state] = nil
457
479
  end
458
480
  end
459
481
 
460
482
  def send_json(socket, msg)
461
- socket.write(JSON.generate(msg) + "\n")
483
+ write_line(socket, JSON.generate(msg) + "\n")
484
+ end
485
+
486
+ def write_line(socket, line)
487
+ socket.write(line)
462
488
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError, OpenSSL::SSL::SSLError
463
489
  nil
464
490
  end
@@ -919,7 +945,7 @@ module Termfront
919
945
 
920
946
  def replenish_wavesfight_roster(roster, session)
921
947
  roster.each do |player|
922
- player[:shield] = [player[:shield] + 35.0, Config::SHIELD_MAX].min
948
+ player[:shield] = Config::SHIELD_MAX
923
949
  player[:health] = [player[:health] + 20.0, Config::HEALTH_MAX].min
924
950
  player[:last_damage] = session[:clock] - Config::SHIELD_DELAY
925
951
  player[:alive] = true