@agent-os-lab/agent-game-sdk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/package.json +38 -0
- package/src/core/agent-game-store.ts +110 -0
- package/src/core/agent-service-event-adapter.ts +20 -0
- package/src/core/assets.ts +119 -0
- package/src/core/commands.ts +42 -0
- package/src/core/errors.ts +19 -0
- package/src/core/event-adapter.ts +40 -0
- package/src/core/index.ts +23 -0
- package/src/core/life-presets.ts +54 -0
- package/src/core/movement.ts +50 -0
- package/src/core/office-building-layout.ts +376 -0
- package/src/core/office-layout.ts +152 -0
- package/src/core/pixel-character-avatar.ts +87 -0
- package/src/core/pixel-character.ts +684 -0
- package/src/core/realtime-events.ts +44 -0
- package/src/core/realtime-transport.ts +39 -0
- package/src/core/reducer.ts +105 -0
- package/src/core/scene.ts +144 -0
- package/src/core/schedule.ts +20 -0
- package/src/core/sequence.ts +48 -0
- package/src/core/state.ts +26 -0
- package/src/core/svg-pixel-avatar.ts +372 -0
- package/src/core/town-office-assets.ts +109 -0
- package/src/core/town-office-room-presets.ts +455 -0
- package/src/core/town-office-seat-layout.ts +238 -0
- package/src/graph.ts +112 -0
- package/src/index.ts +2 -0
- package/src/office/core/projection.ts +89 -0
- package/src/office/core/source.ts +46 -0
- package/src/office/core/types.ts +110 -0
- package/src/office/index.ts +4 -0
- package/src/office/mount.ts +104 -0
- package/src/office/react/AgentGameOfficeView.ts +58 -0
- package/src/office/react/index.ts +1 -0
- package/src/office/renderers/three/agent-activity-effects.ts +161 -0
- package/src/office/renderers/three/agent-animation.ts +205 -0
- package/src/office/renderers/three/agent-body-instancing.ts +119 -0
- package/src/office/renderers/three/agent-label.ts +82 -0
- package/src/office/renderers/three/agent-layout.ts +72 -0
- package/src/office/renderers/three/agent-mesh.ts +145 -0
- package/src/office/renderers/three/mount.ts +253 -0
- package/src/office/renderers/three/scene.ts +790 -0
- package/src/phaser/agent-game-scene.ts +87 -0
- package/src/phaser/anchor-debug.ts +22 -0
- package/src/phaser/avatar-registry.ts +46 -0
- package/src/phaser/camera-controls.ts +419 -0
- package/src/phaser/camera-model.ts +81 -0
- package/src/phaser/create-agent-game.ts +242 -0
- package/src/phaser/debug-overlay.ts +21 -0
- package/src/phaser/index.ts +13 -0
- package/src/phaser/movement-tween.ts +59 -0
- package/src/phaser/office-background.ts +48 -0
- package/src/phaser/office-building-renderer.ts +87 -0
- package/src/phaser/office-layout-renderer.ts +58 -0
- package/src/phaser/render-layers.ts +30 -0
- package/src/phaser/scene-reconciler.ts +614 -0
- package/src/phaser/scene-renderer.ts +138 -0
- package/src/phaser/text-style.ts +8 -0
- package/src/phaser/town-office-business-props.ts +256 -0
- package/src/phaser/town-office-environment.ts +89 -0
- package/src/phaser/town-office-furniture.ts +182 -0
- package/src/phaser/town-office-primitives.ts +53 -0
- package/src/phaser/town-office-renderer.ts +429 -0
- package/src/phaser/types.ts +67 -0
- package/src/phaser/viewport.ts +88 -0
- package/src/runtime-client.ts +435 -0
- package/src/types.ts +80 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { AgentAvatarDefinition, AgentGameSceneDefinition, OfficeBuildingLayout, OfficeLayoutDefinition } from "../core/index.js";
|
|
2
|
+
import { drawAnchorDebug } from "./anchor-debug.js";
|
|
3
|
+
import { registerAgentAvatarAnimations } from "./avatar-registry.js";
|
|
4
|
+
import {
|
|
5
|
+
installCameraControls,
|
|
6
|
+
type AgentGameCameraControlsController,
|
|
7
|
+
type AgentGameCameraControlsOptions,
|
|
8
|
+
} from "./camera-controls.js";
|
|
9
|
+
import type { AgentGameDebugOverlays } from "./debug-overlay.js";
|
|
10
|
+
import {
|
|
11
|
+
createDefaultAgentGameSceneRendererRegistry,
|
|
12
|
+
type AgentGameRendererConfig,
|
|
13
|
+
type AgentGameSceneRendererRegistry,
|
|
14
|
+
} from "./scene-renderer.js";
|
|
15
|
+
import type { AgentGameSceneReconciler, PhaserReconcilerSceneLike } from "./scene-reconciler.js";
|
|
16
|
+
import type { TownOfficeDomOverlayRoot } from "./town-office-renderer.js";
|
|
17
|
+
|
|
18
|
+
export const AGENT_GAME_PHASER_SCENE_KEY = "agent-game-scene";
|
|
19
|
+
|
|
20
|
+
export type AgentGamePhaserSceneConfig = {
|
|
21
|
+
key?: string;
|
|
22
|
+
definition: AgentGameSceneDefinition;
|
|
23
|
+
avatars: AgentAvatarDefinition[];
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
backgroundImage?: {
|
|
27
|
+
key: string;
|
|
28
|
+
url: string;
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
};
|
|
32
|
+
officeLayout?: {
|
|
33
|
+
atlasKey: string;
|
|
34
|
+
imageUrl: string;
|
|
35
|
+
atlasUrl: string;
|
|
36
|
+
definition: OfficeLayoutDefinition;
|
|
37
|
+
};
|
|
38
|
+
officeBuilding?: {
|
|
39
|
+
atlasKey: string;
|
|
40
|
+
imageUrl: string;
|
|
41
|
+
atlasUrl: string;
|
|
42
|
+
layout: OfficeBuildingLayout;
|
|
43
|
+
};
|
|
44
|
+
townOfficeBuilding?: {
|
|
45
|
+
layout: OfficeBuildingLayout;
|
|
46
|
+
};
|
|
47
|
+
renderer: AgentGameRendererConfig;
|
|
48
|
+
rendererRegistry?: AgentGameSceneRendererRegistry;
|
|
49
|
+
domOverlayRoot?: TownOfficeDomOverlayRoot;
|
|
50
|
+
debugOverlays?: AgentGameDebugOverlays;
|
|
51
|
+
showAnchorDebug?: boolean;
|
|
52
|
+
cameraControls?: AgentGameCameraControlsOptions;
|
|
53
|
+
movementDurationMs?: number;
|
|
54
|
+
onCameraControlsReady?: (controls: AgentGameCameraControlsController | null) => void;
|
|
55
|
+
onCreate?: (scene: PhaserReconcilerSceneLike) => AgentGameSceneReconciler;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createAgentGamePhaserSceneConfig(config: AgentGamePhaserSceneConfig) {
|
|
59
|
+
const rendererRegistry = config.rendererRegistry ?? createDefaultAgentGameSceneRendererRegistry();
|
|
60
|
+
const renderer = rendererRegistry.create(config.renderer.kind, {
|
|
61
|
+
...(typeof config.renderer.options === "object" && config.renderer.options ? config.renderer.options : {}),
|
|
62
|
+
domOverlayRoot: config.domOverlayRoot,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
key: config.key ?? AGENT_GAME_PHASER_SCENE_KEY,
|
|
67
|
+
preload(this: PhaserReconcilerSceneLike) {
|
|
68
|
+
renderer.preload?.(this);
|
|
69
|
+
for (const avatar of config.avatars) {
|
|
70
|
+
this.load?.atlas(avatar.id, avatar.imageUrl, avatar.atlasUrl);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
create(this: PhaserReconcilerSceneLike) {
|
|
74
|
+
for (const avatar of config.avatars) {
|
|
75
|
+
registerAgentAvatarAnimations(this, avatar);
|
|
76
|
+
}
|
|
77
|
+
renderer.create(this);
|
|
78
|
+
if (config.debugOverlays?.anchors || config.showAnchorDebug) {
|
|
79
|
+
drawAnchorDebug(this, config.definition);
|
|
80
|
+
}
|
|
81
|
+
if (config.cameraControls) {
|
|
82
|
+
config.onCameraControlsReady?.(installCameraControls(this, config.cameraControls));
|
|
83
|
+
}
|
|
84
|
+
config.onCreate?.(this);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentGameSceneDefinition } from "../core/index.js";
|
|
2
|
+
import { debugDepth } from "./render-layers.js";
|
|
3
|
+
import type { PhaserDisplayObjectLike, PhaserTextLike } from "./scene-reconciler.js";
|
|
4
|
+
import { crispTextStyle } from "./text-style.js";
|
|
5
|
+
|
|
6
|
+
export type AnchorDebugSceneLike = {
|
|
7
|
+
add: {
|
|
8
|
+
rectangle: (x: number, y: number, width: number, height: number, fillColor: number) => PhaserDisplayObjectLike;
|
|
9
|
+
text: (x: number, y: number, text: string, style?: Record<string, unknown>) => PhaserTextLike;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function drawAnchorDebug(scene: AnchorDebugSceneLike, definition: AgentGameSceneDefinition): void {
|
|
14
|
+
for (const anchor of definition.anchors) {
|
|
15
|
+
scene.add.rectangle(anchor.x, anchor.y, 8, 8, 0xf97316).setDepth?.(debugDepth(10));
|
|
16
|
+
scene.add.text(anchor.x + 8, anchor.y + 6, anchor.id, crispTextStyle({
|
|
17
|
+
color: "#f8fafc",
|
|
18
|
+
fontSize: "10px",
|
|
19
|
+
fontFamily: "monospace",
|
|
20
|
+
})).setDepth?.(debugDepth(11));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentGameError,
|
|
3
|
+
REQUIRED_AGENT_AVATAR_ANIMATIONS,
|
|
4
|
+
normalizeAgentAvatarAnimationFrames,
|
|
5
|
+
validateAgentAvatarDefinition,
|
|
6
|
+
type AgentAvatarDefinition,
|
|
7
|
+
type AgentAvatarAnimationName,
|
|
8
|
+
} from "../core/index.js";
|
|
9
|
+
|
|
10
|
+
export type PhaserAnimationSceneLike = {
|
|
11
|
+
textures?: {
|
|
12
|
+
getFrame?: (textureKey: string, frame: string) => unknown;
|
|
13
|
+
};
|
|
14
|
+
anims?: {
|
|
15
|
+
create: (config: {
|
|
16
|
+
key: string;
|
|
17
|
+
frames: Array<{ key: string; frame: string }>;
|
|
18
|
+
frameRate: number;
|
|
19
|
+
repeat: number;
|
|
20
|
+
}) => unknown;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createAgentAvatarAnimationKey(avatarId: string, animation: AgentAvatarAnimationName): string {
|
|
25
|
+
return `${avatarId}:${animation}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function registerAgentAvatarAnimations(scene: PhaserAnimationSceneLike, avatar: AgentAvatarDefinition): void {
|
|
29
|
+
validateAgentAvatarDefinition(avatar);
|
|
30
|
+
|
|
31
|
+
for (const animation of REQUIRED_AGENT_AVATAR_ANIMATIONS) {
|
|
32
|
+
const frames = normalizeAgentAvatarAnimationFrames(avatar.animations[animation]);
|
|
33
|
+
for (const frame of frames) {
|
|
34
|
+
if (scene.textures?.getFrame && !scene.textures.getFrame(avatar.id, frame)) {
|
|
35
|
+
throw new AgentGameError("missing_animation", `Agent game avatar ${avatar.id} is missing frame: ${frame}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
scene.anims?.create({
|
|
40
|
+
key: createAgentAvatarAnimationKey(avatar.id, animation),
|
|
41
|
+
frames: frames.map((frame) => ({ key: avatar.id, frame })),
|
|
42
|
+
frameRate: animation.startsWith("walk.") ? 8 : 4,
|
|
43
|
+
repeat: -1,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampCameraScrollValue,
|
|
3
|
+
dragCamera,
|
|
4
|
+
getCameraScrollBounds,
|
|
5
|
+
snapCameraScrollToPixel,
|
|
6
|
+
zoomAroundPoint,
|
|
7
|
+
} from "./camera-model.js";
|
|
8
|
+
|
|
9
|
+
export type AgentGameCameraControlsOptions = {
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
worldWidth: number;
|
|
12
|
+
worldHeight: number;
|
|
13
|
+
initialZoom?: number | "fit-width";
|
|
14
|
+
minZoom?: number;
|
|
15
|
+
maxZoom?: number;
|
|
16
|
+
zoomStep?: number;
|
|
17
|
+
zoomLevels?: number[];
|
|
18
|
+
wheelZoom?: boolean;
|
|
19
|
+
centerWhenViewLarger?: boolean;
|
|
20
|
+
onZoomChange?: (zoom: number) => void;
|
|
21
|
+
onViewChange?: (view: AgentGameCameraViewState) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AgentGameCameraControlsController = {
|
|
25
|
+
getZoom(): number;
|
|
26
|
+
zoomIn(): void;
|
|
27
|
+
zoomOut(): void;
|
|
28
|
+
setZoom(zoom: number): void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type AgentGameCameraViewState = {
|
|
32
|
+
zoom: number;
|
|
33
|
+
scrollX: number;
|
|
34
|
+
scrollY: number;
|
|
35
|
+
visibleWidth: number;
|
|
36
|
+
visibleHeight: number;
|
|
37
|
+
bounds: {
|
|
38
|
+
minX: number;
|
|
39
|
+
maxX: number;
|
|
40
|
+
minY: number;
|
|
41
|
+
maxY: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type PhaserCameraControlsSceneLike = {
|
|
46
|
+
cameras?: {
|
|
47
|
+
main?: PhaserCameraLike;
|
|
48
|
+
};
|
|
49
|
+
input?: {
|
|
50
|
+
on: (eventName: string, handler: (...args: any[]) => void) => unknown;
|
|
51
|
+
};
|
|
52
|
+
game?: {
|
|
53
|
+
canvas?: HTMLCanvasElement;
|
|
54
|
+
};
|
|
55
|
+
sys?: {
|
|
56
|
+
game?: {
|
|
57
|
+
canvas?: HTMLCanvasElement;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type PhaserCameraLike = {
|
|
63
|
+
zoom: number;
|
|
64
|
+
scrollX: number;
|
|
65
|
+
scrollY: number;
|
|
66
|
+
width?: number;
|
|
67
|
+
height?: number;
|
|
68
|
+
setBounds?: (x: number, y: number, width: number, height: number) => unknown;
|
|
69
|
+
setOrigin?: (x: number, y: number) => unknown;
|
|
70
|
+
setZoom?: (zoom: number) => unknown;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type PointerLike = {
|
|
74
|
+
x: number;
|
|
75
|
+
y: number;
|
|
76
|
+
isDown?: boolean;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function installCameraControls(
|
|
80
|
+
scene: PhaserCameraControlsSceneLike,
|
|
81
|
+
options: AgentGameCameraControlsOptions,
|
|
82
|
+
): AgentGameCameraControlsController | null {
|
|
83
|
+
if (options.enabled === false) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const camera = scene.cameras?.main;
|
|
88
|
+
if (!camera || !scene.input) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const minZoom = options.minZoom ?? 0.5;
|
|
93
|
+
const maxZoom = options.maxZoom ?? 2;
|
|
94
|
+
const zoomStep = options.zoomStep ?? 0.1;
|
|
95
|
+
camera.setOrigin?.(0, 0);
|
|
96
|
+
camera.setZoom?.(resolveInitialZoom(camera, options, minZoom, maxZoom));
|
|
97
|
+
clampCameraScroll(camera, options.worldWidth, options.worldHeight);
|
|
98
|
+
centerCameraIfViewLarger(camera, options);
|
|
99
|
+
snapCameraScroll(camera);
|
|
100
|
+
emitCameraView(camera, options);
|
|
101
|
+
|
|
102
|
+
const controls: AgentGameCameraControlsController = {
|
|
103
|
+
getZoom() {
|
|
104
|
+
return camera.zoom;
|
|
105
|
+
},
|
|
106
|
+
zoomIn() {
|
|
107
|
+
zoomCamera(camera, options, getNextZoom(camera.zoom, 1, {
|
|
108
|
+
minZoom,
|
|
109
|
+
maxZoom,
|
|
110
|
+
zoomStep,
|
|
111
|
+
zoomLevels: options.zoomLevels,
|
|
112
|
+
}));
|
|
113
|
+
},
|
|
114
|
+
zoomOut() {
|
|
115
|
+
zoomCamera(camera, options, getNextZoom(camera.zoom, -1, {
|
|
116
|
+
minZoom,
|
|
117
|
+
maxZoom,
|
|
118
|
+
zoomStep,
|
|
119
|
+
zoomLevels: options.zoomLevels,
|
|
120
|
+
}));
|
|
121
|
+
},
|
|
122
|
+
setZoom(zoom: number) {
|
|
123
|
+
zoomCamera(camera, options, clamp(zoom, minZoom, maxZoom));
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
let dragStart: { x: number; y: number; scrollX: number; scrollY: number } | null = null;
|
|
128
|
+
let windowMoveHandler: ((event: PointerEvent) => void) | null = null;
|
|
129
|
+
let windowUpHandler: (() => void) | null = null;
|
|
130
|
+
|
|
131
|
+
if (options.wheelZoom !== false) {
|
|
132
|
+
scene.input.on("wheel", (pointer: PointerLike, _gameObjects: unknown, _deltaX: number, deltaY: number) => {
|
|
133
|
+
const direction = deltaY < 0 ? 1 : -1;
|
|
134
|
+
const newZoom = getNextZoom(camera.zoom, direction, {
|
|
135
|
+
minZoom,
|
|
136
|
+
maxZoom,
|
|
137
|
+
zoomStep,
|
|
138
|
+
zoomLevels: options.zoomLevels,
|
|
139
|
+
});
|
|
140
|
+
zoomCamera(camera, options, newZoom, getZoomAnchor(pointer, camera));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
scene.input.on("pointerdown", (pointer: PointerLike) => {
|
|
145
|
+
dragStart = {
|
|
146
|
+
x: pointer.x,
|
|
147
|
+
y: pointer.y,
|
|
148
|
+
scrollX: camera.scrollX,
|
|
149
|
+
scrollY: camera.scrollY,
|
|
150
|
+
};
|
|
151
|
+
installWindowDragHandlers(scene, camera, options, () => dragStart, (point) => {
|
|
152
|
+
updateDrag(camera, options, dragStart, point);
|
|
153
|
+
}, () => {
|
|
154
|
+
dragStart = null;
|
|
155
|
+
uninstallWindowDragHandlers(windowMoveHandler, windowUpHandler);
|
|
156
|
+
windowMoveHandler = null;
|
|
157
|
+
windowUpHandler = null;
|
|
158
|
+
}, (moveHandler, upHandler) => {
|
|
159
|
+
windowMoveHandler = moveHandler;
|
|
160
|
+
windowUpHandler = upHandler;
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
scene.input.on("pointermove", (pointer: PointerLike) => {
|
|
165
|
+
if (!dragStart || pointer.isDown === false) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
updateDrag(camera, options, dragStart, pointer);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const stopDrag = () => {
|
|
172
|
+
dragStart = null;
|
|
173
|
+
uninstallWindowDragHandlers(windowMoveHandler, windowUpHandler);
|
|
174
|
+
windowMoveHandler = null;
|
|
175
|
+
windowUpHandler = null;
|
|
176
|
+
};
|
|
177
|
+
scene.input.on("pointerup", stopDrag);
|
|
178
|
+
scene.input.on("pointerupoutside", stopDrag);
|
|
179
|
+
|
|
180
|
+
return controls;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function zoomCamera(
|
|
184
|
+
camera: PhaserCameraLike,
|
|
185
|
+
options: AgentGameCameraControlsOptions,
|
|
186
|
+
targetZoom: number,
|
|
187
|
+
anchor = getZoomAnchor(undefined, camera),
|
|
188
|
+
): void {
|
|
189
|
+
const zoomed = zoomAroundPoint({
|
|
190
|
+
scrollX: camera.scrollX,
|
|
191
|
+
scrollY: camera.scrollY,
|
|
192
|
+
zoom: camera.zoom,
|
|
193
|
+
targetZoom,
|
|
194
|
+
anchorX: anchor.x,
|
|
195
|
+
anchorY: anchor.y,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
camera.setZoom?.(zoomed.zoom);
|
|
199
|
+
camera.scrollX = zoomed.scrollX;
|
|
200
|
+
camera.scrollY = zoomed.scrollY;
|
|
201
|
+
clampCameraScroll(camera, options.worldWidth, options.worldHeight);
|
|
202
|
+
centerCameraIfViewLarger(camera, options);
|
|
203
|
+
snapCameraScroll(camera);
|
|
204
|
+
emitCameraView(camera, options);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function updateDrag(
|
|
208
|
+
camera: PhaserCameraLike,
|
|
209
|
+
options: AgentGameCameraControlsOptions,
|
|
210
|
+
dragStart: { x: number; y: number; scrollX: number; scrollY: number } | null,
|
|
211
|
+
point: { x: number; y: number },
|
|
212
|
+
): void {
|
|
213
|
+
if (!dragStart) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const next = dragCamera({
|
|
218
|
+
startX: dragStart.x,
|
|
219
|
+
startY: dragStart.y,
|
|
220
|
+
currentX: point.x,
|
|
221
|
+
currentY: point.y,
|
|
222
|
+
startScrollX: dragStart.scrollX,
|
|
223
|
+
startScrollY: dragStart.scrollY,
|
|
224
|
+
zoom: camera.zoom,
|
|
225
|
+
xBounds: getCameraScrollBounds(options.worldWidth, camera.width ?? 0, camera.zoom),
|
|
226
|
+
yBounds: getCameraScrollBounds(options.worldHeight, camera.height ?? 0, camera.zoom),
|
|
227
|
+
});
|
|
228
|
+
camera.scrollX = next.scrollX;
|
|
229
|
+
camera.scrollY = next.scrollY;
|
|
230
|
+
snapCameraScroll(camera);
|
|
231
|
+
emitCameraView(camera, options);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function installWindowDragHandlers(
|
|
235
|
+
scene: PhaserCameraControlsSceneLike,
|
|
236
|
+
camera: PhaserCameraLike,
|
|
237
|
+
options: AgentGameCameraControlsOptions,
|
|
238
|
+
getDragStart: () => { x: number; y: number; scrollX: number; scrollY: number } | null,
|
|
239
|
+
update: (point: { x: number; y: number }) => void,
|
|
240
|
+
stop: () => void,
|
|
241
|
+
setHandlers: (moveHandler: (event: PointerEvent) => void, upHandler: () => void) => void,
|
|
242
|
+
): void {
|
|
243
|
+
if (!globalThis.window || !globalThis.window.addEventListener) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const canvas = getSceneCanvas(scene);
|
|
248
|
+
if (!canvas) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const moveHandler = (event: PointerEvent) => {
|
|
253
|
+
if (!getDragStart()) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
update(clientPointToGamePoint(event.clientX, event.clientY, canvas, camera));
|
|
257
|
+
};
|
|
258
|
+
const upHandler = () => {
|
|
259
|
+
stop();
|
|
260
|
+
};
|
|
261
|
+
setHandlers(moveHandler, upHandler);
|
|
262
|
+
globalThis.window.addEventListener("pointermove", moveHandler);
|
|
263
|
+
globalThis.window.addEventListener("pointerup", upHandler);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function uninstallWindowDragHandlers(
|
|
267
|
+
moveHandler: ((event: PointerEvent) => void) | null,
|
|
268
|
+
upHandler: (() => void) | null,
|
|
269
|
+
): void {
|
|
270
|
+
if (!globalThis.window || !globalThis.window.removeEventListener) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (moveHandler) {
|
|
274
|
+
globalThis.window.removeEventListener("pointermove", moveHandler);
|
|
275
|
+
}
|
|
276
|
+
if (upHandler) {
|
|
277
|
+
globalThis.window.removeEventListener("pointerup", upHandler);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getSceneCanvas(scene: PhaserCameraControlsSceneLike): HTMLCanvasElement | undefined {
|
|
282
|
+
return scene.game?.canvas ?? scene.sys?.game?.canvas;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function clientPointToGamePoint(
|
|
286
|
+
clientX: number,
|
|
287
|
+
clientY: number,
|
|
288
|
+
canvas: HTMLCanvasElement,
|
|
289
|
+
camera: PhaserCameraLike,
|
|
290
|
+
): { x: number; y: number } {
|
|
291
|
+
const bounds = canvas.getBoundingClientRect();
|
|
292
|
+
const width = camera.width ?? bounds.width;
|
|
293
|
+
const height = camera.height ?? bounds.height;
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
x: bounds.width > 0 ? (clientX - bounds.left) * width / bounds.width : clientX,
|
|
297
|
+
y: bounds.height > 0 ? (clientY - bounds.top) * height / bounds.height : clientY,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function clamp(value: number, min: number, max: number): number {
|
|
302
|
+
return Math.min(max, Math.max(min, value));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveInitialZoom(
|
|
306
|
+
camera: PhaserCameraLike,
|
|
307
|
+
options: AgentGameCameraControlsOptions,
|
|
308
|
+
minZoom: number,
|
|
309
|
+
maxZoom: number,
|
|
310
|
+
): number {
|
|
311
|
+
if (options.initialZoom === "fit-width") {
|
|
312
|
+
const width = camera.width ?? 0;
|
|
313
|
+
if (width > 0 && options.worldWidth > 0) {
|
|
314
|
+
return clamp(width / options.worldWidth, minZoom, maxZoom);
|
|
315
|
+
}
|
|
316
|
+
return clamp(camera.zoom ?? 1, minZoom, maxZoom);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return clamp(options.initialZoom ?? camera.zoom ?? 1, minZoom, maxZoom);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function getNextZoom(
|
|
323
|
+
currentZoom: number,
|
|
324
|
+
direction: number,
|
|
325
|
+
options: {
|
|
326
|
+
minZoom: number;
|
|
327
|
+
maxZoom: number;
|
|
328
|
+
zoomStep: number;
|
|
329
|
+
zoomLevels?: number[];
|
|
330
|
+
},
|
|
331
|
+
): number {
|
|
332
|
+
const levels = normalizeZoomLevels(options.zoomLevels, options.minZoom, options.maxZoom);
|
|
333
|
+
if (levels.length > 0) {
|
|
334
|
+
if (direction > 0) {
|
|
335
|
+
return levels.find((level) => level > currentZoom + Number.EPSILON) ?? levels.at(-1)!;
|
|
336
|
+
}
|
|
337
|
+
return [...levels].reverse().find((level) => level < currentZoom - Number.EPSILON) ?? levels[0]!;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return clamp(currentZoom + direction * options.zoomStep, options.minZoom, options.maxZoom);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function normalizeZoomLevels(levels: number[] | undefined, minZoom: number, maxZoom: number): number[] {
|
|
344
|
+
return [...new Set((levels ?? [])
|
|
345
|
+
.filter((level) => Number.isFinite(level) && level >= minZoom && level <= maxZoom))]
|
|
346
|
+
.sort((a, b) => a - b);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function getZoomAnchor(pointer: PointerLike | undefined, camera: PhaserCameraLike): { x: number; y: number } {
|
|
350
|
+
return {
|
|
351
|
+
x: Number.isFinite(pointer?.x) ? pointer!.x : (camera.width ?? 0) / 2,
|
|
352
|
+
y: Number.isFinite(pointer?.y) ? pointer!.y : (camera.height ?? 0) / 2,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function clampScroll(value: number, worldSize: number, viewportSize: number | undefined, zoom: number): number {
|
|
357
|
+
if (!viewportSize || viewportSize <= 0) {
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return clampCameraScrollValue(value, getCameraScrollBounds(worldSize, viewportSize, zoom));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function clampCameraScroll(camera: PhaserCameraLike, worldWidth: number, worldHeight: number): void {
|
|
365
|
+
camera.scrollX = clampScroll(camera.scrollX, worldWidth, camera.width, camera.zoom);
|
|
366
|
+
camera.scrollY = clampScroll(camera.scrollY, worldHeight, camera.height, camera.zoom);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function snapCameraScroll(camera: PhaserCameraLike): void {
|
|
370
|
+
camera.scrollX = snapCameraScrollToPixel(camera.scrollX, camera.zoom);
|
|
371
|
+
camera.scrollY = snapCameraScrollToPixel(camera.scrollY, camera.zoom);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function centerCameraIfViewLarger(camera: PhaserCameraLike, options: AgentGameCameraControlsOptions): void {
|
|
375
|
+
if (!options.centerWhenViewLarger) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const width = camera.width ?? 0;
|
|
380
|
+
const height = camera.height ?? 0;
|
|
381
|
+
if (width > 0 && width / camera.zoom > options.worldWidth) {
|
|
382
|
+
const bounds = getCameraScrollBounds(options.worldWidth, width, camera.zoom);
|
|
383
|
+
camera.scrollX = (bounds.min + bounds.max) / 2;
|
|
384
|
+
}
|
|
385
|
+
if (height > 0 && height / camera.zoom > options.worldHeight) {
|
|
386
|
+
const bounds = getCameraScrollBounds(options.worldHeight, height, camera.zoom);
|
|
387
|
+
camera.scrollY = (bounds.min + bounds.max) / 2;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function emitCameraView(camera: PhaserCameraLike, options: AgentGameCameraControlsOptions): void {
|
|
392
|
+
options.onZoomChange?.(camera.zoom);
|
|
393
|
+
options.onViewChange?.(createCameraViewState(camera, options.worldWidth, options.worldHeight));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createCameraViewState(
|
|
397
|
+
camera: PhaserCameraLike,
|
|
398
|
+
worldWidth: number,
|
|
399
|
+
worldHeight: number,
|
|
400
|
+
): AgentGameCameraViewState {
|
|
401
|
+
const visibleWidth = camera.width && camera.width > 0 ? camera.width / camera.zoom : 0;
|
|
402
|
+
const visibleHeight = camera.height && camera.height > 0 ? camera.height / camera.zoom : 0;
|
|
403
|
+
const xBounds = getCameraScrollBounds(worldWidth, camera.width ?? 0, camera.zoom);
|
|
404
|
+
const yBounds = getCameraScrollBounds(worldHeight, camera.height ?? 0, camera.zoom);
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
zoom: camera.zoom,
|
|
408
|
+
scrollX: camera.scrollX,
|
|
409
|
+
scrollY: camera.scrollY,
|
|
410
|
+
visibleWidth,
|
|
411
|
+
visibleHeight,
|
|
412
|
+
bounds: {
|
|
413
|
+
minX: xBounds.min,
|
|
414
|
+
maxX: xBounds.max,
|
|
415
|
+
minY: yBounds.min,
|
|
416
|
+
maxY: yBounds.max,
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type CameraScrollBounds = {
|
|
2
|
+
min: number;
|
|
3
|
+
max: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ZoomAroundPointInput = {
|
|
7
|
+
scrollX: number;
|
|
8
|
+
scrollY: number;
|
|
9
|
+
zoom: number;
|
|
10
|
+
targetZoom: number;
|
|
11
|
+
anchorX: number;
|
|
12
|
+
anchorY: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type DragCameraInput = {
|
|
16
|
+
startX: number;
|
|
17
|
+
startY: number;
|
|
18
|
+
currentX: number;
|
|
19
|
+
currentY: number;
|
|
20
|
+
startScrollX: number;
|
|
21
|
+
startScrollY: number;
|
|
22
|
+
zoom: number;
|
|
23
|
+
xBounds: CameraScrollBounds;
|
|
24
|
+
yBounds: CameraScrollBounds;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function getCameraScrollBounds(worldSize: number, viewportSize: number, zoom: number): CameraScrollBounds {
|
|
28
|
+
if (viewportSize <= 0) {
|
|
29
|
+
return { min: 0, max: worldSize };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const visibleWorldSize = viewportSize / zoom;
|
|
33
|
+
const edgeAlignedScroll = worldSize - visibleWorldSize;
|
|
34
|
+
return {
|
|
35
|
+
min: Math.min(0, edgeAlignedScroll),
|
|
36
|
+
max: Math.max(0, edgeAlignedScroll),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clampCameraScrollValue(value: number, bounds: CameraScrollBounds): number {
|
|
41
|
+
return Math.min(bounds.max, Math.max(bounds.min, value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getWorldOriginScreenPosition(scrollX: number, scrollY: number, zoom: number): { x: number; y: number } {
|
|
45
|
+
return {
|
|
46
|
+
x: -scrollX * zoom,
|
|
47
|
+
y: -scrollY * zoom,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function snapCameraScrollToPixel(scroll: number, zoom: number): number {
|
|
52
|
+
if (!Number.isFinite(scroll) || !Number.isFinite(zoom) || zoom <= 0) {
|
|
53
|
+
return scroll;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Math.round(scroll * zoom) / zoom;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function zoomAroundPoint(input: ZoomAroundPointInput): { zoom: number; scrollX: number; scrollY: number } {
|
|
60
|
+
const anchorWorldX = input.scrollX + input.anchorX / input.zoom;
|
|
61
|
+
const anchorWorldY = input.scrollY + input.anchorY / input.zoom;
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
zoom: input.targetZoom,
|
|
65
|
+
scrollX: anchorWorldX - input.anchorX / input.targetZoom,
|
|
66
|
+
scrollY: anchorWorldY - input.anchorY / input.targetZoom,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function dragCamera(input: DragCameraInput): { scrollX: number; scrollY: number } {
|
|
71
|
+
return {
|
|
72
|
+
scrollX: clampCameraScrollValue(
|
|
73
|
+
input.startScrollX + (input.startX - input.currentX) / input.zoom,
|
|
74
|
+
input.xBounds,
|
|
75
|
+
),
|
|
76
|
+
scrollY: clampCameraScrollValue(
|
|
77
|
+
input.startScrollY + (input.startY - input.currentY) / input.zoom,
|
|
78
|
+
input.yBounds,
|
|
79
|
+
),
|
|
80
|
+
};
|
|
81
|
+
}
|