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.
- checksums.yaml +4 -4
- data/Gemfile.lock +1 -0
- data/Rakefile +8 -2
- data/lib/sscharter/chart.rb +52 -8
- data/lib/sscharter/charter/basic_events.rb +277 -0
- data/lib/sscharter/charter/beat.rb +334 -0
- data/lib/sscharter/charter/check.rb +37 -0
- data/lib/sscharter/charter/event.rb +429 -0
- data/lib/sscharter/charter/events_manip.rb +175 -0
- data/lib/sscharter/charter/group.rb +130 -0
- data/lib/sscharter/charter/metadata.rb +129 -0
- data/lib/sscharter/charter/story_events.rb +54 -0
- data/lib/sscharter/charter/tip_point.rb +208 -0
- data/lib/sscharter/charter.rb +87 -0
- data/lib/sscharter/tools/svg_path.rb +549 -0
- data/lib/sscharter/tools.rb +6 -0
- data/lib/sscharter/utils.rb +19 -2
- data/lib/sscharter/version.rb +1 -1
- data/lib/sscharter.rb +2 -1074
- data/lock/ruby-3.0.7-bundler-2.5.23-Gemfile.lock +67 -0
- data/lock/ruby-3.1.7-bundler-2.6.9-Gemfile.lock +67 -0
- data/lock/ruby-3.2.11-bundler-4.0.10-Gemfile.lock +98 -0
- data/lock/ruby-3.3.11-bundler-4.0.10-Gemfile.lock +98 -0
- data/lock/ruby-3.4.9-bundler-4.0.10-Gemfile.lock +101 -0
- data/lock/ruby-4.0.2-bundler-4.0.10-Gemfile.lock +101 -0
- data/tutorial/advanced.md +33 -0
- data/tutorial/tools.md +26 -0
- data/tutorial/tutorial.md +6 -32
- metadata +64 -16
- data/Gemfile.lock +0 -48
|
@@ -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
|