Compare commits

...
Author SHA1 Message Date
dingyuntian b315bf144b Merge branch 'feature/音乐介入' into 'develop'
Feature/音乐介入

See merge request aibis-dream/aibis-dream!781
2026-07-24 17:12:58 +00:00
dingyuntian da7821cde5 Merge branch 'bugfix/去除测试快捷键' into 'develop'
Bugfix/去除测试快捷键

See merge request aibis-dream/aibis-dream!780
2026-07-24 17:09:11 +00:00
Ovid cfd8cda709 debug 2026-07-25 01:07:25 +08:00
dingyuntian 96796f6b80 fix: 动画错误 2026-07-25 01:04:49 +08:00
Ovid 5faa81c3c2 debug 2026-07-25 01:00:21 +08:00
Ovid dfdb75f833 Merge branch 'develop' into feature/音乐介入 2026-07-25 00:39:36 +08:00
Ovid c368ff416d debug 2026-07-25 00:38:04 +08:00
Ovid 1e56b83e06 音乐接入 2026-07-25 00:30:36 +08:00
dingyuntian 9c30561ed8 fix: 配角色表 2026-07-25 00:21:25 +08:00
dingyuntian ce8486c1eb fix: 去掉语言测试相关功能 2026-07-25 00:13:27 +08:00
dingyuntian f679985369 Merge branch 'feature/美术资源替换' into 'develop'
Feature/美术资源替换

See merge request aibis-dream/aibis-dream!779
2026-07-24 15:46:52 +00:00
dingyuntian 43bd3ece5b art: 地铁微调 2026-07-24 23:43:06 +08:00
dingyuntian 4cf8febec2 art: 地铁美术和更新 2026-07-24 23:01:32 +08:00
dingyuntian 2384ef80f2 art: 插孔和按钮 2026-07-24 19:55:12 +08:00
dingyuntian dcfd013db8 Merge branch 'feature/开发模式' into 'develop'
Feature/开发模式

See merge request aibis-dream/aibis-dream!778
2026-07-24 08:41:26 +00:00
dingyuntian c6bcd0b798 yarn: 节点调整 2026-07-24 16:40:43 +08:00
dingyuntian d0904efc20 fix: 部分存档问题修复 2026-07-24 16:40:20 +08:00
dingyuntian 1072b65a00 fix: 移除Timer 2026-07-24 16:39:39 +08:00
dingyuntian 7f087d89b6 fix: 开发模式调整 2026-07-24 16:39:15 +08:00
dingyuntian 590c1576e2 Merge branch 'develop' into feature/开发模式 2026-07-24 13:50:49 +08:00
dingyuntian 94e27b4d11 Merge branch '0723火山表情添加1' into 'develop'
上传

See merge request aibis-dream/aibis-dream!777
2026-07-24 05:24:27 +00:00
dingyuntian f3cff824ce fix: 解析时自动去前后空格 2026-07-24 13:23:16 +08:00
dingyuntian e6ae903c03 feat: 测试存档功能 2026-07-24 13:16:03 +08:00
NeologismWu 8a68e0676b 1
11
2026-07-23 22:09:58 +08:00
dingyuntian 2cec5dd66c Merge branch 'bugfix/问题修复' into 'develop'
Bugfix/问题修复

See merge request aibis-dream/aibis-dream!776
2026-07-23 13:38:03 +00:00
dingyuntian 15f5c34db5 audio: 增加音频项 2026-07-23 21:36:37 +08:00
dingyuntian 13d7c305c8 art: 佩佩美术更新 2026-07-23 21:36:05 +08:00
dingyuntian 03a71b4ba3 fix: show_large_sprite闪烁问题 2026-07-23 20:46:13 +08:00
dingyuntian 2ef6db54da feat: 开发面板调整 2026-07-23 16:57:58 +08:00
dingyuntian 4a9f487db9 fix: 处理一下编辑器编译错误 2026-07-23 16:57:34 +08:00
dingyuntian 360293941a chore: 编辑器分类清理 2026-07-23 16:25:45 +08:00
dingyuntian 28e4214413 feat: 清除几个旧的混用测试存档功能 2026-07-23 15:42:21 +08:00
dingyuntian 33ec00ddb8 Merge branch 'feature/PPaseprite多余的删除' into 'develop'
删除多余资源

See merge request aibis-dream/aibis-dream!775
2026-07-23 06:34:09 +00:00
Ovid 07f60c29c4 删除多余资源 2026-07-23 14:24:00 +08:00
dingyuntian cd2c7f6406 Merge branch 'develop' into feature/开发模式 2026-07-23 13:47:17 +08:00
dingyuntian 7e89b2cb3e Merge branch 'feature/pp-aseprite修改' into 'develop'
Update 佩佩像素版-通用动画.aseprite

See merge request aibis-dream/aibis-dream!774
2026-07-23 04:50:44 +00:00
zhouziqian 1ba1a007a8 Update 佩佩像素版-通用动画.aseprite 2026-07-23 12:47:59 +08:00
NeologismWu 132098f70c 上传
上传
2026-07-23 10:47:34 +08:00
dingyuntian 5ef26b6259 Merge branch 'feature/demo脚本改动0722' into 'develop'
D2S开头去掉

See merge request aibis-dream/aibis-dream!773
2026-07-22 17:03:15 +00:00
Ovid a4fec41afb D2S开头去掉 2026-07-23 01:00:47 +08:00
dingyuntian c254d39fd7 Merge branch 'feature/demo脚本改动0722' into 'develop'
Feature/demo脚本改动0722

See merge request aibis-dream/aibis-dream!772
2026-07-22 16:34:27 +00:00
Ovid d7e2e8e690 乌鸦改金色背景 2026-07-23 00:31:19 +08:00
Ovid e4697e2462 Merge branch 'develop' into feature/demo脚本改动0722 2026-07-23 00:27:00 +08:00
Ovid 9a3cb3f054 结尾! 2026-07-23 00:26:47 +08:00
dingyuntian 2c74e7d5e0 Merge branch 'feature/帧动画编辑器重构' into 'develop'
Feature/帧动画编辑器重构

See merge request aibis-dream/aibis-dream!771
2026-07-22 14:30:43 +00:00
dingyuntian 8722c92706 feat: 帧动画编辑器及相关文档 2026-07-22 22:30:10 +08:00
dingyuntian 1fdca74bfb Merge branch 'develop' into feature/帧动画编辑器重构 2026-07-22 21:59:35 +08:00
dingyuntian d82a4a3606 Merge branch 'bugfix/美术素材填充' into 'develop'
Bugfix/美术素材填充

See merge request aibis-dream/aibis-dream!770
2026-07-22 13:57:49 +00:00
dingyuntian 0825204666 Merge branch 'develop' into bugfix/美术素材填充 2026-07-22 21:55:45 +08:00
dingyuntian 1af6f0b47d yarn: 改错别字 2026-07-22 21:50:44 +08:00
dingyuntian 0e0ed2ff7a art: 素材修改 2026-07-22 21:19:10 +08:00
dingyuntian 2ae6f712a4 fix: 修正文字速度修改不生效的问题 2026-07-22 21:17:16 +08:00
dingyuntian b20b936966 Merge branch 'feature/demo脚本改动0722' into 'develop'
Feature/demo脚本改动0722

