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
|
@@ -5,62 +5,92 @@ module RoadToRubykaigi
|
|
|
5
5
|
LEFT = RoadToRubykaigi::Sprite::Player::LEFT
|
|
6
6
|
FILE_PATH = "player.txt"
|
|
7
7
|
|
|
8
|
+
STATUSES_BY_POSTURE = {
|
|
9
|
+
standup: %i[normal stunned running],
|
|
10
|
+
crouching: %i[normal stunned],
|
|
11
|
+
}.freeze
|
|
12
|
+
FRAMES = [1, 2].freeze
|
|
13
|
+
|
|
14
|
+
Direction = Data.define(:value) do
|
|
15
|
+
def right? = value == RIGHT
|
|
16
|
+
def name = right? ? 'RIGHT' : 'LEFT'
|
|
17
|
+
end
|
|
18
|
+
DIRECTIONS = [Direction.new(RIGHT), Direction.new(LEFT)].freeze
|
|
19
|
+
|
|
20
|
+
Variant = Data.define(:posture, :status, :direction, :default_frames, :attack_frames) do
|
|
21
|
+
class << self
|
|
22
|
+
def build(posture:, status:, direction:, parts:)
|
|
23
|
+
new(
|
|
24
|
+
posture: posture,
|
|
25
|
+
status: status,
|
|
26
|
+
direction: direction,
|
|
27
|
+
default_frames: FRAMES.map { |frame| default_frame(posture, status, direction, frame, parts) },
|
|
28
|
+
attack_frames: FRAMES.map { |frame| attack_frame(posture, status, direction, frame, parts) },
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def face_key(posture, status, direction) = :"face_#{posture}_#{status}_#{direction.name}"
|
|
35
|
+
def foot_key(status, frame) = :"foot_#{status == :running ? :normal : status}_#{frame}"
|
|
36
|
+
def crouching?(posture) = posture == :crouching
|
|
37
|
+
|
|
38
|
+
def default_frame(posture, status, direction, frame, parts)
|
|
39
|
+
[
|
|
40
|
+
parts[:head],
|
|
41
|
+
parts[face_key(posture, status, direction)],
|
|
42
|
+
crouching?(posture) ? nil : parts[foot_key(status, frame)],
|
|
43
|
+
].compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def attack_frame(posture, status, direction, frame, parts)
|
|
47
|
+
head = parts[:head]
|
|
48
|
+
face = parts[face_key(posture, status, direction)]
|
|
49
|
+
foot = crouching?(posture) ? nil : parts[foot_key(status, frame)]
|
|
50
|
+
|
|
51
|
+
if direction.right?
|
|
52
|
+
[head + " ".chars, face + "_◢◤".chars, foot && foot + " ".chars].compact
|
|
53
|
+
else
|
|
54
|
+
[" ".chars + head, "◥◣_".chars + face, foot && " ".chars + foot].compact
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
8
60
|
class << self
|
|
9
61
|
def character(posture:, status:, direction:, attack_mode:)
|
|
10
|
-
load_data
|
|
11
|
-
|
|
12
|
-
|
|
62
|
+
load_data unless @variants
|
|
63
|
+
|
|
64
|
+
variant = @variants[[posture, status, direction]]
|
|
65
|
+
attack_mode ? variant.attack_frames : variant.default_frames
|
|
13
66
|
end
|
|
14
67
|
|
|
15
68
|
private
|
|
16
69
|
|
|
17
70
|
def load_data
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@attack_characters = {
|
|
26
|
-
standup: { normal: hash.dup, stunned: hash.dup },
|
|
27
|
-
crouching: { normal: hash.dup, stunned: hash.dup },
|
|
28
|
-
}
|
|
29
|
-
set = {}
|
|
30
|
-
File.read("#{__dir__}/#{FILE_PATH}").scan(/# (\w+)\n(.*)\n/) do |type, line|
|
|
31
|
-
set[type.to_sym] = line.chars.map do |char|
|
|
32
|
-
fullwidth?(char) ? [char, ANSI::NULL] : char
|
|
33
|
-
end.flatten
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
%i[standup crouching].each do |posture|
|
|
37
|
-
%i[normal stunned].each do |status|
|
|
38
|
-
index.each do |(direction, direction_value)|
|
|
39
|
-
(1..2).each do |i|
|
|
40
|
-
@default_characters[posture][status][direction_value] << (
|
|
41
|
-
[
|
|
42
|
-
set[:head],
|
|
43
|
-
set[:"face_#{posture}_#{status}_#{direction}"],
|
|
44
|
-
posture == :crouching ? nil : set[:"foot_#{status}_#{i}"],
|
|
45
|
-
].compact
|
|
46
|
-
)
|
|
47
|
-
@attack_characters[posture][status][direction_value] << (
|
|
48
|
-
direction == "RIGHT" ?
|
|
49
|
-
[
|
|
50
|
-
set[:head] + " ".chars,
|
|
51
|
-
set[:"face_#{posture}_#{status}_#{direction}"] + "_◢◤".chars,
|
|
52
|
-
posture == :crouching ? nil : set[:"foot_#{status}_#{i}"] + " ".chars,
|
|
53
|
-
].compact :
|
|
54
|
-
[
|
|
55
|
-
" ".chars + set[:head],
|
|
56
|
-
"◥◣_".chars + set[:"face_#{posture}_#{status}_#{direction}"],
|
|
57
|
-
posture == :crouching ? nil : " ".chars + set[:"foot_#{status}_#{i}"],
|
|
58
|
-
].compact
|
|
59
|
-
)
|
|
60
|
-
end
|
|
61
|
-
end
|
|
71
|
+
parts = load_parts
|
|
72
|
+
@variants = STATUSES_BY_POSTURE.flat_map do |posture, statuses|
|
|
73
|
+
statuses.product(DIRECTIONS).map do |status, direction|
|
|
74
|
+
[
|
|
75
|
+
[posture, status, direction.value],
|
|
76
|
+
Variant.build(posture:, status:, direction:, parts:),
|
|
77
|
+
]
|
|
62
78
|
end
|
|
63
|
-
end
|
|
79
|
+
end.to_h
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
83
|
+
# {head: ["╭", "─", "─", "─", "─", "─", "─", "╮"],
|
|
84
|
+
# foot_normal_1: ["╰", "─", "∪", "─", "─", "─", "∪", "╯"],
|
|
85
|
+
# face_standup_normal_RIGHT: ["│", "。", "・", "\u0000", "◡", "・", "\u0000", "│"], ...
|
|
86
|
+
def load_parts
|
|
87
|
+
File.read("#{__dir__}/#{FILE_PATH}")
|
|
88
|
+
.scan(/^# (\w+)\n(.*)$/)
|
|
89
|
+
.to_h { |name, line| [name.to_sym, expand_cells(line)] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def expand_cells(line)
|
|
93
|
+
line.chars.flat_map { |char| fullwidth?(char) ? [char, ANSI::NULL] : [char] }
|
|
64
94
|
end
|
|
65
95
|
|
|
66
96
|
def fullwidth?(char)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module RoadToRubykaigi
|
|
2
|
+
# Detects squat-jumps from the accelerometer stream.
|
|
3
|
+
#
|
|
4
|
+
# Axis-agnostic: consumes the gravity-compensated vertical acceleration
|
|
5
|
+
# instead of any single raw axis. vertical_acceleration = projection of the
|
|
6
|
+
# sample onto the gravity vector, minus |gravity|. So rest = 0, upward
|
|
7
|
+
# proper acceleration > 0, downward (or free fall) < 0.
|
|
8
|
+
#
|
|
9
|
+
# Rule: loaded hold + bottom turnover. Running strides briefly peak the
|
|
10
|
+
# vertical acceleration above the loaded threshold at each footstrike.
|
|
11
|
+
# A squat-jump holds it above the threshold noticeably longer. Fire at the
|
|
12
|
+
# bottom of that span (slope becomes clearly negative), whether the signal
|
|
13
|
+
# is still above the threshold or has just crossed back below.
|
|
14
|
+
#
|
|
15
|
+
# Firing at the bottom of the load (not at landing or at full takeoff)
|
|
16
|
+
# minimizes latency.
|
|
17
|
+
#
|
|
18
|
+
# The loaded span must start from an up-crossing (a prior sample at or
|
|
19
|
+
# below the threshold) inside the buffer. Otherwise a stationary sensor
|
|
20
|
+
# reading near 0 could drift above threshold and accumulate an unbounded
|
|
21
|
+
# "loaded hold" that passes the duration gate on its own.
|
|
22
|
+
class JumpDetector
|
|
23
|
+
LOADED_THRESHOLD = 0.20 # vertical_acceleration above this counts as "loaded" (body under extra g-load)
|
|
24
|
+
LOADED_MIN_SECONDS = 0.2 # loaded span qualifying as squat hold
|
|
25
|
+
TAKEOFF_SLOPE_MAX = -1.0 # g/s — fall slope indicating takeoff turnover
|
|
26
|
+
SLOPE_WINDOW_SECONDS = 0.08
|
|
27
|
+
LOADED_END_GRACE_SECONDS = 0.15 # allow fire shortly past the span's last above-threshold point
|
|
28
|
+
|
|
29
|
+
COOLDOWN_SECONDS = 0.8 # min gap between consecutive fires (covers takeoff→landing)
|
|
30
|
+
SAMPLE_BUFFER_SECONDS = 1.2 # retain samples long enough to cover an elongated squat hold
|
|
31
|
+
MIN_SAMPLES_FOR_ANALYSIS = 5 # slope + hold both need a few samples to be meaningful
|
|
32
|
+
|
|
33
|
+
def initialize(gravity:)
|
|
34
|
+
@gravity = gravity
|
|
35
|
+
@gravity_magnitude = Math.sqrt(gravity[0] ** 2 + gravity[1] ** 2 + gravity[2] ** 2)
|
|
36
|
+
@last_samples = [] # [{time:, vertical_acceleration:}] sliding window
|
|
37
|
+
@last_jump_time = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def detect(sample:)
|
|
41
|
+
now = Time.now
|
|
42
|
+
@last_samples << { time: now, vertical_acceleration: vertical_acceleration(sample) }
|
|
43
|
+
cutoff = now - SAMPLE_BUFFER_SECONDS
|
|
44
|
+
@last_samples.shift while !@last_samples.empty? && @last_samples.first[:time] < cutoff
|
|
45
|
+
|
|
46
|
+
if !buffer_ready? || !squat_takeoff? || cooling_down?(now)
|
|
47
|
+
false
|
|
48
|
+
else
|
|
49
|
+
@last_jump_time = now
|
|
50
|
+
debug_log('JUMP_FIRED')
|
|
51
|
+
true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def vertical_acceleration(sample)
|
|
58
|
+
projection = (-sample[0] * @gravity[0] + sample[1] * @gravity[1] + sample[2] * @gravity[2]) / @gravity_magnitude
|
|
59
|
+
projection - @gravity_magnitude
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def buffer_ready? = @last_samples.size >= MIN_SAMPLES_FOR_ANALYSIS
|
|
63
|
+
def cooling_down?(now) = @last_jump_time && (now - @last_jump_time) < COOLDOWN_SECONDS
|
|
64
|
+
|
|
65
|
+
def squat_takeoff?
|
|
66
|
+
hold_seconds = last_loaded_hold_seconds
|
|
67
|
+
slope = last_slope
|
|
68
|
+
debug_log(format('vertical_acceleration=%+.2f hold=%.3f slope=%+.2f', @last_samples.last[:vertical_acceleration], hold_seconds, slope)) if hold_seconds > 0
|
|
69
|
+
|
|
70
|
+
if hold_seconds < LOADED_MIN_SECONDS
|
|
71
|
+
false
|
|
72
|
+
else
|
|
73
|
+
slope <= TAKEOFF_SLOPE_MAX
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Duration of the latest span where vertical_acceleration stayed
|
|
78
|
+
# continuously above LOADED_THRESHOLD. Returns 0 if the span reaches the
|
|
79
|
+
# buffer edge without a preceding below-threshold sample — that indicates
|
|
80
|
+
# stationary state (signal hovering near 0 continuously), not an
|
|
81
|
+
# oscillating stride.
|
|
82
|
+
# The span may still be in progress (latest sample above threshold) or
|
|
83
|
+
# just ended within the last sample — jumps launch fast enough that the
|
|
84
|
+
# signal can skip from +1g to below threshold between consecutive samples.
|
|
85
|
+
def last_loaded_hold_seconds
|
|
86
|
+
loaded_end_idx = @last_samples.rindex { |sample| sample[:vertical_acceleration] > LOADED_THRESHOLD }
|
|
87
|
+
return 0.0 unless loaded_end_idx
|
|
88
|
+
loaded_end_time = @last_samples[loaded_end_idx][:time]
|
|
89
|
+
# Too long past the span's end — no longer just after the turnover
|
|
90
|
+
return 0.0 if @last_samples.last[:time] - loaded_end_time > LOADED_END_GRACE_SECONDS
|
|
91
|
+
|
|
92
|
+
pre_load_idx = @last_samples[0...loaded_end_idx].rindex { |sample| sample[:vertical_acceleration] <= LOADED_THRESHOLD }
|
|
93
|
+
if pre_load_idx
|
|
94
|
+
loaded_begin_idx = pre_load_idx + 1
|
|
95
|
+
loaded_begin_time = @last_samples[loaded_begin_idx][:time]
|
|
96
|
+
loaded_end_time - loaded_begin_time
|
|
97
|
+
else
|
|
98
|
+
0.0
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Slope (g/s) of vertical_acceleration over the last SLOPE_WINDOW_SECONDS,
|
|
103
|
+
# measured as (latest - reference) / dt where reference is the newest
|
|
104
|
+
# sample at or before the window cutoff. A clearly negative value means
|
|
105
|
+
# the signal has peaked and started falling — the takeoff turnover.
|
|
106
|
+
def last_slope
|
|
107
|
+
window_end_sample = @last_samples.last
|
|
108
|
+
cutoff = window_end_sample[:time] - SLOPE_WINDOW_SECONDS
|
|
109
|
+
window_begin_sample = @last_samples.reverse_each.find { |sample| sample[:time] <= cutoff }
|
|
110
|
+
return 0.0 unless window_begin_sample
|
|
111
|
+
|
|
112
|
+
elapsed_seconds = window_end_sample[:time] - window_begin_sample[:time]
|
|
113
|
+
if elapsed_seconds <= 0
|
|
114
|
+
0.0
|
|
115
|
+
else
|
|
116
|
+
(window_end_sample[:vertical_acceleration] - window_begin_sample[:vertical_acceleration]) / elapsed_seconds
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def debug_log(message)
|
|
121
|
+
return unless ENV['JUMP_LOG'] == '1'
|
|
122
|
+
$stderr.puts "[JumpDetector] #{Time.now.to_f} #{message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -23,6 +23,7 @@ module RoadToRubykaigi
|
|
|
23
23
|
],
|
|
24
24
|
}
|
|
25
25
|
WALK_SOUND_INTERVAL = 0.25
|
|
26
|
+
RUN_SOUND_INTERVAL = 0.11
|
|
26
27
|
|
|
27
28
|
SOUND_FILES.keys.each do |action|
|
|
28
29
|
define_method(action) {
|
|
@@ -53,9 +54,10 @@ module RoadToRubykaigi
|
|
|
53
54
|
end
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
def walk
|
|
57
|
+
def walk(running: false)
|
|
57
58
|
now = Time.now
|
|
58
|
-
|
|
59
|
+
interval = running ? RUN_SOUND_INTERVAL : WALK_SOUND_INTERVAL
|
|
60
|
+
if (now - @last_walk_time) >= interval
|
|
59
61
|
@audio_engine.add_source(@sources[:walk][@walk_index])
|
|
60
62
|
@last_walk_time = now
|
|
61
63
|
@walk_index = (@walk_index + 1) % @sources[:walk].size
|
|
@@ -69,6 +71,10 @@ module RoadToRubykaigi
|
|
|
69
71
|
@melody_sequencer = Audio::MelodySequencer.new
|
|
70
72
|
@fanfare_sequencer = Audio::FanfareSequencer.new
|
|
71
73
|
@audio_engine = Audio::AudioEngine.new(@bass_sequencer, @melody_sequencer)
|
|
74
|
+
if Config.bgm_off?
|
|
75
|
+
@audio_engine.remove_source(@bass_sequencer)
|
|
76
|
+
@audio_engine.remove_source(@melody_sequencer)
|
|
77
|
+
end
|
|
72
78
|
@sources = SOUND_FILES
|
|
73
79
|
dir = __dir__.sub("lib/road_to_rubykaigi/manager", "")
|
|
74
80
|
@sources.each do |action, file_paths|
|
|
@@ -3,7 +3,7 @@ module RoadToRubykaigi
|
|
|
3
3
|
class GameManager
|
|
4
4
|
UPDATE_RATE = 1.0 / 10
|
|
5
5
|
FRAME_RATE = 1.0 / 60
|
|
6
|
-
GOAL_X = 650
|
|
6
|
+
GOAL_X = { 2025 => 650, 2026 => 800 }
|
|
7
7
|
DEMO_GOAL_X = 540
|
|
8
8
|
STATE = {
|
|
9
9
|
playing: 0,
|
|
@@ -15,7 +15,7 @@ module RoadToRubykaigi
|
|
|
15
15
|
attr_reader :fireworks
|
|
16
16
|
|
|
17
17
|
def self.goal_x
|
|
18
|
-
@goal_x ||= RoadToRubykaigi.demo? ? DEMO_GOAL_X : GOAL_X
|
|
18
|
+
@goal_x ||= RoadToRubykaigi.demo? ? DEMO_GOAL_X : GOAL_X[RoadToRubykaigi.version]
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def offset_x
|
|
@@ -25,6 +25,7 @@ module RoadToRubykaigi
|
|
|
25
25
|
def update
|
|
26
26
|
@deadline.activate(player_x: @player.x)
|
|
27
27
|
@enemies.activate if player_moved?
|
|
28
|
+
@score_board.start_timer if @player.x > Sprite::Player::WARMUP_END_X[RoadToRubykaigi.version]
|
|
28
29
|
if @player.x >= GameManager.goal_x && playing?
|
|
29
30
|
EventDispatcher.publish(:ending)
|
|
30
31
|
end
|
|
@@ -27,29 +27,100 @@ module RoadToRubykaigi
|
|
|
27
27
|
│。・◡・│_◢◤
|
|
28
28
|
╰ᜊ───ᜊ─╯
|
|
29
29
|
PLAYER
|
|
30
|
+
VERSION_ROW = 25
|
|
30
31
|
|
|
31
32
|
def display
|
|
32
|
-
x = 0
|
|
33
|
-
direction = 1
|
|
34
|
-
|
|
35
33
|
loop do
|
|
36
34
|
ANSI.clear
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
render
|
|
36
|
+
$stdin.raw do
|
|
37
|
+
if handle_input == :SELECTED
|
|
38
|
+
item = menu_items[@menu_index]
|
|
39
|
+
case item
|
|
40
|
+
when :calibrate
|
|
41
|
+
CalibrationScreen.new.display
|
|
42
|
+
when :input_source
|
|
43
|
+
Config.cycle_input_source
|
|
44
|
+
@menu_items = nil
|
|
45
|
+
@menu_index = menu_items.index(:input_source) || 0
|
|
46
|
+
when :open_controller
|
|
47
|
+
GameServer.start
|
|
48
|
+
GameServer.open_controller
|
|
49
|
+
else
|
|
50
|
+
return item
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
move_player
|
|
54
|
+
sleep Manager::GameManager::FRAME_RATE
|
|
46
55
|
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def initialize
|
|
62
|
+
@player_x = 0
|
|
63
|
+
@direction = 1
|
|
64
|
+
@last_move_time = Time.now
|
|
65
|
+
@menu_index = 0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def menu_items
|
|
69
|
+
return @menu_items if @menu_items
|
|
70
|
+
@menu_items = RoadToRubykaigi::VERSIONS + [:input_source]
|
|
71
|
+
@menu_items = @menu_items + [:calibrate, :open_controller] if Config.external_input?
|
|
72
|
+
@menu_items
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
INPUT_SOURCE_LABELS = { ble: 'BLE', serial: 'Serial', nil => 'Keyboard' }.freeze
|
|
76
|
+
|
|
77
|
+
def render
|
|
78
|
+
puts [
|
|
79
|
+
"\e[6;1H",
|
|
80
|
+
LOGO,
|
|
81
|
+
PLAYER.lines.map.with_index do |line, i|
|
|
82
|
+
"\e[#{i+1};#{@player_x+OFFSET}H" + line
|
|
83
|
+
end.join,
|
|
84
|
+
"\e[4;1H",
|
|
85
|
+
" Press Space to start...",
|
|
86
|
+
]
|
|
87
|
+
menu_items.each_with_index do |item, i|
|
|
88
|
+
cursor = i == @menu_index ? " -> " : " "
|
|
89
|
+
row_offset = case item
|
|
90
|
+
when :input_source, :calibrate, :open_controller then 1
|
|
91
|
+
else 0
|
|
92
|
+
end
|
|
93
|
+
label =
|
|
94
|
+
case item
|
|
95
|
+
when :calibrate then 'Calibrate sensor'
|
|
96
|
+
when :open_controller then 'Open Controller'
|
|
97
|
+
when :input_source then "Input: #{INPUT_SOURCE_LABELS[Config.input_source]}"
|
|
98
|
+
else "ver. #{RoadToRubykaigi::VERSIONS[i]}"
|
|
99
|
+
end
|
|
100
|
+
print "\e[#{VERSION_ROW + i + row_offset};1H#{cursor}#{label}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def handle_input
|
|
105
|
+
case $stdin.read_nonblock(3, exception: false)
|
|
106
|
+
when ANSI::UP
|
|
107
|
+
@menu_index = (@menu_index - 1) % menu_items.size
|
|
108
|
+
when ANSI::DOWN
|
|
109
|
+
@menu_index = (@menu_index + 1) % menu_items.size
|
|
110
|
+
when " ", ANSI::ENTER, ANSI::LF
|
|
111
|
+
:SELECTED
|
|
112
|
+
when ANSI::ETX
|
|
113
|
+
raise Interrupt
|
|
114
|
+
end
|
|
115
|
+
end
|
|
47
116
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
117
|
+
def move_player
|
|
118
|
+
if Time.now - @last_move_time >= DELAY
|
|
119
|
+
@player_x += @direction
|
|
120
|
+
if @player_x >= WIDTH || @player_x <= 0
|
|
121
|
+
@direction = -@direction
|
|
51
122
|
end
|
|
52
|
-
|
|
123
|
+
@last_move_time = Time.now
|
|
53
124
|
end
|
|
54
125
|
end
|
|
55
126
|
end
|
|
@@ -4,6 +4,10 @@ module RoadToRubykaigi
|
|
|
4
4
|
@score += 1
|
|
5
5
|
end
|
|
6
6
|
|
|
7
|
+
def start_timer
|
|
8
|
+
@start_time ||= Time.now
|
|
9
|
+
end
|
|
10
|
+
|
|
7
11
|
def render_score_board
|
|
8
12
|
"Score: #{@score}".ljust(10).rjust(Map::VIEWPORT_WIDTH)
|
|
9
13
|
end
|
|
@@ -24,10 +28,11 @@ module RoadToRubykaigi
|
|
|
24
28
|
|
|
25
29
|
def initialize
|
|
26
30
|
@score = 0
|
|
27
|
-
@start_time = Time.now
|
|
31
|
+
@start_time = Config.external_input? ? nil : Time.now
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def result_time
|
|
35
|
+
return 0.0 unless @start_time
|
|
31
36
|
(Time.now - @start_time).round(2)
|
|
32
37
|
end
|
|
33
38
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require 'singleton'
|
|
2
|
+
require 'logger'
|
|
3
|
+
require 'uart'
|
|
4
|
+
|
|
5
|
+
module RoadToRubykaigi
|
|
6
|
+
class SerialReader
|
|
7
|
+
include Singleton
|
|
8
|
+
|
|
9
|
+
BAUD = 115200
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
extend Forwardable
|
|
13
|
+
def_delegators :instance, :start, :queue, :drain
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :queue
|
|
17
|
+
|
|
18
|
+
def drain
|
|
19
|
+
until @queue.empty?
|
|
20
|
+
yield @queue.pop(true)
|
|
21
|
+
end
|
|
22
|
+
rescue ThreadError
|
|
23
|
+
# pop(true) raises if the queue empties mid-drain
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
@queue.clear
|
|
28
|
+
Config.detect_serial_port!
|
|
29
|
+
@port = Config.serial_port
|
|
30
|
+
return if @thread
|
|
31
|
+
|
|
32
|
+
@thread = Thread.new { read_loop }
|
|
33
|
+
at_exit { @thread&.kill }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@queue = Thread::Queue.new
|
|
40
|
+
log_path = Config.debug? ? File.join(Config.project_root, 'tmp/serial_reader.log') : File::NULL
|
|
41
|
+
@logger = Logger.new(log_path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read_loop
|
|
45
|
+
serial = UART.open(@port, BAUD)
|
|
46
|
+
buf = +''
|
|
47
|
+
loop do
|
|
48
|
+
chunk = serial.sysread(256)
|
|
49
|
+
buf << chunk
|
|
50
|
+
while (idx = buf.index("\n"))
|
|
51
|
+
line = buf.slice!(0..idx).strip
|
|
52
|
+
next if line.empty?
|
|
53
|
+
|
|
54
|
+
data = parse_line(line)
|
|
55
|
+
unless data.empty?
|
|
56
|
+
@logger.info(data.inspect)
|
|
57
|
+
@queue.push(data)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
rescue EOFError
|
|
61
|
+
sleep 0.05
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
$stderr.puts "[SerialReader] #{e.message}"
|
|
65
|
+
ensure
|
|
66
|
+
serial&.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_line(line)
|
|
70
|
+
line.split(',').each_with_object({}) do |pair, hash|
|
|
71
|
+
key, value = pair.split('=', 2)
|
|
72
|
+
hash[key] = value if key && value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|