first commmit
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user