feat: unify life-sim play controls
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user