sscharter 0.7.0 → 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1695717274c0888590e92f6a3dabf86fca4a2d34aaad9f17dc9a88ab20311207
4
- data.tar.gz: 78096695809b63551d5f48a3c11d4120ead84157c1146b4064a9f8f1262b66c7
3
+ metadata.gz: 77116185ddcbeeeff551d0584bc35b8a10da9f0d7fcbae95a520b95909c355dd
4
+ data.tar.gz: 5594955220d37210bf710314b379e88d8a23a5a5174291c7d06c91cf25e37dee
5
5
  SHA512:
6
- metadata.gz: 8e64530c4c426710eae970e9bd7fd4609d307a75102cdea4920f9ed5ae8208313cc48efae86c545b95cdecaec79e6da6738d92d5db1f8861ea142b3f2b843c88
7
- data.tar.gz: 64725aa5bf3336f3ab3965f583cf4ed80e02296a18a1be9c1427542d9bc79164b602f99b19a2c7f45e243d32e8456911940971ffd3794e8027ec6e4db17d5221
6
+ metadata.gz: b67bc6805cf36ab1d69c4b85bbe0dce05d0db8b794caa6c8cc0cc8a7f2f28bb5c840688122527f2e2b44190740756020d4308a0ba0ca3bfbec570108cdf0a108
7
+ data.tar.gz: 6523d03c7b22171f37829136a21e4a378947eed410dd9f22c825046c6ffdb71269392d65f6b6621cb7c43521e994a8b6fc1d11351d9141432898fd12e61e5dc9
data/Gemfile CHANGED
@@ -2,17 +2,4 @@
2
2
 
3
3
  source "https://rubygems.org"
4
4
 
5
- # Specify your gem's dependencies in sscharter.gemspec
6
5
  gemspec
7
-
8
- group :develop do
9
- gem 'rake', '~> 13.0'
10
- gem 'minitest', '~> 5.0'
11
- end
12
-
13
- gem 'rubyzip', '~> 2.3'
14
- gem 'launchy', '~> 2.5'
15
- gem 'webrick', '~> 1.8'
16
- gem 'filewatcher', '~> 2.0'
17
- gem 'em-websocket', '~> 0.5'
18
- gem 'concurrent-ruby', '~> 1.3'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sscharter (0.7.0)
4
+ sscharter (0.8.0)
5
5
  concurrent-ruby (~> 1.3)
6
6
  em-websocket (~> 0.5)
7
7
  filewatcher (~> 2.0)
@@ -24,27 +24,23 @@ GEM
24
24
  http_parser.rb (0.8.0)
25
25
  launchy (2.5.2)
26
26
  addressable (~> 2.8)
27
- minitest (5.25.1)
27
+ minitest (5.25.4)
28
28
  module_methods (0.1.0)
29
29
  public_suffix (6.0.1)
30
30
  rake (13.2.1)
31
- rubyzip (2.3.2)
32
- webrick (1.8.1)
31
+ rubyzip (2.4.1)
32
+ webrick (1.9.1)
33
+ yard (0.9.37)
33
34
 
34
35
  PLATFORMS
35
36
  x64-mingw-ucrt
36
37
  x86_64-linux
37
38
 
38
39
  DEPENDENCIES
39
- concurrent-ruby (~> 1.3)
40
- em-websocket (~> 0.5)
41
- filewatcher (~> 2.0)
42
- launchy (~> 2.5)
43
40
  minitest (~> 5.0)
44
41
  rake (~> 13.0)
45
- rubyzip (~> 2.3)
46
42
  sscharter!
47
- webrick (~> 1.8)
43
+ yard (~> 0.9)
48
44
 
49
45
  BUNDLED WITH
50
- 2.5.17
46
+ 2.6.2
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2023 sunniesnow
3
+ Copyright (c) 2025 sunniesnow
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Sscharter
1
+ # sscharter
2
2
 
3
3
  A Ruby DSL for writing Sunniesnow charts.
4
4
 