See merge request aibis-dream/aibis-dream!769
2026-07-22 12:19:02 +00:00
Ovid 4a0e3ba0b9 debug 2026-07-22 20:18:29 +08:00
dingyuntian 2a1cfbc208 Merge branch 'feature/demo脚本改动0722' into 'develop'
Feature/demo脚本改动0722

See merge request aibis-dream/aibis-dream!768
2026-07-22 10:40:12 +00:00
Ovid e3762e5462 Merge branch 'develop' into feature/demo脚本改动0722 2026-07-22 17:55:35 +08:00
dingyuntian 5029a4b9b5 Merge branch '7.22春优化' into 'develop'
fix(fixsystem): 修复线缆收放状态与阶段切换竞态

See merge request aibis-dream/aibis-dream!767
2026-07-22 08:33:58 +00:00
bottlefishandCursor 3debdb39e4 merge: 合并 develop 到 7.22春优化
保留 PullUpCable 线缆收起逻辑,并接入 develop 的 BodyModule 退出清屏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:30:10 +08:00
bottlefishandCursor 04edf9b355 chore(devsave): 清理 Huoshan1 开发存档快照
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:26:01 +08:00
bottlefishandCursor 6854679bcb yarn(peipei): 补充阶段切换时的放线指令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:25:50 +08:00
bottlefishandCursor 515f57e28c yarn(huoshan): 重构任务面板流程并移除 Stage7 插线检查
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:25:36 +08:00
bottlefishandCursor 97ef0972fd art(fixsystem): 调整物理线缆色阶与高光参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:25:17 +08:00
bottlefishandCursor 2fb32c44e8 fix(fixsystem): 修复发条归零时充能状态未重置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:25:05 +08:00
bottlefishandCursor 3d65941b03 fix(fixsystem): 修复线缆收放状态与阶段切换竞态
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 16:24:52 +08:00
Ovid 5091851ead 结尾调整进行中 2026-07-22 04:23:37 +08:00
dingyuntian 34d2ac40eb fix: 修复初次show_obj的延迟 2026-07-22 04:16:10 +08:00
dingyuntian 4411bfa4b6 Merge branch 'bugfix/美术素材填充' into 'develop'
fix: 保证离开BodyModule一定清屏

See merge request aibis-dream/aibis-dream!766
2026-07-21 20:06:30 +00:00
dingyuntian 02d3fc04e6 fix: 保证离开BodyModule一定清屏 2026-07-22 04:05:46 +08:00
dingyuntian ef3c14d3de Merge branch 'bugfix/美术素材填充' into 'develop'
Bugfix/美术素材填充

See merge request aibis-dream/aibis-dream!765
2026-07-21 20:02:51 +00:00
dingyuntian b7fdcf97fe fix: 插头不拔出屏幕不刷新 2026-07-22 04:01:52 +08:00
dingyuntian ba0ce38c11 art: 美术素材问题 2026-07-22 03:17:40 +08:00
dingyuntian 3b5d05260e Merge branch 'bugfix/已知问题修复' into 'develop'
Bugfix/已知问题修复

See merge request aibis-dream/aibis-dream!764
2026-07-21 18:33:48 +00:00
dingyuntian f4488a68d0 feat: 检索及彩带Timeline 2026-07-22 02:32:42 +08:00
dingyuntian c6c76666ef feat: 帧动画系统Timeline兼容 2026-07-22 02:32:04 +08:00
Ovid d655887a02 debug一些 2026-07-22 02:25:43 +08:00
Ovid adbcd89918 Merge branch 'develop' into feature/demo脚本改动0722 2026-07-22 02:10:50 +08:00
dingyuntian 9b2caf64c9 art: 部分图像配置与按钮更新 2026-07-22 01:53:07 +08:00
dingyuntian 82b20a8fab art: 火山彩带资源 2026-07-22 01:46:39 +08:00
dingyuntian 0f06041fa2 Merge branch '0720-振彤火山文本迭代' into 'develop'
火山stage8和第二天中午文本

See merge request aibis-dream/aibis-dream!763
2026-07-21 17:26:30 +00:00
dingyuntian 8a5d83d888 Merge branch 'develop' into '0720-振彤火山文本迭代'
# Conflicts:
#   Assets/Yarn/FP/FP_Day2_mid/Day2_mid.yarn
2026-07-21 17:26:22 +00:00
Ovid 71a803fd72 Merge branch 'develop' into feature/demo脚本改动0722 2026-07-22 01:25:08 +08:00
Ovid 672d554b40 PP2调整上传 2026-07-22 01:23:01 +08:00
NeologismWu 4259306e1f 0722提交
0722提交
2026-07-22 01:21:10 +08:00
dingyuntian 8b16aebc6b Merge branch '7.21春优化' into 'develop'
7.21春优化

See merge request aibis-dream/aibis-dream!762
2026-07-21 16:56:47 +00:00
bottlefish 52db661484 fix(dialog): 修复D2 Sleep换图与门观察表现 2026-07-22 00:50:59 +08:00
bottlefish aed9998d1a refactor: 拆分D2 Sleep表现并接入快照 2026-07-22 00:50:16 +08:00
dingyuntian 3919ea9a75 fix: 气泡时序问题 2026-07-22 00:07:54 +08:00
dingyuntian 3891b6bd37 editor: 编辑器微调 2026-07-22 00:06:59 +08:00
dingyuntian 9f61f53b7f art: 地铁火山更新 2026-07-22 00:06:10 +08:00
dingyuntian 6dc0983961 Merge branch 'bugfix/已知问题修复' into 'develop'
Bugfix/已知问题修复

See merge request aibis-dream/aibis-dream!761
2026-07-21 14:29:31 +00:00
dingyuntian 9627586130 Merge branch 'develop' into bugfix/已知问题修复 2026-07-21 22:28:53 +08:00
dingyuntian 28cc1127f5 fix: 部分小问题修复 2026-07-21 22:27:51 +08:00
dingyuntian 532d9fb320 Merge branch '7.20春优化' into 'develop'
revert(huoshan): 将释放log表现回退至6月版本

