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
@@ -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
- Basic: [
10
- { x: 39, y: 22, character: :ruby },
11
- { x: 46, y: 22, character: :ruby },
12
- { x: 53, y: 22, character: :ruby },
13
- { x: 107, y: 23, character: :coffee },
14
- { x: 110, y: 23, character: :book },
15
- { x: 142, y: 16, character: :ruby },
16
- { x: 146, y: 16, character: :ruby },
17
- { x: 205, y: 19, character: :money },
18
- { x: 212, y: 19, character: :money },
19
- { x: 205, y: 19, character: :money },
20
- { x: 212, y: 19, character: :money },
21
- { x: 223, y: 17, character: :money },
22
- { x: 231, y: 17, character: :money },
23
- { x: 243, y: 13, character: :money },
24
- { x: 250, y: 13, character: :money },
25
- { x: 260, y: 10, character: :sushi },
26
- { x: 265, y: 10, character: :meat },
27
- { x: 270, y: 10, character: :fish },
28
- { x: 260, y: 10, character: :sushi },
29
- { x: 265, y: 10, character: :meat },
30
- { x: 270, y: 10, character: :fish },
31
- { x: 275, y: 10, character: :sushi },
32
- { x: 280, y: 10, character: :meat },
33
- { x: 285, y: 10, character: :fish },
34
- { x: 290, y: 10, character: :sushi },
35
- { x: 295, y: 10, character: :meat },
36
- { x: 300, y: 10, character: :fish },
37
- { x: 358, y: 15, character: :money },
38
- { x: 363, y: 13, character: :money },
39
- { x: 368, y: 15, character: :money },
40
- { x: 373, y: 13, character: :money },
41
- { x: 378, y: 15, character: :money },
42
- { x: 383, y: 13, character: :money },
43
- { x: 388, y: 15, character: :money },
44
- ],
45
- Alcohol: [
46
- { x: 217, y: 28, character: :beer },
47
- { x: 220, y: 28, character: :beer },
48
- { x: 223, y: 28, character: :beer },
49
- ],
50
- Laptop: [
51
- { x: 298, y: 23, character: :laptop },
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
- FixedPatrol: [
10
- { x: 55, y: 6, left_bound: 0, right_bound: 0, speed: 0, character: :ladybug },
11
- { x: 125, y: 8, left_bound: 0, right_bound: 0, speed: 0, character: :ladybug },
12
- { x: 293, y: 23, left_bound: 0, right_bound: 0, speed: 0, character: :spider },
13
- ],
14
- HorizontalPatrol: [
15
- { x: 123, y: 26, left_bound: 114, right_bound: 123, speed: 1.5, character: :bee },
16
- { x: 171, y: 26, left_bound: 162, right_bound: 171, speed: 1.5, character: :bee },
17
- { x: 278, y: 15, left_bound: 270, right_bound: 278, speed: 1.5, character: :bug },
18
- { x: 291, y: 15, left_bound: 283, right_bound: 291, speed: 1.5, character: :bug },
19
- { x: 302, y: 15, left_bound: 297, right_bound: 302, speed: 1.5, character: :bug },
20
- ],
21
- ScreenEntryPatrol: [
22
- { x: 63, y: 27, left_bound: 0, right_bound: 63, speed: 4.0, character: :bug },
23
- { x: 76, y: 27, left_bound: 0, right_bound: 76, speed: 4.0, character: :bug },
24
- { x: 87, y: 27, left_bound: 0, right_bound: 76, speed: 4.0, character: :bug },
25
- { x: 221, y: 23, left_bound: 0, right_bound: 151, speed: 6.0, character: :bee },
26
- { x: 240, y: 19, left_bound: 0, right_bound: 170, speed: 6.0, character: :bee },
27
- { x: 256, y: 16, left_bound: 0, right_bound: 186, speed: 6.0, character: :bee },
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 = BASE_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
- @vx = @vx.clamp(-WALK_MAX_SPEED, WALK_MAX_SPEED)
209
- Manager::AudioManager.instance.walk
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: posture, status: status, direction: current_direction, attack_mode: @attack_mode
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