/**
* Portals Guardian avatar SDK — TypeScript declarations.
*
* Stamped into every published bundle as a pair. Include the script exactly
* like `_portals/sdk.js`:
*
*
*
*
*
* const { PortalsAvatars } = await PortalsGuardians.ready();
*
* or, from a module, through the bare specifier the managed import map
* provides:
*
* import { PortalsAvatars } from '@portals/avatars';
*
* Both reach the same ES module at `_portals/guardians-sdk.module.js`.
* `three` is a peer: it resolves through the same import map to the managed
* runtime at `_portals/vendor/three/three.module.js`, so the page has exactly
* one Three.js instance.
*
* Hand-authored, like portals.d.ts. `guardiansSdkSource.test.ts` fails if the
* built bundle exports a name this file does not declare.
*/
/** Global installed by the `_portals/guardians-sdk.js` classic script. */
declare const PortalsGuardians: {
/**
* Resolves with the SDK's module namespace. Safe to call repeatedly — the
* module is fetched once and the promise is shared.
*/
ready(): Promise;
/** True once `ready()` has resolved and the exports are on this object. */
loaded: boolean;
/** Absolute URL of the ES module this loader pulls in. */
moduleUrl: string;
version: string;
} & Partial;
declare module '@portals/avatars' {
import type * as THREE from 'three';
/**
* The managed Three.js, re-exported. A game loaded through the classic
* script has no other way to reach it, and taking it from here guarantees
* there is exactly one instance on the page.
*/
export * as THREE from 'three';
export const GUARDIANS_SDK_VERSION: string;
// -------------------------------------------------------------------------
// Core types
// -------------------------------------------------------------------------
export type BodyType = 'male' | 'female';
export type WearableSlot =
| 'hat'
| 'glasses'
| 'neck'
| 'top'
| 'sleeves'
| 'gloves'
| 'left_hand'
| 'right_hand'
| 'back'
| 'bottom'
| 'shoes'
| 'full_body'
| 'not_interact';
/** Behaviour class for `right_hand` items; drives the carry pose and use action. */
export type HandItemType = 'carry' | 'melee' | 'throwable' | 'gun' | 'gun2h';
export type CameraMode = 'third' | 'first';
/** See `ControllerOptions.strafe`. */
export type StrafeMode = 'off' | 'always' | 'aim';
/** Which take a diagonal collapses onto — see `StrafeTuning.collapse`. */
export type StrafeCollapse = 'off' | 'hip' | 'aim' | 'auto';
/**
* Shooter-grade strafe selection. Every field is off by default, so a game
* that does not set `strafeTuning` animates exactly as it always has.
*/
export interface StrafeTuning {
/**
* Radians the body turns to face a FORWARD diagonal instead of crabbing
* along it. π/4 makes a diagonal run read as an angled run. Read the live
* value off `CharacterController.diagonalYaw`. Needs `collapse` on `'hip'`
* or `'auto'` to have an effect.
*/
diagonalTurn?: number;
/**
* `'aim'` folds every diagonal onto the pure sidestep; `'hip'` sends the
* forward diagonals to the plain forward clip and the back diagonals to the
* sidestep; `'auto'` is `'aim'` while aiming and `'hip'` otherwise.
*/
collapse?: StrafeCollapse;
/**
* Keep the strafe lock while sprinting and cap the sprint at run pace in
* every direction but straight ahead and the forward diagonals.
*/
sprintCap?: boolean;
/** Dead band (2.0 / 2.6 m/s) on the walk/run clip boundary. */
hysteresis?: boolean;
}
/** Local transform applied when a rigid wearable is attached to a bone. */
export interface WearableOffset {
position?: [number, number, number];
rotation?: [number, number, number];
scale?: number;
}
/** Ragdoll feel — see `RAGDOLL_DEFAULTS` for what each field does. */
export interface RagdollTuning {
gravity?: number;
knockback?: number;
headshotKnockback?: number;
lift?: number;
spread?: number;
woundRadius?: number;
momentum?: number;
drag?: number;
groundFriction?: number;
limpTime?: number;
limpFloor?: number;
bounce?: number;
iterations?: number;
maxSubsteps?: number;
maxSpeed?: number;
settleDrift?: number;
settleTime?: number;
maxTime?: number;
}
export interface RagdollOptions {
tuning?: RagdollTuning;
/** `SceneCollider.getGroundHeight` — what a body lands on. */
getGroundHeight?: (x: number, z: number, y?: number, radius?: number) => number;
/** `SceneCollider.raycast` — what keeps a thrown body out of walls. */
raycast?: (origin: THREE.Vector3, direction: THREE.Vector3, maxDistance: number) => RaycastHit | null;
/** Half-width of the box bodies are kept inside; omit for a map with a void. */
bounds?: number | null;
}
export interface ShotKickParams {
from: THREE.Vector3;
point: THREE.Vector3;
head?: boolean;
tuning?: RagdollTuning;
}
/** Spread straight into `Ragdoll.start()`. */
export interface ShotKick {
impulse: THREE.Vector3;
point: THREE.Vector3;
}
/** The three gun stances `loadGunPoses` registers. */
export type GunPoseName = 'adsPose' | 'hipPose' | 'heavyPose';
/**
* Per-stance gun placement in the hand, for `aimWeapon`'s `nudge` and `tilt`.
* Keyed by the stance being held; `down` is the carry with no stance overlay
* (`pose` null). Rows are `[x, y, z]` in the WEAPON's own frame: metres for a
* nudge (x right, y up, z back along the barrel), Euler XYZ radians for a
* tilt. A missing row means no adjustment for that stance.
*/
export type GunPoseTable = Partial>;
export interface LoadGunPosesOptions {
/** Log a warning when the clip set is unavailable. Default true. */
warn?: boolean;
}
/** How much of the camera's pitch the body takes on — see `AIM_BEND_DEFAULTS`. */
export interface AimBendTuning {
follow?: number;
limit?: number;
hipSpineShare?: number;
}
/** The two-handed hip carry — see `HEAVY_CARRY` for what each field does. */
export interface CarryTuning {
twist?: number;
tuck?: number;
reach?: number;
hug?: number;
elbowLift?: number;
elbowBack?: number;
kickSpine?: number;
kickShoulder?: number;
kickElbow?: number;
kickBarrel?: number;
}
/**
* Where the same two-handed weapon goes when it is SHOULDERED — see
* `SCOPE_DEFAULTS`. A superset of `CarryTuning`: the raise walks each carry
* number toward this table's copy of it, so spread a carry into a custom one
* (`{ ...RAIL_CARRY, ...yours }`) rather than writing it from scratch.
*/
export interface ScopeTuning extends CarryTuning {
/** Exponential rate, per second, the weapon comes up and goes down at. */
blend?: number;
/** Where the trigger hand welds, in metres off the Head bone, camera frame. */
hand?: { forward: number; right: number; up: number };
/** Support-hand target in the WEAPON's frame, shouldered. */
foregrip?: [number, number, number];
/** Radians the clavicle lifts into the stock — the pocket the butt sits in. */
shoulderLift?: number;
/** Radians the chin drops onto the cheek plate. */
headTilt?: number;
/** Share of the aim the torso does NOT take that the neck makes up. */
headFollow?: number;
}
/** What a shot does to the gun and the arm — see `RECOIL_DEFAULTS`. */
export interface RecoilTuning {
gunKickBack?: number;
gunKickArm?: number;
gunKickForearm?: number;
}
export interface AimWeaponParams {
/** The stance being held, from `wantedPose`. Null leaves the body alone. */
pose: GunPoseName | null;
/** The CAMERA's heading, radians — not the body's. */
lookYaw: number;
/** Camera elevation, radians, positive looking up. */
pitch: number;
/** `CharacterController.diagonalYaw`. */
diagYaw?: number;
/** 0..1 decay of the current shot's jolt, the game's own to drive. */
kick?: number;
carry?: Required;
bend?: Required;
recoil?: Required;
/** Name of the empty marking the barrel tip. Default `'muzzle'`. */
muzzleNode?: string;
/** Support-hand target in the WEAPON's frame. Default `HEAVY_FOREGRIP`. */
foregrip?: [number, number, number];
/**
* Where each stance wants the gun IN THE HAND, over the equip-time grip —
* see `GunPoseTable`. Applied to the wearable holder every frame (at kick 0
* too, so the recoil slide returns to the nudged rest). Pass the same table
* at both ends of the wire, like every other posing input here.
*/
nudge?: GunPoseTable;
/**
* ...and how each stance wants the gun TURNED in the grip, over the
* equip-time rotation. The nudge's frame and the recoil slide both read the
* tilted orientation. On a RAISED weapon the barrel is re-aimed through the
* wrist afterwards, so pitch/yaw read as the gun sitting differently in the
* grip while the barrel stays on the crosshair; only the `down` carry (and
* roll everywhere) shows raw.
*/
tilt?: GunPoseTable;
/**
* Bring the waist carry UP to the eye — the shouldered scope pose. Default
* false. Only `heavyPose` reads it; drive it from the same flag at both ends
* of the wire, or a peer sniping you still holds the weapon at their hip.
*/
scoped?: boolean;
/** Where the shouldered weapon goes. Default `SCOPE_DEFAULTS`. */
scope?: Required;
/**
* Which caller is posing this body, when more than one does in a frame
* (a killcam rewind alongside the live loop). Default `'live'`. The raise is
* eased per avatar AND per driver, or one advances it and the other does not.
*/
driver?: string;
/** Seconds since this driver's last call. Omit and the raise reads its own clock. */
dt?: number;
}
/** Shared by `muzzleWorld` and `shotOrigin`. */
export interface MuzzleWorldOptions {
/** Name of the empty marking the barrel tip. Default `'muzzle'`. */
muzzleNode?: string;
/** How far from the feet a muzzle may be before it is not this body's gun, m. Default 3. */
maxReach?: number;
/** Fallback height above the feet when there is no barrel to read. Default 1.16. */
chestHeight?: number;
}
/** Everything `shotOrigin` needs. */
export interface ShotOriginParams extends MuzzleWorldOptions {
/** The world's ray test — pass `SceneCollider.raycast`. */
raycast: (
origin: THREE.Vector3,
direction: THREE.Vector3,
maxDistance: number
) => RaycastHit | null;
/** `CharacterController.crouching`. Lowers the fallback under a low ledge. */
crouching?: boolean;
/** Fallback height above the feet while crouched. Default 0.82. */
crouchChestHeight?: number;
}
export type AnimationName =
| 'idle'
| 'walk'
| 'run'
| 'sprint'
| 'jump'
| 'fall'
| 'land'
| (string & {});
/** Declarative avatar appearance. Every field is optional. */
export interface GuardianConfig {
bodyType?: BodyType;
skinColor?: string | number;
hairStyle?: string | null;
hairColor?: string | number;
eyeColor?: string | number;
facialHair?: string | null;
hiddenParts?: string[];
wearables?: string[];
}
export interface AvatarLoadOptions extends GuardianConfig {
modelUrl?: string;
/** Preserve an override model's authored materials, visibility, eye treatment, and geometry. */
preserveModelAppearance?: boolean;
/** Override whether this avatar receives the protected basic outfit. */
defaultOutfit?: boolean;
position?: THREE.Vector3 | { x: number; y: number; z: number };
rotationY?: number;
shadows?: boolean;
/** Clamp model height to [1, 1.6] m, matching the Unity client. Default true. */
normalizeHeight?: boolean;
eyeTextures?: { diffuse: string; mask: string };
}
/** One public wearable in the saved `/avatar` look. */
export interface PortalsPlayerAvatarWearable {
itemId: string;
name: string;
slots: WearableSlot[];
glb: string;
removesHair: boolean;
}
/** A selected full-avatar replacement in the saved `/avatar` look. */
export interface PortalsPlayerFullAvatar {
itemId: string;
name: string;
glb: string;
}
export interface PortalsPlayerAvatar {
config: {
bodyType: BodyType;
skinColor: string;
hairColor: string;
hairStyle: string;
};
customAvatar: PortalsPlayerFullAvatar | null;
wearables: PortalsPlayerAvatarWearable[];
}
/** Current public profile returned by `Portals.player.get()`. */
export interface PortalsPlayerProfile {
playerId: string | null;
displayName: string | null;
avatarUrl: string | null;
username: string | null;
avatar: PortalsPlayerAvatar | null;
}
export type PlayerAvatarLoadOptions = Omit<
AvatarLoadOptions,
keyof GuardianConfig | 'modelUrl' | 'preserveModelAppearance' | 'defaultOutfit'
>;
/**
* A wearable. `id` is yours to choose; set `portalsItemId` only when the
* item genuinely came from a player's Portals inventory, which is what lets
* an asset on a non-Portals host reach a published game via the item proxy.
*/
export interface WearableDef {
id: string;
name: string;
slot: WearableSlot;
/** All slots occupied, for multi-slot items. Defaults to `[slot]`. */
slots?: WearableSlot[];
/** Portals inventory item id, when this came from a player's inventory. */
portalsItemId?: string;
url?: string;
/** GLB used on a female Guardian (Portals `SourceUrlFemale`). */
urlFemale?: string;
object?: THREE.Object3D;
hides?: string[];
hideHair?: boolean;
attachBone?: string;
offset?: WearableOffset;
bodyType?: BodyType;
thumbnail?: string;
handItemType?: HandItemType;
}
export interface EquippedWearable {
def: WearableDef;
slots: WearableSlot[];
root: THREE.Object3D;
hiddenMeshes: THREE.Object3D[];
}
export interface AnimationSource {
name: AnimationName;
url: string;
/** Clip name inside the GLB; defaults to the first clip. */
clip?: string;
loop?: boolean;
timeScale?: number;
/** Register as a masked overlay layer instead of a full-body action. */
overlay?: 'rightArm' | 'leftArm' | 'upperBody' | RegExp;
/**
* Overlays only. Take the phase from the base locomotion clip instead of a
* free-running clock — the port of Unity's synced animator layer
* (`m_SyncedLayerIndex` on the Unity client's arms layer). Needed by any overlay
* that moves: an arm swing on its own clock drifts against the stride.
*/
sync?: boolean;
/**
* Corrective rotations (Euler XYZ radians) baked into named bones'
* keyframes at registration — fixes a clip rather than everything it
* holds. Useful when a source take does not suit the rig; a per-item
* offset cannot fix a bad grip without
* breaking the same item's (correct) idle grip.
*/
boneAdjust?: Record;
}
export interface HairStyleInfo {
id: string;
name: string;
node: THREE.Object3D;
}
export interface RaycastHit {
point: { x: number; y: number; z: number };
normal: { x: number; y: number; z: number };
/**
* The mesh that was struck, when the caster knows it — `SceneCollider`
* reports it; hand-rolled `raycast` hooks may omit it (features that
* read it, like `CameraRig.allowedOccluders`, then simply stay off).
*/
object?: THREE.Object3D;
}
export interface ControllerOptions {
domElement: HTMLElement;
camera: THREE.PerspectiveCamera;
/**
* On-screen touch controls — virtual joystick, drag-to-look with
* pinch-to-zoom, semi-transparent action buttons — drawn as a DOM overlay
* over the renderer element.
*
* - `'auto'` (default) — shown on coarse-pointer (touch) devices only.
* - `true` / `false` — force on / off.
* - a `TouchControlsOptions` object — force on, configured.
*
* The overlay only drives the controller's public input API
* (`setMoveVector`, `setJumpHeld`, `setSprint`, `beginPrimaryAction`, …) —
* a game with its own mobile UI passes `false` and calls those itself.
* Restyle via the `.pa-touch*` CSS classes; the instance is
* `controller.touch`.
*/
touch?: boolean | 'auto' | TouchControlsOptions;
cameraMode?: CameraMode;
walkSpeed?: number;
runSpeed?: number;
sprintSpeed?: number;
/** Jump apex height in metres, clamped to `MOVEMENT_LIMITS.jumpHeight`. */
jumpHeight?: number;
jumpSpeed?: number;
jumpTimer?: number;
/** Downward acceleration, m/s². Default 12. The default 1.6 m jump apex is derived from it. */
gravity?: number;
/** Terminal fall speed, m/s. Default 12. */
maxFallSpeed?: number;
/**
* Biggest drop walked down without leaving the ground, metres. Default 0.4
* — keeps a run down a staircase out of the falling clip.
*/
stepDown?: number;
airSpeed?: number;
crouchSpeed?: number;
/**
* Max speed while swimming, m/s (default 1.9 — the swim clip's authored
* pace). Swim mode itself is toggled with `controller.setSwimming()`.
*/
swimSpeed?: number;
invertX?: boolean;
/**
* Strafing — hold the camera's heading and slide, instead of turning to
* face the direction of travel.
*
* - `'off'` (default) — the body turns toward the move direction, so A/D
* walk it left/right rather than sidestepping.
* - `'always'` — the body keeps the camera's heading and the 8-way
* directional clips play according to the direction of travel.
* - `'aim'` — free movement until aiming (`setAiming(true)`, or holding a
* `gun` / `gun2h` right-hand item).
*
* First person always strafes regardless of this setting.
*/
strafe?: StrafeMode;
/**
* Shooter-grade refinements to how a strafing body is animated and faced.
* Every field is off by default — see `StrafeTuning`. Change them at
* runtime with `setStrafeTuning()`.
*/
strafeTuning?: StrafeTuning;
rotationSpeed?: number;
cameraDistance?: number;
cameraHeight?: number;
shoulderOffset?: number;
/**
* Keep the third-person camera out of the level: the boom shortens when
* something comes between the character and the camera, and drops to first
* person when there is no room left. **On by default** — it reuses the
* `raycast` hook below, so a game with no raycaster pays nothing.
*
* Other avatars never block the view (`SceneCollider` skips skinned
* meshes); `userData.noCollide` hides a mesh from the character and the
* camera alike, and `cameraCollision.probe` gives the camera its own
* geometry.
*/
cameraCollision?: boolean | CameraCollisionOptions;
/**
* Restrict how far the camera can be turned — a yaw window, tighter pitch
* stops, or an axis locked away from the player. Unrestricted by default
* apart from the standing pitch stops.
*
* ```js
* // A side-on stage: the view looks along −Z ±34°, height fixed.
* cameraLimits: { minYaw: -0.6, maxYaw: 0.6, lockPitch: true }
* ```
*/
cameraLimits?: CameraLimitOptions;
/**
* `y` is the character's current height, so a sampler that cares about
* overhangs can report the surface under the character rather than the
* topmost one in the column; `radius` the capsule radius, for samplers
* that probe like a capsule bottom rather than a point (`SceneCollider`
* does). Ignoring either is fine for open levels.
*/
getGroundHeight?: (x: number, z: number, y?: number, radius?: number) => number;
/**
* Collide with the other avatars in the world (default true): the
* character is pushed back out of any avatar capsule it overlaps, so
* players cannot run through NPCs or each other. Wired automatically for
* controllers and NPCs created through `PortalsAvatars` (hidden avatars
* are skipped). Set false for a ghost — a spectator, a cutscene actor.
*/
collideWithAvatars?: boolean;
autoJump?: boolean;
autoJumpDelay?: number;
resolveMovement?: (
current: THREE.Vector3,
proposed: THREE.Vector3,
radius: number
) => THREE.Vector3;
/**
* Capsule depenetration: return the position (feet) pushed back out of any
* geometry the body's capsule overlaps. Runs after the vertical step each
* frame — the safety net that keeps a falling character from sinking into
* walls, which `resolveMovement`'s swept probes cannot catch.
* `SceneCollider.controllerOptions()` supplies it (see its `capsule`
* option, default on).
*/
depenetrate?: (
position: THREE.Vector3,
radius: number,
height: number
) => THREE.Vector3;
/**
* The same recovery for a capsule the caller positions: given the two
* sphere CENTRES in world space and the radius, return the offset that
* lifts the capsule clear. Swim mode uses it for the prone body — flat,
* reaching ~0.9 m ahead of and behind the root — which no upright capsule
* describes. `SceneCollider.controllerOptions()` supplies it; without it a
* swimmer falls back to the upright `depenetrate` capsule.
*/
depenetrateSegment?: (
start: THREE.Vector3,
end: THREE.Vector3,
radius: number
) => THREE.Vector3;
/**
* Enables ledge grabbing. Two rows of rays, one per shoulder, are cast down
* from above the head across the character's reach; a hit within 30° of
* horizontal is a ledge, and the line between the rows' hits is the edge
* the character squares up to. Both rows must hit, and the probe only runs
* while airborne, so a climb is always the result of a jump or a fall.
* Nothing taller than the grabbed surface may stand between it and the
* character, so a wall in front is never climbed through to reach a roof
* behind it. Without this hook the character cannot climb at all.
*/
raycast?: (
origin: THREE.Vector3,
direction: THREE.Vector3,
maxDistance: number
) => RaycastHit | null;
/**
* Standing-room check at a prospective ledge target. Make it a VOLUME test:
* rays started inside a single-sided mesh see nothing, so rays alone let a
* ledge whose top lies inside the neighbouring prop pass — and the mantle
* then teleports the body, head included, into it. `SceneCollider` runs a
* capsule overlap plus `isUnderSurface` (0.42.0).
*/
checkClearance?: (
point: { x: number; y: number; z: number },
radius: number,
height: number
) => boolean;
/**
* Key that, HELD for `respawnHoldMs`, calls `controller.requestRespawn()`
* — the player's own way out of a spot the physics lost them in. Default
* `'KeyR'`; `null` disables the key (0.42.0).
*/
respawnKey?: string | null;
/** How long `respawnKey` must be held, ms (default 800). */
respawnHoldMs?: number;
}
// -------------------------------------------------------------------------
// Asset resolution
// -------------------------------------------------------------------------
export interface ManagedAssetOrigin {
prefix: string;
hosts: readonly string[];
}
/**
* Managed same-origin path prefixes and the upstream hosts they front.
* Published games run under `connect-src 'self'` and cannot reach the CDNs
* directly.
*/
export const PORTALS_MANAGED_ORIGINS: readonly ManagedAssetOrigin[];
/** Prefix of the item proxy, used for assets on non-Portals hosts. */
export const PORTALS_ITEM_PREFIX: string;
export type AssetRewriteMode = 'auto' | 'always' | 'never';
/**
* Map an asset URL onto the managed same-origin path a published game can
* actually fetch. Relative, `blob:` and `data:` URLs pass through untouched.
* Use it for `
` thumbnails too — `img-src` is `'self'` as well.
*/
export function resolveAssetUrl(
url: string,
options?: { itemId?: string; variant?: 'female' }
): string;
/** Override sandbox detection. `'auto'` rewrites only in an Arcade build. */
export function setAssetRewriting(next: AssetRewriteMode): void;
/** Release one parsed GLTF template from the shared cache without disposing live clones. */
export function evictGLTF(
url: string,
options?: { itemId?: string; variant?: 'female' }
): boolean;
// -------------------------------------------------------------------------
// Catalog
// -------------------------------------------------------------------------
export const PORTALS_CDN: string;
export const GUARDIAN_MODELS: Record;
export const GUARDIAN_SKIN_COLORS: readonly string[];
export const GUARDIAN_HAIR_COLORS: readonly string[];
export const GUARDIAN_EYE_COLORS: readonly string[];
export const DEFAULT_SKIN_COLOR: string;
export const DEFAULT_HAIR_COLOR: string;
export const DEFAULT_EYE_COLOR: string;
export const DEFAULT_HAIR: Record;
export const SKIN_PRESETS: Record;
export const HAIR_COLOR_PRESETS: Record;
export const EYE_COLOR_PRESETS: Record;
/**
* Body-clip GLBs on the Portals CDN, one per behaviour. Clip loading is lazy
* per URL, so a game only downloads the behaviours it actually uses.
*/
export const GUARDIAN_LOCOMOTION_URL: string;
export const GUARDIAN_LOCOMOTION_EXTRA_URL: string;
export const GUARDIAN_STRAFE_URL: string;
export const GUARDIAN_CROUCH_URL: string;
export const GUARDIAN_CRAWL_URL: string;
export const GUARDIAN_CLIMB_URL: string;
export const GUARDIAN_LEDGE_URL: string;
export const GUARDIAN_ACROBATICS_URL: string;
export const GUARDIAN_SWIM_URL: string;
export const GUARDIAN_UNARMED_URL: string;
export const GUARDIAN_HIT_URL: string;
export const GUARDIAN_SWORD_URL: string;
export const GUARDIAN_PISTOL_URL: string;
export const GUARDIAN_SPELL_URL: string;
export const GUARDIAN_SIT_URL: string;
export const GUARDIAN_INTERACT_URL: string;
export const GUARDIAN_COUNTER_URL: string;
export const GUARDIAN_EMOTE_URL: string;
/**
* Second clip library: a walk-speed 8-way directional set, purpose-built wall
* mantles (`ledge_2m` is what the ledge grab plays), parkour, weapon movesets,
* gathering, a zombie NPC set, and a real overhand throw.
*/
export const GUARDIAN_WALK_URL: string;
export const GUARDIAN_LEDGE_2M_URL: string;
export const GUARDIAN_MANTLE_URL: string;
export const GUARDIAN_PARKOUR_URL: string;
export const GUARDIAN_NINJA_JUMP_URL: string;
export const GUARDIAN_TURN_URL: string;
export const GUARDIAN_SWORD_LIGHT_URL: string;
export const GUARDIAN_SWORD_HEAVY_URL: string;
export const GUARDIAN_SWORD_REGULAR_URL: string;
export const GUARDIAN_SWORD_SPECIAL_URL: string;
export const GUARDIAN_MELEE_COMBO_URL: string;
export const GUARDIAN_BOW_URL: string;
export const GUARDIAN_SHIELD_URL: string;
export const GUARDIAN_LAUNCHED_URL: string;
export const GUARDIAN_FARM_URL: string;
export const GUARDIAN_FISH_URL: string;
export const GUARDIAN_GATHER_URL: string;
export const GUARDIAN_IDLE_EXTRA_URL: string;
export const GUARDIAN_LAY_URL: string;
export const GUARDIAN_THROW_ACTION_URL: string;
export const GUARDIAN_ZOMBIE_URL: string;
export const GUARDIAN_MONSTER_URL: string;
/**
* Behaviour groups beyond what the character controller drives itself:
* `locomotionExtra`, `lean`, `crouchTransitions`, `crawl`, `climb`,
* `acrobatics`, `swim`, `unarmed`, `hit`, `sword`, `pistol`, `spell`, `sit`,
* `interact`, `counter`, `emote`.
*
* ```js
* await avatar.animations.load(ANIMATION_SETS.emote);
* avatar.animations.play('dance');
* ```
*/
export const ANIMATION_SETS: Record;
/**
* Which clip set backs the run/jog band: `'run4'` (default — a natural
* forward run at ≈3.6 m/s world, with the UAL strafe/backward band behind
* the other seven headings) or `'jog2'` (its jog-pace sibling: a relaxed
* ≈2.0 m/s forward jog over the same strafe band). Both stay available at
* runtime; switch with `PortalsAvatars.setRunStyle()`.
*/
export type RunStyle = 'run4' | 'jog2';
/** The run/jog clip sets, keyed by `RunStyle`. */
export const RUN_STYLES: Record;
/** The jog takes GLB (`runStyle: 'jog2'` uses its `Jog_Fwd`). */
export const JOG_SET_URL: string;
/** The run takes GLB (`runStyle: 'run4'` uses its `Running_B`). */
export const RUN_SET_URL: string;
/**
* Eye textures for the iris-mask shader, applied to every avatar by default.
* Without them `setEyeColor` tints the whole eyeball rather than the iris.
*/
export const DEFAULT_EYE_TEXTURES: { diffuse: string; mask: string };
/** Older Guardian_v1 clip pack — still the source of the `throw` overlay. */
export const GUARDIAN_HAND_ITEMS_URL: string;
/**
* Every clip the character controller plays by itself: `idle`, `walk`, `run`,
* `sprint`, `jump`, `fall`, `land`, `roll`, the 8-way directional set
* (`runBack`, `runLeft`, `runRight`, `runFwdLeft`, `runFwdRight`,
* `runBackLeft`, `runBackRight`), the matching WALK-speed set (`walkBack`,
* `walkLeft`, `walkRight`, `walkFwdLeft`, `walkFwdRight`, `walkBackLeft`,
* `walkBackRight`), the crouch set (`crouchIdle`, `crouchWalk`, `crouchBack`,
* `crouchLeft`, `crouchRight`, `crouchFwdLeft`, `crouchFwdRight`,
* `crouchBackLeft`, `crouchBackRight`), plus `climbTop` and `pickup`.
*/
export const DEFAULT_ANIMATIONS: AnimationSource[];
/**
* Masked overlays for right-hand items: `carry` (held steady through the
* run's arm swing, the Unity client's RightArm layer) plus `meleeA`/`meleeB`,
* `throw`, `shoot`/`shoot2h`. Names match `CharacterController.itemAnimations`.
*/
export const DEFAULT_HAND_ANIMATIONS: AnimationSource[];
export const DEFAULT_FACE_ANIMATIONS: AnimationSource[];
/** The three default outfit items the Portals client equips. */
export const GUARDIAN_BASIC_SET: WearableDef[];
// -------------------------------------------------------------------------
// Entry point
// -------------------------------------------------------------------------
export interface PortalsAvatarsOptions {
scene: THREE.Scene;
animations?: AnimationSource[];
/**
* Hand-item overlays; defaults to `DEFAULT_HAND_ANIMATIONS`, `[]` to skip.
* Fetched only once the avatar actually holds something.
*/
handAnimations?: AnimationSource[];
wearables?: WearableDef[];
faceAnimations?: AnimationSource[];
basicSet?: WearableDef[];
/**
* Run/jog clip style for every avatar: `'run4'` (default) or `'jog2'`.
* Only a starting default — `setRunStyle()` switches live. Ignored when a
* custom `animations` array is supplied.
*/
runStyle?: RunStyle;
/** Equip the basic set on every new avatar. Default true. */
defaultOutfit?: boolean;
eyeTextures?: { diffuse: string; mask: string };
defaultConfig?: GuardianConfig;
controllerDefaults?: Partial>;
/** NPC defaults applied to every `createNPC()` / `createNPCFromUrl()` call. */
npcDefaults?: NPCOptions;
}
export class PortalsAvatars {
readonly scene: THREE.Scene;
constructor(options: PortalsAvatarsOptions);
createAvatar(options?: AvatarLoadOptions): Promise;
/** Load the active `/avatar` look returned by `Portals.player.get()`. */
createAvatarFromPlayer(
player: PortalsPlayerProfile,
extra?: PlayerAvatarLoadOptions
): Promise;
/** Build an avatar from a Portals avatar URL (`sc`/`hc`/`ec`/`hair` params). */
createAvatarFromUrl(
url: string,
extra?: Omit
): Promise;
createController(
avatar: GuardianAvatar,
options: ControllerOptions
): CharacterController;
/**
* Load a Guardian as an NPC — the same avatar `createAvatar` builds, but
* driven by code instead of player input: `moveTo` / `follow` / `lookAt` /
* `playAnimation` / `show` / `hide`. Updated by the same `update(dt)`.
*/
createNPC(
avatarOptions?: AvatarLoadOptions,
npcOptions?: NPCOptions
): Promise;
/** `createAvatarFromUrl`, wrapped in the code-driven `NPCController`. */
createNPCFromUrl(
url: string,
extra?: Omit,
npcOptions?: NPCOptions
): Promise;
/** Remove an NPC and its avatar from the scene. */
removeNPC(npc: NPCController): void;
/** The run style new avatars are created with. */
getRunStyle(): RunStyle;
/**
* Switch the run/jog clip set live, in either direction, for every
* existing avatar (or just `avatar`) and all future ones. Clips already in
* memory are re-loaded from the other set's GLB; if one is playing right
* now it restarts in the new style with a short cross-fade.
*/
setRunStyle(style: RunStyle, avatar?: GuardianAvatar): Promise;
removeAvatar(avatar: GuardianAvatar): void;
/**
* Force every declared clip into memory (all avatars by default). Clips
* are lazy: `createAvatar` resolves without them and each GLB downloads on
* first use. Call this behind a loading screen if a visible pop from rest
* pose to idle would be worse than a longer load.
*/
/**
* Mount the default customisation panel — body type, skin/hair/eye colour,
* hair, facial hair and wearables — with a small edge button the player
* uses to show and hide it. Open by default.
*/
createSetupPanel(
avatar: GuardianAvatar,
options?: Omit
): SetupPanel;
preloadAnimations(avatar?: GuardianAvatar): Promise;
/** Advance every avatar and controller. Call once per frame. */
update(dt: number): void;
/**
* Run a callback every frame right after the animation mixers have posed
* the skeletons — the slot for anything that writes bones on top of the
* clips (procedural aiming, ragdoll blending, boneAdjust previews, the
* pose tuner dev tool). Callbacks run in registration order, after
* controller post-updates and before the game code that follows its own
* `update(dt)` call. Returns an unsubscribe function. (0.42.0)
*/
onAfterUpdate(callback: (dt: number) => void): () => void;
dispose(): void;
}
// -------------------------------------------------------------------------
// Avatar
// -------------------------------------------------------------------------
export class GuardianAvatar {
readonly root: THREE.Group;
readonly bodyType: BodyType;
readonly wearables: WearableManager;
readonly animations: AnimationController;
readonly face: FaceAnimationController;
readonly expressions: ExpressionManager;
readonly bones: Map;
readonly skeleton: THREE.Skeleton;
static load(options?: AvatarLoadOptions): Promise;
configure(config: GuardianConfig): this;
getConfig(): GuardianConfig;
setSkinColor(color: string | number): this;
getHairStyles(): { id: string; name: string }[];
setHairStyle(styleId: string | null): this;
setHairColor(color: string | number): this;
setEyeColor(color: string | number): this;
/** Swap in the Portals iris-mask eye shader so `eyeColor` tints only the iris. */
applyEyeShader(urls: EyeTextureUrls): Promise;
getFacialHairStyles(): { id: string; name: string }[];
setFacialHair(styleId: string | null): this;
getParts(): { name: string; visible: boolean }[];
setPartVisible(pattern: string, visible: boolean): number;
setHiddenParts(patterns: string[]): this;
applyHiddenParts(): void;
setPartColor(pattern: string, color: string | number): number;
getBone(name: string): THREE.Bone | undefined;
attachToBone(boneName: string, object: THREE.Object3D): THREE.Group;
findMeshes(nameSubstring: string): THREE.Object3D[];
update(dt: number): void;
dispose(): void;
}
// -------------------------------------------------------------------------
// NPCs
// -------------------------------------------------------------------------
/** NPC movement tuning. Numeric defaults match the character controller. */
export interface NPCOptions {
/** Speed below which locomotion is a walk (m/s). Default 1.25. */
walkSpeed?: number;
/** Speed of `moveTo(..., { run: true })` and fast follows (m/s). Default 4.6. */
runSpeed?: number;
/** Yaw smoothing rate toward the travel/look direction. Default 16. */
rotationSpeed?: number;
/** How close to a destination counts as arrived (m). Default 0.25. */
stopDistance?: number;
/**
* Ground height sampler — return the ground Y at (x, z). Without one the
* NPC keeps its current height (right for flat levels).
*/
getGroundHeight?: (x: number, z: number, y?: number, radius?: number) => number;
/**
* Collide with the other avatars in the world (default true), so the NPC
* neither walks through the player nor lets the player stand inside it.
* Wired automatically for NPCs created through `PortalsAvatars`.
*/
collideWithAvatars?: boolean;
}
export interface NPCMoveOptions {
/** Exact speed in m/s; overrides `run`. */
speed?: number;
/** Move at `runSpeed` instead of `walkSpeed`. */
run?: boolean;
/** Arrival radius for this move (m); defaults to the NPC's `stopDistance`. */
stopDistance?: number;
}
/** A movement/look target: object, vector, or plain point. */
export type NPCTarget =
| THREE.Object3D
| THREE.Vector3
| { x: number; y?: number; z: number };
/** A follow target: additionally an accessor returning the current point. */
export type NPCLiveTarget =
| NPCTarget
| (() => THREE.Vector3 | { x: number; y?: number; z: number });
/**
* A code-driven Guardian — the same avatar the player gets (same model,
* wearables, animations, face system), with the player input replaced by a
* command API. Create via `PortalsAvatars.createNPC()` /
* `createNPCFromUrl()`; the shared `PortalsAvatars.update(dt)` drives it.
*
* Everything an avatar can do stays reachable through `npc.avatar`
* (wearables, face emotions, overlays, bones); this class owns movement,
* facing and full-body clip selection.
*/
export class NPCController {
readonly avatar: GuardianAvatar;
/** Push out of other avatars' capsules — see `NPCOptions.collideWithAvatars`. */
collideWithAvatars: boolean;
constructor(avatar: GuardianAvatar, options?: NPCOptions);
/**
* Wire the set of avatars this NPC collides with — `PortalsAvatars` does
* this for every NPC it creates (the NPC's own avatar and hidden roots
* are skipped).
*/
setAvatarObstacles(provider: (() => Iterable) | null): this;
/**
* Walk (or run) to a point or object, resolving `true` on arrival.
* Starting another move, `stop()`, or disposal resolves `false`. An
* Object3D target is re-read every frame, so a moving one is chased.
*/
moveTo(target: NPCTarget, options?: NPCMoveOptions): Promise;
/**
* Keep within `distance` (default 1.5 m) of a target, indefinitely —
* walks when the gap opens, idles when close. `stop()` ends it.
*/
follow(target: NPCLiveTarget, options?: NPCMoveOptions & { distance?: number }): void;
/** Cancel any move/follow and settle into idle. */
stop(): void;
/** True while a `moveTo` or an out-of-range `follow` is driving the NPC. */
isMoving(): boolean;
/** Teleport. Clears any in-flight move (resolved `false`). */
setPosition(position: NPCTarget): this;
getPosition(out?: THREE.Vector3): THREE.Vector3;
/** Instant facing (radians, world yaw). */
setRotationY(yaw: number): this;
getRotationY(): number;
/**
* Turn smoothly to face a point or object (only applies while idle —
* movement owns the facing). `{ instant: true }` snaps.
*/
lookAt(target: NPCTarget, options?: { instant?: boolean }): this;
/** Retune movement at runtime. */
configureMovement(opts: {
walkSpeed?: number;
runSpeed?: number;
rotationSpeed?: number;
stopDistance?: number;
}): this;
/**
* Play a full-body clip by name, fetching it first if needed; resolves
* `true` once it is actually playing. While the NPC moves, the clip takes
* the body over from locomotion until it finishes (one-shots) or
* `stopAnimation()` (loops). For masked overlays (wave while walking) use
* `avatar.animations.playOverlay()` directly.
*/
playAnimation(
name: AnimationName,
opts?: { fade?: number; restart?: boolean; timeScale?: number }
): Promise;
/** Hand the body back to the NPC's own idle/locomotion selection. */
stopAnimation(fade?: number): void;
show(): void;
hide(): void;
setVisible(visible: boolean): void;
readonly visible: boolean;
/** Advance movement and facing — `PortalsAvatars.update()` calls this. */
update(dt: number): void;
dispose(): void;
}
// -------------------------------------------------------------------------
// Wearables
// -------------------------------------------------------------------------
/**
* Warm the GLB cache for a set of wearables, in parallel, without an avatar
* — downloading needs only the definitions, so it can overlap the body
* model's own load. Failures are swallowed; `equip()` surfaces them later.
*/
export function prefetchWearables(defs: WearableDef[], bodyType: BodyType): Promise;
export class WearableManager {
constructor(avatar: GuardianAvatar);
registerCatalog(defs: WearableDef[]): void;
setBasicSet(defs: WearableDef[]): void;
getBasicSet(): WearableDef[];
isBasic(id: string): boolean;
ensureBasicSet(): Promise;
getCatalog(): WearableDef[];
getEquipped(): {
slot: WearableSlot;
slots: WearableSlot[];
id: string;
name: string;
isBasic: boolean;
}[];
getEquippedUrls(): Partial>;
getEquippedUrlEntries(): Array<[WearableSlot, string]>;
isEquipped(id: string): boolean;
getEquippedDef(slot: WearableSlot): WearableDef | undefined;
getHandItem(): { def: WearableDef; type: HandItemType } | null;
/**
* Equip by catalog id, GLB URL, or an inline definition. `options.slot`
* names the slot for a URL whose filename does not reveal it; a catalog
* entry and an inline definition carry their own.
*/
equip(
item: string | WearableDef,
options?: { slot?: WearableSlot }
): Promise;
unequip(slotOrId: WearableSlot | string): Promise;
unequipAll(): void;
dispose(): void;
}
/** Explicit `def.handItemType` wins, otherwise inferred from the name/id. */
export function classifyHandItem(def: WearableDef): HandItemType;
// -------------------------------------------------------------------------
// Animation
// -------------------------------------------------------------------------
export const OVERLAY_MASKS: {
rightArm: RegExp;
leftArm: RegExp;
upperBody: RegExp;
};
export type OverlayMask = keyof typeof OVERLAY_MASKS | RegExp;
export class AnimationController {
readonly mixer: THREE.AnimationMixer;
constructor(avatarRoot: THREE.Object3D, targetMesh?: THREE.SkinnedMesh);
/** Load clip GLBs now. Foreign Mixamo-rigged clips are retargeted automatically. */
load(sources: AnimationSource[]): Promise;
/**
* Register clips without fetching them. The GLB downloads the first time
* something asks to `play()` one of its clips; clips sharing a GLB load
* together. `has()` reports declared clips as available so state selection
* is correct immediately, and `play()` returns false until the bytes land.
*/
declare(sources: AnimationSource[]): void;
/** Load a declared clip if it is not in memory yet. */
ensure(name: AnimationName): Promise;
/** Force declared clips into memory (all of them by default). */
preload(names?: AnimationName[]): Promise;
/** Decoded and playable now, as opposed to merely declared. */
isLoaded(name: AnimationName): boolean;
register(
name: AnimationName,
clip: THREE.AnimationClip,
opts?: { loop?: boolean; timeScale?: number }
): void;
registerOverlay(
name: AnimationName,
clip: THREE.AnimationClip,
opts?: { mask: OverlayMask; loop?: boolean; timeScale?: number }
): void;
hasOverlay(name: AnimationName): boolean;
getOverlayDuration(name: AnimationName): number | null;
isOverlayActive(name: AnimationName): boolean;
playOverlay(
name: AnimationName,
opts?: { fade?: number; restart?: boolean; hold?: number }
): boolean;
stopOverlay(name: AnimationName, fade?: number): void;
/** Available to this avatar — loaded OR declared. See `isLoaded`. */
has(name: AnimationName): boolean;
getClipDuration(name: AnimationName): number | null;
getClipRootRise(name: AnimationName): number;
sampleClipRootRise(name: AnimationName, time: number): number;
getNames(): string[];
getCurrent(): string | null;
play(
name: AnimationName,
opts?: { fade?: number; restart?: boolean; timeScale?: number }
): boolean;
stopAll(fade?: number): void;
setCurrentTimeScale(scale: number): void;
update(dt: number): void;
dispose(): void;
}
export type FaceEmotion = 'neutral' | 'smile' | 'serious' | 'frown';
export class FaceAnimationController {
readonly mixer: THREE.AnimationMixer;
constructor(avatarRoot: THREE.Object3D);
load(sources: AnimationSource[]): Promise;
/**
* Register face clips without fetching them — each emotion is its own
* small GLB, so `setEmotion` / `setTalking` / `blink` pull in only what
* they need. A declared `blink` is fetched immediately when auto-blink is
* on, since nothing else would ever ask for it.
*/
declare(sources: AnimationSource[]): void;
ensure(name: string): Promise;
preload(names?: string[]): Promise;
isLoaded(name: string): boolean;
register(name: string, clip: THREE.AnimationClip, opts?: { loop?: boolean }): void;
getEmotions(): FaceEmotion[];
getEmotion(): FaceEmotion | null;
setEmotion(emotion: FaceEmotion | null, fade?: number): boolean;
isTalking(): boolean;
setTalking(on: boolean, fade?: number): void;
blink(): void;
setAutoBlink(on: boolean): void;
update(dt: number): void;
dispose(): void;
}
/** Morph-target driver. Guardian v1 GLBs ship no morph targets; custom models may. */
export class ExpressionManager {
constructor(root: THREE.Object3D);
getExpressions(): string[];
has(name: string): boolean;
set(name: string, weight?: number, fade?: number): boolean;
get(name: string): number;
reset(fade?: number): void;
update(dt: number): void;
}
// -------------------------------------------------------------------------
// Controller + camera
// -------------------------------------------------------------------------
export const MOVEMENT_LIMITS: {
walkSpeed: [number, number];
runSpeed: [number, number];
sprintSpeed: [number, number];
jumpHeight: [number, number];
gravity: [number, number];
maxFallSpeed: [number, number];
swimSpeed: [number, number];
};
/**
* Capsule radius assumed for OTHER avatars in avatar-vs-avatar collision
* (0.35 — Guardians are all normalized to the same build); the character's
* own side of a pair uses its configurable `radius`.
*/
export const AVATAR_BODY_RADIUS: number;
export function jumpSpeedForHeight(
height: number,
gravity: number,
holdTime: number
): number;
export interface ItemUseEvent {
type: HandItemType;
action: 'melee' | 'throw' | 'shoot';
phase: 'start' | 'release';
hand: THREE.Vector3;
direction: THREE.Vector3;
}
/** An item leaving the avatar via `throwItem()` / `dropItem()`. */
export interface ItemReleaseEvent {
/** The item that left; pass it back to `pickUpItem()` to take it again. */
def: WearableDef;
/**
* The item itself, detached with its world transform kept and parented to
* nothing — add it to your scene and simulate it from here (the host owns
* the physics).
*/
object: THREE.Object3D;
/** Launch velocity in m/s (zero for a drop). */
velocity: THREE.Vector3;
action: 'throw' | 'drop';
}
export class CharacterController {
readonly avatar: GuardianAvatar;
readonly cameraRig: CameraRig;
/**
* The on-screen touch controls, or null when off — see
* `ControllerOptions.touch`. Disposed with the controller.
*/
readonly touch: TouchControls | null;
readonly velocity: THREE.Vector3;
walkSpeed: number;
runSpeed: number;
sprintSpeed: number;
jumpSpeed: number;
jumpTimer: number;
gravity: number;
/** Terminal fall speed, m/s (the Unity client's `limitFallVelocity`). */
maxFallSpeed: number;
/** Biggest drop walked down without going airborne, metres. */
stepDown: number;
/** Seconds of unjumped ground loss tolerated before the falling clip plays. */
fallAnimDelay: number;
airSpeed: number;
/** Max speed while swimming, m/s (default 1.9 — the clip's authored pace). */
swimSpeed: number;
/** Vertical swim speed (Space rises, C dives), m/s. Default 1.5. */
swimVerticalSpeed: number;
/** World Y the root may not swim above, or null for none — see `setSwimming`. */
swimSurfaceY: number | null;
/**
* Radius of the horizontal capsule the swimmer collides with, m (default
* 0.35) — the prone body's cross-section, used in place of `radius` while
* `swimming` is on.
*/
swimRadius: number;
/**
* How far that capsule reaches ahead of and behind the root, less
* `swimRadius` (default 0.55) — so a swimmer stops 0.9 m short of a wall
* head-on and 0.35 m from one it drifts into sideways.
*/
swimReach: number;
rotationSpeed: number;
/** Strafe behaviour — see `ControllerOptions.strafe`. */
strafe: StrafeMode;
/** Shooter-grade strafe refinements, all off by default — see `StrafeTuning`. */
strafeTuning: Required;
/**
* How far the body is currently turned off the camera's heading to face a
* forward diagonal, radians. Always 0 unless `strafeTuning.diagonalTurn` is
* set. Hand it to `aimWeapon` as `diagYaw`, and replicate it so a peer can
* reconstruct the aim line.
*/
readonly diagonalYaw: number;
/** True while the game (or a held gun) has the character aiming. */
aiming: boolean;
/** Push out of other avatars' capsules — see `ControllerOptions.collideWithAvatars`. */
collideWithAvatars: boolean;
getGroundHeight: (x: number, z: number, y?: number, radius?: number) => number;
resolveMovement:
| ((current: THREE.Vector3, proposed: THREE.Vector3, radius: number) => THREE.Vector3)
| null;
/** Capsule depenetration — see `ControllerOptions.depenetrate`. */
depenetrate:
| ((position: THREE.Vector3, radius: number, height: number) => THREE.Vector3)
| null;
/** Depenetration for a caller-placed capsule — see `ControllerOptions.depenetrateSegment`. */
depenetrateSegment:
| ((start: THREE.Vector3, end: THREE.Vector3, radius: number) => THREE.Vector3)
| null;
raycast:
| ((origin: THREE.Vector3, direction: THREE.Vector3, maxDistance: number) => RaycastHit | null)
| null;
checkClearance:
| ((point: { x: number; y: number; z: number }, radius: number, height: number) => boolean)
| null;
/** Return true to suppress the built-in animation state pick for this frame. */
onBeforeAnimationUpdate: ((controller: CharacterController, dt: number) => boolean) | null;
onItemUse: ((event: ItemUseEvent) => void) | null;
/**
* Called when the player asks for a respawn (holding `respawnKey`, or
* `requestRespawn()`). Return `true` once the game has handled it — put
* the player where its own rules say — and the controller does nothing
* more; otherwise it falls back to `respawn()` at the last position a
* `respawn({ position })` call gave it.
*/
onRespawnRequest: (() => boolean | void) | null;
/**
* Fired at the release frame of `throwItem()` / `dropItem()` — the item is
* off the avatar and comes with a launch velocity; add it to your scene and
* simulate it. Without a handler those calls refuse to run (the item would
* be lost).
*/
onItemRelease: ((event: ItemReleaseEvent) => void) | null;
/** Fired when `pickUpItem()` has put an item back on the avatar. */
onItemPickup: ((def: WearableDef) => void) | null;
constructor(avatar: GuardianAvatar, options: ControllerOptions);
/**
* Wire the set of avatars this character collides with (see
* `ControllerOptions.collideWithAvatars`). `PortalsAvatars` does this for
* every controller it creates; hand-built setups can pass their own
* provider (the character's own avatar and hidden roots are skipped).
*/
setAvatarObstacles(provider: (() => Iterable) | null): this;
configureMovement(opts: {
walkSpeed?: number;
runSpeed?: number;
sprintSpeed?: number;
jumpHeight?: number;
gravity?: number;
maxFallSpeed?: number;
swimSpeed?: number;
}): this;
setWalkSpeed(v: number): this;
setRunSpeed(v: number): this;
setSprintSpeed(v: number): this;
setJumpHeight(meters: number): this;
getJumpHeight(): number;
setEnabled(enabled: boolean): void;
/**
* Give the body to game-driven animation: while locked the controller
* stops selecting locomotion clips and ignores movement/jump/item input,
* but gravity and the camera keep working. Play your own full-body clips
* with `avatar.animations.play(...)`, then unlock to resume idle. Use for
* actions the controller has no state for (fishing, mini-games, scripted
* moments).
*/
setBodyLocked(locked: boolean): void;
/** True while the game owns the body via `setBodyLocked(true)`. */
isBodyLocked(): boolean;
/**
* Put the character in (or out of) swim mode — the GAME toggles this at
* the water's edge; the SDK has no idea where a level's water is. While
* on: gravity is off, movement runs at `swimSpeed`, Space swims up and C
* dives (programmatic: `setJumpHeld` / `setSwimVertical`), the
* `swimFwd`/`swimIdle` clips drive themselves, and jumping, crouching,
* the ledge grab and `pickUpItem()` are suspended. `surfaceY` is an
* optional ceiling the feet may not rise above — pick it from your water
* level minus how deep the body should ride (null = open water). Turning
* it off hands the body straight back to normal physics.
*/
setSwimming(on: boolean, opts?: { surfaceY?: number | null }): this;
/** True while swim mode is on. */
isSwimming(): boolean;
/**
* Programmatic vertical swim input, −1 (dive) to 1 (rise) — what Space
* and C feed while swimming. Sticks until the next call, like
* `setMoveVector`.
*/
setSwimVertical(v: number): this;
/**
* Vault out of the water onto a shore ahead. The swim step calls this on
* every fresh jump press: where ground lies within a waist-depth of
* `swimSurfaceY` — or above it, up to a lip the configured jump can clear
* — swim mode ends in the grounded jump's own arc, with a send-off along
* the facing. Over open water nothing answers and Space stays the rise
* key. Needs `swimSurfaceY`; games may also call it directly. Returns
* true when the jump has begun and swim mode is over.
*/
tryJumpOutOfWater(): boolean;
/**
* The player asks to be put back on their feet — what holding
* `respawnKey` does, for a touch button or a menu entry. `onRespawnRequest`
* gets first refusal; otherwise `respawn({ position })` at the last respawn
* target. Returns false when nothing could be done (no handler and no
* respawn position ever given).
*/
requestRespawn(): boolean;
toggleCameraMode(): CameraMode;
/**
* Restrict (or release) how far the player may turn the camera — a yaw
* window, tighter pitch stops, or an axis locked away from input. Only the
* keys passed change; a camera already outside a new range moves to the
* nearest edge at once. Survives an avatar swap via `adoptFrom`.
*/
setCameraLimits(limits: CameraLimitOptions): this;
/** Change strafe behaviour at runtime. */
setStrafe(mode: StrafeMode): this;
/** Turn on (or retune) the shooter-grade strafe refinements. Merges. */
setStrafeTuning(tuning: StrafeTuning): this;
/**
* Turn the character to face a world heading (radians, 0 = +Z), taking the
* camera with it. Use this instead of assigning `avatar.root.rotation.y` —
* the controller owns that every frame, so an external assignment either gets
* overwritten or fights it, leaving the body facing away from its travel
* direction while the forward run clip plays (it looks like running
* backwards). `turnCamera: false` rotates only the body.
*/
setFacing(yaw: number, options?: { turnCamera?: boolean }): this;
/**
* True world yaw of the root, read from the QUATERNION — immune to the
* flipped Euler representation `avatar.root.rotation.y` can carry. This
* is the value to PUBLISH when a multiplayer game shares the player's
* facing; reading `rotation.y` instead points remote copies anywhere but
* where the player looks, most visibly while idle.
*/
readRootYaw(): number;
/**
* Write the root's facing — euler and quaternion together, collapsing any
* flipped representation back to pure yaw. Use this rather than assigning
* `avatar.root.rotation.y` yourself (e.g. when applying a networked yaw
* to a controller-less remote avatar, mirror this: `rotation.set(0, yaw, 0)`
* then `quaternion.setFromEuler(rotation)`).
*/
writeRootYaw(yaw: number): void;
/**
* Back to a known-good state: at `position` (default: unchanged), facing
* `facing` with the camera behind it, standing still on the ground with every
* in-flight action (climb, pick-up, throw, jump) cancelled. Use it for a
* respawn, or as the escape hatch when the character is somewhere it cannot
* get out of.
*/
respawn(options?: {
position?: THREE.Vector3 | { x: number; y: number; z: number };
facing?: number;
turnCamera?: boolean;
}): this;
/**
* Mesh-facing measurement — runs automatically after the animation mixers
* each frame (`PortalsAvatars.update` calls it; don't call it yourself).
* Estimates the structural yaw offset between the root and the visible
* body (hip line, world space); when a rotation is stuck in a node between
* them, the facing code compensates its target so the BODY faces the
* travel direction. Writes no transforms itself.
*/
postUpdate(dt: number): void;
/** Only meaningful under `strafe: 'aim'`, where it is what enables it. */
setAiming(aiming: boolean): this;
/**
* Is the body locked to the camera's heading right now? Always true in
* first person, whatever the strafe mode.
*/
isStrafing(): boolean;
/**
* Inherit camera orientation and movement tuning from the controller this
* one replaces. Call it in `onAvatarReplaced` after a body-type switch —
* without it the new camera rig snaps behind the avatar and WASD silently
* changes direction under the player.
*/
adoptFrom(previous: CharacterController): this;
jump(): void;
/**
* Hold or release the jump control (same as holding Space): the press
* jumps, keeping it held re-jumps on landing (`autoJump`).
*/
setJumpHeld(held: boolean): void;
/** Sprint on/off programmatically (same as holding Shift). */
setSprint(on: boolean): void;
/**
* Analog movement input — the programmatic WASD for touch joysticks and
* gamepad sticks. Camera-relative: `x` moves right, `y` forward; clamped
* to length 1 and it STICKS until the next call, so pass `(0, 0)` on
* stick release. Deflection sets the pace: full tilt = run speed (sprint
* under `setSprint(true)`), part-way proportionally slower, floored at
* walk speed. Movement keys, while pressed, win over the stick.
*/
setMoveVector(x: number, y: number): void;
/**
* Press the primary use control (the programmatic F key / left click /
* touch action button). A melee item with the combo take in memory
* resolves on RELEASE (tap = quick strike, holding past `meleeHoldTime`
* = the full combo), so pair every call with `endPrimaryAction()`;
* every other item fires on the press itself.
*/
beginPrimaryAction(): void;
/** Release the primary use control — see `beginPrimaryAction`. */
endPrimaryAction(): void;
/** Abandon an in-flight primary press without firing (a cancelled gesture). */
cancelPrimaryAction(): void;
setCrouch(on: boolean): void;
getHandItemType(): HandItemType | null;
useItem(): boolean;
/** Play the full melee combo take directly (the long-press action). */
useItemCombo(): boolean;
/**
* Throw the held item away — at the release frame it leaves the avatar and
* is handed to `onItemRelease` with a launch velocity. Returns false when
* the hand is empty or busy, or no `onItemRelease` handler is set.
*/
throwItem(opts?: { speed?: number; angle?: number }): boolean;
/** Let the held item go where you stand, without the throw animation. */
dropItem(): boolean;
/**
* Take an item (back) into the right hand, playing the pick-up animation —
* aligned to `opts.position` (the item's world position) when given.
*/
pickUpItem(
item: WearableDef | string,
opts?: { position?: THREE.Vector3 | { x: number; y: number; z: number } }
): Promise;
/** True while the pick-up animation owns the body. */
isPickingUp(): boolean;
update(dt: number): void;
dispose(): void;
}
// -------------------------------------------------------------------------
// Combat
// -------------------------------------------------------------------------
export interface HealthControllerOptions {
/** Blows to a kill. Default 3. */
maxHealth?: number;
/** Lock/unlock this controller's body around reactions (optional). */
controller?: CharacterController | null;
/** Flinch takes, alternated per blow. Default `['hitChest', 'hitStomach']`. */
hitClips?: string[];
/** Death takes, one picked per death. Default `['death', 'death2']`. */
deathClips?: string[];
/** Longest a flinch may hold the body, seconds. Default 1.1. */
staggerMax?: number;
/** Seconds the body lies still after the death take before respawn. Default 1.4. */
corpseLinger?: number;
/** Seconds of taking no damage after a respawn. Default 3. */
respawnImmunity?: number;
/**
* Where/how to respawn — teleport the avatar, restore state, play effects.
* Called after the corpse has lingered. Omit (null) to stay dead until
* `respawn()` is called by the game.
*/
respawn?: (() => void) | null;
}
export type HealthEvent = 'hurt' | 'died' | 'respawned';
export type HitResult = 'immune' | 'dead' | 'hurt' | 'killed';
/**
* Health, hit reactions, death and respawn for one Guardian — the receiving
* half of melee combat (`MeleeSweep` is the striking half).
*
* Reactions play on the BASE animation layer, so in a multiplayer game they
* travel wherever the game already shares `animations.getCurrent()`; with a
* `controller` attached the body is locked for exactly as long as a
* reaction plays. Call `warm()` when combat becomes likely — a reaction
* first requested at the blow plays nothing while its GLB is on the wire.
*
* Who calls `takeHit()` is the game's business. The usual multiplayer shape
* is victim-authoritative: each client runs a HealthController for its OWN
* avatar and applies only the hits that name it.
*/
export class HealthController {
constructor(avatar: GuardianAvatar, options?: HealthControllerOptions);
readonly maxHealth: number;
health: number;
dead: boolean;
/** Seconds of post-respawn immunity left (0 when vulnerable). */
immunity: number;
/** Pull the reaction takes into memory ahead of the first blow. */
warm(): void;
/** A blow lands. Returns what it did, for the caller to narrate. */
takeHit(options?: { damage?: number }): HitResult;
/** The killing blow (or scripted death): drop where the body stands. */
kill(): void;
/** Back on their feet: full health, brief immunity, the respawn hook. */
respawn(): void;
/** Run the stagger/corpse clocks. Call once per frame. */
update(dt: number): void;
on(event: HealthEvent, cb: (self: HealthController) => void): () => void;
}
export interface MeleeSweepOptions {
/**
* Forgiveness margin, meters: a blow lands when the blade's surface
* passes within this of a body capsule's surface. Default 0.18 (measured:
* with a ~1 m axe, 0.9 m feet-to-feet always hits, 1.5 m never does).
*/
margin?: number;
/** Targets farther than this from the wielder are not tested. Default 3.5 m. */
cullRadius?: number;
/** Blade-path sub-steps between frames. Default 3. */
steps?: number;
/** The weapon capsule's radius never reads below this. Default 0.05 m. */
minBladeRadius?: number;
}
/** Anything sweepable: a record that renders a Guardian. */
export interface MeleeTarget {
avatar: GuardianAvatar;
}
/**
* Continuous melee hit detection: the equipped hand item is measured into a
* capsule (its real length — a longer weapon reaches further with no
* tuning), each target's POSED skeleton into bone capsules, and the blade's
* path is swept between frames so a fast slash cannot tunnel through a limb.
*
* ```ts
* const sweep = new MeleeSweep(avatar);
* controller.onItemUse = ({ type, phase }) => {
* if (type === 'melee' && phase === 'start') sweep.begin();
* };
* // per frame, AFTER the mixers have posed the bones:
* const victim = sweep.swinging && sweep.test(targets);
* if (victim) { sweep.end(); victim.health.takeHit(); }
* ```
*/
export class MeleeSweep {
constructor(wielder: GuardianAvatar, options?: MeleeSweepOptions);
/** A strike is in flight and unlanded — `test()` only runs while true. */
swinging: boolean;
/**
* Diagnostics: closest surface-to-surface approach of the last swing, in
* meters (negative = touching) — tune `margin` from measurements.
*/
lastApproach: number;
/** Open a swing window — call on the strike's `start` (`onItemUse`). */
begin(): void;
/** Close the swing window (a hit landed, or the take finished clean). */
end(): void;
/** (Re)measure the equipped hand item into the blade capsule. */
measure(): boolean;
/**
* Per-frame while swinging: the first target the blade passed through
* this frame, or null. Leave dead/friendly targets out of `targets`.
*/
test(targets: Iterable): T | null;
}
// -------------------------------------------------------------------------
// Level collision
// -------------------------------------------------------------------------
export interface SceneColliderOptions {
/** Height the character steps onto without jumping. Default 0.32. */
stepHeight?: number;
/** Steepest surface still walkable, in degrees. Default 60. */
slopeLimit?: number;
/** Ground height where no mesh is hit. Default 0. */
groundY?: number;
/** Used to place the knee and chest collision probes. Default 1.6. */
characterHeight?: number;
/** How far above the queried Y the ground probe starts. Default 0.8. */
groundSnapUp?: number;
/**
* Capsule collision (default true): builds a BVH per registered geometry
* and adds a `depenetrate` hook that pushes the body back out of walls —
* the fix for sinking into geometry while falling. The BVH also speeds up
* every raycast here, so per-frame cost goes DOWN; what `false` buys is
* load time (the BVH build, ~1 ms per ~1k triangles) and memory on huge
* scenes, at the price of wall penetration coming back.
*/
capsule?: boolean;
}
/**
* Collision read off the scene meshes — supplies every hook the controller
* needs, so a game with ordinary geometry writes none of them:
*
* const collider = new SceneCollider();
* collider.add(ground).add(level);
* avatars.createController(avatar, {
* camera, domElement, ...collider.controllerOptions(),
* });
*
* Being triangle-exact, it handles ramps, cylinders and imported levels.
* Register level geometry only — never avatars or props.
*/
export class SceneCollider {
constructor(root?: THREE.Object3D, options?: SceneColliderOptions);
/** Registered mesh count — what every ray is tested against. */
readonly meshCount: number;
/** Register an object and its descendants. Skinned meshes are skipped. */
add(object: THREE.Object3D): this;
remove(object: THREE.Object3D): this;
clear(): this;
/** Re-scan the registered roots after adding or removing level meshes. */
refresh(): this;
raycast(
origin: THREE.Vector3,
direction: THREE.Vector3,
maxDistance: number
): RaycastHit | null;
/**
* Surface height at (x, z); `y` starts the probe just above the character
* so overhangs are ignored. Pass the capsule `radius` too (the controller
* does) and four rim rays keep the character supported when its centre
* slips past an edge, a plank gap or a pillar's rim — the fix for landing
* half on a ledge and falling straight through it.
*/
getGroundHeight(x: number, z: number, y?: number, radius?: number): number;
resolveMovement(
current: THREE.Vector3,
proposed: THREE.Vector3,
radius: number
): THREE.Vector3;
/**
* Standing room for a capsule rising from `point`: upward rays for a
* ceiling, then (with `capsule` on) the BVH capsule overlap and
* `isUnderSurface`, so a stand point inside a single-sided prop — invisible
* to rays started inside it — is refused rather than climbed into (0.42.0).
*/
checkClearance(
point: { x: number; y: number; z: number },
radius: number,
height: number
): boolean;
/**
* Push a character capsule out of any overlapping triangle and return the
* corrected position (`position` is the feet). Walls push horizontally,
* ceilings push down, floors are left to `getGroundHeight`. Returns the
* position unchanged when the `capsule` option is off.
*/
depenetrate(
position: { x: number; y: number; z: number },
radius: number,
height: number
): THREE.Vector3;
/**
* The same, for a capsule the caller places: pass the two sphere centres
* in world space and get back the offset that lifts it clear. This is what
* the controller collides the flat swim pose with.
*/
depenetrateSegment(
start: { x: number; y: number; z: number },
end: { x: number; y: number; z: number },
radius: number
): THREE.Vector3;
/** All the hooks, ready to spread into `createController`. */
controllerOptions(): {
getGroundHeight: (x: number, z: number, y?: number, radius?: number) => number;
resolveMovement: (
current: THREE.Vector3,
proposed: THREE.Vector3,
radius: number
) => THREE.Vector3;
raycast: (
origin: THREE.Vector3,
direction: THREE.Vector3,
maxDistance: number
) => RaycastHit | null;
checkClearance: (
point: { x: number; y: number; z: number },
radius: number,
height: number
) => boolean;
depenetrate?: (
position: THREE.Vector3,
radius: number,
height: number
) => THREE.Vector3;
depenetrateSegment?: (
start: THREE.Vector3,
end: THREE.Vector3,
radius: number
) => THREE.Vector3;
};
}
/** A ledge the character can pull itself onto. */
export interface LedgeGrab {
/** Point on the ledge's top face where the hands land. */
point: THREE.Vector3;
/** Horizontal unit normal of the face, pointing out toward the character. */
normal: THREE.Vector3;
/** The lip — where the front face meets the top. */
edge: THREE.Vector3;
}
export interface LedgeProbeParams {
position: THREE.Vector3;
forward: THREE.Vector3;
height: number;
radius: number;
grounded: boolean;
/** The Unity client's `IsJustFalling` — extends the reach for a mid-air grab. */
falling: boolean;
raycast: (
origin: THREE.Vector3,
direction: THREE.Vector3,
maxDistance: number
) => RaycastHit | null;
checkClearance?: (
point: { x: number; y: number; z: number },
radius: number,
height: number
) => boolean;
}
/**
* The ledge probe the controller runs each frame, exposed for games that
* want to show a grab prompt or drive their own climb. Up to ten rays.
*/
export function probeLedge(params: LedgeProbeParams): LedgeGrab | null;
// ------------------------------------------------------------------ ragdoll
/** Death feel. Every field is documented on the constant in the SDK source. */
export const RAGDOLL_DEFAULTS: Required;
/**
* The kick a shot gives a body: a velocity along the shot, lifted enough to
* take the body off its feet, and the world point it lands at. The vertical
* part of the shot is mostly discarded, so a hit taken from a rooftop throws
* the body across the floor rather than hammering it into it.
*
* Returns null when `from` and `point` coincide.
*/
export function shotKick(params: ShotKickParams): ShotKick | null;
/**
* A dead Guardian, falling — a position-based solver run directly on the
* skeleton (one particle per joint, distance constraints for the bones, and a
* pull back toward the pose the body died in), not a physics engine.
*
* ```ts
* const ragdoll = new Ragdoll(avatar, { ...collider.controllerOptions(), bounds: 40 });
* controller.setBodyLocked(true); // or the controller keeps walking the corpse
* ragdoll.start({ ...shotKick({ from, point, head }), velocity: controller.velocity });
* // in the render loop, AFTER avatars.update(dt):
* ragdoll.update(dt);
* // on respawn:
* ragdoll.stop();
* ```
*
* ORDERING: `update` must run after `PortalsAvatars.update()`. The mixer
* rewrites every bone local each frame, so a ragdoll stepped before it is
* simply overwritten and the body plays its death clip instead.
*/
export class Ragdoll {
constructor(avatar: GuardianAvatar, options?: RagdollOptions);
readonly avatar: GuardianAvatar;
readonly tuning: Required;
/** Simulating, or holding the pose it settled into. */
active: boolean;
settled: boolean;
/** Abandoned mid-fall because the solve went non-finite; the clip has the body. */
broken: boolean;
elapsed: number;
/** Mean particle speed, m/s. Reported for tuning. */
speed: number;
/** `active && !settled`. */
readonly running: boolean;
/**
* Take the body over. Returns false when the rig is not one the solver can
* drive, so the caller can fall back to a death clip.
*/
start(params?: {
/** Velocity added to the whole body, m/s. */
impulse?: THREE.Vector3 | null;
/** Where it landed — particles near it take a bigger share. */
point?: THREE.Vector3 | null;
/** The motion the body already had. */
velocity?: THREE.Vector3 | null;
}): boolean;
/** Hand the body back, restoring the captured pose. Call before respawning. */
stop(): void;
update(dt: number): void;
}
// ------------------------------------------------------------------- arm IK
/** Rotate a bone about a WORLD axis, expressed into its parent's frame. */
export function rotateBoneWorld(
bone: THREE.Object3D | null | undefined,
axisWorld: THREE.Vector3,
angle: number
): void;
/** Premultiply a bone's WORLD orientation by a quaternion. */
export function turnBoneWorld(
bone: THREE.Object3D | null | undefined,
worldTurn: THREE.Quaternion
): void;
/**
* Pose a two-bone arm so the wrist lands EXACTLY on `target`, with the elbow
* out on the `pole` side — the analytic solve. Use this, not CCD, whenever the
* elbow's direction matters: `pole` is an input here, where CCD converges to
* whatever plane it likes and erases any bias fed in as a starting pose.
*
* Runs after the mixers, like everything that writes bone locals.
*/
export function poseArmTo(
armBone: THREE.Object3D,
foreBone: THREE.Object3D,
handBone: THREE.Object3D,
target: THREE.Vector3,
pole: THREE.Vector3
): void;
/**
* Cyclic coordinate descent down a joint chain (shoulder first, elbow last),
* for "get this hand near this point" with no elbow opinion. Degrades
* gracefully on a target out of reach — the arm points at it rather than
* snapping to it.
*/
export function solveArmCCD(
hand: THREE.Object3D,
joints: THREE.Object3D[],
target: THREE.Vector3,
passes?: number
): void;
// ------------------------------------------------------------ weapon posing
/** Overlay mask covering everything from the hips up — the aimed stance. */
export const AIM_MASK: RegExp;
/** Overlay mask covering the right arm and the upper spine — the hip point. */
export const HIP_MASK: RegExp;
/** Corrective rotation that swings the one-handed point out to the shoulder line. */
export const HIP_ARM_ADJUST: Record;
/**
* Corrective rotation that opens the heavy carry's trigger arm out of the
* fishing take it is cut from — registered on the `heavyPose` clip by
* `loadGunPoses` (fishing take only; the pistol fallback is another shape).
*/
export const HEAVY_ARM_ADJUST: Record;
/** Name of the empty a game marks its barrel tip with, unless it says otherwise. */
export const DEFAULT_MUZZLE_NODE: string;
/**
* Where a Guardian's right hand has to hold a weapon whose barrel points down
* its own +Z with the grip hanging toward −Y. Solved against the pistol
* stance, not eyeballed — spread it into a `WearableDef.offset`.
*/
export const GUARDIAN_PISTOL_GRIP: WearableOffset;
/**
* The same grip for a weapon with NO grip column — a tube the hand closes on
* directly. Sits lower in the hand so the fist meets the tube's underside.
*/
export const GUARDIAN_TUBE_GRIP: WearableOffset;
/**
* Where the support hand goes on a hip-carried two-handed weapon, in the
* WEAPON's frame. Re-measure it whenever the right arm or the mesh moves.
*/
export const HEAVY_FOREGRIP: [number, number, number];
/** How much of the camera's elevation the body takes on. */
export const AIM_BEND_DEFAULTS: Required;
/** The two-handed hip carry, wrapping a fat-bodied weapon. */
export const HEAVY_CARRY: Required;
/** The same carry for a slim weapon: a straight-arm prop, not a wrap. */
export const RAIL_CARRY: Required;
/**
* Where that weapon goes when the sights come up: shouldered, the stock into
* the cheek. A destination the raise walks to, not a clip — see `aimWeapon`'s
* `scoped`.
*/
export const SCOPE_DEFAULTS: Required;
/**
* Where a shot comes from when the barrel cannot be used, metres above the
* feet — the centre of the torso, so a shot leaves where a shot lands.
*/
export const DEFAULT_CHEST_HEIGHT: number;
export const DEFAULT_CROUCH_CHEST_HEIGHT: number;
/** What a shot does to the gun and the arm. Camera kick is the game's own. */
export const RECOIL_DEFAULTS: Required;
/**
* Register the three gun stances (`adsPose`, `hipPose`, `heavyPose`) and the
* one-handed recoil take (`hipShoot`) on an avatar. Returns false when the
* clip set is unavailable, in which case every function below is a no-op and
* the body simply carries its weapon.
*/
export function loadGunPoses(
avatar: GuardianAvatar,
options?: LoadGunPosesOptions
): Promise;
/**
* Which stance a body should be holding, or null for a weapon that is down.
* A two-handed weapon is carried at the waist whether it is aiming or not.
*/
export function wantedPose(raised: boolean, aiming: boolean, twoHanded?: boolean): GunPoseName | null;
/** `RAIL_CARRY` for a slim weapon, `HEAVY_CARRY` for a fat-bodied one. */
export function carryFor(slim: boolean): Required;
/**
* Swap the stance overlay; returns the mode now held. `snap` brings the new
* stance in with no blend, for a stance that comes up because a shot is being
* fired — the bullet leaves the barrel where the barrel is.
*/
export function applyPose(
avatar: GuardianAvatar | null,
from: GunPoseName | null,
to: GunPoseName | null,
snap?: boolean
): GunPoseName | null;
/**
* The recoil take matching the stance, or null for a waist carry — whose
* recoil is procedural, inside `aimWeapon`.
*/
export function recoilClip(
avatar: GuardianAvatar | null,
aiming: boolean,
twoHanded?: boolean
): string | null;
/**
* Point the weapon where the shot is going: the camera-pitch spine bend, the
* procedural recoil, the two-handed hip carry and the barrel itself.
*
* Runs AFTER `PortalsAvatars.update()` — the mixers rewrite every bone local
* each frame, which is also why nothing it writes accumulates. In multiplayer,
* drive every remote body through this same function from its replicated aim
* yaw, pitch and flags: the body is the only aim indicator a peer gets.
*/
export function aimWeapon(avatar: GuardianAvatar | null, params: AimWeaponParams): void;
/**
* The gun's mount, for a tuning tool's translate/rotate gizmo: the holder it
* drags and the rest transform a drag is measured against. `aimWeapon`
* rewrites the holder as rest + nudge + kick every frame, so a dragged-out
* nudge is holder-local `position − rest` at kick 0, and a dragged-out tilt
* is `restQuat⁻¹ × quaternion`. Fold those into the rows of the stance the
* body is actually HOLDING — a two-handed weapon is always in `heavyPose`
* whatever a tuner's own pose selector says. Not used by gameplay.
*/
export function gunMount(
avatar: GuardianAvatar | null,
muzzleNode?: string
): { holder: THREE.Object3D; rest: THREE.Vector3; restQuat: THREE.Quaternion } | null;
/**
* The barrel tip in world space, refreshed off the body's current pose — what
* tracers and muzzle flashes are drawn from. Falls back to the chest when
* there is no weapon to read, or when the node is further than `maxReach` from
* the body (an unparented clone).
*/
export function muzzleWorld(
avatar: GuardianAvatar | null,
out?: THREE.Vector3,
options?: MuzzleWorldOptions
): THREE.Vector3;
/**
* Where a hitscan shot should be traced FROM: the barrel, unless the barrel is
* inside something, in which case the chest.
*
* A third-person camera sees over and around what the gun is still behind, so
* a shot traced from the lens goes through cover the shooter never left. Trace
* the aim from the camera and the shot from here — see the README for the
* two-trace pattern.
*/
export function shotOrigin(
avatar: GuardianAvatar | null,
params: ShotOriginParams
): THREE.Vector3;
/** Tuning for the third-person camera's obstacle avoidance. */
export interface CameraCollisionOptions {
/** Default true. */
enabled?: boolean;
/** Half-width of the cylinder swept along the boom, m (default 0.2). */
radius?: number;
/** Boom length at which third person gives up, m (default 0.7). */
minDistance?: number;
/** How fast the boom extends again once clear, m/s (default 4). */
returnSpeed?: number;
/** Delay before it starts extending, s (default 0.15). */
returnDelay?: number;
/** 5 (default) sweeps a cylinder; 1 casts the axis only. */
rays?: 1 | 5;
/** Drop to first person when squeezed under `minDistance` (default true). */
firstPerson?: boolean;
/** Geometry to test; defaults to the controller's `raycast` hook. */
probe?:
| ((origin: THREE.Vector3, direction: THREE.Vector3, maxDistance: number) => RaycastHit | null)
| null;
}
/**
* How far the camera may be turned — `ControllerOptions.cameraLimits`, or
* `controller.setCameraLimits()` at runtime. Angles in **radians**.
*
* Ranges bound the camera however it is moved: mouse, touch drag, and the
* SDK's own camera writes (`setFacing`, a climb) all end up inside them. The
* locks stop only *input*, leaving the game free to place the camera itself.
*/
export interface CameraLimitOptions {
/**
* Yaw range the camera may look within — pass **both** ends, in the
* convention of `cameraRig.yaw`: 0 looks along −Z, increasing turns left.
* `null` clears the limit, and so does a range a full turn or wider; one
* end alone is ignored, having nothing to enforce on a circle.
*
* Movement is camera-relative and the body faces the camera while strafing
* or in first person, so a narrow window also narrows where the character
* can walk and face — usually the point, but not a view-only setting.
*/
minYaw?: number | null;
maxYaw?: number | null;
/** Lowest the camera may look, radians (default −1.2 ≈ 69° down). */
minPitch?: number;
/** Highest the camera may look, radians (default 1.35 ≈ 77° up). */
maxPitch?: number;
/**
* Ignore horizontal look input. Unlike a zero-width yaw range this pins
* nothing — the camera keeps the yaw it has and the game may still turn it.
*/
lockYaw?: boolean;
/** Ignore vertical look input. */
lockPitch?: boolean;
}
export interface CameraRigOptions {
domElement: HTMLElement;
mode?: CameraMode;
distance?: number;
minDistance?: number;
maxDistance?: number;
height?: number;
shoulderOffset?: number;
eyeHeight?: number;
sensitivity?: number;
smoothing?: number;
minPitch?: number;
maxPitch?: number;
minYaw?: number | null;
maxYaw?: number | null;
lockYaw?: boolean;
lockPitch?: boolean;
invertX?: boolean;
invertY?: boolean;
collision?: boolean | CameraCollisionOptions;
}
export class CameraRig {
readonly camera: THREE.PerspectiveCamera;
mode: CameraMode;
/**
* Camera heading, radians: 0 looks along −Z, increasing turns left. The
* body faces this + π. Assignments are clamped into `[minYaw, maxYaw]`.
*/
yaw: number;
/** Camera elevation, radians (negative looks down). Clamped to the pitch range. */
pitch: number;
distance: number;
height: number;
shoulderOffset: number;
eyeHeight: number;
sensitivity: number;
smoothing: number;
minDistance: number;
maxDistance: number;
minPitch: number;
maxPitch: number;
/** Yaw window, `null` for unlimited — see `setLimits`. */
minYaw: number | null;
maxYaw: number | null;
lockYaw: boolean;
lockPitch: boolean;
invertX: boolean;
invertY: boolean;
/** Resolved avoidance settings; mutate `.enabled` to toggle at runtime. */
readonly collision: Required> & {
probe:
| ((origin: THREE.Vector3, direction: THREE.Vector3, maxDistance: number) => RaycastHit | null)
| null;
};
/**
* Objects allowed to stand between the character and the camera without
* the boom pulling in — the player hides behind them. **Empty by
* default**: nothing occludes until the game `add()`s a mesh (or a group,
* covering its descendants). Even a listed object is avoided while the
* camera would sit inside it, so keep listed meshes closed and
* consistently wound. Everything not listed pulls the boom in as always.
*/
readonly allowedOccluders: Set;
constructor(camera: THREE.PerspectiveCamera, options: CameraRigOptions);
follow(target: THREE.Object3D): void;
setMode(mode: CameraMode): void;
toggleMode(): CameraMode;
onModeChange(cb: (mode: CameraMode) => void): () => void;
getFlatForward(out?: THREE.Vector3): THREE.Vector3;
/**
* Restrict (or release) the look range. Only the keys present are touched,
* so `{ lockPitch: true }` leaves a yaw window alone and
* `{ minYaw: null, maxYaw: null }` clears one. A camera already outside a
* new range is brought to its nearest edge immediately.
*/
setLimits(limits: CameraLimitOptions): void;
/** The limits as `setLimits` would take them — for copying between rigs. */
getLimits(): Required;
/** Take the camera away from the player on both axes, or hand it back. */
setLookLocked(locked: boolean): void;
/** True while neither axis accepts look input. */
isLookLocked(): boolean;
/**
* Turn the camera by a look delta in radians — the path every input device
* takes. Applies inversion, drops a locked axis, and respects the limits, so
* a custom control scheme gets all three for free.
*/
applyLookDelta(yawDelta: number, pitchDelta: number): void;
setCollisionProbe(
probe: ((origin: THREE.Vector3, direction: THREE.Vector3, maxDistance: number) => RaycastHit | null) | null
): void;
/** True while first person is avoidance, not the player's choice. */
isAutoFirstPerson(): boolean;
adoptCollisionState(from: CameraRig): void;
update(dt: number): void;
dispose(): void;
}
// -------------------------------------------------------------------------
// Touch controls
// -------------------------------------------------------------------------
/** The on-screen buttons `TouchControls` can draw. */
export type TouchButton = 'jump' | 'sprint' | 'use' | 'throw' | 'crouch' | 'camera';
export interface TouchControlsOptions {
/**
* Element the overlay is appended to. Defaults to the renderer element's
* parent, else `document.body`.
*/
container?: HTMLElement;
/**
* `'fixed'` (default) anchors the stick bottom-left; `'dynamic'` makes it
* appear wherever the thumb lands on the left half and vanish on release.
*/
joystick?: 'fixed' | 'dynamic';
/** Radians of camera turn per pixel of look-drag (default 0.006). */
lookSensitivity?: number;
/** Fraction of the stick radius that counts as centred (default 0.15). */
deadzone?: number;
/**
* Per-button visibility, e.g. `{ crouch: false }`. Everything is on by
* default; `use` and `throw` also show only while a hand item is held.
*/
buttons?: Partial>;
/** Stack order of the overlay (default 10). Put the game's own UI above it. */
zIndex?: number;
}
/**
* On-screen touch controls: a virtual joystick (left), drag-to-look with
* pinch-to-zoom (pinching past minimum distance enters first person, like
* the mouse wheel), and semi-transparent action buttons (right). Everything
* is tracked per pointer, so stick + look + a button work simultaneously.
*
* Built automatically by `createController` on coarse-pointer devices — see
* `ControllerOptions.touch`. A thin skin over the controller's public input
* API; restyle it via the `.pa-touch*` CSS classes.
*/
export class TouchControls {
constructor(controller: CharacterController, options?: TouchControlsOptions);
/** Overlay root — style it, move it, or `hide()` it. */
readonly element: HTMLDivElement;
/**
* A tap on the empty look area (down and up without dragging): screen
* coordinates, for the game's own tap-to-interact raycasts. Camera drags,
* pinches and button presses never fire it.
*/
onTap: ((x: number, y: number) => void) | null;
show(): void;
hide(): void;
setVisible(visible: boolean): void;
dispose(): void;
}
// -------------------------------------------------------------------------
// Materials + helpers
// -------------------------------------------------------------------------
export interface EyeTextureUrls {
/** Eye albedo (Unity `_MainTex`). */
diffuse: string;
/** Region mask (Unity `_MetallicGlossMap`): G = iris, R = lids. */
mask: string;
}
export function createGuardianEyeMaterial(
textures: { diffuse: THREE.Texture; mask: THREE.Texture },
options?: {
irisColor?: THREE.ColorRepresentation;
lidsColor?: THREE.ColorRepresentation;
tint?: THREE.ColorRepresentation;
}
): THREE.MeshStandardMaterial;
export function loadEyeTextures(
urls: EyeTextureUrls
): Promise<{ diffuse: THREE.Texture; mask: THREE.Texture }>;
export function setIrisColor(
material: THREE.Material,
color: THREE.ColorRepresentation
): boolean;
/** Rebuild halves left unbuilt by an unapplied Blender mirror modifier. */
export function completeMirroredMeshes(root: THREE.Object3D): void;
// -------------------------------------------------------------------------
// Customisation
// -------------------------------------------------------------------------
export interface AvatarSetupOption {
id: string;
label: string;
/** Present for colour options, so a UI can render a swatch. */
color?: string;
}
export interface AvatarSetupSection {
key: 'bodyType' | 'skin' | 'hairColor' | 'eyes' | 'hairStyle' | 'facialHair';
label: string;
kind: 'swatches' | 'list';
options: AvatarSetupOption[];
selected: string | null;
}
export interface AvatarSetupControllerOptions {
avatars: PortalsAvatars;
/**
* A body-type switch reloads the model and disposes the old avatar with
* its controller — rebuild anything bound to it here.
*/
onAvatarReplaced?: (avatar: GuardianAvatar) => void;
onChange?: (config: GuardianConfig, avatar: GuardianAvatar) => void;
}
/** Headless customisation model — build your own UI on it. */
export class AvatarSetupController {
constructor(avatar: GuardianAvatar, options: AvatarSetupControllerOptions);
readonly avatar: GuardianAvatar;
/** True while a body-type reload is in flight; disable inputs meanwhile. */
readonly loading: boolean;
subscribe(listener: () => void): () => void;
/** Sections filtered to what the loaded model actually supports. */
getSections(): AvatarSetupSection[];
getWearables(): Array<{ def: WearableDef; equipped: boolean; basic: boolean }>;
/** `null` clears the option (bald / clean-shaven). */
select(key: AvatarSetupSection['key'], id: string | null): Promise;
toggleWearable(id: string): Promise;
setBodyType(bodyType: BodyType): Promise;
}
export interface SetupPanelOptions extends AvatarSetupControllerOptions {
/** Defaults to `document.body`. */
container?: HTMLElement;
/** Start open. Default false — the edge button opens it. */
open?: boolean;
/** Show the small edge toggle. Default true. */
toggleButton?: boolean;
/** Default 'left'. */
side?: 'left' | 'right';
title?: string;
}
export interface SetupPanel {
readonly controller: AvatarSetupController;
readonly element: HTMLElement;
isOpen(): boolean;
open(): void;
close(): void;
toggle(): boolean;
destroy(): void;
}
/** Prefer `PortalsAvatars.createSetupPanel()`, which supplies `avatars`. */
export function createSetupPanel(
avatar: GuardianAvatar,
options: SetupPanelOptions
): SetupPanel;
export interface ParsedPortalsAvatar {
bodyType: BodyType;
modelUrl: string;
config: GuardianConfig;
/** Wearable GLB URLs keyed by slot. */
wearables: Partial>;
/** Ordered entries preserve repeated non-exclusive accessories. */
wearableEntries: Array<[WearableSlot, string]>;
}
export function parsePortalsAvatarUrl(url: string): ParsedPortalsAvatar;
export function buildPortalsAvatarUrl(avatar: GuardianAvatar): string;
}