sscharter 0.9.0 → 0.10.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.
@@ -0,0 +1,334 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Sunniesnow::Charter
4
+
5
+ using Sunniesnow::Utils
6
+
7
+ class OffsetError < StandardError
8
+ # @param method_name [Symbol]
9
+ def initialize method_name
10
+ super "offset must be set before using #{method_name}"
11
+ end
12
+ end
13
+
14
+ class BpmChangeList
15
+
16
+ class BpmChange
17
+
18
+ # @return [Rational]
19
+ attr_accessor :beat
20
+
21
+ # @return [Float]
22
+ attr_accessor :bps
23
+
24
+ # @param beat [Rational]
25
+ # @param bpm [Float]
26
+ def initialize beat, bpm
27
+ @beat = beat
28
+ @bps = bpm / 60.0
29
+ end
30
+
31
+ # @return [Float]
32
+ def bpm
33
+ @bps * 60.0
34
+ end
35
+
36
+ # @param bpm [Float]
37
+ # @return [Float]
38
+ def bpm= bpm
39
+ @bps = bpm / 60.0
40
+ end
41
+
42
+ # @param other [BpmChange]
43
+ # @return [-1,0,1]
44
+ def <=> other
45
+ @beat <=> other.beat
46
+ end
47
+ end
48
+
49
+ include Enumerable
50
+
51
+ # @return [Float]
52
+ attr_accessor :offset
53
+
54
+ # @param offset [Float]
55
+ def initialize offset
56
+ @offset = offset
57
+ @list = []
58
+ end
59
+
60
+ # @param beat [Rational]
61
+ # @param bpm [Float]
62
+ # @return [BpmChangeList] +self+.
63
+ def add beat, bpm
64
+ if index = @list.bsearch_index { beat <=> _1.beat }
65
+ @list[index].bpm = bpm
66
+ else
67
+ @list.push BpmChange.new beat, bpm
68
+ @list.sort!
69
+ end
70
+ self
71
+ end
72
+
73
+ # @param beat [Rational]
74
+ # @return [Float]
75
+ def time_at beat
76
+ index = @list.bisect(right: true) { _1.beat <=> beat }
77
+ raise ArgumentError, 'beat is before the first bpm change' if index < 0
78
+ bpm = @list[index]
79
+ (0...index).sum @offset + (beat - bpm.beat) / bpm.bps do |i|
80
+ bpm = @list[i]
81
+ (@list[i+1].beat - bpm.beat) / bpm.bps
82
+ end
83
+ end
84
+
85
+ # @param beat [Rational]
86
+ # @return [Float]
87
+ def bps_before beat
88
+ raise ArgumentError, 'beat is before or at the first bpm change' if beat <= @list.first.beat
89
+ @list[@list.bisect(right: false) { _1.beat <=> beat } - 1].bps
90
+ end
91
+
92
+ # @param beat [Rational]
93
+ # @return [Float]
94
+ def bps_after beat
95
+ raise ArgumentError, 'beat is before the first bpm change' if beat < @list.first.beat
96
+ @list[@list.bisect(right: true) { _1.beat <=> beat }].bps
97
+ end
98
+
99
+ # @param index [Integer]
100
+ # @return [BpmChange?]
101
+ def [] index
102
+ @list[index]
103
+ end
104
+
105
+ # @yieldparam [BpmChange]
106
+ def each(...)
107
+ @list.each(...)
108
+ self
109
+ end
110
+ end
111
+
112
+ # @note Internal API.
113
+ # @!parse
114
+ # class BeatState < Data
115
+ # # @return [Rational]
116
+ # attr_reader :current_beat
117
+ # # @return [BpmChangeList]
118
+ # attr_reader :bpm_changes
119
+ # end
120
+ BeatState = Sunniesnow::Utils::Data.define :current_beat, :bpm_changes
121
+
122
+ module Metronomic
123
+
124
+ # @return [Float]
125
+ attr_accessor :offset
126
+
127
+ # @return [Integer, Rational]
128
+ attr_accessor :beat
129
+
130
+ # @return [Integer, Rational, nil]
131
+ attr_accessor :duration_beats
132
+
133
+ # @return [BpmChangeList]
134
+ attr_reader :bpm_changes
135
+
136
+ # @return [Float]
137
+ # @param delta_beat [Integer, Rational]
138
+ def time_at_relative_beat delta_beat
139
+ @offset + @bpm_changes.time_at(@beat + delta_beat)
140
+ end
141
+
142
+ # @return [Float]
143
+ def time
144
+ time_at_relative_beat 0
145
+ end
146
+
147
+ # @return [Float]
148
+ def end_time
149
+ time_at_relative_beat @duration_beats || 0
150
+ end
151
+
152
+ # @note Internal API.
153
+ # @return BeatState
154
+ def beat_state
155
+ BeatState.new @beat, @bpm_changes
156
+ end
157
+
158
+ # @param other [Metronomic]
159
+ # @return [-1,0,1]
160
+ def <=> other
161
+ @beat <=> other.beat
162
+ end
163
+ end
164
+
165
+ # Including this module adds the ability to keep track of the current beat and set BPM changes at the current beat.
166
+ # It provides methods {#beat} and {#beat!} to navigate through the beats.
167
+ #
168
+ # The examples shown in the documentation below assume that +self+ is a {Charter} instance.
169
+ module BeatSeries
170
+
171
+ # It is +nil+ if the offset has not been set by {Charter#offset} yet.
172
+ # @return [Rational?]
173
+ attr_accessor :current_beat
174
+
175
+ # It is +nil+ if the offset has not been set by {Charter#offset} yet.
176
+ # @return [BpmChangeList?]
177
+ attr_reader :bpm_changes
178
+
179
+ # @!group DSL Methods
180
+
181
+ # Set the BPM starting at the current beat.
182
+ # This method must be called after {Charter#offset}.
183
+ # The method can be called multiple times,
184
+ # which is useful when the music changes its tempo from time to time.
185
+ #
186
+ # Internally, this simply calls {BpmChangeList#add} on the BPM changes created by {Charter#offset}.
187
+ # @param bpm [Numeric] the BPM.
188
+ # @raise [OffsetError] if {Charter#offset} has not been called.
189
+ # @return [BpmChangeList] the BPM changes.
190
+ def bpm bpm
191
+ raise OffsetError.new __method__ unless @bpm_changes
192
+ @bpm_changes.add @current_beat, bpm
193
+ end
194
+
195
+ # Increments the current beat by the given delta set by +delta_beat+.
196
+ # It is recommended that +delta_beat+ be a Rational or an Integer for accuracy.
197
+ # Float will be converted to Rational, and a warning will be issued
198
+ # when a Float is used.
199
+ #
200
+ # This method is also useful for inspecting the current beat.
201
+ # If the method is called without an argument, it simply returns the current beat.
202
+ # For this purpose, this method is equivalent to {#beat!}.
203
+ #
204
+ # This method must be called after {Charter#offset}.
205
+ # @param delta_beat [Rational, Integer] the delta to increment the current beat by.
206
+ # @raise [OffsetError] if {Charter#offset} has not been called.
207
+ # @return [Rational] the new current beat.
208
+ # @see #beat!
209
+ # @example Increment the current beat and inspect it
210
+ # offset 0.1; bpm 120
211
+ # p b # Outputs 0, this is the initial value
212
+ # p b 1 # Outputs 1, because it is incremented by 1 when it was 0
213
+ # p b 1/2r # Outputs 3/2, because it is incremented by 3/2 when it was 1
214
+ # p time_at # Outputs 0.85, which is offset + 60s / BPM * beat
215
+ # @example Time the notes
216
+ # offset 0.1; bpm 120
217
+ # t 0, 0; b 1
218
+ # t 50, 0; b 1
219
+ # # Now there are two tap notes, one at beat 0, and the other at beat 1
220
+ def beat delta_beat = 0
221
+ raise OffsetError.new __method__ unless @current_beat
222
+ case delta_beat
223
+ when Integer, Rational
224
+ @current_beat += delta_beat.to_r
225
+ when Float
226
+ warn 'float beat is not recommended'
227
+ @current_beat += delta_beat.to_r
228
+ else
229
+ raise ArgumentError, 'invalid delta_beat'
230
+ end
231
+ end
232
+ alias b beat
233
+
234
+ # Sets the current beat to the given value.
235
+ # It is recommended that +beat+ be a Rational or an Integer for accuracy.
236
+ # Float will be converted to Rational, and a warning will be issued.
237
+ #
238
+ # When called without an argument, this method does nothing and returns the current beat.
239
+ # For this purpose, this method is equivalent to {#beat}.
240
+ #
241
+ # This method must be called after {Charter#offset}.
242
+ # @param beat [Rational, Integer] the new current beat.
243
+ # @raise [OffsetError] if {Charter#offset} has not been called.
244
+ # @return [Rational] the new current beat.
245
+ # @see #beat
246
+ # @example Set the current beat and inspect it
247
+ # offset 0.1; bpm 120
248
+ # p b! # Outputs 0, this is the initial value
249
+ # p b! 1 # Outputs 1, because it is set to 1
250
+ # p b! 1/2r # Outputs 1/2, because it is set to 1/2
251
+ # p time_at # Outputs 0.35, which is offset + 60s / BPM * beat
252
+ def beat! beat = @current_beat
253
+ raise OffsetError.new __method__ unless @current_beat
254
+ case beat
255
+ when Integer, Rational
256
+ @current_beat = beat.to_r
257
+ when Float
258
+ warn 'float beat is not recommended'
259
+ @current_beat = beat.to_r
260
+ else
261
+ raise ArgumentError, 'invalid beat'
262
+ end
263
+ end
264
+ alias b! beat!
265
+
266
+ # @!endgroup
267
+
268
+ # @param beat [Rational]
269
+ # @return [Float]
270
+ # @raise [OffsetError] if {Charter#offset} has not been called.
271
+ def time_at beat = @current_beat
272
+ raise OffsetError.new __method__ unless @bpm_changes
273
+ @bpm_changes.time_at beat
274
+ end
275
+
276
+ # @note Internal API.
277
+ # @return [BeatState]
278
+ def current_beat_state
279
+ BeatState.new @current_beat, @bpm_changes
280
+ end
281
+
282
+ # @note Internal API.
283
+ # @param backup [BeatState]
284
+ # @return [void]
285
+ def restore_beat_state backup
286
+ @current_beat = backup.current_beat
287
+ @bpm_changes = backup.bpm_changes
288
+ nil
289
+ end
290
+ end
291
+
292
+ include BeatSeries
293
+
294
+ # @note Internal API.
295
+ # @return [void]
296
+ def init_beat_state
297
+ @current_beat = nil
298
+ @bpm_changes = nil
299
+ end
300
+
301
+ # @!group DSL Methods
302
+
303
+ # Set the offset.
304
+ # This is the time in seconds of the zeroth beat.
305
+ # This method must be called before any other methods that require a beat,
306
+ # or an {OffsetError} will be raised.
307
+ #
308
+ # After calling this method, the current beat (see {#beat} and {#beat!}) is set to zero,
309
+ # and a new BPM needs to be set using {#bpm}.
310
+ # Only after that can the time of any positive beat be calculated.
311
+ #
312
+ # Though not commonly useful, this method can be called multiple times in a chart.
313
+ # A new call of this method does not affect the events and BPM changes set before.
314
+ # Technically, each event is associated with a BPM change list (see {Event#bpm_changes}),
315
+ # and each call of this method creates a new BPM change list,
316
+ # which is used for the events set after.
317
+ # @param offset [Numeric] the offset in seconds.
318
+ # @return [BpmChangeList] the BPM changes.
319
+ # @see BpmChangeList
320
+ # @raise [ArgumentError] if +offset+ is not a number.
321
+ # @example
322
+ # offset 0.1
323
+ # p time_at # Outputs 0.1, which is the offset
324
+ # offset 0.2
325
+ # p time_at # Outputs 0.2, which is the updated offset by the second call
326
+ def offset offset
327
+ raise ArgumentError, 'offset must be a number' unless offset.is_a? Numeric
328
+ @current_beat = 0r
329
+ @bpm_changes = BpmChangeList.new offset.to_f
330
+ end
331
+
332
+ # @!endgroup
333
+
334
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Sunniesnow::Charter
4
+
5
+ # Check the chart for potential issues.
6
+ # @param notes_in_bound [Boolean] whether to check if tap/hold/drag/flick notes are spatially in bound.
7
+ # @param bg_notes_in_bound [Boolean] whether to check if bg notes are spatially in bound.
8
+ # @return [void]
9
+ def check(
10
+ notes_in_bound: true,
11
+ bg_notes_in_bound: true
12
+ )
13
+ out_of_bound_events = [] if notes_in_bound || bg_notes_in_bound
14
+ @events.each do |event|
15
+ if %i[tap hold drag flick drag_flick].include?(event.type) && notes_in_bound || event.type == :bg_note && bg_notes_in_bound
16
+ if event[:x] < -100-1e-10 || event[:x] > 100+1e-10 || event[:y] < -50-1e-10 || event[:y] > 50+1e-10
17
+ out_of_bound_events.push event
18
+ end
19
+ end
20
+ end
21
+ if notes_in_bound || bg_notes_in_bound
22
+ if out_of_bound_events.empty?
23
+ puts "===== All notes are in bound ====="
24
+ else
25
+ puts "===== Out-of-bound notes ====="
26
+ out_of_bound_events.each do |event|
27
+ p event
28
+ puts "at time #{event.time}"
29
+ puts 'defined at:'
30
+ puts event.backtrace
31
+ end
32
+ end
33
+ end
34
+ nil
35
+ end
36
+
37
+ end