squishjs 1.3.8 → 1.4.0
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/Squisher.js +26 -4
- package/src/squishHelpers/asset.js +35 -5
- package/src/util/views.js +49 -4
- 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/Squisher.js
CHANGED
|
@@ -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
|
|
|
@@ -281,10 +288,25 @@ class Squisher {
|
|
|
281
288
|
}
|
|
282
289
|
|
|
283
290
|
handleStateChange(node, layerName) {
|
|
284
|
-
if (this.listeners.size
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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();
|
|
288
310
|
}
|
|
289
311
|
|
|
290
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/src/util/views.js
CHANGED
|
@@ -23,6 +23,10 @@ const getView = (plane, view, playerIds, translation = {}, scale = {}) => {
|
|
|
23
23
|
let shouldInclude = true;
|
|
24
24
|
|
|
25
25
|
const translatedCoords = [];
|
|
26
|
+
// Same vertices as translatedCoords but WITHOUT the [0,100] viewport
|
|
27
|
+
// clamp. Needed for asset crop math: to know how much of an image
|
|
28
|
+
// falls outside the viewport we need its true (unclamped) extent.
|
|
29
|
+
const rawTranslatedCoords = [];
|
|
26
30
|
|
|
27
31
|
// same hack as geometry utils
|
|
28
32
|
const vertices = node.node.coordinates2d || [
|
|
@@ -70,6 +74,22 @@ const getView = (plane, view, playerIds, translation = {}, scale = {}) => {
|
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
translatedCoords.push([translatedX, translatedY]);
|
|
77
|
+
|
|
78
|
+
// Unclamped transform of the same vertex (scale + translation,
|
|
79
|
+
// but no clamp to [0,100]). Used only for asset crop below.
|
|
80
|
+
let rawX = (scale.x || 1) * (x - view.x);
|
|
81
|
+
let rawY = (scale.y || 1) * (y - view.y);
|
|
82
|
+
|
|
83
|
+
if (shouldTranslate) {
|
|
84
|
+
if (translation.x) {
|
|
85
|
+
rawX += translation.x;
|
|
86
|
+
}
|
|
87
|
+
if (translation.y) {
|
|
88
|
+
rawY += translation.y;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
rawTranslatedCoords.push([rawX, rawY]);
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
if (shouldInclude) {
|
|
@@ -89,10 +109,35 @@ const getView = (plane, view, playerIds, translation = {}, scale = {}) => {
|
|
|
89
109
|
const thirdPoint = copied.node.coordinates2d[2];
|
|
90
110
|
const width = secondPoint[0] - firstPoint[0];
|
|
91
111
|
const height = thirdPoint[1] - secondPoint[1];
|
|
92
|
-
|
|
93
|
-
Object.values(copied.node.asset)[0]
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
|
|
113
|
+
const assetObj = Object.values(copied.node.asset)[0];
|
|
114
|
+
|
|
115
|
+
// pos/size = the clamped, on-screen visible box.
|
|
116
|
+
assetObj.pos.x = firstPoint[0];
|
|
117
|
+
assetObj.pos.y = firstPoint[1];
|
|
118
|
+
assetObj.size.x = width;
|
|
119
|
+
assetObj.size.y = height;
|
|
120
|
+
|
|
121
|
+
// Crop the source image to the portion clipped by the
|
|
122
|
+
// viewport. Without this the renderer squashes the whole
|
|
123
|
+
// image into the (smaller) visible box instead of cutting
|
|
124
|
+
// off the off-screen part. Using the unclamped corners we
|
|
125
|
+
// measure how much of the image fell outside [0,100] on
|
|
126
|
+
// each edge, as a percentage of the image's full span.
|
|
127
|
+
const rawFirst = rawTranslatedCoords[0]; // top-left
|
|
128
|
+
const rawThird = rawTranslatedCoords[2]; // bottom-right
|
|
129
|
+
const fullWidth = rawThird[0] - rawFirst[0];
|
|
130
|
+
const fullHeight = rawThird[1] - rawFirst[1];
|
|
131
|
+
|
|
132
|
+
if (fullWidth > 0) {
|
|
133
|
+
assetObj.cropLeft = Math.max(0, (0 - rawFirst[0]) / fullWidth) * 100;
|
|
134
|
+
assetObj.cropRight = Math.max(0, (rawThird[0] - 100) / fullWidth) * 100;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (fullHeight > 0) {
|
|
138
|
+
assetObj.cropTop = Math.max(0, (0 - rawFirst[1]) / fullHeight) * 100;
|
|
139
|
+
assetObj.cropBottom = Math.max(0, (rawThird[1] - 100) / fullHeight) * 100;
|
|
140
|
+
}
|
|
96
141
|
}
|
|
97
142
|
}
|
|
98
143
|
copied.node.playerIds = playerIds || [];
|
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);
|