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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
require 'singleton'
|
|
2
|
+
|
|
3
|
+
module RoadToRubykaigi
|
|
4
|
+
class SignalInterpreter
|
|
5
|
+
include Singleton
|
|
6
|
+
|
|
7
|
+
CONTINUATION_WINDOW_SECONDS = 0.2 # short window used for continuation detection to avoid tail smoothing
|
|
8
|
+
CONTINUATION_TIMEOUT_SECONDS = 0.8 # time without a continuation event before declaring a stop
|
|
9
|
+
SPEED_RATIO_MIN = 0.7
|
|
10
|
+
SPEED_RATIO_MAX = 2.3
|
|
11
|
+
# Assumed motions:
|
|
12
|
+
# - in-place running (high intensity, walk-level cadence)
|
|
13
|
+
# - forward running (moderate intensity, high cadence)
|
|
14
|
+
# Forward running raises cadence; in-place running raises intensity.
|
|
15
|
+
# Speed = cadence_amp + intensity_boost.
|
|
16
|
+
# cadence_amp is dominant; lifts forward-run.
|
|
17
|
+
# intensity_boost is supplementary; lifts in-place-run, whose cadence
|
|
18
|
+
# stays near walk and so cannot be picked up by cadence_amp alone.
|
|
19
|
+
CADENCE_PIVOT = 1.0 # walking reference; below passes through, above gets gain
|
|
20
|
+
CADENCE_GAIN = 4.5 # cadence ratios are narrow (~1.0-1.3), so amplify aggressively
|
|
21
|
+
INTENSITY_PIVOT = 1.1 # intensity must clearly exceed walking before contributing
|
|
22
|
+
INTENSITY_WEIGHT = 1.6 # additive boost weight for in-place run
|
|
23
|
+
SPEED_SMOOTHING_ALPHA = 0.4 # EMA weight on the newest sample; lower = smoother, laggier
|
|
24
|
+
|
|
25
|
+
# Walk states
|
|
26
|
+
STOPPED = :stopped # no walk in progress; next start flips direction
|
|
27
|
+
WALKING = :walking # continuation events arriving
|
|
28
|
+
PAUSED = :paused # continuation briefly absent; next event -> WALKING (same direction), timeout -> STOPPED
|
|
29
|
+
|
|
30
|
+
Walk = Data.define(:direction, :speed_ratio) do
|
|
31
|
+
def right? = direction == :right
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
extend Forwardable
|
|
36
|
+
def_delegators :instance, :process, :log_cue
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Drain every queued sample per tick so the pipeline rate matches the
|
|
40
|
+
# sensor rate instead of being capped at frame rate. NOTE: :input events
|
|
41
|
+
# may fire multiple times per tick — action handlers must be safe under
|
|
42
|
+
# repeated same-tick calls (idempotent or self-gated).
|
|
43
|
+
def process
|
|
44
|
+
Config.signal_source.drain do |data|
|
|
45
|
+
if (action = interpret(data))
|
|
46
|
+
EventDispatcher.publish(:input, action)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
if walk_expired?
|
|
50
|
+
stop
|
|
51
|
+
EventDispatcher.publish(:input, :stop)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def log_cue(phase, text)
|
|
56
|
+
return unless ENV['SIG_LOG'] == '1'
|
|
57
|
+
|
|
58
|
+
@sig_log_io ||= open_sig_log_io
|
|
59
|
+
@sig_log_io.puts "[cue] t=#{Time.now.to_f} phase=#{phase} #{text}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def initialize
|
|
65
|
+
@window = SignalWindow.new
|
|
66
|
+
@direction = :right
|
|
67
|
+
@state = STOPPED
|
|
68
|
+
@has_started = false
|
|
69
|
+
@last_continuation_time = nil
|
|
70
|
+
@smoothed_speed_ratio = nil
|
|
71
|
+
@start_threshold = Config.start_threshold
|
|
72
|
+
@continuation_threshold = Config.continuation_threshold
|
|
73
|
+
@walk_cadence = Config.walk_cadence
|
|
74
|
+
@walk_intensity = Config.walk_intensity
|
|
75
|
+
@jump_detector = JumpDetector.new(gravity: Config.gravity_vector)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def interpret(data)
|
|
79
|
+
return unless %w[x y z].all? { |key| data.key?(key) }
|
|
80
|
+
|
|
81
|
+
buffer_sample(data)
|
|
82
|
+
return unless window_full?
|
|
83
|
+
|
|
84
|
+
was_walking = walking?
|
|
85
|
+
track_continuation
|
|
86
|
+
update_speed_ratio
|
|
87
|
+
update_walking_state
|
|
88
|
+
@direction = data['b'] == '1' ? :left : :right
|
|
89
|
+
log_signal
|
|
90
|
+
|
|
91
|
+
if jump_detected?
|
|
92
|
+
EventDispatcher.publish(:input, :jump)
|
|
93
|
+
end
|
|
94
|
+
if was_walking && !walking?
|
|
95
|
+
:stop
|
|
96
|
+
elsif walking?
|
|
97
|
+
Walk.new(direction: @direction, speed_ratio: @smoothed_speed_ratio)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def buffer_sample(data)
|
|
102
|
+
@window.buffer_sample([data['x'].to_f, data['y'].to_f, data['z'].to_f])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def track_continuation
|
|
106
|
+
@last_continuation_time = Time.now if continuing?
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# EMA-smoothed mapping of motion strength to output speed. Without
|
|
110
|
+
# smoothing, small frame-to-frame bumps make the speed flicker.
|
|
111
|
+
def update_speed_ratio
|
|
112
|
+
instant = instantaneous_speed_ratio
|
|
113
|
+
@smoothed_speed_ratio ||= instant
|
|
114
|
+
@smoothed_speed_ratio = @smoothed_speed_ratio * (1 - SPEED_SMOOTHING_ALPHA) + instant * SPEED_SMOOTHING_ALPHA
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def instantaneous_speed_ratio
|
|
118
|
+
return 1.0 unless @walk_intensity && @walk_intensity > 0
|
|
119
|
+
|
|
120
|
+
(@window.motion_intensity / @walk_intensity).clamp(SPEED_RATIO_MIN, SPEED_RATIO_MAX)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def jump_detected?
|
|
124
|
+
@jump_detector.detect(sample: @window.last_sample)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def update_walking_state
|
|
128
|
+
case
|
|
129
|
+
when stopped? && walk_started? then start
|
|
130
|
+
when walking? && !continuing? then pause
|
|
131
|
+
when paused? && continuing? then unpause
|
|
132
|
+
when paused? && continuation_timed_out? then stop
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def start
|
|
137
|
+
# @direction = (@direction == :right ? :left : :right) if @has_started
|
|
138
|
+
@has_started = true
|
|
139
|
+
@state = WALKING
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def window_full? = @window.full?
|
|
143
|
+
def stopped? = @state == STOPPED
|
|
144
|
+
def walking? = @state == WALKING
|
|
145
|
+
def unpause = @state = WALKING
|
|
146
|
+
def paused? = @state == PAUSED
|
|
147
|
+
def pause = @state = PAUSED
|
|
148
|
+
def stop = @state = STOPPED
|
|
149
|
+
def continuation_timed_out?
|
|
150
|
+
return false if @last_continuation_time.nil?
|
|
151
|
+
(Time.now - @last_continuation_time) > CONTINUATION_TIMEOUT_SECONDS
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# True when a walk is in progress but the continuation timeout has elapsed.
|
|
155
|
+
# The normal PAUSED -> STOPPED transition lives inside interpret(), so it
|
|
156
|
+
# only fires while samples are flowing. When the stream dries up mid-walk
|
|
157
|
+
# (device off, BLE buffer drained, stale-drop skipping every sample), this
|
|
158
|
+
# predicate lets the caller emit :stop on a pure tick basis instead.
|
|
159
|
+
def walk_expired? = !stopped? && continuation_timed_out?
|
|
160
|
+
|
|
161
|
+
# Start detection uses the full window so that a single noisy sample
|
|
162
|
+
# cannot trigger a fake walk start.
|
|
163
|
+
def walk_started? = @window.full.motion_intensity > @start_threshold
|
|
164
|
+
|
|
165
|
+
# Short-window intensity used for continuation detection. Shorter than the
|
|
166
|
+
# main window so that the signal drops quickly after motion stops, making
|
|
167
|
+
# stop detection responsive.
|
|
168
|
+
def continuing? = @window.tail(seconds: CONTINUATION_WINDOW_SECONDS).motion_intensity > @continuation_threshold
|
|
169
|
+
|
|
170
|
+
def log_signal
|
|
171
|
+
return unless ENV['SIG_LOG'] == '1'
|
|
172
|
+
|
|
173
|
+
@sig_log_io ||= open_sig_log_io
|
|
174
|
+
full = @window.motion_intensity
|
|
175
|
+
tail = @window.tail(seconds: CONTINUATION_WINDOW_SECONDS).motion_intensity
|
|
176
|
+
axes = @window.axis_intensities
|
|
177
|
+
sum = axes.sum
|
|
178
|
+
ratio = sum.zero? ? 0.0 : axes.max / sum
|
|
179
|
+
vx, vy, vz = axes.map { |value| value.round(6) }
|
|
180
|
+
cadence = @window.cadence_hz.round(4)
|
|
181
|
+
instant = instantaneous_speed_ratio.round(4)
|
|
182
|
+
speed = @smoothed_speed_ratio.round(4)
|
|
183
|
+
mag = @window.last_magnitude.round(6)
|
|
184
|
+
jerk = @window.mag_jerk.round(6)
|
|
185
|
+
vertical_acceleration = @window.last_vertical_acceleration(Config.gravity_vector).round(6)
|
|
186
|
+
x, y, z = @window.last_sample.map { |value| value.round(6) }
|
|
187
|
+
@sig_log_io.puts "#{Time.now.to_f},#{full.round(6)},#{tail.round(6)},#{ratio.round(4)},#{vx},#{vy},#{vz},#{cadence},#{instant},#{speed},#{@state},#{mag},#{jerk},#{vertical_acceleration},#{x},#{y},#{z}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def open_sig_log_io
|
|
191
|
+
path = File.join(File.expand_path('../../tmp', __dir__), "sig_#{Time.now.strftime('%Y%m%d_%H%M')}.log")
|
|
192
|
+
file = File.open(path, 'w')
|
|
193
|
+
file.sync = true
|
|
194
|
+
file.puts "t,full,tail,ratio,var_x,var_y,var_z,cadence,instant,speed,state,mag,jerk,vertical_acceleration,x,y,z"
|
|
195
|
+
$stderr.puts "[SIG_LOG] writing to #{path}"
|
|
196
|
+
file
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
module RoadToRubykaigi
|
|
2
|
+
class SignalWindow
|
|
3
|
+
BUFFER_SECONDS = 0.5
|
|
4
|
+
READY_FILL_RATIO = 0.8 # window must be 80% filled (by time) before considered ready
|
|
5
|
+
|
|
6
|
+
StepCadence = Data.define(:recent_magnitudes) do
|
|
7
|
+
WINDOW_SECONDS = 2.0
|
|
8
|
+
MIN_STEP_INTERVAL_SECONDS = 0.15 # min gap between steps (caps cadence at ~6.7Hz)
|
|
9
|
+
MIN_SAMPLES_FOR_LOCAL_MAXIMUM = 3 # need prev/current/next to detect a local maximum
|
|
10
|
+
|
|
11
|
+
def record(sample)
|
|
12
|
+
magnitude = Math.sqrt(sample[0] ** 2 + sample[1] ** 2 + sample[2] ** 2)
|
|
13
|
+
now = Time.now
|
|
14
|
+
recent_magnitudes << { time: now, magnitude: magnitude }
|
|
15
|
+
window_start = now - WINDOW_SECONDS
|
|
16
|
+
recent_magnitudes.shift while recent_magnitudes.first[:time] < window_start
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Step cadence in Hz: count of local maxima in the recent magnitudes divided
|
|
20
|
+
# by the window duration. Walking produce one peak per footstrike, so cadence tracks step frequency.
|
|
21
|
+
def hz
|
|
22
|
+
return 0.0 if recent_magnitudes.size < MIN_SAMPLES_FOR_LOCAL_MAXIMUM
|
|
23
|
+
|
|
24
|
+
magnitudes = recent_magnitudes.map { |entry| entry[:magnitude] }
|
|
25
|
+
mean = magnitudes.sum / magnitudes.size
|
|
26
|
+
step_count = 0
|
|
27
|
+
last_step_time = nil
|
|
28
|
+
(1...(recent_magnitudes.size - 1)).each do |i|
|
|
29
|
+
time = recent_magnitudes[i][:time]
|
|
30
|
+
magnitude = recent_magnitudes[i][:magnitude]
|
|
31
|
+
prev_magnitude = recent_magnitudes[i - 1][:magnitude]
|
|
32
|
+
next_magnitude = recent_magnitudes[i + 1][:magnitude]
|
|
33
|
+
is_local_maximum = magnitude > prev_magnitude && magnitude > next_magnitude
|
|
34
|
+
above_mean = magnitude > mean # only count bumps stronger than the average
|
|
35
|
+
enough_gap_from_last_step = last_step_time.nil? || (time - last_step_time) >= MIN_STEP_INTERVAL_SECONDS
|
|
36
|
+
if is_local_maximum && above_mean && enough_gap_from_last_step
|
|
37
|
+
step_count += 1
|
|
38
|
+
last_step_time = time
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
duration = recent_magnitudes.last[:time] - recent_magnitudes.first[:time]
|
|
43
|
+
return 0.0 if duration <= 0 # guard against division by zero (just in case)
|
|
44
|
+
|
|
45
|
+
step_count / duration
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ready?
|
|
49
|
+
return false if recent_magnitudes.size < MIN_SAMPLES_FOR_LOCAL_MAXIMUM
|
|
50
|
+
(recent_magnitudes.last[:time] - recent_magnitudes.first[:time]) >= WINDOW_SECONDS * READY_FILL_RATIO
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def buffer_sample(sample)
|
|
55
|
+
now = Time.now
|
|
56
|
+
@samples << { time: now, sample: sample }
|
|
57
|
+
window_start = now - BUFFER_SECONDS
|
|
58
|
+
@samples.shift while @samples.first[:time] < window_start
|
|
59
|
+
@step_cadence.record(sample)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def cadence_hz = @step_cadence.hz
|
|
63
|
+
def cadence_ready? = @step_cadence.ready?
|
|
64
|
+
|
|
65
|
+
def full?
|
|
66
|
+
return false if @samples.size < 2
|
|
67
|
+
(@samples.last[:time] - @samples.first[:time]) >= BUFFER_SECONDS * READY_FILL_RATIO
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns how far samples in the window spread from their mean position
|
|
71
|
+
# (RMS distance across all 3 axes).
|
|
72
|
+
def motion_intensity
|
|
73
|
+
Math.sqrt(axis_variance(0) + axis_variance(1) + axis_variance(2))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Per-axis RMS spread. Same unit as motion_intensity but kept separate so
|
|
77
|
+
# callers can compare how energy is distributed across axes.
|
|
78
|
+
def axis_intensities
|
|
79
|
+
[0, 1, 2].map { |index| Math.sqrt(axis_variance(index)) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Raw acceleration magnitude of the latest sample: sqrt(x² + y² + z²).
|
|
83
|
+
def last_magnitude
|
|
84
|
+
sample = @samples.last[:sample]
|
|
85
|
+
Math.sqrt(sample[0] ** 2 + sample[1] ** 2 + sample[2] ** 2)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Raw [x, y, z] of the latest sample (before variance/intensity).
|
|
89
|
+
def last_sample
|
|
90
|
+
@samples.last[:sample]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Signed vertical acceleration of the latest sample after removing
|
|
94
|
+
# gravity. Positive = accelerating upward, negative = downward.
|
|
95
|
+
def last_vertical_acceleration(gravity)
|
|
96
|
+
sample = @samples.last[:sample]
|
|
97
|
+
# Magnitude of the gravity reference vector (used to normalize the
|
|
98
|
+
# projection below and as the resting 1g offset).
|
|
99
|
+
gravity_magnitude = Math.sqrt(gravity[0] ** 2 + gravity[1] ** 2 + gravity[2] ** 2)
|
|
100
|
+
# Sample projected onto the vertical axis, normalized to g units.
|
|
101
|
+
projection = (sample[0] * gravity[0] + sample[1] * gravity[1] + sample[2] * gravity[2]) / gravity_magnitude
|
|
102
|
+
# Subtract the resting offset.
|
|
103
|
+
projection - gravity_magnitude
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Average absolute change in magnitude between consecutive samples.
|
|
107
|
+
# Walking/running produce sharp footstrike impacts (high jerk),
|
|
108
|
+
# while jumping produces smoother acceleration curves (low jerk).
|
|
109
|
+
def mag_jerk
|
|
110
|
+
mags = @samples.map { |entry| s = entry[:sample]; Math.sqrt(s[0] ** 2 + s[1] ** 2 + s[2] ** 2) }
|
|
111
|
+
deltas = mags.each_cons(2).map { |a, b| (b - a).abs }
|
|
112
|
+
deltas.sum / deltas.size
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def full
|
|
116
|
+
self
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sub-window containing samples from the last `seconds`, for continuation detection.
|
|
120
|
+
def tail(seconds:)
|
|
121
|
+
return SignalWindow.new([]) if @samples.empty?
|
|
122
|
+
cutoff = @samples.last[:time] - seconds
|
|
123
|
+
SignalWindow.new(@samples.select { |entry| entry[:time] >= cutoff })
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def initialize(samples = [])
|
|
129
|
+
@samples = samples
|
|
130
|
+
@step_cadence = StepCadence.new(recent_magnitudes: [])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def axis_variance(index)
|
|
134
|
+
values = @samples.map { |entry| entry[:sample][index] }
|
|
135
|
+
mean = values.sum / values.size
|
|
136
|
+
values.sum { |value| (value - mean) ** 2 } / values.size
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -6,50 +6,87 @@ module RoadToRubykaigi
|
|
|
6
6
|
extend Forwardable
|
|
7
7
|
def_delegators :@bonuses, :to_a, :find, :delete
|
|
8
8
|
BONUSES_DATA = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
9
|
+
2025 => {
|
|
10
|
+
Basic: [
|
|
11
|
+
{ x: 39, y: 22, character: :ruby },
|
|
12
|
+
{ x: 46, y: 22, character: :ruby },
|
|
13
|
+
{ x: 53, y: 22, character: :ruby },
|
|
14
|
+
{ x: 107, y: 23, character: :coffee },
|
|
15
|
+
{ x: 110, y: 23, character: :book },
|
|
16
|
+
{ x: 142, y: 16, character: :ruby },
|
|
17
|
+
{ x: 146, y: 16, character: :ruby },
|
|
18
|
+
{ x: 205, y: 19, character: :money },
|
|
19
|
+
{ x: 212, y: 19, character: :money },
|
|
20
|
+
{ x: 205, y: 19, character: :money },
|
|
21
|
+
{ x: 212, y: 19, character: :money },
|
|
22
|
+
{ x: 223, y: 17, character: :money },
|
|
23
|
+
{ x: 231, y: 17, character: :money },
|
|
24
|
+
{ x: 243, y: 13, character: :money },
|
|
25
|
+
{ x: 250, y: 13, character: :money },
|
|
26
|
+
{ x: 260, y: 10, character: :sushi },
|
|
27
|
+
{ x: 265, y: 10, character: :meat },
|
|
28
|
+
{ x: 270, y: 10, character: :fish },
|
|
29
|
+
{ x: 260, y: 10, character: :sushi },
|
|
30
|
+
{ x: 265, y: 10, character: :meat },
|
|
31
|
+
{ x: 270, y: 10, character: :fish },
|
|
32
|
+
{ x: 275, y: 10, character: :sushi },
|
|
33
|
+
{ x: 280, y: 10, character: :meat },
|
|
34
|
+
{ x: 285, y: 10, character: :fish },
|
|
35
|
+
{ x: 290, y: 10, character: :sushi },
|
|
36
|
+
{ x: 295, y: 10, character: :meat },
|
|
37
|
+
{ x: 300, y: 10, character: :fish },
|
|
38
|
+
{ x: 358, y: 15, character: :money },
|
|
39
|
+
{ x: 363, y: 13, character: :money },
|
|
40
|
+
{ x: 368, y: 15, character: :money },
|
|
41
|
+
{ x: 373, y: 13, character: :money },
|
|
42
|
+
{ x: 378, y: 15, character: :money },
|
|
43
|
+
{ x: 383, y: 13, character: :money },
|
|
44
|
+
{ x: 388, y: 15, character: :money },
|
|
45
|
+
],
|
|
46
|
+
Alcohol: [
|
|
47
|
+
{ x: 217, y: 28, character: :beer },
|
|
48
|
+
{ x: 220, y: 28, character: :beer },
|
|
49
|
+
{ x: 223, y: 28, character: :beer },
|
|
50
|
+
],
|
|
51
|
+
Laptop: [
|
|
52
|
+
{ x: 298, y: 23, character: :laptop },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
2026 => {
|
|
56
|
+
Basic: [
|
|
57
|
+
{ x: 60, y: 25, character: :ruby },
|
|
58
|
+
{ x: 67, y: 25, character: :ruby },
|
|
59
|
+
{ x: 74, y: 25, character: :ruby },
|
|
60
|
+
{ x: 107, y: 23, character: :coffee },
|
|
61
|
+
{ x: 110, y: 23, character: :book },
|
|
62
|
+
{ x: 140, y: 13, character: :ruby },
|
|
63
|
+
{ x: 167, y: 13, character: :ruby },
|
|
64
|
+
{ x: 182, y: 13, character: :ruby },
|
|
65
|
+
|
|
66
|
+
{ x: 240, y: 20, character: :money },
|
|
67
|
+
{ x: 260, y: 18, character: :money },
|
|
68
|
+
{ x: 279, y: 16, character: :money },
|
|
69
|
+
|
|
70
|
+
{ x: 315, y: 18, character: :sushi },
|
|
71
|
+
{ x: 320, y: 18, character: :meat },
|
|
72
|
+
{ x: 325, y: 18, character: :fish },
|
|
73
|
+
{ x: 330, y: 18, character: :sushi },
|
|
74
|
+
{ x: 335, y: 18, character: :meat },
|
|
75
|
+
|
|
76
|
+
{ x: 390, y: 13, character: :money },
|
|
77
|
+
{ x: 399, y: 14, character: :money },
|
|
78
|
+
{ x: 406, y: 14, character: :money },
|
|
79
|
+
{ x: 415, y: 14, character: :money },
|
|
80
|
+
|
|
81
|
+
{ x: 600, y: 15, character: :sakura },
|
|
82
|
+
{ x: 613, y: 13, character: :sakura },
|
|
83
|
+
{ x: 626, y: 11, character: :sakura },
|
|
84
|
+
{ x: 713, y: 11, character: :sakura },
|
|
85
|
+
|
|
86
|
+
],
|
|
87
|
+
Alcohol: [],
|
|
88
|
+
Laptop: [],
|
|
89
|
+
},
|
|
53
90
|
}
|
|
54
91
|
|
|
55
92
|
def build_buffer(offset_x:)
|
|
@@ -73,7 +110,7 @@ module RoadToRubykaigi
|
|
|
73
110
|
private
|
|
74
111
|
|
|
75
112
|
def initialize
|
|
76
|
-
@bonuses = BONUSES_DATA.map do |key, bonuses|
|
|
113
|
+
@bonuses = BONUSES_DATA[RoadToRubykaigi.version].map do |key, bonuses|
|
|
77
114
|
bonuses.map do |bonus|
|
|
78
115
|
Bonus.new(
|
|
79
116
|
bonus[:x],
|
|
@@ -94,6 +131,7 @@ module RoadToRubykaigi
|
|
|
94
131
|
sushi: "🍣",
|
|
95
132
|
meat: "🍖",
|
|
96
133
|
fish: "🐟",
|
|
134
|
+
sakura: "🌸",
|
|
97
135
|
beer: "🍺",
|
|
98
136
|
sake: "🍶",
|
|
99
137
|
laptop: "💻",
|
|
@@ -2,7 +2,7 @@ module RoadToRubykaigi
|
|
|
2
2
|
module Sprite
|
|
3
3
|
class Deadline < Sprite
|
|
4
4
|
DEADLINE_SPEED = 0.3
|
|
5
|
-
DEADLINE_START_X = 18
|
|
5
|
+
DEADLINE_START_X = { 2025 => 18, 2026 => 48 }
|
|
6
6
|
|
|
7
7
|
attr_reader :x, :y, :width, :height
|
|
8
8
|
|
|
@@ -35,7 +35,7 @@ module RoadToRubykaigi
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def activate(player_x:)
|
|
38
|
-
@waiting = false if player_x > DEADLINE_START_X
|
|
38
|
+
@waiting = false if player_x > DEADLINE_START_X[RoadToRubykaigi.version]
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
private
|
|
@@ -6,26 +6,33 @@ module RoadToRubykaigi
|
|
|
6
6
|
extend Forwardable
|
|
7
7
|
def_delegators :@enemies, :to_a, :find, :delete, :each
|
|
8
8
|
ENEMIES_DATA = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
9
|
+
2025 => {
|
|
10
|
+
FixedPatrol: [
|
|
11
|
+
{ x: 55, y: 6, left_bound: 0, right_bound: 0, speed: 0, character: :ladybug },
|
|
12
|
+
{ x: 125, y: 8, left_bound: 0, right_bound: 0, speed: 0, character: :ladybug },
|
|
13
|
+
{ x: 293, y: 23, left_bound: 0, right_bound: 0, speed: 0, character: :spider },
|
|
14
|
+
],
|
|
15
|
+
HorizontalPatrol: [
|
|
16
|
+
{ x: 123, y: 26, left_bound: 114, right_bound: 123, speed: 1.5, character: :bee },
|
|
17
|
+
{ x: 171, y: 26, left_bound: 162, right_bound: 171, speed: 1.5, character: :bee },
|
|
18
|
+
{ x: 278, y: 15, left_bound: 270, right_bound: 278, speed: 1.5, character: :bug },
|
|
19
|
+
{ x: 291, y: 15, left_bound: 283, right_bound: 291, speed: 1.5, character: :bug },
|
|
20
|
+
{ x: 302, y: 15, left_bound: 297, right_bound: 302, speed: 1.5, character: :bug },
|
|
21
|
+
],
|
|
22
|
+
ScreenEntryPatrol: [
|
|
23
|
+
{ x: 63, y: 27, left_bound: 0, right_bound: 63, speed: 4.0, character: :bug },
|
|
24
|
+
{ x: 76, y: 27, left_bound: 0, right_bound: 76, speed: 4.0, character: :bug },
|
|
25
|
+
{ x: 87, y: 27, left_bound: 0, right_bound: 76, speed: 4.0, character: :bug },
|
|
26
|
+
{ x: 221, y: 23, left_bound: 0, right_bound: 151, speed: 6.0, character: :bee },
|
|
27
|
+
{ x: 240, y: 19, left_bound: 0, right_bound: 170, speed: 6.0, character: :bee },
|
|
28
|
+
{ x: 256, y: 16, left_bound: 0, right_bound: 186, speed: 6.0, character: :bee },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
2026 => {
|
|
32
|
+
FixedPatrol: [],
|
|
33
|
+
HorizontalPatrol: [],
|
|
34
|
+
ScreenEntryPatrol: [],
|
|
35
|
+
},
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
def build_buffer(offset_x:)
|
|
@@ -62,7 +69,7 @@ module RoadToRubykaigi
|
|
|
62
69
|
private
|
|
63
70
|
|
|
64
71
|
def initialize
|
|
65
|
-
@enemies = ENEMIES_DATA.map do |key, enemies|
|
|
72
|
+
@enemies = ENEMIES_DATA[RoadToRubykaigi.version].map do |key, enemies|
|
|
66
73
|
strategy = RoadToRubykaigi::Sprite.const_get("#{key}Strategy")
|
|
67
74
|
enemies.map do |enemy|
|
|
68
75
|
Enemy.new(
|
|
@@ -7,9 +7,14 @@ module RoadToRubykaigi
|
|
|
7
7
|
WALK_ACCEL = 15.0
|
|
8
8
|
WALK_MAX_SPEED = 20.0
|
|
9
9
|
WALK_FRICTION = 1.0
|
|
10
|
+
RUNNING_SPEED_THRESHOLD = 1.3
|
|
11
|
+
RUNNING_SUSTAIN_SECOND = 0.3
|
|
10
12
|
|
|
11
|
-
INITIAL_X = 10
|
|
13
|
+
INITIAL_X = { 2025 => 10, 2026 => 40 }
|
|
14
|
+
WARMUP_END_X = { 2025 => 18, 2026 => 48 }
|
|
15
|
+
WARMUP_SPEED_RATIO_CAP = 1.0
|
|
12
16
|
BASE_Y = 26
|
|
17
|
+
INITIAL_Y = { 2025 => BASE_Y, 2026 => 15 }
|
|
13
18
|
BASE_HEIGHT = 3
|
|
14
19
|
JUMP_INITIAL_VELOCITY = -40.0
|
|
15
20
|
JUMP_GRAVITY = 80.0
|
|
@@ -23,12 +28,23 @@ module RoadToRubykaigi
|
|
|
23
28
|
RIGHT = 1
|
|
24
29
|
LEFT = -1
|
|
25
30
|
|
|
26
|
-
def right
|
|
27
|
-
move(RIGHT)
|
|
31
|
+
def right(speed_ratio = 1.0)
|
|
32
|
+
move(RIGHT, speed_ratio)
|
|
28
33
|
end
|
|
29
34
|
|
|
30
|
-
def left
|
|
31
|
-
move(LEFT)
|
|
35
|
+
def left(speed_ratio = 1.0)
|
|
36
|
+
move(in_warmup? ? RIGHT : LEFT, speed_ratio)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param [SignalInterpreter::Walk] action
|
|
40
|
+
def walk(action)
|
|
41
|
+
action.right? ? right(action.speed_ratio) : left(action.speed_ratio)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stop
|
|
45
|
+
@vx = 0
|
|
46
|
+
@last_speed_ratio = 1.0
|
|
47
|
+
@fast_speed_since = nil
|
|
32
48
|
end
|
|
33
49
|
|
|
34
50
|
def jump
|
|
@@ -182,8 +198,8 @@ module RoadToRubykaigi
|
|
|
182
198
|
private
|
|
183
199
|
|
|
184
200
|
def initialize
|
|
185
|
-
@x = INITIAL_X
|
|
186
|
-
@y =
|
|
201
|
+
@x = INITIAL_X[RoadToRubykaigi.version]
|
|
202
|
+
@y = INITIAL_Y[RoadToRubykaigi.version]
|
|
187
203
|
@vx = 0.0
|
|
188
204
|
@vy = 0.0
|
|
189
205
|
@width ||= {}
|
|
@@ -198,15 +214,25 @@ module RoadToRubykaigi
|
|
|
198
214
|
@stunned_until = Time.now
|
|
199
215
|
@attack_mode = false
|
|
200
216
|
@last_attack_time = Time.now
|
|
217
|
+
@last_speed_ratio = 1.0
|
|
218
|
+
@fast_speed_since = nil
|
|
201
219
|
end
|
|
202
220
|
|
|
203
|
-
def move(dx)
|
|
221
|
+
def move(dx, speed_ratio = 1.0)
|
|
222
|
+
speed_ratio = [speed_ratio, WARMUP_SPEED_RATIO_CAP].min if in_warmup?
|
|
204
223
|
unless current_direction == dx
|
|
205
224
|
@vx = 0
|
|
206
225
|
end
|
|
207
226
|
@vx += WALK_ACCEL * dx
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
max_speed = WALK_MAX_SPEED * speed_ratio
|
|
228
|
+
@vx = @vx.clamp(-max_speed, max_speed)
|
|
229
|
+
@last_speed_ratio = speed_ratio
|
|
230
|
+
if speed_ratio >= RUNNING_SPEED_THRESHOLD
|
|
231
|
+
@fast_speed_since ||= Time.now
|
|
232
|
+
else
|
|
233
|
+
@fast_speed_since = nil
|
|
234
|
+
end
|
|
235
|
+
Manager::AudioManager.instance.walk(running: running?)
|
|
210
236
|
end
|
|
211
237
|
|
|
212
238
|
def fall
|
|
@@ -237,12 +263,25 @@ module RoadToRubykaigi
|
|
|
237
263
|
|
|
238
264
|
def current_character
|
|
239
265
|
posture = crouching? ? :crouching : :standup
|
|
240
|
-
status = stunned? ? :stunned : :normal
|
|
266
|
+
status = stunned? ? :stunned : running? ? :running : :normal
|
|
241
267
|
Graphics::Player.character(
|
|
242
|
-
posture
|
|
268
|
+
posture:, status:, direction: current_direction, attack_mode: @attack_mode
|
|
243
269
|
)
|
|
244
270
|
end
|
|
245
271
|
|
|
272
|
+
def running? = !crouching? && @fast_speed_since && (Time.now - @fast_speed_since) >= RUNNING_SUSTAIN_SECOND
|
|
273
|
+
|
|
274
|
+
def in_warmup?
|
|
275
|
+
if @warmed_up || !Config.external_input?
|
|
276
|
+
false
|
|
277
|
+
elsif @x > WARMUP_END_X[RoadToRubykaigi.version]
|
|
278
|
+
@warmed_up = true
|
|
279
|
+
false
|
|
280
|
+
else
|
|
281
|
+
true
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
246
285
|
def jumping?
|
|
247
286
|
@jumping
|
|
248
287
|
end
|