@maccesar/titools 4.14.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/skills/ti-game/SKILL.md +80 -180
- package/skills/ti-game/references/api.md +83 -18
- package/skills/ti-game/references/debugging-performance.md +103 -0
- package/skills/ti-game/references/examples.md +54 -0
- package/skills/ti-game/references/patterns.md +699 -0
- package/skills/ti-game/references/project-setup.md +21 -11
- package/skills/ti-game/references/recipes-action.md +633 -0
- package/skills/ti-game/references/recipes-adventure-effects.md +340 -0
- package/skills/ti-game/references/roadmap.md +44 -63
- package/skills/ti-game/references/tilemaps.md +125 -0
- package/skills/ti-game/references/recipes.md +0 -1574
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
# Cross-cutting ti.game patterns
|
|
2
|
+
|
|
3
|
+
Verified against `ti.game@d587081` and the runnable examples under `example/`. These are production-oriented extractions of the API combinations demonstrated upstream; they intentionally omit demo-launcher and back-navigation plumbing.
|
|
4
|
+
|
|
5
|
+
See [examples.md](examples.md) to choose the source demo, [api.md](api.md) for exact members, and the genre recipe files for complete game loops.
|
|
6
|
+
|
|
7
|
+
<!-- TOC-START -->
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- [The scaffolding](#the-scaffolding)
|
|
11
|
+
- [Cross-cutting patterns](#cross-cutting-patterns)
|
|
12
|
+
- [Object pooling](#object-pooling)
|
|
13
|
+
- [Freeing a level's textures](#freeing-a-levels-textures)
|
|
14
|
+
- [Parallax](#parallax)
|
|
15
|
+
- [Multitouch on-screen controls](#multitouch-on-screen-controls)
|
|
16
|
+
- [HUD — `text.js`, `flappy.js`](#hud--textjs-flappyjs)
|
|
17
|
+
- [Labels, bars and turrets pinned to a sprite — `attachTo`, `text.js`](#labels-bars-and-turrets-pinned-to-a-sprite--attachto-textjs)
|
|
18
|
+
- [Invisible triggers](#invisible-triggers)
|
|
19
|
+
- [Seeing the hitbox — `hitbox.js`](#seeing-the-hitbox--hitboxjs)
|
|
20
|
+
- [Shaped solids: ramps, round posts and bodies that push back — `slopes.js`, `circles.js`, `pool.js`, `drum.js`](#shaped-solids-ramps-round-posts-and-bodies-that-push-back--slopesjs-circlesjs-pooljs-drumjs)
|
|
21
|
+
- [Zones you can be inside of (enter / exit) — `zones.js`](#zones-you-can-be-inside-of-enter--exit--zonesjs)
|
|
22
|
+
- [Patrol routes and animation chains — `path.js`](#patrol-routes-and-animation-chains--pathjs)
|
|
23
|
+
- [Line of sight, ledge probes and hitscan — `raycast.js`](#line-of-sight-ledge-probes-and-hitscan--raycastjs)
|
|
24
|
+
- [Pathfinding around obstacles — `maze.js`, `pointclick.js`](#pathfinding-around-obstacles--mazejs-pointclickjs)
|
|
25
|
+
- [Timers on the game clock](#timers-on-the-game-clock)
|
|
26
|
+
- [Hit-stop on impact](#hit-stop-on-impact)
|
|
27
|
+
|
|
28
|
+
<!-- TOC-END -->
|
|
29
|
+
|
|
30
|
+
## The scaffolding
|
|
31
|
+
|
|
32
|
+
Every game repeats this shape. Sheets and sounds are created once, outside `resize`, because they are texture handles, not scene objects.
|
|
33
|
+
|
|
34
|
+
```javascript
|
|
35
|
+
const Game = require('ti.game');
|
|
36
|
+
|
|
37
|
+
module.exports = function () {
|
|
38
|
+
|
|
39
|
+
const win = Ti.UI.createWindow({
|
|
40
|
+
backgroundColor: '#000',
|
|
41
|
+
theme: 'Theme.Titanium.DayNight.NoTitleBar'
|
|
42
|
+
});
|
|
43
|
+
const gameView = Game.createGameView({
|
|
44
|
+
backgroundColor: '#8ed8f8',
|
|
45
|
+
maxFps: 60
|
|
46
|
+
// debug: true // draw every collision shape while tuning hitboxes
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const heroSheet = Game.createSpriteSheet({ image: 'assets/hero.png', frameWidth: 64, frameHeight: 64 });
|
|
50
|
+
const jumpSound = Game.createSound({ url: 'assets/jump.wav', volume: 0.8 });
|
|
51
|
+
|
|
52
|
+
let timer = null;
|
|
53
|
+
win.addEventListener('close', () => {
|
|
54
|
+
if (timer !== null) {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
timer = null;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
let initialized = false;
|
|
61
|
+
gameView.addEventListener('resize', (e) => {
|
|
62
|
+
if (initialized) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
initialized = true;
|
|
66
|
+
init(e.width, e.height);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
function init(W, H) {
|
|
70
|
+
// The whole game lives here. Derive every size from W and H so the
|
|
71
|
+
// layout holds on any screen: SPEED = W * 0.35, GRAVITY = H * 2.2.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
win.add(gameView);
|
|
75
|
+
win.open();
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Sizing everything as a fraction of `W`/`H` is not cosmetic — a jump tuned in absolute pixels becomes unplayable on a different device. The demos consistently express speeds as `W * f`, accelerations as `H * f` and sprite sizes as `Math.min(W, H) * f`.
|
|
80
|
+
|
|
81
|
+
## Cross-cutting patterns
|
|
82
|
+
|
|
83
|
+
The pieces every genre below reuses: pooling, freeing a level's textures, parallax, multitouch controls, HUD text, wrapped dialog, labels and bars attached to a sprite, invisible triggers, seeing the hitbox, enter/exit zones, patrol routes, line of sight, pathfinding, game-clock timers and hit-stop.
|
|
84
|
+
|
|
85
|
+
### Object pooling
|
|
86
|
+
|
|
87
|
+
Create a fixed set once, recycle forever. `visible = false` also removes the sprite from collision, which is what makes this work.
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
const bullets = [];
|
|
91
|
+
for (let i = 0; i < 10; i++) {
|
|
92
|
+
const sprite = Game.createSprite({
|
|
93
|
+
sheet: shipSheet,
|
|
94
|
+
frame: 3,
|
|
95
|
+
width: 8,
|
|
96
|
+
height: 16,
|
|
97
|
+
visible: false,
|
|
98
|
+
wrapAround: true,
|
|
99
|
+
swept: true, // path-tested: a fast bolt cannot skip past a rock
|
|
100
|
+
collidesWith: ['asteroid']
|
|
101
|
+
});
|
|
102
|
+
const state = { sprite: sprite, active: false, generation: 0 };
|
|
103
|
+
|
|
104
|
+
sprite.addEventListener('collision', (e) => {
|
|
105
|
+
if (!state.active) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
deactivate(state);
|
|
109
|
+
e.other.visible = false; // the rock is gone: no render, no collision
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
gameView.add(sprite);
|
|
113
|
+
bullets.push(state);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function deactivate(state) {
|
|
117
|
+
state.active = false;
|
|
118
|
+
state.sprite.visible = false;
|
|
119
|
+
state.sprite.velocityX = 0;
|
|
120
|
+
state.sprite.velocityY = 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fire(x, y, vx, vy) {
|
|
124
|
+
const state = bullets.find((b) => !b.active);
|
|
125
|
+
if (!state) {
|
|
126
|
+
return; // pool exhausted — drop the shot
|
|
127
|
+
}
|
|
128
|
+
state.active = true;
|
|
129
|
+
state.generation++;
|
|
130
|
+
const generation = state.generation;
|
|
131
|
+
Object.assign(state.sprite, { x: x, y: y, velocityX: vx, velocityY: vy, visible: true });
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
// burn out — unless this slot was already recycled by a newer shot
|
|
134
|
+
if (state.active && state.generation === generation) {
|
|
135
|
+
deactivate(state);
|
|
136
|
+
}
|
|
137
|
+
}, 1200);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The `generation` counter matters: without it, a timeout fired for an old shot deactivates the *new* bullet occupying that slot.
|
|
142
|
+
|
|
143
|
+
`swept: true` (compared side by side in `swept.js`) is what keeps a fast bolt from crossing a rock between two frames without ever overlapping it. The asteroids demo predates it and inflates the hitbox instead (`hitboxScale: 1.4`) — that works, at the price of counting near misses as hits. Prefer `swept`, and reserve `hitboxScale` — with `hitboxScaleX`/`hitboxScaleY` when the art's fit differs per axis — for fairness tuning.
|
|
144
|
+
|
|
145
|
+
Recycling a sprite with `visible = false` fires `collisionend` on anything it was touching. Handlers that treat separation as "the player walked away" need to tolerate a despawn.
|
|
146
|
+
|
|
147
|
+
### Freeing a level's textures
|
|
148
|
+
|
|
149
|
+
Pooling recycles sprites; it does not free the **texture** behind them. A game that streams worlds accumulates every atlas it has ever loaded until the GL context dies. `unload()` releases one on the next rendered frame.
|
|
150
|
+
|
|
151
|
+
```javascript
|
|
152
|
+
function unloadLevel(level) {
|
|
153
|
+
level.sprites.forEach((s) => gameView.remove(s));
|
|
154
|
+
level.sheets.forEach((sheet) => sheet.unload()); // only once nothing draws from them
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
It is permanent — not an eviction that reloads on demand. A sprite still pointing at an unloaded sheet stops drawing, silently. A single-level game should not call it: the textures die with the window.
|
|
159
|
+
|
|
160
|
+
### Parallax
|
|
161
|
+
|
|
162
|
+
Two ways, and the choice is decided by whether the **camera** moves or the **world** does.
|
|
163
|
+
|
|
164
|
+
**A camera travelling through a static world** — platformer, top-down, anything using `follow()` — wants `scrollFactor`, one property per layer:
|
|
165
|
+
|
|
166
|
+
```javascript
|
|
167
|
+
const clouds = Game.createSprite({ sheet: cloudSheet, x: W / 2, y: 120, zIndex: 0, scrollFactor: 0.25 });
|
|
168
|
+
const hills = Game.createSprite({ sheet: hillSheet, x: W / 2, y: 260, zIndex: 1, scrollFactor: 0.6 });
|
|
169
|
+
const sun = Game.createSprite({ sheet: sunSheet, x: 60, y: 60, zIndex: 0, scrollFactor: 0 });
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`0` pins the sun to the view while still zooming with `cameraScale` — that is the difference from `screenFixed`, which ignores zoom as well. Values above `1` overtake the camera for foreground grass. The sprite's `x`/`y` never change: `scrollFactor` shifts where it is *drawn* and maps touch back to match, so collisions still happen at the world coordinates you wrote.
|
|
173
|
+
|
|
174
|
+
**A world scrolling under a fixed camera** — endless runner, flappy — cannot use `scrollFactor`, because the camera never travels. Two screen-wide copies per layer, scrolled by native velocity; `wrapX`/`wrapShift` teleport a copy that left the screen back behind its sibling. No JS runs in the loop.
|
|
175
|
+
|
|
176
|
+
```javascript
|
|
177
|
+
function makeLayer(sheet, y, height, speed, z) {
|
|
178
|
+
const copies = [];
|
|
179
|
+
for (let i = 0; i < 2; i++) {
|
|
180
|
+
const s = Game.createSprite({
|
|
181
|
+
sheet: sheet,
|
|
182
|
+
x: W / 2 + i * W,
|
|
183
|
+
y: y,
|
|
184
|
+
width: W,
|
|
185
|
+
height: height,
|
|
186
|
+
zIndex: z,
|
|
187
|
+
tileRepeat: 'x', // keeps texture density; needs repeat: true on the sheet
|
|
188
|
+
wrapX: -W / 2,
|
|
189
|
+
wrapShift: 2 * W
|
|
190
|
+
});
|
|
191
|
+
gameView.add(s);
|
|
192
|
+
copies.push(s);
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
setSpeed: (v) => copies.forEach((copy) => {
|
|
196
|
+
copy.velocityX = -v;
|
|
197
|
+
})
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const skyline = makeLayer(skylineSheet, groundTop - SKY_H / 2, SKY_H, SPEED * 0.12, 1);
|
|
202
|
+
const street = makeLayer(streetSheet, (groundTop + H) / 2, H - groundTop, SPEED, 8);
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Slower layers read as further away. Three layers (clouds at 0.15×, hills at 0.4×, ground at 1×) is the flappy demo's recipe. `camera.js` demos the other half: `scrollFactor` cloud shadows at 1.35× and a `scrollFactor: 0` sun.
|
|
206
|
+
|
|
207
|
+
### Multitouch on-screen controls
|
|
208
|
+
|
|
209
|
+
Each button must be **its own Titanium view** — sibling views receive simultaneous pointers, so holding a direction while pressing jump works. A single container splitting touches itself does not.
|
|
210
|
+
|
|
211
|
+
```javascript
|
|
212
|
+
function makeButton(title, position, fontSize) {
|
|
213
|
+
const button = Ti.UI.createLabel({
|
|
214
|
+
text: title,
|
|
215
|
+
textAlign: 'center',
|
|
216
|
+
color: '#fff',
|
|
217
|
+
font: { fontSize: fontSize || 30, fontWeight: 'bold' },
|
|
218
|
+
backgroundColor: '#59000000',
|
|
219
|
+
borderRadius: 40,
|
|
220
|
+
width: '80dp',
|
|
221
|
+
height: '80dp',
|
|
222
|
+
bottom: '24dp'
|
|
223
|
+
});
|
|
224
|
+
// Manual press feedback: touchFeedback's ripple cannot animate over this
|
|
225
|
+
// canvas and spams RippleDrawable errors on Android.
|
|
226
|
+
button.addEventListener('touchstart', () => {
|
|
227
|
+
button.backgroundColor = '#8c000000';
|
|
228
|
+
});
|
|
229
|
+
['touchend', 'touchcancel'].forEach((event) => {
|
|
230
|
+
button.addEventListener(event, () => {
|
|
231
|
+
button.backgroundColor = '#59000000';
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
Object.assign(button, position);
|
|
235
|
+
return button;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function bindHold(button, apply, release) {
|
|
239
|
+
button.addEventListener('touchstart', apply);
|
|
240
|
+
['touchend', 'touchcancel'].forEach((event) => {
|
|
241
|
+
button.addEventListener(event, release);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const leftButton = makeButton('◀', { left: '24dp' });
|
|
246
|
+
bindHold(leftButton, () => {
|
|
247
|
+
car.steering = -1;
|
|
248
|
+
}, () => {
|
|
249
|
+
if (car.steering === -1) { // don't cancel the other button's input
|
|
250
|
+
car.steering = 0;
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
The guard in the release handler is what keeps two opposite buttons from fighting: releasing left only zeroes steering if left is still the active direction.
|
|
256
|
+
|
|
257
|
+
### HUD — `text.js`, `flappy.js`
|
|
258
|
+
|
|
259
|
+
Text sprites with `screenFixed: true`. They live in the scene — one batch, no overlay views — but ignore the camera, so a score stays in the corner while the world scrolls under it.
|
|
260
|
+
|
|
261
|
+
```javascript
|
|
262
|
+
// Created outside resize (no size known yet), positioned once it is
|
|
263
|
+
const scoreLabel = Game.createText({ text: '0', screenFixed: true, zIndex: 100 });
|
|
264
|
+
const statusLabel = Game.createText({ text: 'Tap to start!', screenFixed: true, zIndex: 100 });
|
|
265
|
+
|
|
266
|
+
function buildLevel(W, H) {
|
|
267
|
+
const TEXT_SCALE = Math.max(1, Math.round(W / 240)); // integer: crisp pixels
|
|
268
|
+
Object.assign(scoreLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.08 });
|
|
269
|
+
Object.assign(statusLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.45 });
|
|
270
|
+
gameView.add([scoreLabel, statusLabel]);
|
|
271
|
+
// ...
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function score() {
|
|
275
|
+
points += 1;
|
|
276
|
+
scoreLabel.text = points + ' / ' + GATE_COUNT;
|
|
277
|
+
scoreLabel.flash('#4dff88', 250); // text flashes like any sprite
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Scaling from `W` is what replaces `fontSize`: a bitmap font has one native size, and an integer multiplier is the only way to keep texels square. `Math.max(1, ...)` guards the smallest screens.
|
|
282
|
+
|
|
283
|
+
Because a label is a sprite, the HUD gets the whole toolbox for free — `animate()` to pop a score on change, `tintColor` per state, `glowColor` on a title, `idleAnimation` for a gentle wobble, and `tap` handlers so a `[ RESET ]` label works as a button with no Titanium view behind it.
|
|
284
|
+
|
|
285
|
+
Drop `screenFixed` and the same label becomes world furniture: signposts, floating damage numbers, a name over an NPC — it scrolls with everything else.
|
|
286
|
+
|
|
287
|
+
A dialog box is one long string with `maxWidth`: the engine breaks the lines on word boundaries and re-wraps every time `text` is written, so the source keeps no hand-placed `\n`.
|
|
288
|
+
|
|
289
|
+
```javascript
|
|
290
|
+
const UNIT = Math.max(1, Math.round(W / 240));
|
|
291
|
+
const dialog = Game.createText({
|
|
292
|
+
text: 'TAP FOR POND WISDOM',
|
|
293
|
+
maxWidth: Math.round(W * 0.55 / UNIT), // font-space px: divide by the scale
|
|
294
|
+
align: 'center',
|
|
295
|
+
lineSpacing: 1.3,
|
|
296
|
+
scale: UNIT,
|
|
297
|
+
x: W / 2,
|
|
298
|
+
y: H * 0.78,
|
|
299
|
+
zIndex: 3
|
|
300
|
+
});
|
|
301
|
+
dialog.addEventListener('tap', () => {
|
|
302
|
+
dialog.text = lines[i++ % lines.length]; // re-wraps natively
|
|
303
|
+
dialog.flash('#fff', 150);
|
|
304
|
+
});
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`maxWidth` is measured before `scale`, which is the only trap here — pass screen pixels straight in and the block comes out `scale` times too wide. `align` positions the lines against each other (the block's own width), not inside the wrap column, so anchor the sprite where the text should sit. A word longer than `maxWidth` overflows instead of breaking mid-word.
|
|
308
|
+
|
|
309
|
+
Overlaid `Ti.UI.Label` views remain the answer for text the engine's font cannot draw (accents, system fonts, right-to-left) and for real UI: menus, dialogs, text inputs.
|
|
310
|
+
|
|
311
|
+
### Labels, bars and turrets pinned to a sprite — `attachTo`, `text.js`
|
|
312
|
+
|
|
313
|
+
A HUD is `screenFixed`; a name tag, a health bar or a shadow is the opposite — it belongs *on* a sprite that moves. `attachTo` is the native version of the JS everyone wrote before it: a timer copying `owner.x` into `tag.x`, one bridge round trip per tag per tick, and always a frame behind.
|
|
314
|
+
|
|
315
|
+
```javascript
|
|
316
|
+
const tag = Game.createText({ text: 'THE BALL', scale: UNIT, tintColor: '#7fd4ff', zIndex: 6 });
|
|
317
|
+
tag.attachTo(ball, { offsetY: -(W * 0.09) }); // offset in the tag's own units
|
|
318
|
+
gameView.add(tag); // the tag is still a scene sprite
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
The tag now tracks the ball through gravity, tweens, a moving platform and a finger drag, with no JS in the loop, and disappears with it — `gameView.remove(ball)` removes everything attached to the ball, recursively.
|
|
322
|
+
|
|
323
|
+
A health bar is the same idea with two sprites, and shows where inheritance stops:
|
|
324
|
+
|
|
325
|
+
```javascript
|
|
326
|
+
// blockSheet is any small opaque texture stretched and tinted — a sheet-less
|
|
327
|
+
// sprite draws nothing at all, that is the invisible-trigger idiom below
|
|
328
|
+
const barBack = Game.createSprite({ sheet: blockSheet, width: 40, height: 5, zIndex: 5, tintColor: '#222' });
|
|
329
|
+
const barFill = Game.createSprite({ sheet: blockSheet, width: 40, height: 5, zIndex: 6, anchor: 'left', tintColor: '#3f6' });
|
|
330
|
+
|
|
331
|
+
barBack.attachTo(hero, { offsetY: -34 });
|
|
332
|
+
barFill.attachTo(hero, { offsetX: -20, offsetY: -34 }); // anchored left → grows to the right
|
|
333
|
+
gameView.add([barBack, barFill]);
|
|
334
|
+
|
|
335
|
+
// on damage: the width is yours to set — scale, visibility and tint are not inherited
|
|
336
|
+
barFill.width = 40 * (hp / hpMax);
|
|
337
|
+
|
|
338
|
+
// on death: one tween fades the hero AND both bars, because opacity does cascade
|
|
339
|
+
hero.animate({ opacity: 0, duration: 400 });
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`rotate: true` is the other half: the offset swings around the target and the sprite copies its rotation, which is what a turret on a tank or a hat on a tumbling hero needs.
|
|
343
|
+
|
|
344
|
+
```javascript
|
|
345
|
+
turret.attachTo(tank, { offsetY: -12, rotate: true });
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Four things to keep in mind:
|
|
349
|
+
|
|
350
|
+
- **The attached sprite has to be added to the scene**, and so does its target. Neither pins otherwise — the sprite just sits at whatever `x`/`y` it was created with.
|
|
351
|
+
- **Its position stops being yours.** Velocity, gravity and `animate({ x })` still run and are overwritten the same frame. To fling a tag off a dying enemy, `detach()` first, then give it velocity.
|
|
352
|
+
- **Match `scrollFactor` between the two.** Attachment pins world coordinates; parallax changes only where a sprite is *drawn*. A tag at `scrollFactor: 1` on a background sprite at `0.5` stays pinned in world space and drifts across the screen anyway.
|
|
353
|
+
- **A faded owner also stops its tags taking taps.** The inherited opacity runs through the hit test as well as the draw, so an owner at `opacity: 0` leaves an attached text button untouchable — while that button's own `opacity` still reads 1. Hide an owner with `visible = false` if the tags should keep working, or detach them.
|
|
354
|
+
|
|
355
|
+
### Invisible triggers
|
|
356
|
+
|
|
357
|
+
A sprite with `width`/`height` and no `sheet` renders nothing but still collides. This is how scores, goals, ceilings, kill floors, walls and checkpoints are built.
|
|
358
|
+
|
|
359
|
+
```javascript
|
|
360
|
+
// score zone in the gap between two pipes
|
|
361
|
+
gameView.add(Game.createSprite({ x: gateX, y: gapCenter, width: 20, height: GAP, collisionGroup: 'score' }));
|
|
362
|
+
|
|
363
|
+
// invisible ceiling so the player cannot escape upward
|
|
364
|
+
gameView.add(Game.createSprite({ x: W / 2, y: -20, width: W * 3, height: 40, collisionGroup: 'ceiling' }));
|
|
365
|
+
|
|
366
|
+
// side walls
|
|
367
|
+
[-20, W + 20].forEach((x) => {
|
|
368
|
+
gameView.add(Game.createSprite({ x: x, y: H / 2, width: 40, height: H * 3, collisionGroup: 'wall' }));
|
|
369
|
+
});
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Attach your own metadata to a sprite and read it back from `e.other` in the handler:
|
|
373
|
+
|
|
374
|
+
```javascript
|
|
375
|
+
const zone = Game.createSprite({ x: cp.x, y: cp.y, width: cp.w, height: cp.h, collisionGroup: 'checkpoint' });
|
|
376
|
+
zone.cpIndex = index;
|
|
377
|
+
|
|
378
|
+
car.addEventListener('collision', (e) => {
|
|
379
|
+
if (e.group === 'checkpoint') {
|
|
380
|
+
visited[e.other.cpIndex] = true;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Seeing the hitbox — `hitbox.js`
|
|
386
|
+
|
|
387
|
+
`debug: true` on the GameView is the whole tuning workflow, and the demo exists to make the overlays legible: two identical adventurers walk into the same wall, one untuned and one with `hitboxScaleX: 0.62` / `hitboxScaleY: 0.92`. The untuned one stops a body's width short and hovers above the floor on his frame's padding; the tuned one goes flush and lands his feet. Tapping toggles the tuning live, so the green box snaps between frame and drawing.
|
|
388
|
+
|
|
389
|
+
```javascript
|
|
390
|
+
const gameView = Game.createGameView({ debug: true }); // green = collision AABB, blue = drawn frame, orange = anchor
|
|
391
|
+
|
|
392
|
+
hero.hitboxScaleX = 0.62; // both are live: write them and watch the box move
|
|
393
|
+
hero.hitboxScaleY = 0.92;
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
Read the two boxes as a pair: green is what collides, blue is what draws **and what takes taps**. Green much smaller than blue on one axis only is exactly the case the per-axis scales exist for; green and blue identical on a sprite whose art has padding is the bug the demo dramatizes.
|
|
397
|
+
|
|
398
|
+
The orange dot is the third variable, not decoration: the box shrinks **around the anchor**, so where the anchor sits decides which edge stays put. A prop that should collide from mid-height down while still resting on the floor is the anchor and the scale together, and both have a readable form now:
|
|
399
|
+
|
|
400
|
+
```javascript
|
|
401
|
+
// the same thing, twice — the second one says what it is doing
|
|
402
|
+
Game.createSprite({ sheet, anchorY: 1, hitboxScaleY: 0.55 });
|
|
403
|
+
Game.createSprite({ sheet, anchor: 'bottom', hitboxScaleY: '55%' });
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Every ratio takes a percentage this way (`opacity`, `scale`, `scrollFactor`, `restitution`, `volume`, the `follow` margins…) and both anchors take names. Coordinates, sizes, degrees and speeds do not — they were never fractions.
|
|
407
|
+
|
|
408
|
+
### Shaped solids: ramps, round posts and bodies that push back — `slopes.js`, `circles.js`, `pool.js`, `drum.js`
|
|
409
|
+
|
|
410
|
+
Until 2026-08-27 a solid was always resolved as an axis-aligned box no matter what it looked like, so a round post showed flat faces and a tilted ramp blocked along a phantom horizontal ledge. Now the **solid's** declared shape takes part, which turns four hand-written workarounds into one property.
|
|
411
|
+
|
|
412
|
+
**A ramp is one turned rectangle**, not a staircase of small ones:
|
|
413
|
+
|
|
414
|
+
```javascript
|
|
415
|
+
const ramp = Game.createSprite({
|
|
416
|
+
sheet: wallSheet, x: W * 0.28, y: H * 0.24,
|
|
417
|
+
width: W * 0.30, height: 14,
|
|
418
|
+
rotation: 12,
|
|
419
|
+
hitboxShape: 'rotatedRect', // the box turns with the art
|
|
420
|
+
collisionGroup: 'ramp'
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const crate = Game.createSprite({
|
|
424
|
+
sheet: crateSheet, x: W * 0.2, y: 0,
|
|
425
|
+
gravity: 1400, swept: true,
|
|
426
|
+
solidWith: ['ramp']
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The rider needs nothing new — `'rect'` against a `'rotatedRect'` goes through separating axes on its own, and a circle rider is taken into the ramp's frame. Both settle on the real face and slide. Keep ramps **short**: a rider that slides a long one arrives at the bottom carrying everything the drop gave it and leaves like a projectile, which reads as the ramp firing it.
|
|
431
|
+
|
|
432
|
+
**A round obstacle wants a circle on the solid, not just on the ball.** `hitboxShape: 'circle'` on the mover has been there for a while; putting it on the *peg* is what makes the contact normal run centre to centre:
|
|
433
|
+
|
|
434
|
+
```javascript
|
|
435
|
+
const peg = Game.createSprite({ sheet, x, y, hitboxShape: 'circle', collisionGroup: 'peg' });
|
|
436
|
+
|
|
437
|
+
const chip = Game.createSprite({
|
|
438
|
+
sheet, hitboxShape: 'circle', gravity: 1200,
|
|
439
|
+
restitution: 0.42,
|
|
440
|
+
swept: true, // small pegs, fast chip: no stepping over one
|
|
441
|
+
collisionGroup: 'chip',
|
|
442
|
+
solidWith: ['peg', 'chip'],
|
|
443
|
+
solidMode: 'push' // …and the chips are bodies to each other
|
|
444
|
+
});
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
As bounding boxes every peg is a square with a flat top and the chips stack instead of scattering. That is the whole Plinko board.
|
|
448
|
+
|
|
449
|
+
**Balls that push each other** need agreement on both sides — this is the one setting that fails silently when it is half-configured:
|
|
450
|
+
|
|
451
|
+
```javascript
|
|
452
|
+
const ball = Game.createSprite({
|
|
453
|
+
sheet: ballSheet, hitboxShape: 'circle',
|
|
454
|
+
restitution: 0.96,
|
|
455
|
+
linearDamping: 0.62, // the felt: it trickles to a halt
|
|
456
|
+
swept: true,
|
|
457
|
+
collisionGroup: 'ball',
|
|
458
|
+
solidWith: ['ball', 'rail'], // ← its own group is in there
|
|
459
|
+
solidMode: 'push'
|
|
460
|
+
});
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
Every clause matters: both sprites `'push'`, both circles, and **each listing the other's group**. Drop any one and the pair quietly degrades to one ball shoving an immovable one, which looks like broken momentum and is a missing string. There are no masses and no spin — a marble and a boulder trade velocity identically.
|
|
464
|
+
|
|
465
|
+
**A container is one sprite, not a ring of walls.** `solidMode: 'contain'` keeps matched circles *inside* a circle, analytically, so there are no seams to squeeze through and nothing to place by hand:
|
|
466
|
+
|
|
467
|
+
```javascript
|
|
468
|
+
// sheetless: it collides and contains, and draws nothing — draw the barrel over it
|
|
469
|
+
gameView.add(Game.createSprite({
|
|
470
|
+
x: CX, y: CY, width: DRUM * 0.88, height: DRUM * 0.88,
|
|
471
|
+
touchEnabled: false,
|
|
472
|
+
hitboxShape: 'circle',
|
|
473
|
+
solidMode: 'contain',
|
|
474
|
+
collisionGroup: 'drum',
|
|
475
|
+
debug: true // green circle = exactly where the wall is
|
|
476
|
+
}));
|
|
477
|
+
|
|
478
|
+
// the balls list the drum; the drum lists nothing — containment is one-directional
|
|
479
|
+
const ball = Game.createSprite({
|
|
480
|
+
sheet: ballSheet, hitboxShape: 'circle',
|
|
481
|
+
gravity: 900, restitution: 0.55, linearDamping: 0.35,
|
|
482
|
+
collisionGroup: 'ball', solidWith: ['drum', 'ball'], solidMode: 'push'
|
|
483
|
+
});
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Two things bite here. The sweep **ignores** anything that is not `solidMode: 'block'`, so `swept: true` will not stop a fast enough ball crossing the boundary between frames — keep the speeds inside a container sane. And a `'contain'` solid only holds *circles*: a rect mover runs a resolver that never reads `solidMode` and gets pushed **out** of the boundary instead of held in.
|
|
487
|
+
|
|
488
|
+
**`linearDamping` is the missing friction, with a caveat.** `drag` only ever worked inside `carMode`; this is the same idea for ordinary sprites, and it is what brings a pool table to rest. But nothing checks contact — it bleeds speed on both axes, in the air as much as on the felt, and along a slope as much as across it. Right for a table, wrong for a hill. Below a combined 4 px/s the engine zeroes the velocity outright, so a deliberate 3 px/s drift dies the moment that sprite also carries damping.
|
|
489
|
+
|
|
490
|
+
**A bouncy surface is now a property of the surface.** `restitution` is read off both sides and mixed as `max`, so a springy floor rebounds riders that are not themselves bouncy:
|
|
491
|
+
|
|
492
|
+
```javascript
|
|
493
|
+
addSurface({ color: '#4caf50', restitution: 0.5 }); // the floor and the side walls
|
|
494
|
+
// the ball keeps restitution: 0.1 and still bounces off them at 0.5
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
The cost of `max` is that it cannot be opted out of — a deliberately dead crate bounces off that floor too — and a rebound over 40 px/s takes the reflecting branch, which never sets `onGround` and never fires `land`. A jump gated on `onGround` stops working on the one platform somebody made bouncy.
|
|
498
|
+
|
|
499
|
+
**`gravityX` is the sibling of `gravity`, not half of a vector.** `gravity` keeps its exact vertical meaning; this is a second constant acceleration on X, read natively every tick, so a field of leaves answers a wind change at once with no per-frame JS:
|
|
500
|
+
|
|
501
|
+
```javascript
|
|
502
|
+
leaves.forEach((leaf) => { leaf.gravityX = wind; }); // on a button, not on a timer
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
The other use has nothing to do with wind: a top-down table with `gravity: 0` and a non-zero `gravityX` is a board seen from above with a lean.
|
|
506
|
+
|
|
507
|
+
### Zones you can be inside of (enter / exit) — `zones.js`
|
|
508
|
+
|
|
509
|
+
`collision` and `collisionend` are the two halves of a trigger: entering water, standing on a pressure plate, holding a capture point. Nothing fires while you stay inside — the state lives in JS between the two events, and that is deliberate, since a per-frame "stay" would be bridge traffic every frame.
|
|
510
|
+
|
|
511
|
+
```javascript
|
|
512
|
+
const pool = Game.createSprite({
|
|
513
|
+
x: W * 0.32, y: H * 0.3, width: W * 0.44, height: H * 0.18,
|
|
514
|
+
sheet: waterSheet, tintColor: '#3d6fd4', opacity: 0.35,
|
|
515
|
+
touchEnabled: false, // drags pass through to whatever is inside
|
|
516
|
+
collisionGroup: 'water'
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
hero.collidesWith = ['water'];
|
|
520
|
+
hero.addEventListener('collision', (e) => {
|
|
521
|
+
if (e.group === 'water') {
|
|
522
|
+
hero.tintColor = '#7fb2ff';
|
|
523
|
+
swimming = true;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
hero.addEventListener('collisionend', (e) => {
|
|
527
|
+
if (e.group === 'water') {
|
|
528
|
+
hero.tintColor = null;
|
|
529
|
+
swimming = false;
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
The listener goes on the sprite that declares `collidesWith`. For a pressure plate holding a door open exactly while a crate rests on it, that is the **plate** (`collidesWith: ['crate']`), not the crate — then `collision` opens the door and `collisionend` closes it, with no polling anywhere.
|
|
535
|
+
|
|
536
|
+
Removing the partner from the scene, hiding it (`visible = false`) or changing its `collisionGroup` mid-contact all count as separation, so the exit still fires and the door cannot stick open when an object despawns.
|
|
537
|
+
|
|
538
|
+
### Patrol routes and animation chains — `path.js`
|
|
539
|
+
|
|
540
|
+
A fixed route is `followPath`, not a chain of tween legs. The points are walked natively at `speed` px/s; `rotate: true` turns the sprite to face along the path (heading `0` = up, same convention as `rotation`), and `smoothing` rounds the corners with a radius in px.
|
|
541
|
+
|
|
542
|
+
```javascript
|
|
543
|
+
guard.followPath([
|
|
544
|
+
{ x: W * 0.22, y: H * 0.46 },
|
|
545
|
+
{ x: W * 0.78, y: H * 0.46 },
|
|
546
|
+
{ x: W * 0.78, y: H * 0.60 },
|
|
547
|
+
{ x: W * 0.22, y: H * 0.60 }
|
|
548
|
+
], { speed: W * 0.16, loop: true }); // sharp rectangle, walks forever
|
|
549
|
+
|
|
550
|
+
ship.followPath(diamond, {
|
|
551
|
+
speed: W * 0.35,
|
|
552
|
+
loop: true,
|
|
553
|
+
rotate: true, // nose follows the heading
|
|
554
|
+
smoothing: W * 0.12 // rounds the diamond into an oval
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
A non-looping run fires `pathcomplete` and clears itself; `followPath(null)` stops in place. Two points is the minimum — one point is ignored with a warning, not treated as "go there".
|
|
559
|
+
|
|
560
|
+
The path writes `x`/`y` outright, after velocity and gravity have been integrated, so **a sprite on a path ignores its own physics for placement**. It still feeds the frame delta, though, which means a platform on a looping circuit carries whatever is standing on it — a moving platform is now a path, not a tween ping-pong.
|
|
561
|
+
|
|
562
|
+
Animation chains are native in the same spirit:
|
|
563
|
+
|
|
564
|
+
```javascript
|
|
565
|
+
guard.play('hop', { then: 'walk' }); // one call, no animationcomplete handler
|
|
566
|
+
bird.play('flap', { then: ['glide', 'idle'] });
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Each queued animation starts as the previous non-looping one finishes, and a looping animation ends the chain. `animationcomplete` still fires per step if you want to hook one. `stop()` drops the queue along with the current animation.
|
|
570
|
+
|
|
571
|
+
### Line of sight, ledge probes and hitscan — `raycast.js`
|
|
572
|
+
|
|
573
|
+
`gameView.raycast(x0, y0, x1, y1, groups)` answers "what is the first thing on this line?" with `null` or `{ x, y, distance, group, sprite, normal }`. The targets need nothing but a `collisionGroup` — no `collidesWith` on the asking side.
|
|
574
|
+
|
|
575
|
+
```javascript
|
|
576
|
+
const hit = gameView.raycast(eyeX, eyeY, dog.x, dog.y, ['blocker']);
|
|
577
|
+
if (hit) {
|
|
578
|
+
aimBeam(sight, eyeX, eyeY, hit.x, hit.y, hit.distance);
|
|
579
|
+
sight.tintColor = '#f44'; // view blocked at the impact point
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
Pass the groups as an **array**. Android also accepts loose arguments, iOS does not, so the array is the portable form. Only `visible`, non-`screenFixed` sprites carrying a `collisionGroup` are candidates — a pooled sprite parked at `visible = false` is correctly invisible to the ray, an untagged wall is invisible too (the usual mistake), and HUD sprites are skipped by design so a screen-fixed score never blocks a world-space sight line.
|
|
584
|
+
|
|
585
|
+
A ledge probe is the same call pointed straight down, one step ahead of the feet:
|
|
586
|
+
|
|
587
|
+
```javascript
|
|
588
|
+
const dir = walker.velocityX >= 0 ? 1 : -1;
|
|
589
|
+
const probeX = walker.x + dir * W * 0.07;
|
|
590
|
+
if (!gameView.raycast(probeX, walker.y, probeX, walker.y + W * 0.12, ['ground'])) {
|
|
591
|
+
walker.velocityX = -walker.velocityX; // nothing underfoot: turn before falling
|
|
592
|
+
walker.flipX = walker.velocityX < 0;
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
Run these from a coarse timer or a tap handler — the demo probes at 150 ms — never once per frame. A per-frame `raycast` from JS is exactly the bridge traffic the engine is built to avoid. Note that `raycast.js` drives its probes with `setInterval`: it was written two commits before game-clock timers existed, and `gameView.every` is the better home for an AI tick today.
|
|
597
|
+
|
|
598
|
+
### Pathfinding around obstacles — `maze.js`, `pointclick.js`
|
|
599
|
+
|
|
600
|
+
`gameView.findPath(from, to, options)` is grid A\* over the same sprites `raycast` sees: visible, tagged with a `collisionGroup`, nothing else to set up. It returns `{ x, y }` waypoints — or `null` — and those waypoints go straight into `followPath`, so the walk itself runs natively.
|
|
601
|
+
|
|
602
|
+
```javascript
|
|
603
|
+
gameView.addEventListener('tap', (e) => {
|
|
604
|
+
const path = gameView.findPath(
|
|
605
|
+
{ x: player.x, y: player.y },
|
|
606
|
+
{ x: e.x, y: e.y },
|
|
607
|
+
{ cellSize: TILE, groups: ['wall'], bounds: MAZE_BOUNDS });
|
|
608
|
+
if (!path || path.length < 2) {
|
|
609
|
+
return; // tapped outside the walkable rect
|
|
610
|
+
}
|
|
611
|
+
player.scaleX = path[1].x < player.x ? -1 : 1; // face the first leg
|
|
612
|
+
player.play('walk');
|
|
613
|
+
player.followPath(path, { speed: SPEED });
|
|
614
|
+
});
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
`cellSize` is the one option that decides everything else. On a tile map, pass the tile size and the grid lines up with the walls exactly; on a free-form scene, something near the walker's width. Too coarse and doorways disappear, too fine and the query gets expensive (over ~1M cells it returns `null` rather than allocating).
|
|
618
|
+
|
|
619
|
+
**The path is a line for the sprite's *center*.** A body wider than a cell scrapes corners unless you pass `clearance` — about half the walker's width:
|
|
620
|
+
|
|
621
|
+
```javascript
|
|
622
|
+
const path = gameView.findPath(from, to, {
|
|
623
|
+
cellSize: Math.round(W * 0.04),
|
|
624
|
+
groups: ['obstacle'],
|
|
625
|
+
clearance: PLAYER_W * 0.35, // keep half a body off the trunk
|
|
626
|
+
bounds: WALK_BOUNDS // the walkable floor, in center coordinates
|
|
627
|
+
});
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
`bounds` doubles as the walkable area, which is how the point-&-click floor clamp survives: `WALK_BOUNDS` is the old `WALK_TOP`/margin clamp expressed as a rect, in **sprite-center coordinates** (tap points are feet positions, so subtract half the sprite height before searching).
|
|
631
|
+
|
|
632
|
+
**Obstacles do not have to be the art.** `pointclick.js` blocks only the oak's trunk with an invisible sprite — `opacity: 0`, `touchEnabled: false`, no collision wiring — because `findPath` reads the hitbox, not the pixels. The canopy stays walkable, so the player still passes behind the tree with `ySort` doing the depth:
|
|
633
|
+
|
|
634
|
+
```javascript
|
|
635
|
+
gameView.add(Game.createSprite({
|
|
636
|
+
sheet: treeSheet, opacity: 0, touchEnabled: false,
|
|
637
|
+
x: tree.x, y: groundY, width: TREE_W * 0.35, height: PLAYER_H * 0.8,
|
|
638
|
+
collisionGroup: 'obstacle'
|
|
639
|
+
}));
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
A blocked goal snaps to the nearest free cell a few rings out, so tapping a wall walks to its edge instead of doing nothing. Tapping outside `bounds` is the case worth guarding — that is what `path.length < 2` catches.
|
|
643
|
+
|
|
644
|
+
**Chasing AI is a timer, not a frame loop.** Re-path on a coarse game-clock tick and let `followPath` cover the gap:
|
|
645
|
+
|
|
646
|
+
```javascript
|
|
647
|
+
const chase = gameView.every(800, () => {
|
|
648
|
+
const path = gameView.findPath({ x: hound.x, y: hound.y }, { x: player.x, y: player.y }, PATH_OPTIONS);
|
|
649
|
+
if (path && path.length > 1) {
|
|
650
|
+
hound.followPath(path, { speed: TILE * 2.6 });
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
win.addEventListener('close', () => gameView.cancelTimer(chase));
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`followPath(null)` cancels an in-flight route — `maze.js` uses it to yank the hound back to its den after it catches you.
|
|
657
|
+
|
|
658
|
+
To *see* what A\* did, ask twice: `simplify: false` returns every grid cell it walked, `simplify: true` (the default) the line-of-sight-reduced corners the sprite actually gets. Dropping a dot on each is the whole route visualization in the maze demo.
|
|
659
|
+
|
|
660
|
+
### Timers on the game clock
|
|
661
|
+
|
|
662
|
+
`gameView.after(ms, cb)` and `every(ms, cb)` run on the engine's clock: they stretch under `timeScale` slow motion, freeze at `0`, and pause with the render loop. Spawn waves, AI decision ticks and respawn delays belong here — a paused game that keeps spawning is the bug this fixes.
|
|
663
|
+
|
|
664
|
+
```javascript
|
|
665
|
+
const wave = gameView.every(2000, () => spawnEnemy());
|
|
666
|
+
gameView.after(500, () => player.play('idle'));
|
|
667
|
+
gameView.cancelTimer(wave); // both return an int id
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
The callback receives `{ id }`. Called without a callback, the view fires a `timer` event carrying the same id instead — pick one, not both. A repeating timer fires at most once per frame and restarts its interval after a stall rather than bursting to catch up.
|
|
671
|
+
|
|
672
|
+
What still belongs in `setTimeout`: anything that must keep running while the game is frozen. A pause menu counting down in real seconds is not on the game clock.
|
|
673
|
+
|
|
674
|
+
### Hit-stop on impact
|
|
675
|
+
|
|
676
|
+
Freezing the scene for a few frames makes an impact read before the explosion starts. `timeScale = 0` stops the engine from advancing while it keeps drawing and accepting touches — no render loop to restart, no state to save. (Pattern from Titanium Lander: 140 ms on a crash, 90 ms when a shield absorbs a hit.)
|
|
677
|
+
|
|
678
|
+
```javascript
|
|
679
|
+
let hitStopTimer = null;
|
|
680
|
+
|
|
681
|
+
function hitStop(ms) {
|
|
682
|
+
clearHitStop();
|
|
683
|
+
gameView.timeScale = 0;
|
|
684
|
+
hitStopTimer = setTimeout(() => {
|
|
685
|
+
hitStopTimer = null;
|
|
686
|
+
gameView.timeScale = 1;
|
|
687
|
+
}, ms);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function clearHitStop() {
|
|
691
|
+
if (hitStopTimer !== null) {
|
|
692
|
+
clearTimeout(hitStopTimer);
|
|
693
|
+
hitStopTimer = null;
|
|
694
|
+
}
|
|
695
|
+
gameView.timeScale = 1;
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Restoring the scale must be unconditional: run `clearHitStop()` from reset, from window close and from every teardown path, because a freeze that outlives a retry starts the next round stopped.
|