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.
data/lib/sscharter.rb CHANGED
@@ -3,1077 +3,5 @@
3
3
  require_relative 'sscharter/version'
4
4
  require_relative 'sscharter/utils'
5
5
  require_relative 'sscharter/chart'
6
-
7
- class Sunniesnow::Charter
8
-
9
- PROJECT_DIR = File.expand_path(ENV['SSCHARTER_PROJECT_DIR'] ||= Dir.pwd)
10
-
11
- using Sunniesnow::Utils
12
-
13
- class OffsetError < StandardError
14
- def initialize method_name
15
- super "offset must be set before using #{method_name}"
16
- end
17
- end
18
-
19
- class TipPointError < StandardError
20
- def initialize *expected_state, actual_state
21
- super "wrong tip point state: expected #{expected_state.join ' or '}, got #{actual_state}"
22
- end
23
-
24
- def self.ensure state, *expected
25
- raise self.new *expected, state unless expected.include? state
26
- end
27
-
28
- end
29
-
30
- class BpmChangeList
31
-
32
- class BpmChange
33
- include Comparable
34
-
35
- attr_accessor :beat, :bps
36
-
37
- def initialize beat, bpm
38
- @beat = beat
39
- @bps = bpm / 60.0
40
- end
41
-
42
- def <=> other
43
- @beat <=> other.beat
44
- end
45
- end
46
-
47
- attr_accessor :offset
48
-
49
- def initialize offset
50
- @offset = offset
51
- @list = []
52
- end
53
-
54
- def add beat, bpm
55
- @list.push BpmChange.new beat, bpm
56
- @list.sort!
57
- self
58
- end
59
-
60
- def time_at beat
61
- index = @list.bisect(right: true) { _1.beat <=> beat }
62
- raise ArgumentError, 'beat is before the first bpm change' if index < 0
63
- bpm = @list[index]
64
- (0...index).sum @offset + (beat - bpm.beat) / bpm.bps do |i|
65
- bpm = @list[i]
66
- (@list[i+1].beat - bpm.beat) / bpm.bps
67
- end
68
- end
69
-
70
- def [] index
71
- @list[index]
72
- end
73
- end
74
-
75
- class Event
76
-
77
- TIP_POINTABLE_TYPES = %i[tap hold flick drag]
78
-
79
- attr_accessor :beat, :offset, :duration_beats, :properties
80
- attr_reader :type, :bpm_changes, :backtrace
81
-
82
- def initialize type, beat, duration_beats = nil, bpm_changes, **properties
83
- @beat = beat
84
- @duration_beats = duration_beats
85
- @type = type
86
- @bpm_changes = bpm_changes
87
- @properties = properties
88
- @offset = 0.0
89
- @backtrace = caller.filter { _1.sub! /^#{PROJECT_DIR}\//, '' }
90
- end
91
-
92
- def time_at_relative_beat delta_beat
93
- @offset + @bpm_changes.time_at(@beat + delta_beat)
94
- end
95
-
96
- def time
97
- time_at_relative_beat 0
98
- end
99
-
100
- def end_time
101
- time_at_relative_beat @duration_beats || 0
102
- end
103
-
104
- def [] key
105
- @properties[key]
106
- end
107
-
108
- def []= key, value
109
- @properties[key] = value
110
- end
111
-
112
- def to_sunniesnow
113
- t = time
114
- properties = @properties.transform_keys &:snake_to_camel
115
- properties[:duration] = end_time - t if @duration_beats
116
- Sunniesnow::Event.new t, @type.snake_to_camel, **properties
117
- end
118
-
119
- def dup
120
- result = super
121
- result.properties = @properties.dup
122
- result
123
- end
124
-
125
- def tip_pointable?
126
- TIP_POINTABLE_TYPES.include? @type
127
- end
128
-
129
- def inspect
130
- "#<#@type at #@beat#{@duration_beats && " for #@duration_beats"} offset #@offset: " +
131
- @properties.map { |k, v| "#{k}=#{v.inspect}" }.join(', ') + '>'
132
- end
133
- end
134
-
135
- # Implements homography
136
- class Transform
137
- include Math
138
- attr_reader :xx, :xy, :xz, :yx, :yy, :yz, :zx, :zy, :zz, :tt, :t1
139
-
140
- def initialize
141
- @xx = @yy = @zz = 1.0
142
- @xy = @xz = @yx = @yz = @zx = @zy = 0.0
143
- @t1 = 0r
144
- @tt = 1r
145
- end
146
-
147
- def apply event
148
- event.beat = @t1 + @tt * event.beat
149
- return unless x = event[:x]
150
- return unless y = event[:y]
151
- rx = xx*x + xy*y + xz
152
- ry = yx*x + yy*y + yz
153
- d = zx*x + zy*y + zz
154
- event[:x] = xp = rx / d
155
- event[:y] = yp = ry / d
156
-
157
- return event unless angle = event[:angle]
158
- dx = cos angle
159
- dy = sin angle
160
- cross = y*dx - x*dy
161
-
162
- cx0 = zy*xx - xy*zx
163
- cxx = zz*xx - xz*zx
164
- cxy = zz*xy - xz*zy
165
- dxp = cx0*cross + cxx*dx + cxy*dy
166
-
167
- cy0 = zx*yy - yx*zy
168
- cyy = zz*yy - yz*zy
169
- cyx = zz*yx - yz*zx
170
- dyp = cy0*-cross + cyy*dy + cyx*dx
171
-
172
- event[:angle] = atan2 dyp, dxp
173
- event
174
- end
175
-
176
- def compound_linear xx, xy, yx, yy
177
- @xx, @xy, @xz, @yx, @yy, @yz = [
178
- xx * @xx + xy * @yx,
179
- xx * @xy + xy * @yy,
180
- xx * @xz + xy * @yz,
181
- yx * @xx + yy * @yx,
182
- yx * @xy + yy * @yy,
183
- yx * @xz + yy * @yz,
184
- ]
185
- end
186
-
187
- def translate dx, dy
188
- raise ArgumentError, 'dx and dy must be numbers' unless dx.is_a?(Numeric) && dy.is_a?(Numeric)
189
- @xz += dx
190
- @yz += dy
191
- end
192
-
193
- def horizontal_flip
194
- compound_linear -1, 0, 0, 1
195
- end
196
-
197
- def vertical_flip
198
- compound_linear 1, 0, 0, -1
199
- end
200
-
201
- def rotate angle
202
- raise ArgumentError, 'angle must be a number' unless angle.is_a? Numeric
203
- warn 'Are you using degrees as angle unit instead of radians?' if angle != 0 && angle % 45 == 0
204
- c = cos angle
205
- s = sin angle
206
- compound_linear c, -s, s, c
207
- end
208
-
209
- def scale sx, sy = sx
210
- raise ArgumentError, 'sx and sy must be numbers' unless sx.is_a?(Numeric) && sy.is_a?(Numeric)
211
- compound_linear sx, 0, 0, sy
212
- end
213
-
214
- def beat_translate delta_beat
215
- raise ArgumentError, 'delta_beat must be a number' unless delta_beat.is_a? Numeric
216
- warn 'Rational is recommended over Float for delta_beat' if delta_beat.is_a? Float
217
- @t1 += delta_beat.to_r
218
- end
219
- end
220
-
221
- class TipPointStart
222
-
223
- def initialize x = 0, y = 0, relative_time = 0.0, relative: true, speed: nil,
224
- relative_beat: nil, beat_speed: nil
225
- @x = x
226
- @y = y
227
- @relative_time = relative_time
228
- @relative = relative
229
- @speed = speed
230
- @relative_beat = relative_beat
231
- @beat_speed = beat_speed
232
- check
233
- end
234
-
235
- def check
236
- if !@x.is_a?(Numeric) || !@y.is_a?(Numeric)
237
- raise ArgumentError, 'x and y must be numbers'
238
- end
239
- @x = @x.to_f
240
- @y = @y.to_f
241
- %i[@relative_time @speed @relative_beat @beat_speed].each do |key|
242
- value = instance_variable_get key
243
- raise ArgumentError, "cannot specify both #@time_key and #{key}" if @time_key && value&.!=(0)
244
- @time_key = key if value&.!=(0)
245
- end
246
- @time_key ||= :@relative_time
247
- end
248
-
249
- def get_start_placeholder start_event
250
- raise ArgumentError, "start_event is not tip-pointable" unless start_event.tip_pointable?
251
- result = Event.new :placeholder, start_event.beat, start_event.bpm_changes
252
- if @relative
253
- result[:x] = start_event[:x] + @x
254
- result[:y] = start_event[:y] + @y
255
- else
256
- result[:x] = @x
257
- result[:y] = @y
258
- end
259
- case @time_key
260
- when :@relative_time
261
- raise ArgumentError, "relative_time must be a number" unless @relative_time.is_a? Numeric
262
- raise ArgumentError, "relative_time must be non-negative" if @relative_time < 0
263
- result.offset = -@relative_time.to_f
264
- when :@speed
265
- raise ArgumentError, "speed must be a number" unless @speed.is_a? Numeric
266
- raise ArgumentError, "speed must be positive" if @speed <= 0
267
- result.offset = -Math.hypot(result[:x] - start_event[:x], result[:y] - start_event[:y]) / @speed
268
- when :@relative_beat
269
- raise ArgumentError, "relative_beat must be a number" unless @relative_beat.is_a? Numeric
270
- raise ArgumentError, "relative_beat must be non-negative" if @relative_beat < 0
271
- warn "Rational is recommended over Float for relative_beat" if @relative_beat.is_a? Float
272
- result.beat -= @relative_beat.to_r
273
- when :@beat_speed
274
- raise ArgumentError, "beat_speed must be a number" unless @beat_speed.is_a? Numeric
275
- raise ArgumentError, "beat_speed must be positive" if @beat_speed <= 0
276
- delta_beat = Math.hypot(result[:x] - start_event[:x], result[:y] - start_event[:y]) / @beat_speed
277
- result.beat -= delta_beat.to_r # a little weird, but fine
278
- end
279
- result[:tip_point] = start_event[:tip_point]
280
- result
281
- end
282
- end
283
-
284
- COLORS = {
285
- easy: '#3eb9fd',
286
- normal: '#f19e56',
287
- hard: '#e75e74',
288
- master: '#8c68f3',
289
- special: '#f156ee'
290
- }.freeze
291
-
292
- DIRECTIONS = {
293
- right: %i[r],
294
- up_right: %i[ur ru],
295
- up: %i[u],
296
- up_left: %i[ul lu],
297
- left: %i[l],
298
- down_left: %i[dl ld],
299
- down: %i[d],
300
- down_right: %i[dr rd]
301
- }.each_with_object({
302
- right: 0.0,
303
- up_right: Math::PI / 4,
304
- up: Math::PI / 2,
305
- up_left: Math::PI * 3 / 4,
306
- left: Math::PI,
307
- down_left: -Math::PI * 3 / 4,
308
- down: -Math::PI / 2,
309
- down_right: -Math::PI / 4
310
- }) do |(direction_name, aliases), directions|
311
- aliases.each { directions[_1] = directions[direction_name] }
312
- end.freeze
313
-
314
- DIRECTIONS.freeze
315
-
316
- class << self
317
- # A hash containing all the charts opened by {::open}.
318
- # The keys are the names of the charts, and the values are the {Sunniesnow::Charter} objects.
319
- # @return [Hash<String, Sunniesnow::Charter>]
320
- attr_reader :charts
321
- end
322
- @charts = {}
323
-
324
- # An array of events.
325
- # @return [Array<Sunniesnow::Charter::Event>]
326
- attr_reader :events
327
-
328
- # Create a new chart or open an existing chart for editing.
329
- # The +name+ is used to check whether the chart already exists.
330
- # If a new chart needs to be created, it is added to {.charts}.
331
- #
332
- # The given +block+ will be evaluated in the context of the chart
333
- # (inside the block, +self+ is the same as the return value, a {Charter} instance).
334
- # This method is intended to be called at the top level of a Ruby script
335
- # to open a context for writing a Sunniesnow chart using the DSL.
336
- #
337
- # In the examples in the documentation of other methods,
338
- # it is assumed that they are run inside a block passed to this method.
339
- #
340
- # @param name [String] the name of the chart.
341
- # @return [Sunniesnow::Charter] the chart.
342
- # @example
343
- # Sunniesnow::Charter.open 'master' do
344
- # # write the chart here
345
- # end
346
- def self.open name, &block
347
- result = @charts[name] ||= new name
348
- result.instance_eval &block if block
349
- result
350
- end
351
-
352
- # Create a new chart.
353
- # Usually you should use {.open} instead of this method.
354
- # @param name [String] the name of the chart.
355
- def initialize name
356
- @name = name
357
- init_chart_info
358
- init_state
359
- init_bookmarks
360
- end
361
-
362
- def init_chart_info
363
- @difficulty_name = ''
364
- @difficulty_color = '#000000'
365
- @difficulty = ''
366
- @difficulty_sup = ''
367
- @title = ''
368
- @artist = ''
369
- @charter = ''
370
- @events = []
371
- end
372
-
373
- def init_bookmarks
374
- @bookmarks = {}
375
- end
376
-
377
- def init_state
378
- @current_beat = nil
379
- @bpm_changes = nil
380
- @tip_point_mode_stack = [:none]
381
- @current_tip_point_stack = []
382
- @current_tip_point_group_stack = []
383
- @tip_point_peak = 0
384
- @current_duplicate = 0
385
- @tip_point_start_stack = [nil]
386
- @tip_point_start_to_add_stack = [nil]
387
- @groups = [@events]
388
- end
389
-
390
- def to_sunniesnow **opts
391
- result = Sunniesnow::Chart.new **opts
392
- result.title = @title
393
- result.artist = @artist
394
- result.charter = @charter
395
- result.difficulty_name = @difficulty_name
396
- result.difficulty_color = @difficulty_color
397
- result.difficulty = @difficulty
398
- result.difficulty_sup = @difficulty_sup
399
- @events.each { result.events.push _1.to_sunniesnow }
400
- result
401
- end
402
-
403
- def output_json **opts
404
- to_sunniesnow(**opts).to_json
405
- end
406
-
407
- def inspect
408
- "#<Sunniesnow::Charter #@name>"
409
- end
410
-
411
- def time_at beat = @current_beat
412
- raise OffsetError.new __method__ unless @bpm_changes
413
- @bpm_changes.time_at beat
414
- end
415
-
416
- def backup_beat
417
- {current_beat: @current_beat, bpm_changes: @bpm_changes}
418
- end
419
-
420
- def restore_beat backup
421
- @current_beat = backup[:current_beat]
422
- @bpm_changes = backup[:bpm_changes]
423
- end
424
-
425
- def backup_state
426
- {
427
- current_beat: @current_beat,
428
- bpm_changes: @bpm_changes,
429
- tip_point_mode_stack: @tip_point_mode_stack.dup,
430
- current_tip_point_stack: @current_tip_point_stack.dup,
431
- current_tip_point_group_stack: @current_tip_point_group_stack.dup,
432
- current_duplicate: @current_duplicate,
433
- tip_point_start_stack: @tip_point_start_stack.dup,
434
- tip_point_start_to_add_stack: @tip_point_start_to_add_stack.dup,
435
- groups: @groups.dup
436
- }
437
- end
438
-
439
- def restore_state backup
440
- @current_beat = backup[:current_beat]
441
- @bpm_changes = backup[:bpm_changes]
442
- @tip_point_mode_stack = backup[:tip_point_mode_stack]
443
- @current_tip_point_stack = backup[:current_tip_point_stack]
444
- @current_tip_point_group_stack = backup[:current_tip_point_group_stack]
445
- @current_duplicate = backup[:current_duplicate]
446
- @tip_point_start_to_add_stack = backup[:tip_point_start_to_add_stack]
447
- @groups = backup[:groups]
448
- nil
449
- end
450
-
451
- def event type, duration_beats = nil, **properties
452
- raise OffsetError.new __method__ unless @bpm_changes
453
- event = Event.new type, @current_beat, duration_beats, @bpm_changes, **properties
454
- @groups.each { _1.push event }
455
- return event unless event.tip_pointable?
456
- case @tip_point_mode_stack.last
457
- when :chain
458
- if @tip_point_start_to_add_stack.last
459
- @current_tip_point_stack[-1] = @tip_point_peak
460
- @tip_point_peak += 1
461
- end
462
- push_tip_point_start event
463
- @tip_point_start_to_add_stack[-1] = nil
464
- when :drop
465
- @current_tip_point_stack[-1] = @tip_point_peak
466
- @tip_point_peak += 1
467
- push_tip_point_start event
468
- when :none
469
- # pass
470
- end
471
- event
472
- end
473
-
474
- def push_tip_point_start start_event
475
- start_event[:tip_point] = @current_tip_point_stack.last.to_s
476
- tip_point_start = @tip_point_start_to_add_stack.last&.get_start_placeholder start_event
477
- return unless tip_point_start
478
- @groups.each do |group|
479
- group.push tip_point_start
480
- break if group.equal?(@current_tip_point_group_stack.last) && @tip_point_mode_stack.last != :drop
481
- end
482
- end
483
-
484
- def tip_point mode, *args, preserve_beat: true, **opts, &block
485
- @tip_point_mode_stack.push mode
486
- if mode == :none
487
- @tip_point_start_stack.push nil
488
- @tip_point_start_to_add_stack.push nil
489
- @current_tip_point_stack.push nil
490
- else
491
- if args.empty? && opts.empty?
492
- unless @tip_point_start_stack.last
493
- raise TipPointError, 'cannot omit tip point arguments at top level or inside tip_point_none'
494
- end
495
- @tip_point_start_stack.push @tip_point_start_stack.last.dup
496
- else
497
- @tip_point_start_stack.push TipPointStart.new *args, **opts
498
- end
499
- @tip_point_start_to_add_stack.push @tip_point_start_stack.last
500
- @current_tip_point_stack.push nil
501
- end
502
- result = group preserve_beat: do
503
- @current_tip_point_group_stack.push @groups.last
504
- instance_eval &block
505
- end
506
- @tip_point_start_stack.pop
507
- @tip_point_start_to_add_stack.pop
508
- @tip_point_mode_stack.pop
509
- @current_tip_point_stack.pop
510
- @current_tip_point_group_stack.pop
511
- result
512
- end
513
-
514
- # @!group DSL methods
515
-
516
- # Set the title of the music for the chart.
517
- # This will be reflected in the return value of {#to_sunniesnow}.
518
- # @see Sunniesnow::Chart#title
519
- # @param title [String] the title of the music.
520
- # @return [String] the title of the music, the same as the argument +title+.
521
- # @raise [ArgumentError] if +title+ is not a String.
522
- def title title
523
- raise ArgumentError, 'title must be a string' unless title.is_a? String
524
- @title = title
525
- end
526
-
527
- # Set the artist of the music for the chart.
528
- # This will be reflected in the return value of {#to_sunniesnow}.
529
- # @see Sunniesnow::Chart#artist
530
- # @param artist [String] the artist of the music.
531
- # @return [String] the artist of the music, the same as the argument +artist+.
532
- # @raise [ArgumentError] if +artist+ is not a String.
533
- def artist artist
534
- raise ArgumentError, 'artist must be a string' unless artist.is_a? String
535
- @artist = artist
536
- end
537
-
538
- # Set the name of the chart author for the chart.
539
- # This will be reflected in the return value of {#to_sunniesnow}.
540
- # @see Sunniesnow::Chart#charter
541
- # @param charter [String] the name of the charter.
542
- # @return [String] the name of the chart author, the same as the argument +charter+.
543
- # @raise [ArgumentError] if +charter+ is not a String.
544
- def charter charter
545
- raise ArgumentError, 'charter must be a string' unless charter.is_a? String
546
- @charter = charter
547
- end
548
-
549
- # Set the name of the difficulty for the chart.
550
- # This will be reflected in the return value of {#to_sunniesnow}.
551
- # @see Sunniesnow::Chart#difficulty_name
552
- # @param difficulty_name [String] the name of the difficulty.
553
- # @return [String] the name of the difficulty, the same as the argument +difficulty_name+.
554
- # @raise [ArgumentError] if +difficulty_name+ is not a String.
555
- def difficulty_name difficulty_name
556
- raise ArgumentError, 'difficulty_name must be a string' unless difficulty_name.is_a? String
557
- @difficulty_name = difficulty_name
558
- end
559
-
560
- # Set the color of the difficulty for the chart.
561
- # This will be reflected in the return value of {#to_sunniesnow}.
562
- #
563
- # The argument +difficulty_color+ can be a color name (a key of {COLORS}),
564
- # an RGB color in hexadecimal format (e.g. +'#8c68f3'+, +'#8CF'+),
565
- # an RGB color in decimal format (e.g. +'rgb(140, 104, 243)'+),
566
- # or an integer representing an RGB color (e.g. +0x8c68f3+).
567
- # @see Sunniesnow::Chart#difficulty_color
568
- # @param difficulty_color [Symbol, String, Integer] the color of the difficulty.
569
- # @return [String] the color of the difficulty in hexadecimal format (e.g. +'#8c68f3'+).
570
- # @raise [ArgumentError] if +difficulty_color+ is not a valid color format.
571
- def difficulty_color difficulty_color
572
- @difficulty_color = case difficulty_color
573
- when Symbol
574
- COLORS[difficulty_color]
575
- when /^#[0-9a-fA-F]{6}$/
576
- difficulty_color
577
- when /^#[0-9a-fA-F]{3}$/
578
- _, r, g, b = difficulty_color.chars
579
- "##{r}#{r}#{g}#{g}#{b}#{b}"
580
- when /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/
581
- r, g, b = $1, $2, $3
582
- sprintf '#%02x%02x%02x', r.to_i, g.to_i, b.to_i
583
- when Integer
584
- sprintf '#%06x', difficulty_color % 0x1000000
585
- else
586
- raise ArgumentError, 'unknown format of difficulty_color'
587
- end
588
- end
589
-
590
- # Set the difficulty level for the chart.
591
- # This will be reflected in the return value of {#to_sunniesnow}.
592
- #
593
- # The argument +difficulty+ should be a string representing the difficulty level.
594
- # Anything other than a string will be converted to a string using +to_s+.
595
- # @see Sunniesnow::Chart#difficulty
596
- # @param difficulty [String] the difficulty level.
597
- # @return [String] the difficulty level (converted to a string).
598
- def difficulty difficulty
599
- @difficulty = difficulty.to_s
600
- end
601
-
602
- # Set the difficulty superscript for the chart.
603
- # This will be reflected in the return value of {#to_sunniesnow}.
604
- #
605
- # The argument +difficulty_sup+ should be a string representing the difficulty superscript.
606
- # Anything other than a string will be converted to a string using +to_s+.
607
- # @see Sunniesnow::Chart#difficulty_sup
608
- # @param difficulty_sup [String] the difficulty superscript.
609
- # @return [String] the difficulty superscript (converted to a string).
610
- def difficulty_sup difficulty_sup
611
- @difficulty_sup = difficulty_sup.to_s
612
- end
613
-
614
- # Set the offset.
615
- # This is the time in seconds of the zeroth beat.
616
- # This method must be called before any other methods that require a beat,
617
- # or an {OffsetError} will be raised.
618
- #
619
- # After calling this method, the current beat (see {#beat} and {#beat!}) is set to zero,
620
- # and a new BPM needs to be set using {#bpm}.
621
- # Only after that can the time of any positive beat be calculated.
622
- #
623
- # Though not commonly useful, this method can be called multiple times in a chart.
624
- # A new call of this method does not affect the events and BPM changes set before.
625
- # Technically, each event is associated with a BPM change list (see {Event#bpm_changes}),
626
- # and each call of this method creates a new BPM change list,
627
- # which is used for the events set after.
628
- # @param offset [Numeric] the offset in seconds.
629
- # @return [BpmChangeList] the BPM changes.
630
- # @see BpmChangeList
631
- # @raise [ArgumentError] if +offset+ is not a number.
632
- # @example
633
- # offset 0.1
634
- # p time_at # Outputs 0.1, which is the offset
635
- # offset 0.2
636
- # p time_at # Outputs 0.2, which is the updated offset by the second call
637
- def offset offset
638
- raise ArgumentError, 'offset must be a number' unless offset.is_a? Numeric
639
- @current_beat = 0r
640
- @bpm_changes = BpmChangeList.new offset.to_f
641
- end
642
-
643
- # Set the BPM starting at the current beat.
644
- # This method must be called after {#offset}.
645
- # The method can be called multiple times,
646
- # which is useful when the music changes its tempo from time to time.
647
- #
648
- # Internally, this simply calls {BpmChangeList#add} on the BPM changes created by {#offset}.
649
- # @param bpm [Numeric] the BPM.
650
- # @raise [OffsetError] if {#offset} has not been called.
651
- # @return [BpmChangeList] the BPM changes.
652
- def bpm bpm
653
- raise OffsetError.new __method__ unless @bpm_changes
654
- @bpm_changes.add @current_beat, bpm
655
- end
656
-
657
- # Increments the current beat by the given delta set by +delta_beat+.
658
- # It is recommended that +delta_beat+ be a Rational or an Integer for accuracy.
659
- # Float will be converted to Rational, and a warning will be issued
660
- # when a Float is used.
661
- #
662
- # This method is also useful for inspecting the current beat.
663
- # If the method is called without an argument, it simply returns the current beat.
664
- # For this purpose, this method is equivalent to {#beat!}.
665
- #
666
- # This method must be called after {#offset}.
667
- # @param delta_beat [Rational, Integer] the delta to increment the current beat by.
668
- # @raise [OffsetError] if {#offset} has not been called.
669
- # @return [Rational] the new current beat.
670
- # @see #beat!
671
- # @example Increment the current beat and inspect it
672
- # offset 0.1; bpm 120
673
- # p b # Outputs 0, this is the initial value
674
- # p b 1 # Outputs 1, because it is incremented by 1 when it was 0
675
- # p b 1/2r # Outputs 3/2, because it is incremented by 3/2 when it was 1
676
- # p time_at # Outputs 0.85, which is offset + 60s / BPM * beat
677
- # @example Time the notes
678
- # offset 0.1; bpm 120
679
- # t 0, 0; b 1
680
- # t 50, 0; b 1
681
- # # Now there are two tap notes, one at beat 0, and the other at beat 1
682
- def beat delta_beat = 0
683
- raise OffsetError.new __method__ unless @current_beat
684
- case delta_beat
685
- when Integer, Rational
686
- @current_beat += delta_beat.to_r
687
- when Float
688
- warn 'float beat is not recommended'
689
- @current_beat += delta_beat.to_r
690
- else
691
- raise ArgumentError, 'invalid delta_beat'
692
- end
693
- end
694
- alias b beat
695
-
696
- # Sets the current beat to the given value.
697
- # It is recommended that +beat+ be a Rational or an Integer for accuracy.
698
- # Float will be converted to Rational, and a warning will be issued.
699
- #
700
- # When called without an argument, this method does nothing and returns the current beat.
701
- # For this purpose, this method is equivalent to {#beat}.
702
- #
703
- # This method must be called after {#offset}.
704
- # @param beat [Rational, Integer] the new current beat.
705
- # @raise [OffsetError] if {#offset} has not been called.
706
- # @return [Rational] the new current beat.
707
- # @see #beat
708
- # @example Set the current beat and inspect it
709
- # offset 0.1; bpm 120
710
- # p b! # Outputs 0, this is the initial value
711
- # p b! 1 # Outputs 1, because it is set to 1
712
- # p b! 1/2r # Outputs 1/2, because it is set to 1/2
713
- # p time_at # Outputs 0.35, which is offset + 60s / BPM * beat
714
- def beat! beat = @current_beat
715
- raise OffsetError.new __method__ unless @current_beat
716
- case beat
717
- when Integer, Rational
718
- @current_beat = beat.to_r
719
- when Float
720
- warn 'float beat is not recommended'
721
- @current_beat = beat.to_r
722
- else
723
- raise ArgumentError, 'invalid beat'
724
- end
725
- end
726
- alias b! beat!
727
-
728
- # Creates a tap note at the given coordinates with the given text.
729
- # The coordinates +x+ and +y+ must be numbers.
730
- # The argument +text+ is the text to be displayed on the note
731
- # (it is converted to a string via +to_s+ if it is not a string).
732
- #
733
- # Technically, this adds an event of type +:tap+ to the chart at the current time
734
- # with properties containing the information provided by +x+, +y+, and +text+.
735
- # @param x [Numeric] the x-coordinate of the note.
736
- # @param y [Numeric] the y-coordinate of the note.
737
- # @param text [String] the text to be displayed on the note.
738
- # @return [Event] the event representing the tap note.
739
- # @raise [ArgumentError] if +x+ or +y+ is not a number.
740
- # @example
741
- # offset 0.1; bpm 120
742
- # t 0, 0, 'Hello'
743
- # t 50, 0, 'world'
744
- # # Now there are two simultaneous tap notes at (0, 0) and (50, 0)
745
- # # with texts 'Hello' and 'world' respectively
746
- def tap x, y, text = ''
747
- if !x.is_a?(Numeric) || !y.is_a?(Numeric)
748
- raise ArgumentError, 'x and y must be numbers'
749
- end
750
- event :tap, x: x.to_f, y: y.to_f, text: text.to_s
751
- end
752
- alias t tap
753
-
754
- # Creates a hold note at the given coordinates with the given duration and text.
755
- # The coordinates +x+ and +y+ must be numbers.
756
- # The argument +duration_beats+ is the duration of the hold note in beats.
757
- # It needs to be a positive Rational or Integer.
758
- # If it is a Float, it will be converted to a Rational, and a warning will be issued.
759
- # The argument +text+ is the text to be displayed on the note
760
- # (it is converted to a string via +to_s+ if it is not a string).
761
- #
762
- # Technically, this adds an event of type +:hold+ to the chart at the current time
763
- # with properties containing the information provided by +x+, +y+, +duration_beats+, and +text+.
764
- # @param x [Numeric] the x-coordinate of the note.
765
- # @param y [Numeric] the y-coordinate of the note.
766
- # @param duration_beats [Rational, Integer] the duration of the hold note in beats.
767
- # @param text [String] the text to be displayed on the note.
768
- # @return [Event] the event representing the hold note.
769
- # @raise [ArgumentError] if +x+, +y+, or +duration_beats+ is not a number,
770
- # or if +duration_beats+ is not positive.
771
- # @example
772
- # offset 0.1; bpm 120
773
- # h 0, 0, 1, 'Hello'
774
- # h 50, 0, 2, 'world'
775
- # # Now there are two hold notes at (0, 0) and (50, 0)
776
- # # with durations 1 and 2 beats and texts 'Hello' and 'world' respectively
777
- def hold x, y, duration_beats, text = ''
778
- if !x.is_a?(Numeric) || !y.is_a?(Numeric) || !duration_beats.is_a?(Numeric)
779
- raise ArgumentError, 'x, y, and duration must be numbers'
780
- end
781
- if duration_beats <= 0
782
- raise ArgumentError, 'duration must be positive'
783
- end
784
- if duration_beats.is_a? Float
785
- warn 'Rational is recommended over Float for duration_beats'
786
- end
787
- event :hold, duration_beats.to_r, x: x.to_f, y: y.to_f, text: text.to_s
788
- end
789
- alias h hold
790
-
791
- # Creates a drag note at the given coordinates.
792
- # The coordinates +x+ and +y+ must be numbers.
793
- #
794
- # Technically, this adds an event of type +:drag+ to the chart at the current time
795
- # with properties containing the information provided by +x+ and +y+.
796
- # @param x [Numeric] the x-coordinate of the note.
797
- # @param y [Numeric] the y-coordinate of the note.
798
- # @return [Event] the event representing the drag note.
799
- # @raise [ArgumentError] if +x+ or +y+ is not a number.
800
- # @example
801
- # offset 0.1; bpm 120
802
- # d 0, 0
803
- # d 50, 0
804
- # # Now there are two drag notes at (0, 0) and (50, 0)
805
- def drag x, y
806
- if !x.is_a?(Numeric) || !y.is_a?(Numeric)
807
- raise ArgumentError, 'x and y must be numbers'
808
- end
809
- event :drag, x: x.to_f, y: y.to_f
810
- end
811
- alias d drag
812
-
813
- # Creates a flick note at the given coordinates with the given direction and text.
814
- # The coordinates +x+ and +y+ must be numbers.
815
- # The argument +direction+ is the direction of the flick note in radians or a symbol.
816
- # If it is a symbol, it should be one of the keys of {DIRECTIONS}
817
- # (which are +:right+, +:up_right+, etc., abbreviated as +:r+, +:ur+ etc.).
818
- # If it is a number, it should be a number representing the angle in radians,
819
- # specifying the angle rorated anticlockwise starting from the positive x-direction.
820
- # The argument +text+ is the text to be displayed on the note
821
- # (it is converted to a string via +to_s+ if it is not a string).
822
- #
823
- # Technically, this adds an event of type +:flick+ to the chart at the current time
824
- # with properties containing the information provided by +x+, +y+, +direction+, and +text+.
825
- # @param x [Numeric] the x-coordinate of the note.
826
- # @param y [Numeric] the y-coordinate of the note.
827
- # @param direction [Numeric, Symbol] the direction of the flick note in radians or a symbol.
828
- # @param text [String] the text to be displayed on the note.
829
- # @return [Event] the event representing the flick note.
830
- # @raise [ArgumentError] if +x+ or +y+ is not a number,
831
- # if +direction+ is not a symbol or a number,
832
- # or if the direction is a symbol that does not represent a known direction.
833
- # @example
834
- # offset 0.1; bpm 120
835
- # f 0, 0, :r, 'Hello'
836
- # f 50, 0, Math::PI / 4, 'world'
837
- # # Now there are two flick notes at (0, 0) and (50, 0)
838
- # # with directions right and up right and texts 'Hello' and 'world' respectively
839
- def flick x, y, direction, text = ''
840
- if !x.is_a?(Numeric) || !y.is_a?(Numeric)
841
- raise ArgumentError, 'x and y must be numbers'
842
- end
843
- if direction.is_a? Symbol
844
- direction = DIRECTIONS[direction]
845
- raise ArgumentError, "unknown direction #{direction}" unless direction
846
- elsif direction.is_a? Numeric
847
- warn 'Are you using degrees as angle unit instead of radians?' if direction != 0 && direction % 45 == 0
848
- direction = direction.to_f
849
- else
850
- raise ArgumentError, 'direction must be a symbol or a number'
851
- end
852
- event :flick, x: x.to_f, y: y.to_f, angle: direction, text: text.to_s
853
- end
854
- alias f flick
855
-
856
- # Creates a background note at the given coordinates with the given duration and text.
857
- # The coordinates +x+ and +y+ must be numbers.
858
- # The argument +duration_beats+ is the duration of the background note in beats.
859
- # It needs to be a non-negative Rational or Integer.
860
- # If it is a Float, it will be converted to a Rational, and a warning will be issued.
861
- # The argument +text+ is the text to be displayed on the note
862
- # (it is converted to a string via +to_s+ if it is not a string).
863
- #
864
- # Both the +duration_beats+ and the +text+ arguments are optional.
865
- # When there are three arguments given in total,
866
- # the method determines whether the third is +duration_beats+ or +text+ based on its type.
867
- #
868
- # Technically, this adds an event of type +:bg_note+ to the chart at the current time
869
- # with properties containing the information provided by +x+, +y+, +duration_beats+, and +text+.
870
- # @param x [Numeric] the x-coordinate of the note.
871
- # @param y [Numeric] the y-coordinate of the note.
872
- # @param duration_beats [Rational, Integer] the duration of the background note in beats.
873
- # @param text [String] the text to be displayed on the note.
874
- # @return [Event] the event representing the background note.
875
- # @raise [ArgumentError] if +x+, +y+, or +duration_beats+ is not a number,
876
- # or if +duration_beats+ is negative.
877
- # @example
878
- # offset 0.1; bpm 120
879
- # bg_note 0, 0, 1, 'Hello' # duration is 1, text is 'Hello'
880
- # bg_note 50, 0, 'world' # duration is 0, text is 'world'
881
- # bg_note -50, 0, 2 # duration is 2, text is ''
882
- def bg_note x, y, duration_beats = 0, text = nil
883
- if text.nil?
884
- if duration_beats.is_a? String
885
- text = duration_beats
886
- duration_beats = 0
887
- else
888
- text = ''
889
- end
890
- end
891
- if !x.is_a?(Numeric) || !y.is_a?(Numeric) || !duration_beats.is_a?(Numeric)
892
- raise ArgumentError, 'x, y, and duration_beats must be numbers'
893
- end
894
- if duration_beats < 0
895
- raise ArgumentError, 'duration must be non-negative'
896
- end
897
- if duration_beats.is_a? Float
898
- warn 'Rational is recommended over Float for duration_beats'
899
- end
900
- event :bg_note, duration_beats.to_r, x: x.to_f, y: y.to_f, text: text.to_s
901
- end
902
-
903
- # Creates a big text.
904
- # The argument +duration_beats+ is the duration of the big text in beats.
905
- # It needs to be a non-negative Rational or Integer.
906
- # If it is a Float, it will be converted to a Rational, and a warning will be issued.
907
- # The argument +text+ is the text to be displayed.
908
- #
909
- # Technically, this adds an event of type +:big_text+ to the chart at the current time
910
- # with properties containing the information provided by +duration_beats+ and +text+.
911
- # @param duration_beats [Rational, Integer] the duration of the big text in beats.
912
- # @param text [String] the text to be displayed.
913
- # @return [Event] the event representing the big text.
914
- # @raise [ArgumentError] if +duration_beats+ is not a number or is negative.
915
- # @example
916
- # offset 0.1; bpm 120
917
- # big_text 1, 'Hello, world!' # duration is 1, text is 'Hello, world!'
918
- # b 1
919
- # big_text 'Goodbye!' # duration is 0, text is 'Goodbye!'
920
- def big_text duration_beats = 0, text
921
- unless duration_beats.is_a? Numeric
922
- raise ArgumentError, 'duration_beats must be a number'
923
- end
924
- if duration_beats < 0
925
- raise ArgumentError, 'duration must be non-negative'
926
- end
927
- if duration_beats.is_a? Float
928
- warn 'Rational is recommended over Float for duration_beats'
929
- end
930
- event :big_text, duration_beats.to_r, text: text.to_s
931
- end
932
-
933
- # @!macro [attach] bg_pattern
934
- # @!method $1(duration_beats = 0)
935
- # Creates a $2 background pattern.
936
- # The argument +duration_beats+ is the duration of the background pattern in beats.
937
- # It needs to be a non-negative Rational or Integer.
938
- # If it is a Float, it will be converted to a Rational, and a warning will be issued.
939
- #
940
- # Technically, this adds an event of type +:bg_pattern+ to the chart at the current time
941
- # with properties containing the information provided by +duration_beats+.
942
- # @param duration_beats [Rational, Integer] the duration of the background pattern in beats.
943
- # @return [Event] the event representing the background pattern.
944
- # @raise [ArgumentError] if +duration_beats+ is not a number or is negative.
945
- # @example
946
- # offset 0.1; bpm 120
947
- # $1 1 # duration is 1
948
- # b 1
949
- # $1 0 # duration is 0
950
- # @!parse bg_pattern :grid, 'grid'
951
- # @!parse bg_pattern :hexagon, 'hexagon'
952
- # @!parse bg_pattern :checkerboard, 'checkerboard'
953
- # @!parse bg_pattern :diamond_grid, 'diamond grid'
954
- # @!parse bg_pattern :pentagon, 'pentagon'
955
- # @!parse bg_pattern :turntable, 'turntable'
956
- # @!parse bg_pattern :hexagram, 'hexagram'
957
- %i[grid hexagon checkerboard diamond_grid pentagon turntable hexagram].each do |method_name|
958
- define_method method_name do |duration_beats = 0|
959
- unless duration_beats.is_a? Numeric
960
- raise ArgumentError, 'duration_beats must be a number'
961
- end
962
- if duration_beats < 0
963
- raise ArgumentError, 'duration must be non-negative'
964
- end
965
- if duration_beats.is_a? Float
966
- warn 'Rational is recommended over Float for duration_beats'
967
- end
968
- event method_name, duration_beats.to_r
969
- end
970
- end
971
-
972
- # Duplicate all events in a given array.
973
- # This method is useful when you want to duplicate a set of events.
974
- # The argument +events+ is an array of events to be duplicated.
975
- # The argument +new_tip_points+ is a boolean indicating whether to create new tip points.
976
- # If it is +true+, new tip points will be created for the duplicated events.
977
- # If it is +false+, each duplicated event shares the same tip point as the original event.
978
- # @param events [Array<Event>] the events to be duplicated.
979
- # @param new_tip_points [Boolean] whether to create new tip points for the duplicated events.
980
- # @return [Array<Event>] the duplicated events.
981
- # @example Duplicate a note
982
- # offset 0.1; bpm 120
983
- # duplicate [t 0, 0]
984
- # @example Duplicate notes that share tip points with the original notes
985
- # offset 0.1; bpm 120
986
- # duplicate tp_chain(0, 100, 1) { t 0, 0 }
987
- def duplicate events, new_tip_points: true
988
- result = []
989
- events.each do |event|
990
- next if event.type == :placeholder && !new_tip_points
991
- result.push event = event.dup
992
- if event[:tip_point] && new_tip_points
993
- event[:tip_point] = "#@current_duplicate #{event[:tip_point]}"
994
- end
995
- @groups.each { _1.push event }
996
- end
997
- @current_duplicate += 1 if new_tip_points
998
- result
999
- end
1000
-
1001
- # Transform all events in a given array in time and/or space.
1002
- # Space transformation does not affect background patterns.
1003
- def transform events, &block
1004
- raise ArgumentError, 'no block given' unless block
1005
- events = [events] if events.is_a? Event
1006
- transform = Transform.new
1007
- transform.instance_eval &block
1008
- events.each { transform.apply _1 }
1009
- end
1010
-
1011
- def group preserve_beat: true, &block
1012
- raise ArgumentError, 'no block given' unless block
1013
- @groups.push result = []
1014
- beat_backup = backup_beat unless preserve_beat
1015
- instance_eval &block
1016
- restore_beat beat_backup unless preserve_beat
1017
- @groups.delete_if { result.equal? _1 }
1018
- result
1019
- end
1020
-
1021
- def remove *events
1022
- events.each { |event| @groups.each { _1.delete event } }
1023
- end
1024
-
1025
- %i[chain drop none].each do |mode|
1026
- define_method "tip_point_#{mode}" do |*args, **opts, &block|
1027
- tip_point mode, *args, **opts, &block
1028
- end
1029
- alias_method "tp_#{mode}", "tip_point_#{mode}"
1030
- end
1031
-
1032
- def mark name
1033
- @bookmarks[name] = backup_state
1034
- name
1035
- end
1036
-
1037
- def at name, preserve_beat: false, update_mark: false, &block
1038
- raise ArgumentError, 'no block given' unless block
1039
- raise ArgumentError, "unknown bookmark #{name}" unless bookmark = @bookmarks[name]
1040
- backup = backup_state
1041
- restore_state bookmark
1042
- result = group &block
1043
- mark name if update_mark
1044
- beat_backup = backup_beat if preserve_beat
1045
- restore_state backup
1046
- restore_beat beat_backup if preserve_beat
1047
- result
1048
- end
1049
-
1050
- def check(
1051
- notes_in_bound: true,
1052
- bg_notes_in_bound: true
1053
- )
1054
- out_of_bound_events = [] if notes_in_bound || bg_notes_in_bound
1055
- @events.each do |event|
1056
- if %i[tap hold drag flick].include?(event.type) && notes_in_bound || event.type == :bg_note && bg_notes_in_bound
1057
- if event[:x] < -100-1e-10 || event[:x] > 100+1e-10 || event[:y] < -50-1e-10 || event[:y] > 50+1e-10
1058
- out_of_bound_events.push event
1059
- end
1060
- end
1061
- end
1062
- if notes_in_bound || bg_notes_in_bound
1063
- if out_of_bound_events.empty?
1064
- puts "===== All notes are in bound ====="
1065
- else
1066
- puts "===== Out-of-bound notes ====="
1067
- out_of_bound_events.each do |event|
1068
- p event
1069
- puts "at time #{event.time}"
1070
- puts 'defined at:'
1071
- puts event.backtrace
1072
- end
1073
- end
1074
- end
1075
- end
1076
-
1077
- # @!endgroup
1078
-
1079
- end
6
+ require_relative 'sscharter/tools'
7
+ require_relative 'sscharter/charter'