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.
- checksums.yaml +4 -4
- data/.road_to_rubykaigi.sample +26 -0
- data/Rakefile +1 -3
- data/examples/accelerometer.rb +131 -0
- data/lib/road_to_rubykaigi/ansi.rb +8 -0
- data/lib/road_to_rubykaigi/calibration_bar.rb +84 -0
- data/lib/road_to_rubykaigi/calibration_result.rb +70 -0
- data/lib/road_to_rubykaigi/calibration_sampler.rb +52 -0
- data/lib/road_to_rubykaigi/calibration_screen.rb +171 -0
- data/lib/road_to_rubykaigi/config.rb +164 -0
- data/lib/road_to_rubykaigi/event_dispatcher.rb +3 -0
- data/lib/road_to_rubykaigi/fireworks.rb +2 -2
- data/lib/road_to_rubykaigi/game.rb +2 -0
- data/lib/road_to_rubykaigi/game_server.rb +91 -0
- data/lib/road_to_rubykaigi/graphics/2026/map.txt +30 -0
- data/lib/road_to_rubykaigi/graphics/2026/mask.txt +30 -0
- data/lib/road_to_rubykaigi/graphics/map.rb +2 -1
- data/lib/road_to_rubykaigi/graphics/mask.rb +1 -1
- data/lib/road_to_rubykaigi/graphics/player.rb +78 -48
- data/lib/road_to_rubykaigi/graphics/player.txt +4 -0
- data/lib/road_to_rubykaigi/jump_detector.rb +125 -0
- data/lib/road_to_rubykaigi/manager/audio_manager.rb +8 -2
- data/lib/road_to_rubykaigi/manager/game_manager.rb +3 -2
- data/lib/road_to_rubykaigi/opening_screen.rb +87 -16
- data/lib/road_to_rubykaigi/score_board.rb +6 -1
- data/lib/road_to_rubykaigi/serial_reader.rb +76 -0
- data/lib/road_to_rubykaigi/signal_interpreter.rb +199 -0
- data/lib/road_to_rubykaigi/signal_window.rb +139 -0
- data/lib/road_to_rubykaigi/sprite/bonus.rb +83 -45
- data/lib/road_to_rubykaigi/sprite/deadline.rb +2 -2
- data/lib/road_to_rubykaigi/sprite/enemy.rb +28 -21
- data/lib/road_to_rubykaigi/sprite/player.rb +51 -12
- data/lib/road_to_rubykaigi/version.rb +1 -1
- data/lib/road_to_rubykaigi.rb +42 -19
- data/public/controller.html +54 -0
- data/public/controller.rb +137 -0
- data/public/init.iife.js +122 -0
- data/public/picoruby.js +2 -0
- data/public/picoruby.wasm +0 -0
- data/tmp/.keep +0 -0
- metadata +41 -8
- data/.standard.yml +0 -29
- /data/lib/road_to_rubykaigi/graphics/{demo-map.txt → 2025/demo-map.txt} +0 -0
- /data/lib/road_to_rubykaigi/graphics/{demo-mask.txt → 2025/demo-mask.txt} +0 -0
- /data/lib/road_to_rubykaigi/graphics/{map.txt → 2025/map.txt} +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 527a733e37bdf8463d938cbfebb32b9760f1635bcf710730952fe6432522e760
|
|
4
|
+
data.tar.gz: f87265b023dde2645e842882550705322045916a9c850ae1dba08e6ff58480e0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|