From 2a5c174ab7168adbe955aa0dc5e1e4e099fba1ad Mon Sep 17 00:00:00 2001 From: lajbel <71136486+lajbel@users.noreply.github.com> Date: Sat, 28 Jun 2025 19:48:43 -0300 Subject: [PATCH 1/4] feat: add SpriteComp.onAnimLoop --- examples/spriteAnim.js | 17 +++- examples/spriteAnim3001.js | 154 ++++++++++++++++++++++++++++++ src/ecs/components/draw/sprite.ts | 20 +++- 3 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 examples/spriteAnim3001.js diff --git a/examples/spriteAnim.js b/examples/spriteAnim.js index 6dd76673f..6c8d02aab 100644 --- a/examples/spriteAnim.js +++ b/examples/spriteAnim.js @@ -12,7 +12,7 @@ // This example may be large, so use regions for navigating faster. Important // content is marked with 👁️ -kaplay({ scale: 4, font: "happy" }); +kaplay({ scale: 4, font: "happy", background: "#aee2ff" }); const SPEED = 120; const JUMP_FORCE = 240; @@ -51,7 +51,7 @@ loadSprite("dino", "/sprites/dungeon-dino.png", { // Add our player character 👁️ const player = add([ - sprite("dino"), + sprite("dino", { anim: "jump" }), pos(center()), anchor("center"), area(), @@ -127,18 +127,29 @@ player.onGround(() => { // #endregion -// You can run functions when a specific animation ends 👁️ +// You can run functions on specific animation moments 👁️ +let loopCount = 0; + +player.onAnimStart((anim) => { + loopCount = 0; +}); + player.onAnimEnd((anim) => { if (anim === "idle") { debug.log("hi!"); } }); +player.onAnimLoop((anim) => { + loopCount++; +}); + // #region UI const getInfo = () => ` Anim: ${player.getCurAnim()?.name} Frame: ${player.frame} +Loops: ${loopCount} `.trim(); // Add some text to show the current animation diff --git a/examples/spriteAnim3001.js b/examples/spriteAnim3001.js new file mode 100644 index 000000000..89dcf3238 --- /dev/null +++ b/examples/spriteAnim3001.js @@ -0,0 +1,154 @@ +/** + * @file Sprite Animations + * @description How to load and animate sprites + * @difficulty 0 + * @tags basics, animation + * @minver 3001.0 + * @category basics + */ + +// Animate Sprites with platformer movement [👁️] + +// This example may be large, so use regions for navigating faster. Important +// content is marked with 👁️ + +kaplay({ scale: 4, font: "happy" }); + +const SPEED = 120; +const JUMP_FORCE = 240; +setGravity(640); + +// #region Loading Assets 👁️ +loadBitmapFont("happy", "/fonts/happy_28x36.png", 28, 36); + +// Loading a multi-frame sprite 👁️ +loadSprite("dino", "/sprites/dungeon-dino.png", { + // The image contains 9 frames layered out horizontally, slice it into individual frames + sliceX: 9, + // Define animations + anims: { + "idle": { + // Starts from frame 0, ends at frame 3 + from: 0, + to: 3, + // Frame per second + speed: 5, + loop: true, + }, + "run": { + from: 4, + to: 7, + speed: 10, + loop: true, + }, + // This animation only has 1 frame + "jump": 8, + }, +}); +// #endregion + +// #region Game Objects + +// Add our player character 👁️ +const player = add([ + sprite("dino", { anim: "jump" }), + pos(center()), + anchor("center"), + area(), + body(), +]); + +// Add a platform +add([ + rect(width(), 24), + area(), + outline(1), + pos(0, height() - 24), + body({ isStatic: true }), +]); +// #endregion + +/* 👁️ +We can animate sprites using obj.play("name") method. + +This time we're defining a function for executing animations conditionally. +*/ + +// #region Player animations 👁️ +const playerPlayRun = () => { + // obj.play() will reset to the first frame of the animation + // so we want to make sure it only runs when the current animation is not "run" + if (player.isGrounded() && player.getCurAnim().name !== "run") { + player.play("run"); + } +}; + +const playerPlayIdle = () => { + // Only reset to "idle" if player is not holding any of these keys + if (player.isGrounded() && !isKeyDown("left") && !isKeyDown("right")) { + player.play("idle"); + } +}; +// #endregion + +// #region Player move/anim 👁️ +onKeyDown("left", () => { + player.move(-SPEED, 0); + player.flipX = true; + playerPlayRun(); +}); + +onKeyDown("right", () => { + player.move(SPEED, 0); + player.flipX = false; + playerPlayRun(); +}); + +onKeyRelease(["left", "right"], () => { + playerPlayIdle(); +}); + +onKeyPress(["space", "up"], () => { + if (player.isGrounded()) { + player.jump(JUMP_FORCE); + player.play("jump"); + } +}); + +// Switch to "idle" or "run" animation when player hits ground +player.onGround(() => { + if (!isKeyDown("left") && !isKeyDown("right")) { + player.play("idle"); + } + else { + player.play("run"); + } +}); + +// #endregion + +// You can run functions when a specific animation ends 👁️ +player.onAnimEnd((anim) => { + if (anim === "idle") { + debug.log("hi!"); + } +}); + +// #region UI +const getInfo = () => + ` +Anim: ${player.getCurAnim()?.name} +Frame: ${player.frame} +`.trim(); + +// Add some text to show the current animation +const label = add([ + text(getInfo(), { size: 12 }), + color(0, 0, 0), + pos(4), +]); + +label.onUpdate(() => { + label.text = getInfo(); +}); +// #endregion diff --git a/src/ecs/components/draw/sprite.ts b/src/ecs/components/draw/sprite.ts index ab06aacd5..ba97ff542 100644 --- a/src/ecs/components/draw/sprite.ts +++ b/src/ecs/components/draw/sprite.ts @@ -34,7 +34,6 @@ export interface SpriteCurAnim { */ frameIndex: number; pingpong: boolean; - onEnd: () => void; } /** @@ -120,6 +119,12 @@ export interface SpriteComp extends Comp { * Register an event that runs when an animation is ended. */ onAnimEnd(action: (anim: string) => void): KEventController; + /** + * Register an event that runs when an animation is looped. + * + * @since v4000.0 + */ + onAnimLoop(action: (anim: string) => void): KEventController; /** * @since v3000.0 */ @@ -462,10 +467,10 @@ export function sprite( } else if (curAnim.loop) { curAnim.frameIndex = 0; + this.trigger("animLoop", curAnim.name); } else { this.frame = frames.at(-1)!; - curAnim.onEnd(); this.stop(); return; } @@ -477,10 +482,10 @@ export function sprite( } else if (curAnim.loop) { curAnim.frameIndex = frames.length - 1; + this.trigger("animLoop", curAnim.name); } else { this.frame = frames[0]; - curAnim.onEnd(); this.stop(); return; } @@ -519,7 +524,6 @@ export function sprite( pingpong: false, speed: 0, frameIndex: 0, - onEnd: () => {}, } : { name: name, @@ -528,7 +532,6 @@ export function sprite( pingpong: opt.pingpong ?? anim.pingpong ?? false, speed: opt.speed ?? anim.speed ?? 10, frameIndex: 0, - onEnd: opt.onEnd ?? (() => {}), }; curAnimDir = typeof anim === "number" ? null : 1; @@ -580,6 +583,13 @@ export function sprite( return this.on("animStart", action); }, + onAnimLoop( + this: GameObj, + action: (name: string) => void, + ): KEventController { + return this.on("animLoop", action); + }, + renderArea() { if (!_shape) { _shape = new Rect(vec2(0), _width, _height); From 5e393dfeb7d7dd4ce779d27027e845eb7e8cf711 Mon Sep 17 00:00:00 2001 From: lajbel <71136486+lajbel@users.noreply.github.com> Date: Sun, 29 Jun 2025 11:21:03 -0300 Subject: [PATCH 2/4] feat: back onLoop and onEnd in options --- src/ecs/components/draw/sprite.ts | 10 +++++++++- src/types.ts | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ecs/components/draw/sprite.ts b/src/ecs/components/draw/sprite.ts index ba97ff542..dc099b239 100644 --- a/src/ecs/components/draw/sprite.ts +++ b/src/ecs/components/draw/sprite.ts @@ -34,6 +34,8 @@ export interface SpriteCurAnim { */ frameIndex: number; pingpong: boolean; + onEnd?: () => void; + onLoop?: () => void; } /** @@ -70,7 +72,7 @@ export interface SpriteComp extends Comp { /** * Play a piece of anim. */ - play(anim: string, options?: SpriteAnimPlayOpt): void; + play(anim: string, opt?: SpriteAnimPlayOpt): void; /** * Stop current anim. */ @@ -467,10 +469,12 @@ export function sprite( } else if (curAnim.loop) { curAnim.frameIndex = 0; + curAnim.onLoop?.(); this.trigger("animLoop", curAnim.name); } else { this.frame = frames.at(-1)!; + curAnim.onEnd?.(); this.stop(); return; } @@ -482,11 +486,13 @@ export function sprite( } else if (curAnim.loop) { curAnim.frameIndex = frames.length - 1; + curAnim.onLoop?.(); this.trigger("animLoop", curAnim.name); } else { this.frame = frames[0]; this.stop(); + curAnim.onEnd?.(); return; } } @@ -532,6 +538,8 @@ export function sprite( pingpong: opt.pingpong ?? anim.pingpong ?? false, speed: opt.speed ?? anim.speed ?? 10, frameIndex: 0, + onEnd: opt.onEnd, + onLoop: opt.onLoop, }; curAnimDir = typeof anim === "number" ? null : 1; diff --git a/src/types.ts b/src/types.ts index 06e431d54..11bd774c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6358,6 +6358,10 @@ export interface SpriteAnimPlayOpt { * Runs when this animation ends. */ onEnd?: () => void; + /** + * Runs when this animation loops. + */ + onLoop?: () => void; } export type MusicData = string; From c42e0e76bc2873451cfaad542bbf1d2f189b3d40 Mon Sep 17 00:00:00 2001 From: lajbel <71136486+lajbel@users.noreply.github.com> Date: Sun, 29 Jun 2025 11:48:54 -0300 Subject: [PATCH 3/4] chore: stuff --- src/ecs/components/draw/sprite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ecs/components/draw/sprite.ts b/src/ecs/components/draw/sprite.ts index dc099b239..c2c9a6dae 100644 --- a/src/ecs/components/draw/sprite.ts +++ b/src/ecs/components/draw/sprite.ts @@ -491,8 +491,8 @@ export function sprite( } else { this.frame = frames[0]; - this.stop(); curAnim.onEnd?.(); + this.stop(); return; } } From 106005640f552b18cf0bb75eed4bed382a8f8ee7 Mon Sep 17 00:00:00 2001 From: lajbel <71136486+lajbel@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:46:37 -0300 Subject: [PATCH 4/4] feat: implement onAnimStop, clarify ambiguities --- examples/spriteAnim.js | 19 +++++++++++++++-- src/ecs/components/draw/sprite.ts | 34 ++++++++++++++++++++++++++++++- src/types.ts | 18 ++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/examples/spriteAnim.js b/examples/spriteAnim.js index 6c8d02aab..8b2d58cd3 100644 --- a/examples/spriteAnim.js +++ b/examples/spriteAnim.js @@ -78,7 +78,7 @@ This time we're defining a function for executing animations conditionally. const playerPlayRun = () => { // obj.play() will reset to the first frame of the animation // so we want to make sure it only runs when the current animation is not "run" - if (player.isGrounded() && player.getCurAnim().name !== "run") { + if (player.isGrounded() && player.getCurAnim()?.name !== "run") { player.play("run"); } }; @@ -115,6 +115,10 @@ onKeyPress(["space", "up"], () => { } }); +onKeyPress("x", () => { + player.stop(); +}); + // Switch to "idle" or "run" animation when player hits ground player.onGround(() => { if (!isKeyDown("left") && !isKeyDown("right")) { @@ -136,7 +140,17 @@ player.onAnimStart((anim) => { player.onAnimEnd((anim) => { if (anim === "idle") { - debug.log("hi!"); + // Will never run as idle is set to loop: true + debug.log("idle animation ended"); + } + else if (anim === "jump") { + debug.log("jump animation ended"); + } +}); + +player.onAnimStop((anim) => { + if (anim === "idle") { + debug.log("idle animation stopped"); } }); @@ -150,6 +164,7 @@ const getInfo = () => Anim: ${player.getCurAnim()?.name} Frame: ${player.frame} Loops: ${loopCount} +Press (x) to stop anim `.trim(); // Add some text to show the current animation diff --git a/src/ecs/components/draw/sprite.ts b/src/ecs/components/draw/sprite.ts index c2c9a6dae..4eda3300f 100644 --- a/src/ecs/components/draw/sprite.ts +++ b/src/ecs/components/draw/sprite.ts @@ -36,6 +36,7 @@ export interface SpriteCurAnim { pingpong: boolean; onEnd?: () => void; onLoop?: () => void; + onStop?: () => void; } /** @@ -119,14 +120,34 @@ export interface SpriteComp extends Comp { onAnimStart(action: (anim: string) => void): KEventController; /** * Register an event that runs when an animation is ended. + * + * End is triggered by: + * + * - Animation reaching the last frame. */ onAnimEnd(action: (anim: string) => void): KEventController; /** * Register an event that runs when an animation is looped. * + * Loop is triggered by: + * + * - Animation reaching the last frame for looping (last one or first/last in pingpong) + * * @since v4000.0 */ onAnimLoop(action: (anim: string) => void): KEventController; + /** + * Register an event that runs when an animation is stopped. + * + * Stop is triggered by: + * + * - obj.stop(); + * - obj.play(); + * - Animation ending (like onEnd) + * + * @since v4000.0 + */ + onAnimStop(action: (anim: string) => void): KEventController; /** * @since v3000.0 */ @@ -474,6 +495,7 @@ export function sprite( } else { this.frame = frames.at(-1)!; + this.trigger("animEnd", curAnim.name); curAnim.onEnd?.(); this.stop(); return; @@ -491,6 +513,7 @@ export function sprite( } else { this.frame = frames[0]; + this.trigger("animEnd", curAnim.name); curAnim.onEnd?.(); this.stop(); return; @@ -540,6 +563,7 @@ export function sprite( frameIndex: 0, onEnd: opt.onEnd, onLoop: opt.onLoop, + onStop: opt.onStop, }; curAnimDir = typeof anim === "number" ? null : 1; @@ -552,9 +576,10 @@ export function sprite( if (!curAnim) { return; } + curAnim.onStop?.(); const prevAnim = curAnim.name; curAnim = null; - this.trigger("animEnd", prevAnim); + this.trigger("animStop", prevAnim); }, numFrames() { @@ -598,6 +623,13 @@ export function sprite( return this.on("animLoop", action); }, + onAnimStop( + this: GameObj, + action: (name: string) => void, + ) { + return this.on("animStop", action); + }, + renderArea() { if (!_shape) { _shape = new Rect(vec2(0), _width, _height); diff --git a/src/types.ts b/src/types.ts index 11bd774c2..a9ee2f3de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6356,12 +6356,30 @@ export interface SpriteAnimPlayOpt { preventRestart?: boolean; /** * Runs when this animation ends. + * + * End is triggered by: + * + * - Animation reaching the last frame. */ onEnd?: () => void; /** * Runs when this animation loops. + * + * Loop is triggered by: + * + * - Animation reaching the last frame for looping (last one or first/last in pingpong) */ onLoop?: () => void; + /** + * Runs when this animation stops. + * + * Stop is triggered by: + * + * - obj.stop(); + * - obj.play(); + * - Animation ending (like onEnd) + */ + onStop?: () => void; } export type MusicData = string;