Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions .claude/skills/document-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
name: document-feature
description: Write or update a conceptual guide page in documentation-site/docs/docs for a new or changed Forge feature, focused on practical usage (common use cases, gotchas, performance notes, code smells to avoid) rather than restating the API surface. Also makes sure the public API has JSDoc for the auto-generated API reference. Use when a component, system, class, or module has been added or changed and needs user-facing documentation.
---

# Document a feature

Produces a handwritten guide page under `documentation-site/docs/docs/`.
These guides are a practical companion to the auto-generated API reference
(`documentation-site/docs/api/`, gitignored, built by typedoc from source
JSDoc), not a restatement of it. Never hand-edit anything under `docs/api/`,
fix the JSDoc in `/src` instead.

## 1. Scope the feature

- Find what changed: `git diff main...HEAD --stat` (or ask the user) to find
the relevant `/src/<module>` directory.
- Read the tests (`*.test.ts`) and any usage in `/demo`. This is where the
"why" and "how it's actually used" lives, not just the constructor
signature.
- Check recent commit messages touching this code for context on tradeoffs,
perf fixes, or bugs that motivated the design. These often become the best
gotcha and performance notes.

## 2. Ensure JSDoc exists (this feeds the API reference, not the guide)

Per AGENTS.md, every public class/method/property needs a JSDoc comment with
`@param`, `@returns`, `@throws` as applicable. If the new API is missing
JSDoc, add it now, this is what `docs/api/` is generated from. If you edit
`/src`, follow CLAUDE.md verification (`npm run check-types`, `npm test`,
`npm run lint`) before finishing.

The guide page in step 3 should assume this reference exists and link to it
rather than duplicating it.

## 3. What belongs in the guide

The guide's job is to help someone use the feature correctly and avoid
mistakes, not to enumerate its API surface (the generated reference already
does that). Favor:

- **Common use cases**: the problem the feature solves, framed around a
realistic scenario, e.g. "use `applyForce` for a continuous push like
wind or thrust, use `applyImpulse` for an instantaneous hit like a
collision or jump."
- **Gotchas**: non-obvious behavior, ordering requirements (e.g. system
registration order), units and coordinate conventions, what happens at
edge values (zero mass, disabled entities, static bodies, etc.).
- **Performance notes**: anything that affects cost at scale, caching
behavior, when an optimization kicks in or is bypassed, what to avoid
doing every frame. Mine recent perf-related commits and code comments for
this.
- **Common mistakes / code smells**: a short "don't do this" example paired
with "do this instead" and a one-line reason.
- **A realistic worked example**: the feature used in context (inside a
system, alongside related components), not just a bare constructor call.

### What does NOT belong in the guide

- Full constructor signatures, parameter lists, or return types. Link to the
API reference instead.
- A "Properties" section that just restates field declarations.
- Method-by-method walkthroughs that mirror the class's public interface.

If you find yourself transcribing JSDoc into the guide, stop, that
information already lives in the generated reference. Link to it using the
site's base URL, following the existing pattern in
`docs/ecs/game.md`:
`[RigidBody](/Forge/docs/api/classes/RigidBody)`,
`[applyForce](/Forge/docs/api/classes/RigidBody#applyforce)`.

## 4. Find or create the guide page

Guide pages live at `documentation-site/docs/docs/<module>/<topic>.md`, where
`<module>` matches the `/src/<module>` folder name (`ecs`, `physics`,
`lifecycle`, `animations`, `common`, `utils`, ...).

- **Module folder already exists** (e.g. `physics/`): add a new
`kebab-case.md` file for the feature, or extend an existing page if the
feature is a small addition to a concept already documented there.
- **Module folder doesn't exist yet**: create it with:
- `_category_.json`
- `index.md`, short overview of the module (1+ paragraphs, optionally a
bullet list of "Guides in this section" linking to each page)
- the new topic page(s)

### Page conventions

- Optional frontmatter `sidebar_position: N` to order pages within a folder
(used in `ecs`, `lifecycle`, `common`), pick a number after the existing
siblings.
- `# Title` in Title Case, naming the use case or concept (not necessarily
the class name), e.g. `# Applying Forces`, not `# RigidBody`.
- Code blocks use ` ```ts ` or ` ```typescript ` and import from the
**published package path** (no relative paths, no `.js`), e.g.:

```ts
import { RigidBody } from '@forge-game-engine/forge/physics';
```

- Cross-link related guide pages with relative markdown links, e.g.
`[World docs](./world.md)`.
- Do not use any en-dashes or em-dashes.

### `_category_.json` shapes

For a module with an `index.md` overview page:

```json
{
"label": "<Display Name>",
"position": <N>,
"link": { "type": "doc", "id": "docs/<module>/index" }
}
```

