Compare commits
374
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b315bf144b | ||
|
|
da7821cde5 | ||
|
|
cfd8cda709 | ||
|
|
96796f6b80 | ||
|
|
5faa81c3c2 | ||
|
|
dfdb75f833 | ||
|
|
c368ff416d | ||
|
|
1e56b83e06 | ||
|
|
9c30561ed8 | ||
|
|
ce8486c1eb | ||
|
|
f679985369 | ||
|
|
43bd3ece5b | ||
|
|
4cf8febec2 | ||
|
|
2384ef80f2 | ||
|
|
dcfd013db8 | ||
|
|
c6bcd0b798 | ||
|
|
d0904efc20 | ||
|
|
1072b65a00 | ||
|
|
7f087d89b6 | ||
|
|
590c1576e2 | ||
|
|
94e27b4d11 | ||
|
|
f3cff824ce | ||
|
|
e6ae903c03 | ||
|
|
8a68e0676b | ||
|
|
2cec5dd66c | ||
|
|
15f5c34db5 | ||
|
|
13d7c305c8 | ||
|
|
03a71b4ba3 | ||
|
|
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 | ||
|
|
2a8d82fd2e | ||
|
|
558706ee08 | ||
|
|
65d895798f | ||
|
|
a305e5ef42 | ||
|
|
0babb520de | ||
|
|
0265de117a | ||
|
|
11d88c146c |
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ MonoBehaviour:
|
||||
m_DefaultGroup: 09546aca93a801441859af2a811fe53b
|
||||
m_currentHash:
|
||||
serializedVersion: 2
|
||||
Hash: 1616e06b587c7889ae21faab8aef8e68
|
||||
Hash: e92b9b9776d0601449874b8c1b0ed1ae
|
||||
m_OptimizeCatalogSize: 0
|
||||
m_BuildRemoteCatalog: 0
|
||||
m_BundleLocalCatalog: 0
|
||||
@@ -59,7 +59,6 @@ MonoBehaviour:
|
||||
- {fileID: 11400000, guid: 6d259d339c6615248b893837398c1438, type: 2}
|
||||
- {fileID: 11400000, guid: 8718a71c99923e247a960cbf4e4795f5, type: 2}
|
||||
- {fileID: 11400000, guid: 1e521cfc13b314642b1e682e3be7ea17, type: 2}
|
||||
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
|
||||
- {fileID: 11400000, guid: 724eed66b2d8eef4faf77a4bee4d2845, type: 2}
|
||||
- {fileID: 11400000, guid: 62a92c74b9bd5bd439690727c1831500, type: 2}
|
||||
- {fileID: 11400000, guid: b593dc98ea11a484995123de77d9bf1a, type: 2}
|
||||
@@ -68,6 +67,7 @@ MonoBehaviour:
|
||||
- {fileID: 11400000, guid: dc8d31ed80ea35945812ff6552efbbe5, type: 2}
|
||||
- {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2}
|
||||
- {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2}
|
||||
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
|
||||
m_BuildSettings:
|
||||
m_CompileScriptsInVirtualMode: 0
|
||||
m_CleanupStreamingAssetsAfterBuilds: 1
|
||||
|
||||
@@ -52,6 +52,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: f19cff809e7020b41ba8ad7da199db86
|
||||
m_Address: "Timeline/day2\u5915\u9633\u533B\u751F\u770B\u4F69\u4F69\u4FA7\u8138"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -82,6 +82,16 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 1799dbcdb4a76624bb89622707581f92
|
||||
m_Address: "Timeline/\u82F1\u91CC\u521D\u767B\u573A"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 3d0198d0d6d221948a3d5ddf9cd2c34f
|
||||
m_Address: "FrameAnimation/\u8BCA\u5BA4\u5916\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -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
|
||||
@@ -92,6 +87,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: efa335dcb6e706347bd4acf3222331b9
|
||||
m_Address: "FrameAnimation/\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
m_ReadOnly: 0
|
||||
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
|
||||
m_SchemaSet:
|
||||
|
||||
@@ -32,8 +32,8 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 809f95af8dd580448ab804b94fbd9cb2
|
||||
m_Address: "Animation/\u4F69\u4F69"
|
||||
- m_GUID: 26105571c9644aa4194f2b981ee33a58
|
||||
m_Address: "FrameAnimation/\u4F69\u4F69"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
|
||||
@@ -22,23 +22,8 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 2d4ccf1c582289344976ab5c3af05515
|
||||
m_Address: "Animation/\u5730\u94C1\u533B\u751F"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 0057bf4b5fe16aa4b8c7db27d0ddfc0a
|
||||
m_Address: "Animation/\u5730\u94C1\u706B\u5C71"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: 40f07326ace570241bd93fcfc3decb87
|
||||
m_Address: "Animation/\u5730\u94C1\u706B\u5C71\u6697"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: a63b52b3e8d9e0441aaff086b9c1ae6b
|
||||
m_Address: "Animation/\u5730\u94C1\u533B\u751F\u6697"
|
||||
- m_GUID: b0ee8d48dab31814da87651bba9b5041
|
||||
m_Address: "FrameAnimation/\u5730\u94C1\u533B\u751F"
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
@@ -57,6 +42,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:
|
||||
|
||||
@@ -32,6 +32,11 @@ MonoBehaviour:
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: b9e730c7e5824c5ca2858a253fe93c98
|
||||
m_Address: Prefab/FrameAnimationActor
|
||||
m_ReadOnly: 0
|
||||
m_SerializedLabels: []
|
||||
FlaggedDuringContentUpdateRestriction: 0
|
||||
- m_GUID: cd996317bdc6ae8478acb2de956f25d9
|
||||
m_Address: Prefab/AnimaMobileObject
|
||||
m_ReadOnly: 0
|
||||
|
||||
Binary file not shown.
@@ -244,3 +244,14 @@ MonoBehaviour:
|
||||
m_PreInfinity: 0
|
||||
m_PostInfinity: 0
|
||||
m_RotationOrder: 0
|
||||
- m_From: '**ANY CAMERA**'
|
||||
m_To: Dream Camera
|
||||
m_Blend:
|
||||
m_Style: 0
|
||||
m_Time: 2
|
||||
m_CustomCurve:
|
||||
serializedVersion: 2
|
||||
m_Curve: []
|
||||
m_PreInfinity: 0
|
||||
m_PostInfinity: 0
|
||||
m_RotationOrder: 0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33e48b17003f9a340a079e8b5993c786
|
||||
guid: 638cd3fa1c2470d42aceb69214c01786
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
@@ -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>())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 27c49345ec3a4e849af3f9da7045f7a3
|
||||
guid: d476e493adf4855468ce46409dae6661
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2e0e2274704e494eb1cf554e2538516
|
||||
guid: 665561f3805a96446b45ea1d50bfe520
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
@@ -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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd5ec77f7adcc584ebc901ac1f73fab6
|
||||
guid: b95ba9d1e80e3dc4e83bc408569c4955
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -0,0 +1,235 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AibisDream.SaveSystem;
|
||||
using Newtonsoft.Json;
|
||||
using NUnit.Framework;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
},
|
||||
DedupeKey = key,
|
||||
EntryId = TestSaveRepository.StableHash(key),
|
||||
ChapterId = sceneSoName,
|
||||
ChapterTitle = sceneSoName,
|
||||
SceneSoName = sceneSoName,
|
||||
YarnProjectId = yarnProject,
|
||||
NodeName = nodeName,
|
||||
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,
|
||||
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
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7376797afa65fb549b5347e5d570dc46
|
||||
guid: cbbba0c3621cf204483f453bad2d75a4
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8d4c5a2b82ac1ca459e228aaad3255c6
|
||||
guid: 9e244d3d62dec3f4f881f7e681da8c03
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9719ab6f265683c4484a170f6c4dc843
|
||||
guid: a21095473b1495440acab244f9e470c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -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;
|
||||
}
|
||||
+1
-3
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ec4ca5fe3cf523d4a88253be45a8f5c1
|
||||
guid: a38dd39c699e42348667cc1264ea9076
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
@@ -7,5 +7,3 @@ ScriptedImporter:
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
|
||||
disableValidation: 0
|
||||
@@ -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>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6fd39d76275817945b272053a20f2c43
|
||||
guid: 3286d11c482fe8640a72c689bed6780c
|
||||
ScriptedImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
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: ed8d66d5054fd594b8a86bb4c9d8685f
|
||||
guid: 5ad5208284c226e4297926dd64539442
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
Binary file not shown.
+1
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1b6fa0ecd874676449e42ab27eeaa760
|
||||
guid: 0a870adb61eef3b4bb54dd6a93d043ef
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
@@ -0,0 +1,78 @@
|
||||
using AibisDream.FrameAnimation;
|
||||
using AibisDream.SaveSystem;
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AddressableAssets;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class FrameAnimationActorIntegrationTests
|
||||
{
|
||||
private const string PrefabPath =
|
||||
"Assets/Prefabs/FixSystemPrefabs/FrameAnimationActor.prefab";
|
||||
|
||||
[Test]
|
||||
public void ActorType_AppendsFrameAnimationWithoutChangingExistingValues()
|
||||
{
|
||||
Assert.That((int)ActorType.Sprite, Is.EqualTo(0));
|
||||
Assert.That((int)ActorType.Anima, Is.EqualTo(1));
|
||||
Assert.That((int)ActorType.AnimaEx, Is.EqualTo(2));
|
||||
Assert.That((int)ActorType.FrameAnimation, Is.EqualTo(3));
|
||||
Assert.That(
|
||||
System.Enum.TryParse("FrameAnimation", out ActorType parsed),
|
||||
Is.True);
|
||||
Assert.That(parsed, Is.EqualTo(ActorType.FrameAnimation));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Prefab_HasRequiredComponentsAndPersistentAddress()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
Assert.That(prefab, Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<SpriteRenderer>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationActor>(), Is.Not.Null);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().PlayOnEnable, Is.False);
|
||||
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().Graph, Is.Null);
|
||||
|
||||
var guid = AssetDatabase.AssetPathToGUID(PrefabPath);
|
||||
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(guid);
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry.address, Is.EqualTo(ConstRef.FrameAnimationActorPrefabName));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CaptureEntry_ReusesActorSnapshotFieldsWithoutSchemaChange()
|
||||
{
|
||||
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
|
||||
var actorObject = Object.Instantiate(prefab);
|
||||
var slotObject = new GameObject("Slot");
|
||||
try
|
||||
{
|
||||
var slot = slotObject.AddComponent<ActorSlot>();
|
||||
slot.slotName = "test-slot";
|
||||
slot.sortingLayer = 0;
|
||||
slot.sortingOrder = 17;
|
||||
var actor = actorObject.GetComponent<FrameAnimationActor>();
|
||||
actor.Init("TestActor", slot, ActorType.FrameAnimation);
|
||||
actor.Show();
|
||||
|
||||
ActorEntrySnapshotDto entry = actor.CaptureEntry();
|
||||
|
||||
Assert.That(entry.actorName, Is.EqualTo("TestActor"));
|
||||
Assert.That(entry.slotName, Is.EqualTo("test-slot"));
|
||||
Assert.That(entry.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
|
||||
Assert.That(entry.alpha, Is.EqualTo(1f));
|
||||
Assert.That(entry.stateName, Is.Empty);
|
||||
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(actorObject);
|
||||
Object.DestroyImmediate(slotObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23aa0707ef5c4618b55c455f2a03a612
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,149 +0,0 @@
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using System.IO;
|
||||
|
||||
/// <summary>
|
||||
/// 生成与 SpriteNoiseGlitch shader 三阶段对应的程序化音效。
|
||||
/// 菜单: Tools > Generate Glitch Noise Audio
|
||||
/// </summary>
|
||||
public static class GlitchNoiseAudioGenerator
|
||||
{
|
||||
private const int SampleRate = 44100;
|
||||
private const float Duration = 4f; // 每段音效时长(秒),便于循环使用
|
||||
private const string OutputFolder = "Assets/RawResources/Audio/GlitchNoise";
|
||||
|
||||
[MenuItem("Tools/Generate Glitch Noise Audio")]
|
||||
public static void Generate()
|
||||
{
|
||||
string dir = Path.Combine(Application.dataPath, "RawResources", "Audio", "GlitchNoise");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
GenerateStage1(dir);
|
||||
GenerateStage2(dir);
|
||||
GenerateStage3(dir);
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
Debug.Log($"[GlitchNoise] 已生成三阶段音效至 Assets/RawResources/Audio/GlitchNoise");
|
||||
}
|
||||
|
||||
/// <summary>Stage 1: 轻微静态噪点 - 柔和白噪声</summary>
|
||||
private static void GenerateStage1(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(12345);
|
||||
|
||||
float gain = 0.12f;
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
data[i] = ((float)rnd.NextDouble() * 2f - 1f) * gain;
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage1_Light.wav", data);
|
||||
}
|
||||
|
||||
/// <summary>Stage 2: 中等 - 更多噪点 + 偶尔 glitch 爆音</summary>
|
||||
private static void GenerateStage2(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(23456);
|
||||
|
||||
float noiseGain = 0.22f;
|
||||
int glitchInterval = SampleRate / 4; // 约每 0.25 秒一次 glitch 爆音
|
||||
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
float n = ((float)rnd.NextDouble() * 2f - 1f) * noiseGain;
|
||||
|
||||
// 随机 glitch 爆音
|
||||
if (i > 0 && i % glitchInterval < 120)
|
||||
{
|
||||
float burst = ((float)rnd.NextDouble() * 2f - 1f) * 0.5f;
|
||||
n += burst * (1f - (i % glitchInterval) / 120f);
|
||||
}
|
||||
|
||||
data[i] = Mathf.Clamp(n, -1f, 1f);
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage2_Medium.wav", data);
|
||||
}
|
||||
|
||||
/// <summary>Stage 3: 严重 - 强烈噪点 + 频繁 glitch + 数字故障感</summary>
|
||||
private static void GenerateStage3(string dir)
|
||||
{
|
||||
int samples = (int)(SampleRate * Duration);
|
||||
float[] data = new float[samples];
|
||||
var rnd = new System.Random(34567);
|
||||
|
||||
float noiseGain = 0.4f;
|
||||
int blockSize = 2205; // 约 0.05 秒一块
|
||||
int glitchBlockEvery = 4;
|
||||
|
||||
for (int i = 0; i < samples; i++)
|
||||
{
|
||||
int block = i / blockSize;
|
||||
float n = ((float)rnd.NextDouble() * 2f - 1f) * noiseGain;
|
||||
|
||||
// 块状 glitch:整块随机反转/爆音
|
||||
if (block % glitchBlockEvery == 0)
|
||||
{
|
||||
int posInBlock = i % blockSize;
|
||||
float t = (float)posInBlock / blockSize;
|
||||
n += ((float)rnd.NextDouble() * 2f - 1f) * 0.6f * (1f - t);
|
||||
}
|
||||
|
||||
// 随机“卡顿”短静音后爆音
|
||||
if (rnd.NextDouble() < 0.0003)
|
||||
{
|
||||
int silenceLen = 100 + rnd.Next(300);
|
||||
int end = Mathf.Min(i + silenceLen, samples);
|
||||
for (int j = i; j < end; j++)
|
||||
{
|
||||
data[j] = j == end - 1 ? ((float)rnd.NextDouble() * 2f - 1f) * 0.8f : 0f;
|
||||
}
|
||||
i = end - 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
data[i] = Mathf.Clamp(n, -1f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
SaveWav(dir, "GlitchNoise_Stage3_Heavy.wav", data);
|
||||
}
|
||||
|
||||
private static void SaveWav(string dir, string filename, float[] samples)
|
||||
{
|
||||
string path = Path.Combine(dir, filename);
|
||||
using (var fs = new FileStream(path, FileMode.Create))
|
||||
using (var bw = new BinaryWriter(fs))
|
||||
{
|
||||
// RIFF header
|
||||
bw.Write(new[] { 'R', 'I', 'F', 'F' });
|
||||
int dataSize = samples.Length * 2; // 16-bit
|
||||
bw.Write(36 + dataSize);
|
||||
bw.Write(new[] { 'W', 'A', 'V', 'E' });
|
||||
|
||||
// fmt chunk
|
||||
bw.Write(new[] { 'f', 'm', 't', ' ' });
|
||||
bw.Write(16); // chunk size
|
||||
bw.Write((short)1); // PCM
|
||||
bw.Write((short)1); // mono
|
||||
bw.Write(SampleRate);
|
||||
bw.Write(SampleRate * 2);
|
||||
bw.Write((short)2);
|
||||
bw.Write((short)16);
|
||||
|
||||
// data chunk
|
||||
bw.Write(new[] { 'd', 'a', 't', 'a' });
|
||||
bw.Write(dataSize);
|
||||
|
||||
foreach (float s in samples)
|
||||
{
|
||||
short sample = (short)Mathf.Clamp((int)(s * 32767), -32768, 32767);
|
||||
bw.Write(sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace AibisDream.EditorTools
|
||||
{
|
||||
public class HandlerNameCollectorWindow : EditorWindow
|
||||
{
|
||||
private const string WindowTitle = "Handler Name Collector";
|
||||
private const string WindowTitle = "名称收集器";
|
||||
|
||||
private HandlerNameCache _cache;
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace AibisDream.EditorTools
|
||||
|
||||
private HandlerNameCacheEntry _selectedEntry;
|
||||
|
||||
[MenuItem("Tools/Handler Name Collector")]
|
||||
[MenuItem(AibisEditorMenus.NameCollector)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
var window = GetWindow<HandlerNameCollectorWindow>(WindowTitle);
|
||||
@@ -38,12 +38,6 @@ namespace AibisDream.EditorTools
|
||||
window.Show();
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Timeline Name Collector", false, 101)]
|
||||
public static void ShowWindowLegacyMenu()
|
||||
{
|
||||
ShowWindow();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
LoadCacheIfNeeded();
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace AibisDream.SystemEditor
|
||||
{
|
||||
private const string BLOCK_SHAPE_PREFAB_PATH = "Assets/Prefabs/BlockPuzzle/BlockShape.prefab";
|
||||
|
||||
[MenuItem("GameObject/Block Puzzle/Shape")]
|
||||
[MenuItem(AibisEditorMenus.BlockPuzzleShape)]
|
||||
public static BlockShape CreateBlockPuzzleShape()
|
||||
{
|
||||
var prefabAsset =AssetDatabase.LoadAssetAtPath<GameObject>(BLOCK_SHAPE_PREFAB_PATH);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using AibisDream.Utility;
|
||||
using NUnit.Framework;
|
||||
using Yarn.Unity;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class OptionPromptMetadataTests
|
||||
{
|
||||
[Test]
|
||||
public void IsOptionPromptLine_WithExactMetadata_ReturnsTrue()
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = new[] { "line:0123456", YarnUtil.OptionPrompt }
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.True);
|
||||
}
|
||||
|
||||
[TestCase("option_prompts")]
|
||||
[TestCase("dream_option_prompt")]
|
||||
[TestCase("OPTION_PROMPT")]
|
||||
public void IsOptionPromptLine_WithSimilarMetadata_ReturnsFalse(string metadata)
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = new[] { metadata }
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsOptionPromptLine_WithoutMetadata_ReturnsFalse()
|
||||
{
|
||||
var line = new LocalizedLine
|
||||
{
|
||||
Metadata = null
|
||||
};
|
||||
|
||||
Assert.That(line.IsOptionPromptLine(), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5e819fca9154e979c37ac490184620b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using AibisDream.SaveSystem;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class PresentationSnapshotContractTests
|
||||
{
|
||||
[Test]
|
||||
public void PresentationProviders_UseStableIdsAndRestoreInDependencyOrder()
|
||||
{
|
||||
var showcase = new ShowcaseSnapshotProvider();
|
||||
var day2Sleep = new Day2SleepPresentationSnapshotProvider();
|
||||
var playTool = new PlayToolSnapshotProvider();
|
||||
var screen = new ScreenSnapshotProvider();
|
||||
|
||||
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
|
||||
Assert.That(day2Sleep.SaveId, Is.EqualTo("day2SleepPresentation"));
|
||||
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
|
||||
Assert.That(showcase.RestoreOrder, Is.LessThan(day2Sleep.RestoreOrder));
|
||||
Assert.That(day2Sleep.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
|
||||
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayToolSection_RoundTripsThroughSnapshotJson()
|
||||
{
|
||||
var source = new SaveSnapshot();
|
||||
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
|
||||
{
|
||||
isObjVisible = true,
|
||||
objPicName = "证物",
|
||||
isFullScreenVisible = true,
|
||||
fullScreenPicName = "教室"
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(source);
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
|
||||
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
|
||||
.ToObject<PlayToolSnapshotDto>();
|
||||
|
||||
Assert.That(playTool.isObjVisible, Is.True);
|
||||
Assert.That(playTool.objPicName, Is.EqualTo("证物"));
|
||||
Assert.That(playTool.isFullScreenVisible, Is.True);
|
||||
Assert.That(playTool.fullScreenPicName, Is.EqualTo("教室"));
|
||||
Assert.That(restored.schemaVersion, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ShowcaseAndDay2SleepSections_RoundTripSemanticState()
|
||||
{
|
||||
var source = new SaveSnapshot();
|
||||
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
|
||||
{
|
||||
displayMode = ShowcaseDisplayMode.Large.ToString(),
|
||||
picName = "D2S星图",
|
||||
isBackgroundVisible = true
|
||||
};
|
||||
source.sections[SnapshotProviderIds.Day2SleepPresentation] =
|
||||
new Day2SleepPresentationSnapshotDto
|
||||
{
|
||||
mode = Day2SleepPresentationMode.LargeEffects.ToString(),
|
||||
largeBlurAmount = 0.72f,
|
||||
largeBlurSize = 0.015f,
|
||||
largeScaleMultiplier = 1.1f,
|
||||
largeAlpha = 0.4f
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(source);
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
|
||||
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
|
||||
.ToObject<ShowcaseSnapshotDto>();
|
||||
var day2Sleep = ((JObject)restored.sections[SnapshotProviderIds.Day2SleepPresentation])
|
||||
.ToObject<Day2SleepPresentationSnapshotDto>();
|
||||
|
||||
Assert.That(showcase.displayMode, Is.EqualTo("Large"));
|
||||
Assert.That(showcase.picName, Is.EqualTo("D2S星图"));
|
||||
Assert.That(showcase.isBackgroundVisible, Is.True);
|
||||
Assert.That(day2Sleep.mode, Is.EqualTo("LargeEffects"));
|
||||
Assert.That(day2Sleep.largeBlurAmount, Is.EqualTo(0.72f));
|
||||
Assert.That(day2Sleep.largeScaleMultiplier, Is.EqualTo(1.1f));
|
||||
Assert.That(day2Sleep.largeAlpha, Is.EqualTo(0.4f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void OldSnapshotWithoutPresentationSections_RemainsCompatible()
|
||||
{
|
||||
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(
|
||||
"{\"schemaVersion\":1,\"sections\":{}}");
|
||||
|
||||
Assert.That(restored.schemaVersion, Is.EqualTo(SaveSnapshotSchema.CurrentVersion));
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Day2SleepPresentation), Is.False);
|
||||
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.PlayTool), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa21851c46624f77a67e29c86ec854c9
|
||||
@@ -1,667 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AibisDream;
|
||||
using AibisDream.SaveSystem;
|
||||
using AibisDream.Utility;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SaveSystem.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 存读档临时测试工具:触发存档、从槽位/测试归档读档、浏览 testsavs 历史。
|
||||
/// </summary>
|
||||
public class SaveSystemTestWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "AIBIS/存档测试工具";
|
||||
private const string WindowTitle = "SaveSystem Test";
|
||||
|
||||
private Vector2 _scrollPosition;
|
||||
private string _lastLog = string.Empty;
|
||||
private int _restoreSlotIndex;
|
||||
private int _manualSlotIndex = 1;
|
||||
private bool _testArchiveEnabled;
|
||||
private string _selectedArchiveEntryId;
|
||||
private Texture2D _selectedThumbnail;
|
||||
|
||||
private List<TestSaveArchiveEntry> _archiveEntries = new();
|
||||
private List<TestSaveArchiveEntry> _archiveEntriesSnapshot = new();
|
||||
private double _lastArchiveRefreshTime;
|
||||
private bool _repaintQueued;
|
||||
private List<string> _slotOverviewLabels = new();
|
||||
private List<string> _restoreLogSnapshot = new();
|
||||
private string _displaySelectedArchiveEntryId;
|
||||
private Texture2D _displayThumbnail;
|
||||
|
||||
private void RequestRepaint()
|
||||
{
|
||||
if (_repaintQueued)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_repaintQueued = true;
|
||||
EditorApplication.delayCall += () =>
|
||||
{
|
||||
_repaintQueued = false;
|
||||
if (this != null)
|
||||
{
|
||||
Repaint();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[MenuItem(MenuPath)]
|
||||
public static void Open()
|
||||
{
|
||||
var window = GetWindow<SaveSystemTestWindow>(WindowTitle);
|
||||
window.minSize = new Vector2(480, 560);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_testArchiveEnabled = EditorPrefs.GetBool(TestSaveArchive.EditorPrefsEnabledKey, true);
|
||||
RefreshArchiveEntries(requestRepaint: false);
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnEditorUpdate;
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
ReleaseThumbnail();
|
||||
}
|
||||
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (EditorApplication.timeSinceStartup - _lastArchiveRefreshTime < 1.0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshArchiveEntries(requestRepaint: true);
|
||||
}
|
||||
|
||||
private void OnPlayModeStateChanged(PlayModeStateChange state)
|
||||
{
|
||||
if (state == PlayModeStateChange.EnteredPlayMode || state == PlayModeStateChange.ExitingPlayMode)
|
||||
{
|
||||
RefreshArchiveEntries();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
if (Event.current.type == EventType.Layout)
|
||||
{
|
||||
SnapshotDynamicGuiData();
|
||||
}
|
||||
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
|
||||
|
||||
DrawHeader();
|
||||
GUILayout.Space(8);
|
||||
DrawTestArchiveSettings();
|
||||
GUILayout.Space(8);
|
||||
DrawSaveActions();
|
||||
GUILayout.Space(8);
|
||||
DrawSlotRestoreSection();
|
||||
GUILayout.Space(8);
|
||||
DrawTestArchiveSection();
|
||||
GUILayout.Space(8);
|
||||
DrawRestoreLogSection();
|
||||
GUILayout.Space(8);
|
||||
DrawUtilitySection();
|
||||
GUILayout.Space(8);
|
||||
DrawLogSection();
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 Layout/Repaint 之前快照可变列表,避免同一 GUI 帧内控件数量不一致。
|
||||
/// </summary>
|
||||
private void SnapshotDynamicGuiData()
|
||||
{
|
||||
_displaySelectedArchiveEntryId = _selectedArchiveEntryId;
|
||||
_displayThumbnail = _selectedThumbnail;
|
||||
|
||||
_archiveEntriesSnapshot.Clear();
|
||||
for (var i = 0; i < _archiveEntries.Count; i++)
|
||||
{
|
||||
_archiveEntriesSnapshot.Add(_archiveEntries[i]);
|
||||
}
|
||||
|
||||
_slotOverviewLabels.Clear();
|
||||
foreach (var vm in SlotManager.GetSlotViewModels())
|
||||
{
|
||||
if (vm.IsEmpty)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var meta = SlotManager.LoadMeta(vm.SlotIndex);
|
||||
_slotOverviewLabels.Add(
|
||||
$"slot_{vm.SlotIndex}" +
|
||||
(vm.IsAutoSlot ? " [自动]" : "") +
|
||||
$" | {vm.SavedAt} | {meta?.nodeName ?? "—"} | {vm.SceneName}");
|
||||
}
|
||||
|
||||
_restoreLogSnapshot.Clear();
|
||||
var restoreLog = SaveRestoreOrchestrator.LastRestoreLog;
|
||||
for (var i = 0; i < restoreLog.Count; i++)
|
||||
{
|
||||
_restoreLogSnapshot.Add(restoreLog[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.LabelField("存读档测试工具", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"用于 Play Mode 下验证自动存档、槽位读档与 testsavs 测试归档。\n" +
|
||||
"正式存档目录: saves/ | 测试归档: testsavs/(每次自动存档独立子文件夹)",
|
||||
MessageType.Info);
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("存档/读档操作需要在 Play Mode 下执行。", MessageType.Warning);
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField(
|
||||
$"状态: IsRestoring={SaveRestoreOrchestrator.IsRestoring}, " +
|
||||
$"SuppressAutoSave={SaveRestoreOrchestrator.IsAutoSaveSuppressed}");
|
||||
}
|
||||
|
||||
private void DrawTestArchiveSettings()
|
||||
{
|
||||
EditorGUILayout.LabelField("测试存档模式", EditorStyles.boldLabel);
|
||||
|
||||
EditorGUI.BeginChangeCheck();
|
||||
_testArchiveEnabled = EditorGUILayout.Toggle("启用测试存档模式", _testArchiveEnabled);
|
||||
if (EditorGUI.EndChangeCheck())
|
||||
{
|
||||
EditorPrefs.SetBool(TestSaveArchive.EditorPrefsEnabledKey, _testArchiveEnabled);
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"testsavs 路径: {ConstRef.TestAutoSaveArchivePath}", EditorStyles.miniLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"开启后:每次 AutoSave 仅在 testsavs 下新建一份独立存档,不写入、不覆盖 saves/slot_0。\n" +
|
||||
"存多少次就保留多少份;关闭后恢复正式行为(仅覆盖 slot_0)。",
|
||||
MessageType.None);
|
||||
}
|
||||
|
||||
private void DrawSaveActions()
|
||||
{
|
||||
EditorGUILayout.LabelField("存档", EditorStyles.boldLabel);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("触发自动存档 (完整流程)"))
|
||||
{
|
||||
TriggerAutoSave();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("仅 Capture (不写盘)"))
|
||||
{
|
||||
CaptureInMemory();
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("手动档: 复制 slot_0 → 上方指定槽位"))
|
||||
{
|
||||
CopyAutoToManual();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSlotRestoreSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("从正式槽位读档", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"saves 路径: {ConstRef.SaveFilePath}", EditorStyles.miniLabel);
|
||||
|
||||
var latest = SlotManager.GetLatestSlotIndex();
|
||||
EditorGUILayout.LabelField($"最近槽位: {(latest.HasValue ? $"slot_{latest.Value}" : "无")}");
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("读档: Latest"))
|
||||
{
|
||||
RestoreLatestSlot();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("读档: slot_0"))
|
||||
{
|
||||
RestoreSlot(SlotIndex.Auto);
|
||||
}
|
||||
}
|
||||
|
||||
_restoreSlotIndex = EditorGUILayout.IntSlider("槽位", _restoreSlotIndex, 0, SlotIndex.ManualEnd);
|
||||
if (GUILayout.Button($"读档: slot_{_restoreSlotIndex}"))
|
||||
{
|
||||
RestoreSlot(_restoreSlotIndex);
|
||||
}
|
||||
|
||||
GUILayout.Space(4);
|
||||
_manualSlotIndex = EditorGUILayout.IntSlider(
|
||||
"复制到手动槽位",
|
||||
_manualSlotIndex,
|
||||
SlotIndex.ManualStart,
|
||||
SlotIndex.ManualEnd);
|
||||
|
||||
DrawSlotBriefOverview();
|
||||
}
|
||||
|
||||
private void DrawSlotBriefOverview()
|
||||
{
|
||||
foreach (var label in _slotOverviewLabels)
|
||||
{
|
||||
EditorGUILayout.LabelField(label, EditorStyles.miniLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawTestArchiveSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("testsavs 测试归档", EditorStyles.boldLabel);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("刷新列表"))
|
||||
{
|
||||
RefreshArchiveEntries();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("清空 testsavs"))
|
||||
{
|
||||
ClearTestArchives();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("打开 testsavs 文件夹"))
|
||||
{
|
||||
OpenDirectory(ConstRef.TestAutoSaveArchivePath);
|
||||
}
|
||||
}
|
||||
|
||||
EditorGUILayout.LabelField($"共 {_archiveEntriesSnapshot.Count} 条", EditorStyles.miniLabel);
|
||||
|
||||
if (_archiveEntriesSnapshot.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"暂无测试归档。开启「testsavs 独立归档」后触发自动存档,或从正式 slot_0 手动归档。",
|
||||
MessageType.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
|
||||
{
|
||||
foreach (var entry in _archiveEntriesSnapshot)
|
||||
{
|
||||
DrawArchiveEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("将当前 slot_0 手动归档到 testsavs"))
|
||||
{
|
||||
ArchiveCurrentAutoSlot();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawArchiveEntry(TestSaveArchiveEntry entry)
|
||||
{
|
||||
var isSelected = _displaySelectedArchiveEntryId == entry.EntryId;
|
||||
var boxStyle = isSelected ? EditorStyles.helpBox : EditorStyles.textArea;
|
||||
|
||||
using (new EditorGUILayout.VerticalScope(boxStyle))
|
||||
{
|
||||
EditorGUILayout.LabelField(entry.EntryId, EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
$"时间: {entry.Meta?.savedAt ?? "—"} | 节点: {entry.Meta?.nodeName ?? "—"}",
|
||||
EditorStyles.miniLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
$"场景: {entry.Meta?.sceneName ?? "—"} | SO: {entry.Meta?.sceneSoName ?? "—"}",
|
||||
EditorStyles.miniLabel);
|
||||
|
||||
if (isSelected)
|
||||
{
|
||||
var maxWidth = EditorGUIUtility.currentViewWidth - 48f;
|
||||
var height = 120f;
|
||||
if (_displayThumbnail != null)
|
||||
{
|
||||
height = Mathf.Min(120f, _displayThumbnail.height * maxWidth / _displayThumbnail.width);
|
||||
GUILayout.Label(_displayThumbnail, GUILayout.Width(maxWidth), GUILayout.Height(height));
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField(
|
||||
"(无缩略图)",
|
||||
EditorStyles.miniLabel,
|
||||
GUILayout.Width(maxWidth),
|
||||
GUILayout.Height(height));
|
||||
}
|
||||
}
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("选中"))
|
||||
{
|
||||
SelectArchiveEntry(entry);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("读档"))
|
||||
{
|
||||
RestoreFromArchive(entry);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("删除"))
|
||||
{
|
||||
DeleteArchiveEntry(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.Space(4);
|
||||
}
|
||||
|
||||
private void DrawRestoreLogSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("读档 Pipeline 日志", EditorStyles.boldLabel);
|
||||
|
||||
if (_restoreLogSnapshot.Count == 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("(暂无)", EditorStyles.miniLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var line in _restoreLogSnapshot)
|
||||
{
|
||||
EditorGUILayout.LabelField(line, EditorStyles.wordWrappedMiniLabel);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawUtilitySection()
|
||||
{
|
||||
EditorGUILayout.LabelField("工具", EditorStyles.boldLabel);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("打开 saves 文件夹"))
|
||||
{
|
||||
OpenDirectory(ConstRef.SaveFilePath);
|
||||
}
|
||||
|
||||
if (GUILayout.Button("打开存档验证工具"))
|
||||
{
|
||||
SaveSystemValidationWindow.Open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLogSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("操作结果", EditorStyles.boldLabel);
|
||||
EditorGUILayout.SelectableLabel(_lastLog, EditorStyles.textArea, GUILayout.MinHeight(80));
|
||||
}
|
||||
|
||||
private void RefreshArchiveEntries(bool requestRepaint = true)
|
||||
{
|
||||
var newEntries = TestSaveArchive.ListEntries();
|
||||
var entriesChanged = !ArchiveEntriesEqual(newEntries, _archiveEntries);
|
||||
_archiveEntries = newEntries;
|
||||
_lastArchiveRefreshTime = EditorApplication.timeSinceStartup;
|
||||
|
||||
if (!string.IsNullOrEmpty(_selectedArchiveEntryId) &&
|
||||
!_archiveEntries.Exists(e => e.EntryId == _selectedArchiveEntryId))
|
||||
{
|
||||
_selectedArchiveEntryId = null;
|
||||
ReleaseThumbnail();
|
||||
entriesChanged = true;
|
||||
}
|
||||
|
||||
if (requestRepaint && entriesChanged)
|
||||
{
|
||||
RequestRepaint();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ArchiveEntriesEqual(List<TestSaveArchiveEntry> a, List<TestSaveArchiveEntry> b)
|
||||
{
|
||||
if (a.Count != b.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < a.Count; i++)
|
||||
{
|
||||
if (a[i].EntryId != b[i].EntryId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SelectArchiveEntry(TestSaveArchiveEntry entry)
|
||||
{
|
||||
_selectedArchiveEntryId = entry.EntryId;
|
||||
ReleaseThumbnail();
|
||||
|
||||
if (!string.IsNullOrEmpty(entry.ThumbnailPath) && File.Exists(entry.ThumbnailPath))
|
||||
{
|
||||
var bytes = File.ReadAllBytes(entry.ThumbnailPath);
|
||||
_selectedThumbnail = new Texture2D(2, 2);
|
||||
_selectedThumbnail.LoadImage(bytes);
|
||||
}
|
||||
|
||||
RequestRepaint();
|
||||
}
|
||||
|
||||
private void ReleaseThumbnail()
|
||||
{
|
||||
if (_selectedThumbnail != null)
|
||||
{
|
||||
DestroyImmediate(_selectedThumbnail);
|
||||
_selectedThumbnail = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerAutoSave()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("需要在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!SavePointEvaluator.CanAutoSave(out var reason))
|
||||
{
|
||||
Log($"CanAutoSave 拒绝: {reason}");
|
||||
return;
|
||||
}
|
||||
|
||||
var routine = SaveRestoreOrchestrator.AutoSaveRoutine();
|
||||
while (routine.MoveNext())
|
||||
{
|
||||
}
|
||||
|
||||
RefreshArchiveEntries();
|
||||
Log(TestSaveArchive.IsEnabled
|
||||
? "测试存档模式:已写入 testsavs(未覆盖 slot_0)。"
|
||||
: "正式模式:已写入 slot_0。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"自动存档失败: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureInMemory()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (YarnVariableStorage.Instance == null)
|
||||
{
|
||||
Log("YarnVariableStorage 未初始化。");
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = SnapshotService.Capture();
|
||||
Log($"Capture 成功: 场景={snapshot.scene?.sceneName}, 节点={snapshot.anchor?.nodeName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Capture 失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyAutoToManual()
|
||||
{
|
||||
if (!SlotDirectory.Exists(SlotIndex.Auto))
|
||||
{
|
||||
Log("slot_0 不存在。");
|
||||
return;
|
||||
}
|
||||
|
||||
SlotManager.CopyAutoToManual(_manualSlotIndex);
|
||||
Log($"已复制到 slot_{_manualSlotIndex}。");
|
||||
}
|
||||
|
||||
private void RestoreLatestSlot()
|
||||
{
|
||||
var latest = SlotManager.GetLatestSlotIndex();
|
||||
if (!latest.HasValue)
|
||||
{
|
||||
Log("没有 latest_slot。");
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreSlot(latest.Value);
|
||||
}
|
||||
|
||||
private void RestoreSlot(int slotIndex)
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("读档需要在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
Log("GameManager 未初始化。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SlotDirectory.Exists(slotIndex))
|
||||
{
|
||||
Log($"slot_{slotIndex} 不存在。");
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSlot(slotIndex);
|
||||
Log($"已启动读档 slot_{slotIndex}。");
|
||||
}
|
||||
|
||||
private void RestoreFromArchive(TestSaveArchiveEntry entry)
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("读档需要在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
Log("GameManager 未初始化。");
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSaveFile(entry.SnapshotPath);
|
||||
Log($"已启动读档: {entry.EntryId}");
|
||||
}
|
||||
|
||||
private void ArchiveCurrentAutoSlot()
|
||||
{
|
||||
var snapshot = SlotManager.LoadSnapshot(SlotIndex.Auto);
|
||||
if (snapshot == null)
|
||||
{
|
||||
Log("slot_0 无快照。");
|
||||
return;
|
||||
}
|
||||
|
||||
var thumbnail = SlotManager.LoadThumbnail(SlotIndex.Auto);
|
||||
var entryId = TestSaveArchive.Archive(snapshot, thumbnail);
|
||||
RefreshArchiveEntries();
|
||||
Log($"已手动归档: {entryId}");
|
||||
}
|
||||
|
||||
private void DeleteArchiveEntry(TestSaveArchiveEntry entry)
|
||||
{
|
||||
var entryId = entry.EntryId;
|
||||
EditorApplication.delayCall += () => ConfirmDeleteArchiveEntry(entryId);
|
||||
}
|
||||
|
||||
private void ConfirmDeleteArchiveEntry(string entryId)
|
||||
{
|
||||
if (!EditorUtility.DisplayDialog("删除测试归档", $"删除 {entryId}?", "删除", "取消"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TestSaveArchive.DeleteEntry(entryId);
|
||||
if (_selectedArchiveEntryId == entryId)
|
||||
{
|
||||
_selectedArchiveEntryId = null;
|
||||
ReleaseThumbnail();
|
||||
}
|
||||
|
||||
RefreshArchiveEntries();
|
||||
Log($"已删除: {entryId}");
|
||||
}
|
||||
|
||||
private void ClearTestArchives()
|
||||
{
|
||||
EditorApplication.delayCall += ConfirmClearTestArchives;
|
||||
}
|
||||
|
||||
private void ConfirmClearTestArchives()
|
||||
{
|
||||
if (!EditorUtility.DisplayDialog("清空 testsavs", "删除所有测试归档?不可撤销。", "删除", "取消"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TestSaveArchive.ClearAll();
|
||||
_selectedArchiveEntryId = null;
|
||||
ReleaseThumbnail();
|
||||
RefreshArchiveEntries();
|
||||
Log("已清空 testsavs。");
|
||||
}
|
||||
|
||||
private static void OpenDirectory(string path)
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
EditorUtility.RevealInFinder(path);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
_lastLog = $"[{DateTime.Now:HH:mm:ss}] {message}";
|
||||
Debug.Log($"[SaveSystemTest] {message}");
|
||||
RequestRepaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AibisDream;
|
||||
using AibisDream.SaveSystem;
|
||||
using AibisDream.Utility;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SaveSystem.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档系统 P1/P2/P3 存盘行为验证工具。
|
||||
/// 不验证读档(P4),不验证复杂 tags 边界(P3 后续)。
|
||||
/// </summary>
|
||||
public class SaveSystemValidationWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "AIBIS/存档系统验证工具";
|
||||
private const string WindowTitle = "SaveSystem Validator";
|
||||
|
||||
private const float SaveHistoryBoxMinHeight = 400f;
|
||||
|
||||
private Vector2 _scrollPosition;
|
||||
private string _lastLog = string.Empty;
|
||||
private int _manualSlotIndex = 1;
|
||||
private int _deleteSlotIndex = 1;
|
||||
private string _testNodeName = "SomeNode";
|
||||
private string _testTags = "";
|
||||
private int _restoreSlotIndex;
|
||||
|
||||
private readonly List<SaveHistoryEntry> _saveHistory = new();
|
||||
private string _lastObservedSavedAt;
|
||||
|
||||
/// <summary>运行时自动存档历史条目,仅内存缓存。</summary>
|
||||
private class SaveHistoryEntry
|
||||
{
|
||||
public string SavedAt;
|
||||
public string NodeName;
|
||||
public string SceneName;
|
||||
}
|
||||
|
||||
[MenuItem(MenuPath)]
|
||||
public static void Open()
|
||||
{
|
||||
var window = GetWindow<SaveSystemValidationWindow>(WindowTitle);
|
||||
window.minSize = new Vector2(420, 640);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
EditorApplication.update += OnEditorUpdate;
|
||||
EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
|
||||
InitializeLastObservedSavedAt();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
EditorApplication.update -= OnEditorUpdate;
|
||||
EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
|
||||
}
|
||||
|
||||
private void OnPlayModeStateChanged(PlayModeStateChange state)
|
||||
{
|
||||
if (state == PlayModeStateChange.EnteredPlayMode)
|
||||
{
|
||||
_saveHistory.Clear();
|
||||
InitializeLastObservedSavedAt();
|
||||
TrySeedHistoryFromCurrentAutoSlot();
|
||||
Repaint();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == PlayModeStateChange.ExitingPlayMode)
|
||||
{
|
||||
_saveHistory.Clear();
|
||||
_lastObservedSavedAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void TrySeedHistoryFromCurrentAutoSlot()
|
||||
{
|
||||
var meta = SlotManager.LoadMeta(SlotIndex.Auto);
|
||||
if (meta == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_saveHistory.Add(CreateHistoryEntry(meta));
|
||||
_lastObservedSavedAt = meta.savedAt;
|
||||
}
|
||||
|
||||
private static SaveHistoryEntry CreateHistoryEntry(SlotMeta meta)
|
||||
{
|
||||
return new SaveHistoryEntry
|
||||
{
|
||||
SavedAt = meta.savedAt,
|
||||
NodeName = string.IsNullOrEmpty(meta.nodeName) ? "(无节点)" : meta.nodeName,
|
||||
SceneName = meta.sceneName
|
||||
};
|
||||
}
|
||||
|
||||
private void InitializeLastObservedSavedAt()
|
||||
{
|
||||
var meta = SlotManager.LoadMeta(SlotIndex.Auto);
|
||||
_lastObservedSavedAt = meta?.savedAt;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 监听 slot_0 的 meta 变化,运行时每次自动存档都会记录到内存列表中。
|
||||
/// 纯 Editor 行为,不侵入运行时代码。
|
||||
/// </summary>
|
||||
private void OnEditorUpdate()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var meta = SlotManager.LoadMeta(SlotIndex.Auto);
|
||||
if (meta == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_lastObservedSavedAt) && _lastObservedSavedAt == meta.savedAt)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastObservedSavedAt = meta.savedAt;
|
||||
_saveHistory.Add(CreateHistoryEntry(meta));
|
||||
|
||||
Repaint();
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition);
|
||||
|
||||
DrawHeader();
|
||||
DrawSlotOverview();
|
||||
GUILayout.Space(12);
|
||||
DrawSaveHistorySection();
|
||||
GUILayout.Space(12);
|
||||
DrawDirectCaptureSection();
|
||||
GUILayout.Space(12);
|
||||
DrawAutoSaveSimulationSection();
|
||||
GUILayout.Space(12);
|
||||
DrawManualSlotSection();
|
||||
GUILayout.Space(12);
|
||||
DrawRestoreSection();
|
||||
GUILayout.Space(12);
|
||||
DrawSavePointEvaluationSection();
|
||||
GUILayout.Space(12);
|
||||
DrawUtilitySection();
|
||||
GUILayout.Space(12);
|
||||
DrawLogSection();
|
||||
|
||||
EditorGUILayout.EndScrollView();
|
||||
}
|
||||
|
||||
#region Drawing
|
||||
|
||||
private void DrawHeader()
|
||||
{
|
||||
EditorGUILayout.LabelField("存档系统存盘验证", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"本工具仅验证 P1/P2/P3 的存盘行为:快照捕获、槽位落盘、元数据、可存点判定框架。\n" +
|
||||
"不验证读档(P4)和复杂 tags 边界(P3 后续)。\n" +
|
||||
"涉及运行时单例(DialogController / GameManager)的操作需要在 Play Mode 下执行。",
|
||||
MessageType.Info);
|
||||
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox("当前不在 Play Mode。Capture / TryAutoSave 等依赖运行时单例的操作可能失败。", MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSlotOverview()
|
||||
{
|
||||
EditorGUILayout.LabelField("槽位概览", EditorStyles.boldLabel);
|
||||
|
||||
var latest = SlotManager.GetLatestSlotIndex();
|
||||
EditorGUILayout.LabelField($"最近槽位: {(latest.HasValue ? latest.Value.ToString() : "无")}");
|
||||
EditorGUILayout.LabelField($"存档根目录: {ConstRef.SaveFilePath}");
|
||||
EditorGUILayout.LabelField(
|
||||
$"读档状态: IsRestoring={SaveRestoreOrchestrator.IsRestoring}, " +
|
||||
$"SuppressAutoSave={SaveRestoreOrchestrator.IsAutoSaveSuppressed}");
|
||||
|
||||
var viewModels = SlotManager.GetSlotViewModels();
|
||||
foreach (var vm in viewModels)
|
||||
{
|
||||
DrawSlotViewModel(vm, latest);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSlotViewModel(SlotViewModel vm, int? latest)
|
||||
{
|
||||
var isLatest = latest.HasValue && latest.Value == vm.SlotIndex;
|
||||
var nodeName = SlotManager.LoadMeta(vm.SlotIndex)?.nodeName;
|
||||
var label = vm.IsEmpty
|
||||
? $"[{vm.SlotIndex}] (空)"
|
||||
: $"[{vm.SlotIndex}] {(vm.IsAutoSlot ? "[自动档]" : "")} {(isLatest ? "[最新]" : "")}\n" +
|
||||
$" 场景: {vm.SceneName}\n" +
|
||||
$" SO: {vm.SceneSoName}\n" +
|
||||
$" 节点: {nodeName}\n" +
|
||||
$" 时间: {vm.SavedAt}\n" +
|
||||
$" 缩略图: {(string.IsNullOrEmpty(vm.ThumbnailPath) ? "无" : Path.GetFileName(vm.ThumbnailPath))}";
|
||||
|
||||
EditorGUILayout.HelpBox(label, vm.IsEmpty ? MessageType.None : MessageType.Info);
|
||||
}
|
||||
|
||||
private void DrawDirectCaptureSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("直接捕获快照", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"调用 SnapshotService.Capture() 组装快照,并在内存中检查结构。\n" +
|
||||
"此操作不写盘,不经过 SavePointEvaluator。",
|
||||
MessageType.None);
|
||||
|
||||
if (GUILayout.Button("Capture Snapshot (内存)"))
|
||||
{
|
||||
CaptureSnapshotInMemory();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawAutoSaveSimulationSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("模拟自动存档", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"调用 SaveRestoreOrchestrator.TryAutoSave(),走完整的判定+捕获+落盘流程。\n" +
|
||||
"需要在 Play Mode 下且有 DialogController 运行时实例。",
|
||||
MessageType.None);
|
||||
|
||||
if (GUILayout.Button("TryAutoSave (完整流程)"))
|
||||
{
|
||||
TryAutoSaveFullFlow();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSaveHistorySection()
|
||||
{
|
||||
EditorGUILayout.LabelField("自动存档历史 (运行时)", EditorStyles.boldLabel);
|
||||
|
||||
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox, GUILayout.MinHeight(SaveHistoryBoxMinHeight)))
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"进入 Play Mode 后,每次 slot_0 自动存档都会在此记录节点名与时间。\n" +
|
||||
"数据仅保存在当前窗口内存中,退出 Play Mode 后清空。",
|
||||
MessageType.Info);
|
||||
}
|
||||
else if (_saveHistory.Count == 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("等待自动存档…", EditorStyles.wordWrappedLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
"触发 content / hub / linear 节点,或点击下方 TryAutoSave。",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorGUILayout.LabelField($"共 {_saveHistory.Count} 条记录", EditorStyles.miniLabel);
|
||||
|
||||
for (var i = 0; i < _saveHistory.Count; i++)
|
||||
{
|
||||
var entry = _saveHistory[i];
|
||||
EditorGUILayout.LabelField(
|
||||
$"{i + 1}. [{entry.SavedAt}] {entry.NodeName}",
|
||||
EditorStyles.wordWrappedLabel);
|
||||
EditorGUILayout.LabelField(
|
||||
$" 场景: {entry.SceneName ?? "N/A"}",
|
||||
EditorStyles.miniLabel);
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
if (GUILayout.Button("清空历史记录"))
|
||||
{
|
||||
_saveHistory.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawManualSlotSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("手动档操作", EditorStyles.boldLabel);
|
||||
|
||||
_manualSlotIndex = EditorGUILayout.IntSlider("复制到槽位", _manualSlotIndex, 1, SlotIndex.ManualEnd);
|
||||
if (GUILayout.Button("Copy Auto → Manual"))
|
||||
{
|
||||
CopyAutoToManual(_manualSlotIndex);
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
|
||||
_deleteSlotIndex = EditorGUILayout.IntSlider("删除槽位", _deleteSlotIndex, 0, SlotIndex.ManualEnd);
|
||||
if (GUILayout.Button("Delete Slot"))
|
||||
{
|
||||
DeleteSlot(_deleteSlotIndex);
|
||||
}
|
||||
|
||||
GUILayout.Space(8);
|
||||
|
||||
if (GUILayout.Button("Clear All Saves"))
|
||||
{
|
||||
ClearAllSaves();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawSavePointEvaluationSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("可存点判定框架检查", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"手动设置节点名和 tags,模拟 OnNodeStart 自动档判定(含 DetourResume)。\n" +
|
||||
"InProgress 来自运行时 DialogController;全局门控按实际 Play Mode 状态取值。",
|
||||
MessageType.None);
|
||||
|
||||
_testNodeName = EditorGUILayout.TextField("节点名", _testNodeName);
|
||||
_testTags = EditorGUILayout.TextField("Tags (逗号分隔)", _testTags);
|
||||
|
||||
if (GUILayout.Button("Evaluate CanAutoSave"))
|
||||
{
|
||||
EvaluateSavePoint();
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawRestoreSection()
|
||||
{
|
||||
EditorGUILayout.LabelField("读档验证 (P4)", EditorStyles.boldLabel);
|
||||
EditorGUILayout.HelpBox(
|
||||
"触发 SaveRestoreOrchestrator.RestoreFromSlot,验证 Phase + Barrier 编排、自动存档抑制与 Provider 还原日志。\n" +
|
||||
"需要在 Play Mode 下执行;不代表正式玩家 UI。",
|
||||
MessageType.None);
|
||||
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("Restore Latest"))
|
||||
{
|
||||
RestoreLatestSlot();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("Restore slot_0"))
|
||||
{
|
||||
RestoreSlot(SlotIndex.Auto);
|
||||
}
|
||||
}
|
||||
|
||||
_restoreSlotIndex = EditorGUILayout.IntSlider("Restore 槽位", _restoreSlotIndex, 0, SlotIndex.ManualEnd);
|
||||
if (GUILayout.Button($"Restore slot_{_restoreSlotIndex}"))
|
||||
{
|
||||
RestoreSlot(_restoreSlotIndex);
|
||||
}
|
||||
|
||||
var restoreLog = SaveRestoreOrchestrator.LastRestoreLog;
|
||||
if (restoreLog.Count > 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("Restore Pipeline Log", EditorStyles.boldLabel);
|
||||
foreach (var line in restoreLog)
|
||||
{
|
||||
EditorGUILayout.LabelField(line, EditorStyles.wordWrappedMiniLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawUtilitySection()
|
||||
{
|
||||
EditorGUILayout.LabelField("工具", EditorStyles.boldLabel);
|
||||
|
||||
if (GUILayout.Button("打开存档目录"))
|
||||
{
|
||||
OpenSaveDirectory();
|
||||
}
|
||||
|
||||
if (GUILayout.Button("刷新"))
|
||||
{
|
||||
Repaint();
|
||||
Log("已刷新。");
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawLogSection()
|
||||
{
|
||||
GUILayout.Space(12);
|
||||
EditorGUILayout.LabelField("最后结果", EditorStyles.boldLabel);
|
||||
EditorGUILayout.SelectableLabel(_lastLog, EditorStyles.textArea, GUILayout.MinHeight(120));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Actions
|
||||
|
||||
private void CaptureSnapshotInMemory()
|
||||
{
|
||||
try
|
||||
{
|
||||
var storage = YarnVariableStorage.Instance;
|
||||
if (storage == null)
|
||||
{
|
||||
Log("YarnVariableStorage 未初始化,尝试直接 new SaveSnapshot 仅做结构演示。");
|
||||
var demo = new SaveSnapshot
|
||||
{
|
||||
gameVersion = Application.version,
|
||||
savedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
|
||||
scene = new SceneSnapshotDto { sceneName = "DemoScene" },
|
||||
anchor = new AnchorSnapshot { nodeName = "DemoNode" },
|
||||
yarnVariables = new YarnVariablesSnapshot()
|
||||
};
|
||||
PrintSnapshotSummary(demo, "演示快照(无运行时)");
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = SnapshotService.Capture();
|
||||
PrintSnapshotSummary(snapshot, "内存快照");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Capture 失败: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryAutoSaveFullFlow()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("TryAutoSave 需要在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var beforeLatest = SlotManager.GetLatestSlotIndex();
|
||||
var beforeSnapshot = SlotManager.LoadSnapshot(0);
|
||||
|
||||
if (!SavePointEvaluator.CanAutoSave(out var rejectReason))
|
||||
{
|
||||
Log($"CanAutoSave 拒绝: {rejectReason}");
|
||||
return;
|
||||
}
|
||||
|
||||
var routine = SaveRestoreOrchestrator.AutoSaveRoutine();
|
||||
while (routine.MoveNext())
|
||||
{
|
||||
}
|
||||
|
||||
var afterSnapshot = SlotManager.LoadSnapshot(0);
|
||||
var afterLatest = SlotManager.GetLatestSlotIndex();
|
||||
|
||||
var summary = string.Empty;
|
||||
if (afterSnapshot == null)
|
||||
{
|
||||
summary = "无快照写入(可能被 SavePointEvaluator 拒绝)。";
|
||||
}
|
||||
else
|
||||
{
|
||||
summary = $"落盘成功。\n" +
|
||||
$"场景: {afterSnapshot.scene?.sceneName}\n" +
|
||||
$"节点: {afterSnapshot.anchor?.nodeName}\n" +
|
||||
$"SO: {afterSnapshot.anchor?.sceneSoName}\n" +
|
||||
$"YarnProject: {afterSnapshot.anchor?.yarnProjectId}\n" +
|
||||
$"Sections: {string.Join(", ", afterSnapshot.sections?.Keys ?? Enumerable.Empty<string>())}\n" +
|
||||
$"latest_slot: {(afterLatest.HasValue ? afterLatest.Value.ToString() : "无")}";
|
||||
}
|
||||
|
||||
Log(summary);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"TryAutoSave 失败: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyAutoToManual(int slotIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!SlotDirectory.Exists(SlotIndex.Auto))
|
||||
{
|
||||
Log("自动档不存在,无法复制手动档。");
|
||||
return;
|
||||
}
|
||||
|
||||
SlotManager.CopyAutoToManual(slotIndex);
|
||||
Log($"已复制自动档到 slot_{slotIndex}。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"复制失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteSlot(int slotIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
SlotManager.DeleteSlot(slotIndex);
|
||||
Log($"已删除/清空 slot_{slotIndex}。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"删除失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearAllSaves()
|
||||
{
|
||||
if (!EditorUtility.DisplayDialog("确认", "删除所有存档槽位?此操作不可撤销。", "删除", "取消"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for (var i = SlotIndex.Auto; i <= SlotIndex.ManualEnd; i++)
|
||||
{
|
||||
SlotManager.DeleteSlot(i);
|
||||
}
|
||||
|
||||
var latestPath = SlotDirectory.GetLatestSlotIndexPath();
|
||||
if (File.Exists(latestPath))
|
||||
{
|
||||
File.Delete(latestPath);
|
||||
}
|
||||
|
||||
Log("已清空所有槽位和 latest_slot 记录。");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"清空失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void EvaluateSavePoint()
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("SavePointEvaluator 需要运行时状态(IsRestoring / GameManager pause)。请在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var projectId = DialogController.Instance?.DialogueRunner?.YarnProject?.name;
|
||||
var tags = ParseTestTags(_testTags);
|
||||
var inProgress = SavePointEvaluator.InProgressNodeNames;
|
||||
|
||||
if (SaveRestoreOrchestrator.IsAutoSaveSuppressed)
|
||||
{
|
||||
Log("全局门控: AutoSaveSuppressed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (SaveRestoreOrchestrator.IsRestoring)
|
||||
{
|
||||
Log("全局门控: Restoring(读档重进走 OnRestoreEnterNode,不写盘)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameManager.Instance != null && GameManager.Instance.state.isInPause)
|
||||
{
|
||||
Log("全局门控: GamePaused");
|
||||
return;
|
||||
}
|
||||
|
||||
var detourResume = !string.IsNullOrEmpty(_testNodeName)
|
||||
&& inProgress.Contains(_testNodeName);
|
||||
var tagOk = SavePointEvaluator.EvaluateNodeTagsForAutoSave(
|
||||
_testNodeName, tags, out var tagReason);
|
||||
var wouldSave = !detourResume && tagOk;
|
||||
|
||||
Log(
|
||||
$"Project = {projectId ?? "(null)"}\n" +
|
||||
$"InProgress = [{string.Join(", ", inProgress)}]\n" +
|
||||
$"模拟节点 = {_testNodeName}, tags = [{string.Join(", ", tags)}]\n" +
|
||||
$"DetourResume = {detourResume}\n" +
|
||||
$"Tag 判定 = {tagOk}, TagReason = {tagReason}\n" +
|
||||
$"OnNodeStart 会自动存 = {wouldSave}\n" +
|
||||
$"(DialogController 当前上下文 CanAutoSave = {SavePointEvaluator.CanAutoSave(out var ctxReason)}, Reason = {ctxReason})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"判定失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] ParseTestTags(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
return raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(tag => tag.Trim())
|
||||
.Where(tag => tag.Length > 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void RestoreLatestSlot()
|
||||
{
|
||||
var latest = SlotManager.GetLatestSlotIndex();
|
||||
if (!latest.HasValue)
|
||||
{
|
||||
Log("没有 latest_slot,无法读档。");
|
||||
return;
|
||||
}
|
||||
|
||||
RestoreSlot(latest.Value);
|
||||
}
|
||||
|
||||
private void RestoreSlot(int slotIndex)
|
||||
{
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Log("Restore 需要在 Play Mode 下执行。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (GameManager.Instance == null)
|
||||
{
|
||||
Log("GameManager 未初始化,无法启动读档协程。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SlotDirectory.Exists(slotIndex))
|
||||
{
|
||||
Log($"slot_{slotIndex} 不存在,无法读档。");
|
||||
return;
|
||||
}
|
||||
|
||||
GameManager.Instance.StartWithSlot(slotIndex);
|
||||
Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。");
|
||||
}
|
||||
|
||||
private void OpenSaveDirectory()
|
||||
{
|
||||
var path = ConstRef.SaveFilePath;
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
}
|
||||
|
||||
EditorUtility.RevealInFinder(path);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private void PrintSnapshotSummary(SaveSnapshot snapshot, string label)
|
||||
{
|
||||
var json = SnapshotPersistence.Serialize(snapshot);
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"=== {label} ===",
|
||||
$"gameVersion: {snapshot.gameVersion}",
|
||||
$"savedAt: {snapshot.savedAt}",
|
||||
$"schemaVersion: {snapshot.schemaVersion}",
|
||||
$"scene: {snapshot.scene?.sceneName}",
|
||||
$"anchor.sceneSoName: {snapshot.anchor?.sceneSoName}",
|
||||
$"anchor.yarnProjectId: {snapshot.anchor?.yarnProjectId}",
|
||||
$"anchor.nodeName: {snapshot.anchor?.nodeName}",
|
||||
$"yarnVariables.floats: {snapshot.yarnVariables?.floats?.Count ?? 0} entries",
|
||||
$"yarnVariables.strings: {snapshot.yarnVariables?.strings?.Count ?? 0} entries",
|
||||
$"yarnVariables.bools: {snapshot.yarnVariables?.bools?.Count ?? 0} entries",
|
||||
$"sections: {(snapshot.sections != null ? string.Join(", ", snapshot.sections.Keys) : "null")}",
|
||||
};
|
||||
|
||||
if (snapshot.sections != null)
|
||||
{
|
||||
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.PunchTape);
|
||||
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Fix);
|
||||
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.FixPanel);
|
||||
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.BodyModule);
|
||||
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Eye);
|
||||
}
|
||||
|
||||
lines.Add("JSON preview (first 1500 chars):");
|
||||
lines.Add(json.Length > 1500 ? json.Substring(0, 1500) + "..." : json);
|
||||
|
||||
Log(string.Join("\n", lines));
|
||||
}
|
||||
|
||||
private static void AppendFixSectionHint(List<string> lines, SaveSnapshot snapshot, string sectionId)
|
||||
{
|
||||
if (snapshot.sections.ContainsKey(sectionId))
|
||||
{
|
||||
lines.Add($" [fix-related] {sectionId}: captured");
|
||||
}
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
_lastLog = $"[{DateTime.Now:HH:mm:ss}] {message}";
|
||||
Debug.Log($"[SaveSystemValidator] {message}");
|
||||
Repaint();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace AibisDream.SystemEditor.Tests
|
||||
{
|
||||
public sealed class TextSpeedSettingsTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
TextSpeedSettings.Reset();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
TextSpeedSettings.Reset();
|
||||
}
|
||||
|
||||
[TestCase("0.5", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)]
|
||||
[TestCase("0.50", TextSpeedSettings.SlowMultiplier, TextSpeedSettings.SlowValue)]
|
||||
[TestCase("1", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)]
|
||||
[TestCase("1.0", TextSpeedSettings.DefaultMultiplier, TextSpeedSettings.DefaultValue)]
|
||||
[TestCase("3", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)]
|
||||
[TestCase("3.0", TextSpeedSettings.FastMultiplier, TextSpeedSettings.FastValue)]
|
||||
public void TryNormalize_SupportedNumericValue_ReturnsCanonicalValue(
|
||||
string rawValue,
|
||||
float expectedMultiplier,
|
||||
string expectedCanonicalValue)
|
||||
{
|
||||
var result = TextSpeedSettings.TryNormalize(
|
||||
rawValue,
|
||||
out var multiplier,
|
||||
out var canonicalValue);
|
||||
|
||||
Assert.That(result, Is.True);
|
||||
Assert.That(multiplier, Is.EqualTo(expectedMultiplier));
|
||||
Assert.That(canonicalValue, Is.EqualTo(expectedCanonicalValue));
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase("invalid")]
|
||||
[TestCase("NaN")]
|
||||
[TestCase("Infinity")]
|
||||
[TestCase("-1")]
|
||||
[TestCase("0")]
|
||||
[TestCase("2")]
|
||||
[TestCase("4")]
|
||||
public void TryNormalize_InvalidOrUnsupportedValue_ReturnsDefault(
|
||||
string rawValue)
|
||||
{
|
||||
var result = TextSpeedSettings.TryNormalize(
|
||||
rawValue,
|
||||
out var multiplier,
|
||||
out var canonicalValue);
|
||||
|
||||
Assert.That(result, Is.False);
|
||||
Assert.That(multiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
|
||||
Assert.That(canonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Reset_AfterApplyingFastValue_RestoresDefault()
|
||||
{
|
||||
Assert.That(TextSpeedSettings.ApplyStoredValue("3"), Is.True);
|
||||
Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.FastMultiplier));
|
||||
|
||||
TextSpeedSettings.Reset();
|
||||
|
||||
Assert.That(TextSpeedSettings.CurrentMultiplier, Is.EqualTo(TextSpeedSettings.DefaultMultiplier));
|
||||
Assert.That(TextSpeedSettings.CurrentCanonicalValue, Is.EqualTo(TextSpeedSettings.DefaultValue));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58d2e4c8752446518dc1357dd586e504
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,303 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using JetBrains.Annotations;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEditor.UIElements;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class AnimatorClipLink : EditorWindow
|
||||
{
|
||||
[SerializeField] private VisualTreeAsset visualTreeAsset;
|
||||
|
||||
private ObjectField _animatorField;
|
||||
private Label _animatorMessage;
|
||||
private ListView _stateInfoView;
|
||||
|
||||
private TextField _clipsPathField;
|
||||
private Label _clipsMessage;
|
||||
private ListView _clipsNameList;
|
||||
|
||||
private HelpBox _matchMessageBox;
|
||||
|
||||
#region 部分缓存
|
||||
|
||||
private static AnimatorController _animatorCache;
|
||||
private static string _clipsPathCache;
|
||||
|
||||
private ChildAnimatorState[] _statesCache;
|
||||
private string[] _clipsCache;
|
||||
|
||||
private bool _isChecked;
|
||||
private bool _allMatched;
|
||||
private bool _isAllLoad;
|
||||
|
||||
#endregion
|
||||
|
||||
[MenuItem("Tools/AnimatorClipLink")]
|
||||
public static void ShowExample()
|
||||
{
|
||||
AnimatorClipLink wnd = GetWindow<AnimatorClipLink>();
|
||||
wnd.titleContent = new GUIContent("AnimatorClipLink");
|
||||
}
|
||||
|
||||
public void CreateGUI()
|
||||
{
|
||||
// Each editor window contains a root VisualElement object
|
||||
VisualElement root = rootVisualElement;
|
||||
|
||||
// Instantiate UXML
|
||||
root.Add(visualTreeAsset.Instantiate());
|
||||
|
||||
// 注册一些事件
|
||||
_animatorField = root.Q<ObjectField>("animator-field");
|
||||
_animatorField.RegisterValueChangedCallback(OnAnimatorChanged);
|
||||
_animatorMessage = root.Q<Label>("animator-message");
|
||||
_stateInfoView = root.Q<ListView>("state-info-list");
|
||||
|
||||
_clipsPathField = root.Q<TextField>("clips-path-field");
|
||||
_clipsPathField.RegisterValueChangedCallback(OnClipsPathChanged);
|
||||
_clipsMessage = root.Q<Label>("clips-message");
|
||||
_clipsNameList = root.Q<ListView>("clips-name-list");
|
||||
|
||||
_matchMessageBox = root.Q<HelpBox>("match-message-box");
|
||||
|
||||
root.Q<Button>("import-merge-button").clicked += ImportMerge;
|
||||
|
||||
// 初始化
|
||||
_animatorMessage.text = "请添加AnimatorController";
|
||||
_clipsMessage.text = "请输入Clips路径";
|
||||
// 加载AnimatorState
|
||||
if (_animatorCache != null)
|
||||
{
|
||||
_animatorField.value = _animatorCache;
|
||||
LoadAnimatorController(_animatorCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateStateInfos(Array.Empty<ChildAnimatorState>());
|
||||
}
|
||||
|
||||
// 加载路径下的Clips
|
||||
if (!string.IsNullOrWhiteSpace(_clipsPathCache))
|
||||
{
|
||||
_clipsPathField.value = _clipsPathCache;
|
||||
LoadClipPath(_clipsPathCache);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateClipInFolder(Array.Empty<string>());
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateStateInfos(ChildAnimatorState[] states)
|
||||
{
|
||||
_statesCache = states;
|
||||
// 总条目
|
||||
var stateList = states.ToList();
|
||||
|
||||
_stateInfoView.makeItem = MakeItem;
|
||||
_stateInfoView.bindItem = BindItem;
|
||||
_stateInfoView.itemsSource = stateList;
|
||||
_stateInfoView.selectionType = UnityEngine.UIElements.SelectionType.None;
|
||||
|
||||
MatchStateAndClip();
|
||||
return;
|
||||
|
||||
// 链接
|
||||
void BindItem(VisualElement item, int index)
|
||||
{
|
||||
if (index >= stateList.Count) return;
|
||||
|
||||
var state = stateList[index];
|
||||
if (item is ObjectField field)
|
||||
{
|
||||
field.value = state.state.motion;
|
||||
field.label = state.state.name;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建
|
||||
VisualElement MakeItem()
|
||||
{
|
||||
var item = new ObjectField
|
||||
{
|
||||
objectType = typeof(AnimationClip)
|
||||
};
|
||||
item.SetEnabled(false);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnAnimatorChanged(ChangeEvent<Object> evt)
|
||||
{
|
||||
if (evt.newValue is AnimatorController animatorController)
|
||||
{
|
||||
LoadAnimatorController(animatorController);
|
||||
}
|
||||
else
|
||||
{
|
||||
LoadAnimatorController(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadAnimatorController([CanBeNull] AnimatorController animatorController)
|
||||
{
|
||||
// 判断新的值是不是空的,是就清空State信息
|
||||
if (animatorController == null)
|
||||
{
|
||||
_animatorCache = null;
|
||||
_animatorMessage.text = "AnimatorController 为空";
|
||||
// 清空State信息
|
||||
UpdateStateInfos(Array.Empty<ChildAnimatorState>());
|
||||
return;
|
||||
}
|
||||
|
||||
_animatorCache = animatorController;
|
||||
// 值更新的时候,更新State信息
|
||||
var states = animatorController.layers[0].stateMachine.states;
|
||||
_animatorMessage.text = $"BaseLayer共有{states.Length}个State";
|
||||
UpdateStateInfos(states);
|
||||
}
|
||||
|
||||
private void OnClipsPathChanged(ChangeEvent<string> evt)
|
||||
{
|
||||
LoadClipPath(evt.newValue);
|
||||
}
|
||||
|
||||
private void LoadClipPath([CanBeNull] string clipsPath)
|
||||
{
|
||||
_clipsPathCache = clipsPath;
|
||||
// 先判断路径是否存在
|
||||
if (!AssetDatabase.IsValidFolder(clipsPath))
|
||||
{
|
||||
_clipsMessage.text = "路径不存在";
|
||||
// 清空Clip信息
|
||||
UpdateClipInFolder(Array.Empty<string>());
|
||||
return;
|
||||
}
|
||||
|
||||
// 从路径中获取Clip
|
||||
var clipIds = AssetDatabase.FindAssets("t:AnimationClip", new[] { clipsPath });
|
||||
// 获取名称
|
||||
var clipNames = clipIds.Select(AssetDatabase.GUIDToAssetPath)
|
||||
.Where(item => item.EndsWith(".anim"))
|
||||
.ToArray();
|
||||
|
||||
_clipsMessage.text = $"共有{clipNames.Length}个Clip";
|
||||
UpdateClipInFolder(clipNames);
|
||||
}
|
||||
|
||||
private void UpdateClipInFolder(string[] clipNames)
|
||||
{
|
||||
_clipsCache = clipNames;
|
||||
var clipNameList = clipNames.Select(Path.GetFileNameWithoutExtension).ToList();
|
||||
|
||||
_clipsNameList.makeItem = MakeItem;
|
||||
_clipsNameList.bindItem = BindItem;
|
||||
_clipsNameList.itemsSource = clipNameList;
|
||||
|
||||
MatchStateAndClip();
|
||||
return;
|
||||
|
||||
void BindItem(VisualElement item, int index)
|
||||
{
|
||||
if (index >= clipNameList.Count) return;
|
||||
|
||||
var clipName = clipNameList[index];
|
||||
if (item is Label label)
|
||||
{
|
||||
label.text = clipName;
|
||||
}
|
||||
}
|
||||
|
||||
VisualElement MakeItem() => new Label();
|
||||
}
|
||||
|
||||
private void MatchStateAndClip()
|
||||
{
|
||||
if (_statesCache is not { Length: > 0 } || _clipsCache is not { Length: > 0 })
|
||||
{
|
||||
_matchMessageBox.text = "请先选择AnimatorController和Clips路径";
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_allMatched = false;
|
||||
_isAllLoad = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var clipNames = _clipsCache.Select(Path.GetFileNameWithoutExtension).ToArray();
|
||||
// 匹配state和clips
|
||||
var notMatchStates =
|
||||
(from state in _statesCache where !clipNames.Contains(state.state.name) select state.state.name)
|
||||
.ToList();
|
||||
|
||||
if (notMatchStates.Count == 0)
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Info;
|
||||
_matchMessageBox.text = "所有State都已经匹配";
|
||||
_allMatched = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = $"未匹配的State有{notMatchStates.Count}个:\n{string.Join(", ", notMatchStates)}";
|
||||
_allMatched = false;
|
||||
}
|
||||
|
||||
_isAllLoad = true;
|
||||
_isChecked = false;
|
||||
}
|
||||
|
||||
private void ImportMerge()
|
||||
{
|
||||
if (!_isAllLoad)
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = "请先选择AnimatorController和Clips路径";
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isChecked || _allMatched)
|
||||
{
|
||||
MergeClips();
|
||||
_isChecked = false;
|
||||
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Info;
|
||||
_matchMessageBox.text = "匹配完成";
|
||||
}
|
||||
else
|
||||
{
|
||||
_matchMessageBox.messageType = HelpBoxMessageType.Warning;
|
||||
_matchMessageBox.text = "仍有未匹配到Clip的State,确认要继续吗?";
|
||||
_isChecked = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void MergeClips()
|
||||
{
|
||||
var clipNames = _clipsCache.Select(Path.GetFileNameWithoutExtension).ToArray();
|
||||
foreach (var state in _statesCache)
|
||||
{
|
||||
var idx = Array.IndexOf(clipNames, state.state.name);
|
||||
if (idx != -1)
|
||||
{
|
||||
var newClip = AssetDatabase.LoadAssetAtPath<AnimationClip>(_clipsCache[idx]);
|
||||
var oldClip = state.state.motion;
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(newClip);
|
||||
settings.loopTime = oldClip.isLooping;
|
||||
AnimationUtility.SetAnimationClipSettings(newClip, settings);
|
||||
|
||||
state.state.motion = newClip;
|
||||
}
|
||||
}
|
||||
|
||||
LoadAnimatorController(_animatorCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0182e64fe4acdbf4aae853bf35db2105
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences:
|
||||
- m_ViewDataDictionary: {instanceID: 0}
|
||||
- visualTreeAsset: {fileID: 9197481963319205126, guid: 6fd39d76275817945b272053a20f2c43, type: 3}
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,5 +0,0 @@
|
||||
.custom-label {
|
||||
font-size: 20px;
|
||||
-unity-font-style: bold;
|
||||
color: rgb(68, 138, 255);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements" xsi="http://www.w3.org/2001/XMLSchema-instance" engine="UnityEngine.UIElements" editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../../../UIElementsSchema/UIElements.xsd" editor-extension-mode="True">
|
||||
<Style src="project://database/Assets/Editor/UXML/AnimatorLinker/AnimatorClipLink.uss?fileID=7433441132597879392&guid=ec4ca5fe3cf523d4a88253be45a8f5c1&type=3#AnimatorClipLink" />
|
||||
<ui:Label text="Animator 动画片段导入" class="custom-label" style="align-items: center; justify-content: flex-start; -unity-text-align: upper-center; height: auto;" />
|
||||
<ui:VisualElement style="flex-grow: 1; flex-direction: row; height: auto;">
|
||||
<ui:VisualElement name="Animator" picking-mode="Ignore" style="flex-grow: 1; width: 50%; align-items: stretch;">
|
||||
<ui:Label tabindex="-1" text="Animator" parse-escape-sequences="true" display-tooltip-when-elided="true" />
|
||||
<uie:ObjectField label="动画控制器" name="animator-field" object-type="UnityEngine.AnimatorController" style="align-items: auto;" />
|
||||
<ui:Label tabindex="-1" text="Label" parse-escape-sequences="true" display-tooltip-when-elided="true" name="animator-message" />
|
||||
<ui:ListView focusable="true" name="state-info-list" selection-type="None" show-add-remove-footer="false" />
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement name="Clips" picking-mode="Ignore" style="flex-grow: 1; width: 50%;">
|
||||
<ui:Label tabindex="-1" text="Clips路径" parse-escape-sequences="true" display-tooltip-when-elided="false" />
|
||||
<ui:TextField label="路径名" name="clips-path-field" style="align-items: auto;" />
|
||||
<ui:Label tabindex="-1" text="Label" parse-escape-sequences="true" display-tooltip-when-elided="true" name="clips-message" />
|
||||
<ui:ListView focusable="true" name="clips-name-list" selection-type="None" show-add-remove-footer="false" />
|
||||
</ui:VisualElement>
|
||||
</ui:VisualElement>
|
||||
<ui:VisualElement style="flex-shrink: 0; justify-content: space-around; align-items: center; align-self: stretch; height: auto; flex-direction: row; max-height: none; margin-top: 10px;">
|
||||
<ui:HelpBox text="请先选择AnimatorController和Clips路径" message-type="Warning" name="match-message-box" style="width: 50%; flex-shrink: 0;" />
|
||||
<ui:Button text="保存" parse-escape-sequences="true" display-tooltip-when-elided="true" name="import-merge-button" style="align-items: auto; flex-shrink: 0;" />
|
||||
</ui:VisualElement>
|
||||
</ui:UXML>
|
||||
@@ -36,7 +36,7 @@ namespace AibisDream.SystemEditor
|
||||
private PivotAlignment pivotAlignment = PivotAlignment.Center;
|
||||
private Vector2 customPivot = new Vector2(0.5f, 0.5f);
|
||||
|
||||
[MenuItem("Tools/动画片段生成器(Animation Clip Generator)")]
|
||||
[MenuItem(AibisEditorMenus.AnimationClipGenerator)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<AnimationClipGenerator>("动画片段生成器(Animation Clip Generator)");
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using AibisDream.Framework;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace AibisDream.SystemEditor
|
||||
{
|
||||
public class AnimatorBlendTreeParserWindow : EditorWindow
|
||||
{
|
||||
private AnimatorController _selectedController;
|
||||
private List<BlendTreeInfo> _blendTreeInfos;
|
||||
private Vector2 _scrollPosition;
|
||||
private string _resultText = "";
|
||||
private bool _includeNested = false; // 是否包含嵌套混合树,默认不提取
|
||||
private string _defaultStateName = ""; // 控制器默认 State 名称
|
||||
|
||||
[MenuItem("Tools/Animator BlendTree Parser")]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<AnimatorBlendTreeParserWindow>("BlendTree Parser");
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// AnimatorController 选择区域
|
||||
EditorGUILayout.LabelField("AnimatorController", EditorStyles.boldLabel);
|
||||
_selectedController = (AnimatorController)EditorGUILayout.ObjectField(
|
||||
"Controller", _selectedController, typeof(AnimatorController), false);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 解析选项
|
||||
EditorGUILayout.LabelField("解析选项", EditorStyles.boldLabel);
|
||||
_includeNested = EditorGUILayout.Toggle("包含嵌套混合树", _includeNested);
|
||||
EditorGUILayout.HelpBox(
|
||||
_includeNested
|
||||
? "将提取所有层级的混合树(包括嵌套在子混合树中的混合树)"
|
||||
: "仅提取第一层混合树(不包含嵌套在子混合树中的混合树)",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 解析按钮
|
||||
EditorGUI.BeginDisabledGroup(_selectedController == null);
|
||||
if (GUILayout.Button("解析混合树", GUILayout.Height(30)))
|
||||
{
|
||||
ParseBlendTree();
|
||||
}
|
||||
EditorGUI.EndDisabledGroup();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 结果显示区域
|
||||
if (_blendTreeInfos != null && _blendTreeInfos.Count > 0)
|
||||
{
|
||||
EditorGUILayout.LabelField("解析结果", EditorStyles.boldLabel);
|
||||
|
||||
// 显示统计信息
|
||||
EditorGUILayout.HelpBox(
|
||||
$"共找到 {_blendTreeInfos.Count} 个混合树",
|
||||
MessageType.Info);
|
||||
|
||||
// 可滚动的文本区域
|
||||
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
|
||||
EditorGUILayout.TextArea(_resultText, GUILayout.ExpandHeight(true));
|
||||
EditorGUILayout.EndScrollView();
|
||||
|
||||
EditorGUILayout.Space(10);
|
||||
|
||||
// 保存 JSON 按钮
|
||||
if (GUILayout.Button("保存为 JSON", GUILayout.Height(30)))
|
||||
{
|
||||
SaveToJson();
|
||||
}
|
||||
}
|
||||
else if (_blendTreeInfos != null && _blendTreeInfos.Count == 0)
|
||||
{
|
||||
EditorGUILayout.HelpBox(
|
||||
"未找到混合树",
|
||||
MessageType.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseBlendTree()
|
||||
{
|
||||
if (_selectedController == null)
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "请先选择 AnimatorController", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 解析默认 State 名称(默认使用第 0 层的默认状态)
|
||||
if (_selectedController.layers != null && _selectedController.layers.Length > 0)
|
||||
{
|
||||
var baseLayer = _selectedController.layers[0];
|
||||
_defaultStateName = baseLayer.stateMachine?.defaultState != null
|
||||
? baseLayer.stateMachine.defaultState.name
|
||||
: string.Empty;
|
||||
}
|
||||
else
|
||||
{
|
||||
_defaultStateName = string.Empty;
|
||||
}
|
||||
|
||||
// 调用解析方法,传入 includeNested 选项
|
||||
_blendTreeInfos = AnimatorKit.ParseBlendTree(_selectedController, _includeNested);
|
||||
|
||||
// 格式化结果显示文本
|
||||
FormatResultText();
|
||||
|
||||
Debug.Log(
|
||||
$"解析完成: 找到 {_blendTreeInfos.Count} 个混合树 (包含嵌套: {_includeNested}),默认 State: {_defaultStateName}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
EditorUtility.DisplayDialog("解析失败", e.Message, "确定");
|
||||
Debug.LogError($"解析失败: {e}");
|
||||
_blendTreeInfos = null;
|
||||
_resultText = "";
|
||||
}
|
||||
}
|
||||
|
||||
private void FormatResultText()
|
||||
{
|
||||
if (_blendTreeInfos == null || _blendTreeInfos.Count == 0)
|
||||
{
|
||||
_resultText = "未找到混合树";
|
||||
return;
|
||||
}
|
||||
|
||||
var lines = new List<string>();
|
||||
lines.Add($"=== 控制器 {_selectedController.name} 的混合树信息 ===");
|
||||
lines.Add($"默认 State: {_defaultStateName}");
|
||||
lines.Add($"共找到 {_blendTreeInfos.Count} 个混合树\n");
|
||||
|
||||
for (int i = 0; i < _blendTreeInfos.Count; i++)
|
||||
{
|
||||
var info = _blendTreeInfos[i];
|
||||
lines.Add($"[{i + 1}] 混合树名称: {info.name}");
|
||||
lines.Add($" 类型: {info.type}");
|
||||
|
||||
if (info.type == "1D")
|
||||
{
|
||||
lines.Add($" 动画片段数量: {info.clipCount}");
|
||||
lines.Add($" 混合参数: {info.blendParameter}");
|
||||
lines.Add($" 参数范围: [{info.minThreshold}, {info.maxThreshold}]");
|
||||
lines.Add($" 动画片段列表 ({info.clips?.Count ?? 0} 个):");
|
||||
if (info.clips != null)
|
||||
{
|
||||
for (int j = 0; j < info.clips.Count; j++)
|
||||
{
|
||||
var clip = info.clips[j];
|
||||
lines.Add($" [{j + 1}] {clip.clipName} (Threshold: {clip.threshold})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (info.type == "2D")
|
||||
{
|
||||
lines.Add($" 混合参数 X: {info.blendParameter}");
|
||||
lines.Add($" 混合参数 Y: {info.blendParameterY}");
|
||||
lines.Add($" 参数范围: [{info.minThreshold}, {info.maxThreshold}]");
|
||||
lines.Add($" 动画片段列表 ({info.clips?.Count ?? 0} 个):");
|
||||
if (info.clips != null)
|
||||
{
|
||||
for (int j = 0; j < info.clips.Count; j++)
|
||||
{
|
||||
var clip = info.clips[j];
|
||||
lines.Add($" [{j + 1}] {clip.clipName} (位置: {clip.position})");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($" 动画片段数量: {info.clipCount}");
|
||||
}
|
||||
|
||||
if (i < _blendTreeInfos.Count - 1)
|
||||
{
|
||||
lines.Add("");
|
||||
}
|
||||
}
|
||||
|
||||
_resultText = string.Join("\n", lines);
|
||||
}
|
||||
|
||||
private void SaveToJson()
|
||||
{
|
||||
if (_selectedController == null || _blendTreeInfos == null || _blendTreeInfos.Count == 0)
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "没有可保存的数据", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 获取 AnimatorController 的资产路径
|
||||
string controllerPath = AssetDatabase.GetAssetPath(_selectedController);
|
||||
if (string.IsNullOrEmpty(controllerPath))
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "无法获取 AnimatorController 的路径", "确定");
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成 JSON 文件路径(与 .controller 文件同名同路径,扩展名为 .json)
|
||||
string directory = Path.GetDirectoryName(controllerPath);
|
||||
string fileName = Path.GetFileNameWithoutExtension(controllerPath);
|
||||
string jsonPath = Path.Combine(directory, $"{fileName}.json");
|
||||
|
||||
// 转换为 Unity 资源路径格式
|
||||
jsonPath = jsonPath.Replace("\\", "/");
|
||||
if (!jsonPath.StartsWith("Assets/"))
|
||||
{
|
||||
// 如果不是 Assets 路径,尝试转换
|
||||
string fullPath = Path.GetFullPath(jsonPath);
|
||||
string assetsPath = Path.GetFullPath(Application.dataPath);
|
||||
if (fullPath.StartsWith(assetsPath))
|
||||
{
|
||||
jsonPath = "Assets" + fullPath.Substring(assetsPath.Length).Replace("\\", "/");
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorUtility.DisplayDialog("错误", "JSON 文件路径必须在 Assets 目录下", "确定");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 配置序列化设置,忽略循环引用
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
ReferenceLoopHandling = ReferenceLoopHandling.Ignore,
|
||||
Formatting = Formatting.Indented
|
||||
};
|
||||
|
||||
// 组装配置对象(包含默认 State 名称和混合树列表)
|
||||
var config = new BlendTreeConfig
|
||||
{
|
||||
defaultStateName = _defaultStateName,
|
||||
blendTrees = _blendTreeInfos
|
||||
};
|
||||
|
||||
// 序列化为 JSON
|
||||
string jsonContent = JsonConvert.SerializeObject(config, settings);
|
||||
|
||||
// 写入文件(如果已存在则覆盖)
|
||||
File.WriteAllText(jsonPath, jsonContent, System.Text.Encoding.UTF8);
|
||||
|
||||
// 刷新资源数据库
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
EditorUtility.DisplayDialog("保存成功", $"JSON 文件已保存到:\n{jsonPath}", "确定");
|
||||
Debug.Log($"JSON 文件已保存: {jsonPath}");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
EditorUtility.DisplayDialog("保存失败", e.Message, "确定");
|
||||
Debug.LogError($"保存 JSON 失败: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace AibisDream.SystemEditor
|
||||
private Dictionary<string, bool> _expandedNodes = new Dictionary<string, bool>();
|
||||
private bool _showDeleteButton = false; // 是否显示JSON编辑器中的删除按钮(默认隐藏)
|
||||
|
||||
[MenuItem("Tools/JSON编辑器 (Json Editor)")]
|
||||
[MenuItem(AibisEditorMenus.JsonEditor)]
|
||||
public static void ShowWindow()
|
||||
{
|
||||
GetWindow<JsonEditWindow>("JSON Editor");
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace AibisDream.YarnLocalizationValidation.Editor
|
||||
{
|
||||
public class YarnL10nValidationWindow : EditorWindow
|
||||
{
|
||||
private const string MenuPath = "AIBIS/Yarn 本地化校验";
|
||||
private const string MenuPath = AibisEditorMenus.YarnLocalizationValidator;
|
||||
private const string WindowTitle = "Yarn Localization Validator";
|
||||
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
|
||||
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a9d37a5cd9ff0d04d81adda3a8bb4db4
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
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