Compare commits
519
Commits
佩佩视觉部分重构
...
7.22night春优化
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc1ebdcb5 | ||
|
|
b20b936966 | ||
|
|
4a0e3ba0b9 | ||
|
|
2a1cfbc208 | ||
|
|
e3762e5462 | ||
|
|
5029a4b9b5 | ||
|
|
3debdb39e4 | ||
|
|
04edf9b355 | ||
|
|
6854679bcb | ||
|
|
515f57e28c | ||
|
|
97ef0972fd | ||
|
|
2fb32c44e8 | ||
|
|
3d65941b03 | ||
|
|
5091851ead | ||
|
|
4411bfa4b6 | ||
|
|
02d3fc04e6 | ||
|
|
ef3c14d3de | ||
|
|
b7fdcf97fe | ||
|
|
ba0ce38c11 | ||
|
|
3b5d05260e | ||
|
|
f4488a68d0 | ||
|
|
c6c76666ef | ||
|
|
d655887a02 | ||
|
|
adbcd89918 | ||
|
|
9b2caf64c9 | ||
|
|
82b20a8fab | ||
|
|
0f06041fa2 | ||
|
|
8a5d83d888 | ||
|
|
71a803fd72 | ||
|
|
672d554b40 | ||
|
|
4259306e1f | ||
|
|
8b16aebc6b | ||
|
|
52db661484 | ||
|
|
aed9998d1a | ||
|
|
3919ea9a75 | ||
|
|
3891b6bd37 | ||
|
|
9f61f53b7f | ||
|
|
6dc0983961 | ||
|
|
9627586130 | ||
|
|
28cc1127f5 | ||
|
|
532d9fb320 | ||
|
|
0d59e7d91e | ||
|
|
859d780b78 | ||
|
|
7ee6cba2ae | ||
|
|
6159704116 | ||
|
|
74f5989802 | ||
|
|
0b848d8040 | ||
|
|
96b309992c | ||
|
|
1547055cc2 | ||
|
|
3d119dcd92 | ||
|
|
c4ef30b151 | ||
|
|
12c5735466 | ||
|
|
35451363a5 | ||
|
|
9ef6bc9bff | ||
|
|
97f0fabd3c | ||
|
|
018e9d3b52 | ||
|
|
c0e58d56b4 | ||
|
|
cffb99a52c | ||
|
|
597e97c5d7 | ||
|
|
7a8f7dbbd7 | ||
|
|
8fee89db36 | ||
|
|
046b1a9efb | ||
|
|
c7c0e96110 | ||
|
|
f6f87c3109 | ||
|
|
98dfb644c5 | ||
|
|
845f46b924 | ||
|
|
5f31cd5635 | ||
|
|
1e8ec2e64f | ||
|
|
e7a0089a46 | ||
|
|
2ac0b4695d | ||
|
|
ecfefa5b87 | ||
|
|
3164fd7d8c | ||
|
|
dc0488f085 | ||
|
|
164e3dd50e | ||
|
|
c985cc2843 | ||
|
|
b0ee02c62c | ||
|
|
a043f27035 | ||
|
|
a9f58fc683 | ||
|
|
94823354b5 | ||
|
|
4ec5e0b442 | ||
|
|
4d44ca387e | ||
|
|
1171ad9e87 | ||
|
|
1318636bee | ||
|
|
f17a8ab8dd | ||
|
|
b70f2614af | ||
|
|
517f4d956a | ||
|
|
46c47ea4be | ||
|
|
41d3e07a55 | ||
|
|
2cd0ae92be | ||
|
|
3491367bd9 | ||
|
|
dc6fe93afb | ||
|
|
ff89ffe552 | ||
|
|
bd30cdabd0 | ||
|
|
6ff260150d | ||
|
|
32d2fcc0a2 | ||
|
|
cca5404f0c | ||
|
|
6e5e5d5790 | ||
|
|
ad759a6e83 | ||
|
|
65205e2552 | ||
|
|
cb255f8ca5 | ||
|
|
5f30f356ec | ||
|
|
3c789cbd65 | ||
|
|
fabef0145d | ||
|
|
785add905d | ||
|
|
2fe558846b | ||
|
|
1fa96151d6 | ||
|
|
c8830d9346 | ||
|
|
ecf75ae6a6 | ||
|
|
5f2f6e6365 | ||
|
|
153d36f884 | ||
|
|
6481858017 | ||
|
|
c9ca07a0b3 | ||
|
|
cb1f5b90a7 | ||
|
|
9f4c473fac | ||
|
|
4eb72911e1 | ||
|
|
5f4f9c1721 | ||
|
|
202cdb4c29 | ||
|
|
1ade595f9d | ||
|
|
3ed84f79b1 | ||
|
|
1444e43449 | ||
|
|
6e7bb3f637 | ||
|
|
5ab204b476 | ||
|
|
84192cb7c4 | ||
|
|
c98644c723 | ||
|
|
15aba8e805 | ||
|
|
90c75d1f45 | ||
|
|
f8821b1b3e | ||
|
|
41ca1927a0 | ||
|
|
6bf967e8b3 | ||
|
|
2514eb0a8d | ||
|
|
f444978717 | ||
|
|
554de790c1 | ||
|
|
1764714cb5 | ||
|
|
a6d541383b | ||
|
|
00b5646b44 | ||
|
|
9403bd9623 | ||
|
|
14fd64e7bd | ||
|
|
5a37f529ab | ||
|
|
06d6127c69 | ||
|
|
53587c413e | ||
|
|
5408fd5320 | ||
|
|
1f6208a094 | ||
|
|
772a6bbbe8 | ||
|
|
bfc61a0097 | ||
|
|
bfbfe494c4 | ||
|
|
297075e0f0 | ||
|
|
71d248c2e6 | ||
|
|
7034b82aa4 | ||
|
|
eb127371f1 | ||
|
|
cec2dae5e0 | ||
|
|
ba19c2eff5 | ||
|
|
7fa12e3d46 | ||
|
|
bb6b3dd599 | ||
|
|
d7030f36d2 | ||
|
|
94a817f5fa | ||
|
|
bfdcb41116 | ||
|
|
6ace48dba2 | ||
|
|
2999f753f7 | ||
|
|
c3da27d07c | ||
|
|
05fe1ebb60 | ||
|
|
ad5111a206 | ||
|
|
592d3a36dd | ||
|
|
3e2a24421f | ||
|
|
bc85e4d343 | ||
|
|
551dd6d183 | ||
|
|
c9f508bb8e | ||
|
|
ba5a9fc2f3 | ||
|
|
d5c4917a8a | ||
|
|
c751a859ff | ||
|
|
081ed0d883 | ||
|
|
a5e417eeae | ||
|
|
2eea496c4b | ||
|
|
d316922b3a | ||
|
|
600fd4cebf | ||
|
|
ce00539c18 | ||
|
|
78d5ad33f0 | ||
|
|
2fec120568 | ||
|
|
4b595fcee9 | ||
|
|
947cfa2fb4 | ||
|
|
0dc1001723 | ||
|
|
7476263411 | ||
|
|
91c3f509e1 | ||
|
|
686c32738d | ||
|
|
b40f17ea9f | ||
|
|
4b86068bbf | ||
|
|
1077bc551c | ||
|
|
a4620dacc8 | ||
|
|
2c8bc17174 | ||
|
|
24f475fe2f | ||
|
|
5236066c7b | ||
|
|
447d61f941 | ||
|
|
7646cb4db3 | ||
|
|
7c04f507db | ||
|
|
5549404fe4 | ||
|
|
667425bd2c | ||
|
|
21d51945c3 | ||
|
|
2dcfeec938 | ||
|
|
94130cd97f | ||
|
|
c617e44813 | ||
|
|
b7ca9ab11f | ||
|
|
847feed926 | ||
|
|
e20b499492 | ||
|
|
5d07012cb6 | ||
|
|
00a43fcad3 | ||
|
|
e363c6e4a5 | ||
|
|
1a113dbd25 | ||
|
|
3959561686 | ||
|
|
031ac79b8d | ||
|
|
0db3f75476 | ||
|
|
f3e1e421a3 | ||
|
|
23b6d9cf87 | ||
|
|
509e0ae169 | ||
|
|
c3c0398905 | ||
|
|
fd79ff9d93 | ||
|
|
8b1fe28edb | ||
|
|
b4f0cebc1d | ||
|
|
46602803dc | ||
|
|
cb1ddc1f93 | ||
|
|
6c75ceaa35 | ||
|
|
cfb4ff96c4 | ||
|
|
7dfd87e868 | ||
|
|
8deff54e48 | ||
|
|
7b287a6248 | ||
|
|
9b6d008263 | ||
|
|
02d3d80f46 | ||
|
|
3eea7e9ca6 | ||
|
|
56045b4bcd | ||
|
|
f51a670239 | ||
|
|
4100232162 | ||
|
|
51fb062225 | ||
|
|
e0318026d7 | ||
|
|
cc354cb37d | ||
|
|
eaba7ea5ab | ||
|
|
5ab8498be5 | ||
|
|
fa79a4bed1 | ||
|
|
f0a92553fb | ||
|
|
85de341cd2 | ||
|
|
ea95111f2a | ||
|
|
0039beb5f9 | ||
|
|
96164e1887 | ||
|
|
754d03b47d | ||
|
|
3be0bf8201 | ||
|
|
60200afe9b | ||
|
|
69315f86f9 | ||
|
|
8c795f4474 | ||
|
|
8c37a0bd09 | ||
|
|
e9e21920f1 | ||
|
|
5c6cc5c4a6 | ||
|
|
61e3f52a38 | ||
|
|
5639bad84a | ||
|
|
3d86790bd7 | ||
|
|
fd77b5c248 | ||
|
|
fa66dbc7c9 | ||
|
|
92087b81ef | ||
|
|
1f931e987f | ||
|
|
7ace1f255a | ||
|
|
21321074bf | ||
|
|
89850c75f3 | ||
|
|
d075d1e0f8 | ||
|
|
161fe6ac7d | ||
|
|
c38c2f75be | ||
|
|
52573095f7 | ||
|
|
ac6ded9f66 | ||
|
|
8a26091590 | ||
|
|
06d85d6460 | ||
|
|
91ffbeb130 | ||
|
|
b14599d18c | ||
|
|
53a4fc86c4 | ||
|
|
079d57e295 | ||
|
|
5f8ffb7f78 | ||
|
|
85a3fc9e14 | ||
|
|
ddb3aba4e5 | ||
|
|
1ee2a949e9 | ||
|
|
dd66a52d65 | ||
|
|
59bffeab06 | ||
|
|
423a3954dd | ||
|
|
4ab2f83395 | ||
|
|
de85ca2944 | ||
|
|
9f6c926dcc | ||
|
|
d101864397 | ||
|
|
1cc5ac32aa | ||
|
|
431401c52f | ||
|
|
0ed1253a40 | ||
|
|
4f59e41b03 | ||
|
|
df6f8c6ec4 | ||
|
|
d00edd7bb8 | ||
|
|
1be7b12deb | ||
|
|
51b8d86603 | ||
|
|
c4dbdc098b | ||
|
|
19b6b9c4e2 | ||
|
|
92e8cd2f13 | ||
|
|
26cfa5d886 | ||
|
|
b98214632e | ||
|
|
2c74ec2030 | ||
|
|
71bfad4dae | ||
|
|
c601dda9de | ||
|
|
1422161844 | ||
|
|
a494cf99cf | ||
|
|
ee1d100fb5 | ||
|
|
d0a08986c6 | ||
|
|
0d04d1a68d | ||
|
|
52e0e9002f | ||
|
|
4b3260dfcd | ||
|
|
6689effb87 | ||
|
|
b062ab4ddc | ||
|
|
4104de4166 | ||
|
|
425cd1e695 | ||
|
|
a48a6bf6ad | ||
|
|
98c558e800 | ||
|
|
cd00a524bb | ||
|
|
6d12bceb36 | ||
|
|
52d7656c30 | ||
|
|
bd3988424f | ||
|
|
49df75279d | ||
|
|
2a43d0af81 | ||
|
|
66672f5188 | ||
|
|
0590e755ee | ||
|
|
467dbfdf84 | ||
|
|
bc14705fb3 | ||
|
|
6e939d2a72 | ||
|
|
2792d8fafa | ||
|
|
024eecb404 | ||
|
|
8d1f072d55 | ||
|
|
18d6d970f9 | ||
|
|
a1bfa4c8b8 | ||
|
|
ea6b75d73d | ||
|
|
4eb9aacc47 | ||
|
|
892912e707 | ||
|
|
3fefa18c65 | ||
|
|
233cedd528 | ||
|
|
b0ece3e0f1 | ||
|
|
8a7b2d0ed2 | ||
|
|
c905eb4625 | ||
|
|
4a66ba766e | ||
|
|
4ded2b8eab | ||
|
|
f6997e8816 | ||
|
|
6a5e8eecc9 | ||
|
|
d0f4a1cf09 | ||
|
|
e1f5484e02 | ||
|
|
2e7c87c886 | ||
|
|
f273569e7c | ||
|
|
68837581af | ||
|
|
d8056155c1 | ||
|
|
2a8d82fd2e | ||
|
|
558706ee08 | ||
|
|
65d895798f | ||
|
|
a305e5ef42 | ||
|
|
0babb520de | ||
|
|
0265de117a | ||
|
|
11d88c146c | ||
|
|
b5aab4edc1 | ||
|
|
3092c3554b | ||
|
|
7d16bfe7bc | ||
|
|
d9c8e85f2b | ||
|
|
b612a0763d | ||
|
|
c7c1571feb | ||
|
|
6469a6b746 | ||
|
|
1fbac2c182 | ||
|
|
ff29142761 | ||
|
|
88dd2c95e8 | ||
|
|
f87dcc687f | ||
|
|
84764d7b6b | ||
|
|
204b2c43fb | ||
|
|
c5c8729704 | ||
|
|
c4ed431753 | ||
|
|
2ff1df2331 | ||
|
|
36a1d783fb | ||
|
|
9a31c71900 | ||
|
|
5033923042 | ||
|
|
5033bf0a3d | ||
|
|
c75cdc4bdf | ||
|
|
c6539c37b9 | ||
|
|
e015b756f6 | ||
|
|
04536c4b81 | ||
|
|
7d7c8893fc | ||
|
|
d3c6880ce6 | ||
|
|
82603c304d | ||
|
|
b89a674a28 | ||
|
|
d5c946d322 | ||
|
|
575ebebfaf | ||
|
|
aea6483849 | ||
|
|
1b33124ec1 | ||
|
|
76302f53c8 | ||
|
|
969bc2756c | ||
|
|
599bb44763 | ||
|
|
78ac7da055 | ||
|
|
e1240f023d | ||
|
|
5933ae954d | ||
|
|
e5a10ea0b7 | ||
|
|
d782a09671 | ||
|
|
440979264e | ||
|
|
39a9eb73bc | ||
|
|
cb4264f26e | ||
|
|
c67fe1b32d | ||
|
|
b8773d968e | ||
|
|
4776066cec | ||
|
|
5305033016 | ||
|
|
bd70442e45 | ||
|
|
a3dadd4d88 | ||
|
|
39ce23d5f3 | ||
|
|
aa0f431955 | ||
|
|
b98681acab | ||
|
|
37e9127260 | ||
|
|
c5c90eb5a3 | ||
|
|
517a03bbc3 | ||
|
|
cb6658e41a | ||
|
|
e78d5eba23 | ||
|
|
478f431dd3 | ||
|
|
a1d972c074 | ||
|
|
0cf659cc20 | ||
|
|
a5352ed250 | ||
|
|
18ab68f841 | ||
|
|
870f914950 | ||
|
|
c08021a2c8 | ||
|
|
6ac7fcf9da | ||
|
|
dfed55acbb | ||
|
|
982914f6e7 | ||
|
|
1583b96200 | ||
|
|
3432053239 | ||
|
|
d054a57e26 | ||
|
|
7884812131 | ||
|
|
33be87215e | ||
|
|
f3c3994f5c | ||
|
|
bd94302714 | ||
|
|
5031906d70 | ||
|
|
bef36bb92f | ||
|
|
496ba885af | ||
|
|
3c7662679c | ||
|
|
0832e4cac1 | ||
|
|
668b735377 | ||
|
|
b26f7d0222 | ||
|
|
80d32a73d8 | ||
|
|
fa3954bbbb | ||
|
|
b9167056cf | ||
|
|
784e1e8f0d | ||
|
|
76d620d8c3 | ||
|
|
1918c8c53b | ||
|
|
07f1e3f5bc | ||
|
|
fe8b4ea3c5 | ||
|
|
a1767fc6d3 | ||
|
|
49a51b6ccb | ||
|
|
08d09bf31d | ||
|
|
4e6c6dac6e | ||
|
|
a603ad928f | ||
|
|
bde1040c9d | ||
|
|
fcd0955ae7 | ||
|
|
b1981431f6 | ||
|
|
7c15663bf2 | ||
|
|
9f5ea54813 | ||
|
|
63d755ee05 | ||
|
|
29267659b9 | ||
|
|
c912ac89c5 | ||
|
|
b2174985ed | ||
|
|
5e64ab4c30 | ||
|
|
cb557c14cf | ||
|
|
3e2d1e931a | ||
|
|
742125c112 | ||
|
|
a8fcb42f85 | ||
|
|
7ddada9726 | ||
|
|
b89fb35559 | ||
|
|
f726472d8a | ||
|
|
2ef0cff2f1 | ||
|
|
58068d1a28 | ||
|
|
2f1bd5707e | ||
|
|
af3c39dcb3 | ||
|
|
d6e7afe4b3 | ||
|
|
73e6b86579 | ||
|
|
df70089ecc | ||
|
|
79a4976d42 | ||
|
|
107a2411e3 | ||
|
|
41e02d48d8 | ||
|
|
3b96003c03 | ||
|
|
f4ee41c522 | ||
|
|
024202b19e | ||
|
|
4deb565882 | ||
|
|
e6dfab3b51 | ||
|
|
9124a4bd6b | ||
|
|
aaf248a0ef | ||
|
|
7177039f71 | ||
|
|
db73e13bcc | ||
|
|
8ddad4fcfc | ||
|
|
8f6616920c | ||
|
|
660f8e24ee | ||
|
|
929e6f0086 | ||
|
|
603dc9dad3 | ||
|
|
a05d5d476c | ||
|
|
da436479db | ||
|
|
93a5076369 | ||
|
|
e50e2f1cd5 | ||
|
|
cf016ecc93 | ||
|
|
39cf031761 | ||
|
|
6d6184e2de | ||
|
|
9867c84ccf | ||
|
|
e4d7b03a89 | ||
|
|
e5306f2b82 | ||
|
|
46cdbd3dd2 | ||
|
|
58488547ce | ||
|
|
6f8e306fb6 | ||
|
|
4fa3aac335 | ||
|
|
568e6ff867 | ||
|
|
eefce92f58 | ||
|
|
d33d4b9dc4 | ||
|
|
c761e9afd7 | ||
|
|
e884f4fb42 | ||
|
|
f35edeabe8 | ||
|
|
d001c824a0 | ||
|
|
d865244a02 | ||
|
|
e6b24556b5 | ||
|
|
681fe5fc98 | ||
|
|
721e82d768 | ||
|
|
296ce4d657 | ||
|
|
2389c9ef3e | ||
|
|
7e21d33cdd | ||
|
|
d2ecd1d846 | ||
|
|
f25674a043 | ||
|
|
6d262a8796 | ||
|
|
32d44b8119 | ||
|
|
7edda0bd6e | ||
|
|
2c9f609616 |
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: narrative-timeline-flow
|
||||
description: >-
|
||||
叙事 Timeline:aseprite/静图改 Timeline、Addressable 登记、黑屏防穿帮、替换 show_full_screen。
|
||||
Use when 加叙事 timeline、aseprite 转 timeline、英里初登场、CG 演出、替换 show_full_screen、
|
||||
hide_full_screen、黑屏穿帮、fade_out 时机、wait_timeline、Day1 begin / Day2 night 天桥 CG、
|
||||
或用户提到 TimelineKit 叙事接入。
|
||||
---
|
||||
|
||||
# 叙事 Timeline 接入(Aseprite / 静图 → Yarn)
|
||||
|
||||
API 细节见 [`Assets/Scripts/SceneManagement/TimelineKit/README_TimelineKit.md`](../../Assets/Scripts/SceneManagement/TimelineKit/README_TimelineKit.md)。本 skill 只记**本仓库约定与踩坑**。
|
||||
|
||||
## 1. 何时用哪种 Yarn 命令
|
||||
|
||||
| 场景 | 命令 |
|
||||
|------|------|
|
||||
| 播完再往下走 | `<<play_timeline 名>>`(阻塞) |
|
||||
| 黑屏下先起播 / 与对白并行 / Loop CG | `<<start_timeline 名>>` |
|
||||
| 已 `start_timeline`,要等播完 | `<<wait_timeline 名>>`(不重头播) |
|
||||
| 关掉整棵 Director GO | `<<hide_timeline 名>>` |
|
||||
|
||||
名称与场景里 `DirectorHandler.timelineName` **完全一致**(叙事多用中文单名,内嵌 Playable)。
|
||||
|
||||
## 2. 资产与 Addressable
|
||||
|
||||
1. Aseprite → `Assets/GameContent/Scene_{场景}/animationRaw/`(或现有 `AnimationRaw/`)
|
||||
2. Timeline → `GameContent/Scene_{场景}/Timeline/{名}.playable`
|
||||
3. Addressable Group = 对应场景线(如 `Scene_ClinicOut`、`Scene_Bridge`)
|
||||
4. Address = `Timeline/{名}`(必须带 `Timeline/` 前缀)
|
||||
5. 叙事 Timeline **一般不**加 `TimelineAssetRef`(维修玩法才用)
|
||||
|
||||
参考范本:`医生出地铁`、`day2夕阳医生看佩佩侧脸`、`英里初登场`。
|
||||
|
||||
## 3. 场景 GO 约定
|
||||
|
||||
- 根节点挂 `PlayableDirector`(`Play On Awake = false`)+ `DirectorHandler`
|
||||
- **根节点默认 Active**:`Start` 里要注册到 `TimelineCenter`;关掉根节点会注册失败
|
||||
- **带 Sprite 的子物体(如 `Visual`)默认 Inactive**;用 ActivationTrack 或播放时再显
|
||||
- `hide_timeline` = 根 `SetActive(false)`;之后再播时 `PlayTimeline` 会重新打开
|
||||
- 全屏 CG 常需调位置(例:天桥侧脸根节点 `Y = -5`);PPU / scale 按相机再调
|
||||
|
||||
## 4. 黑屏防穿帮(必做)
|
||||
|
||||
**错误**:`fade_out` 揭开场景 → 再 `play_timeline`(先露底再播 CG)。
|
||||
|
||||
**正确**(切场景后的 intro CG):
|
||||
|
||||
```yarn
|
||||
<<fade_in>>
|
||||
<<load_scene Scene/ClinicOut>>
|
||||
<<init_actor ...>>
|
||||
<<start_timeline 英里初登场>>
|
||||
<<wait 0.2>>
|
||||
<<fade_out>>
|
||||
<<wait_timeline 英里初登场>>
|
||||
<<hide_timeline 英里初登场>>
|
||||
<<show_actor ...>>
|
||||
<<play_timeline 医生出地铁>>
|
||||
```
|
||||
|
||||
要点:黑屏内起播并稳住首帧 → 再结束黑屏 → 再等播完。CG 盖住初登场时,角色可延后到 `hide_timeline` 后再 `show_actor`。
|
||||
|
||||
## 5. 替换 `show_full_screen` 静图
|
||||
|
||||
常驻 / Loop 演出(对白期间一直播):
|
||||
|
||||
```yarn
|
||||
<<start_timeline day2夕阳医生看佩佩侧脸>>
|
||||
<<fade_out 1>>
|
||||
# ... 对白 ...
|
||||
<<fade_in 1>>
|
||||
<<hide_timeline day2夕阳医生看佩佩侧脸>>
|
||||
<<fade_out 1>>
|
||||
```
|
||||
|
||||
不要再用 `show_full_screen` / `hide_full_screen` 播同一段。
|
||||
|
||||
## 6. 其它坑
|
||||
|
||||
- `<<load_scene>>` **只需场景名**。旧脚本第二参数(如 `1`)已兼容忽略,**新代码不要写**。
|
||||
- 改 `.yarn`:**不要动**已有 `#line:`;新行不加 `#line:`。
|
||||
- 只改运行时实际绑定的 Yarn(如 Day1_begin → FP,不是 Fiction 草稿)。
|
||||
- Aseprite 多 tag:选主演出 clip;Loop CG 用循环 clip + Director `Loop` 或长轨;一次性 intro 用有限时长 + `wait_timeline`。
|
||||
|
||||
## 7. 落地检查清单
|
||||
|
||||
- [ ] Timeline 在正确 `GameContent/Scene_* /Timeline/`
|
||||
- [ ] Addressable:`Timeline/{名}` + 正确 Group
|
||||
- [ ] `timelineName` 与 Yarn 一致;`Play On Awake = false`
|
||||
- [ ] Visual 默认关;进 Play 不会提前露 CG
|
||||
- [ ] 切场景 intro:黑屏内 `start` → 短 wait → `fade_out` → `wait_timeline`
|
||||
- [ ] 静图替换:`start` / `hide` 成对
|
||||
- [ ] 跑一次确认无先露场景、无 `load_scene` 参数错误
|
||||
+2
-1
@@ -75,7 +75,8 @@
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.psb filter=lfs diff=lfs merge=lfs -text
|
||||
*.bank filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# DOTween editor images - exclude from LFS (stored as regular binaries, Unity re-import causes false changes)
|
||||
Assets/Plugins/Demigiant/DOTween/Editor/Imgs/*.png -filter -diff -merge
|
||||
Assets/Plugins/Demigiant/DOTween/Editor/Imgs/*.jpg -filter -diff -merge
|
||||
Assets/Plugins/Demigiant/DOTween/Editor/Imgs/*.jpg -filter -diff -merge
|
||||
|
||||
+6
-1
@@ -78,6 +78,9 @@ crashlytics-build.properties
|
||||
# Local Save Files
|
||||
/[Aa]ssets/[Ss]treamingAssets/SaveFiles/*
|
||||
|
||||
# DevSaveFiles lives outside Assets; Unity metadata from the old location is not needed.
|
||||
/DevSaveFiles/**/*.meta
|
||||
|
||||
# Never ignore DLLs in the FMOD subfolder.
|
||||
!/[Aa]ssets/Plugins/FMOD/**/lib/*
|
||||
|
||||
@@ -110,4 +113,6 @@ fmod_editor.log
|
||||
.history/*
|
||||
**/.history/*
|
||||
|
||||
.fake
|
||||
.fake
|
||||
Keystore/aibis-release-key.keystore
|
||||
Keystore/keystore.bat
|
||||
|
||||
Vendored
+3
@@ -56,6 +56,9 @@
|
||||
"temp/": true,
|
||||
"Temp/": true
|
||||
},
|
||||
"files.associations": {
|
||||
"**/Plugins/Android/*Template.gradle": "plaintext"
|
||||
},
|
||||
"dotnet.defaultSolution": "aibis-dream.sln",
|
||||
"dotnet.preferCSharpExtension": true,
|
||||
"cSpell.enableFiletypes": [
|
||||
|
||||
@@ -15,7 +15,7 @@ MonoBehaviour:
|
||||
m_DefaultGroup: 09546aca93a801441859af2a811fe53b
|
||||
m_currentHash:
|
||||
serializedVersion: 2
|
||||
Hash: 1616e06b587c7889ae21faab8aef8e68
|
||||
Hash: e92b9b9776d0601449874b8c1b0ed1ae
|
||||
m_OptimizeCatalogSize: 0
|
||||
m_BuildRemoteCatalog: 0
|
||||
m_BundleLocalCatalog: 0
|
||||
@@ -59,7 +59,6 @@ MonoBehaviour:
|
||||
- {fileID: 11400000, guid: 6d259d339c6615248b893837398c1438, type: 2}
|
||||
- {fileID: 11400000, guid: 8718a71c99923e247a960cbf4e4795f5, type: 2}
|
||||
- {fileID: 11400000, guid: 1e521cfc13b314642b1e682e3be7ea17, type: 2}
|
||||
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
|
||||
- {fileID: 11400000, guid: 724eed66b2d8eef4faf77a4bee4d2845, type: 2}
|
||||
- {fileID: 11400000, guid: 62a92c74b9bd5bd439690727c1831500, type: 2}
|
||||
- {fileID: 11400000, guid: b593dc98ea11a484995123de77d9bf1a, type: 2}
|
||||
@@ -68,6 +67,7 @@ MonoBehaviour:
|
||||
- {fileID: 11400000, guid: dc8d31ed80ea35945812ff6552efbbe5, type: 2}
|
||||
- {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2}
|
||||
- {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2}
|
||||
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
|
||||
m_BuildSettings:
|
||||
m_CompileScriptsInVirtualMode: 0
|
||||
m_CleanupStreamingAssetsAfterBuilds: 1
|
||||
|
||||
@@ -47,6 +47,11 @@ MonoBehaviour:
|
||||
- Locale-en
|
||||
- Locale-ja-JP
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 7ea8c68fdc624de4c86434963c06ec55
|
||||
m_Address: Assets/Language/ChapterInfo/ChapterInfo Shared Data.asset
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
+6
@@ -29,6 +29,12 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- Locale-zh-Hans
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: bdf8ad79a9f707b45805e23c25ccdadb
|
||||
m_Address: ChapterInfo_zh-Hans
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-zh-Hans
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -29,6 +29,12 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- Locale-en
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 8906df9235e52fb41b11fe8776a77032
|
||||
m_Address: ChapterInfo_en
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-en
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
+6
@@ -29,6 +29,12 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- Locale-ja-JP
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 9d8accfdeabc8034c986233920a17184
|
||||
m_Address: ChapterInfo_ja-JP
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-ja-JP
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -52,6 +52,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f19cff809e7020b41ba8ad7da199db86
|
||||
m_Address: "Timeline/day2\u5915\u9633\u533B\u751F\u770B\u4F69\u4F69\u4FA7\u8138"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -82,6 +82,16 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 1799dbcdb4a76624bb89622707581f92
|
||||
m_Address: "Timeline/\u82F1\u91CC\u521D\u767B\u573A"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 3d0198d0d6d221948a3d5ddf9cd2c34f
|
||||
m_Address: "FrameAnimation/\u8BCA\u5BA4\u5916\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -92,6 +92,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: efa335dcb6e706347bd4acf3222331b9
|
||||
m_Address: "FrameAnimation/\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -32,8 +32,8 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 809f95af8dd580448ab804b94fbd9cb2
|
||||
m_Address: "Animation/\u4F69\u4F69"
|
||||
- m_GUID: 26105571c9644aa4194f2b981ee33a58
|
||||
m_Address: "FrameAnimation/\u4F69\u4F69"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
|
||||
@@ -22,23 +22,8 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2d4ccf1c582289344976ab5c3af05515
|
||||
m_Address: "Animation/\u5730\u94C1\u533B\u751F"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 0057bf4b5fe16aa4b8c7db27d0ddfc0a
|
||||
m_Address: "Animation/\u5730\u94C1\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 40f07326ace570241bd93fcfc3decb87
|
||||
m_Address: "Animation/\u5730\u94C1\u706B\u5C71\u6697"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a63b52b3e8d9e0441aaff086b9c1ae6b
|
||||
m_Address: "Animation/\u5730\u94C1\u533B\u751F\u6697"
|
||||
- m_GUID: b0ee8d48dab31814da87651bba9b5041
|
||||
m_Address: "FrameAnimation/\u5730\u94C1\u533B\u751F"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
@@ -57,6 +42,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b02d1e0ae4bc971408a0b6a667597624
|
||||
m_Address: "FrameAnimation/\u5730\u94C1\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -32,6 +32,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b9e730c7e5824c5ca2858a253fe93c98
|
||||
m_Address: Prefab/FrameAnimationActor
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: cd996317bdc6ae8478acb2de956f25d9
|
||||
m_Address: Prefab/AnimaMobileObject
|
||||
m_ReadOnly: 0
|
||||
|
||||
Binary file not shown.
@@ -244,3 +244,14 @@ MonoBehaviour:
|
||||
m_PreInfinity: 0
|
||||
m_PostInfinity: 0
|
||||
m_RotationOrder: 0
|
||||
- m_From: '**ANY CAMERA**'
|
||||
m_To: Dream Camera
|
||||
m_Blend:
|
||||
m_Style: 0
|
||||
m_Time: 2
|
||||
m_CustomCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 0
|
||||
m_PostInfinity: 0
|
||||
m_RotationOrder: 0
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
using Yarn.Markup;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class AutoNextMetadataTests
|
||||
{
|
||||
[Test]
|
||||
public void AutoNextWithoutParameter_UsesExistingFixedDelay()
|
||||
{
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next"));
|
||||
|
||||
Assert.That(lineInfo.isAutoSkip, Is.True);
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoNextWithParameter_UsesSpecifiedSeconds()
|
||||
{
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
|
||||
|
||||
Assert.That(lineInfo.isAutoSkip, Is.True);
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoNextWithZeroDelay_IsValid()
|
||||
{
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next:0"));
|
||||
|
||||
Assert.That(lineInfo.isAutoSkip, Is.True);
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(0f));
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutoNextParameter_UsesInvariantCulture()
|
||||
{
|
||||
var originalCulture = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
|
||||
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = originalCulture;
|
||||
}
|
||||
}
|
||||
|
||||
[TestCase("auto_next:")]
|
||||
[TestCase("auto_next:abc")]
|
||||
[TestCase("auto_next:-1")]
|
||||
[TestCase("auto_next:NaN")]
|
||||
[TestCase("auto_next:Infinity")]
|
||||
[TestCase("auto_next:2147484")]
|
||||
public void AutoNextWithInvalidParameter_FallsBackToExistingFixedDelay(string metadata)
|
||||
{
|
||||
LogAssert.Expect(LogType.Warning, new Regex("auto_next 参数无效"));
|
||||
|
||||
var lineInfo = LineInfo.Generate(CreateLine(metadata));
|
||||
|
||||
Assert.That(lineInfo.isAutoSkip, Is.True);
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
|
||||
}
|
||||
|
||||
[TestCase("auto_next_extra")]
|
||||
[TestCase("auto_next_extra:4.5")]
|
||||
[TestCase("AUTO_NEXT")]
|
||||
[TestCase("AUTO_NEXT:4.5")]
|
||||
public void SimilarMetadata_IsNotRecognized(string metadata)
|
||||
{
|
||||
var line = CreateLine(metadata);
|
||||
|
||||
Assert.That(line.IsAutoSkipLine(), Is.False);
|
||||
Assert.That(line.TryGetAutoNextDelaySeconds(out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParameterizedTag_TakesPrecedenceOverPlainTag()
|
||||
{
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next", "auto_next:2.5"));
|
||||
|
||||
Assert.That(lineInfo.isAutoSkip, Is.True);
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MultipleParameterizedTags_UseFirstAndWarn()
|
||||
{
|
||||
LogAssert.Expect(LogType.Warning, new Regex("存在多个 auto_next 参数标签"));
|
||||
|
||||
var lineInfo = LineInfo.Generate(CreateLine("auto_next:2.5", "auto_next:4.5"));
|
||||
|
||||
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
|
||||
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
|
||||
}
|
||||
|
||||
private static LocalizedLine CreateLine(params string[] metadata)
|
||||
{
|
||||
return new LocalizedLine
|
||||
{
|
||||
TextID = "line:auto-next-test",
|
||||
Metadata = metadata,
|
||||
Text = new MarkupParseResult("Test line", new List<MarkupAttribute>())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d476e493adf4855468ce46409dae6661
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -148,9 +148,19 @@ namespace AibisDream.SystemEditor
|
||||
});
|
||||
|
||||
// 右侧 GraphView
|
||||
// 套一层容器:GraphView 与左侧 Inspector 并列放在 Flex Row 中时,
|
||||
// 内置 RectangleSelector 绘制的框选矩形会出现坐标偏移(已知 Unity 问题)。
|
||||
// 将 GraphView 放在独立的 Flex 子容器中可使其局部原点与父容器对齐,规避该偏移。
|
||||
var graphViewWrapper = new VisualElement();
|
||||
graphViewWrapper.style.flexGrow = 1;
|
||||
graphViewWrapper.style.flexDirection = FlexDirection.Column;
|
||||
graphViewWrapper.style.overflow = Overflow.Hidden;
|
||||
|
||||
_graphView = new ChapterGraphView();
|
||||
_graphView.style.flexGrow = 1;
|
||||
mainContainer.Add(_graphView);
|
||||
graphViewWrapper.Add(_graphView);
|
||||
|
||||
mainContainer.Add(graphViewWrapper);
|
||||
|
||||
root.Add(mainContainer);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace AibisDream.SystemEditor
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("显示信息", EditorStyles.boldLabel);
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("title"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("titleKey"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("description"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("coverPic"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("chapter"));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e244d3d62dec3f4f881f7e681da8c03
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "AibisDream.FrameAnimation.Editor",
|
||||
"rootNamespace": "AibisDream.FrameAnimation.Editor",
|
||||
"references": [
|
||||
"AibisDream.FrameAnimation.Runtime",
|
||||
"Unity.2D.Sprite.Editor"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1ffe139ca2417941a09835467d1e442
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,198 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal static class AsepriteJsonParser
|
||||
{
|
||||
public static bool TryParse(
|
||||
string json,
|
||||
string sourceId,
|
||||
out AsepriteSourceDocument document,
|
||||
out FrameAnimationImportIssue issue)
|
||||
{
|
||||
document = null;
|
||||
issue = null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
issue = Error(FrameAnimationImportIssueCode.JsonInvalid, sourceId, "Aseprite JSON 为空。");
|
||||
return false;
|
||||
}
|
||||
|
||||
var root = JObject.Parse(json);
|
||||
var framesToken = root["frames"];
|
||||
if (framesToken == null ||
|
||||
(framesToken.Type != JTokenType.Object && framesToken.Type != JTokenType.Array))
|
||||
{
|
||||
issue = Error(FrameAnimationImportIssueCode.FramesMissing, sourceId, "JSON 缺少 Object 或 Array 格式的 frames。");
|
||||
return false;
|
||||
}
|
||||
|
||||
var frames = ParseFrames(framesToken);
|
||||
var meta = root["meta"] as JObject;
|
||||
var size = ParseSize(meta?["size"]);
|
||||
var imageName = meta?.Value<string>("image") ?? string.Empty;
|
||||
var tags = ParseTags(meta?["frameTags"]);
|
||||
document = new AsepriteSourceDocument(frames, tags, size, imageName);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
issue = Error(
|
||||
FrameAnimationImportIssueCode.JsonInvalid,
|
||||
sourceId,
|
||||
$"Aseprite JSON 解析失败:{exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryExpandTag(
|
||||
AsepriteSourceTag tag,
|
||||
int frameCount,
|
||||
string sourceId,
|
||||
out IReadOnlyList<int> indices,
|
||||
out FrameAnimationImportIssue issue)
|
||||
{
|
||||
indices = Array.Empty<int>();
|
||||
issue = null;
|
||||
if (tag == null || tag.From < 0 || tag.To < tag.From || tag.To >= frameCount)
|
||||
{
|
||||
issue = Error(
|
||||
FrameAnimationImportIssueCode.TagRangeInvalid,
|
||||
sourceId,
|
||||
$"Tag '{tag?.Name}' 的范围 {tag?.From}..{tag?.To} 无效。");
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new List<int>();
|
||||
switch ((tag.Direction ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "forward":
|
||||
AddAscending(result, tag.From, tag.To);
|
||||
break;
|
||||
case "reverse":
|
||||
AddDescending(result, tag.To, tag.From);
|
||||
break;
|
||||
case "pingpong":
|
||||
AddAscending(result, tag.From, tag.To);
|
||||
AddDescending(result, tag.To - 1, tag.From + 1);
|
||||
break;
|
||||
case "pingpong_reverse":
|
||||
AddDescending(result, tag.To, tag.From);
|
||||
AddAscending(result, tag.From + 1, tag.To - 1);
|
||||
break;
|
||||
default:
|
||||
issue = Error(
|
||||
FrameAnimationImportIssueCode.TagDirectionInvalid,
|
||||
sourceId,
|
||||
$"Tag '{tag.Name}' 使用了不支持的 direction '{tag.Direction}'。");
|
||||
return false;
|
||||
}
|
||||
|
||||
indices = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<AsepriteSourceFrame> ParseFrames(JToken token)
|
||||
{
|
||||
var frames = new List<AsepriteSourceFrame>();
|
||||
if (token is JObject objectFrames)
|
||||
{
|
||||
foreach (var property in objectFrames.Properties())
|
||||
{
|
||||
frames.Add(ParseFrame(property.Name, property.Value));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var item in (JArray)token)
|
||||
{
|
||||
frames.Add(ParseFrame(item.Value<string>("filename") ?? string.Empty, item));
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
private static AsepriteSourceFrame ParseFrame(string frameName, JToken token)
|
||||
{
|
||||
return new AsepriteSourceFrame(
|
||||
frameName,
|
||||
ParseRect(token["frame"]),
|
||||
token.Value<int?>("duration") ?? 0,
|
||||
token.Value<bool?>("rotated") ?? false,
|
||||
token.Value<bool?>("trimmed") ?? false,
|
||||
ParseRect(token["spriteSourceSize"]),
|
||||
ParseSize(token["sourceSize"]));
|
||||
}
|
||||
|
||||
private static List<AsepriteSourceTag> ParseTags(JToken token)
|
||||
{
|
||||
var tags = new List<AsepriteSourceTag>();
|
||||
if (!(token is JArray array))
|
||||
{
|
||||
return tags;
|
||||
}
|
||||
|
||||
foreach (var item in array)
|
||||
{
|
||||
tags.Add(new AsepriteSourceTag(
|
||||
item.Value<string>("name") ?? string.Empty,
|
||||
item.Value<int?>("from") ?? -1,
|
||||
item.Value<int?>("to") ?? -1,
|
||||
item.Value<string>("direction") ?? string.Empty));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
private static RectInt ParseRect(JToken token)
|
||||
{
|
||||
return token == null
|
||||
? default
|
||||
: new RectInt(
|
||||
token.Value<int?>("x") ?? 0,
|
||||
token.Value<int?>("y") ?? 0,
|
||||
token.Value<int?>("w") ?? 0,
|
||||
token.Value<int?>("h") ?? 0);
|
||||
}
|
||||
|
||||
private static Vector2Int ParseSize(JToken token)
|
||||
{
|
||||
return token == null
|
||||
? default
|
||||
: new Vector2Int(token.Value<int?>("w") ?? 0, token.Value<int?>("h") ?? 0);
|
||||
}
|
||||
|
||||
private static void AddAscending(List<int> target, int from, int to)
|
||||
{
|
||||
for (var index = from; index <= to; index++)
|
||||
{
|
||||
target.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDescending(List<int> target, int from, int to)
|
||||
{
|
||||
for (var index = from; index >= to; index--)
|
||||
{
|
||||
target.Add(index);
|
||||
}
|
||||
}
|
||||
|
||||
private static FrameAnimationImportIssue Error(
|
||||
FrameAnimationImportIssueCode code,
|
||||
string sourceId,
|
||||
string message)
|
||||
{
|
||||
return new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
code,
|
||||
sourceId,
|
||||
sourceId,
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a21095473b1495440acab244f9e470c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8edeae42e1831084cbf05db9d9e5d1e2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal sealed class FrameAnimationTextInputWindow : EditorWindow
|
||||
{
|
||||
private string label;
|
||||
private string value;
|
||||
private string warning;
|
||||
private Func<string, string> validate;
|
||||
private Action<string> confirm;
|
||||
private Vector2 scroll;
|
||||
|
||||
public static void Show(
|
||||
string title,
|
||||
string label,
|
||||
string initialValue,
|
||||
string warning,
|
||||
Func<string, string> validate,
|
||||
Action<string> confirm)
|
||||
{
|
||||
var window = CreateInstance<FrameAnimationTextInputWindow>();
|
||||
window.titleContent = new GUIContent(title);
|
||||
window.label = label;
|
||||
window.value = initialValue ?? string.Empty;
|
||||
window.warning = warning ?? string.Empty;
|
||||
window.validate = validate;
|
||||
window.confirm = confirm;
|
||||
window.minSize = new Vector2(460f, 220f);
|
||||
window.maxSize = new Vector2(700f, 420f);
|
||||
window.ShowUtility();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
scroll = EditorGUILayout.BeginScrollView(scroll);
|
||||
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
|
||||
GUI.SetNextControlName("FrameAnimationTextInput");
|
||||
value = EditorGUILayout.TextField(value);
|
||||
if (!string.IsNullOrEmpty(warning))
|
||||
{
|
||||
EditorGUILayout.HelpBox(warning, MessageType.Warning);
|
||||
}
|
||||
var error = validate?.Invoke(value) ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
EditorGUILayout.HelpBox(error, MessageType.Error);
|
||||
}
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
if (GUILayout.Button("取消", GUILayout.Width(90f)))
|
||||
{
|
||||
Close();
|
||||
}
|
||||
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
|
||||
{
|
||||
if (GUILayout.Button("确认", GUILayout.Width(90f)))
|
||||
{
|
||||
var callback = confirm;
|
||||
var result = value;
|
||||
Close();
|
||||
callback?.Invoke(result);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EditorApplication.delayCall += FocusInput;
|
||||
}
|
||||
|
||||
private void FocusInput()
|
||||
{
|
||||
if (this != null)
|
||||
{
|
||||
Focus();
|
||||
EditorGUI.FocusTextInControl("FrameAnimationTextInput");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationManualClipWindow : EditorWindow
|
||||
{
|
||||
private FrameAnimationGraph graph;
|
||||
private FrameClip source;
|
||||
private string id = "NewClip";
|
||||
private string displayName = "New Clip";
|
||||
private bool external;
|
||||
private Action<FrameClip> completed;
|
||||
|
||||
public static void Show(
|
||||
FrameAnimationGraph graph,
|
||||
FrameClip source,
|
||||
Action<FrameClip> completed)
|
||||
{
|
||||
var window = CreateInstance<FrameAnimationManualClipWindow>();
|
||||
window.titleContent = new GUIContent(source == null ? "Create Manual Clip" : "Copy As Manual Clip");
|
||||
window.graph = graph;
|
||||
window.source = source;
|
||||
window.completed = completed;
|
||||
var baseId = source != null ? source.Id + "_Manual" : "NewClip";
|
||||
window.id = MakeUniqueId(graph, baseId);
|
||||
window.displayName = source != null ? source.DisplayName + " Manual" : window.id;
|
||||
window.minSize = new Vector2(460f, 260f);
|
||||
window.maxSize = new Vector2(700f, 420f);
|
||||
window.ShowUtility();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField(source == null ? "创建 Manual Clip" : "复制为 Manual Clip", EditorStyles.boldLabel);
|
||||
id = EditorGUILayout.TextField("ID", id);
|
||||
displayName = EditorGUILayout.TextField("Display Name", displayName);
|
||||
external = EditorGUILayout.ToggleLeft("保存为外部独立 .asset(默认保存为 Graph sub-asset)", external);
|
||||
var error = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
|
||||
? string.Empty
|
||||
: "ID 为空或与当前 Graph 的 Clip / Flow 冲突。";
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
EditorGUILayout.HelpBox(error, MessageType.Error);
|
||||
}
|
||||
if (source != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"帧表将深拷贝,但 Sprite 仍复用原资源;ImportSource 关联会被清除。",
|
||||
MessageType.Info);
|
||||
}
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
if (GUILayout.Button("取消", GUILayout.Width(90f)))
|
||||
{
|
||||
Close();
|
||||
}
|
||||
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
|
||||
{
|
||||
if (GUILayout.Button("创建", GUILayout.Width(90f)))
|
||||
{
|
||||
Create();
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void Create()
|
||||
{
|
||||
var path = string.Empty;
|
||||
if (external)
|
||||
{
|
||||
path = EditorUtility.SaveFilePanelInProject(
|
||||
"保存外部 Manual Clip",
|
||||
id,
|
||||
"asset",
|
||||
"请选择 Assets 下的保存位置。");
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var succeeded = source == null
|
||||
? FrameAnimationAssetOperations.CreateManualClip(graph, id, displayName, path, out var result, out var error)
|
||||
: FrameAnimationAssetOperations.CopyToManual(graph, source, id, displayName, path, out result, out error);
|
||||
if (!succeeded)
|
||||
{
|
||||
EditorUtility.DisplayDialog("操作失败", error, "确定");
|
||||
return;
|
||||
}
|
||||
var callback = completed;
|
||||
Close();
|
||||
callback?.Invoke(result);
|
||||
}
|
||||
|
||||
private static string MakeUniqueId(FrameAnimationGraph graph, string baseId)
|
||||
{
|
||||
var candidate = baseId;
|
||||
var suffix = 2;
|
||||
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
|
||||
{
|
||||
candidate = baseId + suffix++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationExistingClipWindow : EditorWindow
|
||||
{
|
||||
private FrameAnimationGraph graph;
|
||||
private FrameClip clip;
|
||||
private Action<FrameClip> completed;
|
||||
|
||||
public static void Show(FrameAnimationGraph graph, Action<FrameClip> completed)
|
||||
{
|
||||
var window = CreateInstance<FrameAnimationExistingClipWindow>();
|
||||
window.titleContent = new GUIContent("Add Existing Manual Clip");
|
||||
window.graph = graph;
|
||||
window.completed = completed;
|
||||
window.minSize = new Vector2(460f, 180f);
|
||||
window.maxSize = new Vector2(640f, 260f);
|
||||
window.ShowUtility();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("添加外部 Manual Clip", EditorStyles.boldLabel);
|
||||
clip = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
if (GUILayout.Button("取消", GUILayout.Width(90f)))
|
||||
{
|
||||
Close();
|
||||
}
|
||||
using (new EditorGUI.DisabledScope(clip == null))
|
||||
{
|
||||
if (GUILayout.Button("添加", GUILayout.Width(90f)))
|
||||
{
|
||||
if (!FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out var error))
|
||||
{
|
||||
EditorUtility.DisplayDialog("添加失败", error, "确定");
|
||||
return;
|
||||
}
|
||||
var callback = completed;
|
||||
var result = clip;
|
||||
Close();
|
||||
callback?.Invoke(result);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationFlowWindow : EditorWindow
|
||||
{
|
||||
private FrameAnimationGraph graph;
|
||||
private AnimationNode entry;
|
||||
private string id;
|
||||
private string displayName;
|
||||
private Action<AnimationFlow> completed;
|
||||
|
||||
internal static void Show(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationNode entry,
|
||||
Action<AnimationFlow> completed)
|
||||
{
|
||||
var window = CreateInstance<FrameAnimationFlowWindow>();
|
||||
window.titleContent = new GUIContent("Create Animation Flow");
|
||||
window.graph = graph;
|
||||
window.entry = entry;
|
||||
window.id = FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, entry);
|
||||
window.displayName = window.id;
|
||||
window.completed = completed;
|
||||
window.minSize = new Vector2(460f, 230f);
|
||||
window.maxSize = new Vector2(700f, 360f);
|
||||
window.ShowUtility();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("从选中节点创建 Flow", EditorStyles.boldLabel);
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.TextField("Entry Node", entry?.DisplayName ?? string.Empty);
|
||||
}
|
||||
id = EditorGUILayout.TextField("ID", id);
|
||||
displayName = EditorGUILayout.TextField("Display Name", displayName);
|
||||
var idError = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
|
||||
? string.Empty
|
||||
: "ID 为空或与当前 Graph 中的 Clip / Flow 冲突。";
|
||||
var owner = graph?.Flows.FirstOrDefault(flow => flow != null && flow.EntryNodeId == entry?.InternalId);
|
||||
if (owner != null)
|
||||
{
|
||||
idError = $"该节点已是 Flow '{owner.Id}' 的入口。";
|
||||
}
|
||||
if (!string.IsNullOrEmpty(idError))
|
||||
{
|
||||
EditorGUILayout.HelpBox(idError, MessageType.Error);
|
||||
}
|
||||
GUILayout.FlexibleSpace();
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
GUILayout.FlexibleSpace();
|
||||
if (GUILayout.Button("取消", GUILayout.Width(90f))) Close();
|
||||
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(idError)))
|
||||
{
|
||||
if (GUILayout.Button("创建", GUILayout.Width(90f)))
|
||||
{
|
||||
if (!FrameAnimationGraphMutationService.CreateFlow(
|
||||
graph, entry, id, displayName, out var flow, out var error))
|
||||
{
|
||||
EditorUtility.DisplayDialog("创建 Flow 失败", error, "确定");
|
||||
return;
|
||||
}
|
||||
var callback = completed;
|
||||
Close();
|
||||
callback?.Invoke(flow);
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8f58e7b80263c24087a98874796e91d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,744 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal enum FrameAnimationEditorSelectionKind
|
||||
{
|
||||
None,
|
||||
Graph,
|
||||
Clip,
|
||||
Flow,
|
||||
Source,
|
||||
Node,
|
||||
Edge
|
||||
}
|
||||
|
||||
internal readonly struct FrameAnimationEditorSelection
|
||||
{
|
||||
public FrameAnimationEditorSelectionKind Kind { get; }
|
||||
public object Value { get; }
|
||||
|
||||
public FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind kind, object value)
|
||||
{
|
||||
Kind = kind;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum FrameAnimationClipFilter
|
||||
{
|
||||
All,
|
||||
Manual,
|
||||
Imported,
|
||||
Normal,
|
||||
Missing,
|
||||
Referenced,
|
||||
Unused
|
||||
}
|
||||
|
||||
internal enum FrameAnimationClipSort
|
||||
{
|
||||
Name,
|
||||
Source,
|
||||
FrameCount,
|
||||
Duration,
|
||||
Missing,
|
||||
ReferenceCount
|
||||
}
|
||||
|
||||
internal static class FrameAnimationAssetReferenceIndex
|
||||
{
|
||||
public static IReadOnlyList<FrameAnimationGraph> FindGraphsReferencing(FrameClip clip)
|
||||
{
|
||||
if (clip == null)
|
||||
{
|
||||
return Array.Empty<FrameAnimationGraph>();
|
||||
}
|
||||
|
||||
return AssetDatabase.FindAssets("t:FrameAnimationGraph")
|
||||
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid)))
|
||||
.Where(graph => graph != null && graph.Clips.Any(item => item == clip))
|
||||
.Distinct()
|
||||
.OrderBy(graph => AssetDatabase.GetAssetPath(graph), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationResourceQuery
|
||||
{
|
||||
public static IReadOnlyList<FrameClip> QueryClips(
|
||||
FrameAnimationGraph graph,
|
||||
string search,
|
||||
FrameAnimationClipFilter filter,
|
||||
FrameAnimationClipSort sort)
|
||||
{
|
||||
if (graph == null)
|
||||
{
|
||||
return Array.Empty<FrameClip>();
|
||||
}
|
||||
|
||||
var sourceNames = graph.ImportSources.Where(source => source != null)
|
||||
.GroupBy(source => source.InternalId ?? string.Empty)
|
||||
.ToDictionary(group => group.Key, group => group.First().DisplayName ?? string.Empty);
|
||||
var query = graph.Clips.Where(clip => clip != null && MatchesSearch(clip, sourceNames, search));
|
||||
query = filter switch
|
||||
{
|
||||
FrameAnimationClipFilter.Manual => query.Where(clip => !clip.IsImported),
|
||||
FrameAnimationClipFilter.Imported => query.Where(clip => clip.IsImported),
|
||||
FrameAnimationClipFilter.Normal => query.Where(clip => !clip.IsImported || !clip.ImportInfo.IsMissingFromSource),
|
||||
FrameAnimationClipFilter.Missing => query.Where(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource),
|
||||
FrameAnimationClipFilter.Referenced => query.Where(clip => ReferenceCount(graph, clip) > 0),
|
||||
FrameAnimationClipFilter.Unused => query.Where(clip => ReferenceCount(graph, clip) == 0),
|
||||
_ => query
|
||||
};
|
||||
|
||||
IOrderedEnumerable<FrameClip> ordered = sort switch
|
||||
{
|
||||
FrameAnimationClipSort.Source => query.OrderBy(clip => SourceName(clip, sourceNames), StringComparer.Ordinal),
|
||||
FrameAnimationClipSort.FrameCount => query.OrderBy(clip => clip.FrameCount),
|
||||
FrameAnimationClipSort.Duration => query.OrderBy(clip => clip.TotalDurationMs),
|
||||
FrameAnimationClipSort.Missing => query.OrderBy(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource ? 1 : 0),
|
||||
FrameAnimationClipSort.ReferenceCount => query.OrderBy(clip => ReferenceCount(graph, clip)),
|
||||
_ => query.OrderBy(clip => clip.DisplayName ?? clip.Id, StringComparer.Ordinal)
|
||||
};
|
||||
return ordered.ThenBy(clip => clip.Id, StringComparer.Ordinal)
|
||||
.ThenBy(clip => AssetDatabase.GetAssetPath(clip), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static int ReferenceCount(FrameAnimationGraph graph, FrameClip clip)
|
||||
{
|
||||
return graph?.Nodes.Count(node => node != null && node.ClipId == clip?.Id) ?? 0;
|
||||
}
|
||||
|
||||
public static string SourceName(
|
||||
FrameClip clip,
|
||||
IReadOnlyDictionary<string, string> sourceNames = null)
|
||||
{
|
||||
if (clip == null || !clip.IsImported)
|
||||
{
|
||||
return clip != null && clip.HasStandaloneImportSource ? "Standalone Source" : "Manual";
|
||||
}
|
||||
var sourceId = clip.ImportInfo?.ImportSourceId;
|
||||
if (sourceNames != null && !string.IsNullOrEmpty(sourceId) &&
|
||||
sourceNames.TryGetValue(sourceId, out var name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
return sourceId ?? string.Empty;
|
||||
}
|
||||
|
||||
private static bool MatchesSearch(
|
||||
FrameClip clip,
|
||||
IReadOnlyDictionary<string, string> sourceNames,
|
||||
string search)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return Contains(clip.Id, search) || Contains(clip.DisplayName, search) ||
|
||||
Contains(clip.ImportInfo?.SourceTagName, search) ||
|
||||
Contains(SourceName(clip, sourceNames), search);
|
||||
}
|
||||
|
||||
private static bool Contains(string value, string search)
|
||||
{
|
||||
return !string.IsNullOrEmpty(value) && value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationAssetOperations
|
||||
{
|
||||
public static bool IsPlayableIdAvailable(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
FrameClip exceptClip = null,
|
||||
AnimationFlow exceptFlow = null)
|
||||
{
|
||||
return graph != null && !string.IsNullOrWhiteSpace(id) &&
|
||||
graph.Clips.All(clip => clip == null || clip == exceptClip || clip.Id != id) &&
|
||||
graph.Flows.All(flow => flow == null || flow == exceptFlow || flow.Id != id);
|
||||
}
|
||||
|
||||
public static bool CreateManualClip(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
string displayName,
|
||||
string externalPath,
|
||||
out FrameClip clip,
|
||||
out string error)
|
||||
{
|
||||
clip = null;
|
||||
error = string.Empty;
|
||||
if (!IsPlayableIdAvailable(graph, id))
|
||||
{
|
||||
error = "Clip id 为空或与现有 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
|
||||
Undo.IncrementCurrentGroup();
|
||||
var group = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Create Manual Frame Clip");
|
||||
try
|
||||
{
|
||||
Undo.RecordObject(graph, "Add Manual Frame Clip");
|
||||
clip = ScriptableObject.CreateInstance<FrameClip>();
|
||||
clip.name = id;
|
||||
clip.Configure(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName,
|
||||
Array.Empty<FrameAnimationFrame>(), 1f,
|
||||
graph.Settings.NewManualClipDefaultEndBehavior);
|
||||
if (string.IsNullOrWhiteSpace(externalPath))
|
||||
{
|
||||
AssetDatabase.AddObjectToAsset(clip, graph);
|
||||
}
|
||||
else
|
||||
{
|
||||
AssetDatabase.CreateAsset(clip, externalPath);
|
||||
}
|
||||
Undo.RegisterCreatedObjectUndo(clip, "Create Manual Frame Clip");
|
||||
graph.AddClip(clip);
|
||||
EditorUtility.SetDirty(graph);
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
Undo.CollapseUndoOperations(group);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Undo.RevertAllDownToGroup(group);
|
||||
clip = null;
|
||||
error = exception.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool AddExistingManualClip(
|
||||
FrameAnimationGraph graph,
|
||||
FrameClip clip,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || clip == null)
|
||||
{
|
||||
error = "Graph 或 Clip 为空。";
|
||||
return false;
|
||||
}
|
||||
if (clip.IsImported || AssetDatabase.IsSubAsset(clip) ||
|
||||
string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip)))
|
||||
{
|
||||
error = "只能添加独立 .asset 形式、且不由 Graph ImportSource 管理的 Clip。";
|
||||
return false;
|
||||
}
|
||||
if (graph.Clips.Contains(clip))
|
||||
{
|
||||
error = "当前 Graph 已引用该 Clip。";
|
||||
return false;
|
||||
}
|
||||
if (!IsPlayableIdAvailable(graph, clip.Id))
|
||||
{
|
||||
error = $"playable id '{clip.Id}' 与当前 Graph 中的 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
|
||||
Undo.RecordObject(graph, "Add Existing Manual Frame Clip");
|
||||
graph.AddClip(clip);
|
||||
EditorUtility.SetDirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool CopyToManual(
|
||||
FrameAnimationGraph graph,
|
||||
FrameClip source,
|
||||
string id,
|
||||
string displayName,
|
||||
string externalPath,
|
||||
out FrameClip copy,
|
||||
out string error)
|
||||
{
|
||||
copy = null;
|
||||
error = string.Empty;
|
||||
if (source == null || !IsPlayableIdAvailable(graph, id))
|
||||
{
|
||||
error = "来源为空,或新 id 与现有 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
|
||||
Undo.IncrementCurrentGroup();
|
||||
var group = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Copy Frame Clip As Manual");
|
||||
try
|
||||
{
|
||||
Undo.RecordObject(graph, "Add Manual Frame Clip Copy");
|
||||
copy = ScriptableObject.CreateInstance<FrameClip>();
|
||||
copy.name = id;
|
||||
copy.Configure(id,
|
||||
string.IsNullOrWhiteSpace(displayName) ? source.DisplayName : displayName,
|
||||
source.Frames.Select(frame => frame == null
|
||||
? null
|
||||
: new FrameAnimationFrame(frame.Sprite, frame.DurationMs, frame.FrameName, frame.SourceIndex)),
|
||||
source.Speed,
|
||||
source.DefaultEndBehavior);
|
||||
if (string.IsNullOrWhiteSpace(externalPath))
|
||||
{
|
||||
AssetDatabase.AddObjectToAsset(copy, graph);
|
||||
}
|
||||
else
|
||||
{
|
||||
AssetDatabase.CreateAsset(copy, externalPath);
|
||||
}
|
||||
Undo.RegisterCreatedObjectUndo(copy, "Create Manual Frame Clip Copy");
|
||||
graph.AddClip(copy);
|
||||
EditorUtility.SetDirty(graph);
|
||||
EditorUtility.SetDirty(copy);
|
||||
AssetDatabase.SaveAssets();
|
||||
Undo.CollapseUndoOperations(group);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Undo.RevertAllDownToGroup(group);
|
||||
copy = null;
|
||||
error = exception.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool RenameClip(
|
||||
FrameAnimationGraph graph,
|
||||
FrameClip clip,
|
||||
string newId,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || clip == null || !graph.Clips.Contains(clip))
|
||||
{
|
||||
error = "Clip 不属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
|
||||
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
|
||||
{
|
||||
error = "外部 Manual Clip 被多个 Graph 共享,禁止重命名:" +
|
||||
string.Join(", ", owners.Select(owner => owner.name));
|
||||
return false;
|
||||
}
|
||||
if (!IsPlayableIdAvailable(graph, newId, clip))
|
||||
{
|
||||
error = "新 id 为空或与现有 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var oldId = clip.Id;
|
||||
Undo.IncrementCurrentGroup();
|
||||
var group = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Rename Frame Clip");
|
||||
Undo.RecordObject(graph, "Update Frame Clip References");
|
||||
Undo.RecordObject(clip, "Rename Frame Clip");
|
||||
clip.SetId(newId);
|
||||
foreach (var node in graph.Nodes.Where(node => node != null && node.ClipId == oldId))
|
||||
{
|
||||
node.SetClipId(newId);
|
||||
}
|
||||
if (graph.Settings.DefaultPlayableId == oldId)
|
||||
{
|
||||
graph.Settings.SetDefaultPlayableId(newId);
|
||||
}
|
||||
EditorUtility.SetDirty(graph);
|
||||
EditorUtility.SetDirty(clip);
|
||||
Undo.CollapseUndoOperations(group);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RenameFlow(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationFlow flow,
|
||||
string newId,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || flow == null || !graph.Flows.Contains(flow) ||
|
||||
!IsPlayableIdAvailable(graph, newId, exceptFlow: flow))
|
||||
{
|
||||
error = "Flow 不属于当前 Graph,或新 id 与现有 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var oldId = flow.Id;
|
||||
Undo.RecordObject(graph, "Rename Animation Flow");
|
||||
flow.SetId(newId);
|
||||
if (graph.Settings.DefaultPlayableId == oldId)
|
||||
{
|
||||
graph.Settings.SetDefaultPlayableId(newId);
|
||||
}
|
||||
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null && data.FlowId == oldId))
|
||||
{
|
||||
data.SetFlowId(newId);
|
||||
}
|
||||
EditorUtility.SetDirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RenameGraph(FrameAnimationGraph graph, string newId, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || string.IsNullOrWhiteSpace(newId))
|
||||
{
|
||||
error = "Graph id 不能为空。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Rename Frame Animation Graph");
|
||||
graph.SetId(newId);
|
||||
EditorUtility.SetDirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
|
||||
{
|
||||
if (!CanRemoveClip(graph, clip, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Undo.IncrementCurrentGroup();
|
||||
var group = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Remove Frame Clip");
|
||||
Undo.RecordObject(graph, "Remove Frame Clip Reference");
|
||||
graph.RemoveClip(clip);
|
||||
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) == AssetDatabase.GetAssetPath(graph))
|
||||
{
|
||||
Undo.DestroyObjectImmediate(clip);
|
||||
}
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
Undo.CollapseUndoOperations(group);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool CanRemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || clip == null || !graph.Clips.Contains(clip))
|
||||
{
|
||||
error = "Clip 不属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
var nodeCount = graph.Nodes.Count(node => node != null && node.ClipId == clip.Id);
|
||||
if (nodeCount > 0)
|
||||
{
|
||||
error = $"Clip 仍被 {nodeCount} 个 AnimationNode 引用。";
|
||||
return false;
|
||||
}
|
||||
if (graph.Settings.DefaultPlayableId == clip.Id)
|
||||
{
|
||||
error = "Clip 仍被 defaultPlayableId 引用。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
|
||||
{
|
||||
if (!CanRemoveFlow(graph, flow, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Remove Animation Flow");
|
||||
graph.RemoveFlow(flow);
|
||||
graph.EditorData.RemoveFlowData(flow.Id);
|
||||
EditorUtility.SetDirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool CanRemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || flow == null || !graph.Flows.Contains(flow))
|
||||
{
|
||||
error = "Flow 不属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
if (graph.Settings.DefaultPlayableId == flow.Id)
|
||||
{
|
||||
error = "Flow 仍被 defaultPlayableId 引用。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool RemoveSource(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationImportSource source,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || source == null || !graph.ImportSources.Contains(source))
|
||||
{
|
||||
error = "ImportSource 不属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
var clips = graph.Clips.Where(clip => clip != null &&
|
||||
clip.ImportInfo?.ImportSourceId == source.InternalId).ToArray();
|
||||
if (clips.Length > 0)
|
||||
{
|
||||
error = "ImportSource 仍关联 Imported Clip:" + string.Join(", ", clips.Select(clip => clip.Id));
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Remove Frame Animation ImportSource");
|
||||
graph.RemoveImportSource(source);
|
||||
EditorUtility.SetDirty(graph);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationEditorIssue
|
||||
{
|
||||
public FrameAnimationValidationSeverity Severity { get; }
|
||||
public string Code { get; }
|
||||
public FrameAnimationEditorSelection Selection { get; }
|
||||
public string Message { get; }
|
||||
public string Suggestion { get; }
|
||||
|
||||
public FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity severity,
|
||||
string code,
|
||||
FrameAnimationEditorSelection selection,
|
||||
string message,
|
||||
string suggestion)
|
||||
{
|
||||
Severity = severity;
|
||||
Code = code ?? string.Empty;
|
||||
Selection = selection;
|
||||
Message = message ?? string.Empty;
|
||||
Suggestion = suggestion ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationEditorValidationService
|
||||
{
|
||||
public static IReadOnlyList<FrameAnimationEditorIssue> Validate(
|
||||
FrameAnimationGraph graph,
|
||||
bool includeImports,
|
||||
out FrameAnimationImportPreview importPreview)
|
||||
{
|
||||
importPreview = null;
|
||||
if (graph == null)
|
||||
{
|
||||
return Array.Empty<FrameAnimationEditorIssue>();
|
||||
}
|
||||
|
||||
var issues = FrameAnimationGraphValidator.Validate(graph).Issues
|
||||
.Select(issue => new FrameAnimationEditorIssue(
|
||||
issue.Severity,
|
||||
issue.Code.ToString(),
|
||||
Locate(graph, issue.TargetType, issue.TargetId),
|
||||
issue.Message,
|
||||
Suggestion(issue.Code)))
|
||||
.ToList();
|
||||
|
||||
foreach (var duplicate in graph.Clips.Where(clip => clip != null)
|
||||
.GroupBy(clip => clip).Where(group => group.Count() > 1))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"DuplicateClipReference",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, duplicate.Key),
|
||||
$"Graph 重复引用 Clip '{duplicate.Key.Id}'。",
|
||||
"移除重复的 Clip 引用。"));
|
||||
}
|
||||
|
||||
var graphPath = AssetDatabase.GetAssetPath(graph);
|
||||
foreach (var clip in graph.Clips.Where(clip => clip != null))
|
||||
{
|
||||
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) != graphPath)
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"ForeignSubAssetClip",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
|
||||
$"Clip '{clip.Id}' 是其他资产的 sub-asset。",
|
||||
"移除该引用,并复制为当前 Graph 的 Manual Clip。"));
|
||||
}
|
||||
if (clip.IsImported && (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"ImportedClipOwnership",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
|
||||
$"Imported Clip '{clip.Id}' 不是当前 Graph 的 sub-asset。",
|
||||
"删除非法引用并从对应 ImportSource 重新刷新。"));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in graph.Nodes.Where(node => node != null))
|
||||
{
|
||||
var clip = graph.Clips.SingleOrDefault(item => item != null && item.Id == node.ClipId);
|
||||
if (clip?.ImportInfo?.IsMissingFromSource == true)
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"NodeReferencesMissingClip",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
|
||||
$"Node '{node.InternalId}' 引用了 Missing Clip '{clip.Id}'。",
|
||||
"恢复源 Tag、修改节点引用或删除该节点。"));
|
||||
}
|
||||
}
|
||||
|
||||
var nodeIds = new HashSet<string>(graph.Nodes.Where(node => node != null)
|
||||
.Select(node => node.InternalId));
|
||||
foreach (var node in graph.Nodes.Where(node => node != null &&
|
||||
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
"EditorDataMissing",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
|
||||
$"Node '{node.InternalId}' 缺少画布位置数据。",
|
||||
"打开 Graph Editor 初始化位置,或执行自动布局。"));
|
||||
}
|
||||
foreach (var data in graph.EditorData.NodeEditorData.Where(data => data != null &&
|
||||
!nodeIds.Contains(data.NodeId)))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
"EditorDataOrphan",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
|
||||
$"存在孤立的 NodeEditorData '{data.NodeId}'。",
|
||||
"该数据不会影响运行时;确认无引用后可由后续维护工具清理。"));
|
||||
}
|
||||
foreach (var duplicate in graph.EditorData.NodeEditorData.Where(data => data != null)
|
||||
.GroupBy(data => data.NodeId).Where(group => group.Count() > 1))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"EditorDataDuplicate",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
|
||||
$"NodeEditorData '{duplicate.Key}' 重复。",
|
||||
"保留一条对应位置数据并移除重复项。"));
|
||||
}
|
||||
|
||||
var flowIds = new HashSet<string>(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id));
|
||||
foreach (var flow in graph.Flows.Where(flow => flow != null &&
|
||||
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
"FlowEditorDataMissing",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow),
|
||||
$"Flow '{flow.Id}' 缺少画布颜色数据。",
|
||||
"打开 Graph Editor 初始化 Flow 颜色。"));
|
||||
}
|
||||
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null &&
|
||||
!flowIds.Contains(data.FlowId)))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
"FlowEditorDataOrphan",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
|
||||
$"存在孤立的 FlowEditorData '{data.FlowId}'。",
|
||||
"确认 Flow 已删除后可由后续维护工具清理。"));
|
||||
}
|
||||
foreach (var duplicate in graph.EditorData.FlowEditorData.Where(data => data != null)
|
||||
.GroupBy(data => data.FlowId).Where(group => group.Count() > 1))
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
"FlowEditorDataDuplicate",
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
|
||||
$"FlowEditorData '{duplicate.Key}' 重复。",
|
||||
"保留一条对应颜色数据并移除重复项。"));
|
||||
}
|
||||
|
||||
if (!includeImports)
|
||||
{
|
||||
return issues;
|
||||
}
|
||||
|
||||
importPreview = FrameAnimationImportService.PreviewAll(graph);
|
||||
foreach (var sourcePreview in importPreview.Sources)
|
||||
{
|
||||
foreach (var importIssue in sourcePreview.Issues)
|
||||
{
|
||||
issues.Add(new FrameAnimationEditorIssue(
|
||||
importIssue.Severity,
|
||||
importIssue.Code.ToString(),
|
||||
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, sourcePreview.Source),
|
||||
importIssue.Message,
|
||||
"打开对应 ImportSource,修复来源设置后重新 Preview。"));
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
private static FrameAnimationEditorSelection Locate(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationValidationTargetType type,
|
||||
string id)
|
||||
{
|
||||
return type switch
|
||||
{
|
||||
FrameAnimationValidationTargetType.Clip => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Clip, graph.Clips.FirstOrDefault(clip => clip != null && clip.Id == id)),
|
||||
FrameAnimationValidationTargetType.Flow => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Flow, graph.Flows.FirstOrDefault(flow => flow != null && flow.Id == id)),
|
||||
FrameAnimationValidationTargetType.Node => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Node, graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == id)),
|
||||
FrameAnimationValidationTargetType.Edge => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Edge, graph.Edges.FirstOrDefault(edge => edge != null && edge.InternalId == id)),
|
||||
FrameAnimationValidationTargetType.ImportSource => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Source, graph.ImportSources.FirstOrDefault(source => source != null && source.InternalId == id)),
|
||||
_ => new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph)
|
||||
};
|
||||
}
|
||||
|
||||
private static string Suggestion(FrameAnimationValidationCode code)
|
||||
{
|
||||
return code switch
|
||||
{
|
||||
FrameAnimationValidationCode.PlayableIdEmpty => "使用正式 Rename 命令设置唯一 playable id。",
|
||||
FrameAnimationValidationCode.PlayableIdDuplicate => "重命名冲突的 Clip 或 Flow。",
|
||||
FrameAnimationValidationCode.ClipFramesEmpty => "为 Manual Clip 添加帧,或刷新 Imported Clip 来源。",
|
||||
FrameAnimationValidationCode.FrameDurationInvalid => "将 durationMs 修改为大于 0 的整数。",
|
||||
FrameAnimationValidationCode.DefaultPlayableInvalid => "在 Graph 属性中选择有效的默认 Clip 或 Flow。",
|
||||
FrameAnimationValidationCode.ImportedClipMissingFromSource => "恢复源 Tag,或确认没有引用后删除 Missing Clip。",
|
||||
_ => "打开问题对象并修复对应字段或引用。"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationWorkspaceState
|
||||
{
|
||||
private const string Prefix = "AibisDream.FrameAnimation.Workspace.";
|
||||
|
||||
public static string Key(FrameAnimationGraph graph, string name)
|
||||
{
|
||||
var graphPath = graph != null ? AssetDatabase.GetAssetPath(graph) : string.Empty;
|
||||
var graphGuid = string.IsNullOrEmpty(graphPath) ? "none" : AssetDatabase.AssetPathToGUID(graphPath);
|
||||
var project = Hash128.Compute(Application.dataPath).ToString();
|
||||
return Prefix + project + "." + graphGuid + "." + name;
|
||||
}
|
||||
|
||||
public static float GetFloat(FrameAnimationGraph graph, string name, float fallback) =>
|
||||
EditorPrefs.GetFloat(Key(graph, name), fallback);
|
||||
|
||||
public static void SetFloat(FrameAnimationGraph graph, string name, float value) =>
|
||||
EditorPrefs.SetFloat(Key(graph, name), value);
|
||||
|
||||
public static string GetString(FrameAnimationGraph graph, string name, string fallback) =>
|
||||
EditorPrefs.GetString(Key(graph, name), fallback);
|
||||
|
||||
public static void SetString(FrameAnimationGraph graph, string name, string value) =>
|
||||
EditorPrefs.SetString(Key(graph, name), value ?? string.Empty);
|
||||
|
||||
public static bool GetBool(FrameAnimationGraph graph, string name, bool fallback) =>
|
||||
EditorPrefs.GetBool(Key(graph, name), fallback);
|
||||
|
||||
public static void SetBool(FrameAnimationGraph graph, string name, bool value) =>
|
||||
EditorPrefs.SetBool(Key(graph, name), value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2643ad174ef237e43b98400d60c4a201
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,603 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal static class FrameAnimationFlowFocusPolicy
|
||||
{
|
||||
public static AnimationFlow FindFocusedFlow(FrameAnimationGraph graph, string focusedFlowId)
|
||||
{
|
||||
return graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId);
|
||||
}
|
||||
|
||||
public static bool Contains(
|
||||
FrameAnimationGraph graph,
|
||||
string focusedFlowId,
|
||||
FrameAnimationEditorSelection selection)
|
||||
{
|
||||
var flow = FindFocusedFlow(graph, focusedFlowId);
|
||||
if (flow == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
|
||||
return selection.Value switch
|
||||
{
|
||||
AnimationNode node => reachable.Nodes.Contains(node),
|
||||
AnimationEdge edge => reachable.Edges.Contains(edge),
|
||||
AnimationFlow selectedFlow => selectedFlow == flow,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
public static bool ShouldExitForSelection(
|
||||
FrameAnimationGraph graph,
|
||||
string focusedFlowId,
|
||||
FrameAnimationEditorSelection selection)
|
||||
{
|
||||
if (string.IsNullOrEmpty(focusedFlowId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return selection.Kind switch
|
||||
{
|
||||
FrameAnimationEditorSelectionKind.None => false,
|
||||
FrameAnimationEditorSelectionKind.Flow => false,
|
||||
FrameAnimationEditorSelectionKind.Node => !Contains(graph, focusedFlowId, selection),
|
||||
FrameAnimationEditorSelectionKind.Edge => !Contains(graph, focusedFlowId, selection),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
public static bool ShouldExitForSelectionSet(
|
||||
FrameAnimationGraph graph,
|
||||
string focusedFlowId,
|
||||
IEnumerable<FrameAnimationEditorSelection> selections)
|
||||
{
|
||||
if (string.IsNullOrEmpty(focusedFlowId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var items = (selections ?? Array.Empty<FrameAnimationEditorSelection>()).ToArray();
|
||||
return items.Length > 0 && items.Any(item =>
|
||||
(item.Kind == FrameAnimationEditorSelectionKind.Node ||
|
||||
item.Kind == FrameAnimationEditorSelectionKind.Edge)
|
||||
? !Contains(graph, focusedFlowId, item)
|
||||
: ShouldExitForSelection(graph, focusedFlowId, item));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationGraphImpact
|
||||
{
|
||||
public IReadOnlyList<AnimationFlow> Flows { get; }
|
||||
public int LostNodeCount { get; }
|
||||
|
||||
internal FrameAnimationGraphImpact(IReadOnlyList<AnimationFlow> flows, int lostNodeCount)
|
||||
{
|
||||
Flows = flows ?? Array.Empty<AnimationFlow>();
|
||||
LostNodeCount = lostNodeCount;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationGraphImpactAnalyzer
|
||||
{
|
||||
public static FrameAnimationGraphImpact AnalyzeEdgeRemoval(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationEdge> edges)
|
||||
{
|
||||
var removed = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null));
|
||||
return Analyze(graph, Array.Empty<AnimationNode>(), removed);
|
||||
}
|
||||
|
||||
public static FrameAnimationGraphImpact AnalyzeNodeRemoval(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationNode> nodes)
|
||||
{
|
||||
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null));
|
||||
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
|
||||
var removedEdges = new HashSet<AnimationEdge>(graph.Edges.Where(edge => edge != null &&
|
||||
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
|
||||
return Analyze(graph, removedNodes, removedEdges);
|
||||
}
|
||||
|
||||
public static FrameAnimationGraphImpact AnalyzeRemoval(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationNode> nodes,
|
||||
IEnumerable<AnimationEdge> edges)
|
||||
{
|
||||
if (graph == null)
|
||||
{
|
||||
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
|
||||
}
|
||||
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>())
|
||||
.Where(node => node != null));
|
||||
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
|
||||
var removedEdges = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>())
|
||||
.Where(edge => edge != null));
|
||||
removedEdges.UnionWith(graph.Edges.Where(edge => edge != null &&
|
||||
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
|
||||
return Analyze(graph, removedNodes, removedEdges);
|
||||
}
|
||||
|
||||
private static FrameAnimationGraphImpact Analyze(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyCollection<AnimationNode> removedNodes,
|
||||
IReadOnlyCollection<AnimationEdge> removedEdges)
|
||||
{
|
||||
if (graph == null)
|
||||
{
|
||||
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
|
||||
}
|
||||
var topology = new FrameAnimationGraphTopology(graph);
|
||||
var removedNodeIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
|
||||
var affected = new List<AnimationFlow>();
|
||||
var lost = new HashSet<string>();
|
||||
foreach (var flow in graph.Flows.Where(flow => flow != null))
|
||||
{
|
||||
var before = topology.GetReachable(flow.EntryNodeId).Nodes
|
||||
.Where(node => node != null).Select(node => node.InternalId).ToHashSet();
|
||||
var after = ReachableWithout(graph, flow.EntryNodeId, removedNodeIds, removedEdges);
|
||||
var flowLost = before.Where(nodeId => !after.Contains(nodeId)).ToArray();
|
||||
if (flowLost.Length > 0)
|
||||
{
|
||||
affected.Add(flow);
|
||||
lost.UnionWith(flowLost);
|
||||
}
|
||||
}
|
||||
return new FrameAnimationGraphImpact(
|
||||
affected.OrderBy(flow => flow.Id, StringComparer.Ordinal).ToArray(),
|
||||
lost.Count);
|
||||
}
|
||||
|
||||
private static HashSet<string> ReachableWithout(
|
||||
FrameAnimationGraph graph,
|
||||
string entry,
|
||||
HashSet<string> removedNodeIds,
|
||||
IReadOnlyCollection<AnimationEdge> removedEdges)
|
||||
{
|
||||
var result = new HashSet<string>();
|
||||
if (removedNodeIds.Contains(entry ?? string.Empty))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
var outgoing = graph.Edges.Where(edge => edge != null && !removedEdges.Contains(edge))
|
||||
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
|
||||
.ToDictionary(group => group.Key, group => group.ToArray());
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(entry ?? string.Empty);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
if (removedNodeIds.Contains(id) || !result.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (outgoing.TryGetValue(id, out var edges))
|
||||
{
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
queue.Enqueue(edge.ToNodeId ?? string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationGraphMutationService
|
||||
{
|
||||
private static readonly Color[] FlowColors =
|
||||
{
|
||||
new Color(0.24f, 0.65f, 1f), new Color(0.42f, 0.8f, 0.42f),
|
||||
new Color(1f, 0.62f, 0.24f), new Color(0.82f, 0.42f, 0.9f),
|
||||
new Color(1f, 0.38f, 0.52f), new Color(0.25f, 0.82f, 0.78f)
|
||||
};
|
||||
|
||||
public static bool CreateNode(
|
||||
FrameAnimationGraph graph,
|
||||
FrameClip clip,
|
||||
Vector2 position,
|
||||
out AnimationNode node,
|
||||
out string error)
|
||||
{
|
||||
node = null;
|
||||
error = string.Empty;
|
||||
if (graph == null || clip == null || !graph.Clips.Contains(clip))
|
||||
{
|
||||
error = "只能为当前 Graph 中的 FrameClip 创建节点。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Create Frame Animation Node");
|
||||
node = new AnimationNode(clip.Id,
|
||||
string.IsNullOrWhiteSpace(clip.DisplayName) ? clip.Id : clip.DisplayName);
|
||||
graph.AddNode(node);
|
||||
graph.EditorData.GetOrCreateNodeData(node.InternalId, position).SetPosition(position);
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryConnect(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationNode from,
|
||||
AnimationNode to,
|
||||
out AnimationEdge edge,
|
||||
out string error)
|
||||
{
|
||||
edge = null;
|
||||
if (!CanConnect(graph, from, to, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Connect Frame Animation Nodes");
|
||||
edge = new AnimationEdge(from.InternalId, to.InternalId);
|
||||
graph.AddEdge(edge);
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool CanConnect(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationNode from,
|
||||
AnimationNode to,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || from == null || to == null ||
|
||||
graph.Nodes.Count(node => node == from) != 1 || graph.Nodes.Count(node => node == to) != 1)
|
||||
{
|
||||
error = "连接两端必须唯一属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
if (from == to || from.InternalId == to.InternalId)
|
||||
{
|
||||
error = "节点不允许连接到自身。";
|
||||
return false;
|
||||
}
|
||||
var topology = new FrameAnimationGraphTopology(graph);
|
||||
if (topology.GetOutgoing(from.InternalId).Count > 0)
|
||||
{
|
||||
error = "第一版每个节点最多只能有一个后继。";
|
||||
return false;
|
||||
}
|
||||
if (from.EndBehaviorOverride.HasValue)
|
||||
{
|
||||
error = "该节点已设置终点结束行为,请先清除覆盖。";
|
||||
return false;
|
||||
}
|
||||
if (graph.Edges.Any(existing => existing != null &&
|
||||
existing.FromNodeId == from.InternalId && existing.ToNodeId == to.InternalId))
|
||||
{
|
||||
error = "该连接已经存在。";
|
||||
return false;
|
||||
}
|
||||
if (topology.WouldCreateCycle(from.InternalId, to.InternalId))
|
||||
{
|
||||
error = "该连接会形成自连接或多节点环路。";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void SetNodeDisplayName(FrameAnimationGraph graph, AnimationNode node, string value)
|
||||
{
|
||||
Undo.RecordObject(graph, "Rename Frame Animation Node");
|
||||
node.SetDisplayName(value);
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
public static void SetNodeClip(FrameAnimationGraph graph, AnimationNode node, FrameClip clip)
|
||||
{
|
||||
Undo.RecordObject(graph, "Set Frame Animation Node Clip");
|
||||
node.SetClipId(clip != null ? clip.Id : string.Empty);
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
public static void SetNodeSpeed(FrameAnimationGraph graph, AnimationNode node, float? value)
|
||||
{
|
||||
Undo.RecordObject(graph, "Set Frame Animation Node Speed");
|
||||
node.SetSpeedOverride(value);
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
public static bool SetNodeEndBehavior(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationNode node,
|
||||
FrameClipEndBehavior? value,
|
||||
bool removeOutgoing,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray();
|
||||
if (value.HasValue && outgoing.Length > 0 && !removeOutgoing)
|
||||
{
|
||||
error = "节点仍有后继 Edge。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Set Frame Animation Node End Behavior");
|
||||
if (value.HasValue)
|
||||
{
|
||||
foreach (var edge in outgoing)
|
||||
{
|
||||
graph.RemoveEdge(edge);
|
||||
}
|
||||
}
|
||||
node.SetEndBehaviorOverride(value);
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void RemoveEdges(FrameAnimationGraph graph, IEnumerable<AnimationEdge> edges)
|
||||
{
|
||||
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
|
||||
if (graph == null || items.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.RecordObject(graph, "Disconnect Frame Animation Nodes");
|
||||
foreach (var edge in items)
|
||||
{
|
||||
graph.RemoveEdge(edge);
|
||||
}
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
public static bool RemoveNodes(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationNode> nodes,
|
||||
bool deleteEntryFlows,
|
||||
out string error)
|
||||
{
|
||||
return RemoveNodesAndEdges(
|
||||
graph, nodes, Array.Empty<AnimationEdge>(), deleteEntryFlows, out error);
|
||||
}
|
||||
|
||||
public static bool RemoveNodesAndEdges(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationNode> nodes,
|
||||
IEnumerable<AnimationEdge> additionalEdges,
|
||||
bool deleteEntryFlows,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null)
|
||||
{
|
||||
error = "Graph 已失效。";
|
||||
return false;
|
||||
}
|
||||
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
|
||||
var extraEdges = (additionalEdges ?? Array.Empty<AnimationEdge>())
|
||||
.Where(edge => edge != null).Distinct().ToArray();
|
||||
var ids = new HashSet<string>(items.Select(node => node.InternalId));
|
||||
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
|
||||
if (entryFlows.Length > 0 && !deleteEntryFlows)
|
||||
{
|
||||
error = "删除项包含 Flow 入口节点。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Delete Frame Animation Graph Elements");
|
||||
foreach (var edge in graph.Edges.Where(edge => edge != null &&
|
||||
(ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId) || extraEdges.Contains(edge))).ToArray())
|
||||
{
|
||||
graph.RemoveEdge(edge);
|
||||
}
|
||||
foreach (var flow in entryFlows)
|
||||
{
|
||||
graph.RemoveFlow(flow);
|
||||
graph.EditorData.RemoveFlowData(flow.Id);
|
||||
if (graph.Settings.DefaultPlayableId == flow.Id)
|
||||
{
|
||||
graph.Settings.SetDefaultPlayableId(string.Empty);
|
||||
}
|
||||
}
|
||||
foreach (var node in items)
|
||||
{
|
||||
graph.RemoveNode(node);
|
||||
graph.EditorData.RemoveNodeData(node.InternalId);
|
||||
}
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string MakeUniqueFlowId(FrameAnimationGraph graph, AnimationNode node)
|
||||
{
|
||||
var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "_Flow";
|
||||
var candidate = root;
|
||||
var suffix = 2;
|
||||
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
|
||||
{
|
||||
candidate = root + suffix++;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public static bool CreateFlow(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationNode entry,
|
||||
string id,
|
||||
string displayName,
|
||||
out AnimationFlow flow,
|
||||
out string error)
|
||||
{
|
||||
flow = null;
|
||||
error = string.Empty;
|
||||
if (graph == null || entry == null || !graph.Nodes.Contains(entry))
|
||||
{
|
||||
error = "Flow 入口必须属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing.EntryNodeId == entry.InternalId);
|
||||
if (owner != null)
|
||||
{
|
||||
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
|
||||
return false;
|
||||
}
|
||||
if (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
|
||||
{
|
||||
error = "Flow id 为空或与 Clip / Flow 冲突。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Create Animation Flow");
|
||||
flow = new AnimationFlow(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName, entry.InternalId);
|
||||
graph.AddFlow(flow);
|
||||
graph.EditorData.GetOrCreateFlowData(flow.Id, FlowColors[(graph.Flows.Count - 1) % FlowColors.Length]);
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool SetFlowEntry(
|
||||
FrameAnimationGraph graph,
|
||||
AnimationFlow flow,
|
||||
AnimationNode entry,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (graph == null || flow == null || entry == null ||
|
||||
!graph.Flows.Contains(flow) || !graph.Nodes.Contains(entry))
|
||||
{
|
||||
error = "Flow 或入口节点不属于当前 Graph。";
|
||||
return false;
|
||||
}
|
||||
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing != flow &&
|
||||
existing.EntryNodeId == entry.InternalId);
|
||||
if (owner != null)
|
||||
{
|
||||
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Set Animation Flow Entry");
|
||||
flow.SetEntryNodeId(entry.InternalId);
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void SetFlowColor(FrameAnimationGraph graph, AnimationFlow flow, Color value)
|
||||
{
|
||||
if (graph == null || flow == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.RecordObject(graph, "Set Animation Flow Color");
|
||||
graph.EditorData.GetOrCreateFlowData(flow.Id, value).SetColor(value);
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
public static bool EnsureEditorData(FrameAnimationGraph graph)
|
||||
{
|
||||
if (graph == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var missingNodes = graph.Nodes.Where(node => node != null &&
|
||||
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)).ToArray();
|
||||
var missingFlows = graph.Flows.Where(flow => flow != null &&
|
||||
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)).ToArray();
|
||||
if (missingNodes.Length == 0 && missingFlows.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Undo.RecordObject(graph, "Initialize Frame Animation Graph Layout");
|
||||
for (var index = 0; index < missingNodes.Length; index++)
|
||||
{
|
||||
var position = new Vector2((index % 4) * 300f, (index / 4) * 180f);
|
||||
graph.EditorData.GetOrCreateNodeData(missingNodes[index].InternalId, position);
|
||||
}
|
||||
for (var index = 0; index < missingFlows.Length; index++)
|
||||
{
|
||||
graph.EditorData.GetOrCreateFlowData(missingFlows[index].Id,
|
||||
FlowColors[(graph.Flows.ToList().IndexOf(missingFlows[index]) + FlowColors.Length) % FlowColors.Length]);
|
||||
}
|
||||
Dirty(graph);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Vector2 GetNodePosition(FrameAnimationGraph graph, AnimationNode node)
|
||||
{
|
||||
return graph?.EditorData.NodeEditorData.FirstOrDefault(data =>
|
||||
data != null && data.NodeId == node?.InternalId)?.Position ?? Vector2.zero;
|
||||
}
|
||||
|
||||
public static void SetNodePositions(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyDictionary<AnimationNode, Vector2> positions,
|
||||
string undoName)
|
||||
{
|
||||
if (graph == null || positions == null || positions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.RecordObject(graph, undoName);
|
||||
foreach (var pair in positions)
|
||||
{
|
||||
graph.EditorData.GetOrCreateNodeData(pair.Key.InternalId, pair.Value).SetPosition(pair.Value);
|
||||
}
|
||||
Dirty(graph);
|
||||
}
|
||||
|
||||
private static void Dirty(FrameAnimationGraph graph)
|
||||
{
|
||||
EditorUtility.SetDirty(graph);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationGraphLayoutService
|
||||
{
|
||||
public static IReadOnlyDictionary<AnimationNode, Vector2> Calculate(
|
||||
FrameAnimationGraph graph,
|
||||
IEnumerable<AnimationNode> scope)
|
||||
{
|
||||
var source = scope ?? (graph != null
|
||||
? graph.Nodes
|
||||
: (IEnumerable<AnimationNode>)Array.Empty<AnimationNode>());
|
||||
var nodes = source.Where(node => node != null).Distinct()
|
||||
.GroupBy(node => node.InternalId ?? string.Empty)
|
||||
.Select(group => group.First()).ToArray();
|
||||
if (graph == null || nodes.Length == 0)
|
||||
{
|
||||
return new Dictionary<AnimationNode, Vector2>();
|
||||
}
|
||||
var ids = new HashSet<string>(nodes.Select(node => node.InternalId));
|
||||
var edges = graph.Edges.Where(edge => edge != null && ids.Contains(edge.FromNodeId) && ids.Contains(edge.ToNodeId)).ToArray();
|
||||
var incoming = nodes.ToDictionary(node => node.InternalId, _ => 0);
|
||||
foreach (var edge in edges)
|
||||
{
|
||||
incoming[edge.ToNodeId]++;
|
||||
}
|
||||
var ranks = nodes.ToDictionary(node => node.InternalId, _ => 0);
|
||||
var queue = new Queue<AnimationNode>(nodes.Where(node => incoming[node.InternalId] == 0)
|
||||
.OrderBy(node => node.InternalId, StringComparer.Ordinal));
|
||||
var processed = new HashSet<string>();
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var node = queue.Dequeue();
|
||||
if (!processed.Add(node.InternalId)) continue;
|
||||
foreach (var edge in edges.Where(edge => edge.FromNodeId == node.InternalId))
|
||||
{
|
||||
ranks[edge.ToNodeId] = Math.Max(ranks[edge.ToNodeId], ranks[node.InternalId] + 1);
|
||||
incoming[edge.ToNodeId]--;
|
||||
if (incoming[edge.ToNodeId] == 0)
|
||||
{
|
||||
queue.Enqueue(nodes.First(item => item.InternalId == edge.ToNodeId));
|
||||
}
|
||||
}
|
||||
}
|
||||
var oldPositions = nodes.ToDictionary(node => node,
|
||||
node => FrameAnimationGraphMutationService.GetNodePosition(graph, node));
|
||||
var originX = oldPositions.Values.Min(position => position.x);
|
||||
var originY = oldPositions.Values.Min(position => position.y);
|
||||
var result = new Dictionary<AnimationNode, Vector2>();
|
||||
foreach (var group in nodes.GroupBy(node => ranks[node.InternalId]).OrderBy(group => group.Key))
|
||||
{
|
||||
var ordered = group.OrderBy(node => oldPositions[node].y)
|
||||
.ThenBy(node => node.InternalId, StringComparer.Ordinal).ToArray();
|
||||
for (var index = 0; index < ordered.Length; index++)
|
||||
{
|
||||
result[ordered[index]] = new Vector2(originX + group.Key * 300f, originY + index * 180f);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b397ed96c5b44df89626f866c32e03dd
|
||||
@@ -0,0 +1,33 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
[CustomEditor(typeof(FrameAnimationGraph))]
|
||||
public sealed class FrameAnimationGraphEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
var graph = (FrameAnimationGraph)target;
|
||||
EditorGUILayout.LabelField("FrameAnimationGraph", EditorStyles.boldLabel);
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.TextField("ID", graph.Id);
|
||||
EditorGUILayout.TextField("Display Name", graph.DisplayName);
|
||||
EditorGUILayout.IntField("Clips", graph.Clips.Count);
|
||||
EditorGUILayout.IntField("Nodes", graph.Nodes.Count);
|
||||
EditorGUILayout.IntField("Edges", graph.Edges.Count);
|
||||
EditorGUILayout.IntField("Flows", graph.Flows.Count);
|
||||
EditorGUILayout.IntField("Import Sources", graph.ImportSources.Count);
|
||||
EditorGUILayout.TextField("Default Playable", graph.Settings?.DefaultPlayableId ?? string.Empty);
|
||||
}
|
||||
EditorGUILayout.HelpBox(
|
||||
"Graph 的资源、来源、重命名和删除操作只能在主工作台中执行。",
|
||||
MessageType.Info);
|
||||
if (GUILayout.Button("Open Frame Animation Graph Editor"))
|
||||
{
|
||||
FrameAnimationGraphEditorWindow.Open(graph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74f3b50c2650607499ea1c82a282f00f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
.fa-preview {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
border-left-color: rgb(25, 25, 25);
|
||||
border-right-color: rgb(25, 25, 25);
|
||||
border-top-color: rgb(25, 25, 25);
|
||||
border-bottom-color: rgb(25, 25, 25);
|
||||
}
|
||||
|
||||
.fa-preview__image {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.fa-preview__empty {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
-unity-text-align: middle-center;
|
||||
color: rgb(190, 190, 190);
|
||||
}
|
||||
|
||||
.fa-node__preview {
|
||||
height: 82px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.fa-node--preview-current {
|
||||
border-left-color: rgb(55, 174, 240);
|
||||
border-right-color: rgb(55, 174, 240);
|
||||
border-top-color: rgb(55, 174, 240);
|
||||
border-bottom-color: rgb(55, 174, 240);
|
||||
}
|
||||
|
||||
.fa-node--preview-passed {
|
||||
border-left-color: rgb(83, 154, 113);
|
||||
}
|
||||
|
||||
.fa-node--preview-upcoming {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.fa-clip-preview-panel {
|
||||
flex-shrink: 0;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 5px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(28, 28, 28);
|
||||
}
|
||||
|
||||
.fa-clip-preview-panel .fa-preview {
|
||||
height: 190px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fa-preview-controls {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fa-preview-status {
|
||||
font-size: 10px;
|
||||
color: rgb(185, 185, 185);
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a38dd39c699e42348667cc1264ea9076
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df89f5fa1de04df448957109886651b8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,740 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Experimental.GraphView;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal sealed class FrameAnimationClipNodeView : Node
|
||||
{
|
||||
private readonly Label subtitle;
|
||||
private readonly Label behavior;
|
||||
private readonly Label flowBadge;
|
||||
private readonly Label issueBadge;
|
||||
private readonly FrameAnimationPreviewElement preview;
|
||||
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
|
||||
|
||||
public AnimationNode Data { get; }
|
||||
public Port InputPort { get; }
|
||||
public Port OutputPort { get; }
|
||||
|
||||
internal FrameAnimationClipNodeView(
|
||||
AnimationNode data,
|
||||
Action<FrameAnimationClipNodeView> selectedCallback)
|
||||
{
|
||||
Data = data;
|
||||
this.selectedCallback = selectedCallback;
|
||||
viewDataKey = data.InternalId;
|
||||
AddToClassList("fa-node");
|
||||
style.minWidth = 245f;
|
||||
style.maxWidth = 285f;
|
||||
|
||||
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,
|
||||
typeof(AnimationNode));
|
||||
InputPort.portName = "In";
|
||||
inputContainer.Add(InputPort);
|
||||
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single,
|
||||
typeof(AnimationNode));
|
||||
OutputPort.portName = "Next";
|
||||
outputContainer.Add(OutputPort);
|
||||
|
||||
subtitle = new Label();
|
||||
behavior = new Label();
|
||||
flowBadge = new Label();
|
||||
issueBadge = new Label();
|
||||
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
extensionContainer.Add(subtitle);
|
||||
extensionContainer.Add(behavior);
|
||||
extensionContainer.Add(flowBadge);
|
||||
extensionContainer.Add(issueBadge);
|
||||
preview = new FrameAnimationPreviewElement();
|
||||
preview.AddToClassList("fa-node__preview");
|
||||
extensionContainer.Add(preview);
|
||||
RefreshExpandedState();
|
||||
}
|
||||
|
||||
public override void OnSelected()
|
||||
{
|
||||
base.OnSelected();
|
||||
selectedCallback?.Invoke(this);
|
||||
}
|
||||
|
||||
internal void Refresh(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyList<FrameAnimationEditorIssue> issues,
|
||||
IReadOnlyList<AnimationFlow> flows,
|
||||
IReadOnlyCollection<string> entryFlowIds)
|
||||
{
|
||||
var clip = graph.Clips.FirstOrDefault(item => item != null && item.Id == Data.ClipId);
|
||||
title = string.IsNullOrWhiteSpace(Data.DisplayName) ? Data.ClipId : Data.DisplayName;
|
||||
subtitle.text = clip != null ? $"Clip: {Data.ClipId}" : $"Clip: <Missing {Data.ClipId}>";
|
||||
var speed = Data.SpeedOverride.HasValue
|
||||
? $"Speed {Data.SpeedOverride.Value:0.###} (Node)"
|
||||
: clip != null ? $"Speed {clip.Speed:0.###} (Clip)" : "Speed ?";
|
||||
var end = Data.EndBehaviorOverride?.ToString() ?? "Continue / terminal fallback";
|
||||
behavior.text = $"{speed} · {end}";
|
||||
var used = flows.Select(flow => flow.Id).ToArray();
|
||||
var entries = used.Where(entryFlowIds.Contains).ToArray();
|
||||
flowBadge.text = used.Length == 0
|
||||
? "Unused"
|
||||
: "Flows: " + string.Join(", ", used.Take(3)) + (used.Length > 3 ? $" +{used.Length - 3}" : string.Empty) +
|
||||
(entries.Length > 0 ? " [ENTRY]" : string.Empty);
|
||||
var matching = issues.Where(issue => issue.Selection.Kind == FrameAnimationEditorSelectionKind.Node &&
|
||||
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
|
||||
issueBadge.text = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
|
||||
? "ERROR"
|
||||
: matching.Length > 0 ? "WARNING" : string.Empty;
|
||||
issueBadge.style.color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
|
||||
? new StyleColor(new Color(1f, 0.35f, 0.3f))
|
||||
: new StyleColor(new Color(1f, 0.72f, 0.25f));
|
||||
OutputPort.SetEnabled(!Data.EndBehaviorOverride.HasValue);
|
||||
OutputPort.tooltip = Data.EndBehaviorOverride.HasValue
|
||||
? "该节点已设置终点结束行为,请先清除覆盖。"
|
||||
: "创建唯一的顺序后继";
|
||||
}
|
||||
|
||||
internal void SetDimmed(bool dimmed)
|
||||
{
|
||||
style.opacity = dimmed ? 0.45f : 1f;
|
||||
}
|
||||
|
||||
internal void SetPreview(
|
||||
FrameAnimationPreviewDisplay display,
|
||||
FrameAnimationPreviewBackground background)
|
||||
{
|
||||
preview.SetBackground(background);
|
||||
preview.SetZoom(FrameAnimationPreviewZoomMode.Fit, 1f);
|
||||
preview.SetDisplay(display.Sprite, display.IsEmpty);
|
||||
preview.style.opacity = display.FlowState == FrameAnimationFlowElementState.Upcoming
|
||||
? 0.55f : display.FlowState == FrameAnimationFlowElementState.Passed ? 0.82f : 1f;
|
||||
EnableInClassList("fa-node--preview-current", display.FlowState == FrameAnimationFlowElementState.Current);
|
||||
EnableInClassList("fa-node--preview-passed", display.FlowState == FrameAnimationFlowElementState.Passed);
|
||||
EnableInClassList("fa-node--preview-upcoming", display.FlowState == FrameAnimationFlowElementState.Upcoming);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationEdgeView : Edge
|
||||
{
|
||||
private readonly Action<FrameAnimationEdgeView> selectedCallback;
|
||||
private Color baseColor = Color.white;
|
||||
public AnimationEdge Data { get; }
|
||||
|
||||
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
|
||||
{
|
||||
Data = data;
|
||||
this.selectedCallback = selectedCallback;
|
||||
viewDataKey = data.InternalId;
|
||||
userData = data;
|
||||
}
|
||||
|
||||
public override void OnSelected()
|
||||
{
|
||||
base.OnSelected();
|
||||
selectedCallback?.Invoke(this);
|
||||
}
|
||||
|
||||
internal void SetDimmed(bool dimmed)
|
||||
{
|
||||
style.opacity = dimmed ? 0.30f : 1f;
|
||||
}
|
||||
|
||||
internal void Refresh(IReadOnlyList<FrameAnimationEditorIssue> issues)
|
||||
{
|
||||
var matching = (issues ?? Array.Empty<FrameAnimationEditorIssue>()).Where(issue =>
|
||||
issue.Selection.Kind == FrameAnimationEditorSelectionKind.Edge &&
|
||||
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
|
||||
var color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
|
||||
? new Color(1f, 0.3f, 0.25f)
|
||||
: matching.Length > 0 ? new Color(1f, 0.72f, 0.2f) : Color.white;
|
||||
baseColor = color;
|
||||
edgeControl.inputColor = color;
|
||||
edgeControl.outputColor = color;
|
||||
tooltip = matching.Length == 0
|
||||
? $"{Data.ExitName} / {Data.Condition}"
|
||||
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
|
||||
}
|
||||
|
||||
internal void SetPreviewState(FrameAnimationFlowElementState state)
|
||||
{
|
||||
if (state == FrameAnimationFlowElementState.Current)
|
||||
{
|
||||
edgeControl.inputColor = new Color(0.25f, 0.75f, 1f);
|
||||
edgeControl.outputColor = new Color(0.25f, 0.75f, 1f);
|
||||
}
|
||||
else if (state == FrameAnimationFlowElementState.Passed)
|
||||
{
|
||||
edgeControl.inputColor = new Color(0.35f, 0.68f, 0.46f);
|
||||
edgeControl.outputColor = new Color(0.35f, 0.68f, 0.46f);
|
||||
}
|
||||
else if (state == FrameAnimationFlowElementState.Upcoming)
|
||||
{
|
||||
edgeControl.inputColor = new Color(0.55f, 0.55f, 0.55f);
|
||||
edgeControl.outputColor = new Color(0.55f, 0.55f, 0.55f);
|
||||
}
|
||||
else
|
||||
{
|
||||
edgeControl.inputColor = baseColor;
|
||||
edgeControl.outputColor = baseColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationClipSearchProvider : ScriptableObject, ISearchWindowProvider
|
||||
{
|
||||
private FrameAnimationGraph graph;
|
||||
private Action<FrameClip> selected;
|
||||
|
||||
internal void Configure(FrameAnimationGraph value, Action<FrameClip> callback)
|
||||
{
|
||||
graph = value;
|
||||
selected = callback;
|
||||
}
|
||||
|
||||
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
|
||||
{
|
||||
var entries = new List<SearchTreeEntry>
|
||||
{
|
||||
new SearchTreeGroupEntry(new GUIContent("Create Clip Node"), 0)
|
||||
};
|
||||
foreach (var clip in graph.Clips.Where(clip => clip != null)
|
||||
.OrderBy(clip => clip.DisplayName, StringComparer.Ordinal)
|
||||
.ThenBy(clip => clip.Id, StringComparer.Ordinal))
|
||||
{
|
||||
entries.Add(new SearchTreeEntry(new GUIContent($"{clip.DisplayName} ({clip.Id})"))
|
||||
{
|
||||
level = 1,
|
||||
userData = clip
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
|
||||
{
|
||||
if (entry.userData is FrameClip clip)
|
||||
{
|
||||
selected?.Invoke(clip);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationGraphView : GraphView
|
||||
{
|
||||
internal const string ClipDragKey = "AibisDream.FrameAnimation.ClipDrag";
|
||||
|
||||
private readonly Dictionary<string, FrameAnimationClipNodeView> nodeViews =
|
||||
new Dictionary<string, FrameAnimationClipNodeView>();
|
||||
private readonly Dictionary<string, FrameAnimationEdgeView> edgeViews =
|
||||
new Dictionary<string, FrameAnimationEdgeView>();
|
||||
private FrameAnimationGraph graph;
|
||||
private IReadOnlyList<FrameAnimationEditorIssue> issues = Array.Empty<FrameAnimationEditorIssue>();
|
||||
private bool rebuilding;
|
||||
private Vector2 contextPosition;
|
||||
private string focusedFlowId = string.Empty;
|
||||
|
||||
internal event Action<FrameAnimationEditorSelection> SelectionRequested;
|
||||
internal event Action<IReadOnlyList<AnimationNode>> MultiSelectionChanged;
|
||||
internal event Action<IReadOnlyList<FrameAnimationEditorSelection>> SelectionSetChanged;
|
||||
internal event Action<AnimationNode> CreateFlowRequested;
|
||||
internal event Action<AnimationNode> NodeCreated;
|
||||
internal event Action ExitFocusRequested;
|
||||
internal event Action GraphChanged;
|
||||
internal event Action<string> NotificationRequested;
|
||||
internal event Action<Vector3, float> WorkspaceViewTransformChanged;
|
||||
|
||||
internal FrameAnimationGraphView()
|
||||
{
|
||||
style.flexGrow = 1f;
|
||||
focusable = true;
|
||||
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
|
||||
this.AddManipulator(new ContentDragger());
|
||||
this.AddManipulator(new SelectionDragger());
|
||||
this.AddManipulator(new RectangleSelector());
|
||||
var grid = new GridBackground();
|
||||
Insert(0, grid);
|
||||
grid.StretchToParentSize();
|
||||
graphViewChanged = OnGraphViewChanged;
|
||||
viewTransformChanged += _ =>
|
||||
WorkspaceViewTransformChanged?.Invoke(viewTransform.position, viewTransform.scale.x);
|
||||
RegisterCallback<MouseUpEvent>(_ => NotifySelectionSummary());
|
||||
RegisterCallback<DragUpdatedEvent>(OnDragUpdated);
|
||||
RegisterCallback<DragPerformEvent>(OnDragPerform);
|
||||
RegisterCallback<KeyDownEvent>(OnKeyDown);
|
||||
}
|
||||
|
||||
internal void Bind(
|
||||
FrameAnimationGraph value,
|
||||
IReadOnlyList<FrameAnimationEditorIssue> validationIssues,
|
||||
string focusedFlowId)
|
||||
{
|
||||
graph = value;
|
||||
issues = validationIssues ?? Array.Empty<FrameAnimationEditorIssue>();
|
||||
var selectedIds = selection.OfType<FrameAnimationClipNodeView>()
|
||||
.Select(view => view.Data.InternalId).ToArray();
|
||||
var selectedEdgeIds = selection.OfType<FrameAnimationEdgeView>()
|
||||
.Select(view => view.Data.InternalId).ToArray();
|
||||
rebuilding = true;
|
||||
DeleteElements(graphElements.ToList());
|
||||
nodeViews.Clear();
|
||||
edgeViews.Clear();
|
||||
if (graph != null)
|
||||
{
|
||||
var topology = new FrameAnimationGraphTopology(graph);
|
||||
var entries = new HashSet<string>(graph.Flows.Where(flow => flow != null)
|
||||
.Select(flow => flow.EntryNodeId));
|
||||
foreach (var node in graph.Nodes.Where(node => node != null))
|
||||
{
|
||||
var view = new FrameAnimationClipNodeView(node, OnNodeSelected);
|
||||
view.SetPosition(new Rect(FrameAnimationGraphMutationService.GetNodePosition(graph, node),
|
||||
new Vector2(255f, 150f)));
|
||||
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId), entries);
|
||||
nodeViews[node.InternalId] = view;
|
||||
AddElement(view);
|
||||
}
|
||||
foreach (var edge in graph.Edges.Where(edge => edge != null))
|
||||
{
|
||||
if (!nodeViews.TryGetValue(edge.FromNodeId ?? string.Empty, out var from) ||
|
||||
!nodeViews.TryGetValue(edge.ToNodeId ?? string.Empty, out var to))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var view = new FrameAnimationEdgeView(edge, OnEdgeSelected)
|
||||
{
|
||||
output = from.OutputPort,
|
||||
input = to.InputPort
|
||||
};
|
||||
view.output.Connect(view);
|
||||
view.input.Connect(view);
|
||||
view.Refresh(issues);
|
||||
edgeViews[edge.InternalId] = view;
|
||||
AddElement(view);
|
||||
}
|
||||
foreach (var id in selectedIds.Where(nodeViews.ContainsKey))
|
||||
{
|
||||
AddToSelection(nodeViews[id]);
|
||||
}
|
||||
foreach (var id in selectedEdgeIds.Where(edgeViews.ContainsKey))
|
||||
{
|
||||
AddToSelection(edgeViews[id]);
|
||||
}
|
||||
}
|
||||
rebuilding = false;
|
||||
SetFocusedFlow(focusedFlowId);
|
||||
}
|
||||
|
||||
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
|
||||
{
|
||||
if (evt.target == this || evt.target == contentViewContainer)
|
||||
{
|
||||
contextPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
|
||||
evt.menu.AppendAction("Create Clip Node", _ => OpenClipSearch(evt.mousePosition),
|
||||
graph != null && graph.Clips.Any(clip => clip != null)
|
||||
? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
|
||||
}
|
||||
if (selection.Count == 1 && selection[0] is FrameAnimationClipNodeView nodeView)
|
||||
{
|
||||
evt.menu.AppendAction("Create Flow From Node", _ => CreateFlowRequested?.Invoke(nodeView.Data));
|
||||
}
|
||||
base.BuildContextualMenu(evt);
|
||||
}
|
||||
|
||||
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
|
||||
{
|
||||
var result = new List<Port>();
|
||||
if (graph == null || !(startPort.node is FrameAnimationClipNodeView startNode))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
ports.ForEach(port =>
|
||||
{
|
||||
if (!(port.node is FrameAnimationClipNodeView targetNode) || port.direction == startPort.direction ||
|
||||
targetNode == startNode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var from = startPort.direction == Direction.Output ? startNode.Data : targetNode.Data;
|
||||
var to = startPort.direction == Direction.Output ? targetNode.Data : startNode.Data;
|
||||
if (FrameAnimationGraphMutationService.CanConnect(graph, from, to, out _))
|
||||
{
|
||||
result.Add(port);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
internal void SetFocusedFlow(string flowId)
|
||||
{
|
||||
focusedFlowId = flowId ?? string.Empty;
|
||||
if (graph == null || string.IsNullOrEmpty(flowId))
|
||||
{
|
||||
foreach (var view in nodeViews.Values) view.SetDimmed(false);
|
||||
foreach (var view in edgeViews.Values) view.SetDimmed(false);
|
||||
return;
|
||||
}
|
||||
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
|
||||
var topology = new FrameAnimationGraphTopology(graph);
|
||||
var reachable = flow != null
|
||||
? topology.GetReachable(flow.EntryNodeId)
|
||||
: new FrameAnimationReachability(Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>());
|
||||
var nodeIds = new HashSet<string>(reachable.Nodes.Select(node => node.InternalId));
|
||||
var edgeIds = new HashSet<string>(reachable.Edges.Select(edge => edge.InternalId));
|
||||
foreach (var pair in nodeViews) pair.Value.SetDimmed(!nodeIds.Contains(pair.Key));
|
||||
foreach (var pair in edgeViews) pair.Value.SetDimmed(!edgeIds.Contains(pair.Key));
|
||||
}
|
||||
|
||||
internal void SelectAndFrame(FrameAnimationEditorSelection target)
|
||||
{
|
||||
GraphElement element = target.Value switch
|
||||
{
|
||||
AnimationNode node when nodeViews.TryGetValue(node.InternalId, out var nodeView) => nodeView,
|
||||
AnimationEdge edge when edgeViews.TryGetValue(edge.InternalId, out var edgeView) => edgeView,
|
||||
_ => null
|
||||
};
|
||||
if (element == null) return;
|
||||
ClearSelection();
|
||||
AddToSelection(element);
|
||||
FrameSelection();
|
||||
}
|
||||
|
||||
internal IReadOnlyList<AnimationNode> SelectedNodes =>
|
||||
selection.OfType<FrameAnimationClipNodeView>().Select(view => view.Data).ToArray();
|
||||
|
||||
internal IReadOnlyList<AnimationNode> VisibleNodes => nodeViews.Values
|
||||
.Where(view => view.worldBound.Overlaps(worldBound))
|
||||
.Select(view => view.Data)
|
||||
.ToArray();
|
||||
|
||||
internal void ApplyPreview(FrameAnimationPreviewCoordinator coordinator)
|
||||
{
|
||||
if (coordinator == null) return;
|
||||
foreach (var view in nodeViews.Values)
|
||||
{
|
||||
view.SetPreview(coordinator.GetNodeDisplay(view.Data), coordinator.Background);
|
||||
}
|
||||
foreach (var view in edgeViews.Values)
|
||||
{
|
||||
view.SetPreviewState(coordinator.GetEdgeState(view.Data));
|
||||
}
|
||||
}
|
||||
|
||||
internal void RestoreViewTransform(Vector3 position, float scale)
|
||||
{
|
||||
var safeScale = float.IsNaN(scale) || float.IsInfinity(scale)
|
||||
? 1f : Mathf.Clamp(scale, ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
|
||||
if (float.IsNaN(position.x) || float.IsInfinity(position.x) ||
|
||||
float.IsNaN(position.y) || float.IsInfinity(position.y))
|
||||
{
|
||||
position = Vector3.zero;
|
||||
}
|
||||
UpdateViewTransform(position, new Vector3(safeScale, safeScale, 1f));
|
||||
}
|
||||
|
||||
internal void RequestDeleteNodes(IEnumerable<AnimationNode> nodes)
|
||||
{
|
||||
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
|
||||
if (graph == null || items.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var ids = new HashSet<string>(items.Select(node => node.InternalId));
|
||||
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
|
||||
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeNodeRemoval(graph, items);
|
||||
if (!EditorUtility.DisplayDialog(
|
||||
"Delete Frame Animation Nodes",
|
||||
BuildRemovalMessage("Node", items.Length, impact, entryFlows),
|
||||
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete",
|
||||
"Cancel"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!FrameAnimationGraphMutationService.RemoveNodes(graph, items, entryFlows.Length > 0, out var error))
|
||||
{
|
||||
NotificationRequested?.Invoke(error);
|
||||
return;
|
||||
}
|
||||
GraphChanged?.Invoke();
|
||||
}
|
||||
|
||||
internal void RequestDeleteEdges(IEnumerable<AnimationEdge> edges)
|
||||
{
|
||||
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
|
||||
if (graph == null || items.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, items);
|
||||
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog(
|
||||
"Disconnect Frame Animation Nodes",
|
||||
BuildRemovalMessage("Edge", items.Length, impact, Array.Empty<AnimationFlow>()),
|
||||
"Disconnect",
|
||||
"Cancel"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
FrameAnimationGraphMutationService.RemoveEdges(graph, items);
|
||||
GraphChanged?.Invoke();
|
||||
}
|
||||
|
||||
internal void FrameCurrentFlow(string flowId)
|
||||
{
|
||||
if (graph == null || string.IsNullOrEmpty(flowId)) return;
|
||||
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
|
||||
if (flow == null) return;
|
||||
var ids = new HashSet<string>(new FrameAnimationGraphTopology(graph)
|
||||
.GetReachable(flow.EntryNodeId).Nodes.Select(node => node.InternalId));
|
||||
ClearSelection();
|
||||
foreach (var pair in nodeViews.Where(pair => ids.Contains(pair.Key))) AddToSelection(pair.Value);
|
||||
FrameSelection();
|
||||
}
|
||||
|
||||
internal void ApplyCalculatedPositions(IReadOnlyDictionary<AnimationNode, Vector2> positions)
|
||||
{
|
||||
foreach (var pair in positions)
|
||||
{
|
||||
if (nodeViews.TryGetValue(pair.Key.InternalId, out var view))
|
||||
{
|
||||
var rect = view.GetPosition();
|
||||
rect.position = pair.Value;
|
||||
view.SetPosition(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenClipSearch(Vector2 panelPosition)
|
||||
{
|
||||
var provider = ScriptableObject.CreateInstance<FrameAnimationClipSearchProvider>();
|
||||
provider.Configure(graph, clip => CreateNode(clip, contextPosition));
|
||||
SearchWindow.Open(new SearchWindowContext(GUIUtility.GUIToScreenPoint(panelPosition)), provider);
|
||||
}
|
||||
|
||||
internal void CreateNode(FrameClip clip, Vector2 position)
|
||||
{
|
||||
if (FrameAnimationGraphMutationService.CreateNode(graph, clip, position, out var node, out var error))
|
||||
{
|
||||
NodeCreated?.Invoke(node);
|
||||
GraphChanged?.Invoke();
|
||||
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node));
|
||||
}
|
||||
else
|
||||
{
|
||||
NotificationRequested?.Invoke(error);
|
||||
}
|
||||
}
|
||||
|
||||
private GraphViewChange OnGraphViewChanged(GraphViewChange change)
|
||||
{
|
||||
if (rebuilding || graph == null)
|
||||
{
|
||||
return change;
|
||||
}
|
||||
if (change.edgesToCreate != null)
|
||||
{
|
||||
foreach (var visual in change.edgesToCreate.ToArray())
|
||||
{
|
||||
var error = string.Empty;
|
||||
AnimationEdge data = null;
|
||||
var from = visual.output?.node as FrameAnimationClipNodeView;
|
||||
var to = visual.input?.node as FrameAnimationClipNodeView;
|
||||
if (from == null || to == null ||
|
||||
!FrameAnimationGraphMutationService.TryConnect(
|
||||
graph, from.Data, to.Data, out data, out error))
|
||||
{
|
||||
change.edgesToCreate.Remove(visual);
|
||||
NotificationRequested?.Invoke(string.IsNullOrEmpty(error) ? "无法创建连接。" : error);
|
||||
continue;
|
||||
}
|
||||
visual.userData = data;
|
||||
}
|
||||
GraphChanged?.Invoke();
|
||||
}
|
||||
if (change.elementsToRemove != null)
|
||||
{
|
||||
ProcessRemovals(change);
|
||||
}
|
||||
if (change.movedElements != null)
|
||||
{
|
||||
var positions = change.movedElements.OfType<FrameAnimationClipNodeView>()
|
||||
.ToDictionary(view => view.Data, view => view.GetPosition().position);
|
||||
if (positions.Count > 0)
|
||||
{
|
||||
FrameAnimationGraphMutationService.SetNodePositions(graph, positions,
|
||||
"Move Frame Animation Nodes");
|
||||
GraphChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
return change;
|
||||
}
|
||||
|
||||
private void ProcessRemovals(GraphViewChange change)
|
||||
{
|
||||
var nodeViewsToRemove = change.elementsToRemove.OfType<FrameAnimationClipNodeView>().ToArray();
|
||||
var removedNodeIds = new HashSet<string>(nodeViewsToRemove.Select(view => view.Data.InternalId));
|
||||
if (nodeViewsToRemove.Length > 0)
|
||||
{
|
||||
var nodes = nodeViewsToRemove.Select(view => view.Data).ToArray();
|
||||
var explicitlySelectedEdges = change.elementsToRemove.OfType<Edge>()
|
||||
.Select(edge => edge.userData as AnimationEdge)
|
||||
.Where(edge => edge != null).Distinct().ToArray();
|
||||
var entryFlows = graph.Flows.Where(flow => flow != null && removedNodeIds.Contains(flow.EntryNodeId)).ToArray();
|
||||
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeRemoval(
|
||||
graph, nodes, explicitlySelectedEdges);
|
||||
var message = BuildRemovalMessage("Node", nodes.Length, impact, entryFlows);
|
||||
if (explicitlySelectedEdges.Length > 0)
|
||||
{
|
||||
message += $"\n\n同时删除选中的 Edge:{explicitlySelectedEdges.Length}";
|
||||
}
|
||||
var confirmed = EditorUtility.DisplayDialog("Delete Frame Animation Nodes", message,
|
||||
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete", "Cancel");
|
||||
var removed = false;
|
||||
var error = string.Empty;
|
||||
if (confirmed)
|
||||
{
|
||||
removed = FrameAnimationGraphMutationService.RemoveNodesAndEdges(
|
||||
graph, nodes, explicitlySelectedEdges, entryFlows.Length > 0, out error);
|
||||
}
|
||||
if (!removed)
|
||||
{
|
||||
change.elementsToRemove.Clear();
|
||||
if (!string.IsNullOrEmpty(error)) NotificationRequested?.Invoke(error);
|
||||
}
|
||||
GraphChanged?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
var remainingEdges = change.elementsToRemove.OfType<Edge>()
|
||||
.Where(edge => !IsIncidentTo(edge, removedNodeIds))
|
||||
.Select(edge => edge.userData as AnimationEdge).Where(edge => edge != null).Distinct().ToArray();
|
||||
if (remainingEdges.Length > 0)
|
||||
{
|
||||
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, remainingEdges);
|
||||
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog("Disconnect Frame Animation Nodes",
|
||||
BuildRemovalMessage("Edge", remainingEdges.Length, impact, Array.Empty<AnimationFlow>()),
|
||||
"Disconnect", "Cancel"))
|
||||
{
|
||||
foreach (var edgeView in change.elementsToRemove.OfType<Edge>()
|
||||
.Where(view => remainingEdges.Contains(view.userData as AnimationEdge)).ToArray())
|
||||
{
|
||||
change.elementsToRemove.Remove(edgeView);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
FrameAnimationGraphMutationService.RemoveEdges(graph, remainingEdges);
|
||||
}
|
||||
}
|
||||
GraphChanged?.Invoke();
|
||||
}
|
||||
|
||||
private static bool IsIncidentTo(Edge edge, IReadOnlyCollection<string> nodeIds)
|
||||
{
|
||||
var from = edge?.output?.node as FrameAnimationClipNodeView;
|
||||
var to = edge?.input?.node as FrameAnimationClipNodeView;
|
||||
return from != null && nodeIds.Contains(from.Data.InternalId) ||
|
||||
to != null && nodeIds.Contains(to.Data.InternalId);
|
||||
}
|
||||
|
||||
private static string BuildRemovalMessage(
|
||||
string kind,
|
||||
int count,
|
||||
FrameAnimationGraphImpact impact,
|
||||
IReadOnlyCollection<AnimationFlow> entryFlows)
|
||||
{
|
||||
var message = $"将删除 {count} 个 {kind}。";
|
||||
if (impact.Flows.Count > 0)
|
||||
{
|
||||
message += $"\n\n受影响 Flow:{string.Join(", ", impact.Flows.Select(flow => flow.Id))}" +
|
||||
$"\n将失去可达关系的 Node:{impact.LostNodeCount}";
|
||||
}
|
||||
if (entryFlows.Count > 0)
|
||||
{
|
||||
message += $"\n\n入口 Flow 将同时删除:{string.Join(", ", entryFlows.Select(flow => flow.Id))}";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private void OnNodeSelected(FrameAnimationClipNodeView view)
|
||||
{
|
||||
if (rebuilding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, view.Data));
|
||||
NotifySelectionSummary();
|
||||
}
|
||||
|
||||
private void OnEdgeSelected(FrameAnimationEdgeView view)
|
||||
{
|
||||
if (rebuilding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, view.Data));
|
||||
NotifySelectionSummary();
|
||||
}
|
||||
|
||||
private void NotifySelectionSummary()
|
||||
{
|
||||
if (rebuilding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
MultiSelectionChanged?.Invoke(SelectedNodes);
|
||||
var selected = selection.Select(item => item switch
|
||||
{
|
||||
FrameAnimationClipNodeView nodeView => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Node, nodeView.Data),
|
||||
FrameAnimationEdgeView edgeView => new FrameAnimationEditorSelection(
|
||||
FrameAnimationEditorSelectionKind.Edge, edgeView.Data),
|
||||
_ => default
|
||||
})
|
||||
.Where(item => item.Kind != FrameAnimationEditorSelectionKind.None)
|
||||
.ToArray();
|
||||
SelectionSetChanged?.Invoke(selected);
|
||||
}
|
||||
|
||||
private void OnKeyDown(KeyDownEvent evt)
|
||||
{
|
||||
if (!TryHandleFocusExitShortcut(evt.keyCode, evt.target is TextField))
|
||||
{
|
||||
return;
|
||||
}
|
||||
evt.StopImmediatePropagation();
|
||||
}
|
||||
|
||||
internal bool TryHandleFocusExitShortcut(KeyCode keyCode, bool textInputActive)
|
||||
{
|
||||
if (keyCode != KeyCode.Escape || textInputActive || string.IsNullOrEmpty(focusedFlowId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ExitFocusRequested?.Invoke();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void OnDragUpdated(DragUpdatedEvent evt)
|
||||
{
|
||||
if (DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip && graph?.Clips.Contains(clip) == true)
|
||||
{
|
||||
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragPerform(DragPerformEvent evt)
|
||||
{
|
||||
if (!(DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip) || graph?.Clips.Contains(clip) != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DragAndDrop.AcceptDrag();
|
||||
CreateNode(clip, contentViewContainer.WorldToLocal(evt.mousePosition));
|
||||
DragAndDrop.SetGenericData(ClipDragKey, null);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ffd56cf8d10f4ff9b33510a268d6b615
|
||||
@@ -0,0 +1,353 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal enum FrameAnimationImportIssueCode
|
||||
{
|
||||
JsonMissing,
|
||||
TextureMissing,
|
||||
JsonInvalid,
|
||||
FramesMissing,
|
||||
FrameNameInvalid,
|
||||
FrameNameDuplicate,
|
||||
FrameDurationInvalid,
|
||||
FrameRectInvalid,
|
||||
TextureSizeMismatch,
|
||||
TrimmedUnsupported,
|
||||
RotatedUnsupported,
|
||||
TagNameInvalid,
|
||||
TagNameDuplicate,
|
||||
TagRangeInvalid,
|
||||
TagDirectionInvalid,
|
||||
TagConflict,
|
||||
PlayableIdConflict,
|
||||
ImportSourceIdInvalid,
|
||||
ImportSourceIdDuplicate,
|
||||
ImportSourceReferenceInvalid,
|
||||
ImportedClipOwnershipInvalid,
|
||||
TextureOwnershipConflict,
|
||||
SpriteMatchFailed,
|
||||
TextureImporterInvalid,
|
||||
TextureWriteFailed,
|
||||
SourceChanged,
|
||||
ImageNameMismatch,
|
||||
NoTags,
|
||||
ApplyFailed
|
||||
}
|
||||
|
||||
internal enum FrameAnimationImportChangeKind
|
||||
{
|
||||
Added,
|
||||
Updated,
|
||||
Missing,
|
||||
Unchanged,
|
||||
Error
|
||||
}
|
||||
|
||||
internal enum FrameAnimationSpriteChangeKind
|
||||
{
|
||||
Added,
|
||||
Updated,
|
||||
Retained,
|
||||
Unchanged,
|
||||
Error
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationImportIssue
|
||||
{
|
||||
public FrameAnimationValidationSeverity Severity { get; }
|
||||
public FrameAnimationImportIssueCode Code { get; }
|
||||
public string SourceId { get; }
|
||||
public string TargetId { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity severity,
|
||||
FrameAnimationImportIssueCode code,
|
||||
string sourceId,
|
||||
string targetId,
|
||||
string message)
|
||||
{
|
||||
Severity = severity;
|
||||
Code = code;
|
||||
SourceId = sourceId ?? string.Empty;
|
||||
TargetId = targetId ?? string.Empty;
|
||||
Message = message ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AsepriteSourceFrame
|
||||
{
|
||||
public string FrameName { get; }
|
||||
public RectInt Rect { get; }
|
||||
public int DurationMs { get; }
|
||||
public bool Rotated { get; }
|
||||
public bool Trimmed { get; }
|
||||
public RectInt SpriteSourceSize { get; }
|
||||
public Vector2Int SourceSize { get; }
|
||||
|
||||
public AsepriteSourceFrame(
|
||||
string frameName,
|
||||
RectInt rect,
|
||||
int durationMs,
|
||||
bool rotated,
|
||||
bool trimmed,
|
||||
RectInt spriteSourceSize,
|
||||
Vector2Int sourceSize)
|
||||
{
|
||||
FrameName = frameName ?? string.Empty;
|
||||
Rect = rect;
|
||||
DurationMs = durationMs;
|
||||
Rotated = rotated;
|
||||
Trimmed = trimmed;
|
||||
SpriteSourceSize = spriteSourceSize;
|
||||
SourceSize = sourceSize;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AsepriteSourceTag
|
||||
{
|
||||
public string Name { get; }
|
||||
public int From { get; }
|
||||
public int To { get; }
|
||||
public string Direction { get; }
|
||||
|
||||
public AsepriteSourceTag(string name, int from, int to, string direction)
|
||||
{
|
||||
Name = name ?? string.Empty;
|
||||
From = from;
|
||||
To = to;
|
||||
Direction = direction ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AsepriteSpriteSlot
|
||||
{
|
||||
public RectInt Rect { get; }
|
||||
public string CanonicalFrameName { get; }
|
||||
public IReadOnlyList<int> SourceIndices { get; }
|
||||
public IReadOnlyList<string> FrameNames { get; }
|
||||
|
||||
public AsepriteSpriteSlot(
|
||||
RectInt rect,
|
||||
string canonicalFrameName,
|
||||
IReadOnlyList<int> sourceIndices,
|
||||
IReadOnlyList<string> frameNames)
|
||||
{
|
||||
Rect = rect;
|
||||
CanonicalFrameName = canonicalFrameName ?? string.Empty;
|
||||
SourceIndices = sourceIndices ?? Array.Empty<int>();
|
||||
FrameNames = frameNames ?? Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AsepriteSourceDocument
|
||||
{
|
||||
public IReadOnlyList<AsepriteSourceFrame> Frames { get; }
|
||||
public IReadOnlyList<AsepriteSpriteSlot> SpriteSlots { get; }
|
||||
public IReadOnlyDictionary<int, AsepriteSpriteSlot> SpriteSlotBySourceIndex { get; }
|
||||
public IReadOnlyList<AsepriteSourceTag> Tags { get; }
|
||||
public Vector2Int TextureSize { get; }
|
||||
public string ImageName { get; }
|
||||
|
||||
public AsepriteSourceDocument(
|
||||
IReadOnlyList<AsepriteSourceFrame> frames,
|
||||
IReadOnlyList<AsepriteSourceTag> tags,
|
||||
Vector2Int textureSize,
|
||||
string imageName)
|
||||
{
|
||||
Frames = frames ?? Array.Empty<AsepriteSourceFrame>();
|
||||
BuildSpriteSlots(Frames, out var spriteSlots, out var slotBySourceIndex);
|
||||
SpriteSlots = spriteSlots;
|
||||
SpriteSlotBySourceIndex = slotBySourceIndex;
|
||||
Tags = tags ?? Array.Empty<AsepriteSourceTag>();
|
||||
TextureSize = textureSize;
|
||||
ImageName = imageName ?? string.Empty;
|
||||
}
|
||||
|
||||
private static void BuildSpriteSlots(
|
||||
IReadOnlyList<AsepriteSourceFrame> frames,
|
||||
out IReadOnlyList<AsepriteSpriteSlot> spriteSlots,
|
||||
out IReadOnlyDictionary<int, AsepriteSpriteSlot> slotBySourceIndex)
|
||||
{
|
||||
var builders = new List<SpriteSlotBuilder>();
|
||||
var buildersByRect = new Dictionary<RectInt, SpriteSlotBuilder>();
|
||||
for (var sourceIndex = 0; sourceIndex < frames.Count; sourceIndex++)
|
||||
{
|
||||
var frame = frames[sourceIndex];
|
||||
if (!buildersByRect.TryGetValue(frame.Rect, out var builder))
|
||||
{
|
||||
builder = new SpriteSlotBuilder(frame.Rect, frame.FrameName);
|
||||
buildersByRect.Add(frame.Rect, builder);
|
||||
builders.Add(builder);
|
||||
}
|
||||
builder.SourceIndices.Add(sourceIndex);
|
||||
builder.FrameNames.Add(frame.FrameName);
|
||||
}
|
||||
|
||||
var slots = builders.Select(builder => builder.Build()).ToArray();
|
||||
var index = new Dictionary<int, AsepriteSpriteSlot>();
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
foreach (var sourceIndex in slot.SourceIndices)
|
||||
{
|
||||
index[sourceIndex] = slot;
|
||||
}
|
||||
}
|
||||
|
||||
spriteSlots = slots;
|
||||
slotBySourceIndex = index;
|
||||
}
|
||||
|
||||
private sealed class SpriteSlotBuilder
|
||||
{
|
||||
public RectInt Rect { get; }
|
||||
public string CanonicalFrameName { get; }
|
||||
public List<int> SourceIndices { get; } = new List<int>();
|
||||
public List<string> FrameNames { get; } = new List<string>();
|
||||
|
||||
public SpriteSlotBuilder(RectInt rect, string canonicalFrameName)
|
||||
{
|
||||
Rect = rect;
|
||||
CanonicalFrameName = canonicalFrameName ?? string.Empty;
|
||||
}
|
||||
|
||||
public AsepriteSpriteSlot Build()
|
||||
{
|
||||
return new AsepriteSpriteSlot(
|
||||
Rect,
|
||||
CanonicalFrameName,
|
||||
SourceIndices.ToArray(),
|
||||
FrameNames.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationClipDiff
|
||||
{
|
||||
public FrameAnimationImportChangeKind Kind { get; }
|
||||
public string SourceTagName { get; }
|
||||
public FrameClip Clip { get; }
|
||||
public IReadOnlyList<int> SourceIndices { get; }
|
||||
public string Summary { get; }
|
||||
|
||||
public FrameAnimationClipDiff(
|
||||
FrameAnimationImportChangeKind kind,
|
||||
string sourceTagName,
|
||||
FrameClip clip,
|
||||
IReadOnlyList<int> sourceIndices,
|
||||
string summary)
|
||||
{
|
||||
Kind = kind;
|
||||
SourceTagName = sourceTagName ?? string.Empty;
|
||||
Clip = clip;
|
||||
SourceIndices = sourceIndices ?? Array.Empty<int>();
|
||||
Summary = summary ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationSpriteDiff
|
||||
{
|
||||
public FrameAnimationSpriteChangeKind Kind { get; }
|
||||
public string FrameName { get; }
|
||||
public IReadOnlyList<string> AliasFrameNames { get; }
|
||||
public int SourceFrameCount => AliasFrameNames.Count;
|
||||
public Rect Rect { get; }
|
||||
public string Summary { get; }
|
||||
|
||||
public FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind kind,
|
||||
string frameName,
|
||||
Rect rect,
|
||||
string summary,
|
||||
IReadOnlyList<string> aliasFrameNames = null)
|
||||
{
|
||||
Kind = kind;
|
||||
FrameName = frameName ?? string.Empty;
|
||||
AliasFrameNames = aliasFrameNames ?? Array.Empty<string>();
|
||||
Rect = rect;
|
||||
Summary = summary ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IFrameAnimationImportPreviewSink
|
||||
{
|
||||
void AddIssue(FrameAnimationImportIssue issue);
|
||||
void AddSpriteDiff(FrameAnimationSpriteDiff diff);
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationImportSourcePreview : IFrameAnimationImportPreviewSink
|
||||
{
|
||||
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
|
||||
private readonly List<FrameAnimationClipDiff> clipDiffs = new List<FrameAnimationClipDiff>();
|
||||
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
|
||||
|
||||
public FrameAnimationImportSource Source { get; }
|
||||
public AsepriteSourceDocument Document { get; internal set; }
|
||||
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
|
||||
public string CurrentHash { get; internal set; } = string.Empty;
|
||||
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
|
||||
public IReadOnlyList<FrameAnimationClipDiff> ClipDiffs => clipDiffs;
|
||||
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
|
||||
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
|
||||
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
|
||||
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
|
||||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
|
||||
|
||||
public FrameAnimationImportSourcePreview(FrameAnimationImportSource source)
|
||||
{
|
||||
Source = source;
|
||||
}
|
||||
|
||||
public void AddIssue(FrameAnimationImportIssue issue)
|
||||
{
|
||||
if (issue != null)
|
||||
{
|
||||
issues.Add(issue);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddClipDiff(FrameAnimationClipDiff diff)
|
||||
{
|
||||
if (diff != null)
|
||||
{
|
||||
clipDiffs.Add(diff);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
|
||||
{
|
||||
if (diff != null)
|
||||
{
|
||||
spriteDiffs.Add(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationImportPreview
|
||||
{
|
||||
private readonly List<FrameAnimationImportSourcePreview> sources =
|
||||
new List<FrameAnimationImportSourcePreview>();
|
||||
|
||||
public FrameAnimationGraph Graph { get; }
|
||||
public IReadOnlyList<FrameAnimationImportSourcePreview> Sources => sources;
|
||||
public bool HasErrors => sources.Any(source => source.HasErrors);
|
||||
public bool HasSpriteChanges => sources.Any(source => source.HasSpriteChanges);
|
||||
|
||||
public FrameAnimationImportPreview(FrameAnimationGraph graph)
|
||||
{
|
||||
Graph = graph;
|
||||
}
|
||||
|
||||
public void AddSource(FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
if (preview != null)
|
||||
{
|
||||
sources.Add(preview);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c9d29e9107c2ec549916dfb45e638943
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationImportSampleBuilder
|
||||
{
|
||||
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation/Import";
|
||||
private const string ReadOnlyTexturePath = SampleFolder + "/中文预切图.png";
|
||||
private const string WritableTexturePath = SampleFolder + "/中文自动切图.png";
|
||||
private const string ObjectJsonPath = SampleFolder + "/中文Object.json";
|
||||
private const string ArrayJsonPath = SampleFolder + "/中文Array.json";
|
||||
private const string GraphPath = SampleFolder + "/ImportSampleGraph.asset";
|
||||
private const string IdleNodeId = "40000000000000000000000000000001";
|
||||
private const string BlinkNodeId = "40000000000000000000000000000002";
|
||||
private const string SharedNodeId = "40000000000000000000000000000003";
|
||||
private const string IdleEdgeId = "40000000000000000000000000000011";
|
||||
private const string BlinkEdgeId = "40000000000000000000000000000012";
|
||||
private const string IdleFlowId = "SampleIdleToSharedFlow";
|
||||
private const string BlinkFlowId = "SampleBlinkToSharedFlow";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Rebuild Import Sample")]
|
||||
private static void BuildFromMenu()
|
||||
{
|
||||
BuildFromCommandLine();
|
||||
}
|
||||
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
EnsureFolder(SampleFolder);
|
||||
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
|
||||
{
|
||||
AssetDatabase.DeleteAsset(GraphPath);
|
||||
}
|
||||
|
||||
WriteTexture(ReadOnlyTexturePath);
|
||||
WriteTexture(WritableTexturePath);
|
||||
WriteTextAsset(ObjectJsonPath, CreateObjectJson("中文预切图.png", "待机", "眨眼"));
|
||||
WriteTextAsset(ArrayJsonPath, CreateArrayJson("中文自动切图.png", "转身", "惊讶"));
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
||||
|
||||
var readOnlyTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath);
|
||||
var objectJson = AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath);
|
||||
if (!AsepriteJsonParser.TryParse(objectJson.text, "sample-slicing", out var document, out var parseIssue))
|
||||
{
|
||||
throw new InvalidOperationException(parseIssue.Message);
|
||||
}
|
||||
var slicingSource = new FrameAnimationImportSource(
|
||||
"Prepare Read Only Texture", readOnlyTexture, objectJson, new Vector2(0.5f, 0.5f), true);
|
||||
var slicingPreview = new FrameAnimationImportSourcePreview(slicingSource) { Document = document };
|
||||
var slicingPlan = FrameAnimationSpriteUtility.BuildPlan(slicingSource, document, slicingPreview);
|
||||
if (slicingPreview.HasErrors)
|
||||
{
|
||||
throw new InvalidOperationException(string.Join("\n", System.Linq.Enumerable.Select(
|
||||
slicingPreview.Issues, issue => issue.Message)));
|
||||
}
|
||||
FrameAnimationSpriteUtility.ApplyPlan(slicingPlan);
|
||||
|
||||
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
||||
graph.Configure(
|
||||
"ImportSampleGraph",
|
||||
"Aseprite 导入与稳定刷新样例",
|
||||
Array.Empty<FrameClip>(),
|
||||
Array.Empty<AnimationNode>(),
|
||||
Array.Empty<AnimationEdge>(),
|
||||
Array.Empty<AnimationFlow>(),
|
||||
string.Empty);
|
||||
graph.AddImportSource(new FrameAnimationImportSource(
|
||||
"Object / 只读切图",
|
||||
AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath),
|
||||
AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
false));
|
||||
graph.AddImportSource(new FrameAnimationImportSource(
|
||||
"Array / 自动切图",
|
||||
AssetDatabase.LoadAssetAtPath<Texture2D>(WritableTexturePath),
|
||||
AssetDatabase.LoadAssetAtPath<TextAsset>(ArrayJsonPath),
|
||||
new Vector2(0.5f, 0.5f),
|
||||
true));
|
||||
AssetDatabase.CreateAsset(graph, GraphPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var preview = FrameAnimationImportService.PreviewAll(graph);
|
||||
if (!FrameAnimationImportService.Apply(preview, true, out var error))
|
||||
{
|
||||
throw new InvalidOperationException(error + "\n" + string.Join("\n",
|
||||
System.Linq.Enumerable.SelectMany(preview.Sources,
|
||||
source => System.Linq.Enumerable.Select(source.Issues, issue => issue.Message))));
|
||||
}
|
||||
|
||||
ExtendForWorkspaceSample(graph);
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Selection.activeObject = graph;
|
||||
Debug.Log($"Frame Animation import sample rebuilt: {GraphPath}");
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Upgrade Phase 4 Sample (Non-Destructive)")]
|
||||
public static void UpgradePhase4Sample()
|
||||
{
|
||||
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath);
|
||||
if (graph == null)
|
||||
{
|
||||
Debug.LogError($"Frame Animation sample Graph not found: {GraphPath}");
|
||||
return;
|
||||
}
|
||||
var idleClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "待机");
|
||||
var blinkClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "眨眼");
|
||||
var sharedClip = AssetDatabase.LoadAssetAtPath<FrameClip>(
|
||||
"Assets/GameContent/Test/FrameAnimation/Idle.asset") ??
|
||||
graph.Clips.FirstOrDefault(clip => clip != null && !clip.IsImported);
|
||||
if (idleClip == null || blinkClip == null || sharedClip == null || !graph.Clips.Contains(sharedClip))
|
||||
{
|
||||
Debug.LogError("Phase 4 sample upgrade requires 待机、眨眼 and a Manual Clip already referenced by the Graph.");
|
||||
return;
|
||||
}
|
||||
|
||||
Undo.RecordObject(graph, "Upgrade Frame Animation Phase 4 Sample");
|
||||
var idleNode = GetOrAddNode(graph, IdleNodeId, idleClip, "待机 Node");
|
||||
var blinkNode = GetOrAddNode(graph, BlinkNodeId, blinkClip, "眨眼 Node");
|
||||
var sharedNode = graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == SharedNodeId);
|
||||
if (sharedNode == null)
|
||||
{
|
||||
sharedNode = new AnimationNode(sharedClip.Id, "Shared Idle Node",
|
||||
FrameClipEndBehavior.Loop, internalId: SharedNodeId);
|
||||
graph.AddNode(sharedNode);
|
||||
}
|
||||
GetOrAddEdge(graph, IdleEdgeId, idleNode, sharedNode);
|
||||
GetOrAddEdge(graph, BlinkEdgeId, blinkNode, sharedNode);
|
||||
GetOrAddFlow(graph, IdleFlowId, "待机到共享 Idle", idleNode,
|
||||
new Color(0.24f, 0.65f, 1f));
|
||||
GetOrAddFlow(graph, BlinkFlowId, "眨眼到共享 Idle", blinkNode,
|
||||
new Color(1f, 0.62f, 0.24f));
|
||||
graph.EditorData.GetOrCreateNodeData(IdleNodeId, new Vector2(0f, 0f));
|
||||
graph.EditorData.GetOrCreateNodeData(BlinkNodeId, new Vector2(0f, 220f));
|
||||
graph.EditorData.GetOrCreateNodeData(SharedNodeId, new Vector2(360f, 110f));
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
Selection.activeObject = graph;
|
||||
Debug.Log("Frame Animation Phase 4 sample upgraded without rebuilding existing assets or sources.");
|
||||
}
|
||||
|
||||
private static AnimationNode GetOrAddNode(
|
||||
FrameAnimationGraph graph,
|
||||
string internalId,
|
||||
FrameClip clip,
|
||||
string displayName)
|
||||
{
|
||||
var node = graph.Nodes.FirstOrDefault(item => item != null && item.InternalId == internalId);
|
||||
if (node != null)
|
||||
{
|
||||
return node;
|
||||
}
|
||||
node = new AnimationNode(clip.Id, displayName, internalId: internalId);
|
||||
graph.AddNode(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
private static void GetOrAddEdge(
|
||||
FrameAnimationGraph graph,
|
||||
string internalId,
|
||||
AnimationNode from,
|
||||
AnimationNode to)
|
||||
{
|
||||
if (graph.Edges.Any(edge => edge != null && edge.InternalId == internalId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
graph.AddEdge(new AnimationEdge(from.InternalId, to.InternalId, internalId));
|
||||
}
|
||||
|
||||
private static void GetOrAddFlow(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
string displayName,
|
||||
AnimationNode entry,
|
||||
Color color)
|
||||
{
|
||||
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == id);
|
||||
if (flow == null && FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
|
||||
{
|
||||
flow = new AnimationFlow(id, displayName, entry.InternalId);
|
||||
graph.AddFlow(flow);
|
||||
}
|
||||
if (flow != null)
|
||||
{
|
||||
graph.EditorData.GetOrCreateFlowData(flow.Id, color);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExtendForWorkspaceSample(FrameAnimationGraph graph)
|
||||
{
|
||||
var imported = graph.Clips.First(clip => clip.ImportInfo?.SourceTagName == "待机");
|
||||
var manual = ScriptableObject.CreateInstance<FrameClip>();
|
||||
manual.name = "ManualWorkspaceSample";
|
||||
manual.Configure(
|
||||
"ManualWorkspaceSample",
|
||||
"Graph 内 Manual 样例",
|
||||
imported.Frames.Take(1).Select(frame => new FrameAnimationFrame(
|
||||
frame.Sprite, frame.DurationMs, string.Empty, -1)),
|
||||
1f,
|
||||
FrameClipEndBehavior.HoldLastFrame);
|
||||
AssetDatabase.AddObjectToAsset(manual, graph);
|
||||
|
||||
var clips = graph.Clips.Concat(new[] { manual }).ToList();
|
||||
var external = AssetDatabase.LoadAssetAtPath<FrameClip>(
|
||||
"Assets/GameContent/Test/FrameAnimation/Idle.asset");
|
||||
if (external != null && clips.All(clip => clip.Id != external.Id))
|
||||
{
|
||||
clips.Add(external);
|
||||
}
|
||||
|
||||
var importedNode = new AnimationNode(imported.Id, "Imported 待机");
|
||||
var terminalClip = external != null && clips.Contains(external) ? external : manual;
|
||||
var terminalNode = new AnimationNode(
|
||||
terminalClip.Id,
|
||||
"共享 Manual 终点",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Loop);
|
||||
var edge = new AnimationEdge(importedNode.InternalId, terminalNode.InternalId);
|
||||
var flow = new AnimationFlow(
|
||||
"WorkspaceSampleFlow",
|
||||
"工作台引用定位样例",
|
||||
importedNode.InternalId);
|
||||
graph.Configure(
|
||||
graph.Id,
|
||||
graph.DisplayName,
|
||||
clips,
|
||||
new[] { importedNode, terminalNode },
|
||||
new[] { edge },
|
||||
new[] { flow },
|
||||
flow.Id);
|
||||
}
|
||||
|
||||
private static JObject CreateFrame(string name, int x, int duration)
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["filename"] = name,
|
||||
["frame"] = new JObject { ["x"] = x, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
|
||||
["rotated"] = false,
|
||||
["trimmed"] = false,
|
||||
["spriteSourceSize"] = new JObject { ["x"] = 0, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
|
||||
["sourceSize"] = new JObject { ["w"] = 2, ["h"] = 2 },
|
||||
["duration"] = duration
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateObjectJson(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
var root = CreateRoot(imageName, firstTag, secondTag);
|
||||
root["frames"] = new JObject
|
||||
{
|
||||
["中文帧_00"] = WithoutFilename(CreateFrame("中文帧_00", 0, 160)),
|
||||
["中文帧_01"] = WithoutFilename(CreateFrame("中文帧_01", 2, 90))
|
||||
};
|
||||
return root.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
private static string CreateArrayJson(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
var root = CreateRoot(imageName, firstTag, secondTag);
|
||||
root["frames"] = new JArray(
|
||||
CreateFrame("自动帧_00", 0, 140),
|
||||
CreateFrame("自动帧_01", 2, 110));
|
||||
return root.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
private static JObject CreateRoot(string imageName, string firstTag, string secondTag)
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["meta"] = new JObject
|
||||
{
|
||||
["image"] = imageName,
|
||||
["size"] = new JObject { ["w"] = 4, ["h"] = 2 },
|
||||
["frameTags"] = new JArray(
|
||||
new JObject
|
||||
{
|
||||
["name"] = firstTag, ["from"] = 0, ["to"] = 0, ["direction"] = "forward"
|
||||
},
|
||||
new JObject
|
||||
{
|
||||
["name"] = secondTag, ["from"] = 0, ["to"] = 1, ["direction"] = "pingpong"
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JObject WithoutFilename(JObject frame)
|
||||
{
|
||||
frame.Remove("filename");
|
||||
return frame;
|
||||
}
|
||||
|
||||
private static void WriteTexture(string path)
|
||||
{
|
||||
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
|
||||
texture.SetPixels(new[]
|
||||
{
|
||||
Color.cyan, Color.cyan, Color.magenta, Color.magenta,
|
||||
Color.cyan, Color.cyan, Color.magenta, Color.magenta
|
||||
});
|
||||
texture.Apply();
|
||||
File.WriteAllBytes(path, texture.EncodeToPNG());
|
||||
UnityEngine.Object.DestroyImmediate(texture);
|
||||
}
|
||||
|
||||
private static void WriteTextAsset(string path, string content)
|
||||
{
|
||||
File.WriteAllText(path, content, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
{
|
||||
AssetDatabase.CreateFolder(current, segments[index]);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 291df5abf0a29fd4ca1cb0b86eb24a9e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,627 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal static class FrameAnimationImportService
|
||||
{
|
||||
public static FrameAnimationImportPreview PreviewSource(
|
||||
FrameAnimationGraph graph,
|
||||
string sourceId)
|
||||
{
|
||||
var source = graph?.ImportSources.FirstOrDefault(item => item != null && item.InternalId == sourceId);
|
||||
return PreviewInternal(graph, source != null ? new[] { source } : Array.Empty<FrameAnimationImportSource>());
|
||||
}
|
||||
|
||||
public static FrameAnimationImportPreview PreviewAll(FrameAnimationGraph graph)
|
||||
{
|
||||
return PreviewInternal(
|
||||
graph,
|
||||
graph?.ImportSources.Where(source => source != null && source.IsEnabled).ToArray() ??
|
||||
Array.Empty<FrameAnimationImportSource>());
|
||||
}
|
||||
|
||||
public static bool RefreshSource(
|
||||
FrameAnimationGraph graph,
|
||||
string sourceId,
|
||||
bool allowSpriteChanges,
|
||||
out FrameAnimationImportPreview preview,
|
||||
out string error)
|
||||
{
|
||||
preview = PreviewSource(graph, sourceId);
|
||||
return Apply(preview, allowSpriteChanges, out error);
|
||||
}
|
||||
|
||||
public static bool RefreshAll(
|
||||
FrameAnimationGraph graph,
|
||||
bool allowSpriteChanges,
|
||||
out FrameAnimationImportPreview preview,
|
||||
out string error)
|
||||
{
|
||||
preview = PreviewAll(graph);
|
||||
return Apply(preview, allowSpriteChanges, out error);
|
||||
}
|
||||
|
||||
public static bool Apply(
|
||||
FrameAnimationImportPreview preview,
|
||||
bool allowSpriteChanges,
|
||||
out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (preview == null || preview.Graph == null)
|
||||
{
|
||||
error = "导入预览或 Graph 为空。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preview.HasErrors)
|
||||
{
|
||||
error = "导入预览包含阻断错误,未修改任何资产。";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preview.HasSpriteChanges && !allowSpriteChanges)
|
||||
{
|
||||
error = "导入需要修改 TextureImporter,但尚未获得确认。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var graphPath = AssetDatabase.GetAssetPath(preview.Graph);
|
||||
if (string.IsNullOrEmpty(graphPath))
|
||||
{
|
||||
error = "FrameAnimationGraph 必须先保存为资产才能创建 Imported Clip sub-asset。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var snapshots = new List<FrameAnimationTextureSnapshot>();
|
||||
Undo.IncrementCurrentGroup();
|
||||
var undoGroup = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Refresh Frame Animation Imports");
|
||||
|
||||
try
|
||||
{
|
||||
Undo.RecordObject(preview.Graph, "Refresh Frame Animation Imports");
|
||||
foreach (var sourcePreview in preview.Sources.Where(item =>
|
||||
item.Source.ManageSpriteSlicing && item.HasSpriteChanges))
|
||||
{
|
||||
var snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(sourcePreview.SpritePlan);
|
||||
snapshots.Add(snapshot);
|
||||
FrameAnimationSpriteUtility.ApplyPlan(sourcePreview.SpritePlan);
|
||||
}
|
||||
|
||||
foreach (var sourcePreview in preview.Sources)
|
||||
{
|
||||
ApplySource(preview.Graph, sourcePreview);
|
||||
sourcePreview.Source.SetLastSourceHash(
|
||||
FrameAnimationSpriteUtility.ComputeSourceHash(sourcePreview.Source));
|
||||
}
|
||||
|
||||
EditorUtility.SetDirty(preview.Graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
Undo.CollapseUndoOperations(undoGroup);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
try
|
||||
{
|
||||
Undo.RevertAllDownToGroup(undoGroup);
|
||||
for (var index = snapshots.Count - 1; index >= 0; index--)
|
||||
{
|
||||
FrameAnimationSpriteUtility.RestoreSnapshot(snapshots[index]);
|
||||
}
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
catch (Exception rollbackException)
|
||||
{
|
||||
Debug.LogError($"Frame Animation 导入回滚失败:{rollbackException}");
|
||||
}
|
||||
|
||||
error = $"导入应用失败,已尝试回滚:{exception.Message}";
|
||||
Debug.LogException(exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static FrameAnimationImportPreview PreviewInternal(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyList<FrameAnimationImportSource> targets)
|
||||
{
|
||||
var preview = new FrameAnimationImportPreview(graph);
|
||||
if (graph == null)
|
||||
{
|
||||
return preview;
|
||||
}
|
||||
|
||||
var targetSet = new HashSet<FrameAnimationImportSource>(targets);
|
||||
var targetPreviews = new Dictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview>();
|
||||
foreach (var source in targets)
|
||||
{
|
||||
var sourcePreview = new FrameAnimationImportSourcePreview(source);
|
||||
targetPreviews[source] = sourcePreview;
|
||||
preview.AddSource(sourcePreview);
|
||||
}
|
||||
|
||||
ValidateSourceIds(graph, targetPreviews);
|
||||
var contextDocuments = ParseEnabledContext(graph, targetPreviews);
|
||||
ValidateCrossSourceTags(graph, contextDocuments, targetPreviews);
|
||||
ValidateTextureOwnership(graph, targetPreviews);
|
||||
|
||||
foreach (var pair in targetPreviews)
|
||||
{
|
||||
var source = pair.Key;
|
||||
var sourcePreview = pair.Value;
|
||||
if (!contextDocuments.TryGetValue(source, out var document))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sourcePreview.Document = document;
|
||||
ValidateSource(graph, source, document, sourcePreview);
|
||||
if (sourcePreview.HasErrors)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
sourcePreview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(source, document, sourcePreview);
|
||||
sourcePreview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(source);
|
||||
if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != sourcePreview.CurrentHash)
|
||||
{
|
||||
sourcePreview.AddIssue(new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
FrameAnimationImportIssueCode.SourceChanged,
|
||||
source.InternalId,
|
||||
source.InternalId,
|
||||
"来源内容或切图设置自上次刷新后已发生变化。"));
|
||||
}
|
||||
|
||||
if (!sourcePreview.HasErrors)
|
||||
{
|
||||
BuildClipDiffs(graph, source, document, sourcePreview);
|
||||
}
|
||||
}
|
||||
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static Dictionary<FrameAnimationImportSource, AsepriteSourceDocument> ParseEnabledContext(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
|
||||
{
|
||||
var result = new Dictionary<FrameAnimationImportSource, AsepriteSourceDocument>();
|
||||
foreach (var source in graph.ImportSources.Where(source => source != null && source.IsEnabled))
|
||||
{
|
||||
targetPreviews.TryGetValue(source, out var sourcePreview);
|
||||
if (source.AsepriteJson == null)
|
||||
{
|
||||
sourcePreview?.AddIssue(Error(source, FrameAnimationImportIssueCode.JsonMissing, "ImportSource 缺少 Aseprite JSON。"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, source.InternalId, out var document, out var issue))
|
||||
{
|
||||
sourcePreview?.AddIssue(issue);
|
||||
continue;
|
||||
}
|
||||
result[source] = document;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateSourceIds(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
|
||||
{
|
||||
foreach (var source in graph.ImportSources.Where(source => source != null))
|
||||
{
|
||||
if (!FrameAnimationValueUtility.IsValidInternalId(source.InternalId) &&
|
||||
targetPreviews.TryGetValue(source, out var sourcePreview))
|
||||
{
|
||||
sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdInvalid,
|
||||
"ImportSource internalId 必须是 N 格式 GUID。"));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var duplicate in graph.ImportSources
|
||||
.Where(source => source != null)
|
||||
.GroupBy(source => source.InternalId)
|
||||
.Where(group => group.Count() > 1))
|
||||
{
|
||||
foreach (var source in duplicate)
|
||||
{
|
||||
if (targetPreviews.TryGetValue(source, out var sourcePreview))
|
||||
{
|
||||
sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdDuplicate,
|
||||
$"ImportSource internalId '{source.InternalId}' 重复。"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSource(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationImportSource source,
|
||||
AsepriteSourceDocument document,
|
||||
FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
if (source.Texture == null)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureMissing, "ImportSource 缺少 Texture。"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.Frames.Count == 0)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。"));
|
||||
}
|
||||
|
||||
foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameNameDuplicate,
|
||||
$"frameName '{duplicate.Key}' 在同一来源内重复。"));
|
||||
}
|
||||
|
||||
for (var index = 0; index < document.Frames.Count; index++)
|
||||
{
|
||||
var frame = document.Frames[index];
|
||||
if (string.IsNullOrWhiteSpace(frame.FrameName))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameNameInvalid,
|
||||
$"SourceFrame[{index}] 的 frameName 为空。"));
|
||||
}
|
||||
if (frame.DurationMs <= 0)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameDurationInvalid,
|
||||
$"SourceFrame '{frame.FrameName}' 的 duration 必须大于 0。"));
|
||||
}
|
||||
if (frame.Rect.width <= 0 || frame.Rect.height <= 0 || frame.Rect.x < 0 || frame.Rect.y < 0 ||
|
||||
frame.Rect.xMax > source.Texture.width || frame.Rect.yMax > source.Texture.height)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameRectInvalid,
|
||||
$"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。"));
|
||||
}
|
||||
if (frame.Trimmed)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TrimmedUnsupported,
|
||||
$"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 trimmed。"));
|
||||
}
|
||||
if (frame.Rotated)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.RotatedUnsupported,
|
||||
$"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 rotated。"));
|
||||
}
|
||||
}
|
||||
|
||||
if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureSizeMismatch,
|
||||
$"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。"));
|
||||
}
|
||||
|
||||
var textureFileName = Path.GetFileName(AssetDatabase.GetAssetPath(source.Texture));
|
||||
if (!string.IsNullOrEmpty(document.ImageName) &&
|
||||
!string.Equals(document.ImageName, textureFileName, StringComparison.Ordinal))
|
||||
{
|
||||
preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.ImageNameMismatch,
|
||||
$"meta.image '{document.ImageName}' 与绑定 Texture '{textureFileName}' 不一致。"));
|
||||
}
|
||||
|
||||
if (document.Tags.Count == 0)
|
||||
{
|
||||
preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.NoTags,
|
||||
"JSON 中没有 frameTags,此来源不会生成 Clip。"));
|
||||
}
|
||||
|
||||
foreach (var duplicate in document.Tags.GroupBy(tag => tag.Name).Where(group => group.Count() > 1))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameDuplicate,
|
||||
$"Tag '{duplicate.Key}' 在同一来源内重复。"));
|
||||
}
|
||||
|
||||
foreach (var tag in document.Tags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag.Name))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameInvalid, "Tag 名不能为空。"));
|
||||
continue;
|
||||
}
|
||||
if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out _, out var issue))
|
||||
{
|
||||
preview.AddIssue(issue);
|
||||
}
|
||||
}
|
||||
|
||||
ValidateClipAssociations(graph, source, preview);
|
||||
}
|
||||
|
||||
private static void ValidateClipAssociations(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationImportSource source,
|
||||
FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
var graphPath = AssetDatabase.GetAssetPath(graph);
|
||||
foreach (var clip in graph.Clips.Where(clip => clip != null && clip.IsImported))
|
||||
{
|
||||
if (!graph.ImportSources.Any(item => item != null && item.InternalId == clip.ImportInfo.ImportSourceId))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
|
||||
$"Imported Clip '{clip.Id}' 指向不存在的 ImportSource。"));
|
||||
}
|
||||
if (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid,
|
||||
$"Imported Clip '{clip.Id}' 必须是所属 Graph 的 sub-asset。"));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var duplicate in graph.Clips
|
||||
.Where(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId)
|
||||
.GroupBy(clip => clip.ImportInfo.SourceTagName)
|
||||
.Where(group => group.Count() > 1))
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
|
||||
$"来源 Tag '{duplicate.Key}' 同时关联了多个 Imported Clip。"));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateCrossSourceTags(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyDictionary<FrameAnimationImportSource, AsepriteSourceDocument> documents,
|
||||
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
|
||||
{
|
||||
var tags = documents.SelectMany(pair => pair.Value.Tags.Select(tag => (source: pair.Key, tag)));
|
||||
foreach (var duplicate in tags.Where(item => !string.IsNullOrWhiteSpace(item.tag.Name))
|
||||
.GroupBy(item => item.tag.Name).Where(group => group.Count() > 1))
|
||||
{
|
||||
foreach (var item in duplicate)
|
||||
{
|
||||
if (targetPreviews.TryGetValue(item.source, out var sourcePreview))
|
||||
{
|
||||
sourcePreview.AddIssue(Error(item.source, FrameAnimationImportIssueCode.TagConflict,
|
||||
$"Tag '{duplicate.Key}' 与其他启用 ImportSource 重名。"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in targetPreviews)
|
||||
{
|
||||
if (!documents.TryGetValue(pair.Key, out var document))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var tag in document.Tags)
|
||||
{
|
||||
var existing = graph.Clips.Count(clip => clip != null &&
|
||||
clip.ImportInfo?.ImportSourceId == pair.Key.InternalId &&
|
||||
clip.ImportInfo.SourceTagName == tag.Name);
|
||||
if (existing > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (graph.Clips.Any(clip => clip != null && clip.Id == tag.Name) ||
|
||||
graph.Flows.Any(flow => flow != null && flow.Id == tag.Name))
|
||||
{
|
||||
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.PlayableIdConflict,
|
||||
$"新 Tag '{tag.Name}' 与现有 Clip / Flow playable id 冲突。"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateTextureOwnership(
|
||||
FrameAnimationGraph currentGraph,
|
||||
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
|
||||
{
|
||||
var owners = new List<(FrameAnimationGraph graph, FrameAnimationImportSource source)>();
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph"))
|
||||
{
|
||||
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (graph == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
owners.AddRange(graph.ImportSources.Where(source => source != null && source.IsEnabled &&
|
||||
source.ManageSpriteSlicing && source.Texture != null).Select(source => (graph, source)));
|
||||
}
|
||||
if (!owners.Any(item => item.graph == currentGraph))
|
||||
{
|
||||
owners.AddRange(currentGraph.ImportSources.Where(source => source != null && source.IsEnabled &&
|
||||
source.ManageSpriteSlicing && source.Texture != null).Select(source => (currentGraph, source)));
|
||||
}
|
||||
|
||||
var standaloneOwners = AssetDatabase.FindAssets("t:FrameClip")
|
||||
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid)))
|
||||
.Where(clip => clip?.StandaloneImportSource != null &&
|
||||
clip.StandaloneImportSource.ManageSpriteSlicing &&
|
||||
clip.StandaloneImportSource.Texture != null)
|
||||
.ToArray();
|
||||
|
||||
foreach (var pair in targetPreviews.Where(pair => pair.Key.ManageSpriteSlicing && pair.Key.Texture != null))
|
||||
{
|
||||
var conflicts = owners.Where(item => item.source.Texture == pair.Key.Texture &&
|
||||
!(item.graph == currentGraph && ReferenceEquals(item.source, pair.Key))).ToArray();
|
||||
if (conflicts.Length > 0)
|
||||
{
|
||||
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict,
|
||||
$"Texture '{pair.Key.Texture.name}' 已被其他可写 ImportSource 占用:" +
|
||||
string.Join(", ", conflicts.Select(item => $"{item.graph.name}/{item.source.DisplayName}"))));
|
||||
}
|
||||
var standaloneConflicts = standaloneOwners.Where(clip =>
|
||||
clip.StandaloneImportSource.Texture == pair.Key.Texture).ToArray();
|
||||
if (standaloneConflicts.Length > 0)
|
||||
{
|
||||
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict,
|
||||
$"Texture '{pair.Key.Texture.name}' 已被独立 Clip 来源占用:" +
|
||||
string.Join(", ", standaloneConflicts.Select(clip => clip.name))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildClipDiffs(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationImportSource source,
|
||||
AsepriteSourceDocument document,
|
||||
FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
var seenTags = new HashSet<string>();
|
||||
foreach (var tag in document.Tags)
|
||||
{
|
||||
if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out var indices, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seenTags.Add(tag.Name);
|
||||
var clip = graph.Clips.SingleOrDefault(item => item != null &&
|
||||
item.ImportInfo?.ImportSourceId == source.InternalId &&
|
||||
item.ImportInfo.SourceTagName == tag.Name);
|
||||
if (clip == null)
|
||||
{
|
||||
preview.AddClipDiff(new FrameAnimationClipDiff(
|
||||
FrameAnimationImportChangeKind.Added, tag.Name, null, indices, "创建 Imported Clip sub-asset"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var changed = clip.ImportInfo.IsMissingFromSource ||
|
||||
!FramesEqual(clip, document, preview.SpritePlan, indices);
|
||||
preview.AddClipDiff(new FrameAnimationClipDiff(
|
||||
changed ? FrameAnimationImportChangeKind.Updated : FrameAnimationImportChangeKind.Unchanged,
|
||||
tag.Name,
|
||||
clip,
|
||||
indices,
|
||||
changed ? "原地更新导入帧" : "无变化"));
|
||||
}
|
||||
|
||||
foreach (var clip in graph.Clips.Where(item => item != null &&
|
||||
item.ImportInfo?.ImportSourceId == source.InternalId &&
|
||||
!seenTags.Contains(item.ImportInfo.SourceTagName)))
|
||||
{
|
||||
preview.AddClipDiff(new FrameAnimationClipDiff(
|
||||
clip.ImportInfo.IsMissingFromSource
|
||||
? FrameAnimationImportChangeKind.Unchanged
|
||||
: FrameAnimationImportChangeKind.Missing,
|
||||
clip.ImportInfo.SourceTagName,
|
||||
clip,
|
||||
Array.Empty<int>(),
|
||||
clip.ImportInfo.IsMissingFromSource ? "保持 Missing" : "标记 Missing,不删除 Clip"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool FramesEqual(
|
||||
FrameClip clip,
|
||||
AsepriteSourceDocument document,
|
||||
FrameAnimationSpritePlan spritePlan,
|
||||
IReadOnlyList<int> indices)
|
||||
{
|
||||
if (clip.FrameCount != indices.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (var index = 0; index < indices.Count; index++)
|
||||
{
|
||||
var sourceIndex = indices[index];
|
||||
var sourceFrame = document.Frames[sourceIndex];
|
||||
var frame = clip.Frames[index];
|
||||
if (frame == null || frame.SourceIndex != sourceIndex ||
|
||||
frame.FrameName != sourceFrame.FrameName || frame.DurationMs != sourceFrame.DurationMs ||
|
||||
spritePlan == null || !spritePlan.MatchesSprite(sourceIndex, frame.Sprite))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ApplySource(
|
||||
FrameAnimationGraph graph,
|
||||
FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
var source = preview.Source;
|
||||
var writableSprites = source.ManageSpriteSlicing
|
||||
? FrameAnimationSpriteUtility.LoadSpritesByName(AssetDatabase.GetAssetPath(source.Texture))
|
||||
: null;
|
||||
|
||||
foreach (var diff in preview.ClipDiffs)
|
||||
{
|
||||
if (diff.Kind == FrameAnimationImportChangeKind.Missing)
|
||||
{
|
||||
Undo.RecordObject(diff.Clip, "Mark Imported Clip Missing");
|
||||
diff.Clip.SetImportMissingState(true);
|
||||
EditorUtility.SetDirty(diff.Clip);
|
||||
continue;
|
||||
}
|
||||
if (diff.Kind == FrameAnimationImportChangeKind.Unchanged && diff.Clip != null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var frames = diff.SourceIndices.Select(sourceIndex =>
|
||||
{
|
||||
var sourceFrame = preview.Document.Frames[sourceIndex];
|
||||
if (!preview.SpritePlan.TryResolveSprite(sourceIndex, writableSprites, out var sprite))
|
||||
{
|
||||
var expectedName = preview.SpritePlan.SpriteNamesBySourceIndex.TryGetValue(
|
||||
sourceIndex, out var plannedName)
|
||||
? plannedName
|
||||
: sourceFrame.FrameName;
|
||||
throw new InvalidOperationException(
|
||||
$"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的共享 Sprite '{expectedName}'。 ");
|
||||
}
|
||||
return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex);
|
||||
}).ToArray();
|
||||
|
||||
if (diff.Clip != null)
|
||||
{
|
||||
Undo.RecordObject(diff.Clip, "Update Imported Frame Clip");
|
||||
diff.Clip.ReplaceImportedFrames(frames, source.InternalId, diff.SourceTagName);
|
||||
EditorUtility.SetDirty(diff.Clip);
|
||||
continue;
|
||||
}
|
||||
|
||||
var clip = ScriptableObject.CreateInstance<FrameClip>();
|
||||
clip.name = diff.SourceTagName;
|
||||
clip.Configure(
|
||||
diff.SourceTagName,
|
||||
diff.SourceTagName,
|
||||
frames,
|
||||
1f,
|
||||
source.DefaultNewClipEndBehavior,
|
||||
new FrameClipImportInfo(source.InternalId, diff.SourceTagName, false));
|
||||
AssetDatabase.AddObjectToAsset(clip, graph);
|
||||
Undo.RegisterCreatedObjectUndo(clip, "Create Imported Frame Clip");
|
||||
graph.AddImportedClip(clip);
|
||||
EditorUtility.SetDirty(clip);
|
||||
}
|
||||
}
|
||||
|
||||
private static FrameAnimationImportIssue Error(
|
||||
FrameAnimationImportSource source,
|
||||
FrameAnimationImportIssueCode code,
|
||||
string message)
|
||||
{
|
||||
return new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
code,
|
||||
source?.InternalId,
|
||||
source?.InternalId,
|
||||
message);
|
||||
}
|
||||
|
||||
private static FrameAnimationImportIssue Warning(
|
||||
FrameAnimationImportSource source,
|
||||
FrameAnimationImportIssueCode code,
|
||||
string message)
|
||||
{
|
||||
return new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
code,
|
||||
source?.InternalId,
|
||||
source?.InternalId,
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed2b95cf766a27d4cb93bbc8b2ba472e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,91 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
[CustomEditor(typeof(FrameAnimationPlayer))]
|
||||
public sealed class FrameAnimationPlayerEditor : UnityEditor.Editor
|
||||
{
|
||||
private SerializedProperty graphProperty;
|
||||
private SerializedProperty playOnEnableProperty;
|
||||
private SerializedProperty speedProperty;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
graphProperty = serializedObject.FindProperty("graph");
|
||||
playOnEnableProperty = serializedObject.FindProperty("playOnEnable");
|
||||
speedProperty = serializedObject.FindProperty("speed");
|
||||
}
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
EditorGUILayout.PropertyField(graphProperty);
|
||||
EditorGUILayout.PropertyField(playOnEnableProperty);
|
||||
EditorGUILayout.PropertyField(speedProperty);
|
||||
|
||||
DrawConfigurationWarnings();
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
|
||||
if (Application.isPlaying)
|
||||
{
|
||||
DrawRuntimeState();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawConfigurationWarnings()
|
||||
{
|
||||
var player = (FrameAnimationPlayer)target;
|
||||
var spriteRenderer = player.GetComponent<SpriteRenderer>();
|
||||
var image = player.GetComponent<Image>();
|
||||
|
||||
if (player.GetComponent<FrameClipPlayer>() != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。",
|
||||
MessageType.Error);
|
||||
}
|
||||
|
||||
if (spriteRenderer == null && image == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"同一 GameObject 上必须存在一个 SpriteRenderer 或 Image。",
|
||||
MessageType.Error);
|
||||
}
|
||||
else if (spriteRenderer != null && image != null)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"同一 GameObject 不能同时存在 SpriteRenderer 和 Image。",
|
||||
MessageType.Error);
|
||||
}
|
||||
|
||||
if (graphProperty.objectReferenceValue == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("尚未绑定 FrameAnimationGraph。", MessageType.Error);
|
||||
}
|
||||
|
||||
if (!FrameAnimationValueUtility.IsValidSpeed(speedProperty.floatValue))
|
||||
{
|
||||
EditorGUILayout.HelpBox("Speed 必须是有限且不小于 0 的数值。", MessageType.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRuntimeState()
|
||||
{
|
||||
var player = (FrameAnimationPlayer)target;
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.EnumPopup("State", player.State);
|
||||
EditorGUILayout.TextField("Playable", player.CurrentPlayableId);
|
||||
EditorGUILayout.TextField("Clip", player.CurrentClipId);
|
||||
EditorGUILayout.TextField("Node", player.CurrentNodeId);
|
||||
EditorGUILayout.IntField("Frame", player.CurrentFrameIndex);
|
||||
}
|
||||
|
||||
Repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26b26e9170b32ec4693ae2aa8ab88838
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,603 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal enum FrameAnimationPreviewTargetKind { None, Clip, Node, Flow }
|
||||
internal enum FrameAnimationPreviewState { Stopped, Playing, Paused, Failed }
|
||||
internal enum NodePreviewPolicy { Static, SelectedOnly, AllVisible }
|
||||
internal enum FrameAnimationPreviewBackground { Checkerboard, Dark, Light }
|
||||
internal enum FrameAnimationPreviewZoomMode { Fit, One, Two, Three, Four, Eight, Manual }
|
||||
internal enum FrameAnimationFlowElementState { None, Upcoming, Current, Passed }
|
||||
|
||||
internal readonly struct FrameAnimationPreviewSegment
|
||||
{
|
||||
public int StepIndex { get; }
|
||||
public int FrameIndex { get; }
|
||||
public FrameClip Clip { get; }
|
||||
public string NodeId { get; }
|
||||
public double StartSeconds { get; }
|
||||
public double DurationSeconds { get; }
|
||||
public bool IsBlocked { get; }
|
||||
|
||||
public FrameAnimationPreviewSegment(
|
||||
int stepIndex,
|
||||
int frameIndex,
|
||||
ResolvedPlaybackStep step,
|
||||
double startSeconds,
|
||||
double durationSeconds,
|
||||
bool isBlocked)
|
||||
{
|
||||
StepIndex = stepIndex;
|
||||
FrameIndex = frameIndex;
|
||||
Clip = step.Clip;
|
||||
NodeId = step.NodeId;
|
||||
StartSeconds = startSeconds;
|
||||
DurationSeconds = durationSeconds;
|
||||
IsBlocked = isBlocked;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationPreviewTimeline
|
||||
{
|
||||
private const double Epsilon = 0.000000001d;
|
||||
private readonly List<FrameAnimationPreviewSegment> segments = new List<FrameAnimationPreviewSegment>();
|
||||
|
||||
public IReadOnlyList<FrameAnimationPreviewSegment> Segments => segments;
|
||||
public double DisplayDurationSeconds { get; }
|
||||
public bool IsInfinite { get; }
|
||||
public bool IsBlocked { get; }
|
||||
public int BlockedSegmentIndex { get; } = -1;
|
||||
|
||||
public FrameAnimationPreviewTimeline(ResolvedPlaybackPlan plan)
|
||||
{
|
||||
if (plan?.Steps == null || plan.Steps.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var cursor = 0d;
|
||||
var blockedIndex = -1;
|
||||
for (var stepIndex = 0; stepIndex < plan.Steps.Count; stepIndex++)
|
||||
{
|
||||
var step = plan.Steps[stepIndex];
|
||||
if (step?.Clip == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (var frameIndex = 0; frameIndex < step.Clip.FrameCount; frameIndex++)
|
||||
{
|
||||
var frame = step.Clip.Frames[frameIndex];
|
||||
if (step.PlaybackSpeed == 0f)
|
||||
{
|
||||
blockedIndex = segments.Count;
|
||||
segments.Add(new FrameAnimationPreviewSegment(
|
||||
stepIndex, frameIndex, step, cursor, 0d, true));
|
||||
break;
|
||||
}
|
||||
|
||||
var duration = frame.DurationMs / 1000d / step.PlaybackSpeed;
|
||||
segments.Add(new FrameAnimationPreviewSegment(
|
||||
stepIndex, frameIndex, step, cursor, duration, false));
|
||||
cursor += duration;
|
||||
}
|
||||
if (blockedIndex >= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
DisplayDurationSeconds = cursor;
|
||||
BlockedSegmentIndex = blockedIndex;
|
||||
IsBlocked = blockedIndex >= 0;
|
||||
IsInfinite = IsBlocked || plan.Steps[plan.Steps.Count - 1].TerminalEndBehavior == FrameClipEndBehavior.Loop;
|
||||
}
|
||||
|
||||
public int FindSegmentIndex(double positionSeconds)
|
||||
{
|
||||
if (segments.Count == 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
var position = Math.Max(0d, positionSeconds);
|
||||
for (var index = 0; index < segments.Count; index++)
|
||||
{
|
||||
var segment = segments[index];
|
||||
if (segment.IsBlocked || position < segment.StartSeconds + segment.DurationSeconds - Epsilon)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return segments.Count - 1;
|
||||
}
|
||||
|
||||
public double GetSeekSeconds(int segmentIndex)
|
||||
{
|
||||
return segmentIndex >= 0 && segmentIndex < segments.Count
|
||||
? segments[segmentIndex].StartSeconds
|
||||
: 0d;
|
||||
}
|
||||
|
||||
public double GetPosition(FrameAnimationPlaybackSnapshot snapshot)
|
||||
{
|
||||
if (!snapshot.HasStarted || segments.Count == 0)
|
||||
{
|
||||
return 0d;
|
||||
}
|
||||
var segment = segments.FirstOrDefault(item =>
|
||||
item.StepIndex == snapshot.StepIndex && item.FrameIndex == snapshot.FrameIndex);
|
||||
if (segment.Clip == null)
|
||||
{
|
||||
return snapshot.IsCompleted ? DisplayDurationSeconds : 0d;
|
||||
}
|
||||
if (snapshot.IsCompleted && !IsInfinite)
|
||||
{
|
||||
return DisplayDurationSeconds;
|
||||
}
|
||||
var elapsed = snapshot.StepSpeed > 0f
|
||||
? snapshot.FrameElapsedSeconds / snapshot.StepSpeed
|
||||
: 0d;
|
||||
return Math.Min(DisplayDurationSeconds, segment.StartSeconds + elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct FrameAnimationPreviewDisplay
|
||||
{
|
||||
public Sprite Sprite { get; }
|
||||
public bool IsEmpty { get; }
|
||||
public bool IsActive { get; }
|
||||
public FrameAnimationFlowElementState FlowState { get; }
|
||||
|
||||
public FrameAnimationPreviewDisplay(
|
||||
Sprite sprite,
|
||||
bool isEmpty,
|
||||
bool isActive,
|
||||
FrameAnimationFlowElementState flowState)
|
||||
{
|
||||
Sprite = sprite;
|
||||
IsEmpty = isEmpty;
|
||||
IsActive = isActive;
|
||||
FlowState = flowState;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationPreviewCoordinator
|
||||
{
|
||||
private sealed class NodeRun
|
||||
{
|
||||
public AnimationNode Node;
|
||||
public FrameAnimationPlaybackSession Session;
|
||||
public Sprite Sprite;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, NodeRun> automaticRuns = new Dictionary<string, NodeRun>();
|
||||
private FrameAnimationGraph graph;
|
||||
private object target;
|
||||
private ResolvedPlaybackPlan plan;
|
||||
private FrameAnimationPlaybackSession session;
|
||||
private Sprite currentSprite;
|
||||
private bool terminalTransparent;
|
||||
private bool suppressSelectedNode;
|
||||
private bool flowPresentationActive;
|
||||
|
||||
public event Action Changed;
|
||||
|
||||
public FrameAnimationPreviewTargetKind TargetKind { get; private set; }
|
||||
public FrameAnimationPreviewState State { get; private set; } = FrameAnimationPreviewState.Stopped;
|
||||
public NodePreviewPolicy NodePolicy { get; set; } = NodePreviewPolicy.SelectedOnly;
|
||||
public FrameAnimationPreviewBackground Background { get; set; } = FrameAnimationPreviewBackground.Checkerboard;
|
||||
public FrameAnimationPreviewZoomMode ZoomMode { get; set; } = FrameAnimationPreviewZoomMode.Fit;
|
||||
public float ManualZoom { get; set; } = 1f;
|
||||
public float PreviewSpeed { get; set; } = 1f;
|
||||
public FrameAnimationPreviewTimeline Timeline { get; private set; }
|
||||
public FrameAnimationPlaybackError Error { get; private set; } = FrameAnimationPlaybackError.None;
|
||||
public double PositionSeconds { get; private set; }
|
||||
public string TransitionFromNodeId { get; private set; } = string.Empty;
|
||||
public string TransitionToNodeId { get; private set; } = string.Empty;
|
||||
public object Target => target;
|
||||
public bool IsFlowPreview => TargetKind == FrameAnimationPreviewTargetKind.Flow &&
|
||||
flowPresentationActive && State != FrameAnimationPreviewState.Failed;
|
||||
public FrameAnimationPlaybackSnapshot Snapshot => session != null
|
||||
? session.Snapshot
|
||||
: default;
|
||||
public Sprite CurrentSprite => terminalTransparent ? null : currentSprite;
|
||||
|
||||
public void SetGraph(FrameAnimationGraph value)
|
||||
{
|
||||
if (ReferenceEquals(graph, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
graph = value;
|
||||
ClearTarget();
|
||||
automaticRuns.Clear();
|
||||
}
|
||||
|
||||
public void SetTarget(FrameAnimationPreviewTargetKind kind, object value)
|
||||
{
|
||||
if (TargetKind == kind && ReferenceEquals(target, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
TargetKind = kind;
|
||||
target = value;
|
||||
suppressSelectedNode = false;
|
||||
BuildTarget(false, 0d);
|
||||
if (kind == FrameAnimationPreviewTargetKind.Node && NodePolicy != NodePreviewPolicy.Static && session != null)
|
||||
{
|
||||
State = FrameAnimationPreviewState.Playing;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void ClearTarget()
|
||||
{
|
||||
TargetKind = FrameAnimationPreviewTargetKind.None;
|
||||
target = null;
|
||||
plan = null;
|
||||
session = null;
|
||||
Timeline = null;
|
||||
currentSprite = null;
|
||||
terminalTransparent = false;
|
||||
Error = FrameAnimationPlaybackError.None;
|
||||
PositionSeconds = 0d;
|
||||
State = FrameAnimationPreviewState.Stopped;
|
||||
flowPresentationActive = false;
|
||||
TransitionFromNodeId = string.Empty;
|
||||
TransitionToNodeId = string.Empty;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void RefreshTarget(bool topologyChanged)
|
||||
{
|
||||
if (TargetKind == FrameAnimationPreviewTargetKind.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (topologyChanged && TargetKind == FrameAnimationPreviewTargetKind.Flow)
|
||||
{
|
||||
Stop();
|
||||
BuildTarget(false, 0d);
|
||||
return;
|
||||
}
|
||||
var wasPlaying = State == FrameAnimationPreviewState.Playing;
|
||||
var position = PositionSeconds;
|
||||
BuildTarget(position > 0d, position);
|
||||
if (wasPlaying && session != null)
|
||||
{
|
||||
State = FrameAnimationPreviewState.Playing;
|
||||
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void SetNodePolicy(NodePreviewPolicy value)
|
||||
{
|
||||
NodePolicy = value;
|
||||
automaticRuns.Clear();
|
||||
suppressSelectedNode = false;
|
||||
if (TargetKind == FrameAnimationPreviewTargetKind.Node)
|
||||
{
|
||||
BuildTarget(false, 0d);
|
||||
if (session != null && value != NodePreviewPolicy.Static)
|
||||
{
|
||||
State = FrameAnimationPreviewState.Playing;
|
||||
}
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Play()
|
||||
{
|
||||
if (session == null)
|
||||
{
|
||||
BuildTarget(false, 0d);
|
||||
}
|
||||
if (session == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (session.Snapshot.IsCompleted)
|
||||
{
|
||||
session.Restart(ApplySprite);
|
||||
PositionSeconds = 0d;
|
||||
}
|
||||
terminalTransparent = false;
|
||||
suppressSelectedNode = false;
|
||||
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
|
||||
State = FrameAnimationPreviewState.Playing;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Pause()
|
||||
{
|
||||
if (session == null || State != FrameAnimationPreviewState.Playing) return;
|
||||
State = FrameAnimationPreviewState.Paused;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (session != null)
|
||||
{
|
||||
session.Restart(ApplySprite);
|
||||
}
|
||||
State = FrameAnimationPreviewState.Stopped;
|
||||
PositionSeconds = 0d;
|
||||
terminalTransparent = false;
|
||||
suppressSelectedNode = TargetKind == FrameAnimationPreviewTargetKind.Node;
|
||||
flowPresentationActive = false;
|
||||
TransitionFromNodeId = string.Empty;
|
||||
TransitionToNodeId = string.Empty;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Restart()
|
||||
{
|
||||
if (session == null)
|
||||
{
|
||||
BuildTarget(false, 0d);
|
||||
}
|
||||
session?.Restart(ApplySprite);
|
||||
PositionSeconds = 0d;
|
||||
terminalTransparent = false;
|
||||
suppressSelectedNode = false;
|
||||
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
|
||||
if (session != null) State = FrameAnimationPreviewState.Playing;
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Step(int direction)
|
||||
{
|
||||
if (session == null || Timeline == null || Timeline.Segments.Count == 0) return;
|
||||
var current = Timeline.FindSegmentIndex(PositionSeconds);
|
||||
var next = Mathf.Clamp(current + Math.Sign(direction), 0, Timeline.Segments.Count - 1);
|
||||
Seek(Timeline.GetSeekSeconds(next));
|
||||
}
|
||||
|
||||
public void Seek(double seconds)
|
||||
{
|
||||
if (session == null || Timeline == null) return;
|
||||
var maximum = Timeline.DisplayDurationSeconds;
|
||||
var targetSeconds = Math.Max(0d, Math.Min(maximum, seconds));
|
||||
if (Timeline.IsInfinite && maximum > 0d && targetSeconds >= maximum)
|
||||
{
|
||||
targetSeconds = Math.Max(0d, maximum - 0.000001d);
|
||||
}
|
||||
terminalTransparent = false;
|
||||
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
|
||||
var result = session.Seek(targetSeconds, ApplySprite);
|
||||
ApplyEvaluation(result);
|
||||
PositionSeconds = targetSeconds;
|
||||
if (State != FrameAnimationPreviewState.Failed)
|
||||
{
|
||||
State = FrameAnimationPreviewState.Paused;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public bool Tick(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
|
||||
{
|
||||
var changed = false;
|
||||
TransitionFromNodeId = string.Empty;
|
||||
TransitionToNodeId = string.Empty;
|
||||
if (State == FrameAnimationPreviewState.Playing && session != null)
|
||||
{
|
||||
var before = session.Snapshot;
|
||||
var result = session.Evaluate(deltaSeconds, PreviewSpeed, ApplySprite);
|
||||
var after = session.Snapshot;
|
||||
if (before.StepIndex != after.StepIndex)
|
||||
{
|
||||
TransitionFromNodeId = before.NodeId;
|
||||
TransitionToNodeId = after.NodeId;
|
||||
}
|
||||
ApplyEvaluation(result);
|
||||
PositionSeconds = Timeline?.GetPosition(after) ?? 0d;
|
||||
if (result.IsCompleted && TargetKind == FrameAnimationPreviewTargetKind.Node &&
|
||||
NodePolicy != NodePreviewPolicy.Static && !suppressSelectedNode && session != null)
|
||||
{
|
||||
session.Restart(ApplySprite);
|
||||
State = FrameAnimationPreviewState.Playing;
|
||||
PositionSeconds = 0d;
|
||||
terminalTransparent = false;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!IsFlowPreview)
|
||||
{
|
||||
changed |= TickAutomaticNodes(deltaSeconds, visibleNodes);
|
||||
}
|
||||
else if (automaticRuns.Count > 0)
|
||||
{
|
||||
automaticRuns.Clear();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) Changed?.Invoke();
|
||||
return changed;
|
||||
}
|
||||
|
||||
public FrameAnimationPreviewDisplay GetNodeDisplay(AnimationNode node)
|
||||
{
|
||||
if (node == null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
if (IsFlowPreview && plan != null)
|
||||
{
|
||||
var current = session?.Snapshot.StepIndex ?? 0;
|
||||
var stepIndex = IndexOfNode(plan, node.InternalId);
|
||||
var flowState = stepIndex < 0 ? FrameAnimationFlowElementState.None :
|
||||
stepIndex < current ? FrameAnimationFlowElementState.Passed :
|
||||
stepIndex == current ? FrameAnimationFlowElementState.Current : FrameAnimationFlowElementState.Upcoming;
|
||||
if (flowState == FrameAnimationFlowElementState.Current)
|
||||
{
|
||||
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, true, flowState);
|
||||
}
|
||||
return Representative(node, flowState);
|
||||
}
|
||||
if (TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node) && session != null)
|
||||
{
|
||||
if (NodePolicy == NodePreviewPolicy.Static && State == FrameAnimationPreviewState.Stopped &&
|
||||
!suppressSelectedNode && !session.Snapshot.IsCompleted)
|
||||
{
|
||||
return Representative(node, FrameAnimationFlowElementState.None);
|
||||
}
|
||||
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, State == FrameAnimationPreviewState.Playing,
|
||||
FrameAnimationFlowElementState.None);
|
||||
}
|
||||
if (automaticRuns.TryGetValue(node.InternalId, out var run))
|
||||
{
|
||||
return new FrameAnimationPreviewDisplay(run.Sprite, run.Sprite == null, true, FrameAnimationFlowElementState.None);
|
||||
}
|
||||
return Representative(node, FrameAnimationFlowElementState.None);
|
||||
}
|
||||
|
||||
public FrameAnimationFlowElementState GetEdgeState(AnimationEdge edge)
|
||||
{
|
||||
if (!IsFlowPreview || plan == null || edge == null) return FrameAnimationFlowElementState.None;
|
||||
if (edge.FromNodeId == TransitionFromNodeId && edge.ToNodeId == TransitionToNodeId)
|
||||
{
|
||||
return FrameAnimationFlowElementState.Current;
|
||||
}
|
||||
var from = IndexOfNode(plan, edge.FromNodeId);
|
||||
var to = IndexOfNode(plan, edge.ToNodeId);
|
||||
if (from < 0 || to != from + 1) return FrameAnimationFlowElementState.None;
|
||||
var current = session?.Snapshot.StepIndex ?? 0;
|
||||
return to <= current ? FrameAnimationFlowElementState.Passed : FrameAnimationFlowElementState.Upcoming;
|
||||
}
|
||||
|
||||
private bool TickAutomaticNodes(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
|
||||
{
|
||||
IEnumerable<AnimationNode> desired = Array.Empty<AnimationNode>();
|
||||
if (NodePolicy == NodePreviewPolicy.SelectedOnly && TargetKind == FrameAnimationPreviewTargetKind.Node &&
|
||||
target is AnimationNode selected && !suppressSelectedNode)
|
||||
{
|
||||
desired = new[] { selected };
|
||||
}
|
||||
else if (NodePolicy == NodePreviewPolicy.AllVisible)
|
||||
{
|
||||
desired = visibleNodes ?? Array.Empty<AnimationNode>();
|
||||
}
|
||||
var desiredNodes = desired.Where(node => node != null &&
|
||||
!(TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node)))
|
||||
.GroupBy(node => node.InternalId).Select(group => group.First()).ToArray();
|
||||
var desiredIds = new HashSet<string>(desiredNodes.Select(node => node.InternalId));
|
||||
foreach (var id in automaticRuns.Keys.Where(id => !desiredIds.Contains(id)).ToArray())
|
||||
{
|
||||
automaticRuns.Remove(id);
|
||||
}
|
||||
foreach (var node in desiredNodes)
|
||||
{
|
||||
if (!automaticRuns.TryGetValue(node.InternalId, out var run))
|
||||
{
|
||||
if (!FrameAnimationResolver.TryResolveNode(graph, node, out var nodePlan, out _)) continue;
|
||||
run = new NodeRun { Node = node, Session = new FrameAnimationPlaybackSession(nodePlan) };
|
||||
run.Session.Start(sprite => run.Sprite = sprite);
|
||||
automaticRuns[node.InternalId] = run;
|
||||
}
|
||||
var evaluation = run.Session.Evaluate(deltaSeconds, PreviewSpeed, sprite => run.Sprite = sprite);
|
||||
if (evaluation.IsCompleted)
|
||||
{
|
||||
run.Session.Restart(sprite => run.Sprite = sprite);
|
||||
}
|
||||
else if (evaluation.IsFailed)
|
||||
{
|
||||
automaticRuns.Remove(node.InternalId);
|
||||
}
|
||||
}
|
||||
return desiredNodes.Length > 0 || automaticRuns.Count > 0;
|
||||
}
|
||||
|
||||
private void BuildTarget(bool seek, double position)
|
||||
{
|
||||
plan = null;
|
||||
session = null;
|
||||
Timeline = null;
|
||||
currentSprite = null;
|
||||
terminalTransparent = false;
|
||||
PositionSeconds = 0d;
|
||||
Error = FrameAnimationPlaybackError.None;
|
||||
State = FrameAnimationPreviewState.Stopped;
|
||||
flowPresentationActive = false;
|
||||
var resolved = TargetKind switch
|
||||
{
|
||||
FrameAnimationPreviewTargetKind.Clip when target is FrameClip clip =>
|
||||
TryResolvePreviewClip(clip, out plan, out var clipError)
|
||||
? FrameAnimationPlaybackError.None : clipError,
|
||||
FrameAnimationPreviewTargetKind.Node when target is AnimationNode node =>
|
||||
FrameAnimationResolver.TryResolveNode(graph, node, out plan, out var nodeError)
|
||||
? FrameAnimationPlaybackError.None : nodeError,
|
||||
FrameAnimationPreviewTargetKind.Flow when target is AnimationFlow flow =>
|
||||
FrameAnimationResolver.TryResolve(graph, flow.Id, default, out plan, out var flowError)
|
||||
? FrameAnimationPlaybackError.None : flowError,
|
||||
_ => new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.PlayableNotFound, "没有可预览目标。")
|
||||
};
|
||||
if (resolved.Code != FrameAnimationPlaybackErrorCode.None || plan == null)
|
||||
{
|
||||
Error = resolved;
|
||||
State = TargetKind == FrameAnimationPreviewTargetKind.None
|
||||
? FrameAnimationPreviewState.Stopped : FrameAnimationPreviewState.Failed;
|
||||
return;
|
||||
}
|
||||
Timeline = new FrameAnimationPreviewTimeline(plan);
|
||||
session = new FrameAnimationPlaybackSession(plan);
|
||||
session.Start(ApplySprite);
|
||||
if (seek && position > 0d)
|
||||
{
|
||||
Seek(position);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryResolvePreviewClip(
|
||||
FrameClip clip,
|
||||
out ResolvedPlaybackPlan resolvedPlan,
|
||||
out FrameAnimationPlaybackError error)
|
||||
{
|
||||
return graph != null
|
||||
? FrameAnimationResolver.TryResolve(graph, clip.Id, default, out resolvedPlan, out error)
|
||||
: FrameAnimationResolver.TryResolveClip(clip, default, out resolvedPlan, out error);
|
||||
}
|
||||
|
||||
private void ApplyEvaluation(FrameAnimationSessionEvaluation evaluation)
|
||||
{
|
||||
if (evaluation.IsFailed)
|
||||
{
|
||||
Error = evaluation.Error;
|
||||
State = FrameAnimationPreviewState.Failed;
|
||||
flowPresentationActive = false;
|
||||
return;
|
||||
}
|
||||
if (!evaluation.IsCompleted) return;
|
||||
State = FrameAnimationPreviewState.Stopped;
|
||||
terminalTransparent = evaluation.EndBehavior == FrameClipEndBehavior.Clear ||
|
||||
evaluation.EndBehavior == FrameClipEndBehavior.HideTarget;
|
||||
}
|
||||
|
||||
private void ApplySprite(Sprite sprite)
|
||||
{
|
||||
currentSprite = sprite;
|
||||
terminalTransparent = false;
|
||||
}
|
||||
|
||||
private FrameAnimationPreviewDisplay Representative(AnimationNode node, FrameAnimationFlowElementState flowState)
|
||||
{
|
||||
var clip = graph?.Clips.FirstOrDefault(item => item != null && item.Id == node.ClipId);
|
||||
var sprite = clip?.Frames.FirstOrDefault(frame => frame?.Sprite != null)?.Sprite;
|
||||
return new FrameAnimationPreviewDisplay(sprite, sprite == null, false, flowState);
|
||||
}
|
||||
|
||||
private static int IndexOfNode(ResolvedPlaybackPlan targetPlan, string nodeId)
|
||||
{
|
||||
if (targetPlan?.Steps == null) return -1;
|
||||
for (var index = 0; index < targetPlan.Steps.Count; index++)
|
||||
{
|
||||
if (targetPlan.Steps[index].NodeId == nodeId) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14a91ced504e4af4ad79f4f760794c3c
|
||||
@@ -0,0 +1,163 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal sealed class FrameAnimationPreviewElement : VisualElement
|
||||
{
|
||||
private static Texture2D checkerTexture;
|
||||
private readonly Image image;
|
||||
private readonly Label emptyLabel;
|
||||
private Sprite sprite;
|
||||
private FrameAnimationPreviewZoomMode zoomMode = FrameAnimationPreviewZoomMode.Fit;
|
||||
private float manualZoom = 1f;
|
||||
private Vector2 pan;
|
||||
private Vector2 dragStart;
|
||||
private Vector2 panStart;
|
||||
private bool dragging;
|
||||
|
||||
public FrameAnimationPreviewElement()
|
||||
{
|
||||
AddToClassList("fa-preview");
|
||||
focusable = true;
|
||||
image = new Image { scaleMode = ScaleMode.ScaleToFit, pickingMode = PickingMode.Ignore };
|
||||
image.AddToClassList("fa-preview__image");
|
||||
emptyLabel = new Label("Empty Frame") { pickingMode = PickingMode.Ignore };
|
||||
emptyLabel.AddToClassList("fa-preview__empty");
|
||||
Add(image);
|
||||
Add(emptyLabel);
|
||||
RegisterCallback<GeometryChangedEvent>(_ => UpdateImageLayout());
|
||||
RegisterCallback<PointerDownEvent>(OnPointerDown);
|
||||
RegisterCallback<PointerMoveEvent>(OnPointerMove);
|
||||
RegisterCallback<PointerUpEvent>(OnPointerUp);
|
||||
SetBackground(FrameAnimationPreviewBackground.Checkerboard);
|
||||
}
|
||||
|
||||
public void SetDisplay(Sprite value, bool isEmpty, string emptyText = "Empty Frame")
|
||||
{
|
||||
sprite = value;
|
||||
image.sprite = value;
|
||||
image.style.display = value != null ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
emptyLabel.text = string.IsNullOrEmpty(emptyText) ? "Empty Frame" : emptyText;
|
||||
emptyLabel.style.display = value == null || isEmpty ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
UpdateImageLayout();
|
||||
}
|
||||
|
||||
public void SetBackground(FrameAnimationPreviewBackground background)
|
||||
{
|
||||
switch (background)
|
||||
{
|
||||
case FrameAnimationPreviewBackground.Dark:
|
||||
style.backgroundImage = StyleKeyword.None;
|
||||
style.backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f));
|
||||
break;
|
||||
case FrameAnimationPreviewBackground.Light:
|
||||
style.backgroundImage = StyleKeyword.None;
|
||||
style.backgroundColor = new StyleColor(new Color(0.72f, 0.72f, 0.72f));
|
||||
break;
|
||||
default:
|
||||
style.backgroundImage = new StyleBackground(GetCheckerTexture());
|
||||
style.backgroundColor = StyleKeyword.Null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetZoom(FrameAnimationPreviewZoomMode mode, float value)
|
||||
{
|
||||
zoomMode = mode;
|
||||
manualZoom = Mathf.Clamp(value, 0.25f, 8f);
|
||||
if (mode == FrameAnimationPreviewZoomMode.Fit)
|
||||
{
|
||||
pan = Vector2.zero;
|
||||
}
|
||||
UpdateImageLayout();
|
||||
}
|
||||
|
||||
private void UpdateImageLayout()
|
||||
{
|
||||
if (sprite == null || resolvedStyle.width <= 0f || resolvedStyle.height <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var sourceSize = sprite.rect.size;
|
||||
if (sourceSize.x <= 0f || sourceSize.y <= 0f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var scale = zoomMode == FrameAnimationPreviewZoomMode.Fit
|
||||
? Mathf.Min(resolvedStyle.width / sourceSize.x, resolvedStyle.height / sourceSize.y)
|
||||
: GetScale();
|
||||
var size = sourceSize * Mathf.Max(0.0001f, scale);
|
||||
image.style.width = size.x;
|
||||
image.style.height = size.y;
|
||||
image.style.left = (resolvedStyle.width - size.x) * 0.5f + pan.x;
|
||||
image.style.top = (resolvedStyle.height - size.y) * 0.5f + pan.y;
|
||||
}
|
||||
|
||||
private float GetScale()
|
||||
{
|
||||
return zoomMode switch
|
||||
{
|
||||
FrameAnimationPreviewZoomMode.One => 1f,
|
||||
FrameAnimationPreviewZoomMode.Two => 2f,
|
||||
FrameAnimationPreviewZoomMode.Three => 3f,
|
||||
FrameAnimationPreviewZoomMode.Four => 4f,
|
||||
FrameAnimationPreviewZoomMode.Eight => 8f,
|
||||
FrameAnimationPreviewZoomMode.Manual => manualZoom,
|
||||
_ => 1f
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPointerDown(PointerDownEvent evt)
|
||||
{
|
||||
if (zoomMode == FrameAnimationPreviewZoomMode.Fit || evt.button != 0) return;
|
||||
dragging = true;
|
||||
dragStart = new Vector2(evt.position.x, evt.position.y);
|
||||
panStart = pan;
|
||||
this.CapturePointer(evt.pointerId);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
private void OnPointerMove(PointerMoveEvent evt)
|
||||
{
|
||||
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
|
||||
var position = new Vector2(evt.position.x, evt.position.y);
|
||||
pan = panStart + position - dragStart;
|
||||
UpdateImageLayout();
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
private void OnPointerUp(PointerUpEvent evt)
|
||||
{
|
||||
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
|
||||
dragging = false;
|
||||
this.ReleasePointer(evt.pointerId);
|
||||
evt.StopPropagation();
|
||||
}
|
||||
|
||||
private static Texture2D GetCheckerTexture()
|
||||
{
|
||||
if (checkerTexture != null) return checkerTexture;
|
||||
const int textureSize = 128;
|
||||
const int cellSize = 8;
|
||||
checkerTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false)
|
||||
{
|
||||
name = "Frame Animation Preview Checker",
|
||||
hideFlags = HideFlags.HideAndDontSave,
|
||||
wrapMode = TextureWrapMode.Repeat,
|
||||
filterMode = FilterMode.Point
|
||||
};
|
||||
var light = new Color32(106, 106, 106, 255);
|
||||
var dark = new Color32(78, 78, 78, 255);
|
||||
var pixels = new Color32[textureSize * textureSize];
|
||||
for (var y = 0; y < textureSize; y++)
|
||||
for (var x = 0; x < textureSize; x++)
|
||||
{
|
||||
pixels[y * textureSize + x] = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
|
||||
}
|
||||
checkerTexture.SetPixels32(pixels);
|
||||
checkerTexture.Apply(false, true);
|
||||
return checkerTexture;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54aef216ceea42ed8917b581606ecf3d
|
||||
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationPreviewSampleBuilder
|
||||
{
|
||||
private const string Folder = "Assets/GameContent/Test/FrameAnimation/Preview";
|
||||
private const string GraphPath = Folder + "/PreviewSampleGraph.asset";
|
||||
private const string TexturePath = "Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Create Phase 5 Preview Sample")]
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
|
||||
{
|
||||
Debug.Log($"Frame Animation preview sample already exists; preserved without overwrite: {GraphPath}");
|
||||
return;
|
||||
}
|
||||
EnsureFolder(Folder);
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
|
||||
.OrderBy(sprite => sprite.name, StringComparer.Ordinal).ToArray();
|
||||
if (sprites.Length < 6)
|
||||
{
|
||||
throw new InvalidOperationException("第五阶段预览样例需要至少 6 个已切分 Sprite。");
|
||||
}
|
||||
|
||||
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
|
||||
graph.name = "PreviewSampleGraph";
|
||||
AssetDatabase.CreateAsset(graph, GraphPath);
|
||||
var action = CreateClip(graph, "PreviewAction", "Preview Action", FrameClipEndBehavior.HoldLastFrame,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[0], 100, sprites[0].name, 0),
|
||||
new FrameAnimationFrame(sprites[1], 160, sprites[1].name, 1),
|
||||
new FrameAnimationFrame(sprites[2], 220, sprites[2].name, 2)
|
||||
});
|
||||
var withEmpty = CreateClip(graph, "PreviewEmpty", "Preview Empty Frame", FrameClipEndBehavior.HoldLastFrame,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[3], 120, sprites[3].name, 3),
|
||||
new FrameAnimationFrame(null, 300, "empty", -1),
|
||||
new FrameAnimationFrame(sprites[4], 120, sprites[4].name, 4)
|
||||
});
|
||||
var idle = CreateClip(graph, "PreviewIdle", "Preview Idle Loop", FrameClipEndBehavior.Loop,
|
||||
new[]
|
||||
{
|
||||
new FrameAnimationFrame(sprites[4], 140, sprites[4].name, 4),
|
||||
new FrameAnimationFrame(sprites[5], 140, sprites[5].name, 5)
|
||||
});
|
||||
|
||||
var finiteA = new AnimationNode(action.Id, "Finite Action", speedOverride: 2f,
|
||||
internalId: "51000000000000000000000000000001");
|
||||
var finiteB = new AnimationNode(withEmpty.Id, "Finite Empty",
|
||||
endBehaviorOverride: FrameClipEndBehavior.HoldLastFrame,
|
||||
internalId: "51000000000000000000000000000002");
|
||||
var loopA = new AnimationNode(action.Id, "Loop Intro", internalId: "51000000000000000000000000000003");
|
||||
var loopB = new AnimationNode(idle.Id, "Loop Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Loop,
|
||||
internalId: "51000000000000000000000000000004");
|
||||
var blocked = new AnimationNode(withEmpty.Id, "Zero Speed Block", speedOverride: 0f,
|
||||
internalId: "51000000000000000000000000000005");
|
||||
var clear = new AnimationNode(action.Id, "Clear Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.Clear,
|
||||
internalId: "51000000000000000000000000000006");
|
||||
var hidden = new AnimationNode(idle.Id, "Hide Terminal",
|
||||
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
|
||||
internalId: "51000000000000000000000000000007");
|
||||
var finiteFlow = new AnimationFlow("PreviewFiniteFlow", "Finite Flow", finiteA.InternalId);
|
||||
var loopFlow = new AnimationFlow("PreviewLoopFlow", "Loop Flow", loopA.InternalId);
|
||||
var blockedFlow = new AnimationFlow("PreviewBlockedFlow", "Zero Speed Flow", blocked.InternalId);
|
||||
var clearFlow = new AnimationFlow("PreviewClearFlow", "Clear Flow", clear.InternalId);
|
||||
var hideFlow = new AnimationFlow("PreviewHideFlow", "Hide Flow", hidden.InternalId);
|
||||
graph.Configure(
|
||||
"PreviewSampleGraph",
|
||||
"Phase 5 Preview Sample",
|
||||
new[] { action, withEmpty, idle },
|
||||
new[] { finiteA, finiteB, loopA, loopB, blocked, clear, hidden },
|
||||
new[]
|
||||
{
|
||||
new AnimationEdge(finiteA.InternalId, finiteB.InternalId, "52000000000000000000000000000001"),
|
||||
new AnimationEdge(loopA.InternalId, loopB.InternalId, "52000000000000000000000000000002")
|
||||
},
|
||||
new[] { finiteFlow, loopFlow, blockedFlow, clearFlow, hideFlow },
|
||||
finiteFlow.Id);
|
||||
|
||||
var positions = new[]
|
||||
{
|
||||
(finiteA, new Vector2(80f, 80f)), (finiteB, new Vector2(420f, 80f)),
|
||||
(loopA, new Vector2(80f, 320f)), (loopB, new Vector2(420f, 320f)),
|
||||
(blocked, new Vector2(80f, 560f)), (clear, new Vector2(420f, 560f)),
|
||||
(hidden, new Vector2(760f, 560f))
|
||||
};
|
||||
foreach (var item in positions)
|
||||
{
|
||||
graph.EditorData.GetOrCreateNodeData(item.Item1.InternalId, item.Item2);
|
||||
}
|
||||
var colors = new[]
|
||||
{
|
||||
new Color(0.25f, 0.65f, 0.95f), new Color(0.55f, 0.4f, 0.9f),
|
||||
new Color(0.9f, 0.55f, 0.2f), new Color(0.3f, 0.75f, 0.5f), new Color(0.85f, 0.35f, 0.45f)
|
||||
};
|
||||
var flows = graph.Flows.ToArray();
|
||||
for (var index = 0; index < flows.Length; index++)
|
||||
{
|
||||
graph.EditorData.GetOrCreateFlowData(flows[index].Id, colors[index]);
|
||||
}
|
||||
EditorUtility.SetDirty(graph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"Frame Animation phase 5 preview sample created: {GraphPath}");
|
||||
}
|
||||
|
||||
private static FrameClip CreateClip(
|
||||
FrameAnimationGraph graph,
|
||||
string id,
|
||||
string displayName,
|
||||
FrameClipEndBehavior endBehavior,
|
||||
FrameAnimationFrame[] frames)
|
||||
{
|
||||
var clip = ScriptableObject.CreateInstance<FrameClip>();
|
||||
clip.name = id;
|
||||
clip.Configure(id, displayName, frames, 1f, endBehavior);
|
||||
AssetDatabase.AddObjectToAsset(clip, graph);
|
||||
return clip;
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, segments[index]);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f280e66f096a410eaf3eae8cd9c01134
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public static class FrameAnimationRuntimeSampleBuilder
|
||||
{
|
||||
private const string SourceTexturePath =
|
||||
"Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
|
||||
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation";
|
||||
private const string IntroClipPath = SampleFolder + "/Intro.asset";
|
||||
private const string IdleClipPath = SampleFolder + "/Idle.asset";
|
||||
private const string FlowGraphPath = SampleFolder + "/FlowSampleGraph.asset";
|
||||
private const string DirectGraphPath = SampleFolder + "/DirectSampleGraph.asset";
|
||||
private const string ScenePath = "Assets/Scenes/FrameAnimationRuntimeTest.unity";
|
||||
|
||||
[MenuItem("Tools/Frame Animation/Rebuild Runtime Sample")]
|
||||
private static void BuildFromMenu()
|
||||
{
|
||||
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildFromCommandLine();
|
||||
}
|
||||
|
||||
public static void BuildFromCommandLine()
|
||||
{
|
||||
EnsureFolder(SampleFolder);
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(SourceTexturePath)
|
||||
.OfType<Sprite>()
|
||||
.OrderBy(sprite => ExtractFrameNumber(sprite.name))
|
||||
.ToArray();
|
||||
if (sprites.Length < 21)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"样例源 Texture 需要至少 21 个 Sprite,当前只找到 {sprites.Length} 个。");
|
||||
}
|
||||
|
||||
var introClip = CreateOrLoadAsset<FrameClip>(IntroClipPath);
|
||||
introClip.Configure(
|
||||
"Intro",
|
||||
"Intro",
|
||||
CreateFrames(sprites, 0, 5),
|
||||
1f,
|
||||
FrameClipEndBehavior.HoldLastFrame);
|
||||
EditorUtility.SetDirty(introClip);
|
||||
|
||||
var idleClip = CreateOrLoadAsset<FrameClip>(IdleClipPath);
|
||||
idleClip.Configure(
|
||||
"Idle",
|
||||
"Idle",
|
||||
CreateFrames(sprites, 5, 16),
|
||||
1f,
|
||||
FrameClipEndBehavior.Loop);
|
||||
EditorUtility.SetDirty(idleClip);
|
||||
|
||||
var flowGraph = CreateOrLoadAsset<FrameAnimationGraph>(FlowGraphPath);
|
||||
var introNodeId = FindExistingNodeId(flowGraph, introClip.Id);
|
||||
var idleNodeId = FindExistingNodeId(flowGraph, idleClip.Id);
|
||||
var introNode = new AnimationNode(introClip.Id, "Intro", internalId: introNodeId);
|
||||
var idleNode = new AnimationNode(
|
||||
idleClip.Id,
|
||||
"Idle Loop",
|
||||
FrameClipEndBehavior.Loop,
|
||||
internalId: idleNodeId);
|
||||
var flow = new AnimationFlow("IntroToIdle", "Intro To Idle", introNode.InternalId);
|
||||
flowGraph.Configure(
|
||||
"FlowSampleGraph",
|
||||
"Flow Sample Graph",
|
||||
new[] { introClip, idleClip },
|
||||
new[] { introNode, idleNode },
|
||||
new[] { new AnimationEdge(introNode.InternalId, idleNode.InternalId) },
|
||||
new[] { flow },
|
||||
flow.Id);
|
||||
EditorUtility.SetDirty(flowGraph);
|
||||
|
||||
var directGraph = CreateOrLoadAsset<FrameAnimationGraph>(DirectGraphPath);
|
||||
directGraph.Configure(
|
||||
"DirectSampleGraph",
|
||||
"Direct Sample Graph",
|
||||
new[] { introClip, idleClip },
|
||||
Array.Empty<AnimationNode>(),
|
||||
Array.Empty<AnimationEdge>(),
|
||||
Array.Empty<AnimationFlow>(),
|
||||
idleClip.Id);
|
||||
EditorUtility.SetDirty(directGraph);
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
BuildScene(flowGraph, directGraph);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"Frame Animation runtime sample rebuilt: {ScenePath}");
|
||||
}
|
||||
|
||||
private static IEnumerable<FrameAnimationFrame> CreateFrames(
|
||||
IReadOnlyList<Sprite> sprites,
|
||||
int startIndex,
|
||||
int count)
|
||||
{
|
||||
for (var offset = 0; offset < count; offset++)
|
||||
{
|
||||
var sourceIndex = startIndex + offset;
|
||||
var sprite = sprites[sourceIndex];
|
||||
yield return new FrameAnimationFrame(sprite, 250, sprite.name, sourceIndex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildScene(
|
||||
FrameAnimationGraph flowGraph,
|
||||
FrameAnimationGraph directGraph)
|
||||
{
|
||||
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
|
||||
|
||||
var cameraObject = new GameObject("Main Camera", typeof(Camera));
|
||||
cameraObject.tag = "MainCamera";
|
||||
cameraObject.transform.position = new Vector3(0f, 0f, -10f);
|
||||
var camera = cameraObject.GetComponent<Camera>();
|
||||
camera.orthographic = true;
|
||||
camera.orthographicSize = 6f;
|
||||
camera.clearFlags = CameraClearFlags.SolidColor;
|
||||
camera.backgroundColor = new Color(0.08f, 0.09f, 0.12f, 1f);
|
||||
|
||||
var spriteObject = new GameObject("Flow Sample - SpriteRenderer", typeof(SpriteRenderer));
|
||||
spriteObject.transform.position = new Vector3(-3.5f, 0f, 0f);
|
||||
spriteObject.transform.localScale = Vector3.one * 0.45f;
|
||||
var spritePlayer = spriteObject.AddComponent<FrameAnimationPlayer>();
|
||||
spritePlayer.ConfigureForAuthoring(flowGraph, true, 1f);
|
||||
|
||||
var canvasObject = new GameObject(
|
||||
"Direct Clip Sample Canvas",
|
||||
typeof(Canvas),
|
||||
typeof(CanvasScaler),
|
||||
typeof(GraphicRaycaster));
|
||||
var canvas = canvasObject.GetComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
var scaler = canvasObject.GetComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1280f, 720f);
|
||||
|
||||
var imageObject = new GameObject(
|
||||
"Direct Idle - Image",
|
||||
typeof(RectTransform),
|
||||
typeof(CanvasRenderer),
|
||||
typeof(Image));
|
||||
imageObject.transform.SetParent(canvasObject.transform, false);
|
||||
var rectTransform = imageObject.GetComponent<RectTransform>();
|
||||
rectTransform.sizeDelta = new Vector2(384f, 216f);
|
||||
rectTransform.anchoredPosition = new Vector2(300f, 0f);
|
||||
var image = imageObject.GetComponent<Image>();
|
||||
image.preserveAspect = true;
|
||||
var imagePlayer = imageObject.AddComponent<FrameAnimationPlayer>();
|
||||
imagePlayer.ConfigureForAuthoring(directGraph, true, 1f);
|
||||
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
if (!EditorSceneManager.SaveScene(scene, ScenePath))
|
||||
{
|
||||
throw new InvalidOperationException($"无法保存样例场景:{ScenePath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static T CreateOrLoadAsset<T>(string path) where T : ScriptableObject
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
|
||||
if (asset != null)
|
||||
{
|
||||
return asset;
|
||||
}
|
||||
|
||||
asset = ScriptableObject.CreateInstance<T>();
|
||||
AssetDatabase.CreateAsset(asset, path);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private static string FindExistingNodeId(FrameAnimationGraph graph, string clipId)
|
||||
{
|
||||
var existing = graph.Nodes.FirstOrDefault(node => node != null && node.ClipId == clipId);
|
||||
return existing != null && FrameAnimationValueUtility.IsValidInternalId(existing.InternalId)
|
||||
? existing.InternalId
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int ExtractFrameNumber(string spriteName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(spriteName))
|
||||
{
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
var spaceIndex = spriteName.LastIndexOf(' ');
|
||||
var dotIndex = spriteName.LastIndexOf('.');
|
||||
if (spaceIndex >= 0 && dotIndex > spaceIndex &&
|
||||
int.TryParse(spriteName.Substring(spaceIndex + 1, dotIndex - spaceIndex - 1), out var number))
|
||||
{
|
||||
return number;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
private static void EnsureFolder(string path)
|
||||
{
|
||||
var segments = path.Split('/');
|
||||
var current = segments[0];
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var next = current + "/" + segments[index];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
{
|
||||
AssetDatabase.CreateFolder(current, segments[index]);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 679ccd2bc27e67c468f002c1282b6045
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,529 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.U2D.Sprites;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal readonly struct FrameAnimationSpriteSourceContext
|
||||
{
|
||||
public string SourceId { get; }
|
||||
public Texture2D Texture { get; }
|
||||
public TextAsset AsepriteJson { get; }
|
||||
public Vector2 Pivot { get; }
|
||||
public bool ManageSpriteSlicing { get; }
|
||||
|
||||
public FrameAnimationSpriteSourceContext(
|
||||
string sourceId,
|
||||
Texture2D texture,
|
||||
TextAsset asepriteJson,
|
||||
Vector2 pivot,
|
||||
bool manageSpriteSlicing)
|
||||
{
|
||||
SourceId = sourceId ?? string.Empty;
|
||||
Texture = texture;
|
||||
AsepriteJson = asepriteJson;
|
||||
Pivot = pivot;
|
||||
ManageSpriteSlicing = manageSpriteSlicing;
|
||||
}
|
||||
|
||||
public static FrameAnimationSpriteSourceContext From(FrameAnimationImportSource source)
|
||||
{
|
||||
return new FrameAnimationSpriteSourceContext(
|
||||
source?.InternalId, source?.Texture, source?.AsepriteJson,
|
||||
source?.Pivot ?? new Vector2(0.5f, 0.5f), source?.ManageSpriteSlicing == true);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationSpritePlan
|
||||
{
|
||||
public string TexturePath { get; }
|
||||
public IReadOnlyList<SpriteRect> ExistingRects { get; }
|
||||
public IReadOnlyList<SpriteRect> PlannedRects { get; }
|
||||
public IReadOnlyDictionary<int, string> SpriteNamesBySourceIndex { get; }
|
||||
public IReadOnlyDictionary<int, Sprite> ReadOnlySpritesBySourceIndex { get; }
|
||||
|
||||
public FrameAnimationSpritePlan(
|
||||
string texturePath,
|
||||
IReadOnlyList<SpriteRect> existingRects,
|
||||
IReadOnlyList<SpriteRect> plannedRects,
|
||||
IReadOnlyDictionary<int, string> spriteNamesBySourceIndex,
|
||||
IReadOnlyDictionary<int, Sprite> readOnlySpritesBySourceIndex)
|
||||
{
|
||||
TexturePath = texturePath ?? string.Empty;
|
||||
ExistingRects = existingRects ?? Array.Empty<SpriteRect>();
|
||||
PlannedRects = plannedRects ?? Array.Empty<SpriteRect>();
|
||||
SpriteNamesBySourceIndex = spriteNamesBySourceIndex ?? new Dictionary<int, string>();
|
||||
ReadOnlySpritesBySourceIndex = readOnlySpritesBySourceIndex ?? new Dictionary<int, Sprite>();
|
||||
}
|
||||
|
||||
public bool MatchesSprite(int sourceIndex, Sprite sprite)
|
||||
{
|
||||
if (sprite == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out var readOnlySprite))
|
||||
{
|
||||
return readOnlySprite == sprite;
|
||||
}
|
||||
return SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
|
||||
sprite.name == spriteName;
|
||||
}
|
||||
|
||||
public bool TryResolveSprite(
|
||||
int sourceIndex,
|
||||
IReadOnlyDictionary<string, Sprite> writableSprites,
|
||||
out Sprite sprite)
|
||||
{
|
||||
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out sprite))
|
||||
{
|
||||
return sprite != null;
|
||||
}
|
||||
if (SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
|
||||
writableSprites != null && writableSprites.TryGetValue(spriteName, out sprite))
|
||||
{
|
||||
return sprite != null;
|
||||
}
|
||||
sprite = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationTextureSnapshot
|
||||
{
|
||||
public string TexturePath { get; }
|
||||
public TextureImporterType TextureType { get; }
|
||||
public SpriteImportMode SpriteImportMode { get; }
|
||||
public IReadOnlyList<SpriteRect> SpriteRects { get; }
|
||||
|
||||
public FrameAnimationTextureSnapshot(
|
||||
string texturePath,
|
||||
TextureImporterType textureType,
|
||||
SpriteImportMode spriteImportMode,
|
||||
IReadOnlyList<SpriteRect> spriteRects)
|
||||
{
|
||||
TexturePath = texturePath;
|
||||
TextureType = textureType;
|
||||
SpriteImportMode = spriteImportMode;
|
||||
SpriteRects = spriteRects;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationSpriteUtility
|
||||
{
|
||||
public static FrameAnimationSpritePlan BuildPlan(
|
||||
FrameAnimationImportSource source,
|
||||
AsepriteSourceDocument document,
|
||||
FrameAnimationImportSourcePreview preview)
|
||||
{
|
||||
return BuildPlan(FrameAnimationSpriteSourceContext.From(source), document, preview);
|
||||
}
|
||||
|
||||
public static FrameAnimationSpritePlan BuildPlan(
|
||||
FrameAnimationSpriteSourceContext source,
|
||||
AsepriteSourceDocument document,
|
||||
IFrameAnimationImportPreviewSink preview,
|
||||
IReadOnlyCollection<int> includedSourceIndices = null)
|
||||
{
|
||||
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
|
||||
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
|
||||
if (importer == null)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureImporterInvalid,
|
||||
$"无法读取 TextureImporter:{texturePath}"));
|
||||
return new FrameAnimationSpritePlan(texturePath, null, null, null, null);
|
||||
}
|
||||
|
||||
var existingRects = source.ManageSpriteSlicing && importer.spriteImportMode != SpriteImportMode.Multiple
|
||||
? Array.Empty<SpriteRect>()
|
||||
: ReadSpriteRects(importer);
|
||||
return source.ManageSpriteSlicing
|
||||
? BuildWritablePlan(source, document, preview, texturePath, existingRects, includedSourceIndices)
|
||||
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects, includedSourceIndices);
|
||||
}
|
||||
|
||||
public static Rect ToUnityRect(RectInt asepriteRect, int textureHeight)
|
||||
{
|
||||
return new Rect(
|
||||
asepriteRect.x,
|
||||
textureHeight - asepriteRect.y - asepriteRect.height,
|
||||
asepriteRect.width,
|
||||
asepriteRect.height);
|
||||
}
|
||||
|
||||
public static FrameAnimationTextureSnapshot CaptureSnapshot(FrameAnimationSpritePlan plan)
|
||||
{
|
||||
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
|
||||
if (importer == null)
|
||||
{
|
||||
throw new InvalidOperationException($"无法读取 TextureImporter:{plan.TexturePath}");
|
||||
}
|
||||
|
||||
return new FrameAnimationTextureSnapshot(
|
||||
plan.TexturePath,
|
||||
importer.textureType,
|
||||
importer.spriteImportMode,
|
||||
CloneRects(ReadSpriteRects(importer)));
|
||||
}
|
||||
|
||||
public static void ApplyPlan(FrameAnimationSpritePlan plan)
|
||||
{
|
||||
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
|
||||
if (importer == null)
|
||||
{
|
||||
throw new InvalidOperationException($"无法读取 TextureImporter:{plan.TexturePath}");
|
||||
}
|
||||
|
||||
Undo.RecordObject(importer, "Refresh Frame Animation Sprite Slicing");
|
||||
importer.textureType = TextureImporterType.Sprite;
|
||||
importer.spriteImportMode = SpriteImportMode.Multiple;
|
||||
WriteSpriteRects(importer, plan.PlannedRects);
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
public static void RestoreSnapshot(FrameAnimationTextureSnapshot snapshot)
|
||||
{
|
||||
var importer = AssetImporter.GetAtPath(snapshot.TexturePath) as TextureImporter;
|
||||
if (importer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
importer.textureType = snapshot.TextureType;
|
||||
importer.spriteImportMode = snapshot.SpriteImportMode;
|
||||
WriteSpriteRects(importer, snapshot.SpriteRects);
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
public static IReadOnlyDictionary<string, Sprite> LoadSpritesByName(string texturePath)
|
||||
{
|
||||
return AssetDatabase.LoadAllAssetsAtPath(texturePath)
|
||||
.OfType<Sprite>()
|
||||
.GroupBy(sprite => sprite.name)
|
||||
.Where(group => group.Count() == 1)
|
||||
.ToDictionary(group => group.Key, group => group.Single());
|
||||
}
|
||||
|
||||
public static string ComputeSourceHash(
|
||||
FrameAnimationImportSource source,
|
||||
IReadOnlyList<SpriteRect> spriteRects = null)
|
||||
{
|
||||
return ComputeSourceHash(FrameAnimationSpriteSourceContext.From(source), spriteRects);
|
||||
}
|
||||
|
||||
public static string ComputeSourceHash(
|
||||
FrameAnimationSpriteSourceContext source,
|
||||
IReadOnlyList<SpriteRect> spriteRects = null)
|
||||
{
|
||||
if (source.Texture == null || source.AsepriteJson == null)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("FrameAnimationImportHash:v2");
|
||||
builder.AppendLine(source.AsepriteJson.text ?? string.Empty);
|
||||
builder.AppendLine(AssetDatabase.AssetPathToGUID(texturePath));
|
||||
builder.AppendLine(AssetDatabase.GetAssetDependencyHash(texturePath).ToString());
|
||||
builder.AppendLine(source.Pivot.x.ToString("R", CultureInfo.InvariantCulture));
|
||||
builder.AppendLine(source.Pivot.y.ToString("R", CultureInfo.InvariantCulture));
|
||||
builder.AppendLine(source.ManageSpriteSlicing ? "write" : "read");
|
||||
|
||||
var rects = spriteRects;
|
||||
if (rects == null && AssetImporter.GetAtPath(texturePath) is TextureImporter importer)
|
||||
{
|
||||
rects = ReadSpriteRects(importer);
|
||||
}
|
||||
|
||||
foreach (var rect in (rects ?? Array.Empty<SpriteRect>()).OrderBy(item => item.name))
|
||||
{
|
||||
builder.Append(rect.name).Append('|')
|
||||
.Append(rect.spriteID).Append('|')
|
||||
.Append(rect.rect.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(rect.rect.y.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(rect.rect.width.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(rect.rect.height.ToString("R", CultureInfo.InvariantCulture)).Append('|')
|
||||
.Append(rect.pivot.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
|
||||
.Append(rect.pivot.y.ToString("R", CultureInfo.InvariantCulture)).AppendLine();
|
||||
}
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static FrameAnimationSpritePlan BuildWritablePlan(
|
||||
FrameAnimationSpriteSourceContext source,
|
||||
AsepriteSourceDocument document,
|
||||
IFrameAnimationImportPreviewSink preview,
|
||||
string texturePath,
|
||||
IReadOnlyList<SpriteRect> existingRects,
|
||||
IReadOnlyCollection<int> includedSourceIndices)
|
||||
{
|
||||
var planned = CloneRects(existingRects).ToList();
|
||||
var originalRects = planned.ToArray();
|
||||
var assigned = new HashSet<SpriteRect>();
|
||||
var spriteNamesBySourceIndex = new Dictionary<int, string>();
|
||||
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
|
||||
{
|
||||
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
|
||||
SpriteRect matched = null;
|
||||
var slotHasError = false;
|
||||
foreach (var aliasName in slot.FrameNames)
|
||||
{
|
||||
var nameMatches = planned.Where(rect => rect.name == aliasName).ToArray();
|
||||
if (nameMatches.Length > 1)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||||
$"Texture 中存在多个名为 '{aliasName}' 的 SpriteRect。"));
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Error,
|
||||
slot.CanonicalFrameName,
|
||||
unityRect,
|
||||
"SpriteRect 名称重复",
|
||||
slot.FrameNames));
|
||||
slotHasError = true;
|
||||
break;
|
||||
}
|
||||
if (nameMatches.Length == 1 && !assigned.Contains(nameMatches[0]))
|
||||
{
|
||||
matched = nameMatches[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (slotHasError)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matched == null)
|
||||
{
|
||||
var rectMatches = planned.Where(rect => !assigned.Contains(rect) && RectEquals(rect.rect, unityRect))
|
||||
.ToArray();
|
||||
if (rectMatches.Length > 1)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||||
$"共享帧 '{slot.CanonicalFrameName}' 的 rect 对应多个现有 SpriteRect。"));
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Error,
|
||||
slot.CanonicalFrameName,
|
||||
unityRect,
|
||||
"SpriteRect rect 匹配不唯一",
|
||||
slot.FrameNames));
|
||||
continue;
|
||||
}
|
||||
matched = rectMatches.SingleOrDefault();
|
||||
}
|
||||
|
||||
if (matched == null)
|
||||
{
|
||||
matched = new SpriteRect
|
||||
{
|
||||
name = slot.CanonicalFrameName,
|
||||
rect = unityRect,
|
||||
pivot = source.Pivot,
|
||||
alignment = SpriteAlignment.Custom,
|
||||
spriteID = GUID.Generate()
|
||||
};
|
||||
planned.Add(matched);
|
||||
assigned.Add(matched);
|
||||
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Added,
|
||||
matched.name,
|
||||
unityRect,
|
||||
BuildSlotSummary("新增 SpriteRect", slot),
|
||||
slot.FrameNames));
|
||||
continue;
|
||||
}
|
||||
|
||||
var changed = !RectEquals(matched.rect, unityRect) || matched.pivot != source.Pivot ||
|
||||
matched.alignment != SpriteAlignment.Custom;
|
||||
matched.rect = unityRect;
|
||||
matched.pivot = source.Pivot;
|
||||
matched.alignment = SpriteAlignment.Custom;
|
||||
assigned.Add(matched);
|
||||
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
changed ? FrameAnimationSpriteChangeKind.Updated : FrameAnimationSpriteChangeKind.Unchanged,
|
||||
matched.name,
|
||||
unityRect,
|
||||
BuildSlotSummary(changed ? "更新 rect/pivot,保留 spriteID" : "无变化", slot),
|
||||
slot.FrameNames));
|
||||
}
|
||||
|
||||
foreach (var retained in originalRects.Where(rect => !assigned.Contains(rect)))
|
||||
{
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Retained,
|
||||
retained.name,
|
||||
retained.rect,
|
||||
"源中已消失或不再作为共享帧主 Sprite,保留现有 SpriteRect"));
|
||||
}
|
||||
|
||||
return new FrameAnimationSpritePlan(
|
||||
texturePath,
|
||||
existingRects,
|
||||
planned,
|
||||
spriteNamesBySourceIndex,
|
||||
null);
|
||||
}
|
||||
|
||||
private static FrameAnimationSpritePlan BuildReadOnlyPlan(
|
||||
FrameAnimationSpriteSourceContext source,
|
||||
AsepriteSourceDocument document,
|
||||
IFrameAnimationImportPreviewSink preview,
|
||||
string texturePath,
|
||||
IReadOnlyList<SpriteRect> existingRects,
|
||||
IReadOnlyCollection<int> includedSourceIndices)
|
||||
{
|
||||
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
|
||||
var matches = new Dictionary<int, Sprite>();
|
||||
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
|
||||
{
|
||||
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
|
||||
var rectMatches = sprites.Where(sprite => RectEquals(sprite.rect, unityRect)).ToArray();
|
||||
if (rectMatches.Length != 1)
|
||||
{
|
||||
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
|
||||
$"共享帧 '{slot.CanonicalFrameName}' 无法按 rect 唯一匹配 Sprite,候选数:{rectMatches.Length}。"));
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Error,
|
||||
slot.CanonicalFrameName,
|
||||
unityRect,
|
||||
"只读 Sprite 匹配失败",
|
||||
slot.FrameNames));
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var sourceIndex in slot.SourceIndices)
|
||||
{
|
||||
matches[sourceIndex] = rectMatches[0];
|
||||
}
|
||||
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
|
||||
FrameAnimationSpriteChangeKind.Unchanged,
|
||||
rectMatches[0].name,
|
||||
unityRect,
|
||||
BuildSlotSummary($"匹配 Sprite '{rectMatches[0].name}'", slot),
|
||||
slot.FrameNames));
|
||||
}
|
||||
|
||||
return new FrameAnimationSpritePlan(texturePath, existingRects, existingRects, null, matches);
|
||||
}
|
||||
|
||||
private static void MapSlot(
|
||||
AsepriteSpriteSlot slot,
|
||||
string spriteName,
|
||||
IDictionary<int, string> spriteNamesBySourceIndex)
|
||||
{
|
||||
foreach (var sourceIndex in slot.SourceIndices)
|
||||
{
|
||||
spriteNamesBySourceIndex[sourceIndex] = spriteName;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<AsepriteSpriteSlot> EnumerateSlots(
|
||||
AsepriteSourceDocument document,
|
||||
IReadOnlyCollection<int> includedSourceIndices)
|
||||
{
|
||||
if (includedSourceIndices == null)
|
||||
{
|
||||
return document.SpriteSlots;
|
||||
}
|
||||
|
||||
var included = new HashSet<int>(includedSourceIndices);
|
||||
return document.SpriteSlots.Select(slot =>
|
||||
{
|
||||
var indices = new List<int>();
|
||||
var names = new List<string>();
|
||||
for (var index = 0; index < slot.SourceIndices.Count; index++)
|
||||
{
|
||||
if (!included.Contains(slot.SourceIndices[index])) continue;
|
||||
indices.Add(slot.SourceIndices[index]);
|
||||
names.Add(slot.FrameNames[index]);
|
||||
}
|
||||
return indices.Count == 0
|
||||
? null
|
||||
: new AsepriteSpriteSlot(slot.Rect, names[0], indices, names);
|
||||
}).Where(slot => slot != null);
|
||||
}
|
||||
|
||||
private static string BuildSlotSummary(string prefix, AsepriteSpriteSlot slot)
|
||||
{
|
||||
return slot.SourceIndices.Count > 1
|
||||
? $"{prefix};{slot.SourceIndices.Count} 个 SourceFrame 共享"
|
||||
: prefix;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SpriteRect> ReadSpriteRects(TextureImporter importer)
|
||||
{
|
||||
var factories = new SpriteDataProviderFactories();
|
||||
factories.Init();
|
||||
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
|
||||
if (provider == null)
|
||||
{
|
||||
return Array.Empty<SpriteRect>();
|
||||
}
|
||||
|
||||
provider.InitSpriteEditorDataProvider();
|
||||
return CloneRects(provider.GetSpriteRects());
|
||||
}
|
||||
|
||||
private static void WriteSpriteRects(TextureImporter importer, IReadOnlyList<SpriteRect> rects)
|
||||
{
|
||||
var factories = new SpriteDataProviderFactories();
|
||||
factories.Init();
|
||||
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
|
||||
if (provider == null)
|
||||
{
|
||||
throw new InvalidOperationException($"无法获取 Sprite Data Provider:{importer.assetPath}");
|
||||
}
|
||||
|
||||
provider.InitSpriteEditorDataProvider();
|
||||
provider.SetSpriteRects(CloneRects(rects).ToArray());
|
||||
provider.Apply();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SpriteRect> CloneRects(IEnumerable<SpriteRect> rects)
|
||||
{
|
||||
return (rects ?? Array.Empty<SpriteRect>()).Select(rect => new SpriteRect
|
||||
{
|
||||
name = rect.name,
|
||||
rect = rect.rect,
|
||||
pivot = rect.pivot,
|
||||
alignment = rect.alignment,
|
||||
border = rect.border,
|
||||
spriteID = rect.spriteID
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static bool RectEquals(Rect left, Rect right)
|
||||
{
|
||||
return Mathf.Approximately(left.x, right.x) &&
|
||||
Mathf.Approximately(left.y, right.y) &&
|
||||
Mathf.Approximately(left.width, right.width) &&
|
||||
Mathf.Approximately(left.height, right.height);
|
||||
}
|
||||
|
||||
private static FrameAnimationImportIssue Error(
|
||||
FrameAnimationSpriteSourceContext source,
|
||||
FrameAnimationImportIssueCode code,
|
||||
string message)
|
||||
{
|
||||
return new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
code,
|
||||
source.SourceId,
|
||||
source.SourceId,
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 15c72a516e10f7f4dbfdbce9489a935b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,71 @@
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
[CustomEditor(typeof(FrameClip))]
|
||||
public sealed class FrameClipEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
var clip = (FrameClip)target;
|
||||
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
|
||||
var kind = clip.IsImported
|
||||
? "Graph Imported FrameClip"
|
||||
: clip.HasStandaloneImportSource ? "Source-linked External FrameClip" : "Manual FrameClip";
|
||||
EditorGUILayout.LabelField(kind, EditorStyles.boldLabel);
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.TextField("ID", clip.Id);
|
||||
EditorGUILayout.TextField("Display Name", clip.DisplayName);
|
||||
EditorGUILayout.FloatField("Speed", clip.Speed);
|
||||
EditorGUILayout.EnumPopup("Default End", clip.DefaultEndBehavior);
|
||||
EditorGUILayout.IntField("Frames", clip.FrameCount);
|
||||
EditorGUILayout.LongField("Total Duration Ms", clip.TotalDurationMs);
|
||||
EditorGUILayout.TextField("Storage", AssetDatabase.IsSubAsset(clip) ? "Graph sub-asset" : "External .asset");
|
||||
EditorGUILayout.TextField("Source Tag", clip.ImportInfo?.SourceTagName ?? string.Empty);
|
||||
EditorGUILayout.Toggle("Missing", clip.ImportInfo?.IsMissingFromSource ?? false);
|
||||
EditorGUILayout.ObjectField("Standalone Texture", clip.StandaloneImportSource?.Texture, typeof(Texture2D), false);
|
||||
EditorGUILayout.ObjectField("Standalone JSON", clip.StandaloneImportSource?.AsepriteJson, typeof(TextAsset), false);
|
||||
}
|
||||
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"该外部 Manual Clip 被多个 Graph 共享:" + string.Join(", ", owners.Select(graph => graph.name)),
|
||||
MessageType.Warning);
|
||||
}
|
||||
EditorGUILayout.HelpBox(
|
||||
AssetDatabase.IsSubAsset(clip)
|
||||
? "Graph sub-asset 的 Clip 数据请在 Frame Animation Graph Editor 中编辑。"
|
||||
: "双击资产或使用下方按钮,在独立 Frame Clip Editor 中编辑。",
|
||||
MessageType.Info);
|
||||
if (!AssetDatabase.IsSubAsset(clip) && GUILayout.Button("Open Frame Clip Editor"))
|
||||
{
|
||||
FrameClipEditorWindow.Open(clip);
|
||||
}
|
||||
if (owners.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("当前没有 FrameAnimationGraph 引用该 Clip。", MessageType.Info);
|
||||
}
|
||||
else if (owners.Count == 1)
|
||||
{
|
||||
if (GUILayout.Button("Open Referencing Graph"))
|
||||
{
|
||||
FrameAnimationGraphEditorWindow.Open(owners[0]);
|
||||
}
|
||||
}
|
||||
else if (GUILayout.Button("Choose Referencing Graph"))
|
||||
{
|
||||
var menu = new GenericMenu();
|
||||
foreach (var owner in owners)
|
||||
{
|
||||
var captured = owner;
|
||||
menu.AddItem(new GUIContent(owner.name), false,
|
||||
() => FrameAnimationGraphEditorWindow.Open(captured));
|
||||
}
|
||||
menu.ShowAsContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 842e01eb4f6891e49b3749059adaf6c9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,360 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
public sealed class FrameClipEditorWindow : EditorWindow
|
||||
{
|
||||
private FrameClip clip;
|
||||
private SerializedObject clipSerializedObject;
|
||||
private ReorderableList frameList;
|
||||
private Vector2 scroll;
|
||||
private readonly FrameAnimationPreviewCoordinator preview = new FrameAnimationPreviewCoordinator();
|
||||
private FrameClipImportPreview importPreview;
|
||||
private double lastUpdateTime;
|
||||
|
||||
[MenuItem("Window/AibisDream/Frame Animation/Frame Clip Editor")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
|
||||
}
|
||||
|
||||
public static void Open(FrameClip value)
|
||||
{
|
||||
var window = GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
|
||||
window.SetClip(value);
|
||||
window.Show();
|
||||
window.Focus();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
Undo.undoRedoPerformed += OnUndoRedo;
|
||||
lastUpdateTime = EditorApplication.timeSinceStartup;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnEditorUpdate;
|
||||
Undo.undoRedoPerformed -= OnUndoRedo;
|
||||
}
|
||||
|
||||
private void OnUndoRedo()
|
||||
{
|
||||
RebuildSerializedState();
|
||||
preview.RefreshTarget(false);
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
var now = EditorApplication.timeSinceStartup;
|
||||
var delta = Math.Max(0d, now - lastUpdateTime);
|
||||
lastUpdateTime = now;
|
||||
if (preview.Tick(delta, null)) Repaint();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
var next = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
|
||||
if (next != clip) SetClip(next);
|
||||
if (clip == null)
|
||||
{
|
||||
EditorGUILayout.HelpBox("选择一个外部 FrameClip .asset 开始编辑。", MessageType.Info);
|
||||
return;
|
||||
}
|
||||
if (AssetDatabase.IsSubAsset(clip))
|
||||
{
|
||||
EditorGUILayout.HelpBox("Graph sub-asset Clip 请在 Frame Animation Graph Editor 中编辑。", MessageType.Info);
|
||||
if (GUILayout.Button("Open Referencing Graph"))
|
||||
{
|
||||
var owner = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip).FirstOrDefault();
|
||||
if (owner != null) FrameAnimationGraphEditorWindow.Open(owner);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
DrawPreview();
|
||||
scroll = EditorGUILayout.BeginScrollView(scroll);
|
||||
DrawClipProperties();
|
||||
EditorGUILayout.Space();
|
||||
DrawSourceProperties();
|
||||
EditorGUILayout.Space();
|
||||
frameList?.DoLayoutList();
|
||||
if (clipSerializedObject != null && clipSerializedObject.ApplyModifiedProperties())
|
||||
{
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
}
|
||||
if (!clip.IsImported && !clip.HasStandaloneImportSource &&
|
||||
frameList != null && frameList.index >= 0 && GUILayout.Button("Duplicate Selected Frame"))
|
||||
{
|
||||
var frames = clipSerializedObject.FindProperty("frames");
|
||||
frames.InsertArrayElementAtIndex(frameList.index);
|
||||
clipSerializedObject.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private void DrawPreview()
|
||||
{
|
||||
var rect = GUILayoutUtility.GetRect(100f, 260f, GUILayout.ExpandWidth(true));
|
||||
EditorGUI.DrawRect(rect, new Color(0.12f, 0.12f, 0.12f));
|
||||
var sprite = preview.CurrentSprite;
|
||||
if (sprite != null && sprite.texture != null)
|
||||
{
|
||||
var uv = new Rect(
|
||||
sprite.textureRect.x / sprite.texture.width,
|
||||
sprite.textureRect.y / sprite.texture.height,
|
||||
sprite.textureRect.width / sprite.texture.width,
|
||||
sprite.textureRect.height / sprite.texture.height);
|
||||
var scale = Mathf.Min(rect.width / sprite.rect.width, rect.height / sprite.rect.height);
|
||||
var size = sprite.rect.size * scale;
|
||||
var destination = new Rect(rect.center - size * 0.5f, size);
|
||||
GUI.DrawTextureWithTexCoords(destination, sprite.texture, uv, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
GUI.Label(rect, preview.Error.Code == FrameAnimationPlaybackErrorCode.None
|
||||
? "Empty Frame" : preview.Error.Message, EditorStyles.centeredGreyMiniLabel);
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button(preview.State == FrameAnimationPreviewState.Playing ? "Pause" : "Play"))
|
||||
{
|
||||
if (preview.State == FrameAnimationPreviewState.Playing) preview.Pause();
|
||||
else preview.Play();
|
||||
}
|
||||
if (GUILayout.Button("Stop")) preview.Stop();
|
||||
if (GUILayout.Button("<")) preview.Step(-1);
|
||||
if (GUILayout.Button(">")) preview.Step(1);
|
||||
}
|
||||
var duration = preview.Timeline?.DisplayDurationSeconds ?? 0d;
|
||||
var position = EditorGUILayout.Slider("Time", (float)preview.PositionSeconds, 0f,
|
||||
Mathf.Max(0.0001f, (float)duration));
|
||||
if (!Mathf.Approximately(position, (float)preview.PositionSeconds)) preview.Seek(position);
|
||||
}
|
||||
|
||||
private void DrawClipProperties()
|
||||
{
|
||||
clipSerializedObject.Update();
|
||||
EditorGUILayout.LabelField(clip.HasStandaloneImportSource ? "Source-linked External Clip" : "Manual External Clip",
|
||||
EditorStyles.boldLabel);
|
||||
var idProperty = clipSerializedObject.FindProperty("id");
|
||||
var nextId = EditorGUILayout.DelayedTextField("ID", idProperty.stringValue);
|
||||
if (nextId != idProperty.stringValue) RenameClip(nextId);
|
||||
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("displayName"));
|
||||
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("speed"));
|
||||
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("defaultEndBehavior"));
|
||||
EditorGUILayout.LabelField("Frame Count", clip.FrameCount.ToString());
|
||||
EditorGUILayout.LabelField("Total Duration", clip.TotalDurationMs + " ms");
|
||||
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
|
||||
EditorGUILayout.LabelField("Referencing Graphs", owners.Count.ToString());
|
||||
if (owners.Count > 1)
|
||||
EditorGUILayout.HelpBox("该 Clip 被多个 Graph 共享,ID 重命名被锁定。", MessageType.Warning);
|
||||
if (clipSerializedObject.ApplyModifiedProperties())
|
||||
{
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
importPreview = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSourceProperties()
|
||||
{
|
||||
EditorGUILayout.LabelField("Standalone Import Source", EditorStyles.boldLabel);
|
||||
if (!clip.HasStandaloneImportSource)
|
||||
{
|
||||
if (GUILayout.Button("Enable Texture + JSON Source"))
|
||||
{
|
||||
if (!FrameClipImportService.EnableSource(clip, out var error))
|
||||
EditorUtility.DisplayDialog("无法启用来源", error, "确定");
|
||||
RebuildSerializedState();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clipSerializedObject.Update();
|
||||
var source = clipSerializedObject.FindProperty("standaloneImportSource");
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("texture"));
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("asepriteJson"));
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("pivot"));
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("manageSpriteSlicing"));
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastImportedTagName"));
|
||||
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastSourceHash"));
|
||||
}
|
||||
if (clipSerializedObject.ApplyModifiedProperties())
|
||||
{
|
||||
EditorUtility.SetDirty(clip);
|
||||
importPreview = null;
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Preview Import")) importPreview = FrameClipImportService.Preview(clip);
|
||||
if (GUILayout.Button("Refresh"))
|
||||
{
|
||||
importPreview = FrameClipImportService.Preview(clip);
|
||||
ApplyImportPreview();
|
||||
}
|
||||
if (GUILayout.Button("Detach Source"))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog("断开来源", "保留当前帧并转为手工 Clip?", "断开", "取消"))
|
||||
{
|
||||
FrameClipImportService.DetachSource(clip);
|
||||
importPreview = null;
|
||||
RebuildSerializedState();
|
||||
}
|
||||
}
|
||||
}
|
||||
DrawImportPreview();
|
||||
}
|
||||
|
||||
private void DrawImportPreview()
|
||||
{
|
||||
if (importPreview == null) return;
|
||||
EditorGUILayout.LabelField("Import Preview", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField("Selected Tag",
|
||||
string.IsNullOrEmpty(importPreview.SourceTagName) ? "<all frames>" : importPreview.SourceTagName);
|
||||
EditorGUILayout.LabelField("Selected Frames", importPreview.SourceIndices.Count.ToString());
|
||||
EditorGUILayout.LabelField("Frame Changes", importPreview.HasFrameChanges ? "Yes" : "No");
|
||||
EditorGUILayout.LabelField("Sprite Changes", importPreview.HasSpriteChanges ? "Yes" : "No");
|
||||
foreach (var issue in importPreview.Issues)
|
||||
{
|
||||
var type = issue.Severity == FrameAnimationValidationSeverity.Error
|
||||
? MessageType.Error : MessageType.Warning;
|
||||
EditorGUILayout.HelpBox($"{issue.Code}: {issue.Message}", type);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyImportPreview()
|
||||
{
|
||||
if (importPreview == null || importPreview.HasErrors) return;
|
||||
var allow = !importPreview.HasSpriteChanges || EditorUtility.DisplayDialog(
|
||||
"确认修改 TextureImporter",
|
||||
"独立 Clip 刷新需要更新 Sprite 切图。是否继续?",
|
||||
"应用切图并刷新",
|
||||
"取消");
|
||||
if (!allow) return;
|
||||
if (!FrameClipImportService.Apply(importPreview, true, out var error))
|
||||
{
|
||||
EditorUtility.DisplayDialog("刷新失败", error, "确定");
|
||||
return;
|
||||
}
|
||||
importPreview = FrameClipImportService.Preview(clip);
|
||||
RebuildSerializedState();
|
||||
preview.RefreshTarget(false);
|
||||
}
|
||||
|
||||
private void RenameClip(string newId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newId))
|
||||
{
|
||||
EditorUtility.DisplayDialog("无法重命名", "Clip ID 不能为空。", "确定");
|
||||
return;
|
||||
}
|
||||
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
|
||||
if (owners.Count > 1)
|
||||
{
|
||||
EditorUtility.DisplayDialog("无法重命名", "共享外部 Clip 被多个 Graph 引用。", "确定");
|
||||
return;
|
||||
}
|
||||
if (owners.Count == 1)
|
||||
{
|
||||
if (!FrameAnimationAssetOperations.RenameClip(owners[0], clip, newId, out var error))
|
||||
EditorUtility.DisplayDialog("无法重命名", error, "确定");
|
||||
return;
|
||||
}
|
||||
Undo.RecordObject(clip, "Rename Standalone Frame Clip");
|
||||
clip.SetId(newId);
|
||||
EditorUtility.SetDirty(clip);
|
||||
}
|
||||
|
||||
private void SetClip(FrameClip value)
|
||||
{
|
||||
clip = value;
|
||||
importPreview = null;
|
||||
RebuildSerializedState();
|
||||
preview.SetGraph(null);
|
||||
if (clip != null) preview.SetTarget(FrameAnimationPreviewTargetKind.Clip, clip);
|
||||
else preview.ClearTarget();
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void RebuildSerializedState()
|
||||
{
|
||||
clipSerializedObject = clip != null ? new SerializedObject(clip) : null;
|
||||
frameList = clip != null ? BuildFrameList() : null;
|
||||
}
|
||||
|
||||
private ReorderableList BuildFrameList()
|
||||
{
|
||||
var frames = clipSerializedObject.FindProperty("frames");
|
||||
var editable = !clip.IsImported && !clip.HasStandaloneImportSource;
|
||||
var list = new ReorderableList(clipSerializedObject, frames, editable, true, editable, editable)
|
||||
{
|
||||
elementHeight = 72f,
|
||||
drawHeaderCallback = rect => EditorGUI.LabelField(rect,
|
||||
editable ? "Frames" : "Frames (source-linked, read-only)"),
|
||||
drawElementCallback = (rect, index, active, focused) => DrawFrame(frames, rect, index),
|
||||
onAddCallback = value =>
|
||||
{
|
||||
var index = frames.arraySize;
|
||||
frames.InsertArrayElementAtIndex(index);
|
||||
var element = frames.GetArrayElementAtIndex(index);
|
||||
element.FindPropertyRelative("sprite").objectReferenceValue = null;
|
||||
element.FindPropertyRelative("durationMs").intValue = 100;
|
||||
element.FindPropertyRelative("frameName").stringValue = string.Empty;
|
||||
element.FindPropertyRelative("sourceIndex").intValue = -1;
|
||||
clipSerializedObject.ApplyModifiedProperties();
|
||||
value.index = index;
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
},
|
||||
onRemoveCallback = value =>
|
||||
{
|
||||
ReorderableList.defaultBehaviours.DoRemoveButton(value);
|
||||
clipSerializedObject.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
},
|
||||
onReorderCallback = _ =>
|
||||
{
|
||||
clipSerializedObject.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(clip);
|
||||
preview.RefreshTarget(false);
|
||||
}
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
private static void DrawFrame(SerializedProperty frames, Rect rect, int index)
|
||||
{
|
||||
if (index < 0 || index >= frames.arraySize) return;
|
||||
var element = frames.GetArrayElementAtIndex(index);
|
||||
rect.y += 2f;
|
||||
var line = EditorGUIUtility.singleLineHeight;
|
||||
EditorGUI.PropertyField(new Rect(rect.x, rect.y, rect.width, line),
|
||||
element.FindPropertyRelative("sprite"), new GUIContent($"#{index} Sprite"));
|
||||
EditorGUI.PropertyField(new Rect(rect.x, rect.y + line + 2f, rect.width, line),
|
||||
element.FindPropertyRelative("durationMs"));
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
var half = (rect.width - 4f) * 0.5f;
|
||||
EditorGUI.PropertyField(new Rect(rect.x, rect.y + (line + 2f) * 2f, half, line),
|
||||
element.FindPropertyRelative("frameName"));
|
||||
EditorGUI.PropertyField(new Rect(rect.x + half + 4f, rect.y + (line + 2f) * 2f, half, line),
|
||||
element.FindPropertyRelative("sourceIndex"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5673c406c6069e241b0fad51881213fe
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,333 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal sealed class FrameClipImportPreview : IFrameAnimationImportPreviewSink
|
||||
{
|
||||
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
|
||||
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
|
||||
|
||||
public FrameClip Clip { get; }
|
||||
public AsepriteSourceDocument Document { get; internal set; }
|
||||
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
|
||||
public IReadOnlyList<int> SourceIndices { get; internal set; } = Array.Empty<int>();
|
||||
public string SourceTagName { get; internal set; } = string.Empty;
|
||||
public string CurrentHash { get; internal set; } = string.Empty;
|
||||
public bool HasFrameChanges { get; internal set; }
|
||||
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
|
||||
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
|
||||
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
|
||||
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
|
||||
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
|
||||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
|
||||
|
||||
public FrameClipImportPreview(FrameClip clip)
|
||||
{
|
||||
Clip = clip;
|
||||
}
|
||||
|
||||
public void AddIssue(FrameAnimationImportIssue issue)
|
||||
{
|
||||
if (issue != null) issues.Add(issue);
|
||||
}
|
||||
|
||||
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
|
||||
{
|
||||
if (diff != null) spriteDiffs.Add(diff);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameClipImportService
|
||||
{
|
||||
public static bool EnableSource(FrameClip clip, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (!IsExternalClip(clip))
|
||||
{
|
||||
error = "独立来源只能添加到外部 .asset FrameClip。";
|
||||
return false;
|
||||
}
|
||||
if (clip.IsImported)
|
||||
{
|
||||
error = "Graph Imported Clip 必须继续由所属 Graph 的 ImportSource 管理。";
|
||||
return false;
|
||||
}
|
||||
if (clip.HasStandaloneImportSource) return true;
|
||||
Undo.RecordObject(clip, "Enable Standalone Frame Clip Source");
|
||||
clip.GetOrCreateStandaloneImportSource();
|
||||
EditorUtility.SetDirty(clip);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void DetachSource(FrameClip clip)
|
||||
{
|
||||
if (clip == null || !clip.HasStandaloneImportSource) return;
|
||||
Undo.RecordObject(clip, "Detach Standalone Frame Clip Source");
|
||||
clip.ClearStandaloneImportSource();
|
||||
EditorUtility.SetDirty(clip);
|
||||
}
|
||||
|
||||
public static FrameClipImportPreview Preview(FrameClip clip)
|
||||
{
|
||||
var preview = new FrameClipImportPreview(clip);
|
||||
if (!IsExternalClip(clip))
|
||||
{
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid,
|
||||
"独立来源只能用于外部 .asset FrameClip。"));
|
||||
return preview;
|
||||
}
|
||||
var source = clip.StandaloneImportSource;
|
||||
if (source == null)
|
||||
{
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
|
||||
"FrameClip 尚未启用独立来源。"));
|
||||
return preview;
|
||||
}
|
||||
if (source.Texture == null)
|
||||
{
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureMissing, "独立来源缺少 Texture。"));
|
||||
return preview;
|
||||
}
|
||||
if (source.AsepriteJson == null)
|
||||
{
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.JsonMissing, "独立来源缺少 Aseprite JSON。"));
|
||||
return preview;
|
||||
}
|
||||
|
||||
var sourceId = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip));
|
||||
if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, sourceId, out var document, out var parseIssue))
|
||||
{
|
||||
preview.AddIssue(parseIssue);
|
||||
return preview;
|
||||
}
|
||||
preview.Document = document;
|
||||
ValidateFrames(clip, source, document, preview);
|
||||
if (preview.HasErrors) return preview;
|
||||
|
||||
if (document.Tags.Count > 0)
|
||||
{
|
||||
var first = document.Tags[0];
|
||||
preview.SourceTagName = first.Name;
|
||||
if (string.IsNullOrWhiteSpace(first.Name))
|
||||
{
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TagNameInvalid,
|
||||
"第一个 Tag 的名称不能为空。"));
|
||||
return preview;
|
||||
}
|
||||
if (!AsepriteJsonParser.TryExpandTag(first, document.Frames.Count, sourceId,
|
||||
out var indices, out var tagIssue))
|
||||
{
|
||||
preview.AddIssue(tagIssue);
|
||||
return preview;
|
||||
}
|
||||
preview.SourceIndices = indices;
|
||||
}
|
||||
else
|
||||
{
|
||||
preview.SourceIndices = Enumerable.Range(0, document.Frames.Count).ToArray();
|
||||
}
|
||||
|
||||
ValidateTextureOwnership(clip, source, preview);
|
||||
if (preview.HasErrors) return preview;
|
||||
|
||||
var context = Context(clip, source);
|
||||
preview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(
|
||||
context, document, preview, preview.SourceIndices.Distinct().ToArray());
|
||||
preview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(context);
|
||||
preview.HasFrameChanges = !FramesEqual(clip, preview);
|
||||
if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != preview.CurrentHash)
|
||||
{
|
||||
preview.AddIssue(new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Warning,
|
||||
FrameAnimationImportIssueCode.SourceChanged,
|
||||
sourceId,
|
||||
clip.Id,
|
||||
"来源内容或切图设置自上次刷新后已发生变化。"));
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
public static bool Apply(FrameClipImportPreview preview, bool allowSpriteChanges, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
if (preview?.Clip == null || preview.Document == null || preview.SpritePlan == null)
|
||||
{
|
||||
error = "独立 Clip 导入预览无效。";
|
||||
return false;
|
||||
}
|
||||
if (preview.HasErrors)
|
||||
{
|
||||
error = "导入预览包含阻断错误,未修改任何资产。";
|
||||
return false;
|
||||
}
|
||||
if (preview.HasSpriteChanges && !allowSpriteChanges)
|
||||
{
|
||||
error = "导入需要修改 TextureImporter,但尚未获得确认。";
|
||||
return false;
|
||||
}
|
||||
|
||||
var clip = preview.Clip;
|
||||
var source = clip.StandaloneImportSource;
|
||||
FrameAnimationTextureSnapshot snapshot = null;
|
||||
Undo.IncrementCurrentGroup();
|
||||
var group = Undo.GetCurrentGroup();
|
||||
Undo.SetCurrentGroupName("Refresh Standalone Frame Clip Source");
|
||||
try
|
||||
{
|
||||
if (source.ManageSpriteSlicing && preview.HasSpriteChanges)
|
||||
{
|
||||
snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(preview.SpritePlan);
|
||||
FrameAnimationSpriteUtility.ApplyPlan(preview.SpritePlan);
|
||||
}
|
||||
var sprites = source.ManageSpriteSlicing
|
||||
? FrameAnimationSpriteUtility.LoadSpritesByName(preview.SpritePlan.TexturePath)
|
||||
: null;
|
||||
var frames = preview.SourceIndices.Select(sourceIndex =>
|
||||
{
|
||||
var sourceFrame = preview.Document.Frames[sourceIndex];
|
||||
if (!preview.SpritePlan.TryResolveSprite(sourceIndex, sprites, out var sprite))
|
||||
throw new InvalidOperationException($"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的 Sprite。");
|
||||
return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex);
|
||||
}).ToArray();
|
||||
|
||||
Undo.RecordObject(clip, "Refresh Standalone Frame Clip");
|
||||
clip.SetFrames(frames);
|
||||
if (string.IsNullOrWhiteSpace(clip.Id))
|
||||
{
|
||||
var initialId = !string.IsNullOrWhiteSpace(preview.SourceTagName)
|
||||
? preview.SourceTagName
|
||||
: Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(clip));
|
||||
clip.SetId(initialId);
|
||||
if (string.IsNullOrWhiteSpace(clip.DisplayName)) clip.SetDisplayName(initialId);
|
||||
}
|
||||
source.SetLastImport(
|
||||
FrameAnimationSpriteUtility.ComputeSourceHash(Context(clip, source)),
|
||||
preview.SourceTagName);
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
Undo.CollapseUndoOperations(group);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Undo.RevertAllDownToGroup(group);
|
||||
if (snapshot != null) FrameAnimationSpriteUtility.RestoreSnapshot(snapshot);
|
||||
AssetDatabase.SaveAssets();
|
||||
error = $"独立 Clip 导入失败,已尝试回滚:{exception.Message}";
|
||||
Debug.LogException(exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateFrames(
|
||||
FrameClip clip,
|
||||
FrameClipImportSource source,
|
||||
AsepriteSourceDocument document,
|
||||
FrameClipImportPreview preview)
|
||||
{
|
||||
if (document.Frames.Count == 0)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。"));
|
||||
foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1))
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameDuplicate,
|
||||
$"frameName '{duplicate.Key}' 在来源内重复。"));
|
||||
for (var index = 0; index < document.Frames.Count; index++)
|
||||
{
|
||||
var frame = document.Frames[index];
|
||||
if (string.IsNullOrWhiteSpace(frame.FrameName))
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameInvalid,
|
||||
$"SourceFrame[{index}] 的 frameName 为空。"));
|
||||
if (frame.DurationMs <= 0)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameDurationInvalid,
|
||||
$"SourceFrame '{frame.FrameName}' 的 duration 必须大于 0。"));
|
||||
if (frame.Rect.width <= 0 || frame.Rect.height <= 0 || frame.Rect.x < 0 || frame.Rect.y < 0 ||
|
||||
frame.Rect.xMax > source.Texture.width || frame.Rect.yMax > source.Texture.height)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameRectInvalid,
|
||||
$"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。"));
|
||||
if (frame.Trimmed)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TrimmedUnsupported,
|
||||
$"SourceFrame '{frame.FrameName}' 使用了不支持的 trimmed。"));
|
||||
if (frame.Rotated)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.RotatedUnsupported,
|
||||
$"SourceFrame '{frame.FrameName}' 使用了不支持的 rotated。"));
|
||||
}
|
||||
if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureSizeMismatch,
|
||||
$"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。"));
|
||||
}
|
||||
|
||||
private static void ValidateTextureOwnership(
|
||||
FrameClip clip,
|
||||
FrameClipImportSource source,
|
||||
FrameClipImportPreview preview)
|
||||
{
|
||||
if (!source.ManageSpriteSlicing) return;
|
||||
var owners = new List<string>();
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph"))
|
||||
{
|
||||
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (graph == null) continue;
|
||||
owners.AddRange(graph.ImportSources.Where(item => item != null && item.IsEnabled &&
|
||||
item.ManageSpriteSlicing && item.Texture == source.Texture)
|
||||
.Select(item => $"Graph '{graph.name}' / {item.DisplayName}"));
|
||||
}
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:FrameClip"))
|
||||
{
|
||||
var other = AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
var otherSource = other?.StandaloneImportSource;
|
||||
if (other == null || other == clip || otherSource == null || !otherSource.ManageSpriteSlicing ||
|
||||
otherSource.Texture != source.Texture) continue;
|
||||
owners.Add($"FrameClip '{other.name}'");
|
||||
}
|
||||
if (owners.Count > 0)
|
||||
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureOwnershipConflict,
|
||||
$"Texture '{source.Texture.name}' 已有其他可写来源:{string.Join(", ", owners)}"));
|
||||
}
|
||||
|
||||
private static bool FramesEqual(FrameClip clip, FrameClipImportPreview preview)
|
||||
{
|
||||
if (clip.FrameCount != preview.SourceIndices.Count) return false;
|
||||
for (var index = 0; index < preview.SourceIndices.Count; index++)
|
||||
{
|
||||
var sourceIndex = preview.SourceIndices[index];
|
||||
var sourceFrame = preview.Document.Frames[sourceIndex];
|
||||
var frame = clip.Frames[index];
|
||||
if (frame == null || frame.SourceIndex != sourceIndex || frame.FrameName != sourceFrame.FrameName ||
|
||||
frame.DurationMs != sourceFrame.DurationMs ||
|
||||
!preview.SpritePlan.MatchesSprite(sourceIndex, frame.Sprite)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static FrameAnimationSpriteSourceContext Context(FrameClip clip, FrameClipImportSource source)
|
||||
{
|
||||
return new FrameAnimationSpriteSourceContext(
|
||||
AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip)),
|
||||
source.Texture, source.AsepriteJson, source.Pivot, source.ManageSpriteSlicing);
|
||||
}
|
||||
|
||||
private static bool IsExternalClip(FrameClip clip)
|
||||
{
|
||||
return clip != null && !AssetDatabase.IsSubAsset(clip) &&
|
||||
!string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip));
|
||||
}
|
||||
|
||||
private static FrameAnimationImportIssue Error(
|
||||
FrameClip clip,
|
||||
FrameAnimationImportIssueCode code,
|
||||
string message)
|
||||
{
|
||||
var path = clip != null ? AssetDatabase.GetAssetPath(clip) : string.Empty;
|
||||
return new FrameAnimationImportIssue(
|
||||
FrameAnimationValidationSeverity.Error,
|
||||
code,
|
||||
AssetDatabase.AssetPathToGUID(path),
|
||||
clip != null ? clip.Id : string.Empty,
|
||||
message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4448f0db0909671479f225061710e439
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,51 @@
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
[CustomEditor(typeof(FrameClipPlayer))]
|
||||
public sealed class FrameClipPlayerEditor : UnityEditor.Editor
|
||||
{
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
serializedObject.Update();
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("clip"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("playOnEnable"));
|
||||
EditorGUILayout.PropertyField(serializedObject.FindProperty("speed"));
|
||||
DrawWarnings();
|
||||
serializedObject.ApplyModifiedProperties();
|
||||
|
||||
if (!Application.isPlaying) return;
|
||||
var player = (FrameClipPlayer)target;
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
|
||||
using (new EditorGUI.DisabledScope(true))
|
||||
{
|
||||
EditorGUILayout.EnumPopup("State", player.State);
|
||||
EditorGUILayout.ObjectField("Clip", player.CurrentClip, typeof(FrameClip), false);
|
||||
EditorGUILayout.TextField("Clip ID", player.CurrentClipId);
|
||||
EditorGUILayout.IntField("Frame", player.CurrentFrameIndex);
|
||||
}
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void DrawWarnings()
|
||||
{
|
||||
var player = (FrameClipPlayer)target;
|
||||
var renderer = player.GetComponent<SpriteRenderer>();
|
||||
var image = player.GetComponent<Image>();
|
||||
if (player.GetComponent<FrameAnimationPlayer>() != null)
|
||||
EditorGUILayout.HelpBox("同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。", MessageType.Error);
|
||||
if (renderer == null && image == null)
|
||||
EditorGUILayout.HelpBox("同一 GameObject 上必须存在一个 SpriteRenderer 或 Image。", MessageType.Error);
|
||||
else if (renderer != null && image != null)
|
||||
EditorGUILayout.HelpBox("同一 GameObject 不能同时存在 SpriteRenderer 和 Image。", MessageType.Error);
|
||||
if (serializedObject.FindProperty("clip").objectReferenceValue == null)
|
||||
EditorGUILayout.HelpBox("尚未绑定默认 FrameClip;仍可通过 Play(FrameClip) 临时播放。", MessageType.Info);
|
||||
var value = serializedObject.FindProperty("speed").floatValue;
|
||||
if (!FrameAnimationValueUtility.IsValidSpeed(value))
|
||||
EditorGUILayout.HelpBox("Speed 必须是有限且不小于 0 的数值。", MessageType.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7eddf60189f61740be2fe59cda06957
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using AibisDream.FrameAnimation;
|
||||
using AibisDream.SaveSystem;
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class FrameAnimationActorIntegrationTests
|
||||
{
|
||||
private const string PrefabPath =
|
||||
"Assets/Prefabs/FixSystemPrefabs/FrameAnimationActor.prefab";
|
||||
|
||||
[Test]
|
||||
public void ActorType_AppendsFrameAnimationWithoutChangingExistingValues()
|
||||
{
|
||||
Assert.That((int)ActorType.Sprite, Is.EqualTo(0));
|
||||
Assert.That((int)ActorType.Anima, Is.EqualTo(1));
|
||||
Assert.That((int)ActorType.AnimaEx, Is.EqualTo(2));
|
||||
Assert.That((int)ActorType.FrameAnimation, Is.EqualTo(3));
|
||||
Assert.That(
|
||||
System.Enum.TryParse("FrameAnimation", out ActorType parsed),
|
||||
Is.True);
|
||||
Assert.That(parsed, Is.EqualTo(ActorType.FrameAnimation));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Prefab_HasRequiredComponentsAndPersistentAddress()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<SpriteRenderer>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationActor>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().PlayOnEnable, Is.False);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().Graph, Is.Null);
|
||||
|
||||
var guid = AssetDatabase.AssetPathToGUID(PrefabPath);
|
||||
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(guid);
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry.address, Is.EqualTo(ConstRef.FrameAnimationActorPrefabName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureEntry_ReusesActorSnapshotFieldsWithoutSchemaChange()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
var actorObject = Object.Instantiate(prefab);
|
||||
var slotObject = new GameObject("Slot");
|
||||
try
|
||||
{
|
||||
var slot = slotObject.AddComponent<ActorSlot>();
|
||||
slot.slotName = "test-slot";
|
||||
slot.sortingLayer = 0;
|
||||
slot.sortingOrder = 17;
|
||||
var actor = actorObject.GetComponent<FrameAnimationActor>();
|
||||
actor.Init("TestActor", slot, ActorType.FrameAnimation);
|
||||
actor.Show();
|
||||
|
||||
ActorEntrySnapshotDto entry = actor.CaptureEntry();
|
||||
|
||||
Assert.That(entry.actorName, Is.EqualTo("TestActor"));
|
||||
Assert.That(entry.slotName, Is.EqualTo("test-slot"));
|
||||
Assert.That(entry.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
|
||||
Assert.That(entry.alpha, Is.EqualTo(1f));
|
||||
Assert.That(entry.stateName, Is.Empty);
|
||||
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(actorObject);
|
||||
Object.DestroyImmediate(slotObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23aa0707ef5c4618b55c455f2a03a612
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,44 @@
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class OptionPromptMetadataTests
|
||||
{
|
||||
[Test]
|
||||
public void IsOptionPromptLine_WithExactMetadata_ReturnsTrue()
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = new[] { "line:0123456", YarnUtil.OptionPrompt }
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.True);
|
||||
}
|
||||
|
||||
[TestCase("option_prompts")]
|
||||
[TestCase("dream_option_prompt")]
|
||||
[TestCase("OPTION_PROMPT")]
|
||||
public void IsOptionPromptLine_WithSimilarMetadata_ReturnsFalse(string metadata)
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = new[] { metadata }
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsOptionPromptLine_WithoutMetadata_ReturnsFalse()
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5e819fca9154e979c37ac490184620b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using AibisDream.SaveSystem;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class PresentationSnapshotContractTests
|
||||
{
|
||||
[Test]
|
||||
public void PresentationProviders_UseStableIdsAndRestoreInDependencyOrder()
|
||||
{
|
||||
var showcase = new ShowcaseSnapshotProvider();
|
||||
var day2Sleep = new Day2SleepPresentationSnapshotProvider();
|
||||
var playTool = new PlayToolSnapshotProvider();
|
||||
var screen = new ScreenSnapshotProvider();
|
||||
|
||||
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
|
||||
Assert.That(day2Sleep.SaveId, Is.EqualTo("day2SleepPresentation"));
|
||||
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
|
||||
Assert.That(showcase.RestoreOrder, Is.LessThan(day2Sleep.RestoreOrder));
|
||||
Assert.That(day2Sleep.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
|
||||
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayToolSection_RoundTripsThroughSnapshotJson()
|
||||
{
|
||||
var source = new SaveSnapshot();
|
||||
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
|
||||
{
|
||||
isObjVisible = true,
|
||||
objPicName = "证物",
|
||||
isFullScreenVisible = true,
|
||||
fullScreenPicName = "教室"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(source);
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
|
||||
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
|
||||
.ToObject<PlayToolSnapshotDto>();
|
||||
|
||||
Assert.That(playTool.isObjVisible, Is.True);
|
||||
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
|
||||
Assert.That(playTool.isFullScreenVisible, Is.True);
|
||||
Assert.That(playTool.fullScreenPicName, Is.EqualTo("教室"));
|
||||
Assert.That(restored.schemaVersion, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShowcaseAndDay2SleepSections_RoundTripSemanticState()
|
||||
{
|
||||
var source = new SaveSnapshot();
|
||||
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
|
||||
{
|
||||
displayMode = ShowcaseDisplayMode.Large.ToString(),
|
||||
picName = "D2S星图",
|
||||
isBackgroundVisible = true
|
||||
};
|
||||
source.sections[SnapshotProviderIds.Day2SleepPresentation] =
|
||||
new Day2SleepPresentationSnapshotDto
|
||||
{
|
||||
mode = Day2SleepPresentationMode.LargeEffects.ToString(),
|
||||
largeBlurAmount = 0.72f,
|
||||
largeBlurSize = 0.015f,
|
||||
largeScaleMultiplier = 1.1f,
|
||||
largeAlpha = 0.4f
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(source);
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
|
||||
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
|
||||
.ToObject<ShowcaseSnapshotDto>();
|
||||
var day2Sleep = ((JObject)restored.sections[SnapshotProviderIds.Day2SleepPresentation])
|
||||
.ToObject<Day2SleepPresentationSnapshotDto>();
|
||||
|
||||
Assert.That(showcase.displayMode, Is.EqualTo("Large"));
|
||||
Assert.That(showcase.picName, Is.EqualTo("D2S星图"));
|
||||
Assert.That(showcase.isBackgroundVisible, Is.True);
|
||||
Assert.That(day2Sleep.mode, Is.EqualTo("LargeEffects"));
|
||||
Assert.That(day2Sleep.largeBlurAmount, Is.EqualTo(0.72f));
|
||||
Assert.That(day2Sleep.largeScaleMultiplier, Is.EqualTo(1.1f));
|
||||
Assert.That(day2Sleep.largeAlpha, Is.EqualTo(0.4f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OldSnapshotWithoutPresentationSections_RemainsCompatible()
|
||||
{
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
|
||||
"{\"schemaVersion\":1,\"sections\":{}}");
|
||||
|
||||
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Day2SleepPresentation), Is.False);
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa21851c46624f77a67e29c86ec854c9
|
||||
@@ -0,0 +1,534 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AibisDream.SaveSystem;
|
||||
using AibisDream.Utility;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEditor.AddressableAssets.Settings;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.EditorTools
|
||||
{
|
||||
/// <summary>从本机 testsavs 选择章节、去重排序并提升为可提交测试档。</summary>
|
||||
public sealed class DevSavePromoteWindow : EditorWindow
|
||||
{
|
||||
private readonly List<ArchiveGroup> groups = new();
|
||||
private readonly List<Candidate> preview = new();
|
||||
private int selectedGroup;
|
||||
private string sectionId = string.Empty;
|
||||
private string sectionTitle = string.Empty;
|
||||
private string status = string.Empty;
|
||||
private Vector2 scroll;
|
||||
|
||||
[MenuItem("Tools/Aibis/Dev Save Promote")]
|
||||
public static void Open()
|
||||
{
|
||||
var window = GetWindow<DevSavePromoteWindow>("Dev Save Promote");
|
||||
window.minSize = new Vector2(720f, 520f);
|
||||
window.Show();
|
||||
}
|
||||
|
||||
private void OnEnable() => RefreshGroups();
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("测试存档提升与校验", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"来源是本机 testsavs;目标是项目根目录的 DevSaveFiles。该目录可提交,但不会被 Unity 自动打包。",
|
||||
MessageType.Info);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("刷新本机归档", GUILayout.Height(26f))) RefreshGroups();
|
||||
if (GUILayout.Button("校验全部已提交测试档", GUILayout.Height(26f))) ValidateCommittedCatalog();
|
||||
}
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox("本机没有可用 testsavs。", MessageType.Warning);
|
||||
DrawStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
var labels = groups.Select(group =>
|
||||
$"{group.SceneSoName} | {group.YarnProjectId} | {group.SceneName} ({group.Items.Count})").ToArray();
|
||||
var nextGroup = EditorGUILayout.Popup("章节归档", Mathf.Clamp(selectedGroup, 0, labels.Length - 1), labels);
|
||||
if (nextGroup != selectedGroup || preview.Count == 0)
|
||||
{
|
||||
selectedGroup = nextGroup;
|
||||
SelectGroup(groups[selectedGroup]);
|
||||
}
|
||||
|
||||
sectionId = EditorGUILayout.TextField("Section Id", sectionId);
|
||||
sectionTitle = EditorGUILayout.TextField("显示名称", sectionTitle);
|
||||
EditorGUILayout.LabelField("规则", "每个 Yarn 节点保留 savedAt 最新一份;默认按 savedAt 升序");
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
GUILayout.FlexibleSpace();
|
||||
if (GUILayout.Button("提升当前章节", GUILayout.Width(180f), GUILayout.Height(28f))) Promote();
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(5f);
|
||||
EditorGUILayout.LabelField($"阶段预览:{preview.Count}", EditorStyles.boldLabel);
|
||||
scroll = EditorGUILayout.BeginScrollView(scroll);
|
||||
for (var i = 0; i < preview.Count; i++)
|
||||
{
|
||||
var item = preview[i];
|
||||
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.LabelField($"{i + 1:000}", GUILayout.Width(38f));
|
||||
item.Label = EditorGUILayout.TextField(item.Label, GUILayout.Width(210f));
|
||||
EditorGUILayout.LabelField(item.NodeName, GUILayout.MinWidth(180f));
|
||||
EditorGUILayout.LabelField(item.SavedAt, GUILayout.Width(140f));
|
||||
using (new EditorGUI.DisabledScope(i == 0))
|
||||
{
|
||||
if (GUILayout.Button("↑", GUILayout.Width(26f))) Move(i, i - 1);
|
||||
}
|
||||
using (new EditorGUI.DisabledScope(i == preview.Count - 1))
|
||||
{
|
||||
if (GUILayout.Button("↓", GUILayout.Width(26f))) Move(i, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorGUILayout.EndScrollView();
|
||||
DrawStatus();
|
||||
}
|
||||
|
||||
private void DrawStatus()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
EditorGUILayout.HelpBox(status, status.StartsWith("OK", StringComparison.Ordinal) ? MessageType.Info : MessageType.Warning);
|
||||
}
|
||||
|
||||
private void RefreshGroups()
|
||||
{
|
||||
groups.Clear();
|
||||
preview.Clear();
|
||||
var root = ConstRef.TestAutoSaveArchivePath;
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
status = $"找不到本机归档:{root}";
|
||||
return;
|
||||
}
|
||||
|
||||
var candidates = new List<Candidate>();
|
||||
foreach (var directory in Directory.GetDirectories(root))
|
||||
{
|
||||
var snapshotPath = Path.Combine(directory, $"{ConstRef.SaveSnapshotFileName}.json");
|
||||
if (!File.Exists(snapshotPath)) continue;
|
||||
try
|
||||
{
|
||||
var snapshot = SnapshotPersistence.Load(Path.Combine(directory, ConstRef.SaveSnapshotFileName));
|
||||
if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) continue;
|
||||
candidates.Add(new Candidate(
|
||||
snapshotPath,
|
||||
Path.Combine(directory, $"{ConstRef.SaveMetaFileName}.json"),
|
||||
snapshot.scene?.sceneName,
|
||||
snapshot.anchor.sceneSoName,
|
||||
snapshot.anchor.yarnProjectId,
|
||||
snapshot.anchor.nodeName,
|
||||
snapshot.savedAt));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[DevSavePromote] 跳过损坏归档 {snapshotPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
groups.AddRange(candidates
|
||||
.GroupBy(item => $"{item.SceneName}\n{item.SceneSoName}\n{item.YarnProjectId}", StringComparer.Ordinal)
|
||||
.Select(group => new ArchiveGroup(group.ToList()))
|
||||
.OrderBy(group => group.SceneSoName, StringComparer.Ordinal));
|
||||
selectedGroup = Mathf.Clamp(selectedGroup, 0, Math.Max(0, groups.Count - 1));
|
||||
if (groups.Count > 0) SelectGroup(groups[selectedGroup]);
|
||||
status = $"OK:读取 {candidates.Count} 份归档,分为 {groups.Count} 个章节。";
|
||||
}
|
||||
|
||||
private void SelectGroup(ArchiveGroup group)
|
||||
{
|
||||
preview.Clear();
|
||||
preview.AddRange(group.Items
|
||||
.GroupBy(item => item.NodeName, StringComparer.Ordinal)
|
||||
.Select(nodes => nodes.OrderByDescending(item => item.SavedAt, StringComparer.Ordinal).First())
|
||||
.OrderBy(item => item.SavedAt, StringComparer.Ordinal));
|
||||
sectionId = SanitizeIdentifier(group.SceneSoName);
|
||||
sectionTitle = group.SceneSoName;
|
||||
}
|
||||
|
||||
private void Move(int from, int to)
|
||||
{
|
||||
if (from < 0 || to < 0 || from >= preview.Count || to >= preview.Count) return;
|
||||
var item = preview[from];
|
||||
preview.RemoveAt(from);
|
||||
preview.Insert(to, item);
|
||||
GUI.FocusControl(null);
|
||||
}
|
||||
|
||||
private void Promote()
|
||||
{
|
||||
if (preview.Count == 0 || string.IsNullOrWhiteSpace(sectionId))
|
||||
{
|
||||
status = "没有可提升阶段,或 Section Id 为空。";
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = ValidateCandidates(preview);
|
||||
if (validation.Count > 0)
|
||||
{
|
||||
status = string.Join("\n", validation);
|
||||
return;
|
||||
}
|
||||
|
||||
var group = groups[selectedGroup];
|
||||
var root = ConstRef.TestSaveFilePath;
|
||||
var targetSection = Path.Combine(root, sectionId);
|
||||
if (Directory.Exists(targetSection)) Directory.Delete(targetSection, true);
|
||||
Directory.CreateDirectory(targetSection);
|
||||
|
||||
var catalog = LoadCatalogForWrite();
|
||||
catalog.sections.RemoveAll(section => string.Equals(section.id, sectionId, StringComparison.OrdinalIgnoreCase));
|
||||
var sectionDto = new DevSaveCatalogSectionDto
|
||||
{
|
||||
order = FindDemoChapterOrder(group.SceneSoName),
|
||||
id = sectionId,
|
||||
title = string.IsNullOrWhiteSpace(sectionTitle) ? sectionId : sectionTitle,
|
||||
expectedSceneName = group.SceneName,
|
||||
expectedSceneSoName = group.SceneSoName,
|
||||
expectedYarnProjectId = group.YarnProjectId
|
||||
};
|
||||
|
||||
for (var i = 0; i < preview.Count; i++)
|
||||
{
|
||||
var item = preview[i];
|
||||
var folder = $"{i + 1:000}_{SanitizeIdentifier(item.NodeName)}";
|
||||
var destination = Path.Combine(targetSection, folder);
|
||||
Directory.CreateDirectory(destination);
|
||||
File.Copy(item.SnapshotPath, Path.Combine(destination, $"{ConstRef.SaveSnapshotFileName}.json"), true);
|
||||
if (File.Exists(item.MetaPath))
|
||||
File.Copy(item.MetaPath, Path.Combine(destination, $"{ConstRef.SaveMetaFileName}.json"), true);
|
||||
sectionDto.entries.Add(new DevSaveCatalogEntryDto
|
||||
{
|
||||
order = i + 1,
|
||||
label = string.IsNullOrWhiteSpace(item.Label) ? item.NodeName : item.Label,
|
||||
path = $"{sectionId}/{folder}",
|
||||
anchorNode = item.NodeName
|
||||
});
|
||||
}
|
||||
|
||||
catalog.sections.Add(sectionDto);
|
||||
catalog.sections.Sort((left, right) => left.order.CompareTo(right.order));
|
||||
WriteCatalog(catalog);
|
||||
AssetDatabase.Refresh();
|
||||
ValidateCommittedCatalog();
|
||||
}
|
||||
|
||||
private static List<string> ValidateCandidates(IEnumerable<Candidate> candidates)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var nodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var item in candidates)
|
||||
{
|
||||
if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}");
|
||||
try
|
||||
{
|
||||
var snapshot = SnapshotPersistence.Load(Path.Combine(Path.GetDirectoryName(item.SnapshotPath)!, ConstRef.SaveSnapshotFileName));
|
||||
if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
|
||||
errors.Add($"{item.NodeName}: schema {snapshot.schemaVersion} 不兼容");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{item.NodeName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void ValidateCommittedCatalog()
|
||||
{
|
||||
var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
|
||||
var errors = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError);
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var orders = new HashSet<int>();
|
||||
var nodes = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var entry in section.Entries)
|
||||
{
|
||||
if (!orders.Add(entry.Order)) errors.Add($"{section.Id}: 重复 order {entry.Order}");
|
||||
if (!nodes.Add(entry.AnchorNode)) errors.Add($"{section.Id}: 重复节点 {entry.AnchorNode}");
|
||||
if (!entry.IsValid) errors.Add($"{section.Id}/{entry.Label}: {entry.Error}");
|
||||
}
|
||||
}
|
||||
|
||||
var dto = LoadCatalogForWrite();
|
||||
foreach (var section in dto.sections)
|
||||
{
|
||||
if (!SceneAddressExists(section.expectedSceneName))
|
||||
errors.Add($"{section.id}: Addressable 场景不存在 {section.expectedSceneName}");
|
||||
var sceneSo = FindTalkSceneSo(section.expectedSceneSoName);
|
||||
if (sceneSo == null)
|
||||
errors.Add($"{section.id}: TalkSceneSO 不存在 {section.expectedSceneSoName}");
|
||||
else if (sceneSo.yarnProject == null || sceneSo.yarnProject.name != section.expectedYarnProjectId)
|
||||
errors.Add($"{section.id}: TalkSceneSO 的 YarnProject 不匹配 {section.expectedYarnProjectId}");
|
||||
else
|
||||
{
|
||||
var nodeNames = new HashSet<string>(sceneSo.yarnProject.NodeNames, StringComparer.Ordinal);
|
||||
foreach (var entry in section.entries.Where(entry => !nodeNames.Contains(entry.anchorNode)))
|
||||
errors.Add($"{section.id}: Yarn 节点不存在 {entry.anchorNode}");
|
||||
}
|
||||
}
|
||||
|
||||
status = errors.Count == 0
|
||||
? $"OK:{sections.Count} 个章节、{sections.Sum(s => s.Entries.Count)} 个阶段全部通过校验。"
|
||||
: $"校验失败({errors.Count}):\n{string.Join("\n", errors)}";
|
||||
if (errors.Count > 0) Debug.LogError($"[DevSavePromote] {status}");
|
||||
else Debug.Log($"[DevSavePromote] {status}");
|
||||
}
|
||||
|
||||
private static bool SceneAddressExists(string address)
|
||||
{
|
||||
var settings = AddressableAssetSettingsDefaultObject.Settings;
|
||||
return settings != null && settings.groups
|
||||
.Where(group => group != null)
|
||||
.SelectMany(group => group.entries)
|
||||
.Any(entry => string.Equals(entry.address, address, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static TalkSceneSO FindTalkSceneSo(string assetName)
|
||||
{
|
||||
foreach (var guid in AssetDatabase.FindAssets($"t:TalkSceneSO {assetName}"))
|
||||
{
|
||||
var asset = AssetDatabase.LoadAssetAtPath<TalkSceneSO>(AssetDatabase.GUIDToAssetPath(guid));
|
||||
if (asset != null && asset.name == assetName) return asset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int FindDemoChapterOrder(string sceneSoName)
|
||||
{
|
||||
const string firstChapterPath = "Assets/ScriptableObjects/SceneSO/Demo/Day0_Prologue.asset";
|
||||
var chapter = AssetDatabase.LoadAssetAtPath<TalkSceneSO>(firstChapterPath);
|
||||
var visited = new HashSet<TalkSceneSO>();
|
||||
var order = 1;
|
||||
while (chapter != null && visited.Add(chapter))
|
||||
{
|
||||
if (string.Equals(chapter.name, sceneSoName, StringComparison.Ordinal)) return order;
|
||||
chapter = chapter.GetNextScene();
|
||||
order++;
|
||||
}
|
||||
|
||||
return int.MaxValue;
|
||||
}
|
||||
|
||||
private static DevSaveCatalogDto LoadCatalogForWrite()
|
||||
{
|
||||
var path = Path.Combine(ConstRef.TestSaveFilePath, "catalog.json");
|
||||
if (!File.Exists(path)) return new DevSaveCatalogDto();
|
||||
try
|
||||
{
|
||||
return JsonConvert.DeserializeObject<DevSaveCatalogDto>(File.ReadAllText(path, Encoding.UTF8))
|
||||
?? new DevSaveCatalogDto();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DevSaveCatalogDto();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteCatalog(DevSaveCatalogDto catalog)
|
||||
{
|
||||
Directory.CreateDirectory(ConstRef.TestSaveFilePath);
|
||||
File.WriteAllText(
|
||||
Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"),
|
||||
JsonConvert.SerializeObject(catalog, Formatting.Indented),
|
||||
new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static string SanitizeIdentifier(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return "unnamed";
|
||||
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
|
||||
value = value.Replace(' ', '_');
|
||||
return value.Length <= 48 ? value : value.Substring(0, 48);
|
||||
}
|
||||
|
||||
private sealed class ArchiveGroup
|
||||
{
|
||||
public readonly List<Candidate> Items;
|
||||
public string SceneName => Items[0].SceneName;
|
||||
public string SceneSoName => Items[0].SceneSoName;
|
||||
public string YarnProjectId => Items[0].YarnProjectId;
|
||||
public ArchiveGroup(List<Candidate> items) => Items = items;
|
||||
}
|
||||
|
||||
private sealed class Candidate
|
||||
{
|
||||
public readonly string SnapshotPath;
|
||||
public readonly string MetaPath;
|
||||
public readonly string SceneName;
|
||||
public readonly string SceneSoName;
|
||||
public readonly string YarnProjectId;
|
||||
public readonly string NodeName;
|
||||
public readonly string SavedAt;
|
||||
public string Label;
|
||||
|
||||
public Candidate(string snapshotPath, string metaPath, string sceneName, string sceneSoName,
|
||||
string yarnProjectId, string nodeName, string savedAt)
|
||||
{
|
||||
SnapshotPath = snapshotPath;
|
||||
MetaPath = metaPath;
|
||||
SceneName = sceneName;
|
||||
SceneSoName = sceneSoName;
|
||||
YarnProjectId = yarnProjectId;
|
||||
NodeName = nodeName;
|
||||
SavedAt = savedAt ?? string.Empty;
|
||||
Label = nodeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public sealed class DevSaveCatalogTests
|
||||
{
|
||||
private string root;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
root = Path.Combine(Path.GetTempPath(), $"aibis-dev-save-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(root);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(root)) Directory.Delete(root, true);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatalogUsesExplicitOrderAndKeepsInvalidEntriesVisible()
|
||||
{
|
||||
WriteCatalog(new DevSaveCatalogEntryDto { order = 20, label = "late", path = "S/late", anchorNode = "Late" },
|
||||
new DevSaveCatalogEntryDto { order = 10, label = "early", path = "S/early", anchorNode = "Early" });
|
||||
|
||||
var section = DevSaveCatalog.Load(root).Single();
|
||||
|
||||
Assert.That(section.Entries.Select(entry => entry.Label), Is.EqualTo(new[] { "early", "late" }));
|
||||
Assert.That(section.Entries.All(entry => !entry.IsValid), Is.True);
|
||||
Assert.That(section.Entries.All(entry => entry.Error.Contains("缺失")), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatalogRejectsPathTraversal()
|
||||
{
|
||||
WriteCatalog(new DevSaveCatalogEntryDto { order = 1, label = "unsafe", path = "../outside", anchorNode = "Start" });
|
||||
|
||||
var entry = DevSaveCatalog.Load(root).Single().Entries.Single();
|
||||
|
||||
Assert.That(entry.IsValid, Is.False);
|
||||
Assert.That(entry.Error, Does.Contain("越过"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CatalogReportsCorruptSchemaAndAnchorMismatch()
|
||||
{
|
||||
WriteSnapshot("S/corrupt", "{");
|
||||
WriteSnapshot("S/schema", JsonConvert.SerializeObject(BuildSnapshot("Node", schema: 99)));
|
||||
WriteSnapshot("S/node", JsonConvert.SerializeObject(BuildSnapshot("Actual")));
|
||||
WriteCatalog(
|
||||
new DevSaveCatalogEntryDto { order = 1, label = "corrupt", path = "S/corrupt", anchorNode = "Node" },
|
||||
new DevSaveCatalogEntryDto { order = 2, label = "schema", path = "S/schema", anchorNode = "Node" },
|
||||
new DevSaveCatalogEntryDto { order = 3, label = "node", path = "S/node", anchorNode = "Expected" });
|
||||
|
||||
var entries = DevSaveCatalog.Load(root).Single().Entries;
|
||||
|
||||
Assert.That(entries[0].Error, Does.Contain("JSON"));
|
||||
Assert.That(entries[1].Error, Does.Contain("版本"));
|
||||
Assert.That(entries[2].Error, Does.Contain("节点不匹配"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CommittedCatalogContainsAllDemoChaptersWithValidUniqueEntries()
|
||||
{
|
||||
var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
|
||||
Assert.That(DevSaveCatalog.LastError, Is.Null);
|
||||
Assert.That(sections.Select(section => section.Id), Is.EqualTo(new[]
|
||||
{
|
||||
"Day0Prologue", "Day1Begin", "Peipei1", "Day1Mid", "Day1Night", "Day1Sleep",
|
||||
"Day2Begin", "Huoshan1", "Day2Mid", "Peipei2", "Day2Night", "Day2Sleep", "GeneralEnd"
|
||||
}));
|
||||
foreach (var section in sections)
|
||||
{
|
||||
Assert.That(section.Entries.All(entry => entry.IsValid), Is.True,
|
||||
string.Join("\n", section.Entries.Where(entry => !entry.IsValid).Select(entry => $"{entry.Label}: {entry.Error}")));
|
||||
Assert.That(section.Entries.Select(entry => entry.Order).Distinct().Count(), Is.EqualTo(section.Entries.Count));
|
||||
Assert.That(section.Entries.Select(entry => entry.AnchorNode).Distinct().Count(), Is.EqualTo(section.Entries.Count));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void StrictRestoreContextTracksPhaseWarningsAndErrors()
|
||||
{
|
||||
var context = new SnapshotRestoreContext(new SaveSnapshot(), strictMode: true);
|
||||
context.SetPhase("Provider restore");
|
||||
context.Warn("optional state missing");
|
||||
context.Error("required state missing");
|
||||
|
||||
Assert.That(context.StrictMode, Is.True);
|
||||
Assert.That(context.CurrentPhase, Is.EqualTo("Provider restore"));
|
||||
Assert.That(context.Warnings, Is.EqualTo(new[] { "optional state missing" }));
|
||||
Assert.That(context.Errors, Is.EqualTo(new[] { "required state missing" }));
|
||||
Assert.That(context.HasErrors, Is.True);
|
||||
}
|
||||
|
||||
private void WriteCatalog(params DevSaveCatalogEntryDto[] entries)
|
||||
{
|
||||
var catalog = new DevSaveCatalogDto
|
||||
{
|
||||
sections = new List<DevSaveCatalogSectionDto>
|
||||
{
|
||||
new()
|
||||
{
|
||||
id = "S",
|
||||
title = "Section",
|
||||
expectedSceneName = "Scene/Test",
|
||||
expectedSceneSoName = "TestSO",
|
||||
expectedYarnProjectId = "TestYarn",
|
||||
entries = entries.ToList()
|
||||
}
|
||||
}
|
||||
};
|
||||
File.WriteAllText(Path.Combine(root, "catalog.json"), JsonConvert.SerializeObject(catalog));
|
||||
}
|
||||
|
||||
private void WriteSnapshot(string relativeDirectory, string json)
|
||||
{
|
||||
var directory = Path.Combine(root, relativeDirectory.Replace('/', Path.DirectorySeparatorChar));
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(Path.Combine(directory, "snapshot.json"), json);
|
||||
}
|
||||
|
||||
private static SaveSnapshot BuildSnapshot(string node, int schema = SaveSnapshotSchema.CurrentVersion)
|
||||
{
|
||||
return new SaveSnapshot
|
||||
{
|
||||
schemaVersion = schema,
|
||||
scene = new SceneSnapshotDto { sceneName = "Scene/Test" },
|
||||
anchor = new AnchorSnapshot
|
||||
{
|
||||
sceneSoName = "TestSO",
|
||||
yarnProjectId = "TestYarn",
|
||||
nodeName = node
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b564a87c642e53646baa39708c8f72ac
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -167,7 +167,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
EditorGUILayout.LabelField("存读档测试工具", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"用于 Play Mode 下验证自动存档、槽位读档与 testsavs 测试归档。\n" +
|
||||
"正式存档目录: saves/ | 测试归档: testsavs/(每次自动存档独立子文件夹)",
|
||||
"正式存档目录: demo_saves/ | 测试归档: testsavs/(每次自动存档独立子文件夹)",
|
||||
MessageType.Info);
|
||||
|
||||
if (!Application.isPlaying)
|
||||
@@ -193,7 +193,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
|
||||
EditorGUILayout.LabelField($"testsavs 路径: {ConstRef.TestAutoSaveArchivePath}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"开启后:每次 AutoSave 仅在 testsavs 下新建一份独立存档,不写入、不覆盖 saves/slot_0。\n" +
|
||||
"开启后:每次 AutoSave 仅在 testsavs 下新建一份独立存档,不写入、不覆盖 demo_saves/slot_0。\n" +
|
||||
"存多少次就保留多少份;关闭后恢复正式行为(仅覆盖 slot_0)。",
|
||||
MessageType.None);
|
||||
}
|
||||
@@ -224,7 +224,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
private void DrawSlotRestoreSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("从正式槽位读档", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"saves 路径: {ConstRef.SaveFilePath}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.LabelField($"demo_saves 路径: {ConstRef.SaveFilePath}", EditorStyles.miniLabel);
|
||||
|
||||
var latest = SlotManager.GetLatestSlotIndex();
|
||||
EditorGUILayout.LabelField($"最近槽位: {(latest.HasValue ? $"slot_{latest.Value}" : "无")}");
|
||||
@@ -391,7 +391,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("打开 saves 文件夹"))
|
||||
if (GUILayout.Button("打开 demo_saves 文件夹"))
|
||||
{
|
||||
OpenDirectory(ConstRef.SaveFilePath);
|
||||
}
|
||||
@@ -567,7 +567,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSlot(slotIndex);
|
||||
GameManager.Instance.TryRestoreSlot(slotIndex);
|
||||
Log($"已启动读档 slot_{slotIndex}。");
|
||||
}
|
||||
|
||||
@@ -585,7 +585,9 @@ namespace AibisDream.SaveSystem.Editor
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSaveFile(entry.SnapshotPath);
|
||||
GameManager.Instance.TryRestoreFile(
|
||||
entry.SnapshotPath,
|
||||
RestoreOptions.DevJump);
|
||||
Log($"已启动读档: {entry.EntryId}");
|
||||
}
|
||||
|
||||
|
||||
@@ -332,7 +332,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
{
|
||||
EditorGUILayout.LabelField("读档验证 (P4)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"触发 SaveRestoreOrchestrator.RestoreFromSlot,验证 Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" +
|
||||
"触发 GameManager.TryRestoreSlot,验证预检、Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" +
|
||||
"需要在 Play Mode 下执行;不代表正式玩家 UI。",
|
||||
MessageType.None);
|
||||
|
||||
@@ -559,7 +559,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameManager.Instance != null && GameManager.Instance.state.isInPause)
|
||||
if (GameManager.Instance != null && GameManager.Session.IsPaused)
|
||||
{
|
||||
Log("全局门控: GamePaused");
|
||||
return;
|
||||
@@ -631,7 +631,7 @@ namespace AibisDream.SaveSystem.Editor
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSlot(slotIndex);
|
||||
GameManager.Instance.TryRestoreSlot(slotIndex);
|
||||
Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
|
||||
public class VersionIncrementer : IPreprocessBuildWithReport
|
||||
{
|
||||
@@ -23,5 +24,12 @@ public class VersionIncrementer : IPreprocessBuildWithReport
|
||||
// 更新版本号
|
||||
string date = DateTime.Now.ToString("yyyyMMdd-HHmmss");
|
||||
PlayerSettings.bundleVersion = $"{major}.{minor}.{revision}.{build + 1}-{date}";
|
||||
|
||||
if (report.summary.platform == BuildTarget.Android)
|
||||
{
|
||||
int versionCode = PlayerSettings.Android.bundleVersionCode;
|
||||
PlayerSettings.Android.bundleVersionCode = versionCode + 1;
|
||||
Debug.Log($"[VersionIncrementer] Android versionCode: {versionCode} -> {versionCode + 1}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,6 @@ namespace AibisDream.SystemEditor
|
||||
public AsepriteSize size;
|
||||
public string scale;
|
||||
public List<AsepriteFrameTag> frameTags;
|
||||
public List<object> layers;
|
||||
public List<object> slices;
|
||||
}
|
||||
|
||||
// Aseprite JSON的包装类(因为Unity JsonUtility不支持Dictionary)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using UnityEditor;
|
||||
using UnityEditor.U2D.Sprites;
|
||||
using UnityEngine;
|
||||
@@ -57,8 +58,7 @@ namespace AibisDream.SystemEditor
|
||||
int metaBraceEnd = FindMatchingBrace(jsonContent, metaBraceStart);
|
||||
string metaJson = jsonContent.Substring(metaBraceStart, metaBraceEnd - metaBraceStart + 1);
|
||||
|
||||
// 移除可能的尾随逗号
|
||||
metaJson = metaJson.TrimEnd(',', ' ', '\n', '\r');
|
||||
metaJson = SanitizeJsonForUnity(metaJson);
|
||||
|
||||
AsepriteMeta meta = JsonUtility.FromJson<AsepriteMeta>(metaJson);
|
||||
|
||||
@@ -173,7 +173,7 @@ namespace AibisDream.SystemEditor
|
||||
|
||||
try
|
||||
{
|
||||
AsepriteFrameInfo frameInfo = JsonUtility.FromJson<AsepriteFrameInfo>(frameDataJson);
|
||||
AsepriteFrameInfo frameInfo = JsonUtility.FromJson<AsepriteFrameInfo>(SanitizeJsonForUnity(frameDataJson));
|
||||
if (frameInfo.frame != null)
|
||||
{
|
||||
frames.Add(new FrameData
|
||||
@@ -215,6 +215,14 @@ namespace AibisDream.SystemEditor
|
||||
return int.MaxValue; // 如果无法解析,放在最后
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unity JsonUtility 不支持尾随逗号,Aseprite 导出时偶尔会带上。
|
||||
/// </summary>
|
||||
private static string SanitizeJsonForUnity(string json)
|
||||
{
|
||||
return Regex.Replace(json, @",(\s*[}\]])", "$1");
|
||||
}
|
||||
|
||||
private static int FindMatchingBrace(string str, int startIndex)
|
||||
{
|
||||
if (startIndex < 0 || startIndex >= str.Length) return -1;
|
||||
@@ -536,10 +544,10 @@ namespace AibisDream.SystemEditor
|
||||
// 强制更新动画长度 (m_StopTime) - 修复复用已有 clip 时长度未更新的问题
|
||||
float totalDuration = keyframes.Count > 0 ? keyframes[keyframes.Count - 1].time : 0f;
|
||||
SerializedObject serializedClip = new SerializedObject(clip);
|
||||
SerializedProperty stopTimeProp = serializedClip.FindProperty("m_StopTime");
|
||||
if (stopTimeProp != null)
|
||||
SerializedProperty nestedStopTimeProp = serializedClip.FindProperty("m_AnimationClipSettings.m_StopTime");
|
||||
if (nestedStopTimeProp != null)
|
||||
{
|
||||
stopTimeProp.floatValue = totalDuration;
|
||||
nestedStopTimeProp.floatValue = totalDuration;
|
||||
serializedClip.ApplyModifiedProperties();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe2278382cb87e4993d86ca5ed00e5f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public enum YarnL10nIssueType
|
||||
{
|
||||
MissingLocalization,
|
||||
OrphanLocalization,
|
||||
SourceTextChanged,
|
||||
CompileError,
|
||||
UntaggedLine,
|
||||
CsvMissing,
|
||||
InvalidProject,
|
||||
NoLocalizationConfigured,
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class YarnL10nIssue
|
||||
{
|
||||
public YarnL10nIssueType Type;
|
||||
public string Language;
|
||||
public string LineId;
|
||||
public string SourceText;
|
||||
public string LocText;
|
||||
public string YarnFile;
|
||||
public string YarnLineNumber;
|
||||
public string CsvFile;
|
||||
public string ChapterFolder;
|
||||
public string Message;
|
||||
|
||||
public string IssueTypeLabel => GetDisplayType();
|
||||
|
||||
public string GetDisplayType()
|
||||
{
|
||||
return Type switch
|
||||
{
|
||||
YarnL10nIssueType.MissingLocalization => $"缺少本地化-{Language}",
|
||||
YarnL10nIssueType.OrphanLocalization => $"找不到原文-{Language}",
|
||||
YarnL10nIssueType.SourceTextChanged => $"原文已变更-{Language}",
|
||||
YarnL10nIssueType.CompileError => "编译错误",
|
||||
YarnL10nIssueType.UntaggedLine => "未打line标签",
|
||||
YarnL10nIssueType.CsvMissing => $"CSV缺失-{Language}",
|
||||
YarnL10nIssueType.InvalidProject => "项目无效",
|
||||
YarnL10nIssueType.NoLocalizationConfigured => "未配置本地化",
|
||||
_ => Type.ToString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class YarnL10nChapterSummary
|
||||
{
|
||||
public string ChapterFolder;
|
||||
public string YarnProjectPath;
|
||||
public int SourceLineCount;
|
||||
public Dictionary<string, YarnL10nLocaleSummary> Locales = new();
|
||||
public int IssueCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class YarnL10nLocaleSummary
|
||||
{
|
||||
public string Language;
|
||||
public int MissingCount;
|
||||
public int OrphanCount;
|
||||
public int SourceChangedCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public sealed class YarnL10nValidationResult
|
||||
{
|
||||
public List<YarnL10nIssue> Issues = new();
|
||||
public List<YarnL10nChapterSummary> Chapters = new();
|
||||
public int TotalIssueCount;
|
||||
|
||||
public bool HasIssues => TotalIssueCount > 0;
|
||||
|
||||
public bool HasBlockingIssues
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var issue in Issues)
|
||||
{
|
||||
switch (issue.Type)
|
||||
{
|
||||
case YarnL10nIssueType.MissingLocalization:
|
||||
case YarnL10nIssueType.OrphanLocalization:
|
||||
case YarnL10nIssueType.SourceTextChanged:
|
||||
case YarnL10nIssueType.CompileError:
|
||||
case YarnL10nIssueType.CsvMissing:
|
||||
case YarnL10nIssueType.InvalidProject:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 340d8f4fbf1fb8a458b87a164afd827b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public static class YarnL10nReportExporter
|
||||
{
|
||||
public static void ExportToCsv(YarnL10nValidationResult result, string outputPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(outputPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("issueType,language,lineId,sourceText,locText,yarnFile,yarnLineNumber,csvFile,chapterFolder,message");
|
||||
|
||||
foreach (var issue in result.Issues)
|
||||
{
|
||||
builder.AppendLine(string.Join(",",
|
||||
Escape(issue.GetDisplayType()),
|
||||
Escape(issue.Language),
|
||||
Escape(issue.LineId),
|
||||
Escape(issue.SourceText),
|
||||
Escape(issue.LocText),
|
||||
Escape(issue.YarnFile),
|
||||
Escape(issue.YarnLineNumber),
|
||||
Escape(issue.CsvFile),
|
||||
Escape(issue.ChapterFolder),
|
||||
Escape(issue.Message)));
|
||||
}
|
||||
|
||||
File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
|
||||
}
|
||||
|
||||
public static string BuildDefaultExportFileName(string chapterFolder)
|
||||
{
|
||||
var chapterName = string.IsNullOrEmpty(chapterFolder)
|
||||
? "yarn_l10n"
|
||||
: Path.GetFileName(chapterFolder.Replace('/', Path.DirectorySeparatorChar));
|
||||
return $"{chapterName}_l10n_report_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv";
|
||||
}
|
||||
|
||||
private static string Escape(string value)
|
||||
{
|
||||
value ??= string.Empty;
|
||||
if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r'))
|
||||
{
|
||||
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ab3c79927b366e459587175b8325dd6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,471 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public class YarnL10nValidationWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "AIBIS/Yarn 本地化校验";
|
||||
private const string WindowTitle = "Yarn Localization Validator";
|
||||
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
|
||||
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
|
||||
|
||||
private string _folderPath = "Assets/Yarn/FP/FP_Day1_mid";
|
||||
private DefaultAsset _folderAsset;
|
||||
private bool _scanSubdirectories;
|
||||
private Vector2 _scrollPosition;
|
||||
private YarnL10nValidationResult _lastResult;
|
||||
private string _filterType = "全部";
|
||||
private string _filterLanguage = "全部";
|
||||
private string _searchText = string.Empty;
|
||||
private string[] _typeOptions = { "全部" };
|
||||
private string[] _languageOptions = { "全部" };
|
||||
private int _filterTypeIndex;
|
||||
private int _filterLanguageIndex;
|
||||
|
||||
[MenuItem(MenuPath)]
|
||||
public static void Open()
|
||||
{
|
||||
var window = GetWindow<YarnL10nValidationWindow>(WindowTitle);
|
||||
window.minSize = new Vector2(640, 480);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_folderPath = EditorPrefs.GetString(FolderPathPrefKey, _folderPath);
|
||||
_scanSubdirectories = EditorPrefs.GetBool(ScanSubdirsPrefKey, false);
|
||||
SyncFolderAssetFromPath();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.LabelField("Yarn 本地化校验", EditorStyles.boldLabel);
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
DrawFolderSelector();
|
||||
EditorGUILayout.Space(4);
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("校验", GUILayout.Height(28)))
|
||||
{
|
||||
RunValidation();
|
||||
}
|
||||
|
||||
using (new EditorGUI.DisabledScope(_lastResult == null || _lastResult.Issues.Count == 0))
|
||||
{
|
||||
if (GUILayout.Button("导出 CSV", GUILayout.Height(28)))
|
||||
{
|
||||
ExportReport();
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("定位文件夹", GUILayout.Height(28)))
|
||||
{
|
||||
PingFolder();
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
|
||||
_scanSubdirectories = EditorGUILayout.ToggleLeft("扫描子目录(批量)", _scanSubdirectories);
|
||||
if (GUI.changed)
|
||||
{
|
||||
EditorPrefs.SetBool(ScanSubdirsPrefKey, _scanSubdirectories);
|
||||
}
|
||||
EditorGUILayout.Space(6);
|
||||
|
||||
DrawSummary();
|
||||
DrawFilters();
|
||||
DrawIssueList();
|
||||
}
|
||||
|
||||
private void DrawFolderSelector()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("章节文件夹", GUILayout.Width(80));
|
||||
|
||||
var newAsset = (DefaultAsset)EditorGUILayout.ObjectField(_folderAsset, typeof(DefaultAsset), false);
|
||||
if (newAsset != _folderAsset)
|
||||
{
|
||||
_folderAsset = newAsset;
|
||||
if (_folderAsset != null)
|
||||
{
|
||||
_folderPath = AssetDatabase.GetAssetPath(_folderAsset);
|
||||
SaveFolderPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
var newPath = EditorGUILayout.TextField(_folderPath);
|
||||
if (!string.Equals(newPath, _folderPath, StringComparison.Ordinal))
|
||||
{
|
||||
_folderPath = newPath;
|
||||
SyncFolderAssetFromPath();
|
||||
SaveFolderPreferences();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("浏览", GUILayout.Width(60)))
|
||||
{
|
||||
var startDirectory = GetBrowseStartDirectory();
|
||||
var selected = EditorUtility.OpenFolderPanel("选择 Yarn 章节文件夹", startDirectory, string.Empty);
|
||||
if (!string.IsNullOrEmpty(selected))
|
||||
{
|
||||
_folderPath = YarnL10nValidator.NormalizeProjectRelativePath(selected);
|
||||
SyncFolderAssetFromPath();
|
||||
SaveFolderPreferences();
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
|
||||
private void SaveFolderPreferences()
|
||||
{
|
||||
EditorPrefs.SetString(FolderPathPrefKey, _folderPath);
|
||||
}
|
||||
|
||||
private string GetBrowseStartDirectory()
|
||||
{
|
||||
var absolutePath = YarnL10nValidator.ToAbsolutePath(_folderPath);
|
||||
if (!string.IsNullOrEmpty(absolutePath) && !IsFilesystemRoot(absolutePath))
|
||||
{
|
||||
var parent = Directory.GetParent(absolutePath);
|
||||
if (parent != null)
|
||||
{
|
||||
return parent.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(absolutePath) && Directory.Exists(absolutePath))
|
||||
{
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
var assetsPath = Application.dataPath;
|
||||
return Directory.Exists(assetsPath) ? assetsPath : Environment.CurrentDirectory;
|
||||
}
|
||||
|
||||
private static bool IsFilesystemRoot(string absolutePath)
|
||||
{
|
||||
var normalized = Path.GetFullPath(absolutePath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var root = Path.GetPathRoot(normalized)?
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return !string.IsNullOrEmpty(root) &&
|
||||
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void SyncFolderAssetFromPath()
|
||||
{
|
||||
_folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(_folderPath);
|
||||
}
|
||||
|
||||
private void RunValidation()
|
||||
{
|
||||
SaveFolderPreferences();
|
||||
_lastResult = _scanSubdirectories
|
||||
? YarnL10nValidator.ValidateScanAll(_folderPath)
|
||||
: YarnL10nValidator.ValidateFolder(_folderPath);
|
||||
RefreshFilterOptions();
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void RefreshFilterOptions()
|
||||
{
|
||||
if (_lastResult == null || _lastResult.Issues.Count == 0)
|
||||
{
|
||||
_typeOptions = new[] { "全部" };
|
||||
_languageOptions = new[] { "全部" };
|
||||
}
|
||||
else
|
||||
{
|
||||
var types = _lastResult.Issues
|
||||
.Select(issue => issue.IssueTypeLabel)
|
||||
.Where(label => !string.IsNullOrEmpty(label))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(label => label, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
types.Insert(0, "全部");
|
||||
_typeOptions = types.ToArray();
|
||||
|
||||
var languages = _lastResult.Issues
|
||||
.Select(issue => issue.Language)
|
||||
.Where(language => !string.IsNullOrEmpty(language))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(language => language, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
languages.Insert(0, "全部");
|
||||
_languageOptions = languages.ToArray();
|
||||
}
|
||||
|
||||
_filterTypeIndex = ClampFilterIndex(_filterType, _typeOptions, ref _filterType);
|
||||
_filterLanguageIndex = ClampFilterIndex(_filterLanguage, _languageOptions, ref _filterLanguage);
|
||||
}
|
||||
|
||||
private static int ClampFilterIndex(string currentValue, string[] options, ref string selectedValue)
|
||||
{
|
||||
if (options.Length == 0)
|
||||
{
|
||||
selectedValue = "全部";
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i < options.Length; i++)
|
||||
{
|
||||
if (string.Equals(options[i], currentValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
selectedValue = options[i];
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
selectedValue = options[0];
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void ExportReport()
|
||||
{
|
||||
var defaultName = YarnL10nReportExporter.BuildDefaultExportFileName(_folderPath);
|
||||
var savePath = EditorUtility.SaveFilePanel("导出校验报告", Application.dataPath, defaultName, "csv");
|
||||
if (string.IsNullOrEmpty(savePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
YarnL10nReportExporter.ExportToCsv(_lastResult, savePath);
|
||||
EditorUtility.RevealInFinder(savePath);
|
||||
}
|
||||
|
||||
private void PingFolder()
|
||||
{
|
||||
SyncFolderAssetFromPath();
|
||||
if (_folderAsset == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("Yarn 本地化校验", "无法定位文件夹,请检查路径。", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUIUtility.PingObject(_folderAsset);
|
||||
Selection.activeObject = _folderAsset;
|
||||
}
|
||||
|
||||
private void DrawSummary()
|
||||
{
|
||||
EditorGUILayout.LabelField("摘要", EditorStyles.boldLabel);
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
if (_lastResult == null)
|
||||
{
|
||||
EditorGUILayout.LabelField("尚未执行校验。");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"Issue 总数: {_lastResult.TotalIssueCount}");
|
||||
EditorGUILayout.LabelField($"章节数: {_lastResult.Chapters.Count}");
|
||||
|
||||
foreach (var chapter in _lastResult.Chapters)
|
||||
{
|
||||
var localeParts = chapter.Locales.Values
|
||||
.Select(locale => $"{locale.Language}: 缺 {locale.MissingCount} / 多余 {locale.OrphanCount} / 变更 {locale.SourceChangedCount}")
|
||||
.ToArray();
|
||||
var localeSummary = localeParts.Length > 0 ? string.Join(" | ", localeParts) : "无翻译语言";
|
||||
EditorGUILayout.LabelField($"{chapter.ChapterFolder} — 原文 {chapter.SourceLineCount} 行 | {localeSummary}");
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.Space(6);
|
||||
}
|
||||
|
||||
private void DrawFilters()
|
||||
{
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
EditorGUILayout.LabelField("类型", GUILayout.Width(40));
|
||||
_filterTypeIndex = EditorGUILayout.Popup(_filterTypeIndex, _typeOptions, GUILayout.Width(180));
|
||||
_filterType = _typeOptions[_filterTypeIndex];
|
||||
|
||||
EditorGUILayout.LabelField("语言", GUILayout.Width(40));
|
||||
_filterLanguageIndex = EditorGUILayout.Popup(_filterLanguageIndex, _languageOptions, GUILayout.Width(100));
|
||||
_filterLanguage = _languageOptions[_filterLanguageIndex];
|
||||
|
||||
EditorGUILayout.LabelField("搜索", GUILayout.Width(40));
|
||||
_searchText = EditorGUILayout.TextField(_searchText);
|
||||
EditorGUILayout.EndHorizontal();
|
||||
EditorGUILayout.Space(4);
|
||||
}
|
||||
|
||||
private void DrawIssueList()
|
||||
{
|
||||
EditorGUILayout.LabelField("结果列表", EditorStyles.boldLabel);
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
|
||||
|
||||
if (_lastResult == null || _lastResult.Issues.Count == 0)
|
||||
{
|
||||
EditorGUILayout.LabelField(_lastResult == null ? "无结果。" : "未发现 issue。");
|
||||
}
|
||||
else
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var issue in FilterIssues(_lastResult.Issues))
|
||||
{
|
||||
DrawIssueRow(issue, index);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
private IEnumerable<YarnL10nIssue> FilterIssues(IEnumerable<YarnL10nIssue> issues)
|
||||
{
|
||||
foreach (var issue in issues)
|
||||
{
|
||||
if (!MatchesFilter(issue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return issue;
|
||||
}
|
||||
}
|
||||
|
||||
private bool MatchesFilter(YarnL10nIssue issue)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_filterType) &&
|
||||
!string.Equals(_filterType, "全部", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(issue.IssueTypeLabel, _filterType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_filterLanguage) &&
|
||||
!string.Equals(_filterLanguage, "全部", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(issue.Language, _filterLanguage, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_searchText))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var haystack = string.Join(" ",
|
||||
issue.GetDisplayType(),
|
||||
issue.Language,
|
||||
issue.LineId,
|
||||
issue.SourceText,
|
||||
issue.LocText,
|
||||
issue.YarnFile,
|
||||
issue.ChapterFolder,
|
||||
issue.Message);
|
||||
|
||||
return haystack.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private void DrawIssueRow(YarnL10nIssue issue, int index)
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
EditorGUILayout.LabelField($"{issue.GetDisplayType()} | {issue.Language} | {issue.LineId}", EditorStyles.boldLabel);
|
||||
if (!string.IsNullOrEmpty(issue.ChapterFolder))
|
||||
{
|
||||
EditorGUILayout.LabelField("章节", issue.ChapterFolder);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.YarnFile))
|
||||
{
|
||||
EditorGUILayout.LabelField("源文件", $"{issue.YarnFile}:{issue.YarnLineNumber}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.CsvFile))
|
||||
{
|
||||
EditorGUILayout.LabelField("CSV", issue.CsvFile);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.SourceText))
|
||||
{
|
||||
EditorGUILayout.LabelField("原文", issue.SourceText);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.LocText))
|
||||
{
|
||||
EditorGUILayout.LabelField("翻译", issue.LocText);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.Message))
|
||||
{
|
||||
EditorGUILayout.LabelField("说明", issue.Message);
|
||||
}
|
||||
|
||||
EditorGUILayout.BeginHorizontal();
|
||||
if (GUILayout.Button("定位", GUILayout.Width(80)))
|
||||
{
|
||||
PingIssue(issue);
|
||||
}
|
||||
|
||||
EditorGUILayout.EndHorizontal();
|
||||
}
|
||||
}
|
||||
|
||||
private void PingIssue(YarnL10nIssue issue)
|
||||
{
|
||||
if (issue.Type == YarnL10nIssueType.OrphanLocalization && !string.IsNullOrEmpty(issue.CsvFile))
|
||||
{
|
||||
var csvAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(issue.CsvFile);
|
||||
if (csvAsset != null)
|
||||
{
|
||||
EditorGUIUtility.PingObject(csvAsset);
|
||||
Selection.activeObject = csvAsset;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.YarnFile))
|
||||
{
|
||||
var yarnPath = ResolveYarnAssetPath(issue);
|
||||
var yarnAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(yarnPath);
|
||||
if (yarnAsset != null)
|
||||
{
|
||||
EditorGUIUtility.PingObject(yarnAsset);
|
||||
Selection.activeObject = yarnAsset;
|
||||
|
||||
if (int.TryParse(issue.YarnLineNumber, out var lineNumber) && lineNumber > 0)
|
||||
{
|
||||
var absolutePath = YarnL10nValidator.ToAbsolutePath(yarnPath);
|
||||
InternalEditorUtility.OpenFileAtLineExternal(absolutePath, lineNumber);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.ChapterFolder))
|
||||
{
|
||||
var folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(issue.ChapterFolder);
|
||||
if (folderAsset != null)
|
||||
{
|
||||
EditorGUIUtility.PingObject(folderAsset);
|
||||
Selection.activeObject = folderAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveYarnAssetPath(YarnL10nIssue issue)
|
||||
{
|
||||
if (issue.YarnFile.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return issue.YarnFile;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(issue.ChapterFolder))
|
||||
{
|
||||
return $"{issue.ChapterFolder}/{issue.YarnFile}";
|
||||
}
|
||||
|
||||
return issue.YarnFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fe514a9bb460dd74e8b0ec7e1ae7cf98
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,412 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Yarn.Compiler;
|
||||
using Yarn.Unity;
|
||||
using Yarn.Unity.Editor;
|
||||
|
||||
namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public static class YarnL10nValidator
|
||||
{
|
||||
public static YarnL10nValidationResult ValidateFolder(string folderPath)
|
||||
{
|
||||
var result = new YarnL10nValidationResult();
|
||||
if (string.IsNullOrWhiteSpace(folderPath))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: folderPath,
|
||||
message: "章节文件夹路径为空。"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
var normalizedFolder = NormalizeProjectRelativePath(folderPath);
|
||||
var absoluteFolder = ToAbsolutePath(normalizedFolder);
|
||||
if (!Directory.Exists(absoluteFolder))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: normalizedFolder,
|
||||
message: $"文件夹不存在: {normalizedFolder}"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
var yarnProjects = Directory.GetFiles(absoluteFolder, "*.yarnproject", SearchOption.TopDirectoryOnly);
|
||||
if (yarnProjects.Length == 0)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: normalizedFolder,
|
||||
message: "文件夹内未找到 .yarnproject 文件。"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (yarnProjects.Length > 1)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: normalizedFolder,
|
||||
message: $"文件夹内存在多个 .yarnproject({yarnProjects.Length} 个)。"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
ValidateYarnProjectFile(yarnProjects[0], result);
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static YarnL10nValidationResult ValidateScanAll(string rootPath)
|
||||
{
|
||||
var result = new YarnL10nValidationResult();
|
||||
var normalizedRoot = NormalizeProjectRelativePath(rootPath);
|
||||
var absoluteRoot = ToAbsolutePath(normalizedRoot);
|
||||
if (!Directory.Exists(absoluteRoot))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: normalizedRoot,
|
||||
message: $"扫描根目录不存在: {normalizedRoot}"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
var yarnProjectFiles = Directory.GetFiles(absoluteRoot, "*.yarnproject", SearchOption.AllDirectories);
|
||||
if (yarnProjectFiles.Length == 0)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: normalizedRoot,
|
||||
message: "扫描范围内未找到任何 .yarnproject。"));
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var yarnProjectFile in yarnProjectFiles.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
ValidateYarnProjectFile(yarnProjectFile, result);
|
||||
}
|
||||
|
||||
FinalizeResult(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ValidateYarnProjectFile(string yarnProjectAbsolutePath, YarnL10nValidationResult result)
|
||||
{
|
||||
var chapterFolder = NormalizeProjectRelativePath(Path.GetDirectoryName(yarnProjectAbsolutePath));
|
||||
var yarnProjectRelativePath = NormalizeProjectRelativePath(yarnProjectAbsolutePath);
|
||||
|
||||
Project project;
|
||||
try
|
||||
{
|
||||
project = Project.LoadFromFile(yarnProjectAbsolutePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: chapterFolder,
|
||||
message: $"无法解析 .yarnproject: {ex.Message}"));
|
||||
return;
|
||||
}
|
||||
|
||||
var chapterSummary = new YarnL10nChapterSummary
|
||||
{
|
||||
ChapterFolder = chapterFolder,
|
||||
YarnProjectPath = yarnProjectRelativePath,
|
||||
};
|
||||
var issueStartIndex = result.Issues.Count;
|
||||
|
||||
if (project.Localisation == null || project.Localisation.Count == 0)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.NoLocalizationConfigured,
|
||||
chapterFolder: chapterFolder,
|
||||
message: "yarnproject 未配置 localisation。"));
|
||||
result.Chapters.Add(chapterSummary);
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceFiles = project.SourceFiles?.ToList() ?? new List<string>();
|
||||
if (sourceFiles.Count == 0)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: chapterFolder,
|
||||
message: "yarnproject 未包含任何 .yarn 源文件。"));
|
||||
result.Chapters.Add(chapterSummary);
|
||||
return;
|
||||
}
|
||||
|
||||
var job = CompilationJob.CreateFromFiles(sourceFiles);
|
||||
job.CompilationType = CompilationJob.Type.StringsOnly;
|
||||
var compilationResult = Compiler.Compile(job);
|
||||
|
||||
foreach (var diagnostic in compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.CompileError,
|
||||
chapterFolder: chapterFolder,
|
||||
message: diagnostic.Message));
|
||||
}
|
||||
|
||||
if (compilationResult.ContainsErrors)
|
||||
{
|
||||
result.Chapters.Add(chapterSummary);
|
||||
return;
|
||||
}
|
||||
|
||||
if (compilationResult.ContainsImplicitStringTags)
|
||||
{
|
||||
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.isImplicitTag))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.UntaggedLine,
|
||||
chapterFolder: chapterFolder,
|
||||
lineId: pair.Key,
|
||||
yarnFile: pair.Value.fileName,
|
||||
yarnLineNumber: pair.Value.lineNumber.ToString(),
|
||||
message: "存在未打 #line: 标签的可本地化行。"));
|
||||
}
|
||||
}
|
||||
|
||||
var baseEntries = BuildBaseEntries(project, compilationResult);
|
||||
chapterSummary.SourceLineCount = baseEntries.Count;
|
||||
|
||||
foreach (var localePair in project.Localisation)
|
||||
{
|
||||
var language = localePair.Key;
|
||||
if (string.Equals(language, project.BaseLanguage, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(localePair.Value.Strings))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (localePair.Value.Strings.StartsWith("unity:", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var localeSummary = new YarnL10nLocaleSummary { Language = language };
|
||||
chapterSummary.Locales[language] = localeSummary;
|
||||
|
||||
if (!project.TryGetStringsPath(language, out var csvRelativePath) ||
|
||||
string.IsNullOrEmpty(csvRelativePath))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.CsvMissing,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
message: $"未找到语言 {language} 的 CSV 路径。"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var csvAbsolutePath = ToAbsolutePath(csvRelativePath);
|
||||
if (!File.Exists(csvAbsolutePath))
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.CsvMissing,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
csvFile: csvRelativePath,
|
||||
message: $"本地化 CSV 不存在: {csvRelativePath}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
IEnumerable<StringTableEntry> translatedEntries;
|
||||
try
|
||||
{
|
||||
translatedEntries = StringTableEntry.ParseFromCSV(File.ReadAllText(csvAbsolutePath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.InvalidProject,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
csvFile: csvRelativePath,
|
||||
message: $"无法解析 CSV: {ex.Message}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
var translatedDictionary = translatedEntries
|
||||
.Where(entry => !string.IsNullOrEmpty(entry.ID))
|
||||
.GroupBy(entry => entry.ID)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
var baseIds = baseEntries.Keys.ToHashSet();
|
||||
var translatedIds = translatedDictionary.Keys.ToHashSet();
|
||||
|
||||
foreach (var missingId in baseIds.Except(translatedIds).OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
var baseEntry = baseEntries[missingId];
|
||||
localeSummary.MissingCount++;
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.MissingLocalization,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
lineId: missingId,
|
||||
sourceText: baseEntry.Text,
|
||||
yarnFile: baseEntry.File,
|
||||
yarnLineNumber: baseEntry.LineNumber));
|
||||
}
|
||||
|
||||
foreach (var orphanId in translatedIds.Except(baseIds).OrderBy(id => id, StringComparer.Ordinal))
|
||||
{
|
||||
var translatedEntry = translatedDictionary[orphanId];
|
||||
localeSummary.OrphanCount++;
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.OrphanLocalization,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
lineId: orphanId,
|
||||
locText: translatedEntry.Text,
|
||||
csvFile: csvRelativePath));
|
||||
}
|
||||
|
||||
foreach (var sharedId in baseIds.Intersect(translatedIds))
|
||||
{
|
||||
var baseEntry = baseEntries[sharedId];
|
||||
var translatedEntry = translatedDictionary[sharedId];
|
||||
if (string.IsNullOrEmpty(baseEntry.Lock) || string.IsNullOrEmpty(translatedEntry.Lock))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (baseEntry.Lock == translatedEntry.Lock)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
localeSummary.SourceChangedCount++;
|
||||
AddIssue(result, CreateIssue(
|
||||
YarnL10nIssueType.SourceTextChanged,
|
||||
chapterFolder: chapterFolder,
|
||||
language: language,
|
||||
lineId: sharedId,
|
||||
sourceText: baseEntry.Text,
|
||||
locText: translatedEntry.Text,
|
||||
yarnFile: baseEntry.File,
|
||||
yarnLineNumber: baseEntry.LineNumber,
|
||||
csvFile: csvRelativePath,
|
||||
message: "原文 Lock 与翻译 CSV 不一致,翻译可能已过期。"));
|
||||
}
|
||||
}
|
||||
|
||||
chapterSummary.IssueCount = result.Issues.Count - issueStartIndex;
|
||||
result.Chapters.Add(chapterSummary);
|
||||
}
|
||||
|
||||
private static Dictionary<string, StringTableEntry> BuildBaseEntries(Project project, CompilationResult compilationResult)
|
||||
{
|
||||
var entries = new Dictionary<string, StringTableEntry>(StringComparer.Ordinal);
|
||||
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.text != null))
|
||||
{
|
||||
var entry = new StringTableEntry
|
||||
{
|
||||
ID = pair.Key,
|
||||
Language = project.BaseLanguage ?? "zh-Hans",
|
||||
Text = pair.Value.text,
|
||||
File = pair.Value.fileName,
|
||||
Node = pair.Value.nodeName,
|
||||
LineNumber = pair.Value.lineNumber.ToString(),
|
||||
Lock = YarnImporter.GetHashString(pair.Value.text!, 8),
|
||||
};
|
||||
entries[pair.Key] = entry;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static YarnL10nIssue CreateIssue(
|
||||
YarnL10nIssueType type,
|
||||
string chapterFolder = "",
|
||||
string language = "",
|
||||
string lineId = "",
|
||||
string sourceText = "",
|
||||
string locText = "",
|
||||
string yarnFile = "",
|
||||
string yarnLineNumber = "",
|
||||
string csvFile = "",
|
||||
string message = "")
|
||||
{
|
||||
return new YarnL10nIssue
|
||||
{
|
||||
Type = type,
|
||||
ChapterFolder = chapterFolder ?? string.Empty,
|
||||
Language = language ?? string.Empty,
|
||||
LineId = lineId ?? string.Empty,
|
||||
SourceText = sourceText ?? string.Empty,
|
||||
LocText = locText ?? string.Empty,
|
||||
YarnFile = yarnFile ?? string.Empty,
|
||||
YarnLineNumber = yarnLineNumber ?? string.Empty,
|
||||
CsvFile = csvFile ?? string.Empty,
|
||||
Message = message ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddIssue(YarnL10nValidationResult result, YarnL10nIssue issue)
|
||||
{
|
||||
result.Issues.Add(issue);
|
||||
}
|
||||
|
||||
private static void FinalizeResult(YarnL10nValidationResult result)
|
||||
{
|
||||
result.TotalIssueCount = result.Issues.Count;
|
||||
}
|
||||
|
||||
public static string NormalizeProjectRelativePath(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = path.Replace('\\', '/').Trim();
|
||||
var projectRoot = GetProjectRootPath().Replace('\\', '/');
|
||||
if (Path.IsPathRooted(normalized))
|
||||
{
|
||||
var absolute = Path.GetFullPath(normalized).Replace('\\', '/');
|
||||
if (absolute.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = absolute.Substring(projectRoot.Length).TrimStart('/');
|
||||
}
|
||||
else
|
||||
{
|
||||
normalized = absolute;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.TrimEnd('/');
|
||||
}
|
||||
|
||||
public static string ToAbsolutePath(string projectRelativeOrAbsolutePath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(projectRelativeOrAbsolutePath))
|
||||
{
|
||||
return GetProjectRootPath();
|
||||
}
|
||||
|
||||
if (Path.IsPathRooted(projectRelativeOrAbsolutePath))
|
||||
{
|
||||
return Path.GetFullPath(projectRelativeOrAbsolutePath);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Path.Combine(GetProjectRootPath(), projectRelativeOrAbsolutePath));
|
||||
}
|
||||
|
||||
private static string GetProjectRootPath()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, ".."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ea78060f3c3448d4aa9b19b8b55fb843
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public static class YarnL10nValidatorCli
|
||||
{
|
||||
private const string JsonBeginMarker = "YARN_L10N_JSON_BEGIN";
|
||||
private const string JsonEndMarker = "YARN_L10N_JSON_END";
|
||||
|
||||
public static void Run()
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
var path = GetArgumentValue(args, "-yarnL10nPath");
|
||||
var scanAll = HasFlag(args, "-yarnL10nScanAll");
|
||||
var exportPath = GetArgumentValue(args, "-yarnL10nExport");
|
||||
var outputPath = GetArgumentValue(args, "-yarnL10nOutput");
|
||||
if (string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
outputPath = "Build/yarn_l10n_result.json";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
WriteFailure("缺少参数 -yarnL10nPath。", 2);
|
||||
return;
|
||||
}
|
||||
|
||||
var validationResult = scanAll
|
||||
? YarnL10nValidator.ValidateScanAll(path)
|
||||
: YarnL10nValidator.ValidateFolder(path);
|
||||
|
||||
var json = JsonConvert.SerializeObject(validationResult, Formatting.Indented);
|
||||
WriteJson(json, outputPath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(exportPath))
|
||||
{
|
||||
var absoluteExportPath = YarnL10nValidator.ToAbsolutePath(exportPath);
|
||||
YarnL10nReportExporter.ExportToCsv(validationResult, absoluteExportPath);
|
||||
}
|
||||
|
||||
var exitCode = validationResult.HasBlockingIssues ? 1 : 0;
|
||||
Debug.Log($"[YarnL10nValidatorCli] Completed with exit code {exitCode}, issues={validationResult.TotalIssueCount}");
|
||||
EditorApplication.Exit(exitCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteFailure(ex.ToString(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteJson(string json, string outputPath)
|
||||
{
|
||||
Console.WriteLine(JsonBeginMarker);
|
||||
Console.WriteLine(json);
|
||||
Console.WriteLine(JsonEndMarker);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
{
|
||||
var absoluteOutputPath = YarnL10nValidator.ToAbsolutePath(outputPath);
|
||||
var directory = Path.GetDirectoryName(absoluteOutputPath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(absoluteOutputPath, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
|
||||
Debug.Log($"[YarnL10nValidatorCli] Wrote JSON to {absoluteOutputPath}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFailure(string message, int exitCode)
|
||||
{
|
||||
Debug.LogError($"[YarnL10nValidatorCli] {message}");
|
||||
Console.Error.WriteLine(message);
|
||||
EditorApplication.Exit(exitCode);
|
||||
}
|
||||
|
||||
private static bool HasFlag(string[] args, string flag)
|
||||
{
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string GetArgumentValue(string[] args, string key)
|
||||
{
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
if (arg.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return arg.Substring(key.Length + 1).Trim('"');
|
||||
}
|
||||
|
||||
if (string.Equals(arg, key, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
|
||||
{
|
||||
return args[i + 1].Trim('"');
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23836ef03028055439615fda97642c4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user