For a module without one yet (sidebar lists pages directly):

```json
{
"label": "<Display Name>",
"position": <N>
}
```

Check sibling `_category_.json` files under `documentation-site/docs/docs/`
to pick a `position` that doesn't collide.

## 5. Wire it up

- If the module's `index.md` has a "Guides in this section" list, add the
new page to it.
- Double-check the new page's filename/heading reads sensibly in the
autogenerated sidebar (`docsSidebar` uses `{ type: 'autogenerated', dirName: '.' }`).

## 6. Verify

- `cd documentation-site && npm run start` and visit the new page, confirm
it renders, the sidebar entry appears in the right place, and any internal
links resolve.
- If `/src` was edited in step 2, run the full CLAUDE.md verification suite
(`npm run check-types`, `npm test`, `npm run lint`) from the repo root.
8 changes: 8 additions & 0 deletions .cspell/project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ esbenp
Fira
Flaticon
fract
frontmatter
GLSL
hitscan
hotspot
Infima
inigo
Kenney
keyup
lerp
lifecycles
matterjs
mediump
Menlo
Mertens
mousedown
mousemove
mouseup
ndot
Oboro
perlin
Expand All @@ -47,6 +54,7 @@ sonarsource
spritesheet
stoppables
unmarks
unrotated
updatables
uvec
WASD
Expand Down
6 changes: 5 additions & 1 deletion documentation-site/docs/docs/animations/_category_.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"label": "Animations",
"position": 6
"position": 6,
"link": {
"type": "doc",
"id": "docs/animations/index"
}
}
24 changes: 17 additions & 7 deletions documentation-site/docs/docs/animations/index.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
---
sidebar_position: 1
---

# Animations

An animation is a value or set of values that change over time according to a timing function, easing curve, and optional looping rules. In this engine animations take two primary forms:
An animation is a value (or set of values) that changes over time according
to a timing function, easing curve, and optional looping rules. Forge
provides two ECS-integrated animation systems:

- [`createAnimationEcsSystem`](/Forge/docs/api/functions/createAnimationEcsSystem):
interpolates numeric values, such as position, scale, or opacity, with
easing and looping.
- [`createSpriteAnimationEcsSystem`](/Forge/docs/api/functions/createSpriteAnimationEcsSystem):
advances a [`SpriteAnimationEcsComponent`](/Forge/docs/api/interfaces/SpriteAnimationEcsComponent)
through the frames of an [`AnimationClip`](/Forge/docs/api/classes/AnimationClip)
sliced from a sprite sheet.

Guides in this section:

- Sprite animations: sequences of frames derived from a sprite sheet (advance frame indices / UVs).
- Property animations: interpolation of numeric values such as position, scale, or opacity.
- [Sprite Animations](./sprite-animations.md): slicing sprite sheets into
animation clips and playing them on an entity.
- [Property Animations](./property-animations.md): interpolating numeric
values with easing, looping, and ping-pong.
147 changes: 130 additions & 17 deletions documentation-site/docs/docs/animations/property-animations.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,152 @@
sidebar_position: 2
---

# Property animations
# Property Animations

Property animations interpolate numeric values over time. Use them for position, scale, opacity, or any numeric parameter.
Property animations interpolate a single number over time and report
progress through a callback every tick. Use them for anything you can
express as a number: position, scale, rotation, opacity, a health bar's
fill amount, and so on.

## Defining animated properties
## Adding an animated property

Create an `animation` component with `properties` entries. Each entry specifies `key`, `from`, `to`, `duration` (ms), optional `easing`, and loop behavior.
Animated properties live in the `animations` array of the
[`animation`](/Forge/docs/api/variables/animationId) component
([`AnimationEcsComponent`](/Forge/docs/api/interfaces/AnimationEcsComponent)).
Build each entry with
[`createAnimatedProperty`](/Forge/docs/api/functions/createAnimatedProperty),
which fills in defaults
([`animationDefaults`](/Forge/docs/api/variables/animationDefaults)) for any
[`AnimatedProperty`](/Forge/docs/api/interfaces/AnimatedProperty) field you
don't specify, then register
[`createAnimationEcsSystem`](/Forge/docs/api/functions/createAnimationEcsSystem)
to advance them every tick:

