road_to_rubykaigi 0.2.1 → 2026.0.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.road_to_rubykaigi.sample +26 -0
  3. data/Rakefile +1 -3
  4. data/examples/accelerometer.rb +131 -0
  5. data/lib/road_to_rubykaigi/ansi.rb +8 -0
  6. data/lib/road_to_rubykaigi/calibration_bar.rb +84 -0
  7. data/lib/road_to_rubykaigi/calibration_result.rb +70 -0
  8. data/lib/road_to_rubykaigi/calibration_sampler.rb +52 -0
  9. data/lib/road_to_rubykaigi/calibration_screen.rb +171 -0
  10. data/lib/road_to_rubykaigi/config.rb +164 -0
  11. data/lib/road_to_rubykaigi/event_dispatcher.rb +3 -0
  12. data/lib/road_to_rubykaigi/fireworks.rb +2 -2
  13. data/lib/road_to_rubykaigi/game.rb +2 -0
  14. data/lib/road_to_rubykaigi/game_server.rb +91 -0
  15. data/lib/road_to_rubykaigi/graphics/2026/map.txt +30 -0
  16. data/lib/road_to_rubykaigi/graphics/2026/mask.txt +30 -0
  17. data/lib/road_to_rubykaigi/graphics/map.rb +2 -1
  18. data/lib/road_to_rubykaigi/graphics/mask.rb +1 -1
  19. data/lib/road_to_rubykaigi/graphics/player.rb +78 -48
  20. data/lib/road_to_rubykaigi/graphics/player.txt +4 -0
  21. data/lib/road_to_rubykaigi/jump_detector.rb +125 -0
  22. data/lib/road_to_rubykaigi/manager/audio_manager.rb +8 -2
  23. data/lib/road_to_rubykaigi/manager/game_manager.rb +3 -2
  24. data/lib/road_to_rubykaigi/opening_screen.rb +87 -16
  25. data/lib/road_to_rubykaigi/score_board.rb +6 -1
  26. data/lib/road_to_rubykaigi/serial_reader.rb +76 -0
  27. data/lib/road_to_rubykaigi/signal_interpreter.rb +199 -0
  28. data/lib/road_to_rubykaigi/signal_window.rb +139 -0
  29. data/lib/road_to_rubykaigi/sprite/bonus.rb +83 -45
  30. data/lib/road_to_rubykaigi/sprite/deadline.rb +2 -2
  31. data/lib/road_to_rubykaigi/sprite/enemy.rb +28 -21
  32. data/lib/road_to_rubykaigi/sprite/player.rb +51 -12
  33. data/lib/road_to_rubykaigi/version.rb +1 -1
  34. data/lib/road_to_rubykaigi.rb +42 -19
  35. data/public/controller.html +54 -0
  36. data/public/controller.rb +137 -0
  37. data/public/init.iife.js +122 -0
  38. data/public/picoruby.js +2 -0
  39. data/public/picoruby.wasm +0 -0
  40. data/tmp/.keep +0 -0
  41. metadata +41 -8
  42. data/.standard.yml +0 -29
  43. /data/lib/road_to_rubykaigi/graphics/{demo-map.txt → 2025/demo-map.txt} +0 -0
  44. /data/lib/road_to_rubykaigi/graphics/{demo-mask.txt → 2025/demo-mask.txt} +0 -0
  45. /data/lib/road_to_rubykaigi/graphics/{map.txt → 2025/map.txt} +0 -0
  46. /data/lib/road_to_rubykaigi/graphics/{mask.txt → 2025/mask.txt} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e1c9d75458d0964d5a7016dffb3cd8b80a7528e6dd6988400e54b37d307a2ae
4
- data.tar.gz: 645ba606e44b14f7065136dc22b8dfd7f33fd8af5f0c7d0c2fcf94f928b54997
3
+ metadata.gz: 527a733e37bdf8463d938cbfebb32b9760f1635bcf710730952fe6432522e760
4
+ data.tar.gz: f87265b023dde2645e842882550705322045916a9c850ae1dba08e6ff58480e0
5
5
  SHA512:
