Skip to content

character-animation.controller overview


Table of contents


utils

CharacterAnimationClipMap (type alias)

Maps a {@link CharacterAnimationState} to the animation clip name inside the character's own model that should play for it. A state missing from the map (or whose resolved name isn't actually one of the model's animationNames) is simply never switched into - the controller keeps whatever was last playing rather than switching to nothing.

Signature

export type CharacterAnimationClipMap = Partial<Record<CharacterAnimationState, string>>

CharacterAnimationController (class)

Drives a CharacterController3dEntity's animated model (see IAnimatedDisplayObject3dComponent) by picking a {@link CharacterAnimationState} from the character's own public movement state each tick - isGrounded, isCrouching, isRunning, moveDirection - and calling playAnimation whenever that state changes, plus updateAnimations every tick regardless (to advance the underlying clip time). A no-op entity (still ticks, does nothing) if character.object3D isn't an animated display object at the time of the check - e.g. a physics-only/invisible character, or one using the auto-generated capsule mesh instead of a loaded model - so it's always safe to attach one speculatively rather than checking first.

Ticks at TickOrder.ANIMATION_MIXERS, deliberately after character's own tick (TickOrder .PHYSICS_SIMULATION - 5, pre-physics) and after Entity3d's OBJECTS_BINDING mesh-position sync (400) - both physics simulation and this frame's position/rotation sync have already happened by then, so character.isGrounded and friends reflect this frame's actual result rather than last frame's, and the model's own transform is already in its final place for whatever rendering does next. Mirrors PlayerCharacterController driving CharacterController3dEntity, and ObjectGrabController driving Grabbable3dEntity, in shape - a separate controller entity reading/reacting to another entity's state, rather than folding this into CharacterController3dEntity itself (which ticks pre-physics and has no notion of animation) - see gg-engine-core-development's tickOrder section for why this split is the established pattern here.

Not tied to level JSON: construct and world.addEntity/character.addChildren one directly for a programmatically-built character too. The built-in "Player" level class does exactly this itself (see Player3DSettings.display.model) whenever a model with animations is configured, so a level-JSON player gets this wiring for free with no app code needed.

character.isGrounded is a raw per-tick physics reading, not a stable/hysteresis-free signal - standing right at the edge of a platform (or any other borderline-contact position) can make an adapter's own sweep/overlap check flip it true/false tick to tick with no actual movement involved, which would otherwise make resolveState() flicker the animation between 'jump' and whatever grounded state applies ('idle'/'walk'/'run'/'crouch') every single tick - visibly distracting, and not something any adapter-level fix can resolve (it's inherent to sampling a boundary condition every tick, not a bug in one particular adapter's sweep). This class debounces that raw signal itself (see groundedTransitionDelay) before resolveState() ever sees it, rather than debouncing the resolved animation state - debouncing the state instead would equally suppress a deliberate rapid state change (e.g. running straight off a ledge into open air, which should read as airborne the instant it happens, not groundedTransitionDelay later) purely because grounded-ness happened to be involved, which isn't the actual problem being solved.

Signature

export declare class CharacterAnimationController<TypeDoc> {
  constructor(
    protected readonly character: CharacterController3dEntity<TypeDoc>,
    options: Partial<CharacterAnimationControllerOptions> = {}
  )
}

onSpawned (method)

Signature

onSpawned(world: Gg3dWorld<TypeDoc>): void

resolveState (method)

Resolves the character's current movement state to one of the built-in {@link CharacterAnimationState}s. Airborne takes priority over crouching (a character can't be both at once anyway, since CharacterController3dEntity.tryStandUp only runs while grounded), which takes priority over walking/running, which takes priority over idle. Reads _debouncedGrounded, not character.isGrounded directly - see this class's own doc for why.

Signature

protected resolveState(): CharacterAnimationState

updateGroundedDebounce (method)

Updates {@link _debouncedGrounded} from character.isGrounded's raw reading this tick. The first call ever always adopts the raw value immediately (there's no prior committed state to protect yet - the "just spawned already mid-air" case shouldn't wait out the debounce window before showing an airborne pose). After that, a raw value that disagrees with the currently committed one only replaces it once it's held steady for groundedTransitionDelay seconds straight - a flip back to the committed value at any point before then restarts the timer from 0 rather than carrying over partial progress, so a rapid back-and-forth (the flicker this exists to suppress) never accumulates enough steady time to commit either value.

Signature

private updateGroundedDebounce(deltaSeconds: number): void

update (method)

Signature

private update(deltaSeconds: number): void

dispose (method)

Signature

dispose(): void

tickOrder (property)

Signature

readonly tickOrder: TickOrder.ANIMATION_MIXERS

options (property)

Signature

readonly options: CharacterAnimationControllerOptions

CharacterAnimationControllerOptions (type alias)

Signature

export type CharacterAnimationControllerOptions = {
  /** Overrides `DEFAULT_CLIP_MAP` per state - see {@link CharacterAnimationClipMap}. */
  clipMap: CharacterAnimationClipMap
  /** Crossfade duration (seconds) applied on every state switch. Default 0.2. */
  fadeDuration: number
  /** Minimum `Pnt3.len(character.moveDirection)` that counts as "trying to move" (vs. idle).
   * Default 0.01 - just above float noise, since `moveDirection` is normally either exactly zero
   * or a unit-ish vector from an input driver, never a small nonzero value by accident. */
  movementEpsilon: number
  /**
   * Minimum time (seconds) `character.isGrounded`'s *raw* reading must hold steady at a new value
   * before the animation state machine actually trusts it - see this class's own doc for why this
   * exists at all. `0` disables debouncing entirely (every raw flip is trusted immediately, the
   * pre-debounce behavior). Default 0.15.
   */
  groundedTransitionDelay: number
}

CharacterAnimationState (type alias)

Built-in movement states a CharacterAnimationController distinguishes. Covers the animation set called out for a player character - idle/walking/running/crouching/airborne - not an open-ended state machine; an app wanting more states (e.g. a distinct landing pose, strafing) drives IAnimatedDisplayObject3dComponent.playAnimation directly instead of going through this controller.

Signature

export type CharacterAnimationState = 'idle' | 'walk' | 'run' | 'crouch' | 'jump'