castle-web-cli 0.4.0 → 0.4.2
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/dist/api.d.ts +53 -5
- package/dist/api.js +42 -15
- package/dist/config.d.ts +2 -0
- package/dist/config.js +25 -11
- package/dist/get-deck.d.ts +3 -0
- package/dist/get-deck.js +64 -0
- package/dist/ide-client.d.ts +1 -0
- package/dist/ide-client.js +537 -0
- package/dist/ide.d.ts +16 -0
- package/dist/ide.js +546 -0
- package/dist/index.js +84 -57
- package/dist/init.d.ts +3 -1
- package/dist/init.js +170 -24
- package/dist/localPaths.d.ts +6 -0
- package/dist/localPaths.js +33 -0
- package/dist/login.js +1 -1
- package/dist/preview.d.ts +4 -1
- package/dist/preview.js +63 -41
- package/dist/save-deck.d.ts +2 -0
- package/dist/{push.js → save-deck.js} +66 -5
- package/dist/serve.d.ts +2 -0
- package/dist/serve.js +293 -22
- package/kits/basic-2d/.prettierrc +8 -0
- package/kits/basic-2d/CLAUDE.md +131 -0
- package/kits/basic-2d/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d/drawings/floor.drawing +70 -0
- package/kits/basic-2d/editors/App.jsx +152 -0
- package/kits/basic-2d/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d/editors/editorHistory.js +52 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d/engine/files.js +62 -0
- package/kits/basic-2d/engine/scene.js +420 -0
- package/kits/basic-2d/engine/ui.jsx +344 -0
- package/kits/basic-2d/engine/ui.module.css +928 -0
- package/kits/basic-2d/eslint.config.js +50 -0
- package/kits/basic-2d/index.html +11 -0
- package/kits/basic-2d/main.jsx +10 -0
- package/kits/basic-2d/package-lock.json +2706 -0
- package/kits/basic-2d/package.json +41 -0
- package/kits/basic-2d/scenes/main.scene +108 -0
- package/kits/basic-2d/vite.config.js +1 -0
- package/kits/basic-2d-frozen/.prettierrc +8 -0
- package/kits/basic-2d-frozen/CLAUDE.md +131 -0
- package/kits/basic-2d-frozen/behaviors/Camera.jsx +43 -0
- package/kits/basic-2d-frozen/behaviors/Collider.jsx +71 -0
- package/kits/basic-2d-frozen/behaviors/Drawing.jsx +139 -0
- package/kits/basic-2d-frozen/behaviors/Layout.jsx +16 -0
- package/kits/basic-2d-frozen/drawings/floor.drawing +70 -0
- package/kits/basic-2d-frozen/editors/App.jsx +152 -0
- package/kits/basic-2d-frozen/editors/CodeEditor.jsx +112 -0
- package/kits/basic-2d-frozen/editors/DrawingEditor.jsx +222 -0
- package/kits/basic-2d-frozen/editors/FileBrowser.jsx +143 -0
- package/kits/basic-2d-frozen/editors/PlayOnly.jsx +21 -0
- package/kits/basic-2d-frozen/editors/SceneEditor.jsx +1012 -0
- package/kits/basic-2d-frozen/editors/behaviorRegistry.js +24 -0
- package/kits/basic-2d-frozen/editors/editorHistory.js +52 -0
- package/kits/basic-2d-frozen/engine/ScenePlayer.jsx +83 -0
- package/kits/basic-2d-frozen/engine/SceneUI.jsx +67 -0
- package/kits/basic-2d-frozen/engine/TouchControls.jsx +136 -0
- package/kits/basic-2d-frozen/engine/autoInspector.jsx +51 -0
- package/kits/basic-2d-frozen/engine/files.js +62 -0
- package/kits/basic-2d-frozen/engine/scene.js +420 -0
- package/kits/basic-2d-frozen/engine/ui.jsx +344 -0
- package/kits/basic-2d-frozen/engine/ui.module.css +928 -0
- package/kits/basic-2d-frozen/eslint.config.js +50 -0
- package/kits/basic-2d-frozen/index.html +11 -0
- package/kits/basic-2d-frozen/main.jsx +10 -0
- package/kits/basic-2d-frozen/package-lock.json +2706 -0
- package/kits/basic-2d-frozen/package.json +41 -0
- package/kits/basic-2d-frozen/scenes/main.scene +108 -0
- package/kits/basic-2d-frozen/vite.config.js +1 -0
- package/kits/rpg-2d/.prettierrc +8 -0
- package/kits/rpg-2d/behaviors/Camera.tsx +52 -0
- package/kits/rpg-2d/behaviors/Collider.tsx +98 -0
- package/kits/rpg-2d/behaviors/Dialog.tsx +184 -0
- package/kits/rpg-2d/behaviors/Drawing.tsx +161 -0
- package/kits/rpg-2d/behaviors/Friend.tsx +45 -0
- package/kits/rpg-2d/behaviors/Layout.tsx +29 -0
- package/kits/rpg-2d/behaviors/PlayerController.tsx +255 -0
- package/kits/rpg-2d/behaviors/Portal.tsx +60 -0
- package/kits/rpg-2d/behaviors/QuestLog.tsx +90 -0
- package/kits/rpg-2d/behaviors/SaveMenu.tsx +123 -0
- package/kits/rpg-2d/behaviors/Tilemap.tsx +90 -0
- package/kits/rpg-2d/drawings/bld-home.drawing +8136 -0
- package/kits/rpg-2d/drawings/env-crate.drawing +509 -0
- package/kits/rpg-2d/drawings/env-fence.drawing +536 -0
- package/kits/rpg-2d/drawings/env-flower-bed.drawing +607 -0
- package/kits/rpg-2d/drawings/env-fountain.drawing +2622 -0
- package/kits/rpg-2d/drawings/env-hedge.drawing +601 -0
- package/kits/rpg-2d/drawings/env-house-blue.drawing +1 -0
- package/kits/rpg-2d/drawings/env-house-green.drawing +1 -0
- package/kits/rpg-2d/drawings/env-tree-oak.drawing +1540 -0
- package/kits/rpg-2d/drawings/env-tree-pine.drawing +1315 -0
- package/kits/rpg-2d/drawings/floor.drawing +70 -0
- package/kits/rpg-2d/drawings/fx-sparkle.drawing +926 -0
- package/kits/rpg-2d/drawings/npc-juno-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-juno-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/npc-opal-idle-down.drawing +1099 -0
- package/kits/rpg-2d/drawings/npc-opal-walk-down.drawing +4177 -0
- package/kits/rpg-2d/drawings/player-idle-down.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-left.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-right.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-idle-up.drawing +1070 -0
- package/kits/rpg-2d/drawings/player-walk-down.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-left.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-right.drawing +4148 -0
- package/kits/rpg-2d/drawings/player-walk-up.drawing +4148 -0
- package/kits/rpg-2d/editors/App.tsx +163 -0
- package/kits/rpg-2d/editors/CodeEditor.tsx +120 -0
- package/kits/rpg-2d/editors/DrawingEditor.tsx +278 -0
- package/kits/rpg-2d/editors/FileBrowser.tsx +191 -0
- package/kits/rpg-2d/editors/PlayOnly.tsx +26 -0
- package/kits/rpg-2d/editors/SceneEditor.tsx +1093 -0
- package/kits/rpg-2d/editors/behaviorRegistry.ts +33 -0
- package/kits/rpg-2d/editors/editorHistory.ts +75 -0
- package/kits/rpg-2d/editors/editorProps.ts +10 -0
- package/kits/rpg-2d/engine/ScenePlayer.tsx +130 -0
- package/kits/rpg-2d/engine/SceneUI.tsx +74 -0
- package/kits/rpg-2d/engine/TouchControls.tsx +157 -0
- package/kits/rpg-2d/engine/autoInspector.tsx +111 -0
- package/kits/rpg-2d/engine/drawing.ts +81 -0
- package/kits/rpg-2d/engine/files.ts +215 -0
- package/kits/rpg-2d/engine/scene.ts +484 -0
- package/kits/rpg-2d/engine/ui.module.css +928 -0
- package/kits/rpg-2d/engine/ui.tsx +483 -0
- package/kits/rpg-2d/eslint.config.js +46 -0
- package/kits/rpg-2d/index.html +11 -0
- package/kits/rpg-2d/main.tsx +14 -0
- package/kits/rpg-2d/package-lock.json +3149 -0
- package/kits/rpg-2d/package.json +46 -0
- package/kits/rpg-2d/scenes/main.scene +203 -0
- package/kits/rpg-2d/tsconfig.json +17 -0
- package/kits/rpg-2d/vite-env.d.ts +7 -0
- package/kits/rpg-2d/vite.config.js +1 -0
- package/package.json +27 -5
- package/AGENTS.md +0 -24
- package/dist/push.d.ts +0 -1
- package/src/api.ts +0 -160
- package/src/bundle.ts +0 -28
- package/src/config.ts +0 -36
- package/src/index.ts +0 -110
- package/src/init.ts +0 -71
- package/src/login.ts +0 -24
- package/src/preview.ts +0 -93
- package/src/push.ts +0 -118
- package/src/serve.ts +0 -128
- package/tsconfig.json +0 -13
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
export type FileMap = Record<string, string>;
|
|
2
|
+
export type FileKind = 'scene' | 'drawing' | 'code' | 'text';
|
|
3
|
+
|
|
4
|
+
// Fully transparent pixel -- the empty value in a pixel grid.
|
|
5
|
+
export const TRANSPARENT = '#00000000';
|
|
6
|
+
|
|
7
|
+
export type DrawingPlayMode = 'still' | 'once' | 'loop';
|
|
8
|
+
|
|
9
|
+
// One layer of a drawing: an ordered stack of frames, each frame a flat
|
|
10
|
+
// row-major pixel grid of `width * height` hex-RGBA strings.
|
|
11
|
+
export interface DrawingLayer {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
visible: boolean;
|
|
15
|
+
frames: string[][];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A drawing is a stack of layers, each animated across a shared frame count.
|
|
19
|
+
// Layers composite bottom-to-top; animation advances all layers in lockstep.
|
|
20
|
+
// `initialFrame` is 1-based.
|
|
21
|
+
export interface DrawingData {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
fps: number;
|
|
25
|
+
playMode: DrawingPlayMode;
|
|
26
|
+
initialFrame: number;
|
|
27
|
+
palette: string[];
|
|
28
|
+
layers: DrawingLayer[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Number of animation frames -- layers stay frame-count-synced, so layer 0
|
|
32
|
+
// is authoritative.
|
|
33
|
+
export function frameCount(drawing: DrawingData): number {
|
|
34
|
+
return drawing.layers[0]?.frames.length ?? 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let layerIdCounter = 0;
|
|
38
|
+
|
|
39
|
+
export function newLayerId(): string {
|
|
40
|
+
layerIdCounter += 1;
|
|
41
|
+
return `layer_${Date.now().toString(36)}_${layerIdCounter}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function blankPixelGrid(width: number, height: number): string[] {
|
|
45
|
+
return Array<string>(Math.max(1, width * height)).fill(TRANSPARENT);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Default palette for fresh drawings; user-editable per file.
|
|
49
|
+
const DEFAULT_PALETTE: string[] = [
|
|
50
|
+
'#00000000',
|
|
51
|
+
'#000000FF',
|
|
52
|
+
'#444444FF',
|
|
53
|
+
'#888888FF',
|
|
54
|
+
'#ffffffFF',
|
|
55
|
+
'#8db7ffFF',
|
|
56
|
+
'#3fae5dFF',
|
|
57
|
+
'#ffe17aFF',
|
|
58
|
+
'#ff5d5dFF',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Coerce arbitrary parsed JSON into a valid DrawingData. Accepts the legacy
|
|
62
|
+
// flat `{width,height,pixels}` shape and lifts it into a single layer/frame
|
|
63
|
+
// so old `.drawing` files keep loading.
|
|
64
|
+
export function normalizeDrawing(raw: unknown): DrawingData {
|
|
65
|
+
const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
|
66
|
+
const width = clampDim(obj.width, 16);
|
|
67
|
+
const height = clampDim(obj.height, 16);
|
|
68
|
+
|
|
69
|
+
let layers: DrawingLayer[];
|
|
70
|
+
if (Array.isArray(obj.layers) && obj.layers.length > 0) {
|
|
71
|
+
layers = obj.layers.map((rawLayer, index) =>
|
|
72
|
+
normalizeLayer(rawLayer, index, width, height)
|
|
73
|
+
);
|
|
74
|
+
} else if (Array.isArray(obj.pixels)) {
|
|
75
|
+
layers = [
|
|
76
|
+
{
|
|
77
|
+
id: newLayerId(),
|
|
78
|
+
name: 'Layer 1',
|
|
79
|
+
visible: true,
|
|
80
|
+
frames: [normalizeFrame(obj.pixels as unknown[], width, height)],
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
} else {
|
|
84
|
+
layers = [
|
|
85
|
+
{ id: newLayerId(), name: 'Layer 1', visible: true, frames: [blankPixelGrid(width, height)] },
|
|
86
|
+
];
|
|
87
|
+
}
|
|
88
|
+
const count = layers[0]?.frames.length ?? 1;
|
|
89
|
+
for (const layer of layers) {
|
|
90
|
+
while (layer.frames.length < count) layer.frames.push(blankPixelGrid(width, height));
|
|
91
|
+
layer.frames.length = count;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const palette = Array.isArray(obj.palette)
|
|
95
|
+
? (obj.palette as unknown[]).filter((c): c is string => typeof c === 'string')
|
|
96
|
+
: DEFAULT_PALETTE.slice();
|
|
97
|
+
const fps = typeof obj.fps === 'number' ? obj.fps : 4;
|
|
98
|
+
const playMode: DrawingPlayMode =
|
|
99
|
+
obj.playMode === 'once' || obj.playMode === 'loop' ? obj.playMode : 'still';
|
|
100
|
+
const initialFrame = typeof obj.initialFrame === 'number' ? Math.max(1, obj.initialFrame) : 1;
|
|
101
|
+
|
|
102
|
+
return { width, height, fps, playMode, initialFrame, palette, layers };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clampDim(value: unknown, fallback: number): number {
|
|
106
|
+
return typeof value === 'number' && value > 0 ? Math.floor(value) : fallback;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeFrame(raw: unknown[], width: number, height: number): string[] {
|
|
110
|
+
const out = blankPixelGrid(width, height);
|
|
111
|
+
for (let i = 0; i < out.length; i++) {
|
|
112
|
+
const cell = raw[i];
|
|
113
|
+
if (typeof cell === 'string') out[i] = cell;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeLayer(
|
|
119
|
+
raw: unknown,
|
|
120
|
+
index: number,
|
|
121
|
+
width: number,
|
|
122
|
+
height: number
|
|
123
|
+
): DrawingLayer {
|
|
124
|
+
const obj = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
|
|
125
|
+
const frames: string[][] = Array.isArray(obj.frames)
|
|
126
|
+
? obj.frames.map((frame) =>
|
|
127
|
+
Array.isArray(frame) ? normalizeFrame(frame as unknown[], width, height) : blankPixelGrid(width, height)
|
|
128
|
+
)
|
|
129
|
+
: [blankPixelGrid(width, height)];
|
|
130
|
+
return {
|
|
131
|
+
id: typeof obj.id === 'string' ? obj.id : newLayerId(),
|
|
132
|
+
name: typeof obj.name === 'string' ? obj.name : `Layer ${index + 1}`,
|
|
133
|
+
visible: obj.visible !== false,
|
|
134
|
+
frames,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Seed the file map by scanning the deck dir. Vite module-cache is invalidated
|
|
139
|
+
// on restart, so a newly-created file just shows up on the next reload.
|
|
140
|
+
const rawModules = import.meta.glob<string>(
|
|
141
|
+
['../scenes/*.scene', '../drawings/*.drawing', '../behaviors/*.tsx'],
|
|
142
|
+
{ query: '?raw', import: 'default', eager: true }
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
export const initialFiles: FileMap = Object.fromEntries(
|
|
146
|
+
Object.entries(rawModules)
|
|
147
|
+
.map(([globPath, text]): [string, string] => [globPath.replace(/^\.\.\//, ''), text])
|
|
148
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
export function getFileKind(path: string): FileKind {
|
|
152
|
+
if (path.endsWith('.scene')) return 'scene';
|
|
153
|
+
if (path.endsWith('.drawing')) return 'drawing';
|
|
154
|
+
if (path.endsWith('.ts') || path.endsWith('.tsx')) return 'code';
|
|
155
|
+
return 'text';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function parseJsonFile<TValue = unknown>(
|
|
159
|
+
path: string,
|
|
160
|
+
text: string
|
|
161
|
+
): { value: TValue | null; error: string | null } {
|
|
162
|
+
try {
|
|
163
|
+
return { value: JSON.parse(text) as TValue, error: null };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
166
|
+
return { value: null, error: `${path}: ${message}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function formatJson(value: unknown): string {
|
|
171
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function basename(path: string): string {
|
|
175
|
+
return path.split('/').pop() ?? path;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type OrderNode = {
|
|
179
|
+
isFile: boolean;
|
|
180
|
+
path: string;
|
|
181
|
+
children: OrderNode[];
|
|
182
|
+
childMap: Map<string, OrderNode>;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Flat list of file paths in the order FileBrowser renders them: a
|
|
186
|
+
// depth-first walk of the directory tree, mirroring buildFileTree there.
|
|
187
|
+
export function flatFileOrder(paths: string[]): string[] {
|
|
188
|
+
const root: OrderNode = { isFile: false, path: '', children: [], childMap: new Map() };
|
|
189
|
+
for (const path of paths) {
|
|
190
|
+
const parts = path.split('/');
|
|
191
|
+
let parent = root;
|
|
192
|
+
for (let index = 0; index < parts.length; index++) {
|
|
193
|
+
const name = parts[index];
|
|
194
|
+
const nodePath = parts.slice(0, index + 1).join('/');
|
|
195
|
+
const isFile = index === parts.length - 1;
|
|
196
|
+
if (!parent.childMap.has(name)) {
|
|
197
|
+
const node: OrderNode = { isFile, path: nodePath, children: [], childMap: new Map() };
|
|
198
|
+
parent.childMap.set(name, node);
|
|
199
|
+
parent.children.push(node);
|
|
200
|
+
}
|
|
201
|
+
const child = parent.childMap.get(name);
|
|
202
|
+
if (!child || child.isFile) break;
|
|
203
|
+
parent = child;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const order: string[] = [];
|
|
207
|
+
const walk = (node: OrderNode): void => {
|
|
208
|
+
for (const child of node.children) {
|
|
209
|
+
if (child.isFile) order.push(child.path);
|
|
210
|
+
else walk(child);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
walk(root);
|
|
214
|
+
return order;
|
|
215
|
+
}
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import type { DrawingData, FileMap } from './files';
|
|
2
|
+
import type { LayoutProps } from '../behaviors/Layout.tsx';
|
|
3
|
+
import type { DrawingProps } from '../behaviors/Drawing.tsx';
|
|
4
|
+
|
|
5
|
+
export type ComponentProps = object;
|
|
6
|
+
|
|
7
|
+
export interface ActorData {
|
|
8
|
+
id: string;
|
|
9
|
+
components: Record<string, ComponentProps | undefined>;
|
|
10
|
+
runtime?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SceneData {
|
|
14
|
+
name?: string;
|
|
15
|
+
background?: string;
|
|
16
|
+
actors: ActorData[];
|
|
17
|
+
status?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SceneDrawOptions {
|
|
21
|
+
selectedActorIds?: string[];
|
|
22
|
+
marquee?: SelectionRect | null;
|
|
23
|
+
showGrid?: boolean;
|
|
24
|
+
showDebugColliders?: boolean;
|
|
25
|
+
useCamera?: boolean;
|
|
26
|
+
// When true, draw a faint outlined placeholder for every actor that has a
|
|
27
|
+
// Layout. Editor-only, so actors without a Drawing component are still
|
|
28
|
+
// visible + selectable on the stage.
|
|
29
|
+
editPlaceholders?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SelectionRect {
|
|
33
|
+
x: number;
|
|
34
|
+
y: number;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Behavior<TProps extends object = object> {
|
|
40
|
+
props: TProps;
|
|
41
|
+
update?: (actor: ActorData, scene: SceneRuntime, dt: number) => void;
|
|
42
|
+
draw?: (
|
|
43
|
+
actor: ActorData,
|
|
44
|
+
scene: SceneRuntime,
|
|
45
|
+
ctx: CanvasRenderingContext2D,
|
|
46
|
+
options: SceneDrawOptions
|
|
47
|
+
) => void;
|
|
48
|
+
// Game-time UI. Returns React nodes rendered into a deck-sized, clipped
|
|
49
|
+
// overlay above the canvas; coordinates are card units, like Layout. Called
|
|
50
|
+
// every frame during play -- read state set by `update`, return null to
|
|
51
|
+
// render nothing. The host parents these nodes, so there is no DOM teardown
|
|
52
|
+
// to manage.
|
|
53
|
+
ui?: (actor: ActorData, scene: SceneRuntime) => React.ReactNode;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BehaviorClass {
|
|
57
|
+
behaviorName: string;
|
|
58
|
+
defaultProps: object;
|
|
59
|
+
new (props: never): Behavior;
|
|
60
|
+
Inspector?: (props: never) => React.ReactNode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const CARD_WIDTH = 500;
|
|
64
|
+
const CARD_HEIGHT = 700;
|
|
65
|
+
|
|
66
|
+
export class SceneRuntime {
|
|
67
|
+
behaviors: Map<string, BehaviorClass>;
|
|
68
|
+
drawings: Record<string, DrawingData>;
|
|
69
|
+
time: number;
|
|
70
|
+
keys: Set<string>;
|
|
71
|
+
pointer: { x: number; y: number; down: boolean };
|
|
72
|
+
data: SceneData;
|
|
73
|
+
actors: Map<string, ActorData>;
|
|
74
|
+
camera?: { x: number; y: number };
|
|
75
|
+
status?: string;
|
|
76
|
+
// Set by PlayerController on E-press: the id of the actor to interact with
|
|
77
|
+
// this frame. Behaviors like Dialog watch their own id here and consume it.
|
|
78
|
+
interactTarget?: string;
|
|
79
|
+
// Set by Portal when the player overlaps it; ScenePlayer reloads the scene.
|
|
80
|
+
requestedScene?: string;
|
|
81
|
+
|
|
82
|
+
constructor(
|
|
83
|
+
sceneData: SceneData,
|
|
84
|
+
behaviors: BehaviorClass[],
|
|
85
|
+
drawings: Record<string, DrawingData>
|
|
86
|
+
) {
|
|
87
|
+
this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
|
|
88
|
+
this.drawings = drawings;
|
|
89
|
+
this.time = 0;
|
|
90
|
+
this.keys = new Set();
|
|
91
|
+
this.pointer = { x: 0, y: 0, down: false };
|
|
92
|
+
this.data = { actors: [] };
|
|
93
|
+
this.actors = new Map();
|
|
94
|
+
this.load(sceneData);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
load(sceneData: SceneData): void {
|
|
98
|
+
this.data = structuredClone(sceneData);
|
|
99
|
+
this.actors = new Map();
|
|
100
|
+
for (const actor of this.data.actors ?? []) {
|
|
101
|
+
this.actors.set(actor.id, actor);
|
|
102
|
+
for (const [behaviorName, Behavior] of this.behaviors) {
|
|
103
|
+
const component = actor.components[behaviorName];
|
|
104
|
+
if (component) {
|
|
105
|
+
actor.components[behaviorName] = {
|
|
106
|
+
...Behavior.defaultProps,
|
|
107
|
+
...component,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clone(): SceneRuntime {
|
|
115
|
+
return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.drawings);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
serialize(): SceneData {
|
|
119
|
+
return structuredClone(this.data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getActor(actorId: string): ActorData | undefined {
|
|
123
|
+
return this.actors.get(actorId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Depth-sorted draw order. Top-down feel: the actor whose base
|
|
127
|
+
// (Layout.y + height) sits lower on screen draws in front, so the player
|
|
128
|
+
// walks behind a building when north of its base and in front of it when
|
|
129
|
+
// south. `z` is only a tiebreaker on identical feet-y -- it doesn't
|
|
130
|
+
// override the painterly y sort, which is what bloom-run does too.
|
|
131
|
+
getActors(): ActorData[] {
|
|
132
|
+
return [...this.actors.values()].sort((a, b) => {
|
|
133
|
+
const al = getLayout(a);
|
|
134
|
+
const bl = getLayout(b);
|
|
135
|
+
const afeet = (al?.y ?? 0) + (al?.height ?? 0);
|
|
136
|
+
const bfeet = (bl?.y ?? 0) + (bl?.height ?? 0);
|
|
137
|
+
if (afeet !== bfeet) return afeet - bfeet;
|
|
138
|
+
const az = al?.z ?? 0;
|
|
139
|
+
const bz = bl?.z ?? 0;
|
|
140
|
+
return az - bz;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getComponent(actor: ActorData | null | undefined, behaviorName: string): ComponentProps | null {
|
|
145
|
+
return actor?.components?.[behaviorName] ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Translate a screen-space pointer event into the scene's world-space
|
|
149
|
+
// `pointer`. The runtime owns this mapping (it knows the camera) so every
|
|
150
|
+
// input path -- the standalone ScenePlayer and the editor's play mode -- agree
|
|
151
|
+
// on it; behaviors read `scene.pointer` in world coordinates. Pass `down` to
|
|
152
|
+
// also update the press state.
|
|
153
|
+
setPointerFromScreen(
|
|
154
|
+
canvas: HTMLCanvasElement,
|
|
155
|
+
clientX: number,
|
|
156
|
+
clientY: number,
|
|
157
|
+
down?: boolean
|
|
158
|
+
): void {
|
|
159
|
+
const point = screenToCard(canvas, clientX, clientY);
|
|
160
|
+
this.pointer.x = point.x + (this.camera?.x ?? 0);
|
|
161
|
+
this.pointer.y = point.y + (this.camera?.y ?? 0);
|
|
162
|
+
if (down !== undefined) this.pointer.down = down;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
update(dt: number): void {
|
|
166
|
+
this.time += dt;
|
|
167
|
+
for (const actor of this.getActors()) {
|
|
168
|
+
this.forEachBehavior(actor, (instance) => instance.update?.(actor, this, dt));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private forEachBehavior(actor: ActorData, callback: (instance: Behavior) => void): void {
|
|
173
|
+
for (const [behaviorName, props] of Object.entries(actor.components)) {
|
|
174
|
+
if (!props) continue;
|
|
175
|
+
const Behavior = this.behaviors.get(behaviorName);
|
|
176
|
+
if (!Behavior) continue;
|
|
177
|
+
callback(new Behavior(props as never));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
draw(ctx: CanvasRenderingContext2D, options: SceneDrawOptions = {}): void {
|
|
182
|
+
ctx.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
183
|
+
ctx.fillStyle = this.data.background ?? '#0f2a1d';
|
|
184
|
+
ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
|
|
185
|
+
|
|
186
|
+
ctx.save();
|
|
187
|
+
if (options.useCamera && this.camera) {
|
|
188
|
+
ctx.translate(-Math.round(this.camera.x ?? 0), -Math.round(this.camera.y ?? 0));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const actor of this.getActors()) {
|
|
192
|
+
// Per-actor transform: Layout's `rotation` (degrees) rotates every draw
|
|
193
|
+
// behavior of the actor about its center.
|
|
194
|
+
ctx.save();
|
|
195
|
+
applyLayoutRotation(ctx, getLayout(actor));
|
|
196
|
+
if (options.editPlaceholders) drawEditPlaceholder(ctx, actor);
|
|
197
|
+
this.forEachBehavior(actor, (instance) => instance.draw?.(actor, this, ctx, options));
|
|
198
|
+
ctx.restore();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (options.showGrid) drawGrid(ctx);
|
|
202
|
+
if (options.selectedActorIds) {
|
|
203
|
+
for (const id of options.selectedActorIds) {
|
|
204
|
+
const actor = this.getActor(id);
|
|
205
|
+
if (actor) drawSelection(ctx, actor);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (options.marquee) drawMarquee(ctx, options.marquee);
|
|
209
|
+
ctx.restore();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
actorAt(x: number, y: number): ActorData | null {
|
|
213
|
+
const actors = this.getActors().slice().reverse();
|
|
214
|
+
for (const actor of actors) {
|
|
215
|
+
const layout = getLayout(actor);
|
|
216
|
+
if (!layout) continue;
|
|
217
|
+
if (
|
|
218
|
+
x >= layout.x &&
|
|
219
|
+
x <= layout.x + layout.width &&
|
|
220
|
+
y >= layout.y &&
|
|
221
|
+
y <= layout.y + layout.height
|
|
222
|
+
) {
|
|
223
|
+
return actor;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
actorIdsInRect(rect: SelectionRect): string[] {
|
|
230
|
+
const minX = rect.x;
|
|
231
|
+
const minY = rect.y;
|
|
232
|
+
const maxX = rect.x + rect.width;
|
|
233
|
+
const maxY = rect.y + rect.height;
|
|
234
|
+
const ids: string[] = [];
|
|
235
|
+
for (const actor of this.getActors()) {
|
|
236
|
+
const layout = getLayout(actor);
|
|
237
|
+
if (!layout) continue;
|
|
238
|
+
if (
|
|
239
|
+
layout.x + layout.width >= minX &&
|
|
240
|
+
layout.x <= maxX &&
|
|
241
|
+
layout.y + layout.height >= minY &&
|
|
242
|
+
layout.y <= maxY
|
|
243
|
+
) {
|
|
244
|
+
ids.push(actor.id);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return ids;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function makeScene(
|
|
252
|
+
sceneData: SceneData,
|
|
253
|
+
behaviors: BehaviorClass[],
|
|
254
|
+
drawings: Record<string, DrawingData>
|
|
255
|
+
): SceneRuntime {
|
|
256
|
+
return new SceneRuntime(sceneData, behaviors, drawings);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function setActorComponent<TProps extends ComponentProps>(
|
|
260
|
+
sceneData: SceneData,
|
|
261
|
+
actorId: string,
|
|
262
|
+
behaviorName: string,
|
|
263
|
+
nextProps: Partial<TProps>
|
|
264
|
+
): SceneData {
|
|
265
|
+
const next = structuredClone(sceneData);
|
|
266
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
267
|
+
if (!actor) return sceneData;
|
|
268
|
+
actor.components[behaviorName] = {
|
|
269
|
+
...(actor.components[behaviorName] ?? {}),
|
|
270
|
+
...nextProps,
|
|
271
|
+
};
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function removeActorComponent(
|
|
276
|
+
sceneData: SceneData,
|
|
277
|
+
actorId: string,
|
|
278
|
+
behaviorName: string
|
|
279
|
+
): SceneData {
|
|
280
|
+
const next = structuredClone(sceneData);
|
|
281
|
+
const actor = next.actors.find((candidate) => candidate.id === actorId);
|
|
282
|
+
if (!actor || !(behaviorName in actor.components)) return sceneData;
|
|
283
|
+
delete actor.components[behaviorName];
|
|
284
|
+
return next;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function moveActors(
|
|
288
|
+
sceneData: SceneData,
|
|
289
|
+
actorIds: string[],
|
|
290
|
+
dx: number,
|
|
291
|
+
dy: number
|
|
292
|
+
): SceneData {
|
|
293
|
+
if (actorIds.length === 0) return sceneData;
|
|
294
|
+
const next = structuredClone(sceneData);
|
|
295
|
+
let changed = false;
|
|
296
|
+
for (const id of actorIds) {
|
|
297
|
+
const actor = next.actors.find((candidate) => candidate.id === id);
|
|
298
|
+
const layout = actor ? (actor.components.Layout as LayoutProps | undefined) : null;
|
|
299
|
+
if (!actor || !layout) continue;
|
|
300
|
+
actor.components.Layout = {
|
|
301
|
+
...layout,
|
|
302
|
+
x: Math.round(layout.x + dx),
|
|
303
|
+
y: Math.round(layout.y + dy),
|
|
304
|
+
};
|
|
305
|
+
changed = true;
|
|
306
|
+
}
|
|
307
|
+
return changed ? next : sceneData;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function removeActors(sceneData: SceneData, actorIds: string[]): SceneData {
|
|
311
|
+
if (actorIds.length === 0) return sceneData;
|
|
312
|
+
const toRemove = new Set(actorIds);
|
|
313
|
+
const next = structuredClone(sceneData);
|
|
314
|
+
next.actors = next.actors.filter((actor) => !toRemove.has(actor.id));
|
|
315
|
+
return next.actors.length === sceneData.actors.length ? sceneData : next;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function duplicateActors(
|
|
319
|
+
sceneData: SceneData,
|
|
320
|
+
actorIds: string[]
|
|
321
|
+
): { sceneData: SceneData; newIds: string[] } {
|
|
322
|
+
if (actorIds.length === 0) return { sceneData, newIds: [] };
|
|
323
|
+
const next = structuredClone(sceneData);
|
|
324
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
325
|
+
const newIds: string[] = [];
|
|
326
|
+
for (const id of actorIds) {
|
|
327
|
+
const source = next.actors.find((candidate) => candidate.id === id);
|
|
328
|
+
if (!source) continue;
|
|
329
|
+
const copy = structuredClone(source);
|
|
330
|
+
copy.id = mintActorId(existingIds);
|
|
331
|
+
existingIds.add(copy.id);
|
|
332
|
+
const layout = copy.components.Layout as LayoutProps | undefined;
|
|
333
|
+
if (layout) {
|
|
334
|
+
copy.components.Layout = {
|
|
335
|
+
...layout,
|
|
336
|
+
x: Math.round(layout.x + 1),
|
|
337
|
+
y: Math.round(layout.y + 1),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
next.actors.push(copy);
|
|
341
|
+
newIds.push(copy.id);
|
|
342
|
+
}
|
|
343
|
+
return { sceneData: next, newIds };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function addActor(
|
|
347
|
+
sceneData: SceneData,
|
|
348
|
+
_files: FileMap,
|
|
349
|
+
drawings: Record<string, DrawingData>
|
|
350
|
+
): { sceneData: SceneData; newId: string } {
|
|
351
|
+
const next = structuredClone(sceneData);
|
|
352
|
+
const existingIds = new Set(next.actors.map((actor) => actor.id));
|
|
353
|
+
const id = mintActorId(existingIds);
|
|
354
|
+
const width = 64;
|
|
355
|
+
const height = 64;
|
|
356
|
+
const layout: LayoutProps = {
|
|
357
|
+
x: Math.round((CARD_WIDTH - width) / 2),
|
|
358
|
+
y: Math.round((CARD_HEIGHT - height) / 2),
|
|
359
|
+
width,
|
|
360
|
+
height,
|
|
361
|
+
z: 0,
|
|
362
|
+
rotation: 0,
|
|
363
|
+
};
|
|
364
|
+
const components: ActorData['components'] = { Layout: layout };
|
|
365
|
+
const drawingPath = Object.keys(drawings).sort()[0];
|
|
366
|
+
if (drawingPath) {
|
|
367
|
+
const drawing: DrawingProps = { file: drawingPath };
|
|
368
|
+
components.Drawing = drawing;
|
|
369
|
+
}
|
|
370
|
+
next.actors.push({ id, components });
|
|
371
|
+
return { sceneData: next, newId: id };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function mintActorId(existing: Set<string>): string {
|
|
375
|
+
for (let attempt = 0; attempt < 64; attempt++) {
|
|
376
|
+
const candidate = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0');
|
|
377
|
+
if (!existing.has(candidate)) return candidate;
|
|
378
|
+
}
|
|
379
|
+
return `${Date.now().toString(16)}${Math.floor(Math.random() * 0xffff).toString(16)}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function screenToCard(
|
|
383
|
+
canvas: HTMLCanvasElement,
|
|
384
|
+
clientX: number,
|
|
385
|
+
clientY: number
|
|
386
|
+
): { x: number; y: number } {
|
|
387
|
+
const rect = canvas.getBoundingClientRect();
|
|
388
|
+
return {
|
|
389
|
+
x: ((clientX - rect.left) / rect.width) * CARD_WIDTH,
|
|
390
|
+
y: ((clientY - rect.top) / rect.height) * CARD_HEIGHT,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
|
|
395
|
+
|
|
396
|
+
export function configureSceneCanvas(
|
|
397
|
+
canvas: HTMLCanvasElement,
|
|
398
|
+
ctx = canvas.getContext('2d')
|
|
399
|
+
): CanvasRenderingContext2D | null {
|
|
400
|
+
if (!ctx) return null;
|
|
401
|
+
const rect = canvas.getBoundingClientRect();
|
|
402
|
+
const dpr = window.devicePixelRatio || 1;
|
|
403
|
+
const displayWidth = rect.width || CARD_WIDTH;
|
|
404
|
+
const displayHeight = rect.height || CARD_HEIGHT;
|
|
405
|
+
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr));
|
|
406
|
+
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr));
|
|
407
|
+
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
|
|
408
|
+
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
|
|
409
|
+
ctx.setTransform(pixelWidth / CARD_WIDTH, 0, 0, pixelHeight / CARD_HEIGHT, 0, 0);
|
|
410
|
+
ctx.imageSmoothingEnabled = true;
|
|
411
|
+
return ctx;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function drawGrid(ctx: CanvasRenderingContext2D): void {
|
|
415
|
+
ctx.save();
|
|
416
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
417
|
+
ctx.lineWidth = 1;
|
|
418
|
+
for (let x = 0; x <= CARD_WIDTH; x += 25) {
|
|
419
|
+
ctx.beginPath();
|
|
420
|
+
ctx.moveTo(x + 0.5, 0);
|
|
421
|
+
ctx.lineTo(x + 0.5, CARD_HEIGHT);
|
|
422
|
+
ctx.stroke();
|
|
423
|
+
}
|
|
424
|
+
for (let y = 0; y <= CARD_HEIGHT; y += 25) {
|
|
425
|
+
ctx.beginPath();
|
|
426
|
+
ctx.moveTo(0, y + 0.5);
|
|
427
|
+
ctx.lineTo(CARD_WIDTH, y + 0.5);
|
|
428
|
+
ctx.stroke();
|
|
429
|
+
}
|
|
430
|
+
ctx.restore();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function drawEditPlaceholder(ctx: CanvasRenderingContext2D, actor: ActorData): void {
|
|
434
|
+
const layout = getLayout(actor);
|
|
435
|
+
if (!layout) return;
|
|
436
|
+
ctx.save();
|
|
437
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.04)';
|
|
438
|
+
ctx.fillRect(layout.x, layout.y, layout.width, layout.height);
|
|
439
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
|
440
|
+
ctx.lineWidth = 1;
|
|
441
|
+
ctx.strokeRect(layout.x + 0.5, layout.y + 0.5, layout.width - 1, layout.height - 1);
|
|
442
|
+
ctx.restore();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function drawMarquee(ctx: CanvasRenderingContext2D, rect: SelectionRect): void {
|
|
446
|
+
if (rect.width === 0 || rect.height === 0) return;
|
|
447
|
+
ctx.save();
|
|
448
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
|
|
449
|
+
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
450
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
|
|
451
|
+
ctx.lineWidth = 1;
|
|
452
|
+
ctx.setLineDash([4, 3]);
|
|
453
|
+
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.width - 1, rect.height - 1);
|
|
454
|
+
ctx.restore();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function drawSelection(ctx: CanvasRenderingContext2D, actor: ActorData): void {
|
|
458
|
+
const layout = getLayout(actor);
|
|
459
|
+
if (!layout) return;
|
|
460
|
+
ctx.save();
|
|
461
|
+
applyLayoutRotation(ctx, layout);
|
|
462
|
+
ctx.strokeStyle = '#fff';
|
|
463
|
+
ctx.lineWidth = 2;
|
|
464
|
+
ctx.setLineDash([6, 4]);
|
|
465
|
+
ctx.strokeRect(layout.x + 1, layout.y + 1, layout.width - 2, layout.height - 2);
|
|
466
|
+
ctx.restore();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function applyLayoutRotation(
|
|
470
|
+
ctx: CanvasRenderingContext2D,
|
|
471
|
+
layout: LayoutProps | undefined
|
|
472
|
+
): void {
|
|
473
|
+
const rotation = layout?.rotation ?? 0;
|
|
474
|
+
if (!rotation || !layout) return;
|
|
475
|
+
const cx = layout.x + layout.width / 2;
|
|
476
|
+
const cy = layout.y + layout.height / 2;
|
|
477
|
+
ctx.translate(cx, cy);
|
|
478
|
+
ctx.rotate((rotation * Math.PI) / 180);
|
|
479
|
+
ctx.translate(-cx, -cy);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function getLayout(actor: ActorData): LayoutProps | undefined {
|
|
483
|
+
return actor.components.Layout as LayoutProps | undefined;
|
|
484
|
+
}
|