Compare commits

...
Author SHA1 Message Date
dingyuntian 592d3a36dd art: 音量条 2026-07-19 17:23:55 +08: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
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 bd3988424f Merge branch 'feature/录制模式' into 'develop'
feat(record): 增加录制模式,按ctrl+R

See merge request aibis-dream/aibis-dream!737
2026-07-11 07:29:43 +00:00
dingyuntian 49df75279d Merge branch '风筝交互优化' into 'develop'
art: 添加风筝分层底图与风筝层素材

See merge request aibis-dream/aibis-dream!736
2026-07-11 05:18:01 +00:00
dingyuntian 2a43d0af81 Merge branch '7.3春优化' into 'develop'
fix(camera): 场景卸载后重置机位枚举避免读档跳过切换

See merge request aibis-dream/aibis-dream!735
2026-07-11 05:17:39 +00:00
bottlefishandCursor 66672f5188 docs: 补充风筝命令使用指南
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 12:32:17 +08:00
bottlefishandCursor 0590e755ee yarn(dialog): 调整 D2S 风筝乱度与高潮表现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 12:31:55 +08:00
bottlefishandCursor 467dbfdf84 feat(scene-mgmt): 优化风筝与轱辘交互动效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 12:31:30 +08:00
bottlefish bc14705fb3 yarn(dialog): 优化 D2S 风筝失控表现 2026-07-11 01:08:38 +08:00
bottlefish 6e939d2a72 feat(scene-mgmt): 添加风筝连续表现指令 2026-07-11 01:07:54 +08:00
bottlefishandCursor 2792d8fafa fix(ui): 修复单行对话框未激活时空引用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:33:28 +08:00
bottlefishandCursor 024eecb404 yarn: 风筝段落接入精灵动效命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:33:17 +08:00
bottlefishandCursor 8d1f072d55 feat(dialog): 添加精灵动效 Yarn 命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:33:05 +08:00
bottlefishandCursor 18d6d970f9 feat(scene-mgmt): 实现风筝精灵分层动效系统
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:32:52 +08:00
bottlefishandCursor a1bfa4c8b8 art: 添加风筝分层底图与风筝层素材
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 22:32:38 +08:00
dingyuntian ea6b75d73d feat(record): 增加录制模式,按ctrl+R 2026-07-10 18:58:47 +08:00
bottlefish 4eb9aacc47 Merge branch 'develop' into 7.3春优化 2026-07-09 15:30:40 +08:00
dingyuntian 892912e707 Merge branch 'bugfix/火山修复' into 'develop'
fix: 火山修复

See merge request aibis-dream/aibis-dream!734
2026-07-09 07:16:55 +00:00
dingyuntian 3fefa18c65 fix: 火山修复 2026-07-09 15:16:17 +08:00
bottlefish 233cedd528 Merge branch 'develop' into 7.3春优化 2026-07-09 14:19:28 +08:00
bottlefishandCursor b0ece3e0f1 docs(fixsystem): 补充 PhysicCable 实现说明与渲染方案对比
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:18:47 +08:00
bottlefishandCursor 8a7b2d0ed2 feat(cable): 优化插线手感与线缆表现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:04:48 +08:00
bottlefishandCursor c905eb4625 scene(peipei): 同步佩佩维修场景线缆参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:54:54 +08:00
bottlefishandCursor 4a66ba766e fix(cable): 固定插头与线缆排序并移除拖拽切层
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:54:45 +08:00
bottlefishandCursor 4ded2b8eab feat(cable): 线缆改用 Mesh 渲染并优化插接物理
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:54:36 +08:00
bottlefishandCursor f6997e8816 feat(cable): 新增单 Mesh 线缆渲染器
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:54:28 +08:00
bottlefishandCursor 6a5e8eecc9 docs(fix-system): 添加线缆物理移植分析文档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 12:54:16 +08:00
bottlefish d0f4a1cf09 art(prototype): 添加线缆原型截图素材 2026-07-09 00:52:01 +08:00
bottlefish e1f5484e02 feat(prototype): 更新线缆物理交互原型 2026-07-09 00:51:45 +08:00
bottlefish 2e7c87c886 fix(fix-system): 优化线缆物理拖拽和像素风渲染 2026-07-09 00:51:34 +08:00
bottlefish f273569e7c feat(cable): 线缆优化 2026-07-08 22:31:21 +08:00
bottlefishandCursor 68837581af chore(prototype): 添加线缆切线锁定 HTML 原型
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 20:46:14 +08:00
bottlefishandCursor d8056155c1 chore(prototype): 添加 2D 线缆物理交互 HTML 原型
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 20:45:58 +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
bottlefishandCursor b5aab4edc1 scene(peipei): 同步佩佩维修场景线缆布局
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:21:56 +08:00
bottlefishandCursor 3092c3554b feat(fix-system): 优化线缆渲染与拖拽排序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:21:29 +08:00
bottlefishandCursor 7d16bfe7bc test(fix-system): 扩展佩佩开发档读档跳转菜单
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:22:22 +08:00
bottlefishandCursor d9c8e85f2b fix(save): 读档前恢复 timeScale 并用 unscaledTime 淡入
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:21:52 +08:00
bottlefishandCursor b612a0763d fix(camera): 场景卸载后重置机位枚举避免读档跳过切换
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 20:21:46 +08:00
bottlefish c7c1571feb test(fix-system): 添加佩佩记忆跳转测试菜单 2026-07-06 14:50:03 +08:00
bottlefish 6469a6b746 fix(fix-system): 优化记忆调节接近目标视觉反馈 2026-07-06 14:49:42 +08:00
dingyuntian 1fbac2c182 Merge branch 'feature/主界面UI完善' into 'develop'
feat(editor): 添加 Yarn 本地化校验工具

See merge request aibis-dream/aibis-dream!733
2026-07-04 17:28:30 +00:00
dingyuntian ff29142761 chore: 版本号更新 2026-07-05 01:27:40 +08:00
dingyuntian 88dd2c95e8 fix(ui): 修复记录界面滚动问题 2026-07-05 01:27:08 +08:00
dingyuntian f87dcc687f feat(ui): 添加终端章节面板标题本地化 2026-07-05 00:16:34 +08:00
dingyuntian 84764d7b6b scene(ui): 清理 Persistence 场景废弃 Loading UI 2026-07-05 00:16:16 +08:00
dingyuntian 204b2c43fb fix(ui): 修复终端主菜单启动阶段音频通知时机 2026-07-05 00:12:20 +08:00
dingyuntian c5c8729704 fix(fix-system): 修复修复系统气泡加载前相机切换问题 2026-07-05 00:11:46 +08:00
dingyuntian c4ed431753 Merge branch 'develop' into feature/主界面UI完善 2026-07-04 23:24:09 +08:00
dingyuntian 2ff1df2331 Merge branch 'feature/mainMenuSFX' into 'develop'
Feature/main menu sfx

See merge request aibis-dream/aibis-dream!732
2026-07-04 15:23:33 +00:00
Ovid 36a1d783fb 拉太大了回来点 2026-07-04 23:22:44 +08:00
Ovid 9a31c71900 拉高hover音量 2026-07-04 23:21:57 +08:00
dingyuntian 5033923042 Merge branch 'develop' into feature/主界面UI完善 2026-07-04 23:14:13 +08:00
dingyuntian 5033bf0a3d feat(main-ui): 完善主界面场景与终端设置面板配置
- Persistence 场景绑定 TVOnSignal 并调整倒影 GameObject 参数
- 按任意键开始 Timeline 新增 Signal Track 与 TV 开关机动画关键帧
- 新增 TVOnSignal.signal 资源
- TerminalSettingPanelMini 文本添加本地化组件
2026-07-04 23:13:27 +08:00
dingyuntian c75cdc4bdf art(main-ui): 更新主界面电视界面与倒影动画资源
- 新增电视屏幕开关机动画与控制器
- 更新倒影 loop、环境 loop、海浪控制器以适配新资源
- 替换开始电视界面 aseprite 及导出配置
- 删除旧的开始界面倒影控制器与电视倒影资源
2026-07-04 23:13:13 +08:00
dingyuntian c6539c37b9 yarn(FP): 修复 FP Day1 翻译文本与缺失行
- FP_Day1_night 修复首行斜体闭合标签并同步 line id
- FP_Day1_sleep 英日 CSV 新增 Look inside 选项翻译
2026-07-04 23:12:58 +08:00
dingyuntian e015b756f6 feat(audio-kit): 终端 UI 菜单音效与暂停菜单环境音
- 新增 TerminalUIAudio 事件通知与 TerminalUIAudioBehaviour 组件
- TerminalButton/TerminalMenuItemView/SelectItem/SliderItem 接入悬停与点击音效
- AudioManager 监听 TerminalUIEvent 播放 menu_hover/menu_click
- 新增 PauseMenu 环境音状态及恢复逻辑
- 新增 MainMenuStage FMOD 参数控制
- BaseButton 调用基类 DoStateTransition 保证状态事件正确触发
- 为 TerminalChapterPanel Prefab 添加 TerminalUIAudioBehaviour
2026-07-04 23:12:47 +08:00
dingyuntian 04536c4b81 fix(editor): 修复 Aseprite JSON 尾随逗号解析问题
- 移除 AsepriteMeta 中未使用的 layers/slices 字段
- 新增 SanitizeJsonForUnity 正则清理尾随逗号
- 修复 Aseprite 导出 JSON 偶发尾随逗号导致 JsonUtility 解析失败
2026-07-04 23:12:30 +08:00
dingyuntian 7d7c8893fc feat(editor): 添加 Yarn 本地化校验工具
- 新增 Editor 窗口:AIBIS → Yarn 本地化校验
- 新增 BatchMode CLI:Tools/validate_yarn_l10n.py
- 新增文档:Docs/本地化校验工具.md
- 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
2026-07-04 23:12:21 +08:00
dingyuntian d3c6880ce6 Merge branch 'feature/mainMenuSFX' into 'develop'
Feature/main menu sfx

See merge request aibis-dream/aibis-dream!731
2026-07-04 15:06:22 +00:00
dingyuntian 82603c304d Merge branch 'feature/PP1S1本地化补充' into 'develop'
自己翻译了一句。善!

See merge request aibis-dream/aibis-dream!730
2026-07-04 15:05:43 +00:00
Ovid b89a674a28 mainMenu & pauseMenu SFX 2026-07-04 23:00:28 +08:00
Ovid d5c946d322 自己翻译了一句。善! 2026-07-04 21:20:04 +08:00
dingyuntian 575ebebfaf feat(build): 安卓打包版本号自增 2026-07-03 23:31:27 +08:00
dingyuntian aea6483849 feat(UI): 首页UI更新 2026-07-03 23:30:38 +08:00
dingyuntian 1b33124ec1 Merge branch 'feature/mus_0702' into 'develop'
去除临时文本

See merge request aibis-dream/aibis-dream!729
2026-07-03 07:12:31 +00:00
Ovid 76302f53c8 去除临时文本 2026-07-03 15:11:25 +08:00
dingyuntian 969bc2756c Merge branch 'bugfix/72Tap打包' into 'develop'
fix(tap): 安卓端打包

See merge request aibis-dream/aibis-dream!728
2026-07-02 13:23:15 +00:00
dingyuntian 599bb44763 fix(tap): 安卓端打包 2026-07-02 21:22:48 +08:00
dingyuntian 78ac7da055 Merge branch 'feature/mus_0702' into 'develop'
Feature/mus 0702

See merge request aibis-dream/aibis-dream!727
2026-07-02 11:40:32 +00:00
Ovid e1240f023d sfxdebug-D1S结束时海浪fade掉而不是硬关 2026-07-02 19:39:17 +08:00
dingyuntian 5933ae954d Merge branch 'feature/UI更新&安卓适配' into 'develop'
Feature/ui更新&安卓适配

See merge request aibis-dream/aibis-dream!726
2026-07-02 11:35:08 +00:00
dingyuntian e5a10ea0b7 fix(scene): 海报遮罩范围调整 2026-07-02 19:34:40 +08:00
Ovid d782a09671 textdebug 2026-07-02 19:34:32 +08:00
dingyuntian 440979264e Merge branch 'develop' into feature/UI更新&安卓适配 2026-07-02 19:32:13 +08:00
Ovid 39a9eb73bc textdebug 2026-07-02 19:32:02 +08:00
dingyuntian cb4264f26e Merge branch 'feature/mus_0702' into 'develop'
musupdate

See merge request aibis-dream/aibis-dream!724
2026-07-02 11:24:21 +00:00
dingyuntian c67fe1b32d Merge branch '7.2春优化' into 'develop'
fix(ui): 主界面加载前黑屏遮罩,就绪后再淡出

See merge request aibis-dream/aibis-dream!725
2026-07-02 11:23:50 +00:00
Ovid b8773d968e textdebug 2026-07-02 19:21:45 +08:00
dingyuntian 4776066cec build(android): 更新版本号与堆栈跟踪配置 2026-07-02 19:16:41 +08:00
dingyuntian 5305033016 fix(log-kit): 禁用 Android 真机日志写入 2026-07-02 19:16:35 +08:00
dingyuntian bd70442e45 style(fix-system): 补全 Cable 脚本文件末尾换行 2026-07-02 19:16:28 +08:00
dingyuntian a3dadd4d88 scene(peipei): 调整佩佩修复场景排序与线缆位置 2026-07-02 19:16:20 +08:00
dingyuntian 39ce23d5f3 yarn(dialog): 调整佩佩与序章节点保存标签及本地化 2026-07-02 19:16:14 +08:00
Ovid aa0f431955 textdebug 2026-07-02 19:16:06 +08:00
dingyuntian b98681acab fix(audio-kit): 修复 OpenFixScene 环境音状态 2026-07-02 19:15:52 +08:00
dingyuntian 37e9127260 feat(ui): 添加终端继续游戏入口 2026-07-02 19:15:32 +08:00
dingyuntian c5c90eb5a3 feat(localization): 支持设置语言跟随系统语言 2026-07-02 19:15:21 +08:00
dingyuntian 517a03bbc3 perf(save): 优化存档写入性能并支持关闭缩略图 2026-07-02 19:15:11 +08:00
Ovid cb6658e41a debug 2026-07-02 19:09:57 +08:00
Ovid e78d5eba23 新增EMPTYSCENE 2026-07-02 19:09:37 +08:00
bottlefishandCursor 478f431dd3 chore(ui): 移除移动端强制显示快进按钮
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:09:16 +08:00
bottlefishandCursor a1d972c074 fix(memory): 放宽调频成功判定阈值
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:09:12 +08:00
Ovid 0cf659cc20 破碎坠落时间差处理 2026-07-02 19:04:52 +08:00
bottlefishandCursor a5352ed250 chore(ui): 持久化场景启用并复位快进按钮布局
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:50 +08:00
bottlefishandCursor 18ab68f841 scene(peipei): 调整剪线特写 PPU 与场景路径坐标
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:45 +08:00
bottlefishandCursor 870f914950 fix(memory-shader): 补齐 UI Stencil 并修复 aspect 计算
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:37 +08:00
bottlefishandCursor c08021a2c8 fix(eye-shader): 采样 UV 饱和并统一 half 精度
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:32 +08:00
bottlefishandCursor 6ac7fcf9da fix(eye): 视口遮罩改用屏幕坐标并复用 Canvas 层级
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:03:26 +08:00
Ovid dfed55acbb 主界面audiomanager改到main menu 2026-07-02 18:46:02 +08:00
Ovid 982914f6e7 debug 2026-07-02 18:43:59 +08:00
Ovid 1583b96200 替换新病理分析图 2026-07-02 18:32:03 +08:00
Ovid 3432053239 D2N最后喝醉的时候糊明显一些 2026-07-02 17:59:12 +08:00
bottlefishandCursor d054a57e26 build(shader): Memory 与 Eye shader 加入 Always Included
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:56:57 +08:00
bottlefishandCursor 7884812131 fix(eye): 运行时检测 EyeUnifiedSprite 平台支持
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:56:51 +08:00
bottlefishandCursor 33be87215e fix(eye-shader): 模糊仅混 RGB 保留 alpha 并减移动端采样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:56:50 +08:00
bottlefishandCursor f3c3994f5c fix(memory): 调频 shader 不可用回退 Volume 并绑定场景引用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:56:41 +08:00
bottlefishandCursor bd94302714 fix(memory-shader): 修复调频模糊导致 alpha 变透明并优化移动端采样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:56:33 +08:00
Ovid 5031906d70 D1N路斯改成你 2026-07-02 17:50:59 +08:00
Ovid bef36bb92f ambBar提升开头音量 2026-07-02 17:49:56 +08:00
Ovid 496ba885af prologueEnd玻璃声 2026-07-02 17:43:54 +08:00
bottlefishandCursor 3c7662679c chore(ui): 移动端临时显示快进按钮
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:36:45 +08:00
bottlefishandCursor 0832e4cac1 fix(eye): 遮罩 shader 失败时跳过全屏黑屏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 17:36:39 +08:00
Ovid 668b735377 musupdate 2026-07-02 17:35:37 +08:00
bottlefishandCursor b26f7d0222 yarn(fp): 移除 Peipei1 模块高亮指令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:38:11 +08:00
bottlefishandCursor 80d32a73d8 fix(ui): 主界面加载前黑屏遮罩,就绪后再淡出
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:38:06 +08:00
bottlefish fa3954bbbb 去除测试文本 2026-07-02 16:23:44 +08:00
dingyuntian b9167056cf Merge branch 'feature/UI更新&安卓适配' into 'develop'
Feature/ui更新&安卓适配

See merge request aibis-dream/aibis-dream!723
2026-07-02 06:58:20 +00:00
dingyuntian 784e1e8f0d fix(bloom):bloom问题修复 2026-07-02 14:55:23 +08:00
dingyuntian 76d620d8c3 build(android): 更新版本号与图标 2026-07-02 14:53:09 +08:00
dingyuntian 1918c8c53b chore(config): 修改默认窗口模式为全屏 2026-07-02 14:53:07 +08:00
dingyuntian 07f1e3f5bc yarn(dialog): 更新序章与 Day1/Day2 环境音切换及节点标签 2026-07-02 14:53:05 +08:00
dingyuntian fe8b4ea3c5 chore(scene-so): 更新场景章节与出口名称配置 2026-07-02 14:53:03 +08:00
dingyuntian a1767fc6d3 scene(persistence): 调整持久化场景 UI 布局 2026-07-02 14:53:00 +08:00
dingyuntian 49a51b6ccb chore(render): 清理测试渲染器 VHS 后效 2026-07-02 14:52:58 +08:00
dingyuntian 08d09bf31d art(common): 添加通用 LOGO 资源 2026-07-02 14:52:56 +08:00
dingyuntian 4e6c6dac6e art(ui): 替换功能 UI 资源并调整终端章节卡 2026-07-02 14:52:54 +08:00
dingyuntian a603ad928f refactor(ui): 移除 BoolButton 本地化文本依赖 2026-07-02 14:52:51 +08:00
dingyuntian bde1040c9d feat(audio-kit): 添加主菜单环境音状态 2026-07-02 14:52:49 +08:00
dingyuntian fcd0955ae7 fix(fix-system): 修正线缆连接点与火花 VFX 参数 2026-07-02 14:52:44 +08:00
dingyuntian b1981431f6 Merge branch 'feature/UI更新&安卓适配' into 'develop'
Feature/ui更新&安卓适配

See merge request aibis-dream/aibis-dream!722
2026-07-02 02:54:34 +00:00
dingyuntian 7c15663bf2 Merge branch 'develop' into feature/UI更新&安卓适配 2026-07-02 10:49:12 +08:00
dingyuntian 9f5ea54813 fix(audio): 修正 FMOD Android 平台导入设置 2026-07-02 02:45:41 +08:00
dingyuntian 63d755ee05 build(android): 配置 Android 构建模板、包名与密钥库 2026-07-02 02:45:30 +08:00
dingyuntian 29267659b9 fix(android): 移动端隐藏自定义光标 2026-07-02 02:45:20 +08:00
dingyuntian c912ac89c5 chore(ui): 删除终端旧版构建工具与Timeline 2026-07-02 02:45:10 +08:00
dingyuntian b2174985ed feat(localization): 添加终端界面本地化文本 2026-07-02 02:44:55 +08:00
dingyuntian 5e64ab4c30 scene(ui): 配置终端面板场景实例 2026-07-02 02:44:37 +08:00
dingyuntian cb557c14cf art(ui): 更新终端界面美术资源与预制体 2026-07-02 02:44:28 +08:00
dingyuntian 3e2d1e931a feat(ui): 重构终端面板,拆分主菜单与局内终端 2026-07-02 02:44:02 +08:00
dingyuntian 742125c112 Merge branch 'feature/全文捉虫' into 'develop'
Feature/全文捉虫

See merge request aibis-dream/aibis-dream!721
2026-07-01 18:41:32 +00:00
Ovid a8fcb42f85 Merge branch 'develop' into feature/全文捉虫 2026-07-02 02:40:42 +08:00
Ovid 7ddada9726 musicdebug 2026-07-02 02:38:39 +08:00
Ovid b89fb35559 music debug 2026-07-02 02:37:25 +08:00
Ovid f726472d8a text debug,接音乐 2026-07-02 02:09:15 +08:00
dingyuntian 2ef0cff2f1 Merge branch '7.1春细节优化' into 'develop'
fix(eye): 修复视口遮罩不可见并避免遮挡系统 UI

See merge request aibis-dream/aibis-dream!720
2026-07-01 14:04:29 +00:00
bottlefishandCursor 58068d1a28 art(peipei): 更新佩佩视角屋子导出资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:13:53 +08:00
bottlefishandCursor 2f1bd5707e yarn(eye): 移除重复聚焦与上色命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:13:42 +08:00
bottlefishandCursor af3c39dcb3 fix(eye): 修正视觉聚焦上色流程与 indicator 色值
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:13:07 +08:00
bottlefishandCursor d6e7afe4b3 fix(memory): 增强记忆调谐清晰度与扭曲表现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 21:12:51 +08:00
dingyuntian 73e6b86579 fix(android): 安卓兼容 2026-07-01 19:48:33 +08:00
bottlefish df70089ecc 替换塔罗素材 2026-07-01 19:40:09 +08:00
Ovid 79a4976d42 Merge branch 'develop' into feature/全文捉虫 2026-07-01 19:16:17 +08:00
dingyuntian 107a2411e3 Merge branch 'feature/UI更新' into 'develop'
fix(scene-mgmt): 修复场景加载后立即开对话的时序问题

See merge request aibis-dream/aibis-dream!719
2026-07-01 11:14:35 +00:00
dingyuntian 41e02d48d8 fix(chapter): 章节调整 2026-07-01 19:14:15 +08:00
dingyuntian 3b96003c03 Merge branch 'develop' into feature/UI更新 2026-07-01 19:09:45 +08:00
dingyuntianandCursor f4ee41c522 scene(ui): 更新 Terminal UI 预制体与 Persistence 场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:03:38 +08:00
dingyuntianandCursor 024202b19e feat(ui): 添加 TerminalPanel 统一终端 UI 导航并移除旧面板
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:03:14 +08:00
dingyuntianandCursor 4deb565882 refactor(ui): 重构 SelectItem 与 SliderItem 适配 Terminal 设置页
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 19:02:49 +08:00
bottlefishandCursor e6dfab3b51 scene(peipei): 同步视觉模块眨眼演出参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:28:42 +08:00
bottlefishandCursor 9124a4bd6b feat(eye): 移动结束后先眨眼再触发聚焦或想象效果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:28:36 +08:00
bottlefishandCursor aaf248a0ef feat(eye): 添加聚焦/想象演出眨眼调参项
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:28:31 +08:00
bottlefishandCursor 7177039f71 feat(eye): 视口遮罩演出眨眼序列与自动眨眼冷却重置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:28:24 +08:00
Ovid db73e13bcc Merge branch 'develop' into feature/全文捉虫 2026-07-01 18:04:58 +08:00
Ovid 8ddad4fcfc 整体串通 2026-07-01 18:04:48 +08:00
bottlefishandCursor 8f6616920c docs(agent): 添加 AgentBridge 自动化计划草案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:44:37 +08:00
bottlefishandCursor 660f8e24ee fix(eye): 修复视口遮罩不可见并避免遮挡系统 UI
- 将 overlay Canvas 设到 UI 层以匹配 UI Camera culling mask
- sortingOrder 设为 0,位于 UI Canvas(1) 与 Dialog(3) 之下
- 修正 EnterModule 与 Start 时序,避免模块激活后被误关

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 17:39:24 +08:00
bottlefish 929e6f0086 清理佩佩场景 2026-07-01 17:04:24 +08:00
dingyuntian 603dc9dad3 chore(plugin): 删除没有用到的UI插件 2026-07-01 14:27:37 +08:00
dingyuntianandCursor a05d5d476c feat(ui): 添加 Terminal 风格主界面 UI 系统
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 12:06:11 +08:00
dingyuntianandCursor da436479db fix(scene-mgmt): 修复场景加载后立即开对话的时序问题
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 12:05:46 +08:00
dingyuntian 93a5076369 feat(open): 气泡和相机设置迁移 2026-07-01 12:01:56 +08:00
dingyuntian e50e2f1cd5 art(UI): 初始界面素材 2026-07-01 12:00:48 +08:00
dingyuntian cf016ecc93 fix(font): 字体集缺字 2026-07-01 11:56:23 +08:00
dingyuntian 39cf031761 Merge branch '佩佩记忆部分优化' into 'develop'
feat(peipei-eye): 新增 EyeUnifiedSprite 着色器与材质

See merge request aibis-dream/aibis-dream!718
2026-06-30 13:23:23 +00:00
bottlefishandCursor 6d6184e2de scene(peipei): 同步 EyeViewportOverlay sortingOrder
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 21:21:00 +08:00
bottlefishandCursor 9867c84ccf fix(eye): 视觉模块 overlay 不遮挡系统 UI 与对话框
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 21:20:54 +08:00
bottlefishandCursor e4d7b03a89 scene(peipei): 同步 GameContent 资源迁入后的 PeipeiFixScene 引用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 21:04:20 +08:00
bottlefishandCursor e5306f2b82 art(peipei): 佩佩维修关美术迁入 Scene_PeipeiFix/Art
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:55:35 +08:00
bottlefishandCursor 46cdbd3dd2 art(peipei): UFSearchMemory 材质迁入 Scene_PeipeiFix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:55:26 +08:00
bottlefishandCursor 58488547ce refactor(peipei): Eye 材质迁入 Scene_PeipeiFix/Material/Eye
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:55:20 +08:00
bottlefishandCursor 6f8e306fb6 art(memory): maskTexture 迁入 Feature_Memory/Render
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:55:12 +08:00
bottlefishandCursor 4fa3aac335 art(memory): overlay 笔刷迁入 Feature_Memory/Art/Overlay
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:55:04 +08:00
bottlefishandCursor 568e6ff867 refactor(memory): 记忆材质迁入 Feature_Memory/Material
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:54:56 +08:00
bottlefishandCursor eefce92f58 scene(peipei): 配置记忆 overlay 与 MemoryProcess 引用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:50 +08:00
bottlefishandCursor d33d4b9dc4 perf(memory): 调整记忆高斯模糊卷积核
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:44 +08:00
bottlefishandCursor c761e9afd7 refactor(memory): 精简 MemoryForFade 材质属性
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:36 +08:00
bottlefishandCursor e884f4fb42 art(memory): 新增 MemoryOverlay 材质
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:31 +08:00
bottlefishandCursor f35edeabe8 feat(memory): 搜索 overlay 遮罩与调频目标采样优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:26 +08:00
bottlefishandCursor d001c824a0 feat(memory): 暴露滑条起始值与最大值供目标采样
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 20:39:08 +08:00
Ovid d865244a02 +br和waitfor,语病检查 2026-06-30 19:40:51 +08:00
bottlefishandCursor a97b00072a scene(peipei): 更新 PeipeiFixScene Eye/UF 引用
同步 EyeTarget 运行时材质命名与 UF/错色 overlay 场景配置。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:14:38 +08:00
bottlefishandCursor 06be1d6eef fix(peipei): UF_spriteIn 从 Yarn 变量解析 EyeTarget
切换 UF 模式后 CurrentTarget 可能为空,回退读取 $currentEyeTarget。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:14:33 +08:00
bottlefishandCursor 6dbd6917f9 refactor(peipei): UF 效果改由后处理 Volume 驱动
将 UF 想象/记忆/Ascii 状态从 unified shader weight 迁移至 Volume weight  tween。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:14:22 +08:00
bottlefishandCursor 57e20d0dce fix(peipei): UF overlay fade 前激活 Sprite
fade in 时启用 overlay GameObject,fade out 完成后隐藏。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:14:16 +08:00
bottlefishandCursor 29a1c7d7cb feat(peipei): 错色 Error overlay 淡入淡出驱动
自动绑定 sibling Error Sprite,并在 WrongColor 状态切换时同步 overlay alpha。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:14:10 +08:00
dingyuntian e6b24556b5 Merge branch 'feature/PP1病例分析上传新素材' into 'develop'
Feature/pp1病例分析上传新素材

