122 lines
4.8 KiB
TypeScript
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);
|
|
}
|
|
}
|