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
+61
View File
@@ -81,11 +81,21 @@ export interface MinimapOptions {
maxPixelRatio?: number;
}
export interface MinimapPlayer {
lat: number;
lng: number;
/** Degrees clockwise from true north. */
headingDeg: number;
kind: "vehicle" | "actor" | "aircraft";
}
export interface Minimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
setMarkers(markers: Marker[]): void;
setAircraft(aircraft: Aircraft[]): void;
/** Local possessed entity. Separate from live traffic so it cannot be duplicated. */
setPlayer(player: MinimapPlayer | null): void;
setChapters(chapters: Chapter[], activeId: string): void;
/**
* Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
@@ -208,6 +218,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let aircraft: Aircraft[] = [];
let chapters: Chapter[] = city.chapters;
let activeChapterId = city.chapters[0]?.id ?? "";
let player: MinimapPlayer | null = null;
let night = 0;
let renderedNight = -1;
@@ -219,6 +230,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
let chapterPx = new Float64Array(0);
let activeChapterIndex = -1;
let aircraftPx = new Float64Array(0);
let playerPx = new Float64Array(0);
let landPath = new Path2D();
let parkPath = new Path2D();
@@ -491,6 +503,18 @@ export function createMinimap(options: MinimapOptions): Minimap {
});
}
function layoutPlayer() {
if (scale <= 0 || !player) {
playerPx = new Float64Array(0);
return;
}
playerPx = new Float64Array([
toPxX(world.projectX(player.lng)),
toPxY(world.projectZ(player.lat)),
(player.headingDeg * Math.PI) / 180,
]);
}
// ---- The static map -------------------------------------------------------
function renderStatic() {
@@ -736,6 +760,31 @@ export function createMinimap(options: MinimapOptions): Minimap {
}
}
function drawPlayer(ctx: Ctx) {
if (!player || playerPx.length < 3) return;
const x = playerPx[0] ?? 0;
const y = playerPx[1] ?? 0;
const a = playerPx[2] ?? 0;
const nx = Math.sin(a);
const ny = -Math.cos(a);
const sx = -ny;
const sy = nx;
const r = (player.kind === "aircraft" ? 5.2 : player.kind === "vehicle" ? 4.5 : 4) * dpr;
ctx.beginPath();
ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
ctx.strokeStyle = theme.chapterActive;
ctx.lineWidth = 1.2 * dpr;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
ctx.lineTo(x - nx * r * 0.75 + sx * r * 0.72, y - ny * r * 0.75 + sy * r * 0.72);
ctx.lineTo(x - nx * r * 0.4, y - ny * r * 0.4);
ctx.lineTo(x - nx * r * 0.75 - sx * r * 0.72, y - ny * r * 0.75 - sy * r * 0.72);
ctx.closePath();
ctx.fillStyle = theme.chapterActive;
ctx.fill();
}
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
@@ -765,6 +814,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
drawMarkers(ctx);
drawChapters(ctx);
drawAircraft(ctx);
drawPlayer(ctx);
drawTarget(ctx);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1031,6 +1081,7 @@ export function createMinimap(options: MinimapOptions): Minimap {
layoutMarkers();
layoutChapters();
layoutAircraft();
layoutPlayer();
renderStatic();
dirty = true;
}
@@ -1052,6 +1103,16 @@ export function createMinimap(options: MinimapOptions): Minimap {
dirty = true;
},
setPlayer(next) {
if (
player?.lat === next?.lat && player?.lng === next?.lng &&
player?.headingDeg === next?.headingDeg && player?.kind === next?.kind
) return;
player = next ? { ...next } : null;
layoutPlayer();
dirty = true;
},
setChapters(next, activeId) {
chapters = next;
activeChapterId = activeId;
+45
View File
@@ -106,6 +106,15 @@ export interface OfficeMinimapOptions {
maxPixelRatio?: number;
}
export interface OfficeMinimapPlayer {
levelId: string;
x: number;
z: number;
/** Radians in office X/Z space; zero faces local north (-Z). */
headingRad: number;
kind: "humanoid" | "anonymous-dog";
}
export interface OfficeMinimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
@@ -145,6 +154,7 @@ export interface OfficeMinimap {
* heading until they have taken a step.
*/
setRobots(robots: readonly PlanRobot[]): void;
setPlayer(player: OfficeMinimapPlayer | null): void;
/** Call from the stage tick. Cheap by construction — see the file header. */
tick(): void;
/** Re-do the backing store at the current size and re-rasterise the plan. */
@@ -271,6 +281,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
*/
let level: LevelPlan | null = plan.levels[0] ?? null;
let activeViewId: string | null = null;
let player: OfficeMinimapPlayer | null = null;
/** Occupied seats on this storey: x, y device pixels per person, laid out once. */
let occupiedPx = new Float64Array(0);
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
@@ -927,6 +938,29 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
}
}
function drawPlayer(ctx: Ctx) {
if (!player || !level || player.levelId !== level.id) return;
const x = toPxX(player.x);
const y = toPxY(player.z);
const nx = -Math.sin(player.headingRad);
const ny = -Math.cos(player.headingRad);
const sx = -ny;
const sy = nx;
const r = (player.kind === "anonymous-dog" ? 3.6 : 4.2) * dpr;
ctx.beginPath();
ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
ctx.strokeStyle = theme.viewpointActive;
ctx.lineWidth = 1.2 * dpr;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
ctx.lineTo(x - nx * r * 0.65 + sx * r * 0.65, y - ny * r * 0.65 + sy * r * 0.65);
ctx.lineTo(x - nx * r * 0.65 - sx * r * 0.65, y - ny * r * 0.65 - sy * r * 0.65);
ctx.closePath();
ctx.fillStyle = theme.viewpointActive;
ctx.fill();
}
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
@@ -960,6 +994,7 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
// want to see; the camera is the thing you want to see over everything, and
// that has been the order here since the widget was one function.
drawRobots(ctx);
drawPlayer(ctx);
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
@@ -1356,6 +1391,16 @@ export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinima
dirty = true;
},
setPlayer(next) {
if (
player?.levelId === next?.levelId && player?.x === next?.x &&
player?.z === next?.z && player?.headingRad === next?.headingRad &&
player?.kind === next?.kind
) return;
player = next ? { ...next } : null;
dirty = true;
},
tick() {
if (!ready || !viewCtx) return;
const now = performance.now();
+49 -34
View File
@@ -80,6 +80,7 @@ import type {
} from "../aircraft/controller.ts";
import type { ScenePeers, ScenePeersOptions } from "../realtime/scenePeers.ts";
import type { EntityPoseSnapshot } from "../realtime/types.ts";
import { cityControlOwnership, type CityControlMode } from "../play/controlMode.ts";
export type CityRealtimePeersOptions = Omit<ScenePeersOptions, "project" | "groundAt">;
@@ -195,6 +196,10 @@ export interface SceneHandle {
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
/** Atomically hands local input and follow-camera ownership to one subsystem. */
setControlMode(mode: CityControlMode): void;
controlMode(): CityControlMode;
onControlModeChange(fn: (mode: CityControlMode) => void): void;
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
/** Current playable corridor state, or null on a city-scale board. */
@@ -482,6 +487,33 @@ export async function createScene(
let currentChapter = first.id;
const chapterListeners: ((id: string) => void)[] = [];
let controlMode: CityControlMode = "overview";
const controlModeListeners: ((mode: CityControlMode) => void)[] = [];
function applyControlMode(requested: CityControlMode): CityControlMode {
const next: CityControlMode =
requested === "drive" && !roadTraffic ? "overview"
: requested === "actor" && !sceneActor ? "overview"
: requested === "aircraft" && !sceneAircraft ? "overview"
: requested;
const ownership = cityControlOwnership(next);
sceneActor?.setActive(ownership.actor);
sceneAircraft?.setActive(ownership.aircraft);
roadTraffic?.setFollowing(ownership.drive);
kit.controls.enabled = ownership.orbit;
kit.camera.near = next === "actor" ? 0.001 : next === "aircraft" ? 0.01 : 0.1;
kit.controls.minDistance = next === "actor"
? 0.001
: next === "aircraft"
? 0.01
: orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (next !== controlMode) {
controlMode = next;
for (const fn of controlModeListeners) fn(next);
}
return next;
}
function chapterPose(ch: Chapter): Pose {
const [x, z] = world.project(ch.focus.lat, ch.focus.lng);
@@ -502,19 +534,12 @@ export async function createScene(
// Named viewpoints are observe/vehicle destinations. Possessing an actor
// is an explicit UI action, so a chapter selection always hands the camera
// back before it moves anywhere else.
sceneActor?.setActive(false);
sceneAircraft?.setActive(false);
kit.controls.minDistance = orbitMinDistance;
if (kit.camera.near !== 0.1) {
kit.camera.near = 0.1;
kit.camera.updateProjectionMatrix();
}
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
if (route && roadTraffic) {
roadTraffic.setRoute(route.id);
roadTraffic.setFollowing(true);
applyControlMode("drive");
} else {
roadTraffic?.setFollowing(false);
applyControlMode("overview");
roadTraffic?.setVehicleActions({});
kit.flyTo(chapterPose(ch));
}
@@ -547,14 +572,15 @@ export async function createScene(
// bug.
onExit: () => kit.resetPick(),
tick(dt) {
const actorPlaying = sceneActor?.active() ?? false;
const aircraftPlaying = sceneAircraft?.active() ?? false;
kit.controls.enabled = !actorPlaying && !aircraftPlaying;
// Exactly one subsystem owns the camera. In particular, OrbitControls
// must stay disabled while the road layer writes its follow pose.
const ownership = cityControlOwnership(controlMode);
kit.controls.enabled = ownership.orbit;
kit.tick(dt);
sceneActor?.tick(dt);
if (actorPlaying && sceneActor) kit.setPose(sceneActor.followPose());
if (ownership.actor && sceneActor) kit.setPose(sceneActor.followPose());
sceneAircraft?.tick(dt);
if (aircraftPlaying && sceneAircraft) kit.setPose(sceneAircraft.followPose());
if (ownership.aircraft && sceneAircraft) kit.setPose(sceneAircraft.followPose());
realtimePeers?.tick(Date.now());
roadTraffic?.tick(dt);
clouds.tick(dt);
@@ -645,6 +671,11 @@ export async function createScene(
onChapterChange(fn) {
chapterListeners.push(fn);
},
setControlMode: (mode) => { applyControlMode(mode); },
controlMode: () => controlMode,
onControlModeChange(fn) {
controlModeListeners.push(fn);
},
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
vehicleState: () => roadTraffic?.hero() ?? null,
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
@@ -655,31 +686,15 @@ export async function createScene(
attachActorFaceTexture: (texture) => sceneActor?.attachFaceTexture(texture) ?? false,
clearActorFaceTexture: () => { sceneActor?.clearFaceTexture(); },
setActorActive(active) {
sceneActor?.setActive(active);
if (active) {
sceneAircraft?.setActive(false);
roadTraffic?.setFollowing(false);
}
// State boards compress one real metre to a few hundredths of a scene
// unit. Their possessed actor and chase camera are therefore closer than
// the map camera's 0.1 near plane; lower it only for play mode so the
// procedural rig is not clipped away, then restore the depth precision.
kit.camera.near = active ? 0.001 : 0.1;
kit.controls.minDistance = active ? 0.001 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (active) applyControlMode("actor");
else if (controlMode === "actor") applyControlMode("overview");
},
actorActive: () => sceneActor?.active() ?? false,
setAircraftActions: (actions) => { sceneAircraft?.setActions(actions); },
aircraftState: () => sceneAircraft?.state() ?? null,
setAircraftActive(active) {
sceneAircraft?.setActive(active);
if (active) {
sceneActor?.setActive(false);
roadTraffic?.setFollowing(false);
}
kit.camera.near = active ? 0.01 : 0.1;
kit.controls.minDistance = active ? 0.01 : orbitMinDistance;
kit.camera.updateProjectionMatrix();
if (active) applyControlMode("aircraft");
else if (controlMode === "aircraft") applyControlMode("overview");
},
aircraftActive: () => sceneAircraft?.active() ?? false,
upsertRemoteSnapshot: (snapshot) => {
+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;
}
+560 -178
View File
@@ -37,11 +37,17 @@ import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts";
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
import {
mergeVehicleActions,
sampleStandardGamepad,
type GamepadButtonState,
} from "./input/vehicle.ts";
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts";
aircraftActionsFromPlay,
cameraRelativePlanar,
crowActionsFromPlay,
groundActorActionsFromPlay,
PlayInputRouter,
sampleStandardPlayGamepad,
vehicleActionsFromPlay,
type PlayDigitalControl,
type StandardPlayGamepadButtons,
} from "./input/play.ts";
import { PointerStick } from "./input/pointerStick.ts";
import {
createTeraClient,
describeLiveness,
@@ -91,8 +97,13 @@ import { SRGBColorSpace, VideoTexture } from "three";
import {
CALIFORNIA_AIR_ROUTE,
createAircraftPoseSnapshot,
type AircraftActionSnapshot,
} from "./aircraft/index.ts";
import {
cityControlMode,
createControlModeState,
transitionControlMode,
type ControlMode,
} from "./play/controlMode.ts";
import {
createOfficeScreenPanel,
type MediaSurfaceDescriptor,
@@ -285,6 +296,8 @@ let office: OfficeScene | null = null;
/** The pack used by `office`; kept separate from the currently selected door. */
let builtOfficeId: string | null = null;
let inside = false;
let controlModeState = createControlModeState();
const playInput = new PlayInputRouter();
let markers: Marker[] = SAMPLE_MARKERS;
let realtimeClient: RealtimeClient | null = null;
let presenceIndicator: PresenceIndicator | null = null;
@@ -804,6 +817,8 @@ async function mountCity(id: string) {
weatherWatch = null;
poseEditor?.destroy();
poseEditor = null;
clearPublishedPlayInput();
controlModeState = createControlModeState();
disposeLoadedOffice();
inside = false;
minimap?.dispose();
@@ -1062,6 +1077,7 @@ async function mountCity(id: string) {
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
});
city.onChapterChange(() => renderLegend());
city.onControlModeChange((mode) => adoptCityControlMode(mode));
/**
* The plan view, built last, because it reads the finished `World` — the
@@ -1081,7 +1097,7 @@ async function mountCity(id: string) {
// definition of "phone", in `stage.ts`, read by both.
maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(lat, lng) {
if (!city) return;
if (!city || city.controlMode() !== "overview") return;
/**
* Slide the orbit target and carry the camera with it, keeping the offset
* between them. A seek is "look over there", not "go to chapter three":
@@ -1135,6 +1151,8 @@ async function mountCity(id: string) {
requestAnimationFrame(function pumpMinimap() {
requestAnimationFrame(pumpMinimap);
syncJourneyVehicle(performance.now());
updateLocalPlayerMaps();
renderPlayHud();
// Only the mounted one. The other is still constructed and still holds a live
// camera, but its canvas is out of the document, so `clientWidth` is 0, every
// `resize()` puts it back to `ready = false`, and ticking it would be a
@@ -1149,6 +1167,61 @@ requestAnimationFrame(function pumpMinimap() {
pollLiveness();
});
function updateLocalPlayerMaps(): void {
if (!city) return;
if (inside) {
minimap?.setPlayer(null);
const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null;
officePlan?.setPlayer(walker
? {
levelId: walker.levelId,
x: walker.position.x,
z: walker.position.z,
headingRad: Math.atan2(-walker.facing.x, -walker.facing.z),
kind: walker.actor,
}
: null);
return;
}
officePlan?.setPlayer(null);
if (city.controlMode() === "drive") {
const state = city.vehicleState();
minimap?.setPlayer(state
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" }
: null);
return;
}
if (city.controlMode() === "aircraft") {
const state = city.aircraftState();
minimap?.setPlayer(state
? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" }
: null);
return;
}
if (city.controlMode() === "actor") {
const state = city.actorState();
const pack = CITIES.find((candidate) => candidate.id === cityId)?.city;
if (!state || !pack) {
minimap?.setPlayer(null);
return;
}
const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center;
const [originX, originZ] = city.world.project(anchor.lat, anchor.lng);
const [lat, lng] = city.world.unproject(
originX + state.x / city.world.metresPerUnit,
originZ + state.z / city.world.metresPerUnit,
);
minimap?.setPlayer({
lat,
lng,
headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360,
kind: "actor",
});
return;
}
minimap?.setPlayer(null);
}
/**
* Whether the corner label is still telling the truth, once a second.
*
@@ -1302,8 +1375,13 @@ async function enterOffice() {
office.onViewChange(() => renderLegend());
officePlan = buildOfficePlan(createOfficeMinimap, office);
}
requestControlMode("overview");
city.stage.setScene(office);
inside = true;
controlModeState = {
mode: "office-overview",
revision: controlModeState.revision + 1,
};
attachCurrentWebcamFace();
moveRealtimePresence();
const desiredCity: JourneyCity = officeId === "mateo-court" ? "socal" : "bay-area";
@@ -1344,6 +1422,7 @@ function buildOfficePlan(
// exact drift `deviceProfile` exists in one file to prevent.
maxPixelRatio: deviceProfile().maxPixelRatio,
onSeek(x, z) {
if (controlModeState.mode !== "office-overview") return;
/**
* The same move the city plan makes, and for the same reason: slide the
* orbit target and carry the camera with it, keeping the offset between
@@ -1426,11 +1505,16 @@ function leaveOffice() {
// rather than publish into a room the user has already left.
stopWatchingOccupancy();
disposeOfficeScreenUi();
clearPublishedPlayInput();
office?.walker?.setActive(false);
office?.walker?.setAction({ x: 0, z: 0 });
dispatchJourney({ type: "leave-office" });
city.stage.setScene(city.stageScene);
inside = false;
controlModeState = {
mode: "overview",
revision: controlModeState.revision + 1,
};
city.setControlMode("overview");
attachCurrentWebcamFace();
moveRealtimePresence();
showPlan();
@@ -1614,6 +1698,21 @@ const flyButton = document.querySelector<HTMLButtonElement>("#fly");
const screensButton = document.querySelector<HTMLButtonElement>("#screens");
const walkControls = document.querySelector<HTMLElement>("#walk-controls");
const walkHint = document.querySelector<HTMLElement>("#walk-hint");
const touchPlayControls = document.querySelector<HTMLElement>("#touch-play-controls");
const playStick = document.querySelector<HTMLElement>("#play-stick");
const touchPrimary = document.querySelector<HTMLButtonElement>("#touch-primary");
const touchSecondary = document.querySelector<HTMLButtonElement>("#touch-secondary");
const touchPitchUp = document.querySelector<HTMLButtonElement>("#touch-pitch-up");
const touchPitchDown = document.querySelector<HTMLButtonElement>("#touch-pitch-down");
const touchAssist = document.querySelector<HTMLButtonElement>("#touch-assist");
const touchReset = document.querySelector<HTMLButtonElement>("#touch-reset");
const touchCamera = document.querySelector<HTMLButtonElement>("#touch-camera");
const touchMap = document.querySelector<HTMLButtonElement>("#touch-map");
const modeDock = document.querySelector<HTMLElement>("#mode-dock");
const playHud = document.querySelector<HTMLElement>("#play-hud");
const playHudMode = document.querySelector<HTMLElement>("#play-hud-mode");
const playHudPrimary = document.querySelector<HTMLElement>("#play-hud-primary");
const playHudStatus = document.querySelector<HTMLElement>("#play-hud-status");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
@@ -1623,6 +1722,100 @@ let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
let webcamFaceConsent = createWebcamFaceConsent();
let webcamCapture: WebcamCaptureController | null = null;
function availableControlModes() {
const route = city?.current();
return {
insideOffice: inside,
drive: !inside && city?.vehicleState() !== null &&
(route === "la-sf-us-101" || route === "la-sf-i-5"),
actor: !inside && city?.actorState() !== null,
aircraft: !inside && city?.aircraftState() !== null,
officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
};
}
function clearPublishedPlayInput(): void {
playInput.clearAll();
for (const button of document.querySelectorAll<HTMLElement>(
"[data-drive-key][aria-pressed], [data-walk-key][aria-pressed], [data-play-control][aria-pressed]",
)) button.setAttribute("aria-pressed", "false");
resetPlayStick();
city?.setVehicleActions({});
city?.setActorActions({});
city?.setAircraftActions({});
office?.walker?.setAction({ x: 0, z: 0 });
}
function adoptCityControlMode(mode: ReturnType<typeof cityControlMode>): void {
if (inside) return;
const previous = controlModeState.mode;
if (controlModeState.mode !== mode) {
clearPublishedPlayInput();
controlModeState = { mode, revision: controlModeState.revision + 1 };
}
if (
previous === "overview" && mode !== "overview" &&
(document.body.classList.contains("touch-capable") ||
window.matchMedia?.("(pointer: coarse)").matches === true)
) {
planOpen = false;
planChosen = true;
applyPlan();
}
renderLegend();
}
/** One transaction updates Journey, simulation ownership, input and chrome. */
function requestControlMode(requested: ControlMode): boolean {
const transition = transitionControlMode(controlModeState, requested, availableControlModes());
if (transition.changed) clearPublishedPlayInput();
controlModeState = transition.state;
const next = transition.state.mode;
if (inside) {
city?.setControlMode("overview");
const walking = next === "office-walk";
office?.walker?.setActive(walking);
if (!walking) office?.walker?.setAction({ x: 0, z: 0 });
} else {
office?.walker?.setActive(false);
city?.setControlMode(cityControlMode(next));
}
if (next === "drive") {
const routeId = city?.current();
if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") {
dispatchJourney({ type: "set-mode", mode: "play" });
if (journey.route?.routeId !== routeId) {
dispatchJourney({ type: "select-route", routeId, direction: 1 });
}
if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
}
} else if (journey.vehicle) {
dispatchJourney({ type: "exit-vehicle" });
}
if (next !== "overview" && next !== "office-overview") {
dispatchJourney({ type: "set-mode", mode: "play" });
} else if (!journey.vehicle) {
dispatchJourney({ type: "set-mode", mode: "observe" });
}
if (
transition.changed &&
(transition.previous === "overview" || transition.previous === "office-overview") &&
next !== "overview" && next !== "office-overview" &&
(document.body.classList.contains("touch-capable") ||
window.matchMedia?.("(pointer: coarse)").matches === true)
) {
planOpen = false;
planChosen = true;
applyPlan();
}
renderLegend();
publishPlayActions();
return transition.accepted;
}
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
const body = document.querySelector<HTMLElement>("#detail-text");
@@ -1762,6 +1955,25 @@ function renderLegend() {
const walking = inside && (office?.walker?.active() ?? false);
const exploring = !inside && (city.actorActive() ?? false);
const flying = !inside && (city.aircraftActive() ?? false);
const actualMode: ControlMode = inside
? walking ? "office-walk" : "office-overview"
: city.controlMode();
if (controlModeState.mode !== actualMode) {
controlModeState = { mode: actualMode, revision: controlModeState.revision + 1 };
}
const availability = availableControlModes();
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
const mode = button.dataset.controlMode as ControlMode;
button.hidden = mode === "drive" ? !availability.drive
: mode === "actor" ? !availability.actor
: mode === "aircraft" ? !availability.aircraft
: mode === "office-walk" ? !availability.officeWalk
: false;
const pressed = mode === "overview"
? actualMode === "overview" || actualMode === "office-overview"
: mode === actualMode;
button.setAttribute("aria-pressed", String(pressed));
}
if (walkButton) {
walkButton.hidden = inside ? office?.walker === null : city.actorState() === null;
walkButton.setAttribute("aria-pressed", String(walking || exploring));
@@ -1791,6 +2003,8 @@ function renderLegend() {
? walking
? `${officeName()}, following your ${office?.walker?.state().actor === "anonymous-dog" ? "dog" : "humanoid"}. Use W A S D to move.`
: `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
: routeDriveIsActive()
? `${cityLabel}, following your car on ${city.vehicleState()?.roadName ?? "the selected route"}. Use W A S D to drive.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly or P to resume assisted flight.`
: exploring
@@ -1816,6 +2030,32 @@ function renderLegend() {
: "Walking controls",
);
}
const touchModeActive = routeDriveIsActive() || walking || exploring || flying;
if (touchPlayControls) {
touchPlayControls.hidden = !touchModeActive;
touchPlayControls.setAttribute("aria-label", `${actualMode.replace("office-", "")} touch controls`);
}
const crowPlaying = exploring && city.actorState()?.kind === "crow" &&
city.actorState()?.mode === "flight";
if (touchPrimary) {
touchPrimary.hidden = walking;
touchPrimary.dataset.playControl = crowPlaying ? "ascend" : "primary";
touchPrimary.textContent = routeDriveIsActive() ? "Handbrake"
: flying ? "Throttle"
: crowPlaying ? "Climb"
: "Sprint";
}
if (touchSecondary) {
touchSecondary.hidden = !crowPlaying;
touchSecondary.dataset.playControl = "secondary";
touchSecondary.textContent = "Glide";
}
if (touchPitchUp) touchPitchUp.hidden = !crowPlaying;
if (touchPitchDown) touchPitchDown.hidden = !crowPlaying;
if (touchAssist) touchAssist.hidden = !(routeDriveIsActive() || flying);
if (touchReset) touchReset.hidden = !(routeDriveIsActive() || flying);
if (touchCamera) touchCamera.hidden = !routeDriveIsActive();
if (touchMap) touchMap.setAttribute("aria-pressed", String(planOpen));
for (const control of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
control.textContent = flying
? control.dataset.aircraftLabel ?? control.textContent
@@ -1837,7 +2077,46 @@ function renderLegend() {
? "WASD fly · P assisted · R reset"
: inside
? "V walk · WASD move"
: "V explore · WASD · Q/E altitude · G glide";
: "V explore · WASD · Q/E altitude · I/K pitch · G glide";
}
}
function renderPlayHud(): void {
if (!playHud || !playHudMode || !playHudPrimary || !playHudStatus || !city) return;
const mode = controlModeState.mode;
playHud.hidden = mode === "overview" || mode === "office-overview";
playHudStatus.classList.remove("warning");
if (mode === "drive") {
const state = city.vehicleState();
if (!state) return;
playHudMode.textContent = "Drive";
playHudPrimary.textContent = `${Math.round(state.speedMps * 2.23694)} mph · ${state.roadName}`;
playHudStatus.textContent = `${state.mode} · ${Math.round(state.progress * 100)}% · ${city.vehicleCamera() ?? "chase"}`;
playHudStatus.classList.toggle("warning", state.guardrailContact || state.collisionRisk > 0.55);
} else if (mode === "actor") {
const state = city.actorState();
if (!state) return;
playHudMode.textContent = state.kind === "crow" ? "Crow" : "Explore";
playHudPrimary.textContent = state.kind === "crow"
? `${state.speedMps.toFixed(1)} m/s · ${Math.max(0, state.y).toFixed(0)} m alt`
: `${state.speedMps.toFixed(1)} m/s · ${state.distanceM.toFixed(0)} m travelled`;
playHudStatus.textContent = state.kind === "crow"
? `${state.crowPose} · ${Math.round(state.flightEnergy * 100)}% energy`
: `${state.mode} · ${state.identity.displayName}`;
playHudStatus.classList.toggle("warning", state.altitudeBoundContact !== "none");
} else if (mode === "aircraft") {
const state = city.aircraftState();
if (!state) return;
playHudMode.textContent = "Flight";
playHudPrimary.textContent = `${Math.round(state.speedMps * 1.94384)} kt · ${Math.round(state.altitudeM).toLocaleString()} m`;
playHudStatus.textContent = `${state.mode} · ${Math.round(state.batteryWh)} Wh${state.stalled ? " · STALL" : ""}`;
playHudStatus.classList.toggle("warning", state.stalled || state.hardLanding || state.envelopeContact);
} else if (mode === "office-walk") {
const state = office?.walker?.state();
if (!state) return;
playHudMode.textContent = "Office";
playHudPrimary.textContent = `${officeName()} · ${state.distance.toFixed(0)} m walked`;
playHudStatus.textContent = `${state.position.x.toFixed(1)}, ${state.position.z.toFixed(1)} m`;
}
}
@@ -2824,21 +3103,33 @@ function currentViews(): View[] {
function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
if (inside && office) {
requestControlMode("office-overview");
office.flyTo(view.id);
renderLegend();
}
else {
const destination = cityId === "california" ? CALIFORNIA_DESTINATIONS.get(view.id) : undefined;
if (destination) {
requestControlMode("overview");
officeId = destination.officeId;
journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
switchCity(destination.cityId);
return;
}
if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
dispatchJourney({ type: "set-mode", mode: "play" });
dispatchJourney({ type: "select-route", routeId: view.id, direction: 1 });
dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
city?.flyTo(view.id);
requestControlMode("drive");
if (window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
renderLegend();
return;
}
requestControlMode("overview");
city?.flyTo(view.id);
renderLegend();
}
}
@@ -2870,6 +3161,7 @@ function leaveToCity(cityWanted?: string): boolean {
}
function switchCity(id: string) {
requestControlMode(inside ? "office-overview" : "overview");
if (inside && leaveToCity(id)) return;
if (inside) leaveOffice();
if (id === wantedCity) return;
@@ -2950,9 +3242,8 @@ enterButton?.addEventListener("click", () => void toggleOffice());
function toggleOfficeWalk(): boolean {
if (!inside) {
if (!city?.actorState()) return false;
const active = !city.actorActive();
city.setActorActive(active);
if (!active) city.setActorActions({});
const active = city.controlMode() !== "actor";
requestControlMode(active ? "actor" : "overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2963,8 +3254,7 @@ function toggleOfficeWalk(): boolean {
const walker = office?.walker;
if (!walker) return false;
const active = !walker.active();
walker.setActive(active);
if (!active) walker.setAction({ x: 0, z: 0 });
requestControlMode(active ? "office-walk" : "office-overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2977,9 +3267,8 @@ walkButton?.addEventListener("click", () => toggleOfficeWalk());
function toggleAircraft(): boolean {
if (inside || cityId !== "california" || !city?.aircraftState()) return false;
const active = !city.aircraftActive();
city.setAircraftActive(active);
if (!active) city.setAircraftActions({});
const active = city.controlMode() !== "aircraft";
requestControlMode(active ? "aircraft" : "overview");
if (active && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
@@ -2990,6 +3279,17 @@ function toggleAircraft(): boolean {
flyButton?.addEventListener("click", () => toggleAircraft());
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ?? []) {
button.addEventListener("click", () => {
const mode = button.dataset.controlMode as ControlMode;
requestControlMode(mode);
if (mode !== "overview" && mode !== "office-overview" && window.innerWidth <= 600) {
panelOpen = false;
applyPanel();
}
});
}
/**
* Clicking a building on the city walks into it.
*
@@ -3038,6 +3338,7 @@ function applyPanel() {
function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen);
planToggle?.setAttribute("aria-pressed", String(planOpen));
touchMap?.setAttribute("aria-pressed", String(planOpen));
}
/**
@@ -3109,72 +3410,10 @@ shortcutsCard?.addEventListener("click", (event) => {
if (event.target === shortcutsCard) closeShortcuts();
});
/** Held keyboard state translated into the same snapshot a gamepad/touch UI uses. */
const heldDriveKeys = new Set<string>();
function routeDriveIsActive(): boolean {
const state = !inside ? city?.vehicleState() : null;
return state !== null && state !== undefined && !city?.actorActive() &&
!city?.aircraftActive() && city?.current() === state.routeId;
}
function publishVehicleActions(
supplement: Partial<VehicleActionSnapshot> = {},
): boolean {
if (!routeDriveIsActive() || !city) return false;
const left = heldDriveKeys.has("a");
const right = heldDriveKeys.has("d");
city.setVehicleActions(
mergeVehicleActions(
{
throttle: heldDriveKeys.has("w") ? 1 : 0,
brake: heldDriveKeys.has("s") ? 1 : 0,
steering: (right ? 1 : 0) - (left ? 1 : 0),
handbrake: heldDriveKeys.has(" "),
},
supplement,
),
);
return true;
}
function publishOfficeWalkActions(): boolean {
const walker = inside ? office?.walker : null;
if (!walker?.active()) return false;
walker.setAction({
x: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
z: (heldDriveKeys.has("s") ? 1 : 0) - (heldDriveKeys.has("w") ? 1 : 0),
});
return true;
}
function publishCityActorActions(): boolean {
if (inside || !city?.actorActive()) return false;
city.setActorActions({
forward: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
right: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
turn: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
sprint: heldDriveKeys.has(" "),
climb: (heldDriveKeys.has("e") || heldDriveKeys.has(" ") ? 1 : 0) -
(heldDriveKeys.has("q") ? 1 : 0),
glide: heldDriveKeys.has("g"),
});
return true;
}
function publishAircraftActions(
supplement: Partial<AircraftActionSnapshot> = {},
): boolean {
if (inside || !city?.aircraftActive()) return false;
city.setAircraftActions({
throttle: heldDriveKeys.has(" ") ? 1 : 0,
pitch: (heldDriveKeys.has("w") ? 1 : 0) - (heldDriveKeys.has("s") ? 1 : 0),
roll: (heldDriveKeys.has("d") ? 1 : 0) - (heldDriveKeys.has("a") ? 1 : 0),
yaw: (heldDriveKeys.has("e") ? 1 : 0) - (heldDriveKeys.has("q") ? 1 : 0),
modeRequest: supplement.modeRequest ?? "none",
reset: supplement.reset ?? false,
});
return true;
return state !== null && state !== undefined && city?.controlMode() === "drive" &&
city.current() === state.routeId;
}
function toggleVehicleCamera(): boolean {
@@ -3183,98 +3422,257 @@ function toggleVehicleCamera(): boolean {
return true;
}
for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) {
const key = button.dataset.driveKey;
if (key === undefined) continue;
const release = (event: PointerEvent) => {
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishVehicleActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishVehicleActions();
function cameraForward(
camera: { position: { x: number; z: number } },
controls: { target: { x: number; z: number } },
) {
return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z };
}
function publishPlayActions(): boolean {
const input = playInput.snapshot();
const requests = playInput.consumeRequests();
if (routeDriveIsActive() && city) {
city.setVehicleActions({
...vehicleActionsFromPlay(input),
modeRequest: requests.has("assist") ? "assisted" : "none",
reset: requests.has("reset"),
});
if (requests.has("camera")) toggleVehicleCamera();
return true;
}
const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null;
if (walker?.active() && office) {
walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls)));
return true;
}
if (!inside && city?.controlMode() === "actor") {
const state = city.actorState();
if (!state) return false;
if (state.kind === "crow" && state.mode === "flight") {
city.setActorActions({
...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false,
});
} else {
city.setActorActions({
...groundActorActionsFromPlay(
input,
state.yaw,
cameraForward(city.stageScene.camera, city.stageScene.controls),
),
pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false,
});
}
return true;
}
if (!inside && city?.controlMode() === "aircraft") {
city.setAircraftActions({
...aircraftActionsFromPlay(input),
modeRequest: requests.has("assist") ? "assisted" : "none",
reset: requests.has("reset"),
});
return true;
}
return false;
}
function controlForKey(key: string): PlayDigitalControl | null {
switch (key === " " ? key : key.toLowerCase()) {
case "w": return "forward";
case "s": return "backward";
case "a": return "left";
case "d": return "right";
case "q": return "descend";
case "e": return "ascend";
case "i": return "pitch-up";
case "k": return "pitch-down";
case " ": return "primary";
case "g": return "secondary";
default: return null;
}
}
function bindPointerControls(
container: HTMLElement | null,
selector: "[data-drive-key]" | "[data-walk-key]",
attribute: "driveKey" | "walkKey",
): void {
for (const button of container?.querySelectorAll<HTMLButtonElement>(selector) ?? []) {
const key = button.dataset[attribute];
const control = key === undefined ? null : controlForKey(key);
if (!control) continue;
const release = (event: PointerEvent) => {
playInput.clearSource(`pointer:${event.pointerId}`);
button.setAttribute("aria-pressed", "false");
publishPlayActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
button.setPointerCapture(event.pointerId);
playInput.setDigital(`pointer:${event.pointerId}`, control, true);
button.setAttribute("aria-pressed", "true");
publishPlayActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
}
bindPointerControls(driveControls, "[data-drive-key]", "driveKey");
bindPointerControls(walkControls, "[data-walk-key]", "walkKey");
const pointerStick = new PointerStick();
function paintPlayStick(axes = { moveX: 0, moveY: 0 }): void {
if (!playStick) return;
const travel = Math.max(0, (playStick.getBoundingClientRect().width - 52) / 2);
playStick.style.setProperty("--stick-x", `${axes.moveX * travel}px`);
playStick.style.setProperty("--stick-y", `${-axes.moveY * travel}px`);
}
function resetPlayStick(): void {
const pointerId = pointerStick.activePointer();
if (pointerId !== null) {
pointerStick.end(pointerId);
playInput.clearSource(`stick:${pointerId}`);
}
playStick?.classList.remove("active");
paintPlayStick();
}
if (playStick) {
playStick.addEventListener("pointerdown", (event) => {
const axes = pointerStick.begin(event.pointerId, event.clientX, event.clientY, playStick.getBoundingClientRect());
if (!axes) return;
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
playStick.setPointerCapture(event.pointerId);
playStick.classList.add("active");
playInput.setAxes(`stick:${event.pointerId}`, axes);
paintPlayStick(axes);
publishPlayActions();
event.preventDefault();
});
playStick.addEventListener("pointermove", (event) => {
const axes = pointerStick.move(event.pointerId, event.clientX, event.clientY);
if (!axes) return;
playInput.setAxes(`stick:${event.pointerId}`, axes);
paintPlayStick(axes);
publishPlayActions();
event.preventDefault();
});
const finishStick = (event: PointerEvent, cancelled: boolean) => {
const finished = cancelled ? pointerStick.cancel(event.pointerId) : pointerStick.end(event.pointerId);
if (!finished) return;
playInput.clearSource(`stick:${event.pointerId}`);
playStick.classList.remove("active");
paintPlayStick();
publishPlayActions();
event.preventDefault();
};
playStick.addEventListener("pointerup", (event) => finishStick(event, false));
playStick.addEventListener("pointercancel", (event) => finishStick(event, true));
playStick.addEventListener("lostpointercapture", (event) => finishStick(event, true));
}
function digitalControlFromData(raw: string | undefined): PlayDigitalControl | null {
if (
raw === "forward" || raw === "backward" || raw === "left" || raw === "right" ||
raw === "ascend" || raw === "descend" || raw === "pitch-up" || raw === "pitch-down" ||
raw === "primary" || raw === "secondary"
) return raw;
return null;
}
for (const button of touchPlayControls?.querySelectorAll<HTMLButtonElement>("[data-play-control]") ?? []) {
const held = new Map<number, PlayDigitalControl>();
button.addEventListener("pointerdown", (event) => {
const control = digitalControlFromData(button.dataset.playControl);
if (!control) return;
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
button.setPointerCapture(event.pointerId);
held.set(event.pointerId, control);
playInput.setDigital(`action:${event.pointerId}`, control, true);
button.setAttribute("aria-pressed", "true");
publishPlayActions();
event.preventDefault();
});
const release = (event: PointerEvent) => {
if (!held.delete(event.pointerId)) return;
playInput.clearSource(`action:${event.pointerId}`);
button.setAttribute("aria-pressed", String(held.size > 0));
publishPlayActions();
event.preventDefault();
};
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
for (const button of walkControls?.querySelectorAll<HTMLButtonElement>("[data-walk-key]") ?? []) {
const key = button.dataset.walkKey;
if (key === undefined) continue;
const release = (event: PointerEvent) => {
heldDriveKeys.delete(key);
button.setAttribute("aria-pressed", "false");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
};
button.addEventListener("pointerdown", (event) => {
button.setPointerCapture(event.pointerId);
heldDriveKeys.add(key);
button.setAttribute("aria-pressed", "true");
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
event.preventDefault();
});
button.addEventListener("pointerup", release);
button.addEventListener("pointercancel", release);
button.addEventListener("lostpointercapture", release);
}
touchAssist?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
touchReset?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
touchCamera?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
touchMap?.addEventListener("click", () => {
togglePlan();
touchMap.setAttribute("aria-pressed", String(planOpen));
});
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
?.addEventListener("click", () => toggleVehicleCamera());
?.addEventListener("click", () => { playInput.request("camera"); publishPlayActions(); });
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='assist']")
?.addEventListener("click", () => publishAircraftActions({ modeRequest: "assisted" }));
?.addEventListener("click", () => { playInput.request("assist"); publishPlayActions(); });
walkControls?.querySelector<HTMLButtonElement>("[data-aircraft-action='reset']")
?.addEventListener("click", () => publishAircraftActions({ reset: true }));
?.addEventListener("click", () => { playInput.request("reset"); publishPlayActions(); });
window.addEventListener("keyup", (event) => {
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
if (!heldDriveKeys.delete(key)) return;
if (
publishVehicleActions() || publishOfficeWalkActions() ||
publishAircraftActions() || publishCityActorActions()
) event.preventDefault();
const control = controlForKey(event.key);
if (!control) return;
playInput.setDigital("keyboard", control, false);
if (publishPlayActions()) event.preventDefault();
});
window.addEventListener("blur", () => {
heldDriveKeys.clear();
publishVehicleActions();
publishOfficeWalkActions();
publishAircraftActions();
publishCityActorActions();
function releaseAllPlayInput(): void {
clearPublishedPlayInput();
publishPlayActions();
}
window.addEventListener("blur", releaseAllPlayInput);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible") releaseAllPlayInput();
});
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
function pollDriveGamepad() {
let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
function pollPlayGamepad() {
try {
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
if (pad && routeDriveIsActive()) {
const sample = sampleStandardGamepad(pad, gamepadButtons);
if (pad) {
const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
gamepadButtons = sample.buttons;
publishVehicleActions(sample.actions);
playInput.clearSource("gamepad");
playInput.setAxes("gamepad", sample.axes);
for (const control of sample.digital) playInput.setDigital("gamepad", control, true);
for (const request of sample.requests) playInput.request(request);
publishPlayActions();
} else {
gamepadButtons = { assist: false, reset: false };
playInput.clearSource("gamepad");
gamepadButtons = { assist: false, reset: false, camera: false };
}
} catch {
// Some privacy-hardened browsers expose the method but throw until a pad
// has produced a trusted event. Keyboard/touch remain fully functional.
}
requestAnimationFrame(pollDriveGamepad);
requestAnimationFrame(pollPlayGamepad);
}
requestAnimationFrame(pollDriveGamepad);
requestAnimationFrame(pollPlayGamepad);
window.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch") document.body.classList.add("touch-capable");
}, { capture: true });
/**
* Keyboard access to everything the mouse can reach.
@@ -3286,7 +3684,7 @@ requestAnimationFrame(pollDriveGamepad);
* belongs to that control, not to this.
*/
window.addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
@@ -3324,45 +3722,29 @@ window.addEventListener("keydown", (event) => {
return;
}
const lower = event.key.toLowerCase();
if (
lower === "w" || lower === "a" || lower === "s" || lower === "d" ||
lower === "q" || lower === "e" || lower === "g" || event.key === " "
) {
heldDriveKeys.add(event.key === " " ? " " : lower);
if (publishVehicleActions()) {
event.preventDefault();
return;
}
if (publishOfficeWalkActions()) {
event.preventDefault();
return;
}
if (publishAircraftActions()) {
event.preventDefault();
return;
}
if (publishCityActorActions()) {
const control = controlForKey(event.key);
if (control) {
playInput.setDigital("keyboard", control, true);
if (publishPlayActions()) {
event.preventDefault();
return;
}
}
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
if (lower === "p" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("assist");
publishPlayActions();
event.preventDefault();
return;
}
if (lower === "p" && publishAircraftActions({ modeRequest: "assisted" })) {
if (lower === "r" && (routeDriveIsActive() || city?.controlMode() === "aircraft")) {
playInput.request("reset");
publishPlayActions();
event.preventDefault();
return;
}
if (lower === "r" && publishVehicleActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "r" && publishAircraftActions({ reset: true })) {
event.preventDefault();
return;
}
if (lower === "c" && toggleVehicleCamera()) {
if (lower === "c" && routeDriveIsActive()) {
playInput.request("camera");
publishPlayActions();
event.preventDefault();
return;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Renderer-neutral ownership for the one local player and the one local camera.
*
* A mode is deliberately more specific than "play": it names the subsystem
* allowed to consume held input and write the follow camera. Keeping this as a
* small pure state machine lets the DOM, Three.js scene and Journey reducer
* agree on a transition without any of them becoming the source of truth for
* the others.
*/
export type CityControlMode = "overview" | "drive" | "actor" | "aircraft";
export type OfficeControlMode = "office-overview" | "office-walk";
export type ControlMode = CityControlMode | OfficeControlMode;
export interface ControlModeAvailability {
insideOffice: boolean;
drive: boolean;
actor: boolean;
aircraft: boolean;
officeWalk: boolean;
}
export interface ControlModeState {
mode: ControlMode;
/** Increases only for an accepted change, useful to invalidate stale input. */
revision: number;
}
export interface ControlModeTransition {
previous: ControlMode;
state: ControlModeState;
changed: boolean;
accepted: boolean;
reason: "unchanged" | "unavailable" | null;
}
export interface CityControlOwnership {
orbit: boolean;
drive: boolean;
actor: boolean;
aircraft: boolean;
}
/** The renderer consumes this table; exactly one camera writer is always true. */
export function cityControlOwnership(mode: CityControlMode): CityControlOwnership {
return {
orbit: mode === "overview",
drive: mode === "drive",
actor: mode === "actor",
aircraft: mode === "aircraft",
};
}
export function createControlModeState(
mode: ControlMode = "overview",
): ControlModeState {
return { mode, revision: 0 };
}
export function cityControlMode(mode: ControlMode): CityControlMode {
if (mode === "drive" || mode === "actor" || mode === "aircraft") return mode;
return "overview";
}
export function controlModeAvailable(
mode: ControlMode,
availability: Readonly<ControlModeAvailability>,
): boolean {
if (availability.insideOffice) {
return mode === "office-overview" || (mode === "office-walk" && availability.officeWalk);
}
if (mode === "overview") return true;
if (mode === "drive") return availability.drive;
if (mode === "actor") return availability.actor;
if (mode === "aircraft") return availability.aircraft;
return false;
}
/** Resolve one requested transition without performing any side effects. */
export function transitionControlMode(
current: Readonly<ControlModeState>,
requested: ControlMode,
availability: Readonly<ControlModeAvailability>,
): ControlModeTransition {
const fallback: ControlMode = availability.insideOffice ? "office-overview" : "overview";
const next = controlModeAvailable(requested, availability) ? requested : fallback;
const accepted = next === requested;
if (next === current.mode) {
return {
previous: current.mode,
state: { ...current },
changed: false,
accepted,
reason: accepted ? "unchanged" : "unavailable",
};
}
return {
previous: current.mode,
state: { mode: next, revision: current.revision + 1 },
changed: true,
accepted,
reason: accepted ? null : "unavailable",
};
}
+59
View File
@@ -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");
});
});
+112
View File
@@ -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);
});
});
+34
View File
@@ -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);
});
});