```ts
entity.addComponent(animationComponent({
properties: [
{ key: 'alpha', from: 0, to: 1, duration: 400, easing: easeInOutSine }
]
}));
import { positionId } from '@forge-game-engine/forge/common';
import { Vector2 } from '@forge-game-engine/forge/math';
import {
animationId,
createAnimatedProperty,
createAnimationEcsSystem,
easeInOutSine,
} from '@forge-game-engine/forge/animations';

const entity = world.createEntity();

world.addComponent(entity, positionId, {
local: Vector2.zero,
world: Vector2.zero,
});

world.addComponent(entity, animationId, {
animations: [
createAnimatedProperty({
startValue: 0,
endValue: 100,
duration: 400,
easing: easeInOutSine,
updateCallback: (x) => {
const position = world.getComponent(entity, positionId);

if (position) {
position.local.x = x;
}
},
}),
],
});

world.addSystem(createAnimationEcsSystem(time));
```

## Easing
Every `world.update()`, the system adds `deltaTimeInMilliseconds` to each
animation's `elapsed`, runs `elapsed / duration` through `easing`, and calls
`updateCallback` with the result mapped between `startValue` and `endValue`.
When `elapsed >= duration`, `updateCallback` is called once more with the
exact `endValue` so the animation always lands precisely on target, then
`finishedCallback` runs and the entry is removed from `animations` (unless
it's looping, see below).

Pick an easing function that matches the motion you want. Available functions include `linear`, `easeInOutSine`, `easeInBack`, `easeInOutQuint`, `easeInOutElastic`, and `easeInOutBack`.
## Triggering animations at runtime

`animations` is a plain array, so you can push new entries onto it whenever
something happens in your game, for example fading out a sprite when an
entity is defeated:

```ts
import { easeInOutSine } from '...';
import { Color, spriteId } from '@forge-game-engine/forge/rendering';

const animationComponent = world.getComponent(entity, animationId);

property.easing = easeInOutSine;
animationComponent?.animations.push(
createAnimatedProperty({
startValue: 1,
endValue: 0,
duration: 200,
updateCallback: (alpha) => {
const sprite = world.getComponent(entity, spriteId);

if (sprite) {
sprite.tintColor = new Color(1, 1, 1, alpha);
}
},
finishedCallback: () => world.removeEntity(entity),
}),
);
```

## Easing

Pick an easing function that matches the motion you want:
[`linear`](/Forge/docs/api/functions/linear),
[`easeInOutSine`](/Forge/docs/api/functions/easeInOutSine),
[`easeInOutQuint`](/Forge/docs/api/functions/easeInOutQuint),
[`easeInBack`](/Forge/docs/api/functions/easeInBack),
[`easeInOutBack`](/Forge/docs/api/functions/easeInOutBack), and
[`easeInOutElastic`](/Forge/docs/api/functions/easeInOutElastic).

:::tip
The "back" and "elastic" easing functions overshoot, producing values below 0
or above 1 partway through the animation (a wind-up or bounce). If
`updateCallback` can't handle values outside `[startValue, endValue]`
(for example, clamped properties like alpha), pick a non-overshooting easing
function such as `easeInOutSine` or `easeInOutQuint` instead.
:::

## Looping and ping-pong

Set `loop` to `'loop'` or `'pingpong'` and control repetition with `loopCount` (-1 for infinite). The system updates `elapsed` each tick and calls `updateCallback(value)` with the eased progress.
Set `loop` to `'loop'` or `'pingpong'` (the default is `'none'`) to repeat
the animation, and `loopCount` to control how many times (`-1`, the default,
loops forever):

- `'loop'` resets `elapsed` to `0` and jumps back to `startValue`, then plays
forward to `endValue` again.
- `'pingpong'` resets `elapsed` to `0` and swaps `startValue` and `endValue`,
so the next iteration plays in reverse. Each iteration swaps them again,
producing a back-and-forth motion.

Each iteration after the first decrements `loopCount` (if it's `0` or
greater). Once `loopCount` reaches `0`, the animation is removed and
`finishedCallback` runs.

:::caution
With `'pingpong'`, `startValue` and `endValue` are swapped in place on the
`AnimatedProperty` object every iteration. Don't rely on either field holding
its original value after the first loop; keep a separate copy of the
original bounds if you need them.
:::

## Notes and troubleshooting

- Avoid `duration` of zero; the system expects positive durations.
- Factory helpers apply sensible defaults but user-provided values take precedence.
- If an animation finishes immediately, inspect `duration`, `elapsed`, and `easing` values.
- A `duration` of `0` (or any value `elapsed` already exceeds) completes the
animation on its very first tick: `updateCallback` runs with `endValue`
immediately and `finishedCallback` fires right away.
- `updateCallback` can run twice in the tick where an animation finishes,
once with the eased value and once with the exact `endValue`. If your
callback has side effects beyond setting a value (playing a sound,
incrementing a counter), guard against double-firing or move that logic
into `finishedCallback`.
- `finishedCallback` only runs when the animation is fully removed. For
looping animations, that's after `loopCount` reaches `0`, not after every
individual iteration.
Loading
Loading