See merge request aibis-dream/aibis-dream!760
2026-07-21 14:25:04 +00:00
bottlefishandCursor 0d59e7d91e chore(devsave): 移除 Day2Sleep 开发存档条目
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:56 +08:00
bottlefishandCursor 859d780b78 yarn(FP_Day2_sleep): 简化风筝放手与推门演出并更新梦境素材
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:46 +08:00
bottlefishandCursor 7ee6cba2ae fix(yarn): 修正 Stage6 hide_full_screen 拼写
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:46 +08:00
bottlefishandCursor 6159704116 feat(cable): 线缆暖色配色与拉紧驱动线盘转动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:41 +08:00
bottlefishandCursor 74f5989802 fix(memory): 调整记忆播放结束复位时序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:16:34 +08:00
bottlefishandCursor 0b848d8040 fix(peipei): 收紧 Eye 聚焦平移边界与视口计算
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:13:22 +08:00
bottlefishandCursor 96b309992c refactor(save): 移除 Showcase 快照持久化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 22:13:11 +08:00
bottlefish 1547055cc2 Revert "scene(peipei): 添加Bloom并调整维修场景布局"
This reverts commit 9ef6bc9bff.
2026-07-21 18:51:48 +08:00
bottlefishandCursor 3d119dcd92 refactor(knob): 移除旋钮旋转光标交互逻辑
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:50:57 +08:00
bottlefishandCursor c4ef30b151 style(screen): 调整维修面板标题与日志文本配色
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:50:48 +08:00
bottlefishandCursor 12c5735466 feat(ui): 添加 TerminalText 正文颜色设置方法
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:50:03 +08:00
bottlefishandCursor 35451363a5 scene(yingli): 调整场景物件位置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:48:30 +08:00
bottlefishandCursor 9ef6bc9bff scene(peipei): 添加Bloom并调整维修场景布局
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:48:27 +08:00
bottlefishandCursor 97f0fabd3c scene(clinic): 调整英里初登场等待与Timeline
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:44:26 +08:00
bottlefishandCursor 018e9d3b52 fix(bar): 修复醉酒状态切换抽搐并收紧过渡
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:44:17 +08:00
bottlefishandCursor c0e58d56b4 feat(scene-mgmt): 调整轱辘拉锯为发力微颤
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:43:00 +08:00
bottlefishandCursor cffb99a52c fix(cutline): 剪线后立即禁用避免重复触发
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:42:59 +08:00
bottlefishandCursor 597e97c5d7 fix(bodymodule): 切割入场同步全部模块CutText
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:42:58 +08:00
bottlefishandCursor 7a8f7dbbd7 fix(cable): 强制收线改用运动学收拢避免甩鞭
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:42:50 +08:00
bottlefishandCursor 8fee89db36 fix(heatmap): 按Sprite几何中心旋转风扇避免散架
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:42:44 +08:00
bottlefishandCursor 046b1a9efb fix(memory): 优化彩色褪色扫光并复位拉杆视觉
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 17:41:46 +08:00
bottlefishandCursor c7c0e96110 scene(open): 固定Emergency气泡Timeline时长
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:41:40 +08:00
bottlefish f6f87c3109 Merge branch 'develop' into 7.20春优化 2026-07-21 01:39:29 +08:00
bottlefishandCursor 98dfb644c5 feat(scene-mgmt): 放缓轱辘拉锯幅度与节奏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:20:13 +08:00
bottlefishandCursor 845f46b924 fix(memory): 记忆结束增加短闪雪花过渡
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:20:04 +08:00
bottlefishandCursor 5f31cd5635 fix(peipei-uf): 重绑垂手脸图并加固faceIn
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:19:16 +08:00
bottlefishandCursor 1e8ec2e64f feat(eye): 眨眼仅用于想象上色并关闭自动眨眼
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:18:46 +08:00
bottlefishandCursor e7a0089a46 feat(cutline): Timeline黑场交接UI避免剪线露头
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:18:16 +08:00
bottlefishandCursor 2ac0b4695d feat(fix-panel): Warning回落增加闪烁淡出并对齐等待
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:18:03 +08:00
bottlefishandCursor ecfefa5b87 fix(bodymodule): 切割时按舱外状态同步CutText
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:17:52 +08:00
bottlefishandCursor 3164fd7d8c feat(shock): 拉杆接入EventTriggerEx并统一光标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:17:41 +08:00
bottlefishandCursor dc0488f085 fix(eventsystem): 修复锁定时松手未清理导致光标卡住
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 01:16:57 +08:00
bottlefishandCursor 164e3dd50e revert(huoshan): 将释放log表现回退至6月版本
保留 Stage6 当前台词,仅恢复粒子与完成表现及 start_expression 参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 00:03:31 +08:00
dingyuntian c985cc2843 Merge branch 'bugfix/已知问题修复' into 'develop'
fix: 佩佩场景部分位置

See merge request aibis-dream/aibis-dream!759
2026-07-20 15:26:38 +00:00
dingyuntian b0ee02c62c fix: 佩佩场景部分位置 2026-07-20 23:26:01 +08:00
dingyuntian a043f27035 Merge branch 'bugfix/已知问题修复' into 'develop'
Bugfix/已知问题修复

See merge request aibis-dream/aibis-dream!758
2026-07-20 15:16:29 +00:00
dingyuntian a9f58fc683 fix: 已知问题修复 2026-07-20 23:15:34 +08:00
dingyuntian 94823354b5 feat: 延时auto_next 2026-07-20 22:34:23 +08:00
dingyuntian 982a1eedbe feat: 帧动画编辑器美化 2026-07-20 22:04:32 +08:00
dingyuntian a5ec4bb413 Merge branch 'develop' into feature/开发模式 2026-07-20 18:15:05 +08:00
dingyuntian 4ec5e0b442 Merge branch 'feature/振彤Yarn提交' into 'develop'
Feature/振彤yarn提交

See merge request aibis-dream/aibis-dream!757
2026-07-20 10:14:24 +00:00
dingyuntian 4d44ca387e docs: 新增Demo QA测试说明文档 2026-07-20 18:12:56 +08:00
dingyuntian 1171ad9e87 build: 更新版本号至0.6.0.1-20260720 2026-07-20 18:12:32 +08:00
dingyuntian 1318636bee yarn: 补Day2_mid节点跳转对话 2026-07-20 18:11:26 +08:00
dingyuntian f17a8ab8dd scene(peipei): 调整冷色特效参数与线缆位置 2026-07-20 18:11:05 +08:00
dingyuntian b70f2614af art: 移动光标贴图至Common并更新 2026-07-20 18:10:42 +08:00
dingyuntian 517f4d956a refactor(savesystem): 正式存档目录更名为demo_saves 2026-07-20 18:09:54 +08:00
dingyuntian 46c47ea4be Merge branch 'feature/振彤Yarn提交' into 'develop'
yarn: 文本修改

See merge request aibis-dream/aibis-dream!756
2026-07-20 07:01:25 +00:00
dingyuntian 41d3e07a55 yarn: 文本修改 2026-07-20 14:41:08 +08:00
dingyuntian d10dfc1892 feat: 开发模式 2026-07-20 14:29:47 +08:00
dingyuntian 2cd0ae92be Merge branch 'feature/demo调整0719true' into 'develop'
Feature/demo调整0719true

See merge request aibis-dream/aibis-dream!755
2026-07-19 19:59:47 +00:00
dingyuntian 3491367bd9 Merge branch '7.19春优化' into 'develop'
fix(heatmap): 低温段改为蓝色噪点并保留发热色带

See merge request aibis-dream/aibis-dream!754
2026-07-19 19:55:33 +00:00
bottlefish dc6fe93afb 提交结尾场景为天桥 2026-07-20 03:53:07 +08:00
Ovid ff89ffe552 GTS幽灵音量+大 2026-07-20 03:52:09 +08:00
bottlefish bd30cdabd0 Merge branch 'develop' into 7.19春优化 2026-07-20 03:50:37 +08:00
Ovid 6ff260150d HS1病例补充动态 2026-07-20 03:50:00 +08:00
bottlefishandCursor 32d2fcc0a2 yarn(dream): 优化 Day2 梦境演出与放手流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:47:13 +08:00
bottlefishandCursor cca5404f0c feat(scene-mgmt): 支持无线轱辘分层与加速晃动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:47:05 +08:00
bottlefishandCursor 6e5e5d5790 art(dream): 添加 Day2 梦境 aseprite 源文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:46:54 +08:00
bottlefishandCursor ad759a6e83 art(dream): 新增 Day2 梦境演出贴图并更新素材
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:46:33 +08:00
bottlefishandCursor 65205e2552 yarn(huoshan): 简化 Stage7 直接回诊室流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 03:46:11 +08:00
dingyuntian cb255f8ca5 Merge branch 'bugfix/佩佩动画迁移' into 'develop'
Bugfix/佩佩动画迁移

