Skip to content

Commit 7495fbb

Browse files
committed
start work on encoder
1 parent 6e5c7a9 commit 7495fbb

File tree

6 files changed

+582
-2
lines changed

6 files changed

+582
-2
lines changed

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@
1818
"devDependencies": {
1919
"babel-cli": "^6.3.17",
2020
"babel-preset-es2015": "^6.3.13",
21-
"standard": "^10.0.0"
21+
"jascpal": "^0.1.4",
22+
"pngjs": "^3.3.0",
23+
"standard": "^10.0.0",
24+
"tape": "^4.8.0"
2225
},
2326
"scripts": {
2427
"build": "rm -r lib && babel --presets es2015 --out-dir lib src",
25-
"test": "standard src/*.js",
28+
"test": "tape test/*.js && standard src/*.js test/*.js",
2629
"prepublish": "npm run build"
2730
}
2831
}

src/SLPEncoder.js

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
const Struct = require('awestruct')
2+
3+
const t = Struct.types
4+
5+
module.exports = SLPEncoder
6+
7+
// SLP commands
8+
const SLP_END_OF_ROW = 0x0f
9+
const SLP_COLOR_LIST = 0x00
10+
const SLP_COLOR_LIST_EX = 0x02
11+
const SLP_COLOR_LIST_PLAYER = 0x06
12+
const SLP_SKIP = 0x01
13+
const SLP_SKIP_EX = 0x03
14+
const SLP_FILL = 0x07
15+
const SLP_FILL_PLAYER = 0x0a
16+
const SLP_SHADOW = 0x0b
17+
const SLP_EXTENDED = 0x0e
18+
const SLP_EX_OUTLINE1 = 0x40
19+
const SLP_EX_FILL_OUTLINE1 = 0x50
20+
const SLP_EX_OUTLINE2 = 0x60
21+
const SLP_EX_FILL_OUTLINE2 = 0x70
22+
const SLP_LINE_EMPTY = 0x8000
23+
24+
// Render commands
25+
const RENDER_NEXTLINE = 0x00
26+
const RENDER_COLOR = 0x01
27+
const RENDER_SKIP = 0x02
28+
const RENDER_PLAYER_COLOR = 0x03
29+
const RENDER_SHADOW = 0x04
30+
const RENDER_OUTLINE = 0x05
31+
const RENDER_FILL = 0x06
32+
const RENDER_PLAYER_FILL = 0x07
33+
34+
// SLP Header
35+
const headerStruct = Struct({
36+
version: t.string(4),
37+
numFrames: t.int32,
38+
comment: t.string(24),
39+
40+
frames: t.array('numFrames', Struct({
41+
cmdTableOffset: t.uint32,
42+
outlineTableOffset: t.uint32,
43+
paletteOffset: t.uint32,
44+
properties: t.uint32,
45+
46+
width: t.int32,
47+
height: t.int32,
48+
hotspot: Struct({
49+
x: t.int32,
50+
y: t.int32
51+
})
52+
}))
53+
})
54+
55+
function last (arr) {
56+
return arr[arr.length - 1]
57+
}
58+
59+
function padSlice (str, len, padding = '\0') {
60+
str = str.slice(0, len)
61+
while (str.length < len) str += padding
62+
return str
63+
}
64+
65+
function SLPEncoder (options = {}) {
66+
if (!(this instanceof SLPEncoder)) return new SLPEncoder(options)
67+
68+
if (!options.palette) {
69+
throw new Error('SLPEncoder: `palette` option is required')
70+
}
71+
72+
this.version = options.version || '1.00'
73+
this.comment = options.comment || ''
74+
this.palette = options.palette
75+
this.frames = []
76+
77+
this.colorIndices = {}
78+
for (let i = 0; i < this.palette.length; i++) {
79+
const [r, g, b] = this.palette[i]
80+
const c = (r << 16) + (g << 8) + b
81+
this.colorIndices[c] = i
82+
}
83+
}
84+
85+
function pixelsToRenderCommands (palette, { width, height, data }) {
86+
const commands = []
87+
88+
let prevCommand
89+
let prevArg
90+
function push (command, arg) {
91+
prevCommand = command
92+
prevArg = arg
93+
commands.push({ command, arg })
94+
}
95+
96+
for (let i = 0; i < data.length; i += 4) {
97+
if (i > 0 && (i / 4) % width === 0) {
98+
push(RENDER_NEXTLINE)
99+
}
100+
// transparent pixel
101+
if (data[i + 3] === 0) {
102+
if (prevCommand === RENDER_SKIP) {
103+
last(commands).arg++
104+
} else {
105+
push(RENDER_SKIP, 1)
106+
}
107+
continue
108+
}
109+
110+
const r = data[i]
111+
const g = data[i + 1]
112+
const b = data[i + 2]
113+
const c = (r << 16) + (g << 8) + b
114+
const index = palette[c]
115+
if (!index) {
116+
throw new Error(`[${r} ${g} ${b}] is not in palette`)
117+
}
118+
if (prevCommand === RENDER_FILL && index === prevArg.color) {
119+
prevArg.pxCount++
120+
} else if (prevCommand === RENDER_COLOR && index === prevArg) {
121+
commands.pop()
122+
push(RENDER_FILL, {
123+
pxCount: 2,
124+
color: index
125+
})
126+
} else {
127+
push(RENDER_COLOR, index)
128+
}
129+
}
130+
131+
return commands
132+
}
133+
134+
function renderCommandsToSlpFrame ({ width, height, commands, baseOffset }) {
135+
const buffer = Buffer.alloc(
136+
(height * 4) + // outlines
137+
(height * 4) + // cmd table offsets
138+
// space for cmd table. each render command takes at most 2 bytes
139+
(commands.length * 2)
140+
)
141+
const outlines = [
142+
{ left: 0, right: 0 }
143+
]
144+
let offset = (height * 4) + (height * 4)
145+
const offsets = [
146+
offset
147+
]
148+
149+
150+
let x = 0
151+
let y = 0
152+
for (let i = 0; i < commands.length; i++) {
153+
const { command, arg } = commands[i]
154+
if (command === RENDER_NEXTLINE) {
155+
buffer[offset++] = SLP_END_OF_ROW
156+
offsets.push(offset)
157+
y++
158+
x = 0
159+
outlines[y] = { left: 0, right: 0 }
160+
} else if (command === RENDER_COLOR) {
161+
let end = i
162+
while (commands[end].command === RENDER_COLOR) {
163+
end++
164+
}
165+
buffer[offset++] = SLP_COLOR_LIST | ((end - i) << 2)
166+
for (; i < end; i++) {
167+
buffer[offset++] = commands[i].arg
168+
x++
169+
}
170+
i--
171+
} else if (command === RENDER_FILL) {
172+
if (arg.pxCount < 16) {
173+
buffer[offset++] = SLP_FILL | (arg.pxCount << 4)
174+
} else {
175+
buffer[offset++] = SLP_FILL
176+
buffer[offset++] = arg.pxCount
177+
}
178+
buffer[offset++] = arg.color
179+
x += arg.pxCount
180+
} else if (command === RENDER_SKIP) {
181+
if (x === 0) {
182+
outlines[y].left = arg
183+
} else if (x + arg === width) {
184+
outlines[y].right = arg
185+
} else if (arg >= 64) {
186+
buffer[offset++] = SLP_SKIP
187+
buffer[offset++] = arg
188+
} else {
189+
buffer[offset++] = SLP_SKIP | (arg << 2)
190+
}
191+
x += arg
192+
}
193+
}
194+
195+
buffer[offset++] = SLP_END_OF_ROW
196+
197+
// Flush outlines
198+
for (let i = 0; i < outlines.length; i++) {
199+
buffer.writeUInt16LE(outlines[i].left, i * 4)
200+
buffer.writeUInt16LE(outlines[i].right, i * 4 + 2)
201+
}
202+
for (let i = 0; i < offsets.length; i++) {
203+
buffer.writeUInt32LE(baseOffset + offsets[i], (height * 4) + (i * 4))
204+
}
205+
206+
return buffer.slice(0, offset)
207+
}
208+
209+
SLPEncoder.prototype.addFrame = function ({ width, height, data, hotspot }) {
210+
const commands = pixelsToRenderCommands(this.colorIndices, { width, height, data })
211+
212+
this.frames.push({
213+
cmdTableOffset: 0,
214+
outlineTableOffset: 0,
215+
paletteOffset: 0,
216+
properties: 0,
217+
width,
218+
height,
219+
hotspot: hotspot || { x: 0, y: 0 },
220+
data,
221+
commands
222+
})
223+
224+
return commands
225+
}
226+
227+
SLPEncoder.prototype.encode = function () {
228+
const header = {
229+
version: padSlice(this.version, 4),
230+
numFrames: this.frames.length,
231+
comment: padSlice(this.comment, 24),
232+
frames: this.frames
233+
}
234+
235+
let offset = headerStruct.size(header)
236+
const frameBuffers = this.frames.map((frame) => {
237+
frame.outlineTableOffset = offset
238+
frame.cmdTableOffset = offset + frame.height * 4
239+
const buffer = renderCommandsToSlpFrame({
240+
width: frame.width,
241+
height: frame.height,
242+
commands: frame.commands,
243+
baseOffset: offset
244+
})
245+
offset += buffer.length
246+
return buffer
247+
})
248+
249+
return Buffer.concat([
250+
headerStruct.encode(header),
251+
...frameBuffers
252+
])
253+
}

