Files
2026-05-06 08:17:32 +08:00

122 lines
4.8 KiB
TypeScript

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<ILayoutDelta> | 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<Partial<ILayoutDelta> | 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);
}
}