Compare commits

...
Author SHA1 Message Date
dingyuntian 9cda02f02b Merge branch 'feature/本地化' of ssh://114.67.212.218:2222/aibis-dream/aibis-dream into feature/本地化 2026-07-29 20:36:30 +08:00
dingyuntian 61a2e779df feat: 指针问题 2026-07-29 20:35:25 +08:00
chenziqin 97ae87372d Merge branch '录屏test' into 'feature/本地化'
# Conflicts:
#   Assets/Yarn/FP/FP_Huoshan1/Stage6.yarn
2026-07-29 09:10:02 +00:00
dingyuntian 61fb8820a3 feat: 梦境dos界面 2026-07-29 15:57:44 +08:00
dingyuntian ac0b20a3a8 art: 资源迁移 2026-07-29 14:12:47 +08:00
dingyuntian 8f2ee603d5 feat: 火山表达维修粒子字符池拆分 2026-07-29 13:00:59 +08:00
dingyuntian a48e4babd4 feat: 硬编码本地化 2026-07-28 21:34:42 +08:00
Ovid 8671179ef4 commit 2026-07-28 21:30:55 +08:00
dingyuntian ca5b714124 feat: 火山玩法本地化
仍有硬编码残留,待处理
2026-07-28 19:13:53 +08:00
dingyuntian c735f3084e Merge branch 'feature/章节名' into 'develop'
章节名

See merge request aibis-dream/aibis-dream!817
2026-07-28 10:16:00 +00:00
Ovid e881c9e710 章节名 2026-07-28 17:52:03 +08:00
dingyuntian 3de3f1a2e2 feat: 添加本地化语言设置 2026-07-28 17:15:03 +08:00
dingyuntian fe11a89b2d Merge branch 'feature/本地化' into 'develop'
yarn: 本地化

See merge request aibis-dream/aibis-dream!816
2026-07-28 09:14:26 +00:00
dingyuntian 5b02636572 yarn: 本地化 2026-07-28 17:12:12 +08:00
dingyuntian 9386af3155 Merge branch 'feature/本地化' into 'develop'
yarn: 添加行号

See merge request aibis-dream/aibis-dream!815
2026-07-28 09:03:35 +00:00
dingyuntian 62586a2ec9 yarn: 错误行 2026-07-28 17:03:08 +08:00
dingyuntian a498fcb7f0 yarn: 添加行号 2026-07-28 17:00:54 +08:00
dingyuntian ddeeeebf25 Merge branch 'feature/musfix' into 'develop'
Feature/musfix

See merge request aibis-dream/aibis-dream!814
2026-07-28 08:10:27 +00:00
Ovid c4f3e79c23 debug 2026-07-28 16:04:04 +08:00
Ovid b091cf0d15 musfix 2026-07-28 16:03:22 +08:00
dingyuntian 0466649aa4 Merge branch '7.28补充' into 'develop'
修复问题

See merge request aibis-dream/aibis-dream!813
2026-07-27 20:33:52 +00:00
bottlefish 114b90eaca 修复问题 2026-07-28 04:33:07 +08:00
dingyuntian 6a4401b3f7 Merge branch 'bugfix7.28' into 'develop'
sfx命令修复

See merge request aibis-dream/aibis-dream!812
2026-07-27 19:47:43 +00:00
bottlefish f1a0e4fe0e sfx命令修复 2026-07-28 03:47:00 +08:00
dingyuntian 39ab86fd05 Merge branch 'feature/bnkFix' into 'develop'
Update Environment.bank

See merge request aibis-dream/aibis-dream!811
2026-07-27 19:46:49 +00:00
Ovid 4d0f6954ab Update Environment.bank 2026-07-28 03:46:05 +08:00
dingyuntian 008817269f Merge branch 'feature/bnkFix' into 'develop'
rebd

See merge request aibis-dream/aibis-dream!810
2026-07-27 18:56:06 +00:00
Ovid 5fa12aee74 rebd 2026-07-28 02:55:04 +08:00
dingyuntian 2fce9d4c8d Merge branch '7.27night春晖优化' into 'develop'
feat(tarot): 添加悬停与选牌音效

See merge request aibis-dream/aibis-dream!807
2026-07-27 18:43:07 +00:00
dingyuntian 9bfa990e33 Merge branch 'bugfix/结尾' into 'develop'
fix: 结尾

See merge request aibis-dream/aibis-dream!808
2026-07-27 18:34:29 +00:00
dingyuntian 03d9631ce4 fix: 结尾 2026-07-28 02:34:06 +08:00
dingyuntian 7371848889 Merge branch 'feature/0727SFXdebug' into 'develop'
Feature/0727 sf xdebug

See merge request aibis-dream/aibis-dream!806
2026-07-27 18:28:21 +00:00
Ovid 1eeb2eb6d9 PP2MUS 2026-07-28 02:20:53 +08:00
bottlefishandCursor 4605fc3215 yarn(peipei): 更新停止想象任务提示文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 02:02:07 +08:00
bottlefishandCursor 85c7e54f53 yarn(huoshan): 调整火山关于留下与赢的台词
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 02:01:58 +08:00
bottlefishandCursor 218b3939ff yarn: 缩短地铁安慰段落等待时间
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 02:01:48 +08:00
bottlefishandCursor a9063726a9 feat(tarot): 添加悬停与选牌音效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 02:01:06 +08:00
dingyuntian 71043c108c Merge branch 'bugfix/存档功能过严' into 'develop'
art: 佩佩动画更新

See merge request aibis-dream/aibis-dream!805
2026-07-27 17:50:42 +00:00
dingyuntian e803f9f8b6 art: 佩佩动画更新 2026-07-28 01:47:12 +08:00
Ovid 90c1c34d68 debug 2026-07-28 01:42:21 +08:00
Ovid 093ce6886d PP1mus 2026-07-28 01:31:47 +08:00
dingyuntian 7961ebbd79 Merge branch 'bugfix/存档功能过严' into 'develop'
Bugfix/存档功能过严

See merge request aibis-dream/aibis-dream!804
2026-07-27 17:30:56 +00:00
dingyuntian 738f112a1d yarn: 音效已合入Timeline 2026-07-28 01:29:49 +08:00
dingyuntian 9904e91145 art: 火山插孔排列 2026-07-28 01:28:12 +08:00
dingyuntian e1c8ba4677 Merge branch 'bugfix/存档功能过严' into 'develop'
Bugfix/存档功能过严

See merge request aibis-dream/aibis-dream!803
2026-07-27 17:19:08 +00:00
dingyuntian 07bfd9020a Merge branch 'develop' into bugfix/存档功能过严 2026-07-28 01:18:48 +08:00
dingyuntian 608d87725f Merge branch '7.27night春晖优化' into 'develop'
fix(fix-system): 修正线盘松弛预判朝向与绷紧判定

See merge request aibis-dream/aibis-dream!801
2026-07-27 17:16:14 +00:00
dingyuntian b013ced409 Merge branch 'feature/音频参数补全' into 'develop'
Feature/音频参数补全

See merge request aibis-dream/aibis-dream!802
2026-07-27 17:13:09 +00:00
dingyuntian 22a0d7377d feat: 音频参数保存功能 2026-07-28 01:12:22 +08:00
dingyuntian 2902c554a6 feat: 修改存档和结果路径 2026-07-28 01:11:46 +08:00
bottlefishandCursor 21592b3c73 scene: 精简 Renderer2Dtest 后处理特效
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 01:10:30 +08:00
bottlefishandCursor 3b49326762 yarn: 天台窗户按 gameStage 分支文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 01:10:23 +08:00
bottlefishandCursor 3155b9b9f9 yarn(huoshan): 火山彩带段落补玩笑 BGM
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 01:09:57 +08:00
dingyuntian 80e965b274 art: 色觉芯片更新 2026-07-28 01:02:24 +08:00
dingyuntian 8dc27144c6 art: 角色动画更新 2026-07-28 00:58:45 +08:00
bottlefishandCursor 5b4d6e3d42 fix(bar): 修正喝醉状态过渡到 loop
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:45:50 +08:00
bottlefishandCursor 17f07006fa fix(huoshan): 校准步进模式滑片起始角防跳位
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:45:40 +08:00
bottlefishandCursor 76c8db7f62 art(huoshan): 更新步进模式滑片切图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:45:29 +08:00
Ovid 4c4888b14c 上传一波声音 2026-07-27 23:52:29 +08:00
bottlefishandCursor 53f0019205 scene(fix-system): 修正维修面板 Layer 并同步维修场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:14:46 +08:00
bottlefishandCursor 35bac7c59b scene: 下调 Bloom 强度并扩展模糊滤波核
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:14:20 +08:00
bottlefishandCursor 064d6f83c6 chore: 更新 bundleVersion 至 0.6.1.2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:48:43 +08:00
bottlefishandCursor 37048c1206 yarn(peipei): 调整开场对话演员状态切换时机
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:48:34 +08:00
bottlefishandCursor b242c05e06 fix(peipei): 修复未上色目标换目标时未失焦
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:48:26 +08:00
bottlefishandCursor 223bcc8c61 fix(huoshan): 修复完成态文字粒子贴边出屏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:48:18 +08:00
bottlefishandCursor 65735dec52 fix(fix-system): 修正线盘松弛预判朝向与绷紧判定
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:48:10 +08:00
Ovid a7a8d082e3 增加了一些声音 2026-07-27 21:37:26 +08:00
dingyuntian d1a332e9c8 fix: 火山插孔 2026-07-27 20:55:45 +08:00
dingyuntian 45fb678f39 Merge branch 'develop' into bugfix/存档功能过严 2026-07-27 20:24:37 +08:00
dingyuntian 9f509a2a66 fix: 设置和记录重叠问题 2026-07-27 20:05:42 +08:00
dingyuntian f608fec8ca fix: 扩大按钮选择范围 2026-07-27 19:50:08 +08:00
dingyuntian 1fb2e63866 audio: 彩带音频并入Timeline 2026-07-27 19:49:50 +08:00
dingyuntian f8fc8bd597 art: 美术资源调整 2026-07-27 19:48:26 +08:00
dingyuntian bc4475ae66 Merge branch 'bugfix7.27' into 'develop'
yarn(day1): 将塔罗描述卡牌改为卡面

See merge request aibis-dream/aibis-dream!799
2026-07-27 08:04:02 +00:00
dingyuntian bb11478605 Merge branch 'bugfix/存档功能过严' into 'develop'
fix: 存档功能修理

See merge request aibis-dream/aibis-dream!800
2026-07-27 08:01:21 +00:00
dingyuntian 31e6ae87be fix: 存档功能修理 2026-07-27 16:00:30 +08:00
bottlefishandCursor 9bfe9ca231 chore: 更新 bundleVersion 至 0.6.0.9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:16:52 +08:00
bottlefishandCursor 1a6184dc82 yarn(prologue): 修正切割流程两处错字
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:16:42 +08:00
bottlefishandCursor ca345a51c6 yarn(huoshan): 修正 Stage5 临时改造台词说话人
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:16:34 +08:00
bottlefishandCursor b38343e427 yarn(day2): 修正 VERIFYING 拼写与多处文案
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:16:26 +08:00
bottlefishandCursor aa2b2d2e65 yarn(day1): 将塔罗描述卡牌改为卡面
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:16:17 +08:00
dingyuntian 3e6fab9762 Merge branch '7.27早春晖bugfix' into 'develop'
yarn(peipei): 手术入口淡出后跳转到手术准备

See merge request aibis-dream/aibis-dream!797
2026-07-27 06:42:53 +00:00
dingyuntian c0e7f2ddb4 Merge branch 'bugfix/美术问题调整' into 'develop'
fix: 跳转和位置修复

See merge request aibis-dream/aibis-dream!798
2026-07-27 06:42:42 +00:00
dingyuntian 05df341bcd fix: 跳转和位置修复 2026-07-27 14:42:18 +08:00
bottlefishandCursor 1bdcacd2ff yarn(prologue): 调整切割台词与切换工具的顺序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:37:32 +08:00
bottlefishandCursor ec8246a193 yarn(peipei): 手术入口淡出后跳转到手术准备
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:37:01 +08:00
dingyuntian 1b87f400ae Merge branch '7.27早春晖bugfix' into 'develop'
yarn(huoshan): 将 Center 初始 gameStage 重置为 1

See merge request aibis-dream/aibis-dream!796
2026-07-27 06:23:30 +00:00
bottlefishandCursor f28fc76bb1 chore: 更新 bundleVersion 至 0.6.0.8
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:22:09 +08:00
bottlefishandCursor f91579ba8a yarn(huoshan): 将 Center 初始 gameStage 重置为 1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 14:22:01 +08:00
dingyuntian 7fe63f77f3 Merge branch 'feature/d2sSFX' into 'develop'
Feature/d2s sfx

See merge request aibis-dream/aibis-dream!795
2026-07-27 05:20:28 +00:00
Yuntien 55ffaf494f Merge branch 'develop' into feature/d2sSFX 2026-07-27 13:19:16 +08:00
dingyuntian 206018e8e6 Merge branch '7.27春fix' into 'develop'
art(clinic): 更新检索 Timeline 图集资源

See merge request aibis-dream/aibis-dream!794
2026-07-27 05:06:27 +00:00
Ovid 90601ac6a2 debug 2026-07-27 07:53:54 +08:00
Ovid 0ef54b04cb HS1上传 2026-07-27 07:51:30 +08:00
Ovid f0e85493d7 debug 2026-07-27 04:28:04 +08:00
Ovid 81b0828ba9 D2S debug 2026-07-27 04:12:43 +08:00
bottlefishandCursor 63c884b591 chore(dialog): 挂接叙事文本样式配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:12:29 +08:00
bottlefishandCursor 0c6575b4d3 scene(peipei): 调整维修场景演出与交互参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:12:23 +08:00
bottlefishandCursor af36da918c yarn(huoshan): 修复维修阶段跳转与任务流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:12:10 +08:00
bottlefishandCursor 615963e535 art(clinic): 更新检索 Timeline 图集资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 02:06:42 +08:00
Ovid a98d230e05 sfxUpdate 2026-07-27 01:46:26 +08:00
dingyuntian 8b7a4b7bf2 Merge branch 'feature/增加结尾保存节点' into 'develop'
fix: 插孔问题修复

See merge request aibis-dream/aibis-dream!793
2026-07-26 17:05:44 +00:00
dingyuntian bae1d0473b fix: 插孔问题修复 2026-07-27 01:04:22 +08:00
dingyuntian 1e56201ce8 Merge branch 'feature/增加结尾保存节点' into 'develop'
yarn: 给维修过程打标签

See merge request aibis-dream/aibis-dream!792
2026-07-26 16:58:23 +00:00
dingyuntian 4b7887cd17 Merge branch 'develop' into feature/增加结尾保存节点 2026-07-27 00:57:05 +08:00
dingyuntian 784801b500 yarn: 给维修过程打标签 2026-07-27 00:49:59 +08:00
Ovid e8940ca7da Merge branch 'develop' into feature/d2sSFX 2026-07-27 00:22:27 +08:00
Ovid 90f075d51f D2SMUS 2026-07-27 00:12:34 +08:00
dingyuntian ae5ba2c913 Merge branch 'feature/增加结尾保存节点' into 'develop'
art: 天桥佩佩更新

See merge request aibis-dream/aibis-dream!791
2026-07-26 16:03:52 +00:00
dingyuntian d4673bf851 Merge branch 'develop' into 'feature/增加结尾保存节点'
# Conflicts:
#   Assets/Yarn/FP/FP_Day2_night/FP_Day2_night.yarn
2026-07-26 16:03:46 +00:00
dingyuntian f78da1de4d fix: 冲突处理 2026-07-27 00:03:14 +08:00
dingyuntian 8c4c99aecf art: 天桥佩佩更新 2026-07-27 00:01:02 +08:00
dingyuntian 71a771d56c Merge branch '7.26春表现优化' into 'develop'
[ide-config] chore(配置): 添加 Yarn 文本审校命令

See merge request aibis-dream/aibis-dream!790
2026-07-26 15:50:57 +00:00
dingyuntian 6591001416 Merge branch 'develop' into '7.26春表现优化'
# Conflicts:
#   Assets/Yarn/FP/FP_Peipei2/Stage6_分析.yarn
2026-07-26 15:50:44 +00:00
bottlefishandCursor d4c8b8db64 yarn(dialog): 修正佩佩分析对话空行与命令拼写
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:49:58 +08:00
bottlefishandCursor 76055e8554 chore: 更新 bundleVersion
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:45:11 +08:00
bottlefishandCursor 45d71a2d40 docs(huoshan): 补充 Stage6 本地化实施计划
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:45:02 +08:00
bottlefishandCursor 95a1a86b71 art: 更新对话字体与 Fallback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:44:54 +08:00
bottlefishandCursor 305d6a71f2 fix(fix-system): 修复拖拽 UI 跳位
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:44:10 +08:00
bottlefishandCursor add9891934 feat(peipei): 支持 Eye/UF 演出倍速与音效 pitch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:44:00 +08:00
bottlefishandCursor 9dab57ab87 yarn(dialog): 为对话加入语义标记与停顿
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:43:47 +08:00
bottlefishandCursor 046f262b11 feat(dialog): 支持叙事文本语义样式展开
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:43:37 +08:00
bottlefishandCursor 75de932ea0 feat(tools): 添加 Yarn 文本审校脚本
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:43:04 +08:00
bottlefishandCursor b578ea45cf [ide-config] chore(配置): 添加 Yarn 文本审校命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:42:31 +08:00
dingyuntian 5f83618c3a Merge branch 'feature/增加结尾保存节点' into 'develop'
feat(save): 为存档锚点增加显式恢复标记并加固退出档

See merge request aibis-dream/aibis-dream!789
2026-07-26 15:39:41 +00:00
dingyuntianandCursor c57a2d2950 test: 隔离帧动画导入测试临时目录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:41 +08:00
dingyuntianandCursor cd3343457b feat(dialog): 允许梦境选项提示语跨节点保留
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:40 +08:00
dingyuntianandCursor efe9c5baaa yarn: 添加 Day2 sleep 重构剧本草稿
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:27 +08:00
dingyuntianandCursor d582e26a83 art: 添加天桥佩佩动作源文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:27 +08:00
dingyuntianandCursor 02967a5468 art(huoshan): 更新火山像素版动画并同步状态名
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:14 +08:00
dingyuntianandCursor d06a67bcb1 yarn(peipei): 将维修入口改为 interaction 退出存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:38:01 +08:00
dingyuntianandCursor a9bc442bc8 feat(save): 为存档锚点增加显式恢复标记并加固退出档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 23:37:47 +08:00
dingyuntian a177fc8b16 Merge branch 'develop' into feature/增加结尾保存节点 2026-07-26 20:00:11 +08:00
dingyuntian 82c15e8f48 feat: 存档系统增加退出节点保存时机 2026-07-26 19:59:56 +08:00
dingyuntian ee7cfaa1c1 Merge branch 'bugfix/问题修复' into 'develop'
fix: 任务面板重构

See merge request aibis-dream/aibis-dream!788
2026-07-26 10:09:09 +00:00
dingyuntian bb207573b2 Merge branch '表现优化春' into 'develop'
fix(day2sleep): 修复大图虚焦清零后贴图变白

See merge request aibis-dream/aibis-dream!787
2026-07-26 10:08:12 +00:00
dingyuntian 3cdedb155c fix: 任务面板重构 2026-07-26 18:07:34 +08:00
bottlefish 05e074b19a yarn(dialog): 调整火山结尾屏幕特效顺序 2026-07-26 17:36:15 +08:00
bottlefish 196c4e5c07 scene(fix-system): 调整维修场景表现参数 2026-07-26 17:35:56 +08:00
bottlefish 0e0cc66546 feat(fix-system): 优化线缆收起动画 2026-07-26 17:35:40 +08:00
dingyuntian 4caf70b554 Merge branch 'bugfix/问题修复' into 'develop'
fix: 部分QA问题处理

See merge request aibis-dream/aibis-dream!786
2026-07-26 08:40:27 +00:00
dingyuntian 0a1adf1558 fix: 部分QA问题处理
1. 返回主菜单闪烁
2. 对话隐藏
3. 历史记录宽度调整
2026-07-26 16:36:51 +08:00
bottlefish 681ac01eb9 Merge branch 'develop' into 表现优化春 2026-07-26 16:28:58 +08:00
bottlefish 528d683929 build(config): 更新项目构建版本号 2026-07-26 15:47:12 +08:00
bottlefish 630d9627b3 art(ui): 更新方正黑体简体 SDF 字体资源 2026-07-26 15:46:48 +08:00
bottlefish 4be8b0ece7 scene(fix-system): 调整维修面板日志模式界面 2026-07-26 15:46:03 +08:00
bottlefish 3506fa9f2b scene(huoshan): 添加 BloomText 后处理配置 2026-07-26 15:45:47 +08:00
bottlefishandCursor 29b02c57b1 yarn(huoshan): 调整 Stage6 真相收束时序
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 14:33:36 +08:00
bottlefishandCursor 2dafeb8432 feat(huoshan): 校准表达屏布局与状态文字
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 14:30:56 +08:00
bottlefish 52fe5c9485 fix(huoshan): 优化攻击段愤怒红屏反馈 2026-07-26 14:16:53 +08:00
bottlefish 0aae59262a feat(huoshan): 接入表达界面序列帧与本地化文字 2026-07-26 13:06:25 +08:00
bottlefish 3c27b0ef03 fix(day2sleep): 修复风筝坠落与文本表现不一致 2026-07-26 13:05:24 +08:00
bottlefish cd1fbe4e02 fix(fix-system): 修复线盘底座旋转未生效 2026-07-26 13:04:50 +08:00
bottlefish 6eaa4545bb fix(fix-system): 扩大热力图拖拽命中范围 2026-07-26 13:04:39 +08:00
bottlefish 6a029bfeb4 fix(huoshan): 重构记忆放映失真效果 2026-07-26 13:03:58 +08:00
bottlefish 3ed8999bbb fix(fix-system): 优化线缆收起的复位与拉回表现 2026-07-26 13:03:39 +08:00
bottlefish 56eab46b0c fix(fix-system): 修复线缆未跟随环境光变色 2026-07-26 13:03:10 +08:00
bottlefishandCursor d5c0842fd9 fix(huoshan): 收紧辉光管绘制区域避免轻微溢出
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 23:28:32 +08:00
bottlefishandCursor c16e9d06ee fix(huoshan): 修复记忆淡入被noise遮挡并恢复材质失真交接
进记忆前保留 glitch_transition noise,begin 时透明淡出并改由 HuoshanMemory 材质承接轻微失真;记忆渲染进 HuoshanScreen 以进入 VolFx 合成。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 23:26:56 +08:00
dingyuntian cd63b44ed6 Merge branch 'feature/voUpdate' into 'develop'
更新VO和说话人

See merge request aibis-dream/aibis-dream!785
2026-07-25 13:58:46 +00:00
Ovid 5f14efe12a 更新VO和说话人 2026-07-25 21:44:37 +08:00
bottlefishandCursor aa912c045c fix(day2sleep): 补全 SetAlpha 扩展方法引用以修复编译错误
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:36:32 +08:00
bottlefishandCursor d81c1ea76d fix(dreamdoor): 优化终端变形顿挫并提高跳字速度
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:26:08 +08:00
bottlefishandCursor 0e1604d18b fix(dreamdoor): 修复星空默认开启导致变门穿帮
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:25:46 +08:00
bottlefishandCursor 166b0ac4af fix(dreamdoor): 近景门框改大图并修复 closeup 残留
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:24:56 +08:00
bottlefishandCursor 08594fae10 fix(day2sleep): 修复大图虚焦清零后贴图变白
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 21:24:32 +08:00
dingyuntian 3951e06bc1 Merge branch '7.25春优化' into 'develop'
art(open): 新增带不带记忆模块电击闪烁图

See merge request aibis-dream/aibis-dream!784
2026-07-25 12:03:16 +00:00
bottlefishandCursor abfe710985 build: 更新版本号至0.6.0.3-20260725
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:49:44 +08:00
bottlefishandCursor d091c47b5a perf(day2sleep): 提高轱辘拉拽动画倍速
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:48:31 +08:00
bottlefishandCursor f37f24ccd0 art: 更新D2S轱辘风筝层贴图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:48:22 +08:00
bottlefishandCursor be1ab7ed80 chore(huoshan): 移除表达开发预览与调试快捷键残留
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:48:06 +08:00
bottlefishandCursor 0d40b8db14 feat(open): 支持按记忆模块切换电击闪烁图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:47:57 +08:00
bottlefishandCursor 783ef340f5 art(open): 新增带不带记忆模块电击闪烁图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 19:47:48 +08:00
dingyuntian 2d0c45f2e7 Merge branch '火山log释放迭代' into 'develop'
art(huoshan): 添加记忆演出贴图与材质着色器

See merge request aibis-dream/aibis-dream!783
2026-07-25 10:41:45 +00:00
bottlefish dae9aa100e Merge branch 'develop' into 火山log释放迭代 2026-07-25 18:33:50 +08:00
bottlefishandCursor e9792776a9 docs(huoshan): 同步屏幕配平与头痛3说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:30:10 +08:00
bottlefishandCursor d62124d72b yarn(huoshan): 收紧LOG释放节奏并改用头痛3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:30:01 +08:00
bottlefishandCursor 971b106c81 art(huoshan): 新增LOG3短头痛Timeline
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:29:40 +08:00
bottlefishandCursor dd1e2b1d94 scene(huoshan): 拆分Canvas与postCanvas后处理层
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:29:13 +08:00
bottlefishandCursor d4429deddc art(huoshan): 压低高档雪花并保留故障底
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 18:29:01 +08:00
bottlefishandCursor 2db69b1cff docs(huoshan): 同步屏幕stress与pulse命令文档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:28:54 +08:00
bottlefishandCursor 78d01dfe8f yarn(huoshan): Stage6改用屏幕蓄压与冲击命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:28:48 +08:00
bottlefishandCursor 84cfe459de feat(huoshan): 屏幕蓄压冲击并删除聚焦干扰
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:28:41 +08:00
bottlefishandCursor cfacff9943 art(huoshan): 屏幕glitch改荧光体并支持愤怒红
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 17:28:35 +08:00
dingyuntian 0a489b1250 Merge branch 'bugfix/场景小问题微调' into 'develop'
fix: 场景微调

See merge request aibis-dream/aibis-dream!782
2026-07-25 08:23:18 +00:00
dingyuntian 2808535688 fix: 场景微调 2026-07-25 16:22:38 +08:00
bottlefishandCursor a1afc021f1 art(huoshan): 重做屏幕glitch为坏电视故障语汇
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 16:06:09 +08:00
bottlefishandCursor b0c6068ea8 docs(huoshan): 同步log释放Yarn命令文档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:57:30 +08:00
bottlefishandCursor 1017ff4e90 yarn(huoshan): 更新Stage6与Day1开场对话
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:57:24 +08:00
bottlefishandCursor 6977aca95d scene(huoshan): 同步场景与后处理渲染配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:57:18 +08:00
bottlefishandCursor 042785e3dc art(huoshan): 调整记忆shader表现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:57:10 +08:00
bottlefishandCursor 2c6ac3e142 feat(huoshan): 扩展辉光管与旋钮表现
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:57:03 +08:00
bottlefishandCursor bee4e0c796 feat(huoshan): 迭代log释放演出与Yarn命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:56:53 +08:00
bottlefishandCursor e2461ff0e5 feat(huoshan): 新增屏幕glitch shader与强度驱动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 15:56:41 +08:00
bottlefishandCursor 1620c25f82 yarn(huoshan): 改用固定红并细化LOG3攻击节奏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:42:43 +08:00
bottlefishandCursor ed19d28a9c scene(huoshan): 同步愤怒红与后处理Bloom参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:42:34 +08:00
bottlefishandCursor 5f7986bb82 feat(huoshan): 统一愤怒红并改屏幕Bloom提亮
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:42:22 +08:00
bottlefishandCursor 26f917bba5 art(huoshan): 收紧log释放字体材质发光
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:42:16 +08:00
bottlefishandCursor fc530e77b4 yarn(huoshan): 改写Stage6演出并恢复Stage8出口
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:00:27 +08:00
bottlefishandCursor f557f10b02 scene(huoshan): 调低log释放后处理Bloom强度
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 02:00:07 +08:00
bottlefishandCursor 5fb5e51992 feat(huoshan): 添加真话失稳并增强聚焦彩虹干扰
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:59:35 +08:00
dingyuntian b315bf144b Merge branch 'feature/音乐介入' into 'develop'
Feature/音乐介入

See merge request aibis-dream/aibis-dream!781
2026-07-24 17:12:58 +00:00
bottlefishandCursor 886ed05ab0 yarn(huoshan): 改写Stage6 LOG1与LOG2演出流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:12:49 +08:00
bottlefishandCursor 7e037bbe9b scene(huoshan): 配置log释放后处理与场景挂接
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:12:42 +08:00
bottlefishandCursor 11384d763b feat(huoshan): 迭代粒子视觉并添加聚焦干扰与真话攻击
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:12:33 +08:00
bottlefishandCursor 3e445e9977 art(huoshan): 添加log释放字体材质与脑部特写
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 01:11:53 +08:00
dingyuntian da7821cde5 Merge branch 'bugfix/去除测试快捷键' into 'develop'
Bugfix/去除测试快捷键

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

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

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

See merge request aibis-dream/aibis-dream!777
2026-07-24 05:24:27 +00:00
dingyuntian f3cff824ce fix: 解析时自动去前后空格 2026-07-24 13:23:16 +08:00
dingyuntian e6ae903c03 feat: 测试存档功能 2026-07-24 13:16:03 +08:00
bottlefishandCursor a3f3fadaba docs(huoshan): 补充 log 释放真假争夺命令说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:56:18 +08:00
bottlefishandCursor ce468ea9cc yarn(huoshan): 改写 Stage6 LOG1 真假争夺演出
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:50:56 +08:00
bottlefishandCursor aac0fd87a4 scene(huoshan): 配置 log 释放演出颜色与红屏引用
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:49:18 +08:00
bottlefishandCursor 84f5cf2346 feat(huoshan): 添加真话聚合与真假闪切演出命令
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:48:15 +08:00
bottlefishandCursor 377271a5d5 art(huoshan): 添加火山脑部红屏特写贴图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 23:47:55 +08:00
NeologismWu 8a68e0676b 1
11
2026-07-23 22:09:58 +08:00
dingyuntian 2cec5dd66c Merge branch 'bugfix/问题修复' into 'develop'
Bugfix/问题修复

See merge request aibis-dream/aibis-dream!776
2026-07-23 13:38:03 +00:00
dingyuntian 15f5c34db5 audio: 增加音频项 2026-07-23 21:36:37 +08:00
dingyuntian 13d7c305c8 art: 佩佩美术更新 2026-07-23 21:36:05 +08:00
dingyuntian 03a71b4ba3 fix: show_large_sprite闪烁问题 2026-07-23 20:46:13 +08:00
bottlefishandCursor 63820cae8d feat(devsave): 支持launchNode并添加火山测试存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:55:01 +08:00
bottlefishandCursor 1113e5707b test(huoshan): 添加Log释放演出编辑器测试
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:54:53 +08:00
bottlefishandCursor 075d978b4d docs(huoshan): 补充Log释放Yarn命令说明
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:54:46 +08:00
bottlefishandCursor 2d3cef3875 yarn(huoshan): 更新Stage6 Log释放演出流程
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:54:39 +08:00
bottlefishandCursor e3a69ea0f5 feat(huoshan): 实现Log释放演出控制器与场景挂接
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:54:29 +08:00
bottlefishandCursor f55cd31f6f feat(effects): 为噪点Glitch增加透明叠加合成模式
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:54:08 +08:00
bottlefishandCursor 5a59d6c876 art(huoshan): 添加记忆演出贴图与材质着色器
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:53:35 +08:00
dingyuntian 2ef6db54da feat: 开发面板调整 2026-07-23 16:57:58 +08:00
dingyuntian 4a9f487db9 fix: 处理一下编辑器编译错误 2026-07-23 16:57:34 +08:00
dingyuntian 360293941a chore: 编辑器分类清理 2026-07-23 16:25:45 +08:00
dingyuntian 28e4214413 feat: 清除几个旧的混用测试存档功能 2026-07-23 15:42:21 +08:00
dingyuntian 33ec00ddb8 Merge branch 'feature/PPaseprite多余的删除' into 'develop'
删除多余资源

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

