osb 1.0.3

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: dae3bb6b24e591176075dcfaeb67dc5e74c625c668e44dd91e8f35b660973222
4
+ data.tar.gz: 30a7304fe4b0c47a81d9a55ea0cc91d5e60775ab06f01aa85e4d76c46b241c0c
5
+ SHA512:
6
+ metadata.gz: 7fd1cc3edbf6bc0995eb04951ac640ca7d583130e36e46b8b9aa801cbbdbd040cbcd41f1b1ea2eff477361f9af39f7775487b80b65332737df0a7055830c877e
7
+ data.tar.gz: 3d4a452f5fdc3002e544d5ffc32542fa850f792dc01e19da19a32ae506da120fccfdf49d68c2c6d25534320d9406bdf575a67d63c1b1904a98878f26be9c1424
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # A moving image.
5
+ class Animation
6
+ # @api private
7
+ attr_reader :commands, :layer
8
+ include Commandable
9
+
10
+ # @param [String] layer the layer the object appears on.
11
+ # @param [String] origin where on the image should osu! consider that image's origin (coordinate) to be.
12
+ # @param [String] file_path filename of the image.
13
+ # @param [Vector2, nil] initial_position where the object should be by default.
14
+ # @param [Integer] frame_count how many frames the animation has.
15
+ # @param [Integer] frame_delay how many milliseconds should be in between each frame.
16
+ # @param [Boolean] repeat if the animation should loop or not.
17
+ def initialize(
18
+ layer: Osb::Layer::Background,
19
+ origin: Osb::Origin::Center,
20
+ file_path:,
21
+ initial_position: nil,
22
+ frame_count:,
23
+ frame_delay:,
24
+ repeat: false
25
+ )
26
+ Internal.assert_type!(layer, String, "layer")
27
+ Internal.assert_value!(layer, Osb::Layer::ALL, "layer")
28
+
29
+ Internal.assert_type!(origin, String, "origin")
30
+ Internal.assert_value!(origin, Osb::Origin::ALL, "origin")
31
+
32
+ Internal.assert_type!(file_path, String, "file_path")
33
+ Internal.assert_file_name_ext!(file_path, %w[png jpg jpeg])
34
+ if initial_position
35
+ Internal.assert_type!(initial_position, Vector2, "initial_position")
36
+ end
37
+
38
+ Internal.assert_type!(frame_count, Integer, "frame_count")
39
+ Internal.assert_type!(frame_delay, Integer, "frame_delay")
40
+ Internal.assert_type!(repeat, Internal::Boolean, "repeat")
41
+
42
+ @layer = layer
43
+
44
+ first_command = "Animation,#{layer},#{origin},\"#{file_path}\""
45
+ if initial_position
46
+ first_command += ",#{initial_position.x},#{initial_position.y}"
47
+ else
48
+ first_command += ",,"
49
+ end
50
+ first_command += ",#{frame_count}"
51
+ first_command += ",#{frame_delay}"
52
+ looptype = repeat ? "LoopForever" : "LoopOnce"
53
+ first_command += ",#{type}" if repeat
54
+ # @type [Array<String>]
55
+ @commands = [first_command]
56
+ end
57
+ end
58
+ end
data/lib/osb/assert.rb ADDED
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # @api private
5
+ class TypeError < StandardError
6
+ end
7
+
8
+ # @api private
9
+ class InvalidValueError < StandardError
10
+ end
11
+
12
+ # @api private
13
+ module Internal
14
+ # @api private
15
+ Boolean = [TrueClass, FalseClass]
16
+
17
+ # @api private
18
+ class TypedArray
19
+ # @param [Class] type
20
+ def initialize(type)
21
+ @type = type
22
+ end
23
+
24
+ def name
25
+ "Array<#{@type.name}>"
26
+ end
27
+
28
+ def valid?(object)
29
+ object.is_a?(Array) && object.all? { |value| value.is_a?(@type) }
30
+ end
31
+ end
32
+
33
+ # @api private
34
+ # @type [Hash{Class => Hash{Class => Object}}]
35
+ T = { Array => { Numeric => TypedArray.new(Numeric) } }
36
+
37
+ # Check if supplied argument is correctly typed.
38
+ # @param [Object] arg
39
+ # @param [BasicObject, Array, TypedArray] possible_types
40
+ # @param [String] param_name
41
+ # @return [void]
42
+ # @api private
43
+ def self.assert_type!(arg, possible_types, param_name)
44
+ if possible_types.is_a?(Array)
45
+ valid =
46
+ possible_types.any? do |type|
47
+ type.is_a?(TypedArray) ? type.valid?(arg) : arg.is_a?(type)
48
+ end
49
+ unless valid
50
+ accepted_types = possible_types.map { |type| type.name }.join(" or ")
51
+
52
+ raise TypeError,
53
+ "Parameter #{param_name} expects type #{accepted_types}, " +
54
+ "got type #{arg.class.name} instead."
55
+ end
56
+ elsif possible_types.is_a?(TypedArray)
57
+ valid = possible_types.valid?(arg)
58
+ unless valid
59
+ raise TypeError,
60
+ "Parameter #{param_name} expects type Array<#{possible_types.type.name}>, " +
61
+ "got type #{arg.class.name} instead."
62
+ end
63
+ else
64
+ type = possible_types
65
+ unless arg.is_a?(type)
66
+ raise TypeError,
67
+ "Parameter #{param_name} expects type #{type.name}, " +
68
+ "got type #{arg.class.name} instead."
69
+ end
70
+ end
71
+ end
72
+
73
+ # Ensures the supplied argument is correct.
74
+ # @param [Object] arg
75
+ # @param [BasicObject, Array, Range] possible_values
76
+ # @param [String] param_name
77
+ # @return [void]
78
+ # @api private
79
+ def self.assert_value!(arg, possible_values, param_name)
80
+ val =
81
+ if arg.is_a?(String) && arg.empty?
82
+ "an empty string"
83
+ else
84
+ arg
85
+ end
86
+
87
+ if possible_values.is_a?(Array)
88
+ valid = possible_values.any? { |value| arg == value }
89
+ unless valid
90
+ accepted_values = possible_values.join(" or ")
91
+
92
+ raise InvalidValueError,
93
+ "Parameter #{param_name} expects #{accepted_values}, " +
94
+ "got #{val} instead."
95
+ end
96
+ elsif possible_values.is_a?(Range)
97
+ valid = arg >= possible_values.min && arg <= possible_values.max
98
+ unless valid
99
+ raise InvalidValueError,
100
+ "Parameter #{param_name} expects value within " +
101
+ "#{possible_values.min} to #{possible_values.max}, " +
102
+ "got #{val} instead."
103
+ end
104
+ else
105
+ unless arg == possible_values
106
+ raise InvalidValueError,
107
+ "Parameter #{param_name} expects #{possible_values}, " +
108
+ "got #{val} instead."
109
+ end
110
+ end
111
+ end
112
+
113
+ # Ensure the file name extenstion is correct.
114
+ # @param [String] file_name
115
+ # @param [String, Array<String>] exts
116
+ # @api private
117
+ def self.assert_file_name_ext!(file_name, exts)
118
+ if exts.is_a?(Array)
119
+ exts_ = exts.join("|")
120
+ exts__ = exts.join(" or ")
121
+ unless /[\w\s\d]+\.(#{exts_})/.match(file_name)
122
+ raise InvalidValueError, "File name must end with #{exts__}"
123
+ end
124
+ else
125
+ unless /[\w\s\d]+\.#{exts}/.match(file_name)
126
+ raise InvalidValueError, "File name must end with #{exts}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,14 @@
1
+ module Osb
2
+ # Beatmap's background.
3
+ class Background
4
+ # @api private
5
+ attr_reader :command
6
+
7
+ # @param [String] file_name location of the background image relative to the beatmap directory.
8
+ def initialize(file_path:)
9
+ Internal.assert_type!(file_path, String, "file_path")
10
+ Internal.assert_file_name_ext!(file_path, %w[png jpg jpeg])
11
+ @command = "0,0,\"#{file_path}\""
12
+ end
13
+ end
14
+ end
data/lib/osb/color.rb ADDED
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # Represents an RGB color.
5
+ class Color
6
+ attr_accessor :r, :g, :b
7
+ # @attribute [rw] r
8
+ # @return Red value.
9
+ # @attribute [rw] g
10
+ # @return Green value.
11
+ # @attribute [rw] b
12
+ # @return Blue value.
13
+
14
+ # @param [Integer] r red value
15
+ # @param [Integer] g green value
16
+ # @param [Integer] b blue value
17
+ def initialize(r, g, b)
18
+ Internal.assert_type!(r, Integer, "r")
19
+ Internal.assert_value!(r, 0..255, "r")
20
+
21
+ Internal.assert_type!(g, Integer, "g")
22
+ Internal.assert_value!(g, 0..255, "g")
23
+
24
+ Internal.assert_type!(b, Integer, "b")
25
+ Internal.assert_value!(b, 0..255, "b")
26
+
27
+ @r = r
28
+ @g = g
29
+ @b = b
30
+ end
31
+
32
+ # Returns whether 2 colors are not equal.
33
+ # @param [Color] color
34
+ def !=(color)
35
+ Internal.assert_type!(color, Color, "color")
36
+
37
+ color.r != self.r && color.g != self.g && color.b != self.b
38
+ end
39
+
40
+ # Converts an HSL color value to RGB.
41
+ # @param [Integer] h
42
+ # @param [Integer] s
43
+ # @param [Integer] l
44
+ # @return [Color]
45
+ def self.from_hsl(h, s, l)
46
+ Internal.assert_type!(h, Integer, "h")
47
+ Internal.assert_type!(s, Integer, "s")
48
+ Internal.assert_type!(l, Integer, "l")
49
+
50
+ h = h / 360.0
51
+ s = s / 100.0
52
+ l = l / 100.0
53
+
54
+ r = 0.0
55
+ g = 0.0
56
+ b = 0.0
57
+
58
+ if s == 0.0
59
+ r = l.to_f
60
+ g = l.to_f
61
+ b = l.to_f
62
+ else
63
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
64
+ p = 2 * l - q
65
+ r = hue_to_rgb(p, q, h + 1 / 3.0)
66
+ g = hue_to_rgb(p, q, h)
67
+ b = hue_to_rgb(p, q, h - 1 / 3.0)
68
+ end
69
+
70
+ Color.new((r * 255).round, (g * 255).round, (b * 255).round)
71
+ end
72
+
73
+ # @api private
74
+ # @param [Float] p
75
+ # @param [Float] q
76
+ # @param [Float] t_
77
+ # @return [Float]
78
+ def self.hue_to_rgb(p, q, t_)
79
+ t = t_ + 1 if t_ < 0
80
+ t = t_ - 1 if t_ > 1
81
+ return(p + (q - p) * 6 * t) if t < 1 / 6.0
82
+ return q if t < 1 / 2.0
83
+ return(p + (q - p) * (2 / 3.0 - t) * 6) if t < 2 / 3.0
84
+ return p
85
+ end
86
+
87
+ # Create a Color object from hex string.
88
+ # @param [String] hex
89
+ # @return [Color]
90
+ def self.from_hex(hex)
91
+ Internal.assert_type!(hex, String, "hex")
92
+
93
+ hex.gsub!("#", "")
94
+ components = hex.scan(/.{2}/)
95
+ components.collect { |component| component.to_i(16) }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ module Internal
5
+ # @param [Integer] time
6
+ # @return [void]
7
+ # @api private
8
+ def self.raise_if_invalid_start_time!(time)
9
+ Internal.assert_type!(time, Integer, "start_time")
10
+ end
11
+
12
+ # @param [Integer] time
13
+ # @return [void]
14
+ # @api private
15
+ def self.raise_if_invalid_end_time!(time)
16
+ Internal.assert_type!(time, Integer, "end_time")
17
+ end
18
+
19
+ # @param [Integer] easing
20
+ # @return [void]
21
+ # @api private
22
+ def self.raise_if_invalid_easing!(easing)
23
+ Internal.assert_type!(easing, Integer, "easing")
24
+ Internal.assert_value!(easing, Easing::ALL, "easing")
25
+ end
26
+ end
27
+
28
+ module Commandable
29
+ private def tab_level
30
+ @is_in_trigger ? 2 : 1
31
+ end
32
+
33
+ private def raise_if_trigger_called!
34
+ if @trigger_called
35
+ raise RuntimeError, "Do not add commands after #trigger is called."
36
+ end
37
+ end
38
+
39
+ # Change the opacity of the object (how transparent it is).
40
+ # @param [Integer] start_time
41
+ # @param [Integer] end_time
42
+ # @param [Integer] easing
43
+ # @param [Numeric] start_opacity
44
+ # @param [Numeric] end_opacity
45
+ def fade(
46
+ start_time:,
47
+ end_time: start_time,
48
+ easing: Easing::Linear,
49
+ start_opacity:,
50
+ end_opacity: start_opacity
51
+ )
52
+ self.raise_if_trigger_called!
53
+ Internal.raise_if_invalid_start_time!(start_time)
54
+ Internal.raise_if_invalid_end_time!(end_time)
55
+ Internal.raise_if_invalid_easing!(easing)
56
+ Internal.assert_type!(start_opacity, Numeric, "start_opacity")
57
+ Internal.assert_type!(end_opacity, Numeric, "end_opacity")
58
+ Internal.assert_value!(start_opacity, 0..1, "start_opacity")
59
+ Internal.assert_value!(end_opacity, 0..1, "end_opacity")
60
+
61
+ end_time = "" if start_time == end_time
62
+ tabs = " " * self.tab_level
63
+ command = "#{tabs}F,#{start_time},#{end_time},#{start_opacity}"
64
+ command += ",#{end_opacity}" if end_opacity != start_opacity
65
+ @commands << command
66
+ end
67
+
68
+ # Move the object to a new position in the play area.
69
+ # @param [Integer] start_time
70
+ # @param [Integer] end_time
71
+ # @param [Integer] easing
72
+ # @param [Osb::Vector2, Array<Numeric>] start_position
73
+ # @param [Osb::Vector2, Array<Numeric>] end_position
74
+ def move(
75
+ start_time:,
76
+ end_time: start_time,
77
+ easing: Easing::Linear,
78
+ start_position:,
79
+ end_position: start_position
80
+ )
81
+ self.raise_if_trigger_called!
82
+ Internal.raise_if_invalid_start_time!(start_time)
83
+ Internal.raise_if_invalid_end_time!(end_time)
84
+ Internal.raise_if_invalid_easing!(easing)
85
+ Internal.assert_type!(
86
+ start_position,
87
+ [Osb::Vector2, T[Array][Numeric]],
88
+ "start_position"
89
+ )
90
+ Internal.assert_type!(
91
+ end_position,
92
+ [Osb::Vector2, T[Array][Numeric]],
93
+ "end_position"
94
+ )
95
+ if start_position.is_a?(Array)
96
+ start_position = Osb::Vector2.new(start_position)
97
+ end
98
+ end_position = Osb::Vector2.new(end_position) if end_position.is_a?(Array)
99
+ end_time = "" if start_time == end_time
100
+ tabs = " " * self.tab_level
101
+ command =
102
+ "#{tabs}M,#{start_time},#{end_time},#{start_position.x},#{start_position.y}"
103
+ command += ",#{end_position.x},#{end_position.y}" if end_position !=
104
+ start_position
105
+ @commands << command
106
+ end
107
+
108
+ # Move the object along the x axis.
109
+ # @param [Integer] start_time
110
+ # @param [Integer] end_time
111
+ # @param [Integer] easing
112
+ # @param [Numeric] start_x
113
+ # @param [Numeric] end_x
114
+ def move_x(
115
+ start_time:,
116
+ end_time: start_time,
117
+ easing: Easing::Linear,
118
+ start_x:,
119
+ end_x: start_x
120
+ )
121
+ self.raise_if_trigger_called!
122
+ Internal.raise_if_invalid_start_time!(start_time)
123
+ Internal.raise_if_invalid_end_time!(end_time)
124
+ Internal.raise_if_invalid_easing!(easing)
125
+ Internal.assert_type!(start_x, Numeric, "start_x")
126
+ Internal.assert_type!(end_x, Numeric, "end_x")
127
+
128
+ end_time = "" if start_time == end_time
129
+ tabs = " " * self.tab_level
130
+ command = "#{tabs}MX,#{start_time},#{end_time},#{start_x}"
131
+ command += ",#{end_x}" if end_x
132
+ @commands << command
133
+ end
134
+
135
+ # Move the object along the y axis.
136
+ # @param [Integer] start_time
137
+ # @param [Integer] end_time
138
+ # @param [Integer] easing
139
+ # @param [Numeric] start_y
140
+ # @param [Numeric] end_y
141
+ def move_y(
142
+ start_time:,
143
+ end_time: start_time,
144
+ easing: Easing::Linear,
145
+ start_y:,
146
+ end_y: start_y
147
+ )
148
+ self.raise_if_trigger_called!
149
+ Internal.raise_if_invalid_start_time!(start_time)
150
+ Internal.raise_if_invalid_end_time!(end_time) if end_time
151
+ Internal.raise_if_invalid_easing!(easing)
152
+ Internal.assert_type!(start_y, Numeric, "start_y")
153
+ Internal.assert_type!(end_y, Numeric, "end_y")
154
+
155
+ end_time = "" if start_time == end_time
156
+ tabs = " " * self.tab_level
157
+ command = "#{tabs}MY,#{start_time},#{end_time},#{start_y}"
158
+ command += ",#{end_y}" if end_y != start_y
159
+ @commands << command
160
+ end
161
+
162
+ # Change the size of the object relative to its original size. Will scale
163
+ # seperatedly if given +Osb::Vector2+s or +Array<Numeric>+s. The scaling is
164
+ # affected by the object's origin
165
+ # @param [Integer] start_time
166
+ # @param [Integer] end_time
167
+ # @param [Integer] easing
168
+ # @param [Numeric, Osb::Vector2, Array<Numeric>] start_scale
169
+ # @param [Numeric, Osb::Vector2, Array<Numeric>] end_scale
170
+ def scale(
171
+ start_time:,
172
+ end_time: start_time,
173
+ easing: Easing::Linear,
174
+ start_scale:,
175
+ end_scale: start_scale
176
+ )
177
+ self.raise_if_trigger_called!
178
+ Internal.raise_if_invalid_start_time!(start_time)
179
+ Internal.raise_if_invalid_end_time!(end_time) if end_time
180
+ Internal.raise_if_invalid_easing!(easing)
181
+ Internal.assert_type!(
182
+ start_scale,
183
+ [Numeric, T[Array][Numeric], Osb::Vector2],
184
+ "start_scale"
185
+ )
186
+ Internal.assert_type!(
187
+ end_scale,
188
+ [Numeric, T[Array][Numeric], Osb::Vector2],
189
+ "end_scale"
190
+ )
191
+
192
+ end_time = "" if start_time == end_time
193
+ tabs = " " * self.tab_level
194
+
195
+ if start_scale.is_a?(Numeric)
196
+ unless end_scale.is_a?(Numeric)
197
+ raise InvalidValueError,
198
+ "start_scale and end_scale must be either both Numeric values or Vector2-like values."
199
+ end
200
+ command = "#{tabs}S,#{start_time},#{end_time},#{start_scale}"
201
+ command += ",#{end_scale}" if end_scale != start_scale
202
+ @commands << command
203
+ else
204
+ if end_scale.is_a?(Numeric)
205
+ raise InvalidValueError,
206
+ "start_scale and end_scale must be either both Numeric values or Vector2-like values."
207
+ end
208
+
209
+ start_scale = Osb::Vector2.new(start_scale) if start_scale.is_a?(Array)
210
+
211
+ end_scale = Osb::Vector2.new(end_scale) if end_scale.is_a?(Array)
212
+
213
+ command =
214
+ "#{tabs}V,#{start_time},#{end_time},#{start_scale.x},#{start_scale.y}"
215
+ command += ",#{end_scale.x},#{end_scale.y}" if end_scale
216
+ @commands << command
217
+ end
218
+ end
219
+
220
+ # Rotate the object around its origin.
221
+ # @param [Integer] start_time
222
+ # @param [Integer] end_time
223
+ # @param [Integer] easing
224
+ # @param [Float] start_angle start angle in radians.
225
+ # @param [Float] end_angle end angle in radians.
226
+ def rotate(
227
+ start_time:,
228
+ end_time: start_time,
229
+ easing: Easing::Linear,
230
+ start_angle:,
231
+ end_angle: start_angle
232
+ )
233
+ self.raise_if_trigger_called!
234
+ Internal.raise_if_invalid_start_time!(start_time)
235
+ Internal.raise_if_invalid_end_time!(end_time)
236
+ Internal.raise_if_invalid_easing!(easing)
237
+ Internal.assert_type!(start_angle, Numeric, "start_angle")
238
+ Internal.assert_type!(end_angle, Numeric, "end_angle")
239
+
240
+ end_time = "" if start_time == end_time
241
+ tabs = " " * self.tab_level
242
+ command = "#{tabs}R,#{start_time},#{end_time},#{start_angle}"
243
+ command += ",#{end_angle}" if end_angle != start_angle
244
+ @commands << command
245
+ end
246
+
247
+ # The virtual light source colour on the object. The colours of the pixels on the object are determined subtractively.
248
+ # @param [Integer] start_time
249
+ # @param [Integer] end_time
250
+ # @param [Integer] easing
251
+ # @param [Osb::Color] start_color
252
+ # @param [Osb::Color] end_color
253
+ def color(
254
+ start_time:,
255
+ end_time: start_time,
256
+ easing: Easing::Linear,
257
+ start_color:,
258
+ end_color: start_color
259
+ )
260
+ self.raise_if_trigger_called!
261
+ Internal.raise_if_invalid_start_time!(start_time)
262
+ Internal.raise_if_invalid_end_time!(end_time)
263
+ Internal.raise_if_invalid_easing!(easing)
264
+ Internal.assert_type!(start_color, Osb::Color, "start_color")
265
+ Internal.assert_type!(end_color, Osb::Color, "end_color")
266
+
267
+ end_time = "" if start_time == end_time
268
+ tabs = " " * self.tab_level
269
+ command =
270
+ "#{tabs}C,#{start_time},#{end_time},#{start_color.r},#{start_color.g},#{start_color.b}"
271
+ if end_color != start_color
272
+ command += ",#{end_color.r},#{end_color.g},#{end_color.b}"
273
+ end
274
+ @commands << command
275
+ end
276
+
277
+ # Flip the object horizontally or vertically.
278
+ # @param [Integer] start_time
279
+ # @param [Integer] end_time
280
+ # @param [Boolean] horizontally
281
+ # @param [Boolean] vertically
282
+ def flip(start_time:, end_time:, horizontally: true, vertically: false)
283
+ self.raise_if_trigger_called!
284
+ Internal.raise_if_invalid_start_time!(start_time)
285
+ Internal.raise_if_invalid_end_time!(end_time)
286
+ Internal.assert_type!(horizontally, Internal::Boolean, "horizontally")
287
+ Internal.assert_type!(vertically, Internal::Boolean, "vertically")
288
+
289
+ if horizontally && vertically
290
+ raise InvalidValueError,
291
+ "Cannot flip an object both horizontally and vertically."
292
+ end
293
+ if !horizontally && !vertically
294
+ raise InvalidValueError, "Specify a direction to flip."
295
+ end
296
+
297
+ direction = horizontally ? "H" : "V"
298
+ end_time = "" if start_time == end_time
299
+ tabs = " " * self.tab_level
300
+ command = "#{tabs}P,#{start_time},#{end_time},#{direction}"
301
+ @commands << command
302
+ end
303
+
304
+ # Use additive-color blending instead of alpha-blending.
305
+ # @param [Integer] start_time
306
+ # @param [Integer] end_time
307
+ def additive_color_blending(start_time:, end_time:)
308
+ self.raise_if_trigger_called!
309
+ Internal.raise_if_invalid_start_time!(start_time)
310
+ Internal.raise_if_invalid_end_time!(end_time)
311
+
312
+ tabs = " " * self.tab_level
313
+ command = "#{tabs}P,#{start_time},#{end_time},A"
314
+ @commands << command
315
+ end
316
+
317
+ # Add a group of commands on a specific condition.
318
+ # `#trigger` can only be called on an empty object declaration (no commands).
319
+ # Pass a block to this method call to specify which commands to run if
320
+ # the condition is met.
321
+ #
322
+ # @example
323
+ # img.trigger(on: "Passing", start_time: 0, end_time: 1000) do
324
+ # img.fade(start_time: 0, start_opacity: 0.5)
325
+ # end
326
+ #
327
+ # In addition to the "implicit" player feedback via the separate
328
+ # Pass/Fail layers, you can use one of several Trigger conditions
329
+ # to cause a series of events to happen whenever that condition is
330
+ # fulfilled within a certain time period.
331
+ # Note that `start_time` and `end_time` of any commands called inside
332
+ # the block become relative to the `start_time` and `end_time` of the
333
+ # `#trigger` command.
334
+ #
335
+ # While osu! supports trigger on hitsounds playing, we decided to not
336
+ # include it in because it is unreliable/unpredictable.
337
+ #
338
+ # @param [String] on indicates the trigger condition. It can be "Failing" or "Passing".
339
+ # @param [Integer] start_time the timestamp at which the trigger becomes valid.
340
+ # @param [Integer] end_time the timestamp at which the trigger stops being valid.
341
+ def trigger(on:, start_time:, end_time:)
342
+ self.raise_if_trigger_called!
343
+ Internal.raise_if_invalid_start_time!(start_time)
344
+ Internal.raise_if_invalid_end_time!(end_time)
345
+ Internal.assert_type!(on, String, "on")
346
+ Internal.assert_value!(on, %w[Passing Failing], "on")
347
+
348
+ unless block_given?
349
+ raise InvalidValueError, "Do not use an empty trigger."
350
+ end
351
+
352
+ if @commands.size > 1
353
+ raise RuntimeError, "Do not call #trigger after any other commands."
354
+ end
355
+
356
+ if @is_in_trigger
357
+ raise RuntimeError,
358
+ "Do not call #trigger inside another #trigger block."
359
+ end
360
+
361
+ command = " T,#{on},#{start_time},#{end_time}"
362
+ @commands << command
363
+
364
+ @is_in_trigger = true
365
+ yield self
366
+ unless @commands.size > 1
367
+ raise InvalidValueError, "Do not use an empty trigger."
368
+ end
369
+ @is_in_trigger = false
370
+ @trigger_called = true
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ module Easing
5
+ Linear = 0
6
+ Out = 1
7
+ In = 2
8
+ InQuad = 3
9
+ OutQuad = 4
10
+ InOutQuad = 5
11
+ InCubic = 6
12
+ OutCubic = 7
13
+ InOutCubic = 8
14
+ InQuart = 9
15
+ OutQuart = 10
16
+ InOutQuart = 11
17
+ InQuint = 12
18
+ OutQuint = 13
19
+ InOutQuint = 14
20
+ InSine = 15
21
+ OutSine = 16
22
+ InOutSine = 17
23
+ InExpo = 18
24
+ OutExpo = 19
25
+ InOutExpo = 20
26
+ InCirc = 21
27
+ OutCirc = 22
28
+ InOutCirc = 23
29
+ InElastic = 24
30
+ OutElastic = 25
31
+ OutElasticHalf = 26
32
+ OutElasticQuarter = 27
33
+ InOutElastic = 28
34
+ InBack = 29
35
+ OutBack = 30
36
+ InOutBack = 31
37
+ InBounce = 32
38
+ OutBounce = 33
39
+ InOutBounce = 34
40
+ ALL = 0..34
41
+ end
42
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ module Layer
5
+ Background = "Background"
6
+ Fail = "Fail"
7
+ Pass = "Pass"
8
+ Foreground = "Foreground"
9
+ Overlay = "Overlay"
10
+ ALL = %w[Background Fail Pass Foreground Overlay]
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ module Origin
5
+ TopLeft = "TopLeft"
6
+ TopCentre = "TopCentre"
7
+ TopCenter = "TopCentre"
8
+ TopRight = "TopRight"
9
+ CentreLeft = "CentreLeft"
10
+ CenterLeft = "CentreLeft"
11
+ Centre = "Centre"
12
+ Center = "Centre"
13
+ CentreRight = "CentreRight"
14
+ CenterRight = "CentreRight"
15
+ BottomLeft = "BottomLeft"
16
+ BottomCentre = "BottomCentre"
17
+ BottomCenter = "BottomCentre"
18
+ BottomRight = "BottomRight"
19
+ ALL = %w[
20
+ TopLeft
21
+ TopCentre
22
+ TopRight
23
+ CentreLeft
24
+ Centre
25
+ CentreRight
26
+ BottomLeft
27
+ BottomCentre
28
+ BottomRight
29
+ ]
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ class Integer
2
+ # Does nothing. Just a way to tell people it's represented in milliseconds.
3
+ # @return [Integer]
4
+ def ms
5
+ self
6
+ end
7
+
8
+ # Convert from seconds to milliseconds.
9
+ # @return [Integer]
10
+ def second
11
+ self * 1000
12
+ end
13
+
14
+ # Convert from percentage to float.
15
+ # @return [Float]
16
+ def percent
17
+ self / 100.0
18
+ end
19
+ end
data/lib/osb/math.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Math
4
+ # Returns whether 2 real numbers are equal within tolerance.
5
+ # @param [Numeric] x
6
+ # @param [Numeric] y
7
+ # @param [Numeric] tolerance
8
+ # @return [Boolean]
9
+ def self.fuzzy_equal(x, y, tolerance = 1e-8)
10
+ (x - y).abs < tolerance
11
+ end
12
+ end
@@ -0,0 +1,7 @@
1
+ class Numeric
2
+ # The degrees method is used to convert from degrees to radians.
3
+ # @return [Float]
4
+ def degrees
5
+ self * Math::PI / 180
6
+ end
7
+ end
data/lib/osb/sample.rb ADDED
@@ -0,0 +1,34 @@
1
+ module Osb
2
+ class Sample
3
+ # @api private
4
+ attr_reader :command
5
+
6
+ # @param [Integer] time the timestamp that the sound should start playing.
7
+ # @param [String] layer the layer you want the sound to be on.
8
+ # @param [String] file_path filename of the audio.
9
+ # @param [Integer] volume indicate the relative loudness of the sound.
10
+ def initialize(time:, layer:, file_path:, volume: 100)
11
+ Internal.assert_type!(layer, String, "layer")
12
+ Internal.assert_value!(layer, Layer::ALL, "layer")
13
+ Internal.assert_type!(file_path, String, "file_path")
14
+
15
+ layer_ =
16
+ case layer
17
+ when Osb::Layer::Background
18
+ 0
19
+ when Osb::Layer::Foreground
20
+ 1
21
+ when Osb::Layer::Fail
22
+ 2
23
+ when Osb::Layer::Pass
24
+ 3
25
+ else
26
+ raise InvalidValueError,
27
+ "An audio sample can only exists in one of these layers: " +
28
+ "Background, Foreground, Fail or Pass."
29
+ end
30
+
31
+ @command = "Sample,#{time},#{layer_},\"#{file_path}}\",#{volume}"
32
+ end
33
+ end
34
+ end
data/lib/osb/sprite.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # A still image.
5
+ class Sprite
6
+ # @api private
7
+ attr_reader :commands, :layer
8
+ include Commandable
9
+
10
+ # @param [String] layer the layer the object appears on.
11
+ # @param [String] origin where on the image should osu! consider that image's origin (coordinate) to be.
12
+ # @param [String] file_path filename of the image.
13
+ # @param [Osb::Vector2, nil] initial_position where the object should be by default.
14
+ def initialize(
15
+ layer: Layer::Background,
16
+ origin: Origin::Center,
17
+ file_path:,
18
+ initial_position: nil
19
+ )
20
+ Internal.assert_type!(layer, String, "layer")
21
+ Internal.assert_value!(layer, Layer::ALL, "layer")
22
+ Internal.assert_type!(origin, String, "origin")
23
+ Internal.assert_value!(origin, Origin::ALL, "origin")
24
+ Internal.assert_type!(file_path, String, "file_path")
25
+ Internal.assert_file_name_ext!(file_path, %w[png jpg jpeg])
26
+ if initial_position
27
+ Internal.assert_type!(
28
+ initial_position,
29
+ Osb::Vector2,
30
+ "initial_position"
31
+ )
32
+ end
33
+
34
+ @layer = layer
35
+
36
+ first_command = "Sprite,#{layer},#{origin},\"#{file_path}\""
37
+ if initial_position
38
+ first_command += ",#{initial_position.x},#{initial_position.y}"
39
+ end
40
+ # @type [Array<String>]
41
+ @commands = [first_command]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # @api private
5
+ module Internal
6
+ # @api private
7
+ class LayerManager
8
+ attr_reader :background,
9
+ :foreground,
10
+ :fail,
11
+ :pass,
12
+ :overlay,
13
+ :samples,
14
+ :bg_and_video
15
+
16
+ def initialize
17
+ # @type [Array<Osb::Sprite, Osb::Animation>]
18
+ @background = []
19
+ # @type [Array<Osb::Sprite, Osb::Animation>]
20
+ @foreground = []
21
+ # @type [Array<Osb::Sprite, Osb::Animation>]
22
+ @fail = []
23
+ # @type [Array<Osb::Sprite, Osb::Animation>]
24
+ @pass = []
25
+ # @type [Array<Osb::Sprite, Osb::Animation>]
26
+ @overlay = []
27
+ # @type [Array<Osb::Background, Osb::Video>]
28
+ @bg_and_video = []
29
+ # @type [Array<Osb::Sample>]
30
+ @samples = []
31
+ end
32
+
33
+ # @param [Osb::Sprite, Osb::Animation, Osb::Sample, Osb::Background, Osb::Video] object
34
+ def add(object)
35
+ case object
36
+ when Osb::Sprite, Osb::Animation
37
+ case object.layer
38
+ when Layer::Background
39
+ @background << object
40
+ when Layer::Foreground
41
+ @foreground << object
42
+ when Layer::Fail
43
+ @fail << object
44
+ when Layer::Pass
45
+ @pass << object
46
+ when Layer::Overlay
47
+ @overlay << object
48
+ end
49
+ when Osb::Sample
50
+ @samples << object
51
+ when Osb::Background, Osb::Video
52
+ @bg_and_video << object
53
+ end
54
+ end
55
+
56
+ # @param [Osb::Group] group
57
+ def concat(group)
58
+ @background.concat(group.layers.background)
59
+ @foreground.concat(group.layers.foreground)
60
+ @fail.concat(group.layers.fail)
61
+ @pass.concat(group.layers.pass)
62
+ @overlay.concat(group.layers.overlay)
63
+ @samples.concat(group.layers.samples)
64
+ end
65
+ end
66
+ end
67
+
68
+ # When designing storyboard, we often want to group storyboard elements
69
+ # that are used in a similar context (eg. a scene), so this class' purpose
70
+ # is only to act as a container. You can add the elements directly to the
71
+ # +Osb::Storyboard+ object, but we recommend you to split the project into multiple
72
+ # +Osb::Group+ so it will be easier to manage.
73
+ class Group
74
+ # @api private
75
+ attr_reader :layers
76
+
77
+ def initialize
78
+ @layers = Internal::LayerManager.new
79
+ end
80
+
81
+ # Add an +Osb::Sprite+, +Osb::Animation+, +Osb::Sample+ or +Osb::Group+ to
82
+ # this group.
83
+ # @param [Osb::Group, Osb::Sprite, Osb::Animation, Osb::Sample] object
84
+ # @return [self]
85
+ def add(object)
86
+ Internal.assert_type!(
87
+ object,
88
+ [
89
+ Osb::Group,
90
+ Osb::Sprite,
91
+ Osb::Animation,
92
+ Osb::Sample,
93
+ Osb::Video,
94
+ Osb::Background
95
+ ],
96
+ "object"
97
+ )
98
+
99
+ case object
100
+ when Osb::Sprite, Osb::Animation, Osb::Sample
101
+ @layers.add(object)
102
+ when Osb::Group
103
+ @layers.concat(object)
104
+ end
105
+
106
+ return self
107
+ end
108
+
109
+ # Add an +Osb::Sprite+, +Osb::Animation+, +Osb::Sample+ or +Osb::Group+ to
110
+ # this group. Alias for +#add+.
111
+ # @param [Osb::Group, Osb::Sprite, Osb::Animation, Osb::Sample] object
112
+ # @return [self]
113
+ def <<(object)
114
+ self.add(object)
115
+ end
116
+ end
117
+
118
+ # Represent a osu! storyboard. Each sprite or animation can be added directly
119
+ # to the storyboard instance, or through an intermediate group. A group can
120
+ # have multiple nested groups in itself.
121
+ class Storyboard
122
+ # @api private
123
+ attr_reader :layers
124
+
125
+ def initialize
126
+ @layers = Internal::LayerManager.new
127
+ end
128
+
129
+ # Add an +Osb::Sprite+, +Osb::Animation+, +Osb::Sample+, +Osb::Video+,
130
+ # +Osb::Background+ or +Osb::Group+ to this storyboard.
131
+ # @param [Osb::Group, Osb::Sprite, Osb::Animation, Osb::Sample, Osb::Video,
132
+ # Osb::Background] object
133
+ # @return [self]
134
+ def add(object)
135
+ Internal.assert_type!(
136
+ object,
137
+ [
138
+ Osb::Group,
139
+ Osb::Sprite,
140
+ Osb::Animation,
141
+ Osb::Video,
142
+ Osb::Background,
143
+ Osb::Sample
144
+ ],
145
+ "object"
146
+ )
147
+
148
+ case object
149
+ when Osb::Sprite, Osb::Animation, Osb::Sample, Osb::Video, Osb::Background
150
+ @layers.add(object)
151
+ when Osb::Group
152
+ @layers.concat(object)
153
+ end
154
+
155
+ return self
156
+ end
157
+
158
+ # Add an +Osb::Sprite+, +Osb::Animation+, +Osb::Sample+, +Osb::Video+,
159
+ # +Osb::Background+ or +Osb::Group+ to this storyboard. Alias for +#add+.
160
+ # @param [Osb::Group, Osb::Sprite, Osb::Animation, Osb::Sample, Osb::Video,
161
+ # Osb::Background] object
162
+ # @return [self]
163
+ def <<(object)
164
+ self.add(object)
165
+ end
166
+
167
+ # Returns the storyboard string.
168
+ # @return [String]
169
+ def to_s
170
+ bg_and_video_layer =
171
+ @layers.bg_and_video.map { |object| object.command }.join("\n")
172
+ background_layer =
173
+ @layers
174
+ .background
175
+ .map { |object| object.commands.join("\n") }
176
+ .join("\n")
177
+ fail_layer =
178
+ @layers.fail.map { |object| object.commands.join("\n") }.join("\n")
179
+ pass_layer =
180
+ @layers.pass.map { |object| object.commands.join("\n") }.join("\n")
181
+ foreground_layer =
182
+ @layers
183
+ .foreground
184
+ .map { |object| object.commands.join("\n") }
185
+ .join("\n")
186
+ overlay_layer =
187
+ @layers.overlay.map { |object| object.commands.join("\n") }.join("\n")
188
+ samples_layer = @layers.samples.map { |object| object.command }.join("\n")
189
+
190
+ osb_string = "[Events]\n"
191
+ osb_string +=
192
+ "//Background and Video events\n" + bg_and_video_layer + "\n" +
193
+ "//Storyboard Layer 0 (Background)\n" + background_layer + "\n" +
194
+ "//Storyboard Layer 1 (Fail)\n" + fail_layer + "\n" +
195
+ "//Storyboard Layer 2 (Pass)\n" + pass_layer + "\n" +
196
+ "//Storyboard Layer 3 (Foreground)\n" + foreground_layer + "\n" +
197
+ "//Storyboard Layer 4 (Overlay)\n" + overlay_layer + "\n" +
198
+ "//Storyboard Sound Samples\n" + samples_layer + "\n"
199
+ end
200
+
201
+ # Generate an osb or osu file for this storyboard.
202
+ # @param [String] out_path path to .osb or .osu file
203
+ # @return [void]
204
+ def generate(out_path)
205
+ Internal.assert_file_name_ext!(out_path, %w[osb osu])
206
+
207
+ case File.extname(out_path)
208
+ when ".osu"
209
+ unless File.exist?(out_path)
210
+ raise InvalidValueError, "Cannot find osu file."
211
+ end
212
+
213
+ out_osu_file = ""
214
+ File
215
+ .readlines(out_path)
216
+ .each do |line|
217
+ if line.match(/[Events]/)
218
+ out_osu_file += self.to_s
219
+ else
220
+ out_osu_file += line
221
+ end
222
+ end
223
+
224
+ File.new(out_path, "w").write(out_osu_file)
225
+ when ".osb"
226
+ File.new(out_path, "w").write(self.to_s)
227
+ else
228
+ raise InvalidValueError, "Not osu or osb file." # should not be here
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Osb
4
+ # Represents a 2d point or vector.
5
+ class Vector2
6
+ attr_accessor :x, :y
7
+ # @!attribute [rw] x
8
+ # @return x coordinate of this vector
9
+ # @!attribute [rw] y
10
+ # @return y coordinate of this vector
11
+
12
+ # @param [Numeric, Array<Numeric>] x
13
+ # x coordinate of this +Vector2+, or an +Array+ of 2 numbers.
14
+ # @param [Numeric] y y coordinate of this +Vector2+
15
+ def initialize(x = 0, y = 0)
16
+ Internal.assert_type!(x, [Numeric, Internal::T[Array][Numeric]], "x")
17
+ Internal.assert_type!(y, Numeric, "y")
18
+
19
+ if x.is_a?(Array)
20
+ raise InvalidValueError, "Must be an Array of 2 Numeric values." if x.size != 2
21
+ @x = x[0]
22
+ @y = x[1]
23
+ else
24
+ @x = x
25
+ @y = y
26
+ end
27
+ end
28
+
29
+ # Add another +Vector2+ to this one.
30
+ # @param [Vector2] vector
31
+ # @return [Vector2]
32
+ def +(vector)
33
+ Internal.assert_type!(vector, Vector2, "vector")
34
+ Vector2.new(self.x + vector.x, self.y + vector.y)
35
+ end
36
+
37
+ # Subtract another +Vector2+ from this one.
38
+ # @param [Vector2] vector
39
+ # @return [Vector2]
40
+ def -(vector)
41
+ Internal.assert_type!(vector, Vector2, "vector")
42
+ Vector2.new(self.x - vector.x, self.y - vector.y)
43
+ end
44
+
45
+ # Returns whether two +Vector2+ are equal within tolerance
46
+ # @param [Vector2] vector
47
+ # @return [Boolean]
48
+ def ==(vector)
49
+ Internal.assert_type!(vector, Vector2, "vector")
50
+ Math.fuzzy_equal(self.x, vector.x) && Math.fuzzy_equal(self.y, vector.y)
51
+ end
52
+
53
+ # Returns whether two +Vector2+ are not equal within tolerance
54
+ # @param [Vector2] vector
55
+ # @return [Boolean]
56
+ def !=(vector)
57
+ !(self == vector)
58
+ end
59
+
60
+ # Makes a copy of this +Vector2+.
61
+ # @return [Vector2]
62
+ def clone
63
+ Vector2.new(self.x, self.y)
64
+ end
65
+
66
+ # Retrieves the coordinates in an Array.
67
+ # @return [Array(Float, Float)]
68
+ def to_a
69
+ [self.x, self.y]
70
+ end
71
+
72
+ # Returns a string representation of this +Vector2+.
73
+ # @return [String]
74
+ def to_s
75
+ self.to_a.to_s
76
+ end
77
+
78
+ # Returns the length of this +Vector2+.
79
+ # @return [Float]
80
+ def length
81
+ Math.sqrt(self.x**2 + self.y**2)
82
+ end
83
+ end
84
+ end
data/lib/osb/video.rb ADDED
@@ -0,0 +1,17 @@
1
+ module Osb
2
+ # A video.
3
+ class Video
4
+ # @api private
5
+ attr_reader :commands, :layer
6
+
7
+ # @param [String] file_path location of the background image relative to the beatmap directory.
8
+ # @param [Integer] start_time when the video starts.
9
+ def initialize(file_path:, start_time:)
10
+ Internal.assert_type!(file_path, String, "file_path")
11
+ Internal.assert_file_name_ext!(file_path, %w[png jpg jpeg])
12
+ Internal.assert_type!(start_time, Integer, "start_time")
13
+
14
+ @command = "1,#{start_time},\"#{file_path}\""
15
+ end
16
+ end
17
+ end
data/lib/osb.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "osb/integer"
4
+ require_relative "osb/numeric"
5
+ require_relative "osb/assert"
6
+ require_relative "osb/math"
7
+ require_relative "osb/vector2"
8
+ require_relative "osb/color"
9
+ require_relative "osb/enums/layer"
10
+ require_relative "osb/enums/easing"
11
+ require_relative "osb/enums/origin"
12
+ require_relative "osb/commandable"
13
+ require_relative "osb/animation"
14
+ require_relative "osb/sprite"
15
+ require_relative "osb/sample"
16
+ require_relative "osb/video"
17
+ require_relative "osb/background"
18
+ require_relative "osb/storyboard"
19
+
20
+ module Osb
21
+ VERSION = "1.0.3"
22
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: osb
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Dinh Vu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-08-19 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: A simple framework for building osu! storyboard.
14
+ email: dinhvu2509@gmail.com
15
+ executables: []
16
+ extensions: []
17
+ extra_rdoc_files: []
18
+ files:
19
+ - lib/osb.rb
20
+ - lib/osb/animation.rb
21
+ - lib/osb/assert.rb
22
+ - lib/osb/background.rb
23
+ - lib/osb/color.rb
24
+ - lib/osb/commandable.rb
25
+ - lib/osb/enums/easing.rb
26
+ - lib/osb/enums/layer.rb
27
+ - lib/osb/enums/origin.rb
28
+ - lib/osb/integer.rb
29
+ - lib/osb/math.rb
30
+ - lib/osb/numeric.rb
31
+ - lib/osb/sample.rb
32
+ - lib/osb/sprite.rb
33
+ - lib/osb/storyboard.rb
34
+ - lib/osb/vector2.rb
35
+ - lib/osb/video.rb
36
+ homepage: https://github.com/nanachi-code/osb-ruby
37
+ licenses:
38
+ - MIT
39
+ metadata: {}
40
+ post_install_message:
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 3.1.6
56
+ signing_key:
57
+ specification_version: 4
58
+ summary: osu! storyboard framework
59
+ test_files: []