Proposal: 2D animation — spritesheet/frame animation + tweening (deterministic, ECS-native) #5

Closed
opened 2026-08-29 18:13:01 +02:00 by orkun · 1 comment
Owner

Context

Two kinds of motion cover almost all 2D games, and Godot cleanly separates them: sprite-frame animation (named clips like idle/run/jump played from a spritesheet, via AnimatedSprite2D + SpriteFrames) and tweening (interpolating any value over time with easing, via Tween). Ludic has neither today. This proposes both, ECS-native and deterministic.

Deterministic by design: animation and tweens advance on Ludic's fixed frame clock (Time.frame/Time.delta from #2), so a replay reproduces every frame and every eased value exactly. Great for lockstep netcode and diffable headless renders.

Part 1 — Spritesheet & frame animation (Sprite. + a component)

Define clips over a spritesheet (grid of cells), then play by name:

# describe the sheet once (cell size + named clips of frame indices)
let hero = Sprite.sheet(image: hero_png, cell_width: 16, cell_height: 16)
Sprite.clip(hero, name: "run",  frames: [0, 1, 2, 3], fps: 12, loop: true)
Sprite.clip(hero, name: "idle", frames: [4, 5],       fps: 4,  loop: true)

# ECS-native: an entity animates itself
property SpriteAnim { sheet: int = 0, clip: int = 0, frame: int = 0, timer: fixed = 0.0,
                      flip_x: bool = false, speed: fixed = 1.0 }
