feat: unify life-sim play controls
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
/** Device-neutral, multi-source play input state. */
|
||||
|
||||
export type PlayDigitalControl =
|
||||
| "forward"
|
||||
| "backward"
|
||||
| "left"
|
||||
| "right"
|
||||
| "ascend"
|
||||
| "descend"
|
||||
| "pitch-up"
|
||||
| "pitch-down"
|
||||
| "primary"
|
||||
| "secondary";
|
||||
|
||||
export type PlayEdgeControl = "assist" | "reset" | "camera";
|
||||
|
||||
export interface PlayAxes {
|
||||
moveX: number;
|
||||
moveY: number;
|
||||
lookX: number;
|
||||
lookY: number;
|
||||
throttle: number;
|
||||
brake: number;
|
||||
}
|
||||
|
||||
export interface PlayInputSnapshot extends PlayAxes {
|
||||
ascend: number;
|
||||
descend: number;
|
||||
primary: boolean;
|
||||
secondary: boolean;
|
||||
}
|
||||
|
||||
export const NEUTRAL_PLAY_INPUT: Readonly<PlayInputSnapshot> = Object.freeze({
|
||||
moveX: 0,
|
||||
moveY: 0,
|
||||
lookX: 0,
|
||||
lookY: 0,
|
||||
throttle: 0,
|
||||
brake: 0,
|
||||
ascend: 0,
|
||||
descend: 0,
|
||||
primary: false,
|
||||
secondary: false,
|
||||
});
|
||||
|
||||
interface SourceState {
|
||||
digital: Set<PlayDigitalControl>;
|
||||
axes: PlayAxes;
|
||||
}
|
||||
|
||||
function clamp(value: number, min = -1, max = 1): number {
|
||||
return Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : 0;
|
||||
}
|
||||
|
||||
function neutralAxes(): PlayAxes {
|
||||
return { moveX: 0, moveY: 0, lookX: 0, lookY: 0, throttle: 0, brake: 0 };
|
||||
}
|
||||
|
||||
function strongest(current: number, candidate: number): number {
|
||||
return Math.abs(candidate) > Math.abs(current) ? candidate : current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input sources never share held state. A pointer release therefore cannot
|
||||
* cancel a keyboard key or another finger that is still down.
|
||||
*/
|
||||
export class PlayInputRouter {
|
||||
private readonly sources = new Map<string, SourceState>();
|
||||
private readonly edges = new Set<PlayEdgeControl>();
|
||||
|
||||
private source(id: string): SourceState {
|
||||
const existing = this.sources.get(id);
|
||||
if (existing) return existing;
|
||||
const created = { digital: new Set<PlayDigitalControl>(), axes: neutralAxes() };
|
||||
this.sources.set(id, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
setDigital(sourceId: string, control: PlayDigitalControl, pressed: boolean): void {
|
||||
const source = this.source(sourceId);
|
||||
if (pressed) source.digital.add(control);
|
||||
else source.digital.delete(control);
|
||||
this.dropEmpty(sourceId, source);
|
||||
}
|
||||
|
||||
setAxes(sourceId: string, axes: Partial<PlayAxes>): void {
|
||||
const source = this.source(sourceId);
|
||||
source.axes = {
|
||||
moveX: clamp(axes.moveX ?? 0),
|
||||
moveY: clamp(axes.moveY ?? 0),
|
||||
lookX: clamp(axes.lookX ?? 0),
|
||||
lookY: clamp(axes.lookY ?? 0),
|
||||
throttle: clamp(axes.throttle ?? 0, 0, 1),
|
||||
brake: clamp(axes.brake ?? 0, 0, 1),
|
||||
};
|
||||
this.dropEmpty(sourceId, source);
|
||||
}
|
||||
|
||||
request(control: PlayEdgeControl): void {
|
||||
this.edges.add(control);
|
||||
}
|
||||
|
||||
consumeRequests(): ReadonlySet<PlayEdgeControl> {
|
||||
const result = new Set(this.edges);
|
||||
this.edges.clear();
|
||||
return result;
|
||||
}
|
||||
|
||||
clearSource(sourceId: string): void {
|
||||
this.sources.delete(sourceId);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.sources.clear();
|
||||
this.edges.clear();
|
||||
}
|
||||
|
||||
snapshot(): PlayInputSnapshot {
|
||||
let moveX = 0;
|
||||
let moveY = 0;
|
||||
let lookX = 0;
|
||||
let lookY = 0;
|
||||
let throttle = 0;
|
||||
let brake = 0;
|
||||
let ascend = 0;
|
||||
let descend = 0;
|
||||
let primary = false;
|
||||
let secondary = false;
|
||||
for (const source of this.sources.values()) {
|
||||
const horizontal = (source.digital.has("right") ? 1 : 0) -
|
||||
(source.digital.has("left") ? 1 : 0);
|
||||
const vertical = (source.digital.has("forward") ? 1 : 0) -
|
||||
(source.digital.has("backward") ? 1 : 0);
|
||||
const pitch = (source.digital.has("pitch-up") ? 1 : 0) -
|
||||
(source.digital.has("pitch-down") ? 1 : 0);
|
||||
moveX = strongest(moveX, strongest(source.axes.moveX, horizontal));
|
||||
moveY = strongest(moveY, strongest(source.axes.moveY, vertical));
|
||||
lookX = strongest(lookX, source.axes.lookX);
|
||||
lookY = strongest(lookY, strongest(source.axes.lookY, pitch));
|
||||
throttle = Math.max(throttle, source.axes.throttle);
|
||||
brake = Math.max(brake, source.axes.brake);
|
||||
ascend = Math.max(ascend, source.digital.has("ascend") ? 1 : 0);
|
||||
descend = Math.max(descend, source.digital.has("descend") ? 1 : 0);
|
||||
primary ||= source.digital.has("primary");
|
||||
secondary ||= source.digital.has("secondary");
|
||||
}
|
||||
return {
|
||||
moveX,
|
||||
moveY,
|
||||
lookX,
|
||||
lookY,
|
||||
throttle,
|
||||
brake,
|
||||
ascend,
|
||||
descend,
|
||||
primary,
|
||||
secondary,
|
||||
};
|
||||
}
|
||||
|
||||
activeSourceCount(): number {
|
||||
return this.sources.size;
|
||||
}
|
||||
|
||||
private dropEmpty(id: string, source: SourceState): void {
|
||||
const axes = source.axes;
|
||||
if (
|
||||
source.digital.size === 0 && axes.moveX === 0 && axes.moveY === 0 &&
|
||||
axes.lookX === 0 && axes.lookY === 0 && axes.throttle === 0 && axes.brake === 0
|
||||
) this.sources.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export interface StandardPlayGamepadLike {
|
||||
axes: readonly number[];
|
||||
buttons: readonly { pressed: boolean; value: number }[];
|
||||
}
|
||||
|
||||
export interface StandardPlayGamepadButtons {
|
||||
assist: boolean;
|
||||
reset: boolean;
|
||||
camera: boolean;
|
||||
}
|
||||
|
||||
export interface StandardPlayGamepadSample {
|
||||
axes: PlayAxes;
|
||||
digital: ReadonlySet<PlayDigitalControl>;
|
||||
requests: ReadonlySet<PlayEdgeControl>;
|
||||
buttons: StandardPlayGamepadButtons;
|
||||
}
|
||||
|
||||
function axis(value: number | undefined, deadzone = 0.12): number {
|
||||
const raw = clamp(value ?? 0);
|
||||
if (Math.abs(raw) <= deadzone) return 0;
|
||||
return Math.sign(raw) * ((Math.abs(raw) - deadzone) / (1 - deadzone));
|
||||
}
|
||||
|
||||
function button(pad: StandardPlayGamepadLike, index: number): number {
|
||||
const found = pad.buttons[index];
|
||||
if (!found) return 0;
|
||||
return clamp(Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0, 0, 1);
|
||||
}
|
||||
|
||||
/** Standard layout: two sticks, triggers, shoulders and rising-edge face buttons. */
|
||||
export function sampleStandardPlayGamepad(
|
||||
pad: StandardPlayGamepadLike,
|
||||
previous: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false },
|
||||
): StandardPlayGamepadSample {
|
||||
const buttons = {
|
||||
assist: button(pad, 3) > 0.5,
|
||||
reset: button(pad, 2) > 0.5,
|
||||
camera: button(pad, 9) > 0.5,
|
||||
};
|
||||
const digital = new Set<PlayDigitalControl>();
|
||||
if (button(pad, 5) > 0.5) digital.add("ascend");
|
||||
if (button(pad, 4) > 0.5) digital.add("descend");
|
||||
if (button(pad, 1) > 0.5) digital.add("primary");
|
||||
if (button(pad, 0) > 0.5) digital.add("secondary");
|
||||
const requests = new Set<PlayEdgeControl>();
|
||||
if (buttons.assist && !previous.assist) requests.add("assist");
|
||||
if (buttons.reset && !previous.reset) requests.add("reset");
|
||||
if (buttons.camera && !previous.camera) requests.add("camera");
|
||||
return {
|
||||
axes: {
|
||||
moveX: axis(pad.axes[0]),
|
||||
moveY: -axis(pad.axes[1]),
|
||||
lookX: axis(pad.axes[2]),
|
||||
lookY: -axis(pad.axes[3]),
|
||||
brake: button(pad, 6),
|
||||
throttle: button(pad, 7),
|
||||
},
|
||||
digital,
|
||||
requests,
|
||||
buttons,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanarDirection {
|
||||
x: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/** Camera-relative input without importing a renderer or allocating vectors. */
|
||||
export function cameraRelativePlanar(
|
||||
input: Pick<PlayInputSnapshot, "moveX" | "moveY">,
|
||||
cameraForward: Readonly<PlanarDirection>,
|
||||
): PlanarDirection {
|
||||
const length = Math.hypot(cameraForward.x, cameraForward.z);
|
||||
const fx = length > 1e-8 ? cameraForward.x / length : 0;
|
||||
const fz = length > 1e-8 ? cameraForward.z / length : -1;
|
||||
const rx = -fz;
|
||||
const rz = fx;
|
||||
const x = rx * input.moveX + fx * input.moveY;
|
||||
const z = rz * input.moveX + fz * input.moveY;
|
||||
const magnitude = Math.hypot(x, z);
|
||||
return magnitude > 1 ? { x: x / magnitude, z: z / magnitude } : { x, z };
|
||||
}
|
||||
|
||||
export function vehicleActionsFromPlay(
|
||||
input: Readonly<PlayInputSnapshot>,
|
||||
): {
|
||||
throttle: number;
|
||||
brake: number;
|
||||
steering: number;
|
||||
handbrake: boolean;
|
||||
} {
|
||||
return {
|
||||
throttle: Math.max(input.throttle, input.moveY > 0 ? input.moveY : 0),
|
||||
brake: Math.max(input.brake, input.moveY < 0 ? -input.moveY : 0),
|
||||
steering: input.moveX,
|
||||
handbrake: input.primary,
|
||||
};
|
||||
}
|
||||
|
||||
export function aircraftActionsFromPlay(
|
||||
input: Readonly<PlayInputSnapshot>,
|
||||
): { throttle: number; yaw: number; pitch: number; roll: number } {
|
||||
return {
|
||||
throttle: Math.max(input.throttle, input.primary ? 1 : 0),
|
||||
yaw: clamp(input.ascend - input.descend + input.lookX),
|
||||
pitch: input.moveY !== 0 ? input.moveY : input.lookY,
|
||||
roll: input.moveX,
|
||||
};
|
||||
}
|
||||
|
||||
export function crowActionsFromPlay(
|
||||
input: Readonly<PlayInputSnapshot>,
|
||||
): {
|
||||
forward: number;
|
||||
right: number;
|
||||
turn: number;
|
||||
pitch: number;
|
||||
climb: number;
|
||||
sprint: boolean;
|
||||
glide: boolean;
|
||||
} {
|
||||
return {
|
||||
forward: input.moveY,
|
||||
right: 0,
|
||||
turn: strongest(input.moveX, input.lookX),
|
||||
pitch: input.lookY,
|
||||
climb: clamp(input.ascend - input.descend),
|
||||
sprint: false,
|
||||
glide: input.secondary,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a desired camera-relative world direction into the actor's local axes. */
|
||||
export function groundActorActionsFromPlay(
|
||||
input: Readonly<PlayInputSnapshot>,
|
||||
yaw: number,
|
||||
cameraForward: Readonly<PlanarDirection>,
|
||||
): {
|
||||
forward: number;
|
||||
right: number;
|
||||
turn: number;
|
||||
sprint: boolean;
|
||||
} {
|
||||
const desired = cameraRelativePlanar(input, cameraForward);
|
||||
const cos = Math.cos(yaw);
|
||||
const sin = Math.sin(yaw);
|
||||
return {
|
||||
right: desired.x * cos - desired.z * sin,
|
||||
forward: -desired.x * sin - desired.z * cos,
|
||||
turn: input.lookX,
|
||||
sprint: input.primary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface PointerStickAxes {
|
||||
moveX: number;
|
||||
moveY: number;
|
||||
}
|
||||
|
||||
export interface PointerStickBounds {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One pointer owns one analogue stick gesture. Other fingers may press action
|
||||
* buttons without stealing or releasing it; cancellation always returns zero.
|
||||
*/
|
||||
export class PointerStick {
|
||||
private pointerId: number | null = null;
|
||||
private bounds: PointerStickBounds | null = null;
|
||||
|
||||
begin(pointerId: number, clientX: number, clientY: number, bounds: PointerStickBounds): PointerStickAxes | null {
|
||||
if (this.pointerId !== null || !validBounds(bounds)) return null;
|
||||
this.pointerId = pointerId;
|
||||
this.bounds = { ...bounds };
|
||||
return this.sample(clientX, clientY);
|
||||
}
|
||||
|
||||
move(pointerId: number, clientX: number, clientY: number): PointerStickAxes | null {
|
||||
if (pointerId !== this.pointerId || !this.bounds) return null;
|
||||
return this.sample(clientX, clientY);
|
||||
}
|
||||
|
||||
end(pointerId: number): boolean {
|
||||
if (pointerId !== this.pointerId) return false;
|
||||
this.pointerId = null;
|
||||
this.bounds = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
cancel(pointerId: number): boolean {
|
||||
return this.end(pointerId);
|
||||
}
|
||||
|
||||
activePointer(): number | null {
|
||||
return this.pointerId;
|
||||
}
|
||||
|
||||
private sample(clientX: number, clientY: number): PointerStickAxes {
|
||||
const bounds = this.bounds;
|
||||
if (!bounds || !Number.isFinite(clientX) || !Number.isFinite(clientY)) {
|
||||
return { moveX: 0, moveY: 0 };
|
||||
}
|
||||
const radius = Math.max(1, Math.min(bounds.width, bounds.height) / 2);
|
||||
let x = (clientX - (bounds.left + bounds.width / 2)) / radius;
|
||||
let y = (clientY - (bounds.top + bounds.height / 2)) / radius;
|
||||
const length = Math.hypot(x, y);
|
||||
if (length > 1) {
|
||||
x /= length;
|
||||
y /= length;
|
||||
}
|
||||
return { moveX: x === 0 ? 0 : x, moveY: y === 0 ? 0 : -y };
|
||||
}
|
||||
}
|
||||
|
||||
function validBounds(bounds: PointerStickBounds): boolean {
|
||||
return Number.isFinite(bounds.left) && Number.isFinite(bounds.top) &&
|
||||
Number.isFinite(bounds.width) && Number.isFinite(bounds.height) &&
|
||||
bounds.width > 0 && bounds.height > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user