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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Read(//Users/josephgarcia/homegames/homegames-client/**)",
5
+ "Read(//Users/josephgarcia/homegames/homegames-web/**)"
6
+ ]
7
+ }
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "squishjs",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "squish & unsquish stuff",
5
5
  "scripts": {
6
6
  "test": "node testRunner.js"
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 > 0) {
285
- this.state = this.squish(this.game.getLayers());
286
- this.broadcast();
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(10 + assetKey.length);
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[10 + i] = assetKey.codePointAt(i);
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 assetKey = String.fromCodePoint.apply(null, squished.slice(10));
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
- Object.values(copied.node.asset)[0].pos.x = firstPoint[0];
93
- Object.values(copied.node.asset)[0].pos.y = firstPoint[1];
94
- Object.values(copied.node.asset)[0].size.x = width;
95
- Object.values(copied.node.asset)[0].size.y = height;
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 || [];
@@ -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);
@@ -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 === 2);
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);