# a built-in system advances `frame`/`timer` each frame; you just set the clip:
Anim.play(self(), "run")      # switch clip
Anim.stop(self())
  • Per-clip: fps, loop, ping_pong, flip_x/flip_y.
  • Frame events / callbacks: fire an event on a given frame (footstep sound, hitbox active) — ties to Ludic's event/@On.
  • Screen.sprite(sheet, frame, x, y) (from #2) is the low-level draw the system calls.

Part 2 — Tweening (Tween.)

Short, disposable interpolation — damage numbers, fades, camera recoil, UI pops:

Tween.to(target: self(), field: "Position.column", to: 120, duration: fx_ratio(1,2), ease: Ease.OutBack)
Tween.by(target: self(), field: "Health.current", by: -10, duration: fx(1))
  • Chaining & parallel: Tween.chain(...), Tween.parallel(...), delay, loop, yoyo (DOTween/Godot Tween style).
  • Easing pulls from Ease.* in #2 (in/out/in_out/elastic/bounce/back).
  • Interpolates int, fixed, vec2, and color (types from #1) — position, scale, rotation, tint, alpha.
  • Two forms: a handle for one-offs, and a Tween/Motion component for entity-bound motion advanced by a built-in system (ECS-native, replay-safe).

Why both (Godot's rule of thumb)

Reusable, repeated motion → frame animation (clips). Throwaway one-off motion → tween. Offering both keeps each simple.

Phasing

  1. Sprite.sheet/Sprite.clip + SpriteAnim component + built-in frame stepper.
  2. Tween.to/by one-offs with Ease.*.
  3. Frame events, ping_pong/flip, tween chain/parallel/loop/yoyo.
  4. Tween/Motion component; tweening vec2/color.

References

Frame animation + tweening, ECS-native and advanced on the deterministic fixed frame clock.

## Context Two kinds of motion cover almost all 2D games, and **Godot** cleanly separates them: **sprite-frame animation** (named clips like `idle`/`run`/`jump` played from a spritesheet, via `AnimatedSprite2D` + `SpriteFrames`) and **tweening** (interpolating any value over time with easing, via `Tween`). Ludic has neither today. This proposes both, ECS-native and deterministic. > **Deterministic by design:** animation and tweens advance on Ludic's **fixed frame clock** (`Time.frame`/`Time.delta` from #2), so a replay reproduces every frame and every eased value exactly. Great for lockstep netcode and diffable headless renders. ## Part 1 — Spritesheet & frame animation (`Sprite.` + a component) Define clips over a spritesheet (grid of cells), then play by name: ``` # describe the sheet once (cell size + named clips of frame indices) let hero = Sprite.sheet(image: hero_png, cell_width: 16, cell_height: 16) Sprite.clip(hero, name: "run", frames: [0, 1, 2, 3], fps: 12, loop: true) Sprite.clip(hero, name: "idle", frames: [4, 5], fps: 4, loop: true) # ECS-native: an entity animates itself property SpriteAnim { sheet: int = 0, clip: int = 0, frame: int = 0, timer: fixed = 0.0, flip_x: bool = false, speed: fixed = 1.0 } # a built-in system advances `frame`/`timer` each frame; you just set the clip: Anim.play(self(), "run") # switch clip Anim.stop(self()) ``` - Per-clip: `fps`, `loop`, `ping_pong`, `flip_x/flip_y`. - **Frame events / callbacks**: fire an `event` on a given frame (footstep sound, hitbox active) — ties to Ludic's `event`/`@On`. - `Screen.sprite(sheet, frame, x, y)` (from #2) is the low-level draw the system calls. ## Part 2 — Tweening (`Tween.`) Short, disposable interpolation — damage numbers, fades, camera recoil, UI pops: ``` Tween.to(target: self(), field: "Position.column", to: 120, duration: fx_ratio(1,2), ease: Ease.OutBack) Tween.by(target: self(), field: "Health.current", by: -10, duration: fx(1)) ``` - Chaining & parallel: `Tween.chain(...)`, `Tween.parallel(...)`, `delay`, `loop`, `yoyo` (DOTween/Godot `Tween` style). - Easing pulls from `Ease.*` in #2 (`in`/`out`/`in_out`/`elastic`/`bounce`/`back`). - Interpolates `int`, `fixed`, `vec2`, and `color` (types from #1) — position, scale, rotation, tint, alpha. - Two forms: a **handle** for one-offs, and a **`Tween`/`Motion` component** for entity-bound motion advanced by a built-in system (ECS-native, replay-safe). ## Why both (Godot's rule of thumb) Reusable, repeated motion → **frame animation** (clips). Throwaway one-off motion → **tween**. Offering both keeps each simple. ## Phasing 1. `Sprite.sheet`/`Sprite.clip` + `SpriteAnim` component + built-in frame stepper. 2. `Tween.to`/`by` one-offs with `Ease.*`. 3. Frame events, `ping_pong`/flip, tween chain/parallel/loop/yoyo. 4. `Tween`/`Motion` component; tweening `vec2`/`color`. ## References - [Godot — 2D sprite animation](https://docs.godotengine.org/en/stable/tutorials/2d/2d_sprite_animation.html) and [`AnimatedSprite2D`](https://docs.godotengine.org/en/stable/classes/class_animatedsprite2d.html) (SpriteFrames: named clips, fps, loop); Godot `Tween` for throwaway interpolation; DOTween-style chaining. - Related: `Ease.*` in #2; `vec2`/`color` in #1; `Time.*` in #2; `event`/`@On` for frame callbacks. _Frame animation + tweening, ECS-native and advanced on the deterministic fixed frame clock._
orkun added the
proposal
priority:medium
area:rendering
labels 2026-08-29 19:51:36 +02:00
Author
Owner

Shipped the deterministic math core of both halves in e4d1e95 (pushed to main).

Anim.* — spritesheet frame animation: frame (looping), once (clamped one-shot), pingpong, finished, duration, and cell_x/cell_y (source rect on a grid sheet).

Tween.* — value tweening over a timeline: progress/loop/yoyo/done (normalized amount from a timer + duration), ease(t, mode) (curve 0..6, sharing Ease.*'s exact formulas via a new shared ease_eval), and typed blends number/round/point/tint for a fixed / int / Vector / color.

Everything is pure Q16.16 / integer math with no new runtime and no heap — the game stores a timer on a component and calls these each frame, exactly like Collision.* / Grid.*. Because it runs off the fixed frame clock, a replay reproduces every frame and every eased value bit-for-bit, so lockstep netcode and diffable headless renders stay exact.

Verified by examples/library/anim.ludic (34 self-asserting cases: frame math, clamping, ping-pong, cell geometry, timeline clamp/loop/yoyo, half-up rounding, color/vector blends, and Ease.in == Tween.ease(., 1)), wired into x test (now 62 passed). Docs: Anim + Tween sections with 16 per-symbol pages; x check-impl / x check-docs green. Seed reseeded and the C-free bootstrap fixpoint holds.

The stateful sugar the proposal sketches — named clips, Anim.play, fluent Tween.chain/parallel handles, frame-event callbacks, and an auto-injected advance system — is tracked as a follow-up in #43, since it needs engine-owned systems over user components rather than pure inline math. Closing this as the math core both halves stand on.

Shipped the deterministic **math core** of both halves in e4d1e95 (pushed to `main`). **`Anim.*`** — spritesheet frame animation: `frame` (looping), `once` (clamped one-shot), `pingpong`, `finished`, `duration`, and `cell_x`/`cell_y` (source rect on a grid sheet). **`Tween.*`** — value tweening over a timeline: `progress`/`loop`/`yoyo`/`done` (normalized amount from a timer + duration), `ease(t, mode)` (curve `0..6`, sharing Ease.*'s exact formulas via a new shared `ease_eval`), and typed blends `number`/`round`/`point`/`tint` for a `fixed` / `int` / `Vector` / color. Everything is pure Q16.16 / integer math with no new runtime and no heap — the game stores a timer on a component and calls these each frame, exactly like `Collision.*` / `Grid.*`. Because it runs off the fixed frame clock, a replay reproduces every frame and every eased value bit-for-bit, so lockstep netcode and diffable headless renders stay exact. Verified by `examples/library/anim.ludic` (34 self-asserting cases: frame math, clamping, ping-pong, cell geometry, timeline clamp/loop/yoyo, half-up rounding, color/vector blends, and `Ease.in == Tween.ease(., 1)`), wired into `x test` (now 62 passed). Docs: `Anim` + `Tween` sections with 16 per-symbol pages; `x check-impl` / `x check-docs` green. Seed reseeded and the C-free bootstrap fixpoint holds. The stateful sugar the proposal sketches — named clips, `Anim.play`, fluent `Tween.chain`/`parallel` handles, frame-event callbacks, and an auto-injected advance system — is tracked as a follow-up in #43, since it needs engine-owned systems over user components rather than pure inline math. Closing this as the math core both halves stand on.
orkun closed this issue 2026-08-31 12:11:54 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#5
No description provided.