See merge request aibis-dream/aibis-dream!753
2026-07-19 19:39:32 +00:00
dingyuntian 5f30f356ec fix: 章节本地化 2026-07-20 03:38:59 +08:00
dingyuntian 3c789cbd65 art: 火山动画及行人动画 2026-07-20 03:38:47 +08:00
Ovid fabef0145d +上没有女孩的椅子 2026-07-20 03:34:03 +08:00
Ovid 785add905d HS1病例上传 2026-07-20 03:20:15 +08:00
Ovid 2fe558846b PP2分析上传 2026-07-20 03:10:49 +08:00
dingyuntian 1fa96151d6 Merge branch 'bugfix/佩佩动画迁移' into 'develop'
Bugfix/佩佩动画迁移

See merge request aibis-dream/aibis-dream!752
2026-07-19 19:01:06 +00:00
dingyuntian c8830d9346 yarn: 多余fadeout 2026-07-20 03:00:19 +08:00
dingyuntian ecf75ae6a6 art: 光标更新 2026-07-20 03:00:04 +08:00
dingyuntian 5f2f6e6365 art: 清除背景残留 2026-07-20 02:59:24 +08:00
dingyuntian 153d36f884 art: 清理废弃资源 2026-07-20 02:59:06 +08:00
NeologismWu 6481858017 Update Day2_mid.yarn 2026-07-20 02:33:35 +08:00
NeologismWu c9ca07a0b3 火山stage8和第二天中午文本
111
2026-07-20 02:29:02 +08:00
Ovid cb1f5b90a7 恢复病历图界面缺失的图层 2026-07-20 02:27:36 +08:00
Ovid 9f4c473fac 序章调整 2026-07-20 02:22:07 +08:00
dingyuntian 4eb72911e1 Merge branch 'bugfix/佩佩动画迁移' into 'develop'
Bugfix/佩佩动画迁移

See merge request aibis-dream/aibis-dream!751
2026-07-19 18:13:56 +00:00
dingyuntian 5f4f9c1721 art(ui): 调整对话气泡图片透明度 2026-07-20 02:10:25 +08:00
dingyuntian 202cdb4c29 feat(peipei): 佩佩动画状态迁移到Flow图 2026-07-20 02:10:25 +08:00
dingyuntian 1ade595f9d yarn(dialog): generate_PunchTape命令改用定义ID参数 2026-07-20 02:10:24 +08:00
dingyuntian 3ed84f79b1 refactor(punchtape): 打孔带数据改为Catalog定义驱动并迁移脚本目录 2026-07-20 02:10:24 +08:00
bottlefishandCursor 1444e43449 chore(scene): 调整 Playtest 结束流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:27:30 +08:00
bottlefishandCursor 6e7bb3f637 art(bridge): 更新下雪序列帧并接入天桥场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:22:04 +08:00
bottlefishandCursor 5ab204b476 fix(yarn): 修正 Day2 night BE detour 节点名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:21:55 +08:00
bottlefishandCursor 84192cb7c4 yarn(dialog): 优化 Day2 梦境 Bloom 与全屏演出流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:21:48 +08:00
bottlefishandCursor c98644c723 art(dream): 新增 Day2 梦境天花板风扇与星图素材
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:21:23 +08:00
bottlefishandCursor 15aba8e805 feat(dialog): 新增全屏序列帧与模糊 Yarn 指令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:21:07 +08:00
bottlefishandCursor 90c75d1f45 feat(scene-mgmt): 全屏图柔焦模糊与序列帧播放
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:20:52 +08:00
bottlefishandCursor f8821b1b3e fix(fix-system): 切割进入时支持 Cut Text 闪烁
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:20:25 +08:00
bottlefishandCursor 41ca1927a0 fix(heatmap): 低温段改为蓝色噪点并保留发热色带
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 01:20:12 +08:00
dingyuntian 6bf967e8b3 Merge branch 'feature/demo脚本调整0719' into 'develop'
Feature/demo脚本调整0719

See merge request aibis-dream/aibis-dream!750
2026-07-19 15:47:28 +00:00
dingyuntian 2514eb0a8d Merge branch 'bugfix/小问题修复' into 'develop'
Bugfix/小问题修复

See merge request aibis-dream/aibis-dream!749
2026-07-19 15:44:28 +00:00
dingyuntian f444978717 fix: 火山表情修复 2026-07-19 23:43:53 +08:00
Ovid 554de790c1 Merge branch 'develop' into feature/demo脚本调整0719 2026-07-19 23:08:02 +08:00
Ovid 1764714cb5 PP2部分调整 2026-07-19 23:07:33 +08:00
dingyuntian a6d541383b Merge branch 'develop' into bugfix/小问题修复 2026-07-19 23:04:53 +08:00
dingyuntian 00b5646b44 feat: 打孔带更新 2026-07-19 23:04:04 +08:00
dingyuntian 9403bd9623 Merge branch '7.17春优化' into 'develop'
7.17春优化

See merge request aibis-dream/aibis-dream!748
2026-07-19 15:00:09 +00:00
bottlefish 14fd64e7bd fix(dialog): 修复 Day2 梦境终端过渡异常 2026-07-19 22:39:19 +08:00
bottlefish 5a37f529ab fix(dialog): 修复快进时后处理渐变回写 2026-07-19 22:36:45 +08:00
bottlefish 06d6127c69 chore(merge): 合并 develop 并整合 Day2 梦境剧情与演出 2026-07-19 21:25:30 +08:00
Ovid 53587c413e D1S手账串接 2026-07-19 20:50:22 +08:00
bottlefish 5408fd5320 build(config): 改用本地 Unity MCP 包 2026-07-19 20:42:51 +08:00
bottlefish 1f6208a094 yarn(dialog): 调整 Day2 天桥后处理权重 2026-07-19 20:42:19 +08:00
bottlefish 772a6bbbe8 yarn(dialog): 优化 Day2 梦境演出流程 2026-07-19 20:41:35 +08:00
bottlefish bfc61a0097 feat(scene-mgmt): 重构梦境门海浪切换演出 2026-07-19 20:40:20 +08:00
bottlefish bfbfe494c4 art(scene-mgmt): 更新梦境门框与海浪动画资源 2026-07-19 20:38:28 +08:00
bottlefish 297075e0f0 feat(dialog): 添加后处理参数 Yarn 指令 2026-07-19 20:37:32 +08:00
dingyuntian 71d248c2e6 Merge branch '0718-振彤优化' into 'develop'
Update Day2_mid.yarn

