squishjs 1.3.7 → 1.3.9
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.
- package/.claude/settings.local.json +8 -0
- package/package.json +1 -1
- package/src/Asset.js +15 -19
- package/src/Squisher.js +97 -55
- package/src/squishHelpers/asset.js +35 -5
- package/test/Layers.test.js +2 -0
- package/test/Squisher.test.js +18 -17
- package/test/main.test.js +2 -0
package/package.json
CHANGED
package/src/Asset.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const crypto = require('crypto');
|
|
2
6
|
const { getAppDataPath } = require('./utils');
|
|
3
7
|
|
|
4
8
|
// assets is the old stuff, save it for backward compatibility
|
|
@@ -20,14 +24,6 @@ class Asset {
|
|
|
20
24
|
if (data) {
|
|
21
25
|
this.data = data;
|
|
22
26
|
}
|
|
23
|
-
|
|
24
|
-
// this is dumb. was trying something but made it dumb.
|
|
25
|
-
this.https = require('https');
|
|
26
|
-
this.http = require('http');
|
|
27
|
-
this.fs = require('fs');
|
|
28
|
-
this.crypto = require('crypto');
|
|
29
|
-
this.path = require('path');
|
|
30
|
-
this.process = require('process');
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
getConfigValue(key, _default = undefined) {
|
|
@@ -62,9 +58,9 @@ class Asset {
|
|
|
62
58
|
let _config = {};
|
|
63
59
|
|
|
64
60
|
for (let i = 0; i < options.length; i++) {
|
|
65
|
-
if (
|
|
61
|
+
if (fs.existsSync(`${options[i]}/config.json`)) {
|
|
66
62
|
console.log(`Using config at ${options[i]}`);
|
|
67
|
-
_config = JSON.parse(
|
|
63
|
+
_config = JSON.parse(fs.readFileSync(`${options[i]}/config.json`));
|
|
68
64
|
break;
|
|
69
65
|
}
|
|
70
66
|
}
|
|
@@ -76,7 +72,7 @@ class Asset {
|
|
|
76
72
|
|
|
77
73
|
|
|
78
74
|
getHash(str) {
|
|
79
|
-
const shasum =
|
|
75
|
+
const shasum = crypto.createHash('sha1');
|
|
80
76
|
shasum.update(str);
|
|
81
77
|
return shasum.digest('hex');
|
|
82
78
|
};
|
|
@@ -98,7 +94,7 @@ class Asset {
|
|
|
98
94
|
return resolve(true);
|
|
99
95
|
}
|
|
100
96
|
const fileLocation = this.getFileLocation(this.info.id);
|
|
101
|
-
|
|
97
|
+
fs.exists(fileLocation, (exists) => {
|
|
102
98
|
resolve(exists && fileLocation);
|
|
103
99
|
});
|
|
104
100
|
});
|
|
@@ -124,8 +120,8 @@ class Asset {
|
|
|
124
120
|
|
|
125
121
|
download(force) {
|
|
126
122
|
const HG_ASSET_PATH = path.join(getAppDataPath(), 'asset-cache');
|
|
127
|
-
if (!
|
|
128
|
-
|
|
123
|
+
if (!fs.existsSync(HG_ASSET_PATH)) {
|
|
124
|
+
fs.mkdirSync(HG_ASSET_PATH);
|
|
129
125
|
}
|
|
130
126
|
return new Promise((resolve, reject) => {
|
|
131
127
|
this.existsLocally().then(fileLocation => {
|
|
@@ -150,7 +146,7 @@ class Asset {
|
|
|
150
146
|
resolve(this.data);
|
|
151
147
|
} else {
|
|
152
148
|
this.download().then(fileLocation => {
|
|
153
|
-
|
|
149
|
+
fs.readFile(fileLocation, (err, buf) => {
|
|
154
150
|
if (err) {
|
|
155
151
|
reject(err);
|
|
156
152
|
} else {
|
|
@@ -164,13 +160,13 @@ class Asset {
|
|
|
164
160
|
});
|
|
165
161
|
}
|
|
166
162
|
|
|
167
|
-
doDownload(assetId,
|
|
163
|
+
doDownload(assetId, assetPath) {
|
|
168
164
|
return new Promise((resolve, reject) => {
|
|
169
165
|
const fileHash = this.getHash(assetId);
|
|
170
|
-
const filePath = `${
|
|
166
|
+
const filePath = `${assetPath}/${fileHash}`;
|
|
171
167
|
|
|
172
|
-
const writeStream =
|
|
173
|
-
const getModule = ASSET_URL.startsWith('https') ?
|
|
168
|
+
const writeStream = fs.createWriteStream(filePath);
|
|
169
|
+
const getModule = ASSET_URL.startsWith('https') ? https : http;
|
|
174
170
|
|
|
175
171
|
writeStream.on('close', () => {
|
|
176
172
|
resolve(filePath);
|
package/src/Squisher.js
CHANGED
|
@@ -11,7 +11,7 @@ class Squisher {
|
|
|
11
11
|
|
|
12
12
|
this.game = game;
|
|
13
13
|
this.gameMetadata = game.constructor.metadata && game.constructor.metadata();
|
|
14
|
-
this.
|
|
14
|
+
this._assetBufferCache = {};
|
|
15
15
|
this.playerSettings = {};
|
|
16
16
|
|
|
17
17
|
this.customBottomLayer = customBottomLayer;
|
|
@@ -23,6 +23,13 @@ class Squisher {
|
|
|
23
23
|
this.playerFrames = {};
|
|
24
24
|
|
|
25
25
|
this.listeners = new Set();
|
|
26
|
+
// Coalescing: a single tick often mutates many nodes, each firing
|
|
27
|
+
// onStateChange. Instead of re-squishing the whole tree + broadcasting
|
|
28
|
+
// per mutation, we mark dirty and flush once at the end of the event
|
|
29
|
+
// loop turn. Consumers that need the current state synchronously (e.g.
|
|
30
|
+
// sending an initial frame to a player who just joined) call flush().
|
|
31
|
+
this._dirty = false;
|
|
32
|
+
this._flushScheduled = false;
|
|
26
33
|
this.scale = scale || {x: 1, y: 1};
|
|
27
34
|
this.state = this.squish(this.game.getLayers());
|
|
28
35
|
|
|
@@ -97,6 +104,10 @@ class Squisher {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
handleNewAsset(key, asset) {
|
|
107
|
+
// Invalidate just this key so initialize() re-fetches only its data
|
|
108
|
+
if (this._assetBufferCache) {
|
|
109
|
+
delete this._assetBufferCache[key];
|
|
110
|
+
}
|
|
100
111
|
return this.initialize();
|
|
101
112
|
}
|
|
102
113
|
|
|
@@ -169,9 +180,6 @@ class Squisher {
|
|
|
169
180
|
|
|
170
181
|
initialize() {
|
|
171
182
|
return new Promise((resolve, reject) => {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const assets = Object.assign({}, this.assets || {});
|
|
175
183
|
|
|
176
184
|
const gameMetadata = this.game.constructor.metadata && this.game.constructor.metadata();
|
|
177
185
|
|
|
@@ -190,65 +198,84 @@ class Squisher {
|
|
|
190
198
|
Object.assign(gameAssets, this.game.getAssets());
|
|
191
199
|
}
|
|
192
200
|
|
|
193
|
-
const
|
|
201
|
+
const assetTypeMap = {
|
|
202
|
+
'image': 1,
|
|
203
|
+
'audio': 2,
|
|
204
|
+
'font': 3
|
|
205
|
+
};
|
|
194
206
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const totalCount = Object.keys(allAssets).length;
|
|
207
|
+
const assetKeys = Object.keys(gameAssets);
|
|
208
|
+
const totalCount = assetKeys.length;
|
|
198
209
|
|
|
199
210
|
if (totalCount === 0) {
|
|
200
|
-
this.assetBundle =
|
|
201
|
-
resolve(
|
|
211
|
+
this.assetBundle = Buffer.alloc(0);
|
|
212
|
+
resolve(this.assetBundle);
|
|
202
213
|
return;
|
|
203
214
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
215
|
+
|
|
216
|
+
if (!this._assetBufferCache) {
|
|
217
|
+
this._assetBufferCache = {};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let finishedCount = 0;
|
|
221
|
+
const perAssetBuffers = new Array(totalCount);
|
|
222
|
+
|
|
223
|
+
for (let idx = 0; idx < totalCount; idx++) {
|
|
224
|
+
const key = assetKeys[idx];
|
|
225
|
+
|
|
226
|
+
// If we already have a packed buffer for this key, reuse it
|
|
227
|
+
if (this._assetBufferCache[key]) {
|
|
228
|
+
perAssetBuffers[idx] = this._assetBufferCache[key];
|
|
229
|
+
finishedCount += 1;
|
|
230
|
+
if (finishedCount === totalCount) {
|
|
231
|
+
this.assetBundle = Buffer.concat(perAssetBuffers);
|
|
232
|
+
resolve(this.assetBundle);
|
|
213
233
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
'audio': 2,
|
|
220
|
-
'font': 3
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const assetType = assetTypeMap[allAssets[key].info.type];
|
|
224
|
-
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
gameAssets[key].getData().then(buf => {
|
|
238
|
+
const assetKeyLength = 32;
|
|
225
239
|
const encodedMaxLength = 10;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
240
|
+
|
|
241
|
+
const headerLength = 2 + encodedMaxLength + assetKeyLength;
|
|
242
|
+
const assetBuf = Buffer.alloc(headerLength + buf.length);
|
|
243
|
+
|
|
244
|
+
assetBuf[0] = ASSET_TYPE;
|
|
245
|
+
assetBuf[1] = assetTypeMap[gameAssets[key].info.type];
|
|
246
|
+
|
|
247
|
+
// Encode the length (data + key length) as base-36, left-padded
|
|
248
|
+
const encodedLength = (buf.length + assetKeyLength).toString(36);
|
|
249
|
+
const padLen = encodedMaxLength - encodedLength.length;
|
|
250
|
+
for (let i = 0; i < padLen; i++) {
|
|
251
|
+
assetBuf[2 + i] = 48; // '0' charCode
|
|
229
252
|
}
|
|
230
|
-
for (let
|
|
231
|
-
|
|
253
|
+
for (let i = 0; i < encodedLength.length; i++) {
|
|
254
|
+
assetBuf[2 + padLen + i] = encodedLength.charCodeAt(i);
|
|
232
255
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
|
|
257
|
+
// Write asset key (up to 32 chars, rest stays 0)
|
|
258
|
+
const keyWriteLen = Math.min(assetKeyLength, key.length);
|
|
259
|
+
for (let i = 0; i < keyWriteLen; i++) {
|
|
260
|
+
assetBuf[2 + encodedMaxLength + i] = key.charCodeAt(i);
|
|
236
261
|
}
|
|
237
|
-
|
|
238
|
-
this.assets[key] = [ASSET_TYPE, assetType, ...encodedLengthArray, ...assetKeyArray, ...buf];
|
|
239
|
-
assetBundleSize += this.assets[key].length;
|
|
240
|
-
finishedCount += 1;
|
|
241
262
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
263
|
+
// Copy the raw asset data
|
|
264
|
+
if (Buffer.isBuffer(buf)) {
|
|
265
|
+
buf.copy(assetBuf, headerLength);
|
|
266
|
+
} else {
|
|
267
|
+
for (let i = 0; i < buf.length; i++) {
|
|
268
|
+
assetBuf[headerLength + i] = buf[i];
|
|
249
269
|
}
|
|
250
|
-
|
|
251
|
-
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this._assetBufferCache[key] = assetBuf;
|
|
273
|
+
perAssetBuffers[idx] = assetBuf;
|
|
274
|
+
finishedCount += 1;
|
|
275
|
+
|
|
276
|
+
if (finishedCount === totalCount) {
|
|
277
|
+
this.assetBundle = Buffer.concat(perAssetBuffers);
|
|
278
|
+
resolve(this.assetBundle);
|
|
252
279
|
}
|
|
253
280
|
}).catch(err => {
|
|
254
281
|
console.error('Unable to get asset data for key ' + key);
|
|
@@ -261,10 +288,25 @@ class Squisher {
|
|
|
261
288
|
}
|
|
262
289
|
|
|
263
290
|
handleStateChange(node, layerName) {
|
|
264
|
-
if (this.listeners.size
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
291
|
+
if (this.listeners.size === 0) return;
|
|
292
|
+
this._dirty = true;
|
|
293
|
+
if (this._flushScheduled) return;
|
|
294
|
+
this._flushScheduled = true;
|
|
295
|
+
const schedule = (typeof setImmediate === 'function')
|
|
296
|
+
? setImmediate
|
|
297
|
+
: (fn) => setTimeout(fn, 0);
|
|
298
|
+
schedule(() => this.flush());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Squish + broadcast any pending state changes synchronously. Safe to call
|
|
302
|
+
// when nothing is pending (no-op). Coalesces a burst of mutations into a
|
|
303
|
+
// single squish + single broadcast carrying the latest state.
|
|
304
|
+
flush() {
|
|
305
|
+
this._flushScheduled = false;
|
|
306
|
+
if (!this._dirty) return;
|
|
307
|
+
this._dirty = false;
|
|
308
|
+
this.state = this.squish(this.game.getLayers());
|
|
309
|
+
this.broadcast();
|
|
268
310
|
}
|
|
269
311
|
|
|
270
312
|
broadcast() {
|
|
@@ -6,7 +6,7 @@ const squishAsset = {
|
|
|
6
6
|
type: ASSET_SUBTYPE,
|
|
7
7
|
squish: (a, scale) => {
|
|
8
8
|
const assetKey = Object.keys(a)[0];
|
|
9
|
-
const squishedAssets = new Array(
|
|
9
|
+
const squishedAssets = new Array(18 + assetKey.length);
|
|
10
10
|
|
|
11
11
|
const asset = a[assetKey];
|
|
12
12
|
|
|
@@ -18,6 +18,15 @@ const squishAsset = {
|
|
|
18
18
|
|
|
19
19
|
const startTimeSecond = asset.startTime || 0;
|
|
20
20
|
|
|
21
|
+
// Crop values are percentages (0-100) of the source image, expressed as
|
|
22
|
+
// the amount removed from each edge. They sample a sub-region of the
|
|
23
|
+
// source and are independent of layer scale (which affects pos/size, i.e.
|
|
24
|
+
// where the cropped region lands on screen). Default to 0 (no crop).
|
|
25
|
+
const cropLeft = asset.cropLeft || 0;
|
|
26
|
+
const cropTop = asset.cropTop || 0;
|
|
27
|
+
const cropRight = asset.cropRight || 0;
|
|
28
|
+
const cropBottom = asset.cropBottom || 0;
|
|
29
|
+
|
|
21
30
|
squishedAssets[0] = Math.floor(posX);
|
|
22
31
|
squishedAssets[1] = getFractional(posX);
|
|
23
32
|
|
|
@@ -33,8 +42,20 @@ const squishAsset = {
|
|
|
33
42
|
squishedAssets[8] = Math.floor(startTimeSecond);
|
|
34
43
|
squishedAssets[9] = getFractional(startTimeSecond);
|
|
35
44
|
|
|
45
|
+
squishedAssets[10] = Math.floor(cropLeft);
|
|
46
|
+
squishedAssets[11] = getFractional(cropLeft);
|
|
47
|
+
|
|
48
|
+
squishedAssets[12] = Math.floor(cropTop);
|
|
49
|
+
squishedAssets[13] = getFractional(cropTop);
|
|
50
|
+
|
|
51
|
+
squishedAssets[14] = Math.floor(cropRight);
|
|
52
|
+
squishedAssets[15] = getFractional(cropRight);
|
|
53
|
+
|
|
54
|
+
squishedAssets[16] = Math.floor(cropBottom);
|
|
55
|
+
squishedAssets[17] = getFractional(cropBottom);
|
|
56
|
+
|
|
36
57
|
for (let i = 0; i < assetKey.length; i++) {
|
|
37
|
-
squishedAssets[
|
|
58
|
+
squishedAssets[18 + i] = assetKey.codePointAt(i);
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
return squishedAssets;
|
|
@@ -48,7 +69,12 @@ const squishAsset = {
|
|
|
48
69
|
|
|
49
70
|
const startTime = squished[8] + squished[9] / 100;
|
|
50
71
|
|
|
51
|
-
const
|
|
72
|
+
const cropLeft = squished[10] + squished[11] / 100;
|
|
73
|
+
const cropTop = squished[12] + squished[13] / 100;
|
|
74
|
+
const cropRight = squished[14] + squished[15] / 100;
|
|
75
|
+
const cropBottom = squished[16] + squished[17] / 100;
|
|
76
|
+
|
|
77
|
+
const assetKey = String.fromCodePoint.apply(null, squished.slice(18));
|
|
52
78
|
|
|
53
79
|
return {
|
|
54
80
|
[assetKey]: {
|
|
@@ -60,7 +86,11 @@ const squishAsset = {
|
|
|
60
86
|
x: assetSizeX,
|
|
61
87
|
y: assetSizeY
|
|
62
88
|
},
|
|
63
|
-
startTime
|
|
89
|
+
startTime,
|
|
90
|
+
cropLeft,
|
|
91
|
+
cropTop,
|
|
92
|
+
cropRight,
|
|
93
|
+
cropBottom
|
|
64
94
|
}
|
|
65
95
|
}
|
|
66
96
|
}
|
|
@@ -69,4 +99,4 @@ const squishAsset = {
|
|
|
69
99
|
module.exports = {
|
|
70
100
|
ASSET_SUBTYPE,
|
|
71
101
|
squishAsset
|
|
72
|
-
};
|
|
102
|
+
};
|
package/test/Layers.test.js
CHANGED
|
@@ -56,6 +56,8 @@ test("test layer with child added", () => {
|
|
|
56
56
|
|
|
57
57
|
// adding a child should trigger an update in the squisher and result in a new state
|
|
58
58
|
layer.addChild(child);
|
|
59
|
+
// state changes are coalesced; flush to materialize them synchronously
|
|
60
|
+
squisher.flush();
|
|
59
61
|
|
|
60
62
|
const expectedLayerValue = [squish(layer), squish(child)].flat();
|
|
61
63
|
const stateWithChild = Array.from(squisher.state);
|
package/test/Squisher.test.js
CHANGED
|
@@ -5,48 +5,49 @@ const { unsquish } = require('../src/squish');
|
|
|
5
5
|
|
|
6
6
|
const { FakeGame, verifyArrayEquality, rectNode, textNode, polygonNode, circleNode, lineNode } = require('./utils');
|
|
7
7
|
|
|
8
|
-
test("squisher listener", () => {
|
|
8
|
+
test("squisher listener coalesces state changes", () => {
|
|
9
9
|
|
|
10
10
|
const root = lineNode({coords: [[20, 20], [80, 80]], color: COLORS.ORANGE});
|
|
11
|
-
|
|
12
|
-
const game = new FakeGame([
|
|
11
|
+
|
|
12
|
+
const game = new FakeGame([
|
|
13
13
|
{
|
|
14
14
|
root,
|
|
15
15
|
scale: {
|
|
16
16
|
x: 1,
|
|
17
17
|
y: 1
|
|
18
18
|
}
|
|
19
|
-
}
|
|
19
|
+
}
|
|
20
20
|
]);
|
|
21
21
|
|
|
22
22
|
const squisher = new Squisher({ game });
|
|
23
23
|
|
|
24
24
|
let timesInvoked = 0;
|
|
25
|
+
let lastState = null;
|
|
25
26
|
|
|
26
27
|
squisher.addListener((newSquishedState) => {
|
|
27
|
-
if (timesInvoked === 0) {
|
|
28
|
-
assert(newSquishedState.length == 1);
|
|
29
|
-
const updatedRoot = unsquish(newSquishedState[0]);
|
|
30
|
-
verifyArrayEquality(updatedRoot.node.color, COLORS.BLACK);
|
|
31
|
-
} else if (timesInvoked === 1) {
|
|
32
|
-
assert(newSquishedState.length == 1);
|
|
33
|
-
const updatedRoot = unsquish(newSquishedState[0]);
|
|
34
|
-
verifyArrayEquality(updatedRoot.node.color, COLORS.GREEN);
|
|
35
|
-
}
|
|
36
28
|
timesInvoked++;
|
|
29
|
+
lastState = newSquishedState;
|
|
37
30
|
});
|
|
38
31
|
|
|
39
32
|
assert(timesInvoked === 0);
|
|
40
33
|
|
|
34
|
+
// A burst of mutations in the same turn is deferred — nothing broadcast yet.
|
|
41
35
|
root.node.color = COLORS.BLACK;
|
|
42
36
|
root.node.onStateChange();
|
|
43
|
-
|
|
44
|
-
assert(timesInvoked === 1);
|
|
45
|
-
|
|
46
37
|
root.node.color = COLORS.GREEN;
|
|
47
38
|
root.node.onStateChange();
|
|
48
39
|
|
|
49
|
-
assert(timesInvoked ===
|
|
40
|
+
assert(timesInvoked === 0);
|
|
41
|
+
|
|
42
|
+
// flush() coalesces the burst into a single broadcast with the final state.
|
|
43
|
+
squisher.flush();
|
|
44
|
+
assert(timesInvoked === 1);
|
|
45
|
+
assert(lastState.length == 1);
|
|
46
|
+
verifyArrayEquality(unsquish(lastState[0]).node.color, COLORS.GREEN);
|
|
47
|
+
|
|
48
|
+
// flush() with nothing pending is a no-op.
|
|
49
|
+
squisher.flush();
|
|
50
|
+
assert(timesInvoked === 1);
|
|
50
51
|
});
|
|
51
52
|
|
|
52
53
|
|
package/test/main.test.js
CHANGED
|
@@ -435,6 +435,7 @@ test("Simple shape with updates", () => {
|
|
|
435
435
|
base.update({
|
|
436
436
|
fill: COLORS.BLUE
|
|
437
437
|
});
|
|
438
|
+
squisher.flush();
|
|
438
439
|
|
|
439
440
|
const state2 = Array.from(squisher.state);
|
|
440
441
|
verifyArrayEquality(unsquish(state2[0]).node.fill, COLORS.BLUE);
|
|
@@ -442,6 +443,7 @@ test("Simple shape with updates", () => {
|
|
|
442
443
|
base.update({
|
|
443
444
|
fill: COLORS.GREEN
|
|
444
445
|
});
|
|
446
|
+
squisher.flush();
|
|
445
447
|
|
|
446
448
|
const state3 = Array.from(squisher.state);
|
|
447
449
|
verifyArrayEquality(unsquish(state3[0]).node.fill, COLORS.GREEN);
|