export const ACTION_LAYERS = ["primary", "secondary", "tertiary"] as const; export type ActionBindingLayer = typeof ACTION_LAYERS[number]; export const ACTION_CONTROLS = [ "face-top", "face-right", "face-bottom", "face-left", "r1", "r2", "dpad-left", "dpad-right", ] as const; export type ActionBindingControl = typeof ACTION_CONTROLS[number]; export type ActionBindingId = `${ActionBindingLayer}:${ActionBindingControl}`; export type ActionBindings = Readonly>; export interface ActionBindingControlDefinition { readonly id: ActionBindingControl; readonly label: string; readonly gamepadLabel: string; readonly keyboardLabel: string; readonly gamepadToken: string; readonly keyboardCode: string; } export const ACTION_LAYER_LABELS: Readonly> = Object.freeze({ primary: "Base", secondary: "L1", tertiary: "L2", }); export const ACTION_CONTROL_DEFINITIONS: Readonly> = Object.freeze({ "face-top": { id: "face-top", label: "Triangle", gamepadLabel: "△", keyboardLabel: "3", gamepadToken: "Button3", keyboardCode: "Digit3", }, "face-right": { id: "face-right", label: "Circle", gamepadLabel: "○", keyboardLabel: "4", gamepadToken: "Button1", keyboardCode: "Digit4", }, "face-bottom": { id: "face-bottom", label: "Cross", gamepadLabel: "×", keyboardLabel: "1", gamepadToken: "Button0", keyboardCode: "Digit1", }, "face-left": { id: "face-left", label: "Square", gamepadLabel: "□", keyboardLabel: "2", gamepadToken: "Button2", keyboardCode: "Digit2", }, r1: { id: "r1", label: "R1", gamepadLabel: "R1", keyboardLabel: "5", gamepadToken: "Button5", keyboardCode: "Digit5", }, r2: { id: "r2", label: "R2", gamepadLabel: "R2", keyboardLabel: "6", gamepadToken: "Button7", keyboardCode: "Digit6", }, "dpad-left": { id: "dpad-left", label: "D-pad Left", gamepadLabel: "←", keyboardLabel: "7", gamepadToken: "Button14", keyboardCode: "Digit7", }, "dpad-right": { id: "dpad-right", label: "D-pad Right", gamepadLabel: "→", keyboardLabel: "8", gamepadToken: "Button15", keyboardCode: "Digit8", }, }); // Preserve the prototype's existing Cross, Square, Triangle progression for // the first three abilities, then finish the face diamond before filling the // remaining base and modifier slots. const DEFAULT_ASSIGNMENT_CONTROLS: readonly ActionBindingControl[] = [ "face-bottom", "face-left", "face-top", "face-right", "r1", "r2", "dpad-left", "dpad-right", ]; export function actionBindingId( layer: ActionBindingLayer, control: ActionBindingControl, ): ActionBindingId { return `${layer}:${control}`; } export function emptyActionBindings(): Record { return Object.fromEntries(ACTION_LAYERS.flatMap((layer) => ACTION_CONTROLS.map((control) => [ actionBindingId(layer, control), null, ]))) as Record; } export function defaultActionBindings(abilityIds: readonly string[]): ActionBindings { const bindings = emptyActionBindings(); const assignmentOrder = ACTION_LAYERS.flatMap((layer) => DEFAULT_ASSIGNMENT_CONTROLS.map((control) => ( actionBindingId(layer, control) ))); abilityIds.slice(0, assignmentOrder.length).forEach((abilityId, index) => { bindings[assignmentOrder[index]] = abilityId; }); return bindings; } export function normalizeActionBindings( value: unknown, defaultAbilityIds: readonly string[], allowedAbilityIds: readonly string[] = defaultAbilityIds, ): ActionBindings { const defaults = defaultActionBindings(defaultAbilityIds); if (!value || typeof value !== "object" || Array.isArray(value)) return defaults; const source = value as Record; const allowed = new Set(allowedAbilityIds); const bindings = { ...defaults }; for (const layer of ACTION_LAYERS) { for (const control of ACTION_CONTROLS) { const id = actionBindingId(layer, control); if (!Object.prototype.hasOwnProperty.call(source, id)) continue; const abilityId = source[id]; bindings[id] = abilityId === null ? null : typeof abilityId === "string" && allowed.has(abilityId) ? abilityId : null; } } return bindings; } export function withActionBinding( bindings: ActionBindings, layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null, ): ActionBindings { return { ...bindings, [actionBindingId(layer, control)]: abilityId }; } export function resetActionBindingLayer( bindings: ActionBindings, layer: ActionBindingLayer, abilityIds: readonly string[], ): ActionBindings { const defaults = defaultActionBindings(abilityIds); const next = { ...bindings }; for (const control of ACTION_CONTROLS) { const id = actionBindingId(layer, control); next[id] = defaults[id]; } return next; } export function actionLayerForModifiers(l1: boolean, l2: boolean): ActionBindingLayer { if (l2) return "tertiary"; if (l1) return "secondary"; return "primary"; } export function actionControlForControllerToken(token: string): ActionBindingControl | null { return ACTION_CONTROLS.find((control) => ACTION_CONTROL_DEFINITIONS[control].gamepadToken === token) ?? null; } export function actionControlForKeyboardCode(code: string): ActionBindingControl | null { return ACTION_CONTROLS.find((control) => ACTION_CONTROL_DEFINITIONS[control].keyboardCode === code) ?? null; } export function actionBindingChordLabel( layer: ActionBindingLayer, control: ActionBindingControl, inputMode: "keyboard" | "gamepad" = "gamepad", ): string { const definition = ACTION_CONTROL_DEFINITIONS[control]; if (inputMode === "keyboard") { const modifier = layer === "secondary" ? "Shift + " : layer === "tertiary" ? "Alt + " : ""; return `${modifier}${definition.keyboardLabel}`; } const modifier = layer === "secondary" ? "L1 + " : layer === "tertiary" ? "L2 + " : ""; return `${modifier}${definition.label}`; }