See merge request aibis-dream/aibis-dream!745
2026-07-19 12:37:31 +00:00
bottlefish 7034b82aa4 perf(scene-mgmt): 优化星空失焦模糊效果 2026-07-19 20:36:06 +08:00
bottlefish eb127371f1 fix(localization): 补充终端特殊字符字形 2026-07-19 20:32:48 +08:00
bottlefish cec2dae5e0 yarn(dialog): 为火山头冷台词添加振动演出 2026-07-19 20:32:09 +08:00
bottlefish ba19c2eff5 feat(scene-mgmt): 添加角色立绘持续振动指令 2026-07-19 20:31:16 +08:00
bottlefish 7fa12e3d46 style(ui): 调整任务条目文字颜色 2026-07-19 20:30:29 +08:00
bottlefish bb6b3dd599 feat(fix-system): 区分维修终端错误提示颜色 2026-07-19 20:30:05 +08:00
bottlefish d7030f36d2 feat(fix-system): 优化低温热力图视觉表现 2026-07-19 20:29:30 +08:00
bottlefish 94a817f5fa feat(tarot): 添加塔罗展示背景压暗 2026-07-19 20:29:01 +08:00
bottlefish bfdcb41116 art(tarot): 更新塔罗牌面资源 2026-07-19 20:28:25 +08:00
bottlefish 6ace48dba2 art(scene-bar): 修正酒吧角色动画表现 2026-07-19 20:27:00 +08:00
dingyuntian 2999f753f7 fix: 进入记忆界面相关修改 2026-07-19 19:41:13 +08:00
dingyuntian c3da27d07c fix: 梦境对话跳Warning 2026-07-19 19:05:06 +08:00
dingyuntian 05fe1ebb60 Merge branch 'feature/demo脚本调整' into bugfix/小问题修复 2026-07-19 18:15:58 +08:00
dingyuntian ad5111a206 fix: 字符缺失问题 2026-07-19 18:14:17 +08:00
dingyuntian 592d3a36dd art: 音量条 2026-07-19 17:23:55 +08:00
dingyuntian 3e2a24421f Merge branch 'feature/demo脚本调整' into 'develop'
Feature/demo脚本调整

See merge request aibis-dream/aibis-dream!747
2026-07-18 16:14:23 +00:00
dingyuntian bc85e4d343 Merge branch 'develop' into feature/demo脚本调整 2026-07-19 00:13:36 +08:00
Ovid 551dd6d183 debug 2026-07-19 00:11:25 +08:00
dingyuntian c9f508bb8e Merge branch 'feature/GameManager相关重构' into 'develop'
feat(log-kit): 重构文件日志系统支持异步结构化日志

See merge request aibis-dream/aibis-dream!746
2026-07-18 16:04:06 +00:00
dingyuntian ba5a9fc2f3 docs(dialog): 添加梦境选项提示语使用文档 2026-07-19 00:00:54 +08:00
dingyuntian d5c4917a8a test(dialog): 添加选项提示语元数据测试
校验 IsOptionPromptLine 仅匹配精确的 option_prompt 元数据。
2026-07-19 00:00:54 +08:00
dingyuntian c751a859ff feat(dialog): 添加梦境选项提示语支持
标记 #option_prompt 的台词不再进入普通台词流程,由 OptionView
暂存并在下一组 Dream Options 出现时显示在选项上方,同时通过
新增 LineHistoryAppend 事件加入对话历史。OnelineOptions 支持
提示语文本与独立选项根节点,并改为按实例追踪销毁按钮。
2026-07-19 00:00:17 +08:00
dingyuntian 081ed0d883 test(save): 添加演出快照契约测试
校验 showcase/playTool/screen 的 SaveId 与还原顺序,
以及快照 section 的 JSON 序列化往返。
2026-07-19 00:00:01 +08:00
dingyuntian a5e417eeae feat(save): 新增梦境演出图与物品图的存档快照
新增 showcase(order 69) 与 playTool(order 70) 两个快照 section,
分别持久化 SpriteShowcase 小图/大图与 PlayToolPanel 物品图/全屏图
的终态,读档时直接还原静止画面。SpriteShowcase 与 PlayToolPanel
补充快照捕获/还原与立即清理逻辑,GameManager 在卸载场景前清理
演出效果。screen 还原顺序顺延至 80。
2026-07-18 23:59:52 +08:00
dingyuntian 2eea496c4b feat(ui): 支持在指定面板上点击推进对话
新增 AllowDialogueAdvanceOnPointer 标记组件,IsPointerOverUI 命中
带该标记的 UI 时不再阻止对话推进;同时缓存 PointerEventData 与
射线结果列表,避免每次检测分配。Play Tool 与 Screen Transition
面板挂上该组件。
2026-07-18 23:59:33 +08:00
dingyuntian d316922b3a fix(fix-system): 修复维修中心销毁时的依赖清理异常
FixSystemCenter.OnSingletonDestroy 在场景销毁阶段访问的 CameraKit、
BubbleSlotKit 可能已清理,且 Director.QuitCurrentMode 协程依赖的
对象可能已拆除。改为判空访问,并新增 Director.Reset 直接清理状态。
2026-07-18 23:59:19 +08:00
dingyuntian 600fd4cebf fix: 修复场景卸载句柄自动释放导致的异常
UnloadSceneAsync 默认 autoReleaseHandle 为 true,yield 完成后访问
Status 会抛异常。改为手动管理句柄生命周期,并将 FixSystemCenter
与 ResourceSystem 的清理移到卸载成功之后。
2026-07-18 23:58:52 +08:00
bottlefish ce00539c18 feat(save): 迁移并完善开发跳转存档 2026-07-18 22:15:52 +08:00
NeologismWu 78d5ad33f0 Update Stage6.yarn 2026-07-18 21:48:06 +08:00
NeologismWu 2fec120568 Update Day2_mid.yarn 2026-07-18 20:45:41 +08:00
dingyuntian 4b595fcee9 fix: 对话框底色小改动 2026-07-18 19:24:13 +08:00
dingyuntian 947cfa2fb4 Merge remote-tracking branch 'origin/feature/日志系统翻新' into feature/GameManager相关重构 2026-07-18 17:33:31 +08:00
dingyuntian 0dc1001723 fix: BodyModule可能的注册失败问题 2026-07-18 17:28:06 +08:00
dingyuntian 7476263411 Merge branch 'develop' into feature/GameManager相关重构 2026-07-17 22:21:00 +08:00
dingyuntian 91c3f509e1 yarn: 佩佩2表情替换(待全量检查) 2026-07-17 22:20:22 +08:00
dingyuntian 686c32738d Merge branch 'develop' into feature/demo脚本调整 2026-07-17 20:24:18 +08:00
dingyuntian b40f17ea9f Merge branch 'feature/帧动画系统' into 'develop'
Feature/帧动画系统

