import { DEFAULT_LAYOUT, IFloatingLayout, IHitRect } from './InputModel'; import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; import { STORAGE_KEY } from '../common/Constants'; /** * Persisted representation of the user's custom control layout. * * We intentionally persist **deltas** on top of the design-baseline * `DEFAULT_LAYOUT` rather than absolute positions: * * - Keeps old save data forward-compatible when we retune the baseline. * - Keeps the stored blob under ~100 bytes (well within the 17.x budget). * * Requirement traceability: * - req 1.6 — long-press customisation mode stores this payload. * - req 17.2 — persists across sessions. * - req 17.6 — any parse failure must fall back to the default, not crash. */ export interface ILayoutDelta { /** Offset applied on top of the default rect, in landscape design px. */ joystickOffset: { dx: number; dy: number }; jumpOffset: { dx: number; dy: number }; shurikenOffset: { dx: number; dy: number }; ninjaSwordOffset: { dx: number; dy: number }; /** Multipliers applied to default `w`/`h`. Clamped to 0.7 — 1.4. */ buttonSizeScale: number; /** UI opacity 0.3 — 1.0 (req 1.1 default 0.7). */ opacity: number; } export const DEFAULT_LAYOUT_DELTA: ILayoutDelta = { joystickOffset: { dx: 0, dy: 0 }, jumpOffset: { dx: 0, dy: 0 }, shurikenOffset: { dx: 0, dy: 0 }, ninjaSwordOffset: { dx: 0, dy: 0 }, buttonSizeScale: 1.0, opacity: 0.7, }; /** Numeric clamps enforced on any delta the user (or stale storage) gives us. */ export const LAYOUT_DELTA_BOUNDS = { offsetPxMax: 240, sizeScaleMin: 0.7, sizeScaleMax: 1.4, opacityMin: 0.3, opacityMax: 1.0, } as const; /** Clamp + sanitise a raw delta object received from storage. */ export function sanitiseLayoutDelta(raw: Partial | null | undefined): ILayoutDelta { if (!raw || typeof raw !== 'object') { return { ...DEFAULT_LAYOUT_DELTA }; } const clamp = (v: number, lo: number, hi: number): number => { if (typeof v !== 'number' || Number.isNaN(v)) return (lo + hi) / 2; return v < lo ? lo : v > hi ? hi : v; }; const clampOffset = (o?: { dx?: number; dy?: number }) => ({ dx: clamp(o?.dx ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax), dy: clamp(o?.dy ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax), }); return { joystickOffset: clampOffset(raw.joystickOffset), jumpOffset: clampOffset(raw.jumpOffset), shurikenOffset: clampOffset(raw.shurikenOffset), ninjaSwordOffset: clampOffset(raw.ninjaSwordOffset), buttonSizeScale: clamp( raw.buttonSizeScale ?? 1, LAYOUT_DELTA_BOUNDS.sizeScaleMin, LAYOUT_DELTA_BOUNDS.sizeScaleMax ), opacity: clamp(raw.opacity ?? 0.7, LAYOUT_DELTA_BOUNDS.opacityMin, LAYOUT_DELTA_BOUNDS.opacityMax), }; } /** Apply a sanitised delta on top of the baseline default layout. */ export function applyLayoutDelta(baseline: IFloatingLayout, delta: ILayoutDelta): IFloatingLayout { const offsetRect = (r: IHitRect, off: { dx: number; dy: number }): IHitRect => ({ cx: r.cx + off.dx, cy: r.cy + off.dy, w: r.w * delta.buttonSizeScale, h: r.h * delta.buttonSizeScale, }); return { joystick: offsetRect(baseline.joystick, delta.joystickOffset), jump: offsetRect(baseline.jump, delta.jumpOffset), shuriken: offsetRect(baseline.shuriken, delta.shurikenOffset), ninjaSword: offsetRect(baseline.ninjaSword, delta.ninjaSwordOffset), joystickDeadzone: baseline.joystickDeadzone, opacity: delta.opacity, }; } /** * Thin adapter over `StorageMgr` that handles the `kl_control_layout` key. * The adapter always produces a valid `IFloatingLayout` — even when the * underlying storage is corrupted (req 17.6). */ export class LayoutCustomizer { constructor( private readonly baseline: IFloatingLayout = DEFAULT_LAYOUT, private readonly storage: StorageMgr = globalStorageMgr ) {} /** Load the saved delta (or default) and produce the concrete layout. */ public loadLayout(): { layout: IFloatingLayout; delta: ILayoutDelta } { const raw = this.storage.get | null>(STORAGE_KEY.ControlLayout, null); const delta = sanitiseLayoutDelta(raw); return { layout: applyLayoutDelta(this.baseline, delta), delta }; } /** Persist the given delta after sanitising it. */ public saveDelta(delta: ILayoutDelta): void { this.storage.set(STORAGE_KEY.ControlLayout, sanitiseLayoutDelta(delta)); } /** Reset the layout back to the landscape default. */ public reset(): void { this.storage.remove(STORAGE_KEY.ControlLayout); } }