See merge request aibis-dream/aibis-dream!717
2026-06-30 07:44:53 +00:00
bottlefishandCursor 04b441d977 feat(peipei-eye): 新增视口椭圆遮罩与眨眼效果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:55:57 +08:00
bottlefishandCursor 488149e8d4 fix(peipei-eye): 编辑模式下改用 sharedMaterial 避免材质泄漏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 13:55:51 +08:00
bottlefishandCursor 7a14cc52e9 scene(peipei): 配置 unified eye 与视觉调参
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:56 +08:00
bottlefishandCursor cc069b1da3 fix(fix-system): 调整视觉模块进入时 UI 时序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:42 +08:00
bottlefishandCursor 2fcb998e6f refactor(peipei-eye): 视觉模块平移改用 Cinemachine 锚点
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:35 +08:00
bottlefishandCursor 140e05b4d1 feat(peipei-eye): UF 效果迁移到 unified sprite 权重
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:29 +08:00
bottlefishandCursor 07d330f6db refactor(peipei-eye): 重构 Eye 上色为 unified 驱动并集中调参
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:23 +08:00
bottlefishandCursor 4f6f5a1ae9 feat(peipei-eye): 新增 EyeUnifiedSprite 着色器与材质
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 12:54:08 +08:00
bottlefish db73b64980 Merge branch '佩佩记忆部分优化' of http://114.67.212.218:8011/aibis-dream/aibis-dream into 佩佩记忆部分优化 2026-06-29 22:58:45 +08:00
bottlefish ece0d4ed8e scene(peipei): 配置记忆画面UI显示 2026-06-29 22:58:01 +08:00
bottlefish 268bdd19d9 feat(fix-system): 支持记忆画面使用UI显示 2026-06-29 22:57:14 +08:00
bottlefish 40358dd8a1 feat(fix-system): 支持记忆画面使用UI显示 2026-06-29 22:56:28 +08:00
bottlefish f42ec20dc8 art(ui): 更新默认字体备用字形 2026-06-29 22:23:43 +08:00
bottlefish ca3d4cb3cb scene(peipei): 更新佩佩记忆场景配置 2026-06-29 22:23:26 +08:00
bottlefish 814b659c39 feat(fix-system): 迁移记忆调频到精灵材质 2026-06-29 22:23:10 +08:00
bottlefish c392c81d31 build: 切换 Unity MCP 依赖分支 2026-06-29 22:22:10 +08:00
Ovid 681fe5fc98 autonext处理 2026-06-29 21:54:12 +08:00
Ovid 721e82d768 PP1病理分析上传新素材 2026-06-29 21:45:25 +08:00
dingyuntian 296ce4d657 Merge branch 'bugfix-showobj更新' into 'develop'
fix(ui): show_obj改进

See merge request aibis-dream/aibis-dream!716
2026-06-29 12:21:11 +00:00
dingyuntian 2389c9ef3e fix(ui): show_obj改进 2026-06-29 20:20:04 +08:00
dingyuntian 7e21d33cdd fix(anima): 动画片段及生成器修正 2026-06-29 20:09:39 +08:00
dingyuntian d2ecd1d846 Merge branch 'bugfix/showobj加上时间控制' into 'develop'
fix: 修正场景加载参数并添加展示隐藏时间控制

See merge request aibis-dream/aibis-dream!715
2026-06-29 11:32:32 +00:00
dingyuntian f25674a043 fix: 修正场景加载参数并添加展示隐藏时间控制 2026-06-29 19:31:48 +08:00
dingyuntian d1eab5612d Merge branch '记忆检查shader优化' into 'develop'
记忆检查shader优化

See merge request aibis-dream/aibis-dream!714
2026-06-29 10:17:34 +00:00
dingyuntian 1e60b0005e Merge branch 'develop' into 记忆检查shader优化 2026-06-29 17:52:47 +08:00
dingyuntian 6d262a8796 Merge branch 'develop' into feature/存档 2026-06-29 17:47:05 +08:00
dingyuntian 32d44b8119 chore(peipei): 整理佩佩场景美术资源目录 2026-06-29 17:45:55 +08:00
dingyuntian 7edda0bd6e art(peipei): 调整剪线像素化资源 2026-06-29 17:45:42 +08:00
dingyuntian 2c9f609616 feat(main-ui): 添加开始界面背景与启动流程调整 2026-06-29 17:45:32 +08:00
dingyuntian c0325b0a35 Merge branch 'D1S流程加锁' into 'develop'
Lock Day1 sleep flow and allow early ring pickup

See merge request aibis-dream/aibis-dream!713
2026-06-29 09:39:53 +00:00
bottlefish 228f91e8ab Lock Day1 sleep flow and allow early ring pickup 2026-06-29 17:04:21 +08:00
bottlefish a36dc7c003 Fix mobile render texture black screens 2026-06-29 16:34:26 +08:00
dingyuntian e75bd5e552 Merge branch 'feature/存档' into 'develop'
refactor(save): 合并 SaveSystem 过度拆分的文件

See merge request aibis-dream/aibis-dream!712
2026-06-29 08:03:26 +00:00
dingyuntian 05855e2a9f scene(demo): 调整 Day1_night 初始场景 2026-06-29 16:00:24 +08:00
dingyuntian f188f4c1fc yarn(dialog): 调整 FP 流程节点标签与存档边界 2026-06-29 16:00:08 +08:00
dingyuntian 870bf96c0f docs(save): 更新存档系统 README 与设计方案 2026-06-29 15:59:50 +08:00
dingyuntian 67f3abd825 test(save): 更新验证窗口模拟 OnNodeStart 自动档判定 2026-06-29 15:59:35 +08:00
dingyuntian 3775aed368 feat(save): 支持无节点 anchor 恢复与延迟屏幕遮罩应用 2026-06-29 15:59:21 +08:00
dingyuntian 08b8f34bde fix(timeline-kit): 避免未播放 Timeline 读档时被 Reset 2026-06-29 15:59:05 +08:00
dingyuntian e41756d0f0 fix(camera): 修复立即切换相机时 CustomBlend 仍生效 2026-06-29 15:58:51 +08:00
dingyuntian 2d43af4827 feat(fix-system): 支持 Fix 系统任务面板与身体模块状态读档恢复 2026-06-29 15:58:35 +08:00
dingyuntian 0f681b518e feat(save): 添加 Yarn 节点 InProgress 跟踪与 detour 续跑去重 2026-06-29 15:58:15 +08:00
dingyuntian 382b784605 feat(save): 扩展快照 DTO 支持任务面板与模块状态 2026-06-29 15:57:59 +08:00
dingyuntian a5aa37ffcc refactor(prefab): 移除 ActorAnimaEx 废弃 actorData 字段 2026-06-24 22:45:42 +08:00
dingyuntian 009776eb32 docs(save): 更新存档规范与 Yarn 节点类型文档 2026-06-24 22:45:32 +08:00
dingyuntian b1fdec64e1 yarn(fp-day1): 调整节点标签与场景切换结构,适配存档边界 2026-06-24 22:45:21 +08:00
dingyuntian 43c3f8531d feat(editor): 新增 SaveSystem 测试窗口并更新验证工具读档入口 2026-06-24 22:45:11 +08:00
dingyuntian 5a0b7a047b feat(save-system): 新增 Yarn <<save>> 显式存档与测试归档模式 2026-06-24 22:45:00 +08:00
dingyuntian e90da7bab7 fix(save-system): 修复读档时 UI 与游戏状态未对齐 2026-06-24 22:44:43 +08:00
dingyuntian b53a3f45b7 refactor(fix-system): 移除 IFixSceneCue.SetArgs,改用 Director 与 Transition 传参 2026-06-24 22:44:01 +08:00
dingyuntian e4ec8fce57 refactor(save-system): EyeSystem 与 PunchTape 迁移为显式 DTO 快照 2026-06-24 22:43:49 +08:00
dingyuntian d780ff59ee refactor(fix-system): FixPanel 与 Cable 迁移为显式快照 2026-06-24 22:43:39 +08:00
dingyuntian 57b03aeb08 refactor(fix-system): BodyModule 持久化从 IData 改为显式快照 2026-06-24 22:43:28 +08:00
dingyuntian b7bfa28fd7 refactor(actor-kit): SubwayCenter 迁移为显式快照 2026-06-24 22:43:17 +08:00
dingyuntian 2a0c34fd7a refactor(actor-kit): Actor 体系迁移为 DTO 快照与异步恢复 2026-06-24 22:43:08 +08:00
dingyuntian 3746f4aeb6 feat(save-system): 新增维修与场景子模块快照 DTO 与 Provider 2026-06-24 22:42:56 +08:00
dingyuntian 5cae227fec feat(framework): 状态机与 AnimatorKit 支持读档即时恢复 2026-06-24 22:34:57 +08:00
dingyuntian d88f794759 refactor(save-system): 移除旧存档基础设施 DeepRepair、IData 与 LoadIndex 2026-06-24 22:34:47 +08:00
dingyuntian 67968b0102 scene: 同步场景与 Prefab 的导演、气泡和相机配置 2026-06-22 19:03:12 +08:00
dingyuntian d2c7837a74 refactor(timeline-kit): 清理 DirectorHandler 自动绑定冗余日志 2026-06-22 19:02:58 +08:00
dingyuntian 07850fcf1c feat: 添加 BubbleSlotKit 与 VirtualCamRegistry 集中管理气泡槽位和虚拟相机 2026-06-22 19:02:52 +08:00
dingyuntian 0368374a98 refactor(fix-system): 将 FixStateMachine 重构为 FixSceneDirector 导演模式 2026-06-22 19:01:02 +08:00
dingyuntian 749b9974c0 chore(meta): 添加项目 AI 助手元数据与技能定义 2026-06-22 14:49:23 +08:00
dingyuntian a434ffd7e0 docs(SaveSystem): 更新 P4 读档编排文档 2026-06-22 14:49:09 +08:00
dingyuntian a080f507d1 yarn(FP_Peipei1): 删除 Stage5 分析废弃节点 2026-06-22 14:48:55 +08:00
dingyuntian 50566a1d42 fix(timeline-kit): Addressable Timeline 恢复改为可等待协程 2026-06-22 14:48:43 +08:00
dingyuntian 67668f18e0 feat(SaveSystem): 读档编排 Phase+Barrier 与自动存档抑制 2026-06-22 14:48:30 +08:00
dingyuntian e2bdd8e0fa refactor: 同步化 Actor/Environment/Audio 快照还原接口 2026-06-22 14:48:14 +08:00
dingyuntian 6a289e0255 refactor(SaveSystem): 拆分 Provider 还原契约为 sync/async 接口 2026-06-22 14:47:59 +08:00
dingyuntian 1fe4c44f0c refactor(FixSystemNew): 拆分状态类到 States 目录 2026-06-22 14:47:43 +08:00
dingyuntianandCursor 3cee30f099 chore: 配置 Yarn Spinner 的 cSpell 忽略规则
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 14:35:03 +08:00
dingyuntianandCursor 4d8707cf4e docs(save): 补充瞬跳节点与存档边界设计说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 14:35:03 +08:00
dingyuntianandCursor fbb9e65338 yarn(dialog): 调整佩佩 FP 维修脚本存档边界 tag
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 14:34:42 +08:00
dingyuntianandCursor 0b0d22d009 feat(save): 验证工具增加运行时自动存档历史
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 14:34:29 +08:00
dingyuntianandCursor 0a6e892504 feat(save): 实现 Yarn tag 自动存档判定与异步写盘
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 14:34:19 +08:00
dingyuntian de671ff94c docs(yarn): 更新节点类型规范并新增维修流程规范 2026-06-17 12:51:10 +08:00
dingyuntian fc4b1c8805 fix(peipei): 切割线完成后跳转到新事件节点 2026-06-17 12:51:02 +08:00
dingyuntian 29e698c370 yarn(fp): 规范化维修流程节点标签 2026-06-17 12:50:52 +08:00
dingyuntian b30010a459 scene(save): 配置 Persistence 场景存档系统组件 2026-06-17 12:50:12 +08:00
dingyuntian 181eeb679b test(save): 添加存档系统编辑器验证工具 2026-06-17 12:50:03 +08:00
dingyuntian a633b47d5b fix(save): 快照捕获增加管理器单例空值保护 2026-06-17 12:49:55 +08:00
dingyuntian c55220d739 feat(save): 槽位元数据增加节点名记录 2026-06-17 12:49:46 +08:00
dingyuntian 2796f96215 chore(save): 彻底废弃 auto_save Yarn 命令 2026-06-12 19:58:28 +08:00
dingyuntian e01c8af449 feat(save): 添加可存点判定器与节点事件触发机制 2026-06-12 19:58:20 +08:00
dingyuntian 748c875e93 fix(save): 修复 SlotManager 云存档命名空间引用 2026-06-12 19:58:14 +08:00
dingyuntian afd062bda2 docs(save): 更新 SaveSystem README
同步 README 目录结构:
- 反映已合并的 P1 文件(SaveSnapshotSummary、SnapshotBootstrap 等)
- 补充 P2 新增文件(SlotTypes、SlotFileSystem、SlotManager、CloudSave)
- 更新 Provider 注册与 section 反序列化指引
2026-06-12 17:41:23 +08:00
dingyuntian d6912fb1a6 feat(save): 迁移存读档入口到槽位系统
将游戏流程接入 P2 槽位层:
- SaveRestoreOrchestrator:SaveToAutoSlot / CreateManualSlot / RestoreFromSlot
- GameManager:<<auto_save>> 调用新入口,新增 StartWithSlot 读档入口

保留 RestoreFromFile 旧档兼容路径。
2026-06-12 17:41:10 +08:00
dingyuntian 6fcf340230 feat(save): 添加槽位类型与云存档扩展层
实现 P2 槽位/落盘层核心:
- SlotTypes:槽位常量、sidecar 元数据、UI 视图模型
- SlotFileSystem:槽位路径工具与原子写
- SlotManager:自动档、手动档、读档、继续游戏、P1 迁移
- SlotThumbnailCapture:存档截图 helper
- CloudSave:Steam/TapTap 云存档适配器接口与协调器

在 ConstRef 中补充槽位、截图、文件名常量。
2026-06-12 17:40:57 +08:00
dingyuntian cc3e1dd138 refactor(save): 合并 SaveSystem 过度拆分的文件
将以下小文件合并到相关核心类中,减少文件数量并保持职责清晰:
- SaveSnapshotSummary -> SaveSnapshot
- SnapshotProviderIds + SnapshotBootstrap -> SnapshotRegistry
- SnapshotSectionDeserializer -> SnapshotRestore
- SnapshotSerializer -> SnapshotPersistence

