/** * Castle Run — the custom behaviors. Almost everything that CAN be a built-in IS * one (Pickup coins, Patrol enemies/lifts, Oscillate bobbing pickups, ScoreKeeper * score/lives/win-lose, AudioPlayer SFX, all wired in `game.scene.json`). These * exist only for the parts a renderer-agnostic library has no opinion about — the * platformer GAME FEEL and the presentation: * * PlayerController — the heart. A hand-tuned platformer controller on the * CharacterBody2D: run + gravity + COYOTE TIME + JUMP BUFFER + DOUBLE JUMP + * VARIABLE jump height, plus STOMP-to-kill (Mario-style), side-hit knockback, * hearts + lives, checkpoint RESPAWN, moving-platform CARRY, and the * idle/run/jump sprite + facing. The built-in CharacterController2D is * frame-perfect and stiff; a great platformer needs the forgiveness above. * GoblinSkin — face the patrol direction + play the goblin walk clip. * FollowCam — follow the knight (look-ahead + smoothing + world clamp) AND * screen-SHAKE on stomps/landings/hits (juice the built-in camera * can't do; one script per node, so follow + shake live together). * ParallaxLayer— scroll a castle backdrop slower than the camera for depth. * HudUpdater — paint ScoreKeeper/hearts/lives into the HUD + win/lose banner. */ import type { Node, UiBanner, UiText } from 'incanto'; import { Behavior } from 'incanto'; import type { AnimatedSprite2D, Camera2D, CharacterBody2D, Node2D } from 'incanto/2d'; import { Particles2D } from 'incanto/2d'; import type { Health } from 'incanto/gameplay'; // ---- tuning (px, y-down; scene gravity is [0, 1800]) -------------------------- const RUN_SPEED = 250; const JUMP_V = 735; // ≈ √(2·1800·150) → ~150px apex const DOUBLE_V = 625; // the air jump is a touch weaker const MAX_FALL = 1250; const GROUND_SNAP = 20; // tiny downward bias keeps the controller glued to the floor const COYOTE = 0.1; // grace window to still jump just after leaving a ledge const BUFFER = 0.12; // press jump slightly early and it still fires on landing const JUMP_CUT = 0.45; // release jump while rising → cut the climb (variable height) const MAX_JUMPS = 2; // ground jump + one air (double) jump const STOMP_BOUNCE = 560; // upward pop after squashing a goblin // hearts, i-frames, the knockback and the control lock after a hit are the // Player's `Vitals` Health (game.scene.json) — this controller reads them const RESPAWN_INVULN = 1.4; const PLAYER_HW = 18; const PLAYER_HH = 33; const ENEMY_HW = 26; const ENEMY_HH = 30; const STOMP_SCORE = 50; const aabb = ( ax: number, ay: number, ahw: number, ahh: number, bx: number, by: number, bhw: number, bhh: number, ): boolean => Math.abs(ax - bx) < ahw + bhw && Math.abs(ay - by) < ahh + bhh; interface ScoreLike { score: number; lives: number; scoreToWin: number; addScore(n: number): void; setScore(n: number): void; loseLife(): void; } interface Audio { play(): void; } export class PlayerController extends Behavior { /** Hearts = the Vitals' hit points; the HUD reads this. */ get hearts(): number { return this.health.current; } private gravity = 1800; private coyote = 0; private buffer = 0; private jumps = 0; private wasGrounded = true; private respawn: [number, number] = [0, 0]; private platform: Node2D | null = null; private platPrev: [number, number] = [0, 0]; private over = false; private facing = 1; private get body(): CharacterBody2D { return this.node as unknown as CharacterBody2D; } private get health(): Health { return this.getNode('Vitals').behavior as unknown as Health; } private get skin(): AnimatedSprite2D { return this.getNode('Skin') as unknown as AnimatedSprite2D; } private get scoreKeeper(): ScoreLike { return this.getNode('/root').behavior as unknown as ScoreLike; } private sfx(name: string): void { (this.getNode(`/root/${name}`) as unknown as Audio | null)?.play?.(); } private gravityRead = false; override onReady(): void { const p = this.body.position; this.respawn = [p[0] ?? 0, p[1] ?? 0]; this.getNode('/root').on('lost', () => { this.over = true; }); // a death is the Vitals' signal, whoever dealt it — a hit, a pit, or a // probe calling `kill()` — so every route spends a life the same way this.getNode('Vitals').on('died', () => this.die()); // moving-platform CARRY: a Feet sensor remembers the platform we stand on so we // ride its motion (the kinematic controller doesn't inherit platform velocity). const feet = this.getNode('Feet'); feet.on('triggerEnter', (o) => { const n = o as Node2D; if ((n as unknown as Node).isInGroup?.('platform')) { this.platform = n; this.platPrev = [n.position[0] ?? 0, n.position[1] ?? 0]; } }); feet.on('triggerExit', (o) => { if (o === (this.platform as unknown)) this.platform = null; }); } override fixedUpdate(dt: number): void { const body = this.body; if (!this.gravityRead) { this.gravity = (this.engine.scene?.physics?.gravity as number[] | undefined)?.[1] ?? 1800; this.gravityRead = true; } if (this.over) { body.velocity = [0, 0]; body.moveAndSlide(); return; } this.coyote = Math.max(0, this.coyote - dt); this.buffer = Math.max(0, this.buffer - dt); const grounded = body.isOnFloor(); const dir = this.input.getVector('move'); let vx = body.velocity[0] ?? 0; let vy = body.velocity[1] ?? 0; // a hit's stagger takes the stick, and its kick IS the velocity while it // lasts: this controller drives the body, so it reads the Health's kick // (`applyKnockback: false` in the scene) instead of Health moving the body const health = this.health; if (!health.staggered) vx = dir.x * RUN_SPEED; const kick = health.kickVelocity; if (kick.length > 0) vx = kick[0] ?? vx; if (grounded) { this.jumps = 0; this.coyote = COYOTE; if (vy >= 0) vy = GROUND_SNAP; if (!this.wasGrounded && vy >= 0) this.onLand(); } else { vy += this.gravity * dt; if (vy > MAX_FALL) vy = MAX_FALL; } if (this.input.justPressed('jump')) this.buffer = BUFFER; if (this.buffer > 0) { if (this.jumps === 0 && (grounded || this.coyote > 0)) { vy = -JUMP_V; this.jumps = 1; this.buffer = 0; this.coyote = 0; this.platform = null; this.sfx('SfxJump'); } else if (this.jumps < MAX_JUMPS) { vy = -DOUBLE_V; this.jumps = Math.max(this.jumps, 1) + 1; this.buffer = 0; this.platform = null; this.sfx('SfxJump'); this.puff(body.position[0] ?? 0, (body.position[1] ?? 0) + PLAYER_HH, '#cfe8ff'); } } if (this.input.justReleased('jump') && vy < 0) vy *= JUMP_CUT; // the hit's hop: the kick's vertical part comes out ONCE, then gravity owns it const lift = health.takeLift(); if (lift !== 0) { vy = lift; this.platform = null; } body.velocity = [vx, vy]; // ride a moving platform: add its per-frame delta before resolving collisions if (this.platform && grounded) { const cx = this.platform.position[0] ?? 0; const cy = this.platform.position[1] ?? 0; body.position = [ (body.position[0] ?? 0) + (cx - this.platPrev[0]), (body.position[1] ?? 0) + (cy - this.platPrev[1]), ]; } if (this.platform) { this.platPrev = [this.platform.position[0] ?? 0, this.platform.position[1] ?? 0]; } const preBottom = (body.position[1] ?? 0) + PLAYER_HH; body.moveAndSlide(); this.wasGrounded = grounded; this.resolveContacts(preBottom); } /** Stomps, side-hits, spikes, pits, checkpoints, goal — all by AABB so there is * one authority and no double-trigger (stomp vs damage on the same frame). */ private resolveContacts(preBottom: number): void { const body = this.body; const px = body.position[0] ?? 0; const py = body.position[1] ?? 0; const tree = this.node.tree; if (!tree) return; let vy = body.velocity[1] ?? 0; for (const e of tree.getNodesInGroup('enemy')) { const en = e as unknown as Node2D; const ex = en.position[0] ?? 0; const ey = en.position[1] ?? 0; if (!aabb(px, py, PLAYER_HW, PLAYER_HH, ex, ey, ENEMY_HW, ENEMY_HH)) continue; const cameFromAbove = preBottom <= ey - ENEMY_HH + 18 && vy > 0; if (cameFromAbove) { e.queueFree(); this.scoreKeeper.addScore(STOMP_SCORE); this.sfx('SfxStomp'); this.puff(ex, ey - ENEMY_HH, '#9be36b'); vy = -STOMP_BOUNCE; body.velocity = [body.velocity[0] ?? 0, vy]; this.jumps = 1; // a stomp refreshes the air jump — chain bounces this.shake(8); } else if (this.health.invulnerableRemaining <= 0) { this.takeHit([ex, ey]); } } if (this.health.invulnerableRemaining <= 0) { for (const h of tree.getNodesInGroup('hazard')) { const hn = h as unknown as Node2D; const size = ((hn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [ 40, 40, ]) as number[]; if ( aabb( px, py, PLAYER_HW, PLAYER_HH, hn.position[0] ?? 0, hn.position[1] ?? 0, (size[0] ?? 40) / 2, (size[1] ?? 40) / 2, ) ) { this.takeHit([px + this.facing * 10, py]); // a hazard hits from the front break; } } } for (const pit of tree.getNodesInGroup('pit')) { const pn = pit as unknown as Node2D; const size = ((pn as unknown as { collider?: { size?: number[] } }).collider?.size ?? [ 4000, 80, ]) as number[]; if ( aabb( px, py, PLAYER_HW, PLAYER_HH, pn.position[0] ?? 0, pn.position[1] ?? 0, (size[0] ?? 4000) / 2, (size[1] ?? 80) / 2, ) ) { this.health.kill(); // → `died` → `die()` return; } } for (const c of tree.getNodesInGroup('checkpoint')) { const cn = c as unknown as Node2D; if (aabb(px, py, PLAYER_HW, PLAYER_HH, cn.position[0] ?? 0, cn.position[1] ?? 0, 36, 80)) { const cx = cn.position[0] ?? 0; const cy = cn.position[1] ?? 0; if (this.respawn[0] !== cx || this.respawn[1] !== cy) { this.respawn = [cx, cy]; this.sfx('SfxCheckpoint'); this.puff(cx, cy - 40, '#ffd166'); } } } for (const g of tree.getNodesInGroup('goal')) { const gn = g as unknown as Node2D; if (aabb(px, py, PLAYER_HW, PLAYER_HH, gn.position[0] ?? 0, gn.position[1] ?? 0, 40, 80)) { const sk = this.scoreKeeper; if (sk.score < sk.scoreToWin) { sk.setScore(sk.scoreToWin); this.over = true; } } } } /** Hurt from a world point: the Vitals take the heart, the i-frames, the kick and the stagger. */ private takeHit(from: [number, number]): void { const health = this.health; health.damage(1, from); // the killing blow reaches `die()` through `died` this.sfx('SfxHurt'); this.shake(10); } private die(): void { const sk = this.scoreKeeper; sk.loseLife(); if (sk.lives > 0) { this.health.revive(); this.health.protect(RESPAWN_INVULN); this.platform = null; this.body.velocity = [0, 0]; this.body.position = [this.respawn[0], this.respawn[1]]; this.shake(6); } else { this.over = true; } } private onLand(): void { const vyFall = this.body.velocity[1] ?? 0; if (vyFall > 200) { this.puff(this.body.position[0] ?? 0, (this.body.position[1] ?? 0) + PLAYER_HH, '#d9c9a3'); this.shake(3); } } private shake(mag: number): void { const cam = this.getNode('/root/Camera').behavior as unknown as { shake?: (m: number) => void }; cam?.shake?.(mag); } private puff(x: number, y: number, color: string): void { const p = new Particles2D('Puff'); p.position = [x, y]; p.preset = 'custom'; p.emitting = false; p.rate = 0; p.burst = 12; p.spreadDeg = 360; p.directionDeg = -90; p.lifetime = [0.18, 0.4]; p.speed = [60, 200]; p.gravity = [0, 300]; p.drag = 2; p.sizeStart = 16; p.sizeEnd = 2; p.colorStart = color; p.colorEnd = color; p.alphaStart = 0.9; p.alphaEnd = 0; p.blend = 'normal'; p.maxParticles = 14; this.node.getRoot().addChild(p as unknown as Node); p.on('finished', () => p.parent?.removeChild(p as unknown as Node)); } // ---- presentation (animation + facing + i-frame blink) ---------------------- override update(): void { const body = this.body; const skin = this.skin; if (!skin) return; const vx = body.velocity[0] ?? 0; const vy = body.velocity[1] ?? 0; if (vx > 8) this.facing = 1; else if (vx < -8) this.facing = -1; skin.flipX = this.facing < 0; let clip = 'idle'; if (!body.isOnFloor() && Math.abs(vy) > 40) clip = 'jump'; else if (Math.abs(vx) > 12) clip = 'run'; this.playOnce(skin, clip); // blink while invulnerable const inv = this.health.invulnerableRemaining; skin.opacity = inv > 0 && Math.floor(inv * 14) % 2 === 0 ? 0.35 : 1; } private lastClip = ''; private playOnce(skin: AnimatedSprite2D, name: string): void { if (this.lastClip === name) return; this.lastClip = name; skin.play(name); } } /** * GoblinSkin — face the patrol heading + play the walk clip. The built-in Patrol * moves the node; this only flips the sprite to look where it walks. */ export class GoblinSkin extends Behavior { private lastX = 0; /** The goblin's moving root (Patrol drives the parent; this lives on a child). */ private get root(): Node2D { return this.node.parent as unknown as Node2D; } private get skin(): AnimatedSprite2D { return this.getNode('../Skin') as unknown as AnimatedSprite2D; } override onReady(): void { this.lastX = this.root.position[0] ?? 0; this.skin?.play('move'); } override update(): void { const x = this.root.position[0] ?? 0; const dx = x - this.lastX; this.lastX = x; if (Math.abs(dx) > 0.05) this.skin.flipX = dx < 0; // sheet faces right by default } } /** * FollowCam — chase the knight (look-ahead + smoothing + world clamp) and add a * decaying screen SHAKE on demand. One script per node, so the camera owns both. */ export class FollowCam extends Behavior { static readonly props = { target: { default: '/root/Player' }, offset: { default: [70, -40] }, lookahead: { default: 90 }, limits: { default: [] as number[] }, }; target = '/root/Player'; offset: number[] = [70, -40]; lookahead = 90; limits: number[] = []; private shakeT = 0; private shakeMag = 0; private lookX = 0; shake(mag: number): void { this.shakeMag = Math.max(this.shakeMag, mag); this.shakeT = 0.28; } override update(dt: number): void { const cam = this.node as unknown as Camera2D; const t = this.getNode(this.target) as unknown as Node2D | null; if (!t) return; const vx = ((t.behavior as unknown as { body?: { velocity: number[] } })?.body?.velocity?.[0] ?? 0) as number; const wantLook = vx > 8 ? this.lookahead : vx < -8 ? -this.lookahead : this.lookX; this.lookX += (wantLook - this.lookX) * Math.min(1, dt * 4); const dx = (t.position[0] ?? 0) + (this.offset[0] ?? 0) + this.lookX; const dy = (t.position[1] ?? 0) + (this.offset[1] ?? 0); const a = 1 - Math.exp(-dt * 7); let cx = (cam.position[0] ?? 0) + (dx - (cam.position[0] ?? 0)) * a; let cy = (cam.position[1] ?? 0) + (dy - (cam.position[1] ?? 0)) * a; if (this.limits.length === 4) { cx = Math.min(Math.max(cx, this.limits[0] ?? cx), this.limits[2] ?? cx); cy = Math.min(Math.max(cy, this.limits[1] ?? cy), this.limits[3] ?? cy); } if (this.shakeT > 0) { this.shakeT -= dt; const k = (this.shakeMag * Math.max(0, this.shakeT)) / 0.28; cx += (this.rng.next() * 2 - 1) * k; cy += (this.rng.next() * 2 - 1) * k; if (this.shakeT <= 0) this.shakeMag = 0; } cam.position = [cx, cy]; } } /** * ParallaxLayer — scroll a backdrop slower than the camera for depth. factor 0 = * locked to the world, 1 = locked to the screen (infinitely far). */ export class ParallaxLayer extends Behavior { static readonly props = { factor: { default: 0.5 } }; factor = 0.5; private baseX = 0; override onReady(): void { this.baseX = (this.node as unknown as Node2D).position[0] ?? 0; } override update(): void { const cam = this.getNode('/root/Camera') as unknown as Node2D; const layer = this.node as unknown as Node2D; layer.position = [this.baseX + (cam.position[0] ?? 0) * this.factor, layer.position[1] ?? 0]; } } /** * HudUpdater — paint ScoreKeeper.score, the player's hearts, and lives into the * HUD Labels; flip the won/lost signals into the centered banner. No rules here. */ interface HeartState { hearts: number; } export class HudUpdater extends Behavior { /* * `UiText` under a `HudLayer`, not `Label` under a `UILayer`. * * A `Label` lives in the WORLD and scales with `viewport.design`. At * 1280x800 this HUD painted at 20 device px; on a 390x844 phone the same * nodes painted at 6.1 — an unreadable grey smear — because the viewport * scale there is 0.406. The DOM widgets are declared in CSS pixels and are * immune, which is why the engine's own volume sliders and touch controls * stayed legible in the same capture while the flagship's HUD did not. */ private get coinLabel(): UiText { return this.getNode('CoinLabel') as unknown as UiText; } private get heartLabel(): UiText { return this.getNode('HeartLabel') as unknown as UiText; } private get livesLabel(): UiText { return this.getNode('LivesLabel') as unknown as UiText; } private get banner(): UiBanner { return this.getNode('/root/BannerLayer/Banner') as unknown as UiBanner; } private get score(): ScoreLike { return this.getNode('/root').behavior as unknown as ScoreLike; } private get player(): HeartState { return this.getNode('/root/Player').behavior as unknown as HeartState; } override onReady(): void { const root = this.getNode('/root'); root.on('won', () => this.show('CASTLE CLEARED!', '#ffd166')); root.on('lost', () => this.show('GAME OVER', '#ef476f')); this.refresh(); } override update(): void { this.refresh(); } private refresh(): void { const hearts = Math.max(0, this.player.hearts ?? 0); this.coinLabel.text = `◆ ${this.score.score}`; this.heartLabel.text = `${'♥'.repeat(hearts)}${'·'.repeat(Math.max(0, 3 - hearts))}`; this.livesLabel.text = `x ${Math.max(0, this.score.lives)}`; } private show(text: string, color: string): void { // `UiBanner` owns its own fade and queue; a sticky one stays until the // next `show`, which is what a win/lose screen wants. this.banner.show(text, { color, seconds: 0 }); } }