1
0

feat: unify life-sim play controls

This commit is contained in:
2026-08-19 03:04:53 -07:00
parent 120eac878a
commit 264daaab61
14 changed files with 1697 additions and 234 deletions
+328
View File
@@ -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,
};
}
+69
View File
@@ -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;
}