test/archer.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const fs = require('fs')
2+
const test = require('tape')
3+
const { PNG } = require('pngjs')
4+
const Palette = require('jascpal')
5+
const SLP = require('../src/SLP')
6+
const SLPEncoder = require('../src/SLPEncoder')
7+
8+
const slpPath = require.resolve('./archer/12.slp')
9+
const pngPath = require.resolve('./archer/15.png')
10+
const palPath = require.resolve('./archer/palette.pal')
11+
12+
const slp = SLP(fs.readFileSync(slpPath))
13+
const palette = Palette(fs.readFileSync(palPath))
14+
const png = new PNG()
15+
fs.createReadStream(pngPath).pipe(png)
16+
png.on('parsed', () => {
17+
normalizeTransparentPixels(png.data)
18+
19+
run()
20+
})
21+
22+
function normalizeTransparentPixels (data) {
23+
for (let i = 0; i < data.length; i += 4) {
24+
if (data[i] === 0xFF && data[i + 1] === 0xFF && data[i + 2] === 0xFF && data[i + 3] === 0) {
25+
data[i] = data[i + 1] = data[i + 2] = 0
26+
}
27+
}
28+
}
29+
30+
function run () {
31+
test('SLP#renderFrame', (t) => {
32+
const image = slp.renderFrame(15, palette, { player: 1 })
33+
34+
t.equal(
35+
Buffer.from(image.data).toString('base64'),
36+
png.data.toString('base64'),
37+
'rendered frame should be same as comparison png'
38+
)
39+
t.end()
40+
})
41+
42+
test('SLPEncoder', (t) => {
43+
const enc = SLPEncoder({
44+
palette,
45+
version: '1.00',
46+
comment: 'Generated by genie-slp'
47+
})
48+
enc.addFrame(png) // `width`, `height`, `data` props
49+
50+
const slpBuffer = enc.encode()
51+
const slp2 = SLP(slpBuffer)
52+
slp2.parseHeader()
53+
54+
t.equal(slp2.comment, 'Generated by genie-slp\0\0')
55+
56+
const image = slp.renderFrame(15, palette, { player: 1 })
57+
const image2 = slp2.renderFrame(0, palette, { player: 1 })
58+
t.equal(
59+
Buffer.from(image.data).toString('base64'),
60+
Buffer.from(image2.data).toString('base64'),
61+
'frame from generated slp should be same as original'
62+
)
63+
t.end()
64+
})
65+
}

test/archer/12.slp

56.4 KB
Binary file not shown.

test/archer/15.png

1.78 KB
Loading

0 commit comments

Comments
 (0)