@waica/behaviors 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ayrton Marini and Waica contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ import { Component, type RoleDefinition, type RoleGraph } from '@waica/engine';
2
+ export type ChaserMode = 'walker' | 'ghost' | 'flyer';
3
+ /**
4
+ * Pursuit toward the player while they are within sight range, in one of
5
+ * three modes: a walker paces the ground (X only, gravity, blocked by
6
+ * Solids), a ghost floats straight at them through everything, a flyer
7
+ * floats straight at them but walls and ceilings stop it.
8
+ * Passive like a motor: no onUpdate of its own — the 'chaser' logic set's
9
+ * chase state calls step(dt), so the StateMachine stays the single owner
10
+ * of the frame.
11
+ */
12
+ export declare class Chaser extends Component {
13
+ static componentName: string;
14
+ static params: {
15
+ mode: {
16
+ label: string;
17
+ options: string[];
18
+ };
19
+ range: {
20
+ label: string;
21
+ min: number;
22
+ max: number;
23
+ step: number;
24
+ };
25
+ speed: {
26
+ label: string;
27
+ min: number;
28
+ max: number;
29
+ step: number;
30
+ };
31
+ gravity: {
32
+ label: string;
33
+ min: number;
34
+ max: number;
35
+ step: number;
36
+ };
37
+ };
38
+ mode: ChaserMode;
39
+ range: number;
40
+ speed: number;
41
+ gravity: number;
42
+ private vy;
43
+ private target;
44
+ /** The player is the live entity whose StateMachine declares that role. */
45
+ private findTarget;
46
+ /** One chase step in the current mode, flipping the sprite toward prey. */
47
+ step(dt: number): void;
48
+ /** Ground pursuit: chase on X when in sight; gravity and Solids always rule. */
49
+ private stepWalker;
50
+ /** Straight-line pursuit; the flyer sweeps against Solids, the ghost doesn't. */
51
+ private stepAirborne;
52
+ /** Resolves this mode's body through the engine's shared Solid solver. */
53
+ private resolveAxis;
54
+ /** AABB sized by the sibling Hitbox (chassis default if none). */
55
+ private collisionBody;
56
+ }
57
+ /** The state graph new chasing characters start with, as prefab data. */
58
+ export declare const CHASER_STATE_GRAPH: RoleGraph;
59
+ export declare const CHASER_ROLE: RoleDefinition;
package/dist/chaser.js ADDED
@@ -0,0 +1,127 @@
1
+ import { Component, Hitbox, resolveSolidAxis, } from '@waica/engine';
2
+ import { isPlayer } from './player-identity.js';
3
+ /**
4
+ * Pursuit toward the player while they are within sight range, in one of
5
+ * three modes: a walker paces the ground (X only, gravity, blocked by
6
+ * Solids), a ghost floats straight at them through everything, a flyer
7
+ * floats straight at them but walls and ceilings stop it.
8
+ * Passive like a motor: no onUpdate of its own — the 'chaser' logic set's
9
+ * chase state calls step(dt), so the StateMachine stays the single owner
10
+ * of the frame.
11
+ */
12
+ export class Chaser extends Component {
13
+ static componentName = 'Chaser';
14
+ static params = {
15
+ mode: { label: 'Mode', options: ['walker', 'ghost', 'flyer'] },
16
+ range: { label: 'Sight range', min: 1, max: 30, step: 0.5 },
17
+ speed: { label: 'Speed', min: 0.5, max: 15, step: 0.5 },
18
+ gravity: { label: 'Gravity (walker)', min: 5, max: 120, step: 1 },
19
+ };
20
+ mode = 'walker';
21
+ range = 6;
22
+ speed = 3;
23
+ gravity = 42;
24
+ vy = 0;
25
+ target;
26
+ /** The player is the live entity whose StateMachine declares that role. */
27
+ findTarget() {
28
+ if (!this.target?.alive || !isPlayer(this.target)) {
29
+ this.target = this.entity.game.entities.find(isPlayer);
30
+ }
31
+ return this.target;
32
+ }
33
+ /** One chase step in the current mode, flipping the sprite toward prey. */
34
+ step(dt) {
35
+ if (this.mode === 'walker')
36
+ this.stepWalker(dt);
37
+ else
38
+ this.stepAirborne(dt);
39
+ }
40
+ /** Ground pursuit: chase on X when in sight; gravity and Solids always rule. */
41
+ stepWalker(dt) {
42
+ const pos = this.entity.position;
43
+ const target = this.findTarget();
44
+ const dx = target ? target.position.x - pos.x : 0;
45
+ if (target && Math.abs(dx) <= this.range && Math.abs(dx) > 0.05) {
46
+ const dir = dx > 0 ? 1 : -1;
47
+ const previous = pos.x;
48
+ pos.x += dir * Math.min(this.speed * dt, Math.abs(dx));
49
+ this.resolveAxis('x', previous);
50
+ this.entity.scale.x = dir;
51
+ }
52
+ // Gravity applies in and out of sight — a walker spawned midair lands.
53
+ this.vy = Math.max(this.vy - this.gravity * dt, -22);
54
+ const previous = pos.y;
55
+ pos.y += this.vy * dt;
56
+ this.resolveAxis('y', previous);
57
+ }
58
+ /** Straight-line pursuit; the flyer sweeps against Solids, the ghost doesn't. */
59
+ stepAirborne(dt) {
60
+ const pos = this.entity.position;
61
+ const target = this.findTarget();
62
+ if (!target)
63
+ return;
64
+ const dx = target.position.x - pos.x;
65
+ const dy = target.position.y - pos.y;
66
+ const dist = Math.hypot(dx, dy);
67
+ // The inner deadband keeps it from jittering on top of the player.
68
+ if (dist > this.range || dist < 0.1)
69
+ return;
70
+ const move = Math.min(this.speed * dt, dist);
71
+ const solid = this.mode === 'flyer';
72
+ const previousX = pos.x;
73
+ pos.x += (dx / dist) * move;
74
+ if (solid)
75
+ this.resolveAxis('x', previousX);
76
+ const previousY = pos.y;
77
+ pos.y += (dy / dist) * move;
78
+ if (solid)
79
+ this.resolveAxis('y', previousY);
80
+ if (Math.abs(dx) > 0.05)
81
+ this.entity.scale.x = dx > 0 ? 1 : -1;
82
+ }
83
+ /** Resolves this mode's body through the engine's shared Solid solver. */
84
+ resolveAxis(axis, previous) {
85
+ const collided = resolveSolidAxis({
86
+ entity: this.entity,
87
+ axis,
88
+ previous,
89
+ body: () => this.collisionBody(),
90
+ });
91
+ if (collided && axis === 'y')
92
+ this.vy = 0;
93
+ }
94
+ /** AABB sized by the sibling Hitbox (chassis default if none). */
95
+ collisionBody() {
96
+ const box = this.entity.get(Hitbox);
97
+ return {
98
+ x: this.entity.position.x + (box?.offsetX ?? 0),
99
+ y: this.entity.position.y + (box?.offsetY ?? 0),
100
+ width: box?.width ?? 0.9,
101
+ height: box?.height ?? 0.95,
102
+ shape: 'rectangle',
103
+ };
104
+ }
105
+ }
106
+ /** The state graph new chasing characters start with, as prefab data. */
107
+ export const CHASER_STATE_GRAPH = {
108
+ initial: 'chase',
109
+ states: { chase: {} },
110
+ };
111
+ // The pursuer role. One state out of the box; give chasing characters more
112
+ // states by registering on top (defineStates('chaser', { flee: {...} }))
113
+ // plus prefab data.
114
+ export const CHASER_ROLE = {
115
+ description: 'Hunts the player when they come within sight range. Its Mode picks ' +
116
+ 'the body: a walker paces the ground with gravity, a ghost floats ' +
117
+ 'through walls, a flyer floats but walls stop it. Its states move Chaser.',
118
+ driver: 'Chaser',
119
+ graph: CHASER_STATE_GRAPH,
120
+ states: {
121
+ chase: {
122
+ onUpdate({ entity }, dt) {
123
+ entity.get(Chaser)?.step(dt);
124
+ },
125
+ },
126
+ },
127
+ };
@@ -0,0 +1,25 @@
1
+ import { Component, type Entity } from '@waica/engine';
2
+ /**
3
+ * Collected when the entity with the player role touches
4
+ * it: adds its value to a stat, fires onCollect and destroys itself.
5
+ * Requires Hitbox on both entities.
6
+ */
7
+ export declare class Collectible extends Component {
8
+ static componentName: string;
9
+ static params: {
10
+ value: {
11
+ label: string;
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ };
16
+ stat: {
17
+ label: string;
18
+ };
19
+ };
20
+ value: number;
21
+ /** Stat receiving the value ('' collects without counting anywhere). */
22
+ stat: string;
23
+ onCollect?: (value: number) => void;
24
+ onCollide(other: Entity): void;
25
+ }
@@ -0,0 +1,27 @@
1
+ import { Component } from '@waica/engine';
2
+ import { isPlayer } from './player-identity.js';
3
+ /**
4
+ * Collected when the entity with the player role touches
5
+ * it: adds its value to a stat, fires onCollect and destroys itself.
6
+ * Requires Hitbox on both entities.
7
+ */
8
+ export class Collectible extends Component {
9
+ static componentName = 'Collectible';
10
+ static params = {
11
+ value: { label: 'Value', min: 1, max: 100, step: 1 },
12
+ stat: { label: 'Adds to stat' },
13
+ };
14
+ value = 1;
15
+ /** Stat receiving the value ('' collects without counting anywhere). */
16
+ stat = 'points';
17
+ onCollect;
18
+ onCollide(other) {
19
+ if (!isPlayer(other))
20
+ return;
21
+ this.onCollect?.(this.value);
22
+ if (this.stat)
23
+ this.game.stats.add(this.stat, this.value);
24
+ this.game.events.emit('collect', this.value);
25
+ this.entity.destroy();
26
+ }
27
+ }
@@ -0,0 +1,29 @@
1
+ import { Component, type Entity } from '@waica/engine';
2
+ export type HazardTouch = 'stomp' | 'hurt';
3
+ /**
4
+ * Touching a hazard by stomping it from above (falling, with the feet
5
+ * above its center) squashes it; any other contact hurts.
6
+ * Pure, so the decision is testable without the engine.
7
+ */
8
+ export declare function resolveHazardTouch(playerVy: number, playerBottom: number, hazardY: number, stompable: boolean): HazardTouch;
9
+ /**
10
+ * Hurts the player on contact. If stompable (Mario-style), stomping it
11
+ * destroys it and bounces the player. Requires Hitbox on both entities.
12
+ */
13
+ export declare class Hazard extends Component {
14
+ static componentName: string;
15
+ static params: {
16
+ stompable: {
17
+ label: string;
18
+ };
19
+ bounce: {
20
+ label: string;
21
+ min: number;
22
+ max: number;
23
+ step: number;
24
+ };
25
+ };
26
+ stompable: boolean;
27
+ bounce: number;
28
+ onCollide(other: Entity): void;
29
+ }
package/dist/hazard.js ADDED
@@ -0,0 +1,42 @@
1
+ import { Component } from '@waica/engine';
2
+ import { PlatformerMotor } from './platformer-motor.js';
3
+ import { isPlayer } from './player-identity.js';
4
+ import { Respawnable } from './respawnable.js';
5
+ /**
6
+ * Touching a hazard by stomping it from above (falling, with the feet
7
+ * above its center) squashes it; any other contact hurts.
8
+ * Pure, so the decision is testable without the engine.
9
+ */
10
+ export function resolveHazardTouch(playerVy, playerBottom, hazardY, stompable) {
11
+ if (stompable && playerVy < 0 && playerBottom > hazardY)
12
+ return 'stomp';
13
+ return 'hurt';
14
+ }
15
+ /**
16
+ * Hurts the player on contact. If stompable (Mario-style), stomping it
17
+ * destroys it and bounces the player. Requires Hitbox on both entities.
18
+ */
19
+ export class Hazard extends Component {
20
+ static componentName = 'Hazard';
21
+ static params = {
22
+ stompable: { label: 'Stompable' },
23
+ bounce: { label: 'Stomp bounce', min: 0, max: 30, step: 0.5 },
24
+ };
25
+ stompable = true;
26
+ bounce = 10;
27
+ onCollide(other) {
28
+ if (!isPlayer(other))
29
+ return;
30
+ const motor = other.get(PlatformerMotor);
31
+ if (motor) {
32
+ const playerBottom = other.position.y - motor.hitboxHeight / 2;
33
+ const touch = resolveHazardTouch(motor.vy, playerBottom, this.entity.position.y, this.stompable);
34
+ if (touch === 'stomp') {
35
+ this.entity.destroy();
36
+ motor.vy = this.bounce;
37
+ return;
38
+ }
39
+ }
40
+ other.get(Respawnable)?.respawn();
41
+ }
42
+ }
@@ -0,0 +1,9 @@
1
+ export { PlatformerMotor } from './platformer-motor.js';
2
+ export { PLAYER_ROLE, PLAYER_STATE_GRAPH, playerUpdate } from './player-states.js';
3
+ export { Collectible } from './collectible.js';
4
+ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH, type PatrolAxis } from './patrol.js';
5
+ export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH, type ChaserMode } from './chaser.js';
6
+ export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
7
+ export { Hazard, resolveHazardTouch, type HazardTouch } from './hazard.js';
8
+ export { Respawnable } from './respawnable.js';
9
+ export { Lifetime } from './lifetime.js';
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export { PlatformerMotor } from './platformer-motor.js';
2
+ export { PLAYER_ROLE, PLAYER_STATE_GRAPH, playerUpdate } from './player-states.js';
3
+ export { Collectible } from './collectible.js';
4
+ export { Patrol, PATROLLER_ROLE, PATROLLER_STATE_GRAPH } from './patrol.js';
5
+ export { Chaser, CHASER_ROLE, CHASER_STATE_GRAPH } from './chaser.js';
6
+ export { NPC_ROLE, NPC_STATE_GRAPH } from './npc.js';
7
+ export { Hazard, resolveHazardTouch } from './hazard.js';
8
+ export { Respawnable } from './respawnable.js';
9
+ export { Lifetime } from './lifetime.js';
@@ -0,0 +1,16 @@
1
+ import { Component } from '@waica/engine';
2
+ /** Destroys its entity after a configurable amount of simulated time. */
3
+ export declare class Lifetime extends Component {
4
+ static componentName: string;
5
+ static params: {
6
+ seconds: {
7
+ label: string;
8
+ min: number;
9
+ max: number;
10
+ step: number;
11
+ };
12
+ };
13
+ seconds: number;
14
+ private elapsed;
15
+ onUpdate(dt: number): void;
16
+ }
@@ -0,0 +1,19 @@
1
+ import { Component } from '@waica/engine';
2
+ /** Destroys its entity after a configurable amount of simulated time. */
3
+ export class Lifetime extends Component {
4
+ static componentName = 'Lifetime';
5
+ static params = {
6
+ seconds: { label: 'Seconds', min: 0.05, max: 60, step: 0.05 },
7
+ };
8
+ seconds = 1;
9
+ elapsed = 0;
10
+ onUpdate(dt) {
11
+ // The frame that destroys the entity still iterates a copy of its
12
+ // components: stop counting instead of destroying twice.
13
+ if (!this.entity.alive)
14
+ return;
15
+ this.elapsed += dt;
16
+ if (this.elapsed >= this.seconds)
17
+ this.entity.destroy();
18
+ }
19
+ }
package/dist/npc.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import { type RoleDefinition, type RoleGraph } from '@waica/engine';
2
+ /** The state graph new NPCs start with, as prefab data. */
3
+ export declare const NPC_STATE_GRAPH: RoleGraph;
4
+ export declare const NPC_ROLE: RoleDefinition;
package/dist/npc.js ADDED
@@ -0,0 +1,14 @@
1
+ import {} from '@waica/engine';
2
+ /** The state graph new NPCs start with, as prefab data. */
3
+ export const NPC_STATE_GRAPH = {
4
+ initial: 'idle',
5
+ states: { idle: {} },
6
+ };
7
+ // The bystander role: no driver and no code — the character stands where
8
+ // you put it, playing its idle animation, and hurts no one. Bring it to
9
+ // life by adding states (defineStates('npc', {...})) and behaviours.
10
+ export const NPC_ROLE = {
11
+ description: 'Stands where you put it — no input, no movement, harmless. Good for ' +
12
+ 'villagers and signposts. Give it states to bring it to life.',
13
+ graph: NPC_STATE_GRAPH,
14
+ };
@@ -0,0 +1,43 @@
1
+ import { Component, type RoleDefinition, type RoleGraph } from '@waica/engine';
2
+ export type PatrolAxis = 'horizontal' | 'vertical';
3
+ /**
4
+ * Rail patrol: back and forth `distance` units from the starting
5
+ * position along one axis — sideways or up and down — turning around
6
+ * at the ends (with a sprite flip when walking sideways).
7
+ * Passive like a motor: no onUpdate of its own — the 'patroller' logic
8
+ * set's walk state calls step(dt), so the StateMachine stays the single
9
+ * owner of the frame.
10
+ */
11
+ export declare class Patrol extends Component {
12
+ static componentName: string;
13
+ static params: {
14
+ axis: {
15
+ label: string;
16
+ options: string[];
17
+ };
18
+ distance: {
19
+ label: string;
20
+ min: number;
21
+ max: number;
22
+ step: number;
23
+ };
24
+ speed: {
25
+ label: string;
26
+ min: number;
27
+ max: number;
28
+ step: number;
29
+ };
30
+ };
31
+ axis: PatrolAxis;
32
+ distance: number;
33
+ speed: number;
34
+ private originX;
35
+ private originY;
36
+ private dir;
37
+ onReady(): void;
38
+ /** One patrol step: advance, turn at the rail's ends, flip the sprite. */
39
+ step(dt: number): void;
40
+ }
41
+ /** The state graph new patrolling characters start with, as prefab data. */
42
+ export declare const PATROLLER_STATE_GRAPH: RoleGraph;
43
+ export declare const PATROLLER_ROLE: RoleDefinition;
package/dist/patrol.js ADDED
@@ -0,0 +1,68 @@
1
+ import { Component } from '@waica/engine';
2
+ /**
3
+ * Rail patrol: back and forth `distance` units from the starting
4
+ * position along one axis — sideways or up and down — turning around
5
+ * at the ends (with a sprite flip when walking sideways).
6
+ * Passive like a motor: no onUpdate of its own — the 'patroller' logic
7
+ * set's walk state calls step(dt), so the StateMachine stays the single
8
+ * owner of the frame.
9
+ */
10
+ export class Patrol extends Component {
11
+ static componentName = 'Patrol';
12
+ static params = {
13
+ axis: { label: 'Axis', options: ['horizontal', 'vertical'] },
14
+ distance: { label: 'Distance', min: 0.5, max: 20, step: 0.5 },
15
+ speed: { label: 'Speed', min: 0.5, max: 15, step: 0.5 },
16
+ };
17
+ axis = 'horizontal';
18
+ distance = 3;
19
+ speed = 2;
20
+ // Both origins are captured so a live axis switch keeps a valid rail.
21
+ originX = 0;
22
+ originY = 0;
23
+ dir = 1;
24
+ onReady() {
25
+ this.originX = this.entity.position.x;
26
+ this.originY = this.entity.position.y;
27
+ }
28
+ /** One patrol step: advance, turn at the rail's ends, flip the sprite. */
29
+ step(dt) {
30
+ const pos = this.entity.position;
31
+ const vertical = this.axis === 'vertical';
32
+ const key = vertical ? 'y' : 'x';
33
+ const origin = vertical ? this.originY : this.originX;
34
+ let next = pos[key] + this.dir * this.speed * dt;
35
+ if (next > origin + this.distance) {
36
+ next = origin + this.distance;
37
+ this.dir = -1;
38
+ }
39
+ else if (next < origin - this.distance) {
40
+ next = origin - this.distance;
41
+ this.dir = 1;
42
+ }
43
+ pos[key] = next;
44
+ if (!vertical)
45
+ this.entity.scale.x = this.dir;
46
+ }
47
+ }
48
+ /** The state graph new patrolling characters start with, as prefab data. */
49
+ export const PATROLLER_STATE_GRAPH = {
50
+ initial: 'walk',
51
+ states: { walk: {} },
52
+ };
53
+ // The walking-critter role. One state out of the box; give patrolling
54
+ // characters more states by registering on top (defineStates('patroller',
55
+ // { chasing: {...} })) plus prefab data.
56
+ export const PATROLLER_ROLE = {
57
+ description: 'Walks back and forth on its own along a rail — no player input, no ' +
58
+ 'gravity. Good for critters and moving hazards. Its states move Patrol.',
59
+ driver: 'Patrol',
60
+ graph: PATROLLER_STATE_GRAPH,
61
+ states: {
62
+ walk: {
63
+ onUpdate({ entity }, dt) {
64
+ entity.get(Patrol)?.step(dt);
65
+ },
66
+ },
67
+ },
68
+ };
@@ -0,0 +1,124 @@
1
+ import { Component } from '@waica/engine';
2
+ /**
3
+ * Passive platformer motor: tuning params, physical state and movement
4
+ * methods for state code to call. It has no onUpdate of its own — the
5
+ * StateMachine is the only owner of the frame; this is the toolbox the
6
+ * active state moves the body with. The out-of-the-box game feel a
7
+ * beginner doesn't know they need lives here: coyote time, jump
8
+ * buffering, jump cut (releasing jumps shorter) and squash & stretch.
9
+ * Per-axis collision against the scene's Solids — deterministic genre
10
+ * movement (Celeste-style), not "realistic" physics.
11
+ */
12
+ export declare class PlatformerMotor extends Component {
13
+ static componentName: string;
14
+ static displayName: string;
15
+ static params: {
16
+ moveSpeed: {
17
+ label: string;
18
+ min: number;
19
+ max: number;
20
+ step: number;
21
+ };
22
+ acceleration: {
23
+ label: string;
24
+ min: number;
25
+ max: number;
26
+ step: number;
27
+ };
28
+ deceleration: {
29
+ label: string;
30
+ min: number;
31
+ max: number;
32
+ step: number;
33
+ };
34
+ jumpVelocity: {
35
+ label: string;
36
+ min: number;
37
+ max: number;
38
+ step: number;
39
+ };
40
+ gravity: {
41
+ label: string;
42
+ min: number;
43
+ max: number;
44
+ step: number;
45
+ };
46
+ maxFallSpeed: {
47
+ label: string;
48
+ min: number;
49
+ max: number;
50
+ step: number;
51
+ };
52
+ coyoteTime: {
53
+ label: string;
54
+ min: number;
55
+ max: number;
56
+ step: number;
57
+ };
58
+ jumpBuffer: {
59
+ label: string;
60
+ min: number;
61
+ max: number;
62
+ step: number;
63
+ };
64
+ jumpCutStrength: {
65
+ label: string;
66
+ min: number;
67
+ max: number;
68
+ step: number;
69
+ };
70
+ runThreshold: {
71
+ label: string;
72
+ min: number;
73
+ max: number;
74
+ step: number;
75
+ };
76
+ squashStretch: {
77
+ label: string;
78
+ };
79
+ };
80
+ moveSpeed: number;
81
+ acceleration: number;
82
+ deceleration: number;
83
+ jumpVelocity: number;
84
+ gravity: number;
85
+ maxFallSpeed: number;
86
+ coyoteTime: number;
87
+ jumpBuffer: number;
88
+ /** >1 applies extra gravity while rising without holding jump. */
89
+ jumpCutStrength: number;
90
+ /** |vx| above this reads as running (the idle ↔ run edge). */
91
+ runThreshold: number;
92
+ squashStretch: boolean;
93
+ /** The character's AABB hitbox. */
94
+ hitboxWidth: number;
95
+ hitboxHeight: number;
96
+ vx: number;
97
+ vy: number;
98
+ grounded: boolean;
99
+ /** 1 facing right, -1 facing left. */
100
+ facing: number;
101
+ private coyoteTimer;
102
+ private bufferTimer;
103
+ private squashX;
104
+ private squashY;
105
+ /**
106
+ * Per-frame bookkeeping: forgiveness timers, squash decay and the
107
+ * facing flip. The logic set's '*' hook runs it in every state, so
108
+ * coyote and buffer survive state transitions.
109
+ */
110
+ tick(dt: number): void;
111
+ /** Accelerates toward dir (-1..1) * moveSpeed, flipping facing. */
112
+ runTowards(dir: number, dt: number): void;
113
+ /** A buffered jump press within coyote time — the frame to call jump(). */
114
+ wantsJump(): boolean;
115
+ jump(): void;
116
+ /** Gravity with jump cut: rising without holding jump falls sooner. */
117
+ applyGravity(dt: number): void;
118
+ /** Integrates velocity and resolves collisions against Solids. */
119
+ step(dt: number): void;
120
+ halt(): void;
121
+ private applySquash;
122
+ private resolveAxis;
123
+ private collisionBody;
124
+ }
@@ -0,0 +1,146 @@
1
+ import { Component, resolveSolidAxis, THREE } from '@waica/engine';
2
+ /**
3
+ * Passive platformer motor: tuning params, physical state and movement
4
+ * methods for state code to call. It has no onUpdate of its own — the
5
+ * StateMachine is the only owner of the frame; this is the toolbox the
6
+ * active state moves the body with. The out-of-the-box game feel a
7
+ * beginner doesn't know they need lives here: coyote time, jump
8
+ * buffering, jump cut (releasing jumps shorter) and squash & stretch.
9
+ * Per-axis collision against the scene's Solids — deterministic genre
10
+ * movement (Celeste-style), not "realistic" physics.
11
+ */
12
+ export class PlatformerMotor extends Component {
13
+ static componentName = 'PlatformerMotor';
14
+ static displayName = 'Motor';
15
+ static params = {
16
+ moveSpeed: { label: 'Speed', min: 1, max: 30, step: 0.5 },
17
+ acceleration: { label: 'Acceleration', min: 5, max: 200, step: 5 },
18
+ deceleration: { label: 'Deceleration', min: 5, max: 200, step: 5 },
19
+ jumpVelocity: { label: 'Jump impulse', min: 2, max: 40, step: 0.5 },
20
+ gravity: { label: 'Gravity', min: 5, max: 120, step: 1 },
21
+ maxFallSpeed: { label: 'Max fall speed', min: 5, max: 60, step: 1 },
22
+ coyoteTime: { label: 'Coyote time (s)', min: 0, max: 0.4, step: 0.01 },
23
+ jumpBuffer: { label: 'Jump buffer (s)', min: 0, max: 0.4, step: 0.01 },
24
+ jumpCutStrength: { label: 'Jump cut', min: 1, max: 6, step: 0.1 },
25
+ runThreshold: { label: 'Run threshold', min: 0, max: 5, step: 0.1 },
26
+ squashStretch: { label: 'Squash & stretch' },
27
+ };
28
+ moveSpeed = 9;
29
+ acceleration = 60;
30
+ deceleration = 80;
31
+ jumpVelocity = 14;
32
+ gravity = 42;
33
+ maxFallSpeed = 22;
34
+ coyoteTime = 0.1;
35
+ jumpBuffer = 0.12;
36
+ /** >1 applies extra gravity while rising without holding jump. */
37
+ jumpCutStrength = 2.5;
38
+ /** |vx| above this reads as running (the idle ↔ run edge). */
39
+ runThreshold = 0.5;
40
+ squashStretch = true;
41
+ /** The character's AABB hitbox. */
42
+ hitboxWidth = 0.9;
43
+ hitboxHeight = 0.95;
44
+ vx = 0;
45
+ vy = 0;
46
+ grounded = false;
47
+ /** 1 facing right, -1 facing left. */
48
+ facing = 1;
49
+ coyoteTimer = 0;
50
+ bufferTimer = 0;
51
+ squashX = 1;
52
+ squashY = 1;
53
+ /**
54
+ * Per-frame bookkeeping: forgiveness timers, squash decay and the
55
+ * facing flip. The logic set's '*' hook runs it in every state, so
56
+ * coyote and buffer survive state transitions.
57
+ */
58
+ tick(dt) {
59
+ this.coyoteTimer = this.grounded ? this.coyoteTime : this.coyoteTimer - dt;
60
+ this.bufferTimer = this.game.input.justPressed('jump')
61
+ ? this.jumpBuffer
62
+ : this.bufferTimer - dt;
63
+ this.squashX = THREE.MathUtils.damp(this.squashX, 1, 12, dt);
64
+ this.squashY = THREE.MathUtils.damp(this.squashY, 1, 12, dt);
65
+ this.entity.scale.set(this.facing * this.squashX, this.squashY, 1);
66
+ }
67
+ /** Accelerates toward dir (-1..1) * moveSpeed, flipping facing. */
68
+ runTowards(dir, dt) {
69
+ const target = dir * this.moveSpeed;
70
+ const rate = dir !== 0 ? this.acceleration : this.deceleration;
71
+ this.vx = THREE.MathUtils.damp(this.vx, target, rate / this.moveSpeed, dt);
72
+ if (dir !== 0)
73
+ this.facing = dir < 0 ? -1 : 1;
74
+ }
75
+ /** A buffered jump press within coyote time — the frame to call jump(). */
76
+ wantsJump() {
77
+ return this.bufferTimer > 0 && this.coyoteTimer > 0;
78
+ }
79
+ jump() {
80
+ this.vy = this.jumpVelocity;
81
+ this.coyoteTimer = 0;
82
+ this.bufferTimer = 0;
83
+ // The press that launched this jump is spent: a "key press jump"
84
+ // transition needs a NEW press, not the one already used here.
85
+ this.game.input.consume('jump');
86
+ this.applySquash(0.8, 1.25);
87
+ }
88
+ /** Gravity with jump cut: rising without holding jump falls sooner. */
89
+ applyGravity(dt) {
90
+ const cut = this.vy > 0 && !this.game.input.held('jump') ? this.jumpCutStrength : 1;
91
+ this.vy = Math.max(this.vy - this.gravity * cut * dt, -this.maxFallSpeed);
92
+ }
93
+ /** Integrates velocity and resolves collisions against Solids. */
94
+ step(dt) {
95
+ const pos = this.entity.position;
96
+ const wasAirborne = !this.grounded;
97
+ const previousX = pos.x;
98
+ pos.x += this.vx * dt;
99
+ this.resolveAxis('x', previousX);
100
+ this.grounded = false;
101
+ const previousY = pos.y;
102
+ pos.y += this.vy * dt;
103
+ this.resolveAxis('y', previousY);
104
+ if (wasAirborne && this.grounded)
105
+ this.applySquash(1.25, 0.8);
106
+ }
107
+ halt() {
108
+ this.vx = 0;
109
+ this.vy = 0;
110
+ }
111
+ applySquash(x, y) {
112
+ if (!this.squashStretch)
113
+ return;
114
+ this.squashX = x;
115
+ this.squashY = y;
116
+ }
117
+ resolveAxis(axis, previous) {
118
+ const collided = resolveSolidAxis({
119
+ entity: this.entity,
120
+ axis,
121
+ previous,
122
+ body: () => this.collisionBody(),
123
+ });
124
+ if (!collided)
125
+ return;
126
+ if (axis === 'x') {
127
+ this.vx = 0;
128
+ }
129
+ else if (this.vy <= 0) {
130
+ this.vy = 0;
131
+ this.grounded = true;
132
+ }
133
+ else {
134
+ this.vy = 0;
135
+ }
136
+ }
137
+ collisionBody() {
138
+ return {
139
+ x: this.entity.position.x,
140
+ y: this.entity.position.y,
141
+ width: this.hitboxWidth,
142
+ height: this.hitboxHeight,
143
+ shape: 'rectangle',
144
+ };
145
+ }
146
+ }
@@ -0,0 +1,3 @@
1
+ import { type Entity } from '@waica/engine';
2
+ /** Player identity is the role contract, independent of the movement driver. */
3
+ export declare function isPlayer(entity: Entity): boolean;
@@ -0,0 +1,5 @@
1
+ import { StateMachine } from '@waica/engine';
2
+ /** Player identity is the role contract, independent of the movement driver. */
3
+ export function isPlayer(entity) {
4
+ return entity.get(StateMachine)?.role === 'player';
5
+ }
@@ -0,0 +1,18 @@
1
+ import { type RoleDefinition, type RoleGraph, type StateContext } from '@waica/engine';
2
+ /**
3
+ * The 'player' role: the character you control. All four default states
4
+ * share one body update; they differ only in the edges their prefab data
5
+ * declares. Extending the player is defineStates('player', { yourState:
6
+ * {...} }) plus a state in the prefab — never a fight with a parallel
7
+ * controller.
8
+ */
9
+ /** The state graph new player characters start with, as prefab data. */
10
+ export declare const PLAYER_STATE_GRAPH: RoleGraph;
11
+ /**
12
+ * One body update shared by every default state: move, jump, gravity,
13
+ * collide, then report what the body is doing as signals. Unmatched
14
+ * signals are no-ops, so each state only reacts to the edges its data
15
+ * declares.
16
+ */
17
+ export declare function playerUpdate({ entity, game, fsm }: StateContext, dt: number): void;
18
+ export declare const PLAYER_ROLE: RoleDefinition;
@@ -0,0 +1,95 @@
1
+ import {} from '@waica/engine';
2
+ import { PlatformerMotor } from './platformer-motor.js';
3
+ /**
4
+ * The 'player' role: the character you control. All four default states
5
+ * share one body update; they differ only in the edges their prefab data
6
+ * declares. Extending the player is defineStates('player', { yourState:
7
+ * {...} }) plus a state in the prefab — never a fight with a parallel
8
+ * controller.
9
+ */
10
+ /** The state graph new player characters start with, as prefab data. */
11
+ export const PLAYER_STATE_GRAPH = {
12
+ initial: 'idle',
13
+ states: {
14
+ idle: {
15
+ transitions: [
16
+ { on: 'signal:move', to: 'run' },
17
+ { on: 'signal:rise', to: 'jump' },
18
+ { on: 'signal:fall', to: 'fall' },
19
+ ],
20
+ },
21
+ run: {
22
+ transitions: [
23
+ { on: 'signal:stop', to: 'idle' },
24
+ { on: 'signal:rise', to: 'jump' },
25
+ { on: 'signal:fall', to: 'fall' },
26
+ ],
27
+ },
28
+ jump: {
29
+ transitions: [
30
+ { on: 'signal:fall', to: 'fall' },
31
+ { on: 'signal:land', to: 'idle' },
32
+ ],
33
+ },
34
+ fall: {
35
+ // 'rise' from here is the coyote jump (and hazard stomp bounces).
36
+ transitions: [
37
+ { on: 'signal:rise', to: 'jump' },
38
+ { on: 'signal:land', to: 'idle' },
39
+ ],
40
+ },
41
+ },
42
+ };
43
+ /**
44
+ * One body update shared by every default state: move, jump, gravity,
45
+ * collide, then report what the body is doing as signals. Unmatched
46
+ * signals are no-ops, so each state only reacts to the edges its data
47
+ * declares.
48
+ */
49
+ export function playerUpdate({ entity, game, fsm }, dt) {
50
+ const motor = entity.get(PlatformerMotor);
51
+ if (!motor)
52
+ return;
53
+ motor.runTowards(game.input.axis(), dt);
54
+ if (motor.wantsJump())
55
+ motor.jump();
56
+ motor.applyGravity(dt);
57
+ motor.step(dt);
58
+ if (motor.grounded) {
59
+ fsm.signal('land');
60
+ fsm.signal(Math.abs(motor.vx) > motor.runThreshold ? 'move' : 'stop');
61
+ }
62
+ else {
63
+ fsm.signal(motor.vy > 0 ? 'rise' : 'fall');
64
+ }
65
+ }
66
+ export const PLAYER_ROLE = {
67
+ description: 'You control this character with the project controls: run and jump ' +
68
+ 'with platformer physics and curated game feel (coyote time, jump ' +
69
+ 'buffering). Its states move the Motor.',
70
+ driver: 'PlatformerMotor',
71
+ graph: PLAYER_STATE_GRAPH,
72
+ signals: {
73
+ move: 'on the ground and moving',
74
+ stop: 'on the ground and standing still',
75
+ rise: 'airborne, going up',
76
+ fall: 'airborne, going down',
77
+ land: 'just touched the ground',
78
+ },
79
+ states: {
80
+ // Always-hook: motor bookkeeping (coyote/buffer timers, squash, facing)
81
+ // must survive custom states like a dash, so it runs in every state.
82
+ '*': {
83
+ onUpdate({ entity }, dt) {
84
+ entity.get(PlatformerMotor)?.tick(dt);
85
+ },
86
+ },
87
+ // Fallback: a custom state without its own onUpdate keeps the full
88
+ // body update — its file only has to say what makes it special.
89
+ default: { onUpdate: playerUpdate },
90
+ idle: { onUpdate: playerUpdate },
91
+ run: { onUpdate: playerUpdate },
92
+ jump: { onUpdate: playerUpdate },
93
+ fall: { onUpdate: playerUpdate },
94
+ },
95
+ };
@@ -0,0 +1,23 @@
1
+ import { Component } from '@waica/engine';
2
+ /**
3
+ * Remembers the spawn point and puts the entity back there on death
4
+ * (falling off the world or touching a Hazard).
5
+ */
6
+ export declare class Respawnable extends Component {
7
+ static componentName: string;
8
+ static displayName: string;
9
+ static params: {
10
+ killY: {
11
+ label: string;
12
+ min: number;
13
+ max: number;
14
+ step: number;
15
+ };
16
+ };
17
+ /** Falling below this height respawns. */
18
+ killY: number;
19
+ private spawn;
20
+ onReady(): void;
21
+ respawn(): void;
22
+ onUpdate(): void;
23
+ }
@@ -0,0 +1,27 @@
1
+ import { Component, THREE } from '@waica/engine';
2
+ import { PlatformerMotor } from './platformer-motor.js';
3
+ /**
4
+ * Remembers the spawn point and puts the entity back there on death
5
+ * (falling off the world or touching a Hazard).
6
+ */
7
+ export class Respawnable extends Component {
8
+ static componentName = 'Respawnable';
9
+ static displayName = 'Respawn';
10
+ static params = {
11
+ killY: { label: 'Kill height', min: -50, max: 0, step: 1 },
12
+ };
13
+ /** Falling below this height respawns. */
14
+ killY = -12;
15
+ spawn = new THREE.Vector3();
16
+ onReady() {
17
+ this.spawn.copy(this.entity.position);
18
+ }
19
+ respawn() {
20
+ this.entity.position.copy(this.spawn);
21
+ this.entity.get(PlatformerMotor)?.halt();
22
+ }
23
+ onUpdate() {
24
+ if (this.entity.position.y < this.killY)
25
+ this.respawn();
26
+ }
27
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@waica/behaviors",
3
+ "version": "0.3.0",
4
+ "description": "Waica built-in behaviors — the curated game-feel library",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/chichex/waica",
10
+ "directory": "packages/behaviors"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@waica/engine": "^0.3.0"
23
+ },
24
+ "devDependencies": {
25
+ "vitest": "^4.1.10"
26
+ },
27
+ "scripts": {
28
+ "build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
29
+ "typecheck": "tsc --noEmit"
30
+ }
31
+ }