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 extends readonly unknown[] ? T : T extends object ? { [K in keyof T]?: DeepPartial } : T; export interface EyeMacroConfig { /** Seconds spent at the look-at target before the next look-away, keyed by the current `AvatarState`. */ lookAtLengthMinMaxByState: Record; /** 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 { 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 }; }