See merge request aibis-dream/aibis-dream!744
2026-07-17 12:23:38 +00:00
dingyuntian 4b86068bbf chore: 删除残留 TerminalUI 文件夹 meta 2026-07-17 20:20:25 +08:00
dingyuntian 1077bc551c docs(frame-animation): 补充共享 Sprite 与 Actor 接入文档 2026-07-17 20:20:16 +08:00
dingyuntian a4620dacc8 yarn(dialog): 火山初始化改为 FrameAnimation 类型 2026-07-17 20:20:06 +08:00
dingyuntian 2c8bc17174 chore(addressables): 注册帧动画 Prefab 与 Graph 资源 2026-07-17 20:19:56 +08:00
dingyuntian 24f475fe2f art(frame-animation): 添加帧动画角色 Prefab 与示例素材 2026-07-17 20:19:46 +08:00
dingyuntian 5236066c7b test(frame-animation): 补充共享 Sprite 与 Actor 集成测试 2026-07-17 20:19:34 +08:00
dingyuntian 447d61f941 feat(frame-animation): 编辑器支持共享 SpriteRect 与 Graph 属性面板 2026-07-17 20:19:24 +08:00
dingyuntian 7646cb4db3 feat(scene-mgmt): 添加 FrameAnimation 角色类型与异步加载 2026-07-17 20:19:14 +08:00
dingyuntian 7c04f507db feat(frame-animation): 支持运行时 Graph 绑定与终态恢复 2026-07-17 20:19:04 +08:00
Ovid 5549404fe4 蓝眼无神+入 2026-07-17 17:21:35 +08:00
dingyuntian 667425bd2c feat: 调整日志堆栈等级 2026-07-17 14:25:55 +08:00
dingyuntian 21d51945c3 feat: GameManager相关重构 2026-07-17 14:24:02 +08:00
dingyuntian 2dcfeec938 chore: 去除部分已不再使用的UI代码 2026-07-17 14:22:33 +08:00
Ovid 94130cd97f PP2表情上传 2026-07-17 05:09:02 +08:00
dingyuntian c617e44813 Merge branch 'develop' into feature/帧动画系统 2026-07-16 23:43:54 +08:00
dingyuntian b7ca9ab11f Merge branch 'develop' into feature/demo脚本调整 2026-07-16 23:13:46 +08:00
dingyuntian 847feed926 Merge branch 'develop' into feature/demo脚本调整 2026-07-16 23:13:31 +08:00
dingyuntian e20b499492 Merge branch 'bugfix/预览模式问题' into 'develop'
Bugfix/预览模式问题

See merge request aibis-dream/aibis-dream!743
2026-07-16 15:12:30 +00:00
dingyuntian 5d07012cb6 fix: 预览模式问题 2026-07-16 23:11:36 +08:00
dingyuntian 00a43fcad3 docs: GameManager大重构计划,需要重新审视 2026-07-16 23:07:24 +08:00
dingyuntian e363c6e4a5 Merge branch 'bugfix/合并问题修复' into 'develop'
fix: 冲突合并问题,修复线缆相关

See merge request aibis-dream/aibis-dream!742
2026-07-16 15:06:22 +00:00
dingyuntian 1a113dbd25 fix: 冲突合并问题,修复线缆相关 2026-07-16 23:05:46 +08:00
dingyuntian 3959561686 Merge branch 'develop' into feature/帧动画系统 2026-07-16 20:56:53 +08:00
dingyuntian 031ac79b8d Merge branch 'feature/demo脚本调整' of ssh://114.67.212.218:2222/aibis-dream/aibis-dream into feature/demo脚本调整 2026-07-16 20:24:20 +08:00
Ovid 0db3f75476 D2S结尾调整 2026-07-16 19:39:24 +08:00
dingyuntian f3e1e421a3 Merge branch 'develop' into feature/demo脚本调整 2026-07-16 19:18:06 +08:00
dingyuntian 23b6d9cf87 Merge branch '7.11春优化' into 'develop'
fix(heatmap): 优化温度过渡并修复热点尺寸

See merge request aibis-dream/aibis-dream!741
2026-07-16 11:17:04 +00:00
bottlefish 509e0ae169 Merge branch 'develop' into '7.11春优化'
# Conflicts:
#   Assets/Scripts/FixSystemNew/Screen System/FixPanel/CablePanel.cs
2026-07-16 11:11:07 +00:00
dingyuntian c3c0398905 Merge branch 'bugfix/插孔打开时机问题' into 'develop'
fix: 插孔打开时机

See merge request aibis-dream/aibis-dream!740
2026-07-16 11:09:39 +00:00
bottlefish fd79ff9d93 feat(fix-system): 优化线缆松手下坠与插头朝向 2026-07-16 19:09:25 +08:00
bottlefish 8b1fe28edb fix(fix-system): 移除线缆拖拽的插孔自动吸附 2026-07-16 19:08:31 +08:00
dingyuntian b4f0cebc1d fix: 插孔打开时机 2026-07-16 18:50:00 +08:00
dingyuntian 46602803dc feat: 动画系统第五阶段(未验收) 2026-07-16 18:49:12 +08:00
bottlefish cb1ddc1f93 Merge branch 'develop' into 7.11春优化 2026-07-16 18:42:23 +08:00
bottlefish 6c75ceaa35 docs(save): 补充严格开发跳转使用说明 2026-07-16 18:41:20 +08:00
bottlefish cfb4ff96c4 fix(ui): 修复场景卸载后透明遮罩阻塞交互 2026-07-16 18:40:53 +08:00
bottlefish 7dfd87e868 test(save): 添加开发存档清单校验测试 2026-07-16 18:40:14 +08:00
bottlefish 8deff54e48 feat(save): 增强开发存档跳转与严格校验 2026-07-16 18:39:50 +08:00
bottlefish 7b287a6248 test(save): 更新开发跳转测试存档与清单 2026-07-16 18:37:14 +08:00
dingyuntian 9b6d008263 Merge branch 'develop' into feature/帧动画系统 2026-07-16 18:06:01 +08:00
dingyuntian 02d3d80f46 Merge branch 'feature/转场功能更新' into 'develop'
feat: 添加全局屏幕转场面板

See merge request aibis-dream/aibis-dream!739
2026-07-16 09:58:38 +00:00
dingyuntian 3eea7e9ca6 docs(dialog): 添加 Yarn 全局转场指令表
- 列出 fade_in/out、fade_in_white、flash_white、
  wipe_in/out、shutter_in/out 等指令的用法与示例
2026-07-16 17:57:06 +08:00
dingyuntian 56045b4bcd fix(fade-kit): 修复 FadeKit 空引用和零时长异常
- 增加 Image 空引用保护
- 处理 duration 小于等于 0 的边界情况
- 动画开始前调用 DOKill 避免冲突
- 使用 DOFade 替代 DOBlendableColor,行为更明确
2026-07-16 17:56:59 +08:00
dingyuntian f51a670239 refactor: 将淡入淡出迁移到 ScreenTransitionPanel
- 将 BaseYarnCommand、FixSystemNew 各状态、GameManager、
  SaveSystem、PlayToolPanel 中的 PlayToolPanel 遮罩调用
  统一迁移到 ScreenTransitionPanel
- 从 PlayToolPanel 移除 _fadeImage 及相关 fade 方法
- 从 Play Tool Panel Prefab 移除 Fade Image 子对象
2026-07-16 17:56:51 +08:00
dingyuntian 4100232162 feat: 添加全局屏幕转场面板
- 新增 ScreenTransitionPanel,支持 fade_in/out、fade_in_white、flash_white、
  wipe_in/out、shutter_in/out 等多种转场效果