6
- metadata.gz: cc76ee396cf3a522c4aca51b02a4fc3009e7f7dea49c4ca59bcaa4161c70e8bc9a1276549e8777385a9baa4b1960f682ee0415b3534a06f22bc2f5d6bd71780a
7
- data.tar.gz: a6f482e2a301be08a089b978950a1d890417653243e63b985b24e2f2c94a7c1bb64a61fde9b47d0eb885d531b778f10f94c0c280e45a81a2184dd69b6e3aa33d
6
+ metadata.gz: 26b4d55a9e985cdf15edde864070eb4294727571ea8bfe6fe3c2be96a15283c1a896dffb22834963ab278ab487f5e335094b5e33000b2c0ecca1a334f9c36f00
7
+ data.tar.gz: 78161e32af91184d672f0279b662b0e7a22c323651299f5a0925987d460d22dd5687a34be698d0d23a24a2a2f4e74ab0099ee294c67c78791bb34738af2f66e5
@@ -0,0 +1,26 @@
1
+ # Configuration file for Road to RubyKaigi
2
+ # Copy this file to .road_to_rubykaigi and uncomment the settings you want to enable.
3
+ #
4
+ # Input source for the accelerometer signal: `ble` or `serial`.
5
+ # Leave unset to play with keyboard only.
6
+ # INPUT_SOURCE=ble
7
+ # Enable debug logging to tmp/game_server.log
8
+ # DEBUG=1
9
+ # Mute BGM (sound effects play)
10
+ # BGM_OFF=1
11
+ # Skip auto-opening the controller browser on game start
12
+ # DEMO=1
13
+ # Start threshold for run start detection (set by calibration)
14
+ # START_THRESHOLD=0.025
15
+ # Continuation threshold for step detection (set by calibration)
16
+ # CONTINUATION_THRESHOLD=0.05
17
+ # Walking step cadence in Hz (set by calibration)
18
+ # WALK_CADENCE=2.6
19
+ # Walking motion intensity median (set by calibration)
20
+ # WALK_INTENSITY=0.6
21
+ # Gravity vector (x,y,z, set by calibration, used to project vertical axis)
22
+ # GRAVITY=0.0,0.0,1.0
23
+ # Max vertical acceleration at a committed jump (set by calibration)
24
+ # JUMP_V_MAX=2.0
25
+ # Serial port device path (default: /dev/tty.usbmodem0001)
26
+ # SERIAL_PORT=/dev/tty.usbmodem0001
data/Rakefile CHANGED
@@ -6,6 +6,4 @@ import "util/audio.rake"
6
6
 
7
7
  RSpec::Core::RakeTask.new(:spec)
8
8
 
