sabrina 0.5.5
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 +7 -0
- data/LICENSE +21 -0
- data/lib/sabrina.rb +46 -0
- data/lib/sabrina/bytestream.rb +266 -0
- data/lib/sabrina/bytestream/byte_input.rb +126 -0
- data/lib/sabrina/bytestream/byte_output.rb +112 -0
- data/lib/sabrina/bytestream/rom_operations.rb +138 -0
- data/lib/sabrina/children_manager.rb +60 -0
- data/lib/sabrina/config.rb +112 -0
- data/lib/sabrina/config/charmap_in.rb +81 -0
- data/lib/sabrina/config/charmap_out.rb +144 -0
- data/lib/sabrina/config/charmap_out_special.rb +28 -0
- data/lib/sabrina/config/main.rb +105 -0
- data/lib/sabrina/gba_string.rb +156 -0
- data/lib/sabrina/lz77.rb +161 -0
- data/lib/sabrina/meta.rb +33 -0
- data/lib/sabrina/monster.rb +147 -0
- data/lib/sabrina/palette.rb +216 -0
- data/lib/sabrina/plugin.rb +145 -0
- data/lib/sabrina/plugin/load.rb +43 -0
- data/lib/sabrina/plugin/register.rb +32 -0
- data/lib/sabrina/plugins/spritesheet.rb +196 -0
- data/lib/sabrina/plugins/stats.rb +257 -0
- data/lib/sabrina/rom.rb +302 -0
- data/lib/sabrina/sprite.rb +312 -0
- metadata +113 -0
data/lib/sabrina/rom.rb
ADDED
@@ -0,0 +1,302 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# A class for handling low-level read and write operations upon a ROM file.
|
3
|
+
# Beyond creating a new {Rom} from filename for passing to
|
4
|
+
# {Bytestream}-family objects, you should not need to deal with it
|
5
|
+
# or any of its methods directly.
|
6
|
+
class Rom
|
7
|
+
# The position in the ROM file from which to read the 4-byte
|
8
|
+
# identifier string. This is relied upon to pull the ROM type
|
9
|
+
# data from the {Config}.
|
10
|
+
ID_OFFSET = 172
|
11
|
+
|
12
|
+
# The 4-byte ID of the ROM.
|
13
|
+
#
|
14
|
+
# @return [String]
|
15
|
+
# @see Config.rom_params
|
16
|
+
attr_reader :id
|
17
|
+
|
18
|
+
# The full path and filename of the ROM file.
|
19
|
+
#
|
20
|
+
# @return [String]
|
21
|
+
attr_reader :path
|
22
|
+
|
23
|
+
# Just the filename of the ROM file.
|
24
|
+
#
|
25
|
+
# @return [String]
|
26
|
+
attr_reader :filename
|
27
|
+
|
28
|
+
# The ROM file object.
|
29
|
+
#
|
30
|
+
# @return [File]
|
31
|
+
attr_reader :file
|
32
|
+
|
33
|
+
class << self
|
34
|
+
# Converts a numerical offset to a GBA-compliant,
|
35
|
+
# reverse 3-byte pointer.
|
36
|
+
#
|
37
|
+
# @return [Integer]
|
38
|
+
def offset_to_pointer(offset)
|
39
|
+
format('%06X', offset).scan(/../).reverse.map { |x| x.hex.chr }.join('')
|
40
|
+
end
|
41
|
+
|
42
|
+
# Converts a reverse 3-byte pointer to a numerical offset.
|
43
|
+
#
|
44
|
+
# @return [Integer]
|
45
|
+
def pointer_to_offset(pointer)
|
46
|
+
Bytestream.from_bytes(pointer.reverse).to_i
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Creates a new Rom object from the supplied ROM image file.
|
51
|
+
#
|
52
|
+
# @return [Rom]
|
53
|
+
def initialize(rom_file)
|
54
|
+
@path = rom_file
|
55
|
+
@file = File.new(rom_file, 'r+b')
|
56
|
+
|
57
|
+
@filename = @path.rpartition('/').last.rpartition('.').first
|
58
|
+
@id = load_id
|
59
|
+
|
60
|
+
@params = Config.rom_params(@id)
|
61
|
+
|
62
|
+
@params.each_key do |key|
|
63
|
+
m = key.downcase.to_sym
|
64
|
+
define_singleton_method(m) { @params[key] } unless respond_to?(m)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Returns the numerical offset associated with the +name+ in the
|
69
|
+
# current ROM {Config} data.
|
70
|
+
#
|
71
|
+
# @param [String, Symbol] name
|
72
|
+
# @return [Integer]
|
73
|
+
def table(name)
|
74
|
+
Bytestream.parse_offset(param(name))
|
75
|
+
end
|
76
|
+
|
77
|
+
# Fetches a specific key from the ROM {Config} data.
|
78
|
+
#
|
79
|
+
# @param [String, Symbol] p the key to look for.
|
80
|
+
# @see Config
|
81
|
+
def param(p)
|
82
|
+
s = p.to_sym
|
83
|
+
@params.fetch(s) { fail "No parameter #{s} for ROM type #{@id}." }
|
84
|
+
end
|
85
|
+
|
86
|
+
# Gets the name of the monster identified by +real_index+ from
|
87
|
+
# the ROM file.
|
88
|
+
#
|
89
|
+
# @param [Integer] real_index
|
90
|
+
# @return [String]
|
91
|
+
# @see Monster.parse_index
|
92
|
+
def monster_name(real_index)
|
93
|
+
read_string_from_table(:name_table, real_index, name_length)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Takes a table name and index and returns a byte offset.
|
97
|
+
#
|
98
|
+
# @param [String, Symbol] name the name of the table as specified
|
99
|
+
# in the ROM {Config} data.
|
100
|
+
# @param [Integer] index in the case of a monster, the real index
|
101
|
+
# of the monster.
|
102
|
+
# @param [Integer] index_length The number of bytes occupied by each
|
103
|
+
# index in +table+. If absent, will search {Config} for a +_length+
|
104
|
+
# param associated with the table.
|
105
|
+
# @return [String]
|
106
|
+
def table_to_offset(name, index, index_length = nil)
|
107
|
+
index_length ||= param(name.to_s.sub('_table', '_length'))
|
108
|
+
|
109
|
+
table(name) + index * index_length
|
110
|
+
end
|
111
|
+
|
112
|
+
# Reads a numerical offset associated with +index+ from the table
|
113
|
+
# +name+. This assumes the table contains 3-byte GBA pointers.
|
114
|
+
#
|
115
|
+
# @param [String, Symbol] name the name of the table as specified
|
116
|
+
# in the ROM {Config} data.
|
117
|
+
# @param [Integer] index in the case of a monster, the real index
|
118
|
+
# of the monster.
|
119
|
+
# @return [Integer]
|
120
|
+
# @see offset_to_pointer
|
121
|
+
def read_offset_from_table(name, index)
|
122
|
+
pointer = read_table(name, index, 8, 3)
|
123
|
+
self.class.pointer_to_offset(pointer)
|
124
|
+
end
|
125
|
+
|
126
|
+
# Reads a stream expected to be an 0xFF-terminated GBA string from the
|
127
|
+
# table. This assumes the entries before +index+ are each +index_length+
|
128
|
+
# long.
|
129
|
+
#
|
130
|
+
# @param [String, Symbol] name the name of the table as specified
|
131
|
+
# in the ROM {Config} data.
|
132
|
+
# @param [Integer] index in the case of a monster, the real index
|
133
|
+
# of the monster.
|
134
|
+
# @param [Integer] index_length The number of bytes occupied by each
|
135
|
+
# index in +table+. If absent, will search {Config} for a +_length+
|
136
|
+
# param associated with the table.
|
137
|
+
# @return [String]
|
138
|
+
# @see GBAString
|
139
|
+
def read_string_from_table(name, index, index_length = nil)
|
140
|
+
index_length ||= param(name.to_s.sub('_table', '_length'))
|
141
|
+
s = read_string(table(name) + index * index_length)
|
142
|
+
GBAString.from_bytes(s).to_s
|
143
|
+
end
|
144
|
+
|
145
|
+
# Reads +length+ bytes of data from the table. This assumes the
|
146
|
+
# entries before +index+ are each +index_length+ long.
|
147
|
+
#
|
148
|
+
# @param [String, Symbol] name the name of the table as specified
|
149
|
+
# in the ROM {Config} data.
|
150
|
+
# @param [Integer] index in the case of a monster, the real index
|
151
|
+
# of the monster.
|
152
|
+
# @param [Integer] index_length The number of bytes occupied by each
|
153
|
+
# index in +table+. If absent, will search {Config} for a +_length+
|
154
|
+
# param associated with the table.
|
155
|
+
# @param [Integer] length how many bytes to read. Will assume +index_length+
|
156
|
+
# if absent.
|
157
|
+
# @return [String]
|
158
|
+
# @see Bytestream
|
159
|
+
def read_table(name, index, index_length = nil, length = nil)
|
160
|
+
index_length ||= param(name.to_s.sub('_table', '_length'))
|
161
|
+
length ||= index_length
|
162
|
+
read(table(name) + index * index_length, length)
|
163
|
+
end
|
164
|
+
|
165
|
+
# Returns the position of the first occurence of +length+ 0xFF
|
166
|
+
# bytes, assumed to be free space available for writing.
|
167
|
+
# If +start+ is +nil+, the search will begin at the +:free_space_start+
|
168
|
+
# offset specified in the ROM {Config}.
|
169
|
+
#
|
170
|
+
# @param [Integer] length
|
171
|
+
# @return [Integer] the first found offset.
|
172
|
+
def find_free(length, start = nil)
|
173
|
+
query = ("\xFF" * length).force_encoding('ASCII-8BIT')
|
174
|
+
start ||= Bytestream.parse_offset(free_space_start)
|
175
|
+
|
176
|
+
@file.seek(start)
|
177
|
+
match = start + @file.read.index(query)
|
178
|
+
|
179
|
+
return match if match % 4 == 0 || !match
|
180
|
+
|
181
|
+
match += 1 until match % 4 == 0
|
182
|
+
find_free(length, match)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Reads the data from +offset+, assuming it to be {Lz77}-compressed.
|
186
|
+
#
|
187
|
+
# @param [Integer] offset
|
188
|
+
# @return [Hash] contains the uncompressed data as +:stream+ and the
|
189
|
+
# estimated original compressed length as +:original_length+.
|
190
|
+
# @see Lz77.uncompress
|
191
|
+
def read_lz77(offset)
|
192
|
+
Lz77.uncompress(self, offset)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Reads a stream expected to be an 0xFF-terminated GBA string from +offset+.
|
196
|
+
#
|
197
|
+
# @param [Integer] offset
|
198
|
+
# @return [String]
|
199
|
+
# @see GBAString
|
200
|
+
def read_string(offset)
|
201
|
+
term = "\xFF".force_encoding('ASCII-8BIT')
|
202
|
+
|
203
|
+
@file.seek(offset)
|
204
|
+
@file.gets(term)
|
205
|
+
end
|
206
|
+
|
207
|
+
# Reads +length+ bytes from +offset+, or the entire rest of the file
|
208
|
+
# if +length+ is not specified.
|
209
|
+
#
|
210
|
+
# @param [Integer] offset
|
211
|
+
# @param [Integer] length
|
212
|
+
# @return [String]
|
213
|
+
def read(offset, length = nil)
|
214
|
+
@file.seek(offset)
|
215
|
+
length ? @file.read(length) : @file.read
|
216
|
+
end
|
217
|
+
|
218
|
+
# Writes a stream of bytes at the provided offset.
|
219
|
+
#
|
220
|
+
# @param [Integer] offset
|
221
|
+
# @return [String] a debug message.
|
222
|
+
def write(offset, b)
|
223
|
+
@file.seek(offset)
|
224
|
+
@file.write(b.force_encoding('ASCII-8BIT'))
|
225
|
+
|
226
|
+
"Rom#write: Wrote #{b.length} bytes at #{offset}" \
|
227
|
+
" (#{ format('%06X', offset) })."
|
228
|
+
end
|
229
|
+
|
230
|
+
# Writes the +offset+ associated with +index+ to the table
|
231
|
+
# +name+. This assumes the table contains 3-byte GBA pointers.
|
232
|
+
#
|
233
|
+
# @param [Integer] offset the offset. It will be converted to a
|
234
|
+
# GBA-compliant 3-byte pointer before writing.
|
235
|
+
# @param [String, Symbol] name the name of the table as specified
|
236
|
+
# in the ROM {Config} data.
|
237
|
+
# @param [Integer] index in the case of a monster, the real index
|
238
|
+
# of the monster.
|
239
|
+
# @return [String] a debug message.
|
240
|
+
# @see offset_to_pointer
|
241
|
+
def write_offset_to_table(name, index, offset)
|
242
|
+
write(
|
243
|
+
table(name) + index * 8,
|
244
|
+
self.class.offset_to_pointer(offset)
|
245
|
+
)
|
246
|
+
end
|
247
|
+
|
248
|
+
# Writes a stream of +length+ 0xFF bytes at the provided +offset+.
|
249
|
+
# This ought to be recognized as free space available for writing.
|
250
|
+
# Unless +force+ is set to true, the method will do nothing
|
251
|
+
# if there may be multiple pointers referencing the given offset
|
252
|
+
# within the ROM file.
|
253
|
+
#
|
254
|
+
# @param [Integer] offset
|
255
|
+
# @param [Integer] length
|
256
|
+
# @param [Boolean] force whether to force wiping even if the offset
|
257
|
+
# appears to be pointed at multiple times.
|
258
|
+
# @return [String] a debug message.
|
259
|
+
def wipe(offset, length, force = false)
|
260
|
+
unless force
|
261
|
+
pointer = self.class.offset_to_pointer(offset)
|
262
|
+
|
263
|
+
@file.rewind
|
264
|
+
hits = @file.read.scan(pointer).length
|
265
|
+
if hits > 1
|
266
|
+
return "Rom.wipe: Offset #{offset} (#{ format('%06X', offset) })" \
|
267
|
+
" appears to be referenced by multiple pointers (#{hits})," \
|
268
|
+
' not wiping. Use wipe(offset, length, true) to override.'
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
write(offset, "\xFF" * length)
|
273
|
+
|
274
|
+
"Rom#wipe: Wiped #{length} bytes at #{offset}" \
|
275
|
+
"(#{ format('%06X', offset) })."
|
276
|
+
end
|
277
|
+
|
278
|
+
# Closes the ROM file.
|
279
|
+
#
|
280
|
+
# @return [0]
|
281
|
+
def close
|
282
|
+
@file.close
|
283
|
+
0
|
284
|
+
end
|
285
|
+
|
286
|
+
# Returns a blurb consisting of the ROM title and ID.
|
287
|
+
#
|
288
|
+
# @return [String]
|
289
|
+
def to_s
|
290
|
+
"#{ param(:title) } [#{@id}]"
|
291
|
+
end
|
292
|
+
|
293
|
+
private
|
294
|
+
|
295
|
+
# Reads the type ID from the ROM file.
|
296
|
+
#
|
297
|
+
# @return [String]
|
298
|
+
def load_id
|
299
|
+
read(ID_OFFSET, 4)
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
@@ -0,0 +1,312 @@
|
|
1
|
+
module Sabrina
|
2
|
+
# A class tailored towards dealing with graphical (sprite) data.
|
3
|
+
#
|
4
|
+
# Note that a sprite generated from ROM data does not contain color data by
|
5
|
+
# default. Pass a {Palette} as :palette in the option hash, or to
|
6
|
+
# {#palette=}, to specify a default palette for the RGB output methods.
|
7
|
+
class Sprite < Bytestream
|
8
|
+
# Gets or sets the default palette for RGB output. Note that this will not
|
9
|
+
# cause the palette to also be automatically written to ROM on sprite
|
10
|
+
# {RomOperations#write_to_rom}.
|
11
|
+
attr_accessor :palette
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Same as {ByteInput#from_table_as_pointer}, but also supports a
|
15
|
+
# width parameter for the resulting picture.
|
16
|
+
#
|
17
|
+
# @param [Integer] width
|
18
|
+
# @param [Hash] h see {Bytestream#initialize}
|
19
|
+
# @return [Sprite]
|
20
|
+
# @see ByteInput#from_table_as_pointer
|
21
|
+
def from_table(rom, table, index, width = 64, h = {})
|
22
|
+
h.merge!(width: width)
|
23
|
+
from_table_as_pointer(rom, table, index, h)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Same as {ByteInput#from_rom}, but supplies no +length+ (due to
|
27
|
+
# implicit {Lz77} mode) and supports a width parameter for the resulting
|
28
|
+
# picture.
|
29
|
+
#
|
30
|
+
# @param [Integer] width
|
31
|
+
# @param [Hash] h see {Bytestream#initialize}
|
32
|
+
# @return [Sprite]
|
33
|
+
# @see ByteInput#from_rom
|
34
|
+
def from_rom(rom, offset, width = 64, h = {})
|
35
|
+
h.merge!(width: width)
|
36
|
+
super(rom, offset, nil, h)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Generates a sprite from a PNG file and optionally attempts to match the
|
40
|
+
# colors to the provided {Palette}.
|
41
|
+
#
|
42
|
+
# Internally, this creates a
|
43
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
44
|
+
# object from the provided file and then passes the extracted RGB stream
|
45
|
+
# and width to {from_rgb} along with the palette.
|
46
|
+
#
|
47
|
+
# @param [String] file
|
48
|
+
# @param [Palette] palette
|
49
|
+
# @param [Hash] h see {Bytestream#initialize}
|
50
|
+
# @return [Sprite]
|
51
|
+
def from_png(file, palette = Palette.empty, h = {})
|
52
|
+
c = ChunkyPNG::Canvas.from_file(file)
|
53
|
+
|
54
|
+
from_canvas(c, palette, h)
|
55
|
+
end
|
56
|
+
|
57
|
+
# Generates a sprite from a
|
58
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
59
|
+
# and optionally attempts to match the
|
60
|
+
# colors to the provided {Palette}.
|
61
|
+
#
|
62
|
+
# @param [Canvas] c
|
63
|
+
# @param [Palette] palette
|
64
|
+
# @param [Hash] h see {Bytestream#initialize}
|
65
|
+
# @return [Sprite]
|
66
|
+
def from_canvas(c, palette = Palette.empty, h = {})
|
67
|
+
from_rgb(c.to_rgb_stream, c.width, palette, h)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Generates a sprite from a stream of bytes following the +0xRRGGBB+
|
71
|
+
# format with the provided +width+, optionally matching the colors to the
|
72
|
+
# supplied {Palette} (and failing if the sprite dimensions are not
|
73
|
+
# multiples of 8 or the total number of colors in the image and the
|
74
|
+
# palette exceeds 16).
|
75
|
+
#
|
76
|
+
# It is important to remember that while the resulting image will be ready
|
77
|
+
# for saving to PNG, writing it to a ROM will not save the color data by
|
78
|
+
# itself. The generated palette (accessible via {#palette}) should be
|
79
|
+
# written separately. The {Plugins::Spritesheet Spritesheet} plugin should
|
80
|
+
# take care of that for you.
|
81
|
+
#
|
82
|
+
# @param [String] rgb
|
83
|
+
# @param [Integer] width
|
84
|
+
# @param [Palette] palette
|
85
|
+
# @param [Hash] h see {Bytestream#initialize}
|
86
|
+
# @return [Sprite]
|
87
|
+
def from_rgb(rgb, width = 64, palette = Palette.empty, h = {})
|
88
|
+
fail 'RGB stream length must divide by 3.' unless rgb.length % 3 == 0
|
89
|
+
|
90
|
+
unless width % 8 == 0 && (rgb.length / 3 / width) % 8 == 0
|
91
|
+
fail 'Sprite dimensions must be divisible by 8.'
|
92
|
+
end
|
93
|
+
|
94
|
+
out_array = []
|
95
|
+
|
96
|
+
until rgb.empty?
|
97
|
+
pixel = rgb.slice!(0, 3).unpack('CCC')
|
98
|
+
palette.add(pixel) unless palette.index_of(pixel)
|
99
|
+
out_array << palette.index_of(pixel).to_s(16).upcase
|
100
|
+
end
|
101
|
+
|
102
|
+
h.merge!(
|
103
|
+
representation: out_array,
|
104
|
+
width: width,
|
105
|
+
palette: palette
|
106
|
+
)
|
107
|
+
new(h)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Same as {Bytestream#initialize}, but with +:lz77+ and
|
112
|
+
# +:pointer_mode+ set to true by default and support for the following extra
|
113
|
+
# options.
|
114
|
+
#
|
115
|
+
# @param [Hash] h
|
116
|
+
# @option h [Integer] :width The width of the picture.
|
117
|
+
# @option h [Palette] :palette The default palette to use
|
118
|
+
# with the RGB and Canvas output methods.
|
119
|
+
# @see Bytestream#initialize
|
120
|
+
def initialize(h = {})
|
121
|
+
@lz77 = true
|
122
|
+
@pointer_mode = true
|
123
|
+
|
124
|
+
@width = 64
|
125
|
+
@palette = nil
|
126
|
+
|
127
|
+
super
|
128
|
+
end
|
129
|
+
|
130
|
+
# Returns an array of characters that represent the palette index of each
|
131
|
+
# pixel in hexadecimal format.
|
132
|
+
#
|
133
|
+
# @return [Array] an array of characters from 0 through F.
|
134
|
+
def present
|
135
|
+
return @representation if @representation
|
136
|
+
# return nil if to_bytes.empty?
|
137
|
+
|
138
|
+
in_array = []
|
139
|
+
to_hex.scan(/(.)(.)/) { |x, y| in_array += [y, x] }
|
140
|
+
|
141
|
+
column_num = @width / 8
|
142
|
+
|
143
|
+
blocks = []
|
144
|
+
blocks << in_array.slice!(0, 64) until in_array.empty?
|
145
|
+
|
146
|
+
out_array = []
|
147
|
+
i = 0
|
148
|
+
loop do
|
149
|
+
loop do
|
150
|
+
break if blocks[i % column_num].empty?
|
151
|
+
out_array += blocks[i % column_num].slice!(0, 8)
|
152
|
+
i += 1
|
153
|
+
end
|
154
|
+
blocks.slice!(0, column_num)
|
155
|
+
break if blocks.empty?
|
156
|
+
end
|
157
|
+
|
158
|
+
@representation = out_array
|
159
|
+
end
|
160
|
+
|
161
|
+
alias_method :to_a, :present
|
162
|
+
|
163
|
+
# @todo Some breakage with number 360, is this the culprit?
|
164
|
+
# Converts the internal representation to a GBA-compatible stream of bytes.
|
165
|
+
#
|
166
|
+
# @return [String]
|
167
|
+
# @see ByteOutput#generate_bytes
|
168
|
+
def generate_bytes
|
169
|
+
in_array = []
|
170
|
+
present.join('').scan(/(.)(.)/) { |x, y| in_array += [y, x] }
|
171
|
+
|
172
|
+
column_num = @width / 8
|
173
|
+
out_array = []
|
174
|
+
|
175
|
+
loop do
|
176
|
+
break if in_array.empty?
|
177
|
+
columns = [[]] * column_num
|
178
|
+
|
179
|
+
until columns[0].length == 8 * 8
|
180
|
+
column_num.times { |i| columns[i] += in_array.slice!(0, 8) }
|
181
|
+
end # Filled one block
|
182
|
+
|
183
|
+
out_array += columns.slice!(0) until columns.empty?
|
184
|
+
end
|
185
|
+
|
186
|
+
Bytestream.from_hex(out_array.join('')).to_b
|
187
|
+
end
|
188
|
+
|
189
|
+
# Converts the internal representation to a stream of +0xRRGGBB+ bytes using
|
190
|
+
# the default palette or the provided one (and failing if neither is
|
191
|
+
# present.)
|
192
|
+
#
|
193
|
+
# @param [Palette] pal
|
194
|
+
# @return [String]
|
195
|
+
# @see #palette=
|
196
|
+
def to_rgb(pal = @palette)
|
197
|
+
fail 'A palette must be specified for conversion to RGB.' unless pal
|
198
|
+
|
199
|
+
rgb = present.map do |x|
|
200
|
+
color = pal.present[x.hex]
|
201
|
+
fail "No such entry in the palette: #{x.hex}" unless color
|
202
|
+
color.map(&:chr).join('')
|
203
|
+
end
|
204
|
+
|
205
|
+
rgb.join('')
|
206
|
+
end
|
207
|
+
|
208
|
+
# Crops or repeats the sprite vertically until it meets the current ROM's
|
209
|
+
# frame count, assuming 64x64 pixels per frame.
|
210
|
+
#
|
211
|
+
# @return [self]
|
212
|
+
def justify
|
213
|
+
frame_count =
|
214
|
+
if @table.to_sym == :front_table
|
215
|
+
@rom.special_frames.fetch(@index, @rom.frames).first
|
216
|
+
else
|
217
|
+
@rom.special_frames.fetch(@index, @rom.frames).last
|
218
|
+
end
|
219
|
+
|
220
|
+
h = { lz77: false }
|
221
|
+
target = frame_count * 64 * 64
|
222
|
+
|
223
|
+
old_rep = @representation.dup
|
224
|
+
if @representation.length < target
|
225
|
+
@representation += old_rep until @representation.length >= target
|
226
|
+
h = { lz77: true }
|
227
|
+
elsif @representation.length > target
|
228
|
+
@representation.slice!(0, target)
|
229
|
+
h = { lz77: true }
|
230
|
+
end
|
231
|
+
|
232
|
+
clear_cache(h)
|
233
|
+
end
|
234
|
+
|
235
|
+
# @see Bytestream#rom=
|
236
|
+
def rom=(p_rom)
|
237
|
+
@rom = p_rom
|
238
|
+
justify
|
239
|
+
@rom
|
240
|
+
end
|
241
|
+
|
242
|
+
# Converts the internal representation to a
|
243
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
244
|
+
# object using the default palette or the provided one (and failing if
|
245
|
+
# neither is present.)
|
246
|
+
#
|
247
|
+
# @param [Palette] pal
|
248
|
+
# @return [Canvas] a
|
249
|
+
# {http://rdoc.info/gems/chunky_png/1.2.0/ChunkyPNG/Canvas Canvas}
|
250
|
+
# object.
|
251
|
+
# @see #palette=
|
252
|
+
def to_canvas(pal = @palette)
|
253
|
+
ChunkyPNG::Canvas.from_rgb_stream(
|
254
|
+
@width,
|
255
|
+
present.length / @width,
|
256
|
+
to_rgb(pal)
|
257
|
+
)
|
258
|
+
end
|
259
|
+
|
260
|
+
# Saves the internal representation to a PNG file (appending the file
|
261
|
+
# extension if absent) using the default palette or the provided one (and
|
262
|
+
# failing if neither is present.)
|
263
|
+
#
|
264
|
+
# @param [String] file the file to save to. A +.png+ extension is optional.
|
265
|
+
# @param [Palette] pal
|
266
|
+
# @see #palette=
|
267
|
+
def to_png(file, dir = '', pal = @palette)
|
268
|
+
dir << '/' unless dir.empty? || dir.end_with?('/')
|
269
|
+
FileUtils.mkpath(dir) unless Dir.exist?(dir)
|
270
|
+
|
271
|
+
path = dir << file
|
272
|
+
path << '.png' unless path.downcase.end_with?('.png')
|
273
|
+
|
274
|
+
to_canvas(pal).save(path)
|
275
|
+
end
|
276
|
+
|
277
|
+
alias_method :to_file, :to_png
|
278
|
+
|
279
|
+
# Outputs the sprite as ASCII art with each pixel represented by the
|
280
|
+
# hexadecimal value of its palette index.
|
281
|
+
#
|
282
|
+
# @param [Integer] width_multiplier each pixel will be repeated this
|
283
|
+
# many times horizontally. Defaults to 2 for better proportions
|
284
|
+
# with typical monospace fonts.
|
285
|
+
# @return [String]
|
286
|
+
# @see #palette=
|
287
|
+
def to_ascii(width_multiplier = 2)
|
288
|
+
output = ''
|
289
|
+
present.each_index do |i|
|
290
|
+
output << present[i] * width_multiplier
|
291
|
+
output << "\n" if (i + 1) % @width == 0
|
292
|
+
end
|
293
|
+
output
|
294
|
+
end
|
295
|
+
|
296
|
+
# A blurb showing the sprite dimensions.
|
297
|
+
#
|
298
|
+
# @return [String]
|
299
|
+
def to_s
|
300
|
+
"Sprite (#{ @width }x#{ present.length / @width })"
|
301
|
+
end
|
302
|
+
|
303
|
+
private
|
304
|
+
|
305
|
+
# @see {Bytestream#load_settings}
|
306
|
+
def load_settings(h)
|
307
|
+
@width = h.fetch(:width, @width)
|
308
|
+
@palette = h.fetch(:palette, @palette)
|
309
|
+
super
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|