@maccesar/titools 4.15.0 → 4.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/package.json +1 -1
- package/skills/ti-game/SKILL.md +90 -197
- package/skills/ti-game/references/api.md +52 -15
- package/skills/ti-game/references/debugging-performance.md +102 -0
- package/skills/ti-game/references/examples.md +53 -0
- package/skills/ti-game/references/patterns.md +699 -0
- package/skills/ti-game/references/project-setup.md +19 -6
- package/skills/ti-game/references/recipes-action.md +653 -0
- package/skills/ti-game/references/recipes-adventure-effects.md +340 -0
- package/skills/ti-game/references/roadmap.md +45 -65
- package/skills/ti-game/references/tilemaps.md +127 -0
- package/skills/ti-game/references/recipes.md +0 -1679
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# Action-game recipes
|
|
2
|
+
|
|
3
|
+
Production-oriented patterns distilled from the upstream action demos at `ti.game@c216e7f`. Use [examples.md](examples.md) to locate the full runnable demo and [api.md](api.md) for exact defaults and payloads. The wall-contact API is source/README-backed because upstream has not added a wall-jump demo yet.
|
|
4
|
+
|
|
5
|
+
<!-- TOC-START -->
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- [Tap-to-flap](#tap-to-flap)
|
|
9
|
+
- [Platformer](#platformer)
|
|
10
|
+
- [Top-down / Zelda](#top-down--zelda)
|
|
11
|
+
- [Top-down racer](#top-down-racer)
|
|
12
|
+
- [Asteroids / space shooter](#asteroids--space-shooter)
|
|
13
|
+
- [Lunar lander](#lunar-lander)
|
|
14
|
+
- [Endless runner](#endless-runner)
|
|
15
|
+
- [Rhythm game](#rhythm-game)
|
|
16
|
+
- [Ball sports / breakout physics](#ball-sports--breakout-physics)
|
|
17
|
+
|
|
18
|
+
<!-- TOC-END -->
|
|
19
|
+
|
|
20
|
+
## Tap-to-flap
|
|
21
|
+
|
|
22
|
+
Gravity plus an impulse per tap. Scoring and death are collisions with invisible zones. The rotation tween is what sells the arc.
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
const pig = Game.createSprite({
|
|
26
|
+
sheet: pigSheet,
|
|
27
|
+
x: W * 0.28,
|
|
28
|
+
y: H * 0.42,
|
|
29
|
+
width: PIG_SIZE,
|
|
30
|
+
height: PIG_SIZE,
|
|
31
|
+
zIndex: 10,
|
|
32
|
+
hitboxScale: 0.7, // the art does not fill the frame
|
|
33
|
+
hitboxScaleY: 0.85, // ...and the pig is wider than he is tall
|
|
34
|
+
collidesWith: ['pipe', 'ground', 'score', 'ceiling'],
|
|
35
|
+
animations: { fly: { frames: [0, 1, 2, 1], fps: 10, loop: true } }
|
|
36
|
+
});
|
|
37
|
+
pig.play('fly');
|
|
38
|
+
|
|
39
|
+
gameView.addEventListener('press', () => {
|
|
40
|
+
if (over) {
|
|
41
|
+
reset();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!started) {
|
|
45
|
+
start(); // first tap turns gravity on
|
|
46
|
+
}
|
|
47
|
+
pig.velocityY = FLAP; // e.g. -H * 0.6
|
|
48
|
+
pig.clearTweens();
|
|
49
|
+
pig.rotation = -15; // snap the nose up...
|
|
50
|
+
pig.animate({ rotation: 25, duration: 900, easing: Game.EASE_IN }); // ...then tip forward
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
pig.addEventListener('collision', (e) => {
|
|
54
|
+
if (over) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (e.group === 'score') {
|
|
58
|
+
score++;
|
|
59
|
+
} else if (e.group === 'ceiling') {
|
|
60
|
+
pig.velocityY = H * 0.1; // gentle push back down instead of a hard stop
|
|
61
|
+
} else {
|
|
62
|
+
gameOver();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
## Platformer
|
|
70
|
+
|
|
71
|
+
`solidWith` handles the blocking; you only decide when to jump.
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
const WALL_KICK_X = RUN_SPEED * 1.35;
|
|
75
|
+
const WALL_KICK_Y = JUMP * 0.9;
|
|
76
|
+
|
|
77
|
+
const player = Game.createSprite({
|
|
78
|
+
sheet: playerSheet,
|
|
79
|
+
x: W * 0.15,
|
|
80
|
+
y: groundTop - PLAYER_SIZE / 2,
|
|
81
|
+
width: PLAYER_SIZE,
|
|
82
|
+
height: PLAYER_SIZE,
|
|
83
|
+
zIndex: 10,
|
|
84
|
+
gravity: GRAVITY, // H * 2.2
|
|
85
|
+
hitboxScale: 0.85,
|
|
86
|
+
hitboxScaleX: 0.66, // the blob is 38px wide in a 64px frame, but nearly full height
|
|
87
|
+
solidWith: ['solid', 'trampoline'],
|
|
88
|
+
wallSlideSpeed: H * 0.18, // 0 disables the native downward cap
|
|
89
|
+
animations: {
|
|
90
|
+
idle: { frames: [0], fps: 1, loop: true },
|
|
91
|
+
walk: { frames: [1, 2], fps: 8, loop: true }
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
gameView.follow(player, { topMargin: 0.33, bottomMargin: 0.7, maxY: 0 });
|
|
96
|
+
|
|
97
|
+
let moveDir = 0;
|
|
98
|
+
|
|
99
|
+
function applyMovement() {
|
|
100
|
+
player.velocityX = moveDir * RUN_SPEED;
|
|
101
|
+
if (moveDir !== 0) {
|
|
102
|
+
player.scaleX = moveDir; // face the running direction
|
|
103
|
+
}
|
|
104
|
+
if (player.onGround) {
|
|
105
|
+
player.play(moveDir !== 0 ? 'walk' : 'idle');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function jump() {
|
|
110
|
+
if (player.onGround) { // gate on the engine's own flag
|
|
111
|
+
player.velocityY = -JUMP; // H * 0.95 ≈ 0.2 * H of height
|
|
112
|
+
player.stop();
|
|
113
|
+
player.frame = 3; // hold the jump pose
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const away = player.onWallLeft ? 1 : player.onWallRight ? -1 : 0;
|
|
118
|
+
if (away !== 0) { // read state when the jump is requested
|
|
119
|
+
player.velocityX = away * WALL_KICK_X;
|
|
120
|
+
player.velocityY = -WALL_KICK_Y;
|
|
121
|
+
player.scaleX = away;
|
|
122
|
+
player.stop();
|
|
123
|
+
player.frame = 3;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
player.addEventListener('land', (e) => {
|
|
128
|
+
if (e.group === 'trampoline') {
|
|
129
|
+
player.velocityY = -JUMP * 1.1; // bounce higher than a jump
|
|
130
|
+
player.stop();
|
|
131
|
+
player.frame = 3;
|
|
132
|
+
e.other.frame = 1; // squash the mat you landed on
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
e.other.frame = 0;
|
|
135
|
+
}, 150);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
applyMovement(); // back from the jump pose
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
player.addEventListener('wallhit', () => {
|
|
142
|
+
player.flash('#d7f4ff', 80); // transition effect, not jump gating
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
**Wall jumps and slides**: `onWallLeft` / `onWallRight` are read-only contact state and only stay true while the resolver is pushing the player sideways. Read them in `jump()`; do not poll `wallhit` or make it sticky in JS. `wallhit` fires on first contact or a side switch and is ideal for sound/dust. `wallSlideSpeed` caps downward velocity only, so the upward kick remains intact. With touch controls that continuously reapply horizontal movement, let the wall-jump impulse win briefly before writing the held direction again. TileLayer walls set the same flags, but their `wallhit` payload has no `other` or `group`.
|
|
149
|
+
|
|
150
|
+
**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.
|
|
151
|
+
|
|
152
|
+
**Moving platforms carry riders for free** — a solid moved by `velocityX` or a tween drags whoever stands on it, with no re-landing jitter:
|
|
153
|
+
|
|
154
|
+
```javascript
|
|
155
|
+
const mover = Game.createSprite({
|
|
156
|
+
sheet: moverSheet, x: W * 0.18, y: groundTop - STEP * 2,
|
|
157
|
+
width: W * 0.26, height: PLAT_H, collisionGroup: 'solid'
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
function patrol() {
|
|
161
|
+
mover.animate({
|
|
162
|
+
x: (mover.x < W * 0.35) ? W * 0.5 : W * 0.18,
|
|
163
|
+
duration: 2600,
|
|
164
|
+
easing: Game.EASE_IN_OUT
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
mover.addEventListener('complete', patrol); // ping-pong forever
|
|
168
|
+
patrol();
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Set `carryRiders: false` on terrain that scrolls under a player who should stay put (endless runners).
|
|
172
|
+
|
|
173
|
+
## Top-down / Zelda
|
|
174
|
+
|
|
175
|
+
A tile map from a string array, plus `ySort` for depth. Only water is solid; trees are pure decoration you can walk behind.
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
const MAP = [
|
|
179
|
+
'WWWWWWWWWWWW',
|
|
180
|
+
'WGGGGGGGGGGW',
|
|
181
|
+
'WGGFGGGPGGGW',
|
|
182
|
+
'WWWWWWWWWWWW'
|
|
183
|
+
];
|
|
184
|
+
const FRAMES = { G: 0, F: 1, P: 2, W: 3 };
|
|
185
|
+
const COLS = MAP[0].length;
|
|
186
|
+
const ROWS = MAP.length;
|
|
187
|
+
const TILE = Math.floor(Math.min(W / COLS, (H - buttonZone) / ROWS));
|
|
188
|
+
const ox = (W - COLS * TILE) / 2;
|
|
189
|
+
const oy = (H - buttonZone - ROWS * TILE) / 2;
|
|
190
|
+
|
|
191
|
+
const level = [];
|
|
192
|
+
for (let row = 0; row < ROWS; row++) {
|
|
193
|
+
for (let col = 0; col < COLS; col++) {
|
|
194
|
+
const kind = MAP[row].charAt(col);
|
|
195
|
+
level.push(Game.createSprite({
|
|
196
|
+
sheet: tileSheet,
|
|
197
|
+
frame: FRAMES[kind],
|
|
198
|
+
x: ox + (col + 0.5) * TILE,
|
|
199
|
+
y: oy + (row + 0.5) * TILE,
|
|
200
|
+
width: TILE,
|
|
201
|
+
height: TILE,
|
|
202
|
+
zIndex: 0,
|
|
203
|
+
collisionGroup: (kind === 'W') ? 'solid' : null // only water blocks
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
gameView.add(level); // one bridge crossing
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This sprite-per-tile pattern is still reasonable at 12×15 when trees and buildings need independent sprite behavior. At 200×200 it ticks, sorts, draws, and collision-scans far too many objects. Use one or more native `Game.createTileLayer()` grids for large/static terrain, then keep only interactive actors and props as sprites. See [tilemaps.md](tilemaps.md).
|
|
211
|
+
|
|
212
|
+
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.
|
|
213
|
+
|
|
214
|
+
```javascript
|
|
215
|
+
const player = Game.createSprite({
|
|
216
|
+
sheet: walkerSheet,
|
|
217
|
+
x: start.x, y: start.y, width: TILE, height: TILE,
|
|
218
|
+
zIndex: 5,
|
|
219
|
+
ySort: true,
|
|
220
|
+
hitboxScale: 0.6, // top-down games feel best with a small body box
|
|
221
|
+
solidWith: ['solid'],
|
|
222
|
+
animations: {
|
|
223
|
+
down: { frames: [0, 1], fps: 6, loop: true },
|
|
224
|
+
up: { frames: [2, 3], fps: 6, loop: true },
|
|
225
|
+
side: { frames: [4, 5], fps: 6, loop: true }
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
8-way movement from four buttons, tracking facing yourself because `animation` keeps its value after `stop()`:
|
|
231
|
+
|
|
232
|
+
```javascript
|
|
233
|
+
let moveX = 0;
|
|
234
|
+
let moveY = 0;
|
|
235
|
+
let lastFacing = 'down';
|
|
236
|
+
let walking = false;
|
|
237
|
+
|
|
238
|
+
function applyMovement() {
|
|
239
|
+
player.velocityX = moveX * SPEED;
|
|
240
|
+
player.velocityY = moveY * SPEED;
|
|
241
|
+
if (moveX === 0 && moveY === 0) {
|
|
242
|
+
player.stop();
|
|
243
|
+
walking = false;
|
|
244
|
+
player.frame = { down: 0, up: 2, side: 4 }[lastFacing];
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const facing = (moveX !== 0) ? 'side' : (moveY < 0 ? 'up' : 'down');
|
|
248
|
+
if (moveX !== 0) {
|
|
249
|
+
player.scaleX = moveX; // side frames face right; mirror for left
|
|
250
|
+
}
|
|
251
|
+
if (!walking || facing !== lastFacing) {
|
|
252
|
+
player.play(facing);
|
|
253
|
+
walking = true;
|
|
254
|
+
}
|
|
255
|
+
lastFacing = facing;
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**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.
|
|
260
|
+
|
|
261
|
+
```javascript
|
|
262
|
+
timer = setInterval(() => {
|
|
263
|
+
const dx = player.x - dog.x;
|
|
264
|
+
const dy = player.y - dog.y;
|
|
265
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
266
|
+
if (dist > FOLLOW_DISTANCE) {
|
|
267
|
+
dog.velocityX = (dx / dist) * DOG_SPEED;
|
|
268
|
+
dog.velocityY = (dy / dist) * DOG_SPEED;
|
|
269
|
+
if (Math.abs(dx) > 2) {
|
|
270
|
+
dog.scaleX = (dx > 0) ? 1 : -1;
|
|
271
|
+
}
|
|
272
|
+
setDogState('walk');
|
|
273
|
+
} else {
|
|
274
|
+
dog.velocityX = 0;
|
|
275
|
+
dog.velocityY = 0;
|
|
276
|
+
setDogState('sit');
|
|
277
|
+
}
|
|
278
|
+
}, 100);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Top-down racer
|
|
282
|
+
|
|
283
|
+
`carMode` gives you throttle, steering and emergent drift. You supply the track and the lap logic.
|
|
284
|
+
|
|
285
|
+
```javascript
|
|
286
|
+
const car = Game.createSprite({
|
|
287
|
+
sheet: carSheet,
|
|
288
|
+
x: W * 0.38,
|
|
289
|
+
y: laneY,
|
|
290
|
+
width: CAR_SIZE,
|
|
291
|
+
height: CAR_SIZE,
|
|
292
|
+
pixelSnap: true, // stable texel phase for pixel art in motion
|
|
293
|
+
rotation: 90, // facing right along the bottom straight
|
|
294
|
+
zIndex: 10,
|
|
295
|
+
hitboxScale: 0.75,
|
|
296
|
+
carMode: true,
|
|
297
|
+
enginePower: MAX_SPEED * 1.1,
|
|
298
|
+
maxSpeed: MAX_SPEED,
|
|
299
|
+
turnRate: 210,
|
|
300
|
+
grip: 3.2, // below the default 4 → drifts in fast corners
|
|
301
|
+
drag: 0.8,
|
|
302
|
+
skidMarks: true,
|
|
303
|
+
restitution: 0.3, // bump off the barriers instead of sticking
|
|
304
|
+
solidWith: ['wall'],
|
|
305
|
+
collidesWith: ['checkpoint', 'goal']
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Controls write `throttle` and `steering` once per touch — never per frame:
|
|
310
|
+
|
|
311
|
+
```javascript
|
|
312
|
+
bindHold(gasButton, () => {
|
|
313
|
+
car.throttle = 1;
|
|
314
|
+
}, () => {
|
|
315
|
+
if (car.throttle === 1) {
|
|
316
|
+
car.throttle = 0;
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
bindHold(brakeButton, () => {
|
|
320
|
+
car.throttle = -1;
|
|
321
|
+
}, () => {
|
|
322
|
+
if (car.throttle === -1) {
|
|
323
|
+
car.throttle = 0;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
330
|
+
```javascript
|
|
331
|
+
car.addEventListener('collision', (e) => {
|
|
332
|
+
if (e.group === 'checkpoint') {
|
|
333
|
+
visited[e.other.cpIndex] = true;
|
|
334
|
+
} else if (e.group === 'goal' && Object.keys(visited).length === 3) {
|
|
335
|
+
laps++;
|
|
336
|
+
visited = {};
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
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.
|
|
342
|
+
|
|
343
|
+
## Asteroids / space shooter
|
|
344
|
+
|
|
345
|
+
Newtonian flight: `thrust` accelerates along the heading, `angularVelocity` turns, `wrapAround` re-enters from the opposite edge, momentum does the rest.
|
|
346
|
+
|
|
347
|
+
```javascript
|
|
348
|
+
const ship = Game.createSprite({
|
|
349
|
+
sheet: shipSheet,
|
|
350
|
+
x: W / 2, y: H / 2, width: SHIP_SIZE, height: SHIP_SIZE,
|
|
351
|
+
zIndex: 10,
|
|
352
|
+
maxSpeed: MAX_SPEED,
|
|
353
|
+
wrapAround: true,
|
|
354
|
+
hitboxScale: 0.6,
|
|
355
|
+
hitboxShape: 'circle',
|
|
356
|
+
collidesWith: ['asteroid'],
|
|
357
|
+
animations: { thrust: { frames: [1, 2], fps: 14, loop: true } } // flickering flame
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
bindHold(thrustButton, () => {
|
|
361
|
+
ship.thrust = THRUST; // px/s² along the heading
|
|
362
|
+
ship.play('thrust');
|
|
363
|
+
thrustSound.play(); // created with loop: true
|
|
364
|
+
}, () => {
|
|
365
|
+
ship.thrust = 0;
|
|
366
|
+
ship.stop();
|
|
367
|
+
ship.frame = 0;
|
|
368
|
+
thrustSound.stop();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
bindHold(leftButton, () => {
|
|
372
|
+
ship.angularVelocity = -TURN_SPEED;
|
|
373
|
+
}, () => {
|
|
374
|
+
if (ship.angularVelocity < 0) {
|
|
375
|
+
ship.angularVelocity = 0;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Firing from the nose, inheriting the ship's momentum — with `rotation` in degrees clockwise and y pointing down, the heading vector is `(sin θ, -cos θ)`:
|
|
381
|
+
|
|
382
|
+
```javascript
|
|
383
|
+
const rad = ship.rotation * Math.PI / 180;
|
|
384
|
+
const dirX = Math.sin(rad);
|
|
385
|
+
const dirY = -Math.cos(rad);
|
|
386
|
+
bullet.x = ship.x + dirX * SHIP_SIZE * 0.55;
|
|
387
|
+
bullet.y = ship.y + dirY * SHIP_SIZE * 0.55;
|
|
388
|
+
bullet.rotation = ship.rotation;
|
|
389
|
+
bullet.velocityX = ship.velocityX + dirX * BULLET_SPEED;
|
|
390
|
+
bullet.velocityY = ship.velocityY + dirY * BULLET_SPEED;
|
|
391
|
+
bullet.visible = true;
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Give bolts `blend: 'add'` so they glow over the starfield. Autofire is a `setInterval` on the fire button — a cadence decision, not motion.
|
|
395
|
+
|
|
396
|
+
Damage feedback and invulnerability:
|
|
397
|
+
|
|
398
|
+
```javascript
|
|
399
|
+
let invulnerableUntil = 0;
|
|
400
|
+
|
|
401
|
+
ship.addEventListener('collision', (e) => {
|
|
402
|
+
if (e.group !== 'asteroid' || Date.now() < invulnerableUntil) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
lives--;
|
|
406
|
+
explodeSound.play();
|
|
407
|
+
ship.flash('#ff5252', 400); // native red damage flash
|
|
408
|
+
invulnerableUntil = Date.now() + 1500;
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// last-life warning: one JS timer, the fade itself runs in the engine
|
|
412
|
+
warnTimer = setInterval(() => {
|
|
413
|
+
ship.flash('#ff5252', 350);
|
|
414
|
+
}, 700);
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Rocks are round (`hitboxShape: 'circle'`), drift on random velocities, spin on `angularVelocity`, and are recycled by `visible = false` rather than removed.
|
|
418
|
+
|
|
419
|
+
## Lunar lander
|
|
420
|
+
|
|
421
|
+
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.
|
|
422
|
+
|
|
423
|
+
```javascript
|
|
424
|
+
const lander = Game.createSprite({
|
|
425
|
+
sheet: shipSheet,
|
|
426
|
+
x: W / 2, y: H * 0.2, width: SHIP, height: SHIP,
|
|
427
|
+
zIndex: 10,
|
|
428
|
+
gravity: world.gravity, // per-world, e.g. H * 0.35 for the Moon
|
|
429
|
+
maxSpeed: MAX_SPEED,
|
|
430
|
+
hitboxScale: 0.8,
|
|
431
|
+
solidWith: ['terrain', 'pad'],
|
|
432
|
+
collidesWith: ['hazard']
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const MAX_LANDING_SPEED = H * 0.18;
|
|
436
|
+
const MAX_LANDING_TILT = 12; // degrees
|
|
437
|
+
|
|
438
|
+
lander.addEventListener('land', (e) => {
|
|
439
|
+
const speed = Math.abs(lander.velocityY);
|
|
440
|
+
const tilt = Math.abs(((lander.rotation % 360) + 540) % 360 - 180);
|
|
441
|
+
const onPad = e.group === 'pad';
|
|
442
|
+
|
|
443
|
+
if (onPad && speed <= MAX_LANDING_SPEED && tilt <= MAX_LANDING_TILT) {
|
|
444
|
+
succeed();
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
crash();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
function crash() {
|
|
451
|
+
hitStop(140); // see the hit-stop pattern
|
|
452
|
+
lander.flash('#ff5252', 400);
|
|
453
|
+
explosion.x = lander.x;
|
|
454
|
+
explosion.y = lander.y;
|
|
455
|
+
explosion.emit(30);
|
|
456
|
+
gameView.shake({ strength: SHIP * 0.5, duration: 450 });
|
|
457
|
+
crashSound.play();
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
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:
|
|
462
|
+
|
|
463
|
+
```javascript
|
|
464
|
+
timer = setInterval(() => {
|
|
465
|
+
if (mainThrusterHeld && fuel > 0) {
|
|
466
|
+
fuel -= BURN_RATE;
|
|
467
|
+
lander.thrust = THRUST;
|
|
468
|
+
exhaust.emitting = true;
|
|
469
|
+
} else {
|
|
470
|
+
lander.thrust = 0;
|
|
471
|
+
exhaust.emitting = false;
|
|
472
|
+
}
|
|
473
|
+
fuelLabel.text = Math.max(0, Math.round(fuel)) + '%';
|
|
474
|
+
}, 80);
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
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.
|
|
478
|
+
|
|
479
|
+
## Endless runner
|
|
480
|
+
|
|
481
|
+
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.
|
|
482
|
+
|
|
483
|
+
```javascript
|
|
484
|
+
const player = Game.createSprite({
|
|
485
|
+
sheet: skaterSheet,
|
|
486
|
+
x: W * 0.24,
|
|
487
|
+
y: groundTop - PLAYER / 2,
|
|
488
|
+
width: PLAYER, height: PLAYER,
|
|
489
|
+
zIndex: 10,
|
|
490
|
+
gravity: GRAVITY,
|
|
491
|
+
hitboxScale: 0.9,
|
|
492
|
+
solidWith: ['ground', 'platform'],
|
|
493
|
+
collidesWith: ['wall', 'pit'],
|
|
494
|
+
animations: { roll: { frames: [0, 1], fps: 6, loop: true } }
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
function jump() {
|
|
498
|
+
if (!over && player.onGround) {
|
|
499
|
+
jumpSound.play();
|
|
500
|
+
dust.emitting = false; // no dust while airborne
|
|
501
|
+
player.velocityY = -JUMP;
|
|
502
|
+
player.stop();
|
|
503
|
+
player.frame = 2; // tuck pose
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
player.addEventListener('land', () => {
|
|
508
|
+
if (!over) {
|
|
509
|
+
player.play('roll');
|
|
510
|
+
dust.emitting = true;
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
Spawning is a self-rescheduling timeout, so the gap can vary and a long obstacle can push the next spawn further out:
|
|
516
|
+
|
|
517
|
+
```javascript
|
|
518
|
+
function scheduleSpawn() {
|
|
519
|
+
spawnTimer = setTimeout(() => {
|
|
520
|
+
spawnObstacle();
|
|
521
|
+
scheduleSpawn();
|
|
522
|
+
}, 1100 + Math.random() * 800 + extraDelay);
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
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.
|
|
527
|
+
|
|
528
|
+
Crash sequence: stop the timers, zero gravity, freeze every scroller, switch to the wipeout frame, fire a spark burst, shake the camera.
|
|
529
|
+
|
|
530
|
+
```javascript
|
|
531
|
+
function crash(group) {
|
|
532
|
+
over = true;
|
|
533
|
+
music.stop();
|
|
534
|
+
crashSound.play();
|
|
535
|
+
clearTimers();
|
|
536
|
+
player.gravity = 0;
|
|
537
|
+
player.velocityY = 0;
|
|
538
|
+
player.stop();
|
|
539
|
+
player.frame = 3;
|
|
540
|
+
layers.forEach((layer) => layer.setSpeed(0));
|
|
541
|
+
setObstacleSpeed(0);
|
|
542
|
+
dust.emitting = false;
|
|
543
|
+
sparks.x = player.x;
|
|
544
|
+
sparks.y = player.y;
|
|
545
|
+
sparks.emit(26);
|
|
546
|
+
gameView.shake({ strength: PLAYER * 0.12, duration: 450 });
|
|
547
|
+
}
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
## Rhythm game
|
|
551
|
+
|
|
552
|
+
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.
|
|
553
|
+
|
|
554
|
+
```javascript
|
|
555
|
+
const pads = LANES.map((x, lane) => {
|
|
556
|
+
const pad = Game.createSprite({ sheet: padSheet, x: x, y: PAD_Y, width: PAD, height: PAD, zIndex: 5 });
|
|
557
|
+
pad.addEventListener('press', () => hitLane(lane, pad));
|
|
558
|
+
gameView.add(pad);
|
|
559
|
+
return pad;
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// gems must not swallow pad presses
|
|
563
|
+
const sprite = Game.createSprite({
|
|
564
|
+
sheet: noteSheet, width: NOTE, height: NOTE, zIndex: 10,
|
|
565
|
+
visible: false,
|
|
566
|
+
touchEnabled: false,
|
|
567
|
+
collidesWith: ['misszone']
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
function hitLane(lane, pad) {
|
|
571
|
+
pad.frame = 1;
|
|
572
|
+
setTimeout(() => {
|
|
573
|
+
pad.frame = 0;
|
|
574
|
+
}, 120);
|
|
575
|
+
|
|
576
|
+
let best = null;
|
|
577
|
+
let bestDistance = Infinity;
|
|
578
|
+
notes.forEach((note) => {
|
|
579
|
+
if (note.active && note.lane === lane) {
|
|
580
|
+
const distance = Math.abs(note.sprite.y - PAD_Y); // live native read
|
|
581
|
+
if (distance < bestDistance) {
|
|
582
|
+
bestDistance = distance;
|
|
583
|
+
best = note;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
if (best !== null && bestDistance <= HIT_RANGE) {
|
|
589
|
+
recycle(best);
|
|
590
|
+
goodSound.play();
|
|
591
|
+
burst.x = LANES[lane];
|
|
592
|
+
burst.y = PAD_Y;
|
|
593
|
+
burst.tint = LANE_TINTS[lane];
|
|
594
|
+
burst.emit(18);
|
|
595
|
+
score += (bestDistance <= PERFECT) ? 3 : 1;
|
|
596
|
+
} else {
|
|
597
|
+
badSound.play();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
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.
|
|
603
|
+
|
|
604
|
+
## Ball sports / breakout physics
|
|
605
|
+
|
|
606
|
+
`restitution` on a circle-hitbox ball gives real reflection off walls and obstacles. Player contact is handled in JS so you control the feel.
|
|
607
|
+
|
|
608
|
+
```javascript
|
|
609
|
+
const ball = Game.createSprite({
|
|
610
|
+
sheet: ballSheet,
|
|
611
|
+
x: W / 2, y: H * 0.3, width: BALL_SIZE, height: BALL_SIZE,
|
|
612
|
+
zIndex: 12,
|
|
613
|
+
hitboxScale: 0.9,
|
|
614
|
+
hitboxShape: 'circle', // bounces off the net's corner along the normal
|
|
615
|
+
restitution: 0.75,
|
|
616
|
+
solidWith: ['wall', 'net'], // blocked and bounced by these
|
|
617
|
+
collidesWith: ['player', 'floor'] // only reported for these
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
ball.addEventListener('collision', (e) => {
|
|
621
|
+
if (e.group === 'player') {
|
|
622
|
+
// reflect away from the blob's center, biased upward
|
|
623
|
+
const blob = e.other;
|
|
624
|
+
const dx = ball.x - blob.x;
|
|
625
|
+
const dy = ball.y - blob.y;
|
|
626
|
+
const len = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
627
|
+
ball.velocityX = (dx / len) * HIT_SPEED;
|
|
628
|
+
ball.velocityY = Math.min((dy / len) * HIT_SPEED, -HIT_SPEED * 0.45);
|
|
629
|
+
} else if (e.group === 'floor') {
|
|
630
|
+
roundOver(ball.x > W / 2);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
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.
|
|
636
|
+
|
|
637
|
+
A `restitution: 1` ball on an invisible floor bounces forever with no JS at all — and since 2026-08-27 the bounce can live on the *floor* instead, which is what makes one springy platform in an otherwise dead level a one-property change. Round obstacles, bumpers that knock each other around and a table that brings everything to rest are all in [Shaped solids](patterns.md#shaped-solids-ramps-round-posts-and-bodies-that-push-back--slopesjs-circlesjs-pooljs-drumjs).
|
|
638
|
+
|
|
639
|
+
**Opponent AI** on an 80 ms timer, reading a handful of live properties per tick:
|
|
640
|
+
|
|
641
|
+
```javascript
|
|
642
|
+
timer = setInterval(() => {
|
|
643
|
+
const onCpuSide = ball.x > W / 2;
|
|
644
|
+
const target = (running && onCpuSide) ? ball.x + PLAYER_SIZE * 0.2 : cpuHomeX;
|
|
645
|
+
const dx = target - cpu.x;
|
|
646
|
+
cpu.velocityX = (Math.abs(dx) > PLAYER_SIZE * 0.15) ? (dx > 0 ? RUN_SPEED : -RUN_SPEED) : 0;
|
|
647
|
+
if (running && onCpuSide && cpu.onGround
|
|
648
|
+
&& Math.abs(ball.x - cpu.x) < PLAYER_SIZE * 1.2
|
|
649
|
+
&& ball.y < cpu.y && ball.velocityY > 0) {
|
|
650
|
+
cpu.velocityY = -JUMP;
|
|
651
|
+
}
|
|
652
|
+
}, 80);
|
|
653
|
+
```
|