9
- require "standard/rake"
10
-
11
- task default: %i[spec standard]
9
+ task default: %i[spec]
@@ -0,0 +1,131 @@
1
+ # Sample PicoRuby script for reading acceleration data from KXR94-2050 module
2
+ # and sending it via BLE UART and hardware UART (serial).
3
+ #
4
+ # Data is always sent to both channels simultaneously:
5
+ # - BLE UART: received by browser controller -> GameServer
6
+ # - Hardware UART (GP0): received by USB-TTL cable -> SerialReader on the host
7
+ # Switch SERIAL=1 in .road_to_rubykaigi on the host side to choose which to use.
8
+ #
9
+ # Wiring (USB-TTL cable -> Pico W):
10
+ # Cable RX -> GP0 (UART0 TX)
11
+ # Cable GND -> GND
12
+ #
13
+ # KXR94-2050 datasheet: https://akizukidenshi.com/goodsaffix/KXR94_2025_06_25.pdf
14
+ # Sensitivity: Vdd / 5 (V/g)
15
+ # Zero-g offset: Vdd / 2 (V)
16
+ #
17
+ # Flash this script to a Raspberry Pi Pico (2) W with PicoRuby.
18
+
19
+ require 'adc'
20
+ require 'uart'
21
+ require 'ble'
22
+ require 'ble-uart'
23
+
24
+ # Switch this to match the target board before flashing.
25
+ BOARD = :ble # :ble or :serial
26
+
27
+ ZERO = 3.3 / 2.0
28
+ SENSITIVITY = 3.3 / 5.0
29
+ # Sensor axes in chip-native frame (both boards mount the chip the same way
30
+ # relative to the body):
31
+ # chip X = vertical (up/down along gravity)
32
+ # chip Y = horizontal (lateral)
33
+ # chip Z = body front-back
34
+ # Emitted stream uses a rotated frame where Z = up, so at rest both boards
35
+ # emit (x=0, y=0, z=+1). Chip X reads -1g at rest, so Z_SIGN=-1 flips it.
36
+ if BOARD == :ble
37
+ # Chip X/Y physically swapped on this board: pin 26 reads chip Y, pin 27
38
+ # reads chip X.
39
+ X_PIN, Y_PIN, Z_PIN = 26, 28, 27
40
+ else # :serial
41
+ # Straight pin-to-chip mapping: pin 26=chip X, 27=chip Y, 28=chip Z.
42
+ X_PIN, Y_PIN, Z_PIN = 27, 28, 26
43
+ end
44
+ X_SIGN, Y_SIGN, Z_SIGN = 1, 1, -1
45
+
46
+ class Accelerometer
47
+ def initialize
48
+ GPIO.new(19, GPIO::OUT).write(1)
49
+ sleep 0.5
50
+
51
+ @ax = ADC.new(X_PIN)
52
+ @ay = ADC.new(Y_PIN)
53
+ @az = ADC.new(Z_PIN)
54
+ end
55
+
56
+ def read
57
+ x = to_g(@ax.read_voltage) * X_SIGN
58
+ y = to_g(@ay.read_voltage) * Y_SIGN
59
+ z = to_g(@az.read_voltage) * Z_SIGN
60
+ bootsel = Machine.bootsel_pressed? ? 1 : 0
61
+ "x=#{x.round(5)},y=#{y.round(5)},z=#{z.round(5)},b=#{bootsel}"
62
+ end
63
+
64
+ # private
65
+
66
+ def to_g(voltage)
67
+ (voltage - ZERO) / SENSITIVITY
68
+ end
69
+ end
70
+
71
+ class Blinker
72
+ LED_PIN = CYW43::GPIO::LED_PIN
73
+ FAST_INTERVAL_MS = 200
74
+ SLOW_INTERVAL_MS = 500
75
+ TICK_INTERVAL_MS = BLE::POLLING_UNIT_MS / BLE::UART::USER_BLOCK_CALL_COUNT_PER_POLL
76
+ INTERVALS = {
77
+ fast: FAST_INTERVAL_MS / TICK_INTERVAL_MS,
78
+ slow: SLOW_INTERVAL_MS / TICK_INTERVAL_MS,
79
+ }
80
+
81
+ attr_writer :mode
82
+
83
+ def initialize
84
+ @led = CYW43::GPIO.new(LED_PIN)
85
+ @on = false
86
+ @tick = 0
87
+ @mode = :fast
88
+ end
89
+
90
+ def tick
91
+ @tick += 1
92
+ return if @tick < INTERVALS[@mode]
93
+
94
+ @tick = 0
95
+ @on = !@on
96
+ @led.write(@on ? 1 : 0)
97
+ end
98
+ end
99
+
100
+ accelerometer = Accelerometer.new
101
+ serial = UART.new(unit: :RP2040_UART0, txd_pin: 0, baudrate: 115200)
102
+ ble_uart = BLE::UART.new(name: 'RtR')
103
+ ble_uart.debug = true
104
+ blinker = Blinker.new
105
+
106
+ # The BLE connection interval (negotiated by the central) caps the notification rate,
107
+ # the sensor produces faster than a single-sample-per-notify stream can be delivered.
108
+ # Batching keeps the effective sample rate while staying under the notification throughput ceiling.
109
+ BLE_BATCH_SIZE = 2
110
+ SAMPLE_SEPARATOR = '|'
111
+
112
+ ble_batch = []
113
+
114
+ # BLE::UART#start calls this block USER_BLOCK_CALL_COUNT_PER_POLL times per BLE
115
+ # polling cycle (every POLLING_UNIT_MS / USER_BLOCK_CALL_COUNT_PER_POLL ≈ 20ms).
116
+ # Send one sample per call — no inner loop, no sleep_ms here.
117
+ ble_uart.start do
118
+ data = accelerometer.read
119
+ if ble_uart.connected?
120
+ ble_batch << data
121
+ if ble_batch.size >= BLE_BATCH_SIZE
122
+ ble_uart.puts(ble_batch.join(SAMPLE_SEPARATOR))
123
+ ble_batch.clear
124
+ end
125
+ else
126
+ serial.puts(data)
127
+ end
128
+
129
+ blinker.mode = ble_uart.connected? ? :slow : :fast
130
+ blinker.tick
131
+ end
@@ -5,6 +5,7 @@ module RoadToRubykaigi
5
5
  CURSOR_ON = "\e[?25h"