- 新增 Screen Transition Panel Prefab 并在 Persistence 场景挂载
- UIManager 启动时自动显示该面板
- BaseYarnCommand 新增对应的 Yarn 全局转场指令
2026-07-16 17:56:33 +08:00
bottlefish 51fb062225 build(config): 更新项目构建版本 2026-07-16 17:55:05 +08:00
bottlefish e0318026d7 scene(ui): 调整持久场景终端界面配置 2026-07-16 17:54:50 +08:00
bottlefish cc354cb37d scene(scene-flow): 更新剧情场景流转配置 2026-07-16 17:54:33 +08:00
bottlefish eaba7ea5ab fix(dialog): 修正时段初始化与角色气泡 2026-07-16 17:54:06 +08:00
bottlefish 5ab8498be5 art: 更新医生与英里角色资源 2026-07-16 17:53:51 +08:00
bottlefish fa79a4bed1 art(bar): 更新调酒妹动画与状态机 2026-07-16 17:53:31 +08:00
bottlefish f0a92553fb feat(kite): 完善风筝回合动态表现 2026-07-16 17:53:08 +08:00
bottlefish 85de341cd2 fix(ui): 修复梦境终端输入法交互 2026-07-16 17:52:49 +08:00
bottlefish ea95111f2a feat(dream): 完善梦境门与星空聚焦演出 2026-07-16 17:52:34 +08:00
bottlefish 0039beb5f9 art(huoshan): 调整火山角色表情动画 2026-07-16 17:51:23 +08:00
bottlefish 96164e1887 feat(huoshan): 重构笑话发生器像素噪点表现 2026-07-16 17:51:01 +08:00
bottlefish 754d03b47d feat(fix-system): 优化损坏模块火花表现 2026-07-16 17:50:48 +08:00
bottlefish 3be0bf8201 feat(fix-system): 重构序章维修流程与面板启动表现 2026-07-16 17:50:35 +08:00
Ovid 60200afe9b debug:云的图片名引用错误 2026-07-16 02:15:57 +08:00
Ovid 69315f86f9 添加枯树大图 2026-07-15 22:06:02 +08:00
dingyuntian 8c795f4474 Merge branch 'develop' into feature/新动画系统 2026-07-15 20:21:37 +08:00
bottlefish 8c37a0bd09 Merge remote-tracking branch 'origin/feature/demo脚本调整' into 7.11春优化 2026-07-15 19:55:59 +08:00
bottlefish e9e21920f1 scene: 调整持久化场景默认对象状态 2026-07-15 19:42:30 +08:00
bottlefish 5c6cc5c4a6 scene(fix-system): 同步维修场景与温控器配置 2026-07-15 19:42:00 +08:00
bottlefish 61e3f52a38 fix(dialog): 修正夜间剧情环境初始化 2026-07-15 19:41:16 +08:00
bottlefish 5639bad84a yarn(peipei): 清理失效的模块高亮指令 2026-07-15 19:40:44 +08:00
bottlefish 3d86790bd7 yarn(huoshan): 清理模块高亮并修正阶段跳转 2026-07-15 19:40:17 +08:00
bottlefish fd77b5c248 feat(effects): 优化故障噪声与场景后处理表现 2026-07-15 19:39:43 +08:00
bottlefish fa66dbc7c9 feat(kite): 优化风筝镜头追赶与双捕捉表现 2026-07-15 19:39:12 +08:00
bottlefish 92087b81ef feat(dream): 添加梦境数据溢出与虚焦表现 2026-07-15 19:38:42 +08:00
bottlefish 1f931e987f fix(huoshan): 优化语言粒子布局与状态重置 2026-07-15 19:37:37 +08:00
bottlefish 7ace1f255a fix(fix-system): 修复维修场景鼠标视差偏移 2026-07-15 19:36:58 +08:00
bottlefish 21321074bf fix(fix-system): 优化温控器风扇停止流程 2026-07-15 19:36:24 +08:00
bottlefish 89850c75f3 feat(fix-system): 优化线缆与警报灯表现 2026-07-15 19:35:47 +08:00
bottlefish d075d1e0f8 feat(fix-system): 添加维修模块运行状态表现 2026-07-15 19:34:50 +08:00
bottlefish 161fe6ac7d test(dev-save): 支持从测试归档恢复阶段存档 2026-07-15 19:27:53 +08:00
bottlefish c38c2f75be fix(save): 修复跨章节存档恢复校验 2026-07-15 19:27:18 +08:00
Ovid 52573095f7 Merge branch 'develop' into feature/demo脚本调整 2026-07-15 17:40:59 +08:00
Ovid ac6ded9f66 脚本调整上传 2026-07-15 17:40:29 +08:00
dingyuntian 8a26091590 Merge branch 'feature/录制模式相关改动' into 'develop'
fix(dialog): 修复点击 UI 时对话被误推进

