Skip to content

Commit 57a64c0

Browse files
committed
save direction info on export
1 parent 4a9a177 commit 57a64c0

16 files changed

Lines changed: 497 additions & 78 deletions

src/docs/cli.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,14 +257,27 @@ The JSON `animations` array contains one entry per direction per clip:
257257
```json
258258
{
259259
"animations": [
260-
{ "name": "idle_N", "fps": 10, "frames": 8, "frameWidth": 64, "frameHeight": 64, "quads": [...] },
260+
{ "name": "idle_N", "fps": 10, "frames": 8, "frameWidth": 64, "frameHeight": 64, "workflow": { "workflowId": "topdown-4dir", "workflowLabel": "Top Down 4-directional", "animationName": "idle", "directionLabel": "N" }, "quads": [...] },
261261
{ "name": "idle_E", ... },
262262
{ "name": "idle_S", ... },
263263
{ "name": "idle_W", ... },
264264
{ "name": "walk_N", ... },
265265
{ "name": "walk_E", ... },
266266
{ "name": "walk_S", ... },
267267
{ "name": "walk_W", ... }
268+
],
269+
"directionalAnimations": [
270+
{
271+
"name": "idle",
272+
"workflowId": "topdown-4dir",
273+
"workflowLabel": "Top Down 4-directional",
274+
"directions": [
275+
{ "label": "N", "animation": "idle_N" },
276+
{ "label": "E", "animation": "idle_E" },
277+
{ "label": "S", "animation": "idle_S" },
278+
{ "label": "W", "animation": "idle_W" }
279+
]
280+
}
268281
]
269282
}
270283
```

