homegames-common 1.5.2 → 1.5.4
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/docker-helper.js +2 -1
- package/docs/ARCHITECTURE.md +173 -0
- package/docs/DATA-MODEL.md +115 -0
- package/docs/FLOWS.md +125 -0
- package/docs/INFRA.md +111 -0
- package/docs/OPERATIONS.md +121 -0
- package/docs/SYSTEM.md +118 -0
- package/docs/squishjs-game-authoring.md +895 -0
- package/game-loader.js +2 -0
- package/game-session-manager.js +106 -5
- package/game-session.js +26 -1
- package/index.js +18 -1
- package/package.json +1 -1
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
# SquishJS Game Authoring — Technical Reference for Code Generation
|
|
2
|
+
|
|
3
|
+
> Context document for an LLM that writes Homegames games. Everything here is verified against `squishjs@1.3.8` (the `squish-138` alias) and real published games. If you generate a game, follow this contract exactly. Code that violates the **Hard Constraints** section will be auto-rejected by the publishing pipeline before a human ever sees it.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. What a Homegames game is
|
|
8
|
+
|
|
9
|
+
A Homegames game is a **single JavaScript class** that extends `Game` from a versioned SquishJS package. The server (`homegames-core`) instantiates the class, repeatedly serializes ("squishes") its scene graph into a compact binary form, and streams it to every connected browser client over WebSocket. Clients render the scene and send input (clicks, key presses) back to the server, which calls methods on your game instance.
|
|
10
|
+
|
|
11
|
+
Key mental model:
|
|
12
|
+
|
|
13
|
+
- **You never render or draw.** You build and mutate a tree of nodes (`Shape`, `Text`, `Asset`). The client draws them.
|
|
14
|
+
- **You never write networking.** The server multiplexes all players into one shared game instance. Player input arrives as method calls.
|
|
15
|
+
- **The coordinate plane is `0–100` on both axes**, regardless of screen size or aspect ratio. `(0,0)` is top-left, `(100,100)` is bottom-right. Think percentages.
|
|
16
|
+
- **State changes are not automatic.** After you mutate a node, you must signal it (see §4). This is the #1 mistake — read §4 carefully.
|
|
17
|
+
- **The game is shared, not per-player.** One instance serves all players. Per-player visuals are done with `playerIds` (see §8), not separate instances.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. Hard Constraints (your code is validated automatically)
|
|
22
|
+
|
|
23
|
+
The publish pipeline runs an AST scan, then loads and runs your game in a Docker sandbox for ~5 seconds. To pass:
|
|
24
|
+
|
|
25
|
+
1. **Entry point is `index.js`** and it must `module.exports = YourGameClass;` (a class, not an instance).
|
|
26
|
+
2. **`require` only the SquishJS package and your own local files.** Use `require('squish-138')`. Do **not** require Node built-ins (`fs`, `http`, `https`, `net`, `child_process`, `os`, `path`, `crypto`, `cluster`, `dgram`, etc.). Do not make network requests, touch the filesystem, spawn processes, or read `process.env`.
|
|
27
|
+
3. **No dynamic code execution:** no `eval`, no `new Function(...)`, no `require(variable)`.
|
|
28
|
+
4. **`static metadata()` is required** and must return an object whose `squishVersion` matches the package you imported (`'138'` for `squish-138`).
|
|
29
|
+
5. **It must not throw** during `require`, construction, or the first few seconds of ticking. A crash = rejected.
|
|
30
|
+
6. **Size limits:** total game ≤ 20 MB, any single file ≤ 5 MB. Keep assets external (referenced by id), not inlined.
|
|
31
|
+
7. **License:** published games are GPLv3. A `LICENSE` file is required at publish time (not your concern when generating the game code itself, but don't add a conflicting license header).
|
|
32
|
+
|
|
33
|
+
Default to **`squish-138` / `squishVersion: '138'`** for all new games. (Older games pin other versions like `1006`, `0767`, `136`; the version in the `require` and in `metadata()` must always match.) One exception: if the game needs **image cropping / spritesheets**, target **`squish-140`** instead (§7.3.1) — that feature does not exist on `138`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 3. The Game class contract
|
|
38
|
+
|
|
39
|
+
Extend `Game` and implement these. Only `metadata()`, the constructor, and `getLayers()` are mandatory; the rest are optional hooks the server calls when present.
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const { Game, GameNode, Colors, Shapes, ShapeUtils } = require('squish-138');
|
|
43
|
+
const { COLORS } = Colors;
|
|
44
|
+
|
|
45
|
+
class MyGame extends Game {
|
|
46
|
+
// REQUIRED. Static. Describes the game. squishVersion must match the require above.
|
|
47
|
+
static metadata() { return { squishVersion: '138', name: 'My Game' /* ... */ }; }
|
|
48
|
+
|
|
49
|
+
// REQUIRED. Build your initial scene graph. ALWAYS call super() first.
|
|
50
|
+
constructor() {
|
|
51
|
+
super();
|
|
52
|
+
// build this.base and children here
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// REQUIRED. Return the layer list. Almost always a single root.
|
|
56
|
+
getLayers() {
|
|
57
|
+
return [{ root: this.base }];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// OPTIONAL HOOKS (implement the ones you need):
|
|
61
|
+
|
|
62
|
+
// A player joined. info.name is their display name.
|
|
63
|
+
handleNewPlayer({ playerId, info, settings, clientInfo }) {}
|
|
64
|
+
|
|
65
|
+
// A player left. Clean up their nodes/state.
|
|
66
|
+
handlePlayerDisconnect(playerId) {}
|
|
67
|
+
|
|
68
|
+
// Keyboard input. key is like 'ArrowUp', 'w', 'a', ' ', 'Enter', etc.
|
|
69
|
+
handleKeyDown(playerId, key) {}
|
|
70
|
+
handleKeyUp(playerId, key) {}
|
|
71
|
+
|
|
72
|
+
// Called every frame if metadata().tickRate is set. Use for game loops/physics.
|
|
73
|
+
tick() {}
|
|
74
|
+
|
|
75
|
+
// Gatekeeper for joins. Return false to refuse the player (e.g. game full).
|
|
76
|
+
canAddPlayer() { return true; }
|
|
77
|
+
|
|
78
|
+
// Called when the session ends. Base Game.close() clears tracked timers (see §10).
|
|
79
|
+
// Override to also remove nodes, but you usually don't need to.
|
|
80
|
+
close() { super.close?.(); }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = MyGame;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Method signatures are exact and important:**
|
|
87
|
+
- `handleNewPlayer` receives a **single object** `{ playerId, info, settings, clientInfo }` — destructure it. `playerId` is a number. `info.name` is the player's name.
|
|
88
|
+
- `handlePlayerDisconnect(playerId)` receives the **bare id**, not an object.
|
|
89
|
+
- `handleKeyDown(playerId, key)` / `handleKeyUp(playerId, key)` — two positional args.
|
|
90
|
+
- A node's `onClick` receives `(playerId, x, y)` — see §9.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 4. The single most important rule: state changes need `onStateChange()`
|
|
95
|
+
|
|
96
|
+
Node properties (`coordinates2d`, `fill`, `color`, `text`, `playerIds`, ...) are **plain fields with no setters**. Mutating them updates your data but does **not** push anything to clients. You must notify after mutating:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
// Move a node and recolor it:
|
|
100
|
+
this.player.node.coordinates2d = ShapeUtils.rectangle(newX, newY, 5, 5);
|
|
101
|
+
this.player.node.fill = COLORS.RED;
|
|
102
|
+
this.base.node.onStateChange(); // <-- REQUIRED. Without this, nothing updates on screen.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Rules of thumb:
|
|
106
|
+
- After a batch of direct property mutations, call `onStateChange()` **once** on your root node (`this.base.node.onStateChange()`).
|
|
107
|
+
- The convenience methods that change tree structure already notify for you: `addChild`, `addChildren`, `removeChild`, `clearChildren`, and `BaseNode.update(...)`. You do **not** need an extra `onStateChange()` after those.
|
|
108
|
+
- In a `tick()` loop, do all your mutations, then one `onStateChange()` at the end.
|
|
109
|
+
|
|
110
|
+
Two equivalent ways to change a node:
|
|
111
|
+
|
|
112
|
+
```js
|
|
113
|
+
// (a) BaseNode.update() — sets fill and/or coordinates2d AND notifies:
|
|
114
|
+
this.box.update({ fill: COLORS.GREEN, coordinates2d: ShapeUtils.rectangle(10, 10, 20, 20) });
|
|
115
|
+
|
|
116
|
+
// (b) Direct field mutation + explicit notify:
|
|
117
|
+
this.box.node.fill = COLORS.GREEN;
|
|
118
|
+
this.base.node.onStateChange();
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
To change text, reassign the whole `text` object (then notify):
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
this.scoreText.node.text = { text: `Score: ${this.score}`, x: 50, y: 10, size: 3, align: 'center', color: COLORS.WHITE };
|
|
125
|
+
this.base.node.onStateChange();
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 5. `metadata()` reference
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
static metadata() {
|
|
134
|
+
return {
|
|
135
|
+
squishVersion: '138', // REQUIRED. Must match require('squish-138').
|
|
136
|
+
name: 'Hot Potato', // Display name.
|
|
137
|
+
author: 'Your Name', // Creator.
|
|
138
|
+
description: 'One-line pitch shown in the catalog.',
|
|
139
|
+
aspectRatio: { x: 16, y: 9 }, // Display aspect ratio. Common: {16,9}, {4,3}, {1,1}.
|
|
140
|
+
thumbnail: 'asset-id-hash', // Optional asset id used as the catalog thumbnail.
|
|
141
|
+
tickRate: 60, // Frames/sec for tick(). Omit if you have no game loop.
|
|
142
|
+
assets: { // Optional. Images/audio/fonts (see §11).
|
|
143
|
+
'potato': new Asset({ id: '48685183f94c7a3c14f315444c6460bd', type: 'image' })
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- The plane is always `0–100`; `aspectRatio` only controls how that square is presented (letterboxing on the client). Build your layout in `0–100` space and pick an aspect ratio that suits it.
|
|
150
|
+
- **Aspect-ratio distortion gotcha:** because the `0–100` square is stretched to fill the aspect rectangle, an x-unit and a y-unit are **not** the same size on screen unless the ratio is `{1,1}`. At `{16,9}` everything is wider than tall — a "square" looks like a rectangle, a `polyCircle` looks like an ellipse, and a 45° heading doesn't look like 45°. For UI/party games this is fine. For **rotation- or distance-based geometry** (twin-stick shooters, anything with circles, orbits, or true angles), prefer **`aspectRatio: { x: 1, y: 1 }`** so the math matches the pixels, or compensate for the ratio in your trig.
|
|
151
|
+
- `tickRate` is frames per second. `60` for action games, `10–30` for casual, omit entirely for purely event-driven games (click/turn-based) that update only in handlers.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 6. Coordinates and colors
|
|
156
|
+
|
|
157
|
+
**Coordinates** are `[x, y]` pairs in `0–100` space. A shape's `coordinates2d` is an array of vertices. Build rectangles and triangles with `ShapeUtils`:
|
|
158
|
+
|
|
159
|
+
```js
|
|
160
|
+
ShapeUtils.rectangle(x, y, width, height)
|
|
161
|
+
// returns [[x,y],[x+w,y],[x+w,y+h],[x,y+h],[x,y]] (top-left origin, closed loop)
|
|
162
|
+
|
|
163
|
+
ShapeUtils.triangle(x1, y1, x2, y2, x3, y3)
|
|
164
|
+
// returns [[x1,y1],[x2,y2],[x3,y3],[x1,y1]]
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
You can also pass a raw vertex array for arbitrary polygons: `coordinates2d: [[10,10],[90,10],[50,90],[10,10]]`.
|
|
168
|
+
|
|
169
|
+
**Reading a rectangle's position/size back** (common in physics/collision code):
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
const x = node.node.coordinates2d[0][0];
|
|
173
|
+
const y = node.node.coordinates2d[0][1];
|
|
174
|
+
const w = node.node.coordinates2d[1][0] - x;
|
|
175
|
+
const h = node.node.coordinates2d[2][1] - y;
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
> **Precision gotcha:** coordinates are serialized as an integer + a 2-decimal fraction, so the on-screen resolution is **~0.01 units** in `0–100` space. Movement smaller than that per frame rounds away — a `tick()` that adds `0.005` to a position will visually stutter or not move at all. Keep per-frame deltas ≥ ~0.05, or accumulate sub-unit motion in a plain variable and only write the rounded value into `coordinates2d`.
|
|
179
|
+
|
|
180
|
+
**Colors** are `[r, g, b, a]` arrays, each `0–255`. `a` (alpha) of `0` is fully transparent, `255` fully opaque.
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
const { COLORS } = Colors;
|
|
184
|
+
COLORS.RED; // [255, 0, 0, 255]
|
|
185
|
+
COLORS.HG_BLUE; // [148, 210, 230, 255] (Homegames brand blue)
|
|
186
|
+
[0, 0, 0, 0]; // transparent (useful for invisible click targets / hit boxes)
|
|
187
|
+
Colors.randomColor(); // a random named color
|
|
188
|
+
Colors.randomColor(['BLACK', 'WHITE', 'ALMOST_BLACK']); // random, excluding by NAME
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
> **`randomColor` excludes by color NAME (string), not by value.** The exclusion list is matched against palette key names like `'BLACK'`/`'HG_BLUE'`, **not** color arrays — `Colors.randomColor([COLORS.BLACK])` silently excludes nothing (you passed an array, not the name). To keep, say, ships off a dark background, pass the names: `Colors.randomColor(['BLACK','ALMOST_BLACK','HG_BLACK','CHARCOAL'])`.
|
|
192
|
+
|
|
193
|
+
There is a large named palette (e.g. `BLACK, WHITE, RED, GREEN, BLUE, GOLD, EMERALD, CORAL, TEAL, PURPLE, HG_BLUE, HG_RED, HG_YELLOW, CANDY_RED, CANDY_PINK, SKY_BLUE, ...`). When unsure, use a named color or an explicit `[r,g,b,a]` array.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 7. Node types
|
|
198
|
+
|
|
199
|
+
There are exactly three, all created via `GameNode`. All accept an optional numeric `id`, `playerIds`, and (for visible nodes) `onClick`, `onHover`, `offHover`.
|
|
200
|
+
|
|
201
|
+
### 7.1 `GameNode.Shape` — polygons, rectangles, lines
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
const box = new GameNode.Shape({
|
|
205
|
+
shapeType: Shapes.POLYGON, // POLYGON | LINE (do NOT use CIRCLE)
|
|
206
|
+
coordinates2d: ShapeUtils.rectangle(10, 10, 30, 20),
|
|
207
|
+
fill: COLORS.CORAL, // interior color (RGBA). PREFER fill.
|
|
208
|
+
color: [0, 0, 0, 255], // STROKE color — required if you set border
|
|
209
|
+
border: 6, // optional outline WIDTH (a number, see below)
|
|
210
|
+
onClick: (playerId, x, y) => { /* ... */ }, // optional; makes it interactive
|
|
211
|
+
playerIds: [0] // [0] = everyone (default). See §8.
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- `shapeType` comes from `Shapes`: `Shapes.POLYGON` (the workhorse) or `Shapes.LINE`.
|
|
216
|
+
- **`Shapes.CIRCLE` exists as a constant but is NOT rendered — do not use it.** Approximate a circle with a many-sided polygon (see the helper below).
|
|
217
|
+
- **Use `fill` for the shape's interior color.** `color` is the **stroke** color, used together with `border`.
|
|
218
|
+
- **`border` is a single NUMBER, not an object.** It's an outline width on a `0–255` scale (the client renders it as `(border/255) * 0.1 * canvasWidth`, so small numbers like `2`–`10` are thin lines). The stroke is drawn in the node's **`color`** field — so **if you set `border` you must also set `color`**, or rendering the stroke will fail. There is **no `border.color`/`border.width` object** — `border: { color, width }` squishes to garbage. Outlined shape = `fill` (interior) + `color` (stroke) + `border` (numeric width).
|
|
219
|
+
- Rectangles and arbitrary polygons via `POLYGON` cover ~95% of needs. `coordinates2d` is just a vertex list, so you can build **any polygon and rotate it with plain trig** — e.g. a ship triangle that points along an angle, or a round shape:
|
|
220
|
+
|
|
221
|
+
```js
|
|
222
|
+
// A round-ish polygon (since CIRCLE doesn't render). sides ~16 looks smooth.
|
|
223
|
+
const polyCircle = (cx, cy, r, sides = 16) => {
|
|
224
|
+
const pts = [];
|
|
225
|
+
for (let i = 0; i <= sides; i++) {
|
|
226
|
+
const a = (i / sides) * Math.PI * 2;
|
|
227
|
+
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
|
228
|
+
}
|
|
229
|
+
return pts; // closed loop (last point == first)
|
|
230
|
+
};
|
|
231
|
+
// A triangle rotated to face `angle` (radians), centered at (x,y):
|
|
232
|
+
const facing = (x, y, angle, size) => ShapeUtils.triangle(
|
|
233
|
+
x + Math.cos(angle) * size, y + Math.sin(angle) * size,
|
|
234
|
+
x + Math.cos(angle + 2.6) * size, y + Math.sin(angle + 2.6) * size,
|
|
235
|
+
x + Math.cos(angle - 2.6) * size, y + Math.sin(angle - 2.6) * size,
|
|
236
|
+
);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- Transparent fill `[0,0,0,0]` + an `onClick` makes an invisible hit-box / button overlay. It's also the clean way to **temporarily hide a node without removing it** (e.g. a dead/respawning entity): set `fill` to `[0,0,0,0]` and restore it later. Don't try to hide via `playerIds: []` — `[0]` means everyone and an empty array is not a reliable "hide from all".
|
|
240
|
+
- A `Shape` can also carry an `input` field (to act as an on-screen text box) and `onHover`/`offHover` callbacks — see §9.
|
|
241
|
+
|
|
242
|
+
### 7.2 `GameNode.Text`
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
const label = new GameNode.Text({
|
|
246
|
+
textInfo: {
|
|
247
|
+
text: 'Hello',
|
|
248
|
+
x: 50, y: 20, // anchor position in 0–100 space
|
|
249
|
+
size: 2, // relative font size (1 ≈ small, 3 ≈ large heading)
|
|
250
|
+
align: 'center', // 'left' | 'center' | 'right'
|
|
251
|
+
color: COLORS.WHITE,
|
|
252
|
+
font: 'default' // optional
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Note the field is `textInfo` in the constructor, but it is stored on the node as `node.text` (so you update it via `label.node.text = {...}`, see §4).
|
|
258
|
+
|
|
259
|
+
> **Text nodes are NOT clickable.** Unlike `Shape` and `Asset`, the `Text` constructor only accepts `{ textInfo, playerIds, input, node, id }` — there is **no `onClick`, `onHover`, or `offHover`**. Passing an `onClick` to a `Text` node does nothing; it is silently dropped and the text will never respond to taps. **To make a clickable text label / "button", put the click handler on a `Shape` and render the `Text` on top of it** — see §7.2.1.
|
|
260
|
+
|
|
261
|
+
#### 7.2.1 Text is not a button — build buttons from a Shape + Text
|
|
262
|
+
|
|
263
|
+
There is no button node and text cannot receive clicks. A "button" is just a **clickable `Shape` polygon with a `Text` node positioned on top of it**. The `Shape` carries the `onClick` and the visible background; the `Text` carries the label and must be drawn *after* (or as a sibling added after) the shape so it sits on top. Size the shape — not the text — to define the tap target, and center the text over it.
|
|
264
|
+
|
|
265
|
+
```js
|
|
266
|
+
// Reusable helper: a rectangular button at (x,y) sized (w,h) with a centered label.
|
|
267
|
+
makeButton({ x, y, w, h, label, fill, onClick }) {
|
|
268
|
+
const bg = new GameNode.Shape({
|
|
269
|
+
shapeType: Shapes.POLYGON,
|
|
270
|
+
coordinates2d: ShapeUtils.rectangle(x, y, w, h),
|
|
271
|
+
fill,
|
|
272
|
+
onClick // the SHAPE is what's clickable
|
|
273
|
+
});
|
|
274
|
+
const text = new GameNode.Text({
|
|
275
|
+
textInfo: {
|
|
276
|
+
text: label,
|
|
277
|
+
x: x + w / 2, // horizontal center of the shape
|
|
278
|
+
y: y + h / 2 - 1.5, // nudge up so the text baseline looks vertically centered
|
|
279
|
+
size: 2,
|
|
280
|
+
align: 'center', // pairs with x being the center
|
|
281
|
+
color: COLORS.WHITE
|
|
282
|
+
}
|
|
283
|
+
// NO onClick here — it would be ignored. The bg shape handles the tap.
|
|
284
|
+
});
|
|
285
|
+
bg.addChild(text); // text rides along as a child of the button background
|
|
286
|
+
return bg;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// usage:
|
|
290
|
+
const startBtn = this.makeButton({
|
|
291
|
+
x: 35, y: 45, w: 30, h: 12, label: 'Start', fill: COLORS.GREEN,
|
|
292
|
+
onClick: (playerId, x, y) => this.startGame(playerId)
|
|
293
|
+
});
|
|
294
|
+
this.base.addChild(startBtn);
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
Notes:
|
|
298
|
+
- The text's `x`/`y` are independent plane coordinates, **not** relative to the parent shape — compute them from the shape's position (`x + w/2`, etc.). Adding the text as a child of the shape only affects render order and tree cleanup, not positioning.
|
|
299
|
+
- Because the text is a child of the button shape, removing the button (`removeChild(bg.id)`) removes the label with it.
|
|
300
|
+
- The whole shape is the hit target, so the player can tap anywhere on the button — including directly on the letters — and the shape's `onClick` fires. The text being non-interactive doesn't create a "dead zone".
|
|
301
|
+
- For an invisible text-over-image button, use the same pattern with a transparent fill `[0,0,0,0]` or an `Asset` node as the background.
|
|
302
|
+
|
|
303
|
+
### 7.3 `GameNode.Asset` — images and audio
|
|
304
|
+
|
|
305
|
+
```js
|
|
306
|
+
const sprite = new GameNode.Asset({
|
|
307
|
+
coordinates2d: ShapeUtils.rectangle(25, 25, 50, 50), // clickable bounds of the node
|
|
308
|
+
assetInfo: {
|
|
309
|
+
'potato': { // KEY must match a key in metadata().assets
|
|
310
|
+
pos: { x: 30, y: 35 }, // where the image's top-left sits, 0–100 space
|
|
311
|
+
size: { x: 40, y: 30 } // image width/height, 0–100 space
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
playerIds: [0]
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Audio is also an `Asset` node — give it zero size and a `startTime` (seconds into the clip):
|
|
319
|
+
|
|
320
|
+
```js
|
|
321
|
+
const sound = new GameNode.Asset({
|
|
322
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 0, 0),
|
|
323
|
+
assetInfo: {
|
|
324
|
+
'hiss': { pos: { x: 0, y: 0 }, size: { x: 0, y: 0 }, startTime: 0 }
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
// Add it to the tree to play; remove it to stop:
|
|
328
|
+
this.base.addChild(sound);
|
|
329
|
+
this.setTimeout(() => this.base.removeChild(sound.id), 250);
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The `assetInfo` key (`'potato'`, `'hiss'`) is a **reference to `metadata().assets`** — you do not embed image/audio bytes in the game; you reference them by the asset id declared in metadata.
|
|
333
|
+
|
|
334
|
+
### 7.3.1 Cropping an image / spritesheets (`squish-140`+)
|
|
335
|
+
|
|
336
|
+
An image `Asset` can show just a **rectangular sub-region** of its source image instead of the whole thing, via four optional fields inside `assetInfo[key]`:
|
|
337
|
+
|
|
338
|
+
```js
|
|
339
|
+
const tile = new GameNode.Asset({
|
|
340
|
+
coordinates2d: ShapeUtils.rectangle(10, 10, 10, 10),
|
|
341
|
+
assetInfo: {
|
|
342
|
+
'sheet': {
|
|
343
|
+
pos: { x: 10, y: 10 },
|
|
344
|
+
size: { x: 10, y: 10 },
|
|
345
|
+
// Crop: percentage (0–100) of the SOURCE image to cut off EACH edge.
|
|
346
|
+
cropLeft: 0, cropTop: 0, cropRight: 50, cropBottom: 50 // show the top-left quarter
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
- The crop fields are an **inset per edge, in percent of the source image** — `cropLeft: 25` drops the left 25% of the image, `cropRight: 25` drops the right 25%, etc. All default to `0` (whole image).
|
|
353
|
+
- The surviving sub-region is then **stretched to fill `pos`/`size`** (no automatic aspect-ratio preservation) — so pick a `size` whose aspect matches the cropped region if you don't want distortion.
|
|
354
|
+
- `cropLeft + cropRight` (and `cropTop + cropBottom`) must be `< 100`; an inset that collapses the region is ignored and the full image is drawn.
|
|
355
|
+
|
|
356
|
+
This unlocks **spritesheets and tilemaps from a single asset** — pack many frames/tiles into one image and crop to the one you want. To select frame `(col, row)` from a `cols × rows` grid:
|
|
357
|
+
|
|
358
|
+
```js
|
|
359
|
+
const frameCrop = (col, row, cols, rows) => ({
|
|
360
|
+
cropLeft: (col / cols) * 100,
|
|
361
|
+
cropRight: ((cols - 1 - col) / cols) * 100,
|
|
362
|
+
cropTop: (row / rows) * 100,
|
|
363
|
+
cropBottom: ((rows - 1 - row) / rows) * 100,
|
|
364
|
+
});
|
|
365
|
+
// animate by reassigning assetInfo each tick:
|
|
366
|
+
this.sprite.node.asset = { 'sheet': { pos, size, ...frameCrop(this.frame, this.facing, 8, 4) } };
|
|
367
|
+
this.base.node.onStateChange();
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
> **Version gate:** cropping requires **`squish-140` or newer** (`require('squish-140')` + `squishVersion: '140'`). On `138`/`139` the crop fields are silently ignored — the image renders whole, no error — so a game must be on `140`+ for cropping to take effect.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
374
|
+
## 8. Player visibility model (`playerIds`)
|
|
375
|
+
|
|
376
|
+
Every node has `playerIds`, an array controlling who sees it:
|
|
377
|
+
|
|
378
|
+
- `[0]` (the default) → **visible to all players.**
|
|
379
|
+
- `[42]` → visible only to player `42`.
|
|
380
|
+
- `[42, 99]` → visible to players `42` and `99`.
|
|
381
|
+
|
|
382
|
+
This is how you build per-player UI (private hands, individual HUDs, "your turn" prompts) in a single shared game. Helpers on every node:
|
|
383
|
+
|
|
384
|
+
```js
|
|
385
|
+
node.showFor(playerId); // add a player to the visibility set (and drop the "everyone" 0)
|
|
386
|
+
node.hideFor(playerId); // remove a player; if none left, reverts to [0] (everyone)
|
|
387
|
+
// or set directly, then notify:
|
|
388
|
+
node.node.playerIds = [playerId];
|
|
389
|
+
this.base.node.onStateChange();
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Example — give each player their own colored marker only they can see:
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
handleNewPlayer({ playerId }) {
|
|
396
|
+
const marker = new GameNode.Shape({
|
|
397
|
+
shapeType: Shapes.POLYGON,
|
|
398
|
+
coordinates2d: ShapeUtils.rectangle(45, 90, 10, 5),
|
|
399
|
+
fill: Colors.randomColor(),
|
|
400
|
+
playerIds: [playerId] // only this player sees it
|
|
401
|
+
});
|
|
402
|
+
this.players[playerId] = marker;
|
|
403
|
+
this.base.addChild(marker);
|
|
404
|
+
}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## 9. Input
|
|
410
|
+
|
|
411
|
+
### Clicks / taps
|
|
412
|
+
Attach `onClick` to a **`Shape` or `Asset`** node (the only node types that accept it — **`Text` does not**, see §7.2). Signature: **`(playerId, x, y)`** — the clicking player's id, and the click position in `0–100` plane space. To make a clickable label, put the `onClick` on a `Shape` and lay a `Text` node on top of it (§7.2.1).
|
|
413
|
+
|
|
414
|
+
```js
|
|
415
|
+
const button = new GameNode.Shape({
|
|
416
|
+
shapeType: Shapes.POLYGON,
|
|
417
|
+
coordinates2d: ShapeUtils.rectangle(40, 40, 20, 20),
|
|
418
|
+
fill: COLORS.GREEN,
|
|
419
|
+
onClick: (playerId, x, y) => {
|
|
420
|
+
// Only the owner may press their button:
|
|
421
|
+
if (Number(playerId) === Number(this.ownerId)) this.doThing();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Clicks and taps are unified — `onClick` fires for both mouse and touch. Make tap targets generously sized for mobile.
|
|
427
|
+
|
|
428
|
+
### Keyboard
|
|
429
|
+
Implement `handleKeyDown(playerId, key)` / `handleKeyUp(playerId, key)`. `key` is the standard browser key string: `'ArrowUp'`, `'ArrowDown'`, `'ArrowLeft'`, `'ArrowRight'`, `'w'`, `'a'`, `'s'`, `'d'`, `' '` (space), `'Enter'`, single characters, etc. Support both arrows and WASD for movement. Debounce with per-player cooldowns if needed (see the movement example in §14).
|
|
430
|
+
|
|
431
|
+
> Mobile players have no physical keyboard. If your game needs keyboard control, also provide on-screen `onClick` buttons, or design click/tap-first.
|
|
432
|
+
|
|
433
|
+
### Text input (on-screen fields)
|
|
434
|
+
|
|
435
|
+
Besides clicks and keyboard there is a third input path: a node can present an **editable text field** via an `input` property. It is accepted on **`Shape` and `Text`** nodes (not `Asset`). The client renders an input box over the node's bounds; as the player edits it, the server calls your `oninput(playerId, value)` with the field's current contents:
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
const searchBox = new GameNode.Shape({
|
|
439
|
+
shapeType: Shapes.POLYGON,
|
|
440
|
+
coordinates2d: ShapeUtils.rectangle(20, 5, 60, 10),
|
|
441
|
+
fill: COLORS.WHITE,
|
|
442
|
+
input: {
|
|
443
|
+
type: 'text',
|
|
444
|
+
oninput: (playerId, value) => {
|
|
445
|
+
this.query = value; // `value` is the full current text, not one keystroke
|
|
446
|
+
this.runSearch(playerId); // mutate state, then onStateChange()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
- `oninput` fires on change; treat `value` as the field's entire current string.
|
|
453
|
+
- This is exactly the mechanism the Homegames dashboard's own search box uses, so it is the same well-trodden path the platform relies on.
|
|
454
|
+
- Scope the field with `playerIds` (§8) so only the intended player sees and edits it — `input` is per node, but visibility still follows `playerIds`.
|
|
455
|
+
- There is also `type: 'file'` for uploads; its `oninput(playerId, files)` receives an **array** of files instead of a string. Rarely needed in games.
|
|
456
|
+
|
|
457
|
+
### Hover
|
|
458
|
+
|
|
459
|
+
`Shape` and `Asset` nodes also accept `onHover(playerId)` and `offHover(playerId)` (pointer enter / leave). Use them only for cosmetic affordances — **touch devices have no hover**, so never gate a mechanic on it.
|
|
460
|
+
|
|
461
|
+
---
|
|
462
|
+
|
|
463
|
+
## 10. Timing and game loop
|
|
464
|
+
|
|
465
|
+
- Set `metadata().tickRate` (FPS) and implement `tick()` for continuous simulation (movement, physics, timers counting down). Do mutations in `tick()` and end with one `onStateChange()`.
|
|
466
|
+
- For delayed / repeating logic, **use the tracked timer helpers from the base `Game` class**, not the globals — they are auto-cleared when the session closes, preventing leaks:
|
|
467
|
+
|
|
468
|
+
```js
|
|
469
|
+
this.setTimeout(() => this.explode(), 5000); // tracked
|
|
470
|
+
this.setInterval(() => this.spawnEnemy(), 1000); // tracked
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
`Game.close()` clears all timers created via `this.setInterval` / `this.setTimeout`. If you use the global `setTimeout`/`setInterval`, you are responsible for clearing them yourself in `close()`.
|
|
474
|
+
|
|
475
|
+
**Idiom — prefer a tick counter over timers for cooldowns/durations in tick-driven games.** When you already have a `tick()` loop, the cleanest way to do fire cooldowns, respawn delays, spawn protection, etc. is to keep an incrementing tick count and compare against it — it's deterministic, needs no cleanup, and can't leak:
|
|
476
|
+
|
|
477
|
+
```js
|
|
478
|
+
constructor() { super(); this._t = 0; /* ... */ }
|
|
479
|
+
tick() {
|
|
480
|
+
this._t++;
|
|
481
|
+
// fire on a cooldown:
|
|
482
|
+
if (firePressed && this._t >= player.fireReady) { this.fire(player); player.fireReady = this._t + 16; }
|
|
483
|
+
// respawn after ~2.5s (at tickRate 60):
|
|
484
|
+
if (!player.alive && this._t >= player.respawnAt) this.respawn(player);
|
|
485
|
+
// ... mutate, then ONE onStateChange() ...
|
|
486
|
+
this.base.node.onStateChange();
|
|
487
|
+
}
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
492
|
+
## 11. Assets (images, audio, fonts)
|
|
493
|
+
|
|
494
|
+
1. Declare them in `metadata().assets`, keyed by a short name, each an `Asset` instance with an `id` (the asset's hash in the Homegames asset store) and `type`:
|
|
495
|
+
|
|
496
|
+
```js
|
|
497
|
+
const { Asset } = require('squish-138');
|
|
498
|
+
// ...
|
|
499
|
+
assets: {
|
|
500
|
+
'hero': new Asset({ id: 'c0ffee...hash', type: 'image' }),
|
|
501
|
+
'jump': new Asset({ id: 'deadbe...hash', type: 'audio' }),
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
2. Reference them by key in `GameNode.Asset` `assetInfo` (see §7.3). Images use real `size`; audio uses zero size + `startTime`.
|
|
506
|
+
3. `type` is `'image'`, `'audio'`, or `'font'`. Keep total size within the limits in §2.
|
|
507
|
+
|
|
508
|
+
> When generating a game from scratch with no real asset ids available, prefer **drawing with shapes and text** rather than inventing fake asset ids (a fake id will load nothing). Only use `Asset` nodes when you have, or are given, valid asset ids.
|
|
509
|
+
|
|
510
|
+
---
|
|
511
|
+
|
|
512
|
+
## 12. Utilities
|
|
513
|
+
|
|
514
|
+
- `ShapeUtils.rectangle(x,y,w,h)`, `ShapeUtils.triangle(...)` — vertex builders (§6).
|
|
515
|
+
- `Colors.COLORS.*`, `Colors.randomColor(exclusions?)` — palette (§6).
|
|
516
|
+
- `GeometryUtils.checkCollisions(root, node, filter?)` — returns nodes under `root` that overlap `node` (axis-aligned rectangle test). Optional `filter(node) => boolean` to limit candidates. Useful but simple; many games hand-roll AABB checks for control (see §14).
|
|
517
|
+
- `ViewUtils.getView(plane, view, playerIds, translation?, scale?)` — projects a slice of a large world into the `0–100` viewport; optional `translation`/`scale` inset the projection into part of the screen (see §13 / §13.1).
|
|
518
|
+
- `Shapes.POLYGON | LINE` — shape type enum (`CIRCLE` exists but is not rendered).
|
|
519
|
+
|
|
520
|
+
---
|
|
521
|
+
|
|
522
|
+
## 13. Large scrolling worlds and per-player cameras (`ViewableGame`)
|
|
523
|
+
|
|
524
|
+
Everything above renders directly into the shared `0–100` plane, where every player sees the same thing. For games with a **world larger than one screen** (scrolling levels, top-down arenas, anything with a camera) or **per-player cameras** (each player sees their own region), extend **`ViewableGame`** instead of `Game`.
|
|
525
|
+
|
|
526
|
+
`ViewableGame` gives you a large square **world plane** of size `planeSize × planeSize` (in its own world units), plus a separate **view root** that is what clients actually render. The rendered viewport is **always `0–100`** — you project a rectangular slice of the big world into that `0–100` viewport, per player. This is how you get cameras, scrolling, and split per-player views in one shared game.
|
|
527
|
+
|
|
528
|
+
### Setup
|
|
529
|
+
|
|
530
|
+
```js
|
|
531
|
+
const { ViewableGame, GameNode, Colors, Shapes, ShapeUtils, ViewUtils } = require('squish-138');
|
|
532
|
+
|
|
533
|
+
class MyWorld extends ViewableGame {
|
|
534
|
+
constructor() {
|
|
535
|
+
super(1000); // world is 1000 x 1000 world-units. Call super(planeSize), NOT super().
|
|
536
|
+
this.playerViews = {};
|
|
537
|
+
// ... build world content (Approach A) or keep entities as plain data (Approach B)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
getLayers() {
|
|
541
|
+
return [{ root: this.getViewRoot() }]; // render the VIEW root, not the world plane
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
API added by `ViewableGame`:
|
|
547
|
+
- `super(planeSize)` — **required**; sets up the world plane and the view root. (Plain `Game` uses `super()` with no args; `ViewableGame` needs the size.)
|
|
548
|
+
- `getPlane()` — the world plane `Shape` (size `planeSize`). For the built-in projection approach, add your world content as children of this.
|
|
549
|
+
- `getPlaneSize()` / `updatePlaneSize(n)` — read / change the world size.
|
|
550
|
+
- `getViewRoot()` — the **render root**. It starts **empty**. Whatever you want on screen must be added here, normally per-player view roots restricted with `playerIds`.
|
|
551
|
+
|
|
552
|
+
> **Critical:** with `ViewableGame`, `getViewRoot()` is empty by default and the world plane is **not** rendered directly. If you build a world in `getPlane()` but never put a projected view under `getViewRoot()`, **players see a blank screen.**
|
|
553
|
+
|
|
554
|
+
A "view" is a rectangle into the world: `{ x, y, w, h }` in world units. You render the slice of the world inside that rectangle, scaled to fill the `0–100` viewport.
|
|
555
|
+
|
|
556
|
+
### Approach A — built-in projection with `ViewUtils.getView`
|
|
557
|
+
|
|
558
|
+
Build the world once in `getPlane()`, then per player project a view window into a render root. `ViewUtils.getView(plane, view, playerIds)` clones the world nodes inside `view`, translates/clips them into `0–100`, and tags them for `playerIds`. (It has two more optional args, `translation` and `scale`, for placing the projection in only part of the screen — see §13.1.):
|
|
559
|
+
|
|
560
|
+
```js
|
|
561
|
+
handleNewPlayer({ playerId }) {
|
|
562
|
+
const view = { x: 0, y: 0, w: 100, h: 100 }; // window into the world, in world units
|
|
563
|
+
|
|
564
|
+
// A solid backdrop this player always sees, so they never see blank space:
|
|
565
|
+
const playerRoot = new GameNode.Shape({
|
|
566
|
+
shapeType: Shapes.POLYGON,
|
|
567
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
|
|
568
|
+
fill: Colors.COLORS.BLACK,
|
|
569
|
+
playerIds: [playerId]
|
|
570
|
+
});
|
|
571
|
+
playerRoot.addChild(ViewUtils.getView(this.getPlane(), view, [playerId]));
|
|
572
|
+
|
|
573
|
+
this.playerViews[playerId] = { view, root: playerRoot };
|
|
574
|
+
this.getViewRoot().addChild(playerRoot);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// To move a player's camera, recompute the view and rebuild that player's projection:
|
|
578
|
+
panCamera(playerId, dx, dy) {
|
|
579
|
+
const pv = this.playerViews[playerId];
|
|
580
|
+
pv.view = { ...pv.view, x: pv.view.x + dx, y: pv.view.y + dy };
|
|
581
|
+
pv.root.node.clearChildren();
|
|
582
|
+
pv.root.node.addChild(ViewUtils.getView(this.getPlane(), pv.view, [playerId]));
|
|
583
|
+
pv.root.node.onStateChange();
|
|
584
|
+
}
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### Approach B — manual projection (best for camera-follow / many moving entities)
|
|
588
|
+
|
|
589
|
+
Keep world entities as **plain data** (not nodes), and rebuild each player's view nodes yourself whenever they move or each tick. Full control — e.g. a camera centered on the player. World→view transform: `viewCoord = ((world − viewOrigin) / viewSize) * 100`.
|
|
590
|
+
|
|
591
|
+
```js
|
|
592
|
+
createPlayerView(playerId) {
|
|
593
|
+
const player = this.players[playerId];
|
|
594
|
+
const { w: viewW, h: viewH } = player.view; // camera window size, in world units
|
|
595
|
+
const viewX = player.x - viewW / 2; // center the camera on the player
|
|
596
|
+
const viewY = player.y - viewH / 2;
|
|
597
|
+
|
|
598
|
+
const viewRoot = new GameNode.Shape({
|
|
599
|
+
shapeType: Shapes.POLYGON,
|
|
600
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100), // viewport is always 0–100
|
|
601
|
+
fill: [50, 50, 50, 255],
|
|
602
|
+
playerIds: [playerId]
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const toView = (wx, wy, wsize) => ({
|
|
606
|
+
x: ((wx - viewX) / viewW) * 100,
|
|
607
|
+
y: ((wy - viewY) / viewH) * 100,
|
|
608
|
+
size: (wsize / viewW) * 100
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// player is always drawn centered:
|
|
612
|
+
const ps = (player.size / viewW) * 100;
|
|
613
|
+
viewRoot.addChild(new GameNode.Shape({
|
|
614
|
+
shapeType: Shapes.POLYGON,
|
|
615
|
+
coordinates2d: ShapeUtils.rectangle(50 - ps / 2, 50 - ps / 2, ps, ps),
|
|
616
|
+
fill: player.color, playerIds: [playerId]
|
|
617
|
+
}));
|
|
618
|
+
|
|
619
|
+
for (const e of this.enemies) {
|
|
620
|
+
const r = toView(e.x, e.y, e.size);
|
|
621
|
+
viewRoot.addChild(new GameNode.Shape({
|
|
622
|
+
shapeType: Shapes.POLYGON,
|
|
623
|
+
coordinates2d: ShapeUtils.rectangle(r.x - r.size / 2, r.y - r.size / 2, r.size, r.size),
|
|
624
|
+
fill: e.color, playerIds: [playerId]
|
|
625
|
+
}));
|
|
626
|
+
}
|
|
627
|
+
return viewRoot;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// On movement, swap the player's view root under getViewRoot():
|
|
631
|
+
updatePlayerView(playerId) {
|
|
632
|
+
const player = this.players[playerId];
|
|
633
|
+
if (player.viewRoot) this.getViewRoot().removeChild(player.viewRoot.node.id);
|
|
634
|
+
player.viewRoot = this.createPlayerView(playerId);
|
|
635
|
+
this.getViewRoot().addChild(player.viewRoot);
|
|
636
|
+
player.viewRoot.node.onStateChange();
|
|
637
|
+
}
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### 13.1 Projecting into a sub-region (static frame around a scrolling viewport)
|
|
641
|
+
|
|
642
|
+
`getView` takes two optional trailing arguments that let you place the projection somewhere other than the full screen:
|
|
643
|
+
|
|
644
|
+
```js
|
|
645
|
+
ViewUtils.getView(plane, view, playerIds, translation, scale)
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
- `translation` — `{ x, y, filter? }`. After a node is projected, shift it by `x`/`y` (plane units). The optional `filter(node) => boolean` applies the shift to only the nodes it returns true for.
|
|
649
|
+
- `scale` — `{ x, y }`. Multiplies the projected coordinates per axis; values `< 1` shrink the projection so it fills only part of the viewport.
|
|
650
|
+
|
|
651
|
+
Per-vertex transform order is: subtract the view origin and clamp to `0–100`, **then** multiply by `scale`, **then** add `translation`, then clamp again.
|
|
652
|
+
|
|
653
|
+
This is how you keep **static UI fixed on screen while a world region scrolls inside an inset panel** — the exact pattern the Homegames dashboard uses: a search box and scroll arrows are plain `0–100` nodes, and the scrollable game grid is a `getView` projection scaled down and pushed below them.
|
|
654
|
+
|
|
655
|
+
```js
|
|
656
|
+
renderForPlayer(playerId) {
|
|
657
|
+
const root = this.playerRoots[playerId]; // full-screen node tagged [playerId]
|
|
658
|
+
|
|
659
|
+
// 1) static chrome: plain nodes, fixed position, NOT projected
|
|
660
|
+
root.node.addChild(this.buildToolbar(playerId)); // e.g. the search field from §9
|
|
661
|
+
|
|
662
|
+
// 2) the scrollable world, projected into the lower/inset part of the screen
|
|
663
|
+
const view = this.playerStates[playerId].view; // { x, y, w, h } in world units
|
|
664
|
+
root.node.addChild(ViewUtils.getView(
|
|
665
|
+
this.getPlane(), view, [playerId],
|
|
666
|
+
{ x: 12.5, y: 18 }, // push the projection right + down, clear of the toolbar
|
|
667
|
+
{ x: 0.75, y: 0.75 } // shrink it to leave margins
|
|
668
|
+
));
|
|
669
|
+
root.node.onStateChange();
|
|
670
|
+
}
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
Gotchas:
|
|
674
|
+
- The projection includes only nodes that **overlap** the `view` rectangle (a collision test against the plane's children), so off-screen world content is free — but a node straddling the view edge has its vertices **clamped to `0–100` individually**, which can distort a shape that's half in and half out. Size views so content isn't sliced, or accept the clamp.
|
|
675
|
+
- `getView` returns a brand-new subtree each call. To scroll or pan, rebuild that player's projection (`clearChildren()` + re-add, or swap the subtree) and `onStateChange()` — see the `panCamera` example above.
|
|
676
|
+
- `onClick` survives projection (the clone keeps its handler), so projected world nodes stay tappable.
|
|
677
|
+
|
|
678
|
+
**Choosing:** use plain `Game` for single-screen games (most party/casual games). Use `ViewableGame` only when the world exceeds one screen or players need different cameras. Approach A is less code when the world is mostly static nodes; Approach B is better for smooth camera-follow and lots of moving entities. Either way: clean up a player's view root in `handlePlayerDisconnect`, and `getLayers()` returns `[{ root: this.getViewRoot() }]`.
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## 14. Complete, correct examples
|
|
683
|
+
|
|
684
|
+
### 14.1 Single-player click game (event-driven, no tick)
|
|
685
|
+
|
|
686
|
+
```js
|
|
687
|
+
const { Game, GameNode, Colors, Shapes, ShapeUtils } = require('squish-138');
|
|
688
|
+
const { COLORS } = Colors;
|
|
689
|
+
|
|
690
|
+
class ClickCounter extends Game {
|
|
691
|
+
static metadata() {
|
|
692
|
+
return {
|
|
693
|
+
squishVersion: '138',
|
|
694
|
+
name: 'Click Counter',
|
|
695
|
+
author: 'AI',
|
|
696
|
+
description: 'Tap the square to score points.',
|
|
697
|
+
aspectRatio: { x: 16, y: 9 }
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
constructor() {
|
|
702
|
+
super();
|
|
703
|
+
this.score = 0;
|
|
704
|
+
|
|
705
|
+
this.base = new GameNode.Shape({
|
|
706
|
+
shapeType: Shapes.POLYGON,
|
|
707
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
|
|
708
|
+
fill: COLORS.HG_BLUE
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
this.scoreText = new GameNode.Text({
|
|
712
|
+
textInfo: { text: 'Score: 0', x: 50, y: 12, size: 4, align: 'center', color: COLORS.WHITE }
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
this.button = new GameNode.Shape({
|
|
716
|
+
shapeType: Shapes.POLYGON,
|
|
717
|
+
coordinates2d: ShapeUtils.rectangle(35, 35, 30, 30),
|
|
718
|
+
fill: COLORS.CANDY_RED,
|
|
719
|
+
onClick: (playerId, x, y) => {
|
|
720
|
+
this.score += 1;
|
|
721
|
+
this.scoreText.node.text = {
|
|
722
|
+
text: `Score: ${this.score}`, x: 50, y: 12, size: 4, align: 'center', color: COLORS.WHITE
|
|
723
|
+
};
|
|
724
|
+
this.base.node.onStateChange(); // REQUIRED after mutating text
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
this.base.addChildren(this.scoreText, this.button);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
getLayers() {
|
|
732
|
+
return [{ root: this.base }];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
module.exports = ClickCounter;
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
### 14.2 Multiplayer movement game (players join, move with keys, tick loop)
|
|
740
|
+
|
|
741
|
+
```js
|
|
742
|
+
const { Game, GameNode, Colors, Shapes, ShapeUtils } = require('squish-138');
|
|
743
|
+
const { COLORS } = Colors;
|
|
744
|
+
|
|
745
|
+
class Movers extends Game {
|
|
746
|
+
static metadata() {
|
|
747
|
+
return {
|
|
748
|
+
squishVersion: '138',
|
|
749
|
+
name: 'Movers',
|
|
750
|
+
author: 'AI',
|
|
751
|
+
description: 'Everyone gets a square. Move with arrows or WASD.',
|
|
752
|
+
aspectRatio: { x: 16, y: 9 },
|
|
753
|
+
tickRate: 30
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
constructor() {
|
|
758
|
+
super();
|
|
759
|
+
this.players = {}; // playerId -> { node, vx, vy }
|
|
760
|
+
this.base = new GameNode.Shape({
|
|
761
|
+
shapeType: Shapes.POLYGON,
|
|
762
|
+
coordinates2d: ShapeUtils.rectangle(0, 0, 100, 100),
|
|
763
|
+
fill: COLORS.ALMOST_BLACK
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
handleNewPlayer({ playerId, info }) {
|
|
768
|
+
const square = new GameNode.Shape({
|
|
769
|
+
shapeType: Shapes.POLYGON,
|
|
770
|
+
coordinates2d: ShapeUtils.rectangle(47, 47, 6, 6),
|
|
771
|
+
fill: Colors.randomColor(['ALMOST_BLACK']) // exclude by NAME (string)
|
|
772
|
+
});
|
|
773
|
+
const name = new GameNode.Text({
|
|
774
|
+
textInfo: { text: info?.name || 'player', x: 50, y: 5, size: 1.5, align: 'center', color: COLORS.WHITE },
|
|
775
|
+
playerIds: [playerId] // each player sees only their own name banner
|
|
776
|
+
});
|
|
777
|
+
this.players[playerId] = { node: square, vx: 0, vy: 0 };
|
|
778
|
+
this.base.addChildren(square, name);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
handlePlayerDisconnect(playerId) {
|
|
782
|
+
const p = this.players[playerId];
|
|
783
|
+
if (p) {
|
|
784
|
+
this.base.removeChild(p.node.id);
|
|
785
|
+
delete this.players[playerId];
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
handleKeyDown(playerId, key) {
|
|
790
|
+
const p = this.players[playerId];
|
|
791
|
+
if (!p) return;
|
|
792
|
+
if (key === 'ArrowUp' || key === 'w') p.vy = -1;
|
|
793
|
+
else if (key === 'ArrowDown' || key === 's') p.vy = 1;
|
|
794
|
+
else if (key === 'ArrowLeft' || key === 'a') p.vx = -1;
|
|
795
|
+
else if (key === 'ArrowRight' || key === 'd') p.vx = 1;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
handleKeyUp(playerId, key) {
|
|
799
|
+
const p = this.players[playerId];
|
|
800
|
+
if (!p) return;
|
|
801
|
+
if (key === 'ArrowUp' || key === 'w' || key === 'ArrowDown' || key === 's') p.vy = 0;
|
|
802
|
+
if (key === 'ArrowLeft' || key === 'a' || key === 'ArrowRight' || key === 'd') p.vx = 0;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
tick() {
|
|
806
|
+
let changed = false;
|
|
807
|
+
for (const id in this.players) {
|
|
808
|
+
const p = this.players[id];
|
|
809
|
+
if (p.vx === 0 && p.vy === 0) continue;
|
|
810
|
+
const x = p.node.node.coordinates2d[0][0];
|
|
811
|
+
const y = p.node.node.coordinates2d[0][1];
|
|
812
|
+
const nx = Math.max(0, Math.min(94, x + p.vx)); // clamp to plane (square is 6 wide)
|
|
813
|
+
const ny = Math.max(0, Math.min(94, y + p.vy));
|
|
814
|
+
p.node.node.coordinates2d = ShapeUtils.rectangle(nx, ny, 6, 6);
|
|
815
|
+
changed = true;
|
|
816
|
+
}
|
|
817
|
+
if (changed) this.base.node.onStateChange(); // ONE notify per tick
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
getLayers() {
|
|
821
|
+
return [{ root: this.base }];
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
module.exports = Movers;
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
---
|
|
829
|
+
|
|
830
|
+
## 15. Idioms and anti-patterns (checklist before you output)
|
|
831
|
+
|
|
832
|
+
Do:
|
|
833
|
+
- [ ] `module.exports = TheClass;` at the end (export the class, not an instance).
|
|
834
|
+
- [ ] `require('squish-138')` and `squishVersion: '138'` agree.
|
|
835
|
+
- [ ] Call `super()` first thing in the constructor.
|
|
836
|
+
- [ ] Build a single root `this.base` shape sized `rectangle(0,0,100,100)`; return it from `getLayers()` as `[{ root: this.base }]`. (For worlds bigger than one screen or per-player cameras, extend `ViewableGame` and render `getViewRoot()` instead — §13.)
|
|
837
|
+
- [ ] Call `onStateChange()` on the root after any direct property mutation (§4).
|
|
838
|
+
- [ ] Use `this.setTimeout` / `this.setInterval` (tracked) for timers.
|
|
839
|
+
- [ ] Clean up a leaving player's nodes in `handlePlayerDisconnect`.
|
|
840
|
+
- [ ] Size click/tap targets generously and support tap-first or both arrows+WASD.
|
|
841
|
+
- [ ] Keep everything in `0–100` coordinate space.
|
|
842
|
+
|
|
843
|
+
Don't:
|
|
844
|
+
- [ ] Don't forget `onStateChange()` — mutating `coordinates2d`/`fill`/`text` without it shows nothing.
|
|
845
|
+
- [ ] Don't put `onClick` on a `Text` node — it's silently ignored. Build buttons as a clickable `Shape` with a `Text` on top (§7.2.1).
|
|
846
|
+
- [ ] Don't pass `border` as an object — it's a **number** (outline width), and you must also set `color` for the stroke (§7.1).
|
|
847
|
+
- [ ] Don't pass color **arrays** to `Colors.randomColor()` — it excludes by **name** strings (§6).
|
|
848
|
+
- [ ] Don't expect circles/true angles at non-`{1,1}` aspect ratios — the plane is stretched; use `{x:1,y:1}` for geometry (§5).
|
|
849
|
+
- [ ] Don't `require` Node built-ins, hit the network/filesystem, read `process.env`, or use `eval`/`new Function`.
|
|
850
|
+
- [ ] Don't spin up one game instance per player — it's one shared instance; use `playerIds` for per-player views.
|
|
851
|
+
- [ ] Don't invent asset ids. If you have none, draw with shapes and text instead of `Asset` nodes.
|
|
852
|
+
- [ ] Don't use coordinates outside `0–100` expecting them to be visible.
|
|
853
|
+
- [ ] Don't rely on sub-`0.01` precision — per-frame motion below ~`0.01` units rounds away (§6). Keep deltas ≥ ~0.05 or accumulate off-node.
|
|
854
|
+
- [ ] Don't use the `crop*` asset fields on `squish-138`/`139` — cropping needs `squish-140`+ (§7.3.1); on older versions they're ignored.
|
|
855
|
+
- [ ] Don't block the event loop (no busy loops, no synchronous long work); drive motion from `tick()`.
|
|
856
|
+
- [ ] Don't assume a keyboard exists on mobile — provide on-screen controls if keys are core.
|
|
857
|
+
|
|
858
|
+
---
|
|
859
|
+
|
|
860
|
+
## 16. Quick reference card
|
|
861
|
+
|
|
862
|
+
```
|
|
863
|
+
require('squish-138') -> { Game, GameNode, Colors, Shapes, ShapeUtils, GeometryUtils, Asset, Physics, ... }
|
|
864
|
+
|
|
865
|
+
Game hooks: metadata() [static, required] · constructor()->super() · getLayers()->[{root}]
|
|
866
|
+
handleNewPlayer({playerId,info,settings,clientInfo}) · handlePlayerDisconnect(playerId)
|
|
867
|
+
handleKeyDown(playerId,key) · handleKeyUp(playerId,key) · tick() · canAddPlayer() · close()
|
|
868
|
+
|
|
869
|
+
Nodes: GameNode.Shape({ shapeType, coordinates2d, fill, color, border, onClick, playerIds }) // clickable
|
|
870
|
+
border = NUMBER (outline width), NOT an object; stroke uses `color` -> set both. fill=interior.
|
|
871
|
+
round shape: build a many-sided polygon (CIRCLE doesn't render). rotate verts with trig (§7.1).
|
|
872
|
+
GameNode.Text({ textInfo:{ text,x,y,size,align,color,font }, playerIds }) // NO onClick
|
|
873
|
+
GameNode.Asset({ coordinates2d, assetInfo:{ key:{pos:{x,y},size:{x,y},startTime} }, playerIds }) // clickable
|
|
874
|
+
Asset crop (squish-140+): assetInfo.key.{cropLeft,cropTop,cropRight,cropBottom} = % inset per edge -> spritesheets (§7.3.1)
|
|
875
|
+
Button: no button node + Text isn't clickable -> clickable Shape (onClick) with a Text node on top (§7.2.1)
|
|
876
|
+
|
|
877
|
+
Tree ops: n.addChild(c) · n.addChildren(a,b) · n.removeChild(id) · n.clearChildren([keepIds])
|
|
878
|
+
n.findChild(id) · n.update({fill,coordinates2d}) · n.showFor(pid) · n.hideFor(pid)
|
|
879
|
+
NOTIFY: n.node.onStateChange() // after direct field mutation; tree ops notify for you
|
|
880
|
+
|
|
881
|
+
Shapes: Shapes.POLYGON | LINE (CIRCLE constant exists but does NOT render — don't use)
|
|
882
|
+
Coords: ShapeUtils.rectangle(x,y,w,h) · ShapeUtils.triangle(x1,y1,x2,y2,x3,y3) · plane is 0..100
|
|
883
|
+
Colors: Colors.COLORS.RED ... ([r,g,b,a] 0..255) · Colors.randomColor(['BLACK',...]) // exclude by NAME, not value
|
|
884
|
+
Aspect: plane is 0..100 but stretched to aspectRatio -> use {1,1} for circles/orbits/true angles (§5)
|
|
885
|
+
Hide a node: set fill [0,0,0,0] (don't rely on playerIds:[])
|
|
886
|
+
onClick: (playerId, x, y) => {} // Shape/Asset only
|
|
887
|
+
text input: Shape/Text input:{ type:'text', oninput:(playerId, value)=>{} } // value = full field text
|
|
888
|
+
hover: Shape/Asset onHover(pid) / offHover(pid) // cosmetic only; no hover on touch
|
|
889
|
+
playerIds: [0] = everyone (default) · [id,...] = only those players
|
|
890
|
+
|
|
891
|
+
Big worlds: extend ViewableGame · super(planeSize) · getPlane()/getPlaneSize() = world
|
|
892
|
+
getViewRoot() = render root (starts EMPTY) · getLayers()->[{root:getViewRoot()}]
|
|
893
|
+
ViewUtils.getView(plane,{x,y,w,h},[pid], translation?, scale?) projects a world slice into 0..100
|
|
894
|
+
translation={x,y,filter?} scale={x,y} -> inset the projection (static frame + scroll region, §13.1)
|
|
895
|
+
```
|