@@ -7,7 +7,7 @@ A Ruby DSL for writing Sunniesnow charts.
7
7
  [Install Ruby](https://www.ruby-lang.org/en/documentation/installation/).
8
8
  Then, run
9
9
 
10
- ```shell
10
+ ```
11
11
  gem install sscharter
12
12
  ```
13
13
 
@@ -27,4 +27,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/sunnie
27
27
 
28
28
  ## Code of Conduct
29
29
 
30
- Everyone interacting in the Sscharter project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/sscharter/blob/master/CODE_OF_CONDUCT.md).
30
+ Everyone interacting in the sscharter project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/sunniesnow/sscharter/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rake/testtask"
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+ require 'yard'
5
6
 
6
- Rake::TestTask.new(:test) do |t|
7
- t.libs << "test"
8
- t.libs << "lib"
9
- t.test_files = FileList["test/**/test_*.rb"]
7
+ Rake::TestTask.new :test do |t|
8
+ t.libs << 'test'
9
+ t.libs << 'lib'
10
+ t.test_files = FileList['test/**/test_*.rb']
11
+ end
12
+
13
+ YARD::Rake::YardocTask.new do |t|
14
+ t.files.concat %w[README.md]
15
+ t.options.concat %w[--title sscharter --output-dir doc --exclude lib/sscharter/cli.rb --readme]
10
16
  end
11
17
 
12
18
  task default: :test
@@ -2,14 +2,41 @@
2
2
 
3
3
  require 'json'
4
4
 
5
+ # A class to represent a chart.
6
+ # Basically a wrapper around the metadata and events ({Sunniesnow::Event}) of a chart.
7
+ # The main method is {#to_json}, which converts the chart to a JSON string
8
+ # that is actually recognizable by Sunniesnow.
5
9
  class Sunniesnow::Chart
6
10
 
7
11
  using Sunniesnow::Utils
8
12
 
9
- attr_accessor :title, :artist, :charter
10
- attr_accessor :difficulty_name, :difficulty_color, :difficulty, :difficulty_sup
13
+ # The schema URL for the output JSON.
14
+ # This will be set as the `$schema` property in the generated JSON.
15
+ SCHEMA = 'https://sunniesnow.github.io/schema/chart-1.0.json'
16
+
17
+ # The title of the music.
18
+ # This is one of the metadata of the chart which will be reflected in the generated JSON.
19
+ # Also, see
20
+ # {chart file specifications}[https://sunniesnow.github.io/doc/chart.html#title]
21
+ # for more info.
22
+ attr_accessor :title
23
+
24
+ attr_accessor :artist
25
+ attr_accessor :charter
26
+ attr_accessor :difficulty_name
27
+ attr_accessor :difficulty_color
28
+ attr_accessor :difficulty
29
+ attr_accessor :difficulty_sup
30
+
31
+ # An array of Sunniesnow::Event.
11
32
  attr_reader :events
12
33
 
34
+ # @param [Integer] live_reload_port The port to use for live reload.
35
+ # It is useless if +production+ is +true+.
36
+ # @param [Boolean] production Whether the chart is in production or not.
37
+ # If +true+, the generated JSON (see {#to_json}) will not contain
38
+ # necessary information for sscharter integration in Sunniesnow
39
+ # (such as the live reload feature and reverse search feature).
13
40
  def initialize live_reload_port: 31108, production: false
14
41
  @title = ''
15
42
  @artist = ''
@@ -23,8 +50,13 @@ class Sunniesnow::Chart
23
50
  @production = production
24
51
  end
25
52
 
53
+ ##
54
+ # Convert to JSON.
55
+ # A Sunniesnow chart is always a JSON file in the level file.
56
+ # This method is used to generate that JSON file.
26
57
  def to_json *args
27
58
  hash = {
59
+ '$schema': SCHEMA,
28
60
  title: @title,
29
61
  artist: @artist,
30
62
  charter: @charter,
data/lib/sscharter/cli.rb CHANGED
@@ -22,13 +22,12 @@ module Sunniesnow
22
22
  module_function
23
23
 
24
24
  def config
25
- config_filename = File.join PROJECT_DIR, '.sscharter.yml'
26
- config_filename = File.join PROJECT_DIR, '.sscharter.yaml' unless File.exist? config_filename
27
- unless File.exist? config_filename
28
- puts 'No .sscharter.yml found'
25
+ filenames = %w[sscharter.yml sscharter.yaml .sscharter.yml .sscharter.yaml].map { File.join PROJECT_DIR, _1 }
26
+ unless filename = filenames.find { File.exist? _1 }
27
+ $stderr.puts 'Config file sscharter.yml not found'
29
28
  return nil
30
29
  end
31
- YAML.load_file config_filename, symbolize_names: true
30
+ YAML.load_file filename, symbolize_names: true
32
31
  end
33
32
 
34
33
  singleton_class.attr_reader :commands
@@ -87,20 +86,18 @@ Sunniesnow::Charter::CLI::Subcommand.new :init, option_parser do |project_dir =
87
86
  FileUtils.cp_r files, files_dir
88
87
  FileUtils.cd project_dir do
89
88
  File.write 'Gemfile', <<~GEMFILE
90
- # frozen_string_literal: true
91
89
  source 'https://rubygems.org'
92
90
  gem 'sscharter', '~> #{Sunniesnow::Charter::VERSION}'
93
91
  gem 'rake', '~> #{Rake::VERSION}'
94
92
  gem 'bundler', '~> #{Bundler::VERSION}'
95
93
  GEMFILE
96
94
  File.write 'Rakefile', <<~RAKEFILE
97
- # frozen_string_literal: true
98
95
  task default: :build
99
96
  task :build do
100
97
  exec 'bundle exec sscharter build'
101
98
  end
102
99
  task :serve do
103
- exec 'bundle exec sscharter serve'
100
+ exec 'bundle exec sscharter serve --no-open-browser'
104
101
  end
105
102
  RAKEFILE
106
103
  File.write '.gitignore', <<~GITIGNORE
@@ -108,7 +105,7 @@ Sunniesnow::Charter::CLI::Subcommand.new :init, option_parser do |project_dir =
108
105
  /tmp/
109
106
  /build/
110
107
  GITIGNORE
111
- File.write '.sscharter.yml', <<~SSCHARTER
108
+ File.write 'sscharter.yml', <<~SSCHARTER
112
109
  ---
113
110
  project_name: #{File.basename project_dir}
114
111
  build_dir: build
@@ -136,21 +133,19 @@ Sunniesnow::Charter::CLI::Subcommand.new :init, option_parser do |project_dir =
136
133
  README
137
134
  FileUtils.mkdir_p 'src'
138
135
  File.write 'src/master.rb', <<~CHART
139
- # frozen_string_literal: true
140
-
141
136
  Sunniesnow::Charter.open 'master' do
142
137
 
143
138
  title 'The title of the music'
144
139
  artist 'The artist of the music'
145
140
  charter 'Your name'
146
141
  difficulty_name 'Master'
147
- difficulty_color '#8c68f3'
142
+ difficulty_color :master
148
143
  difficulty '12'
149
144
 
150
145
  offset 0
151
146
  bpm 120
152
147
 
153
- tp_chain 0, 0, 1 do
148
+ tp_chain 0, 100, 1 do
154
149
  t -50, 0, 'hello'
155
150
  b 1 # proceed by 1 beat
156
151
  t 50, 0, 'world'
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Sunniesnow
4
4
  class Charter
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/lib/sscharter.rb CHANGED
@@ -30,6 +30,8 @@ class Sunniesnow::Charter
30
30
  class BpmChangeList
31
31
 
32
32
  class BpmChange
33
+ include Comparable
34
+
33
35
  attr_accessor :beat, :bps
34
36
 
35
37
  def initialize beat, bpm
@@ -51,6 +53,8 @@ class Sunniesnow::Charter
51
53
 
52
54
  def add beat, bpm
53
55
  @list.push BpmChange.new beat, bpm
56
+ @list.sort!
57
+ self
54
58
  end
55
59
 
56
60
  def time_at beat
@@ -286,16 +290,6 @@ class Sunniesnow::Charter
286
290
  }.freeze
287
291
 
288
292
  DIRECTIONS = {
289
- right: 0.0,
290
- up_right: Math::PI / 4,
291
- up: Math::PI / 2,
292
- up_left: Math::PI * 3 / 4,
293
- left: Math::PI,
294
- down_left: -Math::PI * 3 / 4,
295
- down: -Math::PI / 2,
296
- down_right: -Math::PI / 4
297
- }
298
- {
299
293
  right: %i[r],
300
294
  up_right: %i[ur ru],
301
295
  up: %i[u],
@@ -304,22 +298,60 @@ class Sunniesnow::Charter
304
298
  down_left: %i[dl ld],
305
299
  down: %i[d],
306
300
  down_right: %i[dr rd]
307
- }.each do |direction_name, aliases|
308
- aliases.each { DIRECTIONS[_1] = DIRECTIONS[direction_name] }
309
- end
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
+
310
314
  DIRECTIONS.freeze
311
315
 
312
- singleton_class.attr_reader :charts
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
313
322
  @charts = {}
314
323
 
324
+ # An array of events.
325
+ # @return [Array<Sunniesnow::Charter::Event>]
315
326
  attr_reader :events
316
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
317
346
  def self.open name, &block
318
347
  result = @charts[name] ||= new name
319
348
  result.instance_eval &block if block
320
349
  result
321
350
  end
322
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.
323
355
  def initialize name
324
356
  @name = name
325
357
  init_chart_info
@@ -350,30 +382,192 @@ class Sunniesnow::Charter
350
382
  @current_tip_point_group_stack = []
351
383
  @tip_point_peak = 0
352
384
  @current_duplicate = 0
385
+ @tip_point_start_stack = [nil]
353
386
  @tip_point_start_to_add_stack = [nil]
354
387
  @groups = [@events]
355
388
  end
356
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.
357
522
  def title title
358
523
  raise ArgumentError, 'title must be a string' unless title.is_a? String
359
524
  @title = title
360
525
  end
361
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.
362
533
  def artist artist
363
534
  raise ArgumentError, 'artist must be a string' unless artist.is_a? String
364
535
  @artist = artist
365
536
  end
366
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.
367
544
  def charter charter
368
545
  raise ArgumentError, 'charter must be a string' unless charter.is_a? String
369
546
  @charter = charter
370
547
  end
371
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.
372
555
  def difficulty_name difficulty_name
373
556
  raise ArgumentError, 'difficulty_name must be a string' unless difficulty_name.is_a? String
374
557
  @difficulty_name = difficulty_name
375
558
  end
376
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.
377
571
  def difficulty_color difficulty_color
378
572
  @difficulty_color = case difficulty_color
379
573
  when Symbol
@@ -393,25 +587,98 @@ class Sunniesnow::Charter
393
587
  end
394
588
  end
395
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).
396
598
  def difficulty difficulty
397
599
  @difficulty = difficulty.to_s
398
600
  end
399
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).
400
610
  def difficulty_sup difficulty_sup
401
611
  @difficulty_sup = difficulty_sup.to_s
402
612
  end
403
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
404
637
  def offset offset
405
638
  raise ArgumentError, 'offset must be a number' unless offset.is_a? Numeric
406
639
  @current_beat = 0r
407
640
  @bpm_changes = BpmChangeList.new offset.to_f
408
641
  end
409
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.
410
652
  def bpm bpm
411
653
  raise OffsetError.new __method__ unless @bpm_changes
412
654
  @bpm_changes.add @current_beat, bpm
413
655
  end
414
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
415
682
  def beat delta_beat = 0
416
683
  raise OffsetError.new __method__ unless @current_beat
417
684
  case delta_beat
@@ -426,6 +693,24 @@ class Sunniesnow::Charter
426
693
  end
427
694
  alias b beat
428
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
429
714
  def beat! beat = @current_beat
430
715
  raise OffsetError.new __method__ unless @current_beat
431
716
  case beat
@@ -440,156 +725,24 @@ class Sunniesnow::Charter
440
725
  end
441
726
  alias b! beat!
442
727
 
443
- def time_at beat = @current_beat
444
- raise OffsetError.new __method__ unless @bpm_changes
445
- @bpm_changes.time_at beat
446
- end
447
-
448
- %i[chain drop none].each do |mode|
449
- define_method "tip_point_#{mode}" do |*args, **opts, &block|
450
- tip_point mode, *args, **opts, &block
451
- end
452
- alias_method "tp_#{mode}", "tip_point_#{mode}"
453
- end
454
-
455
- def backup_beat
456
- {current_beat: @current_beat, bpm_changes: @bpm_changes}
457
- end
458
-
459
- def restore_beat backup
460
- @current_beat = backup[:current_beat]
461
- @bpm_changes = backup[:bpm_changes]
462
- end
463
-
464
- def group preserve_beat: true, &block
465
- raise ArgumentError, 'no block given' unless block
466
- @groups.push result = []
467
- beat_backup = backup_beat unless preserve_beat
468
- instance_eval &block
469
- restore_beat beat_backup unless preserve_beat
470
- @groups.delete_if { result.equal? _1 }
471
- result
472
- end
473
-
474
- def backup_state
475
- {
476
- current_beat: @current_beat,
477
- bpm_changes: @bpm_changes,
478
- tip_point_mode_stack: @tip_point_mode_stack.dup,
479
- current_tip_point_stack: @current_tip_point_stack.dup,
480
- current_tip_point_group_stack: @current_tip_point_group_stack.dup,
481
- current_duplicate: @current_duplicate,
482
- tip_point_start_to_add_stack: @tip_point_start_to_add_stack.dup,
483
- groups: @groups.dup
484
- }
485
- end
486
-
487
- def restore_state backup
488
- @current_beat = backup[:current_beat]
489
- @bpm_changes = backup[:bpm_changes]
490
- @tip_point_mode_stack = backup[:tip_point_mode_stack]
491
- @current_tip_point_stack = backup[:current_tip_point_stack]
492
- @current_tip_point_group_stack = backup[:current_tip_point_group_stack]
493
- @current_duplicate = backup[:current_duplicate]
494
- @tip_point_start_to_add_stack = backup[:tip_point_start_to_add_stack]
495
- @groups = backup[:groups]
496
- nil
497
- end
498
-
499
- def mark name
500
- @bookmarks[name] = backup_state
501
- name
502
- end
503
-
504
- def at name, preserve_beat: false, update_mark: false, &block
505
- raise ArgumentError, 'no block given' unless block
506
- raise ArgumentError, "unknown bookmark #{name}" unless bookmark = @bookmarks[name]
507
- backup = backup_state
508
- restore_state bookmark
509
- result = group &block
510
- mark name if update_mark
511
- beat_backup = backup_beat if preserve_beat
512
- restore_state backup
513
- restore_beat beat_backup if preserve_beat
514
- result
515
- end
516
-
517
- def tip_point mode, *args, preserve_beat: true, **opts, &block
518
- @tip_point_mode_stack.push mode
519
- if mode == :none
520
- @tip_point_start_to_add_stack.push nil
521
- @current_tip_point_stack.push nil
522
- else
523
- @tip_point_start_to_add_stack.push TipPointStart.new *args, **opts
524
- @current_tip_point_stack.push @tip_point_peak
525
- @tip_point_peak += 1
526
- end
527
- result = group preserve_beat: do
528
- @current_tip_point_group_stack.push @groups.last
529
- instance_eval &block
530
- end
531
- @tip_point_start_to_add_stack.pop
532
- @tip_point_mode_stack.pop
533
- @current_tip_point_stack.pop
534
- @current_tip_point_group_stack.pop
535
- result
536
- end
537
-
538
- def remove *events
539
- events.each { |event| @groups.each { _1.delete event } }
540
- end
541
-
542
- def event type, duration_beats = nil, **properties
543
- raise OffsetError.new __method__ unless @bpm_changes
544
- event = Event.new type, @current_beat, duration_beats, @bpm_changes, **properties
545
- @groups.each { _1.push event }
546
- return event unless event.tip_pointable?
547
- case @tip_point_mode_stack.last
548
- when :chain
549
- push_tip_point_start event
550
- @tip_point_start_to_add_stack[-1] = nil
551
- when :drop
552
- push_tip_point_start event
553
- @current_tip_point_stack[-1] = @tip_point_peak
554
- @tip_point_peak += 1
555
- when :none
556
- # pass
557
- end
558
- event
559
- end
560
-
561
- def push_tip_point_start start_event
562
- start_event[:tip_point] = @current_tip_point_stack.last.to_s
563
- tip_point_start = @tip_point_start_to_add_stack.last&.get_start_placeholder start_event
564
- return unless tip_point_start
565
- @groups.each do |group|
566
- group.push tip_point_start
567
- break if group.equal?(@current_tip_point_group_stack.last) && @tip_point_mode_stack.last != :drop
568
- end
569
- end
570
-
571
- def transform events, &block
572
- raise ArgumentError, 'no block given' unless block
573
- events = [events] if events.is_a? Event
574
- transform = Transform.new
575
- transform.instance_eval &block
576
- events.each { transform.apply _1 }
577
- end
578
-
579
- def duplicate events, new_tip_points: true
580
- result = []
581
- events.each do |event|
582
- next if event.type == :placeholder && !new_tip_points
583
- result.push event = event.dup
584
- if event[:tip_point] && new_tip_points
585
- event[:tip_point] = "#@current_duplicate #{event[:tip_point]}"
586
- end
587
- @groups.each { _1.push event }
588
- end
589
- @current_duplicate += 1 if new_tip_points
590
- result
591
- end
592
-
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
593
746
  def tap x, y, text = ''
594
747
  if !x.is_a?(Numeric) || !y.is_a?(Numeric)
595
748
  raise ArgumentError, 'x and y must be numbers'
@@ -598,6 +751,29 @@ class Sunniesnow::Charter
598
751
  end
599
752
  alias t tap
600
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
601
777
  def hold x, y, duration_beats, text = ''
602
778
  if !x.is_a?(Numeric) || !y.is_a?(Numeric) || !duration_beats.is_a?(Numeric)
603
779
  raise ArgumentError, 'x, y, and duration must be numbers'
@@ -612,6 +788,20 @@ class Sunniesnow::Charter
612
788
  end
613
789
  alias h hold
614
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)
615
805
  def drag x, y
616
806
  if !x.is_a?(Numeric) || !y.is_a?(Numeric)
617
807
  raise ArgumentError, 'x and y must be numbers'
@@ -620,6 +810,32 @@ class Sunniesnow::Charter
620
810
  end
621
811
  alias d drag
622
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
623
839
  def flick x, y, direction, text = ''
624
840
  if !x.is_a?(Numeric) || !y.is_a?(Numeric)
625
841
  raise ArgumentError, 'x and y must be numbers'
@@ -637,6 +853,32 @@ class Sunniesnow::Charter
637
853
  end
638
854
  alias f flick
639
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 ''
640
882
  def bg_note x, y, duration_beats = 0, text = nil
641
883
  if text.nil?
642
884
  if duration_beats.is_a? String
@@ -658,6 +900,23 @@ class Sunniesnow::Charter
658
900
  event :bg_note, duration_beats.to_r, x: x.to_f, y: y.to_f, text: text.to_s
659
901
  end
660
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!'
661
920
  def big_text duration_beats = 0, text
662
921
  unless duration_beats.is_a? Numeric
663
922
  raise ArgumentError, 'duration_beats must be a number'
@@ -671,6 +930,30 @@ class Sunniesnow::Charter
671
930
  event :big_text, duration_beats.to_r, text: text.to_s
672
931
  end
673
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'
674
957
  %i[grid hexagon checkerboard diamond_grid pentagon turntable hexagram].each do |method_name|
675
958
  define_method method_name do |duration_beats = 0|
676
959
  unless duration_beats.is_a? Numeric
@@ -686,25 +969,82 @@ class Sunniesnow::Charter
686
969
  end
687
970
  end
688
971
 
689
- def to_sunniesnow **opts
690
- result = Sunniesnow::Chart.new **opts
691
- result.title = @title
692
- result.artist = @artist
693
- result.charter = @charter
694
- result.difficulty_name = @difficulty_name
695
- result.difficulty_color = @difficulty_color
696
- result.difficulty = @difficulty
697
- result.difficulty_sup = @difficulty_sup
698
- @events.each { result.events.push _1.to_sunniesnow }
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
699
998
  result
700
999
  end
701
1000
 
702
- def output_json **opts
703
- to_sunniesnow(**opts).to_json
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 }
704
1009
  end
705
1010
 
706
- def inspect
707
- "#<Sunniesnow::Charter #@name>"
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
708
1048
  end
709
1049
 
710
1050
  def check(
@@ -734,4 +1074,6 @@ class Sunniesnow::Charter
734
1074
  end
735
1075
  end
736
1076
 
1077
+ # @!endgroup
1078
+
737
1079
  end
data/tutorial/tutorial.md CHANGED
@@ -107,7 +107,7 @@ big-d
107
107
  ├── README.md
108
108
  ├── src
109
109
  │   └── master.rb
110
- └── .sscharter.yml
110
+ └── sscharter.yml
111
111
  ```
112
112
 
113
113
  Here are some explanation for each of them.
@@ -121,13 +121,13 @@ see [the official documentation](https://bundler.io/man/gemfile.5.html).
121
121
  If you do not use Git as the version manager for your project, it does nothing.
122
122
  - `Rakefile` contains some tasks that can be run by [Rake](https://ruby.github.io/rake/).
123
123
  - `README.md` is the README file of your project.
124
- - `.sscharter.yml` is the sscharter configuration for your project.
124
+ - `sscharter.yml` is the sscharter configuration for your project.
125
125
  - `files` is the directory whose files will be included in the final level file.
126
- You can change the directory in `.sscharter.yml`.
126
+ You can change the directory in `sscharter.yml`.
127
127
  - `src` is the directory that contains the source codes of your project.
128
- You can change the directory in `.sscharter.yml`.
128
+ You can change the directory in `sscharter.yml`.
129
129
 
130
- Open `.sscharter.yml` using your text editor.
130
+ Open `sscharter.yml` using your text editor.
131
131
  Here are the contents that you should see:
132
132
 
133
133
  ```yaml
@@ -332,7 +332,7 @@ Then, edit `Rakefile` to disable browser launching and enable live restart:
332
332
 
333
333
  ```ruby
334
334
  task :serve do
335
- exec 'bundle exec sscharter serve --no-open-browser --live-restart'
335
+ exec 'bundle exec sscharter serve --no-open-browser'
336
336
  end
337
337
  ```
338
338
 
@@ -952,6 +952,56 @@ end
952
952
  # write something here...
953
953
  ```
954
954
 
955
+ Sometimes even `preserve_beat: false` is not convenient enough to write complicated note patterns.
956
+ The `mark` and `at` methods can help you with more complicated charts.
957
+
958
+ Use `mark` to mark a place and label it with a symbol of your choice
959
+ (e.g. `:left_hand`), and use `at` to write notes at that point.
960
+
961
+ ```ruby
962
+ group, preserve_beat: false do
963
+ # write something here...
964
+ mark :left_hand # mark the current place with the label `:left_hand`
965
+ end
966
+
967
+ group do
968
+ # write something here...
969
+ mark :right_hand # mark the current place with the label `:right_hand`
970
+ end
971
+
972
+ at :left_hand do
973
+ # write notes here as if continuing where `mark :left_hand` is
974
+ end
975
+
976
+ at :right_hand do
977
+ # write notes here as if continuing where `mark :right_hand` is
978
+ end
979
+ ```
980
+
981
+ You can also use `at` with `update_mark: true` to update the mark
982
+ to the place where the block in `at` is ended.
983
+
984
+ ```ruby
985
+ group do
986
+ # ...
987
+ mark :x
988
+ # ...
989
+ end
990
+
991
+ at :x, update_mark: true do
992
+ # ...
993
+ end
994
+
995
+ at :x do
996
+ # as if continuing the last `at :x, update_mark: true` block
997
+ end
998
+ ```
999
+
1000
+ Usually, when `at` finishes, the current beat is set
1001
+ to whatever it was before `at` was called.
1002
+ You can keep the current beat by using `preserve_beat: true`
1003
+ in the call of `at`.
1004
+
955
1005
  ### BPM changes
956
1006
 
957
1007
  > [!NOTE]
@@ -1191,6 +1241,21 @@ transform duplicate notes, new_tip_points: false do
1191
1241
  end
1192
1242
  ```
1193
1243
 
1244
+ When you use `mark` inside a tip point block,
1245
+ the tip point state is also preserved when you use `at` later.
1246
+ For example:
1247
+
1248
+ ```ruby
1249
+ tp_chain 0, 100, 1 do
1250
+ t 0, 0; b 1
1251
+ mark :my_mark
1252
+ end
1253
+
1254
+ at :my_mark do
1255
+ t 100, 0 # this note will be connected by the same tip point
1256
+ end
1257
+ ```
1258
+
1194
1259
  ## Advanced charting techniques
1195
1260
 
1196
1261
  TODO.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sscharter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ulysses Zhan
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-09 00:00:00.000000000 Z
11
+ date: 2025-06-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rubyzip
@@ -122,7 +122,21 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '13.0'
125
- description:
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.9'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.9'
139
+ description:
126
140
  email:
127
141
  - ulysseszhan@gmail.com
128
142
  executables:
@@ -148,7 +162,7 @@ licenses: []
148
162
  metadata:
149
163
  homepage_uri: https://github.com/sunniesnow/sscharter
150
164
  source_code_uri: https://github.com/sunniesnow/sscharter
151
- post_install_message:
165
+ post_install_message:
152
166
  rdoc_options: []
153
167
  require_paths:
154
168
  - lib
@@ -163,8 +177,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
163
177
  - !ruby/object:Gem::Version
164
178
  version: '0'
165
179
  requirements: []
166
- rubygems_version: 3.5.3
167
- signing_key:
180
+ rubygems_version: 3.5.16
181
+ signing_key:
168
182
  specification_version: 4
169
183
  summary: A Ruby DSL for writing Sunniesnow charts
170
184
  test_files: []