You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
145 lines
5.2 KiB
145 lines
5.2 KiB
import type { AvatarState } from '../state.js';
|
|
|
|
/**
|
|
* Recursively-partial variant of a config type, used by `resolveEyeConfig`'s
|
|
* overrides parameter. Arrays/tuples (e.g. a `[min, max]` range) are treated
|
|
* as atomic — overriding one means supplying the whole tuple, not patching
|
|
* one element — everything else recurses field-by-field.
|
|
*/
|
|
export type DeepPartial<T> = T extends readonly unknown[]
|
|
? T
|
|
: T extends object
|
|
? { [K in keyof T]?: DeepPartial<T[K]> }
|
|
: T;
|
|
|
|
export interface EyeMacroConfig {
|
|
/** Seconds spent at the look-at target before the next look-away, keyed by the current `AvatarState`. */
|
|
lookAtLengthMinMaxByState: Record<AvatarState, [number, number]>;
|
|
/** Seconds spent looking away before returning or chaining a secondary. */
|
|
lookAwayLengthMinMax: [number, number];
|
|
/** Seconds spent looking away while chaining a secondary glance. */
|
|
lookAwayLengthSecondaryMinMax: [number, number];
|
|
/** Chance (0-1) a secondary glance is chained once a look-away expires. */
|
|
secondaryProbability: number;
|
|
/** A secondary can only be chained while `|goal.x|` and `|goal.y|` are both within this. */
|
|
secondaryCutoff: number;
|
|
/** Max magnitude of the additional offset a secondary glance adds onto the current goal. */
|
|
secondaryMaxStrength: number;
|
|
/** Max cm offset on each axis at `goal` magnitude 1, before distance scaling. */
|
|
offsetStrengthCm: { x: number; y: number };
|
|
/** Reference distance (cm) in the `distance/reference + floor` scale formula. */
|
|
distanceScaleReferenceCm: number;
|
|
/** Minimum distance scale (reached as distance approaches 0). */
|
|
distanceScaleFloor: number;
|
|
/** Bias passed to `biasedSmoothstepSigned` when drawing the Y axis of a macro goal. */
|
|
yCurveBias: number;
|
|
}
|
|
|
|
export interface EyeMicroConfig {
|
|
/** Seconds range for the X-axis micro timer, remapped through `easeOutCubic` on redraw. */
|
|
xTimerMinMax: [number, number];
|
|
/** Seconds range for the Y-axis micro timer, remapped through `easeOutCubic` on redraw. */
|
|
yTimerMinMax: [number, number];
|
|
/** Max cm offset on each axis (goal is always -1, 0, or 1). */
|
|
offsetStrengthCm: { x: number; y: number };
|
|
}
|
|
|
|
export interface EyeNanoConfig {
|
|
/** Seconds range for the (single, unremapped) nano timer. */
|
|
timerMinMax: [number, number];
|
|
/** Max cm offset magnitude (goal direction is unit-length, so this is the only strength term). */
|
|
offsetStrengthCm: number;
|
|
}
|
|
|
|
export interface EyeConfig {
|
|
macro: EyeMacroConfig;
|
|
micro: EyeMicroConfig;
|
|
nano: EyeNanoConfig;
|
|
/** Shared exponential-decay time constant (seconds) all three layers' `current` springs toward `goal` with. */
|
|
springTimeConstantSeconds: number;
|
|
/**
|
|
* Gaze angle (radians) at which `gazeOffsetToBlendShapes` (blend-shapes.ts)
|
|
* saturates a channel to weight 1. No `lookAtDistanceCm` default lives
|
|
* here — that's always a live runtime value (see `EyeGazeInput` in
|
|
* gaze.ts), not a config constant, since it's also what Macro's distance
|
|
* scaling reads every tick.
|
|
*/
|
|
maxGazeAngleRad: number;
|
|
}
|
|
|
|
/**
|
|
* Defaults taken directly from the ported design doc's "Eye-System
|
|
* Variablen & Logik" section, except `yCurveBias` and `maxGazeAngleRad`
|
|
* (both new web-port knobs — see DESIGN_NOTES.md).
|
|
*/
|
|
export const DEFAULT_EYE_CONFIG: EyeConfig = {
|
|
macro: {
|
|
lookAtLengthMinMaxByState: {
|
|
listening: [7, 15],
|
|
thinking: [3, 10],
|
|
talking: [3, 10],
|
|
idleActive: [3, 9],
|
|
idlePassive: [2, 9]
|
|
},
|
|
lookAwayLengthMinMax: [0.3, 1.3],
|
|
lookAwayLengthSecondaryMinMax: [0.3, 0.8],
|
|
secondaryProbability: 0.5,
|
|
secondaryCutoff: 0.7,
|
|
secondaryMaxStrength: 0.2,
|
|
offsetStrengthCm: { x: 20, y: 15 },
|
|
distanceScaleReferenceCm: 300,
|
|
distanceScaleFloor: 0.66,
|
|
yCurveBias: 0.5
|
|
},
|
|
micro: {
|
|
xTimerMinMax: [0.05, 6],
|
|
yTimerMinMax: [0.05, 4],
|
|
offsetStrengthCm: { x: 3, y: 1 }
|
|
},
|
|
nano: {
|
|
timerMinMax: [0.05, 2.0],
|
|
offsetStrengthCm: 0.5
|
|
},
|
|
springTimeConstantSeconds: 0.125,
|
|
maxGazeAngleRad: (30 * Math.PI) / 180
|
|
};
|
|
|
|
/**
|
|
* Merges a project's partial overrides onto `DEFAULT_EYE_CONFIG`. Unlike
|
|
* `resolveAnimationConfig`'s flat `{...DEFAULT, ...overrides}` (every field
|
|
* of `AnimationConfig` is a scalar), `EyeConfig`'s fields are themselves
|
|
* objects, so this merges each nested section independently — a caller
|
|
* overriding only `macro.secondaryProbability` must not blank out the rest
|
|
* of `macro`, or `micro`/`nano`/the top-level scalars.
|
|
*/
|
|
export function resolveEyeConfig(overrides: DeepPartial<EyeConfig> = {}): EyeConfig {
|
|
return {
|
|
macro: {
|
|
...DEFAULT_EYE_CONFIG.macro,
|
|
...overrides.macro,
|
|
lookAtLengthMinMaxByState: {
|
|
...DEFAULT_EYE_CONFIG.macro.lookAtLengthMinMaxByState,
|
|
...overrides.macro?.lookAtLengthMinMaxByState
|
|
},
|
|
offsetStrengthCm: {
|
|
...DEFAULT_EYE_CONFIG.macro.offsetStrengthCm,
|
|
...overrides.macro?.offsetStrengthCm
|
|
}
|
|
},
|
|
micro: {
|
|
...DEFAULT_EYE_CONFIG.micro,
|
|
...overrides.micro,
|
|
offsetStrengthCm: {
|
|
...DEFAULT_EYE_CONFIG.micro.offsetStrengthCm,
|
|
...overrides.micro?.offsetStrengthCm
|
|
}
|
|
},
|
|
nano: {
|
|
...DEFAULT_EYE_CONFIG.nano,
|
|
...overrides.nano
|
|
},
|
|
springTimeConstantSeconds:
|
|
overrides.springTimeConstantSeconds ?? DEFAULT_EYE_CONFIG.springTimeConstantSeconds,
|
|
maxGazeAngleRad: overrides.maxGazeAngleRad ?? DEFAULT_EYE_CONFIG.maxGazeAngleRad
|
|
};
|
|
}
|
|
|