src/docs/workflows.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ walk_NE
6767
...
6868
```
6969

70-
These labeled sequences are embedded in the exported JSON/metadata file so your game engine can look them up by name.
70+
These labeled sequences are embedded in the exported JSON/metadata file so your game engine can look them up by name. Workflow exports also include structured direction metadata: each sequence records its base animation and direction, and `directionalAnimations` groups rows like `walk_N`, `walk_E`, `walk_S`, and `walk_W` under the shared `walk` animation.
7171

7272
## Tips
7373

src/hooks/next/use-export.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { useSceneStore } from "@/components/panels/scene/store";
1212
import { useSettingsStore } from "@/store/next/settings";
1313
import { useImagesStore } from "@/store/next/images";
1414
import { useSpritePostprocessStore } from "@/store/next/sprite-postprocess";
15+
import { useModelsStore } from "@/store/next/models";
16+
import { useEntitiesStore } from "@/store/next/entities";
1517
import type { ExportFormat } from "@/types/file";
1618
import { exporters } from "@/utils/exports";
1719
import { buildZip, getNormalCoverage } from "@/utils/exports/helpers";
@@ -23,6 +25,7 @@ import {
2325
validateExportRequest,
2426
} from "@/utils/export-validation";
2527
import { addExportHistoryEntry } from "@/utils/export-history";
28+
import { getActiveAnimationSequenceLabel } from "@/utils/animation-sequence-label";
2629

2730
const NORMAL_MAP_EXPORT_FORMATS = new Set<ExportFormat>([
2831
"spritesheet",
@@ -250,27 +253,38 @@ export const useExport = () => {
250253
if (!gl) return;
251254
if (intervalRef.current) clearInterval(intervalRef.current);
252255

256+
const modelState = useModelsStore.getState();
257+
const sequenceLabel =
258+
payload?.label ??
259+
getActiveAnimationSequenceLabel({
260+
animations: modelState.animations,
261+
clips: modelState.clips,
262+
selectedUuid: useEntitiesStore.getState().selected,
263+
}) ??
264+
`animation_${lastIndex.current + 1}`;
265+
const capturePayload = { ...(payload ?? {}), label: sequenceLabel };
266+
253267
images.current = [];
254268
normalImages.current = [];
255-
activeCaptureRef.current = payload ?? {};
269+
activeCaptureRef.current = capturePayload;
256270

257271
intervalRef.current = scheduleInterval(
258272
captureScreenshotData,
259273
intervals,
260274
iterations,
261275
() => {
262276
PubSub.emit(EventType.ASSETS_CREATION_PROGRESS, {
263-
label: payload?.label,
264-
workflowRunId: payload?.workflowRunId,
277+
label: capturePayload.label,
278+
workflowRunId: capturePayload.workflowRunId,
265279
capturedFrames: images.current.length,
266280
expectedFrames: iterations,
267281
});
268282
},
269283
async () => {
270284
intervalRef.current = null;
271285
PubSub.emit(EventType.STOP_ASSETS_CREATION, {
272-
label: payload?.label,
273-
workflowRunId: payload?.workflowRunId,
286+
label: capturePayload.label,
287+
workflowRunId: capturePayload.workflowRunId,
274288
capturedFrames: images.current.length,
275289
expectedFrames: iterations,
276290
status: "done",
@@ -279,14 +293,15 @@ export const useExport = () => {
279293

280294
addImages(
281295
Date.now().toString(),
282-
payload?.label ?? `animation_${lastIndex.current + 1}`,
296+
capturePayload.label,
283297
images.current.map((img) => img.dataURL),
284298
exportNormalMap
285299
? normalImages.current.map((img) => img.dataURL)
286300
: undefined,
287301
exportWidth,
288302
exportHeight,
289303
Math.round(1000 / intervals),
304+
capturePayload.rowMetadata,
290305
);
291306
lastIndex.current += 1;
292307
},

src/hooks/next/use-workflow.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,15 @@ export const useWorkflow = () => {
385385
workflowRunId,
386386
stepIndex: index + 1,
387387
totalSteps: steps.length,
388+
rowMetadata: {
389+
workflow: {
390+
workflowId: workflow.id,
391+
workflowLabel: workflow.label,
392+
...(step.modelUuid ? { modelUuid: step.modelUuid } : {}),
393+
animationName: step.animationName,
394+
directionLabel: step.directionLabel,
395+
},
396+
},
388397
});
389398

390399
const result = await captureDone;

src/lib/events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { pubSubEventClient } from "../../devtools/pubsub-event-client";
33
import {
44
ExportFormats,
55
type AtlasOptions,
6+
type ExportRowMetadata,
67
type ExportFormat,
78
} from "@/types/file";
89
import type { SpritePostprocessSnapshot } from "@/types/sprite-postprocess";
@@ -80,6 +81,7 @@ export type CaptureStartPayload = {
8081
workflowRunId?: string;
8182
stepIndex?: number;
8283
totalSteps?: number;
84+
rowMetadata?: ExportRowMetadata;
8385
};
8486

8587
export type CaptureStopPayload = {

src/store/next/images.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { create } from "zustand";
22
import { inspector } from "@kyonru/zustand-inspector";
33
import type { SnapshotEnabledStore } from "@/types/ecs";
4-
import type { ExportRow } from "@/types/file";
4+
import type { ExportRow, ExportRowMetadata } from "@/types/file";
55

66
export interface ImagesState {
77
intervals: number;
@@ -26,6 +26,7 @@ interface ImagesActions extends SnapshotEnabledStore<ImagesState> {
2626
frameWidth: number,
2727
frameHeight: number,
2828
fps: number,
29+
metadata?: ExportRowMetadata,
2930
) => void;
3031
removeImagesRow: (index: number) => void;
3132
removeImageFromRow: (index: number, imageIndex: number) => void;
@@ -89,6 +90,7 @@ export const useImagesStore = create<ImagesStore>()(
8990
frameWidth,
9091
frameHeight,
9192
fps,
93+
metadata,
9294
) =>
9395
set((state) => ({
9496
images: [
@@ -101,6 +103,7 @@ export const useImagesStore = create<ImagesStore>()(
101103
frameWidth,
102104
frameHeight,
103105
fps,
106+
...(metadata ? { metadata } : {}),
104107
},
105108
],
106109
})),

src/types/file.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,30 @@ export interface ExportRow {
3636
frameWidth: number;
3737
frameHeight: number;
3838
fps: number;
39+
metadata?: ExportRowMetadata;
40+
}
41+
42+
export interface ExportRowMetadata {
43+
workflow?: ExportRowWorkflowMetadata;
44+
}
45+
46+
export interface ExportRowWorkflowMetadata {
47+
workflowId: string;
48+
workflowLabel: string;
49+
modelUuid?: string;
50+
animationName: string;
51+
directionLabel: string;
52+
}
53+
54+
export interface DirectionalAnimationGroup {
55+
name: string;
56+
workflowId: string;
57+
workflowLabel: string;
58+
modelUuid?: string;
59+
directions: {
60+
label: string;
61+
animation: string;
62+
}[];
3963
}
4064

4165
export type ExportContext = {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
interface ClipNameEntry {
2+
clip: {
3+
name: string;
4+
};
5+
}
6+
7+
interface AnimationSequenceLabelInput {
8+
animations: Record<string, string | undefined>;
9+
clips: Record<string, ClipNameEntry[] | undefined>;
10+
selectedUuid?: string;
11+
}
12+
13+
function getActiveClipName(
14+
input: AnimationSequenceLabelInput,
15+
uuid: string | undefined,
16+
) {
17+
if (!uuid) return undefined;
18+
19+
const animationName = input.animations[uuid];
20+
if (!animationName || !animationName.trim() || animationName === "none") {
21+
return undefined;
22+
}
23+
24+
const hasMatchingClip = input.clips[uuid]?.some(
25+
(entry) => entry.clip.name === animationName,
26+
);
27+
28+
return hasMatchingClip ? animationName : undefined;
29+
}
30+
31+
export function getActiveAnimationSequenceLabel(
32+
input: AnimationSequenceLabelInput,
33+
) {
34+
const selectedAnimationName = getActiveClipName(input, input.selectedUuid);
35+
if (selectedAnimationName) return selectedAnimationName;
36+
37+
const animationNames = new Set<string>();
38+
39+
for (const uuid of Object.keys(input.animations)) {
40+
const animationName = getActiveClipName(input, uuid);
41+
if (animationName) animationNames.add(animationName);
42+
}
43+
44+
const names = Array.from(animationNames);
45+
if (names.length === 0) return undefined;
46+
47+
return names.join("_");
48+
}

src/utils/assets.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import GIF from "gif.js.optimized";
22
import openFile from "../lib/file/opener.web";
33
import { toast } from "sonner";
4-
import type { ExportRow } from "@/types/file";
4+
import type {
5+
DirectionalAnimationGroup,
6+
ExportRow,
7+
ExportRowWorkflowMetadata,
8+
} from "@/types/file";
9+
import {
10+
buildDirectionalAnimationGroups,
11+
getRowWorkflowMetadata,
12+
} from "@/utils/export-row-metadata";
513

614
export interface SpritesheetOptions {
715
spacing: number; // px between frames
@@ -173,8 +181,10 @@ export interface SpritesheetJSON {
173181
fps: number;
174182
frameWidth: number;
175183
frameHeight: number;
184+
workflow?: ExportRowWorkflowMetadata;
176185
quads: { x: number; y: number; w: number; h: number; page?: number }[];
177186
}[];
187+
directionalAnimations?: DirectionalAnimationGroup[];
178188
}
179189

180190
export const createSpritesheetJSON = (
@@ -200,6 +210,8 @@ export const createSpritesheetJSON = (
200210

201211
let yOffset = margin;
202212

213+
const directionalAnimations = buildDirectionalAnimationGroups(rows);
214+
203215
return {
204216
meta: {
205217
version: "1.0",
@@ -212,12 +224,14 @@ export const createSpritesheetJSON = (
212224
margin,
213225
},
214226
animations: rows.map((row) => {
227+
const workflow = getRowWorkflowMetadata(row);
215228
const animation = {
216229
name: row.label,
217230
frames: row.images.length,
218231
fps: row.fps ?? 12,
219232
frameWidth: row.frameWidth,
220233
frameHeight: row.frameHeight,
234+
...(workflow ? { workflow } : {}),
221235
quads: row.images.map((_, frameIndex) => ({
222236
x: margin + frameIndex * (row.frameWidth + spacing),
223237
y: yOffset,
@@ -230,6 +244,7 @@ export const createSpritesheetJSON = (
230244

231245
return animation;
232246
}),
247+
...(directionalAnimations.length > 0 ? { directionalAnimations } : {}),
233248
};
234249
};
235250

src/utils/atlas.ts

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { AtlasOptions, ExportFormat, ExportRow } from "@/types/file";
22
import type { SpritesheetJSON } from "./assets";
3+
import {
4+
buildDirectionalAnimationGroups,
5+
getRowWorkflowMetadata,
6+
} from "./export-row-metadata";
37

48
export const DEFAULT_ATLAS_OPTIONS: AtlasOptions = {
59
layout: "rows",
@@ -596,28 +600,41 @@ export function createSpritesheetJSONFromAtlasPlan(
596600
});
597601
}
598602

603+
const directionalAnimations = buildDirectionalAnimationGroups(rows);
604+
599605
return {
600606
meta,
601-
animations: rows.map((row, rowIndex) => ({
602-
name: row.label,
603-
frames: row.images.length,
604-
fps: row.fps ?? 12,
605-
frameWidth: Math.max(1, Math.round(row.frameWidth * plan.options.scale)),
606-
frameHeight: Math.max(1, Math.round(row.frameHeight * plan.options.scale)),
607-
quads: row.images.map((_, frameIndex) => {
608-
const placement = placements.get(placementKey(rowIndex, frameIndex));
609-
if (!placement) {
610-
throw new Error(`Missing atlas placement for ${row.label}:${frameIndex}`);
611-
}
612-
613-
const quad = {
614-
x: placement.x,
615-
y: placement.y,
616-
w: placement.w,
617-
h: placement.h,
618-
};
619-
return multiPage ? { ...quad, page: placement.page } : quad;
620-
}),
621-
})),
607+
animations: rows.map((row, rowIndex) => {
608+
const workflow = getRowWorkflowMetadata(row);
609+
610+
return {
611+
name: row.label,
612+
frames: row.images.length,
613+
fps: row.fps ?? 12,
614+
frameWidth: Math.max(1, Math.round(row.frameWidth * plan.options.scale)),
615+
frameHeight: Math.max(
616+
1,
617+
Math.round(row.frameHeight * plan.options.scale),
618+
),
619+
...(workflow ? { workflow } : {}),
620+
quads: row.images.map((_, frameIndex) => {
621+
const placement = placements.get(placementKey(rowIndex, frameIndex));
622+
if (!placement) {
623+
throw new Error(
624+
`Missing atlas placement for ${row.label}:${frameIndex}`,
625+
);
626+
}
627+
628+
const quad = {
629+
x: placement.x,
630+
y: placement.y,
631+
w: placement.w,
632+
h: placement.h,
633+
};
634+
return multiPage ? { ...quad, page: placement.page } : quad;
635+
}),
636+
};
637+
}),
638+
...(directionalAnimations.length > 0 ? { directionalAnimations } : {}),
622639
};
623640
}

0 commit comments

Comments
 (0)