See merge request aibis-dream/aibis-dream!738
2026-07-15 08:53:25 +00:00
dingyuntian 06d85d6460 refactor(ui): 移除 InfoPanel 的 I 键变量栏切换
删除 Update 中通过 I 键切换 variableBar 显隐的调试代码,
避免与现有输入冲突并保持面板职责单一。
2026-07-15 16:52:08 +08:00
dingyuntian 91ffbeb130 feat(ui): 添加录制工具面板
新增 RecordingToolPanel,按 Ctrl+R 唤出侧边栏,可独立控制光标、
版本号、Top Bar 与对话视觉的显隐。移除旧的
UIEventEnum.RecordingModeChanged 事件,改为各子系统提供显式
SetVisible API。在 Persistence 场景中配置面板层级与四个开关。
2026-07-15 16:51:55 +08:00
dingyuntian b14599d18c fix(dialog): 修复点击 UI 时对话被误推进
在 LineAdvanceInput 中通过 UIManager.IsPointerOverUI 判断
鼠标是否位于主 UI Canvas 的射线接收元素上,避免点击录制工具
等 UI 时推进对话。
2026-07-15 16:51:11 +08:00
dingyuntian 53a4fc86c4 feat: 设置修改,方便跑单元测试 2026-07-15 15:14:55 +08:00
dingyuntian 079d57e295 chore: 动画系统原型 2026-07-15 15:14:32 +08:00
dingyuntian 5f8ffb7f78 docs: 动画系统文档 2026-07-15 15:14:18 +08:00
dingyuntian 85a3fc9e14 feat(frameAnima): 动画系统前四个阶段 2026-07-15 15:13:56 +08:00
bottlefishandCursor ddb3aba4e5 art: 更新开盖动画资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:04:19 +08:00
bottlefishandCursor 1ee2a949e9 art(clinicout): 更新外出医生动画
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:02:38 +08:00
bottlefishandCursor dd66a52d65 scene: 将 Persistence 开场对话切到 Day2_FIRST_HS1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:00:48 +08:00
bottlefishandCursor 59bffeab06 test(dev-save): 重建 Huoshan1 阶段存档目录与 catalog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:58:01 +08:00
bottlefishandCursor 423a3954dd feat(dev-save): 强化 Huoshan 阶段存档提升与跳转校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:57:21 +08:00
bottlefishandCursor 4ab2f83395 yarn(huoshan): 用阶段就绪节点替换中段存档点
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:55:33 +08:00
Ovid de85ca2944 录屏版本提交 2026-07-14 13:45:37 +08:00
bottlefishandCursor 9f6c926dcc test(dev-save): 按叙事顺序更新 Huoshan1 测试存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:55 +08:00
bottlefishandCursor d101864397 yarn(huoshan): 规范节点标签并在 Stage 入口补存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:43 +08:00
bottlefishandCursor 1cc5ac32aa scene(huoshan): 同步旋钮三态与发条触发角
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:27 +08:00
bottlefishandCursor 431401c52f fix(huoshan): 修复发条终点判定与越界拖拽
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:12 +08:00
bottlefishandCursor 0ed1253a40 feat(huoshan): 旋钮改为三态切图外观
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:58 +08:00
bottlefishandCursor 4f59e41b03 feat(subway): 添加车厢加速度驱动与扶手摆动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:43 +08:00
bottlefishandCursor df6f8c6ec4 yarn: 用 Timeline 替换英里登场与天桥 CG 静图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:15 +08:00
bottlefishandCursor d00edd7bb8 scene: 登记叙事 Timeline 并挂入场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:59 +08:00
bottlefishandCursor 1be7b12deb art: 添加英里初登场与天桥夕阳 Timeline 资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:24 +08:00
bottlefishandCursor 51b8d86603 fix(dialog): 兼容 load_scene 多余淡入时长参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:10 +08:00
bottlefishandCursor c4dbdc098b feat(timeline): 添加 wait_timeline 以支持黑屏起播
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:52:54 +08:00
dingyuntian 19b6b9c4e2 fix: 章节编辑器问题修复 2026-07-13 22:24:39 +08:00
dingyuntian 92e8cd2f13 docs: 动画文档 2026-07-13 22:24:16 +08:00
bottlefishandCursor 26cfa5d886 test(dev-save): 添加 Day2Sleep 测试存档与晋升脚本
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:57:03 +08:00
bottlefishandCursor b98214632e feat(dream): 实现风筝镜头补偿与虚焦驱动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:51 +08:00
bottlefishandCursor 2c74ec2030 feat(tarot): 优化摊牌缩放与逆位动画节奏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:33 +08:00
bottlefishandCursor 71bfad4dae scene(huoshan): 同步表达与销售模块场景参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:23 +08:00
bottlefishandCursor c601dda9de fix(huoshan): 调整销售滑片角度并完善发条调试可视化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:14 +08:00
bottlefishandCursor 1422161844 feat(huoshan): 增强表达模块干扰表现与完成释放效果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:00 +08:00
Ovid a494cf99cf D1S梦境调整 2026-07-13 19:42:33 +08:00
bottlefishandCursor ee1d100fb5 chore(render): 启用 ShapesRenderFeature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:25:23 +08:00
bottlefishandCursor d0a08986c6 yarn(huoshan): 调整销售模块文案并补充对话节拍
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:54 +08:00
bottlefishandCursor 0d04d1a68d scene(huoshan): 同步发条与平静场场景参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:29 +08:00
bottlefishandCursor 52e0e9002f fix(huoshan): 优化发条完成判定并防止销售轮次重复触发
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:05 +08:00
bottlefishandCursor 4b3260dfcd feat(huoshan): 为聚焦文本添加 Glitch 平静场
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:23:41 +08:00
bottlefishandCursor 6689effb87 feat(ui): 为旋钮交互添加旋转光标状态
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:22:17 +08:00
bottlefishandCursor b062ab4ddc feat(dev-save): 用通用跳转工具替换佩佩存档跳转
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:21:51 +08:00
bottlefishandCursor 4104de4166 scene(persistence): 调整启动对话与单行框状态
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:41:17 +08:00
bottlefishandCursor 425cd1e695 chore(tmp): 补充 Fallback 动态字形数据
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:59 +08:00
bottlefishandCursor a48a6bf6ad scene(peipei): 同步后处理材质并调整场景配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:45 +08:00
bottlefishandCursor 98c558e800 feat(render): 配置 VolFx Ascii 后处理通道
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:30 +08:00
bottlefishandCursor cd00a524bb fix(heatmap): 优化温度过渡并修复热点尺寸
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:10 +08:00
dingyuntian 6d12bceb36 yarn: Yarn冲突处理 2026-07-11 16:25:58 +08:00
dingyuntian 52d7656c30 docs: 动画文档未完成版 2026-07-11 16:17:09 +08:00
dingyuntian 2a8d82fd2e art(ui): 更新字体 SDF 资源
- 更新 ChillBitmap 16px 与 WenQuanYi Bitmap Song 14px 的 SDF 图集
2026-07-08 15:21:33 +08:00
dingyuntian 558706ee08 scene(render): 清理 Renderer2Dtest 中 VolFx 特性
- 移除 VolFx Renderer Feature 及相关引用
2026-07-08 15:21:27 +08:00
dingyuntian 65d895798f scene(camera): 添加 Dream Camera 镜头混合规则
- 新增从任意相机切换到 Dream Camera 的混合配置
2026-07-08 15:21:23 +08:00
dingyuntian a305e5ef42 scene(open-fix): 调整 OpenFixScene 相机与对象位置
- 降低虚拟相机优先级
- 微调 Prefab 本地坐标与路径点位置
2026-07-08 15:21:18 +08:00
dingyuntian 0babb520de yarn(dialog): 修正火山 Stage2 语言标签与运算符格式
- 将 baseLanguage 从 zh-CHS 修正为 zh-Hans
- 补全 $TaskToDo 减法运算符两侧空格
2026-07-08 15:21:13 +08:00
dingyuntian 0265de117a docs(log-kit): 添加日志系统改进留档
- 记录背景、已完成内容、文件格式、平台默认配置
- 说明 GameLog 用法与后续待补足项
2026-07-08 15:21:05 +08:00
dingyuntian 11d88c146c feat(log-kit): 重构文件日志系统支持异步结构化日志
- 使用 ConcurrentQueue + 后台线程批量写入
- 新增 LogLevel、LogCategory、LogEntry、LogFormatter
- 新增 GameLog 业务日志入口
- 新增 LogSettingsProfiles 平台化配置
- 支持按 session 分文件、大小轮转与队列满时低优先级丢弃
2026-07-08 15:20:53 +08:00
1261 changed files with 155363 additions and 41807 deletions
@@ -0,0 +1,94 @@
---
name: narrative-timeline-flow
description: >-
叙事 Timelineaseprite/静图改 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:选主演出 clipLoop 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
View File
@@ -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: {}
+123
View File
@@ -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,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,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,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,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,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,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,5 +1,5 @@
fileFormatVersion: 2
guid: ed8d66d5054fd594b8a86bb4c9d8685f
guid: 5ad5208284c226e4297926dd64539442
folderAsset: yes
DefaultImporter:
externalObjects: {}
Binary file not shown.
@@ -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:
-149
View File
@@ -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
}
}
+75
View File
@@ -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&amp;guid=ec4ca5fe3cf523d4a88253be45a8f5c1&amp;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}");
}
}
}
}
+1 -1
View File
@@ -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:
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More