feat: unify life-sim play controls
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
cityControlOwnership,
|
||||
cityControlMode,
|
||||
createControlModeState,
|
||||
transitionControlMode,
|
||||
} from "../play/controlMode.ts";
|
||||
|
||||
const OUTSIDE = {
|
||||
insideOffice: false,
|
||||
drive: true,
|
||||
actor: true,
|
||||
aircraft: true,
|
||||
officeWalk: false,
|
||||
};
|
||||
|
||||
describe("exclusive play control mode", () => {
|
||||
it("assigns exactly one local camera writer in every city mode", () => {
|
||||
for (const mode of ["overview", "drive", "actor", "aircraft"] as const) {
|
||||
const ownership = cityControlOwnership(mode);
|
||||
assert.equal(Object.values(ownership).filter(Boolean).length, 1);
|
||||
assert.equal(ownership[mode === "overview" ? "orbit" : mode], true);
|
||||
}
|
||||
assert.equal(cityControlOwnership("drive").orbit, false);
|
||||
});
|
||||
|
||||
it("changes exactly one owner and revisions accepted transitions", () => {
|
||||
let state = createControlModeState();
|
||||
const driving = transitionControlMode(state, "drive", OUTSIDE);
|
||||
assert.equal(driving.accepted, true);
|
||||
assert.equal(driving.changed, true);
|
||||
assert.deepEqual(driving.state, { mode: "drive", revision: 1 });
|
||||
state = driving.state;
|
||||
const aircraft = transitionControlMode(state, "aircraft", OUTSIDE);
|
||||
assert.deepEqual(aircraft.state, { mode: "aircraft", revision: 2 });
|
||||
assert.equal(aircraft.previous, "drive");
|
||||
});
|
||||
|
||||
it("falls back safely when a requested subsystem is unavailable", () => {
|
||||
const unavailable = transitionControlMode(
|
||||
createControlModeState("drive"),
|
||||
"aircraft",
|
||||
{ ...OUTSIDE, aircraft: false },
|
||||
);
|
||||
assert.equal(unavailable.accepted, false);
|
||||
assert.equal(unavailable.reason, "unavailable");
|
||||
assert.equal(unavailable.state.mode, "overview");
|
||||
});
|
||||
|
||||
it("keeps office and city modes in disjoint places", () => {
|
||||
const office = { ...OUTSIDE, insideOffice: true, officeWalk: true };
|
||||
const entered = transitionControlMode(createControlModeState(), "office-walk", office);
|
||||
assert.equal(entered.state.mode, "office-walk");
|
||||
const rejected = transitionControlMode(entered.state, "drive", office);
|
||||
assert.equal(rejected.state.mode, "office-overview");
|
||||
assert.equal(cityControlMode(rejected.state.mode), "overview");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
aircraftActionsFromPlay,
|
||||
cameraRelativePlanar,
|
||||
crowActionsFromPlay,
|
||||
groundActorActionsFromPlay,
|
||||
PlayInputRouter,
|
||||
sampleStandardPlayGamepad,
|
||||
vehicleActionsFromPlay,
|
||||
} from "../input/play.ts";
|
||||
|
||||
function pad(over: { axes?: number[]; buttons?: Record<number, number> } = {}) {
|
||||
return {
|
||||
axes: over.axes ?? [0, 0, 0, 0],
|
||||
buttons: Array.from({ length: 10 }, (_, index) => ({
|
||||
pressed: (over.buttons?.[index] ?? 0) > 0.5,
|
||||
value: over.buttons?.[index] ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("multi-source play input", () => {
|
||||
it("does not let one source release another source's held control", () => {
|
||||
const router = new PlayInputRouter();
|
||||
router.setDigital("keyboard", "forward", true);
|
||||
router.setDigital("pointer:7", "forward", true);
|
||||
router.clearSource("pointer:7");
|
||||
assert.equal(router.snapshot().moveY, 1);
|
||||
router.setDigital("keyboard", "forward", false);
|
||||
assert.deepEqual(router.snapshot(), {
|
||||
moveX: 0, moveY: 0, lookX: 0, lookY: 0, throttle: 0, brake: 0,
|
||||
ascend: 0, descend: 0, primary: false, secondary: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("merges strongest analogue intent with independent digital actions", () => {
|
||||
const router = new PlayInputRouter();
|
||||
router.setAxes("gamepad", { moveX: 0.45, moveY: 0.8, throttle: 0.7 });
|
||||
router.setDigital("pointer:1", "left", true);
|
||||
router.setDigital("pointer:2", "ascend", true);
|
||||
const snapshot = router.snapshot();
|
||||
assert.equal(snapshot.moveX, -1);
|
||||
assert.equal(snapshot.moveY, 0.8);
|
||||
assert.equal(snapshot.throttle, 0.7);
|
||||
assert.equal(snapshot.ascend, 1);
|
||||
});
|
||||
|
||||
it("clears sources and one-shot requests atomically on mode changes", () => {
|
||||
const router = new PlayInputRouter();
|
||||
router.setDigital("keyboard", "primary", true);
|
||||
router.request("reset");
|
||||
assert.equal(router.consumeRequests().has("reset"), true);
|
||||
assert.equal(router.consumeRequests().size, 0);
|
||||
router.clearAll();
|
||||
assert.equal(router.activeSourceCount(), 0);
|
||||
assert.equal(router.snapshot().primary, false);
|
||||
});
|
||||
|
||||
it("samples both sticks, triggers, shoulders and rising-edge requests", () => {
|
||||
const first = sampleStandardPlayGamepad(pad({
|
||||
axes: [0.5, -0.7, -0.4, 0.6],
|
||||
buttons: { 3: 1, 2: 1, 9: 1, 4: 1, 5: 1, 7: 0.8, 6: 0.2 },
|
||||
}));
|
||||
assert.ok(first.axes.moveX > 0);
|
||||
assert.ok(first.axes.moveY > 0);
|
||||
assert.ok(first.axes.lookX < 0);
|
||||
assert.ok(first.axes.lookY < 0);
|
||||
assert.equal(first.axes.throttle, 0.8);
|
||||
assert.equal(first.axes.brake, 0.2);
|
||||
assert.equal(first.digital.has("ascend"), true);
|
||||
assert.equal(first.digital.has("descend"), true);
|
||||
assert.deepEqual([...first.requests].sort(), ["assist", "camera", "reset"]);
|
||||
assert.equal(sampleStandardPlayGamepad(pad({ buttons: { 3: 1, 2: 1, 9: 1 } }), first.buttons).requests.size, 0);
|
||||
});
|
||||
|
||||
it("maps one snapshot coherently into each deterministic controller", () => {
|
||||
const router = new PlayInputRouter();
|
||||
router.setAxes("gamepad", {
|
||||
moveX: 0.4, moveY: 0.7, lookX: -0.25, lookY: 0.6,
|
||||
throttle: 0.5, brake: 0.1,
|
||||
});
|
||||
router.setDigital("keyboard", "ascend", true);
|
||||
router.setDigital("keyboard", "primary", true);
|
||||
router.setDigital("keyboard", "secondary", true);
|
||||
const input = router.snapshot();
|
||||
assert.deepEqual(vehicleActionsFromPlay(input), {
|
||||
throttle: 0.7, brake: 0.1, steering: 0.4, handbrake: true,
|
||||
});
|
||||
assert.deepEqual(aircraftActionsFromPlay(input), {
|
||||
throttle: 1, yaw: 0.75, pitch: 0.7, roll: 0.4,
|
||||
});
|
||||
assert.deepEqual(crowActionsFromPlay(input), {
|
||||
forward: 0.7, right: 0, turn: 0.4, pitch: 0.6, climb: 1,
|
||||
sprint: false, glide: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps camera-relative walking normalized and actor-local", () => {
|
||||
const input = { moveX: 1, moveY: 1 };
|
||||
const world = cameraRelativePlanar(input, { x: 0, z: -1 });
|
||||
assert.ok(Math.abs(Math.hypot(world.x, world.z) - 1) < 1e-12);
|
||||
const actor = groundActorActionsFromPlay(
|
||||
{ moveX: 0, moveY: 1, lookX: 0, lookY: 0, throttle: 0, brake: 0,
|
||||
ascend: 0, descend: 0, primary: false, secondary: false },
|
||||
Math.PI / 2,
|
||||
{ x: 1, z: 0 },
|
||||
);
|
||||
assert.ok(Math.abs(actor.forward + 1) < 1e-12);
|
||||
assert.ok(Math.abs(actor.right) < 1e-12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { PointerStick } from "../input/pointerStick.ts";
|
||||
|
||||
const BOUNDS = { left: 10, top: 20, width: 120, height: 120 };
|
||||
|
||||
describe("analogue pointer stick", () => {
|
||||
it("normalizes the disc and preserves the vertical controller convention", () => {
|
||||
const stick = new PointerStick();
|
||||
assert.deepEqual(stick.begin(7, 70, 20, BOUNDS), { moveX: 0, moveY: 1 });
|
||||
const corner = stick.move(7, 130, 20);
|
||||
assert.ok(corner);
|
||||
assert.ok(Math.abs(Math.hypot(corner.moveX, corner.moveY) - 1) < 1e-12);
|
||||
});
|
||||
|
||||
it("does not let another finger steal or release the active gesture", () => {
|
||||
const stick = new PointerStick();
|
||||
stick.begin(7, 70, 20, BOUNDS);
|
||||
assert.equal(stick.begin(8, 10, 80, BOUNDS), null);
|
||||
assert.equal(stick.end(8), false);
|
||||
assert.equal(stick.activePointer(), 7);
|
||||
assert.ok(stick.move(7, 10, 80));
|
||||
});
|
||||
|
||||
it("returns to neutral ownership after release or cancellation", () => {
|
||||
const stick = new PointerStick();
|
||||
stick.begin(3, 70, 20, BOUNDS);
|
||||
assert.equal(stick.end(3), true);
|
||||
assert.equal(stick.activePointer(), null);
|
||||
assert.deepEqual(stick.begin(4, 70, 80, BOUNDS), { moveX: 0, moveY: 0 });
|
||||
assert.equal(stick.cancel(4), true);
|
||||
assert.equal(stick.activePointer(), null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user