incanto 0.57.0 → 0.58.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 +6 -4
- package/bin/incanto-check.mjs +27 -0
- package/bin/incanto-new.mjs +29 -7
- package/bin/incanto-playtest.mjs +28 -2
- package/bin/incanto-verify.mjs +87 -15
- package/bin/incanto.mjs +106 -0
- package/dist/2d.js +2 -2
- package/dist/3d.js +2 -2
- package/dist/{create-game-DpbUrMOQ.js → create-game-BZwWJIns.js} +1 -1
- package/dist/{create-game-C5jQYPah.js → create-game-D10bU5_J.js} +56 -8
- package/dist/index.js +1 -1
- package/dist/{physics-2d-BLcvEFDR.js → physics-2d-EqA-jddf.js} +2 -1
- package/dist/{physics-3d-QBrfIT2Y.js → physics-3d-Dnz4fsXX.js} +2 -1
- package/dist/quiet-rapier-BAJ4K94N.js +46 -0
- package/dist/react.js +1 -1
- package/dist/{src-CGjmPw65.js → src-C1J09Op6.js} +1 -1
- package/dist/{test-it1VekWs.js → test-ZBga8kQ9.js} +36 -10
- package/dist/test.d.ts +1 -1
- package/dist/test.js +1 -1
- package/dist/vite.js +2 -2
- package/editor/assets/{agent8-CGT7r3Mb.js → agent8-BQQjE9UQ.js} +1 -1
- package/editor/assets/{debug-BxWSIHG3.js → debug-C9UCsXBS.js} +1 -1
- package/editor/assets/{index-CV1m-aX5.js → index-CBgfM3WD.js} +51 -51
- package/editor/index.html +1 -1
- package/package.json +2 -1
- package/skills/incanto-building-2d-games.md +13 -0
- package/skills/incanto-building-3d-games.md +3 -3
- package/skills/incanto-playtesting.md +19 -5
- package/skills/incanto-verifying-your-game.md +11 -4
- package/templates-app/beacon-isle-3d/package.json +1 -1
- package/templates-app/beacon-isle-3d/src/game.scene.json +7 -6
- package/templates-app/platformer-2d/PROJECT/Context.md +70 -0
- package/templates-app/platformer-2d/PROJECT/Requirements.md +63 -0
- package/templates-app/platformer-2d/PROJECT/Status.md +60 -0
- package/templates-app/platformer-2d/PROJECT/Structure.md +77 -0
- package/templates-app/platformer-2d/docs/project-2d-rules.md +61 -0
- package/templates-app/platformer-2d/index.html +99 -0
- package/templates-app/platformer-2d/package.json +23 -0
- package/templates-app/platformer-2d/src/behaviors.ts +541 -0
- package/templates-app/platformer-2d/src/game.scene.json +2061 -0
- package/templates-app/platformer-2d/src/main.ts +68 -0
- package/templates-app/platformer-2d/tsconfig.json +13 -0
- package/templates-app/platformer-2d/verify.ts +275 -0
- package/templates-app/platformer-2d/vite.config.ts +12 -0
- package/templates-app/star-survivor/PROJECT/Context.md +55 -0
- package/templates-app/star-survivor/PROJECT/Requirements.md +47 -0
- package/templates-app/star-survivor/PROJECT/Status.md +44 -0
- package/templates-app/star-survivor/PROJECT/Structure.md +63 -0
- package/templates-app/star-survivor/docs/project-2d-rules.md +53 -0
- package/templates-app/star-survivor/index.html +232 -0
- package/templates-app/star-survivor/package.json +23 -0
- package/templates-app/star-survivor/src/behaviors.ts +624 -0
- package/templates-app/star-survivor/src/game.scene.json +464 -0
- package/templates-app/star-survivor/src/main.ts +49 -0
- package/templates-app/star-survivor/tsconfig.json +13 -0
- package/templates-app/star-survivor/verify.ts +193 -0
- package/templates-app/star-survivor/vite.config.ts +12 -0
- package/templates-app/tps-3d/package.json +1 -1
- package/templates-app/tps-3d/src/game.scene.json +6 -3
- package/templates-app/tps-3d/verify.ts +17 -1
- package/templates-app/village-quest-3d/package.json +1 -1
- package/templates-app/village-quest-3d/src/grove.scene.json +14 -13
- package/templates-app/village-quest-3d/src/village.scene.json +5 -5
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "platformer-2d",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite",
|
|
7
|
+
"build": "vite build",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"check": "incanto-check src",
|
|
10
|
+
"verify": "bun verify.ts"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@dimforge/rapier2d-compat": "0.19.3",
|
|
14
|
+
"incanto": "^0.58.0",
|
|
15
|
+
"three": "^0.184.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^24.0.0",
|
|
19
|
+
"@types/three": "^0.184.1",
|
|
20
|
+
"typescript": "^6.0.3",
|
|
21
|
+
"vite": "^8.0.16"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Castle Run — the custom behaviors. Almost everything that CAN be a built-in IS
|
|
3
|
+
* one (Pickup coins, Patrol enemies/lifts, Oscillate bobbing pickups, ScoreKeeper
|
|
4
|
+
* score/lives/win-lose, AudioPlayer SFX, all wired in `game.scene.json`). These
|
|
5
|
+
* exist only for the parts a renderer-agnostic library has no opinion about — the
|
|
6
|
+
* platformer GAME FEEL and the presentation:
|
|
7
|
+
*
|
|
8
|
+
* PlayerController — the heart. A hand-tuned platformer controller on the
|
|
9
|
+
* CharacterBody2D: run + gravity + COYOTE TIME + JUMP BUFFER + DOUBLE JUMP +
|
|
10
|
+
* VARIABLE jump height, plus STOMP-to-kill (Mario-style), side-hit knockback,
|
|
11
|
+
* hearts + lives, checkpoint RESPAWN, moving-platform CARRY, and the
|
|
12
|
+
* idle/run/jump sprite + facing. The built-in CharacterController2D is
|
|
13
|
+
* frame-perfect and stiff; a great platformer needs the forgiveness above.
|
|
14
|
+
* GoblinSkin — face the patrol direction + play the goblin walk clip.
|
|
15
|
+
* FollowCam — follow the knight (look-ahead + smoothing + world clamp) AND
|
|
16
|
+
* screen-SHAKE on stomps/landings/hits (juice the built-in camera
|
|
17
|
+
* can't do; one script per node, so follow + shake live together).
|
|
18
|
+
* ParallaxLayer— scroll a castle backdrop slower than the camera for depth.
|
|
19
|
+
* HudUpdater — paint ScoreKeeper/hearts/lives into the HUD + win/lose banner.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Node } from 'incanto';
|
|
23
|
+
import { Behavior } from 'incanto';
|
|
24
|
+
import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Label, Node2D } from 'incanto/2d';
|
|
25
|
+
import { Particles2D } from 'incanto/2d';
|
|
26
|
+
|
|
27
|
+
// ---- tuning (px, y-down; scene gravity is [0, 1800]) --------------------------
|
|
28
|
+
const RUN_SPEED = 250;
|
|
29
|
+
const JUMP_V = 735; // ≈ √(2·1800·150) → ~150px apex
|
|
30
|
+
const DOUBLE_V = 625; // the air jump is a touch weaker
|
|
31
|
+
const MAX_FALL = 1250;
|
|
32
|
+
const GROUND_SNAP = 20; // tiny downward bias keeps the controller glued to the floor
|
|
33
|
+
const COYOTE = 0.1; // grace window to still jump just after leaving a ledge
|
|
34
|
+
const BUFFER = 0.12; // press jump slightly early and it still fires on landing
|
|
35
|
+
const JUMP_CUT = 0.45; // release jump while rising → cut the climb (variable height)
|
|
36
|
+
const MAX_JUMPS = 2; // ground jump + one air (double) jump
|
|
37
|
+
const STOMP_BOUNCE = 560; // upward pop after squashing a goblin
|
|
38
|
+
const KNOCK_X = 300;
|
|
39
|
+
const KNOCK_UP = 360;
|
|
40
|
+
const CONTROL_LOCK = 0.22; // seconds the player can't steer after a knockback
|
|
41
|
+
const INVULN = 1.1; // i-frames after a hit
|
|
42
|
+
const RESPAWN_INVULN = 1.4;
|
|
43
|
+
const MAX_HEARTS = 3;
|
|
44
|
+
const PLAYER_HW = 18;
|
|
45
|
+
const PLAYER_HH = 33;
|
|
46
|
+
const ENEMY_HW = 26;
|
|
47
|
+
const ENEMY_HH = 30;
|
|
48
|
+
const STOMP_SCORE = 50;
|
|
49
|
+
|
|
50
|
+
const aabb = (
|
|
51
|
+
ax: number,
|
|
52
|
+
ay: number,
|
|
53
|
+
ahw: number,
|
|
54
|
+
ahh: number,
|
|
55
|
+
bx: number,
|
|
56
|
+
by: number,
|
|
57
|
+
bhw: number,
|
|
58
|
+
bhh: number,
|
|
59
|
+
): boolean => Math.abs(ax - bx) < ahw + bhw && Math.abs(ay - by) < ahh + bhh;
|
|
60
|
+
|
|
61
|
+
interface ScoreLike {
|
|
62
|
+
score: number;
|
|
63
|
+
lives: number;
|
|
64
|
+
scoreToWin: number;
|
|
65
|
+
addScore(n: number): void;
|
|
66
|
+
setScore(n: number): void;
|
|
67
|
+
loseLife(): void;
|
|
68
|
+
}
|
|
69
|
+
interface Audio {
|
|
70
|
+
play(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class PlayerController extends Behavior {
|
|
74
|
+
hearts = MAX_HEARTS;
|
|
75
|
+
|
|
76
|
+
private gravity = 1800;
|
|
77
|
+
private coyote = 0;
|
|
78
|
+
private buffer = 0;
|
|
79
|
+
private jumps = 0;
|
|
80
|
+
private invuln = 0;
|
|
81
|
+
private lock = 0;
|
|
82
|
+
private wasGrounded = true;
|
|
83
|
+
private respawn: [number, number] = [0, 0];
|
|
84
|
+
private platform: Node2D | null = null;
|
|
85
|
+
private platPrev: [number, number] = [0, 0];
|
|
86
|
+
private over = false;
|
|
87
|
+
private facing = 1;
|
|
88
|
+
|
|
89
|
+
private get body(): CharacterBody2D {
|
|
90
|
+
return this.node as unknown as CharacterBody2D;
|
|
91
|
+
}
|
|
92
|
+
private get skin(): AnimatedSprite2D {
|
|
93
|
+
return this.getNode('Skin') as unknown as AnimatedSprite2D;
|
|
94
|
+
}
|
|
95
|
+
private get scoreKeeper(): ScoreLike {
|
|
96
|
+
return this.getNode('/root').behavior as unknown as ScoreLike;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private sfx(name: string): void {
|
|
100
|
+
(this.getNode(`/root/${name}`) as unknown as Audio | null)?.play?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private gravityRead = false;
|
|
104
|
+
|
|
105
|
+
override onReady(): void {
|
|
106
|
+
const p = this.body.position;
|
|
107
|
+
this.respawn = [p[0] ?? 0, p[1] ?? 0];
|
|
108
|
+
this.getNode('/root').on('lost', () => {
|
|
109
|
+
this.over = true;
|
|
110
|
+
});
|
|
111
|
+
// moving-platform CARRY: a Feet sensor remembers the platform we stand on so we
|
|
112
|
+
// ride its motion (the kinematic controller doesn't inherit platform velocity).
|
|
113
|
+
const feet = this.getNode('Feet');
|
|
114
|
+
feet.on('triggerEnter', (o) => {
|
|
115
|
+
const n = o as Node2D;
|
|
116
|
+
if ((n as unknown as Node).isInGroup?.('platform')) {
|
|
117
|
+
this.platform = n;
|
|
118
|
+
this.platPrev = [n.position[0] ?? 0, n.position[1] ?? 0];
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
feet.on('triggerExit', (o) => {
|
|
122
|
+
if (o === (this.platform as unknown)) this.platform = null;
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
override fixedUpdate(dt: number): void {
|
|
127
|
+
const body = this.body;
|
|
128
|
+
if (!this.gravityRead) {
|
|
129
|
+
this.gravity = (this.engine.scene?.physics?.gravity as number[] | undefined)?.[1] ?? 1800;
|
|
130
|
+
this.gravityRead = true;
|
|
131
|
+
}
|
|
132
|
+
if (this.over) {
|
|
133
|
+
body.velocity = [0, 0];
|
|
134
|
+
body.moveAndSlide();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
this.coyote = Math.max(0, this.coyote - dt);
|
|
139
|
+
this.buffer = Math.max(0, this.buffer - dt);
|
|
140
|
+
this.invuln = Math.max(0, this.invuln - dt);
|
|
141
|
+
this.lock = Math.max(0, this.lock - dt);
|
|
142
|
+
|
|
143
|
+
const grounded = body.isOnFloor();
|
|
144
|
+
const dir = this.input.getVector('move');
|
|
145
|
+
let vx = body.velocity[0] ?? 0;
|
|
146
|
+
let vy = body.velocity[1] ?? 0;
|
|
147
|
+
|
|
148
|
+
if (this.lock <= 0) vx = dir.x * RUN_SPEED;
|
|
149
|
+
|
|
150
|
+
if (grounded) {
|
|
151
|
+
this.jumps = 0;
|
|
152
|
+
this.coyote = COYOTE;
|
|
153
|
+
if (vy >= 0) vy = GROUND_SNAP;
|
|
154
|
+
if (!this.wasGrounded && vy >= 0) this.onLand();
|
|
155
|
+
} else {
|
|
156
|
+
vy += this.gravity * dt;
|
|
157
|
+
if (vy > MAX_FALL) vy = MAX_FALL;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.input.justPressed('jump')) this.buffer = BUFFER;
|
|
161
|
+
if (this.buffer > 0) {
|
|
162
|
+
if (this.jumps === 0 && (grounded || this.coyote > 0)) {
|
|
163
|
+
vy = -JUMP_V;
|
|
164
|
+
this.jumps = 1;
|
|
165
|
+
this.buffer = 0;
|
|
166
|
+
this.coyote = 0;
|
|
167
|
+
this.platform = null;
|
|
168
|
+
this.sfx('SfxJump');
|
|
169
|
+
} else if (this.jumps < MAX_JUMPS) {
|
|
170
|
+
vy = -DOUBLE_V;
|
|
171
|
+
this.jumps = Math.max(this.jumps, 1) + 1;
|
|
172
|
+
this.buffer = 0;
|
|
173
|
+
this.platform = null;
|
|
174
|
+
this.sfx('SfxJump');
|
|
175
|
+
this.puff(body.position[0] ?? 0, (body.position[1] ?? 0) + PLAYER_HH, '#cfe8ff');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (this.input.justReleased('jump') && vy < 0) vy *= JUMP_CUT;
|
|
179
|
+
|
|
180
|
+
body.velocity = [vx, vy];
|
|
181
|
+
|
|
182
|
+
// ride a moving platform: add its per-frame delta before resolving collisions
|
|
183
|
+
if (this.platform && grounded) {
|
|
184
|
+
const cx = this.platform.position[0] ?? 0;
|
|
185
|
+
const cy = this.platform.position[1] ?? 0;
|
|
186
|
+
body.position = [
|
|
187
|
+
(body.position[0] ?? 0) + (cx - this.platPrev[0]),
|
|
188
|
+
(body.position[1] ?? 0) + (cy - this.platPrev[1]),
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
if (this.platform) {
|
|
192
|
+
this.platPrev = [this.platform.position[0] ?? 0, this.platform.position[1] ?? 0];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const preBottom = (body.position[1] ?? 0) + PLAYER_HH;
|
|
196
|
+
body.moveAndSlide();
|
|
197
|
+
this.wasGrounded = grounded;
|
|
198
|
+
|
|
199
|
+
this.resolveContacts(preBottom);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Stomps, side-hits, spikes, pits, checkpoints, goal — all by AABB so there is
|
|
203
|
+
* one authority and no double-trigger (stomp vs damage on the same frame). */
|
|
204
|
+
private resolveContacts(preBottom: number): void {
|
|
205
|
+
const body = this.body;
|
|
206
|
+
const px = body.position[0] ?? 0;
|
|
207
|
+
const py = body.position[1] ?? 0;
|
|
208
|
+
const tree = this.node.tree;
|
|
209
|
+
if (!tree) return;
|
|
210
|
+
let vy = body.velocity[1] ?? 0;
|
|
211
|
+
|
|
212
|
+
for (const e of tree.getNodesInGroup('enemy')) {
|
|
213
|
+
const en = e as unknown as Node2D;
|
|
214
|
+
const ex = en.position[0] ?? 0;
|
|
215
|
+
const ey = en.position[1] ?? 0;
|
|
216
|
+
if (!aabb(px, py, PLAYER_HW, PLAYER_HH, ex, ey, ENEMY_HW, ENEMY_HH)) continue;
|
|
217
|
+
const cameFromAbove = preBottom <= ey - ENEMY_HH + 18 && vy > 0;
|
|
218
|
+
if (cameFromAbove) {
|
|
219
|
+
e.queueFree();
|
|
220
|
+
this.scoreKeeper.addScore(STOMP_SCORE);
|
|
221
|
+
this.sfx('SfxStomp');
|
|
222
|
+
this.puff(ex, ey - ENEMY_HH, '#9be36b');
|
|
223
|
+
vy = -STOMP_BOUNCE;
|
|
224
|
+
body.velocity = [body.velocity[0] ?? 0, vy];
|
|
225
|
+
this.jumps = 1; // a stomp refreshes the air jump — chain bounces
|
|
226
|
+
this.shake(8);
|
|
227
|
+
} else if (this.invuln <= 0) {
|
|
228
|
+
this.takeHit(px < ex ? -1 : 1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (this.invuln <= 0) {
|
|
233
|
+
for (const h of tree.getNodesInGroup('hazard')) {
|
|
234
|
+
const hn = h as unknown as Node2D;
|
|
235
|
+
const size = ((hn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [
|
|
236
|
+
40, 40,
|
|
237
|
+
]) as number[];
|
|
238
|
+
if (
|
|
239
|
+
aabb(
|
|
240
|
+
px,
|
|
241
|
+
py,
|
|
242
|
+
PLAYER_HW,
|
|
243
|
+
PLAYER_HH,
|
|
244
|
+
hn.position[0] ?? 0,
|
|
245
|
+
hn.position[1] ?? 0,
|
|
246
|
+
(size[0] ?? 40) / 2,
|
|
247
|
+
(size[1] ?? 40) / 2,
|
|
248
|
+
)
|
|
249
|
+
) {
|
|
250
|
+
this.takeHit(this.facing * -1);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
for (const pit of tree.getNodesInGroup('pit')) {
|
|
257
|
+
const pn = pit as unknown as Node2D;
|
|
258
|
+
const size = ((pn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [
|
|
259
|
+
4000, 80,
|
|
260
|
+
]) as number[];
|
|
261
|
+
if (
|
|
262
|
+
aabb(
|
|
263
|
+
px,
|
|
264
|
+
py,
|
|
265
|
+
PLAYER_HW,
|
|
266
|
+
PLAYER_HH,
|
|
267
|
+
pn.position[0] ?? 0,
|
|
268
|
+
pn.position[1] ?? 0,
|
|
269
|
+
(size[0] ?? 4000) / 2,
|
|
270
|
+
(size[1] ?? 80) / 2,
|
|
271
|
+
)
|
|
272
|
+
) {
|
|
273
|
+
this.die();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const c of tree.getNodesInGroup('checkpoint')) {
|
|
279
|
+
const cn = c as unknown as Node2D;
|
|
280
|
+
if (aabb(px, py, PLAYER_HW, PLAYER_HH, cn.position[0] ?? 0, cn.position[1] ?? 0, 36, 80)) {
|
|
281
|
+
const cx = cn.position[0] ?? 0;
|
|
282
|
+
const cy = cn.position[1] ?? 0;
|
|
283
|
+
if (this.respawn[0] !== cx || this.respawn[1] !== cy) {
|
|
284
|
+
this.respawn = [cx, cy];
|
|
285
|
+
this.sfx('SfxCheckpoint');
|
|
286
|
+
this.puff(cx, cy - 40, '#ffd166');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const g of tree.getNodesInGroup('goal')) {
|
|
292
|
+
const gn = g as unknown as Node2D;
|
|
293
|
+
if (aabb(px, py, PLAYER_HW, PLAYER_HH, gn.position[0] ?? 0, gn.position[1] ?? 0, 40, 80)) {
|
|
294
|
+
const sk = this.scoreKeeper;
|
|
295
|
+
if (sk.score < sk.scoreToWin) {
|
|
296
|
+
sk.setScore(sk.scoreToWin);
|
|
297
|
+
this.over = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private takeHit(dirX: number): void {
|
|
304
|
+
this.hearts -= 1;
|
|
305
|
+
this.invuln = INVULN;
|
|
306
|
+
this.lock = CONTROL_LOCK;
|
|
307
|
+
this.body.velocity = [dirX * KNOCK_X, -KNOCK_UP];
|
|
308
|
+
this.sfx('SfxHurt');
|
|
309
|
+
this.shake(10);
|
|
310
|
+
if (this.hearts <= 0) this.die();
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private die(): void {
|
|
314
|
+
const sk = this.scoreKeeper;
|
|
315
|
+
sk.loseLife();
|
|
316
|
+
if (sk.lives > 0) {
|
|
317
|
+
this.hearts = MAX_HEARTS;
|
|
318
|
+
this.invuln = RESPAWN_INVULN;
|
|
319
|
+
this.lock = 0;
|
|
320
|
+
this.platform = null;
|
|
321
|
+
this.body.velocity = [0, 0];
|
|
322
|
+
this.body.position = [this.respawn[0], this.respawn[1]];
|
|
323
|
+
this.shake(6);
|
|
324
|
+
} else {
|
|
325
|
+
this.over = true;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private onLand(): void {
|
|
330
|
+
const vyFall = this.body.velocity[1] ?? 0;
|
|
331
|
+
if (vyFall > 200) {
|
|
332
|
+
this.puff(this.body.position[0] ?? 0, (this.body.position[1] ?? 0) + PLAYER_HH, '#d9c9a3');
|
|
333
|
+
this.shake(3);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private shake(mag: number): void {
|
|
338
|
+
const cam = this.getNode('/root/Camera').behavior as unknown as { shake?: (m: number) => void };
|
|
339
|
+
cam?.shake?.(mag);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private puff(x: number, y: number, color: string): void {
|
|
343
|
+
const p = new Particles2D('Puff');
|
|
344
|
+
p.position = [x, y];
|
|
345
|
+
p.preset = 'custom';
|
|
346
|
+
p.emitting = false;
|
|
347
|
+
p.rate = 0;
|
|
348
|
+
p.burst = 12;
|
|
349
|
+
p.spreadDeg = 360;
|
|
350
|
+
p.directionDeg = -90;
|
|
351
|
+
p.lifetime = [0.18, 0.4];
|
|
352
|
+
p.speed = [60, 200];
|
|
353
|
+
p.gravity = [0, 300];
|
|
354
|
+
p.drag = 2;
|
|
355
|
+
p.sizeStart = 16;
|
|
356
|
+
p.sizeEnd = 2;
|
|
357
|
+
p.colorStart = color;
|
|
358
|
+
p.colorEnd = color;
|
|
359
|
+
p.alphaStart = 0.9;
|
|
360
|
+
p.alphaEnd = 0;
|
|
361
|
+
p.blend = 'normal';
|
|
362
|
+
p.maxParticles = 14;
|
|
363
|
+
this.node.getRoot().addChild(p as unknown as Node);
|
|
364
|
+
p.on('finished', () => p.parent?.removeChild(p as unknown as Node));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---- presentation (animation + facing + i-frame blink) ----------------------
|
|
368
|
+
override update(): void {
|
|
369
|
+
const body = this.body;
|
|
370
|
+
const skin = this.skin;
|
|
371
|
+
if (!skin) return;
|
|
372
|
+
const vx = body.velocity[0] ?? 0;
|
|
373
|
+
const vy = body.velocity[1] ?? 0;
|
|
374
|
+
if (vx > 8) this.facing = 1;
|
|
375
|
+
else if (vx < -8) this.facing = -1;
|
|
376
|
+
skin.flipX = this.facing < 0;
|
|
377
|
+
|
|
378
|
+
let clip = 'idle';
|
|
379
|
+
if (!body.isOnFloor() && Math.abs(vy) > 40) clip = 'jump';
|
|
380
|
+
else if (Math.abs(vx) > 12) clip = 'run';
|
|
381
|
+
this.playOnce(skin, clip);
|
|
382
|
+
|
|
383
|
+
// blink while invulnerable
|
|
384
|
+
skin.opacity = this.invuln > 0 && Math.floor(this.invuln * 14) % 2 === 0 ? 0.35 : 1;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private lastClip = '';
|
|
388
|
+
private playOnce(skin: AnimatedSprite2D, name: string): void {
|
|
389
|
+
if (this.lastClip === name) return;
|
|
390
|
+
this.lastClip = name;
|
|
391
|
+
skin.play(name);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* GoblinSkin — face the patrol heading + play the walk clip. The built-in Patrol
|
|
397
|
+
* moves the node; this only flips the sprite to look where it walks.
|
|
398
|
+
*/
|
|
399
|
+
export class GoblinSkin extends Behavior {
|
|
400
|
+
private lastX = 0;
|
|
401
|
+
/** The goblin's moving root (Patrol drives the parent; this lives on a child). */
|
|
402
|
+
private get root(): Node2D {
|
|
403
|
+
return this.node.parent as unknown as Node2D;
|
|
404
|
+
}
|
|
405
|
+
private get skin(): AnimatedSprite2D {
|
|
406
|
+
return this.getNode('../Skin') as unknown as AnimatedSprite2D;
|
|
407
|
+
}
|
|
408
|
+
override onReady(): void {
|
|
409
|
+
this.lastX = this.root.position[0] ?? 0;
|
|
410
|
+
this.skin?.play('move');
|
|
411
|
+
}
|
|
412
|
+
override update(): void {
|
|
413
|
+
const x = this.root.position[0] ?? 0;
|
|
414
|
+
const dx = x - this.lastX;
|
|
415
|
+
this.lastX = x;
|
|
416
|
+
if (Math.abs(dx) > 0.05) this.skin.flipX = dx < 0; // sheet faces right by default
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* FollowCam — chase the knight (look-ahead + smoothing + world clamp) and add a
|
|
422
|
+
* decaying screen SHAKE on demand. One script per node, so the camera owns both.
|
|
423
|
+
*/
|
|
424
|
+
export class FollowCam extends Behavior {
|
|
425
|
+
static readonly props = {
|
|
426
|
+
target: { default: '/root/Player' },
|
|
427
|
+
offset: { default: [70, -40] },
|
|
428
|
+
lookahead: { default: 90 },
|
|
429
|
+
limits: { default: [] as number[] },
|
|
430
|
+
};
|
|
431
|
+
target = '/root/Player';
|
|
432
|
+
offset: number[] = [70, -40];
|
|
433
|
+
lookahead = 90;
|
|
434
|
+
limits: number[] = [];
|
|
435
|
+
|
|
436
|
+
private shakeT = 0;
|
|
437
|
+
private shakeMag = 0;
|
|
438
|
+
private lookX = 0;
|
|
439
|
+
|
|
440
|
+
shake(mag: number): void {
|
|
441
|
+
this.shakeMag = Math.max(this.shakeMag, mag);
|
|
442
|
+
this.shakeT = 0.28;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
override update(dt: number): void {
|
|
446
|
+
const cam = this.node as unknown as Camera2D;
|
|
447
|
+
const t = this.getNode(this.target) as unknown as Node2D | null;
|
|
448
|
+
if (!t) return;
|
|
449
|
+
const vx = ((t.behavior as unknown as { body?: { velocity: number[] } })?.body?.velocity?.[0] ??
|
|
450
|
+
0) as number;
|
|
451
|
+
const wantLook = vx > 8 ? this.lookahead : vx < -8 ? -this.lookahead : this.lookX;
|
|
452
|
+
this.lookX += (wantLook - this.lookX) * Math.min(1, dt * 4);
|
|
453
|
+
|
|
454
|
+
const dx = (t.position[0] ?? 0) + (this.offset[0] ?? 0) + this.lookX;
|
|
455
|
+
const dy = (t.position[1] ?? 0) + (this.offset[1] ?? 0);
|
|
456
|
+
const a = 1 - Math.exp(-dt * 7);
|
|
457
|
+
let cx = (cam.position[0] ?? 0) + (dx - (cam.position[0] ?? 0)) * a;
|
|
458
|
+
let cy = (cam.position[1] ?? 0) + (dy - (cam.position[1] ?? 0)) * a;
|
|
459
|
+
|
|
460
|
+
if (this.limits.length === 4) {
|
|
461
|
+
cx = Math.min(Math.max(cx, this.limits[0] ?? cx), this.limits[2] ?? cx);
|
|
462
|
+
cy = Math.min(Math.max(cy, this.limits[1] ?? cy), this.limits[3] ?? cy);
|
|
463
|
+
}
|
|
464
|
+
if (this.shakeT > 0) {
|
|
465
|
+
this.shakeT -= dt;
|
|
466
|
+
const k = (this.shakeMag * Math.max(0, this.shakeT)) / 0.28;
|
|
467
|
+
cx += (this.rng.next() * 2 - 1) * k;
|
|
468
|
+
cy += (this.rng.next() * 2 - 1) * k;
|
|
469
|
+
if (this.shakeT <= 0) this.shakeMag = 0;
|
|
470
|
+
}
|
|
471
|
+
cam.position = [cx, cy];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* ParallaxLayer — scroll a backdrop slower than the camera for depth. factor 0 =
|
|
477
|
+
* locked to the world, 1 = locked to the screen (infinitely far).
|
|
478
|
+
*/
|
|
479
|
+
export class ParallaxLayer extends Behavior {
|
|
480
|
+
static readonly props = { factor: { default: 0.5 } };
|
|
481
|
+
factor = 0.5;
|
|
482
|
+
private baseX = 0;
|
|
483
|
+
|
|
484
|
+
override onReady(): void {
|
|
485
|
+
this.baseX = (this.node as unknown as Node2D).position[0] ?? 0;
|
|
486
|
+
}
|
|
487
|
+
override update(): void {
|
|
488
|
+
const cam = this.getNode('/root/Camera') as unknown as Node2D;
|
|
489
|
+
const layer = this.node as unknown as Node2D;
|
|
490
|
+
layer.position = [this.baseX + (cam.position[0] ?? 0) * this.factor, layer.position[1] ?? 0];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* HudUpdater — paint ScoreKeeper.score, the player's hearts, and lives into the
|
|
496
|
+
* HUD Labels; flip the won/lost signals into the centered banner. No rules here.
|
|
497
|
+
*/
|
|
498
|
+
interface HeartState {
|
|
499
|
+
hearts: number;
|
|
500
|
+
}
|
|
501
|
+
export class HudUpdater extends Behavior {
|
|
502
|
+
private get coinLabel(): Label {
|
|
503
|
+
return this.getNode('CoinLabel') as unknown as Label;
|
|
504
|
+
}
|
|
505
|
+
private get heartLabel(): Label {
|
|
506
|
+
return this.getNode('HeartLabel') as unknown as Label;
|
|
507
|
+
}
|
|
508
|
+
private get livesLabel(): Label {
|
|
509
|
+
return this.getNode('LivesLabel') as unknown as Label;
|
|
510
|
+
}
|
|
511
|
+
private get banner(): Label {
|
|
512
|
+
return this.getNode('/root/BannerLayer/Banner') as unknown as Label;
|
|
513
|
+
}
|
|
514
|
+
private get score(): ScoreLike {
|
|
515
|
+
return this.getNode('/root').behavior as unknown as ScoreLike;
|
|
516
|
+
}
|
|
517
|
+
private get player(): HeartState {
|
|
518
|
+
return this.getNode('/root/Player').behavior as unknown as HeartState;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
override onReady(): void {
|
|
522
|
+
const root = this.getNode('/root');
|
|
523
|
+
root.on('won', () => this.show('CASTLE CLEARED!', '#ffd166'));
|
|
524
|
+
root.on('lost', () => this.show('GAME OVER', '#ef476f'));
|
|
525
|
+
this.refresh();
|
|
526
|
+
}
|
|
527
|
+
override update(): void {
|
|
528
|
+
this.refresh();
|
|
529
|
+
}
|
|
530
|
+
private refresh(): void {
|
|
531
|
+
const hearts = Math.max(0, this.player.hearts ?? 0);
|
|
532
|
+
this.coinLabel.text = `◆ ${this.score.score}`;
|
|
533
|
+
this.heartLabel.text = `${'♥'.repeat(hearts)}${'·'.repeat(Math.max(0, 3 - hearts))}`;
|
|
534
|
+
this.livesLabel.text = `x ${Math.max(0, this.score.lives)}`;
|
|
535
|
+
}
|
|
536
|
+
private show(text: string, color: string): void {
|
|
537
|
+
const b = this.banner;
|
|
538
|
+
b.text = text;
|
|
539
|
+
b.color = color;
|
|
540
|
+
}
|
|
541
|
+
}
|