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