squishjs 1.4.0 → 1.4.2
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 +2 -1
- package/package.json +1 -1
- package/src/Squisher.js +38 -22
- package/src/squishHelpers/coordinates2d.js +19 -7
- package/src/util/views.js +8 -1
- package/test/Squisher.test.js +92 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"permissions": {
|
|
3
3
|
"allow": [
|
|
4
4
|
"Read(//Users/josephgarcia/homegames/homegames-client/**)",
|
|
5
|
-
"Read(//Users/josephgarcia/homegames/homegames-web/**)"
|
|
5
|
+
"Read(//Users/josephgarcia/homegames/homegames-web/**)",
|
|
6
|
+
"Read(//Users/josephgarcia/homegames/homegamesio/**)"
|
|
6
7
|
]
|
|
7
8
|
}
|
|
8
9
|
}
|
package/package.json
CHANGED
package/src/Squisher.js
CHANGED
|
@@ -67,30 +67,34 @@ class Squisher {
|
|
|
67
67
|
let toSquish = [];
|
|
68
68
|
|
|
69
69
|
const playerMap = {};
|
|
70
|
+
// Shared (visible-to-everyone) nodes in traversal order, kept so a
|
|
71
|
+
// player frame created mid-traversal can be seeded with everything
|
|
72
|
+
// shared that was squished before that player's first scoped node.
|
|
73
|
+
const sharedNodes = [];
|
|
70
74
|
|
|
71
75
|
if (this.customBottomLayer) {
|
|
72
76
|
const squishedLayer = [];
|
|
73
|
-
this.squishHelper(this.customBottomLayer.root, squishedLayer, this.customBottomLayer.scale, playerMap);
|
|
77
|
+
this.squishHelper(this.customBottomLayer.root, squishedLayer, this.customBottomLayer.scale, playerMap, new Set(), sharedNodes);
|
|
74
78
|
toSquish.push(squishedLayer);
|
|
75
79
|
}
|
|
76
|
-
|
|
80
|
+
|
|
77
81
|
for (const layerIndex in layers) {
|
|
78
82
|
const squishedLayer = [];
|
|
79
83
|
const layerInfo = layers[layerIndex];
|
|
80
|
-
|
|
84
|
+
|
|
81
85
|
const layerScale = layerInfo.scale ? {
|
|
82
86
|
x: this.scale.x * layerInfo.scale.x,
|
|
83
87
|
y: this.scale.y * layerInfo.scale.y
|
|
84
88
|
} : this.scale;
|
|
85
89
|
|
|
86
|
-
this.squishHelper(layerInfo.root, squishedLayer, scale || layerScale, playerMap);
|
|
90
|
+
this.squishHelper(layerInfo.root, squishedLayer, scale || layerScale, playerMap, new Set(), sharedNodes);
|
|
87
91
|
toSquish.push(squishedLayer);
|
|
88
92
|
}
|
|
89
93
|
|
|
90
94
|
|
|
91
|
-
if (this.customTopLayer) {
|
|
95
|
+
if (this.customTopLayer) {
|
|
92
96
|
const squishedLayer = [];
|
|
93
|
-
this.squishHelper(this.customTopLayer.root, squishedLayer, this.customTopLayer.scale, playerMap);
|
|
97
|
+
this.squishHelper(this.customTopLayer.root, squishedLayer, this.customTopLayer.scale, playerMap, new Set(), sharedNodes);
|
|
94
98
|
toSquish.push(squishedLayer);
|
|
95
99
|
}
|
|
96
100
|
|
|
@@ -111,7 +115,19 @@ class Squisher {
|
|
|
111
115
|
return this.initialize();
|
|
112
116
|
}
|
|
113
117
|
|
|
114
|
-
|
|
118
|
+
// Whether an audio node should be withheld from a player who has muted
|
|
119
|
+
// sound. Non-audio nodes (and unregistered assets) are never withheld.
|
|
120
|
+
// Applied consistently in both the player-scoped and broadcast paths.
|
|
121
|
+
_isAudioMutedFor(node, playerId) {
|
|
122
|
+
if (!node.node.asset) return false;
|
|
123
|
+
const assetKey = Object.keys(node.node.asset)[0];
|
|
124
|
+
const assetInfo = this.gameAssets && this.gameAssets[assetKey] && this.gameAssets[assetKey].info;
|
|
125
|
+
if (!assetInfo || assetInfo.type !== 'audio') return false;
|
|
126
|
+
const sound = this.playerSettings[playerId] && this.playerSettings[playerId].SOUND;
|
|
127
|
+
return !!(sound && sound.enabled === false);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
squishHelper(node, squishedNodes, scale = {x: 1, y: 1}, playerMap = {}, playerIdFilter = new Set(), sharedNodes = []) {
|
|
115
131
|
if (!node.node.listeners.has(this)) {
|
|
116
132
|
node.addListener(this);
|
|
117
133
|
}
|
|
@@ -127,11 +143,19 @@ class Squisher {
|
|
|
127
143
|
let playerIdsToRemove = new Set();
|
|
128
144
|
for (let playerId of playerIdFilter) {
|
|
129
145
|
if (!playerMap[playerId]) {
|
|
130
|
-
|
|
131
|
-
|
|
146
|
+
// First scoped node seen for this player. Seed their frame
|
|
147
|
+
// with every shared node squished so far — otherwise the
|
|
148
|
+
// frame would be missing everything shared (root
|
|
149
|
+
// background included) that preceded it in traversal.
|
|
150
|
+
playerMap[Number(playerId)] = sharedNodes
|
|
151
|
+
.filter(shared => !this._isAudioMutedFor(shared.node, playerId))
|
|
152
|
+
.map(shared => shared.squished);
|
|
153
|
+
}
|
|
132
154
|
|
|
133
155
|
if (node.node.playerIds.length === 0 || node.node.playerIds.findIndex(i => Number(i) === Number(playerId)) >= 0) {
|
|
134
|
-
|
|
156
|
+
if (!this._isAudioMutedFor(node, playerId)) {
|
|
157
|
+
playerMap[playerId].push(squished);
|
|
158
|
+
}
|
|
135
159
|
} else {
|
|
136
160
|
playerIdsToRemove.add(playerId);
|
|
137
161
|
}
|
|
@@ -140,17 +164,9 @@ class Squisher {
|
|
|
140
164
|
playerIdFilter.delete(id);
|
|
141
165
|
}
|
|
142
166
|
} else {
|
|
167
|
+
sharedNodes.push({ node, squished });
|
|
143
168
|
Object.keys(playerMap).forEach(playerId => {
|
|
144
|
-
if (!
|
|
145
|
-
playerMap[Number(playerId)] = [];
|
|
146
|
-
}
|
|
147
|
-
if (node.node.asset) {
|
|
148
|
-
const assetInfo = this.gameAssets[Object.keys(node.node.asset)[0]]?.info;
|
|
149
|
-
if (assetInfo.type === 'audio' && this.playerSettings[playerId]?.SOUND && !this.playerSettings[playerId].SOUND.enabled) {
|
|
150
|
-
} else {
|
|
151
|
-
playerMap[playerId].push(squished);
|
|
152
|
-
}
|
|
153
|
-
} else {
|
|
169
|
+
if (!this._isAudioMutedFor(node, playerId)) {
|
|
154
170
|
playerMap[playerId].push(squished);
|
|
155
171
|
}
|
|
156
172
|
})
|
|
@@ -158,10 +174,10 @@ class Squisher {
|
|
|
158
174
|
|
|
159
175
|
for (let i = 0; i < node.node.children.length; i++) {
|
|
160
176
|
// if (node.node.children[i].node.playerIds
|
|
161
|
-
// make a new set so child calls within a single generation arent
|
|
177
|
+
// make a new set so child calls within a single generation arent
|
|
162
178
|
// modifying the same filter set
|
|
163
179
|
const pathFilter = new Set(playerIdFilter);
|
|
164
|
-
this.squishHelper(node.node.children[i], squishedNodes, scale, playerMap, pathFilter);
|
|
180
|
+
this.squishHelper(node.node.children[i], squishedNodes, scale, playerMap, pathFilter, sharedNodes);
|
|
165
181
|
}
|
|
166
182
|
|
|
167
183
|
}
|
|
@@ -3,6 +3,17 @@ const subtypes = require('../subtypes');
|
|
|
3
3
|
|
|
4
4
|
const COORDINATES_2D_SUBTYPE = 52;
|
|
5
5
|
|
|
6
|
+
// The wire format packs each coordinate's integer part into a single unsigned
|
|
7
|
+
// byte, so any value outside [0, 255] wraps on transport — e.g. a vertex at
|
|
8
|
+
// -1.4 floors to -2, which becomes byte 254 and unsquishes to ~254.6. For a
|
|
9
|
+
// shape with some on-screen vertices that turns the whole polygon into a
|
|
10
|
+
// screen-spanning smear. Coordinates are only meaningful inside the 0–100
|
|
11
|
+
// plane; clamp to the byte-safe range so off-plane geometry pins near the edge
|
|
12
|
+
// instead of exploding. Values 100–255 are preserved so content can still slide
|
|
13
|
+
// off the right/bottom edge cleanly.
|
|
14
|
+
const BYTE_MAX = 255;
|
|
15
|
+
const clampCoord = (v) => (v < 0 ? 0 : (v > BYTE_MAX ? BYTE_MAX : v));
|
|
16
|
+
|
|
6
17
|
const squishHelper = (scale, coord) => {
|
|
7
18
|
const scaledCenter = scale * coord;
|
|
8
19
|
const removedSpaceCenter = Math.round(100 * (1 - scale));
|
|
@@ -17,11 +28,11 @@ const squishCoordinates2d = {
|
|
|
17
28
|
|
|
18
29
|
if (node.subType == subtypes.SHAPE_2D_CIRCLE) {
|
|
19
30
|
if (scale) {
|
|
20
|
-
const shiftedCenterX = squishHelper(scale.x, originalCoords[0])
|
|
31
|
+
const shiftedCenterX = clampCoord(squishHelper(scale.x, originalCoords[0]))
|
|
21
32
|
squished[0] = shiftedCenterX;
|
|
22
33
|
squished[1] = getFractional(shiftedCenterX);
|
|
23
34
|
|
|
24
|
-
const shiftedCenterY = squishHelper(scale.y, originalCoords[1])
|
|
35
|
+
const shiftedCenterY = clampCoord(squishHelper(scale.y, originalCoords[1]))
|
|
25
36
|
squished[2] = shiftedCenterY;
|
|
26
37
|
squished[3] = getFractional(shiftedCenterY);
|
|
27
38
|
|
|
@@ -36,11 +47,11 @@ const squishCoordinates2d = {
|
|
|
36
47
|
squished[4] = Math.floor(diagonal);
|
|
37
48
|
squished[5] = getFractional(diagonal);
|
|
38
49
|
} else {
|
|
39
|
-
const centerX = originalCoords[0];
|
|
50
|
+
const centerX = clampCoord(originalCoords[0]);
|
|
40
51
|
squished[0] = Math.floor(centerX);
|
|
41
52
|
squished[1] = getFractional(centerX);
|
|
42
53
|
|
|
43
|
-
const centerY = originalCoords[1];
|
|
54
|
+
const centerY = clampCoord(originalCoords[1]);
|
|
44
55
|
squished[2] = Math.floor(centerY);
|
|
45
56
|
squished[3] = getFractional(centerY);
|
|
46
57
|
|
|
@@ -57,14 +68,15 @@ const squishCoordinates2d = {
|
|
|
57
68
|
|
|
58
69
|
const removedSpace = Math.round(100 * (1 - scaleValue));
|
|
59
70
|
|
|
60
|
-
const shifted = scaled + (removedSpace / 2);
|
|
71
|
+
const shifted = clampCoord(scaled + (removedSpace / 2));
|
|
61
72
|
|
|
62
73
|
squished[2 * i] = shifted;
|
|
63
74
|
squished[(2 * i) + 1] = getFractional(shifted);
|
|
64
75
|
|
|
65
76
|
} else {
|
|
66
|
-
|
|
67
|
-
squished[
|
|
77
|
+
const coord = clampCoord(originalCoords[i]);
|
|
78
|
+
squished[2 * i] = Math.floor(coord);
|
|
79
|
+
squished[(2 * i) + 1] = Math.round(100 * (coord - Math.floor(coord)));
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
}
|
package/src/util/views.js
CHANGED
|
@@ -93,7 +93,14 @@ const getView = (plane, view, playerIds, translation = {}, scale = {}) => {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if (shouldInclude) {
|
|
96
|
-
const copied = node.clone({
|
|
96
|
+
const copied = node.clone({
|
|
97
|
+
handleClick: node.node.handleClick === null || node.node.handleClick === undefined ? null : node.node.handleClick,
|
|
98
|
+
// Preserve the source node's id so each view-clone keeps a
|
|
99
|
+
// stable id across frames. Without this, clone() assigns a
|
|
100
|
+
// fresh random id every frame, so the client's hover logic
|
|
101
|
+
// sees the node "change" every frame and spams onhover/offhover.
|
|
102
|
+
id: node.node.id
|
|
103
|
+
});
|
|
97
104
|
|
|
98
105
|
if (translatedCoords && translatedCoords.length) {
|
|
99
106
|
if (copied.node.text) {
|
package/test/Squisher.test.js
CHANGED
|
@@ -4,6 +4,9 @@ const Squisher = require('../src/Squisher');
|
|
|
4
4
|
const { unsquish } = require('../src/squish');
|
|
5
5
|
|
|
6
6
|
const { FakeGame, verifyArrayEquality, rectNode, textNode, polygonNode, circleNode, lineNode } = require('./utils');
|
|
7
|
+
const { GameNode } = require('../src/GameNode');
|
|
8
|
+
const Shapes = require('../src/Shapes');
|
|
9
|
+
const ShapeUtils = require('../src/util/shapes');
|
|
7
10
|
|
|
8
11
|
test("squisher listener coalesces state changes", () => {
|
|
9
12
|
|
|
@@ -50,4 +53,93 @@ test("squisher listener coalesces state changes", () => {
|
|
|
50
53
|
assert(timesInvoked === 1);
|
|
51
54
|
});
|
|
52
55
|
|
|
56
|
+
test("squisher does not crash on an asset node with an unregistered key", () => {
|
|
57
|
+
// Reproduces the mid-upload case: a GameNode.Asset referencing a key that
|
|
58
|
+
// isn't in gameAssets yet. Must not throw while building per-player frames.
|
|
59
|
+
const root = rectNode({ x: 0, y: 0, width: 100, height: 100 });
|
|
60
|
+
|
|
61
|
+
const scoped = new GameNode.Shape({
|
|
62
|
+
shapeType: Shapes.POLYGON,
|
|
63
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 10, 10),
|
|
64
|
+
fill: COLORS.RED,
|
|
65
|
+
playerIds: [1]
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const assetNode = new GameNode.Asset({
|
|
69
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 10, 10),
|
|
70
|
+
assetInfo: { 'unregistered-key': { pos: { x: 2, y: 2 }, size: { x: 5, y: 5 } } }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
root.addChild(scoped);
|
|
74
|
+
root.addChild(assetNode);
|
|
75
|
+
|
|
76
|
+
const game = new FakeGame([{ root, scale: { x: 1, y: 1 } }]);
|
|
77
|
+
const squisher = new Squisher({ game });
|
|
78
|
+
|
|
79
|
+
squisher.squish(game.getLayers()); // must not throw
|
|
80
|
+
const frame = squisher.getPlayerFrame(1);
|
|
81
|
+
// Unregistered asset is treated as non-audio, so it's still delivered.
|
|
82
|
+
assert(frame && frame.length >= 1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("player frame includes shared nodes squished before the player's first scoped node", () => {
|
|
86
|
+
// The root background and an early shared sibling precede the scoped
|
|
87
|
+
// node in traversal order. The player's frame must still contain them
|
|
88
|
+
// (seeded on frame creation), in draw order.
|
|
89
|
+
const root = rectNode({ x: 0, y: 0, width: 100, height: 100, fill: COLORS.WHITE });
|
|
90
|
+
const earlyShared = rectNode({ x: 1, y: 1, width: 5, height: 5, fill: COLORS.GREEN });
|
|
91
|
+
const scoped = new GameNode.Shape({
|
|
92
|
+
shapeType: Shapes.POLYGON,
|
|
93
|
+
coordinates2d: ShapeUtils.rectangle(2, 2, 5, 5),
|
|
94
|
+
fill: COLORS.BLUE,
|
|
95
|
+
playerIds: [7]
|
|
96
|
+
});
|
|
97
|
+
const lateShared = rectNode({ x: 3, y: 3, width: 5, height: 5, fill: COLORS.RED });
|
|
98
|
+
|
|
99
|
+
root.addChild(earlyShared);
|
|
100
|
+
root.addChild(scoped);
|
|
101
|
+
root.addChild(lateShared);
|
|
102
|
+
|
|
103
|
+
const game = new FakeGame([{ root, scale: { x: 1, y: 1 } }]);
|
|
104
|
+
const squisher = new Squisher({ game });
|
|
105
|
+
|
|
106
|
+
const frame = squisher.getPlayerFrame(7);
|
|
107
|
+
assert(frame.length === 4);
|
|
108
|
+
const fills = frame.map(squished => unsquish(squished).node.fill);
|
|
109
|
+
verifyArrayEquality(fills[0], COLORS.WHITE);
|
|
110
|
+
verifyArrayEquality(fills[1], COLORS.GREEN);
|
|
111
|
+
verifyArrayEquality(fills[2], COLORS.BLUE);
|
|
112
|
+
verifyArrayEquality(fills[3], COLORS.RED);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("squisher withholds muted audio from a player-scoped subtree", () => {
|
|
116
|
+
const root = new GameNode.Shape({
|
|
117
|
+
shapeType: Shapes.POLYGON,
|
|
118
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
|
|
119
|
+
fill: COLORS.WHITE,
|
|
120
|
+
playerIds: [1]
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const audioNode = new GameNode.Asset({
|
|
124
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 10, 10),
|
|
125
|
+
assetInfo: { 'song': { pos: { x: 0, y: 0 }, size: { x: 1, y: 1 } } }
|
|
126
|
+
});
|
|
127
|
+
root.addChild(audioNode);
|
|
128
|
+
|
|
129
|
+
const game = new FakeGame([{ root, scale: { x: 1, y: 1 } }]);
|
|
130
|
+
const squisher = new Squisher({ game });
|
|
131
|
+
squisher.gameAssets = { song: { info: { type: 'audio' } } };
|
|
132
|
+
|
|
133
|
+
// Sound on (default): the audio node reaches player 1.
|
|
134
|
+
squisher.squish(game.getLayers());
|
|
135
|
+
const withSound = squisher.getPlayerFrame(1).length;
|
|
136
|
+
|
|
137
|
+
// Mute player 1: the audio node is withheld even though it's player-scoped.
|
|
138
|
+
squisher.updatePlayerSettings(1, { SOUND: { enabled: false } });
|
|
139
|
+
squisher.squish(game.getLayers());
|
|
140
|
+
const muted = squisher.getPlayerFrame(1).length;
|
|
141
|
+
|
|
142
|
+
assert(muted === withSound - 1);
|
|
143
|
+
});
|
|
144
|
+
|
|
53
145
|
|