6
6
  HOME = "\e[H"
7
7
  RESET = "\e[0m"
8
+ BOLD = "\e[1m"
8
9
  RED = "\e[31m"
9
10
  BLUE = "\e[38;5;39m"
10
11
  YELLOW = "\e[33m"
@@ -12,6 +13,13 @@ module RoadToRubykaigi
12
13
  DEFAULT_TEXT_COLOR = "\e[38;5;238m"
13
14
  RESULT_DATA = ["\e[4;18H", "\e[5;18H", "\e[6;18H"]
14
15
  NULL = "\0"
16
+ UP = "\e[A"
17
+ DOWN = "\e[B"
18
+ ENTER = "\r"
19
+ LF = "\n"
20
+ SPACE = " "
21
+ ESC = "\e"
22
+ ETX = "\x03"
15
23
 
16
24
  self.constants(false).each do |constant|
17
25
  ANSI.define_singleton_method(constant.to_s.downcase) {
@@ -0,0 +1,84 @@
1
+ module RoadToRubykaigi
2
+ class CalibrationBar
3
+ BAR_WIDTH = 100
4
+ BAR_MAX = 2.5
5
+ EMOJI_WIDTH = 2
6
+ BOUNCE_HZ = 4
7
+
8
+ BASE_X = 5
9
+ EMOJI_BOUNCE_Y = 6
10
+ EMOJI_BASE_Y = EMOJI_BOUNCE_Y + 1
11
+
12
+ MESSAGES = {
13
+ remaining: [5, 5, "#{ANSI::BOLD}%-20s#{ANSI::RESET} %5.1fs"],
14
+ intensity: [5, 8, '[%s] %.4f'],
15
+ }.freeze
16
+
17
+ LABELS = {
18
+ static: { text: 'Hold still', emoji: '🧍' },
19
+ walk: { text: 'Walk', emoji: '🚶‍➡️', emoji_bounce: '🏃‍➡️' },
20
+ jump: { text: 'Jump', emoji: '🧍', emoji_bounce: '🤸' },
21
+ }.freeze
22
+
23
+ def self.states = LABELS.keys.dup
24
+
25
+ def self.clear_emoji
26
+ blank = ' ' * (BAR_WIDTH + EMOJI_WIDTH)
27
+ [
28
+ [BASE_X + 1, EMOJI_BOUNCE_Y, blank],
29
+ [BASE_X + 1, EMOJI_BASE_Y, blank],
30
+ ]
31
+ end
32
+
33
+ def render
34
+ result = [
35
+ format_line(MESSAGES[:remaining], "▶ #{@label[:text]}", @sampler.remaining),
36
+ format_line(MESSAGES[:intensity], bar, @sampler.intensity),
37
+ ]
38
+
39
+ if bouncing_state?
40
+ emoji_x = BASE_X + 1 + (@sampler.progress * BAR_WIDTH).to_i.clamp(0, BAR_WIDTH)
41
+ # Clear previous emoji
42
+ unless @prev_emoji_x == emoji_x
43
+ result << [@prev_emoji_x, EMOJI_BOUNCE_Y, ' ' * EMOJI_WIDTH]
44
+ result << [@prev_emoji_x, EMOJI_BASE_Y, ' ' * EMOJI_WIDTH]
45
+ @prev_emoji_x = emoji_x
46
+ end
47
+
48
+ if bouncing?
49
+ result << [emoji_x, EMOJI_BOUNCE_Y, @label[:emoji_bounce]]
50
+ result << [emoji_x, EMOJI_BASE_Y, ' ' * EMOJI_WIDTH]
51
+ else
52
+ result << [emoji_x, EMOJI_BOUNCE_Y, ' ' * EMOJI_WIDTH]
53
+ result << [emoji_x, EMOJI_BASE_Y, @label[:emoji]]
54
+ end
55
+ else
56
+ result << [BASE_X + 1, EMOJI_BASE_Y, @label[:emoji]]
57
+ end
58
+
59
+ result
60
+ end
61
+
62
+ private
63
+
64
+ def initialize(sampler, state:)
65
+ @sampler = sampler
66
+ @state = state
67
+ @label = LABELS.fetch(state)
68
+ @prev_emoji_x = BASE_X + 1
69
+ end
70
+
71
+ def bar
72
+ filled = (@sampler.intensity / BAR_MAX * BAR_WIDTH).to_i.clamp(0, BAR_WIDTH)
73
+ '█' * filled + '░' * (BAR_WIDTH - filled)
74
+ end
75
+
76
+ def bouncing_state? = @label.key?(:emoji_bounce)
77
+ def bouncing? = (Time.now.to_f * BOUNCE_HZ).to_i.odd?
78
+
79
+ def format_line(line, *args)
80
+ x, y, template = line
81
+ [x, y, format(template, *args)]
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,70 @@
1
+ module RoadToRubykaigi
2
+ CalibrationResult = Data.define(
3
+ :start_threshold,
4
+ :continuation_threshold,
5
+ :walk_cadence,
6
+ :walk_cadence_sample_count,
7
+ :walk_intensity,
8
+ :gravity_vector,
9
+ :jump_v_max,
10
+ :static_sample_count,
11
+ :jump_sample_count,
12
+ ) do
13
+ # Derives calibrated values from per-phase collected samples.
14
+ def self.from_samples(static:, walk:, jump:)
15
+ # Use p95 rather than max so a single stray spike during "still" doesn't
16
+ # inflate every downstream threshold.
17
+ sorted_static = static[:intensities].sort
18
+ noise_ceiling = sorted_static[(sorted_static.size * 0.95).floor] || 0.0
19
+ # Noise ceiling * 2.5 as the threshold separating noise from walking.
20
+ # Stays above noise even in short-window valleys between steps.
21
+ # 2.5 is an empirical factor derived from real calibration data.
22
+ start_threshold = noise_ceiling * 2.5
23
+ # Continuation uses a short window (fast stop detection) which is noise-sensitive,
24
+ # so use a higher threshold for noise tolerance.
25
+ continuation_threshold = noise_ceiling * 5.0
26
+ gravity_vector = mean_vector(static[:raw_samples])
27
+ new(
28
+ start_threshold:,
29
+ continuation_threshold:,
30
+ walk_cadence: median(walk[:cadences]), # Median walking step cadence in Hz, representing individual step frequency.
31
+ walk_intensity: median(walk[:intensities]), # Used by the intensity-boost path that lifts in-place running (elevated intensity, walk-level cadence).
32
+ gravity_vector:,
33
+ jump_v_max: max_vertical_acceleration(jump[:raw_samples], gravity_vector),
34
+
35
+ static_sample_count: static[:intensities].size,
36
+ walk_cadence_sample_count: walk[:cadences].size,
37
+ jump_sample_count: jump[:raw_samples].size,
38
+ )
39
+ end
40
+
41
+ def self.median(values)
42
+ sorted = values.sort
43
+ sorted[sorted.size / 2] || 0.0
44
+ end
45
+
46
+ # Averaged [x, y, z] of the resting accelerometer, used as the gravity reference.
47
+ def self.mean_vector(samples)
48
+ samples.transpose.map { |values| values.sum / values.size }
49
+ end
50
+
51
+ # Max upward acceleration (in g, gravity subtracted) observed across jump samples.
52
+ def self.max_vertical_acceleration(samples, gravity)
53
+ gravity_magnitude = Math.sqrt(gravity[0] ** 2 + gravity[1] ** 2 + gravity[2] ** 2)
54
+ samples.map { |sample|
55
+ (sample[0] * gravity[0] + sample[1] * gravity[1] + sample[2] * gravity[2]) / gravity_magnitude - gravity_magnitude
56
+ }.max || 0.0
57
+ end
58
+
59
+ def save
60
+ Config.save_calibration(
61
+ start_threshold: start_threshold.round(6),
62
+ continuation_threshold: continuation_threshold.round(6),
63
+ walk_cadence: walk_cadence.round(6),
64
+ walk_intensity: walk_intensity.round(6),
65
+ gravity_vector: gravity_vector.map { |value| value.round(6) },
66
+ jump_v_max: jump_v_max.round(6),
67
+ )
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ module RoadToRubykaigi
2
+ class CalibrationSampler
3
+ PHASE_SECONDS = 5
4
+
5
+ attr_reader :intensities, :cadences, :raw_samples, :intensity
6
+
7
+ def tick
8
+ Config.signal_source.drain do |data|
9
+ sample = parse_sample(data)
10
+ next unless sample
11
+ @window.buffer_sample(sample)
12
+ @raw_samples << sample
13
+ end
14
+ return unless @window.full?
15
+ @intensity = @window.motion_intensity
16
+ @intensities << @intensity
17
+ @cadences << @window.cadence_hz if @window.cadence_ready?
18
+ end
19
+
20
+ def finished?
21
+ elapsed >= PHASE_SECONDS
22
+ end
23
+
24
+ def remaining
25
+ PHASE_SECONDS - elapsed
26
+ end
27
+
28
+ def progress
29
+ (elapsed / PHASE_SECONDS.to_f).clamp(0.0, 1.0)
30
+ end
31
+
32
+ private
33
+
34
+ def initialize
35
+ @window = SignalWindow.new
36
+ @intensities = []
37
+ @cadences = []
38
+ @raw_samples = []
39
+ @intensity = 0.0
40
+ @started_at = Time.now
41
+ end
42
+
43
+ def elapsed
44
+ Time.now - @started_at
45
+ end
46
+
47
+ def parse_sample(data)
48
+ return nil unless %w[x y z].all? { |key| data.key?(key) }
49
+ [data['x'].to_f, data['y'].to_f, data['z'].to_f]
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,171 @@
1
+ module RoadToRubykaigi
2
+ class CalibrationScreen
3
+ COUNTDOWN_FROM = 5
4
+
5
+ MESSAGES = {
6
+ title: [5, 3, '=== Sensor Calibration ==='],
7
+ intro: [
8
+ [5, 10, '[Enter/Space] start'],
9
+ [5, 11, '[ESC] return'],
10
+ ],
11
+ cancel: [5, 11, '[ESC] cancel'],
12
+ instructions: [
13
+ [5, 13, '🧍 Hold -> 🚶‍➡️ Walk -> 🤸 Jump!'],
14
+ [5, 14, "#{CalibrationSampler::PHASE_SECONDS}s each"],
15
+ ],
16
+ clear_instructions: [
17
+ [5, 13, ' ' * 30],
18
+ [5, 14, ' ' * 30],
19
+ ],
20
+ countdown: [5, 8, 'Starting in %d...'],
21
+ countdown_clear: [5, 8, ' ' * 20],
22
+ done_static: [5, 6, 'Static: %.6f (%d samples)'],
23
+ done_walk: [5, 7, 'Walk: %.3f Hz (%d samples)'],
24
+ done_jump: [5, 8, 'Jump: %.3f g (%d samples)'],
25
+ done_return: [5, 10, '[Enter/ESC] return'],
26
+ not_connected: [
27
+ [5, 6, 'Sensor not connected.'],
28
+ [5, 7, 'Connect the sensor and try again.'],
29
+ [5, 10, '[Enter/ESC] return'],
30
+ ],
31
+ }.freeze
32
+
33
+ def display
34
+ if Config.serial? && !File.exist?(Config.serial_port)
35
+ enter_not_connected
36
+ else
37
+ Config.signal_source.start
38
+ enter_intro
39
+ end
40
+ $stdin.raw do
41
+ loop do
42
+ case tick
43
+ when :done then return
44
+ end
45
+ sleep Manager::GameManager::FRAME_RATE
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def tick
53
+ action = read_action
54
+ if action == :cancel
55
+ if @state == :intro || @state == :done
56
+ return :done
57
+ else
58
+ enter_intro && return
59
+ end
60
+ end
61
+
62
+ case @state
63
+ when :intro then action == :proceed && enter_countdown
64
+ when :countdown then tick_countdown
65
+ when :collect then tick_collect
66
+ when :done then action == :proceed && enter_intro
67
+ end
68
+ end
69
+
70
+ def enter_intro
71
+ @state = :intro
72
+ @results = {}
73
+ @remaining_keys = CalibrationBar.states
74
+ ANSI.clear
75
+ draw MESSAGES[:title], *MESSAGES[:intro], *MESSAGES[:instructions]
76
+ end
77
+
78
+ def enter_countdown
79
+ @state = :countdown
80
+ @countdown_remaining = COUNTDOWN_FROM
81
+ @countdown_last_tick = nil
82
+ ANSI.clear
83
+ draw MESSAGES[:title], MESSAGES[:cancel], *MESSAGES[:instructions]
84
+ end
85
+
86
+ def tick_countdown
87
+ now = Time.now
88
+ @countdown_last_tick ||= now
89
+
90
+ if now - @countdown_last_tick >= 1.0
91
+ @countdown_remaining -= 1
92
+ @countdown_last_tick = now
93
+ end
94
+
95
+ if @countdown_remaining <= 0
96
+ draw MESSAGES[:countdown_clear]
97
+ enter_collect
98
+ else
99
+ draw format_line(MESSAGES[:countdown], @countdown_remaining)
100
+ end
101
+ end
102
+
103
+ def enter_not_connected
104
+ @state = :done
105
+ ANSI.clear
106
+ draw MESSAGES[:title], *MESSAGES[:not_connected]
107
+ end
108
+
109
+ def enter_collect
110
+ if @results.empty? && Config.signal_source.queue.empty?
111
+ enter_not_connected && return
112
+ end
113
+
114
+ @state = :collect
115
+ @current_key = @remaining_keys.first
116
+ @sampler = CalibrationSampler.new
117
+ @bar = CalibrationBar.new(@sampler, state: @current_key)
118
+ draw *MESSAGES[:clear_instructions], *CalibrationBar.clear_emoji
119
+ end
120
+
121
+ def tick_collect
122
+ @sampler.tick
123
+ draw *@bar.render
124
+
125
+ return unless @sampler.finished?
126
+
127
+ @results[@current_key] = {
128
+ intensities: @sampler.intensities,
129
+ cadences: @sampler.cadences,
130
+ raw_samples: @sampler.raw_samples,
131
+ }
132
+ @remaining_keys.shift
133
+
134
+ if @remaining_keys.empty?
135
+ enter_done
136
+ else
137
+ enter_collect
138
+ end
139
+ end
140
+
141
+ def enter_done
142
+ @state = :done
143
+ result = CalibrationResult.from_samples(**@results)
144
+ result.save
145
+ ANSI.clear
146
+ draw MESSAGES[:title],
147
+ format_line(MESSAGES[:done_static], result.continuation_threshold, result.static_sample_count),
148
+ format_line(MESSAGES[:done_walk], result.walk_cadence, result.walk_cadence_sample_count),
149
+ format_line(MESSAGES[:done_jump], result.jump_v_max, result.jump_sample_count),
150
+ MESSAGES[:done_return]
151
+ end
152
+
153
+ def format_line(line, *args)
154
+ x, y, template = line
155
+ [x, y, format(template, *args)]
156
+ end
157
+
158
+ def read_action
159
+ case $stdin.read_nonblock(3, exception: false)
160
+ when ANSI::ETX then raise Interrupt
161
+ when ANSI::ENTER, ANSI::LF, ANSI::SPACE then :proceed
162
+ when ANSI::ESC then :cancel
163
+ end
164
+ end
165
+
166
+ def draw(*lines)
167
+ lines.each { |x, y, text| print "\e[#{y};#{x}H#{text}" }
168
+ $stdout.flush
169
+ end
170
+ end
171
+ end