Compare commits
888
Commits
琪琳维修原型
...
feature/本地化
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cda02f02b | ||
|
|
61a2e779df | ||
|
|
97ae87372d | ||
|
|
61fb8820a3 | ||
|
|
ac0b20a3a8 | ||
|
|
8f2ee603d5 | ||
|
|
a48e4babd4 | ||
|
|
8671179ef4 | ||
|
|
ca5b714124 | ||
|
|
c735f3084e | ||
|
|
e881c9e710 | ||
|
|
3de3f1a2e2 | ||
|
|
fe11a89b2d | ||
|
|
5b02636572 | ||
|
|
9386af3155 | ||
|
|
62586a2ec9 | ||
|
|
a498fcb7f0 | ||
|
|
ddeeeebf25 | ||
|
|
c4f3e79c23 | ||
|
|
b091cf0d15 | ||
|
|
0466649aa4 | ||
|
|
114b90eaca | ||
|
|
6a4401b3f7 | ||
|
|
f1a0e4fe0e | ||
|
|
39ab86fd05 | ||
|
|
4d0f6954ab | ||
|
|
008817269f | ||
|
|
5fa12aee74 | ||
|
|
2fce9d4c8d | ||
|
|
9bfa990e33 | ||
|
|
03d9631ce4 | ||
|
|
7371848889 | ||
|
|
1eeb2eb6d9 | ||
|
|
4605fc3215 | ||
|
|
85c7e54f53 | ||
|
|
218b3939ff | ||
|
|
a9063726a9 | ||
|
|
71043c108c | ||
|
|
e803f9f8b6 | ||
|
|
90c1c34d68 | ||
|
|
093ce6886d | ||
|
|
7961ebbd79 | ||
|
|
738f112a1d | ||
|
|
9904e91145 | ||
|
|
e1c8ba4677 | ||
|
|
07bfd9020a | ||
|
|
608d87725f | ||
|
|
b013ced409 | ||
|
|
22a0d7377d | ||
|
|
2902c554a6 | ||
|
|
21592b3c73 | ||
|
|
3b49326762 | ||
|
|
3155b9b9f9 | ||
|
|
80e965b274 | ||
|
|
8dc27144c6 | ||
|
|
5b4d6e3d42 | ||
|
|
17f07006fa | ||
|
|
76c8db7f62 | ||
|
|
4c4888b14c | ||
|
|
53f0019205 | ||
|
|
35bac7c59b | ||
|
|
064d6f83c6 | ||
|
|
37048c1206 | ||
|
|
b242c05e06 | ||
|
|
223bcc8c61 | ||
|
|
65735dec52 | ||
|
|
a7a8d082e3 | ||
|
|
d1a332e9c8 | ||
|
|
45fb678f39 | ||
|
|
9f509a2a66 | ||
|
|
f608fec8ca | ||
|
|
1fb2e63866 | ||
|
|
f8fc8bd597 | ||
|
|
bc4475ae66 | ||
|
|
bb11478605 | ||
|
|
31e6ae87be | ||
|
|
9bfe9ca231 | ||
|
|
1a6184dc82 | ||
|
|
ca345a51c6 | ||
|
|
b38343e427 | ||
|
|
aa2b2d2e65 | ||
|
|
3e6fab9762 | ||
|
|
c0e7f2ddb4 | ||
|
|
05df341bcd | ||
|
|
1bdcacd2ff | ||
|
|
ec8246a193 | ||
|
|
1b87f400ae | ||
|
|
f28fc76bb1 | ||
|
|
f91579ba8a | ||
|
|
7fe63f77f3 | ||
|
|
55ffaf494f | ||
|
|
206018e8e6 | ||
|
|
90601ac6a2 | ||
|
|
0ef54b04cb | ||
|
|
f0e85493d7 | ||
|
|
81b0828ba9 | ||
|
|
63c884b591 | ||
|
|
0c6575b4d3 | ||
|
|
af36da918c | ||
|
|
615963e535 | ||
|
|
a98d230e05 | ||
|
|
8b7a4b7bf2 | ||
|
|
bae1d0473b | ||
|
|
1e56201ce8 | ||
|
|
4b7887cd17 | ||
|
|
784801b500 | ||
|
|
e8940ca7da | ||
|
|
90f075d51f | ||
|
|
ae5ba2c913 | ||
|
|
d4673bf851 | ||
|
|
f78da1de4d | ||
|
|
8c4c99aecf | ||
|
|
71a771d56c | ||
|
|
6591001416 | ||
|
|
d4c8b8db64 | ||
|
|
76055e8554 | ||
|
|
45d71a2d40 | ||
|
|
95a1a86b71 | ||
|
|
305d6a71f2 | ||
|
|
add9891934 | ||
|
|
9dab57ab87 | ||
|
|
046f262b11 | ||
|
|
75de932ea0 | ||
|
|
b578ea45cf | ||
|
|
5f83618c3a | ||
|
|
c57a2d2950 | ||
|
|
cd3343457b | ||
|
|
efe9c5baaa | ||
|
|
d582e26a83 | ||
|
|
02967a5468 | ||
|
|
d06a67bcb1 | ||
|
|
a9bc442bc8 | ||
|
|
a177fc8b16 | ||
|
|
82c15e8f48 | ||
|
|
ee7cfaa1c1 | ||
|
|
bb207573b2 | ||
|
|
3cdedb155c | ||
|
|
05e074b19a | ||
|
|
196c4e5c07 | ||
|
|
0e0cc66546 | ||
|
|
4caf70b554 | ||
|
|
0a1adf1558 | ||
|
|
681ac01eb9 | ||
|
|
528d683929 | ||
|
|
630d9627b3 | ||
|
|
4be8b0ece7 | ||
|
|
3506fa9f2b | ||
|
|
29b02c57b1 | ||
|
|
2dafeb8432 | ||
|
|
52fe5c9485 | ||
|
|
0aae59262a | ||
|
|
3c27b0ef03 | ||
|
|
cd1fbe4e02 | ||
|
|
6eaa4545bb | ||
|
|
6a029bfeb4 | ||
|
|
3ed8999bbb | ||
|
|
56eab46b0c | ||
|
|
d5c0842fd9 | ||
|
|
c16e9d06ee | ||
|
|
cd63b44ed6 | ||
|
|
5f14efe12a | ||
|
|
aa912c045c | ||
|
|
d81c1ea76d | ||
|
|
0e1604d18b | ||
|
|
166b0ac4af | ||
|
|
08594fae10 | ||
|
|
3951e06bc1 | ||
|
|
abfe710985 | ||
|
|
d091c47b5a | ||
|
|
f37f24ccd0 | ||
|
|
be1ab7ed80 | ||
|
|
0d40b8db14 | ||
|
|
783ef340f5 | ||
|
|
2d0c45f2e7 | ||
|
|
dae9aa100e | ||
|
|
e9792776a9 | ||
|
|
d62124d72b | ||
|
|
971b106c81 | ||
|
|
dd1e2b1d94 | ||
|
|
d4429deddc | ||
|
|
2db69b1cff | ||
|
|
78d01dfe8f | ||
|
|
84cfe459de | ||
|
|
cfacff9943 | ||
|
|
0a489b1250 | ||
|
|
2808535688 | ||
|
|
a1afc021f1 | ||
|
|
b0c6068ea8 | ||
|
|
1017ff4e90 | ||
|
|
6977aca95d | ||
|
|
042785e3dc | ||
|
|
2c6ac3e142 | ||
|
|
bee4e0c796 | ||
|
|
e2461ff0e5 | ||
|
|
1620c25f82 | ||
|
|
ed19d28a9c | ||
|
|
5f7986bb82 | ||
|
|
26f917bba5 | ||
|
|
fc530e77b4 | ||
|
|
f557f10b02 | ||
|
|
5fb5e51992 | ||
|
|
b315bf144b | ||
|
|
886ed05ab0 | ||
|
|
7e037bbe9b | ||
|
|
11384d763b | ||
|
|
3e445e9977 | ||
|
|
da7821cde5 | ||
|
|
cfd8cda709 | ||
|
|
96796f6b80 | ||
|
|
5faa81c3c2 | ||
|
|
dfdb75f833 | ||
|
|
c368ff416d | ||
|
|
1e56b83e06 | ||
|
|
9c30561ed8 | ||
|
|
ce8486c1eb | ||
|
|
f679985369 | ||
|
|
43bd3ece5b | ||
|
|
4cf8febec2 | ||
|
|
2384ef80f2 | ||
|
|
dcfd013db8 | ||
|
|
c6bcd0b798 | ||
|
|
d0904efc20 | ||
|
|
1072b65a00 | ||
|
|
7f087d89b6 | ||
|
|
590c1576e2 | ||
|
|
94e27b4d11 | ||
|
|
f3cff824ce | ||
|
|
e6ae903c03 | ||
|
|
a3f3fadaba | ||
|
|
ce468ea9cc | ||
|
|
aac0fd87a4 | ||
|
|
84f5cf2346 | ||
|
|
377271a5d5 | ||
|
|
8a68e0676b | ||
|
|
2cec5dd66c | ||
|
|
15f5c34db5 | ||
|
|
13d7c305c8 | ||
|
|
03a71b4ba3 | ||
|
|
63820cae8d | ||
|
|
1113e5707b | ||
|
|
075d978b4d | ||
|
|
2d3cef3875 | ||
|
|
e3a69ea0f5 | ||
|
|
f55cd31f6f | ||
|
|
5a59d6c876 | ||
|
|
2ef6db54da | ||
|
|
4a9f487db9 | ||
|
|
360293941a | ||
|
|
28e4214413 | ||
|
|
33ec00ddb8 | ||
|
|
07f60c29c4 | ||
|
|
cd2c7f6406 | ||
|
|
7e89b2cb3e | ||
|
|
1ba1a007a8 | ||
|
|
132098f70c | ||
|
|
5ef26b6259 | ||
|
|
a4fec41afb | ||
|
|
c254d39fd7 | ||
|
|
d7e2e8e690 | ||
|
|
e4697e2462 | ||
|
|
9a3cb3f054 | ||
|
|
2c74e7d5e0 | ||
|
|
8722c92706 | ||
|
|
1fdca74bfb | ||
|
|
d82a4a3606 | ||
|
|
0825204666 | ||
|
|
1af6f0b47d | ||
|
|
0e0ed2ff7a | ||
|
|
2ae6f712a4 | ||
|
|
b20b936966 | ||
|
|
4a0e3ba0b9 | ||
|
|
2a1cfbc208 | ||
|
|
e3762e5462 | ||
|
|
5029a4b9b5 | ||
|
|
3debdb39e4 | ||
|
|
04edf9b355 | ||
|
|
6854679bcb | ||
|
|
515f57e28c | ||
|
|
97ef0972fd | ||
|
|
2fb32c44e8 | ||
|
|
3d65941b03 | ||
|
|
5091851ead | ||
|
|
34d2ac40eb | ||
|
|
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 | ||
|
|
982a1eedbe | ||
|
|
a5ec4bb413 | ||
|
|
4ec5e0b442 | ||
|
|
4d44ca387e | ||
|
|
1171ad9e87 | ||
|
|
1318636bee | ||
|
|
f17a8ab8dd | ||
|
|
b70f2614af | ||
|
|
517f4d956a | ||
|
|
46c47ea4be | ||
|
|
41d3e07a55 | ||
|
|
d10dfc1892 | ||
|
|
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 | ||
|
|
a97b00072a | ||
|
|
06be1d6eef | ||
|
|
6dbd6917f9 | ||
|
|
57e20d0dce | ||
|
|
29a1c7d7cb | ||
|
|
e6b24556b5 | ||
|
|
04b441d977 | ||
|
|
488149e8d4 | ||
|
|
7a14cc52e9 | ||
|
|
cc069b1da3 | ||
|
|
2fcb998e6f | ||
|
|
140e05b4d1 | ||
|
|
07d330f6db | ||
|
|
4f6f5a1ae9 | ||
|
|
db73b64980 | ||
|
|
ece0d4ed8e | ||
|
|
268bdd19d9 | ||
|
|
40358dd8a1 | ||
|
|
f42ec20dc8 | ||
|
|
ca3d4cb3cb | ||
|
|
814b659c39 | ||
|
|
c392c81d31 | ||
|
|
681fe5fc98 | ||
|
|
721e82d768 | ||
|
|
296ce4d657 | ||
|
|
2389c9ef3e | ||
|
|
7e21d33cdd | ||
|
|
d2ecd1d846 | ||
|
|
f25674a043 | ||
|
|
d1eab5612d | ||
|
|
1e60b0005e | ||
|
|
6d262a8796 | ||
|
|
32d44b8119 | ||
|
|
7edda0bd6e | ||
|
|
2c9f609616 | ||
|
|
c0325b0a35 | ||
|
|
228f91e8ab | ||
|
|
a36dc7c003 | ||
|
|
e75bd5e552 | ||
|
|
05855e2a9f | ||
|
|
f188f4c1fc | ||
|
|
870bf96c0f | ||
|
|
67f3abd825 | ||
|
|
3775aed368 | ||
|
|
08b8f34bde | ||
|
|
e41756d0f0 | ||
|
|
2d43af4827 | ||
|
|
0f681b518e | ||
|
|
382b784605 | ||
|
|
5efb2ff157 | ||
|
|
43465e8f34 | ||
|
|
bf416f604b | ||
|
|
a5aa37ffcc | ||
|
|
009776eb32 | ||
|
|
b1fdec64e1 | ||
|
|
43c3f8531d | ||
|
|
5a0b7a047b | ||
|
|
e90da7bab7 | ||
|
|
b53a3f45b7 | ||
|
|
e4ec8fce57 | ||
|
|
d780ff59ee | ||
|
|
57b03aeb08 | ||
|
|
b7bfa28fd7 | ||
|
|
2a0c34fd7a | ||
|
|
3746f4aeb6 | ||
|
|
5cae227fec | ||
|
|
d88f794759 | ||
|
|
67968b0102 | ||
|
|
d2c7837a74 | ||
|
|
07850fcf1c | ||
|
|
0368374a98 | ||
|
|
749b9974c0 | ||
|
|
a434ffd7e0 | ||
|
|
a080f507d1 | ||
|
|
50566a1d42 | ||
|
|
67668f18e0 | ||
|
|
e2bdd8e0fa | ||
|
|
6a289e0255 | ||
|
|
1fe4c44f0c | ||
|
|
3cee30f099 | ||
|
|
4d8707cf4e | ||
|
|
fbb9e65338 | ||
|
|
0b0d22d009 | ||
|
|
0a6e892504 | ||
|
|
de671ff94c | ||
|
|
fc4b1c8805 | ||
|
|
29e698c370 | ||
|
|
b30010a459 | ||
|
|
181eeb679b | ||
|
|
a633b47d5b | ||
|
|
c55220d739 | ||
|
|
2796f96215 | ||
|
|
e01c8af449 | ||
|
|
748c875e93 | ||
|
|
afd062bda2 | ||
|
|
d6912fb1a6 | ||
|
|
6fcf340230 | ||
|
|
cc3e1dd138 | ||
|
|
789ca207bd | ||
|
|
4bb0215f3d | ||
|
|
c67d988365 | ||
|
|
13e51a86e6 | ||
|
|
2cd3a1a7df | ||
|
|
2c1e5d8fa7 | ||
|
|
1065e2b8b9 | ||
|
|
8e5346da46 | ||
|
|
2c89feec9d | ||
|
|
a33d6a9fe1 | ||
|
|
33a028f071 |
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: "source-command-split-commit"
|
||||
description: "将当前未提交改动拆分为多个符合规范的原子提交"
|
||||
---
|
||||
|
||||
# source-command-split-commit
|
||||
|
||||
Use this skill when the user asks to run the migrated source command `split-commit`.
|
||||
|
||||
## Command Template
|
||||
|
||||
将当前未提交改动拆分为多个原子提交。执行前会分析变更、生成分组方案,经用户确认后逐组提交。
|
||||
|
||||
## 执行流程
|
||||
|
||||
### Step 1: 前置检查
|
||||
|
||||
1. **清理残留文件**:若存在 `msg.txt`,先 `rm -f msg.txt`
|
||||
2. **读取规范**:确认 `COMMIT_CONVENTION.md` 存在且可读
|
||||
|
||||
### Step 2: 保护分支检查
|
||||
|
||||
保护分支列表(按优先级):`AGENTS.md` > `.cursor/rules/` > `.git/config branch.protected` > 默认(`master|main|develop`)
|
||||
|
||||
若当前在保护分支,询问用户:
|
||||
- **A** - 创建 feature 分支后继续拆分(推荐)
|
||||
- **B** - 转移到新分支后压成单提交
|
||||
- **C** - 取消
|
||||
|
||||
### Step 3: 盘点变更
|
||||
|
||||
执行以下命令分析:
|
||||
- `git status` - 整体状态
|
||||
- `git diff --stat` - 文件统计
|
||||
- `git diff` - 详细改动
|
||||
- `git diff --cached` - 已暂存改动
|
||||
|
||||
### Step 4: 规则化分组
|
||||
|
||||
按"意图一致"原则分组,**优先按 type 分,再按 scope 分**。
|
||||
|
||||
**必须拆分的情况**:
|
||||
- 同一组可归属多个 type
|
||||
- 同文件包含多个独立目的(如功能+重构)
|
||||
- 资源替换与代码逻辑混合
|
||||
|
||||
**可以合并的情况**(最小拆分原则):
|
||||
- 同一功能的多文件改动(脚本+Prefab)
|
||||
- 同一模块的多个相关 Bug 修复
|
||||
|
||||
### Step 5: 生成方案表格
|
||||
|
||||
以表格形式展示拆分方案:
|
||||
|
||||
| 顺序 | 文件 | 变更目的 | 提交头 | 依据 | 方式 |
|
||||
|------|------|----------|--------|------|------|
|
||||
| 1 | `路径` | 描述 | `type(scope): subject` | 原因 | `git add`/`git add -p` |
|
||||
|
||||
**Type 选择优先级**:`fix` > `feat` > `art` > `audio` > `scene` > `yarn` > `perf|refactor|...`
|
||||
|
||||
**Scope 规则**:单模块用模块名(如 `blockpuzzle`),跨模块省略。
|
||||
|
||||
### Step 6: 用户确认
|
||||
|
||||
询问:`[Y 执行 / n 取消 / 提出调整]`
|
||||
|
||||
- 若调整:根据反馈修改方案,再次确认
|
||||
- 若执行:进入 Step 7
|
||||
- 若取消:清理并退出
|
||||
|
||||
### Step 7: 逐组提交
|
||||
|
||||
对每组执行:
|
||||
1. `git add <file>` 或 `git add -p <file>`
|
||||
2. 复查:`git diff --cached --stat` 和 `git diff --cached`
|
||||
3. 写入 `msg.txt` 并提交:`git commit -F msg.txt`
|
||||
4. 检查状态:`git status`
|
||||
|
||||
**失败处理**:
|
||||
- `git add -p` 跳过关键 hunk → 暂停,提示处理方案
|
||||
- pre-commit hook 失败 → 询问 [R]重试 [S]跳过 [A]中止
|
||||
|
||||
### Step 8: 结果校验
|
||||
|
||||
输出:
|
||||
1. `git log --oneline -<N>`
|
||||
2. 回放清单(验证 type/scope/subject 合规性)
|
||||
3. 若不合规,询问是否修正
|
||||
|
||||
### Step 9: 清理
|
||||
|
||||
`rm -f msg.txt`
|
||||
|
||||
---
|
||||
|
||||
## Unity 改动速查表
|
||||
|
||||
| 改动类型 | type | scope 示例 |
|
||||
|----------|------|-----------|
|
||||
| 脚本逻辑修复 | `fix` | `blockpuzzle`, `dialog` |
|
||||
| 脚本新功能 | `feat` | `huoshan`, `clue` |
|
||||
| 美术资源(贴图/模型/动画) | `art` | 对应模块或省略 |
|
||||
| 音频资源 | `audio` | `audio-kit` 或省略 |
|
||||
| 场景配置(灯光/镜头) | `scene` | 对应模块或省略 |
|
||||
| Yarn 对话 | `yarn` | `dialog` |
|
||||
| 测试代码 | `test` | 对应模块 |
|
||||
|
||||
---
|
||||
|
||||
## 规范校验清单(执行前检查)
|
||||
|
||||
- [ ] 格式:`<type>(<scope>): <subject>` 或 `<type>: <subject>`
|
||||
- [ ] type ∈ {`feat`,`fix`,`art`,`audio`,`scene`,`yarn`,`perf`,`refactor`,`style`,`docs`,`build`,`chore`,`test`}
|
||||
- [ ] subject:中文、动词开头、≤50字、无句号
|
||||
- [ ] 一次提交只做一件事
|
||||
|
||||
---
|
||||
|
||||
## 中文提交方法
|
||||
|
||||
由于 PowerShell 中文乱码问题,统一使用文件方式:
|
||||
|
||||
```bash
|
||||
echo "type(scope): 描述" > msg.txt
|
||||
git commit -F msg.txt
|
||||
rm -f msg.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关于 Pre-commit Hooks
|
||||
|
||||
若配置了 husky/lint-staged:
|
||||
- 每次提交都会触发检查
|
||||
- 如需跳过(**不推荐**):`git commit -F msg.txt --no-verify`
|
||||
- 跳过可能导致 CI 失败
|
||||
|
||||
---
|
||||
|
||||
开始执行请回复 **"开始"**,或告知需要的调整。
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: yarn-text-audit
|
||||
description: 全量审校 Yarn 文本,生成可追溯问题台账并可导出飞书多维表格
|
||||
---
|
||||
|
||||
对用户指定的 Yarn 目录执行全量文本审校。确定性覆盖与安全门禁由 `Tools/yarn_text_audit.py` 提供;语言和叙事判断必须保持可复核。
|
||||
|
||||
## 执行流程
|
||||
|
||||
1. 明确目标目录、参考目录、是否包含注释、是否只出报告。
|
||||
2. 对所有目标目录运行:
|
||||
|
||||
```powershell
|
||||
python Tools/yarn_text_audit.py scan `
|
||||
--root <Yarn目录,可重复> `
|
||||
--output Temp/YarnTextAudit `
|
||||
--review-memory <可选:上次飞书审阅导出的 reviewed-base.json>
|
||||
```
|
||||
|
||||
3. 检查 `Temp/YarnTextAudit/summary.json`,必须满足:
|
||||
- `unknown_active_lines == 0`
|
||||
- `unreviewed_records == 0`
|
||||
- `active_records == ledger.jsonl` 实际行数
|
||||
4. 分文件、分节点复核 `ledger.jsonl`。活动文本包括命名对白、无角色旁白、系统文本、选项和 option prompt;排除首个非空白字符为 `//` 的注释、节点元数据和 Yarn 命令。
|
||||
5. 将确定性错误标为“建议修改”,语义、节奏、专名和重要信息等判断标为“需确认”;不以自动规则冒充绝对正确。
|
||||
6. 交付 `issues.csv` 和 `coverage.csv`。用户要求飞书时,先运行 `python Tools/yarn_text_audit.py lark-payloads --output Temp/YarnTextAudit`,再创建 `问题清单`、`覆盖报告`、`术语与样式` 三张表;单批不超过 200 条并核对返回数量。
|
||||
7. 用户完成多维表格审阅后,导出完整记录并执行:
|
||||
|
||||
```powershell
|
||||
python Tools/yarn_text_audit.py apply-review `
|
||||
--reviews Temp/YarnTextAudit/reviewed-base.json `
|
||||
--manifest Temp/YarnTextAudit/manifest.json `
|
||||
--report Temp/YarnTextAudit/apply-report.json
|
||||
|
||||
# 预览无误后才实际写入
|
||||
python Tools/yarn_text_audit.py apply-review `
|
||||
--reviews Temp/YarnTextAudit/reviewed-base.json `
|
||||
--manifest Temp/YarnTextAudit/manifest.json `
|
||||
--report Temp/YarnTextAudit/apply-report.json `
|
||||
--apply
|
||||
```
|
||||
|
||||
8. 修改后重新扫描并运行 `verify`。不得新增、重生成或修改 `#line:` 标签;不得增加文本显示行数。
|
||||
|
||||
## 人工反馈优先级
|
||||
|
||||
- 后续扫描使用 `--review-memory`;脚本会按原文或已应用后的预期文本重建稳定 ID,不重复报告已经人工决策的记录。
|
||||
- “按照备注修改”高于分类和脚本建议;只执行备注明确要求的断行、颜色、文字或效果。
|
||||
- 不因 `Task` 说话者、`role="Aside"` 或专有名词出现本身自动判定颜色。必须结合是否为玩家行动目标、真实内心表达及当前叙事重点逐处判断。
|
||||
- 专有名词不要求每次出现都标蓝;检查/拆除/短接/装回等玩家下一步操作可以按上下文标黄。
|
||||
- 笑话效果优先只包裹双关或谐音落点,不默认包裹整句。
|
||||
- `#autonext` 不应一律修正为 `#auto_next`;若审阅备注要求移除,则直接移除。
|
||||
- 长行断点以约 18 个全角字符的实际 UI 宽度估算自动折行数,优先在逗号处显式控制;已有断行未经批准不重排。
|
||||
|
||||
## AIBIS 审校规则
|
||||
|
||||
- 检查错别字、错误重复、缺字候选、的地得、全半角和标点、标签配对及换行舒适度。
|
||||
- 换行优先考虑逗号附近;只有在不增加显示行数时才建议调整。逗号停顿只用于调整后的断点或明确节奏位置。
|
||||
- `[[aside]]...[[/aside]]`:实际自言自语、念文本或小声说话;斜体本身不是充分条件。
|
||||
- `[[term]]...[[/term]]`:游戏专有名词,默认蓝色 `#52B6FF`。
|
||||
- `[[key]]...[[/key]]`:玩家必须关注的信息,优先级高于专名。
|
||||
- `[[comma-pause]]`:可配置的 0.2 秒逗号停顿。
|
||||
- `[[ulm]]`:FP_Peipei2 中每条活动 `ULM自训练进程:` 文本。
|
||||
- `[[joke]]...[[/joke]]`:火山实际讲出的笑话,不包括讨论笑话的说明对白。
|
||||
- 本地输出放入已忽略的 `Temp/YarnTextAudit`;修改前保存文件 SHA-256,应用时必须精确匹配原文与哈希。
|
||||
|
||||
## 自检
|
||||
|
||||
```powershell
|
||||
python Tools/yarn_text_audit.py self-test
|
||||
python Tools/yarn_text_audit.py verify --output Temp/YarnTextAudit
|
||||
```
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -110,4 +110,6 @@ fmod_editor.log
|
||||
.history/*
|
||||
**/.history/*
|
||||
|
||||
.fake
|
||||
.fake
|
||||
Keystore/aibis-release-key.keystore
|
||||
Keystore/keystore.bat
|
||||
|
||||
Vendored
+65
-1
@@ -56,6 +56,70 @@
|
||||
"temp/": true,
|
||||
"Temp/": true
|
||||
},
|
||||
"files.associations": {
|
||||
"**/Plugins/Android/*Template.gradle": "plaintext"
|
||||
},
|
||||
"dotnet.defaultSolution": "aibis-dream.sln",
|
||||
"dotnet.preferCSharpExtension": true
|
||||
"dotnet.preferCSharpExtension": true,
|
||||
"cSpell.enableFiletypes": [
|
||||
"yarnspinner"
|
||||
],
|
||||
"cSpell.languageSettings": [
|
||||
{
|
||||
"languageId": "yarnspinner",
|
||||
"patterns": [
|
||||
{
|
||||
"name": "yarn-commands",
|
||||
"pattern": "/<<[^>]*>>/g"
|
||||
},
|
||||
{
|
||||
"name": "yarn-expressions",
|
||||
"pattern": "/\\{[^}]*\\}/g"
|
||||
},
|
||||
{
|
||||
"name": "yarn-variables",
|
||||
"pattern": "/\\$[a-zA-Z_][a-zA-Z0-9_]*/g"
|
||||
},
|
||||
{
|
||||
"name": "yarn-markup",
|
||||
"pattern": "/\\[[^\\]]*\\]/g"
|
||||
},
|
||||
{
|
||||
"name": "yarn-hashtags",
|
||||
"pattern": "/#[a-zA-Z_][a-zA-Z0-9_:-]*/g"
|
||||
},
|
||||
{
|
||||
"name": "yarn-node-markers",
|
||||
"pattern": "/^(---|===)$/gm"
|
||||
},
|
||||
{
|
||||
"name": "yarn-headers",
|
||||
"pattern": "/^(title|tags|position|color|colorID|tracking|custom|style):.*/gm"
|
||||
},
|
||||
{
|
||||
"name": "yarn-character-names",
|
||||
"pattern": "/^\\s*[A-Za-z_][A-Za-z0-9_]*(?=:)/gm"
|
||||
},
|
||||
{
|
||||
"name": "yarn-comments",
|
||||
"pattern": "/\\/\\/.*$/gm"
|
||||
}
|
||||
],
|
||||
"ignoreRegExpList": [
|
||||
"yarn-commands",
|
||||
"yarn-expressions",
|
||||
"yarn-variables",
|
||||
"yarn-markup",
|
||||
"yarn-hashtags",
|
||||
"yarn-node-markers",
|
||||
"yarn-headers",
|
||||
"yarn-character-names",
|
||||
"yarn-comments"
|
||||
]
|
||||
}
|
||||
],
|
||||
"cSpell.words": [
|
||||
"Aibis",
|
||||
"Newtonsoft"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
AIBIS Dream (also known as "AllOurBrokenParts") is a Unity-based narrative adventure game with investigative elements. The project uses Yarn Spinner for dialogue, features a custom framework with modular systems, and includes multiple mini-games and a modular repair/fix system.
|
||||
|
||||
**Unity Version:** 2022.3.7f1c1 (Universal Render Pipeline)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Framework (Assets/Scripts/Framework/)
|
||||
|
||||
Modular framework with Kit-pattern utilities:
|
||||
|
||||
- **ActionKit**: Action sequencing and command pattern
|
||||
- **AudioKit**: Audio management (includes FMOD integration)
|
||||
- **Config / Core**: Configuration and core utilities
|
||||
- **EventSystemKit**: Decoupled event system
|
||||
- **LogKit**: Structured logging with categories
|
||||
- **PoolKit**: Object pooling
|
||||
- **ResourceKit**: Asset loading via `ResourceSystem` (Addressables-based)
|
||||
- **Note**: New code must use `ResourceSystem`, never `Resources.Load`. See [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md). Asset placement rules in [Resource Management Spec](Docs/ResourceManagementSpec.md).
|
||||
- **SelectorKit**: Weighted/batch/non-repeat random selection
|
||||
- **SingletonKit**: Singleton base classes
|
||||
- **StateMachineKit**: State management
|
||||
- **OtherKit**: Miscellaneous utilities (`CameraKit`, `AnimatorKit`, `FadeKit`, `LocalizationKit`, etc.)
|
||||
- **Attribute**: Custom editor attributes
|
||||
|
||||
### Key Game Systems
|
||||
|
||||
- **Game Loop** (Scripts/Game Loop/): `GameManager` (Singleton entry), `YarnVariableStorage` (Yarn variables), `SaveRestoreOrchestrator` / `SaveSystem` (save snapshots), `SceneLoader`, `ChapterController`, `TalkSceneSO`
|
||||
- **Dialog System** (Scripts/Dialog System/): Yarn Spinner integration — `DialogController`, `LineRunner`, `LocalisedLineProvider`, `BaseYarnCommand`
|
||||
- **SceneManagement** (Scripts/SceneManagement/): `ActorKit/`, `AnimatorKit/`, `EnvironmentKit/`, `SpawnerKit/`, `TimelineKit/`, `SceneCenter/`, `DreamDoorSystem`
|
||||
- **UI** (Scripts/UI/): `DialogUI/`, `Panel/`, `Form/`, `Components/`, `Cursor/`, `UIManager`
|
||||
- **MiniGame** (Scripts/MiniGame/): `HuoShan/` (language particles, EmotionWave), `BlockPuzzle/`, `Peipei/` (Eye, CutLine, Memory)
|
||||
- **FixSystem** (Scripts/FixSystem/): Legacy — HeatMap, PunchTape, Memory, screen effects
|
||||
- **FixSystemNew** (Scripts/FixSystemNew/): Active repair system — `FixSystemCenter`, `BodyModule/`, `Cable/`, `WhackMole/`, `Screen System/`, `Open/`, `Clinic/`
|
||||
- **AssetRefs** (Scripts/AssetRefs/): Addressable key constants (`ConstRef`, `FixAssetRef`, `TimelineAssetRef`)
|
||||
|
||||
### Content & Localization
|
||||
|
||||
- **Assets/GameContent/**: Runtime assets by scope (`Common/`, `UI/`, `Scene_{Name}/`, `Feature_{Name}/`)
|
||||
- **Assets/Language/**: Unity Localization tables (runtime active: `UIText/`, `Locales/`, `Sprite/`; `Dialog/`、`ActorName/`、`Params/` currently kept as migration backups)
|
||||
- **Assets/Resources/Yarn/**: Current Yarn dialogue files (target migration to `Assets/Narrative/` per spec, not yet complete)
|
||||
- **Assets/StreamingAssets/Config/**: CSV-driven localized gameplay data (`character.csv` for actor names, `params.csv` for `l10n.*` substitution terms)
|
||||
- **Assets/Prototype/**: HTML/JS prototypes for mechanic testing
|
||||
|
||||
## Critical Dependencies
|
||||
|
||||
### Unity Packages
|
||||
- **Yarn Spinner** 3.2.2 (OpenUPM), **Addressables** 1.21.21, **Cinemachine** 2.9.7
|
||||
- **URP** 14.0.8, **TextMesh Pro** 3.0.6, **Timeline** 1.7.5, **Localization** 1.4.5
|
||||
- **Visual Effect Graph** 14.0.8, **Visual Scripting** 1.8.0
|
||||
|
||||
### Third-Party
|
||||
- **FMOD** (audio engine), **DOTween** (tweening), **Shapes** (vector graphics)
|
||||
- **More Mountains Feedbacks** (game feel), **Destructible 2D**
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Organization
|
||||
1. Each major system self-contained in its own folder under `Assets/Scripts/`
|
||||
2. Kit-pattern utilities in `Framework/` — reusable across systems
|
||||
3. Singleton pattern for global managers (derived from `SingletonKit`)
|
||||
4. Favor composition over inheritance
|
||||
5. All runtime asset loading via `ResourceSystem` — never `Resources.Load`
|
||||
|
||||
### Scene Structure
|
||||
- Scenes in `Assets/Scenes/`; `Persistence` scene hosts global managers and Persistent Loader
|
||||
- Fix scenes: `{Character}FixScene` (e.g. `HuoShanFixScene`, `PeipeiFixScene`, `ExpressFixScene`)
|
||||
- Test scenes: `languageTest`, `HuoShanExpressionTest`, `wavetest`, etc.
|
||||
|
||||
### Addressables
|
||||
- Config in `Assets/AddressableAssetsData/`; Groups: `Core`, `Scene_*`, `Feature_*`, `Shared_*`, `Localization-*`
|
||||
- Key format: `Category/Path` (e.g. `Animation/Peipei`, `Timeline/BlockPuzzle/PanelOpen`)
|
||||
- Scene keys use bare PascalCase names (e.g. `ClinicOut`)
|
||||
- Details in [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md)
|
||||
|
||||
### Yarn Dialogue Files
|
||||
- Current path: `Assets/Resources/Yarn/` (migration to `Assets/Narrative/Yarn/` planned)
|
||||
- Pattern: `{Character}_{Stage}.yarn`; UTF-8 encoding required for Chinese
|
||||
|
||||
#### Yarn Line Tag Maintenance
|
||||
- **Line tags** (`#line:xxxxxxx`) must be unique across the entire file. Duplicate tags break Yarn Spinner localization.
|
||||
- Duplicates typically occur when copying an existing node to create a variant (e.g. `地铁ver1` → `地铁ver3`).
|
||||
- **Use the dedup tool** (`Tools/dedup_yarn_tags.py`) for bulk cleanup:
|
||||
```bash
|
||||
# Preview duplicates from a specific node onward
|
||||
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/Fiction_Day1_begin/Fiction_Day1_begin.yarn --from-node 地铁ver4
|
||||
# Fix duplicates from a specific line onward
|
||||
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/Fiction_Day1_begin/Fiction_Day1_begin.yarn --from-line 535 --fix
|
||||
# Scan an entire directory (preview only)
|
||||
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/ --scan-all
|
||||
```
|
||||
|
||||
#### Yarn Script Generation
|
||||
- **Do NOT add `#line:` tags when generating or editing Yarn dialogue.** Leave all line-tag generation to the user's localization workflow (Unity Localization + Yarn Spinner import tools).
|
||||
- When creating new `.yarn` files, write plain dialogue lines without any `#line:` suffixes.
|
||||
|
||||
### Asset Naming
|
||||
- **Scripts**: PascalCase (e.g. `DialogController.cs`)
|
||||
- **Addressable Keys**: `Category/Path` — no spaces, no extensions, ≤3 levels
|
||||
- **Addressable Groups**: `{Type}_{PascalCaseName}`
|
||||
|
||||
### Git & Commits
|
||||
- LFS for large binaries; ignored: Library/, Temp/, Build/, *.csproj, *.sln
|
||||
- **Commit format**: `<type>(<scope>): <subject>` — subject in Chinese, type/scope in English
|
||||
- Extended types: `feat`, `fix`, `art`, `audio`, `scene`, `yarn`, `perf`, `refactor`, `style`, `docs`, `build`, `chore`, `test`
|
||||
- See [COMMIT_CONVENTION.md](COMMIT_CONVENTION.md) for full details
|
||||
|
||||
## Key Build Commands
|
||||
|
||||
```bash
|
||||
# Windows build
|
||||
"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe" -batchmode -quit -projectPath . -buildWindows64Player "Build/AIBIS_Dream.exe" -logFile Build/log.txt
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New Mini-Game
|
||||
1. Create `Assets/Scripts/MiniGame/{GameName}/`
|
||||
2. Add assets to `Assets/GameContent/Feature_{GameName}/`
|
||||
3. Create Addressable Group `Feature_{GameName}`
|
||||
4. Create test scene in `Assets/Scenes/`
|
||||
|
||||
### Adding Yarn Dialogue
|
||||
1. Create `.yarn` in `Assets/Resources/Yarn/{Module}/`
|
||||
2. Ensure YarnProject localization assets/CSV are updated (Unity `Assets/Language/Dialog/` is backup-only during migration)
|
||||
3. Reference in scene's `DialogueRunner`; test with `DialogController`
|
||||
|
||||
## Debugging & Performance
|
||||
- `LogKit` for structured logging; Addressables Event Viewer for resource debugging
|
||||
- Monitor `SceneResourceLoader.TotalHandleCount` for leak detection
|
||||
- Always use PoolKit for frequent instantiation; `LoadUncachedAsync` + `EarlyRelease` for large Timeline assets
|
||||
- Use `LoadAsync<GameObject>` + `Instantiate` for prefabs, never `InstantiateAsync`
|
||||
|
||||
## Important Notes
|
||||
- Narrative-focused game — dialogue and story take precedence
|
||||
- `FixSystemNew` is the active repair architecture; `FixSystem` is legacy
|
||||
- `YarnVariableStorage` holds Yarn runtime variables; save/load flows through `SaveRestoreOrchestrator` and `SaveSystem`
|
||||
|
||||
## Reference Documents
|
||||
- [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md) — `ResourceSystem` API, Key/Group/Label naming, lifecycle
|
||||
- [Resource Management Spec](Docs/ResourceManagementSpec.md) — Asset placement: `GameContent/`, `Narrative/`, `_Archive/`
|
||||
- [Commit Convention](COMMIT_CONVENTION.md) — Commit message format and branch naming
|
||||
- [HuoShan Docs](Docs/HuoShan/) — Narrative flow, resources, test flowcharts
|
||||
|
||||
---
|
||||
|
||||
*This document provides guidance for Codex when working with the AIBIS Dream codebase.*
|
||||
@@ -15,7 +15,7 @@ MonoBehaviour:
|
||||
m_DefaultGroup: 09546aca93a801441859af2a811fe53b
|
||||
m_currentHash:
|
||||
serializedVersion: 2
|
||||
Hash: 1616e06b587c7889ae21faab8aef8e68
|
||||
Hash: ae2a22805ec7ff55174c38995aa3e54d
|
||||
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,10 @@ 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}
|
||||
- {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
|
||||
- {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
|
||||
- {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
|
||||
m_BuildSettings:
|
||||
m_CompileScriptsInVirtualMode: 0
|
||||
m_CleanupStreamingAssetsAfterBuilds: 1
|
||||
@@ -115,6 +118,9 @@ MonoBehaviour:
|
||||
- Locale-zh-Hans
|
||||
- SceneSO
|
||||
- Locale-ja-JP
|
||||
- Locale-pt-BR
|
||||
- Locale-ru
|
||||
- Locale-es
|
||||
m_SchemaTemplates: []
|
||||
m_GroupTemplateObjects:
|
||||
- {fileID: 11400000, guid: 0c158052680a3e94184c450d00291514, type: 2}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -35,6 +35,24 @@ MonoBehaviour:
|
||||
m_SerializedLabels:
|
||||
- Locale
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 9b1581a48f0ca11448dbddaf79c11252
|
||||
m_Address: Russian (ru)
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 3cadb0d5f8bf5e64599f52e139f7e3cc
|
||||
m_Address: Spanish (es)
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: d0c46f4777ac92249a43143a15892fd2
|
||||
m_Address: Portuguese (Brazil) (pt-BR)
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale
|
||||
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:
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
|
||||
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
|
||||
m_EditorClassIdentifier:
|
||||
m_GroupName: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
|
||||
m_Data:
|
||||
m_SerializedData: []
|
||||
m_GUID: 67ced675275774640b77e0ded8750379
|
||||
m_SerializeEntries:
|
||||
- m_GUID: 63300a11309a7b34bbf7a345b123cab8
|
||||
m_Address: ChapterInfo_pt-BR
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-pt-BR
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 6ad3c9298dbc3b642b1251ffc306eaa6
|
||||
m_Address: Params_pt-BR
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-pt-BR
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 7d496f91e240bb143b62a6239c817d09
|
||||
m_Address: UIText_pt-BR
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-pt-BR
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
m_Schemas:
|
||||
- {fileID: 11400000, guid: b430e9b782d3119489b903dce4915542, type: 2}
|
||||
- {fileID: 11400000, guid: 70748601b27158b47810538f6ed1c013, type: 2}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 089fc86f714d6a94bafea3b117fbd639
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
|
||||
m_Name: Localization-String-Tables-Russian (ru)
|
||||
m_EditorClassIdentifier:
|
||||
m_GroupName: Localization-String-Tables-Russian (ru)
|
||||
m_Data:
|
||||
m_SerializedData: []
|
||||
m_GUID: c1e47d7d0d831ac4b881f19aa2bf17e0
|
||||
m_SerializeEntries:
|
||||
- m_GUID: c69caec2066296840887d7e0c2eff52e
|
||||
m_Address: ChapterInfo_ru
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-ru
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b91f4281442d2b54f92fbecb5bd56426
|
||||
m_Address: Params_ru
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-ru
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f679069e455200841a92c27186383940
|
||||
m_Address: UIText_ru
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-ru
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
m_Schemas:
|
||||
- {fileID: 11400000, guid: b251ae14d0b273843b5674d239daa31c, type: 2}
|
||||
- {fileID: 11400000, guid: 808fc72235ececa47aa55960a94a9a9a, type: 2}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1c592abe924a856439461f3785ec53da
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
|
||||
m_Name: Localization-String-Tables-Spanish (es)
|
||||
m_EditorClassIdentifier:
|
||||
m_GroupName: Localization-String-Tables-Spanish (es)
|
||||
m_Data:
|
||||
m_SerializedData: []
|
||||
m_GUID: c8471b00dabdd224ab9c17d71d951cbc
|
||||
m_SerializeEntries:
|
||||
- m_GUID: 728457f9c2ef69c489530047d495eca5
|
||||
m_Address: ChapterInfo_es
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-es
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 1edf9254b97981643bdecd6d22ed1150
|
||||
m_Address: Params_es
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-es
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 380c27123212d66468aa1f3ed3bf72ea
|
||||
m_Address: UIText_es
|
||||
m_ReadOnly: 1
|
||||
m_SerializedLabels:
|
||||
- Locale-es
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 1
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
m_Schemas:
|
||||
- {fileID: 11400000, guid: 3ddf5f47a1b265049825b193e7e93fbd, type: 2}
|
||||
- {fileID: 11400000, guid: 3e5e5c2400a39814994a9fcb8eaf49a6, type: 2}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26fd03652560e7f4abec092229fc38f2
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -47,8 +47,13 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: ea008b0c912c0d748997bf458879cb74
|
||||
m_Address: "Animation/\u5929\u6865\u4F69\u4F69"
|
||||
- m_GUID: f19cff809e7020b41ba8ad7da199db86
|
||||
m_Address: "Timeline/day2\u5915\u9633\u533B\u751F\u770B\u4F69\u4F69\u4FA7\u8138"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f18fc85f0eeecb44b9cfae24e8e19fa1
|
||||
m_Address: "FrameAnimation/\u5929\u6865\u4F69\u4F69"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
|
||||
@@ -17,8 +17,8 @@ MonoBehaviour:
|
||||
m_SerializedData: []
|
||||
m_GUID: 9ae1da1b7a680244cbe735b3b4437f26
|
||||
m_SerializeEntries:
|
||||
- m_GUID: b6a14d8a19b9a3041baf76df2e8e4558
|
||||
m_Address: "Animation/\u8BCA\u5BA4\u5916\u4F69\u4F69"
|
||||
- m_GUID: 01b1640d62cd5ff4686088bb3d4c8fba
|
||||
m_Address: "FrameAnimation/\u8BCA\u5BA4\u5916\u4F69\u4F69"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
@@ -72,13 +72,18 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 7760fc67933fb684d81ea5275fac0a50
|
||||
m_Address: "Animation/\u8BCA\u5BA4\u5916\u706B\u5C71"
|
||||
- m_GUID: 253f47db959b2814998695fb5d1f4d97
|
||||
m_Address: "Timeline/\u65CB\u8F6C\u53F6\u67C4"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 253f47db959b2814998695fb5d1f4d97
|
||||
m_Address: "Timeline/\u65CB\u8F6C\u53F6\u67C4"
|
||||
- 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
|
||||
|
||||
@@ -32,11 +32,6 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 417a7984630786f46a5ade5d2b457852
|
||||
m_Address: "Animation/\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 838bd5cc2ceffc1459d167675d96f66a
|
||||
m_Address: "Timeline/\u706B\u5C71\u8868\u8FBEpanel"
|
||||
m_ReadOnly: 0
|
||||
@@ -87,11 +82,21 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 58d09572ecd54714a787a1fc5aec6c5f
|
||||
m_Address: "Timeline/\u706B\u5C71\u5934\u75DB3"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: e69af1ed498c57f4799f936aa28e4ed4
|
||||
m_Address: "Timeline/\u706B\u5C71\u5934\u75DB1"
|
||||
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,16 @@ 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_GUID: 66be33a1bdb16ee409c6ac05b61e08a7
|
||||
m_Address: "Subway/\u76F8\u9519\u5217\u8F66"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
|
||||
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_BundledAssetGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
|
||||
m_InternalBundleIdMode: 1
|
||||
m_Compression: 1
|
||||
m_IncludeAddressInCatalog: 1
|
||||
m_IncludeGUIDInCatalog: 1
|
||||
m_IncludeLabelsInCatalog: 1
|
||||
m_InternalIdNamingMode: 0
|
||||
m_CacheClearBehavior: 0
|
||||
m_IncludeInBuild: 1
|
||||
m_BundledAssetProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
|
||||
m_ForceUniqueProvider: 0
|
||||
m_UseAssetBundleCache: 1
|
||||
m_UseAssetBundleCrc: 1
|
||||
m_UseAssetBundleCrcForCachedBundles: 1
|
||||
m_UseUWRForLocalBundles: 0
|
||||
m_Timeout: 0
|
||||
m_ChunkedTransfer: 0
|
||||
m_RedirectLimit: -1
|
||||
m_RetryCount: 0
|
||||
m_BuildPath:
|
||||
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
|
||||
m_LoadPath:
|
||||
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
|
||||
m_BundleMode: 0
|
||||
m_AssetBundleProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
|
||||
m_BundleNaming: 1
|
||||
m_AssetLoadMode: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b430e9b782d3119489b903dce4915542
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
|
||||
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_ContentUpdateGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
|
||||
m_StaticContent: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 70748601b27158b47810538f6ed1c013
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
|
||||
m_Name: Localization-String-Tables-Russian (ru)_BundledAssetGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
|
||||
m_InternalBundleIdMode: 1
|
||||
m_Compression: 1
|
||||
m_IncludeAddressInCatalog: 1
|
||||
m_IncludeGUIDInCatalog: 1
|
||||
m_IncludeLabelsInCatalog: 1
|
||||
m_InternalIdNamingMode: 0
|
||||
m_CacheClearBehavior: 0
|
||||
m_IncludeInBuild: 1
|
||||
m_BundledAssetProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
|
||||
m_ForceUniqueProvider: 0
|
||||
m_UseAssetBundleCache: 1
|
||||
m_UseAssetBundleCrc: 1
|
||||
m_UseAssetBundleCrcForCachedBundles: 1
|
||||
m_UseUWRForLocalBundles: 0
|
||||
m_Timeout: 0
|
||||
m_ChunkedTransfer: 0
|
||||
m_RedirectLimit: -1
|
||||
m_RetryCount: 0
|
||||
m_BuildPath:
|
||||
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
|
||||
m_LoadPath:
|
||||
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
|
||||
m_BundleMode: 0
|
||||
m_AssetBundleProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
|
||||
m_BundleNaming: 1
|
||||
m_AssetLoadMode: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b251ae14d0b273843b5674d239daa31c
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
|
||||
m_Name: Localization-String-Tables-Russian (ru)_ContentUpdateGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
|
||||
m_StaticContent: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 808fc72235ececa47aa55960a94a9a9a
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
|
||||
m_Name: Localization-String-Tables-Spanish (es)_BundledAssetGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
|
||||
m_InternalBundleIdMode: 1
|
||||
m_Compression: 1
|
||||
m_IncludeAddressInCatalog: 1
|
||||
m_IncludeGUIDInCatalog: 1
|
||||
m_IncludeLabelsInCatalog: 1
|
||||
m_InternalIdNamingMode: 0
|
||||
m_CacheClearBehavior: 0
|
||||
m_IncludeInBuild: 1
|
||||
m_BundledAssetProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
|
||||
m_ForceUniqueProvider: 0
|
||||
m_UseAssetBundleCache: 1
|
||||
m_UseAssetBundleCrc: 1
|
||||
m_UseAssetBundleCrcForCachedBundles: 1
|
||||
m_UseUWRForLocalBundles: 0
|
||||
m_Timeout: 0
|
||||
m_ChunkedTransfer: 0
|
||||
m_RedirectLimit: -1
|
||||
m_RetryCount: 0
|
||||
m_BuildPath:
|
||||
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
|
||||
m_LoadPath:
|
||||
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
|
||||
m_BundleMode: 0
|
||||
m_AssetBundleProviderType:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
|
||||
m_BundleNaming: 1
|
||||
m_AssetLoadMode: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3ddf5f47a1b265049825b193e7e93fbd
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
|
||||
m_Name: Localization-String-Tables-Spanish (es)_ContentUpdateGroupSchema
|
||||
m_EditorClassIdentifier:
|
||||
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
|
||||
m_StaticContent: 0
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e5e5c2400a39814994a9fcb8eaf49a6
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &-7273616238501396146
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
|
||||
m_Name: VolumeAsset
|
||||
m_EditorClassIdentifier:
|
||||
m_Template:
|
||||
Weight: 1
|
||||
m_Volume: {fileID: 11400000, guid: 0af8555a6447b50409db2139400d6ab7, type: 2}
|
||||
--- !u!114 &-3378792728507138695
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
|
||||
m_Name: Volume Track (1)
|
||||
m_EditorClassIdentifier:
|
||||
m_Version: 3
|
||||
m_AnimClip: {fileID: 0}
|
||||
m_Locked: 0
|
||||
m_Muted: 0
|
||||
m_CustomPlayableFullTypename:
|
||||
m_Curves: {fileID: 0}
|
||||
m_Parent: {fileID: 11400000}
|
||||
m_Children: []
|
||||
m_Clips:
|
||||
- m_Version: 1
|
||||
m_Start: 0
|
||||
m_ClipIn: 0
|
||||
m_Asset: {fileID: 7967868333749088964}
|
||||
m_Duration: 0.75
|
||||
m_TimeScale: 1
|
||||
m_ParentTrack: {fileID: -3378792728507138695}
|
||||
m_EaseInDuration: 0.05
|
||||
m_EaseOutDuration: 0.28
|
||||
m_BlendInDuration: -1
|
||||
m_BlendOutDuration: -1
|
||||
m_MixInCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_MixOutCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_BlendInCurveMode: 0
|
||||
m_BlendOutCurveMode: 0
|
||||
m_ExposedParameterNames: []
|
||||
m_AnimationCurves: {fileID: 0}
|
||||
m_Recordable: 0
|
||||
m_PostExtrapolationMode: 0
|
||||
m_PreExtrapolationMode: 0
|
||||
m_PostExtrapolationTime: 0
|
||||
m_PreExtrapolationTime: 0
|
||||
m_DisplayName: VolumeAsset
|
||||
m_Markers:
|
||||
m_Objects: []
|
||||
m_Layer: 0
|
||||
m_Priority: 0
|
||||
--- !u!114 &-3117513502812618692
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
|
||||
m_Name: Volume Track
|
||||
m_EditorClassIdentifier:
|
||||
m_Version: 3
|
||||
m_AnimClip: {fileID: 0}
|
||||
m_Locked: 0
|
||||
m_Muted: 0
|
||||
m_CustomPlayableFullTypename:
|
||||
m_Curves: {fileID: 0}
|
||||
m_Parent: {fileID: 11400000}
|
||||
m_Children: []
|
||||
m_Clips:
|
||||
- m_Version: 1
|
||||
m_Start: 0
|
||||
m_ClipIn: 0
|
||||
m_Asset: {fileID: -7273616238501396146}
|
||||
m_Duration: 0.75
|
||||
m_TimeScale: 1
|
||||
m_ParentTrack: {fileID: -3117513502812618692}
|
||||
m_EaseInDuration: 0.05
|
||||
m_EaseOutDuration: 0.28
|
||||
m_BlendInDuration: -1
|
||||
m_BlendOutDuration: -1
|
||||
m_MixInCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_MixOutCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_BlendInCurveMode: 0
|
||||
m_BlendOutCurveMode: 0
|
||||
m_ExposedParameterNames: []
|
||||
m_AnimationCurves: {fileID: 0}
|
||||
m_Recordable: 0
|
||||
m_PostExtrapolationMode: 0
|
||||
m_PreExtrapolationMode: 0
|
||||
m_PostExtrapolationTime: 0
|
||||
m_PreExtrapolationTime: 0
|
||||
m_DisplayName: VolumeAsset
|
||||
m_Markers:
|
||||
m_Objects: []
|
||||
m_Layer: 0
|
||||
m_Priority: 0
|
||||
--- !u!114 &-2955631063214815081
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
|
||||
m_Name: Volume Track (2)
|
||||
m_EditorClassIdentifier:
|
||||
m_Version: 3
|
||||
m_AnimClip: {fileID: 0}
|
||||
m_Locked: 0
|
||||
m_Muted: 0
|
||||
m_CustomPlayableFullTypename:
|
||||
m_Curves: {fileID: 0}
|
||||
m_Parent: {fileID: 11400000}
|
||||
m_Children: []
|
||||
m_Clips:
|
||||
- m_Version: 1
|
||||
m_Start: 0
|
||||
m_ClipIn: 0
|
||||
m_Asset: {fileID: -1361877534822022742}
|
||||
m_Duration: 0.75
|
||||
m_TimeScale: 1
|
||||
m_ParentTrack: {fileID: -2955631063214815081}
|
||||
m_EaseInDuration: 0.05
|
||||
m_EaseOutDuration: 0.28
|
||||
m_BlendInDuration: -1
|
||||
m_BlendOutDuration: -1
|
||||
m_MixInCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_MixOutCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 1
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
- serializedVersion: 3
|
||||
time: 1
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 0
|
||||
weightedMode: 0
|
||||
inWeight: 0
|
||||
outWeight: 0
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
m_BlendInCurveMode: 0
|
||||
m_BlendOutCurveMode: 0
|
||||
m_ExposedParameterNames: []
|
||||
m_AnimationCurves: {fileID: 0}
|
||||
m_Recordable: 0
|
||||
m_PostExtrapolationMode: 0
|
||||
m_PreExtrapolationMode: 0
|
||||
m_PostExtrapolationTime: 0
|
||||
m_PreExtrapolationTime: 0
|
||||
m_DisplayName: VolumeAsset
|
||||
m_Markers:
|
||||
m_Objects: []
|
||||
m_Layer: 0
|
||||
m_Priority: 0
|
||||
--- !u!114 &-1361877534822022742
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
|
||||
m_Name: VolumeAsset
|
||||
m_EditorClassIdentifier:
|
||||
m_Template:
|
||||
Weight: 1
|
||||
m_Volume: {fileID: 11400000, guid: 631b3200517e20440b88f28015fc3a5c, type: 2}
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bfda56da833e2384a9677cd3c976a436, type: 3}
|
||||
m_Name: "\u706B\u5C71\u5934\u75DB3"
|
||||
m_EditorClassIdentifier:
|
||||
m_Version: 0
|
||||
m_Tracks:
|
||||
- {fileID: -3117513502812618692}
|
||||
- {fileID: -3378792728507138695}
|
||||
- {fileID: -2955631063214815081}
|
||||
- {fileID: 2112685405309563144}
|
||||
m_FixedDuration: 0
|
||||
m_EditorSettings:
|
||||
m_Framerate: 60
|
||||
m_ScenePreview: 1
|
||||
m_DurationMode: 0
|
||||
m_MarkerTrack: {fileID: 0}
|
||||
--- !u!74 &1077202520131138910
|
||||
AnimationClip:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: Recorded
|
||||
serializedVersion: 7
|
||||
m_Legacy: 0
|
||||
m_Compressed: 0
|
||||
m_UseHighQualityCurve: 1
|
||||
m_RotationCurves: []
|
||||
m_CompressedRotationCurves: []
|
||||
m_EulerCurves: []
|
||||
m_PositionCurves: []
|
||||
m_ScaleCurves: []
|
||||
m_FloatCurves:
|
||||
- serializedVersion: 2
|
||||
curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.07
|
||||
value: 0.8
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.42
|
||||
value: 0.8
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.68
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
attribute: m_Color.a
|
||||
path:
|
||||
classID: 212
|
||||
script: {fileID: 0}
|
||||
flags: 0
|
||||
m_PPtrCurves: []
|
||||
m_SampleRate: 60
|
||||
m_WrapMode: 0
|
||||
m_Bounds:
|
||||
m_Center: {x: 0, y: 0, z: 0}
|
||||
m_Extent: {x: 0, y: 0, z: 0}
|
||||
m_ClipBindingConstant:
|
||||
genericBindings:
|
||||
- serializedVersion: 2
|
||||
path: 0
|
||||
attribute: 304273561
|
||||
script: {fileID: 0}
|
||||
typeID: 212
|
||||
customType: 0
|
||||
isPPtrCurve: 0
|
||||
isIntCurve: 0
|
||||
isSerializeReferenceCurve: 0
|
||||
pptrCurveMapping: []
|
||||
m_AnimationClipSettings:
|
||||
serializedVersion: 2
|
||||
m_AdditiveReferencePoseClip: {fileID: 0}
|
||||
m_AdditiveReferencePoseTime: 0
|
||||
m_StartTime: 0
|
||||
m_StopTime: 0.68
|
||||
m_OrientationOffsetY: 0
|
||||
m_Level: 0
|
||||
m_CycleOffset: 0
|
||||
m_HasAdditiveReferencePose: 0
|
||||
m_LoopTime: 0
|
||||
m_LoopBlend: 0
|
||||
m_LoopBlendOrientation: 0
|
||||
m_LoopBlendPositionY: 0
|
||||
m_LoopBlendPositionXZ: 0
|
||||
m_KeepOriginalOrientation: 0
|
||||
m_KeepOriginalPositionY: 1
|
||||
m_KeepOriginalPositionXZ: 0
|
||||
m_HeightFromFeet: 0
|
||||
m_Mirror: 0
|
||||
m_EditorCurves:
|
||||
- serializedVersion: 2
|
||||
curve:
|
||||
serializedVersion: 2
|
||||
m_Curve:
|
||||
- serializedVersion: 3
|
||||
time: 0
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.07
|
||||
value: 0.8
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.42
|
||||
value: 0.8
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
- serializedVersion: 3
|
||||
time: 0.68
|
||||
value: 0
|
||||
inSlope: 0
|
||||
outSlope: 0
|
||||
tangentMode: 136
|
||||
weightedMode: 0
|
||||
inWeight: 0.33333334
|
||||
outWeight: 0.33333334
|
||||
m_PreInfinity: 2
|
||||
m_PostInfinity: 2
|
||||
m_RotationOrder: 4
|
||||
attribute: m_Color.a
|
||||
path:
|
||||
classID: 212
|
||||
script: {fileID: 0}
|
||||
flags: 0
|
||||
m_EulerEditorCurves: []
|
||||
m_HasGenericRootTransform: 0
|
||||
m_HasMotionFloatCurves: 0
|
||||
m_Events: []
|
||||
--- !u!114 &2112685405309563144
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: d21dcc2386d650c4597f3633c75a1f98, type: 3}
|
||||
m_Name: Animation Track
|
||||
m_EditorClassIdentifier:
|
||||
m_Version: 3
|
||||
m_AnimClip: {fileID: 0}
|
||||
m_Locked: 0
|
||||
m_Muted: 0
|
||||
m_CustomPlayableFullTypename:
|
||||
m_Curves: {fileID: 0}
|
||||
m_Parent: {fileID: 11400000}
|
||||
m_Children: []
|
||||
m_Clips: []
|
||||
m_Markers:
|
||||
m_Objects: []
|
||||
m_InfiniteClipPreExtrapolation: 1
|
||||
m_InfiniteClipPostExtrapolation: 1
|
||||
m_InfiniteClipOffsetPosition: {x: 0, y: 0, z: 0}
|
||||
m_InfiniteClipOffsetEulerAngles: {x: 0, y: 0, z: 0}
|
||||
m_InfiniteClipTimeOffset: 0
|
||||
m_InfiniteClipRemoveOffset: 0
|
||||
m_InfiniteClipApplyFootIK: 1
|
||||
mInfiniteClipLoop: 0
|
||||
m_MatchTargetFields: 63
|
||||
m_Position: {x: 0, y: 0, z: 0}
|
||||
m_EulerAngles: {x: 0, y: 0, z: 0}
|
||||
m_AvatarMask: {fileID: 0}
|
||||
m_ApplyAvatarMask: 1
|
||||
m_TrackOffset: 0
|
||||
m_InfiniteClip: {fileID: 1077202520131138910}
|
||||
m_OpenClipOffsetRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_Rotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_ApplyOffsets: 0
|
||||
--- !u!114 &7967868333749088964
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 1
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
|
||||
m_Name: VolumeAsset
|
||||
m_EditorClassIdentifier:
|
||||
m_Template:
|
||||
Weight: 1
|
||||
m_Volume: {fileID: 11400000, guid: 841479fd6ffe3ee40a88ee41bd395f09, type: 2}
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58d09572ecd54714a787a1fc5aec6c5f
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
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,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 638cd3fa1c2470d42aceb69214c01786
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AibisDream.Kit;
|
||||
using AibisDream.SaveSystem;
|
||||
using FMOD;
|
||||
using FMOD.Studio;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class AudioGlobalParameterSnapshotTests
|
||||
{
|
||||
[Test]
|
||||
public void SetFromYarn_TracksOnlySuccessfulUniqueNames()
|
||||
{
|
||||
var api = new FakeFmodGlobalParameterApi();
|
||||
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
|
||||
var registry = new YarnGlobalParameterRegistry(api);
|
||||
|
||||
Assert.That(registry.SetFromYarn("reverb", 0.4f), Is.EqualTo(RESULT.OK));
|
||||
Assert.That(registry.SetFromYarn("REVERB", 0.7f), Is.EqualTo(RESULT.OK));
|
||||
Assert.That(
|
||||
registry.SetFromYarn("missing", 1f),
|
||||
Is.EqualTo(RESULT.ERR_EVENT_NOTFOUND));
|
||||
|
||||
Assert.That(registry.TrackedNames, Has.Count.EqualTo(1));
|
||||
Assert.That(registry.TrackedNames.Single(), Is.EqualTo("reverb"));
|
||||
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.False);
|
||||
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureFinalValues_ReadsFmodFinalValueAndRetainsFailuresForRetry()
|
||||
{
|
||||
var api = new FakeFmodGlobalParameterApi();
|
||||
api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK);
|
||||
api.Reads["missing"] = new ParameterRead(0f, 0f, RESULT.ERR_EVENT_NOTFOUND);
|
||||
var registry = new YarnGlobalParameterRegistry(api);
|
||||
registry.SetFromYarn("stage", 9f);
|
||||
registry.SetFromYarn("missing", 1f);
|
||||
var warnings = new List<string>();
|
||||
|
||||
var snapshot = registry.CaptureFinalValues(warnings.Add);
|
||||
|
||||
Assert.That(snapshot, Has.Count.EqualTo(1));
|
||||
Assert.That(snapshot["stage"], Is.EqualTo(2.37f));
|
||||
Assert.That(snapshot["stage"], Is.Not.EqualTo(9f));
|
||||
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage", "missing" }));
|
||||
Assert.That(warnings, Has.Count.EqualTo(1));
|
||||
StringAssert.Contains("missing", warnings[0]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetToDefaults_RemovesOnlyParametersThatFmodResetSuccessfully()
|
||||
{
|
||||
var goodId = new PARAMETER_ID { data1 = 1, data2 = 2 };
|
||||
var failedId = new PARAMETER_ID { data1 = 3, data2 = 4 };
|
||||
var api = new FakeFmodGlobalParameterApi();
|
||||
api.Descriptions["good"] = new ParameterDescriptionRead(
|
||||
new PARAMETER_DESCRIPTION { id = goodId, defaultvalue = 0.25f },
|
||||
RESULT.OK);
|
||||
api.Descriptions["failed"] = new ParameterDescriptionRead(
|
||||
new PARAMETER_DESCRIPTION { id = failedId, defaultvalue = 0.75f },
|
||||
RESULT.OK);
|
||||
api.SetByIdResults[failedId] = RESULT.ERR_INVALID_PARAM;
|
||||
var registry = new YarnGlobalParameterRegistry(api);
|
||||
registry.SetFromYarn("good", 1f);
|
||||
registry.SetFromYarn("failed", 1f);
|
||||
var warnings = new List<string>();
|
||||
|
||||
registry.ResetToDefaults(warnings.Add);
|
||||
|
||||
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "failed" }));
|
||||
Assert.That(api.SetByIdCalls, Has.Count.EqualTo(2));
|
||||
var goodReset = api.SetByIdCalls.Single(call => call.Id.Equals(goodId));
|
||||
Assert.That(goodReset.Value, Is.EqualTo(0.25f));
|
||||
Assert.That(goodReset.IgnoreSeekSpeed, Is.True);
|
||||
Assert.That(warnings, Has.Count.EqualTo(1));
|
||||
StringAssert.Contains("failed", warnings[0]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RestoreFinalValues_JumpsImmediatelyThenNextYarnTargetUsesSeekSpeed()
|
||||
{
|
||||
var api = new FakeFmodGlobalParameterApi();
|
||||
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
|
||||
var registry = new YarnGlobalParameterRegistry(api);
|
||||
var warnings = new List<string>();
|
||||
|
||||
registry.RestoreFinalValues(
|
||||
new Dictionary<string, float>
|
||||
{
|
||||
["stage"] = 2.37f,
|
||||
["missing"] = 1f
|
||||
},
|
||||
warnings.Add);
|
||||
registry.SetFromYarn("stage", 3f);
|
||||
|
||||
Assert.That(api.SetByNameCalls, Has.Count.EqualTo(3));
|
||||
Assert.That(api.SetByNameCalls[0].Value, Is.EqualTo(2.37f));
|
||||
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.True);
|
||||
Assert.That(api.SetByNameCalls[1].Name, Is.EqualTo("missing"));
|
||||
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.True);
|
||||
Assert.That(api.SetByNameCalls[2].Value, Is.EqualTo(3f));
|
||||
Assert.That(api.SetByNameCalls[2].IgnoreSeekSpeed, Is.False);
|
||||
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage" }));
|
||||
Assert.That(warnings, Has.Count.EqualTo(1));
|
||||
StringAssert.Contains("missing", warnings[0]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AudioSnapshotDto_RoundTripsFinalValuesWithoutChangingSchema()
|
||||
{
|
||||
var source = new SaveSnapshot();
|
||||
source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto
|
||||
{
|
||||
ambState = "Dream",
|
||||
yarnGlobalParameters = new Dictionary<string, float>
|
||||
{
|
||||
["reverb"] = 0.42f,
|
||||
["hs1LogStage"] = 2.37f
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(source);
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
|
||||
var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio])
|
||||
.ToObject<AudioSnapshotDto>();
|
||||
|
||||
Assert.That(restored.schemaVersion, Is.EqualTo(2));
|
||||
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(2));
|
||||
Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f));
|
||||
Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f));
|
||||
}
|
||||
|
||||
private readonly struct SetByNameCall
|
||||
{
|
||||
internal SetByNameCall(string name, float value, bool ignoreSeekSpeed)
|
||||
{
|
||||
Name = name;
|
||||
Value = value;
|
||||
IgnoreSeekSpeed = ignoreSeekSpeed;
|
||||
}
|
||||
|
||||
internal string Name { get; }
|
||||
internal float Value { get; }
|
||||
internal bool IgnoreSeekSpeed { get; }
|
||||
}
|
||||
|
||||
private readonly struct SetByIdCall
|
||||
{
|
||||
internal SetByIdCall(PARAMETER_ID id, float value, bool ignoreSeekSpeed)
|
||||
{
|
||||
Id = id;
|
||||
Value = value;
|
||||
IgnoreSeekSpeed = ignoreSeekSpeed;
|
||||
}
|
||||
|
||||
internal PARAMETER_ID Id { get; }
|
||||
internal float Value { get; }
|
||||
internal bool IgnoreSeekSpeed { get; }
|
||||
}
|
||||
|
||||
private readonly struct ParameterRead
|
||||
{
|
||||
internal ParameterRead(float value, float finalValue, RESULT result)
|
||||
{
|
||||
Value = value;
|
||||
FinalValue = finalValue;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
internal float Value { get; }
|
||||
internal float FinalValue { get; }
|
||||
internal RESULT Result { get; }
|
||||
}
|
||||
|
||||
private readonly struct ParameterDescriptionRead
|
||||
{
|
||||
internal ParameterDescriptionRead(
|
||||
PARAMETER_DESCRIPTION description,
|
||||
RESULT result)
|
||||
{
|
||||
Description = description;
|
||||
Result = result;
|
||||
}
|
||||
|
||||
internal PARAMETER_DESCRIPTION Description { get; }
|
||||
internal RESULT Result { get; }
|
||||
}
|
||||
|
||||
private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi
|
||||
{
|
||||
internal readonly Dictionary<string, RESULT> SetByNameResults =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
internal readonly Dictionary<string, ParameterRead> Reads =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
internal readonly Dictionary<string, ParameterDescriptionRead> Descriptions =
|
||||
new(System.StringComparer.OrdinalIgnoreCase);
|
||||
internal readonly Dictionary<PARAMETER_ID, RESULT> SetByIdResults = new();
|
||||
internal readonly List<SetByNameCall> SetByNameCalls = new();
|
||||
internal readonly List<SetByIdCall> SetByIdCalls = new();
|
||||
|
||||
public RESULT SetParameterByName(
|
||||
string name,
|
||||
float value,
|
||||
bool ignoreSeekSpeed)
|
||||
{
|
||||
SetByNameCalls.Add(new SetByNameCall(name, value, ignoreSeekSpeed));
|
||||
return SetByNameResults.TryGetValue(name, out var result)
|
||||
? result
|
||||
: RESULT.OK;
|
||||
}
|
||||
|
||||
public RESULT GetParameterByName(
|
||||
string name,
|
||||
out float value,
|
||||
out float finalValue)
|
||||
{
|
||||
if (Reads.TryGetValue(name, out var read))
|
||||
{
|
||||
value = read.Value;
|
||||
finalValue = read.FinalValue;
|
||||
return read.Result;
|
||||
}
|
||||
|
||||
value = 0f;
|
||||
finalValue = 0f;
|
||||
return RESULT.OK;
|
||||
}
|
||||
|
||||
public RESULT GetParameterDescriptionByName(
|
||||
string name,
|
||||
out PARAMETER_DESCRIPTION description)
|
||||
{
|
||||
if (Descriptions.TryGetValue(name, out var read))
|
||||
{
|
||||
description = read.Description;
|
||||
return read.Result;
|
||||
}
|
||||
|
||||
description = default;
|
||||
return RESULT.ERR_EVENT_NOTFOUND;
|
||||
}
|
||||
|
||||
public RESULT SetParameterById(
|
||||
PARAMETER_ID id,
|
||||
float value,
|
||||
bool ignoreSeekSpeed)
|
||||
{
|
||||
SetByIdCalls.Add(new SetByIdCall(id, value, ignoreSeekSpeed));
|
||||
return SetByIdResults.TryGetValue(id, out var result)
|
||||
? result
|
||||
: RESULT.OK;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e120d9cfce5a4723a6e640b3cc3ce29b
|
||||
@@ -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:
|
||||
@@ -22,7 +22,7 @@ namespace AibisDream.SystemEditor
|
||||
private DropdownField _folderDropdown;
|
||||
private readonly Dictionary<string, string> _displayToPath = new();
|
||||
|
||||
[MenuItem("Window/Chapter Graph Editor")]
|
||||
[MenuItem(AibisEditorMenus.ChapterGraphEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<ChapterGraphEditorWindow>("Chapter Graph Editor");
|
||||
@@ -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: 665561f3805a96446b45ea1d50bfe520
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,117 @@
|
||||
#if UNITY_EDITOR
|
||||
using System.Linq;
|
||||
using AibisDream.Kit;
|
||||
using AibisDream.UI;
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.DeveloperMode.Editor
|
||||
{
|
||||
public sealed class DeveloperModeCoreTests
|
||||
{
|
||||
[Test]
|
||||
public void RuntimeLogBufferEvictsOldestAndPreservesOrder()
|
||||
{
|
||||
var buffer = new RuntimeLogBuffer(3);
|
||||
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, "one"));
|
||||
buffer.Add(LogEntry.System(LogLevel.Warning, LogCategory.Save, "two"));
|
||||
buffer.Add(LogEntry.System(LogLevel.Error, LogCategory.Yarn, "three"));
|
||||
buffer.Add(LogEntry.System(LogLevel.Fatal, LogCategory.Scene, "four"));
|
||||
|
||||
var snapshot = buffer.Snapshot();
|
||||
Assert.That(snapshot.Select(item => item.Message), Is.EqualTo(new[] { "two", "three", "four" }));
|
||||
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
|
||||
Assert.That(snapshot.Length, Is.EqualTo(buffer.Capacity));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RuntimeLogBufferAcceptsConcurrentWritersAndClears()
|
||||
{
|
||||
var buffer = new RuntimeLogBuffer(128);
|
||||
System.Threading.Tasks.Parallel.For(0, 1000, index =>
|
||||
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, index.ToString())));
|
||||
|
||||
var snapshot = buffer.Snapshot();
|
||||
Assert.That(snapshot.Length, Is.EqualTo(128));
|
||||
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
|
||||
buffer.Clear();
|
||||
Assert.That(buffer.Snapshot(), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void YarnStorageTypedSettersAndDebugSnapshotAreReadOnlyAndConsistent()
|
||||
{
|
||||
var go = new GameObject("YarnVariableStorage Test");
|
||||
try
|
||||
{
|
||||
var storage = go.AddComponent<YarnVariableStorage>();
|
||||
storage.SetValue("$score", 12.5f);
|
||||
storage.SetValue("$global_flag", true);
|
||||
storage.SetValue("$name", "AIBIS");
|
||||
|
||||
var snapshot = DeveloperVariableSnapshot.Capture(storage, null);
|
||||
Assert.That(snapshot.Select(item => item.Name),
|
||||
Is.EquivalentTo(new[] { "$score", "$global_flag", "$name" }));
|
||||
Assert.That(snapshot.Single(item => item.Name == "$score").Value, Is.EqualTo("12.5"));
|
||||
Assert.That(snapshot.Single(item => item.Name == "$global_flag").IsGlobal, Is.True);
|
||||
Assert.That(snapshot.All(item => item.HasRuntimeOverride), Is.True);
|
||||
|
||||
storage.ClearLocal();
|
||||
var remaining = DeveloperVariableSnapshot.Capture(storage, null);
|
||||
Assert.That(remaining.Select(item => item.Name), Is.EqualTo(new[] { "$global_flag" }));
|
||||
storage.Clear();
|
||||
Assert.That(DeveloperVariableSnapshot.Capture(storage, null), Is.Empty);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(go);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EditorBuildEnablesDeveloperMode()
|
||||
{
|
||||
Assert.That(DeveloperModeGate.IsEnabled, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PrefabIsConfiguredAndPersistenceReferencesIt()
|
||||
{
|
||||
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
|
||||
const string scenePath = "Assets/Scenes/Persistence.unity";
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
Assert.That(prefab.activeSelf, Is.False);
|
||||
var panel = prefab.GetComponent<DeveloperModePanel>();
|
||||
Assert.That(panel, Is.Not.Null);
|
||||
Assert.That(panel.IsConfigured, Is.True);
|
||||
Assert.That(prefab.GetComponent<RectTransform>().sizeDelta, Is.EqualTo(new Vector2(840f, 760f)));
|
||||
Assert.That(AssetDatabase.GetDependencies(scenePath), Does.Contain(prefabPath));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PanelToggleIsNonModalAndEscapeClosesIt()
|
||||
{
|
||||
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
|
||||
var instance = Object.Instantiate(prefab);
|
||||
try
|
||||
{
|
||||
var panel = instance.GetComponent<DeveloperModePanel>();
|
||||
var originalScale = Time.timeScale;
|
||||
panel.TogglePanel();
|
||||
Assert.That(panel.IsOpen, Is.True);
|
||||
Assert.That(Time.timeScale, Is.EqualTo(originalScale));
|
||||
Assert.That(panel.HandleEscape(), Is.True);
|
||||
Assert.That(panel.IsOpen, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95ba9d1e80e3dc4e83bc408569c4955
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,476 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AibisDream.Framework;
|
||||
using AibisDream.SaveSystem;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.DeveloperMode.Editor.Tests
|
||||
{
|
||||
public sealed class TestSaveSystemTests
|
||||
{
|
||||
private string _root;
|
||||
private TestSaveRepository _repository;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"AibisDream-TestSaveTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
_repository = new TestSaveRepository(_root);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_SameAnchor_ReplacesSnapshotAndKeepsFirstSeenOrder()
|
||||
{
|
||||
var first = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneOne"));
|
||||
var second = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneTwo"));
|
||||
var scan = _repository.Scan("1.0");
|
||||
|
||||
Assert.That(scan.Entries.Count, Is.EqualTo(1));
|
||||
Assert.That(second.Meta.firstSeenOrder, Is.EqualTo(first.Meta.firstSeenOrder));
|
||||
Assert.That(scan.Entries[0].Meta.sceneName, Is.EqualTo("SceneTwo"));
|
||||
Assert.That(_repository.TryLoad(scan.Entries[0], out var snapshot, out var error), Is.True, error);
|
||||
Assert.That(snapshot.scene.sceneName, Is.EqualTo("SceneTwo"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_ReplacingEarlierEntry_DoesNotMoveItsOrder()
|
||||
{
|
||||
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
|
||||
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
|
||||
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA2"));
|
||||
|
||||
var entries = _repository.Scan().Entries.ToArray();
|
||||
Assert.That(entries.Select(item => item.NodeName), Is.EqualTo(new[] { "NodeA", "NodeB" }));
|
||||
Assert.That(entries[0].Meta.firstSeenOrder, Is.LessThan(entries[1].Meta.firstSeenOrder));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scan_RetainsMissingSnapshotAsInvalid()
|
||||
{
|
||||
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
|
||||
File.Delete(entry.SnapshotPath);
|
||||
|
||||
var scan = _repository.Scan();
|
||||
|
||||
Assert.That(scan.ValidCount, Is.Zero);
|
||||
Assert.That(scan.InvalidCount, Is.EqualTo(1));
|
||||
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.MissingSnapshot));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scan_RetainsOrphanSnapshotWithBrokenMeta()
|
||||
{
|
||||
var directory = Path.Combine(_root, "chapter", "broken");
|
||||
Directory.CreateDirectory(directory);
|
||||
File.WriteAllText(
|
||||
Path.Combine(directory, TestSaveRepository.SnapshotFileName),
|
||||
"{}",
|
||||
Encoding.UTF8);
|
||||
|
||||
var scan = _repository.Scan();
|
||||
|
||||
Assert.That(scan.InvalidCount, Is.EqualTo(1));
|
||||
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptMeta));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Validate_CorruptSnapshot_IsRetainedAndDisabledOnNextScan()
|
||||
{
|
||||
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
|
||||
File.WriteAllText(entry.SnapshotPath, "{broken", Encoding.UTF8);
|
||||
|
||||
Assert.That(_repository.Validate(entry, out var error), Is.False);
|
||||
var rescanned = _repository.Scan();
|
||||
|
||||
Assert.That(error, Does.Contain("无法解析"));
|
||||
Assert.That(rescanned.InvalidCount, Is.EqualTo(1));
|
||||
Assert.That(rescanned.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptSnapshot));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scan_RecoversCompleteStagingDirectory()
|
||||
{
|
||||
var final = Path.Combine(_root, "chapter", "node");
|
||||
var staging = final + ".__staging";
|
||||
Directory.CreateDirectory(staging);
|
||||
var request = CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA");
|
||||
var meta = CreateMeta(request, 1);
|
||||
File.WriteAllText(
|
||||
Path.Combine(staging, TestSaveRepository.MetaFileName),
|
||||
JsonConvert.SerializeObject(meta),
|
||||
Encoding.UTF8);
|
||||
File.WriteAllText(
|
||||
Path.Combine(staging, TestSaveRepository.SnapshotFileName),
|
||||
SnapshotPersistence.Serialize(request.Snapshot),
|
||||
Encoding.UTF8);
|
||||
|
||||
var scan = _repository.Scan();
|
||||
|
||||
Assert.That(Directory.Exists(final), Is.True);
|
||||
Assert.That(Directory.Exists(staging), Is.False);
|
||||
Assert.That(scan.ValidCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Record_ConcurrentRequests_ProducesCompleteUniqueEntries()
|
||||
{
|
||||
var tasks = Enumerable.Range(0, 12)
|
||||
.Select(index => Task.Run(() =>
|
||||
_repository.Record(CreateRequest(
|
||||
"ChapterA",
|
||||
"ProjectA",
|
||||
$"Node{index:00}",
|
||||
$"Scene{index:00}"))))
|
||||
.ToArray();
|
||||
|
||||
Task.WaitAll(tasks);
|
||||
var scan = _repository.Scan();
|
||||
|
||||
Assert.That(scan.ValidCount, Is.EqualTo(12));
|
||||
Assert.That(scan.InvalidCount, Is.Zero);
|
||||
Assert.That(scan.Entries.Select(item => item.Meta.firstSeenOrder).Distinct().Count(), Is.EqualTo(12));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DeleteInvalid_RemovesOnlyInvalidEntries()
|
||||
{
|
||||
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
|
||||
var invalid = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
|
||||
File.Delete(invalid.SnapshotPath);
|
||||
|
||||
var removed = _repository.DeleteInvalid(out var error);
|
||||
var scan = _repository.Scan();
|
||||
|
||||
Assert.That(error, Is.Null);
|
||||
Assert.That(removed, Is.EqualTo(1));
|
||||
Assert.That(scan.ValidCount, Is.EqualTo(1));
|
||||
Assert.That(scan.InvalidCount, Is.Zero);
|
||||
}
|
||||
|
||||
[TestCase(new[] { "hub" }, true)]
|
||||
[TestCase(new[] { "linear" }, true)]
|
||||
[TestCase(new[] { "content" }, true)]
|
||||
[TestCase(new[] { "event" }, false)]
|
||||
[TestCase(new[] { "hub", "no_save" }, false)]
|
||||
[TestCase(new string[0], true)]
|
||||
public void SilentCoverageEvaluator_MatchesSavePointTagSemantics(string[] tags, bool expected)
|
||||
{
|
||||
var actual = SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(
|
||||
"Node",
|
||||
tags,
|
||||
out _);
|
||||
Assert.That(actual, Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[TestCase(new[] { "content" }, NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode)]
|
||||
[TestCase(new[] { "interaction" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
|
||||
[TestCase(new[] { "content", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
|
||||
[TestCase(new[] { "event", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
|
||||
[TestCase(new[] { "interaction", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
|
||||
[TestCase(new[] { "interaction", "no_save" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
|
||||
[TestCase(new[] { "content", "event" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
|
||||
public void ResolveNodePolicy_MapsNodeTypeAndModifiers(
|
||||
string[] tags,
|
||||
NodeSaveTiming expectedTiming,
|
||||
SaveResumeMode expectedResumeMode)
|
||||
{
|
||||
var policy = SavePointEvaluator.ResolveNodePolicy(
|
||||
"Node",
|
||||
tags,
|
||||
out _,
|
||||
logWarnings: false);
|
||||
|
||||
Assert.That(policy.Timing, Is.EqualTo(expectedTiming));
|
||||
Assert.That(policy.ResumeMode, Is.EqualTo(expectedResumeMode));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Repository_StateOnlySnapshot_PreservesAnchorNode()
|
||||
{
|
||||
var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA");
|
||||
request.Snapshot.anchor.startDialogueOnRestore = false;
|
||||
request.SaveTrigger = "DialogueExit";
|
||||
request.ResumeMode = nameof(SaveResumeMode.StateOnly);
|
||||
request.StartDialogueOnRestore = false;
|
||||
request.DedupeKey = TestSaveRecorder.BuildDedupeKey(
|
||||
request.SceneSoName,
|
||||
request.YarnProjectId,
|
||||
request.NodeName,
|
||||
request.ResumeMode);
|
||||
request.EntryId = TestSaveRepository.StableHash(request.DedupeKey);
|
||||
|
||||
var entry = _repository.Record(request);
|
||||
var loaded = _repository.TryLoad(entry, out var snapshot, out var error);
|
||||
|
||||
Assert.That(loaded, Is.True, error);
|
||||
Assert.That(entry.NodeName, Is.EqualTo("InteractionNode"));
|
||||
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.StateOnly)));
|
||||
Assert.That(snapshot.anchor.nodeName, Is.EqualTo("InteractionNode"));
|
||||
Assert.That(snapshot.anchor.startDialogueOnRestore, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SnapshotPersistence_MissingRestoreFlag_IsRejected()
|
||||
{
|
||||
const string json =
|
||||
"{\"schemaVersion\":2,\"anchor\":{\"sceneSoName\":\"ChapterA\"," +
|
||||
"\"yarnProjectId\":\"ProjectA\",\"nodeName\":\"NodeA\"}}";
|
||||
|
||||
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
|
||||
() => SnapshotPersistence.Deserialize(json));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SerialTaskQueue_DoesNotDropOrReorderRequests()
|
||||
{
|
||||
var queue = new SerialTaskQueue();
|
||||
var releaseFirst = new TaskCompletionSource<bool>();
|
||||
var firstStarted = new TaskCompletionSource<bool>();
|
||||
var order = new List<int>();
|
||||
|
||||
var first = queue.Enqueue(async () =>
|
||||
{
|
||||
order.Add(1);
|
||||
firstStarted.SetResult(true);
|
||||
await releaseFirst.Task;
|
||||
order.Add(2);
|
||||
});
|
||||
var second = queue.Enqueue(() =>
|
||||
{
|
||||
order.Add(3);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
Assert.That(firstStarted.Task.Wait(TimeSpan.FromSeconds(2)), Is.True);
|
||||
Assert.That(order, Is.EqualTo(new[] { 1 }));
|
||||
Assert.That(queue.PendingCount, Is.EqualTo(2));
|
||||
|
||||
releaseFirst.SetResult(true);
|
||||
Assert.That(
|
||||
Task.WaitAll(
|
||||
new[] { first.Completion, second.Completion },
|
||||
TimeSpan.FromSeconds(2)),
|
||||
Is.True);
|
||||
|
||||
Assert.That(first.Sequence, Is.LessThan(second.Sequence));
|
||||
Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SaveRequest_MapsResumeModeToExplicitAnchorFlag()
|
||||
{
|
||||
var enter = SaveRequest.NodeEnter("NodeA", "ProjectA", "ChapterA", 3, 7);
|
||||
var exit = SaveRequest.DialogueExit("NodeB", "ProjectA", "ChapterA", 3, 9);
|
||||
|
||||
Assert.That(enter.AnchorSpec.NodeName, Is.EqualTo("NodeA"));
|
||||
Assert.That(enter.AnchorSpec.StartDialogueOnRestore, Is.True);
|
||||
Assert.That(exit.AnchorSpec.NodeName, Is.EqualTo("NodeB"));
|
||||
Assert.That(exit.AnchorSpec.StartDialogueOnRestore, Is.False);
|
||||
}
|
||||
|
||||
[TestCase(false, "DetourNode", true)]
|
||||
[TestCase(false, "NoSaveNode", false)]
|
||||
[TestCase(true, "AnotherNode", true)]
|
||||
public void ValidateDialogueTiming_NonExitRequestIgnoresLaterDialogueState(
|
||||
bool explicitCommand,
|
||||
string currentNodeName,
|
||||
bool isDialogueRunning)
|
||||
{
|
||||
var request = explicitCommand
|
||||
? SaveRequest.Explicit("SourceNode", "ProjectA", "ChapterA", 3, 7)
|
||||
: SaveRequest.NodeEnter("SourceNode", "ProjectA", "ChapterA", 3, 7);
|
||||
|
||||
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
|
||||
request,
|
||||
currentDialogueRunId: 99,
|
||||
currentFlowRevision: 101,
|
||||
currentNodeName: currentNodeName,
|
||||
isDialogueRunning: isDialogueRunning,
|
||||
out var reason);
|
||||
|
||||
Assert.That(valid, Is.True, reason);
|
||||
Assert.That(request.AnchorSpec.NodeName, Is.EqualTo("SourceNode"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ValidateDialogueTiming_ConsecutiveNodeEnterRequestsRemainEligible()
|
||||
{
|
||||
var first = SaveRequest.NodeEnter("FirstNode", "ProjectA", "ChapterA", 3, 7);
|
||||
var second = SaveRequest.NodeEnter("SecondNode", "ProjectA", "ChapterA", 3, 8);
|
||||
|
||||
var firstValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
|
||||
first,
|
||||
currentDialogueRunId: 3,
|
||||
currentFlowRevision: 8,
|
||||
currentNodeName: "SecondNode",
|
||||
isDialogueRunning: true,
|
||||
out var firstReason);
|
||||
var secondValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
|
||||
second,
|
||||
currentDialogueRunId: 3,
|
||||
currentFlowRevision: 8,
|
||||
currentNodeName: "SecondNode",
|
||||
isDialogueRunning: true,
|
||||
out var secondReason);
|
||||
|
||||
Assert.That(firstValid, Is.True, firstReason);
|
||||
Assert.That(secondValid, Is.True, secondReason);
|
||||
Assert.That(first.AnchorSpec.NodeName, Is.EqualTo("FirstNode"));
|
||||
Assert.That(second.AnchorSpec.NodeName, Is.EqualTo("SecondNode"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ValidateDialogueTiming_DialogueExitAcceptsStableCompletedFlow()
|
||||
{
|
||||
var request = SaveRequest.DialogueExit(
|
||||
"InteractionNode",
|
||||
"ProjectA",
|
||||
"ChapterA",
|
||||
3,
|
||||
9);
|
||||
|
||||
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
|
||||
request,
|
||||
currentDialogueRunId: 3,
|
||||
currentFlowRevision: 9,
|
||||
currentNodeName: null,
|
||||
isDialogueRunning: false,
|
||||
out var reason);
|
||||
|
||||
Assert.That(valid, Is.True, reason);
|
||||
}
|
||||
|
||||
[TestCase(4, 9, null, false, "dialogue run changed")]
|
||||
[TestCase(3, 10, null, false, "flow revision changed")]
|
||||
[TestCase(3, 9, "DetourNode", false, "current Yarn node")]
|
||||
[TestCase(3, 9, "NoSaveNode", false, "current Yarn node")]
|
||||
[TestCase(3, 9, "JumpTarget", true, "still running")]
|
||||
public void ValidateDialogueTiming_DialogueExitRejectsChangedOrRunningFlow(
|
||||
long currentDialogueRunId,
|
||||
long currentFlowRevision,
|
||||
string currentNodeName,
|
||||
bool isDialogueRunning,
|
||||
string expectedReason)
|
||||
{
|
||||
var request = SaveRequest.DialogueExit(
|
||||
"InteractionNode",
|
||||
"ProjectA",
|
||||
"ChapterA",
|
||||
3,
|
||||
9);
|
||||
|
||||
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
|
||||
request,
|
||||
currentDialogueRunId,
|
||||
currentFlowRevision,
|
||||
currentNodeName,
|
||||
isDialogueRunning,
|
||||
out var reason);
|
||||
|
||||
Assert.That(valid, Is.False);
|
||||
Assert.That(reason, Does.Contain(expectedReason));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InteractionLock_ComposesWithDialogueLock()
|
||||
{
|
||||
var gameObject = new GameObject("EventSystemEx-Test");
|
||||
try
|
||||
{
|
||||
var eventSystem = gameObject.AddComponent<EventSystemEx>();
|
||||
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
|
||||
var checkpointLock = eventSystem.AcquireInteractionLock("test");
|
||||
|
||||
EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd);
|
||||
Assert.That(eventSystem.isLocked, Is.True);
|
||||
|
||||
checkpointLock.Dispose();
|
||||
Assert.That(eventSystem.isLocked, Is.False);
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static TestSaveRecordRequest CreateRequest(
|
||||
string sceneSoName,
|
||||
string yarnProject,
|
||||
string nodeName,
|
||||
string sceneName)
|
||||
{
|
||||
var key = TestSaveRecorder.BuildDedupeKey(sceneSoName, yarnProject, nodeName);
|
||||
return new TestSaveRecordRequest
|
||||
{
|
||||
Snapshot = new SaveSnapshot
|
||||
{
|
||||
gameVersion = "1.0",
|
||||
savedAt = "2026-01-01 00:00:00",
|
||||
scene = new SceneSnapshotDto { sceneName = sceneName },
|
||||
anchor = new AnchorSnapshot
|
||||
{
|
||||
sceneSoName = sceneSoName,
|
||||
yarnProjectId = yarnProject,
|
||||
nodeName = nodeName,
|
||||
startDialogueOnRestore = true
|
||||
}
|
||||
},
|
||||
DedupeKey = key,
|
||||
EntryId = TestSaveRepository.StableHash(key),
|
||||
ChapterId = sceneSoName,
|
||||
ChapterTitle = sceneSoName,
|
||||
SceneSoName = sceneSoName,
|
||||
YarnProjectId = yarnProject,
|
||||
NodeName = nodeName,
|
||||
SaveTrigger = "NodeEnter",
|
||||
ResumeMode = nameof(SaveResumeMode.RestartNode),
|
||||
StartDialogueOnRestore = true,
|
||||
SceneName = sceneName,
|
||||
GameVersion = "1.0"
|
||||
};
|
||||
}
|
||||
|
||||
private static TestSaveMeta CreateMeta(TestSaveRecordRequest request, long order)
|
||||
{
|
||||
return new TestSaveMeta
|
||||
{
|
||||
entryId = request.EntryId,
|
||||
dedupeKey = request.DedupeKey,
|
||||
chapterId = request.ChapterId,
|
||||
chapterTitle = request.ChapterTitle,
|
||||
sceneSoName = request.SceneSoName,
|
||||
yarnProjectId = request.YarnProjectId,
|
||||
nodeName = request.NodeName,
|
||||
saveTrigger = request.SaveTrigger,
|
||||
resumeMode = request.ResumeMode,
|
||||
startDialogueOnRestore = request.StartDialogueOnRestore,
|
||||
sceneName = request.SceneName,
|
||||
firstSeenOrder = order,
|
||||
firstRecordedAt = "2026-01-01 00:00:00",
|
||||
lastRecordedAt = "2026-01-01 00:00:00",
|
||||
snapshotSchemaVersion = SaveSnapshotSchema.CurrentVersion,
|
||||
gameVersion = request.GameVersion
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbbba0c3621cf204483f453bad2d75a4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9e244d3d62dec3f4f881f7e681da8c03
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "AibisDream.FrameAnimation.Editor",
|
||||
"rootNamespace": "AibisDream.FrameAnimation.Editor",
|
||||
"references": [
|
||||
"AibisDream.FrameAnimation.Runtime",
|
||||
"AibisDream.Menus",
|
||||
"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).Trim(),
|
||||
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,254 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace AibisDream.FrameAnimation.Editor
|
||||
{
|
||||
internal enum FrameAnimationBrowserEntryKind
|
||||
{
|
||||
Header,
|
||||
Clip,
|
||||
Flow,
|
||||
Source
|
||||
}
|
||||
|
||||
internal enum FrameAnimationBrowserIssueState
|
||||
{
|
||||
None,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationBrowserEntry
|
||||
{
|
||||
public FrameAnimationBrowserEntryKind Kind;
|
||||
public string Title = string.Empty;
|
||||
public string Id = string.Empty;
|
||||
public string TypeLabel = string.Empty;
|
||||
public string Meta = string.Empty;
|
||||
public string StatusLabel = string.Empty;
|
||||
public Color? AccentColor;
|
||||
public FrameAnimationBrowserIssueState IssueState;
|
||||
public FrameAnimationEditorSelection Selection;
|
||||
|
||||
public bool IsHeader => Kind == FrameAnimationBrowserEntryKind.Header;
|
||||
}
|
||||
|
||||
internal readonly struct FrameAnimationFlowColorPalette
|
||||
{
|
||||
public readonly Color Raw;
|
||||
public readonly Color Stroke;
|
||||
public readonly Color Header;
|
||||
public readonly Color Muted;
|
||||
public readonly Color Text;
|
||||
|
||||
public FrameAnimationFlowColorPalette(Color raw, Color stroke, Color header, Color muted, Color text)
|
||||
{
|
||||
Raw = raw;
|
||||
Stroke = stroke;
|
||||
Header = header;
|
||||
Muted = muted;
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class FrameAnimationFlowColorUtility
|
||||
{
|
||||
private static readonly Color Panel = new Color32(43, 47, 52, 255);
|
||||
private static readonly Color LightText = new Color32(244, 246, 248, 255);
|
||||
private static readonly Color DarkText = new Color32(24, 27, 30, 255);
|
||||
private static readonly Color Neutral = new Color32(100, 168, 216, 255);
|
||||
|
||||
internal static Color ResolveRaw(FrameAnimationGraph graph, AnimationFlow flow)
|
||||
{
|
||||
return Opaque(ResolveStored(graph, flow));
|
||||
}
|
||||
|
||||
internal static Color ResolveStored(FrameAnimationGraph graph, AnimationFlow flow)
|
||||
{
|
||||
if (graph == null || flow == null)
|
||||
{
|
||||
return Neutral;
|
||||
}
|
||||
var data = graph.EditorData?.FlowEditorData == null
|
||||
? null
|
||||
: System.Linq.Enumerable.FirstOrDefault(graph.EditorData.FlowEditorData,
|
||||
item => item != null && item.FlowId == flow.Id);
|
||||
return data?.Color ?? Neutral;
|
||||
}
|
||||
|
||||
internal static FrameAnimationFlowColorPalette CreatePalette(Color value)
|
||||
{
|
||||
var raw = Opaque(value);
|
||||
var luminance = Luminance(raw);
|
||||
var stroke = luminance < 0.30f
|
||||
? Color.Lerp(raw, Color.white, Mathf.InverseLerp(0.30f, 0.02f, luminance) * 0.42f)
|
||||
: luminance > 0.82f
|
||||
? Color.Lerp(raw, Color.black, Mathf.InverseLerp(0.82f, 1f, luminance) * 0.22f)
|
||||
: raw;
|
||||
stroke.a = 1f;
|
||||
var header = Color.Lerp(Panel, stroke, 0.68f);
|
||||
header.a = 1f;
|
||||
var muted = Color.Lerp(Panel, stroke, 0.42f);
|
||||
muted.a = 1f;
|
||||
var text = Luminance(header) > 0.22f ? DarkText : LightText;
|
||||
return new FrameAnimationFlowColorPalette(raw, stroke, header, muted, text);
|
||||
}
|
||||
|
||||
internal static Color Opaque(Color value) => new Color(
|
||||
Mathf.Clamp01(value.r), Mathf.Clamp01(value.g), Mathf.Clamp01(value.b), 1f);
|
||||
|
||||
internal static float Luminance(Color value)
|
||||
{
|
||||
var linear = value.linear;
|
||||
return linear.r * 0.2126f + linear.g * 0.7152f + linear.b * 0.0722f;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationResourceRowElement : VisualElement
|
||||
{
|
||||
private readonly VisualElement flowDot;
|
||||
private readonly Label typeBadge;
|
||||
private readonly Label titleLabel;
|
||||
private readonly Label idLabel;
|
||||
private readonly Label metaLabel;
|
||||
private readonly Label statusBadge;
|
||||
|
||||
internal FrameAnimationResourceRowElement()
|
||||
{
|
||||
AddToClassList("fa-resource-row");
|
||||
|
||||
var main = new VisualElement();
|
||||
main.AddToClassList("fa-resource-row__main");
|
||||
flowDot = new VisualElement { pickingMode = PickingMode.Ignore };
|
||||
flowDot.AddToClassList("fa-flow-dot");
|
||||
typeBadge = new Label { pickingMode = PickingMode.Ignore };
|
||||
typeBadge.AddToClassList("fa-badge");
|
||||
titleLabel = new Label { pickingMode = PickingMode.Ignore };
|
||||
titleLabel.AddToClassList("fa-resource-row__name");
|
||||
idLabel = new Label { pickingMode = PickingMode.Ignore };
|
||||
idLabel.AddToClassList("fa-resource-row__id");
|
||||
statusBadge = new Label { pickingMode = PickingMode.Ignore };
|
||||
statusBadge.AddToClassList("fa-badge");
|
||||
main.Add(flowDot);
|
||||
main.Add(typeBadge);
|
||||
main.Add(titleLabel);
|
||||
main.Add(idLabel);
|
||||
main.Add(statusBadge);
|
||||
|
||||
metaLabel = new Label { pickingMode = PickingMode.Ignore };
|
||||
metaLabel.AddToClassList("fa-resource-row__meta");
|
||||
Add(main);
|
||||
Add(metaLabel);
|
||||
}
|
||||
|
||||
internal void Bind(FrameAnimationBrowserEntry entry, bool selected)
|
||||
{
|
||||
userData = entry;
|
||||
var isHeader = entry?.IsHeader == true;
|
||||
EnableInClassList("fa-resource-group", isHeader);
|
||||
EnableInClassList("fa-resource-row--selected", selected);
|
||||
EnableInClassList("fa-resource-row--error", entry?.IssueState == FrameAnimationBrowserIssueState.Error);
|
||||
EnableInClassList("fa-resource-row--warning", entry?.IssueState == FrameAnimationBrowserIssueState.Warning);
|
||||
if (entry == null)
|
||||
{
|
||||
style.display = DisplayStyle.None;
|
||||
return;
|
||||
}
|
||||
style.display = DisplayStyle.Flex;
|
||||
titleLabel.text = entry.Title;
|
||||
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
idLabel.text = entry.Id;
|
||||
metaLabel.text = entry.Meta;
|
||||
typeBadge.text = entry.TypeLabel;
|
||||
typeBadge.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
|
||||
idLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
|
||||
metaLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
|
||||
statusBadge.text = entry.StatusLabel;
|
||||
statusBadge.style.display = isHeader || string.IsNullOrEmpty(entry.StatusLabel) ? DisplayStyle.None : DisplayStyle.Flex;
|
||||
flowDot.style.display = !isHeader && entry.AccentColor.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (entry.AccentColor.HasValue)
|
||||
{
|
||||
flowDot.style.backgroundColor = FrameAnimationFlowColorUtility.CreatePalette(entry.AccentColor.Value).Stroke;
|
||||
}
|
||||
typeBadge.EnableInClassList("fa-badge--blue", entry.Kind == FrameAnimationBrowserEntryKind.Clip);
|
||||
typeBadge.EnableInClassList("fa-badge--purple", entry.Kind == FrameAnimationBrowserEntryKind.Flow);
|
||||
typeBadge.EnableInClassList("fa-badge--green", entry.Kind == FrameAnimationBrowserEntryKind.Source);
|
||||
statusBadge.EnableInClassList("fa-badge--red", entry.IssueState == FrameAnimationBrowserIssueState.Error);
|
||||
statusBadge.EnableInClassList("fa-badge--yellow", entry.IssueState == FrameAnimationBrowserIssueState.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FrameAnimationFrameRowElement : VisualElement
|
||||
{
|
||||
private readonly ObjectField spriteField;
|
||||
private readonly IntegerField durationField;
|
||||
private readonly Label frameNameLabel;
|
||||
private readonly Label sourceIndexLabel;
|
||||
private FrameClip clip;
|
||||
private int index;
|
||||
private bool readOnly;
|
||||
private Action changed;
|
||||
|
||||
internal FrameAnimationFrameRowElement()
|
||||
{
|
||||
AddToClassList("fa-frame-row");
|
||||
spriteField = new ObjectField("Sprite") { objectType = typeof(Sprite), allowSceneObjects = false };
|
||||
durationField = new IntegerField("Duration Ms");
|
||||
var readOnlyRow = new VisualElement();
|
||||
readOnlyRow.AddToClassList("fa-frame-row__readonly");
|
||||
frameNameLabel = new Label();
|
||||
sourceIndexLabel = new Label();
|
||||
readOnlyRow.Add(frameNameLabel);
|
||||
readOnlyRow.Add(sourceIndexLabel);
|
||||
Add(spriteField);
|
||||
Add(durationField);
|
||||
Add(readOnlyRow);
|
||||
spriteField.RegisterValueChangedCallback(OnSpriteChanged);
|
||||
durationField.RegisterValueChangedCallback(OnDurationChanged);
|
||||
}
|
||||
|
||||
internal void Bind(FrameClip value, int itemIndex, bool isReadOnly, Action onChanged)
|
||||
{
|
||||
clip = value;
|
||||
index = itemIndex;
|
||||
readOnly = isReadOnly;
|
||||
changed = onChanged;
|
||||
userData = itemIndex;
|
||||
var frame = clip != null && index >= 0 && index < clip.Frames.Count ? clip.Frames[index] : null;
|
||||
spriteField.SetValueWithoutNotify(frame?.Sprite);
|
||||
durationField.SetValueWithoutNotify(frame?.DurationMs ?? 100);
|
||||
frameNameLabel.text = "Frame " + (frame?.FrameName ?? string.Empty);
|
||||
sourceIndexLabel.text = "Source " + (frame?.SourceIndex.ToString() ?? "-1");
|
||||
spriteField.SetEnabled(!readOnly);
|
||||
durationField.SetEnabled(!readOnly);
|
||||
}
|
||||
|
||||
private void OnSpriteChanged(ChangeEvent<UnityEngine.Object> evt)
|
||||
{
|
||||
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
|
||||
var serialized = new SerializedObject(clip);
|
||||
serialized.Update();
|
||||
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
|
||||
element.FindPropertyRelative("sprite").objectReferenceValue = evt.newValue;
|
||||
serialized.ApplyModifiedProperties();
|
||||
EditorUtility.SetDirty(clip);
|
||||
changed?.Invoke();
|
||||
}
|
||||
|
||||
private void OnDurationChanged(ChangeEvent<int> evt)
|
||||
{
|
||||
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
|
||||
var serialized = new SerializedObject(clip);
|
||||
serialized.Update();
|
||||
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
|
||||
element.FindPropertyRelative("durationMs").intValue = Mathf.Max(1, evt.newValue);
|
||||
serialized.ApplyModifiedProperties();
|
||||
durationField.SetValueWithoutNotify(Mathf.Max(1, evt.newValue));
|
||||
EditorUtility.SetDirty(clip);
|
||||
changed?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 876191b33b10eef44b133cc291ebc912
|
||||
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,783 @@
|
||||
:root {
|
||||
--fa-bg: rgb(27, 29, 32);
|
||||
--fa-canvas: rgb(30, 32, 35);
|
||||
--fa-panel: rgb(36, 39, 43);
|
||||
--fa-panel-raised: rgb(43, 46, 51);
|
||||
--fa-panel-hover: rgb(48, 52, 58);
|
||||
--fa-field: rgb(28, 31, 34);
|
||||
--fa-line: rgb(17, 19, 21);
|
||||
--fa-line-soft: rgb(62, 67, 74);
|
||||
--fa-text: rgb(215, 217, 220);
|
||||
--fa-muted: rgb(143, 150, 158);
|
||||
--fa-accent: rgb(61, 145, 212);
|
||||
--fa-accent-soft: rgb(36, 79, 112);
|
||||
--fa-success: rgb(86, 185, 123);
|
||||
--fa-warning: rgb(224, 184, 88);
|
||||
--fa-error: rgb(223, 106, 106);
|
||||
}
|
||||
|
||||
.fa-workbench {
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
background-color: var(--fa-bg);
|
||||
color: var(--fa-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fa-global-toolbar-host,
|
||||
.fa-main-host,
|
||||
.fa-bottom-host {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fa-main-host {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.fa-workbench .unity-toolbar {
|
||||
min-height: 32px;
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
background-color: var(--fa-panel-raised);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-workbench .unity-toolbar-button,
|
||||
.fa-workbench .unity-button {
|
||||
min-height: 24px;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
border-left-color: var(--fa-line);
|
||||
border-right-color: var(--fa-line);
|
||||
border-top-color: var(--fa-line);
|
||||
border-bottom-color: var(--fa-line);
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
background-color: rgb(54, 58, 63);
|
||||
color: var(--fa-text);
|
||||
}
|
||||
|
||||
.fa-workbench .unity-toolbar-button:hover,
|
||||
.fa-workbench .unity-button:hover {
|
||||
background-color: rgb(70, 75, 81);
|
||||
}
|
||||
|
||||
.fa-button--primary {
|
||||
background-color: rgb(42, 105, 150);
|
||||
border-left-color: rgb(25, 74, 104);
|
||||
border-right-color: rgb(25, 74, 104);
|
||||
border-top-color: rgb(25, 74, 104);
|
||||
border-bottom-color: rgb(25, 74, 104);
|
||||
}
|
||||
|
||||
.fa-button--danger {
|
||||
background-color: rgb(87, 54, 56);
|
||||
color: rgb(244, 205, 207);
|
||||
}
|
||||
|
||||
.fa-toolbar-group {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fa-toolbar-separator {
|
||||
width: 1px;
|
||||
height: 21px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
background-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-panel {
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
background-color: var(--fa-panel);
|
||||
}
|
||||
|
||||
.fa-panel-header {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
-unity-text-align: middle-left;
|
||||
-unity-font-style: bold;
|
||||
background-color: rgb(44, 47, 51);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-resizer--vertical {
|
||||
width: 4px;
|
||||
background-color: rgb(24, 26, 29);
|
||||
}
|
||||
|
||||
.fa-resizer--horizontal {
|
||||
height: 4px;
|
||||
background-color: rgb(24, 26, 29);
|
||||
}
|
||||
|
||||
.fa-resizer--vertical:hover,
|
||||
.fa-resizer--horizontal:hover {
|
||||
background-color: var(--fa-accent);
|
||||
}
|
||||
|
||||
.fa-resource-tabs {
|
||||
min-height: 34px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
padding-top: 3px;
|
||||
background-color: rgb(31, 34, 38);
|
||||
}
|
||||
|
||||
.fa-resource-tabs .unity-toolbar-button,
|
||||
.fa-bottom-tabs .unity-toolbar-button {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-height: 28px;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
border-left-width: 0;
|
||||
border-right-width: 0;
|
||||
border-top-width: 0;
|
||||
border-bottom-width: 2px;
|
||||
border-bottom-color: rgba(0, 0, 0, 0);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
color: rgb(169, 175, 182);
|
||||
}
|
||||
|
||||
.fa-resource-tabs .unity-toolbar-button:hover,
|
||||
.fa-bottom-tabs .unity-toolbar-button:hover {
|
||||
color: var(--fa-text);
|
||||
background-color: rgb(48, 52, 57);
|
||||
}
|
||||
|
||||
.fa-resource-tabs .unity-toolbar-button.fa-tab--active,
|
||||
.fa-bottom-tabs .unity-toolbar-button.fa-tab--active {
|
||||
color: white;
|
||||
background-color: var(--fa-panel-raised);
|
||||
border-bottom-color: var(--fa-accent);
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.fa-resource-tools {
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-resource-list {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
background-color: var(--fa-panel);
|
||||
}
|
||||
|
||||
.fa-resource-row {
|
||||
height: 48px;
|
||||
padding-left: 9px;
|
||||
padding-right: 7px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 4px;
|
||||
border-left-width: 3px;
|
||||
border-left-color: rgba(0, 0, 0, 0);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(32, 35, 39);
|
||||
}
|
||||
|
||||
.fa-resource-row:hover {
|
||||
background-color: var(--fa-panel-hover);
|
||||
}
|
||||
|
||||
.fa-resource-row--selected {
|
||||
background-color: rgb(37, 75, 103);
|
||||
border-left-color: rgb(98, 178, 234);
|
||||
}
|
||||
|
||||
.fa-resource-row--error {
|
||||
border-left-color: var(--fa-error);
|
||||
}
|
||||
|
||||
.fa-resource-row--warning {
|
||||
border-left-color: var(--fa-warning);
|
||||
}
|
||||
|
||||
.fa-resource-row__main,
|
||||
.fa-resource-row__meta,
|
||||
.fa-badge-row {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fa-resource-row__main {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.fa-resource-row__name {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
-unity-font-style: bold;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fa-resource-row__id {
|
||||
max-width: 115px;
|
||||
margin-left: 5px;
|
||||
color: rgb(156, 163, 171);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fa-resource-row__meta {
|
||||
height: 17px;
|
||||
color: var(--fa-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fa-resource-group {
|
||||
height: 27px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
-unity-text-align: middle-left;
|
||||
-unity-font-style: bold;
|
||||
color: rgb(184, 189, 195);
|
||||
background-color: rgb(41, 44, 49);
|
||||
border-top-width: 1px;
|
||||
border-top-color: rgb(52, 56, 61);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(26, 28, 31);
|
||||
}
|
||||
|
||||
.fa-badge {
|
||||
min-height: 16px;
|
||||
margin-right: 4px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
-unity-text-align: middle-center;
|
||||
font-size: 9px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
background-color: rgb(52, 56, 61);
|
||||
color: rgb(174, 180, 187);
|
||||
}
|
||||
|
||||
.fa-badge--blue { background-color: rgb(37, 63, 85); color: rgb(143, 202, 239); }
|
||||
.fa-badge--green { background-color: rgb(35, 63, 49); color: rgb(131, 209, 160); }
|
||||
.fa-badge--yellow { background-color: rgb(74, 62, 37); color: rgb(240, 200, 111); }
|
||||
.fa-badge--red { background-color: rgb(75, 41, 44); color: rgb(239, 141, 147); }
|
||||
.fa-badge--purple { background-color: rgb(59, 48, 75); color: rgb(195, 168, 232); }
|
||||
|
||||
.fa-flow-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.fa-canvas-panel {
|
||||
flex-grow: 1;
|
||||
min-width: 280px;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background-color: var(--fa-canvas);
|
||||
}
|
||||
|
||||
.fa-canvas-toolbar {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.fa-toolbar-label {
|
||||
margin-left: 2px;
|
||||
margin-right: 4px;
|
||||
color: var(--fa-muted);
|
||||
-unity-text-align: middle-left;
|
||||
}
|
||||
|
||||
.fa-canvas-toolbar #flow-focus-field {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.fa-flow-focus {
|
||||
min-width: 110px;
|
||||
max-width: 240px;
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
-unity-font-style: bold;
|
||||
-unity-text-align: middle-left;
|
||||
border-left-width: 3px;
|
||||
border-left-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.fa-empty-state {
|
||||
color: var(--fa-muted);
|
||||
}
|
||||
|
||||
.fa-node {
|
||||
min-width: 224px;
|
||||
width: 224px;
|
||||
max-width: 224px;
|
||||
min-height: 164px;
|
||||
border-left-width: 1px;
|
||||
border-right-width: 1px;
|
||||
border-top-width: 1px;
|
||||
border-bottom-width: 1px;
|
||||
border-left-color: var(--fa-line);
|
||||
border-right-color: var(--fa-line);
|
||||
border-top-color: var(--fa-line);
|
||||
border-bottom-color: var(--fa-line);
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: rgb(43, 47, 52);
|
||||
}
|
||||
|
||||
.fa-node .title {
|
||||
min-height: 31px;
|
||||
padding-left: 7px;
|
||||
padding-right: 7px;
|
||||
background-color: rgb(46, 111, 155);
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-node .title-label {
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.fa-node .input,
|
||||
.fa-node .output {
|
||||
min-width: 12px;
|
||||
}
|
||||
|
||||
.fa-node__meta {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 4px;
|
||||
color: rgb(175, 181, 188);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fa-node__clip-id {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fa-node__behavior {
|
||||
margin-left: 5px;
|
||||
color: rgb(201, 205, 210);
|
||||
}
|
||||
|
||||
.fa-node__badges {
|
||||
min-height: 21px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
margin-top: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fa-node__entry {
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 5px;
|
||||
padding-left: 3px;
|
||||
padding-right: 3px;
|
||||
-unity-text-align: middle-center;
|
||||
-unity-font-style: bold;
|
||||
font-size: 9px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.fa-node__preview {
|
||||
height: 78px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.fa-node--focused {
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.fa-node.selected {
|
||||
border-left-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-top-width: 2px;
|
||||
border-bottom-width: 2px;
|
||||
border-left-color: rgb(105, 185, 237);
|
||||
border-right-color: rgb(105, 185, 237);
|
||||
border-top-color: rgb(105, 185, 237);
|
||||
border-bottom-color: rgb(105, 185, 237);
|
||||
}
|
||||
|
||||
.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-inspector-scroll,
|
||||
.fa-bottom-scroll {
|
||||
flex-grow: 1;
|
||||
min-height: 0;
|
||||
background-color: var(--fa-panel);
|
||||
}
|
||||
|
||||
.fa-inspector-title {
|
||||
padding-left: 11px;
|
||||
padding-right: 11px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-inspector-title__name {
|
||||
font-size: 15px;
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.fa-inspector-title__id {
|
||||
margin-top: 3px;
|
||||
color: var(--fa-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fa-section {
|
||||
padding-bottom: 8px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-section__title {
|
||||
height: 27px;
|
||||
padding-left: 10px;
|
||||
-unity-text-align: middle-left;
|
||||
-unity-font-style: bold;
|
||||
background-color: rgb(41, 44, 48);
|
||||
}
|
||||
|
||||
.fa-section__body {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.fa-section__body > .unity-base-field,
|
||||
.fa-section__body > .unity-button,
|
||||
.fa-section__body > .fa-readonly-row,
|
||||
.fa-section__body > .fa-callout {
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fa-readonly-row {
|
||||
min-height: 22px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fa-readonly-row__label {
|
||||
width: 118px;
|
||||
color: rgb(173, 179, 186);
|
||||
}
|
||||
|
||||
.fa-readonly-row__value {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
color: rgb(197, 202, 208);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fa-callout {
|
||||
padding-left: 9px;
|
||||
padding-right: 9px;
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
white-space: normal;
|
||||
border-left-width: 3px;
|
||||
border-left-color: var(--fa-accent);
|
||||
background-color: rgb(34, 48, 58);
|
||||
color: rgb(188, 209, 223);
|
||||
}
|
||||
|
||||
.fa-callout--warning { border-left-color: var(--fa-warning); background-color: rgb(57, 51, 33); color: rgb(228, 208, 161); }
|
||||
.fa-callout--error { border-left-color: var(--fa-error); background-color: rgb(63, 39, 42); color: rgb(239, 184, 188); }
|
||||
|
||||
.fa-frame-row {
|
||||
min-height: 72px;
|
||||
padding-left: 5px;
|
||||
padding-right: 5px;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(48, 51, 55);
|
||||
}
|
||||
|
||||
.fa-frame-row__readonly {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.fa-frame-row__readonly > .unity-label {
|
||||
flex-grow: 1;
|
||||
color: var(--fa-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fa-bottom-panel {
|
||||
flex-direction: column;
|
||||
min-height: 28px;
|
||||
background-color: rgb(32, 35, 39);
|
||||
border-top-width: 1px;
|
||||
border-top-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-bottom-toolbar {
|
||||
min-height: 31px;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.fa-bottom-tabs {
|
||||
min-width: 230px;
|
||||
height: 30px;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.fa-bottom-tabs .unity-toolbar-button {
|
||||
min-width: 108px;
|
||||
padding-left: 14px;
|
||||
padding-right: 14px;
|
||||
}
|
||||
|
||||
.fa-bottom-toolbar .fa-bottom-collapse {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fa-result-controls {
|
||||
min-height: 31px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: var(--fa-line);
|
||||
}
|
||||
|
||||
.fa-result-header,
|
||||
.fa-result-row {
|
||||
min-height: 29px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: rgb(41, 44, 48);
|
||||
}
|
||||
|
||||
.fa-result-header {
|
||||
min-height: 26px;
|
||||
color: var(--fa-muted);
|
||||
background-color: rgb(40, 43, 48);
|
||||
}
|
||||
|
||||
.fa-result-row:hover { background-color: var(--fa-panel-hover); }
|
||||
.fa-result-severity { width: 82px; -unity-font-style: bold; }
|
||||
.fa-result-kind { width: 135px; }
|
||||
.fa-result-object { width: 180px; }
|
||||
.fa-result-message { flex-grow: 1; min-width: 260px; white-space: normal; }
|
||||
.fa-severity--error { color: var(--fa-error); }
|
||||
.fa-severity--warning { color: var(--fa-warning); }
|
||||
.fa-severity--info { color: rgb(114, 189, 232); }
|
||||
|
||||
.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-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-selection-preview-header {
|
||||
min-height: 34px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fa-selection-preview-title {
|
||||
color: var(--fa-text);
|
||||
-unity-font-style: bold;
|
||||
}
|
||||
|
||||
.fa-selection-preview-target {
|
||||
margin-top: 1px;
|
||||
color: var(--fa-muted);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fa-clip-preview-panel .fa-preview {
|
||||
height: 190px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.fa-preview-controls {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
min-height: 27px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.fa-selection-preview-panel .fa-preview-controls .fa-preview-button {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
max-width: 24px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
max-height: 24px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-left: 1px;
|
||||
margin-right: 1px;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fa-preview-button__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fa-preview-timeline {
|
||||
min-width: 28px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.fa-preview-time {
|
||||
width: 82px;
|
||||
min-width: 82px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
-unity-text-align: middle-right;
|
||||
}
|
||||
|
||||
.fa-preview-settings {
|
||||
min-height: 27px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fa-preview-setting {
|
||||
flex-grow: 1;
|
||||
flex-basis: 0;
|
||||
min-width: 0;
|
||||
margin-right: 5px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fa-preview-setting > .unity-label {
|
||||
width: 64px;
|
||||
min-width: 64px;
|
||||
color: var(--fa-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.fa-preview-setting__field {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fa-preview-status {
|
||||
font-size: 10px;
|
||||
color: rgb(185, 185, 185);
|
||||
margin-top: 2px;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a38dd39c699e42348667cc1264ea9076
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="True">
|
||||
<ui:VisualElement name="fa-workbench" class="fa-workbench">
|
||||
<ui:VisualElement name="global-toolbar-host" class="fa-global-toolbar-host" />
|
||||
<ui:VisualElement name="main-host" class="fa-main-host" />
|
||||
<ui:VisualElement name="bottom-host" class="fa-bottom-host" />
|
||||
</ui:VisualElement>
|
||||
</ui:UXML>
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3286d11c482fe8640a72c689bed6780c
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
|
||||
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,855 @@
|
||||
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 issueBadge;
|
||||
private readonly Label entryBadge;
|
||||
private readonly VisualElement badgeContainer;
|
||||
private readonly FrameAnimationPreviewElement preview;
|
||||
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
|
||||
private FrameAnimationFlowColorPalette? entryPalette;
|
||||
|
||||
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");
|
||||
|
||||
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,
|
||||
typeof(AnimationNode));
|
||||
InputPort.portName = string.Empty;
|
||||
inputContainer.Add(InputPort);
|
||||
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single,
|
||||
typeof(AnimationNode));
|
||||
OutputPort.portName = string.Empty;
|
||||
outputContainer.Add(OutputPort);
|
||||
|
||||
subtitle = new Label();
|
||||
subtitle.AddToClassList("fa-node__clip-id");
|
||||
behavior = new Label();
|
||||
behavior.AddToClassList("fa-node__behavior");
|
||||
var meta = new VisualElement();
|
||||
meta.AddToClassList("fa-node__meta");
|
||||
meta.Add(subtitle);
|
||||
meta.Add(behavior);
|
||||
extensionContainer.Add(meta);
|
||||
|
||||
preview = new FrameAnimationPreviewElement();
|
||||
preview.AddToClassList("fa-node__preview");
|
||||
extensionContainer.Add(preview);
|
||||
|
||||
badgeContainer = new VisualElement();
|
||||
badgeContainer.AddToClassList("fa-node__badges");
|
||||
entryBadge = new Label("E") { tooltip = "Flow Entry" };
|
||||
entryBadge.AddToClassList("fa-node__entry");
|
||||
badgeContainer.Add(entryBadge);
|
||||
issueBadge = new Label();
|
||||
issueBadge.AddToClassList("fa-badge");
|
||||
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
|
||||
badgeContainer.Add(issueBadge);
|
||||
extensionContainer.Add(badgeContainer);
|
||||
RefreshExpandedState();
|
||||
}
|
||||
|
||||
public override void OnSelected()
|
||||
{
|
||||
base.OnSelected();
|
||||
selectedCallback?.Invoke(this);
|
||||
}
|
||||
|
||||
internal void Refresh(
|
||||
FrameAnimationGraph graph,
|
||||
IReadOnlyList<FrameAnimationEditorIssue> issues,
|
||||
IReadOnlyList<AnimationFlow> flows)
|
||||
{
|
||||
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 ? Data.ClipId : $"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.Where(flow => flow != null).ToArray();
|
||||
var entryFlows = used.Where(flow => flow.EntryNodeId == Data.InternalId).ToArray();
|
||||
entryPalette = entryFlows.Length > 0
|
||||
? FrameAnimationFlowColorUtility.CreatePalette(
|
||||
FrameAnimationFlowColorUtility.ResolveRaw(graph, entryFlows[0]))
|
||||
: (FrameAnimationFlowColorPalette?)null;
|
||||
entryBadge.style.display = entryPalette.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
if (entryPalette.HasValue)
|
||||
{
|
||||
entryBadge.style.backgroundColor = entryPalette.Value.Stroke;
|
||||
entryBadge.style.color = entryPalette.Value.Text;
|
||||
entryBadge.tooltip = "Flow Entry: " + string.Join(", ", entryFlows.Select(flow => flow.Id));
|
||||
}
|
||||
|
||||
foreach (var label in badgeContainer.Query<Label>(className: "fa-flow-badge").ToList())
|
||||
{
|
||||
label.RemoveFromHierarchy();
|
||||
}
|
||||
if (used.Length == 0)
|
||||
{
|
||||
var unused = new Label("UNUSED");
|
||||
unused.AddToClassList("fa-badge");
|
||||
unused.AddToClassList("fa-badge--yellow");
|
||||
unused.AddToClassList("fa-flow-badge");
|
||||
badgeContainer.Insert(entryPalette.HasValue ? 1 : 0, unused);
|
||||
}
|
||||
else
|
||||
{
|
||||
var insertIndex = entryPalette.HasValue ? 1 : 0;
|
||||
foreach (var flow in used.Take(3))
|
||||
{
|
||||
var palette = FrameAnimationFlowColorUtility.CreatePalette(
|
||||
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
|
||||
var badge = new Label(flow.Id) { tooltip = FlowTooltip(flow) };
|
||||
badge.AddToClassList("fa-badge");
|
||||
badge.AddToClassList("fa-flow-badge");
|
||||
badge.style.backgroundColor = palette.Muted;
|
||||
badge.style.color = palette.Text;
|
||||
badgeContainer.Insert(insertIndex++, badge);
|
||||
}
|
||||
}
|
||||
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.display = matching.Length > 0 ? DisplayStyle.Flex : DisplayStyle.None;
|
||||
issueBadge.EnableInClassList("fa-badge--red",
|
||||
matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error));
|
||||
issueBadge.EnableInClassList("fa-badge--yellow",
|
||||
matching.Length > 0 && matching.All(issue => issue.Severity != FrameAnimationValidationSeverity.Error));
|
||||
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
|
||||
? "该节点已设置终点结束行为,请先清除覆盖。"
|
||||
: "创建唯一的顺序后继";
|
||||
}
|
||||
|
||||
private static string FlowTooltip(AnimationFlow flow) =>
|
||||
$"Flow: {flow.DisplayName} ({flow.Id})";
|
||||
|
||||
internal void SetFocusedFlow(FrameAnimationGraph graph, AnimationFlow flow, bool belongs)
|
||||
{
|
||||
EnableInClassList("fa-node--focused", flow != null && belongs);
|
||||
if (flow != null && belongs)
|
||||
{
|
||||
var palette = FrameAnimationFlowColorUtility.CreatePalette(
|
||||
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
|
||||
titleContainer.style.backgroundColor = palette.Header;
|
||||
var titleLabel = titleContainer.Q<Label>(className: "title-label");
|
||||
if (titleLabel != null) titleLabel.style.color = palette.Text;
|
||||
style.borderLeftColor = palette.Stroke;
|
||||
}
|
||||
else
|
||||
{
|
||||
titleContainer.style.backgroundColor = StyleKeyword.Null;
|
||||
var titleLabel = titleContainer.Q<Label>(className: "title-label");
|
||||
if (titleLabel != null) titleLabel.style.color = StyleKeyword.Null;
|
||||
style.borderLeftColor = StyleKeyword.Null;
|
||||
}
|
||||
}
|
||||
|
||||
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 = new Color32(119, 127, 135, 255);
|
||||
private bool hasError;
|
||||
private bool hasWarning;
|
||||
private FrameAnimationFlowElementState previewState;
|
||||
public AnimationEdge Data { get; }
|
||||
|
||||
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
|
||||
{
|
||||
Data = data;
|
||||
this.selectedCallback = selectedCallback;
|
||||
viewDataKey = data.InternalId;
|
||||
userData = data;
|
||||
RegisterCallback<AttachToPanelEvent>(_ => ApplyVisualState());
|
||||
}
|
||||
|
||||
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();
|
||||
hasError = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
|
||||
hasWarning = !hasError && matching.Length > 0;
|
||||
ApplyVisualState();
|
||||
tooltip = matching.Length == 0
|
||||
? $"{Data.ExitName} / {Data.Condition}"
|
||||
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
|
||||
}
|
||||
|
||||
internal void SetPreviewState(FrameAnimationFlowElementState state)
|
||||
{
|
||||
previewState = state;
|
||||
ApplyVisualState();
|
||||
}
|
||||
|
||||
internal void SetFocusedFlow(Color? flowColor, bool belongs)
|
||||
{
|
||||
baseColor = flowColor.HasValue && belongs
|
||||
? FrameAnimationFlowColorUtility.CreatePalette(flowColor.Value).Stroke
|
||||
: new Color32(119, 127, 135, 255);
|
||||
ApplyVisualState();
|
||||
}
|
||||
|
||||
private void ApplyVisualState()
|
||||
{
|
||||
var color = hasError
|
||||
? new Color32(223, 106, 106, 255)
|
||||
: hasWarning ? new Color32(224, 184, 88, 255) : baseColor;
|
||||
var width = 3f;
|
||||
if (!hasError && !hasWarning)
|
||||
{
|
||||
if (previewState == FrameAnimationFlowElementState.Current)
|
||||
{
|
||||
color = Color.Lerp(color, Color.white, 0.28f);
|
||||
width = 5f;
|
||||
}
|
||||
else if (previewState == FrameAnimationFlowElementState.Passed)
|
||||
{
|
||||
color = Color.Lerp(color, new Color32(86, 185, 123, 255), 0.22f);
|
||||
width = 3.5f;
|
||||
}
|
||||
else if (previewState == FrameAnimationFlowElementState.Upcoming)
|
||||
{
|
||||
color = Color.Lerp(color, new Color32(43, 47, 52, 255), 0.42f);
|
||||
width = 2f;
|
||||
}
|
||||
}
|
||||
edgeControl.inputColor = color;
|
||||
edgeControl.outputColor = color;
|
||||
if (panel != null)
|
||||
{
|
||||
edgeControl.edgeWidth = Mathf.RoundToInt(width);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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(224f, 164f)));
|
||||
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId));
|
||||
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);
|
||||
view.SetFocusedFlow(graph, null, false);
|
||||
}
|
||||
foreach (var view in edgeViews.Values)
|
||||
{
|
||||
view.SetDimmed(false);
|
||||
view.SetFocusedFlow(null, 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));
|
||||
var flowColor = flow != null ? FrameAnimationFlowColorUtility.ResolveRaw(graph, flow) : (Color?)null;
|
||||
foreach (var pair in nodeViews)
|
||||
{
|
||||
var belongs = nodeIds.Contains(pair.Key);
|
||||
pair.Value.SetDimmed(!belongs);
|
||||
pair.Value.SetFocusedFlow(graph, flow, belongs);
|
||||
}
|
||||
foreach (var pair in edgeViews)
|
||||
{
|
||||
var belongs = edgeIds.Contains(pair.Key);
|
||||
pair.Value.SetDimmed(!belongs);
|
||||
pair.Value.SetFocusedFlow(flowColor, belongs);
|
||||
}
|
||||
}
|
||||
|
||||
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,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,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(AibisEditorMenus.FrameClipEditor)]
|
||||
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:
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 038041fcf8ae74f06a33182ac1dbdc21
|
||||
guid: 5ad5208284c226e4297926dd64539442
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
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