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

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
};
}