Skip to content

i-character-controller-3d.component overview


Table of contents


utils

ICharacterController3dComponent (interface)

A capsule-shaped kinematic character controller: a thin, physics-engine-specific "move and collide" primitive, parallel to IRaycastVehicleComponent. Unlike a raycast vehicle it does not own any gameplay logic (gravity, jumping, walk/run speed) - that lives once, backend- agnostically, in CharacterController3dEntity, which is the class apps/other core code should normally use instead of this interface directly.

Implementations must make move() fully synchronous: by the time it returns, position, rotation, isGrounded and groundNormal must already reflect the result of that call, with no dependency on a subsequent IPhysicsWorld3dComponent.simulate() call. This is what lets CharacterController3dEntity integrate gravity/jumping itself and get identical behavior regardless of which physics backend is plugged in, instead of relying on (and diverging on) each native engine's own character-controller gravity/impulse model.

removeFromWorld(world, dispose) (inherited from IBodyComponent/IWorldComponent - see their doc for the general contract) matters especially here: CharacterController3dEntity swaps this component out wholesale on every crouch/stand transition (recreateCapsule), calling removeFromWorld(world, true) on the discarded capsule and then dropping its only reference to it. An implementation whose removeFromWorld ignores dispose and only detaches from the world's own bookkeeping - without also freeing the native capsule shape/ghost object/collider - leaks one such native object per crouch/stand transition, since nothing else will ever call dispose() on that discarded instance afterwards.

Signature

export interface ICharacterController3dComponent<PTypeDoc extends PhysicsTypeDocRepo3D = PhysicsTypeDocRepo3D>
  extends IBodyComponent<Point3, Point4, PTypeDoc> {
  /** Capsule radius, as given at creation time. */
  readonly radius: number
  /** Distance between the two capsule hemisphere centers, as given at creation time. */
  readonly centersDistance: number
  /** World "up" direction used to tell the floor from walls/ceilings. */
  up: Point3
  /** Whether the capsule is currently resting on the ground (as of the last `move()` call). */
  readonly isGrounded: boolean
  /** The ground's surface normal, if `isGrounded`; `null` otherwise. */
  readonly groundNormal: Point3 | null

  /**
   * Rigid bodies this character's own collision queries (the sweeps/overlap-recovery behind
   * `move()`) must skip entirely - not just "don't collide", genuinely invisible to this character's
   * queries, as if temporarily removed from the world. `ownCollisionGroups`/`interactWithCollisionGroups`
   * **cannot** express this: excluding one specific body while both it and this character still need
   * to collide with the rest of the world (almost always true) requires a bit that's absent from
   * *both* sides of the pair, but present on each side for every other collision that still needs to
   * happen - impossible with a single shared group both sides must keep for ordinary world collision
   * (see `gg-engine-core-development`'s "Collision groups can't express..." note for the full
   * argument). This is the actual, working mechanism instead: a real per-pair exclusion, checked
   * directly by each adapter's own sweep/overlap query rather than via broadphase group/mask bits.
   *
   * A plain mutable `Set`, not a getter/setter pair - a caller adds/removes individual bodies
   * (e.g. `Grabbable3dEntity`'s `objectBody`, added by `ObjectGrabController` on `grab()`, removed on
   * `release()`/`throw()`) without needing to read-modify-write a whole replacement collection.
   * Implementations must consult this set fresh on every `move()` call - membership can change
   * between ticks. Empty by default (no exclusions).
   */
  readonly ignoredBodies: Set<PTypeDoc['rigidBody']>

  /**
   * Attempts to move the character by exactly this desired displacement, sliding along
   * obstacles, automatically stepping over ledges up to `maxStepHeight`, and snapping to the
   * ground per `snapToGroundDistance` - see `CharacterController3dOptions`. Fully resolves
   * `position`/`rotation`/`isGrounded`/`groundNormal` before returning (see interface doc).
   *
   * `dt`, when given, is the real time (seconds) `desiredTranslation` was computed to cover -
   * `CharacterController3dEntity` always passes it (its own tick delta). It exists purely so an
   * implementation that pushes dynamic bodies (see `CharacterController3dOptions.pushMass`) can
   * recover the character's actual speed (`desiredTranslation` magnitude / `dt`) rather than
   * working from a per-tick distance alone; a mover that doesn't implement pushing is free to
   * ignore it entirely. A mover that *does* push dynamic bodies must not treat a missing `dt` as
   * license to use `desiredTranslation`'s raw per-tick magnitude as if it were already a speed -
   * that understates push force by roughly a factor of `dt` (a 16ms tick's displacement is ~60x
   * smaller than the equivalent m/s figure), silently, not just imprecisely. Skip the push for that
   * tick instead (a one-time warning is reasonable) whenever `dt` isn't available to compute a real
   * speed from.
   *
   * Calling this before the component has been added to a world (see `addToWorld`) must be a
   * silent no-op rather than throwing, so backend-agnostic caller code behaves identically
   * regardless of which adapter is plugged in.
   */
  move(desiredTranslation: Point3, dt?: number): void

  clone(): ICharacterController3dComponent<PTypeDoc>
}