See merge request aibis-dream/aibis-dream!738
2026-07-15 08:53:25 +00:00
dingyuntian 06d85d6460 refactor(ui): 移除 InfoPanel 的 I 键变量栏切换
删除 Update 中通过 I 键切换 variableBar 显隐的调试代码,
避免与现有输入冲突并保持面板职责单一。
2026-07-15 16:52:08 +08:00
dingyuntian 91ffbeb130 feat(ui): 添加录制工具面板
新增 RecordingToolPanel,按 Ctrl+R 唤出侧边栏,可独立控制光标、
版本号、Top Bar 与对话视觉的显隐。移除旧的
UIEventEnum.RecordingModeChanged 事件,改为各子系统提供显式
SetVisible API。在 Persistence 场景中配置面板层级与四个开关。
2026-07-15 16:51:55 +08:00
dingyuntian b14599d18c fix(dialog): 修复点击 UI 时对话被误推进
在 LineAdvanceInput 中通过 UIManager.IsPointerOverUI 判断
鼠标是否位于主 UI Canvas 的射线接收元素上,避免点击录制工具
等 UI 时推进对话。
2026-07-15 16:51:11 +08:00
dingyuntian 53a4fc86c4 feat: 设置修改,方便跑单元测试 2026-07-15 15:14:55 +08:00
dingyuntian 079d57e295 chore: 动画系统原型 2026-07-15 15:14:32 +08:00
dingyuntian 5f8ffb7f78 docs: 动画系统文档 2026-07-15 15:14:18 +08:00
dingyuntian 85a3fc9e14 feat(frameAnima): 动画系统前四个阶段 2026-07-15 15:13:56 +08:00
bottlefishandCursor ddb3aba4e5 art: 更新开盖动画资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:04:19 +08:00
bottlefishandCursor 1ee2a949e9 art(clinicout): 更新外出医生动画
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:02:38 +08:00
bottlefishandCursor dd66a52d65 scene: 将 Persistence 开场对话切到 Day2_FIRST_HS1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 14:00:48 +08:00
bottlefishandCursor 59bffeab06 test(dev-save): 重建 Huoshan1 阶段存档目录与 catalog
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:58:01 +08:00
bottlefishandCursor 423a3954dd feat(dev-save): 强化 Huoshan 阶段存档提升与跳转校验
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:57:21 +08:00
bottlefishandCursor 4ab2f83395 yarn(huoshan): 用阶段就绪节点替换中段存档点
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 13:55:33 +08:00
Ovid de85ca2944 录屏版本提交 2026-07-14 13:45:37 +08:00
bottlefishandCursor 9f6c926dcc test(dev-save): 按叙事顺序更新 Huoshan1 测试存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:55 +08:00
bottlefishandCursor d101864397 yarn(huoshan): 规范节点标签并在 Stage 入口补存档
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:43 +08:00
bottlefishandCursor 1cc5ac32aa scene(huoshan): 同步旋钮三态与发条触发角
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:27 +08:00
bottlefishandCursor 431401c52f fix(huoshan): 修复发条终点判定与越界拖拽
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:55:12 +08:00
bottlefishandCursor 0ed1253a40 feat(huoshan): 旋钮改为三态切图外观
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:58 +08:00
bottlefishandCursor 4f59e41b03 feat(subway): 添加车厢加速度驱动与扶手摆动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:43 +08:00
bottlefishandCursor df6f8c6ec4 yarn: 用 Timeline 替换英里登场与天桥 CG 静图
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:54:15 +08:00
bottlefishandCursor d00edd7bb8 scene: 登记叙事 Timeline 并挂入场景
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:59 +08:00
bottlefishandCursor 1be7b12deb art: 添加英里初登场与天桥夕阳 Timeline 资源
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:24 +08:00
bottlefishandCursor 51b8d86603 fix(dialog): 兼容 load_scene 多余淡入时长参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:53:10 +08:00
bottlefishandCursor c4dbdc098b feat(timeline): 添加 wait_timeline 以支持黑屏起播
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 22:52:54 +08:00
dingyuntian 19b6b9c4e2 fix: 章节编辑器问题修复 2026-07-13 22:24:39 +08:00
dingyuntian 92e8cd2f13 docs: 动画文档 2026-07-13 22:24:16 +08:00
bottlefishandCursor 26cfa5d886 test(dev-save): 添加 Day2Sleep 测试存档与晋升脚本
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:57:03 +08:00
bottlefishandCursor b98214632e feat(dream): 实现风筝镜头补偿与虚焦驱动
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:51 +08:00
bottlefishandCursor 2c74ec2030 feat(tarot): 优化摊牌缩放与逆位动画节奏
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:33 +08:00
bottlefishandCursor 71bfad4dae scene(huoshan): 同步表达与销售模块场景参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:23 +08:00
bottlefishandCursor c601dda9de fix(huoshan): 调整销售滑片角度并完善发条调试可视化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:14 +08:00
bottlefishandCursor 1422161844 feat(huoshan): 增强表达模块干扰表现与完成释放效果
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 20:56:00 +08:00
Ovid a494cf99cf D1S梦境调整 2026-07-13 19:42:33 +08:00
bottlefishandCursor ee1d100fb5 chore(render): 启用 ShapesRenderFeature
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:25:23 +08:00
bottlefishandCursor d0a08986c6 yarn(huoshan): 调整销售模块文案并补充对话节拍
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:54 +08:00
bottlefishandCursor 0d04d1a68d scene(huoshan): 同步发条与平静场场景参数
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:29 +08:00
bottlefishandCursor 52e0e9002f fix(huoshan): 优化发条完成判定并防止销售轮次重复触发
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:24:05 +08:00
bottlefishandCursor 4b3260dfcd feat(huoshan): 为聚焦文本添加 Glitch 平静场
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:23:41 +08:00
bottlefishandCursor 6689effb87 feat(ui): 为旋钮交互添加旋转光标状态
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:22:17 +08:00
bottlefishandCursor b062ab4ddc feat(dev-save): 用通用跳转工具替换佩佩存档跳转
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 14:21:51 +08:00
bottlefishandCursor 4104de4166 scene(persistence): 调整启动对话与单行框状态
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:41:17 +08:00
bottlefishandCursor 425cd1e695 chore(tmp): 补充 Fallback 动态字形数据
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:59 +08:00
bottlefishandCursor a48a6bf6ad scene(peipei): 同步后处理材质并调整场景配置
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:45 +08:00
bottlefishandCursor 98c558e800 feat(render): 配置 VolFx Ascii 后处理通道
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:30 +08:00
bottlefishandCursor cd00a524bb fix(heatmap): 优化温度过渡并修复热点尺寸
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 19:40:10 +08:00
dingyuntian 6d12bceb36 yarn: Yarn冲突处理 2026-07-11 16:25:58 +08:00
dingyuntian 52d7656c30 docs: 动画文档未完成版 2026-07-11 16:17:09 +08:00
dingyuntian 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
4066 changed files with 273458 additions and 677325 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 失败
---
开始执行请回复 **"开始"**,或告知需要的调整。
+72
View File
@@ -0,0 +1,72 @@
---
name: yarn-text-audit
description: 全量审校 Yarn 文本,生成可追溯问题台账并可导出飞书多维表格
---
对用户指定的 Yarn 目录执行全量文本审校。确定性覆盖与安全门禁由 `Tools/yarn_text_audit.py` 提供;语言和叙事判断必须保持可复核。
## 执行流程
1. 明确目标目录、参考目录、是否包含注释、是否只出报告。
2. 对所有目标目录运行:
```powershell
python Tools/yarn_text_audit.py scan `
--root <Yarn目录可重复> `
--output Temp/YarnTextAudit `
--review-memory <可选上次飞书审阅导出的 reviewed-base.json>
```
3. 检查 `Temp/YarnTextAudit/summary.json`,必须满足:
- `unknown_active_lines == 0`
- `unreviewed_records == 0`
- `active_records == ledger.jsonl` 实际行数
4. 分文件、分节点复核 `ledger.jsonl`。活动文本包括命名对白、无角色旁白、系统文本、选项和 option prompt;排除首个非空白字符为 `//` 的注释、节点元数据和 Yarn 命令。
5. 将确定性错误标为“建议修改”,语义、节奏、专名和重要信息等判断标为“需确认”;不以自动规则冒充绝对正确。
6. 交付 `issues.csv``coverage.csv`。用户要求飞书时,先运行 `python Tools/yarn_text_audit.py lark-payloads --output Temp/YarnTextAudit`,再创建 `问题清单``覆盖报告``术语与样式` 三张表;单批不超过 200 条并核对返回数量。
7. 用户完成多维表格审阅后,导出完整记录并执行:
```powershell
python Tools/yarn_text_audit.py apply-review `
--reviews Temp/YarnTextAudit/reviewed-base.json `
--manifest Temp/YarnTextAudit/manifest.json `
--report Temp/YarnTextAudit/apply-report.json
# 预览无误后才实际写入
python Tools/yarn_text_audit.py apply-review `
--reviews Temp/YarnTextAudit/reviewed-base.json `
--manifest Temp/YarnTextAudit/manifest.json `
--report Temp/YarnTextAudit/apply-report.json `
--apply
```
8. 修改后重新扫描并运行 `verify`。不得新增、重生成或修改 `#line:` 标签;不得增加文本显示行数。
## 人工反馈优先级
- 后续扫描使用 `--review-memory`;脚本会按原文或已应用后的预期文本重建稳定 ID,不重复报告已经人工决策的记录。
- “按照备注修改”高于分类和脚本建议;只执行备注明确要求的断行、颜色、文字或效果。
- 不因 `Task` 说话者、`role="Aside"` 或专有名词出现本身自动判定颜色。必须结合是否为玩家行动目标、真实内心表达及当前叙事重点逐处判断。
- 专有名词不要求每次出现都标蓝;检查/拆除/短接/装回等玩家下一步操作可以按上下文标黄。
- 笑话效果优先只包裹双关或谐音落点,不默认包裹整句。
- `#autonext` 不应一律修正为 `#auto_next`;若审阅备注要求移除,则直接移除。
- 长行断点以约 18 个全角字符的实际 UI 宽度估算自动折行数,优先在逗号处显式控制;已有断行未经批准不重排。
## AIBIS 审校规则
- 检查错别字、错误重复、缺字候选、的地得、全半角和标点、标签配对及换行舒适度。
- 换行优先考虑逗号附近;只有在不增加显示行数时才建议调整。逗号停顿只用于调整后的断点或明确节奏位置。
- `[[aside]]...[[/aside]]`:实际自言自语、念文本或小声说话;斜体本身不是充分条件。
- `[[term]]...[[/term]]`:游戏专有名词,默认蓝色 `#52B6FF`
- `[[key]]...[[/key]]`:玩家必须关注的信息,优先级高于专名。
- `[[comma-pause]]`:可配置的 0.2 秒逗号停顿。
- `[[ulm]]`FP_Peipei2 中每条活动 `ULM自训练进程:` 文本。
- `[[joke]]...[[/joke]]`:火山实际讲出的笑话,不包括讨论笑话的说明对白。
- 本地输出放入已忽略的 `Temp/YarnTextAudit`;修改前保存文件 SHA-256,应用时必须精确匹配原文与哈希。
## 自检
```powershell
python Tools/yarn_text_audit.py self-test
python Tools/yarn_text_audit.py verify --output Temp/YarnTextAudit
```
@@ -0,0 +1,94 @@
---
name: narrative-timeline-flow
description: >-
叙事 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: ae2a22805ec7ff55174c38995aa3e54d
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 0
m_BundleLocalCatalog: 0
@@ -59,7 +59,6 @@ MonoBehaviour:
- {fileID: 11400000, guid: 6d259d339c6615248b893837398c1438, type: 2}
- {fileID: 11400000, guid: 8718a71c99923e247a960cbf4e4795f5, type: 2}
- {fileID: 11400000, guid: 1e521cfc13b314642b1e682e3be7ea17, type: 2}
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
- {fileID: 11400000, guid: 724eed66b2d8eef4faf77a4bee4d2845, type: 2}
- {fileID: 11400000, guid: 62a92c74b9bd5bd439690727c1831500, type: 2}
- {fileID: 11400000, guid: b593dc98ea11a484995123de77d9bf1a, type: 2}
@@ -68,6 +67,10 @@ MonoBehaviour:
- {fileID: 11400000, guid: dc8d31ed80ea35945812ff6552efbbe5, type: 2}
- {fileID: 11400000, guid: af94af36950b08d4f9a5037546bbe040, type: 2}
- {fileID: 11400000, guid: 60fd2fd8c7094b242b01c63de65de9ea, type: 2}
- {fileID: 11400000, guid: 7e4ab0de168458b43a21dc2f9e811ff5, type: 2}
- {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
- {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
- {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_BuildSettings:
m_CompileScriptsInVirtualMode: 0
m_CleanupStreamingAssetsAfterBuilds: 1
@@ -115,6 +118,9 @@ MonoBehaviour:
- Locale-zh-Hans
- SceneSO
- Locale-ja-JP
- Locale-pt-BR
- Locale-ru
- Locale-es
m_SchemaTemplates: []
m_GroupTemplateObjects:
- {fileID: 11400000, guid: 0c158052680a3e94184c450d00291514, type: 2}
@@ -47,6 +47,11 @@ MonoBehaviour:
- Locale-en
- Locale-ja-JP
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7ea8c68fdc624de4c86434963c06ec55
m_Address: Assets/Language/ChapterInfo/ChapterInfo Shared Data.asset
m_ReadOnly: 1
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -35,6 +35,24 @@ MonoBehaviour:
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 9b1581a48f0ca11448dbddaf79c11252
m_Address: Russian (ru)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 3cadb0d5f8bf5e64599f52e139f7e3cc
m_Address: Spanish (es)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: d0c46f4777ac92249a43143a15892fd2
m_Address: Portuguese (Brazil) (pt-BR)
m_ReadOnly: 1
m_SerializedLabels:
- Locale
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -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:
@@ -0,0 +1,43 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Portuguese (Brazil) (pt-BR)
m_Data:
m_SerializedData: []
m_GUID: 67ced675275774640b77e0ded8750379
m_SerializeEntries:
- m_GUID: 63300a11309a7b34bbf7a345b123cab8
m_Address: ChapterInfo_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 6ad3c9298dbc3b642b1251ffc306eaa6
m_Address: Params_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7d496f91e240bb143b62a6239c817d09
m_Address: UIText_pt-BR
m_ReadOnly: 1
m_SerializedLabels:
- Locale-pt-BR
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: b430e9b782d3119489b903dce4915542, type: 2}
- {fileID: 11400000, guid: 70748601b27158b47810538f6ed1c013, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 089fc86f714d6a94bafea3b117fbd639
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Russian (ru)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Russian (ru)
m_Data:
m_SerializedData: []
m_GUID: c1e47d7d0d831ac4b881f19aa2bf17e0
m_SerializeEntries:
- m_GUID: c69caec2066296840887d7e0c2eff52e
m_Address: ChapterInfo_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: b91f4281442d2b54f92fbecb5bd56426
m_Address: Params_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: f679069e455200841a92c27186383940
m_Address: UIText_ru
m_ReadOnly: 1
m_SerializedLabels:
- Locale-ru
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: b251ae14d0b273843b5674d239daa31c, type: 2}
- {fileID: 11400000, guid: 808fc72235ececa47aa55960a94a9a9a, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 1c592abe924a856439461f3785ec53da
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,43 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bbb281ee3bf0b054c82ac2347e9e782c, type: 3}
m_Name: Localization-String-Tables-Spanish (es)
m_EditorClassIdentifier:
m_GroupName: Localization-String-Tables-Spanish (es)
m_Data:
m_SerializedData: []
m_GUID: c8471b00dabdd224ab9c17d71d951cbc
m_SerializeEntries:
- m_GUID: 728457f9c2ef69c489530047d495eca5
m_Address: ChapterInfo_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 1edf9254b97981643bdecd6d22ed1150
m_Address: Params_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 380c27123212d66468aa1f3ed3bf72ea
m_Address: UIText_es
m_ReadOnly: 1
m_SerializedLabels:
- Locale-es
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 1
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
m_Schemas:
- {fileID: 11400000, guid: 3ddf5f47a1b265049825b193e7e93fbd, type: 2}
- {fileID: 11400000, guid: 3e5e5c2400a39814994a9fcb8eaf49a6, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 26fd03652560e7f4abec092229fc38f2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -47,8 +47,13 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: ea008b0c912c0d748997bf458879cb74
m_Address: "Animation/\u5929\u6865\u4F69\u4F69"
- m_GUID: f19cff809e7020b41ba8ad7da199db86
m_Address: "Timeline/day2\u5915\u9633\u533B\u751F\u770B\u4F69\u4F69\u4FA7\u8138"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: f18fc85f0eeecb44b9cfae24e8e19fa1
m_Address: "FrameAnimation/\u5929\u6865\u4F69\u4F69"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
@@ -17,8 +17,8 @@ MonoBehaviour:
m_SerializedData: []
m_GUID: 9ae1da1b7a680244cbe735b3b4437f26
m_SerializeEntries:
- m_GUID: b6a14d8a19b9a3041baf76df2e8e4558
m_Address: "Animation/\u8BCA\u5BA4\u5916\u4F69\u4F69"
- m_GUID: 01b1640d62cd5ff4686088bb3d4c8fba
m_Address: "FrameAnimation/\u8BCA\u5BA4\u5916\u4F69\u4F69"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
@@ -72,13 +72,18 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 7760fc67933fb684d81ea5275fac0a50
m_Address: "Animation/\u8BCA\u5BA4\u5916\u706B\u5C71"
- m_GUID: 253f47db959b2814998695fb5d1f4d97
m_Address: "Timeline/\u65CB\u8F6C\u53F6\u67C4"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 253f47db959b2814998695fb5d1f4d97
m_Address: "Timeline/\u65CB\u8F6C\u53F6\u67C4"
- m_GUID: 1799dbcdb4a76624bb89622707581f92
m_Address: "Timeline/\u82F1\u91CC\u521D\u767B\u573A"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 3d0198d0d6d221948a3d5ddf9cd2c34f
m_Address: "FrameAnimation/\u8BCA\u5BA4\u5916\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
@@ -32,11 +32,6 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 417a7984630786f46a5ade5d2b457852
m_Address: "Animation/\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 838bd5cc2ceffc1459d167675d96f66a
m_Address: "Timeline/\u706B\u5C71\u8868\u8FBEpanel"
m_ReadOnly: 0
@@ -87,11 +82,21 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 58d09572ecd54714a787a1fc5aec6c5f
m_Address: "Timeline/\u706B\u5C71\u5934\u75DB3"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: e69af1ed498c57f4799f936aa28e4ed4
m_Address: "Timeline/\u706B\u5C71\u5934\u75DB1"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: efa335dcb6e706347bd4acf3222331b9
m_Address: "FrameAnimation/\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -32,8 +32,8 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 809f95af8dd580448ab804b94fbd9cb2
m_Address: "Animation/\u4F69\u4F69"
- m_GUID: 26105571c9644aa4194f2b981ee33a58
m_Address: "FrameAnimation/\u4F69\u4F69"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
@@ -22,23 +22,8 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 2d4ccf1c582289344976ab5c3af05515
m_Address: "Animation/\u5730\u94C1\u533B\u751F"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 0057bf4b5fe16aa4b8c7db27d0ddfc0a
m_Address: "Animation/\u5730\u94C1\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 40f07326ace570241bd93fcfc3decb87
m_Address: "Animation/\u5730\u94C1\u706B\u5C71\u6697"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: a63b52b3e8d9e0441aaff086b9c1ae6b
m_Address: "Animation/\u5730\u94C1\u533B\u751F\u6697"
- m_GUID: b0ee8d48dab31814da87651bba9b5041
m_Address: "FrameAnimation/\u5730\u94C1\u533B\u751F"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
@@ -57,6 +42,16 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: b02d1e0ae4bc971408a0b6a667597624
m_Address: "FrameAnimation/\u5730\u94C1\u706B\u5C71"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: 66be33a1bdb16ee409c6ac05b61e08a7
m_Address: "Subway/\u76F8\u9519\u5217\u8F66"
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
m_ReadOnly: 0
m_Settings: {fileID: 11400000, guid: 77169ce22e430f64fb36c771815a4a7b, type: 2}
m_SchemaSet:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b430e9b782d3119489b903dce4915542
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Portuguese (Brazil) (pt-BR)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 089fc86f714d6a94bafea3b117fbd639, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 70748601b27158b47810538f6ed1c013
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Russian (ru)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b251ae14d0b273843b5674d239daa31c
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Russian (ru)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 1c592abe924a856439461f3785ec53da, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 808fc72235ececa47aa55960a94a9a9a
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e5d17a21594effb4e9591490b009e7aa, type: 3}
m_Name: Localization-String-Tables-Spanish (es)_BundledAssetGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_InternalBundleIdMode: 1
m_Compression: 1
m_IncludeAddressInCatalog: 1
m_IncludeGUIDInCatalog: 1
m_IncludeLabelsInCatalog: 1
m_InternalIdNamingMode: 0
m_CacheClearBehavior: 0
m_IncludeInBuild: 1
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_ForceUniqueProvider: 0
m_UseAssetBundleCache: 1
m_UseAssetBundleCrc: 1
m_UseAssetBundleCrcForCachedBundles: 1
m_UseUWRForLocalBundles: 0
m_Timeout: 0
m_ChunkedTransfer: 0
m_RedirectLimit: -1
m_RetryCount: 0
m_BuildPath:
m_Id: 2ae6a7549097a9b4caefe44b61b1fcc5
m_LoadPath:
m_Id: 631ec5c677ab8084fb9be9b9ee39c6ef
m_BundleMode: 0
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_BundleNaming: 1
m_AssetLoadMode: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3ddf5f47a1b265049825b193e7e93fbd
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,16 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5834b5087d578d24c926ce20cd31e6d6, type: 3}
m_Name: Localization-String-Tables-Spanish (es)_ContentUpdateGroupSchema
m_EditorClassIdentifier:
m_Group: {fileID: 11400000, guid: 26fd03652560e7f4abec092229fc38f2, type: 2}
m_StaticContent: 0
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3e5e5c2400a39814994a9fcb8eaf49a6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
@@ -32,6 +32,11 @@ MonoBehaviour:
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: b9e730c7e5824c5ca2858a253fe93c98
m_Address: Prefab/FrameAnimationActor
m_ReadOnly: 0
m_SerializedLabels: []
FlaggedDuringContentUpdateRestriction: 0
- m_GUID: cd996317bdc6ae8478acb2de956f25d9
m_Address: Prefab/AnimaMobileObject
m_ReadOnly: 0
@@ -0,0 +1,550 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &-7273616238501396146
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
m_Name: VolumeAsset
m_EditorClassIdentifier:
m_Template:
Weight: 1
m_Volume: {fileID: 11400000, guid: 0af8555a6447b50409db2139400d6ab7, type: 2}
--- !u!114 &-3378792728507138695
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
m_Name: Volume Track (1)
m_EditorClassIdentifier:
m_Version: 3
m_AnimClip: {fileID: 0}
m_Locked: 0
m_Muted: 0
m_CustomPlayableFullTypename:
m_Curves: {fileID: 0}
m_Parent: {fileID: 11400000}
m_Children: []
m_Clips:
- m_Version: 1
m_Start: 0
m_ClipIn: 0
m_Asset: {fileID: 7967868333749088964}
m_Duration: 0.75
m_TimeScale: 1
m_ParentTrack: {fileID: -3378792728507138695}
m_EaseInDuration: 0.05
m_EaseOutDuration: 0.28
m_BlendInDuration: -1
m_BlendOutDuration: -1
m_MixInCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_MixOutCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_BlendInCurveMode: 0
m_BlendOutCurveMode: 0
m_ExposedParameterNames: []
m_AnimationCurves: {fileID: 0}
m_Recordable: 0
m_PostExtrapolationMode: 0
m_PreExtrapolationMode: 0
m_PostExtrapolationTime: 0
m_PreExtrapolationTime: 0
m_DisplayName: VolumeAsset
m_Markers:
m_Objects: []
m_Layer: 0
m_Priority: 0
--- !u!114 &-3117513502812618692
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
m_Name: Volume Track
m_EditorClassIdentifier:
m_Version: 3
m_AnimClip: {fileID: 0}
m_Locked: 0
m_Muted: 0
m_CustomPlayableFullTypename:
m_Curves: {fileID: 0}
m_Parent: {fileID: 11400000}
m_Children: []
m_Clips:
- m_Version: 1
m_Start: 0
m_ClipIn: 0
m_Asset: {fileID: -7273616238501396146}
m_Duration: 0.75
m_TimeScale: 1
m_ParentTrack: {fileID: -3117513502812618692}
m_EaseInDuration: 0.05
m_EaseOutDuration: 0.28
m_BlendInDuration: -1
m_BlendOutDuration: -1
m_MixInCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_MixOutCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_BlendInCurveMode: 0
m_BlendOutCurveMode: 0
m_ExposedParameterNames: []
m_AnimationCurves: {fileID: 0}
m_Recordable: 0
m_PostExtrapolationMode: 0
m_PreExtrapolationMode: 0
m_PostExtrapolationTime: 0
m_PreExtrapolationTime: 0
m_DisplayName: VolumeAsset
m_Markers:
m_Objects: []
m_Layer: 0
m_Priority: 0
--- !u!114 &-2955631063214815081
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: cd3925730e7a59446932e9c8cba28ccf, type: 3}
m_Name: Volume Track (2)
m_EditorClassIdentifier:
m_Version: 3
m_AnimClip: {fileID: 0}
m_Locked: 0
m_Muted: 0
m_CustomPlayableFullTypename:
m_Curves: {fileID: 0}
m_Parent: {fileID: 11400000}
m_Children: []
m_Clips:
- m_Version: 1
m_Start: 0
m_ClipIn: 0
m_Asset: {fileID: -1361877534822022742}
m_Duration: 0.75
m_TimeScale: 1
m_ParentTrack: {fileID: -2955631063214815081}
m_EaseInDuration: 0.05
m_EaseOutDuration: 0.28
m_BlendInDuration: -1
m_BlendOutDuration: -1
m_MixInCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_MixOutCurve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 1
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
- serializedVersion: 3
time: 1
value: 0
inSlope: 0
outSlope: 0
tangentMode: 0
weightedMode: 0
inWeight: 0
outWeight: 0
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
m_BlendInCurveMode: 0
m_BlendOutCurveMode: 0
m_ExposedParameterNames: []
m_AnimationCurves: {fileID: 0}
m_Recordable: 0
m_PostExtrapolationMode: 0
m_PreExtrapolationMode: 0
m_PostExtrapolationTime: 0
m_PreExtrapolationTime: 0
m_DisplayName: VolumeAsset
m_Markers:
m_Objects: []
m_Layer: 0
m_Priority: 0
--- !u!114 &-1361877534822022742
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
m_Name: VolumeAsset
m_EditorClassIdentifier:
m_Template:
Weight: 1
m_Volume: {fileID: 11400000, guid: 631b3200517e20440b88f28015fc3a5c, type: 2}
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bfda56da833e2384a9677cd3c976a436, type: 3}
m_Name: "\u706B\u5C71\u5934\u75DB3"
m_EditorClassIdentifier:
m_Version: 0
m_Tracks:
- {fileID: -3117513502812618692}
- {fileID: -3378792728507138695}
- {fileID: -2955631063214815081}
- {fileID: 2112685405309563144}
m_FixedDuration: 0
m_EditorSettings:
m_Framerate: 60
m_ScenePreview: 1
m_DurationMode: 0
m_MarkerTrack: {fileID: 0}
--- !u!74 &1077202520131138910
AnimationClip:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Recorded
serializedVersion: 7
m_Legacy: 0
m_Compressed: 0
m_UseHighQualityCurve: 1
m_RotationCurves: []
m_CompressedRotationCurves: []
m_EulerCurves: []
m_PositionCurves: []
m_ScaleCurves: []
m_FloatCurves:
- serializedVersion: 2
curve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.07
value: 0.8
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.42
value: 0.8
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.68
value: 0
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
attribute: m_Color.a
path:
classID: 212
script: {fileID: 0}
flags: 0
m_PPtrCurves: []
m_SampleRate: 60
m_WrapMode: 0
m_Bounds:
m_Center: {x: 0, y: 0, z: 0}
m_Extent: {x: 0, y: 0, z: 0}
m_ClipBindingConstant:
genericBindings:
- serializedVersion: 2
path: 0
attribute: 304273561
script: {fileID: 0}
typeID: 212
customType: 0
isPPtrCurve: 0
isIntCurve: 0
isSerializeReferenceCurve: 0
pptrCurveMapping: []
m_AnimationClipSettings:
serializedVersion: 2
m_AdditiveReferencePoseClip: {fileID: 0}
m_AdditiveReferencePoseTime: 0
m_StartTime: 0
m_StopTime: 0.68
m_OrientationOffsetY: 0
m_Level: 0
m_CycleOffset: 0
m_HasAdditiveReferencePose: 0
m_LoopTime: 0
m_LoopBlend: 0
m_LoopBlendOrientation: 0
m_LoopBlendPositionY: 0
m_LoopBlendPositionXZ: 0
m_KeepOriginalOrientation: 0
m_KeepOriginalPositionY: 1
m_KeepOriginalPositionXZ: 0
m_HeightFromFeet: 0
m_Mirror: 0
m_EditorCurves:
- serializedVersion: 2
curve:
serializedVersion: 2
m_Curve:
- serializedVersion: 3
time: 0
value: 0
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.07
value: 0.8
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.42
value: 0.8
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
- serializedVersion: 3
time: 0.68
value: 0
inSlope: 0
outSlope: 0
tangentMode: 136
weightedMode: 0
inWeight: 0.33333334
outWeight: 0.33333334
m_PreInfinity: 2
m_PostInfinity: 2
m_RotationOrder: 4
attribute: m_Color.a
path:
classID: 212
script: {fileID: 0}
flags: 0
m_EulerEditorCurves: []
m_HasGenericRootTransform: 0
m_HasMotionFloatCurves: 0
m_Events: []
--- !u!114 &2112685405309563144
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d21dcc2386d650c4597f3633c75a1f98, type: 3}
m_Name: Animation Track
m_EditorClassIdentifier:
m_Version: 3
m_AnimClip: {fileID: 0}
m_Locked: 0
m_Muted: 0
m_CustomPlayableFullTypename:
m_Curves: {fileID: 0}
m_Parent: {fileID: 11400000}
m_Children: []
m_Clips: []
m_Markers:
m_Objects: []
m_InfiniteClipPreExtrapolation: 1
m_InfiniteClipPostExtrapolation: 1
m_InfiniteClipOffsetPosition: {x: 0, y: 0, z: 0}
m_InfiniteClipOffsetEulerAngles: {x: 0, y: 0, z: 0}
m_InfiniteClipTimeOffset: 0
m_InfiniteClipRemoveOffset: 0
m_InfiniteClipApplyFootIK: 1
mInfiniteClipLoop: 0
m_MatchTargetFields: 63
m_Position: {x: 0, y: 0, z: 0}
m_EulerAngles: {x: 0, y: 0, z: 0}
m_AvatarMask: {fileID: 0}
m_ApplyAvatarMask: 1
m_TrackOffset: 0
m_InfiniteClip: {fileID: 1077202520131138910}
m_OpenClipOffsetRotation: {x: 0, y: 0, z: 0, w: 1}
m_Rotation: {x: 0, y: 0, z: 0, w: 1}
m_ApplyOffsets: 0
--- !u!114 &7967868333749088964
MonoBehaviour:
m_ObjectHideFlags: 1
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: bcae7dc92d05e1e4094e0e448b670355, type: 3}
m_Name: VolumeAsset
m_EditorClassIdentifier:
m_Template:
Weight: 1
m_Volume: {fileID: 11400000, guid: 841479fd6ffe3ee40a88ee41bd395f09, type: 2}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 58d09572ecd54714a787a1fc5aec6c5f
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:
Binary file not shown.
@@ -244,3 +244,14 @@ MonoBehaviour:
m_PreInfinity: 0
m_PostInfinity: 0
m_RotationOrder: 0
- m_From: '**ANY CAMERA**'
m_To: Dream Camera
m_Blend:
m_Style: 0
m_Time: 2
m_CustomCurve:
serializedVersion: 2
m_Curve: []
m_PreInfinity: 0
m_PostInfinity: 0
m_RotationOrder: 0
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 638cd3fa1c2470d42aceb69214c01786
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,260 @@
using System.Collections.Generic;
using System.Linq;
using AibisDream.Kit;
using AibisDream.SaveSystem;
using FMOD;
using FMOD.Studio;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NUnit.Framework;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AudioGlobalParameterSnapshotTests
{
[Test]
public void SetFromYarn_TracksOnlySuccessfulUniqueNames()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
Assert.That(registry.SetFromYarn("reverb", 0.4f), Is.EqualTo(RESULT.OK));
Assert.That(registry.SetFromYarn("REVERB", 0.7f), Is.EqualTo(RESULT.OK));
Assert.That(
registry.SetFromYarn("missing", 1f),
Is.EqualTo(RESULT.ERR_EVENT_NOTFOUND));
Assert.That(registry.TrackedNames, Has.Count.EqualTo(1));
Assert.That(registry.TrackedNames.Single(), Is.EqualTo("reverb"));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.False);
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.False);
}
[Test]
public void CaptureFinalValues_ReadsFmodFinalValueAndRetainsFailuresForRetry()
{
var api = new FakeFmodGlobalParameterApi();
api.Reads["stage"] = new ParameterRead(9f, 2.37f, RESULT.OK);
api.Reads["missing"] = new ParameterRead(0f, 0f, RESULT.ERR_EVENT_NOTFOUND);
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("stage", 9f);
registry.SetFromYarn("missing", 1f);
var warnings = new List<string>();
var snapshot = registry.CaptureFinalValues(warnings.Add);
Assert.That(snapshot, Has.Count.EqualTo(1));
Assert.That(snapshot["stage"], Is.EqualTo(2.37f));
Assert.That(snapshot["stage"], Is.Not.EqualTo(9f));
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage", "missing" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void ResetToDefaults_RemovesOnlyParametersThatFmodResetSuccessfully()
{
var goodId = new PARAMETER_ID { data1 = 1, data2 = 2 };
var failedId = new PARAMETER_ID { data1 = 3, data2 = 4 };
var api = new FakeFmodGlobalParameterApi();
api.Descriptions["good"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = goodId, defaultvalue = 0.25f },
RESULT.OK);
api.Descriptions["failed"] = new ParameterDescriptionRead(
new PARAMETER_DESCRIPTION { id = failedId, defaultvalue = 0.75f },
RESULT.OK);
api.SetByIdResults[failedId] = RESULT.ERR_INVALID_PARAM;
var registry = new YarnGlobalParameterRegistry(api);
registry.SetFromYarn("good", 1f);
registry.SetFromYarn("failed", 1f);
var warnings = new List<string>();
registry.ResetToDefaults(warnings.Add);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "failed" }));
Assert.That(api.SetByIdCalls, Has.Count.EqualTo(2));
var goodReset = api.SetByIdCalls.Single(call => call.Id.Equals(goodId));
Assert.That(goodReset.Value, Is.EqualTo(0.25f));
Assert.That(goodReset.IgnoreSeekSpeed, Is.True);
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("failed", warnings[0]);
}
[Test]
public void RestoreFinalValues_JumpsImmediatelyThenNextYarnTargetUsesSeekSpeed()
{
var api = new FakeFmodGlobalParameterApi();
api.SetByNameResults["missing"] = RESULT.ERR_EVENT_NOTFOUND;
var registry = new YarnGlobalParameterRegistry(api);
var warnings = new List<string>();
registry.RestoreFinalValues(
new Dictionary<string, float>
{
["stage"] = 2.37f,
["missing"] = 1f
},
warnings.Add);
registry.SetFromYarn("stage", 3f);
Assert.That(api.SetByNameCalls, Has.Count.EqualTo(3));
Assert.That(api.SetByNameCalls[0].Value, Is.EqualTo(2.37f));
Assert.That(api.SetByNameCalls[0].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[1].Name, Is.EqualTo("missing"));
Assert.That(api.SetByNameCalls[1].IgnoreSeekSpeed, Is.True);
Assert.That(api.SetByNameCalls[2].Value, Is.EqualTo(3f));
Assert.That(api.SetByNameCalls[2].IgnoreSeekSpeed, Is.False);
Assert.That(registry.TrackedNames, Is.EquivalentTo(new[] { "stage" }));
Assert.That(warnings, Has.Count.EqualTo(1));
StringAssert.Contains("missing", warnings[0]);
}
[Test]
public void AudioSnapshotDto_RoundTripsFinalValuesWithoutChangingSchema()
{
var source = new SaveSnapshot();
source.sections[SnapshotProviderIds.Audio] = new AudioSnapshotDto
{
ambState = "Dream",
yarnGlobalParameters = new Dictionary<string, float>
{
["reverb"] = 0.42f,
["hs1LogStage"] = 2.37f
}
};
var json = JsonConvert.SerializeObject(source);
var restored = JsonConvert.DeserializeObject<SaveSnapshot>(json);
var audio = ((JObject)restored.sections[SnapshotProviderIds.Audio])
.ToObject<AudioSnapshotDto>();
Assert.That(restored.schemaVersion, Is.EqualTo(2));
Assert.That(SaveSnapshotSchema.CurrentVersion, Is.EqualTo(2));
Assert.That(audio.yarnGlobalParameters["reverb"], Is.EqualTo(0.42f));
Assert.That(audio.yarnGlobalParameters["hs1LogStage"], Is.EqualTo(2.37f));
}
private readonly struct SetByNameCall
{
internal SetByNameCall(string name, float value, bool ignoreSeekSpeed)
{
Name = name;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal string Name { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct SetByIdCall
{
internal SetByIdCall(PARAMETER_ID id, float value, bool ignoreSeekSpeed)
{
Id = id;
Value = value;
IgnoreSeekSpeed = ignoreSeekSpeed;
}
internal PARAMETER_ID Id { get; }
internal float Value { get; }
internal bool IgnoreSeekSpeed { get; }
}
private readonly struct ParameterRead
{
internal ParameterRead(float value, float finalValue, RESULT result)
{
Value = value;
FinalValue = finalValue;
Result = result;
}
internal float Value { get; }
internal float FinalValue { get; }
internal RESULT Result { get; }
}
private readonly struct ParameterDescriptionRead
{
internal ParameterDescriptionRead(
PARAMETER_DESCRIPTION description,
RESULT result)
{
Description = description;
Result = result;
}
internal PARAMETER_DESCRIPTION Description { get; }
internal RESULT Result { get; }
}
private sealed class FakeFmodGlobalParameterApi : IFmodGlobalParameterApi
{
internal readonly Dictionary<string, RESULT> SetByNameResults =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterRead> Reads =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<string, ParameterDescriptionRead> Descriptions =
new(System.StringComparer.OrdinalIgnoreCase);
internal readonly Dictionary<PARAMETER_ID, RESULT> SetByIdResults = new();
internal readonly List<SetByNameCall> SetByNameCalls = new();
internal readonly List<SetByIdCall> SetByIdCalls = new();
public RESULT SetParameterByName(
string name,
float value,
bool ignoreSeekSpeed)
{
SetByNameCalls.Add(new SetByNameCall(name, value, ignoreSeekSpeed));
return SetByNameResults.TryGetValue(name, out var result)
? result
: RESULT.OK;
}
public RESULT GetParameterByName(
string name,
out float value,
out float finalValue)
{
if (Reads.TryGetValue(name, out var read))
{
value = read.Value;
finalValue = read.FinalValue;
return read.Result;
}
value = 0f;
finalValue = 0f;
return RESULT.OK;
}
public RESULT GetParameterDescriptionByName(
string name,
out PARAMETER_DESCRIPTION description)
{
if (Descriptions.TryGetValue(name, out var read))
{
description = read.Description;
return read.Result;
}
description = default;
return RESULT.ERR_EVENT_NOTFOUND;
}
public RESULT SetParameterById(
PARAMETER_ID id,
float value,
bool ignoreSeekSpeed)
{
SetByIdCalls.Add(new SetByIdCall(id, value, ignoreSeekSpeed));
return SetByIdResults.TryGetValue(id, out var result)
? result
: RESULT.OK;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e120d9cfce5a4723a6e640b3cc3ce29b
+123
View File
@@ -0,0 +1,123 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Yarn.Markup;
using Yarn.Unity;
namespace AibisDream.SystemEditor.Tests
{
public sealed class AutoNextMetadataTests
{
[Test]
public void AutoNextWithoutParameter_UsesExistingFixedDelay()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[Test]
public void AutoNextWithParameter_UsesSpecifiedSeconds()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
[Test]
public void AutoNextWithZeroDelay_IsValid()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next:0"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(0f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.Zero);
}
[Test]
public void AutoNextParameter_UsesInvariantCulture()
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
var lineInfo = LineInfo.Generate(CreateLine("auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(4.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(4500));
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
[TestCase("auto_next:")]
[TestCase("auto_next:abc")]
[TestCase("auto_next:-1")]
[TestCase("auto_next:NaN")]
[TestCase("auto_next:Infinity")]
[TestCase("auto_next:2147484")]
public void AutoNextWithInvalidParameter_FallsBackToExistingFixedDelay(string metadata)
{
LogAssert.Expect(LogType.Warning, new Regex("auto_next 参数无效"));
var lineInfo = LineInfo.Generate(CreateLine(metadata));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.Null);
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(ConstRef.FixedDelay));
}
[TestCase("auto_next_extra")]
[TestCase("auto_next_extra:4.5")]
[TestCase("AUTO_NEXT")]
[TestCase("AUTO_NEXT:4.5")]
public void SimilarMetadata_IsNotRecognized(string metadata)
{
var line = CreateLine(metadata);
Assert.That(line.IsAutoSkipLine(), Is.False);
Assert.That(line.TryGetAutoNextDelaySeconds(out _), Is.False);
}
[Test]
public void ParameterizedTag_TakesPrecedenceOverPlainTag()
{
var lineInfo = LineInfo.Generate(CreateLine("auto_next", "auto_next:2.5"));
Assert.That(lineInfo.isAutoSkip, Is.True);
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
[Test]
public void MultipleParameterizedTags_UseFirstAndWarn()
{
LogAssert.Expect(LogType.Warning, new Regex("存在多个 auto_next 参数标签"));
var lineInfo = LineInfo.Generate(CreateLine("auto_next:2.5", "auto_next:4.5"));
Assert.That(lineInfo.autoNextDelaySeconds, Is.EqualTo(2.5f));
Assert.That(lineInfo.CalcAutoNextDelayTime(), Is.EqualTo(2500));
}
private static LocalizedLine CreateLine(params string[] metadata)
{
return new LocalizedLine
{
TextID = "line:auto-next-test",
Metadata = metadata,
Text = new MarkupParseResult("Test line", new List<MarkupAttribute>())
};
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d476e493adf4855468ce46409dae6661
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -22,7 +22,7 @@ namespace AibisDream.SystemEditor
private DropdownField _folderDropdown;
private readonly Dictionary<string, string> _displayToPath = new();
[MenuItem("Window/Chapter Graph Editor")]
[MenuItem(AibisEditorMenus.ChapterGraphEditor)]
public static void ShowWindow()
{
var window = GetWindow<ChapterGraphEditorWindow>("Chapter Graph Editor");
@@ -148,9 +148,19 @@ namespace AibisDream.SystemEditor
});
// 右侧 GraphView
// 套一层容器:GraphView 与左侧 Inspector 并列放在 Flex Row 中时,
// 内置 RectangleSelector 绘制的框选矩形会出现坐标偏移(已知 Unity 问题)。
// 将 GraphView 放在独立的 Flex 子容器中可使其局部原点与父容器对齐,规避该偏移。
var graphViewWrapper = new VisualElement();
graphViewWrapper.style.flexGrow = 1;
graphViewWrapper.style.flexDirection = FlexDirection.Column;
graphViewWrapper.style.overflow = Overflow.Hidden;
_graphView = new ChapterGraphView();
_graphView.style.flexGrow = 1;
mainContainer.Add(_graphView);
graphViewWrapper.Add(_graphView);
mainContainer.Add(graphViewWrapper);
root.Add(mainContainer);
@@ -17,6 +17,7 @@ namespace AibisDream.SystemEditor
EditorGUILayout.Space();
EditorGUILayout.LabelField("显示信息", EditorStyles.boldLabel);
EditorGUILayout.PropertyField(serializedObject.FindProperty("title"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("titleKey"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("description"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("coverPic"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("chapter"));
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 665561f3805a96446b45ea1d50bfe520
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
#if UNITY_EDITOR
using System.Linq;
using AibisDream.Kit;
using AibisDream.UI;
using AibisDream.Utility;
using NUnit.Framework;
using UnityEditor;
using UnityEngine;
namespace AibisDream.DeveloperMode.Editor
{
public sealed class DeveloperModeCoreTests
{
[Test]
public void RuntimeLogBufferEvictsOldestAndPreservesOrder()
{
var buffer = new RuntimeLogBuffer(3);
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, "one"));
buffer.Add(LogEntry.System(LogLevel.Warning, LogCategory.Save, "two"));
buffer.Add(LogEntry.System(LogLevel.Error, LogCategory.Yarn, "three"));
buffer.Add(LogEntry.System(LogLevel.Fatal, LogCategory.Scene, "four"));
var snapshot = buffer.Snapshot();
Assert.That(snapshot.Select(item => item.Message), Is.EqualTo(new[] { "two", "three", "four" }));
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
Assert.That(snapshot.Length, Is.EqualTo(buffer.Capacity));
}
[Test]
public void RuntimeLogBufferAcceptsConcurrentWritersAndClears()
{
var buffer = new RuntimeLogBuffer(128);
System.Threading.Tasks.Parallel.For(0, 1000, index =>
buffer.Add(LogEntry.System(LogLevel.Info, LogCategory.General, index.ToString())));
var snapshot = buffer.Snapshot();
Assert.That(snapshot.Length, Is.EqualTo(128));
Assert.That(snapshot.Select(item => item.Sequence), Is.Ordered.Ascending);
buffer.Clear();
Assert.That(buffer.Snapshot(), Is.Empty);
}
[Test]
public void YarnStorageTypedSettersAndDebugSnapshotAreReadOnlyAndConsistent()
{
var go = new GameObject("YarnVariableStorage Test");
try
{
var storage = go.AddComponent<YarnVariableStorage>();
storage.SetValue("$score", 12.5f);
storage.SetValue("$global_flag", true);
storage.SetValue("$name", "AIBIS");
var snapshot = DeveloperVariableSnapshot.Capture(storage, null);
Assert.That(snapshot.Select(item => item.Name),
Is.EquivalentTo(new[] { "$score", "$global_flag", "$name" }));
Assert.That(snapshot.Single(item => item.Name == "$score").Value, Is.EqualTo("12.5"));
Assert.That(snapshot.Single(item => item.Name == "$global_flag").IsGlobal, Is.True);
Assert.That(snapshot.All(item => item.HasRuntimeOverride), Is.True);
storage.ClearLocal();
var remaining = DeveloperVariableSnapshot.Capture(storage, null);
Assert.That(remaining.Select(item => item.Name), Is.EqualTo(new[] { "$global_flag" }));
storage.Clear();
Assert.That(DeveloperVariableSnapshot.Capture(storage, null), Is.Empty);
}
finally
{
Object.DestroyImmediate(go);
}
}
[Test]
public void EditorBuildEnablesDeveloperMode()
{
Assert.That(DeveloperModeGate.IsEnabled, Is.True);
}
[Test]
public void PrefabIsConfiguredAndPersistenceReferencesIt()
{
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
const string scenePath = "Assets/Scenes/Persistence.unity";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
Assert.That(prefab, Is.Not.Null);
Assert.That(prefab.activeSelf, Is.False);
var panel = prefab.GetComponent<DeveloperModePanel>();
Assert.That(panel, Is.Not.Null);
Assert.That(panel.IsConfigured, Is.True);
Assert.That(prefab.GetComponent<RectTransform>().sizeDelta, Is.EqualTo(new Vector2(840f, 760f)));
Assert.That(AssetDatabase.GetDependencies(scenePath), Does.Contain(prefabPath));
}
[Test]
public void PanelToggleIsNonModalAndEscapeClosesIt()
{
const string prefabPath = "Assets/GameContent/Feature_MainUI/Prefabs/DeveloperModePanel.prefab";
var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
var instance = Object.Instantiate(prefab);
try
{
var panel = instance.GetComponent<DeveloperModePanel>();
var originalScale = Time.timeScale;
panel.TogglePanel();
Assert.That(panel.IsOpen, Is.True);
Assert.That(Time.timeScale, Is.EqualTo(originalScale));
Assert.That(panel.HandleEscape(), Is.True);
Assert.That(panel.IsOpen, Is.False);
}
finally
{
Object.DestroyImmediate(instance);
}
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b95ba9d1e80e3dc4e83bc408569c4955
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,476 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AibisDream.Framework;
using AibisDream.SaveSystem;
using Newtonsoft.Json;
using NUnit.Framework;
using UnityEngine;
namespace AibisDream.DeveloperMode.Editor.Tests
{
public sealed class TestSaveSystemTests
{
private string _root;
private TestSaveRepository _repository;
[SetUp]
public void SetUp()
{
_root = Path.Combine(
Path.GetTempPath(),
"AibisDream-TestSaveTests",
Guid.NewGuid().ToString("N"));
_repository = new TestSaveRepository(_root);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, true);
}
}
[Test]
public void Record_SameAnchor_ReplacesSnapshotAndKeepsFirstSeenOrder()
{
var first = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneOne"));
var second = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneTwo"));
var scan = _repository.Scan("1.0");
Assert.That(scan.Entries.Count, Is.EqualTo(1));
Assert.That(second.Meta.firstSeenOrder, Is.EqualTo(first.Meta.firstSeenOrder));
Assert.That(scan.Entries[0].Meta.sceneName, Is.EqualTo("SceneTwo"));
Assert.That(_repository.TryLoad(scan.Entries[0], out var snapshot, out var error), Is.True, error);
Assert.That(snapshot.scene.sceneName, Is.EqualTo("SceneTwo"));
}
[Test]
public void Record_ReplacingEarlierEntry_DoesNotMoveItsOrder()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA2"));
var entries = _repository.Scan().Entries.ToArray();
Assert.That(entries.Select(item => item.NodeName), Is.EqualTo(new[] { "NodeA", "NodeB" }));
Assert.That(entries[0].Meta.firstSeenOrder, Is.LessThan(entries[1].Meta.firstSeenOrder));
}
[Test]
public void Scan_RetainsMissingSnapshotAsInvalid()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.Delete(entry.SnapshotPath);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.Zero);
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.MissingSnapshot));
}
[Test]
public void Scan_RetainsOrphanSnapshotWithBrokenMeta()
{
var directory = Path.Combine(_root, "chapter", "broken");
Directory.CreateDirectory(directory);
File.WriteAllText(
Path.Combine(directory, TestSaveRepository.SnapshotFileName),
"{}",
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(scan.InvalidCount, Is.EqualTo(1));
Assert.That(scan.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptMeta));
}
[Test]
public void Validate_CorruptSnapshot_IsRetainedAndDisabledOnNextScan()
{
var entry = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
File.WriteAllText(entry.SnapshotPath, "{broken", Encoding.UTF8);
Assert.That(_repository.Validate(entry, out var error), Is.False);
var rescanned = _repository.Scan();
Assert.That(error, Does.Contain("无法解析"));
Assert.That(rescanned.InvalidCount, Is.EqualTo(1));
Assert.That(rescanned.Entries[0].Status, Is.EqualTo(TestSaveEntryStatus.CorruptSnapshot));
}
[Test]
public void Scan_RecoversCompleteStagingDirectory()
{
var final = Path.Combine(_root, "chapter", "node");
var staging = final + ".__staging";
Directory.CreateDirectory(staging);
var request = CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA");
var meta = CreateMeta(request, 1);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.MetaFileName),
JsonConvert.SerializeObject(meta),
Encoding.UTF8);
File.WriteAllText(
Path.Combine(staging, TestSaveRepository.SnapshotFileName),
SnapshotPersistence.Serialize(request.Snapshot),
Encoding.UTF8);
var scan = _repository.Scan();
Assert.That(Directory.Exists(final), Is.True);
Assert.That(Directory.Exists(staging), Is.False);
Assert.That(scan.ValidCount, Is.EqualTo(1));
}
[Test]
public void Record_ConcurrentRequests_ProducesCompleteUniqueEntries()
{
var tasks = Enumerable.Range(0, 12)
.Select(index => Task.Run(() =>
_repository.Record(CreateRequest(
"ChapterA",
"ProjectA",
$"Node{index:00}",
$"Scene{index:00}"))))
.ToArray();
Task.WaitAll(tasks);
var scan = _repository.Scan();
Assert.That(scan.ValidCount, Is.EqualTo(12));
Assert.That(scan.InvalidCount, Is.Zero);
Assert.That(scan.Entries.Select(item => item.Meta.firstSeenOrder).Distinct().Count(), Is.EqualTo(12));
}
[Test]
public void DeleteInvalid_RemovesOnlyInvalidEntries()
{
_repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeA", "SceneA"));
var invalid = _repository.Record(CreateRequest("ChapterA", "ProjectA", "NodeB", "SceneB"));
File.Delete(invalid.SnapshotPath);
var removed = _repository.DeleteInvalid(out var error);
var scan = _repository.Scan();
Assert.That(error, Is.Null);
Assert.That(removed, Is.EqualTo(1));
Assert.That(scan.ValidCount, Is.EqualTo(1));
Assert.That(scan.InvalidCount, Is.Zero);
}
[TestCase(new[] { "hub" }, true)]
[TestCase(new[] { "linear" }, true)]
[TestCase(new[] { "content" }, true)]
[TestCase(new[] { "event" }, false)]
[TestCase(new[] { "hub", "no_save" }, false)]
[TestCase(new string[0], true)]
public void SilentCoverageEvaluator_MatchesSavePointTagSemantics(string[] tags, bool expected)
{
var actual = SavePointEvaluator.EvaluateNodeTagsForAutoSaveSilently(
"Node",
tags,
out _);
Assert.That(actual, Is.EqualTo(expected));
}
[TestCase(new[] { "content" }, NodeSaveTiming.NodeEnter, SaveResumeMode.RestartNode)]
[TestCase(new[] { "interaction" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "content", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "event", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "interaction", "save_on_exit" }, NodeSaveTiming.DialogueExit, SaveResumeMode.StateOnly)]
[TestCase(new[] { "interaction", "no_save" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
[TestCase(new[] { "content", "event" }, NodeSaveTiming.None, SaveResumeMode.RestartNode)]
public void ResolveNodePolicy_MapsNodeTypeAndModifiers(
string[] tags,
NodeSaveTiming expectedTiming,
SaveResumeMode expectedResumeMode)
{
var policy = SavePointEvaluator.ResolveNodePolicy(
"Node",
tags,
out _,
logWarnings: false);
Assert.That(policy.Timing, Is.EqualTo(expectedTiming));
Assert.That(policy.ResumeMode, Is.EqualTo(expectedResumeMode));
}
[Test]
public void Repository_StateOnlySnapshot_PreservesAnchorNode()
{
var request = CreateRequest("ChapterA", "ProjectA", "InteractionNode", "SceneA");
request.Snapshot.anchor.startDialogueOnRestore = false;
request.SaveTrigger = "DialogueExit";
request.ResumeMode = nameof(SaveResumeMode.StateOnly);
request.StartDialogueOnRestore = false;
request.DedupeKey = TestSaveRecorder.BuildDedupeKey(
request.SceneSoName,
request.YarnProjectId,
request.NodeName,
request.ResumeMode);
request.EntryId = TestSaveRepository.StableHash(request.DedupeKey);
var entry = _repository.Record(request);
var loaded = _repository.TryLoad(entry, out var snapshot, out var error);
Assert.That(loaded, Is.True, error);
Assert.That(entry.NodeName, Is.EqualTo("InteractionNode"));
Assert.That(entry.ResumeMode, Is.EqualTo(nameof(SaveResumeMode.StateOnly)));
Assert.That(snapshot.anchor.nodeName, Is.EqualTo("InteractionNode"));
Assert.That(snapshot.anchor.startDialogueOnRestore, Is.False);
}
[Test]
public void SnapshotPersistence_MissingRestoreFlag_IsRejected()
{
const string json =
"{\"schemaVersion\":2,\"anchor\":{\"sceneSoName\":\"ChapterA\"," +
"\"yarnProjectId\":\"ProjectA\",\"nodeName\":\"NodeA\"}}";
Assert.Throws<Newtonsoft.Json.JsonSerializationException>(
() => SnapshotPersistence.Deserialize(json));
}
[Test]
public void SerialTaskQueue_DoesNotDropOrReorderRequests()
{
var queue = new SerialTaskQueue();
var releaseFirst = new TaskCompletionSource<bool>();
var firstStarted = new TaskCompletionSource<bool>();
var order = new List<int>();
var first = queue.Enqueue(async () =>
{
order.Add(1);
firstStarted.SetResult(true);
await releaseFirst.Task;
order.Add(2);
});
var second = queue.Enqueue(() =>
{
order.Add(3);
return Task.CompletedTask;
});
Assert.That(firstStarted.Task.Wait(TimeSpan.FromSeconds(2)), Is.True);
Assert.That(order, Is.EqualTo(new[] { 1 }));
Assert.That(queue.PendingCount, Is.EqualTo(2));
releaseFirst.SetResult(true);
Assert.That(
Task.WaitAll(
new[] { first.Completion, second.Completion },
TimeSpan.FromSeconds(2)),
Is.True);
Assert.That(first.Sequence, Is.LessThan(second.Sequence));
Assert.That(order, Is.EqualTo(new[] { 1, 2, 3 }));
}
[Test]
public void SaveRequest_MapsResumeModeToExplicitAnchorFlag()
{
var enter = SaveRequest.NodeEnter("NodeA", "ProjectA", "ChapterA", 3, 7);
var exit = SaveRequest.DialogueExit("NodeB", "ProjectA", "ChapterA", 3, 9);
Assert.That(enter.AnchorSpec.NodeName, Is.EqualTo("NodeA"));
Assert.That(enter.AnchorSpec.StartDialogueOnRestore, Is.True);
Assert.That(exit.AnchorSpec.NodeName, Is.EqualTo("NodeB"));
Assert.That(exit.AnchorSpec.StartDialogueOnRestore, Is.False);
}
[TestCase(false, "DetourNode", true)]
[TestCase(false, "NoSaveNode", false)]
[TestCase(true, "AnotherNode", true)]
public void ValidateDialogueTiming_NonExitRequestIgnoresLaterDialogueState(
bool explicitCommand,
string currentNodeName,
bool isDialogueRunning)
{
var request = explicitCommand
? SaveRequest.Explicit("SourceNode", "ProjectA", "ChapterA", 3, 7)
: SaveRequest.NodeEnter("SourceNode", "ProjectA", "ChapterA", 3, 7);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId: 99,
currentFlowRevision: 101,
currentNodeName: currentNodeName,
isDialogueRunning: isDialogueRunning,
out var reason);
Assert.That(valid, Is.True, reason);
Assert.That(request.AnchorSpec.NodeName, Is.EqualTo("SourceNode"));
}
[Test]
public void ValidateDialogueTiming_ConsecutiveNodeEnterRequestsRemainEligible()
{
var first = SaveRequest.NodeEnter("FirstNode", "ProjectA", "ChapterA", 3, 7);
var second = SaveRequest.NodeEnter("SecondNode", "ProjectA", "ChapterA", 3, 8);
var firstValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
first,
currentDialogueRunId: 3,
currentFlowRevision: 8,
currentNodeName: "SecondNode",
isDialogueRunning: true,
out var firstReason);
var secondValid = SaveRestoreOrchestrator.ValidateDialogueTiming(
second,
currentDialogueRunId: 3,
currentFlowRevision: 8,
currentNodeName: "SecondNode",
isDialogueRunning: true,
out var secondReason);
Assert.That(firstValid, Is.True, firstReason);
Assert.That(secondValid, Is.True, secondReason);
Assert.That(first.AnchorSpec.NodeName, Is.EqualTo("FirstNode"));
Assert.That(second.AnchorSpec.NodeName, Is.EqualTo("SecondNode"));
}
[Test]
public void ValidateDialogueTiming_DialogueExitAcceptsStableCompletedFlow()
{
var request = SaveRequest.DialogueExit(
"InteractionNode",
"ProjectA",
"ChapterA",
3,
9);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId: 3,
currentFlowRevision: 9,
currentNodeName: null,
isDialogueRunning: false,
out var reason);
Assert.That(valid, Is.True, reason);
}
[TestCase(4, 9, null, false, "dialogue run changed")]
[TestCase(3, 10, null, false, "flow revision changed")]
[TestCase(3, 9, "DetourNode", false, "current Yarn node")]
[TestCase(3, 9, "NoSaveNode", false, "current Yarn node")]
[TestCase(3, 9, "JumpTarget", true, "still running")]
public void ValidateDialogueTiming_DialogueExitRejectsChangedOrRunningFlow(
long currentDialogueRunId,
long currentFlowRevision,
string currentNodeName,
bool isDialogueRunning,
string expectedReason)
{
var request = SaveRequest.DialogueExit(
"InteractionNode",
"ProjectA",
"ChapterA",
3,
9);
var valid = SaveRestoreOrchestrator.ValidateDialogueTiming(
request,
currentDialogueRunId,
currentFlowRevision,
currentNodeName,
isDialogueRunning,
out var reason);
Assert.That(valid, Is.False);
Assert.That(reason, Does.Contain(expectedReason));
}
[Test]
public void InteractionLock_ComposesWithDialogueLock()
{
var gameObject = new GameObject("EventSystemEx-Test");
try
{
var eventSystem = gameObject.AddComponent<EventSystemEx>();
EnumEventSystem.Global.Send(InteractionEventEnum.DialogStart);
var checkpointLock = eventSystem.AcquireInteractionLock("test");
EnumEventSystem.Global.Send(InteractionEventEnum.DialogEnd);
Assert.That(eventSystem.isLocked, Is.True);
checkpointLock.Dispose();
Assert.That(eventSystem.isLocked, Is.False);
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
private static TestSaveRecordRequest CreateRequest(
string sceneSoName,
string yarnProject,
string nodeName,
string sceneName)
{
var key = TestSaveRecorder.BuildDedupeKey(sceneSoName, yarnProject, nodeName);
return new TestSaveRecordRequest
{
Snapshot = new SaveSnapshot
{
gameVersion = "1.0",
savedAt = "2026-01-01 00:00:00",
scene = new SceneSnapshotDto { sceneName = sceneName },
anchor = new AnchorSnapshot
{
sceneSoName = sceneSoName,
yarnProjectId = yarnProject,
nodeName = nodeName,
startDialogueOnRestore = true
}
},
DedupeKey = key,
EntryId = TestSaveRepository.StableHash(key),
ChapterId = sceneSoName,
ChapterTitle = sceneSoName,
SceneSoName = sceneSoName,
YarnProjectId = yarnProject,
NodeName = nodeName,
SaveTrigger = "NodeEnter",
ResumeMode = nameof(SaveResumeMode.RestartNode),
StartDialogueOnRestore = true,
SceneName = sceneName,
GameVersion = "1.0"
};
}
private static TestSaveMeta CreateMeta(TestSaveRecordRequest request, long order)
{
return new TestSaveMeta
{
entryId = request.EntryId,
dedupeKey = request.DedupeKey,
chapterId = request.ChapterId,
chapterTitle = request.ChapterTitle,
sceneSoName = request.SceneSoName,
yarnProjectId = request.YarnProjectId,
nodeName = request.NodeName,
saveTrigger = request.SaveTrigger,
resumeMode = request.ResumeMode,
startDialogueOnRestore = request.StartDialogueOnRestore,
sceneName = request.SceneName,
firstSeenOrder = order,
firstRecordedAt = "2026-01-01 00:00:00",
lastRecordedAt = "2026-01-01 00:00:00",
snapshotSchemaVersion = SaveSnapshotSchema.CurrentVersion,
gameVersion = request.GameVersion
};
}
}
}
#endif
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cbbba0c3621cf204483f453bad2d75a4
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9e244d3d62dec3f4f881f7e681da8c03
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,20 @@
{
"name": "AibisDream.FrameAnimation.Editor",
"rootNamespace": "AibisDream.FrameAnimation.Editor",
"references": [
"AibisDream.FrameAnimation.Runtime",
"AibisDream.Menus",
"Unity.2D.Sprite.Editor"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: d1ffe139ca2417941a09835467d1e442
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class AsepriteJsonParser
{
public static bool TryParse(
string json,
string sourceId,
out AsepriteSourceDocument document,
out FrameAnimationImportIssue issue)
{
document = null;
issue = null;
try
{
if (string.IsNullOrWhiteSpace(json))
{
issue = Error(FrameAnimationImportIssueCode.JsonInvalid, sourceId, "Aseprite JSON 为空。");
return false;
}
var root = JObject.Parse(json);
var framesToken = root["frames"];
if (framesToken == null ||
(framesToken.Type != JTokenType.Object && framesToken.Type != JTokenType.Array))
{
issue = Error(FrameAnimationImportIssueCode.FramesMissing, sourceId, "JSON 缺少 Object 或 Array 格式的 frames。");
return false;
}
var frames = ParseFrames(framesToken);
var meta = root["meta"] as JObject;
var size = ParseSize(meta?["size"]);
var imageName = meta?.Value<string>("image") ?? string.Empty;
var tags = ParseTags(meta?["frameTags"]);
document = new AsepriteSourceDocument(frames, tags, size, imageName);
return true;
}
catch (Exception exception)
{
issue = Error(
FrameAnimationImportIssueCode.JsonInvalid,
sourceId,
$"Aseprite JSON 解析失败:{exception.Message}");
return false;
}
}
public static bool TryExpandTag(
AsepriteSourceTag tag,
int frameCount,
string sourceId,
out IReadOnlyList<int> indices,
out FrameAnimationImportIssue issue)
{
indices = Array.Empty<int>();
issue = null;
if (tag == null || tag.From < 0 || tag.To < tag.From || tag.To >= frameCount)
{
issue = Error(
FrameAnimationImportIssueCode.TagRangeInvalid,
sourceId,
$"Tag '{tag?.Name}' 的范围 {tag?.From}..{tag?.To} 无效。");
return false;
}
var result = new List<int>();
switch ((tag.Direction ?? string.Empty).ToLowerInvariant())
{
case "forward":
AddAscending(result, tag.From, tag.To);
break;
case "reverse":
AddDescending(result, tag.To, tag.From);
break;
case "pingpong":
AddAscending(result, tag.From, tag.To);
AddDescending(result, tag.To - 1, tag.From + 1);
break;
case "pingpong_reverse":
AddDescending(result, tag.To, tag.From);
AddAscending(result, tag.From + 1, tag.To - 1);
break;
default:
issue = Error(
FrameAnimationImportIssueCode.TagDirectionInvalid,
sourceId,
$"Tag '{tag.Name}' 使用了不支持的 direction '{tag.Direction}'。");
return false;
}
indices = result;
return true;
}
private static List<AsepriteSourceFrame> ParseFrames(JToken token)
{
var frames = new List<AsepriteSourceFrame>();
if (token is JObject objectFrames)
{
foreach (var property in objectFrames.Properties())
{
frames.Add(ParseFrame(property.Name, property.Value));
}
}
else
{
foreach (var item in (JArray)token)
{
frames.Add(ParseFrame(item.Value<string>("filename") ?? string.Empty, item));
}
}
return frames;
}
private static AsepriteSourceFrame ParseFrame(string frameName, JToken token)
{
return new AsepriteSourceFrame(
frameName,
ParseRect(token["frame"]),
token.Value<int?>("duration") ?? 0,
token.Value<bool?>("rotated") ?? false,
token.Value<bool?>("trimmed") ?? false,
ParseRect(token["spriteSourceSize"]),
ParseSize(token["sourceSize"]));
}
private static List<AsepriteSourceTag> ParseTags(JToken token)
{
var tags = new List<AsepriteSourceTag>();
if (!(token is JArray array))
{
return tags;
}
foreach (var item in array)
{
tags.Add(new AsepriteSourceTag(
(item.Value<string>("name") ?? string.Empty).Trim(),
item.Value<int?>("from") ?? -1,
item.Value<int?>("to") ?? -1,
item.Value<string>("direction") ?? string.Empty));
}
return tags;
}
private static RectInt ParseRect(JToken token)
{
return token == null
? default
: new RectInt(
token.Value<int?>("x") ?? 0,
token.Value<int?>("y") ?? 0,
token.Value<int?>("w") ?? 0,
token.Value<int?>("h") ?? 0);
}
private static Vector2Int ParseSize(JToken token)
{
return token == null
? default
: new Vector2Int(token.Value<int?>("w") ?? 0, token.Value<int?>("h") ?? 0);
}
private static void AddAscending(List<int> target, int from, int to)
{
for (var index = from; index <= to; index++)
{
target.Add(index);
}
}
private static void AddDescending(List<int> target, int from, int to)
{
for (var index = from; index >= to; index--)
{
target.Add(index);
}
}
private static FrameAnimationImportIssue Error(
FrameAnimationImportIssueCode code,
string sourceId,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
sourceId,
sourceId,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a21095473b1495440acab244f9e470c6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AibisDream.FrameAnimation.Tests.EditMode")]
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8edeae42e1831084cbf05db9d9e5d1e2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,307 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationTextInputWindow : EditorWindow
{
private string label;
private string value;
private string warning;
private Func<string, string> validate;
private Action<string> confirm;
private Vector2 scroll;
public static void Show(
string title,
string label,
string initialValue,
string warning,
Func<string, string> validate,
Action<string> confirm)
{
var window = CreateInstance<FrameAnimationTextInputWindow>();
window.titleContent = new GUIContent(title);
window.label = label;
window.value = initialValue ?? string.Empty;
window.warning = warning ?? string.Empty;
window.validate = validate;
window.confirm = confirm;
window.minSize = new Vector2(460f, 220f);
window.maxSize = new Vector2(700f, 420f);
window.ShowUtility();
}
private void OnGUI()
{
scroll = EditorGUILayout.BeginScrollView(scroll);
EditorGUILayout.LabelField(label, EditorStyles.boldLabel);
GUI.SetNextControlName("FrameAnimationTextInput");
value = EditorGUILayout.TextField(value);
if (!string.IsNullOrEmpty(warning))
{
EditorGUILayout.HelpBox(warning, MessageType.Warning);
}
var error = validate?.Invoke(value) ?? string.Empty;
if (!string.IsNullOrEmpty(error))
{
EditorGUILayout.HelpBox(error, MessageType.Error);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
{
if (GUILayout.Button("确认", GUILayout.Width(90f)))
{
var callback = confirm;
var result = value;
Close();
callback?.Invoke(result);
}
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndScrollView();
}
private void OnEnable()
{
EditorApplication.delayCall += FocusInput;
}
private void FocusInput()
{
if (this != null)
{
Focus();
EditorGUI.FocusTextInControl("FrameAnimationTextInput");
}
}
}
internal sealed class FrameAnimationManualClipWindow : EditorWindow
{
private FrameAnimationGraph graph;
private FrameClip source;
private string id = "NewClip";
private string displayName = "New Clip";
private bool external;
private Action<FrameClip> completed;
public static void Show(
FrameAnimationGraph graph,
FrameClip source,
Action<FrameClip> completed)
{
var window = CreateInstance<FrameAnimationManualClipWindow>();
window.titleContent = new GUIContent(source == null ? "Create Manual Clip" : "Copy As Manual Clip");
window.graph = graph;
window.source = source;
window.completed = completed;
var baseId = source != null ? source.Id + "_Manual" : "NewClip";
window.id = MakeUniqueId(graph, baseId);
window.displayName = source != null ? source.DisplayName + " Manual" : window.id;
window.minSize = new Vector2(460f, 260f);
window.maxSize = new Vector2(700f, 420f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField(source == null ? "创建 Manual Clip" : "复制为 Manual Clip", EditorStyles.boldLabel);
id = EditorGUILayout.TextField("ID", id);
displayName = EditorGUILayout.TextField("Display Name", displayName);
external = EditorGUILayout.ToggleLeft("保存为外部独立 .asset(默认保存为 Graph sub-asset", external);
var error = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
? string.Empty
: "ID 为空或与当前 Graph 的 Clip / Flow 冲突。";
if (!string.IsNullOrEmpty(error))
{
EditorGUILayout.HelpBox(error, MessageType.Error);
}
if (source != null)
{
EditorGUILayout.HelpBox(
"帧表将深拷贝,但 Sprite 仍复用原资源;ImportSource 关联会被清除。",
MessageType.Info);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(error)))
{
if (GUILayout.Button("创建", GUILayout.Width(90f)))
{
Create();
}
}
EditorGUILayout.EndHorizontal();
}
private void Create()
{
var path = string.Empty;
if (external)
{
path = EditorUtility.SaveFilePanelInProject(
"保存外部 Manual Clip",
id,
"asset",
"请选择 Assets 下的保存位置。");
if (string.IsNullOrEmpty(path))
{
return;
}
}
var succeeded = source == null
? FrameAnimationAssetOperations.CreateManualClip(graph, id, displayName, path, out var result, out var error)
: FrameAnimationAssetOperations.CopyToManual(graph, source, id, displayName, path, out result, out error);
if (!succeeded)
{
EditorUtility.DisplayDialog("操作失败", error, "确定");
return;
}
var callback = completed;
Close();
callback?.Invoke(result);
}
private static string MakeUniqueId(FrameAnimationGraph graph, string baseId)
{
var candidate = baseId;
var suffix = 2;
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
{
candidate = baseId + suffix++;
}
return candidate;
}
}
internal sealed class FrameAnimationExistingClipWindow : EditorWindow
{
private FrameAnimationGraph graph;
private FrameClip clip;
private Action<FrameClip> completed;
public static void Show(FrameAnimationGraph graph, Action<FrameClip> completed)
{
var window = CreateInstance<FrameAnimationExistingClipWindow>();
window.titleContent = new GUIContent("Add Existing Manual Clip");
window.graph = graph;
window.completed = completed;
window.minSize = new Vector2(460f, 180f);
window.maxSize = new Vector2(640f, 260f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField("添加外部 Manual Clip", EditorStyles.boldLabel);
clip = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f)))
{
Close();
}
using (new EditorGUI.DisabledScope(clip == null))
{
if (GUILayout.Button("添加", GUILayout.Width(90f)))
{
if (!FrameAnimationAssetOperations.AddExistingManualClip(graph, clip, out var error))
{
EditorUtility.DisplayDialog("添加失败", error, "确定");
return;
}
var callback = completed;
var result = clip;
Close();
callback?.Invoke(result);
}
}
EditorGUILayout.EndHorizontal();
}
}
internal sealed class FrameAnimationFlowWindow : EditorWindow
{
private FrameAnimationGraph graph;
private AnimationNode entry;
private string id;
private string displayName;
private Action<AnimationFlow> completed;
internal static void Show(
FrameAnimationGraph graph,
AnimationNode entry,
Action<AnimationFlow> completed)
{
var window = CreateInstance<FrameAnimationFlowWindow>();
window.titleContent = new GUIContent("Create Animation Flow");
window.graph = graph;
window.entry = entry;
window.id = FrameAnimationGraphMutationService.MakeUniqueFlowId(graph, entry);
window.displayName = window.id;
window.completed = completed;
window.minSize = new Vector2(460f, 230f);
window.maxSize = new Vector2(700f, 360f);
window.ShowUtility();
}
private void OnGUI()
{
EditorGUILayout.LabelField("从选中节点创建 Flow", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("Entry Node", entry?.DisplayName ?? string.Empty);
}
id = EditorGUILayout.TextField("ID", id);
displayName = EditorGUILayout.TextField("Display Name", displayName);
var idError = FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id)
? string.Empty
: "ID 为空或与当前 Graph 中的 Clip / Flow 冲突。";
var owner = graph?.Flows.FirstOrDefault(flow => flow != null && flow.EntryNodeId == entry?.InternalId);
if (owner != null)
{
idError = $"该节点已是 Flow '{owner.Id}' 的入口。";
}
if (!string.IsNullOrEmpty(idError))
{
EditorGUILayout.HelpBox(idError, MessageType.Error);
}
GUILayout.FlexibleSpace();
EditorGUILayout.BeginHorizontal();
GUILayout.FlexibleSpace();
if (GUILayout.Button("取消", GUILayout.Width(90f))) Close();
using (new EditorGUI.DisabledScope(!string.IsNullOrEmpty(idError)))
{
if (GUILayout.Button("创建", GUILayout.Width(90f)))
{
if (!FrameAnimationGraphMutationService.CreateFlow(
graph, entry, id, displayName, out var flow, out var error))
{
EditorUtility.DisplayDialog("创建 Flow 失败", error, "确定");
return;
}
var callback = completed;
Close();
callback?.Invoke(flow);
}
}
EditorGUILayout.EndHorizontal();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c8f58e7b80263c24087a98874796e91d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,254 @@
using System;
using UnityEditor;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationBrowserEntryKind
{
Header,
Clip,
Flow,
Source
}
internal enum FrameAnimationBrowserIssueState
{
None,
Warning,
Error
}
internal sealed class FrameAnimationBrowserEntry
{
public FrameAnimationBrowserEntryKind Kind;
public string Title = string.Empty;
public string Id = string.Empty;
public string TypeLabel = string.Empty;
public string Meta = string.Empty;
public string StatusLabel = string.Empty;
public Color? AccentColor;
public FrameAnimationBrowserIssueState IssueState;
public FrameAnimationEditorSelection Selection;
public bool IsHeader => Kind == FrameAnimationBrowserEntryKind.Header;
}
internal readonly struct FrameAnimationFlowColorPalette
{
public readonly Color Raw;
public readonly Color Stroke;
public readonly Color Header;
public readonly Color Muted;
public readonly Color Text;
public FrameAnimationFlowColorPalette(Color raw, Color stroke, Color header, Color muted, Color text)
{
Raw = raw;
Stroke = stroke;
Header = header;
Muted = muted;
Text = text;
}
}
internal static class FrameAnimationFlowColorUtility
{
private static readonly Color Panel = new Color32(43, 47, 52, 255);
private static readonly Color LightText = new Color32(244, 246, 248, 255);
private static readonly Color DarkText = new Color32(24, 27, 30, 255);
private static readonly Color Neutral = new Color32(100, 168, 216, 255);
internal static Color ResolveRaw(FrameAnimationGraph graph, AnimationFlow flow)
{
return Opaque(ResolveStored(graph, flow));
}
internal static Color ResolveStored(FrameAnimationGraph graph, AnimationFlow flow)
{
if (graph == null || flow == null)
{
return Neutral;
}
var data = graph.EditorData?.FlowEditorData == null
? null
: System.Linq.Enumerable.FirstOrDefault(graph.EditorData.FlowEditorData,
item => item != null && item.FlowId == flow.Id);
return data?.Color ?? Neutral;
}
internal static FrameAnimationFlowColorPalette CreatePalette(Color value)
{
var raw = Opaque(value);
var luminance = Luminance(raw);
var stroke = luminance < 0.30f
? Color.Lerp(raw, Color.white, Mathf.InverseLerp(0.30f, 0.02f, luminance) * 0.42f)
: luminance > 0.82f
? Color.Lerp(raw, Color.black, Mathf.InverseLerp(0.82f, 1f, luminance) * 0.22f)
: raw;
stroke.a = 1f;
var header = Color.Lerp(Panel, stroke, 0.68f);
header.a = 1f;
var muted = Color.Lerp(Panel, stroke, 0.42f);
muted.a = 1f;
var text = Luminance(header) > 0.22f ? DarkText : LightText;
return new FrameAnimationFlowColorPalette(raw, stroke, header, muted, text);
}
internal static Color Opaque(Color value) => new Color(
Mathf.Clamp01(value.r), Mathf.Clamp01(value.g), Mathf.Clamp01(value.b), 1f);
internal static float Luminance(Color value)
{
var linear = value.linear;
return linear.r * 0.2126f + linear.g * 0.7152f + linear.b * 0.0722f;
}
}
internal sealed class FrameAnimationResourceRowElement : VisualElement
{
private readonly VisualElement flowDot;
private readonly Label typeBadge;
private readonly Label titleLabel;
private readonly Label idLabel;
private readonly Label metaLabel;
private readonly Label statusBadge;
internal FrameAnimationResourceRowElement()
{
AddToClassList("fa-resource-row");
var main = new VisualElement();
main.AddToClassList("fa-resource-row__main");
flowDot = new VisualElement { pickingMode = PickingMode.Ignore };
flowDot.AddToClassList("fa-flow-dot");
typeBadge = new Label { pickingMode = PickingMode.Ignore };
typeBadge.AddToClassList("fa-badge");
titleLabel = new Label { pickingMode = PickingMode.Ignore };
titleLabel.AddToClassList("fa-resource-row__name");
idLabel = new Label { pickingMode = PickingMode.Ignore };
idLabel.AddToClassList("fa-resource-row__id");
statusBadge = new Label { pickingMode = PickingMode.Ignore };
statusBadge.AddToClassList("fa-badge");
main.Add(flowDot);
main.Add(typeBadge);
main.Add(titleLabel);
main.Add(idLabel);
main.Add(statusBadge);
metaLabel = new Label { pickingMode = PickingMode.Ignore };
metaLabel.AddToClassList("fa-resource-row__meta");
Add(main);
Add(metaLabel);
}
internal void Bind(FrameAnimationBrowserEntry entry, bool selected)
{
userData = entry;
var isHeader = entry?.IsHeader == true;
EnableInClassList("fa-resource-group", isHeader);
EnableInClassList("fa-resource-row--selected", selected);
EnableInClassList("fa-resource-row--error", entry?.IssueState == FrameAnimationBrowserIssueState.Error);
EnableInClassList("fa-resource-row--warning", entry?.IssueState == FrameAnimationBrowserIssueState.Warning);
if (entry == null)
{
style.display = DisplayStyle.None;
return;
}
style.display = DisplayStyle.Flex;
titleLabel.text = entry.Title;
titleLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
idLabel.text = entry.Id;
metaLabel.text = entry.Meta;
typeBadge.text = entry.TypeLabel;
typeBadge.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
idLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
metaLabel.style.display = isHeader ? DisplayStyle.None : DisplayStyle.Flex;
statusBadge.text = entry.StatusLabel;
statusBadge.style.display = isHeader || string.IsNullOrEmpty(entry.StatusLabel) ? DisplayStyle.None : DisplayStyle.Flex;
flowDot.style.display = !isHeader && entry.AccentColor.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
if (entry.AccentColor.HasValue)
{
flowDot.style.backgroundColor = FrameAnimationFlowColorUtility.CreatePalette(entry.AccentColor.Value).Stroke;
}
typeBadge.EnableInClassList("fa-badge--blue", entry.Kind == FrameAnimationBrowserEntryKind.Clip);
typeBadge.EnableInClassList("fa-badge--purple", entry.Kind == FrameAnimationBrowserEntryKind.Flow);
typeBadge.EnableInClassList("fa-badge--green", entry.Kind == FrameAnimationBrowserEntryKind.Source);
statusBadge.EnableInClassList("fa-badge--red", entry.IssueState == FrameAnimationBrowserIssueState.Error);
statusBadge.EnableInClassList("fa-badge--yellow", entry.IssueState == FrameAnimationBrowserIssueState.Warning);
}
}
internal sealed class FrameAnimationFrameRowElement : VisualElement
{
private readonly ObjectField spriteField;
private readonly IntegerField durationField;
private readonly Label frameNameLabel;
private readonly Label sourceIndexLabel;
private FrameClip clip;
private int index;
private bool readOnly;
private Action changed;
internal FrameAnimationFrameRowElement()
{
AddToClassList("fa-frame-row");
spriteField = new ObjectField("Sprite") { objectType = typeof(Sprite), allowSceneObjects = false };
durationField = new IntegerField("Duration Ms");
var readOnlyRow = new VisualElement();
readOnlyRow.AddToClassList("fa-frame-row__readonly");
frameNameLabel = new Label();
sourceIndexLabel = new Label();
readOnlyRow.Add(frameNameLabel);
readOnlyRow.Add(sourceIndexLabel);
Add(spriteField);
Add(durationField);
Add(readOnlyRow);
spriteField.RegisterValueChangedCallback(OnSpriteChanged);
durationField.RegisterValueChangedCallback(OnDurationChanged);
}
internal void Bind(FrameClip value, int itemIndex, bool isReadOnly, Action onChanged)
{
clip = value;
index = itemIndex;
readOnly = isReadOnly;
changed = onChanged;
userData = itemIndex;
var frame = clip != null && index >= 0 && index < clip.Frames.Count ? clip.Frames[index] : null;
spriteField.SetValueWithoutNotify(frame?.Sprite);
durationField.SetValueWithoutNotify(frame?.DurationMs ?? 100);
frameNameLabel.text = "Frame " + (frame?.FrameName ?? string.Empty);
sourceIndexLabel.text = "Source " + (frame?.SourceIndex.ToString() ?? "-1");
spriteField.SetEnabled(!readOnly);
durationField.SetEnabled(!readOnly);
}
private void OnSpriteChanged(ChangeEvent<UnityEngine.Object> evt)
{
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
var serialized = new SerializedObject(clip);
serialized.Update();
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
element.FindPropertyRelative("sprite").objectReferenceValue = evt.newValue;
serialized.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
changed?.Invoke();
}
private void OnDurationChanged(ChangeEvent<int> evt)
{
if (readOnly || clip == null || index < 0 || index >= clip.Frames.Count) return;
var serialized = new SerializedObject(clip);
serialized.Update();
var element = serialized.FindProperty("frames").GetArrayElementAtIndex(index);
element.FindPropertyRelative("durationMs").intValue = Mathf.Max(1, evt.newValue);
serialized.ApplyModifiedProperties();
durationField.SetValueWithoutNotify(Mathf.Max(1, evt.newValue));
EditorUtility.SetDirty(clip);
changed?.Invoke();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 876191b33b10eef44b133cc291ebc912
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,744 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationEditorSelectionKind
{
None,
Graph,
Clip,
Flow,
Source,
Node,
Edge
}
internal readonly struct FrameAnimationEditorSelection
{
public FrameAnimationEditorSelectionKind Kind { get; }
public object Value { get; }
public FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind kind, object value)
{
Kind = kind;
Value = value;
}
}
internal enum FrameAnimationClipFilter
{
All,
Manual,
Imported,
Normal,
Missing,
Referenced,
Unused
}
internal enum FrameAnimationClipSort
{
Name,
Source,
FrameCount,
Duration,
Missing,
ReferenceCount
}
internal static class FrameAnimationAssetReferenceIndex
{
public static IReadOnlyList<FrameAnimationGraph> FindGraphsReferencing(FrameClip clip)
{
if (clip == null)
{
return Array.Empty<FrameAnimationGraph>();
}
return AssetDatabase.FindAssets("t:FrameAnimationGraph")
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid)))
.Where(graph => graph != null && graph.Clips.Any(item => item == clip))
.Distinct()
.OrderBy(graph => AssetDatabase.GetAssetPath(graph), StringComparer.Ordinal)
.ToArray();
}
}
internal static class FrameAnimationResourceQuery
{
public static IReadOnlyList<FrameClip> QueryClips(
FrameAnimationGraph graph,
string search,
FrameAnimationClipFilter filter,
FrameAnimationClipSort sort)
{
if (graph == null)
{
return Array.Empty<FrameClip>();
}
var sourceNames = graph.ImportSources.Where(source => source != null)
.GroupBy(source => source.InternalId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.First().DisplayName ?? string.Empty);
var query = graph.Clips.Where(clip => clip != null && MatchesSearch(clip, sourceNames, search));
query = filter switch
{
FrameAnimationClipFilter.Manual => query.Where(clip => !clip.IsImported),
FrameAnimationClipFilter.Imported => query.Where(clip => clip.IsImported),
FrameAnimationClipFilter.Normal => query.Where(clip => !clip.IsImported || !clip.ImportInfo.IsMissingFromSource),
FrameAnimationClipFilter.Missing => query.Where(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource),
FrameAnimationClipFilter.Referenced => query.Where(clip => ReferenceCount(graph, clip) > 0),
FrameAnimationClipFilter.Unused => query.Where(clip => ReferenceCount(graph, clip) == 0),
_ => query
};
IOrderedEnumerable<FrameClip> ordered = sort switch
{
FrameAnimationClipSort.Source => query.OrderBy(clip => SourceName(clip, sourceNames), StringComparer.Ordinal),
FrameAnimationClipSort.FrameCount => query.OrderBy(clip => clip.FrameCount),
FrameAnimationClipSort.Duration => query.OrderBy(clip => clip.TotalDurationMs),
FrameAnimationClipSort.Missing => query.OrderBy(clip => clip.IsImported && clip.ImportInfo.IsMissingFromSource ? 1 : 0),
FrameAnimationClipSort.ReferenceCount => query.OrderBy(clip => ReferenceCount(graph, clip)),
_ => query.OrderBy(clip => clip.DisplayName ?? clip.Id, StringComparer.Ordinal)
};
return ordered.ThenBy(clip => clip.Id, StringComparer.Ordinal)
.ThenBy(clip => AssetDatabase.GetAssetPath(clip), StringComparer.Ordinal)
.ToArray();
}
public static int ReferenceCount(FrameAnimationGraph graph, FrameClip clip)
{
return graph?.Nodes.Count(node => node != null && node.ClipId == clip?.Id) ?? 0;
}
public static string SourceName(
FrameClip clip,
IReadOnlyDictionary<string, string> sourceNames = null)
{
if (clip == null || !clip.IsImported)
{
return clip != null && clip.HasStandaloneImportSource ? "Standalone Source" : "Manual";
}
var sourceId = clip.ImportInfo?.ImportSourceId;
if (sourceNames != null && !string.IsNullOrEmpty(sourceId) &&
sourceNames.TryGetValue(sourceId, out var name))
{
return name;
}
return sourceId ?? string.Empty;
}
private static bool MatchesSearch(
FrameClip clip,
IReadOnlyDictionary<string, string> sourceNames,
string search)
{
if (string.IsNullOrWhiteSpace(search))
{
return true;
}
return Contains(clip.Id, search) || Contains(clip.DisplayName, search) ||
Contains(clip.ImportInfo?.SourceTagName, search) ||
Contains(SourceName(clip, sourceNames), search);
}
private static bool Contains(string value, string search)
{
return !string.IsNullOrEmpty(value) && value.IndexOf(search, StringComparison.OrdinalIgnoreCase) >= 0;
}
}
internal static class FrameAnimationAssetOperations
{
public static bool IsPlayableIdAvailable(
FrameAnimationGraph graph,
string id,
FrameClip exceptClip = null,
AnimationFlow exceptFlow = null)
{
return graph != null && !string.IsNullOrWhiteSpace(id) &&
graph.Clips.All(clip => clip == null || clip == exceptClip || clip.Id != id) &&
graph.Flows.All(flow => flow == null || flow == exceptFlow || flow.Id != id);
}
public static bool CreateManualClip(
FrameAnimationGraph graph,
string id,
string displayName,
string externalPath,
out FrameClip clip,
out string error)
{
clip = null;
error = string.Empty;
if (!IsPlayableIdAvailable(graph, id))
{
error = "Clip id 为空或与现有 Clip / Flow 冲突。";
return false;
}
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Create Manual Frame Clip");
try
{
Undo.RecordObject(graph, "Add Manual Frame Clip");
clip = ScriptableObject.CreateInstance<FrameClip>();
clip.name = id;
clip.Configure(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName,
Array.Empty<FrameAnimationFrame>(), 1f,
graph.Settings.NewManualClipDefaultEndBehavior);
if (string.IsNullOrWhiteSpace(externalPath))
{
AssetDatabase.AddObjectToAsset(clip, graph);
}
else
{
AssetDatabase.CreateAsset(clip, externalPath);
}
Undo.RegisterCreatedObjectUndo(clip, "Create Manual Frame Clip");
graph.AddClip(clip);
EditorUtility.SetDirty(graph);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
catch (Exception exception)
{
Undo.RevertAllDownToGroup(group);
clip = null;
error = exception.Message;
return false;
}
}
public static bool AddExistingManualClip(
FrameAnimationGraph graph,
FrameClip clip,
out string error)
{
error = string.Empty;
if (graph == null || clip == null)
{
error = "Graph 或 Clip 为空。";
return false;
}
if (clip.IsImported || AssetDatabase.IsSubAsset(clip) ||
string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip)))
{
error = "只能添加独立 .asset 形式、且不由 Graph ImportSource 管理的 Clip。";
return false;
}
if (graph.Clips.Contains(clip))
{
error = "当前 Graph 已引用该 Clip。";
return false;
}
if (!IsPlayableIdAvailable(graph, clip.Id))
{
error = $"playable id '{clip.Id}' 与当前 Graph 中的 Clip / Flow 冲突。";
return false;
}
Undo.RecordObject(graph, "Add Existing Manual Frame Clip");
graph.AddClip(clip);
EditorUtility.SetDirty(graph);
return true;
}
public static bool CopyToManual(
FrameAnimationGraph graph,
FrameClip source,
string id,
string displayName,
string externalPath,
out FrameClip copy,
out string error)
{
copy = null;
error = string.Empty;
if (source == null || !IsPlayableIdAvailable(graph, id))
{
error = "来源为空,或新 id 与现有 Clip / Flow 冲突。";
return false;
}
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Copy Frame Clip As Manual");
try
{
Undo.RecordObject(graph, "Add Manual Frame Clip Copy");
copy = ScriptableObject.CreateInstance<FrameClip>();
copy.name = id;
copy.Configure(id,
string.IsNullOrWhiteSpace(displayName) ? source.DisplayName : displayName,
source.Frames.Select(frame => frame == null
? null
: new FrameAnimationFrame(frame.Sprite, frame.DurationMs, frame.FrameName, frame.SourceIndex)),
source.Speed,
source.DefaultEndBehavior);
if (string.IsNullOrWhiteSpace(externalPath))
{
AssetDatabase.AddObjectToAsset(copy, graph);
}
else
{
AssetDatabase.CreateAsset(copy, externalPath);
}
Undo.RegisterCreatedObjectUndo(copy, "Create Manual Frame Clip Copy");
graph.AddClip(copy);
EditorUtility.SetDirty(graph);
EditorUtility.SetDirty(copy);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
catch (Exception exception)
{
Undo.RevertAllDownToGroup(group);
copy = null;
error = exception.Message;
return false;
}
}
public static bool RenameClip(
FrameAnimationGraph graph,
FrameClip clip,
string newId,
out string error)
{
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "Clip 不属于当前 Graph。";
return false;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
error = "外部 Manual Clip 被多个 Graph 共享,禁止重命名:" +
string.Join(", ", owners.Select(owner => owner.name));
return false;
}
if (!IsPlayableIdAvailable(graph, newId, clip))
{
error = "新 id 为空或与现有 Clip / Flow 冲突。";
return false;
}
var oldId = clip.Id;
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Rename Frame Clip");
Undo.RecordObject(graph, "Update Frame Clip References");
Undo.RecordObject(clip, "Rename Frame Clip");
clip.SetId(newId);
foreach (var node in graph.Nodes.Where(node => node != null && node.ClipId == oldId))
{
node.SetClipId(newId);
}
if (graph.Settings.DefaultPlayableId == oldId)
{
graph.Settings.SetDefaultPlayableId(newId);
}
EditorUtility.SetDirty(graph);
EditorUtility.SetDirty(clip);
Undo.CollapseUndoOperations(group);
return true;
}
public static bool RenameFlow(
FrameAnimationGraph graph,
AnimationFlow flow,
string newId,
out string error)
{
error = string.Empty;
if (graph == null || flow == null || !graph.Flows.Contains(flow) ||
!IsPlayableIdAvailable(graph, newId, exceptFlow: flow))
{
error = "Flow 不属于当前 Graph,或新 id 与现有 Clip / Flow 冲突。";
return false;
}
var oldId = flow.Id;
Undo.RecordObject(graph, "Rename Animation Flow");
flow.SetId(newId);
if (graph.Settings.DefaultPlayableId == oldId)
{
graph.Settings.SetDefaultPlayableId(newId);
}
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null && data.FlowId == oldId))
{
data.SetFlowId(newId);
}
EditorUtility.SetDirty(graph);
return true;
}
public static bool RenameGraph(FrameAnimationGraph graph, string newId, out string error)
{
error = string.Empty;
if (graph == null || string.IsNullOrWhiteSpace(newId))
{
error = "Graph id 不能为空。";
return false;
}
Undo.RecordObject(graph, "Rename Frame Animation Graph");
graph.SetId(newId);
EditorUtility.SetDirty(graph);
return true;
}
public static bool RemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
{
if (!CanRemoveClip(graph, clip, out error))
{
return false;
}
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Remove Frame Clip");
Undo.RecordObject(graph, "Remove Frame Clip Reference");
graph.RemoveClip(clip);
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) == AssetDatabase.GetAssetPath(graph))
{
Undo.DestroyObjectImmediate(clip);
}
EditorUtility.SetDirty(graph);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
public static bool CanRemoveClip(FrameAnimationGraph graph, FrameClip clip, out string error)
{
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "Clip 不属于当前 Graph。";
return false;
}
var nodeCount = graph.Nodes.Count(node => node != null && node.ClipId == clip.Id);
if (nodeCount > 0)
{
error = $"Clip 仍被 {nodeCount} 个 AnimationNode 引用。";
return false;
}
if (graph.Settings.DefaultPlayableId == clip.Id)
{
error = "Clip 仍被 defaultPlayableId 引用。";
return false;
}
return true;
}
public static bool RemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
{
if (!CanRemoveFlow(graph, flow, out error))
{
return false;
}
Undo.RecordObject(graph, "Remove Animation Flow");
graph.RemoveFlow(flow);
graph.EditorData.RemoveFlowData(flow.Id);
EditorUtility.SetDirty(graph);
return true;
}
public static bool CanRemoveFlow(FrameAnimationGraph graph, AnimationFlow flow, out string error)
{
error = string.Empty;
if (graph == null || flow == null || !graph.Flows.Contains(flow))
{
error = "Flow 不属于当前 Graph。";
return false;
}
if (graph.Settings.DefaultPlayableId == flow.Id)
{
error = "Flow 仍被 defaultPlayableId 引用。";
return false;
}
return true;
}
public static bool RemoveSource(
FrameAnimationGraph graph,
FrameAnimationImportSource source,
out string error)
{
error = string.Empty;
if (graph == null || source == null || !graph.ImportSources.Contains(source))
{
error = "ImportSource 不属于当前 Graph。";
return false;
}
var clips = graph.Clips.Where(clip => clip != null &&
clip.ImportInfo?.ImportSourceId == source.InternalId).ToArray();
if (clips.Length > 0)
{
error = "ImportSource 仍关联 Imported Clip" + string.Join(", ", clips.Select(clip => clip.Id));
return false;
}
Undo.RecordObject(graph, "Remove Frame Animation ImportSource");
graph.RemoveImportSource(source);
EditorUtility.SetDirty(graph);
return true;
}
}
internal sealed class FrameAnimationEditorIssue
{
public FrameAnimationValidationSeverity Severity { get; }
public string Code { get; }
public FrameAnimationEditorSelection Selection { get; }
public string Message { get; }
public string Suggestion { get; }
public FrameAnimationEditorIssue(
FrameAnimationValidationSeverity severity,
string code,
FrameAnimationEditorSelection selection,
string message,
string suggestion)
{
Severity = severity;
Code = code ?? string.Empty;
Selection = selection;
Message = message ?? string.Empty;
Suggestion = suggestion ?? string.Empty;
}
}
internal static class FrameAnimationEditorValidationService
{
public static IReadOnlyList<FrameAnimationEditorIssue> Validate(
FrameAnimationGraph graph,
bool includeImports,
out FrameAnimationImportPreview importPreview)
{
importPreview = null;
if (graph == null)
{
return Array.Empty<FrameAnimationEditorIssue>();
}
var issues = FrameAnimationGraphValidator.Validate(graph).Issues
.Select(issue => new FrameAnimationEditorIssue(
issue.Severity,
issue.Code.ToString(),
Locate(graph, issue.TargetType, issue.TargetId),
issue.Message,
Suggestion(issue.Code)))
.ToList();
foreach (var duplicate in graph.Clips.Where(clip => clip != null)
.GroupBy(clip => clip).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"DuplicateClipReference",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, duplicate.Key),
$"Graph 重复引用 Clip '{duplicate.Key.Id}'。",
"移除重复的 Clip 引用。"));
}
var graphPath = AssetDatabase.GetAssetPath(graph);
foreach (var clip in graph.Clips.Where(clip => clip != null))
{
if (AssetDatabase.IsSubAsset(clip) && AssetDatabase.GetAssetPath(clip) != graphPath)
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"ForeignSubAssetClip",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
$"Clip '{clip.Id}' 是其他资产的 sub-asset。",
"移除该引用,并复制为当前 Graph 的 Manual Clip。"));
}
if (clip.IsImported && (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"ImportedClipOwnership",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Clip, clip),
$"Imported Clip '{clip.Id}' 不是当前 Graph 的 sub-asset。",
"删除非法引用并从对应 ImportSource 重新刷新。"));
}
}
foreach (var node in graph.Nodes.Where(node => node != null))
{
var clip = graph.Clips.SingleOrDefault(item => item != null && item.Id == node.ClipId);
if (clip?.ImportInfo?.IsMissingFromSource == true)
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"NodeReferencesMissingClip",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
$"Node '{node.InternalId}' 引用了 Missing Clip '{clip.Id}'。",
"恢复源 Tag、修改节点引用或删除该节点。"));
}
}
var nodeIds = new HashSet<string>(graph.Nodes.Where(node => node != null)
.Select(node => node.InternalId));
foreach (var node in graph.Nodes.Where(node => node != null &&
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"EditorDataMissing",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node),
$"Node '{node.InternalId}' 缺少画布位置数据。",
"打开 Graph Editor 初始化位置,或执行自动布局。"));
}
foreach (var data in graph.EditorData.NodeEditorData.Where(data => data != null &&
!nodeIds.Contains(data.NodeId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"EditorDataOrphan",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"存在孤立的 NodeEditorData '{data.NodeId}'。",
"该数据不会影响运行时;确认无引用后可由后续维护工具清理。"));
}
foreach (var duplicate in graph.EditorData.NodeEditorData.Where(data => data != null)
.GroupBy(data => data.NodeId).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"EditorDataDuplicate",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"NodeEditorData '{duplicate.Key}' 重复。",
"保留一条对应位置数据并移除重复项。"));
}
var flowIds = new HashSet<string>(graph.Flows.Where(flow => flow != null).Select(flow => flow.Id));
foreach (var flow in graph.Flows.Where(flow => flow != null &&
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"FlowEditorDataMissing",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Flow, flow),
$"Flow '{flow.Id}' 缺少画布颜色数据。",
"打开 Graph Editor 初始化 Flow 颜色。"));
}
foreach (var data in graph.EditorData.FlowEditorData.Where(data => data != null &&
!flowIds.Contains(data.FlowId)))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Warning,
"FlowEditorDataOrphan",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"存在孤立的 FlowEditorData '{data.FlowId}'。",
"确认 Flow 已删除后可由后续维护工具清理。"));
}
foreach (var duplicate in graph.EditorData.FlowEditorData.Where(data => data != null)
.GroupBy(data => data.FlowId).Where(group => group.Count() > 1))
{
issues.Add(new FrameAnimationEditorIssue(
FrameAnimationValidationSeverity.Error,
"FlowEditorDataDuplicate",
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph),
$"FlowEditorData '{duplicate.Key}' 重复。",
"保留一条对应颜色数据并移除重复项。"));
}
if (!includeImports)
{
return issues;
}
importPreview = FrameAnimationImportService.PreviewAll(graph);
foreach (var sourcePreview in importPreview.Sources)
{
foreach (var importIssue in sourcePreview.Issues)
{
issues.Add(new FrameAnimationEditorIssue(
importIssue.Severity,
importIssue.Code.ToString(),
new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Source, sourcePreview.Source),
importIssue.Message,
"打开对应 ImportSource,修复来源设置后重新 Preview。"));
}
}
return issues;
}
private static FrameAnimationEditorSelection Locate(
FrameAnimationGraph graph,
FrameAnimationValidationTargetType type,
string id)
{
return type switch
{
FrameAnimationValidationTargetType.Clip => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Clip, graph.Clips.FirstOrDefault(clip => clip != null && clip.Id == id)),
FrameAnimationValidationTargetType.Flow => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Flow, graph.Flows.FirstOrDefault(flow => flow != null && flow.Id == id)),
FrameAnimationValidationTargetType.Node => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Node, graph.Nodes.FirstOrDefault(node => node != null && node.InternalId == id)),
FrameAnimationValidationTargetType.Edge => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Edge, graph.Edges.FirstOrDefault(edge => edge != null && edge.InternalId == id)),
FrameAnimationValidationTargetType.ImportSource => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Source, graph.ImportSources.FirstOrDefault(source => source != null && source.InternalId == id)),
_ => new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Graph, graph)
};
}
private static string Suggestion(FrameAnimationValidationCode code)
{
return code switch
{
FrameAnimationValidationCode.PlayableIdEmpty => "使用正式 Rename 命令设置唯一 playable id。",
FrameAnimationValidationCode.PlayableIdDuplicate => "重命名冲突的 Clip 或 Flow。",
FrameAnimationValidationCode.ClipFramesEmpty => "为 Manual Clip 添加帧,或刷新 Imported Clip 来源。",
FrameAnimationValidationCode.FrameDurationInvalid => "将 durationMs 修改为大于 0 的整数。",
FrameAnimationValidationCode.DefaultPlayableInvalid => "在 Graph 属性中选择有效的默认 Clip 或 Flow。",
FrameAnimationValidationCode.ImportedClipMissingFromSource => "恢复源 Tag,或确认没有引用后删除 Missing Clip。",
_ => "打开问题对象并修复对应字段或引用。"
};
}
}
internal static class FrameAnimationWorkspaceState
{
private const string Prefix = "AibisDream.FrameAnimation.Workspace.";
public static string Key(FrameAnimationGraph graph, string name)
{
var graphPath = graph != null ? AssetDatabase.GetAssetPath(graph) : string.Empty;
var graphGuid = string.IsNullOrEmpty(graphPath) ? "none" : AssetDatabase.AssetPathToGUID(graphPath);
var project = Hash128.Compute(Application.dataPath).ToString();
return Prefix + project + "." + graphGuid + "." + name;
}
public static float GetFloat(FrameAnimationGraph graph, string name, float fallback) =>
EditorPrefs.GetFloat(Key(graph, name), fallback);
public static void SetFloat(FrameAnimationGraph graph, string name, float value) =>
EditorPrefs.SetFloat(Key(graph, name), value);
public static string GetString(FrameAnimationGraph graph, string name, string fallback) =>
EditorPrefs.GetString(Key(graph, name), fallback);
public static void SetString(FrameAnimationGraph graph, string name, string value) =>
EditorPrefs.SetString(Key(graph, name), value ?? string.Empty);
public static bool GetBool(FrameAnimationGraph graph, string name, bool fallback) =>
EditorPrefs.GetBool(Key(graph, name), fallback);
public static void SetBool(FrameAnimationGraph graph, string name, bool value) =>
EditorPrefs.SetBool(Key(graph, name), value);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2643ad174ef237e43b98400d60c4a201
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,603 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class FrameAnimationFlowFocusPolicy
{
public static AnimationFlow FindFocusedFlow(FrameAnimationGraph graph, string focusedFlowId)
{
return graph?.Flows.FirstOrDefault(flow => flow != null && flow.Id == focusedFlowId);
}
public static bool Contains(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
var flow = FindFocusedFlow(graph, focusedFlowId);
if (flow == null)
{
return false;
}
var reachable = new FrameAnimationGraphTopology(graph).GetReachable(flow.EntryNodeId);
return selection.Value switch
{
AnimationNode node => reachable.Nodes.Contains(node),
AnimationEdge edge => reachable.Edges.Contains(edge),
AnimationFlow selectedFlow => selectedFlow == flow,
_ => false
};
}
public static bool ShouldExitForSelection(
FrameAnimationGraph graph,
string focusedFlowId,
FrameAnimationEditorSelection selection)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
return selection.Kind switch
{
FrameAnimationEditorSelectionKind.None => false,
FrameAnimationEditorSelectionKind.Flow => false,
FrameAnimationEditorSelectionKind.Node => !Contains(graph, focusedFlowId, selection),
FrameAnimationEditorSelectionKind.Edge => !Contains(graph, focusedFlowId, selection),
_ => true
};
}
public static bool ShouldExitForSelectionSet(
FrameAnimationGraph graph,
string focusedFlowId,
IEnumerable<FrameAnimationEditorSelection> selections)
{
if (string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
var items = (selections ?? Array.Empty<FrameAnimationEditorSelection>()).ToArray();
return items.Length > 0 && items.Any(item =>
(item.Kind == FrameAnimationEditorSelectionKind.Node ||
item.Kind == FrameAnimationEditorSelectionKind.Edge)
? !Contains(graph, focusedFlowId, item)
: ShouldExitForSelection(graph, focusedFlowId, item));
}
}
internal sealed class FrameAnimationGraphImpact
{
public IReadOnlyList<AnimationFlow> Flows { get; }
public int LostNodeCount { get; }
internal FrameAnimationGraphImpact(IReadOnlyList<AnimationFlow> flows, int lostNodeCount)
{
Flows = flows ?? Array.Empty<AnimationFlow>();
LostNodeCount = lostNodeCount;
}
}
internal static class FrameAnimationGraphImpactAnalyzer
{
public static FrameAnimationGraphImpact AnalyzeEdgeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationEdge> edges)
{
var removed = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null));
return Analyze(graph, Array.Empty<AnimationNode>(), removed);
}
public static FrameAnimationGraphImpact AnalyzeNodeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes)
{
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null));
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var removedEdges = new HashSet<AnimationEdge>(graph.Edges.Where(edge => edge != null &&
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
return Analyze(graph, removedNodes, removedEdges);
}
public static FrameAnimationGraphImpact AnalyzeRemoval(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> edges)
{
if (graph == null)
{
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
}
var removedNodes = new HashSet<AnimationNode>((nodes ?? Array.Empty<AnimationNode>())
.Where(node => node != null));
var removedIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var removedEdges = new HashSet<AnimationEdge>((edges ?? Array.Empty<AnimationEdge>())
.Where(edge => edge != null));
removedEdges.UnionWith(graph.Edges.Where(edge => edge != null &&
(removedIds.Contains(edge.FromNodeId) || removedIds.Contains(edge.ToNodeId))));
return Analyze(graph, removedNodes, removedEdges);
}
private static FrameAnimationGraphImpact Analyze(
FrameAnimationGraph graph,
IReadOnlyCollection<AnimationNode> removedNodes,
IReadOnlyCollection<AnimationEdge> removedEdges)
{
if (graph == null)
{
return new FrameAnimationGraphImpact(Array.Empty<AnimationFlow>(), 0);
}
var topology = new FrameAnimationGraphTopology(graph);
var removedNodeIds = new HashSet<string>(removedNodes.Select(node => node.InternalId));
var affected = new List<AnimationFlow>();
var lost = new HashSet<string>();
foreach (var flow in graph.Flows.Where(flow => flow != null))
{
var before = topology.GetReachable(flow.EntryNodeId).Nodes
.Where(node => node != null).Select(node => node.InternalId).ToHashSet();
var after = ReachableWithout(graph, flow.EntryNodeId, removedNodeIds, removedEdges);
var flowLost = before.Where(nodeId => !after.Contains(nodeId)).ToArray();
if (flowLost.Length > 0)
{
affected.Add(flow);
lost.UnionWith(flowLost);
}
}
return new FrameAnimationGraphImpact(
affected.OrderBy(flow => flow.Id, StringComparer.Ordinal).ToArray(),
lost.Count);
}
private static HashSet<string> ReachableWithout(
FrameAnimationGraph graph,
string entry,
HashSet<string> removedNodeIds,
IReadOnlyCollection<AnimationEdge> removedEdges)
{
var result = new HashSet<string>();
if (removedNodeIds.Contains(entry ?? string.Empty))
{
return result;
}
var outgoing = graph.Edges.Where(edge => edge != null && !removedEdges.Contains(edge))
.GroupBy(edge => edge.FromNodeId ?? string.Empty)
.ToDictionary(group => group.Key, group => group.ToArray());
var queue = new Queue<string>();
queue.Enqueue(entry ?? string.Empty);
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (removedNodeIds.Contains(id) || !result.Add(id))
{
continue;
}
if (outgoing.TryGetValue(id, out var edges))
{
foreach (var edge in edges)
{
queue.Enqueue(edge.ToNodeId ?? string.Empty);
}
}
}
return result;
}
}
internal static class FrameAnimationGraphMutationService
{
private static readonly Color[] FlowColors =
{
new Color(0.24f, 0.65f, 1f), new Color(0.42f, 0.8f, 0.42f),
new Color(1f, 0.62f, 0.24f), new Color(0.82f, 0.42f, 0.9f),
new Color(1f, 0.38f, 0.52f), new Color(0.25f, 0.82f, 0.78f)
};
public static bool CreateNode(
FrameAnimationGraph graph,
FrameClip clip,
Vector2 position,
out AnimationNode node,
out string error)
{
node = null;
error = string.Empty;
if (graph == null || clip == null || !graph.Clips.Contains(clip))
{
error = "只能为当前 Graph 中的 FrameClip 创建节点。";
return false;
}
Undo.RecordObject(graph, "Create Frame Animation Node");
node = new AnimationNode(clip.Id,
string.IsNullOrWhiteSpace(clip.DisplayName) ? clip.Id : clip.DisplayName);
graph.AddNode(node);
graph.EditorData.GetOrCreateNodeData(node.InternalId, position).SetPosition(position);
Dirty(graph);
return true;
}
public static bool TryConnect(
FrameAnimationGraph graph,
AnimationNode from,
AnimationNode to,
out AnimationEdge edge,
out string error)
{
edge = null;
if (!CanConnect(graph, from, to, out error))
{
return false;
}
Undo.RecordObject(graph, "Connect Frame Animation Nodes");
edge = new AnimationEdge(from.InternalId, to.InternalId);
graph.AddEdge(edge);
Dirty(graph);
return true;
}
public static bool CanConnect(
FrameAnimationGraph graph,
AnimationNode from,
AnimationNode to,
out string error)
{
error = string.Empty;
if (graph == null || from == null || to == null ||
graph.Nodes.Count(node => node == from) != 1 || graph.Nodes.Count(node => node == to) != 1)
{
error = "连接两端必须唯一属于当前 Graph。";
return false;
}
if (from == to || from.InternalId == to.InternalId)
{
error = "节点不允许连接到自身。";
return false;
}
var topology = new FrameAnimationGraphTopology(graph);
if (topology.GetOutgoing(from.InternalId).Count > 0)
{
error = "第一版每个节点最多只能有一个后继。";
return false;
}
if (from.EndBehaviorOverride.HasValue)
{
error = "该节点已设置终点结束行为,请先清除覆盖。";
return false;
}
if (graph.Edges.Any(existing => existing != null &&
existing.FromNodeId == from.InternalId && existing.ToNodeId == to.InternalId))
{
error = "该连接已经存在。";
return false;
}
if (topology.WouldCreateCycle(from.InternalId, to.InternalId))
{
error = "该连接会形成自连接或多节点环路。";
return false;
}
return true;
}
public static void SetNodeDisplayName(FrameAnimationGraph graph, AnimationNode node, string value)
{
Undo.RecordObject(graph, "Rename Frame Animation Node");
node.SetDisplayName(value);
Dirty(graph);
}
public static void SetNodeClip(FrameAnimationGraph graph, AnimationNode node, FrameClip clip)
{
Undo.RecordObject(graph, "Set Frame Animation Node Clip");
node.SetClipId(clip != null ? clip.Id : string.Empty);
Dirty(graph);
}
public static void SetNodeSpeed(FrameAnimationGraph graph, AnimationNode node, float? value)
{
Undo.RecordObject(graph, "Set Frame Animation Node Speed");
node.SetSpeedOverride(value);
Dirty(graph);
}
public static bool SetNodeEndBehavior(
FrameAnimationGraph graph,
AnimationNode node,
FrameClipEndBehavior? value,
bool removeOutgoing,
out string error)
{
error = string.Empty;
var outgoing = graph.Edges.Where(edge => edge != null && edge.FromNodeId == node.InternalId).ToArray();
if (value.HasValue && outgoing.Length > 0 && !removeOutgoing)
{
error = "节点仍有后继 Edge。";
return false;
}
Undo.RecordObject(graph, "Set Frame Animation Node End Behavior");
if (value.HasValue)
{
foreach (var edge in outgoing)
{
graph.RemoveEdge(edge);
}
}
node.SetEndBehaviorOverride(value);
Dirty(graph);
return true;
}
public static void RemoveEdges(FrameAnimationGraph graph, IEnumerable<AnimationEdge> edges)
{
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
Undo.RecordObject(graph, "Disconnect Frame Animation Nodes");
foreach (var edge in items)
{
graph.RemoveEdge(edge);
}
Dirty(graph);
}
public static bool RemoveNodes(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
bool deleteEntryFlows,
out string error)
{
return RemoveNodesAndEdges(
graph, nodes, Array.Empty<AnimationEdge>(), deleteEntryFlows, out error);
}
public static bool RemoveNodesAndEdges(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> nodes,
IEnumerable<AnimationEdge> additionalEdges,
bool deleteEntryFlows,
out string error)
{
error = string.Empty;
if (graph == null)
{
error = "Graph 已失效。";
return false;
}
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
var extraEdges = (additionalEdges ?? Array.Empty<AnimationEdge>())
.Where(edge => edge != null).Distinct().ToArray();
var ids = new HashSet<string>(items.Select(node => node.InternalId));
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
if (entryFlows.Length > 0 && !deleteEntryFlows)
{
error = "删除项包含 Flow 入口节点。";
return false;
}
Undo.RecordObject(graph, "Delete Frame Animation Graph Elements");
foreach (var edge in graph.Edges.Where(edge => edge != null &&
(ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId) || extraEdges.Contains(edge))).ToArray())
{
graph.RemoveEdge(edge);
}
foreach (var flow in entryFlows)
{
graph.RemoveFlow(flow);
graph.EditorData.RemoveFlowData(flow.Id);
if (graph.Settings.DefaultPlayableId == flow.Id)
{
graph.Settings.SetDefaultPlayableId(string.Empty);
}
}
foreach (var node in items)
{
graph.RemoveNode(node);
graph.EditorData.RemoveNodeData(node.InternalId);
}
Dirty(graph);
return true;
}
public static string MakeUniqueFlowId(FrameAnimationGraph graph, AnimationNode node)
{
var root = (string.IsNullOrWhiteSpace(node?.DisplayName) ? "Animation" : node.DisplayName) + "_Flow";
var candidate = root;
var suffix = 2;
while (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, candidate))
{
candidate = root + suffix++;
}
return candidate;
}
public static bool CreateFlow(
FrameAnimationGraph graph,
AnimationNode entry,
string id,
string displayName,
out AnimationFlow flow,
out string error)
{
flow = null;
error = string.Empty;
if (graph == null || entry == null || !graph.Nodes.Contains(entry))
{
error = "Flow 入口必须属于当前 Graph。";
return false;
}
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing.EntryNodeId == entry.InternalId);
if (owner != null)
{
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
return false;
}
if (!FrameAnimationAssetOperations.IsPlayableIdAvailable(graph, id))
{
error = "Flow id 为空或与 Clip / Flow 冲突。";
return false;
}
Undo.RecordObject(graph, "Create Animation Flow");
flow = new AnimationFlow(id, string.IsNullOrWhiteSpace(displayName) ? id : displayName, entry.InternalId);
graph.AddFlow(flow);
graph.EditorData.GetOrCreateFlowData(flow.Id, FlowColors[(graph.Flows.Count - 1) % FlowColors.Length]);
Dirty(graph);
return true;
}
public static bool SetFlowEntry(
FrameAnimationGraph graph,
AnimationFlow flow,
AnimationNode entry,
out string error)
{
error = string.Empty;
if (graph == null || flow == null || entry == null ||
!graph.Flows.Contains(flow) || !graph.Nodes.Contains(entry))
{
error = "Flow 或入口节点不属于当前 Graph。";
return false;
}
var owner = graph.Flows.FirstOrDefault(existing => existing != null && existing != flow &&
existing.EntryNodeId == entry.InternalId);
if (owner != null)
{
error = $"该节点已是 Flow '{owner.Id}' 的入口。";
return false;
}
Undo.RecordObject(graph, "Set Animation Flow Entry");
flow.SetEntryNodeId(entry.InternalId);
Dirty(graph);
return true;
}
public static void SetFlowColor(FrameAnimationGraph graph, AnimationFlow flow, Color value)
{
if (graph == null || flow == null)
{
return;
}
Undo.RecordObject(graph, "Set Animation Flow Color");
graph.EditorData.GetOrCreateFlowData(flow.Id, value).SetColor(value);
Dirty(graph);
}
public static bool EnsureEditorData(FrameAnimationGraph graph)
{
if (graph == null)
{
return false;
}
var missingNodes = graph.Nodes.Where(node => node != null &&
graph.EditorData.NodeEditorData.All(data => data == null || data.NodeId != node.InternalId)).ToArray();
var missingFlows = graph.Flows.Where(flow => flow != null &&
graph.EditorData.FlowEditorData.All(data => data == null || data.FlowId != flow.Id)).ToArray();
if (missingNodes.Length == 0 && missingFlows.Length == 0)
{
return false;
}
Undo.RecordObject(graph, "Initialize Frame Animation Graph Layout");
for (var index = 0; index < missingNodes.Length; index++)
{
var position = new Vector2((index % 4) * 300f, (index / 4) * 180f);
graph.EditorData.GetOrCreateNodeData(missingNodes[index].InternalId, position);
}
for (var index = 0; index < missingFlows.Length; index++)
{
graph.EditorData.GetOrCreateFlowData(missingFlows[index].Id,
FlowColors[(graph.Flows.ToList().IndexOf(missingFlows[index]) + FlowColors.Length) % FlowColors.Length]);
}
Dirty(graph);
return true;
}
public static Vector2 GetNodePosition(FrameAnimationGraph graph, AnimationNode node)
{
return graph?.EditorData.NodeEditorData.FirstOrDefault(data =>
data != null && data.NodeId == node?.InternalId)?.Position ?? Vector2.zero;
}
public static void SetNodePositions(
FrameAnimationGraph graph,
IReadOnlyDictionary<AnimationNode, Vector2> positions,
string undoName)
{
if (graph == null || positions == null || positions.Count == 0)
{
return;
}
Undo.RecordObject(graph, undoName);
foreach (var pair in positions)
{
graph.EditorData.GetOrCreateNodeData(pair.Key.InternalId, pair.Value).SetPosition(pair.Value);
}
Dirty(graph);
}
private static void Dirty(FrameAnimationGraph graph)
{
EditorUtility.SetDirty(graph);
}
}
internal static class FrameAnimationGraphLayoutService
{
public static IReadOnlyDictionary<AnimationNode, Vector2> Calculate(
FrameAnimationGraph graph,
IEnumerable<AnimationNode> scope)
{
var source = scope ?? (graph != null
? graph.Nodes
: (IEnumerable<AnimationNode>)Array.Empty<AnimationNode>());
var nodes = source.Where(node => node != null).Distinct()
.GroupBy(node => node.InternalId ?? string.Empty)
.Select(group => group.First()).ToArray();
if (graph == null || nodes.Length == 0)
{
return new Dictionary<AnimationNode, Vector2>();
}
var ids = new HashSet<string>(nodes.Select(node => node.InternalId));
var edges = graph.Edges.Where(edge => edge != null && ids.Contains(edge.FromNodeId) && ids.Contains(edge.ToNodeId)).ToArray();
var incoming = nodes.ToDictionary(node => node.InternalId, _ => 0);
foreach (var edge in edges)
{
incoming[edge.ToNodeId]++;
}
var ranks = nodes.ToDictionary(node => node.InternalId, _ => 0);
var queue = new Queue<AnimationNode>(nodes.Where(node => incoming[node.InternalId] == 0)
.OrderBy(node => node.InternalId, StringComparer.Ordinal));
var processed = new HashSet<string>();
while (queue.Count > 0)
{
var node = queue.Dequeue();
if (!processed.Add(node.InternalId)) continue;
foreach (var edge in edges.Where(edge => edge.FromNodeId == node.InternalId))
{
ranks[edge.ToNodeId] = Math.Max(ranks[edge.ToNodeId], ranks[node.InternalId] + 1);
incoming[edge.ToNodeId]--;
if (incoming[edge.ToNodeId] == 0)
{
queue.Enqueue(nodes.First(item => item.InternalId == edge.ToNodeId));
}
}
}
var oldPositions = nodes.ToDictionary(node => node,
node => FrameAnimationGraphMutationService.GetNodePosition(graph, node));
var originX = oldPositions.Values.Min(position => position.x);
var originY = oldPositions.Values.Min(position => position.y);
var result = new Dictionary<AnimationNode, Vector2>();
foreach (var group in nodes.GroupBy(node => ranks[node.InternalId]).OrderBy(group => group.Key))
{
var ordered = group.OrderBy(node => oldPositions[node].y)
.ThenBy(node => node.InternalId, StringComparer.Ordinal).ToArray();
for (var index = 0; index < ordered.Length; index++)
{
result[ordered[index]] = new Vector2(originX + group.Key * 300f, originY + index * 180f);
}
}
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b397ed96c5b44df89626f866c32e03dd
@@ -0,0 +1,33 @@
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameAnimationGraph))]
public sealed class FrameAnimationGraphEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
var graph = (FrameAnimationGraph)target;
EditorGUILayout.LabelField("FrameAnimationGraph", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("ID", graph.Id);
EditorGUILayout.TextField("Display Name", graph.DisplayName);
EditorGUILayout.IntField("Clips", graph.Clips.Count);
EditorGUILayout.IntField("Nodes", graph.Nodes.Count);
EditorGUILayout.IntField("Edges", graph.Edges.Count);
EditorGUILayout.IntField("Flows", graph.Flows.Count);
EditorGUILayout.IntField("Import Sources", graph.ImportSources.Count);
EditorGUILayout.TextField("Default Playable", graph.Settings?.DefaultPlayableId ?? string.Empty);
}
EditorGUILayout.HelpBox(
"Graph 的资源、来源、重命名和删除操作只能在主工作台中执行。",
MessageType.Info);
if (GUILayout.Button("Open Frame Animation Graph Editor"))
{
FrameAnimationGraphEditorWindow.Open(graph);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74f3b50c2650607499ea1c82a282f00f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,783 @@
:root {
--fa-bg: rgb(27, 29, 32);
--fa-canvas: rgb(30, 32, 35);
--fa-panel: rgb(36, 39, 43);
--fa-panel-raised: rgb(43, 46, 51);
--fa-panel-hover: rgb(48, 52, 58);
--fa-field: rgb(28, 31, 34);
--fa-line: rgb(17, 19, 21);
--fa-line-soft: rgb(62, 67, 74);
--fa-text: rgb(215, 217, 220);
--fa-muted: rgb(143, 150, 158);
--fa-accent: rgb(61, 145, 212);
--fa-accent-soft: rgb(36, 79, 112);
--fa-success: rgb(86, 185, 123);
--fa-warning: rgb(224, 184, 88);
--fa-error: rgb(223, 106, 106);
}
.fa-workbench {
flex-grow: 1;
flex-direction: column;
background-color: var(--fa-bg);
color: var(--fa-text);
font-size: 12px;
}
.fa-global-toolbar-host,
.fa-main-host,
.fa-bottom-host {
flex-shrink: 0;
}
.fa-main-host {
flex-grow: 1;
min-height: 0;
}
.fa-workbench .unity-toolbar {
min-height: 32px;
padding-left: 6px;
padding-right: 6px;
background-color: var(--fa-panel-raised);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-workbench .unity-toolbar-button,
.fa-workbench .unity-button {
min-height: 24px;
margin-left: 2px;
margin-right: 2px;
padding-left: 8px;
padding-right: 8px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--fa-line);
border-right-color: var(--fa-line);
border-top-color: var(--fa-line);
border-bottom-color: var(--fa-line);
border-top-left-radius: 3px;
border-top-right-radius: 3px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
background-color: rgb(54, 58, 63);
color: var(--fa-text);
}
.fa-workbench .unity-toolbar-button:hover,
.fa-workbench .unity-button:hover {
background-color: rgb(70, 75, 81);
}
.fa-button--primary {
background-color: rgb(42, 105, 150);
border-left-color: rgb(25, 74, 104);
border-right-color: rgb(25, 74, 104);
border-top-color: rgb(25, 74, 104);
border-bottom-color: rgb(25, 74, 104);
}
.fa-button--danger {
background-color: rgb(87, 54, 56);
color: rgb(244, 205, 207);
}
.fa-toolbar-group {
flex-direction: row;
align-items: center;
flex-shrink: 0;
}
.fa-toolbar-separator {
width: 1px;
height: 21px;
margin-left: 5px;
margin-right: 5px;
background-color: var(--fa-line);
}
.fa-panel {
flex-direction: column;
flex-shrink: 0;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-panel-header {
height: 28px;
min-height: 28px;
padding-left: 9px;
padding-right: 9px;
-unity-text-align: middle-left;
-unity-font-style: bold;
background-color: rgb(44, 47, 51);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-resizer--vertical {
width: 4px;
background-color: rgb(24, 26, 29);
}
.fa-resizer--horizontal {
height: 4px;
background-color: rgb(24, 26, 29);
}
.fa-resizer--vertical:hover,
.fa-resizer--horizontal:hover {
background-color: var(--fa-accent);
}
.fa-resource-tabs {
min-height: 34px;
padding-left: 4px;
padding-right: 4px;
padding-top: 3px;
background-color: rgb(31, 34, 38);
}
.fa-resource-tabs .unity-toolbar-button,
.fa-bottom-tabs .unity-toolbar-button {
flex-grow: 1;
flex-basis: 0;
min-height: 28px;
margin-left: 1px;
margin-right: 1px;
border-left-width: 0;
border-right-width: 0;
border-top-width: 0;
border-bottom-width: 2px;
border-bottom-color: rgba(0, 0, 0, 0);
border-top-left-radius: 0;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background-color: rgba(0, 0, 0, 0);
color: rgb(169, 175, 182);
}
.fa-resource-tabs .unity-toolbar-button:hover,
.fa-bottom-tabs .unity-toolbar-button:hover {
color: var(--fa-text);
background-color: rgb(48, 52, 57);
}
.fa-resource-tabs .unity-toolbar-button.fa-tab--active,
.fa-bottom-tabs .unity-toolbar-button.fa-tab--active {
color: white;
background-color: var(--fa-panel-raised);
border-bottom-color: var(--fa-accent);
-unity-font-style: bold;
}
.fa-resource-tools {
padding-left: 7px;
padding-right: 7px;
padding-top: 7px;
padding-bottom: 7px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-resource-list {
flex-grow: 1;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-resource-row {
height: 48px;
padding-left: 9px;
padding-right: 7px;
padding-top: 5px;
padding-bottom: 4px;
border-left-width: 3px;
border-left-color: rgba(0, 0, 0, 0);
border-bottom-width: 1px;
border-bottom-color: rgb(32, 35, 39);
}
.fa-resource-row:hover {
background-color: var(--fa-panel-hover);
}
.fa-resource-row--selected {
background-color: rgb(37, 75, 103);
border-left-color: rgb(98, 178, 234);
}
.fa-resource-row--error {
border-left-color: var(--fa-error);
}
.fa-resource-row--warning {
border-left-color: var(--fa-warning);
}
.fa-resource-row__main,
.fa-resource-row__meta,
.fa-badge-row {
flex-direction: row;
align-items: center;
}
.fa-resource-row__main {
height: 20px;
}
.fa-resource-row__name {
flex-grow: 1;
min-width: 0;
-unity-font-style: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-resource-row__id {
max-width: 115px;
margin-left: 5px;
color: rgb(156, 163, 171);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-resource-row__meta {
height: 17px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-resource-group {
height: 27px;
padding-left: 8px;
padding-right: 8px;
-unity-text-align: middle-left;
-unity-font-style: bold;
color: rgb(184, 189, 195);
background-color: rgb(41, 44, 49);
border-top-width: 1px;
border-top-color: rgb(52, 56, 61);
border-bottom-width: 1px;
border-bottom-color: rgb(26, 28, 31);
}
.fa-badge {
min-height: 16px;
margin-right: 4px;
padding-left: 5px;
padding-right: 5px;
-unity-text-align: middle-center;
font-size: 9px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
background-color: rgb(52, 56, 61);
color: rgb(174, 180, 187);
}
.fa-badge--blue { background-color: rgb(37, 63, 85); color: rgb(143, 202, 239); }
.fa-badge--green { background-color: rgb(35, 63, 49); color: rgb(131, 209, 160); }
.fa-badge--yellow { background-color: rgb(74, 62, 37); color: rgb(240, 200, 111); }
.fa-badge--red { background-color: rgb(75, 41, 44); color: rgb(239, 141, 147); }
.fa-badge--purple { background-color: rgb(59, 48, 75); color: rgb(195, 168, 232); }
.fa-flow-dot {
width: 8px;
height: 8px;
margin-right: 6px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.fa-canvas-panel {
flex-grow: 1;
min-width: 280px;
min-height: 0;
flex-direction: column;
background-color: var(--fa-canvas);
}
.fa-canvas-toolbar {
min-height: 32px;
}
.fa-toolbar-label {
margin-left: 2px;
margin-right: 4px;
color: var(--fa-muted);
-unity-text-align: middle-left;
}
.fa-canvas-toolbar #flow-focus-field {
flex-shrink: 1;
}
.fa-flow-focus {
min-width: 110px;
max-width: 240px;
padding-left: 7px;
padding-right: 7px;
-unity-font-style: bold;
-unity-text-align: middle-left;
border-left-width: 3px;
border-left-color: rgba(0, 0, 0, 0);
}
.fa-empty-state {
color: var(--fa-muted);
}
.fa-node {
min-width: 224px;
width: 224px;
max-width: 224px;
min-height: 164px;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: var(--fa-line);
border-right-color: var(--fa-line);
border-top-color: var(--fa-line);
border-bottom-color: var(--fa-line);
border-top-left-radius: 5px;
border-top-right-radius: 5px;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: rgb(43, 47, 52);
}
.fa-node .title {
min-height: 31px;
padding-left: 7px;
padding-right: 7px;
background-color: rgb(46, 111, 155);
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-node .title-label {
-unity-font-style: bold;
}
.fa-node .input,
.fa-node .output {
min-width: 12px;
}
.fa-node__meta {
flex-direction: row;
justify-content: space-between;
margin-left: 8px;
margin-right: 8px;
margin-top: 4px;
margin-bottom: 4px;
color: rgb(175, 181, 188);
font-size: 10px;
}
.fa-node__clip-id {
flex-grow: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fa-node__behavior {
margin-left: 5px;
color: rgb(201, 205, 210);
}
.fa-node__badges {
min-height: 21px;
margin-left: 8px;
margin-right: 8px;
margin-top: 5px;
flex-direction: row;
align-items: center;
overflow: hidden;
}
.fa-node__entry {
min-width: 16px;
height: 16px;
margin-right: 5px;
padding-left: 3px;
padding-right: 3px;
-unity-text-align: middle-center;
-unity-font-style: bold;
font-size: 9px;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.fa-node__preview {
height: 78px;
margin-left: 8px;
margin-right: 8px;
margin-top: 1px;
}
.fa-node--focused {
border-left-width: 2px;
}
.fa-node.selected {
border-left-width: 2px;
border-right-width: 2px;
border-top-width: 2px;
border-bottom-width: 2px;
border-left-color: rgb(105, 185, 237);
border-right-color: rgb(105, 185, 237);
border-top-color: rgb(105, 185, 237);
border-bottom-color: rgb(105, 185, 237);
}
.fa-node--preview-current {
border-left-color: rgb(55, 174, 240);
border-right-color: rgb(55, 174, 240);
border-top-color: rgb(55, 174, 240);
border-bottom-color: rgb(55, 174, 240);
}
.fa-node--preview-passed { border-left-color: rgb(83, 154, 113); }
.fa-node--preview-upcoming { opacity: 0.72; }
.fa-inspector-scroll,
.fa-bottom-scroll {
flex-grow: 1;
min-height: 0;
background-color: var(--fa-panel);
}
.fa-inspector-title {
padding-left: 11px;
padding-right: 11px;
padding-top: 10px;
padding-bottom: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-inspector-title__name {
font-size: 15px;
-unity-font-style: bold;
}
.fa-inspector-title__id {
margin-top: 3px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-section {
padding-bottom: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-section__title {
height: 27px;
padding-left: 10px;
-unity-text-align: middle-left;
-unity-font-style: bold;
background-color: rgb(41, 44, 48);
}
.fa-section__body {
padding-left: 10px;
padding-right: 10px;
padding-top: 7px;
}
.fa-section__body > .unity-base-field,
.fa-section__body > .unity-button,
.fa-section__body > .fa-readonly-row,
.fa-section__body > .fa-callout {
margin-top: 2px;
margin-bottom: 2px;
}
.fa-readonly-row {
min-height: 22px;
flex-direction: row;
align-items: center;
}
.fa-readonly-row__label {
width: 118px;
color: rgb(173, 179, 186);
}
.fa-readonly-row__value {
flex-grow: 1;
min-width: 0;
color: rgb(197, 202, 208);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fa-callout {
padding-left: 9px;
padding-right: 9px;
padding-top: 7px;
padding-bottom: 7px;
white-space: normal;
border-left-width: 3px;
border-left-color: var(--fa-accent);
background-color: rgb(34, 48, 58);
color: rgb(188, 209, 223);
}
.fa-callout--warning { border-left-color: var(--fa-warning); background-color: rgb(57, 51, 33); color: rgb(228, 208, 161); }
.fa-callout--error { border-left-color: var(--fa-error); background-color: rgb(63, 39, 42); color: rgb(239, 184, 188); }
.fa-frame-row {
min-height: 72px;
padding-left: 5px;
padding-right: 5px;
padding-top: 4px;
padding-bottom: 4px;
border-bottom-width: 1px;
border-bottom-color: rgb(48, 51, 55);
}
.fa-frame-row__readonly {
flex-direction: row;
}
.fa-frame-row__readonly > .unity-label {
flex-grow: 1;
color: var(--fa-muted);
font-size: 10px;
}
.fa-bottom-panel {
flex-direction: column;
min-height: 28px;
background-color: rgb(32, 35, 39);
border-top-width: 1px;
border-top-color: var(--fa-line);
}
.fa-bottom-toolbar {
min-height: 31px;
padding-left: 4px;
padding-right: 4px;
}
.fa-bottom-tabs {
min-width: 230px;
height: 30px;
flex-direction: row;
align-items: stretch;
}
.fa-bottom-tabs .unity-toolbar-button {
min-width: 108px;
padding-left: 14px;
padding-right: 14px;
}
.fa-bottom-toolbar .fa-bottom-collapse {
width: 28px;
min-width: 28px;
padding-left: 0;
padding-right: 0;
font-size: 14px;
}
.fa-result-controls {
min-height: 31px;
flex-direction: row;
align-items: center;
padding-left: 8px;
padding-right: 8px;
border-bottom-width: 1px;
border-bottom-color: var(--fa-line);
}
.fa-result-header,
.fa-result-row {
min-height: 29px;
flex-direction: row;
align-items: center;
padding-left: 10px;
padding-right: 10px;
border-bottom-width: 1px;
border-bottom-color: rgb(41, 44, 48);
}
.fa-result-header {
min-height: 26px;
color: var(--fa-muted);
background-color: rgb(40, 43, 48);
}
.fa-result-row:hover { background-color: var(--fa-panel-hover); }
.fa-result-severity { width: 82px; -unity-font-style: bold; }
.fa-result-kind { width: 135px; }
.fa-result-object { width: 180px; }
.fa-result-message { flex-grow: 1; min-width: 260px; white-space: normal; }
.fa-severity--error { color: var(--fa-error); }
.fa-severity--warning { color: var(--fa-warning); }
.fa-severity--info { color: rgb(114, 189, 232); }
.fa-preview {
position: relative;
overflow: hidden;
border-left-width: 1px;
border-right-width: 1px;
border-top-width: 1px;
border-bottom-width: 1px;
border-left-color: rgb(25, 25, 25);
border-right-color: rgb(25, 25, 25);
border-top-color: rgb(25, 25, 25);
border-bottom-color: rgb(25, 25, 25);
}
.fa-preview__image {
position: absolute;
}
.fa-preview__empty {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
-unity-text-align: middle-center;
color: rgb(190, 190, 190);
}
.fa-clip-preview-panel {
flex-shrink: 0;
padding-left: 6px;
padding-right: 6px;
padding-top: 4px;
padding-bottom: 5px;
border-bottom-width: 1px;
border-bottom-color: rgb(28, 28, 28);
}
.fa-selection-preview-header {
min-height: 34px;
flex-direction: column;
justify-content: center;
}
.fa-selection-preview-title {
color: var(--fa-text);
-unity-font-style: bold;
}
.fa-selection-preview-target {
margin-top: 1px;
color: var(--fa-muted);
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fa-clip-preview-panel .fa-preview {
height: 190px;
margin-bottom: 4px;
}
.fa-preview-controls {
flex-direction: row;
align-items: center;
flex-shrink: 0;
min-height: 27px;
padding-left: 0;
padding-right: 0;
}
.fa-selection-preview-panel .fa-preview-controls .fa-preview-button {
width: 24px;
min-width: 24px;
max-width: 24px;
height: 24px;
min-height: 24px;
max-height: 24px;
padding-left: 0;
padding-right: 0;
padding-top: 0;
padding-bottom: 0;
margin-left: 1px;
margin-right: 1px;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.fa-preview-button__icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.fa-preview-timeline {
min-width: 28px;
flex-grow: 1;
flex-shrink: 1;
margin-left: 5px;
margin-right: 5px;
}
.fa-preview-time {
width: 82px;
min-width: 82px;
flex-shrink: 0;
white-space: nowrap;
-unity-text-align: middle-right;
}
.fa-preview-settings {
min-height: 27px;
flex-direction: row;
align-items: center;
}
.fa-preview-setting {
flex-grow: 1;
flex-basis: 0;
min-width: 0;
margin-right: 5px;
flex-direction: row;
align-items: center;
}
.fa-preview-setting > .unity-label {
width: 64px;
min-width: 64px;
color: var(--fa-muted);
font-size: 10px;
}
.fa-preview-setting__field {
flex-grow: 1;
min-width: 0;
}
.fa-preview-status {
font-size: 10px;
color: rgb(185, 185, 185);
margin-top: 2px;
white-space: normal;
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: a38dd39c699e42348667cc1264ea9076
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ui:UXML xmlns:ui="UnityEngine.UIElements" editor-extension-mode="True">
<ui:VisualElement name="fa-workbench" class="fa-workbench">
<ui:VisualElement name="global-toolbar-host" class="fa-global-toolbar-host" />
<ui:VisualElement name="main-host" class="fa-main-host" />
<ui:VisualElement name="bottom-host" class="fa-bottom-host" />
</ui:VisualElement>
</ui:UXML>
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 3286d11c482fe8640a72c689bed6780c
ScriptedImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 2
userData:
assetBundleName:
assetBundleVariant:
script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df89f5fa1de04df448957109886651b8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,855 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationClipNodeView : Node
{
private readonly Label subtitle;
private readonly Label behavior;
private readonly Label issueBadge;
private readonly Label entryBadge;
private readonly VisualElement badgeContainer;
private readonly FrameAnimationPreviewElement preview;
private readonly Action<FrameAnimationClipNodeView> selectedCallback;
private FrameAnimationFlowColorPalette? entryPalette;
public AnimationNode Data { get; }
public Port InputPort { get; }
public Port OutputPort { get; }
internal FrameAnimationClipNodeView(
AnimationNode data,
Action<FrameAnimationClipNodeView> selectedCallback)
{
Data = data;
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
AddToClassList("fa-node");
InputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi,
typeof(AnimationNode));
InputPort.portName = string.Empty;
inputContainer.Add(InputPort);
OutputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single,
typeof(AnimationNode));
OutputPort.portName = string.Empty;
outputContainer.Add(OutputPort);
subtitle = new Label();
subtitle.AddToClassList("fa-node__clip-id");
behavior = new Label();
behavior.AddToClassList("fa-node__behavior");
var meta = new VisualElement();
meta.AddToClassList("fa-node__meta");
meta.Add(subtitle);
meta.Add(behavior);
extensionContainer.Add(meta);
preview = new FrameAnimationPreviewElement();
preview.AddToClassList("fa-node__preview");
extensionContainer.Add(preview);
badgeContainer = new VisualElement();
badgeContainer.AddToClassList("fa-node__badges");
entryBadge = new Label("E") { tooltip = "Flow Entry" };
entryBadge.AddToClassList("fa-node__entry");
badgeContainer.Add(entryBadge);
issueBadge = new Label();
issueBadge.AddToClassList("fa-badge");
issueBadge.style.unityFontStyleAndWeight = FontStyle.Bold;
badgeContainer.Add(issueBadge);
extensionContainer.Add(badgeContainer);
RefreshExpandedState();
}
public override void OnSelected()
{
base.OnSelected();
selectedCallback?.Invoke(this);
}
internal void Refresh(
FrameAnimationGraph graph,
IReadOnlyList<FrameAnimationEditorIssue> issues,
IReadOnlyList<AnimationFlow> flows)
{
var clip = graph.Clips.FirstOrDefault(item => item != null && item.Id == Data.ClipId);
title = string.IsNullOrWhiteSpace(Data.DisplayName) ? Data.ClipId : Data.DisplayName;
subtitle.text = clip != null ? Data.ClipId : $"Missing: {Data.ClipId}";
var speed = Data.SpeedOverride.HasValue
? $"Speed {Data.SpeedOverride.Value:0.###} (Node)"
: clip != null ? $"Speed {clip.Speed:0.###} (Clip)" : "Speed ?";
var end = Data.EndBehaviorOverride?.ToString() ?? "Continue / terminal fallback";
behavior.text = $"{speed} · {end}";
var used = flows.Where(flow => flow != null).ToArray();
var entryFlows = used.Where(flow => flow.EntryNodeId == Data.InternalId).ToArray();
entryPalette = entryFlows.Length > 0
? FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, entryFlows[0]))
: (FrameAnimationFlowColorPalette?)null;
entryBadge.style.display = entryPalette.HasValue ? DisplayStyle.Flex : DisplayStyle.None;
if (entryPalette.HasValue)
{
entryBadge.style.backgroundColor = entryPalette.Value.Stroke;
entryBadge.style.color = entryPalette.Value.Text;
entryBadge.tooltip = "Flow Entry: " + string.Join(", ", entryFlows.Select(flow => flow.Id));
}
foreach (var label in badgeContainer.Query<Label>(className: "fa-flow-badge").ToList())
{
label.RemoveFromHierarchy();
}
if (used.Length == 0)
{
var unused = new Label("UNUSED");
unused.AddToClassList("fa-badge");
unused.AddToClassList("fa-badge--yellow");
unused.AddToClassList("fa-flow-badge");
badgeContainer.Insert(entryPalette.HasValue ? 1 : 0, unused);
}
else
{
var insertIndex = entryPalette.HasValue ? 1 : 0;
foreach (var flow in used.Take(3))
{
var palette = FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
var badge = new Label(flow.Id) { tooltip = FlowTooltip(flow) };
badge.AddToClassList("fa-badge");
badge.AddToClassList("fa-flow-badge");
badge.style.backgroundColor = palette.Muted;
badge.style.color = palette.Text;
badgeContainer.Insert(insertIndex++, badge);
}
}
var matching = issues.Where(issue => issue.Selection.Kind == FrameAnimationEditorSelectionKind.Node &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
issueBadge.text = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? "ERROR"
: matching.Length > 0 ? "WARNING" : string.Empty;
issueBadge.style.display = matching.Length > 0 ? DisplayStyle.Flex : DisplayStyle.None;
issueBadge.EnableInClassList("fa-badge--red",
matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error));
issueBadge.EnableInClassList("fa-badge--yellow",
matching.Length > 0 && matching.All(issue => issue.Severity != FrameAnimationValidationSeverity.Error));
issueBadge.style.color = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error)
? new StyleColor(new Color(1f, 0.35f, 0.3f))
: new StyleColor(new Color(1f, 0.72f, 0.25f));
OutputPort.SetEnabled(!Data.EndBehaviorOverride.HasValue);
OutputPort.tooltip = Data.EndBehaviorOverride.HasValue
? "该节点已设置终点结束行为,请先清除覆盖。"
: "创建唯一的顺序后继";
}
private static string FlowTooltip(AnimationFlow flow) =>
$"Flow: {flow.DisplayName} ({flow.Id})";
internal void SetFocusedFlow(FrameAnimationGraph graph, AnimationFlow flow, bool belongs)
{
EnableInClassList("fa-node--focused", flow != null && belongs);
if (flow != null && belongs)
{
var palette = FrameAnimationFlowColorUtility.CreatePalette(
FrameAnimationFlowColorUtility.ResolveRaw(graph, flow));
titleContainer.style.backgroundColor = palette.Header;
var titleLabel = titleContainer.Q<Label>(className: "title-label");
if (titleLabel != null) titleLabel.style.color = palette.Text;
style.borderLeftColor = palette.Stroke;
}
else
{
titleContainer.style.backgroundColor = StyleKeyword.Null;
var titleLabel = titleContainer.Q<Label>(className: "title-label");
if (titleLabel != null) titleLabel.style.color = StyleKeyword.Null;
style.borderLeftColor = StyleKeyword.Null;
}
}
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.45f : 1f;
}
internal void SetPreview(
FrameAnimationPreviewDisplay display,
FrameAnimationPreviewBackground background)
{
preview.SetBackground(background);
preview.SetZoom(FrameAnimationPreviewZoomMode.Fit, 1f);
preview.SetDisplay(display.Sprite, display.IsEmpty);
preview.style.opacity = display.FlowState == FrameAnimationFlowElementState.Upcoming
? 0.55f : display.FlowState == FrameAnimationFlowElementState.Passed ? 0.82f : 1f;
EnableInClassList("fa-node--preview-current", display.FlowState == FrameAnimationFlowElementState.Current);
EnableInClassList("fa-node--preview-passed", display.FlowState == FrameAnimationFlowElementState.Passed);
EnableInClassList("fa-node--preview-upcoming", display.FlowState == FrameAnimationFlowElementState.Upcoming);
}
}
internal sealed class FrameAnimationEdgeView : Edge
{
private readonly Action<FrameAnimationEdgeView> selectedCallback;
private Color baseColor = new Color32(119, 127, 135, 255);
private bool hasError;
private bool hasWarning;
private FrameAnimationFlowElementState previewState;
public AnimationEdge Data { get; }
internal FrameAnimationEdgeView(AnimationEdge data, Action<FrameAnimationEdgeView> selectedCallback)
{
Data = data;
this.selectedCallback = selectedCallback;
viewDataKey = data.InternalId;
userData = data;
RegisterCallback<AttachToPanelEvent>(_ => ApplyVisualState());
}
public override void OnSelected()
{
base.OnSelected();
selectedCallback?.Invoke(this);
}
internal void SetDimmed(bool dimmed)
{
style.opacity = dimmed ? 0.30f : 1f;
}
internal void Refresh(IReadOnlyList<FrameAnimationEditorIssue> issues)
{
var matching = (issues ?? Array.Empty<FrameAnimationEditorIssue>()).Where(issue =>
issue.Selection.Kind == FrameAnimationEditorSelectionKind.Edge &&
ReferenceEquals(issue.Selection.Value, Data)).ToArray();
hasError = matching.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
hasWarning = !hasError && matching.Length > 0;
ApplyVisualState();
tooltip = matching.Length == 0
? $"{Data.ExitName} / {Data.Condition}"
: string.Join("\n", matching.Select(issue => $"[{issue.Code}] {issue.Message}"));
}
internal void SetPreviewState(FrameAnimationFlowElementState state)
{
previewState = state;
ApplyVisualState();
}
internal void SetFocusedFlow(Color? flowColor, bool belongs)
{
baseColor = flowColor.HasValue && belongs
? FrameAnimationFlowColorUtility.CreatePalette(flowColor.Value).Stroke
: new Color32(119, 127, 135, 255);
ApplyVisualState();
}
private void ApplyVisualState()
{
var color = hasError
? new Color32(223, 106, 106, 255)
: hasWarning ? new Color32(224, 184, 88, 255) : baseColor;
var width = 3f;
if (!hasError && !hasWarning)
{
if (previewState == FrameAnimationFlowElementState.Current)
{
color = Color.Lerp(color, Color.white, 0.28f);
width = 5f;
}
else if (previewState == FrameAnimationFlowElementState.Passed)
{
color = Color.Lerp(color, new Color32(86, 185, 123, 255), 0.22f);
width = 3.5f;
}
else if (previewState == FrameAnimationFlowElementState.Upcoming)
{
color = Color.Lerp(color, new Color32(43, 47, 52, 255), 0.42f);
width = 2f;
}
}
edgeControl.inputColor = color;
edgeControl.outputColor = color;
if (panel != null)
{
edgeControl.edgeWidth = Mathf.RoundToInt(width);
}
}
}
internal sealed class FrameAnimationClipSearchProvider : ScriptableObject, ISearchWindowProvider
{
private FrameAnimationGraph graph;
private Action<FrameClip> selected;
internal void Configure(FrameAnimationGraph value, Action<FrameClip> callback)
{
graph = value;
selected = callback;
}
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
var entries = new List<SearchTreeEntry>
{
new SearchTreeGroupEntry(new GUIContent("Create Clip Node"), 0)
};
foreach (var clip in graph.Clips.Where(clip => clip != null)
.OrderBy(clip => clip.DisplayName, StringComparer.Ordinal)
.ThenBy(clip => clip.Id, StringComparer.Ordinal))
{
entries.Add(new SearchTreeEntry(new GUIContent($"{clip.DisplayName} ({clip.Id})"))
{
level = 1,
userData = clip
});
}
return entries;
}
public bool OnSelectEntry(SearchTreeEntry entry, SearchWindowContext context)
{
if (entry.userData is FrameClip clip)
{
selected?.Invoke(clip);
return true;
}
return false;
}
}
internal sealed class FrameAnimationGraphView : GraphView
{
internal const string ClipDragKey = "AibisDream.FrameAnimation.ClipDrag";
private readonly Dictionary<string, FrameAnimationClipNodeView> nodeViews =
new Dictionary<string, FrameAnimationClipNodeView>();
private readonly Dictionary<string, FrameAnimationEdgeView> edgeViews =
new Dictionary<string, FrameAnimationEdgeView>();
private FrameAnimationGraph graph;
private IReadOnlyList<FrameAnimationEditorIssue> issues = Array.Empty<FrameAnimationEditorIssue>();
private bool rebuilding;
private Vector2 contextPosition;
private string focusedFlowId = string.Empty;
internal event Action<FrameAnimationEditorSelection> SelectionRequested;
internal event Action<IReadOnlyList<AnimationNode>> MultiSelectionChanged;
internal event Action<IReadOnlyList<FrameAnimationEditorSelection>> SelectionSetChanged;
internal event Action<AnimationNode> CreateFlowRequested;
internal event Action<AnimationNode> NodeCreated;
internal event Action ExitFocusRequested;
internal event Action GraphChanged;
internal event Action<string> NotificationRequested;
internal event Action<Vector3, float> WorkspaceViewTransformChanged;
internal FrameAnimationGraphView()
{
style.flexGrow = 1f;
focusable = true;
SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
this.AddManipulator(new ContentDragger());
this.AddManipulator(new SelectionDragger());
this.AddManipulator(new RectangleSelector());
var grid = new GridBackground();
Insert(0, grid);
grid.StretchToParentSize();
graphViewChanged = OnGraphViewChanged;
viewTransformChanged += _ =>
WorkspaceViewTransformChanged?.Invoke(viewTransform.position, viewTransform.scale.x);
RegisterCallback<MouseUpEvent>(_ => NotifySelectionSummary());
RegisterCallback<DragUpdatedEvent>(OnDragUpdated);
RegisterCallback<DragPerformEvent>(OnDragPerform);
RegisterCallback<KeyDownEvent>(OnKeyDown);
}
internal void Bind(
FrameAnimationGraph value,
IReadOnlyList<FrameAnimationEditorIssue> validationIssues,
string focusedFlowId)
{
graph = value;
issues = validationIssues ?? Array.Empty<FrameAnimationEditorIssue>();
var selectedIds = selection.OfType<FrameAnimationClipNodeView>()
.Select(view => view.Data.InternalId).ToArray();
var selectedEdgeIds = selection.OfType<FrameAnimationEdgeView>()
.Select(view => view.Data.InternalId).ToArray();
rebuilding = true;
DeleteElements(graphElements.ToList());
nodeViews.Clear();
edgeViews.Clear();
if (graph != null)
{
var topology = new FrameAnimationGraphTopology(graph);
foreach (var node in graph.Nodes.Where(node => node != null))
{
var view = new FrameAnimationClipNodeView(node, OnNodeSelected);
view.SetPosition(new Rect(FrameAnimationGraphMutationService.GetNodePosition(graph, node),
new Vector2(224f, 164f)));
view.Refresh(graph, issues, topology.FindFlowsUsingNode(node.InternalId));
nodeViews[node.InternalId] = view;
AddElement(view);
}
foreach (var edge in graph.Edges.Where(edge => edge != null))
{
if (!nodeViews.TryGetValue(edge.FromNodeId ?? string.Empty, out var from) ||
!nodeViews.TryGetValue(edge.ToNodeId ?? string.Empty, out var to))
{
continue;
}
var view = new FrameAnimationEdgeView(edge, OnEdgeSelected)
{
output = from.OutputPort,
input = to.InputPort
};
view.output.Connect(view);
view.input.Connect(view);
view.Refresh(issues);
edgeViews[edge.InternalId] = view;
AddElement(view);
}
foreach (var id in selectedIds.Where(nodeViews.ContainsKey))
{
AddToSelection(nodeViews[id]);
}
foreach (var id in selectedEdgeIds.Where(edgeViews.ContainsKey))
{
AddToSelection(edgeViews[id]);
}
}
rebuilding = false;
SetFocusedFlow(focusedFlowId);
}
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
if (evt.target == this || evt.target == contentViewContainer)
{
contextPosition = contentViewContainer.WorldToLocal(evt.mousePosition);
evt.menu.AppendAction("Create Clip Node", _ => OpenClipSearch(evt.mousePosition),
graph != null && graph.Clips.Any(clip => clip != null)
? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled);
}
if (selection.Count == 1 && selection[0] is FrameAnimationClipNodeView nodeView)
{
evt.menu.AppendAction("Create Flow From Node", _ => CreateFlowRequested?.Invoke(nodeView.Data));
}
base.BuildContextualMenu(evt);
}
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
var result = new List<Port>();
if (graph == null || !(startPort.node is FrameAnimationClipNodeView startNode))
{
return result;
}
ports.ForEach(port =>
{
if (!(port.node is FrameAnimationClipNodeView targetNode) || port.direction == startPort.direction ||
targetNode == startNode)
{
return;
}
var from = startPort.direction == Direction.Output ? startNode.Data : targetNode.Data;
var to = startPort.direction == Direction.Output ? targetNode.Data : startNode.Data;
if (FrameAnimationGraphMutationService.CanConnect(graph, from, to, out _))
{
result.Add(port);
}
});
return result;
}
internal void SetFocusedFlow(string flowId)
{
focusedFlowId = flowId ?? string.Empty;
if (graph == null || string.IsNullOrEmpty(flowId))
{
foreach (var view in nodeViews.Values)
{
view.SetDimmed(false);
view.SetFocusedFlow(graph, null, false);
}
foreach (var view in edgeViews.Values)
{
view.SetDimmed(false);
view.SetFocusedFlow(null, false);
}
return;
}
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
var topology = new FrameAnimationGraphTopology(graph);
var reachable = flow != null
? topology.GetReachable(flow.EntryNodeId)
: new FrameAnimationReachability(Array.Empty<AnimationNode>(), Array.Empty<AnimationEdge>());
var nodeIds = new HashSet<string>(reachable.Nodes.Select(node => node.InternalId));
var edgeIds = new HashSet<string>(reachable.Edges.Select(edge => edge.InternalId));
var flowColor = flow != null ? FrameAnimationFlowColorUtility.ResolveRaw(graph, flow) : (Color?)null;
foreach (var pair in nodeViews)
{
var belongs = nodeIds.Contains(pair.Key);
pair.Value.SetDimmed(!belongs);
pair.Value.SetFocusedFlow(graph, flow, belongs);
}
foreach (var pair in edgeViews)
{
var belongs = edgeIds.Contains(pair.Key);
pair.Value.SetDimmed(!belongs);
pair.Value.SetFocusedFlow(flowColor, belongs);
}
}
internal void SelectAndFrame(FrameAnimationEditorSelection target)
{
GraphElement element = target.Value switch
{
AnimationNode node when nodeViews.TryGetValue(node.InternalId, out var nodeView) => nodeView,
AnimationEdge edge when edgeViews.TryGetValue(edge.InternalId, out var edgeView) => edgeView,
_ => null
};
if (element == null) return;
ClearSelection();
AddToSelection(element);
FrameSelection();
}
internal IReadOnlyList<AnimationNode> SelectedNodes =>
selection.OfType<FrameAnimationClipNodeView>().Select(view => view.Data).ToArray();
internal IReadOnlyList<AnimationNode> VisibleNodes => nodeViews.Values
.Where(view => view.worldBound.Overlaps(worldBound))
.Select(view => view.Data)
.ToArray();
internal void ApplyPreview(FrameAnimationPreviewCoordinator coordinator)
{
if (coordinator == null) return;
foreach (var view in nodeViews.Values)
{
view.SetPreview(coordinator.GetNodeDisplay(view.Data), coordinator.Background);
}
foreach (var view in edgeViews.Values)
{
view.SetPreviewState(coordinator.GetEdgeState(view.Data));
}
}
internal void RestoreViewTransform(Vector3 position, float scale)
{
var safeScale = float.IsNaN(scale) || float.IsInfinity(scale)
? 1f : Mathf.Clamp(scale, ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
if (float.IsNaN(position.x) || float.IsInfinity(position.x) ||
float.IsNaN(position.y) || float.IsInfinity(position.y))
{
position = Vector3.zero;
}
UpdateViewTransform(position, new Vector3(safeScale, safeScale, 1f));
}
internal void RequestDeleteNodes(IEnumerable<AnimationNode> nodes)
{
var items = (nodes ?? Array.Empty<AnimationNode>()).Where(node => node != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
var ids = new HashSet<string>(items.Select(node => node.InternalId));
var entryFlows = graph.Flows.Where(flow => flow != null && ids.Contains(flow.EntryNodeId)).ToArray();
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeNodeRemoval(graph, items);
if (!EditorUtility.DisplayDialog(
"Delete Frame Animation Nodes",
BuildRemovalMessage("Node", items.Length, impact, entryFlows),
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete",
"Cancel"))
{
return;
}
if (!FrameAnimationGraphMutationService.RemoveNodes(graph, items, entryFlows.Length > 0, out var error))
{
NotificationRequested?.Invoke(error);
return;
}
GraphChanged?.Invoke();
}
internal void RequestDeleteEdges(IEnumerable<AnimationEdge> edges)
{
var items = (edges ?? Array.Empty<AnimationEdge>()).Where(edge => edge != null).Distinct().ToArray();
if (graph == null || items.Length == 0)
{
return;
}
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, items);
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog(
"Disconnect Frame Animation Nodes",
BuildRemovalMessage("Edge", items.Length, impact, Array.Empty<AnimationFlow>()),
"Disconnect",
"Cancel"))
{
return;
}
FrameAnimationGraphMutationService.RemoveEdges(graph, items);
GraphChanged?.Invoke();
}
internal void FrameCurrentFlow(string flowId)
{
if (graph == null || string.IsNullOrEmpty(flowId)) return;
var flow = graph.Flows.FirstOrDefault(item => item != null && item.Id == flowId);
if (flow == null) return;
var ids = new HashSet<string>(new FrameAnimationGraphTopology(graph)
.GetReachable(flow.EntryNodeId).Nodes.Select(node => node.InternalId));
ClearSelection();
foreach (var pair in nodeViews.Where(pair => ids.Contains(pair.Key))) AddToSelection(pair.Value);
FrameSelection();
}
internal void ApplyCalculatedPositions(IReadOnlyDictionary<AnimationNode, Vector2> positions)
{
foreach (var pair in positions)
{
if (nodeViews.TryGetValue(pair.Key.InternalId, out var view))
{
var rect = view.GetPosition();
rect.position = pair.Value;
view.SetPosition(rect);
}
}
}
private void OpenClipSearch(Vector2 panelPosition)
{
var provider = ScriptableObject.CreateInstance<FrameAnimationClipSearchProvider>();
provider.Configure(graph, clip => CreateNode(clip, contextPosition));
SearchWindow.Open(new SearchWindowContext(GUIUtility.GUIToScreenPoint(panelPosition)), provider);
}
internal void CreateNode(FrameClip clip, Vector2 position)
{
if (FrameAnimationGraphMutationService.CreateNode(graph, clip, position, out var node, out var error))
{
NodeCreated?.Invoke(node);
GraphChanged?.Invoke();
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, node));
}
else
{
NotificationRequested?.Invoke(error);
}
}
private GraphViewChange OnGraphViewChanged(GraphViewChange change)
{
if (rebuilding || graph == null)
{
return change;
}
if (change.edgesToCreate != null)
{
foreach (var visual in change.edgesToCreate.ToArray())
{
var error = string.Empty;
AnimationEdge data = null;
var from = visual.output?.node as FrameAnimationClipNodeView;
var to = visual.input?.node as FrameAnimationClipNodeView;
if (from == null || to == null ||
!FrameAnimationGraphMutationService.TryConnect(
graph, from.Data, to.Data, out data, out error))
{
change.edgesToCreate.Remove(visual);
NotificationRequested?.Invoke(string.IsNullOrEmpty(error) ? "无法创建连接。" : error);
continue;
}
visual.userData = data;
}
GraphChanged?.Invoke();
}
if (change.elementsToRemove != null)
{
ProcessRemovals(change);
}
if (change.movedElements != null)
{
var positions = change.movedElements.OfType<FrameAnimationClipNodeView>()
.ToDictionary(view => view.Data, view => view.GetPosition().position);
if (positions.Count > 0)
{
FrameAnimationGraphMutationService.SetNodePositions(graph, positions,
"Move Frame Animation Nodes");
GraphChanged?.Invoke();
}
}
return change;
}
private void ProcessRemovals(GraphViewChange change)
{
var nodeViewsToRemove = change.elementsToRemove.OfType<FrameAnimationClipNodeView>().ToArray();
var removedNodeIds = new HashSet<string>(nodeViewsToRemove.Select(view => view.Data.InternalId));
if (nodeViewsToRemove.Length > 0)
{
var nodes = nodeViewsToRemove.Select(view => view.Data).ToArray();
var explicitlySelectedEdges = change.elementsToRemove.OfType<Edge>()
.Select(edge => edge.userData as AnimationEdge)
.Where(edge => edge != null).Distinct().ToArray();
var entryFlows = graph.Flows.Where(flow => flow != null && removedNodeIds.Contains(flow.EntryNodeId)).ToArray();
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeRemoval(
graph, nodes, explicitlySelectedEdges);
var message = BuildRemovalMessage("Node", nodes.Length, impact, entryFlows);
if (explicitlySelectedEdges.Length > 0)
{
message += $"\n\n同时删除选中的 Edge{explicitlySelectedEdges.Length}";
}
var confirmed = EditorUtility.DisplayDialog("Delete Frame Animation Nodes", message,
entryFlows.Length > 0 ? "Delete Nodes + Flows" : "Delete", "Cancel");
var removed = false;
var error = string.Empty;
if (confirmed)
{
removed = FrameAnimationGraphMutationService.RemoveNodesAndEdges(
graph, nodes, explicitlySelectedEdges, entryFlows.Length > 0, out error);
}
if (!removed)
{
change.elementsToRemove.Clear();
if (!string.IsNullOrEmpty(error)) NotificationRequested?.Invoke(error);
}
GraphChanged?.Invoke();
return;
}
var remainingEdges = change.elementsToRemove.OfType<Edge>()
.Where(edge => !IsIncidentTo(edge, removedNodeIds))
.Select(edge => edge.userData as AnimationEdge).Where(edge => edge != null).Distinct().ToArray();
if (remainingEdges.Length > 0)
{
var impact = FrameAnimationGraphImpactAnalyzer.AnalyzeEdgeRemoval(graph, remainingEdges);
if (impact.Flows.Count > 0 && !EditorUtility.DisplayDialog("Disconnect Frame Animation Nodes",
BuildRemovalMessage("Edge", remainingEdges.Length, impact, Array.Empty<AnimationFlow>()),
"Disconnect", "Cancel"))
{
foreach (var edgeView in change.elementsToRemove.OfType<Edge>()
.Where(view => remainingEdges.Contains(view.userData as AnimationEdge)).ToArray())
{
change.elementsToRemove.Remove(edgeView);
}
}
else
{
FrameAnimationGraphMutationService.RemoveEdges(graph, remainingEdges);
}
}
GraphChanged?.Invoke();
}
private static bool IsIncidentTo(Edge edge, IReadOnlyCollection<string> nodeIds)
{
var from = edge?.output?.node as FrameAnimationClipNodeView;
var to = edge?.input?.node as FrameAnimationClipNodeView;
return from != null && nodeIds.Contains(from.Data.InternalId) ||
to != null && nodeIds.Contains(to.Data.InternalId);
}
private static string BuildRemovalMessage(
string kind,
int count,
FrameAnimationGraphImpact impact,
IReadOnlyCollection<AnimationFlow> entryFlows)
{
var message = $"将删除 {count} 个 {kind}。";
if (impact.Flows.Count > 0)
{
message += $"\n\n受影响 Flow{string.Join(", ", impact.Flows.Select(flow => flow.Id))}" +
$"\n将失去可达关系的 Node{impact.LostNodeCount}";
}
if (entryFlows.Count > 0)
{
message += $"\n\n入口 Flow 将同时删除:{string.Join(", ", entryFlows.Select(flow => flow.Id))}";
}
return message;
}
private void OnNodeSelected(FrameAnimationClipNodeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Node, view.Data));
NotifySelectionSummary();
}
private void OnEdgeSelected(FrameAnimationEdgeView view)
{
if (rebuilding)
{
return;
}
SelectionRequested?.Invoke(new FrameAnimationEditorSelection(FrameAnimationEditorSelectionKind.Edge, view.Data));
NotifySelectionSummary();
}
private void NotifySelectionSummary()
{
if (rebuilding)
{
return;
}
MultiSelectionChanged?.Invoke(SelectedNodes);
var selected = selection.Select(item => item switch
{
FrameAnimationClipNodeView nodeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Node, nodeView.Data),
FrameAnimationEdgeView edgeView => new FrameAnimationEditorSelection(
FrameAnimationEditorSelectionKind.Edge, edgeView.Data),
_ => default
})
.Where(item => item.Kind != FrameAnimationEditorSelectionKind.None)
.ToArray();
SelectionSetChanged?.Invoke(selected);
}
private void OnKeyDown(KeyDownEvent evt)
{
if (!TryHandleFocusExitShortcut(evt.keyCode, evt.target is TextField))
{
return;
}
evt.StopImmediatePropagation();
}
internal bool TryHandleFocusExitShortcut(KeyCode keyCode, bool textInputActive)
{
if (keyCode != KeyCode.Escape || textInputActive || string.IsNullOrEmpty(focusedFlowId))
{
return false;
}
ExitFocusRequested?.Invoke();
return true;
}
private void OnDragUpdated(DragUpdatedEvent evt)
{
if (DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip && graph?.Clips.Contains(clip) == true)
{
DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
evt.StopPropagation();
}
}
private void OnDragPerform(DragPerformEvent evt)
{
if (!(DragAndDrop.GetGenericData(ClipDragKey) is FrameClip clip) || graph?.Clips.Contains(clip) != true)
{
return;
}
DragAndDrop.AcceptDrag();
CreateNode(clip, contentViewContainer.WorldToLocal(evt.mousePosition));
DragAndDrop.SetGenericData(ClipDragKey, null);
evt.StopPropagation();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ffd56cf8d10f4ff9b33510a268d6b615
@@ -0,0 +1,353 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationImportIssueCode
{
JsonMissing,
TextureMissing,
JsonInvalid,
FramesMissing,
FrameNameInvalid,
FrameNameDuplicate,
FrameDurationInvalid,
FrameRectInvalid,
TextureSizeMismatch,
TrimmedUnsupported,
RotatedUnsupported,
TagNameInvalid,
TagNameDuplicate,
TagRangeInvalid,
TagDirectionInvalid,
TagConflict,
PlayableIdConflict,
ImportSourceIdInvalid,
ImportSourceIdDuplicate,
ImportSourceReferenceInvalid,
ImportedClipOwnershipInvalid,
TextureOwnershipConflict,
SpriteMatchFailed,
TextureImporterInvalid,
TextureWriteFailed,
SourceChanged,
ImageNameMismatch,
NoTags,
ApplyFailed
}
internal enum FrameAnimationImportChangeKind
{
Added,
Updated,
Missing,
Unchanged,
Error
}
internal enum FrameAnimationSpriteChangeKind
{
Added,
Updated,
Retained,
Unchanged,
Error
}
internal sealed class FrameAnimationImportIssue
{
public FrameAnimationValidationSeverity Severity { get; }
public FrameAnimationImportIssueCode Code { get; }
public string SourceId { get; }
public string TargetId { get; }
public string Message { get; }
public FrameAnimationImportIssue(
FrameAnimationValidationSeverity severity,
FrameAnimationImportIssueCode code,
string sourceId,
string targetId,
string message)
{
Severity = severity;
Code = code;
SourceId = sourceId ?? string.Empty;
TargetId = targetId ?? string.Empty;
Message = message ?? string.Empty;
}
}
internal sealed class AsepriteSourceFrame
{
public string FrameName { get; }
public RectInt Rect { get; }
public int DurationMs { get; }
public bool Rotated { get; }
public bool Trimmed { get; }
public RectInt SpriteSourceSize { get; }
public Vector2Int SourceSize { get; }
public AsepriteSourceFrame(
string frameName,
RectInt rect,
int durationMs,
bool rotated,
bool trimmed,
RectInt spriteSourceSize,
Vector2Int sourceSize)
{
FrameName = frameName ?? string.Empty;
Rect = rect;
DurationMs = durationMs;
Rotated = rotated;
Trimmed = trimmed;
SpriteSourceSize = spriteSourceSize;
SourceSize = sourceSize;
}
}
internal sealed class AsepriteSourceTag
{
public string Name { get; }
public int From { get; }
public int To { get; }
public string Direction { get; }
public AsepriteSourceTag(string name, int from, int to, string direction)
{
Name = name ?? string.Empty;
From = from;
To = to;
Direction = direction ?? string.Empty;
}
}
internal sealed class AsepriteSpriteSlot
{
public RectInt Rect { get; }
public string CanonicalFrameName { get; }
public IReadOnlyList<int> SourceIndices { get; }
public IReadOnlyList<string> FrameNames { get; }
public AsepriteSpriteSlot(
RectInt rect,
string canonicalFrameName,
IReadOnlyList<int> sourceIndices,
IReadOnlyList<string> frameNames)
{
Rect = rect;
CanonicalFrameName = canonicalFrameName ?? string.Empty;
SourceIndices = sourceIndices ?? Array.Empty<int>();
FrameNames = frameNames ?? Array.Empty<string>();
}
}
internal sealed class AsepriteSourceDocument
{
public IReadOnlyList<AsepriteSourceFrame> Frames { get; }
public IReadOnlyList<AsepriteSpriteSlot> SpriteSlots { get; }
public IReadOnlyDictionary<int, AsepriteSpriteSlot> SpriteSlotBySourceIndex { get; }
public IReadOnlyList<AsepriteSourceTag> Tags { get; }
public Vector2Int TextureSize { get; }
public string ImageName { get; }
public AsepriteSourceDocument(
IReadOnlyList<AsepriteSourceFrame> frames,
IReadOnlyList<AsepriteSourceTag> tags,
Vector2Int textureSize,
string imageName)
{
Frames = frames ?? Array.Empty<AsepriteSourceFrame>();
BuildSpriteSlots(Frames, out var spriteSlots, out var slotBySourceIndex);
SpriteSlots = spriteSlots;
SpriteSlotBySourceIndex = slotBySourceIndex;
Tags = tags ?? Array.Empty<AsepriteSourceTag>();
TextureSize = textureSize;
ImageName = imageName ?? string.Empty;
}
private static void BuildSpriteSlots(
IReadOnlyList<AsepriteSourceFrame> frames,
out IReadOnlyList<AsepriteSpriteSlot> spriteSlots,
out IReadOnlyDictionary<int, AsepriteSpriteSlot> slotBySourceIndex)
{
var builders = new List<SpriteSlotBuilder>();
var buildersByRect = new Dictionary<RectInt, SpriteSlotBuilder>();
for (var sourceIndex = 0; sourceIndex < frames.Count; sourceIndex++)
{
var frame = frames[sourceIndex];
if (!buildersByRect.TryGetValue(frame.Rect, out var builder))
{
builder = new SpriteSlotBuilder(frame.Rect, frame.FrameName);
buildersByRect.Add(frame.Rect, builder);
builders.Add(builder);
}
builder.SourceIndices.Add(sourceIndex);
builder.FrameNames.Add(frame.FrameName);
}
var slots = builders.Select(builder => builder.Build()).ToArray();
var index = new Dictionary<int, AsepriteSpriteSlot>();
foreach (var slot in slots)
{
foreach (var sourceIndex in slot.SourceIndices)
{
index[sourceIndex] = slot;
}
}
spriteSlots = slots;
slotBySourceIndex = index;
}
private sealed class SpriteSlotBuilder
{
public RectInt Rect { get; }
public string CanonicalFrameName { get; }
public List<int> SourceIndices { get; } = new List<int>();
public List<string> FrameNames { get; } = new List<string>();
public SpriteSlotBuilder(RectInt rect, string canonicalFrameName)
{
Rect = rect;
CanonicalFrameName = canonicalFrameName ?? string.Empty;
}
public AsepriteSpriteSlot Build()
{
return new AsepriteSpriteSlot(
Rect,
CanonicalFrameName,
SourceIndices.ToArray(),
FrameNames.ToArray());
}
}
}
internal sealed class FrameAnimationClipDiff
{
public FrameAnimationImportChangeKind Kind { get; }
public string SourceTagName { get; }
public FrameClip Clip { get; }
public IReadOnlyList<int> SourceIndices { get; }
public string Summary { get; }
public FrameAnimationClipDiff(
FrameAnimationImportChangeKind kind,
string sourceTagName,
FrameClip clip,
IReadOnlyList<int> sourceIndices,
string summary)
{
Kind = kind;
SourceTagName = sourceTagName ?? string.Empty;
Clip = clip;
SourceIndices = sourceIndices ?? Array.Empty<int>();
Summary = summary ?? string.Empty;
}
}
internal sealed class FrameAnimationSpriteDiff
{
public FrameAnimationSpriteChangeKind Kind { get; }
public string FrameName { get; }
public IReadOnlyList<string> AliasFrameNames { get; }
public int SourceFrameCount => AliasFrameNames.Count;
public Rect Rect { get; }
public string Summary { get; }
public FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind kind,
string frameName,
Rect rect,
string summary,
IReadOnlyList<string> aliasFrameNames = null)
{
Kind = kind;
FrameName = frameName ?? string.Empty;
AliasFrameNames = aliasFrameNames ?? Array.Empty<string>();
Rect = rect;
Summary = summary ?? string.Empty;
}
}
internal interface IFrameAnimationImportPreviewSink
{
void AddIssue(FrameAnimationImportIssue issue);
void AddSpriteDiff(FrameAnimationSpriteDiff diff);
}
internal sealed class FrameAnimationImportSourcePreview : IFrameAnimationImportPreviewSink
{
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
private readonly List<FrameAnimationClipDiff> clipDiffs = new List<FrameAnimationClipDiff>();
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
public FrameAnimationImportSource Source { get; }
public AsepriteSourceDocument Document { get; internal set; }
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
public string CurrentHash { get; internal set; } = string.Empty;
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
public IReadOnlyList<FrameAnimationClipDiff> ClipDiffs => clipDiffs;
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
public FrameAnimationImportSourcePreview(FrameAnimationImportSource source)
{
Source = source;
}
public void AddIssue(FrameAnimationImportIssue issue)
{
if (issue != null)
{
issues.Add(issue);
}
}
public void AddClipDiff(FrameAnimationClipDiff diff)
{
if (diff != null)
{
clipDiffs.Add(diff);
}
}
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
{
if (diff != null)
{
spriteDiffs.Add(diff);
}
}
}
internal sealed class FrameAnimationImportPreview
{
private readonly List<FrameAnimationImportSourcePreview> sources =
new List<FrameAnimationImportSourcePreview>();
public FrameAnimationGraph Graph { get; }
public IReadOnlyList<FrameAnimationImportSourcePreview> Sources => sources;
public bool HasErrors => sources.Any(source => source.HasErrors);
public bool HasSpriteChanges => sources.Any(source => source.HasSpriteChanges);
public FrameAnimationImportPreview(FrameAnimationGraph graph)
{
Graph = graph;
}
public void AddSource(FrameAnimationImportSourcePreview preview)
{
if (preview != null)
{
sources.Add(preview);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9d29e9107c2ec549916dfb45e638943
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,627 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal static class FrameAnimationImportService
{
public static FrameAnimationImportPreview PreviewSource(
FrameAnimationGraph graph,
string sourceId)
{
var source = graph?.ImportSources.FirstOrDefault(item => item != null && item.InternalId == sourceId);
return PreviewInternal(graph, source != null ? new[] { source } : Array.Empty<FrameAnimationImportSource>());
}
public static FrameAnimationImportPreview PreviewAll(FrameAnimationGraph graph)
{
return PreviewInternal(
graph,
graph?.ImportSources.Where(source => source != null && source.IsEnabled).ToArray() ??
Array.Empty<FrameAnimationImportSource>());
}
public static bool RefreshSource(
FrameAnimationGraph graph,
string sourceId,
bool allowSpriteChanges,
out FrameAnimationImportPreview preview,
out string error)
{
preview = PreviewSource(graph, sourceId);
return Apply(preview, allowSpriteChanges, out error);
}
public static bool RefreshAll(
FrameAnimationGraph graph,
bool allowSpriteChanges,
out FrameAnimationImportPreview preview,
out string error)
{
preview = PreviewAll(graph);
return Apply(preview, allowSpriteChanges, out error);
}
public static bool Apply(
FrameAnimationImportPreview preview,
bool allowSpriteChanges,
out string error)
{
error = string.Empty;
if (preview == null || preview.Graph == null)
{
error = "导入预览或 Graph 为空。";
return false;
}
if (preview.HasErrors)
{
error = "导入预览包含阻断错误,未修改任何资产。";
return false;
}
if (preview.HasSpriteChanges && !allowSpriteChanges)
{
error = "导入需要修改 TextureImporter,但尚未获得确认。";
return false;
}
var graphPath = AssetDatabase.GetAssetPath(preview.Graph);
if (string.IsNullOrEmpty(graphPath))
{
error = "FrameAnimationGraph 必须先保存为资产才能创建 Imported Clip sub-asset。";
return false;
}
var snapshots = new List<FrameAnimationTextureSnapshot>();
Undo.IncrementCurrentGroup();
var undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Refresh Frame Animation Imports");
try
{
Undo.RecordObject(preview.Graph, "Refresh Frame Animation Imports");
foreach (var sourcePreview in preview.Sources.Where(item =>
item.Source.ManageSpriteSlicing && item.HasSpriteChanges))
{
var snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(sourcePreview.SpritePlan);
snapshots.Add(snapshot);
FrameAnimationSpriteUtility.ApplyPlan(sourcePreview.SpritePlan);
}
foreach (var sourcePreview in preview.Sources)
{
ApplySource(preview.Graph, sourcePreview);
sourcePreview.Source.SetLastSourceHash(
FrameAnimationSpriteUtility.ComputeSourceHash(sourcePreview.Source));
}
EditorUtility.SetDirty(preview.Graph);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(undoGroup);
return true;
}
catch (Exception exception)
{
try
{
Undo.RevertAllDownToGroup(undoGroup);
for (var index = snapshots.Count - 1; index >= 0; index--)
{
FrameAnimationSpriteUtility.RestoreSnapshot(snapshots[index]);
}
AssetDatabase.SaveAssets();
}
catch (Exception rollbackException)
{
Debug.LogError($"Frame Animation 导入回滚失败:{rollbackException}");
}
error = $"导入应用失败,已尝试回滚:{exception.Message}";
Debug.LogException(exception);
return false;
}
}
private static FrameAnimationImportPreview PreviewInternal(
FrameAnimationGraph graph,
IReadOnlyList<FrameAnimationImportSource> targets)
{
var preview = new FrameAnimationImportPreview(graph);
if (graph == null)
{
return preview;
}
var targetSet = new HashSet<FrameAnimationImportSource>(targets);
var targetPreviews = new Dictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview>();
foreach (var source in targets)
{
var sourcePreview = new FrameAnimationImportSourcePreview(source);
targetPreviews[source] = sourcePreview;
preview.AddSource(sourcePreview);
}
ValidateSourceIds(graph, targetPreviews);
var contextDocuments = ParseEnabledContext(graph, targetPreviews);
ValidateCrossSourceTags(graph, contextDocuments, targetPreviews);
ValidateTextureOwnership(graph, targetPreviews);
foreach (var pair in targetPreviews)
{
var source = pair.Key;
var sourcePreview = pair.Value;
if (!contextDocuments.TryGetValue(source, out var document))
{
continue;
}
sourcePreview.Document = document;
ValidateSource(graph, source, document, sourcePreview);
if (sourcePreview.HasErrors)
{
continue;
}
sourcePreview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(source, document, sourcePreview);
sourcePreview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(source);
if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != sourcePreview.CurrentHash)
{
sourcePreview.AddIssue(new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Warning,
FrameAnimationImportIssueCode.SourceChanged,
source.InternalId,
source.InternalId,
"来源内容或切图设置自上次刷新后已发生变化。"));
}
if (!sourcePreview.HasErrors)
{
BuildClipDiffs(graph, source, document, sourcePreview);
}
}
return preview;
}
private static Dictionary<FrameAnimationImportSource, AsepriteSourceDocument> ParseEnabledContext(
FrameAnimationGraph graph,
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
{
var result = new Dictionary<FrameAnimationImportSource, AsepriteSourceDocument>();
foreach (var source in graph.ImportSources.Where(source => source != null && source.IsEnabled))
{
targetPreviews.TryGetValue(source, out var sourcePreview);
if (source.AsepriteJson == null)
{
sourcePreview?.AddIssue(Error(source, FrameAnimationImportIssueCode.JsonMissing, "ImportSource 缺少 Aseprite JSON。"));
continue;
}
if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, source.InternalId, out var document, out var issue))
{
sourcePreview?.AddIssue(issue);
continue;
}
result[source] = document;
}
return result;
}
private static void ValidateSourceIds(
FrameAnimationGraph graph,
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
{
foreach (var source in graph.ImportSources.Where(source => source != null))
{
if (!FrameAnimationValueUtility.IsValidInternalId(source.InternalId) &&
targetPreviews.TryGetValue(source, out var sourcePreview))
{
sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdInvalid,
"ImportSource internalId 必须是 N 格式 GUID。"));
}
}
foreach (var duplicate in graph.ImportSources
.Where(source => source != null)
.GroupBy(source => source.InternalId)
.Where(group => group.Count() > 1))
{
foreach (var source in duplicate)
{
if (targetPreviews.TryGetValue(source, out var sourcePreview))
{
sourcePreview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceIdDuplicate,
$"ImportSource internalId '{source.InternalId}' 重复。"));
}
}
}
}
private static void ValidateSource(
FrameAnimationGraph graph,
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview)
{
if (source.Texture == null)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureMissing, "ImportSource 缺少 Texture。"));
return;
}
if (document.Frames.Count == 0)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。"));
}
foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameNameDuplicate,
$"frameName '{duplicate.Key}' 在同一来源内重复。"));
}
for (var index = 0; index < document.Frames.Count; index++)
{
var frame = document.Frames[index];
if (string.IsNullOrWhiteSpace(frame.FrameName))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameNameInvalid,
$"SourceFrame[{index}] 的 frameName 为空。"));
}
if (frame.DurationMs <= 0)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameDurationInvalid,
$"SourceFrame '{frame.FrameName}' 的 duration 必须大于 0。"));
}
if (frame.Rect.width <= 0 || frame.Rect.height <= 0 || frame.Rect.x < 0 || frame.Rect.y < 0 ||
frame.Rect.xMax > source.Texture.width || frame.Rect.yMax > source.Texture.height)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.FrameRectInvalid,
$"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。"));
}
if (frame.Trimmed)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TrimmedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 trimmed。"));
}
if (frame.Rotated)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.RotatedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了第一版不支持的 rotated。"));
}
}
if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureSizeMismatch,
$"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。"));
}
var textureFileName = Path.GetFileName(AssetDatabase.GetAssetPath(source.Texture));
if (!string.IsNullOrEmpty(document.ImageName) &&
!string.Equals(document.ImageName, textureFileName, StringComparison.Ordinal))
{
preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.ImageNameMismatch,
$"meta.image '{document.ImageName}' 与绑定 Texture '{textureFileName}' 不一致。"));
}
if (document.Tags.Count == 0)
{
preview.AddIssue(Warning(source, FrameAnimationImportIssueCode.NoTags,
"JSON 中没有 frameTags,此来源不会生成 Clip。"));
}
foreach (var duplicate in document.Tags.GroupBy(tag => tag.Name).Where(group => group.Count() > 1))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameDuplicate,
$"Tag '{duplicate.Key}' 在同一来源内重复。"));
}
foreach (var tag in document.Tags)
{
if (string.IsNullOrWhiteSpace(tag.Name))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TagNameInvalid, "Tag 名不能为空。"));
continue;
}
if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out _, out var issue))
{
preview.AddIssue(issue);
}
}
ValidateClipAssociations(graph, source, preview);
}
private static void ValidateClipAssociations(
FrameAnimationGraph graph,
FrameAnimationImportSource source,
FrameAnimationImportSourcePreview preview)
{
var graphPath = AssetDatabase.GetAssetPath(graph);
foreach (var clip in graph.Clips.Where(clip => clip != null && clip.IsImported))
{
if (!graph.ImportSources.Any(item => item != null && item.InternalId == clip.ImportInfo.ImportSourceId))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
$"Imported Clip '{clip.Id}' 指向不存在的 ImportSource。"));
}
if (!AssetDatabase.IsSubAsset(clip) || AssetDatabase.GetAssetPath(clip) != graphPath)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid,
$"Imported Clip '{clip.Id}' 必须是所属 Graph 的 sub-asset。"));
}
}
foreach (var duplicate in graph.Clips
.Where(clip => clip != null && clip.ImportInfo?.ImportSourceId == source.InternalId)
.GroupBy(clip => clip.ImportInfo.SourceTagName)
.Where(group => group.Count() > 1))
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
$"来源 Tag '{duplicate.Key}' 同时关联了多个 Imported Clip。"));
}
}
private static void ValidateCrossSourceTags(
FrameAnimationGraph graph,
IReadOnlyDictionary<FrameAnimationImportSource, AsepriteSourceDocument> documents,
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
{
var tags = documents.SelectMany(pair => pair.Value.Tags.Select(tag => (source: pair.Key, tag)));
foreach (var duplicate in tags.Where(item => !string.IsNullOrWhiteSpace(item.tag.Name))
.GroupBy(item => item.tag.Name).Where(group => group.Count() > 1))
{
foreach (var item in duplicate)
{
if (targetPreviews.TryGetValue(item.source, out var sourcePreview))
{
sourcePreview.AddIssue(Error(item.source, FrameAnimationImportIssueCode.TagConflict,
$"Tag '{duplicate.Key}' 与其他启用 ImportSource 重名。"));
}
}
}
foreach (var pair in targetPreviews)
{
if (!documents.TryGetValue(pair.Key, out var document))
{
continue;
}
foreach (var tag in document.Tags)
{
var existing = graph.Clips.Count(clip => clip != null &&
clip.ImportInfo?.ImportSourceId == pair.Key.InternalId &&
clip.ImportInfo.SourceTagName == tag.Name);
if (existing > 0)
{
continue;
}
if (graph.Clips.Any(clip => clip != null && clip.Id == tag.Name) ||
graph.Flows.Any(flow => flow != null && flow.Id == tag.Name))
{
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.PlayableIdConflict,
$"新 Tag '{tag.Name}' 与现有 Clip / Flow playable id 冲突。"));
}
}
}
}
private static void ValidateTextureOwnership(
FrameAnimationGraph currentGraph,
IReadOnlyDictionary<FrameAnimationImportSource, FrameAnimationImportSourcePreview> targetPreviews)
{
var owners = new List<(FrameAnimationGraph graph, FrameAnimationImportSource source)>();
foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph"))
{
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid));
if (graph == null)
{
continue;
}
owners.AddRange(graph.ImportSources.Where(source => source != null && source.IsEnabled &&
source.ManageSpriteSlicing && source.Texture != null).Select(source => (graph, source)));
}
if (!owners.Any(item => item.graph == currentGraph))
{
owners.AddRange(currentGraph.ImportSources.Where(source => source != null && source.IsEnabled &&
source.ManageSpriteSlicing && source.Texture != null).Select(source => (currentGraph, source)));
}
var standaloneOwners = AssetDatabase.FindAssets("t:FrameClip")
.Select(guid => AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid)))
.Where(clip => clip?.StandaloneImportSource != null &&
clip.StandaloneImportSource.ManageSpriteSlicing &&
clip.StandaloneImportSource.Texture != null)
.ToArray();
foreach (var pair in targetPreviews.Where(pair => pair.Key.ManageSpriteSlicing && pair.Key.Texture != null))
{
var conflicts = owners.Where(item => item.source.Texture == pair.Key.Texture &&
!(item.graph == currentGraph && ReferenceEquals(item.source, pair.Key))).ToArray();
if (conflicts.Length > 0)
{
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict,
$"Texture '{pair.Key.Texture.name}' 已被其他可写 ImportSource 占用:" +
string.Join(", ", conflicts.Select(item => $"{item.graph.name}/{item.source.DisplayName}"))));
}
var standaloneConflicts = standaloneOwners.Where(clip =>
clip.StandaloneImportSource.Texture == pair.Key.Texture).ToArray();
if (standaloneConflicts.Length > 0)
{
pair.Value.AddIssue(Error(pair.Key, FrameAnimationImportIssueCode.TextureOwnershipConflict,
$"Texture '{pair.Key.Texture.name}' 已被独立 Clip 来源占用:" +
string.Join(", ", standaloneConflicts.Select(clip => clip.name))));
}
}
}
private static void BuildClipDiffs(
FrameAnimationGraph graph,
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview)
{
var seenTags = new HashSet<string>();
foreach (var tag in document.Tags)
{
if (!AsepriteJsonParser.TryExpandTag(tag, document.Frames.Count, source.InternalId, out var indices, out _))
{
continue;
}
seenTags.Add(tag.Name);
var clip = graph.Clips.SingleOrDefault(item => item != null &&
item.ImportInfo?.ImportSourceId == source.InternalId &&
item.ImportInfo.SourceTagName == tag.Name);
if (clip == null)
{
preview.AddClipDiff(new FrameAnimationClipDiff(
FrameAnimationImportChangeKind.Added, tag.Name, null, indices, "创建 Imported Clip sub-asset"));
continue;
}
var changed = clip.ImportInfo.IsMissingFromSource ||
!FramesEqual(clip, document, preview.SpritePlan, indices);
preview.AddClipDiff(new FrameAnimationClipDiff(
changed ? FrameAnimationImportChangeKind.Updated : FrameAnimationImportChangeKind.Unchanged,
tag.Name,
clip,
indices,
changed ? "原地更新导入帧" : "无变化"));
}
foreach (var clip in graph.Clips.Where(item => item != null &&
item.ImportInfo?.ImportSourceId == source.InternalId &&
!seenTags.Contains(item.ImportInfo.SourceTagName)))
{
preview.AddClipDiff(new FrameAnimationClipDiff(
clip.ImportInfo.IsMissingFromSource
? FrameAnimationImportChangeKind.Unchanged
: FrameAnimationImportChangeKind.Missing,
clip.ImportInfo.SourceTagName,
clip,
Array.Empty<int>(),
clip.ImportInfo.IsMissingFromSource ? "保持 Missing" : "标记 Missing,不删除 Clip"));
}
}
private static bool FramesEqual(
FrameClip clip,
AsepriteSourceDocument document,
FrameAnimationSpritePlan spritePlan,
IReadOnlyList<int> indices)
{
if (clip.FrameCount != indices.Count)
{
return false;
}
for (var index = 0; index < indices.Count; index++)
{
var sourceIndex = indices[index];
var sourceFrame = document.Frames[sourceIndex];
var frame = clip.Frames[index];
if (frame == null || frame.SourceIndex != sourceIndex ||
frame.FrameName != sourceFrame.FrameName || frame.DurationMs != sourceFrame.DurationMs ||
spritePlan == null || !spritePlan.MatchesSprite(sourceIndex, frame.Sprite))
{
return false;
}
}
return true;
}
private static void ApplySource(
FrameAnimationGraph graph,
FrameAnimationImportSourcePreview preview)
{
var source = preview.Source;
var writableSprites = source.ManageSpriteSlicing
? FrameAnimationSpriteUtility.LoadSpritesByName(AssetDatabase.GetAssetPath(source.Texture))
: null;
foreach (var diff in preview.ClipDiffs)
{
if (diff.Kind == FrameAnimationImportChangeKind.Missing)
{
Undo.RecordObject(diff.Clip, "Mark Imported Clip Missing");
diff.Clip.SetImportMissingState(true);
EditorUtility.SetDirty(diff.Clip);
continue;
}
if (diff.Kind == FrameAnimationImportChangeKind.Unchanged && diff.Clip != null)
{
continue;
}
var frames = diff.SourceIndices.Select(sourceIndex =>
{
var sourceFrame = preview.Document.Frames[sourceIndex];
if (!preview.SpritePlan.TryResolveSprite(sourceIndex, writableSprites, out var sprite))
{
var expectedName = preview.SpritePlan.SpriteNamesBySourceIndex.TryGetValue(
sourceIndex, out var plannedName)
? plannedName
: sourceFrame.FrameName;
throw new InvalidOperationException(
$"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的共享 Sprite '{expectedName}'。 ");
}
return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex);
}).ToArray();
if (diff.Clip != null)
{
Undo.RecordObject(diff.Clip, "Update Imported Frame Clip");
diff.Clip.ReplaceImportedFrames(frames, source.InternalId, diff.SourceTagName);
EditorUtility.SetDirty(diff.Clip);
continue;
}
var clip = ScriptableObject.CreateInstance<FrameClip>();
clip.name = diff.SourceTagName;
clip.Configure(
diff.SourceTagName,
diff.SourceTagName,
frames,
1f,
source.DefaultNewClipEndBehavior,
new FrameClipImportInfo(source.InternalId, diff.SourceTagName, false));
AssetDatabase.AddObjectToAsset(clip, graph);
Undo.RegisterCreatedObjectUndo(clip, "Create Imported Frame Clip");
graph.AddImportedClip(clip);
EditorUtility.SetDirty(clip);
}
}
private static FrameAnimationImportIssue Error(
FrameAnimationImportSource source,
FrameAnimationImportIssueCode code,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
source?.InternalId,
source?.InternalId,
message);
}
private static FrameAnimationImportIssue Warning(
FrameAnimationImportSource source,
FrameAnimationImportIssueCode code,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Warning,
code,
source?.InternalId,
source?.InternalId,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ed2b95cf766a27d4cb93bbc8b2ba472e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,91 @@
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameAnimationPlayer))]
public sealed class FrameAnimationPlayerEditor : UnityEditor.Editor
{
private SerializedProperty graphProperty;
private SerializedProperty playOnEnableProperty;
private SerializedProperty speedProperty;
private void OnEnable()
{
graphProperty = serializedObject.FindProperty("graph");
playOnEnableProperty = serializedObject.FindProperty("playOnEnable");
speedProperty = serializedObject.FindProperty("speed");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(graphProperty);
EditorGUILayout.PropertyField(playOnEnableProperty);
EditorGUILayout.PropertyField(speedProperty);
DrawConfigurationWarnings();
serializedObject.ApplyModifiedProperties();
if (Application.isPlaying)
{
DrawRuntimeState();
}
}
private void DrawConfigurationWarnings()
{
var player = (FrameAnimationPlayer)target;
var spriteRenderer = player.GetComponent<SpriteRenderer>();
var image = player.GetComponent<Image>();
if (player.GetComponent<FrameClipPlayer>() != null)
{
EditorGUILayout.HelpBox(
"同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。",
MessageType.Error);
}
if (spriteRenderer == null && image == null)
{
EditorGUILayout.HelpBox(
"同一 GameObject 上必须存在一个 SpriteRenderer 或 Image。",
MessageType.Error);
}
else if (spriteRenderer != null && image != null)
{
EditorGUILayout.HelpBox(
"同一 GameObject 不能同时存在 SpriteRenderer 和 Image。",
MessageType.Error);
}
if (graphProperty.objectReferenceValue == null)
{
EditorGUILayout.HelpBox("尚未绑定 FrameAnimationGraph。", MessageType.Error);
}
if (!FrameAnimationValueUtility.IsValidSpeed(speedProperty.floatValue))
{
EditorGUILayout.HelpBox("Speed 必须是有限且不小于 0 的数值。", MessageType.Error);
}
}
private void DrawRuntimeState()
{
var player = (FrameAnimationPlayer)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.EnumPopup("State", player.State);
EditorGUILayout.TextField("Playable", player.CurrentPlayableId);
EditorGUILayout.TextField("Clip", player.CurrentClipId);
EditorGUILayout.TextField("Node", player.CurrentNodeId);
EditorGUILayout.IntField("Frame", player.CurrentFrameIndex);
}
Repaint();
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 26b26e9170b32ec4693ae2aa8ab88838
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,603 @@
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal enum FrameAnimationPreviewTargetKind { None, Clip, Node, Flow }
internal enum FrameAnimationPreviewState { Stopped, Playing, Paused, Failed }
internal enum NodePreviewPolicy { Static, SelectedOnly, AllVisible }
internal enum FrameAnimationPreviewBackground { Checkerboard, Dark, Light }
internal enum FrameAnimationPreviewZoomMode { Fit, One, Two, Three, Four, Eight, Manual }
internal enum FrameAnimationFlowElementState { None, Upcoming, Current, Passed }
internal readonly struct FrameAnimationPreviewSegment
{
public int StepIndex { get; }
public int FrameIndex { get; }
public FrameClip Clip { get; }
public string NodeId { get; }
public double StartSeconds { get; }
public double DurationSeconds { get; }
public bool IsBlocked { get; }
public FrameAnimationPreviewSegment(
int stepIndex,
int frameIndex,
ResolvedPlaybackStep step,
double startSeconds,
double durationSeconds,
bool isBlocked)
{
StepIndex = stepIndex;
FrameIndex = frameIndex;
Clip = step.Clip;
NodeId = step.NodeId;
StartSeconds = startSeconds;
DurationSeconds = durationSeconds;
IsBlocked = isBlocked;
}
}
internal sealed class FrameAnimationPreviewTimeline
{
private const double Epsilon = 0.000000001d;
private readonly List<FrameAnimationPreviewSegment> segments = new List<FrameAnimationPreviewSegment>();
public IReadOnlyList<FrameAnimationPreviewSegment> Segments => segments;
public double DisplayDurationSeconds { get; }
public bool IsInfinite { get; }
public bool IsBlocked { get; }
public int BlockedSegmentIndex { get; } = -1;
public FrameAnimationPreviewTimeline(ResolvedPlaybackPlan plan)
{
if (plan?.Steps == null || plan.Steps.Count == 0)
{
return;
}
var cursor = 0d;
var blockedIndex = -1;
for (var stepIndex = 0; stepIndex < plan.Steps.Count; stepIndex++)
{
var step = plan.Steps[stepIndex];
if (step?.Clip == null)
{
continue;
}
for (var frameIndex = 0; frameIndex < step.Clip.FrameCount; frameIndex++)
{
var frame = step.Clip.Frames[frameIndex];
if (step.PlaybackSpeed == 0f)
{
blockedIndex = segments.Count;
segments.Add(new FrameAnimationPreviewSegment(
stepIndex, frameIndex, step, cursor, 0d, true));
break;
}
var duration = frame.DurationMs / 1000d / step.PlaybackSpeed;
segments.Add(new FrameAnimationPreviewSegment(
stepIndex, frameIndex, step, cursor, duration, false));
cursor += duration;
}
if (blockedIndex >= 0)
{
break;
}
}
DisplayDurationSeconds = cursor;
BlockedSegmentIndex = blockedIndex;
IsBlocked = blockedIndex >= 0;
IsInfinite = IsBlocked || plan.Steps[plan.Steps.Count - 1].TerminalEndBehavior == FrameClipEndBehavior.Loop;
}
public int FindSegmentIndex(double positionSeconds)
{
if (segments.Count == 0)
{
return -1;
}
var position = Math.Max(0d, positionSeconds);
for (var index = 0; index < segments.Count; index++)
{
var segment = segments[index];
if (segment.IsBlocked || position < segment.StartSeconds + segment.DurationSeconds - Epsilon)
{
return index;
}
}
return segments.Count - 1;
}
public double GetSeekSeconds(int segmentIndex)
{
return segmentIndex >= 0 && segmentIndex < segments.Count
? segments[segmentIndex].StartSeconds
: 0d;
}
public double GetPosition(FrameAnimationPlaybackSnapshot snapshot)
{
if (!snapshot.HasStarted || segments.Count == 0)
{
return 0d;
}
var segment = segments.FirstOrDefault(item =>
item.StepIndex == snapshot.StepIndex && item.FrameIndex == snapshot.FrameIndex);
if (segment.Clip == null)
{
return snapshot.IsCompleted ? DisplayDurationSeconds : 0d;
}
if (snapshot.IsCompleted && !IsInfinite)
{
return DisplayDurationSeconds;
}
var elapsed = snapshot.StepSpeed > 0f
? snapshot.FrameElapsedSeconds / snapshot.StepSpeed
: 0d;
return Math.Min(DisplayDurationSeconds, segment.StartSeconds + elapsed);
}
}
internal readonly struct FrameAnimationPreviewDisplay
{
public Sprite Sprite { get; }
public bool IsEmpty { get; }
public bool IsActive { get; }
public FrameAnimationFlowElementState FlowState { get; }
public FrameAnimationPreviewDisplay(
Sprite sprite,
bool isEmpty,
bool isActive,
FrameAnimationFlowElementState flowState)
{
Sprite = sprite;
IsEmpty = isEmpty;
IsActive = isActive;
FlowState = flowState;
}
}
internal sealed class FrameAnimationPreviewCoordinator
{
private sealed class NodeRun
{
public AnimationNode Node;
public FrameAnimationPlaybackSession Session;
public Sprite Sprite;
}
private readonly Dictionary<string, NodeRun> automaticRuns = new Dictionary<string, NodeRun>();
private FrameAnimationGraph graph;
private object target;
private ResolvedPlaybackPlan plan;
private FrameAnimationPlaybackSession session;
private Sprite currentSprite;
private bool terminalTransparent;
private bool suppressSelectedNode;
private bool flowPresentationActive;
public event Action Changed;
public FrameAnimationPreviewTargetKind TargetKind { get; private set; }
public FrameAnimationPreviewState State { get; private set; } = FrameAnimationPreviewState.Stopped;
public NodePreviewPolicy NodePolicy { get; set; } = NodePreviewPolicy.SelectedOnly;
public FrameAnimationPreviewBackground Background { get; set; } = FrameAnimationPreviewBackground.Checkerboard;
public FrameAnimationPreviewZoomMode ZoomMode { get; set; } = FrameAnimationPreviewZoomMode.Fit;
public float ManualZoom { get; set; } = 1f;
public float PreviewSpeed { get; set; } = 1f;
public FrameAnimationPreviewTimeline Timeline { get; private set; }
public FrameAnimationPlaybackError Error { get; private set; } = FrameAnimationPlaybackError.None;
public double PositionSeconds { get; private set; }
public string TransitionFromNodeId { get; private set; } = string.Empty;
public string TransitionToNodeId { get; private set; } = string.Empty;
public object Target => target;
public bool IsFlowPreview => TargetKind == FrameAnimationPreviewTargetKind.Flow &&
flowPresentationActive && State != FrameAnimationPreviewState.Failed;
public FrameAnimationPlaybackSnapshot Snapshot => session != null
? session.Snapshot
: default;
public Sprite CurrentSprite => terminalTransparent ? null : currentSprite;
public void SetGraph(FrameAnimationGraph value)
{
if (ReferenceEquals(graph, value))
{
return;
}
graph = value;
ClearTarget();
automaticRuns.Clear();
}
public void SetTarget(FrameAnimationPreviewTargetKind kind, object value)
{
if (TargetKind == kind && ReferenceEquals(target, value))
{
return;
}
TargetKind = kind;
target = value;
suppressSelectedNode = false;
BuildTarget(false, 0d);
if (kind == FrameAnimationPreviewTargetKind.Node && NodePolicy != NodePreviewPolicy.Static && session != null)
{
State = FrameAnimationPreviewState.Playing;
}
Changed?.Invoke();
}
public void ClearTarget()
{
TargetKind = FrameAnimationPreviewTargetKind.None;
target = null;
plan = null;
session = null;
Timeline = null;
currentSprite = null;
terminalTransparent = false;
Error = FrameAnimationPlaybackError.None;
PositionSeconds = 0d;
State = FrameAnimationPreviewState.Stopped;
flowPresentationActive = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
Changed?.Invoke();
}
public void RefreshTarget(bool topologyChanged)
{
if (TargetKind == FrameAnimationPreviewTargetKind.None)
{
return;
}
if (topologyChanged && TargetKind == FrameAnimationPreviewTargetKind.Flow)
{
Stop();
BuildTarget(false, 0d);
return;
}
var wasPlaying = State == FrameAnimationPreviewState.Playing;
var position = PositionSeconds;
BuildTarget(position > 0d, position);
if (wasPlaying && session != null)
{
State = FrameAnimationPreviewState.Playing;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
}
Changed?.Invoke();
}
public void SetNodePolicy(NodePreviewPolicy value)
{
NodePolicy = value;
automaticRuns.Clear();
suppressSelectedNode = false;
if (TargetKind == FrameAnimationPreviewTargetKind.Node)
{
BuildTarget(false, 0d);
if (session != null && value != NodePreviewPolicy.Static)
{
State = FrameAnimationPreviewState.Playing;
}
}
Changed?.Invoke();
}
public void Play()
{
if (session == null)
{
BuildTarget(false, 0d);
}
if (session == null)
{
return;
}
if (session.Snapshot.IsCompleted)
{
session.Restart(ApplySprite);
PositionSeconds = 0d;
}
terminalTransparent = false;
suppressSelectedNode = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
State = FrameAnimationPreviewState.Playing;
Changed?.Invoke();
}
public void Pause()
{
if (session == null || State != FrameAnimationPreviewState.Playing) return;
State = FrameAnimationPreviewState.Paused;
Changed?.Invoke();
}
public void Stop()
{
if (session != null)
{
session.Restart(ApplySprite);
}
State = FrameAnimationPreviewState.Stopped;
PositionSeconds = 0d;
terminalTransparent = false;
suppressSelectedNode = TargetKind == FrameAnimationPreviewTargetKind.Node;
flowPresentationActive = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
Changed?.Invoke();
}
public void Restart()
{
if (session == null)
{
BuildTarget(false, 0d);
}
session?.Restart(ApplySprite);
PositionSeconds = 0d;
terminalTransparent = false;
suppressSelectedNode = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
if (session != null) State = FrameAnimationPreviewState.Playing;
Changed?.Invoke();
}
public void Step(int direction)
{
if (session == null || Timeline == null || Timeline.Segments.Count == 0) return;
var current = Timeline.FindSegmentIndex(PositionSeconds);
var next = Mathf.Clamp(current + Math.Sign(direction), 0, Timeline.Segments.Count - 1);
Seek(Timeline.GetSeekSeconds(next));
}
public void Seek(double seconds)
{
if (session == null || Timeline == null) return;
var maximum = Timeline.DisplayDurationSeconds;
var targetSeconds = Math.Max(0d, Math.Min(maximum, seconds));
if (Timeline.IsInfinite && maximum > 0d && targetSeconds >= maximum)
{
targetSeconds = Math.Max(0d, maximum - 0.000001d);
}
terminalTransparent = false;
flowPresentationActive = TargetKind == FrameAnimationPreviewTargetKind.Flow;
var result = session.Seek(targetSeconds, ApplySprite);
ApplyEvaluation(result);
PositionSeconds = targetSeconds;
if (State != FrameAnimationPreviewState.Failed)
{
State = FrameAnimationPreviewState.Paused;
}
Changed?.Invoke();
}
public bool Tick(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
{
var changed = false;
TransitionFromNodeId = string.Empty;
TransitionToNodeId = string.Empty;
if (State == FrameAnimationPreviewState.Playing && session != null)
{
var before = session.Snapshot;
var result = session.Evaluate(deltaSeconds, PreviewSpeed, ApplySprite);
var after = session.Snapshot;
if (before.StepIndex != after.StepIndex)
{
TransitionFromNodeId = before.NodeId;
TransitionToNodeId = after.NodeId;
}
ApplyEvaluation(result);
PositionSeconds = Timeline?.GetPosition(after) ?? 0d;
if (result.IsCompleted && TargetKind == FrameAnimationPreviewTargetKind.Node &&
NodePolicy != NodePreviewPolicy.Static && !suppressSelectedNode && session != null)
{
session.Restart(ApplySprite);
State = FrameAnimationPreviewState.Playing;
PositionSeconds = 0d;
terminalTransparent = false;
}
changed = true;
}
if (!IsFlowPreview)
{
changed |= TickAutomaticNodes(deltaSeconds, visibleNodes);
}
else if (automaticRuns.Count > 0)
{
automaticRuns.Clear();
changed = true;
}
if (changed) Changed?.Invoke();
return changed;
}
public FrameAnimationPreviewDisplay GetNodeDisplay(AnimationNode node)
{
if (node == null)
{
return default;
}
if (IsFlowPreview && plan != null)
{
var current = session?.Snapshot.StepIndex ?? 0;
var stepIndex = IndexOfNode(plan, node.InternalId);
var flowState = stepIndex < 0 ? FrameAnimationFlowElementState.None :
stepIndex < current ? FrameAnimationFlowElementState.Passed :
stepIndex == current ? FrameAnimationFlowElementState.Current : FrameAnimationFlowElementState.Upcoming;
if (flowState == FrameAnimationFlowElementState.Current)
{
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, true, flowState);
}
return Representative(node, flowState);
}
if (TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node) && session != null)
{
if (NodePolicy == NodePreviewPolicy.Static && State == FrameAnimationPreviewState.Stopped &&
!suppressSelectedNode && !session.Snapshot.IsCompleted)
{
return Representative(node, FrameAnimationFlowElementState.None);
}
return new FrameAnimationPreviewDisplay(CurrentSprite, CurrentSprite == null, State == FrameAnimationPreviewState.Playing,
FrameAnimationFlowElementState.None);
}
if (automaticRuns.TryGetValue(node.InternalId, out var run))
{
return new FrameAnimationPreviewDisplay(run.Sprite, run.Sprite == null, true, FrameAnimationFlowElementState.None);
}
return Representative(node, FrameAnimationFlowElementState.None);
}
public FrameAnimationFlowElementState GetEdgeState(AnimationEdge edge)
{
if (!IsFlowPreview || plan == null || edge == null) return FrameAnimationFlowElementState.None;
if (edge.FromNodeId == TransitionFromNodeId && edge.ToNodeId == TransitionToNodeId)
{
return FrameAnimationFlowElementState.Current;
}
var from = IndexOfNode(plan, edge.FromNodeId);
var to = IndexOfNode(plan, edge.ToNodeId);
if (from < 0 || to != from + 1) return FrameAnimationFlowElementState.None;
var current = session?.Snapshot.StepIndex ?? 0;
return to <= current ? FrameAnimationFlowElementState.Passed : FrameAnimationFlowElementState.Upcoming;
}
private bool TickAutomaticNodes(double deltaSeconds, IReadOnlyCollection<AnimationNode> visibleNodes)
{
IEnumerable<AnimationNode> desired = Array.Empty<AnimationNode>();
if (NodePolicy == NodePreviewPolicy.SelectedOnly && TargetKind == FrameAnimationPreviewTargetKind.Node &&
target is AnimationNode selected && !suppressSelectedNode)
{
desired = new[] { selected };
}
else if (NodePolicy == NodePreviewPolicy.AllVisible)
{
desired = visibleNodes ?? Array.Empty<AnimationNode>();
}
var desiredNodes = desired.Where(node => node != null &&
!(TargetKind == FrameAnimationPreviewTargetKind.Node && ReferenceEquals(target, node)))
.GroupBy(node => node.InternalId).Select(group => group.First()).ToArray();
var desiredIds = new HashSet<string>(desiredNodes.Select(node => node.InternalId));
foreach (var id in automaticRuns.Keys.Where(id => !desiredIds.Contains(id)).ToArray())
{
automaticRuns.Remove(id);
}
foreach (var node in desiredNodes)
{
if (!automaticRuns.TryGetValue(node.InternalId, out var run))
{
if (!FrameAnimationResolver.TryResolveNode(graph, node, out var nodePlan, out _)) continue;
run = new NodeRun { Node = node, Session = new FrameAnimationPlaybackSession(nodePlan) };
run.Session.Start(sprite => run.Sprite = sprite);
automaticRuns[node.InternalId] = run;
}
var evaluation = run.Session.Evaluate(deltaSeconds, PreviewSpeed, sprite => run.Sprite = sprite);
if (evaluation.IsCompleted)
{
run.Session.Restart(sprite => run.Sprite = sprite);
}
else if (evaluation.IsFailed)
{
automaticRuns.Remove(node.InternalId);
}
}
return desiredNodes.Length > 0 || automaticRuns.Count > 0;
}
private void BuildTarget(bool seek, double position)
{
plan = null;
session = null;
Timeline = null;
currentSprite = null;
terminalTransparent = false;
PositionSeconds = 0d;
Error = FrameAnimationPlaybackError.None;
State = FrameAnimationPreviewState.Stopped;
flowPresentationActive = false;
var resolved = TargetKind switch
{
FrameAnimationPreviewTargetKind.Clip when target is FrameClip clip =>
TryResolvePreviewClip(clip, out plan, out var clipError)
? FrameAnimationPlaybackError.None : clipError,
FrameAnimationPreviewTargetKind.Node when target is AnimationNode node =>
FrameAnimationResolver.TryResolveNode(graph, node, out plan, out var nodeError)
? FrameAnimationPlaybackError.None : nodeError,
FrameAnimationPreviewTargetKind.Flow when target is AnimationFlow flow =>
FrameAnimationResolver.TryResolve(graph, flow.Id, default, out plan, out var flowError)
? FrameAnimationPlaybackError.None : flowError,
_ => new FrameAnimationPlaybackError(FrameAnimationPlaybackErrorCode.PlayableNotFound, "没有可预览目标。")
};
if (resolved.Code != FrameAnimationPlaybackErrorCode.None || plan == null)
{
Error = resolved;
State = TargetKind == FrameAnimationPreviewTargetKind.None
? FrameAnimationPreviewState.Stopped : FrameAnimationPreviewState.Failed;
return;
}
Timeline = new FrameAnimationPreviewTimeline(plan);
session = new FrameAnimationPlaybackSession(plan);
session.Start(ApplySprite);
if (seek && position > 0d)
{
Seek(position);
}
}
private bool TryResolvePreviewClip(
FrameClip clip,
out ResolvedPlaybackPlan resolvedPlan,
out FrameAnimationPlaybackError error)
{
return graph != null
? FrameAnimationResolver.TryResolve(graph, clip.Id, default, out resolvedPlan, out error)
: FrameAnimationResolver.TryResolveClip(clip, default, out resolvedPlan, out error);
}
private void ApplyEvaluation(FrameAnimationSessionEvaluation evaluation)
{
if (evaluation.IsFailed)
{
Error = evaluation.Error;
State = FrameAnimationPreviewState.Failed;
flowPresentationActive = false;
return;
}
if (!evaluation.IsCompleted) return;
State = FrameAnimationPreviewState.Stopped;
terminalTransparent = evaluation.EndBehavior == FrameClipEndBehavior.Clear ||
evaluation.EndBehavior == FrameClipEndBehavior.HideTarget;
}
private void ApplySprite(Sprite sprite)
{
currentSprite = sprite;
terminalTransparent = false;
}
private FrameAnimationPreviewDisplay Representative(AnimationNode node, FrameAnimationFlowElementState flowState)
{
var clip = graph?.Clips.FirstOrDefault(item => item != null && item.Id == node.ClipId);
var sprite = clip?.Frames.FirstOrDefault(frame => frame?.Sprite != null)?.Sprite;
return new FrameAnimationPreviewDisplay(sprite, sprite == null, false, flowState);
}
private static int IndexOfNode(ResolvedPlaybackPlan targetPlan, string nodeId)
{
if (targetPlan?.Steps == null) return -1;
for (var index = 0; index < targetPlan.Steps.Count; index++)
{
if (targetPlan.Steps[index].NodeId == nodeId) return index;
}
return -1;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14a91ced504e4af4ad79f4f760794c3c
@@ -0,0 +1,163 @@
using UnityEngine;
using UnityEngine.UIElements;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameAnimationPreviewElement : VisualElement
{
private static Texture2D checkerTexture;
private readonly Image image;
private readonly Label emptyLabel;
private Sprite sprite;
private FrameAnimationPreviewZoomMode zoomMode = FrameAnimationPreviewZoomMode.Fit;
private float manualZoom = 1f;
private Vector2 pan;
private Vector2 dragStart;
private Vector2 panStart;
private bool dragging;
public FrameAnimationPreviewElement()
{
AddToClassList("fa-preview");
focusable = true;
image = new Image { scaleMode = ScaleMode.ScaleToFit, pickingMode = PickingMode.Ignore };
image.AddToClassList("fa-preview__image");
emptyLabel = new Label("Empty Frame") { pickingMode = PickingMode.Ignore };
emptyLabel.AddToClassList("fa-preview__empty");
Add(image);
Add(emptyLabel);
RegisterCallback<GeometryChangedEvent>(_ => UpdateImageLayout());
RegisterCallback<PointerDownEvent>(OnPointerDown);
RegisterCallback<PointerMoveEvent>(OnPointerMove);
RegisterCallback<PointerUpEvent>(OnPointerUp);
SetBackground(FrameAnimationPreviewBackground.Checkerboard);
}
public void SetDisplay(Sprite value, bool isEmpty, string emptyText = "Empty Frame")
{
sprite = value;
image.sprite = value;
image.style.display = value != null ? DisplayStyle.Flex : DisplayStyle.None;
emptyLabel.text = string.IsNullOrEmpty(emptyText) ? "Empty Frame" : emptyText;
emptyLabel.style.display = value == null || isEmpty ? DisplayStyle.Flex : DisplayStyle.None;
UpdateImageLayout();
}
public void SetBackground(FrameAnimationPreviewBackground background)
{
switch (background)
{
case FrameAnimationPreviewBackground.Dark:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.08f, 0.08f, 0.08f));
break;
case FrameAnimationPreviewBackground.Light:
style.backgroundImage = StyleKeyword.None;
style.backgroundColor = new StyleColor(new Color(0.72f, 0.72f, 0.72f));
break;
default:
style.backgroundImage = new StyleBackground(GetCheckerTexture());
style.backgroundColor = StyleKeyword.Null;
break;
}
}
public void SetZoom(FrameAnimationPreviewZoomMode mode, float value)
{
zoomMode = mode;
manualZoom = Mathf.Clamp(value, 0.25f, 8f);
if (mode == FrameAnimationPreviewZoomMode.Fit)
{
pan = Vector2.zero;
}
UpdateImageLayout();
}
private void UpdateImageLayout()
{
if (sprite == null || resolvedStyle.width <= 0f || resolvedStyle.height <= 0f)
{
return;
}
var sourceSize = sprite.rect.size;
if (sourceSize.x <= 0f || sourceSize.y <= 0f)
{
return;
}
var scale = zoomMode == FrameAnimationPreviewZoomMode.Fit
? Mathf.Min(resolvedStyle.width / sourceSize.x, resolvedStyle.height / sourceSize.y)
: GetScale();
var size = sourceSize * Mathf.Max(0.0001f, scale);
image.style.width = size.x;
image.style.height = size.y;
image.style.left = (resolvedStyle.width - size.x) * 0.5f + pan.x;
image.style.top = (resolvedStyle.height - size.y) * 0.5f + pan.y;
}
private float GetScale()
{
return zoomMode switch
{
FrameAnimationPreviewZoomMode.One => 1f,
FrameAnimationPreviewZoomMode.Two => 2f,
FrameAnimationPreviewZoomMode.Three => 3f,
FrameAnimationPreviewZoomMode.Four => 4f,
FrameAnimationPreviewZoomMode.Eight => 8f,
FrameAnimationPreviewZoomMode.Manual => manualZoom,
_ => 1f
};
}
private void OnPointerDown(PointerDownEvent evt)
{
if (zoomMode == FrameAnimationPreviewZoomMode.Fit || evt.button != 0) return;
dragging = true;
dragStart = new Vector2(evt.position.x, evt.position.y);
panStart = pan;
this.CapturePointer(evt.pointerId);
evt.StopPropagation();
}
private void OnPointerMove(PointerMoveEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
var position = new Vector2(evt.position.x, evt.position.y);
pan = panStart + position - dragStart;
UpdateImageLayout();
evt.StopPropagation();
}
private void OnPointerUp(PointerUpEvent evt)
{
if (!dragging || !this.HasPointerCapture(evt.pointerId)) return;
dragging = false;
this.ReleasePointer(evt.pointerId);
evt.StopPropagation();
}
private static Texture2D GetCheckerTexture()
{
if (checkerTexture != null) return checkerTexture;
const int textureSize = 128;
const int cellSize = 8;
checkerTexture = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false)
{
name = "Frame Animation Preview Checker",
hideFlags = HideFlags.HideAndDontSave,
wrapMode = TextureWrapMode.Repeat,
filterMode = FilterMode.Point
};
var light = new Color32(106, 106, 106, 255);
var dark = new Color32(78, 78, 78, 255);
var pixels = new Color32[textureSize * textureSize];
for (var y = 0; y < textureSize; y++)
for (var x = 0; x < textureSize; x++)
{
pixels[y * textureSize + x] = ((x / cellSize) + (y / cellSize)) % 2 == 0 ? light : dark;
}
checkerTexture.SetPixels32(pixels);
checkerTexture.Apply(false, true);
return checkerTexture;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 54aef216ceea42ed8917b581606ecf3d
@@ -0,0 +1,529 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using UnityEditor;
using UnityEditor.U2D.Sprites;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal readonly struct FrameAnimationSpriteSourceContext
{
public string SourceId { get; }
public Texture2D Texture { get; }
public TextAsset AsepriteJson { get; }
public Vector2 Pivot { get; }
public bool ManageSpriteSlicing { get; }
public FrameAnimationSpriteSourceContext(
string sourceId,
Texture2D texture,
TextAsset asepriteJson,
Vector2 pivot,
bool manageSpriteSlicing)
{
SourceId = sourceId ?? string.Empty;
Texture = texture;
AsepriteJson = asepriteJson;
Pivot = pivot;
ManageSpriteSlicing = manageSpriteSlicing;
}
public static FrameAnimationSpriteSourceContext From(FrameAnimationImportSource source)
{
return new FrameAnimationSpriteSourceContext(
source?.InternalId, source?.Texture, source?.AsepriteJson,
source?.Pivot ?? new Vector2(0.5f, 0.5f), source?.ManageSpriteSlicing == true);
}
}
internal sealed class FrameAnimationSpritePlan
{
public string TexturePath { get; }
public IReadOnlyList<SpriteRect> ExistingRects { get; }
public IReadOnlyList<SpriteRect> PlannedRects { get; }
public IReadOnlyDictionary<int, string> SpriteNamesBySourceIndex { get; }
public IReadOnlyDictionary<int, Sprite> ReadOnlySpritesBySourceIndex { get; }
public FrameAnimationSpritePlan(
string texturePath,
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyList<SpriteRect> plannedRects,
IReadOnlyDictionary<int, string> spriteNamesBySourceIndex,
IReadOnlyDictionary<int, Sprite> readOnlySpritesBySourceIndex)
{
TexturePath = texturePath ?? string.Empty;
ExistingRects = existingRects ?? Array.Empty<SpriteRect>();
PlannedRects = plannedRects ?? Array.Empty<SpriteRect>();
SpriteNamesBySourceIndex = spriteNamesBySourceIndex ?? new Dictionary<int, string>();
ReadOnlySpritesBySourceIndex = readOnlySpritesBySourceIndex ?? new Dictionary<int, Sprite>();
}
public bool MatchesSprite(int sourceIndex, Sprite sprite)
{
if (sprite == null)
{
return false;
}
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out var readOnlySprite))
{
return readOnlySprite == sprite;
}
return SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
sprite.name == spriteName;
}
public bool TryResolveSprite(
int sourceIndex,
IReadOnlyDictionary<string, Sprite> writableSprites,
out Sprite sprite)
{
if (ReadOnlySpritesBySourceIndex.TryGetValue(sourceIndex, out sprite))
{
return sprite != null;
}
if (SpriteNamesBySourceIndex.TryGetValue(sourceIndex, out var spriteName) &&
writableSprites != null && writableSprites.TryGetValue(spriteName, out sprite))
{
return sprite != null;
}
sprite = null;
return false;
}
}
internal sealed class FrameAnimationTextureSnapshot
{
public string TexturePath { get; }
public TextureImporterType TextureType { get; }
public SpriteImportMode SpriteImportMode { get; }
public IReadOnlyList<SpriteRect> SpriteRects { get; }
public FrameAnimationTextureSnapshot(
string texturePath,
TextureImporterType textureType,
SpriteImportMode spriteImportMode,
IReadOnlyList<SpriteRect> spriteRects)
{
TexturePath = texturePath;
TextureType = textureType;
SpriteImportMode = spriteImportMode;
SpriteRects = spriteRects;
}
}
internal static class FrameAnimationSpriteUtility
{
public static FrameAnimationSpritePlan BuildPlan(
FrameAnimationImportSource source,
AsepriteSourceDocument document,
FrameAnimationImportSourcePreview preview)
{
return BuildPlan(FrameAnimationSpriteSourceContext.From(source), document, preview);
}
public static FrameAnimationSpritePlan BuildPlan(
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
IFrameAnimationImportPreviewSink preview,
IReadOnlyCollection<int> includedSourceIndices = null)
{
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
var importer = AssetImporter.GetAtPath(texturePath) as TextureImporter;
if (importer == null)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.TextureImporterInvalid,
$"无法读取 TextureImporter{texturePath}"));
return new FrameAnimationSpritePlan(texturePath, null, null, null, null);
}
var existingRects = source.ManageSpriteSlicing && importer.spriteImportMode != SpriteImportMode.Multiple
? Array.Empty<SpriteRect>()
: ReadSpriteRects(importer);
return source.ManageSpriteSlicing
? BuildWritablePlan(source, document, preview, texturePath, existingRects, includedSourceIndices)
: BuildReadOnlyPlan(source, document, preview, texturePath, existingRects, includedSourceIndices);
}
public static Rect ToUnityRect(RectInt asepriteRect, int textureHeight)
{
return new Rect(
asepriteRect.x,
textureHeight - asepriteRect.y - asepriteRect.height,
asepriteRect.width,
asepriteRect.height);
}
public static FrameAnimationTextureSnapshot CaptureSnapshot(FrameAnimationSpritePlan plan)
{
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
if (importer == null)
{
throw new InvalidOperationException($"无法读取 TextureImporter{plan.TexturePath}");
}
return new FrameAnimationTextureSnapshot(
plan.TexturePath,
importer.textureType,
importer.spriteImportMode,
CloneRects(ReadSpriteRects(importer)));
}
public static void ApplyPlan(FrameAnimationSpritePlan plan)
{
var importer = AssetImporter.GetAtPath(plan.TexturePath) as TextureImporter;
if (importer == null)
{
throw new InvalidOperationException($"无法读取 TextureImporter{plan.TexturePath}");
}
Undo.RecordObject(importer, "Refresh Frame Animation Sprite Slicing");
importer.textureType = TextureImporterType.Sprite;
importer.spriteImportMode = SpriteImportMode.Multiple;
WriteSpriteRects(importer, plan.PlannedRects);
importer.SaveAndReimport();
}
public static void RestoreSnapshot(FrameAnimationTextureSnapshot snapshot)
{
var importer = AssetImporter.GetAtPath(snapshot.TexturePath) as TextureImporter;
if (importer == null)
{
return;
}
importer.textureType = snapshot.TextureType;
importer.spriteImportMode = snapshot.SpriteImportMode;
WriteSpriteRects(importer, snapshot.SpriteRects);
importer.SaveAndReimport();
}
public static IReadOnlyDictionary<string, Sprite> LoadSpritesByName(string texturePath)
{
return AssetDatabase.LoadAllAssetsAtPath(texturePath)
.OfType<Sprite>()
.GroupBy(sprite => sprite.name)
.Where(group => group.Count() == 1)
.ToDictionary(group => group.Key, group => group.Single());
}
public static string ComputeSourceHash(
FrameAnimationImportSource source,
IReadOnlyList<SpriteRect> spriteRects = null)
{
return ComputeSourceHash(FrameAnimationSpriteSourceContext.From(source), spriteRects);
}
public static string ComputeSourceHash(
FrameAnimationSpriteSourceContext source,
IReadOnlyList<SpriteRect> spriteRects = null)
{
if (source.Texture == null || source.AsepriteJson == null)
{
return string.Empty;
}
var texturePath = AssetDatabase.GetAssetPath(source.Texture);
var builder = new StringBuilder();
builder.AppendLine("FrameAnimationImportHash:v2");
builder.AppendLine(source.AsepriteJson.text ?? string.Empty);
builder.AppendLine(AssetDatabase.AssetPathToGUID(texturePath));
builder.AppendLine(AssetDatabase.GetAssetDependencyHash(texturePath).ToString());
builder.AppendLine(source.Pivot.x.ToString("R", CultureInfo.InvariantCulture));
builder.AppendLine(source.Pivot.y.ToString("R", CultureInfo.InvariantCulture));
builder.AppendLine(source.ManageSpriteSlicing ? "write" : "read");
var rects = spriteRects;
if (rects == null && AssetImporter.GetAtPath(texturePath) is TextureImporter importer)
{
rects = ReadSpriteRects(importer);
}
foreach (var rect in (rects ?? Array.Empty<SpriteRect>()).OrderBy(item => item.name))
{
builder.Append(rect.name).Append('|')
.Append(rect.spriteID).Append('|')
.Append(rect.rect.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
.Append(rect.rect.y.ToString("R", CultureInfo.InvariantCulture)).Append(',')
.Append(rect.rect.width.ToString("R", CultureInfo.InvariantCulture)).Append(',')
.Append(rect.rect.height.ToString("R", CultureInfo.InvariantCulture)).Append('|')
.Append(rect.pivot.x.ToString("R", CultureInfo.InvariantCulture)).Append(',')
.Append(rect.pivot.y.ToString("R", CultureInfo.InvariantCulture)).AppendLine();
}
using var sha = SHA256.Create();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(builder.ToString()));
return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLowerInvariant();
}
private static FrameAnimationSpritePlan BuildWritablePlan(
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
IFrameAnimationImportPreviewSink preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyCollection<int> includedSourceIndices)
{
var planned = CloneRects(existingRects).ToList();
var originalRects = planned.ToArray();
var assigned = new HashSet<SpriteRect>();
var spriteNamesBySourceIndex = new Dictionary<int, string>();
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
{
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
SpriteRect matched = null;
var slotHasError = false;
foreach (var aliasName in slot.FrameNames)
{
var nameMatches = planned.Where(rect => rect.name == aliasName).ToArray();
if (nameMatches.Length > 1)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
$"Texture 中存在多个名为 '{aliasName}' 的 SpriteRect。"));
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Error,
slot.CanonicalFrameName,
unityRect,
"SpriteRect 名称重复",
slot.FrameNames));
slotHasError = true;
break;
}
if (nameMatches.Length == 1 && !assigned.Contains(nameMatches[0]))
{
matched = nameMatches[0];
break;
}
}
if (slotHasError)
{
continue;
}
if (matched == null)
{
var rectMatches = planned.Where(rect => !assigned.Contains(rect) && RectEquals(rect.rect, unityRect))
.ToArray();
if (rectMatches.Length > 1)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
$"共享帧 '{slot.CanonicalFrameName}' 的 rect 对应多个现有 SpriteRect。"));
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Error,
slot.CanonicalFrameName,
unityRect,
"SpriteRect rect 匹配不唯一",
slot.FrameNames));
continue;
}
matched = rectMatches.SingleOrDefault();
}
if (matched == null)
{
matched = new SpriteRect
{
name = slot.CanonicalFrameName,
rect = unityRect,
pivot = source.Pivot,
alignment = SpriteAlignment.Custom,
spriteID = GUID.Generate()
};
planned.Add(matched);
assigned.Add(matched);
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Added,
matched.name,
unityRect,
BuildSlotSummary("新增 SpriteRect", slot),
slot.FrameNames));
continue;
}
var changed = !RectEquals(matched.rect, unityRect) || matched.pivot != source.Pivot ||
matched.alignment != SpriteAlignment.Custom;
matched.rect = unityRect;
matched.pivot = source.Pivot;
matched.alignment = SpriteAlignment.Custom;
assigned.Add(matched);
MapSlot(slot, matched.name, spriteNamesBySourceIndex);
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
changed ? FrameAnimationSpriteChangeKind.Updated : FrameAnimationSpriteChangeKind.Unchanged,
matched.name,
unityRect,
BuildSlotSummary(changed ? "更新 rect/pivot,保留 spriteID" : "无变化", slot),
slot.FrameNames));
}
foreach (var retained in originalRects.Where(rect => !assigned.Contains(rect)))
{
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Retained,
retained.name,
retained.rect,
"源中已消失或不再作为共享帧主 Sprite,保留现有 SpriteRect"));
}
return new FrameAnimationSpritePlan(
texturePath,
existingRects,
planned,
spriteNamesBySourceIndex,
null);
}
private static FrameAnimationSpritePlan BuildReadOnlyPlan(
FrameAnimationSpriteSourceContext source,
AsepriteSourceDocument document,
IFrameAnimationImportPreviewSink preview,
string texturePath,
IReadOnlyList<SpriteRect> existingRects,
IReadOnlyCollection<int> includedSourceIndices)
{
var sprites = AssetDatabase.LoadAllAssetsAtPath(texturePath).OfType<Sprite>().ToArray();
var matches = new Dictionary<int, Sprite>();
foreach (var slot in EnumerateSlots(document, includedSourceIndices))
{
var unityRect = ToUnityRect(slot.Rect, source.Texture.height);
var rectMatches = sprites.Where(sprite => RectEquals(sprite.rect, unityRect)).ToArray();
if (rectMatches.Length != 1)
{
preview.AddIssue(Error(source, FrameAnimationImportIssueCode.SpriteMatchFailed,
$"共享帧 '{slot.CanonicalFrameName}' 无法按 rect 唯一匹配 Sprite,候选数:{rectMatches.Length}。"));
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Error,
slot.CanonicalFrameName,
unityRect,
"只读 Sprite 匹配失败",
slot.FrameNames));
continue;
}
foreach (var sourceIndex in slot.SourceIndices)
{
matches[sourceIndex] = rectMatches[0];
}
preview.AddSpriteDiff(new FrameAnimationSpriteDiff(
FrameAnimationSpriteChangeKind.Unchanged,
rectMatches[0].name,
unityRect,
BuildSlotSummary($"匹配 Sprite '{rectMatches[0].name}'", slot),
slot.FrameNames));
}
return new FrameAnimationSpritePlan(texturePath, existingRects, existingRects, null, matches);
}
private static void MapSlot(
AsepriteSpriteSlot slot,
string spriteName,
IDictionary<int, string> spriteNamesBySourceIndex)
{
foreach (var sourceIndex in slot.SourceIndices)
{
spriteNamesBySourceIndex[sourceIndex] = spriteName;
}
}
private static IEnumerable<AsepriteSpriteSlot> EnumerateSlots(
AsepriteSourceDocument document,
IReadOnlyCollection<int> includedSourceIndices)
{
if (includedSourceIndices == null)
{
return document.SpriteSlots;
}
var included = new HashSet<int>(includedSourceIndices);
return document.SpriteSlots.Select(slot =>
{
var indices = new List<int>();
var names = new List<string>();
for (var index = 0; index < slot.SourceIndices.Count; index++)
{
if (!included.Contains(slot.SourceIndices[index])) continue;
indices.Add(slot.SourceIndices[index]);
names.Add(slot.FrameNames[index]);
}
return indices.Count == 0
? null
: new AsepriteSpriteSlot(slot.Rect, names[0], indices, names);
}).Where(slot => slot != null);
}
private static string BuildSlotSummary(string prefix, AsepriteSpriteSlot slot)
{
return slot.SourceIndices.Count > 1
? $"{prefix}{slot.SourceIndices.Count} 个 SourceFrame 共享"
: prefix;
}
private static IReadOnlyList<SpriteRect> ReadSpriteRects(TextureImporter importer)
{
var factories = new SpriteDataProviderFactories();
factories.Init();
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
if (provider == null)
{
return Array.Empty<SpriteRect>();
}
provider.InitSpriteEditorDataProvider();
return CloneRects(provider.GetSpriteRects());
}
private static void WriteSpriteRects(TextureImporter importer, IReadOnlyList<SpriteRect> rects)
{
var factories = new SpriteDataProviderFactories();
factories.Init();
var provider = factories.GetSpriteEditorDataProviderFromObject(importer);
if (provider == null)
{
throw new InvalidOperationException($"无法获取 Sprite Data Provider{importer.assetPath}");
}
provider.InitSpriteEditorDataProvider();
provider.SetSpriteRects(CloneRects(rects).ToArray());
provider.Apply();
}
private static IReadOnlyList<SpriteRect> CloneRects(IEnumerable<SpriteRect> rects)
{
return (rects ?? Array.Empty<SpriteRect>()).Select(rect => new SpriteRect
{
name = rect.name,
rect = rect.rect,
pivot = rect.pivot,
alignment = rect.alignment,
border = rect.border,
spriteID = rect.spriteID
}).ToArray();
}
private static bool RectEquals(Rect left, Rect right)
{
return Mathf.Approximately(left.x, right.x) &&
Mathf.Approximately(left.y, right.y) &&
Mathf.Approximately(left.width, right.width) &&
Mathf.Approximately(left.height, right.height);
}
private static FrameAnimationImportIssue Error(
FrameAnimationSpriteSourceContext source,
FrameAnimationImportIssueCode code,
string message)
{
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
source.SourceId,
source.SourceId,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 15c72a516e10f7f4dbfdbce9489a935b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameClip))]
public sealed class FrameClipEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
var clip = (FrameClip)target;
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
var kind = clip.IsImported
? "Graph Imported FrameClip"
: clip.HasStandaloneImportSource ? "Source-linked External FrameClip" : "Manual FrameClip";
EditorGUILayout.LabelField(kind, EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.TextField("ID", clip.Id);
EditorGUILayout.TextField("Display Name", clip.DisplayName);
EditorGUILayout.FloatField("Speed", clip.Speed);
EditorGUILayout.EnumPopup("Default End", clip.DefaultEndBehavior);
EditorGUILayout.IntField("Frames", clip.FrameCount);
EditorGUILayout.LongField("Total Duration Ms", clip.TotalDurationMs);
EditorGUILayout.TextField("Storage", AssetDatabase.IsSubAsset(clip) ? "Graph sub-asset" : "External .asset");
EditorGUILayout.TextField("Source Tag", clip.ImportInfo?.SourceTagName ?? string.Empty);
EditorGUILayout.Toggle("Missing", clip.ImportInfo?.IsMissingFromSource ?? false);
EditorGUILayout.ObjectField("Standalone Texture", clip.StandaloneImportSource?.Texture, typeof(Texture2D), false);
EditorGUILayout.ObjectField("Standalone JSON", clip.StandaloneImportSource?.AsepriteJson, typeof(TextAsset), false);
}
if (!AssetDatabase.IsSubAsset(clip) && owners.Count > 1)
{
EditorGUILayout.HelpBox(
"该外部 Manual Clip 被多个 Graph 共享:" + string.Join(", ", owners.Select(graph => graph.name)),
MessageType.Warning);
}
EditorGUILayout.HelpBox(
AssetDatabase.IsSubAsset(clip)
? "Graph sub-asset 的 Clip 数据请在 Frame Animation Graph Editor 中编辑。"
: "双击资产或使用下方按钮,在独立 Frame Clip Editor 中编辑。",
MessageType.Info);
if (!AssetDatabase.IsSubAsset(clip) && GUILayout.Button("Open Frame Clip Editor"))
{
FrameClipEditorWindow.Open(clip);
}
if (owners.Count == 0)
{
EditorGUILayout.HelpBox("当前没有 FrameAnimationGraph 引用该 Clip。", MessageType.Info);
}
else if (owners.Count == 1)
{
if (GUILayout.Button("Open Referencing Graph"))
{
FrameAnimationGraphEditorWindow.Open(owners[0]);
}
}
else if (GUILayout.Button("Choose Referencing Graph"))
{
var menu = new GenericMenu();
foreach (var owner in owners)
{
var captured = owner;
menu.AddItem(new GUIContent(owner.name), false,
() => FrameAnimationGraphEditorWindow.Open(captured));
}
menu.ShowAsContext();
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 842e01eb4f6891e49b3749059adaf6c9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,360 @@
using System;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
public sealed class FrameClipEditorWindow : EditorWindow
{
private FrameClip clip;
private SerializedObject clipSerializedObject;
private ReorderableList frameList;
private Vector2 scroll;
private readonly FrameAnimationPreviewCoordinator preview = new FrameAnimationPreviewCoordinator();
private FrameClipImportPreview importPreview;
private double lastUpdateTime;
[MenuItem(AibisEditorMenus.FrameClipEditor)]
public static void ShowWindow()
{
GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
}
public static void Open(FrameClip value)
{
var window = GetWindow<FrameClipEditorWindow>("Frame Clip Editor");
window.SetClip(value);
window.Show();
window.Focus();
}
private void OnEnable()
{
EditorApplication.update += OnEditorUpdate;
Undo.undoRedoPerformed += OnUndoRedo;
lastUpdateTime = EditorApplication.timeSinceStartup;
}
private void OnDisable()
{
EditorApplication.update -= OnEditorUpdate;
Undo.undoRedoPerformed -= OnUndoRedo;
}
private void OnUndoRedo()
{
RebuildSerializedState();
preview.RefreshTarget(false);
Repaint();
}
private void OnEditorUpdate()
{
var now = EditorApplication.timeSinceStartup;
var delta = Math.Max(0d, now - lastUpdateTime);
lastUpdateTime = now;
if (preview.Tick(delta, null)) Repaint();
}
private void OnGUI()
{
var next = (FrameClip)EditorGUILayout.ObjectField("FrameClip", clip, typeof(FrameClip), false);
if (next != clip) SetClip(next);
if (clip == null)
{
EditorGUILayout.HelpBox("选择一个外部 FrameClip .asset 开始编辑。", MessageType.Info);
return;
}
if (AssetDatabase.IsSubAsset(clip))
{
EditorGUILayout.HelpBox("Graph sub-asset Clip 请在 Frame Animation Graph Editor 中编辑。", MessageType.Info);
if (GUILayout.Button("Open Referencing Graph"))
{
var owner = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip).FirstOrDefault();
if (owner != null) FrameAnimationGraphEditorWindow.Open(owner);
}
return;
}
DrawPreview();
scroll = EditorGUILayout.BeginScrollView(scroll);
DrawClipProperties();
EditorGUILayout.Space();
DrawSourceProperties();
EditorGUILayout.Space();
frameList?.DoLayoutList();
if (clipSerializedObject != null && clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
if (!clip.IsImported && !clip.HasStandaloneImportSource &&
frameList != null && frameList.index >= 0 && GUILayout.Button("Duplicate Selected Frame"))
{
var frames = clipSerializedObject.FindProperty("frames");
frames.InsertArrayElementAtIndex(frameList.index);
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
EditorGUILayout.EndScrollView();
}
private void DrawPreview()
{
var rect = GUILayoutUtility.GetRect(100f, 260f, GUILayout.ExpandWidth(true));
EditorGUI.DrawRect(rect, new Color(0.12f, 0.12f, 0.12f));
var sprite = preview.CurrentSprite;
if (sprite != null && sprite.texture != null)
{
var uv = new Rect(
sprite.textureRect.x / sprite.texture.width,
sprite.textureRect.y / sprite.texture.height,
sprite.textureRect.width / sprite.texture.width,
sprite.textureRect.height / sprite.texture.height);
var scale = Mathf.Min(rect.width / sprite.rect.width, rect.height / sprite.rect.height);
var size = sprite.rect.size * scale;
var destination = new Rect(rect.center - size * 0.5f, size);
GUI.DrawTextureWithTexCoords(destination, sprite.texture, uv, true);
}
else
{
GUI.Label(rect, preview.Error.Code == FrameAnimationPlaybackErrorCode.None
? "Empty Frame" : preview.Error.Message, EditorStyles.centeredGreyMiniLabel);
}
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button(preview.State == FrameAnimationPreviewState.Playing ? "Pause" : "Play"))
{
if (preview.State == FrameAnimationPreviewState.Playing) preview.Pause();
else preview.Play();
}
if (GUILayout.Button("Stop")) preview.Stop();
if (GUILayout.Button("<")) preview.Step(-1);
if (GUILayout.Button(">")) preview.Step(1);
}
var duration = preview.Timeline?.DisplayDurationSeconds ?? 0d;
var position = EditorGUILayout.Slider("Time", (float)preview.PositionSeconds, 0f,
Mathf.Max(0.0001f, (float)duration));
if (!Mathf.Approximately(position, (float)preview.PositionSeconds)) preview.Seek(position);
}
private void DrawClipProperties()
{
clipSerializedObject.Update();
EditorGUILayout.LabelField(clip.HasStandaloneImportSource ? "Source-linked External Clip" : "Manual External Clip",
EditorStyles.boldLabel);
var idProperty = clipSerializedObject.FindProperty("id");
var nextId = EditorGUILayout.DelayedTextField("ID", idProperty.stringValue);
if (nextId != idProperty.stringValue) RenameClip(nextId);
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("displayName"));
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("speed"));
EditorGUILayout.PropertyField(clipSerializedObject.FindProperty("defaultEndBehavior"));
EditorGUILayout.LabelField("Frame Count", clip.FrameCount.ToString());
EditorGUILayout.LabelField("Total Duration", clip.TotalDurationMs + " ms");
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
EditorGUILayout.LabelField("Referencing Graphs", owners.Count.ToString());
if (owners.Count > 1)
EditorGUILayout.HelpBox("该 Clip 被多个 Graph 共享,ID 重命名被锁定。", MessageType.Warning);
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
importPreview = null;
}
}
private void DrawSourceProperties()
{
EditorGUILayout.LabelField("Standalone Import Source", EditorStyles.boldLabel);
if (!clip.HasStandaloneImportSource)
{
if (GUILayout.Button("Enable Texture + JSON Source"))
{
if (!FrameClipImportService.EnableSource(clip, out var error))
EditorUtility.DisplayDialog("无法启用来源", error, "确定");
RebuildSerializedState();
}
return;
}
clipSerializedObject.Update();
var source = clipSerializedObject.FindProperty("standaloneImportSource");
EditorGUILayout.PropertyField(source.FindPropertyRelative("texture"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("asepriteJson"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("pivot"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("manageSpriteSlicing"));
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastImportedTagName"));
EditorGUILayout.PropertyField(source.FindPropertyRelative("lastSourceHash"));
}
if (clipSerializedObject.ApplyModifiedProperties())
{
EditorUtility.SetDirty(clip);
importPreview = null;
}
using (new EditorGUILayout.HorizontalScope())
{
if (GUILayout.Button("Preview Import")) importPreview = FrameClipImportService.Preview(clip);
if (GUILayout.Button("Refresh"))
{
importPreview = FrameClipImportService.Preview(clip);
ApplyImportPreview();
}
if (GUILayout.Button("Detach Source"))
{
if (EditorUtility.DisplayDialog("断开来源", "保留当前帧并转为手工 Clip", "断开", "取消"))
{
FrameClipImportService.DetachSource(clip);
importPreview = null;
RebuildSerializedState();
}
}
}
DrawImportPreview();
}
private void DrawImportPreview()
{
if (importPreview == null) return;
EditorGUILayout.LabelField("Import Preview", EditorStyles.boldLabel);
EditorGUILayout.LabelField("Selected Tag",
string.IsNullOrEmpty(importPreview.SourceTagName) ? "<all frames>" : importPreview.SourceTagName);
EditorGUILayout.LabelField("Selected Frames", importPreview.SourceIndices.Count.ToString());
EditorGUILayout.LabelField("Frame Changes", importPreview.HasFrameChanges ? "Yes" : "No");
EditorGUILayout.LabelField("Sprite Changes", importPreview.HasSpriteChanges ? "Yes" : "No");
foreach (var issue in importPreview.Issues)
{
var type = issue.Severity == FrameAnimationValidationSeverity.Error
? MessageType.Error : MessageType.Warning;
EditorGUILayout.HelpBox($"{issue.Code}: {issue.Message}", type);
}
}
private void ApplyImportPreview()
{
if (importPreview == null || importPreview.HasErrors) return;
var allow = !importPreview.HasSpriteChanges || EditorUtility.DisplayDialog(
"确认修改 TextureImporter",
"独立 Clip 刷新需要更新 Sprite 切图。是否继续?",
"应用切图并刷新",
"取消");
if (!allow) return;
if (!FrameClipImportService.Apply(importPreview, true, out var error))
{
EditorUtility.DisplayDialog("刷新失败", error, "确定");
return;
}
importPreview = FrameClipImportService.Preview(clip);
RebuildSerializedState();
preview.RefreshTarget(false);
}
private void RenameClip(string newId)
{
if (string.IsNullOrWhiteSpace(newId))
{
EditorUtility.DisplayDialog("无法重命名", "Clip ID 不能为空。", "确定");
return;
}
var owners = FrameAnimationAssetReferenceIndex.FindGraphsReferencing(clip);
if (owners.Count > 1)
{
EditorUtility.DisplayDialog("无法重命名", "共享外部 Clip 被多个 Graph 引用。", "确定");
return;
}
if (owners.Count == 1)
{
if (!FrameAnimationAssetOperations.RenameClip(owners[0], clip, newId, out var error))
EditorUtility.DisplayDialog("无法重命名", error, "确定");
return;
}
Undo.RecordObject(clip, "Rename Standalone Frame Clip");
clip.SetId(newId);
EditorUtility.SetDirty(clip);
}
private void SetClip(FrameClip value)
{
clip = value;
importPreview = null;
RebuildSerializedState();
preview.SetGraph(null);
if (clip != null) preview.SetTarget(FrameAnimationPreviewTargetKind.Clip, clip);
else preview.ClearTarget();
Repaint();
}
private void RebuildSerializedState()
{
clipSerializedObject = clip != null ? new SerializedObject(clip) : null;
frameList = clip != null ? BuildFrameList() : null;
}
private ReorderableList BuildFrameList()
{
var frames = clipSerializedObject.FindProperty("frames");
var editable = !clip.IsImported && !clip.HasStandaloneImportSource;
var list = new ReorderableList(clipSerializedObject, frames, editable, true, editable, editable)
{
elementHeight = 72f,
drawHeaderCallback = rect => EditorGUI.LabelField(rect,
editable ? "Frames" : "Frames (source-linked, read-only)"),
drawElementCallback = (rect, index, active, focused) => DrawFrame(frames, rect, index),
onAddCallback = value =>
{
var index = frames.arraySize;
frames.InsertArrayElementAtIndex(index);
var element = frames.GetArrayElementAtIndex(index);
element.FindPropertyRelative("sprite").objectReferenceValue = null;
element.FindPropertyRelative("durationMs").intValue = 100;
element.FindPropertyRelative("frameName").stringValue = string.Empty;
element.FindPropertyRelative("sourceIndex").intValue = -1;
clipSerializedObject.ApplyModifiedProperties();
value.index = index;
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
},
onRemoveCallback = value =>
{
ReorderableList.defaultBehaviours.DoRemoveButton(value);
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
},
onReorderCallback = _ =>
{
clipSerializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(clip);
preview.RefreshTarget(false);
}
};
return list;
}
private static void DrawFrame(SerializedProperty frames, Rect rect, int index)
{
if (index < 0 || index >= frames.arraySize) return;
var element = frames.GetArrayElementAtIndex(index);
rect.y += 2f;
var line = EditorGUIUtility.singleLineHeight;
EditorGUI.PropertyField(new Rect(rect.x, rect.y, rect.width, line),
element.FindPropertyRelative("sprite"), new GUIContent($"#{index} Sprite"));
EditorGUI.PropertyField(new Rect(rect.x, rect.y + line + 2f, rect.width, line),
element.FindPropertyRelative("durationMs"));
using (new EditorGUI.DisabledScope(true))
{
var half = (rect.width - 4f) * 0.5f;
EditorGUI.PropertyField(new Rect(rect.x, rect.y + (line + 2f) * 2f, half, line),
element.FindPropertyRelative("frameName"));
EditorGUI.PropertyField(new Rect(rect.x + half + 4f, rect.y + (line + 2f) * 2f, half, line),
element.FindPropertyRelative("sourceIndex"));
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5673c406c6069e241b0fad51881213fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,333 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEngine;
namespace AibisDream.FrameAnimation.Editor
{
internal sealed class FrameClipImportPreview : IFrameAnimationImportPreviewSink
{
private readonly List<FrameAnimationImportIssue> issues = new List<FrameAnimationImportIssue>();
private readonly List<FrameAnimationSpriteDiff> spriteDiffs = new List<FrameAnimationSpriteDiff>();
public FrameClip Clip { get; }
public AsepriteSourceDocument Document { get; internal set; }
public FrameAnimationSpritePlan SpritePlan { get; internal set; }
public IReadOnlyList<int> SourceIndices { get; internal set; } = Array.Empty<int>();
public string SourceTagName { get; internal set; } = string.Empty;
public string CurrentHash { get; internal set; } = string.Empty;
public bool HasFrameChanges { get; internal set; }
public IReadOnlyList<FrameAnimationImportIssue> Issues => issues;
public IReadOnlyList<FrameAnimationSpriteDiff> SpriteDiffs => spriteDiffs;
public bool HasErrors => issues.Any(issue => issue.Severity == FrameAnimationValidationSeverity.Error);
public bool HasSpriteChanges => spriteDiffs.Any(diff =>
diff.Kind == FrameAnimationSpriteChangeKind.Added ||
diff.Kind == FrameAnimationSpriteChangeKind.Updated);
public FrameClipImportPreview(FrameClip clip)
{
Clip = clip;
}
public void AddIssue(FrameAnimationImportIssue issue)
{
if (issue != null) issues.Add(issue);
}
public void AddSpriteDiff(FrameAnimationSpriteDiff diff)
{
if (diff != null) spriteDiffs.Add(diff);
}
}
internal static class FrameClipImportService
{
public static bool EnableSource(FrameClip clip, out string error)
{
error = string.Empty;
if (!IsExternalClip(clip))
{
error = "独立来源只能添加到外部 .asset FrameClip。";
return false;
}
if (clip.IsImported)
{
error = "Graph Imported Clip 必须继续由所属 Graph 的 ImportSource 管理。";
return false;
}
if (clip.HasStandaloneImportSource) return true;
Undo.RecordObject(clip, "Enable Standalone Frame Clip Source");
clip.GetOrCreateStandaloneImportSource();
EditorUtility.SetDirty(clip);
return true;
}
public static void DetachSource(FrameClip clip)
{
if (clip == null || !clip.HasStandaloneImportSource) return;
Undo.RecordObject(clip, "Detach Standalone Frame Clip Source");
clip.ClearStandaloneImportSource();
EditorUtility.SetDirty(clip);
}
public static FrameClipImportPreview Preview(FrameClip clip)
{
var preview = new FrameClipImportPreview(clip);
if (!IsExternalClip(clip))
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportedClipOwnershipInvalid,
"独立来源只能用于外部 .asset FrameClip。"));
return preview;
}
var source = clip.StandaloneImportSource;
if (source == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.ImportSourceReferenceInvalid,
"FrameClip 尚未启用独立来源。"));
return preview;
}
if (source.Texture == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureMissing, "独立来源缺少 Texture。"));
return preview;
}
if (source.AsepriteJson == null)
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.JsonMissing, "独立来源缺少 Aseprite JSON。"));
return preview;
}
var sourceId = AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip));
if (!AsepriteJsonParser.TryParse(source.AsepriteJson.text, sourceId, out var document, out var parseIssue))
{
preview.AddIssue(parseIssue);
return preview;
}
preview.Document = document;
ValidateFrames(clip, source, document, preview);
if (preview.HasErrors) return preview;
if (document.Tags.Count > 0)
{
var first = document.Tags[0];
preview.SourceTagName = first.Name;
if (string.IsNullOrWhiteSpace(first.Name))
{
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TagNameInvalid,
"第一个 Tag 的名称不能为空。"));
return preview;
}
if (!AsepriteJsonParser.TryExpandTag(first, document.Frames.Count, sourceId,
out var indices, out var tagIssue))
{
preview.AddIssue(tagIssue);
return preview;
}
preview.SourceIndices = indices;
}
else
{
preview.SourceIndices = Enumerable.Range(0, document.Frames.Count).ToArray();
}
ValidateTextureOwnership(clip, source, preview);
if (preview.HasErrors) return preview;
var context = Context(clip, source);
preview.SpritePlan = FrameAnimationSpriteUtility.BuildPlan(
context, document, preview, preview.SourceIndices.Distinct().ToArray());
preview.CurrentHash = FrameAnimationSpriteUtility.ComputeSourceHash(context);
preview.HasFrameChanges = !FramesEqual(clip, preview);
if (!string.IsNullOrEmpty(source.LastSourceHash) && source.LastSourceHash != preview.CurrentHash)
{
preview.AddIssue(new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Warning,
FrameAnimationImportIssueCode.SourceChanged,
sourceId,
clip.Id,
"来源内容或切图设置自上次刷新后已发生变化。"));
}
return preview;
}
public static bool Apply(FrameClipImportPreview preview, bool allowSpriteChanges, out string error)
{
error = string.Empty;
if (preview?.Clip == null || preview.Document == null || preview.SpritePlan == null)
{
error = "独立 Clip 导入预览无效。";
return false;
}
if (preview.HasErrors)
{
error = "导入预览包含阻断错误,未修改任何资产。";
return false;
}
if (preview.HasSpriteChanges && !allowSpriteChanges)
{
error = "导入需要修改 TextureImporter,但尚未获得确认。";
return false;
}
var clip = preview.Clip;
var source = clip.StandaloneImportSource;
FrameAnimationTextureSnapshot snapshot = null;
Undo.IncrementCurrentGroup();
var group = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Refresh Standalone Frame Clip Source");
try
{
if (source.ManageSpriteSlicing && preview.HasSpriteChanges)
{
snapshot = FrameAnimationSpriteUtility.CaptureSnapshot(preview.SpritePlan);
FrameAnimationSpriteUtility.ApplyPlan(preview.SpritePlan);
}
var sprites = source.ManageSpriteSlicing
? FrameAnimationSpriteUtility.LoadSpritesByName(preview.SpritePlan.TexturePath)
: null;
var frames = preview.SourceIndices.Select(sourceIndex =>
{
var sourceFrame = preview.Document.Frames[sourceIndex];
if (!preview.SpritePlan.TryResolveSprite(sourceIndex, sprites, out var sprite))
throw new InvalidOperationException($"刷新后找不到 SourceFrame '{sourceFrame.FrameName}' 对应的 Sprite。");
return new FrameAnimationFrame(sprite, sourceFrame.DurationMs, sourceFrame.FrameName, sourceIndex);
}).ToArray();
Undo.RecordObject(clip, "Refresh Standalone Frame Clip");
clip.SetFrames(frames);
if (string.IsNullOrWhiteSpace(clip.Id))
{
var initialId = !string.IsNullOrWhiteSpace(preview.SourceTagName)
? preview.SourceTagName
: Path.GetFileNameWithoutExtension(AssetDatabase.GetAssetPath(clip));
clip.SetId(initialId);
if (string.IsNullOrWhiteSpace(clip.DisplayName)) clip.SetDisplayName(initialId);
}
source.SetLastImport(
FrameAnimationSpriteUtility.ComputeSourceHash(Context(clip, source)),
preview.SourceTagName);
EditorUtility.SetDirty(clip);
AssetDatabase.SaveAssets();
Undo.CollapseUndoOperations(group);
return true;
}
catch (Exception exception)
{
Undo.RevertAllDownToGroup(group);
if (snapshot != null) FrameAnimationSpriteUtility.RestoreSnapshot(snapshot);
AssetDatabase.SaveAssets();
error = $"独立 Clip 导入失败,已尝试回滚:{exception.Message}";
Debug.LogException(exception);
return false;
}
}
private static void ValidateFrames(
FrameClip clip,
FrameClipImportSource source,
AsepriteSourceDocument document,
FrameClipImportPreview preview)
{
if (document.Frames.Count == 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FramesMissing, "Aseprite JSON 的 frames 为空。"));
foreach (var duplicate in document.Frames.GroupBy(frame => frame.FrameName).Where(group => group.Count() > 1))
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameDuplicate,
$"frameName '{duplicate.Key}' 在来源内重复。"));
for (var index = 0; index < document.Frames.Count; index++)
{
var frame = document.Frames[index];
if (string.IsNullOrWhiteSpace(frame.FrameName))
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameNameInvalid,
$"SourceFrame[{index}] 的 frameName 为空。"));
if (frame.DurationMs <= 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameDurationInvalid,
$"SourceFrame '{frame.FrameName}' 的 duration 必须大于 0。"));
if (frame.Rect.width <= 0 || frame.Rect.height <= 0 || frame.Rect.x < 0 || frame.Rect.y < 0 ||
frame.Rect.xMax > source.Texture.width || frame.Rect.yMax > source.Texture.height)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.FrameRectInvalid,
$"SourceFrame '{frame.FrameName}' 的 rect 越出 Texture 范围。"));
if (frame.Trimmed)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TrimmedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了不支持的 trimmed。"));
if (frame.Rotated)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.RotatedUnsupported,
$"SourceFrame '{frame.FrameName}' 使用了不支持的 rotated。"));
}
if (document.TextureSize.x != source.Texture.width || document.TextureSize.y != source.Texture.height)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureSizeMismatch,
$"meta.size {document.TextureSize.x}x{document.TextureSize.y} 与 Texture {source.Texture.width}x{source.Texture.height} 不一致。"));
}
private static void ValidateTextureOwnership(
FrameClip clip,
FrameClipImportSource source,
FrameClipImportPreview preview)
{
if (!source.ManageSpriteSlicing) return;
var owners = new List<string>();
foreach (var guid in AssetDatabase.FindAssets("t:FrameAnimationGraph"))
{
var graph = AssetDatabase.LoadAssetAtPath<FrameAnimationGraph>(AssetDatabase.GUIDToAssetPath(guid));
if (graph == null) continue;
owners.AddRange(graph.ImportSources.Where(item => item != null && item.IsEnabled &&
item.ManageSpriteSlicing && item.Texture == source.Texture)
.Select(item => $"Graph '{graph.name}' / {item.DisplayName}"));
}
foreach (var guid in AssetDatabase.FindAssets("t:FrameClip"))
{
var other = AssetDatabase.LoadAssetAtPath<FrameClip>(AssetDatabase.GUIDToAssetPath(guid));
var otherSource = other?.StandaloneImportSource;
if (other == null || other == clip || otherSource == null || !otherSource.ManageSpriteSlicing ||
otherSource.Texture != source.Texture) continue;
owners.Add($"FrameClip '{other.name}'");
}
if (owners.Count > 0)
preview.AddIssue(Error(clip, FrameAnimationImportIssueCode.TextureOwnershipConflict,
$"Texture '{source.Texture.name}' 已有其他可写来源:{string.Join(", ", owners)}"));
}
private static bool FramesEqual(FrameClip clip, FrameClipImportPreview preview)
{
if (clip.FrameCount != preview.SourceIndices.Count) return false;
for (var index = 0; index < preview.SourceIndices.Count; index++)
{
var sourceIndex = preview.SourceIndices[index];
var sourceFrame = preview.Document.Frames[sourceIndex];
var frame = clip.Frames[index];
if (frame == null || frame.SourceIndex != sourceIndex || frame.FrameName != sourceFrame.FrameName ||
frame.DurationMs != sourceFrame.DurationMs ||
!preview.SpritePlan.MatchesSprite(sourceIndex, frame.Sprite)) return false;
}
return true;
}
private static FrameAnimationSpriteSourceContext Context(FrameClip clip, FrameClipImportSource source)
{
return new FrameAnimationSpriteSourceContext(
AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(clip)),
source.Texture, source.AsepriteJson, source.Pivot, source.ManageSpriteSlicing);
}
private static bool IsExternalClip(FrameClip clip)
{
return clip != null && !AssetDatabase.IsSubAsset(clip) &&
!string.IsNullOrEmpty(AssetDatabase.GetAssetPath(clip));
}
private static FrameAnimationImportIssue Error(
FrameClip clip,
FrameAnimationImportIssueCode code,
string message)
{
var path = clip != null ? AssetDatabase.GetAssetPath(clip) : string.Empty;
return new FrameAnimationImportIssue(
FrameAnimationValidationSeverity.Error,
code,
AssetDatabase.AssetPathToGUID(path),
clip != null ? clip.Id : string.Empty,
message);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4448f0db0909671479f225061710e439
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
using UnityEditor;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream.FrameAnimation.Editor
{
[CustomEditor(typeof(FrameClipPlayer))]
public sealed class FrameClipPlayerEditor : UnityEditor.Editor
{
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.PropertyField(serializedObject.FindProperty("clip"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("playOnEnable"));
EditorGUILayout.PropertyField(serializedObject.FindProperty("speed"));
DrawWarnings();
serializedObject.ApplyModifiedProperties();
if (!Application.isPlaying) return;
var player = (FrameClipPlayer)target;
EditorGUILayout.Space();
EditorGUILayout.LabelField("Runtime State", EditorStyles.boldLabel);
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.EnumPopup("State", player.State);
EditorGUILayout.ObjectField("Clip", player.CurrentClip, typeof(FrameClip), false);
EditorGUILayout.TextField("Clip ID", player.CurrentClipId);
EditorGUILayout.IntField("Frame", player.CurrentFrameIndex);
}
Repaint();
}
private void DrawWarnings()
{
var player = (FrameClipPlayer)target;
var renderer = player.GetComponent<SpriteRenderer>();
var image = player.GetComponent<Image>();
if (player.GetComponent<FrameAnimationPlayer>() != null)
EditorGUILayout.HelpBox("同一 GameObject 不能同时包含 FrameAnimationPlayer 和 FrameClipPlayer。", MessageType.Error);
if (renderer == null && image == null)
EditorGUILayout.HelpBox("同一 GameObject 上必须存在一个 SpriteRenderer 或 Image。", MessageType.Error);
else if (renderer != null && image != null)
EditorGUILayout.HelpBox("同一 GameObject 不能同时存在 SpriteRenderer 和 Image。", MessageType.Error);
if (serializedObject.FindProperty("clip").objectReferenceValue == null)
EditorGUILayout.HelpBox("尚未绑定默认 FrameClip;仍可通过 Play(FrameClip) 临时播放。", MessageType.Info);
var value = serializedObject.FindProperty("speed").floatValue;
if (!FrameAnimationValueUtility.IsValidSpeed(value))
EditorGUILayout.HelpBox("Speed 必须是有限且不小于 0 的数值。", MessageType.Error);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7eddf60189f61740be2fe59cda06957
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 038041fcf8ae74f06a33182ac1dbdc21
guid: 5ad5208284c226e4297926dd64539442
folderAsset: yes
DefaultImporter:
externalObjects: {}
Binary file not shown.

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