sscharter 0.1.0

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 ADDED
@@ -0,0 +1,587 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sscharter/version'
4
+ require_relative 'sscharter/utils'
5
+ require_relative 'sscharter/chart'
6
+
7
+ class Sunniesnow::Charter
8
+
9
+ using Sunniesnow::Utils
10
+
11
+ class OffsetError < StandardError
12
+ def initialize method_name
13
+ super "offset must be set before using #{method_name}"
14
+ end
15
+ end
16
+
17
+ class TipPointError < StandardError
18
+ def initialize *expected_state, actual_state
19
+ super "wrong tip point state: expected #{expected_state.join ' or '}, got #{actual_state}"
20
+ end
21
+
22
+ def self.ensure state, *expected
23
+ raise self.new *expected, state unless expected.include? state
24
+ end
25
+
26
+ end
27
+
28
+ class BpmChangeList
29
+
30
+ class BpmChange
31
+ attr_accessor :beat, :bps
32
+
33
+ def initialize beat, bpm
34
+ @beat = beat
35
+ @bps = bpm / 60.0
36
+ end
37
+
38
+ def <=> other
39
+ @beat <=> other.beat
40
+ end
41
+ end
42
+
43
+ attr_accessor :offset
44
+
45
+ def initialize offset
46
+ @offset = offset
47
+ @list = []
48
+ end
49
+
50
+ def add beat, bpm
51
+ @list.push BpmChange.new beat, bpm
52
+ end
53
+
54
+ def time_at beat
55
+ index = @list.bisect(right: true) { _1.beat <=> beat }
56
+ raise ArgumentError, 'beat is before the first bpm change' if index < 0
57
+ bpm = @list[index]
58
+ (0...index).sum @offset + (beat - bpm.beat) / bpm.bps do |i|
59
+ bpm = @list[i]
60
+ (@list[i+1].beat - bpm.beat) / bpm.bps
61
+ end
62
+ end
63
+
64
+ def [] index
65
+ @list[index]
66
+ end
67
+ end
68
+
69
+ class Event
70
+
71
+ TIP_POINTABLE_TYPES = %i[tap hold flick drag]
72
+
73
+ attr_accessor :beat, :offset, :duration_beats, :properties
74
+ attr_reader :type, :bpm_changes
75
+
76
+ def initialize type, beat, duration_beats = nil, bpm_changes, **properties
77
+ @beat = beat
78
+ @duration_beats = duration_beats
79
+ @type = type
80
+ @bpm_changes = bpm_changes
81
+ @properties = properties
82
+ @offset = 0.0
83
+ end
84
+
85
+ def time_at_relative_beat delta_beat
86
+ @offset + @bpm_changes.time_at(@beat + delta_beat)
87
+ end
88
+
89
+ def time
90
+ time_at_relative_beat 0
91
+ end
92
+
93
+ def end_time
94
+ time_at_relative_beat @duration_beats || 0
95
+ end
96
+
97
+ def [] key
98
+ @properties[key]
99
+ end
100
+
101
+ def []= key, value
102
+ @properties[key] = value
103
+ end
104
+
105
+ def to_sunniesnow
106
+ t = time
107
+ properties = @properties.transform_keys &:snake_to_camel
108
+ properties[:duration] = end_time - t if @duration_beats
109
+ Sunniesnow::Event.new t, @type.snake_to_camel, **properties
110
+ end
111
+
112
+ def dup
113
+ result = super
114
+ result.properties = @properties.dup
115
+ result
116
+ end
117
+
118
+ def tip_pointable?
119
+ TIP_POINTABLE_TYPES.include? @type
120
+ end
121
+ end
122
+
123
+ # Implements homography
124
+ class Transform
125
+ include Math
126
+ attr_reader :xx, :xy, :xz, :yx, :yy, :yz, :zx, :zy, :zz, :tt, :t1
127
+
128
+ def initialize
129
+ @xx = @yy = @zz = 1.0
130
+ @xy = @xz = @yx = @yz = @zx = @zy = 0.0
131
+ @t1 = 0r
132
+ @tt = 1r
133
+ end
134
+
135
+ def apply event
136
+ event.beat = @t1 + @tt * event.beat
137
+ return unless x = event[:x]
138
+ return unless y = event[:y]
139
+ rx = xx*x + xy*y + xz
140
+ ry = yx*x + yy*y + yz
141
+ d = zx*x + zy*y + zz
142
+ event[:x] = xp = rx / d
143
+ event[:y] = yp = ry / d
144
+
145
+ return event unless angle = event[:angle]
146
+ dx = cos angle
147
+ dy = sin angle
148
+ cross = y*dx - x*dy
149
+
150
+ cx0 = zy*xx - xy*zx
151
+ cxx = zz*xx - xz*zx
152
+ cxy = zz*xy - xz*zy
153
+ dxp = cx0*cross + cxx*dx + cxy*dy
154
+
155
+ cy0 = zx*yy - yx*zy
156
+ cyy = zz*yy - yz*zy
157
+ cyx = zz*yx - yz*zx
158
+ dyp = cy0*-cross + cyy*dy + cyx*dx
159
+
160
+ event[:angle] = atan2 dyp, dxp
161
+ event
162
+ end
163
+
164
+ def compound_linear xx, xy, yx, yy
165
+ @xx, @xy, @xz, @yx, @yy, @yz = [
166
+ xx * @xx + xy * @yx,
167
+ xx * @xy + xy * @yy,
168
+ xx * @xz + xy * @yz,
169
+ yx * @xx + yy * @yx,
170
+ yx * @xy + yy * @yy,
171
+ yx * @xz + yy * @yz,
172
+ ]
173
+ end
174
+
175
+ def translate dx, dy
176
+ raise ArgumentError, 'dx and dy must be numbers' unless dx.is_a?(Numeric) && dy.is_a?(Numeric)
177
+ @xz += dx
178
+ @yz += dy
179
+ end
180
+
181
+ def horizontal_flip
182
+ compound_linear -1, 0, 0, 1
183
+ end
184
+
185
+ def vertical_flip
186
+ compound_linear 1, 0, 0, -1
187
+ end
188
+
189
+ def rotate angle
190
+ raise ArgumentError, 'angle must be a number' unless angle.is_a? Numeric
191
+ warn 'Are you using degrees as angle unit instead of radians?' if angle != 0 && angle % 45 == 0
192
+ c = cos angle
193
+ s = sin angle
194
+ compound_linear c, -s, s, c
195
+ end
196
+
197
+ def scale sx, sy = sx
198
+ raise ArgumentError, 'sx and sy must be numbers' unless sx.is_a?(Numeric) && sy.is_a?(Numeric)
199
+ compound_linear sx, 0, 0, sy
200
+ end
201
+
202
+ def beat_translate delta_beat
203
+ raise ArgumentError, 'delta_beat must be a number' unless delta_beat.is_a? Numeric
204
+ warn 'Rational is recommended over Float for delta_beat' if delta_beat.is_a? Float
205
+ @t1 += delta_beat.to_r
206
+ end
207
+ end
208
+
209
+ class TipPointStart
210
+
211
+ def initialize x = 0, y = 0, relative_time = 0.0, relative: true, speed: nil,
212
+ relative_beat: nil, beat_speed: nil
213
+ @x = x
214
+ @y = y
215
+ @relative_time = relative_time
216
+ @relative = relative
217
+ @speed = speed
218
+ @relative_beat = relative_beat
219
+ @beat_speed = beat_speed
220
+ check
221
+ end
222
+
223
+ def check
224
+ %i[@relative_time @speed @relative_beat @beat_speed].each do |key|
225
+ value = instance_variable_get key
226
+ raise ArgumentError, "cannot specify both #@time_key and #{key}" if @time_key && value&.!=(0)
227
+ @time_key = key if value&.!=(0)
228
+ end
229
+ @time_key ||= :@relative_time
230
+ end
231
+
232
+ def get_start_placeholder start_event
233
+ raise ArgumentError, "start_event is not tip-pointable" unless start_event.tip_pointable?
234
+ result = Event.new :placeholder, start_event.beat, start_event.bpm_changes
235
+ if @relative
236
+ result[:x] = start_event[:x] + @x
237
+ result[:y] = start_event[:y] + @y
238
+ else
239
+ result[:x] = @x
240
+ result[:y] = @y
241
+ end
242
+ case @time_key
243
+ when :@relative_time
244
+ raise ArgumentError, "relative_time must be a number" unless @relative_time.is_a? Numeric
245
+ raise ArgumentError, "relative_time must be non-negative" if @relative_time < 0
246
+ result.offset = -@relative_time.to_f
247
+ when :@speed
248
+ raise ArgumentError, "speed must be a number" unless @speed.is_a? Numeric
249
+ raise ArgumentError, "speed must be positive" if @speed <= 0
250
+ result.offset = -Math.hypot(result[:x] - start_event[:x], result[:y] - start_event[:y]) / @speed
251
+ when :@relative_beat
252
+ raise ArgumentError, "relative_beat must be a number" unless @relative_beat.is_a? Numeric
253
+ raise ArgumentError, "relative_beat must be non-negative" if @relative_beat < 0
254
+ warn "Rational is recommended over Float for relative_beat" if @relative_beat.is_a? Float
255
+ result.beat -= @relative_beat.to_r
256
+ when :@beat_speed
257
+ raise ArgumentError, "beat_speed must be a number" unless @beat_speed.is_a? Numeric
258
+ raise ArgumentError, "beat_speed must be positive" if @beat_speed <= 0
259
+ delta_beat = Math.hypot(result[:x] - start_event[:x], result[:y] - start_event[:y]) / @beat_speed
260
+ result.beat -= delta_beat.to_r # a little weird, but fine
261
+ end
262
+ result[:tip_point] = start_event[:tip_point]
263
+ result
264
+ end
265
+ end
266
+
267
+ COLORS = {
268
+ easy: '#3eb9fd',
269
+ normal: '#f19e56',
270
+ hard: '#e75e74',
271
+ master: '#8c68f3',
272
+ special: '#f156ee'
273
+ }
274
+
275
+ DIRECTIONS = {
276
+ right: 0.0,
277
+ up_right: Math::PI / 4,
278
+ up: Math::PI / 2,
279
+ up_left: Math::PI * 3 / 4,
280
+ left: Math::PI,
281
+ down_left: -Math::PI * 3 / 4,
282
+ down: -Math::PI / 2,
283
+ down_right: -Math::PI / 4
284
+ }
285
+
286
+ singleton_class.attr_reader :charts
287
+ @charts = {}
288
+
289
+ def self.open name, &block
290
+ result = @charts[name] ||= new name
291
+ result.instance_eval &block if block
292
+ result
293
+ end
294
+
295
+ def initialize name
296
+ @name = name
297
+ init_chart_info
298
+ init_state
299
+ end
300
+
301
+ def init_chart_info
302
+ @difficulty_name = ''
303
+ @difficulty_color = '#000000'
304
+ @difficulty = ''
305
+ @title = ''
306
+ @artist = ''
307
+ @charter = ''
308
+ @events = []
309
+ end
310
+
311
+ def init_state
312
+ @current_offset = nil
313
+ @current_beat = nil
314
+ @bpm_changes = nil
315
+ @tip_point_mode = :none
316
+ @current_tip_point = 0
317
+ @current_duplicate = 0
318
+ @tip_point_start_to_add = nil
319
+ @groups = [@events]
320
+ end
321
+
322
+ def title title
323
+ raise ArgumentError, 'title must be a string' unless title.is_a? String
324
+ @title = title
325
+ end
326
+
327
+ def artist artist
328
+ raise ArgumentError, 'artist must be a string' unless artist.is_a? String
329
+ @artist = artist
330
+ end
331
+
332
+ def charter charter
333
+ raise ArgumentError, 'charter must be a string' unless charter.is_a? String
334
+ @charter = charter
335
+ end
336
+
337
+ def difficulty_name difficulty_name
338
+ raise ArgumentError, 'difficulty_name must be a string' unless difficulty_name.is_a? String
339
+ @difficulty_name = difficulty_name
340
+ end
341
+
342
+ def difficulty_color difficulty_color
343
+ @difficulty_color = case difficulty_color
344
+ when Symbol
345
+ COLORS[difficulty_color]
346
+ when /^#[0-9a-fA-F]{6}$/
347
+ difficulty_color
348
+ when /^#[0-9a-fA-F]{3}$/
349
+ _, r, g, b = difficulty_color.chars
350
+ "##{r}#{r}#{g}#{g}#{b}#{b}"
351
+ when /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/
352
+ r, g, b = $1, $2, $3
353
+ sprintf '#%02x%02x%02x', r.to_i, g.to_i, b.to_i
354
+ when Integer
355
+ sprintf '#%06x', difficulty_color % 0x1000000
356
+ else
357
+ raise ArgumentError, 'unknown format of difficulty_color'
358
+ end
359
+ end
360
+
361
+ def difficulty difficulty
362
+ @difficulty = difficulty.to_s
363
+ end
364
+
365
+ def offset offset
366
+ raise ArgumentError, 'offset must be a number' unless offset.is_a? Numeric
367
+ @current_offset = offset.to_f
368
+ @current_beat = 0r
369
+ @bpm_changes = BpmChangeList.new @current_offset
370
+ end
371
+
372
+ def bpm bpm
373
+ raise OffsetError.new __method__ unless @bpm_changes
374
+ @bpm_changes.add @current_beat, bpm
375
+ end
376
+
377
+ def beat delta_beat = 0
378
+ raise OffsetError.new __method__ unless @current_beat
379
+ case delta_beat
380
+ when Integer, Rational
381
+ @current_beat += delta_beat.to_r
382
+ when Float
383
+ warn 'float beat is not recommended'
384
+ @current_beat += delta_beat.to_r
385
+ else
386
+ raise ArgumentError, 'invalid delta_beat'
387
+ end
388
+ end
389
+ alias b beat
390
+
391
+ def beat! beat = @current_beat
392
+ raise OffsetError.new __method__ unless @current_beat
393
+ case beat
394
+ when Integer, Rational
395
+ @current_beat = beat.to_r
396
+ when Float
397
+ warn 'float beat is not recommended'
398
+ @current_beat = beat.to_r
399
+ else
400
+ raise ArgumentError, 'invalid beat'
401
+ end
402
+ end
403
+ alias b! beat!
404
+
405
+ def time_at beat = @current_beat
406
+ raise OffsetError.new __method__ unless @bpm_changes
407
+ @bpm_changes.time_at beat
408
+ end
409
+
410
+ def tip_point_chain *args, preserve_beat: true, **opts, &block
411
+ raise ArgumentError, 'no block given' unless block
412
+ tip_point :chain, *args, **opts do
413
+ group preserve_beat: preserve_beat, &block
414
+ end.tap { @current_tip_point += 1 }
415
+ end
416
+ alias tp_chain tip_point_chain
417
+
418
+ def tip_point_drop *args, preserve_beat: true, **opts, &block
419
+ raise ArgumentError, 'no block given' unless block
420
+ tip_point :drop, *args, **opts do
421
+ group preserve_beat: preserve_beat, &block
422
+ end
423
+ end
424
+ alias tp_drop tip_point_drop
425
+
426
+ def group preserve_beat: true, &block
427
+ raise ArgumentError, 'no block given' unless block
428
+ @groups.push result = []
429
+ last_beat = @current_beat
430
+ instance_eval &block
431
+ beat! last_beat unless preserve_beat
432
+ @groups.delete_if { result.equal? _1 }
433
+ result
434
+ end
435
+
436
+ def clear_tip_point
437
+ TipPointError.ensure @tip_point_mode, :chain, :drop
438
+ @tip_point_start_to_add = nil
439
+ @tip_point_mode = :none
440
+ end
441
+
442
+ def tip_point mode, *args, **opts, &block
443
+ TipPointError.ensure @tip_point_mode, :none
444
+ @tip_point_mode = mode
445
+ @tip_point_start_to_add = TipPointStart.new *args, **opts
446
+ result = block.()
447
+ @tip_point_start_to_add = nil
448
+ @tip_point_mode = :none
449
+ result
450
+ end
451
+
452
+ def event type, duration_beats = nil, **properties
453
+ raise OffsetError.new __method__ unless @bpm_changes
454
+ event = Event.new type, @current_beat, duration_beats, @bpm_changes, **properties
455
+ @groups.each { _1.push event }
456
+ return event unless event.tip_pointable?
457
+ case @tip_point_mode
458
+ when :chain
459
+ push_tip_point_start event
460
+ @tip_point_start_to_add = nil
461
+ when :drop
462
+ push_tip_point_start event
463
+ @current_tip_point += 1
464
+ when :none
465
+ # pass
466
+ end
467
+ event
468
+ end
469
+
470
+ def push_tip_point_start start_event
471
+ start_event[:tip_point] = @current_tip_point.to_s
472
+ tip_point_start = @tip_point_start_to_add&.get_start_placeholder start_event
473
+ @groups.each { _1.push tip_point_start } if tip_point_start
474
+ end
475
+
476
+ def transform events, &block
477
+ raise ArgumentError, 'no block given' unless block
478
+ events = [events] if events.is_a? Event
479
+ transform = Transform.new
480
+ transform.instance_eval &block
481
+ events.each { transform.apply _1 }
482
+ end
483
+
484
+ def duplicate events
485
+ result = []
486
+ events.each do |event|
487
+ result.push event = event.dup
488
+ if event[:tip_point]
489
+ event[:tip_point] = "#@current_duplicate #{event[:tip_point]}"
490
+ end
491
+ @groups.each { _1.push event }
492
+ end
493
+ result
494
+ end
495
+
496
+ def tap x, y, text = ''
497
+ if !x.is_a?(Numeric) || !y.is_a?(Numeric)
498
+ raise ArgumentError, 'x and y must be numbers'
499
+ end
500
+ event :tap, x: x.to_f, y: y.to_f, text: text.to_s
501
+ end
502
+ alias t tap
503
+
504
+ def hold x, y, duration_beats, text = ''
505
+ if !x.is_a?(Numeric) || !y.is_a?(Numeric) || !duration_beats.is_a?(Numeric)
506
+ raise ArgumentError, 'x, y, and duration must be numbers'
507
+ end
508
+ if duration_beats <= 0
509
+ raise ArgumentError, 'duration must be positive'
510
+ end
511
+ if duration_beats.is_a? Float
512
+ warn 'Rational is recommended over Float for duration_beats'
513
+ end
514
+ event :hold, duration_beats.to_r, x: x.to_f, y: y.to_f, text: text.to_s
515
+ end
516
+ alias h hold
517
+
518
+ def drag x, y
519
+ if !x.is_a?(Numeric) || !y.is_a?(Numeric)
520
+ raise ArgumentError, 'x and y must be numbers'
521
+ end
522
+ event :drag, x: x.to_f, y: y.to_f
523
+ end
524
+ alias d drag
525
+
526
+ def flick x, y, direction, text = ''
527
+ if !x.is_a?(Numeric) || !y.is_a?(Numeric)
528
+ raise ArgumentError, 'x and y must be numbers'
529
+ end
530
+ if direction.is_a? Symbol
531
+ direction = DIRECTIONS[direction]
532
+ elsif direction.is_a? Numeric
533
+ warn 'Are you using degrees as angle unit instead of radians?' if direction != 0 && direction % 45 == 0
534
+ direction = direction.to_f
535
+ else
536
+ raise ArgumentError, 'direction must be a symbol or a number'
537
+ end
538
+ event :flick, x: x, y: y, angle: direction, text: text.to_s
539
+ end
540
+ alias f flick
541
+
542
+ def bg_note x, y, duration_beats = 0, text = ''
543
+ if !x.is_a?(Numeric) || !y.is_a?(Numeric)
544
+ raise ArgumentError, 'x and y must be numbers'
545
+ end
546
+ if duration_beats < 0
547
+ raise ArgumentError, 'duration must be non-negative'
548
+ end
549
+ if duration_beats.is_a? Float
550
+ warn 'Rational is recommended over Float for duration_beats'
551
+ end
552
+ event :bg_note, duration_beats.to_r, x: x.to_f, y: y.to_f, text: text.to_s
553
+ end
554
+
555
+ def big_text duration_beats = 0, text
556
+ event :big_text, duration_beats.to_r, text: text.to_s
557
+ end
558
+
559
+ %i[grid hexagon checkerboard diamond_grid pentagon turntable].each do |method_name|
560
+ define_method method_name do |duration_beats = 0|
561
+ if duration_beats < 0
562
+ raise ArgumentError, 'duration must be non-negative'
563
+ end
564
+ if duration_beats.is_a? Float
565
+ warn 'Rational is recommended over Float for duration_beats'
566
+ end
567
+ event method_name, duration_beats.to_r
568
+ end
569
+ end
570
+
571
+ def to_sunniesnow
572
+ result = Sunniesnow::Chart.new
573
+ result.title = @title
574
+ result.artist = @artist
575
+ result.charter = @charter
576
+ result.difficulty_name = @difficulty_name
577
+ result.difficulty_color = @difficulty_color
578
+ result.difficulty = @difficulty
579
+ @events.each { result.events.push _1.to_sunniesnow }
580
+ result
581
+ end
582
+
583
+ def output_json
584
+ to_sunniesnow.to_json
585
+ end
586
+
587
+ end