@maccesar/titools 4.6.1 → 4.8.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 +46 -8
- package/commands/ti-audit.md +1 -0
- package/commands/ti-check.md +3 -2
- package/lib/config.js +1 -0
- package/package.json +1 -1
- package/skills/ti-game/SKILL.md +198 -0
- package/skills/ti-game/references/api.md +408 -0
- package/skills/ti-game/references/project-setup.md +306 -0
- package/skills/ti-game/references/recipes.md +1268 -0
- package/skills/ti-game/references/roadmap.md +57 -0
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
# ti.game recipes by genre
|
|
2
|
+
|
|
3
|
+
Working patterns distilled from the 21 official demos in `example/` and from a shipped Alloy game (Titanium Lander). Each recipe shows the part that is specific to the genre; the shared scaffolding is in [The scaffolding](#the-scaffolding) and is not repeated.
|
|
4
|
+
|
|
5
|
+
Every snippet uses only API that exists in upstream `main` as of 2026-08-18. Check [roadmap.md](roadmap.md) before reaching for anything not shown here.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
- [The scaffolding](#the-scaffolding)
|
|
10
|
+
- [Cross-cutting patterns](#cross-cutting-patterns) — pooling, parallax, multitouch controls, HUD text, invisible triggers, enter/exit zones, hit-stop
|
|
11
|
+
- [Tap-to-flap](#tap-to-flap) (`flappy.js`)
|
|
12
|
+
- [Platformer](#platformer) (`platformer.js`)
|
|
13
|
+
- [Top-down / Zelda](#top-down--zelda) (`topdown.js`)
|
|
14
|
+
- [Top-down racer](#top-down-racer) (`racing.js`)
|
|
15
|
+
- [Asteroids / space shooter](#asteroids--space-shooter) (`asteroids.js`)
|
|
16
|
+
- [Lunar lander](#lunar-lander)
|
|
17
|
+
- [Endless runner](#endless-runner) (`skate.js`)
|
|
18
|
+
- [Rhythm game](#rhythm-game) (`rhythm.js`)
|
|
19
|
+
- [Ball sports / breakout physics](#ball-sports--breakout-physics) (`volley.js`)
|
|
20
|
+
- [Cards and board games](#cards-and-board-games) (`cards.js`, `puzzle.js`)
|
|
21
|
+
- [Point & click adventure](#point--click-adventure) (`pointclick.js`)
|
|
22
|
+
- [Particles](#particles) (`particles.js`)
|
|
23
|
+
- [Ropes and chains](#ropes-and-chains) (`rope.js`)
|
|
24
|
+
- [Camera work](#camera-work) (`camera.js`)
|
|
25
|
+
- [Visual effects](#visual-effects) (`blend.js`, `flip.js`, `timescale.js`)
|
|
26
|
+
|
|
27
|
+
## The scaffolding
|
|
28
|
+
|
|
29
|
+
Every game repeats this shape. Sheets and sounds are created once, outside `resize`, because they are texture handles, not scene objects.
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
const Game = require('ti.game');
|
|
33
|
+
|
|
34
|
+
module.exports = function () {
|
|
35
|
+
|
|
36
|
+
const win = Ti.UI.createWindow({
|
|
37
|
+
backgroundColor: '#000',
|
|
38
|
+
theme: 'Theme.Titanium.DayNight.NoTitleBar'
|
|
39
|
+
});
|
|
40
|
+
const gameView = Game.createGameView({
|
|
41
|
+
backgroundColor: '#8ed8f8',
|
|
42
|
+
maxFps: 60
|
|
43
|
+
// debug: true // draw every collision shape while tuning hitboxes
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const heroSheet = Game.createSpriteSheet({ image: 'assets/hero.png', frameWidth: 64, frameHeight: 64 });
|
|
47
|
+
const jumpSound = Game.createSound({ url: 'assets/jump.wav', volume: 0.8 });
|
|
48
|
+
|
|
49
|
+
let timer = null;
|
|
50
|
+
win.addEventListener('close', () => {
|
|
51
|
+
if (timer !== null) {
|
|
52
|
+
clearInterval(timer);
|
|
53
|
+
timer = null;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let initialized = false;
|
|
58
|
+
gameView.addEventListener('resize', (e) => {
|
|
59
|
+
if (initialized) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
initialized = true;
|
|
63
|
+
init(e.width, e.height);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function init(W, H) {
|
|
67
|
+
// The whole game lives here. Derive every size from W and H so the
|
|
68
|
+
// layout holds on any screen: SPEED = W * 0.35, GRAVITY = H * 2.2.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
win.add(gameView);
|
|
72
|
+
win.open();
|
|
73
|
+
};
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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`.
|
|
77
|
+
|
|
78
|
+
## Cross-cutting patterns
|
|
79
|
+
|
|
80
|
+
### Object pooling
|
|
81
|
+
|
|
82
|
+
Create a fixed set once, recycle forever. `visible = false` also removes the sprite from collision, which is what makes this work.
|
|
83
|
+
|
|
84
|
+
```javascript
|
|
85
|
+
const bullets = [];
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const sprite = Game.createSprite({
|
|
88
|
+
sheet: shipSheet,
|
|
89
|
+
frame: 3,
|
|
90
|
+
width: 8,
|
|
91
|
+
height: 16,
|
|
92
|
+
visible: false,
|
|
93
|
+
wrapAround: true,
|
|
94
|
+
swept: true, // path-tested: a fast bolt cannot skip past a rock
|
|
95
|
+
collidesWith: ['asteroid']
|
|
96
|
+
});
|
|
97
|
+
const state = { sprite: sprite, active: false, generation: 0 };
|
|
98
|
+
|
|
99
|
+
sprite.addEventListener('collision', (e) => {
|
|
100
|
+
if (!state.active) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
deactivate(state);
|
|
104
|
+
e.other.visible = false; // the rock is gone: no render, no collision
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
gameView.add(sprite);
|
|
108
|
+
bullets.push(state);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function deactivate(state) {
|
|
112
|
+
state.active = false;
|
|
113
|
+
state.sprite.visible = false;
|
|
114
|
+
state.sprite.velocityX = 0;
|
|
115
|
+
state.sprite.velocityY = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fire(x, y, vx, vy) {
|
|
119
|
+
const state = bullets.find((b) => !b.active);
|
|
120
|
+
if (!state) {
|
|
121
|
+
return; // pool exhausted — drop the shot
|
|
122
|
+
}
|
|
123
|
+
state.active = true;
|
|
124
|
+
state.generation++;
|
|
125
|
+
const generation = state.generation;
|
|
126
|
+
Object.assign(state.sprite, { x: x, y: y, velocityX: vx, velocityY: vy, visible: true });
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
// burn out — unless this slot was already recycled by a newer shot
|
|
129
|
+
if (state.active && state.generation === generation) {
|
|
130
|
+
deactivate(state);
|
|
131
|
+
}
|
|
132
|
+
}, 1200);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The `generation` counter matters: without it, a timeout fired for an old shot deactivates the *new* bullet occupying that slot.
|
|
137
|
+
|
|
138
|
+
`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` for fairness tuning.
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
### Seamless parallax
|
|
143
|
+
|
|
144
|
+
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.
|
|
145
|
+
|
|
146
|
+
```javascript
|
|
147
|
+
function makeLayer(sheet, y, height, speed, z) {
|
|
148
|
+
const copies = [];
|
|
149
|
+
for (let i = 0; i < 2; i++) {
|
|
150
|
+
const s = Game.createSprite({
|
|
151
|
+
sheet: sheet,
|
|
152
|
+
x: W / 2 + i * W,
|
|
153
|
+
y: y,
|
|
154
|
+
width: W,
|
|
155
|
+
height: height,
|
|
156
|
+
zIndex: z,
|
|
157
|
+
tileRepeat: 'x', // keeps texture density; needs repeat: true on the sheet
|
|
158
|
+
wrapX: -W / 2,
|
|
159
|
+
wrapShift: 2 * W
|
|
160
|
+
});
|
|
161
|
+
gameView.add(s);
|
|
162
|
+
copies.push(s);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
setSpeed: (v) => copies.forEach((copy) => {
|
|
166
|
+
copy.velocityX = -v;
|
|
167
|
+
})
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const skyline = makeLayer(skylineSheet, groundTop - SKY_H / 2, SKY_H, SPEED * 0.12, 1);
|
|
172
|
+
const street = makeLayer(streetSheet, (groundTop + H) / 2, H - groundTop, SPEED, 8);
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
### Multitouch on-screen controls
|
|
178
|
+
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
```javascript
|
|
182
|
+
function makeButton(title, position, fontSize) {
|
|
183
|
+
const button = Ti.UI.createLabel({
|
|
184
|
+
text: title,
|
|
185
|
+
textAlign: 'center',
|
|
186
|
+
color: '#fff',
|
|
187
|
+
font: { fontSize: fontSize || 30, fontWeight: 'bold' },
|
|
188
|
+
backgroundColor: '#59000000',
|
|
189
|
+
borderRadius: 40,
|
|
190
|
+
width: '80dp',
|
|
191
|
+
height: '80dp',
|
|
192
|
+
bottom: '24dp'
|
|
193
|
+
});
|
|
194
|
+
// Manual press feedback: touchFeedback's ripple cannot animate over this
|
|
195
|
+
// canvas and spams RippleDrawable errors on Android.
|
|
196
|
+
button.addEventListener('touchstart', () => {
|
|
197
|
+
button.backgroundColor = '#8c000000';
|
|
198
|
+
});
|
|
199
|
+
['touchend', 'touchcancel'].forEach((event) => {
|
|
200
|
+
button.addEventListener(event, () => {
|
|
201
|
+
button.backgroundColor = '#59000000';
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
Object.assign(button, position);
|
|
205
|
+
return button;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function bindHold(button, apply, release) {
|
|
209
|
+
button.addEventListener('touchstart', apply);
|
|
210
|
+
['touchend', 'touchcancel'].forEach((event) => {
|
|
211
|
+
button.addEventListener(event, release);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const leftButton = makeButton('◀', { left: '24dp' });
|
|
216
|
+
bindHold(leftButton, () => {
|
|
217
|
+
car.steering = -1;
|
|
218
|
+
}, () => {
|
|
219
|
+
if (car.steering === -1) { // don't cancel the other button's input
|
|
220
|
+
car.steering = 0;
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
### HUD — `text.js`, `flappy.js`
|
|
228
|
+
|
|
229
|
+
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.
|
|
230
|
+
|
|
231
|
+
```javascript
|
|
232
|
+
// Created outside resize (no size known yet), positioned once it is
|
|
233
|
+
const scoreLabel = Game.createText({ text: '0', screenFixed: true, zIndex: 100 });
|
|
234
|
+
const statusLabel = Game.createText({ text: 'Tap to start!', screenFixed: true, zIndex: 100 });
|
|
235
|
+
|
|
236
|
+
function buildLevel(W, H) {
|
|
237
|
+
const TEXT_SCALE = Math.max(1, Math.round(W / 240)); // integer: crisp pixels
|
|
238
|
+
Object.assign(scoreLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.08 });
|
|
239
|
+
Object.assign(statusLabel, { scale: TEXT_SCALE, x: W / 2, y: H * 0.45 });
|
|
240
|
+
gameView.add([scoreLabel, statusLabel]);
|
|
241
|
+
// ...
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function score() {
|
|
245
|
+
points += 1;
|
|
246
|
+
scoreLabel.text = points + ' / ' + GATE_COUNT;
|
|
247
|
+
scoreLabel.flash('#4dff88', 250); // text flashes like any sprite
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
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.
|
|
252
|
+
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+
Drop `screenFixed` and the same label becomes world furniture: signposts, floating damage numbers, a name over an NPC — it scrolls with everything else.
|
|
256
|
+
|
|
257
|
+
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.
|
|
258
|
+
|
|
259
|
+
### Invisible triggers
|
|
260
|
+
|
|
261
|
+
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.
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
// score zone in the gap between two pipes
|
|
265
|
+
gameView.add(Game.createSprite({ x: gateX, y: gapCenter, width: 20, height: GAP, collisionGroup: 'score' }));
|
|
266
|
+
|
|
267
|
+
// invisible ceiling so the player cannot escape upward
|
|
268
|
+
gameView.add(Game.createSprite({ x: W / 2, y: -20, width: W * 3, height: 40, collisionGroup: 'ceiling' }));
|
|
269
|
+
|
|
270
|
+
// side walls
|
|
271
|
+
[-20, W + 20].forEach((x) => {
|
|
272
|
+
gameView.add(Game.createSprite({ x: x, y: H / 2, width: 40, height: H * 3, collisionGroup: 'wall' }));
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Attach your own metadata to a sprite and read it back from `e.other` in the handler:
|
|
277
|
+
|
|
278
|
+
```javascript
|
|
279
|
+
const zone = Game.createSprite({ x: cp.x, y: cp.y, width: cp.w, height: cp.h, collisionGroup: 'checkpoint' });
|
|
280
|
+
zone.cpIndex = index;
|
|
281
|
+
|
|
282
|
+
car.addEventListener('collision', (e) => {
|
|
283
|
+
if (e.group === 'checkpoint') {
|
|
284
|
+
visited[e.other.cpIndex] = true;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Zones you can be inside of (enter / exit) — `zones.js`
|
|
290
|
+
|
|
291
|
+
`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.
|
|
292
|
+
|
|
293
|
+
```javascript
|
|
294
|
+
const pool = Game.createSprite({
|
|
295
|
+
x: W * 0.32, y: H * 0.3, width: W * 0.44, height: H * 0.18,
|
|
296
|
+
sheet: waterSheet, tintColor: '#3d6fd4', opacity: 0.35,
|
|
297
|
+
touchEnabled: false, // drags pass through to whatever is inside
|
|
298
|
+
collisionGroup: 'water'
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
hero.collidesWith = ['water'];
|
|
302
|
+
hero.addEventListener('collision', (e) => {
|
|
303
|
+
if (e.group === 'water') {
|
|
304
|
+
hero.tintColor = '#7fb2ff';
|
|
305
|
+
swimming = true;
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
hero.addEventListener('collisionend', (e) => {
|
|
309
|
+
if (e.group === 'water') {
|
|
310
|
+
hero.tintColor = null;
|
|
311
|
+
swimming = false;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
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.
|
|
317
|
+
|
|
318
|
+
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.
|
|
319
|
+
|
|
320
|
+
### Hit-stop on impact
|
|
321
|
+
|
|
322
|
+
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.)
|
|
323
|
+
|
|
324
|
+
```javascript
|
|
325
|
+
let hitStopTimer = null;
|
|
326
|
+
|
|
327
|
+
function hitStop(ms) {
|
|
328
|
+
clearHitStop();
|
|
329
|
+
gameView.timeScale = 0;
|
|
330
|
+
hitStopTimer = setTimeout(() => {
|
|
331
|
+
hitStopTimer = null;
|
|
332
|
+
gameView.timeScale = 1;
|
|
333
|
+
}, ms);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function clearHitStop() {
|
|
337
|
+
if (hitStopTimer !== null) {
|
|
338
|
+
clearTimeout(hitStopTimer);
|
|
339
|
+
hitStopTimer = null;
|
|
340
|
+
}
|
|
341
|
+
gameView.timeScale = 1;
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
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.
|
|
346
|
+
|
|
347
|
+
## Tap-to-flap
|
|
348
|
+
|
|
349
|
+
Gravity plus an impulse per tap. Scoring and death are collisions with invisible zones. The rotation tween is what sells the arc.
|
|
350
|
+
|
|
351
|
+
```javascript
|
|
352
|
+
const pig = Game.createSprite({
|
|
353
|
+
sheet: pigSheet,
|
|
354
|
+
x: W * 0.28,
|
|
355
|
+
y: H * 0.42,
|
|
356
|
+
width: PIG_SIZE,
|
|
357
|
+
height: PIG_SIZE,
|
|
358
|
+
zIndex: 10,
|
|
359
|
+
hitboxScale: 0.7, // the art does not fill the frame
|
|
360
|
+
collidesWith: ['pipe', 'ground', 'score', 'ceiling'],
|
|
361
|
+
animations: { fly: { frames: [0, 1, 2, 1], fps: 10, loop: true } }
|
|
362
|
+
});
|
|
363
|
+
pig.play('fly');
|
|
364
|
+
|
|
365
|
+
gameView.addEventListener('press', () => {
|
|
366
|
+
if (over) {
|
|
367
|
+
reset();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (!started) {
|
|
371
|
+
start(); // first tap turns gravity on
|
|
372
|
+
}
|
|
373
|
+
pig.velocityY = FLAP; // e.g. -H * 0.6
|
|
374
|
+
pig.clearTweens();
|
|
375
|
+
pig.rotation = -15; // snap the nose up...
|
|
376
|
+
pig.animate({ rotation: 25, duration: 900, easing: Game.EASE_IN }); // ...then tip forward
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
pig.addEventListener('collision', (e) => {
|
|
380
|
+
if (over) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (e.group === 'score') {
|
|
384
|
+
score++;
|
|
385
|
+
} else if (e.group === 'ceiling') {
|
|
386
|
+
pig.velocityY = H * 0.1; // gentle push back down instead of a hard stop
|
|
387
|
+
} else {
|
|
388
|
+
gameOver();
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Obstacles are pooled gates: three sprites each (top pipe, bottom pipe, invisible score zone) that scroll left on `velocityX` and get repositioned when they leave the screen. Freezing the game means setting every scroller's `velocityX = 0` and the player's `gravity = 0` — the engine has no global "stop", but `gameView.timeScale = 0` is one if you want the scene frozen mid-flight.
|
|
394
|
+
|
|
395
|
+
## Platformer
|
|
396
|
+
|
|
397
|
+
`solidWith` handles the blocking; you only decide when to jump.
|
|
398
|
+
|
|
399
|
+
```javascript
|
|
400
|
+
const player = Game.createSprite({
|
|
401
|
+
sheet: playerSheet,
|
|
402
|
+
x: W * 0.15,
|
|
403
|
+
y: groundTop - PLAYER_SIZE / 2,
|
|
404
|
+
width: PLAYER_SIZE,
|
|
405
|
+
height: PLAYER_SIZE,
|
|
406
|
+
zIndex: 10,
|
|
407
|
+
gravity: GRAVITY, // H * 2.2
|
|
408
|
+
hitboxScale: 0.85,
|
|
409
|
+
solidWith: ['solid', 'trampoline'],
|
|
410
|
+
animations: {
|
|
411
|
+
idle: { frames: [0], fps: 1, loop: true },
|
|
412
|
+
walk: { frames: [1, 2], fps: 8, loop: true }
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
gameView.follow(player, { topMargin: 0.33, bottomMargin: 0.7, maxY: 0 });
|
|
417
|
+
|
|
418
|
+
let moveDir = 0;
|
|
419
|
+
|
|
420
|
+
function applyMovement() {
|
|
421
|
+
player.velocityX = moveDir * RUN_SPEED;
|
|
422
|
+
if (moveDir !== 0) {
|
|
423
|
+
player.scaleX = moveDir; // face the running direction
|
|
424
|
+
}
|
|
425
|
+
if (player.onGround) {
|
|
426
|
+
player.play(moveDir !== 0 ? 'walk' : 'idle');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function jump() {
|
|
431
|
+
if (player.onGround) { // gate on the engine's own flag
|
|
432
|
+
player.velocityY = -JUMP; // H * 0.95 ≈ 0.2 * H of height
|
|
433
|
+
player.stop();
|
|
434
|
+
player.frame = 3; // hold the jump pose
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
player.addEventListener('land', (e) => {
|
|
439
|
+
if (e.group === 'trampoline') {
|
|
440
|
+
player.velocityY = -JUMP * 1.1; // bounce higher than a jump
|
|
441
|
+
player.stop();
|
|
442
|
+
player.frame = 3;
|
|
443
|
+
e.other.frame = 1; // squash the mat you landed on
|
|
444
|
+
setTimeout(() => {
|
|
445
|
+
e.other.frame = 0;
|
|
446
|
+
}, 150);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
applyMovement(); // back from the jump pose
|
|
450
|
+
});
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Jump height is `JUMP² / (2 × GRAVITY)`. Space your platforms comfortably below it — the demo uses steps of `0.14 * H` against a `0.2 * H` jump.
|
|
454
|
+
|
|
455
|
+
**One-way platforms**: `oneWay: true` on the solid lets riders jump up through it and land on top, never blocking sideways or from below. This is what makes a staircase climbable.
|
|
456
|
+
|
|
457
|
+
**Moving platforms carry riders for free** — a solid moved by `velocityX` or a tween drags whoever stands on it, with no re-landing jitter:
|
|
458
|
+
|
|
459
|
+
```javascript
|
|
460
|
+
const mover = Game.createSprite({
|
|
461
|
+
sheet: moverSheet, x: W * 0.18, y: groundTop - STEP * 2,
|
|
462
|
+
width: W * 0.26, height: PLAT_H, collisionGroup: 'solid'
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
function patrol() {
|
|
466
|
+
mover.animate({
|
|
467
|
+
x: (mover.x < W * 0.35) ? W * 0.5 : W * 0.18,
|
|
468
|
+
duration: 2600,
|
|
469
|
+
easing: Game.EASE_IN_OUT
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
mover.addEventListener('complete', patrol); // ping-pong forever
|
|
473
|
+
patrol();
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Set `carryRiders: false` on terrain that scrolls under a player who should stay put (endless runners).
|
|
477
|
+
|
|
478
|
+
## Top-down / Zelda
|
|
479
|
+
|
|
480
|
+
A tile map from a string array, plus `ySort` for depth. Only water is solid; trees are pure decoration you can walk behind.
|
|
481
|
+
|
|
482
|
+
```javascript
|
|
483
|
+
const MAP = [
|
|
484
|
+
'WWWWWWWWWWWW',
|
|
485
|
+
'WGGGGGGGGGGW',
|
|
486
|
+
'WGGFGGGPGGGW',
|
|
487
|
+
'WWWWWWWWWWWW'
|
|
488
|
+
];
|
|
489
|
+
const FRAMES = { G: 0, F: 1, P: 2, W: 3 };
|
|
490
|
+
const COLS = MAP[0].length;
|
|
491
|
+
const ROWS = MAP.length;
|
|
492
|
+
const TILE = Math.floor(Math.min(W / COLS, (H - buttonZone) / ROWS));
|
|
493
|
+
const ox = (W - COLS * TILE) / 2;
|
|
494
|
+
const oy = (H - buttonZone - ROWS * TILE) / 2;
|
|
495
|
+
|
|
496
|
+
const level = [];
|
|
497
|
+
for (let row = 0; row < ROWS; row++) {
|
|
498
|
+
for (let col = 0; col < COLS; col++) {
|
|
499
|
+
const kind = MAP[row].charAt(col);
|
|
500
|
+
level.push(Game.createSprite({
|
|
501
|
+
sheet: tileSheet,
|
|
502
|
+
frame: FRAMES[kind],
|
|
503
|
+
x: ox + (col + 0.5) * TILE,
|
|
504
|
+
y: oy + (row + 0.5) * TILE,
|
|
505
|
+
width: TILE,
|
|
506
|
+
height: TILE,
|
|
507
|
+
zIndex: 0,
|
|
508
|
+
collisionGroup: (kind === 'W') ? 'solid' : null // only water blocks
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
gameView.add(level); // one bridge crossing
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
This is fine at 12×15 tiles and dead at 200×200 — one sprite per tile. A native tilemap layer is on the roadmap; until then, build large worlds from a few big `tileRepeat` sprites plus sparse solids.
|
|
516
|
+
|
|
517
|
+
Depth sorting: give the player, trees and buildings the **same `zIndex`** with `ySort: true`, and keep ground tiles on a lower `zIndex`. The engine then orders them by bottom edge.
|
|
518
|
+
|
|
519
|
+
```javascript
|
|
520
|
+
const player = Game.createSprite({
|
|
521
|
+
sheet: walkerSheet,
|
|
522
|
+
x: start.x, y: start.y, width: TILE, height: TILE,
|
|
523
|
+
zIndex: 5,
|
|
524
|
+
ySort: true,
|
|
525
|
+
hitboxScale: 0.6, // top-down games feel best with a small body box
|
|
526
|
+
solidWith: ['solid'],
|
|
527
|
+
animations: {
|
|
528
|
+
down: { frames: [0, 1], fps: 6, loop: true },
|
|
529
|
+
up: { frames: [2, 3], fps: 6, loop: true },
|
|
530
|
+
side: { frames: [4, 5], fps: 6, loop: true }
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
8-way movement from four buttons, tracking facing yourself because `animation` keeps its value after `stop()`:
|
|
536
|
+
|
|
537
|
+
```javascript
|
|
538
|
+
let moveX = 0;
|
|
539
|
+
let moveY = 0;
|
|
540
|
+
let lastFacing = 'down';
|
|
541
|
+
let walking = false;
|
|
542
|
+
|
|
543
|
+
function applyMovement() {
|
|
544
|
+
player.velocityX = moveX * SPEED;
|
|
545
|
+
player.velocityY = moveY * SPEED;
|
|
546
|
+
if (moveX === 0 && moveY === 0) {
|
|
547
|
+
player.stop();
|
|
548
|
+
walking = false;
|
|
549
|
+
player.frame = { down: 0, up: 2, side: 4 }[lastFacing];
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
const facing = (moveX !== 0) ? 'side' : (moveY < 0 ? 'up' : 'down');
|
|
553
|
+
if (moveX !== 0) {
|
|
554
|
+
player.scaleX = moveX; // side frames face right; mirror for left
|
|
555
|
+
}
|
|
556
|
+
if (!walking || facing !== lastFacing) {
|
|
557
|
+
player.play(facing);
|
|
558
|
+
walking = true;
|
|
559
|
+
}
|
|
560
|
+
lastFacing = facing;
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
**NPC on a decision timer.** The AI thinks every 100 ms; the walking itself is native velocity between ticks. This is the sanctioned use of a timer — decisions, not motion.
|
|
565
|
+
|
|
566
|
+
```javascript
|
|
567
|
+
timer = setInterval(() => {
|
|
568
|
+
const dx = player.x - dog.x;
|
|
569
|
+
const dy = player.y - dog.y;
|
|
570
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
571
|
+
if (dist > FOLLOW_DISTANCE) {
|
|
572
|
+
dog.velocityX = (dx / dist) * DOG_SPEED;
|
|
573
|
+
dog.velocityY = (dy / dist) * DOG_SPEED;
|
|
574
|
+
if (Math.abs(dx) > 2) {
|
|
575
|
+
dog.scaleX = (dx > 0) ? 1 : -1;
|
|
576
|
+
}
|
|
577
|
+
setDogState('walk');
|
|
578
|
+
} else {
|
|
579
|
+
dog.velocityX = 0;
|
|
580
|
+
dog.velocityY = 0;
|
|
581
|
+
setDogState('sit');
|
|
582
|
+
}
|
|
583
|
+
}, 100);
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
## Top-down racer
|
|
587
|
+
|
|
588
|
+
`carMode` gives you throttle, steering and emergent drift. You supply the track and the lap logic.
|
|
589
|
+
|
|
590
|
+
```javascript
|
|
591
|
+
const car = Game.createSprite({
|
|
592
|
+
sheet: carSheet,
|
|
593
|
+
x: W * 0.38,
|
|
594
|
+
y: laneY,
|
|
595
|
+
width: CAR_SIZE,
|
|
596
|
+
height: CAR_SIZE,
|
|
597
|
+
pixelSnap: true, // stable texel phase for pixel art in motion
|
|
598
|
+
rotation: 90, // facing right along the bottom straight
|
|
599
|
+
zIndex: 10,
|
|
600
|
+
hitboxScale: 0.75,
|
|
601
|
+
carMode: true,
|
|
602
|
+
enginePower: MAX_SPEED * 1.1,
|
|
603
|
+
maxSpeed: MAX_SPEED,
|
|
604
|
+
turnRate: 210,
|
|
605
|
+
grip: 3.2, // below the default 4 → drifts in fast corners
|
|
606
|
+
drag: 0.8,
|
|
607
|
+
skidMarks: true,
|
|
608
|
+
restitution: 0.3, // bump off the barriers instead of sticking
|
|
609
|
+
solidWith: ['wall'],
|
|
610
|
+
collidesWith: ['checkpoint', 'goal']
|
|
611
|
+
});
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Controls write `throttle` and `steering` once per touch — never per frame:
|
|
615
|
+
|
|
616
|
+
```javascript
|
|
617
|
+
bindHold(gasButton, () => {
|
|
618
|
+
car.throttle = 1;
|
|
619
|
+
}, () => {
|
|
620
|
+
if (car.throttle === 1) {
|
|
621
|
+
car.throttle = 0;
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
bindHold(brakeButton, () => {
|
|
625
|
+
car.throttle = -1;
|
|
626
|
+
}, () => {
|
|
627
|
+
if (car.throttle === -1) {
|
|
628
|
+
car.throttle = 0;
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
Track and laps: the visible track is one sprite; the collision geometry is invisible rectangles (outer border, inner island). Checkpoints are strips across each lane, and the goal only counts a lap once all of them were visited — that is what stops a player from reversing over the finish line.
|
|
634
|
+
|
|
635
|
+
```javascript
|
|
636
|
+
car.addEventListener('collision', (e) => {
|
|
637
|
+
if (e.group === 'checkpoint') {
|
|
638
|
+
visited[e.other.cpIndex] = true;
|
|
639
|
+
} else if (e.group === 'goal' && Object.keys(visited).length === 3) {
|
|
640
|
+
laps++;
|
|
641
|
+
visited = {};
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
Tuning: lower `grip` = more drift; `turnRate` is degrees per second at full steering and full speed; `drag` decides how fast the car coasts to a stop. Read-only `drifting` is the hook for a tire-squeal sound.
|
|
647
|
+
|
|
648
|
+
## Asteroids / space shooter
|
|
649
|
+
|
|
650
|
+
Newtonian flight: `thrust` accelerates along the heading, `angularVelocity` turns, `wrapAround` re-enters from the opposite edge, momentum does the rest.
|
|
651
|
+
|
|
652
|
+
```javascript
|
|
653
|
+
const ship = Game.createSprite({
|
|
654
|
+
sheet: shipSheet,
|
|
655
|
+
x: W / 2, y: H / 2, width: SHIP_SIZE, height: SHIP_SIZE,
|
|
656
|
+
zIndex: 10,
|
|
657
|
+
maxSpeed: MAX_SPEED,
|
|
658
|
+
wrapAround: true,
|
|
659
|
+
hitboxScale: 0.6,
|
|
660
|
+
hitboxShape: 'circle',
|
|
661
|
+
collidesWith: ['asteroid'],
|
|
662
|
+
animations: { thrust: { frames: [1, 2], fps: 14, loop: true } } // flickering flame
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
bindHold(thrustButton, () => {
|
|
666
|
+
ship.thrust = THRUST; // px/s² along the heading
|
|
667
|
+
ship.play('thrust');
|
|
668
|
+
thrustSound.play(); // created with loop: true
|
|
669
|
+
}, () => {
|
|
670
|
+
ship.thrust = 0;
|
|
671
|
+
ship.stop();
|
|
672
|
+
ship.frame = 0;
|
|
673
|
+
thrustSound.stop();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
bindHold(leftButton, () => {
|
|
677
|
+
ship.angularVelocity = -TURN_SPEED;
|
|
678
|
+
}, () => {
|
|
679
|
+
if (ship.angularVelocity < 0) {
|
|
680
|
+
ship.angularVelocity = 0;
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
Firing from the nose, inheriting the ship's momentum — with `rotation` in degrees clockwise and y pointing down, the heading vector is `(sin θ, -cos θ)`:
|
|
686
|
+
|
|
687
|
+
```javascript
|
|
688
|
+
const rad = ship.rotation * Math.PI / 180;
|
|
689
|
+
const dirX = Math.sin(rad);
|
|
690
|
+
const dirY = -Math.cos(rad);
|
|
691
|
+
bullet.x = ship.x + dirX * SHIP_SIZE * 0.55;
|
|
692
|
+
bullet.y = ship.y + dirY * SHIP_SIZE * 0.55;
|
|
693
|
+
bullet.rotation = ship.rotation;
|
|
694
|
+
bullet.velocityX = ship.velocityX + dirX * BULLET_SPEED;
|
|
695
|
+
bullet.velocityY = ship.velocityY + dirY * BULLET_SPEED;
|
|
696
|
+
bullet.visible = true;
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
Give bolts `blend: 'add'` so they glow over the starfield. Autofire is a `setInterval` on the fire button — a cadence decision, not motion.
|
|
700
|
+
|
|
701
|
+
Damage feedback and invulnerability:
|
|
702
|
+
|
|
703
|
+
```javascript
|
|
704
|
+
let invulnerableUntil = 0;
|
|
705
|
+
|
|
706
|
+
ship.addEventListener('collision', (e) => {
|
|
707
|
+
if (e.group !== 'asteroid' || Date.now() < invulnerableUntil) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
lives--;
|
|
711
|
+
explodeSound.play();
|
|
712
|
+
ship.flash('#ff5252', 400); // native red damage flash
|
|
713
|
+
invulnerableUntil = Date.now() + 1500;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// last-life warning: one JS timer, the fade itself runs in the engine
|
|
717
|
+
warnTimer = setInterval(() => {
|
|
718
|
+
ship.flash('#ff5252', 350);
|
|
719
|
+
}, 700);
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Rocks are round (`hitboxShape: 'circle'`), drift on random velocities, spin on `angularVelocity`, and are recycled by `visible = false` rather than removed.
|
|
723
|
+
|
|
724
|
+
## Lunar lander
|
|
725
|
+
|
|
726
|
+
Same flight model as Asteroids, with gravity on and a landing test. There is no "landing" event — you judge the touchdown yourself in the `land` handler, where velocities are still readable.
|
|
727
|
+
|
|
728
|
+
```javascript
|
|
729
|
+
const lander = Game.createSprite({
|
|
730
|
+
sheet: shipSheet,
|
|
731
|
+
x: W / 2, y: H * 0.2, width: SHIP, height: SHIP,
|
|
732
|
+
zIndex: 10,
|
|
733
|
+
gravity: world.gravity, // per-world, e.g. H * 0.35 for the Moon
|
|
734
|
+
maxSpeed: MAX_SPEED,
|
|
735
|
+
hitboxScale: 0.8,
|
|
736
|
+
solidWith: ['terrain', 'pad'],
|
|
737
|
+
collidesWith: ['hazard']
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
const MAX_LANDING_SPEED = H * 0.18;
|
|
741
|
+
const MAX_LANDING_TILT = 12; // degrees
|
|
742
|
+
|
|
743
|
+
lander.addEventListener('land', (e) => {
|
|
744
|
+
const speed = Math.abs(lander.velocityY);
|
|
745
|
+
const tilt = Math.abs(((lander.rotation % 360) + 540) % 360 - 180);
|
|
746
|
+
const onPad = e.group === 'pad';
|
|
747
|
+
|
|
748
|
+
if (onPad && speed <= MAX_LANDING_SPEED && tilt <= MAX_LANDING_TILT) {
|
|
749
|
+
succeed();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
crash();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
function crash() {
|
|
756
|
+
hitStop(140); // see the hit-stop pattern
|
|
757
|
+
lander.flash('#ff5252', 400);
|
|
758
|
+
explosion.x = lander.x;
|
|
759
|
+
explosion.y = lander.y;
|
|
760
|
+
explosion.emit(30);
|
|
761
|
+
gameView.shake({ strength: SHIP * 0.5, duration: 450 });
|
|
762
|
+
crashSound.play();
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Thrust burns fuel on the same coarse tick that reads the controls — 80 ms is fast enough to feel responsive and slow enough to stay off the bridge:
|
|
767
|
+
|
|
768
|
+
```javascript
|
|
769
|
+
timer = setInterval(() => {
|
|
770
|
+
if (mainThrusterHeld && fuel > 0) {
|
|
771
|
+
fuel -= BURN_RATE;
|
|
772
|
+
lander.thrust = THRUST;
|
|
773
|
+
exhaust.emitting = true;
|
|
774
|
+
} else {
|
|
775
|
+
lander.thrust = 0;
|
|
776
|
+
exhaust.emitting = false;
|
|
777
|
+
}
|
|
778
|
+
fuelLabel.text = Math.max(0, Math.round(fuel)) + '%';
|
|
779
|
+
}, 80);
|
|
780
|
+
```
|
|
781
|
+
|
|
782
|
+
Wind is just a horizontal acceleration you re-apply on that same tick by nudging `velocityX`; per-world gravity, drag and wind are plain numbers in a catalog object.
|
|
783
|
+
|
|
784
|
+
## Endless runner
|
|
785
|
+
|
|
786
|
+
The player never moves horizontally. Gravity plus an invisible ground solid handle the jump arc; the world scrolls past at the same speed as the obstacles, so they look glued to the street.
|
|
787
|
+
|
|
788
|
+
```javascript
|
|
789
|
+
const player = Game.createSprite({
|
|
790
|
+
sheet: skaterSheet,
|
|
791
|
+
x: W * 0.24,
|
|
792
|
+
y: groundTop - PLAYER / 2,
|
|
793
|
+
width: PLAYER, height: PLAYER,
|
|
794
|
+
zIndex: 10,
|
|
795
|
+
gravity: GRAVITY,
|
|
796
|
+
hitboxScale: 0.9,
|
|
797
|
+
solidWith: ['ground', 'platform'],
|
|
798
|
+
collidesWith: ['wall', 'pit'],
|
|
799
|
+
animations: { roll: { frames: [0, 1], fps: 6, loop: true } }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
function jump() {
|
|
803
|
+
if (!over && player.onGround) {
|
|
804
|
+
jumpSound.play();
|
|
805
|
+
dust.emitting = false; // no dust while airborne
|
|
806
|
+
player.velocityY = -JUMP;
|
|
807
|
+
player.stop();
|
|
808
|
+
player.frame = 2; // tuck pose
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
player.addEventListener('land', () => {
|
|
813
|
+
if (!over) {
|
|
814
|
+
player.play('roll');
|
|
815
|
+
dust.emitting = true;
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
Spawning is a self-rescheduling timeout, so the gap can vary and a long obstacle can push the next spawn further out:
|
|
821
|
+
|
|
822
|
+
```javascript
|
|
823
|
+
function scheduleSpawn() {
|
|
824
|
+
spawnTimer = setTimeout(() => {
|
|
825
|
+
spawnObstacle();
|
|
826
|
+
scheduleSpawn();
|
|
827
|
+
}, 1100 + Math.random() * 800 + extraDelay);
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Raised road sections are solids the player jumps onto, with `carryRiders: false` (the road scrolls, the skater must not be dragged with it) plus an invisible trigger on the front face so rolling into the step is a crash rather than the solid shoving the player off screen.
|
|
832
|
+
|
|
833
|
+
Crash sequence: stop the timers, zero gravity, freeze every scroller, switch to the wipeout frame, fire a spark burst, shake the camera.
|
|
834
|
+
|
|
835
|
+
```javascript
|
|
836
|
+
function crash(group) {
|
|
837
|
+
over = true;
|
|
838
|
+
music.stop();
|
|
839
|
+
crashSound.play();
|
|
840
|
+
clearTimers();
|
|
841
|
+
player.gravity = 0;
|
|
842
|
+
player.velocityY = 0;
|
|
843
|
+
player.stop();
|
|
844
|
+
player.frame = 3;
|
|
845
|
+
layers.forEach((layer) => layer.setSpeed(0));
|
|
846
|
+
setObstacleSpeed(0);
|
|
847
|
+
dust.emitting = false;
|
|
848
|
+
sparks.x = player.x;
|
|
849
|
+
sparks.y = player.y;
|
|
850
|
+
sparks.emit(26);
|
|
851
|
+
gameView.shake({ strength: PLAYER * 0.12, duration: 450 });
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
## Rhythm game
|
|
856
|
+
|
|
857
|
+
Notes fall on native velocity; JS only handles presses and a spawn timer. Pads use `press`, not `tap` — a rhythm game cannot wait for the finger to lift.
|
|
858
|
+
|
|
859
|
+
```javascript
|
|
860
|
+
const pads = LANES.map((x, lane) => {
|
|
861
|
+
const pad = Game.createSprite({ sheet: padSheet, x: x, y: PAD_Y, width: PAD, height: PAD, zIndex: 5 });
|
|
862
|
+
pad.addEventListener('press', () => hitLane(lane, pad));
|
|
863
|
+
gameView.add(pad);
|
|
864
|
+
return pad;
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// gems must not swallow pad presses
|
|
868
|
+
const sprite = Game.createSprite({
|
|
869
|
+
sheet: noteSheet, width: NOTE, height: NOTE, zIndex: 10,
|
|
870
|
+
visible: false,
|
|
871
|
+
touchEnabled: false,
|
|
872
|
+
collidesWith: ['misszone']
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
function hitLane(lane, pad) {
|
|
876
|
+
pad.frame = 1;
|
|
877
|
+
setTimeout(() => {
|
|
878
|
+
pad.frame = 0;
|
|
879
|
+
}, 120);
|
|
880
|
+
|
|
881
|
+
let best = null;
|
|
882
|
+
let bestDistance = Infinity;
|
|
883
|
+
notes.forEach((note) => {
|
|
884
|
+
if (note.active && note.lane === lane) {
|
|
885
|
+
const distance = Math.abs(note.sprite.y - PAD_Y); // live native read
|
|
886
|
+
if (distance < bestDistance) {
|
|
887
|
+
bestDistance = distance;
|
|
888
|
+
best = note;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
if (best !== null && bestDistance <= HIT_RANGE) {
|
|
894
|
+
recycle(best);
|
|
895
|
+
goodSound.play();
|
|
896
|
+
burst.x = LANES[lane];
|
|
897
|
+
burst.y = PAD_Y;
|
|
898
|
+
burst.tint = LANE_TINTS[lane];
|
|
899
|
+
burst.emit(18);
|
|
900
|
+
score += (bestDistance <= PERFECT) ? 3 : 1;
|
|
901
|
+
} else {
|
|
902
|
+
badSound.play();
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
An invisible `misszone` trigger below the pads catches gems that got away and counts the miss. Derive the fall speed from the beat: `FALL_SPEED = (PAD_Y + NOTE) / 1.6` with a 400 ms spawn interval puts four eighth notes between spawn and pad at 150 BPM.
|
|
908
|
+
|
|
909
|
+
## Ball sports / breakout physics
|
|
910
|
+
|
|
911
|
+
`restitution` on a circle-hitbox ball gives real reflection off walls and obstacles. Player contact is handled in JS so you control the feel.
|
|
912
|
+
|
|
913
|
+
```javascript
|
|
914
|
+
const ball = Game.createSprite({
|
|
915
|
+
sheet: ballSheet,
|
|
916
|
+
x: W / 2, y: H * 0.3, width: BALL_SIZE, height: BALL_SIZE,
|
|
917
|
+
zIndex: 12,
|
|
918
|
+
hitboxScale: 0.9,
|
|
919
|
+
hitboxShape: 'circle', // bounces off the net's corner along the normal
|
|
920
|
+
restitution: 0.75,
|
|
921
|
+
solidWith: ['wall', 'net'], // blocked and bounced by these
|
|
922
|
+
collidesWith: ['player', 'floor'] // only reported for these
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
ball.addEventListener('collision', (e) => {
|
|
926
|
+
if (e.group === 'player') {
|
|
927
|
+
// reflect away from the blob's center, biased upward
|
|
928
|
+
const blob = e.other;
|
|
929
|
+
const dx = ball.x - blob.x;
|
|
930
|
+
const dy = ball.y - blob.y;
|
|
931
|
+
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
932
|
+
ball.velocityX = (dx / len) * HIT_SPEED;
|
|
933
|
+
ball.velocityY = Math.min((dy / len) * HIT_SPEED, -HIT_SPEED * 0.45);
|
|
934
|
+
} else if (e.group === 'floor') {
|
|
935
|
+
roundOver(ball.x > W / 2);
|
|
936
|
+
}
|
|
937
|
+
});
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
Note the split: walls and the net are `solidWith` (the engine bounces the ball off them for free), while the players and the floor are `collidesWith` (you decide what happens). For breakout, bricks go in `collidesWith` and are removed with `visible = false` on hit while the paddle and walls stay solid.
|
|
941
|
+
|
|
942
|
+
A `restitution: 1` ball on an invisible floor bounces forever with no JS at all.
|
|
943
|
+
|
|
944
|
+
**Opponent AI** on an 80 ms timer, reading a handful of live properties per tick:
|
|
945
|
+
|
|
946
|
+
```javascript
|
|
947
|
+
timer = setInterval(() => {
|
|
948
|
+
const onCpuSide = ball.x > W / 2;
|
|
949
|
+
const target = (running && onCpuSide) ? ball.x + PLAYER_SIZE * 0.2 : cpuHomeX;
|
|
950
|
+
const dx = target - cpu.x;
|
|
951
|
+
cpu.velocityX = (Math.abs(dx) > PLAYER_SIZE * 0.15) ? (dx > 0 ? RUN_SPEED : -RUN_SPEED) : 0;
|
|
952
|
+
if (running && onCpuSide && cpu.onGround
|
|
953
|
+
&& Math.abs(ball.x - cpu.x) < PLAYER_SIZE * 1.2
|
|
954
|
+
&& ball.y < cpu.y && ball.velocityY > 0) {
|
|
955
|
+
cpu.velocityY = -JUMP;
|
|
956
|
+
}
|
|
957
|
+
}, 80);
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
## Cards and board games
|
|
961
|
+
|
|
962
|
+
No physics at all — sprite taps, native tweens, glow and idle wobble.
|
|
963
|
+
|
|
964
|
+
**Dealing** staggers one tween per card with `delay`, and flips the art on arrival:
|
|
965
|
+
|
|
966
|
+
```javascript
|
|
967
|
+
cards.forEach((card, index) => {
|
|
968
|
+
card.state = 'dealing';
|
|
969
|
+
card.sprite.idleAnimation = false; // still cards land exactly on target
|
|
970
|
+
card.sprite.frame = BACK_FRAME;
|
|
971
|
+
card.sprite.x = DECK_X;
|
|
972
|
+
card.sprite.y = DECK_Y;
|
|
973
|
+
card.sprite.zIndex = 30 + index; // above the deck while flying
|
|
974
|
+
card.sprite.animate({
|
|
975
|
+
x: card.home.x,
|
|
976
|
+
y: card.home.y,
|
|
977
|
+
rotation: card.home.rotation,
|
|
978
|
+
duration: 380,
|
|
979
|
+
delay: index * 140, // one card after another
|
|
980
|
+
easing: Game.EASE_IN_OUT
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
sprite.addEventListener('complete', () => {
|
|
985
|
+
if (card.state === 'dealing') {
|
|
986
|
+
card.state = 'hand';
|
|
987
|
+
sprite.frame = card.face; // flip face up on arrival
|
|
988
|
+
sprite.zIndex = card.index;
|
|
989
|
+
sprite.idleAnimation = true; // wobble only once it has settled
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
```
|
|
993
|
+
|
|
994
|
+
**Selection** lifts the card and fades a glow in — `glowOpacity` is tweenable, so the halo appears with the movement instead of popping:
|
|
995
|
+
|
|
996
|
+
```javascript
|
|
997
|
+
sprite.addEventListener('tap', () => {
|
|
998
|
+
if (card.selected) {
|
|
999
|
+
card.selected = false;
|
|
1000
|
+
sprite.animate({ x: home.x, y: home.y, rotation: home.rotation, scale: 1, glowOpacity: 0, duration: 150, easing: Game.EASE_OUT });
|
|
1001
|
+
} else if (selectedCards().length < MAX_SELECTED) {
|
|
1002
|
+
card.selected = true;
|
|
1003
|
+
sprite.glowColor = '#ffc94d';
|
|
1004
|
+
sprite.glowBlur = CARD_W * 0.14;
|
|
1005
|
+
sprite.glowOpacity = 0;
|
|
1006
|
+
sprite.animate({ y: home.y - LIFT, scale: 1.12, glowOpacity: 1, duration: 150, easing: Game.EASE_OUT });
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
**Drag & drop with snapping** (puzzle pieces, board tiles). The drag itself is native; you only decide where it lands:
|
|
1012
|
+
|
|
1013
|
+
```javascript
|
|
1014
|
+
const piece = Game.createSprite({ sheet: sheet, frame: index, x: homeX, y: homeY, width: PIECE, height: PIECE, draggable: true, zIndex: 10 });
|
|
1015
|
+
|
|
1016
|
+
piece.addEventListener('press', () => {
|
|
1017
|
+
piece.zIndex = 100; // draw above everything while held
|
|
1018
|
+
piece.animate({ scale: 1.15, duration: 120, easing: Game.EASE_OUT });
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
piece.addEventListener('release', () => {
|
|
1022
|
+
piece.animate({ scale: 1, duration: 120, easing: Game.EASE_OUT });
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
piece.addEventListener('dragend', (e) => {
|
|
1026
|
+
let best = null;
|
|
1027
|
+
let bestDistance = SNAP_DISTANCE;
|
|
1028
|
+
cells.forEach((cell) => {
|
|
1029
|
+
if (cell.piece) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const d = Math.sqrt(Math.pow(e.x - cell.x, 2) + Math.pow(e.y - cell.y, 2));
|
|
1033
|
+
if (d < bestDistance) {
|
|
1034
|
+
bestDistance = d;
|
|
1035
|
+
best = cell;
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
piece.zIndex = 10;
|
|
1039
|
+
if (best) {
|
|
1040
|
+
best.piece = piece;
|
|
1041
|
+
piece.animate({ x: best.x, y: best.y, duration: 150, easing: Game.EASE_OUT });
|
|
1042
|
+
} else {
|
|
1043
|
+
piece.animate({ x: homeX, y: homeY, duration: 400, easing: Game.EASE_IN_OUT });
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
Because every handler is per sprite, this is multi-touch for free: each finger grabs its own piece.
|
|
1049
|
+
|
|
1050
|
+
## Point & click adventure
|
|
1051
|
+
|
|
1052
|
+
Tap-to-walk with a distance-sized tween gives constant walking speed regardless of distance:
|
|
1053
|
+
|
|
1054
|
+
```javascript
|
|
1055
|
+
function walkTo(x, y) {
|
|
1056
|
+
const targetX = Math.min(Math.max(x, W * 0.06), W * 0.94);
|
|
1057
|
+
const targetY = Math.min(Math.max(y, WALK_TOP), H * 0.95) - PLAYER_H / 2;
|
|
1058
|
+
const distance = Math.sqrt(Math.pow(targetX - player.x, 2) + Math.pow(targetY - player.y, 2));
|
|
1059
|
+
if (distance < 4) {
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
player.scaleX = targetX < player.x ? -1 : 1;
|
|
1063
|
+
player.clearTweens(); // cancel a walk in progress
|
|
1064
|
+
player.play('walk');
|
|
1065
|
+
player.animate({
|
|
1066
|
+
x: targetX,
|
|
1067
|
+
y: targetY,
|
|
1068
|
+
duration: distance / WALK_SPEED * 1000, // constant px/s
|
|
1069
|
+
easing: Game.EASE_LINEAR // no ease on a walk
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
player.addEventListener('complete', () => player.play('idle'));
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
The walkable floor is a clamp (`WALK_TOP`), not geometry — no collision needed for a classic adventure floor.
|
|
1077
|
+
|
|
1078
|
+
**Hotspots and the tap conflict.** The GameView fires `tap` for *every* touch, including ones a sprite already handled, so a naive walk handler also walks when you click the bird. Hit-test the interactive sprites in JS and bail out:
|
|
1079
|
+
|
|
1080
|
+
```javascript
|
|
1081
|
+
function over(sprite, x, y) {
|
|
1082
|
+
return sprite.visible !== false
|
|
1083
|
+
&& Math.abs(x - sprite.x) <= sprite.width / 2 + PAD
|
|
1084
|
+
&& Math.abs(y - sprite.y) <= sprite.height / 2 + PAD;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
gameView.addEventListener('tap', (e) => {
|
|
1088
|
+
if (over(bird, e.x, e.y) || over(handIcon, e.x, e.y) || over(talkIcon, e.x, e.y)) {
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
hideIcons();
|
|
1092
|
+
walkTo(e.x, e.y);
|
|
1093
|
+
});
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
A verb coin is two icon sprites with `visible` toggled and `idleAnimation: true` for a bit of life while open.
|
|
1097
|
+
|
|
1098
|
+
## Particles
|
|
1099
|
+
|
|
1100
|
+
Three modes, all native. Tint white art at runtime so one 16×16 texture covers every effect in the game.
|
|
1101
|
+
|
|
1102
|
+
**Continuous fountain**:
|
|
1103
|
+
|
|
1104
|
+
```javascript
|
|
1105
|
+
const fountain = Game.createEmitter({
|
|
1106
|
+
sheet: sparkSheet, frame: 1,
|
|
1107
|
+
x: W / 2, y: H - UNIT * 0.05,
|
|
1108
|
+
rate: 90,
|
|
1109
|
+
lifetime: 1600,
|
|
1110
|
+
speed: H * 0.75, // launch up...
|
|
1111
|
+
angle: 0, // 0 = up, clockwise degrees
|
|
1112
|
+
spread: 25,
|
|
1113
|
+
gravity: H * 0.45, // ...and fall back
|
|
1114
|
+
size: UNIT * 0.03,
|
|
1115
|
+
startScale: 1, endScale: 0.4,
|
|
1116
|
+
startOpacity: 1, endOpacity: 0,
|
|
1117
|
+
tint: '#ffcc44',
|
|
1118
|
+
maxParticles: 400
|
|
1119
|
+
});
|
|
1120
|
+
gameView.add(fountain);
|
|
1121
|
+
```
|
|
1122
|
+
|
|
1123
|
+
**Burst on demand** — `rate: 0`, position it and call `emit(n)`:
|
|
1124
|
+
|
|
1125
|
+
```javascript
|
|
1126
|
+
const firework = Game.createEmitter({
|
|
1127
|
+
sheet: sparkSheet, frame: 1,
|
|
1128
|
+
rate: 0,
|
|
1129
|
+
lifetime: 900,
|
|
1130
|
+
speed: UNIT * 0.6,
|
|
1131
|
+
spread: 360,
|
|
1132
|
+
gravity: H * 0.25,
|
|
1133
|
+
startScale: 1, endScale: 0.3,
|
|
1134
|
+
maxParticles: 500
|
|
1135
|
+
});
|
|
1136
|
+
gameView.add(firework);
|
|
1137
|
+
|
|
1138
|
+
gameView.addEventListener('tap', (e) => {
|
|
1139
|
+
firework.x = e.x;
|
|
1140
|
+
firework.y = e.y;
|
|
1141
|
+
firework.tint = COLORS[colorIndex++ % COLORS.length];
|
|
1142
|
+
firework.emit(45);
|
|
1143
|
+
});
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
**Trail following a sprite** — `target` keeps up even mid-drag, with no bridge traffic:
|
|
1147
|
+
|
|
1148
|
+
```javascript
|
|
1149
|
+
const dust = Game.createEmitter({
|
|
1150
|
+
sheet: sparkSheet, frame: 0,
|
|
1151
|
+
target: player,
|
|
1152
|
+
offsetX: -PLAYER * 0.32, // behind the rear wheels
|
|
1153
|
+
offsetY: PLAYER * 0.42,
|
|
1154
|
+
rate: 22,
|
|
1155
|
+
lifetime: 450,
|
|
1156
|
+
speed: W * 0.08,
|
|
1157
|
+
angle: -35, // up and back
|
|
1158
|
+
spread: 50,
|
|
1159
|
+
startOpacity: 0.45, endOpacity: 0,
|
|
1160
|
+
tint: '#8a8580',
|
|
1161
|
+
zIndex: 9 // behind the player
|
|
1162
|
+
});
|
|
1163
|
+
gameView.add(dust);
|
|
1164
|
+
|
|
1165
|
+
dust.emitting = false; // the one property JS toggles at runtime
|
|
1166
|
+
```
|
|
1167
|
+
|
|
1168
|
+
Add `blend: 'add'` for fire, magic and sparks — overlaps bloom instead of covering.
|
|
1169
|
+
|
|
1170
|
+
## Ropes and chains
|
|
1171
|
+
|
|
1172
|
+
```javascript
|
|
1173
|
+
// hangs from a draggable ball
|
|
1174
|
+
const ball = Game.createSprite({ sheet: ballSheet, x: W / 2, y: H * 0.22, width: BALL, height: BALL, zIndex: 10, draggable: true, hitboxShape: 'circle' });
|
|
1175
|
+
gameView.add(ball);
|
|
1176
|
+
|
|
1177
|
+
gameView.add(Game.createRope({
|
|
1178
|
+
sheet: ropeSheet,
|
|
1179
|
+
segments: 14,
|
|
1180
|
+
segmentLength: H * 0.04,
|
|
1181
|
+
thickness: W * 0.03,
|
|
1182
|
+
gravity: H * 1.6,
|
|
1183
|
+
damping: 0.985,
|
|
1184
|
+
iterations: 3,
|
|
1185
|
+
head: ball, // pinned to the sprite — drag it and the rope follows
|
|
1186
|
+
zIndex: 5
|
|
1187
|
+
}));
|
|
1188
|
+
|
|
1189
|
+
// fixed anchor + a weight on a tether: drag it too far and it is pulled back
|
|
1190
|
+
gameView.add(Game.createRope({
|
|
1191
|
+
sheet: ropeSheet,
|
|
1192
|
+
segments: 10,
|
|
1193
|
+
segmentLength: SEG_LEN,
|
|
1194
|
+
gravity: H * 1.6,
|
|
1195
|
+
x: W * 0.15, y: H * 0.1, // fixed head anchor
|
|
1196
|
+
tail: weight,
|
|
1197
|
+
maxLength: 10 * SEG_LEN, // wrecking balls, leashes, yo-yos
|
|
1198
|
+
zIndex: 5
|
|
1199
|
+
}));
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
With sprites on both ends plus `maxLength`, dragging either one tows the other once the rope goes taut — carts, chained crates, a grappling hook whose tip you read from `rope.endX` / `rope.endY`.
|
|
1203
|
+
|
|
1204
|
+
## Camera work
|
|
1205
|
+
|
|
1206
|
+
```javascript
|
|
1207
|
+
gameView.follow(player, {
|
|
1208
|
+
leftMargin: 0.4, rightMargin: 0.6, // horizontal follow is off without these
|
|
1209
|
+
topMargin: 0.4, bottomMargin: 0.6,
|
|
1210
|
+
smoothing: 0.12, // 0 snaps; higher eases
|
|
1211
|
+
maxY: WORLD_H // default 0 clamps to the top of the world
|
|
1212
|
+
});
|
|
1213
|
+
gameView.cameraBounds = { minX: 0, minY: 0, maxX: WORLD_W, maxY: WORLD_H };
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
Sprites live in world coordinates and touch input is mapped back to world space automatically, zoom included — taps and drags keep working while scrolled. Overlaid Titanium controls are screen-fixed and unaffected.
|
|
1217
|
+
|
|
1218
|
+
```javascript
|
|
1219
|
+
gameView.cameraScale = Math.min(2.5, Math.max(0.5, gameView.cameraScale * 1.25)); // zoom
|
|
1220
|
+
gameView.shake({ strength: W * 0.02, duration: 500 }); // impact
|
|
1221
|
+
gameView.cameraEffect = 'tint'; // night vision
|
|
1222
|
+
gameView.cameraTint = '#4f8';
|
|
1223
|
+
gameView.cameraEffectIntensity = 0.7;
|
|
1224
|
+
gameView.cameraEffect = 'glitch'; // broken signal
|
|
1225
|
+
gameView.cameraEffect = 'none'; // skips the pass entirely
|
|
1226
|
+
```
|
|
1227
|
+
|
|
1228
|
+
For a big ground plane, one `tileRepeat` sprite covering the whole world beats a grid of tile sprites — the texture keeps its native density and the scene stays one draw call.
|
|
1229
|
+
|
|
1230
|
+
## Visual effects
|
|
1231
|
+
|
|
1232
|
+
**`blend: 'add'`** — identical sprites, different blend: normal overlaps cover each other, additive overlaps sum and bloom toward white. Use it for glows, fire, lasers and light shafts, and group additive sprites on their own `zIndex` band since each blend change costs a batch flush.
|
|
1233
|
+
|
|
1234
|
+
**`flash(color, duration)`** — a solid-color silhouette that fades out natively. This is the damage flash a multiplicative `tintColor` cannot produce (white tint = no change). Retrigger it on a timer for invincibility blinking.
|
|
1235
|
+
|
|
1236
|
+
**`flipX` / `flipY` from movement** — mirror the drawn frame without touching physics:
|
|
1237
|
+
|
|
1238
|
+
```javascript
|
|
1239
|
+
// tween patrol: flip on each turn-around
|
|
1240
|
+
function patrol() {
|
|
1241
|
+
const goingRight = bird.x < W / 2;
|
|
1242
|
+
bird.flipX = !goingRight;
|
|
1243
|
+
bird.animate({ x: goingRight ? W - MARGIN : MARGIN, duration: 3000, easing: Game.EASE_IN_OUT });
|
|
1244
|
+
}
|
|
1245
|
+
bird.addEventListener('complete', patrol);
|
|
1246
|
+
|
|
1247
|
+
// velocity runner: a slow watchdog turns it around, never moves it
|
|
1248
|
+
timer = setInterval(() => {
|
|
1249
|
+
[dog, player].forEach((runner) => {
|
|
1250
|
+
if (runner.velocityX > 0 && runner.x > W - MARGIN) {
|
|
1251
|
+
runner.velocityX = -runner.velocityX;
|
|
1252
|
+
} else if (runner.velocityX < 0 && runner.x < MARGIN) {
|
|
1253
|
+
runner.velocityX = -runner.velocityX;
|
|
1254
|
+
}
|
|
1255
|
+
runner.flipX = runner.velocityX < 0;
|
|
1256
|
+
});
|
|
1257
|
+
}, 150);
|
|
1258
|
+
|
|
1259
|
+
// VVVVVV-style gravity inversion
|
|
1260
|
+
gameView.addEventListener('tap', () => {
|
|
1261
|
+
player.gravity = -player.gravity;
|
|
1262
|
+
player.flipY = player.gravity < 0;
|
|
1263
|
+
});
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
**`timeScale`** — one property slows or freezes physics, sheet animations, tweens, particles and the camera together, while rendering and touch keep running. `0.5` for slow motion, `0` for a pause that still draws, short bursts of `0` for hit-stop.
|
|
1267
|
+
|
|
1268
|
+
**`idleAnimation`** — a per-sprite organic sway that composes with tweens and drags. Cheap life for cards, icons, floating pickups and menu art. Disable it before a tween that must land precisely.
|