同步更新 SnapshotService 与 YarnVariableStorage 的调用点。
2026-06-12 17:40:36 +08:00
dingyuntian 789ca207bd docs(save): 更新存档系统设计与 README 2026-06-11 21:49:37 +08:00
dingyuntian 4bb0215f3d feat(fix-system): 为读档添加状态机即时切换与快照支持 2026-06-11 21:49:18 +08:00
dingyuntian c67d988365 feat(save): 重构快照结构并添加 Fix 场景支持 2026-06-11 21:48:50 +08:00
dingyuntian 13e51a86e6 chore: 补充 SaveSystem 目录 meta 文件 2026-06-03 19:59:17 +08:00
dingyuntian 2cd3a1a7df docs: 补充存档系统设计文档并更新 CLAUDE.md 2026-06-03 19:58:49 +08:00
dingyuntian 2c1e5d8fa7 chore: 清理旧节点 Prefab 资源 2026-06-03 19:58:36 +08:00
dingyuntian 1065e2b8b9 feat(scene-mgmt): 为场景管理与音频系统添加快照支持 2026-06-03 19:58:02 +08:00
dingyuntian 8e5346da46 feat(fix-system): 修复系统迁移到 DeepRepairDataRegistry 2026-06-03 19:57:46 +08:00
dingyuntian 2c89feec9d feat(save): 移除旧 StorageSystem 并适配 Game Loop 2026-06-03 19:57:29 +08:00
dingyuntian a33d6a9fe1 feat(dialog): 拆分 Yarn 变量存储为独立单例 2026-06-03 19:57:12 +08:00
dingyuntian 33a028f071 feat(save): 添加快照存档系统核心框架 2026-06-03 19:56:45 +08:00
3605 changed files with 196473 additions and 653391 deletions
@@ -0,0 +1,140 @@
---
name: "source-command-split-commit"
description: "将当前未提交改动拆分为多个符合规范的原子提交"
---
# source-command-split-commit
Use this skill when the user asks to run the migrated source command `split-commit`.
## Command Template
将当前未提交改动拆分为多个原子提交。执行前会分析变更、生成分组方案,经用户确认后逐组提交。
## 执行流程
### Step 1: 前置检查
1. **清理残留文件**:若存在 `msg.txt`,先 `rm -f msg.txt`
2. **读取规范**:确认 `COMMIT_CONVENTION.md` 存在且可读
### Step 2: 保护分支检查
保护分支列表(按优先级):`AGENTS.md` > `.cursor/rules/` > `.git/config branch.protected` > 默认(`master|main|develop`)
若当前在保护分支,询问用户:
- **A** - 创建 feature 分支后继续拆分(推荐)
- **B** - 转移到新分支后压成单提交
- **C** - 取消
### Step 3: 盘点变更
执行以下命令分析:
- `git status` - 整体状态
- `git diff --stat` - 文件统计
- `git diff` - 详细改动
- `git diff --cached` - 已暂存改动
### Step 4: 规则化分组
按"意图一致"原则分组,**优先按 type 分,再按 scope 分**。
**必须拆分的情况**
- 同一组可归属多个 type
- 同文件包含多个独立目的(如功能+重构)
- 资源替换与代码逻辑混合
**可以合并的情况**(最小拆分原则):
- 同一功能的多文件改动(脚本+Prefab)
- 同一模块的多个相关 Bug 修复
### Step 5: 生成方案表格
以表格形式展示拆分方案:
| 顺序 | 文件 | 变更目的 | 提交头 | 依据 | 方式 |
|------|------|----------|--------|------|------|
| 1 | `路径` | 描述 | `type(scope): subject` | 原因 | `git add`/`git add -p` |
**Type 选择优先级**`fix` > `feat` > `art` > `audio` > `scene` > `yarn` > `perf|refactor|...`
**Scope 规则**:单模块用模块名(如 `blockpuzzle`),跨模块省略。
### Step 6: 用户确认
询问:`[Y 执行 / n 取消 / 提出调整]`
- 若调整:根据反馈修改方案,再次确认
- 若执行:进入 Step 7
- 若取消:清理并退出
### Step 7: 逐组提交
对每组执行:
1. `git add <file>``git add -p <file>`
2. 复查:`git diff --cached --stat``git diff --cached`
3. 写入 `msg.txt` 并提交:`git commit -F msg.txt`
4. 检查状态:`git status`
**失败处理**
- `git add -p` 跳过关键 hunk → 暂停,提示处理方案
- pre-commit hook 失败 → 询问 [R]重试 [S]跳过 [A]中止
### Step 8: 结果校验
输出:
1. `git log --oneline -<N>`
2. 回放清单(验证 type/scope/subject 合规性)
3. 若不合规,询问是否修正
### Step 9: 清理
`rm -f msg.txt`
---
## Unity 改动速查表
| 改动类型 | type | scope 示例 |
|----------|------|-----------|
| 脚本逻辑修复 | `fix` | `blockpuzzle`, `dialog` |
| 脚本新功能 | `feat` | `huoshan`, `clue` |
| 美术资源(贴图/模型/动画) | `art` | 对应模块或省略 |
| 音频资源 | `audio` | `audio-kit` 或省略 |
| 场景配置(灯光/镜头) | `scene` | 对应模块或省略 |
| Yarn 对话 | `yarn` | `dialog` |
| 测试代码 | `test` | 对应模块 |
---
## 规范校验清单(执行前检查)
- [ ] 格式:`<type>(<scope>): <subject>``<type>: <subject>`
- [ ] type ∈ {`feat`,`fix`,`art`,`audio`,`scene`,`yarn`,`perf`,`refactor`,`style`,`docs`,`build`,`chore`,`test`}
- [ ] subject:中文、动词开头、≤50字、无句号
- [ ] 一次提交只做一件事
---
## 中文提交方法
由于 PowerShell 中文乱码问题,统一使用文件方式:
```bash
echo "type(scope): 描述" > msg.txt
git commit -F msg.txt
rm -f msg.txt
```
---
## 关于 Pre-commit Hooks
若配置了 husky/lint-staged
- 每次提交都会触发检查
- 如需跳过(**不推荐**):`git commit -F msg.txt --no-verify`
- 跳过可能导致 CI 失败
---
开始执行请回复 **"开始"**,或告知需要的调整。
@@ -0,0 +1,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
+3 -1
View File
@@ -110,4 +110,6 @@ fmod_editor.log
.history/*
**/.history/*
.fake
.fake
Keystore/aibis-release-key.keystore
Keystore/keystore.bat
+65 -1
View File
@@ -56,6 +56,70 @@
"temp/": true,
"Temp/": true
},
"files.associations": {
"**/Plugins/Android/*Template.gradle": "plaintext"
},
"dotnet.defaultSolution": "aibis-dream.sln",
"dotnet.preferCSharpExtension": true
"dotnet.preferCSharpExtension": true,
"cSpell.enableFiletypes": [
"yarnspinner"
],
"cSpell.languageSettings": [
{
"languageId": "yarnspinner",
"patterns": [
{
"name": "yarn-commands",
"pattern": "/<<[^>]*>>/g"
},
{
"name": "yarn-expressions",
"pattern": "/\\{[^}]*\\}/g"
},
{
"name": "yarn-variables",
"pattern": "/\\$[a-zA-Z_][a-zA-Z0-9_]*/g"
},
{
"name": "yarn-markup",
"pattern": "/\\[[^\\]]*\\]/g"
},
{
"name": "yarn-hashtags",
"pattern": "/#[a-zA-Z_][a-zA-Z0-9_:-]*/g"
},
{
"name": "yarn-node-markers",
"pattern": "/^(---|===)$/gm"
},
{
"name": "yarn-headers",
"pattern": "/^(title|tags|position|color|colorID|tracking|custom|style):.*/gm"
},
{
"name": "yarn-character-names",
"pattern": "/^\\s*[A-Za-z_][A-Za-z0-9_]*(?=:)/gm"
},
{
"name": "yarn-comments",
"pattern": "/\\/\\/.*$/gm"
}
],
"ignoreRegExpList": [
"yarn-commands",
"yarn-expressions",
"yarn-variables",
"yarn-markup",
"yarn-hashtags",
"yarn-node-markers",
"yarn-headers",
"yarn-character-names",
"yarn-comments"
]
}
],
"cSpell.words": [
"Aibis",
"Newtonsoft"
]
}
+152
View File
@@ -0,0 +1,152 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
AIBIS Dream (also known as "AllOurBrokenParts") is a Unity-based narrative adventure game with investigative elements. The project uses Yarn Spinner for dialogue, features a custom framework with modular systems, and includes multiple mini-games and a modular repair/fix system.
**Unity Version:** 2022.3.7f1c1 (Universal Render Pipeline)
## Architecture
### Core Framework (Assets/Scripts/Framework/)
Modular framework with Kit-pattern utilities:
- **ActionKit**: Action sequencing and command pattern
- **AudioKit**: Audio management (includes FMOD integration)
- **Config / Core**: Configuration and core utilities
- **EventSystemKit**: Decoupled event system
- **LogKit**: Structured logging with categories
- **PoolKit**: Object pooling
- **ResourceKit**: Asset loading via `ResourceSystem` (Addressables-based)
- **Note**: New code must use `ResourceSystem`, never `Resources.Load`. See [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md). Asset placement rules in [Resource Management Spec](Docs/ResourceManagementSpec.md).
- **SelectorKit**: Weighted/batch/non-repeat random selection
- **SingletonKit**: Singleton base classes
- **StateMachineKit**: State management
- **OtherKit**: Miscellaneous utilities (`CameraKit`, `AnimatorKit`, `FadeKit`, `LocalizationKit`, etc.)
- **Attribute**: Custom editor attributes
### Key Game Systems
- **Game Loop** (Scripts/Game Loop/): `GameManager` (Singleton entry), `YarnVariableStorage` (Yarn variables), `SaveRestoreOrchestrator` / `SaveSystem` (save snapshots), `SceneLoader`, `ChapterController`, `TalkSceneSO`
- **Dialog System** (Scripts/Dialog System/): Yarn Spinner integration — `DialogController`, `LineRunner`, `LocalisedLineProvider`, `BaseYarnCommand`
- **SceneManagement** (Scripts/SceneManagement/): `ActorKit/`, `AnimatorKit/`, `EnvironmentKit/`, `SpawnerKit/`, `TimelineKit/`, `SceneCenter/`, `DreamDoorSystem`
- **UI** (Scripts/UI/): `DialogUI/`, `Panel/`, `Form/`, `Components/`, `Cursor/`, `UIManager`
- **MiniGame** (Scripts/MiniGame/): `HuoShan/` (language particles, EmotionWave), `BlockPuzzle/`, `Peipei/` (Eye, CutLine, Memory)
- **FixSystem** (Scripts/FixSystem/): Legacy — HeatMap, PunchTape, Memory, screen effects
- **FixSystemNew** (Scripts/FixSystemNew/): Active repair system — `FixSystemCenter`, `BodyModule/`, `Cable/`, `WhackMole/`, `Screen System/`, `Open/`, `Clinic/`
- **AssetRefs** (Scripts/AssetRefs/): Addressable key constants (`ConstRef`, `FixAssetRef`, `TimelineAssetRef`)
### Content & Localization
- **Assets/GameContent/**: Runtime assets by scope (`Common/`, `UI/`, `Scene_{Name}/`, `Feature_{Name}/`)
- **Assets/Language/**: Unity Localization tables (runtime active: `UIText/`, `Locales/`, `Sprite/`; `Dialog/``ActorName/``Params/` currently kept as migration backups)
- **Assets/Resources/Yarn/**: Current Yarn dialogue files (target migration to `Assets/Narrative/` per spec, not yet complete)
- **Assets/StreamingAssets/Config/**: CSV-driven localized gameplay data (`character.csv` for actor names, `params.csv` for `l10n.*` substitution terms)
- **Assets/Prototype/**: HTML/JS prototypes for mechanic testing
## Critical Dependencies
### Unity Packages
- **Yarn Spinner** 3.2.2 (OpenUPM), **Addressables** 1.21.21, **Cinemachine** 2.9.7
- **URP** 14.0.8, **TextMesh Pro** 3.0.6, **Timeline** 1.7.5, **Localization** 1.4.5
- **Visual Effect Graph** 14.0.8, **Visual Scripting** 1.8.0
### Third-Party
- **FMOD** (audio engine), **DOTween** (tweening), **Shapes** (vector graphics)
- **More Mountains Feedbacks** (game feel), **Destructible 2D**
## Development Guidelines
### Code Organization
1. Each major system self-contained in its own folder under `Assets/Scripts/`
2. Kit-pattern utilities in `Framework/` — reusable across systems
3. Singleton pattern for global managers (derived from `SingletonKit`)
4. Favor composition over inheritance
5. All runtime asset loading via `ResourceSystem` — never `Resources.Load`
### Scene Structure
- Scenes in `Assets/Scenes/`; `Persistence` scene hosts global managers and Persistent Loader
- Fix scenes: `{Character}FixScene` (e.g. `HuoShanFixScene`, `PeipeiFixScene`, `ExpressFixScene`)
- Test scenes: `languageTest`, `HuoShanExpressionTest`, `wavetest`, etc.
### Addressables
- Config in `Assets/AddressableAssetsData/`; Groups: `Core`, `Scene_*`, `Feature_*`, `Shared_*`, `Localization-*`
- Key format: `Category/Path` (e.g. `Animation/Peipei`, `Timeline/BlockPuzzle/PanelOpen`)
- Scene keys use bare PascalCase names (e.g. `ClinicOut`)
- Details in [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md)
### Yarn Dialogue Files
- Current path: `Assets/Resources/Yarn/` (migration to `Assets/Narrative/Yarn/` planned)
- Pattern: `{Character}_{Stage}.yarn`; UTF-8 encoding required for Chinese
#### Yarn Line Tag Maintenance
- **Line tags** (`#line:xxxxxxx`) must be unique across the entire file. Duplicate tags break Yarn Spinner localization.
- Duplicates typically occur when copying an existing node to create a variant (e.g. `地铁ver1``地铁ver3`).
- **Use the dedup tool** (`Tools/dedup_yarn_tags.py`) for bulk cleanup:
```bash
# Preview duplicates from a specific node onward
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/Fiction_Day1_begin/Fiction_Day1_begin.yarn --from-node 地铁ver4
# Fix duplicates from a specific line onward
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/Fiction_Day1_begin/Fiction_Day1_begin.yarn --from-line 535 --fix
# Scan an entire directory (preview only)
python Tools/dedup_yarn_tags.py Assets/Resources/Yarn/ --scan-all
```
#### Yarn Script Generation
- **Do NOT add `#line:` tags when generating or editing Yarn dialogue.** Leave all line-tag generation to the user's localization workflow (Unity Localization + Yarn Spinner import tools).
- When creating new `.yarn` files, write plain dialogue lines without any `#line:` suffixes.
### Asset Naming
- **Scripts**: PascalCase (e.g. `DialogController.cs`)
- **Addressable Keys**: `Category/Path` — no spaces, no extensions, ≤3 levels
- **Addressable Groups**: `{Type}_{PascalCaseName}`
### Git & Commits
- LFS for large binaries; ignored: Library/, Temp/, Build/, *.csproj, *.sln
- **Commit format**: `<type>(<scope>): <subject>` — subject in Chinese, type/scope in English
- Extended types: `feat`, `fix`, `art`, `audio`, `scene`, `yarn`, `perf`, `refactor`, `style`, `docs`, `build`, `chore`, `test`
- See [COMMIT_CONVENTION.md](COMMIT_CONVENTION.md) for full details
## Key Build Commands
```bash
# Windows build
"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe" -batchmode -quit -projectPath . -buildWindows64Player "Build/AIBIS_Dream.exe" -logFile Build/log.txt
```
## Common Tasks
### Adding a New Mini-Game
1. Create `Assets/Scripts/MiniGame/{GameName}/`
2. Add assets to `Assets/GameContent/Feature_{GameName}/`
3. Create Addressable Group `Feature_{GameName}`
4. Create test scene in `Assets/Scenes/`
### Adding Yarn Dialogue
1. Create `.yarn` in `Assets/Resources/Yarn/{Module}/`
2. Ensure YarnProject localization assets/CSV are updated (Unity `Assets/Language/Dialog/` is backup-only during migration)
3. Reference in scene's `DialogueRunner`; test with `DialogController`
## Debugging & Performance
- `LogKit` for structured logging; Addressables Event Viewer for resource debugging
- Monitor `SceneResourceLoader.TotalHandleCount` for leak detection
- Always use PoolKit for frequent instantiation; `LoadUncachedAsync` + `EarlyRelease` for large Timeline assets
- Use `LoadAsync<GameObject>` + `Instantiate` for prefabs, never `InstantiateAsync`
## Important Notes
- Narrative-focused game — dialogue and story take precedence
- `FixSystemNew` is the active repair architecture; `FixSystem` is legacy
- `YarnVariableStorage` holds Yarn runtime variables; save/load flows through `SaveRestoreOrchestrator` and `SaveSystem`
## Reference Documents
- [Resource Loading Best Practices](Docs/ResourceLoadingBestPractices.md) — `ResourceSystem` API, Key/Group/Label naming, lifecycle
- [Resource Management Spec](Docs/ResourceManagementSpec.md) — Asset placement: `GameContent/`, `Narrative/`, `_Archive/`
- [Commit Convention](COMMIT_CONVENTION.md) — Commit message format and branch naming
- [HuoShan Docs](Docs/HuoShan/) — Narrative flow, resources, test flowcharts
---
*This document provides guidance for Codex when working with the AIBIS Dream codebase.*
@@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 09546aca93a801441859af2a811fe53b
m_currentHash:
serializedVersion: 2
Hash: 1616e06b587c7889ae21faab8aef8e68
Hash: cf6c8ca853b0b2e0e22d46c14944816d
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 0
m_BundleLocalCatalog: 0
@@ -59,7 +59,6 @@ MonoBehaviour:
- {fileID: 11400000, guid: 6d259d339c6615248b893837398c1438, type: 2}
- {fileID: 11400000, guid: 8718a71c99923e247a960cbf4e4795f5, type: 2}
- {fileID: 11400000, guid: 1e521cfc13b314642b1e682e3be7ea17, type: 2}
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
- {fileID: 11400000, guid: 724eed66b2d8eef4faf77a4bee4d2845, type: 2}
- {fileID: 11400000, guid: 62a92c74b9bd5bd439690727c1831500, type: 2}
- {fileID: 11400000, guid: b593dc98ea11a484995123de77d9bf1a, type: 2}
@@ -68,6 +67,7 @@ MonoBehaviour:
- {fileID: 11400000, guid: dc8d31ed80ea35945812ff6552efbbe5, type: 2}
- {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2}
- {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2}
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
m_BuildSettings:
m_CompileScriptsInVirtualMode: 0
m_CleanupStreamingAssetsAfterBuilds: 1
@@ -47,6 +47,11 @@ MonoBehaviour:
- Locale-en
- Locale-ja-JP
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7ea8c68fdc624de4c86434963c06ec55
m_Address: Assets/Language/ChapterInfo/ChapterInfo Shared Data.asset
m_ReadOnly: 1
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -29,6 +29,12 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-zh-Hans
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: bdf8ad79a9f707b45805e23c25ccdadb
m_Address: ChapterInfo_zh-Hans
m_ReadOnly: 1
m_SerializedLabels:
- Locale-zh-Hans
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -29,6 +29,12 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-en
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 8906df9235e52fb41b11fe8776a77032
m_Address: ChapterInfo_en
m_ReadOnly: 1
m_SerializedLabels:
- Locale-en
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -29,6 +29,12 @@ MonoBehaviour:
m_SerializedLabels:
- Locale-ja-JP
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 9d8accfdeabc8034c986233920a17184
m_Address: ChapterInfo_ja-JP
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ja-JP
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -52,6 +52,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: f19cff809e7020b41ba8ad7da199db86
m_Address: "Timeline/day2\u5915\u9633\u533B\u751F\u770B\u4F69\u4F69\u4FA7\u8138"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -82,6 +82,11 @@ 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_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -92,6 +92,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: efa335dcb6e706347bd4acf3222331b9
m_Address: "FrameAnimation/\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -37,6 +37,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 26105571c9644aa4194f2b981ee33a58
m_Address: "FrameAnimation/\u4F69\u4F69"
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
@@ -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
@@ -148,9 +148,19 @@ namespace AibisDream.SystemEditor
});
// 右侧 GraphView
// 套一层容器:GraphView 与左侧 Inspector 并列放在 Flex Row 中时,
// 内置 RectangleSelector 绘制的框选矩形会出现坐标偏移(已知 Unity 问题)。
// 将 GraphView 放在独立的 Flex 子容器中可使其局部原点与父容器对齐,规避该偏移。
var graphViewWrapper = new VisualElement();
graphViewWrapper.style.flexGrow = 1;
graphViewWrapper.style.flexDirection = FlexDirection.Column;
graphViewWrapper.style.overflow = Overflow.Hidden;
_graphView = new ChapterGraphView();
_graphView.style.flexGrow = 1;
mainContainer.Add(_graphView);
graphViewWrapper.Add(_graphView);
mainContainer.Add(graphViewWrapper);
root.Add(mainContainer);
@@ -17,6 +17,7 @@ namespace AibisDream.SystemEditor
EditorGUILayout.Space();
EditorGUILayout.LabelField("显示信息", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedObject.FindProperty("title"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("titleKey"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("description"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("coverPic"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("chapter"));
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9e244d3d62dec3f4f881f7e681da8c03
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
{
"name": "AibisDream.FrameAnimation.Editor",
"rootNamespace": "AibisDream.FrameAnimation.Editor",
"references": [
"AibisDream.FrameAnimation.Runtime",
"Unity.2D.Sprite.Editor"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d1ffe139ca2417941a09835467d1e442
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class AsepriteJsonParser
{
public static bool TryParse(
string json,
string sourceId,
out AsepriteSourceDocument document,
out FrameAnimationImportIssue issue)
{
document = null;
issue = null;
try
{
if (string.IsNullOrWhiteSpace(json))
{
issue = Error(FrameAnimationImportIssueCode.JsonInvalid, sourceId, "Aseprite JSON 为空。");
return false;
}
var root = JObject.Parse(json);
var framesToken = root["frames"];
if (framesToken == null ||
(framesToken.Type != JTokenType.Object && framesToken.Type != JTokenType.Array))
{
issue = Error(FrameAnimationImportIssueCode.FramesMissing, sourceId, "JSON 缺少 Object 或 Array 格式的 frames。");
return false;
}
var frames = ParseFrames(framesToken);
var meta = root["meta"] as JObject;
var size = ParseSize(meta?["size"]);
var imageName = meta?.Value<string>("image") ?? string.Empty;
var tags = ParseTags(meta?["frameTags"]);
document = new AsepriteSourceDocument(frames, tags, size, imageName);
return true;
}
catch (Exception exception)
{
issue = Error(
FrameAnimationImportIssueCode.JsonInvalid,
sourceId,
$"Aseprite JSON 解析失败:{exception.Message}");
return false;
}
}
public static bool TryExpandTag(
AsepriteSourceTag tag,
int frameCount,
string sourceId,
out IReadOnlyList<int> indices,
out FrameAnimationImportIssue issue)
{
indices = Array.Empty<int>();
issue = null;
if (tag == null || tag.From < 0 || tag.To < tag.From || tag.To >= frameCount)
{
issue = Error(
FrameAnimationImportIssueCode.TagRangeInvalid,
sourceId,
$"Tag '{tag?.Name}' 的范围 {tag?.From}..{tag?.To} 无效。");
return false;
}
var result = new List<int>();
switch ((tag.Direction ?? string.Empty).ToLowerInvariant())
{
case "forward":
AddAscending(result, tag.From, tag.To);
break;
case "reverse":
AddDescending(result, tag.To, tag.From);
break;
case "pingpong":
AddAscending(result, tag.From, tag.To);
AddDescending(result, tag.To - 1, tag.From + 1);
break;
case "pingpong_reverse":
AddDescending(result, tag.To, tag.From);
AddAscending(result, tag.From + 1, tag.To - 1);
break;
default:
issue = Error(
FrameAnimationImportIssueCode.TagDirectionInvalid,
sourceId,
$"Tag '{tag.Name}' 使用了不支持的 direction '{tag.Direction}'。");
return false;
}
indices = result;
return true;
}
private static List<AsepriteSourceFrame> ParseFrames(JToken token)
{
var frames = new List<AsepriteSourceFrame>();
if (token is JObject objectFrames)
{
foreach (var property in objectFrames.Properties())
{
frames.Add(ParseFrame(property.Name, property.Value));
}
}
else
{
foreach (var item in (JArray)token)
{
frames.Add(ParseFrame(item.Value<string>("filename") ?? string.Empty, item));
}
}
return frames;
}
private static AsepriteSourceFrame ParseFrame(string frameName, JToken token)
{
return new AsepriteSourceFrame(
frameName,
ParseRect(token["frame"]),
token.Value<int?>("duration") ?? 0,
token.Value<bool?>("rotated") ?? false,
token.Value<bool?>("trimmed") ?? false,
ParseRect(token["spriteSourceSize"]),
ParseSize(token["sourceSize"]));
}
private static List<AsepriteSourceTag> ParseTags(JToken token)
{
var tags = new List<AsepriteSourceTag>();
if (!(token is JArray array))
{
return tags;
}
foreach (var item in array)
{
tags.Add(new AsepriteSourceTag(
item.Value<string>("name") ?? string.Empty,
item.Value<int?>("from") ?? -1,
item.Value<int?>("to") ?? -1,
item.Value<string>("direction") ?? string.Empty));
}
return tags;
}
private static RectInt ParseRect(JToken token)
{
return token == null
? default
: new RectInt(
token.Value<int?>("x") ?? 0,
token.Value<int?>("y") ?? 0,
token.Value<int?>("w") ?? 0,
token.Value<int?>("h") ?? 0);
}
private static Vector2Int ParseSize(JToken token)
{
return token == null
? default
: new Vector2Int(token.Value<int?>("w") ?? 0, token.Value<int?>("h") ?? 0);
}
private static void AddAscending(List<int> target, int from, int to)
{
for (var index = from; index <= to; index++)
{
target.Add(index);
}
}
private static void AddDescending(List<int> target, int from, int to)
{
for (var index = from; index >= to; index--)
{
target.Add(index);
}
}
private static FrameAnimationImportIssue Error(
FrameAnimationImportIssueCode code,
string sourceId,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
sourceId,
sourceId,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a21095473b1495440acab244f9e470c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8edeae42e1831084cbf05db9d9e5d1e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,307 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationTextInputWindow : EditorWindow
{
private string label;
private string value;
private string warning;
private Func<string, string> validate;
private Action<string> confirm;
private Vector2 scroll;
public static void Show(
string title,
string label,
string initialValue,
string warning,
Func<string, string> validate,
Action<string> confirm)
{
var window = CreateInstance<FrameAnimationTextInputWindow>();
window.titleContent = new GUIContent(title);
window.label = label;
window.value = initialValue ?? string.Empty;
window.warning = warning ?? string.Empty;
window.validate = validate;
window.confirm = confirm;
window.minSize = new Vector2(460f, 220f);
window.maxSize = new Vector2(700f, 420f);
window.ShowUtility();
}
private void OnGUI()
{
scroll = EditorGUILayout.BeginScrollView(scroll);
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
GUI.SetNextControlName("FrameAnimationTextInput");
value = EditorGUILayout.TextField(value);
if (!string.IsNullOrEmpty(warning))
{
EditorGUILayout.HelpBox(warning, MessageType.Warning);
}
var error = validate?.Invoke(value) ?? string.Empty;
if (!string.IsNullOrEmpty(error))
{
EditorGUILayout.HelpBox(error, MessageType.Error);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
{
if (GUILayout.Button("确认", GUILayout.Width(90f)))
{
var callback = confirm;
var result = value;
Close();
callback?.Invoke(result);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndScrollView();
}
private void OnEnable()
{
EditorApplication.delayCall += FocusInput;
}
private void FocusInput()
{
if (this != null)
{
Focus();
EditorGUI.FocusTextInControl("FrameAnimationTextInput");
}
}
}
internal sealed class FrameAnimationManualClipWindow : EditorWindow
{
private FrameAnimationGraph graph;
private FrameClip source;
private string id = "NewClip";
private string displayName = "New Clip";
private bool external;
private Action<FrameClip> completed;
public static void Show(
FrameAnimationGraph graph,
FrameClip source,
Action<FrameClip> completed)
{
var window = CreateInstance<FrameAnimationManualClipWindow>();
window.titleContent = new GUIContent(source == null ? "Create Manual Clip" : "Copy As Manual Clip");
window.graph = graph;
window.source = source;
window.completed = completed;
var baseId = source != null ? source.Id + "_Manual" : "NewClip";
window.id = MakeUniqueId(graph, baseId);
window.displayName = source != null ? source.DisplayName + " Manual" : window.id;
window.minSize = new Vector2(460f, 260f);
window.maxSize = new Vector2(700f, 420f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField(source == null ? "创建 Manual Clip" : "复制为 Manual Clip", EditorStyles.boldLabel);
id = EditorGUILayout.TextField("ID", id);
displayName = EditorGUILayout.TextField("Display Name", displayName);
external = EditorGUILayout.ToggleLeft("保存为外部独立 .asset(默认保存为 Graph sub-asset", external);
var error = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
? string.Empty
: "ID 为空或与当前 Graph 的 Clip / Flow 冲突。";
if (!string.IsNullOrEmpty(error))
{
EditorGUILayout.HelpBox(error, MessageType.Error);
}
if (source != null)
{
EditorGUILayout.HelpBox(
"帧表将深拷贝,但 Sprite 仍复用原资源;ImportSource 关联会被清除。",
MessageType.Info);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
{
if (GUILayout.Button("创建", GUILayout.Width(90f)))
{
Create();
}
}
EditorGUILayout.EndHorizontal();
}
private void Create()
{
var path = string.Empty;
if (external)
{
path = EditorUtility.SaveFilePanelInProject(
"保存外部 Manual Clip",
id,
"asset",
"请选择 Assets 下的保存位置。");
if (string.IsNullOrEmpty(path))
{
return;
}
}
var succeeded = source == null
? FrameAnimationAssetOperations.CreateManualClip(graph, id, displayName, path, out var result, out var error)
: FrameAnimationAssetOperations.CopyToManual(graph, source, id, displayName, path, out result, out error);
if (!succeeded)
{
EditorUtility.DisplayDialog("操作失败", error, "确定");
return;
}
var callback = completed;
Close();
callback?.Invoke(result);
}
private static string MakeUniqueId(FrameAnimationGraph graph, string baseId)
{
var candidate = baseId;
var suffix = 2;
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
{
candidate = baseId + suffix++;
}
return candidate;
}
}
internal sealed class FrameAnimationExistingClipWindow : EditorWindow
{
private FrameAnimationGraph graph;
private FrameClip clip;
private Action<FrameClip> completed;
public static void Show(FrameAnimationGraph graph, Action<FrameClip> completed)
{
var window = CreateInstance<FrameAnimationExistingClipWindow>();
window.titleContent = new GUIContent("Add Existing Manual Clip");
window.graph = graph;
window.completed = completed;
window.minSize = new Vector2(460f, 180f);
window.maxSize = new Vector2(640f, 260f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField("添加外部 Manual Clip", EditorStyles.boldLabel);
clip = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(clip == null))
{
if (GUILayout.Button("添加", GUILayout.Width(90f)))
{
if (!FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out var error))
{
EditorUtility.DisplayDialog("添加失败", error, "确定");
return;
}
var callback = completed;
var result = clip;
Close();
callback?.Invoke(result);
}
}
EditorGUILayout.EndHorizontal();
}
}
internal sealed class FrameAnimationFlowWindow : EditorWindow
{
private FrameAnimationGraph graph;
private AnimationNode entry;
private string id;
private string displayName;
private Action<AnimationFlow> completed;
internal static void Show(
FrameAnimationGraph graph,
AnimationNode entry,
Action<AnimationFlow> completed)
{
var window = CreateInstance<FrameAnimationFlowWindow>();
window.titleContent = new GUIContent("Create Animation Flow");
window.graph = graph;
window.entry = entry;
window.id = FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, entry);
window.displayName = window.id;
window.completed = completed;
window.minSize = new Vector2(460f, 230f);
window.maxSize = new Vector2(700f, 360f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField("从选中节点创建 Flow", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("Entry Node", entry?.DisplayName ?? string.Empty);
}
id = EditorGUILayout.TextField("ID", id);
displayName = EditorGUILayout.TextField("Display Name", displayName);
var idError = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
? string.Empty
: "ID 为空或与当前 Graph 中的 Clip / Flow 冲突。";
var owner = graph?.Flows.FirstOrDefault(flow => flow != null && flow.EntryNodeId == entry?.InternalId);
if (owner != null)
{
idError = $"该节点已是 Flow '{owner.Id}' 的入口。";
}
if (!string.IsNullOrEmpty(idError))
{
EditorGUILayout.HelpBox(idError, MessageType.Error);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f))) Close();
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(idError)))
{
if (GUILayout.Button("创建", GUILayout.Width(90f)))
{
if (!FrameAnimationGraphMutationService.CreateFlow(
graph, entry, id, displayName, out var flow, out var error))
{
EditorUtility.DisplayDialog("创建 Flow 失败", error, "确定");
return;
}
var callback = completed;
Close();
callback?.Invoke(flow);
}
}
EditorGUILayout.EndHorizontal();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c8f58e7b80263c24087a98874796e91d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,744 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationEditorSelectionKind
{
None,
Graph,
Clip,
Flow,
Source,
Node,
Edge
}
internal readonly struct FrameAnimationEditorSelection
{
public FrameAnimationEditorSelectionKind Kind { get; }
public object Value { get; }
public FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind kind, object value)
{
Kind = kind;
Value = value;
}
}
internal enum FrameAnimationClipFilter
{
All,
Manual,
Imported,
Normal,
Missing,
Referenced,
Unused
}
internal enum FrameAnimationClipSort
{
Name,
Source,
FrameCount,
Duration,
Missing,
ReferenceCount
}
internal static class FrameAnimationAssetReferenceIndex
{
public static IReadOnlyList<FrameAnimationGraph> FindGraphsReferencing(FrameClip clip)
{
if (clip == null)
{
return Array.Empty<FrameAnimationGraph>();
}
return AssetDatabase.FindAssets("t:FrameAnimationGraph")
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid)))
.Where(graph => graph != null && graph.Clips.Any(item => item == clip))
.Distinct()
.OrderBy(graph => AssetDatabase.GetAssetPath(graph), StringComparer.Ordinal)
.ToArray();
}
}
internal static class FrameAnimationResourceQuery
{
public static IReadOnlyList<FrameClip> QueryClips(
FrameAnimationGraph graph,
string search,
FrameAnimationClipFilter filter,
FrameAnimationClipSort sort)
{
if (graph == null)
{
return Array.Empty<FrameClip>();
}
var sourceNames = graph.ImportSources.Where(source => source != null)
.GroupBy(source => source.InternalId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.First().DisplayName ?? string.Empty);
var query = graph.Clips.Where(clip => clip != null && MatchesSearch(clip, sourceNames, search));
query = filter switch
{
FrameAnimationClipFilter.Manual => query.Where(clip => !clip.IsImported),
FrameAnimationClipFilter.Imported => query.Where(clip => clip.IsImported),
FrameAnimationClipFilter.Normal => query.Where(clip => !clip.IsImported || !clip.ImportInfo.IsMissingFromSource),
FrameAnimationClipFilter.Missing => query.Where(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource),
FrameAnimationClipFilter.Referenced => query.Where(clip => ReferenceCount(graph, clip) > 0),
FrameAnimationClipFilter.Unused => query.Where(clip => ReferenceCount(graph, clip) == 0),
_ => query
};
IOrderedEnumerable<FrameClip> ordered = sort switch
{
FrameAnimationClipSort.Source => query.OrderBy(clip => SourceName(clip, sourceNames), StringComparer.Ordinal),
FrameAnimationClipSort.FrameCount => query.OrderBy(clip => clip.FrameCount),
FrameAnimationClipSort.Duration => query.OrderBy(clip => clip.TotalDurationMs),
FrameAnimationClipSort.Missing => query.OrderBy(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource ? 1 : 0),
FrameAnimationClipSort.ReferenceCount => query.OrderBy(clip => ReferenceCount(graph, clip)),
_ => query.OrderBy(clip => clip.DisplayName ?? clip.Id, StringComparer.Ordinal)
};
return ordered.ThenBy(clip => clip.Id, StringComparer.Ordinal)
.ThenBy(clip => AssetDatabase.GetAssetPath(clip), StringComparer.Ordinal)
.ToArray();
}
public static int ReferenceCount(FrameAnimationGraph graph, FrameClip clip)
{
return graph?.Nodes.Count(node => node != null && node.ClipId == clip?.Id) ?? 0;
}
public static string SourceName(
FrameClip clip,
IReadOnlyDictionary<string, string> sourceNames = null)
{
if (clip == null || !clip.IsImported)
{
return "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 形式的 Manual Clip。";
return false;
}
if (graph.Clips.Contains(clip))
{
error = "当前 Graph 已引用该 Clip。";
return false;
}
if (!IsPlayableIdAvailable(graph, clip.Id))
{
error = $"playable id '{clip.Id}' 与当前 Graph 中的 Clip / Flow 冲突。";
return false;
}
Undo.RecordObject(graph, "Add Existing Manual Frame Clip");
graph.AddClip(clip);
EditorUtility.SetDirty(graph);
return true;
}
public static bool CopyToManual(
FrameAnimationGraph graph,
FrameClip source,
string id,
string displayName,
string externalPath,
out FrameClip copy,
out string error)
{
copy = null;
error = string.Empty;
if (source == null || !IsPlayableIdAvailable(graph, id))
{
error = "来源为空,或新 id 与现有 Clip / Flow 冲突。";
return false;
}
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Copy Frame Clip As Manual");
try
{
Undo.RecordObject(graph, "Add Manual Frame Clip Copy");
copy = ScriptableObject.CreateInstance<FrameClip>();
copy.name = id;
copy.Configure(id,
string.IsNullOrWhiteSpace(displayName) ? source.DisplayName : displayName,
source.Frames.Select(frame => frame == null
? null
: new FrameAnimationFrame(frame.Sprite, frame.DurationMs, frame.FrameName, frame.SourceIndex)),
source.Speed,
source.DefaultEndBehavior);
if (string.IsNullOrWhiteSpace(externalPath))
{
AssetDatabase.AddObjectToAsset(copy, graph);
}
else
{
AssetDatabase.CreateAsset(copy, externalPath);
}
Undo.RegisterCreatedObjectUndo(copy, "Create Manual Frame Clip Copy");
graph.AddClip(copy);
EditorUtility.SetDirty(graph);
EditorUtility.SetDirty(copy);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
catch (Exception exception)
{
Undo.RevertAllDownToGroup(group);
copy = null;
error = exception.Message;
return false;
}
}
public static bool RenameClip(
FrameAnimationGraph graph,
FrameClip clip,
string newId,
out string error)
{
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "Clip 不属于当前 Graph。";
return false;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
error = "外部 Manual Clip 被多个 Graph 共享,禁止重命名:" +
string.Join(", ", owners.Select(owner => owner.name));
return false;
}
if (!IsPlayableIdAvailable(graph, newId, clip))
{
error = "新 id 为空或与现有 Clip / Flow 冲突。";
return false;
}
var oldId = clip.Id;
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Rename Frame Clip");
Undo.RecordObject(graph, "Update Frame Clip References");
Undo.RecordObject(clip, "Rename Frame Clip");
clip.SetId(newId);
foreach (var node in graph.Nodes.Where(node => node != null && node.ClipId == oldId))
{
node.SetClipId(newId);
}
if (graph.Settings.DefaultPlayableId == oldId)
{
graph.Settings.SetDefaultPlayableId(newId);
}
EditorUtility.SetDirty(graph);
EditorUtility.SetDirty(clip);
Undo.CollapseUndoOperations(group);
return true;
}
public static bool RenameFlow(
FrameAnimationGraph graph,
AnimationFlow flow,
string newId,
out string error)
{
error = string.Empty;
if (graph == null || flow == null || !graph.Flows.Contains(flow) ||
!IsPlayableIdAvailable(graph, newId, exceptFlow: flow))
{
error = "Flow 不属于当前 Graph,或新 id 与现有 Clip / Flow 冲突。";
return false;
}
var oldId = flow.Id;
Undo.RecordObject(graph, "Rename Animation Flow");
flow.SetId(newId);
if (graph.Settings.DefaultPlayableId == oldId)
{
graph.Settings.SetDefaultPlayableId(newId);
}
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null && data.FlowId == oldId))
{
data.SetFlowId(newId);
}
EditorUtility.SetDirty(graph);
return true;
}
public static bool RenameGraph(FrameAnimationGraph graph, string newId, out string error)
{
error = string.Empty;
if (graph == null || string.IsNullOrWhiteSpace(newId))
{
error = "Graph id 不能为空。";
return false;
}
Undo.RecordObject(graph, "Rename Frame Animation Graph");
graph.SetId(newId);
EditorUtility.SetDirty(graph);
return true;
}
public static bool RemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
{
if (!CanRemoveClip(graph, clip, out error))
{
return false;
}
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Remove Frame Clip");
Undo.RecordObject(graph, "Remove Frame Clip Reference");
graph.RemoveClip(clip);
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) == AssetDatabase.GetAssetPath(graph))
{
Undo.DestroyObjectImmediate(clip);
}
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
public static bool CanRemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
{
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "Clip 不属于当前 Graph。";
return false;
}
var nodeCount = graph.Nodes.Count(node => node != null && node.ClipId == clip.Id);
if (nodeCount > 0)
{
error = $"Clip 仍被 {nodeCount} 个 AnimationNode 引用。";
return false;
}
if (graph.Settings.DefaultPlayableId == clip.Id)
{
error = "Clip 仍被 defaultPlayableId 引用。";
return false;
}
return true;
}
public static bool RemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
{
if (!CanRemoveFlow(graph, flow, out error))
{
return false;
}
Undo.RecordObject(graph, "Remove Animation Flow");
graph.RemoveFlow(flow);
graph.EditorData.RemoveFlowData(flow.Id);
EditorUtility.SetDirty(graph);
return true;
}
public static bool CanRemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
{
error = string.Empty;
if (graph == null || flow == null || !graph.Flows.Contains(flow))
{
error = "Flow 不属于当前 Graph。";
return false;
}
if (graph.Settings.DefaultPlayableId == flow.Id)
{
error = "Flow 仍被 defaultPlayableId 引用。";
return false;
}
return true;
}
public static bool RemoveSource(
FrameAnimationGraph graph,
FrameAnimationImportSource source,
out string error)
{
error = string.Empty;
if (graph == null || source == null || !graph.ImportSources.Contains(source))
{
error = "ImportSource 不属于当前 Graph。";
return false;
}
var clips = graph.Clips.Where(clip => clip != null &&
clip.ImportInfo?.ImportSourceId == source.InternalId).ToArray();
if (clips.Length > 0)
{
error = "ImportSource 仍关联 Imported Clip" + string.Join(", ", clips.Select(clip => clip.Id));
return false;
}
Undo.RecordObject(graph, "Remove Frame Animation ImportSource");
graph.RemoveImportSource(source);
EditorUtility.SetDirty(graph);
return true;
}
}
internal sealed class FrameAnimationEditorIssue
{
public FrameAnimationValidationSeverity Severity { get; }
public string Code { get; }
public FrameAnimationEditorSelection Selection { get; }
public string Message { get; }
public string Suggestion { get; }
public FrameAnimationEditorIssue(
FrameAnimationValidationSeverity severity,
string code,
FrameAnimationEditorSelection selection,
string message,
string suggestion)
{
Severity = severity;
Code = code ?? string.Empty;
Selection = selection;
Message = message ?? string.Empty;
Suggestion = suggestion ?? string.Empty;
}
}
internal static class FrameAnimationEditorValidationService
{
public static IReadOnlyList<FrameAnimationEditorIssue> Validate(
FrameAnimationGraph graph,
bool includeImports,
out FrameAnimationImportPreview importPreview)
{
importPreview = null;
if (graph == null)
{
return Array.Empty<FrameAnimationEditorIssue>();
}
var issues = FrameAnimationGraphValidator.Validate(graph).Issues
.Select(issue => new FrameAnimationEditorIssue(
issue.Severity,
issue.Code.ToString(),
Locate(graph, issue.TargetType, issue.TargetId),
issue.Message,
Suggestion(issue.Code)))
.ToList();
foreach (var duplicate in graph.Clips.Where(clip => clip != null)
.GroupBy(clip => clip).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"DuplicateClipReference",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, duplicate.Key),
$"Graph 重复引用 Clip '{duplicate.Key.Id}'。",
"移除重复的 Clip 引用。"));
}
var graphPath = AssetDatabase.GetAssetPath(graph);
foreach (var clip in graph.Clips.Where(clip => clip != null))
{
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) != graphPath)
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"ForeignSubAssetClip",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
$"Clip '{clip.Id}' 是其他资产的 sub-asset。",
"移除该引用,并复制为当前 Graph 的 Manual Clip。"));
}
if (clip.IsImported && (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"ImportedClipOwnership",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
$"Imported Clip '{clip.Id}' 不是当前 Graph 的 sub-asset。",
"删除非法引用并从对应 ImportSource 重新刷新。"));
}
}
foreach (var node in graph.Nodes.Where(node => node != null))
{
var clip = graph.Clips.SingleOrDefault(item => item != null && item.Id == node.ClipId);
if (clip?.ImportInfo?.IsMissingFromSource == true)
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"NodeReferencesMissingClip",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
$"Node '{node.InternalId}' 引用了 Missing Clip '{clip.Id}'。",
"恢复源 Tag、修改节点引用或删除该节点。"));
}
}
var nodeIds = new HashSet<string>(graph.Nodes.Where(node => node != null)
.Select(node => node.InternalId));
foreach (var node in graph.Nodes.Where(node => node != null &&
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"EditorDataMissing",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
$"Node '{node.InternalId}' 缺少画布位置数据。",
"打开 Graph Editor 初始化位置,或执行自动布局。"));
}
foreach (var data in graph.EditorData.NodeEditorData.Where(data => data != null &&
!nodeIds.Contains(data.NodeId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"EditorDataOrphan",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"存在孤立的 NodeEditorData '{data.NodeId}'。",
"该数据不会影响运行时;确认无引用后可由后续维护工具清理。"));
}
foreach (var duplicate in graph.EditorData.NodeEditorData.Where(data => data != null)
.GroupBy(data => data.NodeId).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"EditorDataDuplicate",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"NodeEditorData '{duplicate.Key}' 重复。",
"保留一条对应位置数据并移除重复项。"));
}
var flowIds = new HashSet<string>(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id));
foreach (var flow in graph.Flows.Where(flow => flow != null &&
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"FlowEditorDataMissing",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow),
$"Flow '{flow.Id}' 缺少画布颜色数据。",
"打开 Graph Editor 初始化 Flow 颜色。"));
}
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null &&
!flowIds.Contains(data.FlowId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"FlowEditorDataOrphan",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"存在孤立的 FlowEditorData '{data.FlowId}'。",
"确认 Flow 已删除后可由后续维护工具清理。"));
}
foreach (var duplicate in graph.EditorData.FlowEditorData.Where(data => data != null)
.GroupBy(data => data.FlowId).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"FlowEditorDataDuplicate",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"FlowEditorData '{duplicate.Key}' 重复。",
"保留一条对应颜色数据并移除重复项。"));
}
if (!includeImports)
{
return issues;
}
importPreview = FrameAnimationImportService.PreviewAll(graph);
foreach (var sourcePreview in importPreview.Sources)
{
foreach (var importIssue in sourcePreview.Issues)
{
issues.Add(new FrameAnimationEditorIssue(
importIssue.Severity,
importIssue.Code.ToString(),
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, sourcePreview.Source),
importIssue.Message,
"打开对应 ImportSource,修复来源设置后重新 Preview。"));
}
}
return issues;
}
private static FrameAnimationEditorSelection Locate(
FrameAnimationGraph graph,
FrameAnimationValidationTargetType type,
string id)
{
return type switch
{
FrameAnimationValidationTargetType.Clip => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Clip, graph.Clips.FirstOrDefault(clip => clip != null && clip.Id == id)),
FrameAnimationValidationTargetType.Flow => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Flow, graph.Flows.FirstOrDefault(flow => flow != null && flow.Id == id)),
FrameAnimationValidationTargetType.Node => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Node, graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == id)),
FrameAnimationValidationTargetType.Edge => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Edge, graph.Edges.FirstOrDefault(edge => edge != null && edge.InternalId == id)),
FrameAnimationValidationTargetType.ImportSource => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Source, graph.ImportSources.FirstOrDefault(source => source != null && source.InternalId == id)),
_ => new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph)
};
}
private static string Suggestion(FrameAnimationValidationCode code)
{
return code switch
{
FrameAnimationValidationCode.PlayableIdEmpty => "使用正式 Rename 命令设置唯一 playable id。",
FrameAnimationValidationCode.PlayableIdDuplicate => "重命名冲突的 Clip 或 Flow。",
FrameAnimationValidationCode.ClipFramesEmpty => "为 Manual Clip 添加帧,或刷新 Imported Clip 来源。",
FrameAnimationValidationCode.FrameDurationInvalid => "将 durationMs 修改为大于 0 的整数。",
FrameAnimationValidationCode.DefaultPlayableInvalid => "在 Graph 属性中选择有效的默认 Clip 或 Flow。",
FrameAnimationValidationCode.ImportedClipMissingFromSource => "恢复源 Tag,或确认没有引用后删除 Missing Clip。",
_ => "打开问题对象并修复对应字段或引用。"
};
}
}
internal static class FrameAnimationWorkspaceState
{
private const string Prefix = "AibisDream.FrameAnimation.Workspace.";
public static string Key(FrameAnimationGraph graph, string name)
{
var graphPath = graph != null ? AssetDatabase.GetAssetPath(graph) : string.Empty;
var graphGuid = string.IsNullOrEmpty(graphPath) ? "none" : AssetDatabase.AssetPathToGUID(graphPath);
var project = Hash128.Compute(Application.dataPath).ToString();
return Prefix + project + "." + graphGuid + "." + name;
}
public static float GetFloat(FrameAnimationGraph graph, string name, float fallback) =>
EditorPrefs.GetFloat(Key(graph, name), fallback);
public static void SetFloat(FrameAnimationGraph graph, string name, float value) =>
EditorPrefs.SetFloat(Key(graph, name), value);
public static string GetString(FrameAnimationGraph graph, string name, string fallback) =>
EditorPrefs.GetString(Key(graph, name), fallback);
public static void SetString(FrameAnimationGraph graph, string name, string value) =>
EditorPrefs.SetString(Key(graph, name), value ?? string.Empty);
public static bool GetBool(FrameAnimationGraph graph, string name, bool fallback) =>
EditorPrefs.GetBool(Key(graph, name), fallback);
public static void SetBool(FrameAnimationGraph graph, string name, bool value) =>
EditorPrefs.SetBool(Key(graph, name), value);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2643ad174ef237e43b98400d60c4a201
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,603 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class FrameAnimationFlowFocusPolicy
{
public static AnimationFlow FindFocusedFlow(FrameAnimationGraph graph, string focusedFlowId)
{
return graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId);
}
public static bool Contains(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
var flow = FindFocusedFlow(graph, focusedFlowId);
if (flow == null)
{
return false;
}
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return selection.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
AnimationFlow selectedFlow => selectedFlow == flow,
_ => false
};
}
public static bool ShouldExitForSelection(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
return selection.Kind switch
{
FrameAnimationEditorSelectionKind.None => false,
FrameAnimationEditorSelectionKind.Flow => false,
FrameAnimationEditorSelectionKind.Node => !Contains(graph, focusedFlowId, selection),
FrameAnimationEditorSelectionKind.Edge => !Contains(graph, focusedFlowId, selection),
_ => true
};
}
public static bool ShouldExitForSelectionSet(
FrameAnimationGraph graph,
string focusedFlowId,
IEnumerable<FrameAnimationEditorSelection> selections)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
var items = (selections ?? Array.Empty<FrameAnimationEditorSelection>()).ToArray();
return items.Length > 0 && items.Any(item =>
(item.Kind == FrameAnimationEditorSelectionKind.Node ||
item.Kind == FrameAnimationEditorSelectionKind.Edge)
? !Contains(graph, focusedFlowId, item)
: ShouldExitForSelection(graph, focusedFlowId, item));
}
}
internal sealed class FrameAnimationGraphImpact
{
public IReadOnlyList<AnimationFlow> Flows { get; }
public int LostNodeCount { get; }
internal FrameAnimationGraphImpact(IReadOnlyList<AnimationFlow> flows, int lostNodeCount)
{
Flows = flows ?? Array.Empty<AnimationFlow>();
LostNodeCount = lostNodeCount;
}
}
internal static class FrameAnimationGraphImpactAnalyzer
{
public static FrameAnimationGraphImpact AnalyzeEdgeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationEdge> edges)
{
var removed = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null));
return Analyze(graph, Array.Empty<AnimationNode>(), removed);
}
public static FrameAnimationGraphImpact AnalyzeNodeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes)
{
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null));
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var removedEdges = new HashSet<AnimationEdge>(graph.Edges.Where(edge => edge != null &&
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
return Analyze(graph, removedNodes, removedEdges);
}
public static FrameAnimationGraphImpact AnalyzeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> edges)
{
if (graph == null)
{
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
}
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>())
.Where(node => node != null));
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var removedEdges = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>())
.Where(edge => edge != null));
removedEdges.UnionWith(graph.Edges.Where(edge => edge != null &&
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
return Analyze(graph, removedNodes, removedEdges);
}
private static FrameAnimationGraphImpact Analyze(
FrameAnimationGraph graph,
IReadOnlyCollection<AnimationNode> removedNodes,
IReadOnlyCollection<AnimationEdge> removedEdges)
{
if (graph == null)
{
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
}
var topology = new FrameAnimationGraphTopology(graph);
var removedNodeIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var affected = new List<AnimationFlow>();
var lost = new HashSet<string>();
foreach (var flow in graph.Flows.Where(flow => flow != null))
{
var before = topology.GetReachable(flow.EntryNodeId).Nodes
.Where(node => node != null).Select(node => node.InternalId).ToHashSet();
var after = ReachableWithout(graph, flow.EntryNodeId, removedNodeIds, removedEdges);
var flowLost = before.Where(nodeId => !after.Contains(nodeId)).ToArray();
if (flowLost.Length > 0)
{
affected.Add(flow);
lost.UnionWith(flowLost);
}
}
return new FrameAnimationGraphImpact(
affected.OrderBy(flow => flow.Id, StringComparer.Ordinal).ToArray(),
lost.Count);
}
private static HashSet<string> ReachableWithout(
FrameAnimationGraph graph,
string entry,
HashSet<string> removedNodeIds,
IReadOnlyCollection<AnimationEdge> removedEdges)
{
var result = new HashSet<string>();
if (removedNodeIds.Contains(entry ?? string.Empty))
{
return result;
}
var outgoing = graph.Edges.Where(edge => edge != null && !removedEdges.Contains(edge))
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.ToArray());
var queue = new Queue<string>();
queue.Enqueue(entry ?? string.Empty);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (removedNodeIds.Contains(id) || !result.Add(id))
{
continue;
}
if (outgoing.TryGetValue(id, out var edges))
{
foreach (var edge in edges)
{
queue.Enqueue(edge.ToNodeId ?? string.Empty);
}
}
}
return result;
}
}
internal static class FrameAnimationGraphMutationService
{
private static readonly Color[] FlowColors =
{
new Color(0.24f, 0.65f, 1f), new Color(0.42f, 0.8f, 0.42f),
new Color(1f, 0.62f, 0.24f), new Color(0.82f, 0.42f, 0.9f),
new Color(1f, 0.38f, 0.52f), new Color(0.25f, 0.82f, 0.78f)
};
public static bool CreateNode(
FrameAnimationGraph graph,
FrameClip clip,
Vector2 position,
out AnimationNode node,
out string error)
{
node = null;
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "只能为当前 Graph 中的 FrameClip 创建节点。";
return false;
}
Undo.RecordObject(graph, "Create Frame Animation Node");
node = new AnimationNode(clip.Id,
string.IsNullOrWhiteSpace(clip.DisplayName) ? clip.Id : clip.DisplayName);
graph.AddNode(node);
graph.EditorData.GetOrCreateNodeData(node.InternalId, position).SetPosition(position);
Dirty(graph);
return true;
}
public static bool TryConnect(
FrameAnimationGraph graph,
AnimationNode from,
AnimationNode to,
out AnimationEdge edge,
out string error)
{
edge = null;
if (!CanConnect(graph, from, to, out error))
{
return false;
}
Undo.RecordObject(graph, "Connect Frame Animation Nodes");
edge = new AnimationEdge(from.InternalId, to.InternalId);
graph.AddEdge(edge);
Dirty(graph);
return true;
}
public static bool CanConnect(
FrameAnimationGraph graph,
AnimationNode from,
AnimationNode to,
out string error)
{
error = string.Empty;
if (graph == null || from == null || to == null ||
graph.Nodes.Count(node => node == from) != 1 || graph.Nodes.Count(node => node == to) != 1)
{
error = "连接两端必须唯一属于当前 Graph。";
return false;
}
if (from == to || from.InternalId == to.InternalId)
{
error = "节点不允许连接到自身。";
return false;
}
var topology = new FrameAnimationGraphTopology(graph);
if (topology.GetOutgoing(from.InternalId).Count > 0)
{
error = "第一版每个节点最多只能有一个后继。";
return false;
}
if (from.EndBehaviorOverride.HasValue)
{
error = "该节点已设置终点结束行为,请先清除覆盖。";
return false;
}
if (graph.Edges.Any(existing => existing != null &&
existing.FromNodeId == from.InternalId && existing.ToNodeId == to.InternalId))
{
error = "该连接已经存在。";
return false;
}
if (topology.WouldCreateCycle(from.InternalId, to.InternalId))
{
error = "该连接会形成自连接或多节点环路。";
return false;
}
return true;
}
public static void SetNodeDisplayName(FrameAnimationGraph graph, AnimationNode node, string value)
{
Undo.RecordObject(graph, "Rename Frame Animation Node");
node.SetDisplayName(value);
Dirty(graph);
}
public static void SetNodeClip(FrameAnimationGraph graph, AnimationNode node, FrameClip clip)
{
Undo.RecordObject(graph, "Set Frame Animation Node Clip");
node.SetClipId(clip != null ? clip.Id : string.Empty);
Dirty(graph);
}
public static void SetNodeSpeed(FrameAnimationGraph graph, AnimationNode node, float? value)
{
Undo.RecordObject(graph, "Set Frame Animation Node Speed");
node.SetSpeedOverride(value);
Dirty(graph);
}
public static bool SetNodeEndBehavior(
FrameAnimationGraph graph,
AnimationNode node,
FrameClipEndBehavior? value,
bool removeOutgoing,
out string error)
{
error = string.Empty;
var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray();
if (value.HasValue && outgoing.Length > 0 && !removeOutgoing)
{
error = "节点仍有后继 Edge。";
return false;
}
Undo.RecordObject(graph, "Set Frame Animation Node End Behavior");
if (value.HasValue)
{
foreach (var edge in outgoing)
{
graph.RemoveEdge(edge);
}
}
node.SetEndBehaviorOverride(value);
Dirty(graph);
return true;
}
public static void RemoveEdges(FrameAnimationGraph graph, IEnumerable<AnimationEdge> edges)
{
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
Undo.RecordObject(graph, "Disconnect Frame Animation Nodes");
foreach (var edge in items)
{
graph.RemoveEdge(edge);
}
Dirty(graph);
}
public static bool RemoveNodes(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
bool deleteEntryFlows,
out string error)
{
return RemoveNodesAndEdges(
graph, nodes, Array.Empty<AnimationEdge>(), deleteEntryFlows, out error);
}
public static bool RemoveNodesAndEdges(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> additionalEdges,
bool deleteEntryFlows,
out string error)
{
error = string.Empty;
if (graph == null)
{
error = "Graph 已失效。";
return false;
}
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
var extraEdges = (additionalEdges ?? Array.Empty<AnimationEdge>())
.Where(edge => edge != null).Distinct().ToArray();
var ids = new HashSet<string>(items.Select(node => node.InternalId));
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
if (entryFlows.Length > 0 && !deleteEntryFlows)
{
error = "删除项包含 Flow 入口节点。";
return false;
}
Undo.RecordObject(graph, "Delete Frame Animation Graph Elements");
foreach (var edge in graph.Edges.Where(edge => edge != null &&
(ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId) || extraEdges.Contains(edge))).ToArray())
{
graph.RemoveEdge(edge);
}
foreach (var flow in entryFlows)
{
graph.RemoveFlow(flow);
graph.EditorData.RemoveFlowData(flow.Id);
if (graph.Settings.DefaultPlayableId == flow.Id)
{
graph.Settings.SetDefaultPlayableId(string.Empty);
}
}
foreach (var node in items)
{
graph.RemoveNode(node);
graph.EditorData.RemoveNodeData(node.InternalId);
}
Dirty(graph);
return true;
}
public static string MakeUniqueFlowId(FrameAnimationGraph graph, AnimationNode node)
{
var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "Flow";
var candidate = root;
var suffix = 2;
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
{
candidate = root + suffix++;
}
return candidate;
}
public static bool CreateFlow(
FrameAnimationGraph graph,
AnimationNode entry,
string id,
string displayName,
out AnimationFlow flow,
out string error)
{
flow = null;
error = string.Empty;
if (graph == null || entry == null || !graph.Nodes.Contains(entry))
{
error = "Flow 入口必须属于当前 Graph。";
return false;
}
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing.EntryNodeId == entry.InternalId);
if (owner != null)
{
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
return false;
}
if (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
{
error = "Flow id 为空或与 Clip / Flow 冲突。";
return false;
}
Undo.RecordObject(graph, "Create Animation Flow");
flow = new AnimationFlow(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName, entry.InternalId);
graph.AddFlow(flow);
graph.EditorData.GetOrCreateFlowData(flow.Id, FlowColors[(graph.Flows.Count - 1) % FlowColors.Length]);
Dirty(graph);
return true;
}
public static bool SetFlowEntry(
FrameAnimationGraph graph,
AnimationFlow flow,
AnimationNode entry,
out string error)
{
error = string.Empty;
if (graph == null || flow == null || entry == null ||
!graph.Flows.Contains(flow) || !graph.Nodes.Contains(entry))
{
error = "Flow 或入口节点不属于当前 Graph。";
return false;
}
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing != flow &&
existing.EntryNodeId == entry.InternalId);
if (owner != null)
{
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
return false;
}
Undo.RecordObject(graph, "Set Animation Flow Entry");
flow.SetEntryNodeId(entry.InternalId);
Dirty(graph);
return true;
}
public static void SetFlowColor(FrameAnimationGraph graph, AnimationFlow flow, Color value)
{
if (graph == null || flow == null)
{
return;
}
Undo.RecordObject(graph, "Set Animation Flow Color");
graph.EditorData.GetOrCreateFlowData(flow.Id, value).SetColor(value);
Dirty(graph);
}
public static bool EnsureEditorData(FrameAnimationGraph graph)
{
if (graph == null)
{
return false;
}
var missingNodes = graph.Nodes.Where(node => node != null &&
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)).ToArray();
var missingFlows = graph.Flows.Where(flow => flow != null &&
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)).ToArray();
if (missingNodes.Length == 0 && missingFlows.Length == 0)
{
return false;
}
Undo.RecordObject(graph, "Initialize Frame Animation Graph Layout");
for (var index = 0; index < missingNodes.Length; index++)
{
var position = new Vector2((index % 4) * 300f, (index / 4) * 180f);
graph.EditorData.GetOrCreateNodeData(missingNodes[index].InternalId, position);
}
for (var index = 0; index < missingFlows.Length; index++)
{
graph.EditorData.GetOrCreateFlowData(missingFlows[index].Id,
FlowColors[(graph.Flows.ToList().IndexOf(missingFlows[index]) + FlowColors.Length) % FlowColors.Length]);
}
Dirty(graph);
return true;
}
public static Vector2 GetNodePosition(FrameAnimationGraph graph, AnimationNode node)
{
return graph?.EditorData.NodeEditorData.FirstOrDefault(data =>
data != null && data.NodeId == node?.InternalId)?.Position ?? Vector2.zero;
}
public static void SetNodePositions(
FrameAnimationGraph graph,
IReadOnlyDictionary<AnimationNode, Vector2> positions,
string undoName)
{
if (graph == null || positions == null || positions.Count == 0)
{
return;
}
Undo.RecordObject(graph, undoName);
foreach (var pair in positions)
{
graph.EditorData.GetOrCreateNodeData(pair.Key.InternalId, pair.Value).SetPosition(pair.Value);
}
Dirty(graph);
}
private static void Dirty(FrameAnimationGraph graph)
{
EditorUtility.SetDirty(graph);
}
}
internal static class FrameAnimationGraphLayoutService
{
public static IReadOnlyDictionary<AnimationNode, Vector2> Calculate(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> scope)
{
var source = scope ?? (graph != null
? graph.Nodes
: (IEnumerable<AnimationNode>)Array.Empty<AnimationNode>());
var nodes = source.Where(node => node != null).Distinct()
.GroupBy(node => node.InternalId ?? string.Empty)
.Select(group => group.First()).ToArray();
if (graph == null || nodes.Length == 0)
{
return new Dictionary<AnimationNode, Vector2>();
}
var ids = new HashSet<string>(nodes.Select(node => node.InternalId));
var edges = graph.Edges.Where(edge => edge != null && ids.Contains(edge.FromNodeId) && ids.Contains(edge.ToNodeId)).ToArray();
var incoming = nodes.ToDictionary(node => node.InternalId, _ => 0);
foreach (var edge in edges)
{
incoming[edge.ToNodeId]++;
}
var ranks = nodes.ToDictionary(node => node.InternalId, _ => 0);
var queue = new Queue<AnimationNode>(nodes.Where(node => incoming[node.InternalId] == 0)
.OrderBy(node => node.InternalId, StringComparer.Ordinal));
var processed = new HashSet<string>();
while (queue.Count > 0)
{
var node = queue.Dequeue();
if (!processed.Add(node.InternalId)) continue;
foreach (var edge in edges.Where(edge => edge.FromNodeId == node.InternalId))
{
ranks[edge.ToNodeId] = Math.Max(ranks[edge.ToNodeId], ranks[node.InternalId] + 1);
incoming[edge.ToNodeId]--;
if (incoming[edge.ToNodeId] == 0)
{
queue.Enqueue(nodes.First(item => item.InternalId == edge.ToNodeId));
}
}
}
var oldPositions = nodes.ToDictionary(node => node,
node => FrameAnimationGraphMutationService.GetNodePosition(graph, node));
var originX = oldPositions.Values.Min(position => position.x);
var originY = oldPositions.Values.Min(position => position.y);
var result = new Dictionary<AnimationNode, Vector2>();
foreach (var group in nodes.GroupBy(node => ranks[node.InternalId]).OrderBy(group => group.Key))
{
var ordered = group.OrderBy(node => oldPositions[node].y)
.ThenBy(node => node.InternalId, StringComparer.Ordinal).ToArray();
for (var index = 0; index < ordered.Length; index++)
{
result[ordered[index]] = new Vector2(originX + group.Key * 300f, originY + index * 180f);
}
}
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b397ed96c5b44df89626f866c32e03dd
@@ -0,0 +1,33 @@
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameAnimationGraph))]
public sealed class FrameAnimationGraphEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
var graph = (FrameAnimationGraph)target;
EditorGUILayout.LabelField("FrameAnimationGraph", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("ID", graph.Id);
EditorGUILayout.TextField("Display Name", graph.DisplayName);
EditorGUILayout.IntField("Clips", graph.Clips.Count);
EditorGUILayout.IntField("Nodes", graph.Nodes.Count);
EditorGUILayout.IntField("Edges", graph.Edges.Count);
EditorGUILayout.IntField("Flows", graph.Flows.Count);
EditorGUILayout.IntField("Import Sources", graph.ImportSources.Count);
EditorGUILayout.TextField("Default Playable", graph.Settings?.DefaultPlayableId ?? string.Empty);
}
EditorGUILayout.HelpBox(
"Graph 的资源、来源、重命名和删除操作只能在主工作台中执行。",
MessageType.Info);
if (GUILayout.Button("Open Frame Animation Graph Editor"))
{
FrameAnimationGraphEditorWindow.Open(graph);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74f3b50c2650607499ea1c82a282f00f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,72 @@
.fa-preview {
position: relative;
overflow: hidden;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: rgb(25, 25, 25);
border-right-color: rgb(25, 25, 25);
border-top-color: rgb(25, 25, 25);
border-bottom-color: rgb(25, 25, 25);
}
.fa-preview__image {
position: absolute;
}
.fa-preview__empty {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
-unity-text-align: middle-center;
color: rgb(190, 190, 190);
}
.fa-node__preview {
height: 82px;
margin-top: 5px;
}
.fa-node--preview-current {
border-left-color: rgb(55, 174, 240);
border-right-color: rgb(55, 174, 240);
border-top-color: rgb(55, 174, 240);
border-bottom-color: rgb(55, 174, 240);
}
.fa-node--preview-passed {
border-left-color: rgb(83, 154, 113);
}
.fa-node--preview-upcoming {
opacity: 0.72;
}
.fa-clip-preview-panel {
flex-shrink: 0;
padding-left: 6px;
padding-right: 6px;
padding-top: 4px;
padding-bottom: 5px;
border-bottom-width: 1px;
border-bottom-color: rgb(28, 28, 28);
}
.fa-clip-preview-panel .fa-preview {
height: 190px;
margin-bottom: 4px;
}
.fa-preview-controls {
flex-direction: row;
align-items: center;
}
.fa-preview-status {
font-size: 10px;
color: rgb(185, 185, 185);
margin-top: 2px;
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a38dd39c699e42348667cc1264ea9076
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df89f5fa1de04df448957109886651b8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,740 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationClipNodeView : Node
{
private readonly Label subtitle;
private readonly Label behavior;
private readonly Label flowBadge;
private readonly Label issueBadge;
private readonly FrameAnimationPreviewElement preview;
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
public AnimationNode Data { get; }
public Port InputPort { get; }
public Port OutputPort { get; }
internal FrameAnimationClipNodeView(
AnimationNode data,
Action<FrameAnimationClipNodeView> selectedCallback)
{
Data = data;
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
AddToClassList("fa-node");
style.minWidth = 245f;
style.maxWidth = 285f;
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,
typeof(AnimationNode));
InputPort.portName = "In";
inputContainer.Add(InputPort);
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single,
typeof(AnimationNode));
OutputPort.portName = "Next";
outputContainer.Add(OutputPort);
subtitle = new Label();
behavior = new Label();
flowBadge = new Label();
issueBadge = new Label();
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
extensionContainer.Add(subtitle);
extensionContainer.Add(behavior);
extensionContainer.Add(flowBadge);
extensionContainer.Add(issueBadge);
preview = new FrameAnimationPreviewElement();
preview.AddToClassList("fa-node__preview");
extensionContainer.Add(preview);
RefreshExpandedState();
}
public override void OnSelected()
{
base.OnSelected();
selectedCallback?.Invoke(this);
}
internal void Refresh(
FrameAnimationGraph graph,
IReadOnlyList<FrameAnimationEditorIssue> issues,
IReadOnlyList<AnimationFlow> flows,
IReadOnlyCollection<string> entryFlowIds)
{
var clip = graph.Clips.FirstOrDefault(item => item != null && item.Id == Data.ClipId);
title = string.IsNullOrWhiteSpace(Data.DisplayName) ? Data.ClipId : Data.DisplayName;
subtitle.text = clip != null ? $"Clip: {Data.ClipId}" : $"Clip: <Missing {Data.ClipId}>";
var speed = Data.SpeedOverride.HasValue
? $"Speed {Data.SpeedOverride.Value:0.###} (Node)"
: clip != null ? $"Speed {clip.Speed:0.###} (Clip)" : "Speed ?";
var end = Data.EndBehaviorOverride?.ToString() ?? "Continue / terminal fallback";
behavior.text = $"{speed} · {end}";
var used = flows.Select(flow => flow.Id).ToArray();
var entries = used.Where(entryFlowIds.Contains).ToArray();
flowBadge.text = used.Length == 0
? "Unused"
: "Flows: " + string.Join(", ", used.Take(3)) + (used.Length > 3 ? $" +{used.Length - 3}" : string.Empty) +
(entries.Length > 0 ? " [ENTRY]" : string.Empty);
var matching = issues.Where(issue => issue.Selection.Kind == FrameAnimationEditorSelectionKind.Node &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
issueBadge.text = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? "ERROR"
: matching.Length > 0 ? "WARNING" : string.Empty;
issueBadge.style.color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? new StyleColor(new Color(1f, 0.35f, 0.3f))
: new StyleColor(new Color(1f, 0.72f, 0.25f));
OutputPort.SetEnabled(!Data.EndBehaviorOverride.HasValue);
OutputPort.tooltip = Data.EndBehaviorOverride.HasValue
? "该节点已设置终点结束行为,请先清除覆盖。"
: "创建唯一的顺序后继";
}
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.45f : 1f;
}
internal void SetPreview(
FrameAnimationPreviewDisplay display,
FrameAnimationPreviewBackground background)
{
preview.SetBackground(background);
preview.SetZoom(FrameAnimationPreviewZoomMode.Fit, 1f);
preview.SetDisplay(display.Sprite, display.IsEmpty);
preview.style.opacity = display.FlowState == FrameAnimationFlowElementState.Upcoming
? 0.55f : display.FlowState == FrameAnimationFlowElementState.Passed ? 0.82f : 1f;
EnableInClassList("fa-node--preview-current", display.FlowState == FrameAnimationFlowElementState.Current);
EnableInClassList("fa-node--preview-passed", display.FlowState == FrameAnimationFlowElementState.Passed);
EnableInClassList("fa-node--preview-upcoming", display.FlowState == FrameAnimationFlowElementState.Upcoming);
}
}
internal sealed class FrameAnimationEdgeView : Edge
{
private readonly Action<FrameAnimationEdgeView> selectedCallback;
private Color baseColor = Color.white;
public AnimationEdge Data { get; }
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
{
Data = data;
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
userData = data;
}
public override void OnSelected()
{
base.OnSelected();
selectedCallback?.Invoke(this);
}
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.30f : 1f;
}
internal void Refresh(IReadOnlyList<FrameAnimationEditorIssue> issues)
{
var matching = (issues ?? Array.Empty<FrameAnimationEditorIssue>()).Where(issue =>
issue.Selection.Kind == FrameAnimationEditorSelectionKind.Edge &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
var color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? new Color(1f, 0.3f, 0.25f)
: matching.Length > 0 ? new Color(1f, 0.72f, 0.2f) : Color.white;
baseColor = color;
edgeControl.inputColor = color;
edgeControl.outputColor = color;
tooltip = matching.Length == 0
? $"{Data.ExitName} / {Data.Condition}"
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
}
internal void SetPreviewState(FrameAnimationFlowElementState state)
{
if (state == FrameAnimationFlowElementState.Current)
{
edgeControl.inputColor = new Color(0.25f, 0.75f, 1f);
edgeControl.outputColor = new Color(0.25f, 0.75f, 1f);
}
else if (state == FrameAnimationFlowElementState.Passed)
{
edgeControl.inputColor = new Color(0.35f, 0.68f, 0.46f);
edgeControl.outputColor = new Color(0.35f, 0.68f, 0.46f);
}
else if (state == FrameAnimationFlowElementState.Upcoming)
{
edgeControl.inputColor = new Color(0.55f, 0.55f, 0.55f);
edgeControl.outputColor = new Color(0.55f, 0.55f, 0.55f);
}
else
{
edgeControl.inputColor = baseColor;
edgeControl.outputColor = baseColor;
}
}
}
internal sealed class FrameAnimationClipSearchProvider : ScriptableObject, ISearchWindowProvider
{
private FrameAnimationGraph graph;
private Action<FrameClip> selected;
internal void Configure(FrameAnimationGraph value, Action<FrameClip> callback)
{
graph = value;
selected = callback;
}
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>
{
new SearchTreeGroupEntry(new GUIContent("Create Clip Node"), 0)
};
foreach (var clip in graph.Clips.Where(clip => clip != null)
.OrderBy(clip => clip.DisplayName, StringComparer.Ordinal)
.ThenBy(clip => clip.Id, StringComparer.Ordinal))
{
entries.Add(new SearchTreeEntry(new GUIContent($"{clip.DisplayName} ({clip.Id})"))
{
level = 1,
userData = clip
});
}
return entries;
}
public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
{
if (entry.userData is FrameClip clip)
{
selected?.Invoke(clip);
return true;
}
return false;
}
}
internal sealed class FrameAnimationGraphView : GraphView
{
internal const string ClipDragKey = "AibisDream.FrameAnimation.ClipDrag";
private readonly Dictionary<string, FrameAnimationClipNodeView> nodeViews =
new Dictionary<string, FrameAnimationClipNodeView>();
private readonly Dictionary<string, FrameAnimationEdgeView> edgeViews =
new Dictionary<string, FrameAnimationEdgeView>();
private FrameAnimationGraph graph;
private IReadOnlyList<FrameAnimationEditorIssue> issues = Array.Empty<FrameAnimationEditorIssue>();
private bool rebuilding;
private Vector2 contextPosition;
private string focusedFlowId = string.Empty;
internal event Action<FrameAnimationEditorSelection> SelectionRequested;
internal event Action<IReadOnlyList<AnimationNode>> MultiSelectionChanged;
internal event Action<IReadOnlyList<FrameAnimationEditorSelection>> SelectionSetChanged;
internal event Action<AnimationNode> CreateFlowRequested;
internal event Action<AnimationNode> NodeCreated;
internal event Action ExitFocusRequested;
internal event Action GraphChanged;
internal event Action<string> NotificationRequested;
internal event Action<Vector3, float> WorkspaceViewTransformChanged;
internal FrameAnimationGraphView()
{
style.flexGrow = 1f;
focusable = true;
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new RectangleSelector());
var grid = new GridBackground();
Insert(0, grid);
grid.StretchToParentSize();
graphViewChanged = OnGraphViewChanged;
viewTransformChanged += _ =>
WorkspaceViewTransformChanged?.Invoke(viewTransform.position, viewTransform.scale.x);
RegisterCallback<MouseUpEvent>(_ => NotifySelectionSummary());
RegisterCallback<DragUpdatedEvent>(OnDragUpdated);
RegisterCallback<DragPerformEvent>(OnDragPerform);
RegisterCallback<KeyDownEvent>(OnKeyDown);
}
internal void Bind(
FrameAnimationGraph value,
IReadOnlyList<FrameAnimationEditorIssue> validationIssues,
string focusedFlowId)
{
graph = value;
issues = validationIssues ?? Array.Empty<FrameAnimationEditorIssue>();
var selectedIds = selection.OfType<FrameAnimationClipNodeView>()
.Select(view => view.Data.InternalId).ToArray();
var selectedEdgeIds = selection.OfType<FrameAnimationEdgeView>()
.Select(view => view.Data.InternalId).ToArray();
rebuilding = true;
DeleteElements(graphElements.ToList());
nodeViews.Clear();
edgeViews.Clear();
if (graph != null)
{
var topology = new FrameAnimationGraphTopology(graph);
var entries = new HashSet<string>(graph.Flows.Where(flow => flow != null)
.Select(flow => flow.EntryNodeId));
foreach (var node in graph.Nodes.Where(node => node != null))
{
var view = new FrameAnimationClipNodeView(node, OnNodeSelected);
view.SetPosition(new Rect(FrameAnimationGraphMutationService.GetNodePosition(graph, node),
new Vector2(255f, 150f)));
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId), entries);
nodeViews[node.InternalId] = view;
AddElement(view);
}
foreach (var edge in graph.Edges.Where(edge => edge != null))
{
if (!nodeViews.TryGetValue(edge.FromNodeId ?? string.Empty, out var from) ||
!nodeViews.TryGetValue(edge.ToNodeId ?? string.Empty, out var to))
{
continue;
}
var view = new FrameAnimationEdgeView(edge, OnEdgeSelected)
{
output = from.OutputPort,
input = to.InputPort
};
view.output.Connect(view);
view.input.Connect(view);
view.Refresh(issues);
edgeViews[edge.InternalId] = view;
AddElement(view);
}
foreach (var id in selectedIds.Where(nodeViews.ContainsKey))
{
AddToSelection(nodeViews[id]);
}
foreach (var id in selectedEdgeIds.Where(edgeViews.ContainsKey))
{
AddToSelection(edgeViews[id]);
}
}
rebuilding = false;
SetFocusedFlow(focusedFlowId);
}
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
if (evt.target == this || evt.target == contentViewContainer)
{
contextPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
evt.menu.AppendAction("Create Clip Node", _ => OpenClipSearch(evt.mousePosition),
graph != null && graph.Clips.Any(clip => clip != null)
? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
}
if (selection.Count == 1 && selection[0] is FrameAnimationClipNodeView nodeView)
{
evt.menu.AppendAction("Create Flow From Node", _ => CreateFlowRequested?.Invoke(nodeView.Data));
}
base.BuildContextualMenu(evt);
}
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var result = new List<Port>();
if (graph == null || !(startPort.node is FrameAnimationClipNodeView startNode))
{
return result;
}
ports.ForEach(port =>
{
if (!(port.node is FrameAnimationClipNodeView targetNode) || port.direction == startPort.direction ||
targetNode == startNode)
{
return;
}
var from = startPort.direction == Direction.Output ? startNode.Data : targetNode.Data;
var to = startPort.direction == Direction.Output ? targetNode.Data : startNode.Data;
if (FrameAnimationGraphMutationService.CanConnect(graph, from, to, out _))
{
result.Add(port);
}
});
return result;
}
internal void SetFocusedFlow(string flowId)
{
focusedFlowId = flowId ?? string.Empty;
if (graph == null || string.IsNullOrEmpty(flowId))
{
foreach (var view in nodeViews.Values) view.SetDimmed(false);
foreach (var view in edgeViews.Values) view.SetDimmed(false);
return;
}
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
var topology = new FrameAnimationGraphTopology(graph);
var reachable = flow != null
? topology.GetReachable(flow.EntryNodeId)
: new FrameAnimationReachability(Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>());
var nodeIds = new HashSet<string>(reachable.Nodes.Select(node => node.InternalId));
var edgeIds = new HashSet<string>(reachable.Edges.Select(edge => edge.InternalId));
foreach (var pair in nodeViews) pair.Value.SetDimmed(!nodeIds.Contains(pair.Key));
foreach (var pair in edgeViews) pair.Value.SetDimmed(!edgeIds.Contains(pair.Key));
}
internal void SelectAndFrame(FrameAnimationEditorSelection target)
{
GraphElement element = target.Value switch
{
AnimationNode node when nodeViews.TryGetValue(node.InternalId, out var nodeView) => nodeView,
AnimationEdge edge when edgeViews.TryGetValue(edge.InternalId, out var edgeView) => edgeView,
_ => null
};
if (element == null) return;
ClearSelection();
AddToSelection(element);
FrameSelection();
}
internal IReadOnlyList<AnimationNode> SelectedNodes =>
selection.OfType<FrameAnimationClipNodeView>().Select(view => view.Data).ToArray();
internal IReadOnlyList<AnimationNode> VisibleNodes => nodeViews.Values
.Where(view => view.worldBound.Overlaps(worldBound))
.Select(view => view.Data)
.ToArray();
internal void ApplyPreview(FrameAnimationPreviewCoordinator coordinator)
{
if (coordinator == null) return;
foreach (var view in nodeViews.Values)
{
view.SetPreview(coordinator.GetNodeDisplay(view.Data), coordinator.Background);
}
foreach (var view in edgeViews.Values)
{
view.SetPreviewState(coordinator.GetEdgeState(view.Data));
}
}
internal void RestoreViewTransform(Vector3 position, float scale)
{
var safeScale = float.IsNaN(scale) || float.IsInfinity(scale)
? 1f : Mathf.Clamp(scale, ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
if (float.IsNaN(position.x) || float.IsInfinity(position.x) ||
float.IsNaN(position.y) || float.IsInfinity(position.y))
{
position = Vector3.zero;
}
UpdateViewTransform(position, new Vector3(safeScale, safeScale, 1f));
}
internal void RequestDeleteNodes(IEnumerable<AnimationNode> nodes)
{
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
var ids = new HashSet<string>(items.Select(node => node.InternalId));
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeNodeRemoval(graph, items);
if (!EditorUtility.DisplayDialog(
"Delete Frame Animation Nodes",
BuildRemovalMessage("Node", items.Length, impact, entryFlows),
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete",
"Cancel"))
{
return;
}
if (!FrameAnimationGraphMutationService.RemoveNodes(graph, items, entryFlows.Length > 0, out var error))
{
NotificationRequested?.Invoke(error);
return;
}
GraphChanged?.Invoke();
}
internal void RequestDeleteEdges(IEnumerable<AnimationEdge> edges)
{
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, items);
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog(
"Disconnect Frame Animation Nodes",
BuildRemovalMessage("Edge", items.Length, impact, Array.Empty<AnimationFlow>()),
"Disconnect",
"Cancel"))
{
return;
}
FrameAnimationGraphMutationService.RemoveEdges(graph, items);
GraphChanged?.Invoke();
}
internal void FrameCurrentFlow(string flowId)
{
if (graph == null || string.IsNullOrEmpty(flowId)) return;
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
if (flow == null) return;
var ids = new HashSet<string>(new FrameAnimationGraphTopology(graph)
.GetReachable(flow.EntryNodeId).Nodes.Select(node => node.InternalId));
ClearSelection();
foreach (var pair in nodeViews.Where(pair => ids.Contains(pair.Key))) AddToSelection(pair.Value);
FrameSelection();
}
internal void ApplyCalculatedPositions(IReadOnlyDictionary<AnimationNode, Vector2> positions)
{
foreach (var pair in positions)
{
if (nodeViews.TryGetValue(pair.Key.InternalId, out var view))
{
var rect = view.GetPosition();
rect.position = pair.Value;
view.SetPosition(rect);
}
}
}
private void OpenClipSearch(Vector2 panelPosition)
{
var provider = ScriptableObject.CreateInstance<FrameAnimationClipSearchProvider>();
provider.Configure(graph, clip => CreateNode(clip, contextPosition));
SearchWindow.Open(new SearchWindowContext(GUIUtility.GUIToScreenPoint(panelPosition)), provider);
}
internal void CreateNode(FrameClip clip, Vector2 position)
{
if (FrameAnimationGraphMutationService.CreateNode(graph, clip, position, out var node, out var error))
{
NodeCreated?.Invoke(node);
GraphChanged?.Invoke();
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node));
}
else
{
NotificationRequested?.Invoke(error);
}
}
private GraphViewChange OnGraphViewChanged(GraphViewChange change)
{
if (rebuilding || graph == null)
{
return change;
}
if (change.edgesToCreate != null)
{
foreach (var visual in change.edgesToCreate.ToArray())
{
var error = string.Empty;
AnimationEdge data = null;
var from = visual.output?.node as FrameAnimationClipNodeView;
var to = visual.input?.node as FrameAnimationClipNodeView;
if (from == null || to == null ||
!FrameAnimationGraphMutationService.TryConnect(
graph, from.Data, to.Data, out data, out error))
{
change.edgesToCreate.Remove(visual);
NotificationRequested?.Invoke(string.IsNullOrEmpty(error) ? "无法创建连接。" : error);
continue;
}
visual.userData = data;
}
GraphChanged?.Invoke();
}
if (change.elementsToRemove != null)
{
ProcessRemovals(change);
}
if (change.movedElements != null)
{
var positions = change.movedElements.OfType<FrameAnimationClipNodeView>()
.ToDictionary(view => view.Data, view => view.GetPosition().position);
if (positions.Count > 0)
{
FrameAnimationGraphMutationService.SetNodePositions(graph, positions,
"Move Frame Animation Nodes");
GraphChanged?.Invoke();
}
}
return change;
}
private void ProcessRemovals(GraphViewChange change)
{
var nodeViewsToRemove = change.elementsToRemove.OfType<FrameAnimationClipNodeView>().ToArray();
var removedNodeIds = new HashSet<string>(nodeViewsToRemove.Select(view => view.Data.InternalId));
if (nodeViewsToRemove.Length > 0)
{
var nodes = nodeViewsToRemove.Select(view => view.Data).ToArray();
var explicitlySelectedEdges = change.elementsToRemove.OfType<Edge>()
.Select(edge => edge.userData as AnimationEdge)
.Where(edge => edge != null).Distinct().ToArray();
var entryFlows = graph.Flows.Where(flow => flow != null && removedNodeIds.Contains(flow.EntryNodeId)).ToArray();
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeRemoval(
graph, nodes, explicitlySelectedEdges);
var message = BuildRemovalMessage("Node", nodes.Length, impact, entryFlows);
if (explicitlySelectedEdges.Length > 0)
{
message += $"\n\n同时删除选中的 Edge{explicitlySelectedEdges.Length}";
}
var confirmed = EditorUtility.DisplayDialog("Delete Frame Animation Nodes", message,
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete", "Cancel");
var removed = false;
var error = string.Empty;
if (confirmed)
{
removed = FrameAnimationGraphMutationService.RemoveNodesAndEdges(
graph, nodes, explicitlySelectedEdges, entryFlows.Length > 0, out error);
}
if (!removed)
{
change.elementsToRemove.Clear();
if (!string.IsNullOrEmpty(error)) NotificationRequested?.Invoke(error);
}
GraphChanged?.Invoke();
return;
}
var remainingEdges = change.elementsToRemove.OfType<Edge>()
.Where(edge => !IsIncidentTo(edge, removedNodeIds))
.Select(edge => edge.userData as AnimationEdge).Where(edge => edge != null).Distinct().ToArray();
if (remainingEdges.Length > 0)
{
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, remainingEdges);
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog("Disconnect Frame Animation Nodes",
BuildRemovalMessage("Edge", remainingEdges.Length, impact, Array.Empty<AnimationFlow>()),
"Disconnect", "Cancel"))
{
foreach (var edgeView in change.elementsToRemove.OfType<Edge>()
.Where(view => remainingEdges.Contains(view.userData as AnimationEdge)).ToArray())
{
change.elementsToRemove.Remove(edgeView);
}
}
else
{
FrameAnimationGraphMutationService.RemoveEdges(graph, remainingEdges);
}
}
GraphChanged?.Invoke();
}
private static bool IsIncidentTo(Edge edge, IReadOnlyCollection<string> nodeIds)
{
var from = edge?.output?.node as FrameAnimationClipNodeView;
var to = edge?.input?.node as FrameAnimationClipNodeView;
return from != null && nodeIds.Contains(from.Data.InternalId) ||
to != null && nodeIds.Contains(to.Data.InternalId);
}
private static string BuildRemovalMessage(
string kind,
int count,
FrameAnimationGraphImpact impact,
IReadOnlyCollection<AnimationFlow> entryFlows)
{
var message = $"将删除 {count} 个 {kind}。";
if (impact.Flows.Count > 0)
{
message += $"\n\n受影响 Flow{string.Join(", ", impact.Flows.Select(flow => flow.Id))}" +
$"\n将失去可达关系的 Node{impact.LostNodeCount}";
}
if (entryFlows.Count > 0)
{
message += $"\n\n入口 Flow 将同时删除:{string.Join(", ", entryFlows.Select(flow => flow.Id))}";
}
return message;
}
private void OnNodeSelected(FrameAnimationClipNodeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, view.Data));
NotifySelectionSummary();
}
private void OnEdgeSelected(FrameAnimationEdgeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, view.Data));
NotifySelectionSummary();
}
private void NotifySelectionSummary()
{
if (rebuilding)
{
return;
}
MultiSelectionChanged?.Invoke(SelectedNodes);
var selected = selection.Select(item => item switch
{
FrameAnimationClipNodeView nodeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Node, nodeView.Data),
FrameAnimationEdgeView edgeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Edge, edgeView.Data),
_ => default
})
.Where(item => item.Kind != FrameAnimationEditorSelectionKind.None)
.ToArray();
SelectionSetChanged?.Invoke(selected);
}
private void OnKeyDown(KeyDownEvent evt)
{
if (!TryHandleFocusExitShortcut(evt.keyCode, evt.target is TextField))
{
return;
}
evt.StopImmediatePropagation();
}
internal bool TryHandleFocusExitShortcut(KeyCode keyCode, bool textInputActive)
{
if (keyCode != KeyCode.Escape || textInputActive || string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
ExitFocusRequested?.Invoke();
return true;
}
private void OnDragUpdated(DragUpdatedEvent evt)
{
if (DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip && graph?.Clips.Contains(clip) == true)
{
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
evt.StopPropagation();
}
}
private void OnDragPerform(DragPerformEvent evt)
{
if (!(DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip) || graph?.Clips.Contains(clip) != true)
{
return;
}
DragAndDrop.AcceptDrag();
CreateNode(clip, contentViewContainer.WorldToLocal(evt.mousePosition));
DragAndDrop.SetGenericData(ClipDragKey, null);
evt.StopPropagation();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ffd56cf8d10f4ff9b33510a268d6b615
@@ -0,0 +1,347 @@
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 sealed class FrameAnimationImportSourcePreview
{
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
private readonly List<FrameAnimationClipDiff> clipDiffs = new List<FrameAnimationClipDiff>();
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
public FrameAnimationImportSource Source { get; }
public AsepriteSourceDocument Document { get; internal set; }
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
public string CurrentHash { get; internal set; } = string.Empty;
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
public IReadOnlyList<FrameAnimationClipDiff> ClipDiffs => clipDiffs;
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
public FrameAnimationImportSourcePreview(FrameAnimationImportSource source)
{
Source = source;
}
public void AddIssue(FrameAnimationImportIssue issue)
{
if (issue != null)
{
issues.Add(issue);
}
}
public void AddClipDiff(FrameAnimationClipDiff diff)
{
if (diff != null)
{
clipDiffs.Add(diff);
}
}
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
{
if (diff != null)
{
spriteDiffs.Add(diff);
}
}
}
internal sealed class FrameAnimationImportPreview
{
private readonly List<FrameAnimationImportSourcePreview> sources =
new List<FrameAnimationImportSourcePreview>();
public FrameAnimationGraph Graph { get; }
public IReadOnlyList<FrameAnimationImportSourcePreview> Sources => sources;
public bool HasErrors => sources.Any(source => source.HasErrors);
public bool HasSpriteChanges => sources.Any(source => source.HasSpriteChanges);
public FrameAnimationImportPreview(FrameAnimationGraph graph)
{
Graph = graph;
}
public void AddSource(FrameAnimationImportSourcePreview preview)
{
if (preview != null)
{
sources.Add(preview);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9d29e9107c2ec549916dfb45e638943
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,335 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
public static class FrameAnimationImportSampleBuilder
{
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation/Import";
private const string ReadOnlyTexturePath = SampleFolder + "/中文预切图.png";
private const string WritableTexturePath = SampleFolder + "/中文自动切图.png";
private const string ObjectJsonPath = SampleFolder + "/中文Object.json";
private const string ArrayJsonPath = SampleFolder + "/中文Array.json";
private const string GraphPath = SampleFolder + "/ImportSampleGraph.asset";
private const string IdleNodeId = "40000000000000000000000000000001";
private const string BlinkNodeId = "40000000000000000000000000000002";
private const string SharedNodeId = "40000000000000000000000000000003";
private const string IdleEdgeId = "40000000000000000000000000000011";
private const string BlinkEdgeId = "40000000000000000000000000000012";
private const string IdleFlowId = "SampleIdleToSharedFlow";
private const string BlinkFlowId = "SampleBlinkToSharedFlow";
[MenuItem("Tools/Frame Animation/Rebuild Import Sample")]
private static void BuildFromMenu()
{
BuildFromCommandLine();
}
public static void BuildFromCommandLine()
{
EnsureFolder(SampleFolder);
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
{
AssetDatabase.DeleteAsset(GraphPath);
}
WriteTexture(ReadOnlyTexturePath);
WriteTexture(WritableTexturePath);
WriteTextAsset(ObjectJsonPath, CreateObjectJson("中文预切图.png", "待机", "眨眼"));
WriteTextAsset(ArrayJsonPath, CreateArrayJson("中文自动切图.png", "转身", "惊讶"));
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
var readOnlyTexture = AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath);
var objectJson = AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath);
if (!AsepriteJsonParser.TryParse(objectJson.text, "sample-slicing", out var document, out var parseIssue))
{
throw new InvalidOperationException(parseIssue.Message);
}
var slicingSource = new FrameAnimationImportSource(
"Prepare Read Only Texture", readOnlyTexture, objectJson, new Vector2(0.5f, 0.5f), true);
var slicingPreview = new FrameAnimationImportSourcePreview(slicingSource) { Document = document };
var slicingPlan = FrameAnimationSpriteUtility.BuildPlan(slicingSource, document, slicingPreview);
if (slicingPreview.HasErrors)
{
throw new InvalidOperationException(string.Join("\n", System.Linq.Enumerable.Select(
slicingPreview.Issues, issue => issue.Message)));
}
FrameAnimationSpriteUtility.ApplyPlan(slicingPlan);
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.Configure(
"ImportSampleGraph",
"Aseprite 导入与稳定刷新样例",
Array.Empty<FrameClip>(),
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
string.Empty);
graph.AddImportSource(new FrameAnimationImportSource(
"Object / 只读切图",
AssetDatabase.LoadAssetAtPath<Texture2D>(ReadOnlyTexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(ObjectJsonPath),
new Vector2(0.5f, 0.5f),
false));
graph.AddImportSource(new FrameAnimationImportSource(
"Array / 自动切图",
AssetDatabase.LoadAssetAtPath<Texture2D>(WritableTexturePath),
AssetDatabase.LoadAssetAtPath<TextAsset>(ArrayJsonPath),
new Vector2(0.5f, 0.5f),
true));
AssetDatabase.CreateAsset(graph, GraphPath);
AssetDatabase.SaveAssets();
var preview = FrameAnimationImportService.PreviewAll(graph);
if (!FrameAnimationImportService.Apply(preview, true, out var error))
{
throw new InvalidOperationException(error + "\n" + string.Join("\n",
System.Linq.Enumerable.SelectMany(preview.Sources,
source => System.Linq.Enumerable.Select(source.Issues, issue => issue.Message))));
}
ExtendForWorkspaceSample(graph);
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Selection.activeObject = graph;
Debug.Log($"Frame Animation import sample rebuilt: {GraphPath}");
}
[MenuItem("Tools/Frame Animation/Upgrade Phase 4 Sample (Non-Destructive)")]
public static void UpgradePhase4Sample()
{
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath);
if (graph == null)
{
Debug.LogError($"Frame Animation sample Graph not found: {GraphPath}");
return;
}
var idleClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "待机");
var blinkClip = graph.Clips.FirstOrDefault(clip => clip?.ImportInfo?.SourceTagName == "眨眼");
var sharedClip = AssetDatabase.LoadAssetAtPath<FrameClip>(
"Assets/GameContent/Test/FrameAnimation/Idle.asset") ??
graph.Clips.FirstOrDefault(clip => clip != null && !clip.IsImported);
if (idleClip == null || blinkClip == null || sharedClip == null || !graph.Clips.Contains(sharedClip))
{
Debug.LogError("Phase 4 sample upgrade requires 待机、眨眼 and a Manual Clip already referenced by the Graph.");
return;
}
Undo.RecordObject(graph, "Upgrade Frame Animation Phase 4 Sample");
var idleNode = GetOrAddNode(graph, IdleNodeId, idleClip, "待机 Node");
var blinkNode = GetOrAddNode(graph, BlinkNodeId, blinkClip, "眨眼 Node");
var sharedNode = graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == SharedNodeId);
if (sharedNode == null)
{
sharedNode = new AnimationNode(sharedClip.Id, "Shared Idle Node",
FrameClipEndBehavior.Loop, internalId: SharedNodeId);
graph.AddNode(sharedNode);
}
GetOrAddEdge(graph, IdleEdgeId, idleNode, sharedNode);
GetOrAddEdge(graph, BlinkEdgeId, blinkNode, sharedNode);
GetOrAddFlow(graph, IdleFlowId, "待机到共享 Idle", idleNode,
new Color(0.24f, 0.65f, 1f));
GetOrAddFlow(graph, BlinkFlowId, "眨眼到共享 Idle", blinkNode,
new Color(1f, 0.62f, 0.24f));
graph.EditorData.GetOrCreateNodeData(IdleNodeId, new Vector2(0f, 0f));
graph.EditorData.GetOrCreateNodeData(BlinkNodeId, new Vector2(0f, 220f));
graph.EditorData.GetOrCreateNodeData(SharedNodeId, new Vector2(360f, 110f));
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
Selection.activeObject = graph;
Debug.Log("Frame Animation Phase 4 sample upgraded without rebuilding existing assets or sources.");
}
private static AnimationNode GetOrAddNode(
FrameAnimationGraph graph,
string internalId,
FrameClip clip,
string displayName)
{
var node = graph.Nodes.FirstOrDefault(item => item != null && item.InternalId == internalId);
if (node != null)
{
return node;
}
node = new AnimationNode(clip.Id, displayName, internalId: internalId);
graph.AddNode(node);
return node;
}
private static void GetOrAddEdge(
FrameAnimationGraph graph,
string internalId,
AnimationNode from,
AnimationNode to)
{
if (graph.Edges.Any(edge => edge != null && edge.InternalId == internalId))
{
return;
}
graph.AddEdge(new AnimationEdge(from.InternalId, to.InternalId, internalId));
}
private static void GetOrAddFlow(
FrameAnimationGraph graph,
string id,
string displayName,
AnimationNode entry,
Color color)
{
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == id);
if (flow == null && FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
{
flow = new AnimationFlow(id, displayName, entry.InternalId);
graph.AddFlow(flow);
}
if (flow != null)
{
graph.EditorData.GetOrCreateFlowData(flow.Id, color);
}
}
private static void ExtendForWorkspaceSample(FrameAnimationGraph graph)
{
var imported = graph.Clips.First(clip => clip.ImportInfo?.SourceTagName == "待机");
var manual = ScriptableObject.CreateInstance<FrameClip>();
manual.name = "ManualWorkspaceSample";
manual.Configure(
"ManualWorkspaceSample",
"Graph 内 Manual 样例",
imported.Frames.Take(1).Select(frame => new FrameAnimationFrame(
frame.Sprite, frame.DurationMs, string.Empty, -1)),
1f,
FrameClipEndBehavior.HoldLastFrame);
AssetDatabase.AddObjectToAsset(manual, graph);
var clips = graph.Clips.Concat(new[] { manual }).ToList();
var external = AssetDatabase.LoadAssetAtPath<FrameClip>(
"Assets/GameContent/Test/FrameAnimation/Idle.asset");
if (external != null && clips.All(clip => clip.Id != external.Id))
{
clips.Add(external);
}
var importedNode = new AnimationNode(imported.Id, "Imported 待机");
var terminalClip = external != null && clips.Contains(external) ? external : manual;
var terminalNode = new AnimationNode(
terminalClip.Id,
"共享 Manual 终点",
endBehaviorOverride: FrameClipEndBehavior.Loop);
var edge = new AnimationEdge(importedNode.InternalId, terminalNode.InternalId);
var flow = new AnimationFlow(
"WorkspaceSampleFlow",
"工作台引用定位样例",
importedNode.InternalId);
graph.Configure(
graph.Id,
graph.DisplayName,
clips,
new[] { importedNode, terminalNode },
new[] { edge },
new[] { flow },
flow.Id);
}
private static JObject CreateFrame(string name, int x, int duration)
{
return new JObject
{
["filename"] = name,
["frame"] = new JObject { ["x"] = x, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
["rotated"] = false,
["trimmed"] = false,
["spriteSourceSize"] = new JObject { ["x"] = 0, ["y"] = 0, ["w"] = 2, ["h"] = 2 },
["sourceSize"] = new JObject { ["w"] = 2, ["h"] = 2 },
["duration"] = duration
};
}
private static string CreateObjectJson(string imageName, string firstTag, string secondTag)
{
var root = CreateRoot(imageName, firstTag, secondTag);
root["frames"] = new JObject
{
["中文帧_00"] = WithoutFilename(CreateFrame("中文帧_00", 0, 160)),
["中文帧_01"] = WithoutFilename(CreateFrame("中文帧_01", 2, 90))
};
return root.ToString(Formatting.Indented);
}
private static string CreateArrayJson(string imageName, string firstTag, string secondTag)
{
var root = CreateRoot(imageName, firstTag, secondTag);
root["frames"] = new JArray(
CreateFrame("自动帧_00", 0, 140),
CreateFrame("自动帧_01", 2, 110));
return root.ToString(Formatting.Indented);
}
private static JObject CreateRoot(string imageName, string firstTag, string secondTag)
{
return new JObject
{
["meta"] = new JObject
{
["image"] = imageName,
["size"] = new JObject { ["w"] = 4, ["h"] = 2 },
["frameTags"] = new JArray(
new JObject
{
["name"] = firstTag, ["from"] = 0, ["to"] = 0, ["direction"] = "forward"
},
new JObject
{
["name"] = secondTag, ["from"] = 0, ["to"] = 1, ["direction"] = "pingpong"
})
}
};
}
private static JObject WithoutFilename(JObject frame)
{
frame.Remove("filename");
return frame;
}
private static void WriteTexture(string path)
{
var texture = new Texture2D(4, 2, TextureFormat.RGBA32, false);
texture.SetPixels(new[]
{
Color.cyan, Color.cyan, Color.magenta, Color.magenta,
Color.cyan, Color.cyan, Color.magenta, Color.magenta
});
texture.Apply();
File.WriteAllBytes(path, texture.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(texture);
}
private static void WriteTextAsset(string path, string content)
{
File.WriteAllText(path, content, new UTF8Encoding(false));
}
private static void EnsureFolder(string path)
{
var segments = path.Split('/');
var current = segments[0];
for (var index = 1; index < segments.Length; index++)
{
var next = current + "/" + segments[index];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, segments[index]);
}
current = next;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 291df5abf0a29fd4ca1cb0b86eb24a9e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,612 @@
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)));
}
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}"))));
}
}
}
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,84 @@
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 (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,593 @@
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 =>
FrameAnimationResolver.TryResolve(graph, clip.Id, default, 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 void ApplyEvaluation(FrameAnimationSessionEvaluation evaluation)
{
if (evaluation.IsFailed)
{
Error = evaluation.Error;
State = FrameAnimationPreviewState.Failed;
flowPresentationActive = false;
return;
}
if (!evaluation.IsCompleted) return;
State = FrameAnimationPreviewState.Stopped;
terminalTransparent = evaluation.EndBehavior == FrameClipEndBehavior.Clear ||
evaluation.EndBehavior == FrameClipEndBehavior.HideTarget;
}
private void ApplySprite(Sprite sprite)
{
currentSprite = sprite;
terminalTransparent = false;
}
private FrameAnimationPreviewDisplay Representative(AnimationNode node, FrameAnimationFlowElementState flowState)
{
var clip = graph?.Clips.FirstOrDefault(item => item != null && item.Id == node.ClipId);
var sprite = clip?.Frames.FirstOrDefault(frame => frame?.Sprite != null)?.Sprite;
return new FrameAnimationPreviewDisplay(sprite, sprite == null, false, flowState);
}
private static int IndexOfNode(ResolvedPlaybackPlan targetPlan, string nodeId)
{
if (targetPlan?.Steps == null) return -1;
for (var index = 0; index < targetPlan.Steps.Count; index++)
{
if (targetPlan.Steps[index].NodeId == nodeId) return index;
}
return -1;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14a91ced504e4af4ad79f4f760794c3c
@@ -0,0 +1,163 @@
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationPreviewElement : VisualElement
{
private static Texture2D checkerTexture;
private readonly Image image;
private readonly Label emptyLabel;
private Sprite sprite;
private FrameAnimationPreviewZoomMode zoomMode = FrameAnimationPreviewZoomMode.Fit;
private float manualZoom = 1f;
private Vector2 pan;
private Vector2 dragStart;
private Vector2 panStart;
private bool dragging;
public FrameAnimationPreviewElement()
{
AddToClassList("fa-preview");
focusable = true;
image = new Image { scaleMode = ScaleMode.ScaleToFit, pickingMode = PickingMode.Ignore };
image.AddToClassList("fa-preview__image");
emptyLabel = new Label("Empty Frame") { pickingMode = PickingMode.Ignore };
emptyLabel.AddToClassList("fa-preview__empty");
Add(image);
Add(emptyLabel);
RegisterCallback<GeometryChangedEvent>(_ => UpdateImageLayout());
RegisterCallback<PointerDownEvent>(OnPointerDown);
RegisterCallback<PointerMoveEvent>(OnPointerMove);
RegisterCallback<PointerUpEvent>(OnPointerUp);
SetBackground(FrameAnimationPreviewBackground.Checkerboard);
}
public void SetDisplay(Sprite value, bool isEmpty, string emptyText = "Empty Frame")
{
sprite = value;
image.sprite = value;
image.style.display = value != null ? DisplayStyle.Flex : DisplayStyle.None;
emptyLabel.text = string.IsNullOrEmpty(emptyText) ? "Empty Frame" : emptyText;
emptyLabel.style.display = value == null || isEmpty ? DisplayStyle.Flex : DisplayStyle.None;
UpdateImageLayout();
}
public void SetBackground(FrameAnimationPreviewBackground background)
{
switch (background)
{
case FrameAnimationPreviewBackground.Dark:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f));
break;
case FrameAnimationPreviewBackground.Light:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.72f, 0.72f, 0.72f));
break;
default:
style.backgroundImage = new StyleBackground(GetCheckerTexture());
style.backgroundColor = StyleKeyword.Null;
break;
}
}
public void SetZoom(FrameAnimationPreviewZoomMode mode, float value)
{
zoomMode = mode;
manualZoom = Mathf.Clamp(value, 0.25f, 8f);
if (mode == FrameAnimationPreviewZoomMode.Fit)
{
pan = Vector2.zero;
}
UpdateImageLayout();
}
private void UpdateImageLayout()
{
if (sprite == null || resolvedStyle.width <= 0f || resolvedStyle.height <= 0f)
{
return;
}
var sourceSize = sprite.rect.size;
if (sourceSize.x <= 0f || sourceSize.y <= 0f)
{
return;
}
var scale = zoomMode == FrameAnimationPreviewZoomMode.Fit
? Mathf.Min(resolvedStyle.width / sourceSize.x, resolvedStyle.height / sourceSize.y)
: GetScale();
var size = sourceSize * Mathf.Max(0.0001f, scale);
image.style.width = size.x;
image.style.height = size.y;
image.style.left = (resolvedStyle.width - size.x) * 0.5f + pan.x;
image.style.top = (resolvedStyle.height - size.y) * 0.5f + pan.y;
}
private float GetScale()
{
return zoomMode switch
{
FrameAnimationPreviewZoomMode.One => 1f,
FrameAnimationPreviewZoomMode.Two => 2f,
FrameAnimationPreviewZoomMode.Three => 3f,
FrameAnimationPreviewZoomMode.Four => 4f,
FrameAnimationPreviewZoomMode.Eight => 8f,
FrameAnimationPreviewZoomMode.Manual => manualZoom,
_ => 1f
};
}
private void OnPointerDown(PointerDownEvent evt)
{
if (zoomMode == FrameAnimationPreviewZoomMode.Fit || evt.button != 0) return;
dragging = true;
dragStart = new Vector2(evt.position.x, evt.position.y);
panStart = pan;
this.CapturePointer(evt.pointerId);
evt.StopPropagation();
}
private void OnPointerMove(PointerMoveEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
var position = new Vector2(evt.position.x, evt.position.y);
pan = panStart + position - dragStart;
UpdateImageLayout();
evt.StopPropagation();
}
private void OnPointerUp(PointerUpEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
dragging = false;
this.ReleasePointer(evt.pointerId);
evt.StopPropagation();
}
private static Texture2D GetCheckerTexture()
{
if (checkerTexture != null) return checkerTexture;
const int textureSize = 128;
const int cellSize = 8;
checkerTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false)
{
name = "Frame Animation Preview Checker",
hideFlags = HideFlags.HideAndDontSave,
wrapMode = TextureWrapMode.Repeat,
filterMode = FilterMode.Point
};
var light = new Color32(106, 106, 106, 255);
var dark = new Color32(78, 78, 78, 255);
var pixels = new Color32[textureSize * textureSize];
for (var y = 0; y < textureSize; y++)
for (var x = 0; x < textureSize; x++)
{
pixels[y * textureSize + x] = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
}
checkerTexture.SetPixels32(pixels);
checkerTexture.Apply(false, true);
return checkerTexture;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54aef216ceea42ed8917b581606ecf3d
@@ -0,0 +1,142 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
public static class FrameAnimationPreviewSampleBuilder
{
private const string Folder = "Assets/GameContent/Test/FrameAnimation/Preview";
private const string GraphPath = Folder + "/PreviewSampleGraph.asset";
private const string TexturePath = "Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
[MenuItem("Tools/Frame Animation/Create Phase 5 Preview Sample")]
public static void BuildFromCommandLine()
{
if (AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(GraphPath) != null)
{
Debug.Log($"Frame Animation preview sample already exists; preserved without overwrite: {GraphPath}");
return;
}
EnsureFolder(Folder);
var sprites = AssetDatabase.LoadAllAssetsAtPath(TexturePath).OfType<Sprite>()
.OrderBy(sprite => sprite.name, StringComparer.Ordinal).ToArray();
if (sprites.Length < 6)
{
throw new InvalidOperationException("第五阶段预览样例需要至少 6 个已切分 Sprite。");
}
var graph = ScriptableObject.CreateInstance<FrameAnimationGraph>();
graph.name = "PreviewSampleGraph";
AssetDatabase.CreateAsset(graph, GraphPath);
var action = CreateClip(graph, "PreviewAction", "Preview Action", FrameClipEndBehavior.HoldLastFrame,
new[]
{
new FrameAnimationFrame(sprites[0], 100, sprites[0].name, 0),
new FrameAnimationFrame(sprites[1], 160, sprites[1].name, 1),
new FrameAnimationFrame(sprites[2], 220, sprites[2].name, 2)
});
var withEmpty = CreateClip(graph, "PreviewEmpty", "Preview Empty Frame", FrameClipEndBehavior.HoldLastFrame,
new[]
{
new FrameAnimationFrame(sprites[3], 120, sprites[3].name, 3),
new FrameAnimationFrame(null, 300, "empty", -1),
new FrameAnimationFrame(sprites[4], 120, sprites[4].name, 4)
});
var idle = CreateClip(graph, "PreviewIdle", "Preview Idle Loop", FrameClipEndBehavior.Loop,
new[]
{
new FrameAnimationFrame(sprites[4], 140, sprites[4].name, 4),
new FrameAnimationFrame(sprites[5], 140, sprites[5].name, 5)
});
var finiteA = new AnimationNode(action.Id, "Finite Action", speedOverride: 2f,
internalId: "51000000000000000000000000000001");
var finiteB = new AnimationNode(withEmpty.Id, "Finite Empty",
endBehaviorOverride: FrameClipEndBehavior.HoldLastFrame,
internalId: "51000000000000000000000000000002");
var loopA = new AnimationNode(action.Id, "Loop Intro", internalId: "51000000000000000000000000000003");
var loopB = new AnimationNode(idle.Id, "Loop Terminal",
endBehaviorOverride: FrameClipEndBehavior.Loop,
internalId: "51000000000000000000000000000004");
var blocked = new AnimationNode(withEmpty.Id, "Zero Speed Block", speedOverride: 0f,
internalId: "51000000000000000000000000000005");
var clear = new AnimationNode(action.Id, "Clear Terminal",
endBehaviorOverride: FrameClipEndBehavior.Clear,
internalId: "51000000000000000000000000000006");
var hidden = new AnimationNode(idle.Id, "Hide Terminal",
endBehaviorOverride: FrameClipEndBehavior.HideTarget,
internalId: "51000000000000000000000000000007");
var finiteFlow = new AnimationFlow("PreviewFiniteFlow", "Finite Flow", finiteA.InternalId);
var loopFlow = new AnimationFlow("PreviewLoopFlow", "Loop Flow", loopA.InternalId);
var blockedFlow = new AnimationFlow("PreviewBlockedFlow", "Zero Speed Flow", blocked.InternalId);
var clearFlow = new AnimationFlow("PreviewClearFlow", "Clear Flow", clear.InternalId);
var hideFlow = new AnimationFlow("PreviewHideFlow", "Hide Flow", hidden.InternalId);
graph.Configure(
"PreviewSampleGraph",
"Phase 5 Preview Sample",
new[] { action, withEmpty, idle },
new[] { finiteA, finiteB, loopA, loopB, blocked, clear, hidden },
new[]
{
new AnimationEdge(finiteA.InternalId, finiteB.InternalId, "52000000000000000000000000000001"),
new AnimationEdge(loopA.InternalId, loopB.InternalId, "52000000000000000000000000000002")
},
new[] { finiteFlow, loopFlow, blockedFlow, clearFlow, hideFlow },
finiteFlow.Id);
var positions = new[]
{
(finiteA, new Vector2(80f, 80f)), (finiteB, new Vector2(420f, 80f)),
(loopA, new Vector2(80f, 320f)), (loopB, new Vector2(420f, 320f)),
(blocked, new Vector2(80f, 560f)), (clear, new Vector2(420f, 560f)),
(hidden, new Vector2(760f, 560f))
};
foreach (var item in positions)
{
graph.EditorData.GetOrCreateNodeData(item.Item1.InternalId, item.Item2);
}
var colors = new[]
{
new Color(0.25f, 0.65f, 0.95f), new Color(0.55f, 0.4f, 0.9f),
new Color(0.9f, 0.55f, 0.2f), new Color(0.3f, 0.75f, 0.5f), new Color(0.85f, 0.35f, 0.45f)
};
var flows = graph.Flows.ToArray();
for (var index = 0; index < flows.Length; index++)
{
graph.EditorData.GetOrCreateFlowData(flows[index].Id, colors[index]);
}
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"Frame Animation phase 5 preview sample created: {GraphPath}");
}
private static FrameClip CreateClip(
FrameAnimationGraph graph,
string id,
string displayName,
FrameClipEndBehavior endBehavior,
FrameAnimationFrame[] frames)
{
var clip = ScriptableObject.CreateInstance<FrameClip>();
clip.name = id;
clip.Configure(id, displayName, frames, 1f, endBehavior);
AssetDatabase.AddObjectToAsset(clip, graph);
return clip;
}
private static void EnsureFolder(string path)
{
var segments = path.Split('/');
var current = segments[0];
for (var index = 1; index < segments.Length; index++)
{
var next = current + "/" + segments[index];
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(current, segments[index]);
current = next;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f280e66f096a410eaf3eae8cd9c01134
@@ -0,0 +1,223 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Editor
{
public static class FrameAnimationRuntimeSampleBuilder
{
private const string SourceTexturePath =
"Assets/GameContent/Test/AnimatorRaw/手接树叶特写.png";
private const string SampleFolder = "Assets/GameContent/Test/FrameAnimation";
private const string IntroClipPath = SampleFolder + "/Intro.asset";
private const string IdleClipPath = SampleFolder + "/Idle.asset";
private const string FlowGraphPath = SampleFolder + "/FlowSampleGraph.asset";
private const string DirectGraphPath = SampleFolder + "/DirectSampleGraph.asset";
private const string ScenePath = "Assets/Scenes/FrameAnimationRuntimeTest.unity";
[MenuItem("Tools/Frame Animation/Rebuild Runtime Sample")]
private static void BuildFromMenu()
{
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
{
return;
}
BuildFromCommandLine();
}
public static void BuildFromCommandLine()
{
EnsureFolder(SampleFolder);
var sprites = AssetDatabase.LoadAllAssetsAtPath(SourceTexturePath)
.OfType<Sprite>()
.OrderBy(sprite => ExtractFrameNumber(sprite.name))
.ToArray();
if (sprites.Length < 21)
{
throw new InvalidOperationException(
$"样例源 Texture 需要至少 21 个 Sprite,当前只找到 {sprites.Length} 个。");
}
var introClip = CreateOrLoadAsset<FrameClip>(IntroClipPath);
introClip.Configure(
"Intro",
"Intro",
CreateFrames(sprites, 0, 5),
1f,
FrameClipEndBehavior.HoldLastFrame);
EditorUtility.SetDirty(introClip);
var idleClip = CreateOrLoadAsset<FrameClip>(IdleClipPath);
idleClip.Configure(
"Idle",
"Idle",
CreateFrames(sprites, 5, 16),
1f,
FrameClipEndBehavior.Loop);
EditorUtility.SetDirty(idleClip);
var flowGraph = CreateOrLoadAsset<FrameAnimationGraph>(FlowGraphPath);
var introNodeId = FindExistingNodeId(flowGraph, introClip.Id);
var idleNodeId = FindExistingNodeId(flowGraph, idleClip.Id);
var introNode = new AnimationNode(introClip.Id, "Intro", internalId: introNodeId);
var idleNode = new AnimationNode(
idleClip.Id,
"Idle Loop",
FrameClipEndBehavior.Loop,
internalId: idleNodeId);
var flow = new AnimationFlow("IntroToIdle", "Intro To Idle", introNode.InternalId);
flowGraph.Configure(
"FlowSampleGraph",
"Flow Sample Graph",
new[] { introClip, idleClip },
new[] { introNode, idleNode },
new[] { new AnimationEdge(introNode.InternalId, idleNode.InternalId) },
new[] { flow },
flow.Id);
EditorUtility.SetDirty(flowGraph);
var directGraph = CreateOrLoadAsset<FrameAnimationGraph>(DirectGraphPath);
directGraph.Configure(
"DirectSampleGraph",
"Direct Sample Graph",
new[] { introClip, idleClip },
Array.Empty<AnimationNode>(),
Array.Empty<AnimationEdge>(),
Array.Empty<AnimationFlow>(),
idleClip.Id);
EditorUtility.SetDirty(directGraph);
AssetDatabase.SaveAssets();
BuildScene(flowGraph, directGraph);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log($"Frame Animation runtime sample rebuilt: {ScenePath}");
}
private static IEnumerable<FrameAnimationFrame> CreateFrames(
IReadOnlyList<Sprite> sprites,
int startIndex,
int count)
{
for (var offset = 0; offset < count; offset++)
{
var sourceIndex = startIndex + offset;
var sprite = sprites[sourceIndex];
yield return new FrameAnimationFrame(sprite, 250, sprite.name, sourceIndex);
}
}
private static void BuildScene(
FrameAnimationGraph flowGraph,
FrameAnimationGraph directGraph)
{
var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var cameraObject = new GameObject("Main Camera", typeof(Camera));
cameraObject.tag = "MainCamera";
cameraObject.transform.position = new Vector3(0f, 0f, -10f);
var camera = cameraObject.GetComponent<Camera>();
camera.orthographic = true;
camera.orthographicSize = 6f;
camera.clearFlags = CameraClearFlags.SolidColor;
camera.backgroundColor = new Color(0.08f, 0.09f, 0.12f, 1f);
var spriteObject = new GameObject("Flow Sample - SpriteRenderer", typeof(SpriteRenderer));
spriteObject.transform.position = new Vector3(-3.5f, 0f, 0f);
spriteObject.transform.localScale = Vector3.one * 0.45f;
var spritePlayer = spriteObject.AddComponent<FrameAnimationPlayer>();
spritePlayer.ConfigureForAuthoring(flowGraph, true, 1f);
var canvasObject = new GameObject(
"Direct Clip Sample Canvas",
typeof(Canvas),
typeof(CanvasScaler),
typeof(GraphicRaycaster));
var canvas = canvasObject.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
var scaler = canvasObject.GetComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1280f, 720f);
var imageObject = new GameObject(
"Direct Idle - Image",
typeof(RectTransform),
typeof(CanvasRenderer),
typeof(Image));
imageObject.transform.SetParent(canvasObject.transform, false);
var rectTransform = imageObject.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(384f, 216f);
rectTransform.anchoredPosition = new Vector2(300f, 0f);
var image = imageObject.GetComponent<Image>();
image.preserveAspect = true;
var imagePlayer = imageObject.AddComponent<FrameAnimationPlayer>();
imagePlayer.ConfigureForAuthoring(directGraph, true, 1f);
EditorSceneManager.MarkSceneDirty(scene);
if (!EditorSceneManager.SaveScene(scene, ScenePath))
{
throw new InvalidOperationException($"无法保存样例场景:{ScenePath}");
}
}
private static T CreateOrLoadAsset<T>(string path) where T : ScriptableObject
{
var asset = AssetDatabase.LoadAssetAtPath<T>(path);
if (asset != null)
{
return asset;
}
asset = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(asset, path);
return asset;
}
private static string FindExistingNodeId(FrameAnimationGraph graph, string clipId)
{
var existing = graph.Nodes.FirstOrDefault(node => node != null && node.ClipId == clipId);
return existing != null && FrameAnimationValueUtility.IsValidInternalId(existing.InternalId)
? existing.InternalId
: null;
}
private static int ExtractFrameNumber(string spriteName)
{
if (string.IsNullOrEmpty(spriteName))
{
return int.MaxValue;
}
var spaceIndex = spriteName.LastIndexOf(' ');
var dotIndex = spriteName.LastIndexOf('.');
if (spaceIndex >= 0 && dotIndex > spaceIndex &&
int.TryParse(spriteName.Substring(spaceIndex + 1, dotIndex - spaceIndex - 1), out var number))
{
return number;
}
return int.MaxValue;
}
private static void EnsureFolder(string path)
{
var segments = path.Split('/');
var current = segments[0];
for (var index = 1; index < segments.Length; index++)
{
var next = current + "/" + segments[index];
if (!AssetDatabase.IsValidFolder(next))
{
AssetDatabase.CreateFolder(current, segments[index]);
}
current = next;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 679ccd2bc27e67c468f002c1282b6045
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,455 @@
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 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)
{
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)
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects);
}
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)
{
if (source == null || 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(
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects)
{
var planned = CloneRects(existingRects).ToList();
var originalRects = planned.ToArray();
var assigned = new HashSet<SpriteRect>();
var spriteNamesBySourceIndex = new Dictionary<int, string>();
foreach (var slot in document.SpriteSlots)
{
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(
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects)
{
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
var matches = new Dictionary<int, Sprite>();
foreach (var slot in document.SpriteSlots)
{
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 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(
FrameAnimationImportSource source,
FrameAnimationImportIssueCode code,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
source?.InternalId,
source?.InternalId,
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,60 @@
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);
EditorGUILayout.LabelField(clip.IsImported ? "Imported FrameClip" : "Manual FrameClip", 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);
}
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
EditorGUILayout.HelpBox(
"该外部 Manual Clip 被多个 Graph 共享:" + string.Join(", ", owners.Select(graph => graph.name)),
MessageType.Warning);
}
EditorGUILayout.HelpBox(
"Clip id、帧表和资产所有权操作只能在 Frame Animation Graph Editor 中执行。",
MessageType.Info);
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,78 @@
using AibisDream.FrameAnimation;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEngine;
namespace AibisDream.SystemEditor.Tests
{
public sealed class FrameAnimationActorIntegrationTests
{
private const string PrefabPath =
"Assets/Prefabs/FixSystemPrefabs/FrameAnimationActor.prefab";
[Test]
public void ActorType_AppendsFrameAnimationWithoutChangingExistingValues()
{
Assert.That((int)ActorType.Sprite, Is.EqualTo(0));
Assert.That((int)ActorType.Anima, Is.EqualTo(1));
Assert.That((int)ActorType.AnimaEx, Is.EqualTo(2));
Assert.That((int)ActorType.FrameAnimation, Is.EqualTo(3));
Assert.That(
System.Enum.TryParse("FrameAnimation", out ActorType parsed),
Is.True);
Assert.That(parsed, Is.EqualTo(ActorType.FrameAnimation));
}
[Test]
public void Prefab_HasRequiredComponentsAndPersistentAddress()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.GetComponent<SpriteRenderer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationActor>(), Is.Not.Null);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().PlayOnEnable, Is.False);
Assert.That(prefab.GetComponent<FrameAnimationPlayer>().Graph, Is.Null);
var guid = AssetDatabase.AssetPathToGUID(PrefabPath);
var entry = AddressableAssetSettingsDefaultObject.Settings.FindAssetEntry(guid);
Assert.That(entry, Is.Not.Null);
Assert.That(entry.address, Is.EqualTo(ConstRef.FrameAnimationActorPrefabName));
}
[Test]
public void CaptureEntry_ReusesActorSnapshotFieldsWithoutSchemaChange()
{
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(PrefabPath);
var actorObject = Object.Instantiate(prefab);
var slotObject = new GameObject("Slot");
try
{
var slot = slotObject.AddComponent<ActorSlot>();
slot.slotName = "test-slot";
slot.sortingLayer = 0;
slot.sortingOrder = 17;
var actor = actorObject.GetComponent<FrameAnimationActor>();
actor.Init("TestActor", slot, ActorType.FrameAnimation);
actor.Show();
ActorEntrySnapshotDto entry = actor.CaptureEntry();
Assert.That(entry.actorName, Is.EqualTo("TestActor"));
Assert.That(entry.slotName, Is.EqualTo("test-slot"));
Assert.That(entry.actorType, Is.EqualTo(nameof(ActorType.FrameAnimation)));
Assert.That(entry.alpha, Is.EqualTo(1f));
Assert.That(entry.stateName, Is.Empty);
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(1));
}
finally
{
Object.DestroyImmediate(actorObject);
Object.DestroyImmediate(slotObject);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23aa0707ef5c4618b55c455f2a03a612
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,44 @@
using AibisDream.Utility;
using NUnit.Framework;
using Yarn.Unity;
namespace AibisDream.SystemEditor.Tests
{
public sealed class OptionPromptMetadataTests
{
[Test]
public void IsOptionPromptLine_WithExactMetadata_ReturnsTrue()
{
var line = new LocalizedLine
{
Metadata = new[] { "line:0123456", YarnUtil.OptionPrompt }
};
Assert.That(line.IsOptionPromptLine(), Is.True);
}
[TestCase("option_prompts")]
[TestCase("dream_option_prompt")]
[TestCase("OPTION_PROMPT")]
public void IsOptionPromptLine_WithSimilarMetadata_ReturnsFalse(string metadata)
{
var line = new LocalizedLine
{
Metadata = new[] { metadata }
};
Assert.That(line.IsOptionPromptLine(), Is.False);
}
[Test]
public void IsOptionPromptLine_WithoutMetadata_ReturnsFalse()
{
var line = new LocalizedLine
{
Metadata = null
};
Assert.That(line.IsOptionPromptLine(), Is.False);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d5e819fca9154e979c37ac490184620b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,69 @@
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class PresentationSnapshotContractTests
{
[Test]
public void Providers_UseDistinctStableIdsAndRestoreBeforeScreen()
{
var showcase = new ShowcaseSnapshotProvider();
var playTool = new PlayToolSnapshotProvider();
var screen = new ScreenSnapshotProvider();
Assert.That(showcase.SaveId, Is.EqualTo("showcase"));
Assert.That(playTool.SaveId, Is.EqualTo("playTool"));
Assert.That(showcase.RestoreOrder, Is.LessThan(playTool.RestoreOrder));
Assert.That(playTool.RestoreOrder, Is.LessThan(screen.RestoreOrder));
}
[Test]
public void PresentationSections_RoundTripThroughSnapshotJson()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.PlayTool] = new PlayToolSnapshotDto
{
isObjVisible = true,
objPicName = "证物",
isFullScreenVisible = true,
fullScreenPicName = "教室"
};
source.sections[SnapshotProviderIds.Showcase] = new ShowcaseSnapshotDto
{
displayMode = nameof(ShowcaseDisplayMode.Small),
picName = "D2S轱辘",
usesSplitLayers = true
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var playTool = ((JObject)restored.sections[SnapshotProviderIds.PlayTool])
.ToObject<PlayToolSnapshotDto>();
var showcase = ((JObject)restored.sections[SnapshotProviderIds.Showcase])
.ToObject<ShowcaseSnapshotDto>();
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(showcase.displayMode, Is.EqualTo(nameof(ShowcaseDisplayMode.Small)));
Assert.That(showcase.picName, Is.EqualTo("D2S轱辘"));
Assert.That(showcase.usesSplitLayers, Is.True);
Assert.That(restored.schemaVersion, Is.EqualTo(1));
}
[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.PlayTool), Is.False);
Assert.That(restored.sections.ContainsKey(SnapshotProviderIds.Showcase), Is.False);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: aa21851c46624f77a67e29c86ec854c9
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d4c5a2b82ac1ca459e228aaad3255c6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,513 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AibisDream.SaveSystem;
using AibisDream.Utility;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
namespace AibisDream.EditorTools
{
/// <summary>从本机 testsavs 选择章节、去重排序并提升为可提交测试档。</summary>
public sealed class DevSavePromoteWindow : EditorWindow
{
private readonly List<ArchiveGroup> groups = new();
private readonly List<Candidate> preview = new();
private int selectedGroup;
private string sectionId = string.Empty;
private string sectionTitle = string.Empty;
private string status = string.Empty;
private Vector2 scroll;
[MenuItem("Tools/Aibis/Dev Save Promote")]
public static void Open()
{
var window = GetWindow<DevSavePromoteWindow>("Dev Save Promote");
window.minSize = new Vector2(720f, 520f);
window.Show();
}
private void OnEnable() => RefreshGroups();
private void OnGUI()
{
EditorGUILayout.LabelField("测试存档提升与校验", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"来源是本机 testsavs;目标是可提交、可随 Development Build 分发的 StreamingAssets/TestSaveFiles。",
MessageType.Info);
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("刷新本机归档", GUILayout.Height(26f))) RefreshGroups();
if (GUILayout.Button("校验全部已提交测试档", GUILayout.Height(26f))) ValidateCommittedCatalog();
}
if (groups.Count == 0)
{
EditorGUILayout.HelpBox("本机没有可用 testsavs。", MessageType.Warning);
DrawStatus();
return;
}
var labels = groups.Select(group =>
$"{group.SceneSoName} | {group.YarnProjectId} | {group.SceneName} ({group.Items.Count})").ToArray();
var nextGroup = EditorGUILayout.Popup("章节归档", Mathf.Clamp(selectedGroup, 0, labels.Length - 1), labels);
if (nextGroup != selectedGroup || preview.Count == 0)
{
selectedGroup = nextGroup;
SelectGroup(groups[selectedGroup]);
}
sectionId = EditorGUILayout.TextField("Section Id", sectionId);
sectionTitle = EditorGUILayout.TextField("显示名称", sectionTitle);
EditorGUILayout.LabelField("规则", "每个 Yarn 节点保留 savedAt 最新一份;默认按 savedAt 升序");
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
if (GUILayout.Button("提升当前章节", GUILayout.Width(180f), GUILayout.Height(28f))) Promote();
}
EditorGUILayout.Space(5f);
EditorGUILayout.LabelField($"阶段预览:{preview.Count}", EditorStyles.boldLabel);
scroll = EditorGUILayout.BeginScrollView(scroll);
for (var i = 0; i < preview.Count; i++)
{
var item = preview[i];
using (new EditorGUILayout.HorizontalScope(EditorStyles.helpBox))
{
EditorGUILayout.LabelField($"{i + 1:000}", GUILayout.Width(38f));
item.Label = EditorGUILayout.TextField(item.Label, GUILayout.Width(210f));
EditorGUILayout.LabelField(item.NodeName, GUILayout.MinWidth(180f));
EditorGUILayout.LabelField(item.SavedAt, GUILayout.Width(140f));
using (new EditorGUI.DisabledScope(i == 0))
{
if (GUILayout.Button("↑", GUILayout.Width(26f))) Move(i, i - 1);
}
using (new EditorGUI.DisabledScope(i == preview.Count - 1))
{
if (GUILayout.Button("↓", GUILayout.Width(26f))) Move(i, i + 1);
}
}
}
EditorGUILayout.EndScrollView();
DrawStatus();
}
private void DrawStatus()
{
if (!string.IsNullOrWhiteSpace(status))
EditorGUILayout.HelpBox(status, status.StartsWith("OK", StringComparison.Ordinal) ? MessageType.Info : MessageType.Warning);
}
private void RefreshGroups()
{
groups.Clear();
preview.Clear();
var root = ConstRef.TestAutoSaveArchivePath;
if (!Directory.Exists(root))
{
status = $"找不到本机归档:{root}";
return;
}
var candidates = new List<Candidate>();
foreach (var directory in Directory.GetDirectories(root))
{
var snapshotPath = Path.Combine(directory, $"{ConstRef.SaveSnapshotFileName}.json");
if (!File.Exists(snapshotPath)) continue;
try
{
var snapshot = SnapshotPersistence.Load(Path.Combine(directory, ConstRef.SaveSnapshotFileName));
if (snapshot?.anchor == null || string.IsNullOrWhiteSpace(snapshot.anchor.nodeName)) continue;
candidates.Add(new Candidate(
snapshotPath,
Path.Combine(directory, $"{ConstRef.SaveMetaFileName}.json"),
snapshot.scene?.sceneName,
snapshot.anchor.sceneSoName,
snapshot.anchor.yarnProjectId,
snapshot.anchor.nodeName,
snapshot.savedAt));
}
catch (Exception ex)
{
Debug.LogWarning($"[DevSavePromote] 跳过损坏归档 {snapshotPath}: {ex.Message}");
}
}
groups.AddRange(candidates
.GroupBy(item => $"{item.SceneName}\n{item.SceneSoName}\n{item.YarnProjectId}", StringComparer.Ordinal)
.Select(group => new ArchiveGroup(group.ToList()))
.OrderBy(group => group.SceneSoName, StringComparer.Ordinal));
selectedGroup = Mathf.Clamp(selectedGroup, 0, Math.Max(0, groups.Count - 1));
if (groups.Count > 0) SelectGroup(groups[selectedGroup]);
status = $"OK:读取 {candidates.Count} 份归档,分为 {groups.Count} 个章节。";
}
private void SelectGroup(ArchiveGroup group)
{
preview.Clear();
preview.AddRange(group.Items
.GroupBy(item => item.NodeName, StringComparer.Ordinal)
.Select(nodes => nodes.OrderByDescending(item => item.SavedAt, StringComparer.Ordinal).First())
.OrderBy(item => item.SavedAt, StringComparer.Ordinal));
sectionId = SanitizeIdentifier(group.SceneSoName);
sectionTitle = group.SceneSoName;
}
private void Move(int from, int to)
{
if (from < 0 || to < 0 || from >= preview.Count || to >= preview.Count) return;
var item = preview[from];
preview.RemoveAt(from);
preview.Insert(to, item);
GUI.FocusControl(null);
}
private void Promote()
{
if (preview.Count == 0 || string.IsNullOrWhiteSpace(sectionId))
{
status = "没有可提升阶段,或 Section Id 为空。";
return;
}
var validation = ValidateCandidates(preview);
if (validation.Count > 0)
{
status = string.Join("\n", validation);
return;
}
var group = groups[selectedGroup];
var root = ConstRef.TestSaveFilePath;
var targetSection = Path.Combine(root, sectionId);
if (Directory.Exists(targetSection)) Directory.Delete(targetSection, true);
Directory.CreateDirectory(targetSection);
var catalog = LoadCatalogForWrite();
catalog.sections.RemoveAll(section => string.Equals(section.id, sectionId, StringComparison.OrdinalIgnoreCase));
var sectionDto = new DevSaveCatalogSectionDto
{
id = sectionId,
title = string.IsNullOrWhiteSpace(sectionTitle) ? sectionId : sectionTitle,
expectedSceneName = group.SceneName,
expectedSceneSoName = group.SceneSoName,
expectedYarnProjectId = group.YarnProjectId
};
for (var i = 0; i < preview.Count; i++)
{
var item = preview[i];
var folder = $"{i + 1:000}_{SanitizeIdentifier(item.NodeName)}";
var destination = Path.Combine(targetSection, folder);
Directory.CreateDirectory(destination);
File.Copy(item.SnapshotPath, Path.Combine(destination, $"{ConstRef.SaveSnapshotFileName}.json"), true);
if (File.Exists(item.MetaPath))
File.Copy(item.MetaPath, Path.Combine(destination, $"{ConstRef.SaveMetaFileName}.json"), true);
sectionDto.entries.Add(new DevSaveCatalogEntryDto
{
order = i + 1,
label = string.IsNullOrWhiteSpace(item.Label) ? item.NodeName : item.Label,
path = $"{sectionId}/{folder}",
anchorNode = item.NodeName
});
}
catalog.sections.Add(sectionDto);
WriteCatalog(catalog);
AssetDatabase.Refresh();
ValidateCommittedCatalog();
}
private static List<string> ValidateCandidates(IEnumerable<Candidate> candidates)
{
var errors = new List<string>();
var nodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in candidates)
{
if (!nodes.Add(item.NodeName)) errors.Add($"重复节点:{item.NodeName}");
try
{
var snapshot = SnapshotPersistence.Load(Path.Combine(Path.GetDirectoryName(item.SnapshotPath)!, ConstRef.SaveSnapshotFileName));
if (snapshot.schemaVersion != SaveSnapshotSchema.CurrentVersion)
errors.Add($"{item.NodeName}: schema {snapshot.schemaVersion} 不兼容");
}
catch (Exception ex)
{
errors.Add($"{item.NodeName}: {ex.Message}");
}
}
return errors;
}
private void ValidateCommittedCatalog()
{
var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
var errors = new List<string>();
if (!string.IsNullOrWhiteSpace(DevSaveCatalog.LastError)) errors.Add(DevSaveCatalog.LastError);
foreach (var section in sections)
{
var orders = new HashSet<int>();
var nodes = new HashSet<string>(StringComparer.Ordinal);
foreach (var entry in section.Entries)
{
if (!orders.Add(entry.Order)) errors.Add($"{section.Id}: 重复 order {entry.Order}");
if (!nodes.Add(entry.AnchorNode)) errors.Add($"{section.Id}: 重复节点 {entry.AnchorNode}");
if (!entry.IsValid) errors.Add($"{section.Id}/{entry.Label}: {entry.Error}");
}
}
var dto = LoadCatalogForWrite();
foreach (var section in dto.sections)
{
if (!SceneAddressExists(section.expectedSceneName))
errors.Add($"{section.id}: Addressable 场景不存在 {section.expectedSceneName}");
var sceneSo = FindTalkSceneSo(section.expectedSceneSoName);
if (sceneSo == null)
errors.Add($"{section.id}: TalkSceneSO 不存在 {section.expectedSceneSoName}");
else if (sceneSo.yarnProject == null || sceneSo.yarnProject.name != section.expectedYarnProjectId)
errors.Add($"{section.id}: TalkSceneSO 的 YarnProject 不匹配 {section.expectedYarnProjectId}");
else
{
var nodeNames = new HashSet<string>(sceneSo.yarnProject.NodeNames, StringComparer.Ordinal);
foreach (var entry in section.entries.Where(entry => !nodeNames.Contains(entry.anchorNode)))
errors.Add($"{section.id}: Yarn 节点不存在 {entry.anchorNode}");
}
}
status = errors.Count == 0
? $"OK{sections.Count} 个章节、{sections.Sum(s => s.Entries.Count)} 个阶段全部通过校验。"
: $"校验失败({errors.Count}):\n{string.Join("\n", errors)}";
if (errors.Count > 0) Debug.LogError($"[DevSavePromote] {status}");
else Debug.Log($"[DevSavePromote] {status}");
}
private static bool SceneAddressExists(string address)
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
return settings != null && settings.groups
.Where(group => group != null)
.SelectMany(group => group.entries)
.Any(entry => string.Equals(entry.address, address, StringComparison.Ordinal));
}
private static TalkSceneSO FindTalkSceneSo(string assetName)
{
foreach (var guid in AssetDatabase.FindAssets($"t:TalkSceneSO {assetName}"))
{
var asset = AssetDatabase.LoadAssetAtPath<TalkSceneSO>(AssetDatabase.GUIDToAssetPath(guid));
if (asset != null && asset.name == assetName) return asset;
}
return null;
}
private static DevSaveCatalogDto LoadCatalogForWrite()
{
var path = Path.Combine(ConstRef.TestSaveFilePath, "catalog.json");
if (!File.Exists(path)) return new DevSaveCatalogDto();
try
{
return JsonConvert.DeserializeObject<DevSaveCatalogDto>(File.ReadAllText(path, Encoding.UTF8))
?? new DevSaveCatalogDto();
}
catch
{
return new DevSaveCatalogDto();
}
}
private static void WriteCatalog(DevSaveCatalogDto catalog)
{
Directory.CreateDirectory(ConstRef.TestSaveFilePath);
File.WriteAllText(
Path.Combine(ConstRef.TestSaveFilePath, "catalog.json"),
JsonConvert.SerializeObject(catalog, Formatting.Indented),
new UTF8Encoding(false));
}
private static string SanitizeIdentifier(string value)
{
if (string.IsNullOrWhiteSpace(value)) return "unnamed";
foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_');
value = value.Replace(' ', '_');
return value.Length <= 48 ? value : value.Substring(0, 48);
}
private sealed class ArchiveGroup
{
public readonly List<Candidate> Items;
public string SceneName => Items[0].SceneName;
public string SceneSoName => Items[0].SceneSoName;
public string YarnProjectId => Items[0].YarnProjectId;
public ArchiveGroup(List<Candidate> items) => Items = items;
}
private sealed class Candidate
{
public readonly string SnapshotPath;
public readonly string MetaPath;
public readonly string SceneName;
public readonly string SceneSoName;
public readonly string YarnProjectId;
public readonly string NodeName;
public readonly string SavedAt;
public string Label;
public Candidate(string snapshotPath, string metaPath, string sceneName, string sceneSoName,
string yarnProjectId, string nodeName, string savedAt)
{
SnapshotPath = snapshotPath;
MetaPath = metaPath;
SceneName = sceneName;
SceneSoName = sceneSoName;
YarnProjectId = yarnProjectId;
NodeName = nodeName;
SavedAt = savedAt ?? string.Empty;
Label = nodeName;
}
}
}
[TestFixture]
public sealed class DevSaveCatalogTests
{
private string root;
[SetUp]
public void SetUp()
{
root = Path.Combine(Path.GetTempPath(), $"aibis-dev-save-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(root)) Directory.Delete(root, true);
}
[Test]
public void CatalogUsesExplicitOrderAndKeepsInvalidEntriesVisible()
{
WriteCatalog(new DevSaveCatalogEntryDto { order = 20, label = "late", path = "S/late", anchorNode = "Late" },
new DevSaveCatalogEntryDto { order = 10, label = "early", path = "S/early", anchorNode = "Early" });
var section = DevSaveCatalog.Load(root).Single();
Assert.That(section.Entries.Select(entry => entry.Label), Is.EqualTo(new[] { "early", "late" }));
Assert.That(section.Entries.All(entry => !entry.IsValid), Is.True);
Assert.That(section.Entries.All(entry => entry.Error.Contains("缺失")), Is.True);
}
[Test]
public void CatalogRejectsPathTraversal()
{
WriteCatalog(new DevSaveCatalogEntryDto { order = 1, label = "unsafe", path = "../outside", anchorNode = "Start" });
var entry = DevSaveCatalog.Load(root).Single().Entries.Single();
Assert.That(entry.IsValid, Is.False);
Assert.That(entry.Error, Does.Contain("越过"));
}
[Test]
public void CatalogReportsCorruptSchemaAndAnchorMismatch()
{
WriteSnapshot("S/corrupt", "{");
WriteSnapshot("S/schema", JsonConvert.SerializeObject(BuildSnapshot("Node", schema: 99)));
WriteSnapshot("S/node", JsonConvert.SerializeObject(BuildSnapshot("Actual")));
WriteCatalog(
new DevSaveCatalogEntryDto { order = 1, label = "corrupt", path = "S/corrupt", anchorNode = "Node" },
new DevSaveCatalogEntryDto { order = 2, label = "schema", path = "S/schema", anchorNode = "Node" },
new DevSaveCatalogEntryDto { order = 3, label = "node", path = "S/node", anchorNode = "Expected" });
var entries = DevSaveCatalog.Load(root).Single().Entries;
Assert.That(entries[0].Error, Does.Contain("JSON"));
Assert.That(entries[1].Error, Does.Contain("版本"));
Assert.That(entries[2].Error, Does.Contain("节点不匹配"));
}
[Test]
public void CommittedCatalogContainsOnlyValidUniqueEntries()
{
var sections = DevSaveCatalog.Load(ConstRef.TestSaveFilePath);
Assert.That(DevSaveCatalog.LastError, Is.Null);
Assert.That(sections.Count, Is.GreaterThan(0));
foreach (var section in sections)
{
Assert.That(section.Entries, Is.Not.Empty, section.Id);
Assert.That(section.Entries.All(entry => entry.IsValid), Is.True,
string.Join("\n", section.Entries.Where(entry => !entry.IsValid).Select(entry => $"{entry.Label}: {entry.Error}")));
Assert.That(section.Entries.Select(entry => entry.Order).Distinct().Count(), Is.EqualTo(section.Entries.Count));
Assert.That(section.Entries.Select(entry => entry.AnchorNode).Distinct().Count(), Is.EqualTo(section.Entries.Count));
}
}
[Test]
public void StrictRestoreContextTracksPhaseWarningsAndErrors()
{
var context = new SnapshotRestoreContext(new SaveSnapshot(), strictMode: true);
context.SetPhase("Provider restore");
context.Warn("optional state missing");
context.Error("required state missing");
Assert.That(context.StrictMode, Is.True);
Assert.That(context.CurrentPhase, Is.EqualTo("Provider restore"));
Assert.That(context.Warnings, Is.EqualTo(new[] { "optional state missing" }));
Assert.That(context.Errors, Is.EqualTo(new[] { "required state missing" }));
Assert.That(context.HasErrors, Is.True);
}
private void WriteCatalog(params DevSaveCatalogEntryDto[] entries)
{
var catalog = new DevSaveCatalogDto
{
sections = new List<DevSaveCatalogSectionDto>
{
new()
{
id = "S",
title = "Section",
expectedSceneName = "Scene/Test",
expectedSceneSoName = "TestSO",
expectedYarnProjectId = "TestYarn",
entries = entries.ToList()
}
}
};
File.WriteAllText(Path.Combine(root, "catalog.json"), JsonConvert.SerializeObject(catalog));
}
private void WriteSnapshot(string relativeDirectory, string json)
{
var directory = Path.Combine(root, relativeDirectory.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(directory);
File.WriteAllText(Path.Combine(directory, "snapshot.json"), json);
}
private static SaveSnapshot BuildSnapshot(string node, int schema = SaveSnapshotSchema.CurrentVersion)
{
return new SaveSnapshot
{
schemaVersion = schema,
scene = new SceneSnapshotDto { sceneName = "Scene/Test" },
anchor = new AnchorSnapshot
{
sceneSoName = "TestSO",
yarnProjectId = "TestYarn",
nodeName = node
}
};
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b564a87c642e53646baa39708c8f72ac
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,669 @@
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.TryRestoreSlot(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.TryRestoreFile(
entry.SnapshotPath,
RestoreOptions.DevJump);
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();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 27c49345ec3a4e849af3f9da7045f7a3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,704 @@
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(
"触发 GameManager.TryRestoreSlot,验证预检、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.Session.IsPaused)
{
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.TryRestoreSlot(slotIndex);
Log($"已启动 Restore slot_{slotIndex}。请观察 Restore Pipeline Log 与场景状态。");
}
private void OpenSaveDirectory()
{
var path = ConstRef.SaveFilePath;
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
EditorUtility.RevealInFinder(path);
}
#endregion
#region Helpers
private void PrintSnapshotSummary(SaveSnapshot snapshot, string label)
{
var json = SnapshotPersistence.Serialize(snapshot);
var lines = new List<string>
{
$"=== {label} ===",
$"gameVersion: {snapshot.gameVersion}",
$"savedAt: {snapshot.savedAt}",
$"schemaVersion: {snapshot.schemaVersion}",
$"scene: {snapshot.scene?.sceneName}",
$"anchor.sceneSoName: {snapshot.anchor?.sceneSoName}",
$"anchor.yarnProjectId: {snapshot.anchor?.yarnProjectId}",
$"anchor.nodeName: {snapshot.anchor?.nodeName}",
$"yarnVariables.floats: {snapshot.yarnVariables?.floats?.Count ?? 0} entries",
$"yarnVariables.strings: {snapshot.yarnVariables?.strings?.Count ?? 0} entries",
$"yarnVariables.bools: {snapshot.yarnVariables?.bools?.Count ?? 0} entries",
$"sections: {(snapshot.sections != null ? string.Join(", ", snapshot.sections.Keys) : "null")}",
};
if (snapshot.sections != null)
{
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.PunchTape);
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Fix);
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.FixPanel);
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.BodyModule);
AppendFixSectionHint(lines, snapshot, SnapshotProviderIds.Eye);
}
lines.Add("JSON preview (first 1500 chars):");
lines.Add(json.Length > 1500 ? json.Substring(0, 1500) + "..." : json);
Log(string.Join("\n", lines));
}
private static void AppendFixSectionHint(List<string> lines, SaveSnapshot snapshot, string sectionId)
{
if (snapshot.sections.ContainsKey(sectionId))
{
lines.Add($" [fix-related] {sectionId}: captured");
}
}
private void Log(string message)
{
_lastLog = $"[{DateTime.Now:HH:mm:ss}] {message}";
Debug.Log($"[SaveSystemValidator] {message}");
Repaint();
}
#endregion
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9719ab6f265683c4484a170f6c4dc843
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -2,6 +2,7 @@ using System;
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEngine;
public class VersionIncrementer : IPreprocessBuildWithReport
{
@@ -23,5 +24,12 @@ public class VersionIncrementer : IPreprocessBuildWithReport
// 更新版本号
string date = DateTime.Now.ToString("yyyyMMdd-HHmmss");
PlayerSettings.bundleVersion = $"{major}.{minor}.{revision}.{build + 1}-{date}";
if (report.summary.platform == BuildTarget.Android)
{
int versionCode = PlayerSettings.Android.bundleVersionCode;
PlayerSettings.Android.bundleVersionCode = versionCode + 1;
Debug.Log($"[VersionIncrementer] Android versionCode: {versionCode} -> {versionCode + 1}");
}
}
}
@@ -74,8 +74,6 @@ namespace AibisDream.SystemEditor
public AsepriteSize size;
public string scale;
public List<AsepriteFrameTag> frameTags;
public List<object> layers;
public List<object> slices;
}
// Aseprite JSON的包装类(因为Unity JsonUtility不支持Dictionary
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.U2D.Sprites;
using UnityEngine;
@@ -57,8 +58,7 @@ namespace AibisDream.SystemEditor
int metaBraceEnd = FindMatchingBrace(jsonContent, metaBraceStart);
string metaJson = jsonContent.Substring(metaBraceStart, metaBraceEnd - metaBraceStart + 1);
// 移除可能的尾随逗号
metaJson = metaJson.TrimEnd(',', ' ', '\n', '\r');
metaJson = SanitizeJsonForUnity(metaJson);
AsepriteMeta meta = JsonUtility.FromJson<AsepriteMeta>(metaJson);
@@ -173,7 +173,7 @@ namespace AibisDream.SystemEditor
try
{
AsepriteFrameInfo frameInfo = JsonUtility.FromJson<AsepriteFrameInfo>(frameDataJson);
AsepriteFrameInfo frameInfo = JsonUtility.FromJson<AsepriteFrameInfo>(SanitizeJsonForUnity(frameDataJson));
if (frameInfo.frame != null)
{
frames.Add(new FrameData
@@ -215,6 +215,14 @@ namespace AibisDream.SystemEditor
return int.MaxValue; // 如果无法解析,放在最后
}
/// <summary>
/// Unity JsonUtility 不支持尾随逗号,Aseprite 导出时偶尔会带上。
/// </summary>
private static string SanitizeJsonForUnity(string json)
{
return Regex.Replace(json, @",(\s*[}\]])", "$1");
}
private static int FindMatchingBrace(string str, int startIndex)
{
if (startIndex < 0 || startIndex >= str.Length) return -1;
@@ -536,10 +544,10 @@ namespace AibisDream.SystemEditor
// 强制更新动画长度 (m_StopTime) - 修复复用已有 clip 时长度未更新的问题
float totalDuration = keyframes.Count > 0 ? keyframes[keyframes.Count - 1].time : 0f;
SerializedObject serializedClip = new SerializedObject(clip);
SerializedProperty stopTimeProp = serializedClip.FindProperty("m_StopTime");
if (stopTimeProp != null)
SerializedProperty nestedStopTimeProp = serializedClip.FindProperty("m_AnimationClipSettings.m_StopTime");
if (nestedStopTimeProp != null)
{
stopTimeProp.floatValue = totalDuration;
nestedStopTimeProp.floatValue = totalDuration;
serializedClip.ApplyModifiedProperties();
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3fe2278382cb87e4993d86ca5ed00e5f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public enum YarnL10nIssueType
{
MissingLocalization,
OrphanLocalization,
SourceTextChanged,
CompileError,
UntaggedLine,
CsvMissing,
InvalidProject,
NoLocalizationConfigured,
}
[Serializable]
public sealed class YarnL10nIssue
{
public YarnL10nIssueType Type;
public string Language;
public string LineId;
public string SourceText;
public string LocText;
public string YarnFile;
public string YarnLineNumber;
public string CsvFile;
public string ChapterFolder;
public string Message;
public string IssueTypeLabel => GetDisplayType();
public string GetDisplayType()
{
return Type switch
{
YarnL10nIssueType.MissingLocalization => $"缺少本地化-{Language}",
YarnL10nIssueType.OrphanLocalization => $"找不到原文-{Language}",
YarnL10nIssueType.SourceTextChanged => $"原文已变更-{Language}",
YarnL10nIssueType.CompileError => "编译错误",
YarnL10nIssueType.UntaggedLine => "未打line标签",
YarnL10nIssueType.CsvMissing => $"CSV缺失-{Language}",
YarnL10nIssueType.InvalidProject => "项目无效",
YarnL10nIssueType.NoLocalizationConfigured => "未配置本地化",
_ => Type.ToString(),
};
}
}
[Serializable]
public sealed class YarnL10nChapterSummary
{
public string ChapterFolder;
public string YarnProjectPath;
public int SourceLineCount;
public Dictionary<string, YarnL10nLocaleSummary> Locales = new();
public int IssueCount;
}
[Serializable]
public sealed class YarnL10nLocaleSummary
{
public string Language;
public int MissingCount;
public int OrphanCount;
public int SourceChangedCount;
}
[Serializable]
public sealed class YarnL10nValidationResult
{
public List<YarnL10nIssue> Issues = new();
public List<YarnL10nChapterSummary> Chapters = new();
public int TotalIssueCount;
public bool HasIssues => TotalIssueCount > 0;
public bool HasBlockingIssues
{
get
{
foreach (var issue in Issues)
{
switch (issue.Type)
{
case YarnL10nIssueType.MissingLocalization:
case YarnL10nIssueType.OrphanLocalization:
case YarnL10nIssueType.SourceTextChanged:
case YarnL10nIssueType.CompileError:
case YarnL10nIssueType.CsvMissing:
case YarnL10nIssueType.InvalidProject:
return true;
}
}
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 340d8f4fbf1fb8a458b87a164afd827b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nReportExporter
{
public static void ExportToCsv(YarnL10nValidationResult result, string outputPath)
{
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var builder = new StringBuilder();
builder.AppendLine("issueType,language,lineId,sourceText,locText,yarnFile,yarnLineNumber,csvFile,chapterFolder,message");
foreach (var issue in result.Issues)
{
builder.AppendLine(string.Join(",",
Escape(issue.GetDisplayType()),
Escape(issue.Language),
Escape(issue.LineId),
Escape(issue.SourceText),
Escape(issue.LocText),
Escape(issue.YarnFile),
Escape(issue.YarnLineNumber),
Escape(issue.CsvFile),
Escape(issue.ChapterFolder),
Escape(issue.Message)));
}
File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}
public static string BuildDefaultExportFileName(string chapterFolder)
{
var chapterName = string.IsNullOrEmpty(chapterFolder)
? "yarn_l10n"
: Path.GetFileName(chapterFolder.Replace('/', Path.DirectorySeparatorChar));
return $"{chapterName}_l10n_report_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv";
}
private static string Escape(string value)
{
value ??= string.Empty;
if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ab3c79927b366e459587175b8325dd6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,471 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public class YarnL10nValidationWindow : EditorWindow
{
private const string MenuPath = "AIBIS/Yarn 本地化校验";
private const string WindowTitle = "Yarn Localization Validator";
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
private string _folderPath = "Assets/Yarn/FP/FP_Day1_mid";
private DefaultAsset _folderAsset;
private bool _scanSubdirectories;
private Vector2 _scrollPosition;
private YarnL10nValidationResult _lastResult;
private string _filterType = "全部";
private string _filterLanguage = "全部";
private string _searchText = string.Empty;
private string[] _typeOptions = { "全部" };
private string[] _languageOptions = { "全部" };
private int _filterTypeIndex;
private int _filterLanguageIndex;
[MenuItem(MenuPath)]
public static void Open()
{
var window = GetWindow<YarnL10nValidationWindow>(WindowTitle);
window.minSize = new Vector2(640, 480);
}
private void OnEnable()
{
_folderPath = EditorPrefs.GetString(FolderPathPrefKey, _folderPath);
_scanSubdirectories = EditorPrefs.GetBool(ScanSubdirsPrefKey, false);
SyncFolderAssetFromPath();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Yarn 本地化校验", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
DrawFolderSelector();
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("校验", GUILayout.Height(28)))
{
RunValidation();
}
using (new EditorGUI.DisabledScope(_lastResult == null || _lastResult.Issues.Count == 0))
{
if (GUILayout.Button("导出 CSV", GUILayout.Height(28)))
{
ExportReport();
}
}
if (GUILayout.Button("定位文件夹", GUILayout.Height(28)))
{
PingFolder();
}
EditorGUILayout.EndHorizontal();
_scanSubdirectories = EditorGUILayout.ToggleLeft("扫描子目录(批量)", _scanSubdirectories);
if (GUI.changed)
{
EditorPrefs.SetBool(ScanSubdirsPrefKey, _scanSubdirectories);
}
EditorGUILayout.Space(6);
DrawSummary();
DrawFilters();
DrawIssueList();
}
private void DrawFolderSelector()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("章节文件夹", GUILayout.Width(80));
var newAsset = (DefaultAsset)EditorGUILayout.ObjectField(_folderAsset, typeof(DefaultAsset), false);
if (newAsset != _folderAsset)
{
_folderAsset = newAsset;
if (_folderAsset != null)
{
_folderPath = AssetDatabase.GetAssetPath(_folderAsset);
SaveFolderPreferences();
}
}
var newPath = EditorGUILayout.TextField(_folderPath);
if (!string.Equals(newPath, _folderPath, StringComparison.Ordinal))
{
_folderPath = newPath;
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
if (GUILayout.Button("浏览", GUILayout.Width(60)))
{
var startDirectory = GetBrowseStartDirectory();
var selected = EditorUtility.OpenFolderPanel("选择 Yarn 章节文件夹", startDirectory, string.Empty);
if (!string.IsNullOrEmpty(selected))
{
_folderPath = YarnL10nValidator.NormalizeProjectRelativePath(selected);
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
}
EditorGUILayout.EndHorizontal();
}
private void SaveFolderPreferences()
{
EditorPrefs.SetString(FolderPathPrefKey, _folderPath);
}
private string GetBrowseStartDirectory()
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(_folderPath);
if (!string.IsNullOrEmpty(absolutePath) && !IsFilesystemRoot(absolutePath))
{
var parent = Directory.GetParent(absolutePath);
if (parent != null)
{
return parent.FullName;
}
}
if (!string.IsNullOrEmpty(absolutePath) && Directory.Exists(absolutePath))
{
return absolutePath;
}
var assetsPath = Application.dataPath;
return Directory.Exists(assetsPath) ? assetsPath : Environment.CurrentDirectory;
}
private static bool IsFilesystemRoot(string absolutePath)
{
var normalized = Path.GetFullPath(absolutePath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.GetPathRoot(normalized)?
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return !string.IsNullOrEmpty(root) &&
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
}
private void SyncFolderAssetFromPath()
{
_folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(_folderPath);
}
private void RunValidation()
{
SaveFolderPreferences();
_lastResult = _scanSubdirectories
? YarnL10nValidator.ValidateScanAll(_folderPath)
: YarnL10nValidator.ValidateFolder(_folderPath);
RefreshFilterOptions();
Repaint();
}
private void RefreshFilterOptions()
{
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
_typeOptions = new[] { "全部" };
_languageOptions = new[] { "全部" };
}
else
{
var types = _lastResult.Issues
.Select(issue => issue.IssueTypeLabel)
.Where(label => !string.IsNullOrEmpty(label))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(label => label, StringComparer.OrdinalIgnoreCase)
.ToList();
types.Insert(0, "全部");
_typeOptions = types.ToArray();
var languages = _lastResult.Issues
.Select(issue => issue.Language)
.Where(language => !string.IsNullOrEmpty(language))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(language => language, StringComparer.OrdinalIgnoreCase)
.ToList();
languages.Insert(0, "全部");
_languageOptions = languages.ToArray();
}
_filterTypeIndex = ClampFilterIndex(_filterType, _typeOptions, ref _filterType);
_filterLanguageIndex = ClampFilterIndex(_filterLanguage, _languageOptions, ref _filterLanguage);
}
private static int ClampFilterIndex(string currentValue, string[] options, ref string selectedValue)
{
if (options.Length == 0)
{
selectedValue = "全部";
return 0;
}
for (var i = 0; i < options.Length; i++)
{
if (string.Equals(options[i], currentValue, StringComparison.OrdinalIgnoreCase))
{
selectedValue = options[i];
return i;
}
}
selectedValue = options[0];
return 0;
}
private void ExportReport()
{
var defaultName = YarnL10nReportExporter.BuildDefaultExportFileName(_folderPath);
var savePath = EditorUtility.SaveFilePanel("导出校验报告", Application.dataPath, defaultName, "csv");
if (string.IsNullOrEmpty(savePath))
{
return;
}
YarnL10nReportExporter.ExportToCsv(_lastResult, savePath);
EditorUtility.RevealInFinder(savePath);
}
private void PingFolder()
{
SyncFolderAssetFromPath();
if (_folderAsset == null)
{
EditorUtility.DisplayDialog("Yarn 本地化校验", "无法定位文件夹,请检查路径。", "确定");
return;
}
EditorGUIUtility.PingObject(_folderAsset);
Selection.activeObject = _folderAsset;
}
private void DrawSummary()
{
EditorGUILayout.LabelField("摘要", EditorStyles.boldLabel);
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
if (_lastResult == null)
{
EditorGUILayout.LabelField("尚未执行校验。");
return;
}
EditorGUILayout.LabelField($"Issue 总数: {_lastResult.TotalIssueCount}");
EditorGUILayout.LabelField($"章节数: {_lastResult.Chapters.Count}");
foreach (var chapter in _lastResult.Chapters)
{
var localeParts = chapter.Locales.Values
.Select(locale => $"{locale.Language}: 缺 {locale.MissingCount} / 多余 {locale.OrphanCount} / 变更 {locale.SourceChangedCount}")
.ToArray();
var localeSummary = localeParts.Length > 0 ? string.Join(" | ", localeParts) : "无翻译语言";
EditorGUILayout.LabelField($"{chapter.ChapterFolder} — 原文 {chapter.SourceLineCount} 行 | {localeSummary}");
}
}
EditorGUILayout.Space(6);
}
private void DrawFilters()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("类型", GUILayout.Width(40));
_filterTypeIndex = EditorGUILayout.Popup(_filterTypeIndex, _typeOptions, GUILayout.Width(180));
_filterType = _typeOptions[_filterTypeIndex];
EditorGUILayout.LabelField("语言", GUILayout.Width(40));
_filterLanguageIndex = EditorGUILayout.Popup(_filterLanguageIndex, _languageOptions, GUILayout.Width(100));
_filterLanguage = _languageOptions[_filterLanguageIndex];
EditorGUILayout.LabelField("搜索", GUILayout.Width(40));
_searchText = EditorGUILayout.TextField(_searchText);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
}
private void DrawIssueList()
{
EditorGUILayout.LabelField("结果列表", EditorStyles.boldLabel);
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
EditorGUILayout.LabelField(_lastResult == null ? "无结果。" : "未发现 issue。");
}
else
{
var index = 0;
foreach (var issue in FilterIssues(_lastResult.Issues))
{
DrawIssueRow(issue, index);
index++;
}
}
EditorGUILayout.EndScrollView();
}
private IEnumerable<YarnL10nIssue> FilterIssues(IEnumerable<YarnL10nIssue> issues)
{
foreach (var issue in issues)
{
if (!MatchesFilter(issue))
{
continue;
}
yield return issue;
}
}
private bool MatchesFilter(YarnL10nIssue issue)
{
if (!string.IsNullOrWhiteSpace(_filterType) &&
!string.Equals(_filterType, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.IssueTypeLabel, _filterType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(_filterLanguage) &&
!string.Equals(_filterLanguage, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.Language, _filterLanguage, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(_searchText))
{
return true;
}
var haystack = string.Join(" ",
issue.GetDisplayType(),
issue.Language,
issue.LineId,
issue.SourceText,
issue.LocText,
issue.YarnFile,
issue.ChapterFolder,
issue.Message);
return haystack.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0;
}
private void DrawIssueRow(YarnL10nIssue issue, int index)
{
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.LabelField($"{issue.GetDisplayType()} | {issue.Language} | {issue.LineId}", EditorStyles.boldLabel);
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
EditorGUILayout.LabelField("章节", issue.ChapterFolder);
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
EditorGUILayout.LabelField("源文件", $"{issue.YarnFile}:{issue.YarnLineNumber}");
}
if (!string.IsNullOrEmpty(issue.CsvFile))
{
EditorGUILayout.LabelField("CSV", issue.CsvFile);
}
if (!string.IsNullOrEmpty(issue.SourceText))
{
EditorGUILayout.LabelField("原文", issue.SourceText);
}
if (!string.IsNullOrEmpty(issue.LocText))
{
EditorGUILayout.LabelField("翻译", issue.LocText);
}
if (!string.IsNullOrEmpty(issue.Message))
{
EditorGUILayout.LabelField("说明", issue.Message);
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("定位", GUILayout.Width(80)))
{
PingIssue(issue);
}
EditorGUILayout.EndHorizontal();
}
}
private void PingIssue(YarnL10nIssue issue)
{
if (issue.Type == YarnL10nIssueType.OrphanLocalization && !string.IsNullOrEmpty(issue.CsvFile))
{
var csvAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(issue.CsvFile);
if (csvAsset != null)
{
EditorGUIUtility.PingObject(csvAsset);
Selection.activeObject = csvAsset;
return;
}
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
var yarnPath = ResolveYarnAssetPath(issue);
var yarnAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(yarnPath);
if (yarnAsset != null)
{
EditorGUIUtility.PingObject(yarnAsset);
Selection.activeObject = yarnAsset;
if (int.TryParse(issue.YarnLineNumber, out var lineNumber) && lineNumber > 0)
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(yarnPath);
InternalEditorUtility.OpenFileAtLineExternal(absolutePath, lineNumber);
}
return;
}
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
var folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(issue.ChapterFolder);
if (folderAsset != null)
{
EditorGUIUtility.PingObject(folderAsset);
Selection.activeObject = folderAsset;
}
}
}
private static string ResolveYarnAssetPath(YarnL10nIssue issue)
{
if (issue.YarnFile.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return issue.YarnFile;
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
return $"{issue.ChapterFolder}/{issue.YarnFile}";
}
return issue.YarnFile;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fe514a9bb460dd74e8b0ec7e1ae7cf98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Yarn.Compiler;
using Yarn.Unity;
using Yarn.Unity.Editor;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nValidator
{
public static YarnL10nValidationResult ValidateFolder(string folderPath)
{
var result = new YarnL10nValidationResult();
if (string.IsNullOrWhiteSpace(folderPath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: folderPath,
message: "章节文件夹路径为空。"));
FinalizeResult(result);
return result;
}
var normalizedFolder = NormalizeProjectRelativePath(folderPath);
var absoluteFolder = ToAbsolutePath(normalizedFolder);
if (!Directory.Exists(absoluteFolder))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: $"文件夹不存在: {normalizedFolder}"));
FinalizeResult(result);
return result;
}
var yarnProjects = Directory.GetFiles(absoluteFolder, "*.yarnproject", SearchOption.TopDirectoryOnly);
if (yarnProjects.Length == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: "文件夹内未找到 .yarnproject 文件。"));
FinalizeResult(result);
return result;
}
if (yarnProjects.Length > 1)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: $"文件夹内存在多个 .yarnproject{yarnProjects.Length} 个)。"));
FinalizeResult(result);
return result;
}
ValidateYarnProjectFile(yarnProjects[0], result);
FinalizeResult(result);
return result;
}
public static YarnL10nValidationResult ValidateScanAll(string rootPath)
{
var result = new YarnL10nValidationResult();
var normalizedRoot = NormalizeProjectRelativePath(rootPath);
var absoluteRoot = ToAbsolutePath(normalizedRoot);
if (!Directory.Exists(absoluteRoot))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedRoot,
message: $"扫描根目录不存在: {normalizedRoot}"));
FinalizeResult(result);
return result;
}
var yarnProjectFiles = Directory.GetFiles(absoluteRoot, "*.yarnproject", SearchOption.AllDirectories);
if (yarnProjectFiles.Length == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedRoot,
message: "扫描范围内未找到任何 .yarnproject。"));
FinalizeResult(result);
return result;
}
foreach (var yarnProjectFile in yarnProjectFiles.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
ValidateYarnProjectFile(yarnProjectFile, result);
}
FinalizeResult(result);
return result;
}
private static void ValidateYarnProjectFile(string yarnProjectAbsolutePath, YarnL10nValidationResult result)
{
var chapterFolder = NormalizeProjectRelativePath(Path.GetDirectoryName(yarnProjectAbsolutePath));
var yarnProjectRelativePath = NormalizeProjectRelativePath(yarnProjectAbsolutePath);
Project project;
try
{
project = Project.LoadFromFile(yarnProjectAbsolutePath);
}
catch (Exception ex)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
message: $"无法解析 .yarnproject: {ex.Message}"));
return;
}
var chapterSummary = new YarnL10nChapterSummary
{
ChapterFolder = chapterFolder,
YarnProjectPath = yarnProjectRelativePath,
};
var issueStartIndex = result.Issues.Count;
if (project.Localisation == null || project.Localisation.Count == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.NoLocalizationConfigured,
chapterFolder: chapterFolder,
message: "yarnproject 未配置 localisation。"));
result.Chapters.Add(chapterSummary);
return;
}
var sourceFiles = project.SourceFiles?.ToList() ?? new List<string>();
if (sourceFiles.Count == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
message: "yarnproject 未包含任何 .yarn 源文件。"));
result.Chapters.Add(chapterSummary);
return;
}
var job = CompilationJob.CreateFromFiles(sourceFiles);
job.CompilationType = CompilationJob.Type.StringsOnly;
var compilationResult = Compiler.Compile(job);
foreach (var diagnostic in compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CompileError,
chapterFolder: chapterFolder,
message: diagnostic.Message));
}
if (compilationResult.ContainsErrors)
{
result.Chapters.Add(chapterSummary);
return;
}
if (compilationResult.ContainsImplicitStringTags)
{
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.isImplicitTag))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.UntaggedLine,
chapterFolder: chapterFolder,
lineId: pair.Key,
yarnFile: pair.Value.fileName,
yarnLineNumber: pair.Value.lineNumber.ToString(),
message: "存在未打 #line: 标签的可本地化行。"));
}
}
var baseEntries = BuildBaseEntries(project, compilationResult);
chapterSummary.SourceLineCount = baseEntries.Count;
foreach (var localePair in project.Localisation)
{
var language = localePair.Key;
if (string.Equals(language, project.BaseLanguage, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrEmpty(localePair.Value.Strings))
{
continue;
}
if (localePair.Value.Strings.StartsWith("unity:", StringComparison.Ordinal))
{
continue;
}
var localeSummary = new YarnL10nLocaleSummary { Language = language };
chapterSummary.Locales[language] = localeSummary;
if (!project.TryGetStringsPath(language, out var csvRelativePath) ||
string.IsNullOrEmpty(csvRelativePath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CsvMissing,
chapterFolder: chapterFolder,
language: language,
message: $"未找到语言 {language} 的 CSV 路径。"));
continue;
}
var csvAbsolutePath = ToAbsolutePath(csvRelativePath);
if (!File.Exists(csvAbsolutePath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CsvMissing,
chapterFolder: chapterFolder,
language: language,
csvFile: csvRelativePath,
message: $"本地化 CSV 不存在: {csvRelativePath}"));
continue;
}
IEnumerable<StringTableEntry> translatedEntries;
try
{
translatedEntries = StringTableEntry.ParseFromCSV(File.ReadAllText(csvAbsolutePath));
}
catch (Exception ex)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
language: language,
csvFile: csvRelativePath,
message: $"无法解析 CSV: {ex.Message}"));
continue;
}
var translatedDictionary = translatedEntries
.Where(entry => !string.IsNullOrEmpty(entry.ID))
.GroupBy(entry => entry.ID)
.ToDictionary(group => group.Key, group => group.First());
var baseIds = baseEntries.Keys.ToHashSet();
var translatedIds = translatedDictionary.Keys.ToHashSet();
foreach (var missingId in baseIds.Except(translatedIds).OrderBy(id => id, StringComparer.Ordinal))
{
var baseEntry = baseEntries[missingId];
localeSummary.MissingCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.MissingLocalization,
chapterFolder: chapterFolder,
language: language,
lineId: missingId,
sourceText: baseEntry.Text,
yarnFile: baseEntry.File,
yarnLineNumber: baseEntry.LineNumber));
}
foreach (var orphanId in translatedIds.Except(baseIds).OrderBy(id => id, StringComparer.Ordinal))
{
var translatedEntry = translatedDictionary[orphanId];
localeSummary.OrphanCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.OrphanLocalization,
chapterFolder: chapterFolder,
language: language,
lineId: orphanId,
locText: translatedEntry.Text,
csvFile: csvRelativePath));
}
foreach (var sharedId in baseIds.Intersect(translatedIds))
{
var baseEntry = baseEntries[sharedId];
var translatedEntry = translatedDictionary[sharedId];
if (string.IsNullOrEmpty(baseEntry.Lock) || string.IsNullOrEmpty(translatedEntry.Lock))
{
continue;
}
if (baseEntry.Lock == translatedEntry.Lock)
{
continue;
}
localeSummary.SourceChangedCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.SourceTextChanged,
chapterFolder: chapterFolder,
language: language,
lineId: sharedId,
sourceText: baseEntry.Text,
locText: translatedEntry.Text,
yarnFile: baseEntry.File,
yarnLineNumber: baseEntry.LineNumber,
csvFile: csvRelativePath,
message: "原文 Lock 与翻译 CSV 不一致,翻译可能已过期。"));
}
}
chapterSummary.IssueCount = result.Issues.Count - issueStartIndex;
result.Chapters.Add(chapterSummary);
}
private static Dictionary<string, StringTableEntry> BuildBaseEntries(Project project, CompilationResult compilationResult)
{
var entries = new Dictionary<string, StringTableEntry>(StringComparer.Ordinal);
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.text != null))
{
var entry = new StringTableEntry
{
ID = pair.Key,
Language = project.BaseLanguage ?? "zh-Hans",
Text = pair.Value.text,
File = pair.Value.fileName,
Node = pair.Value.nodeName,
LineNumber = pair.Value.lineNumber.ToString(),
Lock = YarnImporter.GetHashString(pair.Value.text!, 8),
};
entries[pair.Key] = entry;
}
return entries;
}
private static YarnL10nIssue CreateIssue(
YarnL10nIssueType type,
string chapterFolder = "",
string language = "",
string lineId = "",
string sourceText = "",
string locText = "",
string yarnFile = "",
string yarnLineNumber = "",
string csvFile = "",
string message = "")
{
return new YarnL10nIssue
{
Type = type,
ChapterFolder = chapterFolder ?? string.Empty,
Language = language ?? string.Empty,
LineId = lineId ?? string.Empty,
SourceText = sourceText ?? string.Empty,
LocText = locText ?? string.Empty,
YarnFile = yarnFile ?? string.Empty,
YarnLineNumber = yarnLineNumber ?? string.Empty,
CsvFile = csvFile ?? string.Empty,
Message = message ?? string.Empty,
};
}
private static void AddIssue(YarnL10nValidationResult result, YarnL10nIssue issue)
{
result.Issues.Add(issue);
}
private static void FinalizeResult(YarnL10nValidationResult result)
{
result.TotalIssueCount = result.Issues.Count;
}
public static string NormalizeProjectRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var normalized = path.Replace('\\', '/').Trim();
var projectRoot = GetProjectRootPath().Replace('\\', '/');
if (Path.IsPathRooted(normalized))
{
var absolute = Path.GetFullPath(normalized).Replace('\\', '/');
if (absolute.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{
normalized = absolute.Substring(projectRoot.Length).TrimStart('/');
}
else
{
normalized = absolute;
}
}
return normalized.TrimEnd('/');
}
public static string ToAbsolutePath(string projectRelativeOrAbsolutePath)
{
if (string.IsNullOrWhiteSpace(projectRelativeOrAbsolutePath))
{
return GetProjectRootPath();
}
if (Path.IsPathRooted(projectRelativeOrAbsolutePath))
{
return Path.GetFullPath(projectRelativeOrAbsolutePath);
}
return Path.GetFullPath(Path.Combine(GetProjectRootPath(), projectRelativeOrAbsolutePath));
}
private static string GetProjectRootPath()
{
return Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, ".."));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea78060f3c3448d4aa9b19b8b55fb843
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,116 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nValidatorCli
{
private const string JsonBeginMarker = "YARN_L10N_JSON_BEGIN";
private const string JsonEndMarker = "YARN_L10N_JSON_END";
public static void Run()
{
try
{
var args = Environment.GetCommandLineArgs();
var path = GetArgumentValue(args, "-yarnL10nPath");
var scanAll = HasFlag(args, "-yarnL10nScanAll");
var exportPath = GetArgumentValue(args, "-yarnL10nExport");
var outputPath = GetArgumentValue(args, "-yarnL10nOutput");
if (string.IsNullOrWhiteSpace(outputPath))
{
outputPath = "Build/yarn_l10n_result.json";
}
if (string.IsNullOrWhiteSpace(path))
{
WriteFailure("缺少参数 -yarnL10nPath。", 2);
return;
}
var validationResult = scanAll
? YarnL10nValidator.ValidateScanAll(path)
: YarnL10nValidator.ValidateFolder(path);
var json = JsonConvert.SerializeObject(validationResult, Formatting.Indented);
WriteJson(json, outputPath);
if (!string.IsNullOrWhiteSpace(exportPath))
{
var absoluteExportPath = YarnL10nValidator.ToAbsolutePath(exportPath);
YarnL10nReportExporter.ExportToCsv(validationResult, absoluteExportPath);
}
var exitCode = validationResult.HasBlockingIssues ? 1 : 0;
Debug.Log($"[YarnL10nValidatorCli] Completed with exit code {exitCode}, issues={validationResult.TotalIssueCount}");
EditorApplication.Exit(exitCode);
}
catch (Exception ex)
{
WriteFailure(ex.ToString(), 3);
}
}
private static void WriteJson(string json, string outputPath)
{
Console.WriteLine(JsonBeginMarker);
Console.WriteLine(json);
Console.WriteLine(JsonEndMarker);
if (!string.IsNullOrWhiteSpace(outputPath))
{
var absoluteOutputPath = YarnL10nValidator.ToAbsolutePath(outputPath);
var directory = Path.GetDirectoryName(absoluteOutputPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(absoluteOutputPath, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
Debug.Log($"[YarnL10nValidatorCli] Wrote JSON to {absoluteOutputPath}");
}
}
private static void WriteFailure(string message, int exitCode)
{
Debug.LogError($"[YarnL10nValidatorCli] {message}");
Console.Error.WriteLine(message);
EditorApplication.Exit(exitCode);
}
private static bool HasFlag(string[] args, string flag)
{
foreach (var arg in args)
{
if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string GetArgumentValue(string[] args, string key)
{
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (arg.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase))
{
return arg.Substring(key.Length + 1).Trim('"');
}
if (string.Equals(arg, key, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
return args[i + 1].Trim('"');
}
}
return string.Empty;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23836ef03028055439615fda97642c4e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@
!"#%'()*+,-./01234
56789:;<=>?ABCDEFGHI
JKLMNOPQRSTUVWY[\]^_
JKLMNOPQRSTUVWXYZ[\]^_
abcdefghijklmnopqrst
uvwxyz{}~λ—―“”…₭ 、。「
」【】〜ぁあいぅうえおかがきぎくぐけげこ
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 59969a588ee9e4b4894d6c1c2d4647a0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9754e778b306b94468d281937d9b0b8b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.

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