squishjs 1.3.9 → 1.4.1
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 +17 -9
- package/src/squishHelpers/coordinates2d.js +19 -7
- package/src/util/views.js +57 -5
- package/test/Squisher.test.js +62 -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
|
@@ -111,6 +111,18 @@ class Squisher {
|
|
|
111
111
|
return this.initialize();
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// Whether an audio node should be withheld from a player who has muted
|
|
115
|
+
// sound. Non-audio nodes (and unregistered assets) are never withheld.
|
|
116
|
+
// Applied consistently in both the player-scoped and broadcast paths.
|
|
117
|
+
_isAudioMutedFor(node, playerId) {
|
|
118
|
+
if (!node.node.asset) return false;
|
|
119
|
+
const assetKey = Object.keys(node.node.asset)[0];
|
|
120
|
+
const assetInfo = this.gameAssets && this.gameAssets[assetKey] && this.gameAssets[assetKey].info;
|
|
121
|
+
if (!assetInfo || assetInfo.type !== 'audio') return false;
|
|
122
|
+
const sound = this.playerSettings[playerId] && this.playerSettings[playerId].SOUND;
|
|
123
|
+
return !!(sound && sound.enabled === false);
|
|
124
|
+
}
|
|
125
|
+
|
|
114
126
|
squishHelper(node, squishedNodes, scale = {x: 1, y: 1}, playerMap = {}, playerIdFilter = new Set()) {
|
|
115
127
|
if (!node.node.listeners.has(this)) {
|
|
116
128
|
node.addListener(this);
|
|
@@ -128,10 +140,12 @@ class Squisher {
|
|
|
128
140
|
for (let playerId of playerIdFilter) {
|
|
129
141
|
if (!playerMap[playerId]) {
|
|
130
142
|
playerMap[Number(playerId)] = [];
|
|
131
|
-
}
|
|
143
|
+
}
|
|
132
144
|
|
|
133
145
|
if (node.node.playerIds.length === 0 || node.node.playerIds.findIndex(i => Number(i) === Number(playerId)) >= 0) {
|
|
134
|
-
|
|
146
|
+
if (!this._isAudioMutedFor(node, playerId)) {
|
|
147
|
+
playerMap[playerId].push(squished);
|
|
148
|
+
}
|
|
135
149
|
} else {
|
|
136
150
|
playerIdsToRemove.add(playerId);
|
|
137
151
|
}
|
|
@@ -144,13 +158,7 @@ class Squisher {
|
|
|
144
158
|
if (!playerMap[playerId]) {
|
|
145
159
|
playerMap[Number(playerId)] = [];
|
|
146
160
|
}
|
|
147
|
-
if (
|
|
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 {
|
|
161
|
+
if (!this._isAudioMutedFor(node, playerId)) {
|
|
154
162
|
playerMap[playerId].push(squished);
|
|
155
163
|
}
|
|
156
164
|
})
|
|
@@ -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
|
@@ -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,10 +74,33 @@ 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) {
|
|
76
|
-
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
|
+
});
|
|
77
104
|
|
|
78
105
|
if (translatedCoords && translatedCoords.length) {
|
|
79
106
|
if (copied.node.text) {
|
|
@@ -89,10 +116,35 @@ const getView = (plane, view, playerIds, translation = {}, scale = {}) => {
|
|
|
89
116
|
const thirdPoint = copied.node.coordinates2d[2];
|
|
90
117
|
const width = secondPoint[0] - firstPoint[0];
|
|
91
118
|
const height = thirdPoint[1] - secondPoint[1];
|
|
92
|
-
|
|
93
|
-
Object.values(copied.node.asset)[0]
|
|
94
|
-
|
|
95
|
-
|
|
119
|
+
|
|
120
|
+
const assetObj = Object.values(copied.node.asset)[0];
|
|
121
|
+
|
|
122
|
+
// pos/size = the clamped, on-screen visible box.
|
|
123
|
+
assetObj.pos.x = firstPoint[0];
|
|
124
|
+
assetObj.pos.y = firstPoint[1];
|
|
125
|
+
assetObj.size.x = width;
|
|
126
|
+
assetObj.size.y = height;
|
|
127
|
+
|
|
128
|
+
// Crop the source image to the portion clipped by the
|
|
129
|
+
// viewport. Without this the renderer squashes the whole
|
|
130
|
+
// image into the (smaller) visible box instead of cutting
|
|
131
|
+
// off the off-screen part. Using the unclamped corners we
|
|
132
|
+
// measure how much of the image fell outside [0,100] on
|
|
133
|
+
// each edge, as a percentage of the image's full span.
|
|
134
|
+
const rawFirst = rawTranslatedCoords[0]; // top-left
|
|
135
|
+
const rawThird = rawTranslatedCoords[2]; // bottom-right
|
|
136
|
+
const fullWidth = rawThird[0] - rawFirst[0];
|
|
137
|
+
const fullHeight = rawThird[1] - rawFirst[1];
|
|
138
|
+
|
|
139
|
+
if (fullWidth > 0) {
|
|
140
|
+
assetObj.cropLeft = Math.max(0, (0 - rawFirst[0]) / fullWidth) * 100;
|
|
141
|
+
assetObj.cropRight = Math.max(0, (rawThird[0] - 100) / fullWidth) * 100;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (fullHeight > 0) {
|
|
145
|
+
assetObj.cropTop = Math.max(0, (0 - rawFirst[1]) / fullHeight) * 100;
|
|
146
|
+
assetObj.cropBottom = Math.max(0, (rawThird[1] - 100) / fullHeight) * 100;
|
|
147
|
+
}
|
|
96
148
|
}
|
|
97
149
|
}
|
|
98
150
|
copied.node.playerIds = playerIds || [];
|
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,63 @@ 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("squisher withholds muted audio from a player-scoped subtree", () => {
|
|
86
|
+
const root = new GameNode.Shape({
|
|
87
|
+
shapeType: Shapes.POLYGON,
|
|
88
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
|
|
89
|
+
fill: COLORS.WHITE,
|
|
90
|
+
playerIds: [1]
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const audioNode = new GameNode.Asset({
|
|
94
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 10, 10),
|
|
95
|
+
assetInfo: { 'song': { pos: { x: 0, y: 0 }, size: { x: 1, y: 1 } } }
|
|
96
|
+
});
|
|
97
|
+
root.addChild(audioNode);
|
|
98
|
+
|
|
99
|
+
const game = new FakeGame([{ root, scale: { x: 1, y: 1 } }]);
|
|
100
|
+
const squisher = new Squisher({ game });
|
|
101
|
+
squisher.gameAssets = { song: { info: { type: 'audio' } } };
|
|
102
|
+
|
|
103
|
+
// Sound on (default): the audio node reaches player 1.
|
|
104
|
+
squisher.squish(game.getLayers());
|
|
105
|
+
const withSound = squisher.getPlayerFrame(1).length;
|
|
106
|
+
|
|
107
|
+
// Mute player 1: the audio node is withheld even though it's player-scoped.
|
|
108
|
+
squisher.updatePlayerSettings(1, { SOUND: { enabled: false } });
|
|
109
|
+
squisher.squish(game.getLayers());
|
|
110
|
+
const muted = squisher.getPlayerFrame(1).length;
|
|
111
|
+
|
|
112
|
+
assert(muted === withSound - 1);
|
|
113
|
+
});
|
|
114
|
+
|
|
53
115
|
|