sscharter 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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