diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 66e52ecb..3536ffe9 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -1,5 +1,33 @@ # GalNet Agent Knowledge +- [必填字符串与 schema 默认值](lessons/L-20261024-01-required-string-defaults.md):Required 字段有默认值仍会被 Normalize 补齐;要求显式提供且空串合法时不声明 schema 默认,并显式 AllowNull=false。DTO 构造函数赋值不导出为 schema 默认。 + +- [契约与 Git 构建元数据](lessons/L-20261023-02-contract-git-metadata.md):SDK 的 InformationalVersion 可包含 commit;严格 DLL 哈希需要稳定非功能元数据,并用不同 SourceRevisionId 重建比较验证。 + +- [调试 Step 与异步呈现](lessons/L-20261023-02-debug-step-async-presentation.md):Step 响应不保证异步截图等呈现任务已完成;按具体完成信号有限等待,CatchUp 不产生截图副作用。 + +- [启动日志前的调试失败](lessons/L-20261023-01-debug-launch-before-logging.md):契约校验可早于日志初始化;App 顶层需 stderr/非零退出码,Editor 需监控自己启动的进程,不能只等待 TCP。 + +- [选择器准备与作者代次](lessons/L-20261022-02-selector-preparation-generation.md):同代后台准备可推进结果 Revision;等待完成后按不可变作者快照身份与资源代次复核,再更新选择快照。 + +- [变量桥接原键与 UID](lessons/L-20261022-01-variable-bridge-original-key.md):Runtime 去作用域前缀,回写已有玩家/系统变量必须保留桥接服务原键;仅 Runtime 改值通过不能证明重开/落盘正确。 + +- [窗口关闭晚于 scope 释放](lessons/L-20261021-01-window-closed-after-scope-disposal.md):异步 Closing 先释放 scope 时,Closed 不能再 GetRequiredService;窗口绑定应在释放前幂等解绑,并用真实进程正常退出验证。 + +- [复合分析原子发布](lessons/L-20261020-04-composite-analysis-publication.md):容错 Timeline 分析可能返回半份结果;复合条目先在临时事实中验证本次完整性,成功后发布,保留上游不确定性。 + +- [跨宿主 DTO 表单样式](lessons/L-20261020-01-schema-form-host-style.md):新 Dock/Flyout 要显式提供 SchemaForm 模板;Host.Diagnostics 不替代实际字段与子诊断检查。 + +- [隔离构建的契约产物](lessons/L-20261019-03-isolated-contract-artifacts.md):默认契约 Content 和 schema fixture 固定读取普通 bin;全新 artifacts 快照要用同源码产物补齐,不能借用旧 DLL。 + +- [精确关键帧边界](lessons/L-20261019-02-timeline-exact-key-frame.md):Step 段需验证内部键及前后分数帧;Editor 直接共用 Runtime sampler,不用时间偏移掩盖边界错误。 + +- [延迟模板祖先类型解析](lessons/L-20261019-01-template-ancestor-type-resolution.md):带命名空间的祖先反射绑定必须实际挂载验证;已复现表达式使用 RelativeSource + x:Type 修复。 + +- [查询上下文随项目 scope 失效](lessons/L-20261018-04-query-scope-disposal.md):旧服务仍可能持有 IsLoaded 文档;查询服务释放时清缓存并推进代次,不能以可读数据代替有效会话。 + +- [L-20261018-03 派生控件主题键](lessons/L-20261018-03-derived-control-theme-key.md):复用 Expander 等基类模板时显式 StyleKeyOverride;挂载后验证实际交互入口,属性赋值测试不能证明按钮可用。 + 这是 GalNet 项目的长期 agent 上下文入口。这里只保存经过验证、以后工作仍然有用的项目事实、边界和经验索引;不要写入对当前任务没有复用价值的对话流水账、秘密或未经确认的猜测。 ## 使用方式 @@ -10,19 +38,19 @@ ## 项目事实(已从仓库文档确认) -- 宿主分层已落地:GameHost 提供 GameSession/IGameRun/GameRun、PlayerSession、PlayerPreferences/PlayerStartup;Avalonia.GameHost 提供默认页面会话、运行绑定、展示组合、资源预加载和设置/输入/窗口适配。Sample 仅保留项目配置、启动适配、本地化和调试。中立应用接口在 Presentation.Abstractions.Application,共享音频后端在 Media.LibVlc;项目删除与 ProjectTools.Windows 已移除。见 docs/spec/architecture.md 和 docs/design/game-host-refactor.md。 +- 宿主分层已落地:GameHost 提供 GameSession/IGameRun/GameRun、PlayerSession、PlayerPreferences/PlayerStartup;Avalonia.GameHost 提供默认页面会话、运行绑定、展示组合、资源预加载和设置/输入/窗口适配。Sample 仅保留项目配置、启动适配、本地化和调试。中立应用接口在 Presentation.Abstractions.Application,共享音频后端在 Media.LibVlc;项目删除与 ProjectTools.Windows 已移除。见 docs/spec/architecture.md。 - 解决方案入口是 `GalNet.slnx`。 -- `docs/spec/` 描述已经实现的稳定事实;`docs/design/` 描述设计和分阶段计划。 +- `docs/spec/` 描述已经实现的稳定事实;`docs/manual/` 描述二次开发路径;`features/` 保留实现过程和历史。 - Runtime/Core 应保持与 Avalonia、WPF、Skia、具体文件系统和其他平台实现解耦。 -- Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为完整内置运行入口;独立 GalNet.Contracts.Builtins 只依赖 Core,CreateAllModules 返回 13 个组件模块。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/adr/0002-entry-schema-execution-registration.md 和 docs/development.md。 +- Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为完整内置运行入口;独立 GalNet.Contracts.Builtins 只依赖 Core,CreateAllModules 返回 14 个组件模块,包含 custom.invoke 的固定双字符串 schema。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/spec/runtime.md、docs/spec/entry-types.md 和 docs/manual/。 - 当前 GameSnapshot 版本为 4,不兼容旧版;持久数据在注册式 GameState 表中,快照为 StateId → version/JSON payload;读档先验证,再取消旧 scope 并替换整个状态根。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。 -- Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;GameHost 仅协调应用会话,不复制 Engine 执行机制。AvaloniaGamePresenters 只组合端口,AvaloniaGamePresentation 管理平台资源。见 docs/design/game-runner-refactor.md 和 docs/spec/runtime.md。 +- Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;GameHost 仅协调应用会话,不复制 Engine 执行机制。AvaloniaGamePresenters 只组合端口,AvaloniaGamePresentation 管理平台资源。见 docs/spec/runtime.md 和 docs/spec/architecture.md。 - Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前程序集布局见 docs/spec/architecture.md。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。 - 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。 - 持久条目先在 `GameState.Update` 中修改唯一数据记录,再通知展示层;模块通过 PrimitiveRegistry.State/Restore/ValidateState 声明状态、恢复依赖和校验,宿主使用展示抽象 StateRestoreSession 统一恢复、等待和逆序清理。旧 SceneState/Active* 镜像与 Builtins 中央 replay 已删除。 - 视觉扩展应通过展示抽象和渲染端口接入;不要把平台类型或渲染资源泄漏进 Core/Runtime。 -- 当前已有大型阶段计划:[runtime-presentation-decoupling-phase-plan.md](../docs/design/runtime-presentation-decoupling-phase-plan.md) 和 [render-effects-pipeline-plan.md](../docs/design/render-effects-pipeline-plan.md)。新增 feature 应链接它们,不要复制其中的长期设计。 +- 新增 feature 的长期实现意图记录在其自身 feature 目录;只有已验证的稳定事实才合并到 docs/spec/。 - 玩家变量桥接实现为 Runtime.Variables.PlayerVariableService,存储通过 IPlayerVariableStore 注入;Variable.Clone 保留 Uid,VariableValue 不可变,可共享。Runtime loader/SaveManager 不执行文件 I/O。见 docs/spec/architecture.md。 - Runtime.I18n.GameTextResolver 提供 culture-first、父文化与默认文化回退,只依赖宿主查询委托;DynamicLocalization/JSON 适配留在 Sample 的 GameLocalizationProvider。见 docs/spec/i18n.md。 @@ -36,11 +64,34 @@ - `.agents/skills/`:项目 skill 的可版本化源文件;可复用经验晋升后在这里形成独立 skill。 - Codex 的用户级 skill 发现目录保存运行时副本;源文件变化后使用 `galnet-sync-skills` 同步,避免 agent 使用旧版本。 - `docs/spec/`:当前系统事实。 -- `docs/design/`:跨 feature 设计和推荐工作流。 -- `docs/adr/`:重要架构决定;接受后的 ADR 不直接改写。 +- `docs/manual/`:二次开发流程与框架接入指南。 ## 当前工作 +- 2026-10-19 P6e3b 已验证:属性展开为内联值曲线,右键插值/左右侧切线模式、持久自动贝塞尔重算、联动/断开和精确输入弹窗取代独立详情区;共享 Avalonia 控件直接调用 Core sampler,Step 精确内部键边界已修正。editorTangents 只属于作者模式,Runtime 消费烘焙斜率;值/柄手势预览、松手单历史与过期取消,关闭弹窗释放绑定和草稿。隔离候选六项目 810 项、弹窗收尾 Editor 184 项、构建/格式/指纹通过,六项媒体未执行;主工作区另外的控件改动和首次三个非 Timeline 失败保留。P6e3 verified,P6e4/P6f 后续,见 features/F-20261015-02-editor-authoring-workspace/phase-6e3.md。 + +- 2026-10-19 Group 属性编辑器改造已验证:统一响应式字段行、标题左上/重置同行右侧,去掉常驻 presence 操作;Transform 成员与类型工厂、ColorPicker、Timeline Dock 按钮,列表按元素类型分派。CanReset/HostContext 经 DTO scope 转发,Timeline 无默认定义禁止通用重置;子项 patch 保留未知 JSON,变换按语义相等保留无关更新下的草稿。802 项相关回归、解决方案构建、格式/源码指纹通过,六项媒体未执行,四语 727 keys。见 [实施记录](../features/F-20261015-02-editor-authoring-workspace/phase-group-properties.md)。动态结构 JSON 回退、八层/200 元素保护及 P6e3b/P6e4/P6f 范围保留。 + +- 2026-10-19 P6e3a 已验证:选中关键帧的值/出段 Step/Linear/Hermite/入出切线、混合值、多键单历史、平坦/线性/平滑/对齐一次性预设与值/秒显示换算;保存仍值/帧,切 Step/Linear 保留切线。详情按文档代次与选择失效,保留未知 JSON/大小写/首帧,越界/斜率溢出整批拒绝;布局预留不因选择移动画布。解决方案构建、Editor/Core/Architecture 332 项、格式/指纹通过,四语 705 keys,见 [phase-6e3](../features/F-20261015-02-editor-authoring-workspace/phase-6e3.md)。P6e3b 图形曲线/拖柄仍 planned,P6e4/P6f 随后;没有新增 Runtime 的极值拒绝规则或实时预览。开工已有的 feature.md/Group 属性设计改动保留未提交。 + +- 2026-10-18 P6e2b 已验证:ProjectSceneTargets 是指定 Group/Entry 位置的查询服务,缓存作者快照并随项目 scope 释放失效;SceneTargetCatalog/Selection 适配候选与 Locator 解析,控件自己管理弹窗。Assets/普通字段/Timeline 共用 ResourcePickerPanel。Timeline 顶部“+”只选对象,对象行左侧“+”逐项选属性;未知初值待明确输入才创建,空对象仅面板草稿。整个对象替换保留键/未知 JSON、单历史。初轮六项目 905 项、用户两步交互修订后最终 461 项相关回归、解决方案构建及格式/指纹通过;6 项原生媒体用例未执行,四语 691 keys。见 [scene-locator-plan](../features/F-20261015-02-editor-authoring-workspace/scene-locator-plan.md)。下一步 P6e3 曲线/切线,P6e4 剩余语义/事件/引用;实时预览不列阶段。 + +- 2026-10-18 Timeline 交互简化已验证,取代 P6e2 原保秒重定时/裁剪方案:已有轨道持续显示绝对值/叠加偏移模式,切换保留数值与切线;结束帧/FPS Enter/失焦独立提交、FPS 保留帧号、结束帧下限覆盖键/事件,越界拖动与延长合为单历史。旧应用/裁剪/保秒生产路径移除,四语各 678 keys。初轮六项目 901 项通过,模式拒绝回显补充后的最终 457 项、解决方案构建、格式/指纹通过,6 项媒体环境未执行;见 [timeline-simplification](../features/F-20261015-02-editor-authoring-workspace/timeline-simplification.md)。下一步 P6e3 曲线/切线。 + +- 2026-10-18 P6e2 用户反馈修正已验证:播放头拖动只更新 DisplayFrame 预览,松手才定位/采样一次,取消/版本失效不提交;Timeline 复选框居中、NumericUpDown 实际模板按钮长按 250ms 后每 30ms 连发。457 项受影响回归、解决方案构建、格式/指纹通过,6 项原生媒体环境未执行,见 [phase-6e2 修正记录](../features/F-20261015-02-editor-authoring-workspace/phase-6e2.md#用户反馈修正播放头与控件交互)。 + +- 2026-10-18 P6e2 已验证:共享 Avalonia TimelineCanvas/Model 与 Editor 无损 JSON 修改分层,树/画布共用扁平可见行和纵向滚动;关键帧多选/框选/整帧移动、首键保护/明确碰撞替换、时长/FPS/保秒重定时与裁剪均单历史,源变化取消旧手势。六项目 901 项回归、解决方案构建、格式/源码指纹通过;6 项原生媒体环境未执行。见 [phase-6e2](../features/F-20261015-02-editor-authoring-workspace/phase-6e2.md)。下一步 P6e3 曲线/切线,P6e4 事件/剩余状态与引用,实时预览不列阶段。 + +- 2026-10-18 P6e1 已验证:Editor.Shared 的 SceneTargetAnalysis 对作者快照做纯有限事实分析,Group 入口固定点、确定交/可能并、条件/未知语义/循环预算与跨类型身份冲突保守处理;ProjectSceneTargets 整代缓存,不执行游戏。Timeline 对象/属性树支持建/移轨道、未知初值和偏移首键、旧行失效;GUI 创建者自动内部 ID,Entry 粘贴按语义槽位重映射内部引用。解决方案构建、同源码 656 项回归、格式/指纹通过;一次未修改文件面板指针测试偶发已记录,隔离及完整重跑通过,根因未确认。见 [phase-6e1](../features/F-20261015-02-editor-authoring-workspace/phase-6e1.md)、[当前查询规范](../docs/spec/editor-scene-targets.md)。下一步 P6e2 画布/关键帧,P6e3 曲线;P6e4 事件/剩余语义、通用 Locator、Graph/转场复制和缺目标核验。只提供固定内置能力子集,未分析 Timeline/转场不当无影响;实时预览不列阶段。 + +- 2026-10-18 P6d 已验证:Timeline 默认底部单实例 Dock 跟随活动 Group Entry,项目 scope 的 EntrySelectionContext/TimelineEditSession 不借用页面字段 lease;已有轨道 NumericUpDown 按当前帧覆盖/插键,保留切线/未知 JSON,单次项目历史。旧行在切换/删除/换类型/关闭后失效,保存和切语言保留未确认输入;切 Entry/帧/根值及关闭 Dock 明确取消。644 项相关回归、解决方案构建、格式与源码指纹通过。见 [phase-6d](../features/F-20261015-02-editor-authoring-workspace/phase-6d.md)。下一步 P6e 目标/属性能力、对象树、创建轨道与画布/曲线;P6f animate 自定义曲线,P7 统一关闭协调。当前仅 Timeline 行隐藏 handle,Group JSON 仍是修复入口;实时预览不列阶段。 + +- 2026-10-18 P6c 已验证:AnimationPlan 全面统一为 Timeline 类型/播放接口,Entry 为 timeline.play、根参数 timeline,转场与循环恢复同步;builtin.animations section v2,PrimitiveRegistry.State 可显式 version(其他默认 1)。1081 项回归、解决方案构建、格式与源码指纹通过,6 项原生媒体环境未执行。见 [phase-6c](../features/F-20261015-02-editor-authoring-workspace/phase-6c.md)。下一步 P6d 实现跟随活动 Entry 的 Timeline Dock/项目编辑会话,P6e 对象树/关键帧/曲线,P6f animate 自定义曲线;实时预览不列阶段。 + +- 2026-10-18 P6b 已验证:普通生成 DTO 对象/列表、presence/null/default、JSON 回退、懒创建/释放接入共享层和 Group 页,879 项回归通过,6 项原生媒体环境用例未执行,解决方案构建/格式/源码指纹通过。详见 [phase-6b](../features/F-20261015-02-editor-authoring-workspace/phase-6b.md)。剩余 P6 为未知 Entry JSON 修改、状态查询/Locator 和 AnimationPlan 等复杂定制;后者按用户要求留后续阶段。 + +- [F-20261015-02 Editor 创作工作区](../features/F-20261015-02-editor-authoring-workspace/phase-plan.md):P1a/P1b/P2/P2i/P3/P4a/P4b 已验证,P4b 隔离构建与 383 项相关回归通过。项目单栈、Graph、文件事务/双版本文档协调、metadata/引用保护、文件树/网格、路径块/全局文件名搜索、项目文本缓冲与四语已接通;P6a 已提前完成共享 DTO UI 基础,610 项相关回归通过,另 6 项媒体环境用例未执行;三层注册、默认表单、多 DTO 面板与两个宿主已接入。P5a Group 交互已验证(搜索/键盘插入、批量、折叠拖放、尾部取消选择),614 项相关回归通过,6 个媒体环境用例未执行。P5b 多选剪贴板/批量删除与移动已验证,493 项相关回归和解决方案构建通过,含异步剪贴板保护与集合补偿故障测试。P5c 独立变量面板及声明/引用单历史已验证,611 项相关回归和解决方案构建通过;玩家/存档页签、草稿/应用、说明、引用诊断与补全刷新已接通。P5d Entry 字段与新增入口迁移已验证,614 项相关回归及解决方案构建通过;字段历史按稳定 ID/作者 JSON 执行,不保留页面,旧表单在删除/换类型及撤销后不复活。P5e GUI 作者草稿保存已验证,635 项相关回归、解决方案构建与格式/源码指纹通过;Authoring 保留未知/不完整条目 JSON,默认 Complete 与 CLI 校验/编译仍严格。P5 整体 verified,后续继续 P6 状态查询/Locator 和复杂值功能、P7 验收。游戏预览另立 feature。当前实现见 docs/spec/editor-graph.md、editor-groups.md、editor-variables.md、editor-files.md 和 editor-history-and-save.md。 + - F-20261015-03 模块化游戏状态:GameSnapshot v4;状态 catalog 随 view 组合,Engine 接入;中立 Layer/EffectInstance/ParticleEmitterInstance 直接作为唯一可变数据记录,属性变更必须在 State.Update,编码形成隔离快照。LoopAnimationState 保存类型化 request/plan;停止控制与播放 identity 仅运行期持有。音频 AudioTrackState/AudioSEffectState 包含暂停、队列与空闲效果,所有轨道按稳定检查点从头恢复;不恢复 seek/淡化时钟,玩家设置不回滚。恢复首帧不重复一次性事件,暂停声音在 Resume 前不启动后端。七个相关项目 665 项测试、解决方案构建与 CLI 实际项目校验/编译通过;新档验证见 [feature summary](../features/F-20261015-03-modular-game-state/summary.md)。 @@ -54,7 +105,7 @@ - [F-20261011-02-player-page-layout](../features/F-20261011-02-player-page-layout/summary.md) 已补齐三套内置主题的全部普通页面 XAML View,主题目录自行维护布局、共享 ViewModel/命令;页面无外框,主题内导航位置一致。对白名字比正文大4,Classic/Paper 26/22、Minimal 24/20;默认 GameView 页面回退已由 F-20261011-03 删除。 -- [F-20261011-01-game-ui-themes](../features/F-20261011-01-game-ui-themes/summary.md) 已完成启动期主题组合:可选 Themes 提供三布局/三配色,页面顺序已由 F-20261011-03 收敛为共享 GamePage→主题→宿主→完整性校验;GamePage 是稳定 TemplatedControl,四个 PART_* 承载其自有内容,派生页与 Runtime 保持同一 Scoped 实例。样式应用于玩家根,官方主题局部 Fluent 与宿主 Semi 隔离。当前无运行期切换;配置/契约见 Themes README、docs/spec/control.md、ADR 0003。 +- [F-20261011-01-game-ui-themes](../features/F-20261011-01-game-ui-themes/summary.md) 已完成启动期主题组合:可选 Themes 提供三布局/三配色,页面顺序已由 F-20261011-03 收敛为共享 GamePage→主题→宿主→完整性校验;GamePage 是稳定 TemplatedControl,四个 PART_* 承载其自有内容,派生页与 Runtime 保持同一 Scoped 实例。样式应用于玩家根,官方主题局部 Fluent 与宿主 Semi 隔离。当前无运行期切换;配置/契约见 Themes README 和 docs/spec/control.md。 - [F-20261009-02-video-entries](../features/F-20261009-02-video-entries/feature.md) 已实现剧情视频图层、阻塞分段 seek、停止/替换清理和非阻塞视频定义恢复。平台视频必须经 AssetManager 获取,解码帧通过租约进入场景管线;播放游标不入存档。验证见 feature summary。 @@ -76,6 +127,16 @@ ## 经验索引 +- [测试 fixture 的服务策略](lessons/L-20261018-02-fixture-service-policy.md):confirmed;无故障注入时保留生产 DI 注册,防止测试的手工默认构造覆盖新策略而给出误导性失败。 + +- [Entry 旧绑定复活边界](lessons/L-20261018-01-entry-binding-revival.md):candidate;稳定 ID 可随撤销恢复,控件提交租约必须重新建立,不能只检查对象存在和类型相同。 + +- [项目监听器身份](lessons/L-20261017-03-project-watcher-owner.md):后台事件使用创建时固定的项目根,投递 UI 后复查项目身份与 disposed;关闭后不可再从全局 Current 取根。 + +- [容错读取与作者重写](lessons/L-20261017-02-tolerant-reader-authoring.md):读取 codec 返回默认 DTO 不代表可以无损写回;作者操作须严格校验并保留原始文件。 + +- [局部编辑的全局位置](lessons/L-20261017-01-local-edit-global-indices.md):删除局部集合中的中间项后,After 快照必须同步重算全局索引;比较完整作者快照验证撤销。 + - [批量预加载的部分成功租约](lessons/L-20261016-06-partial-preload-leases.md):每次成功获取立即登记;WhenAll 失败仍须释放其他已成功任务的租约。 - [分支节点高度变化必须发布连线布局刷新](lessons/L-20261016-04-graph-edge-layout-refresh.md):连接器重建时输入集合事件可能早于输出集合重建,节点布局完成后必须单独通知入边刷新路径。 @@ -114,7 +175,7 @@ - [读档必须隔离旧运行实例](lessons/L-20261010-02-restore-run-scope.md):取消与场景对象隔离同时使用;句柄复用的清理需校验实例 identity;快照不能共享 mutable 对象。 - [删除项目后检查依赖锁图](lessons/L-20261010-03-lock-graph-after-project-removal.md):检查 lock 内容;缓存可能保留旧项目,需要对受影响 roots 强制重算再 locked restore。 - [Engine 停止覆盖所有续跑](lessons/L-20261010-04-engine-stop-continuations.md):首次推进返回不等于运行结束;停止须取消 scope、等待内部 continuation,统一发布终态。 -- [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 ADR-0001 和 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。 +- [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。 - [受限环境中的平台 SDK 枚举](lessons/L-20260925-01-platform-sdk-sandbox.md):全解决方案构建可能只因 Android/Browser/iOS 的 SDK 探测读取用户目录失败;应区分平台环境限制和业务项目编译结果。 - [受限环境中的 Avalonia licensing 枚举](lessons/L-20260926-01-avalonia-license-sandbox.md):Avalonia BuildServices 即使关闭 telemetry 仍可能读取用户级 licensing tickets;应与业务编译失败区分。 - [首次对话的 Avalonia 模板就绪](lessons/L-20260927-01-dialogue-template-startup.md):阻塞对话不能因模板部件尚未就绪而静默完成,否则引擎会错误地跑完整个流程。 @@ -157,3 +218,52 @@ - [Headless 会话发布竞态](lessons/L-20261014-05-headless-session-publication-race.md):12.0.5 StartNew 的 task 闭包发布存在竞态;Presentation 通过官方程序集缓存会话避免逐测试释放 worker,Dispatch 保持 PerTest 隔离。 - [Entry 默认值使用完整 codec](lessons/L-20261014-06-entry-defaults-use-value-codec.md):JSON 字符串编码不代表 CLR 字符串;无法无损投影的默认值保留在字段描述,不填入旧 Defaults。 + +- [L-20261017-05 scoped binding 幂等退订](lessons/L-20261017-05-scoped-binding-unsubscribe.md):字段与DTO两层释放只移除一次实际 source 订阅,不释放借用 binding。 + +- [L-20261017-06 列表可滚动尾部](lessons/L-20261017-06-listbox-scrollable-tail.md):使用主题 Padding 入口并测量满视口时的实际尾部空间;短列表空白不能证明实现有效。 + +- [L-20261017-07 可观察集合批量补偿](lessons/L-20261017-07-observable-batch-compensation.md):通知抛错时当前集合操作可能已生效,应按实际状态恢复;补偿失败进入 RepairRequired。已由故障注入确认。 + +- [L-20261017-08 作者元数据与严格 Raw shape](lessons/L-20261017-08-author-metadata-strict-shape.md):新增声明字段须同步写入前的严格白名单/类型校验,保留保存重开证据,不能绕过未知字段规则。 + +- [文件路径复用与删除身份](lessons/L-20261019-04-file-document-path-reuse.md):FileDocuments 按会话 ID 持有历史;删除后同路径新建可同时保留旧记录,路径查询须优先有效身份并保留删除遮罩。 + +- 2026-10-20 游戏本地化 P2 已验证:字段标记/只读索引之上,LocalizationDocumentStore 与数据操作接入单栈和双版本文件事务;相关 JSON 草稿校验/重基与冲突保留、catalog/metadata 专属管理、角色编号持久预留完成。新增 21 项通过,完整相关回归 495 通过、3 个 P6e3 已记录 UI 失败;构建/格式/任务源码指纹通过。P3 角色 UI、P4 表单映射、P5 多语言表格、P6 交换后续。见 features/F-20261019-01-editor-game-localization/phase-2.md 与 docs/spec/editor-game-localization.md。 + +- [嵌套语义引用使用 wire 名](lessons/L-20261020-02-nested-semantic-wire-name.md):FieldDescriptor.Id 不等于嵌套作者 JSON 属性名;按值契约 JsonTypeInfo 定位,重建字段须保留 Semantic。P3 索引大小写与真实列表角色改 key 测试已确认。 + +- [程序化 TextBox 刷新与延迟事件](lessons/L-20261020-03-deferred-text-change-is-not-user-edit.md):TextChanged 延迟到 bool guard 之后,可能触发失焦写入;使用同步 TextProperty 监听并保留旧异常 JSON。角色阶段完整 Editor 回归已复现并验证修正。 + +- 2026-10-20 游戏本地化 P3 已 verified:角色 Dock、开发语言姓名/key、speaker 姓名/key 搜索与失焦创建、跨全部 Group/嵌套引用改 key、一次历史与旧值显式导入完成。新增 14 项全通过;相关回归 759 通过、3 项 P6e3 已记录 UI 失败,完整构建 0 错误。正文/分支默认语言映射及复制身份 P4、多语言表格/语言 P5、交换/完整校验 P6 待续;见 features/F-20261019-01-editor-game-localization/phase-3.md。 + +- 用户协作约定(2026-10-20):每个实施阶段验证结束后提交 Git,包含本阶段相关代码、测试和文档,并报告 commit;不要混入其他任务改动。 + +- 用户修订:本地化 feature 不考虑向后兼容;旧 Speakers、角色迁移 UI/服务与回调已删除(d2e4b82),后续只支持当前作者及剪贴板格式。 +- [文本历史与不可撤销编号](lessons/L-20261020-05-text-history-reserved-number.md):同文件的 characterHighWater 仅允许单调重基,文本历史不能回退编号;其他 metadata 严格校验。 +- [多行提交的 Tunnel 路由](lessons/L-20261020-06-multiline-commit-tunnel.md):Ctrl+Enter 在 TextBox 类处理前截获,保留普通换行与 IME 边界,验证真实挂载输入。 + +- 2026-10-20 游戏本地化 P4 已 verified:默认语言正文/分支语义编辑器、稳定 key、Entry/Graph/列表复制各语言快照、源文复核状态及跨文件单次历史完成。新增 16 项通过,解决方案 0 错误;完整相关回归 812 通过、3 失败(2 项既有布局断言,1 项 Headless DisposeAsync 空引用,独立复验该方法 7 项通过)。四语资源各 850 key 一致、格式/源码指纹/文档链接通过;见 features/F-20261019-01-editor-game-localization/phase-4.md。P5 多语言表格与语言管理、P6 交换/完整校验待续。 + +- [虚拟化模板的空内容](lessons/L-20261021-01-virtualized-null-content.md):容器解绑回收时 FuncDataTemplate 会收到 null,工厂须返回空占位,真实滚动与关闭验证。 +- [异步文本提交的焦点](lessons/L-20261021-02-async-textbox-focus.md):Busy 用 IsReadOnly 保护输入,避免 IsEnabled 使 Ctrl+Enter 后丢焦点、后续失焦提交消失。 +- [项目级 Dock 草稿生命周期](lessons/L-20261021-03-project-owned-dock-drafts.md):实际菜单 CloseDocument 会主动 Dispose Context;项目级草稿模型须保留到 scope 结束,并用真实开关路径验证。 +- [历史队列执行时重查](lessons/L-20261021-04-history-admission-recheck.md):入队后仍可因前序操作改变原文或语言;单元格及开发语言预览的 guard 必须进入实际 Redo 边界,两种交错已复现并验证。 + +- 2026-10-21 游戏本地化 P5 已 verified:项目级 i18n Dock、角色/全部 Group/分支虚拟化多语言表格、语言新增/调序、开发语言缺译预览及切换、单元格状态/草稿保留/Save All 和历史队列执行时重查完成。I18nLocale 统一 CultureInfo 名称,支持脚本子标签标准大小写。新增 17 项通过,正常解决方案构建 0 错误;Core/Runtime/Editor.Shared/Architecture/Presentation/Editor 合计 1039 通过、2 项 P6e3 既有布局失败,另有 6 个视频环境跳过。四语各 882 key、格式/17 源码指纹/文档链接通过。见 features/F-20261019-01-editor-game-localization/phase-5.md 与 docs/manual/game-localization.md;P6 导入导出及发布前完整校验待续。 + +- 2026-10-21 Editor 写入口与状态已收口:GUI dirty 唯一来源为 ProjectContentSession 的内容/保存基线比较,Document 与 GalProject 不再持有 dirty 标记;变量声明通过 VariableAuthoringService,IVariableDefinitionService 仅返回保留 UID 的独立快照并通知刷新。Preview 只编辑运行时当前值,旧声明表单、Workspace 快照补历史和 PushAlreadyApplied 已删除,不保留兼容层。见 features/F-20261021-01-editor-state-simplification/summary.md、docs/spec/editor-variables.md 与 docs/spec/editor-history-and-save.md。 + +- [组合查询的完整性原因](lessons/L-20261021-05-query-completeness-causes.md):内部保留语义未知与对象能力缺失的区别;停止效果可消除后者,不能抹掉前者。candidate,见双路径回归。 + +- [架构项目发现排除构建输出](lessons/L-20261021-06-architecture-project-discovery.md):递归 csproj 枚举须排除任意层级 bin/obj;保留临时仓库副本复验 45 项通过,不能靠删除其他任务产物修复测试。 +- 2026-10-21 游戏本地化 P6 已 verified,feature P1–P6 完成:单语言模板/平铺 JSON 导入导出、项目交换 GUID、稳定 key 与源文指纹、选择预览、明确空串、整批单次历史及执行时证据重查;完整 project.validate/build 增加包内默认语言和角色语义校验,未知范围 warning/Complete=false。身份预留可能在输出失败后保留,旧文本历史须保留新身份(见文本历史 lesson)。新增 20 项通过,正常解决方案构建 0 错误;完整相关回归 1180 通过、2 项 P6e3 既有布局失败,另有 6 个视频环境跳过。原生文件选择/IME 和大项目性能阈值仍待人工验收。当前契约见 docs/spec/editor-game-localization.md,操作见 docs/manual/game-localization.md,交付与自审见 features/F-20261019-01-editor-game-localization/summary.md、review.md;不保留旧格式兼容。 + +- [作者参数线格式](lessons/L-20261021-07-author-parameter-wire-format.md):测试作者 JSON 必须按字段 schema/converter 编码;Animate 的字符串布尔与嵌套 Timeline 的 JSON bool 不可混用,保留坏格式拒绝与有效夹具 Complete 断言。 + +- [透明 hover 与预乘 alpha(已废止)](lessons/L-20261024-01-transparent-hover-interpolation.md):全局自定义画刷过渡会影响 Group entry 等非按钮容器;Editor 已恢复 Ursa/Semi 原生 hover 反馈。 +- 2026-10-24 Editor UIUX:Core `[Common]` → `EditorHints.IsCommon`,共享表单默认 All,Group 显式 Common;独立 Inspector 绑定与项目 EntryEditSession 草稿,辅助工具不清空详情。保存/检查状态分离,Problems 用当前快照与稳定 ID 导航;三套手动布局及最大化恢复不进入作者历史。契约、使用与证据见 features/F-20261023-01-editor-uiux/summary.md、docs/manual/editor-authoring-workspace.md。真实 DPI/原生 IME 仍需人工验收;旧页面绑定失效规则仍保留,不用复用控件/旧绑定解决草稿保留。 +- [Dock 焦点与 Inspector](lessons/L-20261024-02-dock-focus-and-inspector.md):已活动文档跨区域返回只改变 FocusedDockable;详情跟随须同时处理焦点和激活,并防后台选择抢占及辅助工具覆盖解锁目标。 +- [外部调试子进程监控生命周期](lessons/L-20261024-03-external-debug-child-monitor-lifetime.md):断开调试不能等待未终止的外部进程退出;Editor 存活时仍需排空重定向输出并隔离旧会话事件。candidate,待运行复现。 +- [异步构建的同步前缀](lessons/L-20261024-04-sync-build-work-ui-thread.md):Task API 首个 await 前的验证、扫描与编译仍会阻塞调用线程;先在 UI 线程捕获安全快照,再后台运行纯构建工作。candidate,待大项目运行复现。 +- [动画帧颜色与主题](lessons/L-20261024-05-animated-brush-measurement.md):中间帧比较须纳入 Brush.Opacity,并显式使用实际 ThemeVariant;只看 Color 或默认资源会误报跳色。 diff --git a/.agents/lessons/L-20261017-01-local-edit-global-indices.md b/.agents/lessons/L-20261017-01-local-edit-global-indices.md new file mode 100644 index 00000000..65b0dd52 --- /dev/null +++ b/.agents/lessons/L-20261017-01-local-edit-global-indices.md @@ -0,0 +1,15 @@ +# 局部集合删除也要重算全局位置快照 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P3 + +现象:分支按稳定选项 ID 重排后,删除一个在全局边列表中间的选项,再 Undo,会被 BranchEdit 的预期状态校验拒绝;删除末项的测试却通过。 + +根因:分支编辑保留局部出边及其全局插入索引。删除一条边后,其后的全局索引前移,而预先生成的 After 快照仍保存旧索引,导致实际状态与历史预期不一致。稳定对象 ID 不能替代集合顺序快照的一致性。 + +正确做法:生成 After 时,每条保留边的全局位置减去在其前面删除的边数;Undo 使用 Before 中的原始全局位置恢复,其他节点的边保持原顺序。预期校验必须在所有写操作之前完成。 + +验证:p3-middle-edge-regression.trx 的 Choice/Condition 两用例稳定失败;修正后 p3-middle-edge-fixed.trx 的两种分支与真实控件用例共 3 项通过。用例从三个出口中删除原第二条边,再撤销两步并比较完整作者快照,不只比较局部连线目标。 + +适用范围:跨多个集合的局部增删、索引重映射、批量历史和投影同步。不能只测试尾部删除,也不能通过放宽状态校验掩盖错误索引。 diff --git a/.agents/lessons/L-20261017-02-tolerant-reader-authoring.md b/.agents/lessons/L-20261017-02-tolerant-reader-authoring.md new file mode 100644 index 00000000..7edec880 --- /dev/null +++ b/.agents/lessons/L-20261017-02-tolerant-reader-authoring.md @@ -0,0 +1,15 @@ +# 容错读取不等于可以安全重写 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P4a + +现象:AssetMetaCodec 对合法 JSON 中无法类型化的可选字段会回退到默认 DTO;读取仍能取得 ID 和 path,但直接序列化回去会丢掉无效字段及原始扩展内容。 + +根因:运行时读取的容错策略与作者写入的无损要求不同。文件改名/复制即使只想改 path,也会经过完整 metadata 重写。 + +正确做法:作者操作先用相同 DTO 和 JSON 配置进行严格类型化校验;失败时拒绝整个文件计划,保留原始资源和 sidecar,不将默认 DTO 当可无损写回的来源。合法 metadata 继续通过 codec 保留 Gallery 和 extension data,不修改 Core 读端既有策略。 + +验证:ProjectFileTransactionTests.InvalidOptionalMetadataCannotBeSilentlyReplacedByAuthoring 使用 gallery 为字符串且包含 custom 对象的真实 sidecar,改名返回失败,源文件、原 metadata 字节不变,目标不存在。P4a Shared 回归已通过。 + +适用范围:共享容错 codec 被 Editor 作者写入、迁移或导入工具复用时。禁止仅凭 Deserialize 返回对象就声称数据可无损往返;不能为通过测试静默删掉原字段。 diff --git a/.agents/lessons/L-20261017-03-project-watcher-owner.md b/.agents/lessons/L-20261017-03-project-watcher-owner.md new file mode 100644 index 00000000..0bd801df --- /dev/null +++ b/.agents/lessons/L-20261017-03-project-watcher-owner.md @@ -0,0 +1,15 @@ +# 项目文件监听器必须持有所属项目的身份 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P4b + +现象:第一轮 P4b 测试在项目关闭后被 FileSystemWatcher 回调中的 NullReferenceException 终止,日志为 p4b-initial.log/trx。回调通过 Index.Root 再读取全局 IProjectService.Current.RootPath,而关闭流程已经清空 Current。 + +根因:项目 scope 及其后台回调的释放晚于全局当前项目切换。延迟事件不能假设全局 Current 仍是创建监听器的项目;相同问题还可能把旧项目事件投递给新项目。 + +正确做法:项目 index 在创建时捕获固定根路径;回调先检查 disposed,只使用该固定路径。投递到 UI 后再次核对当前项目身份和 disposed,再更新列表。停止 watcher/timer 后仍需防范已经排队的回调。 + +验证:修正后 P4b-cases-fixed 的 13 项包括连续创建/关闭项目和真实挂载的文件面板全部通过,未捕获并吞掉原始 NullReferenceException;测试主机不再退出。 + +适用范围:项目级 watcher、异步扫描、thumbnail 和延迟刷新。禁止让后台回调通过全局 Current 动态决定它的所属根目录。 diff --git a/.agents/lessons/L-20261017-05-scoped-binding-unsubscribe.md b/.agents/lessons/L-20261017-05-scoped-binding-unsubscribe.md new file mode 100644 index 00000000..905a93c5 --- /dev/null +++ b/.agents/lessons/L-20261017-05-scoped-binding-unsubscribe.md @@ -0,0 +1,15 @@ +# Scoped binding 的订阅必须只退订一次 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P6a + +现象:DtoEditorTests 首轮 27/29 通过,移除一个 DTO 后订阅计数为 -1,失败工厂与正常项共享测试 binding 时计数为 0 而非 1;证据 p4b-validation/dto-ui-focused.trx。 + +根因:字段 FieldEditorContext.Dispose 先从 scoped binding 移除订阅,随后 DtoEditorContext.Dispose 再释放 scoped binding,两条释放路径均无条件向借用 source 退订。普通 CLR event 的重复 remove 看似无害,但拥有自定义事件访问器的宿主可能按订阅计数维护资源。 + +正确做法:包装器显式记录是否实际订阅 source。最后一个字段订阅移除和包装器 Dispose 统一走幂等 Unsubscribe;只移除自己建立的 source 事件,不释放借用 binding。上下文释放保留异常并继续清理其他字段/控件。 + +验证:修正后共享聚焦 29/29 通过;重复类型重排、移除、创建失败与窗口卸载均检查订阅数量。新增 release failure 用例继续验证其他项仍被释放。 + +适用:多层 binding/事件转发包装器、控件 lease、并行存在的多次释放入口。禁止把重复 event remove 无异常当作生命周期正确性的证据。 \ No newline at end of file diff --git a/.agents/lessons/L-20261017-06-listbox-scrollable-tail.md b/.agents/lessons/L-20261017-06-listbox-scrollable-tail.md new file mode 100644 index 00000000..aed245dd --- /dev/null +++ b/.agents/lessons/L-20261017-06-listbox-scrollable-tail.md @@ -0,0 +1,13 @@ +# 可滚动尾部留白必须验证满视口并使用主题公开入口 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P5a + +现象:短列表背景取消选择测试通过,但完整回归中的长列表断言显示最后一项与视口底部间距为 0,而非预期约 64 DIP。首轮 Editor 140/141 通过,失败为 GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor,当时日志见 phase-5a.md;同名 editor.trx 已被最终成功回归覆盖。 + +根因与验证:初版通过普通样式设置 ItemsPresenter.Margin,没有在所用 Semi 模板下获得实际尾部留白。该模板将 ListBox.Padding 绑定到 ItemsPresenter.Margin;改用 ListBox.Padding 后,不更换 ItemsPanel、不添加伪条目,超过视口时可滚动到最后一项之后的空白,group-tail-fixed.trx 通过。 + +正确做法:使用主题公开的 Padding 入口,并在项目实际主题下检查滚动到最大 Offset 后最后容器的相对位置。验证列表应超过 Viewport;短列表天然存在的空白不能证明尾部空间有效。不要为留白移除虚拟化或把假 Entry 放进作者集合。 + +公开模板参考:https://github.com/irihitech/Semi.Avalonia/blob/main/src/Semi.Avalonia/Controls/ListBox.axaml 。参考源码不代替所安装版本的实际控件测试。适用范围:列表尾部插入区域、背景取消选择、虚拟化列表的 margin/padding 调整。 \ No newline at end of file diff --git a/.agents/lessons/L-20261017-07-observable-batch-compensation.md b/.agents/lessons/L-20261017-07-observable-batch-compensation.md new file mode 100644 index 00000000..9a1b8711 --- /dev/null +++ b/.agents/lessons/L-20261017-07-observable-batch-compensation.md @@ -0,0 +1,11 @@ +# 可观察集合批量历史要按实际状态补偿 + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P5b + +发现方式:复核 GroupEntryAuthoringService 的 PresenceEdit/OrderEdit。ObservableCollection 会先修改集合再发通知;观察者抛错时,抛错的当前 Insert/Remove/Move 可能已经生效。只统计“正常返回的操作数”再逆向恢复,会漏掉当前一步,导致历史未追加或游标未移动、内容却已变化。 + +正确做法:批次开始记录受影响集合的实际顺序与对象身份;异常时对照真实集合恢复缺失项、移除本次新增项并恢复顺序/序号。恢复失败抛 HistoryRepairRequiredException,交由项目历史阻止后续写入。不能捕获异常后静默返回成功,也不能仅依赖 CompositeEdit 补偿已经正常返回的子步骤。 + +验证入口:EntryBatchFailuresRestoreContentAndHistoryOrRequireRepair,覆盖新增/删除/移动、Undo/Redo 失败时状态与游标,以及恢复再失败时项目 RepairRequired。p5b-failure.trx 的故障注入测试已通过,覆盖全部上述路径;适用于会触发同步通知的集合型历史。 diff --git a/.agents/lessons/L-20261017-08-author-metadata-strict-shape.md b/.agents/lessons/L-20261017-08-author-metadata-strict-shape.md new file mode 100644 index 00000000..fe8a7790 --- /dev/null +++ b/.agents/lessons/L-20261017-08-author-metadata-strict-shape.md @@ -0,0 +1,11 @@ +# 作者元数据扩展要同步严格 Raw shape + +状态:confirmed +日期:2026-10-17 +来源:F-20261015-02-editor-authoring-workspace / P5c + +现象:ProjectVariableDefinition 新增 description 后,面板与内存历史可编辑,但保存测试 3/4:Graph/saveVariables/0/description 报 json.fieldUnknown。新增 CLR/JsonPropertyName 和 Clone 不代表作者写入协议已经接通。 + +根因:ProjectValidationService 独立严格检查 Raw Graph 声明的字段白名单;作者保存先经过此规则,而非只执行类型化序列化。正确做法是在唯一声明 shape 明确允许新元数据并验证其类型,同时同步中立模型克隆、正式格式说明和保存重开测试。不能绕过保存校验、宽泛允许未知字段或在保存时删掉新字段。 + +证据:p5c-focused.trx 的 description 失败;修正后 p5c-focused-fixed/final.trx 与完整 611 项回归通过。VariableDescriptionIsOptionalTextAndDoesNotRelaxUnknownFields 覆盖字符串/数字/null,变量面板测试覆盖说明及 UID 保存重开。适用范围:给作者 DTO/Graph/Group/settings 增加由 Editor 写出的正式字段时。 diff --git a/.agents/lessons/L-20261018-01-entry-binding-revival.md b/.agents/lessons/L-20261018-01-entry-binding-revival.md new file mode 100644 index 00000000..7499fa75 --- /dev/null +++ b/.agents/lessons/L-20261018-01-entry-binding-revival.md @@ -0,0 +1,13 @@ +# Entry 撤销不能复活旧控件的提交权 + +状态:candidate +日期:2026-10-18 +来源:F-20261015-02-editor-authoring-workspace / P5d + +迁移审查发现,仅按 Entry 存在性和 type ID 校验旧字段不能区分“当前绑定”与“删除/换类型后又撤销恢复的旧绑定”。稳定 Entry ID 应跨撤销保持,但控件绑定的生命周期不能因此恢复。 + +实现采用参数表单 Dispose、字段集合成员身份及专属控件 schema generation:条目移除/换类型/页面关闭使现有绑定永久失效;撤销恢复作者数据时重新创建绑定。项目历史只保留稳定 ID 和作者值,不保留页面。禁止用“条目又出现了/类型又相同了”作为恢复旧控件提交权的条件。 + +EntryBindingsPreserveAuthorJsonAndRejectRemovedAndReplacedSchemas 验证换类型、删除及各自撤销后旧字段提交仍返回 obsolete,当前绑定正常读取作者内容;跨页面挂载测试验证页面关闭后旧表单拒绝提交,项目 Undo/Redo 和保存重开仍有效。p5d-binding-focused.trx 的 4 项均通过;本条是审查发现后的防护回归,未记录修复前失败运行,因此保留 candidate,不宣称先前生产环境已复现。 + +适用:异步 DTO/字段编辑器、资源选择器、页面重建和撤销恢复。相关规则见 docs/spec/editor-groups.md。 diff --git a/.agents/lessons/L-20261018-02-fixture-service-policy.md b/.agents/lessons/L-20261018-02-fixture-service-policy.md new file mode 100644 index 00000000..3f99c5b2 --- /dev/null +++ b/.agents/lessons/L-20261018-02-fixture-service-policy.md @@ -0,0 +1,15 @@ +# 集成 fixture 不应覆盖要验证的生产服务策略 + +状态:confirmed +日期:2026-10-18 +来源:F-20261015-02-editor-authoring-workspace / P5e + +现象:GUI 已注册 Authoring 仓库,但 AuthoringDraftsReopenInMountedGroupAndKeepOpaqueValuesThroughEditsCopyUndoAndSave 首次仍在 SerializeEntry 抛 Unknown parameter 'future'。堆栈经 ProjectContentSession.SaveAsync,容易误以为生产 DI 没有生效。 + +根因:公共 Services() fixture 在 AddEditorServices() 后无条件覆盖 IEditorDocumentRepository,手工 new 默认 Complete 仓库;这个覆盖原本只是为了支持文件故障注入,却改变了所有普通用例的生产策略。 + +修正:没有故障事务时直接保留生产注册;确需注入故障时仅包装明确 Authoring 的仓库。禁止为了让集成测试通过而全局把 CLI 默认模式改为 Authoring,或让所有测试另行手工拼装“等效”生产策略。 + +验证:修改 fixture 后同一真实 GUI 草稿保存、重开、字段编辑、Entry/Graph 复制和 Undo/Redo 用例通过;p5e-editor-focused.trx 为修复后 2/2,首次失败堆栈保留在本轮工具输出及 phase-5e 记录。 + +适用:已有 DI 集成 fixture、新增服务策略、临时 fault decorator、production 注册回归。关联 docs/spec/editor-groups.md 与 phase-5e.md。 diff --git a/.agents/lessons/L-20261018-03-derived-control-theme-key.md b/.agents/lessons/L-20261018-03-derived-control-theme-key.md new file mode 100644 index 00000000..4e65041c --- /dev/null +++ b/.agents/lessons/L-20261018-03-derived-control-theme-key.md @@ -0,0 +1,17 @@ +# 派生主题控件需验证实际模板入口 + +状态:confirmed +日期:2026-10-18 +来源:F-20261015-02-editor-authoring-workspace / P6b + +现象:StructuredFieldEditor 继承 Expander,测试直接设置 IsExpanded 后能编辑和保存,但实际 Semi 截图缺少标题和折叠按钮,用户无法从界面展开。已有业务状态和子控件测试全部通过,未覆盖主题模板入口。 + +根因:自定义派生控件默认使用自身类型作为样式键,而宿主只有标准 Expander 的主题。直接设置属性绕过了缺失的交互模板。 + +正确做法:复用基类主题时显式覆盖 StyleKeyOverride 为 typeof(Expander);真实挂载后检查模板内的 ToggleButton 存在,并检查截图。自定义独立皮肤则由宿主提供对应类型的完整主题。 + +补充:覆盖样式键同时影响类型选择器。首轮完整 Editor 回归 155 通过、1 失败:forms|StructuredFieldEditor 不再匹配,四语文案停在英文默认值。宿主改用 Expander.structured-field 类选择器,并以带 owner 的属性名设置自定义 StyledProperty;需要同时验证主题按钮和本地化刷新。 + +适用:派生 Expander/Button 等模板控件。禁止仅凭属性赋值后业务结果正确,就声称用户能够触发该功能。 + +证据:StructuredGroupFormTests 检查实际主题 ToggleButton;p6b-structured-group.png 检查展开/收起入口和字段内容。 diff --git a/.agents/lessons/L-20261018-04-query-scope-disposal.md b/.agents/lessons/L-20261018-04-query-scope-disposal.md new file mode 100644 index 00000000..fefc9d45 --- /dev/null +++ b/.agents/lessons/L-20261018-04-query-scope-disposal.md @@ -0,0 +1,13 @@ +# 查询上下文须随项目 scope 失效 + +状态:confirmed +日期:2026-10-18 +来源:F-20261015-02-editor-authoring-workspace / P6e2b + +现象:SceneQueryIsPositionBasedAndSelectionsRejectChangedOrClosedProjects 在 CloseAsync 完成后,旧选择仍能解析 handle,首轮新增回归稳定复现。早期 locator-focused.log 已被后续重跑覆盖;保留用例作为关闭/过期行为的回归,最终结果见 timeline-validation/locator-GalNet.Editor.Tests.trx。 + +根因:ProjectService 关闭时释放项目 scope,EditorDocumentService 对象仍可能被旧控件或局部变量引用,IsLoaded 不等于项目仍处于活动状态。原查询服务仅检查 IsLoaded 和内容版本,没有自己的释放状态。 + +正确做法:项目 scope 的查询服务实现幂等 IDisposable,释放时清除分析缓存并推进查询代次;旧选择按捕获代次拒绝解析。查询服务不拥有控件,也不应通过主动卸载其他服务来弥补自己的生命周期。 + +适用:持有不可变分析快照、选择候选或异步返回结果的项目服务。禁止仅以源对象尚可读取或 IsLoaded=true 作为会话仍有效的证据。新增测试同时验证保存相同内容不换代、作者修改/撤销换代、关闭项目使旧选择失效。 diff --git a/.agents/lessons/L-20261019-01-template-ancestor-type-resolution.md b/.agents/lessons/L-20261019-01-template-ancestor-type-resolution.md new file mode 100644 index 00000000..e28a5b64 --- /dev/null +++ b/.agents/lessons/L-20261019-01-template-ancestor-type-resolution.md @@ -0,0 +1,15 @@ +# 延迟模板中的祖先类型需要显式解析 + +状态:confirmed +日期:2026-10-19 +来源:F-20261015-02-editor-authoring-workspace / Group 属性编辑器改造 + +现象:Group 字段 DataTemplate 中的反射绑定 `$parent[forms:SchemaForm].ShowLabels` 可以通过 XAML 编译,但实际挂载时抛出 `Unable to resolve type forms:SchemaForm`,整个参数区无法生成。三个真实 Group 控件测试同时失败,单独 FieldEditorContext 测试通过。 + +根因:该延迟模板的运行期 ReflectionBinding 类型解析上下文不能解析这段表达式的命名空间别名。构建成功不能证明运行时绑定可解析。 + +修正:用 `ShowLabels, RelativeSource={RelativeSource AncestorType={x:Type forms:SchemaForm}}`,由 XAML 显式解析类型。修正后五项重点测试以及完整 Editor 181 项回归通过,包含宽窄截图、嵌套对象/列表与 Timeline 挂载。 + +适用:带命名空间祖先类型的延迟 DataTemplate/ControlTemplate 反射绑定。不要凭编译结果宣称模板可用;需要真实主题挂载、交互与截图。此结论针对已复现表达式,不声称所有 `$parent` 语法都不可用。 + +证据:features/F-20261015-02-editor-authoring-workspace/phase-group-properties.md;Temp/group-properties/group-properties-focused.trx、GalNet.Editor.Tests.verified.trx。 diff --git a/.agents/lessons/L-20261019-02-timeline-exact-key-frame.md b/.agents/lessons/L-20261019-02-timeline-exact-key-frame.md new file mode 100644 index 00000000..78f5343d --- /dev/null +++ b/.agents/lessons/L-20261019-02-timeline-exact-key-frame.md @@ -0,0 +1,17 @@ +# 曲线采样需覆盖精确关键帧边界 + +状态:confirmed +日期:2026-10-19 +来源:F-20261015-02-editor-authoring-workspace / P6e3b + +现象:Step 段之后的中间关键帧,采样恰好落在其帧号时仍返回前一键值;第 0 帧、末键及区间中点原测试均通过。内联值曲线的键位置与运行时该时刻值可能不同。 + +根因:TimelineSampler 查找区间使用 next.Frame < frame,把内部键的精确时刻归给前段。Linear/Hermite 在末端返回下一键值,掩盖了问题;Step 保持前值而暴露错误。 + +发现与复现:新增 0→10→20 三键测试,首段 Step;Evaluate(9.999)=0,Evaluate(10) 应为 1、实际为 0,基线失败日志和 TRX 为 timeline-validation/inline-curve-step-before-fix.*。修正查找为 <=,精确内部键归入它自己的出段,末键仍由已有末端分支处理。 + +正确做法:动画/曲线采样回归同时覆盖精确内部键、键前后分数时刻、首末键和不同插值组合;Editor 绘制直接复用中立采样器,不在 UI 加偏移来绕过错误。 + +适用范围:时间区间归属、阶梯/离散插值、曲线与 Runtime 一致性。禁止仅测试区间中点或末键便宣称边界一致。 + +关联:features/F-20261015-02-editor-authoring-workspace/phase-6e3.md;docs/spec/editor-groups.md。 diff --git a/.agents/lessons/L-20261019-03-isolated-contract-artifacts.md b/.agents/lessons/L-20261019-03-isolated-contract-artifacts.md new file mode 100644 index 00000000..405bb3e3 --- /dev/null +++ b/.agents/lessons/L-20261019-03-isolated-contract-artifacts.md @@ -0,0 +1,15 @@ +# 全新隔离源码需补齐固定路径契约产物 + +状态:confirmed +日期:2026-10-19 +来源:F-20261015-02-editor-authoring-workspace / P6e3b + +现象:在 HEAD 加本轮改动的全新源码快照中,离线 restore 成功,使用 --artifacts-path 构建时 Editor Copy 阶段 MSB3030,缺少 Editor.DefaultContracts/bin/Debug/net10.0/GalNet.Project.Contracts.dll;主工作树同参数构建此前成功。 + +根因:Editor.csproj 的 Content 固定读取默认 bin 路径,而 --artifacts-path 将项目输出移至独立 artifacts/bin/项目/debug。已有工作树残留的普通 bin 产物掩盖了这一前置条件。Editor 的 Contracts()/Deploy() 测试夹具也按源码树 test/Fixtures/*/bin/Debug/net10.0 定位。 + +发现方式:inline-curve-isolated-build.log 在全新快照中复现;不修改业务项目文件,将该快照本次构建产物补到它自身预期的默认契约与 fixture bin 位置后,inline-curve-isolated-build-2.log 构建通过。 + +正确做法:在验证脚本中明确准备此类固定路径前置产物,且必须来自同一源码快照;或另立构建修正任务消除硬编码。不要把主工作树不明版本 DLL 复制过来,也不要把有旧 bin 的 --artifacts-path 构建当作干净构建证据。临时快照包含重复 csproj 时放在忽略目录并在验证后清理,避免架构测试递归扫描测试根目录时误识别重复项目。 + +适用:全新源码快照、多个输出根、动态 schema 测试夹具;不代表新建 Editor 项目需要用户准备 DLL,Editor 仍自带默认契约。 diff --git a/.agents/lessons/L-20261019-04-file-document-path-reuse.md b/.agents/lessons/L-20261019-04-file-document-path-reuse.md new file mode 100644 index 00000000..0e30f67e --- /dev/null +++ b/.agents/lessons/L-20261019-04-file-document-path-reuse.md @@ -0,0 +1,25 @@ +# 文件路径复用与保留的删除身份 + +状态:confirmed +日期:2026-10-19 +来源:F-20261019-01-editor-game-localization / P1 + +## 现象与根因 + +ProjectContentSession.FileDocuments 按会话 ID 持有文件。删除仅将 Exists 设为 false,旧记录为撤销/恢复保留;TrackFile 同一路径时只复用仍存在的记录,否则分配新 ID。因此不能假设所有 FileDocument.Path 唯一,直接 ToDictionary(file => file.Path) 可能在删除后重建时抛异常。 + +## 发现方式与修正 + +复核 TrackFile 实现发现该边界;LocalizationReferenceIndexTests.ProjectQuerySeesUnsavedManagedFilesAndDisposalInvalidatesCachedSnapshots 增加删除后同路径 TrackFile 的回归断言。ProjectLocalizationQuery 按平台路径比较器分组,优先选有效记录;无有效记录时保留删除遮罩,不能重新读出磁盘旧文件。多个有效身份同时拥有路径时报告分析不完整。 + +## 适用范围与禁止事项 + +适用于作者文件查询、结构化文件编辑器及任何消费 FileDocuments 的服务。不要按路径删除历史身份,不要用删除记录遮蔽新有效文档,也不要忽略删除遮罩并重新读取未保存删除的磁盘内容。长期身份按文件 ID,路径只用于定位当前内容。 + +关联:docs/spec/editor-history-and-save.md;docs/spec/editor-game-localization.md。 + +## 2026-10-20 P2:保存期望也必须按有效路径身份归并 + +新增 LocalizationP2ReplacingAnUndoneNewFileAtTheSamePathCanBeSaved 在修正前稳定失败:第二份同路径文档已创建,但 CheckTrackedFiles 仍把旧删除记录解释为路径必须不存在,报告 files.changed。p2-pre-fix.trx 为 10 通过、1 失败。 + +ProjectContentSession.CheckTrackedFiles 与 TrackedExpectations 改为先按平台路径比较器归并,优先唯一有效身份,再检查 ManagedContent;不能先筛选 managed 记录,否则可能跳过后来接管该路径的有效非文本身份。历史删除记录继续保留,多个有效身份仍拒绝。 diff --git a/.agents/lessons/L-20261020-01-schema-form-host-style.md b/.agents/lessons/L-20261020-01-schema-form-host-style.md new file mode 100644 index 00000000..69c6ac62 --- /dev/null +++ b/.agents/lessons/L-20261020-01-schema-form-host-style.md @@ -0,0 +1,15 @@ +# DTO 表单跨宿主时必须显式提供展示样式 + +状态:confirmed +日期:2026-10-20 +来源:F-20261015-02-editor-authoring-workspace / P6e4b + +现象:Timeline 事件弹窗的 DtoEditorHost 构建成功、Diagnostics 为空,但找不到 TransformX,实际表单没有字段。真实挂载测试 events-b-focused.trx 复现,只有模型/控件构造检查不能发现。 + +根因:SchemaForm 是需要宿主模板的共享 TemplatedControl;Editor 原先把模板和字段操作样式限定在 EntryListEditorControl.Styles 内。新的 Timeline Flyout 不在该样式作用域中。DtoEditorHost 的诊断不等于子 SchemaForm 已取得模板或没有 fallback 诊断。 + +修正:把 Editor 表单样式抽到 Controls/AuthorFormStyles.axaml,由 Group 和事件弹窗显式引用;共享控件继续不依赖 Editor 外观。字段注册仍使用同一个 EditorRegistry,事件所需资产字段注册复用 AssetPicker。 + +验证:真实 popup 中取得 TransformX 并编辑、应用单历史、检查子 SchemaForm.Diagnostics;十种内置事件均实际挂载,检查资产选择、事件前场景选择及隐藏输出身份。events-b-form-focused.trx 与 events-b-all-types.trx 通过;保留首轮失败日志。 + +适用:把 DtoEditorHost / SchemaForm 接到 Dock、Flyout 或新窗口时。禁止仅凭 Host.Diagnostics 为空或编译成功宣称表单可用;验证模板、实际字段、子诊断和提交链路。不要为复用样式引入共享 UI 对 Editor 的依赖。 diff --git a/.agents/lessons/L-20261020-02-nested-semantic-wire-name.md b/.agents/lessons/L-20261020-02-nested-semantic-wire-name.md new file mode 100644 index 00000000..998e526a --- /dev/null +++ b/.agents/lessons/L-20261020-02-nested-semantic-wire-name.md @@ -0,0 +1,23 @@ +# 嵌套语义引用使用实际 JSON wire 名 + +状态:confirmed +日期:2026-10-20 +来源:F-20261019-01-editor-game-localization / P3 + +## 现象与根因 + +FieldDescriptor.Id 是 DTO schema 的规范名称(生成器通常采用 camelCase),不保证等于嵌套 DTO 在作者 JSON 中的属性名。ValueDescriptor 的 JsonOptions 默认保持 PascalCase,也可能指定命名策略或 JsonPropertyName。仅以 field.Id 访问 JSON 会漏掉实际引用并误报未知字段。 + +## 修正与证据 + +LocalizationReferenceIndex 按 Field 标记关联 CLR member,再从该值契约的 JsonTypeInfo 取得实际 wire 名;纯手写 schema 没有 CLR member 时保留 field.Id fallback。导航 JSON Pointer 使用实际 wire 名,并按 JSON Pointer 规则转义。 + +LocalizationReferenceIndexTests.NestedRoleFieldsUseActualSerializerWireNames 分别验证默认 PascalCase 与显式 camelCase,两项通过;CharacterNestedListFieldUsesSemanticEditorAndRenameUpdatesStoredWirePath 真实挂载列表内 speaker 输入框,验证创建和角色改 key 更新 /items/0/Speaker,撤销恢复。 + +## 适用范围 + +本地化、角色、变量或资产等需要遍历嵌套 DTO 的索引和重写器。不要用显示标签或规范 schema 名猜测作者 JSON 路径;不要把未知结构当作无引用。相关共享结构化表单重建 FieldDescriptor 时也须保留 Semantic。 + +关联:docs/spec/editor-game-localization.md;features/F-20261019-01-editor-game-localization/phase-3.md。 + +多层 DTO 同样必须沿实际外层 JsonOptions 递归,不在每个子字段恢复其默认配置;否则外层 camelCase 的第二层会再次被当成 PascalCase。NestedWireOptionsPropagateThroughMultipleDtoLevels 两种策略用例通过,最终 Shared 264 项全通过。 diff --git a/.agents/lessons/L-20261020-03-deferred-text-change-is-not-user-edit.md b/.agents/lessons/L-20261020-03-deferred-text-change-is-not-user-edit.md new file mode 100644 index 00000000..d34518e5 --- /dev/null +++ b/.agents/lessons/L-20261020-03-deferred-text-change-is-not-user-edit.md @@ -0,0 +1,21 @@ +# 程序化 TextBox 刷新不能通过延迟 TextChanged 标记草稿 + +状态:confirmed +日期:2026-10-20 +来源:F-20261019-01-editor-game-localization / P3 + +## 现象和证据 + +首次完整 Editor 回归中 AuthoringDraftsReopenInMountedGroupAndKeepOpaqueValuesThroughEditsCopyUndoAndSave 失败:旧项目 speaker 保存的是待修复 JSON 对象,仅挂载页面并修改另一字段就变成字符串。失败记录为 Temp/localization-p3-GalNet.Editor.Tests.log;修正后的完整重跑 Temp/localization-p3-GalNet.Editor.Tests-final.log 中该项通过,210 项为 208 通过和 2 项已记录布局失败。 + +## 根因与修正 + +Avalonia TextBox.TextChanged 延迟派发。Refresh 在 updating=true 内赋 Text,finally 解除标志,随后事件才到达并被误判为用户输入;输入未聚焦又触发失焦提交,导致未主动编辑的作者值被覆盖。 + +CharacterFieldEditor 改订阅同步的 TextBox.TextProperty PropertyChanged,并在同一 updating guard 内忽略程序化赋值。异常非字符串 JSON 显示原始 JSON 文本但不自动提交;普通字段和姓名刷新也不再生成草稿。新角色提交成功后主动刷新候选缓存,避免提交期间因 context.IsEnabled=false 跳过来源通知而继续显示 raw ID。 + +## 适用范围 + +任何把输入框变更自动映射为草稿、失焦保存或创建记录的自定义编辑器。禁止用只覆盖属性赋值瞬间的 bool guard 假定延迟事件已被抑制;必须用同步属性事件或明确版本/期望值判定,并通过真实挂载、旧异常值和编辑其他字段的测试验证不会无意写入。 + +关联:docs/spec/editor-game-localization.md;features/F-20261019-01-editor-game-localization/phase-3.md。 diff --git a/.agents/lessons/L-20261020-04-composite-analysis-publication.md b/.agents/lessons/L-20261020-04-composite-analysis-publication.md new file mode 100644 index 00000000..2202d2ac --- /dev/null +++ b/.agents/lessons/L-20261020-04-composite-analysis-publication.md @@ -0,0 +1,18 @@ +# 复合条目不能发布容错分析的半份结果 + +状态:confirmed +日期:2026-10-20 +来源:F-20261015-02-editor-authoring-workspace / P6e4c2 +关联:[阶段记录](../../features/F-20261015-02-editor-authoring-workspace/phase-6e4.md) + +现象:转场经纯编译展开后复用 Timeline 分析。新层 transform.x 为字符串时,五种转场的查询把旧层移除,未保留保守输入;Slide 在编译期拒绝,所以不受同一条路径影响。 + +根因:Timeline 事件分析逐项捕获错误、标记不完整并继续,适合展示部分作者事实。转场直接复用该结果后,新层创建失败,后续隐藏旧层仍然生效。无异常返回不等于整个复合分析成功。 + +发现与复现:新增六种 MalformedTransitionTransformCannotApplyOnlyTheOutgoingDeletion 用例,在修复前 5 失败/1 通过。证据位于 timeline-validation/transitions-c2-malformed-before.trx 和同名前缀日志;失败明确显示预期 old,但结果为空。 + +正确做法:在临时事实状态中分析整个复合展开,单独跟踪本次分析完整性,成功后才发布;局部失败时保留原输入并按既有规则降为可能,不能丢掉原输入。上游本就不完整的标记仍须保留,不能因本次局部成功而提升全局完整性。 + +适用:复合条目、宏或其他批量语义调用逐项容错的分析器。禁止仅以函数未抛异常判断整批分析成功,或直接重置正式状态的 Complete 来掩盖上游不确定性。后续验证还应覆盖局部失败和上游不完整但本次成功两条路径。 + +修正验证:transitions-c2-atomic-focused.trx 中 55 项场景分析用例全部通过,包含六种坏 Transform 的整批拒绝和上游不完整标记保留。正式候选仍需连同实际 Editor 控件完成阶段回归,结果以 feature 记录为准。 diff --git a/.agents/lessons/L-20261020-05-text-history-reserved-number.md b/.agents/lessons/L-20261020-05-text-history-reserved-number.md new file mode 100644 index 00000000..f2207d17 --- /dev/null +++ b/.agents/lessons/L-20261020-05-text-history-reserved-number.md @@ -0,0 +1,13 @@ +# 文本历史不能回退不可撤销的角色编号 + +状态:confirmed +日期:2026-10-20 +来源:F-20261019-01-editor-game-localization / P4 + +原文复核状态和 characterHighWater 同在 Graph/localization-authoring.json。编号预留直接更新 Working/Saved,故创建角色再撤销仍保留更高编号;文本历史若按整个 metadata 字节检查/恢复,会拒绝更早文本的撤销,或把编号写回旧值。 + +LocalizationEditOperation 对该文件仅允许 characterHighWater 单调重基:期望比较忽略这一字段,实际值不能低于期望值;应用文本 before/after 时保留当前更高编号。其他 metadata 字段仍必须相等,不能把例外泛化成整文件盲覆盖。编号未变时返回原目标字节,避免仅因格式变化产生 dirty。 + +GameTextUndoPreservesRoleNumberReservationsAndEmptyTextIsDistinctFromMissing 验证文本→角色→撤销角色→撤销文本→重做文本→创建角色不重用编号;初始定向通过。适用于把不可撤销预留与可撤销编辑存入同一作者文档的场景。相关规范:docs/spec/editor-game-localization.md;过程:features/F-20261019-01-editor-game-localization/phase-4.md。 + +P6 补充(2026-10-21):同一文档新增不可撤销的 exchangeProjectId 预留。旧文本历史的期望缺少该字段时,可重基到已经初始化的合法 GUID,应用 before/after 必须保留当前 GUID;期望已有 GUID 时仍严格匹配,不能将两种不同身份视为等价。仅同样保护明确的不可撤销预留,不放宽其他 metadata。交换用例验证首次模板导出后旧文本操作仍能撤销/重做,身份持久保留,其他 dirty 内容不会被导出顺带保存。 diff --git a/.agents/lessons/L-20261020-06-multiline-commit-tunnel.md b/.agents/lessons/L-20261020-06-multiline-commit-tunnel.md new file mode 100644 index 00000000..a5b63268 --- /dev/null +++ b/.agents/lessons/L-20261020-06-multiline-commit-tunnel.md @@ -0,0 +1,11 @@ +# 多行 TextBox 提交快捷键在 Tunnel 处理 + +状态:confirmed +日期:2026-10-20 +来源:F-20261019-01-editor-game-localization / P4 + +首轮 P4 定向中后端文本/复制测试通过,但两个真实挂载的多行原文输入用例无法提交。TextBox 的类处理会先消费 Enter 类按键,普通 KeyDown 订阅不能可靠收到 Ctrl+Enter。 + +GameTextFieldEditor 使用 InputElement.KeyDownEvent 的 RoutingStrategies.Tunnel,在 TextBox 默认插入换行前处理 Ctrl+Enter;普通 Enter 保留多行输入,单行 Enter 提交,组合态不提交。之后相关 27 项及 29 项定向通过。不要仅调用后端提交或手动设置 VM 来替代多行输入控件的实际 routed input 验证。 + +关联:features/F-20261019-01-editor-game-localization/phase-4.md;docs/spec/editor-game-localization.md。 diff --git a/.agents/lessons/L-20261021-01-virtualized-null-content.md b/.agents/lessons/L-20261021-01-virtualized-null-content.md new file mode 100644 index 00000000..6c6c9777 --- /dev/null +++ b/.agents/lessons/L-20261021-01-virtualized-null-content.md @@ -0,0 +1,11 @@ +# 虚拟化行模板允许空内容 + +状态:confirmed +日期:2026-10-21 +来源:F-20261019-01-editor-game-localization / P5 + +真实 ListBox 滚动测试在窗口关闭时于 GameLocalizationPanelView.CreateRow 发生空引用。FuncDataTemplate 的声明类型不能保证传入内容非 null:虚拟化容器解绑/回收时会使用空内容刷新 ContentPresenter,直接 row! 后访问属性会使关闭或滚动抛错。 + +模板工厂对 null 返回空 Border,其余内容按正常行创建。181 行和 58 个分类的真实挂载测试验证只实现部分容器、滚动至最后文本并关闭窗口;修正后该用例通过。不要用全部预生成控件绕过虚拟化回收,也不要仅根据 rows 集合元素非空推断模板输入必非空。 + +关联:features/F-20261019-01-editor-game-localization/phase-5.md。 diff --git a/.agents/lessons/L-20261021-01-window-closed-after-scope-disposal.md b/.agents/lessons/L-20261021-01-window-closed-after-scope-disposal.md new file mode 100644 index 00000000..7668bf3a --- /dev/null +++ b/.agents/lessons/L-20261021-01-window-closed-after-scope-disposal.md @@ -0,0 +1,11 @@ +# Closed 回调可能晚于 DI scope 释放 + +状态:confirmed。 + +来源:F-20261021-03 外部调试的真实 Sample 子进程验收。连接/开始/变量/跳转/停止/重开均通过,正常关闭进程却以 0xC000041D 退出;stderr 指向 MainWindow.OnClosed 对已释放 IServiceProvider 的 GetRequiredService。 + +根因:App 的异步 Closing 先释放项目 scope,再二次调用 Close;原生窗口 Closed 随后才发生。Headless 不完整模拟此原生关闭顺序,构造/挂载成功不能证明最终退出正常。 + +正确做法:窗口所需实例在构造时捕获;窗口绑定提供幂等显式解绑,App 在 scope 释放前调用,Closed 再调用可安全返回。不要在 Closed/迟到的 UI 回调中重新解析可能已释放的 scope,也不要用忽略异常掩盖解绑遗漏。 + +验证方式:真实启动独立 Sample,完成一次开始/停止/重开,使用正常窗口关闭并断言退出码为零,而不是仅 Kill 测试进程。适用于异步 Closing、项目 scope 和窗口生命周期分离的宿主。 diff --git a/.agents/lessons/L-20261021-02-async-textbox-focus.md b/.agents/lessons/L-20261021-02-async-textbox-focus.md new file mode 100644 index 00000000..9a916a4c --- /dev/null +++ b/.agents/lessons/L-20261021-02-async-textbox-focus.md @@ -0,0 +1,11 @@ +# 异步提交用只读保护文本输入焦点 + +状态:confirmed +日期:2026-10-21 +来源:F-20261019-01-editor-game-localization / P5 + +i18n 表格测试首次 Ctrl+Enter 提交成功,接着编辑再移出焦点却不提交。提交期间 Busy 绑定到 TextBox.IsEnabled 会让仍在编辑的输入框立即丢失焦点;恢复启用不会恢复原焦点,后续不再有预期的失焦事件。 + +提交期间绑定 TextBox.IsReadOnly,禁止用户改写正在提交的值,同时维持控件启用和焦点。保留异步重入保护,提交完成后重新读取作者值;项目失效仍独立禁止写入。实际挂载 Ctrl+Enter→再输入→失焦提交在修正后通过,而不是在测试中额外 Focus 掩盖行为。 + +关联:features/F-20261019-01-editor-game-localization/phase-5.md。 diff --git a/.agents/lessons/L-20261021-03-project-owned-dock-drafts.md b/.agents/lessons/L-20261021-03-project-owned-dock-drafts.md new file mode 100644 index 00000000..7d60825e --- /dev/null +++ b/.agents/lessons/L-20261021-03-project-owned-dock-drafts.md @@ -0,0 +1,11 @@ +# 关闭 Dock 不释放项目持有的草稿模型 + +状态:confirmed +日期:2026-10-21 +来源:F-20261019-01-editor-game-localization / P5 + +手动将视图从窗口移除再重建能保留草稿,不代表真实 Dock 菜单关闭也正确。P5 的 Scoped ViewModel 在该路径测试中 IsDisposed 变为 true;EditorDockFactory.CloseDocument 对任意 IDisposable Context 执行 Dispose,绕过了项目 scope 的实际生命周期。 + +本地化 ViewModel 和草稿由项目 scope 持有,普通 CloseDocument 保留它,视图只解绑自己的订阅;项目关闭仍由 DI scope 释放 ViewModel 和草稿注册。不要在重开时返回已 Dispose 的 scoped 实例,也不要为了维持草稿而禁止项目释放。 + +真实菜单打开两次→输入待提交草稿→关闭→重开验证同一模型、IsDisposed=false、草稿仍在;最终 P5 定向 14 项通过。适用于需要跨面板关闭保留草稿的项目级工具。关联:features/F-20261019-01-editor-game-localization/phase-5.md。 diff --git a/.agents/lessons/L-20261021-04-history-admission-recheck.md b/.agents/lessons/L-20261021-04-history-admission-recheck.md new file mode 100644 index 00000000..9dc5e1d3 --- /dev/null +++ b/.agents/lessons/L-20261021-04-history-admission-recheck.md @@ -0,0 +1,11 @@ +# 入队检查不能代替历史执行时的检查 + +状态:confirmed +日期:2026-10-21 +来源:F-20261019-01-editor-game-localization / P5 + +异步历史通过 gate 串行执行,服务在调用 ExecuteAsync 前验证源值/预览仍可能过期:排在前面的语言切换或文本编辑会先改变状态。两个交错测试用 CallbackEdit 持有 gate,分别先排开发语言切换和先排译文修改;旧实现两个用例均未拒绝过期操作。 + +单元格在历史 RedoAsync 真正执行前检查开发语言、分类引用和原文基线,然后交给数据操作核对文件期望值。开发语言预览也在语言 edit 的 Redo 时重新验证证据。Undo 复原原值,Redo 重新核对提交前置条件;guard 只持有项目服务及数据,不持有控件。 + +修正后 i18n 定向 16 项通过,两个交错用例分别验证旧草稿不覆盖和旧预览不切换语言。不能只测试顺序 await 的正常路径,也不能靠忙碌 UI 按钮推断底层服务不会排队。关联:features/F-20261019-01-editor-game-localization/phase-5.md。 diff --git a/.agents/lessons/L-20261021-05-query-completeness-causes.md b/.agents/lessons/L-20261021-05-query-completeness-causes.md new file mode 100644 index 00000000..b00f22e9 --- /dev/null +++ b/.agents/lessons/L-20261021-05-query-completeness-causes.md @@ -0,0 +1,16 @@ +# 组合查询不能把能力缺失压成不可恢复的语义未知 + +状态:candidate +日期:2026-10-21 +来源:F-20261015-02-editor-authoring-workspace / P6e4c3b +关联:[阶段记录](../../features/F-20261015-02-editor-authoring-workspace/phase-6e4.md) + +现象:效果 metadata 缺失时,目标身份仍然可知,但能力信息不完整。如果 AtTimeline 从 Before 的扁平 Complete=false 重建内部状态,Timeline 中停止该效果后仍会残留不完整提示。 + +根因:对外 Complete 同时包含语义分析是否完成、当前目标的能力是否完整。前者必须保守传播,后者会随目标移除而消失。将两者合成的结果再次作为内部语义状态,会永久丢失原因。 + +发现方式:实现中的代码审查发现 Before → AtTimeline 重建路径;未留存修正前失败 TRX,因此保留 candidate。新增 StoppingAnIncompleteEffectInsideTimelineRecoversOnlyCapabilityCompleteness 同时验证移除缺声明效果恢复完整性、上游未知自定义条目继续不完整。最终验证证据见 timeline-validation/metadata-c3b-analysis-focused.trx 与对应阶段记录。 + +正确做法:组合内部查询时传递保留原因的内部状态,只在对外结果边界汇总 Complete。不要通过“当前存在能力不完整对象”推断所有 false 都可恢复。既验证可恢复路径,也验证不能抹掉上游语义未知。 + +适用:对象能力、资源准备状态等会随对象移除而消失的局部不完整事实,与不可逆的未知语义共同组成对外分析状态时。 diff --git a/.agents/lessons/L-20261021-06-architecture-project-discovery.md b/.agents/lessons/L-20261021-06-architecture-project-discovery.md new file mode 100644 index 00000000..940fa7d5 --- /dev/null +++ b/.agents/lessons/L-20261021-06-architecture-project-discovery.md @@ -0,0 +1,11 @@ +# 架构扫描必须排除嵌套构建输出 + +状态:confirmed +日期:2026-10-21 +来源:F-20261019-01-editor-game-localization / P6;提交 c7c97b7 + +架构测试递归枚举 test 下的 csproj,曾把 test/GalNet.Editor.Tests/bin/te4c3b 中的临时仓库副本也当作源码;15 项依赖断言因此报告同一测试项目出现两次。逐条检查失败项目路径,确认重复项均位于 bin 内。 + +扫描发现源码项目时须排除路径任一段为 bin/obj 的文件,复用现有 IsBuildOutput 规则;不能只排除项目根目录直接子级,也不能靠删除用户或其他任务的临时产物维持测试通过。修正规则后保留原副本,45 项架构测试全部通过。 + +适用于递归工程发现;不改变构建工具对显式指定项目的处理。过程见 features/F-20261019-01-editor-game-localization/phase-6.md。 diff --git a/.agents/lessons/L-20261021-07-author-parameter-wire-format.md b/.agents/lessons/L-20261021-07-author-parameter-wire-format.md new file mode 100644 index 00000000..c4dd74e3 --- /dev/null +++ b/.agents/lessons/L-20261021-07-author-parameter-wire-format.md @@ -0,0 +1,14 @@ +# 作者参数测试必须按 schema 的线格式编码 + +状态:confirmed +日期:2026-10-21 +来源:F-20261015-02-editor-authoring-workspace / P6e4c4 +关联:[阶段记录](../../features/F-20261015-02-editor-authoring-workspace/phase-6e4.md) + +现象:播放 Locator 首轮 99 项分析专项中 16 项失败,Animate 候选为空。测试 helper 用原生 JSON bool 构造顶层 blocking;AnimationOptions 的 StringBooleanValueConverter 按契约拒绝它,分析器捕获异常并退回保守状态。嵌套 TimelineDefinition 则确实使用 JSON bool,两者不能混用。 + +根因与发现:检查 AnimationOptions 的字段属性及 GalNet.Core.Parameters.StringValueConverters 后确认。CLR 属性 bool 不表示作者 wire value 必为 JSON bool;Field schema 及编码 converter 才决定序列化格式。首次失败证据保留于 timeline-validation/playback-c4-analysis-first.trx 和同名前缀 log。 + +正确做法:构造作者 Entry.Parameters 时使用该字段的 schema/编码;Animate 的 blocking/skippable 使用字符串 true/false,Timeline 根内相应字段使用 JSON bool。修正 helper 后保留原生 bool 输入的拒绝用例,并为所有有效夹具显式断言 Complete,避免“候选为空”偶然满足排除条件。 + +适用:手工 JSON fixture、DTO 字段到作者参数的投影、跨嵌套契约测试。禁止为了使错误夹具通过而拓宽生产 converter,或仅根据 CLR 类型猜测 JSON 线格式。 diff --git a/.agents/lessons/L-20261022-01-variable-bridge-original-key.md b/.agents/lessons/L-20261022-01-variable-bridge-original-key.md new file mode 100644 index 00000000..a304c4ae --- /dev/null +++ b/.agents/lessons/L-20261022-01-variable-bridge-original-key.md @@ -0,0 +1,9 @@ +# 变量回写保留桥接服务原键 + +状态:confirmed。来源:F-20261021-03 的实际 Sample 跨进程变量验收。 + +Runtime 的 VariableStore 将 player./save. 前缀归一化,但 PlayerVariableService 可以保留 `player.galnet.*` 系统键。加载进 Runtime 后再按归一化 name 回写,会在服务中生成第二个别名,旧系统键仍是旧值。仅查询 Runtime 显示新值不能证明重开/存储正确。 + +修正:已有变量按 UID 找到桥接服务的原键再通知,不改变已建立的存储身份;新变量沿用既有命名规则。不要直接给服务写一个猜测前缀,也不要只更新服务而漏掉当前 Runtime。 + +验证:FileVariableServiceTests 的恢复前缀变量修改后,服务只有一项、UID 不变、重新加载值正确;原生 Sample 修改 Player 值、停止、重开、正常退出并读取 profile。实际落盘还依赖宿主刷新,不能以 Runtime 新值代替持久化证据。 diff --git a/.agents/lessons/L-20261022-02-selector-preparation-generation.md b/.agents/lessons/L-20261022-02-selector-preparation-generation.md new file mode 100644 index 00000000..e3b85142 --- /dev/null +++ b/.agents/lessons/L-20261022-02-selector-preparation-generation.md @@ -0,0 +1,16 @@ +# 选择器准备完成不等于作者内容失效 + +状态:confirmed +日期:2026-10-22 +来源:F-20261021-02-image-variants / P4b + +现象:选择器首次打开时捕获场景查询 Revision,然后等待后台资源定义准备;准备完成正常推进 Revision,选择器自身变成过期,必须关闭重开才可选择。仅测试已预热的查询会漏掉问题。 + +根因:单一结果 Revision 同时表示作者内容/资源代次变化和同代异步准备完成。等待前后的结果版本不同,不足以证明原作者上下文失效;也不能在等待后无条件重建快照,否则会接受等待期间修改、撤销、刷新或关闭后的旧手势。 + +发现与验证:ImageVariantsSceneTests.ColdAtlasSelectorCanFinishPreparationButAnAuthorEditStillInvalidatesIt 用受控资源读取 Gate 验证冷启动等待后可解析;既有刷新、关闭与作者修改测试继续验证旧选择被拒绝。 + +正确做法:等待前保存不可变作者快照身份与资源代次。准备完成后,只有作者快照仍为同一对象、资源代次未变且 scope 有效时,才允许刷新结果快照;最终确认仍走当前 Revision 与 Locator 校验。Undo 回到相同 JSON 不复活旧快照身份。 + +适用:异步准备资源后再呈现候选的字段、Timeline 或类似选择 lease。 +禁止:无条件接受等待后的当前快照;只在预热后验证选择;仅因预期的后台准备推进结果版本而永久禁用本次选择。 diff --git a/.agents/lessons/L-20261023-01-debug-launch-before-logging.md b/.agents/lessons/L-20261023-01-debug-launch-before-logging.md new file mode 100644 index 00000000..fe2fbfb6 --- /dev/null +++ b/.agents/lessons/L-20261023-01-debug-launch-before-logging.md @@ -0,0 +1,11 @@ +# 启动日志之前的调试校验失败 + +- 状态:confirmed +- 来源:F-20261021-03-external-game-debugging,2026-10-23 启动失败复现。 +- 现象:Editor 启动 App 后一直等待连接,没有游戏日志。 +- 根因:Sample 在日志初始化前解析参数并校验实际 Contracts SHA256;项目默认契约与 Sample 契约不同,抛异常退出;Editor 原先立即释放 Process,未收集 stderr/退出码。 +- 发现方式:读取本地启动配置,对比两个实际 DLL 的哈希,以相同预期契约参数启动独立 Sample 进程,复现 InvalidDataException。 +- 正确做法:组合根顶层捕获日志初始化前的异常,输出 stderr 并以非零码退出;Editor 监控自己启动的进程,持续排空并限制保留的 stderr,按会话隔离错误,断开后取消监控而不杀进程。 +- 适用范围:外部 App 调试启动及其他先校验后初始化日志的 CLI/GUI 入口。 +- 禁止:将没有日志视为没有错误;直接信任预期哈希;为方便连接取消真实契约校验。 +- 文档:docs/manual/external-game-debugging.md。 diff --git a/.agents/lessons/L-20261023-02-contract-git-metadata.md b/.agents/lessons/L-20261023-02-contract-git-metadata.md new file mode 100644 index 00000000..b821c38a --- /dev/null +++ b/.agents/lessons/L-20261023-02-contract-git-metadata.md @@ -0,0 +1,10 @@ +# Git 构建元数据会改变契约二进制指纹 + +状态:confirmed。来源:F-20261021-03,用户再次反馈启动契约不匹配。 + +- 现象:项目和导出目录契约相同,重新构建的 Sample 契约 SHA256 不同,未改契约逻辑也被拒绝。 +- 证据:两个实际 DLL 长度均 14848,反编译 IL 文本完全相同;AssemblyInformationalVersion 分别包含不同 Git commit。SDK 自动追加的 SourceRevisionId 属于文件内容,改变整个 DLL 的 SHA256。 +- 正确做法:区分文件身份与语义兼容;选择严格二进制身份时,应稳定被哈希产物的非功能构建元数据,并以不同 SourceRevisionId 的真实重建比较 SHA256。部署后重开 Editor 项目,因为其 schema context 保留当前会话指纹。 +- 补充:只稳定最终契约的 InformationalVersion 不够,编译引用的元数据也影响输出;SourceLink 的提交 URL 进入 PDB,其 checksum 也在 DLL 中。保持本地 PDB 不等于必须保留会变的 SourceLink URL。 +- 禁止:把相同反编译代码直接解释为任意版本兼容;关闭真正的契约校验;把当前工作区哈希相同当成跨提交稳定性证据。 +- 构建修正与验证见 feature 的 phase-contract-identity.md;其他编译配置、源码、生成器及依赖变化仍可能需要重新部署。 diff --git a/.agents/lessons/L-20261023-02-debug-step-async-presentation.md b/.agents/lessons/L-20261023-02-debug-step-async-presentation.md new file mode 100644 index 00000000..a16dbe57 --- /dev/null +++ b/.agents/lessons/L-20261023-02-debug-step-async-presentation.md @@ -0,0 +1,11 @@ +# 调试 Step 返回不等于异步呈现完成 + +- 状态:confirmed +- 来源:F-20261021-02-image-variants P5 原生 Sample 测试,2026-10-23。 +- 现象:外部调试 Step 已返回,立刻检查 sample.screenshot 目标文件却不存在;等待其原子发布后五个检查点全部通过。 +- 根因:GameEngine.DebugSession.StepAsync 推进指令范围并返回,AsyncPrimitiveInstance 的呈现任务仍可能执行;协议响应不是外部 I/O 的完成信号。 +- 发现方式:复现原生发行包测试,对照 DebugStepping 与 SampleScreenshotModule 的异步实现,加入有截止时间的文件完成等待后复跑通过。 +- 正确做法:先以 seek/pause 定位到作者范围,再 Step;根据具体任务的完成信号等待(截图为原子发布的目标文件),并设取消与超时。截图前也需给异步图片加载和布局完成的机会。 +- 适用范围:外部调试驱动截图、媒体或其他 AsyncPrimitiveInstance 的集成测试;普通同步图集 select 不需要此等待。 +- 禁止:把 Step 返回当作所有呈现任务完成;用无限等待掩盖呈现失败;在 CatchUp 中期待有外部截图副作用。 +- 验证:ImageVariantsNativeSampleTests.Packaged_variants_render_in_native_sample_and_close_cleanly;artifacts/image-variants-p5/p5-native.trx。 diff --git a/.agents/lessons/L-20261024-01-required-string-defaults.md b/.agents/lessons/L-20261024-01-required-string-defaults.md new file mode 100644 index 00000000..6aa85c7d --- /dev/null +++ b/.agents/lessons/L-20261024-01-required-string-defaults.md @@ -0,0 +1,14 @@ +# 必填字符串字段不能同时声明可用的默认值 + +状态:confirmed +来源:custom.invoke 独立实现(2026-10-24) + +现象:`CustomInvokeOptions.Argument` 声明 Required=true,并以空字符串初始化;缺少 argument 的输入被 Normalize 补成空串,仍到达处理器。 + +根因:SchemaGenerator 把常量属性初始化值导出为 schema 默认值;PrimitiveArgumentHelper.Normalize 优先补默认值,再检查缺失的 required。这是现有规则,不是本次通用原语需要改变的框架行为。 + +修正:要求作者显式提供、且空串合法的 string 字段,应明确 AllowNull=false,并不声明 schema 默认值。需要 CLR DTO 初值时在构造函数初始化,生成器不会把构造函数赋值导出为默认值。 + +证据:CustomActionTests.Contract_rejects_invalid_arguments_before_handler_creation 初轮缺 argument 的用例失败,调整 DTO 声明后通过;显式 null 也有拒绝回归。编译入口额外断言两个字段 IsRequired、AllowNull=false、DefaultValue=null。 + +适用范围:可接受空串但不接受缺省/null 的 DTO 字段。不要把 Required=true 等同于“即使有默认值也必须显式提供”,也不要为了局部字段语义修改全局 Normalize 的默认值规则。关联:docs/spec/entry-types.md、docs/manual/custom-actions.md。 diff --git a/.agents/lessons/L-20261024-01-transparent-hover-interpolation.md b/.agents/lessons/L-20261024-01-transparent-hover-interpolation.md new file mode 100644 index 00000000..7de5c7e7 --- /dev/null +++ b/.agents/lessons/L-20261024-01-transparent-hover-interpolation.md @@ -0,0 +1,15 @@ +# 暗色 hover 的透明颜色插值 + +状态:superseded +日期:2026-10-24 +来源:features/F-20261023-01-editor-uiux + +现象:按钮从透明背景 hover 到暗色填充时,先出现浅亮色,再落回目标色。降低目标亮度或关闭动画都会掩盖真正原因。 + +根因与复现:Avalonia 12.0.5 的 ColorAnimator 将 Transparent(#00FFFFFF)的隐藏白色 RGB 参与插值;到 #FF313244 的 25% / 50% 为 #40e2e2e3 / #80bebec0,叠在暗背景上的中间结果亮于两个端点。Editor 的全局 Border/文字过渡使该行为扩散到模板子控件。 + +原方案:solid brush 的颜色和透明度以预乘 alpha 插值,再转换为输出 brush;保留 160ms 线性过渡及中途反向。检查合成后颜色是否位于端点之间,并验证正常图标按钮透明、hover、按下、焦点和 checked 状态。 + +证据:EditorUiuxTests.TransparentHoverDoesNotCreateALightIntermediateSurface 复现框架中间色并验证修复;EditorUiuxIntegrationTests 的真实模板挂载与四主题截图检查。Headless 没有持续桌面帧时钟,稳定状态截图可禁用实例过渡后生成,但必须独立测试真正的插值与反向,不能用截图取代动画验证。 + +废止原因(2026-10-24):这套补偿被全局应用到 `Border`,使 Group entry 等非按钮容器也参与背景过渡,与 Ursa/Semi 的原生模板反馈叠加后出现卡顿。Editor 已删除 `LinearBrushTransition`、`ThemeTransitions` 与图标按钮的专用过渡,恢复由 Ursa/Semi 模板处理普通控件状态;不要据此 lesson 重新引入全局或图标按钮 hover 动画。 diff --git a/.agents/lessons/L-20261024-02-dock-focus-and-inspector.md b/.agents/lessons/L-20261024-02-dock-focus-and-inspector.md new file mode 100644 index 00000000..3246a79d --- /dev/null +++ b/.agents/lessons/L-20261024-02-dock-focus-and-inspector.md @@ -0,0 +1,11 @@ +# Inspector 必须跟随 Dock 焦点 + +状态:confirmed +日期:2026-10-24 +来源:features/F-20261023-01-editor-uiux/follow-up-20261024.md + +复现:Group 已是主文档的 ActiveDockable,点击底部资源区后再点击 Group 条目。主文档 ActiveDockable 没变,只有 FocusedDockable 改变;仅监听 ActiveDockableChanged 会把资源 Inspector 留在画面中。 + +规则:Inspector 和撤销路由同时处理标签激活与 Dock 焦点。辅助工具没有 Inspector 时,不覆盖最近可检查的作者目标;后台 Workspace 选择刷新不能抢走当前目标。锁定期间仍更新最近作者上下文,解锁时再跟随。焦点事件不应被当作作者历史或触发布局自动保存的内容变化。 + +验证:UiuxInspectorFollowsFocusBackToAlreadyActiveGroupAndRespectsLock 使用真实项目 Dock Factory 的 SetFocusedDockable,覆盖跨区域返回、锁定/解锁、后台资源刷新及辅助 Log;修复前稳定失败。 diff --git a/.agents/lessons/L-20261024-03-external-debug-child-monitor-lifetime.md b/.agents/lessons/L-20261024-03-external-debug-child-monitor-lifetime.md new file mode 100644 index 00000000..600377b3 --- /dev/null +++ b/.agents/lessons/L-20261024-03-external-debug-child-monitor-lifetime.md @@ -0,0 +1,23 @@ +# 外部调试子进程的监控生命周期 + +状态:candidate +来源 Feature:F-20261021-03-external-game-debugging + +## 现象、根因与证据 + +外部 App 超时后按策略继续运行;原 `DisconnectAsync` 却等待启动监控任务结束,而监控任务等待子进程退出。用户断开一个仍运行的 App 时,Editor 因此会继续等待。原启动监控还把 stdout/stderr 读取绑定到调试会话取消令牌;提前停止读取会让仍存活的子进程失去管道消费者。 + +根因由代码路径确认,尚待真实运行复现。 + +## 正确做法 + +断开只使当前调试 epoch 失效、关闭控制连接并取消 UI 会话等待,不等待外部进程退出。只要 Editor 仍在运行,就继续排空子进程 stdout/stderr;让启动监控在子进程退出后结束并关闭该次诊断文件。迟到连接和退出事件必须通过 epoch 检查,不能更新新调试会话的状态。 + +## 适用范围与禁止做法 + +适用于 Editor 启动但不拥有终止策略的外部进程。不要把子进程退出当作断开操作的完成条件;不要取消仍存活子进程的重定向管道读取。 + +## 关联文档 + +- `features/F-20261021-03-external-game-debugging/phase-liveness-diagnostics.md` +- `docs/manual/external-game-debugging.md` diff --git a/.agents/lessons/L-20261024-04-sync-build-work-ui-thread.md b/.agents/lessons/L-20261024-04-sync-build-work-ui-thread.md new file mode 100644 index 00000000..64184e64 --- /dev/null +++ b/.agents/lessons/L-20261024-04-sync-build-work-ui-thread.md @@ -0,0 +1,23 @@ +# 异步构建入口可能先同步阻塞 UI + +状态:candidate +来源 Feature:F-20261021-03-external-game-debugging + +## 现象、根因与证据 + +Editor 调试准备直接从 UI 命令调用 `ProjectContentBuilder.BuildAsync`。该方法在首个 `await` 前执行输出路径检查、项目完整校验、资源与本地化扫描及 Raw 文件枚举;之后作者映射还同步逐组编译。即使方法返回 `Task`,这些同步 CPU/文件工作仍先占用 Avalonia UI 线程。 + +调用顺序和首个异步边界由源码确认,尚待大项目运行复现其界面影响。 + +## 正确做法 + +保持需要 UI 一致性的文档快照捕获在 UI 线程;把纯文件校验、内容构建和作者源码映射等可脱离 UI 的 CPU/IO 工作放到后台执行。状态栏报告当前阶段与耗时,避免把“仍在准备”呈现为无响应。 + +## 适用范围与禁止做法 + +适用于 Avalonia 命令调用 `async Task` 构建/编译 API 的场景。不能仅凭返回类型是 `Task` 就认为同步前缀不会阻塞 UI;也不要把访问可变 UI 文档对象的操作未经快照就整体搬到后台。 + +## 关联文档 + +- `features/F-20261021-03-external-game-debugging/phase-liveness-diagnostics.md` +- `docs/manual/external-game-debugging.md` diff --git a/.agents/skills/galnet-agent-context/SKILL.md b/.agents/skills/galnet-agent-context/SKILL.md index 8ebf8ff8..dd0d7259 100644 --- a/.agents/skills/galnet-agent-context/SKILL.md +++ b/.agents/skills/galnet-agent-context/SKILL.md @@ -13,7 +13,7 @@ description: "在 GalNet 开始工作、上下文可能过时或需要整理长 1. 从项目根目录读取 `.agents/agent-knowledge.md`。 2. 根据当前任务的关键词,只读取相关的 `.agents/lessons/` 和 `.agents/skills/`,不要默认加载全部经验。 -3. 阅读对应的 `docs/spec/`、`docs/design/` 和 feature 文档;正式文档中的当前事实优先于旧 lesson。 +3. 阅读对应的 `docs/spec/`、`docs/manual/` 和 feature 文档;正式规范中的当前事实优先于旧 lesson 和 feature 过程记录。 4. 只把已经验证、未来仍有复用价值的事实写回 `agent-knowledge.md`。 5. 如果规则已经失效,标记关联 lesson 或 skill 为 `superseded`,不要继续保留为当前规则。 diff --git a/.agents/skills/galnet-feature-closeout/SKILL.md b/.agents/skills/galnet-feature-closeout/SKILL.md index 8bd3ec6a..334843ab 100644 --- a/.agents/skills/galnet-feature-closeout/SKILL.md +++ b/.agents/skills/galnet-feature-closeout/SKILL.md @@ -13,7 +13,7 @@ feature 实现和 review 后,或用户要求总结实现、整理经验、维 1. 读取 feature 全部文档、实际 diff、review 结果、测试输出和已记录的 lessons。 2. 在 `features//summary.md` 中记录实现结果、修改模块、关键决定、验证证据、计划偏差、已知限制和后续工作。 -3. 调用或执行 `galnet-sync-docs` 的规则,把稳定的当前事实同步到 `docs/spec/`、长期设计同步到 `docs/design/`,重要架构决定同步到 ADR;不要把 agent 日志复制进正式文档。 +3. 调用或执行 `galnet-sync-docs` 的规则,把稳定的当前事实同步到 `docs/spec/`;需要操作说明时更新 `docs/manual/`,设计取舍与过程保留在 feature 文档;不要把 agent 日志复制进正式文档。 4. 将开发过程中新发现的经验整理到 `.agents/lessons/`;重复、跨 feature 或高风险的经验交给 `galnet-promote-lesson`。 5. 更新 `.agents/agent-knowledge.md`,只保留未来任务仍有用的规则、索引和来源。 6. 如果仍有 Blocker、失败验证或未完成迁移,明确保留状态,不把 feature 标记为完成。 @@ -22,7 +22,7 @@ feature 实现和 review 后,或用户要求总结实现、整理经验、维 - `summary.md`:给开发者看的 feature 历史和实现概要。 - `.agents/lessons/`:给 agent 看的具体踩坑。 -- `docs/`:当前系统和长期设计的正式文档。 +- `docs/`:当前系统规范、操作手册与架构概览。 - `.agents/agent-knowledge.md`:给 agent 的精简长期上下文。 ## 边界 diff --git a/.agents/skills/galnet-feature-design/SKILL.md b/.agents/skills/galnet-feature-design/SKILL.md index 0f8daf33..d99f7fb2 100644 --- a/.agents/skills/galnet-feature-design/SKILL.md +++ b/.agents/skills/galnet-feature-design/SKILL.md @@ -11,10 +11,10 @@ feature 已创建,或用户要求为某个 GalNet 改动设计方案、评估 ## 工作方式 -1. 读取 `.agents/agent-knowledge.md`、对应 `feature.md`、相关 `docs/spec/` 和 `docs/design/`。 +1. 读取 `.agents/agent-knowledge.md`、对应 `feature.md`、相关 `docs/spec/` 和 `docs/manual/`。 2. 检查真实代码和项目依赖,区分当前事实、目标状态和推测。 3. 在 `features//design.md` 中记录:问题、目标、设计边界、数据流、接口、模块职责、候选方案、取舍、兼容性、迁移、风险和验收影响。 -4. 只在架构决定确实跨模块、难以逆转或需要长期引用时提出 ADR;ADR 接受前保持 Proposed,不替用户宣布 Accepted。 +4. 对跨模块或难以逆转的决定,在 feature 的设计与阶段计划中明确记录取舍,并在实现验证后将稳定约束收敛到对应 spec。 5. 更新 `feature.md` 的链接和设计状态,但不伪造用户尚未确认的决定。 ## 边界 diff --git a/.agents/skills/galnet-feature-fast-track/SKILL.md b/.agents/skills/galnet-feature-fast-track/SKILL.md index 7da9beba..0b0d5b9a 100644 --- a/.agents/skills/galnet-feature-fast-track/SKILL.md +++ b/.agents/skills/galnet-feature-fast-track/SKILL.md @@ -18,7 +18,7 @@ description: "按 GalNet 快速 feature 通道处理边界清晰的小到中等 3. 只有用户明确要求完成或实现该 feature 时,才进入代码修改;单纯“规划一下”不等于实现授权。 4. 遇到以下情况时退回普通 feature 流程,并说明原因: - 需求或验收标准仍不清楚。 - - 需要 ADR、长期架构决策或大范围迁移。 + - 需要长期架构决策或大范围迁移。 - 影响核心分层、存档语义、公共 API 或多个并行 feature。 - 工作树中已有改动与本任务冲突。 - 无法定义快速、可信的验证方式。 @@ -49,7 +49,7 @@ description: "按 GalNet 快速 feature 通道处理边界清晰的小到中等 遵守 `galnet-feature-closeout`: - 在 `summary.md` 记录实际实现、验证证据、计划偏差、已知限制和后续工作。 -- 将稳定事实同步到 `docs/spec/` 或 `docs/design/`;只把 agent 经验放入 `.agents/lessons/` 或 `.agents/agent-knowledge.md`。 +- 将稳定事实同步到 `docs/spec/`,必要的接入说明同步到 `docs/manual/`;只把 agent 经验放入 `.agents/lessons/` 或 `.agents/agent-knowledge.md`。 - 需要独立审核或风险较高时,先使用 `galnet-feature-review`,不要把 review 隐藏进 summary。 ## 完成条件 diff --git a/.agents/skills/galnet-sync-docs/SKILL.md b/.agents/skills/galnet-sync-docs/SKILL.md index 7386827c..8d652bb0 100644 --- a/.agents/skills/galnet-sync-docs/SKILL.md +++ b/.agents/skills/galnet-sync-docs/SKILL.md @@ -1,6 +1,6 @@ --- name: galnet-sync-docs -description: "将 GalNet 已验证的实现事实同步到正确的开发者文档层级,区分 spec、design 和 ADR;不记录 agent 私人经验。" +description: "将 GalNet 已验证的实现事实同步到正确的开发者文档层级,区分 spec、manual 与 feature 历史;不记录 agent 私人经验。" --- # Sync GalNet Developer Documentation @@ -12,8 +12,8 @@ feature 完成、代码行为已经改变,或用户单独要求维护开发者 ## 文档归属 - `docs/spec/`:当前已经实现并应被使用的稳定事实、契约和行为。 -- `docs/design/`:跨 feature 的长期设计、路线和推荐工作流。 -- `docs/adr/`:上下文、候选方案、决定和后果;接受后的记录不直接改写。 +- `docs/manual/`:在既有边界内接入新表现层或定制 Sample 的操作指南。 +- `docs/README.md`、`docs/philosophy.md`、`docs/glossary.md`:文档索引、架构理念和项目术语。 - `features//`:一次 feature 的过程和历史。 - `.agents/`:agent 专用上下文、lesson 和 skill 源文件。 @@ -21,9 +21,9 @@ feature 完成、代码行为已经改变,或用户单独要求维护开发者 1. 以实际代码、测试和 review 证据为准,确认文档描述的是当前事实而不是计划。 2. 更新已有相关页面,优先保持一个事实来源,避免新建重复说明。 -3. 重大架构决定写入 ADR,并从 feature 设计文档链接过去。 -4. 对设计、spec 和 ADR 的变更分别检查是否改变了文档层级的含义。 -5. 将更新的文档链接回 feature 的 `summary.md`。 +3. 将已验证的架构决定和约束整合进对应 spec;设计取舍、候选方案和实施计划保留在 feature 文档中。 +4. 检查 spec、manual 与 feature 历史是否各自只表达其职责范围。 +5. 在 feature 的 `summary.md` 中链接更新后的规范。 ## 边界 diff --git a/.gitignore b/.gitignore index 4013d2e9..c4311db1 100644 --- a/.gitignore +++ b/.gitignore @@ -461,6 +461,7 @@ riderModule.iml !.vscode/extensions.json # Project-specific +/output/ dev/* .galnet/ GameTestCase/Output/ diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..cf2a329b --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,9 @@ + + + + false + + false + + diff --git a/GalNet.slnx b/GalNet.slnx index 1f77a99b..aea9aac8 100644 --- a/GalNet.slnx +++ b/GalNet.slnx @@ -41,6 +41,7 @@ + @@ -50,6 +51,7 @@ + diff --git a/GameTestCase/Assets/Effects/Bloom.sksl b/GameTestCase/Assets/Effects/Bloom.sksl new file mode 100644 index 00000000..2bc86108 --- /dev/null +++ b/GameTestCase/Assets/Effects/Bloom.sksl @@ -0,0 +1,60 @@ +/* +@gal.effect v=1 +@input source +@targets scenePost + +@param strength + uniform: strength + type: float + default: 3 + range: 0..3 + animatable: false + +@param radius + uniform: radius + type: float + default: 40 + range: 8..48 + animatable: false +*/ +uniform shader source; +uniform float strength; +uniform float radius; + +half4 main(float2 p) { + half4 original = source.eval(p); + float3 baseColor = float3(original.rgb); + float3 glow = float3(0.0); + float totalWeight = 0.0; + // The classroom image is already bright in LDR; isolate the near-white windows so + // the glow stays local instead of lifting the whole ceiling and walls. + const float threshold = 0.84; + const float softKnee = 0.12; + const float sigma = 1.5; + + for (int y = -2; y <= 2; y++) { + for (int x = -2; x <= 2; x++) { + float2 offset = float2(float(x), float(y)) * (radius * 0.5); + float3 color = max(float3(source.eval(p + offset).rgb), float3(0.0)); + float luminance = dot(color, float3(0.2126, 0.7152, 0.0722)); + float soft = clamp(luminance - threshold + softKnee, 0.0, 2.0 * softKnee); + soft = soft * soft / (4.0 * softKnee + 0.0001); + float contribution = max(luminance - threshold, soft); + float highlight = contribution / max(luminance, 0.0001); + float distanceSquared = float(x * x + y * y); + float weight = exp(-0.5 * distanceSquared / (sigma * sigma)); + + glow += color * highlight * weight; + totalWeight += weight; + } + } + + glow /= max(totalWeight, 0.0001); + + // Scene surfaces are 8-bit LDR, so use remaining headroom as a soft highlight shoulder. + float3 headroom = max(float3(1.0) - clamp(baseColor, 0.0, 1.0), float3(0.0)); + // Keep a little headroom even for strong settings so this LDR pass never clips to white. + float3 bloom = clamp(glow * strength, float3(0.0), float3(0.65)); + float3 result = baseColor + headroom * bloom; + return half4(half3(clamp(result, 0.0, 1.0)), original.a); +} diff --git a/GameTestCase/Assets/Effects/Bloom.sksl.meta b/GameTestCase/Assets/Effects/Bloom.sksl.meta new file mode 100644 index 00000000..aa9034a8 --- /dev/null +++ b/GameTestCase/Assets/Effects/Bloom.sksl.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000012","type":"effect-program","path":"Effects/Bloom.sksl"} diff --git a/GameTestCase/Assets/Effects/Blur.sksl b/GameTestCase/Assets/Effects/Blur.sksl new file mode 100644 index 00000000..7381a1f4 --- /dev/null +++ b/GameTestCase/Assets/Effects/Blur.sksl @@ -0,0 +1,44 @@ +/* +@gal.effect v=1 +@input source +@targets scenePost + +@param radius + uniform: radius + type: float + default: 28 + range: 4..48 + animatable: false + +@param direction + uniform: direction + type: vector2 + default: [1,0] + animatable: false +*/ +uniform shader source; +uniform float radius; +uniform float2 direction; + +half4 main(float2 p) { + float sigma = max(radius / 3.0, 0.5); + float4 color = float4(source.eval(p)); + float totalWeight = 1.0; + + // Five bands with three dense taps per band approximate a sampled Gaussian over 3 sigma. + for (int iteration = 0; iteration < 5; iteration++) { + for (int substep = 0; substep < 3; substep++) { + float sampleIndex = float(iteration * 3 + substep + 1); + float offset = radius * sampleIndex / 15.0; + float normalizedOffset = offset / sigma; + float weight = exp(-0.5 * normalizedOffset * normalizedOffset); + float2 delta = direction * offset; + + color += float4(source.eval(p + delta)) * weight; + color += float4(source.eval(p - delta)) * weight; + totalWeight += 2.0 * weight; + } + } + + return half4(color / totalWeight); +} diff --git a/GameTestCase/Assets/Effects/Blur.sksl.meta b/GameTestCase/Assets/Effects/Blur.sksl.meta new file mode 100644 index 00000000..a72bbec7 --- /dev/null +++ b/GameTestCase/Assets/Effects/Blur.sksl.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000013","type":"effect-program","path":"Effects/Blur.sksl"} diff --git a/GameTestCase/Assets/Effects/ShaderToyGrid.sksl b/GameTestCase/Assets/Effects/ShaderToyGrid.sksl new file mode 100644 index 00000000..88e3a854 --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyGrid.sksl @@ -0,0 +1,355 @@ +/* +@gal.effect v=1 +@input source +@targets scenePost + +@param time + uniform: iTime + type: float + default: 0 + range: 0..100000 + animatable: true + +@param mixAmount + uniform: mixAmount + type: float + default: 0.55 + range: 0..1 + animatable: false +*/ +uniform shader source; +uniform float2 size; +uniform float mixAmount; +uniform float iTime; + +// ------------------------------------------------------------ +// Hash replacement +// ------------------------------------------------------------ +// The original Shadertoy shader uses floatBitsToInt() and XOR. +// Those are not supported by this RuntimeEffect environment. +// +// This preserves the purpose of the hash: deterministic, +// pseudo-random values based on a 3D position. +// ------------------------------------------------------------ +float hash(float3 p) { + float h = dot( + p, + float3(127.1, 311.7, 74.7) + ); + + return fract( + sin(h) * 43758.5453123 + ); +} + +// ------------------------------------------------------------ +// Grid distance +// ------------------------------------------------------------ +void dogrid( + float3 ro, + float3 rd, + float size, + out float3 gridId, + out float gridD +) { + gridId = + (floor(ro + rd * 1e-3) / size + 0.5) + * size; + + float3 src = + -(ro - gridId) / rd; + + float3 dst = + abs(0.5 * size) / rd; + + float3 bz = src + dst; + + gridD = min( + bz.x, + min(bz.y, bz.z) + ); +} + +// ------------------------------------------------------------ +// Rodrigues rotation +// ------------------------------------------------------------ +float3 erot( + float3 p, + float3 ax, + float t +) { + return + mix( + dot(ax, p) * ax, + p, + cos(t) + ) + + + cross(ax, p) * sin(t); +} + +// ------------------------------------------------------------ +// tanh replacement +// tanh(x) = (e^(2x)-1)/(e^(2x)+1) +// +// Clamp keeps exp() from getting excessively large. +// ------------------------------------------------------------ +float tanhApprox(float x) { + x = clamp(x, -8.0, 8.0); + + float e = exp(2.0 * x); + + return (e - 1.0) / (e + 1.0); +} + +// ------------------------------------------------------------ +// Main +// ------------------------------------------------------------ +half4 shadertoyMain(float2 fragCoord) { + + float2 uv = + (fragCoord - 0.5 * size.xy) + / size.y; + + float3 col = float3(0.0); + + float3 ro = + float3(0.2, 0.2, -5.0); + + float3 rt = + float3(0.0); + + float3 z = + normalize(rt - ro); + + float3 x = + normalize( + cross( + z, + float3(0.0, -1.0, 0.0) + ) + ); + + float3 y = + cross(z, x); + + // -------------------------------------------------------- + // Camera ray + // -------------------------------------------------------- + + float hv = + hash( + float3( + uv.x, + uv.y, + uv.y + ) + iTime + ); + + float rayZ = + 2.0 + + tanhApprox( + hv * 0.5 + + 10.0 * sin(iTime) + ); + + float3 camDir = + normalize( + float3( + uv.x, + uv.y, + rayZ + ) + ); + + // Equivalent to: + // mat3(x,y,z) * camDir + float3 rd = + x * camDir.x + + y * camDir.y + + z * camDir.z; + + float e = 0.01; + float g = 0.0; + + float gridLen = 0.0; + + float3 gridId = + float3(0.0); + + float gridD = 0.0; + + // -------------------------------------------------------- + // Raymarch + // -------------------------------------------------------- + + for (int iter = 0; iter < 99; iter++) { + + float i = + float(iter + 1); + + float3 p = + ro + rd * g; + + float3 oop = p; + + // Rotate world + p = + erot( + p, + normalize( + sin( + iTime * 0.33 + + float3( + -0.6, + 0.4, + 0.2 + ) + ) + ), + iTime * 0.2 + ); + + p.z += iTime; + + float3 op = p; + + // ---------------------------------------------------- + // Advance grid + // ---------------------------------------------------- + + if (gridLen <= g) { + + dogrid( + p, + rd, + 1.0, + gridId, + gridD + ); + + gridLen += gridD; + } + + p -= gridId; + + // ---------------------------------------------------- + // Grid pattern + // ---------------------------------------------------- + + float3 gridIdZXY = + float3( + gridId.z, + gridId.x, + gridId.y + ); + + float gy = + dot( + sin(gridId * 2.0), + cos(gridIdZXY * 5.0) + ); + + float rn = + hash( + gridId + + floor(iTime) + ); + + p.x += sin(rn) * 0.25; + + // ---------------------------------------------------- + // Distance + // ---------------------------------------------------- + + float h; + + // NOTE: + // The original uses rn > 0.0. + // This is effectively always true because our + // replacement hash returns [0,1). + // + // To preserve the original shader's intended + // alternating behavior, create a signed hash. + float signedRn = + rn * 2.0 - 1.0; + + if (signedRn > 0.0) { + + h = 0.5; + + } else { + + h = + length(p) + - 0.01 + - gy * 0.05 + + signedRn * 0.02; + } + + // ---------------------------------------------------- + // Raymarch step + // ---------------------------------------------------- + + e = + max( + 0.001 + + op.z * 0.000002, + + abs(h) + ); + + g += e; + + // ---------------------------------------------------- + // Accumulate color + // ---------------------------------------------------- + + float glow = + exp( + 5.0 * + fract( + gy + iTime + ) + ); + + col += + float3( + 0.25, + 0.25, + 1.0 + + abs(signedRn) + ) + * + ( + 0.025 + + 0.02 * glow + ) + / + exp( + e * e * i + ); + } + + // -------------------------------------------------------- + // Final shading + // -------------------------------------------------------- + + col *= exp(-0.08 * g); + + col = + sqrt( + max(col, float3(0.0)) + ); + + return half4( + col, + 1.0 + ); +} + +half4 main(float2 fragCoord) { + float4 scene = float4(source.eval(fragCoord)); + float4 generated = float4(shadertoyMain(fragCoord)); + return half4(mix(scene, generated, clamp(mixAmount, 0.0, 1.0))); +} \ No newline at end of file diff --git a/GameTestCase/Assets/Effects/ShaderToyGrid.sksl.meta b/GameTestCase/Assets/Effects/ShaderToyGrid.sksl.meta new file mode 100644 index 00000000..235558e2 --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyGrid.sksl.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000015","type":"effect-program","path":"Effects/ShaderToyGrid.sksl"} diff --git a/GameTestCase/Assets/Effects/ShaderToyRain.sksl b/GameTestCase/Assets/Effects/ShaderToyRain.sksl new file mode 100644 index 00000000..7f4ad12a --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyRain.sksl @@ -0,0 +1,1605 @@ +/* +@gal.effect v=1 +@input source +@targets scenePost + +@param time + uniform: iTime + type: float + default: 0 + range: 0..100000 + animatable: true + +@param mixAmount + uniform: mixAmount + type: float + default: 0.55 + range: 0..1 + animatable: false +*/ +uniform shader source; +uniform float2 size; +uniform float mixAmount; +uniform float iTime; + + +// ============================================================ +// Constants +// ============================================================ + +const float3 BACKGROUND_COLOR = + float3(0.2, 0.4, 0.6) * 0.09; + +const int MARCH_STEPS = 48; +const int MARCH_STEPS_REFLECTION = 16; + +const float LIGHT_INTENSITY = 5.0; + +const float PI = + 3.141592653589793238; + + +// ============================================================ +// 2D rotation +// ============================================================ + +float2 rotate2( + float2 p, + float2 cs +) { + return float2( + cs.x * p.x - cs.y * p.y, + cs.y * p.x + cs.x * p.y + ); +} + + +// ============================================================ +// Noise +// ============================================================ + +float hash(float n) { + return fract( + sin(n) * + 687.3123 + ); +} + +float noise(float2 x) { + + float2 p = + floor(x); + + float2 f = + fract(x); + + f = + f * + f * + ( + 3.0 - + 2.0 * f + ); + + float n = + p.x + + p.y * 157.0; + + return mix( + mix( + hash(n), + hash(n + 1.0), + f.x + ), + mix( + hash(n + 157.0), + hash(n + 158.0), + f.x + ), + f.y + ); +} + +float fbm(float2 p) { + + float f = 0.0; + + f += + 0.5000 * + noise(p); + + p = + rotate2( + p, + float2( + 0.80, + 0.60 + ) + ) + * + 2.02; + + f += + 0.2500 * + noise(p); + + p = + rotate2( + p, + float2( + 0.80, + 0.60 + ) + ) + * + 2.03; + + f += + 0.1250 * + noise(p); + + return + f / 0.9375; +} + + +// ============================================================ +// Distance primitives +// ============================================================ + +float udRoundBox( + float3 p, + float3 b, + float r +) { + return + length( + max( + abs(p) - b, + float3(0.0) + ) + ) + - + r; +} + +float sdBox( + float3 p, + float3 b +) { + + float3 d = + abs(p) - b; + + return + min( + max( + d.x, + max(d.y, d.z) + ), + 0.0 + ) + + + length( + max( + d, + float3(0.0) + ) + ); +} + +float sdSphere( + float3 p, + float s +) { + return length(p) - s; +} + +float sdCylinder( + float3 p, + float2 h +) { + + float2 d = + abs( + float2( + length(p.xz), + p.y + ) + ) + - + h; + + return + min( + max(d.x, d.y), + 0.0 + ) + + + length( + max( + d, + float2(0.0) + ) + ); +} + + +// ============================================================ +// Distance operators +// ============================================================ + +float opU( + float d2, + float d1 +) { + return min(d1, d2); +} + +float opS( + float d2, + float d1 +) { + return max(-d1, d2); +} + +float smin( + float a, + float b, + float k +) { + + float mn = + min(a, b); + + float mx = + max(a, b); + + return + mn - + log( + 1.0 + + exp( + -k * + (mx - mn) + ) + ) + / k; +} + + +// ============================================================ +// Car +// ============================================================ + +float mapCar( + float3 p0 +) { + + float3 p = + p0 + + float3( + 0.0, + 1.24, + 0.0 + ); + + float r = + length(p.yz); + + float d = + length( + max( + float3( + abs(p.x) - 0.35, + r - 1.92, + -p.y + 1.4 + ), + float3(0.0) + ) + ) + - + 0.05; + + d = + max( + d, + p.z - 1.0 + ); + + p = + p0 + + float3( + 0.0, + -0.22, + 0.39 + ); + + p.xz = + abs(p.xz) - + float2( + 0.5300, + 0.9600 + ); + + p.x = + abs(p.x); + + r = + length(p.yz); + + d = + smin( + d, + length( + max( + float3( + p.x - 0.08, + r - 0.25, + -p.y - 0.08 + ), + float3(0.0) + ) + ) + - + 0.04, + 8.0 + ); + + d = + max( + d, + -max( + p.x - 0.165, + r - 0.24 + ) + ); + + float d2 = + length( + float2( + max( + p.x - 0.13, + 0.0 + ), + r - 0.2 + ) + ) + - + 0.02; + + d = + min( + d, + d2 + ); + + return d; +} + + +// ============================================================ +// Map result +// ============================================================ + +struct MapResult { + float d; + float lightDist; +}; + + +// ============================================================ +// Scene map +// ============================================================ + +MapResult mapScene( + float3 p +) { + + float3 pd = + p; + + float d = 0.0; + + pd.x = + abs(pd.x); + + pd.z *= + -sign(p.x); + + float time = + iTime + 90.0; + + float ch = + hash( + floor( + (pd.z + 18.0 * time) / + 40.0 + ) + ); + + float lh = + hash( + floor( + pd.z / 13.0 + ) + ); + + float3 pdm = + float3( + pd.x, + pd.y, + mod(pd.z, 10.0) - 5.0 + ); + + float dL = + sdSphere( + float3( + pdm.x - 8.1, + pdm.y - 4.5, + pdm.z + ), + 0.1 + ); + + dL = + opU( + dL, + sdBox( + float3( + pdm.x - 12.0, + pdm.y - 9.5 - lh, + mod(pd.z, 91.0) - 45.5 + ), + float3( + 0.2, + 4.5, + 0.2 + ) + ) + ); + + dL = + opU( + dL, + sdBox( + float3( + pdm.x - 12.0, + pdm.y - 11.5 + lh, + mod(pd.z, 31.0) - 15.5 + ), + float3( + 0.22, + 5.5, + 0.2 + ) + ) + ); + + dL = + opU( + dL, + sdBox( + float3( + pdm.x - 12.0, + pdm.y - 8.5 - lh, + mod(pd.z, 41.0) - 20.5 + ), + float3( + 0.24, + 3.5, + 0.2 + ) + ) + ); + + if (lh > 0.5) { + + dL = + opU( + dL, + sdBox( + float3( + pdm.x - 12.5, + pdm.y - 2.75 - lh, + mod(pd.z, 13.0) - 6.5 + ), + float3( + 0.1, + 0.25, + 3.2 + ) + ) + ); + } + + float3 pm = + float3( + mod( + pd.x + + floor(pd.z * 4.0) * + 0.25, + 0.5 + ) - + 0.25, + + pd.y, + + mod(pd.z, 0.25) - + 0.125 + ); + + d = + udRoundBox( + pm, + float3( + 0.245, + 0.1, + 0.12 + ), + 0.005 + ); + + d = + opS( + d, + -(p.x + 8.0) + ); + + d = + opU( + d, + pd.y + ); + + float3 pdc = + float3( + pd.x, + pd.y, + mod( + pd.z + + 18.0 * time, + 40.0 + ) - + 20.0 + ); + + if (ch > 0.75) { + + pdc.x += + (ch - 0.75) * + 4.0; + + dL = + opU( + dL, + sdSphere( + float3( + abs(pdc.x - 5.0) - 1.05, + pdc.y - 0.55, + pdc.z + ), + 0.025 + ) + ); + + dL = + opU( + dL, + sdSphere( + float3( + abs(pdc.x - 5.0) - 1.2, + pdc.y - 0.65, + pdc.z + 6.05 + ), + 0.025 + ) + ); + + d = + opU( + d, + mapCar( + ( + pdc - + float3( + 5.0, + -0.025, + -2.3 + ) + ) + * + 0.45 + ) + ); + } + + d = + opU( + d, + 13.0 - pd.x + ); + + d = + opU( + d, + sdCylinder( + float3( + pdm.x - 8.5, + pdm.y, + pdm.z + ), + float2( + 0.075, + 4.5 + ) + ) + ); + + d = + opU( + d, + dL + ); + + MapResult result; + + result.d = d; + result.lightDist = dL; + + return result; +} + + +// ============================================================ +// Surface normal +// ============================================================ + +float3 calcNormalSimple( + float3 pos +) { + + const float e = 0.005; + + float3 ex = + float3( + e, + -e, + -e + ); + + float3 n = + ex.xyy * + mapScene( + pos + ex.xyy + ).d + + + ex.yyx * + mapScene( + pos + ex.yyx + ).d + + + ex.yxy * + mapScene( + pos + ex.yxy + ).d + + + ex.xxx * + mapScene( + pos + ex.xxx + ).d; + + return normalize(n); +} + +float3 calcNormal( + float3 pos +) { + + float3 n = + calcNormalSimple(pos); + + if (pos.y > 0.12) { + return n; + } + + float2 oc = + floor( + float2( + pos.x + + floor(pos.z * 4.0) * + 0.25, + pos.z + ) + * + float2( + 2.0, + 4.0 + ) + ); + + if (abs(pos.x) < 8.0) { + oc = pos.xz; + } + + float3 pp = + pos * 250.0; + + float3 xn = + 0.05 * + float3( + noise(pp.xz) - 0.5, + 0.0, + noise(pp.zx) - 0.5 + ); + + xn += + 0.1 * + float3( + fbm(oc.xy) - 0.5, + 0.0, + fbm(oc.yx) - 0.5 + ); + + return normalize( + xn + n + ); +} + + +// ============================================================ +// Trace result +// ============================================================ + +struct TraceResult { + float hit; + float3 hit1; + float3 hit2; + float3 normal1; + float4 light1; + float4 light2; +}; + + +// ============================================================ +// Intersection +// ============================================================ + +TraceResult intersectScene( + float3 ro, + float3 rd +) { + + const float precis = 0.001; + + float h = + precis * 2.0; + + float t = 0.0; + + float3 hit1 = + float3(-500.0); + + float3 hit2 = + float3(-500.0); + + float3 normal1 = + float3(0.0); + + float4 light1 = + float4(-500.0); + + float4 light2 = + float4(-500.0); + + float minLight = + 100.0; + + for ( + int i = 0; + i < MARCH_STEPS; + i++ + ) { + + MapResult mr = + mapScene( + ro + + rd * t + ); + + h = mr.d; + + if ( + mr.lightDist < + minLight + ) { + + minLight = + mr.lightDist; + + light1.xyz = + ro + + rd * t; + + light1.w = + abs( + mr.lightDist + ); + } + + if (h < precis) { + + hit1 = + ro + + rd * t; + + break; + } + + t += + max( + h, + precis * 2.0 + ); + } + + if ( + hit1.z < -400.0 || + t > 300.0 + ) { + + float planeT = + -( + ro.y + 0.1 + ) + / + rd.y; + + if (planeT > 0.0) { + + hit1 = + ro + + rd * + planeT; + + } else { + + TraceResult miss; + + miss.hit = -1.0; + miss.hit1 = hit1; + miss.hit2 = hit2; + miss.normal1 = normal1; + miss.light1 = light1; + miss.light2 = light2; + + return miss; + } + } + + ro = + ro + + rd * t; + + normal1 = + calcNormal(ro); + + ro += + 0.01 * + normal1; + + rd = + reflect( + rd, + normal1 + ); + + t = 0.0; + h = precis * 2.0; + minLight = 100.0; + + for ( + int i = 0; + i < MARCH_STEPS_REFLECTION; + i++ + ) { + + MapResult mr = + mapScene( + ro + + rd * t + ); + + h = mr.d; + + if ( + mr.lightDist < + minLight + ) { + + minLight = + mr.lightDist; + + light2.xyz = + ro + + rd * t; + + light2.w = + abs( + mr.lightDist + ); + } + + if (h < precis) { + + hit2 = + ro + + rd * t; + + break; + } + + t += + max( + h, + precis * 2.0 + ); + } + + TraceResult result; + + result.hit = + hit2.z > -400.0 + ? 1.0 + : 0.0; + + result.hit1 = hit1; + result.hit2 = hit2; + result.normal1 = normal1; + result.light1 = light1; + result.light2 = light2; + + return result; +} + + +// ============================================================ +// Shading +// ============================================================ + +float3 shade( + float3 ro, + float3 pos, + float3 nor +) { + + float3 col = + float3(0.5); + + if ( + abs(pos.x) > 15.0 || + abs(pos.x) < 8.0 + ) { + col = + float3(0.02); + } + + if (pos.y < 0.01) { + + float ax = + abs(pos.x); + + if (ax < 0.1) { + col = + float3(0.9); + } + + if ( + abs(ax - 7.4) < + 0.1 + ) { + col = + float3(0.9); + } + } + + float sh = + clamp( + dot( + nor, + normalize( + float3( + -0.3, + 0.3, + -0.5 + ) + ) + ), + 0.0, + 1.0 + ); + + col *= + sh * + BACKGROUND_COLOR; + + if ( + abs(pos.x) > 12.9 && + pos.y > 9.0 + ) { + + float ha = + hash( + 133.1234 * + floor(pos.y / 3.0) + + floor(pos.z / 3.0) + ); + + if (ha > 0.95) { + + col = + (ha - 0.95) * + 10.0 * + float3( + 1.0, + 0.7, + 0.4 + ); + } + } + + col = + mix( + BACKGROUND_COLOR, + col, + exp( + min( + max( + 0.1 * pos.y, + 0.25 + ) + - + 0.065 * + distance( + pos, + ro + ), + 0.0 + ) + ) + ); + + return col; +} + + +// ============================================================ +// Light color +// ============================================================ + +float3 getLightColor( + float3 pos +) { + + float3 lcol = + float3( + 1.0, + 0.7, + 0.5 + ); + + float3 pd = + pos; + + pd.x = + abs(pd.x); + + pd.z *= + -sign(pos.x); + + float time = + iTime + 90.0; + + float ch = + hash( + floor( + ( + pd.z + + 18.0 * time + ) + / + 40.0 + ) + ); + + float3 pdc = + float3( + pd.x, + pd.y, + mod( + pd.z + + 18.0 * time, + 40.0 + ) + - + 20.0 + ); + + if (ch > 0.75) { + + pdc.x += + (ch - 0.75) * + 4.0; + + if ( + sdSphere( + float3( + abs( + pdc.x - 5.0 + ) + - + 1.05, + pdc.y - 0.55, + pdc.z + ), + 0.25 + ) < 2.0 + ) { + + lcol = + float3( + 1.0, + 0.05, + 0.01 + ); + } + } + + if ( + pd.y > 2.0 && + abs(pd.x) > 10.0 && + pd.y < 5.0 + ) { + + float fl = + floor( + pd.z / 13.0 + ); + + lcol = + 0.4 * lcol + + 0.5 * + float3( + hash(0.1562 + fl), + hash(0.423134 + fl), + 0.0 + ); + } + + if ( + abs(pd.x) > 10.0 && + pd.y > 5.0 + ) { + + float fl = + floor( + pd.z / 2.0 + ); + + lcol = + 0.5 * lcol + + 0.5 * + float3( + hash(0.1562 + fl), + hash(0.923134 + fl), + hash(0.423134 + fl) + ); + } + + return lcol; +} + + +// ============================================================ +// Random start +// ============================================================ + +float randomStart( + float2 co +) { + + return + 0.8 + + 0.2 * + hash( + dot( + co, + float2( + 123.42, + 117.853 + ) + ) + * + 412.453 + ); +} + + +// ============================================================ +// Main +// ============================================================ + +half4 shadertoyMain( + float2 fragCoord +) { + + float2 q = + fragCoord / + size.xy; + + float2 p = + -1.0 + + 2.0 * q; + + p.x *= + size.x / + size.y; + + // IMPORTANT: + // Skia Labs' fragCoord orientation is opposite to the + // orientation expected by this Shadertoy shader. + p.y = -p.y; + + + // Cinematic bars + if ( + q.y < 0.12 || + q.y >= 0.88 + ) { + + return half4( + 0.0, + 0.0, + 0.0, + 1.0 + ); + } + + + // ======================================================== + // Camera + // ======================================================== + + float time = + iTime + 90.0; + + float z = + time; + + float x = + -10.9 + + sin(time * 0.2); + + float3 ro = + float3( + x, + 1.3 + + 0.3 * + cos(time * 0.26), + z - 1.0 + ); + + float3 ta = + float3( + -8.0, + 1.3 + + 0.4 * + cos(time * 0.26), + z + + 4.0 + + cos(time * 0.04) + ); + + float3 ww = + normalize( + ta - ro + ); + + float3 uu = + normalize( + cross( + ww, + float3( + 0.0, + 1.0, + 0.0 + ) + ) + ); + + float3 vv = + normalize( + cross( + uu, + ww + ) + ); + + float3 rd = + normalize( + -p.x * uu + + p.y * vv + + 2.2 * ww + ); + + + float3 col = + BACKGROUND_COLOR; + + + // ======================================================== + // Raymarch + // ======================================================== + + float start = + randomStart(p); + + TraceResult trace = + intersectScene( + ro + + start * rd, + rd + ); + + float ints = + trace.hit; + + + if (ints > -0.5) { + + float r = + 0.09; + + if ( + trace.hit1.y > + 0.129 + ) { + + r = + 0.025 * + hash( + 133.1234 * + floor( + trace.hit1.y / + 3.0 + ) + + + floor( + trace.hit1.z / + 3.0 + ) + ); + } + + if ( + abs(trace.hit1.x) < + 8.0 + ) { + + if ( + trace.hit1.y < + 0.01 + ) { + + r = + 0.007 * + fbm( + trace.hit1.xz + ); + + } else { + + r = + 0.02; + } + } + + if ( + abs(trace.hit1.x) < + 0.1 + ) { + r *= 4.0; + } + + if ( + abs( + abs(trace.hit1.x) - + 7.4 + ) < + 0.1 + ) { + r *= 4.0; + } + + r *= 2.0; + + col = + shade( + ro, + trace.hit1, + trace.normal1 + ); + + if (ints > 0.5) { + + col += + r * + shade( + trace.hit1, + trace.hit2, + calcNormalSimple( + trace.hit2 + ) + ); + } + + if ( + trace.light2.w > + 0.0 + ) { + + col += + ( + r * + LIGHT_INTENSITY * + exp( + -trace.light2.w * + 7.0 + ) + ) + * + getLightColor( + trace.light2.xyz + ); + } + } + + + // ======================================================== + // Rain + // ======================================================== + + float2 st = + 256.0 * + ( + p * + float2( + 0.5, + 0.01 + ) + + + float2( + time * 0.13 - + q.y * 0.6, + time * 0.13 + ) + ); + + float rain = + noise(st) * + noise(st * 0.773) * + 1.55; + + rain = + 0.25 + + clamp( + pow( + abs(rain), + 13.0 + ) + * + 13.0, + 0.0, + q.y * 0.14 + ); + + if ( + trace.light1.w > + 0.0 + ) { + + col += + ( + rain * + LIGHT_INTENSITY * + exp( + -trace.light1.w * + 7.0 + ) + ) + * + getLightColor( + trace.light1.xyz + ); + } + + col += + 0.25 * + rain * + ( + 0.2 + + BACKGROUND_COLOR + ); + + + // ======================================================== + // Post processing + // ======================================================== + + col = + clamp( + col, + float3(0.0), + float3(1.0) + ); + + col = + float3( + pow(col.x, 0.4545), + pow(col.y, 0.4545), + pow(col.z, 0.4545) + ); + + col *= + 1.2 * + float3( + 1.0, + 0.99, + 0.95 + ); + + col = + clamp( + 1.06 * col - + 0.03, + float3(0.0), + float3(1.0) + ); + + float vignetteY = + ( + q.y - + 0.12 + ) + / + 0.76; + + float vignette = + 0.5 + + 0.5 * + pow( + max( + 16.0 * + q.x * + vignetteY * + (1.0 - q.x) * + (1.0 - vignetteY), + 0.0 + ), + 0.1 + ); + + col *= + vignette; + + return half4( + col, + 1.0 + ); +} + +half4 main(float2 fragCoord) { + float4 scene = float4(source.eval(fragCoord)); + float4 generated = float4(shadertoyMain(fragCoord)); + return half4(mix(scene, generated, clamp(mixAmount, 0.0, 1.0))); +} diff --git a/GameTestCase/Assets/Effects/ShaderToyRain.sksl.meta b/GameTestCase/Assets/Effects/ShaderToyRain.sksl.meta new file mode 100644 index 00000000..c31854e0 --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyRain.sksl.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000016","type":"effect-program","path":"Effects/ShaderToyRain.sksl"} diff --git a/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl b/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl new file mode 100644 index 00000000..2bea7048 --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl @@ -0,0 +1,165 @@ +/* +@gal.effect v=1 +@input source +@targets scenePost + +@param time + uniform: iTime + type: float + default: 0 + range: 0..100000 + animatable: true + +@param mixAmount + uniform: mixAmount + type: float + default: 1 + range: 0..1 + animatable: false +*/ +uniform shader source; +uniform float2 size; +uniform float mixAmount; +uniform float iTime; + +// The tunnel keeps the supplied shader's three defining cues: a wandering +// centerline, a rough, ribbed inner shell, and colored vapor in the depth. +const int MARCH_STEPS = 56; +const float FAR_DISTANCE = 42.0; + +float hash12(float2 p) { + p = fract(p * float2(123.34, 345.45)); + p += dot(p, p + 34.345); + return fract(p.x * p.y); +} + +float noise2(float2 p) { + float2 cell = floor(p); + float2 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float a = hash12(cell); + float b = hash12(cell + float2(1.0, 0.0)); + float c = hash12(cell + float2(0.0, 1.0)); + float d = hash12(cell + float2(1.0, 1.0)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +float fbm2(float2 p) { + float sum = 0.0; + sum += 0.56 * noise2(p); + sum += 0.29 * noise2(p * 2.03 + float2(17.0, 9.0)); + sum += 0.15 * noise2(p * 4.07 + float2(5.0, 31.0)); + return sum; +} + +float2 tunnelCenter(float z) { + float t = iTime; + return float2( + 0.34 * sin(z * 0.15 + t * 0.22) + 0.15 * sin(z * 0.39 - t * 0.31), + 0.27 * cos(z * 0.12 - t * 0.18) + 0.10 * sin(z * 0.31 + t * 0.27) + ); +} + +float tunnelRadius(float z, float angle) { + float t = iTime; + float longWave = sin(z * 0.78 + t * 0.66 + sin(z * 0.18) * 0.8); + float spiral = sin(angle * 3.0 + z * 1.48 - t * 0.74); + float corrugation = sin(angle * 7.0 - z * 0.58 + t * 0.34); + float rib = pow(max(0.0, 0.5 + 0.5 * cos(z * 5.8 + angle * 2.0 - t * 0.95)), 8.0); + return 1.86 + 0.17 * longWave + 0.10 * spiral + 0.055 * corrugation + 0.13 * rib; +} + +float wallDistance(float3 p) { + float2 radial = p.xy - tunnelCenter(p.z); + float angle = atan(radial.y, radial.x); + return abs(length(radial) - tunnelRadius(p.z, angle)); +} + +float3 toneMap(float3 radiance) { + float3 positive = max(radiance, float3(0.0)); + float3 mapped = positive / (float3(1.0) + positive); + return pow(mapped, float3(0.88)); +} + +half4 shadertoyMain(float2 fragCoord) { + float2 uv = (fragCoord - 0.5 * size) / size.y; + uv.y = -uv.y; + + float3 rayOrigin = float3(0.0, 0.0, -2.6); + float3 rayDirection = normalize(float3(uv * 1.12, 1.35)); + float travel = 0.0; + float3 hit = rayOrigin; + bool hasHit = false; + + for (int step = 0; step < MARCH_STEPS; step++) { + float3 position = rayOrigin + rayDirection * travel; + float distanceToWall = wallDistance(position); + if (distanceToWall < 0.009) { + hit = position; + hasHit = true; + break; + } + travel += max(distanceToWall * 0.68, 0.012); + if (travel > FAR_DISTANCE) break; + } + + float portalDistance = length(uv * float2(1.0, 0.72)); + float portalRing = exp(-abs(portalDistance - (0.18 + 0.025 * sin(iTime * 0.7))) * 25.0); + float portalGlow = exp(-portalDistance * 3.2); + float3 fogColor = float3(0.008, 0.018, 0.036) + + portalGlow * float3(0.055, 0.018, 0.072) + + portalRing * float3(0.035, 0.11, 0.14); + + if (!hasHit) { + float stars = pow(max(0.0, noise2(uv * 95.0 + iTime * 0.025) - 0.79), 8.0); + return half4(toneMap(fogColor + stars * float3(0.55, 0.24, 0.18)), 1.0); + } + + float2 radial = normalize(hit.xy - tunnelCenter(hit.z)); + float angle = atan(radial.y, radial.x); + float2 surfaceUv = float2( + hit.x * 1.55 + hit.z * 0.42 + sin(hit.z * 0.19) * 0.35, + hit.y * 1.55 + hit.z * 0.62 + iTime * 0.035 + ); + + float mottling = fbm2(surfaceUv); + float fineGrain = noise2(surfaceUv * 5.3 + float2(7.0, -iTime * 0.11)); + float patina = smoothstep(0.48, 0.73, 0.68 * mottling + 0.32 * fineGrain); + + float3 deepMaterial = float3(0.105, 0.018, 0.045); + float3 warmMaterial = float3(0.72, 0.19, 0.115); + float3 baseColor = mix(deepMaterial, warmMaterial, smoothstep(0.27, 0.76, mottling)); + baseColor = mix(baseColor, float3(0.045, 0.33, 0.35), patina * 0.64); + + float ribPhase = cos(hit.z * 5.8 + angle * 2.0 - iTime * 0.95); + float raisedRibs = pow(max(0.0, ribPhase), 10.0); + float recessedGrooves = pow(max(0.0, -ribPhase), 5.0); + float3 surfaceNormal = normalize(float3(radial, -0.18 * sin(hit.z * 0.78 + angle + iTime * 0.2))); + float3 lightDirection = normalize(float3(-0.54, 0.68, -0.48)); + float diffuse = 0.28 + 0.72 * max(dot(surfaceNormal, lightDirection), 0.0); + float grazing = pow(1.0 - abs(dot(surfaceNormal, rayDirection)), 2.0); + float lighting = diffuse + 0.27 * grazing; + + float3 surfaceColor = baseColor * lighting; + surfaceColor += raisedRibs * float3(0.55, 0.19, 0.12); + surfaceColor *= 1.0 - 0.26 * recessedGrooves; + surfaceColor += pow(max(0.0, fineGrain - 0.72), 3.0) * float3(0.7, 0.31, 0.16); + + float fog = exp(-travel * 0.055); + float3 radiance = mix(fogColor, surfaceColor, fog); + + // Cheap animated vapor keeps the source shader's blue-green steam without + // sampling a full volume at every ray-march step. + float vaporField = fbm2(float2(uv.x * 5.0 + iTime * 0.09, uv.y * 7.0 + travel * 0.12)); + float vapor = smoothstep(0.47, 0.78, vaporField) * exp(-travel * 0.026); + radiance += vapor * float3(0.025, 0.17, 0.20); + + float vignette = clamp(1.08 - 0.32 * length(uv), 0.48, 1.0); + return half4(toneMap(radiance * vignette), 1.0); +} + +half4 main(float2 fragCoord) { + float3 scene = float3(source.eval(fragCoord).rgb); + float3 generated = float3(shadertoyMain(fragCoord).rgb); + return half4(mix(scene, generated, clamp(mixAmount, 0.0, 1.0)), 1.0); +} diff --git a/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl.meta b/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl.meta new file mode 100644 index 00000000..f568f81f --- /dev/null +++ b/GameTestCase/Assets/Effects/ShaderToyTunnel.sksl.meta @@ -0,0 +1 @@ +{"id":"a1000000000000000000000000000014","type":"effect-program","path":"Effects/ShaderToyTunnel.sksl"} diff --git a/GameTestCase/Assets/Variants/classroom.variants b/GameTestCase/Assets/Variants/classroom.variants new file mode 100644 index 00000000..a4517339 --- /dev/null +++ b/GameTestCase/Assets/Variants/classroom.variants @@ -0,0 +1,19 @@ +{ + "version": 1, + "folder": "classroom", + "default": "classroom_idle_empty", + "dimensions": [ + { + "key": "scene", + "label": "场景" + }, + { + "key": "mode", + "label": "状态" + }, + { + "key": "focus", + "label": "人物" + } + ] +} diff --git a/GameTestCase/Assets/Variants/classroom.variants.meta b/GameTestCase/Assets/Variants/classroom.variants.meta new file mode 100644 index 00000000..ef8ffa43 --- /dev/null +++ b/GameTestCase/Assets/Variants/classroom.variants.meta @@ -0,0 +1,5 @@ +{ + "id": "a1000000000000000000000000000021", + "type": "image-variants", + "path": "Variants/classroom.variants" +} diff --git a/GameTestCase/Assets/bg classroom.jpeg b/GameTestCase/Assets/Variants/classroom/classroom_idle_empty.jpeg similarity index 100% rename from GameTestCase/Assets/bg classroom.jpeg rename to GameTestCase/Assets/Variants/classroom/classroom_idle_empty.jpeg diff --git a/GameTestCase/Assets/Variants/classroom/classroom_idle_empty.jpeg.meta b/GameTestCase/Assets/Variants/classroom/classroom_idle_empty.jpeg.meta new file mode 100644 index 00000000..d7145cd9 --- /dev/null +++ b/GameTestCase/Assets/Variants/classroom/classroom_idle_empty.jpeg.meta @@ -0,0 +1,14 @@ +{ + "id": "a1000000000000000000000000000001", + "type": "sprite", + "path": "Variants/classroom/classroom_idle_empty.jpeg", + "filter": "bilinear", + "compress": "none", + "gallery": [ + { + "id": 100, + "typeId": "cg", + "title": "Classroom" + } + ] +} diff --git a/GameTestCase/Assets/bg classroom speech teacher.jpeg b/GameTestCase/Assets/Variants/classroom/classroom_speech_teacher.jpeg similarity index 100% rename from GameTestCase/Assets/bg classroom speech teacher.jpeg rename to GameTestCase/Assets/Variants/classroom/classroom_speech_teacher.jpeg diff --git a/GameTestCase/Assets/Variants/classroom/classroom_speech_teacher.jpeg.meta b/GameTestCase/Assets/Variants/classroom/classroom_speech_teacher.jpeg.meta new file mode 100644 index 00000000..12c434f2 --- /dev/null +++ b/GameTestCase/Assets/Variants/classroom/classroom_speech_teacher.jpeg.meta @@ -0,0 +1,7 @@ +{ + "id": "a1000000000000000000000000000002", + "type": "sprite", + "path": "Variants/classroom/classroom_speech_teacher.jpeg", + "filter": "bilinear", + "compress": "none" +} diff --git a/GameTestCase/Assets/Variants/xy.variants b/GameTestCase/Assets/Variants/xy.variants new file mode 100644 index 00000000..c44c74f5 --- /dev/null +++ b/GameTestCase/Assets/Variants/xy.variants @@ -0,0 +1,15 @@ +{ + "version": 1, + "folder": "xy", + "default": "xy_idle", + "dimensions": [ + { + "key": "pose", + "label": "姿态" + }, + { + "key": "expression", + "label": "表情" + } + ] +} diff --git a/GameTestCase/Assets/Variants/xy.variants.meta b/GameTestCase/Assets/Variants/xy.variants.meta new file mode 100644 index 00000000..05c5d8e8 --- /dev/null +++ b/GameTestCase/Assets/Variants/xy.variants.meta @@ -0,0 +1,5 @@ +{ + "id": "a1000000000000000000000000000020", + "type": "image-variants", + "path": "Variants/xy.variants" +} diff --git a/GameTestCase/Assets/xy 2 (1).png b/GameTestCase/Assets/Variants/xy/solo.png similarity index 100% rename from GameTestCase/Assets/xy 2 (1).png rename to GameTestCase/Assets/Variants/xy/solo.png diff --git a/GameTestCase/Assets/Variants/xy/solo.png.meta b/GameTestCase/Assets/Variants/xy/solo.png.meta new file mode 100644 index 00000000..4d691279 --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/solo.png.meta @@ -0,0 +1,7 @@ +{ + "id": "4ff50b60a7d047a2915630c0e068faf8", + "type": "sprite", + "filter": "bilinear", + "compress": "none", + "path": "Variants/xy/solo.png" +} diff --git a/GameTestCase/Assets/xy (18).png b/GameTestCase/Assets/Variants/xy/xy_cutelyannoyed.png similarity index 100% rename from GameTestCase/Assets/xy (18).png rename to GameTestCase/Assets/Variants/xy/xy_cutelyannoyed.png diff --git a/GameTestCase/Assets/Variants/xy/xy_cutelyannoyed.png.meta b/GameTestCase/Assets/Variants/xy/xy_cutelyannoyed.png.meta new file mode 100644 index 00000000..dce16002 --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/xy_cutelyannoyed.png.meta @@ -0,0 +1,7 @@ +{ + "id": "5cffe47908384ea5a061a8f07cba0c2d", + "type": "sprite", + "filter": "bilinear", + "compress": "none", + "path": "Variants/xy/xy_cutelyannoyed.png" +} diff --git a/GameTestCase/Assets/xy (20).png b/GameTestCase/Assets/Variants/xy/xy_idle.png similarity index 100% rename from GameTestCase/Assets/xy (20).png rename to GameTestCase/Assets/Variants/xy/xy_idle.png diff --git a/GameTestCase/Assets/Variants/xy/xy_idle.png.meta b/GameTestCase/Assets/Variants/xy/xy_idle.png.meta new file mode 100644 index 00000000..3ced8671 --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/xy_idle.png.meta @@ -0,0 +1,7 @@ +{ + "id": "a5cd492b4d7242738f000dbd93efec15", + "type": "sprite", + "filter": "bilinear", + "compress": "none", + "path": "Variants/xy/xy_idle.png" +} diff --git a/GameTestCase/Assets/xy (17).png b/GameTestCase/Assets/Variants/xy/xy_shock.png similarity index 100% rename from GameTestCase/Assets/xy (17).png rename to GameTestCase/Assets/Variants/xy/xy_shock.png diff --git a/GameTestCase/Assets/Variants/xy/xy_shock.png.meta b/GameTestCase/Assets/Variants/xy/xy_shock.png.meta new file mode 100644 index 00000000..0732681e --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/xy_shock.png.meta @@ -0,0 +1,7 @@ +{ + "id": "a1000000000000000000000000000004", + "type": "sprite", + "path": "Variants/xy/xy_shock.png", + "filter": "bilinear", + "compress": "none" +} diff --git a/GameTestCase/Assets/xy 2 (4).png b/GameTestCase/Assets/Variants/xy/xy_shy.png similarity index 100% rename from GameTestCase/Assets/xy 2 (4).png rename to GameTestCase/Assets/Variants/xy/xy_shy.png diff --git a/GameTestCase/Assets/Variants/xy/xy_shy.png.meta b/GameTestCase/Assets/Variants/xy/xy_shy.png.meta new file mode 100644 index 00000000..6c07ffec --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/xy_shy.png.meta @@ -0,0 +1,7 @@ +{ + "id": "85fd444ac26a4d92848eff57e9a966c5", + "type": "sprite", + "filter": "bilinear", + "compress": "none", + "path": "Variants/xy/xy_shy.png" +} diff --git a/GameTestCase/Assets/Variants/xy/xy_wink.png b/GameTestCase/Assets/Variants/xy/xy_wink.png new file mode 100644 index 00000000..f8f79855 Binary files /dev/null and b/GameTestCase/Assets/Variants/xy/xy_wink.png differ diff --git a/GameTestCase/Assets/Variants/xy/xy_wink.png.meta b/GameTestCase/Assets/Variants/xy/xy_wink.png.meta new file mode 100644 index 00000000..0dbb133c --- /dev/null +++ b/GameTestCase/Assets/Variants/xy/xy_wink.png.meta @@ -0,0 +1,7 @@ +{ + "id": "313ac965658640c68f5a39794536a5ab", + "type": "sprite", + "filter": "bilinear", + "compress": "none", + "path": "Variants/xy/xy_wink.png" +} diff --git a/GameTestCase/Assets/bg classroom speech teacher.jpeg.meta b/GameTestCase/Assets/bg classroom speech teacher.jpeg.meta deleted file mode 100644 index 339e449d..00000000 --- a/GameTestCase/Assets/bg classroom speech teacher.jpeg.meta +++ /dev/null @@ -1 +0,0 @@ -{"id":"a1000000000000000000000000000002","type":"sprite","path":"bg classroom speech teacher.jpeg","filter":"bilinear","compress":"none"} diff --git a/GameTestCase/Assets/bg classroom.jpeg.meta b/GameTestCase/Assets/bg classroom.jpeg.meta deleted file mode 100644 index 480b7af9..00000000 --- a/GameTestCase/Assets/bg classroom.jpeg.meta +++ /dev/null @@ -1 +0,0 @@ -{"id":"a1000000000000000000000000000001","type":"sprite","path":"bg classroom.jpeg","filter":"bilinear","compress":"none","gallery":[{"id":100,"typeId":"cg","title":"Classroom"}]} diff --git a/GameTestCase/Assets/xy (17).png.meta b/GameTestCase/Assets/xy (17).png.meta deleted file mode 100644 index 275a0fed..00000000 --- a/GameTestCase/Assets/xy (17).png.meta +++ /dev/null @@ -1 +0,0 @@ -{"id":"a1000000000000000000000000000004","type":"sprite","path":"xy (17).png","filter":"bilinear","compress":"none"} diff --git a/GameTestCase/Graph/groups/harbor.rawgalgroup b/GameTestCase/Graph/groups/harbor.rawgalgroup index a7592e0c..0b94d850 100644 --- a/GameTestCase/Graph/groups/harbor.rawgalgroup +++ b/GameTestCase/Graph/groups/harbor.rawgalgroup @@ -5,6 +5,34 @@ { "id": "route-blinds", "type": "transition.blinds", "parameters": { "playbackHandleId": "route-blinds", "oldHandleId": "background", "newHandleId": "route-background", "assetId": "a1000000000000000000000000000002", "maskProgram": "a1000000000000000000000000000008", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "displayMode": "Fill", "bladeCount": 14, "orientation": "Vertical", "durationFrames": 42 } }, { "id": "snow", "type": "particle.play", "parameters": { "instanceId": "route-particles", "z": 100, "parameters": { "particleTexture": "a1000000000000000000000000000006", "rate": 72, "maxParticles": 240, "seed": 20260929, "initial": { "lifetime": { "min": 3, "max": 4 }, "velocityX": { "min": -28, "max": -16 }, "velocityY": { "min": 68, "max": 92 }, "size": { "min": 0.6, "max": 0.9 }, "rotationDegrees": { "min": 0, "max": 360 }, "angularVelocityDegrees": { "min": -25, "max": 25 }, "color": { "min": "#DDEFFFFF", "max": "#FFFFFFFF" } }, "motion": { "gravityY": 12, "noise": 24, "drag": 0.08 }, "lifetime": { "opacity": [ { "time": 0, "value": 0 }, { "time": 0.12, "value": 1 }, { "time": 0.8, "value": 1 }, { "time": 1, "value": 0 } ], "size": [ { "time": 0, "value": 0.8 }, { "time": 1, "value": 1.1 } ] }, "shape": { "type": "box", "x": 640, "y": -12, "width": 1280, "height": 0 } } } }, { "id": "spark-burst", "type": "particle.burst", "parameters": { "count": 48, "z": 110, "parameters": { "particleTexture": "a1000000000000000000000000000005", "maxParticles": 48, "seed": 20260930, "initial": { "lifetime": { "min": 1.2, "max": 1.8 }, "velocityX": { "min": -20, "max": 20 }, "velocityY": { "min": -110, "max": -70 }, "size": { "min": 0.45, "max": 0.65 }, "rotationDegrees": { "min": 0, "max": 360 }, "angularVelocityDegrees": { "min": -180, "max": 180 }, "color": { "min": "#FFF0A0FF", "max": "#FFFFFFFF" } }, "motion": { "gravityY": 120, "radialVelocity": 80, "drag": 0.25 }, "lifetime": { "size": [ { "time": 0, "value": 0.5 }, { "time": 0.25, "value": 1 }, { "time": 1, "value": 0.2 } ], "opacity": [ { "time": 0, "value": 1 }, { "time": 0.7, "value": 1 }, { "time": 1, "value": 0 } ], "velocity": [ { "time": 0, "value": 1 }, { "time": 1, "value": 0.35 } ], "color": [ { "time": 0, "color": "#FFFFFFFF" }, { "time": 1, "color": "#FF7040FF" } ] }, "shape": { "type": "circle", "x": 640, "y": 420, "radius": 24 }, "flipbook": { "columns": 2, "rows": 2, "frameCount": 4, "cyclesOverLifetime": { "min": 0.9, "max": 1.1 }, "startFrame": { "min": 0, "max": 1 }, "loop": false } } } }, - { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.line.content" } } + { "id": "line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.line.content" } }, + { "id": "shader-intro", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-intro.content" } }, + { "id": "shader-bloom", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000012", "instanceId": "sample-scene-bloom", "parameters": { "strength": 3, "radius": 40 } } }, + { "id": "shader-bloom-line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-bloom.content" } }, + { "id": "shader-bloom-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-scene-bloom" } }, + { "id": "shader-blur-horizontal", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000013", "instanceId": "sample-scene-blur-horizontal", "parameters": { "radius": 28, "direction": [1, 0] } } }, + { "id": "shader-blur-vertical", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000013", "instanceId": "sample-scene-blur-vertical", "parameters": { "radius": 28, "direction": [0, 1] } } }, + { "id": "shader-blur-line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-blur.content" } }, + { "id": "shader-blur-vertical-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-scene-blur-vertical" } }, + { "id": "shader-blur-horizontal-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-scene-blur-horizontal" } }, + { "id": "shader-toy-solid-background", "type": "layer.showColor", "parameters": { "handleId": "shader-toy-background", "color": "#0C1524", "z": 1000, "opacity": 1 } }, + { "id": "shader-toy-hide-route-background", "type": "layer.hide", "parameters": { "handleId": "route-background" } }, + { "id": "shader-toy-hide-portrait", "type": "layer.hide", "parameters": { "handleId": "teacher" } }, + { "id": "shader-toy-stop-snow", "type": "particle.stop", "parameters": { "instanceId": "route-particles" } }, + { "id": "shader-toy-tunnel", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000014", "instanceId": "sample-shader-toy-tunnel", "parameters": { "time": 0, "mixAmount": 1 } } }, + { "id": "shader-toy-tunnel-time", "type": "animation.animate", "parameters": { "playbackHandleId": "sample-shader-toy-tunnel-time", "handleId": "sample-shader-toy-tunnel", "property": "time", "from": 0, "to": 30, "duration": 30, "curve": "Linear", "blocking": "false", "skippable": "false", "loopMode": "Loop" } }, + { "id": "shader-toy-tunnel-line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-toy-tunnel.content" } }, + { "id": "shader-toy-tunnel-time-stop", "type": "animation.stop", "parameters": { "playbackHandleId": "sample-shader-toy-tunnel-time", "mode": "CompleteImmediately" } }, + { "id": "shader-toy-tunnel-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-shader-toy-tunnel" } }, + { "id": "shader-toy-grid", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000015", "instanceId": "sample-shader-toy-grid", "parameters": { "time": 0, "mixAmount": 1 } } }, + { "id": "shader-toy-grid-time", "type": "animation.animate", "parameters": { "playbackHandleId": "sample-shader-toy-grid-time", "handleId": "sample-shader-toy-grid", "property": "time", "from": 0, "to": 30, "duration": 30, "curve": "Linear", "blocking": "false", "skippable": "false", "loopMode": "Loop" } }, + { "id": "shader-toy-grid-line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-toy-grid.content" } }, + { "id": "shader-toy-grid-time-stop", "type": "animation.stop", "parameters": { "playbackHandleId": "sample-shader-toy-grid-time", "mode": "CompleteImmediately" } }, + { "id": "shader-toy-grid-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-shader-toy-grid" } }, + { "id": "shader-toy-rain", "type": "effect.apply", "parameters": { "program": "a1000000000000000000000000000016", "instanceId": "sample-shader-toy-rain", "parameters": { "time": 0, "mixAmount": 1 } } }, + { "id": "shader-toy-rain-time", "type": "animation.animate", "parameters": { "playbackHandleId": "sample-shader-toy-rain-time", "handleId": "sample-shader-toy-rain", "property": "time", "from": 0, "to": 30, "duration": 30, "curve": "Linear", "blocking": "false", "skippable": "false", "loopMode": "Loop" } }, + { "id": "shader-toy-rain-line", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.harbor.entry.shader-toy-rain.content" } }, + { "id": "shader-toy-rain-time-stop", "type": "animation.stop", "parameters": { "playbackHandleId": "sample-shader-toy-rain-time", "mode": "CompleteImmediately" } }, + { "id": "shader-toy-rain-stop", "type": "effect.stop", "parameters": { "instanceId": "sample-shader-toy-rain" } } ] } diff --git a/GameTestCase/Graph/groups/intro.rawgalgroup b/GameTestCase/Graph/groups/intro.rawgalgroup index 71e7319a..49bc009e 100644 --- a/GameTestCase/Graph/groups/intro.rawgalgroup +++ b/GameTestCase/Graph/groups/intro.rawgalgroup @@ -4,10 +4,23 @@ "entries": [ { "id": "dialogue-show", "type": "dialogue.show", "parameters": {} }, { "id": "bgm-fade-in", "type": "audio.play", "parameters": { "trackId": "bgm", "asset": "a1000000000000000000000000000010", "gain": 1, "placement": "replace", "fadeIn": 2 } }, - { "id": "background", "type": "layer.show", "parameters": { "handleId": "background", "assetId": "a1000000000000000000000000000001", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "opacity": 1, "displayMode": "Fill" } }, + { "id": "background", "type": "variants.show", "parameters": { "handleId": "background", "assetId": "a1000000000000000000000000000021", "transform": { "x": 0, "y": 0, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 0, "opacity": 1, "displayMode": "Fill" } }, { "id": "unlock-gallery", "type": "gallery.unlock", "parameters": { "id": 100 } }, - { "id": "portrait", "type": "layer.show", "parameters": { "handleId": "teacher", "assetId": "a1000000000000000000000000000004", "transform": { "x": 420, "y": 80, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 10, "opacity": 1, "displayMode": "Native" } }, + { "id": "portrait", "type": "variants.show", "parameters": { "handleId": "teacher", "assetId": "a1000000000000000000000000000020", "selection": { "expression": "shock" }, "transform": { "x": 420, "y": 80, "rotationDegrees": 0, "scaleX": 1, "scaleY": 1 }, "z": 10, "opacity": 1, "displayMode": "Native" } }, { "id": "intro-line-1", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-1.content" } }, + { "id": "variants-shy", "type": "variants.select", "parameters": { "handleId": "teacher", "dimension": "expression", "value": "shy" } }, + { "id": "variants-exact", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-exact.content" } }, + { "id": "variants-solo", "type": "variants.select", "parameters": { "handleId": "teacher", "dimension": "pose", "value": "solo" } }, + { "id": "variants-reduced", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-reduced.content" } }, + { "id": "variants-return", "type": "variants.select", "parameters": { "handleId": "teacher", "dimension": "pose", "value": "xy" } }, + { "id": "variants-remembered", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-remembered.content" } }, + { "id": "classroom-speech", "type": "variants.select", "parameters": { "handleId": "background", "dimension": "mode", "value": "speech" } }, + { "id": "variants-default", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-default.content" } }, + { "id": "classroom-teacher", "type": "variants.select", "parameters": { "handleId": "background", "dimension": "focus", "value": "teacher" } }, + { "id": "variants-scene", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-scene.content" } }, + { "id": "variants-hide", "type": "layer.hide", "parameters": { "handleId": "teacher" } }, + { "id": "variants-reshow", "type": "variants.show", "parameters": { "handleId": "teacher", "assetId": "a1000000000000000000000000000020", "transform": { "x": 420, "y": 80 }, "z": 10, "displayMode": "Native" } }, + { "id": "variants-reset", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.variants-reset.content" } }, { "id": "hide-dialogue-for-video", "type": "dialogue.hide", "parameters": {} }, { "id": "video-blocking", "type": "video.play", "parameters": { "asset": "a1000000000000000000000000000011", "handleId": "sample-video", "blocking": "true", "skipPoints": [3, 6, 9, 999999], "z": 5, "opacity": 0.85, "displayMode": "Uniform" } }, { "id": "show-dialogue-after-video", "type": "dialogue.show", "parameters": {} }, @@ -28,6 +41,7 @@ { "id": "intro-line-high-pass", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-high-pass.content" } }, { "id": "bgm-stop-high-pass", "type": "audio.seffect.stop", "parameters": { "trackId": "bgm", "instanceId": "sample-bgm-high-pass" } }, { "id": "portrait-jitter", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-jitter", "handleId": "teacher", "property": "transform.y", "from": 0, "to": 12, "duration": 0.06, "curve": "EaseInOut", "blocking": "false", "skippable": "false", "loopMode": "PingPong", "blendMode": "Additive" } }, + { "id": "variants-during-jitter", "type": "variants.select", "parameters": { "handleId": "teacher", "dimension": "expression", "value": "wink" } }, { "id": "intro-line-2", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-2.content" } }, { "id": "portrait-spin", "type": "animation.animate", "parameters": { "playbackHandleId": "portrait-spin", "handleId": "teacher", "property": "transform.rotationDegrees", "from": 0, "to": 360, "duration": 0.7, "curve": "EaseInOut", "blocking": "true", "skippable": "true", "loopMode": "Once", "blendMode": "Replace" } }, { "id": "intro-line-3", "type": "dialogue.text", "parameters": { "speaker": "guide", "content": "group.intro.entry.intro-line-3.content" } }, diff --git a/GameTestCase/I18n/en-US/groups/harbor.json b/GameTestCase/I18n/en-US/groups/harbor.json index 66b5e9c2..14a8438a 100644 --- a/GameTestCase/I18n/en-US/groups/harbor.json +++ b/GameTestCase/I18n/en-US/groups/harbor.json @@ -1,3 +1,9 @@ { - "group.harbor.entry.line.content": "The route combines a persistent rate emitter with a one-shot, per-particle flipbook burst." + "group.harbor.entry.line.content": "The route combines a persistent rate emitter with a one-shot, per-particle flipbook burst.", + "group.harbor.entry.shader-intro.content": "Now I’ll show five scene-post visual effects one at a time. Blur uses horizontal and vertical Gaussian passes; the ShaderToy demos use a plain color stage.", + "group.harbor.entry.shader-bloom.content": "Bloom is the first shader sample after the route choice, just before Blur. Look for a soft halo spreading inward from the bright classroom windows.", + "group.harbor.entry.shader-blur.content": "Blur uses a separable Gaussian with five iteration bands and 15 dense taps in each horizontal and vertical pass. Its 28-pixel radius softly diffuses the scene.", + "group.harbor.entry.shader-toy-tunnel.content": "ShaderToy sample: an animated procedural fog tunnel.", + "group.harbor.entry.shader-toy-grid.content": "ShaderToy sample: an animated, perspective 3D grid.", + "group.harbor.entry.shader-toy-rain.content": "ShaderToy sample: a raymarched rainy road with reflections." } diff --git a/GameTestCase/I18n/en-US/groups/intro.json b/GameTestCase/I18n/en-US/groups/intro.json index a542cc4a..8ba341a6 100644 --- a/GameTestCase/I18n/en-US/groups/intro.json +++ b/GameTestCase/I18n/en-US/groups/intro.json @@ -1,4 +1,10 @@ { + "group.intro.entry.variants-exact.content": "Exact match: xy_shy. Next, switch the first dimension to exercise a missing combination.", + "group.intro.entry.variants-reduced.content": "Requested solo_shy; removing the second dimension shows solo (a copy of wink). Save and load here: the shy selection is retained.", + "group.intro.entry.variants-remembered.content": "Switching the first dimension back to xy restores the remembered xy_shy.", + "group.intro.entry.variants-default.content": "The background request classroom_speech_empty has no match, so the explicit default classroom_idle_empty is shown. Save and load here too.", + "group.intro.entry.variants-scene.content": "Selecting teacher in the third dimension matches classroom_speech_teacher, retaining the earlier speech request.", + "group.intro.entry.variants-reset.content": "Hide and re-show resets the portrait to xy_idle. A later selection switches to wink during the loop animation.", "group.intro.entry.intro-line-1.content": "The Runtime sample now builds from Raw authoring content. The test BGM is fading in on the bgm track.", "group.intro.entry.intro-video-background.content": "niko.mp4 is a scene layer below the portrait, with rotation, scale and opacity. This non-blocking video lets dialogue advance. Advance to stop it.", "group.intro.entry.intro-line-effect-on.content": "A seffect.gain effect is now active, ducking this BGM track while the scene continues.", diff --git a/GameTestCase/I18n/zh-CN/groups/harbor.json b/GameTestCase/I18n/zh-CN/groups/harbor.json index ab3babb7..d9faba42 100644 --- a/GameTestCase/I18n/zh-CN/groups/harbor.json +++ b/GameTestCase/I18n/zh-CN/groups/harbor.json @@ -1,3 +1,9 @@ { - "group.harbor.entry.line.content": "这条路线同时展示持续发射器,以及使用逐粒子序列帧的一次性粒子爆发。" + "group.harbor.entry.line.content": "这条路线同时展示持续发射器,以及使用逐粒子序列帧的一次性粒子爆发。", + "group.harbor.entry.shader-intro.content": "接下来依次展示五种场景后处理效果,每次只展示一种;Blur 内部会用横向和纵向两遍高斯采样,ShaderToy 示例会使用纯色舞台。", + "group.harbor.entry.shader-bloom.content": "Bloom 是路线选项后的第一个 shader 示例、紧接着介绍并先于 Blur;留意教室明亮窗户边缘向室内扩散的柔和光晕。", + "group.harbor.entry.shader-blur.content": "Blur 使用分离式高斯模糊,横向和纵向各经过 5 个迭代段、15 个密集采样点;28 像素半径让画面柔和扩散。", + "group.harbor.entry.shader-toy-tunnel.content": "ShaderToy 示例:动态程序化雾气隧道。", + "group.harbor.entry.shader-toy-grid.content": "ShaderToy 示例:带透视感的动态三维网格。", + "group.harbor.entry.shader-toy-rain.content": "ShaderToy 示例:通过光线步进绘制的雨夜道路与倒影。" } diff --git a/GameTestCase/I18n/zh-CN/groups/intro.json b/GameTestCase/I18n/zh-CN/groups/intro.json index 773c36d4..f6233fd9 100644 --- a/GameTestCase/I18n/zh-CN/groups/intro.json +++ b/GameTestCase/I18n/zh-CN/groups/intro.json @@ -1,4 +1,10 @@ { + "group.intro.entry.variants-exact.content": "图集精确命中 xy_shy。接下来切换第一维,测试缺组合时的回退。", + "group.intro.entry.variants-reduced.content": "请求 solo_shy,削减第二维后显示 solo(复用 wink 素材)。此处可存档再读档;shy 选择仍被记住。", + "group.intro.entry.variants-remembered.content": "第一维切回 xy,恢复之前选择的 xy_shy。", + "group.intro.entry.variants-default.content": "背景请求 classroom_speech_empty,没有匹配,显示显式默认图 classroom_idle_empty。此处也可存读档。", + "group.intro.entry.variants-scene.content": "第三维切换为 teacher,命中 classroom_speech_teacher;先前请求的 speech 得以保留。", + "group.intro.entry.variants-reset.content": "隐藏后重新显示立绘,选择重置为默认 xy_idle;后续循环动画期间会切换至 wink。", "group.intro.entry.intro-line-1.content": "运行时示例由 raw 作者内容编译而成。测试背景音乐正在 bgm 轨道上淡入。", "group.intro.entry.intro-video-background.content": "niko.mp4 是位于立绘下方的场景图层,带有旋转、缩放和透明度。非阻塞视频允许对话继续推进;推进后将停止视频。", "group.intro.entry.intro-line-effect-on.content": "seffect.gain 轨道效果已启用,场景继续时背景音乐音量会降低。", diff --git a/GameTestCase/README.md b/GameTestCase/README.md index 82154785..42556dce 100644 --- a/GameTestCase/README.md +++ b/GameTestCase/README.md @@ -23,6 +23,7 @@ and increment the count. Debug **Clear game data** resets player variables and r so the picker reappears. Use `--profile ` for an isolated test profile. - `layer.show`, `layer.replace`, `layer.move`, and `layer.hide` through `ILayerPresenter`; +- `variants.show` / `variants.select` for the XY portrait and three-dimensional classroom backgrounds (see the walkthrough below); - `niko.mp4` as a real scene layer: blocking `video.play` with `[3, 6, 9, 999999]` second skip points, then transformed non-blocking playback and explicit `video.stop`; the portrait stays above it by z order; - blocking `dialogue.text` with typewriter directives and `\skip` boundaries; - explicit `nvl.show`, `nvl.append`, `nvl.clear`, and `nvl.hide`, including rich-text and typewriter NVL history; @@ -36,6 +37,60 @@ The `.rawgalgroup` files are the sole authoring source. Build them with `GalNet. the generated `.galgroup` files use the version 3 compiled format (`typeId`, `arguments`, and reading metadata). The raw authoring format remains version 2. Every resource reference is its stable GUID; human-readable paths occur only in adjacent `.meta` files. +## Image variants walkthrough + +Rebuild the project after pulling these changes; an existing `Output/` still contains the old compiled intro. +The default intro now pauses at these checkpoints, in both English and Chinese: + +| Checkpoint entry | Request / action | Visible result | +| --- | --- | --- | +| `portrait` | `pose=xy`, `expression=shock` | `xy_shock` | +| `variants-exact` | select `expression=shy` | `xy_shy` (Exact) | +| `variants-reduced` | select `pose=solo`, retain `shy` | `solo` (Reduced); save/load here | +| `variants-remembered` | select `pose=xy` | `xy_shy` restored | +| `variants-default` | background `scene=classroom`, `mode=speech`, `focus=empty` | `classroom_idle_empty` (Default); save/load here | +| `variants-scene` | select `focus=teacher`, retain `speech` | `classroom_speech_teacher` (Exact) | +| `variants-reset` | hide and show the portrait again | `xy_idle` (default selection) | +| `intro-line-2` | select `expression=wink` during the existing jitter loop | `xy_wink`, same moving layer | + +`Assets/Variants/xy.variants` points to `xy/`: the first filename segment is `pose`, +the second is `expression`. The five renamed `xy_*.png` files retain their pixels; +`solo.png` is a copy of `xy_wink.png` used to demonstrate a shorter filename and retained selection. +This is a matching fixture, not an additional illustration. + +`Assets/Variants/classroom.variants` points to `classroom/`, with `scene`, `mode`, and +`focus` dimensions. The two original classroom JPEGs are now named +`classroom_idle_empty.jpeg` and `classroom_speech_teacher.jpeg`. Their resource IDs +and the classroom Gallery item remain unchanged, so existing route references work. +Each atlas directory contains only its own images; unrelated assets are not scanned. + +The descriptor IDs are `a1000000000000000000000000000020` (portrait) and +`a1000000000000000000000000000021` (classroom). Use these image-variants resources +in `variants.show`; each image still has its own sprite ID. See +[the authoring guide](../docs/manual/image-variants.md) for creation and editing. + +From the repository root, validate and rebuild with the current project contracts: + +```powershell +./scripts/build-project-contracts.ps1 -ContractsProject ./src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj -ProjectDirectory ./GameTestCase +dotnet run --project ./src/Editor/GalNet.Editor.Headless -- validate ./GameTestCase +dotnet run --project ./src/Editor/GalNet.Editor.Headless -- build ./GameTestCase +``` + +When supplying a custom host, register the image-variants decoder and prepare its catalog +before constructing execution modules; pass that catalog to `AddBuiltins(imageVariants: ...)`. +The default Avalonia and Headless Samples already prepare it. + +Choosing the second route option demonstrates five scene-post effect programs in sequence: bloom, +blur, and three adapted Shadertoy samples. Bloom is shown first, before blur; it uses a high-threshold +bright-pass, a wide Gaussian spread, and bounded LDR highlight roll-off. Blur is one visual demo +implemented as horizontal and vertical separable Gaussian passes, with five iteration bands and 15 dense taps per axis. +Before the Shadertoy +samples, the route background and portrait are hidden, the snow emitter is stopped, and a +solid-color scene layer is shown. The sequence proceeds directly into the ending on that stage; +it does not restore the harbor scene. Each visual demonstration stops before the next begins; +Blur's paired passes are one demonstration. Animated samples also stop their time animation before stopping the shader. + Entry IDs must be unique within each group, not across groups. Read progress uses the group ID together with the compiled entry ID. The current `progress.json` format is version 2; older progress is rejected rather than migrated. For an old diff --git a/README.md b/README.md index 2d79e9c8..0ed1a136 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ src/ Samples/ Avalonia 与 Headless 玩家示例 Launcher/ 共享 Launcher 与 Desktop/Browser/Android/iOS 宿主 test/ 按生产边界拆分的 10 个测试项目 -docs/ 当前 spec、长期 design、ADR 与文档索引 +docs/ 当前 spec、二次开发手册与文档索引 features/ 按 feature 保存的需求、设计、阶段计划与总结 scripts/ 可直接运行的样例脚本 GameTestCase/ 示例工程与 smoke test 内容 ``` -从[引擎使用手册](docs/engine.md)了解内容与运行期;基于本项目制作客户端或添加玩法能力时,请从[二次开发手册](docs/development.md)开始。程序集边界、依赖方向和测试职责见[架构文档](docs/spec/architecture.md)。 +从[文档索引](docs/README.md)进入当前规范;基于本项目制作客户端、添加玩法能力或定制示例时,请阅读[二次开发手册](docs/manual/)。程序集边界、依赖方向和测试职责见[架构文档](docs/spec/architecture.md)。 ## 构建与测试 @@ -78,6 +78,6 @@ Headless 示例在终端中交互;Avalonia 示例会启动桌面窗口。两 ## 开发流程 -较大的功能、架构调整和跨阶段重构使用 `features/F-YYYYMMDD-NN-short-slug/` 记录需求、设计、实施计划和验证总结。文档职责与推荐流程见 [Feature 工作流与 Agent 知识维护](docs/design/feature-workflow.md)。 +较大的功能、架构调整和跨阶段重构使用 `features/F-YYYYMMDD-NN-short-slug/` 记录需求、设计、实施计划和验证总结。当前可用的模块事实同步到 `docs/spec/`;feature 目录保留过程与历史,不替代规范。 GalNet 采用 [MIT License](LICENSE) 发布,允许用于商业和非商业项目。 diff --git a/artifacts-uiux-format-files.txt b/artifacts-uiux-format-files.txt new file mode 100644 index 00000000..3f9352a0 --- /dev/null +++ b/artifacts-uiux-format-files.txt @@ -0,0 +1,59 @@ +src/Editor/GalNet.Editor.Abstraction/Services/IProjectContentSession.cs +src/Editor/GalNet.Editor.Shared/Services/ProjectContentSession.cs +src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs +src/Editor/GalNet.Editor/Controls/AuthoringExpressionFieldEditors.cs +src/Editor/GalNet.Editor/Controls/CharacterFieldEditor.cs +src/Editor/GalNet.Editor/Controls/EditorFieldEditors.cs +src/Editor/GalNet.Editor/Controls/EntryListEditorControl.axaml.cs +src/Editor/GalNet.Editor/Controls/GameTextFieldEditor.cs +src/Editor/GalNet.Editor/Controls/GraphMiniMap.cs +src/Editor/GalNet.Editor/Controls/TransformFieldEditor.cs +src/Editor/GalNet.Editor/Dock/BuiltInDockContributions.cs +src/Editor/GalNet.Editor/Dock/DockInspectorCoordinator.cs +src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs +src/Editor/GalNet.Editor/Inspector/ViewModels/EntryInspectorViewModel.cs +src/Editor/GalNet.Editor/Inspector/ViewModels/InspectorHostViewModel.cs +src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml.cs +src/Editor/GalNet.Editor/Models/Graph/EntryParameterSchema.cs +src/Editor/GalNet.Editor/Models/Graph/GraphPresentationModels.cs +src/Editor/GalNet.Editor/Services/AuthorNavigationService.cs +src/Editor/GalNet.Editor/Services/EditorGroupProjectionStore.cs +src/Editor/GalNet.Editor/Services/EditorLogSink.cs +src/Editor/GalNet.Editor/Services/EntryEditSession.cs +src/Editor/GalNet.Editor/Services/VariableAuthoringService.cs +src/Editor/GalNet.Editor/Themes/EditorButton.cs +src/Editor/GalNet.Editor/Themes/LinearBrushTransition.cs +src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs +src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs +src/Editor/GalNet.Editor/ViewModels/ExternalDebugPanelViewModel.cs +src/Editor/GalNet.Editor/ViewModels/GroupEditorPanelViewModel.cs +src/Editor/GalNet.Editor/ViewModels/LogPanelViewModel.cs +src/Editor/GalNet.Editor/ViewModels/ProblemsPanelViewModel.cs +src/Editor/GalNet.Editor/ViewModels/VariablesPanelViewModel.cs +src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs +src/Editor/GalNet.Editor/Views/LogPanelView.axaml.cs +src/Editor/GalNet.Editor/Views/NodeGraphPanelView.axaml.cs +src/Editor/GalNet.Editor/Views/ProblemsPanelView.axaml.cs +src/Editor/GalNet.Editor/Views/TimelinePanelView.axaml.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/DefaultFieldEditors.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/DtoEditorContext.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/DtoEditorHost.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/FieldBinding.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/FieldDraftState.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/FieldEditorContext.cs +src/Presentation/GalNet.Avalonia.Controls/Forms/StructuredFieldEditor.cs +src/Presentation/GalNet.Avalonia.Controls/Timeline/TimelineCanvasModel.cs +src/Shared/GalNet.Contracts.Builtins/Entry/ComplexPrimitiveOptions.cs +src/Shared/GalNet.Contracts.Builtins/Entry/CompositeOptions.cs +src/Shared/GalNet.Contracts.Builtins/Entry/PrimitiveOptions.cs +src/Shared/GalNet.Contracts.Builtins/Entry/SimplePrimitiveOptions.cs +src/Shared/GalNet.Contracts.Builtins/Entry/VariantsOptions.cs +src/Shared/GalNet.Core/Parameters/EditorHints.cs +src/Shared/GalNet.Core/Parameters/FieldDto.cs +src/Tooling/GalNet.SchemaGenerator/FieldSchemaGenerator.cs +test/GalNet.Editor.Shared.Tests/ProjectContentSessionTests.cs +test/GalNet.Editor.Tests/EditorUiuxIntegrationTests.cs +test/GalNet.Editor.Tests/EditorUiuxTests.cs +test/GalNet.Editor.Tests/ProjectFilesPanelTests.cs +test/GalNet.Editor.Tests/TimelineDockTests.cs +test/GalNet.Editor.Tests/TimelinePropertyTreeTests.cs diff --git a/docs/README.md b/docs/README.md index 9a2f53d0..e9f03c09 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,16 +1,35 @@ # GalNet 文档 -- [引擎使用手册](engine.md):内容、编译、Runtime、推进、存档和宿主的总览入口。 -- [二次开发手册](development.md):不修改 Shared 层时,如何添加项目自有原语、模块、页面和资源能力。 -- [架构](spec/architecture.md):当前程序集边界、游戏运行、编辑器工作流与维护规则。 -- [运行时](spec/runtime.md):游戏引擎、变量、快照与条目执行的精确契约。 -- [条目类型](spec/entry-types.md):primitive/composite、模块和内置条目的精确契约。 -- [文件格式](spec/file-formats.md):项目目录、图、条目组和当前 `.galpak` 发布布局。 -- [资源](spec/assets.md):资源提供者、归档和包格式。 -- [Avalonia 游戏页面](spec/control.md):共享游戏页面、导航、组合和 Avalonia 呈现边界。 -- [国际化](spec/i18n.md):编辑器本地化、运行时文本解析器和项目语言设置。 -- [术语表](glossary.md):项目术语。 -- [杂项待办](design/misc-todo.md):尚未实现的工程性后续工作。 -- [Feature 工作流与 Agent 知识维护](design/feature-workflow.md):推荐的 feature 记录流程,以及开发者文档、feature 历史与 agent 知识的职责边界。 -- [分阶段渲染与 Effect 管线计划](design/render-effects-pipeline-plan.md):Layer Source、Shader 效果、UI 前后全屏后处理与 Flipbook 的实施计划。 -- [音频系统设计](design/audio-system-design.md):尚未实施的音频能力设计。 +这里仅保留当前可用的项目文档。功能开发过程、已废弃方案和决策记录不作为规范来源;需要追溯一次实现时,查看对应的 `features/F-*/` 记录。 + +## 项目入口 + +- [设计理念与架构概览](philosophy.md):分层原则、依赖方向与高层架构图。 +- [术语表](glossary.md):项目通用术语的统一定义。 + +## 规范 + +`spec/` 描述已实现的模块契约和当前限制。新增或修改功能后,应更新其唯一对应页面,而不是另建设计或 ADR 文档。 + +- [架构与程序集边界](spec/architecture.md) +- [运行时与存档](spec/runtime.md) +- [条目类型与项目契约](spec/entry-types.md) +- [资源管理](spec/assets.md) +- [项目与发布格式](spec/file-formats.md) +- [Avalonia 游戏页面](spec/control.md) +- [国际化](spec/i18n.md) +- [Editor 游戏本地化](spec/editor-game-localization.md) +- [Editor Graph 编辑](spec/editor-graph.md) +- [Editor 历史与保存](spec/editor-history-and-save.md) +- [Editor 项目文件操作](spec/editor-files.md) +- [Dock 集成](spec/dock-design.md) + +## 二次开发手册 + +- [接入新的表现层](manual/new-presentation-layer.md):为 WPF、WinUI 或其他 UI 框架实现宿主和呈现适配的最小路径。 +- [定制 Avalonia Sample](manual/customize-avalonia-sample.md):基于现有 Sample 落地项目配置、主题、原语和平台能力。 +- [注册通用 action](manual/custom-actions.md):用固定双字符串原语调用项目逻辑,配置同步、异步和完整实例的运行行为。 +- [对接外部游戏调试](manual/external-game-debugging.md):可选宿主接入、测试启动配置、实例变量、路线定位与缓存边界。 +- [编辑游戏文本与翻译](manual/game-localization.md):开发语言创作、多语言表格、语言顺序和草稿处理。 +- [创建图片差分图集](manual/image-variants.md):目录导入、配置与差分预览、剧情初始选择及维度切换。 +- [使用与扩展 Editor 创作工作区](manual/editor-authoring-workspace.md):常用字段、Inspector、会话草稿、问题定位与手动布局。 diff --git a/docs/adr/0002-entry-schema-execution-registration.md b/docs/adr/0002-entry-schema-execution-registration.md deleted file mode 100644 index dd5a6bdf..00000000 --- a/docs/adr/0002-entry-schema-execution-registration.md +++ /dev/null @@ -1,25 +0,0 @@ -# ADR-0002:Entry 契约与执行注册分离 - -状态:Accepted(2026-10-10) - -## 上下文 - -旧 EntryModule 同时保存参数 schema、composite 展开和 primitive 工厂。编译器必须创建运行期模块,平台依赖与契约耦合;按模块/前缀排他注册不利于局部替换。此次迁移不保留旧 API 或存档兼容,不改造 Editor/Launcher 功能。 - -## 决定 - -`IEntryModule` 只声明 schema 和编译期展开;`IPrimitiveModule` 贡献完整指令 ID 与实例工厂并拥有共享状态。`GameRegistration` 独立组合两者。前缀和 schema 模块名不排他,仅完整 ID 必须唯一。主内置入口 `AddBuiltins` 组合 schema 和所有执行模块;开发者可只选择 schema、部分内置逻辑或全部自定义实现。 - -轨道规则留在平台无关 `AudioRuntime`,播放由 `IAudioPlaybackHost` 提供。Avalonia/LibVLC 和 Unity/原生音频均通过组合接口接入;不强制继承内置基类。执行模块是共享状态的生命周期单元,指令是路由单元。 - -## 候选与取舍 - -- 逐条注册可用于小扩展,但不强迫所有开发者采用;领域模块便于共享轨道和服务。 -- 继续将工厂放入 schema 会维持编译器/平台耦合,因此不采用。 -- 强制平台继承轨道基类会绑定后端结构;组合接口更容易适配已有播放系统。 - -## 后果 - -宿主须明确管理 Engine、view、presenter 的释放顺序;schema-only view 可构建,但 GameEngine 拒绝缺失执行实现的 Graph。新执行模块必须提供对应 schema,重复 ID 明确失败。旧模块工厂 API 与无消费者音频协议删除;快照移除冗余索引并升级版本 3。验证由注册、运行时、内置和跨层集成测试覆盖,使用示例见 [development](../development.md)。 - -旧 Presentation.Defaults 的唯一剩余空 view 移入 Presentation.Abstractions,独立 Defaults 项目和无用引用删除。 diff --git a/docs/adr/0003-startup-game-layout-themes.md b/docs/adr/0003-startup-game-layout-themes.md deleted file mode 100644 index 5217594f..00000000 --- a/docs/adr/0003-startup-game-layout-themes.md +++ /dev/null @@ -1,23 +0,0 @@ -# 0003:启动期组合玩家布局主题与独立配色 - -状态:Accepted,2026-10-11。 - -## 背景 - -GalNet 的行为控件应可复用,开发者需要完整玩家界面的主题参考,同时能仅替换某一个页面。GamePage 的 scene/presenter/打字机/NVL 状态已有共享生命周期,复制游戏页面逻辑会破坏状态与输入接线。 - -## 决定 - -采用启动期固定的 IGameLayoutTheme:提供局部样式及普通页面注册。页面映射按默认→主题→宿主覆盖组合后固定,最终 View 使用当前游戏 Scope。布局和 Palette 独立,官方示例集中在可选 Themes 项目;Shared 与 GameView 不反向依赖该集合。 - -普通页面使用共享 ViewModel 配合可替换 View。GamePage 为稳定 TemplatedControl,拥有 Scene/Dialogue/Nvl/Choice 内容,主题以验证过的四个 ContentPresenter 安排布局。自定义 GamePage 类型须继承该稳定宿主,并与 Runtime 使用同一 Scoped 实例。 - -官方主题把 Fluent 限制在玩家视觉根内,公共色表映射为语义 Color/Brush;外层宿主资源拥有覆盖优先权。截图等独立资源路径显式引入主题。 - -## 备选与后果 - -只抽颜色无法改变结构;所有 Page 模板化会增加简单页面的扩展负担;每套主题复制游戏页面会复制生命周期。选定方案集中运行行为,并允许单页覆盖而无需新主题。 - -运行中切换主题需要独立处理打字机任务、NVL 历史、模板部件和媒体状态,本期不支持。DynamicResource 用于资源引用,不代表完整运行期切换保证。官方布局适合 Sample 的宽屏设计画布;宿主不同尺寸需自行验证。 - -来源:[feature](../../features/F-20261011-01-game-ui-themes/feature.md)、[设计](../design/game-ui-themes.md)、[当前契约](../spec/control.md)。 diff --git a/docs/adr/0004-project-entry-contract-assembly.md b/docs/adr/0004-project-entry-contract-assembly.md deleted file mode 100644 index 69a2c68e..00000000 --- a/docs/adr/0004-project-entry-contract-assembly.md +++ /dev/null @@ -1,35 +0,0 @@ -# ADR-0004:项目 Entry 契约程序集与平台执行独立组合 - -状态:Accepted(2026-10-13) - -## 上下文 - -ADR-0002 已将 IEntryModule/schema/composite 与 IPrimitiveModule 执行注册分离。当前内置契约和执行仍位于同一程序集,CLI/Editor 固定使用全部内置 schema,无法表达每个游戏项目的实际支持集合。 - -开发者需要一个独立契约项目,游戏工程直接引用它;构建产物也能独立部署给工具。AI 可以直接编辑内容文件,工具通过同一契约校验/编译。单平台执行模块允许直接操作视图。 - -## 决定 - -- 内置 schema、参数类型、纯校验及 Composite 展开抽到仅依赖 Core 的新契约项目,提供组件选择和全量组合入口;执行模块引用契约。 -- 每个内容项目在固定目录部署固定名称契约 DLL,以确定的静态只读模块集合声明项目支持范围。CLI/Editor 的外层加载器构造项目 catalog,不扫描并加入未选择能力。 -- 游戏通过普通项目引用调用同一契约集合,另行注册执行与平台实现;工具动态加载不要求游戏发布也采用动态加载。 -- Editor catalog 及其消费者归项目生命周期;框架 Core 只认识 IEntryCatalog,不认识 DLL 路径或平台资源。 -- 契约 DLL 不创建执行模块、捕获会话状态或引用平台实现。执行与契约按完整 typeId 连接,平台展示通过接口或位于平台外层的直接调用接入。 -- 校验/编译/build/export 共用选定 catalog;缺契约/缺执行明确失败,不回退全内置或静默跳过。JSON 描述是派生查询结果。 - -## 取舍与后果 - -固定入口降低发现复杂度,静态列表复用现有模块协议;代价是开发者必须部署与框架兼容的契约及依赖。加载器需要共享公共类型身份并隔离同名项目程序集。 - -程序集拆分会改变内置作者类型的命名空间和引用;迁移一次性完成,不复制两套参数规则。工具无需平台执行依赖,单平台扩展也无需被强制改写成通用 presenter。 - -schema 可声明编辑/编译契约,无法自行证明实际媒体/视觉后端可用;宿主需要检查执行覆盖和平台端口。契约版本与游戏构建应保持一致,不承诺任意旧 DLL 二进制兼容。 - -实施已验证独立内置模块、两个同名项目 DLL 的隔离与共享类型身份、CLI/Editor 项目 catalog 生命周期、使用相同契约的编译/导出,以及单平台截图执行。固定文件为 `Contracts/GalNet.Project.Contracts.dll`,入口为 `GalNet.Project.Contracts.ProjectSchemas.Modules`。模块 ID 可重复,仅完整 entry ID 排他。实现结果和环境边界见关联 feature;不提供旧接口或旧 DLL 的兼容 fallback。 - -## 关联 - -- [Feature](../../features/F-20261013-01-editor-cli-operations/feature.md) -- [设计](../../features/F-20261013-01-editor-cli-operations/design.md) -- [阶段计划](../../features/F-20261013-01-editor-cli-operations/phase-plan.md) -- [ADR-0002](0002-entry-schema-execution-registration.md) diff --git a/docs/adr/0005-modular-persistent-state.md b/docs/adr/0005-modular-persistent-state.md deleted file mode 100644 index 8544c189..00000000 --- a/docs/adr/0005-modular-persistent-state.md +++ /dev/null @@ -1,27 +0,0 @@ -# ADR-0005:模块注册持久状态与恢复处理器 - -状态:Accepted(2026-10-15,用户确认设计并授权实施) - -## 上下文 - -固定 SceneState、逐字段 SnapshotCopy、场景实例镜像和 Builtins 中央重放清单使新增持久领域需要修改多个中央位置。音频逻辑与后端身份混合,不能直接保存。 - -## 决定 - -- GameState 按注册类型及稳定 item ID 持有必要数据;存档以稳定 StateId、section version 和 JSON payload 编码。GameSnapshot v4 直接替换旧格式,不保留兼容读取或迁移路径。 -- 执行模块通过 PrimitiveRegistry 贡献状态、恢复处理器及业务校验。Core/Runtime 不列举具体领域类型;schema 编译不创建执行模块。恢复依赖在 view 组合时检查并排序。 -- 复用中立 Layer、EffectInstance、ParticleEmitterInstance 数据对象,删除 Active* 镜像。记录修改与快照捕获共享 State.Update 的同步边界;索引只引用记录。Task、取消源、动画停止控制和后端播放身份留在运行期。 -- 读档验证候选状态后替换运行代;旧回调保留旧状态根。宿主 StateRestoreSession 统一恢复 continuation 与逆序清理;成功前不继续剧情,失败明确诊断。 -- AudioTrackState 与 AudioSEffectState 保存所有仍存在的轨道逻辑、暂停、队列与效果;恢复从头播放,使用当前玩家设置与项目轨道配置,不恢复媒体游标或淡化时钟。 - -## 候选与取舍 - -继续固定列表虽改动小,但保留中央扩展点与双写。仅把列表放入 Type 字典仍不能处理 codec、身份及资源所有权。采用注册表与统一恢复会话增加一份通用协议,换取各领域独立贡献及删除中央类型分支。 - -首期不增加反射发现、自动对象图持久化、迁移器、事件溯源或深度不可变模型。可变记录需要模块遵守同步写入契约;通用 codec 无法推导业务规则,模块仍承担领域验证与恢复语义。 - -## 关联 - -- [设计](../design/modular-game-state-and-restore.md) -- [运行时契约](../spec/runtime.md) -- [实现与验证](../../features/F-20261015-03-modular-game-state/summary.md) diff --git a/docs/adr/0006-game-host-layering.md b/docs/adr/0006-game-host-layering.md deleted file mode 100644 index 8b01edb4..00000000 --- a/docs/adr/0006-game-host-layering.md +++ /dev/null @@ -1,29 +0,0 @@ -# ADR-0006:游戏应用流程与 Avalonia 默认宿主分层 - -状态:Accepted(2026-10-16,用户确认重构范围后实施) - -## 上下文 - -Sample 已承载可复用的运行替换、存档协调、平台资源释放、设置和输入实现。新客户端若复制这些代码,会重复承担取消、恢复和清理的正确性责任。将这些能力全部放入 Runtime 或 Avalonia 页面程序集,会分别引入宿主策略或扩大页面层依赖。 - -## 决定 - -- Core/Runtime 继续提供调度、编译、指令执行与平台无关状态机制;指令通过独立模块注册。通用宿主不隐式加入 Builtins,也不要求 Layer Presenter。 -- 新增平台无关 GameHost,负责应用命令互斥、可替换运行生命周期、玩家持久化与偏好规则。Engine 仍独占剧情执行与执行 scope,不创建第二套推进器。 -- Presentation.Abstractions.Application 承载会话、显示能力及 Gallery 路径等无 UI 框架类型的契约。 -- Avalonia.GameHost 提供默认页面会话、运行绑定、展示组合、纹理租约、设置与平台输入/窗口适配。项目通过配置选择 catalog、执行模块及可替换工厂。 -- 文件资源与玩家存储组合留在 Storage.FileSystem;无 Avalonia 依赖的 LibVLC 音频实现独立为 Media.LibVlc。视频画面接入留在 Avalonia。 -- Sample 保留项目契约、启动参数、本地化配置和调试策略。Editor 保留创作工作区和自身文档生命周期,本轮不接入游戏预览页面。 -- 本轮不移动 Core 内现有场景模型,也不实现 WPF/WinUI/Unity。未来平台通过独立模块或 Presenter 接口接入,不要求复用 Avalonia 的渲染对象。 - -## 候选与取舍 - -继续放在 Sample 的成本最低,但每个宿主都需要复制生命周期代码;全部放入 Runtime 会混入应用策略;全部放入 GameView 会迫使页面组件依赖完整运行和内置模块。独立宿主层增加少量程序集与配置入口,换取跨 UI 框架的会话复用和轻量 Sample。 - -迁移会改变应用接口与音频实现的命名空间;调用点一次性迁移,不保留重复实现。默认 Avalonia 宿主可以依赖具体文件和媒体适配,GameHost 与 Runtime 的依赖边界由架构测试约束。 - -## 所有权与验证 - -运行先停止 Engine,再等待恢复与 view 清理,然后释放后端和资源。显式注入的恢复会话由调用者拥有;GameRun 自动创建的恢复会话由自身释放。工厂返回的新 Layer/音频适配由展示会话拥有,输入资源租约和共享 LibVLC 运行时仍由外层拥有。 - -自定义指令在不注册 Builtins/Layer 的情况下运行、命令互斥、失败清理、工厂释放与非法偏好回退均有回归测试。当前接口与依赖图见 [架构规范](../spec/architecture.md),分步实现与验证见 [重构记录](../design/game-host-refactor.md)。 diff --git a/docs/design/audio-system-design.md b/docs/design/audio-system-design.md deleted file mode 100644 index c95071d7..00000000 --- a/docs/design/audio-system-design.md +++ /dev/null @@ -1,407 +0,0 @@ -# GalNet 音频系统设计 - -> 状态:设计稿。本文不代表已实现的接口或行为;当前已实现的行为以 Runtime、Entry 和对应测试为准。 - -> 2026-10-09 已实现基线:`AudioRuntime`、`IAudioPlaybackHost`、固定 bus 上的项目轨道、Avalonia LibVLC host、`audio.*` 原语、语音文本 composite,以及 host 注册的轨道 `seffect.gain`、`seffect.lowPass` 和 `seffect.highPass`。其中低/高通由 LibVLC 10 段 equalizer 近似实现;item scope、参数动画、编辑器表单、其他 DSP、存档与精确调度仍为设计,不应当作现有承诺。 - -## 目标与边界 - -音频系统让项目作者在编辑器中定义任意数量的逻辑轨道,并由剧情原语以轨道为单位控制音频资源的播放、队列和音量。默认项目创建三个轨道:`bgm`、`sfx`、`vocal`。它必须同时满足: - -- 作者可以增加、删除、排序和命名轨道,运行时只以稳定的轨道 ID 寻址; -- BGM、音效、语音可以并行播放;短音效不会因使用同一 `sfx` 轨道而互相打断; -- 播放队列、顺序/随机选择、暂停、继续、跳过下一首具有确定的语义; -- 玩家音量偏好与作者混音值分离; -- Runtime / Core 不依赖 Avalonia、LibVLC、Skia 或具体文件路径; -- 音频处理效果可以附着在轨道或单个播放项上;声明为可动画的参数只允许在轨道 Effect 上由通用动画体系驱动; -- 运行时可诊断无效内容而不因普通内容错误崩溃,存档恢复有明确的降级规则。 - -第一版的非目标:空间化场景建模、实时录音、任意 VST/AU 插件、网络同步播放、精确逐样本的跨设备恢复。淡入淡出和单声部轨道的交叉淡化属于 Phase 4 的播放项模型;ducking、循环区间与 Effect 参数自动化可预留数据模型,但不应阻塞基本播放。 - -## 术语 - -| 术语 | 含义 | -| --- | --- | -| **混音总线(bus)** | 用于玩家音量设置和全局处理的一组声音,如 BGM、SFX、Vocal。一个轨道属于一个 bus。 | -| **轨道(track)** | 作者可配置、剧情原语直接寻址的逻辑播放单元,拥有队列、选择规则、音量和效果链。 | -| **声部(voice)** | 后端实际解码/输出的一个同时播放实例。一个单声部轨道通常只有一个当前项;SFX 轨道可有多个声部。 | -| **播放项(item)** | 队列中的一次播放请求,引用一个音频资源并携带本次音量、循环和完成策略。 | -| **放置策略(placement)** | 新播放项如何进入轨道:入队、下一首、替换当前或替换全部。 | -| **选择模式(selection mode)** | 队列在需要下一项时按顺序取出还是随机取出。 | -| **SEffect** | 对 PCM/采样流或混音节点施加的处理器,如低通、声像、均衡、空间化。与图像 Effect 同为可配置实例,但不进入图像渲染管线。 | - -`sfx` 不应被理解为“只允许一个声音的音效轨道”。默认 `sfx` 是一个可多声部的逻辑轨道;需要严格排他的音效时,作者可另建 `ui_sfx`、`ambience` 等轨道并设置 `maxVoices: 1`。 - -## 总体结构 - -```mermaid -flowchart LR - Entry[剧情原语\naudio.play / next / pause] --> Runtime[AudioRuntime\n队列、状态、诊断、快照] - Project[项目轨道定义] --> Runtime - Settings[玩家设置\nMaster + Bus/Track 音量] --> Mixer - Runtime --> Request[IAudioPlaybackHost] - Request --> Backend[宿主音频后端\n解码、时钟、结束事件] - Backend --> ItemFx[播放项 Effect 链] - ItemFx --> TrackFx[轨道 Effect 链] - TrackFx --> Bus[Bus 混音节点] - Bus --> Mixer[Master 混音] - Mixer --> Device[音频设备] - Backend --> Events[开始/结束/失败事件] - Events --> Runtime -``` - -Core 保存可序列化的定义、原语数据、状态和 Effect 元数据;Runtime 维护队列并将命令转换为宿主请求;Presentation/后端负责资源定位后的解码、实际时钟、DSP、设备选择和结束事件。宿主不得把 LibVLC 的 `MediaPlayer`、路径或平台对象泄漏到 Core/Runtime。 - -## 项目轨道定义 - -轨道定义属于项目配置,而不是每个剧情条目。建议作为项目文档的 `audio.tracks` 数组持久化,数组顺序仅决定编辑器显示与 mixer 排序;运行时索引始终使用 `id`。 - -```json -{ - "audio": { - "tracks": [ - { - "id": "bgm", - "name": "背景音乐", - "bus": "bgm", - "defaultVolume": 0.8, - "selectionMode": "sequential", - "maxVoices": 1, - "maxQueuedItems": 64, - "queueOverflowPolicy": "dropNewest", - "overflowPolicy": "stopOldest" - }, - { - "id": "sfx", - "name": "音效", - "bus": "sfx", - "defaultVolume": 1.0, - "selectionMode": "sequential", - "maxVoices": 8, - "maxQueuedItems": 0, - "queueOverflowPolicy": "dropNewest", - "overflowPolicy": "dropNewest" - }, - { - "id": "vocal", - "name": "语音", - "bus": "vocal", - "defaultVolume": 1.0, - "selectionMode": "sequential", - "maxVoices": 1, - "maxQueuedItems": 64, - "queueOverflowPolicy": "dropNewest", - "overflowPolicy": "stopOldest" - } - ] - } -} -``` - -### 字段约束 - -| 字段 | 规则 | -| --- | --- | -| `id` | 项目内唯一、非空、建议小写 kebab-case。它是剧情和玩家设置的稳定键;创建后不支持直接编辑。重命名通过编辑器的“重命名并更新引用”重构操作完成。 | -| `name` | 仅编辑器显示名,可随时修改,不参与运行时查找。 | -| `bus` | 初版限定并内置 `bgm`、`sfx`、`vocal` 三个 bus;轨道可选择其中之一。本 feature 不提供 bus CRUD;自定义 bus 留给后续。 | -| `defaultVolume` | 作者的轨道基础增益,范围 `[0, 1]`。不是玩家偏好。 | -| `selectionMode` | `sequential` 或 `shuffle`,只影响从待播队列取下一项的顺序。 | -| `maxVoices` | 正整数。`1` 表示单声部的“当前项”;大于 `1` 时允许同时播放多个项。 | -| `maxQueuedItems` | 非负整数,限制待播项数量。`0` 禁止排队:声部饱和时直接按 `overflowPolicy` 处理,避免短 SFX 延迟播放。 | -| `queueOverflowPolicy` | 队列已满时的策略。初版支持 `dropNewest`、`dropOldest`。 | -| `overflowPolicy` | 尝试立即启动但超过活动声部上限时的策略。初版支持 `dropNewest`、`stopOldest`;`stopQuietest` 可后续添加。 | - -删除被剧情条目、轨道 Effect 或玩家设置引用的轨道时,编辑器应显示引用清单并要求作者先迁移引用或确认删除。Runtime 遇到不存在的轨道仅记录诊断并安全跳过。 - -## 音量与静音 - -音量必须分层,避免玩家设置覆盖作者为具体资源做出的相对混音: - -```text -effectiveGain = masterGain × playerBusGain × playerTrackGain × trackDefaultGain × itemGain -``` - -- `masterGain`:全局玩家音量。 -- `playerBusGain`:默认设置页的 BGM/SFX/Vocal 音量,对所有属于该 bus 的轨道生效。 -- `playerTrackGain`:可选的玩家逐轨道偏好;不提供逐轨设置 UI 时默认 `1`,仍应能持久化。 -- `trackDefaultGain`:项目作者在轨道定义中设定的基础增益。 -- `itemGain`:一次 `audio.play` 的相对增益,默认 `1`,不写入玩家设置。 - -静音是对应层增益为 `0` 的明确状态,不能通过停止音频实现;静音期间队列、结束事件和剧情流程仍正常运行。首期只实现作者轨道基础增益和播放项局部增益;master、按 bus/track 的玩家音量以及 UI 留给后续 feature,旧 `BgmVolume/SfxVolume/VoiceVolume` 不迁移。 - -## 队列与播放项 - -一个播放项至少包含以下数据: - -```json -{ - "assetId": "audio/bgm/forest.ogg", - "gain": 1.0, - "loopCount": 1, - "completion": "discard", - "fadeIn": { "duration": 0.8, "curve": "equalPower" }, - "fadeOut": { "duration": 0.8, "curve": "equalPower" }, - "effects": [ - { "id": "seffect.lowPass", "parameters": { "cutoffHz": 2400 } } - ] -} -``` - -| 字段 | 语义 | -| --- | --- | -| `assetId` | 资源目录中的稳定音频资源 ID,不传本地路径。 | -| `gain` | 本次相对于轨道的增益,范围 `[0, 1]`,默认 `1`。 | -| `loopCount` | 当前播放项连续播放次数:`1` 为一次,正整数为有限循环,`infinite` 为后端原生循环。它服务于无缝循环。 | -| `completion` | `discard`:完成后移除;`requeueTail`:完成后放回待播队尾。它服务于歌单/轮播逻辑,不保证无缝。 | -| `fadeIn` | 可选的启动包络,含非负 `duration`(秒)和 `curve`。不存在或 `duration: 0` 表示立即到达目标增益。 | -| `fadeOut` | 可选的结束包络,含非负 `duration` 和 `curve`。它只用于播放项离开轨道且没有后继项接管切换时。 | -| `effects` | 可选的单播放项 SEffect attachment 列表;与资源同时创建,按列表顺序执行,且输出仍会经过轨道 SEffect 链。 | - -`loopCount` 和 `completion` 不能互相替代:无限 BGM 循环应使用 `loopCount: infinite`,A/B 两首歌轮播应使用 `completion: requeueTail`。后者的重新入队可能有解码/调度间隙。 - -### 淡入、淡出与交叉淡化 - -`fadeIn` / `fadeOut` 都是播放项自己的包络配置,支持 `linear`、`equalPower`、`easeIn`、`easeOut` 四种初始曲线。`equalPower` 是 BGM 交叉淡化的推荐默认值;其入/出包络分别使用互补的正弦/余弦曲线,感知响度不会在切换中明显下陷。 - -单声部轨道(`maxVoices: 1`)采用**后继项拥有切换权**的规则: - -1. 当前项之后存在要立即开始的后继项,且后继项指定非零 `fadeIn` 时,该 `fadeIn` 同时定义整次交叉淡化的时长和曲线。 -2. Runtime 由后继项的淡入曲线推导当前项的互补淡出曲线;当前项自己的 `fadeOut` 在这次切换中不生效。 -3. 因此“前一首恒定功率淡出、后一首使用别的淡入”时,以后一首的淡入曲线为准,不会出现两套相互竞争的淡出定义。 -4. 只有队列中没有可接管的后继项,或发生显式 `audio.stop` 时,当前项自己的 `fadeOut` 才生效;这符合“淡出只在轨道将耗尽时使用”的作者直觉。 -5. 若后继项未指定淡入或淡入时长为零,则它立即开始,当前项立即停止;不会暗中套用当前项的 `fadeOut`,以避免一次替换既延迟又产生不可预测的重叠。 - -对于自然播放完毕的队尾项,后端应在资源结束前 `fadeOut.duration` 启动其淡出。对于 `replace` / `replaceAll`,交叉淡化从命令执行时开始;若后继项有淡入,则无需等待旧资源自然结束。后端需要已知资源时长并能预加载后继资源,才能在自然衔接时做到无间隙交叉淡化;做不到时应在编辑器/日志中报告能力降级,而非悄悄改用另一种曲线。 - -多声部轨道没有唯一的“前一首”,因此不使用“后继项接管”规则:`fadeIn` 只作用于新声部自身,`fadeOut` 仅用于该声部被显式停止、被溢出策略淘汰或在它本身自然结束前收尾。`replace` / `replaceAll` 若用于多声部轨道,应在编辑器中标为高影响操作,并按被停止声部各自的 `fadeOut`(或立即停止)处理。 - -### 放置策略 - -`audio.play` 的 `placement` 与轨道的 `selectionMode` 完全独立: - -| `placement` | 语义 | -| --- | --- | -| `enqueue` | 有空闲声部时立即启动;否则若允许排队则加到待播队列尾部。`maxQueuedItems: 0` 时直接按 `overflowPolicy` 处理;允许排队但队列满时按 `queueOverflowPolicy` 处理。Runtime 在每次声部释放后 pump 队列,因此多声部轨道在未满时会立即叠加播放。 | -| `next` | 插入单声部轨道当前项之后;若无活动项,等价于立即播放。多声部轨道没有唯一“下一首”,初版调用必须诊断并安全完成。 | -| `replace` | 立即停止当前活动项并启动新项,保留既有待播队列。多声部轨道停止所有活动声部。 | -| `replaceAll` | 立即停止活动项、清空待播队列,再启动新项。BGM/语音切换通常使用它。 | - -`audio.next` 仅适用于 `maxVoices: 1`:它丢弃唯一活动项,并从优先待播区/普通队列按选择规则启动后继项;队列为空则使轨道停止。暂停轨道执行 `next` 后应进入播放状态,避免“下一首已选中却无声音”的隐式状态。 - -`audio.stop` 需显式接受 `clearQueue`(默认 `true`)。需要仅终止当前项而保留歌单时使用 `clearQueue: false`;也可提供等价且更易发现的 `audio.clear` 原语清空待播项。不能让 `stop` 的清队行为含糊。 - -### 顺序与随机 - -- `sequential`:按优先待播区、再按普通队列的稳定插入顺序取项。 -- `shuffle`:在可选队列中按确定性伪随机数选择一项;不要依赖宿主的非确定性随机源。 -- 轨道状态须保存 shuffle 的随机状态(或已洗牌顺序)及当前队列。这样读档后不会无故改变歌单顺序。 -- 第一版不做“不重复直到播完”的智能随机;若加入,应命名为 `shuffleCycle`,并同时保存已播集合/洗牌序列。 - -## 剧情原语 - -原语均以 `trackId` 定位轨道,并默认非阻塞。现有 `audio.play`、`audio.stop`、`audio.pause`、`audio.resume`、`audio.enqueue` 应在 Phase 4 一次性迁移,不能同时保留两套互相矛盾的 `channel/mode/times` 语义。 - -### 建议的作者侧条目 - -| 条目 | 关键参数 | 作用 | -| --- | --- | --- | -| `audio.play` | `trackId`、`asset`、`placement`、`gain?`、`loopCount?`、`completion?`、`fadeIn?`、`fadeOut?`、`effects?` | 创建并放置一个播放项;单资源 Effect 只能通过 `effects` 同时创建。 | -| `audio.pause` | `trackId` | 暂停全部活动声部,保留队列。 | -| `audio.resume` | `trackId` | 恢复已暂停的声部;若没有暂停声部则为幂等空操作。 | -| `audio.next` | `trackId` | 丢弃活动项并播放后继项。 | -| `audio.stop` | `trackId`、`clearQueue?` | 停止活动声部,可选清空队列。 | -| `audio.clear` | `trackId` | 保留当前声音,仅清空待播队列。 | -| `audio.setVolume` | `trackId`、`volume` | 设置该次运行的轨道覆盖增益;不修改玩家持久化偏好。 | - -示例: - -```json -{ "type": "audio.play", "trackId": "bgm", "asset": "audio/bgm/forest.ogg", "placement": "replaceAll", "loopCount": "infinite", "fadeIn": { "duration": 0.8, "curve": "equalPower" } } -{ "type": "audio.play", "trackId": "sfx", "asset": "audio/sfx/door.ogg", "placement": "enqueue", "gain": 0.9, "effects": [{ "id": "seffect.pan", "parameters": { "pan": -0.4 } }] } -{ "type": "audio.play", "trackId": "vocal", "asset": "audio/voice/a_001.ogg", "placement": "replaceAll" } -``` - -`dialogue.text` 不再拥有 `voice` 参数,`nvl.append` 保持纯文本。新增 composite `dialogue.textVoice` 与 `nvl.appendVoice`,分别编译为音频播放加文本 primitive;它们的音频附加参数只有 `gain` 与 `trackId`,后者默认 `vocal`。播放使用 `replaceIfDifferent`:同一轨道正在播放相同资源时不重放,仅更新局部 gain;不同资源才替换。它们不再通过 `IDialoguePresenter.SetVoice` 建立第二条路径。点击推进默认不停止配音,由下一个 voice entry、明确的 `audio.stop` 或 scope disposal 控制。 - -所有原语都应对无效轨道、非音频资源、无效枚举/数值产生诊断并安全完成。若剧情确实需要等待声音结束,应以后续单独设计的 `audio.wait` 或一个等待型语义实现;不要把普通 `audio.play` 改成隐式阻塞,也不要把完成回调直接变成任意剧情跳转。 - -## Runtime 状态、事件与存档 - -AudioRuntime 是播放意图和队列事实来源,后端是实际设备播放与精确时钟来源。建议状态至少包括: - -```text -AudioRuntimeState - tracks[trackId] - runtimeGain - playbackState: stopped | playing | paused - pendingItems / priorityItems - activeItems: assetId, generation, loop/completion/fade metadata, inline effects - selectionMode + random state - track effect attachments: instanceId, parameters, committed animation values -``` - -每次启动一个后端播放项都生成单调递增的 `generation`。后端报告 `Ended(trackId, generation)` 或 `Failed(trackId, generation, reason)` 时,Runtime 仅处理仍匹配当前活动项的事件。这样旧 BGM 被 `replaceAll` 停止后迟到的结束回调不会错误推进新队列。 - -后续存档若实现,应保存项目可恢复的逻辑状态:队列、优先队列、活动资源、暂停状态、运行时轨道覆盖音量、随机状态和 SEffect attachment;本 feature 不改存档格式。可采用“重启式恢复”:恢复后从活动资源开头重新播放,且不承诺毫秒级位置;文档和 UI 必须说明此限制。后端稳定支持 seek 后,才可扩展保存位置(毫秒)和循环轮次,恢复失败仍应降级为从头播放并记录诊断。 - -退出游戏页、切换项目或释放宿主时必须先使 Runtime 停止并取消所有活动 generation,再释放后端 player/媒体资源。资源加载失败、设备不可用、解码异常不会使游戏流程抛出;它们通过统一诊断/日志向作者和宿主暴露。 - -## 宿主端口与资源定位 - -当前 `BuiltinAudioModule` 的音频条目只注册静态 schema,创建的是空的即时原语;仓库不存在可替换的 `IAudioView`。历史 `AudioChannelManager`、`IAudioService` 和 `LibVlcAudioController` 的字符串 `mode`、`times` 与每 channel 单 player 模型也无法表达完整队列状态。Phase 4 应新建独立的强类型宿主端口,并在 Builtin 模块中创建实际的非阻塞音频原语,例如: - -```csharp -public interface IAudioPlaybackHost -{ - Task ApplyAudioCommandAsync(AudioCommand command, CancellationToken ct); -} -``` - -`AudioCommand` 是 Core 中的可判别、强类型数据(播放、暂停、继续、下一首、停止、清队、设置运行时音量、恢复快照),其中只含 `trackId`、资源 ID、增益和枚举。Runtime 负责队列并将其降为带 `(trackId, voiceId, generation)` 的声部级宿主请求;宿主不得自行排队。宿主以项目的资源提供器把资源 ID 解析为可播放流或临时 URI;Runtime 不传 `FileInfo`、磁盘路径或 LibVLC 类型。 - -需要后端事件时,宿主以一个受控事件/回调接口上报 `Started`、`Ended`、`Failed`,并回传 `trackId`、`voiceId` 和 generation。事件转回引擎线程或由 AudioRuntime 串行化处理,不能在后端回调线程直接修改 UI 或游戏状态。宿主还必须声明 duration、scheduled start、seek、DSP 等能力;无间隙循环和自然结束前的交叉淡化只在对应能力可验证时启用。 - -`LibVlcAudioController` 可以作为初期宿主适配器,但其当前“一轨道一个 `MediaPlayer`、`Enqueue` 立即 `Play`”的实现不能满足真实队列、并发 SFX、循环和结束事件语义。后端选型不应锁死在 LibVLC;只要适配相同端口即可替换。 - -## SEffect 后处理设计 - -### 为什么独立于图像 Effect - -图像 Effect 处理的是 `Texture → Texture` 的画面 pass,并挂在 Layer/ScenePost。SEffect 处理的是音频流或混音节点,拓扑是 `PCM/voice → item SEffects → track SEffects → bus → master`。二者共享“独立实例、声明式 descriptor、参数校验、生命周期和可动画参数”的架构思想,但不能复用 `EffectStage`、`IImageEffect`、SkSL 或 Layer handle。 - -SEffect 是统一的 descriptor、参数和生命周期模型;作者只选择 attachment 的目标。scope 只有以下两种: - -| Scope | 挂载点 | 适合的效果 | 生命周期 | -| --- | --- | --- | --- | -| `item` | 一次播放项/声部 | 一句电话滤镜、一次性方位、音高变化 | 播放项结束、被替换或停止时自动释放。 | -| `track` | 轨道的混音节点 | BGM 低通、Vocal 压缩、整条轨道声像 | 显式停止、轨道删除/释放时结束;可被存档恢复。 | - -同一声音的拓扑固定为 `item SEffect 链 → track SEffect 链 → bus`。因此单播放项 SEffect 是**叠加并先行处理**轨道声音,而不是绕过或隐式替换轨道 SEffect。例如一段语音的 `seffect.lowPass` 仍会继续经过轨道上的 `seffect.gain`;两个低通串联的听感会更强,这不是参数覆盖。若未来确有“暂时替换轨道某个 SEffect”的叙事需求,应引入带稳定 attachment ID 的显式 enable/disable 或参数动画,不能依据相同 `effectId` 偷偷合并。 - -Bus/master 级 Effect(例如总线限制器)是未来扩展,不放进 Phase 4 作者原语;它们更接近项目混音配置,不能由任意剧情条目随意附着到全局输出。 - -### Effect 数据与目录 - -每个具体后处理由后端模块提供一个平台无关的 `SEffectDefinition`,承担编辑器下拉、参数表单、范围验证、可动画属性提示和宿主能力声明的唯一来源。结构应和视觉 `EffectCatalog` 对齐,但单独定义类型: - -```csharp -enum SEffectScope { Item, Track } - -record SEffectDefinition( - string Id, - IReadOnlySet SupportedScopes, - IReadOnlyList Parameters, - IReadOnlyList AnimatableProperties, - bool RequiresRealtimeDsp = true); -``` - -当前基线已经实现 `SEffectCatalog`,但它只描述并校验 host 已注册的轨道 effect。Avalonia host 在创建时收集 `IAudioSEffectProcessor`,由注册项形成不可变 catalog;Runtime 在保存 attachment 前校验 ID、scope 和参数。当前注册 `seffect.gain`(`gain: 0..1`)以及 `seffect.lowPass` / `seffect.highPass`(必填 `cutoffHz: 20..20000`、可选 `attenuationDb: -20..0`)。未知、未注册或非法参数会保留干声并产生诊断。低/高通的频段近似不支持 `resonance`。 - -`AnimatableProperties` 表示某个参数**具备**连续动画能力,而不是每一个 attachment 必然存在动画。初版中 `seffect.lowPass.cutoffHz`、`seffect.lowPass.resonance`、`seffect.pan.pan`、`seffect.gain.gain`、`seffect.pitch.semitones` 和 `seffect.spatial` 的坐标/距离参数均可声明为可动画;布尔开关、枚举、资源引用等仍为静态参数。 - -初始内置 descriptor 建议如下: - -| ID | Scope | 参数 | 说明 | -| --- | --- | --- | --- | -| `seffect.lowPass` | item、track | `cutoffHz`、`resonance` | 低通滤波,适合隔墙、收音机、远景。 | -| `seffect.highPass` | item、track | `cutoffHz`、`resonance` | 高通滤波,适合电话/去低频。 | -| `seffect.pan` | item、track | `pan` (`-1..1`) | 立体声声像;不等同于三维空间化。 | -| `seffect.gain` | item、track | `gain` | 额外动态增益;应与持久音量层区分。 | -| `seffect.pitch` | item | `semitones` | 音高/速度处理取决于后端能力,初版可不实现。 | -| `seffect.spatial` | item | `x`、`y`、`z`、`minDistance`、`maxDistance` | 预留三维方位/衰减;要求后端提供位置与听者模型。 | - -`seffect.lowPass` 等是稳定作者 ID,而不是作者传入任意 DSP/shader 文本。每个宿主后端可以声明它支持的 ID;不支持的 SEffect 必须保持干声通过、记录一次诊断,而不能使资源静音或崩溃。 - -### Attachment 入口与顺序 - -单播放项 Effect 不提供独立的运行时寻址入口;作者只在 `audio.play.effects` 中传入 attachment 列表。这样它和播放项同生共死,不需要作者记忆短暂的 item ID: - -```text -audio.play(..., effects: [ - { id, parameters, order? }, - ... -]) -``` - -轨道 Effect 则使用独立且只接受轨道目标的原语: - -```text -audio.effect.apply(trackId, effectId, instanceId?, order, parameters) -audio.effect.stop(instanceId) -``` - -- Runtime 为轨道 attachment 分配稳定 `instanceId`,以便生命周期、动画和存档恢复;普通作者不应依赖“按 ID 查找第一个某种效果”。 -- 同一挂载点的 Effect 按 `order` 升序、同序按添加顺序稳定执行;顺序是作者可见数据,例如 `lowPass → gain` 与 `gain → lowPass` 的结果可能不同。 -- `audio.play.effects` 的 item attachment 按 `order`(缺省为列表顺序)形成 item 链,然后始终接入轨道的完整 Effect 链。 -- 视觉 Effect 的 `effect.apply` / `effect.stop` 不应扩大参数后兼容音频;应使用独立条目,避免 `targetHandleId` 与 `trackId` 的歧义。 - -### 可动画约束 - -轨道提供稳定的运行时存在期:它在项目配置中有 `trackId`,持续的轨道 Effect 又有 `instanceId`。因此 Runtime 可以将轨道 Effect 暴露为通用动画系统的目标,形式为 `AudioTrackEffectInstance(instanceId) + parameter`。编辑器把“动画此轨道 Effect 参数”的声明编译为该内部目标;作者不需要手写临时播放器句柄。 - -`audio.play.effects` 的 item attachment 虽然使用同一个 `SEffectDefinition`,其可动画参数也仍在 descriptor 中声明,但该 attachment 随播放项创建和销毁,尤其在多声部 SFX 下不存在可供后续条目可靠寻址的唯一句柄。因此它只接受播放时给定的静态参数,**不能成为 `animate` 的目标**。这不是人为削减 SEffect 能力,而是音频资源播放没有类似 Layer 的稳定、可寻址实例句柄所导致的边界。 - -若某段声音确实需要被剧情持续动画化(例如 BGM 的低通扫频、持续环境音的声像移动),作者应把它放在专用的单声部轨道上,并将 SEffect 附着到该轨道;不要试图对一次性 SFX 播放项补造公开句柄。实时 DSP 参数更新应使用平滑 ramp(例如 5–20 ms 或由后端决定),防止低通截止频率或 gain 突变造成 click/pop。SEffect 动画默认不阻塞剧情;需要叙事同步时由独立等待能力表达。 - -### 方位与空间化的取舍 - -`pan` 是廉价、确定的立体声左右平衡,推荐后续 SEffect feature 实现。`spatial` 是三维声源模型,必须先定义:听者坐标从何而来、世界坐标和屏幕坐标是否关联、距离衰减曲线、立体声资源如何处理、耳机/HRTF 是否可用。因此只保留 descriptor 和后端能力协商,不在本 feature 承诺实现。对于视觉小说常见的“从左侧说话”,`seffect.pan` 已足够。 - -## 编辑器、验证与诊断 - -- 项目设置页应提供轨道表格:ID、名称、bus、默认音量、顺序/随机、最大声部、溢出策略。ID 创建后锁定,提供安全重构入口。 -- 剧情条目的 `trackId` 使用项目轨道下拉,而非写死 `bgm/sfx/voice`;资源选择器只显示音频资源。 -- `audio.play` 根据轨道 `maxVoices` 显示策略提示:多声部 SFX 的 `replaceAll` 会停止全部活动音效,应当明显标注。 -- SEffect 面板通过 `SEffectCatalog` 动态生成 scope、参数、范围、单位和能力诊断,不能在编辑器中为每个 SEffect 写一套表单。 -- 编辑器预览使用隔离的 preview track,不污染游戏运行时队列和玩家音量偏好;预览停止时释放所有试听声部和 Effect。 -- 运行时至少记录:未知轨道、轨道 ID 重复、资源类型不符、资源解析/解码失败、无可用音频设备、后端不支持的 Effect、无效 Effect 参数、过期 generation 事件、声部溢出和存档 seek 降级。 - -## 分阶段落地 - -### Phase 4A:轨道与基础播放 - -1. 定义项目 `AudioTrackDefinition`、默认三轨及编辑器 CRUD/引用验证。 -2. 以 `trackId` 和强类型 `AudioCommand` 替换当前硬编码 channel、字符串 `mode/times` 和空的 `ConfigureAudioQueue`。 -3. 实现单声部 BGM/Vocal、可配置多声部 SFX、有界入队、单声部下一首、替换/替换全部、暂停/继续/停止/清队和结束 generation 防护;命令触发的交叉淡化仅在后端支持时完成,否则记录诊断并停止后开始。自然结束前交叉淡化和无间隙循环留给具备预加载/调度能力的后续阶段。 -4. 接通资源 ID 定位、音量分层、Sample 的 BGM 切换与 SFX/Vocal 播放。 - -验收:Windows Avalonia Sample 能按流程切换循环 BGM,同时叠加音效,播放/替换语音;错误资源、轨道、设备或不支持的淡化能力均不终止流程并产生诊断。Headless fake host 覆盖队列、声部溢出和 generation 语义。 - -### 后续:持久化与播放器设置 - -1. 将轨道逻辑状态加入 `GameSnapshot`,完成重启式恢复。 -2. 单独决定是否引入按 bus/track 的玩家音量映射;当前固定 BGM/SFX/Voice 设置不迁移。 -3. 保存 shuffle 状态、队列和暂停状态;实现资源失败和 seek 不可用的降级诊断。 - -验收:读档后 BGM/队列/暂停和玩家音量语义一致,随机歌单不无故重排。 - -### 后续:SEffect 基础 - -1. 建立统一的 `SEffectDefinition` / catalog、scope/order、校验、生命周期和后端能力声明。 -2. 接入 `seffect.lowPass`、`seffect.pan`、`seffect.gain` 的 track attachment 与 `audio.play.effects` item attachment;不支持后端干声降级。 -3. 将可动画的轨道 Effect 映射为通用动画目标;明确拒绝动画 item attachment。 -4. 将 SEffect attachments 和已提交的轨道动画值纳入快照;提供最小编辑器表单和 Sample 验证场景。 - -验收:BGM 轨道可持续低通,单句语音可向左/右声像,停止或替换播放项时 item SEffect 无泄漏。 - -### 后续阶段 - -ducking、精确播放位置恢复、`audio.wait`、`shuffleCycle`、高通/压缩/混响、动态 range 和三维 `seffect.spatial` 按需求逐项设计和实现。它们不得通过改变本文件中已定义的队列、音量、淡入淡出、可动画边界或生命周期语义来“补丁式”加入。 - -## 测试策略 - -- **Core/Runtime 单元测试**:轨道 ID 验证、默认轨道、音量合成、各放置策略、停止是否清队、顺序/随机、completion/loop 区分、后继项淡入接管交叉淡化、队尾/停止时淡出、轨道 SEffect 参数动画、拒绝 item SEffect 动画目标、声部溢出、过期 generation 事件。 -- **序列化测试**:轨道配置、音频运行时快照、随机状态和 SEffect attachment。旧 `BgmVolume/SfxVolume/VoiceVolume` 不兼容且不迁移。 -- **后端契约测试**:资源定位、结束/失败事件、暂停恢复、释放后不再上报有效事件、Effect 不支持时干声降级。 -- **集成测试**:剧情条目驱动 BGM 切换、SFX 重叠、`dialogue.textVoice`/`nvl.appendVoice` 的 Vocal 路由、页面离开释放资源。 -- **听感/手动验收**:低通和声像参数变化无爆音;多 SFX 不异常截断;BGM 的 equal-power 交叉淡化无明显响度下陷;无限循环在目标后端不产生可察觉间隙。 - -## 与现有实现的迁移决策 - -当前 `AudioChannelManager`、`IAudioService` 和 `LibVlcAudioController` 使用预置 channel、单个 `CurrentAsset`、字符串 `mode/times` 与不具备真实队列语义的 `Enqueue`;`BuiltinAudioModule` 则只提供空的 schema 原语。它们可作为 Phase 4 前的占位实现和测试基线,但不能直接扩展为本设计的核心模型。 - -本设计以新模型取代旧 channel 约定,而非将 `bgm2`、`sfx1` 等命名作为兼容层永久固化。旧项目内容和已编译内容不做迁移:遇到旧 schema/format 应明确拒绝并要求按新 schema 重编译。 - - -2026-10-15 更新:轨道与轨道 SEffect 的重启式存档恢复已在 [模块化游戏状态](modular-game-state-and-restore.md) 实施,当前行为以 [runtime spec](../spec/runtime.md) 为准。旧的未来计划不再表示该项尚未实现。 diff --git a/docs/design/entry-registration-refactor.md b/docs/design/entry-registration-refactor.md deleted file mode 100644 index 3cd06975..00000000 --- a/docs/design/entry-registration-refactor.md +++ /dev/null @@ -1,60 +0,0 @@ -# Entry 契约与执行注册迁移 - -用户授权:不保留旧 API/存档兼容;排除 Editor/Launcher 功能改造,允许迁移其公共 API 调用点以保持构建。 - -## 目标与决定 - -Entry module 只拥有参数契约和编译期 composite。执行 module 只拥有完整指令 ID 与实例工厂,并可持有共享服务/状态。模块名和指令前缀不是排他注册键,完整 ID 才是。宿主通过 GameRegistration 独立注册契约与实现,在创建游戏时冻结并转移执行模块所有权。 - -内置入口 AddBuiltins 组合 AddBuiltinSchemas 与 AddBuiltinImplementations;每个领域有独立实现注册入口。音频轨道逻辑可复用 AudioRuntime,平台通过 IAudioPlaybackHost 组合实现;无需继承播放器基类。平台后端不进入 schema。 - -## 实施顺序 - -1. 契约/工厂拆分、注册入口、领域拆文件、消费者迁移与注册测试。 -2. 删除旧协议与重复状态,统一快照复制及读档取消,补充生命周期回归测试。 -3. 修正音频 pending-load/命令顺序、动画统一播放时间源、诊断和宿主生命周期。 -4. 顺序执行边界测试、集成测试、格式检查并同步当前文档。 - -## 验收 - -纯 schema 注册不创建执行服务;schema-only、自定义实现、部分内置实现、全内置均可组合;相同前缀不同指令可注册,完整 ID 重复报错;缺失实现明确失败;旧异步任务不能污染读档后状态;快照与调用者隔离;停止待加载音频后不能晚开始;动画轨道与事件使用同一播放进度。 - -## 风险 - -公共 API 破坏性迁移需要同步测试及所有消费者。取消并不等待所有外部任务,旧任务必须使用运行代际/状态对象隔离。后端资源销毁必须先取消待加载、等待工作结束后删除临时目录。 - -## 实现结果(2026-10-10) - -目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。 - -删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。 - -音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。 - -## 验证与边界 - -- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。 -- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。 -- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。 -- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。 -- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。 - -使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。 - -## 实现结果(2026-10-10) - -目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。 - -删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。 - -音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。 - -## 验证与边界 - -- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。 -- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。 -- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。 -- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。 -- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。 - -使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。 diff --git a/docs/design/feature-workflow.md b/docs/design/feature-workflow.md deleted file mode 100644 index f1e41670..00000000 --- a/docs/design/feature-workflow.md +++ /dev/null @@ -1,81 +0,0 @@ -# Feature 工作流与 Agent 知识维护 - -## 定位 - -这是 GalNet 推荐的实现工作流,不是强制状态机。它为较大的功能、架构调整和需要长期维护的改动提供统一记录;简单修复、独立审核或文档任务可以直接调用相应 skill。 - -本流程由 [F-20260916-01-agent-feature-workflow 的实现总结](../../features/F-20260916-01-agent-feature-workflow/summary.md) 建立;本页仅保留已验证、可长期复用的约定。 - -## 推荐顺序 - -```text -新建 Feature - ↓ -需求澄清 - ↓ -设计文档 - ↓ -Phase Plan - ↓ -逐阶段实现 - ↓ -Review - ↓ -总结与知识沉淀 -``` - -## 快速通道 - -边界清晰的小到中等 feature 可以使用 `galnet-feature-fast-track`,把流程压缩成三段: - -```text -设计规划 → 实现验证 → 总结收尾 -``` - -快速通道仍然保留 `feature.md`、`design.md`、`phase-plan.md` 和 `summary.md`,但内容可以只覆盖会影响实现和验证的事实。它不是质量门禁的豁免:如果需求不清、需要 ADR、大范围迁移、影响核心分层或无法定义可信验证,应退回普通 feature 流程。 - -## Feature 产物 - -每次采用此工作流的实现创建一个新的 `features/F-YYYYMMDD-NN-short-slug/` 目录。 - -### `feature.md` - -需求和状态入口,包括问题、目标、范围、非目标、验收标准、约束、开放问题和相关链接。 - -### `design.md` - -记录系统应该如何变化、候选方案、取舍、接口/数据边界、迁移和风险。它回答“为什么这样设计”。重要且难以逆转的决定另建 `docs/adr/` 下的 ADR。 - -### `phase-plan.md` - -将设计拆成顺序明确、可独立验证的 Phase。每个 Phase 应有目标、依赖、修改范围、测试、文档和退出条件。它回答“如何分步实现”。 - -### `review.md` - -记录独立审核结果、问题严重级别、验证证据和最终结论。Review 默认只报告问题,不自动修复。 - -### `summary.md` - -面向开发者,记录实现结果、实际修改、关键决策、验证、偏差、已知限制和后续工作。agent 经验不应只依赖这里保存。 - -## Agent 经验 - -遇到以下情况时立即使用 `galnet-capture-lesson`: - -- 非显然的构建、测试、运行或工具问题。 -- 用户纠正了 agent 对架构或项目约定的理解。 -- 原计划因现有代码事实而失败。 -- 发现可能在多个 feature 重复出现的边界或陷阱。 -- 重复一次会造成较大返工、数据损坏或错误架构方向的问题。 - -lesson 先保存在 `.agents/lessons/`。经过重复验证、风险评估或跨 feature 复用确认后,再使用 `galnet-promote-lesson` 生成独立 skill。独立 skill 应写规则和检查方式,不应复制完整事故流水账。 - -## 文档分层 - -- `.agents/`:只给 agent 使用的上下文、lesson 和 skill 源文件。 -- `features/`:一次 feature 的过程和人类可读总结。 -- `docs/spec/`:当前已经实现的系统事实。 -- `docs/design/`:跨 feature 设计和长期计划。 -- `docs/adr/`:重要架构决定。 - -不要把 agent 的临时经验直接写进 `docs/spec`;只有已经验证并属于系统公共约定的内容才同步到正式文档。 diff --git a/docs/design/game-host-refactor.md b/docs/design/game-host-refactor.md deleted file mode 100644 index 2d4b0db0..00000000 --- a/docs/design/game-host-refactor.md +++ /dev/null @@ -1,57 +0,0 @@ -# 游戏宿主分层重构 - -用户已确认:核心提供调度、编译和执行机制,指令由可选模块提供。保留 Core 现有模型位置,暂不实现 WPF、WinUI 或 Unity。 - -目标:Core/Runtime → 平台无关的游戏应用与展示契约 → Avalonia 实现与默认组合 → Sample/Editor。Assets 与 Storage.FileSystem 作为外部基础设施组合。 - -## 实施步骤 - -1. 移除项目目录删除与 ProjectTools.Windows;保留最近记录移除。 -2. 下放默认 Avalonia 展示组合、纹理工厂及释放流程。 -3. 抽出 GameHost 通用运行会话;Engine 独占执行机制。 -4. 分离通用资源和 Avalonia 纹理预加载。 -5. 下放玩家持久化协调,分离 Gallery 展示。 -6. 分离玩家偏好规则与 Avalonia 设置 UI。 -7. 调整平台无关接口归属,检查媒体后端边界。 -8. 收敛 Sample、补充架构文档并执行回归。 - -每一步验证后独立提交。不得提交任务开始前已有的输入设置与编辑器改动。 - -## 验证记录 - -- Step 1 verified: Architecture 37/37; Editor.Shared 180/180; ProjectSchemaLifecycle 5/5. -- Step 2: 默认展示组合位于独立 Avalonia.GameHost,避免页面控件层反向依赖 Runtime/Builtins。模块由调用者配置;纹理工厂已下放。配置与清理回归 6/6。 -- Step 3: 新增中立 GameSession/IGameRun/GameRun;Sample 的存档与替换共用命令门,Headless 也接入 GameRun。增加串行保存/替换、准备失败清理、停止失败与重复关闭测试。 -- Step 4: GameResourceScope 位于文件基础设施,AvaloniaGameResources 仅持有渲染资源租约;移除 Sample 资源 scope。批量预加载逐个记录成功租约,部分失败时统一释放。 -- Step 4 verified: 资源、配置与清理回归 7/7;经验见 `.agents/lessons/L-20261016-06-partial-preload-leases.md`。 -- Step 5: PlayerSession 统一存档、空槽选择、继续选择、进度 flush、玩家变量和 Gallery 数据;FilePlayerSession 组合文件实现。Gallery 媒体 resolver 改由展示会话独立持有。清理协作者 ResourceCleanup 下放 GameHost。 -- Step 6: PlayerPreferences/PlayerStartup 为中立偏好读写、非法值回退和首次启动规则;AvaloniaPlayerSettingsService 负责设置控件、预览 timer 与 UI 应用。默认窗口/手柄适配下放 Avalonia,编辑器 registry 可注入,项目本地化由 IPlayerLocalization 接入。 -- Step 7: 会话/显示/Gallery 路径接口迁至 Presentation.Abstractions.Application;无 Avalonia API 的音频播放、音效处理和共享 LibVLC 生命周期迁至独立 GalNet.Media.LibVlc。视频画面仍由 Avalonia 适配。 -- Step 8: 默认页面会话和运行绑定下放 Avalonia.GameHost;Sample 会话仅保留启动参数与调试策略。Layer/音频工厂可替换并统一转交所有权。增加无 Builtins/Layer 的自定义指令运行测试和工厂释放测试。 - -## 收敛结果 - -中立 GameHost 不注册内置指令、不要求 Layer Presenter;默认 GameRun 在借用 CompositeGameView 时可自行创建恢复会话,并先释放 Engine 后释放自身拥有的恢复会话。显式提供的恢复会话仍由调用者拥有。Headless 通过 Prepare/Start 走相同读档展示同步流程。 - -实际可替换点:catalog、执行模块、decoder、纹理/效果预加载策略、Layer 工厂、音频后端、设置字段编辑器、本地化适配。Sample 的 Session 只保留启动参数与调试策略。 - -## 分步提交与最终验证 - -| 步骤 | 提交 | 验证 | -| --- | --- | --- | -| 1 删除项目清理 | `76bf416` | 架构 37、Editor.Shared 180、项目生命周期 5 | -| 2 默认展示组合 | `edac1cd` | 配置/清理 6、架构 38、Sample 编译 | -| 3 通用运行会话 | `43248b4` | 会话 3、集成 7、架构 40、Headless 编译 | -| 4 资源分离 | `e63f650` | 资源/配置/清理 7,含部分获取失败释放 | -| 5 玩家持久化 | `ccc24fb` | 集成 8、架构 40、Sample 编译 | -| 6 偏好与设置 UI | `1b380f1` | 设置回归 25、中立层 4、架构 40 | -| 7 应用契约与音频后端 | `49b8976` | 展示 80、集成 6、架构 45、Editor 编译 | -| 8 Sample 收敛与文档 | 本次收尾提交 | 全部 12 个测试项目共 1,038 项通过,零失败;Release 全解决方案构建、格式与 IDE0005 检查通过 | - -原生音视频播放和完整 GUI 人工流程未验收;自动回归覆盖媒体生命周期、音效逻辑、页面绑定、状态恢复与失败清理。Debug 全解决方案构建仅因正在运行的 Sample 锁定可执行文件而失败,独立 Release 输出的完整解决方案构建通过(零错误)。 - -## 后续补充:共享本地化扩展 - -Sample 的 `LocalizeExtension` 下放到 `Avalonia.GameView.MarkupExtensions`,与 `PlayerTextExtension` 共用 observable text 解析入口;Sample 的三个窗口/页面改用共享扩展。保留 Key 回退、StringFormat 和语言即时刷新,不依赖 SampleLocalizationService.Current,也不向中立层引入 Avalonia。Editor 的动态 Path 本地化扩展保留原实现。 - -验证:Sample XAML 与集成项目编译通过;本地化集成测试 11/11,包含实际 TextBlock 的中英文切换、格式化、未配置解析器与空 Key 回退。 diff --git a/docs/design/game-runner-refactor.md b/docs/design/game-runner-refactor.md deleted file mode 100644 index 90515c11..00000000 --- a/docs/design/game-runner-refactor.md +++ /dev/null @@ -1,33 +0,0 @@ -# 游戏运行编排与 Avalonia 呈现收敛 - -> 后续宿主分层已由 [game-host-refactor](game-host-refactor.md) 实现;本文保留早期收敛设计,当前类型和归属以架构规范为准。 - -## 范围 - -保留页面、presenter 和运行会话的职责边界。不改变 Editor/Launcher 功能;Editor 仅迁移共享类型名称和事件订阅 API。 - -## 设计 - -- 不新增通用 Runner。Engine 统一报告运行、完成、失败和停止,覆盖玩家推进、选择结果和阻塞实例自然完成三个入口。 -- Engine 提供 `StopAsync` 和异步释放,停止先拒绝新请求并取消 scope,再清理活动实例、等待内部 continuation 退出。停止是 Engine 的终态,重开创建新实例。 -- 状态事件在执行锁外按发生顺序发布;订阅者异常诊断但不改变剧情状态。事件只报告事实,不承担 UI、日志文案或存储业务。 -- 准备、页面切换、首次推进留在宿主;内置场景重放继续复用 Builtins 入口。 -- `AvaloniaGamePageView` 改名为 `AvaloniaGamePresenters`。玩家推进直接从页面 ViewModel 接到宿主,不经过 presenter 集合转发。 -- Sample 的平台对象装配和统一清理集中到一个 `SampleGamePresentation`;执行模块先于借用的 presenter/后端释放。清理一个对象失败不能阻止其他对象清理。 -- 删除未使用的 `SampleMediaViews`,Sample Runner 只保留平台绑定、日志和 UI 更新。 - -## 验证阶段 - -1. **verified**:Engine 停止、状态通知与生命周期测试,新增 11 个用例。 -2. **verified**:Avalonia 命名/API 迁移、Sample 装配与事件绑定、旧代码清理。 -3. **verified**:全仓 10 个测试项目共 384 项通过;另外 2 项原生音频取消测试通过。Sample.Headless 和 Editor.Headless 构建通过,solution whitespace / IDE0005 门禁及 git diff --check 通过。 - -当前状态:已实现并验证。 - -## 结果和限制 - -- 平台实现只提供呈现端口,无需继承 Runner 基类、管理 Engine continuation 或重复判断剧情终态。 -- SampleGameRunner 保留页面准备/切换、日志和 UI 反馈;平台对象由 SampleGamePresentation 统一装配与释放。删除旧媒体占位对象和推进事件的两层转发。 -- 放弃最初的通用 GameRunner 草案,职责直接归 Engine;不兼容旧 AvaloniaGamePageView 类型名。 -- Editor 仅迁移共享 API,原预览编排未重构;Launcher 未改动。 -- 常规测试不包含显式视频原生测试,本次未进行完整 GUI 操作验收。Avalonia 构建有既有 AVLN3001 警告;沙箱许可目录读取限制通过授权验证处理,没有修改产品代码绕过。 diff --git a/docs/design/game-ui-themes.md b/docs/design/game-ui-themes.md deleted file mode 100644 index 9e6aee67..00000000 --- a/docs/design/game-ui-themes.md +++ /dev/null @@ -1,195 +0,0 @@ -# 游戏 UI 主题与配色设计 - -状态:Implemented,2026-10-11,关联 [F-20261011-01](../../features/F-20261011-01-game-ui-themes/feature.md)。当前契约以 [control.md](../spec/control.md) 和 [Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md) 为准。以下保留初始设计分析,候选项不自动成为实现承诺。 - -## 落地决定与初始设计差异 - -- 单一可选 GalNet.Avalonia.Themes 项目,Classic/Minimal/Paper 布局和 Ocean/Rose/Mono 独立配色;Sample 默认 Classic + Ocean。 -- 启动代码通过 IGameLayoutTheme 同时提供 Styles 与页面注册,GameShell 自动加载资源;未提供直接的主题 XAML 元素、运行期选择器或继承/ID 注册系统。 -- 普通页保留 View + ViewModel;内置布局各自提供全部普通页面 XAML,复用共享业务 ViewModel,F-20261011-03 已移除默认页面回退;主题参数必需,缺失映射在注册时拒绝。页面外框取消,各主题内的导航位置保持一致。GamePage 成为稳定 TemplatedControl,以 Scene/Dialogue/Nvl/Choice 四个内容属性和 PART_* ContentPresenter 安排布局。 -- 注册顺序为共享 GamePage→主题→宿主 Gallery 回调→宿主 View 回调→完整性校验,普通 View 自动补充 Scoped 注册。截图行为基类留在 GameView,布局、工具栏和通用玩家样式归 Themes。游戏页自定义类型须继承 GamePage,确保 Runtime 与页面工厂使用同一实例。 -- 玩家根内使用 Fluent,基础色桥接至所选语义配色;宿主 Semi 与编辑器隔离。截图弹窗显式引入相同主题。当前 Sample 的确认/通知属于调试功能,沿用宿主外观。 -- 固定美术可以保留;Paper 标题的原创矢量纹理使用固定棕色,交互与文本跟随配色。 -- 配色目前包含 21 个必需画刷,未实现下述所有扩展状态键。九种组合在 1280×720 headless Skia 渲染并验证输入及截图;原生媒体、系统对话框、全 DPI/字体环境未纳入该测试。 - -架构取舍记录于 [ADR 0003](../adr/0003-startup-game-layout-themes.md)。 - -## 目标与范围 - -保持 GalNet Avalonia 控件的行为可复用,为开发者提供覆盖主要玩家界面的参考主题。第一版由开发者在代码/XAML 的应用组合期配置主题与配色,运行中保持固定。用户已明确先取消动态主题切换;不实现玩家变量绑定、运行期主题选择器或安全边界切换协调器。配色保持独立资源结构,热切换配色不作为第一版验收要求。 - -覆盖标题、加载、游戏对白与 NVL、剧情选择、游戏工具栏、存读档、设置、全部内置鉴赏页面、关于、截图和应用确认/错误弹窗,以及 Sample 首次语言选择和启动状态。调试窗口、编辑器自身 UI、操作系统文件选择器不属于游戏主题覆盖范围。Sample 窗口客户区属于覆盖范围,原生窗口装饰由平台宿主决定。 - -主题不决定游戏内容、导航可用性、存档格式、媒体后端、玩家变量语义或业务执行。Core/Runtime 不依赖主题系统。剧情明确指定的文本颜色、场景图像与效果仍属于游戏内容,不自动改成 UI 配色。 - -## 已验证的实现基础与缺口 - -- `GalNet.Avalonia.Controls/Styles/Generic.axaml` 已有对白、选项、存档卡和 NVL 模板,但含固定色值和尺寸。 -- `GameShell` 和页面有本地视觉值及局部样式;主题迁移需要主动清理这些决定,不能只在 App 末尾追加样式。 -- `PageViewRegistry` 在组合期构建后不可变;`PageViewFactory` 从游戏 Scope 解析 View。默认页面 View 与 ViewModel 均为 Scoped,现有工厂不会为外观变化自然创建新实例。 -- `AvaloniaGamePresenters`、Sample 会话与 presentation 直接依赖具体 `GamePage`。`GameShell` 截图路径也把当前内容转换为 `GamePage`。 -- `GamePage` 具名元素承担渲染、对白与输入接线;对白运行状态保存在 `TypewriterTextBlock`,NVL 历史保存在模板内部的行容器。保留 ViewModel 或外层控件实例不足以保证重新应用模板时保留这些状态。 -- Sample 首次语言选择与启动状态位于 `GameShell` 之外,需要独立接入同一个外观上下文。 -- Sample App 同时引入 Semi、UrsaSemi 和 Fluent。游戏 UI 必须明确基础控件主题与隔离方式,不能依赖多个基础主题的偶然加载顺序。 - -当前页面边界见 [control.md](../spec/control.md)。 - -## 外观模型 - -### 基础色表 - -提供原始颜色及色阶,例如 `GalNet.Color.Blue.500`、`GalNet.Color.Neutral.900`,不包含控件或业务语义。色表可扩展,配色也允许直接使用自定义颜色;不强制所有色值来自官方色表。 - -### 语义配色 Palette - -将原始颜色映射到用途,建议包含: - -- Primary、Secondary 及 Hover/Pressed/Disabled 状态。 -- Success、Warning、Error、Info 及对应前景与柔和背景。 -- Background、Surface、SurfaceRaised、Overlay。 -- TextPrimary、TextSecondary、TextDisabled。 -- OnPrimary、OnSecondary,确保强调背景上的文字有明确配色。 -- Border、Divider、Focus、Selection。 - -区分 Color 与 Brush 的资源类型,例如 `GalNet.Palette.PrimaryColor` 与 `GalNet.Brush.Primary`。不使用字符串资源冒充颜色,不在不支持动态引用的位置假设资源别名可以自动追踪变化。迁移时以真实 Avalonia 12 页面验证“基础色 → 语义色 → Brush → 控件”的更新链路。 - -配色提供完整语义资源。Light/Dark 属于配色维度,不应混同完整布局主题;第一版配置具体配色资源或对象,不要求建立运行期 ID 注册表。 - -### 视觉主题 Theme - -提供控件模板、普通页面 View、游戏布局、背景图与纹理、字体、间距、圆角、装饰和动效。模板对需要跟随配色的值使用动态语义资源。 - -视觉主题可以选择纯色、图片或混合背景,也可以固定部分美术颜色。公共配色的采用范围由主题说明:完整、部分或固定美术配色。主题推荐一个默认 Palette,但显式选择 Palette 时不得偷偷覆盖请求。 - -主题可定义专用资源,例如 `GalNet.Theme.Dialogue.Surface`、`GalNet.Theme.Title.BackgroundImage`。专用颜色默认可以引用语义配色,也允许固定颜色;图像和字体不属于 Palette。 - -AppearanceVariant 不作为第一版必需的第三选择轴。二周目外观与其他运行期变更先留作后续需求,不为其预建选择机制。 - -## 模块与资源作用域 - -- Controls:行为、模板契约、最小可用的中性基础模板;保持独立引用能力。 -- GameView:共享 ViewModel、导航、稳定游戏宿主、页面与模板契约、组合期页面注册。 -- 官方内置主题集合项目:建议新增 `src/Presentation/GalNet.Avalonia.Themes`,集中提供内置布局主题、页面 View、控件模板、配色和主题素材,依赖 Controls/GameView;共享层不反向依赖它。Sample 与其他宿主按需引用该项目。第一版不按每套主题或配色拆程序集。 -- Palette:在主题集合项目中独立组织,不嵌在某套布局主题目录内。布局主题与配色不是一一对应关系,可独立组合。 -- Sample/其他宿主:在组合期配置主题与配色、覆盖页面并提供平台能力。 - -建议目录结构(名称均为设计提案): - -```text -GalNet.Avalonia.Themes/ - Colors/ # 原始色表 - Palettes/ # Ocean、Rose、Mono 等完整语义配色 - Base/ # 公共视觉资源与可复用模板 - Layouts/ - Classic/ # 页面、控件主题、主题素材 - Minimal/ - Paper/ - Composition/ # 引入资源与组合期页面注册入口 -``` - -主题集合只是官方实现的容器,不承担游戏执行逻辑。第三方可以另建项目,使用共享的控件/页面契约,无需引用官方主题集合;仅需要复用官方色表或模板时才引用。以后出现包体或发布节奏需求再拆项目,不先创建 Themes.Abstractions 或每主题一包。 - -组合举例:Classic + Ocean、Classic + Rose、Minimal + Ocean。各布局依赖相同语义键,不读取配色名称或限定其目录路径;布局推荐配色只是默认值,不是绑定关系。固定美术区域可以不跟随配色,其支持范围需在 README 中说明。 - -应用内每个独立游戏/预览根使用明确的游戏资源作用域,避免样式污染其他预览或编辑器。Sample 的启动页、语言选择页和后续 GameShell 使用同一份外观配置,不依赖玩家存储就绪。第一版不需要可变 AppearanceContext 服务。 - -资源逻辑优先级从低到高为:基础必需资源 → 已配置的完整配色 → 已配置的主题专用资源 → 宿主覆盖。公共语义配色键与主题专用键分开,主题不得以重定义公共 Palette 键的方式绕过宿主配色配置。 - -该优先级是目标契约,必须通过资源结构和实际查找路径实现并验证;不能假定不同字典层级的加载先后等于优先级。 - -弹窗、Popup、独立截图窗口等可能有不同的资源查找路径,需要显式接入相同的外观资源。原生文件选择器采用系统外观。 - -## 页面与控件扩展 - -### 普通页面 - -标题、设置、存档列表、鉴赏入口、关于等采用共享 ViewModel + 主题 UserControl。主题可只覆盖部分页面,其余沿用基础主题;不要求所有页面改成 TemplatedControl。 - -页面 View 负责视觉与绑定,不复制加载、保存、导航或变量逻辑。必要行为通过稳定命令与宿主服务暴露。长文本、动态本地化、禁用状态和未知鉴赏类型仍必须可用。 - -ViewModel 与默认页面 View 可沿用现有游戏 Scope 生命周期。没有动态切换,不需要新增按主题缓存、View 热替换和视觉状态迁移机制。 - -### 游戏与有媒体状态的页面 - -推荐将 GamePage 本身改为外观中性的 TemplatedControl,而不是让每个主题提供另一种 GamePage 类型。名称可沿用 GamePage;不要求额外包装一个 GamePageHost。 - -共享 GamePage 保留场景渲染、输入路由、presenter 接线和截图能力。运行实例拥有 Scene、Dialogue、Nvl、Choice 等核心内容控件,主题通过 ControlTheme 的布局模板及有限 ContentPresenter 承载点安排它们;工具栏绑定共享 ViewModel 的命令。主题不创建第二套游戏 presenter,也不拥有 Engine 或媒体服务。 - -该内容插槽方案与“模板直接创建全部核心 PART 控件”相比,需要少量额外内容属性,但减少主题对核心控件类型与接线的重复。第一版采用内容插槽方向,具体属性和部件名在 feature 设计时确定。 - -在初始化/模板就绪阶段验证必需内容已挂载,成功后才允许首条对白执行。保持基础可用模板;宿主替换成损坏模板时给出明确诊断。 - -现有 presenter 与 Sample 可继续依赖这个稳定的 GamePage 类型,只调整具名字段访问为稳定内容属性。截图也继续调用其场景截图能力。只有确实需要允许替换整个游戏宿主实现时才增加接口,第一版不为主题专门增加这一层。 - -同一内容控件只能有一个父节点,主题模板不得把同一个场景或对白插槽显示两次。页面导航仍复用同一游戏实例,不因换页重新创建 presenter。取消动态切换后,不需要支持运行中跨模板迁移。 - -Gallery 视频/音频播放页仍需分离共享播放行为与主题外观,但可在组合期决定 View。复用既有媒体服务,不复制播放生命周期,不为第一版增加运行期替换机制。 - -游戏输入应根据明确的交互区域/消费状态决定是否推进,不能只识别 Button 或 ChoiceList。主题新增 Slider、TextBox 等交互内容时不得误推进剧情。 - -### 控件模板契约 - -保留并文档化 `PART_Typewriter`、NVL 行容器与滚动容器等现有必需部件。缺失部件给出明确诊断,不能把待完成对白静默判为已完成。 - -基础模板保证键盘焦点、禁用/选择状态和行为可用。主题的模板变更可改变结构,但不能改变完成语义。若以后支持运行中重新创建打字机/NVL 内部部件,需先把进度、历史和完成任务生命周期从模板部件移到独立呈现状态。 - -## 注册、回退与兼容性 - -在组合期选择一套主题,装配其资源、ControlTheme 和普通页面 View 映射。沿用现有不可变页面注册表及 PageViewFactory,不增加主题感知的运行期 View 工厂。 - -页面注册顺序为基础页面 → 所选主题覆盖 → 宿主显式覆盖,最终映射在启动完成前固定。现有宿主页面覆盖保留优先权。主题需要同时支持 XAML 样式引入和代码侧页面注册;具体统一配置入口在 feature 中确定,避免开发者只引入样式却漏掉页面映射。 - -主题可以声明一个基础主题;第一版优先单层继承。部分主题未提供页面/专用资源时允许回退,循环继承与重复 ID 在注册时拒绝。 - -区分“可选覆盖缺失”和“声明的实现损坏”:前者回退,后者诊断并拒绝初始化,不能通过静默回退掩盖绑定或模板错误。Palette 必需键在初始化时检查。 - -包内引用主题即可;第一版不加载任意目录下的 XAML/程序集作为运行期插件。主题包说明默认配色、覆盖能力与模板契约;不需要为配置类额外建立运行期主题 ID、选择状态和偏好存储系统。 - -## 配置与后续扩展边界 - -第一版配置流程:开发者选定主题与配色 → 引入主题资源 → 注册主题页面覆盖 → 宿主追加定制 → 构建游戏 Scope → 验证并显示 UI。 - -配色和主题独立配置;未显式提供配色时使用主题默认配色。样式继续通过资源引用获取颜色,使用 DynamicResource 不意味着库承诺运行时完整主题切换。 - -取消 ThemeId/PaletteId 绑定、外观协调器、Applied/Pending 状态、安全边界检测和热替换 View。二周目等需求若以后恢复,应单独分析:启动前条件选择和游戏运行中切换是不同能力。启动前读取玩家数据也是宿主责任,不作为本期配置流程要求。 - -动态主题切换的历史风险保留在事实说明中:当前对白/NVL 状态与模板部件耦合,未来恢复该能力时必须重新设计和验证,不能把本期可替换模板视为任意时刻重建模板的保证。 - -## 示例与开发者体验 - -拟提供 Classic、Minimal、Paper 三个示例,体现不同布局和背景策略,不只是三套换色。每个示例可引用共享的基础页面与模板,避免机械复制整个 UI。示例素材具备可再分发与二次开发许可。 - -Sample 外观展示入口包含所有页面、ADV/NVL、长文本、选项、存档空/正常/损坏状态、设置控件、应用弹窗以及键盘状态。UI 文案继续使用已有本地化机制,字体需考虑中文等 fallback;宽高采用容器约束与可滚动布局,验证现有最小窗口、DPI 与长文案。 - -README 分别示范代码/XAML 配置、仅覆盖配色、仅替换一个页面以及基于基础主题修改。标明主题哪些区域跟随 Palette、哪些属于固定美术。展示多个主题可用不同启动配置,不要求运行期选择器。 - -## 候选方案与取舍 - -- 只抽资源:成本低,可换色,无法覆盖主页面结构;不满足目标。 -- 所有 Page 模板化:统一外观入口,但扩大部件契约并增加简单页面的开发负担;不采用为通用要求。 -- 每套主题复制所有 Page 和 code-behind:自由度高,但复制业务与媒体生命周期;不采用。 -- 普通 View 在组合期替换 + GamePage 本身布局模板化:保留开发自由并集中生命周期,是推荐方向;代价是把现有 GamePage 的视觉树移入模板并暴露稳定内容属性。 - -## 验收与实施顺序建议 - -1. 最小原型验证游戏根资源隔离、配色资源链、Popup/弹窗资源路径及单一明确基础控件主题。 -2. 抽取公共资源和稳定游戏宿主,清理本地视觉硬编码,完成一个主题覆盖全 Sample。 -3. 完成组合期主题页面注册与回退,保留原宿主页面覆盖能力。 -4. 制作布局不同的其他主题、配色、展示入口与开发者配置示例。 - -验收重点:不同启动配置能覆盖主要玩家界面;首条对白等待模板就绪且正常阻塞;NVL/选项/输入推进行为不受布局影响;同页导航重挂不出现双父节点;截图与媒体仍可用;语言选择、弹窗与正文 UI 配色一致;游戏根之间不串资源;宿主覆盖与缺失回退结果确定;会话正常释放事件订阅与图片资源。 - -## 需要确认的产品选择 - -- 视觉主题对 Palette 的支持是否允许部分/固定?推荐允许并明确能力,官方示例至少包含完整采用配色的主题。 -- 是否单独要求运行时配色切换?当前按组合期固定配色处理,语义资源结构保留,但不增加切换 API。 -- 默认样例主题与配色,以及三个参考主题的美术方向尚未确认。 -- 官方集合项目建议统一为 GalNet.Avalonia.Themes;公开配置 API 名称、基础控件主题策略和兼容版本策略待 feature 中细化。 - -## Avalonia 参考 - -- [Control themes](https://docs.avaloniaui.net/docs/styling/control-themes):控件模板与控件主题机制。 -- [Property value precedence](https://docs.avaloniaui.net/docs/properties/value-precedence):本地值与样式优先级。 -- [Theme variants](https://docs.avaloniaui.net/docs/styling/theme-variants):明暗变体与资源机制。 - -这些机制提供实现基础,不证明现有 GalNet 控件具备无缝换模板能力;相关保证需要上述迁移与验收。 diff --git a/docs/design/misc-todo.md b/docs/design/misc-todo.md deleted file mode 100644 index 5d90a549..00000000 --- a/docs/design/misc-todo.md +++ /dev/null @@ -1,24 +0,0 @@ -# 杂项待办 - -本文件是跨 feature 的简洁待办索引;详细设计留在对应 feature。完成后移除对应条目,并在相关规范中留下最终行为。 - -## 功能后续 - -- 粒子 Renderer:补 blend mode、alignment;trail、collision、mask、sub-emitter、GPU simulation 按实际效果需求再评估。详见 [F-20260929-02 Phase 3](../../features/F-20260929-02-particle-system-v2/phase-plan.md#phase-3--renderer-与高级模块)。 - -## 条目编译层 - -- 将 `GalgroupCompiler` 返回的 source map 输出为可随构建产物分发的映射文件,供编辑器诊断、断点和跳转定位使用。 -- 设计用户自定义复用非原语的定义与调用格式:类型化参数、局部句柄作用域、调用栈与递归/循环调用诊断。 -- 支持 `animation.play.events` 中的非原语在同一帧展开为多个原语事件;在明确异步与阻塞语义前,继续拒绝此类输入。 -- 将 `.rawgalgroup → .galgroup` 编译接入正式 `.galpak` 导出,而不仅是编辑器预览。 -- 为旧项目从 `*.galgroup` 编辑源迁移到 `*.rawgalgroup` 提供显式迁移与旧文件清理,避免保存后遗留两份来源不明的内容。 - -## 资源定位统一 - -- 已完成基础运行时资源模型:`IAssetManager.LoadAsync(guid)` 以 `GUID + 目标类型` 为缓存键,并维护引用计数与 `IDisposable` 资源释放;宿主通过 `RegisterDecoder` 注册平台/领域类型的解码器。`SceneTexture` 的 Skia 解码器属于 Avalonia Rendering,Core 与 Assets 不依赖 Avalonia。 -- 已完成 Sample 的场景预热:启动时异步获取所有 Sprite 的 `SceneTexture`,Layer 绘制只查询已加载的强类型资源;Session 结束统一释放 AssetManager 所有缓存。shader 文本同样走 AssetManager,已编译的 GPU shader program 由 renderer 在 effect runtime 生命周期内管理。 -- `LoadByPathAsync` 与无类型 `Release(id)` 仅保留给旧工具和迁移逻辑;正式游戏内容应始终用 GUID,且新调用点应配对使用 `LoadAsync` / `Release`。 -- 为 Headless / 已发布宿主接入与 Sample 相同的 AssetManager GUID 解析,并在加载阶段拒绝旧路径引用;Sample 已完成此迁移。 -- `.sksl` 已纳入正式 `EffectProgram` 资源类型,AssetCatalog/文件命令会生成对应 `.meta`,资源选择和打包保留该类型。仍需让编译器在写入 `EffectProgramResource` 与 Layer 引用时强制验证 GUID;相对路径仅保留为开发诊断显示或明确的迁移输入,不能成为正式存档与已发布 galgroup 的依赖。 -- 新项目模板预置效果资源属于后续独立工作,不是渲染器的内嵌资源。 diff --git a/docs/design/modular-game-state-and-restore.md b/docs/design/modular-game-state-and-restore.md deleted file mode 100644 index 494d4002..00000000 --- a/docs/design/modular-game-state-and-restore.md +++ /dev/null @@ -1,219 +0,0 @@ -# 模块化游戏状态与存档恢复 - -状态:已实施,2026-10-15;验收见 [feature 总结](../../features/F-20261015-03-modular-game-state/summary.md)。中央类型化容器、持久数据与运行实例分离、模块贡献恢复逻辑是已确认方向。用户进一步确认:项目快速迭代,不保留旧逻辑或旧档兼容;音频轨道与 SEffect 同批纳入;以逻辑清晰、没有冗余为约束。本文保留设计依据;实际 API 与生命周期以 runtime spec、development 和下方实施决策为准。 - -## 已确认的实施约束 - -1. 直接替换旧路径,迁移完成后删除旧 SceneState 列表、中央类型分支、双写镜像和旧恢复入口,不保留长期适配器或双格式读写。 -2. 不提供旧存档迁移、旧字段回退、未知 section 的透明兼容。旧档拒绝读取;验证时创建新存档,不批量改写磁盘上已有存档。 -3. 音频同批完成轨道和轨道 SEffect 的持久化、恢复、状态增删与生命周期验证,采用与 layer/effect 一致的状态注册方式。 -4. 只保留必要的数据、索引和生命周期结构;不为可能的未来需求增加恢复策略框架、多种存储模型、自动发现或多层包装。 -5. 更新实际 Sample 契约/配置/剧情文件及测试生产者,使其生成新格式;不在测试中保留旧生产路径来模拟兼容。 - - -## 1. 变更前的问题与事实 - -变更前采用稳定快照,GameSnapshot v3 保存剧情位置、Save 变量和固定结构的 SceneState;读档并非重新执行先前所有 primitive。 - -| 位置 | 当前职责与扩展成本 | -| --- | --- | -| `SceneState`(已删除) | 固定声明 Layers、ActiveEffects、ActiveParticleEmitters、ActiveAnimations | -| [SnapshotCopy](../../src/Shared/GalNet.Core/Runtime/SnapshotCopy.cs) | 逐类型、逐字段深复制;新增持久字段需要同步修改 | -| [GameRuntime](../../src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs) | 显式从各类描述重建 EffectInstance、ParticleEmitterInstance、AnimationPlaybackInstance | -| [SceneInstanceManager](../../src/Shared/GalNet.Runtime/Runtime/SceneInstanceManager.cs) | 维护全局场景句柄索引,并特殊处理 Layer 的持久列表增删 | -| `BuiltinPresentationReplay`(已删除) | 分别恢复图层、视频、效果、粒子、动画;动画仍按 EntryType 分支 | -| `PersistentSceneReplaySession`(已删除) | 统一拥有恢复产生的 continuation,并在下次恢复/释放前等待清理 | -| [动画值同步](../../src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationValues.cs) | 按实例类型 switch,将效果/粒子动画值复制回持久描述 | -| [效果/粒子状态操作](../../src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.EffectParticleState.cs) | 同时增删运行实例和持久列表,维护两份相关状态 | -| [AudioRuntime](../../src/Shared/GalNet.Runtime/Audio/AudioRuntime.cs) | TrackState 混合轨道定义、活动 voice、队列、ticket、generation 和效果;没有存档接口 | - -Layer 与其他类型也不一致:Layer 本身既是可序列化状态又是动画目标,并包含 JsonIgnore 的 VideoPlaybackId;效果/粒子则另有 Active*State 描述。仅将四个 List 改成 Dictionary,不能消除这些差异和中央分支。 - -已有不变量见 [runtime spec](../spec/runtime.md)、[资源所有权](runtime-resource-ownership.md)、[执行注册设计](entry-registration-refactor.md) 和 [渲染管线计划](render-effects-pipeline-plan.md)。本设计不替代这些长期设计,也不改写 Accepted ADR-0002。 - -## 2. 目标与边界 - -目标:新增一个持久状态类型及其恢复实现时,只在所属模块/宿主组合根注册,不修改中央快照字段、深复制 switch、Runtime 恢复分支和内置重放分支。 - -中央容器统一持有可恢复事实。模块定义业务含义;通用机制负责索引、快照、版本和恢复生命周期。Core/Runtime 不引用平台类型或 Builtins 的具体模块。 - -剧情位置、Save/Player 变量、项目配置、玩家设置、资源服务继续有清晰边界;不立即将它们全部塞进通用字典。UI 页面、历史日志、单次截图、一次性 burst、Task 与后端资源不是自动持久化对象。 - -音频是同批必需的实现和验收消费者,轨道与 SEffect 都必须使用该机制,不允许只保留“未来可接入”的接口。实施结果见 feature 总结。 - -## 3. 中央容器存必要状态,不存执行对象 - -建议将覆盖视觉和音频的容器称为 GameState。Scene 可以作为它的领域视图;不再让名称暗示“只有画面”。概念结构为: - -```text -GameState(一次运行代) - Type → StateTable(稳定 item ID → 状态记录) - 派生的场景句柄/动画目标索引 - -GameSnapshot(稳定检查点) - 流程位置 + Save 变量 + StateId → { Version, Payload } - -运行期对象 - PrimitiveInstance、播放任务、ticket、generation、取消源、平台句柄 -``` - -类型化表 API 的示意,不是最终接口: - -```csharp -state.Table().TryGet(handleId, out var layer); -state.Update(batch => batch.Table().Set(handleId, updatedLayer)); -``` - -选择 Type → 按 ID 索引的表,而非直接暴露 Type → List:按 ID 更新和删除无需扫描;同表重复 ID 明确失败;不能绕过入口修改集合。确有顺序语义的数据应在记录中保存有序列表或显式顺序字段,不能依赖 Dictionary 的遍历顺序。集合为空与单例默认值应由状态注册声明,读取未注册类型报错,不隐式注册。 - -首期可将单例放在约定 key 的表中,不必同时推出多套 Singleton/List/Map 容器。若实际消费者证明单例很多,再增加薄的便捷 API。 - -| 数据 | 持久化策略 | -| --- | --- | -| 图层资源 ID、transform、z、opacity、visible | 作为 LayerState 保存 | -| 持续效果、粒子定义与稳定参数/动画值 | 作为状态记录保存 | -| 循环动画/计划的完整定义与稳定起始值 | 保存定义,默认从头重建 | -| non-blocking Once 动画 | 保持现有末值语义,不保存中间展示游标 | -| 阻塞操作的执行中间状态 | 保持现有稳定检查点策略,不新增中途存档 | -| 音频内容身份、内容 gain、暂停状态、有序逻辑队列 | 作为 AudioTrackState 保存,读档从头恢复活动内容 | -| 音频轨道 SEffect 的类型、参数、实例身份、所属轨道 | 作为 AudioSEffectState 保存,先恢复轨道绑定再建立声音效果 | -| PrimitiveInstance、Task、ticket、事件、CTS、VideoPlaybackId、平台 voice ID | 不保存,恢复时重新生成 | - -Options 表达一次指令输入,State 表达当前可恢复事实,Request 表达给展示端口的操作;三者不能直接视为同一种数据。例如停止/替换后的状态、动画稳定起始值并不等于原始 Options。可共享中立值对象,不能把原始 Options 自动原样当存档。 - -## 4. 唯一事实来源与动画索引 - -建议新状态采用不可变记录/深度不可变值,通过 Set/Remove 或同步批量 Update 替换。单纯使用 record 并不能保证内部 List/Dictionary/自定义值不可变;注册类型必须满足状态契约,且通过序列化 round-trip 与别名隔离测试。 - -运行实例可以拥有展示临时状态,也可以持有该运行代的表引用;不得另存一份需要手工同步的权威业务状态。Effect/Particle 的动画目标适配器应读写同一状态记录,取代 SyncEffectAnimationState / SyncParticleAnimationState。适配器与按句柄的索引是派生缓存,不再进入存档。 - -仍需尊重当前全局场景句柄语义:Type 分表不能使同一个视觉句柄悄悄同时属于图层和效果。注册时为可被场景动画寻址的类型提供 ID/属性访问适配器,派生索引继续检查跨类型冲突;音频 track 等独立身份域不参与视觉句柄冲突。首期保留已有剧情 handle 格式,避免顺带修改内容协议。 - -删除与替换应由统一写入入口维护表和派生索引,不能再由 SceneInstanceManager 对 Layer 做特殊持久化处理。旧异步清理除校验运行代外,还应校验 item revision/播放 identity,不能仅凭相同字符串 ID 删除后来替换的记录。 - -## 5. 模块注册、序列化与校验 - -内存 Type 仅用于类型化访问;存档使用显式稳定 StateId,例如 builtin.layers、builtin.effects、builtin.audio.tracks,另有 section version。禁止将 AssemblyQualifiedName 或任意反射类型名作为存档类型协议。 - -注册示意: - -```csharp -states.RegisterTable("builtin.layers", version: 1, key: value => value.Id); -restore.Register("builtin.layers", restoreLayers); -``` - -StateCatalog 是平台无关、冻结的元数据;恢复处理器在执行/展示组合期绑定服务。二者可由同一领域模块贡献,但不要求所有 IPrimitiveModule 都实现存档接口,不为无状态指令增加空方法。延续 GameRegistration 的显式组合:不扫描程序集、不全局服务定位、不按 CLR 类型自动发现插件。 - -组合根必须把同一份冻结 StateCatalog 提供给 Runtime 和存档边界,并把对应恢复处理器提供给恢复协调器;执行 schema-only 编译不得因此创建平台服务。具体 builder 方法名留待实施阶段确定,避免为这些产物新增多层会话包装对象。 - -通用机制承担:StateId/CLR 类型/恢复 ID 唯一性、item key、section version、JSON 编解码、载荷隔离、恢复依赖检查,以及已注册的业务校验回调。JSON 值必须由容器拥有,不能依赖已释放 JsonDocument 的生命周期。 - -业务模块承担:字段之外的领域规则、跨记录引用和恢复策略。例如效果目标必须存在、动画首帧与轨道合法、声音是否应在读档后重启。应复用现有中立校验函数,不能假定 Entry 字段 schema 就足以验证当前状态。 - -首期建议使用受限的纯数据 DTO 和通用 JSON codec。复杂类型显式提供 converter/codec;不要求每类手写 Clone。注册一份 payload codec,快照捕获时创建容器拥有的不可变 JSON 载荷,读档时解码新对象,避免再维护逐字段 SnapshotCopy。序列化只发生在快照/读档边界,不在每次状态更新时执行。 - -对未知 section、重复 StateId/item ID、版本不支持、payload 不合法、必要引用缺失均明确拒绝,且在停止现有运行前完成验证。没有旧格式回退、自动迁移、未知载荷透明保留或兼容读取路径。新存档写出所有已注册 section,空集合也显式写出;读档按当前格式要求其完整,避免“缺失时猜测默认状态”掩盖损坏文件。 - -整体容器格式改变升级 GameSnapshot 版本(已由 v3 升至 v4),与 section version 分工:前者约束封套结构,后者约束模块载荷。两者都只接受当前支持的版本,保留版本字段用于明确拒绝不匹配,而不是引入迁移器。测试直接新建新格式存档;磁盘旧档无需转换。 - -## 6. 稳定快照与并发 - -保留 Engine 现有稳定边界:游标已提交、没有未完成 blocking instance、没有 pending Choice。保存使用最后稳定检查点,而非临时读取当前容器。 - -状态写入和快照冻结必须共享一个短的同步边界。一次 Update 同步完成跨表变更与索引更新,锁内禁止 await、调用 presenter 或触发用户回调。稳定检查点需要把流程位置、Save 变量和所有状态表绑定到同一代/同一提交边界,不能分别异步采集后拼成存档。 - -后台完成回调通过运行代绑定的写入口更新逻辑状态;快照捕获期间不能出现“效果已删除、动画引用尚未删除”的半次更新。锁顺序须固定:Engine 执行门 → 状态提交边界;后端回调不得持有 AudioRuntime 锁再等待 Engine,避免与保存/停止相互等待。音频后端通知应先形成带 generation 的短事件,退出后端锁后再提交所需状态更新。 - -先正确实现完整冻结/编码,再测量实际快照成本。可缓存未变化 section 的不可变 payload,但必须有可靠 revision,不能因直接修改内部对象漏增 revision。不要首期就实现增量日志、事件溯源或完整 MVCC。 - -## 7. 读档与展示恢复协议 - -建议恢复流程: - -1. 解码、校验版本与状态,构造隔离的新 GameState 和派生索引;检查恢复依赖。验证失败保留当前运行。 -2. 进入 Engine 执行门,暂停推进,取消旧 scope/Choice,停止并等待旧恢复 continuation 与相关后端清理。 -3. 原子切换流程位置、Save 变量、新 GameState 与新运行代。Player 变量、已读进度、玩家音量不随普通读档回滚。 -4. 向模块提供绑定新代的上下文,按注册的依赖顺序重建展示;生成新的 voice/video identity,不恢复旧 Task/ticket。 -5. 恢复完成后才允许剧情继续。后端重建失败则保持可诊断的恢复失败状态并阻止推进;不声称能够回滚已释放的平台资源。重试先清理本次部分资源,再按同一稳定状态恢复。 - -运行代隔离不能只靠 CancellationToken:旧实例必须继续持有旧 GameState/旧表,而不是通过共享 Runtime 动态取得新表。模块异步回调还需检查代/identity。音频模块通常跨多个运行代复用,故旧 scope 的 Reset 回调不能在新音频启动后清空新代;恢复协议必须先排空旧代并重新绑定模块资源。 - -恢复协调器不认识 Layer/Audio/Particle,模块处理器只恢复本领域,并声明依赖。例如:图层 → 依赖图层的效果 → 控制这些对象的动画;音频可独立恢复。依赖拓扑在组合时检查循环与缺失,同序稳定排序;首期顺序执行,未经测量不引入并行恢复。 - -恢复处理器返回其拥有的 continuation/清理句柄。协调器统一跟踪、取消、等待和逆序释放,复用当前 PersistentSceneReplaySession 已验证的职责,而非每个模块重新实现同一套任务管理。恢复协调器借用执行模块与 presenter,宿主先停止 Engine/恢复工作,再释放执行 view 和后端。 - -从存档恢复和 CatchUp 结束后的展示同步可共用“按状态构建展示”能力,但调用原因与策略必须显式:例如已有的 CatchUp BGM 投影不能被读档 Reset 策略误清空。重建动画时不得重放已有状态修改/一次性事件,避免 additive 累加或重复 burst;恢复路径应区分“建立展示 continuation”和“执行一次新指令”。 - -## 8. 音频轨道与 SEffect:同批实现 - -当前音频没有存档恢复。TrackState/VoiceState 不能直接序列化:它们包含 ticket、generation、运行集合与轨道定义引用。按 layer/effect 的目标结构拆分两类状态: - -| 类型 | 保存内容 | 不保存内容 | -| --- | --- | --- | -| AudioTrackState | 轨道 ID、当前逻辑播放项、内容 gain、暂停状态、有序待播队列;仅保留恢复所需且现有系统支持的字段 | host、播放器、ticket、后端 voice ID/generation、分发任务、轨道配置副本、玩家音量副本 | -| AudioSEffectState | 所属 track ID、轨道内唯一的 effect instance ID、SEffect 类型 ID、参数 | 后端滤镜对象、播放器绑定、平台资源 | - -两个状态表分别注册为 builtin.audio.tracks / builtin.audio.seffects。SEffect 通过 track ID 引用轨道,实例身份保持现有“轨道内唯一”的语义,复合索引键必须无碰撞,不能用未经转义的字符串拼接。删除/替换操作在一次状态提交内维护引用完整性。 - -所有仍存在的轨道逻辑状态采用同一持久化规则,不在中央按 BGM/SFX/Vocal 写分支,也不为首期再引入可选的轨道恢复策略框架。已经自然结束或被停止清除的播放项不恢复;仍活动或排队的内容会进入快照,包括 SFX/Vocal。这样恢复行为由检查点中实际存在的状态决定;当前内容在读档后从头播放,不承诺在句中精确续播。 - -SEffect 是轨道上的持久逻辑对象。没有 active voice 时也保留;恢复后必须继续作用于该轨道后来启动的声音,不能只恢复到读档当时的 voice。Stop/Clear、删除轨道和 StopSEffect 必须明确区分清除播放项与清除轨道效果,避免把现有操作混成一个 Reset。 - -恢复顺序为:验证项目轨道配置与中立 SEffect 定义 → 还原轨道/效果逻辑记录 → 建立效果绑定 → 用新后端 identity 启动活动项并应用当前玩家音量与暂停状态 → 恢复队列调度。音量仍按当前玩家设置与内容/轨道 gain 合成;项目路由定义不复制进存档。暂停成为明确的逻辑轨道状态,恢复时不能先短暂有声播放再遗漏暂停;新入队内容也必须遵守同一暂停语义。 - -默认从头恢复内容,不保存淡化墙钟进度。当前 host 协议没有通用 seek/游标查询,因此不增加精确续播、后台播放器快照或新的媒体时钟。现有系统尚未实现的循环播放、item scope effect、音频参数动画不因加入存档而顺带新增。仍需完成现有 gain、lowPass、highPass 及项目自定义轨道 SEffect 的恢复链路。 - -音频自然结束会改变活动项/队列,因此不仅 Play/Stop 指令,后端完成事件也必须通过代际隔离入口提交状态。后端 ticket/voice 映射是派生运行对象,不能再维护一套独立权威的内容队列后手动同步 AudioTrackState。capture 不向播放器异步查询,以保持检查点的一致性。 - -没有 host 时仍维护逻辑状态,展示不可用不会导致存档数据消失;流程等待按明确的无后端策略完成。自定义 SEffect 的中立描述/参数校验应能独立于平台播放器获得,不能为了读档校验先创建播放器。headless 验证需覆盖轨道状态、队列和效果 round-trip,不能只以 GUI 播放成功作为证据。 - -## 9. 可进一步优化的项目 - -| 优先级 | 改动与代码依据 | 收益与代价 | -| --- | --- | --- | -| P0 | 注册式状态表、codec 和恢复处理器,替换 SceneState / SnapshotCopy / GameRuntime / Replay 的类型清单 | 实现模块扩展目标;跨 Core、Runtime、Builtins、Sample,不能当局部字典替换 | -| P0 | 统一效果/粒子持久事实,删除同步镜像路径 | 消除双写、漏同步和按列表查找;要迁移动画目标读写,保留句柄和稳定值语义 | -| P0 | 恢复阶段、运行代、continuation 所有权统一 | 新模块自动得到取消/等待/失败门禁;不改变每个领域对状态含义的定义 | -| P1 | 循环动画从 EntryType + Dictionary 改为注册的类型化持久定义 | 去除 ToReplayParameters 与反向解析,以及中央动画类型分支;状态表示仍须独立于一次调用 Options | -| P1 | 正常执行和恢复共用 State → Request 映射/中立值对象 | 减少重复字段搬运;不同路径仍保留“是否修改逻辑状态/产生事件”的显式区别 | -| P0 | 音频轨道/SEffect 同批落地,并作为新增状态类型的扩展验收 | 验证无需修改中央协议即可恢复音频;消除混合音频状态和额外镜像,覆盖全部仍存在的轨道状态 | -| P1 | 持久类型与适配器逐步归领域所有,Core 仅留中立协议 | 降低 Core 的内置领域耦合;会影响公开类型引用,适合在状态迁移后分步做 | -| P2 | 不变 section payload 按 revision 复用,ID 查找取代 FirstOrDefault/RemoveAll | 降低快照和大集合成本;先基准测试,不提前承诺性能或净删行 | -| 暂缓 | 自动捕获 CLR 对象图、反射插件发现、统一业务 DSL、任意时间点精确媒体续播 | 不能从纯类型推导领域恢复语义,容易把资源/生命周期带进存档,超出当前问题 | - -这次值得优先减少的是“一个领域在五处登记/同步”的重复职责,而不是增加通用容器后继续保留全部旧镜像。完整迁移的行数应同时计算新状态/恢复协议、适配器和删除的中央分支;成本统计见 feature 总结,不只计算删除的中央分支。 - -## 10. 迁移顺序与验收建议 - -实施时建议先形成正式 feature/Phase 计划,再按下列可验证切片推进: - -1. 建立状态注册、类型化表、封套 codec、稳定捕获与代际隔离。用一个自定义测试状态证明 round-trip 不要求修改中央代码。 -2. 迁移图层,并验证视频临时 identity 不入存档、旧代引用不能影响新代、跨类型视觉句柄冲突仍报错。 -3. 迁移效果/粒子/动画与统一恢复协调器,删除对应旧列表、镜像同步和中央重建分支;保持 Once/Loop/阻塞和计划事件语义。 -4. 同批接入 AudioTrackState / AudioSEffectState,证明新增状态无需修改中央协议;覆盖自然结束、多 voice、暂停、队列、SEffect、玩家设置、无 host 与旧代回调。 -5. 删除剩余旧场景状态/中央重放路径及临时过渡代码,更新正式 spec、Sample 实际文件和开发者示例;新建存档进行验证,不迁移磁盘旧档。 - -关键验收:自定义模块可独立扩展;快照无 mutable 别名;输入验证失败不破坏当前运行;跨表提交一致;旧任务与旧 scope 的 Reset 不污染新代;恢复失败不推进剧情;重复恢复不重复副作用;未知版本/重复 ID/依赖环明确报错;取消/部分重建失败后所有资源有所有者并能清理。 - -## 11. 清晰与无冗余的退出条件 - -- 每类可恢复事实只有一个持久状态来源;不允许“新状态表 + 旧 SceneState 列表 + AudioRuntime 私有权威队列”长期双写。 -- 类型化访问、编码、恢复职责各有一个入口;增加模块不需要在 Core/Runtime/中央 replay 新增类型分支。 -- 运行期索引、适配器和 ticket 映射明确为派生数据,可从状态重建;只有性能/后端身份所需的索引才保留。 -- 正常执行与恢复共用数据转换,恢复不重放业务副作用;不为了复用少量字段把 Options、State、Request 强行合成一个大 DTO。 -- 不保留旧 API 别名、旧存档转换器、双路径开关、反射兼容层或无人消费的抽象。 -- 音频按两个状态类型完整接通,SEffect 的后续 voice 继承、暂停和自然结束后队列变化都有实际验证。 -- 测试通过实际生产路径生成新快照;Sample 使用更新后的真实文件,不靠旧格式 fixture 维持假兼容。 -- 审核同时计算公共机制和适配器成本,不能只报告中央代码的删行;若某个抽象只搬移代码而没有消除职责重复,应收敛回直接实现。 - -用户的三项范围约束已经确认。具体接口签名和每个实现切片仍需在实施计划中落定,不再将旧档兼容或是否包含音频列为待确认问题。 - -## 12. 实施决策 - -- 最终注册入口为 `PrimitiveRegistry.State` / `Restore` / `ValidateState`,view 提供冻结的 StateCatalog。GameSnapshot v4 使用 `States`,中央机制不枚举领域类型。 -- 复用 Layer、EffectInstance、ParticleEmitterInstance 的中立数据与动画属性操作作为唯一记录;没有为它们再增加 State DTO/适配器壳。记录仍可变,字段修改必须在 `State.Update` 内完成,捕获通过 JSON 形成隔离载荷。不可变记录是后续可选演进,并非本次已经实现的保证。 -- 场景索引由实现 ISceneInstance 的注册记录自动派生;动画停止控制另属瞬时索引。视频每次建立图层都替换记录引用,旧任务仅能清理其持有的记录。 -- StateRestoreSession 跟踪 continuation 和逆序清理回调,取消等待保留所有权,清理错误聚合报告;已经完成的释放不会在下次重试重复执行。 -- 音频轨道/SEffect 的逻辑表为唯一事实,voice generation、ticket 和派生绑定不保存。内置 SEffect 中立描述由 Core 提供,平台处理器复用;自定义描述可注入 AudioRuntime。 -- 验证器按领域显式注册。通用 codec 不能推导所有业务约束,不自动校验资源文件存在性或任意自定义模块的跨表语义;这些职责仍属于模块。项目轨道、音效参数、恢复依赖与重复身份均有实际校验。 -- 保留稳定检查点语义:保存的是检查点当时的音频状态;后续自然结束不会改写历史快照。没有精确 seek、淡化游标或旧档迁移。 diff --git a/docs/design/render-effects-pipeline-plan.md b/docs/design/render-effects-pipeline-plan.md deleted file mode 100644 index 02714021..00000000 --- a/docs/design/render-effects-pipeline-plan.md +++ /dev/null @@ -1,232 +0,0 @@ -# 分阶段渲染与可扩展 Effect 管线计划 - -## 目标 - -将当前以 Avalonia 控件直接合成 Layer 的呈现方式,演进为可处理动态源和 GPU 像素效果的渲染管线。新设计必须支持: - -- 单个 Layer 串联局部效果; -- 静态图片、Flipbook 和未来视频等动态 Layer 源; -- 场景合成后、游戏 UI 合成前的全屏后处理; -- 每个具体效果由自己的模块负责,不在场景宿主中加入按 effect ID 分支; -- 保持 Core / Runtime 不依赖 Avalonia、Skia 或 GPU 类型。 - -## 渲染阶段 - -```mermaid -flowchart LR - Source[Layer Source\n静态图 / Flipbook / 视频] --> LE[LayerEffect 链] - LE --> Scene[Scene Render Target\n按 z 合成] - Scene --> Post[ScenePost 后处理链] - Post --> Output[场景输出] - Output --> UI[GameShell Avalonia UI\n对话 / 选项 / HUD] -``` - -| 阶段 | 输入 | 典型效果 | 是否影响游戏 UI | -| --- | --- | --- | --- | -| `Layer` | 一个 Layer 的当前帧 | 调色、溶解、局部发光、扭曲 | 否 | -| `ScenePost` | 已合成的场景纹理 | LUT、暗角、bloom、景深、场景闪白 | 否 | -所有阶段都使用同一个纹理流接口:取上一个 pass 的输出纹理,写出下一张纹理。`ScenePost` 是 Layer 全部完成并合成后唯一的全局后处理插槽。 - -同一阶段的 effect 必须按显式 `order` 升序稳定执行;相同 `order` 时按添加顺序执行。效果顺序是作者可见的数据,因为调色和 bloom 的顺序会改变结果。 - -## 边界与核心抽象 - -### Layer source - -`Layer` 持有 `ILayerSource`,不再直接假定资产一定是一张静态图片。渲染端的 source 每帧产出当前输入纹理;effect 只处理该纹理,不知道 source 的类型。 - -```csharp -interface ILayerSource -{ - TextureHandle GetFrame(in RenderFrameContext frame); -} -``` - -- `StaticImageSource`:返回同一纹理;保留现有 `assetId` 作为兼容的 authoring 简写。 -- `FlipbookSource`:由帧列表、FPS、起始偏移和循环模式决定当前纹理。 -- `VideoSource`:以后由解码器提供当前帧,不改变 effect 接口。 - -`TextureHandle`、`RenderFrameContext` 和 GPU 命令上下文都属于 Avalonia/渲染实现程序集;Core 中只保留 source 的可序列化描述、effect 定义和参数 schema。 - -### 像素 effect - -局部和全屏的 Shader 效果共享“输入纹理到输出纹理”的执行模型。具体效果独立声明元数据、参数、可动画属性、资源需求和渲染实现。 - -```csharp -interface IImageEffect -{ - EffectDefinition Definition { get; } - TextureHandle Render(TextureHandle input, in EffectRenderContext context); -} -``` - -`EffectRenderContext` 提供当前时间、尺寸、动画参数、辅助输入(mask/LUT/noise)及受控的临时纹理申请接口。Effect 不直接访问其他 Layer、页面 ViewModel 或全局状态。 - -### Scene object - -粒子不是“处理一张已有纹理”的 effect,而是独立更新并参与场景合成的渲染对象。它与 Layer 使用同一个 GPU 场景目标、坐标系和排序规则,但不进入 `IImageEffect`。 - -```csharp -interface ISceneRenderable -{ - int Order { get; } - void Update(in RenderFrameContext frame); - void Render(in SceneRenderContext context); -} -``` - -`ParticleEmitter` 实现此接口:负责粒子出生、生命周期和运动状态;渲染端将存活粒子批量提交为 sprite/quad instance。雨、雪、花瓣、火星等也复用这一类场景对象能力。 - -### 固定像素管线 - -effect 管线只接受 `Texture → Texture` 的像素 effect;粒子、独立控件和几何裁剪不属于该管线。纹理 mask、溶解和边缘燃烧由具体 `IImageEffect` 以辅助输入实现,不单独设 `MaskEffect` 运行时类别。 - -现有 `particle.emitter` 和 `mask.blinds` 是离屏渲染后端落地前的 Avalonia 视觉实现。进入 Phase 2 时,前者迁为 `ISceneRenderable`,后者改为 shader mask;不为它们保留第二条 Avalonia 视觉分支。 - -## 数据模型 - -直接使用阶段语义,不保留 `EffectScope` 或旧内容兼容层: - -```csharp -enum EffectStage { Layer, ScenePost } -``` - -- `Layer` 阶段必须有 `targetHandleId`。 -- `ScenePost` 不得指定 `targetHandleId`。 -- 所有阶段都由同一个 `IImageEffect.Render(input, context)` 执行契约处理;阶段只决定 input 是单个 Layer 还是已合成场景。 -- `EffectDefinition` 继续是编辑器下拉、参数检查和动画属性提示的唯一元数据来源。 -- `EffectInstance` 继续保存静态参数和已提交动画值;新增 `stage`、`order` 等字段时,同步更新快照、恢复、导出和测试。 - -Layer source 的 authoring 数据优先采用显式 `source` 对象;现阶段 `assetId` 与可选 `flipbook` 已足够表示静态图和精灵表,后续再统一写入 `source` 对象。 - -## 实施阶段 - -### Phase 1:渲染抽象与数据契约(已完成) - -已完成:定义 `EffectStage`,将 factory 元数据与编辑器提示切换到 Stage;`Layer` stage 强制要求目标 Layer,`ScenePost` 禁止目标 Layer;effect 实例及存档状态保存同阶段的 `order`。Layer 的 source 已抽出当前帧选择,静态图与 Flipbook 使用同一路径。 - -验收:非法目标/阶段组合能显示诊断;Layer、Effect 和快照测试覆盖配置及恢复。 - -### Phase 2:离屏场景与 effect render graph(已完成首版) - -- `GalNet.Avalonia.Rendering` 承载 `SceneTexture`、`SceneLayerHost`、稳定 `SceneRenderPlan` 和 `ISceneRenderable`;通用 Controls 不再保存场景渲染状态。 -- 每个 Layer 先渲染为完整场景尺寸的 Skia 纹理,按 `order` 运行 Layer pass,再合成并运行唯一的 ScenePost pass;UI 保持在 Host 外。 -- `ITextureEffect` 是唯一像素效果契约。`mask.blinds` 为 SkSL shader,转场宏仅动画其 `progress` uniform;`layer.colorGrade` 与 `scene.colorGrade` 复用一份 SkSL 调色实现。 -- 缺失资源与 Flipbook 均通过 `SceneTexture` 入口解析;scene-only 截图复用同一管线输出。最终场景帧以 Avalonia custom draw 直接提交到其 Skia canvas,不经过逐帧 PNG 编码、解码或 `Avalonia Bitmap` 构造;未提供 Skia canvas 的后端跳过场景并记录一次诊断。 -- 桌面 GPU 上,Layer 合成、`mask.blinds` 与调色 pass 使用 `SKSurface` / `SKImage` 完成 GPU 纹理 ping-pong;静态资产保留稳定 image 身份以供 Skia 跨帧缓存。CPU `SKBitmap` 路径只用于截图、测试和无 GPU texture context 的降级,并记录一次诊断。 -- 转场是编译期宏,固定编译为可跳过的阻塞 `animation.play` 原语;动画计划以单一时钟采样所有 track,并以一次 UI 提交更新,最终的 Layer / Effect 清理完成后才允许后续 UI 或剧情指令执行。 -- `ISceneRenderable` 已暴露给 Host;粒子仍是临时 Avalonia overlay,明确不进入 ScenePost,等待 Phase 4 迁移。 - -验收:两种无副作用的测试 Shader 可串联到同一 Layer;一个测试 renderable 可与 Layer 按 order 合成;ScenePost 能改变合成场景而不影响 GameShell UI。 - -### Phase 3:首批 Shader 效果 - -- 已完成 `layer.colorGrade` / `scene.colorGrade`:亮度、饱和度、色相共享一份 shader;参数范围分别为 `[-1,1]`、`[-1,1]`、`[-180,180]`。 -- `layer.dissolve`:原图、mask、`progress`、边缘宽度和边缘色。 -- `layer.glow`:阈值、颜色、半径和强度。 -- `scene.vignette` 与 `scene.colorGrade`:验证全屏场景后处理。 -- `scene.flash`:作为 `ScenePost` 的最小验证效果。 - -验收:每种 effect 只有自身 factory/Shader/参数解析模块知晓其参数;动画计划可驱动 `progress`、`intensity` 等属性并在存档恢复后保持状态。 - - -### Phase 4:GPU 粒子与其他场景对象(已完成) - -- 已将 `particle.emitter` 替换为独立 `particle.play` / `particle.stop` 原语;`ParticleEmitter : IFrameUpdatableSceneRenderable` 不再创建 Avalonia `Control`。 -- 粒子 authoring 已按 Initial、Motion、Lifetime 模块拆分:出生范围覆盖 lifetime、velocity、size、rotation、angular velocity、color;运动支持 gravity、noise、drag、radial、orbit、attractor;生命周期线性插值 size、opacity、velocity、rotation、color。Point、Box、Circle、Line 共用 seed 确定性采样。`particle.play` 用 rate 创建可停止、可存档的持续 emitter;`particle.burst` 用 count 创建不进 Runtime 状态的一次性效果。持续时间和重复 burst 由流程或 animation plan 编排,不在 emitter 内维护另一套时间线。 -- 粒子 Renderer 支持 row-major sprite-sheet flipbook;每颗粒子按自身年龄以固定 FPS 或生命周期循环次数独立选帧,播放速度和起始帧均可使用 seed 驱动的范围,仍保持单次 Skia atlas batch。 -- 使用 Skia atlas 批量 sprite draw 绘制存活粒子;不为每颗粒子创建 Layer、Control 或独立 draw target。 -- Layer 与粒子统一实现 `ISceneRenderable`,按 `z` 与稳定插入顺序合成。Layer 先完成其局部 effect 链,粒子随后以同一场景画布坐标参与合成,最后统一进入 ScenePost。 - -验收:高数量粒子不创建 Avalonia 控件;粒子位于 `ScenePost` 之前且不影响 GameShell UI;停止、seed 驱动的确定性重启式存档恢复和资源释放有确定行为。后续 Initial / Motion / Lifetime / Renderer 模块的路线记录在 `features/F-20260929-02-particle-system-v2`。 - -### Phase 5:性能、降级与创作体验 - -- 限制可配置的渲染分辨率、effect 数量和纹理池预算;记录帧耗时与纹理分配诊断。 -- 无 Shader / GPU 后端时,明确每个 effect 的降级策略:近似控件实现、跳过并记录诊断,或阻止执行。 -- 在编辑器中提供 effect 排序、阶段选择、参数表单、资源选择和小型预览。 -- 为常用组合提供预设,避免作者手写 JSON。 - -已完成运行时部分:`SceneRenderBudget` 限制每帧 effect pass、ping-pong 中间像素和纹理字节预算;超限或 effect 执行失败时保持原输入并记录去重诊断。`SceneRenderDiagnostics` 记录帧耗时、pass 数、中间纹理峰值和 GPU/CPU 路径。每帧 effect 链只排序一次,并按 Layer target 预分组,避免每个 Layer 重复扫描完整 effect 列表。编辑器预览、参数面板和预设按当前范围暂缓。 - -验收:效果失败不会破坏场景渲染;资源不足或不支持时有可理解的编辑器与运行时诊断。 - -### Phase 6:注释驱动的通用 Shader Effect(进行中) - -目标是从“按 effect ID 发现 factory”过渡到“目标 + shader 资源 + 参数”的通用 attachment。`EffectInstance` 的 Runtime handle 继续存在,用于生命周期、动画和存档恢复;它是编译器/Runtime 的内部资源,内容作者不需要命名或操作它。 - -作者侧不通过 handle 查找已有 effect。effect 在其目标的声明作用域内创建,初始参数与参数动画一起声明;编译器将动画属性引用解析为内部 `EffectInstanceId + parameter`。Layer 被隐藏或替换时,其附属 effect 的清理同样由 Runtime 生命周期完成。需要整体变更效果链时,使用对目标 effect chain 的原子替换,而不是按 shader 名称查找“第一个实例”。 - -#### 资源格式 - -每个 effect program 资源以注释块声明平台无关 metadata。第一版约定仅解析下列固定语法,避免让 Core 解析或编译任何 shader 语言: - -```glsl -/* -@gal.effect v=1 -@input source -@targets layer,scenePost - -@param progress - uniform: uProgress - type: float - default: 0 - range: 0..1 - animatable: true - -@texture noise - uniform: uNoise - required: false -*/ -``` - -- `source` 是唯一强制输入纹理;Layer 当前帧与 ScenePost 合成图只是它的不同来源。 -- `@targets` 是资源的可用范围;attachment 的 target 决定实际 stage,不由 shader 运行时猜测。 -- `@param` 支持 `float`、`int`、`bool`、`color`、`vec2`、`vec4`、`enum`;`@texture` 声明额外资源槽。`default`、`range`、`step`、`animatable`、`required`、`options`、`displayName`、`group` 与 `tooltip` 是可选 metadata。 -- 注释 metadata 是编辑器和 Runtime 的参数协议;Avalonia Rendering 必须用 SkSL reflection 验证其中的 uniform/child 名称。当前 SkiaSharp 版本未公开 uniform 的声明类型反射,因此类型仍由 metadata 协议约束,通用 binder 落地时会按该协议严格绑定。 - -除 `uniform` 与 `type`(`@texture` 自动推断 `texture`)外,参数 metadata 都可省略。parser 在 Core 中补齐稳定默认值,显式写出的字段总是覆盖默认值: - -| 类型 | 缺省值 | 缺省 step | 缺省 animatable | -| --- | --- | --- | --- | -| `float` | `0` | `0.01` | 是 | -| `int` | `0` | `1` | 是(离散值) | -| `bool` | `false` | — | 否 | -| `color` | `#00000000` | — | 是 | -| `vec2` / `vec4` | 全零向量 | — | 是 | -| `texture` | 未绑定 | — | 否 | -| `enum` | `options` 的第一项 | — | 否 | - -不写 `range` 表示不执行范围钳制;不写 `required` 视为 `false`;不写 `displayName` 时使用参数名。默认值在 descriptor 中完成标准化,编辑器和所有渲染后端不得自行推断另一套默认规则。 - -`animatable` 只表示参数具备被动画驱动的能力,不表示当前 attachment 正在播放该参数的动画。Runtime 可以预先为可动画参数准备绑定,以支持 effect 创建之后才开始的动画轨道,但没有动画值时必须使用 attachment 的静态参数或 descriptor 默认值;每条动画轨道仍以一个 effect instance 的一个参数为最小绑定单位。 - -Core 中的 `EffectProgramResource` 只是稳定 asset reference,不包含文件路径语义、SkSL、`SKRuntimeEffect` 或 GPU 纹理。`ShaderEffectAttachment` 保存内部 Runtime handle、目标、program reference、order 和原始参数值;它可按 descriptor 枚举 program 本身以及静态 texture 参数作为预加载依赖。资源的文件读取、解码、SkSL 编译、reflection、纹理上传和缓存全部属于 Avalonia Rendering。 - -已完成:平台无关的注释 parser、descriptor、attachment 和 metadata resolver;Avalonia 的 program resolver 只从宿主配置的项目资源 source 读取 `.sksl`、缓存、编译,并验证 source/uniform/texture child 绑定名。不存在 `builtin/*` 或嵌入 shader 的运行时分支;找不到资源时保留输入纹理并只记录诊断。`.sksl` 已成为正式 `EffectProgram` 资源类型,`effect.apply.program` 与 `transition.blinds.maskProgram` 使用类型化资源引用。Sample 使用 `IAssetManager` 以 GUID 枚举并预编译 EffectProgram,resolver 暴露命中、未命中和总加载耗时统计,避免首次转场现场编译。发布/编辑器宿主的严格 GUID 校验仍见杂项待办。随后按以下顺序迁移: - -`effect.apply` 现可带 `program` resource locator;Runtime 仍只管理不向作者暴露的 `instanceId`,并将 locator 连同参数与动画值存档。Avalonia 根据 target 推导 `Layer` / `ScenePost`,从 program metadata 自动产生当前 catalog 所需参数 schema,再用同一套 `ShaderProgramTextureEffect` 绑定 scalar、enum、color、vec2 和 vec4 参数。旧 `id` 保留仅供尚未迁移的 overlay 粒子使用,不再是新 texture effect 的创作入口。 - -1. Avalonia Rendering 读取 effect resource、缓存编译结果并交叉校验 metadata。 -2. 用通用 texture pass 替代 `ITextureEffectFactory` 的按 ID 分支。 -3. 将百叶窗和调色迁为首批 `.sksl` 资源;百叶窗转场由其 `maskProgram` 参数引用项目资源,并只生成通用 attachment 与动画原语。 -4. 编辑器复用同一 metadata parser 动态生成参数面板。 -5. 已在 Sample 资源预热阶段批量编译 EffectProgram;静态 texture 参数的预解码与 child shader 绑定尚未完成。 - -一个 attachment 在作者模型中对应一个 shader program;渲染后端的内部契约不应排除未来将一个 program 扩展为多个 pass,例如 bloom 或双向模糊。动态性体现在动态创建 attachment、设置参数、绑定动画和切换已加载资源,而不是在游戏帧内传入任意 shader 文本并即时编译。 - -## 测试策略 - -- Core/Runtime:阶段、目标、排序、序列化、旧内容和快照恢复的纯逻辑测试。 -- 渲染后端:固定输入纹理的像素/快照测试,验证局部效果与唯一 `ScenePost` 的范围。 -- 动态 source:使用可预测时钟验证 Flipbook 取帧、循环、暂停和 effect 链输入。 -- 集成:Sample 中分别展示人物 dissolve、场景 LUT/暗角和场景闪白;对话框始终不受影响。 -- 性能:多 Layer、多 effect、长时 Flipbook 的纹理池复用与分配计数测试。 -- Shader metadata:合法资源、缺失 `@input source`、未知类型、重复参数、范围错误、非法 targets,以及 program/texture 资源依赖枚举。 - -## 非目标 - -- 不在 Core 或 Runtime 中引入 Avalonia、Skia、Shader 字节码或 GPU 资源。 -- 不把每个 effect 的参数写入 `SceneLayerHost` 或 ViewModel 的专用字段。 -- 在 Phase 2 之后不为粒子、几何裁剪等保留第二条 Avalonia 视觉分支;粒子迁为场景对象,几何裁剪改写为像素 effect 或渲染状态。 -- 不让内容作者以任意参数改变 UI 前后层级;阶段属于 effect 定义和受验证的实例数据。 diff --git a/docs/design/runtime-resource-ownership.md b/docs/design/runtime-resource-ownership.md deleted file mode 100644 index f7f0e61b..00000000 --- a/docs/design/runtime-resource-ownership.md +++ /dev/null @@ -1,25 +0,0 @@ -# Runtime 与宿主资源的职责边界 - -## 执行与通用规则 - -Runtime 拥有变量桥接、语言查询顺序、已读索引和故事执行;宿主选择存储、文本 provider 与平台后端。PlayerVariableService 依赖 IPlayerVariableStore,GameTextResolver 接收文化对应的查询委托。文件内容加载和存档 I/O 位于外层,Runtime 的 loader 只解析已读取的内容。 - -Engine 是执行门禁和运行 scope 的唯一所有者。内部 ActivePrimitiveRegistry 管理实例订阅、顺序和 batch;GameGraphNavigator 索引本次运行的稳定编译图。音频领域状态由 AudioRuntime 拥有,AudioBackendDispatcher 与它共享同步锁,负责后端命令顺序及清理等待。 - -## 媒体文件依赖 - -Core.Assets 声明 IAssetFileMaterializer 与 AssetFileLease;GalNet.Assets 提供从 AssetManager 获取字节并创建本地临时文件的实现。GameView 增加对 Assets 的直接引用,用于音频、视频和 Gallery adapter 的默认 materializer 构造。宿主可注入其他实现;官方 Sample 在资源 scope 中创建一个实例并共享给三个消费者。 - -这个依赖只存在于平台展示 adapter,Core/Runtime 保持与实现解耦。资产解码与缓存仍由 AssetManager 管理;播放器持有文件租约,最后一个消费者释放后才删除文件。materializer 关闭时拒绝新请求并取消待加载操作,已有租约继续有效。Gallery 缩略图拥有独立抽取与缓存生命周期。 - -## 恢复与关闭 - -展示抽象的 StateRestoreSession 拥有恢复产生的展示 continuation;等待取消后仍保留未清理实例。宿主先停止 Engine,再释放重放会话、执行 view 和平台后端,最后释放资源 scope。 - -已读进度的内存更新与磁盘提交分开。文件实现默认每 2 秒提交 dirty revision;存档和停止边界显式等待 flush。持久化失败保留 dirty 状态并重试,关闭失败向调用者传播。强制终止可能丢失上次成功提交后的进度;持续 I/O 失败时没有最大丢失时间保证。 - -当前契约和行为分别见 [architecture](../spec/architecture.md)、[assets](../spec/assets.md)、[runtime](../spec/runtime.md) 和 [i18n](../spec/i18n.md)。 - -## 默认宿主组合 - -`GameHost` 提供平台无关的命令协调、运行生命周期和玩家会话;`Avalonia.GameHost` 提供默认页面适配、纹理租约和展示组合。中立资源 scope 在文件基础设施中,不含渲染纹理。共享音频后端为 `Media.LibVlc`,视频画面由 Avalonia 实现。可替换工厂及职责图见 [当前架构](../spec/architecture.md)。 diff --git a/docs/development.md b/docs/development.md deleted file mode 100644 index 512b59c6..00000000 --- a/docs/development.md +++ /dev/null @@ -1,412 +0,0 @@ -# 二次开发手册 - -本手册面向“基于 GalNet 做自己的游戏/客户端”的开发者。默认原则是:**不修改 `src/Shared` 中的 Core、Runtime、Assets、Presentation.Abstractions 或 Builtins;在自己的外层程序集和组合根中扩展。** Shared 层是可升级的框架契约,而不是项目玩法代码的位置。 - -## 先决定扩展落点 - -| 需求 | 推荐落点 | 不应做的事 | -| --- | --- | --- | -| 自定义剧情指令 | 自己的 Entry Module | 往 Core 写某个项目专属 primitive | -| 自定义即时或异步玩法逻辑 | 自己的 `PrimitiveInstance` | 让 Engine 知道项目的指令名 | -| 高级作者指令 | 自己的 composite,编译为 primitive | 让 Runtime 直接执行 composite | -| 新页面、主题、窗口或媒体后端 | `MyGame.View` / 客户端宿主 | 把项目布局写入 `GalNet.Avalonia.GameView` | -| 新资源类型、decoder、Gallery 页面 | 外层注册和平台程序集 | 让 Core 引用 Avalonia/Skia | -| 项目存档位置、账户、平台服务 | 客户端宿主基础设施 | 修改 Runtime 的状态机 | - -推荐结构: - -```text -MyGame/ - MyGame.Gameplay/ 自定义 module、primitive、纯玩法服务 - MyGame.View/ Avalonia 页面、主题、presenter、renderer、decoder - MyGame.Client/ Composition root、窗口和平台服务 - MyGame.Editor/ 可选:编辑器扩展和同一 target profile -``` - -`MyGame.Gameplay` 可以依赖 Core、Runtime 所需的公开协议和 Presentation.Abstractions;`MyGame.View` 才依赖 Avalonia 或其他具体渲染技术。项目自有类型不要声明在 `GalNet.Core.*` 命名空间下。 - -## 配置玩家界面主题 - -引用可选 `GalNet.Avalonia.Themes`,在创建游戏 Scope 前配置布局和配色: - -```csharp -services.AddAvaloniaGameViewPages( - theme: new ClassicTheme(GamePalette.Ocean), - configureViews: views => views.Register()); -``` - -主题参数必需,普通页面没有默认回退;也可实现自己的 IGameLayoutTheme 而不引用内置 Themes。主题注册先于宿主覆盖;仅替换一个页面不需要另写主题。GameShell 自动加载对应玩家样式,其他玩家根用同一主题的 CreateStyles 加载。普通页保持 View + 共享 ViewModel;GamePage 通过 ControlTheme 安排稳定场景/对白/选项内容。当前配置在运行期固定。 - -三套布局、三套配色和模板扩展示例见 [Themes README](../src/Presentation/GalNet.Avalonia.Themes/README.md),契约见 [游戏页面规范](spec/control.md)。Sample 的 SampleCustomization.PlayerTheme 是实际启动配置入口。 - -## 创建一个自定义原语 - -先建立独立契约项目(仅依赖 Core,以及选用的 Contracts.Builtins),程序集名为 `GalNet.Project.Contracts`,公开 `GalNet.Project.Contracts.ProjectSchemas.Modules : IReadOnlyList` 静态属性。游戏引用该项目,另行注册执行模块;内容项目在 `Contracts/` 部署 DLL 及依赖,CLI/Editor 从这里加载同一集合。无需导出第二份手写 schema,也无需启动 Engine。模块名可重复,仅完整 typeId 冲突;部署和单平台截图示例见 [Sample.Contracts README](../src/Samples/GalNet.Sample.Contracts/README.md)。 - -可复用的值规则用 `GalNet.Core.Parameters.ValueDescriptor` 声明,Entry 参数通过 `new DynamicParameterDescriptor(name, valueDescriptor, isRequired, defaultValue)` 组合;CLI/设置等消费者也可调用 `ValueCodec` 和 `ValueValidation`,无需依赖 Entry 模型。描述允许传入 JSON 配置,构建时复制并冻结;JSON 名称和 converter 使用相同配置。必填/省略与默认值属于字段使用方,允许 null 单独指定。通用约束是值级规则,业务及跨字段校验继续手写;结构描述并非自定义代码的完整替代。公共工具与共享表单已实现,生成器尚未接入,不需要新增 Helper 项目。 - -Agent 可以直接写作者 JSON,再使用 `help --search --json`、`files`、`find`、`references`、`validate`、`graph/group validate` 和 `group compile` 检查。需要稳定单元操作时通过 `execute` 使用帮助中的 JSON schema。命令协议、项目/Group 生命周期和 dry-run 边界以 [CLI README](../src/Editor/GalNet.Editor.Headless/README.md) 为准;当前版本一次调用一个进程,不提供旧扁平命令兼容别名。 - -### 1. 选定稳定 type ID 与参数 - -type ID 使用点分隔稳定名称,例如 `mygame.reputation.add`。模块 ID 必须是小写且不含点,例如 `mygame`。参数 schema 是编辑器、编译器和运行时共用的唯一事实来源;参数使用 JSON 类型,不写 CLR 类型名。 - -```csharp -using System.Text.Json; -using GalNet.Core.Entry; -using GalNet.Core.Primitives; - -var reputationParameters = new DynamicParameterTable( -[ - new DynamicParameterDescriptor("faction", typeof(string), isRequired: true), - new DynamicParameterDescriptor( - "amount", typeof(int), - defaultValue: JsonSerializer.SerializeToElement(1)) -]); -``` - -`DynamicParameterDescriptor` 可声明 required、JSON 默认值与 JSON constraints。若主要目的是提供编辑器控件提示,也可用 `EntrySchema.DynamicParameters(...)` 由 `EntryParameterType` 构建 schema。 - -字段可直接手写并组合进 Entry,表单顺序取 `DynamicParameterTable.Fields`: - -```csharp -var amount = new FieldDescriptor("amount", - new ValueDescriptor(typeof(int), constraints: JsonSerializer.SerializeToElement(new { minimum = 0, maximum = 100 })), - defaultValue: JsonSerializer.SerializeToElement(1), - editor: new EditorHints(ValueEditorIds.Slider, Step: 1), labelKey: "Amount"); -var parameters = new DynamicParameterTable([new DynamicParameterDescriptor(amount)]); -``` - -编辑文本转换必须传完整 ValueDescriptor:`ValueCodec.FromEditorText(valueDescriptor, text)` / `ToEditorText(valueDescriptor, json)`,这样枚举 converter、约束及 JSON 配置不会丢失。真实 string 使用原始文本;其他类型(包括用 JSON string 表示的枚举)使用 JSON 文本。 - -设置的 `SettingItem.Field` 提供同一字段结构;数值设置的范围校验与字段描述共用 ValueContract,EditorHints 可显式指定自定义 ID。设置 DefaultValue 可由宿主动态修改,重置及有效性语义仍归设置,不复制进冻结字段;ActionSetting 没有值字段,ChoiceSetting 的动态选项与标签仍由宿主通知。现有 NumericEditorKind 仅供尚未迁移的主题消费,后续表单工厂使用 EditorId。 - -共享 Avalonia 表单使用宿主提供的 registry 和 binding;主题可在组合入口贡献工厂和样式: - -```csharp -using GalNet.Game.Controls.Forms; - -var registry = EditorRegistry.CreateDefault(); -registry.Register("game.nickname", context => -{ - var lease = DefaultFieldEditors.CreateText(context); - lease.Control.Classes.Add("game-nickname"); - return lease; -}); -var form = new SchemaForm -{ - Schema = parameters.Fields, - Registry = registry, - Binding = field => bindings[field.Id] // 宿主提供的 IFieldBinding 字典 -}; -``` - -在玩家/编辑器根加载 `avares://GalNet.Avalonia.Controls/Styles/SchemaForm.axaml`,或提供自己的表单模板。布局与视觉归主题,registry 实例和业务 binding 归宿主。binding 的 Changed 更新值/选项/可用性;CommitAsync 接收 FieldValue 与取消令牌,返回业务 ValueIssue 或 null。省略使用 default(FieldValue),显式 null 使用包含 JSON null 的 FieldValue。Text/Numeric/JSON 工厂保留草稿,未编辑的空/null/default 不自动写回;宿主需要强制提交或删除时显式调用 Context.CommitAsync。自定义工厂返回独立控件及释放回调,借用业务服务不由 lease 释放;详见 [表单契约](spec/control.md#schema-字段表单)。Editor 和 Sample 设置已经接入:Editor 的通用参数提交经 Entry.Parameters 进入 workspace 历史/保存,专属资源/表达式等保留原控件;Sample 的 SettingItem 提交继续经过 SettingsCoordinator 的异步应用与保存队列。IPlayerSettingsService.Editors 提供宿主 registry,三种主题复用同一个设置结构。 - -设置宿主创建一次 `EditorRegistry.CreateDefault()`,在 `IPlayerSettingsService.Editors` 返回它,并显式调用主题或项目的工厂扩展方法。新增普通设置直接复用 `SettingItem`;业务 read/apply/Validator 留在设置对象,不复制到工厂。动态选项继续调用 `ChoiceSetting.UpdateOptionsAsync`。仅改变外观时覆写主题模板/资源;改变控件时注册/替换工厂,例如: - -```csharp -// 项目或主题提供扩展方法,宿主在展示前显式调用。 -public static void RegisterProjectEditors(EditorRegistry registry) -{ - registry.Register("game.precise", DefaultFieldEditors.CreateNumeric); -} - -var editors = EditorRegistry.CreateDefault(); -RegisterProjectEditors(editors); -var wait = new IntSetting("wait", "Settings.Wait", 50, 0, 5000, 10, - () => currentWait, (value, token) => ApplyWaitAsync(value, token)) -{ Editor = new("game.precise") }; -``` - -自定义控件工厂通过 context 读取类型化值、提交以及订阅变化,卸载时由 lease 退订控件自己的事件;不要另建保存队列。Editor 的对应扩展入口是项目作用域 Group 面板的 FieldEditors。项目 schema 只写 EditorId,不引用平台控件/工厂。当前 Editor 的字符串文档无法无损表示显式 null 字符串,适配器拒绝这类提交;需要原始 JSON 时使用 CLI。缺少自定义工厂会显示诊断,不自动替换为文本控件。 - -### DTO 字段生成与成员绑定 - -可在契约项目通过 Analyzer ProjectReference 引用 `GalNet.SchemaGenerator`(`ReferenceOutputAssembly="false"`,`GlobalPropertiesToRemove="OutputPath;OutDir"`),Roslyn 不进入运行时依赖。DTO 声明示例: - -```csharp -[GenerateFieldSchema] -public partial class PulseOptions -{ - [Field(0, Required = true, AllowNull = false)] - public string? Faction { get; set; } - - [Field(1, Minimum = 0, EditorId = "numeric")] - public int Amount { get; set; } = 1; -} - -var entry = DefaultPrimitiveEntryBase.ForDto("game.pulse"); -``` - -生成 `PulseOptions.Schema` 以及 `IFieldDto` 的成员访问代码。ForDto 从同一 schema 建立参数表并记录 FieldDataType;不手写第二份参数描述,也不生成执行 module/保存逻辑。首轮支持顶层非泛型 partial class 上的公开可读写属性;private backing field、init-only 属性、非恒定默认表达式等报告 GNF001。字段顺序由 Field 的 order 明确指定,成员类型和常量属性初始化值来自 DTO;省略初始化值不等于声明 CLR 默认值为 schema 默认值。禁止执行任意 DTO 构造函数推断默认值。 - -可将共同的 Field 属性放在当前编译中的源基类,生成器按显式 Order 合并继承字段;默认值、JSON 名称和 converter 沿用成员声明,重复 Order/ID 或隐藏、重写标记成员给出 GNF001。内置颜色过渡的 12 个公共字段只在 ColorFieldOptionsBase 声明一次。 - -字段 ID 使用 JsonPropertyName,未指定时为 camelCase;支持属性 JsonConverter、JsonRequired。Field 还可声明 Required/AllowNull、范围/MaxLength/ValueFormat,以及 EditorId/Step/Format/UnitKey/标签和说明。C# nullable 注解不等于 JSON null/presence 策略;AllowNull 未指定时遵循现有 ValueDescriptor 的 CLR 类型规则。复杂业务校验继续传给 ForDto 的 validateArguments。 - -对象/数组等默认值可用 `[Field(0, DefaultJson = "{}", EditorId = "json")]` 或 `DefaultJson = "[1,2]"` 声明,属性不再重复写默认初始化值。JSON 默认在 schema 初始化时经 ValueCodec.ParseDefault 校验,执行时独立反序列化;DefaultJson 与属性初始化值同时声明会产生 GNF001。固定合法选项用 `Choices = new[] { "Left", "Right" }`,仍与 EditorId 分开。 - -保持旧字符串布尔/枚举内容格式时,可在 nullable bool/enum 属性上使用 `JsonConverter(typeof(StringBooleanValueConverter))` 或 `StringEnumValueConverter`;例如 `bool? ClearQueue = true` 对应 JSON `"true"`,显式 null 由 AllowNull 单独控制。转换器通过 IStringValueEncoding 声明字符串编辑表示和合法选项,Describe 仍输出字符串 wire schema。普通 JsonStringEnumConverter 维持既有 JSON 编辑文本规则;不能仅凭 JsonValueKind.String 去掉引号。带命名策略的枚举可以派生 StringEnumValueConverter,在构造函数显式选择 JsonNamingPolicy。 - -UI 不必继承固定基类。普通 DTO 使用 `DtoBinding.Refresh()` 显式刷新;实现 INotifyPropertyChanged 会自动通知,ObservableObject 是一种可选实现。CommunityToolkit 的 `[ObservableProperty] public partial int Amount { get; set; }` 可同时标记 Field,两个生成器读取同一属性声明;不要求生成器读取彼此产物,首轮不支持给私有 ObservableProperty backing field 标记 Field。 - -```csharp -using var binding = new DtoBinding(dto); -var form = new SchemaForm -{ - Schema = binding.Fields, - Binding = binding.Binding, - Registry = EditorRegistry.CreateDefault() -}; -``` - -SchemaForm 只需要字段列表和 IFieldBinding,不要求数据源是 DTO。DtoBinding 的普通提交经值校验后写成员;async apply/save 使用其宿主提交回调,由宿主更新数据并通知/刷新。绑定借用 DTO,表单拥有草稿/控件订阅;宿主在换对象时释放 DtoBinding。对象/数组继续用 JSON 编辑器,不生成递归面板。 - -Editor 对采用通用字段编辑器的 DTO 契约 Entry 使用整组 SchemaForm 和 EntryParameterFormBinding,直接读写作者参数字典,不实例化 DTO。省略与显式 null 保留在文档层;提交替换 Parameters,由既有 workspace 历史/保存链路观察。默认值仅用于展示,打开面板不写入文档;无效但可解析的 JSON 值仍可读取并修正。旧字符串作者文档无法无损表达的值(如显式 null 字符串)仍明确拒绝。含专属补全、多行文本、资源或表达式编辑提示的 DTO 使用已有逐字段控件投影,参数事实仍来自 DTO.Schema;手写契约继续支持。 - -完整指令业务校验与逐字段编辑可以分开:ForDto 的 validatePartialArguments 接收尚未完成、未补默认值的作者参数;不提供时沿用 validateArguments。依赖完整必备字段的校验可显式提供局部回调,字段自身类型/范围仍由 ValueValidation 验证,编译/执行始终调用完整校验。内置粒子、视频和动画计划采用此入口,避免必须同时填写多个字段才能提交第一个字段。 - -执行模块可选择强类型入口,与契约使用相同 DTO: - -```csharp -var module = new PrimitiveModule(registry => registry.Immediate( - "game.pulse", (context, options) => ApplyPulse(options.Faction!, options.Amount))); -``` - -CompositeGameView 构建时检查执行工厂的 DTO 类型与契约 FieldDataType 一致。Dispatch 先用契约规范化和校验 JSON,再由工厂使用每个字段的 ValueCodec 和生成成员访问填充独立 DTO;参数默认值和属性 converter 不另写一份。Context 继续提供 Runtime、BatchId 和 ScopeCancellation,业务 DTO 不需要 ObservableObject。未提供且无 schema 默认值的字段保持新 DTO 的成员值;需要判断 presence 时查询 context.Arguments,其中包含规范化后的默认值,并非原始作者输入。普通 JSON 工厂仍可用于手写/动态契约;Register 不支持直接把未经规范化的参数交给工厂执行。 - -Sample 的 ScreenshotOptions 同时作为契约定义和截图执行参数;Editor 只消费其 schema,不维护副本。见 [阶段证据](../features/F-20261013-02-parameter-and-sample-simplification/phase-2e.md)。 - -### 按执行语义选择最短路径 - -无参数同步 Entry 不需要空 DTO。声明空参数契约,并把行为注册到普通 module: - -```csharp -const string type = "game.mark"; -using var registration = new GameRegistration() - .RegisterSchema(new DefaultEntryModule("game", - [new DefaultPrimitiveEntryBase(type, DynamicParameterTable.Empty)])) - .RegisterImplementation(new PrimitiveModule(registry => - registry.Immediate(type, context => context.Runtime.SetVariable("seen", true)))); -using var view = registration.BuildView(); -var compiled = GalgroupCompiler.Compile(new GroupDocument -{ - Entries = [new() { Id = "mark", Type = type }] -}, registration.BuildCatalog()).Document.Entries.Single(); -view.Dispatch(new PrimitiveEntry(compiled.TypeId, compiled.Arguments, compiled.BatchId), new GameRuntime(null), default); -``` - -典型带参数同步 Entry 使用上面的 PulseOptions + ForDto + Immediate:只声明一次成员类型、默认值和约束。一个 PrimitiveModule 可以包含多条相关实现,也可共用同一 DTO,例如内置 audio.pause/resume/next/clear 共用 AudioTrackOptions 的 TrackId 默认值。无需为每条 Entry 新建实例类,也无需手动传递 BatchId。 - -Immediate / Immediate 创建非阻塞、不可跳过的同步实例,回调完成后逻辑完成;Normal 和 Quick 使用 dispatch,CatchUp 默认重复同一行为。需要不同重放策略时显式传入 catchUp 回调;纯副作用可传空回调,持久状态操作应在 catchUp 中保留必要状态更新。回调异常仍传播,工厂不负责 UI dispatch、自动取消或后台任务;不能用 async lambda 向 Action 回调隐藏异步工作。 - -复杂校验继续通过 ForDto 的 validateArguments 处理,例如 ScreenshotSchema.ValidateFileName 的安全文件名规则;值类型、长度、默认值等仍来自 ScreenshotOptions。异步或可跳过实例使用 Register 返回业务实例,显式传递 BatchId / ScopeCancellation: - -```csharp -var module = new PrimitiveModule(registry => registry.Register(BuiltinEntryIds.Wait, (context, options) => - new WaitPrimitiveInstance(TimeSpan.FromSeconds(options.Duration), context.BatchId, context.ScopeCancellation))); -``` - -这段是内置 flow.wait 的实际路径,等待、Quick/CatchUp 和 skip 保留原实例语义。截图仍使用 Register 返回自己的异步实例;Composite 仍通过 Compile 显式展开成 primitive,不生成业务、生命周期或持久化。完整 Composite 示例见下文。内置 Entry 不再需要一条指令一个作者 CLR 类;稳定 ID 位于 BuiltinEntryIds,参数事实从 catalog.DynamicParameters 或 DTO.Schema 取得,执行使用通用 PrimitiveEntry。 - -### 2. 分别定义契约和执行模块 - -schema 不引用游戏服务或平台对象。执行模块持有共享数据和服务,贡献完整指令 ID 与实例工厂: - -```csharp -// 契约项目只引用 Core,generator 作为构建期 Analyzer。 -using GalNet.Core.Entry; -using GalNet.Core.Parameters; - -[GenerateFieldSchema] -public partial class ReputationOptions -{ - [Field(0, Required = true, AllowNull = false)] - public string? Faction { get; set; } - [Field(1)] - public int Amount { get; set; } = 1; -} - -public sealed class MyGameSchemas() : EntryModuleBase("mygame", - [DefaultPrimitiveEntryBase.ForDto("mygame.reputation.add")]) { } -``` - -执行项目引用契约和展示执行抽象,借用自己的业务服务: - -```csharp -using GalNet.Presentation.Abstractions.Execution; - -public sealed class MyGameModule(IReputationService reputationService) : PrimitiveModule( - registry => registry.Immediate("mygame.reputation.add", - (_, options) => reputationService.Add(options.Faction!, options.Amount))) { } -``` - -IPrimitiveModule 通过 Register(PrimitiveRegistry) 在 BuildView 时贡献工厂;PrimitiveModule 接受注册回调。注册表直接保存完整 ID 到实例工厂,注册结束或失败后冻结,不创建逐指令 implementation 对象。复杂实例可以接收 Options 和 PrimitiveCreateContext;Options 只保存契约参数,context 和构造参数提供 runtime、取消、批次和 presenter。 - -不必逐条在组合根注册。一个领域模块可以贡献多个指令,并在实例间共享轨道、缓存或服务;也可用 `RegisterInstruction` 单独贡献指令。同前缀可以由多个模块贡献不同指令,只有完整 type ID 重复才冲突。schema 模块无资源生命周期;执行模块可覆写 `Dispose` / `DisposeAsync`。 - -单次异步业务可以使用 `PrimitiveRegistry.Async`,默认非阻塞、不可跳过;Quick 正常执行,CatchUp 默认重复执行,若该模式不应产生副作用,显式提供 catchUp 回调: - -```csharp -var module = new PrimitiveModule(registry => registry.Async("project.export", - (_, options, token) => exporter.ExportAsync(options.Name, token), - blocking: true, - catchUp: (_, _, _) => Task.CompletedTask)); -``` - -该工厂自动传递 BatchId/执行 scope token,每次调用独立解码 DTO;失败通过 PresentationCompletion 传播。需要实例状态或不同错误策略时,用 `Register` 返回继承 `AsyncPrimitiveInstance` 的实例:`OnStarting` 同步提交状态,`ExecuteAsync` 包含业务操作与 finally 清理;`HandleExecutionFailure` 默认返回 false 传播错误,只有明确处理错误时才返回 true。IsBlocking/IsSkippable、模式与 skip 钩子仍可重载,CatchUp 无异步操作时调用 `CompleteWithoutExecution`;若还需同步提交状态则调用 `DispatchWithoutExecution`。PresentationCompletion 从实例创建时保持同一任务,在操作和清理后发布结果/错误,随后发送 Completed 通知。 - -模块需要拥有后台操作时可组合 `PrimitiveTaskScope`:`Run(token => operation)` 只登记已运行任务,完成即移除;Dispose 启动取消,DisposeAsync 等待操作和清理、聚合保留的错误,重复释放等待同一任务。业务需要同时遵守 Engine scope 时仍应链接两个 token;scope 不拥有 presenter 或其他平台资源。释放时传播还是记录错误由模块明确选择。截图模块是实际示例。 - -Sample 的 Session、Runner、Presentation 与下属存档/资源 scope 按明确顺序尝试全部独立清理,最后聚合错误;某项 flush 或后端释放失败不会阻止其余资源释放。Session 重复异步关闭等待同一任务并保留失败结果。Engine 先于 view/backend 释放,页面/NVL 重置早于 presentation 释放;内部 SampleCleanup 只收集错误,不拥有资源或推断顺序。 - -多阶段交互、视频或动画可以直接继承 `PrimitiveInstance`,保留自身推进/资源规则。不要用未经观察异常的 `async void`。创建实例时原样传入 `context.BatchId`,`OnSkip` 必须幂等且只处理自己的资源。non-blocking 原语应先提交可存档状态,再启动展示。 - -文本显示后仍要等待玩家推进时,可以继承 Builtins 的 TextAdvancePrimitiveInstance,共用阶段/skip/模式机制,重载展示操作。下面是使用现有对白端口的完整实例;执行模块通过 Register 传入正文、BatchId 和 ScopeCancellation: - -```csharp -using GalNet.Presentation.Abstractions.View; -using GalNet.Primitives.Builtins; - -public sealed class NarrationInstance(IDialoguePresenter presenter, string text, - string? batchId, CancellationToken token) : TextAdvancePrimitiveInstance(batchId, token) -{ - protected override void OnTextStarting() => presenter.ShowDialogue(); - protected override Task PresentTextAsync(bool immediately, CancellationToken cancellationToken) => immediately - ? presenter.PresentTextImmediatelyAsync("", text, cancellationToken) - : presenter.PresentTextAsync("", text, cancellationToken); - protected override void SkipText() => presenter.SkipText(); - protected override void RevealAllText() => presenter.RevealAllText(); - protected override void RecordCatchUpText() - { - if (presenter is ICatchUpTextRecorder recorder) recorder.RecordDialogue("", text); - } -} -``` - -文本默认处理展示失败并结束这一行;HandlePresentationFailure 可重载诊断(NVL 记录警告,Dialogue 沿用原处理)。该类适用于“打字后等待推进”,不替代任意多阶段交互。PresentationCompletion 和逻辑完成是两个独立阶段,不能在正文显示结束时自行提前推进剧情。 - -### 3. 在游戏组合根独立注册 - -```csharp -using GalNet.Presentation.Abstractions.View; - -// 编译器只需契约,不构造执行服务。 -using var registration = new GameRegistration() - .RegisterSchema(new MyGameSchemas()) - .RegisterImplementation(new MyGameModule(reputationService)); -var profile = registration.BuildCatalog(); - -// BuildView 冻结注册并将执行模块所有权转移给 view。 -await using var view = registration.BuildView(); -``` - -编译期 target profile 和运行期 view 使用等价的 schema;执行模块只在运行期创建。宿主先停止/释放 Engine,再异步释放 view,最后释放它借用的 presenter 和平台资源。注册失败时释放 registration,避免遗留已创建的执行模块。 - -平台实现只需要提供 presenter/后端,不需要实现 Runner、跟踪 Engine continuation 或自行推断剧情结束。宿主调用 `AdvanceAsync()`,通过 `StateChanged` 接收统一运行状态,关闭时调用 `StopAsync()` 或异步释放 Engine。选择或阻塞实例自然完成后的推进与终态通知由 Engine 负责。页面切换前的快照恢复及 Builtins 展示重放仍由宿主编排。 - -### 4. 自由选择内置能力 - -```csharp -// 主入口:所有内置 schema 与实现,平台能力通过端口提供。 -await using var full = new GameRegistration() - .AddBuiltins(dialoguePresenter: dialogue, audioHost: audioHost) - .BuildView(); - -// 内置 schema + 部分内置逻辑 + 自己的实现。 -await using var custom = new GameRegistration() - .AddBuiltinSchemas() - .AddBuiltinVariables() - .RegisterImplementation(new MyPlatformModule()) - .BuildView(); -``` - -`AddBuiltinSchemas` 不创建播放器或运行期状态;`AddBuiltinImplementations(catalog, ...)` 只暴露传入 catalog 声明的内置 primitive,保留整个组件的共享状态和释放所有权;`AddBuiltinAudio`、`AddBuiltinAnimations`、`AddBuiltinVariables` 等按领域选择完整组件实现。也可以完全不使用内置契约。 - -作者契约位于独立的 `GalNet.Contracts.Builtins` 项目,只依赖 Core。`BuiltinSchemas.CreateAllModules()` 返回模块列表;可单独选择 `CreateDialogueModule()`、`CreateAudioModule()` 等组件,使用 `new TargetProfileEntryCatalog(modules)` 构造通用 catalog。游戏注册独立契约项目的 `ProjectSchemas.Modules` 后,将 `registration.BuildCatalog()` 传给内置实现选择入口;Editor preview 使用 `UseCatalog(projectCatalog)`,不能扩充项目的 schema 集合。参数类型、内置 Entry DTO 和 Composite 使用 `GalNet.Contracts.Builtins` 命名空间,执行模块仍使用 `GalNet.Primitives.Builtins`。 - -只注册 schema 的 view 可以构建,但不能执行尚未提供实现的指令。宿主启动前通过 `RequireInstructions(compiledTypes)` 检查实际编译内容的执行覆盖;`RequireExecutionCoverage()` 可要求整个契约集合都有实现。Engine 创建时也检查 Graph 引用的所有 primitive;缺失实现直接报错,不静默跳过。Editor 不会自动获得自定义平台执行模块。没有 presenter 的内置模块不等于具备平台播放能力:部分模块可只提交状态,对话等需要交互的能力仍要求对应 presenter。 - -音频用组合而非强制继承:`BuiltinAudioModule`/`AudioRuntime` 管理轨道与 voice,`IAudioPlaybackHost` 管理平台播放。Avalonia 可提供 LibVLC host,Unity 可提供原生音频 host;若轨道语义也需替换,只注册内置 schema 和自己的音频执行模块。 - -## 创建 composite(可选) - -Composite 用于改善作者体验,不是另一种 Runtime 指令。继承 `CompositeEntry`,在 `Compile(EntryCompileContext)` 中返回 primitive 序列,并通过 `DefaultCompositeEntryBase` 注册到模块的 `compositeEntries`。 - -组合中的子 Primitive 参数可以用 `ValueCodec.SerializeFields(childOptions)` 序列化,按子 DTO 的逐字段 codec 写入 wire JSON;JsonElement.Undefined 字段省略,默认保留显式 null,`omitNullValues: true` 可主动省略 null。编译规范化负责补充省略字段的 schema 默认值。控制用 batchId 仍通过 PrimitiveEntry.BatchId 传递,不能把它混入计划 payload。语音 Composite 使用 AudioPlayOptions + DialogueTextOptions/NvlAppendOptions,动画计划的开放事件参数仍显式写 JSON。 - -DTO 输入也可使用 `DefaultCompositeEntryBase.ForDto(typeId, (context, options) => primitives)`:与 primitive 共用 TOptions.Schema 和逐字段值 codec,源 JSON 的省略/null 在编译中保留,规范化/业务校验后交给类型化展开回调。回调继续显式设置输出 primitive 的 condition、batch 等语义;已有手写 Composite 仍支持。无原始 JSON 的 catalog.Create/作者 Values 路径按 schema 解析字段文本。该入口不创建运行时执行模块。 - -```csharp -public sealed class AlertCompositeEntry : CompositeEntry -{ - public override string Type => "mygame.alert"; - - public override IReadOnlyList Compile(EntryCompileContext context) => - [ - new PrimitiveEntry("mygame.ui.showAlert", - JsonSerializer.SerializeToElement(new { message = Values["message"] })) - { Condition = context.Condition } - ]; -} -``` - -展开得到的每一个 primitive 都必须已在 target profile 注册。Composite 本身不会写入 `.galgroup`,也不应该创建平台对象。 - -## 平台相关逻辑怎么写 - -有两种合规路径: - -1. **推荐:窄端口。** 在 `MyGame.Gameplay` 定义项目自己的小接口,例如 `IMyNotificationPresenter`;Avalonia/Unity/其他宿主在 `MyGame.View` 实现它。primitive 依赖接口,逻辑仍可 Headless 测试。 -2. **单平台项目:直接依赖。** 让模块闭包捕获 Avalonia 页面服务、Skia renderer 或媒体后端也可行,但该模块必须留在 `MyGame.View` 或客户端层。它将无法用于 Headless/其他前端,且读档后仍需由宿主按 `GameState` 中注册的稳定状态重建视觉结果。 - -无论哪种路径,都不能让 `GalNet.Core`、`GalNet.Runtime` 或 `GalNet.Editor.Shared` 反向依赖具体平台程序集。 - -## 扩展页面、资源和宿主 - -- 在 `MyGame.View` 使用 `AddAvaloniaGameViewPages(theme)` 的注册回调替换主题 View,或追加页面;运行期 registry 构建后不可修改。 -- 新 Gallery 类型通过 `IGalleryPageRegistry` 按精确 `typeId` 注册,不按资源类型推断 fallback。 -- 新资源类型要在组合期注册同一份 `IResourceTypeCatalog`;decoder 按 `(typeId, CLR 类型)` 注册到资产管理器。开发内容、导出器、安装内容和预览必须使用同一份冻结 catalog。 -- 平台媒体若需要文件路径,先通过 `IAssetManager` acquire 内容,再实体化到受控会话临时目录;不要扫描项目目录或 PAK。 -- 每个游戏会话使用独立的 Runtime、页面 scope 与模块实例;切换项目、重载或关闭时先停止 runner,再释放资源与 scope。 - -页面和资源边界的详细约束见[Avalonia 游戏页面](spec/control.md)、[资源](spec/assets.md)和[架构](spec/architecture.md)。 - -## 二次开发的验收清单 - -- 自定义 type ID 在目标 profile 中存在,且无重复。 -- Raw Group 可编译为只含 primitive 的 `.galgroup`。 -- Runtime 的 view 使用等价 schema,且 Graph 所需指令都有实现。 -- 参数默认值、必填项和 JSON 类型有测试。 -- blocking、skip、取消和完成事件分别有测试。 -- 任何影响长期画面的状态先写入可恢复的状态模型,再调用平台呈现。 -- 读档后验证状态重放,而不是只验证第一次播放。 -- 项目程序集没有让 Shared/Core/Runtime 依赖具体 UI、渲染或文件系统实现。 - -## Sample 项目组合入口 - -Avalonia 的 `src/Samples/GalNet.Sample.Avalonia/SampleCustomization.cs` 集中项目选择:`ConfigureServices` 选择主题、本地化启动和可选调试(`enableDebug`);`CreateFieldEditors` 创建每个设置作用域的默认/自定义字段工厂;`CreateContentCatalogs`、`ConfigureDecoders`、`SelectTexturePreloadsAsync` 和 `SelectEffectProgramsAsync` 选择资源/Gallery 类型、解码器和预加载策略;`ConfigureExecution` 选择项目契约并注册内置及自定义执行模块。业务工厂借用明确的平台对象,不通过服务定位器查找运行中的对象。 - -契约的唯一选择源仍是 `ProjectSchemas.Modules`;游戏的 Avalonia 和 Headless 经 `ProjectSchemas.CreateCatalog()` 构造 catalog,Editor/CLI 继续加载项目 DLL 的 Modules。只选择部分内置时修改 Modules,游戏不会自动追加全量内置 schema。Headless 不注册平台截图,运行含截图的剧情时明确报告缺少实现。 - -新增截图一类的业务时:在契约项目声明参数/schema 并加入 Modules,在平台业务文件实现 module,然后在 SampleCustomization.ConfigureExecution 注册。需要专属字段编辑器时在 CreateFieldEditors 注册工厂,需要新资源类型时在资源策略入口配置。App、Session、Runner 和关闭流程不需要认识新 module;BuildView 将执行模块所有权转交给 view,模块只释放自己的资源,借用的页面和媒体端口由 Presentation 在 view 之后释放。契约变化后重新部署 DLL 给工具使用。 - -默认运行框架位于 `GalNet.GameHost` 和 `GalNet.Avalonia.GameHost`。`SampleCustomization.CreateHostOptions()` 选择项目 catalog、指令实现和预加载策略;`AvaloniaGameSession` 直接提供新游戏、继续、存读档及关闭流程。Sample 的会话派生类仅适配启动参数和调试存档,不需要复制生命周期代码。 - -`GameSession` 通过 `IGameRun` 管理替换和命令互斥;`GameRun` 不自动注册内置模块。WPF/WinUI 可复用这一层,另行实现页面/Presenter 绑定。现有 Core 场景模型保持原位,通用宿主 API 不要求 Layer Presenter。 - -偏好读写与非法值回退由 `PlayerPreferences` 承担,首次启动规则由 `PlayerStartup` 承担;Avalonia 设置 UI 使用 `AvaloniaPlayerSettingsService`,可注入字段编辑器 registry,并通过 `IPlayerLocalization` 接入项目本地化。`AvaloniaGamePresentationOptions.CreateLayers/CreateAudioHost` 可替换默认纹理工厂与音频后端,工厂返回的新对象由展示会话负责释放。共享音频实现位于 `GalNet.Media.LibVlc`。默认 layer resolver 拥有 fallback,Gallery resolver 持有独立媒体租约,二者不能共用释放逻辑。 - - -## 持久状态与读档扩展 - -执行模块可以在同一个注册回调中调用 `registry.State(stateId, key, restore, validate, after)` 注册纯数据表。StateId 是稳定协议 ID,内存通过类型访问,不使用 CLR 名称序列化。恢复依赖可引用已声明状态表或恢复器 ID;组合时检查缺失、重复和循环。无需修改中央快照、Runtime 恢复分支或内置重放 switch。只需恢复行为而不新增状态表时使用 `registry.Restore`;跨表/项目配置校验通过 `registry.ValidateState` 登记。 - -状态通过 `context.Runtime.State.Table()` 读写,同步批量操作使用 `State.Update`。表对象与记录属于该运行代,不缓存一个会动态指向新 Runtime 状态的全局入口;不要保存 ticket、Task、CTS、平台句柄或玩家设置副本。不要在 Update 的同步边界内 await 或调用展示端口。 - -需要长期恢复的状态与一次 Entry 的 Options 分开定义。动画目标适配器直接读写该数据记录;场景索引只保存引用,不再维护第二份可存档镜像。复杂状态需要可序列化的中立值对象;通用 codec 在快照时捕获隔离载荷,不需要每类手写 Clone。 - -宿主用 `new StateRestoreSession(view)` 管理恢复资源。读档顺序为 `engine.RestoreFrom(snapshot)`,再 `await engine.SynchronizePresentationAsync(restorer.RestoreAsync)`,成功后继续推进。恢复处理器通过 `context.Start(instance)` 登记动画/视频 continuation,通过 `context.Own(cleanup)` 登记平台资源的异步清理,均应先登记再启动可能部分失败的操作。处理器恢复展示不重放变量修改或一次性事件。先停止 Engine 和恢复会话,再释放 view 与后端。 - -GameSnapshot v4 只接受新格式;更新示例后直接新建存档,不迁移旧文件。纯 schema 编译仍不需要创建执行/平台模块。直接构造 Runtime 做模块测试时,使用 view.StateCatalog 配置运行状态;Engine 构造会自动接入 view 的状态 catalog。 diff --git a/docs/engine.md b/docs/engine.md deleted file mode 100644 index 22272e19..00000000 --- a/docs/engine.md +++ /dev/null @@ -1,82 +0,0 @@ -# 引擎使用手册 - -本页说明 GalNet 已实现的引擎契约:内容如何进入 Runtime、剧情如何推进,以及存档和呈现如何协作。它面向使用引擎制作游戏的人;如果目标是给项目增加自己的原语、页面或平台能力,请阅读[二次开发手册](development.md)。 - -## 运行全景 - -```text -Graph + 已编译 .galgroup - -> GameEngine - -> IGameView.Dispatch - -> PrimitiveInstance - -> presenter / 场景渲染 -``` - -`GameEngine` 是剧情流程和活动 primitive 队列的唯一拥有者。玩家和 UI 只应调用 `AdvanceAsync()` 推进;Choice、条件、分支映射和节点跳转属于 Engine 内置控制流,不能实现为普通 primitive。 - -`IGameRuntime` 是运行事实的唯一来源,保存当前节点、条目位置、变量和 `GameState`。呈现端口负责显示,不反过来拥有剧情或可存档状态。 - -Engine 的 `StateChanged` 统一报告开始运行、完成、失败和停止,覆盖选择结果和阻塞实例自然完成;宿主无需在多个推进入口重复判断结束。关闭会话使用 `await engine.StopAsync()` 或异步释放,随后释放执行 view 与平台后端。停止是 Engine 终态,重开创建新实例。详细契约见[运行时参考](spec/runtime.md#状态通知和停止)。 - -## 内容项目与发布 - -开发项目的最小布局如下: - -```text -Project/ - settings.json - Graph/ - graph.json - groups/*.rawgalgroup - Assets/ - **/* - **/*.meta - I18n/ -``` - -- `graph.json` 是编辑图;Group 的编辑源是 `*.rawgalgroup`。 -- Raw Group 可以包含 primitive 和 composite。编译后产生的 `*.galgroup` 只能包含 primitive,Runtime 只加载这一种文件。 -- 原语参数始终是 JSON;内容和存档中不保存 CLR 类型名。 -- 资源引用应使用 `.meta` 的稳定 `id`(通常为 GUID),而不是资源路径。 - -导出结果是 `.galpak`:它包含 Graph、I18n、settings、生成的 `gallery.json` 和资源 PAK。安装后,特殊内容由内容 provider 读取,只有 `Assets/Paks/**/*.pak` 会成为资源 provider。完整格式见[文件格式](spec/file-formats.md)和[资源](spec/assets.md)。 - -## 条目、原语和编译 - -一条剧情动作称为 Entry。它有两种形式: - -- **primitive**:运行期直接执行的通用 envelope,字段为 `id`、`typeId`、`arguments`、`condition` 和可选 `batchId`。 -- **composite**:仅作者期存在的高级指令。编译时展开成有序 primitive 序列,不能直接进入 Runtime。 - -例如,一个转场 composite 可展开为显示图层、等待和隐藏图层;Runtime 只会收到展开后的三个 primitive。 - -每次 primitive 调用都会创建一个 `PrimitiveInstance`。它在创建后固定 `IsBlocking` 和 `BatchId`,并通过 `Dispatch()` 启动,通过一次 `TryComplete()` 结束。`IsSkippable` 可随执行阶段变化,`Skip()` 必须幂等。 - -`batchId` 是单次 Group 执行内的跳过分组:同 batch 的可跳过实例会一起收到 skip;空值表示独立分组。它不是全局 ID,也不跨两次进入同一 Group 生效。 - -## 推进、阻塞和存档 - -一次 `AdvanceAsync()` 会清理已完成实例、在稳定边界更新快照,并继续消费剧情,直到遇到新的 blocking primitive、Choice 或结尾。若调用开始时已有 blocking primitive,Engine 只跳过其所属 batch,不会把同一次输入延伸到之后才分发的动作。 - -稳定快照只在以下条件同时满足时更新:剧情游标已提交、没有未完成 blocking primitive、没有 pending Choice。因此: - -- non-blocking primitive 必须在 `Dispatch()` 返回前写入它的最终可存档逻辑状态; -- blocking primitive 的中间展示状态不会成为新的稳定存档; -- 读档不恢复 Task、CancellationToken、平台控件或活动 `PrimitiveInstance`;宿主通过 `StateRestoreSession` 按模块注册处理器恢复展示与音频。 - -持久条目应先在 `GameState.Update` 内写入状态,再调用 presenter。这样存档恢复、Headless 和平台前端能保持同一游戏事实。 - -## 默认能力与宿主 - -`GalNet.Primitives.Builtins` 提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与执行模块。`GameRegistration.AddBuiltins` 是主内置注册入口;也可只注册 `AddBuiltinSchemas`,再选择部分内置实现或全部自定义实现。媒体后端通过平台端口组合,不要求继承播放器基类;多个领域模块可共享指令前缀,但完整 ID 必须唯一。示例见[二次开发手册](development.md)。 - -官方 Avalonia 宿主 `GalNet.Avalonia.GameView` 提供 `GameShell`、导航、页面和默认 presenter。它不读取项目目录,也不决定媒体或存档实现;这些由游戏宿主在组合根提供。页面集成见[Avalonia 游戏页面](spec/control.md)。 - -## 推荐验证路径 - -1. 在编辑器或 `GalgroupCompiler` 中用目标 profile 编译 Raw Group。 -2. 用 Headless 宿主验证 primitive 的状态、条件、阻塞和存档语义。 -3. 用平台宿主验证 presenter、资源解码和恢复后的场景重放。 -4. 为同步、阻塞/跳过、取消和读档后重放分别写测试;不要只验证“画面曾出现”。 - -更多准确的逐项契约以[运行时参考](spec/runtime.md)、[条目类型](spec/entry-types.md)和[架构](spec/architecture.md)为准。 diff --git a/docs/glossary.md b/docs/glossary.md index b5b6aa13..74986ff2 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -38,8 +38,8 @@ | 图层 | `Layer` | 背景与立绘的统一资源图层,拥有 transform、z、显示模式和 opacity。 | | 场景句柄 | `handleId` | 作者内容中用于定位场上实例的稳定字符串,不是显示名称。 | | 动画请求 | `AnimationRequest` | 对单一可动画属性的一次 Replace(绝对值)或 Additive(相对增量)插值请求。 | -| 动画计划 | `AnimationPlanDefinition` | 多 track 时间线;当前作为单个 primitive instance 执行。 | -| 效果状态 | `ActiveEffectState` | 可存档的活跃效果描述,包含 effect id、program、instance id、目标、顺序和参数。 | +| 时间轴 | `TimelineDefinition` | 多 track 时间线;当前作为单个 primitive instance 执行。 | +| 效果实例 | `EffectInstance` | 可存档的活跃效果描述,包含 effect id、program、instance id、目标、顺序和参数。 | ## 页面与 UI diff --git a/docs/manual/custom-actions.md b/docs/manual/custom-actions.md new file mode 100644 index 00000000..eb3521cf --- /dev/null +++ b/docs/manual/custom-actions.md @@ -0,0 +1,73 @@ +# 用通用原语调用项目逻辑 + +`custom.invoke` 用两个字符串调用宿主注册的处理器。每个 action 不需要单独的参数 DTO 或项目专属 schema;项目 catalog 仍须包含内置 `BuiltinSchemas.CreateCustomActionsModule()`。`CreateAllModules()` 和 `AddBuiltinSchemas()` 已包含它。CLI、Editor 和 App 必须使用同一套契约,已有项目需重新构建并部署契约 DLL。 + +作者 `.rawgalgroup` 的条目示例: + +```json +{ + "id": "set-score", + "type": "custom.invoke", + "parameters": { + "action": "project.score", + "argument": "12" + } +} +``` + +`action` 必须是非空白字符串,按 `StringComparer.Ordinal` 精确匹配,不裁剪空白、不反射查找 CLR 方法。`argument` 必须显式提供字符串,可以为空;框架不解析、求值、本地化或替换其内容。项目可以自行使用 JSON、简单文本或其他语法。编辑器使用普通文本和多行文本字段,编译器只检查共享字段结构,不检查处理器是否注册。 + +## 注册同步处理器 + +```csharp +using GalNet.Presentation.Abstractions.View; +using GalNet.Primitives.Builtins; + +Action setScore = + (context, argument) => context.Runtime.SetVariable("score", + int.Parse(argument, System.Globalization.CultureInfo.InvariantCulture)); + +var actions = new BuiltinCustomActionModule() + .RegisterImmediate("project.score", setScore, catchUp: setScore) + .RegisterImmediate("project.notify", argument => ShowNotification(argument)); + +using var registration = new GameRegistration(); +var view = registration.AddBuiltins(customActions: actions).BuildView(); +``` + +上例 `ShowNotification` 是项目自己的方法。只需要参数时使用 `Action` 重载;需要变量、状态或当前条目时,使用 `Action` 重载。资源管理器、presenter 等宿主服务可以由委托捕获,所有权与释放仍由原宿主管理。 + +每次调用只选择一个委托: + +| 初始执行模式 | 调用行为 | +| --- | --- | +| Normal | `execute`,注册时必填 | +| Quick | `quick`;省略时调用 `execute` | +| CatchUp | `catchUp`;省略时不执行并立即完成 | + +`RegisterImmediate` 同步执行,非阻塞、不可跳过。CatchUp 默认空操作是此模块便利入口的策略,与通用 `PrimitiveRegistry.Immediate` 默认重做 dispatch 不同。需要追帧重建逻辑状态时必须提供 `catchUp`;一次性副作用通常保持空操作。 + +## 异步与完整实例 + +一次异步操作使用 `RegisterAsync`,委托类型为 `Func` 或带 `PrimitiveCreateContext` 的重载: + +```csharp +var actions = new BuiltinCustomActionModule() + .RegisterAsync("project.prepare", (argument, token) => PrepareResourceAsync(argument, token), + blocking: true, + quick: (argument, token) => PrepareResourceAsync(argument, token)); +``` + +`PrepareResourceAsync` 是宿主提供的方法。异步便利入口沿用上述模式选择,`blocking` 默认 false;委托的 token 联合游戏运行 scope 与模块生命周期,Task 必须包含资源清理的完成过程。Engine 等待阻塞实例完成后继续,未处理异常进入 Engine 的失败路径。模块通过 `PrimitiveTaskScope` 跟踪便利入口的异步操作;宿主停止 Engine 后应 `await view.DisposeAsync()`,取消并等待全部操作清理,未处理失败在异步释放时聚合传播。不要使用 `async void` 或把后台任务丢弃。 + +运行期间切换 Quick/CatchUp 不会重新调用另一套委托,也不会自动结束正在等待的操作。处理器应响应取消;需要在模式切换时加速、结束操作,或支持玩家跳过时,使用 `RegisterFactory(id, (context, argument) => new MyPrimitiveInstance(...))`。每次返回新实例并保留 `context.BatchId`,副作用放在实例 dispatch 中。完整实例自行管理 Normal/Quick/CatchUp、模式切换、跳过与完成语义,异步任务所有权和资源清理也由其项目模块/宿主管理;便利入口的默认模式策略与任务跟踪不覆盖它。 + +## 注册与错误 + +创建 view 时冻结 action 注册;重复 ID、空白 ID 和冻结后注册会报错。模块属于一个游戏 view,不能复用到另一个 view。`AddBuiltins(customActions: actions)` 或 `AddBuiltinImplementations(catalog, customActions: actions)` 会接收该模块所有权;部分组合可用 `AddBuiltinCustomActions(actions)`,不要再重复注册同一条执行指令。 + +未注册 action 在所有模式下记 warning,包含 action、Group 和条目身份,不包含 argument,然后作为已完成的非阻塞空操作继续。`new BuiltinCustomActionModule(strictMissingActions: true)` 可将这种情况升级为异常。已注册处理器或工厂抛出的异常始终正常传播。 + +注册表、委托、Task、服务和资源租约不进入存档。需要持久化的数据仍通过游戏变量或其他模块的 `PrimitiveRegistry.State/Restore/ValidateState` 声明与恢复,数据修改遵守 `GameState.Update` 边界。 + +Sample 的调试定位不会自动允许 `custom.invoke`:单个 wire ID 背后可以有任意行为。项目确认所有相关 action 的追帧语义后,才能在自己的调试策略中为该指令声明 Scene 或 Ignore;当前调试策略没有按 action 字符串单独授权的能力。 diff --git a/docs/manual/customize-avalonia-sample.md b/docs/manual/customize-avalonia-sample.md new file mode 100644 index 00000000..124aa2bb --- /dev/null +++ b/docs/manual/customize-avalonia-sample.md @@ -0,0 +1,56 @@ +# 定制 Avalonia Sample + +`GalNet.Sample.Avalonia` 是项目级组合根示例:保留启动参数、项目选择、本地化和调试策略;通用会话、页面与展示组合应优先复用 `Avalonia.GameHost`、`Avalonia.GameView` 和 Themes,而不是复制其生命周期代码。 + +外部 Editor 调试的启动配置、运行变量面板、路线定位和可选宿主接入见[对接外部游戏调试](external-game-debugging.md)。 + +只需用两个字符串调用项目逻辑时,可用内置 `custom.invoke`,在运行组合中注册 action,无需为每个 action 新建项目契约;见[通用 action 注册与模式语义](custom-actions.md)。 + +## 推荐改造顺序 + +1. **建立项目契约。** 复制或引用 `GalNet.Sample.Contracts`,在 `ProjectSchemas.Modules` 中列出项目允许的 `IEntryModule`。构建后用 `scripts/build-project-contracts.ps1` 部署到内容项目的 `Contracts/`。CLI/Editor 与玩家必须消费同一契约集合。 +2. **配置运行组合。** 在 Sample 的 `SampleCustomization` 中配置 catalog、执行模块、资源/玩家存储、音频 host 和项目启动策略。把项目专属 primitive 注册在这里;不要修改 `App`、Session 或关闭链来接入一条业务能力。 +3. **定制外观。** 通过 `AddAvaloniaGameViewPages` 选择 `ClassicTheme`、`MinimalTheme` 或 `PaperTheme`,也可在 `configureViews` 覆盖单个页面。运行期主题切换不受支持。 +4. **扩展项目功能。** schema/DTO 与 composite 放在契约项目;执行模块放在 Avalonia 或项目执行程序集。参数 DTO 用 `GenerateFieldSchema`/字段特性生成 descriptor,平台控件仅在页面/设置工厂中处理。 + +## 页面与主题边界 + +- `GamePage` 是稳定的游戏页面宿主;其模板必须保留 `PART_Scene`、`PART_Dialogue`、`PART_Nvl` 和 `PART_Choice` 四个 ContentPresenter。 +- 对话与 NVL 的内部部件分别需要 `PART_Typewriter`、`PART_Lines` 和 `PART_ScrollViewer`。不要在剧情播放中重建这些部件。 +- 主题页面复用共享 ViewModel 和命令;只替换布局/样式,不复制 presenter、媒体或游戏会话逻辑。 +- Palette 使用完整的语义资源字典。宿主若覆写资源,以玩家根的资源为准;不要跨多个根复用同一 `Styles` 实例。 + +## 上线前检查 + +- Project `Contracts/` 是与当前 GalNet API 匹配的可信构建产物;重新构建游戏或 CLI 不会自动更新该目录。 +- 自定义 entry 已在 CLI 校验/编译、Avalonia 运行和保存恢复路径中测试;Headless 缺少平台实现时应清晰报错。 +- 主题的普通页面映射完整,`GamePage` 布局可在目标分辨率和输入方式下工作。 +- 退出、切换项目和读档均由会话链路释放旧的 presentation、媒体与资源。 + +主题的可复制示例在[Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md);运行与页面的精确约束见[Avalonia 游戏页面规范](../spec/control.md)。 + +## 注册 DTO 参数控件 + +共享 UI 构建位于 GalNet.Avalonia.Controls 项目,命名空间为 GalNet.Game.Controls.Forms。Sample 的 SampleCustomization.CreateFieldEditors 是组合入口:先 CreateDefault,再 RegisterDto、RegisterMember 或 RegisterDefault;内置已有类型用 ReplaceDefault,最后 Freeze。每个工厂返回新的 FieldEditorLease,不能重用已挂载 Control。 + +~~~csharp +var editors = EditorRegistry.CreateDefault(); +editors.RegisterDto(context => +{ + var panel = new StackPanel(); + panel.Children.Add(new TextBlock { Text = "Custom settings" }); + panel.Children.Add(context.CreateDefaultForm()); + return new FieldEditorLease(panel); +}); +return editors.Freeze(); +~~~ + +MyOptions 代表项目自己的 schema DTO 类型;真实业务提交仍由宿主 binding 负责。只要替换一个字段,使用 RegisterMember(x => x.SomeProperty, factory),当前只支持直接的生成 DTO 属性,字段 JsonPropertyName 会自动解析。在根工厂中调用 CreateDefaultForm("已由自定义控件负责的字段ID") 可以保留剩余字段,排除参数使用 schema 的字段 ID。 + +多项 DTO 用各自 DtoBinding 或作者文档 binding 创建 DtoEditorItem,再传给 DtoPanel.ItemsSource;每项 Id 唯一,同一 Type 可出现多次。只展示一个 DTO 时也可使用同一分派逻辑的 DtoEditorHost。IFieldDto 的 CLR 对象仍由宿主管理,Editor 直接绑定作者 JSON;只为查找控件不需要创建完整 DTO。原子多字段 patch 必须提供 DtoEditorItem 的 commit 委托;普通单字段 DTO/设置绑定继续沿用现有提交路径。 + +普通生成 DTO 的嵌套对象和常用列表/一维数组会自动获得可折叠表单,不需要逐类型注册;内部字段继续使用相同 registry。展开创建子控件,折叠释放;未知类型、复杂 converter、过深或过长的结构使用明确 JSON 回退。专业时间轴、曲线等仍通过成员或整 DTO 覆盖接入,不为所有 JsonElement 注册同一个专属控件。 + +共享默认样式中的 FieldEditorActions 提供显式默认值/null/缺省/恢复草稿。缺省提交需要宿主保存 presence,普通 CLR DtoBinding 没有这种能力时返回 dto.presence,不能把删除字段等同于 CLR 赋 null;Sample 的设置应用/保存规则保持由原宿主管理。 + +具体生命周期、冻结、错误及当前支持范围见[Schema 字段表单](../spec/control.md#schema-字段表单)。 diff --git a/docs/manual/editor-authoring-workspace.md b/docs/manual/editor-authoring-workspace.md new file mode 100644 index 00000000..098fbcc4 --- /dev/null +++ b/docs/manual/editor-authoring-workspace.md @@ -0,0 +1,35 @@ +# Editor 创作工作区 + +## 编写与参数 + +Group 行内默认展示常用字段。单击选择条目后,Inspector 显示完整参数和执行条件;箭头独立控制行内展开。搜索框可以按正文、类型、条件或显示序号查找。锁定 Inspector 可持续查看指定条目;辅助面板不会清空它。 + +人物输入按姓名或 ID 搜索后,可直接按 Enter 采用高亮候选,也可用方向键进入列表选择。输入法组合期间的 Enter 保留给输入法。 + +未完成输入允许暂时离开或关闭面板,在同一项目中重新打开仍可继续。Escape 取消当前输入;Problems 可以定位或取消已经关闭面板的草稿。Save All 会尝试提交输入,冲突、无效值及输入法尚未完成的组合文字需要先处理。待处理输入不会跨应用重启保留。 + +## 检查与导航 + +点击状态栏的检查状态或从视图菜单打开 Problems,使用“检查当前内容”检查未保存的作者版本。检查结果与保存状态分开;检查不完整或内容后来改变时会明确提示。点击具体问题或变量引用,打开对应 Group/条目及参数;目标已经移除时不会跳到占用旧行号的另一个条目。 + +Graph 顶部可以搜索节点名称/ID、适配全图、定位选择和缩放;小地图按需开启。资源工具栏提供导入、新建、类型筛选和列表视图,搜索范围为整个项目。 + +## 布局 + +窗口菜单中手动选择剧情编写、动画编辑或游戏调试布局;工具打开不会替你切换预设。已有保存布局继续优先恢复。最大化面板可以再恢复到原布局。Timeline 左侧树与画布之间的分隔条可以拖动,底部可显示完整时长或定位播放头。 + +## 为项目 DTO 标记常用字段 + +```csharp +[GenerateFieldSchema] +public partial class MyOptions +{ + [Field(0), Common] + public string? Content { get; set; } + + [Field(1)] + public float AdvancedValue { get; set; } +} +``` + +Common 只影响行内展示;AdvancedValue 仍在 Inspector 编辑。按现有项目契约流程重建 DLL,使 Editor 和游戏加载相同契约。手写 schema 可设置 `new EditorHints(IsCommon: true)`;自定义 DTO 编辑工厂读取 `DtoEditorContext.DisplayMode`,默认表单会自动筛选。嵌套对象继续使用自身编辑器,不自动铺开所有成员。完整契约见 [共享表单](../spec/control.md) 和 [Group 编辑](../spec/editor-groups.md)。 diff --git a/docs/manual/external-game-debugging.md b/docs/manual/external-game-debugging.md new file mode 100644 index 00000000..21607251 --- /dev/null +++ b/docs/manual/external-game-debugging.md @@ -0,0 +1,72 @@ +# 对接外部游戏调试 + +调试由游戏开发者显式启用。`GalNet.Runtime` 只提供 Engine 的受控调试端口;可选 `GalNet.Debugging` 提供 loopback TCP、会话和默认 Engine 适配。游戏、Engine 和玩家数据仍由宿主拥有。 + +## Editor 操作 + +1. 在“项目设置”的调试启动配置中设置可执行文件、附加参数(每行一个)及可选工作目录,点击保存本地配置;也可以首次使用“测试 → 启动应用”时选文件。配置保存在项目 `.galnet/editor/debug-launch.json`。 +2. 启动前保存并正式校验/编译工程,产物位于 `Output/Debug`。Editor 先监听,再向 App 传入该目录、`--profile <项目/.galnet/debug-profile>` 和调试邀请参数。该 profile 与游戏原有玩家目录分离。 +3. App 连接后,若注册了开始回调,可点“开始游戏”;否则在 App 中自行开始。标题页连接和游戏运行绑定是两件事。 +4. 打开“游戏调试”面板,分别查看 Player/Save 当前实例变量。修改输入框后点击“应用”,刷新不会覆盖草稿。原值已改变时应用会拒绝;使用重置按钮取当前值后再编辑。变量提交不产生作者 Undo 或工程 dirty。 +5. Graph 的绿色运行标记独立于作者选择。右键 Group 跳到入口;选中 B/C/D/E 构成唯一完整直接后继链时优先使用该链,否则从有界候选中选路线。候选包含实际出口,条件不参与路线筛选。 +6. Group 页面显示活动作者条目。条目右键可定位到其编译展开范围开头,也可切换断点。定位后暂停;继续或单步时仅目标作者范围一次性豁免条件,之后恢复普通语义。单步保留正常阻塞/Skip 行为,不强制播放完不可跳过媒体。 + +“当前 Group 末尾”在出边前停止。与游戏根断开的 Group 可以显式选“以初始场景从此处测试”,缺少前置对象时仍会报告实际错误。 + +调试面板顶部使用分组图标工具栏,悬停可查看操作名称,灰显表示当前不可用;窄 Dock 会按组换行。启动配置默认折叠。保存、构建、作者映射和启动阶段会显示在状态栏;构建与作者映射在后台执行,避免大项目校验占住 Editor UI。变量按“当前值/修改为”显示,勾选图标应用草稿,回转箭头取游戏当前值重置草稿;值冲突会显示文字提示。连接指示、运行状态和错误位于底部,较长错误可滚动并复制。 + +关闭调试 Dock 保留连接和草稿;关闭项目会断开。停止只停止当前运行,App 可以再次开始。默认 Engine 适配在断线后取消旧请求、清除断点并解除暂停,App 继续由玩家控制。断开不会终止外部进程。 + +“等待应用”用于 IDE 自行启动:面板显示构建目录、隔离 profile 和 `--debug-port`、`--debug-token`、`--debug-session`、`--debug-project`、`--debug-contract` 参数。邀请只针对本次监听。 + +## 游戏接入 + +引用 `GalNet.Debugging`,在项目组合根创建服务。开始回调可省略,开始/停止回调中所需的 UI 调度由宿主负责。 + +```csharp +await using var host = new GameDebugHost(startGame: StartNewGameAsync); +await host.ConnectAsync(invitation, cancellationToken); + +// 在宿主创建本次 Engine 后绑定;停止与释放 Engine 仍走原有宿主链。 +using var adapter = new EngineDebugRun(engine, new GameDebugOptions +{ + Instructions = new Dictionary + { + ["dialogue.text"] = DebugReplayBehavior.Scene, + ["variable.set"] = DebugReplayBehavior.Ignore + } +}, stop: StopCurrentGameAsync); +using var binding = host.AttachRun(adapter); + +// 生命周期结束:先 await engine.StopAsync(),再释放 binding / adapter。 +``` + +上例白名单仅为接口示例,应列出实际支持的指令。Scene 表示模块支持 CatchUp 场景重建;Ignore 表示调试重放时跳过副作用;未声明的类型默认拒绝定位。不能把任意插件一律标为 Scene。参数和资源校验仍执行,不因忽略流程条件而放宽。 + +需要呈现恢复的 view 必须提供 `ICatchUpPresentation` 或 `GameDebugOptions.SynchronizePresentation`。回调不得重新等待 Engine 推进门。默认 Avalonia 宿主提供恢复协调,只需通过 `AvaloniaGameHostOptions.AttachDebugRun` 的可选中立回调绑定;其程序集不引用调试通信组件。Sample 的 `SampleExternalDebug` 展示完整接入,包括对白、场景指令、变量/Gallery/截图副作用策略。 + +自定义运行可以直接实现 `IDebugGameRun`,按 `Info.Capabilities` 报告可用方法;可覆写 `OnDisconnectedAsync` 定义断线行为。它不要求实现 IGameRun 或持有 Editor 文档。 + +## 状态与缓存语义 + +定位沿显式边路线无条件追帧,保留当前 Player/Save 值,跳过声明为 Ignore 的变量赋值、Gallery 解锁和其他持久副作用。它生成测试场景,不恢复真实玩家历史。普通 `SkipToGroupEndAsync` 默认仍执行原条件和逻辑写入,和调试定位有不同用途。 + +每 Group 最多一份最近自然入口的纯场景;向后恢复入口再重放,向前先收束当前活动呈现再重放余下内容。调试追帧不覆盖自然入口。默认 LRU 上限为 256 个 Group、64 MiB 序列化 payload,可通过 `GameDebugOptions` 调整或设为 0;单独保留初始运行场景。该预算不表示精确托管堆占用。普通读档清缓存并推进执行代次。热点前缀和固定场景预计算未启用。 + +Player 修改先同步当前 Runtime 与玩家变量服务,默认 Avalonia 宿主在停止/释放运行时刷新到隔离 profile;Save 修改保留在当前运行,普通保存仍由游戏触发。面板不把“已同步”解释为“已写存档槽”。 + +当前协议校验版本、随机会话邀请、项目身份和构建内容指纹;请求按 runId/执行代次拒绝过期运行,变量还检查 UID/类型/原值。内容指纹覆盖 Graph、I18n、settings 及存在的 Contracts 目录;另可在 DebugIdentity 中提供 ContractFingerprint。Editor 使用项目已加载契约 DLL 的 SHA256,并通过 `--debug-contract` 传入预期值;Sample 对自己实际加载的 `ProjectSchemas` 程序集计算 SHA256,不匹配即拒绝。自定义 App 必须同样提供实际契约身份,不应直接信任命令行中的预期值。契约指纹不等价于执行模块程序集的代码指纹。 + +CLI/Editor/App 应使用同一份项目契约构建产物,包括默认只含内置条目的工程;选择 Sample 时需先部署 Sample 对应的 Contracts。运行内容来自构建目录,不提供资源热更新;作者内容编辑后旧源码映射失效,需要重新启动测试。 + +在仓库根目录运行 `./scripts/export-sample-contracts.ps1`(可选 `-Configuration Release`),会先构建 Avalonia Sample,再将配套契约及依赖 DLL 导出到根目录 `output/Contracts`。该输出目录已被 Git 忽略。关闭 Editor 项目,将导出的 DLL 复制到项目 `Contracts` 目录,重新打开项目,并使用脚本打印的对应 App 路径启动测试。 + +仓库对项目契约及其 Core/Builtins 编译引用禁用提交相关的 InformationalVersion 后缀和 SourceLink,以免仅提交无关文件就改变 DLL 身份;PDB 和本地调试仍保留,App 自身仍带提交信息。可用 `./scripts/test-contract-reproducibility.ps1` 对两个 SourceRevisionId 做真实 Rebuild 检查。首次迁移此构建规则需重导出并部署一次旧契约;源码、引用 API、生成器、SDK、路径或编译配置变化仍可能改变严格二进制身份。仓库外自定义契约及依赖也需自行保证构建稳定,不能只依靠相同程序集版本号。 + +Editor 启动的 App 在 30 秒内未连接时,调试面板会报告连接超时;Editor 不会因此终止 App。调试请求 60 秒无响应时,面板会结束等待并提示操作可能仍在运行;先检查运行状态再重试。状态/检查轮询的单次请求上限为 10 秒。 + +每次本地测试会话会在测试 profile 的 `logs/` 下生成 `debug-launch-*.log`,记录项目保存/构建/作者映射耗时、进程 ID、连接和调试请求结果、退出码及 App 的 stdout/stderr;创建时会把完整文件路径写入内置 Log Panel 的 Game 频道及 Editor Console。App 输出也会转发到 Editor 的内置 Log Panel(Game 频道;stderr 显示为 Error)和 Editor 的 Serilog Console sink,因此从命令行启动 Editor 时也能在命令行看到。单份诊断日志最多 1 MiB,最多保留 20 份;启动元数据不记录调试 token 或配置参数值,stdout/stderr 中的调试 token 会被遮盖。错误信息中会附诊断文件路径。Sample 的运行日志仍写入同目录的 `game-YYYYMMDD.log`。反馈问题时请一并提供这两类日志;启动前的契约校验错误可能只出现在 `debug-launch` 日志,因为它发生在 Game 日志初始化之前。 + +Sample 的命令行/契约检查失败会输出异常并以非零码退出。断开调试只断开控制通道,不终止外部 App;Editor 会继续排空其标准输出/错误并记录到本次诊断日志,直到 App 退出。IDE 自行启动的进程仍由 IDE 管理。 + +当前状态同步为 150ms 全量轮询,有帧大小、并发请求和路线搜索上限;流程暂停不完整冻结媒体时钟。Group/Entry 定位、变量修改和生命周期已有真实 Sample 进程及挂载 UI 测试,视频/音频/效果的全部组合仍需项目自行验收。 diff --git a/docs/manual/game-localization.md b/docs/manual/game-localization.md new file mode 100644 index 00000000..52b3c9d5 --- /dev/null +++ b/docs/manual/game-localization.md @@ -0,0 +1,39 @@ +# 编辑游戏文本与翻译 + +在视图菜单打开“游戏本地化”。左侧选择角色表、任意 GalGroup 或分支选项,右侧按语言显示文本;开发语言始终在最左列。普通剧情表单和角色表仍直接填写开发语言,移动条目不会改变译文身份。 + +在语言代码框填写如 en-US、ja-JP、zh-Hans-CN,然后点击“添加语言”。新列初始缺译,不会复制原文。列头左右箭头调整其他语言的位置;开发语言不能用箭头移走。 + +单元格支持多行:Enter 换行,Ctrl+Enter 或移出焦点提交,Escape 放弃当前输入。缺译和空字符串不同;对空单元格明确按 Ctrl+Enter 会保存空字符串。修改原文会保留其他语言,并使已有原文关联的译文显示“待复核”;检查后重新提交译文即可关联当前原文。“复核状态未知”表示缺少关联证据。 + +分支选项或其他 `LocalizedText` 字段留空时会显示为空文本,不要求在本地化文件中建立空 key;只有非空 key 才参与缺译检查。 + +同分类内共享文本只显示一行,并提示引用数量;修改会影响共享位置。使用“定位引用”打开对应创作位置。表格横向滚动可查看更多语言,左侧分类和右侧文本行可独立纵向滚动。 + +需要更换项目开发语言时,先添加该语言,再在“开发语言”中选择它,点击“检查变更”。面板显示目标语言缺译数量;检查后点击“应用开发语言”。这不会自动翻译或复制文本,普通创作页随后使用新的开发语言。检查后文本发生变化时,需要重新检查。项目设置页面的开发语言仅显示当前值。 + +所有文本和语言操作支持项目撤销/重做,使用 Save All 保存。切换分类或关闭面板会保留未提交草稿;Save All 会尝试提交它们。遇到原文、译文、引用或 JSON 草稿冲突时,当前输入保留,请先检查现值;“放弃草稿”会清除面板中所有分类的待提交输入。项目关闭后不再接受旧面板写入。 + +选择交换语言,保留“全部分类”或取消以导出当前分类,再点击“导出 JSON”。默认输出带上下文和原文指纹的单语言模板;只修改 rows 中的 translation,保留项目身份、key、语言和原文列。行顺序可以调整。勾选“运行文本”会输出直接译文的平铺 JSON,缺译不输出。导出位置须在项目目录之外。 + +点击“导入预览”选择模板,也可导入平铺 key→string/null JSON。平铺文件使用面板所选语言;模板按自身目标语言预览。目标语言必须先添加,且不能是当前开发语言。预览显示当前原文、导入值与状态,勾选要应用的行;未知文本不能选,null 跳过,空字符串须同时选择该行并勾选“允许空字符串清空译文”。原文发生变化的译文会显示待复核,应用不覆盖原文。预览过期时重新打开文件,不能强行覆盖当前值。 + +点击“应用所选”把整批译文和复核状态纳入一次撤销/重做,随后 Save All。应用开始前可取消预览,应用期间控件暂时禁用。首次模板导出会持久分配项目交换 GUID,改目录名不影响匹配;后续文件输出失败时该身份仍保留,不会保存其他 dirty 文本。 + +发布前运行完整项目检查或构建。包内开发语言缺译、未知角色和损坏来源会阻止替换构建输出,外部覆盖包不能补足默认语言。仅切换开发语言时看到的缺译数量不能代替发布检查;自定义无法分析的范围会明确提示不完整。 + +扩展 DTO 可声明文本与角色语义: + +```csharp +[GenerateFieldSchema] +public partial class MyLineOptions +{ + [Field(0, AllowNull = true), CharacterReference] + public string? Speaker { get; set; } + + [Field(1, AllowNull = true, EditorId = "entry.multilinetext"), LocalizedText] + public string? Content { get; set; } +} +``` + +使用 GalNet.Core.Parameters 中的特性和项目 schema generator;注册生成的字段 schema 后,工具按开发语言编辑并保存 key。自定义运行指令仍需通过 ITextResolver 解析文本 key,角色名通过 catalog 的姓名文本 key 解析;不把任意字符串或 opaque JSON 自动当成本地化文本。作者数据及分析边界见 [游戏本地化规范](../spec/editor-game-localization.md)。 diff --git a/docs/manual/image-variants.md b/docs/manual/image-variants.md new file mode 100644 index 00000000..8ad1270a --- /dev/null +++ b/docs/manual/image-variants.md @@ -0,0 +1,56 @@ +# 创建和使用图片差分图集 + +图集把一组完整立绘或场景图片作为一个资源,实例记住各维度选择,再显示匹配的一张图;它不进行纹理打包或图片叠加。 + +## 创建与配置 + +1. 在 Assets 面板导入图片目录,例如 `smile_coat.png`、`smile_skirt.png`、`idle_coat.png`、`idle_skirt.png` 和 `weap.png`。使用普通导入操作,让每张图片获得资源 sidecar。 +2. 进入该目录,或选中目录,在菜单选择“从目录创建差分图集”。编辑器创建 `variants.variants` 和 sidecar;重名时使用新的文件名。默认图先按稳定顺序选择第一张。 +3. 选中描述文件,在属性区配置图片目录、默认图片和维度。目录相对描述文件,例如同目录为 `.`;允许在 Assets 内使用相对路径,不允许越界或链接。 +4. 自动维度默认是 `d1`、`d2` 等,对应文件名从左到右的位置。可把显示名改为“表情”“服装”;key 是剧情引用身份,label 只是显示文字。修改 key 或顺序后自行检查已有剧情引用,编辑器不会自动迁移。 +5. 点击“应用配置”形成一条可撤销的文件修改,或直接使用保存全部提交草稿并保存。关闭属性面板不会丢弃未应用的草稿。无效配置会阻止提交,诊断中的路径和代码可帮助定位。 + +同一目录中不能有同名主干的多种图片,例如 `idle_coat.png` 和 `idle_coat.jpg`。下划线之间不能有空段,default 必须精确匹配已有主干。不同图片的段数可以不同;显式维度必须覆盖最长名称。 + +原始描述示例: + +```json +{ + "version": 1, + "folder": ".", + "default": "idle_coat", + "dimensions": [ + { "key": "d1", "label": "表情" }, + { "key": "d2", "label": "服装" } + ] +} +``` + +## 预览与回退 + +在属性区选择 `smile` 和 `skirt`,得到 `smile_skirt`;把第 1 维切为 `weap`,没有 `weap_skirt` 时显示 `weap`,但请求中的 `skirt` 保留。再切回 `smile`,恢复 `smile_skirt`。 + +面板分别列出请求和实际图片,并展示每一步候选。匹配失败时,从后向前累计去掉这次没有明确选择的维度,仍按原位置精确匹配;全部失败则使用显式默认图。回退不会把请求覆盖为实际图的选择,也不会隐藏缺组合的值。 + +扫描、切换预览和语言切换不写作者文件。配置或图片变化后可重新扫描;失效的预览请求会显示诊断,点击“重置预览选择”才重新从默认图开始。 + +## 在剧情中使用 + +新增“显示差分图集”(`variants.show`),用资源选择器选择图集。初始选择字段列出全部维度;“使用默认选择”表示不覆盖该维度。变换、透明度、层级和显示模式沿用普通图层。 + +新增“切换图片差分”(`variants.select`),选择之前创建的图集对象,再选择维度和值。切换保持该实例的变换和连续属性动画。已知普通图层不会出现在图集对象候选中;分支导致来源不唯一时只提供保守对象候选,不猜测维度和值。已有未知值仍保留在手动输入中,需作者修正。 + +普通换图会清除对象的图集模式,隐藏会删除实例;再次显示图集重新初始化选择。Timeline 可以操作图集 Layer 的普通动画属性,但当前没有新增 Timeline 差分切换事件。 + +配置应用后,即使尚未保存到磁盘,剧情字段会使用已提交的项目工作版本。仅在属性面板输入、尚未应用的配置草稿只用于本面板预览;应用或保存全部后再影响剧情查询。刷新、换条目或关闭项目后,旧选项不能继续提交。 + +## 运行仓库示例 + +`GameTestCase` 的默认 intro 包含两个可直接运行的图集,拉取后需重新构建项目,旧 Output 不会自动更新: + +- `Assets/Variants/xy.variants`:五张 `xy_表情.png` 立绘和一张复用 wink 图片的 `solo.png`。选择 shy → 第一维 solo 时削减显示 solo,再切回 xy 恢复 shy;Reduced 对话处可存读档。 +- `Assets/Variants/classroom.variants`:两张原有 classroom 背景,按 `scene_mode_focus` 命名。选择 speech 时缺少 empty 组合,显示显式默认背景;再选择 teacher 显示讲台背景。Default 对话处可存读档。 + +隐藏再显示会恢复默认选择;后续 wink 切换发生在既有立绘循环动画期间。详细检查点、资源 ID 和构建命令见 [示例说明](../../GameTestCase/README.md#image-variants-walkthrough)。 + +标准导出包将图集编译定义与图片一起放入 PAK,运行不需要原始图片目录或作者 `.variants` 文件。自定义宿主需在创建执行模块前注册 decoder、准备图集 catalog,并传入 `AddBuiltins(imageVariants: ...)`。 diff --git a/docs/manual/new-presentation-layer.md b/docs/manual/new-presentation-layer.md new file mode 100644 index 00000000..0237d370 --- /dev/null +++ b/docs/manual/new-presentation-layer.md @@ -0,0 +1,44 @@ +# 接入新的表现层 + +本手册适用于 WPF、WinUI 或其他新的 UI 框架。目标是复用 Core、Runtime、GameHost、Assets 和 Storage,而不是移植 Avalonia 的 View 或渲染对象。 + +## 最小接入路径 + +1. 新建框架专属项目,引用 `GalNet.GameHost`、`GalNet.Presentation.Abstractions`、`GalNet.Runtime` 和所需的内容/存储项目;禁止让这些内层项目反向引用新框架。 +2. 以项目契约构建 `IEntryCatalog`,再通过 `GameRegistration` 注册对应的 `IPrimitiveModule`。只注册内容实际允许的 schema 和具有执行实现的 type ID。 +3. 实现 `IGameView`,或组合框架专属的 dialogue、choice、layer、animation、effect、particle、video presenter 后构建它。Presenter 将 Runtime 的中立请求映射为框架对象;不得把 UI 控件、Dispatcher 或媒体对象写入 `GameState`。 +4. 创建 `GameSession`/`GameRun` 的应用组合根。它负责开始、存档、替换运行和退出;停止时先释放 Engine,再异步释放 view、presenter、媒体后端及资源。 +5. 通过 `IAssetManager` 获取图片、音频和视频。若后端必须使用文件,使用 Asset 的 materializer/lease 产生临时实体文件,lease 存续期间才可使用。 + +## 必须保持的契约 + +- `GameEngine` 是唯一的剧情推进器;UI 不自行推进、跳过 batch 或推断 Engine 终态。 +- primitive 先在 `GameState.Update` 提交可存档事实,再通知 presenter。读档后的展示由注册的恢复处理器和 `StateRestoreSession` 重建。 +- `GameSnapshot` 当前版本为 4;不要序列化 UI、Task、取消源、播放器 identity 或媒体时间轴。 +- 音频平台实现通过 `IAudioPlaybackHost` 组合;同一逻辑轨道的队列和状态仍由 `AudioRuntime` 管理。 +- 新的自定义 primitive 需要同时提供 schema、执行注册、参数/恢复验证和覆盖其生命周期的测试。 + +## 接入图片差分 + +内置 layers 模块包含 `variants.show/select`。资源 scope 建立后,先注册 decoder 并异步准备目录,再构造执行 view: + +```csharp +using GalNet.Assets.Variants; + +assets.RegisterImageVariantsDecoder(); +var imageVariants = await assets.LoadImageVariantsCatalogAsync(cancellationToken); +using var registration = new GameRegistration().UseCatalog(projectCatalog); +var view = registration.AddBuiltinImplementations(projectCatalog, + layerPresenter: layers, imageVariants: imageVariants).BuildView(); +``` + +图集定义无需持有资源租约;sprite 图片仍按现有 presenter 生命周期管理。普通 `ILayerPresenter.ShowLayer/ReplaceLayer` 已足够,渲染器不解析文件名或维度。会话播放中目录不变,读档由同一目录进行同步内容预检;切换内容或项目时重新准备,不能把旧目录挂到新项目。默认 Avalonia 配置回调可从 `presentation.ImageVariants` 获取已准备目录,并传入 AddBuiltinImplementations。 + +## 验证清单 + +- 只含自定义 schema/执行的最小内容可编译和运行,缺失执行会明确失败。 +- 正常推进、Choice、阻塞实例自然完成、停止和重新打开不会并发推进或泄漏旧回调。 +- 存档/读档能重放 layer、effect、particle、循环动画及音频的稳定状态。 +- 资源、临时媒体文件、平台对象和 DI scope 均按失败路径释放。 + +详细模块契约请分别查看[运行时](../spec/runtime.md)、[条目类型](../spec/entry-types.md)、[资源](../spec/assets.md)和[架构](../spec/architecture.md)。 diff --git a/docs/philosophy.md b/docs/philosophy.md new file mode 100644 index 00000000..0d2d6f7b --- /dev/null +++ b/docs/philosophy.md @@ -0,0 +1,38 @@ +# 设计理念与架构概览 + +GalNet 将叙事运行、内容与资源、呈现和具体应用宿主分开。目标不是提供唯一的玩家界面,而是让同一份已编译内容可以由不同表现层与平台宿主可靠运行。 + +## 核心原则 + +- **内层不认识平台。** `Core`、`Runtime` 与内容契约不引用 Avalonia、WPF、WinUI、Skia、LibVLC 或文件路径。 +- **内容和执行分离。** schema 与 composite 属于契约;primitive 模块、页面与媒体后端在宿主组合。完整 entry type ID 是唯一的注册键。 +- **状态是可验证的事实。** 可持久化场景状态集中在注册式 `GameState`;任务、取消源、播放器身份和 UI 对象只在运行期存在。 +- **资源通过租约取得。** 上层代码以 `IAssetManager` 获取资源,不扫描安装目录或假定实体文件路径。 +- **组合根拥有策略和释放顺序。** Runtime 推进剧情,宿主负责应用会话、玩家偏好、平台资源和依赖注入;释放顺序始终从 Engine 到执行 view、presenter/后端及资源。 + +## 高层架构 + +```mermaid +flowchart TB + Content[Graph / .galgroup / 项目设置] --> Runtime[Runtime\n编译内容、Engine、GameState、存档] + Contracts[Contracts.Builtins\nSchema 与 Composite] --> Runtime + Runtime --> Ports[Presentation.Abstractions\nIGameView、Presenter、应用端口] + Core[Core\n领域模型与基础契约] --> Contracts + Core --> Runtime + Core --> Assets[Assets\nProvider、Archive、AssetManager] + Ports --> GameHost[GameHost\n会话、运行生命周期、玩家规则] + Runtime --> GameHost + Assets --> Storage[Storage.FileSystem\n项目、安装、存档、变量、进度] + GameHost --> AvaloniaHost[Avalonia.GameHost\n默认展示组合与平台适配] + Ports --> AvaloniaView[Avalonia.GameView / Rendering] + AvaloniaHost --> AvaloniaView + AvaloniaHost --> Media[Media.LibVlc] + Storage --> AvaloniaHost + AvaloniaHost --> Sample[Sample 或项目宿主] +``` + +这是依赖方向图,不是运行时创建顺序。具体程序集职责、生命周期和测试边界见[架构规范](spec/architecture.md)。 + +## 文档约定 + +`spec/` 只记录已经由代码和测试验证的事实;`manual/` 说明如何在既有边界内扩展。一次性 feature 的需求、设计取舍与验证历史保留在 `features/`,不作为当前 API 的权威来源。 diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 7e8a9767..ee8df62a 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -2,7 +2,7 @@ ## 分层 -`Core` 不依赖 UI、文件系统或 DI。Runtime 只依赖 Core 与呈现抽象;宿主组装文件系统、资源、媒体和页面实现。[ADR-0001](../adr/0001-runtime-storage-contract-ownership.md) 已完成落地,旧 `GalNet.Storage.Abstractions` 项目已删除。 +`Core` 不依赖 UI、文件系统或 DI。Runtime 只依赖 Core 与呈现抽象;宿主组装文件系统、资源、媒体和页面实现。旧 `GalNet.Storage.Abstractions` 项目已删除。 ```text Core ───────────────────── 领域模型、资源/Gallery registry、服务契约 @@ -19,7 +19,7 @@ Editor.Shared ──────────── 项目读写、命令、导 Editor / Samples ───────── 组合根与平台宿主 ``` -宿主分层决定见 [ADR-0006](../adr/0006-game-host-layering.md)。主要依赖方向如下(省略重复的 Core 引用): +主要依赖方向如下(省略重复的 Core 引用): ```mermaid flowchart TB @@ -64,7 +64,7 @@ src/ 物理分组用于表达查找和所有权,不改变程序集名称、根命名空间或依赖方向。新增生产项目应放入与其职责一致的分组,并同步 `GalNet.slnx`;测试项目统一位于 `test/.Tests`。 -程序集拥有自己的根命名空间:展示与执行注册端口位于 `GalNet.Presentation.Abstractions.*`,内置 schema、Entry DTO 与 Composite 位于 `GalNet.Contracts.Builtins`,primitive 和执行 module 位于 `GalNet.Primitives.Builtins`。契约项目只依赖 Core,执行模块按领域共享状态,可依赖 Runtime 的平台无关逻辑(如音频轨道);Runtime 不反向依赖 Builtins。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。注册与程序集拆分决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)、[ADR-0004](../adr/0004-project-entry-contract-assembly.md)。 +程序集拥有自己的根命名空间:展示与执行注册端口位于 `GalNet.Presentation.Abstractions.*`,内置 schema、Entry DTO 与 Composite 位于 `GalNet.Contracts.Builtins`,primitive 和执行 module 位于 `GalNet.Primitives.Builtins`。契约项目只依赖 Core,执行模块按领域共享状态,可依赖 Runtime 的平台无关逻辑(如音频轨道);Runtime 不反向依赖 Builtins。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。注册的精确规则见[条目类型](entry-types.md)。 资源层只向上暴露 `IAssetProvider`、`IArchive`、`IGameFile` 和 `IAssetManager`。provider 定位源文件;manager 负责 GUID 查询、decoder 分派、缓存和 `AssetHandle` 生命周期。Core、Runtime 与 Editor.Shared 不引用 Avalonia。 @@ -84,6 +84,8 @@ src/ ## 游戏运行 +可选 `GalNet.Debugging` 组件提供本机长度帧 JSON 连接、会话/内容身份校验、App 服务和借用的运行绑定。组件可以引用 Runtime,Runtime 不反向依赖通信。App 的调试连接与当前游戏运行独立,开始回调可省略;Editor/Sample 的默认交互接入仍按 feature 后续阶段推进。 + `GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。Engine 统一报告运行状态,负责停止、取消执行 scope 和等待内部 continuation 退出,不拥有执行 view 或平台后端。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `GameState`,再通知呈现端;读取快照后由宿主调用注册式 StateRestoreSession。 Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePresenters` 只创建并暴露这些 presenter,`CompositeGameView` 组合独立的 schema catalog 与执行模块,提供 Runtime 使用的 `IGameView`。`GameHost.GameSession` 串行化准备、开始、存档回调、运行替换与停止,拥有可替换的 `IGameRun`;默认 `GameRun` 组合 Engine 与展示恢复,不注册任何 Builtins,也不要求 Layer Presenter。`PlayerSession` 协调玩家持久化,`PlayerPreferences` 和 `PlayerStartup` 提供偏好与首次启动规则。文件实现通过 `GameResourceScope` 和 `FilePlayerSession` 组合。 @@ -137,7 +139,7 @@ Integration 测试只承载必须联合多个外层实现的场景;单一生 ## 维护规则 - 不让 Core、Runtime 或 Editor.Shared 反向引用具体 Avalonia 实现。 -- `GalNet.Architecture.Tests/ProjectDependencyTests` 检查内层与共享项目、测试项目的直接依赖 allowlist,禁止测试项目互相引用,并要求 `GalNet.Storage.Abstractions` 项目及引用保持不存在;依赖边界变化必须同步更新 ADR 或设计依据。 +- `GalNet.Architecture.Tests/ProjectDependencyTests` 检查内层与共享项目、测试项目的直接依赖 allowlist,禁止测试项目互相引用,并要求 `GalNet.Storage.Abstractions` 项目及引用保持不存在;依赖边界变化必须同步更新本页和受影响模块规范。 - 内容、保存、玩家变量、变量桥接和进度协议由 Runtime 拥有;资源访问协议由 Core.Assets 拥有;Editor 设置和退出协议由 Editor.Abstraction 拥有。 - `ISaveService` 只提供异步、可取消的 I/O API,保存请求统一使用 `SaveRequest` 携带 snapshot 与可选展示 metadata。 - Provider 不管理解码对象或引用计数;页面/媒体实现不理解目录与 PAK 布局。 @@ -150,4 +152,4 @@ Runtime 的 GraphLoader/GalgroupLoader/SaveManager 仅解析和序列化内容 展示执行抽象的 StateRestoreSession 拥有重放 continuation;宿主在执行 view 和平台 presenter 释放前异步释放该会话。Suspend 等待取消后仍保留旧实例,下一次重放或释放会继续等待真实展示清理。 -运行与宿主资源的依赖及生命周期依据见 [runtime-resource-ownership](../design/runtime-resource-ownership.md),包括 GameView 对 Assets 的媒体 adapter 默认实现依赖。 +运行与宿主资源的依赖及生命周期以本页的游戏运行段和[运行时规范](runtime.md)为准,包括 GameView 对 Assets 的媒体 adapter 默认实现依赖。 diff --git a/docs/spec/assets.md b/docs/spec/assets.md index b45668e5..78ab32db 100644 --- a/docs/spec/assets.md +++ b/docs/spec/assets.md @@ -16,13 +16,15 @@ ``` - `id` 是非空、全项目唯一的稳定资源 ID;推荐使用 GUID,但当前 codec 不限制其字符串形态。 -- `type` 是资源类型 ID;组合根通过冻结的 `IResourceTypeCatalog` 将其映射到具体 `AssetMeta` DTO。内置类型为 `sprite`、`audio`、`video`、`font`、`effect-program` 和 `data`。 +- `type` 是资源类型 ID;组合根通过冻结的 `IResourceTypeCatalog` 将其映射到具体 `AssetMeta` DTO。内置类型为 `sprite`、`image-variants`、`audio`、`video`、`font`、`effect-program` 和 `data`。 - `path` 相对 `Assets/`;provider 会以实际逻辑路径校正缺失或过时的值。 - 可选字段缺失时使用 DTO 默认值;未知字段保存在 extension data,供 Editor 重写时保留。未知 `type`、无效 JSON、缺失 GUID 或缺失源文件是加载错误,不能静默跳过。 - `gallery` 是 Gallery 的唯一作者真源。每项使用全局唯一正整数 ID;没有 `sortOrder` 或项目根可编辑 `gallery.json`。 资源类型只负责识别、保存、枚举和打包;它不注册 decoder、Editor inspector 或 Avalonia 页面。decoder 以 `(typeId, target CLR type)` 另行注册。 +Editor 将原始资源与 sidecar 作为一次项目文件编辑:移动保留 ID 并重定位 path,导入/复制生成新资源 ID,保留 Gallery 内容与 metadata 扩展;Gallery ID 与项目/同批内容冲突时为副本重新分配正整数 ID,type/title 保持原值;无法无损类型化的 metadata 拒绝重写。资源索引刷新只读,不负责补建 sidecar。GUI 操作支持项目历史撤销,CLI 共用事务而不保留 GUI 历史,见 [Editor 项目文件操作](editor-files.md)。 + ## 查询、解码与生命周期 ```text @@ -54,6 +56,36 @@ IAssetProvider / IArchive / IGameFile 数据块可使用 `none`、`deflate`、`gzip` 或 `brotli`;`PakBuilder` 默认选择 Brotli,并会跳过过小数据的压缩。旧 enum PAK 格式不受支持。 +## 图片差分资源 + +`image-variants` 的作者扩展名为 `.variants`,metadata DTO 为 `ImageVariantsAssetMeta`。它管理一组完整 sprite 图片,不表示纹理合并、Flipbook 或 CG Gallery。游戏显示/切换使用 [variants 指令](entry-types.md#图片差分指令);编辑器支持从目录创建、配置和差分预览,操作见[图集手册](../manual/image-variants.md)。 + +```json +{ + "version": 1, + "folder": "./alice", + "default": "idle_coat", + "dimensions": [ + { "key": "expression", "label": "表情" }, + { "key": "outfit", "label": "服装" } + ] +} +``` + +folder 相对描述文件,允许 `.` 和不越过 Assets 的 `..`,兼容两种目录分隔符。仅收集指定目录直接包含的 sprite,不递归其子目录;空目录或缺省默认图无效。图片文件主名按 `_` 划分有序维度,空段、主名跨扩展名重复及仅大小写不同的变体键均无效。dimensions 可省略以生成 d1/d2 等位置 key;选项及变体按 ordinal 稳定排序,维度和值本身采用精确比较,不做大小写或空格归一化。 + +`LocalFileProvider` 先读取资源文件和 sidecar 的同一目录索引,再通过 `LocalAssetCompiler` / Core 的纯定义构建器编译所有图集;不会补建 metadata。目标目录内的 sprite 必须已有正常资源 ID 和正确类型,缺图、缺 sidecar、坏 default、坏描述等拒绝该归档,而不是略过错误变体。目录树和祖先路径不允许经过链接/重解析点。扫描刷新、取消和销毁具有代次检查;`Invalidate()` 可在已知作者修改后显式使目录索引失效。已经返回的编译字节属于该次快照;持有中的解码句柄仍遵循一般 AssetManager 缓存语义,不承诺原地热更新。 + +编译后的 IGameFile 保留图集自身 ID、逻辑路径、类型和 metadata,payload 为 `kind="image-variants"`、version=1 的定义,包含维度 key/label、default 和各变体 key/sprite ID,不含 folder。解码后重新校验并建立不可变 `ImageVariantsDefinition`,选项从变体事实推导。`ImageVariantsCodec` 严格拒绝未知/重复 JSON 属性;作者描述不能当作发行 payload 解码。 + +`PakBuilder` 直接打包本地归档的编译结果及正常 sprite,PAK 格式仍为 2。导出和包内验证沿用调用方的同一 resource catalog;从 PAK 读取定义无需原始图片文件夹。`project.validate`(项目范围,包括 structure-only)与 build 校验该资源,业务错误报告 `variants.invalid`;原始目录索引刷新不写作者文件或 dirty 状态。 + +解码注册仍与类型注册分离。使用 Assets 中的 `RegisterImageVariantsDecoder()` 扩展方法注册 `(image-variants, ImageVariantsDefinition)`,默认 Avalonia 游戏宿主与项目级 EditorAssetManager 已接入;自定义宿主覆写 decoder 配置时需自行注册。编辑器资源刷新重建内部 manager 时重新注册该 decoder。 + +执行会话准备使用 `LoadImageVariantsCatalogAsync()` 一次异步加载图集定义,并检查全部变体引用都存在且为 sprite。返回的 Core `ImageVariantsCatalog` 只持有不可变定义,不持有资源句柄;加载失败/取消不会返回半份目录。资源 ID 使用与 AssetManager 相同的首尾空格处理和大小写无关查询,变体键与维度值仍精确比较。目录按会话注入内置 Layer 模块;播放中不扫描文件夹或异步加载定义,重新准备会话才采用新内容。默认 Avalonia 游戏呈现、Sample Headless 和 Editor Preview 已在注册执行模块前完成该准备。 + +项目资源文件事务在 Assets 改动发布前,使用投影后的文件/目录快照调用同一编译器预检。移动描述文件或图片目录会调整已知的相对 folder;复制完整目录时生成独立资源 ID 并将内部目录关系指向副本;这些改动与原操作一起撤销/重做。显式 metadata 的图集类型不因改扩展名而丢失。改名或删除若令 default 等定义失效,会在写盘前拒绝,不能靠文件路径变化静默迁移维度语义。显式导入包含图集的完整目录会在同一事务中为资源创建 sidecar。 + ## 后端媒体文件租约 `IAssetFileMaterializer` / `AssetFileLease` 位于 Core.Assets;Assets 的 `AssetFileMaterializer` 通过 AssetManager acquire 的字节实体化媒体。同一会话内资源 ID(忽略大小写)共享文件,最后一个租约释放才删除。关闭 materializer 取消待加载请求并拒绝新请求,已借用文件仍有效直到释放。Sample 将同一实例传给剧情音频、视频与 Gallery;视频先释放原生播放器再释放文件租约。Gallery 缩略图抽取和临时缓存由独立服务拥有。 diff --git a/docs/spec/control.md b/docs/spec/control.md index 88994862..6dfad421 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -104,23 +104,43 @@ Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细 ## Schema 字段表单 -`GalNet.Game.Controls.Forms.SchemaForm` 位于 GalNet.Avalonia.Controls。宿主传入有序 Schema(FieldDescriptor 列表)、Registry 和 Binding(字段到 IFieldBinding 的函数)。控件只创建、挂载和释放字段编辑器;不选择游戏主题,不持有保存/撤销服务。结构样式位于 `avares://GalNet.Avalonia.Controls/Styles/SchemaForm.axaml`,只定义标签、编辑器与诊断的结构,没有颜色、间距或皮肤资源;宿主显式加载或提供自己的 Template/Theme。 +`GalNet.Game.Controls.Forms.SchemaForm` 位于 GalNet.Avalonia.Controls。宿主传入有序 Schema(FieldDescriptor 列表)、Registry 和 Binding(字段到 IFieldBinding 的函数),可选 DtoType 启用特定 DTO 成员映射。控件只创建、挂载和释放字段编辑器;不选择游戏主题,不持有保存/撤销服务。结构样式位于 `avares://GalNet.Avalonia.Controls/Styles/SchemaForm.axaml`,只定义标签、编辑器与诊断的结构,没有颜色、间距或皮肤资源;宿主显式加载或提供自己的 Template/Theme。 -EditorRegistry 以开放 EditorId 映射到工厂,提供 Register/Replace/RegisterDefault/Resolve。显式 ID 精确匹配;省略 ID 时按合法选项、注册的值类型及 enum/JSON 默认规则选择。重复注册、未知 ID 和不支持类型均明确报错,不静默替换自定义 ID。默认集合由宿主调用 CreateDefault 创建;主题可贡献样式或工厂注册方法,由宿主显式组合,不拥有 registry/设置服务的生命周期。 +EditorRegistry 以开放 EditorId 映射到工厂,提供 Register/Replace/RegisterDefault/Resolve。显式 ID 精确匹配;省略 ID 时按合法选项、注册的值类型及 enum/生成 DTO/列表/JSON 默认规则选择。重复注册明确报错;未知 ID 或工厂以 NotSupportedException 声明不支持时,SchemaForm 保留 editor.fallback 诊断并为该字段提供明确 JSON 回退。其他工厂异常仍按创建失败清理,不伪装为成功。默认集合由宿主调用 CreateDefault 创建;主题可贡献样式或工厂注册方法,由宿主显式组合,不拥有 registry/设置服务的生命周期。 -默认 Text/Numeric/JSON 使用 TextBox 保存草稿,Enter/失焦时只提交有编辑的文本;Numeric 不经过 double/decimal 控件值转换,以保留大整数和未完成输入。Toggle/Choice/Slider 提交选定值,复用 Core 的 codec/校验和项目 JSON 配置。Slider 需要有限 min/max、正步长;decimal、不安全整数精度/CLR 范围或非整数整数滑块配置明确诊断。Step 仅决定滑块交互,不限制业务合法值。合法选项与标签分开;binding 的动态选项更新不会重建表单。 +默认 Text/Numeric 使用 TextBox 保存草稿,Enter/失焦时只提交有编辑的文本;JSON 使用带标识的多行 TextBox,Ctrl+Enter/失焦提交,按原始 JSON 表达字符串引号,Escape 恢复未提交草稿;Numeric 不经过 double/decimal 控件值转换,以保留大整数和未完成输入。Toggle/Choice/Slider 提交选定值,复用 Core 的 codec/校验和项目 JSON 配置。Slider 需要有限 min/max、正步长;decimal、不安全整数精度/CLR 范围或非整数整数滑块配置明确诊断。Step 仅决定滑块交互,不限制业务合法值。合法选项与标签分开;binding 的动态选项更新不会重建表单。 -FieldValue 以未提供的 Json 和显式 JSON null 区分省略/null。Context 保留草稿和诊断;默认值只用于未提供状态的展示,未编辑的默认/null/空状态失焦不会写回。宿主可通过 CommitAsync 显式提交省略或 null,默认结构未提供统一的删除/null 操作按钮。对象/数组以 JSON 文本编辑,不承诺任意嵌套控件。 +宿主可通过 `EditorRegistry.CreateDefault(emptyInputClearsOptionalFields: true)` 选择非字符串空文本恢复缺省的策略;必填无默认值仍拒绝,JSON 源码不套用此策略。FieldEditorContext.Registry 是借用的只读引用,自定义组合编辑器创建子 Context 时可以传递同一 registry,使输入策略和工厂组合一致。FieldEditorRow 提供可响应宽度的标签、编辑区、诊断和单重置布局;FieldEditorActions.Simplified 选择单重置行为,默认共享表单和 Sample 仍保留原动作集。 -IFieldBinding 是借用的宿主对象,提供 Read、Changed、CommitAsync、可用性/标签/可选选项。除 Changed 可从后台发出外,接口调用与工厂/Context 操作均在 UI 线程。binding 负责保存、撤销、异步应用和业务校验,必须捕获对应项目/服务作用域并遵守 CancellationToken;Context 只隔离过期 UI 结果,不回滚宿主已完成的业务写入。 +FieldValue 以未提供的 Json 和显式 JSON null 区分省略/null。Context 保留草稿和诊断;默认值只用于未提供状态的展示,未编辑的默认/null/空状态失焦不会写回。宿主可通过 CommitAsync 显式提交省略或 null;共享默认结构通过 FieldEditorActions 提供相应按钮,宿主自定义模板可自行组合。支持范围内对象/数组使用懒创建表单,其他类型明确使用 JSON;普通 CLR DtoBinding 没有 presence 时仍拒绝省略,不能静默写成 null。 + +IFieldBinding 是借用的宿主对象,提供 Read、Changed、CommitAsync、可用性/标签/可选选项,以及可选的中立 HostContext 宿主元数据和 CanReset 操作能力。DtoEditorContext 的作用域适配转发元数据与操作能力,FieldEditorContext 只读公开它们;导航仍需检查当前字段可用性,不通过元数据绕过绑定生命周期。CanReset 允许宿主禁止特定字段的通用重置,例如没有默认定义的 Timeline;不改变文件中的 presence 语义。除 Changed 可从后台发出外,接口调用与工厂/Context 操作均在 UI 线程。binding 负责保存、撤销、异步应用和业务校验,必须捕获对应项目/服务作用域并遵守 CancellationToken;Context 只隔离过期 UI 结果,不回滚宿主已完成的业务写入。 工厂返回独立、无父级的 FieldEditorLease;SchemaForm 在换 schema/registry/binding、卸载或创建失败时先取消所有 Context,再释放全部 lease,最后移除子控件;清理异常保留为 Diagnostics,不短路后续释放。控件不释放借用的 binding。工厂在返回 lease 前失败时,需自行清理尚未交出的临时资源。registry 注册更新后显式调用 RefreshEditors,普通值/选项/可用性通知不重建控件。现有 CustomSettingEditor 复用同一 FieldEditorLease,旧 SettingEditorLease 已移除。 -Editor 的通用数值、布尔、文本、选项与 JSON 参数通过 SchemaEntryParameterEditorItemViewModel 使用完整值契约,提交替换 Entry.Parameters,继续由 workspace 历史与保存链路观察。每个 Group 面板拥有独立 registry,EditorFieldEditors 仅扩展选项标签本地化,转换与提交复用默认工厂。多行文本、资源选择、说话人/变量补全及表达式仍使用现有专属控件。仅打开参数面板不会写入省略的默认值;字符串文档不能无损表示的值(如显式 null 字符串)明确拒绝,原始工具路径可处理。设置页和 Editor 各自提供表单模板,业务对象不进入项目 schema DLL。 +Editor 的通用数值、布尔、文本、选项与 JSON 参数通过 SchemaEntryParameterEditorItemViewModel 使用完整值契约,提交替换 Entry.Parameters,提交通过项目作者意图记录历史并由统一保存链路持久化。Group 面板使用项目 scope 的 registry(直接构造时可显式传入);EditorFieldEditors 组合选项标签本地化、Transform/颜色/Timeline 成员及类型工厂和场景目标控件。多行文本、资源选择、说话人/变量补全及表达式仍使用专属控件,其行布局与重置同样使用 FieldEditorRow。仅打开参数面板不会写入省略的默认值;生成表单直接保存作者 JSON,缺省与显式 null 可以无损往返。设置页和 Editor 各自提供表单模板,业务对象不进入项目 schema DLL。Editor 的具体操作策略见 [Group 属性编辑](editor-groups.md#属性行与常用专属编辑器)。 `DtoBinding` 从生成的 IFieldDto 取得字段列表和成员访问,提供同一 IFieldBinding 协议,不再为每种 DTO 定义字段 ViewModel。普通提交调用 ValueCodec/ValueValidation 后写成员;提供 commit 回调时由宿主承担提交与数据同步。INotifyPropertyChanged 自动刷新;无通知 DTO 显式 Refresh。字段订阅随 Context 的 Changed 订阅建立/移除,Dispose 释放自身订阅,不释放借用 DTO。普通 CLR 成员不被当作 presence 容器。 -Editor 中有 FieldDataType 的 Entry 用一个 SchemaForm 承载全部字段。EntryParameterFormBinding 直接读取参数字典并通过现有提交方法替换 Parameters,不创建或同步 DTO;外部修改、撤销/重做由 Parameters 变化通知刷新。省略与显式 null 保留在文档,默认值不在打开面板时写回。重新配置释放旧绑定,旧字段不能再提交。通用表单行模板在 Editor 面板内共享,专属资源/表达式等继续保留原路径。字段编辑校验当前候选及自定义跨字段规则,完整文档必填检查留在编译/执行阶段,以允许逐步修正不完整文档。 +Editor 中有 FieldDataType 且不需要旧专属字段路径的 Entry 通过 DtoEditorHost 承载参数区,未注册整 DTO 覆盖时使用 SchemaForm。EntryParameterFormBinding 直接读取参数字典并通过现有提交方法替换 Parameters,不创建或同步 DTO;外部修改、撤销/重做由 Parameters 变化通知刷新。省略与显式 null 保留在文档,默认值不在打开面板时写回。重新配置释放旧绑定,旧字段不能再提交。通用表单行模板在 Editor 面板内共享,专属资源/表达式等继续保留原路径。字段编辑校验当前候选及自定义跨字段规则,完整文档必填检查留在编译/执行阶段,以允许逐步修正不完整文档。 + +### DTO 控件构建 + +同一 EditorRegistry 另外提供类型化的三层注册:RegisterDto/ReplaceDto 替换整个参数区,RegisterMember(成员表达式, factory)/ReplaceMember 替换指定成员,RegisterDefault/ReplaceDefault 指定字段类型默认工厂。默认集合中已有的类型需要 ReplaceDefault,不允许重复 RegisterDefault。显式成员覆盖先于类型覆盖,类型覆盖先于内部 EditorId/default 适配;nullable 按基础类型选择工厂,原字段 codec、presence/null 语义保留。Freeze 后所有注册/替换返回错误;宿主在组合结束后显式冻结,未冻结的旧 registry 仍可按原方式显式刷新。 + +当前成员表达式只接受直接的生成 DTO 属性(有 Field 特性),用 JsonPropertyName 或与 generator 一致的 camelCase 取得 wire ID,不构造 DTO;嵌套路径、任意 CLR 属性、集合路径模板尚未实现。重复整 DTO/成员注册报错,替换必须明确调用 Replace。内部 EditorId 注册继续承接已有专属控件,不要求新的类型扩展作者使用字符串 ID。 + +DtoEditorItem 包含稳定 Id、可选 DtoType、冻结字段清单及借用 binding,可选传入原子 patch 提交委托。DtoEditorHost 对单项选择整 DTO 工厂或默认 SchemaForm;FormTheme 可为自动表单提供宿主皮肤。DtoPanel 将有序项列表映射到多个 Host,按项 Id 而非 Type 识别;相同类型多项可以并存,移动未替换的项不重建控件或清除草稿。ItemsSource 支持 UI 线程的集合通知,替换同一 Id 的项对象会重新建立上下文;重复/空项以 Diagnostics 拒绝本次刷新。DtoPanel 是顺序容器,不承诺长列表虚拟化;需要虚拟化或特殊选择/重排外壳的宿主可在自己的列表模板内使用同一 DtoEditorHost。 + +整 DTO 工厂获得 DtoEditorContext,可 Read 独立字段快照、CreateField、CreateDefaultForm(排除字段),或 CommitAsync 提交字段 patch。混合表单直接创建默认表单,不再次走整 DTO 分派;其他字段仍遵循成员/默认类型映射。Editor 的 patch 在一次 Parameters 替换中验证并提交,因此多字段修改只产生一条项目历史;控件不以完整 DTO 反序列化/再序列化替换作者数据。没有宿主原子回调时只允许单字段提交,多字段 patch 整体拒绝,不能逐字段提交并假装原子成功。 + +Context 的生命周期包装取消旧提交、保留动态选项和 IFieldPreviewBinding 的能力;卸载、换项或创建失败先取消上下文并释放自建字段,再卸载内容与释放控件 lease,清理错误进入 Diagnostics。事件仅由实际订阅者退订一次,不释放借用宿主 binding。宿主提交回调必须遵守取消令牌,框架不回滚回调已完成的写入;工厂在交出 lease 前仍须负责自身临时控件资源。 + +内置设置主题的普通设置改用 SettingItem.EditorItem/DtoEditorHost,继续提供原单字段 SchemaForm 的皮肤与设置应用/保存队列;Action/ICustomSetting 保留各自行为。Sample 的 CreateFieldEditors 组合结束后冻结 registry。Editor 的 Dock 创建 Group 面板时传入项目 registry;目前依赖旧资源/表达式等专属字段的 Entry 继续走原路径。P6b 补充普通嵌套结构表单;所有专属字段迁移、手势合并协议、状态查询与 timeline/曲线仍未包含。 + +StructuredFieldEditor 复用 Expander 主题(StyleKey 为 Expander,宿主样式使用 Expander.structured-field 类选择器),按展开创建、折叠释放子控件。支持有生成静态 Schema 的 DTO,以及一维数组、List/IList/IReadOnlyList/IEnumerable。嵌套对象继续通过 DtoEditorHost 支持整 DTO 覆盖,默认内部表单继续成员/类型注册;根据 JsonTypeInfo 映射实际 wire 名,不构造 DTO 来生成 UI。对象提交合并当前 JSON,仅替换编辑成员,保留未知成员。自定义 converter、无 schema 的对象、字典、八层深度或超过 200 元素使用 JSON;用户也可显式切换结构/JSON。 + +列表结构变化取消当前版本令牌,旧索引绑定和延迟提交失效;普通元素值编辑保留其余行控件。共享控件不实现保存或撤销,依旧提交一个根字段 JSON 值,由宿主负责原子修改及历史。FieldEditorActions 提供显式默认值、缺省、null 和草稿恢复,CanOmit/CanSetNull/HasDefault 决定入口,宿主用 StyledProperty 翻译提示;仅打开表单不写默认值。 ## 宿主接入约束 @@ -129,6 +149,14 @@ Editor 中有 FieldDataType 的 Entry 用一个 SchemaForm 承载全部字段。 - 共享页面可以被替换或覆写;最终客户端的窗口、主题、资源定位和平台服务不进入共享页面项目。 - 新增页面时,需要补充 ViewModel、View、组合注册和导航测试。 +## 资源与对象候选面板 + +共享 Forms 命名空间的 `ResourcePickerPanel` 接收不透明 ID、显示文本、详情、图标、可选缩略图与徽标候选,提供卡片搜索和键鼠选择事件,不解析资产/场景对象,也不保存数据。缩略图由宿主管理。Editor 的 Assets Picker 与场景目标选择控件复用这一组件,各自提供数据源及提交规则;项目状态查询保持在 Editor 服务中。 + +## Timeline 关键帧画布 + +共享 `GalNet.Avalonia.Controls` 的 `TimelineCanvas` 与 `TimelineCanvasModel` 提供整数帧标尺、播放头、可见行关键帧/折叠汇总绘制、多选/框选、水平拖动预览及缩放平移。模型通过委托请求开始、预览、提交、取消和加删键,通过事件请求定位;标尺拖动通过 DisplayFrame 显示本地预览,松手只请求一次定位,取消/失效恢复实际帧。控件不引用 Editor、作者 JSON、项目历史或游戏执行状态。宿主提供可见行、会话内键身份及版本失效,并协调相同高度的属性行。Editor 的无损修改与单历史规则见 [Timeline Dock](editor-groups.md#timeline-dock)。 + ## 剧情视频图层 `AvaloniaVideoPresenter` 是宿主注入的 `IVideoPresenter` 实现,Sample 在会话构造时将它挂入推荐视频模块。它通过 `IAssetManager` 获取 video 资源并实体化临时媒体文件,LibVLC 像素回调按实际视频尺寸发布 `SceneFrameSource`。视频帧经过现有 `SceneLayerItem` 和场景合成管线,参与 Layer effect 与 ScenePost,而不是独立的原生 VideoView。 @@ -136,3 +164,11 @@ Editor 中有 FieldDataType 的 Entry 用一个 SchemaForm 承载全部字段。 帧源租约保护渲染线程读取的纹理;被新帧替换或停止的源在安全时释放。UI 失效通知合并,播放完成/失败的 native callback 只发出信号,停止和释放播放器在回调线程之外完成。隐藏、替换、会话取消会停止对应视频,包括尚未完成的资源加载;异步会话销毁等待所有播放清理后释放 LibVLC。 当前实现使用 CPU 解码帧上传,帧尺寸上限 8192×8192;真实解码验证在 Windows LibVLC 后端完成。其他平台需提供可用的原生 LibVLC,缺失后端会诊断并安全完成剧情视频。Gallery 保持自己的媒体生命周期。 + +### 常用字段与宿主草稿 + +`[Common]` 是独立的 Core DTO 字段标记,经生成器进入 `EditorHints.IsCommon`(默认 false);手写 schema 可以直接设置该提示。它不改变必填、默认值、JSON 或运行时语义。`DtoEditorHost.DisplayMode` 默认 `All`;`Common` 仅筛选本层根字段,保持声明顺序及绑定可见性。复杂对象继续使用既有编辑器,不递归强制筛选;自定义 DTO 工厂通过 `DtoEditorContext.DisplayMode` 决定自身展示,默认表单自动遵循该模式。 + +`IFieldBinding.Draft` 可选返回宿主拥有的 `FieldDraftState`。控件上下文同步输入和诊断,挂载结束只解除订阅;宿主决定草稿的保存、冲突和生命周期。ScopedBinding 转发该状态,结构化对象/列表和 Transform 子编辑器通过子路径取得独立草稿。输入法组合保护随控件租约登记,项目保存不能越过组合输入。没有宿主草稿的设置/游戏表单沿用控件本地生命周期。 + +需要来源版本保护的宿主在内容变化时调用 `FieldDraftState.CheckSource()`;一旦观察到冲突,即使源值随后恢复相同内容也保留冲突,直到明确清除草稿。 diff --git a/docs/spec/dock-design.md b/docs/spec/dock-design.md index 1d3ffe3c..cff5c494 100644 --- a/docs/spec/dock-design.md +++ b/docs/spec/dock-design.md @@ -77,6 +77,7 @@ RootDock ("Root") │ └── DocumentDock ("LogDocuments") ← 底部区域 │ ├── Document ("Log") ← 日志面板 │ ├── Document ("Assets") ← 资源面板 + │ ├── Document ("Timeline") ← 当前 Entry 的 Timeline │ ├── Document ("UiCustomization") ← UI 定制面板 │ └── ... (其他底部面板) ├── ProportionalDockSplitter @@ -134,11 +135,14 @@ registry.RegisterDockPanel(new DelegateDockPanelContribution( | `NodeGraph` | MainDocument | 是 | 是 | 节点图编辑器 | | `Assets` | BottomDocument | 是 | 是 | 资源管理 | | `Log` | BottomDocument | 是 | 是 | 日志面板 | +| `Timeline` | BottomDocument | 是 | 是 | 跟随活动 Entry,场景属性树、轨道创建与当前帧数值编辑 | | `GroupEditor` | MainDocument | 否 | 否 | 组编辑器(按需打开) | | `Inspector` | InspectorDocument | 否 | 是 | 属性检查器 | Editor 工作区重构期间暂不注册 `GamePreview`,旧布局中的该项会作为不可用面板移除;当前也没有 `UiCustomization` contribution。`EditorDockFactory` 属于项目 scope。Group contribution 接收稳定 Group ID 字符串,项目文档及共享投影不由 Dock 页面拥有;关闭页面释放其订阅,重开继续使用相同项目内容。 +Timeline 使用项目 scope 的 `EntrySelectionContext` 与 `TimelineEditSession`;页面只发布稳定选择,面板不持有 Group 页字段 lease。激活 Group 页恢复其活动选择,工具面板获焦不改变来源。关闭 Timeline 释放面板并取消未确认输入,项目会话与内容历史保留;重新打开使用同一个项目会话。当前编辑行为与后续画布范围见 [Group 编辑](editor-groups.md#timeline-dock)。 + --- ## 5. 内容解析机制 @@ -302,3 +306,11 @@ services.AddTransient(); - [Dock.Avalonia GitHub](https://github.com/wieslawsoltes/Dock) - [Document and Tool Content Guide](https://wieslawsoltes.github.io/Dock/articles/dock-content-guide.html) - [Dock MVVM Guide](https://wieslawsoltes.github.io/Dock/articles/dock-mvvm.html) + +## 创作工作区预设 + +窗口菜单提供手动应用的剧情、动画和调试布局。剧情预设主区/Inspector 为 72:28,底部约 22% 激活资源;动画隐藏 Inspector、底部约 45% 激活 Timeline;调试底部约 40% 激活外部调试,与日志共组。打开工具只激活面板,不自动应用预设;原保存布局优先恢复。新工作区默认剧情布局,Timeline 按需打开。 + +预设调整现有 Dock,不销毁打开的文档、作者选择或项目草稿。最大化当前面板可恢复操作前结构;临时最大化状态不覆盖自动保存的布局。Timeline 属性树初始宽 320 DIP,可通过分隔条调整;显示完整时长、定位播放头及缩放读数只改变视口。 + +Editor 图标按钮默认透明、32 DIP 点击区域,保留键盘焦点、禁用及切换反馈。按钮、背景与文字使用 Editor 调色板;普通 Button、ToggleButton、输入与菜单的 hover 直接使用 Ursa/Semi 原生模板行为。Editor 不再向全局 `Border`、控件模板或图标按钮注入自定义画刷过渡,以免 entry 卡片等非按钮容器参与动画。游戏作者数据和其他宿主不依赖这些 Editor 外观。 diff --git a/docs/spec/editor-files.md b/docs/spec/editor-files.md new file mode 100644 index 00000000..740d9dc4 --- /dev/null +++ b/docs/spec/editor-files.md @@ -0,0 +1,70 @@ +# Editor 项目文件操作 + +GUI 的文件操作进入项目统一内容历史,每次完整意图占一条记录。`ProjectFileAuthoringService` 接收创建文件/目录、导入、复制、移动、改名、删除和资源 metadata 修改请求;改名是指定完整目标路径的移动。项目文件面板和资源 Inspector 使用该入口,普通文本页共用项目作者状态、历史和保存事务。 + +## 文件面板 + +左侧树显示目录层级,右侧网格显示当前目录的文件与子目录。父项展开并生成子目录行时,先检查这些目录是否含有可见子目录;空目录、只有文件或只有隐藏/链接子目录的项不显示展开三角,刷新后重新判断。树选择、双击目录、地址栏内的目录块和项目相对路径导航使用同一当前目录;后退/前进与返回上一级分开。目录展开、选择、筛选和缩略图大小只影响视图,不产生内容历史。刷新保留未改变的网格项,避免监听通知打断选择或改名。 + +原生列表支持 Ctrl/Shift 多选;Enter 打开,F2 改名,Ctrl+C/X/V 使用项目内部剪贴板,Ctrl+D 创建副本,Ctrl+A 全选,Ctrl+F 定位全局搜索,Backspace 返回父目录,Alt+左右键前后导航。文本输入控件先处理自己的快捷键。右上搜索在整个可浏览项目中按文件名连续子串匹配,忽略大小写;不解析 `t:`。非空时显示圆形 × 按钮,清空搜索或点击该按钮时,有选中结果则导航到首个选中结果的父目录并保留选择,否则回到开始搜索的目录。类型筛选在查询接口中保留,目前没有类型或范围控件。当前不包含收藏夹、保存搜索或 Unity 资源导入管线。 + +可从文件网格多选后拖到网格中的目录、左侧目录树、地址栏中的目录块或当前目录空白处;Ctrl 拖放复制,普通拖放移动。原生外部文件/目录拖入或文件选择器导入使用复制。目标占用、跨类别和受保护入口由统一事务拒绝,不覆盖原内容;复制/导入碰撞时为副本生成不重复的名称。资源与 sidecar 组合显示,`.galnet`、版本控制、IDE、bin/obj 隐藏;Output/Temp 默认隐藏,但可通过相对目录路径查看。 + +地址栏默认显示可点击目录块,点击空白切换路径输入,Enter 导航,Escape 或失焦返回目录块。导入和创建通过右键菜单进入,“新建”二级菜单包含文件夹与文本;刷新位于底部缩略图滑条右侧。网格 tooltip 显示完整文件名。 + +删除先显示所选路径、关联 Group 和引用候选;确认后才执行。关联 Group 删除和候选字符串放行各自需要显式勾选;确定引用始终受保护。固定路径仍可浏览,并显示禁用操作原因。受 Graph 管理的 Group 打开专属页面,settings/graph 固定入口打开对应编辑界面;其他文件进入通用文件页。 + +## 普通文件页 + +项目拥有稳定文件 ID 和缓冲,Dock 页面只持有视图。关闭标签不会丢弃未保存文本,再次打开复用缓冲;移动/改名更新同一缓冲的路径与标题。删除使页面只读,撤销删除恢复工作文本和原先保存文本的各自版本。文本修改提交为一次项目历史操作,Save All 将其与 Graph/Group/settings 一起写盘;文件结构操作不会顺带保存文本草稿。保存后再撤销文件移动保留最新保存文本。 + +最多 2 MiB 的普通文本支持严格 UTF-8(有/无 BOM)、带 BOM 的 UTF-16 LE/BE 和 UTF-32 LE/BE。先验证解码后能逐字节往返,保留 BOM 与输入中的换行,不猜测其他编码或统一换行。已知二进制、无法确认无损编码、大文件、受保护文件和未绑定 Graph 的 Raw 源以只读信息/文本显示,并提供路径、大小和外部打开。孤立 Raw 的专属创作入口仍待 Group 编辑阶段。 + +Apply 或失焦提交输入,Cancel input 只取消当前输入;文本控件保留本地撤销。项目撤销和保存前提交有效输入,关闭项目时未保存缓冲参与脏检查。正在发布保存时,首次打开新的文件缓冲返回忙状态,避免将新身份加入旧捕获版本;已有缓冲可继续存在。跨重启不恢复这些标签或 Undo 栈。 + +监听发现文本被外部修改时保留当前缓冲并提示;保存和结构操作复核保存基线及目标不存在条件,拒绝覆盖外部文件。当前没有三方合并或交互式重载:需要关闭项目、核对两个版本后重新打开。 + +## 支持范围 + +| 路径 | 当前规则 | +| --- | --- | +| `Assets/**` | 原始文件与相邻 `.meta` 整体操作,禁止单独操作 sidecar;移动保留 ID,复制/导入分配新 ID | +| `Graph/**` | 仅在 Graph 内移动;受管理 Group 的 source path 同步修改,Group/Entry 身份不变;关联源删除需显式选择同时删除节点及关联边 | +| `I18n//**`、`I18n/External//**` | 同一文化子目录内操作,跨文化/内外源移动被拒绝;文化根和 catalog 受保护 | +| 其他项目内文件 | 原样保留字节与目录信息;不分析未知格式的内部语义 | +| `settings.json`、`Graph/graph.json`、`Assets`、`Graph`、`I18n` 根 | 固定入口,通过专属领域编辑,禁止普通文件改名/移动/删除 | +| `Contracts/`、`Output/`、`Temp/`、`.galnet/`、版本控制及 IDE 内部根目录 | 禁止普通文件修改 | + +预检拒绝根路径逃逸、链接/重解析点、非法名称、自目录移动、目标占用、跨类别移动和批次目标冲突。选中父目录和它的子项时折叠成一次目录操作;Windows 支持仅修改大小写并恢复实际文件名拼写。空目录也参与撤销。 + +资源 metadata 通过项目的 `IResourceTypeCatalog` 与 `AssetMetaCodec` 处理,保留 Gallery 和未知扩展字段。移动保留 Gallery 身份;复制/导入时,已被项目或同批内容占用的 Gallery ID 为副本重新分配,保留 type/title,避免生成重复解锁身份。无法完整类型化的 metadata 拒绝重写;检查受影响资源与其他 sidecar 的 ID 冲突。Assets 索引仅负责读取、缩略图与通知,不在刷新/监听时创建或改写 metadata。 + +## 文档与引用 + +文件结构操作立即发布。相同结构意图分别应用于工作版本与最近保存版本,磁盘只写变换后的保存版本,未保存文本不被顺带保存。Group source path 改变后仍使用同一会话文档;之后保存再撤销路径操作,保留刚保存的文本。放弃未保存内容不会撤销已完成的文件操作。详见 [历史与保存](editor-history-and-save.md)。 + +删除预检检查所有已加载作者 Group,包括没有打开页面的 Group。按字段 EditorId 声明识别的完整资源值匹配是确定引用,不能通过候选确认绕过。普通字符串/嵌套 JSON 的路径或 ID 匹配、工作版 settings、已跟踪文本缓冲的工作版和未被缓冲覆盖的磁盘 `UI/ui.json` 的匹配仅是候选;默认拒绝删除,调用者必须显式允许候选后才可继续。关联 Group 同批删除时忽略该 Group 自己的引用。此检查不代表能发现所有自定义格式引用,也不执行猜测性字符串重写。 + +CLI 的现有资产命令使用同一文件计划与发布机制,执行前加载并校验作者文档;Graph 等作者文件损坏时返回失败,保留文件。CLI 不保留 GUI 撤销记录,也不允许与文档命令混为一个批次。`UpdateReferences` 不再进行未知字符串的全局替换。 + +## 图片差分图集作者界面 + +Assets 文件面板可从当前或选中的图片目录创建 `.variants`,使用现有 CreateFile 事务生成描述及 sidecar。只读取已导入图片,按稳定顺序选择第一张作为显式 default,维度省略时由文件名推断;不在扫描时修复或补建图片 metadata。导入图片仍使用既有 Import 事务。 + +图集属性区提供相对目录、默认图、维度 key/显示名、添加/移除/重排与重新推断。配置是项目 `ProjectTextBuffer` 的类型化投影:修改先成为草稿,应用时单次文件历史,保存全部也可提交尚未应用的有效草稿。关闭属性面板保留项目草稿;坏 default、维度或目录使应用与保存全部失败,即使属性面板已经关闭也一样。未知 JSON 字段/坏结构保留原文并禁用类型化改写,可用 JSON 入口修复。资源类型按扩展名或 sidecar 声明识别,支持图集描述被改为自定义扩展名。 + +扫描使用与编译相同的相对路径、metadata codec 和 Core builder,拒绝越界和链接。预览列出所有维度的全部选项,以普通 bitmap 展示实际 sprite,分别显示完整请求、实际图、结果原因和候选削减链。切换与刷新只改变面板状态,不写作者文件和历史;缺组合不隐藏选项。重新扫描保留请求意图及保护维度;请求因定义修改而失效时诊断,不静默改选默认图,作者可显式重置预览。异步图片读取和旧预览行按代次失效,面板释放时释放 bitmap 并解绑事件。 + +维度 key/顺序变化显示引用影响提示,不迁移剧情参数;label 不参与身份。图片移动/改名继续由既有资源事务预检维护或拒绝无效关系。操作见 [图片差分图集](../manual/image-variants.md)。 + +## 事务、撤销材料与恢复 + +普通文件备份在 `.galnet/editor/history//`,导入的重做读取保留副本,不再依赖外部源。文件复制和 SHA-256 通过磁盘/流处理,不将普通大文件整体读入托管内存;作者 JSON 和小型 sidecar 仍需要序列化。保留字节计入项目历史软预算,记录淘汰、redo 截断和项目关闭时释放。活动记录持有独占 owner lock;项目打开后清理已失去所有者的历史材料。Undo 栈不跨重启恢复。 + +每次正向、撤销和重做发布都复核当前文件哈希及目录状态,目标被外部内容占用时拒绝覆盖。文件与作者数据在同一 `.galnet/editor/transactions//` manifest 下暂存,记录文件哈希、目录前后状态和大小写改名中间路径。失败补偿后历史游标不变;补偿失败保留材料并禁止继续写入。打开项目先核对未完成事务,遇到未知外部内容停止恢复。事务内大小写中间对象也可在异常退出后恢复。 + +这不是跨进程隔离或断电原子性保证。文件 staging 当前在结构发布调用内同步执行,大型项目的交互性能仍需后续性能验收。 + +## 资源面板常用入口 + +导入、新建文本和新建文件夹在工具栏直接可见,原右键菜单仍可用。类型筛选与缩略图/列表视图只影响浏览;搜索明确覆盖整个项目。空目录、搜索或筛选无结果、读取错误分别呈现,不把加载失败当作空文件夹。排队刷新使用已捕获的项目内容服务,不在 scope 释放后重新解析服务。浏览与布局不进入作者历史。 \ No newline at end of file diff --git a/docs/spec/editor-game-localization.md b/docs/spec/editor-game-localization.md new file mode 100644 index 00000000..a91dbca8 --- /dev/null +++ b/docs/spec/editor-game-localization.md @@ -0,0 +1,150 @@ +# Editor 游戏本地化基础 + +当前实现包含字段语义、项目索引、受管理本地化文档、角色表/speaker 输入、默认语言正文/分支编辑与稳定复制身份、多语言表格、语言管理、JSON 交换及发布前本地化检查(F-20261019-01 / P1–P6)。只支持当前作者及模板格式,没有旧 Speakers、角色迁移或旧版本模板入口。 + +## 字段值语义 + +Core.Parameters 提供 `LocalizedTextAttribute`、`CharacterReferenceAttribute` 和 `FieldValueSemantic`。 + +- `[Field(...), LocalizedText]` 声明字符串成员存储游戏文本 key。 +- `[Field(...), CharacterReference]` 声明字符串成员存储角色 ID。 +- 未标记成员语义为 None,不根据名称或 EditorId 推断。 + +两种标记互斥,generator 对非字符串、没有 Field 的成员或冲突标记报告 GNF001;支持继承字段。生成的 FieldDescriptor.Semantic 独立于字段 UI 的 LabelKey/DescriptionKey 和 EditorHints。手工 schema 可通过 FieldDescriptor 构造函数的 semantic 参数声明,同样检查类型与枚举值。 + +DialogueTextOptions、NvlAppendOptions、TextVoiceOptions、NvlVoiceOptions 的对应成员已经标记。标记保留在动态参数表的 FieldDescriptor 中;项目 schema 查询的参数描述提供 Semantic 字符串。 + +ValueCodec、编译和运行格式不变:字段仍保存原 key。自定义运行指令需自行调用 ITextResolver,标记不隐式改写运行 DTO。 + +## 只读作者索引 + +Editor.Shared 的 LocalizationReferenceIndex 分析一个 EditorProjectDocument 与给定 LocalizationSource 列表: + +- 遍历全部 GroupEntries,不依赖页面是否打开;定位包含 Group ID、Entry StableId 和 JSON Pointer。 +- 对 Choice 分支使用稳定 option ID,显式索引 Text;角色 catalog 的 DisplayNameKey 也进入文本引用。 +- 递归分析带静态 Schema 的 IFieldDto 和数组/List/IList/IReadOnlyList/IEnumerable;列表下标只是导航位置,索引不生成或重算文本身份。 +- 缺省、null 与精确空字符串不生成引用,不补默认值;空 LocalizedText key 表示有意的空文本,运行时解析为空字符串。仅含空白字符的 key 仍报错;未知字段/类型、不可遍历结构与错误 shape 报告分析不完整。 +- 深度上限 16,整个分析的值预算 100,000。超限产生 analysis.limit,不能以未找到引用认定角色未使用。 + +I18n/catalog/characters.json 的角色按 Ordinal ID 检查;允许姓名译文相同。I18n//**/*.json 支持平铺与嵌套字符串叶值,按文件路径 Ordinal 排序并保留每个来源及属性位置。FindDirect 返回同语言最后生效的定义;缺少直接翻译返回 null,不做运行时回退。显式空串与缺失独立。External 不进入作者索引。 + +重复 JSON 属性、同文件扁平/嵌套 key 冲突、非字符串叶值、坏 JSON/无效 culture、无效 catalog 均有诊断,其他可读取文件仍可查询。未知角色和默认语言缺 key 单独报告;如果引用位置已经全部分析,这类目标不存在错误本身不会使 Complete 变 false。Complete 只描述分析覆盖程度,不代表项目可运行。 + +## 项目会话查询 + +ProjectLocalizationQuery 已注册为 Editor 项目 scope 服务。Get 捕获当前 Working 文档,通过 ProjectRawReader 读取尚未受管理的 I18n 来源,再以受管理文件的内存内容/删除状态覆盖磁盘来源。新建、未保存修改和删除因此可被同一索引观察;查询不写盘。 + +查询按作者快照和来源内容缓存分析并提供 Revision;文档卸载或服务 Dispose 时清缓存,已释放查询始终返回 null。没有 I18n 目录的项目可正常查询。文件读取或枚举失败进入不完整诊断,文件系统链接按现有路径策略拒绝。 + +当前是按需查询:每次 Get 会捕获文档和读取来源以判断变化,尚未实现文件级增量性能优化。该服务不是文本编辑/草稿提交入口。 + +## 受管理作者文档与写入 + +LocalizationDocumentStore 捕获文本编辑目标和文件快照,LocalizationEditOperation 执行可逆的字节补丁,GUI 的 LocalizationAuthoringService 将整个操作接入项目内容历史。快照和历史不持有页面;项目 scope 释放后拒绝继续修改。 + +专属文件入口只允许 I18n//**/*.json、I18n/catalog/characters.json 和 Graph/localization-authoring.json,拒绝 External 与其他受保护路径。已有只读跟踪文件可被专属入口提升为受管理文档,提升时核对磁盘字节并建立保存基线。catalog 和作者 metadata 仍不能从普通文件菜单修改或删除;普通 JSON 页面也不因其他服务提升文档而获得编辑它们的权限。 + +文本编辑选择当前生效的包内定义,沿原有嵌套属性路径替换值,保留其他叶值和原文件编码/BOM;新 key 默认写 I18n//authoring.json,调用者可指定分片。重复属性、扁平/嵌套冲突和非字符串叶值在写入前拒绝;未知 catalog/metadata 字段保留,不承诺保留原空白格式。 + +已有文件的普通值修改只更新 Working,在 Save All 才写盘。包含新文件的操作通过双版本结构事务建立有效 JSON 的空保存基线(文本 {}、catalog []、metadata 初始对象),同时只在 Working 应用实际内容,不顺带保存其他 dirty 字段。撤销创建会删除该结构文件;重做恢复撤销当时的 Saved 内容,Working 仍按当前历史意图恢复,避免保存之后用旧空文件覆盖新基线。外部冲突、事务补偿和 RepairRequired 复用项目会话机制。 + +## 作者 metadata 与角色编号 + +Graph/localization-authoring.json 使用 version=1、characterHighWater=非负 Int64;其他字段可保留。ReserveCharacterKeyAsync 结合 metadata 及已有 character_<数字> 角色 ID 的最大编号,通过作者文件事务预留下一个编号,返回 character_<数字>。 + +预留同时推进 Working/Saved 的计数,保留各自其他内容,不是可撤销的创作操作,也不保存其他未提交字段。后续角色创建可单独形成一条历史;撤销或创建失败允许留下编号空洞,不回收编号。溢出与发布失败拒绝分配。只有实际调用预留 API 才会分配编号,P2 不自动建立角色记录。 + +## 普通 JSON 草稿协调 + +文件缓冲通过 RegisterFileDraft 注册文件身份、提交和结构校验。CommitFileDraftsAsync 只校验并提交相关文件的有效草稿,其他文件或设置草稿不被顺带提交。Save All 的普通文本提交仍允许保留作者的待修复原文;结构化编辑前的严格校验不改变普通文本保存策略。 + +结构化文字更新按原目标 key、文件身份和期望原文检查冲突;同文件其他叶值的有效草稿可先提交,再在新文档上修改目标文本。相同目标值已变、有效来源已换文件或旧行已过期时拒绝修改。JSON 草稿的提交与随后结构化修改是两条各自完整的历史,不伪装成同一个输入操作。 + +文档刷新只有在缓冲无草稿或内容等于刚提交值时同步 Text;存在冲突草稿则保留输入及原字节基线、显示冲突,并阻止它覆盖新文档。取消草稿后重新读取当前受管理文档,控件随绑定刷新。受管理文件即使尚未保存,也从内存打开,不依赖旧磁盘字节。 + +同一路径可以保留多个删除身份和一个有效身份;保存哈希期望与外部冲突检查优先唯一有效记录,无有效记录才使用删除遮罩。不会因为旧删除记录而误拒绝同路径新文件保存。 + +## 角色表与 speaker 创作 + +角色 Dock 是项目级单例文档页,可从面板菜单打开,关闭后释放订阅,重新打开读取当前作者文档。列表显示开发语言姓名与角色 ID;搜索同时匹配两者,编辑草稿通过应用/还原按钮处理,Save All 仅保存已应用的作者值。 + +CharacterAuthoringService 以 catalog 为唯一角色身份来源,以项目 TargetLocale 的直接姓名译文为显示值;编辑器 UI 语言不改变作者姓名。允许同名、Ordinal 唯一 key;新角色不填 key 时使用持久预留的 character_n。修改角色 key 更新全部已声明的 CharacterReference,包括未打开 Group 与嵌套 DTO/列表中的实际 JSON 路径;姓名 DisplayNameKey 不变,其他语言译文保留。 + +新建记录、姓名译文及 speaker 引用构成一次项目历史,保存前只修改 Working。第一次需要文件时,先发布空 catalog 和开发语言 characters.json 保存基线;初始化文件和编号预留不随值历史撤销,允许空文件与编号空洞。保存后撤销创建只还原 Working 值,下一次 Save All 整体落盘,不删除仍服务于 Saved Group 的文件。未使用角色只能显式删除,撤销可恢复;删除保留姓名文本。引用分析不完整时禁止删除和改 key,仍允许修改姓名。 + +标记 CharacterReference 的字符串字段使用专属输入框,根字段与嵌套表单共用语义注册。候选按姓名/key 不区分大小写匹配,最多显示 50 项;选择提交精确 ID。搜索后默认高亮第一项,候选打开且输入非空时,在输入框按 Enter 可直接采用高亮角色;也可用向下键进入候选列表后按 Enter 确认。候选关闭或没有匹配时的 Enter、直接输入失焦仍按精确 key、唯一精确姓名的顺序解析;同名歧义要求选择,其他非空文本创建角色。空白输入清空引用,不创建角色。输入法组合期间不拦截 Enter/Escape,组合完成后 Escape 还原草稿;过期表单、取消或已移除 Entry 不能继续创建角色。普通未标记字符串保持原提交行为。 + +角色身份只来源于 catalog,ProjectSettings 不再包含 Speakers。没有旧角色候选、旧姓名迁移或导入兼容入口;未知 speaker ID 报告 character.unknown。原文/JSON 草稿冲突沿用受管理文档的校验与保留规则。 + +## 默认语言文本与稳定身份 + +LocalizedText 字段使用 GameTextAuthoringService 与专属编辑器,读取项目 TargetLocale 的直接文本;UI 语言切换不改变作者值。缺译显示提示,不显示 key 或其他语言的回退文本。共享 key 显示引用数量,修改影响全部共享位置;未标记字符串保持原值语义。 + +初次提交生成不依赖位置的 GUID 文本 key,正常修改沿用原 key。Entry StableId、选项 id、文本 key 是不同身份;移动 Entry/列表项搬运含 key 的原 JSON,不能按新位置重算。null、缺省与显式空译文分别保留;打开表单不创建文本,清空文本并明确提交得到空字符串译文,移除字段不自动删除翻译。 + +单行 Enter、多行 Ctrl+Enter 和失焦提交,普通多行 Enter 输入换行;Escape 还原草稿,IME 组合态不提交。控件固定首次输入时的原引用、原文与开发语言基线,引用重绑定、原文变化、切开发语言、删除或关闭项目后,过期草稿不能覆盖当前值。根字段和嵌套 DTO/列表走同一语义提交边界;原文、复核状态与引用更新是一条历史。角色与正文在同一次 DTO 提交中也保持单次历史。 + +Entry/Graph 剪贴板 version 2 保存作者数据及各语言当前生效的包内文本快照,旧剪贴板直接拒绝;External 和被遮蔽的定义不作为复制译文。复制批次为原文本 key 分配新 key,保留批次内共享关系,各语言文本和已有复核状态一起复制。普通字符串、未知 JSON、nullable/default 保留,角色 ID 仍共享;跨项目复制文本使用快照,缺角色仍按当前项目诊断,不自动迁移 catalog。 + +列表的“复制项”使用相同语义规则。副本不会按相同原文自动合并;原本缺开发语言文本的副本仍缺译,不伪造空字符串。复制后的移动和保存重开保持新 key,撤销/重做恢复同一身份。 + +新翻译/metadata 文件先建立空 Saved 基线,实际值与复制内容进入 Working。含本地化引用的 Graph 复制在结构发布时使用空 Group 内容/空选项文本作为 Saved 基线;Working 持有完整副本,Save All 才整体发布,避免撤销→保存→重做时提前发布缺少译文的新 key。历史不持有控件或输入草稿。 + +## 原文指纹与复核状态 + +Graph/localization-authoring.json 的可选 textStates 记录每个文本 key 的 sourceLocale、sourceFingerprint 和 translations(locale→原文指纹或 null)。指纹为原文 UTF-8 的 SHA-256,不复制译文值作为第二作者来源。源文变化保留各语言译文;有旧原文关联的译文标为需复核,没有关联证据时保持未知。角色姓名修改复用该记录,复制保留已有状态,跨开发语言复制按目标开发原文建立状态。 + +字符编号预留和文本历史共享 metadata 文件,但 characterHighWater 不可撤销。文本历史只对该字段允许单调重基,应用 before/after 时保留当前更高预留值;其他 metadata 内容仍严格检查。不能为了撤销文本而回退编号,也不能借此忽略其他冲突。 + +## 游戏本地化面板 + +Localization Dock 为项目级单实例,可通过视图菜单打开。左侧包含角色表、全部作者 Group(含未打开页面)和分支选项;分类和文本行使用 ListBox 虚拟化,右侧表格支持整体横向滚动。正常表格显示行/字段或选项上下文、直接文本及状态,不显示文本 key。同分类共享 key 合为一行并提示共享数量;定位按钮跳到角色表或对应 Group 条目/分支检查器。 + +每列对应一种支持语言。开发语言固定第一,其余列按 AvailableLocales 顺序排列,可通过列头左右按钮调序。新增语言规范化并验证 culture,拒绝重复,只新增支持项,不复制任何原文。I18nLocale 使用 CultureInfo 标准名称,包括 zh-Hans-CN 等脚本子标签;项目设置原开发语言字段只读,切换入口在本面板。 + +切换开发语言分为检查和应用:先显示目标语言缺少的被引用文本数量,入队及历史真正执行时重查当前开发语言、引用 key 集合及目标直接文本;预览过期则拒绝。分析不完整会显示提示,缺译数量不代表完整发布检查。该操作保留全部原有文本,只修改 TargetLocale/AvailableLocales;新增语言、调序和切开发语言均可一次撤销,且不覆盖其他项目设置。 + +单元格区分缺译、空字符串、待复核、复核未知;空字符串可同时待复核,不用运行时回退冒充直接译文。多行 Ctrl+Enter 或失焦提交,Enter 保留换行,Escape 还原,组合输入期间不提交。异步提交将输入框设为只读,保留焦点。译文修改与其对应原文指纹进入同次历史;有直接开发原文时记录关联,缺原文时保持未知。切开发语言后,旧 sourceLocale 的证据不能冒充当前原文的已复核状态。 + +单元格草稿绑定分类、文本身份、目标译文和开发原文基线。相关 JSON 草稿先校验/提交;无效 JSON 保留,目标值、来源、开发语言或引用变化后拒绝过期写入。前置检查也在历史执行边界重做,不能因入队时有效就忽略前序操作的修改。面板 ViewModel 属于项目 scope,切分类、调列序、关闭/重开视图均保留草稿;Save All 提交所有已访问分类的草稿,冲突保留并阻止保存,可用“放弃草稿”显式清除。干净单元格随作者文档刷新,输入中的草稿不被静默覆盖。每次刷新共用一个派生快照及一次 metadata 解析,不按每个按键/单元格重建全项目索引。 + +## 模板、导出与导入 + +面板提供当前分类/全部分类、单目标语言的 JSON 模板导出;勾选“运行文本”则输出已定义直接译文的平铺 key→string JSON,缺译不写入,显式空串保留。External 不进入作者交换来源。模板是 UTF-8 JSON,当前仅接受 version 1: + +```json +{ + "version": 1, + "projectId": "0123456789abcdef0123456789abcdef", + "sourceLocale": "zh-CN", + "targetLocale": "en-US", + "rows": [{ + "key": "text_example", + "sourceText": "Hello", + "sourceFingerprint": "185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969", + "context": ["group/entry/content"], + "translation": null + }], + "complete": true +} +``` + +projectId 来自 Graph/localization-authoring.json 的 exchangeProjectId,不使用显示名或目录名。首次模板导出持久预留 GUID,分别保留 Working/Saved 中其他内容,不顺带保存 dirty 文本;身份不随文本历史撤销,已有身份不能被历史替换。首次预留可能在后续输出失败后保留,译文仍不改变。complete 表示引用分析范围是否完整,不表示全部文本已翻译或整个项目可发布;无法分析的范围保持 false,已知文本仍可交换。 + +导出先提交有效作者草稿,选择取消则不启动;目标须在项目目录外且不得经过目录链接,先写临时文件再原子替换,避免覆盖受管理作者来源或产生半个文件。文件中的 key 是机器身份,表格和导入预览不显示它。 + +导入接受上述模板,或单语言平铺 JSON(key→string/null)。模板核对项目 GUID、开发语言、支持的目标语言、版本、重复属性/行及原文指纹自洽;平铺 JSON 使用面板所选语言,没有原文证据。导入仅写开发语言以外的已支持语言,不隐式添加语言或改原文。单文件最多 8 MiB、100000 行,拒绝其他 JSON 值类型。 + +预览按稳定 key 匹配,显示当前开发原文、待写译文和状态;原模板原文可在提示中查看。未知 key 不可选;null 跳过;空串默认不选,选入后仍须显式允许清空。未变值默认不选。原文已变化的模板译文可选择应用,并按旧指纹标待复核;平铺输入或缺原文时复核状态未知。行顺序和 context 只供阅读,不用于身份匹配,角色改 ID 不改变姓名文本 key。 + +应用前提交相关有效 JSON 草稿,失效或损坏的草稿保留。预览捕获包内文本/引用、语言与 metadata 证据,在历史实际执行时再次核对;变动或项目关闭则拒绝旧预览。按文件共用快照并批量修改叶值,保留非目标译文、嵌套布局和其他 metadata。值与复核状态作为一次项目历史,撤销/重做沿用相同身份;新文件先初始化空 Saved 基线。应用期间预览控件禁用,取消预览用于尚未开始的应用。 + +## 发布检查与自定义契约 + +完整非 structure-only 的 project.validate 接入 localization-semantics,列出检查的包内翻译、catalog 和 metadata;独立 graph/group 范围明确报告跳过完整本地化检查。ProjectContentBuilder 在创建/替换输出前检查已声明文本、角色引用、默认语言、来源 JSON 和语言设置,确定的缺译、未知角色、重复/损坏来源和非法设置阻止发布,已有输出保留。External 不能补足包内默认语言完整性。 + +未知 schema、opaque 字段或分析预算上限报告 warning 和范围不完整,project.validate 的 Complete 为 false,不声称已检查自定义代码内部字符串。构建结果及 CLI diagnostics 保留这些警告;确定的错误仍阻止构建。作者 Save All 继续允许可修复的语义草稿,不能把作者保存等同于发布检查。 + +自定义 DTO 在 string/string? 成员上使用 [LocalizedText] 或 [CharacterReference],生成/手工 schema 应保留 FieldDescriptor.Semantic;嵌套 DTO 和列表使用同一 walker。标记不改变 ValueCodec 的字符串契约,也不执行自定义游戏模块。自定义运行指令收到的仍是 key,须自行调用 ITextResolver;无标记字符串不被猜测或替换。接入示例见 [使用说明](../manual/game-localization.md)。 + +操作步骤见 [游戏本地化使用说明](../manual/game-localization.md)。实现与验证记录见 [阶段计划](../../features/F-20261019-01-editor-game-localization/phase-plan.md)、[P6](../../features/F-20261019-01-editor-game-localization/phase-6.md) 和 [实现总结](../../features/F-20261019-01-editor-game-localization/summary.md)。 diff --git a/docs/spec/editor-graph.md b/docs/spec/editor-graph.md index ddf9b55b..89f99cb5 100644 --- a/docs/spec/editor-graph.md +++ b/docs/spec/editor-graph.md @@ -1,6 +1,12 @@ -# Editor Graph 基础编辑 +# Editor Graph 编辑 -当前实现:F-20261015-02 / P2。作者数据在项目文档中,GraphNode/GraphEdge 是可观察投影。基本节点增删、改名、移动与连线记录只持项目数据和稳定 ID;关闭或重建 Workspace 不丢失这些操作的撤销能力。分支字段、条目和变量的旧记录仍按 P3/P5 迁移。 +当前实现:F-20261015-02 / P2、P3。作者数据在项目文档中,GraphNode/GraphEdge 是可观察投影。节点增删、改名、移动、连线、分支与粘贴记录只持项目数据和稳定 ID;关闭或重建 Workspace 不丢失这些操作的撤销能力。Entry 编辑使用项目作者服务,变量声明及引用意图见 [变量编辑](editor-variables.md);Preview 只编辑运行时当前值。 + +## 外部游戏运行标记 + +外部调试以独立的运行标记高亮当前 Group,不修改作者选择。Group 页面通过本次编译 SourceMap 高亮活动作者 Entry,下一 primitive 游标不等同当前显示条目;映射随构建内容绑定,作者内容变化后停止使用旧映射。运行标记、断点和实例变量修改不进入作者历史。 + +Group/Entry 右键可以触发无条件场景追帧定位;多选 Group 能组成唯一完整的直接后继链时优先采用,否则显示有界的边/出口路线列表。目标前暂停,普通玩家执行条件的恢复、入口缓存和变量策略见 [Runtime](runtime.md);启动与面板操作见[外部游戏调试手册](../manual/external-game-debugging.md)。 ## 图规则 @@ -20,9 +26,23 @@ | 改名、连线、断开 | 普通内容编辑;可撤销,保存全部后写盘 | | 拖动一个或多个节点 | X/Y 为界面预览,作者坐标直到松手才提交;一次手势记一步。Escape、丢失捕获、页面脱离取消预览 | | 选择、框选、平移缩放 | 不进入内容历史;视口通过独立 view-state 保存 | +| 分支项增删、重排、文本或条件修改 | 使用稳定项 ID,整次操作进入项目历史;删除中间项只删除其出边,其余连线跟随原项并映射到新 outlet | +| 复制粘贴 | 复制所选非 Entry 节点及内部边,Group 内容深复制;整批粘贴和源文件一次撤销/重做 | Group 文件结构操作分别变换 Working 和 Saved:删除前未保存的条目保留在 Working 撤销材料中,磁盘恢复使用 Saved 材料。等待已有保存完成后,才在发布门内捕获两者;再次删除重新捕获当前基线,避免覆盖后来保存的内容。工作版本恢复保留已有作者对象身份,以便已有条目绑定继续指向实际内容。关闭时 Discard 不反向撤销已经发布的文件结构操作。 -Workspace 根据文档事件增量协调节点与边,页面释放时解除监听;被移除边释放节点事件订阅。基本名称不再通过属性监听补记历史。尚未迁移的条目/分支监听在项目历史执行时停止记账,并在结束后刷新前值,避免页面重建后撤销又生成新记录。 +Workspace 根据文档事件增量协调节点、分支项与边,页面释放时解除监听;被移除边释放节点事件订阅。名称和分支字段在作者变更前进入历史,不通过属性监听补记。投影保留已有分支 DTO 和编辑控件身份,输入刷新不会重建整个 Inspector;锁定 Inspector 的节点删除后停止展示旧目标,撤销后按 ID 绑定恢复的节点。连接器保留分支项 ID,排队从输出口创建节点时再次按该 ID 定位出口;Working 与 Saved 顺序不同的情况下分别解析该 ID,不顺带保存重排。空分支没有可连接出口。尚未迁移的条目监听在项目历史执行时停止记账,并在结束后刷新前值。 + +## 复制边界与键盘 + +画布使用系统文本剪贴板保存版本化选择快照;当前版本为 1,粘贴入口最多接受 1000 个节点、16 MiB 文本。每次粘贴重新生成节点、边、分支项和 Entry 稳定 ID,并为 Group 生成独立源路径;相对位置保留,外部入边/出边不复制,Entry 根始终不复制。重做使用历史保留的副本,不再依赖剪贴板或源内容。无效格式在修改前拒绝。当前字段契约未声明额外 Graph 引用类型,已重映射的结构关系是边端点和 Group 文件归属;自定义参数中的字符串/JSON 原样保留,粘贴后状态提示作者检查这些引用,不猜测替换。 + +画布 Ctrl+C/V/A 分别复制、粘贴和全选,Delete 删除选择,F 定位选择(没有选择时显示全图),Home 显示全图;Ctrl/Shift 点击追加选择。这些 Graph 专用键位当前固定,右键菜单显示相应操作和键位,未接入编辑器设置中的自定义手势。文字输入框先消费本地操作,再由页面处理项目快捷键;正在拖动或连线时只有 Escape 取消交互。定位、选择和视口不改变作者内容或历史。 + +文件外部变化或发布失败会报告错误,失败不推进历史;批量粘贴与撤销的中段文件发布失败使用相同事务补偿并可重试。保存、补偿与恢复语义见 [项目历史与保存](editor-history-and-save.md)。项目文件操作见 [项目文件](editor-files.md),Entry 参数草稿见 [项目历史与保存](editor-history-and-save.md#entry-输入草稿与保存反馈);原生桌面完整人工验收尚未进行。 + +Group 页的类型搜索、批量插入、空格快速插入、折叠拖放和选择行为见 [Group 编辑交互](editor-groups.md)。P5c 独立变量面板、P5d 条目字段历史绑定和 P5e 未完成作者草稿保存已验证;复杂参数编辑与状态查询继续按 P6 推进。 + +## 可见导航工具 -文件外部变化或发布失败会报告错误,失败不推进历史。保存、补偿与恢复语义见 [项目历史与保存](editor-history-and-save.md)。当前提供基础 Graph 创作,复制粘贴、分支完整交互、项目文件界面及完整参数草稿按后续 Phase 实施;原生桌面完整人工验收尚未进行。 +画布工具栏提供名称/稳定 ID 搜索、显示全图、定位选择、缩放滑块及比例。滑块缩放保留视口中心;可切换的小地图默认关闭,点击只改变视口,不修改节点坐标或作者历史。Group、Problems 和变量引用使用统一作者导航服务定位当前有效节点/条目;字段存在时 Inspector 滚动并聚焦对应根字段。原有 F、Home 和画布快捷键继续可用。 \ No newline at end of file diff --git a/docs/spec/editor-groups.md b/docs/spec/editor-groups.md new file mode 100644 index 00000000..bdd829d7 --- /dev/null +++ b/docs/spec/editor-groups.md @@ -0,0 +1,132 @@ +# Editor Group 编辑交互 + +Group 页面编辑项目会话中的作者条目,使用项目统一内容历史与 Save All。交互由 F-20261015-02 / P5a、P5b 验证,变量面板见 P5c,条目字段提交见 P5d,作者草稿保存见 P5e;共享 DTO 参数控件基础见 [Schema 字段表单](control.md#schema-字段表单)。P6b 接入普通复杂值表单,P6d 接入 Timeline Dock,P6e1 接入基础场景查询和属性轨道树,P6e2 接入关键帧画布和时间编辑,P6e2b 接入共用场景对象选择及 Timeline 两步添加对象/属性;未知 schema 的整参数 JSON 修改器、其余状态语义及曲线工具仍待后续切片。 + +## 条目字段提交 + +类型、条件和参数编辑通过项目级意图修改作者数据,再通知投影刷新;Workspace 不再监听 Entry PropertyChanged 补记历史。生成表单直接读取作者 JSON 并提交字段 patch;文本、资源与表达式专属控件按同一契约转换输入,提交后刷新。Parameters 字符串字典只是显示投影,不能通过修改返回字典绕过提交。 + +字段提交记录 Group ID、Entry 稳定 ID 和前后作者值,不持有页面或控件;类型与完整参数一次恢复,条件及未知 JSON 参数保留在快照中。相同值不增加历史,跨 Group 编辑按项目顺序撤销,关闭页面后仍能执行 Undo/Redo。新增条目复用项目级批量服务,不使用 Workspace 的事后闭包记录。 + +打开、重建或切换语言不清理作者参数,也不把默认值自动写成已提供值;缺省和显式 null 在内存及 Raw 文件中分开保留。表单在条目移除、换类型、页面关闭或项目关闭后失效;撤销删除/换类型不会让旧字段绑定重新获得提交权。局部参数提交保留 partial 校验,专属文本/资源/表达式字段也只更新自己的 JSON 值,不重解析其他作者参数。 + +GUI 使用仓库 Authoring 模式,允许缺必填参数、错误类型的参数值、未知类型/参数及未完成条件表达式保存重开;不会 Normalize、插入默认值或把未知内容静默删除。未知类型显示当前契约无编辑表单的提示;已知类型的未知参数显示保留提示。作者可以复制、换类型、撤销或保存,完整语义错误留待检查/编译修复。设置草稿和变量声明的既有规则不因此放宽。 + +作者模式仍拒绝损坏 JSON、重复 JSON 属性/稳定 ID、未知顶层或 Entry 信封字段、非对象参数、无效 Graph 拓扑及不安全/重复归属的源路径,并保留外部文件冲突检查。共享仓库默认 Complete,CLI 写入与 validate/group compile 继续完整校验;预览编译路径亦保持原有严格契约。保存草稿成功不等于能构建或运行游戏。 + +字段历史执行前检查当前作者值,发现外部变更则拒绝覆盖并保持历史游标。投影通知失败会恢复原作者值和投影;恢复再次失败进入 RepairRequired。 + +## 普通复杂值表单 + +### 属性行与常用专属编辑器 + +Group 的生成字段及资源、文本等专属字段使用统一 FieldEditorRow:宽面板为标签/值/操作三列,标题左上对齐,重置在第一行右侧;窄面板把值区移到下一行,标题与重置仍同行。错误只在存在时显示。Group 不显示常驻省略、null 和草稿恢复按钮;Escape 取消未提交输入,已提交内容使用项目 Undo。 + +有声明默认值时,重置显式写入默认值;无默认值的可选字段恢复缺省,必填且无默认值禁用并说明原因。普通字符串清空保存空字符串;可选数字等非字符串输入清空恢复缺省,必填无默认值的空草稿显示错误,不写成 0。清空可选资源选择恢复缺省。已有显式 null 不因打开/失焦变成缺省。这些简化策略属于 Editor,不改变 Sample 设置表单。 + +Builtins 的 Transform/ToTransform 通过成员注册展示位置 X/Y、角度和缩放 X/Y;LayerTransform 类型本身也有默认工厂,列表元素可复用。编辑按实际键名更新单个成员,保留大小写、未知字段和原有对象/JSON 字符串表示,不将视觉展开改为参数层级扁平化。缩放缺省为 1。源值异常时提供高级源码修复入口,正常结构不显示额外源码按钮。 + +颜色字段通过成员注册使用色块、十六进制输入与支持 alpha 的 ColorPicker。新值使用 `#RRGGBB` 或 `#AARRGGBB`,打开旧值不自动重写;非法输入保留草稿和错误。拾色器内改变颜色不写作者数据,确认一条项目历史,取消不写;外部值改变或字段失效取消旧选择。 + +Timeline 参数常态为“打开时间轴”及帧数/FPS/轨道数摘要。点击按当前字段来源的 Group/Entry 身份定位,选中对应 Entry 后激活项目单实例 Dock;关闭后可重开。缺省/null 不因打开创建空 Timeline,创建仍由 Dock 的明确动作完成;无默认定义的重置禁用。异常定义显示诊断并提供源码修复,旧绑定失效后不得导航。 + +生成 DTO 的对象字段按展开创建嵌套表单,常用一维数组/List 支持逐元素编辑、添加、删除与上下移动。三层 registry 在嵌套处仍有效;没有专属工厂时,没有 schema、字典、复杂转换器、超过八层或 200 元素的结构使用明确的多行 JSON 回退。成员覆盖与自定义类型工厂优先于显式 EditorId;无覆盖时显式 EditorId 决定常规工厂,不强行展开声明为 JSON 的字段。JSON 使用 Ctrl+Enter 或失焦提交,Escape 恢复未提交输入。 + +缺省、显式 null 与显式采用默认值在作者文档中独立保留;Group 操作入口按上述简化策略展示。短 DTO 对象默认展开,其余对象/列表可按需展开;组标题本地化,列表元素不重复显示 element 标签,控件按元素类型继续分派。展开不写默认值;对象局部编辑保留未知成员,按当前序列化配置使用实际属性名。每次成功提交通过原字段作者意图产生一条项目历史;列表结构变化取消旧索引绑定,Undo 不使旧绑定复活。折叠对象释放其子控件;Editor 的未提交字段草稿、已提交内容和历史仍属于项目会话。 + +共享表单不查询游戏状态;Timeline 专属会话使用独立的[场景目标查询](editor-scene-targets.md)。通用 Locator 字段、Timeline 曲线和其他复杂专属控件仍待后续切片。 + +## Timeline Dock + +Timeline 是项目级单实例面板,打开后与资源、日志在底部同组;新用户的剧情布局按需打开,动画布局默认激活,可从查看菜单重新打开。它跟随当前活动 Group 页的活动 Entry,多选时使用最后活动项;无选择、普通条目和无效 Timeline 分别显示提示。聚焦工具面板不改变来源,关闭非活动 Group 页不会清除另一页的选择。 + +树只显示已有属性轨道、当前面板中选择的对象及等待初值的属性草稿。顶部“+”仅打开场景对象选择器,卡片/搜索/确认与普通字段和 Assets Picker 使用相同组件,不包含属性或初值表单。确认对象后出现对象行;行左侧“+”弹出尚未加入的属性列表,选择一个属性加入重载,随后直接在叶行编辑当前帧值和绝对/叠加模式。 + +属性当前值已知时,选择属性直接创建必要的第 0 帧和当前帧键,一次选择是一条项目历史;已经存在的属性不重复列出。值未知时先展示空值草稿行,明确输入数值后才创建轨道;绝对模式使用输入作为缺失的第 0 帧初值,叠加模式首帧为 0。新增属性时展开所属对象。只选对象、取消选择以及等待初值均不写空对象或不完整轨道,面板草稿不进入内容历史/持久化,切换 Entry 后清除;已提交轨道正常保存并支持撤销/重做。 + +对象、属性组和属性叶行右键打开“取消重载”菜单:属性移除自身轨道,对象/属性组移除全部后代轨道,整次操作只产生一条项目历史;Undo 整体恢复。空对象和等待初值的属性只移除面板草稿,不写历史。菜单捕获的旧树行在来源、帧或作者数据版本改变后失效,不能通过旧索引删除新的轨道。目标查询与字段适配见 [场景目标查询](editor-scene-targets.md#查询与选择控件)。 + +当前以对象→属性组→属性叶节点展示入口候选及已有轨道;显示创建来源和一定/可能存在,未解析引用保留为编号,不显示底层 handle。属性来自 Core 已声明能力,折叠与选择不进入内容历史,播放头变化保留折叠状态。帧输入不修改作者数据;叶值使用 NumericUpDown,Enter 或失焦确认,Escape 恢复未确认输入。当前帧有键时只更新值、保留切线和插值;没有键时按帧序插入,继承原区间插值和曲线导数。每次确认产生一条项目内容历史,可跨页撤销、保存和重开,未知 JSON 成员保留。 + +统一从对象行“+”添加属性,从右键菜单取消重载;不提供属性勾选、可添加属性模式或更换对象按钮。添加未知初值属性后,确认叶值写入必要的 0 帧与当前帧键,当前帧为 0 时只建一键;绝对模式采用已知初值,否则明确输入值同时用于第 0 帧;偏移模式非零帧创建时首键为零。不因获得焦点或初始化绑定建轨道,重复对象/属性不会新增。 + +每个叶属性的模式下拉框始终显示“绝对值 / 叠加偏移”。已有轨道切换模式只修改整条轨道的混合方式,保留所有键的数值、插值、切线和未知字段,产生一次项目历史;不自动换算基础值。绝对值不满足已知属性范围时拒绝切换并显示错误。未建轨道时只是本地创建选项,不因选择模式自动建轨道。 + +缺失或 null 的 timeline 仅在明确点击创建后写入空定义;打开或选择不会补默认值。切换条目、帧、类型,关闭面板、相关根值或场景事实改变会取消旧行输入并使其失效;撤销删除不会使旧行恢复提交权。保存、场景事实和根值均未变的无关更新及切换语言保留当前本地输入。项目拥有会话和已提交历史,面板关闭只取消本地输入。 + +属性树按展开状态生成可变高度的可见行(普通行 38、叶属性展开额外增加 176 的值图区域),与右侧关键帧画布共用纵向滚动;折叠父行显示后代关键帧汇总,汇总可选择和拖动:移动该汇总点所在时间的所有后代键,包括折叠隐藏的子项,其他时间的键不变。拖动沿用单历史、首帧和碰撞保护。时间区域支持缩放条、水平滚动条、Ctrl+滚轮锚定缩放及 Shift+滚轮平移;拖动顶部标尺时只重绘播放头预览线,松手后定位并更新属性采样一次,不在每个鼠标移动事件中重建属性行。Escape、捕获丢失、换来源或版本失效取消预览;点击同样在释放时定位,仅改变编辑帧。 + +画布支持单选、Ctrl 多选、框选、整帧横向拖动、Delete 删除、Insert/按钮在当前帧加键及上一键/下一键定位。加键使用所选键的轨道,或左侧最后点击的属性轨道;已有键只选中,不重复写入。拖动只更新预览,松手一次提交项目历史;越过结束帧时自动延长,撤销同时恢复所有选中键和结束帧。Escape、源变化或捕获丢失取消旧手势。0 帧键不能移动或删除,负帧及整数溢出拒绝;目标碰撞保持非法预览,需明确替换碰撞键或取消,不能覆盖 0 帧键。 + +结束帧与 FPS 使用 NumericUpDown,Enter 或失焦各自确认,Escape 恢复未确认草稿。FPS 修改保留帧号与切线,只改变播放速度;结束帧下限为最后一个关键帧或事件,输入更小值时钳制到下限,不删除内容。没有保留秒数、应用或裁剪按钮,也不保留旧重定时/裁剪路径;同帧事件顺序和未知 JSON 保留,一次确认一条历史,无变化不增加历史。整体时间缩放另作后续明确操作。 + +每个已重载属性都有展开按钮,展开后在该属性下显示内联值曲线,与原时间轴共用横坐标、播放头、选择和横向缩放/滚动;下方属性随同一行高移动,左侧当前帧值仍可编辑。图形纵坐标表示值,Ctrl 多选后拖动按相同时间/值增量移动所选键,保留键之间的差值。选中 Hermite 键后显示存在的入/出切线柄;Alt+滚轮按鼠标处数值缩放,Alt+拖动平移值域,“适应值域”包含 Hermite 区间极值。这些摄像机/展开状态不写作者历史,值域不钳制既有 overshoot。 + +右键未选关键帧会先选中它,右键已选关键帧保留多选集合。菜单提供出段 Step/Linear/Hermite、双侧/入侧/出侧切线模式(自由、平坦、线性、自动贝塞尔)、联动/断开及“编辑数值…”弹窗。固定独立详情区已移除;弹窗使用 NumericUpDown 精确输入值、入/出切线,可按秒显示斜率,混合值留空。Enter/失焦确认,Escape 恢复未确认草稿;批量设绝对值与曲线增量拖动是不同动作。任一已知绝对属性端点不满足范围就整批拒绝,不在 Editor 单方面新增区间极值拒绝规则。 + +曲线键/柄拖动只更新本地预览,松手一条项目历史;Escape、捕获丢失、选择/帧/条目/源版本变化取消旧手势。非法曲线手势释放时取消,不隐式覆盖碰撞键;普通时间轴仍保留原显式碰撞替换。第 0 帧可改值和切线,不能横移或删除。未知 JSON、同帧事件顺序和根字段大小写保留;保存重开及跨页面撤销/重做保持作者内容。 + +键的 Editor 专用 `editorTangents` 保存 incoming/outgoing(Free/Flat/Linear/Auto)和 linked;运行时继续消费原有值/帧斜率,不解释编辑模式。修改值或增删/移动邻键时,自动/线性/平坦派生斜率与当前动作在同一 patch 中重算;无模式的键默认自由且断开,打开页面不补写。平坦为零;线性采用各自相邻段斜率;自动使用前后邻键中心斜率,局部极值为零,其他内部键限制在相邻同号斜率较小绝对值的三倍内,端点用相邻段斜率。此为非加权自动贝塞尔,保存的是 Hermite 等价斜率,不宣称复制其他产品的内部算法。 + +单侧模式选择会断开联动;联动先取已有入/出斜率平均并转自由模式,之后手动拖柄/精确输入同步另一侧。Step/Linear 保留未使用的切线与模式,切回 Hermite 可继续使用;非有限导数拒绝整批修改。切线保存单位为值/帧,按秒显示乘 FPS,改 FPS 保留原始斜率。旧菜单/弹窗草稿在来源改变后不能提交,保存和切语言保留有效草稿。 + +曲线绘制直接调用 Core TimelineSampler;精确内部关键帧采用自身值及出段语义,包括前段为 Step 的情况,不通过绘制时偏移帧号绕过采样边界。 + +偏移是叠加值:基础 X 为 100、轨道值为 20 时结果为 120;普通模式的轨道值 20 表示目标 X 为 20。FPS 例如将 60 改为 30,第 60 帧仍是第 60 帧,从第 1 秒变成第 2 秒。Timeline 内复选框及说明垂直居中,数字框长按上下按钮在 250ms 后以 30ms 间隔重复,单次步长不变。 + +没有候选或既有轨道时提示先在前文创建场景对象,无效作者内容可通过条目 JSON 修复。事件、加权曲线与其他剩余语义仍待后续;实时场景预览不在阶段计划中。内联曲线和切线验证见 [P6e3](../../features/F-20261015-02-editor-authoring-workspace/phase-6e3.md)。此前验证记录见 [P6d](../../features/F-20261015-02-editor-authoring-workspace/phase-6d.md)、[P6e1](../../features/F-20261015-02-editor-authoring-workspace/phase-6e1.md)、[P6e2](../../features/F-20261015-02-editor-authoring-workspace/phase-6e2.md)。 + +## 类型选择 + +行内类型选择器、顶部插入选择器和 Space 快速插入共用 EntryTypePicker。弹窗打开后清空旧查询并直接聚焦搜索框;按当前语言显示名、原英文 ID 忽略大小写匹配,叶项同时显示显示名与英文 ID,缺译回退为 ID。语言切换重新筛选当前查询,不修改作者内容。 + +内置常用类型提供轻量全拼/首字母别名,自定义 EntryTypeOption 可传 SearchAliases;没有泛化汉字转写、模糊拼音或多音字推断。当前支持的内置别名见 picker 的 SimplePinyin 表,不能据此承诺任意类型均能拼音查询。 + +输入改变后清除高亮。首次 Up/Down 均选首个叶结果,之后前后移动并在边界停住,跳过分类标题,滚动到高亮结果;Enter 确认高亮,鼠标点击叶项确认。没有高亮或没有结果时 Enter 不提交,Escape/外部关闭不修改条目。IME 有预编辑文本时先交给输入法。搜索框中的文字按键用于查询,不执行原单字母类型快捷键;原类型快捷键仅在选择器非搜索状态使用。 + +行内确认只修改当前条目的类型与参数,完整意图为一条历史,Undo 恢复原类型和全部原参数。插入选择器用明确确认事件触发新增,不能监听 SelectedValue 改变来新增,因此重复选择同一类型仍可连续添加。 + +## 添加、追加与快速插入 + +顶部排列为添加条目、追加条目、数量、插入类型选择器,窄窗口允许换行;原底部数量/追加区域已移除。 + +- 添加条目按数量在所选条目后插入,无选择时追加末尾;追加条目按数量始终在末尾添加。数量取 1–1000 的整数。 +- 直接确认顶部类型或 Space 弹窗时只新增一条,与数量无关。新建后选中新项,批量新增选中最后一项并滚动到可见区域。 +- 普通按钮使用已确认的插入类型,初始为 dialogue.text;契约不含该类型时先打开选择器,保留原按钮的数量与添加/追加意图。不会默认创建 catalog 第一项。 +- 每次插入在条目加入列表前确定类型和参数,分配新稳定 ID,批量一条项目历史;Redo 复用该次操作保留的身份。 + +弹窗打开时捕获稳定插入锚点,切换焦点不会改变锚点。目标在弹窗期间被删除时取消此次插入并提示;以该行作为 Popup target 时,控件自动关闭也会清理待执行意图。没有选择的追加不依赖旧末项的存活,确认时取当前末尾。 + +列表或 Group 编辑区自身获得键盘焦点时,Space 在所选条目附近打开插入选择器;无选择时先滚动到列表尾部再打开。关闭快速插入恢复列表焦点。文字/数字/条件输入、按钮、ComboBox 和已有 picker 先处理自己的按键;不从其他 Dock 页抢占空格。Shift+A 和 Ctrl+Shift+A 沿用添加/追加入口,数量规则相同。 + +## 选择、折叠与拖放 + +列表内容末尾有约 64 DIP 可滚动留白,不使用伪条目占位。点击列表背景取消选择;行内控件、工具栏、滚动条不误取消,其他 Dock 页不会通过全局点击监听清除此页选择。无选择时后续新增从末尾开始。 + +参数展开状态由页面按 Entry StableId 保存,通过投影的 IsParametersExpanded 驱动行与拖动预览;它不进入作者 JSON、内容历史或 dirty。移动事件不重新配置字段表单,避免拖动/重排重建参数控件。拖放、重排和同页 Undo 保留折叠,面板重新打开默认展开,不跨重启保存。 + +关闭/重建列表容器不改变条目稳定身份。条目序号仅为展示顺序,重排后可重新编号,不能作为插入锚点或折叠状态的 key。持久化与失败撤销规则见 [历史与保存](editor-history-and-save.md)。 + +## 多选与剪贴板 + +列表支持 Ctrl 点击增减选择、Shift 点击连续范围、Ctrl+A 全选;最后活动项作为添加/粘贴锚点,复制与删除按列表顺序处理。背景或 Escape 清空选择,无选择时粘贴追加末尾。工具栏直接插入后选中新项;粘贴/副本选中新插入的全部条目。 + +右键菜单及快捷键提供复制 Ctrl+C、剪切 Ctrl+X、粘贴 Ctrl+V、创建副本 Ctrl+D、删除 Delete。文字、数字、条件输入保留本地快捷键,菜单不覆盖这些控件自己的编辑菜单;右击未选中的行先单选该行,右击已选中的行保留集合。行内删除按钮仅删除该行,Delete/菜单删除处理当前集合。 + +剪贴板为 GalNet.Entries / Version 1 JSON,最多 1,000 条和 16 MiB UTF-8 文本;与 Graph 剪贴板区分。复制保留作者参数 JSON、条件、条目顺序和省略/显式 null,粘贴及副本产生新稳定 ID,不猜测或替换 JSON 中的普通字符串。GUI 粘贴保留目标契约暂不支持的类型/字段,以作者草稿保存;格式错误或超限仍整批拒绝。Graph 复制也保留所含 Group 的未知或不完整条目;当前不提供未知 schema 的通用 JSON 编辑。 + +粘贴、批量删除和所选集合移动各为一条项目历史。多选时拖动其中一项会按原相对顺序移动整个集合;拖动未选中项先单选该项。折叠状态沿用稳定 ID,不随集合移动展开。Undo 恢复删除项的原位置,Redo 复用本次粘贴的身份;页面关闭不影响这些项目历史。 + +剪切先等待系统剪贴板写入成功,再删除原条目;写入失败或等待期间原条目发生变化则保留原条目并提示。异步粘贴使用开始操作时的稳定锚点,锚点删除时拒绝;页面销毁或换绑后不执行晚返回。操作失败保持错误反馈,相关提示与历史标题支持四语。 + +嵌套字段提交仍经过根字段的值契约与宿主 partial 校验;其他已有子值使整个对象无法类型化时,失败诊断会显示高级源码入口,可切换 JSON 模式一并修复,当前不绕过根值校验。 + +## 常用字段与 Inspector + +Group 页面默认在行内展示 DTO 标记为 Common 的根字段,折叠后保留内容摘要。选中与展开独立;单击条目选择,展开箭头不隐式更改作者选择。条件在完整 Inspector 编辑,非空条件在行内显示摘要。工具栏支持内容/类型/条件搜索、输入显示序号定位,以及全部展开/折叠;搜索结果重排按当前作者顺序映射,不把过滤后的索引当作原始索引。 + +Inspector 为活动条目创建自己的字段控件和绑定租约,显示 Group、条目序号、类型、条件及完整可见参数。锁定时保留目标并持续显示锁定状态;日志、Timeline、Problems 等无自身 Inspector 的辅助工具不会清空作者详情。关闭 Group 页面使原页面字段绑定失效,锁定的 Inspector 使用自己的绑定;项目草稿不随页面释放。多选只沿用已有批量操作,不隐式批量修改详情参数。 + +详情跟随 Dock 的标签激活和面板焦点:从资源区返回已处于活动状态的 Group,仍会恢复条目 Inspector。后台资源/节点选择刷新不抢走当前详情;无 Inspector 的辅助工具也不替换用于解锁恢复的最近作者目标。 + +字段草稿、源冲突和校验问题通过条目问题标记及 Problems 面板可见。Problems、变量引用和节点查找使用稳定作者身份导航;失效目标不按旧行号重定向。完整参数仍保留未知类型/参数提示,不因 Common 筛选丢弃作者数据。 diff --git a/docs/spec/editor-history-and-save.md b/docs/spec/editor-history-and-save.md index b95aba8e..4d53b134 100644 --- a/docs/spec/editor-history-and-save.md +++ b/docs/spec/editor-history-and-save.md @@ -1,11 +1,13 @@ # Editor 项目历史与保存 -当前实现见 `features/F-20261015-02-editor-authoring-workspace/phase-1b.md`。P2 已接入 [Graph 基础编辑](editor-graph.md);分支完整交互、项目文件界面和完整 Group 参数 UI 按后续阶段继续迁移。 +当前实现见 `features/F-20261015-02-editor-authoring-workspace/phase-1b.md`。P2/P3 已接入 [Graph 编辑](editor-graph.md),包括分支与整批粘贴;项目文件界面及文本页已接入,见 [项目文件操作](editor-files.md);完整 Group 参数 UI 继续按后续阶段迁移。 ## 操作与输入 一个项目拥有一条内容历史,页面、Inspector 和 Dock 焦点不切换内容栈。页面展开、选择、滚动与 Dock 布局不进入内容历史。撤销/重做菜单显示下一项操作描述。关闭设置页面后,已提交的设置仍可撤销;关闭项目后不恢复旧 Undo 栈。 +本地化作者文档与单次文本修改也使用该内容历史,见 [游戏本地化基础](editor-game-localization.md)。JSON 文件缓冲按文件身份注册草稿;结构化编辑只预先校验并提交相关文件草稿。文档刷新不会覆盖已有冲突草稿,过期输入必须取消或重新核对后才能提交。 + 设置页的名称、分辨率、存档槽、音频与语言输入先保留为本地草稿,点击 Apply changes 或 Enter 提交为一次编辑,Escape 或 Cancel changes 取消草稿。保存全部及项目菜单撤销/重做会先尝试提交有效设置草稿;无效草稿显示错误并阻止保存关闭。文本控件先处理自己的撤销,再冒泡至项目快捷键。提交不逐字符记账,不依赖 IME 的逐段文本事件构造历史。 历史串行执行异步编辑;同步入口在有编辑执行时拒绝重入。执行、撤销与重做都在成功后移动游标;失败不截断 redo。复合操作补偿已完成步骤,每个单独步骤必须自行保证失败时保持输入状态。补偿失败进入 RepairRequired,禁止继续编辑/写盘并保留作者恢复快照,用户需检查恢复材料再重新打开。通知观察者失败记录日志,不将已提交内容伪装为应用失败。 @@ -16,21 +18,35 @@ GUI 的项目保存统一经过项目 scope 的 `ProjectContentSession`。先提 Graph、Group、settings 与当前 editor-state 在同一作者文件事务发布。变量定义以 Graph 为准,settings 中的变量副本由序列化派生。视口状态可以单独保存,不会自动保存其他作者内容。外部改变已有作者文件或 settings 后,保存明确失败,不以自己的旧内容覆盖;不承诺跨进程多文件原子性。 -dirty 依据当前作者内容与最近成功保存内容比较,独立于历史位置。比较忽略 Group 查询字典的插入顺序、参数查询顺序和显示行号;保留实际 Graph/Entry 顺序与 JSON 值。保存失败不更新基线,保存期间产生的新编辑仍为 dirty。设置草稿也参与关闭脏检查。 +GUI 的 Repository 显式使用 Authoring 验证策略:Raw Group 可以保留未知类型/参数、缺必填值、无效参数类型或未完成条件表达式,Graph 分支条件也可保留未完成文本;保存不 Normalize、不补默认值。当前格式、稳定身份、严格 JSON shape、重复属性、Graph 拓扑、源路径/归属及外部冲突仍检查。仓库默认 Complete 供 CLI 严格写入使用,validate/group compile 和预览编译不因 GUI 草稿可保存而放宽。保存后继续撤销类型变更会恢复原作者 JSON,并重新成为未保存内容。 + +GUI 的 dirty 状态统一由 ProjectContentSession 依据当前作者内容与最近成功保存内容比较,独立于历史位置;History.IsDirty 查询同一会话,保存和关闭检查不依赖 Document 或 GalProject 的手工 dirty 标记。比较忽略 Group 查询字典的插入顺序、参数查询顺序和显示行号;保留实际 Graph/Entry 顺序与 JSON 值。保存失败不更新基线,保存期间产生的新编辑仍为 dirty。设置草稿也参与关闭脏检查。 关闭项目或应用时先挂起并等待当前编辑与保存队列,取消尚未开始的自动保存。若有未保存内容,提供 Save All、Discard Unsaved Content、Cancel;取消保持原项目与草稿,保存失败也保留会话。保存期间再次产生未保存内容时重新检查,不能保存旧捕获版后直接关闭。放弃只丢弃未保存内容;已经完成的结构写盘不反向撤销。 ## 结构计划与恢复材料 -`IProjectStructureChange` 分别对 Working 与 Saved 版本应用同一结构意图,允许删除/恢复保留各自的 Group 内容。`PublishStructureAsync` 只发布变换后的保存基线,内存仍保留原有未保存字段。后续保存再撤销路径变更时,变换应用于当时的基线,不用旧整份文件覆盖刚保存的其他字段。当前底层支持 Graph/Raw Group/settings 的小型计划,已验证 Group 创建、删除、路径变更及恢复;任意项目文件操作和其 GUI 入口尚未开放。 +`IProjectStructureChange` 分别对 Working 与 Saved 版本应用同一结构意图,允许删除/恢复保留各自的 Group 内容。`PublishStructureAsync` 只发布变换后的保存基线,内存仍保留原有未保存字段。后续保存再撤销路径变更时,变换应用于当时的基线,不用旧整份文件覆盖刚保存的其他字段。`IProjectFileStructureChange` 可附带普通文件、目录与大小写改名计划,与 Graph/Raw Group/settings 一起发布;项目文件界面与文本缓冲已接入。项目拥有的文件描述项使用稳定会话 ID,独立保存 path、存在状态和受管理文本字节;关闭视图不删除该状态。删除项的保留字节不参与当前 dirty 比较,但仍保留于历史/恢复材料,以支持分别恢复 Working/Saved。支持矩阵与引用规则见 [项目文件操作](editor-files.md)。 作者文件事务材料位于 `.galnet/editor/transactions//`:manifest 记录路径、前后内容哈希与 prepared/publishing/committed/repair-required 状态,编号 `.old`/`.new` 文件保留备份与新内容。失败逆序恢复;恢复失败保留目录。打开项目时检查未完成计划,只在现有内容匹配记录的旧/新哈希时恢复,发现外部内容则拒绝覆盖并报告材料位置。成功材料清理失败只记录并延后清理,不将已成功发布报告为保存失败。 作者恢复快照位于 `.galnet/editor/recovery/`,包含版本、时间、载荷及 SHA-256;`ProjectRecoveryStore.Read` 验证完整性。默认有变更时每 30 秒捕获,关闭前及事务失败时也捕获;仅保留最近 3 份。历史默认 200 步,已声明的持有数据和恢复快照各按可配置 1 GiB 软预算淘汰,最新完整快照即使单份超预算仍保留。淘汰历史或截断 redo 会释放该记录自有的 disposable 资源。它不是进程堆内存硬限制,也不是跨重启历史。 +删除记录为历史保留,路径复用后同一路径可以同时拥有删除身份与一个有效身份;保存期望按有效路径归并,不分别要求同一路径既存在又不存在。多个有效身份占用同一路径仍是冲突。 + ## 当前边界 -- Graph 基本节点、连线、名称与坐标已迁移为项目数据记录;Group 生命周期接入双版本结构计划,见 [Graph 基础编辑](editor-graph.md)。分支字段、条目和变量的旧监听记录继续在 P3/P5 迁移,不代表所有 Graph 交互已重写。 -- Group 的复杂/无效字段草稿关闭协调、未知字段和不完整作者文档保存仍在 P5/P6;目前完整契约校验保持不变。 +- Graph 基本节点、连线、名称与坐标已迁移为项目数据记录;Group 生命周期接入双版本结构计划,见 [Graph 基础编辑](editor-graph.md)。分支字段、Entry 类型/条件/参数及变量声明使用显式项目意图;Preview 只编辑运行时当前值,不补记声明历史。Entry 字段与新增条目历史不持有 Workspace/Group 页面,见 [Group 编辑](editor-groups.md)。 +- 已提交的 Group 作者 JSON 可在 P5e 保存重开,包括未知/不完整语义;Entry 表单中尚未转换成 JSON 的输入由项目会话保留,见下文;Timeline 关键帧行等专属工具仍使用各自的本地输入生命周期。损坏 JSON 和不安全结构不作为作者草稿写盘。 - 事务恢复可在打开时自动核对;作者快照提供材料及读取校验,尚无交互式快照还原页面。 - 原生 IME 候选输入及完整桌面人工操作未验收;已通过 Headless 实际控件的本地文本撤销、设置提交和关闭对话框验证。 + +## Entry 输入草稿与保存反馈 + +`EntryEditSession` 为项目 scope 拥有的输入会话,以 Group、Entry 稳定身份、来源代次和字段路径管理草稿。行内和 Inspector 创建各自控件,共享输入状态与现有作者提交意图。切条目、折叠、关闭面板、切语言或套用布局保留尚未提交的输入;Escape 或 Problems 中取消草稿明确丢弃它。源值、类型或列表结构改变后,旧草稿标记冲突,不写入新来源,也不因撤销而静默复活旧绑定。 + +项目内容变化时检查草稿来源;已经观察到的来源冲突不会因为撤销恢复了相同 JSON 而自动解除,需明确取消旧草稿后重新编辑。 + +Save All 经过现有草稿登记统一尝试提交;有效提交进入项目历史,不能转换、冲突或正在组合输入的草稿阻止此次写盘并保留输入。保存前已有的有效草稿提交可能已成为作者修改,保存失败不会伪装为这些修改从未发生。自动保存沿用相同规则,不弹出打断式对话框。未完成控件输入仅保留在当前项目会话,不承诺跨进程恢复。 + +状态栏分别显示保存状态、待处理输入和作者检查状态;保存成功不等于可以运行。发布期间的新编辑仍为未保存内容。Problems 检查当前作者快照并复用 Graph/Group/本地化规则;不完整分析与已过期结果明确显示,普通日志不靠解析字符串猜测跳转位置。 diff --git a/docs/spec/editor-scene-targets.md b/docs/spec/editor-scene-targets.md new file mode 100644 index 00000000..76a20829 --- /dev/null +++ b/docs/spec/editor-scene-targets.md @@ -0,0 +1,103 @@ +# Editor 场景目标查询 + +`Editor.Shared.Services.SceneTargetAnalysis` 对作者 Graph 和 Group 快照进行纯分析,不创建游戏、不执行 Entry,也不读取或修改玩家数据。项目 scope 的 `ProjectSceneTargets` 是场景状态查询入口,按当前内存作者快照复用一代分析,包含 Group 入口事实;修改、撤销、重做或拓扑改变后整体换代,保存相同内容复用原代。项目 scope 释放时清除缓存并使旧查询选择失效。场景分析仍为同步纯计算,没有增量前缀缓存;效果声明与已编译图集定义的资源读取和解析在后台准备,完成后发布不可变快照并推进查询代次。 + +普通查询位置是某个 Group 的某个稳定 Entry **执行前**,null 表示 Group 末尾;`AtTimeline` 查询指定 Timeline 的帧或事件前位置。目标来源为 Group ID、创建者稳定 Entry ID 和输出槽位组成的 `SceneLocator`,Timeline 事件创建者另有事件身份,内部解析到运行时 handle。返回对象种类、来源、资源提示、能力、唯一可知数值与存在性;候选的存在性只有一定存在、可能存在。没有入口或尚未分析到的位置返回不可分析结果,不伪造空场景已经完整分析。 + +所有可达 Graph 边参与保守合并,不求变量表达式的具体值:确定集合取交,可能集合取并;属性初值只有在所有输入都确定存在且数值相同时保留。空条件或字面量 true 执行,字面量 false 跳过,其他条件合并执行与跳过两路。循环工作队列求有限事实固定点;预算耗尽时只发布可能候选并标记不完整,不把未收敛状态当确定事实。 + +两条分支若手工复用同一底层身份却产生不同对象类型,合并后保留为可能的未解析目标,不宣称其中一种类型一定存在,也不提供推测出的属性能力。 + +当前支持: + +| 作者操作 | 分析行为 | +| --- | --- | +| layer.show / showColor | 按 handle 创建或替换来源,提供 Core 的变换/不透明度能力和可知初值;show 的有效 flipbook 另提供索引能力 | +| layer.hide / move / replace | 按层语义删除、更新变换、替换资源,保留实例身份 | +| particle.play / stop | 使用纯粒子定义解码和 Core 声明的 float 能力,创建/删除目标 | +| effect.apply / stop | 创建/删除效果目标,保留 Core progress,并从 program metadata 或 legacy catalog 增补支持的标量能力 | +| video.play / stop | 视频可能自然结束,创建后仅标可能存在;停止移除层候选 | +| animation.animate | 保留场景对象存在性,清除相关对象的唯一数值;额外提供可定位的播放候选 | +| animation.stop | 立即完成时移除播放候选;本轮结束后停止只保留可能候选,不推测当前显示值 | +| 已知对白、音频、变量等中立条目 | 保持场景候选事实 | +| timeline.play | 阻塞 Once 向后传播事件与轨道终态;非阻塞/循环保守保留入口、终态和中途创建者的可能集合 | +| 六种内置转场 | 复用内置契约的纯编译展开,按阻塞 Timeline 终态传播新层、删除旧层;临时遮罩/覆盖层在终态移除 | +| 自定义或未支持语义 | 已知目标降为可能,清除唯一数值并提示分析不完整;不执行它们获取结果 | + +候选缺失不清空既有轨道,无法匹配的引用显示为未解析对象。查询是作者辅助,没有改变编译或运行时的参数校验规则。 + +layer.show 的非空、有效 FlipbookDefinition 提供 Core 的 flipbook.index 属性及可写的原始浮点初值;静态图片、null、颜色层和视频不提供此能力。显式空对象使用 Core 的 1×1 默认定义。索引不取整,也不以 FrameCount 设置动画上限;格子显示的 floor/clamp 仍由渲染路径负责。无效布局保留基础层事实、移除索引能力并标记不完整;负值或非有限索引没有可写初值。无法解码的 JSON 使条目分析退回保守输入,不发布新层。 + +Move/Replace 保留已创建对象的 flipbook 能力,重新 Show 静态层移除;分支合并取共同属性能力,可能存在的对象不发布唯一数值。Timeline 事件创建、未来候选、帧内与终态采样使用同一能力,未来候选保留能力但不提供当前数值。源定义变更使旧行失效,既有轨道保留供作者修正。此分析只读取作者 JSON,不读取图片尺寸或编译效果程序。 + +内置转场只通过固定内置契约目录调用纯编译器,不加载或运行项目自定义条目。展开结果和无效输入按不可变快照代次缓存,条件在原条目边界合并;缺失旧层不阻止创建新层。无效参数保留保守输入事实并标记不完整,不发布部分展开对象。未知作者扩展不参与临时编译投影,原 JSON 保留,严格项目编译仍执行原校验。转场输出的 Locator 使用原 Group/Entry 及 newHandleId/toLayerHandleId 输出槽位,不使用编译生成事件或瞬态对象的身份。 + +## 效果能力与资源快照 + +非空 program 使用 shader 注释的 Core `ShaderEffectMetadataParser` 声明,优先于 legacy id;空 program 才使用宿主 `IEffectCatalog`。两种来源均检查目标 stage。Core `ShaderEffectAnimationProperties` 与渲染器共用转换,只增补 animatable 的 Float/Integer 标量,不把 bool、颜色、向量或纹理臆造为 float 轨道。 + +标量初值优先取 parameters 中的有限数值,其次取 metadata 默认值或 0,再按声明范围夹取。无效静态值保留属性能力但不发布唯一初值;重复参数同样保守处理。Core 自带的 progress 仍单独保留:EffectRequest 已提供初值 0,先于 shader 静态值/默认值;metadata 声明同名属性时使用与 Core 0..1 的共同范围,不能共同接受 0 时不显示可写初值。 + +同步查询只使用已经准备的资源事实,并排队后台准备;`PrepareAsync` 可等待资源就绪。准备直接读取 IGameFile 源字节,不借用 renderer decoder 缓存、不执行项目自定义代码、不编译 GPU shader。普通 effect.apply 及 Timeline 事件中的 program 引用共用快照;每个作者快照最多准备前 256 个不同 program,每份源码上限 1 MiB。错误类型、缺失、读取失败、坏 UTF-8/metadata 和超出预算均不推测 shader 能力;失败缓存至下一次资源刷新。 + +缺声明的效果仍具有已知身份和 Core 能力,按目标标记 CapabilitiesComplete=false。对外结果汇总当前能力完整性,内部组合查询仍保留语义完整性:停止此效果可以消除能力缺失,不会抹掉上游未知语义。条件、分支、Timeline 事件/未来候选和采样沿用同一声明;未来或可能对象不提供唯一当前数值。 + +资产目录 Changed 通知使资源快照失效并重新准备。完成、刷新和项目释放都推进查询代次;旧资源加载不能覆盖新代,旧 Locator 选择、属性行和手势不能提交。取消单个 PrepareAsync 调用只取消该调用方的等待;刷新或项目释放才取消共享加载。Timeline 订阅变化并更新属性菜单/行,既有轨道与未知作者 JSON 保留,查询和刷新不写内容历史。没有实时游戏预览。 + +## Timeline 帧内查询 + +`SceneTimelinePosition(Frame, BeforeEventIndex, IncludeFuture)` 描述第一次正向行程;事件按帧、再按作者数组原序处理。通常先应用截至当前帧的事件,再用共享 `TimelineSampler` 采样;事件前查询不执行该事件及同帧后续事件,也不猜测尚未采样的轨道属性值。事件索引是查询边界,不是 Locator 身份。 + +`TimelineEventCapabilities` 统一内置十种允许事件和参数规范化入口,契约校验与查询共同使用。缺目标操作跳过。未知或无效语义使分析不完整;Additive 与对象事件组合的基值不能证明时清除唯一值。非阻塞/循环后的剧情位置不能假定某个动画时刻,包含仅在中途创建又删除的可能对象;不模拟实时播放生命周期。 + +`IncludeFuture` 额外返回之后才创建的对象,标注创建帧、可能存在、尚未存在且没有当前值;不会将未来未知事件误用于降级当前已知存在性。事件 Locator 优先使用作者 `editorId`,缺省按输出槽位和声明输出身份定位,不补写作者 JSON、不依赖数组下标;重复显式事件身份视为不完整数据。查询有一万事件/关键帧预算。 + +Timeline 对象“+”选择器使用当前帧并包含未来候选,普通字段仍使用 Entry 前位置。新增属性的第 0 帧初值与当前帧值分别查询;中途事件改变后的值不会回填成第 0 帧初值。选择 lease 包含查询位置,资源标签/语言刷新保留该位置,切换帧使 Timeline 旧草稿失效。查询、搜索和选择对象本身不写作者内容。 + +Timeline 事件作者界面使用独立事件行,按帧和同帧作者顺序显示;事件与属性轨道共用播放头、缩放和横向位置。有限类型选择器只提供十种受支持事件,确认后在当前帧新增,同帧顺序在已有事件之后;显式新增生成 editorId 和创建者内部输出身份。 + +事件画布复用共享选择/框选/整帧拖动,多事件可以在同一帧,帧 0 事件也可以移动;不使用属性关键帧的碰撞替换或首键限制。拖动仅预览,松手把移动和需要的时长延长合成一次内容历史,负帧拒绝、Escape 取消。事件右键支持同帧提前/延后和批量删除,未知事件保留原 JSON,可移动和删除,禁止进入不受支持的表单。 + +事件参数弹窗通过 `DtoEditorHost` 使用同一个 `EditorRegistry`:整 DTO、成员及默认类型控件均可复用。Editor 外观由 Group 与事件弹窗共同引用 `AuthorFormStyles.axaml`;图片/效果程序成员复用资产选择控件,目标成员复用场景选择控件。输出身份隐藏;目标候选使用该事件执行前的位置,不包含同帧后续或未来创建者。表单修改先进入可取消草稿,点击应用才以单次历史 patch 参数;未改字段、未知扩展和作者大小写保留。帧/目标/相关作者版本改变或关闭后旧草稿不可提交,查询、打开、取消和语言切换均不写内容。实时预览仍不在范围内。 + +GUI 新增或切换为内置层、视频、粒子、效果创建条目时分配新的内部 GUID;animate 同时分配播放身份。六种内置转场分配输出新对象和播放身份,输入对象仍由作者选择。打开作者内容不会补写身份。 + +Group 条目粘贴先为批内创建者和 Timeline 播放/事件输出分配身份,再按已声明的引用槽位重映射批内引用。Graph 粘贴使用覆盖全部选中 Group 的同一批次映射,因此跨 Group 的对象、播放、Timeline 轨道/事件引用一起指向复制后的创建者。转场的输入引用仅在输入创建者也被复制时改写,转场输出和播放身份独立更新。外部引用、资源 ID、不相关 JSON 字符串、未知自定义条目的同名参数保留。重复粘贴生成不同身份,同次粘贴的身份随 Undo/Redo 保留。 + +转场表单的输入成员复用场景 Picker,只列出转场前的层候选;输出对象和播放身份复用共同的输出声明隐藏,不要求作者输入内部 ID。播放停止字段已接入下述播放实例 Locator;这不表示所有未来扩展字段都已隐藏内部身份。 + +## 图片差分图集事实 + +`variants.show` 创建 Layer 候选,来源槽位仍为 `handleId`。`SceneTarget.Resource` 表示实际 sprite;可选 `Variants` 单独记录可能的图集资源 ID、图集来源是否唯一以及可知的不可变选择结果。选择结果包含完整请求、保护维度和回退链,使用与运行时相同的 `ImageVariantsResolver`;查询不执行游戏。`variants.select` 更新可知的图片与选择事实,保留对象 Locator、存在性、连续属性能力和已知数值。普通 show/color/video/replace 清除图集身份;hide 移除对象,再次 show 使用新初始选择。 + +合并不同请求的分支时保留共同的图集来源,但不选择其中一条路径的请求;不同图集来源合并为可能 ID 集合,不提供唯一来源。条件性普通换图也会使图集模式仅为可能。此时后续 select 不推测实际图片,结果标记不完整;未知场景语义清除图集绑定。对象是否存在与图集来源是否唯一是独立事实。Timeline 可定位此前创建的图集 Layer 并采样原有动画属性,这不增加 Timeline 的差分切换事件类型。 + +项目查询仅异步读取编译 payload,不同步扫描作者目录;每代最多准备 256 个图集引用,每个 payload 最多 1 MiB,超出部分保持不完整。缺失、类型错误或坏定义缓存为失败,直到资源刷新。资源刷新或项目释放取消该代读取并使旧选择失效,迟到结果不能发布进新代;取消一个查询者的等待不取消项目共享准备。 + +GUI 的 `variants.show` 资源字段筛选 `image-variants`,变换复用既有控件;`variants.select` 对象字段复用场景 Picker,包含已知可能属于图集的 Layer,排除已知普通图层。来源不唯一的候选显示路径差异提示。创建者内部身份自动分配且隐藏,复制统一重映射 show/select 及 Timeline 轨道引用,不改资源 ID 和维度值。选择确认进入一条内容历史,撤销/重做及保存重开保留未知 JSON。 + +show 的初始选择按定义提供所有维度与全部值,空选项表示继承默认图的选择;修改一个维度以一次参数 patch 保留其余选择和未知键,JSON 修复入口继续可用。select 的维度和值跟随对象来源及当前维度联动,并保留手动文本入口;既有未知值不会因刷新自动清空。来源不唯一或定义未准备时不伪造确定选项。资源准备完成和语言变化刷新控件,旧控件的提交检查作者参数、当前资源结果代次与字段有效性。 + +项目已跟踪图集文本的工作版本提交或撤销/重做使资源查询换代;后台准备使用该次捕获的工作字节重建定义,不读取尚未提交的文本草稿。原始目录和图片事实沿用作者扫描规则,未知或失效的请求不因配置变化自动迁移。冷启动选择器等待同一作者快照和同一资源代次的准备完成后可更新结果快照;等待期间若发生作者修改、资源刷新或 scope 关闭则保持过期。详见 [图集作者流程](../manual/image-variants.md)。 + +## 播放实例 Locator + +`SceneTargetResult.Targets` 仍只包含场景对象;`Playbacks` 单独返回播放控制候选,复用 SceneTarget 数据和 Locator,但没有可动画属性或唯一数值。停止动画字段查询 Playbacks,Timeline 对象选择和属性树继续使用 Targets。两种查询共用同一项目作者快照、资源代次及过期保护,选择 lease 保留查询域,语言刷新不会把播放候选换成场景对象。 + +Animate 的来源槽位是原条目的 playbackHandleId;Timeline 的来源槽位是 timeline.playbackHandleId。非阻塞 Once、Loop 和 PingPong 在后续剧情位置只标可能存在,不推测自然完成、替换或并发时刻。阻塞完成的 Once 和零时长 Once 不新增后续候选。Timeline 内部第一行程的非终点可确定当前播放存在,终点和零时长保持可能;查询不是实时播放状态。 + +CompleteImmediately 停止移除候选,AfterIteration 只请求结束当前迭代,不能当作已经停止,也不会据此猜测对象终值。条件、Graph 分支/循环与分析预算沿用保守规则。重复播放身份指向不同创建者、或与场景身份冲突时不提供可解析的播放选择;空身份不自动生成,不修改作者 JSON。 + +animation.stop 的播放字段使用共享卡片选择器,显示本地化播放类型、原 Group/Entry 来源和存在性;支持本地化名称及原英文类型 ID 搜索,底层 handle 不展示。既有未解析引用原样保留,显式重选或清空才写入一条内容历史。复制时来源和停止引用继续使用统一身份重映射,其他字符串及未知字段不变。此切片不改变运行时播放或停止语义,也未完成所有缺目标路径的独立运行核验。 + +## 查询与选择控件 + +查询服务只接受剧情位置并返回场景事实,不拥有控件或弹窗。Editor 的 `SceneTargetCatalog` 将事实适配为资源名、类型、创建来源和存在性候选,并通过 Locator 解析内部 handle;`SceneTargetSelection` 捕获位置、候选身份与分析代次,提交前重新检查有效性。普通条目字段和 Timeline 都使用这一查询入口,筛选条件由调用方提供。 + +共享 Avalonia `ResourcePickerPanel` 提供搜索、卡片、可选缩略图、方向键高亮、Enter 确认和 Escape 取消;原 Assets Picker 与 Editor 的 `SceneTargetPicker` 均使用此面板。资产文件与场景创建实例是不同的数据源,场景中使用同一资源的两个对象分别显示。搜索无结果时清除当前选择,不会确认上次目标;语言切换不写作者数据。 + +内置 layer.hide/move/replace、video.stop、animation.animate 的目标,effect.apply 的目标层,以及 effect.stop、particle.stop 的实例字段通过 DTO 成员映射使用场景选择控件,不为所有字符串注册同一控件。创建者的内部输出身份从普通生成表单中隐藏,尚未解析的既有引用显示提示并保留原值;重选或清空才修改参数。effect.apply 的空目标显示“整个场景”。播放停止字段复用同一控件并使用独立的播放查询域;转场输入字段仅提供层候选。 + +选择器展示一定存在与可能存在两类候选;不完整分析显示提示,仍允许作者选择可能目标。控件自行管理展示和焦点,字段与 Timeline 各自保存结果;没有统一弹窗调度服务。普通字段确认是一条项目内容历史,候选查询、搜索、取消均不写内容。切换/删除条目、改变作者数据或关闭项目后,旧选择不能提交;保存相同作者内容不使选择失效。 + +验证记录见 [P6e1](../../features/F-20261015-02-editor-authoring-workspace/phase-6e1.md)、[P6e2b](../../features/F-20261015-02-editor-authoring-workspace/scene-locator-plan.md) 与 [P6e4a](../../features/F-20261015-02-editor-authoring-workspace/phase-6e4.md)。 diff --git a/docs/spec/editor-variables.md b/docs/spec/editor-variables.md new file mode 100644 index 00000000..d6081e45 --- /dev/null +++ b/docs/spec/editor-variables.md @@ -0,0 +1,27 @@ +# Editor 变量面板 + +“查看 → 变量”打开项目内唯一的变量 Dock 面板,可关闭、重开和浮动;不默认开启游戏预览。面板内以玩家变量/存档变量页签区分作用域。玩家变量跨游戏存档持久化,存档变量随游戏存档保存/恢复;此处只编辑声明和初始值,运行期当前值由 Preview 工具负责。 + +## 草稿与项目历史 + +左侧列出当前作用域的变量,右侧编辑名称、类型、默认值和说明。输入保留在面板草稿,点击应用才提交;还原草稿只重读当前声明,不产生历史。切换选中或作用域重新加载对应声明。外部声明刷新不静默覆盖同一选中项的未应用草稿,提交时按原声明快照检查冲突;发生冲突可还原后重新编辑。 + +新增、应用和删除各是一条项目历史,变量稳定 UID 不随改名/类型变化改变;关闭面板仍可撤销。Graph 的 playerVariables/saveVariables 为唯一作者来源,settings 中同名列表由保存派生,不由面板另外维护。GUI 声明修改统一通过 VariableAuthoringService,不依赖 Workspace 监听声明变化后补记历史。 + +IVariableDefinitionService 只提供保留稳定 UID 的独立声明快照和刷新通知,不提供声明写入或改名接口。Preview 显示只读名称、类型、默认值,并通过运行时变量服务编辑当前值;不提供声明新增、删除、排序、改名和默认值编辑。运行时变化更新 Preview 显示,不回写声明、不增加内容历史,也不影响作者内容的未保存状态。声明刷新后重建 Preview 行,已替换或隐藏的行不能继续提交当前值。 + +名称在两个作用域间唯一、区分大小写;使用 ASCII 字母或下划线开头,后续允许字母、数字和下划线。表达式关键字及 Gallery 生成名称保留。类型包括 Bool、Int、Float、String:切换类型把默认值草稿重置为相应零值;Bool 默认值使用 CheckBox,Int/Float 使用 NumericUpDown(步长分别为 1 和 0.1),String 使用 TextBox。数字输入使用固定小数点格式,应用时 Int 限 Int32,Float 必须有限,String 为无需 JSON 引号的原文。说明是作者内容,不翻译。非法输入不写作者文档,不追加历史。 + +## 引用检查 + +检查覆盖 Graph 选项/分支条件、当前项目全部作者 Group(包括未打开页面)的 Entry 条件,以及契约 schema 标注为 VariableName/Expression 的参数。 + +- 变量名字段匹配精确名称以及 player./save. 前缀,重命名保留原前缀。 +- 表达式先按与现有校验相同的 NCalc 语法检查,再按词法边界替换方括号参数与裸标识符;跳过引号和日期字面量、函数名与非支持限定表达式。不是全字符串替换。 +- 普通条目参数、选项文本、不能可靠处理的表达式只列候选位置,不自动修改。候选可能是普通文本出现同名片段,不承诺它确实是运行时引用。 + +重命名同时更新明确引用,定义及引用可一次完整撤销/重做。明确引用存在时禁止删除;候选不作为强制阻止条件,删除前可在引用列表查看;删除不自动修改候选文本。改名返回的候选会继续显示,供作者检查旧名称。引用位置目前为逻辑路径,不包含 P7 的完整 Problems 定位体验。 + +项目意图按稳定 UID/节点 ID/Entry ID 查找,不持有关闭的面板;修改前检查实际声明和引用,失败不移动历史游标。通知失败尝试恢复声明与引用,恢复再次失败进入项目 RepairRequired。已打开 Group 的条件、参数投影、变量候选及诊断在编辑和历史执行后刷新。四语覆盖面板、错误和历史标题。 + +变量面板实现与验证见 F-20261015-02 / P5c;声明/运行时边界与旧入口清理见 [Editor 状态精简](../../features/F-20261021-01-editor-state-simplification/summary.md)。CLI 变量重命名策略没有借此变为相同策略;后续统一入口时须另外处理。 diff --git a/docs/spec/entry-types.md b/docs/spec/entry-types.md index 896c7f90..e8995161 100644 --- a/docs/spec/entry-types.md +++ b/docs/spec/entry-types.md @@ -14,7 +14,7 @@ 参数值的公共基础位于 `GalNet.Core.Parameters`:`ValueDescriptor` 提供类型、null、约束及冻结的 JSON 配置,`ValueCodec` 负责序列化/反序列化/结构描述,`ValueValidation` 返回包含 code/path 的校验问题。Entry 的 `DynamicParameterDescriptor` 组合该描述,名称、必填和默认值仍归 Entry;CLI 可独立组合相同值描述。对象/数组通过 JSON/CLR 契约处理,不增加新的值容器。支持范围、字符长度、数组长度、enum/options;未知业务约束由契约自己的 validator 执行,结构描述不能替代完整代码校验。省略与显式 null 分开,根值规则不改变递归子类型;嵌入 schema 时需重定位其局部引用。 -项目集合来自独立契约程序集的 `GalNet.Project.Contracts.ProjectSchemas.Modules`;CLI 每次调用加载,Editor 在打开项目时加载并由项目 scope 持有,关闭/切换释放。相同名称 DLL 可声明不同集合,模块名可重复但完整 typeId 排他。工具加载共享 Core/内置契约类型身份,拒绝缺失依赖和不兼容版本;不存在默认内置 fallback。[ADR-0004](../adr/0004-project-entry-contract-assembly.md) 记录边界,命令见 [CLI README](../../src/Editor/GalNet.Editor.Headless/README.md)。 +项目集合来自独立契约程序集的 `GalNet.Project.Contracts.ProjectSchemas.Modules`;CLI 每次调用加载,Editor 在打开项目时加载并由项目 scope 持有,关闭/切换释放。相同名称 DLL 可声明不同集合,模块名可重复但完整 typeId 排他。工具加载共享 Core/内置契约类型身份,拒绝缺失依赖和不兼容版本;不存在默认内置 fallback。命令见 [CLI README](../../src/Editor/GalNet.Editor.Headless/README.md)。 单平台执行可在外层模块直接借用具体页面;Sample 的 `sample.screenshot(fileName?)` 契约无 Avalonia 依赖,Avalonia 模块直接捕获 GamePage 场景,自动写入宿主 Pictures 目录。它不属于全内置模块,Headless 只可编译,缺执行实现时拒绝运行。Normal/Quick 生成 PNG,CatchUp 不写文件;参数、生命周期与示例见 [Sample.Contracts](../../src/Samples/GalNet.Sample.Contracts/README.md)。 @@ -24,11 +24,13 @@ dialogue.text、nvl.append、layer.hide/replace、animation.stop、audio.seffect 其余带参数的内置 Primitive 也采用 DTO:layer.show/showColor/move、animation.animate/play、audio.play/enqueue/seffect.apply、video.play、effect.apply、particle.play/burst。audio.play/enqueue 共用 AudioPlayOptions,enqueue 仍显式选择 Enqueue 策略。嵌套 JSON 字段保留 JsonElement 与原解析规则;固定标量、布尔和枚举使用强类型属性。动画 from 的省略由规范化参数 presence 判断,batchId 仍由编译信封提取;快照/重放与复杂实例生命周期未改变。 +`animation.animate.property` 的内置选择包含 `time`,供 effect-program 元数据将该逻辑参数映射到 shader 时间 uniform;具体 program 仍需把对应参数标为可动画。 + DynamicParameterTable 的 ValidatePartialArguments 用于尚未补默认值的局部作者参数;可选回调省略时沿用完整 validator。ForDto 可显式提供 validatePartialArguments;字段自身校验照常执行,Normalize 的完整业务校验仍使用 ValidateArguments。内置视频/粒子/动画计划将完整业务规则保留在编译/执行边界,逐字段创作不要求其他尚未填写的字段。 所有 8 条内置 Composite 使用 ForDto 接收输入,普通 schema 类型/选项/未知字段检查由同一 Normalize 执行;业务展开、正数/颜色/变换校验保持显式。语音子指令及计划 Primitive 外层参数通过 ValueCodec.SerializeFields 使用对应子 DTO 的字段 codec;undefined JSON 字段省略,null 默认保留,可显式选择省略 null。黑白与自定义颜色过渡共用源基类的 12 个字段;生成器合并继承字段并拒绝隐藏/重写或 Order/ID 冲突。 -BuiltinEntryIds 集中声明 42 个内置稳定 wire ID。内置 Primitive 的作者类壳及取 ID 的泛型工厂已移除;catalog.Create 继续返回 Core 的通用作者表示,运行时依旧使用通用 PrimitiveEntry。项目仍显式选择 13 个独立 schema modules,不通过 CLR 类扫描启用指令。 +BuiltinEntryIds 集中声明 45 个内置稳定 wire ID。内置 Primitive 的作者类壳及取 ID 的泛型工厂已移除;catalog.Create 继续返回 Core 的通用作者表示,运行时依旧使用通用 PrimitiveEntry。项目仍显式选择 14 个独立 schema modules,不通过 CLR 类扫描启用指令。 Field.DefaultJson 声明经值 codec 校验的 JSON 默认,不能与属性初始化值同时声明;Choices 提供固定合法选项。StringBooleanValueConverter / StringEnumValueConverter 通过 IStringValueEncoding 保留字符串 wire 类型、选项及编辑文本,同时提供强类型成员;普通 converter 行为不改变。audio.stop 已用 AudioStopOptions,clearQueue 保持字符串 true/false 与显式 null 的旧行为。DefaultCompositeEntryBase.ForDto 提供类型化编译展开,共用 ValueCodec.DeserializeFields,源 JSON 保留 null/presence,输出仍通过目标 primitive 契约规范化。 @@ -72,7 +74,7 @@ transition.fadeColor ## 内置 primitive ID -`GalNet.Contracts.Builtins` 提供只依赖 Core 的作者契约;`GalNet.Primitives.Builtins` 提供运行时领域模块。`BuiltinSchemas.CreateAllModules()` 返回 13 个独立模块的只读列表,各组件也可通过 `CreateModule()` 单独选择;没有合成全内置大模块或内置专用 catalog 工厂。消费者使用 `new TargetProfileEntryCatalog(modules)`。 +`GalNet.Contracts.Builtins` 提供只依赖 Core 的作者契约;`GalNet.Primitives.Builtins` 提供运行时领域模块。`BuiltinSchemas.CreateAllModules()` 返回 14 个独立模块的只读列表,各组件也可通过 `CreateModule()` 单独选择;没有合成全内置大模块或内置专用 catalog 工厂。消费者使用 `new TargetProfileEntryCatalog(modules)`。 完整运行入口是 `GameRegistration.AddBuiltins`;也可单独调用 `AddBuiltinSchemas` 注册模块列表,再组合部分内置或自定义执行模块。内置 entry DTO、纯参数校验和 Composite 位于 `GalNet.Contracts.Builtins`,primitive instance 和执行 module 位于 `GalNet.Primitives.Builtins`;`GalNet.Core.Entry` 只包含通用基础设施。内容格式只保存下列稳定 `TypeId`,不保存 CLR 类型名。 @@ -82,23 +84,41 @@ transition.fadeColor dialogue.text, dialogue.textVoice, dialogue.show, dialogue.hide nvl.show, nvl.hide, nvl.clear, nvl.append, nvl.appendVoice layer.show, layer.showColor, layer.hide, layer.move, layer.replace -animation.animate, animation.play, animation.stop +variants.show, variants.select +animation.animate, animation.stop +timeline.play audio.play, audio.stop, audio.pause, audio.resume, audio.enqueue, audio.next, audio.clear audio.seffect.apply, audio.seffect.stop video.play, video.stop effect.apply, effect.stop particle.play, particle.stop flow.wait, variable.set, gallery.unlock +custom.invoke ``` `gallery.unlock` 的参数是正整数 `id`,其值必须是当前 `GalleryCatalog` 中存在的稳定 Gallery item ID。执行时写入 `player.gallery__unlocked = true`;Gallery item 从资源 `.meta.gallery[]` 聚合,发行运行读取其生成快照。旧的 category/sequence 参数不受支持。 +## 图片差分指令 + +`variants.show` / `variants.select` 属于既有 layers schema 与执行模块,内置模块数仍为 13,不增加第二张 Layer 状态表。 + +- `variants.show(handleId, assetId, selection?, transform?, z?, opacity?, displayMode?)`:assetId 指向已准备的 image-variants 资源,handleId 非空;selection 为维度 key 到字符串选项的对象,缺省 `{}`,不接受重复 key。以默认图的已有维度填初值,再应用显式选择;显式选择形成初次保护集合。transform 默认为 `{}`,支持与普通 Layer 一致的对象/嵌入 JSON 文本,要求有限值且缩放不低于 0.001;opacity 在 0–1 之间。 +- `variants.select(handleId, dimension, value)`:仅改该实例的一个维度;同值重选也以该维度作为本次保护维度。未知维度/值是输入错误,合法值但缺组合才执行回退。不存在或已退出图集模式的 handle 明确报错。 + +优先完整匹配,再按维度从后向前累计删除未保护项,按剩余的原位置精确匹配;删除不是通配符,也不压缩中间位置。全未命中时使用显式 default。请求选择不因回退丢失,例如 smile_skirt → weap → smile 时可回到 smile_skirt。 + +两条指令在 Dispatch 返回前完成逻辑状态提交,无指令内异步 I/O,保持非阻塞、不可跳过及 batchId。Normal/Quick 更新状态后通知普通 Layer presenter,CatchUp 仅更新同样的状态,由 StateRestoreSession 重建显示。宿主先异步准备 `ImageVariantsCatalog`,再通过 AddBuiltins/AddBuiltinImplementations/AddBuiltinLayers 的 imageVariants 参数注入;未注入时旧的普通图层仍可运行,但图集资源查询会失败。 + +select 保留同一个 Layer 对象、变换、显示模式、层级、透明度及活动连续属性动画。hide 删除完整实例;普通 layer.replace/show/showColor 或视频来源替换退出图集模式,再执行 select 会报错;重新 variants.show 按默认/初始选择重建实例。图集与 Color/IsVideo/Flipbook 互斥,没有增加连续差分动画轨道或专属切图淡化。编辑器提供 show 的资源/初始选择、select 的对象/维度/值联动表单,场景静态分析保留图集来源、请求和实际图片事实;来源不唯一时提供保守候选,见[图集手册](../manual/image-variants.md)。 + `nvl.show` 和 `nvl.hide` 只改变 NVL 覆盖层的可见性;`nvl.clear` 移除已累积的历史行;`nvl.append` 只接收长文本 `content`,以与 `dialogue.text` 相同的富文本和打字机语义追加一段,并在文字完成后等待一次玩家推进。它不会自动显示 NVL,剧情应显式调用 `nvl.show`。 这些名称和参数属于可选内置契约。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。 当前已实现 Runtime 行为的内置能力见 [runtime.md](runtime.md)。 +`custom.invoke` 属于独立的 custom schema 模块,参数 DTO 为 `CustomInvokeOptions`:`action` 和 `argument` 均为无默认值、不可为 null 的必填 string,action 额外拒绝空白;argument 可为空。参数内容不求值、不解析也不标为本地化文本。编译期不检查 action 注册或其参数语法。执行模块与模式策略见[通用 action 手册](../manual/custom-actions.md)。 + ## 视频图层 entry `video.play`: @@ -122,4 +142,6 @@ flow.wait, variable.set, gallery.unlock ## DTO 字段声明 -GenerateFieldSchema + Field 属性生成中立 FieldDescriptor 列表及 IFieldDto 成员访问,不生成平台控件。DefaultPrimitiveEntryBase.ForDto 使用 T.Schema 建表,并记录 FieldDataType,不提供 Editor DTO 工厂。运行时仍调度通用 PrimitiveEntry;执行模块可使用 PrimitiveRegistry.Register,注册时检查 DTO 类型,执行时用规范化参数和逐字段 ValueCodec 填充独立 DTO。契约运行时不依赖 Roslyn,生成器仅为构建工具。默认值通过完整 ValueCodec 投影;旧字符串文档不能无损表示的默认值不填入 Defaults,仍保留在字段描述中,避免 JSON null 字符串和字符串编码枚举被改写。具体支持范围和可复制示例见 development.md 的 DTO 字段生成章节。 +GenerateFieldSchema + Field 属性生成中立 FieldDescriptor 列表及 IFieldDto 成员访问,不生成平台控件。DefaultPrimitiveEntryBase.ForDto 使用 T.Schema 建表,并记录 FieldDataType,不提供 Editor DTO 工厂。运行时仍调度通用 PrimitiveEntry;执行模块可使用 PrimitiveRegistry.Register,注册时检查 DTO 类型,执行时用规范化参数和逐字段 ValueCodec 填充独立 DTO。契约运行时不依赖 Roslyn,生成器仅为构建工具。默认值通过完整 ValueCodec 投影;旧字符串文档不能无损表示的默认值不填入 Defaults,仍保留在字段描述中,避免 JSON null 字符串和字符串编码枚举被改写。具体接入方式见[定制 Avalonia Sample 手册](../manual/customize-avalonia-sample.md)。 + +Timeline 使用 TimelineOptions.Timeline 对应的 timeline 根参数(JsonElement),内容为 TimelineDefinition;播放接口为 PlayTimelineAsync。旧 animation.play ID 和 plan 根参数没有别名。动画轨道、键与事件分别使用 TimelineTrackDefinition、TimelineKeyframeDefinition、TimelineEventDefinition,纯采样入口为 TimelineSampler。 diff --git a/docs/spec/file-formats.md b/docs/spec/file-formats.md index 8f6cda2d..6fa0ee74 100644 --- a/docs/spec/file-formats.md +++ b/docs/spec/file-formats.md @@ -31,9 +31,9 @@ Project/ 图允许多个前驱汇合到同一目标;每个输出口最多一条边,禁止自连接和连接到 Entry。GUI 与 CLI 新建连接只替换同一输出口的旧边。 -变量声明的字段固定为 `name`、`defaultValue`、`type`,不随 JSON serializer 的命名策略变化;`defaultValue` 使用变量 converter 的 `Uid`、`Name`、`Type`、`Value`。编辑工具只接受当前源格式,不补稳定 ID、不从 settings 迁移变量。原始查询保留未知数据,当前类型化加载/保存仍拒绝未知字段/参数或不符合完整契约的内容;空字符串不会作为缺失参数删除。Editor 作者参数直接保存 JSON 值,保留复杂值、大整数、缺省与显式 null,不要求所有值先通过字符串表示往返。Group 源路径按 Graph 中的 file 原样保留,显示名改名不重建文件名。GUI 打开另要求显式且唯一的 Entry 根节点,结构/CLI 工具不因此统一限制为 GUI 入口规则。 +变量声明的字段固定为 `name`、`defaultValue`、`type`,以及可选字符串 `description`(作者说明,省略时为空),不随 JSON serializer 的命名策略变化;`defaultValue` 使用变量 converter 的 `Uid`、`Name`、`Type`、`Value`。编辑工具只接受当前源格式,不补稳定 ID、不从 settings 迁移变量。GUI 使用 Authoring 模式保留未知 Entry 类型/参数、不完整参数和条件表达式;原始 JSON 结构、信封字段和稳定身份必须有效,未知 Graph/Group 顶层或 Entry 信封字段仍拒绝。默认 Complete 仓库与 CLI 检查/编译继续要求完整契约有效;空字符串不会作为缺失参数删除。Editor 作者参数直接保存 JSON 值,保留复杂值、大整数、缺省与显式 null,不要求所有值先通过字符串表示往返。Group 源路径按 Graph 中的 file 原样保留,显示名改名不重建文件名。GUI 打开另要求显式且唯一的 Entry 根节点,结构/CLI 工具不因此统一限制为 GUI 入口规则。 -共享保存先准备并校验 Graph/Group 写入计划,再暂存全部目标文件后发布。删除 Group 节点并删除其 entries 时同步删除该源文件;保留 entries 时源文件成为孤立作者文件。同一 Group 变更 source path 时,新路径写入计划并删除旧源文件。GUI 保存全部将 settings 与 editor-state 合入同一计划,失败不更新作者基线。作者文件事务在 `.galnet/editor/transactions/` 持久化 manifest、前后哈希和备份,后续发布失败逆序恢复;恢复失败明确报告并保留材料。GUI 打开项目核对未完成计划,外部内容不匹配时停止恢复,不承诺断电或跨进程修改下的整体原子事务。周期作者恢复快照另存于 `.galnet/editor/recovery/`,不进入游戏内容或跨重启 Undo 栈。详见 [Editor 项目历史与保存](editor-history-and-save.md)。 +共享保存先准备并校验 Graph/Group 写入计划,再暂存全部目标文件后发布。删除 Group 节点并删除其 entries 时同步删除该源文件;保留 entries 时源文件成为孤立作者文件。同一 Group 变更 source path 时,新路径写入计划并删除旧源文件。GUI 保存全部将 settings、editor-state 和已跟踪的普通文本字节合入同一计划,失败不更新作者基线。普通文本描述项的 ID/路径/存在状态/字节属于 Editor 会话与恢复材料,不改变游戏源文件格式;结构操作对 Working/Saved 分别变换,文件页关闭不丢弃缓冲。作者文件事务在 `.galnet/editor/transactions/` 持久化 manifest、前后哈希和备份,后续发布失败逆序恢复;恢复失败明确报告并保留材料。GUI 打开项目核对未完成计划,外部内容不匹配时停止恢复,不承诺断电或跨进程修改下的整体原子事务。周期作者恢复快照另存于 `.galnet/editor/recovery/`,不进入游戏内容或跨重启 Undo 栈。普通文件事务也记录目录状态与大小写改名的中间路径;会话历史字节材料另存 `.galnet/editor/history/`,不进入发布包或跨重启 Undo 栈。详见 [Editor 项目历史与保存](editor-history-and-save.md) 和 [项目文件操作](editor-files.md)。 Compiled primitive 使用 `{ id, typeId, batchId, condition, arguments }` 信封。`arguments` 保存 JSON 值,不保存 CLR 类型名;当前 target profile 的冻结 schema 负责校验。Graph 与 Group 目前是特殊内容文件,不通过资源 GUID provider 查询。 diff --git a/docs/spec/i18n.md b/docs/spec/i18n.md index 668ec722..473f2b37 100644 --- a/docs/spec/i18n.md +++ b/docs/spec/i18n.md @@ -1,5 +1,7 @@ # 国际化 +Editor 的成员值本地化标记、作者索引、角色表/speaker、默认语言正文/分支编辑与复制身份、多语言表格、语言管理和 JSON 交换见 [Editor 游戏本地化基础](editor-game-localization.md)。普通表单填写开发语言原文,内部保存稳定 key;修改保留译文与复核指纹,复制生成独立 key。游戏本地化面板支持语言增序、开发语言缺译预览、单语言模板/运行文本导出及导入预览;完整项目校验和构建检查包内默认语言及角色,未能分析的自定义范围明确报告不完整。 + ## 两条边界 编辑器 UI 与游戏文本使用不同的边界。 @@ -18,7 +20,7 @@ Editor 的 `LocalizeExtension` 对静态 key 显式绑定 `CurrentCulture`,再 展示边界使用 `EditorText` 保存 key 与参数,在显示时翻译。历史保留原始描述,通过显式的内置描述映射提供动态文案;未知扩展描述原样显示。Shared 的已识别操作错误使用 `EditorOperationException` 携带稳定 code、参数和原始诊断,由 Editor 映射到 `Error.`;Shared 不依赖 UI 翻译框架。未知异常显示本地化失败提示及原始技术细节。作者内容、技术 ID、路径及原始日志不翻译。 -当前覆盖启动/新建项目、项目和编辑器设置、关闭提示、基础 Graph/Inspector、历史、保存恢复状态和相关错误。分支批量交互、项目文件界面、Group/schema 表单随相应创作工作区阶段继续接入;游戏预览另行实现。四语资源的 key、非空文本、格式语法和占位符一致性,以及真实控件切换和数值保存由 Editor 测试验证。 +当前覆盖启动/新建项目、项目和编辑器设置、关闭提示、Graph/Inspector、分支操作、项目文件面板/文本页、复制粘贴与删除反馈、历史、保存恢复状态和相关错误。四语资源保持相同的 key 集合;分支历史按目标名称、粘贴历史按节点数量、文件历史按路径动态格式化。Group 类型选择器/搜索、顶部操作、批量历史、剪贴板菜单与动态错误反馈已接入,统一变量面板、草稿错误和声明历史已接入;Entry 类型/条件/参数历史及过期/冲突错误已本地化,未知类型/参数的作者保留提示支持四语;普通复杂值表单的增删重排、缺省/null/default/草稿恢复已接入四语;Timeline Dock 状态、创建、帧和值输入提示、对象来源/存在性、属性树、轨道创建、画布操作、时间编辑、关键帧错误、场景对象候选/过期提示和选择对象/添加属性重载/右键取消重载、关键帧菜单/精确输入弹窗、内联值曲线、切线模式和单位已接入四语;其余专属表单随相应创作工作区阶段继续接入;游戏预览另行实现。四语资源的 key、非空文本、格式语法和占位符一致性,以及真实控件切换和数值保存由 Editor 测试验证。 ## 键与稳定性 diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 752d2306..209049fe 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -46,6 +46,24 @@ Engine 同时支持同步和异步释放;异步宿主优先使用 `await using ## Batch 与活动实例 +### 可选调试会话 + +宿主显式 `AttachDebugging(GameDebugOptions)` 获取借用的 DebugSession。查询与变量修改经过同一执行门,返回独立 DTO;提交按执行代次、作用域、UID、类型与原值重查,数值解析使用固定文化与有限 Float。Pause 阻止玩家/自动/自然继续,Choice 已产生的结果可延后到 Resume;不表示媒体时钟完整冻结。 + +调试会话每 Group 保存最近自然入口的单份逻辑场景,不存变量或执行任务。指定路线定位验证真实后继与模块 Scene/Ignore 能力,恢复场景根并无条件追帧到目标前,保留当前变量且不重复写已读;普通读档清空入口缓存并推进代次,调试重建不覆盖基线。Stop/读档会取消旧定位,宿主先停止 Engine,再释放调试 binding。查询/追帧/通信尚未由 Runtime 自动启用。 + +入口缓存默认 LRU 限制 256 个 Group、64 MiB 序列化 payload,`MaxEntranceGroups/MaxEntrancePayloadBytes` 可调整,0 关闭;初始运行场景单独保留。查询包括 payload 预算和最近追帧耗时/指令数;不预跑任意插件或授予未经证明的固定场景。前跳从活动实例 CatchUp 收束后的场景捕获基线;回跳从入口捕获场景重建。 + +`GameDebugSpan` 表示匹配作者映射提供的 primitive 半开范围。断点在范围起点前,单步不跨出范围且保留正常阻塞/Skip;定位的 `TargetEnd` 指定一次目标条件豁免范围,离开后恢复普通条件。Engine 不解析作者 ID 或网络协议。`InitialScene` 是调用方显式选择的独立测试起点,不自动把不可达位置视为正常剧情路线。 + +Runtime 本地变量表归一化作用域前缀;向 IVariableService 回写已有变量时按 UID 保留桥接服务中的原键,避免同一 Player 系统变量生成两个存储别名。默认 Avalonia 运行停止/释放后刷新玩家变量;Save 仍由正常保存链写入。 + +### 追帧到当前 Group 末尾 + +`GameEngine.SkipToGroupEndAsync(bool ignoreConditions = false, CancellationToken cancellationToken = default)` 复用现有 CatchUp 展示压缩与清理机制,消费当前 Group 的剩余条目,不受未读文本限制。默认执行条件和逻辑写入;显式忽略条件只绕过 Entry 条件,不跳过变量赋值或参数/资源校验。 + +完成时游标仍在当前 Group、EntryIndex 等于条目数,Manual 模式,不执行出边或呈现后继 Choice;下一次显式 Advance 才离开。没有运行中的 Group 时不做操作。异步展示先清理,最终场景同步完成后任务返回;不支持 CatchUp 的阻塞扩展仍等待其自然完成,可通过 CancelCatchUp/停止取消。相同追帧请求共享正在执行的任务,不同追帧目标或策略同时请求明确拒绝。此能力不增加玩家控制接口或 Sample 按钮,原 SkipToUnread 行为保持。 + `PrimitiveEntry.BatchId` 是编译后的可选局部分组字段。GameView 创建实例时把它原样传入 `PrimitiveInstance.BatchId`,Engine 实际按 `(GroupExecutionId, BatchId)` 匹配 skip 批次: - 同一次 Group 执行内,同 batch 的 blocking 与 non-blocking 实例可以一起 skip。 @@ -83,7 +101,9 @@ Choice 节点由 Engine 求值并过滤可见选项,再通过 `IChoicePresente | `State` / `SceneInstances` | 按注册类型/ID 存储的 GameState,以及其派生场景句柄索引与临时执行控制 | | 变量存储 | Player / Save 作用域变量与表达式求值 | -`GameSnapshot` 当前版本为 4,旧版本直接拒绝,不提供迁移兼容。它包含 `NodeId`、`EntryIndex`、Save 变量与 `States`:稳定 StateId → section version / JSON payload。模块组合时显式注册纯数据表与恢复处理器,未注册类型、重复 ID、缺失/未知 section、版本不符及无效载荷明确报错。每份存档写出全部注册表,包括空表;不写 CLR 类型名、Task 或后端对象。 +`GameSnapshot` 当前版本为 4,旧版本直接拒绝,不提供迁移兼容。它包含 `NodeId`、`EntryIndex`、Save 变量与 `States`:稳定 StateId → section version / JSON payload。模块组合时显式注册纯数据表与恢复处理器,未注册类型、重复 ID、缺失/未知 section、版本不符及无效载荷明确报错。builtin.animations 当前 section version 为 2,LoopAnimationState 保存 Request 或 Timeline;builtin.layers 当前 section version 也为 2,新增可选的 LayerVariantsState;两者均明确拒绝旧 v1 section。PrimitiveRegistry.State 的 version 参数传递到 Core 状态契约,其他模块默认仍为 1。每份存档写出全部注册表,包括空表;不写 CLR 类型名、Task 或后端对象。 + +差分 Layer 保存图集资源 ID、完整请求、最后保护集合、实际变体键和解析原因,实际 sprite ID 仍以 Layer.AssetId 为真源。解码阶段检查来源互斥与状态结构,模块的同步 ValidateState 使用会话已准备的不可变 ImageVariantsCatalog 重新解析,核对实际变体/图片/原因,再允许替换状态根;内容版本变更导致不一致时拒绝读档。展示恢复还会在呈现前复核内容。定义异步加载在会话准备阶段完成,show/select 本身同步,无需改变 Engine 读档协议。恢复后继续选择仍保留曾因缺组合而未显示的服装等意图。 `CreateSaveData()` 返回 Engine 保存的最后稳定快照。状态表编码共享短同步边界,JSON 载荷和变量与运行对象隔离;不再由中央代码逐类型复制字段。读档先解码/校验候选状态与模块规则,再取消旧 scope 并替换整个 GameState/SceneInstances。旧回调保留旧状态根,不能清理新代。展示恢复成功前暂停推进,恢复失败进入 Faulted。 @@ -100,7 +120,7 @@ Save scope 随存档槽保存和恢复;Player scope 由宿主变量服务独 动画按稳定语义存档,而不是保存展示层播放游标: - `Once` 的 non-blocking 动画在分发时提交末值,读档直接显示末值; -- `Loop` / `PingPong` 保持第 0 帧逻辑状态,并在 `LoopAnimationState` 表保存类型化 request 或 plan,读档后从第 0 帧重新播放; +- `Loop` / `PingPong` 保持第 0 帧逻辑状态,并在 `LoopAnimationState` 表保存类型化 request 或 timeline,读档后从第 0 帧重新播放; - blocking 动画运行期间不会产生新的稳定快照,因此不保存其中间帧; - 循环粒子属性动画保存 emitter、初始属性值与动画定义,不保存单颗粒子、渲染资源或时钟。 @@ -159,20 +179,21 @@ Engine 不拥有 view。宿主先释放 Engine,再 `await view.DisposeAsync()` - `dialogue.text`:对话与打字机阶段,支持 `\skip` 分段跳过;`dialogue.show` / `dialogue.hide` 控制显示状态。 - `nvl.show` / `nvl.hide` / `nvl.clear`:分别显示、隐藏和清空 NVL 历史;`nvl.append` 以相同的富文本和打字机语义追加一行,完成打字后等待玩家推进。NVL 不会因 append 自动显示。 - `layer.*`:show/showColor/hide/move/replace,先更新 `GameState` 的 Layer 表再通知 `ILayerPresenter`。 -- `animation.animate` / `animation.play` / `animation.stop`:创建 animation primitive instance;一次性动画先提交最终逻辑状态,循环动画提交第 0 帧状态并保存完整重放定义,再启动呈现动画。plan 是单个 instance,内部事件只作为 animation 模块私有 layer/effect 事件处理,不重新进入通用 Entry 分发。 -- `effect.apply` / `effect.stop`:维护唯一的 EffectInstance 数据表和 `IEffectPresenter` 调用;动画直接修改同一记录,不维护 ActiveEffectState 镜像。 +- `animation.animate` / `timeline.play` / `animation.stop`:创建 animation primitive instance;一次性动画先提交最终逻辑状态,循环动画提交第 0 帧状态并保存完整重放定义,再启动呈现动画。timeline 是单个 instance,内部事件只作为 animation 模块私有 layer/effect 事件处理,不重新进入通用 Entry 分发。 +- `effect.apply` / `effect.stop`:维护唯一的 EffectInstance 数据表和 `IEffectPresenter` 调用;动画直接修改同一记录,不维护 ActiveEffectState 镜像。Skia effect-program 的 `@gal.effect` 元数据声明可动画参数,动画使用其逻辑参数名;`time` 可映射到常见 shader 时间 uniform。 - `audio.play` / `audio.stop` / `audio.pause` / `audio.resume` / `audio.enqueue` / `audio.next` / `audio.clear`:`AudioRuntime` 持有轨道队列与 voice 生命周期,`IAudioPlaybackHost` 只执行平台播放请求。`dialogue.textVoice` 与 `nvl.appendVoice` 会先发出非阻塞的 `audio.play(replaceIfDifferent)`,默认使用 `vocal` 轨道,再按正常文本语义阻塞。 - `audio.seffect.apply` / `audio.seffect.stop`:以 `trackId` 和 `instanceId` 附着或移除宿主 catalog 已注册的轨道 SEffect;当前 Avalonia host 支持 `seffect.gain`、基于 equalizer 频段近似的 `seffect.lowPass` 与 `seffect.highPass`。未知 ID、非法参数或不支持 scope 只记录诊断,不会静默附着。 - `particle.play` / `particle.stop`:用 `rate` 创建和停止持续 emitter,维护唯一的 ParticleEmitterInstance 数据表,场景句柄查找引用同一记录。存档只保存 emitter 定义、排序与动画值;宿主读档后通过模块注册的恢复处理器 以相同 seed 从头重建,不保存单颗粒子。`initial` 的 lifetime、velocity、size、rotation、angular velocity、color 以及 `flipbook` 的播放速度与起始帧都按粒子确定性采样;`motion` 提供 gravity、noise、drag、radial、orbit、attractor,`lifetime` 曲线线性插值 size、opacity、velocity、rotation、color。当前粒子年龄、位置和 flipbook 帧不单独存档。 -- `particle.burst`:用 `count` 向 `IParticlePresenter` 发出一次性 burst,不创建 Runtime scene state,也不参与存档恢复。有限持续效果由流程或 animation plan 编排 play/stop;重复 burst 由 animation plan 事件表达,跳过 plan 时不补发未来 burst。 +- `particle.burst`:用 `count` 向 `IParticlePresenter` 发出一次性 burst,不创建 Runtime scene state,也不参与存档恢复。有限持续效果由流程或 animation timeline 编排 play/stop;重复 burst 由 animation timeline 事件表达,跳过 timeline 时不补发未来 burst。 - `video.play` / `video.stop`:视频是 Runtime Layer,`IVideoPresenter` 将平台解码帧送入场景管线;阻塞时每次推进跳到当前播放位置之后的下一时间戳,跳转期间保持阻塞,结束后继续剧情。稳定存档只保存视频图层定义,非阻塞视频恢复时从零播放;播放 identity 和游标不入存档。 - `flow.wait`:blocking、skippable 的等待实例。 - `variable.set`:求值后写入 Runtime 变量。 - `gallery.unlock`:按稳定 item ID 解锁 Gallery,写入对应的系统 Player bool。 +- `custom.invoke`:`BuiltinCustomActionModule` 按 ordinal action ID 查注册的实例工厂,原样传入 argument。注册在 view 组合时冻结,重复/空白 ID 拒绝。`RegisterImmediate` 同步非阻塞;`RegisterAsync` 支持运行 scope/模块生命周期联合取消和可选阻塞,并通过 PrimitiveTaskScope 在异步释放时取消、排空任务和聚合错误。两种便利入口 Normal 调 execute,Quick 调 quick 或回退 execute,CatchUp 调 catchUp 或空操作;运行中模式切换不重启委托。`RegisterFactory` 返回的独立实例自行管理完整模式/跳过/完成和资源清理语义。未匹配时所有模式 warning 后同步完成,可配置严格缺失报错;处理器自身失败正常传播。模块不自动注册持久状态,也不序列化委托或资源。接入见[通用 action](../manual/custom-actions.md)。 视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由内置模块实现。玩家音量/静音按 master × bus × 内容 gain × 轨道默认 gain 合成并应用到当前及后续 voice;item effect 和音频参数动画仍未实现。AudioTrackState 保存轨道当前播放项、内容 gain、暂停状态与有序队列;AudioSEffectState 单独保存轨道效果,空闲时仍保留并作用于后续 voice。所有仍存在的轨道状态按检查点恢复,包含 SFX/Vocal;活动内容从头开始,不恢复游标和淡化时钟。暂停轨道在 Resume 前不启动播放器。恢复重新读取项目轨道配置并应用当前玩家音量;正常结束先更新逻辑状态和队列,后端身份/ticket 不入存档。Gallery 的平台无关 catalog 与解锁数据层由 Core/Runtime 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 -动画 plan 的事件与轨道共用 presenter 的播放时间源。`PlayAnimationPlanAsync` 必须在采样前 await `beforeFrame(elapsedFrames)`,正常完成前提供末帧;Loop/PingPong 的 elapsedFrames 单调增加,不在循环时归零。跳过不补发未来 particle burst。 +动画 timeline 的事件与轨道共用 presenter 的播放时间源。`PlayTimelineAsync` 必须在采样前 await `beforeFrame(elapsedFrames)`,正常完成前提供末帧;Loop/PingPong 的 elapsedFrames 单调增加,不在循环时归零。跳过不补发未来 particle burst。 音频后端请求按 voice 顺序提交,Stop 即刻取消尚未完成的 Start;异步销毁等待排队请求结束。Avalonia host 先登记待加载音频,停止/销毁取消加载,等待加载结束后释放 LibVLC 与临时目录。读档停止旧 scope 音频,模块恢复时重新生成后端 identity。旧 scope Reset 与延迟终态事件不能影响新代。Headless 仍保留轨道/效果逻辑数据;缺少 host 时流程 ticket 完成。 diff --git a/features/F-20261015-02-editor-authoring-workspace/complex-editor-design.md b/features/F-20261015-02-editor-authoring-workspace/complex-editor-design.md new file mode 100644 index 00000000..f87c00bc --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/complex-editor-design.md @@ -0,0 +1,258 @@ +# 复杂参数定制与对象树时间轴 + +最新顺序调整:通用场景目标定位器及 Timeline“+”/更换对象入口提前为 P6e2b,先于曲线编辑。选择器、普通 DTO 字段和 Timeline 共享边界及新增协议见 [scene-locator-plan.md](scene-locator-plan.md);当前仅具备查询与内部 Locator 数据,不表示选择 UI 已完成。 + +P6e2 后续时间设置与轨道模式采用 [Timeline 交互简化](timeline-simplification.md):直接确认结束帧/FPS,保留帧号,不提供裁剪/保秒模式;模式持续可改、跨结尾拖动自动延长。下文旧时间操作候选以此修订为准。 + +日期:2026-10-18。状态:部分实施,P6c 已完成命名与契约,见 [phase-6c.md](phase-6c.md);P6d 已验证 Dock、活动选择与已有轨道数值编辑,见 [phase-6d.md](phase-6d.md);P6e1 已验证基础场景查询、对象/属性树和轨道创建,见 [phase-6e1.md](phase-6e1.md)。P6e2 已验证关键帧画布、选择/移动、缩放滚动与时间编辑,见 [phase-6e2.md](phase-6e2.md)。专属曲线工具和 Timeline 事件语义尚未实现。归属 F-20261015-02 / P6 后续,扩展 [参数控件与场景目标设计](parameter-editor-design.md) 第 5–8 节;三层注册与状态查询的总体职责仍以该文为准。 + +## 1. 用户要求与本轮选择 + +用户要求复杂定制参考 Unity 类动画编辑交互:时间轴左侧树形结构以场景对象为父节点、对象支持的属性为子节点;可以选择属性加入动画控制,节点能够编辑值和切线,并展开为值曲线。此处按用户描述设计交互,不承诺复制 Unity 全部功能。 + +“作为轴重载”按属性加入当前动画的控制范围理解:未加入时不生成轨道;加入后由轨道控制该属性。它与运行时 Replace/Additive 混合方式分开。后者是轨道设置,不是第二套同属性覆盖轨道;当前运行时要求同一目标和属性在一个 Plan 内唯一。 + +用户随后将近期工具明确收敛为两个:先实现 Timeline 对象树时间轴,另一个是 animate 自定义曲线编辑器。Timeline 包括对象/属性轨道、关键帧、Hermite 曲线、事件、纯数值预览与项目历史;animate 自定义曲线单列后续切片。完整游戏场景预览、录制游戏对象操作、嵌套动画片段、音视频剪辑、多层动画混合不在首版范围。下文颜色、变换、粒子与 effect 参数只保留扩展接点,不作为本轮额外专业工具交付目标。 + +## 2. 已核对的代码事实 + +| 事实 | 设计影响 | +| --- | --- | +| P6a/P6b 已有 RegisterDto/RegisterMember/RegisterDefault、DtoEditorHost、SchemaForm、lease 与复杂值 JSON 回退 | 继续在三层映射上组合,不增加另一套编辑器注册体系 | +| AnimationPlanOptions.Plan 是 JsonElement,BatchId 是另一个字段 | 对 Plan 注册成员覆盖即可;BatchId 继续自动表单 | +| Core 的 AnimationPlanDefinition 已有 FrameRate、DurationFrames、Tracks、Events、LoopMode、Blocking、Skippable | 时间轴编辑现有计划,不另造编译时动画语言 | +| AnimationTrackDefinition 是 HandleId + Property + BlendMode + Keys | 对象父行由轨道分组投影产生,默认不增加运行时对象树 | +| Key.Frame 是整数,Value/InTangent/OutTangent 是 float,插值为 Step/Linear/CubicHermite,插值属于当前键之后的区间 | 编辑数据按帧保存;不能把任意加权 Bezier 或无限斜率直接塞进现有格式 | +| AnimationTrackSampler.Evaluate 是现有纯采样器 | 曲线显示和数值预览复用它,避免两套插值结果 | +| 完整校验要求首键在 0 帧、键严格递增、不得超过时长、同目标属性轨道唯一、浮点数有限、FPS 为 1..240 | 明确处理首键移动、碰撞、时长缩短和切线输入 | +| 内置 Plan 事件只接受图层显示/隐藏/移动/替换、effect 应用/停止、粒子启动/停止/burst 等限定类型 | 事件选择器使用明确能力清单,不能展示任意 catalog Entry | +| Plan 事件有同帧原数组顺序;运行时在采样前通知相应事件 | 事件排序与定位必须保留原序,同帧对象创建和动画采样要一致 | +| Editor 只有字段/DTO patch 提交与部分 Preview binding,没有通用拖动事务或作者场景查询 | 这两部分是待实现依赖,不能把现有接口改名当作完成 | + +主要证据:[Plan/采样器](../../src/Shared/GalNet.Core/Scene/Timeline.cs)、[契约 DTO](../../src/Shared/GalNet.Contracts.Builtins/Entry/ComplexPrimitiveOptions.cs)、[完整校验](../../src/Shared/GalNet.Contracts.Builtins/BuiltinArgumentValidation.cs)、[事件调度](../../src/Shared/GalNet.Primitives.Builtins/TimelinePrimitiveInstance.cs)、[当前表单协议](../../src/Presentation/GalNet.Avalonia.Controls/Forms/FieldBinding.cs)。这是静态代码核对,不是新增功能验证。 + +## 3. 界面与树形轨道 + +时间轴是独立 Timeline Dock,默认与资源/日志在底部同一区域,以同组标签页停靠,可拖动/浮动;通过视图菜单重新打开。Group 的 Plan 字段保留紧凑摘要和“编辑时间轴”入口,只激活该 Dock,不在每个 Entry 中嵌入完整画布。详情区继续复用 DTO 表单。 + +Timeline Dock 为项目会话内单实例,直接跟随当前选中的 Entry,不提供锁定/固定旧目标模式。目标用 Group 文档身份、Entry StableId 和字段 ID 定位,标题/面包屑显示 Group 与条目来源;切换普通 Entry 也必须切换面板状态,不能继续编辑上一个 Timeline。 + +| 当前选择 | 面板行为 | +| --- | --- | +| Timeline Entry | 绑定该条目并显示编辑器;未创建内容时显示明确的新建入口,不因选择自动写默认值 | +| 其他类型 Entry | 解除旧条目编辑绑定,显示“当前条目不支持时间轴编辑”,隐藏画布或显示禁用占位 | +| 没有选中 Entry | 显示“请选择时间轴条目”,编辑操作不可用 | +| Timeline 内容无效/契约暂不可用 | 仍定位当前条目,显示诊断和可用修复/JSON 入口,不伪造空时间轴覆盖原内容 | + +多选时使用 Group 列表的活动项(SelectedEntry),不按类型从集合中筛选一个 Timeline,也不混合编辑多个条目。项目级选择上下文保存 Group/Entry 稳定身份,只在条目选择或活动 Group 改变时更新;点击 Timeline Dock 自身不会清除选中条目。跨 Group 选择、切类型、删除/取消选择和项目关闭均立即更新状态,过期异步结果不得重新绑定旧条目。 + +切换前先按统一草稿协议结束当前输入/取消未完成拖动,再释放旧字段租约。所有提交捕获原 Entry/字段/版本,不能在 await 后查询“当前选中项”作为提交目标。选择变化与不可编辑提示不产生作者历史,切回已提交条目恢复其作者内容。关闭 Group 页面不丢已提交内容;若它拥有当前选择则清除选择并进入无选择状态。Dock 从项目作者服务创建自己的 binding,不能借用随 Group 页面释放的参数 binding。关闭 Dock 释放绘图/订阅并协调草稿,项目历史依旧可用。 + +```text +播放/暂停 上一键 下一键 当前帧/秒 FPS 时长 吸附 [关键帧 | 曲线] +对象与属性树 当前值 时间刻度 / 播放头 +▾ 左侧角色 ◇ ◇ ← 汇总 + ▾ 位置 (仅分组) + ☑ X [120] ◆────────◆ ← 属性轨道 + ☑ Y [40] ◆────◆───◆ + ☑ 不透明度 [0.8] ◆────────◆ +▾ 背景 + ☑ 不透明度 ◆────◆ +▾ 事件 ▣ ▣ ← 独立事件行 +选中关键帧 / 轨道 / 事件的详情(默认表单 + 必要专属控件) +``` + +对象行展示友好名称、类型/缩略图、一定存在或可能存在;不显示 handle。同名对象加来源 Group/创建条目说明。位置/旋转等中间节点只是显示分组,只有运行时实际声明可动画的叶属性可加入轨道,不能凭 DTO 的所有数值字段推测动画能力。 + +添加对象后,从支持属性中搜索/勾选要加入的属性;可以切换“显示可添加属性”,默认树主要展示已加入的轨道。属性勾选代表已创作轨道,取消勾选是明确移除轨道的一条可撤销操作;单纯隐藏曲线、折叠或筛选不删除数据。编辑未加入属性的当前值也可以显式建立轨道,规则见下节。空父对象可保留为本地视图状态,保存不写入空运行时对象组。 + +一个属性只能有一条轨道。Replace/Additive 使用现有 BlendMode,界面显示“绝对值/叠加偏移”等可理解文案。没有运行时 mute/solo 字段,首版的曲线显示开关只影响视图,不能暗示关闭了游戏播放。 + +对象/分组父行的菱形是子轨道同帧键的汇总;点击可选择这些键。首版拖动在叶行完成,父行汇总只用于定位与选择,避免折叠时无意批量修改。左树和右侧时间区域共用行布局、纵向滚动及折叠状态;横向缩放/滚动只影响时间区域。 + +多对象选择和曲线可见性属于视图状态,不进内容历史。键盘操作遵守输入控件焦点:Space 仅在时间轴画布播放/暂停,文本和 NumericUpDown 保留自己的输入;Delete 删除所选键/事件,Ctrl+Z/Redo 路由到项目历史,不能单独维护时间轴历史。 + +## 4. 关键帧与值曲线 + +关键帧模式以菱形编辑帧位置,曲线模式以值对帧绘图;两者使用同一轨道和选择集合。曲线模式可以显示多条选中属性曲线,各有颜色、图例和可见开关;默认按属性单独缩放 Y,允许显式叠加并提示单位不同。鼠标悬浮/播放头显示当前帧及采样值。 + +选中键后详情显示帧、秒、值、出段插值、入/出切线。数字用 NumericUpDown,bool 用 CheckBox;枚举选择器支持搜索。画布拖动和数值输入相互同步,批量选择中不相同的字段显示混合值,修改后对选中集合原子应用。 + +### 4.1 叶节点当前值与自动写键 + +每个属性叶节点右侧常驻当前播放头的值编辑框;有轨道时显示采样值,并用实心/空心键标记区分当前位置已有键或仅为插值。未加入轨道时展示可确定的基准值,无法确定时显示空值提示而不是虚构 0。首版 Timeline 轨道仍为 float,因此这些叶节点使用 NumericUpDown。 + +用户确认输入或完成一次数值调节时,按捕获的属性与当前整数帧执行 upsert:该帧已有键,替换它的 Value,保留切线、插值和未知数据;该帧没有键,插入新键并保持帧序。仅刷新显示值/拖动播放头不写键。不同于之前“加键按钮选中已有键”的规则,值输入本身明确表达修改意图,不要求额外按录制或添加按钮。 + +新键默认继承包含该时间点区间的插值类别;Hermite 区间可用该点原曲线导数初始化入/出切线,随后替换为输入值,允许曲线随新值改变。不能用全局切线平滑顺手重写其他键。 + +若属性尚未加入轨道,首次输入一次性创建轨道、必要的 0 帧键及当前帧键:Replace 在基准值唯一时以它建立 0 帧;基准未知时以本次输入建立常值首帧,并明确提示采用了这个初值,作者可以回到 0 帧修改;Additive 首帧采用 0 偏移。当前帧为 0 时只建立一个键。所有动作合为一条项目历史,不留下无首键的半成品轨道。仅勾选加轨道但没有可用初值时,仍要求输入明确初值。 + +Additive 行的可编辑值是轨道的偏移量,并标注“偏移”;有唯一基值时可额外只读显示合成结果,不能把合成值直接存成偏移。进入值编辑时暂停播放并固定本次输入对应的帧;有效输入先提交到原帧再允许跳转,非法中间文本留在输入框且阻止目标/帧跳转,Escape 取消。一次按住 NumericUpDown 的连续调节/一次确认输入合为一个手势。 + +### 4.2 帧、时长和切线规则 + +规则: + +- 添加轨道必须显式创建首键 0;Replace 在静态查询能给出唯一值时提供该值,否则要求输入,不假装“可能值”是真实当前值;Additive 首键可以明确采用 0 偏移。创建轨道和首键为一次历史。 +- 在当前帧加键:已有键则选中它并编辑,不隐式覆盖/合并;新键默认取当前已验证曲线的采样值。无可采样数据时要求输入。 +- 横向拖动吸附整数帧,可暂时关闭网格吸附,但提交仍取明确整数帧;垂直拖动改变值。Shift 约束方向,框选/Ctrl 多选,组合拖动保持键间相对帧差。 +- 首键固定在 0 帧;选中首键的组合平移只允许改值或先取消首键选择。删除首键而轨道还存在时拒绝,删除整轨道可行。与已有键碰撞则本次提交拒绝并保留草稿,提供显式替换操作,不能静默丢键。 +- 时长增长不自动添加末键;短轨道按现有 sampler 保持末值。缩短到已有键/事件之前时拒绝直接应用,提供明确的“裁剪内容并缩短”操作,整体一条历史。 +- 修改 FPS 默认保留帧位置并改变秒数;另提供“保持秒数重新定时”,一次计算所有键/事件。整数舍入产生同帧键时报告冲突,不自动合并;同帧事件仍按既有相对顺序。 + +Hermite 的切线单位为“值/帧”。若横轴显示秒,绘图/输入换算使用“值/秒 = 值/帧 × FPS”,保存仍为值/帧。切线拖柄控制导数;只接受有限值。支持分离编辑入/出切线、一次性对齐斜率、设为线性斜率或平滑斜率。首版不宣称支持加权切线、任意 Bezier 权重、持续自动重算切线模式;“平滑”是明确执行的一次操作,不偷偷持久化不存在的模式字段。 + +切到 Step/Linear 时只改变插值选择,保留未使用的有限切线值,切回 Hermite 可恢复。末键的出段插值保留但无后段效果。对于 opacity/scale 等有限范围,检查 Hermite 区间内极值,不能只检查端点;禁止通过图表显示钳位来掩盖实际 sampler 越界。若完整业务校验尚无此规则,提取共享校验并统一编译/运行边界,不能仅在 Editor 私自改变语义。 + +## 5. 注册与复用默认表单 + +| 注册粒度 | 建议定制 | 保留的默认表单 | +| --- | --- | --- | +| AnimationPlanOptions.Plan 成员 | 对象树、关键帧/曲线/事件画布 | BatchId 及未覆盖根字段 | +| 变换 JSON 成员 | X/Y/旋转/缩放分组数值编辑 | 所在 DTO 的其他字段 | +| 颜色/向量等明确值类型 | 颜色选择、分量数值控件 | 外层布局、诊断、presence 操作 | +| ParticlePlay/Burst 的 Parameters 成员 | 发射/初始/运动/生命周期等模块表单 | 根部数量、Z 等字段 | +| EffectApply 的 Parameters 成员 | 根据选中程序 metadata 动态生成表单 | 程序选择、目标、顺序等根字段 | +| 特殊项目 DTO 整体 | 自定义画布 + context.CreateDefaultForm(排除已负责字段) | 未排除的字段 | + +默认类型注册只用于语义一致的值类型,不给全部 string 注册目标选择器,也不给全部 JsonElement 注册时间轴。Timeline、Curve、Color 等行为控件和基础编辑模型放共享 Avalonia.Controls;Editor 的 Plan JSON 适配、项目历史和场景候选适配放 Editor,纯状态分析放 Editor.Shared。Core 保留运行时模型、采样/验证等中立事实,不引用 Control。 + +时间轴选中关键帧/轨道的详情用局部字段描述 + SchemaForm 生成;这些描述是专属适配器对现有契约的投影,不另注册执行 Entry,也不通过创建运行时对象获取属性。Sample 可直接组合同一曲线控件与自己的 binding;Editor 的 Locator/服务不成为共享控件必需依赖。 + +粒子 JSON 目前有自定义字段解析与默认/钳位行为,不能直接把 ParticleEmitterDefinition 的 CLR 属性名当作者 wire 名。需要显式对应 rate、initial 等现有键,保留未知模块;颜色、区间、渐变、生命周期曲线按独立控件复用。Effect 表单复用现有 shader metadata parser,数值/枚举/纹理/颜色按描述生成;程序变化只诊断不兼容的已有参数,不自动清理作者数据,缺少 metadata 时回退 JSON。专业 GPU/粒子实时预览继续后置。 + +## 6. 手势、草稿与 JSON 无损提交 + +```mermaid +flowchart LR + Registry[成员或整 DTO 注册] --> Adapter[Plan JSON 适配器] + Adapter --> UI[对象树 / 关键帧 / 曲线 / 默认详情表单] + Query[状态与属性查询] --> Adapter + UI --> Draft[局部手势草稿与纯采样] + Draft --> Commit[版本核对与原子字段 patch] + Commit --> History[项目统一历史与作者 JSON] +``` + +手势采用 Begin/Update/Commit/Cancel 的概念协议,具体接口在实施时收敛。Begin 捕获 Entry/schema 生命周期、根字段原始 JSON 与集合版本;Update 只改变局部草稿并刷新纯采样;Commit 一次提交根字段 patch;Cancel 释放草稿,作者 JSON 和历史均不变。现有 IFieldPreviewBinding 不具备完整事务能力,不能把它直接当成这套协议。 + +拖动一百次鼠标移动只产生一次历史;无变化不增加历史。数值连续调节在一次按住/拖动或一次输入确认时合并,点击离散按钮各为一次。多键/多轨道/事件批量编辑、重新定时各为一次原子提交。多字段操作必须走宿主原子 patch,不逐字段写入后声称可撤销事务。 + +提交前核对原始根值与当前值,以及 Entry/type/collection 版本。其他页面修改、Undo/Redo、切类型、删除 Entry、切项目时取消手势并刷新,旧行绑定不会按旧数组索引写回。首版冲突整次拒绝,不做字段级自动合并;其他字段与作者未知参数仍保留。 + +轨道/键/事件的选择使用编辑会话内 RowId;不是数组下标,也不要求给现有运行时关键帧增加持久 ID。每次提交维护 RowId 到当前元素的映射,外部替换/无法可靠匹配时重建版本并取消旧选择/草稿。跨重开不保留关键帧选择,历史不持有画布或控件。 + +JSON 适配器保留原始树,只对已负责的键打 patch;移动数组元素时连同未知字段一起移动,修改值不重建整条 track/key/event。对象省略/null/default 原样保留。读取允许已知属性名的既有大小写,修改时使用原属性名;新属性使用约定 wire 名,歧义重复字段拒绝编辑并提示修复。不能通过 Deserialize 后 Serialize 覆盖作者数据。 + +语法正确但语义不完整的 Plan 可以保存为作者草稿;画布只对安全可解释的节点开放对应编辑,其余节点留诊断及原始 JSON。数值中间文本、正在拖动的非法帧位置不写作者内容。显式创建/修复才补必要数据,打开时间轴不补 defaults,不把旧无效轨道静默删掉。 + +关闭窗口/切换 JSON 模式/保存时先结束活动手势。无效输入留在当前编辑会话并显示错误,不因折叠卸载而丢失;关闭页面遵守统一草稿 Apply/Discard/Cancel 协调。自动保存只保存已提交作者内容,不隐式提交拖动中状态。此要求需要与 P7 关闭协调接通,不能沿用 P6b 折叠释放时直接丢弃复杂草稿的简化方式。 + +P6d 数值行先采用明确取消规则:Enter/失焦提交,Escape 恢复;切换 Entry、帧、相关根值或关闭 Dock 时取消未确认输入并使旧行失效。保存不隐式确认,根值未变时保留输入,切语言也不丢草稿。对象树/画布引入的长手势和统一关闭协调仍按 P6e/P7 验收,不能据此认定上述完整草稿要求已实现。 + +## 7. 对象、属性与时间轴事件 + +对象候选来自当前 Entry 执行前的抽象状态查询;Plan 内再叠加当前帧之前和同帧按原序发生的事件,并区分播放阶段。未来才创建的对象可在添加目标时搜索,但提示创建时间,播放头位置下尚不存在时不伪装一定存在。Loop/PingPong 等复杂事件生命周期在语义贡献器未完成时保守标记可能并提示分析不完整,不运行真实 Entry 来获取候选。 + +候选只分一定存在/可能存在;现有引用即使暂时无候选也不清空,缺目标按用户确认的“有则执行,没有忽略”处理。目标存在性与属性值唯一性是两回事;无法给出唯一初始数值时要求作者输入。 + +对象及播放身份由 Editor 自动分配,选择以 Locator 解析到底层 handle,正常作者 UI 不出现 HandleId/PlaybackHandleId 输入框。显示名称采用资源名、创建条目来源和可选友好标签,稳定身份不依赖名称/序号/路径。Plan 事件创建的对象沿用其已分配的不透明输出身份;事件重排不重新分配,引用不依赖事件数组下标。批量复制只按语义声明重映射内部引用,不能全 JSON 搜索替换相同字符串。 + +对象属性来源按领域贡献:Layer.AnimationProperties、ParticleEmitterInstance.AnimationProperties,以及 effect metadata/实际支持能力的交集。首版仅列出目前能由 float 轨道采样/执行的属性。颜色、向量、bool 不能因有数值输入控件就直接作为 float 轨道;需要现有分量能力或另立运行时契约扩展。未知自定义对象可以保留已有轨道,但缺少能力贡献时不臆造可添加属性。 + +事件行独立于数值轨道,支持添加、移动、多选、同帧明确前后排序,详情复用相应 DTO 表单及同一 Locator。选择器仅列当前支持的事件;未来项目扩展需同时提供契约允许、编译校验、状态转换与执行支持,不能只注册一个控件。事件参数同样支持不完整作者草稿。 + +内置事件能力清单目前散在私有 validator 与执行 switch。建议后续提取中立的能力描述供 Editor/校验复用,增加“声明事件都有执行/分析覆盖”的测试;运行时执行分派仍由对应模块负责,不把 UI 注册当执行授权。 + +## 8. 预览和验证分层 + +首版播放头和播放按钮只驱动局部时间与 AnimationTrackSampler,显示曲线采样和数值;拖动播放头不会执行 show/hide、修改变量、写存档、发通知或修改项目作者数据。事件只显示将发生的列表/标记;重播/循环不会真的重复触发副作用。静态对象缩略图不等同于真实游戏预览。 + +值合法性、目标属性类型、帧/键唯一性和编译规则继续来自统一契约。Editor 附加定位到对象/轨道/键/事件的诊断。部分无效内容可保存;完整编译严格失败。曲线控件可独立采样测试,最终接入场景预览另走游戏预览阶段。 + +## 9. 取舍与实施顺序 + +| 方案 | 选择与理由 | +| --- | --- | +| 所有复杂对象都套默认表单 | 基础可用,但不满足关键帧/切线编辑;保留为详情与回退 | +| 重写整个 AnimationPlan DTO 面板 | 需要协调多个根字段时可用;首版成员覆盖 Plan 更聚焦,其他字段继续复用 | +| 将 Plan 改强类型并 round-trip | 不是 UI 先决条件,会丢未知字段;首版保留 JsonElement + 专属无损适配 | +| 本次引入新的动画文件/Clip 资源格式 | 首版不引入;先将现有内嵌 Plan 编辑完整,复用 Clip 另行评估 | +| 把每次预览写进项目再取消恢复 | 首版采用局部草稿,避免脏状态、自动保存和项目 Undo 交错 | +| 自动附加运行时/长期持久子 ID | 首版不为键/轨道增加 ID;会话行身份 + 集合版本保护足够,未来引用关键帧本身再设计格式 | + +建议按可验证依赖顺序推进:先完成 Locator/场景状态与属性能力基础,再交付 Plan 无损适配和手势/草稿协议,随后对象树与关键帧、Hermite 曲线和纯采样,最后时间轴事件的帧内查询与专业参数面板。曲线控件本身可先独立实现,作者可用的完整对象时间轴应在隐藏 handle 的选择器具备后交付。 + +验收至少覆盖:两同名对象、同对象多属性树和折叠同步;加/移轨道的单历史;0 帧保护/碰撞/时长与 FPS;Step/Linear/Hermite 采样及切线单位/极值;批量拖动 Cancel/Undo;旧版本异步提交;未知 JSON 往返与无打开写入;同帧事件顺序和事件创建对象;复制内部引用重映射;四语、缩放/滚动与真实画布键鼠;保存/关闭/重开。规模以可见区域绘制、按需计算与曲线降采样控制,阈值经实施实测再确定,不宣称已有性能数字。 + +本设计不修改业务代码、Core 契约或正式 spec,也不将上述建议标记为用户已逐项确认。当前没有阻塞设计的必答问题;正式 Phase 的退出条件和编号在下一步 phase plan 中细化。 + +## 10. 两个工具与 Timeline 命名收敛 + +用户确认近期只有两个专业工具,顺序为 Timeline 时间轴优先、animate 自定义曲线后续。它们共用曲线画布、关键帧选择/切线交互和手势协议,但数据含义不同:Timeline 编辑绝对帧与属性值;animate 编辑归一化时间到缓动进度的自定义函数,不能因为画布相同就共用同一份时间单位/值契约。 + +AnimationPlan 改为 Timeline 可行。当前静态搜索命中 23 个 C# 文件,包含 Core、契约、Primitives、展示接口/Avalonia 实现、状态恢复、Sample 和测试;这个数字不包含所有 JSON/文档,实施前仍需全仓核对。主要工作是统一符号、数据入口和消费者,并非重写采样算法。 + +P6c 已按以下对应关系完成命名统一;表左为设计基线旧名,表右为当前名称: + +| 现名 | 目标名 | +| --- | --- | +| AnimationPlanDefinition / AnimationPlanOptions | TimelineDefinition / TimelineOptions | +| AnimationTrackDefinition / AnimationKeyframeDefinition | TimelineTrackDefinition / TimelineKeyframeDefinition | +| AnimationPlanEventDefinition / AnimationPlanPlayResult | TimelineEventDefinition / TimelinePlayResult | +| AnimationTrackSampler / AnimationPlanPrimitiveInstance | TimelineSampler / TimelinePrimitiveInstance | +| IAnimationPresenter.PlayAnimationPlanAsync | IAnimationPresenter.PlayTimelineAsync | +| BuiltinEntryIds.PlayAnimationPlan | BuiltinEntryIds.PlayTimeline | +| LoopAnimationState.Plan | LoopAnimationState.Timeline | +| animation.play、根参数 plan | timeline.play、根参数 timeline | + +UI 和代码采用 Timeline 作为概念名称;IAnimationPresenter、AnimationBlendMode、AnimationLoopMode、动画播放身份/停止协议仍与 animate 共用,不为了消除所有 Animation 字样而改名。单属性 animate 继续使用原有单属性入口。 + +不保留旧符号别名或旧 Entry ID 回退。若采用表内 wire 命名变更,契约、编译、运行、事件/转场 composite writer、Sample 内容、fixtures、Raw 编辑/CLI 和恢复 payload 必须同一切片更新;核对受影响的状态 codec 版本,避免旧 payload 被当作新格式解释。这是可一起完成的内容变更,不是仅给窗口改标题。旧作者内容不做兼容迁移,但仓库中仍作为有效样例使用的内容必须同步更新。用户后续已授权实施,P6c 已验证这些命名与内容入口变更;Dock 和时间轴编辑工具仍留后续阶段。 + +animate 自定义曲线目前尚无可保存的自由曲线契约:AnimationOptions.Curve / AnimationRequest.CurveKind 保存的是内置枚举,IAnimationCurve 的运行期覆盖不参与 JSON。后续需要可序列化的归一化曲线定义、统一 sampler/validator、编译转换和循环动画恢复;只增加编辑 UI 会导致保存重开/循环恢复退回原曲线。首版建议保留内置预设并增加自定义模式,范围/端点/是否允许 overshoot 单独在该切片设计,不把 Timeline 的帧曲线直接塞进枚举字符串。 + +## 11. 实时预览架构备忘(不纳入阶段计划) + +用户明确实时预览不作为阶段计划。本节仅保存上轮问题的架构分析,不安排实施步骤、不设退出条件,也不作为 Timeline 的前置依赖或验收范围;当前纯数值采样/曲线显示仍是时间轴编辑功能。 + +### 11.1 分离创作会话与预览场景 + +TimelineEditSession(概念名)因编辑本身需要目标身份、当前有效 timeline/手势草稿、播放头、revision 和诊断。若未来另行决定做预览,可订阅同一会话;现在不为预览新增必需服务。预览只消费快照,不能调用“修改叶节点值”的作者 upsert 接口,否则刷新画面会反过来写关键帧。预览失败不撤销已合法提交的作者内容。 + +未来增加独立 PreviewSession,拥有自己的 GameState/场景实例、展示 scope 和资源租约。与实际游戏运行、项目作者文档、玩家变量/存档/已读/成就/音频服务隔离;只能通过显式的作者编辑意图把用户操作写回 Timeline。它不是复用正在游戏中运行的唯一场景,也不能用静态“一定/可能”候选集伪造一条真实游戏状态。 + +### 11.2 基准场景与任意时间求值 + +每次预览捕获 Timeline 入口的不可变基准场景、变量值、资源版本及路径上下文。直线剧情可通过安全的状态贡献器构建;分支/条件不确定时让作者选择路径上下文或使用实际运行捕获的检查点,不能默认为两条分支的对象都在场。 + +对 Seek(t) 的结果应由“基准快照 + Timeline 草稿 + 时间 + 明确的预览上下文”决定。每次从干净基准/有效检查点重建需要的状态,按事件顺序推进到 t,再执行相应阶段的轨道采样;不能对已被上一次预览覆盖的场景继续累加 Additive,避免反复拖动数值漂移。纯采样仍复用 TimelineSampler;绘图用的归一化/单位换算不改变实际运行时求值。 + +时间轴事件不能在每次拖动中直接重新 Dispatch 普通游戏 Entry。为可预览事件提供无持久副作用的状态转换/展示适配,按同帧原序重建 show/hide/effect 等;不支持的事件展示清晰的预览不完整提示,仍保留原作者数据。Loop/PingPong 和事件重复按运行时约定建模;在契约未明确之前不能仅对时间取模来猜测事件状态。 + +### 11.3 可控时钟与渲染应用 + +当前 AvaloniaAnimationPresenter.PlayAnimationPlanAsync 使用 Stopwatch + 顺向循环,IAnimationPresenter 没有 Seek;StateRestoreSession 会启动恢复 continuation,也不是天然的编辑器 seek API。因此后续需要拆出可共享的时间映射/采样/混合求值,以及受控的预览帧应用能力。运行时继续用实时驱动,预览用 ManualClock/Seek 驱动,二者使用相同数学和属性执行规则,不要求为了首版编辑器马上改写整个 Engine。 + +```mermaid +flowchart LR + Draft[Timeline 会话 / 有效草稿] --> Request[Seek 请求与 revision] + Base[入口基准场景 / 路径上下文] --> Eval[无副作用状态推演与同源采样] + Request --> Eval + Eval --> Frame[隔离预览状态 / Render 帧] + Frame --> View[预览面板] +``` + +渲染器/动画、粒子、shader、flipbook 必须使用一致的预览时间。场景渲染中已有自己的 Stopwatch,不能只控制动画播放头就声称全场景可回退。粒子采用固定 seed、固定步长,从有效检查点重模拟;视频拖动使用后端 seek 能力和异步帧,不能直接假定图层属性采样涵盖媒体位置。拖动默认静音,音频预听另开明确模式,存档/进度/成就等服务不接真实持久层。 + +Seek 和草稿改变可高频合并;每个任务带项目/schema/资源版本、草稿 revision、请求序号,取消旧任务并只发布最新结果。Undo/Redo、编辑入口之前的剧情、资源变化使基准或帧缓存失效;关闭预览/项目时释放解码、渲染和资源租约。性能优化采用局部属性更新、状态差量与检查点,不能以复用上一次污染的状态为代价。 + +## 12. 本轮明确的落地顺序 + +1. 统一 Timeline 命名方案与内容范围,形成可验证的全仓变更清单;命名不能只改 Editor。 +2. 建立项目拥有的 Timeline 编辑会话、独立 BottomDocument Dock、无损字段适配和单历史手势;先有当前属性采样和叶值写键闭环。 +3. 接入隐藏 handle 的对象/属性查询、树/帧/曲线画布和事件编辑。可先交付直线剧情与已有引用保留,分支与循环查询保守标记可能,不必等全量缓存优化才开始 Dock,但不能把不支持语义当作确定无影响。 +4. 验证叶值改已有键/新建键/未加入属性建轨道、首键、切线保留、Additive 偏移、输入与播放头冲突、Dock 跟随当前活动 Entry、普通条目/无选择禁用、关闭、跨页撤销与保存重开、四语和真实模板键鼠。 +5. Timeline 交付后推进 animate 自定义曲线契约与控件。实时预览不进入本阶段计划,不添加预览 Phase,也不作为上述功能的依赖或验收项。 + +本轮新增用户确认的是工具范围、Timeline 优先、独立 Dock/资源区域、叶值 upsert、跟随当前所选 Entry,以及实时预览不进入阶段计划;命名变更表、未知基值首帧策略与预览备忘仍是基于代码事实的设计建议。此文没有实施业务代码或声称具备实时预览能力。 diff --git a/features/F-20261015-02-editor-authoring-workspace/design.md b/features/F-20261015-02-editor-authoring-workspace/design.md index b98fd540..32bd16e0 100644 --- a/features/F-20261015-02-editor-authoring-workspace/design.md +++ b/features/F-20261015-02-editor-authoring-workspace/design.md @@ -1,6 +1,28 @@ # Editor 创作工作区设计 -日期:2026-10-15;进度更新:2026-10-16。状态:目标设计,P1a/P1b/P2/P2i 已实施并验证,其余阶段未实施;产品范围以 [feature.md](feature.md) 为准,实际进度与证据见 [phase-plan.md](phase-plan.md)。第 1 节记录设计时的基线事实;本文中的类型名用于界定职责,不要求逐一新建接口或服务。 +2026-10-21 P6e4c4:播放控制候选以 Playbacks 与场景 Targets 分域,共用纯分析、项目代次和 Locator 选择 lease;停止动画字段复用共享选择器,语言刷新保留域,UI 只显示类型及原条目来源。后续播放存在性保持可能,立即完成与迭代后停止区别处理,阻塞终态不泄漏内部播放。范围及验证见 [phase-6e4.md](phase-6e4.md),当前行为见 [场景目标规范](../../docs/spec/editor-scene-targets.md)。 + +2026-10-21 P6e4c3b:效果声明通过项目级后台资源准备进入不可变场景查询代次,Core 与 renderer 共用受支持标量转换;legacy catalog 与 program metadata 分开处理。当前对象能力缺失与上游语义未知独立保留;资源刷新/关闭使旧加载、选择及编辑行失效,既有轨道保留。已验证边界见 [phase-6e4.md](phase-6e4.md),稳定规则见 [场景目标查询规范](../../docs/spec/editor-scene-targets.md)。 + +2026-10-21 P6e4c3a:从作者 JSON 中有效的 FlipbookDefinition 接入 flipbook.index,复用 Core 属性声明和 Timeline 既有浮点编辑/采样;不把显示格子的取整/夹取当作动画值限制。无效数据保守退回,定义变更使旧行失效而不删除轨道。效果 metadata 需要独立的异步资源快照与刷新,留 P6e4c3b,不在同步查询或共享层编译 GPU shader。见 [phase-6e4.md](phase-6e4.md)。 + +2026-10-20 P6e4c2:六种内置转场通过固定内置契约目录做纯编译展开,复用 Timeline 终态分析,不复制转场算法或执行项目扩展。每代作者快照缓存展开及失败,原条件只在条目边界合并;整段在临时事实中验证后发布,失败保留保守输入,上游不完整标记不被清除。输出 Locator 映回原转场输出槽位。输入对象复用场景 Picker,输出/播放身份由统一声明隐藏;未知作者扩展不进入临时编译投影、不改写原始 JSON,严格项目编译不受影响。实施与验证见 [phase-6e4.md](phase-6e4.md)。 + +2026-10-20 P6e4b:事件独立行共用时间视图,复用共享画布的选择/框选/拖动;同帧允许多事件并通过显式提前/延后排序。参数详情通过共享 DTO 宿主形成可取消草稿,应用单历史提交,事件前查询提供 Locator 候选;Group 与弹窗复用 Editor 表单外观,资产与场景选择保持为各自的数据源。未知事件保留并允许移动/删除。实施/验证见 [phase-6e4.md](phase-6e4.md),不加入实时预览。 + +2026-10-19 P6e4 按 [phase-6e4.md](phase-6e4.md) 分为 a 帧内查询与事件能力、b 事件作者 UI、c 剩余状态/能力/引用。a 使用统一内置事件清单,按帧/同帧作者顺序纯分析,允许查询事件前边界;当前帧与未来创建候选复用现有场景对象选择器。第 0 帧初值与当前值分别查询;循环/非阻塞后续位置维持保守可能集合。事件行编辑和实时游戏预览均不由此查询接入冒称交付。 + +2026-10-19 Timeline UX 修订:取消更换对象/属性 checkbox/可添加属性模式,统一“+”添加与右键取消重载,父行汇总点可移动同帧后代键;P6e3b 已实现属性内联值曲线与关键帧切线菜单并替换独立详情区;自动模式保存 Editor 作者元数据并在编辑时烘焙为 Runtime 既有斜率。最新行为与计划见 [phase-6e3.md](phase-6e3.md)。 + +P6e2 后续交互修订以 [Timeline 交互简化](timeline-simplification.md) 为准:模式为整轨道属性,可持续切换;结束帧/FPS 直接确认,保留帧号、不裁剪内容,越界拖动自动延长。旧保秒重定时和应用/裁剪组合已退出本轮设计。 + +2026-10-18 复杂定制补充设计见 [对象树时间轴与专业参数控件](complex-editor-design.md):按用户描述的 Unity 类交互组织“对象 → 可动画属性”,关键帧/曲线共用数据,明确切线、手势、JSON 适配、Locator 和事件边界。此为设计,尚未实施或验证,不改变 P6a/P6b 已交付状态。 + +后续用户将近期范围限定为 Timeline Dock 与 animate 自定义曲线两个工具,Timeline 优先。Dock 默认与资源同组,叶节点当前值输入执行当前帧关键帧 upsert;详细设计已同步第 3–4 节。全面 Timeline 命名方案与独立预览场景/可控时钟/seek 的未来接点见该文第 10–12 节,属于待实施设计,不表示现有 IAnimationPresenter 已支持 seek。 + +最新确认:Timeline Dock 直接跟随当前活动 Entry,选择非 Timeline/无选择时进入不可编辑提示,不保留可写旧目标或固定模式;实时预览移出阶段计划,仅保留架构备忘,不作为实现依赖。 + +日期:2026-10-15;进度更新:2026-10-18。状态:目标设计,P1a/P1b/P2/P2i/P3/P4a/P4b/P6a/P6b/P6c/P6d/P6e1/P6e2 与 P5a–P5e 已验证,其余范围以阶段计划为准;产品范围以 [feature.md](feature.md) 为准,实际进度与证据见 [phase-plan.md](phase-plan.md)。第 1 节记录设计时的基线事实;本文中的类型名用于界定职责,不要求逐一新建接口或服务。 ## 1. 当前事实与目标差异 @@ -98,6 +120,8 @@ ### 6.1 文件类别与操作矩阵 +2026-10-17 用户确认文件面板采用左侧目录树、右侧当前目录网格的双栏布局。目录树只展示层级导航,网格展示当前目录的文件与子目录;面包屑、树选择与网格导航共用状态。目录展开、选择与缩略图大小不进入内容历史,重命名/移动/删除等动作共用 P4a 的项目事务。界面在 P4b 实施。 + | 类别 | 浏览/打开 | 改名、移动、删除与创建 | | --- | --- | --- | | `Graph/**/*.rawgalgroup` | Group 专属页面,支持孤立文档 | 在 Graph 内移动/改名并更新绑定;删除关联节点走复合操作;新建空源后可绑定 Group | @@ -205,3 +229,55 @@ P2i 先补齐已实现的启动/项目设置/关闭恢复/基础 Graph 与语言 ## 11. 待落定项 文件立即写盘是 6.2 中显式标记的设计假设,等待可能的用户修订;其余产品范围已明确。精确类名、schema fallback 控件及性能预算由相应 Phase 的最小实测确定,不构成追加产品授权要求。 + +### P4b 实施补充(2026-10-17) + +用户确认行为层面参考 Unity Project 窗口:目录树/网格、原生 Ctrl/Shift 多选、双击打开、F2 改名、Ctrl+C/X/V/D、拖放、全项目文件名搜索与缩略图大小。保持 GalNet 的固定入口、资源 sidecar 与 Group 归属规则,不复刻 Unity 导入管线或 Asset Store。通用文本缓冲由项目拥有并进入 Working/Saved、项目历史与事务保存;关闭 Dock 页面不销毁缓冲。支持严格 UTF-8 与带 BOM 的 UTF-16/32,无法确认无损往返、二进制和超过 2 MiB 的文件只读,提供外部打开。参考:https://docs.unity.com/en-us/engine/6000.3/manual/unity-editor/editor-windows-views-reference/project-view 。 + +复制资源同时处理身份冲突:资源 ID 必定重新分配;Gallery 注解的正整数 ID 若已被项目或同批导入占用,则为副本分配新的未占用 ID,保留 type/title。移动保持全部身份。这样 Duplicate 不会生成无法构建的重复 Gallery ID,也不会让副本复用原条目的解锁身份。 + +用户追加界面规则:地址栏默认内嵌可点击的目录块,点击空白切换相对路径输入,Enter 导航、Escape/失焦退出;树目录与地址块均接收文件移动。创建和导入仅从右键菜单进入,新建为二级菜单;刷新放在底部缩略图滑条右侧。网格 tooltip 显示完整文件名。右上搜索目前只做全项目文件名连续子串匹配,类型筛选作为独立查询参数保留,未来再提供类型/范围控制;不解析 t: 语法。非空搜索显示圆形 × 按钮,清空搜索与点击该按钮一致:有选中结果时导航到首个选中结果的父目录并保留选中,否则返回开始搜索的目录。 + +### Group 编辑交互与类型控件扩展(2026-10-17) + +详细补充见 [galgroup 编辑设计](group-editing-design.md)。P5 纳入搜索型 Entry picker、顶部添加/追加/数量、按稳定 Entry 身份保存折叠状态、尾部空白取消选择和 Space 快速插入。第 6 点仅设计:共享 SchemaForm 的成员/值类型控件替换、整个 DTO 面板替换、Sample 入口注册复用及 Editor 场景目标 Locator;底层 handle 保留,剧情/演出作者不需感知。数量作用两个按钮,picker 直接确认一条,均已获用户确认。新增注册服务、场景分析与 timeline/曲线不在本轮实现。 + +第 6 点独立整理到 [参数控件与场景目标设计](parameter-editor-design.md),替代 Group 补充文档中的详细扩展段落;注册优先级、宿主边界、数据提交与生命周期、场景分析及实施切片以该文为准。 + +用户进一步明确:UI 构建下放共享 Avalonia 层,三层注册分别为特定 DTO/特定 DTO 成员/默认类型;默认 DTO 表单负责后两层,多 DTO 面板负责第一层覆盖与默认表单选择。Editor 状态查询独立,资源只分一定存在/可能存在,缺目标跳过执行;缓存 Group 入口/出口与按需前缀。详见第 6 点独立设计的第 3、4、7 节。 + +### P6a 实施进度 + +用户后续已授权实施共享 UI 构建,三层注册、默认表单与多 DTO 面板及 Editor/Sample 宿主接入已验证,610 项相关回归通过,见 [phase-6a.md](phase-6a.md)。当前支持直接生成 DTO 成员映射;嵌套结构、状态查询及动画工具仍保留为后续设计,不能把原设计阶段的“本轮不实现”解读为后续授权无效。 + +### P5a 实施进度(2026-10-17) + +Group 补充第 1–5 点已验证:搜索/键盘类型选择、顶部批量和直接插入、折叠拖放、可滚动尾部与取消选择、Space 快速插入。类型及完整参数切换作为一次历史撤销。解决方案构建成功,614 项相关回归通过,另 6 个媒体环境用例未执行,见 [phase-5a.md](phase-5a.md)。P5 多选剪贴板、变量面板及剩余作者绑定迁移继续 planned;状态查询与专业动画编辑仍属后续范围。 + +### P5b 实施进度(2026-10-17) + +Entry 多选、剪切/复制/粘贴/副本、批量删除和多选拖放已验证,批量为单条项目历史;失败剪贴板/源变化/晚返回与集合补偿已覆盖。解决方案构建成功,493 项相关回归通过,whitespace/IDE0005 verify 与源码指纹核对通过。见 [phase-5b.md](phase-5b.md) 和 [Group 当前规范](../../docs/spec/editor-groups.md)。P5 变量编辑与剩余作者绑定迁移仍 planned,P6 状态查询/复杂值和游戏预览范围不变。 + +### P5c 实施进度(2026-10-17) + +统一变量面板已验证:查看菜单入口,玩家/存档页签,声明/初值/说明草稿与显式应用,按 UID 的项目历史、明确引用重命名及删除保护、候选诊断、已打开 Group 投影/补全/诊断刷新。说明字段纳入严格 Graph 格式与克隆/保存往返。解决方案构建成功,611 项相关测试通过,格式及源码指纹门禁通过,见 [phase-5c.md](phase-5c.md) 和 [变量当前规范](../../docs/spec/editor-variables.md)。剩余 Entry 作者绑定迁移、旧 Preview 路径与 P6/P7 范围继续单列,P5 整体不标记完成。 + +### P5d 实施进度(2026-10-18) + +Entry 类型、条件、参数与新增入口已迁移为项目级意图,移除 Workspace 的 Entry 属性监听补记历史,表单直接读写作者 JSON。跨页面历史、关闭页面后撤销、过期绑定、快照保留及失败补偿通过验证。解决方案构建成功,614 项相关回归通过,格式与源码指纹通过,见 [phase-5d.md](phase-5d.md)。当前 Repository 保存仍要求完整契约有效,未完成/未知作者内容无损保存拆为 P5e,完整编译继续严格校验;旧 Preview 和 P6/P7 范围不变。 + +### P5e 实施进度(2026-10-18) + +GUI Authoring 与默认 Complete 仓库模式分开:未完成/未知条目、无效参数类型及条件文本可以保存重开,严格 JSON shape、身份、Graph 拓扑、路径和外部冲突继续检查;CLI 校验/编译仍完整。未知 schema 提示、Entry/Graph 复制、已知字段单 JSON 更新及类型保存后撤销通过验证。解决方案构建成功,635 项相关回归与格式/源码指纹通过,见 [phase-5e.md](phase-5e.md)。P5 整体 verified,继续 P6 状态查询/Locator、复杂值 UI 和 P7 验收;游戏预览另立 feature。 + +### P6c 实施进度(2026-10-18) + +Timeline 类型、播放接口、timeline.play/timeline 根参数及转场/恢复消费者已统一,模块状态版本显式传递,builtin.animations 升为 v2。旧入口无别名;1081 项回归、解决方案构建及格式/源码指纹通过,另 6 项原生媒体环境用例未执行。见 [phase-6c.md](phase-6c.md)。实时预览不进入阶段计划。 + +### P6d 实施进度(2026-10-18) + +Timeline 底部 Dock、项目级活动 Entry 选择与直接作者绑定已验证;已有轨道按当前帧采样,NumericUpDown 确认覆盖/插入键,单次项目历史且保留切线、插值和未知 JSON。缺失定义显式创建,切换/删除/换类型/关闭后旧行不能写回。解决方案构建、644 项相关回归、格式与源码指纹通过,见 [phase-6d.md](phase-6d.md)。目标候选、对象树、轨道创建、画布和曲线仍属 P6e,完整草稿关闭协调留 P7。 + +### P6e1 实施进度(2026-10-18) + +Editor.Shared 纯场景分析与项目快照代缓存已接通;确定/可能合并、条件、循环预算、未知语义降级和类型冲突保守处理已验证。Timeline 基础对象/属性树支持显式轨道创建/删除和当前帧建键,首帧初值/偏移、未知引用与 JSON 保留、源变化后旧行失效、四语和保存重开已接通。GUI 创建及 Entry 复制引用身份保持项目历史,Graph/转场引用等剩余语义留 P6e4。解决方案构建及同源码 656 项相关回归、格式/指纹通过,见 [phase-6e1.md](phase-6e1.md);P6e2 已验证关键帧画布、共用可见行、整帧多选拖动、首键/碰撞保护和时间编辑,901 项回归、构建及格式/指纹通过,6 项原生媒体环境未执行;见 [phase-6e2.md](phase-6e2.md)。曲线/事件工具仍 planned。 diff --git a/features/F-20261015-02-editor-authoring-workspace/feature.md b/features/F-20261015-02-editor-authoring-workspace/feature.md index f6093dd8..817dcb6a 100644 --- a/features/F-20261015-02-editor-authoring-workspace/feature.md +++ b/features/F-20261015-02-editor-authoring-workspace/feature.md @@ -4,7 +4,7 @@ title: Editor 创作工作区重构 type: refactor status: in-progress created: 2026-10-15 -updated: 2026-10-16 +updated: 2026-10-21 --- # Editor 创作工作区重构 @@ -23,11 +23,30 @@ UI Editor 的功能落后于当前项目能力,需要分析并重构整体架 - 新建项目不要求用户准备或编译 DLL:Editor 自带默认契约并自动部署,自定义契约目录是可选项。 - 每次完成阶段或修正后提交 Git,仅提交本任务改动。 - 2026-10-16 用户追加:将编辑器 UI i18n 纳入本 feature 的实施计划。 +- 2026-10-17 用户确认文件面板采用左侧目录树、右侧当前目录网格的双栏布局,作为 P4b 的界面要求。 -2026-10-15 用户已明确授权开始实施。P1a 基本页面、作者文档和项目会话已验证,见 [阶段记录](phase-1a.md);P1b 项目历史、事务保存与恢复骨架已于 2026-10-16 在当前工作树集成验证,构建成功、311 项测试通过,见 [phase-1b.md](phase-1b.md)。P2 Graph 基础操作已完成,当前工作树构建成功、323 项回归通过,见 [phase-2.md](phase-2.md)。P2i 编辑器本地化基础已完成,隔离基线构建成功、328 项回归通过,见 [phase-2i.md](phase-2i.md)。下一阶段为 P3 Graph 完整交互。[设计](design.md) 和 [实施计划](phase-plan.md) 保留整体范围,其余阶段尚未开始。本记录覆盖前期创作工作区;游戏预览另立后续 feature。 +2026-10-15 用户已明确授权开始实施。P1a 基本页面、作者文档和项目会话已验证,见 [阶段记录](phase-1a.md);P1b 项目历史、事务保存与恢复骨架已于 2026-10-16 在当前工作树集成验证,构建成功、311 项测试通过,见 [phase-1b.md](phase-1b.md)。P2 Graph 基础操作已完成,当前工作树构建成功、323 项回归通过,见 [phase-2.md](phase-2.md)。P2i 编辑器本地化基础已完成,隔离基线构建成功、328 项回归通过,见 [phase-2i.md](phase-2i.md)。P3 Graph 完整交互已完成,当前工作树构建成功、339 项回归通过,见 [phase-3.md](phase-3.md)。P4a 文件事务与文档协调已完成,当前工作树构建成功、365 项相关回归通过,见 [phase-4a.md](phase-4a.md)。P4b 文件界面、P6a 共享 DTO UI 基础和 P5a Group 交互已验证,P5b 多选与剪贴板也已验证。[设计](design.md) 和 [实施计划](phase-plan.md) 保留整体范围,剩余范围见阶段状态与证据。本记录覆盖前期创作工作区;游戏预览另立后续 feature。 + +P6b 普通复杂值表单已验证:对象/列表、缺省/null/default、JSON 回退、四语、项目历史和保存重开已接通,879 项相关回归与解决方案构建通过;复杂定制与 AnimationPlan 按用户要求留后续阶段,状态查询/Locator 仍待实现。见 [phase-6b.md](phase-6b.md)。 + +2026-10-18 用户要求设计复杂定制,并补充参考 Unity 类对象/属性树、可选择动画轨道、逐键值/切线和曲线展开。详细方案见 [complex-editor-design.md](complex-editor-design.md),状态为 design;首版范围、手势/JSON 协议和运行时限制为本轮设计建议,尚未实施。 + +用户进一步收敛为两个工具:Timeline 时间轴先做,animate 自定义曲线后做。Timeline 使用独立 Dock,默认与资源同区域;属性叶节点右侧显示当前帧值,输入确认覆盖该帧已有键或新增键。AnimationPlan 全面改为 Timeline 的可行性、命名/内容影响及未来实时预览接点见复杂定制设计第 10–12 节;这些命名与预览方案尚未实施。 + +用户明确 Timeline Dock 跟随当前所选 Entry:Timeline 类型显示编辑器,其他类型或无选择显示不可编辑提示;不固定上一个 Timeline。多选按活动项处理。实时预览不纳入阶段计划,只保留架构备忘,不作为 Timeline 的依赖或验收项。 + +P6c Timeline 命名与契约已验证:类型/播放接口、timeline.play/timeline、转场和循环恢复统一,builtin.animations section 升至 v2;1081 项回归、解决方案构建及格式/源码指纹通过,见 [phase-6c.md](phase-6c.md)。 + +P6d Timeline Dock 与当前 Entry 绑定已验证:底部单实例、普通条目/无选择提示、已有轨道的当前帧 NumericUpDown 写键、项目历史、无损保存和旧行失效接通。解决方案构建成功,644 项相关回归及格式/源码指纹通过,见 [phase-6d.md](phase-6d.md)。 + +P6e1 已验证基础场景候选和对象→属性树,轨道可显式创建/移除,未知初值与偏移首键规则接通;GUI 创建者分配内部身份,Entry 粘贴按声明槽位重映射批内引用。分支/循环查询保守分类,不支持的 Timeline/转场/自定义语义提示不完整。解决方案构建、656 项相关回归、格式与源码指纹通过,见 [phase-6e1.md](phase-6e1.md)。P6e 整体仍在实施,P6e2 画布和关键帧操作见下方进度,曲线、事件/剩余引用随后推进;实时预览继续排除。 + +P6e2 已验证关键帧画布、树/画布行对齐、缩放/滚动、选择/拖动/删除、首键与碰撞保护、时长/FPS 和单历史手势。解决方案构建、901 项相关回归及格式/源码指纹通过,6 项原生媒体环境用例未执行;见 [phase-6e2.md](phase-6e2.md)。下一步 P6e3 曲线和切线,P6e4 事件与剩余语义;实时预览不在计划。 ## 问题与现有基础 +P6e2 用户反馈进一步收敛交互:持续显示轨道的绝对值/叠加偏移模式;结束帧/FPS 直接确认、保留帧号,结束帧至少覆盖全部键/事件,关键帧越界拖动自动延长;移除保留秒数、裁剪和应用入口及旧路径。修订进度见 [Timeline 交互简化](timeline-simplification.md)。 + - Editor.Shared 已有类型化编辑命令、项目查询、校验、编译和文件操作;GUI 仍有直接修改可观察模型、独立历史及保存映射路径,需要重新明确作者数据与页面状态的所有权。 - 当前历史按 Graph、UI、Settings 等领域组织,并根据激活目标路由。跨 Graph、Group 内容和文件的操作需要一致的撤销边界,不能简单以页面实例划分。 - 项目已有契约加载、DTO/字段描述、SchemaForm 与资源/表达式专属编辑器,应基于实际能力接入,不重新声明另一套字段事实。 @@ -128,7 +147,7 @@ UI Editor 的功能落后于当前项目能力,需要分析并重构整体架 ## 假设与开放问题 -- 已确认事项不重复询问;用户在设计及 Phase Plan 后明确授权开始业务实现,P1a/P1b/P2/P2i 已验证,P3 及后续阶段按计划推进。 +- 已确认事项不重复询问;用户在设计及 Phase Plan 后明确授权开始业务实现,P1a/P1b/P2/P2i/P3/P4a/P4b 已验证;P6a 共享 UI 基础独立提前验证,后续阶段按计划推进。 - 设计已比较模型/历史候选,明确文档身份、文件操作矩阵、未知内容保留、恢复快照及历史预算;具体类型名不作为已实现 API。 - 文件结构操作暂按“立即落盘并可撤销”设计,已向用户提出可选澄清但尚未单独确认;与延迟到保存的候选方案及未保存内容/保存基线处理见 design 6.2。这是可修订的设计假设,不伪称已确认产品要求。 - 功能顺序已落实为 P1a/P1b → P2 → P2i → P3 → P4a/P4b → P5 → P6 → P7;正式依赖、状态、验证和退出条件见 phase-plan.md。 @@ -136,9 +155,12 @@ UI Editor 的功能落后于当前项目能力,需要分析并重构整体架 ## 相关记录与当前事实 +- [GalGroup 属性编辑器改造设计](group-property-editor-design.md):用户于 2026-10-19 授权实施;统一布局/单重置、Transform/列表控件、Timeline 面板按钮和 ColorPicker 已 verified,802 项相关回归、解决方案构建与格式检查通过;范围和证据见 [实施记录](phase-group-properties.md)。 - [架构设计](design.md) +- [参数控件与场景目标设计(第 6 点)](parameter-editor-design.md):共享 UI 基础已完成 [P6a](phase-6a.md),状态查询、隐藏 handle 和动画编辑仍按后续切片推进。 +- [galgroup 编辑交互与控件扩展补充设计](group-editing-design.md):用户追加第 1–5 点纳入 P5;第 6 点仅设计,底层保留 handle,作者选择可读场景目标。 - [实施 Phase Plan](phase-plan.md) -- [Feature 工作流](../../docs/design/feature-workflow.md) +- [Feature 实施流程](../../.agents/skills/galnet-feature-implement/SKILL.md) - [当前架构](../../docs/spec/architecture.md) - [文件格式](../../docs/spec/file-formats.md) - [资源](../../docs/spec/assets.md) @@ -147,3 +169,39 @@ UI Editor 的功能落后于当前项目能力,需要分析并重构整体架 - [Editor 职责拆分](../F-20260929-10-editor-responsibility-split/summary.md):已有职责抽取,本次重新设计交互和所有权,不改写该 feature 的完成状态。 - [Editor/CLI 操作](../F-20261013-01-editor-cli-operations/summary.md):复用项目契约、查询、校验、编译和文件操作基础,不沿用其未要求文件撤销的范围限制。 - [参数契约共用与 Sample 简化](../F-20261013-02-parameter-and-sample-simplification/summary.md):复用值描述、schema 表单及专属编辑器基础。 + +### P5a 实施进度(2026-10-17) + +Group 补充第 1–5 点已验证:搜索/键盘类型选择、顶部批量和直接插入、折叠拖放、可滚动尾部与取消选择、Space 快速插入。类型及完整参数切换作为一次历史撤销。解决方案构建成功,614 项相关回归通过,另 6 个媒体环境用例未执行,见 [phase-5a.md](phase-5a.md)。P5 多选剪贴板、变量面板及剩余作者绑定迁移继续 planned;状态查询与专业动画编辑仍属后续范围。 + +### P5b 实施进度(2026-10-17) + +Entry 多选、剪切/复制/粘贴/副本、批量删除和多选拖放已验证,批量为单条项目历史;失败剪贴板/源变化/晚返回与集合补偿已覆盖。解决方案构建成功,493 项相关回归通过,whitespace/IDE0005 verify 与源码指纹核对通过。见 [phase-5b.md](phase-5b.md) 和 [Group 当前规范](../../docs/spec/editor-groups.md)。P5 变量编辑与剩余作者绑定迁移仍 planned,P6 状态查询/复杂值和游戏预览范围不变。 + +### P5c 实施进度(2026-10-17) + +统一变量面板已验证:查看菜单入口,玩家/存档页签,声明/初值/说明草稿与显式应用,按 UID 的项目历史、明确引用重命名及删除保护、候选诊断、已打开 Group 投影/补全/诊断刷新。说明字段纳入严格 Graph 格式与克隆/保存往返。解决方案构建成功,611 项相关测试通过,格式及源码指纹门禁通过,见 [phase-5c.md](phase-5c.md) 和 [变量当前规范](../../docs/spec/editor-variables.md)。剩余 Entry 作者绑定迁移、旧 Preview 路径与 P6/P7 范围继续单列,P5 整体不标记完成。 + +### P5d 实施进度(2026-10-18) + +Entry 类型、条件、参数和新增入口已迁移为显式项目意图,移除 Workspace 的 Entry 属性监听历史;表单直接提交作者 JSON,关闭页面不影响已提交历史,旧绑定不会随撤销重新生效。解决方案构建成功,614 项相关回归通过,见 [phase-5d.md](phase-5d.md)。未完成/未知作者内容的保存仍受现有完整契约校验限制,单列 P5e;P5 整体与 P6/P7、旧 Preview 范围均未宣称完成。 + +### P5e 实施进度(2026-10-18) + +GUI 作者草稿保存已验证:未知/不完整 Entry、无效参数值与未完成条件可保存重开,JSON 值无损,Entry/Graph 复制和项目历史有效;完整 CLI 校验/编译仍严格,结构、身份、路径和外部冲突防护保留。最终解决方案构建及 635 项相关回归通过,见 [phase-5e.md](phase-5e.md)。P5 整体已验证,后续 P6 状态查询/Locator 和复杂值、P7 前期整体验收继续推进。 + +2026-10-19 Timeline UI 简化已验证:统一“+”添加和右键取消重载,移除更换对象、属性 checkbox 与可添加属性开关;父行汇总关键帧可拖动同帧所有后代键。解决方案构建、580 项相关回归、格式和源码指纹通过,另六项原生媒体用例未执行。用户要求的属性内联值曲线、关键帧入/出切线及自动模式菜单、替换独立详情区列入 P6e3b,仍 planned;详见 [phase-6e3.md](phase-6e3.md)。 + +P6e3b 已验证:属性内联值曲线、共享 Runtime 采样器、关键帧右键双侧/入侧/出侧模式、持久自动贝塞尔重算、联动/断开、值/柄单历史手势及精确输入弹窗替换独立详情区。修正 Step 精确内部键的共享采样边界。隔离候选解决方案构建、六项目 810 项及弹窗收尾 Editor 184 项、格式/指纹通过;另六项原生媒体未执行。原工作区另有控件改动,首次完整回归 3 项非 Timeline 失败记录保留,未混入提交。见 [phase-6e3.md](phase-6e3.md)。P6e3 verified,P6e4/P6f 随后,实时预览仍不在计划。 + +P6e4 实施中,按 [phase-6e4.md](phase-6e4.md) 分为 a 帧内查询/事件能力、b 事件作者 UI、c 剩余状态/引用。a 已验证,将事件创建者和未来候选接入既有对象选择器,分开查询首帧初值/当前值;隔离候选解决方案构建、797 项回归及格式/源码指纹通过,另 2 项 Group 布局失败已在基线独立复现并保留记录。事件行与详情 UI 不在本切片。P6e4 整体及 P6f 尚未完成。 + +P6e4b 已验证(2026-10-20):事件独立行、受限搜索类型选择、多选拖动/删除与同帧排序;参数复用共享 DTO 面板,资产/场景控件复用,使用事件前 Locator,可取消草稿应用单历史。拖动只预览帧号,切换/撤销/关闭使旧草稿失效,未知 JSON 和身份保留。隔离解决方案构建、799 项相关回归和格式/指纹通过,仍保留两项已在基线复现的 Group 布局失败;见 [phase-6e4.md](phase-6e4.md)。P6e4c/P6f 尚未实施。 + +### P6e4c3b 实施进度(2026-10-21) + +效果 program metadata 的后台资源快照与 legacy catalog 能力接通,静态/默认初值和 Core progress 规则明确,资源刷新与项目关闭使旧加载/选择/编辑行失效;Timeline 属性菜单、数值输入、单步历史和保存重开已验证。隔离解决方案构建、1192 项相关回归与格式/源码指纹通过,另两项既有 Group 布局失败保留,6 项原生媒体未执行。阶段证据见 [phase-6e4.md](phase-6e4.md),实现摘要及规范链接见 [summary.md](summary.md)。P6e4c 仍在实施,继续播放实例 Locator 与运行时缺目标核验。 + +### P6e4c4 实施进度(2026-10-21) + +播放实例 Locator 与停止字段已接通:播放控制和场景对象独立查询,共用选择控件与项目代次;显示本地化类型/来源、支持英文类型搜索,语言切换保留域,旧引用/旧选择与单历史保存行为已验证。纯查询 100 项及真实选择器专项通过,隔离构建和七项目 1239 项回归通过,保留两项既有 Group 布局失败,6 项原生媒体未执行。证据见 [phase-6e4.md](phase-6e4.md),稳定行为见 [场景目标规范](../../docs/spec/editor-scene-targets.md)。P6e4c 继续剩余内置语义及缺目标运行时独立核验,实时预览不列 Phase。 diff --git a/features/F-20261015-02-editor-authoring-workspace/group-editing-design.md b/features/F-20261015-02-editor-authoring-workspace/group-editing-design.md new file mode 100644 index 00000000..d86ed0a9 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/group-editing-design.md @@ -0,0 +1,63 @@ +# galgroup 编辑交互与控件扩展补充设计 + +日期:2026-10-17。状态:design;P5/P6 尚未实施。来源:用户追加 6 点要求。此文原为设计阶段记录;用户随后授权第 6 点开始实施,共享 UI 基础进度见 [P6a](phase-6a.md),其余设计不因开始实施而宣称已完成。 + +## 现有基础与问题 + +- EntryTypePicker 已是 ToggleButton + Popup + 分类 TreeView,并非必须替换成原生 ComboBox;当前打开时焦点落到树,没有搜索。 +- EntryListEditorControl 顶部只有添加,底部有数量/批量追加。Group VM 的 Add 在选择后插入、无选择时末尾插入;Append 始终末尾。新需求调整布局,并统一类型选择的插入意图。 +- ParametersToggle 在模板中写死 IsChecked=True,拖动预览重新使用 ItemTemplate,重排/虚拟化也可能重建容器。折叠状态必须离开容器,不能靠保留控件实例修补。 +- 现有 SchemaForm、EditorRegistry、IFieldBinding、FieldEditorContext/Lease 已供 Editor 和 Sample 设置页复用。EntryDefinition.FieldDataType 保留根 DTO Type,FieldDescriptor.Value.ValueType 保留字段 Type,可以接类型注册;共享表单不拥有项目历史。 +- EditorRegistry 当前按显式 EditorId 或值类型默认工厂解析,不具备整个 DTO 面板替换、按 DTO 成员替换及场景目标分析。AnimationPlanOptions.Plan 当前是 JsonElement,不能直接按 AnimationPlanDefinition 类型命中 timeline。 + +## 1. 可搜索的类型选择器 + +保留现有分类展示及快捷键提示,弹窗顶部增加搜索框。每次打开清空上次搜索并直接聚焦搜索框,初始不预选结果;可见文本为当前 UI 语言的名称,旁边始终显示稳定英文 type ID,缺译时使用 ID。搜索按当前显示名称和完整英文 ID 忽略大小写匹配,空查询显示全部。 + +拼音作为轻量别名:内置中文名称可提供人工确认的全拼/首字母别名;自定义类型可选贡献别名。没有别名也能按名称/ID 查找。不新增大型转写依赖,不承诺多音字推断、声调、纠错或模糊中文分词。语言切换重新建立显示名索引,别名不修改作者内容。 + +键盘状态分为查询和高亮:输入改变后清除旧高亮;首次 Up 或 Down 都高亮首个可选叶结果,后续 Up/Down 向前/后移动,边界停住,跳过分类标题。搜索框可保持文本焦点并处理导航键;滚动确保高亮项可见。Enter 仅确认当前高亮,零结果/无高亮不创建;鼠标点击叶项直接确认。Escape/外部关闭不改内容。IME 正在组合时不拦截其 Enter/方向键,空格在搜索框中输入文字。 + +统一组件暴露确认事件/意图,区分两个用途:条目行内选择器确认会更改该 Entry 的类型;顶部/空格插入选择器确认会新建 Entry。不能用 SelectedValue 的属性变化触发新建,防止初始化、重开及选同一类型造成意外行为;重复确认相同类型仍能连续插入。 + +## 2. 顶部工具区与插入规则 + +布局顺序:添加条目 | 追加条目 | NumericUpDown | 类型选择器。使用文本按钮,保留无障碍名称和四语说明;窄 Dock 允许自然换行。删除原底部工具区域。 + +“添加条目”默认在当前活动 Entry 后插入,无选择则追加末尾;“追加条目”无论选择始终追加末尾。类型选择器直接确认时,在活动 Entry 后插入所选类型一条,无选择则末尾一条。新建后选中新项并滚动到可见区域;批量创建后活动项为最后一个。一个批量意图一条项目历史记录,分配全新稳定 ID,不使用 catalog 第一项作为隐式默认类型。 + +用户已确认:数量作用于添加/追加两个按钮,类型选择器直接确认始终一条。普通按钮使用显式配置的默认类型(优先已确认的插入类型,无配置则内置 dialogue.text;不可用时打开类型选择器),不猜测契约首项。 + +P5 计划保留多选能力时,插入锚点使用最后活动项,其他选中项不被更改;活动锚点删除/移动后按稳定 ID 重新检查。弹窗打开时捕获插入锚点,不因按钮获取焦点丢失锚点;锚点失效则取消插入并提示,不悄悄插入其他位置。 + +## 3. 折叠、拖放与选择 + +页面状态按 (Group DocumentId, Entry StableId) 保存 IsParametersExpanded,不写作者 JSON、不进入内容 Undo、不产生 dirty。新 Entry 默认展开;旧 Entry 重排、同页 Undo/Redo、容器回收与拖动预览读取同一状态,复制生成的新身份使用默认状态。关闭页面释放页面状态,跨重启不承诺保留。 + +ListBox 内容尾部留约 64 DIP 可点击空白,列表铺满也能滚动到这块区域;不用伪 Entry 充当占位,避免影响序号/重排/编译。点击本 Group 编辑区内 Entry 行之外的背景会取消选择。工具区、弹窗、滚动条和行内控件不触发背景取消,其他 Dock 页面不会通过全局点击监听清空此页选择。清空后新建以末尾为锚点。用户拖动过程中不执行背景取消。 + +## 4. 空格快速插入 + +Group 内容页面或列表拥有键盘焦点时,Space 打开与顶部同一套插入类型弹窗:有活动 Entry 则锚定该行边上,无选择则锚定列表尾部空白;必要时先滚动到锚点。确认插入一条后关闭弹窗,选择新项并恢复列表焦点;取消恢复原焦点,不改内容。 + +TextBox、NumericUpDown、条件/表达式控件、已有类型搜索框和其他正在编辑的控件先处理空格;不通过全窗口 Tunnel handler 抢键。按键重复只保持一个弹窗。没有选中项时“页面有焦点”指该 Group 页面内部键盘焦点,不是鼠标悬停或后台 Dock 页。 + +## 5. 第 6 点:参数控件与场景目标扩展(仅设计) + +第 6 点的完整方案集中到 [参数控件与场景目标设计](parameter-editor-design.md),此处只保留边界:默认使用共享 SchemaForm;支持特定 DTO、特定 DTO 成员、默认类型三层映射,默认 DTO 表单负责后两层,多 DTO 面板负责逐项分派;Editor 与 Sample 在程序入口组合注册,各自提供业务 binding。底层 handle 保留,对演出/剧本作者隐藏,场景实例目标与资产库资源分开解析,状态只分一定存在/可能存在,缺目标跳过执行。三层注册基础已由 P6a 验证,Locator 和 timeline/曲线尚未实现。 + +## 验收与阶段归属 + +P5 承接第 1–5 点:真实弹窗输入/焦点/IME/UpDown/Enter/Escape、当前四语名称和英文 ID、重复选同类型新增、顶部布局/数量、背景取消/末尾插入、跨页选择隔离、折叠后拖放与虚拟化/Undo、每次完整插入一条项目历史。拼音仅验收明确声明的别名,不承诺全汉字转写。 + +第 6 点原为 P6 的设计输入,后续授权后先独立实现 P6a 的共享 Forms 与宿主接入;契约与 Runtime 不在该基础阶段修改。P6 未来实现先完成注册/解析/lease/binding 的小型端到端切片,再分别安排成员/根 DTO 替换、场景引用、timeline/曲线;不把完整场景分析、任意自定义 Entry 推断和专业动画工具塞进同一个最小阶段。Sample 采用同一 API 可行,其设置页继续使用设置 binding,Editor 使用作者文档 binding,二者不共享历史/业务会话。 + +用户已确认数量作用域和 handle 隐藏边界,没有阻塞本设计的待答问题。P5 与 P6 剩余功能仍为 planned;共享 UI 基础的实现事实与证据单列 P6a。 + +### P5a 实施进度(2026-10-17) + +Group 补充第 1–5 点已验证:搜索/键盘类型选择、顶部批量和直接插入、折叠拖放、可滚动尾部与取消选择、Space 快速插入。类型及完整参数切换作为一次历史撤销。解决方案构建成功,614 项相关回归通过,另 6 个媒体环境用例未执行,见 [phase-5a.md](phase-5a.md)。P5 多选剪贴板、变量面板及剩余作者绑定迁移继续 planned;状态查询与专业动画编辑仍属后续范围。 + +### P5b 实施进度(2026-10-17) + +Entry 多选、剪切/复制/粘贴/副本、批量删除和多选拖放已验证,批量为单条项目历史;失败剪贴板/源变化/晚返回与集合补偿已覆盖。解决方案构建成功,493 项相关回归通过,whitespace/IDE0005 verify 与源码指纹核对通过。见 [phase-5b.md](phase-5b.md) 和 [Group 当前规范](../../docs/spec/editor-groups.md)。P5 变量编辑与剩余作者绑定迁移仍 planned,P6 状态查询/复杂值和游戏预览范围不变。 diff --git a/features/F-20261015-02-editor-authoring-workspace/group-property-editor-design.md b/features/F-20261015-02-editor-authoring-workspace/group-property-editor-design.md new file mode 100644 index 00000000..c2e57b16 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/group-property-editor-design.md @@ -0,0 +1,146 @@ +# GalGroup 属性编辑器改造设计 + +日期:2026-10-18;实施授权:2026-10-19。状态:verified;用户已确认没有阻塞即可实施,实际实现范围、验证及保留边界见 [实施记录](phase-group-properties.md)。下文保留设计阶段的候选与取舍,不将所有长期建议都标记为已实现。 + +归属:F-20261015-02 Editor 创作工作区,承接 P6a/P6b 的共享参数表单与 P6d/P6e 的 Timeline 会话。用户本次要求:布局整齐、属性标题左上对齐、重置移到同行右侧、移除 null/省略等常驻操作、常用复杂值尽量不用 JSON、Timeline 改为打开面板按钮、颜色用 ColorPicker。以下默认行为和扩展方式是建议,尚未经用户逐项确认。 + +## 1. 已核实的现状与原因 + +- `EntryListEditorControl.axaml` 覆盖共享 SchemaForm 模板,列宽为 `130,*`;标题 `VerticalAlignment=Center`,输入区域把输入、JSON 标签、FieldEditorActions 和错误纵向堆叠。复杂控件越高,标题越偏离左上,操作行又增加高度。 +- `FieldEditorActions` 同时显示默认值、omit、null、恢复草稿及状态符号。`ResetDefaultAsync` 当前写入显式默认值,不等同于删除属性;只对有默认值的字段可用。 +- EditorRegistry 已提供整 DTO、DTO 成员和值类型注册;字段解析依次考虑成员覆盖、自定义类型工厂、显式 EditorId、自动推导。普通 IFieldDto 对象和一维列表已能递归生成控件,不必重新建设属性系统。 +- 更直接的 Transform 原因是 Builtins 的 Transform/ToTransform 字段实际声明为 `JsonElement`,且显式标记 `EditorId=json`;不是已经声明为 LayerTransform 而只缺一个模板。因此只注册 `LayerTransform` 默认工厂无法命中现有字段。LayerTransform 本身也没有生成字段 schema。 +- TimelineOptions.Timeline 同样是 JsonElement;现有项目级 TimelineEditSession、活动 Entry 上下文和单实例 Dock 可以复用,不应再在行内构建第二个 Timeline 编辑器。 +- LayerColorOptions.Color、ColorFadeOptions.Color 等颜色字段是字符串文本编辑器。Editor 已引入 ColorPickerSemiTheme;已有主题依赖不能代替实际绑定和提交验证。Layer 的颜色约定是 `#RRGGBB` / `#AARRGGBB`。 +- 作者文档保留缺省、显式 null、未知 JSON 与不完整值;历史和保存归项目会话。嵌套字段当前仍做根字段校验,其他坏子值可能阻止局部修复。 + +## 2. 目标、边界和布局 + +默认表单以作者可理解的控件表达值。首批覆盖普通字段、Transform、普通对象/列表、颜色和 Timeline 入口,不把所有动态效果参数、粒子系统及 Flipbook 一次纳入。作者格式和 Runtime 含义本轮保持兼容,不因为 UI 扁平化更改 JSON 层级。 + +统一字段容器,负责标签、布局、重置、描述和错误;专属编辑器只负责值区域,避免每个控件各自安排标题和按钮。 + +宽布局: + +```text +资源 [选择资源 / 当前资源名称 ] [↺] +变换 [位置 X [ ] Y [ ] ] [↺] + [旋转 [ ] ° ] + [缩放 X [ ] Y [ ] ] +颜色 [色块] [#AARRGGBB ] [↺] +时间轴 [打开时间轴] 120 帧 · 60 FPS · 3 条轨道 [↺] +``` + +- 使用三列 `标签 / 值 / 操作`;同一表单的标签和重置列统一,标题垂直靠上,与第一行控件文字有一致的顶部留白。重置与该属性的第一行同行,右侧对齐。 +- 标签宽度用统一主题约束,并结合可用宽度换布局,不让每行独立测量造成错位。长标签换行;窄 Dock 改为标题行加下方值区,重置仍在标题行右侧,不掉到输入区底下。 +- 输入高度、行距、组间距和嵌套缩进统一;普通对象用本地化组标题,去掉 CLR 类型名和重复的 element 标题。复杂编辑器内部可分组,避免层层边框。 +- 错误放在值区下方,只在有内容时占空间。帮助优先 Tooltip,确实影响填写的说明才常驻。重置图标具备 Tooltip、键盘焦点及无障碍名称。 +- 统一容器可放共享 Controls,Group 的简化交互策略由宿主显式选择;不依靠一个全局样式悄悄改变 Sample 设置页的业务语义。 + +## 3. 清空、缺省和重置 + +去掉常驻的省略、null、恢复草稿按钮和状态符号,只留一个右侧重置图标。Escape 继续恢复本次未提交输入,项目 Undo 恢复已提交操作;错误或空值不再自动触发 JSON 编辑器。 + +“没填就是空”需要按值类型定义,不能把数值空白解释为 0、把布尔空白解释为 false: + +| 场景 | 建议行为 | +| --- | --- | +| 从未填写 | 保持缺省;有默认值显示其有效值,不自动写入作者文档 | +| 普通字符串清空 | 提交空字符串;空白字符串是否允许由字段校验决定,不统一 Trim | +| 资源/场景目标清空 | 取消已选引用:可选字段恢复缺省;必填字段显示未填写状态与诊断 | +| 数字/枚举清空 | 有默认或可缺省时提交缺省;必填无默认时保留空草稿并提示,不写 0/首项 | +| 有默认值点重置 | 恢复声明的默认值,沿用显式默认值提交;一次历史,等值无历史 | +| 无默认值点重置 | 可选字段恢复缺省,Tooltip 为“清空”;必填且无默认值时禁用并解释原因 | +| 已有显式 null | 保留原始状态,显示为空;仅显示/打开不转换为缺省。用户明确填写后才替换 | + +这是简化 UI,不是合并持久语义。若确有“显式 null 才能关闭继承”等字段,应提供该领域的“无/禁用/自动”选项,不恢复通用 null 按钮。无默认字段不擅自把 CLR 零值当作声明默认值。 + +父对象从未提供时,只展示有效子默认值;首次修改才创建所需父对象并写入目标路径。子属性默认值与父字段 DefaultJson/运行期默认值必须一致,特别是 ScaleX/ScaleY 为 1。对象重置可替换整个对象,这是用户明确操作;普通子字段编辑则必须保留未知成员。 + +## 4. 自定义控件、自动对象与扁平化 + +采用“专属编辑器 + 通用结构表单 + 布局提示”的组合,三者不是互斥选择。 + +### 4.1 先复用注册,补 Transform 控件 + +第一步用现有成员注册覆盖所有 Builtins Transform/ToTransform 字段,即使它们的 EditorId 目前为 json,也能命中成员覆盖。控件提供 X/Y、角度和 ScaleX/ScaleY,显示逻辑画布像素与度;保留负缩放,0 是否允许按领域校验,不额外施加任意范围。比例锁定可后续增加,默认关闭。 + +编辑器通过隔离的 JSON 路径适配器读写当前字段,只 patch 被编辑成员,保留未知键与原字段序列化约定;不 deserialize 后重新 serialize 整个 LayerTransform。默认值需从已确认的领域定义与父字段配置取得,不能维护两份互相漂移的数字。 + +更长期可以把稳定结构的作者参数改成类型化 DTO,或给 JsonElement 增加中立的“语义结构描述”,使通用表单识别 Transform。两者都涉及 schema/校验和兼容性,单独评估;本轮无需为了换控件先迁移 Runtime 类型。 + +### 4.2 普通对象默认自动生成,视觉展开不改变数据 + +已有生成 schema 的 DTO 继续递归分派,短对象默认直接显示,较长对象可折叠。不添加一个让所有对象无条件反射展开的全局规则,避免把 converter、多态对象和递归类型错误当普通 DTO。 + +可后续扩展中立布局提示,以下名称仅为设计示意,不是现有 API:`Inline/Expanded/Collapsible`、分组和顺序。宿主注册可配置;确有跨宿主复用需求再扩展 Field/EditorHints 及 generator。属性特性只声明布局或 editor ID,不引用 Avalonia Control 类型。 + +扁平化指 `transform.x` 在同一值区显示,写回仍是 `transform: { x: ... }`,不是把 x 提升为 Entry 参数。小型数值结构用专属紧凑布局;普通业务对象用自动表单,避免给每个 DTO 手写控件。 + +### 4.3 列表按元素描述递归分派 + +- 数字、字符串、枚举:紧凑行。 +- DTO:元素标题与操作栏,下方自动表单;已注册 DTO 面板仍优先。 +- 特殊值:调用同一编辑器解析规则;元素描述必须携带语义提示和 converter,不能仅凭 CLR 类型猜颜色或 Transform。 +- 新增/删除/移动各为一个意图;结构变化使旧索引 binding 失效,旧弹窗和异步提交不得写到新位置。 +- 空列表显示空态和添加,不显示 `[]`。新元素使用声明默认值;复杂必填元素必要时先本地草稿确认,避免默默写一堆 null/0。 +- 现有 200 元素/8 层防护不能直接移除。大列表优先分页或虚拟化;深递归显式按需展开。动态字典、多态等结构单列能力,不承诺任意 JSON 都能自动生成合适 UI。 + +注册优先级本轮维持现状,文档中明确成员覆盖、自定义类型工厂、显式 EditorId 和自动推导的顺序。若以后调整显式提示与类型工厂的优先级,应视作独立兼容性改动。 + +## 5. Timeline 入口 + +Timeline 字段用“打开时间轴”按钮与简短摘要替代 JSON。成员工厂由 Editor 注入打开/激活 Dock 的动作,不让共享 Controls 依赖 Dock 或 Editor 服务。 + +点击顺序:校验 Group/Entry 稳定身份与当前 scope → 将该行设为活动 Entry → 打开或激活项目单实例 Timeline Dock → 现有会话跟随目标。不能只打开面板而仍编辑上一个 Entry;也不能每点一次新建会话。 + +打开和读取摘要不写作者内容、不增加历史。缺省/null 时 Dock 保持现有明确创建入口;按钮本身不顺带创建空 Timeline。坏 Timeline 仍可打开,显示诊断与修复入口,不静默清空。关闭 Dock 后按钮可以重新打开。旧行在删除、换类型、项目关闭后不得导航或提交。 + +Timeline 没有声明默认定义,因此重置禁用,不把 `{}` 当合法默认,也不把“重置”暗中定义为清除全部轨道。若需要清空内容,未来另设有明确语义的 Timeline 操作。曲线、事件、实时预览仍按已有独立范围推进。 + +## 6. 颜色 + +颜色编辑器为色块按钮、ColorPicker 弹层及可直接输入的十六进制文本,支持透明度和棋盘格背景。使用成员/语义注册命中颜色字段,不按属性名 Color 或所有 string 全局替换。 + +新提交使用 `#RRGGBB` 或含透明度的 `#AARRGGBB`,明确 alpha 在前;打开旧值不重写大小写或格式。原值无法解析时保留原文本并显示错误,不能替换为黑色/透明。 + +弹层拖动只更新本地预览;明确确认后一次项目历史,取消/Escape 恢复。文本 Enter/失焦遵循既有提交规则。外部值改变、撤销、Entry 失效时取消旧预览。透明度与 Layer.Opacity 是独立字段,不互相覆盖。解析支持范围和格式需以项目实际消费者为准,不能仅以 ColorPicker 接受的格式扩展文件规范。 + +## 7. 数据流与职责 + +```text +字段容器 → registry 选择值控件 → 控件本地草稿 + → 字段/路径 patch → IFieldBinding / 项目作者意图 + → 局部校验 → 单次内容历史 → 作者文档 → 表单与 Timeline 刷新 +``` + +共享 Controls 拥有布局、草稿、普通结构与 lease 生命周期;Editor 拥有 Builtins 成员组合、颜色/Transform 语义适配、Dock 跳转、项目历史与保存。Core/Contracts 只拥有中立字段事实,Runtime 不依赖 UI。 + +正常编辑不通过 DTO round-trip 丢失未知成员。初始化、打开/折叠、切语言和切选中项不写默认值。所有新编辑器复用现有版本/取消规则,清理订阅并拒绝失效 binding。 + +## 8. 问题、候选方案与风险 + +1. **只改模板**能解决 1–3 的外观,但不能解决 Transform 的 JsonElement 契约、清空语义和 Timeline 定位,因此需要一层小的交互策略。 +2. **所有复杂值都改 DTO**能增强 schema,但波及契约/编译器/Runtime,收益不能只按少写几个控件衡量。本轮先成员覆盖,逐类迁移再议。 +3. **所有对象都用反射展开**对 converter、动态参数、深递归不可靠;已有 DTO schema 优先,无描述时显式提示能力缺失。 +4. **完全删除 JSON 修复能力**会让坏值、未知字段或不支持的 schema 无法修复。建议普通表单不露 JSON 按钮,异常时提供独立“高级源码/修复”入口;原有 UI 未提供的修复操作需要明确补齐,不能假设已存在。 +5. **根字段校验**可能让正确的子字段修改被另一个坏子值阻止。第一步在诊断中指出具体路径;若要逐项修复,应设计保留原始作者 JSON 的局部 shape/value 校验,不通过关闭全部校验解决。 +6. **一律把空值转 null**会覆盖缺省语义、默认值和继承含义。按第 3 节定义清空策略,旧值无编辑时无损保留。 +7. **颜色拖动/数值滚动的历史噪声**需要以用户一次手势或一次确认合并;本轮 ColorPicker 已明确本地预览提交边界。 +8. **布局仅在普通短文本验证**无法证明真实效果;必须覆盖嵌套、长标题、错误状态、主题模板、四语及缩放。 + +## 9. 建议推进和验收影响 + +建议拆为:A 统一字段容器与清空/重置策略;B Transform 与 ColorPicker;C Timeline 跳转与摘要;D 普通对象/列表展示完善及必要修复入口。A/B/C 先让高频创作摆脱 JSON,D 根据具体 schema 逐项补齐,不把整个任意结构编辑器作为前置工程。 + +验收至少包含: + +- 窄/宽 Dock、长本地化标题、100%/150%/200% 缩放,标题左上、重置同行右侧,无遮挡错位;真正挂载主题后的交互与截图。 +- 清空/默认/必填/可选/已有 null 的保存重开与 Undo/Redo;仅打开、切语言无 dirty;无默认字段不伪造默认值。 +- 每个 Builtins Transform/ToTransform 注册命中;缩放默认 1、负值、未知键保留、错误对象不被自动替换。 +- 普通 DTO、标量列表、DTO 列表、专属元素工厂、converter 与列表重排旧输入失效。 +- 颜色 alpha 顺序、取消不写、确认一条历史、原有非法值可见、外部更新取消旧预览。 +- 点击非活动 Timeline 行准确定位;关闭后重开、无值不创建、删除/换类型旧按钮失效;已有轨道与未知内容不丢。 +- JSON 修复退出普通路径后,未支持类型和坏值仍有明确可行的诊断/修复路径。 + +本文保留设计阶段的目标与取舍;实际实现、验证结果和明确的延期项以 [实施记录](phase-group-properties.md) 为准。 diff --git a/features/F-20261015-02-editor-authoring-workspace/parameter-editor-design.md b/features/F-20261015-02-editor-authoring-workspace/parameter-editor-design.md new file mode 100644 index 00000000..48182057 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/parameter-editor-design.md @@ -0,0 +1,194 @@ +# 参数控件与场景目标设计(用户第 6 点) + +最新阶段调整:通用场景定位器选择/解析及普通 DTO 字段接入提前到 P6e2b,先于曲线编辑,Timeline“+”和更换对象复用同一组件。范围与退出条件见 [scene-locator-plan.md](scene-locator-plan.md);已有查询/SceneLocator 不等同于已完成选择控件。 + +日期:2026-10-17;补充:2026-10-18。状态:design;用户随后已授权实施,共享 UI 基础已由 [P6a](phase-6a.md) 验证,普通嵌套/列表和 presence 操作由 [P6b](phase-6b.md) 验证,其余部分仍为设计。所属:[Editor 创作工作区](feature.md) / P6;Group 交互第 1–5 点另见 [Group 编辑设计](group-editing-design.md)。本文件集中第 6 点的总体职责与状态查询设计;复杂控件的细节见 [对象树时间轴设计](complex-editor-design.md),不重复建立另一套注册/查询架构。 + +## 1. 目标与已确认边界 + +默认以 DTO/schema 生成参数面板;多数扩展只需声明类型、字段及值约束。需要特别交互时,可以替换一个字段的控件,也可以替换整个 DTO 的参数面板,其他字段仍能复用自动表单。Sample 设置页与 Editor 共用这套能力,开发者在程序入口注册扩展。 + +用户已确认:写演出/剧本的人不需要知道 handle。Editor 展示名称、缩略图和可选择的场景目标;底层继续使用 handle,C# 执行接口不在本轮更改。EditorId 也不作为普通控件扩展作者必须掌握的注册方式;内置 hint 可继续作为框架内部适配。 + +原设计阶段不修改业务代码;用户随后授权开始,P6a 先落地三层注册/默认表单/多 DTO 面板及两个宿主接入,目标解析、timeline/曲线及内容格式不在 P6a 实施。后续实施不考虑旧 UI API 兼容层,但任何 wire 格式变动仍须统一处理 codec、编译、执行与 fixture,不能仅改一个控件。 + +## 2. 当前可以复用的事实 + +| 现有部分 | 复用方式 | 尚缺能力 | +| --- | --- | --- | +| EntryDefinition.FieldDataType | 精确定位根 DTO 类型 | 整 DTO 面板工厂入口 | +| FieldDescriptor / ValueDescriptor / ValueCodec | 字段类型、值约束、JSON 转换 | 成员路径与局部工厂映射 | +| SchemaForm / EditorRegistry | 默认表单及已有字段工厂 | 类型覆盖统一解析、嵌套组合 | +| IFieldBinding / FieldEditorContext / FieldEditorLease | 草稿、提交、诊断及控件生命周期 | 整 DTO 的多字段原子编辑上下文 | +| Editor 的作者 JSON 与项目历史 | 唯一内容真源及 Undo/Save | 复杂交互的手势事务适配 | +| SampleCustomization / DtoBinding | 程序入口组合及设置数据适配 | 新注册协议的共享接入 | +| IAssetManager / ISceneInstanceManager | 分别查询资产、运行时实例 | 未启动游戏时的作者场景目标分析 | + +P6c 后当前 TimelineOptions.Timeline 是 JsonElement,尚不是 TimelineDefinition 类型字段;目前没有发现可直接复用的作者场景 Locator。不能把这些目标能力写成已存在服务。 + +## 3. 两个独立子系统 + +用户明确分成共享 UI 构建与 Editor 状态查询两部分。UI 不自行推演游戏状态,状态查询不创建 Control。二者只在 Editor 提供的动态候选 binding 处组合。 + +| 子系统 | 归属 | 职责 | +| --- | --- | --- | +| UI 构建 | 共享 Avalonia.Controls Forms 层 | 三层注册、默认 DTO 表单、多 DTO 面板、控件上下文与 lease | +| 状态查询 | Editor.Shared 的中立查询/分析服务,Editor UI 适配 | 按当前位置查询资源/实例及其存在性、增量推演与缓存 | + +Sample 与 Editor 使用同一 UI 构建机制,分别注入设置 binding 与作者文档 binding。Core/契约 DLL 不依赖 Avalonia,默认设置页不依赖 Editor 状态查询。 + +~~~mermaid +flowchart TD + Items[有序 DTO 项列表及各项 binding] --> Panel[多 DTO 面板] + Registry[三层控件注册表] --> Panel + Panel --> Choice{特定 DTO 控件映射} + Choice -->|有| Custom[自定义 DTO 控件] + Choice -->|无| Form[默认 DTO 表单] + Registry --> Form + Form --> Field[成员映射优先,否则默认类型映射] + Custom --> Binding[共享编辑上下文 / 宿主 binding] + Field --> Binding + State[Editor 状态查询与缓存] --> Options[Editor 动态候选适配] + Options --> Binding +~~~ + +共享面板只负责把一组 DTO 项转换成一组控件,不拥有 Group 的增删/排序/选择命令。项目扩展工厂可以由独立 Editor UI 扩展程序集贡献,纯 schema DLL 仍不包含 Control;不扫描任意程序集自动启用扩展。应用内置注册可作模板,项目集合随 scope 释放,避免全局缓存 Type/项目服务使程序集无法卸载。 + +## 4. 三层注册、默认表单与多 DTO 面板 + +注册表严格保留三层映射,不把内部 hint 再扩展成开发者必须注册的第四层: + +| 映射 | 键 | 工厂负责的范围 | +| --- | --- | --- | +| 特定 DTO → 控件 | 精确 DTO 类型 | 一个 DTO 的整个参数区 | +| 特定 DTO 成员 → 控件 | 声明 DTO 类型 + 成员路径 | 该成员的单个字段区域 | +| 默认类型 → 控件 | 字段值的精确类型 | 没有成员覆盖时使用,例如 string/bool/数值/曲线 | + +以下只是设计 API,不是已实现接口: + +~~~csharp +editors.RegisterDto(ctx => CreateCustomPanel(ctx)); +editors.RegisterMember(x => x.Timeline, ctx => CreateTimelineJsonAdapter(ctx)); +editors.RegisterDefault(ctx => CreateTextEditor(ctx)); +editors.RegisterDefault(ctx => CreateCurveEditor(ctx)); +~~~ + +**默认 DTO 表单**负责实现普通 DTO → 控件。它读取同一 schema,逐字段查特定 DTO 成员映射;未命中才使用默认类型映射,然后生成标签、控件和诊断。它不负责“整 DTO 是否被覆盖”的分派,也不负责枚举一组 DTO。值约束、固定/动态选项、单位、格式、只读/可见状态通过字段上下文传入类型工厂;例如默认 string 工厂可以根据选项/多行提示选择内置展示,不需要开发者再注册 editor ID。 + +**多 DTO 面板**接收有序项列表,每项包含稳定 ItemId、DTO 类型/schema 和该项 binding。逐项查特定 DTO → 控件映射;存在则挂载自定义控件,不存在则挂载默认 DTO 表单。面板以 ItemId 复用/释放项,不能以 Type 作唯一 key,因为多个 Entry 常共享同一种 DTO。宿主负责提供顺序与条目外壳;Sample 的设置分组、Editor 的 Entry 列表都可复用其创建/生命周期逻辑,不要求使用相同列表皮肤。 + +嵌套对象由默认结构类型编辑器处理;需要应用整 DTO 覆盖时,复用面板背后的同一个“单 DTO 创建”方法,避免表单和面板各实现一套分派。成员覆盖始终先于默认结构编辑器,集合元素按稳定项身份或集合版本管理,不能把数组下标作为永久绑定身份。 + +成员表达式通过 schema/generated 成员信息解析 wire ID,不直接把 CLR 属性名当 JSON 字段名。首版精确类型匹配,不猜测继承/接口优先级;nullable 可解包基础类型选控件,但 null/presence 与 converter 保持原字段语义。重复注册报错,显式 Replace 才替换;注册完成冻结。 + +原有 EditorRegistry/EditorId 只作实施过渡与内置提示适配,最终公开组合入口是以上三层映射,不要求扩展作者理解字符串 editor ID。已有专属资源/表达式控件在 Editor 组合点注册到对应 DTO 成员,不能为了目标字段全局替换所有 string。未知类型统一走明确的 JSON/结构回退或诊断,递归类型防护不额外建立注册层。 + +## 5. 复用自动表单与编辑协议 + +整 DTO 自定义控件可组合:自定义区域 + 默认生成其余字段。例如 timeline 管理 Plan,BatchId 仍自动生成;不必重新写整套标签、校验、布局和保存。 + +面板上下文提供“创建默认表单并排除已负责字段”的能力。它直接调用默认 DTO 表单,保留成员/默认类型映射,不再次经过多 DTO 面板的整 DTO 分派,避免创建自身。一个字段只由一个已挂载区域负责,避免两个草稿相互覆盖。自动生成按层级展开需要创建控件,循环结构显示可操作的回退。 + +整个 DTO 上下文不能假定作者内容已经是有效 DTO:Entry 可能缺必填值或含未知字段。建议暴露 schema、只读 JSON 快照、字段/子路径 binding、动态选项、原子 patch/手势事务、诊断、取消和版本戳;不向控件交出可直接修改的唯一作者对象。 + +- Editor 的真实数据仍为作者 JSON,控件通过 binding 提交字段或原子 patch;保留未知字段以及缺省/null/default,不用反序列化后重建 DTO 覆盖整个对象。 +- Sample 仍用 DtoBinding/设置提交路径;复用控件协议,不借用 Editor 的历史或文件保存事务。 +- timeline 关键帧拖动、曲线拖柄、颜色连续调整采用开始/预览/提交/取消手势;一次完整手势一条历史,取消恢复开始前状态。没有预览宿主时仍可编辑值,不依赖游戏启动。 +- schema/type 切换、Entry 删除、列表元素移除或项目关闭后,旧版本上下文拒绝晚到提交;数组重排中的草稿绑定需稳定元素身份或在集合版本改变时取消,不能按旧下标写错对象。 +- 每个工厂返回新建、无父级的控件及 lease;卸载/折叠释放控件订阅,作者数据仍留在项目文档。工厂失败保留诊断;有未提交输入时先按统一草稿协议提交或明确取消,不能靠控件销毁丢弃输入。 + +## 6. 资产选择与场景目标选择 + +二者共用候选列表的搜索/缩略图展示,但数据来源、身份和合法性不同: + +| 选择内容 | 来源 | 身份与例子 | +| --- | --- | --- | +| 项目资产 | 项目 asset 索引 | 某张图片、音频文件的 asset ID | +| 在场实例 | 当前 Entry 位置的抽象场景 | 同一图片创建的左侧角色层、右侧角色层 | +| 播放/效果实例 | 创建它的 Entry 及领域语义 | 某次动画播放、某个效果或粒子实例 | + +UI 根据字段语义提供目标类别与合法属性。例如动画先选择目标层,再选择该层可动画属性;effect 字段可按契约提供“整个场景”选项,不将空字符串当作可见业务选项。候选使用稳定身份与友好标签,改显示名不改变引用。 + +建议 Editor 内部 Locator 以创建者的稳定 Entry ID + 输出槽位描述目标,跨 Group 时带文档/Group 身份。它是作者目标解析模型,不是资产 URL,也不要求更改运行时实例管理接口。 + +创建型 Entry 自动分配不透明内部 handle,选择器把目标解析为对应 handle 保存到原参数字段。handle 生成独立于列表序号、文件路径和显示名;名称重命名、Entry 重排不改变目标。替换内容但保留实例身份与“销毁再新建”由该 Entry 的领域语义区别处理,不能一律每次生成新目标。 + +复制创建者时分配新的内部身份;批量复制同时重映射批内目标引用,外部引用保留原目标;撤销/重做恢复同一次操作的已分配身份。删除目标生产者后保留引用并提示当前未匹配到目标,不让刷新候选静默清空参数;运行时目标不存在则跳过,不能仅因没有候选阻止保存/编译。 + +作者界面展示目标名称/类型/来源及可用性提示,不展示或要求输入 handle。对手写内容中无法反查的 handle,同样显示“未解析目标”并允许重新选择,不把底层字符串原样当默认标签。 + +## 7. Editor 当前状态查询与缓存 + +### 7.1 查询契约与执行语义 + +用户确认所有 Entry 对目标存在性的语义为“有则执行,没有则忽略”。查询结果是创作辅助,不是运行时存在性门禁;可能存在、当前未查到、生产者已删除均不因缺少目标自动成为编译错误。参数格式错误、类型/属性不匹配和非法曲线等值契约问题仍按原校验处理,不能被“缺目标忽略”吞掉。后续实现需核对现有执行器与该目标语义一致,有差异时统一修正,不能只在 UI 中隐藏异常。 + +查询位置默认是某个 Group 的某个 Entry **执行前**;列表末尾表示全部已知 Entry 之后,需要执行后状态的控件显式指定。查询包含目标种类过滤,返回稳定身份、名称、资源信息和存在性两种分类: + +- **一定存在**:所有可达路径在该位置都存在。 +- **可能存在**:至少部分路径可能存在,或分析无法保证其一定存在。 + +不向用户引入第三种“未知”资源类别。不确定/不支持的自定义语义只影响内部完整性标记,并将相关已知候选保守归入可能存在;界面可显示不确定原因,但分类仍只有两种。完全无法枚举的动态对象不能凭空产生候选,查询内部返回 incomplete 信息说明列表可能不完整,并保留已有引用。 + +资产库文件与在场实例仍是两种查询域。实际已索引的资产文件不因剧情分支变成可能存在;分支分析主要影响场景/播放/效果实例。两类候选采用相同的存在性字段,但分别判断,不把“资源文件存在”解释为“场上实例存在”。 + +### 7.2 状态表示与控制流 + +状态保存确定集合 D 和可能集合 P,满足 D 是 P 的子集;对外将 D 显示为一定存在,将 P 减 D 显示为可能存在。已知在该位置不可能存在的对象不列入候选,已有引用仍可保留且执行时缺目标跳过。资源身份与属性事实分开:对象一定存在不代表其图片/位置等属性值也唯一。 + +每种 Entry 由纯状态转换贡献器描述创建、替换、删除和无影响行为,不执行游戏命令。条件已知时取对应路径;无法确定时合并“执行”和“跳过”两种状态。可达分支汇合时 D 取交集,P 取并集,不可达前驱不参与。移除/替换按具体领域语义更新状态,不能把所有操作都当作创建新实例。 + +Group 初始状态来自可达前驱/调用上下文,不把每个 Group 都视为空场景。无入口/不可达 Group 提供结构化的未分析提示。跨 Group 复用输入摘要;循环通过工作队列求固定点,超出分析预算时保守扩大可能集合并减少确定事实,候选身份限于已知来源,避免无限枚举运行次数。 + +没有语义贡献器的自定义 Entry 不当作无影响:相关已知目标降为可能存在,内部标记分析不完整,直到后续确定性操作重新建立事实。后期游戏预览可提供某次实际路径的状态;它与静态两类结果来源分开,不用一次观测证明所有路径一定存在。 + +### 7.3 缓存与失效 + +优先缓存每个 Group 的入口状态、出口摘要和依赖信息;大 Group 再按固定间隔或结构节点缓存前缀状态,不为每个 Entry 保存完整场景副本。任意位置查询从最近有效检查点向前推演,结果不可变、可共享,优先使用结构共享或差量降低内存。 + +缓存键至少包含项目/schema/语义提供器版本、Group 文档 revision、入口摘要签名及所需资源索引版本。Entry 修改后失效本 Group 自修改位置之后的前缀与出口;出口摘要变化才传播给后继,分支/Graph 拓扑改变重建依赖。Undo/Redo 同样产生状态失效,不能只监听磁盘文件。纯显示名变化只刷新候选展示,不因名称改变内部身份。 + +跨 Group 输入多样时,默认用合并后的入口状态服务普通 Editor;如果宿主提供明确调用上下文,可以按输入签名分别查询/缓存。缓存设置数量/内存预算并淘汰,项目关闭释放全部分析状态。后台分析捕获一致作者快照,完成时核对 revision/请求位置再发布,过期结果丢弃;共享 UI 只订阅结果,不拥有分析任务。 + +## 8. Timeline 与曲线 + +2026-10-18 用户补充对象/属性树和关键帧/切线/曲线交互,详细设计见 [complex-editor-design.md](complex-editor-design.md)。后者细化本节:对象父行分组、属性选择加入轨道、首帧/碰撞/时长/FPS 规则、Hermite 单位、局部草稿与单历史、事件和隐藏 handle;状态仍为 design。 + +用户进一步确认先做独立 Timeline Dock(默认资源区域),属性树叶节点值直接 upsert 当前帧键,再做 animate 自定义曲线。Timeline 名称替换和未来实时预览另有代码影响分析;Timeline 曲线按帧编辑属性,animate 自定义曲线按归一化时间编辑进度,复用画布但分开值契约。其余专业参数面板保留远期接点,不作为这一轮额外工具目标。 + +Timeline Dock 直接绑定当前活动 Entry,非 Timeline/无选择时显示不可编辑提示,不固定旧目标;切换先协调输入/手势,再使旧租约失效。实时预览不纳入阶段计划,既有预览分析只作架构备忘,不成为 Timeline 的依赖或验收要求。 + +第一步可对 TimelineOptions.Timeline 做成员覆盖,适配现有 JsonElement;只替换这一字段,不对所有 JsonElement 注册 timeline。适配器只改负责的键,未知扩展字段无损保留;无法解析的内容显示诊断/原始值回退,不自动重置为新计划。 + +未来如果把 Timeline 字段改为强类型 TimelineDefinition,默认类型映射即可复用。这个类型化属于契约变化,需要单独验证 codec 与执行参数,不是注册服务的先决条件。 + +timeline 可组合轨道列表、场景目标选择、关键帧视图、属性面板;曲线控件作为独立值/成员覆盖,也可嵌入 timeline。二者使用同一上下文、patch 和手势协议,算法/绘制与 Editor 历史分离。完整动画预览与游戏预览分开:可以先做数值曲线采样预览,游戏场景预览仍放后期。 + +## 9. Sample 接入方式 + +在程序入口组装默认表单注册,再显式添加项目自定义 DTO/DTO 成员/默认类型三层工厂。现有 SampleCustomization.CreateFieldEditors 是自然组合点,服务注入沿用其 scope。工厂只依赖共享表单协议和宿主提供的服务,Editor 专属资源/场景候选不成为共享控件的硬依赖。 + +同一个颜色/曲线控件可在 Sample 设置与 Editor 参数复用;两端使用不同 binding 适配实际数据及提交策略。运行时设置若需要即时预览、取消恢复,继续由设置服务管理,不强行建立 Editor 项目历史。启动入口注册不等于每个控件都是 singleton;控件实例仍逐次创建并释放。 + +## 10. 实施切片与验收 + +| 切片 | 可单独交付的结果 | 关键验收 | +| --- | --- | --- | +| A 注册与字段覆盖 | 三层注册 + 默认 DTO 表单的成员/默认类型覆盖 | 同类型字段局部替换、优先级、重复/Replace、未覆盖字段不变、释放 | +| B 多 DTO 面板 | 有序 DTO 列表、整 DTO 覆盖与默认表单分派 | 同类型多项身份、不递归创建自身、不丢未知字段、缺省/null、原子多字段 Undo | +| C Sample 验证 | 程序入口注册并复用一组控件 | 两个宿主不同 binding、设置预览/取消、无 Editor 依赖 | +| D 场景目标基础 | 内置直线剧情的候选与隐藏 handle | 多实例、自动身份、复制重映射、重排/Undo、删除诊断 | +| E 控制流与缓存 | 分支/跨 Group 两类存在性及 Group 检查点 | 一定/可能两类、缺目标跳过、循环收敛、修改/Undo 增量失效、无游戏执行副作用 | +| F 动画专属编辑 | Plan 成员覆盖及曲线手势 | 关键帧编辑、采样、取消、一次手势一条历史、未知 JSON 保留 | + +这些是候选实施拆分;后续用户已授权开始,P6a 先实现共享 UI 基础,不代表全部六个切片已经完成。P6 开始前按依赖与实际范围细化;P5 继续使用现有基础 SchemaForm,第 1–5 点不等待新 registry、Locator 或动画工具完成。 + +本设计没有新增必须由用户回答的阻塞项。最需要实测的风险是复杂表单草稿/事务生命周期、控制流与 Group 入口缓存的保守正确性,以及现有 Plan JSON 的无损适配;逐切片验证后再更新正式 spec。 + +2026-10-18 用户确认复杂定制与 AnimationPlan 一起放后续阶段;P6b 先交付普通自动生成 UI,不据此承诺 timeline/曲线或场景目标查询完成。 + +## 11. 本轮用户澄清 + +共享 UI 构建确定为特定 DTO、特定 DTO 成员、默认类型三层映射;默认 DTO 表单只负责后两层,多 DTO 面板负责第一层与默认表单的分派。Editor 状态查询独立,公开资源分类只有一定存在/可能存在,目标缺失跳过执行。缓存方案由实现设计决定,优先 Group 入口/出口摘要与按需前缀检查点。当前没有阻塞设计的问题。 + +2026-10-18 实施边界:P6e1 已验证基础场景候选、两类存在性、Group 入口固定点与整快照换代、Timeline 属性树/轨道创建及 Entry 引用复制。当前没有增量前缀缓存、动态状态贡献器、通用 Locator 字段、Graph/转场复制或 Timeline 帧内事件语义;仍按后续 P6e 切片推进,不将本节目标全部视为完成。实际行为见 [场景目标规范](../../docs/spec/editor-scene-targets.md) 和 [phase-6e1.md](phase-6e1.md)。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-3.md b/features/F-20261015-02-editor-authoring-workspace/phase-3.md new file mode 100644 index 00000000..46b0c4af --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-3.md @@ -0,0 +1,45 @@ +# P3:Graph 完整交互 + +日期:2026-10-17。状态:verified。 + +## 实现范围 + +- 分支选项/条件新增、删除、重排及文本/表达式修改通过 GraphAuthoringService 修改作者数据,记录只保留项目文档、ID 和值;移除 GraphEditingService 中旧分支写入口及 Workspace 的页面闭包历史。 +- 连线按稳定分支项身份重新计算 outlet;结构创建在 Working 与 Saved 的不同分支顺序中各自定位同一项,不顺带发布未保存的重排;删除中间项只移除该项连线,其余出口顺移。投影保留 DTO/输入控件身份,刷新已有 Inspector、节点高度和边路径;锁定 Inspector 在删除后解绑,撤销后按 ID 恢复目标,页面释放后仍可撤销;空分支没有可连接出口。 +- 复制选择中的非 Entry 节点和内部边,Group 内容深复制;节点/边/分支项/Entry 稳定 ID 全部重建,Group 文件使用新路径。作者字符串和未知自定义 JSON 不猜测为引用,不做全局替换。当前字段契约没有声明额外 Graph 引用类型;已声明的关系是边端点与 Group 文件归属。 +- 粘贴复用已有 PresenceEdit 与项目结构发布,整批一次撤销,包含复制内容和源文件;重做使用历史保留的内容,不再读取剪贴板。 +- 画布 Ctrl+C/V/A、Delete、F 定位选区、Home 显示完整图、Ctrl/Shift 点击多选,右键菜单显示对应操作及键位。文本控件先消费自己的键盘操作,定位仅更新视图状态。Graph 专用键位当前固定,未新增设置页自定义手势入口。 +- 新增菜单、历史、错误、分支输入/按钮提示与无障碍名称接入四语,历史显示目标名称或批次数量。 + +## 验证过程 + +基线为当前提交 12077f0,开始时工作区干净。与 P2i 不同,本轮直接验证当前工作树。 + +初次 Editor 构建成功。首轮 95 项中 94 项通过,一项 Condition 测试把普通文本填进表达式,保存时被既有语法校验正确拒绝;改用合法条件,保留 Choice 文本含花括号的用例。补充真实文本本地撤销、批量文件发布中段故障、Undo 故障补偿和重试验证,中间轮次 Editor 97 项全通过,随后增加排队创建、空分支、两套基线顺序、锁定 Inspector 与视口几何断言。 + +加强中间项测试时,两种分支均暴露 After 快照的全局边索引未随删边前移;`p3-middle-edge-regression.trx` 稳定失败,修正后 `p3-middle-edge-fixed.trx` 的 3 项通过。lesson 见 `.agents/lessons/L-20261017-01-local-edit-global-indices.md`。没有通过放宽预期校验绕过问题。 + +审查补充结构创建的两套出口映射:保存后的分支重排尚未保存,随后从原项创建 Group;检查 Working 与 Saved 出口下标不同,但均替换同一项,Undo 恢复原边。`p3-independent-orders.trx` 的相关创建/队列/控件 5 项通过;`p3-locked-inspector.trx` 验证锁定目标删除、恢复与页面释放后历史。 + +截图最初仍遗漏远处分支,定位为 Frame All 的固定缩放下限;Graph 最小缩放调整为 0.02,Frame 使用视口实际范围,`p3-framing.trx` 通过真实视口坐标断言每个节点都可见,而非只判断调用返回。 + +## 最终门禁 + +已完成 locked restore;限定非空源文件范围的 whitespace/IDE0005 修复与 verify 覆盖 Editor 10 个、Editor.Tests 2 个 C# 文件。最终当前工作树解决方案构建成功,0 错误、10 个警告。最终测试 339 项通过,0 失败、0 跳过: + +| 测试组 | 通过 | 结果文件 | +| --- | --- | --- | +| Editor | 102 | p3-editor-final.trx | +| Editor.Shared | 180 | p3-shared-final.trx | +| Architecture | 45 | p3-architecture-final.trx | +| 内容集成 | 12 | p3-integration-final.trx | + +计数以当前宿主重构后的基线为准,不沿用 P2i 的项目测试数量。最终 19 个自有源文件的 SHA256 记录在 artifacts/F-20261015-02/p3-source-sha256.json。 + +命令使用 `dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false`,四组使用 `dotnet test --no-build --no-restore`;集成过滤 `ProjectContentBuilderTests|GameInstallationTests|GameTestCaseTests`。没有并行构建共享 obj,验证期间不修改源文件。中间验证输出目录曾被环境清理,最终构建日志、四组 TRX、截图和源文件 hash 已重新生成;以上中间轮次文件名仅记录当时执行过程。最终日志与 TRX 位于 `artifacts/F-20261015-02/p3-*`,真实界面截图为 `p3-graph-inspector.png`,均不纳入 Git。 + +## 后续边界 + +文件改名/移动与完整项目文件界面属于 P4a/P4b;Group 内容交互和 schema 完善属于 P5/P6;游戏预览另立 feature。未声明的自定义引用不自动重写,复制结果由作者检查。原生 OS 剪贴板人工验收与 IME 不由 Headless 自动测试替代。 + +下一阶段为 P4a;整个 feature 保持 in-progress。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-4a.md b/features/F-20261015-02-editor-authoring-workspace/phase-4a.md new file mode 100644 index 00000000..670c8b31 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-4a.md @@ -0,0 +1,47 @@ +# P4a:项目文件事务与文档协调 + +日期:2026-10-17。状态:verified。 + +## 实现 + +- Shared 的 ProjectFileOperation 接收编辑器意图,无需 GUI 构造 CLI JSON;覆盖文件/目录创建、导入、复制、移动/改名、删除和资源 metadata 修改。规范化项目相对路径,拒绝链接、非法路径、受保护入口、目标占用、批次碰撞及跨类别移动,折叠父子选择并保留空目录。Windows 大小写改名有持久中间路径。 +- 扩展既有作者文件事务,以一个 manifest 发布作者写入、普通文件复制/删除、目录前后状态和大小写改名。普通文件与哈希使用磁盘复制/流,备份不作为大 byte[] 保存在历史。导入重做不依赖外部源;历史淘汰释放材料,owner lock 保护活动材料,打开项目清理无所有者材料。 +- Working/Saved 分别变换 Group source path;已打开 Group 和 Entry 仍保留身份。之后保存再撤销文件改名,移动的是当前已保存文本,未保存字段不被顺带写盘。关联 Group 删除需要显式选择,节点/边/Entry 在一条历史中删除和恢复原顺序。 +- 引用检查扫描全部作者 Group,不依赖已打开页面;区分字段声明资源引用与普通字符串候选,工作版 settings/UI JSON 提供候选。确定引用不能绕过,候选需显式允许,不全局猜测替换。metadata 保留 Gallery/扩展,复制分配新 ID、移动保留 ID;无效字段拒绝重写。 +- GUI 接入项目历史,现有 Assets 面板/Inspector 改走该入口;AssetCatalogService 删除写方法,只读索引与通知,发布完成后刷新。CLI 现有资产命令共用预检/持久事务,移除旧专用事务及全局字符串替换,不持有 GUI 历史。 +- 新增历史与错误四语 key;Assets 面板保留错误对象,切换语言后重新显示。Shared 的异常细节不写入 CLI JSON。 + +## 验证与问题修正 + +初期 Editor 全量 117 项通过。覆盖 dirty Group 改名→保存→撤销/重做、文档/Entry 身份、父子选择折叠、目录/空目录和 metadata Gallery/扩展保留、导入源删除后重做、外部目标冲突、路径保护、跨文化拒绝、未打开 Group 引用、关联 Group 删除恢复与 24 MiB 普通文件的托管分配上限。提交、Undo、Redo 的中段 I/O 注入均要求全部文件哈希、Working 快照和历史位置恢复,随后可重试。 + +首轮 Shared 全量 185/186 通过;失败是旧测试要求 Graph 损坏时仍绕过文档加载执行文件操作。统一文件机制需要有效作者文档及引用检查,改为返回明确失败,并检查磁盘完全不变,未保留该旧旁路。补充 CLI JSON 错误结果序列化、持有文件租约/孤儿清理、恢复时外部内容保护、大小写改名中间对象恢复、非法恢复路径和无法类型化 metadata 的保护测试。 + +首轮最终 Editor 回归 116/117 通过,唯一失败位于既有 SchemaFieldCommitUsesWorkspaceHistoryAndSaveSnapshot(true) 的 HeadlessUnitTestSession.DisposeAsync,堆栈为框架释放空引用,产品断言未失败。失败结果保留为 p4a-editor-session-disposal.trx;没有修改或跳过该用例,完整复跑 117 项通过。与已有 [Headless 会话发布竞态](../../.agents/lessons/L-20261014-05-headless-session-publication-race.md) 的表现一致,不宣称本阶段修复了第三方竞态。 + +补充声明资源值的引用检查:即使显式允许字符串候选,确定引用也阻止删除。收尾审查发现 metadata 请求可误传目录,已在预检拒绝,并用空目录 fixture 验证不会删除目录或生成 sidecar。metadata 容错读取与作者重写的差别记录为 [lesson](../../.agents/lessons/L-20261017-02-tolerant-reader-authoring.md)。 + +## 最终门禁 + +当前工作树解决方案构建成功,0 错误、36 个警告;四组相关回归共 365 项通过,0 失败、0 跳过: + +| 测试组 | 通过 | 结果文件 | +| --- | --- | --- | +| Editor.Shared | 191 | p4a-shared-final.trx | +| Editor | 117 | p4a-editor-final.trx | +| Architecture | 45 | p4a-architecture-final.trx | +| 内容集成 | 12 | p4a-integration-final.trx | + +构建命令为 `dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false`;四组顺序执行 `dotnet test --no-build --no-restore`,内容集成过滤 `ProjectContentBuilderTests|GameInstallationTests|GameTestCaseTests`。运行期间保持源文件不变,未并行构建共享 obj。 + +日志、最终 TRX、验证脚本与自有源文件 SHA-256 位于 `C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/p4a-validation/`,不纳入 Git。项目内 Temp/artifacts 曾被环境清理,最终证据改用该目录。限定本阶段 21 个 C# 文件的 whitespace/IDE0005 修复与 verify 均通过,SHA-256 在验证脚本结束后记录。 + +本轮从 ede12a9 开始;另一任务修改的 `test/GalNet.Presentation.Tests/PlayerInputTests.cs` 保留且不纳入本阶段提交。同步 CLI README 时纠正其过期的新建项目说明:Editor 新建面板已自动使用分发的 DefaultContracts,并支持自定义目录;未修改项目创建行为。 + +## 计划调整与边界 + +用户本轮明确要求文件面板参考 Unity:左侧目录树,右侧当前目录网格。已写入 feature/design/P4b;该界面本阶段尚未实现。P4b 将导航、面包屑与前后退共用当前目录状态,展开、选择和缩略图大小为视图状态。 + +CLI 执行文件命令现在也加载作者文档,损坏文档阻止文件发布;未添加旧行为兼容。CLI 仍仅暴露现有资产命令,通用项目文件操作通过 typed service 提供给后续 GUI。普通文本内容页、完整文件树/网格、原生文件对话框验收均在 P4b。 + +事务提供可核对恢复与补偿,不承诺断电/跨进程整体原子性。结构 staging 当前同步执行,P7 再测大型项目交互;完整游戏预览仍另立 feature。正式规则见 [项目文件操作](../../docs/spec/editor-files.md)、[历史与保存](../../docs/spec/editor-history-and-save.md)、[资源](../../docs/spec/assets.md) 和 [目录格式](../../docs/spec/file-formats.md)。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-4b.md b/features/F-20261015-02-editor-authoring-workspace/phase-4b.md new file mode 100644 index 00000000..442096f1 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-4b.md @@ -0,0 +1,49 @@ +# P4b:项目文件界面与文本页 + +日期:2026-10-17。状态:verified。 + +## 已实现范围 + +- 用户确认行为参考 Unity,采用左目录树/右当前目录网格;树懒加载,地址栏内目录块、相对路径、前进/后退、返回父目录共用导航状态。全项目文件名连续子串搜索和缩略图大小为视图状态;刷新复用未改变的网格对象,保留选择与改名输入。 +- 原生 Ctrl/Shift 多选,F2 改名、Enter 打开、Ctrl+A/C/X/V/D、Ctrl+F 与 Alt+左右/Backspace。网格/树目录、地址栏目录块与当前目录空白接收项目内移动、Ctrl 复制和外部导入;剪贴板属于项目会话。改名失焦提交,避免 Enter 与失焦重复执行。副本自动生成唯一名称,资源分配新 ID,Gallery 冲突身份另行分配,保留其 type/title 和 metadata 扩展。 +- 普通文件/目录创建、复制粘贴、移动、改名和删除共用 P4a 事务。删除显示路径/关联 Group/引用候选,关联节点删除及候选放行各自确认,确定引用仍拒绝。固定路径显示原因,隐藏 sidecar/内部目录;Output/Temp 默认隐藏,可通过相对路径查看。 +- managed Group 双击进入原专属页,settings/graph 进入对应界面。其他文件进入可关闭/浮动的文件页;同一会话 ID 复用标签。文件路径变动同步缓冲和页标题,页面关闭只释放视图,项目缓冲继续参与保存、关闭脏检查与撤销。 +- 文本在项目作者聚合中持有稳定 ID/path/存在状态/字节,进入 Working/Saved、克隆、恢复快照及同一次作者文件事务。提交输入只改变工作版;保存后撤销移动保留最新保存文本。删除分别保留两版字节,已删除内容不产生虚假的 dirty,撤销恢复正确两版。 +- 严格 UTF-8(有/无 BOM)与带 BOM 的 UTF-16/32 LE/BE;限制 2 MiB,先核对逐字节往返,保留 BOM 与输入换行。二进制、未知编码、大文件、固定入口及孤立 Raw 源只读,提供路径/大小与外部打开。外部变更保留缓冲并显示错误,保存/移动/Undo 核对基线和目标占用;没有三方合并或热重载。 +- 项目 index 捕获固定根,watcher/timer 排队事件在关闭后不再读取全局 Current。首次打开新缓冲与发布互斥,拒绝把新文件身份加入正在保存的旧捕获。新的菜单、历史与错误四语均接入。 + +- 地址栏空白处切换手输,Enter 导航、Escape/失焦退出;创建与导入使用右键,新建为二级菜单,底部滑条右侧刷新。文件 tooltip 显示全名。搜索非空时显示圆形 ×,清空后进入首个选中结果的父目录(保留选择),无选择则回原目录;类型接口保留但不解析 t:。 + +## 验证过程与修正 + +从 dbda42f 开始,保留并排除另一任务的 `test/GalNet.Presentation.Tests/PlayerInputTests.cs`。 + +第一轮测试主机因关闭后 watcher 再取 Current.RootPath 空引用退出。修正为固定项目根、disposed 检查与 UI 投递后的所属项目复查;见 [lesson](../../.agents/lessons/L-20261017-03-project-watcher-owner.md)。原始证据 p4b-initial.log/trx 保留,没有捕获并吞掉空引用。 + +第二轮 9/10 通过,剩余用例从根容器解析 scoped Dock,修正测试为项目 scope。再补充真实鼠标、多选、拖放、文件发布故障与并发用例;首轮 10/13 通过,发现测试注入适配器只路由旧 PasteFault,新的故障与暂停并未进入事务,同时复用旧视觉对象的坐标在刷新后失效。修正测试路由,并在产品中改为复用未改变的网格项;p4b-cases-fixed.trx 的 13 项全部通过。 + +最终增加打开文本→改名→保存→旧目标被外部文件占用→Undo 拒绝覆盖→移走外部内容再重试的回归。Managed 文本允许内容随保存版本前进,但目标不存在仍是严格发布前置条件。后续完整门禁先后 379/382 项通过;最终 UI 叶子复核还检查同文件标签复用、四语筛选/错误刷新和作者快照/历史不变。 + +追加界面测试通过真实鼠标点击地址块/空白/清空按钮,手动路径 Enter、地址块拖放后 Undo、跨目录大小写文件名匹配、退出搜索选中/无选中规则、字面 t:、新建二级菜单展开、无独立创建按钮、完整名称 tooltip 和底部刷新。首次菜单断言在弹出前读取尚未挂载绑定,改为实际展开菜单/子菜单后验证;路径和搜索相关断言首轮已通过。 + +## 门禁 + +完整四组已有:最终门禁:Shared 191、Editor 135、Architecture 45、内容集成 12,共 383 项全通过,无失败、无跳过。解决方案最终隔离构建成功,0 警告、0 错误。格式 whitespace 与 IDE0005 verify 均通过;源 SHA-256 与已测文件再次核对一致。 + +证据在 `C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/p4b-validation/`。早期 log/trx 位于该目录的父目录。包含验证脚本、构建日志、四组最终 TRX、源 hash 和 `p4b-project-files.png`,不纳入 Git。 + +正在运行的 Editor 占用了默认输出 exe,普通构建报 MSB3027/3021。保留用户进程,locked restore 后改用测试 bin 下按项目隔离的 artifacts 输出;新构建与测试均针对隔离 DLL,避免停掉编辑器或使用旧二进制。 + +## 边界与后续 + +未进行原生文件选择器、Windows Explorer 定位及关联应用启动的人工全程验收;自动用例覆盖挂载控件、内部拖放路由和共享导入事务,不能代替这些原生系统行为。当前不包含收藏夹、保存搜索、IDE 语法服务或 Unity 导入管线。孤立 Raw 的专属创作交由 P5;目前可只读查看。文件标签不跨重启恢复,项目关闭后新建会话身份。 + +大型目录枚举/WrapPanel/缩略图与多个文本缓冲的性能继续按 P7 fixture 测量,不声称已经满足一万文件规模。游戏预览仍是后续独立 feature。 + +正式规则见 [项目文件操作](../../docs/spec/editor-files.md)、[历史与保存](../../docs/spec/editor-history-and-save.md)、[目录格式](../../docs/spec/file-formats.md)、[资源](../../docs/spec/assets.md)、[i18n](../../docs/spec/i18n.md)。下一阶段 P5 Group 核心编辑与最小参数表单;整体 feature 保持 in-progress。 + +## 后续修正:目录展开三角(2026-10-17) + +原实现给所有目录放入占位子项,叶目录首次点击后才消失。改为父项展开生成目录行时,提前探测各行是否包含可见子目录,仅非叶项保留懒加载占位;探测只枚举当前层目录,沿用隐藏与 reparse point 过滤,不递归预载整棵树。刷新重新探测,因此新增/移除子目录后箭头同步更新。 + +隔离 Editor.Tests 项目构建成功(0 错误);新增真实挂载目录树用例覆盖空目录、仅文件、仅隐藏目录、非叶/孙级叶节点和刷新后增删子目录,连同路径块/搜索、文件面板操作回归共 3 项通过,无失败和跳过。证据:p4b-validation/tree-expanders-build.log、tree-expanders.trx。此次为 UI 查询/目录节点修正,未重新运行 Shared/Architecture/Integration 全量;此前 383 项门禁记录保持其原始版本含义。 \ No newline at end of file diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-5a.md b/features/F-20261015-02-editor-authoring-workspace/phase-5a.md new file mode 100644 index 00000000..330217b8 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-5a.md @@ -0,0 +1,28 @@ +# P5a:Group 创作交互 + +日期:2026-10-17。状态:verified。范围为用户确认的 Group 补充设计第 1–5 点,继续使用 P6a 共享 DTO 表单;不将整个 P5 的剩余多选/剪贴板、变量 UI 和作者绑定迁移宣称完成。 + +## 实现 + +- 类型弹窗新增搜索/默认焦点、四语显示名与英文 ID、常用内置拼音/自定义别名、首次上下首项/继续导航/Enter/取消及 IME 预编辑让路;缺译回退为英文 ID。补齐现行内置类型和分类的翻译,参数表单的字段标签使用已有 Entry.Parameter key。 +- 顶部添加/追加/数量/插入类型,移除底部按钮区域;数量作用两按钮,类型直接确认一条。创建前确定类型,不依赖 catalog 首项;默认类型不可用时保存待执行按钮的数量与追加/插入意图,再请求选择类型。 +- 捕获稳定 Entry 锚点并在确认时复查;锚点视图被删除导致 Popup 自动关闭时同样取消待执行意图并反馈。列表尾部 64 DIP 留白和背景取消选择,不干扰工具栏/输入控件/滚动条;Space 快速插入尊重文字/数字/条件编辑及其他 Dock 页焦点。 +- 展开状态按页面内稳定 ID 保存,行及拖动预览共用;移动不重新 Configure 参数控件,折叠不进入内容历史。行内切换类型使用明确意图,一次历史同时保存原类型/原参数,Undo 不留下新类型配旧参数的中间状态。 + +## 验证过程 + +首次测试编译因 partial fixture 的辅助方法 Key 遮蔽 Key 枚举而失败,已改名。聚焦首次运行 14/16:搜索用例直接读取尚未投递的 TextChanged 状态,改为运行 UI 队列后断言;锚点移除会使 Popup 在确认前自动关闭,产品改为在 Closed 同样处理失效并清理待执行意图。修正后 16/16,通过原生鼠标折叠/拖放及预览检查。 + +之后增加自定义契约不使用 catalog 首项,以及行内类型/参数单步 Undo,聚焦 17/17。补充字段标签时遇到 C# 14 属性内 field 关键字与主构造参数同名的编译错误,使用 @field 显式引用后通过。最终补充超过视口的列表尾部留白断言后,首轮完整 Editor 140/141:普通 ItemsPresenter margin 样式未产生实际留白。核对主题的 Padding 模板绑定后,改用 ListBox.Padding,并保留虚拟化面板;group-tail-fixed.trx 的满视口留白断言通过,随后重跑完整门禁。详见 L-20261017-06-listbox-scrollable-tail。 + +测试包含:打开即搜索焦点、当前四语/英文 ID/轻量别名、IME 不误确认、重复同类型确认事件、零结果与取消、真实顶部按钮点击、批量单历史/Redo 身份稳定、Space 锚点插入/无选择末尾/缺失类型保留批量追加、数字输入不抢空格、锚点删除、保存、折叠原生拖放预览/Undo 与作者快照不变。 + +## 门禁与边界 + +最终隔离输出解决方案构建通过(0 警告、0 错误);Editor 141、Shared 191、Presentation 237、Architecture 45,共 614 项通过、0 失败。Presentation 的 6 个原生媒体环境用例为 NotExecuted,不计入通过数。whitespace 与 IDE0005 verify 均通过。证据目录:C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation,含脚本、日志、TRX、源码 hash 与 group-toolbar.png;早期聚焦 log/trx 在相邻 p4b-validation。 + +拼音仅为已声明别名,不含任意中文转写;真实系统 IME 的多平台人工验收不由 headless 预编辑测试代替。P5 多选/剪贴板、变量面板及剩余历史迁移、P6 嵌套结构/状态查询与动画工具仍未完成。当前条目主体仍使用既有投影/字段提交路径,未擅自改写所有业务模型。 + +工作树中有另一任务的 GameTestCase/shader/ComplexPrimitiveOptions、entry-types/runtime 文档及 PlayerInputTests 改动,保留并排除提交。隔离构建目录只隔离输出,不伪称源码工作树与其他任务隔离。 + +最终执行 validate-final.ps1:顺序运行 dotnet build GalNet.slnx --no-restore --artifacts-path test/GalNet.Editor.Tests/bin/p4b-artifacts -m:1 -p:UseSharedCompilation=false、四个测试 DLL,以及 dotnet format whitespace/style --verify-no-changes(IDE0005,仅本阶段 C#)。source-sha256.json 记录最终受检 C#/AXAML/四语输入,提交前再次核对一致。group-toolbar.png 已检查,行折叠、顶部布局与列表实际尾部空间另由真实控件断言验证。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-5b.md b/features/F-20261015-02-editor-authoring-workspace/phase-5b.md new file mode 100644 index 00000000..47422d7c --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-5b.md @@ -0,0 +1,27 @@ +# P5b:Entry 多选与剪贴板 + +日期:2026-10-17。状态:verified。P5 整体仍 in-progress。 + +## 实现与边界 + +- 原生 Ctrl/Shift 多选、活动项与选中集合分离;按列表顺序复制、剪切、粘贴、创建副本与批量删除。右键菜单、Ctrl+C/X/V/D/A、Delete/Escape 保留文字/数字/条件输入的本地处理。多选拖动保持相对顺序及折叠状态;行内删除只删除当前行。 +- 项目级 GroupEntryAuthoringService 以 Group ID/项目投影执行批量意图,历史不持有页面。每批一条历史,删除 Undo 恢复原位置,粘贴 Redo 复用新身份;关闭页面后仍可按项目顺序跨 Group 撤销。当前其他字段与添加入口仍沿用已有投影/绑定路径,变量 UI 和剩余作者绑定迁移未完成。 +- 独立 GalNet.Entries / Version 1 JSON 信封,最多 1,000 条/16 MiB UTF-8。目标缺类型或字段整批拒绝;作者 JSON、条件、省略/显式 null 原样复制,普通字符串不猜测为引用。完整编译校验不挪到剪贴板入口,非法值保存仍沿用原校验;未知 schema 通用编辑不在本切片。 +- 剪切在系统剪贴板写入成功后才删除,并核对等待期间源内容未变。异步粘贴捕获稳定锚点,锚点删除时拒绝;页面销毁或换绑后忽略晚结果。新错误和历史标题接入四语,已显示错误随语言切换更新。 +- 可观察集合在通知抛错时本步可能已生效;批次按实际集合恢复。恢复失败抛 HistoryRepairRequiredException,项目/历史进入 RepairRequired。见 L-20261017-07-observable-batch-compensation。 + +## 验证 + +最终 validate-p5b.ps1:dotnet build GalNet.slnx --no-restore --artifacts-path test/GalNet.Editor.Tests/bin/p4b-artifacts -m:1 -p:UseSharedCompilation=false,通过,0 错误、48 条其他文件的编译/分析器警告。Editor 147、Shared 191、Architecture 45、Integration 110,共 493 项通过、0 失败、0 未执行。自有 C# 的 whitespace 与 IDE0005 --verify-no-changes 通过,前后 SHA256 一致。 + +证据目录:C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation。含 validate-p5b.ps1、p5b-solution-build.log、p5b-*-final.trx、p5b-source-sha256.json 和 group-multiselect.png。截图已检查,选择、活动项、菜单挂载、输入让路及拖放另有真实主题/视觉树的原生鼠标或路由按键断言。系统剪贴板成功路径使用 Headless 平台剪贴板,失败/延迟通过视图的平台适配入口注入;未宣称完成 Windows 原生剪贴板争用的人工验收。 + +故障测试 EntryBatchFailuresRestoreContentAndHistoryOrRequireRepair 覆盖新增/删除/移动中途失败、失败 Undo/Redo 的游标及恢复再次失败;p5b-failure.trx 独立 1/1,最终完整 Editor 再次覆盖。跨 Group 深复制、省略/允许 null/复杂 JSON、单批 Undo/Redo 身份、页面释放、保存重开、非法 payload、上下移动、剪切失败/源变化/晚返回和四语动态错误均通过。 + +## 迭代与自查 + +首次编译的菜单 ListBoxItem/ListBox 空合并改为显式 Control。首次聚焦 2/7:测试误用只供自定义 schema 的 Contracts() fixture,无法创建 Wait;改用 DefaultContracts 后 6/7。剩余失败是以不允许 null 的 duration 做保存断言,改为省略 duration,并在允许 null 的 effect.instanceId 验证显式 null;没有放宽产品校验。修正后聚焦 7/7,第一次完整 Editor 146/146;补充集合补偿故障验证后最终 147/147。 + +复核了全批预检、先写后删、稳定锚点/新身份、项目历史不引用页面、通知失败补偿与四语生命周期。仅推进本切片,不将 P5 或 P6 宣称完成。构建使用当前源码工作树,隔离目录只隔离输出;另一任务 GameTestCase/shader/ComplexPrimitiveOptions/PlayerInputTests/entry-types/runtime 改动保留并排除本阶段提交。 + +当前正式行为见 [Group 规范](../../docs/spec/editor-groups.md);下一步为变量编辑及剩余作者绑定迁移,状态查询和专业动画工具继续后续安排。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-5c.md b/features/F-20261015-02-editor-authoring-workspace/phase-5c.md new file mode 100644 index 00000000..6170e8b0 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-5c.md @@ -0,0 +1,29 @@ +# P5c:统一变量面板 + +日期:2026-10-17。状态:verified。用户已授权一个面板内区分玩家/存档变量,编辑声明/初始值及说明,运行时值留待预览。 + +实施范围:新增全局 Dock 变量面板、两个作用域页签与显式应用草稿;新增/修改/删除走项目统一历史,Graph 声明为唯一数据源,settings 副本由保存派生。按稳定 UID 编辑,类型变化按目标类型初值创建草稿。引用覆盖 Graph 分支条件、所有已加载作者 Group(含未打开页面)的 Entry 条件及 schema 标注的 VariableName/Expression;字符串字面量、函数名与普通参数不猜测替换,候选给出位置。明确引用阻止删除。引用与定义重命名一次历史,更新打开的投影、补全与诊断。旧 Preview 实时编辑未纳入本阶段,P5 剩余作者绑定迁移单列。 + +实现取舍:沿用表达式的方括号/裸标识符词法边界,跳过引号内容、函数及限定符;不宣称这是完整 NCalc AST 重构。不可靠词法内容只作候选。旧 CLI 的通用字符串替换不复用,避免改动对白。新增声明说明字段并保留克隆/保存往返。 + +## 验证过程 + +首次测试编译遗漏 Graph 模型命名空间,补齐后聚焦 3/4:说明已加入中立声明,但严格 Raw Graph shape 仍拒绝 description。补齐唯一变量 shape 的可选字符串字段,没有放宽其他未知字段检查,并增加 Shared 的字符串/数字/null 三例。修复后 4/4,随后增加按现有 NCalc 语法预检以及已挂载条件诊断刷新断言,聚焦再次 4/4(p5c-focused-final.trx)。 + +检查 variables-panel.png,布局包含作用域页签、声明列表、草稿、类型提示、应用/还原/删除及引用区域。验证全局 Dock 单实例与关闭、变量及引用单步 Undo/Redo、不误改文字/函数/不可靠表达式、未打开 Group、跨作用域重名、默认值溢出/NaN/小数点规则、过期草稿、通知失败恢复及 RepairRequired,覆盖保存/重开和说明/UID 保留。 + +最终 validate-p5c.ps1 已通过:只格式化自有 C#,解决方案隔离输出构建,Editor/Shared/Core/Architecture/Integration 全量回归,最终 whitespace/IDE0005 verify 与源码指纹核对。源码仍为共享工作树,其他任务的游戏示例/shader/运行时文档/PlayerInputTests/ComplexPrimitiveOptions 改动保留并排除提交。 + +最终解决方案构建成功,0 错误、73 条编译/分析器警告(含既有模块及测试分析器建议,不宣称零警告)。五个测试项目最终全部通过。源码清单/哈希、日志、TRX 与截图均在 C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation。 + +## 最终门禁与范围 + +Editor 151、Shared 194、Core 111、Architecture 45、Integration 110,共 611 项通过、0 失败、0 未执行。自有 C# 的 whitespace/IDE0005 --verify-no-changes 通过,受检 C#/AXAML/四语文件的前后 SHA256 一致;p5c-source-sha256.json、p5c-*-final.trx 与 validate-p5c.ps1 可复核。Shared 新增 description 的合法字符串/数字/null 三例全部通过。 + +自查了新面板的 Dock 单例与释放、默认预览未启用、草稿不进入项目内容、声明唯一真源、UID/类型/说明保留、引用范围与字面量保护、单历史与旧监听去重、失败恢复/冲突拒绝、四语切换及源码提交边界。Formal docs 已同步 editor-variables、editor-graph、file-formats 和 i18n;旧 CLI 的重命名算法没有在此阶段统一,明确引用扫描不宣称完整 AST 语义分析。未将 P5 剩余作者绑定迁移或 P6 状态查询宣称完成。 + +## 默认值控件跟进(2026-10-17) + +按用户要求,Bool 默认值使用 CheckBox,Int/Float 使用 NumericUpDown(步长 1/0.1),String 保留 TextBox。仍编辑同一份草稿,应用才写项目历史;数字采用固定小数点格式。NumericUpDown 绑定原始文本,避免打开/应用时通过 decimal 值转换截断 float 范围或精度,最终合法性由原有声明服务检查。四语 Bool 提示同步改为勾选说明。 + +扩展现有挂载交互测试,验证复选框点击、数值控件编辑、类型可见性、文本输入、应用前不改作者文档、Undo/Redo 回填,以及 1E+35、1E-35 回填和应用往返。首次构建发现应使用 NumberFormat 而非 CultureInfo 属性,修正后通过。最终隔离 Editor 测试项目构建 0 错误、20 条编译/分析器警告;Editor 全量 151 项通过、0 失败、0 跳过。本次两个 C# 文件 whitespace verify 与自有 diff 空白检查通过,截图已检查。证据为上述验证目录中的 variables-typed-build.log、variables-typed-editor.trx、variables-panel.png;本次未重跑上一节其他四个测试项目。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-5d.md b/features/F-20261015-02-editor-authoring-workspace/phase-5d.md new file mode 100644 index 00000000..eff3c2eb --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-5d.md @@ -0,0 +1,21 @@ +# P5d:Entry 作者绑定迁移 + +日期:2026-10-18。状态:verified。用户已确认继续实施。 + +本切片将 Entry 类型、条件和参数编辑改为项目级显式提交。历史只保留 Group/Entry 稳定身份和作者值,不保留 Workspace、页面或表单;移除 Workspace 的 Entry PropertyChanged 补记历史与参数字符串写回链。新增条目的旧历史路径一并接入已有项目级批量服务,删除、重排继续复用该服务。 + +类型与参数作为完整快照提交和撤销;参数以作者 JSON 为准,生成表单直接提交 JSON patch,专属文本/资源/表达式控件通过契约转换后提交。打开/重建表单不清理参数,不把未知字段或未完成内容当作垃圾删除。局部编辑继续使用 partial 校验,完整校验留在编译。旧表单在类型变化、移除条目、关闭页面、项目关闭后拒绝晚提交。字段编辑失败尝试恢复,恢复失败进入 RepairRequired。 + +验证目标:真实条件与参数控件、跨 Group 交替编辑、单步历史、类型完整恢复、页面关闭后的 Undo/Redo、保存重开、缺省/null/未知参数保留、过期提交、失败补偿与项目历史游标。范围不含 Preview 旧变量路径、游戏预览重构、状态查询/Locator 或复杂编辑器扩展。 + +边界核对:现有 EditorDocumentRepository.Load/PrepareSave 仍调用完整契约校验,SerializeEntry 也拒绝未知参数;Shared 安全测试明确要求保存拒绝这些内容。故 P5d 只验证已知有效条目的文件往返,以及未知/缺省/null 值在内存、字段编辑与撤销快照中的保留,不把内存无损投影宣称为未知或未完成作者文件可保存。P5 需要追加独立草稿持久化切片,区分结构保存与完整语义检查,并修订现有安全测试的责任边界;此处不静默降低全局验证标准。 + +首轮构建成功;现有 Group/Entry/DTO/变量聚焦 39 项通过。新增跨两个真实页面的条件、默认 SchemaForm 数值与专属文本提交,关闭页面后完整 Undo/Redo、保存重开;JSON 缺省/null/未知字段内存保留、旧绑定在换类型/删除及其撤销后不复活;通知一次失败补偿、持续失败 RepairRequired 与外部变更冲突拒绝。新增聚焦 4 项全部通过。 + +## 最终验证 + +隔离输出解决方案构建成功,0 错误、48 条编译/分析器警告;未宣称零警告。Editor 154、Shared 194、Core 111、Architecture 45、Integration 110,共 614 项通过、0 失败、0 跳过。删除仅针对旧监听器的 1 项测试,新增 4 项字段提交/补偿测试,Editor 从 151 变为 154 项;原有类型切换单历史、剪贴板/重排、变量引用、DTO override 与 partial/full 校验回归一并通过。 + +自有 12 个 C# 文件 whitespace/IDE0005 verify 通过,受检 C#/四语文件前后 SHA256 一致,git diff --check 通过。证据目录为 C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation,包含 p5d-solution-build.log、p5d-*-final.trx、p5d-binding-focused.trx 与 p5d-source-sha256.json。格式化曾因使用 git diff 枚举范围而被自动审批拒绝,改为显式列举本次文件后通过,没有请求额外权限或改变其他任务文件。 + +自查:字段历史按稳定 ID 和预期值执行;项目层不保存页面/控件闭包;生成表单直接读写 JSON,专属控件刷新不反向生成第二条历史;新建批量使用同一项目入口;相同值不入栈;删除/类型变化/页面关闭使旧绑定失效;通知失败恢复与 RepairRequired 均有故障注入验证。同步 editor-groups、editor-history-and-save、i18n 和 feature 计划。P5d 完成,P5e/P6/P7 及 Preview 均未宣称完成。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-5e.md b/features/F-20261015-02-editor-authoring-workspace/phase-5e.md new file mode 100644 index 00000000..beec379d --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-5e.md @@ -0,0 +1,27 @@ +# P5e:作者草稿持久化 + +日期:2026-10-18。状态:verified。用户已授权继续。 + +EditorDocumentRepository 显式区分 Complete 与 Authoring:默认 Complete 服务 CLI 编辑/检查的既有严格路径;GUI scope 注册 Authoring,加载及保存允许不完整条目、未知类型/参数、错误类型的参数值和未完成条件表达式。仍验证当前格式、严格顶层/条目 shape、JSON 重复属性、稳定身份、Graph 拓扑、变量声明、源路径/存在性/唯一归属及外部变更,不把 GUI 作者模式等同于全局 structureOnly(后者会跳过源引用检查)。CLI validate/group compile、预览的编译路径继续严格。 + +未知 schema 在 Group 中提示内容保留,既有条目及 Graph 剪贴板按原始 JSON 保留,粘贴分配新身份,支持项目 Undo/Redo。未知 schema 通用 JSON 修改器仍留 P6;本切片不伪造表单或自动修正内容。已知字段表单继续 partial 校验,保存不执行 Normalize、不补默认值、不重写显式 null。专属字段改为单字段 JSON 提交,避免把其他无效的已知字符串参数强制转换成字符串。 + +验证目标:未知/不完整/无效值/条件在真实文件中保存重开,已挂载 Group 表单可编辑已知字段且保留其他 JSON;复制、换类型及撤销往返;作者模式保留严格结构和路径防护;默认 Complete 安全测试与真实 CLI 验证/编译仍拒绝语义无效内容。最终证据见下。 + +首轮 GUI 聚焦 1/2:草稿保存仍报 Unknown parameter 'future',定位为公共 Services() fixture 覆盖生产注册、手工构造默认 Complete 仓库。修正为普通场景保留生产注册,故障注入场景才显式包装 Authoring 仓库;未放宽 CLI 默认策略。修复后 GUI 聚焦 2/2,包括已挂载表单四语刷新、无效已知字符串值保留、未知 Entry 复制和 Graph 复制/撤销、换类型撤销、两轮保存重开。经验见 L-20261018-02-fixture-service-policy。 + +Shared 初始定向 19 项通过,随后补充 structureOnly 即使显式注入 catalog 也不执行语义/复合编译的回归。测试 CLI helper 在 --artifacts-path 布局优先选择同轮 Headless 程序,避免固定默认 bin 路径导致验证旧程序;默认输出布局保留原有入口。CLI 的代码路径与默认 Complete 策略未更换。 + +## P5 整体退出条件核对 + +P5a 交付多 Group 条目交互、搜索/键盘插入、折叠与选择;P5b 交付多选/剪贴板和批量历史;P5c 交付玩家/存档变量声明面板、引用及补全;P5d 交付作者字段提交、跨页面历史与旧绑定失效。P5e 补上已知/未知/不完整条目的文件往返,覆盖类型切换后先保存再撤销、参数完整恢复并重新 dirty。 + +基本标量/枚举与默认 DTO 表单沿用已验证的共享控件,字段局部校验与完整编译分开。复杂值编辑、未知 schema 通用 JSON 修改器、状态查询/Locator 与更完整的动态 schema 生命周期继续按 P6;Problems/检查交互和前期整体验收按 P7。原生输入法及完整桌面人工验收未在本切片执行,不把 Headless 挂载验证表述为人工验收。 + +## 最终门禁 + +隔离解决方案构建通过,0 错误、71 条编译/分析器警告,未宣称零警告。Editor 155、Shared 214、Core 111、Architecture 45、Integration 110,共 635 项通过、0 失败、0 跳过。Shared 的 20 项新增覆盖作者/完整语义边界、Raw 结构拒绝、路径及 Graph 条件、显式 structureOnly 与真实 CLI;原有默认 Complete 严格安全回归继续全部通过。Editor 新增完整挂载生命周期用例,原剪贴板拒绝测试改为拒绝格式错误并额外验证未知 schema 保留,没有移除格式或超限断言。 + +自有 11 个 C# 文件 whitespace/IDE0005 verify 均通过,受检 C#/AXAML/四语文件 SHA256 前后一致;git diff --check 通过。证据目录为 C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation,包含 p5e-solution-build.log、p5e-*-final.trx、p5e-source-sha256.json 和 authoring-drafts.png。截图确认未知类型和未知参数的保留提示可见;既有 instanceId 字段缺译及窄标签截断留 P6 字段 UI 整理,不宣称全部 schema 字段翻译完成。四语静态资源各 611 key。 + +自查保存模式注册、完整编译边界、未归一化的 JSON/缺省/null、大数保留、类型保存后撤销的 dirty、未知 Entry/Graph 复制与身份重建、严格结构/路径/外部冲突检查,以及生产 DI/测试 fault 注入分离。P5a–P5e 的退出条件均有实现和回归证据,P5 标记 verified;P6/P7 与游戏预览未标记完成。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6a.md b/features/F-20261015-02-editor-authoring-workspace/phase-6a.md new file mode 100644 index 00000000..525e6fe7 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6a.md @@ -0,0 +1,24 @@ +# P6a:共享 DTO UI 构建 + +日期:2026-10-17。状态:verified。用户在两子系统设计确认后授权开始,因此将不依赖 P5 交互的共享 UI 基础提前独立实施;场景状态查询仍是后续切片。 + +## 实现 + +- Avalonia.Controls 中新增类型化三层注册、默认 SchemaForm 的 DtoType 成员解析、DtoEditorHost 与按稳定项 Id 更新的 DtoPanel。整 DTO 覆盖可以通过 Context.CreateDefaultForm 排除负责字段并复用默认表单;相同 DTO 类型多项互不冲突,移动保留控件和草稿。 +- DTO 上下文只借用字段 binding,支持字段快照、局部控件及宿主原子 patch。Context 取消和 lease 归属独立,动态预览/选项继续转发;失败创建、旧提交、重复身份和释放错误有反馈。 +- Editor 的项目 scope registry 传入 Dock 的 Group 页;无旧专属字段的生成 DTO 参数区改由共享 Host 分派。多字段 patch 先验证候选再一次替换 Parameters,整次操作一条历史,不反序列化构造完整 DTO。 +- Sample 默认注册入口冻结;内置设置主题普通字段接入 Host 并保留原皮肤与设置提交链。Action 和已有 ICustomSetting 独立行为保留。 + +## 验证过程 + +最初共享聚焦测试 27/29 通过,两项订阅计数异常揭示 scoped binding 在字段 Context 释放和自身释放时重复退订;增加真实订阅状态后 29/29 通过。该包装器的释放流程也改为继续释放全部资源并保留异常。新测试包括 JSON 字段重命名、直接成员覆盖、整 DTO/默认表单混合、重复类型多项重排、草稿保留、创建失败隔离、注册冻结、原子回调及取消。 + +Editor 聚焦首轮 13/14 通过:测试误用仅 Dialogue 的 SchemaA 契约验证 layer.move。改为自带 DefaultContracts 新建项目后 14/14 通过,整 DTO 覆盖实际挂载于 Group 页,多字段修改一次 Undo 恢复、Redo 后保存成功,关闭后旧上下文拒绝提交。期间一次未提供必需 contractsDirectory 的测试编译错误已修正,没有改动产品创建协议。 + +## 门禁与范围 + +最终隔离解决方案构建成功(76 警告、0 错误);Presentation 237、Editor 137、Shared 191、Architecture 45,共 610 项通过、0 失败。Presentation 另有 6 项 LibVLC/视频环境用例为 NotExecuted,终端摘要未计入 skipped,按 TRX 的 UnitTestResult 如实单列,不宣称零跳过。whitespace 与 IDE0005 verify 通过,源 SHA-256 随脚本生成后再次核对。证据目录:C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/dto-ui-validation。聚焦证据位于其相邻 p4b-validation 的 dto-*.log/trx。 + +当前只支持直接生成 DTO 属性的成员映射;嵌套成员路径、通用对象/数组结构 UI、全部旧资源/表达式字段迁移、跨控件草稿协调、手势历史合并、Editor 状态查询与 timeline/曲线均未完成。无原子宿主回调时拒绝多字段提交,不能宣称任意 Sample DTO 多字段写入已经具备事务能力。 + +保留并排除并行修改的 PlayerInputTests、GameTestCase、ComplexPrimitiveOptions、Shader lesson 与 F-20261017-01-sample-shader-effect-showcase;不使用 git add .。已验证事实同步 docs/spec/control.md 与 docs/manual/customize-avalonia-sample.md。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6b.md b/features/F-20261015-02-editor-authoring-workspace/phase-6b.md new file mode 100644 index 00000000..6ce8e683 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6b.md @@ -0,0 +1,22 @@ +# P6b:GalGroup 普通复杂值表单 + +日期:2026-10-18。状态:verified。用户授权普通生成 UI 开始实施,复杂专属控件与 AnimationPlan 一起留下一阶段。 + +范围:复用 P6a 三层 registry 和 P5d 作者 JSON/项目历史,提供生成 DTO 的嵌套对象、常用一维列表/数组、元素修改/添加/删除/上下移动;缺省、显式 null、显式默认值和未提交草稿恢复独立操作;不支持类型/控件提示有明确的多行 JSON 回退。JSON 使用 Ctrl+Enter 或失焦提交,Escape 恢复草稿。未知对象成员通过 JSON 局部合并保留,不通过 DTO round-trip 重写作者数据。 + +对象在展开时创建子表单,折叠释放控件/订阅;最多八层,列表最多创建 200 个元素控件,超限转 JSON。复杂转换器、字典和没有生成 schema 的对象使用 JSON。嵌套 DTO 本身可以整 DTO 覆盖,内部字段继续成员/默认类型注册。静态字段描述不能直接作为嵌套 JSON 的 wire 名:根据当前 JsonTypeInfo 找到实际序列化属性,保留 JsonPropertyName/命名策略和字段转换器。 + +列表结构变化产生新版本并取消旧索引提交,防止延迟写入落到重排后的其他元素。普通元素值修改保留当前控件。只打开/展开不构造 DTO、不写作者默认值;真正提交仍执行值及宿主 partial 校验。宿主负责历史与保存,共享控件不引用 Editor 服务。 + +状态查询/Locator、未知 Entry 的整参数 JSON 修改器和动画/曲线/timeline 专属控件未包含在本切片;P6 不整体标记完成。P7 的全局草稿关闭协调也不在此处宣称完成。 + +验证覆盖:新增共享真实控件测试覆盖 wire 名/未知字段、三层覆盖、列表操作与延迟取消、presence/null/default、JSON 键盘和递归上限;新增实际 Group 页测试验证项目历史、四语和文件重开。首轮回归发现 TextBox 默认处理会先消费 Ctrl+Enter,改为 tunnel 处理;原“不支持工厂删除字段”的旧断言改验有诊断的 JSON 回退,任意工厂异常清理测试保留。 + + +最终隔离解决方案构建成功(25 条警告、0 错误)。Editor 156、Shared 214、Presentation 243、Core 111、Architecture 45、Integration 110,共 879 项通过、0 失败。Presentation 的 6 个原生媒体环境用例为 NotExecuted,未计入通过数;原生媒体和手工完整创作流程未在本切片宣称完成。whitespace/IDE0005 verify 与源码 SHA-256 门禁通过。 + +实际 Semi 页面截图及 Group 测试覆盖主题展开入口、嵌套 wire 名、未知成员、列表重排旧绑定失效、单项目历史 Undo/Redo、显式 null 保存后 Undo 恢复缺省、二次保存与项目重开、四语刷新。共享 Fluent 页面另覆盖列表增删/编辑、递归与长列表回退、取消延迟提交及释放订阅。新增主题样式键经验见 L-20261018-03;原先直接设 IsExpanded 的测试不足以证明模板入口可用,已补断言。 + +证据目录:C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/group-ui-validation,包含 validate-p6b.ps1、p6b-*.log/trx、p6b-source-sha256.json 与 p6b-structured-group.png。P6 整体仍 in-progress。 + +嵌套字段提交仍经过根字段的值契约与宿主 partial 校验;其他已有子值使整个对象无法类型化时,需要切换 JSON 模式一并修复,当前不绕过根值校验。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6c.md b/features/F-20261015-02-editor-authoring-workspace/phase-6c.md new file mode 100644 index 00000000..6d6e3d53 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6c.md @@ -0,0 +1,20 @@ +# P6c:Timeline 契约与命名统一 + +日期:2026-10-18。状态:verified。用户确认 Timeline 交互与选择规则后授权开始,先完成跨模块命名/契约的可验证切片。 + +目标:统一 TimelineDefinition/Options/Track/Keyframe/Event/Sampler/Primitive 及 PlayTimelineAsync,Entry ID 为 timeline.play,根参数为 timeline;不保留旧 AnimationPlan 或 animation.play/plan 别名。animate 单属性入口与共享 AnimationBlendMode/LoopMode 保留。更新转场编译器、运行时恢复、Avalonia/Headless presenter、四语和有效样例/测试;特性历史文档保留当时名称。 + +LoopAnimationState.Plan→Timeline 改变 builtin.animations 的保存 payload,同步升级模块状态版本,明确拒绝旧版本,不使旧字段被当作缺失新字段。完整 GameSnapshot 外层版本不因单模块 payload 升级盲目递增。 + +验证计划与退出条件见 phase-plan.md / P6c。当前仅命名/契约切片,Dock、对象/属性查询、关键帧/切线 UI 和 animate 自定义曲线按后续阶段推进;实时预览不纳入计划。 + +实施发现 PrimitiveRegistry.State 未暴露 Core.StateCatalogBuilder 已支持的 version;新增可选 version(默认 1),builtin.animations 指定 2。新增新 Entry/schema/参数测试与两种循环定义的 JSON state 往返、旧 section v1 拒绝测试;既有 sampler、事件调度、恢复和转场集成用例统一新名称。 + +首轮解决方案构建成功(119 警告、0 错误),14 项 Timeline 聚焦通过。最终回归首轮 Core 111 通过、Primitives 116 通过/1 失败:TypedBuiltinAuthoringTests 的属性列表期望缺少 time。通过 git show 核对 HEAD,AnimationPropertyValueConverter 在本次之前已经 Append("time"),本次没有修改该转换器;仅补齐旧测试的 shader 常用属性期望,保留完整契约断言,未改变生产行为。后续最终回归见下。 + + +最终隔离解决方案构建成功(3 条警告、0 错误);Core 111、Primitives 117、Runtime 85、Presentation 243、Integration 110、Editor 156、Shared 214、Architecture 45,共 1081 项通过、0 失败。Presentation 另有 6 个原生媒体环境用例 NotExecuted,未计入通过数。whitespace/IDE0005 verify 与源文件 SHA-256 门禁通过。 + +证据目录:C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation,包含 validate-p6c.ps1、p6c-*.log/trx、变更 manifest 与源码 hash。当前 src/test C# 已无旧 AnimationPlan/AnimationTrackDefinition/AnimationTrackSampler/AnimationKeyframeDefinition;旧 animation.play 仅在否定测试与解释性文档保留。四语各 620 keys,新增 Timeline 分类及新类型文案。 + +P6c 退出条件已满足。P6d 的 Timeline Dock、项目选择上下文和叶值编辑尚未实施;P6e 的对象树/曲线/事件编辑和 P6f 的 animate 自定义曲线继续 planned。实时预览没有新增阶段。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6d.md b/features/F-20261015-02-editor-authoring-workspace/phase-6d.md new file mode 100644 index 00000000..da4267e6 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6d.md @@ -0,0 +1,23 @@ +# P6d:Timeline Dock 与项目编辑会话 + +日期:2026-10-18。状态:verified。用户授权继续,前置 P6c verified。 + +本切片实现 BottomDocument 的 Timeline 单实例面板,默认与资源/日志同组;项目级活动 Entry 上下文按来源 owner 区分页面,切换普通条目/无选择时禁用,聚焦工具不改变选择。多选只取活动项,关闭非活动 Group 页不会清除另一页的选择。 + +TimelineEditSession 由项目 scope 拥有,直接读作者文档并经 EntryFieldAuthoringService 提交参数,不借用 Group 页字段 lease。显示已有轨道在当前帧的纯采样值,以 NumericUpDown 编辑;Enter/失焦确认,Escape 恢复未确认值。一轮数值输入只提交一次根字段更新;已有键只改 Value 并保留切线/插值/未知字段,新键在当前帧插入并继承原区间插值与导数。 + +绑定捕获原 Entry 引用、根值快照、播放头和集合版本;切选择/帧/类型、外部编辑、Undo、删除/项目关闭后旧行不能提交。打开不写默认值,缺失 timeline 用明确创建动作建立内部播放身份和空轨道;无效内容提示到条目参数修复,不覆盖原值。 + +P6d 仅编辑已有轨道,先用对象序号区分目标且不展示 handle。目标候选、属性加入轨道、真正对象树/画布和曲线工具留 P6e;实时场景预览不纳入计划。验证目标为实际主题面板、默认 Dock/重开、选择矩阵、数值更新/插键/取消、过期行、Undo/Save/重开、未知 JSON 保留及四语。 + +## 验证记录 + +首轮聚焦测试发现动态状态文案将 `Session.StatusKey` 当作固定翻译 key;改为明确 `Path=Session.StatusKey` 后,四语真实视图断言通过。补齐 EditorViewFactory 映射并通过实际工厂创建视图,未绕过生产解析路径。最终针对性 8 项全部通过,包含 6 组缺省/null/无效作者 JSON 保留。 + +最终隔离解决方案构建成功(81 条分析器警告、0 错误,含新增测试的两条 CA1861 数组建议)。Editor 164、Shared 214、Core 111、Architecture 45、Integration 110,共 644 项通过、0 失败、0 跳过。没有重新运行原生媒体用例。真实 Semi 主题下挂载 Group 与 Timeline,核对 NumericUpDown 模板、键盘确认/取消、混合多选、关闭非活动页、Dock 关闭重开、Unknown JSON 与大整数、切线/插值、历史粒度、保存重开和四语切换,产出截图并人工查看。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。脚本 `validate-p6d.ps1`,构建/测试日志及 TRX 为 `p6d-*`,截图 `p6d-timeline-dock.png`,源码指纹 `p6d-source-sha256.json`。四语各 636 keys,新增 16 个 key。 + +whitespace 与 IDE0005 verify 通过;验证期间源码 SHA-256 全部一致。P6d 退出条件满足,P6e/P6f/P7 仍待实施,整个 feature 不标记完成。 + +未确认输入采用明确取消规则,而非完整长手势草稿协议:切 Entry/帧/相关根值、关闭 Dock 时取消;保存或无关更新、切语言时保留。统一关闭草稿协调仍留 P7。当前只隐藏 Timeline Dock 数值行的 handle;Group 的 JSON 修复路径仍可看到底层定义,不宣称已经完成全 Editor 的 Locator 作者 UI。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6e1.md b/features/F-20261015-02-editor-authoring-workspace/phase-6e1.md new file mode 100644 index 00000000..8cdf8c3d --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6e1.md @@ -0,0 +1,33 @@ +# P6e1:场景候选与属性轨道树 + +日期:2026-10-18。状态:verified。前置 P6d verified,用户授权继续 P6e。 + +P6e 按依赖拆分:P6e1 先交付当前 Entry 前的纯场景查询、两类存在性、对象/属性树及轨道创建/移除;P6e2 关键帧画布、选择/移动和帧/FPS 操作;P6e3 曲线/切线;P6e4 Timeline 事件及帧内状态、元数据能力和剩余目标语义。整个 P6e 不因单个切片通过就标记完成。 + +查询放 Editor.Shared,不执行 Runtime。沿可达 Graph 边合并,确定集合交、可能集合并;非空 Entry 条件作为可执行/可跳过两条路径。已知层、粒子、效果的创建/删除/替换贡献纯事实;未支持语义降级已有事实并报告不完整。视频属于可自然结束对象,不标一定存在。循环采用有限事实工作队列并设预算,预算耗尽保守退化。当前保留一份作者快照及其 Group 入口事实;出口在构建分析时参与固定点,任何内容变化整代失效,增量前缀优化后置。 + +Timeline 显示友好对象来源及一定/可能状态,未知引用保留为未解析对象;叶属性来源于 Core 已声明能力。新轨道显式创建首键 0,未知基值以作者输入建立初值,Additive 采用零偏移;确认当前帧值合并首键与当前帧键为一次历史。只看候选/展开树不修改数据。创建者自动内部身份和批量复制的语义引用重映射在本切片同步接通,不搜索替换无关 JSON 字符串。 + +验收包括分支/循环/不可达/未知语义、查询无副作用、内容和 Undo 失效、同名不同实例、首键/重复轨道/删除、未知字段保留、实际控件树/四语、一次操作一次历史和保存重开。实时预览不在范围内。 + +## 实施与验证过程 + +SceneTargetAnalysisTests 的 8 项聚焦通过:直线前缀、多实例、分支交并/唯一数值、条件/未知语义、循环与预算、视频自然结束、无效作者 JSON、粒子/效果能力与语义复制重映射。Editor 的 Timeline 与身份测试聚焦 10 项通过,真实 NumericUpDown 模板内 TextBox 输入和树展开按钮/轨道 CheckBox 已挂载验证。 + +初次 XAML 编译将树项 Style 的 IsExpanded 误按外层 ViewModel 编译;改用项目式树项 DataContext 的 ReflectionBinding。初轮树交互测试假设首个候选就是首个创建条目,但候选按稳定来源排序;修正为捕获实际轨道的 handle 反查创建者,未改变生产排序。第一次完整回归 166 通过、1 失败:新增的拒绝勾选断言在作者条件更新后立即查找旧视觉树,尚未 pump 到新行;补充真实布局刷新,保留该交互断言后重新完整验证。 + +审阅时补上未知初值拒绝创建后的 CheckBox 状态恢复,防止没有轨道却显示已勾选;事件数组的错误 shape 在投影构造时拒绝并清空编辑状态。未知引用和作者 JSON 均不自动修复。当前只是固定内置能力子集,未注册自定义状态贡献器;通用 Locator 控件、Graph/转场复制、shader/flipbook、Timeline 事件和运行时缺目标语义核验留 P6e4。 + +第二次完整验证的 655 项和格式/指纹均通过;最终自审补充不同类型在分支复用同一底层身份的边界,将合并目标保守设为可能/未解析并移除推测能力,新增独立回归后重新执行完整门禁。此改动不以先前 655 项结果作为最终源码证据。 + +该边界修改后的首轮 Editor 回归 166 通过、1 失败,失败为本次未修改的 `BrowserAddressBlocksDropAndGlobalSearchExitFollowSelection`:面包屑鼠标点击后仍在 notes/nested,期望 notes。同一构建产物隔离运行该用例通过,之前完整运行也通过;根因未确认,不称已修复、不删断言、不跳过。保持源码指纹不变,以 `validate-p6e1-retry.ps1` 重新运行全部五项目及格式/指纹门禁;结果以下方最终记录为准。 + +本切片先显示全部可用叶属性,用勾选区分已创作轨道;候选筛选和与画布共享的行布局随 P6e2 细化,不宣称完整 Unity 式时间轴已经完成。 + +## 最终结果 + +最终隔离解决方案构建成功(60 条分析器/既有构建警告、0 错误);同一源码快照的完整重跑为 Editor 167、Shared 223、Core 111、Architecture 45、Integration 110,共 656 项通过、0 失败、0 跳过。whitespace 与 IDE0005 verify 通过,源码 SHA-256 全部一致。未重跑原生媒体测试,不沿用先前阶段的媒体通过/跳过数量。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。完整构建脚本 `validate-p6e1.ps1`,同源码测试重跑 `validate-p6e1-retry.ps1`;最终日志和 TRX 为 `p6e1-GalNet*`,源码指纹为 `p6e1-source-sha256.json`,格式门禁 `p6e1-format-verify.log`。文件面板偶发记录 `p6e1-browser-flake.txt` 与隔离结果 `p6e1-browser-isolation.*`;没有删除该断言或修改文件面板行为。实际 Semi 主题截图 `p6e1-property-tree.png` 已检查,对象/变换/叶值、勾选与两类存在性显示正确。四语各 663 keys,本切片新增 27 keys。 + +P6e1 退出条件满足。P6e 整体仍 in-progress;下一步 P6e2 关键帧画布、缩放/滚动和单历史拖动,P6e3 曲线、P6e4 剩余状态语义/引用与事件工具继续 planned,实时预览不列阶段。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6e2.md b/features/F-20261015-02-editor-authoring-workspace/phase-6e2.md new file mode 100644 index 00000000..e3f5411e --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6e2.md @@ -0,0 +1,43 @@ +# P6e2:关键帧画布与时间编辑 + +本记录保留 P6e2 原实现及验证历史。用户随后要求简化时间设置并允许随时切换轨道模式,原保秒/裁剪/应用交互由 [Timeline 交互简化](timeline-simplification.md) 取代,当前行为以该记录和正式 spec 为准。 + +日期:2026-10-18。状态:verified。前置 P6e1 verified,用户授权继续。 + +共享 Avalonia TimelineCanvas 只负责刻度、可见行、播放头、选择和拖动预览;Editor 会话负责稳定键身份、作者版本、无损 JSON patch 和单次项目历史。属性树改为按展开状态扁平化的同一行列表,固定行高并与画布共用纵向滚动;水平平移/缩放只影响时间区域,折叠父行显示子键汇总,汇总只选择不拖动。 + +本切片实现 Ctrl 多选、框选、整帧拖动、Delete、当前帧加键/上一键/下一键;首键不能平移或删除,碰撞不静默合并,失败保留预览并提供明确的替换碰撞/取消。拖动中不写作者内容,松手原子提交一次;Escape、捕获丢失、切 Entry、关闭和外部作者变化取消旧手势。 + +时长/FPS 使用数字草稿和显式应用:默认保留帧,另有保留秒数重定时;缩短默认拒绝裁掉内容,另有明确裁剪入口。所有键/事件帧一起计算,舍入碰撞整次拒绝,同帧事件顺序保留;Hermite 切线在保秒重定时时按实际相邻段帧跨度换算,保持归一化段形状。事件工具和曲线详情仍属 P6e3/P6e4,不执行游戏实时预览。 + +验证覆盖实际主题键鼠、可见行对齐/折叠、缩放滚动/播放头、一次拖动一条历史、取消/过期/碰撞/首键、时长/FPS/事件顺序、未知 JSON、四语、保存重开与相关回归。 + +## 实施与聚焦验证 + +共享画布和模型位于 Avalonia.Controls/Timeline,不依赖 Editor;Editor 的 TimelineKeyEditing 负责可见行和临时键身份,TimelineDocument.Keys 负责原始 JSON 局部修改。身份随作者版本失效,不给运行时关键帧增加持久 ID。轨道属性仍由既有 NumericUpDown 编辑,横向手势只修改时间,曲线值拖动留 P6e3。 + +13 项 Timeline 聚焦测试通过。新增真实 Semi 主题测试以物理键鼠覆盖两轨多选拖动、折叠汇总选择、纵向滚动前后行对齐、标尺定位、缩放/滚动绑定、碰撞替换、首键保护、框选、外部修改期间手势失效及保存重开;另一个用例覆盖时间草稿拒绝、裁剪、两种 FPS 行为、Hermite 切线换算、舍入碰撞、加键/定位和同帧事件顺序。未知字段、超过双精度整数范围的 JSON 数字及未来事件参数保留。 + +首次聚焦运行发现:折叠汇总留下多选后,单击已有选中键没有收敛到单键,随后 Ctrl 点击会切掉目标键。现于无拖动的鼠标释放时确认单选,拖动保留多选;汇总绘制也先按每个键的预览帧分组,部分子键移动时不会把未选键一起画到新位置。修正后完整 13 项通过,保留原真实指针断言。 + +实际截图 `p6e2-key-canvas.png` 已检查:树行与关键帧对齐,标尺和播放头共享时间原点,工具栏及底部缩放/平移可见;不宣称已有游戏预览或曲线编辑。 + +## 最终结果 + +隔离解决方案构建成功,53 条构建/分析器警告、0 错误。同一源码快照 Editor 169、Shared 223、Core 111、Architecture 45、Integration 110、Presentation 243,共 901 项通过、0 失败。Presentation TRX 另记录 6 项 NotExecuted,原因是未配置 `GALNET_AUDIO_LIBVLC` 原生媒体环境;终端汇总未将这些用例计入通过数,不把它们当作媒体验证成功。whitespace/IDE0005 verify 与源码 SHA-256 核对通过;四语各 680 keys,本切片新增 17 keys。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。复核入口 `validate-p6e2.ps1`;构建 `p6e2-solution-build.log`,六项目结果 `p6e2-GalNet*.log/trx`,格式 `p6e2-format-verify.log`,指纹 `p6e2-source-sha256.json`,截图 `p6e2-key-canvas.png`。本阶段文档相对链接及 `git diff --check` 通过。 + +P6e2 退出条件满足,P6e 整体仍 in-progress。下一步 P6e3 插值、曲线及切线编辑,P6e4 事件和剩余状态/引用语义;实时场景预览继续排除。 + +## 用户反馈修正:播放头与控件交互 + +用户要求复选框与其他控件对齐、播放头拖动松手结算和 NumericUpDown 长按更快。播放头改为共享模型中的临时显示帧:移动只重绘线,松手请求一次定位后采样/重建属性行,Escape、捕获丢失、换模型或版本失效取消预览。三个带说明的 CheckBox 与轨道勾选框统一垂直居中;Timeline 数字框模板 RepeatButton 使用 250ms 初始延迟、30ms 重复间隔,单击步长不变。 + +偏移采用既有 Additive 语义(基础值加轨道值);保留秒数只影响 FPS 修改时的重定时,帧/时长按新旧 FPS 比例调整,未勾选则保留帧号。这次不修改两者的数据契约。 + +初轮模板断言误枚举整个面板的 RepeatButton,包含 Slider 内部按钮,因此读到缩放条的 300ms 延迟。沿实际视觉祖先定位 NumericUpDown 后再检查,保持缩放条原速度;不把这个测试定位错误当作 NumericUpDown 模板缺陷。 + +修正已验证:13 项 Timeline 聚焦通过;同源码解决方案构建成功(53 警告、0 错误),完整受影响回归 Editor 169、Architecture 45、Presentation 243,共 457 项通过、0 失败。Presentation TRX 另有 6 项 NotExecuted,缺少原生 LibVLC/视频环境;本次未修改媒体功能。whitespace/IDE0005 verify、源码指纹和 diff 检查通过。真实指针断言验证拖动 12 次期间实际帧/属性行不变,松手只请求一次定位,Escape 和版本失效不会提交;同时检查 NumericUpDown 真实模板按钮的 Delay/Interval 和复选框对齐。截图 `p6e2-feedback-alignment.png` 已检查。 + +修正证据同目录:`validate-p6e2-feedback.ps1`、`p6e2-feedback-final-solution-build.log`、`p6e2-feedback-final-GalNet*.log/trx`、`p6e2-feedback-final-format-verify.log` 和 `p6e2-feedback-final-source-sha256.json`。此结果独立于上方 P6e2 原提交的 901 项回归记录。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6e3.md b/features/F-20261015-02-editor-authoring-workspace/phase-6e3.md new file mode 100644 index 00000000..9cfee004 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6e3.md @@ -0,0 +1,82 @@ +# P6e3:Timeline 插值、切线与曲线 + +日期:2026-10-19。前置 P6e2b verified。按可验证切片实施,不纳入游戏实时预览。 + +## P6e3a:关键帧详情与批量切线 + +状态:verified。关键帧详情区保留布局空间,无选择时禁用,避免框选/拖动过程中画布换位。选择已有关键帧后显示数值、出段 Step/Linear/Hermite、入/出切线。多选支持一次统一设置一个字段,混合值留空;首帧可改值/切线但沿用既有移动/删除保护。数字使用 NumericUpDown,Enter/失焦提交,Escape 撤销未确认输入;插值立即确认。 + +原始切线单位为值/帧,可切换按秒显示,换算为值/秒 = 值/帧 × FPS,保存始终值/帧。平坦、线性、平滑、对齐为一次性明确动作,不引入持久自动切线模式。切到 Step/Linear 保留未使用切线。每个多键动作是一次根 JSON patch/项目历史,保留大小写、未知字段、键身份和其他参数。选择/Entry/帧/根版本改变拒绝旧详情提交,保存和切语言保留有效草稿。 + +详情是专属 Timeline 控件,延续既有 NumericUpDown/ComboBox 布局,不新增运行时 DTO/schema。同一键的插值属于其后区间;末键仍可保存插值但无出段效果。有限数值和已有属性端点范围沿用现有校验,不在 Editor 单方面加入不同于编译/Runtime 的 Hermite 极值拒绝规则。 + +验证:实际关键帧选择/详情控件、混合值与批量单历史、切换插值保留切线、按秒换算/FPS、一次性预设、过期/取消、四语及保存重开,受影响回归/解决方案构建/格式/源码指纹。 + +## P6e3b:值曲线与拖柄手势 + +状态:verified。前置 P6e3a 和本轮 Timeline 交互简化。按 2026-10-19 用户确认改为属性内联曲线 UX,不再新增独立曲线/详情区域;实时游戏预览继续排除。 + +1. 每个已重载属性提供展开按钮,展开为该属性的值视图,与时间轴共享时间坐标、播放头、选择和横向缩放/滚动。普通关键帧视图的位置表示时间;值视图的纵坐标表示值,支持值域缩放/平移,树与画布行高统一更新,折叠不写作者数据。 +2. 关键帧右键基于当前选择提供插值和切线菜单:左侧/入切线、右侧/出切线、联动/断开、平坦/线性/自动等。右击未选键先选择该键;右击已选键保留多选集合。菜单批量动作一次历史,混合模式不伪装为统一值。 +3. 自动贝塞尔需定义与 Hermite 斜率的对应关系、邻键移动/增删后的自动重算和作者模式持久化;当前“一次性平滑”不得仅改名冒充持续自动模式。首版使用非加权切线;加权柄另行评估,不作为当前必需项。 +4. 值图中关键帧按值定位,显示入/出切线柄;值显示和拖柄按属性类型/能力分派,当前 float 能力先接通数值曲线,不把未来离散类型强行套成连续曲线。手势拖动本地预览,松手一次历史,Escape/捕获丢失/Entry 或源版本变化取消。复用当前单键/多键选择与作者 JSON patch,保留未知字段。0 帧不能横移/删除,但可修改值和切线。 +5. 共享 Avalonia 数值曲线控件负责绘制/命中/手势,Editor 适配负责属性能力、数据/历史和模式重算。Step/Linear/Hermite 绘制与运行时 sampler 一致,不以显示钳位掩盖 overshoot。实施前核对有限属性范围的区间极值诊断和共享校验边界;接受规则变化需同步中立规则与消费者。 +6. 内联编辑和右键菜单接通后,移除当前独立关键帧详情区及独立预设按钮;必要的精确数值输入放在对应键/柄的菜单或就地输入中,避免同一行为出现两套永久入口。 + +退出条件:真实主题下属性展开/折叠与行对齐、键/柄鼠标与菜单、批量单历史/撤销重做、自动模式邻键重算、取消/过期拒绝、四语和保存重开;采样与运行时一致,相关项目回归和解决方案构建通过。上述范围已实施,结果与保留边界见本页实施记录。 + +## Timeline 交互简化(2026-10-19) + +状态:verified。移除更换对象按钮及相关替换路径、属性勾选和“可添加属性”模式,统一为顶部选择对象、对象行“+”选择属性。对象/属性(含属性组)右键“取消重载”;移除对象/属性组整批提交一条内容历史,空对象和等待初值的属性仅取消本地草稿。过期树行不能通过旧菜单修改新索引。父行汇总点允许拖动:该时间点所有后代键一起移动,包含折叠子项;其他时间点不动,沿用第 0 帧/碰撞/取消保护。 + +真实 Semi 控件回归覆盖对象/叶属性右键菜单、整批单历史及 Undo/Redo、空对象/未知初值草稿取消、旧菜单/旧树行拒绝、未知 JSON 保留和保存重开;画布覆盖折叠对象与属性组汇总点拖动、Ctrl 部分选择补齐后代键、同帧子键一致移动而其他帧不变、碰撞及 Escape 取消。聚焦 18 项通过。首轮 17 通过/1 失败来自旧测试假定候选对象总在树第一个位置,已改为按稳定对象身份定位;失败日志 timeline-ux-focused.log 保留,修正后结果 timeline-ux-focused-2.trx。 + +最终隔离解决方案构建成功(105 条构建/分析器警告,0 错误);同一源码快照 Editor 181、Core 111、Architecture 45、Presentation 243,共 580 项通过,0 失败;另有 6 项原生媒体环境用例在 TRX 中为 NotExecuted,未把控制台“已跳过 0”汇总误记为全部执行。whitespace/IDE0005 verify 与源文件 SHA-256 核对通过,四语各 725 keys;菜单和简化布局截图已检查。 + +证据目录沿用本文件末尾 timeline-validation,脚本 validate-timeline-ux.ps1;构建 timeline-ux-final-solution-build.log,回归 timeline-ux-final-GalNet*.trx/.log,格式 timeline-ux-final-format-verify.log,指纹 timeline-ux-final-source-sha256.json,截图 timeline-ux-override-menu.png 和 p6e1-property-tree.png。 + +该次交互简化结束时保留独立详情区;后续 P6e3b 已由内联曲线和菜单替换。P6e4/P6f 仍 planned。 + +## P6e3a 实施记录 + +`TimelineDocument.Curves` 在原始树上批量修改选中键,沿用现有字段大小写、新增字段使用 camelCase;整次 patch 构造成功后才提交。`TimelineKeyEdit` 捕获文档、代次和选中键集合,`TimelineEditSession` 管理当前详情草稿;成功修改后按键位置重新关联选择,其他根替换/选择/帧改变使旧对象失效。详情初次挂载和默认值不写作者数据,避免控件初始化事件补写默认参数。 + +平滑使用前后邻键连线斜率,端点使用相邻区间;线性分别计算入/出区间斜率;对齐取现有入/出斜率平均。中间运算使用 double,保存前检查 float 有限性;任何一个目标失败均不写入。秒制仅用于显示/输入换算,FPS 修改保留原始值/帧。 + +聚焦回归 4 项通过:真实 Semi 主题的鼠标多选、NumericUpDown/ComboBox/预设按钮、混合值、单历史/首帧保护、大小写/未知字段、秒制/FPS、过期/取消、四语/保存重开;混合轨道范围整批拒绝;推导斜率溢出拒绝;原关键帧画布指针手势。截图 timeline-key-details.png 已检查。测试中修正了 Headless 输入修饰键的 API 类型,以及对新写入字段 camelCase 的错误预期,未通过修改产品 JSON 规则迎合测试。 + +最终隔离解决方案构建成功,85 条分析器/构建警告,0 错误;同源码快照 Editor 176、Core 111、Architecture 45,共 332 项通过,0 失败、0 跳过。whitespace/IDE0005 verify 与全部 11 个变更源文件 SHA-256 一致,四语各 705 keys。没有改动 Core/Runtime/编译的 Timeline 接受规则,也没有运行与本切片无关的原生媒体用例。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。脚本 validate-key-details.ps1;构建 key-details-final-solution-build.log;TRX/日志 key-details-final-GalNet*;指纹 key-details-final-source-sha256.json;格式 key-details-final-format-verify.log;真实控件截图 timeline-key-details.png。聚焦结果 key-details-focused.trx 包含新建的 3 个用例及原画布指针用例。 + +正式行为见 [Group/Timeline](../../docs/spec/editor-groups.md)、[i18n](../../docs/spec/i18n.md)。未修改开工时已有的 feature.md 与 group-property-editor-design.md 设计改动。P6e3a 退出条件满足;后续 P6e3b 当前结果见下文,P6e4 事件/剩余语义与 P6f animate 自定义曲线随后推进。 + +## P6e3b 实施决策(2026-10-19) + +作者键增加 Editor 专用 `editorTangents` 对象,保存 incoming/outgoing(Free/Flat/Linear/Auto)及 linked,保留其中未知成员。运行时继续只消费现有值/帧斜率和 Step/Linear/CubicHermite;Editor 提交值、增删/移动邻键时,在同一根 patch 内重算自动/线性/平坦切线。自动模式使用前后邻键中心斜率,在局部极值取零,并限制为相邻同号斜率最小绝对值的三倍;端点采用相邻区间斜率。此为明确的非加权、限制过冲的自动贝塞尔行为,不宣称逐项复制 Unity/Premiere 的内部算法。 + +左右侧可独立设模式,单侧选择断开联动;联动动作先平均入/出斜率并转自由模式,随后拖柄/精确输入同步另一侧。自动模式直接保存派生数值,保存重开仍保留编辑模式;编译/运行时不需要新的动态重算协议。旧无模式键默认自由且断开,打开页面不补写作者数据。 + +属性叶默认收起,展开增加就地值图,原时间坐标和播放头共享;树/画布采用同一可变行高。图形采样直接调用 Core TimelineSampler,适应值域包含 Hermite 区间极值;不更改原有属性端点/Runtime 有限键的接受规则,不新增 Editor 独有的区间越界拒绝。图中值域不钳制,所以已有 overshoot 可见。曲线摄像机是视图状态,Alt+滚轮缩放、Alt+拖动平移、适应值域按钮不写历史。 + +关键帧时间/值及入出柄拖动统一局部预览和松手单历史,首帧只允许改值/切线;非法手势释放取消,不执行隐式覆盖碰撞键。普通时间轴原有显式碰撞替换保留。右键未选键先选中,右键已选键保留多选;插值、左右/双侧模式、联动/断开、精确数值弹窗替代独立详情区。 + +验证已完成,见下方最终记录;P6e4/P6f 和实时游戏预览未混入本切片。 + +共享采样边界复现:新增 Step 前段之后内部键的精确帧与前后分数帧断言,原实现 Evaluate(10) 返回 0、预期 1,基线日志 inline-curve-step-before-fix.log/TRX 已保留;Core 区间查找改为 <=,不改变现有接受范围。经验见 `.agents/lessons/L-20261019-02-timeline-exact-key-frame.md`。两次初始构建分别暴露 Avalonia Point 减法结果不能直接取 Length,以及迁移测试缺少 Avalonia 引用,均已修正,日志 inline-curve-initial-build.log 和 inline-curve-second-build.log 保留;不属于产品运行失败。 + +聚焦回归 20 项通过;追加的精确值域/极值、摄像机缩放锚点、Alt 平移、范围/非有限切线整批拒绝及选择变化取消等边界聚焦 3 项通过。最终完整构建与六项目回归正在验证,退出状态以最终结果为准。 + +主工作区完整构建通过(121 警告、0 错误),但 Editor 总回归为 181 通过/3 失败:BrowserAddressBlocksDropAndGlobalSearchExitFollowSelection 的目录块点击、GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor 的尾部留白、GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave 的宽度。工作区当时另有非本轮 Condition/EntryList 控件及 Condition.Placeholder 修改,未纳入本轮提交;文件面板点击此前已有偶发记录。不凭猜测修改这些文件,也不隐藏失败日志 inline-curve-final-GalNet.Editor.Tests.*。 + +为确认本轮候选,使用 git archive 117d6c1 加明确 Timeline/Core 源文件和仅 12 个 Timeline.Curve.* 新 key 创建隔离快照,排除 Condition.Placeholder 和未提交控件改动;依赖用清空 packageSources 的 config 从全局缓存离线还原。第一次快照构建因默认契约固定 Content 路径 MSB3030 失败;以快照自身 artifacts 产物补齐 DefaultContracts 与 schema fixture 的普通 bin 预期位置后,重建成功(38 警告、0 错误)。相关前置条件见 L-20261019-03 lesson,未改业务 csproj。隔离六项目回归与指纹待最终结果。 + +### P6e3b 最终验证 + +隔离候选源码六项目回归通过:Editor 184、Core 111、Architecture 45、Presentation 243、Primitives.Builtins 117、Integration 110,共 810 项,0 失败;Presentation TRX 另有 6 项 NotExecuted(原生媒体环境),未误用控制台“已跳过 0”汇总。四语各 737 keys,新增的 12 个 Timeline.Curve.* 文案一致;主工作区另有 Condition.Placeholder,不纳入候选或提交。 + +菜单截图 timeline-inline-curve-menu.png 已检查。收尾复核发现精确输入 Flyout 关闭后 attached flyout/content 仍能保留草稿引用,已补关闭时解除绑定、清空 DataContext/Content/attached flyout 的释放逻辑及实际弹窗断言;同一候选快照受影响 Editor 184 项重新全部通过。其他五项目的代码和输入未变化,未重复运行。whitespace/IDE0005 verify 和原工作区/候选快照源码指纹一致性通过。 + +最终证据:inline-curve-isolated-build-2.log;inline-curve-isolated-GalNet*.trx/.log;弹窗修正 inline-curve-popup-build.log、inline-curve-popup-GalNet.Editor.Tests.trx/.log;格式 inline-curve-final-format-verify.log 与 inline-curve-popup-format-verify.log;最终指纹 inline-curve-main-final-source-sha256.json、inline-curve-snapshot-final-source-sha256.json。证据目录沿用本页 timeline-validation。主工作区首次 3 项非 Timeline 失败保留在原日志,不伪报原工作区全绿;本轮候选已排除并保留另一组控件改动。临时源码快照验证后清理,避免测试项目递归扫描误识别重复 csproj;HEAD 源码归档和输入清单保留。 + +P6e3a/P6e3b 退出条件均满足,P6e3 verified;P6e 整体仍 in-progress。后续 P6e4 事件/剩余语义、P6f animate 自定义曲线;加权切线和实时游戏预览不在本轮范围。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-6e4.md b/features/F-20261015-02-editor-authoring-workspace/phase-6e4.md new file mode 100644 index 00000000..f2d05f86 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-6e4.md @@ -0,0 +1,144 @@ +# P6e4:Timeline 事件与剩余状态语义 + +2026-10-19,前置 P6e3 verified。用户授权继续,按可验证切片推进;不加入实时游戏预览。 + +## P6e4a:事件能力与帧内场景查询 + +状态:verified(2026-10-19,保留以下已复现的基线布局失败)。本轮交付统一内置事件能力清单/校验入口、指定 Timeline 帧与事件前位置的纯场景查询、事件创建对象的 Locator、当前 Timeline 对象选择和属性初值接入。支持类型沿用现有十种运行时事件,不扩大执行授权。 + +- 按帧排序,同帧按作者数组原序处理;事件前查询只处理严格更早的帧和同帧前面的事件,当前帧轨道尚未采样。缺目标操作跳过,不虚构对象。 +- 当前帧查询描述当前 Timeline 第一次正向行程,先事件后轨道纯采样;不运行游戏、不调用 presenter。事件前对于已跟踪属性不猜测上一显示采样值。Additive 与事件基值存在未覆盖组合时明确清除该属性唯一值并提示不完整。 +- 已阻塞完成的 Once Timeline(以及零时长 Once)可向后续剧情提供终态事实;非阻塞、循环与 PingPong 向后续传播保守可能集合,不假定已播放完成。复杂播放生命周期仍待后续。 +- 事件对象 Locator 使用所属 Group/Entry、输出槽位和事件身份(有 editorId 使用它,否则按声明输出槽位/不透明输出身份定位),不依赖数组下标;作者界面仅显示创建帧/资源/来源。尚未创建的候选明确显示未来帧、可能存在且没有当前数值。 +- 属性新增的第 0 帧初值来自第 0 帧查询,当前显示值来自当前帧;不把中途事件之后的值误写为第 0 帧基值。未解析/不完整作者 JSON 保留,不因查询写回默认值。 +- 查询缓存按作者快照代次失效;选择 lease 包含帧/事件位置,语言/缩略图刷新不得丢失位置。项目关闭、作者编辑/重排及帧切换使旧提交失效。 + +验证:能力清单和契约一致;十种状态事件覆盖、同帧顺序/事件前、未来创建、重建/缺目标、阻塞/非阻塞/循环、轨道纯采样与未知值;查询无写入、缓存/关闭/过期、实际选择器与初值/单历史/保存重开、四语。完成后提交 Git;P6e4 整体不因 a 完成而标记 verified。 + +### 验证过程与隔离范围 + +- 主工作区场景查询专项 17 项通过;实际对象选择器与 Timeline 专项 24 项通过。四语候选创建帧、未来/当前标签、Frame lease、查询不写历史、第 0 帧与当前帧分别取值、单历史撤销/重做和保存重开均有覆盖。 +- 期间 HEAD 从 `fc06963` 推进到另一项 Group UI 提交 `59ce326`,该提交四语重复 `Entry.Parameter.z`,英文另重复 `Entry.Parameter.opacity`,使 EditorLocalizationService 初始化失败;首次 Editor 专项 23 失败/1 通过尚未进入业务断言。保留日志,移除重复定义后 24 项全部通过;保留新 Group 文案。此修复随本切片提交。 +- 最终候选基于 `59ce326` 加明确清单内 13 个 C# 文件和四语小改动;不混入工作区游戏本地化、schema 或服务注册等未完成改动。独立默认契约 DLL、schema fixture 均由同一候选源码构建,不借用主工作区旧产物。 +- 最终候选解决方案构建成功,125 警告、0 错误;六项目共 797 通过、2 失败、0 跳过:Editor 183/2,Editor.Shared 231/0,Core 111/0,Architecture 45/0,Primitives.Builtins 117/0,Integration 110/0。完整 Editor 包含本轮实际选择器与既有 Timeline 回归。未运行 Presentation 原生媒体用例。 +- 两项失败分别为 `GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor`(尾部留白 0,期望 ≥40)与 `GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave`(窄面板宽度 482,期望 <480)。在同一隔离快照移除本轮 13 文件改动、还原 `59ce326` 并仅修复重复翻译键后独立复测,仍为相同 2 失败/0 通过;不是本轮引入,未修改 Group 布局或放宽断言。P6e4a 的 verified 仅指本切片,不宣称完整 Editor 绿灯;该布局问题仍待处理。 +- 最终候选 whitespace/IDE0005 verify 通过,13 文件源码 SHA256 与主工作区一致;候选源码另存 archive,基线复测和清理均只操作已核对绝对路径的临时快照。 +- 原始证据保存在本机 `C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation/`,前缀 `events-a-`、`timeline-events-`,含 base archive、owned 清单、构建/测试日志、TRX、源码 SHA256 和格式检查。 + +## P6e4b:事件作者界面 + +状态:verified(2026-10-20,保留已独立复现的两项 Group 布局基线失败),依赖 a。独立事件行、有限类型选择器、添加/移动/多选/删除和同帧排序;详情复用 DTO 生成面板,字段 Locator 使用事件前位置;新增创建者身份、raw patch 和未知 JSON 保留、每次手势单历史及草稿/过期保护。这里才交付事件的 UI 新增和编辑,不将查询入口冒称事件编辑器。 + +事件行按帧及同帧作者顺序显示,每个事件占一行,复用共享画布的选择/框选/整帧拖动;与轨道共用缩放、横向位置和播放头。允许多事件位于同帧,不套用属性键的碰撞替换。右键提供编辑、同帧提前/延后及删除;未知事件可移动/删除并保留原 JSON,但不冒充受支持类型编辑。新增在当前帧、同帧末尾插入,输出身份和 editorId 在显式新增时生成。事件详情是可取消的 DTO 草稿,应用一次提交全部字段;切换源、帧、撤销或关闭使草稿失效,查询/打开不写内容。拖到末尾外自动延长且和移动合为单历史,拖到负帧拒绝。 + +### P6e4b 实施与验证过程 + +- `TimelineDocument.Events` 对 raw JSON 做局部添加/移动/删除/排序/参数 patch,保留原根与成员大小写、未知值、事件身份及同帧顺序;只在显式新增时生成内部 ID。 +- `TimelineEventEditing` 是项目会话的事件选择/手势状态,复用共享 TimelineCanvasModel/Canvas,不把事件语义放到共享控件。事件与属性键各自多选,选中另一类时取消旧手势;水平视图双向同步,播放头拖动仍只在松手查询一次。 +- `TimelineEventEdit` 提供 DTO 字段草稿、批量提交入口、可见性和事件前 Locator 选项。目标展示刷新保留位置和筛选条件。普通 DTO 控件、Transform/Color、场景 Picker 复用;图片/效果程序字段注册复用 AssetPicker。 +- 首轮真实弹窗用例因 Group 私有 SchemaForm 模板未进入 Timeline 弹窗而失败:Host.Diagnostics 为空但没有 TransformX。抽出 `AuthorFormStyles.axaml` 并由 Group 和弹窗显式引用后通过;额外检查子 SchemaForm.Diagnostics,十种事件全部真实挂载。经验见 [跨宿主表单样式](../../.agents/lessons/L-20261020-01-schema-form-host-style.md)。 +- 初轮修正后 Timeline/场景选择专项 26 项通过;随后十种表单、负帧拒绝及未知事件保护专项通过。保存重开、相关撤销/关闭过期与完整候选回归继续使用最终同源码证据。 +- 最终验证候选从当前提交构建,仅覆盖本轮 17 个源码文件;组合注册只补资产依赖一处,四语只加入事件文案。其他本地化、文档事务及其服务注册保持在工作区,不加入本轮候选和提交。证据位于上述目录,前缀 `events-b-`,包含清单、archive、日志、TRX、源码指纹和实际弹窗截图。 + +### P6e4b 最终验证 + +- 隔离基线为 `16f8cb9`。全新候选解决方案构建成功,126 警告、0 错误;最终轻量预览/切换源收尾后再次全解构建成功,50 警告、0 错误。没有改动 Core/Runtime 或共享画布的执行语义。 +- 六项目累计 799 通过、2 失败、0 跳过:最终 Editor 185/2,Editor.Shared 231/0、Core 111/0、Architecture 45/0、Primitives.Builtins 117/0、Integration 110/0。最后五项目来源和代码未随收尾修改,沿用此前该候选的通过记录;Editor 全量在收尾后重跑。两项失败仍是上节已在基线独立复现的 Group 尾部留白/窄面板宽度,未放宽断言或混入布局修复。没有运行 Presentation 原生媒体测试。 +- 真实控件覆盖受限搜索类型选择、自动输出身份和 Undo/Redo 稳定性、多事件拖动预览/延长单历史、负帧拒绝/Escape、同帧顺序及保持选择、未知事件移动/删除/无损保存、双向横向视图、四语、DTO/资产控件、事件前目标查询、参数草稿应用单历史/取消、帧/条目/撤销/关闭失效,以及应用后保存重开。 +- 事件投影缓存于不可变文档;拖动预览只校验选中帧,松手才复制/序列化 JSON。切到不可编辑条目立即清空事件选择、手势、行与草稿,项目 scope 释放时移除事件模型回调。 +- 最终 whitespace/IDE0005 verify 与独占源码 SHA256 核对记录见 `events-b-final-*`。组合文件和四语使用候选内的限定 patch,提交时同样只写这些改动;其他工作区内容保持未提交。阶段只标记 b 完成,c/P6f 及实时预览边界不变。 + +## P6e4c:剩余状态能力与引用 + +状态:in-progress,按可验证切片推进。转场及其他未覆盖内置语义、effect metadata/flipbook、播放实例定位器、Graph/转场复制引用重映射,以及缺目标运行时独立核验。循环/并发动画的存在性和值保持保守,未经验证不提升为确定事实。 + +### P6e4c1:Graph 与转场复制身份 + +状态:verified(2026-10-20,保留已独立复现的两项 Group 布局基线失败)。先完成引用边界,不将复制身份支持视为转场状态分析或播放实例选择器已经实现。 + +- Graph 粘贴对实际选中 Group 的所有条目执行一次发现/重写,跨 Group 引用使用同一映射;重复粘贴使用新的身份,单次粘贴的 Undo/Redo 保留既有身份。 +- 六种内置转场明确区分输入对象、输出新对象和播放身份。GUI 新建转场分配输出与播放身份,复制批内声明的创建者时重映射相关引用;仅复制转场时仍引用原外部输入对象。 +- Timeline 轨道、事件创建者和后续停止/效果目标引用参与批次重映射;资源 ID、任意扩展字符串、大整数和未知自定义条目的同名参数不作为引用处理。查询或打开项目不生成身份。 +- 验证边界:六种转场的创建/复制、先引用后创建、内外目标、跨 Group/Timeline 事件引用、重复粘贴、单步撤销/重做及保存重开;隔离快照排除其他并行工作。 + +后续 c 切片仍需实现转场状态语义、effect metadata/flipbook、播放实例 Locator 和缺目标运行时独立核验;不新增实时预览。 + +#### P6e4c1 验证证据 + +- 隔离基线 `8df9d4f`,只覆盖本轮四个 C# 文件,不包含工作区其他未提交修改。全新候选解决方案构建成功:127 警告、0 错误。测试断言收尾后重建 Editor 测试成功:41 警告、0 错误。 +- 最终三个项目共 468 通过、2 失败、0 跳过:Editor 186/2、Editor.Shared 237/0、Architecture 45/0。本轮六种转场参数用例与跨 Group 生命周期用例全部通过;保留的两项失败仍为此前独立复现的 Group 尾部留白和窄面板宽度,未放宽其断言。没有修改 Runtime/媒体行为,也没有运行其测试套件。 +- 首轮新增生命周期用例把文档缓存误当作当前 Group 集合,改为断言正式 Graph 与项目 Group 映射;保存会排序 JSON 对象成员,保存重开检查改用 JSON 语义比较,继续要求数值和引用完全一致。首轮及两次专项结果均保留,最终完整 Editor 重跑仅剩上述已知基线失败。 +- 四个源码文件 whitespace/IDE0005 verify 通过,主工作区与候选源码 SHA256 一致。证据位于前述 `timeline-validation` 目录,前缀 `references-c1-`,包含隔离基线/候选源码 archive、构建与测试日志、TRX、文件清单和指纹。完成后仅提交本切片七个文件,其他工作保持未提交。 + +### P6e4c2:转场状态与作者对象入口 + +状态:verified(2026-10-20,保留两项已独立复现的 Group 布局基线失败)。六种内置转场使用内置契约目录和现有纯编译器展开为 Timeline,再复用事件/轨道终态分析;不运行游戏或执行自定义扩展。展开结果及失败按不可变快照缓存,条件在原作者条目边界合并。未知作者扩展仅从临时编译投影中排除,原始数据不改写,项目的严格编译校验规则不变。 + +转场输出定位器指向原 Group/Entry 和声明输出槽位,不暴露编译生成的事件、遮罩或覆盖层。输入成员复用场景对象选择器且只提供此前层对象;输出对象和播放身份由共同身份声明隐藏。验证包括六种终态/已知数值、缺失输入、条件/循环/无效参数、查询无写入、真实表单与选择器、单步 Undo/Redo 和保存重开。effect metadata/flipbook、播放实例 Locator、缺目标运行时独立核验继续留后续;本切片的缺目标测试只验证作者分析结果。 + + +#### P6e4c2 实施与验证 + +- 只扩展六种已知内置转场。SceneTargetAnalysis.Transitions 缓存纯编译生成的 Timeline,再复用现有有限状态和终态采样;输出恢复为原作者条目 Locator。条件合并、Graph 固定点和项目换代仍沿用原入口。共同身份声明同时用于创建/复制及字段隐藏,五个 DTO 输入成员注册复用现有 SceneTargetFieldEditor。 +- 首轮场景专项 48 项通过,真实控件用例最初误以为所有转场都有整张 ParameterForm;改为检查混合资产/通用字段的实际绑定,并收起无关条目后,六种真实 Picker 用例全部通过。包括隐藏输出/播放身份、只选此前层对象、单历史撤销重做、过期选择拒绝和保存重开;未知作者扩展及大整数保留。 +- 后续主动补测坏 Transform:现有逐事件容错会继续执行隐藏旧层,六种用例修复前 5 失败/1 通过。转场改在临时事实中验证本次完整性后发布;失败保留输入并降为可能,不清除上游已有不完整标记。修复后 55 项场景专项全部通过。经验见 [复合分析原子发布](../../.agents/lessons/L-20261020-04-composite-analysis-publication.md)。 +- 初始隔离基线 5549ffb,仅覆盖本轮八个源码文件,解决方案构建 145 警告/0 错误,首轮五项目 775 通过/2 已知失败。验证期间 HEAD 推进到 d2e4b82,包含 Group/角色设置改动;候选只补入这次已提交的 22 个文件,不纳入其他正在进行的游戏文本工作。重新全解构建 164 警告/0 错误;默认契约与 fixture 固定路径产物均由该候选重建刷新,未借用工作区旧 DLL。原子传播收尾后重建受影响 Editor/Editor.Shared 测试项目,0 错误。 +- 最终五项目 781 通过、2 失败、0 跳过:Editor 213/2、Editor.Shared 296/0、Architecture 45/0、Primitives.Builtins 117/0、Integration 110/0。两项失败仍是先前已独立复现的 Group 尾部留白/窄面板宽度,未放宽断言。中间一次完整 Editor 运行在 EntryNativeMultiSelection 的 HeadlessUnitTestSession.DisposeAsync 出现 NullReferenceException;该用例单独复查和最终完整 Editor 均通过,保留中间日志,不将其冒称已独立确认的基线失败。本切片未修改 Runtime 执行,未运行原生媒体套件,也没有宣称完成缺目标运行时独立核验。 +- 最终八个源码文件 whitespace/IDE0005 verify 通过;七个独占文件 SHA256 与候选一致,共享 EditorFieldEditors 仅核验并提交五行转场注册,保留工作区其他文本服务注册。原始证据位于前述 timeline-validation 目录,前缀 transitions-c2-,包含初始基线 archive、已提交集成 overlay、八文件最终源码 archive、源码指纹、构建/格式日志和所有 TRX。完成后清理已核对绝对路径的临时快照,只提交本切片代码、测试、文档与 lesson。 + +P6e4c 仍在实施;后续为 effect metadata/flipbook 能力、播放实例 Locator、其他内置语义与缺目标运行时独立核验。实时游戏预览不加入本阶段。 + + +### P6e4c3a:Flipbook 索引能力 + +状态:verified(2026-10-21,保留两项已独立复现的 Group 布局基线失败)。c3 拆为直接来自作者 JSON 的 flipbook 能力和需要异步资源读取的效果 metadata 两个可验证切片。本轮仅实现前者,不将效果资源缓存或 shader 编译列为已完成。 + +只为 layer.show 中非空且布局有效的 FlipbookDefinition 提供 Core 声明的 flipbook.index;保留原始浮点索引,不取整、不按 FrameCount 限定动画值。Move/Replace 保留该能力,重新显示静态层移除该能力。无效布局仅保留基础层事实并标记不完整;无效索引不给可写初值,坏 JSON 不发布新层。条件/分支与 Timeline 事件/未来候选/采样复用现有分析,既有无法解析的轨道保留。 + +验证边界:静态/null/default/无效定义、原始浮点与超帧数值、分支能力交集、事件首帧与未来候选、Core 现有独立行为、实际属性菜单及 NumericUpDown、单步 Undo/Redo、能力移除后的旧行失效、四语和保存重开。P6e4c3b 继续接入 shader 注释 metadata 的异步资源快照及刷新,不在同步查询中读取或编译 GPU 程序。 + + +#### P6e4c3a 验证证据 + +- 隔离基线 d0105ba,只覆盖本轮三个 C# 文件。首轮编译的唯一错误是测试调用了内部 Descendants 方法;改用公开 Children 检查,没有扩展产品 API。最终解决方案构建成功,42 警告、0 错误。默认契约和 schema fixture 固定路径产物均来自同一候选源码,工作区其他本地化界面工作未纳入候选。 +- 场景分析专项 67 项通过,包含本轮 12 项:浮点/超帧数值、null/默认/无效布局/整数容量溢出/负初值、坏 JSON 不发布新对象、Move/Replace/重新 Show、条件与分支能力交集、事件未来候选和共用采样。实际 Timeline 用例通过属性“+”菜单和 NumericUpDown 写入 7.25,验证第 0 帧保持 2.5、单步撤销重做、旧行失效不删除轨道、四语及保存重开,保留未知字段和大整数。 +- 最终六项目 921 通过、2 失败、0 跳过:Editor 226/2、Editor.Shared 312/0、Architecture 45/0、Core 111/0、Primitives.Builtins 117/0、Integration 110/0。Core 的现有独立用例确认静态层拒绝索引动画、非空 flipbook 接受超帧数原始索引而只夹取显示格子。保留的两项失败仍是此前独立复现的 Group 尾部留白/窄面板宽度,未修改布局或放宽断言。未运行原生媒体测试。 +- 最终三个源码文件 whitespace/IDE0005 verify 通过,SHA256 与主工作区一致;证据在上述 timeline-validation 目录,前缀 flipbook-c3a-,含基线/候选源码 archive、三个文件清单、源码指纹、构建/格式日志与 TRX。完成后删除核对绝对路径的临时快照,只提交本切片代码、测试和文档。 + +### P6e4c3b:效果 metadata 资源快照 + +状态:verified(2026-10-21,保留两项已独立复现的 Group 布局基线失败)。效果参数声明来自 shader 源码注释,不是 EffectProgramAssetMeta 字段;复用 Core 的 ShaderEffectMetadataParser 和中立描述,不调用 Skia shader 编译器。需要单列异步资源准备、不可变快照代次、资源更新/关闭取消及旧选择 lease 失效;同步场景查询只读取已准备的事实,不能阻塞 UI 等待文件读取。 + +已验证 program 与 legacy catalog 的区分、animatable 标记和受支持标量属性、静态参数/metadata 默认值与现有运行路径一致、缺失/坏资源的保守能力,以及 Timeline 事件、属性菜单和保存闭环。本切片完成;播放实例 Locator 和缺目标运行时独立核验仍保留后续。 + +设计落点:Core 共用受支持标量属性转换;Editor.Shared 接收不可变 program descriptor 快照及 legacy catalog,静态参数优先于 metadata 默认值并夹取范围。资源读取/UTF-8 解析在后台,不编译 shader;每个作者快照准备前 256 个不同 program、单资源最多 1 MiB,失败缓存至资源刷新。项目级查询代次隔离旧加载、选择和编辑行;缺声明仅使该对象能力不完整,不破坏已知身份或让已移除的临时效果污染终态。 + + +#### P6e4c3b 验证证据 + +- 隔离基线 e7aa7f2,只覆盖明确列出的 11 个 C# 文件。本工作区并行的本地化交换功能未纳入候选或提交。默认契约和 schema fixture 的固定路径产物均由同一候选源码生成。首轮受限 .NET 进程无法访问 Windows 服务控制器,改用获准执行环境进行离线构建;首轮两处测试编译错误已修正,未扩展产品 API。最终解决方案构建 115 警告、0 错误。 +- 场景分析专项 74 项通过,本轮增加 7 项;另有 Core 转换 1 项、Editor 异步生命周期和真实 Timeline 2 项。覆盖受支持标量/禁用属性、stage、静态参数与默认值/夹取、legacy 路径、progress 的 Core 初值、事件/未来候选/保守 Additive,以及效果移除后只恢复能力完整性、不抹掉上游未知语义。 +- 异步测试使用可控延迟资源:调用方取消不取消共享准备;刷新后旧结果不回写;坏 metadata、UTF-8、错误类型、过大和缺失资源保守退回并负缓存;释放项目后不回写。真实 Timeline 通过对象行属性菜单和 NumericUpDown 编辑效果参数,验证单步 Undo/Redo、资源 Changed 后旧 Locator/属性行失效、保留既有轨道、刷新不写历史,以及保存重开保留未知 JSON 与大整数。 +- 最终七项目:Editor 242 通过/2 失败、Editor.Shared 319/0、Architecture 45/0、Core 116/0、Primitives.Builtins 117/0、Integration 110/0、Presentation 243/0;合计 1192 通过、2 失败。两项仍为已独立复现的 Group 尾部留白和窄面板宽度断言,未修改布局或放宽断言。6 项原生媒体环境测试未执行;metadata 接入不宣称 GPU shader 可编译或实时预览已实现。 +- 11 个最终源码 whitespace/IDE0005 verify 与 SHA256 核对通过,主工作区、隔离候选一致;证据在 timeline-validation,前缀 metadata-c3b-,含基线/候选源码归档、文件清单、指纹、构建/格式日志和 TRX。删除经绝对路径核对的临时快照后,仅提交本切片代码、测试、文档和 lesson。 + +P6e4c 整体仍 in-progress。下一步为播放实例 Locator、其余内置语义与缺目标运行时独立核验;animate 自定义曲线留 P6f,实时预览不列 Phase。 + +### P6e4c4:播放实例 Locator + +状态:verified(2026-10-21,保留两项已独立复现的 Group 布局基线失败)。在同一纯查询状态中单独保存播放候选,与可动画场景对象分域;使用原 Animate 的 playbackHandleId 或 Timeline 的 timeline.playbackHandleId 槽位形成 Locator。停止动画字段复用同一查询服务、选择 lease 和卡片控件,列表显示本地化类型/原条目来源,不暴露底层 ID;Timeline 对象/属性树不列播放实例。 + +执行后的非阻塞 Once 与循环播放只标可能存在,不猜测自然结束/替换/并发时刻;阻塞完成和零时长 Once 不创建后续候选。Timeline 内部查询可指向当前播放,但第一次行程终点保持保守。CompleteImmediately 停止移除候选,AfterIteration 只保留可能候选。条件、Graph 分支/循环、未知语义、复制身份及项目/作者代次失效共用现有规则;冲突播放身份不猜测创建者。播放生命周期不同于场景对象的动画数值,不在此切片预测完整运行终态或实现实时预览。 + +退出条件:纯查询、条件/分支/循环预算、停止模式、无效/冲突身份、现有场景候选不污染、真实停止字段搜索/键盘/四语/未知引用、单步 Undo/Redo、复制关联、过期选择及保存重开均有证据;其余内置语义和缺目标运行时独立核验保留后续。 + +#### P6e4c4 实施与验证 + +- 同一纯分析 State 分别维护场景和播放候选,条件/分支/循环固定点与预算处理覆盖两域;结果的 Targets、Playbacks 分开,Timeline 树保持场景对象语义。原作者来源槽位生成 Locator,冲突身份保守降为未解析,查询不生成 ID。六种转场的阻塞完成不会泄漏内部播放。 +- 停止字段通过 AnimationStopOptions 成员映射复用 SceneTargetFieldEditor/SceneTargetPicker;快照、解析与语言刷新显式保留播放域。标签、搜索提示四语接通,候选显示本地化类型、英文类型 ID 与来源;不展示底层播放 handle。选择、清空及既有未解析引用沿用项目作者 binding、单历史和 JSON 保留。 +- 首轮 16 项分析失败来自测试 helper 将 Animate 的字符串布尔写成 JSON bool;生产 converter 严格规则保留,修正后加入原生 bool 拒绝和有效夹具 Complete 断言,见 author-parameter-wire-format lesson。另修正预算零尚无入口的夹具预期,并让真实列表用例定位停止条目、先将控件滚动入视区;没有绕过真实指针/键盘,未放宽产品行为断言。原始专项失败日志和 TRX 保留。 +- 最终场景分析专项 100 项通过,本轮新增 26 项;真实播放选择器 1 项通过,覆盖本地化/英文搜索、无结果不提交、四语刷新不换域、不显示 handle、选择/清空单步 Undo/Redo、作者改变/Undo/关闭后旧 lease 拒绝、Timeline 对象树不列播放实例,以及保存重开保留未知字段和大整数。 +- 隔离基线 39bc39b,明确覆盖 13 个 C# 与 4 个语言资源文件;默认契约及 schema fixture 固定路径产物均来自此候选。最终解决方案构建 49 警告、0 错误。最终七项目合计 1239 通过、2 失败、0 跳过:Editor 252/2、Editor.Shared 355/0、Architecture 45/0、Core 116/0、Primitives.Builtins 117/0、Integration 111/0、Presentation 243/0。两项仍是此前独立复现的 Group 尾部留白和窄面板宽度,未修改布局或放宽断言。6 项原生媒体环境测试未执行;本切片未声称运行时全部缺目标语义已独立验证。 +- 13 个 C# 的 whitespace/IDE0005 verify、四语 JSON 唯一 key/键集合、全部 17 个候选源码/资源 SHA256 与归档核对通过。证据在 timeline-validation,前缀 playback-c4-,含基线、清单、候选源码 archive、构建/格式日志及 TRX;仅删除核对绝对路径的本轮临时快照。当前规范见 [Editor 场景目标查询](../../docs/spec/editor-scene-targets.md)。 + +P6e4c 仍 in-progress。继续剩余内置语义与缺目标运行时独立核验;P6f 自定义曲线和实时预览的原有边界不变。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-group-properties.md b/features/F-20261015-02-editor-authoring-workspace/phase-group-properties.md new file mode 100644 index 00000000..39b8386a --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/phase-group-properties.md @@ -0,0 +1,50 @@ +# GalGroup 属性编辑器改造 + +日期:2026-10-19。状态:verified。 + +用户已明确授权实现 [属性编辑器设计](group-property-editor-design.md)。此切片独立于 Timeline 图形曲线阶段;不改写 P6e3a 的完成状态,不实现实时预览。 + +## 范围与退出条件 + +1. 统一字段行、标题左上、重置同行右侧、窄 Dock 排版;包括现有专属资源/文本字段,Group 不显示常驻 null/省略/恢复草稿按钮。 +2. Group 的空非字符串可选输入恢复缺省;默认值重置、可选无默认值清空、必填无默认值禁用,原文件 null 不因显示改变。Sample 的现有操作保持独立。 +3. Builtins Transform/ToTransform 成员注册为结构控件;只更新目标成员,保留大小写与未知内容;颜色使用支持透明度的主题拾色器,确认一次历史。 +4. Timeline 成员改为跳转按钮和摘要,先定位 Entry 再激活单实例 Dock;缺省时不因打开创建数据,旧绑定失效。 +5. 普通 DTO/列表继续按现有类型递归分派,改进组标题和元素重复标签;高级源码仍作为不能用结构控件处理的值的修复入口。 + +验证要求:真实主题挂载及宽窄布局截图;Transform/Color 单历史、取消和保存重开;Timeline 非活动行定位及关闭重开;清空与默认值语义;受影响的 Editor/共享控件回归、架构检查、解决方案构建和格式验证。只有证据齐全才标记 verified。 + +## 实施选择 + +- 复用成员注册覆盖 JsonElement 参数,没有迁移 Core/Runtime 或文件格式。 +- 统一 FieldEditorRow 由共享 Controls 提供,Group 自行选择模板和简化动作;不更改 Sample 操作策略。 +- 默认值重置与无默认可选清空共用一个图标,Tooltip 分别说明;必填无默认禁用。 +- 对象折叠仍保留懒创建/释放语义;不在此切片移除八层/200 元素防护或加入任意动态结构编辑器。 + +## 验证记录 + +证据写入 `Temp/group-properties`,初轮构建日志 `Temp/group-properties-build.log`。首轮捕获 Transform 控件局部事件闭包的 definite-assignment 编译错误,已修正;不是运行期或既有基线故障。 + +真实挂载时三项测试暴露延迟模板无法解析命名空间祖先类型的问题;改为显式 RelativeSource + x:Type 后通过。见 `.agents/lessons/L-20261019-01-template-ancestor-type-resolution.md`。沙箱内 VSTest 在 testhost 连接本机回环端口阶段停滞,诊断日志 `Temp/group-properties-testhost-diag.log`;授权的沙箱外测试能够正常启动,不将启动失败记为用例失败或通过。 + +完整 Editor 181、Presentation 243、Shared 223、Architecture 45、Integration 110 项通过,共 802 个不同用例。Presentation 另外六个原生媒体环境用例未执行,不计入通过数。初轮后补齐截图中的 Transform 子输入对齐、清空与无关更新保留草稿,以及无默认 Timeline 禁止通用重置;最终重新执行 Editor 181 和 Presentation 243 项,均通过。普通对象根校验失败后的源码修复、Typed Transform 列表新增/重排旧绑定、Timeline 禁用重置均有实际控件测试。 + +最终解决方案构建成功,16 条警告、0 错误;whitespace 与 IDE0005 的 verify-no-changes 检查通过,最终源码 SHA-256 核对一致。末轮 CanReset 属性曾触发 C# 14 的 field 关键字绑定冲突,已用 @field 明确引用构造参数后重新构建和回归,不保留失败绕过。 + +证据: + +- `Temp/group-properties/GalNet.Editor.Tests.final.trx`、`GalNet.Presentation.Tests.final.trx`,其余为 `GalNet.Editor.Shared.Tests.trx`、`GalNet.Architecture.Tests.trx`、`GalNet.IntegrationTests.trx`;聚合见 `test-summary.json`。媒体未执行项以逐项 Result 的 NotExecuted 为准,不误用 NUnit 汇总计数。 +- `Temp/group-properties/solution-build.log`、`format-verify.log`、`imports-verify.log`、`source-sha256.json`。 +- `group-properties-wide.png`、`group-properties-narrow.png`、`p6b-structured-group.png`、`p6d-timeline-dock.png` 为实际 Semi 挂载渲染;宽窄截图分别为 1000×760 与 520×760。 +- 四语各 727 keys,键集合及摘要占位符 `{0}/{1}/{2}` 一致。 + +正式事实同步至 `docs/spec/editor-groups.md`、`docs/spec/control.md`。本补充切片 verified,不改变整个 F-20261015-02 仍 in-progress 的状态。 + +## 自审与边界 + +- 专属编辑器仍提交项目作者意图;没有写入 Runtime 或改变作者/编译格式,普通子项 patch 保留未知内容,父级明确重置才替换对象。 +- Timeline 跳转携带稳定 Group/Entry 身份,借用作用域装饰转发宿主元数据,同时保留过期绑定检查;未创建定义不因导航写入。 +- 常态只显示重置;Transform/Timeline 源值异常才提供修复,普通 DTO 局部修改被根校验拒绝时显示源码入口。没有为了消除 JSON 而丢失坏值修复路径。 +- 源值比较使用语义 JSON 相等,避免同值快照克隆或其他字段更新取消 Transform 草稿;列表结构换代仍取消旧元素输入。 +- 保留八层/200 元素防护与动态字典、复杂转换器的 JSON 回退;未实现任意多态 schema、长列表虚拟化或布局特性/generator 扩展。 +- 验证使用实际 Semi 主题挂载和 Headless 渲染,覆盖宽窄窗口与四语;未宣称完成真实桌面多 DPI 人工创作全程或原生媒体验证。Timeline 图形曲线、事件及实时预览均不在本切片范围。 diff --git a/features/F-20261015-02-editor-authoring-workspace/phase-plan.md b/features/F-20261015-02-editor-authoring-workspace/phase-plan.md index 259f7339..a7b032e9 100644 --- a/features/F-20261015-02-editor-authoring-workspace/phase-plan.md +++ b/features/F-20261015-02-editor-authoring-workspace/phase-plan.md @@ -1,6 +1,6 @@ # Editor 创作工作区实施计划 -日期:2026-10-15;进度更新:2026-10-16。依赖:[需求](feature.md)、[设计](design.md)。用户已授权实施,P1a 已验证,见 [phase-1a.md](phase-1a.md);P1b 已完成当前工作树集成验证,构建成功、311 项测试通过,见 [phase-1b.md](phase-1b.md)。P2 已验证,当前工作树构建成功、323 项回归通过,见 [phase-2.md](phase-2.md)。用户追加编辑器 UI i18n 后,在 P3 前插入 P2i;P2i 已验证,隔离基线构建成功、328 项回归通过,见 [phase-2i.md](phase-2i.md);下一阶段 P3,其余 Phase 为 planned。状态只使用 planned / in-progress / verified / skipped;状态更新必须附实际证据。 +日期:2026-10-15;进度更新:2026-10-17。依赖:[需求](feature.md)、[设计](design.md)。用户已授权实施,P1a 已验证,见 [phase-1a.md](phase-1a.md);P1b 已完成当前工作树集成验证,构建成功、311 项测试通过,见 [phase-1b.md](phase-1b.md)。P2 已验证,当前工作树构建成功、323 项回归通过,见 [phase-2.md](phase-2.md)。用户追加编辑器 UI i18n 后,在 P3 前插入 P2i;P2i 已验证,隔离基线构建成功、328 项回归通过,见 [phase-2i.md](phase-2i.md);P3 已验证,当前工作树构建成功、339 项回归通过,见 [phase-3.md](phase-3.md);P4a 已验证,当前工作树构建成功、365 项相关回归通过,见 [phase-4a.md](phase-4a.md);P4b 已验证,最终隔离解决方案构建成功、383 项相关回归通过,见 [phase-4b.md](phase-4b.md);P6a 共享 UI 基础提前验证,P5a Group 交互、P5b 多选剪贴板及 P5c 变量面板已验证,P5d/P5e 已验证,P5 整体 verified(635 项相关回归);状态只使用 planned / in-progress / verified / skipped;状态更新必须附实际证据。 ## 顺序与范围 @@ -21,11 +21,12 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 | P1b | verified | 项目统一历史、保存、失败补偿及恢复骨架;当前工作树构建成功,311 项回归通过 | 2、3、6、7 | | P2 | verified | 基本节点与连线编辑,Group 最小生命周期;323 项回归通过 | 1、4 | | P2i | verified | 四语基础页面、语言偏好与动态刷新;隔离基线构建成功、328 项回归通过 | 11 | -| P3 | planned | 分支、批量编辑、复制粘贴、快捷键 | 2、4 | -| P4a | planned | 项目文件事务和未保存文档协调 | 5、6、7 | -| P4b | planned | 项目文件树与实际文件操作体验 | 1、2、5 | -| P5 | planned | 多 Group 编辑、变量和基本参数 UI | 1、2、3、8 | -| P6 | planned | 完整 schema/复杂值/专属编辑器接入 | 8、9 | +| P3 | verified | 分支、批量编辑、深复制粘贴、键盘操作;339 项回归通过 | 2、4、11 | +| P4a | verified | 项目文件事务、引用保护和未保存文档协调;365 项相关回归通过 | 5、6、7 | +| P4b | verified | 项目文件树与实际文件操作体验 | 1、2、5 | +| P5 | in-progress | 多 Group 编辑、变量和基本参数 UI | 1、2、3、8 | +| P6a | verified | 提前完成共享三层 DTO UI 构建与两个宿主接入;610 项相关回归通过 | 8、9 | +| P6 | planned | 剩余 schema/复杂值/专属编辑器与状态查询 | 8、9 | | P7 | planned | 当前快照校验编译、i18n 与跨功能真实验收 | 1–11 | 阶段边界可因最小闭环调整,但不能跳过行为验证或把未完项藏到“后续优化”。每阶段在本目录新增 `phase-.md` 记录改动、执行命令、证据和偏差;目录不是自动生成空模板的要求。 @@ -130,7 +131,7 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 ## P3:分支、批量操作与 Graph 完整交互 -状态:planned。前置:P2、P2i。 +状态:verified。前置:P2、P2i。2026-10-17 当前工作树解决方案构建成功,Editor 102、Shared 180、架构 45、内容集成 12 项通过,见 [phase-3.md](phase-3.md)。 **目标**:支持有分支的真实剧情结构和批量编辑。 @@ -146,7 +147,7 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 ## P4a:文件事务、文档路径与引用协调 -状态:planned。前置:P3;按 design 6.2 的立即写盘设计假设推进,若用户调整先修订。P2 的最小 Group 文件能力在此统一,不能再形成另一套事务实现。 +状态:verified。2026-10-17 完成;解决方案构建与 365 项回归通过,见 [阶段记录](phase-4a.md)。前置:P3;按 design 6.2 的立即写盘设计假设推进,若用户调整先修订。P2 的最小 Group 文件能力在此统一,不能再形成另一套事务实现。 **目标**:先证明文件操作和 dirty 文档一致,再开放完整文件界面。 @@ -169,13 +170,13 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 ## P4b:项目文件树与文件编辑入口 -状态:planned。前置:P4a。 +状态:verified。2026-10-17 构建及 383 项相关回归通过,见 [phase-4b.md](phase-4b.md);地址栏/拖放/全局搜索和文本页已验证,原生对话框及外部应用人工验收仍单列。前置:P4a。 **目标**:让用户在 GUI 实际完成项目内部文件操作。 **模块**:ProjectFiles/现有 Assets 面板、路径导航/拖放、文件页、Inspector、文件对话框和定位服务。 -**任务**:项目根文件树、过滤/导航、多选、右键/快捷键/拖放;改名、移动、复制/粘贴、导入、创建和删除反馈;文件与 meta 组合显示;固定路径和内部目录禁用原因;通用文本页编辑/保存/撤销及二进制信息/外部打开。外部修改提示不覆盖 dirty 内容。 +**任务**:采用用户确认的双栏文件浏览布局:左侧树形展示目录层级,右侧网格展示当前目录的文件及子目录;树选择、网格双击、面包屑和前进/后退使用同一当前目录状态,目录展开/选择/缩略图大小属于视图状态。项目根文件树、过滤/导航、多选、右键/快捷键/拖放;改名、移动、复制/粘贴、导入、创建和删除反馈;文件与 meta 组合显示;固定路径和内部目录禁用原因;通用文本页编辑/保存/撤销及二进制信息/外部打开。外部修改提示不覆盖 dirty 内容。 **验证**:挂载文件树触发真实改名/拖放/删除;在打开页面中移动文件后标题与保存路径更新;关闭页面再撤销;UTF 编码/BOM/换行 round-trip,无法无损识别时只读;跨类别非法移动、Contracts/内部目录禁用及目录批量冲突。原生系统文件对话框/外部打开单列人工证据。 @@ -185,7 +186,7 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 ## P5:galgroup 多文档编辑与最小参数 UI -状态:planned。前置:P4b。 +状态:verified。P5a/P5b/P5c 已验证,见 [phase-5a.md](phase-5a.md)、[phase-5b.md](phase-5b.md)、[phase-5c.md](phase-5c.md);P5d 作者绑定迁移已验证,见 [phase-5d.md](phase-5d.md)。P5e 未完成/未知作者内容保存已验证,最终解决方案构建和 635 项相关回归通过,格式及源码指纹通过;整体退出条件核对见 [phase-5e.md](phase-5e.md)。前置:P4b。 **目标**:完成可保存、可编译检查的剧情条目创作。 @@ -193,7 +194,9 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 **任务**:多 Group 打开/定位;Entry 增删、插入、重排、多选、复制粘贴;type/condition 编辑;变量定义和补全;基本标量/枚举参数表单。切换类型完整保留撤销数据,不默认用 catalog 第一项创建条目;复制建立新稳定 ID。删除旧 GraphNode.Entries 真源和 VM 参数字符串同步路径。 -**验证**:两个页面交替编辑再按项目顺序撤销;条目换类型→保存→撤销完整恢复;关闭页面重开内容保留;变量和条件补全刷新;条目重排不改作者 ID;必填未完成内容可保存重开,完整编译仍拒绝;已知和未知 Entry 数据往返无丢失。 +**追加交互范围(2026-10-17)**:按 [Group 补充设计](group-editing-design.md) 实现搜索型 Entry picker(打开即搜索框焦点、当前文案/英文 ID、轻量拼音别名、首次上下选首项、后续导航与 Enter 确认);顶部添加/追加/数量/类型插入,数量作用于两个按钮,picker 直接插一条;底部操作区移除,保留列表尾部可点击空白;背景取消选择与无选择末尾插入;Space 贴近活动 Entry 或列表尾部打开插入 picker;按稳定身份保留折叠状态,拖动预览和重排不展开。本切片继续复用已验证的 P6a 三层注册和 DTO 表单;场景状态查询/Locator 仍待后续切片。 + +**验证**:两个页面交替编辑再按项目顺序撤销;条目换类型→保存→撤销完整恢复;关闭页面重开内容保留;变量和条件补全刷新;条目重排不改作者 ID;必填未完成内容可保存重开,完整编译仍拒绝;已知和未知 Entry 数据往返无丢失。真实控件验证搜索焦点/键盘/IME、零结果/取消、重复选同类型插入、批量单历史、背景取消且工具区不误取消、Space 不抢文字输入、折叠拖放及虚拟化后的状态;四语文本同步。 **文档**:阶段记录、Group 文档与 Entry 值/身份规则,更新 entry-types/file-formats 的已验证行为。 @@ -201,7 +204,7 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 ## P6:自动生成 UI 与复杂值编辑 -状态:planned。前置:P5;复用现有参数/表单机制,不重做 DTO generator。 +状态:in-progress。前置:P5;复用现有参数/表单机制,不重做 DTO generator。第 6 点原先仅形成 [扩展设计](parameter-editor-design.md),用户随后已授权实施;共享 UI 基础先独立推进 P6a,状态查询/Locator 和动画工具另作后续切片。 **目标**:让项目契约能够直接获得可用的基础编辑 UI。 @@ -209,6 +212,8 @@ P1a 基本页面/项目文档 → P1b 项目历史/保存骨架 **任务**:嵌套对象、列表元素/顺序、nullable/optional、默认值提示及显式重置;资源/表达式/多行/补全专属 EditorId;不支持节点的明确 JSON 回退;partial/full 校验分离;动态选项与 schema 失效拒绝过期提交;按展开创建字段并释放控件订阅。 +**扩展实施边界**:共享 Avalonia 层三层注册(特定 DTO、特定 DTO 成员、默认类型);默认 DTO 表单只解析成员/默认类型,多 DTO 面板逐项选择整 DTO 覆盖或默认表单。成员与根面板替换分别验收,工厂失败/重复注册/取消晚提交/lease 回收需覆盖。Sample 在程序入口复用注册协议但保持自己的设置 binding。Editor 状态查询的一定/可能两类存在性、Group 入口缓存与缺目标跳过语义、自动 handle 映射、timeline/曲线为后续独立可验收切片,落地前细分范围;不能在 P6 最小表单接入时宣称这些都已完成。 + **验证**:真实视觉树中编辑 string/enum/object/array/缺省/null/default;复杂值编辑后保存重开及撤销;粒子参数按不同字段顺序均可创作,完整编译仍严格;资源 GUID/轨道等动态选项刷新;换类型或删除 Entry 后晚提交被拒绝;未知字段原样保存;长列表与多次开关面板不持续增加控件/订阅。 **文档**:阶段记录、开发指南中的 EditorId/绑定扩展方式,区分通用控件与 Editor 业务行为。 @@ -262,3 +267,80 @@ dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false - 2026-10-15:用户授权实施,开始 P1a。Core/Runtime 的独立并发重构使工作树暂时不能整体构建,采用提交 a674feef 加本次 Editor 文件的隔离验证快照;不通过修改游戏代码消除他项中间态错误。 - 2026-10-15:用户纠正创建入口:不能要求用户准备契约 DLL。P1a 补充 Editor 自带默认契约及自动部署,自定义目录可选;打开已有项目仍严格加载其自身契约。用户要求每次完成后提交 Git。 - 2026-10-16:用户追加编辑器 UI i18n。复用现有四语机制,在 P3 前插入 P2i 补齐已有页面与动态文案;P3–P6 随功能接入,P7 统一验收。游戏文本翻译和新增语言不自动纳入。 + +- 2026-10-17:用户追加 Group 编辑 6 点;第 1–5 点接入 P5 交互范围,第 6 点仅设计并明确字段/整 DTO 注册与场景目标分析边界。数量作用添加/追加,picker 一次一条;底层 handle 保留并对演出/剧本作者隐藏。详见 group-editing-design.md。 + +- 2026-10-17:第 6 点集中为 parameter-editor-design.md,明确三种注册粒度、嵌套解析/默认表单组合、Editor/Sample binding 边界、静态场景置信度与六个候选实施切片;保持 design 状态,无业务实现。 + +- 2026-10-17:用户进一步确认共享 UI 三层映射/默认 DTO 表单/多 DTO 面板的职责;Editor 当前状态查询独立,只公开一定存在/可能存在,Entry 缺目标跳过。缓存采用 Group 入口/出口摘要和按需前缀检查点的设计方向。 + +## P6a:共享 DTO UI 构建(提前独立实施) + +状态:verified。2026-10-17 用户在确认两子系统设计后授权开始。该基础层不依赖 P5 的 Group 交互,因此先落实三层注册、默认 SchemaForm 的成员解析、单 DTO 宿主和多 DTO 面板,接入 Editor 及 Sample 设置皮肤。状态查询继续留在后续独立切片,不在共享控件中实现。 + +验证覆盖重复类型多项、注册优先级/冻结/失败、默认表单混合、自定义成员 wire ID、挂载/卸载取消与释放、顺序更新控件复用,以及两个宿主各自 binding 的真实编辑保存回归。退出条件:共享机制存在真实宿主接入及可复核测试证据;复杂嵌套结构、专业动画工具与场景查询不可混称本阶段完成。 + +P6a 验证:隔离解决方案构建成功,610 项相关回归通过、0 失败,6 个媒体环境用例未执行;whitespace/IDE0005 verify 通过。见 [phase-6a.md](phase-6a.md)。P5 和 P6 剩余功能不据此标记完成。 + +## P5a:Group 创作交互 + +状态:verified。解决方案构建 0 警告/0 错误,614 项相关回归通过,6 个媒体环境用例未执行;whitespace/IDE0005 verify 通过,见 [phase-5a.md](phase-5a.md)。用户在 P6a 完成后确认开始下一步,先交付 Group 补充设计第 1–5 点:搜索/键盘选择器、顶部批量与直接类型插入、折叠状态、尾部留白/取消选择、Space 插入。使用已有作者聚合、项目历史及 P6a DTO 表单,按真实控件和插入/Undo/保存闭环验证。P5 的多选剪贴板、变量表单及其余作者绑定整理仍单列,不因该交互切片完成而标记整个 P5 完成。 + +## P5b:Entry 多选与剪贴板 + +状态:verified。解决方案构建成功,493 项相关回归通过,格式与指纹门禁通过,详见 [phase-5b.md](phase-5b.md)。接续 P5a,实现多选、剪贴板与批量删除/移动。活动项与选中集合分开,复制按列表顺序,新副本稳定 ID 与批量单历史;剪切失败及异步晚返回保护。沿用当前项目投影,不将变量 UI/剩余字段历史迁移混入此切片。退出条件为真实主题键鼠、跨 Group 复制与项目历史、保存重开、错误/取消及构建回归验证;见 [phase-5b.md](phase-5b.md)。 + +## P5c:独立变量面板 + +状态:verified。解决方案构建成功,611 项相关回归通过,whitespace/IDE0005 verify 与源码指纹通过。按用户确认统一面板内分玩家/存档变量,先交付声明/初值/说明、显式草稿和应用、新增/修改/删除、明确引用同步重命名及候选诊断。项目统一历史,Graph 为唯一声明来源;运行期当前值不进入本面板,剩余 Entry 作者绑定迁移仍单列。见 [phase-5c.md](phase-5c.md)。 + +## P5d:Entry 作者字段绑定 + +状态:verified。用户于 2026-10-18 授权继续。迁移类型、条件、参数与新增条目入口为项目级显式意图,删除 Workspace 的 Entry 属性监听补记历史。生成表单读写作者 JSON,专属控件复用契约转换;按稳定 ID 执行完整前后值撤销,关闭页面不影响已提交历史,旧字段租约不会随撤销复活。解决方案构建 0 错误、48 条警告,614 项相关回归通过,格式和源码指纹核对通过。验证和保存边界见 [phase-5d.md](phase-5d.md)。 + +## P5e:未完成作者内容的保存 + +状态:verified。前置 P5d,用户已授权继续。此项是 P5 原有退出条件的显式拆分:实施前 Repository 的 Load/PrepareSave 和 SerializeEntry 要求完整契约有效,不能把 partial 表单可编辑等同于草稿可保存。GUI 现显式使用 Authoring,默认 Complete 及 CLI 完整校验保留;解决方案构建成功,635 项相关回归、格式及源码指纹通过。当前实施与证据见 [phase-5e.md](phase-5e.md)。 + +实现已把 GUI 作者文档的结构/身份安全检查与完整语义检查分开,允许未填写完的已知条目、未知类型/参数无损保存重开;无效 JSON、重复身份、非法路径和未知顶层 shape 继续拒绝。CLI check/compile 和游戏编译继续完整校验;逐项核对现有 Shared 安全测试,不通过删除测试或全局跳过验证达成。未知 schema 的通用 JSON 编辑 UI 与 P6 协调,P5e 至少保证打开、保留、复制/撤销及保存策略明确。退出条件为真实项目文件往返、partial/full 两条验证路径、严格结构拒绝、已知条目及未知 JSON 的无损测试。 + + +## P6b:普通复杂值表单 + +状态:verified。解决方案构建成功,879 项相关回归、格式和源码指纹通过,另 6 项原生媒体环境用例未执行。用户于 2026-10-18 授权开始,复杂定制与 AnimationPlan 一起留下一阶段。范围和证据见 [phase-6b.md](phase-6b.md):普通生成 DTO 嵌套、列表、presence/null/default、JSON 回退、懒创建/释放,沿用项目历史与保存。 + +P6 后续仍包括未知 Entry 整参数 JSON 修改、Editor 状态查询/Locator(一定/可能存在、缺目标跳过、缓存/动态候选),以及 AnimationPlan/timeline/曲线等复杂专属控件。按独立可验证切片推进,不在本阶段引入专业动画 UI。 + +2026-10-18 已形成 [复杂定制设计](complex-editor-design.md),按用户描述的 Unity 类对象/属性树交互细化。建议依赖顺序为目标/属性查询 → Plan 无损适配与手势草稿 → 对象树/关键帧 → Hermite 曲线/纯采样 → 帧内事件与专业参数面板。此处仅记录设计依赖,正式阶段编号和退出条件仍待 phase plan;不标记这些功能已实施或已验证。 + +用户随后收敛工具范围与顺序:先 Timeline Dock,再 animate 自定义曲线;其他专业参数面板移出近期范围。Timeline Dock 默认与资源同 BottomDocument 区域,树叶右侧值输入覆盖/创建当前帧键,并直接跟随当前活动 Entry:Timeline 类型可编辑,非 Timeline/无选择显示不可编辑提示,不固定旧目标。设计已补全全仓命名候选,见复杂定制设计第 10、12 节。实时预览明确不纳入阶段计划,不添加预览 Phase、不作为 Timeline 的前置或验收项;第 11 节仅为架构备忘。正式实施前将命名与 Dock/编辑/查询依赖拆为可验证阶段,不仅修改 UI 标题。 + +## P6c:Timeline 契约与命名统一 + +状态:verified。解决方案构建成功、1081 项回归和格式/源码指纹通过,6 个原生媒体环境用例未执行。2026-10-18 用户授权开始实施。前置 P6b;范围为 AnimationPlan→Timeline 的类型、播放/恢复接口、Entry ID timeline.play 与根参数 timeline,统一 composite writer、Sample/fixtures、四语与当前 spec;移除旧入口,不提供兼容别名。LoopAnimationState 的字段变化同步提升 builtin.animations payload 版本,保留 animate 共享的动画/混合/循环协议名称。 + +验证:旧 Entry ID/旧参数拒绝,新 DTO/schema/compile/执行一致;转场展开到新 ID/参数;循环 Timeline 保存/恢复、原 animate 恢复不回归、旧 payload 版本拒绝;Core、Primitives、Runtime、Presentation、Integration、Editor/Shared/Architecture 受影响回归及解决方案构建。退出条件为所有实际消费者接通和新旧边界有测试证据;不宣称 Dock 或曲线工具已实现。记录见 [phase-6c.md](phase-6c.md)。 + +## P6d:Timeline Dock 与项目编辑会话 + +状态:verified。前置 P6c。项目级活动 Entry 选择上下文、BottomDocument 单实例 Dock、非 Timeline/无选择禁用提示、类型/删除/切项目失效;项目拥有的参数 binding、无损 Timeline JSON 投影、当前帧叶值 upsert 与单历史手势已接通。值输入使用 NumericUpDown,当前帧已有键保留切线/插值。当前切片编辑已有轨道,以对象序号隐藏 handle;目标候选、轨道创建与对象树仍属 P6e。真实 Dock 选择切换、旧提交拒绝、未知字段/缺省保留、输入取消、跨页 Undo/Save/重开与四语通过验证;解决方案构建成功,644 项相关回归、格式及源码指纹通过,见 [phase-6d.md](phase-6d.md)。 + +## P6e:对象树、关键帧与曲线编辑 + +状态:in-progress。前置 P6d。接入隐藏 handle 的目标/属性能力、对象→属性树、时间刻度/缩放/滚动、关键帧多选/移动、Step/Linear/Hermite 切线、纯数值采样和受支持事件。直线查询先行;分支/循环保守分类与缺目标跳过按独立语义验收,不将未分析行为当作无影响。验证首键/碰撞/时长/FPS、切线单位、帧内事件顺序、复制引用、一次手势单历史、实际模板键鼠与保存闭环。实时场景预览不在范围内。 + +- P6e1(verified):纯场景候选、Group 入口分析/快照换代、两类存在性、基础属性树和轨道创建/移除;GUI 创建身份及 Entry 批量复制引用重映射。解决方案构建、656 项相关回归、格式与源码指纹通过;文件面板单次偶发及同源码重跑记录保留,见 [phase-6e1.md](phase-6e1.md)。 +- P6e2(verified,前置 P6e1):时间刻度/缩放/滚动、播放头、关键帧选择/拖动/删除、首键与碰撞保护、时长/FPS 操作及单历史手势;与属性树共用行布局。解决方案构建、901 项相关回归、格式和源码指纹通过,6 项原生媒体环境用例未执行;见 [phase-6e2.md](phase-6e2.md)。 +- P6e2 交互简化修订(verified):用户确认已有轨道模式持续可编辑;结束帧/FPS Enter 或失焦独立提交、FPS 固定保留帧号、结束帧不裁剪内容、越界拖动原子延长;删除旧重定时和裁剪路径。最终 457 项相关回归、解决方案构建、格式/源码指纹通过,6 项媒体环境用例未执行;见 [timeline-simplification.md](timeline-simplification.md)。 +- P6e2b(verified,前置 P6e2):指定剧情位置的场景状态查询、通用 Locator 选择/解析、已支持目标的普通 DTO 字段接入;Timeline 顶部“+”仅选择对象,对象行左侧“+”逐项加入属性轨道、父对象更换定位器。查询与缓存属于服务,展示/选择/弹窗属于控件,不抽弹窗调度服务。初轮六项目 905 项、两步交互修订后最终 461 项相关回归,解决方案构建及格式/源码指纹通过,6 项原生媒体用例未执行;目标/模块/验证/风险及退出条件见 [scene-locator-plan.md](scene-locator-plan.md)。 +- P6e3(verified,前置 P6e2b):按 [phase-6e3.md](phase-6e3.md) 分为 P6e3a(verified,关键帧详情、Step/Linear/Hermite、切线单位/一次性预设、批量单历史;解决方案构建、332 项回归及格式/源码指纹通过)和 P6e3b(verified,属性展开为内联值曲线、关键帧右键入/出切线及自动模式、共享采样与拖柄/值拖动手势,已替换独立详情区)。P6e3a 不宣称已支持图形曲线;P6e3b 保持现有属性端点接受规则,值图显示区间极值而不钳制;隔离候选构建、六项目 810 项及弹窗收尾 Editor 184 项、格式/指纹通过,另六项原生媒体未执行,原工作区其他改动和首次失败记录保留。 +- Timeline 交互简化(verified,2026-10-19):统一“+”添加对象/属性与右键取消重载,删除属性 checkbox/可添加属性模式/更换对象;父行汇总点拖动同帧后代键,单历史及首帧/碰撞规则沿用。解决方案构建、580 项相关回归、格式与指纹通过,另六项原生媒体用例未执行。记录与后续 UX 设计见 [phase-6e3.md](phase-6e3.md)。 +- P6e4(in-progress,按 [phase-6e4.md](phase-6e4.md) 切分为 a 查询/能力 verified、b 事件 UI verified、c 剩余语义/引用 in-progress):a 已接入帧内/事件前纯查询、事件对象与未来候选、首帧初值;候选构建与 797 项回归通过,另 2 项 Group 布局失败已在基线独立复现,格式/指纹通过。b 已交付事件行/有限类型新增/多选移动删除/同帧排序、DTO 草稿与事件前 Locator,799 项回归通过,保留同样两项已复现的基线布局失败;c1 已交付 Graph 跨 Group 与六种转场的复制身份重映射,GUI 新增转场分配输出/播放身份;隔离构建、468 项相关回归、格式/指纹通过,保留同样两项基线布局失败。c2 已交付六种转场的纯编译终态分析、原作者输出 Locator、输入 Picker 和内部输出身份隐藏;局部失败按整段拒绝,781 项相关回归及构建/格式/指纹通过,保留同样两项已知布局失败。c3a 已接入作者 flipbook 的原始浮点索引能力、Timeline 属性菜单/输入及事件采样;隔离构建、921 项回归和格式/指纹通过,保留同样两项基线布局失败。c3b 已接入效果 metadata 后台资源快照、静态/默认初值、资源刷新和旧行失效;隔离构建、1192 项回归及格式/指纹通过,保留同样两项布局基线失败,6 项原生媒体环境用例未执行;其他内置语义、新增播放实例目标及缺目标运行时核验继续单列。当前已支持目标的通用 Locator 选择闭环提前到 P6e2b。 + +## GalGroup 属性编辑器改造(独立补充切片) + +2026-10-19 用户授权独立推进 [GalGroup 属性编辑器改造](phase-group-properties.md),当前 verified;布局/单重置、Transform、ColorPicker、Timeline 面板入口与普通对象/列表展示已接通,802 项相关回归、解决方案构建、格式和源码指纹通过,另六项媒体用例未执行。此切片不依赖图形曲线 P6e3b,不代表 P6e 或 P6f 整体完成。 + +## P6f:animate 自定义曲线 + +状态:planned。前置 Timeline 编辑切片 verified。增加可序列化的归一化自定义曲线契约、采样/校验、编译与循环恢复,复用曲线控件及单历史手势;旧内置预设继续作为明确模式。正式实施前细化端点/overshoot 等值契约,不把界面注册当作持久化支持。实时预览不列 Phase。 diff --git a/features/F-20261015-02-editor-authoring-workspace/scene-locator-plan.md b/features/F-20261015-02-editor-authoring-workspace/scene-locator-plan.md new file mode 100644 index 00000000..e889f2de --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/scene-locator-plan.md @@ -0,0 +1,47 @@ +# 场景定位器复用与 Timeline 对象入口 + +日期:2026-10-18。状态:verified。用户授权实施并明确复用 Assets Picker、抽取指定剧情位置的场景查询服务,以及顶部选对象/对象行添加属性的两步交互。最终结果与证据见末节。 + +实施前 P6e1 已提供 SceneLocator(Group/Entry/输出槽位)、SceneTarget 和 ProjectSceneTargets.Before 查询,没有可复用的作者场景目标选择控件。资产选择的数据源是文件/资产,场景目标是某次创建的实例;同一图片创建的两个对象必须可区分。候选展示可复用,身份和查询域分别适配。 + +## P6e2b:场景定位器与对象选择 + +**前置**:P6e2 及交互简化 verified。先于 P6e3 曲线编辑,从 P6e4 提前提取当前已支持目标的选择闭环,不等待 Timeline 事件或全部自定义语义。 + +**目标与模块**:ProjectSceneTargets 是项目 scope 的场景状态查询服务,输入 Group/Entry 位置,返回该位置执行前的对象、Locator、内部 handle、存在性、支持属性和已知值;null Entry 表示 Group 末尾。缓存与有限状态推导由服务承担,不依赖控件、不打开弹窗。SceneTargetCatalog 将查询事实适配为本地化候选并解析引用;SceneTargetSelection 是控件使用的短期选择上下文。共享 Avalonia 候选展示控件不依赖 Editor 业务服务;普通字段 binding 与 Timeline 各自提交结果,不抽统一弹窗调度服务。通过特定 DTO 成员的语义注册接入,不能给所有 string 注册目标选择器。首批覆盖现有查询支持的层/粒子/效果及目标字段;新增播放实例与 Timeline 帧内查询仍留 P6e4。 + +用户进一步澄清复用层次:从现有 Assets Picker 提取搜索/卡片/缩略图/键鼠确认的 ResourcePickerPanel,原 Assets Picker 本身改用该组件;Editor 公共 SceneTargetSelection/SceneTargetPicker 统一候选、显示、选择、过期校验和 handle 解析。普通字段与 Timeline 直接复用这一对公共对象。资产文件和场上实例的数据源/身份仍独立,不再增加 Timeline 专属的对象/属性合并表单。 + +**选择行为**:可搜索资源名/对象名、类型和创建来源,显示一定/可能存在;查询当前条目执行前的状态。内部以 Locator 识别创建者输出并解析 handle,作者无需看到或输入 handle。不同创建实例分开列出;未解析引用保留并允许重选,刷新不清空参数;不支持语义显示候选可能不完整。 + +**Timeline“+”**:根据用户最新澄清,交互分为两步。顶部“+”只显示对象卡片选择器;确认后在对象栏显示目标,对象行左侧“+”弹出可加入重载的属性列表。选属性后在叶行编辑,不在选择器中配置初值/模式。已知值直接创建轨道,每个属性一次历史;未知值先显示空值草稿行,明确输入后才创建。没有属性的对象仅是当前面板草稿,切换 Entry 清除,不新增空运行时对象数据;取消不写内容,重复对象/属性不重复创建。默认只显示已创作轨道和显式选择的对象,原可用属性显示选项仍保留。 + +**更换对象**:父对象行提供入口,复用同一定位器;一次更新该组轨道目标,保留帧、值、插值、切线与未知字段,一次撤销整体恢复。候选需支持全部已有属性,且不产生重复目标/属性;不静默删轨道或合并数据。新增/替换取消旧手势,提交检查原目标身份和作者版本。 + +**验证**:普通 DTO 字段与 Timeline 共用组件/查询;同资源多实例、跨 Group、两类存在性、搜索/四语、未解析引用、生产者删除/重排/复制、候选更新/过期确认;真实顶部“+”选择对象/对象行“+”选择属性/取消/更换对象/重复防护、单历史及保存重开。按变更运行 Editor/Shared/Presentation/Architecture/Integration 回归和解决方案构建。 + +**风险与退出**:避免把资产文件当作场上实例,或仅换输入框外观仍要求作者理解 ID。至少一个普通 Entry 字段和 Timeline“+”/更换对象完成真实选择→提交→撤销→保存重开的同一服务闭环;共享层无 Editor 依赖,旧引用无静默丢失,并有验证证据。 + +后续顺序:P6e2b → P6e3 曲线/切线 → P6e4 事件/剩余语义 → P6f animate 曲线 → P7 验收。实时游戏预览不纳入。 + +## 实施与验证记录 + +本阶段扩展既有 ProjectSceneTargets 查询入口,而不是新建第二套分析:查询接受 Group/稳定 Entry 位置,null 查询 Group 末尾;缓存当前作者快照,项目 scope 释放时使旧选择过期。SceneTargetCatalog 适配显示候选、语义类型筛选、资源名称和定位器解析;SceneTargetSelection 是短期选择上下文;SceneTargetPicker 仍是控件,不提供弹窗调度服务。用户对“服务”的澄清已经纳入此边界。 + +原 Assets Picker 改用共享 ResourcePickerPanel,保留资产类型筛选、搜索、文件信息和选择结果。普通内置 DTO 目标字段通过成员注册选择控件,创建者输出从生成表单隐藏;未解析引用不因加载/刷新丢失。空目标与 effect.apply 的全局目标分别显示,语言切换按稳定语义 key 更新标签,不通过比较翻译文本决定行为。 + +Timeline 对象选择后不立即写作者数据,对象行“+”逐项添加属性。首个已知属性产生首帧和当前帧键;未知初值行保持草稿,输入后才提交。父对象更换目标仍一次处理所有已有轨道,拒绝重复属性并保留所有键及未知 JSON。原显示可用属性模式仍保留。 + +聚焦验证覆盖资产实际按钮打开、共享搜索/键盘确认/空值/Escape;位置查询、同资源多实例与两类存在性、保存不换代、修改/撤销/关闭失效;普通字段未解析提示、单历史、过期确认、语言切换;Timeline 实际“+”选择对象、对象行“+”属性列表、更换对象、数值控件、四语草稿、逐项撤销/重做、未知值草稿、取消、换帧失效、保存重开以及切线/未知 JSON 保留。初轮 5 项聚焦和六项目 905 项回归通过;用户澄清两步交互后的 3 项聚焦通过,最终门禁结果待下方记录。 + +过程修正:首次关闭项目回归暴露仅依赖 IsLoaded 不足以使旧查询失效,已通过服务自身 Dispose/代次解决,见 .agents lesson L-20261018-04。实际更换对象按钮回归暴露打开弹窗前重建树会移除锚点,现改为取消输入而保留节点挂载,真实入口重跑通过。早期测试直接引用内部 TimelineDocument 的编译错误已改为通过公开编辑会话验证,未扩大生产可见性。搜索文本变化的测试等待 UI TextChanged 处理后再发方向键,不把测试中的同步属性赋值当作完整输入事件。 + +## 最终结果 + +初轮六项目回归共 905 项通过(Editor 173、Shared 223、Core 111、Architecture 45、Integration 110、Presentation 243),解决方案构建与格式/源码指纹通过。用户随后明确拆为两步选择,本次最终改动限定在 Editor 对象/属性交互与相关 i18n;共享查询分析、资产面板与运行时没有新增语义变更。按修订范围重新执行解决方案构建、Editor/Architecture/Presentation 全量回归,共 461 项通过(173/45/243),0 失败;whitespace/IDE0005 verify 及全部变更源文件 SHA-256 一致。6 项需要原生 LibVLC 的显式用例未执行,不算通过。 + +最终构建 36 条分析器/既有警告、0 错误,四语各 691 keys。Editor 真实 Semi 主题测试覆盖两级“+”入口、列表选属性、初值已知/未知、单属性单历史、旧行/旧选择失效、整个对象替换和保存重开。截图 scene-target-select.png 与 scene-property-menu.png 已检查,选择器仅含对象搜索/卡片,对象行左侧按钮显示属性菜单,无原对象/属性合并表单。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。初轮脚本/结果为 validate-locator.ps1、locator-GalNet*;最终脚本 validate-locator-final.ps1,构建 locator-final-solution-build.log,TRX/日志 locator-final-GalNet*,指纹 locator-final-source-sha256.json,格式 locator-final-format-verify.log。两步交互聚焦结果 locator-two-step.trx。 + +正式行为已同步 [场景查询](../../docs/spec/editor-scene-targets.md)、[Group/Timeline](../../docs/spec/editor-groups.md)、[共享控件](../../docs/spec/control.md) 和 [i18n](../../docs/spec/i18n.md)。P6e2b 退出条件满足;P6e3 曲线/切线、P6e4 剩余语义/事件/引用、P6f animate 曲线和 P7 仍未完成;没有实现实时游戏预览。 diff --git a/features/F-20261015-02-editor-authoring-workspace/summary.md b/features/F-20261015-02-editor-authoring-workspace/summary.md new file mode 100644 index 00000000..6ae0fa76 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/summary.md @@ -0,0 +1,17 @@ +# 已实施切片摘要 + +本 feature 仍在实施;完整阶段状态见 [phase-plan.md](phase-plan.md),此前实施记录在各 Phase 文档中。本页从 P6e4c3b 起维护已收口切片,不表示整体 feature 已完成。 + +## P6e4c3b:效果 metadata 能力 + +效果源码注释在后台解析为不可变资源声明,场景查询与渲染器共用标量能力转换。Timeline 可从对象行添加效果参数轨道;资源更新时拒绝旧编辑,保留已写轨道及作者扩展 JSON。查询不执行剧情,也不编译 shader。 + +解决方案构建成功;1192 项相关回归通过,2 项既有 Group 布局断言失败,6 项原生媒体用例未执行。范围、退出条件与完整证据见 [阶段记录](phase-6e4.md)。 + +稳定行为以 [Editor 场景目标查询](../../docs/spec/editor-scene-targets.md) 为准。剩余内置语义及缺目标运行时独立核验继续留后续,实时预览不列入计划。 + +## P6e4c4:播放实例 Locator + +停止动画通过共享 Picker 选择 Animate 或 Timeline 的播放来源,支持本地化名称和英文类型搜索,不展示内部 handle。播放候选与可动画场景对象分域,Timeline 对象树保持原有范围;立即停止与迭代后停止采用不同的保守候选规则。选择域、过期保护、单步历史、复制引用及保存重开已验证。 + +解决方案构建成功,1239 项相关回归通过,2 项既有 Group 布局断言失败,6 项原生媒体环境用例未执行。完整证据见 [阶段记录](phase-6e4.md),当前契约见 [Editor 场景目标查询](../../docs/spec/editor-scene-targets.md)。P6e4c 整体仍在实施。 diff --git a/features/F-20261015-02-editor-authoring-workspace/timeline-simplification.md b/features/F-20261015-02-editor-authoring-workspace/timeline-simplification.md new file mode 100644 index 00000000..343a8ab3 --- /dev/null +++ b/features/F-20261015-02-editor-authoring-workspace/timeline-simplification.md @@ -0,0 +1,27 @@ +# Timeline 交互简化 + +日期:2026-10-18。状态:verified,用户明确授权实施。 + +这是 P6e2 的用户反馈修订,替代旧时间设置组合,不引入兼容路径。已有轨道持续显示绝对值/叠加偏移模式,切换只改变该轨道 BlendMode,不换算关键帧数值和切线;一次项目历史,旧行提交仍须通过版本检查。 + +顶部只保留当前帧、结束帧和 FPS。结束帧/FPS 输入 Enter 或失焦确认,Escape 恢复未提交草稿;各自提交,不要求另一输入有效。FPS 保持帧号与切线,改变播放速度;结束帧下限为最后一个关键帧/事件,不提供裁剪。删除尾部内容后可继续缩短。移除保秒重定时、裁剪、应用按钮和相关生产路径。 + +关键帧拖过结尾允许到 int 帧范围内,松手时原子移动并延长结束帧,Undo 一起恢复。负帧、0 帧首键、碰撞和过期手势的既有保护保留。标尺在结尾之外仍显示刻度,拖动预览不写内容;不引入自动播放/游戏预览或整体时间缩放工具。 + +验证覆盖真实模式下拉框、时间输入焦点/键盘、独立提交、帧保留、下限钳制、跨结尾拖动与撤销/取消/溢出、未知 JSON/同帧事件/保存重开,以及受影响回归、构建与格式门禁。 + +## 实施与验证过程 + +原重定时/裁剪实现和对应命令已删除;TimelineDocument 只修改实际变化的帧率/结束帧,确认省略的默认值不会补写字段。结束帧下限由轨道键及事件帧计算;拖动的整帧目的地通过非负/int 边界与首键/碰撞校验后,在同一 JSON patch 中移动并延长。 + +轨道模式以明确选择事件提交,无损更新原 JSON 的 blendMode;已有属性范围不接受绝对值时保持实际模式,界面显式恢复下拉框选项。首次 13 项聚焦和六项目 901 项回归通过后,审查补充了这个拒绝回显边界及真实控件断言,再次 13 项聚焦通过。该补充不复用之前的源码指纹作为最终快照证明,最终门禁单独记录。 + +真实控件覆盖:轨道模式键盘切换、四语选项、结束帧文本 Enter/取消、FPS 失焦提交、一次多轨越界拖动及 Undo、越界预览取消和帧溢出拒绝。服务路径另验证两输入独立拒绝/提交、结束帧钳制且不删事件、FPS 保留键/事件/切线、重复确认不加历史、旧行失效和省略默认字段无写入。Semi 截图已检查,顶部只显示结束帧/FPS,已有轨道右侧持续显示模式。 + +## 最终结果 + +最终解决方案构建成功(增量构建 0 警告、0 错误);最终同源码 Editor 169、Architecture 45、Presentation 243,共 457 项通过、0 失败。Presentation TRX 另记录 6 项 NotExecuted,缺少原生 LibVLC/视频环境;本次不修改媒体功能。whitespace/IDE0005 verify 和源码 SHA-256 一致性通过。四语各 678 keys;文档链接和 diff 检查通过。 + +完整六项目初次验证为 901 项通过、0 失败(86 警告、0 构建错误);随后仅补充 Editor 模式拒绝回显与控件断言,最终快照证据以上方 457 项为准,不把此前指纹当作最终源码。此修订退出条件满足,P6e 整体仍在实施,下一步 P6e3 曲线/切线。 + +证据目录:`C:/Users/Starry/.codex/visualizations/2026/10/15/01a13e35-d53c-7270-b753-7b67a4eee31c/timeline-validation`。最终脚本 `validate-timeline-simple-final.ps1`,结果 `timeline-simple-final-GalNet*.log/trx`,构建 `timeline-simple-final-solution-build.log`,格式 `timeline-simple-final-format-verify.log`,指纹 `timeline-simple-final-source-sha256.json`,截图 `timeline-simple-panel.png`。初次完整验证 `validate-timeline-simple.ps1` / `timeline-simple-GalNet*`,补充聚焦 `timeline-simple-mode.*`。 diff --git a/features/F-20261017-01-sample-shader-effect-showcase/design.md b/features/F-20261017-01-sample-shader-effect-showcase/design.md new file mode 100644 index 00000000..36b2f4c8 --- /dev/null +++ b/features/F-20261017-01-sample-shader-effect-showcase/design.md @@ -0,0 +1,11 @@ +# Design + +The showcase belongs to the existing `GameTestCase` harbor group, which is reached by the second route choice. Each example is a project-owned `effect-program` asset referenced by its stable metadata ID. + +Bloom, blur, and the adapted full-screen shaders declare `@input source` and `@targets scenePost`; the Avalonia effect runtime compiles and runs them through the current scene-post pipeline. The three Shadertoy programs keep their supplied `iTime` uniform and use the renderer-provided `size` uniform for resolution. A lightweight wrapper blends their generated image over the current scene. Their logical `time` metadata parameter is driven by a looping `animation.animate` entry. The built-in animation schema exposes this common shader time property because program-specific metadata cannot be enumerated at compile time. + +The group applies one visual demonstration at a time and pauses on a localized dialogue line before moving on. Blur is implemented with two internal scene-post passes (horizontal, then vertical) that together form one separable Gaussian effect. Before stopping an animated shader it also stops its animation playback. + +Before the Shadertoy portion, the group shows a full-canvas solid-color layer above the scene, hides the route background and portrait, and stops the snow emitter. The color layer masks any remaining one-shot particles while the scene-post shader uses it as its source. After the last shader stops, the route continues directly into the ending while keeping the solid stage; it does not restore the harbor scene. + +The renderer's scene surfaces use 8-bit `RGBA8888`, so they carry LDR values and cannot provide true HDR data for tone mapping. Bloom therefore uses a high-threshold soft-knee bright pass, a 40-pixel Gaussian spread, and remaining per-channel headroom to keep its output bounded. Blur uses a standard separable Gaussian kernel: five iteration bands generate 15 dense weighted samples on each side of the center in the horizontal pass, followed by the same kernel vertically. Its 28-pixel radius controls the outer sample distance. diff --git a/features/F-20261017-01-sample-shader-effect-showcase/feature.md b/features/F-20261017-01-sample-shader-effect-showcase/feature.md new file mode 100644 index 00000000..bbf35b12 --- /dev/null +++ b/features/F-20261017-01-sample-shader-effect-showcase/feature.md @@ -0,0 +1,35 @@ +# F-20261017-01 Sample scene shader effect showcase + +## Goal + +Extend the second route in `GameTestCase` with a walkthrough of scene-post shader effects and Shadertoy-style shaders using the existing effect-program pipeline. + +## Scope + +- Add sample scene bloom and blur shader programs. +- Keep the scene-post sequence single-effect and give the Shadertoy demos a clean solid-color stage. +- Use a wider Gaussian blur and restrained bloom suitable for the renderer's LDR scene surface. +- Adapt the three user-provided full-screen shaders into scene-post effect programs. +- Demonstrate each effect sequentially on the harbor route, stopping the current effect before starting the next. +- Localize the walkthrough in English and Simplified Chinese and document the sample path. + +## Non-goals + +- Change Runtime/Core effect contracts or add new renderer stages. +- Add a general Shadertoy import workflow or an effect selector UI. +- Show multiple scene effects at once. + +## Acceptance criteria + +- Selecting the second route reaches the shader walkthrough. +- Bloom, blur, and all three adapted shaders are invoked as scene-post effects. +- The prior effect is stopped before each next effect is applied; animated time is stopped with its shader. +- Effect descriptions are localized and the GameTestCase README explains the route. +- Shadertoy starts after hiding the route background and portrait and stopping the snow emitter; the route background returns after the last shader. +- Blur uses a normalized Gaussian kernel with a wider pixel radius; bloom uses a soft bright-pass and bounded LDR highlight roll-off. + +## Constraints and open questions + +- The existing Avalonia host resolves `effect-program` resources and supports scene-post shaders with `source` input. +- The supplied shaders are full-screen generators, so their output must be composited with the existing scene to use the current effect pipeline. +- No architecture-level changes are expected. diff --git a/features/F-20261017-01-sample-shader-effect-showcase/phase-plan.md b/features/F-20261017-01-sample-shader-effect-showcase/phase-plan.md new file mode 100644 index 00000000..33b7a16c --- /dev/null +++ b/features/F-20261017-01-sample-shader-effect-showcase/phase-plan.md @@ -0,0 +1,21 @@ +# Phase plan + +## P1 — Add the sequential sample showcase + +**Status:** verified + +Add bloom and blur programs, adapt the three supplied shaders to the existing scene-post contract, append their ordered apply/show/stop entries to the second route, localize the dialogue, and update the sample README. + +**Exit conditions:** all five effect programs have unique `effect-program` metadata IDs; the route stops each visual demonstration before beginning another (the paired Gaussian passes count as one Blur demonstration); each animated shader's time animation stops before its effect; English and Chinese keys cover the walkthrough; the sample content builds successfully. + +**Evidence:** the Headless CLI built `GameTestCase` successfully with four compiled groups and no diagnostics. The Headless CLI project and Sample.Contracts deployment both built with zero errors. Interactive shader compilation/rendering was not exercised. + +## P2 — Refine the scene stage and LDR shader examples + +**Status:** verified + +Give the Shadertoy examples a clean solid-color stage while hiding the route background and portrait and stopping the persistent snow emitter. Continue directly into the ending without restoring the route scene. Replace the compact blur kernel with five wider Gaussian sampling steps, keep bloom visible but bounded for the renderer's LDR surface, and reduce Shadertoy program size to fit Skia RuntimeEffect. + +**Exit conditions:** Shadertoy runs over a solid-color full-canvas layer with other route visuals hidden; the route flows into ending on that stage; bloom remains bounded and visibly targets bright highlights; blur uses five Gaussian-weighted sampling steps; all effect programs compile and content build succeeds. + +**Evidence:** all five sample shaders compiled through Skia `SKRuntimeEffect`; Bloom, Blur, Tunnel, Grid, and Rain were rendered in the Skia CPU preview. The harbor group and English/Chinese localization parsed successfully; only the two internal Blur passes overlap, and no other visual demonstrations overlap them. The Headless CLI built four groups without diagnostics. The final LDR Bloom preview keeps the halo around bright windows bounded and localized. diff --git a/features/F-20261017-01-sample-shader-effect-showcase/summary.md b/features/F-20261017-01-sample-shader-effect-showcase/summary.md new file mode 100644 index 00000000..13874fdb --- /dev/null +++ b/features/F-20261017-01-sample-shader-effect-showcase/summary.md @@ -0,0 +1,39 @@ +# Summary + +## Implemented + +- Added scene-post bloom and blur shader programs. +- Refined bloom with a high-threshold soft-knee bright pass, 40-pixel Gaussian spread, and bounded LDR highlight shoulder. The renderer uses 8-bit `RGBA8888` scene surfaces, so this is not true HDR tone mapping. +- Replaced the sparse radial blur with two ordered separable Gaussian passes. Each axis uses five iteration bands and 15 dense weighted samples per side; the sample radius remains 28 pixels. +- Adapted the three supplied Shadertoy programs as scene-post effects. They sample the existing scene, blend their generated output, use the renderer's `size` uniform, and expose `time` for animation. +- Added the showcase after the second route choice in `GameTestCase`. It applies bloom, the two-pass blur, then the tunnel, grid, and rainy-road shaders sequentially. Each visual demonstration stops before the next begins; animated samples stop their time animation first. +- Added a full-canvas solid-color layer for the Shadertoy segment, hid the route background and portrait, stopped the snow emitter, and kept the stage through the ending instead of restoring the route. +- Made bloom visible around the bright classroom windows without lifting the already-bright walls, and increased all three Shadertoy mix amounts to show the generated image directly. Adapted the Tunnel shader to stay within Skia’s program limits. +- Added English and Simplified Chinese dialogue and updated the sample README. +- Exposed the common `time` property in built-in animation authoring so the shader time uniform can be driven by `animation.animate`. + +## Validation + +- `dotnet build src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj --no-restore -m:1 -v:minimal -p:UseSharedCompilation=false`: succeeded, zero errors; existing analyzer warnings remain. +- `scripts/build-project-contracts.ps1` for `GameTestCase`: succeeded, zero errors. NuGet vulnerability data lookup emitted `NU1900` because the package source was unavailable in the sandbox. +- Headless CLI `project.build` for `GameTestCase`: succeeded, four compiled groups, no diagnostics. The temporary generated output was removed afterward. +- No tests were added or run. Shader compilation and rendering are validated through Skia RuntimeEffect CPU previews; the full Avalonia host was not launched. + +## Plan deviations and known limits + +- The built-in animation property schema is static while shader parameters are program-specific. This sample exposes the common logical parameter name `time`; each shader must still mark its mapped uniform animatable in `@gal.effect` metadata. +- The CLI build validates the authoring flow and resource references but does not compile or render the SkSL programs on a GPU. Skia CPU preview validates shader output, but the full Avalonia host was not launched. + +## Follow-up validation + +- `dotnet run --no-build --project src/Editor/GalNet.Editor.Headless/GalNet.Editor.Headless.csproj -- build GameTestCase --output artifacts/codex-shader-build`: succeeded, four compiled groups, no diagnostics; the temporary output was removed. +- Parsed the edited harbor group and both localization files as JSON; `git diff --check` reported no whitespace errors. +- Checked the authored sequence: the solid stage precedes the first Shadertoy and the active effect count never exceeds one. +- A regular `dotnet run` first attempted to rebuild the CLI and returned `生成失败` without compiler diagnostics. The successful content build reused the existing CLI binary. No tests were added or run. +- After the visual tuning, the no-build Headless CLI built four groups to a temporary output with no diagnostics; all five sample shaders compiled through Skia RuntimeEffect and Bloom, Blur, Tunnel, Grid, and Rain rendered to CPU previews. Only Blur's horizontal and vertical passes overlap; they form one visual demonstration. All passes stop before the next demonstration, the route has no scene-restore entries, and it proceeds through ending on the solid-color stage. + + +## Tunnel refinement + +- Restored visual detail from the supplied source: a wandering centerline, layered corrugated shell, raised ribs, mottled three-octave material, rust and teal patina, atmospheric depth fog, and teal vapor. Kept the procedural texture sampling out of the ray-march loop and bounded the march to 56 steps. +- Compiled the updated program through Skia RuntimeEffect and rendered a 640 × 360 CPU preview. Corrected the angular periodicity of the ribs after the preview exposed a horizontal seam. diff --git a/features/F-20261019-01-editor-game-localization/design.md b/features/F-20261019-01-editor-game-localization/design.md new file mode 100644 index 00000000..c1f37e06 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/design.md @@ -0,0 +1,150 @@ +# 默认开发语言创作与游戏本地化设计 + +状态:design,2026-10-19。本文描述目标方案,不代表当前代码已经实现。产品约束见 [feature.md](feature.md);以下 API 名称和交换格式是设计选择。 + +## 1. 边界和现状 + +项目 ProjectSettings.TargetLocale 作为默认开发语言,AvailableLocales 保存支持语言及顺序。Editor 的 UiLocale 只控制工具 UI,不改变作者文本的编辑语言。 + +以下为设计开工时的事实;已实现阶段的当前行为见 [Editor 游戏本地化基础](../../docs/spec/editor-game-localization.md): + +- Core CharacterDefinition 为 Id/DisplayNameKey;CharacterCatalog 按 Ordinal 验证唯一 Id。Runtime 对话把 speaker 当角色 ID,正文当文本 key。 +- GameLocalizationLoader 读取 I18n/catalog/characters.json 和 I18n//**/*.json,支持平铺/嵌套字符串对象;同语言文件按规范化路径排序,后文件覆盖前文件。 +- 当前默认语言完整性验证只识别内置对白、NVL、分支及角色姓名;自定义参数不会自动推断。 +- FieldAttribute 与 FieldDescriptor 尚无成员值本地化语义。简单和带语音的对白 DTO 均需标记;不能只改最终 primitive。 +- GroupEntryAuthoringService.Insert 分配 GUID StableId,Paste 重建身份,OrderEdit 按 StableId 重排;EditorDocumentRepository 将 StableId 写入作者 Entry.id,重新加载恢复。整数 Id 是位置。 +- ProjectContentSession 已拥有 Working/Saved、Files、draft 注册、捕获保护、保存和恢复。ProjectTextFiles 的现有刷新会以新文档字节覆盖 Text;接入结构化编辑前必须补齐未提交草稿冲突处理。 +- 普通文件策略保护 I18n/catalog。ProjectValidationService 目前声明跳过 localization-semantics。 + +本功能提供作者端投影及交换,不改变 raw 中正文已经是 key、编译原样传递、宿主负责解析的契约。Runtime 不引入 Avalonia、Editor 或 DynamicLocalization 类型。 + +## 2. 统一字段语义 + +建议引入互斥的 LocalizedText 与 CharacterReference 成员特性,并将语义写入 FieldDescriptor(例如 FieldValueSemantic.None/LocalizedText/CharacterReference)。独立于 EditorId:单行/多行仍由 UI hints 决定,引用检查不看控件名字。 + +generator 必须传递源基类与嵌套 DTO 的语义,生成时诊断不适用类型、冲突标记;仅字符串或可空字符串可直接标记。手工 schema 有相同的显式描述入口。缺少标记保持原字符串行为,不按字段名 content/speaker 猜测。 + +Shared 用统一 walker 遍历 descriptor 与作者 JSON:支持嵌套 DTO/列表,保留未知字段、缺省与 null;循环结构和规模有有限保护,不能分析的范围返回“不完整”,不能据此允许删除引用对象。分支选项虽非普通 Entry,也通过同一语义引用模型显式接入。 + +标记负责编辑、索引和校验,不改变字符串 ValueCodec 的全局语义。普通表单在作者提交边界将可读文本转为 key;不能把整个 string 编辑器替换为翻译编辑器。 + +自定义运行指令仍通过 ITextResolver 消费文本 key;不对全部 DTO 字符串进行隐式运行时重写,以免破坏 key、存档和自定义实现契约。 + +## 3. 唯一作者来源与数据流 + +建议 Shared 提供项目 scope 的 LocalizationDocumentStore、LocalizationReferenceIndex、LocalizationEditService 和 LocalizationExchangeService(名称可随实现调整)。UI 服务适配现有 EditorHistories,Shared 不反向依赖 GUI 历史实现。 + +数据流: + +```text +作者 JSON / catalog / 翻译 JSON / 项目设置 + ↓ 同一项目文档,按代次建立派生索引 +字段语义 walker → 引用位置、角色使用数、文本来源和语言状态 + ↓ +Group/Graph 默认语言表单 | 角色 Dock | i18n Dock | 导入预览 + ↓ 显式编辑意图,检查版本和期望值 +项目单一内容历史 → Working → Save All 文件事务 → Saved +``` + +翻译 JSON 的受管理 EditorFileDocument.Data 是保存来源,解析树和表格只是派生视图,不再保留第二份可独立写盘的翻译字典。catalog 通过专属入口纳入同一文档体系,不开放普通文件删除绕过引用保护。 + +新建翻译/catalog 文件走项目文件结构事务,复用双版本结构计划;不能用 File.WriteAllText 绕过历史或顺带保存其他未提交内容。持久作者辅助信息放 Graph/localization-authoring.json(建议路径,须作为受管理作者文件接入),包含版本、角色编号高水位和必要的翻译复核信息;不放入可丢弃的 .galnet 缓存。Runtime 不依赖它。 + +JSON 修改只改目标叶值,保留其他属性、未知信息和文件归属;不承诺逐字保留空白格式。同文件重复属性和扁平/嵌套路径碰撞必须诊断。多文件覆盖合法,但显示来源;改已有译文默认修改当前有效的包内来源,不改被遮蔽旧值。新建内容写入确定分片,例如 characters.json、groups/.json、branches.json,不重分配已有来源。 + +普通文本页有未提交草稿时,对同文件的结构化修改先检查并提交有效草稿;无效或冲突草稿保留并拒绝相关修改。结构化编辑之后,干净文本缓冲自动刷新;带草稿缓冲不静默覆盖。过期面板行、异步导入和已关闭项目不得提交。 + +## 4. 稳定文本身份 + +普通标量文本建议首次生成 entry__。属性名转义/哈希必须确定且防碰撞,生成后检查全项目占用。key 为不透明身份,分类和归属按引用计算,不解析 key 前缀。 + +P4 实施细化:采用 text__ 的不透明文本 key;owner 可为稳定 Entry/option ID 或 copy/item,任何重排都不解析或重建 key。表单提交中只短暂使用编辑意图,作者文件与历史保存最终 key。Entry/Graph 剪贴板提升为 version 2,携带包内各语言有效文本和复核快照,直接拒绝旧版。 + +- 更新原文、Entry 重排、保存重开:保留已存 key。 +- Entry 跨组移动:保留 key,分类随引用改变,不按新 Group 重算。 +- 复制 Entry/Group:新 Entry 身份,复制批次建立旧 key→新 key 映射并复制所有现有译文;角色引用仍共享。相互独立的文本副本不按相同原文合并。 +- 已有内容显式共享 key:保留共享关系,修改提示影响位置;必要时提供创建独立文本,不静默拆分已有数据。 +- 删除/替换字段:不自动删除翻译;未引用 key 只进入诊断/显式清理,不混入正常 Group 行。 +- 分支选项使用已有稳定 option.id,同样不使用选项序号。 + +列表内文本不能以可变数组下标确定永久身份。采用首次创建字段时分配的独立 token(可组合 Entry StableId 与随机 token);列表移动搬运含 key 的原 JSON,增项分配新 token,复制项生成新 key。引用位置中的下标只用于导航。属性路径不是稳定时,宁可使用不透明 token,也不能移动后重算。 + +缺省/null 仍表示原有值状态,不因打开面板就建文本。显式空字符串是有效翻译值,与没有 key 不同;必填规则在原字段/可运行内容校验中处理。已有缺译 key 显示“缺少开发语言文本”,不能把 key 当原文再保存一次。 + +## 5. 角色表和 speaker + +角色页正常数据列保持“开发语言角色名 | key”,使用状态可作为行状态/删除按钮提示。key 对应 CharacterDefinition.Id,隐藏的 DisplayNameKey 与文本表连接。第一版沿用运行格式,不将角色名直接塞入 catalog。 + +自动角色 key 建议 character_1、character_2 等。高水位项目持久化,创建分配后不因删除、撤销而倒退;重做恢复同一角色身份,分配器同时检查现存和历史保留身份冲突。分配失败不复用已保留编号,空洞允许。手动 key 按 Ordinal 唯一,保留前后非空输入校验;匹配不改变存储大小写。 + +角色 key 改名同步所有明确 CharacterReference 槽位;DisplayNameKey 可保持不变,避免不必要重命名所有翻译。角色名修改只修改开发语言姓名译文。二者均支持一次历史操作。 + +搜索:候选按姓名与 key 子串匹配;候选显示姓名和 key。明确选择保留角色身份。失焦提交时,优先精确 key,其次精确姓名:唯一匹配则绑定;同名多个则保留草稿并要求选择;没有精确匹配则以输入文本为姓名创建新角色。搜索不是模糊自动绑定。空白清除 speaker,不建角色;同名角色可在角色表主动新增。 + +控件需处理 IME composition、候选点击引起的焦点顺序和提交重入:选候选不能先触发创建,失焦创建不能执行两次。创建角色、开发语言姓名和 Entry speaker 引用合为一次历史。 + +“只增更新”按语义引用监听和全项目扫描维护,不按展示名字回推角色。GUI 新输入正常创建;扫描已经保存的未知 speaker key 时,不能将 key 猜成姓名,给出缺角色诊断及显式补建入口。已有有效角色不随引用归零删除。扫描未完成/某些 Group 无法分析时禁止据此执行未使用删除。 + +2026-10-20 用户修订:删除 ProjectSettings.Speakers 与迁移候选/导入 UI;角色只来源于 catalog。扫描到未知角色 ID 只报告错误,不自动推断姓名或建立兼容映射。 + +## 6. 普通创作与 i18n Dock + +所有被标记字段默认读取 TargetLocale 的直接译文;不能用其他语言回退值冒充可编辑开发原文。非 i18n 页面不提供独立编辑语言开关。角色搜索、Graph 选项、Group 列表摘要同步显示开发语言。技术诊断和 JSON 修复页可以展示 key。 + +角色 Dock 与 i18n Dock 都是项目级单实例,关闭视图不丢数据。i18n 左侧为虚拟化滚动分类:角色表、全部 galgroup、分支选项。Group 显示名/路径重命名不影响分类身份。右侧为虚拟化表格:开发语言第一列,其他语言按项目设置顺序,其后加语言按钮;可横向滚动和调列序,普通行不显示 key。行按 Entry/字段顺序提供上下文或导航,不把行号当身份。 + +同一 Group 内共享 key 显示一个可编辑文本身份并附引用位置,避免两格草稿互相覆盖;角色表和其他 Group 引用同一 key 时也共享同一文本状态。 + +新增语言验证并规范化 culture,拒绝重复,更新 AvailableLocales 并显示空单元格,不自动复制开发原文。开发语言固定第一,其他语言调序是项目设置编辑。切换开发语言是独立显式操作,先报告目标语言缺失情况,不能通过拖列隐式改变。切 Editor UI 语言不改变文本、dirty 或历史。 + +P5 实施细化(2026-10-21):使用现有 ListBox 虚拟化分类和行,表格整体横向滚动;语言调序用左右按钮。开发语言变更采用缺译预览及独立应用按钮,预览后引用文本变化时拒绝应用;项目设置的开发语言字段只读。I18nLocale 通过 CultureInfo 规范化并验证名称,保留脚本子标签的标准大小写,避免作者设置与目录名不一致。项目级面板 ViewModel 在视图关闭后继续持有单元格草稿,Save All 提交所有已访问分类的草稿,冲突须保留处理。 + +用户已确认原文改动保留译文;建议额外标记“待复核”,用每条译文关联的已审阅开发原文指纹判定。已有项目没有历史基线时视为复核状态未知,不声称已复核。该状态是编辑器 metadata,不进入 Runtime 文本值。缺失、显式空串、待复核分开呈现;运行时回退可查看,但不计作已译。 + +外部翻译 I18n/External 不作为默认写入目标;包内作者数据与第三方覆盖保持现有边界。第一版不增加第三方包管理界面。 + +## 7. 模板导出与导入 + +建议第一版提供 UTF-8 JSON 交换模板,避免新引入电子表格依赖;支持当前分类/全项目、单目标语言模板、单语言运行文本导出。CSV/XLSX 可后续加,不作为首版隐含承诺。 + +交换模板必须有 version、项目身份、sourceLocale、targetLocale 和 rows。项目身份取现有可持久身份;若没有则在作者 metadata 分配,不能使用项目显示名。每行包含稳定 key、sourceText、sourceFingerprint、context、translation(null=未提供,字符串含空串=明确值)。context 包含 Group/Entry/字段位置,仅供阅读,不用于匹配。导出面板无需显示 key,文件中保留机器身份。 + +导出捕获当前一致作者版本,先提交相关有效草稿,不要求先写盘。以捕获数据生成文件,输出到项目外不改变作者内容。输出路径不得覆盖项目受管理源文件,导出失败不修改会话。 + +导入先只读解析并显示预览:匹配、将修改、未知 key、重复 key、错项目/语言/版本、原文变化、并发变化和删除目标。按 key 匹配,不按原文/行号;不根据未知模板 key 自动建 Entry/角色。角色改 key 只改角色引用身份,姓名文本 key 保持稳定,因此旧姓名翻译模板仍可匹配。 + +null 不覆盖;空串默认预览为“清空”,需显式启用清空选项再应用。原文变化仍可接受译文,但标为待复核,不覆盖开发原文。应用时校验预览代次及每个目标期望值,变动则重新生成预览,不盲覆盖;用户选定的可导入部分以一次项目历史提交,失败补偿,不产生部分历史。原文和上下文列为参考,不通过目标语言导入偷偷改开发原文。 + +建议支持单语言平铺 key→string JSON 导入,供现有翻译文件交换使用;没有原文快照时复核状态未知,同样先预览,不绕过身份检查。单语言运行文本导出与带上下文模板明确区分用途。 + +P6 实施细化(2026-10-21):当前模板为 version 1,增加 complete 明确分析范围;项目目录名不够稳定,首次模板导出在作者 metadata 持久预留 exchangeProjectId。预留不撤销,输出失败后可能保留这一身份,文本值与其他 dirty 数据不因此保存。导出限制在项目外;导入仅写非开发语言的受支持语言,平铺 null 同样跳过。批量预览/写入按文件共用快照和解析,实际历史执行重查证据。无法分析范围给 warning/Complete=false,确定错误阻止构建;不把未知范围当成已验证或误判为缺译。 + +## 8. 保存和校验 + +角色创建/改 key/删除、文本创建和更新、复制重映射、语言增序和导入全部通过项目单栈。历史持有稳定目标及 before/after 数据,不持有面板。草稿保留输入基线,选择/语言或源文档变化不静默覆盖输入;提交冲突时提示并保留。 + +保存把 catalog、翻译 JSON、作者辅助信息与 Group/Graph/settings 纳入同一捕获和作者文件事务。损坏翻译 JSON 可保留原文本修复,结构化编辑禁用该文件;不能通过保存全量格式化将损坏内容抹掉。保留现有外部文件冲突检测与 RepairRequired 机制。 + +新增 localization-semantics 检查:明确角色引用、已标记文本 key、默认语言存在性、重复/冲突来源、culture。作者保存允许缺译/未知角色等可修复语义草稿,显示诊断;JSON shape、身份重复和事务安全仍严格。构建/发布前的 Editor 完整检查阻止确定的内容错误;未取得自定义 schema 时报告检查范围不完整。Runtime 继续保留已有内置加载检查,不能为了泛化作者检查而执行自定义模块。 + +只接受当前 CharacterDefinition、文本 key 与作者 metadata 格式,不提供旧项目迁移。编号分配仍核对当前 catalog 中的手动 key,防止自动编号与手动角色身份冲突;这属于当前格式的唯一性规则。 + +## 9. 候选方案与取舍 + +P3 实施细化(2026-10-20):角色首次写入前以结构事务初始化空 catalog 与开发语言 characters.json;该空基线和角色编号预留均不随角色值编辑撤销。角色记录、姓名译文和 Entry 引用随后作为一次 Working 编辑进入历史。原因是保存后撤销角色创建时,不能删除仍被 Saved Group 引用的文件;保存基线继续有效,下一次 Save All 才把跨文件值变化整体落盘。用户撤销仍完整还原角色及引用,允许留有空文件和编号空洞。 + +| 方案 | 取舍 | +| --- | --- | +| raw 保存原文,编译时替换 key | 不采用:破坏现有作者/运行契约,编译重排难维持身份 | +| 按行号或原文生成并持续重算 key | 不采用:换位丢译文、相同原文被误合并 | +| 新建独立可写翻译数据库,再同步 JSON | 不采用:与现有文本页/文件保存出现双写 | +| 在 schema 声明语义,基于稳定 key 投影 | 采用:工具共享事实,运行格式保持稳定 | +| 角色按引用自动清除 | 不采用:用户要求只增与显式删除 | + +## 10. 风险及验收影响 + +最高风险是草稿互相覆盖、列表移动误换 key、复制共享文本未隔离、同名角色失焦误绑定和异步导入过期。阶段验证必须覆盖实际控件挂载与保存重开,不能只验证 ViewModel 属性。 + +多文件覆盖、坏文件和未知 schema 使“没有发现引用”不等于“确定未使用”;删除保护必须有索引完整性状态。大项目只按文档版本缓存并增量失效,单格输入不重复全项目扫描;索引在项目释放后清空并拒绝旧行。 + +尚未验证:API 名字、metadata 精确 JSON schema、虚拟化控件选择和性能阈值。由实现阶段依据现有控件能力落实,不写入当前 spec。验收与退出条件见 [phase-plan.md](phase-plan.md)。 diff --git a/features/F-20261019-01-editor-game-localization/feature.md b/features/F-20261019-01-editor-game-localization/feature.md new file mode 100644 index 00000000..423a4b63 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/feature.md @@ -0,0 +1,82 @@ +--- +id: F-20261019-01-editor-game-localization +title: 默认开发语言创作、角色表与游戏 i18n 面板 +type: feature +status: done +created: 2026-10-19 +updated: 2026-10-21 +--- + +# 默认开发语言创作、角色表与游戏 i18n 面板 + +## 用户目标与已确认需求 + +普通游戏创作界面使用项目默认开发语言,内部保存稳定 key;只有 i18n 面板提供多语言对照。Editor 自身 UI 语言仍独立。 + +- 项目已有默认语言,作为开发语言使用,不另外维护一份开发语言设置。 +- 角色表根据全部 galgroup 使用的 speaker 持续补充,只增、不因引用消失自动删除。人物表页面可删除未使用角色。 +- 人物表是开发语言角色名和唯一 key 的二元组;允许同名,不允许 key 重复。 +- 自动角色 key 使用持久化的只增编号,可以手动修改。 +- speaker 支持直接输入和搜索选择,候选同时匹配开发语言角色名与 key;失焦时不是已有角色则创建。 +- DTO 需要有明确标记,声明哪些成员值是本地化文本;标记成员在 galgroup 内保存 key,在普通表单里显示、填写开发语言文本。 +- i18n 面板左侧为可滚动分类列表,包含角色表、全部 galgroup 和分支选项。 +- 右侧是语言列组成的表格,不显示底层文本 key;开发语言固定最左,支持加语言与其他列调序。 +- 支持单语言模板等导出,以及外部填写后的导入。 +- 修改开发语言原文保留其他语言译文。 +- Entry 换位不能导致译文丢失。key 具体格式为实现细节,优先利用已有 StableId,生成后保持不变。 + +## 开工时的问题与基础 + +- Runtime 已支持 CharacterCatalog、speaker 角色 ID、姓名 DisplayNameKey、对白/NVL/分支文本 key、默认语言验证和语言回退。 +- Editor 的 FieldAttribute/FieldDescriptor 没有声明成员值本地化语义的元数据;现有 LabelKey/DescriptionKey 是字段 UI 文案。 +- Entry 创建时已有 GUID StableId,作者文件保存到 id;移动保留、复制生成新 ID,整数 Id 只是位置。 +- Group 表单仍从 Settings.Speakers 补全,Editor 项目校验目前跳过 localization-semantics。 +- 项目已有单一内容历史、文件文档、保存事务和文本缓冲;I18n/catalog 受普通文件策略保护。 + +## 范围 + +2026-10-20 用户修订:不考虑向后兼容,删除本功能的全部旧数据兼容和迁移入口。移除 ProjectSettings.Speakers 及其候选/回调/迁移 UI;未知角色 ID 作为当前格式错误,不自动补建或解释成姓名。后续实现只针对当前作者格式。 + +字段语义及 generator、项目本地化作者文档与引用索引、角色面板和 speaker 输入、默认语言文本表单、i18n Dock、语言管理、导入导出、保存历史协调、必要的校验和回归测试。 + +## 非目标 + +自动机器翻译、角色立绘/音色配置、本地化二进制资源、运行中剧情热重译、多人实时协作、游戏预览、第三方翻译安装/覆盖协议重做、重写编辑器 UI 本地化服务。 + +## 验收标准 + +1. 创建/选择同名角色有明确身份,不重复创建;空输入不创建角色。全部未打开 Group 的引用也纳入使用统计。 +2. 自动角色编号保存重开后继续增长;删除不回收。改角色 key 同步全部明确引用且可一次撤销;被引用角色禁止删除。 +3. 被标记字段在普通创作界面编辑开发语言文本,作者文件保留 key;自定义未标记字符串不被误判。 +4. 修改文本、Entry 重排、保存重开和撤销重做保留原 key 及译文;复制文本引用得到独立 key,已有翻译随副本复制。 +5. i18n 左侧包含角色/全部 Group/分支;右侧开发语言固定第一列,支持新增语言、其他列排序,正常表格不显示文本 key。 +6. 修改默认语言文本保留译文;模板导出再导入按稳定身份匹配,不能依赖行号或原文。 +7. 面板、普通文本页和 Group 共用作者文档,不互相覆盖未提交草稿;跨文件操作形成完整历史,纳入 Save All、关闭与恢复。 +8. 格式损坏、重复 key、未知角色和默认语言缺译有可定位诊断;作者保存与可运行内容检查区分。 +9. Core/Runtime 保持平台独立,现有回退与外部覆盖行为不回归。 + +## 假设与开放项 + +没有必须先询问用户的产品阻塞项。特性/API 命名、文件分片、交换格式等属于设计选择,须在设计中明确标为建议,不当成用户指定。 + +用户已于 2026-10-19 在设计完成后明确要求继续实施。P1 字段语义和只读索引已 verified,解决方案构建及 411 项相关回归通过,见 [阶段记录](phase-1.md)。 + +2026-10-20 P2 已 verified:受管理本地化写入、单次/跨文件历史、JSON 草稿冲突协调及持久编号预留已接通;新增 21 项通过、完整相关回归 495 通过及 3 个此前已记录的 UI 失败,解决方案构建成功。详见 [P2](phase-2.md)。 + +2026-10-20 P3 已 verified:角色 Dock、开发语言姓名/key 编辑、speaker 搜索与失焦创建、全部明确引用改 key、一次撤销及旧值显式导入完成;新增 14 项通过,相关回归 759 通过及 3 个既有 UI 失败,解决方案构建成功。详见 [P3](phase-3.md)。旧值导入后按用户要求在 [P3a](phase-3a.md) 移除,当前不提供向后兼容。 + +2026-10-20 P4 已 verified:普通正文/分支按开发语言编辑,稳定 key 不随重排变化;Entry/Graph/列表复制生成独立文本身份并携带各语言译文,源文修改保留译文及复核状态。新增 16 项通过,解决方案构建 0 错误;相关完整回归 812 通过、3 失败,其中 2 项既有布局断言,1 项 Headless 释放异常独立复验通过。见 [P4](phase-4.md)。 + +2026-10-21 P5 已 verified:游戏本地化 Dock 的角色/全部 Group/分支虚拟化多语言表格、语言新增/调序、开发语言缺译预览与显式切换、草稿保留/保存及队列提交保护完成。新增 17 项通过,完整解决方案构建成功;相关全量回归 1039 通过、2 项既有布局失败。详见 [P5](phase-5.md) 与 [使用说明](../../docs/manual/game-localization.md)。 + +2026-10-21 P6 已 verified:当前 JSON 模板/运行文本导出、导入预览/选择/整批撤销重做、稳定项目身份及发布前本地化检查完成;新增 20 项通过。正常完整构建 0 错误,相关回归 1180 通过、2 项既有布局失败。范围不完整会明确报告,原生 IME/文件对话框人工验证及大项目性能阈值未声明完成。详见 [P6](phase-6.md)、[审核](review.md) 和 [实现总结](summary.md)。 + +## 关联 + +- [设计](design.md) +- [阶段计划](phase-plan.md) +- [运行时国际化](../../docs/spec/i18n.md) +- [项目历史与保存](../../docs/spec/editor-history-and-save.md) +- [变量面板](../../docs/spec/editor-variables.md) +- [创作工作区](../F-20261015-02-editor-authoring-workspace/feature.md):复用基础,不扩写其未完成阶段。 +- [运行时 i18n](../F-20261010-02-runtime-i18n/summary.md):已有运行支持,本 feature 补作者端。 diff --git a/features/F-20261019-01-editor-game-localization/phase-1.md b/features/F-20261019-01-editor-game-localization/phase-1.md new file mode 100644 index 00000000..661f2b48 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-1.md @@ -0,0 +1,54 @@ +# P1:字段语义与可诊断项目索引 + +状态:verified,2026-10-19。feature 整体仍 in-progress,P2–P6 未实施。 + +## 已实现 + +- Core 提供 LocalizedText/CharacterReference 特性及 FieldDescriptor.Semantic,类型/互斥规则由 generator 和手工 descriptor 构造器分别检查;标签/控件 hints 不再被当作值语义。 +- generator 支持继承成员的标记,错误类型、没有 Field、重复语义报告带源码位置的 GNF001。简单和带语音的对白/NVL DTO 均标记,动态参数表保留原 descriptor;CLI schema 描述增加 Semantic。 +- Shared 提供纯 LocalizationReferenceIndex:全部作者 Group、角色姓名和 Choice 分支引用;嵌套 DTO/列表 walker;保留稳定身份和 JSON Pointer,不生成 key、不改值、不执行剧情。 +- 所有翻译定义按 Ordinal 文件顺序保留来源,FindDirect 用索引返回最后生效的直接译文,区分缺失与空串,不使用回退文本冒充已译;External 不进入作者数据。 +- JSON 重复属性、扁平/嵌套 key 碰撞、坏文件/catalog、未知字段/schema、未知角色、缺默认语言译文均有诊断。分析深度 16、值预算 100,000,Complete 明确表示引用分析覆盖度,不表示项目可运行。 +- ProjectLocalizationQuery 注册为项目 scope,读取 Working 及未受管理的磁盘来源,受管理内存修改/删除优先;缓存分析代次,卸载和释放后失效。同路径删除后重建按有效文档身份选择,不因历史保留记录崩溃。 + +## 验证 + +最终解决方案构建: + +```powershell +$env:AVALONIA_TELEMETRY_OPTOUT='1' +dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false --verbosity quiet +``` + +结果:成功,0 errors,77 warnings。未对整个工作树建立警告增量基线,不宣称无新增告警。日志 `Temp/localization-p1-solution-final.log`。 + +对最终构建使用 `dotnet test --no-build --no-restore`: + +| 测试项目 | 通过 | 失败/跳过 | +| --- | ---: | ---: | +| GalNet.SchemaGenerator.Tests | 18 | 0/0 | +| GalNet.Editor.Shared.Tests | 237 | 0/0 | +| GalNet.Core.Tests | 111 | 0/0 | +| GalNet.Architecture.Tests | 45 | 0/0 | +| 合计 | 411 | 0/0 | + +本次增加 generator 4 项、索引/会话 14 项;覆盖原文相同但 key 独立、未打开 Group、分支身份、来源顺序/空串/External 排除、坏文件不遮蔽正常文件、重复/collision、未知 schema/缺失 Group、null/缺省/未知参数、嵌套指针转义、递归预算、未保存文件/删除重建、卸载/Dispose 缓存失效。TRX 位于 `Temp/localization-p1-results/`,以四个项目名称的结果文件为最终回归证据,早期 focused/旧快照结果不混计。 + +对本任务 11 个 C# 文件的 `dotnet format whitespace ... --no-restore --verify-no-changes --include ...` 通过,`git diff --check` 通过。没有批量格式化其他工作树文件。 + +## 调查与修正 + +1. 受限环境内测试宿主连接超时;退出受限环境运行相同已构建测试后通过。受限环境解决方案构建也遇到浏览器 MarshalingPInvokeScanner 任务宿主连接错误,外部复核构建通过;没有修改产品代码绕过宿主限制。 +2. 测试初版使用 DI BuildServiceProvider,但 Shared 测试只依赖 DI 抽象;改为夹具 EmptyScope,没有引入生产依赖。 +3. 复核 TrackFile 发现删除记录和新有效文件可以拥有同一路径;查询按有效身份分组处理,并补充回归。见 agent lesson L-20261019-04-file-document-path-reuse。 + +## 当前限制与下一阶段 + +- 本阶段只有元数据、只读索引和 scope 查询,没有角色 Dock、自动创建角色、自动建文本 key、默认语言表单映射、翻译表格或导入导出。 +- 普通 JSON 页与结构化写入的草稿冲突还未接通,查询不提交草稿;P2 实现文档写入、文件创建、历史和保存协调。 +- 按需查询仍捕获整份作者文档和读取来源后判断变化,未做文件级增量性能验证,不应逐字符调用。 +- 不完整分析只做诊断;尚无角色删除功能,不宣称已实施删除保护 UI。 +- 未进行桌面 IME 人工验收;本阶段不新增输入控件。 +- 原有未提交/并行工作区修改保留,本记录只描述本 feature 改动;没有执行 Git 提交。 + +当前事实同步到 [规范](../../docs/spec/editor-game-localization.md)。 diff --git a/features/F-20261019-01-editor-game-localization/phase-2.md b/features/F-20261019-01-editor-game-localization/phase-2.md new file mode 100644 index 00000000..d4936247 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-2.md @@ -0,0 +1,68 @@ +# P2:受管理本地化文档与单次文本编辑 + +状态:verified(含下述既有工作树回归限制),2026-10-20。P3–P6 尚未实施。 + +## 实现 + +- Shared 的 LocalizationDocumentStore 以受管理 EditorFileDocument 为唯一作者来源,捕获文件/文本目标与编辑前值,不另建可独立保存的翻译数据库。 +- LocalizationJson 验证翻译、catalog 和 version=1 的作者 metadata;按既有嵌套路径改文本,保留无关叶值、未知 catalog/metadata 成员与原编码/BOM,拒绝重复属性及扁平/嵌套 key 冲突。 +- 专属 TrackLocalizationFile 可接管已有只读文件记录并同步保存基线;路径仅允许包内翻译、固定 catalog 和 Graph/localization-authoring.json,拒绝 External、其他 catalog 路径及任意受保护文件。 +- LocalizationEditOperation 是不依赖 GUI 的可逆数据操作。已有文件的值只改 Working;新文件通过双版本结构事务发布空 JSON 保存基线、同时保留 Working 的实际作者内容。跨文件补丁先全部校验,失败不移动历史。 +- LocalizationAuthoringService 将一次结构化修改适配到项目单栈。创建后保存再撤销/重做,会保留撤销当时的 Saved 版本,不以初始空文件覆盖后来的保存内容。 +- 文本目标按 key、文件身份、有效来源和期望原文检查;同文件其他叶值的草稿可先提交后重基,不覆盖已变的同一目标。普通值 no-op 不新增历史。 +- RegisterFileDraft/CommitFileDraftsAsync 只验证并提交相关文件草稿。普通文本 Save All 仍可保存待修复原文;严格校验只用于结构化编辑前的协调,不全面收紧普通文件保存策略。 +- ProjectTextBuffer 保留草稿的原字节基线。文档刷新遇到冲突时保留输入并提示,拒绝过期提交;取消后同步当前文档。文本页优先从内存打开受管理文件,受保护文档不因被其他服务接管而变成可写。 +- ReserveCharacterKeyAsync 持久预留 character_<递增编号>,结合 catalog 与 metadata 的高水位分配;预留独立于内容撤销,允许编号空洞,不顺带保存其他未提交内容。P2 不自动创建角色记录。 + +## 设计落实 + +作者 metadata 采用 Graph/localization-authoring.json,version=1 与非负 Int64 characterHighWater;运行时不读取它。默认新文本分片是 I18n//authoring.json,后续表单/面板可显式传入 Group 分片。 + +角色编号预留通过结构事务同时推进 Working/Saved 的计数,属于不可回收的身份分配,不在创作 Undo 栈中添加独立条目。P3 的角色创建将使用预留结果形成一条角色/姓名/引用历史,撤销时仍保留高水位。这落实用户要求的只增身份,而非提供普通可撤销 metadata 编辑 UI。 + +JSON 草稿提交和随后的结构化修改是两个已明确提交的输入动作,各自进入历史。结构化服务只提交相关文件,不顺带应用其他文件或设置草稿。 + +## 调查与修正 + +1. 回归稳定复现同路径身份复用的保存错误:撤销第一份新文件、在同路径创建第二份文件后,CheckTrackedFiles 同时要求旧删除身份的路径不存在和新身份的路径存在,报告 files.changed。`p2-pre-fix.trx` 为 10 通过、1 失败。 +2. 保存检查与 TrackedExpectations 改为先按路径归并、优先唯一有效身份,再筛选受管理内容;删除记录继续保留供历史使用。查询阶段已有相同原则,此次补齐保存路径。已更新 lesson L-20261019-04-file-document-path-reuse。 +3. 首轮故障注入未触发,因为既有 FaultRepository 夹具仅识别白名单中的故障类型。将新的 LocalizationPublicationFault 纳入同一夹具后,确认发布失败补偿和恢复失败 RepairRequired 两条路径;没有为此更改生产事务机制。 + +## 验证 + +解决方案最终构建成功: + +```powershell +$env:AVALONIA_TELEMETRY_OPTOUT='1' +dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false --verbosity quiet +``` + +结果为 0 errors、91 warnings,日志 Temp/localization-p2-solution.log;没有建立整个工作树警告增量基线,不声称无新增警告。依照已知任务宿主限制,在受限环境外运行完整构建与测试,未修改产品代码绕过限制。 + +最终测试: + +| 测试项目 | 通过 | 失败 | 跳过 | +| --- | ---: | ---: | ---: | +| GalNet.Editor.Tests | 196 | 3 | 0 | +| GalNet.Editor.Shared.Tests | 254 | 0 | 0 | +| GalNet.Architecture.Tests | 45 | 0 | 0 | +| 合计 | 495 | 3 | 0 | + +P2 新增 Editor 12 项、Shared JSON 9 项,共 21 项全部通过,包括真实控件挂载测试与修正后的同路径重建保存用例。没有将测试宿主退出非零记成全量通过。 + +Editor 的三个失败均与此前 [P6e3 主工作区记录](../F-20261015-02-editor-authoring-workspace/phase-6e3.md) 中明确列出的失败一致:BrowserAddressBlocksDropAndGlobalSearchExitFollowSelection(目录块点击)、GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor(尾部留白)、GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave(行宽)。本阶段未修改这些测试及相关布局实现;未将并行工作区的 UI 改动恢复或纳入本任务修正。因此 P2 标记 verified 指本阶段验收与直接回归通过,不能解释为当前整个工作树全绿。 + +TRX 位于 Temp/localization-p2-results/;以三个项目名称的结果文件为最终回归证据,早期 focused/pre-fix 结果不混计。13 个任务 C# 文件的 whitespace verify 与测试前后 SHA-256 一致性检查通过,清单为 Temp/localization-p2-sources.json。文档链接与 git diff --check 也通过。 + +新增用例覆盖:既有文件修改/撤销/重做/保存重开、保留其他语言和无关叶值、新建文件不保存其他 dirty 内容、保存后创建历史重基、同路径重建保存、有效相关草稿重基、无效相关草稿阻止修改、冲突草稿保留与取消、真实 FileDocumentPanelView/TextBox 挂载、catalog/metadata 多文件历史、外部修改冲突、编号预留跨重开和故障补偿/RepairRequired。 + +## 边界与后续 + +- 本阶段提供受管理写入和历史 API,没有角色 Dock、speaker 搜索/失焦创建、多语言表格、自动 Entry 文本映射、复制文本 key 重映射或导入导出。 +- 默认语言原文更新保留其他语言译文;待复核标记仍由后续文本生命周期阶段接入。 +- 结构化表单尚未消费新 API;普通页面不会仅因 P2 完成就自动把输入转成 key。 +- 未增加新的输入控件,也未进行原生 IME 人工验收;文件页绑定/草稿行为有 Headless 实际控件验证。 +- 全项目查询仍为按需快照和文件读取,未做大项目性能验收。 +- 工作树中另有并行 Timeline/UI 改动,未重置、提交或将其归入本 feature。 + +当前事实见 [规范](../../docs/spec/editor-game-localization.md);下一阶段为 P3 角色 Dock 与可输入 speaker。 diff --git a/features/F-20261019-01-editor-game-localization/phase-3.md b/features/F-20261019-01-editor-game-localization/phase-3.md new file mode 100644 index 00000000..93e0ad51 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-3.md @@ -0,0 +1,43 @@ +# P3:角色表与 speaker 输入 + +状态:verified;2026-10-20。目标与退出条件见 [phase-plan.md](phase-plan.md)。 + +## 实现 + +- 增加角色 Dock、二元组列表、姓名/key 搜索、显式应用/还原、新建/改名/改 key、未使用删除、旧值导入候选。项目级单例,关闭页面释放订阅。 +- CharacterAuthoringService 统一角色身份、开发语言姓名与引用统计;自动编号复用 P2 持久预留。同名允许、key Ordinal 唯一;改 key 按稳定 Entry 身份更新全部明确引用,包括未打开 Group 与嵌套 DTO/列表。 +- 字段编辑器按 CharacterReference 语义注册;结构化表单克隆字段保留 Semantic。专属 speaker 输入显示开发语言姓名、匹配姓名/key、键盘及指针选中提交 ID、失焦创建;精确 key 优先,精确姓名歧义拒绝,空白不创建,组合态 Enter 不提交。 +- 角色记录、姓名和 Entry 参数补丁进入同一项目历史。新文件先建立空保存基线,值修改只进入 Working;撤销角色创建不删除 Saved Group 仍可能使用的文件。编号和空基线允许留空洞,不提前保存其他 dirty 内容。 +- 旧 Settings.Speakers 和未知 speaker 只进入显式迁移候选;正常页面不再往 Settings.Speakers 追加姓名。未知 schema/不完整分析禁止删除、改 key。 +- 四种 Editor UI 语言补齐面板、历史与错误文案;UI 语言切换保留作者姓名和待应用草稿。 + +## 验证与修正 + +新增 10 个 Editor 用例,覆盖一次撤销、保存重开、同名/唯一 key、精确 key 优先、未打开 Group 改 key、保留其他语言、显式删除与编号不回收、旧值导入、不完整分析保护、并发姓名复用、取消/过期 Entry、无效 JSON 草稿及过期改名,以及三个真实挂载用例(角色面板/Dock、speaker 焦点/搜索/键盘/候选点击/组合态、嵌套列表字段)。Shared 新增默认 PascalCase 与 camelCase 的四项实际 JSON 字段路径回归,包括多层 DTO 继承外层值契约的序列化配置。 + +早期失败均保留日志:取消和 JSON 错误最初使用精确异常类型断言,改为接受对应基类;嵌套键盘测试必须先派发文本变更。组合态检查只验证 Enter 防误提交,清除 PreeditText 后再渲染,避免 Avalonia 静态 TextDecoration 跨独立测试 dispatcher 的已观察异常;不声称验证原生 IME 窗口交互。 + +首次完整回归发现 AuthoringDraftsReopenInMountedGroupAndKeepOpaqueValuesThroughEditsCopyUndoAndSave 失败:异常 speaker 对象在初始化时变成字符串。根因是 Avalonia TextChanged 延迟派发,程序化 Refresh 后 updating 标志已解除,初始化被错误当作用户输入并安排失焦提交。改用同步 TextBox.TextProperty 变更监听,程序化刷新受同一 guard 保护;异常 JSON 值保留并显示原文,不因挂载或编辑其他字段被清空。修正后该旧数据生命周期用例在两轮完整 Editor 回归中均通过,未把本阶段引入的失败归为既有失败。 + +最终解决方案构建:`dotnet build GalNet.slnx --no-restore -m:1 -p:UseSharedCompilation=false --verbosity quiet`,启用 Avalonia telemetry opt-out;0 错误、94 警告,52 秒,日志 Temp/localization-p3-final-solution.log。未建立整个工作树警告增量基线,不声称无新增警告。构建、testhost 和 format 的本地管道在受限环境外执行,没有修改业务实现绕过宿主限制。 + +| 测试项目 | 通过 | 失败 | 跳过 | +| --- | ---: | ---: | ---: | +| GalNet.Editor.Tests | 207 | 3 | 0 | +| GalNet.Editor.Shared.Tests | 264 | 0 | 0 | +| GalNet.Architecture.Tests | 45 | 0 | 0 | +| GalNet.Presentation.Tests | 243 | 0 | 0 | +| 合计 | 759 | 3 | 0 | + +本阶段新增 Editor 10 项、Shared 4 项,14 项全部通过。结果以 Temp/localization-p3-results/ 下四份 TRX 为准,早期 focused/pre-fix 结果不混计;最后两份 Editor/Shared 日志为 Temp/localization-p3-final-GalNet.Editor.Tests.log 与 Temp/localization-p3-final-GalNet.Editor.Shared.Tests.log。共享控件和架构回归之后仅修改 Editor 输入事件监听和 Shared walker,受影响的 Editor/Shared 已在最终构建后完整重跑。 + +三个失败均为 [P6e3 已记录](../F-20261015-02-editor-authoring-workspace/phase-6e3.md) 的目录点击、列表尾部空间和行宽用例:BrowserAddressBlocksDropAndGlobalSearchExitFollowSelection、GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor、GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave。其中目录点击在前一轮完整重跑通过、最终重跑失败,保留其偶发记录;未修改这些测试或把相关并行布局改动混入本 feature。P3 verified 只代表本阶段验收成立,不代表当前工作树全绿。 + +20 个任务 C# 文件 whitespace verify 通过;25 个任务 C#/XAML/UI JSON 文件最终 SHA-256 与测试前一致,清单为 Temp/localization-p3-final-sources.json。四语各有相同的 23 个角色 UI key,文档链接与 git diff --check 通过。两条 agent lesson 分别记录实际 wire 名与程序化 TextBox 刷新边界,正式行为同步到 [spec](../../docs/spec/editor-game-localization.md)。 + +## 边界 + +- 角色面板草稿需要显式应用,Save All 保存作者文档中已应用的值,与变量面板一致。 +- 候选上限 50;每次输入使用页面缓存,项目查询仍有 P1/P2 已记录的全快照/文件读取开销,未进行大型项目性能验收。 +- 原生 IME 人工验收未执行;自动测试覆盖实际 TextPresenter 组合态防提交、焦点和 routed input。 +- 正文/分支默认语言映射与复制 key 生命周期仍属 P4;多语言表格、语言管理属 P5;导入导出与发布前完整校验属 P6。不能把 P3 完成解释为整个 feature 完成。 diff --git a/features/F-20261019-01-editor-game-localization/phase-3a.md b/features/F-20261019-01-editor-game-localization/phase-3a.md new file mode 100644 index 00000000..db11827b --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-3a.md @@ -0,0 +1,11 @@ +# P3a:移除旧角色兼容入口 + +状态:verified;2026-10-20,按用户“不考虑向后兼容,删除所有向后兼容部分”的最新要求调整本 feature。 + +删除 ProjectSettings.Speakers 及设置快照复制项;删除旧角色候选/迁移服务、人物表迁移区、四语迁移文案、废弃 speaker 失焦回调和对应测试。角色身份仅以 catalog 为准,未知 ID 只产生 character.unknown,不将存储值解释为姓名。现有设置/保存/深拷贝测试改用仍有效的设置字段,保留原事务与快照断言。 + +验证:Editor 定向 18 项、Shared 文档/事务 17 项,共 35 项通过;Editor.Tests 及 Shared.Tests 构建通过。全解决方案首次构建因同时变动的 SceneAuthoringIdentities.IsOutput 暂缺失败,对应场景实现补齐后受影响项目重建成功;未修改或提交其他任务的场景变更。日志与 TRX 在 Temp/localization-p3a-*。 + +生产与测试 C#/XAML 不再引用 Speakers、CommitSpeaker、LegacyCandidates 或 ImportLegacy。默认语言文本、嵌套 DTO、未知扩展字段保护仍是当前格式的作者文档规则,不作为旧版本迁移入口处理。 + +需求/设计/spec 已同步;P3 原记录仅作为历史。完成后继续 P4。 diff --git a/features/F-20261019-01-editor-game-localization/phase-4.md b/features/F-20261019-01-editor-game-localization/phase-4.md new file mode 100644 index 00000000..78c614d9 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-4.md @@ -0,0 +1,31 @@ +# P4:默认语言创作与稳定 key 生命周期 + +状态:verified;2026-10-20。先完成 [P3a](phase-3a.md) 的兼容移除并提交 d2e4b82,再推进本阶段。 + +## 实现 + +GameTextAuthoringService 处理带 LocalizedText 语义的编辑意图;普通表单读取项目 TargetLocale 的直接原文,提交边界把编辑意图替换成稳定 key,同时更新原文文件、复核 metadata 和 Entry/分支引用。未标记字符串继续按原值编辑。控件不把 key 或其他语言回退值显示为开发原文;缺译有提示,共享 key 显示影响引用数。 + +初次提交分配不依赖行号/数组下标的 GUID 文本身份,之后修改原文和移动沿用 key。单行 Enter、多行 Ctrl+Enter、失焦提交;Escape 还原草稿,IME 组合态不提交。草稿固定原引用、原文和开发语言基线,相关变化后拒绝提交并保留输入。文本输入使用显示缓存,避免每个按键重复全项目查询。 + +根字段和嵌套 DTO/列表共用语义编辑器。结构化列表增加复制项;复制只重映射明确文本引用,保留普通字符串、未知 JSON、null/缺省和共享角色 ID。Entry/Graph 剪贴板 version 2 携带各语言直接文本及复核快照;不接受旧剪贴板,也不借目标项目恰好同名的 key 猜测来源。复制批次维护旧 key→新 key 映射,内部共享关系保留,副本与原文互不串改;跨项目按快照复制。 + +新作者文件先初始化空 Saved 基线;随后值编辑与内容粘贴合为一条历史。Graph 复制的 Saved 基线使用空 Group 内容及空选项文本,防止撤销→保存→重做时提前发布缺译的新 key;Working 保留完整副本,Save All 整体发布。相关 JSON 草稿先校验/提交,失败时不应用跨文件值修改。 + +metadata.textStates 保存 sourceLocale、sourceFingerprint 与各语言已关联的原文指纹;原文变化保留译文并标为需复核,没有关联证据时保持未知。角色姓名更新复用该状态记录。角色编号预留属于不可撤销字段:文本历史检查和应用只对 characterHighWater 允许单调重基,其余 metadata 仍严格检查,防止创建/撤销角色后无法再撤销文本或回退编号。 + +## 验证 + +首轮定向 14 项为 12 通过、2 个多行 UI 提交失败;TextBox 类处理先消费 Ctrl+Enter,改为 Tunnel 阶段处理后修正。后续 27 项及补充后的 29 项定向均通过。最终新增 16 项全部通过:GameTextAuthoringTests 12 项、LocalizationTextStateTests 4 项,覆盖普通原文/译文保留、Entry 与 Graph 复制、保存撤销重做、列表复制/移动、缺开发语言文本、分支真实焦点提交、重绑定和删除保护、取消及原文冲突、角色历史和编号交错、跨项目与混合 DTO 提交。 + +解决方案构建 0 错误、110 警告;后续仅修正 Graph 复制用例对文本 key 和保存重开的预期,Editor.Tests 再构建 0 错误、59 警告,该用例单独通过。最终完整回归:Editor.Shared 300 通过,Presentation 243 通过(总发现 249,日志有媒体环境跳过),Architecture 45 通过,Editor 224 通过、3 失败;合计 812 通过、3 失败,不宣称全绿。 + +Editor 失败中,GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor 的尾部空间 0 < 40、GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave 的宽度 482 >= 480,均已在 [既有 P6e3](../F-20261015-02-editor-authoring-workspace/phase-6e3.md) 记录。另一次 TimelineSelectionNeverNormalizesOrRewritesIncompleteOrInvalidAuthorJson 的 events:null 参数发生 Avalonia.Headless.HeadlessUnitTestSession.DisposeAsync 空引用;同一方法 7 个参数用例在独立进程复跑全部通过,未复现功能断言失败,也未修改无关时间线代码。 + +37 个 C#/XAML/资源文件的最终指纹核对通过;C# whitespace 验证、四语 UI 资源 key 一致性(各 850)、文档相对链接和 diff whitespace 检查通过。构建及 TRX 证据保留在本地 Temp/localization-p4-solution.log、Temp/localization-p4-final-results/、Temp/localization-p4-format-verify.log、Temp/localization-p4-sources.json;Temp 不纳入 Git。 + +## 边界 + +- 不提供旧 Speakers/角色迁移或旧剪贴板兼容;P3 历史文档只记录当时状态。 +- 原生 IME 人工验证未执行,自动测试验证输入、焦点和组合态边界;不宣称已做大项目性能验收。 +- 多语言表格与语言管理属 P5;模板导入导出与发布前完整校验属 P6。自定义运行指令仍需通过 ITextResolver 解析 key。 diff --git a/features/F-20261019-01-editor-game-localization/phase-5.md b/features/F-20261019-01-editor-game-localization/phase-5.md new file mode 100644 index 00000000..2dabec02 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-5.md @@ -0,0 +1,53 @@ +# P5:多语言表格与语言管理 + +状态:verified;2026-10-21。 + +## 实施范围 + +新增项目级游戏本地化 Dock。左侧角色/全部 Group/分支分类,右侧开发语言首列和其他支持语言列;分类与行使用 ListBox 虚拟化,表格整体横向滚动,非开发语言列通过左右按钮调序。正常行只显示上下文和文本,不显示文本 key。 + +GameLocalizationPanelService 投影现有索引和作者文档,同分类共享 key 合为一行。单元格只读取直接包内译文,区分缺译、显式空串、待复核、复核未知。开发语言修改复用 P4 原文链,译文与关联原文指纹为一次历史,缺开发语言文本时不伪造原文;变更开发语言后旧关联不能冒充已复核。 + +语言新增规范化 culture 并拒绝重复,不复制原文;顺序写入 AvailableLocales,开发语言固定首列。切换开发语言使用“检查变更→显示缺译数量→应用”独立操作,应用前重新检查预览证据。项目设置原开发语言字段只读,避免绕过检查;语言历史只修改语言切片,不覆盖其他设置。 + +单元格多行 Ctrl+Enter/失焦提交,Escape 还原;IME 组合态不提交。草稿记录直接原文、目标译文、开发语言及分类引用,源变更/目标变更/引用删除/项目释放后拒绝旧草稿覆盖。ViewModel 为项目 scope,切分类或关闭 Dock 保留草稿;Save All 提交已访问分类的草稿,冲突保留并阻止保存,另有放弃草稿入口。 + +## 验证记录 + +首轮编译发现新的控件不能访问 TextInputMethodClient,已复用现有 TextPresenter.PreeditText 检测。下一次编译中本阶段代码通过,但工作区并行时间线测试出现 TimelineTreeNode.Descendants 未定义;未修改其他任务文件,曾用 Temp 的 CustomAfterMicrosoftCommonTargets 仅排除该未完成测试独立验证。该任务随后在 1a31d37 完成,已恢复正常构建;最终验证不排除任何文件。 + +新增阶段回归覆盖语言规范化/重复/顺序/保存重开、开发语言切换预览失效、全部分类及共享行、译文和复核历史、缺原文/空译文/保存撤销重做、JSON 草稿协调、过期单元格、真实输入/关闭重开/列调序/行及分类滚动;最终结果见下文。 + +首轮隔离定向为 9 通过、3 失败:虚拟化模板在回收时收到 null,提交期间禁用 TextBox 导致后续失焦提交丢失,另一个 JSON 草稿 fixture 错用 Get 而没有 Open。修正后 11 通过、1 失败(动态按钮不在 XAML 名称作用域,改从真实视觉树定位);随后补充组合态测试时须在设置 Text 后注入 PreeditText,否则 TextBox 会先清除组合态。上述失败和复验均保留本地日志,不用重试隐藏产品断言。 + +扩充语言测试发现 I18nLocale 原逻辑把整个后缀大写,zh-Hans-CN 会变成 zh-HANS-CN,与 CaptureText 的 CultureInfo.Name 不一致;改为统一标准 culture 名称并拒绝无效 culture,补脚本语言添加→翻译→开发语言切换→正常正文提交,以及 Core 三个大小写参数和无效名称测试。该修正属于语言管理的必要契约,不加入旧格式迁移。 + +实际 Dock 关闭用例发现 CloseDocument 的通用 IDisposable 释放会提前销毁 scoped ViewModel;关闭本地化视图现在保留项目持有的模型,项目 scope 结束仍释放。真实菜单开关带草稿验证已通过,当时定向 14 项全部通过;随后补充队列交错用例。 + +异步路径检查新增 2 个历史队列交错用例,修改前均失败:入队前检查不能覆盖前序语言切换或译文编辑。单元格 guard 与开发语言预览校验现在进入实际历史 Redo 边界,修改后 i18n 定向 16 项全通过,其中新增 Editor 用例 13 项;连同 Core 新增 4 项,本阶段新增共 17 项通过。整份 metadata 与行上下文每次快照只解析/建立一次,避免按单元格反复扫描。 + +## 最终证据 + +正常完整解决方案构建成功,0 错误、10 警告;最终 Editor 定向 16 项通过。本阶段新增 17 项在完整回归中全部通过。相关全量结果如下: + +| 测试项目 | 通过 | 失败 | +| --- | ---: | ---: | +| Core | 115 | 0 | +| Runtime | 85 | 0 | +| Editor.Shared | 312 | 0 | +| Architecture | 45 | 0 | +| Presentation | 243 | 0 | +| Editor | 239 | 2 | +| 合计 | 1039 | 2 | + +Presentation 日志另有 6 个视频环境用例跳过(TRX 发现 249、执行 243)。Editor 两项失败为 GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor 的尾部留白 0 < 40、GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave 的宽度 482 >= 480,均已在 [P6e3](../F-20261015-02-editor-authoring-workspace/phase-6e3.md) 记录,不隐藏或宣称全绿。最后补充的队列保护只改 GUI 服务和测试,复跑完整 Editor 验证;其余模块保持前次通过的源码。 + +17 个 C#/XAML/四语资源文件的最终源码指纹一致,C# whitespace 验证通过;四种 UI 语言各 882 个 key 一致,文档相对链接、Git diff whitespace 检查通过。使用说明同步至 [manual](../../docs/manual/game-localization.md),当前契约同步至 [spec](../../docs/spec/editor-game-localization.md)。本地证据:Temp/localization-p5-final-solution.log、Temp/localization-p5-final-results/、Temp/localization-p5-results/p5-queue-before.trx 与 p5-queue-fixed.trx、Temp/localization-p5-format-verify.log、Temp/localization-p5-sources.json;不提交 Temp。 + +提交暂存时工作区又出现其他变量/历史相关并行修改,未纳入本阶段。上述构建与回归对应验证时的源码快照,不扩展为后续并行工作树的构建结论;本阶段 17 个源码文件在提交前再次核对无漂移。 + +## 边界 + +- P6 模板导入导出与发布前完整校验尚未实施。 +- 没有新增旧项目或旧剪贴板兼容。 +- 不宣称已完成原生 IME 人工验证或大项目性能验收。 diff --git a/features/F-20261019-01-editor-game-localization/phase-6.md b/features/F-20261019-01-editor-game-localization/phase-6.md new file mode 100644 index 00000000..e532ddb5 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-6.md @@ -0,0 +1,34 @@ +# P6:交换、发布检查与收尾 + +状态:verified;2026-10-21。 + +## 实施细化 + +单语言 JSON 模板 version 1 包含 projectId(来自 exchangeProjectId)、开发/目标语言、稳定 key、开发原文及 SHA-256、上下文、nullable translation,以及分析范围 complete。平铺 JSON 为独立输入形式,null 跳过,空串须明确允许,未知 key 不能应用;导入不修改开发原文,不按行序/原文匹配。表格增加导出范围、运行文本格式、导入预览/选择与整批一次历史。 + +项目 GalProject.Id 实际来自目录名,不能作为持久交换身份。Graph/localization-authoring.json 的 exchangeProjectId 首次模板导出时持久预留;如编号高水位一样,旧文本历史允许缺省→已预留身份并在撤销时保留。已有身份不能被历史替换。首次身份预留可能在输出失败后保留,文本值与其他 dirty 文件不因导出保存;这是持久身份初始化对“导出失败不改会话”的明确例外,不回滚或重用已分配身份。 + +导出必须在项目外,拒绝目录链接,输出先写临时文件再原子替换,不能覆盖受管理来源;取消文件选择不开始导出。导入预览捕获当前作者证据,相关 JSON 草稿须先协调;实际历史执行时再校验,过期、关项目及冲突保留原值与草稿。 + +完整 project.validate 与 ProjectContentBuilder 接入 localization-semantics,读取包内作者来源,不把 External 当默认语言完整性依据;作者保存仍允许语义草稿,不在保存入口强制发布条件。graph/group 单独范围继续明确报告未检查完整本地化范围。 + +## 验证 + +新增 20 项全部通过:Editor 9 项、Shared 交换解析/批量叶值 9 项、完整检查范围 1 项、Integration 缺译阻断 1 项。直接相关定向合计 106 项通过(Editor 37、Shared 66、Integration 3)。 + +端到端覆盖模板导出→外部修改→导入→撤销/重做→保存重开→构建→ProjectGameContentProvider 加载→GameTextResolver 中英文解析;同时验证外部行重排、角色改 ID 后姓名文本身份、旧文本撤销跨身份预留、源文变化需复核、平铺证据未知、空/null/未知选择、过期/关闭/排队预览、输出路径/失败、JSON 草稿冲突和实际控件/文件选择适配。 + +首次测试将 Button.ClickEvent 直接 RaiseEvent,未触发 ICommand;改用真实鼠标点击后 9 项 Editor 交换用例通过。初期正常构建遇并行效果测试未完成(Capability/隐式数组类型),曾以 Temp 的 CustomAfterMicrosoftCommonTargets 排除其两个测试文件做隔离验证;对方修正后已恢复正常完整构建,最终没有排除文件。自己的 OutputRoot/Id 测试属性名修正为实际 OutputPath/ObjectId,未更改业务行为绕过验证。 + +最终正常解决方案构建 0 错误、118 警告。完整相关回归:Core 116、Runtime 85、Editor.Shared 329、Integration 111、Architecture 45、Presentation 243、Editor 251 通过;合计 1180 通过、2 失败。Presentation 日志另有 6 个视频环境跳过(发现 249、执行 243)。Editor 两个失败仍为 [P6e3](../F-20261015-02-editor-authoring-workspace/phase-6e3.md) 已记录的尾部留白 0 < 40 与宽度 482 >= 480;没有隐藏失败或宣称工作树全绿。 + +Architecture 初次 15 项失败来自 test/bin/te4c3b 临时仓库副本重复 csproj 被计为源码。保留副本,测试发现规则排除递归 bin/obj,复验 45 项通过;独立提交 c7c97b7,不混入交换业务。其余项目测试源码和业务实现未因此变化。 + +本阶段 24 个 C#/XAML/四语资源文件最终指纹全部一致;19 个 C# 文件格式只读检查通过,四语资源各 898 key 且无重复、集合一致,56 条相对文档链接及 diff whitespace 校验通过。证据在本地 Temp/localization-p6-final-solution.log、Temp/localization-p6-final-results/、Temp/localization-p6-results/、Temp/localization-p6-sources.json;Temp 不提交。 + +## 边界 + +- 使用 UTF-8 JSON,不新增 CSV/XLSX 或机器翻译依赖。 +- 不加入旧版本模板、旧项目和旧剪贴板迁移。 +- 原生文件对话框/IME 人工验证和大项目性能阈值尚未验收;自动测试使用可替换文件选择服务和真实挂载控件。 +- 自定义未知/opaque 范围不执行游戏代码推断,提供 warning/Complete=false;已知缺译和角色等确定错误仍阻止发布。 diff --git a/features/F-20261019-01-editor-game-localization/phase-plan.md b/features/F-20261019-01-editor-game-localization/phase-plan.md new file mode 100644 index 00000000..ab712bb6 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/phase-plan.md @@ -0,0 +1,92 @@ +# 实施阶段 + +状态:P1–P6 及 P3a verified。前置产品决定见 [feature.md](feature.md),目标架构见 [design.md](design.md)。每阶段只对实际完成并验证的事实更新 spec,不提前把设计当成实现。 + +2026-10-20 用户要求取消向后兼容;P3a 先移除旧 Speakers、迁移服务/面板、兼容回调及对应资源,然后继续 P4。历史 P3 记录保留当时事实,当前行为以最新 spec 为准。 + +## P1:字段语义与可诊断项目索引 — verified + +2026-10-19:解决方案构建成功,generator/Editor.Shared/Core/Architecture 合计 411 项通过;任务文件格式校验通过。实现与限制见 [phase-1.md](phase-1.md)。 + +- 前置:本设计;实施前复核工作树及项目上下文。 +- 模块:Core.Parameters、SchemaGenerator、Contracts.Builtins、Editor.Shared、相关测试。 +- 工作:定义成员语义及生成诊断,标记简单/语音对白和 NVL;实现有界嵌套 walker,接入角色、全部作者 Group 和稳定分支选项;索引保留来源、覆盖及完整性状态。 +- 验证:基类/嵌套 DTO/列表、null/缺省/未知成员、非文本误标、重复 key、嵌套与平铺碰撞、未打开 Group、未知 schema、项目释放后过期查询。 +- 文档:记录字段契约和查询边界,验证后更新 i18n spec。 +- 风险:把 EditorId 误当语义、部分扫描被当作无引用。 +- 退出:全部目标字段可被同一语义模型识别;正常旧内容不改写,不完整分析有明确诊断。 + +## P2:受管理本地化文档与单次文本编辑 — verified + +2026-10-20:受管理写入、跨文件历史、相关 JSON 草稿协调、持久编号预留已实现;新增 21 项全通过,解决方案构建成功。完整相关回归 495 通过、3 项既有 UI 失败,未宣称工作树全绿;证据和限制见 [phase-2.md](phase-2.md)。 + +- 前置:P1。 +- 模块:Editor.Abstraction/Shared 的文档与保存、GUI 历史/ProjectTextFiles、文件事务及恢复测试。 +- 工作:catalog/翻译文件专属文档入口;作者 metadata 和编号分配基础;新文件结构计划;默认语言文本创建/更新的单历史意图;文本缓冲草稿冲突协调;复用现有 JSON 来源,支持精确修改。 +- 验证:改一条默认语言文本→撤销/重做→保存重开;Working/Saved 结构重基;同时打开 JSON/表单、有未提交草稿、外部冲突、失败补偿/RepairRequired;新增 catalog/翻译文件不会顺带保存其他 dirty 内容。 +- 文档:验证后补 editor-history-and-save 和本地化作者存储说明。 +- 风险:双写、覆盖草稿、锁反转、辅助 metadata 不纳入恢复。 +- 退出:UI 不直接文件写盘;单格修改及所有相关作者文件在历史/保存/恢复中保持一致。 + +## P3:角色 Dock 与可输入 speaker — verified + +2026-10-20:角色面板、speaker 搜索/失焦创建、跨 Group/嵌套引用改 key 和一次历史完成;新增 14 项通过,解决方案构建成功。相关回归 759 通过、3 项既有 UI 失败。完整证据和边界见 [P3](phase-3.md)。 + +- 前置:P2。 +- 模块:角色服务、Editor Dock/VM/View、专属字段编辑器、项目 UI 四语资源。 +- 工作:姓名/key 二元组、同名、新建/改名/改 key、未使用删除、引用统计;speaker 姓名/key 搜索和失焦创建;替代 Settings.Speakers 候选;旧数据显式迁移预览。 +- 验证:真实挂载候选点击/焦点/键盘、composition 状态、同名歧义、精确 key 优先、空输入、重复提交;自动编号跨保存/删除/撤销/重做不重用;改 key 更新未打开 Group;引用不完整不允许删除;创建角色及引用一次撤销。 +- 文档:新增角色管理规范,更新 i18n 和编辑器字段说明。 +- 风险:把未知旧 key 当姓名、失焦先于选择、重命名产生半次跨文件更新。 +- 退出:完整“输入新姓名→自动角色→其他 Entry 选择→改名→保存重开”路径通过,旧页面不再直接写 Speakers。 + +## P4:默认语言创作与稳定 key 生命周期 — verified + +2026-10-20:开发语言正文/分支表单、稳定文本身份、Entry/Graph/列表复制各语言快照、源文复核状态及跨文件单次历史完成;新增 16 项全部通过,解决方案构建成功。完整相关回归 812 通过、3 失败(2 项既有布局断言,1 项 Headless 释放异常,独立复验 7 项通过)。详见 [P4](phase-4.md)。 + +实施细化:语义文本控件只把临时编辑意图传入既有表单提交链,提交边界一次替换为稳定 key 并写开发语言文本;列表结构仍搬运含 key 的原 JSON。复制携带各语言文本快照,采用新版本剪贴板,无旧版读取分支。新文件先初始化空保存基线;跨文件值修改与内容复制合成一条历史。 + +- 前置:P2/P3。 +- 模块:Group/Graph 字段提交、嵌套 DTO 表单、Entry/Group 剪贴板、分支操作、Shared 文本意图。 +- 工作:被标记字段显示开发原文,首次提交建 key;处理缺译;标量/列表文本身份;复制批次新 key 与各语言译文复制;分支选项一致行为;源文变化保留译文并记录复核信息。 +- 验证:Entry/列表重排、插入删除、保存重开 key 不变;修改原文不丢其他语言;复制 Entry/Group/列表项互不串改,角色保持共享;已有共享 key 明确提示;UI 语言切换不改作者值;nullable/default 与未知 JSON 保留。 +- 文档:更新 Group/Graph 和 i18n 作者语义,记录自定义运行指令仍需解析文本。 +- 风险:按数组下标重建身份、复用普通 string codec 误改所有字符串。 +- 退出:普通创作不需手填文本 key,所有目标移动/复制路径保持正确文本身份和翻译。 + +## P5:多语言表格与语言管理 — verified + +2026-10-21:游戏本地化 Dock、角色/全部 Group/分支虚拟化表格、语言增序与开发语言缺译预览/切换、直接文本状态、草稿保留和历史排队保护完成。新增 17 项全通过;完整解决方案构建 0 错误。相关完整回归 1039 通过、2 项既有布局断言失败,完整证据与边界见 [P5](phase-5.md)。 + +2026-10-21 实施细化见 [P5](phase-5.md):使用虚拟化行和整体横向滚动;非开发语言列以左右按钮调序,切开发语言采用预览及显式应用;项目级 ViewModel 在关闭 Dock 后保留单元格草稿。 + +- 前置:P4。 +- 模块:i18n Dock、语言设置意图、索引投影、UI 四语资源。 +- 工作:可滚动角色/全部 Group/分支分类;虚拟化多语言表格;开发语言首列、加语言、其他语言调序;直接译文/缺失/空串/待复核状态和引用导航;开发语言变更影响检查。 +- 验证:真实挂载滚动/多行单元格编辑/调列序/切分类/关闭重开;隐藏 key;所有 Group 可访问;共享 key 同步;语言新增/调序可撤销且保存;编辑草稿在源变更时不被覆盖,旧行不提交。 +- 文档:新增游戏 i18n 面板规范及使用说明。 +- 风险:把回退文本当直接译文、表格规模导致全项目逐键扫描。 +- 退出:角色/正文/分支三类文本在普通界面和表格一致,语言支持与顺序跨保存重开保持。 + +## P6:导入导出、完整校验与收尾 — verified + +2026-10-21:模板/运行文本导出、JSON 导入预览/整批历史、稳定项目身份及发布检查完成;新增 20 项全通过。正常完整构建 0 错误;相关回归 1180 通过、2 项既有布局失败。范围和过程见 [P6](phase-6.md),结论见 [审核](review.md) 和 [总结](summary.md)。 + +2026-10-21:当前 JSON 模板、平铺输入、持久交换身份及预览/提交边界实施细化见 [P6](phase-6.md)。目录名不是持久项目身份;首次模板导出预留 exchangeProjectId,之后不随值历史撤销。 + +- 前置:P5。 +- 模块:Shared 交换服务、文件选择适配、导入预览、项目完整校验、集成测试和开发者文档。 +- 工作:版本化单语言 JSON 模板/运行文本导出及模板/平铺 JSON 导入;稳定项目/key/原文指纹核对;清空选项、冲突预览、过期检查、一次历史;接入 localization-semantics 与构建前检查,保留作者草稿保存边界。 +- 验证:导出→外部排序/修改→导入→撤销/重做→保存重开→编译/加载→默认语言及另一语言显示;错项目、重复/未知 key、null/空串、原文变化、预览后并发修改、关项目和导出失败;包内默认语言缺译阻止发布,第三方 External 回退不回归。 +- 文档:交换格式、扩展 DTO 标记/运行解析接入、用户使用说明;完成 review.md/summary.md 并按已验证事实同步 spec/manual。 +- 风险:以行号匹配、运行文本导出覆盖源文件、宣称完整自定义运行验证。 +- 退出:feature 全部验收标准有证据;受影响测试及解决方案构建通过;格式/项目要求的检查完成,环境限制和未完成项明确记录。 + +## 验证约束 + +- 用户约定(2026-10-20):每个阶段验证结束后提交 Git,提交包含该阶段的实现、测试和记录;报告 commit。已有 P1–P3 累积改动以同一次补交保存,后续阶段分别提交。 + +- 阶段状态仅在实际实现和对应验证后改 verified;文档审查不算功能验证。 +- 先运行语义/generator、文档历史/事务、Editor 控件等直接受影响测试,再做最终解决方案构建和游戏内容集成检查;不因纯文档阶段运行全套测试。 +- 真实输入测试优先覆盖同名候选、失焦创建、表格草稿冲突和键盘提交;不以仅设置 VM 属性代替交互证据。原生 IME 人工验证若环境不可用,明确保留限制。 +- 不要求预览功能完成才能验收本 feature。自定义运行指令的本地化解析能力由其实现契约负责,作者检查明确列出范围。 diff --git a/features/F-20261019-01-editor-game-localization/review.md b/features/F-20261019-01-editor-game-localization/review.md new file mode 100644 index 00000000..2ad3bfcb --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/review.md @@ -0,0 +1,15 @@ +# 默认开发语言创作与本地化审核 + +日期:2026-10-21。结论:pass-with-follow-up。 + +这是实现者按 feature 验收点进行的自审,未声明独立第三方审核。审阅当前交换/校验 diff、P1–P5 的阶段证据、正式规范与最终测试结果。 + +未发现阻止本 feature 交付的新增问题。发布前已知引用的包内默认语言、角色、来源与语言错误会阻止输出替换;未知范围明确 warning/Complete=false。模板项目身份不依赖目录名;角色 ID 与姓名文本 key 分离;外部行序与上下文不参与匹配。普通 UI 不暴露文本 key,模板中保留机器身份。 + +导入严格检查 JSON/重复/版本/项目/源语言、只接受受支持的非开发语言,未知/null/未允许空串不可写。当前原文与原始指纹分开,旧证据标待复核。预览证据在实际历史执行前重查,关闭及排队变更已覆盖;按文件批量写入并保留其他值,值与复核状态一次撤销。导出在项目外以临时文件替换,首次持久身份可能在输出失败后保留,这一例外已记录。 + +验收证据分布:角色匹配、同名/唯一 key、只增编号、引用与删除见 P3;原文/稳定 Entry/列表/分支移动与复制见 P4;分类、隐藏 key、语言与草稿见 P5;外部交换、保存重开、构建和运行双语言解析见 P6。依赖边界测试 45 项通过,Core/Runtime 未加入 GUI 依赖。 + +完整相关回归 1180 通过、2 失败。失败为此前 P6e3 已记录的 Group 工具栏尾部留白和属性行宽度,不归因于本 feature,也未修改相关控件掩盖失败。原生文件选择/IME 的人工测试、大项目性能阈值和自定义运行指令内部解析不在已验证结论内;后者仍由 ITextResolver 接入契约负责。视频环境 6 项跳过。 + +后续跟进:既有布局断言、上述平台人工验证与性能验收。没有以向后兼容或迁移入口作为交付依赖。 diff --git a/features/F-20261019-01-editor-game-localization/summary.md b/features/F-20261019-01-editor-game-localization/summary.md new file mode 100644 index 00000000..8e121856 --- /dev/null +++ b/features/F-20261019-01-editor-game-localization/summary.md @@ -0,0 +1,19 @@ +# 默认开发语言创作、角色表与游戏 i18n 面板 + +状态:done;2026-10-21。P1–P6 与取消兼容的 P3a 均已 verified。操作入口和当前契约以 [使用说明](../../docs/manual/game-localization.md)、[Editor 游戏本地化](../../docs/spec/editor-game-localization.md)、[i18n](../../docs/spec/i18n.md) 为准;[设计](design.md) 保留取舍,[审核](review.md) 记录结论和限制。 + +项目 TargetLocale 控制普通创作原文,Editor UI 语言独立。DTO 的 LocalizedText/CharacterReference 语义由 generator 传到嵌套字段,统一索引所有作者 Group、角色与稳定分支选项。当前作者 JSON 保留稳定文本 key 和 Entry GUID,移动不重建,复制得到独立身份并携带各语言译文;自定义未标记字符串不被推断。 + +角色 Dock 编辑开发语言姓名与唯一 ID,speaker 可搜索姓名/ID、选择或失焦创建;同名允许但有歧义时需选择。自动 character_n 高水位持久且不回收;改 ID 同步明确引用,被引用或分析不完整时不允许据此删除。旧 Speakers、迁移候选和旧回调按用户要求删除。 + +游戏本地化 Dock 包含角色、全部 Group 和分支分类,虚拟化文本行,开发语言首列,其余语言可增序。直接译文、缺失、空串、待复核/未知分别呈现;共享 key 合并提示。语言切换先预览缺译。单元格草稿跨分类与 Dock 关闭保留,相关 JSON 草稿协调、实际历史执行时再检查,Save All 共用作者文档和单栈。 + +当前 UTF-8 JSON 模板携带项目 GUID、稳定 key、原文指纹和上下文,支持当前分类/全部分类的单语言模板及运行文本导出;平铺 JSON 也可导入。导入先预览,未知/null 跳过,空串须明确启用,只写非开发语言的支持语言;原文变化保留译文并标复核,整批操作一次历史。交换身份首次持久预留,输出失败可能保留身份,但不保存其他 dirty 内容。 + +完整项目校验和构建接入 localization-semantics,确定的包内默认语言缺译、未知角色和损坏来源阻止替换输出,External 不补足默认语言。无法分析的自定义范围明确 warning/Complete=false;Runtime 仍由宿主 ITextResolver 解析,不运行自定义模块做隐式猜测。 + +模块:Core 字段语义与语言规范化、SchemaGenerator、Builtins 标记;Editor.Shared 索引/受管理文档/交换 JSON/发布检查;Editor 控件、角色与本地化 Dock、历史适配和文件选择;相关单元、真实挂载和构建加载回归。 + +最终证据:正常解决方案构建 0 错误;P6 新增 20 项通过。完整相关回归 1180 通过、2 项 P6e3 既有布局失败,另有 6 个视频环境跳过;原生文件对话框/IME 人工验证及大项目性能阈值没有宣称完成。模板→外部改写→导入→撤销重做→保存重开→构建→中英文运行解析已验证。过程和各阶段数量见 phase-1 至 phase-6。架构测试构建输出扫描修正独立提交 c7c97b7,其他并行任务未纳入本 feature。 + +提交历史:P1–P3 为 5549ffb;兼容移除 d2e4b82;P4 d0105ba;P5 c835243;P6 随本总结提交。后续为既有布局修复、平台人工验证及性能验收,不包含旧版本迁移或机器翻译。 diff --git a/features/F-20261021-01-editor-state-simplification/design.md b/features/F-20261021-01-editor-state-simplification/design.md new file mode 100644 index 00000000..cdfe9051 --- /dev/null +++ b/features/F-20261021-01-editor-state-simplification/design.md @@ -0,0 +1,21 @@ +# 实施设计 + +## 写入口 + +- 删除无调用者的 Workspace 创建项目、旧 Graph 编辑包装、移动 Entry 包装。 +- 删除 GraphEditingService 的 Entry 增删移动与编号实现,保留连接器显示状态职责。 +- 删除 VariableDefinitions 集合包装及 Preview 声明编辑表单。变量面板通过 VariableAuthoringService 修改声明及明确引用;Preview 只提交运行时当前值。 +- IVariableDefinitionService 仅返回保留稳定 UID 的 detached 声明快照,并发布刷新通知,移除声明写 API 和名称修正策略。 +- 删除 Workspace 监听变量后补历史的快照、回放与重入标记,以及不再使用的 PushAlreadyApplied 入口。旧 API 直接移除。 + +## 状态所有权 + +GUI 的唯一内容比较来源为 ProjectContentSession.IsDirty。删除 EditorDocumentService 与 GalProject 的 dirty 字段和标记接口,删除 Workspace 的重复同步与保存 checkpoint。项目保存直接更新 ContentSession 的成功保存基线;History 的 IsDirty 绑定该会话。 + +独立 UndoRedoHistory 仍支持不绑定项目的游标 checkpoint,用于通用历史与独立测试;GUI 不用该游标决定保存状态。保留双版本文件计划、外部冲突、保存期间新增编辑、补偿与 RepairRequired 语义。 + +## 视图与验证 + +Preview 行复制声明元数据和运行时变量,不持有可变声明;名称、类型、默认值只读,当前值可编辑。隐藏或已被替换的行停止提交;项目关闭清除 Preview 行。声明刷新重新投影,运行时通知只更新显示、不回写。 + +Workspace 测试 fixture 改为绑定真实 ContentSession 并执行真实保存,不再依赖生产路径已删除的手工 dirty 标记。用实际挂载的 Preview 控件验证编辑和通知回显,并用项目 DI 验证没有 Workspace 的变量修改、引用撤销与保存重开。 diff --git a/features/F-20261021-01-editor-state-simplification/feature.md b/features/F-20261021-01-editor-state-simplification/feature.md new file mode 100644 index 00000000..30cdcc4e --- /dev/null +++ b/features/F-20261021-01-editor-state-simplification/feature.md @@ -0,0 +1,11 @@ +# Editor 写入口与状态精简 + +日期:2026-10-21 + +用户授权实施 Editor 优化清单的前三项:删除旧生产路径、统一变量声明修改入口、收口 GUI 未保存状态。快速迭代期间不保留旧 API 或转发兼容层。 + +范围:Workspace、GraphEditingService、Preview 变量视图、声明查询接口、Document/GalProject dirty 状态及对应测试和规范。 + +验收:生产 Entry 操作只使用项目作者服务;Preview 仅查看声明与编辑运行时当前值;声明改名与引用仍为一次历史;保存、Undo/Redo、草稿和关闭检查均以 ContentSession 为准;相关测试与构建通过。 + +非目标:全量刷新重构、缓存版本化、文件监听合并、历史内存预算优化、CLI 变量引用策略统一、预览宿主新功能。 diff --git a/features/F-20261021-01-editor-state-simplification/phase-plan.md b/features/F-20261021-01-editor-state-simplification/phase-plan.md new file mode 100644 index 00000000..969efe01 --- /dev/null +++ b/features/F-20261021-01-editor-state-simplification/phase-plan.md @@ -0,0 +1,9 @@ +# 实施阶段 + +## P1:收口旧路径与状态 + +状态:verified(本次实施范围);完整 Editor 回归保留两项此前已记录的布局失败。 + +退出条件:设计中的旧 API 和双重写入口已删除;声明/运行时值边界有行为测试;本次直接相关测试通过,完整回归中的既有失败明确记录;完整构建和 diff 检查通过;正式规范同步;仅提交本任务改动。 + +验证:18 项定向通过;完整 Editor/Editor.Shared/Architecture 合计 597 通过、2 项既有布局失败;完整 Release 构建 0 错误;源码指纹、diff 与文档链接检查通过。详情及失败处理见 [总结](summary.md)。 diff --git a/features/F-20261021-01-editor-state-simplification/summary.md b/features/F-20261021-01-editor-state-simplification/summary.md new file mode 100644 index 00000000..02ab3b05 --- /dev/null +++ b/features/F-20261021-01-editor-state-simplification/summary.md @@ -0,0 +1,38 @@ +# Editor 写入口与状态精简总结 + +## 实现结果 + +- 删除 Workspace 未使用的创建项目、旧 Graph 编辑包装和 Entry 移动包装;GraphEditingService 仅保留连接器显示状态更新,删除旧 Entry 增删移动实现及对应旧测试。 +- 变量声明唯一 GUI 写入口为 VariableAuthoringService。IVariableDefinitionService 改为独立声明快照查询/通知,保留 UID,删除名称修正和声明写接口。 +- Preview 只显示声明元数据并编辑运行时当前值。删除声明增删、改名、排序、类型/默认值编辑、重复名称对话框、VariableDefinitions 包装及其旧测试;声明刷新和关闭使旧行停止提交。 +- 删除 Workspace 的变量快照捕获、监听后补历史、回放和重入标记,删除不再使用的 PushAlreadyApplied API。 +- 删除 Document 与 GalProject 的 dirty 字段/事件/标记 API,以及 Workspace 保存后的多处状态同步。GUI History、保存与关闭检查统一使用 ProjectContentSession 的内容比较和成功保存基线。 +- 生产代码(含 XAML)净减少 973 行。不提供旧 API 的兼容转发层;作者文件格式、事务与双版本结构发布规则保持原有语义。 + +## 验证 + +Release 配置,关闭 Avalonia telemetry 与共享编译器,相关构建串行运行。 + +| 验证 | 结果 | +| --- | --- | +| 定向:变量快照、Preview 控件、Workspace、变量历史与保存 | 18/18 通过 | +| Editor 完整回归 | 240 通过,2 项既有布局失败 | +| Editor.Shared 完整回归 | 312/312 通过 | +| Architecture 完整回归 | 45/45 通过 | +| 完整 GalNet.slnx Release 构建 | 通过,0 错误、42 警告 | +| 验证期间 src/test 源码 SHA-256 指纹 | 一致 | +| git diff --check 与本次文档链接 | 通过 | + +定向用例包含真实挂载 Preview 控件的双向绑定、四种运行时值、显示刷新不回写、隐藏行拒绝提交、声明快照隔离,以及不创建 Workspace 的变量引用改名、一次撤销/重做、运行时改值不影响 dirty、保存后 Undo/Redo 与重开。 + +完整 Editor 的两个失败分别是 GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor(尾部留白 0 < 40)与 GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave(窄行宽度 482 >= 480)。它们与 [本次开始前的 P5 记录](../F-20261019-01-editor-game-localization/phase-5.md) 一致,且 [P6e4 已独立复验旧版本](../F-20261015-02-editor-authoring-workspace/phase-6e4.md);本次未修改相关布局或放宽断言。因此本切片验证不表示全仓回归绿灯。 + +首轮在沙箱内运行时 testhost 连接超时;在允许本机测试进程通信的环境重跑后正常执行。初轮定向的两个失败来自新增测试漏填 Entry 稳定 ID,以及错误地把 NumericUpDown 主题内部命令按钮视为声明操作;补齐 fixture、改为验证只读声明和实际当前值输入后,18 项重跑通过。 + +本地证据:`TestResults/editor-simplification/*.trx`、`Temp/editor-simplification-*.log`、`Temp/editor-simplification-source.sha256`。18 项定向属于完整回归子集,不重复累计;三个完整测试项目合计 597 通过、2 失败。 + +## 文档与边界 + +规范同步到 [变量编辑](../../docs/spec/editor-variables.md)、[历史与保存](../../docs/spec/editor-history-and-save.md) 和 [Graph](../../docs/spec/editor-graph.md)。Workspace fixture 同步到真实 ContentSession 保存基线,避免继续依赖已删除的人工 dirty 标记。 + +独立 UndoRedoHistory 仍可使用无项目的游标 checkpoint;生产 GUI 绑定内容会话,不使用游标判定 dirty。CLI 变量引用策略、事件刷新范围、缓存版本化和文件监听优化不在本次范围。已知两项 Group 布局问题留待独立任务。 diff --git a/features/F-20261021-02-image-variants/design.md b/features/F-20261021-02-image-variants/design.md new file mode 100644 index 00000000..cc8efd4c --- /dev/null +++ b/features/F-20261021-02-image-variants/design.md @@ -0,0 +1,237 @@ +# 多维图片差分图集设计 + +日期:2026-10-21 +状态:实施中;P1–P3 已实现,见第 12–14 节。P4a 场景适配及 P4b 作者界面已接入,验证记录见各阶段文件;最终集成验收仍待 P5。 + +## 1. 问题与设计取向 + +立绘和场景经常有表情、服装、时间、天气等差分。逐条指定完整图片引用,会把组合规则和缺图处理散落在剧情中。本设计将一组完整图片组织成一个差分图集,由实例记住选择,再解析为一张普通图片。 + +“图集”在此表示图片变体集合,不表示纹理打包 atlas,不替代已有 Flipbook,也不等同于供玩家回看的 Gallery。 + +核心原则:文件名便于导入,维度有稳定身份;选择状态不因缺图丢失;回退可解释、可复现;绘制复用 Layer;运行时不扫描文件系统。 + +## 2. 当前事实与接入依据 + +- [资源规范](../../docs/spec/assets.md):资源以 `.meta.id` 为正式身份,类型通过冻结的资源类型 catalog 注册;资源识别和 decoder 分离,发行资源由 PAK 提供。 +- [条目规范](../../docs/spec/entry-types.md):契约和执行模块分离,项目显式选择 schema 和实现;新增能力不能只注册编辑器 UI。 +- `src/Shared/GalNet.Core/Scene/Layer.cs`:Layer 已有 AssetId、变换、透明度、显示模式和可选 Flipbook,属于可动画场景实例。 +- `src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinLayerModule.cs`:`builtin.layers` 已负责 Layer 状态和非视频可见图层恢复,Normal 与 CatchUp 共用状态操作。 +- `src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Layers.cs`:状态修改后通知 presenter;现有 ReplaceLayer 重建 Layer,不会自动保留未来新增的差分状态。 +- `src/Shared/GalNet.Presentation.Abstractions/View/LayerRenderRequest.cs`:展示请求已接收实际图片资源 ID,无需让 renderer 理解维度匹配。 + +上述为已检查事实;下文描述新增目标,不提前写入正式 spec。 + +## 3. 作者格式与导入 + +建议新增 `.variants` JSON 资源描述文件,类型暂称 `image-variants`;它和每张图片一样具有稳定资源 ID 的 sidecar。路径示例: + +```text +Assets/Characters/alice.variants +Assets/Characters/alice.variants.meta +Assets/Characters/alice/smile_coat.png +Assets/Characters/alice/smile_skirt.png +Assets/Characters/alice/idle_coat.png +Assets/Characters/alice/idle_skirt.png +Assets/Characters/alice/weap.png +``` + +```json +{ + "version": 1, + "folder": "./alice", + "dimensions": [ + { "key": "expression", "label": "表情" }, + { "key": "outfit", "label": "服装" } + ], + "default": "idle_coat" +} +``` + +- folder 相对描述文件,只允许指向 Assets 内目录;第一版只识别该目录直接包含、且注册为 sprite 的静态图片,不递归。导入时建立各图片的正常资源身份。 +- 文件主名按 `_` 分割,位置对应维度顺序;段数可不同,没有固定维度上限。空段、空主名非法;第一版不支持段内转义 `_`。 +- 段值采用大小写敏感的 ordinal 精确比较,不做 trim 或模糊归一化。路径大小写不能成为区分两个变体的手段;仅大小写不同的文件名拒绝导入,避免跨文件系统差异。 +- 多扩展名产生同一变体键,例如 `idle.png` 与 `idle.webp`,属于错误;禁止取扫描到的第一张。 +- dimensions 可省略:按最大段数生成稳定位置 key `d1`、`d2` 等,标签显示“1”“2”等。显式提供时,key 非空且唯一,维度数不得少于最大段数;可多于当前图片段数以便后续补图。 +- label 只负责显示;剧情按维度 key 引用,改 label 不改变选择。重排维度或改 key 会影响语义,编辑器须提供影响诊断,不能视作普通改标签。 +- 选项来自该位置实际出现的段值。编辑器可显示 `1-idle` 等位置提示;配置 label 后显示“表情:idle”。 +- default 必须明确指向已存在的完整变体键。创建向导可按 ordinal 排序预选,但必须写入文件;不得以未定义的“第一张”作为运行时回退。 + +目录增删/改名刷新索引并呈现错误,避免继续悄悄使用过期目录快照。第一版不加入手工逐张维度映射或别名。 + +## 4. 选择状态与实际显示 + +每个显示实例分别保存: + +```text +图集资源 ID +请求选择:dimensionKey → value +本次被保护的维度集合 +实际解析的变体键与图片资源 ID +解析原因:Exact / Reduced / Default +``` + +请求选择是玩家指令意图;实际图片是解析结果。回退不改写请求选择。不同实例即使引用同一图集,也不共享选择。 + +显示时先用默认图已有段值初始化选择,再覆盖显式传入的初始选择。默认图没有的维度保持未选择,不擅自填该维度第一项。显式提供的初始选择维度作为本次保护集合;没有覆盖时直接显示默认图。 + +切换时仅更新指定维度。本次指定的维度即被保护,即使值与上次相同也按本次指令重新解析。不存在的维度 key 或从未出现在该维度的值属于输入错误;“各值合法,但组合缺失”才进入回退。 + +示例: + +```text +请求 smile + skirt,实际 smile_skirt +切换 expression=weap:请求 weap + skirt,实际 weap +切换 expression=smile:请求 smile + skirt,实际 smile_skirt +``` + +## 5. 精确匹配与回退算法 + +采用“删除未修改的维度后,按剩余位置精确匹配”的规则。删除不会使该维度变为通配符,也不会替换成另一个值。 + +设 Q 为完整请求映射,P 为本次保护维度集合: + +1. 查找维度映射与 Q 完全相同的变体。 +2. 若未命中,从最后一个维度向前遍历;遇到 Q 中存在且不在 P 的维度,删除该项,并用删除后的映射再做一次完全匹配。 +3. 删除是累积的,不重新组合其他子集;跳过被保护维度及未选择维度。 +4. 所有候选未命中,返回显式 default。只有最终 default 可以不满足本次保护值。 + +第一版文件名只能表达从第一个维度开始的连续前缀。因此候选若只剩第 2 维,不能拿一个单段文件当作第 2 维,也不能把它压缩成第 1 维。该候选就是未命中。这是位置式命名的明确边界,不加入隐含通配符。 + +| 请求与操作 | 候选顺序 | 结果说明 | +| --- | --- | --- | +| idle_skirt,切表情 smile | smile_skirt → smile | 有完整图优先完整图 | +| idle_skirt,切表情 weap | weap_skirt → weap | weap 存在则命中;请求仍保留 skirt | +| smile_coat,切服装 skirt | smile_skirt → 仅第 2 维 skirt | 若没有 smile_skirt,不会自动选 idle_skirt;最终默认图 | +| smile_coat_night,切第 1 维 idle | idle_coat_night → idle_coat → idle | 从后向前累积删除 | +| smile_coat_night,切第 2 维 skirt | smile_skirt_night → smile_skirt → 仅第 2 维 skirt | 不删除本次指定的第 2 维 | + +多维初始覆盖采用同一算法,保护集合可有多个成员;第一版切换指令只改一个维度,避免引入多条同时切换的批次排序语义。 + +返回结果必须包含实际变体、被削减的维度和是否使用默认图。不得用有权重的“最接近图片”替换本算法。维度顺序同时决定削减优先级,编辑器应说明:越靠后的未修改维度越先放弃。 + +## 6. 编辑器交互 + +- 资源面板识别独立图集资源;属性区编辑目录、维度 key/标签及默认图,展示扫描错误和图片数量。 +- 图集预览按维度提供选项;选项来自全图集,不因当前组合缺图而消失。允许主动选择会触发回退的值。 +- 同时展示“当前选择”“实际图片”;出现回退时显示如“服装 skirt 未命中,实际 weap”,并可查看候选链。 +- 独立的图集显示和切换条目使用资源选择器、场景对象选择器和依赖维度的选项控件。只显示已知目标的维度,无法静态确定时保留合法输入并给出不完整诊断,不伪造确定结果。 +- 描述文件及资源操作复用项目事务、撤销与保存机制;目录刷新属于事实发现,不自动形成作者修改或重写文件。 +- 发布校验检查非空图集、default、重复变体键、维度定义和全部图片引用。画布尺寸不一致给出提示;不通过尺寸相近推断锚点或裁切。 + +## 7. 资源和发布数据流 + +```text +描述文件 + 图片目录/sidecar + → 编辑器/构建共用索引编译器 + → 校验后的不可变定义:维度、选项、默认键、变体键 → sprite ID + → 本地预览或发布 PAK 中的运行时定义 + → 实例请求状态 + 纯解析器 + → 实际 sprite ID + → 普通 Layer / ILayerPresenter +``` + +目录路径仅用于作者阶段收集;发行定义不包含对目录扫描的依赖。各图片仍是独立 sprite 资源,描述资源保留原稳定 ID,发布时其 payload 编译为运行时定义。需验证本地 decoder 与打包 producer 对该类型的协作,不能把 JSON 源文件不经处理直接当作发行索引。 + +移动图片时 sprite ID 不变;通过构建重编译索引。移动目录或描述文件时,由作者资源事务修正相对目录引用。改文件名会改变变体键,须报告引用/default/请求影响,不能承诺仅靠 sprite ID 保持维度语义。 + +坏描述、缺图、资源类型不符属于加载/构建错误,不进入“组合不存在”的正常回退。打包必须包含图集引用的全部图片依赖。 + +## 8. 模块职责和显示接口 + +- Core:平台无关的定义 DTO、位置映射与纯解析器、Layer 上可选差分状态;不扫描目录、不解码平台图片。 +- Assets 与作者/构建基础设施:资源类型、定义 decoder、共享索引编译及发布依赖收集;文件系统遍历留在具备文件访问能力的适配层。 +- Contracts.Builtins:图集指令参数、schema 与纯参数校验;动态资源事实由项目校验服务补充。 +- Primitives.Builtins:加载定义、修改唯一 Layer 状态、解析实际 sprite、通知已有 presenter;目标模块以现有 Layer 模块为状态归属,避免重复注册 `builtin.layers`。 +- Editor:图集属性、预览与字段适配;使用同一解析器和索引结果。 +- Presentation/Avalonia:继续接收实际图片 ID,不自行匹配文件名。 + +建议独立作者入口(wire 名待定): + +```text +variants.show(handleId, assetId, selection?, transform?, z?, displayMode?, opacity?) +variants.select(handleId, dimension, value) +``` + +hide/move/普通连续动画继续使用 Layer 的现有能力。show 建立一个普通 Layer,附加差分状态,首次显示默认或解析后的图片。select 保留 ID、变换、透明度、层级、显示模式和已有连续属性动画,只更换实际图片及差分选择状态。 + +静态图、颜色、视频、Flipbook 和差分图集属于互斥来源。普通 `layer.replace` 明确退出图集模式并清除差分状态;不能留下“图片已换但后续 select 又切回”的悬空绑定。再次 show 同一 handle 按现有替换实例语义重置,hide 删除该实例全部状态。对非图集或不存在的 handle 执行 select 是明确执行错误。 + +第一版不为差分选择添加连续动画轨道,不附加专属交叉淡化;图片对齐沿用 Layer 的布局方式。推荐同一图集统一画布和人物基准位置,不自动补齐或调整锚点。 + +## 9. 状态、异步和恢复 + +P3 实施修订:现有 `IGameView.ValidateState` 是同步的读档预检,并在状态根替换前调用。为保持该边界且不以同步等待阻塞异步资源加载,宿主在会话准备时异步加载并验证全部图集,注入不可变 `ImageVariantsCatalog`。show/select 随后只做同步查找和原子状态修改;没有指令内 I/O,也不需要异步切换队列。定义加载的取消/失败在会话准备阶段处理,旧 scope 的任务不会持有新实例。下面原异步风险通过此预加载方案消除,不扩展或改写 Engine 的恢复协议。 + +- Layer 是唯一实例真源;在其可选差分状态中保存图集 ID、完整请求、保护集合及实际变体。AssetId 表示当前实际图片,不能在第二张状态表再维护一份平行 Layer。 +- 定义加载/校验先完成,再在受控执行边界内原子更新请求和实际图片;失败不提交一半选择。并发 select 应保持引擎执行顺序,并校验目标实例仍属于同一次 show,避免旧加载覆盖新实例。 +- Normal 与 CatchUp 共用解析和状态提交;CatchUp 不触发展示副作用,最终由统一状态恢复还原画面。 +- 存档记录请求和实际结果。恢复时加载同一图集定义,用保存的保护集合重算并验证实际结果,再沿用已有 Layer 恢复路径。保存保护集合是必要的:不同最后修改维度可能产生不同回退。 +- 同内容版本中恢复必须确定;内容更新导致维度/变体/图片缺失时报告存档与内容不一致,不静默丢请求。具体快照版本调整随实施变更评估,不提前宣称兼容旧格式。 +- 图片句柄复用现有 presenter 的 acquire/释放机制;定义句柄按实例或项目作用域明确释放。快速切换的过期加载、hide 与关闭均须验证。 + +## 10. 候选方案与取舍 + +| 方案 | 取舍 | +| --- | --- | +| 剧情直接写完整图片 ID | 简单,但组合和回退分散,不能保留独立维度意图 | +| 文件夹运行时扫描 | 作者方便,但不适合 PAK 和稳定资源身份;改为作者扫描并编译 | +| 放弃条件即通配符,选择最接近组合 | 可覆盖更多缺图,但会自动改成其他表情/服装,候选并列复杂;第一版不采用 | +| 显式逐图维度映射与优先回退树 | 能表达中间维度缺省和复杂规则,但增加作者负担;留待真实需求 | +| 独立差分 renderer 和状态表 | 重复图层动画、恢复与资源生命周期;复用 Layer | +| 多层图片拼装 | 可降低组合素材数量,但涉及遮挡、锚点和组合渲染;作为另一能力设计 | + +## 11. 风险和验证重点 + +1. 纯解析:完整命中、不同段数、从后向前累计删除、保护中间维度、多维初始覆盖、最终默认图;验证删维不变通配符、不移动位置。 +2. 意图保留:`smile_skirt → weap → smile_skirt`;多个实例各自保留选择,同值重新选择的保护语义稳定。 +3. 导入与发布:重复主名、多扩展名、大小写碰撞、空段、越界目录、缺图/坏 ID、默认图失效;本地定义与 PAK 定义结果一致。 +4. 图层集成:切图保留变换与动画;replace 清除图集模式;hide、同 handle 再 show、视频/Flipbook 互斥;缺目标不产生幽灵实例。 +5. 生命周期:异步加载失败与过期、快速切图、关闭、资源释放;CatchUp 与 Normal 最终状态一致。 +6. 存档:在削减命中和默认命中后保存,读档后继续选择仍得到相同结果;内容不一致明确失败。 +7. 编辑器:默认/选项/回退链真实挂载验证,撤销保存重开保持配置;场景静态分析识别新指令,不将其误判为无影响。 + +实施顺序见[阶段计划](phase-plan.md):先锁定算法与资源契约,再推进运行时和编辑器。已执行的验证以分阶段实施记录为准。 + +## 12. P1 已实现接口与边界 + +Core 的 `GalNet.Core.Variants` 已提供: + +- `ImageVariantsSource` / `ImageVariantDimensionSource`:带显式 JSON wire 名的作者 DTO,默认 version=1;dimensions 可省略,label 可为空值。目录边界检查仍归未来文件系统适配。 +- `ImageVariantFile`:宿主提供的直接子文件事实(文件名、sprite ID、资源类型);Core 不访问目录或解码图片。 +- `ImageVariantsBuilder.Build`:收集带 code/path/message 的诊断;任何诊断都不发布部分定义。 +- `ImageVariantsDefinition`:不可变维度/选项、变体及默认图,选项和变体按 ordinal 排序;无路径及文件系统依赖。 +- `ImageVariantsResolver.Initialize/Select/Resolve`:初始化、单维切换和显式保护集合重算;非法请求抛出 `ArgumentException`,缺组合按第 5 节回退。 +- `ImageVariantResolution`:独立冻结的完整请求、按维度顺序排列的保护集合、实际变体、Exact/Reduced/Default 原因、按删除顺序排列的维度及各次候选快照。最终 default 单独由原因和实际变体表示,不伪造成某个候选的精确命中。 + +源码位于 `src/Shared/GalNet.Core/Variants/`。P1 交付时未注册 `.variants` 或 `image-variants`,也未加入 Layer 状态及指令;运行时定义的发行序列化和加载由 P2 接续,不能将作者 DTO 直接用作发行协议。 + +P1 没有改变前文匹配规则。具体实现和验证见 [P1 记录](phase-1.md)。 + +## 13. P2 已实现接口与边界 + +- 确定资源类型为 `image-variants`,作者扩展名 `.variants`,metadata 为 `ImageVariantsAssetMeta`。 +- `LocalAssetCompiler.CompileAsync` 接收调用方拥有的逻辑文件/物理备份路径与目录快照。LocalFileProvider 安全枚举磁盘,项目资源事务投影待发布快照;两者共用作者描述解析、目录解析与 P1 定义构建,不需要创建临时项目副本做预检。 +- LocalFileProvider 在生成本地归档时,把图集源描述转换为同 ID、同类型、同逻辑路径的编译 IGameFile。图片仍是普通 sprite。编译后字节随该归档冻结,不将定义本身缓存为当前项目的可变全局状态;目录扫描按 watcher/Invalidate 失效并在编译后复核代次。 +- `ImageVariantsCodec` 的发行 payload 为 kind=image-variants、version=1,含 dimensions、default 和 variants(key/assetId);没有 folder。`ImageVariantsDecoder` 只解码该格式,不扫描文件系统。 +- `RegisterImageVariantsDecoder()` 显式注册 decoder;默认 Avalonia 游戏宿主和项目级 EditorAssetManager 已接入,后者重建 manager 时重新注册。自定义 decoder 配置的宿主仍需自行注册。 +- 项目范围 validate 与 build 使用本地编译器检查图集;GamePackageExporter 打包编译归档,包内校验沿用相同 catalog,不修改通用 PAK 格式。 +- 资源事务对移动/复制改写已知 folder;导入生成 sidecar;发布前验证投影快照,破坏默认图等情况在写盘前拒绝,撤销/重做沿用同一文件事务。显式 metadata 的类型优先于扩展名。 + +这落实了第 7 节的作者准备层:准备发生在本地资源 provider 和事务预检,不放进 runtime decoder,也不把目录扫描引入 Core。当前没有为 P3 添加执行入口或 Layer 状态。稳定资源事实见[资源规范](../../docs/spec/assets.md),验证见 [P2 记录](phase-2.md)。 + +## 14. P3 已实现接口与恢复 + +`variants.show/select` 加入现有 layers schema/执行模块,独立模块数不变;内置总数为 44(36 primitive、8 composite)。源参数由独立生成 DTO 校验,show 的 selection 是对象,transform 保持普通 Layer 的对象/嵌入 JSON 文本语义。 + +会话准备时,Assets 的 `LoadImageVariantsCatalogAsync` acquire 定义、验证所有 sprite 引用,完成后释放句柄并返回不可变 Core `ImageVariantsCatalog`。默认 Avalonia 呈现将其暴露给配置回调;Avalonia Sample、Headless Sample、Editor Preview 均将目录注入执行注册。AddBuiltins/AddBuiltinImplementations/AddBuiltinLayers 提供可选 imageVariants 参数;只使用普通图层时可不传。 + +Layer.Variants 保存图集 ID、完整请求、保护集合、实际变体键与解析原因;实际图片使用原 AssetId。select 原子修改同一 Layer 的图片与差分状态,不替换对象或停止循环动画。普通来源替换不携带 Variants,hide 删实例,重新 show 重置选择。 + +builtin.layers 节升至 v2,旧 v1 明确拒绝;GameSnapshot 外层仍为 v4。State 合约校验来源互斥和子状态结构;view.ValidateState 在 Engine 替换根之前,用已预载目录重算并核对实际结果。StateRestoreSession 在展示前再次验证,普通 Layer presenter 只接收实际 sprite ID。CatchUp 使用同一同步修改逻辑但不通知 presenter。 + +与初始设计的差异仅为异步工作的放置位置:没有指令内加载/队列,也不修改同步的 Engine 恢复预检 API。所有定义异步工作先于执行模块创建,取消/加载失败不产生场景实例;作用域间定义隔离。见 [P3 验证记录](phase-3.md)。 + +## 15. P5 示例素材适配 + +用户将真实立绘更名为 `xy_表情.png`;Sample 使用 pose / expression 两个维度,并复制 wink 为较短名称 `solo.png`,演示削减后保留表情。两张原有 classroom JPEG 使用 scene / mode / focus 三维命名,演示合法值缺组合时显式 Default 及后续精确命中。保留既有资源 ID、图片字节与 Gallery 引用;没有改写算法或引入别名机制。原始 smile/coat/weap 名称矩阵仍由纯解析和运行时测试覆盖。实际示例、验证范围和限制见 [P5](phase-5.md)。 diff --git a/features/F-20261021-02-image-variants/feature.md b/features/F-20261021-02-image-variants/feature.md new file mode 100644 index 00000000..6e2e6257 --- /dev/null +++ b/features/F-20261021-02-image-variants/feature.md @@ -0,0 +1,40 @@ +# 多维图片差分图集 + +日期:2026-10-21 + +状态:P1–P5 技术阶段已 verified,整体自查 pass-with-follow-up;本轮未完成全部人工扩展矩阵,Editor 既有布局失败和释放抖动保留。详见 [实现总结](summary.md)、[P5 验证](phase-5.md)和[自查](review.md)。用户要求每完成一阶段提交一次 Git。 + +## 目标与来源 + +用户希望用一个描述文件指向包含一组图片的文件夹,通过 `xxx_xxx_xxx` 文件名表达数量不限的有序维度,独立显示图集并切换某个维度。示例为 `smile_coat`、`smile_skirt`、`idle_coat`、`idle_skirt`、`weap`。组合不存在时,从后向前逐个削减未修改的维度,最终回退默认图,方便立绘和场景差分创作。 + +讨论后采用的方向:文件名自动识别、可命名维度、保留用户选择状态、确定性回退、显式默认图、底层复用图层显示与资源系统。 + +## 范围 + +- 描述文件与目录导入、维度/选项识别及校验。 +- 图集显示与单维切换;请求状态和实际图片分离。 +- 纯解析规则及可解释的回退结果。 +- 编辑器图集配置、选择和预览;稳定资源引用与打包。 +- 游戏状态、读档恢复、CatchUp 和现有图层能力的接入边界。 + +## 非目标 + +分层纸娃娃、贴图合并/纹理 atlas、序列帧动画、任意标签查询、相似度评分、手写逐图映射和回退树、CG Gallery 自动解锁、维度值连续动画。 + +## 验收 + +1. 用户示例可导入,段数不同合法,无需准备所有组合。 +2. 单维切换保留其他选择;经过 `weap` 后仍能恢复之前服装。 +3. 回退逐步、稳定且可诊断,不依赖目录枚举顺序,不自动替换成其他选项。 +4. 不同显示实例的选择独立;换差分不改变图层身份和变换。 +5. 发布包无需原始文件夹;资源移动保留正式 ID,坏引用和重名不会静默被忽略。 +6. 存读档和 CatchUp 保留完整请求状态及实际显示结果。 + +## 约束与待评审项 + +- [设计](design.md)是目标方案,不代表现有系统已支持。 +- 文件扩展名 `.variants`、资源类型 `image-variants`、指令 `variants.show/select` 已在 P2/P3 固定。 +- 第一版拟采用即时切图,沿用图层移动/动画能力;专属差分交叉淡化后续单独设计。 +- [实施阶段计划](phase-plan.md)记录各阶段状态;[P1 实施与验证](phase-1.md)、[P2 实施与验证](phase-2.md)、[P3 实施与验证](phase-3.md)记录已完成能力及证据。 +- 图集资源、发布、指令、CatchUp 与存档恢复已接入;[P4a](phase-4a.md) 已接入场景静态分析、基础资源/对象选择与引用身份,[P4b](phase-4b.md) 已完成创建、配置、差分预览及维度/值联动。[P5](phase-5.md) 完成立绘/场景示例、真实导出安装与原生 SceneHost 截图验证、回归和正式文档收尾;限制与后续人工范围保持明确。 diff --git a/features/F-20261021-02-image-variants/phase-1.md b/features/F-20261021-02-image-variants/phase-1.md new file mode 100644 index 00000000..43d6ac56 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-1.md @@ -0,0 +1,44 @@ +# P1:定义与纯解析 + +日期:2026-10-21 +状态:verified。 + +## 交付 + +`GalNet.Core.Variants` 提供作者描述 DTO、宿主图片事实、不可变定义、带位置的构建诊断和纯解析器。没有注册资源类型、游戏指令或编辑器入口,没有修改现有 Layer 行为。 + +文件名只表达连续前缀,匹配过程始终保留维度原位置;从后向前累计删除未保护维度,不做通配符或其他值替换。完整请求与实际图片分别保存,经过单段特殊表情后仍可恢复之前服装。相同值重复选择会重新设置本次保护维度。 + +构建器按 ordinal 生成稳定顺序;显式维度不足、重复维度、空段、重复主名/大小写碰撞、坏 default、无资源 ID、非 sprite 等产生诊断,且不返回半份定义。自动维度为 d1/d2 等,未设标签时显示位置编号;没有人为固定维度数量上限。 + +作者 DTO 可用 System.Text.Json 读写;只包含描述字段。运行时索引序列化与 decoder 留 P2,避免现在引入未经验证的发行协议。 + +## 验证证据 + +- Core 测试共 **148 通过,0 失败,0 跳过**,包含新增图集 **32** 项用例。 +- Architecture 测试 **45 通过,0 失败,0 跳过**。 +- 四份新增 Core 源码与新增测试文件的 `dotnet format whitespace --verify-no-changes` 通过;`git diff --check` 通过。 +- Core 项目在测试命令中完成编译。P1 仅新增纯 Core API,没有运行全解决方案/图形人工验证,不宣称运行时图集或 UI 已可用。 + +验证命令(仓库根目录,测试需在允许本地进程通信的执行环境运行): + +```powershell +dotnet test test/GalNet.Core.Tests/GalNet.Core.Tests.csproj --no-restore --logger 'trx;LogFileName=core-p1-final.trx' --results-directory artifacts/image-variants-p1 --verbosity quiet -p:WarningLevel=0 +dotnet test test/GalNet.Architecture.Tests/GalNet.Architecture.Tests.csproj --no-restore --logger 'trx;LogFileName=architecture-p1.trx' --results-directory artifacts/image-variants-p1 --verbosity quiet +``` + +最终 Core 回归仅关闭编译警告输出,未关闭分析器或跳过编译;前次未抑制警告的编译成功,Core 既有 API 命名警告和测试期望数组的 CA1861 建议不影响验证结果。TRX 留在忽略的 artifacts/image-variants-p1,未加入源码提交。 + +覆盖:用户五张示例;三维逐项删除;保护中间维度不压缩位置;不自动切到其他表情;多维初始覆盖;同值重新选择;未知维度和值;空请求与空保护集合;不完整默认图;源对象、请求和实例隔离;目录枚举顺序;十六维与 ar-SA 下的不变编号;错误诊断及作者 JSON wire 字段。 + +## 环境问题与处理 + +第一次基线测试在沙箱中因 testhost 90 秒未连接而中止,没有执行用例;增加到 180 秒也不能恢复本地连接。沙箱内格式检查明确抛出命名管道 `UnauthorizedAccessException`。同一份已编译测试在获准的沙箱外执行后,新增 31 项立即通过;补充十六维用例后的最终全量为 148 项通过。两次沙箱连接失败不计为测试通过或产品断言失败。 + +这与已有 `.agents/lessons/L-20260922-01-dotnet-build-sandbox.md` 的进程间管道权限经验相关;本次验证说明 testhost 和格式工具也可能受限。不要通过修改业务代码或仅延长超时处理;应在许可范围内使用可通信的执行环境,并记录真实通过证据。 + +## 状态与后续 + +P1 退出条件已满足,无匹配算法偏差。P2 接入目录边界检查、资源类型/decoder、本地与 PAK 编译定义和资源操作事务;P3–P5 仍未开始。 + +提交只包含本 feature 文档、Core/Variants 与对应测试。工作区已有 `F-20261015-02-editor-authoring-workspace/phase-plan.md` 修改保留,不混入本阶段。 diff --git a/features/F-20261021-02-image-variants/phase-2.md b/features/F-20261021-02-image-variants/phase-2.md new file mode 100644 index 00000000..ec396d48 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-2.md @@ -0,0 +1,59 @@ +# P2:资源编译与发布 + +日期:2026-10-21 +状态:verified。 + +## 交付 + +- 注册 `.variants` / `image-variants` / `ImageVariantsAssetMeta`。作者 JSON 与编译 payload 分离,严格检查重复/未知字段。 +- Assets 的 `LocalAssetCompiler` 接收文件和目录快照,调用 P1 纯构建器;LocalFileProvider 在返回归档前编译图集,保留正式资源 ID、metadata 和逻辑路径,统一校正过时的 metadata 路径。 +- 编译定义只包含维度、默认变体和 sprite ID 映射;普通 PAK 可直接打包和解码,无需原始文件夹。新增 decoder 与显式注册扩展,接入默认 Avalonia 宿主和每次重建的 EditorAssetManager。 +- 磁盘枚举拒绝链接/重解析点,逻辑目录解析拒绝 Assets 越界;缺 sidecar/图片、错误类型、重复变体、坏 default 等拒绝归档,不自动修复。目录扫描和编译发布检查代次,并提供只读索引失效入口 Invalidate。 +- 项目 validate/build 检查图集并报告 variants.invalid。导出包内校验修正为使用调用方相同 catalog,防止自定义类型在包验证时丢失。 +- 资源事务移动时维护 folder,复制完整目录生成独立 ID 并指向副本,导入同批创建 sidecar。投影后快照复用同一编译器预检;破坏默认图的重命名/删除在写盘前拒绝。修改与原操作一起撤销/重做,不创建额外历史。 + +没有添加图集 show/select 指令、Layer 选择状态或专属编辑 UI,后续仍为 P3–P5。 + +## 设计落点 + +运行时 decoder 不扫描目录。已有 LocalFileProvider 就是开发资源的文件系统适配层,因此作者准备在此进行;导出沿用其编译 IGameFile,通用 PakBuilder/Archive 格式不变。事务预检直接使用逻辑文件到保留备份文件的映射,无需复制全部图片到临时目录。 + +发行 payload version=1,kind=image-variants,字段为 dimensions、default、variants;每个变体保存 key 和 assetId。选项在解码验证时推导,避免存储两份独立真源。目录扫描索引缓存按代次失效,已经 acquire 的定义仍遵循 AssetManager 一般句柄缓存语义;Editor 通过项目/资源刷新重建 manager。 + +## 验证 + +最终相关回归共 **633 项通过,0 失败,0 跳过**: + +| 测试项目 | 通过 | +| --- | ---: | +| Core | 148 | +| Assets | 54 | +| Storage.FileSystem | 21 | +| Architecture | 45 | +| Editor.Shared | 365 | + +本阶段新增 **30 项**:Assets 20、Editor.Shared 10。覆盖实际 LocalFileProvider → PakBuilder → PakFileProvider → AssetManager decoder,移开作者目录后解析与图片获取;路径分隔符/父路径/越界/链接;缺 metadata/图片、类型错误、重复/大小写变体和坏 default;索引失效与取消/销毁;移动描述或目录的身份保留、大小写改名、复制、导入、撤销重做;显式 metadata 类型在改扩展名后仍被验证;坏定义保护旧输出;自定义 catalog 完整导出和包内验证。 + +完整解决方案在 `artifacts/image-variants-p2/build` 隔离目录构建通过(0 错误;保留既有 Avalonia AVLN3001 警告)。普通输出目录构建受运行中的 Editor/.NET Host 文件锁影响失败,未关闭这些程序,也未将其计为源码编译错误。格式检查只针对本阶段 C# 文件;检查中发现的对象初始化换行已修正。后续最终隔离构建使用同一份源码,未夹入其他任务的业务修改。 + +主要命令: + +```powershell +dotnet build GalNet.slnx --artifacts-path artifacts/image-variants-p2/build --verbosity quiet -m:1 -p:WarningLevel=0 -p:NuGetAudit=false --ignore-failed-sources +dotnet build GalNet.slnx --no-restore --artifacts-path artifacts/image-variants-p2/build --verbosity quiet -m:1 -p:WarningLevel=0 +dotnet test test/GalNet.Editor.Shared.Tests/GalNet.Editor.Shared.Tests.csproj --no-restore --verbosity quiet -p:WarningLevel=0 --logger 'trx;LogFileName=editor-shared-p2-final.trx' --results-directory artifacts/image-variants-p2 +``` + +其余四项目使用隔离产物,以 `dotnet test --no-build --no-restore --artifacts-path artifacts/image-variants-p2/build` 验证。TRX 与构建日志保留在忽略的 artifacts/image-variants-p2;未把日志、构建产物或测试临时项目提交到 Git。测试具备真实本地和发布路径,不将手工序列化的定义替代 producer 验证。 + +## 过程中修正 + +第一次资源回归发现提取元数据读取后,错误消息丢失了 sidecar 文件名;已恢复按文件包装异常并通过原回归。自定义 catalog 新测试的最初两次失败均为测试夹具配置错误(非法扩展名以及辅助读取未传入自定义 catalog),修正后新测试和完整 Shared 回归均通过。 + +测试与格式工具继续在允许本地进程通信的执行环境运行,沿用 P1 的沙箱限制结论。普通 Editor 输出锁与既有 `.agents/lessons/L-20261013-01-running-editor-output-lock.md` 一致,后续保持隔离构建,不重复尝试写入已锁输出。 + +## 文档与后续 + +已同步[资源规范](../../docs/spec/assets.md)的已验证事实,并更新[设计](design.md)和[阶段计划](phase-plan.md)。无匹配算法偏差。 + +P3 接续运行指令、Layer 唯一状态、CatchUp 和存读档;图集指令的 wire 名届时核定。本阶段不宣称游戏显示链或专属 GUI 已完成。已有其他 feature 的 phase-plan.md 改动保留,不混入提交。 diff --git a/features/F-20261021-02-image-variants/phase-3.md b/features/F-20261021-02-image-variants/phase-3.md new file mode 100644 index 00000000..69f586a5 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-3.md @@ -0,0 +1,56 @@ +# P3:图层指令与恢复 + +日期:2026-10-21 +状态:verified。 + +## 交付 + +- `variants.show` / `variants.select` 与生成式 DTO、schema、参数业务校验加入既有 layers 模块,内置总数 44,不新建平行场景状态表。局部 schema 选择和完整执行覆盖仍可用。 +- `LayerVariantsState` 保存请求意图与解析事实;实际图片仍由 Layer.AssetId 表示。切换保留 Layer 身份、布局、透明度和活动循环动画;普通来源替换清除图集绑定,hide/re-show 遵循既有实例生命周期。 +- Normal/Quick 提交逻辑状态后通知普通 presenter,CatchUp 提交同样事实且不触发展示;实例非阻塞、不可跳过,batchId 保留。 +- builtin.layers 状态节升至 2,旧 v1 明确拒绝,GameSnapshot 外层仍为 4。结构校验拒绝视频/颜色/Flipbook 冲突和坏差分子状态;模块预检重新解析并核对实际图片、变体键和原因,坏内容在 Engine 替换状态根前拒绝。 +- 恢复阶段再次复核内容并重建普通图层;恢复后可继续选择,Reduced/Default 状态不会丢弃服装等未显示的选择。 +- 默认 Avalonia 呈现、Avalonia Sample、Headless Sample、Editor Preview 已接入会话目录准备和注入;自定义宿主可用显式注册参数接入。 + +## 设计修订 + +现有 IGameView.ValidateState 是同步的读档预检,位于根替换之前。为保留这个边界,不扩展或同步阻塞异步恢复协议,定义改为会话准备时通过 `LoadImageVariantsCatalogAsync` 异步加载全部图集并校验 sprite 引用,释放句柄后返回不可变 ImageVariantsCatalog。show/select 只做同步查找/解析/状态提交,无指令内 I/O 或异步队列。 + +因此不存在旧定义加载晚于新 show 而覆盖新实例的回调;取消或资源失败发生在会话准备阶段,不返回半份目录,也不产生部分 Layer。目录按 scope 隔离,播放中采用准备时的内容快照。P1 匹配算法未变,未改 Engine 生产代码。 + +## 验证 + +相关七项目共 **1097 通过,0 失败,0 跳过**: + +| 项目 | 通过 | +| --- | ---: | +| Core | 148 | +| Assets | 58 | +| Primitives.Builtins | 140 | +| Runtime | 96 | +| Presentation | 243 | +| Editor.Shared | 365 | +| Architecture | 47 | + +本阶段新增 **27 项**,其中 Builtins 23、Assets 4。验证涵盖公开指令三模式、实例/选择隔离、变换与动画属性保留、真实活动循环实例、replace/show/showColor/hide、缺目标/坏输入/取消原子性、Reduced/Default 存读档与继续切换、保护集合持久化、旧状态版本、内容/实际结果/请求/保护/来源冲突在换根前拒绝、资源 ID 规则,以及预载目录的依赖校验、作用域隔离和取消。 + +完整解决方案在隔离目录 `artifacts/image-variants-p3/build` 构建通过,0 错误,保留 4 项既有 Avalonia AVLN3001 警告。19 份相关 C# 文件的 whitespace 检查通过;发现的测试对象初始化换行已修正并单独复验。未新增图形代码,现有 Presentation 全量回归验证底层 Avalonia presenter;真实图集素材的端到端 GUI 人工验收仍留 P4/P5,不以这些测试宣称已完成作者 UI。 + +主要验证方式: + +```powershell +dotnet build GalNet.slnx --artifacts-path artifacts/image-variants-p3/build --verbosity quiet -m:1 -p:WarningLevel=0 -p:NuGetAudit=false --ignore-failed-sources +dotnet build GalNet.slnx --no-restore --artifacts-path artifacts/image-variants-p3/build --verbosity quiet -m:1 -p:WarningLevel=0 +dotnet test test/GalNet.Primitives.Builtins.Tests/GalNet.Primitives.Builtins.Tests.csproj --no-restore --verbosity quiet -p:WarningLevel=0 +dotnet test test/GalNet.Editor.Shared.Tests/GalNet.Editor.Shared.Tests.csproj --no-restore --verbosity quiet -p:WarningLevel=0 +``` + +其余五项目使用相同隔离构建产物,`dotnet test --no-build --no-restore --artifacts-path artifacts/image-variants-p3/build`;TRX、构建日志留在忽略的 artifacts/image-variants-p3。 + +验证基于当时工作区源码,其中并存其他任务的 Runtime/调试模块改动。本阶段不修改或提交这些文件,也不混入其他任务已暂存的解决方案/架构变更;提交使用显式路径范围。已有通用测试随该工作区验证,但不是本阶段新增交付。 + +## 文档和后续 + +已同步[资源规范](../../docs/spec/assets.md)、[指令规范](../../docs/spec/entry-types.md)、[运行时规范](../../docs/spec/runtime.md)和[新表现层接入手册](../../docs/manual/new-presentation-layer.md)。设计偏差、实际接口和状态版本已回写 design.md / phase-plan.md。 + +P4 继续图集配置/预览、动态选择控件与场景查询适配;P5 继续真实素材示例、发布运行和完整收尾。当前不提供图集维度动画轨道或专属切图淡化。 diff --git a/features/F-20261021-02-image-variants/phase-4a.md b/features/F-20261021-02-image-variants/phase-4a.md new file mode 100644 index 00000000..2585e378 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-4a.md @@ -0,0 +1,50 @@ +# P4a:场景分析与基础字段接入 + +日期:2026-10-21 +状态:verified(本切片;P4 整体未完成) + +## 范围与结果 + +P4 拆为 P4a 与 P4b,分别提交并验证。P4a 建立后续联动字段所需的场景事实,并接通基础资源和对象选择;P4b 继续完成目录创建、配置、差分预览、初始选择与维度/值联动、影响诊断及其保存流程。没有修改匹配算法、运行时指令或发布格式。 + +- `SceneTarget.Resource` 仍表示实际 sprite;新增不可变 `SceneImageVariants` 保存图集候选来源、来源确定性和可知的完整选择结果。查询直接复用运行时纯 resolver,保留请求、保护维度和回退链。 +- show 创建 Layer 及原有连续属性;select 改变可知图片事实,保留 Locator、存在性及属性值。普通 show/color/video/replace 清除图集绑定,hide 移除,re-show 重置请求。坏选择或未知场景语义不保留权威图集请求。 +- 分支请求不同但图集相同时保留共同来源、放弃唯一请求;来源不同保留可能 ID 集合。条件性普通换图使图集模式仅为可能。后续 select 无法安全重建请求时不推测实际图片,标记不完整。 +- `ProjectSceneTargets` 在后台读取编译 payload,解码不可变定义,完成后换代;每代最多 256 个图集、每个 payload 最多 1 MiB,超出部分保持不完整。坏定义、缺资源、错误类型缓存到下次刷新。取消一个查询者的等待不取消项目共享准备;刷新及释放使迟到读取与旧选择失效。 +- show 的资源字段筛选 `image-variants`,变换使用既有控件;select 的对象字段使用场景 Picker,排除已知普通图层,保留可能的图集对象并对不唯一来源显示提示。图集 show 的内部 handle 自动分配且隐藏,复制通过声明槽位统一重映射 select 与 Timeline 引用。补齐四语指令、维度、初始选择及来源提示。 +- 基础字段确认复用项目历史;验证撤销/重做、保存重开与未知 JSON 大整数保留。查询和准备不写作者数据。Timeline 能定位图集 Layer 并采样已有属性,没有新增差分切换事件。 + +## 验证证据 + +日志与 TRX 位于忽略目录 `artifacts/image-variants-p4a/`。 + +| 检查 | 结果 | +| --- | --- | +| 新增 Shared 场景用例 | 16 项通过,包含在 Shared 回归中 | +| 新增 Editor 用例 | 2 项通过:异步/取消/坏资源/刷新/释放代次;真实挂载资源与对象选择、单历史、保存重开 | +| Editor.Shared 全量 | 383 通过、1 失败,共 384;失败为并行调试任务的 `DebugRoutePlannerTests.UnorderedSelectedChainIsPreferredWithoutChoosingTheShorterUnselectedRoute` | +| Editor.Shared 排除该并行测试类 | 381 项通过 | +| Editor 全量 | 254 通过、2 失败,共 256;失败与既有记录一致,见下文 | +| Architecture | 47 项通过 | +| 完整隔离解决方案构建 | 失败于并行修改的 `SampleCleanupTests.cs:75`:`SampleGameSessionService` 新构造函数缺少 `services` 参数;4 个既有 Avalonia 警告 | +| Editor 及其测试定向构建 | 成功,使用隔离产物,未关闭正在运行的 Editor | +| 本阶段源码/测试 whitespace | 修正测试字典初始化换行后验证通过 | + +Editor 两项失败:`GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor` 的滚动尾部期望至少 40、实际 0;`GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave` 的行宽期望小于 480、实际 482。相关布局不在本次改动中,未修改断言或实现来消除失败。 + +主要命令(参数省略日志重定向): + +```powershell +dotnet build GalNet.slnx --artifacts-path artifacts/image-variants-p4a/build --verbosity quiet -m:1 -p:WarningLevel=0 -p:NuGetAudit=false --ignore-failed-sources +dotnet test test/GalNet.Editor.Tests/GalNet.Editor.Tests.csproj --artifacts-path artifacts/image-variants-p4a/build --no-restore --verbosity quiet -p:WarningLevel=0 +dotnet test test/GalNet.Editor.Shared.Tests/GalNet.Editor.Shared.Tests.csproj --artifacts-path artifacts/image-variants-p4a/build --no-build --no-restore --filter 'FullyQualifiedName!~DebugRoutePlannerTests' --verbosity quiet +dotnet test test/GalNet.Architecture.Tests/GalNet.Architecture.Tests.csproj --artifacts-path artifacts/image-variants-p4a/build --no-build --no-restore --verbosity quiet +``` + +新增挂载用例最初的失败是测试夹具断言问题:图集使用 DTO 表单而非旧 ParameterFields;对象搜索需明确选择候选而非 None;保存 JSON 的空白变化应按语义比较。修正后新增两项独立重跑通过,完整 Editor 运行也通过这两项。 + +## 剩余范围与限制 + +P4b 尚未开始;当前不能通过专属图集面板创建描述、配置维度/default 或预览差分。初始 selection 和 select 的维度/值仍使用通用字段。后续需要覆盖图集定义草稿、Save All、配置变更的影响诊断、动态选项与打开期间失效,及整条创建→预览→剧情选择流程。实际运行的原生图像人工验收、示例和导出集成留 P5,不能用本切片测试宣称整项图集功能完成。 + +工作区存在并行调试任务的代码、lock 文件和四语调试文案。提交只包含本切片内容,保留其他任务的文件与同文件改动。 diff --git a/features/F-20261021-02-image-variants/phase-4b.md b/features/F-20261021-02-image-variants/phase-4b.md new file mode 100644 index 00000000..7796f879 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-4b.md @@ -0,0 +1,34 @@ +# P4b:图集配置、预览与联动字段 + +日期:2026-10-22 +状态:verified(P4 作者流程完成;整项 feature 最终验收仍待 P5)。 + +## 实现范围 + +- Assets 菜单从当前/选中目录创建图集,按稳定顺序选择显式默认图,省略 dimensions 时从文件名推断。描述文件及 sidecar 通过现有 CreateFile 事务,图片仍通过现有 Import 事务创建 sidecar;扫描不写文件。 +- 新增属性面板:相对目录、默认图、维度 key/label、添加/移除/上移/重新推断、扫描诊断、原始 JSON 修复。维度身份变化有引用影响提示,不迁移剧情引用。 +- 配置编辑直接投影项目拥有的 ProjectTextBuffer。应用配置形成一次文件历史;Save All 也可提交有效草稿,关闭属性面板不丢草稿。扩展名或 sidecar 声明为 image-variants 的文本都在提交及 Save All 验证,坏 default、维度和目录被拒绝,保留原文。项目释放后的旧缓冲只读。 +- 预览列出所有轴的全部值,保留请求及保护维度,复用纯 resolver 显示实际 bitmap、结果原因及候选链。smile_skirt→weap→smile 保留 skirt。刷新/语言切换/预览不写历史;定义改变使请求失效时诊断,显式重置才回默认。图片加载及旧预览行按代次拒绝迟到发布,释放时解绑并释放 bitmap。 +- show 初始选择使用多轴选择与默认继承;保留未知选择键及 JSON 修复。select 的维度/值跟随对象及维度联动,手动字符串入口保留已有未知值。来源不唯一时不生成确定选项,后台准备与作者/资源变代刷新控件并拒绝旧控件提交。 +- 场景查询读取该代捕获的图集工作字节:Apply/Undo/Redo 后无需先保存到磁盘即可获得新定义;尚未提交的描述草稿只用于属性面板预览。资源读取和 Core 解析规则不变,没有把文件系统逻辑加入 Core。 +- 冷启动选择 lease 区分正常资源准备完成与作者/资源代次变化;同一快照的准备完成可刷新结果,等待期间的内容变更/刷新/关闭仍使其过期。新增专门受控加载测试,并记录 agent lesson。 +- 四语新增 18 个 UI 键;同步 editor-files、editor-scene-targets 规范和图集操作手册。 + +## 验证 + +最终日志与 TRX:`artifacts/image-variants-p4b/`;完整解决方案日志位于 `artifacts/image-variants-p4b-solution.log`。 + +- 新增 Shared 用例 9 项:稳定推断/default/全轴值、无写入、无效默认图、未知/重复 JSON、目录越界、缺 sidecar、跨扩展名重名、描述路径不能绕出 Assets。 +- 新增 Editor 用例 3 项:实际挂载的创建→配置→预览→历史→Save All→重开;关闭面板仍校验坏草稿、工作定义与旧控件失效、初始选择、未知 JSON 保留;冷启动准备与作者修改失效。 +- 初次新增扫描 7 项及挂载 2 项通过,补充边界和初始选择后重跑。首次完整 Editor 回归发现新菜单使旧测试按两项定位失效;改为通过命令识别创建菜单并确认图集入口。 +- 初始选择测试在 history.Clear 后持有旧控件,被正确拒绝;调整为清历史后再获取控件。失败测试未关闭项目引发后续 dispatcher 回调访问已释放服务,补充 finally 关闭项目。上述相关五项定向重跑全部通过。 +- 完整解决方案隔离构建通过(首次三个既有 Avalonia 警告,最终增量复核 0 警告/0 错误)。Editor.Shared 全量 393 项通过,Architecture 47 项通过。 +- 最终 Editor 全量:257 通过、3 失败(共 260);两项为 P4a 已记录的 Group 滚动尾部与属性行宽布局断言,另一项为未修改的 `TimelineSelectionNeverNormalizesOrRewritesIncompleteOrInvalidAuthorJson` 在 HeadlessUnitTestSession.DisposeAsync 出现 NullReferenceException。其七个参数用例与本阶段作者流程等共 12 项单独重跑通过;排除两项已知布局失败后,其余 Editor 258 项完整重跑全部通过,未再出现释放异常,未据此宣称最初全量全绿。 +- 最终相关回归合计 698 项通过:Editor.Shared 393、Editor 258(排除上述两个已知布局失败)、Architecture 47;新增 Shared 9 项、Editor 3 项全部包含在通过结果中。实际挂载测试还等待真实 bitmap 解码完成并确认预览非空,验证四语刷新不写作者缓冲及空维度对象的保守诊断。 +- 本阶段源码与测试 whitespace verify、四语资源 JSON 解析/键集合(各 952 keys)、相关文档相对链接检查通过。提交仅包含本阶段文件;另一 feature 的 phase-plan 改动保留。 + +构建/测试使用 `--artifacts-path artifacts/image-variants-p4b/build`,不写正在运行的 Editor 默认输出,不关闭用户程序。测试命令使用 `--logger trx` 留存证据。 + +## 剩余范围 + +本阶段不增加交叉淡化、图片叠加、Timeline 差分事件或全项目引用迁移。大型目录扫描目前是同步作者索引,未宣称无限规模性能;实际规模、内存、打包脱离目录运行及原生人工验收继续在 P5 验证。P4 整体通过不等于本 feature 全部验收完成。 diff --git a/features/F-20261021-02-image-variants/phase-5.md b/features/F-20261021-02-image-variants/phase-5.md new file mode 100644 index 00000000..938b7f79 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-5.md @@ -0,0 +1,74 @@ +# P5:示例、发行集成与收尾 + +日期:2026-10-23 +状态:verified(技术集成范围);整体自查 pass-with-follow-up,未扩大为全部人工矩阵通过。 + +## 交付范围 + +- `GameTestCase` 默认 intro 直接运行两个图集,保持原有四个 Group、两条分支、音视频和 Gallery 示例。 +- 五张用户更名后的 `xy_*.png` 放入独立 `Assets/Variants/xy/`,第一维 pose、第二维 expression。额外 `solo.png` 逐字节复制 wink,用于验证不同段数与 Reduced;没有新绘制素材。 +- 两张原有 classroom JPEG 放入独立 `Assets/Variants/classroom/`,改名为 classroom_idle_empty / classroom_speech_teacher,维度 scene / mode / focus。保留两个背景与 shock 立绘的既有 ID、Gallery item 100;为此前无 sidecar 的图片创建正式 ID。 +- 两个描述文件分别为 `xy.variants`(ID 尾部 0020)、`classroom.variants`(尾部 0021)。所有原有七张图片的 Git blob hash 与 HEAD 原素材一致;solo 与 wink 相同。 +- 增加中英文对话检查点:Exact → Reduced → 恢复保留的 shy;背景 Default → 继续切到 teacher;hide/re-show 回默认;既有循环动画期间切到 wink。检查点与构建命令见 [Sample README](../../GameTestCase/README.md#image-variants-walkthrough)。 +- 本阶段不改业务算法、状态协议或 renderer。更新现有 Sample 测试的 catalog 准备、存档夹具的 variants 筛选,以及默认呈现测试空资源 stub 的枚举接口。 + +## 自动与原生验证 + +新增 `ImageVariantsSampleTests`:三个正式导出用例分别使用 Normal / Quick / CatchUp。通过 GamePackageExporter → GameInstallation → PAK provider → AssetManager 加载,安装 Assets 下只有 PAK;本地/发行定义和所有变体图片逐字节一致。使用实际编译 intro 条目,覆盖 Reduced 和 Default 快照恢复后继续选择、第二实例隔离、移动、循环动画、hide/re-show 和最后一个表情选项。 + +既有 `ModularSaveRestoreTests` 继续从真实 intro 筛选指令,经过 FileSaveService 写新存档文件、加载、模块恢复,核对立绘/背景的实际差分与暂停音轨。内容不一致在换根前拒绝的故障矩阵沿用 P3 测试,本次 Builtins 回归包含这些测试。 + +新增显式 `ImageVariantsNativeSampleTests`:复制真实素材形成最小原生夹具,使用项目的 Screenshot schema,经正式导出安装后**删除整个临时作者项目**,再启动独立 Avalonia Sample 进程。通过外部调试 seek/step 执行五个检查点,截图前允许 bitmap 加载,截图后等待原子文件发布。检查 PNG 大小和五帧不同,人工逐张查看原生 SceneHost 渲染结果:shock、solo/wink、恢复 shy、蓝色 classroom、重显示 idle 均符合预期。最后 CloseMainWindow,等待 exit 0 并清理临时内容与 Pictures 输出。 + +截图是原生 SceneHost 的渲染产物,不是操作系统桌面截图;没有据此宣称人工点击所有菜单、全屏/高 DPI、多平台与长时间压力矩阵均已完成。Editor 创建→配置→预览→撤销/保存→重开的真实挂载证据来自 P4b,并纳入本次 Editor 回归。 + +日志、TRX 和截图留在忽略的 `artifacts/image-variants-p5/`;测试源码和下列数值为可复现的持久记录。 + +| 验证 | 结果 | +| --- | --- | +| Headless CLI validate / build | exit 0;4 个 compiled Group;保留结构化字段的 localization.field.opaque / incomplete 警告 | +| 完整解决方案隔离构建 | 最终通过,0 error、1 个既有 Avalonia AVLN3001 警告 | +| Core | 148 passed | +| Assets | 58 passed | +| Builtins | 140 passed | +| Runtime | 112 passed | +| Presentation | 243 passed | +| Architecture | 47 passed | +| Editor.Shared | 393 passed | +| Integration(非显式) | 最终 117 passed | +| 原生显式 PAK 渲染 | 1 passed,五张不同 PNG,正常退出 | +| Editor | 完整运行 257 passed / 3 failed;两项既有布局失败,另一次 Headless DisposeAsync NRE 的用例单独复跑 1 passed | +| 本阶段五个测试文件 whitespace | verify-no-changes 通过 | +| 文档链接与 Git whitespace | 178 个本地链接目标存在;本阶段 diff --check 通过 | + +首次完整构建被并行调试面板测试缺少 Avalonia 扩展引用阻断;待其自己的修改补齐后完整重建通过,没有修改/提交这些并行文件。第一次 Integration 全量 115 passed / 2 failed:一个空资源 stub 的 GetFilesAsync 抛 NotSupportedException,另一个存档测试只筛选 layer.* 导致遗漏新 variants.show;修正上述测试夹具后全量 117 通过。新用例早期编译引用、编译 ID 的 `#1` 后缀、原生夹具开发语言与异步截图等待等测试接线错误已修正,不将这些失败冒充产品回归。 + +Editor 两项布局失败为 P4a/P4b 已记录的 `GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor`(scroll tail 0,要求 ≥40)与 `GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave`(宽 482,要求 <480)。另一个用例 `EntryFieldsUseProjectOrderAcrossMountedPagesAndSurvivePageCloseAndReopen` 在 Avalonia.Headless.HeadlessUnitTestSession.DisposeAsync 抛 NRE;相同用例单独复跑通过,根因未确认,不隐去首轮失败、不声称 Editor 全量全绿。五个图集挂载/查询测试在本次完整运行均通过。本轮没有改动 Editor 业务/UI。 + +八个非 Editor 回归项目共 1258 passed,另有原生显式 1 passed;Editor 结果单列。最后亦重建了默认 `GameTestCase/Output`(4 Group),方便直接运行新示例。 + +## 规模与内存观测 + +在 Windows / .NET 10.0.12,Debug 隔离产物,合成 16×16×16 共 4096 变体、每轴 16 个选项,10000 次第二维选择:最终回归构建 3.8767 ms / 分配 2699584 bytes,选择 69.6913 ms / 分配 26080000 bytes。测试输出 JSON,可用同一测试复现;受 JIT 与并行负载影响,不设置虚构的性能门槛。 + +原生五检查点测试关闭前 WorkingSet 1169068032 bytes、PrivateBytes 1164673024 bytes、PeakWorkingSet 1823805440 bytes。宿主预载 11 张 sprite 并预热 7 个 effect program;此数值包含完整宿主、图像、渲染器、截图和运行库,不能归因为 catalog,也不能当成每张图片内存成本或泄漏证据。关闭日志记录 scope 正常释放、最终 exit 0;没有测量进程内所有缓存的 GC 留存,也没有宣称大规模原始图片的预加载成本可忽略。 + +纯索引测试不含 4096 张真实图片解码,也不代表数千选项的 GUI 交互延迟。按当前宿主预加载策略,真实素材容量仍需作者控制;大规模图片/菜单的专门性能矩阵作为后续测量范围保留。 + +## 复现入口 + +```powershell +dotnet build GalNet.slnx --artifacts-path artifacts/image-variants-p5/build --verbosity quiet -m:1 -p:WarningLevel=0 -p:NuGetAudit=false --ignore-failed-sources +dotnet artifacts/image-variants-p5/build/bin/GalNet.Editor.Headless/debug/GalNet.Editor.Headless.dll validate GameTestCase +dotnet artifacts/image-variants-p5/build/bin/GalNet.Editor.Headless/debug/GalNet.Editor.Headless.dll build GameTestCase --output G:/program/GalDotNet/artifacts/image-variants-p5/content +dotnet test test/GalNet.IntegrationTests/GalNet.IntegrationTests.csproj --no-build --no-restore --artifacts-path artifacts/image-variants-p5/build +dotnet test test/GalNet.IntegrationTests/GalNet.IntegrationTests.csproj --no-build --no-restore --artifacts-path artifacts/image-variants-p5/build --filter FullyQualifiedName~Packaged_variants_render_in_native_sample_and_close_cleanly +``` + +原生测试为 Explicit:普通测试运行不自动开启窗口;准确筛选方法名才执行,要求桌面及原生依赖。其截图与内存指标通过 TRX 输出/附件归档。构建、testhost 和格式工具使用可访问命名管道的执行环境。 + +## 设计偏差与文档 + +采用用户最新素材名替代原计划的 smile/coat 示例;算法中原始五名称仍由 P1/P3 测试覆盖。常量前缀 xy 本身是第一维,需要额外较短的 solo 来演示削减;两张 classroom 的缺组合则演示显式 Default。无语义或算法改变。 + +补齐 [图集手册](../../docs/manual/image-variants.md),并清除 [assets](../../docs/spec/assets.md) / [entry-types](../../docs/spec/entry-types.md) 中“专属 UI 尚未实现”的过时描述。P5 收尾和验收映射见 [summary](summary.md);实施者自查与限制见 [review](review.md)。异步 Step 的已复现经验记入 `.agents/lessons/L-20261023-02-debug-step-async-presentation.md`。 diff --git a/features/F-20261021-02-image-variants/phase-plan.md b/features/F-20261021-02-image-variants/phase-plan.md new file mode 100644 index 00000000..2d6fce40 --- /dev/null +++ b/features/F-20261021-02-image-variants/phase-plan.md @@ -0,0 +1,188 @@ +# 多维图片差分图集实施阶段计划 + +日期:2026-10-21 +状态:P1–P5 技术阶段 verified;整体自查 pass-with-follow-up,人工扩展矩阵与已知 Editor 问题见 P5 记录。 + +依据:[需求与验收](feature.md)、[设计](design.md)。 + +## 执行约定 + +- 按 P1 → P2 → P3 → P4 → P5 推进。每阶段有可独立验证的产物,不以未来阶段的空实现冒充完成。 +- 每阶段开始核对工作区和当前规范,记录基线;只修改本 feature 所需内容。已有其他 feature 的改动不得混入提交。 +- 每阶段结束记录实际变更、验证证据和偏差;验证完成后提交本阶段相关代码、测试和文档并报告 commit,遵循已有项目协作约定。 +- 实施时按实际改变的依赖图选择测试和构建;出现失败先区分已有问题与本阶段回归。运行中的 Editor 锁住输出时使用隔离产物,不关闭用户程序。 +- 文档中的扩展名 `.variants`、资源类型 `image-variants`、指令 `variants.show/select` 为建议名称。P1/P3 开始时核对 catalog 与命名约定后记录最终名称;普通命名选择不单独形成审批门禁。 +- 规范只同步已验证事实,实施偏差回写 design.md;未完成事项保留明确状态。这里不预设兼容层,也不承诺旧存档兼容。 + +## 阶段概览 + +| Phase | 状态 | 可验证产物 | 依赖 | +| --- | --- | --- | --- | +| P1 定义与纯解析 | verified | 从图片事实构建不可变定义,按规则解析请求和回退链;见 [P1 记录](phase-1.md) | 无 | +| P2 资源编译与发布 | verified | 相同作者输入在本地和 PAK 中产生相同运行时定义;见 [P2 记录](phase-2.md) | P1 | +| P3 图层指令与恢复 | verified | 无 GUI 也能显示/切换,保存恢复后继续正确选择;见 [P3 记录](phase-3.md) | P2 | +| P4a 场景分析与基础字段接入 | verified | 图集来源/请求/实际图片事实、异步资源代次、对象选择和引用追踪;见 [P4a 记录](phase-4a.md) | P3 | +| P4b 图集配置、预览与联动字段 | verified | 创建、配置、差分预览、维度/值联动和撤销保存形成完整作者流程;见 [P4b 记录](phase-4b.md) | P4a | +| P5 集成验收与收尾 | verified(技术集成) | 示例项目、PAK 原生渲染、回归证据与正式文档;见 [P5 记录](phase-5.md),保留人工扩展/基线 follow-up | P4 | + +## P1:定义与纯解析 + +### 目标、前置条件与模块 + +把命名规则、位置语义和回退算法变为可独立验证的纯逻辑。先固定语义,再接文件系统和展示。 + +无前置实现依赖。涉及 Core 资源/场景的中立 DTO、纯定义构建及解析逻辑,对应 Core 测试;作者 JSON codec 的具体归属按已有依赖边界确定。 + +### 任务 + +1. 区分作者描述和运行时不可变定义;定义维度稳定 key、显示 label、位置、选项、默认变体及变体到 sprite ID 的映射。 +2. 定义构建器接收已枚举的图片事实,不在 Core 访问目录。空段、重复键、跨扩展名冲突、大小写碰撞、错误默认图、显式维度不足等返回有位置的诊断。 +3. dimensions 省略时生成 d1/d2 等 key;完整描述不得因重复构建或枚举顺序变化而变动。选项采用稳定顺序,label 不参与身份比较。 +4. 定义完整请求与实际结果;初始化从默认图填已有维度,显式初始覆盖形成保护集合。 +5. 实现原位置精确匹配、从后向前累积删除未保护维度、显式 default;返回候选链和削减维度。候选不能通过拼接压缩把第 2 维误当第 1 维。 +6. 明确未知维度/未知值为输入错误,合法值缺组合才回退;空保护集合、默认图无后续维度、同值重复选择均有确定结果。 + +### 测试与退出条件 + +- 用户五张示例的完整命中、weap 削减命中和返回 smile 时保留 skirt。 +- 三维时依次删第 3、第 2 维;保护第 2 维时跳过它,最终只剩第 2 维的候选不得命中第 1 维同名文件。 +- 有 idle_skirt 但没有 smile_skirt 时,切换服装不会把表情自动改成 idle。 +- 多维初始覆盖、未选择维度、同值重新选择、最终 default 不满足保护值仍返回明确 Default 原因。 +- 扰乱图片事实输入顺序不改变定义、选项顺序和解析结果;验证定义/选择不被外部可变集合修改。 + +退出条件:以上行为测试通过,Core 不增加文件系统/平台依赖;更新 design.md 中的精确定义和实际名称。此阶段不注册半成品用户入口。 + +### 风险 + +删除维度容易被误写成通配符匹配;必须用反例约束。可变字典/数组若逃逸会让实例之间串状态,不能只验证单实例路径。 + +## P2:资源编译与发布 + +### 目标、前置条件与模块 + +同一份图集在开发项目和发行包中得到相同定义;运行时只读取资源 ID,不扫描源目录。 + +前置:P1 verified。涉及资源类型 catalog、Assets、文件系统适配、Editor.Shared 的项目校验/构建与 GamePackageExporter、编辑器及游戏宿主资源组合根,对应资源和编辑器共享测试。 + +已检查的当前接点:IAssetDecoder 接收资源源字节;PakBuilder 只将 IGameFile 序列化到归档,GamePackageExporter 收集资源后交给它。因此图集编译应在资源准备/导出层提供已编译 payload,不能把业务扫描塞入通用归档序列化器。 + +### 任务 + +1. 注册描述扩展名、metadata DTO 和运行时定义 decoder;组合根贯穿同一个冻结 catalog,保留自定义资源类型能力。 +2. 建立共享作者索引编译入口:按 Assets 内目录直接子文件收集 sprite 事实,交给 P1 构建器。解析相对路径时检查实际目录边界,包括链接/重解析点,不能仅检查字符串前缀。 +3. 明确本地定义获取路径:作者准备层产出与发布相同的编译定义;普通运行时 decoder 不隐式依赖项目目录遍历。不同项目、内容代次的定义不能共享过期缓存。 +4. 发布保留图集资源 ID/type/path 身份,替换该类型待打包 payload 为编译定义;全部 sprite 依赖入包,拒绝缺失/错误类型引用。保持通用 PAK 格式,除非真实证据表明格式本身不足。 +5. project.validate/build 与 GUI/CLI 共用诊断;空图集、缺目录、缺 metadata/default、坏 ID 等构建失败,不伪装成正常回退。 +6. 定义目录/文件刷新失效机制和取消边界;查询刷新不自动补建 metadata 或写作者文件。显式导入创建 sidecar 的事务先接好,GUI 向导留 P4。 +7. 资源移动/目录复制操作维护稳定 ID 和相对 folder 引用;复制生成新的资源 ID 并重编译关系。若移动/改名使 default 或维度语义失效,应在事务预检中明确处理或拒绝,禁止静默保存坏定义。 + +### 测试与退出条件 + +- 真正使用 producer 生成 PAK,再从 PakFileProvider/AssetManager 获取定义并解析;移开源目录后仍成功。 +- 相同输入本地与发行定义逐项一致;所有图片仍可按 sprite ID 获取。目录枚举顺序不影响产物。 +- 覆盖目录越界/链接、不同路径分隔符、重复文件主名、缺 sidecar/图片、错误资源类型、坏 default 的真实项目夹具。 +- 移动、复制、撤销/重做、保存重开后关系可用;项目关闭/重开和刷新期间的旧加载不能发布到新代次。 +- 普通 sprite 和自定义 catalog 的既有导出行为无回归。 + +退出条件:本地与 PAK 两条获取路径可用且一致,发布不需要原始图片目录,错误被 project.validate/build 拦截。同步 assets 规范中已经验证的资源行为和诊断,不提前记录运行指令。 + +### 风险 + +最大的接入风险是作者 JSON 和发行 payload 语义不同却共享错误 decoder,或普通文件改动没有使编译定义失效。必须分别验证真实本地/发行路径;不能用手工构造定义替代整条资源链。 + +## P3:图层指令与状态恢复 + +### 目标、前置条件与模块 + +完成独立显示与单维切换,复用现有 Layer 和 presenter,支持 CatchUp 与存读档。 + +前置:P2 verified。涉及 Contracts.Builtins、Primitives.Builtins、Core Layer/GameState、展示抽象及必要宿主注入;对应契约、运行时、展示与架构测试。 + +### 任务 + +1. 固定 variants.show/select 的 DTO 和 schema,接入内置契约集合、完整 ID、模块选择和执行覆盖检查。图集能力使用唯一 Layer 状态注册;核对选择部分内置模块时所需服务依赖。 +2. Layer 附加可选图集状态,保存图集 ID、完整请求、保护集合和实际变体;AssetId 继续表示实际图片。明确状态复制、序列化、验证及注册版本。 +3. show 加载定义后创建实例;select 校验目标和输入,解析后在状态更新中原子提交,再通知已有图片 presenter。切图保留变换、透明度、显示模式、层级与连续属性动画。 +4. 普通 layer.replace 清除图集模式;hide 删除完整实例;同 handle 再 show 重置选择。静态图、颜色、视频、Flipbook 与图集互斥;检查所有重建/复制 Layer 的代码路径,避免丢状态或残留绑定。 +5. 定义 acquire/释放的所有权与执行顺序;使用既有异步 primitive 协议等待定义获取,防止取消/旧加载覆盖替换后的同 handle 实例。 +6. CatchUp 只更新状态;统一恢复前加载定义,按保存的保护集合重算并校验实际结果,然后由已有 Layer 恢复流程展示。坏存档/内容不一致在正式状态替换前被拒绝。 + +### 测试与退出条件 + +- 使用公开注册入口和真实 compiled primitive 执行 show/select;验证完整命中、削减、default、多实例隔离、同值重复选择和非法输入不部分提交。 +- 缺目标/非图集目标 select 明确失败;hide/re-show/replace/视频来源切换后无图集幽灵状态。 +- 带变换及活动连续属性动画时切图,后续采样仍作用于同一实例;验证图片句柄替换与释放。 +- 在 Reduced 和 Default 状态存档,读档后继续切换;Normal/CatchUp 最终请求与实际图一致。 +- 故障注入覆盖定义加载失败、取消、快速连续切换、隐藏/替换和会话关闭;旧任务不得覆盖新实例。 +- 在可用 Avalonia 测试环境验证 presenter 接收实际 sprite 并保留布局;无原生渲染能力时明确保留人工项。 + +退出条件:无需编辑器专属 UI 即可运行包含图集的内容;完整状态和恢复链经过验证,原普通 Layer/Flipbook 行为无回归。同步 entry-types/runtime 与必要状态规范,记录实际快照兼容决定。 + +### 风险 + +现有 ReplaceLayer 重建 Layer、状态恢复异步预检和注册依赖容易产生遗漏。不得用单独平行状态表或 CatchUp 专用算法掩盖这些问题。 + +## P4:编辑器创作与诊断 + +实施拆分:P4a 先验证任务 3 的基础资源/对象选择、任务 4 的场景分析与引用身份,以及这些入口的资源代次和历史保护;P4b 完成目录创建、配置/预览、初始选择与维度/值联动、影响诊断及其完整保存流程。P4a 通过不代表 P4 整体通过,原任务和整体退出条件继续约束 P4b。拆分不改变匹配语义或首版范围。 + +### 目标、前置条件与模块 + +作者可完成创建、配置、预览和剧情切换,不必手写完整图片资源 ID;所有修改进入既有历史与保存体系。 + +前置:P3 verified。涉及 Editor.Abstraction/Shared、Editor 资源 inspector/选择器、共享 SchemaForm、场景查询和四语本地化,对应共享与实际挂载 UI 测试。 + +### 任务 + +1. 资源面板支持创建图集:选择目录、自动识别维度、配置标签和显式 default;创建与图片导入 sidecar 使用已有事务,不把扫描回调当写入口。 +2. 属性区支持目录/维度/default 配置、扫描诊断和预览;候选来自全图集。展示请求、实际图、原因及削减链,不自动隐藏缺组合的值。 +3. show 的资源和初始选择字段、select 的对象/维度/值字段提供联动编辑器;手动编辑仍走相同参数与业务校验。 +4. 新指令接入 SceneTargetAnalysis 与 Locator:show 创建普通 Layer 能力及图集身份提示,select 更新可知资源事实但保留对象/连续属性能力,replace 清除图集身份。条件/分支无法确定定义时提供保守候选和不完整原因,不执行游戏求答案。 +5. 源定义改变使属性选项、场景查询、预览和旧手势失效;保留已有未知/失效作者值供用户修正,禁止刷新自动改写历史。 +6. 维度 key/顺序改动及图片改名提供影响诊断;修改应原子提交或保留原文拒绝,第一版不加入全项目隐式引用迁移。 +7. 配置编辑、撤销/重做、保存、保存全部、关闭重开遵循既有内容会话;补齐四语 UI 资源,避免将算法内部术语直接作为常规操作标签。 + +### 测试与退出条件 + +- 真实挂载完成创建→配置→选值→查看回退→写 show/select 条目;选择器使用正确资源类型和维度选项。 +- smile_skirt→weap→smile 预览恢复 skirt;请求和实际图不混淆。 +- 单次作者修改对应单次历史;撤销/重做及保存重开保留 default、维度和选择。扫描刷新不产生 dirty。 +- 条件分支中定义不唯一时不提供虚假的确定选项;Timeline 能定位新图层和已有动画属性,未知语义仍保守。 +- 打开控件期间刷新/改目录/关闭项目后旧选择不能提交;检查焦点、虚拟化、资源释放和错误文本可见性。 + +退出条件:常规作者流程完整可用,GUI 与 CLI/build 对同一坏配置给出一致结论,相关 UI/历史/场景查询回归通过。同步编辑器规范与操作手册中的已验证行为。 + +### 风险 + +动态字段联动容易把后台刷新当用户提交;旧选项在新项目 scope 仍可见不代表有效。以会话和定义代次校验提交,不能仅靠控件当前是否打开。 + +## P5:集成验收与收尾 + +### 目标、前置条件与模块 + +把各阶段能力串为可交付示例,核对全部验收条件并完成文档收尾。 + +前置:P4 verified。涉及 Sample/项目夹具、构建验证、相关正式规范/手册及本 feature 记录;不夹带无关重构。 + +### 任务与验证 + +1. 提供最小立绘示例(用户五张命名)和三维场景夹具;素材使用可分发的现有资产或可辨识占位图,不引入未经授权外部素材。 +2. 从 Editor 创建并保存项目,用 CLI validate/build 和正式导出链生成安装包;脱离作者目录运行,验证显示、回退、移动、普通动画、hide/re-show。 +3. 在回退状态存读档后继续切换,快速/追赶模式最终结果一致;核对实例隔离与内容不一致诊断。 +4. 跑与变更相关的 Core/Runtime/Editor.Shared/Editor/Presentation/Architecture 测试和解决方案构建、格式与文档链接检查。已通过阶段验证仅在最终集成有影响时重跑对应部分;记录既有失败和环境跳过。 +5. 检查大图集选项/索引构建和快速切图的实际耗时、内存及关闭释放;基于仓库与素材记录可重复样本,不凭空设性能阈值或宣称无限规模支持。 +6. 整理 summary.md、review.md,逐项映射 feature 验收;正式规范说明格式/状态/错误边界,手册说明创建与切换、命名限制和回退示例。整理已确认 lessons,不将设计猜测写为长期事实。 + +退出条件:所有必须验收项有证据、引入的回归已解决,剩余人工或环境限制明确列出;阶段状态如实更新,并提交最终相关变更。必要人工验证未执行时不能宣称整项功能全部 verified。 + +## 当前进度与偏差 + +- 2026-10-21:完成需求、设计与本阶段计划;尚未开始 P1,未运行实现测试。 +- 2026-10-21:P1 已完成。Core 全量 148 项通过(其中新增图集用例 32 项),Architecture 45 项通过,新增源码/测试的 whitespace 格式检查通过;见 [P1 验证记录](phase-1.md)。P2 尚未开始。 +- 2026-10-21:P2 已完成。资源编译、本地/发行统一定义、图集 decoder、validate/build 与资源事务预检已接入;相关五项目 633 项通过,完整隔离解决方案构建通过。见 [P2 验证记录](phase-2.md)。P3 尚未开始。 +- 当前没有算法设计偏差。实施时若改变精确匹配、请求保留或默认图规则,需回写设计并说明用户可见影响,不能作为实现细节悄悄更改。 +- P3 开工修订:现有读档预检为同步接口。定义改为会话准备时异步预载并校验,运行指令同步使用不可变 catalog;以此保留替换状态根之前的完整内容校验,避免同步等待异步 I/O。匹配算法不变,不修改 Engine 恢复协议。 +- P3 已验证:七个相关测试项目共 1097 项通过(新增 27 项),隔离完整解决方案构建通过,格式检查通过。builtin.layers 节升至 v2,GameSnapshot 外层仍为 v4,未引入兼容迁移。P4 尚未开始。 +- P4 拆为两个可独立验证切片。P4a 已验证:新增 18 项通过;Editor.Shared 排除并行调试路线测试后 381 项通过,Architecture 47 项通过,Editor 全量 254 项通过、两项既有布局断言失败。完整解决方案构建被并行 Sample 调试构造函数改动阻断,Editor 及相关测试构建成功;格式检查通过。P4 整体与 P4b 仍未完成,见 [P4a 记录](phase-4a.md)。 +- 2026-10-22:P4b verified,P4 作者流程完成。创建、项目文本草稿/校验、属性配置、真实图片预览、初始选择与维度/值联动、工作定义及冷启动代次保护已验证;完整解决方案隔离构建通过,最终相关回归 698 项通过。Editor 排除两项既有布局失败,其余 258 项通过;原完整运行的一次 Headless 释放异常及复跑证据如实保留。新增 12 项通过;见 [P4b 记录](phase-4b.md)。P5 尚未开始,不宣称整项最终验收完成。 +- 2026-10-23:P5 技术集成 verified,整体自查 pass-with-follow-up。Sample 接入用户更名后的 xy 与两张 classroom 素材;新增三模式正式导出/安装及两类回退快照继续选择、原生五截图与正常退出、可重复规模观测。CLI validate/build(含默认 Output)、完整隔离解决方案构建通过;八个回归项目 1258 passed,原生显式 1 passed。Editor 全量 257 passed / 3 failed,两项既有布局失败及一次 Headless 释放 NRE;后者单独复跑通过。完整证据及未覆盖人工/规模矩阵见 [P5](phase-5.md);没有把全部人工验收标记为 verified。 diff --git a/features/F-20261021-02-image-variants/review.md b/features/F-20261021-02-image-variants/review.md new file mode 100644 index 00000000..f7c879df --- /dev/null +++ b/features/F-20261021-02-image-variants/review.md @@ -0,0 +1,23 @@ +# 图片差分图集收尾自查 + +日期:2026-10-23 +范围:P5 实际 diff、P1–P4 验收记录及当前正式规范。实施者自查,未冒称独立评审。 + +结论:pass-with-follow-up。本阶段功能验收有证据,未发现阻止图集示例交付的问题;Editor 基线失败、释放抖动和未执行的扩展矩阵保留如下。 + +## 核对结果 + +- 样例直接接入默认入口,两个目录只包含其各自图集图片;旧图片像素、稳定 ID 与 Gallery item 保留,既有分支通过中英文完整运行验证。 +- 测试使用真实导出/安装/PAK 与 compiled primitives,不用手写 catalog 代替发布链。原生夹具在启动前移除其作者项目,不能从原目录读取图片。 +- 两种 fallback 均保留请求并在恢复后继续切换;正常、快速、CatchUp 均有相同实际结果断言。移动/循环/第二实例/hide/re-show 有独立断言。 +- native 测试等待外部异步任务发布完成,五个渲染检查点不同,人工逐张查看图片;正常关闭并等待 exit 0。截图与范围明确,不把帧大小断言当成完整视觉证明。 +- 测试夹具因 Sample 默认指令变化而同步适配;没有修改业务匹配算法、状态版本或 presentation 行为。没有暂存/提交并行调试面板等其他 feature 改动。 +- 现有 spec 的过时 UI 状态已修正,手册链接到 Sample 可复现检查点;不把性能观测写成保证。 + +## 保留事项 + +1. Editor 全量 257 passed / 3 failed:两项 P4 已记录的 Group tail/row width 布局断言;另一次 `EntryFieldsUseProjectOrderAcrossMountedPagesAndSurvivePageCloseAndReopen` 在 Headless DisposeAsync 抛 NRE,单独复跑通过。未确认释放抖动根因,不能称其已修复;本轮五个图集 UI 用例均通过。其余八项目 1258 passed,原生显式 1 passed,完整解决方案构建通过。 +2. 未进行本轮人工 Editor/存档菜单点击与高 DPI/全屏/多平台/长时矩阵。已完成的是 P4b 真实挂载作者流程、运行状态/落盘恢复和本轮原生渲染截图,不能宣称所有人工场景完成。 +3. 规模测试为 4096 个纯定义变体及 10000 次选择,未包含 4096 张真实图片或数千 GUI 选项的渲染。原生宿主观测包含预加载和 shader 等资源;当前数据不能证明长时无泄漏或无限规模支持。 + +上述扩展验证作为明确 follow-up,不隐去基线或把未测范围标记为 verified。阶段数字及复现命令见 [P5](phase-5.md)。 diff --git a/features/F-20261021-02-image-variants/summary.md b/features/F-20261021-02-image-variants/summary.md new file mode 100644 index 00000000..4138ab20 --- /dev/null +++ b/features/F-20261021-02-image-variants/summary.md @@ -0,0 +1,46 @@ +# 图片差分图集实现总结 + +日期:2026-10-23 +状态:P1–P5 技术阶段 verified;整体自查 pass-with-follow-up。已知 Editor 基线/释放抖动和未执行的人工扩展矩阵继续保留,不宣称全部人工验收完成。 + +一份 `.variants` 描述指向一个图片目录,以文件主名下划线分段表达有序维度。显示实例独立保留完整请求,按精确 → 从后向前累计删除未保护维度 → 显式默认图解析。换图保留 Layer 身份、变换和连续属性动画,回退不覆盖未显示的请求。 + +## 实现与决定 + +- Core 提供不可变定义、构建诊断和纯解析;目录扫描与 JSON codec 在资源/作者层。 +- 本地 provider 和导出链使用同一编译定义;PAK payload 仅含维度、默认图和 sprite ID。运行不扫描作者目录。 +- 会话异步预载并验证 catalog 后注入 layers 模块。`variants.show/select` 本身同步提交状态;CatchUp 使用同样的逻辑。这个放置方式保持 Engine 读档换根前的同步校验协议。 +- `Layer.Variants` 保存来源、完整请求、保护集合、实际键和原因;`builtin.layers` section v2,外层 GameSnapshot v4。内容不一致和旧 section 明确拒绝,无兼容迁移。 +- Editor 接入目录创建、配置草稿、全轴选项/回退图片预览、show 初始选择和 select 对象/维度/值联动,以及静态场景事实、撤销/保存/资源代次保护。 +- Sample 默认 intro 使用真实 `xy` 立绘与 classroom 背景;源素材字节与已有资源 ID 保留。新命名的适配只改变夹具,不改变解析规则。 + +## 验收映射 + +| 需求验收 | 证据 | +| --- | --- | +| 不同段数合法,不需所有组合 | P1 原始五名称矩阵;P4b 导入/预览;P5 xy/solo 与三轴 classroom | +| 单维保留其他意图,回到原选择 | P1/P3 smile_skirt→weap→smile;P5 xy_shy→solo→xy_shy,含 snapshot | +| 回退稳定、可诊断、无换选项猜测 | P1 乱序/位置反例;P4b 候选链;P5 Reduced/Default 实际图片 | +| 实例独立且保留变换/动画 | P3 Runtime 测试;P5 PAK 第二实例、move、loop、hide/re-show | +| 发布不依赖原目录、ID 稳定、坏引用拒绝 | P2 真实 producer/事务反例;P5 导出定义/图片逐字节比对、原生启动前删除临时作者项目 | +| 存档/CatchUp 保留请求与实际结果 | P3 内容冲突拒绝矩阵;P5 三模式回退快照继续选择、真实 FileSaveService 保存加载 | + +各阶段完整证据:[P1](phase-1.md)、[P2](phase-2.md)、[P3](phase-3.md)、[P4a](phase-4a.md)、[P4b](phase-4b.md)、[P5](phase-5.md)。最终构建/回归数字、曾失败的验证和环境边界以 P5 表格为准。 + +## 当前文档 + +- [资源格式与编译](../../docs/spec/assets.md) +- [指令契约](../../docs/spec/entry-types.md) +- [状态与恢复](../../docs/spec/runtime.md) +- [编辑文件会话](../../docs/spec/editor-files.md) +- [场景对象分析](../../docs/spec/editor-scene-targets.md) +- [创建/配置/切换手册](../../docs/manual/image-variants.md) +- [可运行示例及检查点](../../GameTestCase/README.md#image-variants-walkthrough) + +## 限制与后续 + +没有分层合成、专属差分交叉淡化、维度连续动画、隐式剧情迁移或旧存档兼容。图片改名、维度身份/顺序变化需修正引用,坏配置明确拒绝。资源定义按会话冻结,播放中不热更新。 + +原生渲染证据为独立进程的 SceneHost PNG 及正常退出;Editor 作者流程由真实挂载测试覆盖。本轮没有人工点击完整 Editor/存档菜单,也没有完成全屏、高 DPI、其他平台和长时间压力矩阵;不将自动化/截图验证扩大为全部人工验收。性能数据只覆盖已记录的纯索引样本与当前真实素材宿主,不承诺无限规模或忽略默认预加载的内存成本。 + +最终自查结论和已知基线问题见 [review](review.md)。 diff --git a/features/F-20261021-03-external-game-debugging/design.md b/features/F-20261021-03-external-game-debugging/design.md new file mode 100644 index 00000000..706f900a --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/design.md @@ -0,0 +1,136 @@ +# 外部游戏调试设计草案 + +日期:2026-10-21。状态:accepted-for-implementation;已验证部分见阶段记录,其余不表示已实现。用户明确约束见 [feature.md](feature.md)。 + +2026-10-22 实施结果:核心范围已完成,实际 API/默认值以 [Runtime 规范](../../docs/spec/runtime.md) 和[接入手册](../../docs/manual/external-game-debugging.md) 为准。本文保留最初的建议及开放问题作为设计历史;最终采用保留当前变量、目标前暂停、目标作者范围一次条件豁免、最近自然入口 LRU 缓存和全量轮询。固定/热点前缀预计算延期。验收边界见 [phase-6.md](phase-6.md)。 + +2026-10-24 补充:Editor 本地启动测试需为连接等待和调试请求设上限,并将 Editor 侧启动阶段、请求时长、进程输出与退出状态写入 profile 内的有界会话日志。超时只解除 Editor 操作等待,不自动杀 App;后续状态轮询用来确认迟到连接或仍在运行的操作。详见 [phase-liveness-diagnostics.md](phase-liveness-diagnostics.md)。 + +## 1. 当前选择及修订 + +采用可选宿主调试组件、Engine 内有限调试端口、Editor 客户端三部分。用户最新要求是沿指定路线追帧、忽略所有流程条件,重建用于测试的场景;不还原真实游玩历史。本版替代早先“检查点 + 输入历史 + Player 回滚”的方案,也不采用仅移动游标的跳转。 + +已明确:Graph 当前 Group 高亮、Group 右键跳转、多路线列表、合法选中 Group 链优先、Group 页作者 Entry 高亮和跳转。推荐采用按需缓存,固定场景预计算须有完整证明。变量写入和目标落点的具体默认值仍是下文建议,不能当作已获确认的产品事实。 + +## 2. 已核实的基础 + +- Engine 有唯一执行门、活动实例、scope 取消、Choice、状态事件、稳定快照和展示同步;需扩展有限调试控制策略,不能建立另一个剧情执行器。 +- GameSession 串行化运行替换/停止;IGameRun 不暴露 Engine,自定义运行不能被迫实现庞大调试接口。 +- GameSnapshot v4 含 Save 和模块状态,不含 Player;本功能不改变普通存档语义。 +- GameRuntime 与 PlayerVariableService 各有变量内存,调试写入必须通过当前运行的权威入口;查询所得字典/对象也必须复制成隔离 DTO。 +- 当前 CatchUp 用于跳到未读,仍检查条件并停在 Choice。新定位需要明确的 IgnoreConditions + ExplicitRoute 策略,不能误用普通 Advance 或永久改写 Entry.Condition。 +- GalgroupCompiler 将 primitive.Id 重新按编译后顺序编号;StableId 为作者 Entry ID 加展开序号,并返回 SourceMap。不是保留 rawgroup 数字序号,但具备稳定映射基础。 +- SceneTargetAnalysis 仅做有限作者事实分析,不产生完整可恢复快照;目前字面 false 不执行、其他条件保守合并,与本次无条件追帧不同。 + +## 3. 低耦合、生命周期与连接 + +| 位置 | 职责与边界 | +| --- | --- | +| Runtime 中立调试端口 | 受控状态复制、位置通知、暂停/步进及有界追帧;不引用网络、JSON、Editor、Avalonia | +| 可选 Debugging 组件 | App/运行会话、协议 DTO、定位协调;不持有 Editor 文档或具体 presenter | +| Infrastructure 传输 | loopback TCP、长度前缀 JSON、鉴权、收发/释放;不直接写 Runtime | +| 游戏组合根/可选 GameHost adapter | 显式启用、可选 StartGameAsync、Attach/Detach 当前运行、UI 调度、场景恢复和 profile | +| Editor.Shared | 客户端、启动配置、纯路线规划、编译映射、可选纯预分析;不引用具体 UI | +| Editor | 菜单、Dock、选择、路线列表、高亮和草稿;不执行游戏 Entry | + +以上是职责划分,不要求每行独立程序集。优先保持少量可选组件,不引入通用插件框架;IGameRuntime/IGameRun 不为调试膨胀,默认与自定义宿主按能力接入。 + +App 连接独立于 Engine:标题页可已连接,进入游戏 Attach 后增加运行能力,binding 释放即解绑。开始回调可省略。未启用不建立连接或收集调试数据。Engine 在门内提交状态,通知/网络等待在门外;暂停让执行在边界返回,不持门等待 Editor。 + +首次“测试 → 启动应用”配置 exe、独立参数、工作目录和 profile,项目设置可修改;机器路径放本地 Editor 配置。默认保存、校验并编译后运行;先监听再启动,另支持等待 IDE 自行启动的 App。测试 profile 隔离正式玩家数据。 + +握手检查协议、项目、内容/契约指纹和能力,源码映射只用于匹配内容。请求带 requestId/sessionId/runId/executionGeneration,Choice 带 choiceId,变量带值版本;运行替换和定位都推进执行代次,旧请求/事件失效。修改请求超时查询结果而非盲重试。重连全量刷新,消息/队列/日志有上限。 + +宿主命令序列与 Engine 执行门负责互斥;停止/取消先发取消信号,不能排在长追帧后面永远等待。未知宿主恢复能力按能力禁用,不靠协议层推测。 + +## 4. Graph 与 Group 交互、作者映射 + +Graph 为执行 Group 设置独立运行标记,不修改作者选择集合;因此高亮 B 不会破坏用户选择的 B/C/D/E 路线。转移时更新标记,当前处于分支或定位中时明确显示过渡状态;图页面/Group 页面不打开也不影响游戏。 + +Group 页面标记当前显示/活动作者 Entry,并单独提供下一执行位置。当前 EntryIndex 常已指向下一条,不能直接据此高亮。多个并发实例可报告主阻塞条目和其他活动条目,不伪造唯一活动 primitive。 + +通过 SourceMap 建立 sourceEntryId → primitive 范围及 primitiveStableId → sourceEntryId,绑定编译指纹。展示 raw 作者行号按当前匹配版本的有序列表派生,不从编译数字 ID 或字符串拆分猜测。Composite 展开多个 primitive 仍高亮一条作者 Entry;跳到该行从展开范围开头开始。零展开条目保留虚拟作者边界,落在下一个编译位置或 Group 末尾,不能随便选一个不存在的 primitive。 + +Group 右键以该 Group 入口为目标,Entry 右键以该作者条目前为目标。建议定位完成先暂停;显式“从此处执行”时一次性忽略目标作者 Entry 的条件(覆盖其展开范围),越过该作者范围后恢复普通条件判定。若最终交互改为自动播放目标,必须明确相同的一次条件豁免,不让 false 目标定位后马上消失。此落点交互仍待确认。 + +## 5. 路线计划 + +路线请求由起点基线、起点位置、经过的 edge/outlet 序列、终点 Group/Entry 和策略组成。使用边身份可以区分相同节点对之间的不同出口;不能只有 Group ID 数组。 + +Group 集合形成路线的条件:起点、终点正确,所有选中 Group 恰好使用,连续元素为前一元素的直接后继,能推导唯一顺序。多选本身通常无序,不能信任点击或枚举顺序。满足时优先使用;断链、分叉、方向相反、缺起点/终点都不擅自丢弃选中节点,而是说明原因并进入路线选择。若将来允许在 Group 间隐藏非 Group 分支节点,须解析完整真实边序列;多种连接方式仍要选择,不能放宽为任意可达。 + +例:当前 B,目标 E,选中 B/C/D/E 且存在唯一 B→C→D→E 链,采用该链。若 B→X→E 和 B→Y→E 都可用且没有有效选中链,弹出路线列表,显示经过 Group、分支出口和基线。只列有限、优先短且无重复节点的候选,并提供继续指定途经点;不枚举图中所有路线。候选数量、深度、时间均有预算,超过上限明确提示。 + +追帧沿选定出口,不求 Graph 条件和 Choice 可见性;Entry 条件无论 true/false 都不拦截。循环路径可由显式路线编辑器带重复次数表达;普通 Group 多选不支持表示 B→C→B,默认不自动遍历环。目标拓扑不可达或边不存在仍拒绝。 + +## 6. 无条件追帧语义与基线 + +忽略条件只影响本次调试重建:Entry.Condition、Graph 条件及 Choice 过滤不判定;DTO/资源/参数验证、表达式类型、模块能力和后继边仍须有效。互斥条件下的两条 show 在同一 Group 时会按原顺序全部执行,后一次覆盖前一次;这是明确的调试合成场景,不代表任何真实可达分支。 + +变量策略建议:保留定位开始时当前 Player/Save 值,冻结副本供场景参数读取;场景追帧跳过 variable.set、Gallery 解锁、已读记录及外部持久副作用。这样用户在变量面板准备的测试值不会在回跳或重复追帧中被覆盖,也避免 x=x+1 重复累加。忽略条件本身并不必然意味着跳过变量赋值,此策略需确认;若选择执行赋值,必须在独立重建变量环境中从明确基线重放并纳入缓存键,不能直接累加到当前变量。 + +每次定位必须有明确的场景起点,不能把当前 B 中途的场景当作 B 入口。可用起点:游戏初始逻辑场景、已捕获的本次运行 B 入口、选定路线前缀的合法缓存,以及已证明固定的锚点。普通运行捕获的入口代表它的实际来路,不等同于从游戏起点全程忽略条件所得状态;计划/缓存标识其来源。 + +推荐:在当前 B 向 E 指定 B/C/D/E 时,默认从 B 当前执行边界继续,先按 CatchUp 规则收束/清理已有呈现,再执行 B 剩余条目、C/D,到 E 入口,不把 B 从头重复执行。也可以显式选择 B 入口基线重新构建;两者的起点与缓存身份必须不同。同组往早跳从该次 Group 入口重建到目标之前,往后可从当前合法边界继续;回跳后的场景不携带目标之后的对象。跨组往之前/未访问 Group 时,当前 B 不能到达目标就从更早合法基线/游戏入口重新选择路线,不反向执行边。路线重建语义由基线决定,不承诺不同起点生成完全相同的场景。 + +没有兼容基线时提供从游戏初始场景选择路线重建;如果该 Group 与入口断开,可以显式选择“初始空场景 + 此 Group 作为测试起点”,不自动伪造它有合法来路。任意目标都可以作为独立测试起点,但缺少依赖对象的结果/错误应可见。 + +定位步骤:校验计划与指纹 → 暂停旧执行 → 取消 Choice/活动 scope 并等待清理 → 恢复基线场景 → 以 CatchUp 呈现策略沿路线执行目标前缀 → 重建呈现 → 发布位置并暂停。阶段受取消/预算控制;失败回到可恢复场景或进入明确 Faulted/Stopped,不声称原运行完全没变。宿主需要重建部分 Engine/展示时仍保持只有一个有效执行代。 + +追帧不是播放完每段动画/等待对白:内置模块按既有 CatchUp 逻辑提交场景终态、抑制一次性展示;循环媒体依逻辑恢复规则重新开始,不保持原时钟。未知自定义条目需要显式重建适配,默认不能在后台执行它以“试算”状态;不支持则报告条目和原因。 + +## 7. 缓存与预计算 + +用户最终确认:调试期间每个 Group 只保存最近一次自然进入时的一份入口场景,不累积访问历史;再次自然进入覆盖旧值,并失效旧基线的中间缓存。恢复该入口和组内追帧不是再次自然进入,不覆盖原基线。入口捕获在第一条 Entry 之前,复制完整逻辑场景,含持久非阻塞呈现的恢复描述;不保存 Task/后端句柄,不回滚当前变量。普通读档/内容换代/新运行使不兼容入口缓存失效。最初版本以此单份基线为准,热点前缀和固定预计算是后续优化,不改变基线语义。 + +Group 末尾是游标等于编译条目数、尚未跨出边的明确执行边界。追帧停在这里后维持 Manual,不自动播放或借自然完成回调跨组;下次显式 Advance 才离开。不能通过终态 Stop 实现。取消或失败不返回“到达末尾”的成功状态。 + +### 7.1 性能顺序 + +先实现无缓存、按明确基线追帧的正确性,再加按需缓存;项目打开/游戏启动不枚举所有路线、不预跑全部 Entry。无条件场景重建通常可压缩对白、等待和一次性媒体,实际成本须测量。记录首次/重复定位耗时、执行 primitive 数、恢复/资源加载时间和内存字节,避免只优化计算却忽略纹理解码成本。 + +优先缓存合法 Group 入口和热点 Entry 前缀,长 Group 可按已执行指令成本/时间间隔增加稀疏缓存。缓存纯逻辑状态及恢复描述,不含 Task、控件、纹理或媒体句柄;按 LRU 和字节上限淘汰。异步计算有预算/取消,UI 只接收匹配代次的结果。 + +路线专属缓存键包含内容/契约/模块与恢复能力版本、资源版本、IgnoreConditions/变量副作用策略、基线身份、精确路线前缀和目标位置。读变量的场景还包含冻结输入指纹;第一版可粗粒度使用全部游戏变量 revision,后续证明读集后再细化。变量手改、内容/资源/模块改变均使相关缓存失效;不同来路不能仅凭相同 groupId 命中。 + +### 7.2 固定入口与固定 Entry + +用户提出的“所有入路场景一样”是正确的合流优化方向;“没有 Possible,只有 Definite 对象”只是部分必要信息,不是证明。至少还要覆盖: + +- 对象集合完全已知且相同,没有未知/额外对象。 +- 对象类型、handle、资源、完整属性、排序和附着关系相同。 +- 要恢复的效果、粒子/动画计划、对白/NVL、音轨/队列等域状态相同,或明确不在该缓存域中。 +- 没有未记录的时钟、随机、自定义状态或输入依赖;否则只能带依赖键复用。 +- 忽略条件策略下的分析完整且已收敛,包含所有结构入路/循环,不仅样本执行路径。 + +例如两条路线都必有背景 bg,一条显示白天,一条显示夜晚:对象都是 Definite,场景仍不固定。即使画面相同但 BGM 不同,也不能合成共享的完整恢复快照。Graph 汇合点/支配关系也不证明状态相同。 + +可按域证明和缓存:固定图层不能覆盖路线特有音频;恢复需保持域依赖,未知效果引用图层时不能随意拼接。完整固定入口只在所有纳入恢复的域都闭合并相同后授予;否则继续使用路线专属缓存,不丢弃差异。 + +若入口固定,Group 前缀又全部是确定、可纯计算的场景状态变换,则特定 Entry 前状态也固定,可以预计算;入口不固定时,前缀显式完整清场并重新设置全部相关域,也可能在中途形成固定锚点。只 show 一个背景不是完整清场,未提及对象和音轨可能仍在。 + +预计算只调用模块声明的纯状态变换器/恢复描述,不实例化游戏、不加载真实媒体/执行任意自定义代码。不能纯计算时不预计算,首次真实安全追帧后按需缓存。证明不足/不收敛只取消优化,不阻止 P4 基本定位。 + +### 7.3 与现有查询的边界 + +SceneTargetAnalysis 目前的 Definite/Possible 是目标存在性抽象,Values 也只保留部分可动画数值;不是完整场景状态。普通作者查询按条件合并,调试分析必须显式使用 IgnoreConditions 并在缓存键分离;不能直接改现有查询使 Timeline 作者语义变化。 + +可以复用纯 schema、编译映射和状态变换工具;当前查询的候选/Complete 不当作生成运行快照的凭据。固定证明由独立、覆盖模块状态的结果承担;第一版只实现明确支持域的保守证明,其余返回 Unknown。 + +## 8. 实例变量面板与其他能力 + +独立 Player/Save Dock 查询当前运行实际值,不覆盖作者声明;支持搜索、类型、当前值、草稿、应用/还原与同步状态。初次原子复制 DTO,之后有序增量,断层/恢复全量刷新;后台变化不覆盖草稿。写入携带运行代次、scope、变量 UID/名称、类型及每变量 expected revision,执行时重查;旧行、旧 Choice、迟到结果不能写新运行。 + +拒绝改类型、非有限 Float、越界 Int、只读项与过期版本;明确作用域避免前缀归一化误写。写成功以权威新值回显,不产生项目 dirty/Undo。Player 正常持久化进测试 profile,“已同步游戏”与“已落盘”分开;面板手改推动依赖缓存失效。 + +流程暂停阻止新分发、自动推进、blocking 自然完成后继续和 Choice 提交;媒体完整冻结另报 capability。断点在作者 Entry 前;单步作者 Entry 与玩家推进一次分离。菜单保留启动/连接、开始、暂停、步进、停止、重开、断开和面板入口;停止游戏不等于关闭外部 App。 + +## 9. 取舍、兼容与验收 + +直接改游标无法重建场景,不采用。真实历史回放需要输入日志/Player 回滚,已不在当前范围。全路径预计算易指数膨胀且拖慢启动,不采用。推荐指定路线无条件追帧、按需缓存,再对白名单可证明固定域做共享预计算。 + +调试策略限定在一次请求及目标执行豁免范围,普通游玩继续使用原条件和变量语义;不修改原作者文档、普通存档格式及既有 Timeline 查询。可选组件按实际依赖同步架构检查,不让 Runtime 反向引用 Editor 或通信层。 + +重点验证:真实跨进程连接和变量同步;合法多选链/路线歧义/环/平行边;Entry false 和 Choice 不拦追帧但坏参数仍拒绝;同组倒退和跨组无后续场景残留;Composite 与零展开映射;取消/停止/旧代事件;缓存命中等价及失效;Definite 但资源/属性/音轨不同不错误合流;首次/重复跳转性能与缓存上限。 + +实现阶段及每阶段 Git 提交要求见 [phase-plan.md](phase-plan.md)。 diff --git a/features/F-20261021-03-external-game-debugging/feature.md b/features/F-20261021-03-external-game-debugging/feature.md new file mode 100644 index 00000000..d803f14e --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/feature.md @@ -0,0 +1,80 @@ +# 外部游戏调试与测试 + +- 日期:2026-10-21 +- 状态:verified-with-follow-up;核心实现与跨进程验收完成,既有回归失败、固定预计算延期及人工检查范围见 [summary.md](summary.md)。 +- 工作流:create → design → phase-plan → implement → review → closeout。 +- 设计:[design.md](design.md) +- 阶段计划:[phase-plan.md](phase-plan.md) + +## 用户原始目标 + +Editor 对接外部游戏 App 进行 debug 和测试。底层提供可选调试层,由开发者显式决定是否启用;App 启动后即可与 Editor 通信。顶部“测试”菜单可以启动外部 App,首次配置可执行路径,也可从项目设置修改;启动时通过参数传入游戏工程目录等信息。 + +调试接入可选注册“开始游戏”方法。有回调时 Editor 提供开始游戏入口;没有回调时允许玩家在 App 自行进入游戏,再开始运行调试。需要支持任意位置定位、跨 Group 调试。 + +2026-10-21 用户补充: + +1. 采用 feature 流程,保持较低耦合。 +2. 实施时每个阶段验证完成后提交一次 Git,只包含本阶段相关变更并报告 commit。 +3. 独立面板显示当前游戏实例的玩家变量与存档变量,支持手动修改并同步游戏。 +4. 重点设计顺着剧情前进、回到历史位置及跨 Group 定位的统一方案。 + +随后用户澄清(替代最初的历史回溯设想):跳转仍然通过追帧重建,但追帧期间不考虑任何条件。Graph 高亮游戏当前 Group,Group 右键跳转;有多路线时列出候选供选择。若当前选中的 Group 能组成从所需起点到目标的连续后继序列,则使用该路线,例如 B→C→D→E。当前 Group 页面高亮对应作者 Entry,并支持 Entry 跳转。评估所有入路场景相同的固定位置预计算及缓存。 + +最终确认:调试模式每个 Group 仅保留最近一次自然进入时的入口逻辑场景,再次进入覆盖;回跳重建不当作自然进入,也不覆盖该基线。普通内容版本改变或重开游戏清空缓存。先提供参考 Sample 追帧机制的“当前 Group 末尾”底层能力。用户已要求检查设计后实施,保留每阶段 Git 提交约定。 + +2026-10-24 用户补充:编辑器启动测试不能无限等待;连接/操作无响应时显示明确错误即可,不要求弹窗。诊断日志应足以让用户提供后续问题上下文。 + +## 问题与范围 + +- 区分 App 连接与游戏运行:尚无 Engine 时仍可连接;运行替换后旧操作必须失效。 +- 显式启用、可选启动回调、默认宿主适配与自定义宿主接入。 +- Editor 启动配置、连接/断开、开始/停止运行、基本日志及执行位置。 +- 当前运行的变量查询、类型校验、双向状态同步与冲突保护。 +- 流程暂停/继续、作者 Entry 断点与单步,选择分支操作。 +- 任意作者目标定位:沿显式路线忽略条件追帧,支持同组前后和跨组定位。 +- Graph/Group 执行高亮、选中 Group 链优先、分歧路线选择、作者与编译位置映射。 +- 测试数据隔离、按需场景检查点缓存,以及可证明场景固定位置的预计算评估。 + +## 非目标 + +- 未验证的设计不写入正式 spec;已验证实现按阶段同步。 +- 初版不做 CLR 调试器、任意表达式执行、原生进程附加或程序代码热更新。 +- 不承诺媒体/粒子/动画逐帧逆播,不对任意插件承诺无条件确定性重放。 +- 不实现真实游玩历史还原、历史分支编辑或调试 Player 回滚;早期“检查点 + 输入历史回溯”提案已由指定路线忽略条件追帧替代。 +- 不默认支持远程设备或公网连接;内容热替换、通用自动测试脚本平台留后续评估。 +- 不修改变量声明、作者默认值或项目内容历史。 + +## 验收标准 + +1. 未启用调试时没有连接与历史采集;Core/Runtime 不引用 Editor、Avalonia 或通信实现。 +2. 首次启动可设置路径与参数,后续可修改;支持 Editor 启动和开发者自行启动后连接。 +3. 开始回调可选;当前运行绑定/解绑独立于 App 连接,重开后的旧请求被拒绝。 +4. 运行变量 Dock 同时支持 Player/Save 两作用域;游戏改值能刷新,手动提交经运行调度与类型校验生效,失败明确回显,不改作者内容。 +5. 后台刷新不覆盖未提交草稿;已替换运行的旧行、旧 Choice 和旧异步结果不能写入新运行。 +6. Graph 高亮当前 Group;对应 Group 文档高亮作者 Entry,不能把下一指令游标误当当前显示条目;两处都能触发定位。 +7. 定位按选定路线忽略 Entry 条件及 Graph 分支/Choice 条件;仍校验内容、参数、资源和每条后继边。选中集合形成唯一完整链时直接采用,歧义时列出有界候选路线。 +8. 同组向更早 Entry 和跨组定位从适用基线/场景缓存正向追帧;不直接携带目标之后的场景倒退。无效/不可达路线、未知模块能力和取消失败均明确报告。 +9. 测试默认隔离玩家数据;缓存命中与未命中的定位语义相同,不用场景固定推导变量或整个运行快照固定。 +10. 每个实施 Phase 具备行为测试、适用构建检查、文档记录和独立 Git 提交。 +11. Editor 启动 App 后限时等待连接;启动和调试请求超时后显示错误,且不自动终止用户启动的 App。 +12. 本地启动测试保存有界诊断日志,记录准备阶段、连接/请求耗时、进程退出码及 App 标准输出/错误输出;启动元数据不记录调试 token 或配置参数值。 + +## 约束、假设与开放问题 + +- 用户已确认低耦合、变量面板及按阶段 Git 提交;其余具体接口、默认值和定位策略是推荐方案,尚未被用户逐项确认。 +- “前/后”不按画布位置解释:区分同组较早/较后 Entry,以及选定路线的前后继;循环不能由无序多选表达重复次数。 +- 默认建议仅一个活动受控运行;协议保留实例身份,暂不设计多运行并行控制 UI。 +- 调试场景定位保留当前 Player/Save 变量,跳过变量/解锁/已读写入,参数表达式可读取冻结副本。普通玩家追帧保留原逻辑写入语义。 +- 定位在目标前暂停;显式执行目标的一次作者范围可忽略条件,之后恢复普通条件判定。 +- 缓存内存预算、候选路线数量/深度/时间上限、空场景基线及宿主恢复能力待实施验证。优先按需缓存,固定位置预计算只接受独立完整证明。 + +## 当前实现依据 + +- [运行时规范](../../docs/spec/runtime.md):执行门、稳定存档、状态恢复与停止语义。 +- [架构](../../docs/spec/architecture.md):GameSession/运行/展示生命周期和依赖方向。 +- [变量规范](../../docs/spec/editor-variables.md):声明与运行时当前值的边界。 +- `src/Shared/GalNet.Runtime/Engine/GameEngine.cs`、`GameEngine.CatchUp.cs`。 +- `src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs`、`Variables/VariableStore.cs`、`Variables/PlayerVariableService.cs`。 +- `src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs`:作者 Entry 到编译 primitive 的 SourceMap。 +- `src/Samples/GalNet.Sample.Avalonia/GameLaunchOptions.cs`:当前游戏目录与 profile 参数解析。 diff --git a/features/F-20261021-03-external-game-debugging/phase-0.md b/features/F-20261021-03-external-game-debugging/phase-0.md new file mode 100644 index 00000000..24863e99 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-0.md @@ -0,0 +1,15 @@ +# P0:Runtime 当前 Group 末尾追帧 + +状态:verified。 + +新增 GameEngine.SkipToGroupEndAsync,复用现有 CatchUp;忽略未读边界,显式可忽略 Entry 条件,但原有逻辑写入仍执行。停在当前 Group 的尾游标,不自动进入后继、不显示后继 Choice;下次推进离开。 + +不同目标并发追帧拒绝;同目标同策略共享任务。保留 scope、清理等待、最终展示同步、失败传播与 Stop drain。没有改 IPlayerPlaybackController,也没有提前接入外部 Editor UI。 + +验证: + +- dotnet test test/GalNet.Runtime.Tests/GalNet.Runtime.Tests.csproj --no-restore --filter "FullyQualifiedName~GroupEndCatchUpTests|FullyQualifiedName~PlaybackModeTests" --verbosity minimal:33 通过。 +- dotnet test test/GalNet.Runtime.Tests/GalNet.Runtime.Tests.csproj --no-restore --verbosity minimal:96 通过。 +- 构建包含 Runtime/Core/展示抽象与测试项目;既有 CA1861 建议未修改。 + +测试覆盖场景行为与生命周期,不以字段赋值代替展示清理验证。跨进程/Editor/原生媒体尚未实施验收;P1–P6 继续。 diff --git a/features/F-20261021-03-external-game-debugging/phase-0b.md b/features/F-20261021-03-external-game-debugging/phase-0b.md new file mode 100644 index 00000000..0fb1c927 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-0b.md @@ -0,0 +1,11 @@ +# P0b:可选 Runtime 调试控制与入口场景 + +状态:verified。阶段提前原因:外部 App 运行适配必须调用受控调试端口,不能把 Runtime 对象/字典直接交给协议层;先验证核心,再接 UI。 + +AttachDebugging 显式创建借用会话;不引用 Editor/网络。InspectAsync 在执行门内复制变量、活动位置和 Group 编译身份。变量提交检查代次、UID、类型与观察到的原值,Float 限有限数;当前实现采用原值比较,并非每次写入都有独立 revision 的事件日志。 + +每 Group 仅保存最近一次自然进入时的入口逻辑场景;组内重建不覆盖它。场景缓存不存变量、Task 或平台句柄。回跳替换场景状态根,保留当前变量;显式路线验证真实后继后无条件追帧,未知指令必须声明 Scene/Ignore,否则预检拒绝。调试写入隔离器阻止重建条目通过 Runtime 写变量,不重复标记已读。 + +Pause 阻止普通推进、自动播放与自然完成后的继续;Choice 结果可延后至 Resume。普通 Restore 使缓存/代次失效;Stop/Restore 取消并清理正在进行的调试定位,旧操作不回写新状态。宿主须先停止 Engine、再释放借用调试 binding。 + +验证:Runtime.Tests 全部 104 项通过。新增 8 项覆盖重复回跳不覆盖入口、自然重进覆盖单份入口、跨 Choice 指定路线且忽略条件/变量写入、非法路线/未知模块预检、自然完成暂停、变量复制/类型/代次,以及 Stop/Restore 打断定位。尚未声明真实媒体或跨进程 UI 验收。 diff --git a/features/F-20261021-03-external-game-debugging/phase-1a.md b/features/F-20261021-03-external-game-debugging/phase-1a.md new file mode 100644 index 00000000..2c2df017 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-1a.md @@ -0,0 +1,11 @@ +# P1a:独立调试协议与本机连接 + +状态:verified(协议子阶段)。 + +新增可选 GalNet.Debugging 组件,Runtime 不反向引用。Editor 侧 DebugListener 随机 loopback 端口与会话凭据,App 侧 GameDebugHost 显式连接;握手检查协议、项目与内容指纹。长度帧有上限,双向请求有并发上限,断线取消连接所属工作。 + +App 连接独立于 IDebugGameRun,Attach/Dispose binding 控制当前运行;可选开始回调。状态查询不进入长命令队列,取消独立投递,执行命令在队列中重新检查运行身份和代次。旧 binding 再次释放不能解绑新运行。 + +已有真实 TCP 测试覆盖连接后的绑定/解绑/替换、旧运行拒绝、可选开始回调和错误身份不能占用监听。此子阶段尚不表示 Editor 菜单或 Sample 默认适配已完成,P1b 继续。 + +验证:Debugging.Tests 4 项通过,Architecture.Tests 47 项通过;新增项目依赖锁已生成。不把同进程 TCP 测试声称为完整跨进程 GUI 验收。 diff --git a/features/F-20261021-03-external-game-debugging/phase-1b-2a.md b/features/F-20261021-03-external-game-debugging/phase-1b-2a.md new file mode 100644 index 00000000..28d04e68 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-1b-2a.md @@ -0,0 +1,22 @@ +# P1b / P2a:宿主接入、Editor 面板和运行变量 + +状态:verified(本记录列出的范围);P1 的真实外部 Sample 人工闭环仍待 P6,不等同 feature 完成。 + +## 交付 + +- `EngineDebugRun` 将可选 Runtime 调试会话接入独立协议;默认 Avalonia 宿主仅增加中立的可选绑定回调,不依赖通信组件。Sample 在命令行收到完整调试邀请时连接,标题页可连接,开始回调可选,停止解绑当前运行。 +- Editor 的“测试”菜单、项目级调试 Dock、项目设置中的本地启动配置。先保存并通过正式构建器产出 `Output/Debug`,再监听并启动指定应用;测试 profile 位于项目 `.galnet/debug-profile`,启动参数使用 ArgumentList。 +- 当前实例 Player/Save 变量分栏;轮询权威状态(150ms,当前实现为全量查询),草稿不被刷新覆盖,应用检查运行身份、执行代次、类型及原值。关闭 Dock 保留项目级连接与草稿,关闭项目断开。 +- 当前 Group/作者 Entry 独立高亮;使用编译 SourceMap 映射,不猜测编译数字序号。内容编辑后停止使用旧映射。Graph/Entry 右键定位与有界路线列表先接入现有 Runtime 追帧端口。 +- 路线规划优先唯一、覆盖全部选中节点的直接后继链;即使另有短路,也不丢弃选中节点。保留平行出口身份,候选/深度/搜索量有上限。 + +## 证据 + +- `dotnet test test/GalNet.Debugging.Tests/GalNet.Debugging.Tests.csproj --no-restore -v minimal`:5 项通过,包括真实 TCP 上 Engine 绑定、变量读写、跳转换代、旧请求拒绝和停止解绑。 +- `dotnet test test/GalNet.Editor.Shared.Tests/GalNet.Editor.Shared.Tests.csproj --no-restore --filter FullyQualifiedName~DebugRoutePlanner -v minimal`:3 项通过。 +- `dotnet test test/GalNet.Editor.Tests/GalNet.Editor.Tests.csproj --no-restore --filter FullyQualifiedName~ExternalDebugPanelConnects -v minimal`:1 项通过。实际挂载 Dock View,通过 loopback TCP 更新两作用域、修改变量、保留冲突草稿、检查 Graph/Entry 高亮及作者历史不变、定位后变量保留、断线/关闭项目清理。 +- Sample 构建通过,0 errors;已有 Avalonia 资源构造器 warnings 保留。 + +## 尚未验收 + +上述 TCP 测试的两端运行在同一测试进程,并非外部 Sample 媒体人工验收。文件选择器、真实 Sample 标题页开始/停止、进程重开留给 P6。P3 的断点/作者单步、P4 的目标条件一次豁免和部分跨组边界仍继续实现;缓存性能与固定锚点没有宣称交付。握手目前验证协议/项目/编译内容指纹;没有单独的运行契约实现指纹。 diff --git a/features/F-20261021-03-external-game-debugging/phase-3-4.md b/features/F-20261021-03-external-game-debugging/phase-3-4.md new file mode 100644 index 00000000..c2562f90 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-3-4.md @@ -0,0 +1,23 @@ +# P3 / P4:作者边界控制与路线定位 + +状态:implemented,定向验证通过;完整跨进程/媒体验收归 P6。 + +## 已实现 + +- DebugSession 仍复用 Engine 唯一执行门,按编译 primitive 范围接受作者断点和单步;Editor 从 SourceMap 生成范围,不让 Runtime 依赖作者文档。 +- 断点在条目前触发,继续会越过本次命中,循环再次到达仍命中。追帧忽略断点,定位目标前暂停。单步在一个作者范围内按普通条件/阻塞/Skip 规则推进,遇到阻塞即暂停,不能跨到下一个作者范围;它不会强行完成不可跳过的媒体。 +- 定位请求可携带目标作者范围的 end;显式继续/单步时仅该展开范围豁免条件,越界恢复普通条件,普通读档/新定位使旧豁免失效。原作者 Condition 不修改。 +- 自然入口捕获不在跨组追帧中触发;回到此前自然进入的 Group 仍恢复其原始入口,不会被合成场景污染。 +- 活动条目 DTO 增加 Group 身份,避免并发跨组呈现的同名 primitive 被映射到错误作者条目;下一执行位置仍独立在状态中提供。 +- Graph 可显式选择“以初始场景从此处测试”,用于与根断开的独立 Group;不会暗中将不可达目标视为合法路线。 +- 路线候选显示节点名称和出口编号,截断有提示;唯一完整选中链必须在预算内完整证明,搜索被截断时不伪报唯一。 + +## 验证 + +- Runtime 107 项通过(原 104 + 3);覆盖目标 false 条件复合展开、下一条条件保持、断点前停/单步不越作者范围、自然入口不被跨组追帧覆盖及独立起点。 +- Debugging 5 项、路线规划 3 项通过;实际挂载 UI 的 TCP 集成测试加入断点标记、单步与作者历史保持。 +- 测试取消断言改用 `CatchAsync`:Task 取消可能抛其子类 TaskCanceledException,不能用精确类型断言制造偶发失败;超时 TimeoutException 仍失败。 + +## 边界 + +UI 更新采用有界全量轮询,无增量位置事件。单步不会完整冻结正在播放的媒体;自然完成仅停止后续分发。当前没有图上编辑重复环次数的专用工具,运行端可以接受显式重复边序列,常规多选/候选不会遍历环。固定入口证明、热点前缀预计算还未启用。 diff --git a/features/F-20261021-03-external-game-debugging/phase-5.md b/features/F-20261021-03-external-game-debugging/phase-5.md new file mode 100644 index 00000000..f8cb908a --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-5.md @@ -0,0 +1,12 @@ +# P5:有界的最近自然入口缓存 + +状态:implemented,Runtime 验证通过;热点前缀/固定入口预计算明确延期。 + +- 每 Group 最多一份最近自然入口;默认总计最多 256 个 Group、64 MiB 序列化状态 payload 预算,超过按 LRU 淘汰。超大单项不缓存。0 可关闭入口缓存。初始运行场景单独保留,未计入入口 payload 预算。 +- 预算衡量的是纯状态序列化数据及固定元数据开销,不是精确托管堆容量;不保存媒体、控件或 Task。状态 DTO 提供缓存条数、payload 字节、上限与最近追帧耗时/指令数/是否入口命中。 +- 淘汰后的同组回跳,Editor 改从游戏根规划合法路线;无法构建时仍可显式选择独立初始场景测试。普通读档清空入口和统计;自然再次进入覆盖原值,调试追帧不覆盖。 +- 不缓存热点前缀:当前变量可影响场景参数,尚无可靠读集/纯状态 reducer;每次按当前变量重建前缀,避免过期复用。固定预计算也未启用:现有存在性分析不能证明资源、坐标、音轨及所有模块状态一致。没有后台预跑游戏/任意插件代码。 + +验证:Runtime 110 项通过(新增 3 项);涵盖关闭/超大项、LRU 条数限制、普通读档失效、缓存与根路线重放的完整模块状态一致。2000 条根前缀 + 200 条目标 Group 的人工 primitive 测量:入口重建目标前 150 条为 15.40 ms / 150 条;根路线重建为 27.03 ms / 2151 条;入口 payload 396 bytes。此为本机合成逻辑负载的单次观测,不含真实媒体解码,不作为 SLA。 + +命令:`dotnet test test/GalNet.Runtime.Tests/GalNet.Runtime.Tests.csproj --no-restore -v minimal`;统计观测使用 `--no-build --filter FullyQualifiedName~LatestEntranceLru --logger "console;verbosity=normal"`。 diff --git a/features/F-20261021-03-external-game-debugging/phase-6.md b/features/F-20261021-03-external-game-debugging/phase-6.md new file mode 100644 index 00000000..264f45c5 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-6.md @@ -0,0 +1,56 @@ +# P6:跨进程验收、回归与收尾 + +状态:verified-with-follow-up。核心用户范围已交付;以下既有失败、优化延期及未人工检查项不宣称完成。 + +## 实际跨进程与 UI + +- 显式测试启动独立 Avalonia Sample 原生进程,标题页握手、可选开始、Player 修改、定位 false 目标、单步、停止、重开、旧 runId 拒绝及正常关闭均通过,退出码 0;停止后重开/最终文件读取仍为手改的 Player 值。 +- 邀请独立校验项目、构建内容和可选契约 DLL SHA256。Editor 使用已加载的项目契约指纹;Sample 从实际加载的 ProjectSchemas 程序集计算,不能把命令行预期值直接当实际身份。解析测试确认错误契约拒绝、正确契约接受。 +- 挂载实际调试 Dock、GraphNodeControl 和 EntryListEditorControl,通过真实 loopback TCP 校验两作用域变量、草稿冲突/重置、位置/断点、单步及历史不变。进一步断言实际 BorderThickness,避免只验证 ViewModel 标记。 + +## 验收中修正 + +- 停止/取消绕过普通命令队列,停止不会排在不可跳过的长追帧后;断线取消旧请求,默认 Engine 适配清断点并解除暂停。运行操作代次读取改为原子操作。 +- 普通读档与调试恢复通过同一执行门中的显式身份/代次检查区分,不再使用门外临时恢复标记;普通读档原子移除旧重放策略和缓存,旧 finalizer 不能覆盖新场景。 +- 前跳的当前场景基线在旧活动实例提交 CatchUp 终态并清理后捕获,避免恢复动画中途状态;收束当前未读对白同样抑制已读写入。 +- Runtime 本地变量归一化前缀,桥接回写按 UID 保留原存储键;默认 Avalonia 运行停止/释放时刷新玩家变量,避免面板当前值正确但重开/落盘仍旧值。 +- Sample Closing 在 DI scope 释放前幂等解绑窗口,Closed 不再从已释放容器解析服务。绑定回调失败也通过完整 adapter 清理订阅与运行。 +- 超出 4 MiB 的响应返回有界错误并保留连接,避免请求一直等待;能力不支持的暂停/继续/单步按实际 capability 禁用。 +- Entry 高亮移除会压过 class style 的本地 Border 值;断点标记移到编号旁,保留原拖拽手柄结构。损坏的本地启动配置显示错误而不阻止面板/项目设置构造;项目设置修改后启动可重新加载配置。 + +## 验证证据 + +命令均在主工作区依次执行,不并发构建共享 obj: + +| 验证 | 结果 | +| --- | --- | +| Runtime 全量 | 112 passed;最后普通读档策略清理修订再跑 DebugSession 16 passed | +| Debugging 全量 | 10 passed,含身份不符、超大响应、长重放停止/断线 | +| Storage.FileSystem 全量 | 22 passed,含前缀/UID/实际重新加载 | +| Editor.Shared 全量 | 384 passed;路线定向 3 passed | +| Editor 全量初轮 | 252 passed / 5 failed;两项拖拽结构回归已修复,Headless Dispose 偶发项重跑通过 | +| Editor 最终回归 | 排除已独立复现的两项既有布局用例后 255 passed;实际调试及拖拽等六项定向 4 passed / 2 基线 failures | +| Integration 全量 | 111 passed / 1 既有资源 fake failure;排除该项后 111 passed | +| 原生跨进程显式测试 | 1 passed;默认全量不会自动打开原生窗口 | +| Architecture | 47 passed | +| GalNet.slnx build --no-restore | passed,0 errors;现有分析器/Avalonia warnings 保留 | +| git diff --check | passed | + +跨进程命令:`dotnet test test/GalNet.IntegrationTests/GalNet.IntegrationTests.csproj --no-restore --filter FullyQualifiedName~ExternalSampleConnectsAtTitleStartsEditsSeeksStopsAndRejectsOldRuns -v minimal`。 + +## 未混入本 feature 的既有失败 + +1. `GroupToolbarBatchesAreSingleHistoryStepsAndSpaceInsertsOneAtCapturedAnchor`:滚动尾部间距为 0,断言要求至少 40。 +2. `GroupTransformAndColorUseRealControlsSingleHistoryAndLosslessSave`:窄布局行宽为 482,断言要求小于 480。 + +两项在 UI 接入前提交 `40ae34d` 的独立 `git archive` 快照中重新还原、编译并运行,失败值完全相同;对照目录/ZIP 已安全删除。没有修改原断言或跳过它们来伪报全量通过。最终过滤命令:`--filter 'FullyQualifiedName!~GroupToolbarBatches&FullyQualifiedName!~GroupTransformAndColor'`。 + +3. `DefaultPresentationUsesAndReleasesInjectedAdapters`:既有 Assets fake 的 GetFilesAsync 抛 NotSupportedException,被图像变体 catalog loader 调用。该 fake 和 AvaloniaGamePresentation 相对 `40ae34d` 无 diff,异常发生在 Engine/调试绑定创建之前;本次保留,没有修改其他 feature 的适配。过滤命令:`--filter 'FullyQualifiedName!~DefaultPresentationUsesAndReleasesInjectedAdapters'`。 + +## 范围修订和后续 + +- 首版采用 150ms 全量状态轮询;没有交付增量事件流。运行身份/执行代次/变量原值仍严格校验。 +- 按用户最终确认仅缓存最近自然入口;热点前缀和固定入口预计算缺少完整状态证明而延期,已提供 payload/条数预算和测量,不在项目打开时枚举路线或预跑插件。 +- 原生验收覆盖默认宿主的实际对白/恢复链;没有逐一人工验收音视频/特效所有组合、文件选择器和多路线弹窗的视觉效果。暂停是流程暂停,媒体时钟未完整冻结。 +- 没有专用重复环次数编辑器;普通多选与候选不遍历重复节点,运行协议可传入有界显式重复边。 +- 正式操作与接入以 [外部游戏调试手册](../../docs/manual/external-game-debugging.md) 为准。 diff --git a/features/F-20261021-03-external-game-debugging/phase-contract-identity.md b/features/F-20261021-03-external-game-debugging/phase-contract-identity.md new file mode 100644 index 00000000..e658ad9b --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-contract-identity.md @@ -0,0 +1,24 @@ +# 稳定契约构建身份 + +日期:2026-10-23。状态:verified。 + +## 问题 + +项目与导出目录 DLL 一致,重新构建的 Sample DLL 却再次不匹配。旧文件的 AssemblyInformationalVersion 包含 Git commit 285e8a0,新文件包含 3be07c2;两个 14848 字节 DLL 的反编译 IL 文本 SHA256 完全相同。当前协议按整个 DLL 的 SHA256 比较,版本元数据和 PDB 内 SourceLink URL/校验和也参与文件身份。 + +只去除项目契约自己的 InformationalVersion 后,不同 SourceRevisionId 的真实重建仍产生不同 SHA256:契约编译的 Core/Builtins 引用元数据也会随提交变化,不能仅靠单个属性断言已修复。 + +## 修正 + +- `Directory.Build.targets` 对 GalNet.Project.Contracts、Core 和 Contracts.Builtins 稳定 Git 相关 InformationalVersion 和 SourceLink 元数据;保留 PDB 与本地源码调试。其他 App 程序集仍可携带真实提交身份。 +- 保留严格二进制匹配,不引入“相同版本号即兼容”或绕过真实契约检查。 +- Sample 不匹配时报告预期和实际 SHA256,明确需要部署并重开项目。 +- 增加实际两次 Rebuild 的 SourceRevisionId 稳定性检查脚本;契约源码、依赖 API、生成器或编译配置变化仍可能需要重导出。不同构建路径/SDK/配置间不承诺相同哈希。 +- 本修正会使现有带提交信息的契约产物一次性变化,需重新导出/部署并重开项目。 + +## 验证 + +- `scripts/test-contract-reproducibility.ps1` 成功:SourceRevisionId 为 40 个 1 和 40 个 2 的两次真实 Rebuild 均产生 `C5D6C3E62379955CFF5E943976EF5A15CAAC1BB1B06754D9B176263B0D338AFA`。 +- 正常构建并运行导出脚本成功,0 错误;导出目录与实际 Sample App 契约同为该 SHA256。PDB 保留,新增错误同时包含实际/预期哈希。 +- 复用刚构建引用运行 Integration 的实际契约身份检查与真实失败子进程回归,两项通过;未放宽协议身份比较。 +- 构建有既有分析/XAML 警告及 NuGet 漏洞查询网络警告;不声明全解决方案测试通过。项目中已加载的旧契约不会自动替换,需要用户关闭项目、部署导出 DLL 后重开。 diff --git a/features/F-20261021-03-external-game-debugging/phase-launch-diagnostics.md b/features/F-20261021-03-external-game-debugging/phase-launch-diagnostics.md new file mode 100644 index 00000000..03222afe --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-launch-diagnostics.md @@ -0,0 +1,25 @@ +# 启动诊断与契约导出修正 + +日期:2026-10-23。状态:verified-with-manual-follow-up。 + +## 触发与范围 + +用户反馈启动没有反应。读取实际项目启动配置、对比项目与 Sample 的实际契约 DLL 哈希后,以相同预期契约参数启动 Sample,复现 `InvalidDataException: The App contract differs from the Editor project`。契约校验先于日志初始化,原 Editor 没有监控进程,导致等待连接而无提示。 + +用户要求提供导出 DLL 到根 output 的 ps1,并忽略生成物;Editor 应提示错误。作为已有 feature 的一个修正阶段提交,保留真实契约身份校验。 + +## 实现 + +- `scripts/export-sample-contracts.ps1` 构建 Debug/Release Avalonia Sample,验证实际 App 和契约产物哈希相同,将契约及依赖 DLL 导出至根 `output/Contracts`,打印对应 App 路径;`/output/` 加入 Git 忽略。 +- Editor 在项目会话内持有自己启动的进程监控;持续排空 stderr,只保留最近 8192 字符。连接前任何退出、连接后非零退出均通知现有调试面板 Error 与 Editor 日志;启动本身抛错也进入面板及日志。 +- 断开时取消等待/读取并释放进程句柄,不杀 App;会话取消和代次检查防止旧进程污染新会话。 +- Sample 顶层捕获日志初始化前的参数/契约错误,向 stderr 输出异常,以退出码 1 结束;运行期 fatal 同样保留非零退出码。 +- 未扩展为任意应用通用日志协议;IDE 自行启动的进程仍由 IDE 管理。 + +## 验证 + +- 脚本实际执行成功,输出三个 DLL,导出契约与 Sample 里的 DLL SHA256 一致,`git check-ignore` 确认输出已忽略;构建 0 错误,环境无法查询 NuGet 漏洞数据产生 NU1900 警告。 +- Integration 的外部 Sample 调试定向测试 2 项通过(原契约校验 + 新真实子进程失败路径);新测试确认非零退出、具体异常、Changed 通知、断开后新会话 Error 清空,不需要原生窗口。 +- Editor 调试面板挂载回归构建被正在运行的 .NET Host 锁定默认 Editor 输出目录,MSB3027/MSB3021;不关闭用户进程,改用隔离输出验证构建。实际窗口提示待重启人工确认。 +- `dotnet build src/Editor/GalNet.Editor/GalNet.Editor.csproj --no-restore --output output/Editor -v quiet` 成功,0 错误、67 个现有/环境警告,隔离可执行产物可用于重启验证。 +- `git diff --check`:通过;不包含另一个 feature 开工时已有的 phase-plan.md 改动。 diff --git a/features/F-20261021-03-external-game-debugging/phase-liveness-diagnostics.md b/features/F-20261021-03-external-game-debugging/phase-liveness-diagnostics.md new file mode 100644 index 00000000..5ab772bd --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-liveness-diagnostics.md @@ -0,0 +1,30 @@ +# 启动存活提示与可分享诊断日志 + +日期:2026-10-24。状态:implementing。 + +## 用户反馈 + +Editor 启动游戏测试不应无限等待;出现启动或响应问题时,显示错误即可,不要求弹窗。后续需要能让用户提供足够上下文的日志。 + +## 实现边界 + +- 本地启动后最多等待 30 秒完成认证连接;超时报告错误但继续监控并保留迟到连接机会,不杀 App。 +- 调试请求最多等待 60 秒;状态与检查轮询最多等待 10 秒。请求超时提示远端操作可能仍在运行,不自动重试。 +- 每次调试准备在测试 profile 的 `logs/` 中创建一个 Editor 诊断文件,与 Sample 的 `game-*.log` 放在一起。 +- 诊断文件最多 1 MiB、保留最近 20 份;包含保存、构建、作者映射耗时,启动元数据,连接/请求事件,进程退出码和 App stdout/stderr。调试 token 不写入启动元数据,捕获输出中的 token 会被遮盖。 +- 现有调试面板错误区与 Problems 面板用于提醒;不引入模态窗口,不终止 Editor 启动的外部 App。 + +## 当前改动 + +- `ExternalGameDebugClient` 增加连接与请求超时,并在调试面板错误中带出诊断文件路径。 +- `DebugLaunchDiagnostics` 维护有界、逐次启动的会话日志。 +- 子进程 stdout/stderr 转发到现有 Game Log Panel 和 Serilog Console sink;断开后不等待外部进程退出,并继续排空重定向输出,避免子进程管道阻塞。 +- 内容构建和作者源码映射放到后台执行,Debug Dock 显示保存、构建、作者映射与启动阶段;相关状态文案补充四种本地化。 +- 接入手册和 feature 验收范围已更新。 + +## 验证与收尾 + +- Editor 构建:`dotnet build src/Editor/GalNet.Editor/GalNet.Editor.csproj --no-restore --output Temp/F-20261021-03-debug-diagnostics-build` 成功,0 错误;包含已有分析器/XAML 警告。 +- 待检查:四语 JSON 完整性、代码格式与差异范围。 +- 待验证:无连接超时、契约校验失败日志、正常连接/运行日志、请求无响应错误,以及超时后进程未被终止、迟到连接仍能恢复。 +- 当前未运行测试或真实 Sample 行为验证;本次按用户要求先提交进行中实现,行为验证完成前不标记 verified。 diff --git a/features/F-20261021-03-external-game-debugging/phase-panel-ui.md b/features/F-20261021-03-external-game-debugging/phase-panel-ui.md new file mode 100644 index 00000000..2a28a14c --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-panel-ui.md @@ -0,0 +1,22 @@ +# 调试面板 UI 整理 + +日期:2026-10-23。状态:verified。 + +## 需求和方案 + +用户希望参考 VS 使用无背景图标按钮,改善游戏调试面板 UX,将当前状态放在下面并保持整齐。本阶段复用已有调试命令及可用性,不修改协议、游戏行为和变量同步策略。 + +- 顶部工具栏按启动/连接、运行控制、步进/跳转/断开分组,统一 30px 透明图标按钮;保留悬停/按下/键盘焦点/禁用反馈、四语提示与无障碍名称。窄 Dock 按功能组换行。 +- 启动配置继续默认折叠,字段使用独立标签,参数文本区域有高度上限。 +- Player/Save 共用两行变量模板,显示名称、类型、当前值和草稿,应用与重置改为图标;已有冲突状态增加文字提示。 +- 底部固定连接指示与当前状态;错误限制最大高度、支持滚动和复制,不挤占整个变量区。空变量/Choice 与处理状态提供四语提示。 +- ViewModel 仅增加 HasError 展示通知,命令实现不变。 + +## 验证 + +- 正常输出目录被运行中的 Editor 锁定,改用 `--artifacts-path Temp/debug-ui-build` 独立构建,未关闭用户窗口。 +- 编译通过;新增测试初轮缺少 `using Avalonia` 导致 TranslatePoint 扩展方法不可见,补齐后通过。受限环境 testhost 连接超时,常规权限下同一隔离产物测试通过。 +- 实际挂载的 TCP 调试面板闭环 1 项通过:320px 深色/440px 浅色不横向溢出,状态区在底部;真实指针点击图标暂停/继续,变量更新、草稿冲突/错误显隐、定位/单步及作者历史不变均通过。 +- 截图 `Temp/debug-panel-artifacts/debug-panel-320.png`、`debug-panel-440.png` 已人工检查;窄工具栏按功能组换行,变量名/类型及两列值可读,底部状态固定。 +- EditorLocalizationTests 3 项通过;四语各 958 keys、34 个 Debug keys,无缺项;`git diff --check` 通过。 +- 只验证本阶段相关用例,不宣称全解决方案测试通过。未提交游戏资源和其他 feature 的并行改动。 diff --git a/features/F-20261021-03-external-game-debugging/phase-plan.md b/features/F-20261021-03-external-game-debugging/phase-plan.md new file mode 100644 index 00000000..f7efbd66 --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-plan.md @@ -0,0 +1,102 @@ +# 外部游戏调试阶段计划草案 + +2026-10-23 契约 Git 构建元数据导致重复不匹配的修正见 [phase-contract-identity.md](phase-contract-identity.md)。 + +2026-10-23 调试面板 UI 整理见 [phase-panel-ui.md](phase-panel-ui.md)。 + +2026-10-23 启动诊断与契约导出修正见 [phase-launch-diagnostics.md](phase-launch-diagnostics.md)。 + +2026-10-23 游戏窗口关闭停顿调查见 [phase-shutdown-investigation.md](phase-shutdown-investigation.md);补充耗时日志和真实关闭回归,尚未复现用户停顿,不宣称已修复。 + +状态:verified-with-follow-up;核心范围完成,固定入口/热点前缀优化延期,既有回归失败和人工媒体检查明确保留。最终证据见 [phase-6.md](phase-6.md),使用方式以正式手册为准。 + +修订:用户明确跳转为“选定路线、忽略条件追帧”;原 P4 历史回溯/P5 历史分叉方案被本版替代。P4 先保证无缓存正确性,P5 再优化,避免将优化当成定位的必要前提。 + +## 通用规则 + +- 每个实施阶段完成适用测试/构建、记录结果和已知限制后,提交一次 Git,并向用户报告 commit。 +- 只提交本阶段所属改动,不混入其他 feature;不以接近预算或只有单元测试通过伪报 verified。 +- 阶段记录写明实际变更、测试命令/结果、未执行检查与偏差;已验证行为再同步 spec/manual。 +- 阶段大小可因执行门/恢复机制的实际耦合调整,保留原因;不让未交付定位能力显示为可用。 + +## P0:Runtime 当前 Group 末尾追帧 + +- 状态:verified。先行用户明确要求的底层能力,不代表外部调试已交付。 +- 实现:SkipToGroupEndAsync,未读不拦截、条件策略显式、当前 Group 末尾停点、下一次 Advance 才跨组,共用追帧清理与取消。 +- 验证:Runtime 全部 96 项通过;针对新入口及旧播放追帧 33 项通过。包括未读、条件/变量、后继 Choice、自环、空/末组、清理同步、取消/停止、错误传播与并发请求。 +- 记录:[phase-0.md](phase-0.md)。独立提交在本阶段完成。 + +## 先行 P0b:可选 Runtime 调试会话 + +- 状态:verified;依赖 P0。为 P1b 的真实运行适配先提供受控端口,避免通信层直接访问可变 Runtime。 +- 交付:独立状态 DTO、按运行代次/原值校验的变量写入、流程暂停、Choice 身份、每 Group 最近自然入口一份缓存、显式路线的无条件场景追帧。 +- 验证:Runtime 104 项通过,新增 8 项覆盖缓存覆盖/保护、场景回跳、变量保留、路线/条件、旧代拒绝、暂停和停止/读档打断。 +- 记录:[phase-0b.md](phase-0b.md)。后续 P2–P4 复用此基础;尚不宣称 UI 已验收。 + +## P1:最小跨进程连接与运行绑定 + +- 状态:verified,跨进程默认宿主与挂载 Editor 组合验证,见 P6;文件选择器人工检查未执行。前置:设计范围明确。 +- 拆分:P1a 独立协议与真实 loopback 连接;P1b Sample/Editor 的启动与连接交互。先做 P1a,再让 UI 复用同一通信模型,避免临时内嵌协议。 +- P1b/P2a 已实现并验证可选宿主绑定、菜单/Dock、本地启动配置、真实 TCP 变量双向修改及作者映射;见 [phase-1b-2a.md](phase-1b-2a.md)。共享面板/通信生命周期需要同阶段集成提交;P1 的外部 Sample 人工闭环和 P2 的更多生命周期回归仍待 P6。 +- 模块:可选调试组件、传输、游戏组合根/宿主适配、Editor.Shared/Editor。 +- 任务:协议/能力/身份,显式启用,App 与运行生命周期分离,启动配置与等待外部连接,可选开始回调,停止运行,基本日志与连接状态。 +- 验证:未启用不连接;有/无开始回调;标题页连接后进入游戏;重开旧请求拒绝;断线、错误项目、指纹和协议不符;默认宿主与最小自定义适配。 +- 文档:接入示例、启动配置与协议约束;按实际依赖更新架构测试。 +- 风险:调试服务越过宿主生命周期直接拥有 Engine;机器路径进入发行设置。 +- 退出:真实 Editor→外部 Sample 的连接/开始/停止闭环通过,相关构建与架构检查通过,阶段记录及独立提交完成。 + +## P2:当前运行变量 Dock 与同步 + +- 状态:verified;核心面板、两作用域、草稿冲突、桥接原键及 Player 停止后持久化均有证据,见 P2a/P6。实现偏差:首版全量轮询,没有增量事件流。前置:P1。 +- 模块:Runtime 受控查询/写入、宿主变量适配、调试客户端模型、Editor Dock。 +- 任务:Player/Save 全量及增量,独立 DTO,类型化编辑与手动应用,每变量冲突版本、运行代次校验,草稿保护和事件回显;测试 profile 隔离。 +- 验证:实际游戏修改刷新面板,面板修改影响后续条件;两作用域及服务缓存一致;类型/数值边界,旧运行/同值/冲突,关闭重开、增删恢复和断线重同步;作者 dirty/历史不变。 +- 文档:实例变量与作者变量区分、同步/持久化语义、默认测试数据目录。 +- 风险:现有查询返回内部引用;直接改服务导致当前 Runtime 不变;前缀归一化误写。 +- 退出:真实面板双向修改闭环和生命周期回归通过,记录及独立提交完成。 + +## P3:执行边界、位置、暂停与断点单步 + +- 状态:verified;定向及 P6 回归通过,媒体完整冻结不在交付范围。见 [phase-3-4.md](phase-3-4.md)。前置:P1;集成回归包括 P2。 +- 模块:Runtime 执行门/控制流、编译映射交付、调试组件、Editor 高亮与操作。 +- 任务:作者/primitive 双向映射,当前展示与下一执行位置分离,Group/Entry 实时高亮与控制流事件;流程暂停/恢复、作者断点、单步和 Choice 操作。 +- 验证:Composite 一次命中、条件跳过、循环访问、阻塞自然完成与暂停交错;Choice 结果过期;自动推进不绕过暂停;暂停不持门等待 Editor;内容版本不一致禁用错误映射。 +- 文档:暂停与媒体冻结边界、推进与单步区别。 +- 风险:暂停只拦玩家 Advance 而遗漏内部 continuation;引入第二个执行调度器。 +- 退出:各执行入口共用门禁,真实高亮与断点/Choice/步进验证通过,记录及独立提交完成。 + +## P4:指定路线、忽略条件的场景追帧定位 + +- 状态:verified;路线/Runtime、实际高亮和原生 Sample 定位均有证据;完整媒体组合人工检查保留。见 [phase-3-4.md](phase-3-4.md)。前置:P2、P3。 +- 模块:纯路线规划、Runtime 有界执行策略、调试定位协调、宿主场景重建和 Editor 右键/路线列表。 +- 任务:合法完整多选链优先;边/出口级路线及歧义选择、循环边界;Entry/分支/Choice 条件只在本次追帧中忽略;同组较早/较后及跨组定位;无缓存时从明确基线构建;目标前暂停与一次目标执行语义;变量/副作用策略确认后落实。 +- 验证:B→C→D→E 选中链、选中集合乱序/分叉/断链、平行边、非 Group 中转、多路线候选上限;false 条件仍追帧、非法参数仍拒绝;同组倒退不残留后续图层;目标定位不跳过 Composite 开头;变量保持策略与未知插件;取消/停止、旧 Choice/事件失效。 +- 文档:基线、路线、忽略条件边界、变量策略、目标落点和合成场景含义。 +- 风险:把忽略条件扩展到忽略参数错误;默认拿当前场景向过去重放;候选全路径指数爆炸;永久修改原内容条件。 +- 退出:不依赖缓存的 Graph/Group 跳转闭环通过,普通执行条件不受污染,记录及独立提交完成。 + +## P5:按需缓存、固定场景锚点及性能验证 + +- 状态:verified-with-deferred-optimization,入口缓存/预算/逻辑性能已验证;热点前缀及固定锚点因证明不足明确延期,见 [phase-5.md](phase-5.md)。前置:P4。 +- 模块:调试场景缓存、独立 IgnoreConditions 分析策略/证明结果、纯状态 reducer 和宿主恢复适配。 +- 任务:按需缓存合法 Group 入口和热点 Entry 前缀;内容/资源/契约/模块/变量输入/基线/路线前缀 cache key;LRU 字节预算;仅对白名单、证明完整且一致的域预计算共享固定锚点,其他使用路线专属缓存;后台预算/取消。 +- 验证:命中/未命中语义等价;相同对象不同资源/位置/音轨不能错误合流;未知对象、部分分析、循环不收敛不授予固定性;变量/资产/模块变更失效;不同条件策略不串缓存;大型分支图、长 Group 的首次/重复定位耗时、缓存字节与 UI 响应。 +- 文档:缓存范围/失效/内存预算,固定与部分固定的证据边界,性能测量结果。 +- 风险:将只有 Definite 候选等同完整快照;离线运行自定义 Entry;预计算拖慢项目打开;缓存媒体句柄。 +- 退出:P4 无缓存基线回归保持通过,缓存等价与内存上限通过,有性能测量证明收益;不能证明的固定预计算明确延期,记录及独立提交完成。 + +## P6:集成验收、独立审核与收尾 + +- 状态:verified-with-follow-up,见 [phase-6.md](phase-6.md);不能将隔离复现的既有失败解释为解决方案全测试通过。前置:P1–P5。 +- 模块:跨进程集成测试、接入文档、相关架构规范与 feature 记录。 +- 任务:默认宿主与最小自定义宿主真实接入,跨进程断线/重开/旧事件,路线/缓存预算,Graph/Entry 高亮、变量面板和定位交互验收;整理 review/summary 和稳定文档。 +- 验证:适用测试与解决方案构建、UI 实际交互、人工媒体/文件选择检查;报告环境跳过与遗留项。 +- 风险:只测进程内 fake、未测真实传输/UI;将人工未验收项写为已通过。 +- 退出:需求逐项对照、未完成事项明确,文档与代码一致,独立提交并报告 commit;满足范围后才标记 feature 完成。 + +## P7:启动存活提示与可分享诊断日志 + +- 状态:implementing;用户反馈日期 2026-10-24,阶段记录见 [phase-liveness-diagnostics.md](phase-liveness-diagnostics.md)。 +- 范围:Editor 启动后 30 秒仍未完成连接时显示错误;调试请求 60 秒无响应时结束面板等待并提示操作可能仍在运行;连接状态轮询有 10 秒请求上限。超时不杀进程。 +- 日志:在测试 profile 的 `logs/` 下保存每次会话的 Editor 启动诊断日志,最多每次 1 MiB、保留最近 20 份;收集 App stdout/stderr、准备阶段耗时、连接/请求事件、超时和退出码,错误面板给出文件路径。启动元数据不记录调试 token 或配置参数值。 +- 验证:编译 Editor.Shared/Editor,检查四语文案 JSON 和诊断限额;行为测试与 UI 人工重现待执行。按用户要求先提交进行中实现;行为验证完成前不标记 verified。 diff --git a/features/F-20261021-03-external-game-debugging/phase-shutdown-investigation.md b/features/F-20261021-03-external-game-debugging/phase-shutdown-investigation.md new file mode 100644 index 00000000..2baca93b --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/phase-shutdown-investigation.md @@ -0,0 +1,21 @@ +# 游戏窗口关闭停顿调查 + +日期:2026-10-23。状态:needs-reproduction;诊断和回归补充已验证,未确认或修复用户报告的停顿根因。 + +用户随后确认目前未再出现,决定复现后再处理。本阶段只保留诊断和回归,不继续修改关闭行为。 + +## 现象及范围 + +用户反馈点击游戏 App 关闭会卡住一会儿,明确暂不改变热更新。现场游戏日志最后停在 Engine Stopped 和切回标题页的 crossfade 开始,缺少正常 sample stopped;原日志没有关闭阶段耗时,不能据此判定具体阻塞位置。检查时已无目标游戏进程可以现场获取堆栈。 + +## 补充 + +- Sample 异步 Closing 增加启动关闭、运行停止、scope 释放、root 释放和最终关闭窗口的累计毫秒日志,不改变资源释放顺序或杀进程策略。 +- 原真实外部 Sample 生命周期测试增加直接关闭运行中游戏、长 Timeline 仍运行时关闭两种情况;关闭期间保持与 Editor 相同的 status/inspect 150ms 轮询,以五秒内正常退出约束覆盖回归,同时保留玩家变量持久化断言。 + +## 验证证据 + +- 三个原生独立进程用例通过:先停止再关闭、运行中关闭、带 Timeline 运行中关闭;均有并发轮询。TRX 中释放服务累计约 86–100 ms,未复现停顿。 +- 对用户当前实际构建内容使用隔离测试 profile 启动独立 Sample,开始游戏后两秒正常 CloseMainWindow,退出码 0,实测约 259 ms;停止耗时 22 ms,scope 释放累计 58 ms,root 释放累计 120 ms。未修改用户工程或玩家数据。 +- 尚需确认用户操作时的停顿时长、启动方式及下一次复现的新日志;不能把本次测试正常解释为问题已修复。 +- 最后补充“即将关闭窗口”日志后的构建完成,但复跑测试被 VSTest/testhost 连接 90 秒超时中止;不记为业务测试通过或关闭失败。之前三个原生用例通过的证据保留。 diff --git a/features/F-20261021-03-external-game-debugging/review.md b/features/F-20261021-03-external-game-debugging/review.md new file mode 100644 index 00000000..0c8f1cea --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/review.md @@ -0,0 +1,21 @@ +# 设计审核 + +## 实现复核(2026-10-22) + +结论:pass-with-follow-up。核心范围、执行门边界、可选依赖、变量身份、路线/缓存及真实跨进程闭环有验证。没有发现本 feature 尚未处理的交付阻断项;不把全仓既有失败写成通过。 + +- P1 已修复:停止排队在长重放之后、普通读档与门外调试恢复标记竞争、前跳捕获动画终态之前的旧场景、Sample 在 scope 释放后 Closed 解析服务、玩家变量归一化产生存储别名/未刷新。 +- P2 已修复:准备重放时当前对白写入已读、超大响应不回包导致等待、Entry 本地 Border 值压过高亮样式、断点标记破坏拖拽手柄、过期/冲突草稿写入、旧运行请求失效。实际挂载测试验证可见边框,不只看数据标记。 +- 阶段与依赖:GalNet.Debugging 独立可选,默认宿主只有中立回调;Runtime 无网络/Editor 依赖。原 API 的 Group 末尾追帧与调试场景定位保留不同条件/副作用策略。 +- 证据和保留项:[phase-6.md](phase-6.md)。两个布局失败在 `40ae34d` 隔离快照复现;资源 fake/变体 loader 失败源代码在接入前相同且发生于调试绑定之前。最终 Editor/Integration 明确带过滤,不宣称全量零失败。 +- 后续:固定/热点前缀预计算、增量事件、全部媒体组合和文件选择器/路线弹窗视觉人工检查;这些均未伪装成已交付优化。 + +以下为最初设计审核记录。 + +结论:pass-with-follow-up,用户已授权按阶段实施。此结论针对当前设计,不表示 P1–P6 已实现。 + +- 已补齐:每 Group 只保存最近自然入口;恢复/重建不覆盖入口;同组回跳恢复场景但保留当前变量。 +- 已补齐:Group 末尾不能通过 Stop 实现;必须在出边前停下,Manual,后续显式推进才跨组。 +- 已明确:普通玩家追帧执行原有条件/逻辑写入;调试场景定位独立控制条件和副作用,不能默认共享语义。 +- 后续验收重点:跨 Group 非阻塞呈现的清理/恢复,缓存真实完整性与独立 DTO,恢复代次、路线预算及真实跨进程 UI 验收。 +- 无需阻塞 Runtime 基础阶段;完整功能仍须完成后续各 Phase,不能用 P0 测试代替交付验收。 diff --git a/features/F-20261021-03-external-game-debugging/summary.md b/features/F-20261021-03-external-game-debugging/summary.md new file mode 100644 index 00000000..a7d24bdd --- /dev/null +++ b/features/F-20261021-03-external-game-debugging/summary.md @@ -0,0 +1,46 @@ +# 外部游戏调试实现总结 + +状态:verified-with-follow-up,2026-10-22。核心用户范围已实现;没有把既有测试失败或固定预计算延期写成完成。 + +## 交付行为 + +- “测试”菜单与项目级调试 Dock:本地应用路径/参数、保存校验编译后启动、IDE 等待连接、可选开始回调、暂停/继续/作者单步、停止/重开/断线。开发者显式启用,标题页连接与 Engine 生命周期独立。 +- Player/Save 当前实例变量面板:权威刷新、手工提交、草稿保留、类型/原值/身份冲突校验;不产生作者历史。Player 修改同步桥接服务,默认宿主停止/释放后刷新测试 profile。 +- Graph 与作者 Entry 独立运行高亮、Group/Entry 右键定位、唯一完整多选链优先、有界边/出口路线选择。SourceMap 保留作者映射,不能用 compiled 数字 Id 猜 raw 序号。 +- 定位通过忽略 Entry/Graph/Choice 条件的场景追帧实现,保留当前变量并跳过持久副作用;回跳恢复最近自然入口,前跳先收束活动呈现。目标前暂停,显式执行只豁免目标作者展开范围一次。任意断开的 Group 可显式从初始场景独立测试。 +- 每 Group 最多一份最近自然入口,默认总预算 256 个 Group / 64 MiB 序列化 payload,LRU 淘汰;普通读档失效。提供追帧耗时/指令数与缓存统计;不预跑所有路线。 +- `SkipToGroupEndAsync` 提供普通玩家当前 Group 末尾追帧底层功能,保持默认条件/副作用;停在出边前,下一次 Advance 才跨组。 + +## 边界 + +Runtime 不依赖网络/Editor/Avalonia;可选 GalNet.Debugging 依赖 Runtime,提供 TCP 与默认 Engine 适配;Avalonia 宿主只提供中立可选绑定回调。IGameRun/IGameRuntime 未为调试增加庞大接口。项目组合根决定是否启用、模块重放策略和开始/停止回调。 + +协议使用 loopback、随机邀请、内容/项目/可选契约指纹、运行身份与执行代次;有帧/并发上限,超大响应明确报错。测试 profile 与正式玩家目录分离。断线默认清断点并解除暂停,不终止 App。 + +## 阶段提交 + +| 阶段 | Commit | +| --- | --- | +| 设计和修订 | e75c31d / 4829a35 | +| P0 Group 末尾 | c6b78b3 | +| P1a 协议 | 8301d16 | +| P0b Runtime 调试端口 | c09089a | +| P1b/P2a 宿主与面板 | f3f9863 | +| P3/P4 作者边界与定位 | 98b2ac1 | +| P5 缓存与性能 | 9ed9718 | +| P6 验收修正与文档 | 本次独立收尾提交 | + +## 验证与保留项 + +完整命令、初轮失败、修复与隔离基线见 [phase-6.md](phase-6.md)。Runtime 112、协议 10、存储 22、Editor.Shared 384、架构 47 项通过。Editor 排除两项在旧提交 `40ae34d` 同样失败的布局用例后 255 项通过;Integration 排除一个既有图像变体 Assets fake 失败后 111 项通过。真实原生 Sample 显式跨进程闭环通过,正常退出码 0;解决方案构建 0 errors。 + +首版为 150ms 全量轮询,没有增量事件流。热点前缀和固定入口预计算缺少全状态证明而延期;存在性 Definite 不被当作完整场景固定。单次合成逻辑测量中,入口缓存将目标前重放从 2151 条减为 150 条,最终模块状态相同;不代表真实媒体耗时。 + +尚未逐一人工检查文件选择器、多路线弹窗视觉、音视频/特效全部组合;流程暂停不完整冻结媒体时钟,没有专用重复环次数编辑器。相关局限均在手册中说明。 + +## 正式文档 + +- [外部游戏调试接入与使用](../../docs/manual/external-game-debugging.md) +- [运行时规范](../../docs/spec/runtime.md) +- [Editor Graph 运行标记](../../docs/spec/editor-graph.md) +- [架构与程序集边界](../../docs/spec/architecture.md) diff --git a/features/F-20261023-01-editor-uiux/design.md b/features/F-20261023-01-editor-uiux/design.md new file mode 100644 index 00000000..e83df377 --- /dev/null +++ b/features/F-20261023-01-editor-uiux/design.md @@ -0,0 +1,25 @@ +# 设计 + +## 字段与草稿 + +Core 的 CommonAttribute 由生成器传入 EditorHints.IsCommon,默认 false。共享表单默认完整,Editor 行内显式选择 Common,Inspector 完整。对象保留原有专属编辑器。 + +项目编辑会话拥有字段草稿,以 Group、Entry、字段路径与源值标识;控件拥有展示及订阅,不拥有草稿。来源改变时冲突,不覆盖新作者值。有效输入通过现有作者意图提交,保存统一提交有效草稿,无效草稿保留并定位。 + +## 交互 + +选择与展开独立;列表提供摘要和常用参数,Inspector 显示完整参数、条件及目标身份。多选不隐式批量修改参数。辅助工具不清空详情,Inspector 可锁定。 + +保存状态独立于校验状态;Problems 面向当前作者快照,导航使用稳定身份与版本。原始日志保留,不从字符串猜测位置。 + +剧情、动画、调试布局手动应用;旧保存布局优先。切换、最大化与恢复不丢作者选择、文档或草稿,也不进入作者历史。Timeline 树宽可调,Graph 可搜索和适配,资源增加导入/新建、筛选与列表。 + +## hover + +已用 Avalonia 12.0.5 ColorAnimator 复现 Transparent(#00FFFFFF) → #FF313244:25%=#40e2e2e3、50%=#80bebec0,叠在暗背景会亮于端点。使用预乘透明度的 sRGB 线性插值,透明端点 RGB 不泄漏;160ms 线性过渡,支持从中间值反向。统一透明图标按钮保留焦点、禁用和切换状态。 + +首次使用反馈后的统一策略:背景/边框仅由实际绘制层执行过渡。Button/ToggleButton 外层作为模板绑定来源,不配置同一属性的第二次过渡;普通按钮由 ContentPresenter,图标按钮由 ButtonSurface,输入与菜单由模板 Border 执行。移除宽泛 Panel/TextBlock 过渡,避免额外的继承颜色追逐。时长集中在 EditorMotion.HoverDuration,保持 160ms、零延迟、线性曲线。图标切换按钮显式规定 checked、checked-hover、checked-pressed 的优先级,禁用状态不接受 hover/pressed 覆盖。 + +## 兼容 + +不改变作者 JSON、必填、默认值、运行时语义。保留现有历史与事务;自定义 DTO 可读取表单展示模式。新增 metadata 需要同源码重建契约程序集并核验指纹。 diff --git a/features/F-20261023-01-editor-uiux/feature.md b/features/F-20261023-01-editor-uiux/feature.md new file mode 100644 index 00000000..e5287926 --- /dev/null +++ b/features/F-20261023-01-editor-uiux/feature.md @@ -0,0 +1,15 @@ +# Editor UI/UX 优化 + +状态:implemented / automated-verified;用户于 2026-10-23 明确授权执行已确认方案。真实桌面 DPI/原生 IME 等人工验收保留为 follow-up,见 review。 + +目标:提高剧情编写、参数编辑、问题定位与工具导航效率;保留项目统一历史、作者 JSON、保存事务及自定义契约兼容性。 + +已确认:独立 DTO `[Common]` 标记行内默认字段,选中后 Inspector 完整编辑;手动布局预设;项目会话保存未完成输入;透明图标按钮;160ms 平滑且无亮度峰值的 hover。 + +范围:P1 按钮与 hover;P2 常用字段和项目草稿;P3 Group/Inspector;P4 保存状态和 Problems;P5 布局与导航。 + +非目标:参数多选批量编辑、游戏文件迁移、运行时语义改变、跨重启保留无效控件输入。 + +验收及实施顺序见 [设计](design.md)、[阶段](phase-plan.md)。现有其他 feature 与测试修改保留。 + +首次使用反馈见 [2026-10-24 后续记录](follow-up-20261024.md):人物 Enter 与 Inspector 焦点问题已修复;按钮 hover 最初仅分析,随后按用户授权实施 [动画统一](motion-unification.md)。 diff --git a/features/F-20261023-01-editor-uiux/follow-up-20261024.md b/features/F-20261023-01-editor-uiux/follow-up-20261024.md new file mode 100644 index 00000000..4774e09f --- /dev/null +++ b/features/F-20261023-01-editor-uiux/follow-up-20261024.md @@ -0,0 +1,40 @@ +# 首次使用反馈 + +原 UIUX 已先提交为 `d93bf52`(97 个文件)。原有其他 feature 修改及临时格式清单未纳入。以下为提交后的后续处理。 + +## 1. 按钮 hover 迟缓:仅分析 + +本轮没有改动 hover 实现。检查仓库样式,并通过 Headless 挂载读取已安装 Semi 12.1.0 的实际 ControlTheme 状态树与 VisualTree;临时诊断代码已移除,原始输出在忽略的 `artifacts/uiux/semi-hover-source.txt`。 + +确认的事实: + +- 普通 Button/ToggleButton 自身与模板 `PART_ContentPresenter` 都有背景/边框过渡,160ms、Delay=0、LinearEasing。Semi hover 直接修改 ContentPresenter;ToggleButton 的 checked 基础态修改外层 Background,checked-hover 又修改 ContentPresenter。 +- EntryTypePicker 的 OpenButton 是上述 ToggleButton。条目折叠箭头则使用自定义图标 ToggleButton,其高亮在 ButtonSurface Border;需要区分两条路径,不能因为都叫“展开按钮”就使用同一结论。 +- TextBox 的 hover 主要作用于 `PART_ContentPresenterBorder`;MenuItem 的可见高亮主要作用于 `PART_LayoutRoot` Border。两者同样是 160ms、无 Delay,但更接近单个绘制表面的状态变化。 +- 普通按钮的 Fill0→Fill1 背景变化与 TextBox 边框反馈对比不同。以 Violet 为例,前者为 #313244→#45475A;较弱的早期变化可能被感知为“停了一会儿才亮”。 + +推断与待证实部分:重复动画层及模板绑定在 checked/pressed/hover 交接时可能产生追逐或重启过渡;普通 hover 直接写 ContentPresenter 时并非必然经历两次串联过渡,不能简单断言所有按钮都变成 320ms。还没有真实桌面帧时间证据,不能将其定性为 UI 线程卡顿。 + +建议后续先统一“只由一个实际绘制表面负责过渡”,明确 normal/hover/pressed/checked 的状态优先级,再与 TextBox 对照实际中间帧和快速进出;保留预乘 alpha 防闪白。不要仅缩短时长或关闭动画后宣称修复。 + +## 2. 人物候选 Enter 确认 + +已复现:输入框的 Enter 只提交搜索文字,只有候选列表取得焦点后才提交 ID。现在搜索会高亮首个候选,候选打开且非空时输入框 Enter 直接提交高亮角色的精确 ID;保留方向键进入列表及鼠标选择。组合输入期间 Enter/Escape 不被编辑器消费;无候选和失焦的原始角色解析规则保持。 + +实际挂载回归覆盖大小写搜索后直接 Enter、向下键后 Enter、鼠标选择、清空与有候选时的 IME Enter 保护。 + +## 3. 返回 Group 后 Inspector 仍显示文件夹 + +已复现:只订阅 `ActiveDockableChanged`,跨区域聚焦已活动 Group 不会触发标签变化。新增 `FocusedDockableChanged` 同步详情、作者选择与撤销目标;焦点本身不触发布局保存。后台 Workspace 选择刷新不能抢占当前详情。辅助工具不替换最近可检查作者目标,锁定/解锁后恢复符合当前作者上下文。 + +回归覆盖 Assets→已活动 Group、条目身份、锁定/解锁、后台资源刷新、Log 辅助聚焦及在辅助工具上解锁。 + +## 验证 + +修复前两条实际控件回归均失败(`artifacts-uiux-followup-before.log`);修复后对应回归通过。完整 Editor 回归 271 项通过、0 失败(`artifacts-uiux-followup-full.log`、`test/GalNet.Editor.Tests/TestResults/uiux-followup.trx`),构建也在同一测试命令中完成。 + +本轮五个 C# 文件的只读空白和 IDE0005 检查均 exit 0(`artifacts-uiux-followup-verify-whitespace.log`、`artifacts-uiux-followup-verify-style.log`),`git diff --check` 通过。两项修复及文档保留在工作区,未混入先前提交;其他并行 feature 的新修改未暂存或整理。 + +## 后续授权 + +用户随后明确要求“统一下动画”。已进入并实现 [表面动画统一](motion-unification.md);前文“仅分析”描述首次反馈处理阶段,不代表最终仍未调整。 diff --git a/features/F-20261023-01-editor-uiux/motion-unification.md b/features/F-20261023-01-editor-uiux/motion-unification.md new file mode 100644 index 00000000..254b3c8a --- /dev/null +++ b/features/F-20261023-01-editor-uiux/motion-unification.md @@ -0,0 +1,27 @@ +# 表面反馈动画统一 + +用户在首次反馈分析后明确授权“统一下动画”。范围为按钮、Entry 类型选择器、图标按钮、输入与菜单的背景/边框反馈。 + +## 实现 + +- 新增 `EditorMotion.HoverDuration`,集中定义 160ms。维持零延迟与线性曲线,以及既有预乘 alpha 插值,避免把结构问题简化为缩短时长。 +- Button/ToggleButton 的 Background/BorderBrush 绑定来源不再执行动画,只有模板的实际 ContentPresenter 执行一次。EntryTypePicker 的 ToggleButton 随之使用同一策略。 +- 图标按钮只保留 ButtonSurface Border 的动画,空的 ContentPresenter 从通用规则中排除。 +- 删除宽泛 Panel/TextBlock 过渡,输入和菜单继续由其背景 Border 负责反馈。 +- 图标 ToggleButton 明确 checked → checked-hover → checked-pressed 的目标色,pressed 优先;禁用时 hover/pressed 不覆盖原选中底色,保持禁用透明度。 + +## 验证方式 + +`UiuxMotionUsesOneSurfaceAndRespondsOnTheFirstFrames` 在实际挂载的 Light、Dark、Violet、Aurora 控件上驱动已安装 Avalonia 框架的动画时钟;检查首 32ms 颜色变化、快速反向从当前颜色连续返回、一个过渡时长内到达 checked 目标,以及选中/hover/pressed/disabled 优先级。参考 TextBox 和 MenuItem 的实际 Border 使用同一时长、零延迟和 LinearEasing。既有防闪白插值与四主题小窗口挂载回归继续保留。 + +测试颜色须纳入 Brush.Opacity,并按实际 ThemeVariant 读取目标资源;裸 Color.A 或无主题参数的资源查找不能代表最终绘制颜色。最初的两次断言问题分别由这两项测量错误导致,均修正测试测量后验证实际动画。 + +确定性时钟用于验证状态与中间帧连续性,不将其当作真实桌面 GPU/帧率测量。 + +最终 Editor 全量回归 272 项通过、0 失败(`artifacts-uiux-motion-full.log`、`test/GalNet.Editor.Tests/TestResults/uiux-motion.trx`,2m29s),测试命令同时构建 Editor 与 XAML。动画时钟、防闪白及真实小窗口挂载定向回归 3 项通过(`artifacts-uiux-motion.log`)。新增两个 C# 文件的只读空白和 IDE0005 检查均 exit 0(`artifacts-uiux-motion-verify-whitespace.log`、`artifacts-uiux-motion-verify-style.log`);`git diff --check` 通过。 + +本轮未修改其他并行 feature,未自动提交工作区中的后续修复。 + +## 后续废止 + +2026-10-24 用户实测发现,统一过渡仍会令 Group entry 选择出现卡顿,而顶部菜单及 Ursa 默认控件正常。已删除 `ThemeTransitions`、`LinearBrushTransition`、`EditorMotion` 和图标按钮模板的专用画刷过渡,改回 Ursa/Semi 的普通模板反馈;这里的中间帧测试及其前提不再适用。 diff --git a/features/F-20261023-01-editor-uiux/phase-plan.md b/features/F-20261023-01-editor-uiux/phase-plan.md new file mode 100644 index 00000000..116d5d5d --- /dev/null +++ b/features/F-20261023-01-editor-uiux/phase-plan.md @@ -0,0 +1,43 @@ +# 阶段与证据 + +| Phase | 状态 | 退出条件 | +| --- | --- | --- | +| P1 按钮与 hover | verified(自动验证) | `TransparentHoverDoesNotCreateALightIntermediateSurface` 对照真实 ColorAnimator 的闪白结果,检查多个合成中间值及反向;四主题实际挂载透明图标按钮、焦点/选中反馈 | +| P2 常用字段与草稿 | verified(自动验证) | 继承生成 schema、手写 metadata、旧构造 ABI;实际挂载两处编辑、关闭重开、嵌套缺省对象、来源冲突、本地化与 IME 保存保护 | +| P3 Group 与 Inspector | verified(自动验证) | 完整/常用字段独立展示、Inspector 独立绑定;沿用重排、多选、撤销及旧绑定失效回归 | +| P4 保存与 Problems | verified(自动验证) | 当前未保存快照检查、稳定 ID 导航、重复跳转复用详情、混合草稿提交及发布中新编辑状态断言 | +| P5 布局与导航 | verified(自动验证) | 三套预设保留文档/选择/历史、最大化恢复、被裁剪容器重建、资源重开、Timeline 旧关键帧操作与实际小窗口挂载 | + +## 回归命令和结果(2026-10-24) + +统一测试参数:`dotnet test --no-restore -c Release -m:1 -p:RunAnalyzers=false --verbosity minimal`。本机 testhost 需要沙箱外 IPC;沙箱内超时不作为业务测试失败。 + +| 测试项目 | 结果 | 日志 | +| --- | --- | --- | +| Core | 148 通过 | `artifacts-uiux-final-GalNet.Core.Tests.log` | +| Presentation | 243 通过;来源冲突修复后全量复跑仍为 243 通过 | `artifacts-uiux-presentation-verified.log` | +| Editor.Shared | 393 通过;新增发布状态断言另行通过 1 项 | `artifacts-uiux-final-GalNet.Editor.Shared.Tests.log`、`artifacts-uiux-recheck-GalNet.Editor.Shared.Tests.log` | +| Editor | 270 全量通过;此前新增测试漏装表单模板已修正,UIUX 10 项另行通过 | `artifacts-uiux-editor-verified.log`、`test/GalNet.Editor.Tests/TestResults/uiux-editor-verified.trx` | +| Architecture | 47 通过 | `artifacts-uiux-recheck-GalNet.Architecture.Tests.log` | +| Primitives.Builtins | 175 通过 | `artifacts-uiux-recheck-GalNet.Primitives.Builtins.Tests.log` | +| SchemaGenerator | 18 通过 | `artifacts-uiux-recheck-GalNet.SchemaGenerator.Tests.log` | + +`scripts/test-contract-reproducibility.ps1 -Configuration Release` 通过:两次不同 SourceRevisionId 的 Sample 契约 SHA-256 同为 `9B97CA52F84880E640C7DAD5012C9DB9667C7887FD47959FDB1A07F1FF2CE535`。Editor 默认契约输出与打包目录 DLL 指纹一致。 + +`dotnet build GalNet.slnx --no-restore -c Release -m:1` 在本机 IPC 权限下通过,0 错误、14 个 NU1900 审计连接警告(`artifacts-uiux-solution-elevated.log`)。首次沙箱运行的 Android/Browser MSBuild 任务宿主连接失败;同一命令在本机构建权限下成功,未为绕过错误修改平台构建配置。 + +最终只读格式检查通过:对本 feature 的 59 个 C# 文件运行 `dotnet format whitespace GalNet.slnx --no-restore --include --verify-no-changes` 及 `dotnet format style ... --verify-no-changes --diagnostics IDE0005 --severity info`,均 exit 0;`git diff --check` 通过。格式器提示工作区加载警告,未报告格式违规。 + +默认契约 SHA-256:`D932442825361BC17AE7BE3D292850A468C8DE2BAC704AFBA9159FB7DB58221D`,Editor.DefaultContracts 输出与 Editor `DefaultContracts/` 打包副本相同。 + +最后的来源冲突修复后,Editor 再次全量 270 通过(2m55s),Presentation 再次全量 243 通过。`UiuxNestedDraftCreatesAnOmittedObjectAndRejectsLaterSourceChanges` 覆盖来源值恢复相同 JSON、来源类型切换后恢复仍不得提交旧草稿。对应三个 C# 文件的追加只读空白检查通过(`artifacts-uiux-identity-format.log`)。七组相关回归共 1294 个测试通过,定向复跑不重复计数。 + +来源冲突修复后最终解决方案构建再次通过(`artifacts-uiux-solution-verified.log`):0 错误、175 个警告,包含仓库现有 CA/nullable/XAML 分析器提示与 NU1900 审计连接警告。构建包含 Android 和 Browser 目标;未关闭分析器或通过裁掉平台规避构建。未将成功构建描述为无警告构建。 + +首次使用反馈后,P1 进一步收敛为单个绘制层的动画,共享 EditorMotion.HoverDuration;实际模板时钟验证四主题前 32ms 响应、反向连续和 checked/hover/pressed/disabled 优先级。最新 Editor 272 项通过,局部只读格式检查通过,详见 [动画统一证据](motion-unification.md)。此前两项交互反馈与 271 项回归见 [首次反馈](follow-up-20261024.md)。 + +## 窗口和证据边界 + +实际挂载 1200×800、960×640 和模拟 150% 缩放,并检查实际字段尺寸、透明按钮、资源/Graph/Timeline 控件。稳定截图位于忽略的 `artifacts/uiux/uiux-*.png`。截图捕获临时取消实例过渡以获得稳定端点;颜色中间值由独立过渡测试验证,不用稳定截图替代动画验证。 + +真实系统 DPI、原生 IME 候选窗口及完整桌面人工操作仍待人工验收。GUI 检查同步捕获当前版本,没有异步旧结果发布窗口。对象/列表草稿使用保守根来源签名,相关根值变化需要解决冲突。正式 spec/manual 已同步,详见 summary。 diff --git a/features/F-20261023-01-editor-uiux/review.md b/features/F-20261023-01-editor-uiux/review.md new file mode 100644 index 00000000..a5d70e8c --- /dev/null +++ b/features/F-20261023-01-editor-uiux/review.md @@ -0,0 +1,28 @@ +# 实现复核 + +日期:2026-10-24。范围:本 feature 的需求、设计、实际变更、测试输出与规范同步;同一实现者自查,未声称独立审核。 + +结论:`pass-with-follow-up`。没有发现阻止代码交付的已知问题;真实桌面 DPI/IME 人工验收仍保留为后续项。最终构建及格式退出状态见 [阶段证据](phase-plan.md)。 + +## 重点检查 + +- 常用筛选只由 Editor 行内显式开启,共享表单默认完整;继承字段、手写 metadata 与旧 EditorHints 构造入口有回归。 +- 行内与 Inspector 不借用同一个控件。页面旧绑定继续按既有规则失效;Inspector 拥有自己的绑定,项目拥有草稿。失效、类型和根结构冲突阻止旧输入提交。 +- 双处输入、关闭重开、未完成组合输入及有效/无效混合草稿有实际挂载测试。项目拥有面板关闭后不被误销毁;辅助工具不清空 Inspector。 +- Save All 复用作者意图及事务,不因捕获版发布成功而把更晚编辑标记已保存。Problems 检查内存作者快照;导航解析稳定 ID,重复跳转复用未锁定 Inspector。 +- 预设及最大化不写作者历史,保留文档/选择/草稿;Dock 容器被移除后可重建。Timeline 分隔条不会覆盖 frame 0 点击区。 +- hover 中间帧使用真实框架插值复现作为回归对照,新实现检查透明合成结果及反向过渡。主题、图标按钮尺寸/透明度/焦点和实际字段尺寸均有挂载证据。 + +## 已修复的复核发现 + +1. 暗色背景在过渡中闪白:改用预乘透明度插值,保留动画。 +2. 首版详情复用页面绑定破坏旧绑定失效:Inspector 改持独立绑定。 +3. 关闭项目 scope 面板后重开解析已释放状态:关闭 Dock 不释放借用的项目面板。 +4. Timeline splitter 默认最小宽度覆盖 frame 0:显式缩小 gutter 并设置最小宽度为 0;旧关键帧交互回归通过。 +5. 保存途中新增编辑仍显示“保存中”:增加与发布快照的独立比较,补并发断言。 +6. 新增补全控件测试漏装表单模板:补齐实际样式后验证可视树中的控件及混合草稿保存。 +7. 草稿来源变更后撤销恢复相同 JSON 可能重新匹配旧签名:在项目内容变更时保留已经发生的冲突,取消草稿才重置;来源检查读取实时 Source.Type,避免投影刷新顺序掩盖类型变化。 + +## 后续人工验收 + +在真实桌面完成 150% 系统 DPI、IME 候选窗口、触控板/鼠标和键盘连续操作。Headless 的模拟缩放、组合保护及截图不替代这些证据。Timeline 专属关键帧行仍沿用自身输入生命周期;本 feature 的项目草稿适用于 Entry schema 字段及其专属字段编辑器。 diff --git a/features/F-20261023-01-editor-uiux/summary.md b/features/F-20261023-01-editor-uiux/summary.md new file mode 100644 index 00000000..094077ab --- /dev/null +++ b/features/F-20261023-01-editor-uiux/summary.md @@ -0,0 +1,48 @@ +# 实现总结 + +## 结果 + +P1–P5 已落地。Group 默认显示 DTO 常用字段,完整参数及条件由独立 Inspector 编辑;选择、展开、多选和锁定互不代替。项目会话拥有字段输入草稿,视图只持有自己的绑定和控件;关闭、切语言和套用布局不丢输入。保存、待处理输入和作者校验分别反馈,Problems 与变量引用使用稳定身份定位。 + +公共图标按钮使用透明表面、32 DIP 点击区域、提示及无障碍名称,保留焦点/禁用/切换反馈。hover 保留 160ms 动画,改用预乘透明度插值,修复透明白色在暗色背景造成的中间亮度峰值。 + +新增手动剧情/动画/调试预设及最大化恢复;Timeline 提供可调属性树、适配时长/播放头/缩放工具;Graph 增加搜索、适配、缩放及默认关闭的小地图;资源面板增加导入/新建、类型筛选、列表及明确的空/错误状态。 + +## 主要模块与兼容 + +- Core `CommonAttribute`、`EditorHints.IsCommon` 与生成器;共享表单默认仍为完整模式。保留旧四参数 EditorHints 构造函数和解构入口,支持未重建契约的调用方式。 +- Presentation 的 `FieldDraftState`、`DtoEditorContext.DisplayMode` 和共享字段上下文;Editor 的 `EntryEditSession`、独立 Inspector、Problems 及作者导航。 +- 复用作者意图、项目历史、保存事务、现有变量/表达式补全和专属编辑器。无作者文件格式迁移;列表或来源改变时宁可报告冲突,也不把旧输入写到新目标。 +- 布局不进入作者历史;旧保存布局优先。Dock 自动移除容器后,预设可重新接入既有文档;项目拥有的面板关闭后仍可重开。 + +## 验证 + +最终命令、测试数量和退出状态见 [阶段证据](phase-plan.md);审核结论见 [review](review.md)。本机日志与截图在忽略的 `artifacts-uiux-*.log`、`artifacts/uiux/`。 + +Core、Presentation、Editor.Shared、Editor、Architecture、Primitives.Builtins 和 SchemaGenerator 七组相关回归共 1294 项通过;其中最后的来源冲突修复后,Editor 270 项及 Presentation 243 项已再次全量通过。只读空白/IDE0005 检查及契约重建指纹检查通过。 + +最终完整解决方案 Release 构建通过,0 错误;保留 175 个仓库分析器及 NuGet 审计连接警告,具体输出见阶段证据。 + +实际挂载验证覆盖行内/Inspector、专属控件、关闭重开、草稿冲突、未完成 IME 组合保护、保存期间继续编辑、旧绑定失效、预设恢复及多主题。窗口覆盖 1200×800、960×640 和模拟 150% 缩放;截图只捕获稳定端点,中间帧由独立插值回归验证。 + +## 实施选择与限制 + +作者检查在 UI 调用上同步捕获并检查当前工作版本,因此没有异步旧结果覆盖新结果的发布窗口。日志仅使用明确结构化的作者位置,不解析普通字符串猜测跳转。根字段下的对象/列表草稿使用保守来源签名;相关根值变化需要显式解决冲突。 + +来源冲突在项目内容变化时记录,撤销恢复相同值或类型不会自动解除已经发生的冲突。取消旧草稿后才能重新取得当前来源。 + +真实操作系统 150% DPI、原生 IME 候选窗口和完整鼠标/键盘桌面巡检仍需人工验收;Headless 组合输入保护与模拟缩放不等价于这些验收。无效输入仅保留在项目会话中,不跨进程恢复。Timeline 画布行仍沿用其既有专属会话生命周期。 + +原实现于首次使用反馈前提交为 `d93bf52`,工作区原有其他 feature 和测试修改保留,未创建 PR。随后的人物候选 Enter、Inspector 焦点修复及仅分析的 hover 反馈见 [后续记录](follow-up-20261024.md)。 + +曾尝试将按钮背景反馈集中到单个绘制层;用户实测 Group entry 仍会卡顿后,该自定义过渡已删除,现改回 Ursa/Semi 默认模板反馈。详见[动画统一记录](motion-unification.md)。 + +## 正式文档 + +- [共享表单契约](../../docs/spec/control.md) +- [Group 与 Inspector](../../docs/spec/editor-groups.md) +- [历史、草稿与保存](../../docs/spec/editor-history-and-save.md) +- [Dock 预设](../../docs/spec/dock-design.md) +- [Graph 导航](../../docs/spec/editor-graph.md) +- [资源操作](../../docs/spec/editor-files.md) +- [使用及 DTO 扩展手册](../../docs/manual/editor-authoring-workspace.md) diff --git a/scripts/export-sample-contracts.ps1 b/scripts/export-sample-contracts.ps1 new file mode 100644 index 00000000..ef5f3cb6 --- /dev/null +++ b/scripts/export-sample-contracts.ps1 @@ -0,0 +1,26 @@ +[CmdletBinding()] +param( + [ValidateSet('Debug', 'Release')][string]$Configuration = 'Debug' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$sampleProject = Join-Path $repositoryRoot 'src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.csproj' +$sampleOutput = Join-Path $repositoryRoot "src/Samples/GalNet.Sample.Avalonia/bin/$Configuration/net10.0" +$contractsOutput = Join-Path $repositoryRoot "src/Samples/GalNet.Sample.Contracts/bin/$Configuration/net10.0" +$destination = Join-Path $repositoryRoot 'output/Contracts' + +& dotnet build $sampleProject -c $Configuration -m:1 +if ($LASTEXITCODE -ne 0) { throw 'Sample build failed; no contracts exported.' } +$contractName = 'GalNet.Project.Contracts.dll' +if ((Get-FileHash -LiteralPath (Join-Path $sampleOutput $contractName)).Hash -ne + (Get-FileHash -LiteralPath (Join-Path $contractsOutput $contractName)).Hash) { + throw 'Sample and contract build outputs differ; no contracts exported.' +} +New-Item -ItemType Directory -Path $destination -Force | Out-Null +Get-ChildItem -LiteralPath $contractsOutput -Filter '*.dll' -File | Copy-Item -Destination $destination -Force +Write-Host "Contracts exported to: $destination" +Write-Host "Contracts SHA256: $((Get-FileHash -LiteralPath (Join-Path $destination $contractName) -Algorithm SHA256).Hash)" +Write-Host 'Close the Editor project, copy these DLLs to its Contracts directory, then reopen it.' +Write-Host "Matching App: $(Join-Path $sampleOutput 'GalNet.Sample.Avalonia.exe')" diff --git a/scripts/test-contract-reproducibility.ps1 b/scripts/test-contract-reproducibility.ps1 new file mode 100644 index 00000000..c825f058 --- /dev/null +++ b/scripts/test-contract-reproducibility.ps1 @@ -0,0 +1,20 @@ +[CmdletBinding()] +param([ValidateSet('Debug', 'Release')][string]$Configuration = 'Debug') + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$project = Join-Path $repositoryRoot 'src/Samples/GalNet.Sample.Contracts/GalNet.Sample.Contracts.csproj' +$assembly = Join-Path $repositoryRoot "src/Samples/GalNet.Sample.Contracts/bin/$Configuration/net10.0/GalNet.Project.Contracts.dll" +$expectedHash = $null +foreach ($revision in @('1111111111111111111111111111111111111111', '2222222222222222222222222222222222222222')) { + & dotnet build $project -c $Configuration --no-restore -t:Rebuild -m:1 "-p:SourceRevisionId=$revision" -v quiet + if ($LASTEXITCODE -ne 0) { throw "Contract rebuild failed for revision $revision." } + $actualHash = (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash + Write-Host "Revision ${revision}: $actualHash" + if ($null -ne $expectedHash -and $actualHash -ne $expectedHash) { + throw 'Unchanged contract sources produced different DLL hashes when only SourceRevisionId changed.' + } + $expectedHash = $actualHash +} +Write-Host "Contract hash is stable across SourceRevisionId changes: $expectedHash" diff --git a/src/Editor/GalNet.Editor.Abstraction/Changes/EditorChanges.cs b/src/Editor/GalNet.Editor.Abstraction/Changes/EditorChanges.cs index e633db75..f6d985ec 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Changes/EditorChanges.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Changes/EditorChanges.cs @@ -42,7 +42,8 @@ public static class EditorDocumentCloner GroupEntries = source.GroupEntries.ToDictionary( pair => pair.Key, pair => pair.Value.Select(CloneEntry).ToList()), - Settings = CloneSettings(source.Settings) + Settings = CloneSettings(source.Settings), + Files = source.Files.ToDictionary(pair => pair.Key, pair => new EditorFileDocument { Id = pair.Value.Id, Path = pair.Value.Path, Exists = pair.Value.Exists, ManagedContent = pair.Value.ManagedContent, Data = pair.Value.Data.ToArray() }) }; public static EditorGraphDocument CloneGraph(EditorGraphDocument source) => new() @@ -115,8 +116,7 @@ public static class EditorDocumentCloner MaxQueuedItems = track.MaxQueuedItems, OverflowPolicy = track.OverflowPolicy, QueueOverflowPolicy = track.QueueOverflowPolicy - }).ToList(), - Speakers = source.Speakers.ToList() + }).ToList() }; } diff --git a/src/Editor/GalNet.Editor.Abstraction/Changes/ProjectFilePlan.cs b/src/Editor/GalNet.Editor.Abstraction/Changes/ProjectFilePlan.cs new file mode 100644 index 00000000..ee603ca4 --- /dev/null +++ b/src/Editor/GalNet.Editor.Abstraction/Changes/ProjectFilePlan.cs @@ -0,0 +1,20 @@ +namespace GalNet.Editor.Abstraction.Changes; + +/// Disk publication attached to the same author transaction; copy sources are retained files, never byte buffers. +public sealed class ProjectFilePlan +{ + public Dictionary Writes { get; } = new(StringComparer.Ordinal); + public Dictionary Copies { get; } = new(StringComparer.Ordinal); + public List Deletes { get; } = []; + public List CreateDirectories { get; } = []; + public List DeleteDirectories { get; } = []; + public List CaseRenames { get; } = []; + public Dictionary ExpectedHashes { get; } = new(StringComparer.Ordinal); +} + +public sealed record ProjectCaseRename(string Source, string Target, string? Temporary = null); + +public interface IProjectFileStructureChange : IProjectStructureChange +{ + ProjectFilePlan Files { get; } +} diff --git a/src/Editor/GalNet.Editor.Abstraction/Commands/AssetFileCommands.cs b/src/Editor/GalNet.Editor.Abstraction/Commands/AssetFileCommands.cs index 75c1e05a..9b2a939a 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Commands/AssetFileCommands.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Commands/AssetFileCommands.cs @@ -2,7 +2,7 @@ namespace GalNet.Editor.Abstraction.Commands; -/// Project-scoped file command. File commands are transactional but do not enter document Undo history. +/// Project-scoped file command. CLI commands are transactional; the GUI adapter retains the same operations in project history. public interface IProjectFileCommand : IEditorCommand; public sealed record ImportAssetsCommand(IReadOnlyList SourcePaths, string TargetDirectory = "") : IProjectFileCommand @@ -59,6 +59,8 @@ public sealed record FileCommandResult( IReadOnlyList ChangedResources, IReadOnlyList Diagnostics) { + [System.Text.Json.Serialization.JsonIgnore] + public Exception? Error { get; init; } public static FileCommandResult Failure(params EditorDiagnostic[] diagnostics) => new(false, null, null, [], diagnostics); } diff --git a/src/Editor/GalNet.Editor.Abstraction/Documents/EditorDocumentModels.cs b/src/Editor/GalNet.Editor.Abstraction/Documents/EditorDocumentModels.cs index d882c697..b8b82072 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Documents/EditorDocumentModels.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Documents/EditorDocumentModels.cs @@ -109,10 +109,13 @@ public sealed class EditorEntryData public static Dictionary ParseParameters(IEntryCatalog catalog, string type, IReadOnlyDictionary values) => values.ToDictionary(pair => pair.Key, - pair => ValueCodec.ParseEditorText(catalog.Get(type).DynamicParameters![pair.Key].Value, pair.Value).Clone(), StringComparer.Ordinal); + pair => catalog.TryGet(type, out var schema) && schema.Parameters.ContainsKey(pair.Key) + ? ValueCodec.ParseEditorText(schema.DynamicParameters![pair.Key].Value, pair.Value).Clone() + : JsonSerializer.Deserialize(pair.Value).Clone(), StringComparer.Ordinal); public Dictionary GetParameterText(IEntryCatalog catalog) => Parameters.ToDictionary(pair => pair.Key, - pair => ValueCodec.ToEditorText(catalog.Get(Type).DynamicParameters![pair.Key].Value, pair.Value), StringComparer.Ordinal); + pair => catalog.TryGet(Type, out var schema) && schema.Parameters.ContainsKey(pair.Key) + ? ValueCodec.ToEditorText(schema.DynamicParameters![pair.Key].Value, pair.Value) : pair.Value.GetRawText(), StringComparer.Ordinal); } /// Project-owned content identity; its path can change without replacing the document. @@ -139,4 +142,15 @@ public sealed class EditorProjectDocument public EditorGraphDocument Graph { get; set; } = new(); public Dictionary> GroupEntries { get; set; } = []; public ProjectSettings Settings { get; set; } = new(); + public Dictionary Files { get; set; } = []; +} + +/// Session identity survives path changes and closed views. Only managed text bytes participate in saving. +public sealed class EditorFileDocument +{ + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Path { get; set; } = ""; + public bool Exists { get; set; } = true; + public bool ManagedContent { get; set; } + public byte[] Data { get; set; } = []; } diff --git a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs index 807db360..5ebe3674 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs @@ -68,9 +68,6 @@ public sealed class GalProject : IDisposable, IAsyncDisposable /// 项目级 ServiceProvider public IServiceProvider Services => Scope.ServiceProvider; - /// 工程项目是否有未保存的修改 - public bool IsDirty { get; set; } - public GalProject(string id, string name, string rootPath, ProjectSettings settings, EditorProjectState editorState, IServiceScope scope) { Id = id; diff --git a/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentRepository.cs b/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentRepository.cs index 77c351cd..cc5c5601 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentRepository.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentRepository.cs @@ -20,4 +20,7 @@ void Save( IReadOnlyDictionary> groupEntries); } -public sealed record ProjectSavePlan(IReadOnlyDictionary Writes, IReadOnlyList DeletedFiles); +public sealed record ProjectSavePlan(IReadOnlyDictionary Writes, IReadOnlyList DeletedFiles) +{ + public GalNet.Editor.Abstraction.Changes.ProjectFilePlan? Files { get; init; } +} diff --git a/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentService.cs b/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentService.cs index bb9e22e2..0c5c03cf 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentService.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Services/IEditorDocumentService.cs @@ -9,15 +9,11 @@ public interface IEditorDocumentService LoadedEditorProjectDocument LoadedProject { get; } IReadOnlyDictionary Groups { get; } bool IsLoaded { get; } - bool IsDirty { get; } event Action? DocumentChanged; - event Action? DirtyStateChanged; void Load(LoadedEditorProjectDocument loadedDocument); void Unload(); - void MarkDirty(); - void MarkSaved(); void SynchronizeGroups(); EditorGroupDocument GetOrCreateGroup(string groupId, string relativePath); EditorGroupDocument? FindGroupByPath(string relativePath); diff --git a/src/Editor/GalNet.Editor.Abstraction/Services/IFileDialogService.cs b/src/Editor/GalNet.Editor.Abstraction/Services/IFileDialogService.cs index 9a1cb227..dfe28c4e 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Services/IFileDialogService.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Services/IFileDialogService.cs @@ -11,4 +11,5 @@ public interface IFileDialogService /// 打开文件夹选择对话框,返回选中文件夹路径(未选择返回 null)。 Task OpenFolderPickerAsync(string title); Task> OpenFilePickerAsync(string title, bool allowMultiple = true); + Task SaveFilePickerAsync(string title, string suggestedName); } diff --git a/src/Editor/GalNet.Editor.Abstraction/Services/IProjectContentSession.cs b/src/Editor/GalNet.Editor.Abstraction/Services/IProjectContentSession.cs index 71f21bd9..d81383ef 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Services/IProjectContentSession.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Services/IProjectContentSession.cs @@ -7,11 +7,15 @@ namespace GalNet.Editor.Abstraction.Services; public interface IProjectContentSession { bool IsDirty { get; } + bool IsPublishing => false; + bool HasEditsSincePublication => false; bool RepairRequired { get; } Exception? LastError { get; } Exception? LastRecoveryError { get; } event Action? Changed; IDisposable RegisterDraft(Func hasChanges, Func commit); + IDisposable RegisterFileDraft(string fileId, Func hasChanges, Func commit, Action validate) => RegisterDraft(hasChanges, commit); + Task CommitFileDraftsAsync(string fileId) => CommitDraftsAsync(); IDisposable RegisterCaptureGuard(Func> acquire); Task CommitDraftsAsync(); void Open(GalProject project); @@ -24,6 +28,9 @@ public interface IProjectContentSession Task PublishStructureAsync(Func prepare); EditorProjectDocument CaptureWorking(); EditorProjectDocument CaptureSaved(); + IReadOnlyDictionary FileDocuments => new Dictionary(); + EditorFileDocument TrackFile(string path, byte[]? textBytes = null) => throw new NotSupportedException(); + EditorFileDocument TrackLocalizationFile(string path, byte[] textBytes) => throw new NotSupportedException(); Task SuspendAsync(); void ResumeSaves(); } diff --git a/src/Editor/GalNet.Editor.Abstraction/Services/IVariableDefinitionService.cs b/src/Editor/GalNet.Editor.Abstraction/Services/IVariableDefinitionService.cs index a6b8d1e1..6a39e035 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Services/IVariableDefinitionService.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Services/IVariableDefinitionService.cs @@ -8,17 +8,8 @@ public interface IVariableDefinitionService { event Action? DefinitionsChanged; - List GetDefinitions(VariableScope scope); - - bool IsNameAvailable(string name, VariableScope scope, ProjectVariableDefinition? current = null); - - ProjectVariableDefinition AddDefinition(VariableScope scope); - - bool RemoveDefinition(VariableScope scope, ProjectVariableDefinition definition); - - bool RenameDefinition(VariableScope scope, ProjectVariableDefinition definition, string newName); - - bool UpdateDefinition(ProjectVariableDefinition definition); + /// Detached snapshots; author changes go through project edit intents. + IReadOnlyList GetDefinitions(VariableScope scope); void ResetFromDocument(); } diff --git a/src/Editor/GalNet.Editor.Headless/Program.cs b/src/Editor/GalNet.Editor.Headless/Program.cs index ed262c01..aa8cc635 100644 --- a/src/Editor/GalNet.Editor.Headless/Program.cs +++ b/src/Editor/GalNet.Editor.Headless/Program.cs @@ -142,7 +142,7 @@ private static async Task BuildAsync(string projectPath, string[] args, IEn if (outputIndex >= 0 && outputIndex + 1 >= args.Length) return Fail("--output requires a directory path."); var outputDirectory = outputIndex >= 0 ? Path.GetFullPath(args[outputIndex + 1]) : null; var result = await new ProjectContentBuilder(entryCatalog).BuildAsync(projectPath, outputDirectory); - WriteJson(new ToolResult("project.build", true, new ProjectBuildReport(result.OutputPath, result.CompiledGroupCount), [])); + WriteJson(new ToolResult("project.build", true, new ProjectBuildReport(result.OutputPath, result.CompiledGroupCount), result.Diagnostics)); return 0; } diff --git a/src/Editor/GalNet.Editor.Headless/README.md b/src/Editor/GalNet.Editor.Headless/README.md index 53fcda57..ff42e2f6 100644 --- a/src/Editor/GalNet.Editor.Headless/README.md +++ b/src/Editor/GalNet.Editor.Headless/README.md @@ -24,7 +24,7 @@ project create 创建只生成带稳定 ID 的 Entry、空 Opening Group 和基础目录;不虚构可运行剧情。--contracts 接收已经构建好的目录,校验固定入口 DLL 和依赖,再完整复制到新项目 Contracts/。省略该选项时创建空骨架和部署说明,基础查询/结构校验可用,语义校验/编译/Editor 打开仍要求部署契约。目标必须不存在或为空,拒绝链接路径;dry-run 校验契约并返回文件清单,不创建目标或缺失的父目录。结果明确 contractReady 和契约指纹。 -Editor 的 CreateAsync 也调用同一创建服务,并要求显式契约目录;打开时读取 Graph 的显示名,目录名仅用于既有路径身份。当前未添加契约目录选择 UI,推荐 CLI 创建后在 Editor 打开。没有旧创建 API 重载或默认内置 DLL。 +Editor 的新建面板调用同一创建服务;未指定自定义契约目录时使用随程序分发的 DefaultContracts,用户无需准备 DLL。也可选择已构建的自定义契约目录。打开时读取 Graph 的显示名,目录名仅用于既有路径身份;已有项目仍加载自己的契约,不静默回退到默认契约。 原始磁盘查询已接入:`files`、`find`、`references`、`project info`、`graph show/nodes/node/edges`、`group list/show/entries` 和 `variables` 不需要契约 DLL,不补 ID、不丢未知字段、不写回。列表返回 total/offset/returned/hasMore/items/complete;可使用 --offset/--limit 显式分页。 @@ -61,7 +61,7 @@ group compile --file Graph/groups/example.rawgalgroup --output Output/ build/export 使用项目 DLL 的同一 catalog,先校验原始 Graph 和目标 Group 再编译。共享 ProjectContentBuilder 和 GamePackageExporter 要求显式传入 catalog,没有内置默认值或兼容重载。缺契约或契约未声明的 entry 明确失败;部分组件不会自动补成内置全集。输出拒绝作者目录、Contracts、项目根/祖先和链接路径;导出的 galpak 包含编译内容,不包含工具 DLL。execute 已切换项目 catalog;其无损写入边界与新增生命周期命令在后续阶段完善。 -execute 的文档加载和保存只接受当前格式:不补 ID、不迁移变量,不过滤未知参数。未知 Graph/Group/settings 字段、重复属性、缺稳定 ID 或无法无损转换的参数会阻止写入;原始查询仍可用于检查这些文件。空字符串参数保留。dryRun 使用同一待保存内容校验且不创建文件;加载后源文件或 settings 改变会要求重新加载。Graph、Group 和 settings 统一暂存后发布,后续发布失败会恢复此前改动并清理临时文件;不承诺进程崩溃或跨进程整体原子性。删除 Group 并删除 entries 时同步删除作者文件,保留 entries 时保留孤立文件。资产文件操作仍是单独执行,不能混入文档批次。 +execute 的文档加载和保存只接受当前格式:不补 ID、不迁移变量,不过滤未知参数。未知 Graph/Group/settings 字段、重复属性、缺稳定 ID 或无法无损转换的参数会阻止写入;原始查询仍可用于检查这些文件。空字符串参数保留。dryRun 使用同一待保存内容校验且不创建文件;加载后源文件或 settings 改变会要求重新加载。Graph、Group 和 settings 统一暂存后发布,后续发布失败会恢复此前改动并清理临时文件;不承诺进程崩溃或跨进程整体原子性。删除 Group 并删除 entries 时同步删除作者文件,保留 entries 时保留孤立文件。资产文件操作仍是单独执行,不能混入文档批次;它与 GUI 共用项目文件预检、metadata、引用检查及持久事务,执行前需要有效作者文档。CLI 不保留 GUI Undo 栈,未知引用不自动重写,详见 [项目文件操作](../../../docs/spec/editor-files.md)。 Editor 打开项目时也必须加载 Contracts DLL。ProjectSchemaSession 由项目 DI scope 持有,catalog、文档仓库、Graph 编辑、文件操作和保存协调器均按项目解析,entry 快捷键从该项目集合生成。关闭/切换项目释放契约上下文;契约或打开监听器失败时清理 scope,不能继续沿用旧项目 catalog。预览使用同一 catalog,按选定集合注册内置实现,并在启动前检查实际编译内容的指令。加载不会提供自定义 entry 的平台执行实现,缺少实现时明确失败。 diff --git a/src/Editor/GalNet.Editor.Headless/packages.lock.json b/src/Editor/GalNet.Editor.Headless/packages.lock.json index 1c85173e..106d16c3 100644 --- a/src/Editor/GalNet.Editor.Headless/packages.lock.json +++ b/src/Editor/GalNet.Editor.Headless/packages.lock.json @@ -22,6 +22,12 @@ "galnet.core": { "type": "Project" }, + "galnet.debugging": { + "type": "Project", + "dependencies": { + "GalNet.Runtime": "[1.0.0, )" + } + }, "galnet.editor.abstraction": { "type": "Project", "dependencies": { @@ -35,6 +41,7 @@ "GalNet.Assets": "[1.0.0, )", "GalNet.Contracts.Builtins": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", + "GalNet.Debugging": "[1.0.0, )", "GalNet.Editor.Abstraction": "[1.0.0, )", "GalNet.Runtime": "[1.0.0, )", "Serilog": "[4.2.0, )" diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs index 4e6900af..7eb9577a 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs @@ -11,8 +11,8 @@ public sealed class AssetFileCommandCatalog : IProjectFileCommandCatalog Define("asset.import", "Imports files into the project asset directory."), Define("asset.directory.create", "Creates a directory below the project asset root."), Define("asset.move", "Moves an asset or asset directory within the project asset root."), - Define("asset.rename", "Renames an asset and optionally updates project references."), - Define("asset.delete", "Deletes an asset after returning its project references."), + Define("asset.rename", "Renames an asset and its metadata, preserving its ID. Automatic reference rewriting is unsupported."), + Define("asset.delete", "Deletes an asset and metadata only when no project references or candidates are found."), Define("asset.meta.patch", "Updates supported asset metadata such as filter and compression mode.") ]; diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs index 6bece0e4..c98b5d93 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs @@ -1,264 +1,43 @@ -using System.Globalization; -using System.Text.Json; +using GalNet.Core.Assets; using GalNet.Core.Entry; using GalNet.Editor.Abstraction.Commands; +using GalNet.Editor.Abstraction.Services; using GalNet.Editor.Shared.Services; namespace GalNet.Editor.Shared.Commands; -/// Sandboxed filesystem dispatcher for project asset commands. -public sealed class AssetFileCommandExecutor : IProjectFileCommandExecutor +/// CLI adapter. The same planner and publisher are used by the GUI; only the GUI retains undo records. +public sealed class AssetFileCommandExecutor(IEntryCatalog catalog, IResourceTypeCatalog? resourceTypes = null) : IProjectFileCommandExecutor { - private readonly IEntryCatalog _catalog; - - public AssetFileCommandExecutor(IEntryCatalog catalog) - { - _catalog = catalog; - } - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - - public async Task ExecuteAsync( - string projectPath, - IProjectFileCommand command, - CancellationToken cancellationToken = default) - { - try - { - var root = Path.GetFullPath(Path.Combine(projectPath, "Assets")); - Directory.CreateDirectory(root); - return command switch - { - ImportAssetsCommand value => await ImportAsync(projectPath, root, value, cancellationToken), - CreateAssetDirectoryCommand value => CreateDirectory(root, value), - MoveAssetCommand value => await MoveAsync(root, value, cancellationToken), - RenameAssetCommand value => await RenameAsync(root, value, cancellationToken), - DeleteAssetCommand value => Delete(projectPath, root, value), - PatchAssetMetadataCommand value => await PatchMetadataAsync(root, value, cancellationToken), - _ => FileCommandResult.Failure(EditorDiagnostic.Error("asset.command.unsupported", $"Unsupported asset command '{command.CommandId}'.")) - }; - } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or JsonException) - { - return FileCommandResult.Failure(EditorDiagnostic.Error("asset.command.failed", exception.Message)); - } - } - - private static async Task ImportAsync( - string projectPath, - string root, - ImportAssetsCommand command, - CancellationToken cancellationToken) - { - if (command.SourcePaths.Count == 0) - return FileCommandResult.Failure(EditorDiagnostic.Error("asset.import.empty", "At least one source file is required.")); - var target = Resolve(root, command.TargetDirectory); - Directory.CreateDirectory(target); - var sources = command.SourcePaths.Select(Path.GetFullPath).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - if (sources.Any(path => !File.Exists(path))) - return FileCommandResult.Failure(EditorDiagnostic.Error("asset.import.sourceMissing", "One or more import source files do not exist.")); - - var destinations = sources.Select(path => Path.Combine(target, Path.GetFileName(path))).ToList(); - if (destinations.Distinct(StringComparer.OrdinalIgnoreCase).Count() != destinations.Count - || destinations.Any(path => File.Exists(path) || Directory.Exists(path))) - return FileCommandResult.Failure(EditorDiagnostic.Error("asset.import.collision", "An imported file would collide with an existing asset.")); - - var transactionId = $"filetx_{Guid.NewGuid():N}"; - var staging = Path.Combine(projectPath, ".editor-tx", transactionId); - var moved = new List(); - try - { - Directory.CreateDirectory(staging); - for (var index = 0; index < sources.Count; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - var staged = Path.Combine(staging, index.ToString("D4", CultureInfo.InvariantCulture) + Path.GetExtension(sources[index])); - File.Copy(sources[index], staged, false); - File.Move(staged, destinations[index]); - moved.Add(destinations[index]); - await EnsureMetadataAsync(root, destinations[index], cancellationToken); - } - return Success(transactionId, $"Imported {moved.Count} asset file(s).", moved.Select(path => Resource(root, path))); - } - catch - { - foreach (var path in moved) - { - if (File.Exists(path)) File.Delete(path); - if (File.Exists(path + ".meta")) File.Delete(path + ".meta"); - } - throw; - } - finally - { - if (Directory.Exists(staging)) Directory.Delete(staging, true); - } - } - - private static FileCommandResult CreateDirectory(string root, CreateAssetDirectoryCommand command) - { - ValidateName(command.Name); - var parent = Resolve(root, command.ParentDirectory); - if (!Directory.Exists(parent)) throw new DirectoryNotFoundException("The parent asset directory does not exist."); - var destination = Resolve(root, CombineRelative(command.ParentDirectory, command.Name)); - if (File.Exists(destination) || Directory.Exists(destination)) throw new IOException("An asset already exists at the requested path."); - Directory.CreateDirectory(destination); - return Success(NewId(), $"Created asset directory '{command.Name}'.", [Resource(root, destination)]); - } - - private static async Task MoveAsync(string root, MoveAssetCommand command, CancellationToken cancellationToken) - { - var source = ResolveExisting(root, command.RelativePath); - var targetDirectory = Resolve(root, command.TargetDirectory); - if (!Directory.Exists(targetDirectory)) throw new DirectoryNotFoundException("The target asset directory does not exist."); - if (Directory.Exists(source) && IsWithin(source, targetDirectory)) throw new IOException("An asset directory cannot be moved into itself."); - var destination = Resolve(root, CombineRelative(command.TargetDirectory, Path.GetFileName(source))); - EnsureDestinationFree(destination); - MovePath(source, destination); - await MoveSidecarAndRewriteAsync(root, source, destination, cancellationToken); - return Success(NewId(), $"Moved asset '{command.RelativePath}' to '{command.TargetDirectory}'.", [Resource(root, source), Resource(root, destination)]); - } - - private static async Task RenameAsync(string root, RenameAssetCommand command, CancellationToken cancellationToken) - { - if (command.UpdateReferences) - return FileCommandResult.Failure(EditorDiagnostic.Error("asset.rename.referenceUpdateUnsupported", "Automatic reference updates are not yet supported; retry with updateReferences=false and patch references explicitly.")); - ValidateName(command.NewName); - var source = ResolveExisting(root, command.RelativePath); - var relativeParent = Path.GetDirectoryName(command.RelativePath.Replace('/', Path.DirectorySeparatorChar)) ?? ""; - var destination = Resolve(root, CombineRelative(relativeParent, command.NewName)); - EnsureDestinationFree(destination); - MovePath(source, destination); - await MoveSidecarAndRewriteAsync(root, source, destination, cancellationToken); - return Success(NewId(), $"Renamed asset '{command.RelativePath}' to '{command.NewName}'.", [Resource(root, source), Resource(root, destination)]); - } - - private FileCommandResult Delete(string projectPath, string root, DeleteAssetCommand command) - { - var source = ResolveExisting(root, command.RelativePath); - var references = FindReferences(projectPath, source, command.RelativePath); - if (references.Count > 0) - return FileCommandResult.Failure(references - .Select(resource => EditorDiagnostic.Error("asset.delete.referenced", $"The asset is still referenced by '{resource}'.", resource)) - .ToArray()); - var transactionId = NewId(); - var trashRoot = Path.GetFullPath(Path.Combine(projectPath, ".editor-trash", transactionId)); - Directory.CreateDirectory(trashRoot); - var destination = Path.Combine(trashRoot, Path.GetFileName(source)); - MovePath(source, destination); - if (File.Exists(source + ".meta")) File.Move(source + ".meta", destination + ".meta"); - return Success(transactionId, $"Moved asset '{command.RelativePath}' to the project recovery area.", [Resource(root, source)]); + public static ProjectFileRequest Translate(IProjectFileCommand command) + { + static string Asset(string path) => "Assets/" + path.Trim('/'); + return command switch + { + ImportAssetsCommand value => new(ProjectFileAction.Import, value.SourcePaths, Asset(value.TargetDirectory).TrimEnd('/')), + CreateAssetDirectoryCommand value => new(ProjectFileAction.CreateDirectory, [], Asset(value.ParentDirectory).TrimEnd('/') + "/" + value.Name), + MoveAssetCommand value => new(ProjectFileAction.Move, [Asset(value.RelativePath)], Asset(value.TargetDirectory).TrimEnd('/')), + RenameAssetCommand { UpdateReferences: true } => throw new EditorOperationException("files.references", "Unknown references cannot be rewritten automatically.", "custom references"), + RenameAssetCommand value => new(ProjectFileAction.Move, [Asset(value.RelativePath)], (Path.GetDirectoryName(Asset(value.RelativePath))?.Replace(Path.DirectorySeparatorChar, '/') ?? "Assets") + "/" + value.NewName, false), + DeleteAssetCommand value => new(ProjectFileAction.Delete, [Asset(value.RelativePath)]), + PatchAssetMetadataCommand value => new(ProjectFileAction.Metadata, [Asset(value.RelativePath)], Filter: value.Filter, Compression: value.Compression), + _ => throw new ArgumentException($"Unsupported file command: {command.CommandId}") + }; } - - private List FindReferences(string projectPath, string assetPath, string relativePath) + public async Task ExecuteAsync(string projectPath, IProjectFileCommand command, CancellationToken cancellationToken = default) { - var values = new HashSet(StringComparer.OrdinalIgnoreCase) - { - relativePath.Replace('\\', '/'), - "Assets/" + relativePath.Replace('\\', '/') - }; try { - var metaPath = assetPath + ".meta"; - if (File.Exists(metaPath)) - { - using var meta = JsonDocument.Parse(File.ReadAllText(metaPath)); - if (meta.RootElement.TryGetProperty("Id", out var id) && !string.IsNullOrWhiteSpace(id.GetString())) - values.Add(id.GetString()!); - } + cancellationToken.ThrowIfCancellationRequested(); + var persistence = new DirectProjectPersistence(projectPath, new EditorDocumentRepository(catalog)); + var document = persistence.Load(); + using var edit = new ProjectFileOperation(projectPath, Translate(command), catalog, resourceTypes); + await edit.ApplyAsync(true, prepare => persistence.PublishStructureAsync(document, prepare, cancellationToken)); + return new(true, edit.Id, command.CommandId, [Translate(command).Target], []); } - catch (JsonException) { } - - var references = new List(); - var document = new DirectProjectPersistence(projectPath, new EditorDocumentRepository(_catalog)).Load(); - foreach (var node in document.Graph.Nodes) - if (values.Any(value => string.Equals(node.File, value, StringComparison.OrdinalIgnoreCase))) - references.Add($"graph/nodes/{node.Id}"); - foreach (var (groupId, entries) in document.GroupEntries) - foreach (var entry in entries) - if (values.Any(value => entry.Parameters.Values.Any(parameter => parameter.ToString().Contains(value, StringComparison.OrdinalIgnoreCase)))) - references.Add($"groups/{groupId}/entries/{entry.StableId}"); - var uiPath = Path.Combine(projectPath, "UI", "ui.json"); - if (File.Exists(uiPath)) + catch (Exception failure) when (failure is IOException or InvalidDataException or ArgumentException or UnauthorizedAccessException) { - var uiJson = File.ReadAllText(uiPath); - if (values.Any(value => uiJson.Contains(value, StringComparison.OrdinalIgnoreCase))) - references.Add("ui"); + return FileCommandResult.Failure(EditorDiagnostic.Error(failure is EditorOperationException coded ? coded.Code : "files.failed", failure.Message)) with { Error = failure }; } - return references.Distinct(StringComparer.Ordinal).ToList(); } - - private static async Task PatchMetadataAsync(string root, PatchAssetMetadataCommand command, CancellationToken cancellationToken) - { - var asset = Resolve(root, command.RelativePath); - if (!File.Exists(asset)) throw new FileNotFoundException("The asset file does not exist."); - await EnsureMetadataAsync(root, asset, cancellationToken); - var metaPath = asset + ".meta"; - var values = JsonSerializer.Deserialize>(await File.ReadAllTextAsync(metaPath, cancellationToken)) ?? []; - if (command.Filter is not null) values["Filter"] = JsonSerializer.SerializeToElement(command.Filter); - if (command.Compression is not null) values["Compress"] = JsonSerializer.SerializeToElement(command.Compression); - var temporary = metaPath + ".tmp"; - await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(values, JsonOptions), cancellationToken); - File.Move(temporary, metaPath, true); - return Success(NewId(), $"Updated metadata for asset '{command.RelativePath}'.", [Resource(root, asset) + ".meta"]); - } - - private static async Task MoveSidecarAndRewriteAsync(string root, string source, string destination, CancellationToken cancellationToken) - { - if (File.Exists(destination)) - { - if (File.Exists(source + ".meta")) File.Move(source + ".meta", destination + ".meta"); - await EnsureMetadataAsync(root, destination, cancellationToken, rewritePath: true); - return; - } - foreach (var file in Directory.EnumerateFiles(destination, "*", SearchOption.AllDirectories).Where(path => !path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) - await EnsureMetadataAsync(root, file, cancellationToken, rewritePath: true); - } - - private static async Task EnsureMetadataAsync(string root, string asset, CancellationToken cancellationToken, bool rewritePath = false) - { - var metaPath = asset + ".meta"; - Dictionary values; - if (File.Exists(metaPath)) - values = JsonSerializer.Deserialize>(await File.ReadAllTextAsync(metaPath, cancellationToken)) ?? []; - else - values = new() { ["Id"] = Guid.NewGuid().ToString("N"), ["Type"] = InferType(asset) }; - if (rewritePath || !values.ContainsKey("Path")) values["Path"] = Path.GetRelativePath(root, asset).Replace('\\', '/'); - await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(values, JsonOptions), cancellationToken); - } - - private static string Resolve(string root, string relative) - { - if (Path.IsPathRooted(relative)) throw new UnauthorizedAccessException("Asset paths must be relative to the Assets directory."); - var full = Path.GetFullPath(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar))); - if (!string.Equals(full, root, StringComparison.OrdinalIgnoreCase) && !IsWithin(root, full)) - throw new UnauthorizedAccessException("Asset path escapes the project Assets directory."); - return full; - } - - private static string ResolveExisting(string root, string relative) - { - var path = Resolve(root, relative); - if (!File.Exists(path) && !Directory.Exists(path)) throw new FileNotFoundException($"Asset '{relative}' does not exist."); - if (string.Equals(path, root, StringComparison.OrdinalIgnoreCase)) throw new UnauthorizedAccessException("The Assets root cannot be modified as an asset."); - return path; - } - - private static bool IsWithin(string parent, string candidate) => - candidate.StartsWith(Path.TrimEndingDirectorySeparator(parent) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); - private static string CombineRelative(string parent, string name) => Path.Combine(parent.Replace('/', Path.DirectorySeparatorChar), name); - private static string Resource(string root, string path) => "assets/" + Path.GetRelativePath(root, path).Replace('\\', '/'); - private static string NewId() => $"filetx_{Guid.NewGuid():N}"; - private static void EnsureDestinationFree(string path) { if (File.Exists(path) || Directory.Exists(path)) throw new IOException("An asset already exists at the destination."); } - private static void MovePath(string source, string destination) { if (Directory.Exists(source)) Directory.Move(source, destination); else File.Move(source, destination); } - private static void ValidateName(string name) { if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || name is "." or "..") throw new ArgumentException("Invalid asset name."); } - private static string InferType(string path) => Path.GetExtension(path).ToLowerInvariant() switch - { - ".png" or ".jpg" or ".jpeg" or ".webp" or ".bmp" or ".gif" => "sprite", - ".mp3" or ".wav" or ".ogg" or ".flac" or ".m4a" => "audio", - ".mp4" or ".webm" or ".mkv" or ".avi" or ".mov" => "video", - ".sksl" => "effectProgram", - _ => "unknown" - }; - private static FileCommandResult Success(string transactionId, string description, IEnumerable resources) => - new(true, transactionId, description, resources.Distinct(StringComparer.Ordinal).ToList(), []); } diff --git a/src/Editor/GalNet.Editor.Shared/Commands/DirectProjectPersistence.cs b/src/Editor/GalNet.Editor.Shared/Commands/DirectProjectPersistence.cs index 95159664..8e11eb8c 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/DirectProjectPersistence.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/DirectProjectPersistence.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; using GalNet.Core.Settings; +using GalNet.Editor.Abstraction.Changes; using GalNet.Editor.Abstraction.Documents; using GalNet.Editor.Abstraction.Services; using GalNet.Editor.Shared.Services; @@ -55,6 +56,19 @@ public ProjectSavePlan PrepareSave(EditorProjectDocument document) return new(writes, plan.DeletedFiles); } + public Task PublishStructureAsync(EditorProjectDocument document, Func prepare, CancellationToken cancellationToken = default) + { + var change = prepare(document, document); + var next = EditorDocumentCloner.Clone(document); + change.Apply(next, GalNet.Editor.Abstraction.Changes.AuthorDocumentVersion.Working); + var plan = PrepareSave(next); + if (change is GalNet.Editor.Abstraction.Changes.IProjectFileStructureChange files) plan = plan with { Files = files.Files }; + _repository.ApplySave(_projectPath, plan, cancellationToken); + _settingsBaseline = plan.Writes["settings.json"].ToArray(); + document.Graph = next.Graph; document.GroupEntries = next.GroupEntries; document.Settings = next.Settings; + return Task.CompletedTask; + } + public static ProjectSettings LoadSettings(string projectPath) { var path = new ProjectRawReader(projectPath).Resolve("settings.json"); diff --git a/src/Editor/GalNet.Editor.Shared/Commands/ProjectAuthorFileTransaction.cs b/src/Editor/GalNet.Editor.Shared/Commands/ProjectAuthorFileTransaction.cs index ece24948..6c5ad57f 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/ProjectAuthorFileTransaction.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/ProjectAuthorFileTransaction.cs @@ -26,18 +26,34 @@ public class AuthorFileTransaction private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; public void Commit(string projectPath, IReadOnlyDictionary writes, - IReadOnlyList deletedFiles, CancellationToken cancellationToken = default) + IReadOnlyList deletedFiles, CancellationToken cancellationToken = default) => + Commit(projectPath, new GalNet.Editor.Abstraction.Services.ProjectSavePlan(writes, deletedFiles), cancellationToken); + + public void Commit(string projectPath, GalNet.Editor.Abstraction.Services.ProjectSavePlan plan, CancellationToken cancellationToken = default) { var reader = new ProjectRawReader(projectPath); - var paths = writes.Keys.Concat(deletedFiles).ToArray(); - var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - if (paths.Distinct(comparer).Count() != paths.Length) throw new ArgumentException("A file can only be changed once."); - foreach (var file in paths) ValidatePath(reader, file); + var extra = plan.Files ?? new GalNet.Editor.Abstraction.Changes.ProjectFilePlan(); + var writes = new Dictionary(extra.Writes, StringComparer.Ordinal); + foreach (var pair in plan.Writes) writes[pair.Key] = pair.Value; + var copies = extra.Copies.Where(pair => !writes.ContainsKey(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + var deletes = plan.DeletedFiles.Concat(extra.Deletes).Where(path => !writes.ContainsKey(path) && !copies.ContainsKey(path)).Distinct(StringComparer.Ordinal).ToArray(); + var paths = deletes.Concat(writes.Keys).Concat(copies.Keys).ToArray(); + if (paths.Distinct(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal).Count() != paths.Length) + throw new ArgumentException("Case-only path changes must use a case rename plan."); + foreach (var path in paths) ValidatePath(reader, path); + foreach (var rename in extra.CaseRenames) + { + ValidateLocation(reader, rename.Source); ValidateLocation(reader, rename.Target); + if (!OperatingSystem.IsWindows() || rename.Source == rename.Target || !rename.Source.Equals(rename.Target, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("A case rename must identify the same Windows path."); + } + foreach (var pair in extra.ExpectedHashes) + if (ReadHash(reader.Resolve(pair.Key)) != pair.Value) throw new IOException($"Project file changed before publication: '{pair.Key}'."); cancellationToken.ThrowIfCancellationRequested(); var directory = reader.Resolve(TransactionRoot + "/" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); - var manifest = new AuthorTransactionManifest(); - var publicationStarted = false; + var manifest = new AuthorTransactionManifest { CaseRenames = extra.CaseRenames.Select((item, index) => item with { Temporary = reader.Relative(Path.Combine(directory, "case-" + index)) }).ToList() }; + var started = false; var keep = false; try { @@ -45,41 +61,53 @@ public void Commit(string projectPath, IReadOnlyDictionary write { cancellationToken.ThrowIfCancellationRequested(); var path = reader.Resolve(file); - var original = File.Exists(path) ? File.ReadAllBytes(path) : null; - var next = writes.GetValueOrDefault(file); var index = manifest.Files.Count; - if (original is not null) File.WriteAllBytes(Path.Combine(directory, $"{index}.old"), original); - if (next is not null) File.WriteAllBytes(Path.Combine(directory, $"{index}.new"), next); - manifest.Files.Add(new(file, Hash(original), Hash(next))); + var old = Path.Combine(directory, $"{index}.old"); + var next = Path.Combine(directory, $"{index}.new"); + if (File.Exists(path)) File.Copy(path, old); + if (writes.TryGetValue(file, out var bytes)) File.WriteAllBytes(next, bytes); + else if (copies.TryGetValue(file, out var source)) File.Copy(source, next); + manifest.Files.Add(new(file, ReadHash(old), ReadHash(next))); + } + foreach (var pair in extra.ExpectedHashes.Where(pair => !paths.Contains(pair.Key, OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal))) + manifest.Files.Add(new(pair.Key, pair.Value, pair.Value, false)); + var desiredDirectories = new HashSet(extra.CreateDirectories, StringComparer.Ordinal); + foreach (var path in writes.Keys.Concat(copies.Keys).Concat(extra.CreateDirectories)) + { + var parent = Path.GetDirectoryName(path)?.Replace('\\', '/'); + while (!string.IsNullOrEmpty(parent)) { desiredDirectories.Add(parent); parent = Path.GetDirectoryName(parent)?.Replace('\\', '/'); } + } + foreach (var path in desiredDirectories.Concat(extra.DeleteDirectories).Distinct(StringComparer.Ordinal)) + { + ValidateLocation(reader, path); + if (File.Exists(reader.Resolve(path))) throw new IOException($"A file occupies directory '{path}'."); + manifest.Directories.Add(new(path, Directory.Exists(reader.Resolve(path)), desiredDirectories.Contains(path))); } - manifest.Status = "prepared"; WriteManifest(directory, manifest); cancellationToken.ThrowIfCancellationRequested(); foreach (var file in manifest.Files) - if (ReadHash(reader.Resolve(file.Path)) != file.BeforeHash) - throw new IOException($"Author file changed before publication: '{file.Path}'."); - manifest.Status = "publishing"; - WriteManifest(directory, manifest); - publicationStarted = true; + if (ReadHash(reader.Resolve(file.Path)) != file.BeforeHash) throw new IOException($"Project file changed before publication: '{file.Path}'."); + foreach (var item in manifest.Directories) + if (Directory.Exists(reader.Resolve(item.Path)) != item.Before) throw new IOException($"Directory changed before publication: '{item.Path}'."); + manifest.Status = "publishing"; WriteManifest(directory, manifest); started = true; + foreach (var rename in manifest.CaseRenames) RenameCase(reader, rename.Source, rename.Target, rename.Temporary!); + foreach (var file in manifest.Files.Where(file => file.Publish && file.AfterHash is null)) Publish(reader.Resolve(file.Path), null); + foreach (var item in manifest.Directories.Where(item => !item.After).OrderByDescending(item => item.Path.Length)) + if (Directory.Exists(reader.Resolve(item.Path))) Directory.Delete(reader.Resolve(item.Path), false); + foreach (var item in manifest.Directories.Where(item => item.After).OrderBy(item => item.Path.Length)) Directory.CreateDirectory(reader.Resolve(item.Path)); for (var index = 0; index < manifest.Files.Count; index++) - { - var file = manifest.Files[index]; - Publish(reader.Resolve(file.Path), file.AfterHash is null ? null : Path.Combine(directory, $"{index}.new")); - } - manifest.Status = "committed"; - WriteManifest(directory, manifest); + if (manifest.Files[index] is { Publish: true, AfterHash: not null } file) Publish(reader.Resolve(file.Path), Path.Combine(directory, $"{index}.new")); + manifest.Status = "committed"; WriteManifest(directory, manifest); } catch (Exception failure) { - if (publicationStarted) + if (started) { try { RestoreAll(reader, directory, manifest); } catch (Exception compensation) { - keep = true; - manifest.Status = "repair-required"; - try { WriteManifest(directory, manifest); } - catch (Exception manifestFailure) { compensation = new AggregateException(compensation, manifestFailure); } + keep = true; manifest.Status = "repair-required"; + try { WriteManifest(directory, manifest); } catch (Exception error) { compensation = new AggregateException(compensation, error); } throw new ProjectRepairRequiredException(directory, new AggregateException(failure, compensation)); } } @@ -88,12 +116,25 @@ public void Commit(string projectPath, IReadOnlyDictionary write finally { if (!keep && Directory.Exists(directory)) - try { Directory.Delete(directory, recursive: true); } - catch (IOException cleanup) { Serilog.Log.Warning(cleanup, "Transaction cleanup deferred: {Directory}", directory); } - catch (UnauthorizedAccessException cleanup) { Serilog.Log.Warning(cleanup, "Transaction cleanup deferred: {Directory}", directory); } + try { Directory.Delete(directory, true); } + catch (IOException error) { Serilog.Log.Warning(error, "Transaction cleanup deferred: {Directory}", directory); } + catch (UnauthorizedAccessException error) { Serilog.Log.Warning(error, "Transaction cleanup deferred: {Directory}", directory); } } } + private static void RenameCase(ProjectRawReader reader, string source, string target, string temporaryPath) + { + ValidateLocation(reader, source); ValidateLocation(reader, target); + if (!source.Equals(target, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Invalid case rename."); + var from = reader.Resolve(source); var to = reader.Resolve(target); + var parent = Path.GetDirectoryName(from)!; + if (!Directory.EnumerateFileSystemEntries(parent).Any(path => Path.GetFileName(path).Equals(Path.GetFileName(source), StringComparison.Ordinal))) return; + if (!temporaryPath.StartsWith(TransactionRoot + "/", StringComparison.Ordinal)) throw new InvalidDataException("Invalid case-rename recovery path."); + var temporary = reader.Resolve(temporaryPath); + // The intermediate name is covered by the manifest for recovery, including interruption between these moves. + if (Directory.Exists(from)) { Directory.Move(from, temporary); Directory.Move(temporary, to); } + else { File.Move(from, temporary); File.Move(temporary, to); } + } protected virtual void Publish(string path, string? stagedFile) => Replace(path, stagedFile); protected virtual void Restore(string path, string? backupFile) => Replace(path, backupFile); @@ -108,6 +149,17 @@ private static void Replace(string path, string? source) private void RestoreAll(ProjectRawReader reader, string directory, AuthorTransactionManifest manifest) { + foreach (var item in manifest.Directories) ValidateLocation(reader, item.Path); + foreach (var file in manifest.Files) ValidateLocation(reader, file.Path); + foreach (var rename in manifest.CaseRenames) + { + ValidateLocation(reader, rename.Source); ValidateLocation(reader, rename.Target); + if (!rename.Source.Equals(rename.Target, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Invalid case rename."); + if (rename.Temporary is null || Path.GetDirectoryName(reader.Resolve(rename.Temporary)) != directory) throw new InvalidDataException("Invalid case-rename recovery path."); + var temporary = reader.Resolve(rename.Temporary); + if (Directory.Exists(temporary)) Directory.Move(temporary, reader.Resolve(rename.Source)); + else if (File.Exists(temporary)) File.Move(temporary, reader.Resolve(rename.Source)); + } var failures = new List(); foreach (var file in manifest.Files) { @@ -116,6 +168,7 @@ private void RestoreAll(ProjectRawReader reader, string directory, AuthorTransac if (current != file.BeforeHash && current != file.AfterHash) throw new IOException($"Recovery found external content: '{file.Path}'."); } + foreach (var item in manifest.Directories.Where(item => item.Before).OrderBy(item => item.Path.Length)) Directory.CreateDirectory(reader.Resolve(item.Path)); for (var index = manifest.Files.Count - 1; index >= 0; index--) { var file = manifest.Files[index]; @@ -129,6 +182,10 @@ private void RestoreAll(ProjectRawReader reader, string directory, AuthorTransac } catch (Exception failure) { failures.Add(failure); } } + foreach (var item in manifest.Directories.Where(item => !item.Before).OrderByDescending(item => item.Path.Length)) + try { if (Directory.Exists(reader.Resolve(item.Path))) Directory.Delete(reader.Resolve(item.Path), false); } catch (Exception failure) { failures.Add(failure); } + foreach (var rename in manifest.CaseRenames.AsEnumerable().Reverse()) + try { RenameCase(reader, rename.Target, rename.Source, rename.Temporary!); } catch (Exception failure) { failures.Add(failure); } if (failures.Count > 0) throw new AggregateException("Author-file recovery failed.", failures); } @@ -154,18 +211,26 @@ public void Recover(string projectPath) } } - private static void ValidatePath(ProjectRawReader reader, string file) + private static void ValidateLocation(ProjectRawReader reader, string file) { var path = reader.Resolve(file); - if (reader.Relative(path) != file || - file != "settings.json" && file != "Graph/graph.json" && file != ".galnet/editor-state.json" && - !(file.StartsWith("Graph/", StringComparison.Ordinal) && file.EndsWith(".rawgalgroup", StringComparison.OrdinalIgnoreCase))) - throw new ArgumentException($"Not a normalized author document path: '{file}'."); - if (Directory.Exists(path)) throw new IOException($"A directory occupies author file '{file}'."); + var root = file.Split('/')[0]; + if (string.IsNullOrWhiteSpace(file) || reader.Relative(path) != file || + new[] { "Contracts", "Output", "Temp", ".git", ".vs", ".idea", ".editor-tx", ".editor-trash", ".galnet" }.Contains(root, StringComparer.OrdinalIgnoreCase) && file != ".galnet/editor-state.json" && file != ".galnet") + throw new ArgumentException($"Not an editable project path: '{file}'."); + } + private static void ValidatePath(ProjectRawReader reader, string file) + { + ValidateLocation(reader, file); + if (file == ".galnet") throw new ArgumentException("The editor internal directory cannot be a file."); + if (Directory.Exists(reader.Resolve(file))) throw new IOException($"A directory occupies file '{file}'."); + } + public static string? ReadHash(string path) + { + if (!File.Exists(path)) return null; + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)); } - - private static string? Hash(byte[]? bytes) => bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); - private static string? ReadHash(string path) => File.Exists(path) ? Hash(File.ReadAllBytes(path)) : null; private static void WriteManifest(string directory, AuthorTransactionManifest manifest) { var path = Path.Combine(directory, "manifest.json"); @@ -179,6 +244,9 @@ public sealed class AuthorTransactionManifest { public string Status { get; set; } = "prepared"; public List Files { get; set; } = []; + public List Directories { get; set; } = []; + public List CaseRenames { get; set; } = []; } -public sealed record AuthorTransactionFile(string Path, string? BeforeHash, string? AfterHash); +public sealed record AuthorTransactionFile(string Path, string? BeforeHash, string? AfterHash, bool Publish = true); +public sealed record AuthorTransactionDirectory(string Path, bool Before, bool After); diff --git a/src/Editor/GalNet.Editor.Shared/GalNet.Editor.Shared.csproj b/src/Editor/GalNet.Editor.Shared/GalNet.Editor.Shared.csproj index 693c6d8b..ad7e1bb3 100644 --- a/src/Editor/GalNet.Editor.Shared/GalNet.Editor.Shared.csproj +++ b/src/Editor/GalNet.Editor.Shared/GalNet.Editor.Shared.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Editor/GalNet.Editor.Shared/Services/DebugLaunchDiagnostics.cs b/src/Editor/GalNet.Editor.Shared/Services/DebugLaunchDiagnostics.cs new file mode 100644 index 00000000..dfc9ed22 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/DebugLaunchDiagnostics.cs @@ -0,0 +1,160 @@ +using System.Text; + +namespace GalNet.Editor.Shared.Services; + +/// Bounded, per-launch support log stored beside the Sample App's game logs. +internal sealed class DebugLaunchDiagnostics : IDisposable +{ + private const int MaximumBytes = 1024 * 1024; + private const int RetainedFiles = 20; + private readonly object _gate = new(); + private readonly StreamWriter _writer; + private readonly Dictionary _pendingOutput = new(StringComparer.Ordinal); + private string? _secret; + private int _writtenBytes; + private bool _truncated; + private bool _disposed; + + private DebugLaunchDiagnostics(string path) + { + FullPath = path; + _writer = new StreamWriter(new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read), new UTF8Encoding(false)) + { + AutoFlush = true + }; + } + + public string FullPath { get; } + + public void RegisterSecret(string secret) + { + lock (_gate) _secret = secret; + } + + public static DebugLaunchDiagnostics? TryCreate(string profileRoot) + { + try + { + var directory = Path.Combine(profileRoot, "logs"); + Directory.CreateDirectory(directory); + RetainRecentLogs(directory); + var name = $"debug-launch-{DateTimeOffset.Now:yyyyMMdd-HHmmss-fff}-{Guid.NewGuid():N}.log"; + var diagnostics = new DebugLaunchDiagnostics(Path.Combine(directory, name)); + Serilog.Log.ForContext("LogChannel", "Game") + .Information("Debug launch diagnostics log: {DiagnosticLogPath}", diagnostics.FullPath); + return diagnostics; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException) + { + Serilog.Log.Warning(error, "Could not create a persistent debug launch log under {ProfileRoot}", profileRoot); + return null; + } + } + + public void WriteLine(string message) + { + lock (_gate) + { + if (_disposed || _truncated) return; + AppendLineUnsafe($"[{DateTimeOffset.Now:O}] {message}"); + } + } + + public void WriteOutput(string stream, string text) + { + lock (_gate) + { + if (_disposed || _truncated) return; + if (!_pendingOutput.TryGetValue(stream, out var pending)) _pendingOutput[stream] = pending = new(); + pending.Append(text); + var commitLength = _secret is { Length: > 0 } secret ? pending.Length - secret.Length + 1 : pending.Length; + if (commitLength <= 0) return; + var buffered = pending.ToString(); + if (_secret is { Length: > 0 } token) + { + var searchFrom = 0; + while (searchFrom < commitLength) + { + var tokenIndex = buffered.IndexOf(token, searchFrom, StringComparison.OrdinalIgnoreCase); + if (tokenIndex < 0 || tokenIndex >= commitLength) break; + if (tokenIndex + token.Length > commitLength) + { + commitLength = tokenIndex; + break; + } + searchFrom = tokenIndex + 1; + } + } + if (commitLength <= 0) return; + WriteOutputUnsafe(stream, buffered[..commitLength]); + pending.Remove(0, commitLength); + } + } + + public void CompleteOutput(string stream) + { + lock (_gate) + { + if (_disposed || _truncated || !_pendingOutput.Remove(stream, out var pending)) return; + WriteOutputUnsafe(stream, pending.ToString()); + } + } + + private void WriteOutputUnsafe(string stream, string text) + { + var safeText = _secret is { Length: > 0 } token + ? text.Replace(token, "[REDACTED]", StringComparison.OrdinalIgnoreCase) + : text; + foreach (var line in safeText.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n')) + { + if (line.Length > 0) + AppendLineUnsafe($"[{DateTimeOffset.Now:O}] [{stream}] {line}"); + if (_truncated) return; + } + } + + private void AppendLineUnsafe(string line) + { + var byteCount = Encoding.UTF8.GetByteCount(line) + Environment.NewLine.Length; + if (_writtenBytes + byteCount > MaximumBytes) + { + const string notice = "[diagnostics] Output capture stopped after reaching the 1 MiB per-launch limit."; + var noticeBytes = Encoding.UTF8.GetByteCount(notice) + Environment.NewLine.Length; + if (_writtenBytes + noticeBytes <= MaximumBytes) + { + _writer.WriteLine(notice); + _writtenBytes += noticeBytes; + } + _truncated = true; + return; + } + + _writer.WriteLine(line); + _writtenBytes += byteCount; + } + + private static void RetainRecentLogs(string directory) + { + var logs = Directory.GetFiles(directory, "debug-launch-*.log") + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .Skip(RetainedFiles - 1); + foreach (var file in logs) + { + try { file.Delete(); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + _secret = null; + _pendingOutput.Clear(); + _writer.Dispose(); + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/DebugRoutePlanner.cs b/src/Editor/GalNet.Editor.Shared/Services/DebugRoutePlanner.cs new file mode 100644 index 00000000..590754b7 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/DebugRoutePlanner.cs @@ -0,0 +1,62 @@ +using GalNet.Runtime.Engine; + +namespace GalNet.Editor.Shared.Services; + +public sealed record DebugRoutePlan(GameDebugRouteEdge[][] Routes, bool UsedSelection, bool Truncated); + +/// Bounded topology-only planning. Conditions belong to execution policy, never route discovery. +public static class DebugRoutePlanner +{ + public static DebugRoutePlan Plan(string start, string target, IEnumerable graph, + IReadOnlyCollection? selected = null, int maxRoutes = 24, int maxDepth = 64, int searchBudget = 4096) + { + if (maxRoutes < 1 || maxDepth < 1 || searchBudget < 1) throw new ArgumentOutOfRangeException(nameof(maxRoutes)); + var outgoing = graph.GroupBy(edge => edge.From).ToDictionary(group => group.Key, group => group.ToArray(), StringComparer.Ordinal); + var selection = selected?.ToHashSet(StringComparer.Ordinal) ?? []; + if (selection.Count > 1 && selection.Contains(start) && selection.Contains(target)) + { + var candidates = new List(); + var pending = new Stack<(string Node, GameDebugRouteEdge[] Path, HashSet Visited)>(); + pending.Push((start, [], new(StringComparer.Ordinal) { start })); + var budget = searchBudget; + var selectionTruncated = false; + while (pending.TryPop(out var work) && --budget >= 0 && candidates.Count < 2) + { + if (work.Node == target) + { + if (work.Visited.SetEquals(selection)) candidates.Add(work.Path); + continue; + } + if (work.Path.Length >= maxDepth) { selectionTruncated = true; continue; } + foreach (var edge in outgoing.GetValueOrDefault(work.Node, [])) + if (selection.Contains(edge.To) && !work.Visited.Contains(edge.To)) + { + if (pending.Count >= searchBudget) { selectionTruncated = true; break; } + pending.Push((edge.To, [.. work.Path, edge], new(work.Visited, StringComparer.Ordinal) { edge.To })); + } + } + if (candidates.Count == 1 && pending.Count == 0 && budget >= 0 && !selectionTruncated) return new([candidates[0]], true, false); + } + var routes = new List(); + var queue = new Queue<(string Node, GameDebugRouteEdge[] Path, HashSet Visited)>(); + queue.Enqueue((start, [], new(StringComparer.Ordinal) { start })); + var truncated = false; + while (queue.TryDequeue(out var work)) + { + if (--searchBudget < 0) { truncated = true; break; } + if (work.Node == target) + { + if (routes.Count == maxRoutes) { truncated = true; break; } + routes.Add(work.Path); continue; + } + if (work.Path.Length >= maxDepth) { truncated = true; continue; } + foreach (var edge in outgoing.GetValueOrDefault(work.Node, [])) + { + if (work.Visited.Contains(edge.To)) continue; + if (queue.Count >= searchBudget) { truncated = true; break; } + queue.Enqueue((edge.To, [.. work.Path, edge], new(work.Visited, StringComparer.Ordinal) { edge.To })); + } + } + return new(routes.ToArray(), false, truncated); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentRepository.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentRepository.cs index e35bf63b..775aa6d8 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentRepository.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentRepository.cs @@ -9,8 +9,11 @@ namespace GalNet.Editor.Shared.Services; -public sealed class EditorDocumentRepository(IEntryCatalog catalog) : IEditorDocumentRepository +public enum EditorDocumentValidation { Complete, Authoring } + +public sealed class EditorDocumentRepository(IEntryCatalog catalog, EditorDocumentValidation validation = EditorDocumentValidation.Complete) : IEditorDocumentRepository { + private bool IsAuthoring => validation == EditorDocumentValidation.Authoring; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private Dictionary? _baseline; @@ -20,7 +23,7 @@ public LoadedEditorProjectDocument Load(string projectPath, string projectName, var validator = new ProjectValidationService(projectPath); var diagnostics = new List(); var rawGraph = reader.Read("Graph/graph.json", diagnostics); - validator.ValidateGraph(rawGraph, diagnostics); + validator.ValidateGraph(rawGraph, diagnostics, validateConditionSyntax: !IsAuthoring); RequireValid(diagnostics); var document = rawGraph!.Value.Deserialize()!; var loaded = new LoadedEditorProjectDocument { Document = document }; @@ -28,7 +31,7 @@ public LoadedEditorProjectDocument Load(string projectPath, string projectName, foreach (var node in document.Nodes.Where(node => node.Type == "Group")) { var file = GroupPath(reader, node.File!); - var group = validator.ValidateGroup(file, diagnostics, catalog); + var group = validator.ValidateGroup(file, diagnostics, IsAuthoring ? null : catalog, structureOnly: IsAuthoring); RequireValid(diagnostics); _baseline[file] = File.ReadAllBytes(reader.Resolve(file)); loaded.GroupEntries[node.Id] = group!.Entries.Select((entry, index) => new EditorEntryData @@ -52,7 +55,7 @@ public ProjectSavePlan PrepareSave(string projectPath, EditorGraphDocument docum var diagnostics = new List(); var validator = new ProjectValidationService(projectPath); var graph = JsonSerializer.SerializeToElement(document, JsonOptions); - validator.ValidateGraph(graph, diagnostics, checkSourceReferences: false); + validator.ValidateGraph(graph, diagnostics, checkSourceReferences: false, validateConditionSyntax: !IsAuthoring); var writes = new Dictionary(PathComparer); var owners = new HashSet(PathComparer); foreach (var node in document.Nodes.Where(node => node.Type == "Group")) @@ -62,7 +65,7 @@ public ProjectSavePlan PrepareSave(string projectPath, EditorGraphDocument docum if (!owners.Add(file)) throw new InvalidDataException($"Multiple Groups reference '{file}'."); if (!groupEntries.TryGetValue(node.Id, out var entries)) throw new InvalidDataException($"Group '{node.Id}' has no entry document."); var raw = JsonSerializer.SerializeToElement(new GroupDocument { Kind = GroupDocumentKind.Raw, Entries = entries.Select(SerializeEntry).ToList() }, JsonOptions); - _ = ProjectValidationService.ValidateGroupDocument(raw, file, diagnostics, catalog); + _ = ProjectValidationService.ValidateGroupDocument(raw, file, diagnostics, IsAuthoring ? null : catalog, structureOnly: IsAuthoring); if (File.Exists(reader.Resolve(file)) && _baseline is not null && !_baseline.ContainsKey(file)) throw new EditorOperationException("project.fileCollision", $"New Group source would overwrite an existing unowned file: '{file}'.", file); writes.Add(file, JsonSerializer.SerializeToUtf8Bytes(raw, JsonOptions)); @@ -74,7 +77,7 @@ public ProjectSavePlan PrepareSave(string projectPath, EditorGraphDocument docum if (File.Exists(oldGraphPath)) { using var old = JsonDocument.Parse(File.ReadAllBytes(oldGraphPath)); - validator.ValidateGraph(old.RootElement, diagnostics); + validator.ValidateGraph(old.RootElement, diagnostics, validateConditionSyntax: !IsAuthoring); RequireValid(diagnostics); foreach (var node in old.RootElement.Deserialize()!.Nodes.Where(node => node.Type == "Group")) { @@ -94,18 +97,22 @@ public void Save(string projectPath, EditorGraphDocument document, IReadOnlyDict public void ApplySave(string projectPath, ProjectSavePlan plan, CancellationToken cancellationToken = default) { EnsureUnchanged(new ProjectRawReader(projectPath)); - ProjectAuthorFileTransaction.Commit(projectPath, plan.Writes, plan.DeletedFiles, cancellationToken); + new AuthorFileTransaction().Commit(projectPath, plan, cancellationToken); _baseline ??= new(PathComparer); - foreach (var (file, bytes) in plan.Writes) _baseline[file] = bytes.ToArray(); + foreach (var (file, bytes) in plan.Writes) + if (file == "Graph/graph.json" || file.StartsWith("Graph/", StringComparison.Ordinal) && file.EndsWith(".rawgalgroup", StringComparison.OrdinalIgnoreCase)) _baseline[file] = bytes.ToArray(); foreach (var file in plan.DeletedFiles) _baseline.Remove(file); } public GroupEntryDocument SerializeEntry(EditorEntryData entry) { - var definition = catalog.Get(entry.Type); if (string.IsNullOrWhiteSpace(entry.StableId)) throw new InvalidDataException("Entry stable ID is required."); - foreach (var key in entry.Parameters.Keys) - if (!definition.Parameters.ContainsKey(key)) throw new InvalidDataException($"Unknown parameter '{key}' for '{entry.Type}'."); + if (!IsAuthoring) + { + var definition = catalog.Get(entry.Type); + foreach (var key in entry.Parameters.Keys) + if (!definition.Parameters.ContainsKey(key)) throw new InvalidDataException($"Unknown parameter '{key}' for '{entry.Type}'."); + } return new GroupEntryDocument { Id = entry.StableId, diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentService.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentService.cs index 995256c3..2d4ef9fe 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorDocumentService.cs @@ -12,10 +12,8 @@ public sealed class EditorDocumentService : IEditorDocumentService public EditorGraphDocument CurrentDocument => LoadedProject.Document; public IReadOnlyDictionary Groups => _groups; public bool IsLoaded { get; private set; } - public bool IsDirty { get; private set; } public event Action? DocumentChanged; - public event Action? DirtyStateChanged; public void Load(LoadedEditorProjectDocument loadedDocument) { @@ -24,9 +22,7 @@ public void Load(LoadedEditorProjectDocument loadedDocument) foreach (var node in CurrentDocument.Nodes.Where(node => node.Type == "Group")) GetOrCreateGroup(node.Id, "Graph/" + (node.File ?? $"groups/{node.Id}.rawgalgroup")); IsLoaded = true; - IsDirty = false; NotifyDocumentChanged(); - DirtyStateChanged?.Invoke(false); } public void Unload() @@ -35,27 +31,7 @@ public void Unload() _groups.Clear(); _paths.Clear(); IsLoaded = false; - IsDirty = false; NotifyDocumentChanged(); - DirtyStateChanged?.Invoke(false); - } - - public void MarkDirty() - { - if (IsDirty) - return; - - IsDirty = true; - DirtyStateChanged?.Invoke(true); - } - - public void MarkSaved() - { - if (!IsDirty) - return; - - IsDirty = false; - DirtyStateChanged?.Invoke(false); } public EditorGroupDocument GetOrCreateGroup(string groupId, string relativePath) diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorVariableService.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorVariableService.cs index d5b4f4a6..a4bc9c58 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorVariableService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorVariableService.cs @@ -104,40 +104,6 @@ public void ResetAll() _playerStore.Reset(); } - public void RemoveRuntimeVariable(VariableScope scope, string name) - { - if (scope == VariableScope.Player) - { - _playerStore.Remove(name); - return; - } - - _saveVariables.Remove(name); - } - - public void RenameRuntimeVariable(VariableScope scope, string oldName, string newName) - { - if (string.Equals(oldName, newName, StringComparison.Ordinal)) - return; - - if (scope == VariableScope.Player) - { - if (_playerStore.Snapshot.TryGetValue(oldName, out var current)) - { - _playerStore.SetVariable(newName, CloneVariable(current, newName)); - _playerStore.Remove(oldName); - } - - return; - } - - if (_saveVariables.TryGetValue(oldName, out var saveValue)) - { - _saveVariables[newName] = CloneVariable(saveValue, newName); - _saveVariables.Remove(oldName); - } - } - private void OnDefinitionsChanged(VariableScope scope) { if (scope != VariableScope.Save) diff --git a/src/Editor/GalNet.Editor.Shared/Services/ExternalGameDebugClient.cs b/src/Editor/GalNet.Editor.Shared/Services/ExternalGameDebugClient.cs new file mode 100644 index 00000000..f236cbad --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/ExternalGameDebugClient.cs @@ -0,0 +1,419 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using GalNet.Debugging; +using GalNet.Runtime.Engine; + +namespace GalNet.Editor.Shared.Services; + +public sealed record GameDebugLaunchSettings(string Executable, string[] Arguments, string? WorkingDirectory = null); + +/// Project-owned connection; contains no UI, engine object references or process termination policy. +public sealed class ExternalGameDebugClient : IAsyncDisposable, IDisposable +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + private static readonly TimeSpan LaunchConnectionTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan DebugRequestTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan StatusRequestTimeout = TimeSpan.FromSeconds(10); + private CancellationTokenSource? _lifetime; + private DebugListener? _listener; + private DebugPeer? _peer; + private Task? _connection; + private Task? _launchMonitor; + private TaskCompletionSource? _connected; + private DebugLaunchDiagnostics? _diagnostics; + private long _epoch; + public event Action? Changed; + public DebugHostStatus? Host { get; private set; } + public GameDebugState? State { get; private set; } + public string Status { get; private set; } = "Disconnected"; + public string? Error { get; private set; } + public string? DiagnosticLogPath => _diagnostics?.FullPath; + public DebugInvitation? Invitation => _listener?.Invitation; + public bool IsConnected => _peer?.IsConnected == true; + + public DebugInvitation Listen(DebugIdentity identity) + { + if (_lifetime is not null) throw new InvalidOperationException("Disconnect the previous debug session first."); + _diagnostics?.WriteLine($"Listening for project {identity.ProjectId}; content fingerprint={identity.ContentFingerprint}; contract fingerprint={identity.ContractFingerprint ?? "(none)"}."); + _lifetime = new CancellationTokenSource(); + _listener = new DebugListener(identity); + _diagnostics?.RegisterSecret(_listener.Invitation.Token); + _connected = new(TaskCreationOptions.RunContinuationsAsynchronously); + Status = "Waiting for application"; + Error = null; + var epoch = ++_epoch; + _connection = AcceptAndPollAsync(_listener, epoch, _lifetime.Token); + Changed?.Invoke(); + return _listener.Invitation; + } + + public void BeginDiagnostics(string profileRoot) + { + _diagnostics?.Dispose(); + _diagnostics = DebugLaunchDiagnostics.TryCreate(profileRoot); + _diagnostics?.WriteLine("Editor debug preparation started."); + } + + public void RecordDiagnostic(string message) => _diagnostics?.WriteLine(message); + + public void Launch(GameDebugLaunchSettings settings, string contentRoot, string profileRoot) + { + var invitation = Invitation ?? throw new InvalidOperationException("Start listening before launching the App."); + _diagnostics ??= DebugLaunchDiagnostics.TryCreate(profileRoot); + if (!File.Exists(settings.Executable)) + { + var message = AddDiagnosticPath($"Debug application was not found: {settings.Executable}"); + _diagnostics?.WriteLine(message); + throw new FileNotFoundException(message, settings.Executable); + } + _diagnostics?.WriteLine("Debug App launch requested."); + _diagnostics?.WriteLine($"Executable: {settings.Executable}"); + _diagnostics?.WriteLine($"Working directory: {settings.WorkingDirectory ?? Path.GetDirectoryName(settings.Executable)}"); + _diagnostics?.WriteLine($"Content root: {contentRoot}"); + _diagnostics?.WriteLine($"Profile root: {profileRoot}"); + _diagnostics?.WriteLine($"Project ID: {invitation.Identity.ProjectId}"); + _diagnostics?.WriteLine($"Content fingerprint: {invitation.Identity.ContentFingerprint}"); + _diagnostics?.WriteLine($"Contract fingerprint: {invitation.Identity.ContractFingerprint ?? "(none)"}"); + _diagnostics?.WriteLine($"Additional configured argument count: {settings.Arguments.Length}; argument values are omitted."); + _diagnostics?.WriteLine($"Connection timeout: {LaunchConnectionTimeout.TotalSeconds:0} seconds."); + var start = new ProcessStartInfo(settings.Executable) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + WorkingDirectory = settings.WorkingDirectory ?? Path.GetDirectoryName(settings.Executable)! + }; + foreach (var argument in settings.Arguments) start.ArgumentList.Add(argument); + foreach (var argument in new[] { contentRoot, "--profile", profileRoot, "--debug-port", invitation.Port.ToString(System.Globalization.CultureInfo.InvariantCulture), + "--debug-token", invitation.Token, "--debug-session", invitation.SessionId, "--debug-project", invitation.Identity.ProjectId }) + start.ArgumentList.Add(argument); + if (invitation.Identity.ContractFingerprint is { } contract) { start.ArgumentList.Add("--debug-contract"); start.ArgumentList.Add(contract); } + Process process; + try { process = Process.Start(start) ?? throw new InvalidOperationException("Could not start the debug application."); } + catch (Exception error) + { + _diagnostics?.WriteLine($"Process start failed: {error}"); + throw new InvalidOperationException(AddDiagnosticPath(error.Message), error); + } + Serilog.Log.Information("Started debug application {Executable}, process {ProcessId}", settings.Executable, process.Id); + _diagnostics?.WriteLine($"Process started. PID={process.Id}."); + _diagnostics?.WriteLine("Waiting for the App to complete the authenticated debug connection."); + _launchMonitor = MonitorLaunchAsync(process, _epoch, _connected!.Task, invitation.Token, _diagnostics, _lifetime!.Token); + } + private async Task MonitorLaunchAsync(Process process, long epoch, Task connected, string secretToken, DebugLaunchDiagnostics? diagnostics, CancellationToken token) + { + using (process) + { + try + { + var stdoutTail = new BoundedOutputTail(secretToken); + var stderrTail = new BoundedOutputTail(secretToken); + // Keep draining the redirected pipes until the child exits, even after the + // Editor disconnects. A child can outlive its debug session and must not block + // while writing to a full stdout/stderr pipe. + var stdout = CaptureOutputAsync(process.StandardOutput, "stdout", process.Id, secretToken, stdoutTail, diagnostics); + var stderr = CaptureOutputAsync(process.StandardError, "stderr", process.Id, secretToken, stderrTail, diagnostics); + var exit = process.WaitForExitAsync(CancellationToken.None); + var timeout = Task.Delay(LaunchConnectionTimeout, CancellationToken.None); + var first = await Task.WhenAny(connected, exit, timeout).ConfigureAwait(false); + if (first == timeout && !token.IsCancellationRequested && epoch == _epoch && !connected.IsCompleted && !process.HasExited) + { + var details = FormatOutputTails(stdoutTail.Value, stderrTail.Value); + var message = AddDiagnosticPath( + $"The debug application did not connect within {LaunchConnectionTimeout.TotalSeconds:0} seconds. The process is still running and was not terminated." + details); + diagnostics?.WriteLine(message); + ReportLaunchFailure(message, "Application connection timed out"); + } + + await exit.ConfigureAwait(false); + await Task.WhenAll(stdout, stderr).ConfigureAwait(false); + if (token.IsCancellationRequested || epoch != _epoch) return; + diagnostics?.WriteLine($"Process exited with code {process.ExitCode}."); + if (process.ExitCode == 0 && connected.IsCompleted) return; + if (process.ExitCode != 0 || !connected.IsCompleted) + { + var message = $"Debug application exited {(connected.IsCompleted ? "" : "before connecting ")}(exit code {process.ExitCode})." + + (string.IsNullOrWhiteSpace(stdoutTail.Value) && string.IsNullOrWhiteSpace(stderrTail.Value) + ? " Check the launch diagnostics and game log." + : FormatOutputTails(stdoutTail.Value, stderrTail.Value)); + message = AddDiagnosticPath(message); + diagnostics?.WriteLine(message); + ReportLaunchFailure(message, "Application exited"); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + catch (Exception error) when (error is IOException or InvalidOperationException or ObjectDisposedException) + { + if (token.IsCancellationRequested || epoch != _epoch) return; + Error = AddDiagnosticPath(error.Message); + Status = "Could not monitor application"; + diagnostics?.WriteLine($"Launch monitor failed: {error}"); + Serilog.Log.Error(error, "Could not monitor debug application"); + Changed?.Invoke(); + } + } + } + private static string FormatOutputTails(string stdout, string stderr) + { + var output = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(stdout)) output.AppendLine().Append("stdout:").AppendLine().Append(stdout.Trim()); + if (!string.IsNullOrWhiteSpace(stderr)) output.AppendLine().Append("stderr:").AppendLine().Append(stderr.Trim()); + return output.ToString(); + } + + private static async Task CaptureOutputAsync(StreamReader reader, string stream, int processId, string secretToken, + BoundedOutputTail tail, DebugLaunchDiagnostics? diagnostics) + { + var buffer = new char[1024]; + var line = new StringBuilder(); + try + { + int count; + while ((count = await reader.ReadAsync(buffer.AsMemory()).ConfigureAwait(false)) > 0) + { + var chunk = new string(buffer, 0, count); + tail.Add(chunk); + diagnostics?.WriteOutput(stream, chunk); + foreach (var character in chunk) + { + if (character == '\n') + { + WriteAppOutput(stream, processId, line.ToString(), secretToken); + line.Clear(); + } + else if (character != '\r') + { + line.Append(character); + if (line.Length > 4096) DrainSafeOutputPrefix(stream, processId, line, secretToken); + } + } + } + } + finally + { + if (line.Length > 0) WriteAppOutput(stream, processId, line.ToString(), secretToken); + diagnostics?.CompleteOutput(stream); + } + } + + private static void DrainSafeOutputPrefix(string stream, int processId, StringBuilder pending, string secretToken) + { + var buffered = pending.ToString(); + var commitLength = Math.Max(0, buffered.Length - secretToken.Length + 1); + var searchFrom = 0; + while (commitLength > 0 && searchFrom < commitLength) + { + var tokenIndex = buffered.IndexOf(secretToken, searchFrom, StringComparison.OrdinalIgnoreCase); + if (tokenIndex < 0 || tokenIndex >= commitLength) break; + if (tokenIndex + secretToken.Length > commitLength) + { + commitLength = tokenIndex; + break; + } + searchFrom = tokenIndex + 1; + } + if (commitLength <= 0) return; + WriteAppOutput(stream, processId, buffered[..commitLength], secretToken); + pending.Remove(0, commitLength); + } + + private static void WriteAppOutput(string stream, int processId, string text, string secretToken) + { + if (string.IsNullOrEmpty(text)) return; + var safeText = text.Replace(secretToken, "[REDACTED]", StringComparison.OrdinalIgnoreCase); + var logger = Serilog.Log.ForContext("LogChannel", "Game").ForContext("DebugAppProcessId", processId); + if (stream == "stderr") logger.Error("[Debug App stderr] {Output}", safeText); + else logger.Information("[Debug App stdout] {Output}", safeText); + } + + private sealed class BoundedOutputTail(string secretToken) + { + private const int MaximumTailCharacters = 8192; + private readonly object _gate = new(); + private readonly StringBuilder _value = new(); + public string Value + { + get + { + lock (_gate) + { + var redacted = _value.ToString().Replace(secretToken, "[REDACTED]", StringComparison.OrdinalIgnoreCase); + return redacted.Length > MaximumTailCharacters ? redacted[^MaximumTailCharacters..] : redacted; + } + } + } + public void Add(string value) + { + lock (_gate) + { + _value.Append(value); + var retainedCharacters = MaximumTailCharacters + secretToken.Length; + if (_value.Length > retainedCharacters) _value.Remove(0, _value.Length - retainedCharacters); + } + } + } + + private void ReportLaunchFailure(string message, string status) + { + Error = message; + Status = status; + Serilog.Log.Error("{DebugLaunchError}", message); + Changed?.Invoke(); + } + + private string AddDiagnosticPath(string message) => + DiagnosticLogPath is { } path && !message.Contains(path, StringComparison.OrdinalIgnoreCase) + ? $"{message}{Environment.NewLine}Diagnostics: {path}" + : message; + + private async Task RequestWithTimeoutAsync(DebugPeer peer, string method, object? payload, TimeSpan timeout, CancellationToken cancellationToken) + { + using var request = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + request.CancelAfter(timeout); + var stopwatch = Stopwatch.StartNew(); + try + { + var result = await peer.RequestAsync(method, payload, request.Token).ConfigureAwait(false); + _diagnostics?.WriteLine($"Debug request '{method}' completed in {stopwatch.ElapsedMilliseconds} ms."); + return result; + } + catch (OperationCanceledException) when (request.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + var message = $"The debug application did not respond to '{method}' within {timeout.TotalSeconds:0} seconds. Its operation may still be running; inspect its current state before retrying."; + _diagnostics?.WriteLine($"Debug request '{method}' timed out after {stopwatch.ElapsedMilliseconds} ms."); + throw new TimeoutException(message); + } + catch (Exception error) + { + _diagnostics?.WriteLine($"Debug request '{method}' failed after {stopwatch.ElapsedMilliseconds} ms: {error.Message}"); + throw; + } + } + public async Task SendAsync(string method, object? arguments = null, string? expectedRun = null, long? expectedGeneration = null, + CancellationToken cancellationToken = default) + { + var peer = _peer ?? throw new InvalidOperationException("No debug application is connected."); + object? payload = arguments; + if (method is not ("start" or "status")) + { + var run = Host?.Run ?? throw new InvalidOperationException("No game instance is attached."); + if ((expectedRun is not null && expectedRun != run.RunId) || (expectedGeneration is not null && expectedGeneration != run.Generation)) + throw new InvalidOperationException("The debug execution has changed. Refresh the draft."); + payload = new DebugCommand(method, expectedRun ?? run.RunId, expectedGeneration ?? run.Generation, JsonSerializer.SerializeToElement(arguments, Json)); + } + return await RequestWithTimeoutAsync(peer, method, payload, DebugRequestTimeout, cancellationToken).ConfigureAwait(false); + } + private async Task AcceptAndPollAsync(DebugListener listener, long epoch, CancellationToken token) + { + try + { + var peer = await listener.AcceptAsync(token).ConfigureAwait(false); + if (epoch != _epoch) { await peer.DisposeAsync().ConfigureAwait(false); return; } + _peer = peer; + _connected?.TrySetResult(true); + _diagnostics?.WriteLine("Authenticated debug connection established."); + Error = null; + while (peer.IsConnected) + { + token.ThrowIfCancellationRequested(); + try + { + Host = (await RequestWithTimeoutAsync(peer, "status", null, StatusRequestTimeout, token).ConfigureAwait(false)).Deserialize(Json); + if (Host?.Run is { } run && run.Capabilities.Contains("inspect")) + { + try + { + var command = new DebugCommand("inspect", run.RunId, run.Generation, JsonSerializer.SerializeToElement(new { }, Json)); + State = (await RequestWithTimeoutAsync(peer, "inspect", command, StatusRequestTimeout, token).ConfigureAwait(false)).Deserialize(Json); + } + catch (InvalidOperationException error) { State = null; Error = error.Message; } + } + else State = null; + Error = null; + Status = State is null ? "Application connected" : State.Paused ? "Paused" : State.State.ToString(); + } + catch (TimeoutException error) + { + Error = AddDiagnosticPath(error.Message); + Status = "Application not responding"; + _diagnostics?.WriteLine(error.Message); + } + Changed?.Invoke(); + await Task.Delay(150, token).ConfigureAwait(false); + } + } + catch (Exception error) when (error is IOException or OperationCanceledException or ObjectDisposedException or System.Net.Sockets.SocketException or TimeoutException) + { + if (!token.IsCancellationRequested) + { + Error = AddDiagnosticPath(error.Message); + _diagnostics?.WriteLine($"Debug connection failed: {error}"); + } + } + finally + { + if (epoch == _epoch) { Host = null; State = null; Status = "Disconnected"; Changed?.Invoke(); } + } + } + public async Task DisconnectAsync() + { + var lifetime = _lifetime; + if (lifetime is null) return; + _diagnostics?.WriteLine("Editor debug session disconnected. The external App process was not terminated."); + var launchMonitor = _launchMonitor; + var diagnostics = _diagnostics; + ++_epoch; + await lifetime.CancelAsync().ConfigureAwait(false); + _listener?.Dispose(); + if (_peer is not null) await _peer.DisposeAsync().ConfigureAwait(false); + if (_connection is not null) await _connection.ConfigureAwait(false); + _launchMonitor = null; + _connected = null; + _peer = null; + _listener = null; + _lifetime = null; + lifetime.Dispose(); + _diagnostics = null; + if (diagnostics is not null) + { + if (launchMonitor is null) + { + diagnostics.Dispose(); + } + else + { + _ = launchMonitor.ContinueWith( + completed => + { + if (completed.Exception is { } error) + Serilog.Log.Warning(error.Flatten(), "Debug application output monitor ended unexpectedly"); + diagnostics.Dispose(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + } + Host = null; + State = null; + Status = "Disconnected"; + Changed?.Invoke(); + } + public ValueTask DisposeAsync() => new(DisconnectAsync()); + public void Dispose() => DisconnectAsync().GetAwaiter().GetResult(); + public static GameDebugLaunchSettings? LoadSettings(string projectRoot) + { + var path = SettingsPath(projectRoot); + return File.Exists(path) ? JsonSerializer.Deserialize(File.ReadAllText(path), Json) : null; + } + public static void SaveSettings(string projectRoot, GameDebugLaunchSettings settings) + { + var path = SettingsPath(projectRoot); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(settings, Json)); + } + private static string SettingsPath(string root) => Path.Combine(root, ".galnet", "editor", "debug-launch.json"); +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs index 63b2b99c..d315b4b2 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs @@ -36,7 +36,7 @@ public static async Task ExportAsync( ArgumentNullException.ThrowIfNull(resourceTypes); ArgumentNullException.ThrowIfNull(galleryTypes); stagingRoot = Path.Combine(Path.GetTempPath(), $"galnet-export-{Guid.NewGuid():N}"); - await new ProjectContentBuilder(entryCatalog) + await new ProjectContentBuilder(entryCatalog, resourceTypes) .BuildAsync(projectRoot, stagingRoot, cancellationToken) .ConfigureAwait(false); var assets = await LoadAssetsAsync(Path.Combine(stagingRoot, "Assets"), resourceTypes, cancellationToken); @@ -53,7 +53,7 @@ public static async Task ExportAsync( var manifest = new GalpakManifest(1, projectId, projectName, DateTimeOffset.UtcNow, packages); await WriteEntryAsync(zip, $"{SafeFileName(projectName)}.galnet", JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions), cancellationToken); } - await VerifyAsync(temporaryPath, packages, cancellationToken); + await VerifyAsync(temporaryPath, packages, resourceTypes, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); File.Move(temporaryPath, packagePath, true); return GamePackageExportResult.Succeeded(packagePath); @@ -105,7 +105,7 @@ private static async Task WriteEntryAsync(ZipArchive zip, string path, byte[] da await stream.WriteAsync(data, ct); } - private static async Task VerifyAsync(string path, IReadOnlyList packages, CancellationToken ct) + private static async Task VerifyAsync(string path, IReadOnlyList packages, IResourceTypeCatalog resourceTypes, CancellationToken ct) { await using var file = File.OpenRead(path); using var zip = new ZipArchive(file, ZipArchiveMode.Read); @@ -119,7 +119,7 @@ private static async Task VerifyAsync(string path, IReadOnlyList Files, + IReadOnlyDictionary ImagePaths, ImageVariantsBuildResult Build); + +/// Read-only author inspection, using the same path, metadata and definition rules as compilation. +public static class ImageVariantsAuthoring +{ + public static ImageVariantsAuthoringSnapshot Scan(string root, string descriptorPath, string json, IResourceTypeCatalog types) + { + descriptorPath = ProjectFilePaths.Normalize(descriptorPath); + if (!descriptorPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("An atlas must be inside Assets."); + var source = ImageVariantsCodec.ReadSource(Encoding.UTF8.GetBytes(json)); + var folder = LocalAssetCompiler.ResolveFolder(descriptorPath[7..], source.Folder); + var reader = new ProjectRawReader(root); + var full = reader.Resolve("Assets/" + folder); + var files = new List(); + var paths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var codec = new AssetMetaCodec(types); + foreach (var file in Directory.EnumerateFiles(full).Order(StringComparer.Ordinal)) + { + var relative = reader.Relative(file); + _ = reader.Resolve(relative); // Reject links even when they have a supported extension. + if (file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue; + var spriteExtension = types.TryGetByExtension(Path.GetExtension(file), out var type) && type.TypeId == "sprite"; + var sidecar = reader.Resolve(relative + ".meta"); + AssetMeta? meta = File.Exists(sidecar) ? codec.Deserialize(File.ReadAllText(sidecar), relative[7..]) : null; + if (!spriteExtension && meta?.TypeId != "sprite") continue; + if (meta is null) throw new InvalidDataException($"Image '{relative[7..]}' has no metadata."); + if (!paths.TryAdd(meta.Id, relative)) throw new InvalidDataException($"Duplicate resource ID '{meta.Id}'."); + files.Add(new(Path.GetFileName(file), meta.Id, meta.TypeId)); + } + return new(source, files.AsReadOnly(), paths, ImageVariantsBuilder.Build(source, files)); + } + + public static string Create(string root, string descriptorPath, IResourceTypeCatalog types) + { + var source = new ImageVariantsSource { Folder = ".", Default = "" }; + var snapshot = Scan(root, descriptorPath, System.Text.Json.JsonSerializer.Serialize(source), types); + source.Default = snapshot.Files.Select(file => Path.GetFileNameWithoutExtension(file.FileName)).Order(StringComparer.Ordinal).FirstOrDefault() + ?? throw new InvalidDataException("The folder contains no imported images."); + var build = ImageVariantsBuilder.Build(source, snapshot.Files); + if (build.Definition is null) throw new InvalidDataException(string.Join("\n", build.Diagnostics.Select(item => item.Message))); + return System.Text.Json.JsonSerializer.Serialize(source, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ImageVariantsValidation.cs b/src/Editor/GalNet.Editor.Shared/Services/ImageVariantsValidation.cs new file mode 100644 index 00000000..2a63688f --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/ImageVariantsValidation.cs @@ -0,0 +1,48 @@ +using GalNet.Assets.Provider; +using GalNet.Assets.Variants; +using GalNet.Core.Assets; +using GalNet.Editor.Abstraction.Commands; + +namespace GalNet.Editor.Shared.Services; + +internal static class ImageVariantsValidation +{ + public static IReadOnlyList Validate(string projectRoot, IResourceTypeCatalog resourceTypes) + { + var diagnostics = new List(); + var reader = new ProjectRawReader(projectRoot); + var assetsRoot = reader.Resolve("Assets"); + if (!Directory.Exists(assetsRoot)) return diagnostics; + var files = reader.Files("Assets", diagnostics); + if (!files.Any(path => resourceTypes.TryGetByExtension(Path.GetExtension(path), out var type) && type.TypeId == ImageVariantsCodec.ResourceType + || IsDeclaredVariants(reader, path))) + return diagnostics; + try + { + using var provider = new LocalFileProvider(assetsRoot, resourceTypes); + using var archive = provider.OpenArchive("assets"); + } + catch (Exception exception) when (exception is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or System.Text.Json.JsonException) + { + diagnostics.Add(new("variants.invalid", "error", exception.Message, "Assets")); + } + return diagnostics; + } + + private static bool IsDeclaredVariants(ProjectRawReader reader, string path) + { + if (!path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false; + try + { + using var json = System.Text.Json.JsonDocument.Parse(File.ReadAllText(reader.Resolve(path))); + return json.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object + && json.RootElement.EnumerateObject().Any(property => property.Name.Equals("type", StringComparison.OrdinalIgnoreCase) + && property.Value.ValueKind == System.Text.Json.JsonValueKind.String + && string.Equals(property.Value.GetString()?.Trim(), ImageVariantsCodec.ResourceType, StringComparison.OrdinalIgnoreCase)); + } + catch (Exception exception) when (exception is System.Text.Json.JsonException or IOException or InvalidDataException or UnauthorizedAccessException) + { + return false; // General project metadata validation reports unreadable/invalid sidecars. + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Identity.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Identity.cs new file mode 100644 index 00000000..fe4f89f4 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Identity.cs @@ -0,0 +1,38 @@ +using System.Text.Json.Nodes; +using GalNet.Editor.Abstraction.Changes; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class LocalizationDocumentStore +{ + /// Project identity is durable author metadata, independent of folder/name and content undo. + public async Task EnsureExchangeIdentityAsync() + { + RequireActive(); var file = CaptureFile(LocalizationJson.MetadataPath); + LocalizationJson.Validate(file.Path, file.Data); + if (file.Exists) await content.CommitFileDraftsAsync(file.Id); + string identity = ""; + await content.PublishStructureAsync((working, saved) => + { + RequireActive(); var current = Current(working, file); var baseline = Current(saved, file); + LocalizationJson.Validate(file.Path, current); LocalizationJson.Validate(file.Path, baseline); + identity = JsonNode.Parse(LocalizationJson.Decode(current))!["exchangeProjectId"]?.GetValue() ?? + JsonNode.Parse(LocalizationJson.Decode(baseline))!["exchangeProjectId"]?.GetValue() ?? Guid.NewGuid().ToString("N"); + var plan = new ProjectFilePlan(); if (!file.Exists) plan.ExpectedHashes[file.Path] = null; + return new ExchangeIdentity(file, identity, plan); + }); + return identity; + } + private sealed record ExchangeIdentity(LocalizationFileSnapshot Before, string Identity, ProjectFilePlan Files) : IProjectFileStructureChange + { + public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) + { + var bytes = Current(document, Before); var root = JsonNode.Parse(LocalizationJson.Decode(bytes))!; + if (root["exchangeProjectId"]?.GetValue() is { } current && current != Identity) throw new InvalidDataException("Project identity changed."); + root["exchangeProjectId"] = Identity; + if (!document.Files.TryGetValue(Before.Id, out var file)) document.Files[Before.Id] = file = new() { Id = Before.Id, Path = Before.Path, ManagedContent = true }; + file.Exists = true; file.Data = LocalizationJson.WriteLike(bytes, root); + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Numbers.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Numbers.cs new file mode 100644 index 00000000..5486d7e6 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.Numbers.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Editor.Abstraction.Changes; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class LocalizationDocumentStore +{ + /// Durably reserves an identity. Reservations intentionally survive content undo; gaps are harmless. + public async Task ReserveCharacterKeyAsync() + { + RequireActive(); + var metadata = CaptureFile(LocalizationJson.MetadataPath); + var catalog = CaptureFile(LocalizationJson.CatalogPath); + LocalizationJson.Validate(metadata.Path, metadata.Data); + LocalizationJson.Validate(catalog.Path, catalog.Data); + long reserved = 0; + await content.PublishStructureAsync((working, saved) => + { + RequireActive(); + var currentMetadata = Current(working, metadata); + var savedMetadata = Current(saved, metadata); + var currentCatalog = Current(working, catalog); + LocalizationJson.Validate(metadata.Path, currentMetadata); + LocalizationJson.Validate(metadata.Path, savedMetadata); + LocalizationJson.Validate(catalog.Path, currentCatalog); + var high = Math.Max(HighWater(currentMetadata), HighWater(savedMetadata)); + using var json = JsonDocument.Parse(LocalizationJson.Decode(currentCatalog)); + foreach (var character in json.RootElement.EnumerateArray()) + { + var id = character.GetProperty("id").GetString()!; + if (id.StartsWith("character_", StringComparison.Ordinal) && long.TryParse(id.AsSpan(10), NumberStyles.None, CultureInfo.InvariantCulture, out var number)) + high = Math.Max(high, number); + } + reserved = checked(high + 1); + var files = new ProjectFilePlan(); + if (!metadata.Exists) files.ExpectedHashes[metadata.Path] = null; + return new NumberReservation(metadata, reserved, files); + }); + return "character_" + reserved.ToString(CultureInfo.InvariantCulture); + } + + private static long HighWater(byte[] bytes) => JsonNode.Parse(LocalizationJson.Decode(bytes))!["characterHighWater"]!.GetValue(); + private static byte[] Current(EditorProjectDocument document, LocalizationFileSnapshot file) + { + var live = document.Files.Values.SingleOrDefault(item => item.Exists && ProjectFilePaths.Comparer.Equals(item.Path, file.Path)); + if (live is null && !file.Exists) return file.Data; + if (live is null || live.Id != file.Id || !live.ManagedContent) throw ProjectFilePaths.Error("changed", file.Path); + return live.Data; + } + + private sealed record NumberReservation(LocalizationFileSnapshot Before, long Number, ProjectFilePlan Files) : IProjectFileStructureChange + { + public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) + { + var bytes = Current(document, Before); + var node = JsonNode.Parse(LocalizationJson.Decode(bytes))!; + node["characterHighWater"] = Math.Max(Number, HighWater(bytes)); + if (!document.Files.TryGetValue(Before.Id, out var file)) + document.Files[Before.Id] = file = new() { Id = Before.Id, Path = Before.Path, ManagedContent = true }; + file.Exists = true; + file.Data = LocalizationJson.WriteLike(bytes, node); + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.cs new file mode 100644 index 00000000..ef2d5a64 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationDocumentStore.cs @@ -0,0 +1,117 @@ +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; + +namespace GalNet.Editor.Shared.Services; + +public sealed record LocalizationFileSnapshot(string Id, string Path, bool Exists, byte[] Data); +public sealed record LocalizationTextTarget(string Key, string Culture, LocalizationFileSnapshot File, string? Text); +public sealed record LocalizationFilePatch(LocalizationFileSnapshot Before, byte[] After); + +/// Managed author documents, not a second writable translation database. +public sealed partial class LocalizationDocumentStore(IProjectContentSession content, IEditorDocumentService documents, + IProjectService projects, ProjectLocalizationQuery query) : IDisposable +{ + private bool _disposed; + private string? _projectId; + internal void RequireActive() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!documents.IsLoaded || projects.Current is not { } project || content.RepairRequired) + throw new InvalidOperationException("Localization authoring is unavailable."); + _projectId ??= project.Id; + if (_projectId != project.Id) throw new InvalidOperationException("Localization authoring belongs to a different project."); + } + + public LocalizationFileSnapshot CaptureFile(string path) + { + RequireActive(); + path = LocalizationJson.Path(path); + var live = content.FileDocuments.Values.Where(file => file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, path)).ToArray(); + if (live.Length > 1) throw ProjectFilePaths.Error("changed", path); + if (live.SingleOrDefault() is { ManagedContent: true } managed) + return new(managed.Id, path, true, managed.Data.ToArray()); + var full = new ProjectRawReader(projects.Current!.RootPath).Resolve(path); + // A pending deletion masks the old disk file. A new identity may subsequently occupy this path. + if (live.Length == 0 && content.FileDocuments.Values.Any(file => !file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, path))) + return new(Guid.NewGuid().ToString("N"), path, false, LocalizationJson.Initial(path)); + if (!File.Exists(full)) return new(Guid.NewGuid().ToString("N"), path, false, LocalizationJson.Initial(path)); + var bytes = File.ReadAllBytes(full); + var file = content.TrackLocalizationFile(path, bytes); + return new(file.Id, path, true, file.Data.ToArray()); + } + + public LocalizationTextTarget CaptureText(string key, string? culture = null, string? newFile = null) + { + RequireActive(); + ArgumentException.ThrowIfNullOrWhiteSpace(key); + culture = System.Globalization.CultureInfo.GetCultureInfo(culture ?? content.CaptureWorking().Settings.TargetLocale.Code).Name; + var source = query.Get()?.FindDirect(culture, key); + var path = source?.File ?? newFile ?? $"I18n/{culture}/authoring.json"; + path = LocalizationJson.Path(path); + if (!path.StartsWith($"I18n/{culture}/", StringComparison.OrdinalIgnoreCase)) throw ProjectFilePaths.Error("category", path); + var file = CaptureFile(path); + LocalizationJson.Validate(path, file.Data); + return new(key, culture, file, LocalizationJson.Text(file.Data, key)); + } + + public LocalizationFilePatch PrepareText(LocalizationTextTarget target, string text) + { + RequireActive(); + var current = CaptureText(target.Key, target.Culture, target.File.Path); + if (current.Text != target.Text || current.File.Exists != target.File.Exists || + !ProjectFilePaths.Comparer.Equals(current.File.Path, target.File.Path) || current.File.Exists && current.File.Id != target.File.Id) + throw ProjectFilePaths.Error("changed", target.File.Path); + // New files retain the identity allocated when this draft was created. + var before = current.File with { Id = target.File.Id }; + return new(before, current.Text == text ? before.Data.ToArray() : LocalizationJson.SetText(before.Data, target.Key, text)); + } + + public IReadOnlyDictionary CaptureTexts(IEnumerable keys, string culture) + { + RequireActive(); culture = System.Globalization.CultureInfo.GetCultureInfo(culture).Name; + var index = query.Get()!; + var files = new Dictionary Values)>(ProjectFilePaths.Comparer); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var key in keys.Distinct(StringComparer.Ordinal)) + { + var path = index.FindDirect(culture, key)?.File ?? $"I18n/{culture}/authoring.json"; + if (!files.TryGetValue(path, out var item)) + { + var file = CaptureFile(path); LocalizationJson.Validate(path, file.Data); + files.Add(path, item = (file, LocalizationJson.Texts(file.Data))); + } + result.Add(key, new(key, culture, item.File, item.Values.GetValueOrDefault(key))); + } + return result; + } + public IReadOnlyList PrepareTexts(IEnumerable targets, IReadOnlyDictionary values) + { + var expected = targets.ToArray(); if (expected.Length == 0) return []; + var current = CaptureTexts(expected.Select(target => target.Key), expected[0].Culture); + foreach (var target in expected) + { + var now = current[target.Key]; + if (now.Text != target.Text || now.File.Exists != target.File.Exists || !ProjectFilePaths.Comparer.Equals(now.File.Path, target.File.Path) || now.File.Exists && now.File.Id != target.File.Id) + throw ProjectFilePaths.Error("changed", target.File.Path); + } + return current.Values.GroupBy(target => target.File.Path, ProjectFilePaths.Comparer).Select(group => new LocalizationFilePatch(group.First().File, + LocalizationJson.SetTexts(group.First().File.Data, group.ToDictionary(target => target.Key, target => values[target.Key], StringComparer.Ordinal)))).ToArray(); + } + + public LocalizationEditOperation Prepare(params LocalizationFilePatch[] patches) + { + RequireActive(); + return new(content, RequireActive, patches); + } + public LocalizationEditOperation PrepareWithEntries(IEnumerable patches, IEnumerable entries) + { + RequireActive(); + return new(content, RequireActive, patches, documents, entries); + } + public LocalizationEditOperation PrepareWithOptions(IEnumerable patches, IEnumerable options) + { + RequireActive(); + return new(content, RequireActive, patches, documents, options: options); + } + public void Dispose() => _disposed = true; +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationEditOperation.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationEditOperation.cs new file mode 100644 index 00000000..3ff91314 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationEditOperation.cs @@ -0,0 +1,168 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Editor.Abstraction.Changes; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; + +namespace GalNet.Editor.Shared.Services; + +public sealed record LocalizationEntryPatch(string GroupId, string EntryId, string Type, + IReadOnlyDictionary Before, IReadOnlyDictionary After); +public sealed record LocalizationOptionPatch(string NodeId, string OptionId, string Before, string After); + +/// Data-only atomic edit; a GUI history adapter owns its execution and undo position. +public sealed class LocalizationEditOperation +{ + private readonly IProjectContentSession _content; + private readonly Action _requireActive; + private readonly LocalizationFilePatch[] _patches; + private readonly LocalizationEntryPatch[] _entries; + private readonly LocalizationOptionPatch[] _options; + private readonly IEditorDocumentService? _documents; + private readonly Dictionary _removedSaved = []; + public long RetainedBytes => _patches.Sum(patch => patch.Before.Data.LongLength + patch.After.LongLength) + _removedSaved.Values.Sum(bytes => bytes.LongLength) + + JsonSerializer.SerializeToUtf8Bytes(_entries).LongLength + JsonSerializer.SerializeToUtf8Bytes(_options).LongLength; + + internal LocalizationEditOperation(IProjectContentSession content, Action requireActive, IEnumerable patches, + IEditorDocumentService? documents = null, IEnumerable? entries = null, IEnumerable? options = null) + { + _content = content; _requireActive = requireActive; + _documents = documents; + _options = (options ?? []).ToArray(); + if (_options.Length > 0 && (documents is null || _options.Select(option => (option.NodeId, option.OptionId)).Distinct().Count() != _options.Length)) + throw new ArgumentException("Option targets must be unique and belong to a document service.", nameof(options)); + _entries = (entries ?? []).Select(entry => entry with { Before = Clone(entry.Before), After = Clone(entry.After) }).ToArray(); + if (_entries.Length > 0 && (documents is null || _entries.Select(entry => (entry.GroupId, entry.EntryId)).Distinct().Count() != _entries.Length)) + throw new ArgumentException("Entry targets must be unique and belong to a document service.", nameof(entries)); + _patches = patches.Select(patch => new LocalizationFilePatch(patch.Before with { Path = LocalizationJson.Path(patch.Before.Path), Data = patch.Before.Data.ToArray() }, patch.After.ToArray())).ToArray(); + if (_patches.Any(patch => string.IsNullOrWhiteSpace(patch.Before.Id)) || _patches.Select(patch => patch.Before.Id).Distinct(StringComparer.Ordinal).Count() != _patches.Length) + throw new ArgumentException("File identities must be nonempty and unique.", nameof(patches)); + if (_patches.Select(patch => patch.Before.Path).Distinct(ProjectFilePaths.Comparer).Count() != _patches.Length) + throw new ArgumentException("Combine changes to one file before preparing an operation.", nameof(patches)); + foreach (var patch in _patches) + { + LocalizationJson.Validate(patch.Before.Path, patch.Before.Data); + LocalizationJson.Validate(patch.Before.Path, patch.After); + } + } + + public async Task ApplyAsync(bool forward) + { + _requireActive(); + CheckEntries(forward); + if (_patches.Any(patch => !patch.Before.Exists)) + { + await _content.PublishStructureAsync((working, saved) => + { + Check(working.Files, forward); + CheckEntries(forward); + if (!forward) + foreach (var patch in _patches.Where(patch => !patch.Before.Exists)) + if (saved.Files.TryGetValue(patch.Before.Id, out var file) && file.Exists) _removedSaved[file.Id] = file.Data.ToArray(); + var files = new ProjectFilePlan(); + foreach (var patch in _patches.Where(patch => !patch.Before.Exists)) + if (forward) files.ExpectedHashes[patch.Before.Path] = null; + return new Change(this, forward, files); + }); + } + else + { + Check(_content.FileDocuments, forward); + var values = _patches.Select(patch => (Id: patch.Before.Id, Bytes: Value(patch.Before.Path, forward ? patch.After : patch.Before.Data, _content.FileDocuments[patch.Before.Id].Data))).ToArray(); + var entryValues = _entries.Select(patch => (Entry: Find(patch), Values: Clone(forward ? patch.After : patch.Before))).ToArray(); + var optionValues = _options.Select(patch => (Option: FindOption(patch), Value: forward ? patch.After : patch.Before)).ToArray(); + foreach (var value in values) _content.FileDocuments[value.Id].Data = value.Bytes; + foreach (var value in entryValues) value.Entry.Parameters = value.Values; + foreach (var value in optionValues) value.Option.Text = value.Value; + _content.Refresh(); + } + } + + private EditorEntryData Find(LocalizationEntryPatch patch) => _documents is { IsLoaded: true } && + _documents.Groups.TryGetValue(patch.GroupId, out var group) && group.Entries.SingleOrDefault(entry => entry.StableId == patch.EntryId) is { } entry + ? entry : throw new EditorOperationException("entry.obsolete", "Entry no longer exists."); + private void CheckEntries(bool forward) + { + foreach (var patch in _options) + if (FindOption(patch).Text != (forward ? patch.Before : patch.After)) + throw new EditorOperationException("graph.branchChanged", "The option changed outside this edit."); + foreach (var patch in _entries) + { + var entry = Find(patch); + if (entry.Type != patch.Type || !Same(entry.Parameters, forward ? patch.Before : patch.After)) + throw new EditorOperationException("entry.changed", "Entry changed outside this edit."); + } + } + private EditorGraphBranchOptionDto FindOption(LocalizationOptionPatch patch) => _documents is { IsLoaded: true } && + _documents.CurrentDocument.Nodes.SingleOrDefault(node => node.Id == patch.NodeId && node.Type == "Branch" && node.BranchType == "Choice")?.Options? + .SingleOrDefault(option => option.Id == patch.OptionId) is { } option ? option : throw new EditorOperationException("graph.branchChanged", "The option no longer exists."); + public static bool Same(IReadOnlyDictionary left, IReadOnlyDictionary right) => + left.Count == right.Count && left.All(pair => right.TryGetValue(pair.Key, out var value) && JsonElement.DeepEquals(pair.Value, value)); + private static Dictionary Clone(IReadOnlyDictionary values) => values.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + private static byte[] Value(string path, byte[] desired, byte[] current) + { + if (path != LocalizationJson.MetadataPath) return desired.ToArray(); + var node = JsonNode.Parse(LocalizationJson.Decode(desired))!; + var actualIdentity = JsonNode.Parse(LocalizationJson.Decode(current))!["exchangeProjectId"]?.GetValue(); + if (node["exchangeProjectId"] is null && actualIdentity is not null) + { + node["exchangeProjectId"] = actualIdentity; + desired = LocalizationJson.WriteLike(desired, node); + } + var reserved = JsonNode.Parse(LocalizationJson.Decode(current))!["characterHighWater"]!.GetValue(); + if (reserved <= node["characterHighWater"]!.GetValue()) return desired.ToArray(); + node["characterHighWater"] = reserved; + return LocalizationJson.WriteLike(desired, node); + } + private static bool Matches(string path, byte[] current, byte[] expected) + { + if (path != LocalizationJson.MetadataPath) return current.AsSpan().SequenceEqual(expected); + LocalizationJson.Validate(path, current); + var actual = JsonNode.Parse(LocalizationJson.Decode(current))!.AsObject(); var baseline = JsonNode.Parse(LocalizationJson.Decode(expected))!.AsObject(); + if (baseline["exchangeProjectId"] is null) actual.Remove("exchangeProjectId"); + if (actual["characterHighWater"]!.GetValue() < baseline["characterHighWater"]!.GetValue()) return false; + actual.Remove("characterHighWater"); baseline.Remove("characterHighWater"); return JsonNode.DeepEquals(actual, baseline); + } + + private void Check(IReadOnlyDictionary files, bool forward) + { + foreach (var patch in _patches) + { + var before = patch.Before; + if (forward && !before.Exists) + { + if (files.Values.Any(file => file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, before.Path))) throw ProjectFilePaths.Error("changed", before.Path); + if (files.TryGetValue(before.Id, out var retained) && !ProjectFilePaths.Comparer.Equals(retained.Path, before.Path)) throw ProjectFilePaths.Error("changed", before.Path); + } + else if (!files.TryGetValue(before.Id, out var file) || !file.Exists || !file.ManagedContent || + !ProjectFilePaths.Comparer.Equals(file.Path, before.Path) || !Matches(before.Path, file.Data, forward ? before.Data : patch.After)) + throw ProjectFilePaths.Error("changed", before.Path); + } + } + + private sealed record Change(LocalizationEditOperation Owner, bool Forward, ProjectFilePlan Files) : IProjectFileStructureChange + { + public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) + { + if (version == AuthorDocumentVersion.Working) + { + foreach (var patch in Owner._entries) + document.GroupEntries[patch.GroupId].Single(entry => entry.StableId == patch.EntryId).Parameters = Clone(Forward ? patch.After : patch.Before); + foreach (var patch in Owner._options) + document.Graph.Nodes.Single(node => node.Id == patch.NodeId).Options!.Single(option => option.Id == patch.OptionId).Text = Forward ? patch.After : patch.Before; + } + foreach (var patch in Owner._patches) + { + var before = patch.Before; + if (version == AuthorDocumentVersion.Saved && before.Exists) continue; + if (!document.Files.TryGetValue(before.Id, out var file)) + document.Files[before.Id] = file = new() { Id = before.Id, Path = before.Path, ManagedContent = true }; + file.Exists = Forward || before.Exists; + var desired = (version == AuthorDocumentVersion.Working + ? Forward ? patch.After : before.Data + : Owner._removedSaved.GetValueOrDefault(before.Id) ?? before.Data).ToArray(); + file.Data = file.Data.Length > 0 ? Value(before.Path, desired, file.Data) : desired; + } + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationExchange.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationExchange.cs new file mode 100644 index 00000000..2073feef --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationExchange.cs @@ -0,0 +1,57 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using GalNet.Editor.Abstraction.Commands; + +namespace GalNet.Editor.Shared.Services; + +public sealed record LocalizationExchangeRow(string Key, string? SourceText, string? SourceFingerprint, string[] Context, string? Translation); +public sealed record LocalizationExchangeDocument(int Version, string ProjectId, string SourceLocale, string TargetLocale, LocalizationExchangeRow[] Rows, [property: JsonRequired] bool Complete); +public sealed record LocalizationExchangeInput(string? ProjectId, string? SourceLocale, string TargetLocale, LocalizationExchangeRow[] Rows); + +/// Current JSON exchange format. Order and source text are never used as row identity. +public static class LocalizationExchange +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + public static string Culture(string value) + { + var code = CultureInfo.GetCultureInfo(value).Name; + return code.Length == 0 ? throw new InvalidDataException("Invariant culture is not an exchange language.") : code; + } + public static string Write(LocalizationExchangeDocument document) => JsonSerializer.Serialize(document, Options); + public static LocalizationExchangeInput Parse(string json, string flatLanguage) + { + if (System.Text.Encoding.UTF8.GetByteCount(json) > 8 * 1024 * 1024) throw new InvalidDataException("Exchange file exceeds 8 MiB."); + using var parsed = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 32 }); + var root = parsed.RootElement; + if (root.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Expected an exchange object."); + var duplicates = new List(); ProjectValidationService.DuplicateProperties(root, "exchange", "", duplicates); + if (duplicates.Count > 0) throw new InvalidDataException("Duplicate JSON properties."); + LocalizationExchangeInput input; + if (root.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number) + { + var document = root.Deserialize(Options) ?? throw new InvalidDataException("Missing template."); + if (document.Version != 1 || !Guid.TryParseExact(document.ProjectId, "N", out _) || document.Rows is null) + throw new InvalidDataException("Unsupported template version or project identity."); + input = new(Guid.Parse(document.ProjectId).ToString("N"), Culture(document.SourceLocale), Culture(document.TargetLocale), document.Rows); + } + else + input = new(null, null, Culture(flatLanguage), root.EnumerateObject().Select(property => new LocalizationExchangeRow(property.Name, null, null, [], + property.Value.ValueKind == JsonValueKind.Null ? null : property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : throw new InvalidDataException("Flat translations must be strings or null."))).ToArray()); + if (input.Rows.Length > 100_000 || input.Rows.Any(row => row is null || string.IsNullOrWhiteSpace(row.Key) || row.Context is null || row.Context.Any(context => context is null)) || + input.Rows.Select(row => row.Key).Distinct(StringComparer.Ordinal).Count() != input.Rows.Length) + throw new InvalidDataException("Duplicate, empty or invalid exchange rows."); + foreach (var row in input.Rows) + { + if (input.ProjectId is not null && (row.SourceText is null ? row.SourceFingerprint is not null : + row.SourceFingerprint is null || !string.Equals(LocalizationTextState.Fingerprint(row.SourceText), row.SourceFingerprint, StringComparison.OrdinalIgnoreCase))) + throw new InvalidDataException("Source fingerprint does not match the exported source text."); + } + return input; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationJson.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationJson.cs new file mode 100644 index 00000000..defd7dff --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationJson.cs @@ -0,0 +1,174 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace GalNet.Editor.Shared.Services; + +/// Strict author-file parsing and targeted leaf changes. Unknown catalog/metadata members survive edits. +public static class LocalizationJson +{ + public const string CatalogPath = "I18n/catalog/characters.json"; + public const string MetadataPath = "Graph/localization-authoring.json"; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public static string Path(string path) + { + path = ProjectFilePaths.Normalize(path); + if (path is CatalogPath or MetadataPath) return path; + var parts = path.Split('/'); + if (parts.Length < 3 || parts[0] != "I18n" || !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) || + parts[1].Equals("External", StringComparison.OrdinalIgnoreCase) || parts[1].Equals("catalog", StringComparison.OrdinalIgnoreCase)) + throw ProjectFilePaths.Error("protected", path); + if (CultureInfo.GetCultureInfo(parts[1]).Name.Length == 0) throw ProjectFilePaths.Error("category", path); + return path; + } + + public static byte[] Initial(string path) => Encoding.UTF8.GetBytes(Path(path) switch + { + CatalogPath => "[]", + MetadataPath => "{\"version\":1,\"characterHighWater\":0}", + _ => "{}" + }); + + public static string Decode(byte[] bytes) + { + var (encoding, skip) = EncodingOf(bytes); + return encoding.GetString(bytes, skip, bytes.Length - skip); + } + private static (Encoding Encoding, int Skip) EncodingOf(byte[] bytes) + { + if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe, 0, 0 })) return (new UTF32Encoding(false, true, true), 4); + if (bytes.AsSpan().StartsWith(new byte[] { 0, 0, 0xfe, 0xff })) return (new UTF32Encoding(true, true, true), 4); + if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe })) return (new UnicodeEncoding(false, true, true), 2); + if (bytes.AsSpan().StartsWith(new byte[] { 0xfe, 0xff })) return (new UnicodeEncoding(true, true, true), 2); + var bom = bytes.AsSpan().StartsWith(new byte[] { 0xef, 0xbb, 0xbf }); + return (new UTF8Encoding(bom, true), bom ? 3 : 0); + } + + public static byte[] WriteLike(byte[] original, JsonNode node) + { + var (encoding, _) = EncodingOf(original); + return encoding.GetPreamble().Concat(encoding.GetBytes(node.ToJsonString(JsonOptions))).ToArray(); + } + + public static void Validate(string path, byte[] bytes) + { + path = Path(path); + using var json = JsonDocument.Parse(Decode(bytes)); + CheckProperties(json.RootElement); + if (path == CatalogPath) + { + if (json.RootElement.ValueKind != JsonValueKind.Array) throw new InvalidDataException("Character catalog must be an array."); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var item in json.RootElement.EnumerateArray()) + { + var id = ProjectRawReader.Text(item, "id"); var name = ProjectRawReader.Text(item, "displayNameKey"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name) || !ids.Add(id)) throw new InvalidDataException("Invalid or duplicate character identity."); + } + } + else if (path == MetadataPath) + { + var root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var number) || number != 1 || + !root.TryGetProperty("characterHighWater", out var water) || water.ValueKind != JsonValueKind.Number || !water.TryGetInt64(out var highWater) || highWater < 0) + throw new InvalidDataException("Unsupported localization author metadata."); + if (root.TryGetProperty("exchangeProjectId", out var projectId) && (projectId.ValueKind != JsonValueKind.String || !Guid.TryParseExact(projectId.GetString(), "N", out _))) + throw new InvalidDataException("Invalid localization exchange project identity."); + if (root.TryGetProperty("textStates", out var states)) + { + if (states.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Text states must be an object."); + foreach (var item in states.EnumerateObject()) + { + var state = item.Value; + if (string.IsNullOrWhiteSpace(item.Name) || state.ValueKind != JsonValueKind.Object || !state.TryGetProperty("sourceLocale", out var locale) || locale.ValueKind != JsonValueKind.String || + !state.TryGetProperty("sourceFingerprint", out var fingerprint) || !Hash(fingerprint) || + !state.TryGetProperty("translations", out var translations) || translations.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("Invalid text revision state."); + if (CultureInfo.GetCultureInfo(locale.GetString()!).Name.Length == 0) throw new InvalidDataException("Source culture cannot be invariant."); + foreach (var translation in translations.EnumerateObject()) + { if (CultureInfo.GetCultureInfo(translation.Name).Name.Length == 0 || translation.Value.ValueKind != JsonValueKind.Null && !Hash(translation.Value)) throw new InvalidDataException("Invalid translation revision."); } + } + } + } + else _ = TextLeaves(json.RootElement); + } + private static bool Hash(JsonElement value) => value.ValueKind == JsonValueKind.String && value.GetString() is { Length: 64 } hash && hash.All(Uri.IsHexDigit); + + private static void CheckProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw new InvalidDataException("Duplicate JSON property: " + property.Name); + CheckProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (var item in element.EnumerateArray()) CheckProperties(item); + } + + private static Dictionary TextLeaves(JsonElement root) + { + var leaves = new Dictionary(StringComparer.Ordinal); + Walk(root, "", []); + return leaves; + void Walk(JsonElement value, string prefix, string[] path) + { + if (value.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Translations require string leaves in an object."); + foreach (var property in value.EnumerateObject()) + { + if (string.IsNullOrWhiteSpace(property.Name)) throw new InvalidDataException("Empty translation key."); + var key = prefix.Length == 0 ? property.Name : prefix + "." + property.Name; + var child = path.Append(property.Name).ToArray(); + if (property.Value.ValueKind == JsonValueKind.Object) Walk(property.Value, key, child); + else if (property.Value.ValueKind != JsonValueKind.String || !leaves.TryAdd(key, child)) + throw new InvalidDataException("Invalid or colliding translation key: " + key); + } + } + } + + public static string? Text(byte[] bytes, string key) + { + using var json = JsonDocument.Parse(Decode(bytes)); + CheckProperties(json.RootElement); + if (!TextLeaves(json.RootElement).TryGetValue(key, out var path)) return null; + var value = json.RootElement; + foreach (var part in path) value = value.GetProperty(part); + return value.GetString(); + } + + public static byte[] SetText(byte[] bytes, string key, string text) + => SetTexts(bytes, new Dictionary(StringComparer.Ordinal) { [key] = text }); + + public static IReadOnlyDictionary Texts(byte[] bytes) + { + using var json = JsonDocument.Parse(Decode(bytes)); CheckProperties(json.RootElement); + return TextLeaves(json.RootElement).ToDictionary(pair => pair.Key, pair => + { + var value = json.RootElement; foreach (var part in pair.Value) value = value.GetProperty(part); + return value.GetString()!; + }, StringComparer.Ordinal); + } + public static byte[] SetTexts(byte[] bytes, IReadOnlyDictionary values) + { + using var json = JsonDocument.Parse(Decode(bytes)); + CheckProperties(json.RootElement); + var leaves = TextLeaves(json.RootElement); + var root = JsonNode.Parse(Decode(bytes))!.AsObject(); + foreach (var (key, text) in values) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); ArgumentNullException.ThrowIfNull(text); + var path = leaves.GetValueOrDefault(key) ?? [key]; var parent = root; + foreach (var part in path.SkipLast(1)) parent = parent[part]!.AsObject(); + if (!leaves.ContainsKey(key) && parent.ContainsKey(path[^1])) throw new InvalidDataException("Key conflicts with an existing object."); + parent[path[^1]] = text; + } + var result = WriteLike(bytes, root); + using var check = JsonDocument.Parse(Decode(result)); + _ = TextLeaves(check.RootElement); + return result; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationReferenceIndex.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationReferenceIndex.cs new file mode 100644 index 00000000..abb2a3c8 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationReferenceIndex.cs @@ -0,0 +1,224 @@ +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Core.Parameters; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed record LocalizationSource(string Path, string Json); +public sealed record LocalizationReference(string Owner, string? EntryId, string JsonPointer, string Key, FieldValueSemantic Semantic); +public sealed record LocalizationText(string Culture, string Key, string Text, string File, string JsonPointer); +public sealed record LocalizationIssue(string Code, string File, string JsonPointer, string Message); +public sealed record LocalizationCharacter(string Id, string DisplayNameKey); + +/// Read-only, bounded analysis of one author snapshot. Never executes entries or changes keys. +public sealed class LocalizationReferenceIndex +{ + private const int MaximumDepth = 16; + private const int MaximumValues = 100_000; + private readonly List _references = []; + private readonly List _texts = []; + private readonly List _issues = []; + private readonly List _characters = []; + private readonly Dictionary> _effective = new(StringComparer.OrdinalIgnoreCase); + private int _remaining = MaximumValues; + + public LocalizationReferenceIndex(EditorProjectDocument document, IEntryCatalog catalog, IEnumerable sources, + IEnumerable? sourceIssues = null) + { + foreach (var issue in sourceIssues ?? []) Issue(issue.Code, issue.File, issue.JsonPointer, issue.Message); + foreach (var source in sources.OrderBy(source => source.Path, StringComparer.Ordinal)) ReadSource(source); + foreach (var node in document.Graph.Nodes.Where(node => node.Type == "Group")) + if (!document.GroupEntries.ContainsKey(node.Id)) Issue("group.missing", node.Id, "", "Author Group is unavailable."); + foreach (var (groupId, entries) in document.GroupEntries) + { + var identities = new HashSet(StringComparer.Ordinal); + foreach (var entry in entries) + { + if (!Spend(groupId, "", 0)) break; + if (string.IsNullOrWhiteSpace(entry.StableId) || !identities.Add(entry.StableId)) + Issue("entry.identity", groupId, "", "Entry identity is missing or duplicated."); + if (!catalog.TryGet(entry.Type, out var definition) || definition.DynamicParameters is null) + { Issue("schema.unavailable", groupId, "", $"No field schema for '{entry.Type}'."); continue; } + foreach (var name in entry.Parameters.Keys.Where(name => !definition.DynamicParameters.ContainsKey(name))) + Issue("field.unknown", groupId, "/" + Escape(name), "Unknown author field cannot be analyzed."); + foreach (var field in definition.DynamicParameters.Fields) + if (entry.Parameters.TryGetValue(field.Id, out var value)) + Walk(field, value, groupId, entry.StableId, "/" + Escape(field.Id), 0); + } + } + foreach (var node in document.Graph.Nodes.Where(node => node.Type == "Branch" && node.BranchType == "Choice")) + foreach (var option in node.Options ?? []) + { + if (!Spend(node.Id, "", 0)) break; + Reference(node.Id, option.Id, "/text", option.Text, FieldValueSemantic.LocalizedText); + } + var ids = _characters.Select(character => character.Id).ToHashSet(StringComparer.Ordinal); + var defaultKeys = _texts.Where(text => text.Culture.Equals(document.Settings.TargetLocale.Code, StringComparison.OrdinalIgnoreCase)) + .Select(text => text.Key).ToHashSet(StringComparer.Ordinal); + foreach (var reference in _references) + if (reference.Semantic == FieldValueSemantic.CharacterReference ? !ids.Contains(reference.Key) : !defaultKeys.Contains(reference.Key)) + Issue(reference.Semantic == FieldValueSemantic.CharacterReference ? "character.unknown" : "text.defaultMissing", + reference.Owner, reference.JsonPointer, $"Unresolved reference '{reference.Key}'.", incomplete: false); + References = _references.AsReadOnly(); Texts = _texts.AsReadOnly(); Issues = _issues.AsReadOnly(); Characters = _characters.AsReadOnly(); + } + + public IReadOnlyList References { get; } + /// All definitions in load order, including shadowed definitions. + public IReadOnlyList Texts { get; } + public IReadOnlyList Issues { get; } + public IReadOnlyList Characters { get; } + /// False means absence of a reference cannot establish that a character is unused. + public bool Complete { get; private set; } = true; + public LocalizationText? FindDirect(string culture, string key) => _effective.GetValueOrDefault(culture)?.GetValueOrDefault(key); + + private void Walk(FieldDescriptor field, JsonElement value, string owner, string entryId, string pointer, int depth, JsonSerializerOptions? wireOptions = null) + { + if (!Spend(owner, pointer, depth) || value.ValueKind == JsonValueKind.Null) return; + if (field.Semantic != FieldValueSemantic.None) + { + if (value.ValueKind != JsonValueKind.String) Issue("field.stringRequired", owner, pointer, "Localized fields must contain string keys."); + else Reference(owner, entryId, pointer, value.GetString()!, field.Semantic); + return; + } + WalkValue(field.Value.ValueType, value, owner, entryId, pointer, depth, wireOptions ?? field.Value.JsonOptions); + } + + private void WalkValue(Type type, JsonElement value, string owner, string entryId, string pointer, int depth, JsonSerializerOptions options) + { + if (value.ValueKind == JsonValueKind.Null) return; + var elementType = type.IsArray && type.GetArrayRank() == 1 ? type.GetElementType() + : type.IsGenericType && type.GetGenericTypeDefinition() is var generic && + (generic == typeof(List<>) || generic == typeof(IList<>) || generic == typeof(IReadOnlyList<>) || generic == typeof(IEnumerable<>)) + ? type.GetGenericArguments()[0] : null; + if (elementType is not null) + { + if (value.ValueKind != JsonValueKind.Array) { Issue("field.arrayRequired", owner, pointer, "Expected a list."); return; } + var index = 0; + foreach (var item in value.EnumerateArray()) + { + var child = pointer + "/" + index++; + if (!Spend(owner, child, depth + 1)) break; + WalkValue(elementType, item, owner, entryId, child, depth + 1, options); + } + } + else if (typeof(IFieldDto).IsAssignableFrom(type)) + { + if (value.ValueKind != JsonValueKind.Object) { Issue("field.objectRequired", owner, pointer, "Expected an object."); return; } + IReadOnlyList? fields; + try { fields = type.GetProperty("Schema", BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)?.GetValue(null) as IReadOnlyList; } + catch (Exception error) when (error is TargetInvocationException or TypeInitializationException) + { Issue("schema.invalid", owner, pointer, error.Message); return; } + if (fields is null) { Issue("schema.unavailable", owner, pointer, "Nested DTO has no readable schema."); return; } + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + if (!names.Add(property.Name)) Issue("field.duplicate", owner, pointer, "Duplicate author property."); + var properties = options.GetTypeInfo(type).Properties; + var mapped = fields.Select(field => + { + var member = type.GetMembers(BindingFlags.Public | BindingFlags.Instance).FirstOrDefault(property => property is PropertyInfo or FieldInfo && property.GetCustomAttribute() is not null && + (property.GetCustomAttribute()?.Name ?? JsonNamingPolicy.CamelCase.ConvertName(property.Name)) == field.Id); + var wire = properties.FirstOrDefault(property => property.AttributeProvider is MemberInfo info && info.Name == member?.Name)?.Name ?? field.Id; + return (Field: field, Wire: wire); + }).ToArray(); + foreach (var member in mapped) + if (value.TryGetProperty(member.Wire, out var child)) Walk(member.Field, child, owner, entryId, pointer + "/" + Escape(member.Wire), depth + 1, options); + foreach (var name in names.Where(name => !mapped.Any(member => member.Wire == name))) + Issue("field.unknown", owner, pointer + "/" + Escape(name), "Unknown nested author field cannot be analyzed."); + } + else if (value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) + Issue("field.opaque", owner, pointer, "Structured value has no traversable field schema."); + } + + private void Reference(string owner, string? entryId, string pointer, string key, FieldValueSemantic semantic) + { + if (key == string.Empty) return; + if (string.IsNullOrWhiteSpace(key)) + { + if (semantic == FieldValueSemantic.LocalizedText) Issue("text.emptyKey", owner, pointer, "Text key is empty.", incomplete: false); + return; + } + _references.Add(new(owner, entryId, pointer, key, semantic)); + } + + private void ReadSource(LocalizationSource source) + { + var path = source.Path.Replace('\\', '/'); + if (!path.StartsWith("I18n/", StringComparison.Ordinal)) return; + var parts = path.Split('/'); + if (parts.Length < 3) { Issue("source.layout", path, "", "Localization files must be below a culture directory."); return; } + if (parts[1].Equals("External", StringComparison.OrdinalIgnoreCase)) return; + if (parts[1].Equals("catalog", StringComparison.OrdinalIgnoreCase) && path != "I18n/catalog/characters.json") return; + try + { + using var json = JsonDocument.Parse(source.Json); + if (path == "I18n/catalog/characters.json") + { + if (json.RootElement.ValueKind != JsonValueKind.Array) { Issue("catalog.invalid", path, "", "Expected character array."); return; } + var ids = _characters.Select(character => character.Id).ToHashSet(StringComparer.Ordinal); + var index = 0; + foreach (var element in json.RootElement.EnumerateArray()) + { + var pointer = "/" + index++; + if (!Spend(path, pointer, 0)) break; + var id = ProjectRawReader.Text(element, "id"); var name = ProjectRawReader.Text(element, "displayNameKey"); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(name) || !ids.Add(id)) + { Issue("catalog.invalid", path, pointer, "Missing name key or duplicate/empty character ID."); continue; } + if (element.EnumerateObject().GroupBy(property => property.Name, StringComparer.Ordinal).Any(group => group.Count() > 1)) + Issue("catalog.duplicateProperty", path, pointer, "Duplicate catalog property."); + _characters.Add(new(id, name)); + Reference("characters", id, pointer + "/displayNameKey", name, FieldValueSemantic.LocalizedText); + } + } + else + { + var culture = CultureInfo.GetCultureInfo(parts[1]).Name; + if (culture.Length == 0) { Issue("culture.invalid", path, "", "Invariant culture is not a project language."); return; } + Flatten(json.RootElement, "", "", path, culture, new(StringComparer.Ordinal), 0); + } + } + catch (Exception error) when (error is JsonException or CultureNotFoundException) + { Issue("source.invalid", path, "", error.Message); } + } + + private void Flatten(JsonElement element, string prefix, string pointer, string path, string culture, HashSet keys, int depth) + { + if (!Spend(path, pointer, depth)) return; + if (element.ValueKind != JsonValueKind.Object) { Issue("text.objectRequired", path, pointer, "Expected an object with string leaves."); return; } + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + var child = pointer + "/" + Escape(property.Name); + if (!Spend(path, child, depth + 1)) break; + if (!names.Add(property.Name) || string.IsNullOrWhiteSpace(property.Name)) + Issue("text.duplicateProperty", path, child, "Duplicate or empty property name."); + var key = prefix.Length == 0 ? property.Name : prefix + "." + property.Name; + if (property.Value.ValueKind == JsonValueKind.Object) Flatten(property.Value, key, child, path, culture, keys, depth + 1); + else if (property.Value.ValueKind == JsonValueKind.String) + { + if (!keys.Add(key)) Issue("text.keyCollision", path, child, $"Key '{key}' has multiple definitions in one file."); + var text = new LocalizationText(culture, key, property.Value.GetString()!, path, child); + _texts.Add(text); + if (!_effective.TryGetValue(culture, out var values)) _effective[culture] = values = new(StringComparer.Ordinal); + values[key] = text; + } + else Issue("text.stringRequired", path, child, "Translation must be a string."); + } + } + + private bool Spend(string file, string pointer, int depth) + { + if (_remaining <= 0) return false; + if (--_remaining == 0 || depth > MaximumDepth) + { Issue("analysis.limit", file, pointer, "Localization analysis exceeded its value/depth budget."); return false; } + return true; + } + private void Issue(string code, string file, string pointer, string message, bool incomplete = true) + { + if (incomplete) Complete = false; + _issues.Add(new(code, file, pointer, message)); + } + private static string Escape(string name) => name.Replace("~", "~0", StringComparison.Ordinal).Replace("/", "~1", StringComparison.Ordinal); +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationTextState.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationTextState.cs new file mode 100644 index 00000000..ab5a839d --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationTextState.cs @@ -0,0 +1,37 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace GalNet.Editor.Shared.Services; + +/// Author-only source revision evidence. Translation values are never overwritten by source edits. +public static class LocalizationTextState +{ + public static string Fingerprint(string text) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text))); + public static byte[] Update(byte[] metadata, string key, string culture, string? previous, string text, IEnumerable translatedCultures) + { + LocalizationJson.Validate(LocalizationJson.MetadataPath, metadata); + var root = JsonNode.Parse(LocalizationJson.Decode(metadata))!.AsObject(); + var states = root["textStates"] as JsonObject ?? new(); root["textStates"] = states; + if (states[key] is not JsonObject state) + { + state = new JsonObject { ["sourceLocale"] = culture, ["translations"] = new JsonObject() }; states[key] = state; + } + var translations = state["translations"]!.AsObject(); + foreach (var language in translatedCultures.Where(language => !language.Equals(culture, StringComparison.OrdinalIgnoreCase)).Distinct(StringComparer.OrdinalIgnoreCase)) + if (!translations.ContainsKey(language)) translations[language] = previous is null || previous == text ? null : Fingerprint(previous); + state["sourceLocale"] = culture; state["sourceFingerprint"] = Fingerprint(text); + return LocalizationJson.WriteLike(metadata, root); + } + public static bool? NeedsReview(byte[] metadata, string key, string culture, string? sourceLocale = null) + { + var root = JsonNode.Parse(LocalizationJson.Decode(metadata)); + return NeedsReview(root?["textStates"]?[key] as JsonObject, culture, sourceLocale); + } + public static bool? NeedsReview(JsonObject? state, string culture, string? sourceLocale = null) + { + if (state is null || state["translations"]?[culture] is not JsonValue reviewed) return null; + if (sourceLocale is not null && state["sourceLocale"]?.GetValue() != sourceLocale) return null; + return !string.Equals(reviewed.GetValue(), state["sourceFingerprint"]?.GetValue(), StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/LocalizationValidation.cs b/src/Editor/GalNet.Editor.Shared/Services/LocalizationValidation.cs new file mode 100644 index 00000000..ac13772e --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/LocalizationValidation.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Editor.Abstraction.Commands; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Shared.Commands; + +namespace GalNet.Editor.Shared.Services; + +public static class LocalizationValidation +{ + public static IReadOnlyList ValidateDisk(string root, IEntryCatalog catalog) + { + var diagnostics = new List(); + try + { + var reader = new ProjectRawReader(root); + var settings = File.Exists(reader.Resolve("settings.json")) ? DirectProjectPersistence.LoadSettings(root) : new GalNet.Core.Settings.ProjectSettings(); + var loaded = new EditorDocumentRepository(catalog, EditorDocumentValidation.Authoring).Load(root, "", settings); + var snapshot = new EditorProjectDocument { Graph = loaded.Document, GroupEntries = loaded.GroupEntries, Settings = settings }; + var paths = Directory.Exists(reader.Resolve("I18n")) ? reader.Files("I18n", diagnostics) : []; + var sources = paths.Where(path => path.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("I18n/External/", StringComparison.OrdinalIgnoreCase)) + .Select(path => new LocalizationSource(path, File.ReadAllText(reader.Resolve(path)))).ToArray(); + var index = new LocalizationReferenceIndex(snapshot, catalog, sources); + foreach (var issue in index.Issues) + { + var file = issue.File; var pointer = issue.JsonPointer; + var reference = index.References.FirstOrDefault(reference => reference.Owner == file && reference.JsonPointer == pointer && issue.Message.Contains("'" + reference.Key + "'", StringComparison.Ordinal)); + if (loaded.Document.Nodes.FirstOrDefault(node => node.Id == file) is { } owner) + { + file = owner.Type == "Group" ? "Graph/" + owner.File : "Graph/graph.json"; + if (reference is not null && owner.Type == "Group") pointer = "/entries/" + loaded.GroupEntries[owner.Id].FindIndex(entry => entry.StableId == reference.EntryId) + "/parameters" + pointer; + else if (reference is not null && owner.Options is not null) pointer = "/nodes/" + loaded.Document.Nodes.IndexOf(owner) + "/options/" + owner.Options.FindIndex(option => option.Id == reference.EntryId) + pointer; + } + else if (file == "characters") file = LocalizationJson.CatalogPath; + var incomplete = issue.Code is "schema.unavailable" or "field.unknown" or "field.opaque" or "analysis.limit"; + diagnostics.Add(new("localization." + issue.Code, incomplete ? "warning" : "error", issue.Message, file, reference?.EntryId, pointer)); + } + if (!index.Complete) diagnostics.Add(new("localization.incomplete", "warning", "Localization analysis is incomplete; provide all custom field schemas.")); + if (settings.AvailableLocales.Select(locale => locale.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != settings.AvailableLocales.Count || + !settings.AvailableLocales.Any(locale => locale.Code == settings.TargetLocale.Code)) + diagnostics.Add(new("localization.languages", "error", "Project languages must be unique and include the development language.", "settings.json")); + var metadata = reader.Resolve(LocalizationJson.MetadataPath); + if (File.Exists(metadata)) LocalizationJson.Validate(LocalizationJson.MetadataPath, File.ReadAllBytes(metadata)); + } + catch (Exception error) when (error is IOException or ArgumentException or JsonException or InvalidDataException or UnauthorizedAccessException) + { diagnostics.Add(new("localization.invalid", "error", error.Message)); } + return diagnostics; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs index 23263358..5188bb9b 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using GalNet.Core.Assets; using GalNet.Core.Compilation; using GalNet.Core.Entry; using GalNet.Core.Serialization; @@ -8,7 +9,7 @@ namespace GalNet.Editor.Shared.Services; /// Builds editable project content into the compiled directory layout consumed by Runtime. -public sealed class ProjectContentBuilder(IEntryCatalog entryCatalog) +public sealed class ProjectContentBuilder(IEntryCatalog entryCatalog, IResourceTypeCatalog? resourceTypes = null) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { @@ -34,6 +35,8 @@ public async Task BuildAsync( cancellationToken.ThrowIfCancellationRequested(); var validation = new ProjectValidationService(sourceRoot).Validate("graph"); var diagnostics = validation.Diagnostics.ToList(); + diagnostics.AddRange(ImageVariantsValidation.Validate(sourceRoot, resourceTypes ?? BuiltinResourceTypes.CreateCatalog())); + diagnostics.AddRange(LocalizationValidation.ValidateDisk(sourceRoot, _entryCatalog)); _ = new ProjectRawReader(sourceRoot).Files(null, diagnostics); if (!validation.Success || diagnostics.Any(item => item.Code == "file.linkSkipped" || item.Severity == "error")) throw new InvalidDataException(string.Join("; ", diagnostics.Select(item => $"{item.File}{item.JsonPointer}: {item.Message}"))); @@ -76,7 +79,7 @@ public async Task BuildAsync( backupRoot = null; } - return new ProjectContentBuildResult(targetRoot, compiledGroups); + return new ProjectContentBuildResult(targetRoot, compiledGroups) { Diagnostics = diagnostics.ToArray() }; } finally { @@ -216,4 +219,7 @@ private static bool IsSameOrChild(string candidate, string parent) private static string DisplayPath(string basePath, string path) => Path.GetRelativePath(basePath, path).Replace('\\', '/'); } -public sealed record ProjectContentBuildResult(string OutputPath, int CompiledGroupCount); +public sealed record ProjectContentBuildResult(string OutputPath, int CompiledGroupCount) +{ + public IReadOnlyList Diagnostics { get; init; } = []; +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentSession.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentSession.cs index eb4dbf35..9ab1ad61 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentSession.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentSession.cs @@ -15,9 +15,45 @@ public sealed class ProjectContentSession(IEditorDocumentService documents, IEdi private GalProject? _project; private byte[] _baseline = []; private byte[] _settingsBaseline = []; + private byte[]? _publicationContent; private bool _suspended; private bool _disposed; private readonly List _drafts = []; + private readonly Dictionary _files = []; + public IReadOnlyDictionary FileDocuments => _files; + public EditorFileDocument TrackFile(string path, byte[]? textBytes = null) + { + path = textBytes is null ? ProjectFilePaths.Normalize(path) : ProjectFilePaths.Editable(path); + _ = new ProjectRawReader(_project!.RootPath).Resolve(path); + var existing = _files.Values.FirstOrDefault(file => file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, path)); + if (existing is not null) return existing; + if (IsPublishing) throw ProjectFilePaths.Error("busy", path); + var file = new EditorFileDocument { Path = path, ManagedContent = textBytes is not null, Data = textBytes?.ToArray() ?? [] }; + _files.Add(file.Id, file); + var saved = JsonSerializer.Deserialize(_baseline, JsonOptions)!; + saved.Files.Add(file.Id, EditorDocumentCloner.Clone(new EditorProjectDocument { Files = new() { [file.Id] = file } }).Files[file.Id]); + _baseline = Serialize(saved); + Refresh(); + return file; + } + public EditorFileDocument TrackLocalizationFile(string path, byte[] textBytes) + { + ObjectDisposedException.ThrowIf(_disposed, this); + path = LocalizationJson.Path(path); + if (_suspended || RepairRequired || IsPublishing) throw ProjectFilePaths.Error("busy", path); + var full = new ProjectRawReader(_project!.RootPath).Resolve(path); + if (!File.Exists(full) || !File.ReadAllBytes(full).AsSpan().SequenceEqual(textBytes)) throw ProjectFilePaths.Error("changed", path); + var existing = _files.Values.SingleOrDefault(file => file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, path)); + if (existing is { ManagedContent: true }) return existing; + var saved = CaptureSaved(); + var file = existing ?? new EditorFileDocument { Path = path }; + file.ManagedContent = true; file.Data = textBytes.ToArray(); + _files[file.Id] = file; + saved.Files[file.Id] = new() { Id = file.Id, Path = path, ManagedContent = true, Data = textBytes.ToArray() }; + _baseline = Serialize(saved); + Refresh(); + return file; + } private Func>? _captureGuard; private readonly CancellationTokenSource _lifetime = new(); private ProjectRecoveryStore? _recovery; @@ -27,7 +63,10 @@ public sealed class ProjectContentSession(IEditorDocumentService documents, IEdi public Exception? LastRecoveryError { get; private set; } public Exception? LastNotificationError { get; private set; } - public bool IsDirty => _project is not null && (_drafts.Any(draft => draft.HasChanges()) || !Serialize(Capture()).AsSpan().SequenceEqual(_baseline)); + public bool IsPublishing => !_disposed && _saveGate.CurrentCount == 0; + public bool HasEditsSincePublication => IsPublishing && _publicationContent is { } captured && + (_drafts.Any(draft => draft.HasChanges()) || !Serialize(Capture()).AsSpan().SequenceEqual(captured)); + public bool IsDirty => _project is not null && (_drafts.Any(draft => draft.HasChanges()) || !ComparisonBytes(Capture()).AsSpan().SequenceEqual(ComparisonBytes(JsonSerializer.Deserialize(_baseline, JsonOptions)!))); public bool RepairRequired { get; private set; } public Exception? LastError { get; private set; } public event Action? Changed; @@ -38,6 +77,18 @@ public IDisposable RegisterDraft(Func hasChanges, Func commit) _drafts.Add(registration); return registration; } + public IDisposable RegisterFileDraft(string fileId, Func hasChanges, Func commit, Action validate) + { + var registration = new DraftRegistration(_drafts, hasChanges, commit, fileId, validate); + _drafts.Add(registration); + return registration; + } + public async Task CommitFileDraftsAsync(string fileId) + { + var drafts = _drafts.Where(draft => draft.FileId == fileId && draft.HasChanges()).ToArray(); + foreach (var draft in drafts) draft.Validate?.Invoke(); + foreach (var draft in drafts) await draft.Commit(); + } public IDisposable RegisterCaptureGuard(Func> acquire) { @@ -57,7 +108,8 @@ public async Task CommitDraftsAsync() if (draft.HasChanges()) await draft.Commit(); } - private sealed record DraftRegistration(List Owner, Func HasChanges, Func Commit) : IDisposable + private sealed record DraftRegistration(List Owner, Func HasChanges, Func Commit, + string? FileId = null, Action? Validate = null) : IDisposable { public void Dispose() => Owner.Remove(this); } @@ -65,6 +117,7 @@ private sealed record DraftRegistration(List Owner, Func pair.Key, StringComparer.Ordinal).ToDictionary(pair => pair.Key, pair => pair.Value); + document.Files = document.Files.OrderBy(pair => pair.Key, StringComparer.Ordinal).ToDictionary(pair => pair.Key, pair => pair.Value); foreach (var entry in document.GroupEntries.Values.SelectMany(entries => entries)) { entry.Id = 0; @@ -147,6 +201,12 @@ private static byte[] Serialize(EditorProjectDocument document) return JsonSerializer.SerializeToUtf8Bytes(document, JsonOptions); } + private static byte[] ComparisonBytes(EditorProjectDocument document) + { + foreach (var file in document.Files.Values.Where(file => !file.Exists)) file.Data = []; + return Serialize(document); + } + public async Task SaveAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed, this); @@ -154,6 +214,7 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) try { await CommitDraftsAsync(); } catch (Exception failure) { ReportError(failure); throw; } var captureLease = await AcquirePlanAsync(false, cancellationToken); + Refresh(); try { if (_suspended) throw new InvalidOperationException("Project publication is suspended while closing."); @@ -166,7 +227,9 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) try { var snapshot = Capture(); + CheckTrackedFiles(); capturedContent = Serialize(snapshot); + _publicationContent = capturedContent; if (!File.ReadAllBytes(settingsPath).AsSpan().SequenceEqual(_settingsBaseline)) throw new EditorOperationException("project.externalSettings", "Project settings changed outside the editor. Reload before saving."); var content = repository.PrepareSave(project.RootPath, snapshot.Graph, @@ -177,12 +240,14 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) ["settings.json"] = settings, [".galnet/editor-state.json"] = JsonSerializer.SerializeToUtf8Bytes(project.EditorState, JsonOptions) }; - plan = new ProjectSavePlan(writes, content.DeletedFiles); + foreach (var file in snapshot.Files.Values.Where(file => file.Exists && file.ManagedContent)) writes[file.Path] = file.Data; + plan = new ProjectSavePlan(writes, content.DeletedFiles) { Files = TrackedExpectations() }; } finally { captureLease?.Dispose(); captureLease = null; } // Capture and plan on the UI caller; publish detached bytes while newer author edits remain possible. await Task.Run(() => { + CheckTrackedFiles(); if (!File.ReadAllBytes(settingsPath).AsSpan().SequenceEqual(_settingsBaseline)) throw new EditorOperationException("project.externalSettings", "Project settings changed before publication."); repository.ApplySave(project.RootPath, plan, cancellationToken); @@ -200,7 +265,7 @@ await Task.Run(() => Refresh(); throw; } - finally { captureLease?.Dispose(); _saveGate.Release(); } + finally { captureLease?.Dispose(); _publicationContent = null; _saveGate.Release(); Refresh(); } } public async Task SuspendAsync() @@ -278,10 +343,15 @@ public async Task PublishStructureAsync(Func pair.Key, pair => (IReadOnlyList)pair.Value)); - return new(new Dictionary(content.Writes) { ["settings.json"] = JsonSerializer.SerializeToUtf8Bytes(snapshot.Settings, JsonOptions) }, content.DeletedFiles); + var writes = new Dictionary(content.Writes) { ["settings.json"] = JsonSerializer.SerializeToUtf8Bytes(snapshot.Settings, JsonOptions) }; + foreach (var file in snapshot.Files.Values.Where(file => file.Exists && file.ManagedContent)) writes[file.Path] = file.Data; + var previous = JsonSerializer.Deserialize(_baseline, JsonOptions)!; + var removed = previous.Files.Values.Where(file => file.ManagedContent && file.Exists && + (!snapshot.Files.TryGetValue(file.Id, out var next) || !next.Exists || !ProjectFilePaths.Comparer.Equals(file.Path, next.Path))).Select(file => file.Path); + return new(writes, content.DeletedFiles.Concat(removed).Distinct(ProjectFilePaths.Comparer).ToArray()) { Files = TrackedExpectations() }; + } + + private ProjectFilePlan TrackedExpectations() + { + var plan = new ProjectFilePlan(); + var saved = JsonSerializer.Deserialize(_baseline, JsonOptions)!; + foreach (var file in TrackedFiles(saved)) + plan.ExpectedHashes[file.Path] = file.Exists ? Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(file.Data)) : null; + return plan; } + private void CheckTrackedFiles() + { + var saved = JsonSerializer.Deserialize(_baseline, JsonOptions)!; + var reader = new ProjectRawReader(_project!.RootPath); + foreach (var file in TrackedFiles(saved)) + { + var path = reader.Resolve(file.Path); + if (file.Exists ? !File.Exists(path) || !File.ReadAllBytes(path).AsSpan().SequenceEqual(file.Data) : File.Exists(path)) + throw ProjectFilePaths.Error("changed", file.Path); + } + } + + private static IEnumerable TrackedFiles(EditorProjectDocument document) => document.Files.Values + .GroupBy(file => file.Path, ProjectFilePaths.Comparer) + .Select(group => group.SingleOrDefault(file => file.Exists) ?? group.First()) + .Where(file => file.ManagedContent); + private static void ValidateWorkingStructure(string projectPath, EditorProjectDocument document) { var reader = new ProjectRawReader(projectPath); diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs new file mode 100644 index 00000000..06e72107 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.Variants.cs @@ -0,0 +1,82 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Assets.Variants; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class ProjectFileOperation +{ + private readonly List<(string From, string To)> _variantCopies = []; + + private (Dictionary Files, HashSet Directories) AssetSnapshot() + { + var files = new Dictionary(ProjectFilePaths.Comparer); + var directories = new HashSet(ProjectFilePaths.Comparer); + var root = _reader.Resolve("Assets"); + if (!Directory.Exists(root)) return (files, directories); + var tree = Enumerate(root); + foreach (var path in tree.Files) files.Add(_reader.Relative(path), path); + foreach (var path in tree.Directories) directories.Add(_reader.Relative(path)); + return (files, directories); + } + + private void PrepareVariantRelations() + { + if (_request.Action is not (ProjectFileAction.Move or ProjectFileAction.Copy)) return; + if (!_before.Keys.Concat(_after.Keys).Any(path => path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) return; + var snapshot = AssetSnapshot(); + foreach (var path in _before.Keys) snapshot.Files.Remove(path); + foreach (var pair in _after.Where(pair => pair.Key.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) snapshot.Files[pair.Key] = pair.Value.Backup; + foreach (var pair in snapshot.Files.Where(pair => IsVariants(pair.Key, snapshot.Files)).ToArray()) + { + var destination = pair.Key; + var origin = destination; + (string From, string To)? copied = null; + foreach (var move in _moves.Where(move => !move.From.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + if (ProjectFilePaths.Within(destination, move.To)) { origin = move.From + destination[move.To.Length..]; break; } + foreach (var copy in _variantCopies) + if (ProjectFilePaths.Within(destination, copy.To)) { origin = copy.From + destination[copy.To.Length..]; copied = copy; break; } + var bytes = File.ReadAllBytes(pair.Value); + var source = ImageVariantsCodec.ReadSource(bytes); + var oldFolder = "Assets/" + LocalAssetCompiler.ResolveFolder(origin[7..], source.Folder); + oldFolder = oldFolder.TrimEnd('/'); + var newFolder = Map(oldFolder, true); + if (copied is { } relation && ProjectFilePaths.Within(oldFolder, relation.From)) + newFolder = relation.To + oldFolder[relation.From.Length..]; + if (ProjectFilePaths.Comparer.Equals(origin, destination) && ProjectFilePaths.Comparer.Equals(oldFolder, newFolder)) continue; + var parent = Path.GetDirectoryName(_reader.Resolve(destination))!; + var relative = Path.GetRelativePath(parent, _reader.Resolve(newFolder)).Replace('\\', '/'); + var json = JsonNode.Parse(bytes)!.AsObject(); + json["folder"] = relative; + if (!_after.ContainsKey(destination)) + { + _before[destination] = SnapshotFile(pair.Value); + } + _after[destination] = Store(Encoding.UTF8.GetBytes(json.ToJsonString(new JsonSerializerOptions { WriteIndented = true }))); + } + } + + private bool IsVariants(string path, IReadOnlyDictionary files) + { + if (path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false; + if (_types.TryGetByExtension(Path.GetExtension(path), out var type) && type.TypeId == ImageVariantsCodec.ResourceType) return true; + return files.TryGetValue(path + ".meta", out var sidecar) + && _codec.Deserialize(File.ReadAllText(sidecar), path[7..]).TypeId == ImageVariantsCodec.ResourceType; + } + + private void ValidateVariants(IReadOnlyDictionary current, IReadOnlyDictionary desired, + IReadOnlyCollection currentDirectories, IReadOnlyCollection desiredDirectories) + { + if (!current.Keys.Concat(desired.Keys).Any(path => path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) return; + var snapshot = AssetSnapshot(); + foreach (var path in current.Keys) snapshot.Files.Remove(path); + foreach (var pair in desired.Where(pair => pair.Key.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))) snapshot.Files[pair.Key] = pair.Value.Backup; + if (!snapshot.Files.Keys.Any(path => IsVariants(path, snapshot.Files))) return; + snapshot.Directories.ExceptWith(currentDirectories); + snapshot.Directories.UnionWith(desiredDirectories.Where(path => path == "Assets" || path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))); + var files = snapshot.Files.ToDictionary(pair => pair.Key[7..], pair => pair.Value, StringComparer.OrdinalIgnoreCase); + var directories = snapshot.Directories.Select(path => path == "Assets" ? "" : path[7..]).ToArray(); + _ = LocalAssetCompiler.CompileAsync(files, directories, _types).GetAwaiter().GetResult(); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs new file mode 100644 index 00000000..535ab3cc --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectFileOperation.cs @@ -0,0 +1,457 @@ +using System.Globalization; +using System.Text.Json; +using GalNet.Core.Assets; +using GalNet.Core.Entry; +using GalNet.Editor.Abstraction.Changes; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Shared.Commands; + +namespace GalNet.Editor.Shared.Services; + +public enum ProjectFileAction { Move, Copy, Import, Delete, CreateFile, CreateDirectory, Metadata } +public sealed record ProjectFileRequest(ProjectFileAction Action, IReadOnlyList Sources, string Target = "", + bool TargetIsDirectory = true, byte[]? Content = null, bool DeleteGroups = false, bool AllowCandidateReferences = false, + string? Filter = null, string? Compression = null, bool UniqueNames = false); +public sealed record ProjectFileReference(string Document, string Field, bool Certain); +public sealed record ProjectFileImpact(IReadOnlyList Groups, IReadOnlyList References); + +/// Project-local path policy shared by GUI and CLI. Only explicit declared relations are rewritten. +public static class ProjectFilePaths +{ + public static StringComparer Comparer => OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + public static bool Within(string path, string parent) => Comparer.Equals(path, parent) || path.StartsWith(parent + "/", OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + public static string Normalize(string path) + { + if (Path.IsPathRooted(path)) throw Error("path", path); + path = path.Replace('\\', '/'); + if (string.IsNullOrWhiteSpace(path) || path.Split('/').Any(part => string.IsNullOrWhiteSpace(part) || part is "." or ".." || part.EndsWith('.') || part.EndsWith(' ') || part.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 + || new[] { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }.Contains(part.Split('.')[0], StringComparer.OrdinalIgnoreCase))) throw Error("path", path); + return path; + } + public static string Editable(string path) + { + path = Normalize(path); + var first = path.Split('/')[0]; + if (new[] { ".galnet", ".git", ".vs", ".idea", ".editor-tx", ".editor-trash", "Contracts", "Output", "Temp" }.Contains(first, StringComparer.OrdinalIgnoreCase) + || new[] { "Assets", "Graph", "I18n", "settings.json", "Graph/graph.json", LocalizationJson.MetadataPath }.Contains(path, Comparer) + || path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) throw Error("protected", path); + _ = Category(path); + return path; + } + public static string Category(string path) + { + if (path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) return "Assets"; + if (path.StartsWith("Graph/", StringComparison.OrdinalIgnoreCase)) return "Graph"; + if (!path.StartsWith("I18n/", StringComparison.OrdinalIgnoreCase)) return "Ordinary"; + var parts = path.Split('/'); + var cultureIndex = parts.Length > 1 && parts[1].Equals("External", StringComparison.OrdinalIgnoreCase) ? 2 : 1; + if (parts.Length <= cultureIndex + 1 || parts[1].Equals("catalog", StringComparison.OrdinalIgnoreCase)) throw Error("protected", path); + try { _ = CultureInfo.GetCultureInfo(parts[cultureIndex]); } catch (CultureNotFoundException) { throw Error("category", path); } + return string.Join('/', parts.Take(cultureIndex + 1)); + } + public static EditorOperationException Error(string code, string path) => new("files." + code, $"Project file operation rejected ({code}): '{path}'.", path); +} + +/// Reusable data-only edit. Immutable ordinary bytes live in retained files; Group data is rebased on each publication. +public sealed partial class ProjectFileOperation : IDisposable +{ + private readonly ProjectRawReader _reader; + private readonly ProjectFileRequest _request; + private readonly IEntryCatalog _catalog; + private readonly AssetMetaCodec _codec; + private readonly IResourceTypeCatalog _types; + private readonly string _retained; + private readonly Dictionary _before = new(StringComparer.Ordinal); + private readonly Dictionary _after = new(StringComparer.Ordinal); + private readonly HashSet _beforeDirectories = new(StringComparer.Ordinal); + private readonly HashSet _afterDirectories = new(StringComparer.Ordinal); + private readonly List<(string From, string To)> _moves = []; + private readonly List _roots = []; + private HashSet _managed = new(ProjectFilePaths.Comparer); + private readonly HashSet _targets = new(ProjectFilePaths.Comparer); + private long _storedBytes; + private long _removedBytes; + private bool _initialized; + private bool _disposed; + private FileStream? _lease; + private bool _caseOnly; + private HashSet? _galleryIds; + private RemovedContent? _removedWorking, _removedSaved; + public string Id { get; } = Guid.NewGuid().ToString("N"); + public long RetainedBytes => _storedBytes + _removedBytes; + public IReadOnlyList References { get; private set; } = []; + public string TargetLabel => _request.Target.Length > 0 ? _request.Target : string.Join(", ", _request.Sources); + public ProjectFileAction Action => _request.Action; + public ProjectFileOperation(string root, ProjectFileRequest request, IEntryCatalog catalog, IResourceTypeCatalog? resourceTypes = null) + { + _reader = new(root); _request = request; _catalog = catalog; _types = resourceTypes ?? BuiltinResourceTypes.CreateCatalog(); _codec = new(_types); + _retained = _reader.Resolve(".galnet/editor/history/" + Id); + } + public async Task ApplyAsync(bool forward, Func, Task> publish) + { + ObjectDisposedException.ThrowIf(_disposed, this); + RemovedContent? removedWorking = null, removedSaved = null; + await publish((working, saved) => + { + if (!_initialized) Initialize(working, saved); + foreach (var file in working.Files.Values.Concat(saved.Files.Values).Where(file => file.ManagedContent)) + { + _managed.Add(file.Path); _managed.Add(Map(file.Path, true)); _managed.Add(Map(file.Path, false)); + } + if (forward && _request.Action == ProjectFileAction.Delete) + { + var removed = CaptureRemoved(working); + if (removed.Document.Graph.Nodes.Count > 0 && !_request.DeleteGroups) throw ProjectFilePaths.Error("referenced", string.Join(", ", removed.Document.Graph.Nodes.Select(node => node.Id))); + References = FindReferences(working); + if (References.Any(item => item.Certain || !_request.AllowCandidateReferences)) throw ProjectFilePaths.Error("referenced", string.Join(", ", References.Select(item => item.Document + "/" + item.Field))); + } + var current = forward ? _before : _after; + var desired = forward ? _after : _before; + var currentDirectories = forward ? _beforeDirectories : _afterDirectories; + var desiredDirectories = forward ? _afterDirectories : _beforeDirectories; + foreach (var path in _before.Keys.Concat(_after.Keys).Distinct(ProjectFilePaths.Comparer)) + { + if (_managed.Contains(path)) + { + if (!current.Keys.Contains(path, ProjectFilePaths.Comparer) && File.Exists(_reader.Resolve(path))) throw ProjectFilePaths.Error("changed", path); + continue; + } + var expected = current.FirstOrDefault(pair => ProjectFilePaths.Comparer.Equals(pair.Key, path)).Value; + if (AuthorFileTransaction.ReadHash(_reader.Resolve(path)) != expected?.Hash) throw ProjectFilePaths.Error("changed", path); + } + foreach (var path in _beforeDirectories.Concat(_afterDirectories).Distinct(ProjectFilePaths.Comparer)) + if (Directory.Exists(_reader.Resolve(path)) != currentDirectories.Contains(path, ProjectFilePaths.Comparer)) throw ProjectFilePaths.Error("changed", path); + foreach (var root in _roots.Where(root => currentDirectories.Any(path => ProjectFilePaths.Comparer.Equals(path, root)))) + { + var actual = Enumerate(_reader.Resolve(root)).Files.Select(_reader.Relative).ToHashSet(ProjectFilePaths.Comparer); + var expected = current.Keys.Where(path => ProjectFilePaths.Within(path, root)).ToHashSet(ProjectFilePaths.Comparer); + if (!actual.SetEquals(expected)) throw ProjectFilePaths.Error("changed", root); + } + ValidateVariants(current, desired, currentDirectories, desiredDirectories); + var files = new ProjectFilePlan(); + foreach (var pair in current.Where(pair => !_managed.Contains(pair.Key))) files.ExpectedHashes[pair.Key] = pair.Value.Hash; + foreach (var path in desired.Keys.Where(path => !current.Keys.Contains(path, ProjectFilePaths.Comparer))) files.ExpectedHashes[path] = null; + if (_caseOnly) + { + foreach (var (from, to) in _moves) files.CaseRenames.Add(forward ? new(from, to) : new(to, from)); + foreach (var pair in desired.Where(pair => pair.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase) + || !current.TryGetValue(pair.Key, out var old) || old.Hash != pair.Value.Hash)) files.Writes[pair.Key] = File.ReadAllBytes(pair.Value.Backup); + } + else + { + foreach (var pair in desired.Where(pair => !_managed.Contains(pair.Key) && (!current.TryGetValue(pair.Key, out var original) || original.Hash != pair.Value.Hash))) files.Copies[pair.Key] = pair.Value.Backup; + files.Deletes.AddRange(current.Keys.Where(path => !desired.ContainsKey(path) && !_managed.Contains(path))); + files.CreateDirectories.AddRange(desiredDirectories); + files.DeleteDirectories.AddRange(currentDirectories.Where(path => !desiredDirectories.Contains(path))); + } + if (_request.Action == ProjectFileAction.Delete && forward) + { + removedWorking = CaptureRemoved(working); removedSaved = CaptureRemoved(saved); + } + return new FileChange(this, forward, files, removedWorking ?? _removedWorking, removedSaved ?? _removedSaved); + }); + if (removedWorking is not null) { _removedWorking = removedWorking; _removedSaved = removedSaved; _removedBytes = JsonSerializer.SerializeToUtf8Bytes(new[] { removedWorking, removedSaved }).LongLength; } + } + + public ProjectFileImpact Analyze(EditorProjectDocument working, EditorProjectDocument saved) + { + if (!_initialized) Initialize(working, saved); + return new(CaptureRemoved(working).Document.Graph.Nodes.Select(node => node.Id).ToArray(), References); + } + + private void Initialize(EditorProjectDocument working, EditorProjectDocument saved) + { + Directory.CreateDirectory(_retained); + _lease = new FileStream(Path.Combine(_retained, "owner.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + _managed = working.Graph.Nodes.Concat(saved.Graph.Nodes).Where(node => node.Type == "Group").Select(node => "Graph/" + node.File).ToHashSet(ProjectFilePaths.Comparer); + var sources = _request.Action == ProjectFileAction.Import ? _request.Sources.Select(path => Path.GetFullPath(path).Replace(Path.DirectorySeparatorChar, '/')).Distinct(ProjectFilePaths.Comparer).ToArray() + : _request.Sources.Select(ProjectFilePaths.Editable).Distinct(ProjectFilePaths.Comparer).OrderBy(path => path.Length).ToArray(); + sources = sources.Where(path => !sources.Any(parent => parent != path && ProjectFilePaths.Within(path, parent))).ToArray(); + if (_request.Action is ProjectFileAction.CreateFile or ProjectFileAction.CreateDirectory) + { + var target = ProjectFilePaths.Editable(_request.Target); EnsureFree(target); + if (_request.Action == ProjectFileAction.CreateDirectory) _afterDirectories.Add(target); + else { _after[target] = Store(_request.Content ?? []); AddMetadata(target, null, true); } + } + else foreach (var source in sources) + { + var full = _request.Action == ProjectFileAction.Import ? source : _reader.Resolve(source); + if (!File.Exists(full) && !Directory.Exists(full)) throw ProjectFilePaths.Error("missing", source); + if ((File.GetAttributes(full) & FileAttributes.ReparsePoint) != 0) throw ProjectFilePaths.Error("path", source); + if (_request.Action == ProjectFileAction.Metadata && Directory.Exists(full)) throw ProjectFilePaths.Error("category", source); + string? target = _request.Action is ProjectFileAction.Delete or ProjectFileAction.Metadata ? null + : ProjectFilePaths.Editable(_request.TargetIsDirectory ? (_request.Target.Length == 0 ? "" : _request.Target.TrimEnd('/') + "/") + Path.GetFileName(source) : _request.Target); + if (target is not null) + { + if (_request.UniqueNames && _request.Action is ProjectFileAction.Copy or ProjectFileAction.Import) + { + var original = target; var suffix = 1; + while (_targets.Contains(target) || File.Exists(_reader.Resolve(target)) || Directory.Exists(_reader.Resolve(target))) + { + var extension = File.Exists(full) ? Path.GetExtension(original) : ""; + target = original[..(original.Length - extension.Length)] + " " + suffix++ + extension; + } + } + var parent = Path.GetDirectoryName(target)?.Replace(Path.DirectorySeparatorChar, '/') ?? ""; + if (!Directory.Exists(_reader.Resolve(parent))) throw ProjectFilePaths.Error("missing", parent); + if (_request.Action != ProjectFileAction.Import && ProjectFilePaths.Category(source) != ProjectFilePaths.Category(target)) throw ProjectFilePaths.Error("category", target); + if (_request.Action != ProjectFileAction.Import && ProjectFilePaths.Within(target, source) && !ProjectFilePaths.Comparer.Equals(source, target)) throw ProjectFilePaths.Error("path", target); + var caseRename = OperatingSystem.IsWindows() && source != target && ProjectFilePaths.Comparer.Equals(source, target); + if (!_targets.Add(target)) throw ProjectFilePaths.Error("collision", target); + if (!caseRename) EnsureFree(target); + if (caseRename && (_request.Action != ProjectFileAction.Move || sources.Length != 1)) throw ProjectFilePaths.Error("path", source); + _caseOnly = caseRename; + if (_request.Action == ProjectFileAction.Move) _moves.Add((source, target)); + if (_request.Action == ProjectFileAction.Copy) _variantCopies.Add((source, target)); + } + var tree = Enumerate(full); + if (target is not null && tree.Directories.Count > 0) _roots.Add(target); + var consume = _request.Action is ProjectFileAction.Move or ProjectFileAction.Delete or ProjectFileAction.Metadata; + if (consume) _roots.Add(source); + foreach (var directory in tree.Directories) + { + var relative = _reader.Relative(directory); + if (consume) _beforeDirectories.Add(relative); + if (target is not null) _afterDirectories.Add(Relocate(directory, full, target)); + } + foreach (var file in tree.Files) + { + var relative = _reader.Relative(file); + if (consume) _before[relative] = SnapshotFile(file); + if (_request.Action == ProjectFileAction.Metadata) { _after[relative] = _before[relative]; continue; } + if (target is not null) _after[Relocate(file, full, target)] = SnapshotFile(file); + } + if (File.Exists(full) && source.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && File.Exists(full + ".meta")) + { + if (consume) _before[source + ".meta"] = SnapshotFile(full + ".meta"); + if (_caseOnly) _moves.Add((source + ".meta", target! + ".meta")); + } + foreach (var file in tree.Files.Where(file => !file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + { + var destination = target is null ? source : Relocate(file, full, target); + if (_request.Action != ProjectFileAction.Delete) AddMetadata(destination, file, _request.Action is ProjectFileAction.Copy or ProjectFileAction.Import or ProjectFileAction.CreateFile); + } + } + if (sources.Length == 0 && _request.Action is not (ProjectFileAction.CreateFile or ProjectFileAction.CreateDirectory)) throw ProjectFilePaths.Error("missing", "selection"); + foreach (var node in working.Graph.Nodes.Where(node => node.Type == "Group" && _roots.Any(root => ProjectFilePaths.Within("Graph/" + node.File, root)))) + { + if (_request.Action == ProjectFileAction.Move) + { + var path = Map("Graph/" + node.File, true); + if (!path.EndsWith(".rawgalgroup", StringComparison.OrdinalIgnoreCase)) throw ProjectFilePaths.Error("category", path); + _managed.Add(path); + } + } + if (_request.Action == ProjectFileAction.Delete) + { + References = FindReferences(working); + } + // A copied sidecar is owned by its asset; reject collisions before publication. + foreach (var path in _after.Keys.Where(path => !_before.ContainsKey(path) && !_caseOnly)) EnsureFree(path); + foreach (var path in _after.Keys.Concat(_afterDirectories.ToArray()).ToArray()) + { + var parent = Path.GetDirectoryName(path)?.Replace(Path.DirectorySeparatorChar, '/'); + while (!string.IsNullOrEmpty(parent) && !Directory.Exists(_reader.Resolve(parent))) { _afterDirectories.Add(parent); parent = Path.GetDirectoryName(parent)?.Replace(Path.DirectorySeparatorChar, '/'); } + } + var ids = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pair in _after.Where(pair => pair.Key.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && pair.Key.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + if (!ids.Add(_codec.Deserialize(File.ReadAllText(pair.Value.Backup), pair.Key[7..^5]).Id)) throw ProjectFilePaths.Error("guidCollision", pair.Key); + if (ids.Count > 0 && Directory.Exists(_reader.Resolve("Assets"))) + foreach (var path in Enumerate(_reader.Resolve("Assets")).Files.Where(path => path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + { + var relative = _reader.Relative(path); + if (_before.Keys.Contains(relative, ProjectFilePaths.Comparer) || _after.Keys.Contains(relative, ProjectFilePaths.Comparer)) continue; + var id = _codec.Deserialize(File.ReadAllText(path), relative[7..^5]).Id; + if (ids.Contains(id)) throw ProjectFilePaths.Error("guidCollision", relative); + } + PrepareVariantRelations(); + _initialized = true; + } + + private IReadOnlyList FindReferences(EditorProjectDocument document) + { + var values = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var path in _before.Keys.Where(path => !path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + { + values.Add(path); if (path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) values.Add(path[7..]); + if (_before.TryGetValue(path + ".meta", out var meta)) values.Add(_codec.Deserialize(File.ReadAllText(meta.Backup), path[7..]).Id); + } + var references = new List(); + foreach (var pair in document.GroupEntries) + { + if (_request.DeleteGroups && document.Graph.Nodes.Any(node => node.Id == pair.Key && _roots.Any(root => ProjectFilePaths.Within("Graph/" + node.File, root)))) continue; + foreach (var entry in pair.Value) + foreach (var parameter in entry.Parameters) + { + var schema = _catalog.Definitions.FirstOrDefault(item => item.Type == entry.Type); + var editor = schema?.DynamicParameters?.GetValueOrDefault(parameter.Key)?.Editor?.EditorId; + foreach (var text in Strings(parameter.Value)) + if (values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase))) references.Add(new("group/" + pair.Key + "/entry/" + entry.StableId, parameter.Key, values.Contains(text) && editor?.Contains("asset", StringComparison.OrdinalIgnoreCase) == true)); + } + } + if (Strings(JsonSerializer.SerializeToElement(document.Settings)).Any(text => values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase)))) + references.Add(new("settings.json", "custom", false)); + foreach (var file in document.Files.Values.Where(file => file.Exists && file.ManagedContent && !_roots.Any(root => ProjectFilePaths.Within(file.Path, root)))) + { + using var stream = new MemoryStream(file.Data, writable: false); + using var reader = new StreamReader(stream, new System.Text.UTF8Encoding(false, true), detectEncodingFromByteOrderMarks: true); + var text = reader.ReadToEnd(); + if (values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase))) references.Add(new(file.Path, "custom", false)); + } + foreach (var file in new[] { "UI/ui.json" }.Where(path => File.Exists(_reader.Resolve(path)) && !document.Files.Values.Any(file => file.Exists && file.ManagedContent && ProjectFilePaths.Comparer.Equals(file.Path, path)))) + { + var text = File.ReadAllText(_reader.Resolve(file)); + if (values.Any(value => text.Contains(value, StringComparison.OrdinalIgnoreCase))) references.Add(new(file, "custom", false)); + } + return references.Distinct().ToArray(); + } + private static IEnumerable Strings(JsonElement value) => value.ValueKind switch + { + JsonValueKind.String => [value.GetString()!], + JsonValueKind.Array => value.EnumerateArray().SelectMany(Strings), + JsonValueKind.Object => value.EnumerateObject().SelectMany(item => Strings(item.Value)), + _ => [] + }; + private void AddMetadata(string destination, string? original, bool newId) + { + if (!destination.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) || destination.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return; + AssetMeta meta; + if (original is not null && File.Exists(original + ".meta")) + { + var json = File.ReadAllText(original + ".meta"); + meta = _codec.Deserialize(json, destination[7..]); + // Readers may tolerate malformed optional fields; an authoring operation must not discard them. + try { _ = JsonSerializer.Deserialize(json, _types.Get(meta.TypeId).MetaDtoType, new JsonSerializerOptions(JsonSerializerDefaults.Web)); } + catch (JsonException error) { throw new InvalidDataException($"Invalid asset metadata: '{original}.meta'.", error); } + } + else + { + var type = _types.TryGetByExtension(Path.GetExtension(destination), out var registration) ? registration : _types.Get("data"); + meta = (AssetMeta)Activator.CreateInstance(type.MetaDtoType)!; meta.TypeId = type.TypeId; meta.Id = Guid.NewGuid().ToString("N"); + } + if (newId) meta.Id = Guid.NewGuid().ToString("N"); + if (newId && meta.Gallery is { Count: > 0 }) + { + if (_galleryIds is null) + { + _galleryIds = []; + if (Directory.Exists(_reader.Resolve("Assets"))) + foreach (var path in Enumerate(_reader.Resolve("Assets")).Files.Where(path => path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) + foreach (var item in _codec.Deserialize(File.ReadAllText(path), _reader.Relative(path)[7..^5]).Gallery ?? []) _galleryIds.Add(item.Id); + } + meta.Gallery = meta.Gallery.Select(item => + { + if (item.Id <= 0) throw ProjectFilePaths.Error("galleryId", destination); + if (_galleryIds.Add(item.Id)) return item; + var id = 1; while (_galleryIds.Contains(id)) id = checked(id + 1); + _galleryIds.Add(id); return item with { Id = id }; + }).ToList(); + } + meta.Path = destination[7..]; + if (_request.Action == ProjectFileAction.Metadata) { if (meta is SpriteAssetMeta sprite && _request.Filter is not null) sprite.Filter = _request.Filter; if (_request.Compression is not null) meta.Compress = _request.Compression; } + _after[destination + ".meta"] = Store(System.Text.Encoding.UTF8.GetBytes(_codec.Serialize(meta))); + if (_request.Action == ProjectFileAction.Metadata && original is not null && File.Exists(original + ".meta")) _before[destination + ".meta"] = SnapshotFile(original + ".meta"); + } + private static string Relocate(string file, string source, string target) => file == source ? target : target + "/" + Path.GetRelativePath(source, file).Replace('\\', '/'); + private void EnsureFree(string path) { if (File.Exists(_reader.Resolve(path)) || Directory.Exists(_reader.Resolve(path))) throw ProjectFilePaths.Error("collision", path); } + private string Map(string path, bool forward) + { + foreach (var (from, to) in _moves) + { + var source = forward ? from : to; var target = forward ? to : from; + if (ProjectFilePaths.Within(path, source)) return target + path[source.Length..]; + } + return path; + } + private Snapshot SnapshotFile(string file) + { + var destination = Path.Combine(_retained, Guid.NewGuid().ToString("N")); File.Copy(file, destination); + var length = new FileInfo(destination).Length; _storedBytes += length; + return new(destination, AuthorFileTransaction.ReadHash(destination)!, length); + } + private Snapshot Store(byte[] content) { var path = Path.Combine(_retained, Guid.NewGuid().ToString("N")); File.WriteAllBytes(path, content); _storedBytes += content.LongLength; return new(path, AuthorFileTransaction.ReadHash(path)!, content.LongLength); } + private sealed record Snapshot(string Backup, string Hash, long Length); + private static (List Files, List Directories) Enumerate(string full) + { + if (File.Exists(full)) return ([full], []); + var files = new List(); var directories = new List { full }; + for (var i = 0; i < directories.Count; i++) foreach (var path in Directory.EnumerateFileSystemEntries(directories[i])) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) throw ProjectFilePaths.Error("path", path); + if (Directory.Exists(path)) directories.Add(path); else files.Add(path); + } + return (files, directories); + } + private RemovedContent CaptureRemoved(EditorProjectDocument document) + { + var nodes = document.Graph.Nodes.Where(node => node.Type == "Group" && _roots.Any(root => ProjectFilePaths.Within("Graph/" + node.File, root))).ToArray(); + var ids = nodes.Select(node => node.Id).ToHashSet(); + return new(Clone(new EditorProjectDocument { Graph = new() { Nodes = nodes.ToList(), Edges = document.Graph.Edges.Where(edge => ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId)).ToList() }, GroupEntries = document.GroupEntries.Where(pair => ids.Contains(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value), Files = document.Files.Where(pair => _roots.Any(root => ProjectFilePaths.Within(pair.Value.Path, root))).ToDictionary(pair => pair.Key, pair => pair.Value) }), + document.Graph.Nodes.Select((node, index) => (node.Id, index)).ToDictionary(item => item.Id, item => item.index), + document.Graph.Edges.Select((edge, index) => (edge.Id, index)).ToDictionary(item => item.Id, item => item.index)); + } + private sealed record RemovedContent(EditorProjectDocument Document, Dictionary Nodes, Dictionary Edges); + private static T Clone(T value) => JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(value))!; + private sealed record FileChange(ProjectFileOperation Owner, bool Forward, ProjectFilePlan Files, RemovedContent? Working, RemovedContent? Saved) : IProjectFileStructureChange + { + public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) + { + var removed = version == AuthorDocumentVersion.Working ? Working : Saved; + foreach (var file in document.Files.Values) + { + if (Owner.Action == ProjectFileAction.Move) file.Path = Owner.Map(file.Path, Forward); + if (Owner.Action == ProjectFileAction.Delete && Owner._roots.Any(root => ProjectFilePaths.Within(file.Path, root))) + { + file.Exists = !Forward; + if (!Forward && removed?.Document.Files.TryGetValue(file.Id, out var previous) == true) file.Data = previous.Data.ToArray(); + } + } + if (Owner.Action == ProjectFileAction.Move) + foreach (var node in document.Graph.Nodes.Where(node => node.Type == "Group")) node.File = Owner.Map("Graph/" + node.File, Forward)[6..]; + if (Owner.Action != ProjectFileAction.Delete) return; + if (removed is null) return; + var ids = removed.Document.Graph.Nodes.Select(node => node.Id).ToHashSet(); + if (Forward) + { + document.Graph.Nodes.RemoveAll(node => ids.Contains(node.Id)); document.Graph.Edges.RemoveAll(edge => ids.Contains(edge.FromNodeId) || ids.Contains(edge.ToNodeId)); + foreach (var id in ids) document.GroupEntries.Remove(id); + } + else + { + if (document.Graph.Nodes.Any(node => ids.Contains(node.Id))) throw ProjectFilePaths.Error("changed", "Graph"); + var copy = Clone(removed.Document); + foreach (var node in copy.Graph.Nodes.OrderBy(node => removed.Nodes[node.Id])) document.Graph.Nodes.Insert(Math.Min(removed.Nodes[node.Id], document.Graph.Nodes.Count), node); + foreach (var edge in copy.Graph.Edges.OrderBy(edge => removed.Edges[edge.Id])) document.Graph.Edges.Insert(Math.Min(removed.Edges[edge.Id], document.Graph.Edges.Count), edge); + foreach (var pair in copy.GroupEntries) document.GroupEntries.Add(pair.Key, pair.Value); + } + } + } + public static void CleanupOrphans(string projectPath) + { + var reader = new ProjectRawReader(projectPath); + var root = reader.Resolve(".galnet/editor/history"); + if (!Directory.Exists(root)) return; + foreach (var candidate in Directory.EnumerateDirectories(root)) + { + var directory = reader.Resolve(reader.Relative(candidate)); + var owner = reader.Resolve(reader.Relative(Path.Combine(directory, "owner.lock"))); + if (!File.Exists(owner) && Directory.GetCreationTimeUtc(directory) > DateTime.UtcNow.AddMinutes(-1)) continue; + try + { + using (new FileStream(owner, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None)) { } + Directory.Delete(directory, true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + } + public void Dispose() + { + if (_disposed) return; _disposed = true; + var path = _reader.Resolve(".galnet/editor/history/" + Id); + if (path != _retained) throw new InvalidOperationException("Invalid retained-file root."); + _lease?.Dispose(); + if (Directory.Exists(path)) Directory.Delete(path, true); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectLocalizationQuery.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectLocalizationQuery.cs new file mode 100644 index 00000000..1ce00b07 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectLocalizationQuery.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Editor.Abstraction.Commands; +using GalNet.Editor.Abstraction.Services; + +namespace GalNet.Editor.Shared.Services; + +/// Project-scoped localization snapshots, including untracked disk sources and unsaved managed files. +public sealed class ProjectLocalizationQuery(IProjectContentSession content, IEditorDocumentService documents, + IEntryCatalog catalog, IProjectService projects) : IDisposable +{ + private bool _disposed; + private string? _snapshot; + private LocalizationReferenceIndex? _index; + public long Revision { get; private set; } + + public LocalizationReferenceIndex? Get() + { + if (_disposed || !documents.IsLoaded || projects.Current is null) + { Clear(); return null; } + var document = content.CaptureWorking(); + var sources = new Dictionary(ProjectFilePaths.Comparer); + var issues = new List(); + var reader = new ProjectRawReader(projects.Current.RootPath); + var diagnostics = new List(); + // Managed documents override disk, including deletion and path changes. + var managed = document.Files.Values.GroupBy(file => file.Path, ProjectFilePaths.Comparer).ToDictionary(group => group.Key, group => + { + var live = group.Where(file => file.Exists).ToArray(); + if (live.Length > 1) issues.Add(new("source.identity", group.Key, "", "Multiple live documents own the same path.")); + return live.FirstOrDefault() ?? group.First(); + }, ProjectFilePaths.Comparer); + IReadOnlyList paths = []; + try { if (Directory.Exists(reader.Resolve("I18n"))) paths = reader.Files("I18n", diagnostics); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidDataException) + { issues.Add(new("source.enumeration", "I18n", "", error.Message)); } + foreach (var path in paths.Where(IsSource)) + { + if (managed.ContainsKey(path)) continue; + try { sources[path] = new(path, File.ReadAllText(reader.Resolve(path))); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidDataException) + { issues.Add(new("source.read", path, "", error.Message)); } + } + foreach (var file in managed.Values.Where(file => file.Exists && IsSource(file.Path))) + { + if (!file.ManagedContent) + { + try { sources[file.Path] = new(file.Path, File.ReadAllText(reader.Resolve(file.Path))); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidDataException) + { issues.Add(new("source.read", file.Path, "", error.Message)); } + } + else + { + using var stream = new MemoryStream(file.Data, writable: false); + using var text = new StreamReader(stream, detectEncodingFromByteOrderMarks: true); + sources[file.Path] = new(file.Path, text.ReadToEnd()); + } + } + issues.AddRange(diagnostics.Where(diagnostic => diagnostic.File is null || + !diagnostic.File.StartsWith("I18n/External", StringComparison.OrdinalIgnoreCase)) + .Select(diagnostic => new LocalizationIssue("source.enumeration", diagnostic.File ?? "I18n", diagnostic.JsonPointer ?? "", diagnostic.Message))); + var snapshot = JsonSerializer.Serialize(new { Document = document, Sources = sources.Values.OrderBy(source => source.Path, StringComparer.Ordinal), Issues = issues }); + if (snapshot != _snapshot) + { + _index = new(document, catalog, sources.Values, issues); + _snapshot = snapshot; Revision++; + } + return _index; + } + + private static bool IsSource(string path) => path.StartsWith("I18n/", StringComparison.Ordinal) && + !path.StartsWith("I18n/External/", StringComparison.OrdinalIgnoreCase) && path.EndsWith(".json", StringComparison.OrdinalIgnoreCase); + private void Clear() + { + if (_snapshot is not null) Revision++; + _snapshot = null; _index = null; + } + public void Dispose() + { + if (_disposed) return; + Clear(); _disposed = true; Revision++; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectSchemaQueryService.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectSchemaQueryService.cs index f5079ff3..78d5f2a0 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectSchemaQueryService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectSchemaQueryService.cs @@ -28,7 +28,7 @@ public static ToolResult Describe(ProjectSchemaContext context, string? type = n catch (Exception exception) when (exception is NotSupportedException or InvalidOperationException) { diagnostics.Add(new("schema.descriptionLimited", "warning", $"Cannot fully describe '{definition.Type}.{parameter.Name}': {exception.Message}", context.AssemblyPath, definition.Type)); } return new SchemaParameterDescription(parameter.Name, parameter.IsRequired, parameter.DefaultValue, parameter.Constraints, - valueSchema, valueSchema is not null, parameter.Editor); + valueSchema, valueSchema is not null, parameter.Editor, parameter.Field.Semantic.ToString()); }).ToArray() ?? []; return new SchemaEntryDescription(definition.Type, definition.Category, definition.Kind.ToString().ToLowerInvariant(), definition.IsReading, parameters, "Schema descriptions do not serialize custom C# validators or Composite expansion code; full validation/compilation uses the DLL."); @@ -40,5 +40,5 @@ public static ToolResult Describe(ProjectSchemaContext context, string? type = n public sealed record SchemaEntryDescription(string TypeId, string Category, string Kind, bool IsReading, IReadOnlyList Parameters, string ValidationNote); public sealed record SchemaParameterDescription(string Name, bool Required, JsonElement? Default, - JsonElement? Constraints, JsonElement? ValueSchema, bool DescriptionSupported, EditorHints? Editor); + JsonElement? Constraints, JsonElement? ValueSchema, bool DescriptionSupported, EditorHints? Editor, string Semantic); } diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectService.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectService.cs index 979579af..25260e3b 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectService.cs @@ -117,7 +117,7 @@ private async Task CloseRequestedAsync() { foreach (var participant in participants) await participant.SuspendAsync(); if (content is not null) await content.SuspendAsync(); - while (content?.IsDirty ?? project.IsDirty) + while (content?.IsDirty == true) { var choice = _globalServices.GetService() is { } prompt ? await prompt.ChooseAsync(project.Name) : ProjectCloseChoice.Cancel; @@ -198,7 +198,7 @@ public void RemoveRecentProject(string projectPath) public Task CheckUnsavedChangesAsync() { - return Task.FromResult(_current?.Services.GetService()?.IsDirty ?? _current?.IsDirty == true); + return Task.FromResult(_current is { } project && project.Services.GetRequiredService().IsDirty); } private void AddToRecentProjects(string name, string projectPath) diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectValidationService.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectValidationService.cs index a737b284..18a01f57 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectValidationService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectValidationService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using GalNet.Core.Assets; using GalNet.Core.Compilation; using GalNet.Core.Entry; using GalNet.Core.Serialization; @@ -10,7 +11,7 @@ namespace GalNet.Editor.Shared.Services; /// Validates author files without repository repair, migration or persistence. -public sealed class ProjectValidationService(string projectDirectory, string graphFile = "Graph/graph.json") +public sealed class ProjectValidationService(string projectDirectory, string graphFile = "Graph/graph.json", IResourceTypeCatalog? resourceTypes = null) { private readonly string GraphFile = graphFile.Replace('\\', '/'); private readonly ProjectRawReader _reader = new(projectDirectory); @@ -24,6 +25,7 @@ public ToolResult Validate(string scope = "project", GroupSelector? selector = n var diagnostics = new List(); var files = new List(); var groups = new List(); + if (scope == "project") diagnostics.AddRange(ImageVariantsValidation.Validate(_reader.Root, resourceTypes ?? BuiltinResourceTypes.CreateCatalog())); if (scope == "group") { var file = new ProjectQueryService(_reader.Root).ResolveGroup(selector ?? throw new ArgumentException("Group selector required."), diagnostics); @@ -73,6 +75,12 @@ public ToolResult Validate(string scope = "project", GroupSelector? selector = n files.Add(file); ValidateGroup(file, diagnostics, structureOnly ? null : catalog, structureOnly); } + if (!structureOnly && scope == "project" && catalog is not null) + { + diagnostics.AddRange(LocalizationValidation.ValidateDisk(_reader.Root, catalog)); + if (Directory.Exists(_reader.Resolve("I18n"))) files.AddRange(_reader.Files("I18n", diagnostics).Where(file => file.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && !file.StartsWith("I18n/External/", StringComparison.OrdinalIgnoreCase))); + if (File.Exists(_reader.Resolve(LocalizationJson.MetadataPath))) files.Add(LocalizationJson.MetadataPath); + } } finally { context?.Dispose(); } @@ -80,15 +88,17 @@ public ToolResult Validate(string scope = "project", GroupSelector? selector = n var checks = new List { "raw-json", "duplicate-properties", "current-format", "stable-ids" }; if (scope != "group") checks.AddRange(["graph-topology", "variable-definitions"]); if (scope != "group" && !structureOnly) checks.Add("graph-source-references"); - if (scope == "project") checks.AddRange(["settings-json", "metadata-guid-and-adjacent-file"]); + if (scope == "project") checks.AddRange(["settings-json", "metadata-guid-and-adjacent-file", "image-variants"]); if (!structureOnly) checks.Add("condition-syntax"); if (!structureOnly && scope != "graph" && catalog is not null) checks.AddRange(["entry-parameters-and-contract-validator", "composite-compilation"]); - var skipped = new List { "runtime-expression-evaluation", "custom-code-references", "asset-payloads", "settings-semantics", "localization-semantics" }; + var skipped = new List { "runtime-expression-evaluation", "custom-code-references", "asset-payloads", "settings-semantics" }; + if (!structureOnly && scope == "project" && catalog is not null) checks.Add("localization-semantics"); + else skipped.Add("localization-semantics"); if (structureOnly) skipped.AddRange(["condition-syntax", "entry-contracts", "graph-source-references"]); else if (scope != "graph" && catalog is null) skipped.Add("entry-contracts"); return new(scope + ".validate", !diagnostics.Any(item => item.Severity == "error"), new ProjectValidationReport(scope, structureOnly, semantic, files.Distinct().Order(StringComparer.Ordinal).ToArray(), checks, skipped, - semantic != "unavailable" && !diagnostics.Any(item => item.Code is "file.read" or "directory.read" or "file.linkSkipped" or "json.invalid" or "json.objectRequired" or "graph.shapeInvalid"), contractSha256), diagnostics); + semantic != "unavailable" && !diagnostics.Any(item => item.Code is "file.read" or "directory.read" or "file.linkSkipped" or "json.invalid" or "json.objectRequired" or "graph.shapeInvalid" or "localization.incomplete" or "localization.invalid"), contractSha256), diagnostics); } /// Checks a selected source and returns a DTO only after its raw structure is accepted. @@ -117,7 +127,7 @@ public ToolResult Validate(string scope = "project", GroupSelector? selector = n if (!entry.TryGetProperty("parameters", out var parameters) || !Object(parameters, file, pointer + "/parameters", diagnostics)) { Error(diagnostics, "entry.parametersRequired", "Entry parameters must be an object.", file, pointer + "/parameters"); continue; } if (!structureOnly) Condition(ProjectRawReader.Text(entry, "condition"), file, pointer + "/condition", diagnostics); - if (catalog is null) continue; + if (structureOnly || catalog is null) continue; var type = ProjectRawReader.Text(entry, "type"); if (type is null || !catalog.TryGet(type, out var definition)) { Error(diagnostics, "entry.typeUnknown", $"Entry '{type}' is not declared by the project contract.", file, pointer + "/type", ProjectRawReader.Text(entry, "id")); continue; } @@ -131,14 +141,14 @@ public ToolResult Validate(string scope = "project", GroupSelector? selector = n } if (diagnostics.Skip(start).Any(item => item.Severity == "error")) return null; var document = JsonSerializer.Deserialize(raw, JsonOptions)!; - if (catalog is not null) + if (!structureOnly && catalog is not null) foreach (var (entry, index) in document.Entries.Select((entry, index) => (entry, index))) try { _ = GalgroupCompiler.Compile(new GroupDocument { Entries = [entry] }, catalog); } catch (Exception exception) { Error(diagnostics, "entry.compileInvalid", exception.Message, file, $"/entries/{index}", entry.Id); } return diagnostics.Skip(start).Any(item => item.Severity == "error") ? null : document; } - public void ValidateGraph(JsonElement? content, List diagnostics, bool structureOnly = false, bool checkSourceReferences = true) + public void ValidateGraph(JsonElement? content, List diagnostics, bool structureOnly = false, bool checkSourceReferences = true, bool validateConditionSyntax = true) { if (content is null || !Shape(content.Value, GraphFile, "", diagnostics, ["version", "name", "rootNodeId", "nodes", "edges", "playerVariables", "saveVariables"])) return; var graph = content.Value; @@ -196,9 +206,9 @@ public void ValidateGraph(JsonElement? content, List diagnostics var child = pointer + $"/{key}/{n++}"; if (!Shape(item, GraphFile, child, diagnostics, key == "options" ? ["id", "text", "condition"] : ["id", "expression"])) continue; Identity(item, "id", key == "options" ? options : conditions, GraphFile, child, diagnostics); - String(item, key == "options" ? "text" : "expression", GraphFile, child, diagnostics, required: true, allowEmpty: key == "options"); + String(item, key == "options" ? "text" : "expression", GraphFile, child, diagnostics, required: true, allowEmpty: key == "options" || !validateConditionSyntax); if (key == "options") String(item, "condition", GraphFile, child, diagnostics); - if (!structureOnly) Condition(ProjectRawReader.Text(item, key == "options" ? "condition" : "expression"), GraphFile, child, diagnostics); + if (!structureOnly && validateConditionSyntax) Condition(ProjectRawReader.Text(item, key == "options" ? "condition" : "expression"), GraphFile, child, diagnostics); } } } @@ -220,7 +230,8 @@ public void ValidateGraph(JsonElement? content, List diagnostics for (var i = 0; i < variables.Count; i++) { var variable = variables[i]; var pointer = $"/{key}/{i}"; - if (!Shape(variable, GraphFile, pointer, diagnostics, ["name", "defaultValue", "type"])) continue; + if (!Shape(variable, GraphFile, pointer, diagnostics, ["name", "defaultValue", "type", "description"])) continue; + String(variable, "description", GraphFile, pointer, diagnostics); Identity(variable, "name", variableNames, GraphFile, pointer, diagnostics); if (!variable.TryGetProperty("defaultValue", out var value) || !Shape(value, GraphFile, pointer + "/defaultValue", diagnostics, ["Uid", "Name", "Type", "Value"])) { Error(diagnostics, "variable.defaultRequired", "Typed defaultValue is required.", GraphFile, pointer); continue; } diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneAuthoringIdentities.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneAuthoringIdentities.cs new file mode 100644 index 00000000..8f122fa7 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneAuthoringIdentities.cs @@ -0,0 +1,81 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Contracts.Builtins; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +/// Explicit built-in identity slots. Never replaces unrelated author strings. +public static class SceneAuthoringIdentities +{ + public static Dictionary NewEntry(string type, Dictionary parameters) + { + if (CreationSlot(type) is { } slot) parameters[slot] = JsonSerializer.SerializeToElement(Guid.NewGuid().ToString("N")); + if (HasPlaybackSlot(type)) parameters["playbackHandleId"] = JsonSerializer.SerializeToElement(Guid.NewGuid().ToString("N")); + return parameters; + } + public static string? CreationSlot(string type) => type switch + { + BuiltinEntryIds.ShowLayer or BuiltinEntryIds.ShowColorLayer or BuiltinEntryIds.PlayVideo or BuiltinEntryIds.ShowVariants => "handleId", + BuiltinEntryIds.PlayParticleEmitter or BuiltinEntryIds.ApplyEffect => "instanceId", + BuiltinEntryIds.CrossFadeTransition or BuiltinEntryIds.BlindsTransition => "newHandleId", + BuiltinEntryIds.SlideTransition or BuiltinEntryIds.BlackFadeTransition or BuiltinEntryIds.WhiteFadeTransition or BuiltinEntryIds.ColorFadeTransition => "toLayerHandleId", + _ => null + }; + private static bool HasPlaybackSlot(string type) => type is BuiltinEntryIds.Animate + or BuiltinEntryIds.CrossFadeTransition or BuiltinEntryIds.BlindsTransition or BuiltinEntryIds.SlideTransition + or BuiltinEntryIds.BlackFadeTransition or BuiltinEntryIds.WhiteFadeTransition or BuiltinEntryIds.ColorFadeTransition; + public static bool IsOutput(string type, string field) => CreationSlot(type) == field || field == "playbackHandleId" && HasPlaybackSlot(type); + public static void RemapCopy(IReadOnlyList entries) + { + var roots = entries.Select(entry => (Entry: entry, Parameters: JsonSerializer.SerializeToNode(entry.Parameters)!.AsObject())).ToArray(); + var replacements = new Dictionary(StringComparer.Ordinal); + void Allocate(JsonObject obj, string slot) + { + if (obj[slot] is JsonValue value && value.TryGetValue(out var id) && !string.IsNullOrWhiteSpace(id)) replacements.TryAdd(id, Guid.NewGuid().ToString("N")); + } + void Discover(string type, JsonObject parameters) + { + if (CreationSlot(type) is { } slot) Allocate(parameters, slot); + if (HasPlaybackSlot(type)) Allocate(parameters, "playbackHandleId"); + if (type != BuiltinEntryIds.PlayTimeline || parameters["timeline"] is not JsonObject timeline) return; + foreach (var (key, _) in timeline.ToArray()) if (key.Equals("playbackHandleId", StringComparison.OrdinalIgnoreCase)) Allocate(timeline, key); + foreach (var (key, node) in timeline) if (key.Equals("events", StringComparison.OrdinalIgnoreCase) && node is JsonArray events) + foreach (var item in events.OfType()) if (item.FirstOrDefault(pair => pair.Key.Equals("type", StringComparison.OrdinalIgnoreCase)).Value is JsonValue eventType && eventType.TryGetValue(out var eventId) + && item.FirstOrDefault(pair => pair.Key.Equals("parameters", StringComparison.OrdinalIgnoreCase)).Value is JsonObject eventParameters) Discover(eventId, eventParameters); + } + void Replace(JsonObject obj, string slot) + { + if (obj[slot] is JsonValue value && value.TryGetValue(out var id) && replacements.TryGetValue(id, out var replacement)) obj[slot] = replacement; + } + void Rewrite(string type, JsonObject parameters) + { + if (CreationSlot(type) is { } slot) Replace(parameters, slot); + if (HasPlaybackSlot(type)) Replace(parameters, "playbackHandleId"); + foreach (var field in ReferenceSlots(type)) Replace(parameters, field); + if (type != BuiltinEntryIds.PlayTimeline || parameters["timeline"] is not JsonObject timeline) return; + foreach (var (key, node) in timeline.ToArray()) + { + if (key.Equals("playbackHandleId", StringComparison.OrdinalIgnoreCase)) Replace(timeline, key); + if (key.Equals("tracks", StringComparison.OrdinalIgnoreCase) && node is JsonArray tracks) + foreach (var track in tracks.OfType()) foreach (var field in track.Select(pair => pair.Key).Where(name => name.Equals("handleId", StringComparison.OrdinalIgnoreCase)).ToArray()) Replace(track, field); + if (key.Equals("events", StringComparison.OrdinalIgnoreCase) && node is JsonArray events) + foreach (var item in events.OfType()) if (item.FirstOrDefault(pair => pair.Key.Equals("type", StringComparison.OrdinalIgnoreCase)).Value is JsonValue eventType && eventType.TryGetValue(out var eventId) + && item.FirstOrDefault(pair => pair.Key.Equals("parameters", StringComparison.OrdinalIgnoreCase)).Value is JsonObject eventParameters) Rewrite(eventId, eventParameters); + } + } + foreach (var root in roots) Discover(root.Entry.Type, root.Parameters); + foreach (var root in roots) { Rewrite(root.Entry.Type, root.Parameters); root.Entry.Parameters = root.Parameters.ToDictionary(pair => pair.Key, pair => JsonSerializer.SerializeToElement(pair.Value)); } + } + private static IEnumerable ReferenceSlots(string type) => type switch + { + BuiltinEntryIds.Animate => ["handleId"], + BuiltinEntryIds.CrossFadeTransition or BuiltinEntryIds.BlindsTransition => ["oldHandleId"], + BuiltinEntryIds.SlideTransition or BuiltinEntryIds.BlackFadeTransition or BuiltinEntryIds.WhiteFadeTransition or BuiltinEntryIds.ColorFadeTransition => ["fromLayerHandleId"], + BuiltinEntryIds.StopAnimation => ["playbackHandleId"], + BuiltinEntryIds.HideLayer or BuiltinEntryIds.MoveLayer or BuiltinEntryIds.ReplaceLayer or BuiltinEntryIds.StopVideo or BuiltinEntryIds.SelectVariants => ["handleId"], + BuiltinEntryIds.StopEffect or BuiltinEntryIds.StopParticleEmitter => ["instanceId"], + BuiltinEntryIds.ApplyEffect => ["targetHandleId"], + _ => [] + }; +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Effects.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Effects.cs new file mode 100644 index 00000000..19646e30 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Effects.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using GalNet.Core.Scene; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class SceneTargetAnalysis +{ + private (List Properties, Dictionary Values, bool Complete) EffectCapabilities(EditorEntryData entry) + { + string Text(string key) => entry.Parameters.TryGetValue(key, out var json) ? json.GetString() ?? "" : ""; + var properties = new List { new("progress", AnimationValueKind.Float, 0, 1) }; + var values = new Dictionary { ["progress"] = 0 }; + var stage = string.IsNullOrWhiteSpace(Text("targetHandleId")) ? EffectStage.ScenePost : EffectStage.Layer; + IReadOnlyList declared; + var program = Text("program"); + if (!string.IsNullOrWhiteSpace(program)) + { + if (_programs is null) return (properties, values, true); // Explicit lightweight analysis without a resource snapshot. + if (!_programs.TryGetValue(program, out var descriptor) || descriptor is null || !descriptor.Supports(stage)) return (properties, values, false); + declared = ShaderEffectAnimationProperties.Create(descriptor); + } + else + { + if (_effects is null) return (properties, values, true); + if (!_effects.TryGet(Text("id"), out var definition) || definition.Stage != stage) return (properties, values, false); + declared = definition.AnimatableProperties; + } + var complete = true; + var parameters = entry.Parameters.GetValueOrDefault("parameters"); + var duplicateParameters = parameters.ValueKind == JsonValueKind.Object && parameters.EnumerateObject().GroupBy(property => property.Name, StringComparer.Ordinal).Any(group => group.Count() > 1); + if (duplicateParameters) complete = false; + foreach (var property in declared) + { + if (property.ValueKind != AnimationValueKind.Float || string.IsNullOrWhiteSpace(property.Name) + || property.Minimum is { } min && !float.IsFinite(min) || property.Maximum is { } max && !float.IsFinite(max) + || property.Minimum > property.Maximum) { complete = false; continue; } + if (property.Name == "progress") + { + // Core supplies progress=0 in EffectRequest.AnimationValues, ahead of static/default + // parameters. Restrict writes to values accepted by both Core and the presenter. + var minimum = Math.Max(0, property.Minimum ?? 0); + var maximum = Math.Min(1, property.Maximum ?? 1); + properties.RemoveAll(existing => existing.Name == "progress"); + if (minimum <= maximum) properties.Add(new("progress", AnimationValueKind.Float, minimum, maximum)); + if (minimum > maximum || !property.Accepts(0)) { values.Remove("progress"); complete = false; } + continue; + } + if (properties.Any(existing => existing.Name == property.Name)) { complete = false; continue; } + properties.Add(property); + if (duplicateParameters) continue; + var value = property.DefaultValue ?? 0; + if (parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty(property.Name, out var supplied)) + { + if (supplied.ValueKind != JsonValueKind.Number || !supplied.TryGetSingle(out value) || !float.IsFinite(value)) { complete = false; continue; } + } + value = Math.Clamp(value, property.Minimum ?? float.NegativeInfinity, property.Maximum ?? float.PositiveInfinity); + if (property.Accepts(value)) values[property.Name] = value; else complete = false; + } + return (properties, values, complete); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Playbacks.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Playbacks.cs new file mode 100644 index 00000000..b03bd762 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Playbacks.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using GalNet.Contracts.Builtins; +using GalNet.Core.Scene; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class SceneTargetAnalysis +{ + private static void AddPlayback(State state, string group, EditorEntryData owner, string handle, string slot, TargetPresence presence) + { + // A missing optional identity is unaddressable; querying must never manufacture one. + if (string.IsNullOrWhiteSpace(handle)) return; + var locator = new SceneLocator(group, owner.StableId, slot); + var target = new SceneTarget(locator, handle, SceneTargetKind.Playback, owner.Type, presence, [], EmptyValues); + if (state.Targets.ContainsKey(handle) || state.Playbacks.TryGetValue(handle, out var previous) && (previous.Locator != locator || previous.Kind == SceneTargetKind.Unresolved)) + { + target = target with { Kind = SceneTargetKind.Unresolved, Presence = TargetPresence.Possible, Resource = "" }; + state.Complete = false; + } + state.Playbacks[handle] = target; + } + private static void TransferAnimationPlayback(State state, string group, EditorEntryData entry) + { + var options = JsonSerializer.SerializeToElement(entry.Parameters).Deserialize(TimelineJson)!; + if (!float.IsFinite(options.Duration) || options.Duration < 0 || !float.IsFinite(options.From) || !float.IsFinite(options.To) + || !Enum.IsDefined(options.LoopMode ?? AnimationLoopMode.Once) || !Enum.IsDefined(options.BlendMode ?? AnimationBlendMode.Replace) + || !Enum.IsDefined(options.Curve ?? BuiltinAnimationCurve.Linear)) throw new InvalidDataException("Invalid animation playback."); + if ((options.LoopMode ?? AnimationLoopMode.Once) == AnimationLoopMode.Once && (options.Blocking == true || options.Duration == 0)) return; + AddPlayback(state, group, entry, options.PlaybackHandleId ?? "", "playbackHandleId", TargetPresence.Possible); + } + private static void StopPlayback(State state, EditorEntryData entry) + { + var options = JsonSerializer.SerializeToElement(entry.Parameters).Deserialize(TimelineJson)!; + var mode = options.Mode ?? AnimationStopMode.AfterIteration; + if (!Enum.IsDefined(mode)) throw new InvalidDataException("Invalid stop mode."); + if (mode == AnimationStopMode.CompleteImmediately) state.Playbacks.Remove(options.PlaybackHandleId ?? ""); + else if (state.Playbacks.TryGetValue(options.PlaybackHandleId ?? "", out var target)) + state.Playbacks[target.Handle] = target with { Presence = TargetPresence.Possible }; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Timeline.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Timeline.cs new file mode 100644 index 00000000..ee3900e0 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Timeline.cs @@ -0,0 +1,139 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using GalNet.Contracts.Builtins; +using GalNet.Core.Scene; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class SceneTargetAnalysis +{ + private static readonly JsonSerializerOptions TimelineJson = new() { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }; + private const int TimelineBudget = 10000; + private static JsonElement? Member(JsonElement value, string name) + { + var matches = value.EnumerateObject().Where(property => property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length > 1) throw new InvalidDataException("Ambiguous Timeline member."); + return matches.Length == 0 ? null : matches[0].Value; + } + private static (TimelineDefinition Timeline, JsonElement Raw) ReadTimeline(EditorEntryData entry) + { + if (!entry.Parameters.TryGetValue("timeline", out var raw) || raw.ValueKind != JsonValueKind.Object) throw new InvalidDataException("Missing Timeline."); + foreach (var name in new[] { "tracks", "events", "frameRate", "durationFrames", "loopMode" }) _ = Member(raw, name); + var timeline = raw.Deserialize(TimelineJson) ?? throw new InvalidDataException("Missing Timeline."); + TimelineEventCapabilities.ValidateStructure(timeline); + if (timeline.Events.Count + timeline.Tracks.Sum(track => (long)track.Keys.Count) > TimelineBudget) throw new InvalidDataException("Timeline query budget exceeded."); + if (Member(raw, "events") is { } events) + { + var ids = new HashSet(StringComparer.Ordinal); + foreach (var item in events.EnumerateArray()) + { + foreach (var name in new[] { "frame", "type", "parameters" }) _ = Member(item, name); + if (Member(item, "editorId") is { } identity && (identity.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(identity.GetString()) || !ids.Add(identity.GetString()!))) + throw new InvalidDataException("Ambiguous event identity."); + } + } + return (timeline, raw); + } + private static SceneTargetResult Result(State state) => new(state.Targets.Values.OrderBy(target => target.Locator.GroupId, StringComparer.Ordinal) + .ThenBy(target => target.Locator.EntryId, StringComparer.Ordinal).ThenBy(target => target.CreatedAtFrame).ThenBy(target => target.Locator.EventId, StringComparer.Ordinal).ToArray(), state.Complete && state.Targets.Values.All(target => target.CapabilitiesComplete), true) + { Playbacks = state.Playbacks.Values.OrderBy(target => target.Locator.GroupId, StringComparer.Ordinal).ThenBy(target => target.Locator.EntryId, StringComparer.Ordinal).ToArray() }; + public SceneTargetResult AtTimeline(string groupId, string entryId, SceneTimelinePosition position) + { + var state = StateBefore(groupId, entryId); + if (state is null || !_groups.TryGetValue(groupId, out var entries) || entries.FirstOrDefault(entry => entry.StableId == entryId) is not { Type: BuiltinEntryIds.PlayTimeline } entry) + return new([], false, false); + try + { + var (timeline, raw) = ReadTimeline(entry); + if (position.Frame < 0 || position.Frame > timeline.DurationFrames || position.BeforeEventIndex is { } index && + (index < 0 || index >= timeline.Events.Count || timeline.Events[index].Frame != position.Frame)) return new([], false, false); + EvaluateTimeline(state, groupId, entry, timeline, raw, position, terminal: false); + AddPlayback(state, groupId, entry, timeline.PlaybackHandleId, "timeline.playbackHandleId", + timeline.DurationFrames == 0 || position.Frame == timeline.DurationFrames ? TargetPresence.Possible : TargetPresence.Definite); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) { state.Uncertain(); } + return Result(state); + } + private void TransferTimeline(State state, string group, EditorEntryData entry) + { + var (timeline, raw) = ReadTimeline(entry); var before = state.Clone(); + EvaluateTimeline(state, group, entry, timeline, raw, new(timeline.DurationFrames), terminal: true); + if (timeline.LoopMode == AnimationLoopMode.Once && (timeline.Blocking || timeline.DurationFrames == 0)) return; + // Later story positions cannot assume concurrent or cyclic presentation has finished. + var merged = Merge([before, state]); + // An object may exist between creation and removal even when absent at both endpoints. + for (var index = 0; index < timeline.Events.Count; index++) + { + var item = timeline.Events[index]; + if (TimelineEventCapabilities.CreationSlot(item.Type) is null) continue; + try + { + var candidate = new State(); ApplyEvent(candidate, group, entry, item, index, raw, TimelineEventCapabilities.NormalizeEvent(item)); + foreach (var pair in candidate.Targets) merged.Targets.TryAdd(pair.Key, pair.Value); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) { merged.Complete = false; } + } + state.Targets.Clear(); foreach (var pair in merged.Targets) state.Targets.Add(pair.Key, pair.Value); + state.Playbacks.Clear(); foreach (var pair in merged.Playbacks) state.Playbacks.Add(pair.Key, pair.Value); + state.Uncertain(); + AddPlayback(state, group, entry, timeline.PlaybackHandleId, "timeline.playbackHandleId", TargetPresence.Possible); + } + private void EvaluateTimeline(State state, string group, EditorEntryData owner, TimelineDefinition timeline, JsonElement raw, SceneTimelinePosition position, bool terminal) + { + var ordered = timeline.Events.Select((item, index) => (Item: item, Index: index)).OrderBy(pair => pair.Item.Frame).ThenBy(pair => pair.Index).ToArray(); + var touched = new HashSet(StringComparer.Ordinal); + foreach (var (item, index) in ordered.Where(pair => pair.Item.Frame < position.Frame || pair.Item.Frame == position.Frame && (position.BeforeEventIndex is null || pair.Index < position.BeforeEventIndex))) + { + try + { + var parameters = TimelineEventCapabilities.NormalizeEvent(item); + foreach (var name in new[] { "handleId", "instanceId" }) if (parameters.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) touched.Add(value.GetString()!); + ApplyEvent(state, group, owner, item, index, raw, parameters); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) { state.Uncertain(); } + } + foreach (var track in timeline.Tracks) + { + if (!state.Targets.TryGetValue(track.HandleId, out var target)) continue; + var values = new Dictionary(target.Values); + var capability = target.Properties.FirstOrDefault(property => property.Name == track.Property); + if (position.BeforeEventIndex is not null) values.Remove(track.Property); + else + { + var value = TimelineSampler.Evaluate(track, position.Frame); + var known = target.Presence == TargetPresence.Definite && capability is not null; + if (track.BlendMode == AnimationBlendMode.Additive) + { + known &= (terminal || !touched.Contains(track.HandleId)) && values.TryGetValue(track.Property, out _); + value = known ? value + values[track.Property] : value; + } + if (known && float.IsFinite(value) && capability!.Accepts(value)) values[track.Property] = value; + else { values.Remove(track.Property); state.Complete = false; } + } + state.Targets[track.HandleId] = target with { Values = new ReadOnlyDictionary(values) }; + } + if (!position.IncludeFuture) return; + foreach (var (item, index) in ordered.Where(pair => pair.Item.Frame > position.Frame || pair.Item.Frame == position.Frame && position.BeforeEventIndex is { } boundary && pair.Index >= boundary)) + { + if (!TimelineEventCapabilities.Supports(item.Type)) { state.Complete = false; continue; } + if (TimelineEventCapabilities.CreationSlot(item.Type) is null) continue; + try + { + var candidate = new State(); ApplyEvent(candidate, group, owner, item, index, raw, TimelineEventCapabilities.NormalizeEvent(item)); + foreach (var (handle, target) in candidate.Targets) + state.Targets.TryAdd(handle, target with { Presence = TargetPresence.Possible, Values = EmptyValues, IsFuture = true }); + state.Complete &= candidate.Complete; + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) { state.Complete = false; } + } + } + private void ApplyEvent(State state, string group, EditorEntryData owner, TimelineEventDefinition item, int index, JsonElement raw, JsonElement parameters) + { + Transfer(state, group, new EditorEntryData { StableId = owner.StableId, Type = item.Type, Parameters = parameters.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.Clone()) }); + if (TimelineEventCapabilities.CreationSlot(item.Type) is not { } slot || !parameters.TryGetProperty(slot, out var output) || output.GetString() is not { } handle || !state.Targets.TryGetValue(handle, out var target)) return; + var identity = Member(Member(raw, "events")!.Value[index], "editorId")?.GetString() ?? slot + ":" + handle; + state.Targets[handle] = target with { Locator = new(group, owner.StableId, slot, identity), CreatedAtFrame = item.Frame, IsFuture = false }; + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Transitions.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Transitions.cs new file mode 100644 index 00000000..70871894 --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Transitions.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using GalNet.Contracts.Builtins; +using GalNet.Core.Compilation; +using GalNet.Core.Entry; +using GalNet.Core.Serialization; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public sealed partial class SceneTargetAnalysis +{ + private static readonly Lazy TransitionCatalog = new(() => new TargetProfileEntryCatalog(BuiltinSchemas.CreateAllModules())); + // This analysis owns an immutable snapshot; both successful and invalid expansions are cached per generation. + private readonly Dictionary _transitionTimelines = new(); + private EditorEntryData TransitionTimeline(EditorEntryData entry) + { + if (_transitionTimelines.TryGetValue(entry, out var cached)) return cached ?? throw new InvalidDataException("Invalid transition."); + _transitionTimelines[entry] = null; + var definition = TransitionCatalog.Value.Get(entry.Type); + var source = new GroupDocument + { + Entries = [new() { Id = entry.StableId, Type = entry.Type, + // Unknown author extensions remain on the original document, not in the strict compiler input. + Parameters = entry.Parameters.Where(pair => definition.Parameters.ContainsKey(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value.Clone()) }] + }; + var expanded = GalgroupCompiler.Compile(source, TransitionCatalog.Value).Document.Entries.Single(); + if (expanded.TypeId != BuiltinEntryIds.PlayTimeline) throw new InvalidDataException("Unsupported transition expansion."); + var result = new EditorEntryData + { + StableId = entry.StableId, + Type = expanded.TypeId, + Parameters = expanded.Arguments.EnumerateObject().ToDictionary(property => property.Name, property => property.Value.Clone()) + }; + _transitionTimelines[entry] = result; + return result; + } + private void TransferTransition(State state, string group, EditorEntryData entry) + { + var candidate = state.Clone(); + // Timeline queries recover from individual event errors. A composite must not publish only + // the successful half of its expansion. Track local validity separately from upstream uncertainty. + candidate.Complete = true; + TransferTimeline(candidate, group, TransitionTimeline(entry)); + if (!candidate.Complete) throw new InvalidDataException("Incomplete transition expansion."); + var slot = SceneAuthoringIdentities.CreationSlot(entry.Type)!; + var handle = entry.Parameters[slot].GetString()!; + if (candidate.Targets.TryGetValue(handle, out var target) && target.Locator.GroupId == group && target.Locator.EntryId == entry.StableId) + candidate.Targets[handle] = target with { Locator = new(group, entry.StableId, slot), CreatedAtFrame = null, IsFuture = false }; + state.Targets.Clear(); + foreach (var pair in candidate.Targets) state.Targets.Add(pair.Key, pair.Value); + state.Playbacks.Clear(); + foreach (var pair in candidate.Playbacks) state.Playbacks.Add(pair.Key, pair.Value); + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Variants.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Variants.cs new file mode 100644 index 00000000..fe8b321b --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.Variants.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using GalNet.Contracts.Builtins; +using GalNet.Core.Variants; + +namespace GalNet.Editor.Shared.Services; + +/// Possible atlas identities, separate from the actual sprite and retained selection intent. +public sealed class SceneImageVariants +{ + internal SceneImageVariants(IEnumerable assetIds, bool definite, ImageVariantResolution? selection) + { + AssetIds = Array.AsReadOnly(assetIds.Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToArray()); + Definite = definite; + Selection = selection; + } + + public IReadOnlyList AssetIds { get; } + public bool Definite { get; } + public ImageVariantResolution? Selection { get; } +} + +public sealed partial class SceneTargetAnalysis +{ + private void ShowVariants(State state, string handle, string assetId, JsonElement selection) + { + if (!state.Targets.TryGetValue(handle, out var target) || target.Kind != SceneTargetKind.Layer) return; + if (string.IsNullOrWhiteSpace(assetId)) throw new InvalidDataException("Missing image variants resource."); + assetId = assetId.Trim(); + var binding = new SceneImageVariants([assetId], true, null); + state.Targets[handle] = target with { Resource = "", Variants = binding }; + var request = VariantsArgumentValidation.ReadSelection(selection); + if (_variants?.GetValueOrDefault(assetId) is not { } definition) { state.Complete = false; return; } + var resolved = ImageVariantsResolver.Initialize(definition, request); + state.Targets[handle] = target with { Resource = resolved.Variant.AssetId, Variants = new([assetId], true, resolved) }; + } + + private void SelectVariants(State state, string handle, string dimension, string value) + { + if (!state.Targets.TryGetValue(handle, out var target) || target.Kind != SceneTargetKind.Layer || target.Variants is not { } binding) + { state.Complete = false; return; } + // A new selection must not leave the old sprite advertised when the request cannot be resolved. + state.Targets[handle] = target with { Resource = "", Variants = new(binding.AssetIds, binding.Definite, null) }; + if (!binding.Definite || binding.Selection is null || _variants?.GetValueOrDefault(binding.AssetIds.Single()) is not { } definition) + { state.Complete = false; return; } + var resolved = ImageVariantsResolver.Select(definition, binding.Selection.Request, dimension, value); + state.Targets[handle] = target with { Resource = resolved.Variant.AssetId, Variants = new(binding.AssetIds, true, resolved) }; + } + + private static SceneImageVariants? MergeVariants(IReadOnlyList candidates, bool sameKind) + { + if (!sameKind) return null; + var bindings = candidates.Select(target => target.Variants).ToArray(); + var ids = bindings.OfType().SelectMany(binding => binding.AssetIds).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + if (ids.Length == 0) return null; + var definite = ids.Length == 1 && bindings.All(binding => binding is { Definite: true }); + var first = bindings[0]?.Selection; + var selection = definite && bindings.All(binding => EqualSelection(first, binding!.Selection)) ? first : null; + return new(ids, definite, selection); + } + + private static bool EqualVariants(SceneImageVariants? left, SceneImageVariants? right) => ReferenceEquals(left, right) + || left is not null && right is not null && left.Definite == right.Definite + && left.AssetIds.SequenceEqual(right.AssetIds, StringComparer.OrdinalIgnoreCase) && EqualSelection(left.Selection, right.Selection); + + private static bool EqualSelection(ImageVariantResolution? left, ImageVariantResolution? right) => ReferenceEquals(left, right) + || left is not null && right is not null && left.Variant.Key == right.Variant.Key && left.Variant.AssetId == right.Variant.AssetId + && left.Reason == right.Reason && left.ProtectedDimensions.SequenceEqual(right.ProtectedDimensions) + && left.Request.Count == right.Request.Count && left.Request.All(pair => right.Request.TryGetValue(pair.Key, out var value) && value == pair.Value); +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.cs b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.cs new file mode 100644 index 00000000..eb28e87d --- /dev/null +++ b/src/Editor/GalNet.Editor.Shared/Services/SceneTargetAnalysis.cs @@ -0,0 +1,269 @@ +using System.Collections.ObjectModel; +using System.Text.Json; +using GalNet.Contracts.Builtins; +using GalNet.Core.Scene; +using GalNet.Core.Variants; +using GalNet.Editor.Abstraction.Documents; + +namespace GalNet.Editor.Shared.Services; + +public enum TargetPresence { Definite, Possible } +public enum SceneTargetKind { Layer, Particle, Effect, Unresolved, Playback } +public sealed record SceneLocator(string GroupId, string EntryId, string Slot, string? EventId = null); +public sealed record SceneTimelinePosition(int Frame, int? BeforeEventIndex = null, bool IncludeFuture = false); +public sealed record SceneTarget(SceneLocator Locator, string Handle, SceneTargetKind Kind, string Resource, + TargetPresence Presence, IReadOnlyList Properties, IReadOnlyDictionary Values, int? CreatedAtFrame = null, bool IsFuture = false, bool CapabilitiesComplete = true) +{ + public SceneImageVariants? Variants { get; init; } +} +public sealed record SceneTargetResult(IReadOnlyList Targets, bool Complete, bool Reachable) +{ + public IReadOnlyList Playbacks { get; init; } = []; +} + +/// Pure, finite abstract interpretation. Never instantiates a game or executes an entry. +public sealed partial class SceneTargetAnalysis +{ + private sealed class State + { + public Dictionary Targets { get; } = new(StringComparer.Ordinal); + public Dictionary Playbacks { get; } = new(StringComparer.Ordinal); + public bool Complete { get; set; } = true; + public State Clone() { var copy = new State { Complete = Complete }; foreach (var pair in Targets) copy.Targets.Add(pair.Key, pair.Value); foreach (var pair in Playbacks) copy.Playbacks.Add(pair.Key, pair.Value); return copy; } + public void Uncertain() + { + Complete = false; + foreach (var domain in new[] { Targets, Playbacks }) foreach (var (id, target) in domain.ToArray()) domain[id] = target with { Presence = TargetPresence.Possible, Values = EmptyValues, Variants = null }; + } + } + private static readonly IReadOnlyDictionary EmptyValues = new ReadOnlyDictionary(new Dictionary()); + private readonly IReadOnlyDictionary> _groups; + private readonly IReadOnlyDictionary? _programs; + private readonly IEffectCatalog? _effects; + private readonly IReadOnlyDictionary? _variants; + private readonly Dictionary _inputs = new(StringComparer.Ordinal); + private bool _budgetExceeded; + public SceneTargetAnalysis(EditorGraphDocument graph, IReadOnlyDictionary> groups, int budget = 10000, + IReadOnlyDictionary? programs = null, IEffectCatalog? effects = null, + IReadOnlyDictionary? variants = null) + { + // Caller owns the immutable snapshot for this generation. + _groups = groups; + _programs = programs; _effects = effects; _variants = variants; + var nodes = graph.Nodes.ToDictionary(node => node.Id, StringComparer.Ordinal); + if (!nodes.ContainsKey(graph.RootNodeId)) return; + var outputs = new Dictionary(StringComparer.Ordinal); + var queue = new Queue(); queue.Enqueue(graph.RootNodeId); + var successors = graph.Edges.GroupBy(edge => edge.FromNodeId).ToDictionary(group => group.Key, group => group.Select(edge => edge.ToNodeId).Distinct().Where(nodes.ContainsKey).ToArray()); + var predecessors = graph.Edges.GroupBy(edge => edge.ToNodeId).ToDictionary(group => group.Key, group => group.Select(edge => edge.FromNodeId).Distinct().ToArray()); + while (queue.Count > 0) + { + if (--budget < 0) { _budgetExceeded = true; break; } + var id = queue.Dequeue(); + var incoming = predecessors.TryGetValue(id, out var previous) ? previous.Where(outputs.ContainsKey).Select(key => outputs[key]).ToList() : []; + if (id == graph.RootNodeId) incoming.Add(new State()); + if (incoming.Count == 0) continue; + var input = Merge(incoming); _inputs[id] = input; + var output = input.Clone(); + var node = nodes[id]; + if (node.Type == "Group") + { + if (groups.TryGetValue(id, out var entries)) foreach (var entry in entries) output = Apply(output, id, entry); + else output.Uncertain(); + } + else if (node.Type is not ("Entry" or "Branch" or "Exit")) output.Uncertain(); + if (outputs.TryGetValue(id, out var old) && Equal(old, output)) continue; + outputs[id] = output; + if (successors.TryGetValue(id, out var next)) foreach (var target in next) queue.Enqueue(target); + } + } + public SceneTargetResult Before(string groupId, string? entryId) + => StateBefore(groupId, entryId) is { } state ? Result(state) : new([], false, false); + private State? StateBefore(string groupId, string? entryId) + { + if (!_inputs.TryGetValue(groupId, out var input) || !_groups.TryGetValue(groupId, out var entries)) return null; + var state = input.Clone(); + if (_budgetExceeded) + { + // Do not publish an unfinished fixed point as a definite fact. + state.Uncertain(); + foreach (var (id, group) in _groups) foreach (var entry in group) + { + var candidate = Apply(new State(), id, entry); + foreach (var (handle, target) in candidate.Targets) state.Targets.TryAdd(handle, target with { Presence = TargetPresence.Possible, Values = EmptyValues }); + foreach (var (handle, target) in candidate.Playbacks) state.Playbacks.TryAdd(handle, target with { Presence = TargetPresence.Possible }); + } + } + var found = entryId is null; + foreach (var entry in entries) + { + if (entry.StableId == entryId) { found = true; break; } + state = Apply(state, groupId, entry); + } + if (!found) return null; + if (_budgetExceeded) state.Uncertain(); + return state; + } + private static State Merge(IReadOnlyList states) + { + var result = new State { Complete = states.All(state => state.Complete) }; + MergeDomain(result.Targets, states.Select(state => state.Targets).ToArray()); + MergeDomain(result.Playbacks, states.Select(state => state.Playbacks).ToArray()); + return result; + } + private static void MergeDomain(Dictionary result, IReadOnlyList> domains) + { + foreach (var id in domains.SelectMany(domain => domain.Keys).Distinct(StringComparer.Ordinal)) + { + var candidates = domains.Where(domain => domain.ContainsKey(id)).Select(domain => domain[id]).ToArray(); + var first = candidates.OrderBy(target => target.Locator.GroupId, StringComparer.Ordinal).ThenBy(target => target.Locator.EntryId, StringComparer.Ordinal).First(); + var sameKind = candidates.All(target => target.Kind == first.Kind && (first.Kind != SceneTargetKind.Playback || target.Locator == first.Locator)); + var definite = sameKind && candidates.Length == domains.Count && candidates.All(target => target.Presence == TargetPresence.Definite); + var properties = first.Properties.Where(property => candidates.All(target => target.Kind == first.Kind && target.Properties.Contains(property))).ToArray(); + var values = definite ? first.Values.Where(pair => candidates.All(target => target.Values.TryGetValue(pair.Key, out var value) && value == pair.Value)).ToDictionary(pair => pair.Key, pair => pair.Value) : []; + result[id] = first with + { + Kind = sameKind ? first.Kind : SceneTargetKind.Unresolved, + Presence = definite ? TargetPresence.Definite : TargetPresence.Possible, + Properties = Array.AsReadOnly(properties), + Values = new ReadOnlyDictionary(values), + Resource = candidates.All(target => target.Resource == first.Resource) ? first.Resource : "", + Variants = MergeVariants(candidates, sameKind), + CreatedAtFrame = candidates.All(target => target.Locator == first.Locator && target.CreatedAtFrame == first.CreatedAtFrame) ? first.CreatedAtFrame : null, + IsFuture = candidates.All(target => target.IsFuture), + CapabilitiesComplete = candidates.All(target => target.CapabilitiesComplete) + }; + } + } + private static bool Equal(State left, State right) => left.Complete == right.Complete && EqualDomain(left.Targets, right.Targets) && EqualDomain(left.Playbacks, right.Playbacks); + private static bool EqualDomain(Dictionary left, Dictionary right) => left.Count == right.Count && left.All(pair => + right.TryGetValue(pair.Key, out var other) && pair.Value.Locator == other.Locator && pair.Value.Kind == other.Kind && pair.Value.Resource == other.Resource + && pair.Value.CreatedAtFrame == other.CreatedAtFrame && pair.Value.IsFuture == other.IsFuture + && pair.Value.CapabilitiesComplete == other.CapabilitiesComplete + && EqualVariants(pair.Value.Variants, other.Variants) + && pair.Value.Presence == other.Presence && pair.Value.Properties.SequenceEqual(other.Properties) + && pair.Value.Values.Count == other.Values.Count && pair.Value.Values.All(value => other.Values.TryGetValue(value.Key, out var number) && number == value.Value)); + private State Apply(State before, string group, EditorEntryData entry) + { + if (entry.Condition.Trim().Equals("false", StringComparison.OrdinalIgnoreCase)) return before.Clone(); + var after = before.Clone(); + try { Transfer(after, group, entry); } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) { after.Uncertain(); } + return string.IsNullOrWhiteSpace(entry.Condition) || entry.Condition.Trim().Equals("true", StringComparison.OrdinalIgnoreCase) ? after : Merge([before, after]); + } + private void Transfer(State state, string group, EditorEntryData entry) + { + string Text(string key, string fallback = "") => entry.Parameters.TryGetValue(key, out var value) ? value.GetString() ?? fallback : fallback; + float Number(string key, float fallback) => entry.Parameters.TryGetValue(key, out var value) ? value.GetSingle() : fallback; + JsonElement Json(string key) => entry.Parameters.TryGetValue(key, out var value) ? value : JsonSerializer.SerializeToElement(new { }); + void Create(string handle, SceneTargetKind kind, string resource, IEnumerable properties, Dictionary values) + { + if (string.IsNullOrWhiteSpace(handle) || values.Values.Any(value => !float.IsFinite(value))) { state.Uncertain(); return; } + state.Targets[handle] = new(new(group, entry.StableId, kind == SceneTargetKind.Layer ? "handleId" : "instanceId"), handle, kind, resource, TargetPresence.Definite, + Array.AsReadOnly(properties.ToArray()), new ReadOnlyDictionary(values)); + } + void Transform(string handle, bool create) + { + var transform = entry.Type == BuiltinEntryIds.ShowVariants ? VariantsArgumentValidation.ReadTransform(Json("transform")) + : Json("transform").Deserialize(new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? throw new JsonException(); + Dictionary values = new() { ["transform.x"] = transform.X, ["transform.y"] = transform.Y, ["transform.rotationDegrees"] = transform.RotationDegrees, ["transform.scaleX"] = transform.ScaleX, ["transform.scaleY"] = transform.ScaleY }; + if (values.Values.Any(value => !float.IsFinite(value))) throw new InvalidDataException("Invalid transform."); + if (create) + { + values["opacity"] = Number("opacity", 1); + var properties = Layer.AnimationProperties.Where(property => property.Name != "flipbook.index").ToList(); + if (entry.Type == BuiltinEntryIds.ShowLayer && entry.Parameters.TryGetValue("flipbook", out var raw) && raw.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) + { + var flipbook = raw.Deserialize(TimelineJson); + if (flipbook is { IsValid: true }) + { + var property = Layer.AnimationProperties.Single(property => property.Name == "flipbook.index"); + properties.Add(property); + // Runtime stores the raw float index; only rendering floors/clamps it to a cell. + if (property.Accepts(flipbook.Index)) values[property.Name] = flipbook.Index; + else state.Complete = false; + } + else if (flipbook is not null) state.Complete = false; + } + Create(handle, SceneTargetKind.Layer, Text(entry.Type == BuiltinEntryIds.PlayVideo ? "asset" : entry.Type == BuiltinEntryIds.ShowColorLayer ? "color" : "assetId"), properties, values); + } + else if (state.Targets.TryGetValue(handle, out var target) && target.Kind == SceneTargetKind.Layer) + state.Targets[handle] = target with { Values = new ReadOnlyDictionary(target.Values.Where(pair => !values.ContainsKey(pair.Key)).Concat(values).ToDictionary(pair => pair.Key, pair => pair.Value)) }; + } + switch (entry.Type) + { + case BuiltinEntryIds.ShowLayer: + case BuiltinEntryIds.ShowColorLayer: Transform(Text("handleId"), true); break; + case BuiltinEntryIds.ShowVariants: + Transform(Text("handleId"), true); + ShowVariants(state, Text("handleId"), Text("assetId"), Json("selection")); + break; + case BuiltinEntryIds.SelectVariants: + SelectVariants(state, Text("handleId"), Text("dimension"), Text("value")); + break; + case BuiltinEntryIds.PlayVideo: + Transform(Text("handleId", "video"), true); + if (state.Targets.TryGetValue(Text("handleId", "video"), out var video)) state.Targets[video.Handle] = video with { Presence = TargetPresence.Possible, Values = EmptyValues }; + break; + case BuiltinEntryIds.HideLayer: + case BuiltinEntryIds.StopVideo: + if (state.Targets.TryGetValue(Text("handleId", entry.Type == BuiltinEntryIds.StopVideo ? "video" : ""), out var layer) && layer.Kind == SceneTargetKind.Layer) state.Targets.Remove(layer.Handle); + break; + case BuiltinEntryIds.MoveLayer: Transform(Text("handleId"), false); break; + case BuiltinEntryIds.ReplaceLayer: + if (state.Targets.TryGetValue(Text("handleId"), out var replaced) && replaced.Kind == SceneTargetKind.Layer) state.Targets[replaced.Handle] = replaced with { Resource = Text("assetId"), Variants = null }; + break; + case BuiltinEntryIds.PlayParticleEmitter: + var definition = ParticleEmitterDefinition.FromJson(Json("parameters").GetRawText()); + Create(Text("instanceId"), SceneTargetKind.Particle, definition.ParticleTexture, ParticleEmitterInstance.AnimationProperties, + new() { ["emissionRate"] = definition.EmissionRate, ["initialVelocityX"] = definition.InitialModule.VelocityX.Center, ["initialVelocityY"] = definition.InitialModule.VelocityY.Center, ["noise"] = definition.MotionModule.Noise, ["particleScale"] = definition.InitialModule.Size.Center, ["particleLifetime"] = definition.InitialModule.Lifetime.Center }); + break; + case BuiltinEntryIds.ApplyEffect: + var effect = EffectCapabilities(entry); + Create(Text("instanceId"), SceneTargetKind.Effect, string.IsNullOrWhiteSpace(Text("program")) ? Text("id") : Text("program"), effect.Properties, effect.Values); + if (state.Targets.TryGetValue(Text("instanceId"), out var createdEffect)) state.Targets[createdEffect.Handle] = createdEffect with { CapabilitiesComplete = effect.Complete }; + break; + case BuiltinEntryIds.StopParticleEmitter: + case BuiltinEntryIds.StopEffect: + var kind = entry.Type == BuiltinEntryIds.StopEffect ? SceneTargetKind.Effect : SceneTargetKind.Particle; + if (state.Targets.TryGetValue(Text("instanceId"), out var stopped) && stopped.Kind == kind) state.Targets.Remove(stopped.Handle); + break; + case BuiltinEntryIds.PlayTimeline: TransferTimeline(state, group, entry); break; + case BuiltinEntryIds.CrossFadeTransition: + case BuiltinEntryIds.SlideTransition: + case BuiltinEntryIds.BlindsTransition: + case BuiltinEntryIds.BlackFadeTransition: + case BuiltinEntryIds.WhiteFadeTransition: + case BuiltinEntryIds.ColorFadeTransition: TransferTransition(state, group, entry); break; + case BuiltinEntryIds.Animate: + TransferAnimationPlayback(state, group, entry); + if (state.Targets.TryGetValue(Text("handleId"), out var animated)) state.Targets[animated.Handle] = animated with { Values = EmptyValues }; + break; + case BuiltinEntryIds.StopAnimation: StopPlayback(state, entry); break; + // Proven scene-neutral built-ins. Unknown/composite/Timeline semantics must never be neutral by default. + case BuiltinEntryIds.Text: + case BuiltinEntryIds.ShowDialogue: + case BuiltinEntryIds.HideDialogue: + case BuiltinEntryIds.ShowNvl: + case BuiltinEntryIds.HideNvl: + case BuiltinEntryIds.ClearNvl: + case BuiltinEntryIds.AppendNvl: + case BuiltinEntryIds.TextVoice: + case BuiltinEntryIds.AppendVoiceNvl: + case BuiltinEntryIds.Wait: + case BuiltinEntryIds.SetVariable: + case BuiltinEntryIds.UnlockGallery: + case BuiltinEntryIds.PlayAudio: + case BuiltinEntryIds.StopAudio: + case BuiltinEntryIds.PauseAudio: + case BuiltinEntryIds.ResumeAudio: + case BuiltinEntryIds.NextAudio: + case BuiltinEntryIds.ClearAudio: + case BuiltinEntryIds.EnqueueAudio: + case BuiltinEntryIds.ApplyAudioSEffect: + case BuiltinEntryIds.StopAudioSEffect: + case BuiltinEntryIds.BurstParticles: break; + default: state.Uncertain(); break; + } + } +} diff --git a/src/Editor/GalNet.Editor.Shared/Services/VariableDefinitionService.cs b/src/Editor/GalNet.Editor.Shared/Services/VariableDefinitionService.cs index 1cf4b1e9..d043c5a6 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/VariableDefinitionService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/VariableDefinitionService.cs @@ -1,117 +1,23 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using GalNet.Core.Gallery; using GalNet.Core.Variable; using GalNet.Editor.Abstraction.Services; namespace GalNet.Editor.Shared.Services; -public sealed class VariableDefinitionService : IVariableDefinitionService +/// Read-only declaration snapshots and notifications for preview variable stores. +public sealed class VariableDefinitionService(IEditorDocumentService documents) : IVariableDefinitionService { - private readonly IEditorDocumentService _documentService; - public event Action? DefinitionsChanged; - public VariableDefinitionService(IEditorDocumentService documentService) - { - _documentService = documentService; - } - - public List GetDefinitions(VariableScope scope) => - GetList(scope); - - public bool IsNameAvailable(string name, VariableScope scope, ProjectVariableDefinition? current = null) - { - var sanitized = VariableNameRules.Sanitize(name, current?.Name ?? $"var_{scope.ToString().ToLowerInvariant()}"); - if (GalleryUnlockVariable.IsReservedName(sanitized)) - return false; - return GetAllDefinitions() - .Where(def => !ReferenceEquals(def, current)) - .All(def => !string.Equals(def.Name, sanitized, StringComparison.Ordinal)); - } - - public ProjectVariableDefinition AddDefinition(VariableScope scope) - { - var list = GetMutableList(scope); - var index = list.Count + 1; - var baseName = $"var_{scope.ToString().ToLowerInvariant()}_{index}"; - var name = VariableNameRules.Sanitize(baseName); - var suffix = 1; - while (!IsNameAvailable(name, scope)) - name = $"{baseName}_{suffix++}"; - - var definition = new ProjectVariableDefinition - { - Name = name, - DefaultValue = new Variable { Name = name } - }; - list.Add(definition); - MarkChanged(scope); - return definition; - } - - public bool RemoveDefinition(VariableScope scope, ProjectVariableDefinition definition) - { - var removed = GetMutableList(scope).Remove(definition); - if (removed) - MarkChanged(scope); - return removed; - } - - public bool RenameDefinition(VariableScope scope, ProjectVariableDefinition definition, string newName) + public IReadOnlyList GetDefinitions(VariableScope scope) => (scope switch { - var sanitized = VariableNameRules.Sanitize(newName, definition.Name); - if (string.Equals(definition.Name, sanitized, StringComparison.Ordinal)) - return true; - - if (!IsNameAvailable(sanitized, scope, definition)) - return false; - - definition.Name = sanitized; - definition.DefaultValue.Name = sanitized; - MarkChanged(scope); - return true; - } - - public bool UpdateDefinition(ProjectVariableDefinition definition) - { - if (!GetAllDefinitions().Any(def => ReferenceEquals(def, definition))) - return false; - - definition.DefaultValue.Name = definition.Name; - var scope = GetScope(definition); - MarkChanged(scope); - return true; - } + VariableScope.Player => documents.CurrentDocument.PlayerVariables, + VariableScope.Save => documents.CurrentDocument.SaveVariables, + _ => throw new ArgumentOutOfRangeException(nameof(scope)) + }).Select(definition => definition.Clone()).ToArray(); public void ResetFromDocument() { DefinitionsChanged?.Invoke(VariableScope.Player); DefinitionsChanged?.Invoke(VariableScope.Save); } - - private VariableScope GetScope(ProjectVariableDefinition definition) => - GetMutableList(VariableScope.Save).Any(def => ReferenceEquals(def, definition)) - ? VariableScope.Save - : VariableScope.Player; - - private IReadOnlyList GetAllDefinitions() => - [.. _documentService.CurrentDocument.PlayerVariables, .. _documentService.CurrentDocument.SaveVariables]; - - private List GetMutableList(VariableScope scope) => - scope == VariableScope.Player - ? _documentService.CurrentDocument.PlayerVariables - : _documentService.CurrentDocument.SaveVariables; - - private List GetList(VariableScope scope) => GetMutableList(scope); - - private void MarkChanged(VariableScope scope) - { - VariableNameRules.Normalize( - _documentService.CurrentDocument.PlayerVariables, - _documentService.CurrentDocument.SaveVariables); - _documentService.MarkDirty(); - DefinitionsChanged?.Invoke(scope); - } } diff --git a/src/Editor/GalNet.Editor.Shared/packages.lock.json b/src/Editor/GalNet.Editor.Shared/packages.lock.json index 126c6f3d..cc61d79f 100644 --- a/src/Editor/GalNet.Editor.Shared/packages.lock.json +++ b/src/Editor/GalNet.Editor.Shared/packages.lock.json @@ -28,6 +28,12 @@ "galnet.core": { "type": "Project" }, + "galnet.debugging": { + "type": "Project", + "dependencies": { + "GalNet.Runtime": "[1.0.0, )" + } + }, "galnet.editor.abstraction": { "type": "Project", "dependencies": { diff --git a/src/Editor/GalNet.Editor/App.axaml b/src/Editor/GalNet.Editor/App.axaml index 211ee4f4..23f2b25d 100644 --- a/src/Editor/GalNet.Editor/App.axaml +++ b/src/Editor/GalNet.Editor/App.axaml @@ -25,6 +25,7 @@ + @@ -140,6 +141,6 @@ - + diff --git a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs index 5f01d3e3..d4af549a 100644 --- a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs +++ b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using GalNet.Editor.Abstraction.Extensibility; using GalNet.Editor.Abstraction.Services; using GalNet.Editor.Commands; +using GalNet.Editor.Controls; using GalNet.Editor.Dock; using GalNet.Editor.History; using GalNet.Editor.Inspector.ViewModels; @@ -17,6 +18,7 @@ using GalNet.Editor.Shared.Services; using GalNet.Editor.ViewModels; using GalNet.Editor.Views; +using GalNet.Game.Controls.Forms; using GalNet.Primitives.Builtins; using GalNet.Runtime.Content; using GalNet.Runtime.Variables; @@ -29,6 +31,8 @@ public static class EditorServiceCollectionExtensions public static IServiceCollection AddEditorServices(this IServiceCollection services) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService().Catalog); services.AddEditorCoreServices(); services.AddEditorCommands(); @@ -55,19 +59,55 @@ private static IServiceCollection AddEditorCoreServices(this IServiceCollection services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddTransient(); + services.AddScoped(); + services.AddTransient(); + services.AddScoped(); + services.AddTransient(); + services.AddScoped(sp => + { + var registry = EditorFieldEditors.CreateDefault(sp.GetRequiredService(), context => + { + if (context.IsEnabled && context.HostContext is Models.Graph.IEntryFieldOrigin { GroupId: { } groupId } origin) + sp.GetRequiredService().OpenTimeline(groupId, origin.EntryId); + }, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService()); + registry.Replace("entry.expression", context => AuthoringExpressionFieldEditors.Expression(context, + () => sp.GetRequiredService().GetConditionVariableSuggestions(), + () => sp.GetRequiredService().AllProjectVariableDefinitions)); + registry.Replace("entry.variablename", context => AuthoringExpressionFieldEditors.Variable(context, + () => sp.GetRequiredService().AllProjectVariableDefinitions.Select(variable => variable.Name).ToArray())); + return registry; + }); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddScoped(); services.AddSingleton(new ProjectSessionOptions()); services.AddScoped(); - services.AddScoped(); + services.AddScoped(provider => new EditorDocumentRepository(provider.GetRequiredService(), EditorDocumentValidation.Authoring)); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -132,6 +172,8 @@ private static IServiceCollection AddEditorViews(this IServiceCollection service services.AddTransient(); services.AddTransient(); services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -155,6 +197,8 @@ private static IServiceCollection AddEditorViewModels(this IServiceCollection se services.AddTransient(); services.AddTransient(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs b/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs new file mode 100644 index 00000000..76e66ebd --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/AssetFieldEditor.cs @@ -0,0 +1,36 @@ +using System.ComponentModel; +using System.Text.Json; +using Avalonia; +using Avalonia.Controls; +using GalNet.Core.Assets; +using GalNet.Editor.Models; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Controls; + +internal static class AssetFieldEditor +{ + public static FieldEditorLease Create(FieldEditorContext context, IAssetManager? assets, AssetPickerFilter filter) + { + if (assets is null) return DefaultFieldEditors.CreateText(context); + var picker = new AssetPicker { AssetManager = assets, Filter = filter }; + var lifetime = new CancellationTokenSource(); var refreshing = false; var disposed = false; + void Refresh() + { + if (disposed) return; + refreshing = true; + try { picker.IsEnabled = context.IsEnabled; picker.SelectedAssetId = context.EffectiveValue is { ValueKind: JsonValueKind.String } value ? value.GetString() ?? "" : ""; } + finally { refreshing = false; } + } + void Changed(object? sender, PropertyChangedEventArgs args) => Refresh(); + async void Selected(object? sender, AvaloniaPropertyChangedEventArgs args) + { + if (args.Property != AssetPicker.SelectedAssetIdProperty || refreshing || disposed) return; + try { await context.CommitAsync(new(JsonSerializer.SerializeToElement(picker.SelectedAssetId)), lifetime.Token); } + catch (OperationCanceledException) { } + finally { Refresh(); } + } + Refresh(); context.PropertyChanged += Changed; picker.PropertyChanged += Selected; + return new(picker, () => { disposed = true; lifetime.Cancel(); context.PropertyChanged -= Changed; picker.PropertyChanged -= Selected; picker.FindControl("PickerButton")?.Flyout?.Hide(); picker.AssetManager = null; lifetime.Dispose(); }); + } +} diff --git a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml index f7c55d21..01a81241 100644 --- a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml +++ b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml @@ -1,14 +1,10 @@ + xmlns:local="clr-namespace:GalNet.Editor.Controls" xmlns:forms="using:GalNet.Game.Controls.Forms" x:Class="GalNet.Editor.Controls.AssetPicker" x:Name="Root"> - + diff --git a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs index 1da21a21..6702caa1 100644 --- a/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs +++ b/src/Editor/GalNet.Editor/Controls/AssetPicker.axaml.cs @@ -11,6 +11,7 @@ using Avalonia.Threading; using GalNet.Core.Assets; using GalNet.Editor.Models; +using GalNet.Game.Controls.Forms; namespace GalNet.Editor.Controls; @@ -45,25 +46,27 @@ static AssetPicker() FilterProperty.Changed.AddClassHandler((picker, args) => { if (picker._flyoutOpen) _ = picker.LoadItemsAsync(); }); } - public AssetPicker() => InitializeComponent(); + public AssetPicker() + { + InitializeComponent(); + PickerPanel.Search.PlaceholderText = "Search assets"; + PickerPanel.Confirmed += () => + { + if (PickerPanel.Selected is not { } item) return; + SelectedAssetId = item.Id; PickerButton.Flyout?.Hide(); + }; + PickerPanel.Cancelled += () => PickerButton.Flyout?.Hide(); + } + public ResourcePickerPanel ResourcePanel => PickerPanel; private async void OnFlyoutOpened(object? sender, EventArgs e) { _flyoutOpen = true; await LoadItemsAsync(); - Dispatcher.UIThread.Post(() => SearchBox.Focus()); + Dispatcher.UIThread.Post(() => PickerPanel.Search.Focus()); } private void OnFlyoutClosed(object? sender, EventArgs e) => _flyoutOpen = false; - private void OnSearchTextChanged(object? sender, TextChangedEventArgs e) => ApplyFilter(SearchBox.Text); - - private void OnSelectionChanged(object? sender, SelectionChangedEventArgs e) - { - if (ResourceList.SelectedItem is not AssetPickerItem item) return; - SelectedAssetId = item.File?.Id ?? string.Empty; - PickerButton.Flyout?.Hide(); - ResourceList.SelectedItem = null; - } private async Task ResolveCurrentAsync() { @@ -85,21 +88,21 @@ private async Task LoadItemsAsync() { var assetManager = AssetManager; if (assetManager is null) { _allItems = []; ApplyFilter(null); return; } - var type = Filter switch { AssetPickerFilter.Image => "sprite", AssetPickerFilter.Audio => "audio", AssetPickerFilter.Video => "video", AssetPickerFilter.EffectProgram => "effect-program", _ => null }; + var type = Filter switch { AssetPickerFilter.Image => "sprite", AssetPickerFilter.Audio => "audio", AssetPickerFilter.Video => "video", AssetPickerFilter.EffectProgram => "effect-program", AssetPickerFilter.ImageVariants => "image-variants", _ => null }; var files = await assetManager.GetFilesAsync(type); - if (Filter == AssetPickerFilter.All) files = files.Where(x => x.TypeId is "sprite" or "audio" or "video" or "effect-program").ToArray(); + if (Filter == AssetPickerFilter.All) files = files.Where(x => x.TypeId is "sprite" or "audio" or "video" or "effect-program" or "image-variants").ToArray(); else if (Filter == AssetPickerFilter.Text) files = files.Where(x => IsTextPath(x.Path)).ToArray(); _allItems = await Task.WhenAll(files.Select(AssetPickerItem.CreateAsync)); - ApplyFilter(SearchBox?.Text); + ApplyFilter(null); } internal static bool IsTextPath(string path) => AssetPickerTextFileExtensions.IsSupported(path); private void ApplyFilter(string? search) { - var query = search?.Trim() ?? string.Empty; Items.Clear(); Items.Add(AssetPickerItem.Empty); - foreach (var item in _allItems.Where(x => string.IsNullOrWhiteSpace(query) || x.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || x.Path.Contains(query, StringComparison.OrdinalIgnoreCase))) Items.Add(item); + foreach (var item in _allItems) Items.Add(item); + PickerPanel.SetItems(Items.Select(item => new ResourcePickerItem(item.File?.Id ?? "", item.Name, item.Path, item.Icon, item.Thumbnail)).ToArray()); } private void NotifyDisplay(string assetId) diff --git a/src/Editor/GalNet.Editor/Controls/AuthorFormStyles.axaml b/src/Editor/GalNet.Editor/Controls/AuthorFormStyles.axaml new file mode 100644 index 00000000..6880310c --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/AuthorFormStyles.axaml @@ -0,0 +1,46 @@ + + + + + + diff --git a/src/Editor/GalNet.Editor/Controls/AuthoringExpressionFieldEditors.cs b/src/Editor/GalNet.Editor/Controls/AuthoringExpressionFieldEditors.cs new file mode 100644 index 00000000..96f9a46f --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/AuthoringExpressionFieldEditors.cs @@ -0,0 +1,68 @@ +using System.ComponentModel; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using GalNet.Core.Variable; +using GalNet.Editor.ViewModels; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Controls; + +/// Completion and expression feedback with the shared, project-owned input lifecycle. +internal static class AuthoringExpressionFieldEditors +{ + public static FieldEditorLease Expression(FieldEditorContext context, + Func> suggestions, Func> variables) + { + var control = new ConditionEditorControl { Name = "ExpressionInput" }; + return Bind(control, context, ConditionEditorControl.TextProperty, () => control.Text, value => control.Text = value, + () => { control.Suggestions = suggestions(); control.ValidationVariables = variables(); }); + } + + public static FieldEditorLease Variable(FieldEditorContext context, Func> suggestions) + { + var control = new ComboBox { Name = "VariableNameInput", IsEditable = true, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Stretch }; + return Bind(control, context, ComboBox.TextProperty, () => control.Text ?? "", value => control.Text = value, + () => control.ItemsSource = suggestions()); + } + + private static FieldEditorLease Bind(Control control, FieldEditorContext context, AvaloniaProperty property, + Func read, Action write, Action completion) + { + var syncing = false; var disposed = false; + bool Composing() => control.GetVisualDescendants().OfType().Any(presenter => !string.IsNullOrEmpty(presenter.PreeditText)); + var guard = context.DraftState?.RegisterGuard(() => !Composing()); + void Refresh() + { + if (disposed) return; + syncing = true; + try { completion(); write(context.Text); control.IsEnabled = context.IsEnabled; } + finally { syncing = false; } + } + void Changed(object? sender, PropertyChangedEventArgs args) => Refresh(); + void Edited(object? sender, AvaloniaPropertyChangedEventArgs args) + { if (!syncing && args.Property == property) context.Text = read(); } + void LostFocus(object? sender, RoutedEventArgs args) + { + Dispatcher.UIThread.Post(async () => { if (!disposed && !control.IsKeyboardFocusWithin && !Composing()) await context.CommitTextAsync(); }, DispatcherPriority.Background); + } + void KeyDown(object? sender, KeyEventArgs args) + { + if (args.Handled || Composing()) return; + if (args.Key == Key.Escape) { context.RevertDraft(); args.Handled = true; } + else if (args.Key == Key.Enter) { _ = context.CommitTextAsync(); args.Handled = true; } + } + context.PropertyChanged += Changed; control.PropertyChanged += Edited; + control.LostFocus += LostFocus; control.AddHandler(InputElement.KeyDownEvent, KeyDown, RoutingStrategies.Bubble); + Refresh(); + return new(control, () => + { + disposed = true; guard?.Dispose(); context.PropertyChanged -= Changed; control.PropertyChanged -= Edited; + control.LostFocus -= LostFocus; control.RemoveHandler(InputElement.KeyDownEvent, KeyDown); + }); + } +} diff --git a/src/Editor/GalNet.Editor/Controls/BranchConditionListEditorControl.axaml b/src/Editor/GalNet.Editor/Controls/BranchConditionListEditorControl.axaml index 61d195c5..2c251094 100644 --- a/src/Editor/GalNet.Editor/Controls/BranchConditionListEditorControl.axaml +++ b/src/Editor/GalNet.Editor/Controls/BranchConditionListEditorControl.axaml @@ -1,8 +1,8 @@ - + - - + diff --git a/src/Editor/GalNet.Editor/Controls/BranchOptionListEditorControl.axaml b/src/Editor/GalNet.Editor/Controls/BranchOptionListEditorControl.axaml index 6fbff3f9..20438cbe 100644 --- a/src/Editor/GalNet.Editor/Controls/BranchOptionListEditorControl.axaml +++ b/src/Editor/GalNet.Editor/Controls/BranchOptionListEditorControl.axaml @@ -1,8 +1,9 @@ - + + - - + diff --git a/src/Editor/GalNet.Editor/Controls/CharacterFieldEditor.cs b/src/Editor/GalNet.Editor/Controls/CharacterFieldEditor.cs new file mode 100644 index 00000000..16274274 --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/CharacterFieldEditor.cs @@ -0,0 +1,109 @@ +using System.ComponentModel; +using System.Text.Json; +using Avalonia.Controls; +using Avalonia.Controls.Presenters; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Markup.Xaml.MarkupExtensions; +using Avalonia.Threading; +using Avalonia.VisualTree; +using GalNet.Editor.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Controls; + +internal static class CharacterFieldEditor +{ + public static FieldEditorLease Create(FieldEditorContext context, CharacterAuthoringService characters) + { + var input = new TextBox { Name = "CharacterInput" }; + var choices = new ListBox { Name = "CharacterChoices", MaxHeight = 240, MinWidth = 260 }; + var border = new Border { Child = choices, BorderThickness = new(1) }; + border[!Border.BackgroundProperty] = new DynamicResourceExtension("SemiColorBackground0"); + border[!Border.BorderBrushProperty] = new DynamicResourceExtension("SemiColorBorder"); + var popup = new Popup { PlacementTarget = input, Placement = PlacementMode.Bottom, IsLightDismissEnabled = true, Child = border }; + var panel = new StackPanel { Children = { input, popup } }; + var lifetime = new CancellationTokenSource(); + var rows = characters.Rows(); + var disposed = false; var updating = false; var busy = false; var dirty = false; + bool Composing() => input.GetVisualDescendants().OfType().Any(presenter => !string.IsNullOrEmpty(presenter.PreeditText)); + var guard = context.DraftState?.RegisterGuard(() => !Composing()); + string CurrentKey() => context.Value.Json is { } json ? json.ValueKind == JsonValueKind.String ? json.GetString() ?? "" : json.GetRawText() : ""; + void Filter() + { + if (disposed) return; + if (!context.IsEnabled) { popup.IsOpen = false; return; } + var search = input.Text ?? ""; + choices.ItemsSource = rows.Where(row => row.Id.Contains(search, StringComparison.OrdinalIgnoreCase) || + row.DisplayName.Contains(search, StringComparison.OrdinalIgnoreCase)).Take(50).ToArray(); + choices.SelectedIndex = choices.ItemCount > 0 ? 0 : -1; + popup.IsOpen = input.IsFocused && choices.ItemCount > 0 && !busy; + } + void Refresh() + { + if (disposed) return; + input.IsEnabled = context.IsEnabled && !busy; + input.PlaceholderText = characters.Text["Characters.SpeakerHint"]; + if (context.HasDraft) + { + updating = true; input.Text = context.Text; updating = false; dirty = true; + } + else dirty = false; + if (!dirty && !context.HasDraft) + { + updating = true; + try { input.Text = context.IsEnabled ? rows.FirstOrDefault(row => row.Id == CurrentKey())?.DisplayName ?? CurrentKey() : CurrentKey(); } + finally { updating = false; } + } + } + async Task Commit(string? selected = null) + { + if (disposed || busy || !context.IsEnabled || Composing() || !dirty && selected is null) return; + busy = true; popup.IsOpen = false; Refresh(); + try + { + var value = selected ?? (string.IsNullOrWhiteSpace(input.Text) ? "" : input.Text); + if (await context.CommitAsync(new(JsonSerializer.SerializeToElement(value)), lifetime.Token)) + { rows = characters.Rows(); dirty = false; context.RevertDraft(); } + } + finally { busy = false; Refresh(); } + } + void QueueCommit() => Dispatcher.UIThread.Post(async () => + { + if (!disposed && !input.IsKeyboardFocusWithin && !choices.IsKeyboardFocusWithin) await Commit(); + }, DispatcherPriority.Background); + void Changed(object? sender, PropertyChangedEventArgs args) => Refresh(); + void SourceChanged() { if (!disposed && context.IsEnabled) { rows = characters.Rows(); Refresh(); if (input.IsFocused) Filter(); } } + // TextChanged is deferred; a programmatic refresh would outlive the updating guard. + input.PropertyChanged += (_, change) => + { + if (change.Property == TextBox.TextProperty && !updating && !disposed) + { dirty = true; context.Text = input.Text ?? ""; Filter(); if (!input.IsFocused) QueueCommit(); } + }; + input.GotFocus += (_, _) => Filter(); input.LostFocus += (_, _) => QueueCommit(); choices.LostFocus += (_, _) => QueueCommit(); + input.TextInput += (_, _) => { if (!input.IsFocused) QueueCommit(); }; + input.KeyDown += async (_, args) => + { + if (args.Key == Key.Enter && !Composing()) + { + args.Handled = true; + await Commit(popup.IsOpen && !string.IsNullOrWhiteSpace(input.Text) && choices.SelectedItem is CharacterRow row ? row.Id : null); + } + else if (args.Key == Key.Escape && !Composing()) { args.Handled = true; popup.IsOpen = false; dirty = false; context.RevertDraft(); Refresh(); } + else if (args.Key == Key.Down && choices.ItemCount > 0) { args.Handled = true; popup.IsOpen = true; choices.SelectedIndex = 0; choices.Focus(); } + }; + choices.KeyDown += async (_, args) => + { + if (args.Key == Key.Enter && !Composing() && choices.SelectedItem is CharacterRow row) { args.Handled = true; await Commit(row.Id); input.Focus(); popup.IsOpen = false; } + else if (args.Key == Key.Escape && !Composing()) { args.Handled = true; dirty = false; context.RevertDraft(); Refresh(); input.Focus(); popup.IsOpen = false; } + }; + choices.PointerReleased += async (_, args) => + { + if ((args.Source as Control)?.GetSelfAndVisualAncestors().OfType().FirstOrDefault()?.DataContext is CharacterRow row) + { await Commit(row.Id); input.Focus(); popup.IsOpen = false; } + }; + context.PropertyChanged += Changed; characters.Text.PropertyChanged += Changed; characters.Changed += SourceChanged; + Refresh(); + return new(panel, () => { disposed = true; guard?.Dispose(); popup.IsOpen = false; lifetime.Cancel(); lifetime.Dispose(); context.PropertyChanged -= Changed; characters.Text.PropertyChanged -= Changed; characters.Changed -= SourceChanged; }); + } +} diff --git a/src/Editor/GalNet.Editor/Controls/ColorFieldEditor.cs b/src/Editor/GalNet.Editor/Controls/ColorFieldEditor.cs new file mode 100644 index 00000000..46d39c18 --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/ColorFieldEditor.cs @@ -0,0 +1,92 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Controls; + +internal static class ColorFieldEditor +{ + public static FieldEditorLease Create(FieldEditorContext context) + { + var panel = new Grid { Name = "ColorEditor", ColumnDefinitions = new("Auto,*"), RowDefinitions = new("Auto,Auto"), ColumnSpacing = 8 }; + var swatch = new Border { Width = 24, Height = 18, BorderThickness = new(1), BorderBrush = Brushes.Gray }; + var button = new Button { Name = "PickColor", Content = swatch }; + var input = new TextBox { Name = "ColorHex", PlaceholderText = "#AARRGGBB", MinWidth = 90 }; + var error = new TextBlock { TextWrapping = TextWrapping.Wrap }; + Grid.SetColumn(input, 1); Grid.SetRow(error, 1); Grid.SetColumn(error, 1); + panel.Children.Add(button); panel.Children.Add(input); panel.Children.Add(error); + var picker = new ColorView { Name = "PropertyColorPicker", IsAlphaEnabled = true, IsAlphaVisible = true }; + var apply = new Button { Name = "ApplyColor" }; var cancel = new Button { Name = "CancelColor" }; + var popupButtons = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Right }; + popupButtons.Children.Add(apply); popupButtons.Children.Add(cancel); + var popup = new StackPanel { Spacing = 8, Margin = new(10), Width = 330 }; popup.Children.Add(picker); popup.Children.Add(popupButtons); + var flyout = new Flyout { Content = popup }; button.Flyout = flyout; + var syncing = false; var opened = false; var disposed = false; var ownCommit = false; var baseline = context.Value; + void ShowError(string? message) { error.Text = message; error.IsVisible = !string.IsNullOrEmpty(message); } + void Refresh() + { + if (disposed) return; + if (opened && (!context.IsEnabled || !Same(baseline, context.Value)) && !ownCommit) flyout.Hide(); + syncing = true; + try + { + input.Text = context.Text; + var valid = Color.TryParse(context.Text, out var color); + swatch.Background = valid ? new SolidColorBrush(color) : Brushes.Transparent; + panel.IsEnabled = context.IsEnabled; + ShowError(context.Text.Length > 0 && !valid ? PropertyEditorSupport.Text("Form.ColorInvalid") : context.Issue?.Message); + } + finally { syncing = false; } + } + async Task CommitText() + { + if (disposed || opened || !context.HasDraft) return; + if (context.Text.Length == 0 && context.CanOmit) { await context.OmitAsync(); return; } + if (!TryHex(context.Text, out var color)) { ShowError(PropertyEditorSupport.Text("Form.ColorInvalid")); return; } + await context.CommitValueAsync(Hex(color)); Refresh(); + } + void Changed(object? sender, PropertyChangedEventArgs args) => Refresh(); + input.TextChanged += (_, _) => { if (!syncing) { context.Text = input.Text ?? ""; } }; + input.LostFocus += async (_, _) => await CommitText(); + input.AddHandler(InputElement.KeyDownEvent, async (_, args) => + { + if (args.Key == Key.Escape) { context.RevertDraft(); Refresh(); args.Handled = true; } + else if (args.Key == Key.Enter) { await CommitText(); args.Handled = true; } + }, RoutingStrategies.Tunnel); + flyout.Opened += (_, _) => + { + context.RevertDraft(); baseline = context.Value; opened = true; + picker.Color = Color.TryParse(context.Text, out var color) ? color : Colors.Transparent; + }; + flyout.Closed += (_, _) => { opened = false; Refresh(); }; + apply.Click += async (_, _) => + { + if (!opened || disposed || !context.IsEnabled || !Same(baseline, context.Value)) { flyout.Hide(); return; } + ownCommit = true; + try { if (await context.CommitValueAsync(Hex(picker.Color))) flyout.Hide(); } + finally { ownCommit = false; Refresh(); } + }; + cancel.Click += (_, _) => flyout.Hide(); + var locale = PropertyEditorSupport.Localize(() => + { + apply.Content = PropertyEditorSupport.Text("Common.Apply"); cancel.Content = PropertyEditorSupport.Text("Common.Cancel"); + var name = PropertyEditorSupport.Text("Form.PickColor"); ToolTip.SetTip(button, name); + global::Avalonia.Automation.AutomationProperties.SetName(button, name); Refresh(); + }); + context.PropertyChanged += Changed; Refresh(); + return new(panel, () => { disposed = true; flyout.Hide(); context.PropertyChanged -= Changed; locale.Dispose(); }); + } + private static bool Same(FieldValue a, FieldValue b) => a.IsProvided == b.IsProvided && (!a.IsProvided || JsonElement.DeepEquals(a.Json!.Value, b.Json!.Value)); + private static bool TryHex(string text, out Color color) + { + color = default; + return text.Length is 7 or 9 && text[0] == '#' && uint.TryParse(text.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _) && Color.TryParse(text, out color); + } + private static string Hex(Color color) => color.A == 255 ? $"#{color.R:X2}{color.G:X2}{color.B:X2}" : $"#{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}"; +} diff --git a/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml b/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml index 5f120313..b8214410 100644 --- a/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml +++ b/src/Editor/GalNet.Editor/Controls/ConditionEditorControl.axaml @@ -1,11 +1,13 @@ + Text="{Binding Text, ElementName=Root, Mode=TwoWay}" + PlaceholderText="{loc:Localize Condition.Placeholder}" /> Editor-owned presentation extensions; value conversion and submission stay in the shared factory. public static class EditorFieldEditors { - public static EditorRegistry CreateDefault() + public static EditorRegistry CreateDefault(SceneTargetCatalog? scenes = null, Action? openTimeline = null, GalNet.Core.Assets.IAssetManager? assets = null, CharacterAuthoringService? characters = null, GameTextAuthoringService? texts = null) { - var registry = EditorRegistry.CreateDefault(); + var registry = EditorRegistry.CreateDefault(emptyInputClearsOptionalFields: true, copyValue: texts is null ? null : texts.StageCopy); + if (characters is not null) registry.RegisterSemantic(FieldValueSemantic.CharacterReference, context => CharacterFieldEditor.Create(context, characters)); + if (texts is not null) registry.RegisterSemantic(FieldValueSemantic.LocalizedText, context => GameTextFieldEditor.Create(context, texts)); + registry.Register("entry.imageasset", context => AssetFieldEditor.Create(context, assets, Models.AssetPickerFilter.Image)); + registry.Register("entry.variantsasset", context => AssetFieldEditor.Create(context, assets, Models.AssetPickerFilter.ImageVariants)); + registry.Register("entry.effectprogramasset", context => AssetFieldEditor.Create(context, assets, Models.AssetPickerFilter.EffectProgram)); + registry.Register("entry.audioasset", context => AssetFieldEditor.Create(context, assets, Models.AssetPickerFilter.Audio)); + registry.Register("entry.videoasset", context => AssetFieldEditor.Create(context, assets, Models.AssetPickerFilter.Video)); + foreach (var id in new[] { "entry.autocomplete", "entry.multilinetext" }) + registry.Register(id, context => DefaultFieldEditors.CreateText(context)); + registry.Register("entry.expression", context => AuthoringExpressionFieldEditors.Expression(context, () => [], () => [])); + registry.Register("entry.variablename", context => AuthoringExpressionFieldEditors.Variable(context, () => [])); + registry.RegisterDefault(TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.ToTransform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Transform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.ToTransform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.ToTransform, TransformFieldEditor.Create); + registry.RegisterMember(value => value.Color, ColorFieldEditor.Create); + registry.RegisterMember(value => value.Color, ColorFieldEditor.Create); + registry.RegisterMember(value => value.Timeline, context => TimelineFieldEditor.Create(context, openTimeline)); registry.Replace(ValueEditorIds.Choice, context => { var lease = DefaultFieldEditors.CreateChoice(context); ((ComboBox)lease.Control).ItemTemplate = new FuncDataTemplate((option, _) => { - var label = new TextBlock { DataContext = option?.Label }; - if (option?.Label is EntrySelectOption) + var localizedOption = option?.Label switch { - var value = new LocalizeExtension("DisplayNameKey").ProvideValue(null!); + EntrySelectOption entryOption => entryOption, + string text => new EntrySelectOption(text, $"Entry.Option.{text}"), + _ => null + }; + var label = new TextBlock { DataContext = localizedOption ?? option?.Label }; + if (localizedOption is not null) + { + var value = new LocalizeExtension { Path = "DisplayNameKey", FallbackPath = "Value" }.ProvideValue(null!); if (value is BindingBase binding) label.Bind(TextBlock.TextProperty, binding); - else label.Text = ((EntrySelectOption)option.Label).DisplayNameKey; + else label.Text = localizedOption.Value; } else label.Text = option?.Label?.ToString(); return label; }); return lease; }); + if (scenes is not null) + { + registry.RegisterMember(options => options.Selection, context => VariantFieldEditor.Create(context, scenes, selection: true)); + registry.RegisterMember(options => options.Dimension, context => VariantFieldEditor.Create(context, scenes, selection: false, dimension: true)); + registry.RegisterMember(options => options.Value, context => VariantFieldEditor.Create(context, scenes, selection: false)); + FieldEditorLease Target(FieldEditorContext context) => SceneTargetFieldEditor.Create(context, scenes); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.PlaybackHandleId, Target); + registry.RegisterMember(options => options.HandleId, Target); + registry.RegisterMember(options => options.InstanceId, Target); + registry.RegisterMember(options => options.TargetHandleId, Target); + registry.RegisterMember(options => options.OldHandleId, Target); + registry.RegisterMember(options => options.OldHandleId, Target); + registry.RegisterMember(options => options.FromLayerHandleId, Target); + registry.RegisterMember(options => options.FromLayerHandleId, Target); + registry.RegisterMember(options => options.FromLayerHandleId, Target); + } return registry; } } diff --git a/src/Editor/GalNet.Editor/Controls/EntryListEditorControl.axaml b/src/Editor/GalNet.Editor/Controls/EntryListEditorControl.axaml index 42323d9e..81278a18 100644 --- a/src/Editor/GalNet.Editor/Controls/EntryListEditorControl.axaml +++ b/src/Editor/GalNet.Editor/Controls/EntryListEditorControl.axaml @@ -7,50 +7,38 @@ x:Class="GalNet.Editor.Controls.EntryListEditorControl" x:Name="Root" x:DataType="vm:GroupEditorPanelViewModel" - MoveCommand="{Binding ReorderEntryCommand}"> + MoveCommand="{Binding ReorderEntryCommand}" Focusable="True"> - + + + - + + + + + + + Margin="0,8,4,0" + ItemsSource="{Binding VisibleEntries}" + SelectedItems="{Binding SelectedEntries, Mode=OneWay}" + SelectionMode="Multiple" + Background="Transparent" Padding="0"> + - - + VerticalAlignment="Center" /> + SelectedValue="{Binding Type, Mode=OneWay}" TypeConfirmed="OnEntryTypeConfirmed" + MinHeight="32" VerticalAlignment="Center" HorizontalAlignment="Stretch" /> + + + + + - - + + + + + + - + - - - - - + + + + + @@ -116,17 +118,15 @@ + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + x:DataType="vm:VariableListEditorViewModel"> + + + + + + + + + + + + + - - - - - - + + + + + + diff --git a/src/Editor/GalNet.Editor/Controls/VariableListEditorControl.axaml.cs b/src/Editor/GalNet.Editor/Controls/VariableListEditorControl.axaml.cs index 2e8d4235..7da31351 100644 --- a/src/Editor/GalNet.Editor/Controls/VariableListEditorControl.axaml.cs +++ b/src/Editor/GalNet.Editor/Controls/VariableListEditorControl.axaml.cs @@ -2,11 +2,10 @@ namespace GalNet.Editor.Controls; -public partial class VariableListEditorControl : ReorderableListControl +public partial class VariableListEditorControl : UserControl { public VariableListEditorControl() { InitializeComponent(); - InitializeDragDrop(ItemsListBox); } } diff --git a/src/Editor/GalNet.Editor/Controls/VariantFieldEditor.cs b/src/Editor/GalNet.Editor/Controls/VariantFieldEditor.cs new file mode 100644 index 00000000..2cda88d6 --- /dev/null +++ b/src/Editor/GalNet.Editor/Controls/VariantFieldEditor.cs @@ -0,0 +1,81 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Nodes; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Controls; + +internal static class VariantFieldEditor +{ + public static FieldEditorLease Create(FieldEditorContext context, SceneTargetCatalog scenes, bool selection, bool dimension = false) + { + var panel = new StackPanel { Spacing = 6, Name = selection ? "VariantInitialSelection" : "VariantDependentField" }; + var choices = new StackPanel { Spacing = 4 }; panel.Children.Add(choices); + var manual = selection ? null : DefaultFieldEditors.CreateText(context); + var repair = selection ? PropertyEditorSupport.Repair(context) : default; + if (manual is not null) panel.Children.Add(manual.Control); else panel.Children.Add(repair.Button!); + var lifetime = new CancellationTokenSource(); var disposed = false; var generation = 0; + void Refresh() + { + if (disposed) return; + var version = ++generation; choices.Children.Clear(); + var source = scenes.VariantsSource(context.HostContext as IEntryFieldOrigin); + if (source?.Definition is not { } definition) return; + var before = context.Value; + async Task Commit(string key, string value) + { + if (disposed || version != generation || !context.IsEnabled || !scenes.IsCurrent(source.Snapshot) + || before.IsProvided != context.Value.IsProvided || before.IsProvided && !JsonElement.DeepEquals(before.Json!.Value, context.Value.Json!.Value)) return; + var next = JsonSerializer.SerializeToElement(value); + if (selection) + { + JsonObject json; + try { json = context.EffectiveValue is { ValueKind: JsonValueKind.Object } current ? JsonNode.Parse(current.GetRawText())!.AsObject() : new(); } + catch (JsonException) { return; } + if (value.Length == 0) json.Remove(key); else json[key] = value; + next = JsonSerializer.SerializeToElement(json); + } + try { await context.CommitAsync(new(next), lifetime.Token); } + catch (OperationCanceledException) { } + } + void Add(string key, string label, IReadOnlyList options, string? current) + { + var combo = new ComboBox { Name = "VariantFieldChoice", ItemsSource = options, SelectedItem = options.Contains(current ?? "", StringComparer.Ordinal) ? current : null, PlaceholderText = label }; + if (selection) combo.ItemTemplate = new FuncDataTemplate((item, _) => new TextBlock { Text = string.IsNullOrEmpty(item) ? scenes.Text["Variants.Inherit"] : item }); + combo.IsEnabled = context.IsEnabled; + choices.Children.Add(new TextBlock { Text = label }); choices.Children.Add(combo); + combo.SelectionChanged += async (_, _) => { if (combo.SelectedItem is string value) await Commit(key, value); }; + } + if (selection) + { + // Unknown keys and invalid values stay available through the JSON repair editor. + if (context.EffectiveValue is not { ValueKind: JsonValueKind.Object } values) return; + foreach (var item in definition.Dimensions) + { + var value = values.TryGetProperty(item.Key, out var raw) && raw.ValueKind == JsonValueKind.String ? raw.GetString() : ""; + Add(item.Key, item.Label ?? item.Key, new[] { "" }.Concat(item.Options).ToArray(), value); + } + } + else + { + var options = dimension ? definition.Dimensions.Select(item => item.Key).ToArray() : definition.Dimensions.FirstOrDefault(item => item.Key == source.Dimension)?.Options ?? []; + Add("", scenes.Text[dimension ? "Entry.Parameter.dimension" : "Entry.Parameter.value"], options, + context.EffectiveValue is { ValueKind: JsonValueKind.String } current ? current.GetString() : null); + } + } + void Changed(object? sender, PropertyChangedEventArgs args) => Refresh(); + async Task Prepare() + { + if (context.HostContext is not IEntryFieldOrigin { GroupId: { } group } origin) return; + try { await scenes.PrepareAsync(group, origin.EntryId, token: lifetime.Token); if (!disposed) Refresh(); } + catch (OperationCanceledException) { } + catch (Exception error) { if (!disposed) context.ReportIssue(new("variants.load", error.Message)); } + } + context.PropertyChanged += Changed; scenes.Text.PropertyChanged += Changed; scenes.Changed += Refresh; Refresh(); _ = Prepare(); + return new(panel, () => { disposed = true; generation++; lifetime.Cancel(); context.PropertyChanged -= Changed; scenes.Text.PropertyChanged -= Changed; scenes.Changed -= Refresh; manual?.Dispose(); repair.Lifetime?.Dispose(); lifetime.Dispose(); }); + } +} diff --git a/src/Editor/GalNet.Editor/Dock/BuiltInDockContributions.cs b/src/Editor/GalNet.Editor/Dock/BuiltInDockContributions.cs index 0b87e2b3..10bf89b4 100644 --- a/src/Editor/GalNet.Editor/Dock/BuiltInDockContributions.cs +++ b/src/Editor/GalNet.Editor/Dock/BuiltInDockContributions.cs @@ -22,13 +22,38 @@ public static class EditorDockPanelIds public const string Assets = "Assets"; public const string Log = "Log"; public const string GroupEditor = "GroupEditor"; + public const string FileEditor = "FileEditor"; public const string Inspector = "Inspector"; + public const string Variables = "Variables"; + public const string Characters = "Characters"; + public const string Localization = "Localization"; + public const string Timeline = "Timeline"; + public const string ExternalDebug = "ExternalDebug"; + public const string Problems = "Problems"; } public static class BuiltInDockContributions { public static void Register(IEditorExtensionRegistry registry) { + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.Problems, "Uiux.Problems", DockPanelPlacement.BottomDocument, true, true, true, false, true, + sp => sp.GetRequiredService(), typeof(ProblemsPanelView), null)); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.ExternalDebug, "Debug.Panel", DockPanelPlacement.BottomDocument, true, true, true, false, true, + sp => sp.GetRequiredService(), typeof(ExternalDebugPanelView), null)); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.Localization, "Dock.Panel.Localization", DockPanelPlacement.MainDocument, true, true, true, false, true, + sp => sp.GetRequiredService(), typeof(GameLocalizationPanelView), null)); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.Characters, "Dock.Panel.Characters", DockPanelPlacement.MainDocument, true, true, true, false, true, + sp => sp.GetRequiredService(), typeof(CharactersPanelView), null)); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.Timeline, "Dock.Panel.Timeline", DockPanelPlacement.BottomDocument, true, true, true, false, true, + sp => sp.GetRequiredService(), typeof(TimelinePanelView), null)); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.Variables, "Dock.Panel.Variables", DockPanelPlacement.MainDocument, true, true, true, false, true, + sp => { var vm = sp.GetRequiredService(); vm.Navigation = sp.GetService(); return vm; }, typeof(VariablesPanelView), null)); registry.RegisterDockPanel(new DelegateDockPanelContribution( EditorDockPanelIds.NodeGraph, "Dock.Panel.NodeGraph", DockPanelPlacement.MainDocument, true, true, true, true, true, sp => sp.GetRequiredService(), typeof(NodeGraphPanelView), @@ -41,19 +66,25 @@ public static void Register(IEditorExtensionRegistry registry) (sp, _) => sp.GetRequiredService(), typeof(AssetInspectorControl)))); registry.RegisterDockPanel(new DelegateDockPanelContribution( EditorDockPanelIds.Log, "Dock.Panel.Log", DockPanelPlacement.BottomDocument, true, true, true, true, true, - sp => sp.GetRequiredService(), typeof(LogPanelView), null)); + sp => { var vm = sp.GetRequiredService(); vm.Navigation = sp.GetService(); return vm; }, typeof(LogPanelView), null)); registry.RegisterDockPanel(new DelegateDockPanelContribution( EditorDockPanelIds.GroupEditor, "Dock.Panel.GroupEditor", DockPanelPlacement.MainDocument, false, true, true, false, false, (sp, node) => new GroupEditorPanelViewModel( sp.GetRequiredService(), node, - sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService()), - typeof(GroupEditorPanelView), null)); + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()), + typeof(GroupEditorPanelView), new DelegateInspectorContribution( + (sp, page) => new EntryInspectorViewModel(page, sp.GetService()), typeof(EntryInspectorView)))); + registry.RegisterDockPanel(new DelegateDockPanelContribution( + EditorDockPanelIds.FileEditor, "Files.Document", DockPanelPlacement.MainDocument, false, true, true, false, false, + (sp, id) => new FileDocumentPanelViewModel(sp.GetRequiredService().Get(id)), typeof(FileDocumentPanelView), null)); registry.RegisterDockPanel(new DelegateDockPanelContribution( EditorDockPanelIds.Inspector, "Dock.Panel.Inspector", DockPanelPlacement.InspectorDocument, false, true, true, true, true, sp => sp.GetRequiredService(), typeof(InspectorHostView), null)); diff --git a/src/Editor/GalNet.Editor/Dock/DockInspectorCoordinator.cs b/src/Editor/GalNet.Editor/Dock/DockInspectorCoordinator.cs index 72a8a36d..2ca94734 100644 --- a/src/Editor/GalNet.Editor/Dock/DockInspectorCoordinator.cs +++ b/src/Editor/GalNet.Editor/Dock/DockInspectorCoordinator.cs @@ -78,7 +78,7 @@ public void UpdateFor(IDockable? dockable) if (dockable is null || !_panelByDockable.TryGetValue(dockable, out var panel)) return; - if (panel.PanelId == EditorDockPanelIds.Inspector) + if (panel.PanelId == EditorDockPanelIds.Inspector || panel.Inspector is null) return; _lastInspectableDockable = dockable; @@ -99,7 +99,7 @@ private void UpdateSingle(InspectorHostViewModel inspector, IDockable dockable, return; if (panel.Inspector is null || dockable.Context is null) { - if (!isInitialTarget) inspector.FollowNoInspector(); + // Auxiliary docks (timeline, logs, problems) do not replace the author's detail target. return; } @@ -128,7 +128,7 @@ public void OnWorkspacePropertyChanged(object? sender, PropertyChangedEventArgs if (panelId is null) return; var dockable = findDockable(panelId); - if (dockable is not null) + if (dockable is not null && ReferenceEquals(dockable, _lastInspectableDockable)) UpdateFor(dockable); } } diff --git a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs index 2881ac7b..9dcc5679 100644 --- a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs +++ b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs @@ -31,6 +31,10 @@ public sealed class EditorDockFactory : Factory, IDisposable private bool _activeDockableHandlerAttached; private bool _dockableEventHandlersAttached; private DocumentDock? _documentDock; + private IRootDock? _layoutRoot; + private DocumentDock? _presetInspector; + private (IDock Owner, IDockable Dock, int Index, IDockable[] RootItems, IDockable? RootActive)? _maximized; + public bool IsMaximized => _maximized is not null; private readonly PropertyChangedEventHandler _localizationChanged; /// Raised after a panel is created, closed, moved, or activated. @@ -58,6 +62,7 @@ public EditorDockFactory( public override IRootDock CreateLayout() { + _maximized = null; _documentDock = null; DisposeGroupPages(); _panelByDockable.Clear(); @@ -85,7 +90,7 @@ public override IRootDock CreateLayout() { Id = "InspectorDocuments", Title = "Inspector", - Proportion = 0.32, + Proportion = 0.28, ActiveDockable = inspectorDocument, VisibleDockables = CreateList([inspectorDocument]), EnableGlobalDocking = true @@ -95,7 +100,7 @@ public override IRootDock CreateLayout() { Id = "Center", Title = "Center", - Proportion = 0.68, + Proportion = 0.72, Orientation = Orientation.Vertical, ActiveDockable = _documentDock, VisibleDockables = CreateList( @@ -105,7 +110,8 @@ public override IRootDock CreateLayout() new DocumentDock { Id = "LogDocuments", Title = "Log", - ActiveDockable = logDocument, + Proportion = 0.22, + ActiveDockable = assetsDocument, VisibleDockables = CreateList(CreateDefaultPanels(DockPanelPlacement.BottomDocument, logDocument, assetsDocument).ToArray()), EnableGlobalDocking = true } @@ -122,6 +128,8 @@ public override IRootDock CreateLayout() }; var rootDock = CreateRootDock(); + _documentDock.Proportion = 0.78; + _presetInspector = inspectorDock; rootDock.Id = "Root"; rootDock.IsCollapsable = false; rootDock.ActiveDockable = mainDock; rootDock.DefaultDockable = mainDock; rootDock.VisibleDockables = CreateList([mainDock]); @@ -145,6 +153,7 @@ public override IRootDock CreateLayout() public bool PrepareRestoredLayout(IRootDock layout) { + _maximized = null; DisposeGroupPages(); _panelByDockable.Clear(); _inspector.Clear(); @@ -169,6 +178,7 @@ public bool PrepareRestoredLayout(IRootDock layout) } var services = _serviceProvider.GetService()?.Current?.Services ?? _serviceProvider; + if (panel.PanelId == EditorDockPanelIds.FileEditor) { RemoveRestoredDocument(document); continue; } if (panel.PanelId == EditorDockPanelIds.GroupEditor) { var groupId = GetGroupEditorId(document.Id); @@ -226,6 +236,12 @@ public void ToggleGlobalPanel(string panelId) public void OpenPanel(string panelId) { + if (panelId == EditorDockPanelIds.Inspector && _layoutRoot is not null && _presetInspector is { Owner: null } inspectorDock + && FindDockById(_layoutRoot, "Main") is ProportionalDock main) + { + main.VisibleDockables ??= CreateList(); + main.VisibleDockables.Add(new ProportionalDockSplitter()); main.VisibleDockables.Add(inspectorDock); inspectorDock.Owner = main; + } var panel = _extensions.FindDockPanel(panelId); if (panel is null) return; @@ -246,20 +262,61 @@ public void OpenPanel(string panelId) LayoutChanged?.Invoke(); } + public void ShowInspector() + { + var existing = _panelByDockable.FirstOrDefault(pair => pair.Value.PanelId == EditorDockPanelIds.Inspector + && pair.Key.Context is InspectorHostViewModel { IsLocked: false } && IsAttached(pair.Key)).Key; + if (existing is null) { OpenPanel(EditorDockPanelIds.Inspector); return; } + if (existing.Owner is DocumentDock { Owner: null } inspectorDock && _layoutRoot is not null + && FindDockById(_layoutRoot, "Main") is ProportionalDock main) + { + main.VisibleDockables ??= CreateList(); main.VisibleDockables.Add(new ProportionalDockSplitter()); + main.VisibleDockables.Add(inspectorDock); inspectorDock.Owner = main; + } + Activate(existing); + } + public void OpenGroupEditor(GraphNode groupNode) { if (_documentDock?.VisibleDockables is null) return; var documentId = GetGroupEditorDocumentId(groupNode.Id); var existing = _documentDock.VisibleDockables.FirstOrDefault(d => d.Id == documentId); - if (existing is not null) { _documentDock.ActiveDockable = existing; return; } + if (existing is not null) { _documentDock.ActiveDockable = existing; (existing.Context as GroupEditorPanelViewModel)?.PublishSelection(); return; } var document = CreateDocument(_extensions.FindDockPanel(EditorDockPanelIds.GroupEditor)!, groupNode.Id, documentId, [groupNode.Name]); _documentDock.VisibleDockables.Add(document); _documentDock.ActiveDockable = document; + (document.Context as GroupEditorPanelViewModel)?.PublishSelection(); LayoutChanged?.Invoke(); } + public void OpenTimeline(string groupId, string entryId) + { + var group = _workspace?.Nodes.FirstOrDefault(node => node.Id == groupId); + var entry = group?.Entries.FirstOrDefault(item => item.StableId == entryId); + if (group is null || entry is null || entry.Type != GalNet.Contracts.Builtins.BuiltinEntryIds.PlayTimeline) return; + OpenGroupEditor(group); + var page = _documentDock?.VisibleDockables?.FirstOrDefault(item => item.Id == GetGroupEditorDocumentId(groupId))?.Context as GroupEditorPanelViewModel; + if (page is null || page.IsDisposed) return; + page.SelectedEntry = entry; page.PublishSelection(); + OpenPanel(EditorDockPanelIds.Timeline); + } + + public void NavigateLocalization(string owner, string? entryId) + { + if (owner == "characters") { OpenPanel(EditorDockPanelIds.Characters); return; } + var node = _workspace?.Nodes.FirstOrDefault(node => node.Id == owner); + if (node is null) return; + _workspace!.SelectNode(node); + if (node.NodeKind != GalNet.Editor.Models.Graph.GraphNodeKind.LinearGroup) { OpenPanel(EditorDockPanelIds.NodeGraph); ShowInspector(); return; } + OpenGroupEditor(node); + var page = _documentDock?.VisibleDockables?.FirstOrDefault(item => item.Id == GetGroupEditorDocumentId(owner))?.Context as GroupEditorPanelViewModel; + if (page is null || page.IsDisposed) return; + page.SearchText = ""; + page.SelectedEntry = node.Entries.FirstOrDefault(entry => entry.StableId == entryId); page.PublishSelection(); + } + public void CloseGroupEditor(string groupId) { if (_documentDock?.VisibleDockables is null) return; @@ -267,10 +324,26 @@ public void CloseGroupEditor(string groupId) if (document is not null) CloseDocument(document); } + public void OpenFileEditor(ProjectTextBuffer buffer) + { + if (_documentDock?.VisibleDockables is null) return; + var id = "FileEditor:" + buffer.Id; + var existing = _panelByDockable.Keys.FirstOrDefault(item => item.Id == id); + if (existing is not null) { if (existing.Owner is null) _documentDock.ActiveDockable = existing; else Activate(existing); return; } + var document = CreateDocument(_extensions.FindDockPanel(EditorDockPanelIds.FileEditor)!, buffer.Id, id); + var viewModel = (FileDocumentPanelViewModel)document.Context!; + document.Title = viewModel.Title; + viewModel.PropertyChanged += (_, args) => { if (args.PropertyName == nameof(FileDocumentPanelViewModel.Title)) document.Title = viewModel.Title; }; + AddDocument(document, _extensions.FindDockPanel(EditorDockPanelIds.FileEditor)!); Activate(document); + LayoutChanged?.Invoke(); + } + // ── Init ── public override void InitLayout(IDockable layout) { + _layoutRoot = layout as IRootDock; + _presetInspector = FindDockById(layout, "InspectorDocuments") as DocumentDock ?? _presetInspector; Log.Information("[DockFactory] InitLayout called"); HostWindowLocator = new Dictionary> @@ -289,10 +362,18 @@ public override void InitLayout(IDockable layout) ActiveDockableChanged += (_, args) => { _inspector.UpdateFor(args.Dockable); + if (args.Dockable?.Context is GroupEditorPanelViewModel group) group.PublishSelection(); var services = _serviceProvider.GetService()?.Current?.Services; services?.GetService()?.SetActive(args.Dockable?.Context); LayoutChanged?.Invoke(); }; + FocusedDockableChanged += (_, args) => + { + _inspector.UpdateFor(args.Dockable); + if (args.Dockable?.Context is GroupEditorPanelViewModel group) group.PublishSelection(); + var services = _serviceProvider.GetService()?.Current?.Services; + services?.GetService()?.SetActive(args.Dockable?.Context); + }; _activeDockableHandlerAttached = true; } @@ -304,6 +385,12 @@ public override void InitLayout(IDockable layout) { if (args.Dockable is not { } closed) return; if (closed.Context is GroupEditorPanelViewModel group) group.Dispose(); + if (closed.Context is FileDocumentPanelViewModel file) file.Dispose(); + if (closed.Context is VariablesPanelViewModel variables) variables.Dispose(); + if (closed.Context is CharactersPanelViewModel characters) characters.Dispose(); + if (closed.Context is TimelinePanelViewModel timeline) timeline.Dispose(); + if (closed.Context is LogPanelViewModel log) log.Dispose(); + if (closed.Context is InspectorHostViewModel) _inspector.RemoveInspector(closed); _panelByDockable.Remove(closed); }; DockableMoved += (_, args) => { Log.Information("[DockFactory] DockableMoved: {Id}", args.Dockable?.Id); LayoutChanged?.Invoke(); }; @@ -313,6 +400,109 @@ public override void InitLayout(IDockable layout) base.InitLayout(layout); } + public void ApplyWorkspacePreset(string preset) + { + if (preset is not ("Writing" or "Animation" or "Debug") || _layoutRoot is null) return; + if (_maximized is not null) ToggleMaximizeActive(); + var main = FindDockById(_layoutRoot, "Main") as ProportionalDock; + var center = FindDockById(_layoutRoot, "Center") as ProportionalDock; + var bottom = FindDockById(_layoutRoot, "LogDocuments") as DocumentDock; + var documents = FindDockById(_layoutRoot, "Documents") as DocumentDock; + if (main is null || center is null || bottom is null || documents is null) + { + RebuildWorkspaceContainers(); + main = FindDockById(_layoutRoot, "Main") as ProportionalDock; + center = FindDockById(_layoutRoot, "Center") as ProportionalDock; + bottom = FindDockById(_layoutRoot, "LogDocuments") as DocumentDock; + documents = _documentDock; + } + if (main is null || center is null || bottom is null || documents is null) return; + var height = preset == "Writing" ? 0.22 : preset == "Animation" ? 0.45 : 0.40; + bottom.Proportion = height; documents.Proportion = 1 - height; + main.VisibleDockables = CreateList([center]); center.Owner = main; + center.Proportion = preset == "Animation" ? 1 : 0.72; + if (_presetInspector is { } inspector) + { + inspector.Owner = null; + if (preset != "Animation") + { + inspector.Proportion = 0.28; inspector.Owner = main; + main.VisibleDockables.Add(new ProportionalDockSplitter()); main.VisibleDockables.Add(inspector); + } + } + if (preset != "Animation" && _presetInspector?.VisibleDockables?.Any(item => item.Context is InspectorHostViewModel) != true) + OpenPanel(EditorDockPanelIds.Inspector); + var id = preset == "Writing" ? EditorDockPanelIds.Assets : preset == "Animation" ? EditorDockPanelIds.Timeline : EditorDockPanelIds.ExternalDebug; + OpenPanel(id); + if (documents.ActiveDockable is { } authorTarget) _inspector.UpdateFor(authorTarget); + LayoutChanged?.Invoke(); + } + + private void RebuildWorkspaceContainers() + { + if (_layoutRoot is null) return; + var activeDocument = _documentDock?.ActiveDockable; + var documents = new DocumentDock { Id = "Documents", IsCollapsable = false, EnableGlobalDocking = true, VisibleDockables = CreateList() }; + var bottom = new DocumentDock { Id = "LogDocuments", EnableGlobalDocking = true, VisibleDockables = CreateList() }; + var inspector = new DocumentDock { Id = "InspectorDocuments", EnableGlobalDocking = true, VisibleDockables = CreateList() }; + // Dock may collapse or replace empty containers after a user closes/moves a panel. + // Reparent existing documents, preserving their contexts, selections, and draft leases. + foreach (var pair in _panelByDockable.ToArray()) + { + if (pair.Key is not Document document) continue; + if (document.Owner is IDock oldOwner) oldOwner.VisibleDockables?.Remove(document); + var owner = pair.Value.Placement switch + { + DockPanelPlacement.BottomDocument => bottom, + DockPanelPlacement.InspectorDocument => inspector, + _ => documents + }; + owner.VisibleDockables!.Add(document); document.Owner = owner; + } + documents.ActiveDockable = activeDocument is not null && documents.VisibleDockables!.Contains(activeDocument) ? activeDocument : documents.VisibleDockables!.FirstOrDefault(); + bottom.ActiveDockable = bottom.VisibleDockables!.FirstOrDefault(); inspector.ActiveDockable = inspector.VisibleDockables!.FirstOrDefault(); + var center = new ProportionalDock + { + Id = "Center", + Orientation = Orientation.Vertical, + ActiveDockable = documents, + VisibleDockables = CreateList([documents, new ProportionalDockSplitter(), bottom]) + }; + var main = new ProportionalDock + { + Id = "Main", + Orientation = Orientation.Horizontal, + ActiveDockable = center, + VisibleDockables = CreateList([center, new ProportionalDockSplitter(), inspector]) + }; + _layoutRoot.VisibleDockables = CreateList([main]); _layoutRoot.ActiveDockable = main; _layoutRoot.DefaultDockable = main; + _documentDock = documents; _presetInspector = inspector; + InitLayout(_layoutRoot); + } + + public void ToggleMaximizeActive() + { + if (_layoutRoot is null) return; + if (_maximized is { } saved) + { + saved.Owner.VisibleDockables!.Insert(Math.Min(saved.Index, saved.Owner.VisibleDockables.Count), saved.Dock); + saved.Dock.Owner = saved.Owner; + _layoutRoot.VisibleDockables = CreateList(saved.RootItems); _layoutRoot.ActiveDockable = saved.RootActive; + _maximized = null; + } + else + { + var target = FindActiveInspectableDockable(_layoutRoot); + if (target?.Owner is not IDock dock || dock.Owner is not IDock owner || owner.VisibleDockables is null) return; + var index = owner.VisibleDockables.IndexOf(dock); + if (index < 0) return; + _maximized = (owner, dock, index, _layoutRoot.VisibleDockables?.ToArray() ?? [], _layoutRoot.ActiveDockable); + owner.VisibleDockables.Remove(dock); dock.Owner = _layoutRoot; + _layoutRoot.VisibleDockables = CreateList([dock]); _layoutRoot.ActiveDockable = dock; + } + LayoutChanged?.Invoke(); + } + // ── Document helpers ── private Document CreateDocument(IDockPanelContribution panel, object? parameter = null, string? id = null, object[]? titleArguments = null) @@ -339,7 +529,7 @@ private void RefreshDockTitles() foreach (var (dockable, panel) in _panelByDockable) dockable.Title = panel.PanelId == EditorDockPanelIds.GroupEditor && dockable.Context is GroupEditorPanelViewModel group ? LocalizeTitle(panel, [group.GroupNode.Name]) - : LocalizeTitle(panel); + : dockable.Context is FileDocumentPanelViewModel file ? file.Title : LocalizeTitle(panel); } private static string GetGroupEditorDocumentId(string groupId) => $"GroupEditor:{groupId}"; @@ -390,7 +580,9 @@ private void CloseDocument(IDockable document) owner.ActiveDockable = owner.VisibleDockables.FirstOrDefault(); _inspector.RemoveInspectorAndRecover(document, () => FindActiveInspectableDockable(FactoryExtensions.GetActiveRoot(this))); _panelByDockable.Remove(document); - (document.Context as IDisposable)?.Dispose(); + // Localization drafts belong to the project scope and survive dock closure. + if (document.Context is not (GameLocalizationPanelViewModel or AssetPanelViewModel or ProblemsPanelViewModel or EditorWorkspaceViewModel)) + (document.Context as IDisposable)?.Dispose(); LayoutChanged?.Invoke(); } @@ -408,6 +600,10 @@ private void DisposeGroupPages() { foreach (var page in _panelByDockable.Keys.Select(dockable => dockable.Context).OfType()) page.Dispose(); + foreach (var page in _panelByDockable.Keys.Select(dockable => dockable.Context).OfType()) page.Dispose(); + foreach (var page in _panelByDockable.Keys.Select(dockable => dockable.Context).OfType()) page.Dispose(); + foreach (var page in _panelByDockable.Keys.Select(dockable => dockable.Context).OfType()) page.Dispose(); + foreach (var page in _panelByDockable.Keys.Select(dockable => dockable.Context).OfType()) page.Dispose(); } // ── Utility ── diff --git a/src/Editor/GalNet.Editor/History/EditorHistories.cs b/src/Editor/GalNet.Editor/History/EditorHistories.cs index d7fc0324..6d75eb84 100644 --- a/src/Editor/GalNet.Editor/History/EditorHistories.cs +++ b/src/Editor/GalNet.Editor/History/EditorHistories.cs @@ -25,7 +25,6 @@ public EditorHistories(IProjectContentSession? session = null, ProjectSessionOpt } public void Clear() => Content.Clear(); - public void MarkSaved() => Content.MarkSaved(); public System.Threading.Tasks.Task SuspendAsync() => Content.SuspendAsync(); public void ResumeEditing() => Content.Resume(); public void Dispose() diff --git a/src/Editor/GalNet.Editor/History/UndoRedoHistory.cs b/src/Editor/GalNet.Editor/History/UndoRedoHistory.cs index 9cd0bf81..bee74deb 100644 --- a/src/Editor/GalNet.Editor/History/UndoRedoHistory.cs +++ b/src/Editor/GalNet.Editor/History/UndoRedoHistory.cs @@ -29,7 +29,6 @@ public interface IUndoRedoHistory EditorText? RedoText => RedoDescription is { } text ? EditorText.History(text) : null; event Action? Changed; void Execute(IUndoableEdit edit); - void PushAlreadyApplied(IUndoableEdit edit); void Undo(); void Redo(); void Clear(); @@ -86,12 +85,6 @@ public void Execute(IUndoableEdit edit) RunSync(() => { edit.Redo(); Append(new SyncEdit(edit)); }); } - public void PushAlreadyApplied(IUndoableEdit edit) - { - ArgumentNullException.ThrowIfNull(edit); - RunSync(() => Append(new SyncEdit(edit))); - } - public void BindContent(Func dirty, Func repair) { _contentDirty = dirty; @@ -271,9 +264,10 @@ private void NotifyChanged() public sealed class HistoryRepairRequiredException(string message, Exception innerException) : Exception(message, innerException); -public sealed class AsyncCompositeEdit(string description, IReadOnlyList edits) : IAsyncUndoableEdit, IDisposable +public sealed class AsyncCompositeEdit(string description, IReadOnlyList edits, EditorText? display = null) : IAsyncUndoableEdit, IDisposable { public string Description { get; } = description; + public EditorText DisplayText => display ?? EditorText.History(Description); public long RetainedBytes => edits.Sum(edit => edit.RetainedBytes); public void Dispose() { @@ -309,6 +303,15 @@ public async ValueTask UndoAsync() } } +public sealed class AsyncEditAdapter(IUndoableEdit edit) : IAsyncUndoableEdit +{ + public string Description => edit.Description; + public EditorText DisplayText => edit.DisplayText; + public long RetainedBytes => edit.RetainedBytes; + public ValueTask RedoAsync() { edit.Redo(); return ValueTask.CompletedTask; } + public ValueTask UndoAsync() { edit.Undo(); return ValueTask.CompletedTask; } +} + public sealed class DelegateEdit(string description, Action undo, Action redo) : IUndoableEdit { public string Description { get; } = description; diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs index 9479d505..9bc3dc90 100644 --- a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs @@ -8,10 +8,12 @@ using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using GalNet.Core.Assets; using GalNet.Editor.Abstraction.Commands; using GalNet.Editor.Abstraction.Extensibility; using GalNet.Editor.Abstraction.Services; using GalNet.Editor.Models; +using GalNet.Editor.Services; using GalNet.Editor.Services.Interfaces; using GalNet.Editor.ViewModels; using LibVLCSharp.Shared; @@ -32,6 +34,13 @@ public sealed partial class AssetInspectorControlViewModel : ObservableObject, I private MediaPlayer? _player; private bool _updating; private bool _isSeeking; + private bool _disposed; + private string? _inspectedId; + private string? _inspectedPath; + private readonly string? _projectRoot; + private readonly ProjectTextFiles? _texts; + private readonly IResourceTypeCatalog? _types; + [ObservableProperty] private ImageVariantsInspector? _variants; public EditorWorkspaceViewModel Workspace { get; } public bool IsAvailable => true; @@ -54,14 +63,17 @@ public sealed partial class AssetInspectorControlViewModel : ObservableObject, I [ObservableProperty] private Bitmap? _imagePreview; [ObservableProperty] private string _imagePreviewError = ""; - public AssetInspectorControlViewModel(EditorWorkspaceViewModel workspace, IAssetCatalogService assets, IProjectFileCommandExecutor fileCommands, IProjectService projects, IEditorLocalizationService localization) + public AssetInspectorControlViewModel(EditorWorkspaceViewModel workspace, IAssetCatalogService assets, IProjectFileCommandExecutor fileCommands, IProjectService projects, IEditorLocalizationService localization, ProjectTextFiles? texts = null, IResourceTypeCatalog? types = null) { Workspace = workspace; _assets = assets; _fileCommands = fileCommands; _projects = projects; _localization = localization; + _projectRoot = projects.Current?.RootPath; + _texts = texts; _types = types; SyncAsset(); + _assets.Changed += OnAssetsChanged; Workspace.PropertyChanged += OnWorkspacePropertyChanged; _localization.PropertyChanged += OnLocalizationPropertyChanged; } @@ -77,7 +89,45 @@ public void SetLocked(bool isLocked) private void SyncAsset() { - InspectedAsset = Workspace.SelectedAsset; + _inspectedId = Workspace.SelectedAsset?.Id; + _inspectedPath = Workspace.SelectedAsset?.RelativePath; + SetAsset(Workspace.SelectedAsset); + } + + private void OnAssetsChanged() => Dispatcher.UIThread.Post(() => + { + if (_disposed || _projects.Current?.RootPath != _projectRoot || _inspectedPath is null) return; + try + { + var current = _assets.GetEntry(_inspectedPath); + if (_inspectedId is not null && !string.Equals(current?.Id, _inspectedId, StringComparison.OrdinalIgnoreCase)) + { + current = null; + var pending = new Stack(); pending.Push(""); + while (pending.TryPop(out var directory) && current is null) + foreach (var item in _assets.GetDirectory(directory)) + { + if ((System.IO.File.GetAttributes(item.FullPath) & System.IO.FileAttributes.ReparsePoint) != 0) continue; + if (item.IsDirectory) pending.Push(item.RelativePath); + else if (string.Equals(item.Id, _inspectedId, StringComparison.OrdinalIgnoreCase)) { current = item; break; } + } + } + if (current is not null) _inspectedPath = current.RelativePath; + SetAsset(current); + } + catch (Exception error) { Serilog.Log.Warning(error, "Could not refresh inspected asset identity"); } + }); + + private void SetAsset(AssetEntry? asset) + { + var variantsPath = asset?.Type == "image-variants" ? "Assets/" + asset.RelativePath : null; + if (Variants?.Buffer.Path != variantsPath) + { + Variants?.Dispose(); Variants = null; + if (variantsPath is not null && _texts is not null && _types is not null && _projectRoot is not null) + Variants = new(_projectRoot, _texts.Open(variantsPath), _projects, _types, _assets, _localization); + } + InspectedAsset = asset; Load(); _ = LoadImagePreviewAsync(); OnPropertyChanged(nameof(HasSelectedAsset)); @@ -108,7 +158,6 @@ private async Task SaveMetadataAsync(AssetEntry asset) new PatchAssetMetadataCommand(asset.RelativePath, AssetFilter, AssetCompression)); if (result.Success) { - project.IsDirty = true; await _assets.RefreshAsync(); } } @@ -183,7 +232,7 @@ private async Task LoadImagePreviewAsync() try { var bitmap = await Task.Run(() => new Bitmap(path)); - if (!ReferenceEquals(InspectedAsset, asset)) { bitmap.Dispose(); return; } + if (_disposed || !ReferenceEquals(InspectedAsset, asset)) { bitmap.Dispose(); return; } ImagePreview = bitmap; } catch (Exception ex) { ImagePreviewError = ex.Message; } @@ -192,6 +241,9 @@ private async Task LoadImagePreviewAsync() private static string FormatTime(double milliseconds) => TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)).ToString(@"m\:ss", CultureInfo.CurrentCulture); public void Dispose() { + Variants?.Dispose(); Variants = null; + if (_disposed) return; _disposed = true; + _assets.Changed -= OnAssetsChanged; Workspace.PropertyChanged -= OnWorkspacePropertyChanged; _localization.PropertyChanged -= OnLocalizationPropertyChanged; ImagePreview?.Dispose(); diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/EntryInspectorViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/EntryInspectorViewModel.cs new file mode 100644 index 00000000..e0997476 --- /dev/null +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/EntryInspectorViewModel.cs @@ -0,0 +1,74 @@ +using System.Collections.Specialized; +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Editor.Abstraction.Extensibility; +using GalNet.Editor.History; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Services; +using GalNet.Editor.ViewModels; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Inspector.ViewModels; + +public sealed class EntryInspectorViewModel : ObservableObject, IInspectorControlViewModel, IInspectorLockAware, IUndoRedoTarget +{ + public GroupEditorPanelViewModel Page { get; } + private bool _locked; + private string? _entryId; + private readonly AuthorNavigationService? _navigation; + public string? RequestedField { get; private set; } + public EntryParameterFormBinding? Form { get; private set; } + private EntryEditorItemViewModel? _formOwner; + private string? _formType; + public EntryEditorItemViewModel? Entry => Page.Workspace.Nodes.FirstOrDefault(node => node.Id == Page.GroupId)?.Entries.FirstOrDefault(entry => entry.StableId == _entryId); + public EditorRegistry FieldEditors => Page.FieldEditors; + public bool IsAvailable => Entry is not null; + public IUndoRedoHistory? UndoRedoHistory => Page.UndoRedoHistory; + public string TargetDescription => Entry is { } entry ? $"{Page.Workspace.Nodes.FirstOrDefault(node => node.Id == Page.GroupId)?.Name} / {entry.Id} · {entry.Type}" : ""; + public bool HasMultipleSelection => Page.SelectedEntries.Count > 1; + public int SelectionCount => Page.SelectedEntries.Count; + public EntryInspectorViewModel(GroupEditorPanelViewModel page, AuthorNavigationService? navigation = null) + { + Page = page; + _navigation = navigation; + if (_navigation is not null) _navigation.Requested += Navigate; + Page.PropertyChanged += PageChanged; + Page.SelectedEntries.CollectionChanged += SelectionChanged; + Page.Workspace.Nodes.CollectionChanged += NodesChanged; + Page.Workspace.UndoRedoHistory!.Changed += Refresh; + Sync(); + } + private void PageChanged(object? sender, PropertyChangedEventArgs args) + { if (!_locked && args.PropertyName == nameof(Page.SelectedEntry)) Sync(); } + private void NodesChanged(object? sender, NotifyCollectionChangedEventArgs args) => Refresh(); + private void SelectionChanged(object? sender, NotifyCollectionChangedEventArgs args) + { OnPropertyChanged(nameof(HasMultipleSelection)); OnPropertyChanged(nameof(SelectionCount)); } + private void Sync() { _entryId = Page.SelectedEntry?.StableId; Refresh(); } + private void Navigate(AuthorNavigationTarget target) + { + if (target.NodeId != Page.GroupId || target.EntryId != _entryId) return; + RequestedField = target.FieldId; OnPropertyChanged(nameof(RequestedField)); + } + private void Refresh() + { + var entry = Entry; + if (!ReferenceEquals(entry, _formOwner) || entry?.Type != _formType) + { + Form?.Dispose(); Form = null; _formOwner = entry; _formType = entry?.Type; + if (entry?.DetailForm is { } form) Form = new EntryParameterFormBinding(entry, form.Fields, entry.ParameterDtoType); + OnPropertyChanged(nameof(Form)); + } + Form?.Refresh(); + OnPropertyChanged(nameof(Entry)); OnPropertyChanged(nameof(IsAvailable)); OnPropertyChanged(nameof(TargetDescription)); + } + public void SetLocked(bool isLocked) { _locked = isLocked; if (!_locked) Sync(); } + public void Dispose() + { + Form?.Dispose(); + if (_navigation is not null) _navigation.Requested -= Navigate; + Page.PropertyChanged -= PageChanged; + Page.SelectedEntries.CollectionChanged -= SelectionChanged; + Page.Workspace.Nodes.CollectionChanged -= NodesChanged; + Page.Workspace.UndoRedoHistory!.Changed -= Refresh; + } +} diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/ImageVariantsInspector.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/ImageVariantsInspector.cs new file mode 100644 index 00000000..8ae83022 --- /dev/null +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/ImageVariantsInspector.cs @@ -0,0 +1,165 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GalNet.Assets.Variants; +using GalNet.Core.Assets; +using GalNet.Core.Variants; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Services; +using GalNet.Editor.Services.Interfaces; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Inspector.ViewModels; + +public sealed partial class VariantDimensionRow(Action changed) : ObservableObject +{ + [ObservableProperty] private string _key = ""; + [ObservableProperty] private string _label = ""; + partial void OnKeyChanged(string value) => changed(); + partial void OnLabelChanged(string value) => changed(); +} + +public sealed partial class VariantPreviewRow(string key, string label, IReadOnlyList options, string? value, Action select) : ObservableObject +{ + public string Key { get; } = key; + public string Label { get; } = label; + public IReadOnlyList Options { get; } = options; + [ObservableProperty] private string? _value = value; + partial void OnValueChanged(string? value) { if (value is not null) select(Key, value); } +} + +/// Typed projection of a project-owned text draft. Preview state never enters content history. +public sealed partial class ImageVariantsInspector : ObservableObject, IDisposable +{ + private readonly string _root; + private readonly IProjectService _projects; + private readonly IResourceTypeCatalog _types; + private readonly IAssetCatalogService _assets; + private readonly IEditorLocalizationService _text; + private ImageVariantsSource? _source; + private ImageVariantsAuthoringSnapshot? _snapshot; + private ImageVariantResolution? _selection; + private IReadOnlyDictionary? _request; + private IReadOnlyList _protected = []; + private bool _refreshing, _disposed; + private int _generation, _imageGeneration; + private string? _originalKeys; + private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; + public ProjectTextBuffer Buffer { get; } + public Task PreviewLoading { get; private set; } = Task.CompletedTask; + public ObservableCollection Dimensions { get; } = []; + public ObservableCollection PreviewRows { get; } = []; + public bool CanConfigure => !_disposed && _source is not null && !Buffer.IsReadOnly && _projects.Current?.RootPath == _root; + public string? DefaultChoice { get => VariantKeys.Contains(Default, StringComparer.Ordinal) ? Default : null; set { if (!_refreshing && value is not null && VariantKeys.Contains(value, StringComparer.Ordinal)) Default = value; } } + [ObservableProperty] private string _folder = ""; + [ObservableProperty] private string _default = ""; + [ObservableProperty] private IReadOnlyList _variantKeys = []; + [ObservableProperty] private string _diagnostics = ""; + [ObservableProperty] private string _impact = ""; + [ObservableProperty] private string _requested = ""; + [ObservableProperty] private string _actual = ""; + [ObservableProperty] private string _fallback = ""; + [ObservableProperty] private Bitmap? _preview; + + public ImageVariantsInspector(string root, ProjectTextBuffer buffer, IProjectService projects, IResourceTypeCatalog types, IAssetCatalogService assets, IEditorLocalizationService text) + { + _root = root; Buffer = buffer; _projects = projects; _types = types; _assets = assets; _text = text; + buffer.PropertyChanged += BufferChanged; assets.Changed += ResourcesChanged; text.PropertyChanged += LocaleChanged; + Refresh(); + } + private bool Current => !_disposed && _projects.Current?.RootPath == _root && !Buffer.IsReadOnly; + partial void OnFolderChanged(string value) { if (!_refreshing && CanConfigure) { _source!.Folder = value; Write(); } } + partial void OnDefaultChanged(string value) { OnPropertyChanged(nameof(DefaultChoice)); if (!_refreshing && CanConfigure && value is not null) { _source!.Default = value; Write(); } } + partial void OnVariantKeysChanged(IReadOnlyList value) => OnPropertyChanged(nameof(DefaultChoice)); + private void Write() { if (Current && _source is not null) Buffer.Text = JsonSerializer.Serialize(_source, Json); } + private void BufferChanged(object? sender, PropertyChangedEventArgs args) { if (args.PropertyName is nameof(ProjectTextBuffer.Text) or nameof(ProjectTextBuffer.Path) or nameof(ProjectTextBuffer.IsReadOnly)) Refresh(); } + private void ResourcesChanged() => Dispatcher.UIThread.Post(() => { if (Current) Refresh(); }); + private void LocaleChanged(object? sender, PropertyChangedEventArgs args) { if (args.PropertyName == "Item[]" && Current) Refresh(); } + [RelayCommand] + public void Refresh() + { + if (!Current) { OnPropertyChanged(nameof(CanConfigure)); return; } + var generation = ++_generation; + _refreshing = true; _snapshot = null; _selection = null; _source = null; _imageGeneration++; VariantKeys = []; + PreviewRows.Clear(); Preview?.Dispose(); Preview = null; Actual = ""; Fallback = ""; Diagnostics = ""; + try + { + _source = ImageVariantsCodec.ReadSource(Encoding.UTF8.GetBytes(Buffer.Text)); + Folder = _source.Folder; Default = _source.Default; + _snapshot = ImageVariantsAuthoring.Scan(_root, Buffer.Path, Buffer.Text, _types); + VariantKeys = _snapshot.Files.Select(file => Path.GetFileNameWithoutExtension(file.FileName)).Order(StringComparer.Ordinal).ToArray(); + if (_source.Dimensions?.Any(item => item is null) == true) + { + Diagnostics = string.Join("\n", _snapshot.Build.Diagnostics.Select(item => $"{item.Path} [{item.Code}]: {item.Message}")); + Dimensions.Clear(); _source = null; return; + } + var dimensions = _source.Dimensions ?? _snapshot.Build.Definition?.Dimensions.Select(item => new ImageVariantDimensionSource { Key = item.Key, Label = item.Label }).ToList() ?? []; + while (Dimensions.Count > dimensions.Count) Dimensions.RemoveAt(Dimensions.Count - 1); + while (Dimensions.Count < dimensions.Count) Dimensions.Add(new(DimensionsChanged)); + for (var i = 0; i < dimensions.Count; i++) { Dimensions[i].Key = dimensions[i].Key; Dimensions[i].Label = dimensions[i].Label ?? ""; } + var keys = string.Join("\n", dimensions.Select(item => item.Key)); _originalKeys ??= keys; + Impact = keys == _originalKeys ? "" : _text["Variants.IdentityWarning"]; + Diagnostics = string.Join("\n", _snapshot.Build.Diagnostics.Select(item => $"{item.Path} [{item.Code}]: {item.Message}")); + if (_snapshot.Build.Definition is { } definition) + { + _selection = _request is null ? ImageVariantsResolver.Initialize(definition) : ImageVariantsResolver.Resolve(definition, _request, _protected); + _request = _selection.Request; + foreach (var dimension in definition.Dimensions) + PreviewRows.Add(new(dimension.Key, dimension.Label ?? dimension.Key, dimension.Options, _request.GetValueOrDefault(dimension.Key), (key, value) => { if (generation == _generation && Current) Select(key, value); })); + ShowResult(); + } + } + catch (Exception error) when (error is IOException or InvalidDataException or ArgumentException or UnauthorizedAccessException or JsonException) + { Diagnostics = error.Message; } + finally { _refreshing = false; OnPropertyChanged(nameof(CanConfigure)); } + } + private void DimensionsChanged() + { + if (_refreshing || !CanConfigure) return; + _source!.Dimensions = Dimensions.Select(row => new ImageVariantDimensionSource { Key = row.Key, Label = row.Label }).ToList(); Write(); + } + [RelayCommand] private void AddDimension() { if (!CanConfigure) return; var index = 1; while (Dimensions.Any(row => row.Key == "d" + index)) index++; _refreshing = true; Dimensions.Add(new(DimensionsChanged) { Key = "d" + index }); _refreshing = false; DimensionsChanged(); } + [RelayCommand] private void RemoveDimension(VariantDimensionRow row) { if (CanConfigure && Dimensions.Remove(row)) DimensionsChanged(); } + [RelayCommand] private void MoveUp(VariantDimensionRow row) { var index = Dimensions.IndexOf(row); if (CanConfigure && index > 0) { Dimensions.Move(index, index - 1); DimensionsChanged(); } } + [RelayCommand] private void InferDimensions() { if (CanConfigure) { _source!.Dimensions = null; Write(); } } + [RelayCommand] private void ResetPreview() { if (Current) { _request = null; _protected = []; Refresh(); } } + public void Select(string key, string value) + { + if (!Current || _snapshot?.Build.Definition is not { } definition || _request is null) return; + try { _selection = ImageVariantsResolver.Select(definition, _request, key, value); _request = _selection.Request; _protected = _selection.ProtectedDimensions; ShowResult(); } + catch (ArgumentException error) { Diagnostics = error.Message; } + } + private void ShowResult() + { + if (_selection is not { } result) return; + var previous = Preview; Preview = null; previous?.Dispose(); + Requested = string.Join(" · ", result.Request.Select(pair => pair.Key + "=" + pair.Value)); + Actual = result.Variant.Key + " · " + _text["Variants.Reason." + result.Reason]; + Fallback = string.Join("\n", result.Candidates.Select(item => string.Join(" · ", item.Selection.Select(pair => pair.Key + "=" + pair.Value)) + " → " + (item.Match?.Key ?? "∅"))); + if (_snapshot!.ImagePaths.TryGetValue(result.Variant.AssetId, out var path)) PreviewLoading = LoadPreviewAsync(path, ++_imageGeneration); + } + private async Task LoadPreviewAsync(string path, int generation) + { + try + { + var bytes = await File.ReadAllBytesAsync(new ProjectRawReader(_root).Resolve(path)); + using var stream = new MemoryStream(bytes); var bitmap = Bitmap.DecodeToWidth(stream, 480); + if (!Current || generation != _imageGeneration) { bitmap.Dispose(); return; } + var old = Preview; Preview = bitmap; old?.Dispose(); + } + catch (Exception error) when (error is IOException or ArgumentException or InvalidDataException or UnauthorizedAccessException) + { if (Current && generation == _imageGeneration) Diagnostics = error.Message; } + } + [RelayCommand] private async Task ApplyAsync() { if (!Current) return; try { await Buffer.CommitAsync(); Refresh(); } catch (Exception error) { Diagnostics = error.Message; } } + public void Dispose() + { + if (_disposed) return; _disposed = true; _generation++; _imageGeneration++; + Buffer.PropertyChanged -= BufferChanged; _assets.Changed -= ResourcesChanged; _text.PropertyChanged -= LocaleChanged; + Preview?.Dispose(); Preview = null; + } +} diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/InspectorHostViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/InspectorHostViewModel.cs index 0d0d8763..ebb94b94 100644 --- a/src/Editor/GalNet.Editor/Inspector/ViewModels/InspectorHostViewModel.cs +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/InspectorHostViewModel.cs @@ -21,6 +21,7 @@ public sealed partial class InspectorHostViewModel : ObservableObject, IDisposab [ObservableProperty] private string? _targetPanelId; public bool HasTarget => TargetPanelId is not null; + public string TargetDescription => CurrentInspectorViewModel is EntryInspectorViewModel entry ? entry.TargetDescription : TargetPanelId ?? ""; public IUndoRedoHistory? UndoRedoHistory => (_targetDockViewModel as IUndoRedoTarget)?.UndoRedoHistory; public InspectorHostViewModel(IServiceProvider services, IEditorExtensionRegistry extensions) @@ -103,6 +104,7 @@ private void ReplaceInspector(IInspectorControlViewModel? viewModel, IInspectorC private void OnInspectorPropertyChanged(object? sender, PropertyChangedEventArgs e) { + OnPropertyChanged(nameof(TargetDescription)); if (e.PropertyName == nameof(IInspectorControlViewModel.IsAvailable) && sender is IInspectorControlViewModel vm) CurrentInspectorViewModel = vm.IsAvailable && _currentContribution is not null ? vm : null; } diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/NodeInspectorControlViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/NodeInspectorControlViewModel.cs index 6c98fc17..f1a0af18 100644 --- a/src/Editor/GalNet.Editor/Inspector/ViewModels/NodeInspectorControlViewModel.cs +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/NodeInspectorControlViewModel.cs @@ -18,6 +18,7 @@ public sealed partial class NodeInspectorControlViewModel : ObservableObject, II { private readonly IEditorLocalizationService _localization; private bool _isLocked; + private string? _inspectedNodeId; public EditorWorkspaceViewModel Workspace { get; } public IUndoRedoHistory? UndoRedoHistory => Workspace.UndoRedoHistory; @@ -39,6 +40,7 @@ public NodeInspectorControlViewModel(EditorWorkspaceViewModel workspace, IEditor _localization = localization; SyncSelection(); Workspace.PropertyChanged += OnWorkspacePropertyChanged; + Workspace.Nodes.CollectionChanged += OnNodesChanged; Workspace.VariableDefinitionsChanged += OnVariableDefinitionsChanged; _localization.PropertyChanged += OnLocalizationPropertyChanged; } @@ -84,10 +86,18 @@ private void OnWorkspacePropertyChanged(object? sender, PropertyChangedEventArgs private void SyncSelection() { InspectedNode = Workspace.SelectedNode; + _inspectedNodeId = InspectedNode?.Id; InspectedEdge = Workspace.SelectedEdge; RaiseSelectionPropertiesChanged(); } + private void OnNodesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs args) + { + if (!_isLocked || _inspectedNodeId is null) return; + InspectedNode = Workspace.Nodes.FirstOrDefault(node => node.Id == _inspectedNodeId); + RaiseSelectionPropertiesChanged(); + } + private void RaiseSelectionPropertiesChanged() { OnPropertyChanged(nameof(IsAvailable)); @@ -113,6 +123,7 @@ private void OnLocalizationPropertyChanged(object? sender, PropertyChangedEventA public void Dispose() { Workspace.PropertyChanged -= OnWorkspacePropertyChanged; + Workspace.Nodes.CollectionChanged -= OnNodesChanged; Workspace.VariableDefinitionsChanged -= OnVariableDefinitionsChanged; _localization.PropertyChanged -= OnLocalizationPropertyChanged; } diff --git a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml index ae9d9f4e..fcfac266 100644 --- a/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml +++ b/src/Editor/GalNet.Editor/Inspector/Views/AssetInspectorControl.axaml @@ -1,5 +1,5 @@ + xmlns:vm="using:GalNet.Editor.Inspector.ViewModels" xmlns:views="using:GalNet.Editor.Inspector.Views" xmlns:converters="using:Avalonia.Data.Converters" xmlns:loc="using:GalNet.Editor.MarkupExtensions" x:Class="GalNet.Editor.Inspector.Views.AssetInspectorControl" x:DataType="vm:AssetInspectorControlViewModel"> @@ -12,6 +12,7 @@ + diff --git a/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml b/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml new file mode 100644 index 00000000..3ab25b30 --- /dev/null +++ b/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml.cs b/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml.cs new file mode 100644 index 00000000..9dc45c2e --- /dev/null +++ b/src/Editor/GalNet.Editor/Inspector/Views/EntryInspectorView.axaml.cs @@ -0,0 +1,31 @@ +using Avalonia.Controls; +using Avalonia.Threading; +using Avalonia.VisualTree; +using GalNet.Editor.Inspector.ViewModels; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Inspector.Views; + +public partial class EntryInspectorView : UserControl +{ + private EntryInspectorViewModel? _model; + public EntryInspectorView() + { + InitializeComponent(); + DataContextChanged += (_, _) => Connect(); + AttachedToVisualTree += (_, _) => Connect(); + DetachedFromVisualTree += (_, _) => Disconnect(); + } + private void Disconnect() { if (_model is not null) _model.PropertyChanged -= Changed; _model = null; } + private void Connect() { Disconnect(); _model = DataContext as EntryInspectorViewModel; if (_model is not null) _model.PropertyChanged += Changed; } + private void Changed(object? sender, System.ComponentModel.PropertyChangedEventArgs args) + { + if (args.PropertyName != nameof(EntryInspectorViewModel.RequestedField)) return; + Dispatcher.UIThread.Post(() => + { + var row = this.GetVisualDescendants().OfType().FirstOrDefault(item => item.Context?.Field.Id == _model?.RequestedField); + if (row is null) return; + row.BringIntoView(); row.GetVisualDescendants().OfType().FirstOrDefault(input => input.Focusable && input.IsEffectivelyVisible)?.Focus(); + }, DispatcherPriority.Loaded); + } +} diff --git a/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml b/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml new file mode 100644 index 00000000..404d54d7 --- /dev/null +++ b/src/Editor/GalNet.Editor/Inspector/Views/ImageVariantsEditor.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + diff --git a/src/Editor/GalNet.Editor/Localization/en-US.json b/src/Editor/GalNet.Editor/Localization/en-US.json index addf2dfe..b3d9f11a 100644 --- a/src/Editor/GalNet.Editor/Localization/en-US.json +++ b/src/Editor/GalNet.Editor/Localization/en-US.json @@ -1,4 +1,32 @@ { + "Error.text.changed": "The text reference changed. Reload this draft.", + "Error.text.conflict": "Conflicting edits target the same shared text.", + "History.GameText": "Game text: {0}", + "GameText.Placeholder": "Enter development-language text", + "GameText.Missing": "Development-language text is missing", + "GameText.Shared": "Shared text: changes affect {0} references", + "Error.text.localeChanged": "The project development language changed. Reload this draft.", + "Form.CopyElement": "Copy element", + "Dock.Panel.Characters": "Characters", + "History.Character": "Edit character: {0}", + "Characters.Add": "Add character", + "Characters.Search": "Search development-language names or keys", + "Characters.Name": "Development-language name", + "Characters.Key": "Unique key", + "Characters.AutoKey": "Leave empty for an automatic ID", + "Characters.Apply": "Apply", + "Characters.Revert": "Revert draft", + "Characters.Delete": "Delete unused character", + "Characters.Incomplete": "Reference analysis is incomplete. Deleting characters and changing keys are unavailable.", + "Characters.SpeakerHint": "Enter a name or key, or select a character; an unmatched value creates one on focus loss", + "Error.character.name": "Enter the development-language character name.", + "Error.character.key": "The character key cannot be empty.", + "Error.character.duplicate": "This character key already exists.", + "Error.character.changed": "The character or name changed. Revert the draft and retry.", + "Error.character.inUse": "This character is still used and cannot be deleted.", + "Error.character.incomplete": "Reference analysis is incomplete; this operation cannot be performed safely.", + "Error.character.ambiguous": "Several characters share this name. Select a specific key from the suggestions.", + "Error.character.obsolete": "The project or entry changed. This input is no longer current.", "Entry.AppendBatch": "Append entries", "Entry.Type.dialogue.show": "Show dialogue", "Entry.Type.dialogue.hide": "Hide dialogue", @@ -67,7 +95,7 @@ "Language.en-US": "English", "Dock.Panel.NodeGraph": "Node Graph", "Dock.Panel.GamePreview": "Game Preview", - "Dock.Panel.Assets": "Assets", + "Dock.Panel.Assets": "Project Files", "Dock.Panel.Log": "Log", "Dock.Panel.GroupEditor": "Group: {0}", "Dock.Panel.Inspector": "Inspector", @@ -313,7 +341,6 @@ "Entry.Parameter.asset": "Asset", "Entry.Parameter.x": "X", "Entry.Parameter.y": "Y", - "Entry.Parameter.z": "Z", "Entry.Parameter.transition": "Transition", "Entry.Parameter.duration": "Duration", "Entry.Parameter.channel": "Channel", @@ -328,6 +355,52 @@ "Entry.Parameter.valueType": "Value type", "Entry.Parameter.expression": "Expression", "Entry.Parameter.category": "Category", + "Condition.Placeholder": "Run only when this condition is true", + "Entry.Parameter.assetId": "Asset", + "Entry.Parameter.autoBlocking": "Auto block", + "Entry.Parameter.batchId": "Batch ID", + "Entry.Parameter.bladeCount": "Blade count", + "Entry.Parameter.blendMode": "Blend mode", + "Entry.Parameter.blocking": "Wait for completion", + "Entry.Parameter.clearQueue": "Clear queue", + "Entry.Parameter.count": "Count", + "Entry.Parameter.curve": "Curve", + "Entry.Parameter.direction": "Direction", + "Entry.Parameter.displayMode": "Display mode", + "Entry.Parameter.distance": "Distance", + "Entry.Parameter.durationFrames": "Duration (frames)", + "Entry.Parameter.fadeIn": "Fade in", + "Entry.Parameter.fadeInDuration": "Fade-in duration", + "Entry.Parameter.fadeOut": "Fade out", + "Entry.Parameter.fadeOutDuration": "Fade-out duration", + "Entry.Parameter.flipbook": "Flipbook", + "Entry.Parameter.frameRate": "Frame rate", + "Entry.Parameter.from": "Start value", + "Entry.Parameter.fromLayerHandleId": "Source layer", + "Entry.Parameter.gain": "Gain", + "Entry.Parameter.handleId": "Layer handle", + "Entry.Parameter.holdDuration": "Hold duration", + "Entry.Parameter.instanceId": "Instance ID", + "Entry.Parameter.loopMode": "Loop mode", + "Entry.Parameter.maskProgram": "Mask program", + "Entry.Parameter.newHandleId": "New layer handle", + "Entry.Parameter.oldHandleId": "Old layer handle", + "Entry.Parameter.order": "Order", + "Entry.Parameter.orientation": "Orientation", + "Entry.Parameter.overlayZ": "Overlay Z", + "Entry.Parameter.placement": "Placement", + "Entry.Parameter.playbackHandleId": "Playback handle", + "Entry.Parameter.program": "Program", + "Entry.Parameter.skippable": "Can skip", + "Entry.Parameter.skipPoints": "Skip points", + "Entry.Parameter.targetHandleId": "Target handle", + "Entry.Parameter.to": "End value", + "Entry.Parameter.toAssetId": "Destination asset", + "Entry.Parameter.toDisplayMode": "Destination display mode", + "Entry.Parameter.toLayerHandleId": "Destination layer", + "Entry.Parameter.toZ": "Destination Z", + "Entry.Parameter.trackId": "Track", + "Entry.Parameter.z": "Z order", "Entry.Option.None": "None", "Entry.Option.fade": "Fade", "Entry.Option.dissolve": "Dissolve", @@ -345,6 +418,34 @@ "Entry.Option.Cg": "CG", "Entry.Option.Portrait": "Portrait", "Entry.Option.Scene": "Scene", + "Entry.Option.false": "No", + "Entry.Option.true": "Yes", + "Entry.Option.Native": "Native size", + "Entry.Option.Tile": "Tile", + "Entry.Option.Fill": "Fill", + "Entry.Option.Uniform": "Fit", + "Entry.Option.UniformToFill": "Fill and crop", + "Entry.Option.Left": "Left", + "Entry.Option.Right": "Right", + "Entry.Option.Up": "Up", + "Entry.Option.Down": "Down", + "Entry.Option.Vertical": "Vertical", + "Entry.Option.Horizontal": "Horizontal", + "Entry.Option.Step": "Step", + "Entry.Option.EaseIn": "Ease in", + "Entry.Option.EaseOut": "Ease out", + "Entry.Option.EaseInOut": "Ease in and out", + "Entry.Option.PingPong": "Ping-pong", + "Entry.Option.Replace": "Replace", + "Entry.Option.Additive": "Additive", + "Entry.Option.AfterIteration": "After this iteration", + "Entry.Option.CompleteImmediately": "Complete immediately", + "Entry.Option.enqueue": "Queue", + "Entry.Option.next": "Play next", + "Entry.Option.replace": "Replace current", + "Entry.Option.replaceAll": "Replace all", + "Entry.Option.replaceIfDifferent": "Replace if different", + "Entry.Option.ReplaceAll": "Replace all", "NewProject.Name": "Name", "NewProject.Parent": "Parent folder", "NewProject.Contracts": "Custom contract directory (optional)", @@ -442,5 +543,472 @@ "Settings.Project.RemoveTrack": "Remove track", "Settings.Project.Gain": "Default gain", "Settings.Project.Voices": "Maximum voices", - "Settings.Project.Queue": "Queue capacity" + "Settings.Project.Queue": "Queue capacity", + "History.AddBranchItem": "Add branch item: {0}", + "History.RemoveBranchItem": "Delete branch item: {0}", + "History.MoveBranchItem": "Move branch item: {0}", + "History.EditBranchItem": "Edit branch item: {0}", + "History.PasteNodes": "Paste {0} nodes and content", + "Error.graph.branchChanged": "The branch or its items changed. Review the current values and retry.", + "Error.graph.clipboard": "The clipboard does not contain a supported GalNet graph selection.", + "Error.graph.clipboardUnavailable": "Clipboard is unavailable.", + "Graph.Menu.Copy": "Copy nodes", + "Graph.Menu.Paste": "Paste nodes", + "Graph.Menu.SelectAll": "Select all nodes", + "Graph.Menu.FrameSelection": "Frame selection", + "Graph.Menu.FrameAll": "Frame all nodes", + "Graph.Branch.Add": "Add branch item", + "Graph.Branch.Remove": "Remove branch item", + "Graph.Branch.Text": "Option text key", + "Graph.Branch.Condition": "Condition expression", + "Status.GraphPasted": "Pasted {0} nodes. References inside custom content are unchanged; review them.", + "History.FileMove": "Move files: {0}", + "History.FileCopy": "Copy files: {0}", + "History.FileImport": "Import files: {0}", + "History.FileDelete": "Delete files: {0}", + "History.FileCreateFile": "Create file: {0}", + "History.FileCreateDirectory": "Create directory: {0}", + "History.FileMetadata": "Edit asset metadata: {0}", + "Error.files.path": "Invalid path or linked entry: {0}", + "Error.files.protected": "This path is managed by a dedicated project feature: {0}", + "Error.files.missing": "File or directory not found: {0}", + "Error.files.collision": "The destination is occupied; existing content was preserved: {0}", + "Error.files.category": "Files cannot cross content categories or language directories: {0}", + "Error.files.changed": "The file or directory changed; review it before retrying: {0}", + "Error.files.referenced": "Deletion was blocked by references: {0}", + "Error.files.guidCollision": "Duplicate asset ID; the operation was not applied: {0}", + "Error.files.references": "Undeclared references cannot be rewritten automatically: {0}", + "Error.files.failed": "File operation failed: {0}", + "Files.Back": "Back", + "Files.Forward": "Forward", + "Files.Up": "Parent folder", + "Files.Path": "Project-relative path", + "Files.Search": "Search project filenames", + "Files.Import": "Import", + "Files.NewText": "New text file", + "Files.NewFolderName": "New Folder", + "Files.NewFileName": "New Text.txt", + "Files.Duplicate": "Duplicate", + "Files.ItemCount": "{0} items", + "Files.DeleteQuestion": "Delete selected items? Project Undo can restore them.", + "Files.DeleteGroups": "Also delete linked Group nodes and edges", + "Files.AllowCandidates": "I checked string reference candidates; delete anyway (declared references remain protected)", + "Files.Cancel": "Cancel", + "Files.ThumbnailSize": "Thumbnail size", + "Files.Document": "File", + "Files.Apply": "Apply edit", + "Files.SaveAll": "Save all", + "Files.CancelDraft": "Cancel input", + "Files.OpenExternal": "Open externally", + "Files.Missing": "File deleted. Undo deletion to continue editing.", + "Files.ReadOnly": "Read-only: system file, binary, large file or unsupported encoding. You can open it externally.", + "Files.ExternalChange": "File changed externally. Your edits are retained; saving will not overwrite external content. Close the project, reconcile both versions, and reopen.", + "History.TextFile": "Edit file: {0}", + "Error.files.busy": "Saving in progress. Open this file again shortly: {0}", + "Error.files.galleryId": "Invalid Gallery item ID; no changes were made: {0}", + "Files.New": "Create", + "Files.ClearSearch": "Clear search", + "Entry.SearchTypes": "Search entry types", + "Entry.BatchCount": "Number of entries to add or append", + "Entry.AnchorMissing": "The insertion target was removed. Select it again.", + "Entry.Type.dialogue.text": "Text", + "Entry.Type.flow.wait": "Wait", + "Entry.Type.gallery.unlock": "Unlock gallery", + "Entry.Type.animation.animate": "Animate property", + "Entry.Type.timeline.play": "Play timeline", + "Entry.Type.animation.stop": "Stop animation", + "Entry.Type.layer.showColor": "Show color layer", + "Entry.Type.layer.replace": "Replace layer", + "Entry.Type.audio.next": "Next audio", + "Entry.Type.audio.clear": "Clear audio queue", + "Entry.Type.audio.seffect.apply": "Apply audio effect", + "Entry.Type.audio.seffect.stop": "Stop audio effect", + "Entry.Type.nvl.show": "Show NVL", + "Entry.Type.nvl.hide": "Hide NVL", + "Entry.Type.nvl.clear": "Clear NVL", + "Entry.Type.nvl.append": "Append NVL text", + "Entry.Type.particle.play": "Play particles", + "Entry.Type.particle.stop": "Stop particles", + "Entry.Type.particle.burst": "Burst particles", + "Entry.Type.dialogue.textVoice": "Voiced dialogue", + "Entry.Type.nvl.appendVoice": "Append voiced NVL", + "Entry.Type.transition.crossFade": "Crossfade", + "Entry.Type.transition.slide": "Slide transition", + "Entry.Type.transition.blinds": "Blinds transition", + "Entry.Type.transition.fadeBlack": "Fade through black", + "Entry.Type.transition.fadeWhite": "Fade through white", + "Entry.Type.transition.fadeColor": "Fade through color", + "Entry.Category.Animation": "Animation", + "Entry.Category.Nvl": "NVL", + "Entry.Category.Particle": "Particles", + "Entry.Category.Transition": "Transitions", + "Entry.Category.dialogue": "Dialogue", + "Entry.Category.layer": "Layer", + "Entry.Category.audio": "Audio", + "Entry.Category.video": "Video", + "Entry.Category.effect": "Effect", + "Entry.Category.flow": "Flow", + "Entry.Category.variable": "Variable", + "Entry.Category.gallery": "Gallery", + "Entry.Category.animation": "Animation", + "Entry.Category.timeline": "Timeline", + "Entry.Category.nvl": "NVL", + "Entry.Category.particle": "Particles", + "Entry.Category.transition": "Transitions", + "Entry.Menu.Copy": "Copy entries", + "Entry.Menu.Cut": "Cut entries", + "Entry.Menu.Paste": "Paste entries", + "Entry.Menu.Duplicate": "Duplicate entries", + "Entry.Menu.Delete": "Delete selected entries", + "Entry.Menu.SelectAll": "Select all", + "History.PasteEntries": "Paste {0} entries", + "History.DeleteEntries": "Delete {0} entries", + "History.MoveEntries": "Move entries", + "Error.entry.clipboard": "The clipboard does not contain supported GalNet entries (up to 1,000 entries / 16 MiB of text).", + "Error.entry.clipboardSchema": "This project's contract does not support the clipboard types or fields. No entries were pasted.", + "Error.entry.clipboardUnavailable": "Clipboard is unavailable.", + "Error.entry.groupMissing": "The Group was removed.", + "Error.entry.anchorMissing": "The insertion target was removed. Select a new target.", + "Error.entry.cutChanged": "The source entries changed while writing the clipboard. They were not removed.", + "Dock.Panel.Variables": "Variables", + "Variables.Player": "Player variables", + "Variables.Save": "Save variables", + "Variables.PlayerHint": "Player variables persist in the player profile and are shared across game saves. Edit declarations and initial values here.", + "Variables.SaveHint": "Save variables belong to a game save and are restored with it. Edit declarations and initial values here.", + "Variables.Add": "Add variable", + "Variables.Name": "Name", + "Variables.Type": "Type", + "Variables.Default": "Default value", + "Variables.Description": "Description", + "Variables.Apply": "Apply", + "Variables.Revert": "Revert draft", + "Variables.Delete": "Delete variable", + "Variables.References": "Check references", + "Variables.ReferenceHint": "Known references are renamed and block deletion. Opaque text or uncertain expressions are reported without replacement.", + "Variables.Certain": "Known reference", + "Variables.Possible": "Candidate text (unchanged)", + "Variables.Candidates": "Kept {0} candidate texts unchanged. Review the locations below.", + "Variables.Type.Bool": "Boolean", + "Variables.Type.Int": "Integer", + "Variables.Type.Float": "Float", + "Variables.Type.String": "Text", + "Variables.DefaultHint.Bool": "Checked means true; unchecked means false.", + "Variables.DefaultHint.Int": "Enter a 32-bit integer.", + "Variables.DefaultHint.Float": "Enter a finite floating-point number using a decimal point.", + "Variables.DefaultHint.String": "Enter plain text without JSON quotes.", + "History.Variable.Add": "Add variable {0}", + "History.Variable.Update": "Edit variable {0}", + "History.Variable.Delete": "Delete variable {0}", + "Error.variable.missing": "The variable no longer exists.", + "Error.variable.name": "Start with an ASCII letter or underscore, followed by letters, digits or underscores. Keywords and Gallery names are reserved.", + "Error.variable.duplicate": "Player and save variables share a namespace. This name is already used.", + "Error.variable.default": "The default value is not valid for the selected type.", + "Error.variable.inUse": "The variable has known references. Remove them before deleting it.", + "Error.variable.changed": "The variable or its references changed. Revert the draft and edit again.", + "History.EntryType": "Edit entry type", + "History.EntryCondition": "Edit entry condition", + "History.EntryParameters": "Edit entry parameters", + "Error.entry.obsolete": "The entry was removed or its type changed. Reopen the editor.", + "Error.entry.changed": "The entry changed outside this edit. Refresh and try again.", + "Entry.UnknownTypePreserved": "The current contract has no editor for this type. Its content is preserved and can be saved or changed to another type; validation and compilation still require a valid contract.", + "Entry.UnknownParametersPreserved": "Parameters not declared by the current contract are preserved when saving. Validation or compilation may report errors.", + "Form.Default": "Use default value explicitly", + "Form.Omit": "Omit this field without writing defaults", + "Form.Null": "Set explicit null", + "Form.Revert": "Revert uncommitted input", + "Form.AddElement": "Add element", + "Form.RemoveElement": "Remove element", + "Form.MoveUp": "Move up", + "Form.MoveDown": "Move down", + "Dock.Panel.Timeline": "Timeline", + "Timeline.SelectEntry": "Select a Timeline entry.", + "Timeline.Unsupported": "This entry does not support Timeline editing.", + "Timeline.Missing": "This entry has no timeline.", + "Timeline.Create": "Create timeline", + "Timeline.Editing": "Editing timeline", + "Timeline.Invalid": "Invalid timeline. Repair its JSON in the entry parameters.", + "Timeline.Frame": "Frame", + "Timeline.Duration": "End frame", + "Timeline.Empty": "No editable objects or existing tracks here. Create a scene object before this entry.", + "Timeline.Object": "Object {0}", + "Timeline.Offset": "Additive offset", + "Timeline.Absolute": "Absolute", + "Timeline.ModeHint": "Track mode. Switching preserves keyframe values.", + "Timeline.KeyHere": "Key at this frame", + "Timeline.Sampled": "Sampled value", + "Timeline.EditRejected": "The edit could not be applied.", + "Timeline.InputHint": "Enter or leave the field to apply; Esc cancels. Switching entries cancels unconfirmed input.", + "Timeline.AnalysisIncomplete": "Some scene semantics are not analyzed. Candidates may be incomplete; existing references are retained.", + "Timeline.Definite": "Definitely present", + "Timeline.Possible": "Possibly present", + "Timeline.Unresolved": "Unresolved object {0}", + "Timeline.Kind.Layer": "Layer", + "Timeline.Kind.Particle": "Particle emitter", + "Timeline.Kind.Effect": "Effect", + "Timeline.RemoveOverride": "Remove override", + "Timeline.InitialUnknown": "Unknown initial value. Your input also sets frame 0; offsets start at 0.", + "Timeline.NotTracked": "Not tracked. Confirm a value or check to create a track.", + "Timeline.ValueRange": "The value is outside this property's supported range.", + "Timeline.Property.transform": "Transform", + "Timeline.Property.transform.x": "Position X", + "Timeline.Property.transform.y": "Position Y", + "Timeline.Property.transform.rotationDegrees": "Rotation", + "Timeline.Property.transform.scaleX": "Scale X", + "Timeline.Property.transform.scaleY": "Scale Y", + "Timeline.Property.opacity": "Opacity", + "Timeline.Property.flipbook": "Flipbook", + "Timeline.Property.flipbook.index": "Frame index", + "Timeline.Property.emissionRate": "Emission rate", + "Timeline.Property.initialVelocityX": "Initial velocity X", + "Timeline.Property.initialVelocityY": "Initial velocity Y", + "Timeline.Property.noise": "Noise", + "Timeline.Property.particleScale": "Particle scale", + "Timeline.Property.particleLifetime": "Particle lifetime", + "Timeline.Property.progress": "Progress", + "Timeline.PreviousKey": "Previous key", + "Timeline.NextKey": "Next key", + "Timeline.AddKey": "Add key", + "Timeline.DeleteKeys": "Delete keys", + "Timeline.CanvasHint": "Ruler: seek · Ctrl: multi-select/zoom · drag: move/box select · Delete · Esc: cancel", + "Timeline.Zoom": "Zoom", + "Timeline.ReplaceCollisions": "Replace colliding keys and apply", + "Timeline.CancelMove": "Cancel move", + "Timeline.KeyProtected": "The frame-zero key cannot be moved or deleted. Remove its track explicitly if needed.", + "Timeline.KeyOutside": "Keys must use a nonnegative frame within the supported range.", + "Timeline.KeyCollision": "The move or retiming collides with another key. Nothing was changed.", + "Timeline.SelectTrack": "Select a tracked property or key first.", + "Scene.Playback": "Playback", + "Scene.SearchPlayback": "Search playback type or source", + "Scene.ChoosePlayback": "Choose a playback instance", + "Scene.Target": "Scene target", + "Scene.None": "None", + "Scene.Unresolved": "Unresolved target", + "Scene.Search": "Search resources, objects or sources", + "Scene.CandidateHint": "Candidates use known state before this entry; unsupported behavior may affect results.", + "Scene.Global": "Whole scene (no layer)", + "Scene.Stale": "Content changed. Reopen the selector.", + "Scene.LoadFailed": "Could not load target resource information.", + "Scene.AddObject": "Add animated object", + "Scene.Duplicate": "The target already contains the same property track.", + "Scene.ChooseTarget": "Choose a scene object", + "Scene.AddProperty": "Add property override", + "Timeline.Key.Selected": "Selected keys", + "Timeline.Key.Value": "Value", + "Timeline.Key.Step": "Step", + "Timeline.Key.Linear": "Linear", + "Timeline.Key.Incoming": "In tangent", + "Timeline.Key.Outgoing": "Out tangent", + "Timeline.Key.Seconds": "Tangents per second", + "Timeline.Key.PerFrame": "value/frame", + "Timeline.Key.PerSecond": "value/second", + "Timeline.Key.Flat": "Flat tangents", + "Timeline.Key.LinearTangents": "Linear tangents", + "Timeline.Key.Smooth": "Smooth tangents", + "Timeline.Key.Aligned": "Align tangents", + "Timeline.Key.Hint": "Interpolation affects the following segment. Mixed values are blank; input applies to every selected key.", + "Form.Reset": "Reset to default", + "Form.Clear": "Clear", + "Form.Repair": "Advanced source / repair", + "Form.Object": "Properties", + "Form.List": "List", + "Form.PickColor": "Choose color", + "Form.ColorInvalid": "Enter a #RRGGBB or #AARRGGBB color.", + "Form.TransformInvalid": "Invalid transform. Repair the source or reset.", + "Form.OpenTimeline": "Open timeline", + "Form.TimelineSummary": "{0} frames · {1} FPS · {2} tracks", + "Form.TimelineEmpty": "Not created", + "Form.Transform.X": "X", + "Form.Transform.Y": "Y", + "Form.Transform.RotationDegrees": "Rotation °", + "Form.Transform.ScaleX": "Scale X", + "Form.Transform.ScaleY": "Scale Y", + "Form.NoDefault": "This field has no available default value.", + "Entry.Parameter.color": "Color", + "Entry.Parameter.transform": "Transform", + "Entry.Parameter.opacity": "Opacity", + "Entry.Parameter.toTransform": "Target transform", + "Entry.Parameter.timeline": "Timeline", + "Timeline.Curve.Interpolation": "Interpolation", + "Timeline.Curve.Hermite": "Bézier / Hermite", + "Timeline.Curve.Both": "Both tangents", + "Timeline.Curve.Free": "Free", + "Timeline.Curve.Flat": "Flat", + "Timeline.Curve.Linear": "Linear", + "Timeline.Curve.Auto": "Auto Bézier", + "Timeline.Curve.Linked": "Link tangents", + "Timeline.Curve.Broken": "Break tangents", + "Timeline.Curve.EditValues": "Edit values…", + "Timeline.Curve.Fit": "Fit values", + "Timeline.Curve.Hint": "Alt+wheel zooms values; Alt+drag pans. Drag keys to edit time and value; select keys to show tangent handles.", + "Scene.CreatedAt": "Created at frame {0}", + "Scene.FutureAt": "Created at frame {0} (not present yet)", + "Timeline.Event.Title": "Timeline events", + "Timeline.Event.Add": "Add event", + "Timeline.Event.Label": "Frame {0} · {1}", + "Timeline.Event.Edit": "Edit", + "Timeline.Event.Apply": "Apply", + "Timeline.Event.Cancel": "Cancel", + "Timeline.Event.Earlier": "Earlier at this frame", + "Timeline.Event.Later": "Later at this frame", + "Timeline.Event.Delete": "Delete events", + "Entry.Category.Timeline": "Timeline", + "Dock.Panel.Localization": "Game localization", + "I18n.Characters": "Characters", + "I18n.Branches": "Branch options", + "I18n.CharacterRow": "Character {0}", + "I18n.EntryRow": "Entry {0} · {1}", + "I18n.LanguageCode": "Language code", + "I18n.Add": "+ Add language", + "I18n.RevertDrafts": "Discard drafts", + "I18n.Development": "Development language", + "I18n.PreviewAction": "Check change", + "I18n.ApplyDevelopment": "Apply development language", + "I18n.Context": "Context", + "I18n.MoveLeft": "Move left", + "I18n.MoveRight": "Move right", + "I18n.Source": "Development", + "I18n.Navigate": "Go to reference", + "I18n.Missing": "Missing", + "I18n.Empty": "Empty", + "I18n.Review": "Needs review", + "I18n.Unknown": "Review unknown", + "I18n.Stale": "The text, reference, or development language changed. Keep this draft and review the current value.", + "I18n.Duplicate": "Language is already supported.", + "I18n.AddFirst": "Add the language first.", + "I18n.Preview": "Change development language to {0}: {1} referenced texts are missing. No text will be copied.", + "I18n.Incomplete": "Some references could not be analyzed. Missing counts may be incomplete.", + "I18n.UsePanel": "Change the development language in the game localization panel.", + "History.Languages": "Change project languages", + "I18n.OptionRow": "Option {0}", + "Error.i18n.language": "Enter a valid language code.", + "Error.i18n.stale": "The text, reference, or development language changed. Keep this draft and review the current value.", + "Error.i18n.duplicate": "Language is already supported.", + "Error.i18n.development": "Change the development language in the game localization panel.", + "Exchange.All": "All categories", + "Exchange.Runtime": "Runtime texts", + "Exchange.Export": "Export JSON", + "Exchange.Import": "Import preview", + "Exchange.AllowEmpty": "Allow empty translations to clear text", + "Exchange.Apply": "Apply selected", + "Exchange.Cancel": "Cancel preview", + "Exchange.Unknown": "Unknown text", + "Exchange.Skip": "Skipped (null)", + "Exchange.Clear": "Clear text", + "Exchange.Change": "Will change", + "Exchange.Unchanged": "Unchanged", + "History.ImportLocalization": "Import translations: {0}", + "Error.exchange.project": "The template belongs to another project or development language.", + "Error.exchange.language": "Choose a supported translation language other than the development language.", + "Error.exchange.stale": "The import preview is stale. Preview the file again.", + "Debug.CurrentValue": "Current value", + "Debug.EditValue": "New value", + "Debug.VariableConflict": "The game value changed. Reset before editing again.", + "Debug.NoVariables": "No variables in the current instance", + "Debug.NoChoices": "No choice is waiting for input", + "Debug.Working": "Working…", + "Debug.Preparing": "Preparing debug session…", + "Debug.SavingProject": "Saving project…", + "Debug.BuildingContent": "Building test content…", + "Debug.PreparingSourceMap": "Preparing author source map…", + "Debug.StartingApplication": "Starting application…", + "Debug.Menu": "Test", + "Debug.Step": "Step author entry", + "Debug.Breakpoint": "Toggle breakpoint", + "Debug.IsolatedStart": "Test from initial scene here", + "Debug.Panel": "Game debugging", + "Debug.Launch": "Launch application", + "Debug.Wait": "Wait for application", + "Debug.Start": "Start game", + "Debug.Stop": "Stop game", + "Debug.Pause": "Pause", + "Debug.Resume": "Continue", + "Debug.GroupEnd": "Current Group end", + "Debug.Cancel": "Cancel jump", + "Debug.Disconnect": "Disconnect", + "Debug.Configuration": "Launch settings", + "Debug.Executable": "Executable path", + "Debug.Arguments": "Arguments, one per line", + "Debug.WorkingDirectory": "Working directory (optional)", + "Debug.SaveConfiguration": "Save local settings", + "Debug.PlayerVariables": "Player variables", + "Debug.SaveVariables": "Save variables", + "Debug.Apply": "Apply", + "Debug.Choices": "Choices", + "Debug.Jump": "Jump here", + "Debug.SelectRoute": "Select route", + "Debug.ContentChanged": "Content changed; restart the test to enable location mapping.", + "Debug.NoRoute": "No route reaches this target.", + "Debug.RouteLimit": "Route search limit reached; select a shorter chain.", + "Entry.Type.variants.show": "Show image variants", + "Entry.Type.variants.select": "Select image variant", + "Entry.Parameter.dimension": "Dimension", + "Entry.Parameter.selection": "Initial selection", + "Variants.AmbiguousSource": "Atlas source varies by path", + "Variants.Title": "Image variants", + "Variants.Folder": "Image folder (relative to descriptor)", + "Variants.Default": "Default image", + "Variants.Label": "Display name", + "Variants.AddDimension": "Add dimension", + "Variants.Infer": "Infer dimensions", + "Variants.IdentityWarning": "Changed keys or order may invalidate story references. Check existing entries; references are not migrated automatically.", + "Variants.Apply": "Apply configuration", + "Variants.Refresh": "Rescan", + "Variants.ResetPreview": "Reset preview selection", + "Variants.Requested": "Requested selection", + "Variants.Actual": "Actual image", + "Variants.New": "Create image variants from folder", + "Variants.ChooseFolder": "Choose an imported image folder inside Assets.", + "Variants.Reason.Exact": "Exact match", + "Variants.Reason.Reduced": "Reduced variant", + "Variants.Reason.Default": "Default image", + "Variants.Inherit": "Use default selection", + "Uiux.Entry.ExpandAll": "Expand all", + "Uiux.Entry.CollapseAll": "Collapse all", + "Uiux.Save.Drafts": "Pending input", + "Uiux.Validation.Passed": "Author checks passed", + "Uiux.Validation.Stale": "Results out of date", + "Uiux.Save.Saved": "Saved", + "Uiux.Problems": "Problems", + "Uiux.Inspector.Lock": "Lock or unlock inspector target", + "Uiux.Draft.Invalid": "The input is incomplete or invalid.", + "Uiux.Save.Unsaved": "Unsaved", + "Uiux.Save.Failed": "Save failed", + "Uiux.Validation.Check": "Check current content", + "Uiux.Entry.Condition": "Condition", + "Uiux.Validation.Failed": "Issues found", + "Uiux.Validation.Unchecked": "Not checked", + "Uiux.Entry.Search": "Search entries or number", + "Uiux.Save.Saving": "Saving…", + "Uiux.Draft.Conflict": "The source or target changed. Review or discard this draft.", + "Uiux.Draft.Pending": "Unsubmitted input", + "Uiux.Save.PendingCount": "Pending input: {0}", + "Uiux.Graph.Map": "Overview", + "Uiux.Files.List": "List view", + "Uiux.Files.Empty": "This folder is empty. Import assets or create a file.", + "Uiux.Files.Audio": "Audio", + "Uiux.Layout.Animation": "Animation layout", + "Uiux.Timeline.Fit": "Fit duration", + "Uiux.Layout.Writing": "Writing layout", + "Uiux.Graph.Search": "Search node name or ID", + "Uiux.Files.All": "All types", + "Uiux.Files.SearchScope": "Search: entire project", + "Uiux.Files.Groups": "Group files", + "Uiux.Files.Other": "Other files", + "Uiux.Timeline.Playhead": "Locate playhead", + "Uiux.Files.Images": "Images", + "Uiux.Files.NoResults": "No matches. Adjust the search or filter.", + "Uiux.Inspector.Locked": "Locked", + "Uiux.Layout.Maximize": "Maximize panel / restore layout", + "Uiux.Layout.Debug": "Debug layout", + "Uiux.Graph.FitSelection": "Frame selection", + "Uiux.Graph.FitAll": "Frame all", + "Uiux.Files.Folders": "Folders", + "Uiux.Files.Video": "Video", + "Uiux.Validation.Incomplete": "Checks are incomplete", + "Uiux.Draft.Composing": "Finish composing text before saving.", + "Uiux.Severity.warning": "Warning", + "Uiux.Severity.info": "Info", + "Uiux.Severity.error": "Error", + "Uiux.Entry.Selected": "Selected", + "Uiux.Entry.ActiveOnly": "items; details edit the active entry only" } diff --git a/src/Editor/GalNet.Editor/Localization/ja-JP.json b/src/Editor/GalNet.Editor/Localization/ja-JP.json index 84d6321f..806e206c 100644 --- a/src/Editor/GalNet.Editor/Localization/ja-JP.json +++ b/src/Editor/GalNet.Editor/Localization/ja-JP.json @@ -1,4 +1,32 @@ { + "Error.text.changed": "テキスト参照が変更されました。下書きを再読み込みしてください。", + "Error.text.conflict": "同じ共有テキストに競合する変更があります。", + "History.GameText": "ゲームテキスト:{0}", + "GameText.Placeholder": "開発言語のテキストを入力", + "GameText.Missing": "開発言語のテキストがありません", + "GameText.Shared": "共有テキスト:{0} 箇所に反映されます", + "Error.text.localeChanged": "開発言語が変更されました。下書きを再読み込みしてください。", + "Form.CopyElement": "項目を複製", + "Dock.Panel.Characters": "登場人物", + "History.Character": "登場人物を編集:{0}", + "Characters.Add": "登場人物を追加", + "Characters.Search": "開発言語の名前またはキーで検索", + "Characters.Name": "開発言語の名前", + "Characters.Key": "一意のキー", + "Characters.AutoKey": "空欄で番号を自動割り当て", + "Characters.Apply": "適用", + "Characters.Revert": "下書きを戻す", + "Characters.Delete": "未使用の登場人物を削除", + "Characters.Incomplete": "参照解析が不完全なため、削除とキー変更は利用できません。", + "Characters.SpeakerHint": "名前またはキーを入力して選択。一致しない場合はフォーカスを外すと作成します", + "Error.character.name": "開発言語の名前を入力してください。", + "Error.character.key": "登場人物のキーは空にできません。", + "Error.character.duplicate": "このキーは既に使われています。", + "Error.character.changed": "登場人物または名前が変更されました。下書きを戻して再試行してください。", + "Error.character.inUse": "使用中の登場人物は削除できません。", + "Error.character.incomplete": "参照解析が不完全なため、安全に操作できません。", + "Error.character.ambiguous": "同名の登場人物が複数います。候補からキーを選択してください。", + "Error.character.obsolete": "プロジェクトまたは項目が変更され、この入力は無効になりました。", "Entry.AppendBatch": "エントリを追加", "Entry.Type.dialogue.show": "ダイアログを表示", "Entry.Type.dialogue.hide": "ダイアログを非表示", @@ -67,7 +95,7 @@ "Language.en-US": "英語", "Dock.Panel.NodeGraph": "Node Graph", "Dock.Panel.GamePreview": "Game Preview", - "Dock.Panel.Assets": "Assets", + "Dock.Panel.Assets": "プロジェクトファイル", "Dock.Panel.Log": "Log", "Dock.Panel.GroupEditor": "Group: {0}", "Dock.Panel.Inspector": "Inspector", @@ -313,7 +341,6 @@ "Entry.Parameter.asset": "アセット", "Entry.Parameter.x": "X", "Entry.Parameter.y": "Y", - "Entry.Parameter.z": "Z", "Entry.Parameter.transition": "トランジション", "Entry.Parameter.duration": "時間", "Entry.Parameter.channel": "チャンネル", @@ -328,6 +355,52 @@ "Entry.Parameter.valueType": "値の種類", "Entry.Parameter.expression": "式", "Entry.Parameter.category": "カテゴリ", + "Condition.Placeholder": "この条件が成立した場合のみ実行", + "Entry.Parameter.assetId": "アセット", + "Entry.Parameter.autoBlocking": "完了まで自動待機", + "Entry.Parameter.batchId": "バッチ ID", + "Entry.Parameter.bladeCount": "ブラインド数", + "Entry.Parameter.blendMode": "合成モード", + "Entry.Parameter.blocking": "完了を待機", + "Entry.Parameter.clearQueue": "キューを消去", + "Entry.Parameter.count": "数", + "Entry.Parameter.curve": "カーブ", + "Entry.Parameter.direction": "方向", + "Entry.Parameter.displayMode": "表示モード", + "Entry.Parameter.distance": "距離", + "Entry.Parameter.durationFrames": "長さ(フレーム)", + "Entry.Parameter.fadeIn": "フェードイン", + "Entry.Parameter.fadeInDuration": "フェードイン時間", + "Entry.Parameter.fadeOut": "フェードアウト", + "Entry.Parameter.fadeOutDuration": "フェードアウト時間", + "Entry.Parameter.flipbook": "フリップブック", + "Entry.Parameter.frameRate": "フレームレート", + "Entry.Parameter.from": "開始値", + "Entry.Parameter.fromLayerHandleId": "移行元レイヤー", + "Entry.Parameter.gain": "ゲイン", + "Entry.Parameter.handleId": "レイヤーハンドル", + "Entry.Parameter.holdDuration": "保持時間", + "Entry.Parameter.instanceId": "インスタンス ID", + "Entry.Parameter.loopMode": "ループモード", + "Entry.Parameter.maskProgram": "マスクプログラム", + "Entry.Parameter.newHandleId": "新しいレイヤーハンドル", + "Entry.Parameter.oldHandleId": "古いレイヤーハンドル", + "Entry.Parameter.order": "順序", + "Entry.Parameter.orientation": "向き", + "Entry.Parameter.overlayZ": "重ね合わせ Z 順", + "Entry.Parameter.placement": "配置", + "Entry.Parameter.playbackHandleId": "再生ハンドル", + "Entry.Parameter.program": "プログラム", + "Entry.Parameter.skippable": "スキップ可能", + "Entry.Parameter.skipPoints": "スキップ地点", + "Entry.Parameter.targetHandleId": "対象ハンドル", + "Entry.Parameter.to": "終了値", + "Entry.Parameter.toAssetId": "移行先アセット", + "Entry.Parameter.toDisplayMode": "移行先表示モード", + "Entry.Parameter.toLayerHandleId": "移行先レイヤー", + "Entry.Parameter.toZ": "移行先 Z 順", + "Entry.Parameter.trackId": "トラック", + "Entry.Parameter.z": "Z 順", "Entry.Option.None": "なし", "Entry.Option.fade": "フェード", "Entry.Option.dissolve": "ディゾルブ", @@ -345,6 +418,34 @@ "Entry.Option.Portrait": "立ち絵", "Entry.Option.Cg": "CG", "Entry.Option.Scene": "シーン", + "Entry.Option.false": "いいえ", + "Entry.Option.true": "はい", + "Entry.Option.Native": "元のサイズ", + "Entry.Option.Tile": "タイル", + "Entry.Option.Fill": "塗りつぶし", + "Entry.Option.Uniform": "全体を表示", + "Entry.Option.UniformToFill": "全体を覆って切り抜く", + "Entry.Option.Left": "左", + "Entry.Option.Right": "右", + "Entry.Option.Up": "上", + "Entry.Option.Down": "下", + "Entry.Option.Vertical": "垂直", + "Entry.Option.Horizontal": "水平", + "Entry.Option.Step": "ステップ", + "Entry.Option.EaseIn": "イーズイン", + "Entry.Option.EaseOut": "イーズアウト", + "Entry.Option.EaseInOut": "イーズインアウト", + "Entry.Option.PingPong": "往復", + "Entry.Option.Replace": "置き換え", + "Entry.Option.Additive": "加算", + "Entry.Option.AfterIteration": "現在の反復後", + "Entry.Option.CompleteImmediately": "すぐに完了", + "Entry.Option.enqueue": "キューに追加", + "Entry.Option.next": "次に再生", + "Entry.Option.replace": "現在を置き換え", + "Entry.Option.replaceAll": "すべて置き換え", + "Entry.Option.replaceIfDifferent": "異なる場合に置き換え", + "Entry.Option.ReplaceAll": "すべて置き換え", "NewProject.Name": "名前", "NewProject.Parent": "親フォルダー", "NewProject.Contracts": "カスタム契約フォルダー(任意)", @@ -442,5 +543,472 @@ "Settings.Project.RemoveTrack": "トラックを削除", "Settings.Project.Gain": "既定ゲイン", "Settings.Project.Voices": "最大同時再生数", - "Settings.Project.Queue": "キュー上限" + "Settings.Project.Queue": "キュー上限", + "History.AddBranchItem": "分岐項目を追加:{0}", + "History.RemoveBranchItem": "分岐項目を削除:{0}", + "History.MoveBranchItem": "分岐項目を移動:{0}", + "History.EditBranchItem": "分岐項目を編集:{0}", + "History.PasteNodes": "{0} 個のノードと内容を貼り付け", + "Error.graph.branchChanged": "分岐または項目が変更されました。現在の内容を確認して再試行してください。", + "Error.graph.clipboard": "クリップボードに対応する GalNet グラフ選択がありません。", + "Error.graph.clipboardUnavailable": "クリップボードを利用できません。", + "Graph.Menu.Copy": "ノードをコピー", + "Graph.Menu.Paste": "ノードを貼り付け", + "Graph.Menu.SelectAll": "すべてのノードを選択", + "Graph.Menu.FrameSelection": "選択ノードを表示", + "Graph.Menu.FrameAll": "グラフ全体を表示", + "Graph.Branch.Add": "分岐項目を追加", + "Graph.Branch.Remove": "分岐項目を削除", + "Graph.Branch.Text": "選択肢テキストキー", + "Graph.Branch.Condition": "条件式", + "Status.GraphPasted": "{0} 個のノードを貼り付けました。カスタム内容内の参照は元のままです。確認してください。", + "History.FileMove": "ファイルを移動:{0}", + "History.FileCopy": "ファイルをコピー:{0}", + "History.FileImport": "ファイルをインポート:{0}", + "History.FileDelete": "ファイルを削除:{0}", + "History.FileCreateFile": "ファイルを作成:{0}", + "History.FileCreateDirectory": "フォルダーを作成:{0}", + "History.FileMetadata": "リソース属性を編集:{0}", + "Error.files.path": "パスが無効かリンクを含んでいます:{0}", + "Error.files.protected": "このパスはプロジェクトの専用機能で管理されています:{0}", + "Error.files.missing": "ファイルまたはフォルダーが見つかりません:{0}", + "Error.files.collision": "移動先は使用中です。既存の内容は保持されました:{0}", + "Error.files.category": "異なるコンテンツ分類や言語フォルダーには移動できません:{0}", + "Error.files.changed": "ファイルまたはフォルダーが変更されました。確認して再試行してください:{0}", + "Error.files.referenced": "参照されているため削除しませんでした:{0}", + "Error.files.guidCollision": "リソース ID が重複しています。操作は適用されませんでした:{0}", + "Error.files.references": "未宣言の参照は自動で書き換えられません:{0}", + "Error.files.failed": "ファイル操作に失敗しました:{0}", + "Files.Back": "戻る", + "Files.Forward": "進む", + "Files.Up": "親フォルダー", + "Files.Path": "プロジェクト相対パス", + "Files.Search": "プロジェクトのファイル名を検索", + "Files.Import": "インポート", + "Files.NewText": "テキスト作成", + "Files.NewFolderName": "新しいフォルダー", + "Files.NewFileName": "新しいテキスト.txt", + "Files.Duplicate": "複製", + "Files.ItemCount": "{0} 項目", + "Files.DeleteQuestion": "選択項目を削除しますか?元に戻す操作で復元できます。", + "Files.DeleteGroups": "関連する Group ノードと接続も削除", + "Files.AllowCandidates": "文字列の参照候補を確認し削除する(確定した参照は保護)", + "Files.Cancel": "キャンセル", + "Files.ThumbnailSize": "サムネイルサイズ", + "Files.Document": "ファイル", + "Files.Apply": "編集を適用", + "Files.SaveAll": "すべて保存", + "Files.CancelDraft": "入力を取り消す", + "Files.OpenExternal": "外部で開く", + "Files.Missing": "ファイルは削除されました。削除を元に戻すと編集できます。", + "Files.ReadOnly": "読み取り専用:システム、バイナリ、大容量、または未対応の文字コードです。外部で開けます。", + "Files.ExternalChange": "外部で変更されました。編集内容は保持され、保存で外部の内容を上書きしません。プロジェクトを閉じて両方を確認し、開き直してください。", + "History.TextFile": "ファイル編集:{0}", + "Error.files.busy": "保存中です。少し待ってから開いてください:{0}", + "Error.files.galleryId": "Gallery 項目 ID が無効なため変更しませんでした:{0}", + "Files.New": "新規作成", + "Files.ClearSearch": "検索をクリア", + "Entry.SearchTypes": "エントリの種類を検索", + "Entry.BatchCount": "挿入・追加するエントリ数", + "Entry.AnchorMissing": "挿入位置が削除されました。選び直してください。", + "Entry.Type.dialogue.text": "テキスト", + "Entry.Type.flow.wait": "待機", + "Entry.Type.gallery.unlock": "ギャラリー解除", + "Entry.Type.animation.animate": "プロパティアニメーション", + "Entry.Type.timeline.play": "タイムラインを再生", + "Entry.Type.animation.stop": "アニメーション停止", + "Entry.Type.layer.showColor": "単色レイヤー表示", + "Entry.Type.layer.replace": "レイヤー置換", + "Entry.Type.audio.next": "次の音声", + "Entry.Type.audio.clear": "音声キュー消去", + "Entry.Type.audio.seffect.apply": "音声エフェクト適用", + "Entry.Type.audio.seffect.stop": "音声エフェクト停止", + "Entry.Type.nvl.show": "NVL 表示", + "Entry.Type.nvl.hide": "NVL 非表示", + "Entry.Type.nvl.clear": "NVL 消去", + "Entry.Type.nvl.append": "NVL テキスト追加", + "Entry.Type.particle.play": "パーティクル再生", + "Entry.Type.particle.stop": "パーティクル停止", + "Entry.Type.particle.burst": "パーティクル放出", + "Entry.Type.dialogue.textVoice": "音声付き会話", + "Entry.Type.nvl.appendVoice": "音声付き NVL 追加", + "Entry.Type.transition.crossFade": "クロスフェード", + "Entry.Type.transition.slide": "スライド切替", + "Entry.Type.transition.blinds": "ブラインド切替", + "Entry.Type.transition.fadeBlack": "黒フェード", + "Entry.Type.transition.fadeWhite": "白フェード", + "Entry.Type.transition.fadeColor": "カラーフェード", + "Entry.Category.Animation": "アニメーション", + "Entry.Category.Nvl": "NVL", + "Entry.Category.Particle": "パーティクル", + "Entry.Category.Transition": "トランジション", + "Entry.Category.dialogue": "ダイアログ", + "Entry.Category.layer": "レイヤー", + "Entry.Category.audio": "オーディオ", + "Entry.Category.video": "ビデオ", + "Entry.Category.effect": "エフェクト", + "Entry.Category.flow": "フロー", + "Entry.Category.variable": "変数", + "Entry.Category.gallery": "ギャラリー", + "Entry.Category.animation": "アニメーション", + "Entry.Category.timeline": "タイムライン", + "Entry.Category.nvl": "NVL", + "Entry.Category.particle": "パーティクル", + "Entry.Category.transition": "トランジション", + "Entry.Menu.Copy": "エントリをコピー", + "Entry.Menu.Cut": "エントリを切り取り", + "Entry.Menu.Paste": "エントリを貼り付け", + "Entry.Menu.Duplicate": "エントリを複製", + "Entry.Menu.Delete": "選択したエントリを削除", + "Entry.Menu.SelectAll": "すべて選択", + "History.PasteEntries": "{0} 件のエントリを貼り付け", + "History.DeleteEntries": "{0} 件のエントリを削除", + "History.MoveEntries": "エントリを移動", + "Error.entry.clipboard": "対応する GalNet エントリではありません(最大 1,000 件、テキスト 16 MiB)。", + "Error.entry.clipboardSchema": "このプロジェクトの契約はクリップボードの型またはフィールドに対応していません。貼り付けは行われませんでした。", + "Error.entry.clipboardUnavailable": "クリップボードを利用できません。", + "Error.entry.groupMissing": "Group は削除されました。", + "Error.entry.anchorMissing": "挿入先が削除されました。選択し直してください。", + "Error.entry.cutChanged": "クリップボードへの書き込み中に元のエントリが変更されたため、削除しませんでした。", + "Dock.Panel.Variables": "変数", + "Variables.Player": "プレイヤー変数", + "Variables.Save": "セーブ変数", + "Variables.PlayerHint": "プレイヤー変数はプレイヤー設定に永続化され、セーブ間で共有されます。ここでは宣言と初期値を編集します。", + "Variables.SaveHint": "セーブ変数はセーブデータと共に保存・復元されます。ここでは宣言と初期値を編集します。", + "Variables.Add": "変数を追加", + "Variables.Name": "名前", + "Variables.Type": "型", + "Variables.Default": "初期値", + "Variables.Description": "説明", + "Variables.Apply": "適用", + "Variables.Revert": "下書きを戻す", + "Variables.Delete": "変数を削除", + "Variables.References": "参照を確認", + "Variables.ReferenceHint": "明確な参照は名前変更時に更新され、削除を防ぎます。通常テキストや不確かな式は自動置換せず表示します。", + "Variables.Certain": "明確な参照", + "Variables.Possible": "候補テキスト(変更なし)", + "Variables.Candidates": "{0} 件の候補テキストを保持しました。以下の場所を確認してください。", + "Variables.Type.Bool": "真偽値", + "Variables.Type.Int": "整数", + "Variables.Type.Float": "浮動小数点数", + "Variables.Type.String": "テキスト", + "Variables.DefaultHint.Bool": "チェックありは true、チェックなしは false です。", + "Variables.DefaultHint.Int": "32 ビット整数を入力してください。", + "Variables.DefaultHint.Float": "小数点を使用して有限の数を入力してください。", + "Variables.DefaultHint.String": "JSON の引用符を付けずに入力してください。", + "History.Variable.Add": "変数 {0} を追加", + "History.Variable.Update": "変数 {0} を編集", + "History.Variable.Delete": "変数 {0} を削除", + "Error.variable.missing": "変数は存在しません。", + "Error.variable.name": "英字または下線で始め、英字・数字・下線を使用してください。キーワードと Gallery 名は予約済みです。", + "Error.variable.duplicate": "プレイヤー変数とセーブ変数でこの名前は既に使用されています。", + "Error.variable.default": "初期値が選択した型に適合しません。", + "Error.variable.inUse": "明確な参照があります。参照を削除してから変数を削除してください。", + "Error.variable.changed": "変数または参照が変更されました。下書きを戻して再編集してください。", + "History.EntryType": "エントリの種類を編集", + "History.EntryCondition": "エントリの条件を編集", + "History.EntryParameters": "エントリのパラメーターを編集", + "Error.entry.obsolete": "エントリが削除されたか種類が変更されました。エディターを開き直してください。", + "Error.entry.changed": "エントリが別の操作で変更されました。更新して再試行してください。", + "Entry.UnknownTypePreserved": "現在のコントラクトにはこの種類の編集フォームがありません。内容は保持され、保存や種類の変更ができます。検証・コンパイルには有効なコントラクトが必要です。", + "Entry.UnknownParametersPreserved": "現在のコントラクトにないパラメーターも保存時に保持されます。検証やコンパイルでエラーになる場合があります。", + "Form.Default": "既定値を明示的に設定", + "Form.Omit": "既定値を書き込まずにフィールドを省略", + "Form.Null": "明示的な null を設定", + "Form.Revert": "未確定の入力を元に戻す", + "Form.AddElement": "要素を追加", + "Form.RemoveElement": "要素を削除", + "Form.MoveUp": "上へ", + "Form.MoveDown": "下へ", + "Dock.Panel.Timeline": "タイムライン", + "Timeline.SelectEntry": "タイムラインの項目を選択してください。", + "Timeline.Unsupported": "この項目はタイムライン編集に対応していません。", + "Timeline.Missing": "タイムラインがまだありません。", + "Timeline.Create": "タイムラインを作成", + "Timeline.Editing": "タイムライン編集中", + "Timeline.Invalid": "内容が無効です。項目の JSON パラメーターを修正してください。", + "Timeline.Frame": "現在のフレーム", + "Timeline.Duration": "終了フレーム", + "Timeline.Empty": "編集可能なオブジェクトや既存トラックがありません。このエントリの前にシーンオブジェクトを作成してください。", + "Timeline.Object": "オブジェクト {0}", + "Timeline.Offset": "加算オフセット", + "Timeline.Absolute": "絶対値", + "Timeline.ModeHint": "トラックモード。切り替えてもキーフレーム値を保持します。", + "Timeline.KeyHere": "現在のフレームにキーあり", + "Timeline.Sampled": "サンプル値", + "Timeline.EditRejected": "変更を適用できませんでした。", + "Timeline.InputHint": "Enter またはフォーカス移動で適用、Esc で取消。項目の切替時は未確定の入力を取り消します。", + "Timeline.AnalysisIncomplete": "未解析のシーン操作があります。候補は不完全ですが既存の参照は保持されます。", + "Timeline.Definite": "確実に存在", + "Timeline.Possible": "存在する可能性あり", + "Timeline.Unresolved": "未解決のオブジェクト {0}", + "Timeline.Kind.Layer": "レイヤー", + "Timeline.Kind.Particle": "パーティクル", + "Timeline.Kind.Effect": "エフェクト", + "Timeline.RemoveOverride": "オーバーライドを解除", + "Timeline.InitialUnknown": "初期値は不明です。入力値をフレーム0にも使用します。オフセットは0から開始。", + "Timeline.NotTracked": "未登録。値を確定するかチェックしてトラックを作成。", + "Timeline.ValueRange": "値がこのプロパティの範囲外です。", + "Timeline.Property.transform": "トランスフォーム", + "Timeline.Property.transform.x": "位置 X", + "Timeline.Property.transform.y": "位置 Y", + "Timeline.Property.transform.rotationDegrees": "回転", + "Timeline.Property.transform.scaleX": "スケール X", + "Timeline.Property.transform.scaleY": "スケール Y", + "Timeline.Property.opacity": "不透明度", + "Timeline.Property.flipbook": "フリップブック", + "Timeline.Property.flipbook.index": "フレーム番号", + "Timeline.Property.emissionRate": "放出率", + "Timeline.Property.initialVelocityX": "初速度 X", + "Timeline.Property.initialVelocityY": "初速度 Y", + "Timeline.Property.noise": "ノイズ", + "Timeline.Property.particleScale": "パーティクルサイズ", + "Timeline.Property.particleLifetime": "パーティクル寿命", + "Timeline.Property.progress": "進行度", + "Timeline.PreviousKey": "前のキー", + "Timeline.NextKey": "次のキー", + "Timeline.AddKey": "現在フレームにキー追加", + "Timeline.DeleteKeys": "選択キーを削除", + "Timeline.CanvasHint": "目盛りで移動 · Ctrlで複数選択/拡大 · ドラッグで移動/範囲選択 · Delete削除 · Esc取消", + "Timeline.Zoom": "ズーム", + "Timeline.ReplaceCollisions": "衝突キーを置換して適用", + "Timeline.CancelMove": "移動を取消", + "Timeline.KeyProtected": "フレーム0のキーは移動・削除できません。必要ならトラック全体を削除してください。", + "Timeline.KeyOutside": "キーフレームは対応範囲内の非負フレームに置いてください。", + "Timeline.KeyCollision": "移動または時間変更でキーが衝突します。内容は変更していません。", + "Timeline.SelectTrack": "先にトラックのプロパティまたはキーを選択してください。", + "Scene.Playback": "再生インスタンス", + "Scene.SearchPlayback": "再生の種類や作成元を検索", + "Scene.ChoosePlayback": "再生インスタンスを選択", + "Scene.Target": "シーン対象", + "Scene.None": "未選択", + "Scene.Unresolved": "未解決の対象", + "Scene.Search": "リソース・対象・作成元を検索", + "Scene.CandidateHint": "このエントリー直前の既知状態に基づく候補です。未対応の動作が影響する場合があります。", + "Scene.Global": "シーン全体(レイヤーなし)", + "Scene.Stale": "内容が変更されました。選択画面を開き直してください。", + "Scene.LoadFailed": "対象のリソース情報を読み込めませんでした。", + "Scene.AddObject": "アニメーション対象を追加", + "Scene.Duplicate": "同じプロパティのトラックが対象に存在します。", + "Scene.ChooseTarget": "シーン対象を選択", + "Scene.AddProperty": "プロパティオーバーライドを追加", + "Timeline.Key.Selected": "選択キーフレーム", + "Timeline.Key.Value": "値", + "Timeline.Key.Step": "ステップ", + "Timeline.Key.Linear": "線形", + "Timeline.Key.Incoming": "入接線", + "Timeline.Key.Outgoing": "出接線", + "Timeline.Key.Seconds": "接線を秒単位で表示", + "Timeline.Key.PerFrame": "値/フレーム", + "Timeline.Key.PerSecond": "値/秒", + "Timeline.Key.Flat": "平坦な接線", + "Timeline.Key.LinearTangents": "線形接線", + "Timeline.Key.Smooth": "滑らかな接線", + "Timeline.Key.Aligned": "接線を整列", + "Timeline.Key.Hint": "補間は次の区間に適用されます。混在する値は空欄で、入力は選択した全キーに適用されます。", + "Form.Reset": "既定値に戻す", + "Form.Clear": "クリア", + "Form.Repair": "詳細ソース / 修復", + "Form.Object": "プロパティ", + "Form.List": "リスト", + "Form.PickColor": "色を選択", + "Form.ColorInvalid": "#RRGGBB または #AARRGGBB の色を入力してください。", + "Form.TransformInvalid": "変換値が無効です。ソースを修復するかリセットしてください。", + "Form.OpenTimeline": "タイムラインを開く", + "Form.TimelineSummary": "{0} フレーム · {1} FPS · {2} トラック", + "Form.TimelineEmpty": "未作成", + "Form.Transform.X": "X", + "Form.Transform.Y": "Y", + "Form.Transform.RotationDegrees": "回転 °", + "Form.Transform.ScaleX": "拡大 X", + "Form.Transform.ScaleY": "拡大 Y", + "Form.NoDefault": "この項目には利用可能な既定値がありません", + "Entry.Parameter.color": "色", + "Entry.Parameter.transform": "変換", + "Entry.Parameter.opacity": "不透明度", + "Entry.Parameter.toTransform": "移動先の変換", + "Entry.Parameter.timeline": "タイムライン", + "Timeline.Curve.Interpolation": "補間", + "Timeline.Curve.Hermite": "ベジェ / Hermite", + "Timeline.Curve.Both": "両側の接線", + "Timeline.Curve.Free": "自由", + "Timeline.Curve.Flat": "水平", + "Timeline.Curve.Linear": "線形", + "Timeline.Curve.Auto": "自動ベジェ", + "Timeline.Curve.Linked": "接線を連動", + "Timeline.Curve.Broken": "接線を分離", + "Timeline.Curve.EditValues": "値を編集…", + "Timeline.Curve.Fit": "値域に合わせる", + "Timeline.Curve.Hint": "Alt+ホイールで値域を拡大縮小、Alt+ドラッグで移動。キーをドラッグして時間と値を変更し、選択して接線ハンドルを表示します。", + "Scene.CreatedAt": "フレーム {0} で作成", + "Scene.FutureAt": "フレーム {0} で作成(まだ存在しません)", + "Timeline.Event.Title": "タイムラインイベント", + "Timeline.Event.Add": "イベント追加", + "Timeline.Event.Label": "フレーム {0} · {1}", + "Timeline.Event.Edit": "編集", + "Timeline.Event.Apply": "適用", + "Timeline.Event.Cancel": "キャンセル", + "Timeline.Event.Earlier": "同一フレームで前へ", + "Timeline.Event.Later": "同一フレームで後へ", + "Timeline.Event.Delete": "イベント削除", + "Entry.Category.Timeline": "タイムライン", + "Dock.Panel.Localization": "ゲームのローカライズ", + "I18n.Characters": "キャラクター表", + "I18n.Branches": "分岐の選択肢", + "I18n.CharacterRow": "キャラクター {0}", + "I18n.EntryRow": "項目 {0} · {1}", + "I18n.LanguageCode": "言語コード", + "I18n.Add": "+ 言語を追加", + "I18n.RevertDrafts": "下書きを破棄", + "I18n.Development": "開発言語", + "I18n.PreviewAction": "変更を確認", + "I18n.ApplyDevelopment": "開発言語を適用", + "I18n.Context": "コンテキスト", + "I18n.MoveLeft": "左へ移動", + "I18n.MoveRight": "右へ移動", + "I18n.Source": "開発言語", + "I18n.Navigate": "参照へ移動", + "I18n.Missing": "未翻訳", + "I18n.Empty": "空の文字列", + "I18n.Review": "要確認", + "I18n.Unknown": "確認状態不明", + "I18n.Stale": "原文、参照、または開発言語が変更されました。下書きを保持し、現在の値を確認してください。", + "I18n.Duplicate": "この言語は既に対応しています。", + "I18n.AddFirst": "先に言語を追加してください。", + "I18n.Preview": "開発言語を {0} に変更:参照テキスト {1} 件が未翻訳です。原文はコピーされません。", + "I18n.Incomplete": "一部の参照を解析できません。未翻訳数は不完全な可能性があります。", + "I18n.UsePanel": "ゲームのローカライズ画面で開発言語を確認・変更してください。", + "History.Languages": "プロジェクトの言語を変更", + "I18n.OptionRow": "選択肢 {0}", + "Error.i18n.language": "有効な言語コードを入力してください。", + "Error.i18n.stale": "原文、参照、または開発言語が変更されました。下書きを保持し、現在の値を確認してください。", + "Error.i18n.duplicate": "この言語は既に対応しています。", + "Error.i18n.development": "ゲームのローカライズ画面で開発言語を確認・変更してください。", + "Exchange.All": "全カテゴリ", + "Exchange.Runtime": "実行用テキスト", + "Exchange.Export": "JSON を書き出す", + "Exchange.Import": "読み込みプレビュー", + "Exchange.AllowEmpty": "空文字で翻訳を消去する", + "Exchange.Apply": "選択項目を適用", + "Exchange.Cancel": "プレビューを閉じる", + "Exchange.Unknown": "不明なテキスト", + "Exchange.Skip": "スキップ(null)", + "Exchange.Clear": "翻訳を消去", + "Exchange.Change": "変更予定", + "Exchange.Unchanged": "変更なし", + "History.ImportLocalization": "翻訳を読み込む:{0}", + "Error.exchange.project": "テンプレートのプロジェクトまたは開発言語が異なります。", + "Error.exchange.language": "開発言語以外の対応言語を選択してください。", + "Error.exchange.stale": "プレビューが古くなりました。ファイルを再確認してください。", + "Debug.CurrentValue": "現在の値", + "Debug.EditValue": "変更後の値", + "Debug.VariableConflict": "ゲーム内の値が変更されました。リセットしてから再編集してください。", + "Debug.NoVariables": "現在のインスタンスに表示できる変数はありません", + "Debug.NoChoices": "現在、選択待ちの項目はありません", + "Debug.Working": "処理中…", + "Debug.Preparing": "デバッグセッションを準備中…", + "Debug.SavingProject": "プロジェクトを保存中…", + "Debug.BuildingContent": "テストコンテンツをビルド中…", + "Debug.PreparingSourceMap": "作者ソースマップを準備中…", + "Debug.StartingApplication": "アプリを起動中…", + "Debug.Menu": "テスト", + "Debug.Step": "作成エントリをステップ実行", + "Debug.Breakpoint": "ブレークポイント切替", + "Debug.IsolatedStart": "初期シーンからここをテスト", + "Debug.Panel": "ゲームデバッグ", + "Debug.Launch": "アプリを起動", + "Debug.Wait": "接続を待機", + "Debug.Start": "ゲーム開始", + "Debug.Stop": "ゲーム停止", + "Debug.Pause": "一時停止", + "Debug.Resume": "続行", + "Debug.GroupEnd": "現在の Group の末尾", + "Debug.Cancel": "ジャンプを取消", + "Debug.Disconnect": "切断", + "Debug.Configuration": "起動設定", + "Debug.Executable": "実行ファイル", + "Debug.Arguments": "引数(1行に1つ)", + "Debug.WorkingDirectory": "作業ディレクトリ(任意)", + "Debug.SaveConfiguration": "ローカル設定を保存", + "Debug.PlayerVariables": "プレイヤー変数", + "Debug.SaveVariables": "セーブ変数", + "Debug.Apply": "適用", + "Debug.Choices": "選択肢", + "Debug.Jump": "ここへジャンプ", + "Debug.SelectRoute": "経路を選択", + "Debug.ContentChanged": "内容が変更されました。テストを再起動してください。", + "Debug.NoRoute": "到達できる経路がありません。", + "Debug.RouteLimit": "経路の検索上限に達しました。短いノード列を選択してください。", + "Entry.Type.variants.show": "差分画像を表示", + "Entry.Type.variants.select": "画像差分を切り替え", + "Entry.Parameter.dimension": "次元", + "Entry.Parameter.selection": "初期選択", + "Variants.AmbiguousSource": "経路によって差分画像の参照元が異なります", + "Variants.Title": "画像差分セット", + "Variants.Folder": "画像フォルダー(定義からの相対パス)", + "Variants.Default": "既定の画像", + "Variants.Label": "表示名", + "Variants.AddDimension": "次元を追加", + "Variants.Infer": "次元を再推定", + "Variants.IdentityWarning": "キーや順序の変更はシナリオの参照に影響します。既存の項目を確認してください。参照は自動移行されません。", + "Variants.Apply": "設定を適用", + "Variants.Refresh": "再スキャン", + "Variants.ResetPreview": "プレビュー選択をリセット", + "Variants.Requested": "要求した選択", + "Variants.Actual": "実際の画像", + "Variants.New": "フォルダーから画像差分を作成", + "Variants.ChooseFolder": "Assets 内のインポート済み画像フォルダーを選択してください。", + "Variants.Reason.Exact": "完全一致", + "Variants.Reason.Reduced": "簡略化した差分", + "Variants.Reason.Default": "既定の画像", + "Variants.Inherit": "既定の選択を使用", + "Uiux.Entry.ExpandAll": "すべて展開", + "Uiux.Entry.CollapseAll": "すべて折りたたむ", + "Uiux.Save.Drafts": "未確定の入力", + "Uiux.Validation.Passed": "作成内容の検証に合格", + "Uiux.Validation.Stale": "検証結果が古くなっています", + "Uiux.Save.Saved": "保存済み", + "Uiux.Problems": "問題", + "Uiux.Inspector.Lock": "検査対象を固定・解除", + "Uiux.Draft.Invalid": "入力が未完了または無効です。", + "Uiux.Save.Unsaved": "未保存", + "Uiux.Save.Failed": "保存失敗", + "Uiux.Validation.Check": "現在の内容を検証", + "Uiux.Entry.Condition": "実行条件", + "Uiux.Validation.Failed": "問題あり", + "Uiux.Validation.Unchecked": "未検証", + "Uiux.Entry.Search": "項目の内容・番号を検索", + "Uiux.Save.Saving": "保存中…", + "Uiux.Draft.Conflict": "元の値または対象が変更されました。下書きを確認・破棄してください。", + "Uiux.Draft.Pending": "未確定の入力があります", + "Uiux.Save.PendingCount": "未確定の入力:{0}", + "Uiux.Graph.Map": "ミニマップ", + "Uiux.Files.List": "一覧表示", + "Uiux.Files.Empty": "このフォルダーは空です。素材を読み込むかファイルを作成できます。", + "Uiux.Files.Audio": "音声", + "Uiux.Layout.Animation": "アニメーション編集レイアウト", + "Uiux.Timeline.Fit": "全区間を表示", + "Uiux.Layout.Writing": "シナリオ編集レイアウト", + "Uiux.Graph.Search": "ノード名・IDを検索", + "Uiux.Files.All": "すべての種類", + "Uiux.Files.SearchScope": "検索対象:プロジェクト全体", + "Uiux.Files.Groups": "シナリオファイル", + "Uiux.Files.Other": "その他のファイル", + "Uiux.Timeline.Playhead": "再生ヘッドを表示", + "Uiux.Files.Images": "画像", + "Uiux.Files.NoResults": "一致する項目がありません。検索・フィルターを変更してください。", + "Uiux.Inspector.Locked": "固定中", + "Uiux.Layout.Maximize": "パネル最大化・元に戻す", + "Uiux.Layout.Debug": "デバッグレイアウト", + "Uiux.Graph.FitSelection": "選択項目を表示", + "Uiux.Graph.FitAll": "全体を表示", + "Uiux.Files.Folders": "フォルダー", + "Uiux.Files.Video": "動画", + "Uiux.Validation.Incomplete": "検証は未完了です", + "Uiux.Draft.Composing": "文字の変換を確定してから保存してください。", + "Uiux.Severity.warning": "警告", + "Uiux.Severity.info": "情報", + "Uiux.Severity.error": "エラー", + "Uiux.Entry.Selected": "選択数", + "Uiux.Entry.ActiveOnly": "件・詳細は現在の項目のみ編集" } diff --git a/src/Editor/GalNet.Editor/Localization/ko-KR.json b/src/Editor/GalNet.Editor/Localization/ko-KR.json index 14895755..9139e8f2 100644 --- a/src/Editor/GalNet.Editor/Localization/ko-KR.json +++ b/src/Editor/GalNet.Editor/Localization/ko-KR.json @@ -1,4 +1,32 @@ { + "Error.text.changed": "텍스트 참조가 변경되었습니다. 초안을 다시 불러오세요.", + "Error.text.conflict": "동일한 공유 텍스트의 수정 내용이 충돌합니다.", + "History.GameText": "게임 텍스트: {0}", + "GameText.Placeholder": "개발 언어 텍스트 입력", + "GameText.Missing": "개발 언어 텍스트가 없습니다", + "GameText.Shared": "공유 텍스트: {0}개 참조에 반영됩니다", + "Error.text.localeChanged": "개발 언어가 변경되었습니다. 초안을 다시 불러오세요.", + "Form.CopyElement": "항목 복제", + "Dock.Panel.Characters": "등장인물", + "History.Character": "등장인물 편집: {0}", + "Characters.Add": "등장인물 추가", + "Characters.Search": "개발 언어 이름 또는 키 검색", + "Characters.Name": "개발 언어 이름", + "Characters.Key": "고유 키", + "Characters.AutoKey": "비워 두면 번호 자동 할당", + "Characters.Apply": "적용", + "Characters.Revert": "초안 되돌리기", + "Characters.Delete": "사용하지 않는 등장인물 삭제", + "Characters.Incomplete": "참조 분석이 불완전하여 삭제와 키 변경을 사용할 수 없습니다.", + "Characters.SpeakerHint": "이름이나 키를 입력하거나 선택하세요. 일치하지 않으면 포커스를 잃을 때 생성합니다", + "Error.character.name": "개발 언어 등장인물 이름을 입력하세요.", + "Error.character.key": "등장인물 키는 비워 둘 수 없습니다.", + "Error.character.duplicate": "이미 존재하는 등장인물 키입니다.", + "Error.character.changed": "등장인물이나 이름이 변경되었습니다. 초안을 되돌린 후 다시 시도하세요.", + "Error.character.inUse": "사용 중인 등장인물은 삭제할 수 없습니다.", + "Error.character.incomplete": "참조 분석이 불완전하여 안전하게 작업할 수 없습니다.", + "Error.character.ambiguous": "이름이 같은 등장인물이 여럿입니다. 제안 목록에서 키를 선택하세요.", + "Error.character.obsolete": "프로젝트나 항목이 변경되어 이 입력은 더 이상 유효하지 않습니다.", "Entry.AppendBatch": "엔트리 추가", "Entry.Type.dialogue.show": "대화 상자 표시", "Entry.Type.dialogue.hide": "대화 상자 숨기기", @@ -67,7 +95,7 @@ "Language.en-US": "영어", "Dock.Panel.NodeGraph": "Node Graph", "Dock.Panel.GamePreview": "Game Preview", - "Dock.Panel.Assets": "Assets", + "Dock.Panel.Assets": "프로젝트 파일", "Dock.Panel.Log": "Log", "Dock.Panel.GroupEditor": "Group: {0}", "Dock.Panel.Inspector": "Inspector", @@ -313,7 +341,6 @@ "Entry.Parameter.asset": "에셋", "Entry.Parameter.x": "X", "Entry.Parameter.y": "Y", - "Entry.Parameter.z": "Z", "Entry.Parameter.transition": "전환", "Entry.Parameter.duration": "시간", "Entry.Parameter.channel": "채널", @@ -328,6 +355,52 @@ "Entry.Parameter.valueType": "값 유형", "Entry.Parameter.expression": "식", "Entry.Parameter.category": "분류", + "Condition.Placeholder": "이 조건이 참일 때만 실행", + "Entry.Parameter.assetId": "에셋", + "Entry.Parameter.autoBlocking": "완료까지 자동 대기", + "Entry.Parameter.batchId": "배치 ID", + "Entry.Parameter.bladeCount": "블라인드 수", + "Entry.Parameter.blendMode": "혼합 모드", + "Entry.Parameter.blocking": "완료 대기", + "Entry.Parameter.clearQueue": "대기열 비우기", + "Entry.Parameter.count": "개수", + "Entry.Parameter.curve": "곡선", + "Entry.Parameter.direction": "방향", + "Entry.Parameter.displayMode": "표시 모드", + "Entry.Parameter.distance": "거리", + "Entry.Parameter.durationFrames": "재생 시간(프레임)", + "Entry.Parameter.fadeIn": "페이드 인", + "Entry.Parameter.fadeInDuration": "페이드 인 시간", + "Entry.Parameter.fadeOut": "페이드 아웃", + "Entry.Parameter.fadeOutDuration": "페이드 아웃 시간", + "Entry.Parameter.flipbook": "플립북", + "Entry.Parameter.frameRate": "프레임 속도", + "Entry.Parameter.from": "시작 값", + "Entry.Parameter.fromLayerHandleId": "원본 레이어", + "Entry.Parameter.gain": "게인", + "Entry.Parameter.handleId": "레이어 핸들", + "Entry.Parameter.holdDuration": "유지 시간", + "Entry.Parameter.instanceId": "인스턴스 ID", + "Entry.Parameter.loopMode": "반복 모드", + "Entry.Parameter.maskProgram": "마스크 프로그램", + "Entry.Parameter.newHandleId": "새 레이어 핸들", + "Entry.Parameter.oldHandleId": "이전 레이어 핸들", + "Entry.Parameter.order": "순서", + "Entry.Parameter.orientation": "방향", + "Entry.Parameter.overlayZ": "오버레이 Z 순서", + "Entry.Parameter.placement": "배치 방식", + "Entry.Parameter.playbackHandleId": "재생 핸들", + "Entry.Parameter.program": "프로그램", + "Entry.Parameter.skippable": "건너뛰기 허용", + "Entry.Parameter.skipPoints": "건너뛰기 지점", + "Entry.Parameter.targetHandleId": "대상 핸들", + "Entry.Parameter.to": "끝 값", + "Entry.Parameter.toAssetId": "대상 에셋", + "Entry.Parameter.toDisplayMode": "대상 표시 모드", + "Entry.Parameter.toLayerHandleId": "대상 레이어", + "Entry.Parameter.toZ": "대상 Z 순서", + "Entry.Parameter.trackId": "트랙", + "Entry.Parameter.z": "Z 순서", "Entry.Option.None": "없음", "Entry.Option.fade": "페이드", "Entry.Option.dissolve": "디졸브", @@ -345,6 +418,34 @@ "Entry.Option.Portrait": "인물", "Entry.Option.Cg": "CG", "Entry.Option.Scene": "장면", + "Entry.Option.false": "아니요", + "Entry.Option.true": "예", + "Entry.Option.Native": "원본 크기", + "Entry.Option.Tile": "타일", + "Entry.Option.Fill": "채우기", + "Entry.Option.Uniform": "맞춤", + "Entry.Option.UniformToFill": "채우고 자르기", + "Entry.Option.Left": "왼쪽", + "Entry.Option.Right": "오른쪽", + "Entry.Option.Up": "위쪽", + "Entry.Option.Down": "아래쪽", + "Entry.Option.Vertical": "세로", + "Entry.Option.Horizontal": "가로", + "Entry.Option.Step": "단계", + "Entry.Option.EaseIn": "서서히 시작", + "Entry.Option.EaseOut": "서서히 종료", + "Entry.Option.EaseInOut": "서서히 시작 및 종료", + "Entry.Option.PingPong": "왕복 반복", + "Entry.Option.Replace": "바꾸기", + "Entry.Option.Additive": "더하기", + "Entry.Option.AfterIteration": "현재 반복 후", + "Entry.Option.CompleteImmediately": "즉시 완료", + "Entry.Option.enqueue": "대기열에 추가", + "Entry.Option.next": "다음 재생", + "Entry.Option.replace": "현재 항목 바꾸기", + "Entry.Option.replaceAll": "모두 바꾸기", + "Entry.Option.replaceIfDifferent": "다를 때 바꾸기", + "Entry.Option.ReplaceAll": "모두 바꾸기", "NewProject.Name": "이름", "NewProject.Parent": "상위 폴더", "NewProject.Contracts": "사용자 계약 폴더 (선택)", @@ -442,5 +543,472 @@ "Settings.Project.RemoveTrack": "트랙 제거", "Settings.Project.Gain": "기본 게인", "Settings.Project.Voices": "최대 동시 음성 수", - "Settings.Project.Queue": "대기열 용량" + "Settings.Project.Queue": "대기열 용량", + "History.AddBranchItem": "분기 항목 추가: {0}", + "History.RemoveBranchItem": "분기 항목 삭제: {0}", + "History.MoveBranchItem": "분기 항목 이동: {0}", + "History.EditBranchItem": "분기 항목 편집: {0}", + "History.PasteNodes": "노드 {0}개 및 내용 붙여넣기", + "Error.graph.branchChanged": "분기 또는 항목이 변경되었습니다. 현재 내용을 확인한 후 다시 시도하세요.", + "Error.graph.clipboard": "클립보드에 지원되는 GalNet 그래프 선택 항목이 없습니다.", + "Error.graph.clipboardUnavailable": "클립보드를 사용할 수 없습니다.", + "Graph.Menu.Copy": "노드 복사", + "Graph.Menu.Paste": "노드 붙여넣기", + "Graph.Menu.SelectAll": "모든 노드 선택", + "Graph.Menu.FrameSelection": "선택 항목 보기", + "Graph.Menu.FrameAll": "모든 노드 보기", + "Graph.Branch.Add": "분기 항목 추가", + "Graph.Branch.Remove": "분기 항목 삭제", + "Graph.Branch.Text": "선택지 텍스트 키", + "Graph.Branch.Condition": "조건식", + "Status.GraphPasted": "노드 {0}개를 붙여넣었습니다. 사용자 정의 내용의 참조는 원래 값으로 유지되므로 확인하세요.", + "History.FileMove": "파일 이동: {0}", + "History.FileCopy": "파일 복사: {0}", + "History.FileImport": "파일 가져오기: {0}", + "History.FileDelete": "파일 삭제: {0}", + "History.FileCreateFile": "파일 만들기: {0}", + "History.FileCreateDirectory": "폴더 만들기: {0}", + "History.FileMetadata": "리소스 속성 편집: {0}", + "Error.files.path": "경로가 잘못되었거나 링크가 포함되어 있습니다: {0}", + "Error.files.protected": "이 경로는 프로젝트의 전용 기능에서 관리합니다: {0}", + "Error.files.missing": "파일 또는 폴더를 찾을 수 없습니다: {0}", + "Error.files.collision": "대상 경로가 사용 중이므로 기존 내용을 유지했습니다: {0}", + "Error.files.category": "콘텐츠 종류 또는 언어 폴더 간에는 이동할 수 없습니다: {0}", + "Error.files.changed": "파일 또는 폴더가 변경되었습니다. 확인 후 다시 시도하세요: {0}", + "Error.files.referenced": "참조가 있어 삭제하지 않았습니다: {0}", + "Error.files.guidCollision": "리소스 ID가 중복되어 작업을 적용하지 않았습니다: {0}", + "Error.files.references": "선언되지 않은 참조는 자동으로 변경할 수 없습니다: {0}", + "Error.files.failed": "파일 작업에 실패했습니다: {0}", + "Files.Back": "뒤로", + "Files.Forward": "앞으로", + "Files.Up": "상위 폴더", + "Files.Path": "프로젝트 상대 경로", + "Files.Search": "프로젝트 파일 이름 검색", + "Files.Import": "가져오기", + "Files.NewText": "텍스트 파일 만들기", + "Files.NewFolderName": "새 폴더", + "Files.NewFileName": "새 텍스트.txt", + "Files.Duplicate": "복제", + "Files.ItemCount": "{0}개 항목", + "Files.DeleteQuestion": "선택한 항목을 삭제할까요? 프로젝트 실행 취소로 복원할 수 있습니다.", + "Files.DeleteGroups": "연결된 Group 노드와 연결선도 삭제", + "Files.AllowCandidates": "문자열 참조 후보를 확인하고 삭제 (선언된 참조는 보호됨)", + "Files.Cancel": "취소", + "Files.ThumbnailSize": "썸네일 크기", + "Files.Document": "파일", + "Files.Apply": "편집 적용", + "Files.SaveAll": "모두 저장", + "Files.CancelDraft": "입력 취소", + "Files.OpenExternal": "외부에서 열기", + "Files.Missing": "파일이 삭제되었습니다. 삭제를 실행 취소하면 편집할 수 있습니다.", + "Files.ReadOnly": "읽기 전용: 시스템 파일, 바이너리, 대용량 또는 지원하지 않는 인코딩입니다. 외부에서 열 수 있습니다.", + "Files.ExternalChange": "외부에서 파일이 변경되었습니다. 편집 내용은 유지되며 저장 시 외부 내용을 덮어쓰지 않습니다. 프로젝트를 닫고 두 버전을 확인한 뒤 다시 여세요.", + "History.TextFile": "파일 편집: {0}", + "Error.files.busy": "저장 중입니다. 잠시 후 파일을 다시 여세요: {0}", + "Error.files.galleryId": "Gallery 항목 ID가 유효하지 않아 변경하지 않았습니다: {0}", + "Files.New": "새로 만들기", + "Files.ClearSearch": "검색 지우기", + "Entry.SearchTypes": "항목 유형 검색", + "Entry.BatchCount": "삽입 또는 추가할 항목 수", + "Entry.AnchorMissing": "삽입 위치가 삭제되었습니다. 다시 선택하세요.", + "Entry.Type.dialogue.text": "텍스트", + "Entry.Type.flow.wait": "대기", + "Entry.Type.gallery.unlock": "갤러리 잠금 해제", + "Entry.Type.animation.animate": "속성 애니메이션", + "Entry.Type.timeline.play": "타임라인 재생", + "Entry.Type.animation.stop": "애니메이션 정지", + "Entry.Type.layer.showColor": "색상 레이어 표시", + "Entry.Type.layer.replace": "레이어 교체", + "Entry.Type.audio.next": "다음 오디오", + "Entry.Type.audio.clear": "오디오 대기열 비우기", + "Entry.Type.audio.seffect.apply": "오디오 효과 적용", + "Entry.Type.audio.seffect.stop": "오디오 효과 정지", + "Entry.Type.nvl.show": "NVL 표시", + "Entry.Type.nvl.hide": "NVL 숨기기", + "Entry.Type.nvl.clear": "NVL 비우기", + "Entry.Type.nvl.append": "NVL 텍스트 추가", + "Entry.Type.particle.play": "파티클 재생", + "Entry.Type.particle.stop": "파티클 정지", + "Entry.Type.particle.burst": "파티클 방출", + "Entry.Type.dialogue.textVoice": "음성 대사", + "Entry.Type.nvl.appendVoice": "음성 NVL 추가", + "Entry.Type.transition.crossFade": "크로스페이드", + "Entry.Type.transition.slide": "슬라이드 전환", + "Entry.Type.transition.blinds": "블라인드 전환", + "Entry.Type.transition.fadeBlack": "검은색 페이드", + "Entry.Type.transition.fadeWhite": "흰색 페이드", + "Entry.Type.transition.fadeColor": "색상 페이드", + "Entry.Category.Animation": "애니메이션", + "Entry.Category.Nvl": "NVL", + "Entry.Category.Particle": "파티클", + "Entry.Category.Transition": "전환", + "Entry.Category.dialogue": "대화", + "Entry.Category.layer": "레이어", + "Entry.Category.audio": "오디오", + "Entry.Category.video": "비디오", + "Entry.Category.effect": "효과", + "Entry.Category.flow": "흐름", + "Entry.Category.variable": "변수", + "Entry.Category.gallery": "갤러리", + "Entry.Category.animation": "애니메이션", + "Entry.Category.timeline": "타임라인", + "Entry.Category.nvl": "NVL", + "Entry.Category.particle": "파티클", + "Entry.Category.transition": "전환", + "Entry.Menu.Copy": "항목 복사", + "Entry.Menu.Cut": "항목 잘라내기", + "Entry.Menu.Paste": "항목 붙여넣기", + "Entry.Menu.Duplicate": "항목 복제", + "Entry.Menu.Delete": "선택한 항목 삭제", + "Entry.Menu.SelectAll": "모두 선택", + "History.PasteEntries": "항목 {0}개 붙여넣기", + "History.DeleteEntries": "항목 {0}개 삭제", + "History.MoveEntries": "항목 이동", + "Error.entry.clipboard": "지원하는 GalNet 항목 데이터가 아닙니다(최대 1,000개, 텍스트 16 MiB).", + "Error.entry.clipboardSchema": "이 프로젝트 계약이 클립보드의 유형 또는 필드를 지원하지 않습니다. 항목을 붙여넣지 않았습니다.", + "Error.entry.clipboardUnavailable": "클립보드를 사용할 수 없습니다.", + "Error.entry.groupMissing": "Group이 삭제되었습니다.", + "Error.entry.anchorMissing": "삽입 대상이 삭제되었습니다. 다시 선택하세요.", + "Error.entry.cutChanged": "클립보드에 쓰는 동안 원본 항목이 변경되어 삭제하지 않았습니다.", + "Dock.Panel.Variables": "변수", + "Variables.Player": "플레이어 변수", + "Variables.Save": "저장 변수", + "Variables.PlayerHint": "플레이어 변수는 플레이어 설정에 유지되며 게임 저장 간에 공유됩니다. 여기서는 선언과 초기값을 편집합니다.", + "Variables.SaveHint": "저장 변수는 게임 저장과 함께 저장하고 복원합니다. 여기서는 선언과 초기값을 편집합니다.", + "Variables.Add": "변수 추가", + "Variables.Name": "이름", + "Variables.Type": "유형", + "Variables.Default": "기본값", + "Variables.Description": "설명", + "Variables.Apply": "적용", + "Variables.Revert": "초안 되돌리기", + "Variables.Delete": "변수 삭제", + "Variables.References": "참조 확인", + "Variables.ReferenceHint": "확실한 참조는 이름 변경 시 갱신되며 삭제를 차단합니다. 일반 텍스트나 불확실한 식은 변경하지 않고 표시합니다.", + "Variables.Certain": "확실한 참조", + "Variables.Possible": "후보 텍스트(변경하지 않음)", + "Variables.Candidates": "후보 텍스트 {0}곳을 유지했습니다. 아래 위치를 확인하세요.", + "Variables.Type.Bool": "불리언", + "Variables.Type.Int": "정수", + "Variables.Type.Float": "실수", + "Variables.Type.String": "텍스트", + "Variables.DefaultHint.Bool": "선택하면 true, 선택하지 않으면 false입니다.", + "Variables.DefaultHint.Int": "32비트 정수를 입력하세요.", + "Variables.DefaultHint.Float": "소수점을 사용하여 유한한 실수를 입력하세요.", + "Variables.DefaultHint.String": "JSON 따옴표 없이 텍스트를 입력하세요.", + "History.Variable.Add": "변수 {0} 추가", + "History.Variable.Update": "변수 {0} 편집", + "History.Variable.Delete": "변수 {0} 삭제", + "Error.variable.missing": "변수가 더 이상 존재하지 않습니다.", + "Error.variable.name": "영문자나 밑줄로 시작하고 영문자, 숫자, 밑줄을 사용하세요. 키워드와 Gallery 이름은 예약되어 있습니다.", + "Error.variable.duplicate": "플레이어 변수와 저장 변수에서 이미 사용 중인 이름입니다.", + "Error.variable.default": "선택한 유형에 맞는 기본값을 입력하세요.", + "Error.variable.inUse": "확실한 참조가 있습니다. 참조를 제거한 후 삭제하세요.", + "Error.variable.changed": "변수 또는 참조가 변경되었습니다. 초안을 되돌린 후 다시 편집하세요.", + "History.EntryType": "항목 유형 편집", + "History.EntryCondition": "항목 조건 편집", + "History.EntryParameters": "항목 매개변수 편집", + "Error.entry.obsolete": "항목이 삭제되었거나 유형이 변경되었습니다. 편집기를 다시 여세요.", + "Error.entry.changed": "다른 작업으로 항목이 변경되었습니다. 새로 고친 후 다시 시도하세요.", + "Entry.UnknownTypePreserved": "현재 계약에 이 유형의 편집 양식이 없습니다. 내용은 보존되며 저장하거나 유형을 변경할 수 있습니다. 검사와 컴파일에는 유효한 계약이 필요합니다.", + "Entry.UnknownParametersPreserved": "현재 계약에 선언되지 않은 매개변수도 저장 시 보존됩니다. 검사나 컴파일에서 오류가 보고될 수 있습니다.", + "Form.Default": "기본값을 명시적으로 설정", + "Form.Omit": "기본값을 쓰지 않고 필드 생략", + "Form.Null": "명시적 null 설정", + "Form.Revert": "미확정 입력 되돌리기", + "Form.AddElement": "요소 추가", + "Form.RemoveElement": "요소 삭제", + "Form.MoveUp": "위로", + "Form.MoveDown": "아래로", + "Dock.Panel.Timeline": "타임라인", + "Timeline.SelectEntry": "타임라인 항목을 선택하세요.", + "Timeline.Unsupported": "이 항목은 타임라인 편집을 지원하지 않습니다.", + "Timeline.Missing": "아직 타임라인이 없습니다.", + "Timeline.Create": "타임라인 만들기", + "Timeline.Editing": "타임라인 편집 중", + "Timeline.Invalid": "내용이 잘못되었습니다. 항목 매개변수의 JSON을 수정하세요.", + "Timeline.Frame": "현재 프레임", + "Timeline.Duration": "끝 프레임", + "Timeline.Empty": "편집할 객체나 기존 트랙이 없습니다. 이 항목 앞에 장면 객체를 만드세요.", + "Timeline.Object": "오브젝트 {0}", + "Timeline.Offset": "가산 오프셋", + "Timeline.Absolute": "절대값", + "Timeline.ModeHint": "트랙 모드입니다. 전환해도 키프레임 값을 유지합니다.", + "Timeline.KeyHere": "현재 프레임에 키 있음", + "Timeline.Sampled": "샘플 값", + "Timeline.EditRejected": "변경을 적용하지 못했습니다.", + "Timeline.InputHint": "Enter 또는 입력란을 벗어나면 적용하고 Esc로 취소합니다. 항목 전환 시 미확정 입력을 취소합니다.", + "Timeline.AnalysisIncomplete": "일부 장면 동작이 분석되지 않았습니다. 후보가 불완전할 수 있으며 기존 참조는 유지됩니다.", + "Timeline.Definite": "확실히 존재", + "Timeline.Possible": "존재 가능", + "Timeline.Unresolved": "확인되지 않은 객체 {0}", + "Timeline.Kind.Layer": "레이어", + "Timeline.Kind.Particle": "파티클 방출기", + "Timeline.Kind.Effect": "효과", + "Timeline.RemoveOverride": "오버라이드 제거", + "Timeline.InitialUnknown": "초기값을 알 수 없습니다. 입력값은 0 프레임에도 사용됩니다. 오프셋은 0에서 시작합니다.", + "Timeline.NotTracked": "트랙이 없습니다. 값을 확정하거나 선택하여 만드세요.", + "Timeline.ValueRange": "값이 속성의 허용 범위를 벗어났습니다.", + "Timeline.Property.transform": "변환", + "Timeline.Property.transform.x": "위치 X", + "Timeline.Property.transform.y": "위치 Y", + "Timeline.Property.transform.rotationDegrees": "회전", + "Timeline.Property.transform.scaleX": "크기 X", + "Timeline.Property.transform.scaleY": "크기 Y", + "Timeline.Property.opacity": "불투명도", + "Timeline.Property.flipbook": "플립북", + "Timeline.Property.flipbook.index": "프레임 인덱스", + "Timeline.Property.emissionRate": "방출 속도", + "Timeline.Property.initialVelocityX": "초기 속도 X", + "Timeline.Property.initialVelocityY": "초기 속도 Y", + "Timeline.Property.noise": "노이즈", + "Timeline.Property.particleScale": "파티클 크기", + "Timeline.Property.particleLifetime": "파티클 수명", + "Timeline.Property.progress": "진행률", + "Timeline.PreviousKey": "이전 키", + "Timeline.NextKey": "다음 키", + "Timeline.AddKey": "현재 프레임에 키 추가", + "Timeline.DeleteKeys": "선택 키 삭제", + "Timeline.CanvasHint": "눈금 이동 · Ctrl 다중 선택/확대 · 드래그 이동/영역 선택 · Delete 삭제 · Esc 취소", + "Timeline.Zoom": "확대", + "Timeline.ReplaceCollisions": "충돌 키를 교체하고 적용", + "Timeline.CancelMove": "이동 취소", + "Timeline.KeyProtected": "0 프레임 키는 이동하거나 삭제할 수 없습니다. 필요하면 트랙 전체를 제거하세요.", + "Timeline.KeyOutside": "키프레임은 지원 범위 안의 0 이상 프레임에 있어야 합니다.", + "Timeline.KeyCollision": "이동 또는 시간 변경 시 다른 키와 충돌합니다. 내용은 변경되지 않았습니다.", + "Timeline.SelectTrack": "먼저 트랙의 속성이나 키를 선택하세요。", + "Scene.Playback": "재생 인스턴스", + "Scene.SearchPlayback": "재생 유형 또는 출처 검색", + "Scene.ChoosePlayback": "재생 인스턴스 선택", + "Scene.Target": "장면 대상", + "Scene.None": "선택 안 함", + "Scene.Unresolved": "확인되지 않은 대상", + "Scene.Search": "리소스, 대상 또는 생성 위치 검색", + "Scene.CandidateHint": "이 항목 실행 전의 알려진 상태를 사용합니다. 미지원 동작이 결과에 영향을 줄 수 있습니다.", + "Scene.Global": "전체 장면 (레이어 없음)", + "Scene.Stale": "내용이 변경되었습니다. 선택기를 다시 여세요.", + "Scene.LoadFailed": "대상 리소스 정보를 불러올 수 없습니다.", + "Scene.AddObject": "애니메이션 대상 추가", + "Scene.Duplicate": "대상에 같은 속성 트랙이 이미 있습니다.", + "Scene.ChooseTarget": "장면 대상 선택", + "Scene.AddProperty": "속성 오버라이드 추가", + "Timeline.Key.Selected": "선택한 키프레임", + "Timeline.Key.Value": "값", + "Timeline.Key.Step": "단계", + "Timeline.Key.Linear": "선형", + "Timeline.Key.Incoming": "입력 접선", + "Timeline.Key.Outgoing": "출력 접선", + "Timeline.Key.Seconds": "초 단위 접선 표시", + "Timeline.Key.PerFrame": "값/프레임", + "Timeline.Key.PerSecond": "값/초", + "Timeline.Key.Flat": "평평한 접선", + "Timeline.Key.LinearTangents": "선형 접선", + "Timeline.Key.Smooth": "부드러운 접선", + "Timeline.Key.Aligned": "접선 정렬", + "Timeline.Key.Hint": "보간은 다음 구간에 적용됩니다. 혼합 값은 비워 두며 입력은 선택한 모든 키에 적용됩니다.", + "Form.Reset": "기본값으로 재설정", + "Form.Clear": "비우기", + "Form.Repair": "고급 소스 / 복구", + "Form.Object": "속성", + "Form.List": "목록", + "Form.PickColor": "색상 선택", + "Form.ColorInvalid": "#RRGGBB 또는 #AARRGGBB 색상을 입력하세요.", + "Form.TransformInvalid": "잘못된 변환입니다. 소스를 복구하거나 재설정하세요.", + "Form.OpenTimeline": "타임라인 열기", + "Form.TimelineSummary": "{0} 프레임 · {1} FPS · {2} 트랙", + "Form.TimelineEmpty": "생성되지 않음", + "Form.Transform.X": "X", + "Form.Transform.Y": "Y", + "Form.Transform.RotationDegrees": "회전 °", + "Form.Transform.ScaleX": "배율 X", + "Form.Transform.ScaleY": "배율 Y", + "Form.NoDefault": "이 필드에 사용 가능한 기본값이 없습니다", + "Entry.Parameter.color": "색상", + "Entry.Parameter.transform": "변환", + "Entry.Parameter.opacity": "불투명도", + "Entry.Parameter.toTransform": "대상 변환", + "Entry.Parameter.timeline": "타임라인", + "Timeline.Curve.Interpolation": "보간", + "Timeline.Curve.Hermite": "베지어 / Hermite", + "Timeline.Curve.Both": "양쪽 접선", + "Timeline.Curve.Free": "자유", + "Timeline.Curve.Flat": "평탄", + "Timeline.Curve.Linear": "선형", + "Timeline.Curve.Auto": "자동 베지어", + "Timeline.Curve.Linked": "접선 연결", + "Timeline.Curve.Broken": "접선 분리", + "Timeline.Curve.EditValues": "값 편집…", + "Timeline.Curve.Fit": "값 범위 맞춤", + "Timeline.Curve.Hint": "Alt+휠로 값 범위를 확대하고 Alt+드래그로 이동합니다. 키를 드래그하여 시간과 값을 변경하고 선택하여 접선 핸들을 표시합니다.", + "Scene.CreatedAt": "{0} 프레임에서 생성", + "Scene.FutureAt": "{0} 프레임에서 생성 (아직 없음)", + "Timeline.Event.Title": "타임라인 이벤트", + "Timeline.Event.Add": "이벤트 추가", + "Timeline.Event.Label": "{0} 프레임 · {1}", + "Timeline.Event.Edit": "편집", + "Timeline.Event.Apply": "적용", + "Timeline.Event.Cancel": "취소", + "Timeline.Event.Earlier": "같은 프레임에서 앞으로", + "Timeline.Event.Later": "같은 프레임에서 뒤로", + "Timeline.Event.Delete": "이벤트 삭제", + "Entry.Category.Timeline": "타임라인", + "Dock.Panel.Localization": "게임 현지화", + "I18n.Characters": "캐릭터 표", + "I18n.Branches": "분기 선택지", + "I18n.CharacterRow": "캐릭터 {0}", + "I18n.EntryRow": "항목 {0} · {1}", + "I18n.LanguageCode": "언어 코드", + "I18n.Add": "+ 언어 추가", + "I18n.RevertDrafts": "초안 버리기", + "I18n.Development": "개발 언어", + "I18n.PreviewAction": "변경 확인", + "I18n.ApplyDevelopment": "개발 언어 적용", + "I18n.Context": "문맥", + "I18n.MoveLeft": "왼쪽으로 이동", + "I18n.MoveRight": "오른쪽으로 이동", + "I18n.Source": "개발 언어", + "I18n.Navigate": "참조로 이동", + "I18n.Missing": "번역 없음", + "I18n.Empty": "빈 문자열", + "I18n.Review": "검토 필요", + "I18n.Unknown": "검토 상태 알 수 없음", + "I18n.Stale": "원문, 참조 또는 개발 언어가 변경되었습니다. 초안을 유지하고 현재 값을 확인하세요.", + "I18n.Duplicate": "이미 지원하는 언어입니다.", + "I18n.AddFirst": "먼저 언어를 추가하세요.", + "I18n.Preview": "개발 언어를 {0}(으)로 변경: 참조 텍스트 {1}개에 번역이 없습니다. 원문은 복사되지 않습니다.", + "I18n.Incomplete": "일부 참조를 분석할 수 없습니다. 누락 집계가 불완전할 수 있습니다.", + "I18n.UsePanel": "게임 현지화 패널에서 개발 언어를 확인하고 변경하세요.", + "History.Languages": "프로젝트 언어 변경", + "I18n.OptionRow": "선택지 {0}", + "Error.i18n.language": "올바른 언어 코드를 입력하세요.", + "Error.i18n.stale": "원문, 참조 또는 개발 언어가 변경되었습니다. 초안을 유지하고 현재 값을 확인하세요.", + "Error.i18n.duplicate": "이미 지원하는 언어입니다.", + "Error.i18n.development": "게임 현지화 패널에서 개발 언어를 확인하고 변경하세요.", + "Exchange.All": "모든 분류", + "Exchange.Runtime": "실행 텍스트", + "Exchange.Export": "JSON 내보내기", + "Exchange.Import": "가져오기 미리보기", + "Exchange.AllowEmpty": "빈 문자열로 번역 지우기 허용", + "Exchange.Apply": "선택 항목 적용", + "Exchange.Cancel": "미리보기 취소", + "Exchange.Unknown": "알 수 없는 텍스트", + "Exchange.Skip": "건너뜀(null)", + "Exchange.Clear": "번역 지우기", + "Exchange.Change": "변경 예정", + "Exchange.Unchanged": "변경 없음", + "History.ImportLocalization": "번역 가져오기: {0}", + "Error.exchange.project": "템플릿의 프로젝트 또는 개발 언어가 다릅니다.", + "Error.exchange.language": "개발 언어 이외의 지원 번역 언어를 선택하세요.", + "Error.exchange.stale": "미리보기가 오래되었습니다. 파일을 다시 확인하세요.", + "Debug.CurrentValue": "현재 값", + "Debug.EditValue": "변경할 값", + "Debug.VariableConflict": "게임의 값이 변경되었습니다. 초기화한 후 다시 편집하세요.", + "Debug.NoVariables": "현재 인스턴스에 표시할 변수가 없습니다", + "Debug.NoChoices": "현재 선택을 기다리는 항목이 없습니다", + "Debug.Working": "처리 중…", + "Debug.Preparing": "디버그 세션 준비 중…", + "Debug.SavingProject": "프로젝트 저장 중…", + "Debug.BuildingContent": "테스트 콘텐츠 빌드 중…", + "Debug.PreparingSourceMap": "작성자 소스 맵 준비 중…", + "Debug.StartingApplication": "앱 시작 중…", + "Debug.Menu": "테스트", + "Debug.Step": "작성 항목 단계 실행", + "Debug.Breakpoint": "중단점 전환", + "Debug.IsolatedStart": "초기 장면으로 여기서 테스트", + "Debug.Panel": "게임 디버깅", + "Debug.Launch": "앱 실행", + "Debug.Wait": "앱 연결 대기", + "Debug.Start": "게임 시작", + "Debug.Stop": "게임 중지", + "Debug.Pause": "일시 정지", + "Debug.Resume": "계속", + "Debug.GroupEnd": "현재 Group 끝", + "Debug.Cancel": "이동 취소", + "Debug.Disconnect": "연결 해제", + "Debug.Configuration": "실행 설정", + "Debug.Executable": "실행 파일 경로", + "Debug.Arguments": "인수: 한 줄에 하나", + "Debug.WorkingDirectory": "작업 디렉터리(선택)", + "Debug.SaveConfiguration": "로컬 설정 저장", + "Debug.PlayerVariables": "플레이어 변수", + "Debug.SaveVariables": "저장 변수", + "Debug.Apply": "적용", + "Debug.Choices": "선택지", + "Debug.Jump": "여기로 이동", + "Debug.SelectRoute": "경로 선택", + "Debug.ContentChanged": "내용이 변경되었습니다. 테스트를 다시 시작하세요.", + "Debug.NoRoute": "대상에 도달하는 경로가 없습니다.", + "Debug.RouteLimit": "검색 한도에 도달했습니다. 더 짧은 노드 경로를 선택하세요.", + "Entry.Type.variants.show": "이미지 차분 표시", + "Entry.Type.variants.select": "이미지 차분 전환", + "Entry.Parameter.dimension": "차원", + "Entry.Parameter.selection": "초기 선택", + "Variants.AmbiguousSource": "이야기 경로에 따라 차분 원본이 달라집니다", + "Variants.Title": "이미지 차분 모음", + "Variants.Folder": "이미지 폴더 (설명 파일 기준 상대 경로)", + "Variants.Default": "기본 이미지", + "Variants.Label": "표시 이름", + "Variants.AddDimension": "차원 추가", + "Variants.Infer": "차원 다시 추론", + "Variants.IdentityWarning": "키 또는 순서 변경으로 이야기 참조가 무효화될 수 있습니다. 기존 항목을 확인하세요. 참조는 자동 이전되지 않습니다.", + "Variants.Apply": "설정 적용", + "Variants.Refresh": "다시 스캔", + "Variants.ResetPreview": "미리보기 선택 초기화", + "Variants.Requested": "요청한 선택", + "Variants.Actual": "실제 이미지", + "Variants.New": "폴더에서 이미지 차분 만들기", + "Variants.ChooseFolder": "Assets 안에서 가져온 이미지 폴더를 선택하세요.", + "Variants.Reason.Exact": "정확히 일치", + "Variants.Reason.Reduced": "축소된 차분", + "Variants.Reason.Default": "기본 이미지", + "Variants.Inherit": "기본 선택 사용", + "Uiux.Entry.ExpandAll": "모두 펼치기", + "Uiux.Entry.CollapseAll": "모두 접기", + "Uiux.Save.Drafts": "처리할 입력 있음", + "Uiux.Validation.Passed": "작성 내용 검사 통과", + "Uiux.Validation.Stale": "검사 결과가 오래됨", + "Uiux.Save.Saved": "저장됨", + "Uiux.Problems": "문제", + "Uiux.Inspector.Lock": "검사 대상 고정 또는 해제", + "Uiux.Draft.Invalid": "입력이 불완전하거나 올바르지 않습니다.", + "Uiux.Save.Unsaved": "저장되지 않음", + "Uiux.Save.Failed": "저장 실패", + "Uiux.Validation.Check": "현재 내용 검사", + "Uiux.Entry.Condition": "실행 조건", + "Uiux.Validation.Failed": "문제 있음", + "Uiux.Validation.Unchecked": "검사하지 않음", + "Uiux.Entry.Search": "항목 내용 또는 번호 검색", + "Uiux.Save.Saving": "저장 중…", + "Uiux.Draft.Conflict": "원본 값 또는 대상이 변경되었습니다. 초안을 확인하거나 취소하세요.", + "Uiux.Draft.Pending": "아직 적용하지 않은 입력", + "Uiux.Save.PendingCount": "처리할 입력: {0}", + "Uiux.Graph.Map": "미니맵", + "Uiux.Files.List": "목록 보기", + "Uiux.Files.Empty": "빈 폴더입니다. 리소스를 가져오거나 파일을 만드세요.", + "Uiux.Files.Audio": "오디오", + "Uiux.Layout.Animation": "애니메이션 편집 레이아웃", + "Uiux.Timeline.Fit": "전체 길이 표시", + "Uiux.Layout.Writing": "시나리오 작성 레이아웃", + "Uiux.Graph.Search": "노드 이름 또는 ID 검색", + "Uiux.Files.All": "모든 형식", + "Uiux.Files.SearchScope": "검색 범위: 전체 프로젝트", + "Uiux.Files.Groups": "시나리오 파일", + "Uiux.Files.Other": "기타 파일", + "Uiux.Timeline.Playhead": "재생 헤드로 이동", + "Uiux.Files.Images": "이미지", + "Uiux.Files.NoResults": "일치하는 항목이 없습니다. 검색 또는 필터를 변경하세요.", + "Uiux.Inspector.Locked": "고정됨", + "Uiux.Layout.Maximize": "패널 최대화 / 레이아웃 복원", + "Uiux.Layout.Debug": "디버그 레이아웃", + "Uiux.Graph.FitSelection": "선택 항목 표시", + "Uiux.Graph.FitAll": "전체 그래프 표시", + "Uiux.Files.Folders": "폴더", + "Uiux.Files.Video": "비디오", + "Uiux.Validation.Incomplete": "검사가 완료되지 않음", + "Uiux.Draft.Composing": "문자 조합을 완료한 후 저장하세요.", + "Uiux.Severity.warning": "경고", + "Uiux.Severity.info": "정보", + "Uiux.Severity.error": "오류", + "Uiux.Entry.Selected": "선택됨", + "Uiux.Entry.ActiveOnly": "개; 현재 항목의 매개변수만 편집" } diff --git a/src/Editor/GalNet.Editor/Localization/zh-CN.json b/src/Editor/GalNet.Editor/Localization/zh-CN.json index c9263826..c3355d76 100644 --- a/src/Editor/GalNet.Editor/Localization/zh-CN.json +++ b/src/Editor/GalNet.Editor/Localization/zh-CN.json @@ -1,4 +1,32 @@ { + "Error.text.changed": "文本引用已改变,请重新载入草稿。", + "Error.text.conflict": "同一共享文本收到互相冲突的修改。", + "History.GameText": "游戏文本:{0}", + "GameText.Placeholder": "输入开发语言文本", + "GameText.Missing": "缺少开发语言文本", + "GameText.Shared": "共享文本:修改将影响 {0} 处引用", + "Error.text.localeChanged": "项目开发语言已改变,请重新载入草稿。", + "Form.CopyElement": "复制项", + "Dock.Panel.Characters": "角色表", + "History.Character": "编辑角色:{0}", + "Characters.Add": "新增角色", + "Characters.Search": "按开发语言角色名或 key 搜索", + "Characters.Name": "开发语言角色名", + "Characters.Key": "唯一 key", + "Characters.AutoKey": "留空自动分配编号", + "Characters.Apply": "应用", + "Characters.Revert": "还原草稿", + "Characters.Delete": "删除未使用角色", + "Characters.Incomplete": "引用分析不完整,暂不能删除角色或更改 key。", + "Characters.SpeakerHint": "输入角色名或 key,选择已有角色;未匹配时失焦创建", + "Error.character.name": "请输入开发语言角色名。", + "Error.character.key": "角色 key 不能为空。", + "Error.character.duplicate": "角色 key 已存在。", + "Error.character.changed": "角色或姓名已改变,请还原草稿后重试。", + "Error.character.inUse": "角色仍被使用,不能删除。", + "Error.character.incomplete": "引用分析不完整,无法安全执行此操作。", + "Error.character.ambiguous": "存在同名角色,请从候选中选择具体 key。", + "Error.character.obsolete": "项目或条目已改变,此次输入已失效。", "Entry.AppendBatch": "追加条目", "Entry.Type.dialogue.show": "显示对话框", "Entry.Type.dialogue.hide": "隐藏对话框", @@ -67,7 +95,7 @@ "Language.en-US": "英语", "Dock.Panel.NodeGraph": "节点图", "Dock.Panel.GamePreview": "游戏预览", - "Dock.Panel.Assets": "资源", + "Dock.Panel.Assets": "项目文件", "Dock.Panel.Log": "日志", "Dock.Panel.GroupEditor": "组:{0}", "Dock.Panel.Inspector": "检查器", @@ -313,7 +341,6 @@ "Entry.Parameter.asset": "资源", "Entry.Parameter.x": "X 坐标", "Entry.Parameter.y": "Y 坐标", - "Entry.Parameter.z": "Z 坐标", "Entry.Parameter.transition": "过渡", "Entry.Parameter.duration": "时长", "Entry.Parameter.channel": "音轨", @@ -328,6 +355,52 @@ "Entry.Parameter.valueType": "值类型", "Entry.Parameter.expression": "表达式", "Entry.Parameter.category": "分类", + "Condition.Placeholder": "仅当此条件成立时执行", + "Entry.Parameter.assetId": "资源", + "Entry.Parameter.autoBlocking": "自动等待完成", + "Entry.Parameter.batchId": "批次 ID", + "Entry.Parameter.bladeCount": "百叶数量", + "Entry.Parameter.blendMode": "混合模式", + "Entry.Parameter.blocking": "等待完成", + "Entry.Parameter.clearQueue": "清空队列", + "Entry.Parameter.count": "数量", + "Entry.Parameter.curve": "曲线", + "Entry.Parameter.direction": "方向", + "Entry.Parameter.displayMode": "显示模式", + "Entry.Parameter.distance": "距离", + "Entry.Parameter.durationFrames": "时长(帧)", + "Entry.Parameter.fadeIn": "淡入", + "Entry.Parameter.fadeInDuration": "淡入时长", + "Entry.Parameter.fadeOut": "淡出", + "Entry.Parameter.fadeOutDuration": "淡出时长", + "Entry.Parameter.flipbook": "翻页动画", + "Entry.Parameter.frameRate": "帧率", + "Entry.Parameter.from": "起始值", + "Entry.Parameter.fromLayerHandleId": "来源图层", + "Entry.Parameter.gain": "增益", + "Entry.Parameter.handleId": "图层句柄", + "Entry.Parameter.holdDuration": "停留时长", + "Entry.Parameter.instanceId": "实例 ID", + "Entry.Parameter.loopMode": "循环模式", + "Entry.Parameter.maskProgram": "遮罩程序", + "Entry.Parameter.newHandleId": "新图层句柄", + "Entry.Parameter.oldHandleId": "旧图层句柄", + "Entry.Parameter.order": "顺序", + "Entry.Parameter.orientation": "方向", + "Entry.Parameter.overlayZ": "叠加 Z 顺序", + "Entry.Parameter.placement": "位置方式", + "Entry.Parameter.playbackHandleId": "播放句柄", + "Entry.Parameter.program": "程序", + "Entry.Parameter.skippable": "允许跳过", + "Entry.Parameter.skipPoints": "跳过点", + "Entry.Parameter.targetHandleId": "目标句柄", + "Entry.Parameter.to": "结束值", + "Entry.Parameter.toAssetId": "目标资源", + "Entry.Parameter.toDisplayMode": "目标显示模式", + "Entry.Parameter.toLayerHandleId": "目标图层", + "Entry.Parameter.toZ": "目标 Z 顺序", + "Entry.Parameter.trackId": "音轨", + "Entry.Parameter.z": "Z 顺序", "Entry.Option.None": "无", "Entry.Option.fade": "淡入淡出", "Entry.Option.dissolve": "溶解", @@ -345,6 +418,34 @@ "Entry.Option.Cg": "CG", "Entry.Option.Portrait": "立绘", "Entry.Option.Scene": "场景", + "Entry.Option.false": "否", + "Entry.Option.true": "是", + "Entry.Option.Native": "原始尺寸", + "Entry.Option.Tile": "平铺", + "Entry.Option.Fill": "填充", + "Entry.Option.Uniform": "适应", + "Entry.Option.UniformToFill": "填充并裁剪", + "Entry.Option.Left": "向左", + "Entry.Option.Right": "向右", + "Entry.Option.Up": "向上", + "Entry.Option.Down": "向下", + "Entry.Option.Vertical": "垂直", + "Entry.Option.Horizontal": "水平", + "Entry.Option.Step": "阶梯", + "Entry.Option.EaseIn": "缓入", + "Entry.Option.EaseOut": "缓出", + "Entry.Option.EaseInOut": "缓入缓出", + "Entry.Option.PingPong": "往返循环", + "Entry.Option.Replace": "替换", + "Entry.Option.Additive": "叠加", + "Entry.Option.AfterIteration": "当前循环结束后", + "Entry.Option.CompleteImmediately": "立即完成", + "Entry.Option.enqueue": "加入队列", + "Entry.Option.next": "下一项播放", + "Entry.Option.replace": "替换当前项", + "Entry.Option.replaceAll": "替换全部", + "Entry.Option.replaceIfDifferent": "不同时替换", + "Entry.Option.ReplaceAll": "全部替换", "NewProject.Name": "名称", "NewProject.Parent": "父目录", "NewProject.Contracts": "自定义契约目录(可选)", @@ -442,5 +543,472 @@ "Settings.Project.RemoveTrack": "移除轨道", "Settings.Project.Gain": "默认增益", "Settings.Project.Voices": "最大声音数量", - "Settings.Project.Queue": "队列容量" + "Settings.Project.Queue": "队列容量", + "History.AddBranchItem": "添加分支项:{0}", + "History.RemoveBranchItem": "删除分支项:{0}", + "History.MoveBranchItem": "移动分支项:{0}", + "History.EditBranchItem": "编辑分支项:{0}", + "History.PasteNodes": "粘贴 {0} 个节点及内容", + "Error.graph.branchChanged": "分支或选项已变更,请检查当前内容后重试。", + "Error.graph.clipboard": "剪贴板中没有支持的 GalNet 图选区。", + "Error.graph.clipboardUnavailable": "剪贴板不可用。", + "Graph.Menu.Copy": "复制节点", + "Graph.Menu.Paste": "粘贴节点", + "Graph.Menu.SelectAll": "选择全部节点", + "Graph.Menu.FrameSelection": "定位选中节点", + "Graph.Menu.FrameAll": "显示完整图", + "Graph.Branch.Add": "添加分支项", + "Graph.Branch.Remove": "删除分支项", + "Graph.Branch.Text": "选项文本键", + "Graph.Branch.Condition": "条件表达式", + "Status.GraphPasted": "已粘贴 {0} 个节点。自定义内容中的引用保持原样,请检查。", + "History.FileMove": "移动文件:{0}", + "History.FileCopy": "复制文件:{0}", + "History.FileImport": "导入文件:{0}", + "History.FileDelete": "删除文件:{0}", + "History.FileCreateFile": "创建文件:{0}", + "History.FileCreateDirectory": "创建目录:{0}", + "History.FileMetadata": "编辑资源属性:{0}", + "Error.files.path": "路径无效或包含链接:{0}", + "Error.files.protected": "此路径由项目专用功能管理,不能直接操作:{0}", + "Error.files.missing": "文件或目录不存在:{0}", + "Error.files.collision": "目标已被占用,未覆盖原内容:{0}", + "Error.files.category": "不能将文件移动到不同的内容类别或语言目录:{0}", + "Error.files.changed": "文件或目录在操作后已被更改,请检查后重试:{0}", + "Error.files.referenced": "仍有引用,未执行删除:{0}", + "Error.files.guidCollision": "资源标识重复,未执行操作:{0}", + "Error.files.references": "无法自动改写未声明的引用:{0}", + "Error.files.failed": "文件操作失败:{0}", + "Files.Back": "后退", + "Files.Forward": "前进", + "Files.Up": "上一级", + "Files.Path": "项目相对路径", + "Files.Search": "搜索项目文件名", + "Files.Import": "导入", + "Files.NewText": "新建文本", + "Files.NewFolderName": "新建文件夹", + "Files.NewFileName": "新建文本.txt", + "Files.Duplicate": "创建副本", + "Files.ItemCount": "{0} 项", + "Files.DeleteQuestion": "删除所选项目?可通过项目撤销恢复。", + "Files.DeleteGroups": "同时删除关联的 Group 节点和连线", + "Files.AllowCandidates": "已检查候选字符串引用,仍然删除(确定引用无法忽略)", + "Files.Cancel": "取消", + "Files.ThumbnailSize": "缩略图大小", + "Files.Document": "文件", + "Files.Apply": "提交编辑", + "Files.SaveAll": "保存全部", + "Files.CancelDraft": "取消输入", + "Files.OpenExternal": "外部打开", + "Files.Missing": "文件已删除,撤销删除后可继续编辑。", + "Files.ReadOnly": "只读:系统文件、二进制、大文件或无法无损识别的编码。可外部打开。", + "Files.ExternalChange": "磁盘文件已被外部修改。当前编辑已保留;保存不会覆盖外部内容。请关闭项目并检查两个版本后重新打开。", + "History.TextFile": "编辑文件:{0}", + "Error.files.busy": "正在保存,请稍后再打开新文件:{0}", + "Error.files.galleryId": "Gallery 条目标识无效,未执行操作:{0}", + "Files.New": "新建", + "Files.ClearSearch": "清空搜索", + "Entry.SearchTypes": "搜索条目类型", + "Entry.BatchCount": "添加或追加的条目数量", + "Entry.AnchorMissing": "插入位置已被删除,请重新选择。", + "Entry.Type.dialogue.text": "文本", + "Entry.Type.flow.wait": "等待", + "Entry.Type.gallery.unlock": "解锁画廊", + "Entry.Type.animation.animate": "属性动画", + "Entry.Type.timeline.play": "播放时间轴", + "Entry.Type.animation.stop": "停止动画", + "Entry.Type.layer.showColor": "显示颜色图层", + "Entry.Type.layer.replace": "替换图层", + "Entry.Type.audio.next": "下一首音频", + "Entry.Type.audio.clear": "清空音频队列", + "Entry.Type.audio.seffect.apply": "应用音频效果", + "Entry.Type.audio.seffect.stop": "停止音频效果", + "Entry.Type.nvl.show": "显示 NVL", + "Entry.Type.nvl.hide": "隐藏 NVL", + "Entry.Type.nvl.clear": "清空 NVL", + "Entry.Type.nvl.append": "追加 NVL 文本", + "Entry.Type.particle.play": "播放粒子", + "Entry.Type.particle.stop": "停止粒子", + "Entry.Type.particle.burst": "发射粒子", + "Entry.Type.dialogue.textVoice": "语音对白", + "Entry.Type.nvl.appendVoice": "追加语音 NVL", + "Entry.Type.transition.crossFade": "交叉淡化", + "Entry.Type.transition.slide": "滑动过渡", + "Entry.Type.transition.blinds": "百叶窗过渡", + "Entry.Type.transition.fadeBlack": "黑色过渡", + "Entry.Type.transition.fadeWhite": "白色过渡", + "Entry.Type.transition.fadeColor": "颜色过渡", + "Entry.Category.Animation": "动画", + "Entry.Category.Nvl": "NVL", + "Entry.Category.Particle": "粒子", + "Entry.Category.Transition": "过渡", + "Entry.Category.dialogue": "对话", + "Entry.Category.layer": "图层", + "Entry.Category.audio": "音频", + "Entry.Category.video": "视频", + "Entry.Category.effect": "特效", + "Entry.Category.flow": "流程", + "Entry.Category.variable": "变量", + "Entry.Category.gallery": "画廊", + "Entry.Category.animation": "动画", + "Entry.Category.timeline": "时间轴", + "Entry.Category.nvl": "NVL", + "Entry.Category.particle": "粒子", + "Entry.Category.transition": "过渡", + "Entry.Menu.Copy": "复制条目", + "Entry.Menu.Cut": "剪切条目", + "Entry.Menu.Paste": "粘贴条目", + "Entry.Menu.Duplicate": "创建副本", + "Entry.Menu.Delete": "删除所选条目", + "Entry.Menu.SelectAll": "全选", + "History.PasteEntries": "粘贴 {0} 个条目", + "History.DeleteEntries": "删除 {0} 个条目", + "History.MoveEntries": "移动条目", + "Error.entry.clipboard": "剪贴板不是受支持的 GalNet 条目数据(最多 1,000 条、16 MiB 文本)。", + "Error.entry.clipboardSchema": "目标项目契约不支持剪贴板中的类型或字段,未粘贴任何条目。", + "Error.entry.clipboardUnavailable": "剪贴板不可用。", + "Error.entry.groupMissing": "该 Group 已被删除。", + "Error.entry.anchorMissing": "插入位置已被删除,请重新选择。", + "Error.entry.cutChanged": "写入剪贴板期间原条目发生变化,未删除原条目。", + "Dock.Panel.Variables": "变量", + "Variables.Player": "玩家变量", + "Variables.Save": "存档变量", + "Variables.PlayerHint": "玩家变量随玩家配置持久化,跨游戏存档共用。这里编辑声明和初始值。", + "Variables.SaveHint": "存档变量属于游戏存档,随存档保存和恢复。这里编辑声明和初始值。", + "Variables.Add": "新增变量", + "Variables.Name": "名称", + "Variables.Type": "类型", + "Variables.Default": "默认值", + "Variables.Description": "说明", + "Variables.Apply": "应用", + "Variables.Revert": "还原草稿", + "Variables.Delete": "删除变量", + "Variables.References": "检查引用", + "Variables.ReferenceHint": "明确引用会随重命名更新,并阻止删除;普通文本或不确定表达式仅提示,不自动替换。", + "Variables.Certain": "明确引用", + "Variables.Possible": "候选文本(不自动修改)", + "Variables.Candidates": "已保留 {0} 处候选文本,请检查下方位置。", + "Variables.Type.Bool": "布尔", + "Variables.Type.Int": "整数", + "Variables.Type.Float": "浮点数", + "Variables.Type.String": "文本", + "Variables.DefaultHint.Bool": "勾选为 true,取消勾选为 false。", + "Variables.DefaultHint.Int": "输入 32 位整数。", + "Variables.DefaultHint.Float": "使用小数点输入有限浮点数。", + "Variables.DefaultHint.String": "直接输入文本,不需要 JSON 引号。", + "History.Variable.Add": "新增变量 {0}", + "History.Variable.Update": "编辑变量 {0}", + "History.Variable.Delete": "删除变量 {0}", + "Error.variable.missing": "该变量已不存在。", + "Error.variable.name": "名称须以英文字母或下划线开头,后续可用字母、数字、下划线;关键字和 Gallery 名称保留。", + "Error.variable.duplicate": "玩家变量和存档变量共用名称空间,该名称已存在。", + "Error.variable.default": "默认值与所选类型不符。", + "Error.variable.inUse": "该变量存在明确引用,请先移除引用再删除。", + "Error.variable.changed": "变量或引用已变化,请还原草稿后重新编辑。", + "History.EntryType": "编辑条目类型", + "History.EntryCondition": "编辑条目条件", + "History.EntryParameters": "编辑条目参数", + "Error.entry.obsolete": "条目已移除或其类型已改变,请重新打开编辑。", + "Error.entry.changed": "条目内容已被其他操作修改,请刷新后重试。", + "Entry.UnknownTypePreserved": "当前契约没有此类型的编辑表单。内容已保留,可保存或更换类型;检查和编译仍需有效契约。", + "Entry.UnknownParametersPreserved": "包含当前契约未声明的参数,保存时会保留;检查或编译可能报告问题。", + "Form.Default": "使用默认值(显式写入)", + "Form.Omit": "省略此字段(不写入默认值)", + "Form.Null": "设为显式 null", + "Form.Revert": "还原未提交的输入", + "Form.AddElement": "添加元素", + "Form.RemoveElement": "删除元素", + "Form.MoveUp": "上移", + "Form.MoveDown": "下移", + "Dock.Panel.Timeline": "时间轴", + "Timeline.SelectEntry": "请选择时间轴条目。", + "Timeline.Unsupported": "当前条目不支持时间轴编辑。", + "Timeline.Missing": "此条目尚未创建时间轴。", + "Timeline.Create": "创建时间轴", + "Timeline.Editing": "正在编辑时间轴", + "Timeline.Invalid": "时间轴内容无效,请在条目参数中修复 JSON。", + "Timeline.Frame": "当前帧", + "Timeline.Duration": "结束帧", + "Timeline.Empty": "此位置没有可编辑对象或已有轨道,请先在前文创建场景对象。", + "Timeline.Object": "对象 {0}", + "Timeline.Offset": "叠加偏移", + "Timeline.Absolute": "绝对值", + "Timeline.ModeHint": "轨道模式;切换时保留关键帧数值。", + "Timeline.KeyHere": "当前帧已有键", + "Timeline.Sampled": "采样值", + "Timeline.EditRejected": "无法应用此次修改。", + "Timeline.InputHint": "Enter 或离开输入框应用;Esc 取消。切换条目会取消未确认的输入。", + "Timeline.AnalysisIncomplete": "部分场景语义尚未分析,候选可能不完整;已有引用会保留。", + "Timeline.Definite": "一定存在", + "Timeline.Possible": "可能存在", + "Timeline.Unresolved": "未解析对象 {0}", + "Timeline.Kind.Layer": "图层", + "Timeline.Kind.Particle": "粒子发射器", + "Timeline.Kind.Effect": "效果", + "Timeline.RemoveOverride": "取消重载", + "Timeline.InitialUnknown": "初值无法确定:输入同时用作第 0 帧值;偏移模式从 0 开始。", + "Timeline.NotTracked": "尚未加入轨道,确认数值或勾选以创建。", + "Timeline.ValueRange": "数值超出该属性支持的范围。", + "Timeline.Property.transform": "变换", + "Timeline.Property.transform.x": "位置 X", + "Timeline.Property.transform.y": "位置 Y", + "Timeline.Property.transform.rotationDegrees": "旋转", + "Timeline.Property.transform.scaleX": "缩放 X", + "Timeline.Property.transform.scaleY": "缩放 Y", + "Timeline.Property.opacity": "不透明度", + "Timeline.Property.flipbook": "翻页动画", + "Timeline.Property.flipbook.index": "帧索引", + "Timeline.Property.emissionRate": "发射速率", + "Timeline.Property.initialVelocityX": "初始速度 X", + "Timeline.Property.initialVelocityY": "初始速度 Y", + "Timeline.Property.noise": "噪声", + "Timeline.Property.particleScale": "粒子大小", + "Timeline.Property.particleLifetime": "粒子寿命", + "Timeline.Property.progress": "进度", + "Timeline.PreviousKey": "上一键", + "Timeline.NextKey": "下一键", + "Timeline.AddKey": "当前帧加键", + "Timeline.DeleteKeys": "删除所选键", + "Timeline.CanvasHint": "刻度定位 · Ctrl 多选/滚轮缩放 · 拖动移动/框选 · Delete 删除 · Esc 取消", + "Timeline.Zoom": "缩放", + "Timeline.ReplaceCollisions": "替换碰撞键并应用", + "Timeline.CancelMove": "取消移动", + "Timeline.KeyProtected": "第 0 帧键不能移动或删除,需要时请明确移除整条轨道。", + "Timeline.KeyOutside": "关键帧必须位于支持范围内的非负帧。", + "Timeline.KeyCollision": "移动或重定时会与其他关键帧碰撞,尚未修改内容。", + "Timeline.SelectTrack": "请先选择已有轨道的属性或关键帧。", + "Scene.Playback": "播放实例", + "Scene.SearchPlayback": "搜索播放类型或来源", + "Scene.ChoosePlayback": "选择播放实例", + "Scene.Target": "场上对象", + "Scene.None": "未选择", + "Scene.Unresolved": "未解析目标", + "Scene.Search": "搜索资源、对象或来源", + "Scene.CandidateHint": "候选基于条目执行前的已知状态;未分析的行为可能影响结果。", + "Scene.Global": "整个场景(无图层)", + "Scene.Stale": "内容已变化,请重新打开选择器。", + "Scene.LoadFailed": "目标资源信息加载失败。", + "Scene.AddObject": "添加动画对象", + "Scene.Duplicate": "目标已包含相同属性轨道。", + "Scene.ChooseTarget": "选择场上对象", + "Scene.AddProperty": "添加属性重载", + "Timeline.Key.Selected": "已选关键帧", + "Timeline.Key.Value": "值", + "Timeline.Key.Step": "阶梯", + "Timeline.Key.Linear": "线性", + "Timeline.Key.Incoming": "入切线", + "Timeline.Key.Outgoing": "出切线", + "Timeline.Key.Seconds": "切线按秒显示", + "Timeline.Key.PerFrame": "值/帧", + "Timeline.Key.PerSecond": "值/秒", + "Timeline.Key.Flat": "平坦切线", + "Timeline.Key.LinearTangents": "线性切线", + "Timeline.Key.Smooth": "平滑切线", + "Timeline.Key.Aligned": "对齐切线", + "Timeline.Key.Hint": "插值作用于此键后的区间。混合值留空,输入会统一设置所有选中键。", + "Form.Reset": "重置到默认值", + "Form.Clear": "清空", + "Form.Repair": "高级源码 / 修复", + "Form.Object": "对象属性", + "Form.List": "列表", + "Form.PickColor": "选择颜色", + "Form.ColorInvalid": "请输入 #RRGGBB 或 #AARRGGBB 颜色。", + "Form.TransformInvalid": "变换值无效,请修复源码或重置。", + "Form.OpenTimeline": "打开时间轴", + "Form.TimelineSummary": "{0} 帧 · {1} FPS · {2} 条轨道", + "Form.TimelineEmpty": "尚未创建", + "Form.Transform.X": "X", + "Form.Transform.Y": "Y", + "Form.Transform.RotationDegrees": "旋转 °", + "Form.Transform.ScaleX": "缩放 X", + "Form.Transform.ScaleY": "缩放 Y", + "Form.NoDefault": "此字段没有可用的默认值", + "Entry.Parameter.color": "颜色", + "Entry.Parameter.transform": "变换", + "Entry.Parameter.opacity": "不透明度", + "Entry.Parameter.toTransform": "目标变换", + "Entry.Parameter.timeline": "时间轴", + "Timeline.Curve.Interpolation": "插值", + "Timeline.Curve.Hermite": "贝塞尔 / Hermite", + "Timeline.Curve.Both": "左右切线", + "Timeline.Curve.Free": "自由", + "Timeline.Curve.Flat": "平坦", + "Timeline.Curve.Linear": "线性", + "Timeline.Curve.Auto": "自动贝塞尔", + "Timeline.Curve.Linked": "切线联动", + "Timeline.Curve.Broken": "断开切线", + "Timeline.Curve.EditValues": "编辑数值…", + "Timeline.Curve.Fit": "适应值域", + "Timeline.Curve.Hint": "Alt+滚轮缩放值域,Alt+拖动平移。拖动关键帧调整时间和值;选中后显示切线柄。", + "Scene.CreatedAt": "第 {0} 帧创建", + "Scene.FutureAt": "第 {0} 帧创建(尚未存在)", + "Timeline.Event.Title": "时间轴事件", + "Timeline.Event.Add": "添加事件", + "Timeline.Event.Label": "第 {0} 帧 · {1}", + "Timeline.Event.Edit": "编辑", + "Timeline.Event.Apply": "应用", + "Timeline.Event.Cancel": "取消", + "Timeline.Event.Earlier": "同帧提前", + "Timeline.Event.Later": "同帧延后", + "Timeline.Event.Delete": "删除事件", + "Entry.Category.Timeline": "时间轴", + "Dock.Panel.Localization": "游戏本地化", + "I18n.Characters": "角色表", + "I18n.Branches": "分支选项", + "I18n.CharacterRow": "角色 {0}", + "I18n.EntryRow": "条目 {0} · {1}", + "I18n.LanguageCode": "语言代码", + "I18n.Add": "+ 添加语言", + "I18n.RevertDrafts": "放弃草稿", + "I18n.Development": "开发语言", + "I18n.PreviewAction": "检查变更", + "I18n.ApplyDevelopment": "应用开发语言", + "I18n.Context": "上下文", + "I18n.MoveLeft": "向左移动", + "I18n.MoveRight": "向右移动", + "I18n.Source": "开发语言", + "I18n.Navigate": "定位引用", + "I18n.Missing": "缺译", + "I18n.Empty": "空字符串", + "I18n.Review": "待复核", + "I18n.Unknown": "复核状态未知", + "I18n.Stale": "原文、引用或开发语言已变化。草稿已保留,请检查当前值。", + "I18n.Duplicate": "项目已支持该语言。", + "I18n.AddFirst": "请先添加该语言。", + "I18n.Preview": "切换开发语言为 {0}:有 {1} 条引用文本缺译。不会复制原文。", + "I18n.Incomplete": "部分引用无法分析,缺译统计可能不完整。", + "I18n.UsePanel": "请在游戏本地化面板检查并切换开发语言。", + "History.Languages": "修改项目语言", + "I18n.OptionRow": "选项 {0}", + "Error.i18n.language": "请输入有效语言代码。", + "Error.i18n.stale": "原文、引用或开发语言已变化。草稿已保留,请检查当前值。", + "Error.i18n.duplicate": "项目已支持该语言。", + "Error.i18n.development": "请在游戏本地化面板检查并切换开发语言。", + "Exchange.All": "全部分类", + "Exchange.Runtime": "运行文本", + "Exchange.Export": "导出 JSON", + "Exchange.Import": "导入预览", + "Exchange.AllowEmpty": "允许空字符串清空译文", + "Exchange.Apply": "应用所选", + "Exchange.Cancel": "取消预览", + "Exchange.Unknown": "未知文本", + "Exchange.Skip": "跳过(null)", + "Exchange.Clear": "清空译文", + "Exchange.Change": "将修改", + "Exchange.Unchanged": "未变化", + "History.ImportLocalization": "导入译文:{0}", + "Error.exchange.project": "模板属于其他项目或开发语言。", + "Error.exchange.language": "请选择开发语言以外的受支持翻译语言。", + "Error.exchange.stale": "导入预览已过期,请重新预览文件。", + "Debug.CurrentValue": "当前值", + "Debug.EditValue": "修改为", + "Debug.VariableConflict": "游戏中的值已变化,请重置后重新编辑。", + "Debug.NoVariables": "当前实例没有可显示的变量", + "Debug.NoChoices": "当前没有等待选择的选项", + "Debug.Working": "处理中…", + "Debug.Preparing": "正在准备测试会话…", + "Debug.SavingProject": "正在保存项目…", + "Debug.BuildingContent": "正在构建测试内容…", + "Debug.PreparingSourceMap": "正在准备作者映射…", + "Debug.StartingApplication": "正在启动应用…", + "Debug.Menu": "测试", + "Debug.Step": "单步作者条目", + "Debug.Breakpoint": "切换断点", + "Debug.IsolatedStart": "以初始场景从此处测试", + "Debug.Panel": "游戏调试", + "Debug.Launch": "启动应用", + "Debug.Wait": "等待外部应用", + "Debug.Start": "开始游戏", + "Debug.Stop": "停止游戏", + "Debug.Pause": "暂停", + "Debug.Resume": "继续", + "Debug.GroupEnd": "当前 Group 末尾", + "Debug.Cancel": "取消跳转", + "Debug.Disconnect": "断开连接", + "Debug.Configuration": "启动配置", + "Debug.Executable": "应用程序路径", + "Debug.Arguments": "启动参数,每行一个", + "Debug.WorkingDirectory": "工作目录(可选)", + "Debug.SaveConfiguration": "保存本机配置", + "Debug.PlayerVariables": "玩家变量", + "Debug.SaveVariables": "存档变量", + "Debug.Apply": "应用", + "Debug.Choices": "当前选项", + "Debug.Jump": "跳转到此处", + "Debug.SelectRoute": "选择路线", + "Debug.ContentChanged": "内容已变化,请重新启动测试后定位。", + "Debug.NoRoute": "没有可达目标的路线。", + "Debug.RouteLimit": "路线搜索达到上限,请选择更短的节点链。", + "Entry.Type.variants.show": "显示差分图集", + "Entry.Type.variants.select": "切换图片差分", + "Entry.Parameter.dimension": "维度", + "Entry.Parameter.selection": "初始选择", + "Variants.AmbiguousSource": "图集来源随剧情路径变化", + "Variants.Title": "图片差分图集", + "Variants.Folder": "图片目录(相对描述文件)", + "Variants.Default": "默认图片", + "Variants.Label": "显示名称", + "Variants.AddDimension": "添加维度", + "Variants.Infer": "重新推断维度", + "Variants.IdentityWarning": "维度键或顺序变化可能使剧情引用失效,请核对已有条目。不会自动迁移引用。", + "Variants.Apply": "应用配置", + "Variants.Refresh": "重新扫描", + "Variants.ResetPreview": "重置预览选择", + "Variants.Requested": "请求选择", + "Variants.Actual": "实际图片", + "Variants.New": "从目录创建差分图集", + "Variants.ChooseFolder": "请在 Assets 中选择已导入图片的目录。", + "Variants.Reason.Exact": "完整匹配", + "Variants.Reason.Reduced": "使用简化差分", + "Variants.Reason.Default": "使用默认图片", + "Variants.Inherit": "使用默认选择", + "Uiux.Entry.ExpandAll": "展开全部", + "Uiux.Entry.CollapseAll": "折叠全部", + "Uiux.Save.Drafts": "有待处理输入", + "Uiux.Validation.Passed": "作者检查通过", + "Uiux.Validation.Stale": "检查结果已过期", + "Uiux.Save.Saved": "已保存", + "Uiux.Problems": "问题", + "Uiux.Inspector.Lock": "锁定或解锁检查目标", + "Uiux.Draft.Invalid": "输入尚未完成或无效。", + "Uiux.Save.Unsaved": "未保存", + "Uiux.Save.Failed": "保存失败", + "Uiux.Validation.Check": "检查当前内容", + "Uiux.Entry.Condition": "执行条件", + "Uiux.Validation.Failed": "存在问题", + "Uiux.Validation.Unchecked": "尚未检查", + "Uiux.Entry.Search": "搜索条目内容或序号", + "Uiux.Save.Saving": "正在保存…", + "Uiux.Draft.Conflict": "源值或目标已改变,请检查或取消草稿。", + "Uiux.Draft.Pending": "有尚未提交的输入", + "Uiux.Save.PendingCount": "待处理输入:{0}", + "Uiux.Graph.Map": "小地图", + "Uiux.Files.List": "列表视图", + "Uiux.Files.Empty": "此文件夹为空,可导入资源或新建文件。", + "Uiux.Files.Audio": "音频", + "Uiux.Layout.Animation": "动画编辑布局", + "Uiux.Timeline.Fit": "显示完整时长", + "Uiux.Layout.Writing": "剧情编写布局", + "Uiux.Graph.Search": "搜索节点名称或 ID", + "Uiux.Files.All": "全部类型", + "Uiux.Files.SearchScope": "搜索范围:整个项目", + "Uiux.Files.Groups": "剧情文件", + "Uiux.Files.Other": "其他文件", + "Uiux.Timeline.Playhead": "定位播放头", + "Uiux.Files.Images": "图片", + "Uiux.Files.NoResults": "没有匹配结果,请调整搜索或筛选条件。", + "Uiux.Inspector.Locked": "已锁定", + "Uiux.Layout.Maximize": "最大化面板 / 恢复布局", + "Uiux.Layout.Debug": "游戏调试布局", + "Uiux.Graph.FitSelection": "定位选择", + "Uiux.Graph.FitAll": "显示全图", + "Uiux.Files.Folders": "文件夹", + "Uiux.Files.Video": "视频", + "Uiux.Validation.Incomplete": "检查未完整覆盖当前内容", + "Uiux.Draft.Composing": "输入法正在组合文字,请完成输入后再保存。", + "Uiux.Severity.warning": "警告", + "Uiux.Severity.info": "提示", + "Uiux.Severity.error": "错误", + "Uiux.Entry.Selected": "已选择", + "Uiux.Entry.ActiveOnly": "项;详情仅编辑当前条目" } diff --git a/src/Editor/GalNet.Editor/MarkupExtensions/LocalizeExtension.cs b/src/Editor/GalNet.Editor/MarkupExtensions/LocalizeExtension.cs index 182258a3..cea76262 100644 --- a/src/Editor/GalNet.Editor/MarkupExtensions/LocalizeExtension.cs +++ b/src/Editor/GalNet.Editor/MarkupExtensions/LocalizeExtension.cs @@ -21,6 +21,7 @@ public LocalizeExtension(string key) public string? Key { get; set; } public string? Path { get; set; } + public string? FallbackPath { get; set; } public string? StringFormat { get; set; } @@ -45,6 +46,7 @@ public object ProvideValue(IServiceProvider serviceProvider) { Source = localization }); + if (FallbackPath is not null) binding.Bindings.Add(new ReflectionBinding(FallbackPath)); return binding; } @@ -65,7 +67,8 @@ public object Convert(IList values, Type targetType, object? parameter, if (values.Count == 0 || values[0] is not string key || string.IsNullOrWhiteSpace(key)) return string.Empty; - return localization[key]; + var text = localization[key]; + return text == "[" + key + "]" && values.Count > 2 && values[2] is string fallback ? fallback : text; } } } diff --git a/src/Editor/GalNet.Editor/Models/AssetPickerFilter.cs b/src/Editor/GalNet.Editor/Models/AssetPickerFilter.cs index 10608980..c7775dca 100644 --- a/src/Editor/GalNet.Editor/Models/AssetPickerFilter.cs +++ b/src/Editor/GalNet.Editor/Models/AssetPickerFilter.cs @@ -7,7 +7,8 @@ public enum AssetPickerFilter Audio, Video, EffectProgram, - Text + Text, + ImageVariants } public static class AssetPickerTextFileExtensions diff --git a/src/Editor/GalNet.Editor/Models/Graph/EntryParameterSchema.cs b/src/Editor/GalNet.Editor/Models/Graph/EntryParameterSchema.cs index fe461c7d..532aca70 100644 --- a/src/Editor/GalNet.Editor/Models/Graph/EntryParameterSchema.cs +++ b/src/Editor/GalNet.Editor/Models/Graph/EntryParameterSchema.cs @@ -8,36 +8,61 @@ using GalNet.Core.Entry; using GalNet.Core.Parameters; using GalNet.Editor.Models; +using GalNet.Editor.Services; using GalNet.Game.Controls.Forms; namespace GalNet.Editor.Models.Graph; -public sealed record EntryTypeOption(string Value, string Category, string DisplayNameKey); +public sealed record EntryTypeOption(string Value, string Category, string DisplayNameKey, IReadOnlyList? SearchAliases = null); public sealed record EntrySelectOption(string Value, string DisplayNameKey); +public interface IEntryFieldOrigin +{ + string? GroupId { get; } + string EntryId { get; } +} + /// Schema-driven edits read the author document directly; no DTO snapshot or synchronization is needed. public sealed class EntryParameterFormBinding : IDisposable { private readonly EntryEditorItemViewModel _owner; private EventHandler? _changed; private bool _disposed; - public EntryParameterFormBinding(EntryEditorItemViewModel owner, IReadOnlyList fields) + private readonly string _type; + public EntryParameterFormBinding(EntryEditorItemViewModel owner, IReadOnlyList fields, Type? dtoType = null, Func? isCurrent = null) { _owner = owner; + _isCurrent = isCurrent; + _type = owner.Type; Fields = fields; var members = fields.ToDictionary(field => field.Id, field => (IFieldBinding)new DocumentField(this, field), StringComparer.Ordinal); Binding = field => members[field.Id]; + Item = new(owner.StableId, dtoType, fields, Binding, (patch, token) => !IsCurrent + ? Task.FromResult(new("entry.obsolete", "Entry schema has changed.")) + : owner.CommitParametersAsync(patch.ToDictionary(pair => pair.Key, pair => (fields.Single(field => field.Id == pair.Key), pair.Value)), token)); } + public DtoEditorItem Item { get; } + private readonly Func? _isCurrent; + private bool IsCurrent => !_disposed && _owner.IsCurrent && _owner.Type == _type && (_isCurrent?.Invoke() ?? true); public IReadOnlyList Fields { get; } public Func Binding { get; } public void Refresh() { if (!_disposed) _changed?.Invoke(this, EventArgs.Empty); } public void Dispose() { _disposed = true; _changed = null; } - private sealed class DocumentField(EntryParameterFormBinding form, FieldDescriptor field) : IFieldBinding + private sealed class DocumentField(EntryParameterFormBinding form, FieldDescriptor field) : IFieldBinding, IEntryFieldOrigin { - public bool IsEnabled => !form._disposed; - public FieldValue Read() => form._owner.Parameters.TryGetValue(field.Id, out var text) - ? new(ValueCodec.ParseEditorText(field.Value, text)) : default; + public FieldDraftState? Draft => GetDraft(); + private FieldDraftState? GetDraft() => form._owner.EditSession?.Get(form._owner, field); + public string? GroupId => form._owner.AuthorGroupId; + public string EntryId => form._owner.StableId; + public object HostContext => this; + public object? Label => SceneFieldSemantics.Label(form._owner.Type, @field.Id) ?? @field.LabelKey ?? "Entry.Parameter." + @field.Id; + public bool IsEnabled => form.IsCurrent; + public bool CanReset => form._owner.Type != GalNet.Contracts.Builtins.BuiltinEntryIds.PlayTimeline || @field.Id != "timeline" || @field.DefaultValue is not null; + public bool IsVisible => !form._owner.IsSceneOutput(@field.Id); + public IReadOnlyList? Options => form._owner.SceneOptions(@field.Id); + public FieldValue Read() => form._owner.Source.Parameters.TryGetValue(field.Id, out var json) + ? new(json.Clone()) : default; public event EventHandler? Changed { add { ObjectDisposedException.ThrowIf(form._disposed, form); form._changed += value; } @@ -46,7 +71,7 @@ public event EventHandler? Changed public Task CommitAsync(FieldValue value, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - return form._disposed ? Task.FromResult(new("entry.obsolete", "Entry schema has changed.", field.Id)) : + return !form.IsCurrent ? Task.FromResult(new("entry.obsolete", "Entry schema has changed.", field.Id)) : form._owner.CommitParameterAsync(field, value, cancellationToken); } } @@ -62,7 +87,10 @@ public sealed record EntryParameterDefinition( public partial class EntryParameterEditorItemViewModel : ObservableObject { + internal EntryParameterFormBinding? ActionForm { get; set; } + public IFieldBinding? ActionBinding => ActionForm?.Binding(Definition.Field); private readonly Action _setValue; + private bool _refreshing; public EntryParameterDefinition Definition { get; } public string Id => Definition.Id; @@ -90,29 +118,44 @@ protected EntryParameterEditorItemViewModel(EntryParameterDefinition definition, _stringValue = value; } - partial void OnStringValueChanged(string value) => _setValue(Id, value ?? ""); + partial void OnStringValueChanged(string value) { if (!_refreshing) _setValue(Id, value ?? ""); } + public virtual void RefreshValue(string value) + { + ActionForm?.Refresh(); + _refreshing = true; + try { StringValue = value; } + finally { _refreshing = false; } + } } -/// Editor authoring adapter. Commits still replace Parameters so graph history and persistence observe the edit. +/// Field adapter submits through the project's explicit Entry intent. public sealed class SchemaEntryParameterEditorItemViewModel(EntryParameterDefinition definition, EntryEditorItemViewModel owner, - Action set) : EntryParameterEditorItemViewModel(definition, "", [], set), IFieldBinding + Action set) : EntryParameterEditorItemViewModel(definition, "", [], set), IFieldBinding, IEntryFieldOrigin { + public string? GroupId => owner.AuthorGroupId; + public string EntryId => owner.StableId; + public object HostContext => this; + public bool IsEnabled => owner.IsCurrent && owner.Type == _type && owner.ParameterFields.Contains(this); + public bool CanReset => owner.Type != GalNet.Contracts.Builtins.BuiltinEntryIds.PlayTimeline || Id != "timeline" || Definition.Field.DefaultValue is not null; + private readonly string _type = owner.Type; public IReadOnlyList Fields { get; } = [definition.Field]; public Func FieldBinding => _ => this; + public Type? DtoType => owner.ParameterDtoType; + bool IFieldBinding.IsVisible => !owner.IsSceneOutput(Id); public event EventHandler? Changed; - object? IFieldBinding.Label => Definition.DisplayNameKey; - public void RefreshValue() => Changed?.Invoke(this, EventArgs.Empty); - public FieldValue Read() => owner.Parameters.TryGetValue(Id, out var text) - ? new(ValueCodec.ParseEditorText(Definition.Field.Value, text)) : default; - IReadOnlyList? IFieldBinding.Options => Definition.Options.Count == 0 ? null : Array.AsReadOnly(Definition.Options.Select(option => + object? IFieldBinding.Label => SceneFieldSemantics.Label(owner.Type, Id) ?? Definition.DisplayNameKey; + public override void RefreshValue(string value) => Changed?.Invoke(this, EventArgs.Empty); + public FieldValue Read() => owner.Source.Parameters.TryGetValue(Id, out var json) + ? new(json.Clone()) : default; + IReadOnlyList? IFieldBinding.Options => owner.SceneOptions(Id) ?? (Definition.Options.Count == 0 ? null : Array.AsReadOnly(Definition.Options.Select(option => new FieldEditorOption(option.Value, option, Definition.Field.Value.ValueType == typeof(bool) ? ValueCodec.Serialize(Definition.Field.Value, bool.Parse(option.Value)) - : ValueCodec.FromEditorText(Definition.Field.Value, option.Value))).ToArray()); + : ValueCodec.FromEditorText(Definition.Field.Value, option.Value))).ToArray())); public Task CommitAsync(FieldValue value, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (!owner.ParameterFields.Contains(this)) return Task.FromResult(new("entry.obsolete", "Entry schema has changed.", Id)); + if (!owner.IsCurrent || owner.Type != _type || !owner.ParameterFields.Contains(this)) return Task.FromResult(new("entry.obsolete", "Entry schema has changed.", Id)); return owner.CommitParameterAsync(Definition.Field, value, cancellationToken); } } diff --git a/src/Editor/GalNet.Editor/Models/Graph/GraphPresentationModels.cs b/src/Editor/GalNet.Editor/Models/Graph/GraphPresentationModels.cs index ba3ff749..fdde0b5e 100644 --- a/src/Editor/GalNet.Editor/Models/Graph/GraphPresentationModels.cs +++ b/src/Editor/GalNet.Editor/Models/Graph/GraphPresentationModels.cs @@ -38,6 +38,7 @@ public enum GraphConnectorKind public partial class GraphNode : ObservableObject { + [ObservableProperty] private bool _isDebugCurrent; private static readonly IBrush SelectedBackground = Brush.Parse("#312A55"); private static readonly IBrush DefaultBackground = Brush.Parse("#1E1E2E"); private static readonly IBrush SelectedBorderBrush = Brush.Parse("#8F72FF"); @@ -64,11 +65,43 @@ public string Name if (SetProperty(ref _name, value)) Source.Name = value; } } + private Action? _branchValueRequested; + internal GameTextAuthoringService? GameTexts { get; set; } + public Action? BranchValueRequested + { + get => _branchValueRequested; + set { _branchValueRequested = value; foreach (var item in Options) item.ChangeRequested = value; foreach (var item in Conditions) item.ChangeRequested = value; } + } + private void RefreshBranchItems() + { + var options = Source.Options?.ToArray() ?? []; + var conditions = Source.Conditions?.ToArray() ?? []; + var changed = Options.Count != options.Length || !Options.Select(item => item.Id).SequenceEqual(options.Select(item => item.Id)) + || Conditions.Count != conditions.Length || !Conditions.Select(item => item.Id).SequenceEqual(conditions.Select(item => item.Id)); + foreach (var item in Options.Where(item => !options.Any(dto => ReferenceEquals(dto, item.Source))).ToArray()) { item.ChangeRequested = null; Options.Remove(item); } + foreach (var item in Conditions.Where(item => !conditions.Any(dto => ReferenceEquals(dto, item.Source))).ToArray()) { item.ChangeRequested = null; Conditions.Remove(item); } + for (var i = 0; i < options.Length; i++) + { + var item = Options.FirstOrDefault(item => ReferenceEquals(item.Source, options[i])); + if (item is null) { item = new(options[i]); Options.Insert(i, item); } + else if (Options.IndexOf(item) != i) Options.Move(Options.IndexOf(item), i); + item.ChangeRequested = BranchValueRequested; item.BindText(GameTexts, Id); item.Refresh(); + } + for (var i = 0; i < conditions.Length; i++) + { + var item = Conditions.FirstOrDefault(item => ReferenceEquals(item.Source, conditions[i])); + if (item is null) { item = new(conditions[i]); Conditions.Insert(i, item); } + else if (Conditions.IndexOf(item) != i) Conditions.Move(Conditions.IndexOf(item), i); + item.ChangeRequested = BranchValueRequested; item.Refresh(); + } + if (changed) RefreshConnectors(); + } public void RefreshAuthorValues() { SetProperty(ref _name, Source.Name, nameof(Name)); X = Source.X; Y = Source.Y; + RefreshBranchItems(); } [ObservableProperty] @@ -153,8 +186,8 @@ public void RefreshConnectors() var outputCount = NodeKind switch { GraphNodeKind.Entry => 1, - GraphNodeKind.ChoiceBranch => Math.Max(1, Options.Count), - GraphNodeKind.ConditionBranch => Math.Max(1, Conditions.Count), + GraphNodeKind.ChoiceBranch => Options.Count, + GraphNodeKind.ConditionBranch => Conditions.Count, _ => 1 }; @@ -188,7 +221,10 @@ public partial class GraphConnector : ObservableObject { public GraphNode Node { get; } public GraphConnectorKind Kind { get; } - public int Index { get; } + public string? ItemId { get; } + private readonly int _index; + public int Index => ItemId is null ? _index : Node.NodeKind == GraphNodeKind.ChoiceBranch + ? Node.Options.ToList().FindIndex(item => item.Id == ItemId) : Node.Conditions.ToList().FindIndex(item => item.Id == ItemId); [ObservableProperty] private bool _isConnected; @@ -200,7 +236,9 @@ public GraphConnector(GraphNode node, GraphConnectorKind kind, int index) { Node = node; Kind = kind; - Index = index; + _index = index; + ItemId = kind != GraphConnectorKind.Output ? null : node.NodeKind == GraphNodeKind.ChoiceBranch ? node.Options.ElementAtOrDefault(index)?.Id + : node.NodeKind == GraphNodeKind.ConditionBranch ? node.Conditions.ElementAtOrDefault(index)?.Id : null; } } @@ -291,10 +329,15 @@ partial void OnOutletChanged(int value) public partial class EntryEditorItemViewModel : ObservableObject, IDisposable { + internal EntryEditSession? EditSession { get; set; } + [ObservableProperty] private bool _isDebugBreakpoint; + [ObservableProperty] private bool _isDebugCurrent; + internal string? AuthorGroupId => _groupId; public EditorEntryData Source { get; } private IEntryCatalog? _authorCatalog; private bool _disposed; public string StableId { get => Source.StableId; init => Source.StableId = value; } + [ObservableProperty] private bool _isParametersExpanded = true; public EntryEditorItemViewModel() => Source = new() { StableId = Guid.NewGuid().ToString("N") }; @@ -310,21 +353,74 @@ public EntryEditorItemViewModel(EditorEntryData source, IEntryCatalog catalog) _parameters = source.GetParameterText(catalog); } - partial void OnIdChanged(int value) => Source.Id = value; - partial void OnTypeChanged(string value) => Source.Type = value; - partial void OnConditionChanged(string value) => Source.Condition = value; - - [ObservableProperty] - private int _id; + private EntryFieldAuthoringService? _authoring; + private string? _groupId; + private SceneTargetCatalog? _scenes; + private int _schemaGeneration; + internal void BindAuthoring(string groupId, EntryFieldAuthoringService authoring, SceneTargetCatalog? scenes) { _groupId = groupId; _authoring = authoring; _scenes = scenes; } + internal IReadOnlyList? SceneOptions(string field) => _scenes?.FieldOptions(_groupId!, StableId, Type, field); + internal bool IsSceneOutput(string field) => SceneFieldSemantics.IsOutput(Type, field); + internal Type? ParameterDtoType => _authorCatalog?.TryGet(Type, out var schema) == true ? schema.FieldDataType : null; + internal bool IsCurrent => !_disposed && (_authoring is null || _authoring.IsCurrent(_groupId!, Source)); + internal void RefreshAuthorReferences(IEntryCatalog catalog) + { + var values = Source.GetParameterText(catalog); + var parametersChanged = !_parameters.OrderBy(pair => pair.Key).SequenceEqual(values.OrderBy(pair => pair.Key)); + _parameters = values; + if (_type != Source.Type) InvalidateParameterFields(); + SetProperty(ref _type, Source.Type, nameof(Type)); + SetProperty(ref _condition, Source.Condition, nameof(Condition)); + if (parametersChanged) OnPropertyChanged(nameof(Parameters)); + OnPropertyChanged(nameof(HasUnknownParameters)); + foreach (var field in ParameterFields) field.RefreshValue(Parameters.GetValueOrDefault(field.Id, field.Definition.DefaultValue)); + ParameterForm?.Refresh(); + _detailForm?.Refresh(); + OnPropertyChanged(nameof(DetailForm)); + OnPropertyChanged(nameof(Summary)); + OnPropertyChanged(nameof(HasCondition)); + UpdateEffectHints(); + } - [ObservableProperty] + partial void OnIdChanged(int value) => Source.Id = value; + [ObservableProperty] private int _id; private string _type = ""; - - [ObservableProperty] private string _condition = ""; - - [ObservableProperty] private Dictionary _parameters = new(StringComparer.Ordinal); + public string Type + { + get => Source.Type; + set + { + if (Type == value) return; + if (_authoring is not null) ChangeAuthorType(value, _authorCatalog!.Create(value).Values); + else { Source.Type = value; SetProperty(ref _type, value); } + } + } + internal void ChangeAuthorType(string type, IReadOnlyDictionary parameters) => + _authoring!.SetType(_groupId!, Source, type, EditorEntryData.ParseParameters(_authorCatalog!, type, parameters)); + public string Condition + { + get => Source.Condition; + set + { + value ??= ""; + if (Condition == value) return; + if (_authoring is not null) _authoring.SetCondition(_groupId!, Source, value); + else { Source.Condition = value; SetProperty(ref _condition, value); } + } + } + /// Text is a projection of author JSON, never a second writable document. + public Dictionary Parameters + { + get => _authorCatalog is null ? new(_parameters, StringComparer.Ordinal) : Source.GetParameterText(_authorCatalog); + set + { + if (_authorCatalog is null) { SetProperty(ref _parameters, new(value, StringComparer.Ordinal)); return; } + var parsed = EditorEntryData.ParseParameters(_authorCatalog, Type, value); + if (_authoring is not null) _authoring.SetParameters(_groupId!, Source, Type, parsed); + else { Source.Parameters = parsed; RefreshAuthorReferences(_authorCatalog); } + } + } [ObservableProperty] private string _effectHint = ""; @@ -335,36 +431,47 @@ public EntryEditorItemViewModel(EditorEntryData source, IEntryCatalog catalog) private IEffectCatalog? _effectCatalog; private DynamicParameterTable? _formParameters; [ObservableProperty] private EntryParameterFormBinding? _parameterForm; + private EntryParameterFormBinding? _detailForm; + public EntryParameterFormBinding? DetailForm => ParameterForm ?? _detailForm; + public string Summary => EditSession?.Summary(this) ?? string.Join(" · ", Source.Parameters.Where(pair => pair.Key is "content" or "speaker" or "asset" or "assetId" or "target" or "expression" or "handleId") + .Select(pair => pair.Value.ValueKind == JsonValueKind.String ? pair.Value.GetString() : pair.Value.GetRawText()).Where(value => !string.IsNullOrEmpty(value))).Replace('\n', ' '); + public bool HasCondition => !string.IsNullOrWhiteSpace(Condition); + [ObservableProperty] private int _problemCount; + public bool HasProblems => ProblemCount > 0; + partial void OnProblemCountChanged(int value) => OnPropertyChanged(nameof(HasProblems)); public bool HasParameterForm => ParameterForm is not null; partial void OnParameterFormChanged(EntryParameterFormBinding? value) => OnPropertyChanged(nameof(HasParameterForm)); public bool HasEffectDiagnostic => !string.IsNullOrWhiteSpace(EffectDiagnostic); public ObservableCollection ParameterFields { get; } = []; + public bool IsUnknownType => _authorCatalog is not null && !_authorCatalog.TryGet(Type, out _); + public bool HasUnknownParameters => _authorCatalog is not null && _authorCatalog.TryGet(Type, out var schema) && Source.Parameters.Keys.Any(key => !schema.Parameters.ContainsKey(key)); /// Builds the editor projection from the Core registry, the single schema source. - public void ConfigureParameterFields(IEntryCatalog catalog, IReadOnlyList speakers, IReadOnlyList variableNames, bool resetValues = false, IEffectCatalog? effectCatalog = null) + public void ConfigureParameterFields(IEntryCatalog catalog, IReadOnlyList speakers, IReadOnlyList variableNames, IEffectCatalog? effectCatalog = null) { if (_authorCatalog is null) Source.Parameters = EditorEntryData.ParseParameters(catalog, Type, Parameters); _authorCatalog = catalog; ParameterForm?.Dispose(); ParameterForm = null; + _detailForm?.Dispose(); _detailForm = null; _formParameters = null; _effectCatalog = effectCatalog; - var schema = catalog.Get(Type); - var values = resetValues - ? new Dictionary(schema.Defaults, StringComparer.Ordinal) - : Parameters.Where(pair => schema.Parameters.ContainsKey(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); - if (!Parameters.OrderBy(x => x.Key).SequenceEqual(values.OrderBy(x => x.Key))) - Parameters = values; + _schemaGeneration++; ParameterFields.Clear(); + OnPropertyChanged(nameof(IsUnknownType)); + OnPropertyChanged(nameof(HasUnknownParameters)); + if (!catalog.TryGet(Type, out var schema)) { UpdateEffectHints(); return; } var parameters = schema.DynamicParameters ?? EntrySchema.DynamicParameters(schema.Parameters, schema.Defaults, schema.Options); + _detailForm = new EntryParameterFormBinding(this, parameters.Fields, schema.FieldDataType); + OnPropertyChanged(nameof(DetailForm)); if (schema.FieldDataType is not null) { _formParameters = parameters; if (!schema.Parameters.Any(pair => UsesDedicatedEditor(parameters[pair.Key].Field, pair.Value))) { - ParameterForm = new EntryParameterFormBinding(this, parameters.Fields); + ParameterForm = new EntryParameterFormBinding(this, parameters.Fields, schema.FieldDataType); UpdateEffectHints(); return; } @@ -388,66 +495,85 @@ public void ConfigureParameterFields(IEntryCatalog catalog, IReadOnlyList - field.Value.ValueType == typeof(string) && type is (EntryParameterType.MultilineText or EntryParameterType.Autocomplete + field.Semantic == FieldValueSemantic.None && field.Value.ValueType == typeof(string) && type is (EntryParameterType.MultilineText or EntryParameterType.Autocomplete or EntryParameterType.VariableName or EntryParameterType.Expression or EntryParameterType.ImageAsset or EntryParameterType.AudioAsset or EntryParameterType.VideoAsset or EntryParameterType.EffectProgramAsset) && field.Editor?.EditorId == "entry." + type.ToString().ToLowerInvariant(); - private EntryParameterEditorItemViewModel CreateField(EntryParameterDefinition definition, string value, IReadOnlyList speakers, IReadOnlyList variableNames) => + private EntryParameterEditorItemViewModel CreateField(EntryParameterDefinition definition, string value, IReadOnlyList speakers, IReadOnlyList variableNames) + { + var generation = _schemaGeneration; + var type = Type; + void Commit(string id, string text) { if (generation == _schemaGeneration && Type == type && IsCurrent) SetParameter(id, text); } + EntryParameterEditorItemViewModel result = !UsesDedicatedEditor(definition.Field, definition.Type) - ? new SchemaEntryParameterEditorItemViewModel(definition, this, SetParameter) : definition.Type switch + ? new SchemaEntryParameterEditorItemViewModel(definition, this, Commit) : definition.Type switch { - EntryParameterType.Autocomplete => new AutocompleteEntryParameterEditorItemViewModel(definition, value, speakers, SetParameter), - EntryParameterType.VariableName => new VariableNameEntryParameterEditorItemViewModel(definition, value, variableNames, SetParameter), - EntryParameterType.Expression => new ExpressionEntryParameterEditorItemViewModel(definition, value, variableNames, SetParameter), - EntryParameterType.ImageAsset or EntryParameterType.AudioAsset or EntryParameterType.VideoAsset or EntryParameterType.EffectProgramAsset => new AssetEntryParameterEditorItemViewModel(definition, value, speakers, SetParameter), - _ => new TextEntryParameterEditorItemViewModel(definition, value, speakers, SetParameter) + EntryParameterType.Autocomplete => new AutocompleteEntryParameterEditorItemViewModel(definition, value, speakers, Commit), + EntryParameterType.VariableName => new VariableNameEntryParameterEditorItemViewModel(definition, value, variableNames, Commit), + EntryParameterType.Expression => new ExpressionEntryParameterEditorItemViewModel(definition, value, variableNames, Commit), + EntryParameterType.ImageAsset or EntryParameterType.AudioAsset or EntryParameterType.VideoAsset or EntryParameterType.EffectProgramAsset => new AssetEntryParameterEditorItemViewModel(definition, value, speakers, Commit), + _ => new TextEntryParameterEditorItemViewModel(definition, value, speakers, Commit) }; + result.ActionForm = new(this, [definition.Field], ParameterDtoType, () => generation == _schemaGeneration); + return result; + } private void SetParameter(string id, string value) { - var updated = new Dictionary(Parameters, StringComparer.Ordinal) { [id] = value }; - Parameters = updated; + var updated = Source.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + var field = _authorCatalog!.Get(Type).DynamicParameters![id].Field; + if (value.Length == 0 && (!field.IsRequired || field.DefaultValue is not null) && + field.Editor?.EditorId is "entry.imageasset" or "entry.audioasset" or "entry.videoasset" or "entry.effectprogramasset") updated.Remove(id); + else updated[id] = ValueCodec.ParseEditorText(field.Value, value); + if (_authoring is not null) _authoring.SetParameters(_groupId!, Source, Type, updated); + else { Source.Parameters = updated; RefreshAuthorReferences(_authorCatalog); } } internal Task CommitParameterAsync(FieldDescriptor field, FieldValue value, CancellationToken cancellationToken) + => CommitParametersAsync(new Dictionary { [field.Id] = (field, value) }, cancellationToken); + + internal async Task CommitParametersAsync(IReadOnlyDictionary patch, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (_disposed) return Task.FromResult(new("entry.obsolete", "The project document is closed.", field.Id)); - var updated = new Dictionary(Parameters, StringComparer.Ordinal); - if (value.Json is not { } json) - { - if (field.IsRequired && field.DefaultValue is null) return Task.FromResult(new("entry.required", "A value is required.", field.Id)); - updated.Remove(field.Id); - } - else + if (!IsCurrent) return new("entry.obsolete", "The project document is closed."); + var updated = Source.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + foreach (var (id, (field, value)) in patch) { - if (ValueValidation.Validate(field.Value, json) is { } issue) return Task.FromResult(issue); - var text = ValueCodec.ToEditorText(field.Value, json); - if (!JsonElement.DeepEquals(json, ValueCodec.ParseEditorText(field.Value, text))) - return Task.FromResult(new("entry.representation", "This value cannot be represented by the editor document.", field.Id)); - updated[field.Id] = text; + if (value.Json is not { } json) + { + if (field.IsRequired && field.DefaultValue is null) return new("entry.required", "A value is required.", id); + updated.Remove(id); + } + else + { + if (ValueValidation.Validate(field.Value, json) is { } issue) return issue; + updated[id] = json.Clone(); + } } try { if (_formParameters is { } parameters) - parameters.ValidatePartialArguments(JsonSerializer.SerializeToElement(updated.ToDictionary(pair => pair.Key, - pair => ValueCodec.ParseEditorText(parameters[pair.Key].Value, pair.Value)))); + parameters.ValidatePartialArguments(JsonSerializer.SerializeToElement(updated.Where(pair => patch.ContainsKey(pair.Key) && parameters.Fields.Any(field => field.Id == pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value))); } catch (Exception exception) when (exception is ArgumentException or JsonException) - { return Task.FromResult(new("entry.arguments", exception.Message, field.Id)); } - if (!Parameters.OrderBy(pair => pair.Key).SequenceEqual(updated.OrderBy(pair => pair.Key))) Parameters = updated; - return Task.FromResult(null); + { return new("entry.arguments", exception.Message); } + if (_authoring is not null) await _authoring.SetParametersAsync(_groupId!, Source, Type, updated, cancellationToken); + else { Source.Parameters = updated; RefreshAuthorReferences(_authorCatalog!); } + return null; } - partial void OnParametersChanged(Dictionary value) - { - if (_authorCatalog is not null) Source.Parameters = EditorEntryData.ParseParameters(_authorCatalog, Type, value); - foreach (var field in ParameterFields.OfType()) field.RefreshValue(); - ParameterForm?.Refresh(); - UpdateEffectHints(); - } partial void OnEffectDiagnosticChanged(string value) => OnPropertyChanged(nameof(HasEffectDiagnostic)); private void UpdateEffectHints() @@ -473,6 +599,7 @@ public void Dispose() { if (_disposed) return; _disposed = true; + _detailForm?.Dispose(); ParameterForm?.Dispose(); ParameterFields.Clear(); GC.SuppressFinalize(this); @@ -481,28 +608,33 @@ public void Dispose() public partial class BranchOptionEditorItemViewModel : ObservableObject { + public GameTextAuthoringService? GameTexts { get; private set; } + public IFieldBinding? TextBinding { get; private set; } + internal void BindText(GameTextAuthoringService? service, string node) + { + if (ReferenceEquals(GameTexts, service)) return; + GameTexts = service; TextBinding = service?.BranchBinding(node, Source); + OnPropertyChanged(nameof(GameTexts)); OnPropertyChanged(nameof(TextBinding)); + } public EditorGraphBranchOptionDto Source { get; } public string Id { get => Source.Id; init => Source.Id = value; } - public BranchOptionEditorItemViewModel() => Source = new() { Id = Guid.NewGuid().ToString("N") }; - public BranchOptionEditorItemViewModel(EditorGraphBranchOptionDto source) { Source = source; _text = source.Text; _condition = source.Condition; } - partial void OnTextChanged(string value) => Source.Text = value; - partial void OnConditionChanged(string value) => Source.Condition = value; - - [ObservableProperty] - private string _text = ""; - - [ObservableProperty] - private string _condition = ""; + public Action? ChangeRequested { get; set; } + private string _text = "", _condition = ""; + public string Text { get => _text; set { if (_text == value) return; if (ChangeRequested is { } change) change(Id, nameof(Text), value); else { Source.Text = value ?? ""; Refresh(); } } } + public string Condition { get => _condition; set { if (_condition == value) return; if (ChangeRequested is { } change) change(Id, nameof(Condition), value); else { Source.Condition = value ?? ""; Refresh(); } } } + public BranchOptionEditorItemViewModel() : this(new() { Id = Guid.NewGuid().ToString("N") }) { } + public BranchOptionEditorItemViewModel(EditorGraphBranchOptionDto source) { Source = source; Refresh(); } + public void Refresh() { SetProperty(ref _text, Source.Text, nameof(Text)); SetProperty(ref _condition, Source.Condition, nameof(Condition)); } } public partial class BranchConditionEditorItemViewModel : ObservableObject { public EditorGraphBranchConditionDto Source { get; } public string Id { get => Source.Id; init => Source.Id = value; } - public BranchConditionEditorItemViewModel() => Source = new() { Id = Guid.NewGuid().ToString("N") }; - public BranchConditionEditorItemViewModel(EditorGraphBranchConditionDto source) { Source = source; _expression = source.Expression; } - partial void OnExpressionChanged(string value) => Source.Expression = value; - - [ObservableProperty] + public Action? ChangeRequested { get; set; } private string _expression = ""; + public string Expression { get => _expression; set { if (_expression == value) return; if (ChangeRequested is { } change) change(Id, nameof(Expression), value); else { Source.Expression = value ?? ""; Refresh(); } } } + public BranchConditionEditorItemViewModel() : this(new() { Id = Guid.NewGuid().ToString("N") }) { } + public BranchConditionEditorItemViewModel(EditorGraphBranchConditionDto source) { Source = source; Refresh(); } + public void Refresh() => SetProperty(ref _expression, Source.Expression, nameof(Expression)); } diff --git a/src/Editor/GalNet.Editor/Models/ProjectFileItem.cs b/src/Editor/GalNet.Editor/Models/ProjectFileItem.cs new file mode 100644 index 00000000..1035d672 --- /dev/null +++ b/src/Editor/GalNet.Editor/Models/ProjectFileItem.cs @@ -0,0 +1,38 @@ +using System.Collections.ObjectModel; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GalNet.Editor.Models; + +public sealed partial class ProjectFileItem : ObservableObject, IDisposable +{ + public required string Path { get; init; } + public string Name => System.IO.Path.GetFileName(Path); + public required string FullPath { get; init; } + public bool IsDirectory { get; init; } + public string Type { get; init; } = "file"; + public DateTime WriteTimeUtc { get; init; } + public string ProtectionReason { get; init; } = ""; + public bool CanEdit => ProtectionReason.Length == 0; + public string Icon => IsDirectory ? "📁" : Type == "sprite" ? "▧" : Type == "audio" ? "♫" : Type == "video" ? "▷" : "▤"; + [ObservableProperty] private Bitmap? _thumbnail; + [ObservableProperty] private bool _isRenaming; + [ObservableProperty] private bool _isDropTarget; + public void Dispose() => Thumbnail?.Dispose(); +} + +public sealed partial class ProjectDirectoryNode(string path, string name, Func> load, bool hasChildren = true) : ObservableObject +{ + public string Path { get; } = path; + public string Name { get; } = name; + public ObservableCollection Children { get; } = hasChildren ? [new(path, "…", _ => [], false)] : []; + private bool _loaded = !hasChildren; + [ObservableProperty] private bool _isExpanded; + partial void OnIsExpandedChanged(bool value) { if (value) EnsureChildren(); } + public void EnsureChildren() + { + if (_loaded) return; _loaded = true; + Children.Clear(); + foreach (var child in load(Path)) Children.Add(child); + } +} diff --git a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs b/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs deleted file mode 100644 index a683650b..00000000 --- a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using GalNet.Core.Variable; - -namespace GalNet.Editor.Models; - -/// Owns ordered editor variable definitions and their collection invariants. -public sealed class VariableDefinitions -{ - private readonly List _definitions; - public VariableDefinitions(List definitions) => _definitions = definitions; - public int Count => _definitions.Count; - public IEnumerable Items => _definitions; - public ProjectVariableDefinition Add(string name) - { - var definition = new ProjectVariableDefinition { Name = name, DefaultValue = new Variable { Name = name } }; - _definitions.Add(definition); - return definition; - } - public bool Remove(ProjectVariableDefinition definition) => _definitions.Remove(definition); - public bool Move(ProjectVariableDefinition definition, int newIndex) - { - var oldIndex = _definitions.IndexOf(definition); - if (oldIndex < 0 || newIndex < 0 || newIndex >= _definitions.Count || oldIndex == newIndex) return false; - _definitions.RemoveAt(oldIndex); - _definitions.Insert(newIndex, definition); - return true; - } -} diff --git a/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs b/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs index bbc57f78..16d81d43 100644 --- a/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs +++ b/src/Editor/GalNet.Editor/Services/AssetCatalogService.cs @@ -11,7 +11,7 @@ namespace GalNet.Editor.Services; -/// Owns editor-side Assets file operations and keeps sidecar JSON metadata in lockstep. +/// Read-only Assets index; mutations belong to project file history. public sealed class AssetCatalogService : IAssetCatalogService { private readonly IProjectService _projects; @@ -61,8 +61,7 @@ public async Task RefreshAsync(CancellationToken cancellationToken = default) try { var root = RootOrNull(); if (root is null) return; - foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) - { cancellationToken.ThrowIfCancellationRequested(); await EnsureMetaAsync(file, cancellationToken); } + while (_projects.Current?.Services.GetService(typeof(IProjectContentSession)) is IProjectContentSession { IsPublishing: true }) await Task.Delay(25, cancellationToken); Changed?.Invoke(); } finally { _refreshGate.Release(); } @@ -83,7 +82,6 @@ private void StartWatching() private void OnFileSystemChanged(object? sender, FileSystemEventArgs args) { - if (args.FullPath.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return; _watchDebounce?.Cancel(); _watchDebounce?.Dispose(); var cts = _watchDebounce = new CancellationTokenSource(); _ = Task.Run(async () => @@ -93,40 +91,6 @@ private void OnFileSystemChanged(object? sender, FileSystemEventArgs args) }); } - public async Task ImportAsync(IEnumerable sourcePaths, string targetDirectory, CancellationToken cancellationToken = default) - { - var target = Resolve(targetDirectory); Directory.CreateDirectory(target); - foreach (var source in sourcePaths.Where(File.Exists)) - { - cancellationToken.ThrowIfCancellationRequested(); - var destination = UniquePath(Path.Combine(target, Path.GetFileName(source))); - File.Copy(source, destination); await EnsureMetaAsync(destination, cancellationToken); - } - Changed?.Invoke(); - } - - public async Task ImportExternalAsync(IEnumerable sourcePaths, string targetDirectory, CancellationToken cancellationToken = default) - { - var target = Resolve(targetDirectory); - Directory.CreateDirectory(target); - foreach (var source in sourcePaths.Distinct(StringComparer.OrdinalIgnoreCase)) - { - cancellationToken.ThrowIfCancellationRequested(); - if (File.Exists(source)) - { - var destination = UniquePath(Path.Combine(target, Path.GetFileName(source))); - File.Copy(source, destination); - await EnsureMetaAsync(destination, cancellationToken); - } - else if (Directory.Exists(source)) - { - var destination = UniquePath(Path.Combine(target, Path.GetFileName(source.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)))); - await CopyDirectoryAsync(source, destination, cancellationToken); - } - } - Changed?.Invoke(); - } - public Task GetThumbnailPathAsync(AssetEntry entry, CancellationToken cancellationToken = default) { if (entry.IsImage) return Task.FromResult(entry.FullPath); @@ -135,95 +99,12 @@ public async Task ImportExternalAsync(IEnumerable sourcePaths, string ta return Task.FromResult(null); } - private async Task CopyDirectoryAsync(string source, string destination, CancellationToken cancellationToken) - { - Directory.CreateDirectory(destination); - foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) - Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); - foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).Where(file => !file.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) - { - cancellationToken.ThrowIfCancellationRequested(); - var target = Path.Combine(destination, Path.GetRelativePath(source, file)); - Directory.CreateDirectory(Path.GetDirectoryName(target)!); - File.Copy(file, target, overwrite: false); - await EnsureMetaAsync(target, cancellationToken); - } - } - - public async Task MoveAsync(string relativePath, string targetDirectory, CancellationToken cancellationToken = default) - { - var source = Resolve(relativePath); var targetDir = Resolve(targetDirectory); - if ((!File.Exists(source) && !Directory.Exists(source)) || !Directory.Exists(targetDir)) throw new IOException("Invalid asset move."); - if (Directory.Exists(source) && targetDir.StartsWith(source + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new IOException("A folder cannot be moved into itself."); - if (string.Equals(Path.GetDirectoryName(source), targetDir, StringComparison.OrdinalIgnoreCase)) return; - var destination = UniquePath(Path.Combine(targetDir, Path.GetFileName(source))); - if (Directory.Exists(source)) { Directory.Move(source, destination); await RewriteMetaPathsTreeAsync(destination, cancellationToken); } - else { File.Move(source, destination); var meta = source + ".meta"; if (File.Exists(meta)) File.Move(meta, destination + ".meta"); await RewriteMetaPathAsync(destination, cancellationToken); } - Changed?.Invoke(); - } - - public async Task RenameAsync(string relativePath, string newName, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(newName) || newName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) throw new ArgumentException("Invalid file name."); - var source = Resolve(relativePath); if (!File.Exists(source) && !Directory.Exists(source)) throw new FileNotFoundException(); - var destination = UniquePath(Path.Combine(Path.GetDirectoryName(source)!, newName)); - if (Directory.Exists(source)) { Directory.Move(source, destination); await RewriteMetaPathsTreeAsync(destination, cancellationToken); } - else { File.Move(source, destination); if (File.Exists(source + ".meta")) File.Move(source + ".meta", destination + ".meta"); await RewriteMetaPathAsync(destination, cancellationToken); } - Changed?.Invoke(); - } - - public Task DeleteAsync(string relativePath, CancellationToken cancellationToken = default) - { - var path = Resolve(relativePath); - if (File.Exists(path)) { File.Delete(path); if (File.Exists(path + ".meta")) File.Delete(path + ".meta"); } - else if (Directory.Exists(path)) Directory.Delete(path, recursive: true); - Changed?.Invoke(); return Task.CompletedTask; - } - - public async Task UpdateMetaAsync(AssetEntry entry, string? filter, string? compress, CancellationToken cancellationToken = default) - { - var meta = await ReadMetaAsync(entry.FullPath, cancellationToken) ?? CreateMeta(entry.FullPath); - if (meta is SpriteAssetMeta sprite) sprite.Filter = filter; - meta.Compress = entry.IsImage || entry.IsVideo ? compress : null; - await WriteMetaAsync(entry.FullPath, meta, cancellationToken); Changed?.Invoke(); - } - - public Task CreateDirectoryAsync(string parentDirectory, string name, CancellationToken cancellationToken = default) - { - if (string.IsNullOrWhiteSpace(name) || name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) - throw new ArgumentException("Invalid folder name."); - var parent = Resolve(parentDirectory); - Directory.CreateDirectory(UniquePath(Path.Combine(parent, name))); - Changed?.Invoke(); - return Task.CompletedTask; - } - private AssetEntry CreateDirectory(string fullPath) => new() { FullPath = fullPath, RelativePath = Relative(fullPath), Name = Path.GetFileName(fullPath), IsDirectory = true }; private AssetEntry CreateFile(string fullPath) { var meta = ReadMeta(fullPath); return new() { FullPath = fullPath, RelativePath = Relative(fullPath), Name = Path.GetFileName(fullPath), Type = meta?.TypeId ?? InferType(fullPath), Id = meta?.Id, Filter = (meta as SpriteAssetMeta)?.Filter, Compress = meta?.Compress, HasValidMeta = meta is not null }; } - private async Task EnsureMetaAsync(string fullPath, CancellationToken ct) { if (!File.Exists(fullPath + ".meta")) await WriteMetaAsync(fullPath, CreateMeta(fullPath), ct); } - private AssetMeta CreateMeta(string fullPath) - { - var type = InferType(fullPath); - var registration = _resourceTypes.Get(type); - var meta = (AssetMeta)Activator.CreateInstance(registration.MetaDtoType)!; - meta.Id = Guid.NewGuid().ToString("N"); - meta.TypeId = type; - meta.Path = Relative(fullPath); - meta.Compress = type is "sprite" or "video" ? "none" : null; - if (meta is SpriteAssetMeta sprite) sprite.Filter = "bilinear"; - return meta; - } - private async Task RewriteMetaPathAsync(string path, CancellationToken ct) { var meta = await ReadMetaAsync(path, ct) ?? CreateMeta(path); meta.Path = Relative(path); await WriteMetaAsync(path, meta, ct); } - private async Task RewriteMetaPathsTreeAsync(string directory, CancellationToken ct) - { - foreach (var file in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories).Where(p => !p.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))) - await RewriteMetaPathAsync(file, ct); - } - private async Task ReadMetaAsync(string file, CancellationToken ct) { try { return File.Exists(file + ".meta") ? _metaCodec.Deserialize(await File.ReadAllTextAsync(file + ".meta", ct)) : null; } catch { return null; } } private AssetMeta? ReadMeta(string file) { try @@ -233,7 +114,6 @@ private async Task RewriteMetaPathsTreeAsync(string directory, CancellationToken } catch { return null; } } - private Task WriteMetaAsync(string file, AssetMeta meta, CancellationToken ct) => File.WriteAllTextAsync(file + ".meta", _metaCodec.Serialize(meta), ct); private string? RootOrNull() => _projects.Current?.AssetsPath; private string Resolve(string relative) { @@ -242,7 +122,6 @@ private string Resolve(string relative) } private string Relative(string full) => Path.GetRelativePath(RootOrNull()!, full).Replace('\\', '/'); private string InferType(string path) => _resourceTypes.TryGetByExtension(Path.GetExtension(path), out var registration) ? registration.TypeId : "data"; - private static string UniquePath(string path) { var dir = Path.GetDirectoryName(path)!; var stem = Path.GetFileNameWithoutExtension(path); var ext = Path.GetExtension(path); var candidate = path; var index = 1; while (File.Exists(candidate) || Directory.Exists(candidate)) candidate = Path.Combine(dir, $"{stem} {index++}{ext}"); return candidate; } public void Dispose() { _projects.CurrentChanged -= _projectChangedHandler; diff --git a/src/Editor/GalNet.Editor/Services/AuthorNavigationService.cs b/src/Editor/GalNet.Editor/Services/AuthorNavigationService.cs new file mode 100644 index 00000000..a5fbb483 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/AuthorNavigationService.cs @@ -0,0 +1,30 @@ +using GalNet.Editor.Dock; +using GalNet.Editor.ViewModels; + +namespace GalNet.Editor.Services; + +public sealed record AuthorNavigationTarget(string? NodeId = null, string? EntryId = null, string? FieldId = null, string? File = null, long? Revision = null); + +/// Resolves stable author identities at navigation time; never follows an old row index. +public sealed class AuthorNavigationService(EditorWorkspaceViewModel workspace, EditorDockFactory dock, ProjectTextFiles files) +{ + public event Action? Requested; + public bool Navigate(AuthorNavigationTarget target) + { + if (target.NodeId is { } nodeId) + { + var node = workspace.Nodes.FirstOrDefault(item => item.Id == nodeId); + if (node is null || target.EntryId is { } entryId && !node.Entries.Any(item => item.StableId == entryId)) return false; + dock.NavigateLocalization(nodeId, target.EntryId); + dock.ShowInspector(); + } + else if (target.File is { } path) + { + try { var file = files.Open(path); dock.OpenFileEditor(file); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or InvalidDataException or GalNet.Editor.Abstraction.Services.EditorOperationException) + { return false; } + } + else return false; + Requested?.Invoke(target); return true; + } +} diff --git a/src/Editor/GalNet.Editor/Services/CharacterAuthoringService.cs b/src/Editor/GalNet.Editor/Services/CharacterAuthoringService.cs new file mode 100644 index 00000000..cf486fa8 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/CharacterAuthoringService.cs @@ -0,0 +1,210 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Entry; +using GalNet.Core.Parameters; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +public sealed record CharacterRow(string Id, string DisplayNameKey, string? Name, int Uses) +{ + public string DisplayName => Name ?? Id; + public override string ToString() => DisplayName + " · " + Id; +} + +/// Character identities, default-language names and declared references share one project history. +public sealed class CharacterAuthoringService(LocalizationDocumentStore store, ProjectLocalizationQuery query, + IProjectContentSession content, IEditorDocumentService documents, IEntryCatalog catalog, + LocalizationAuthoringService authoring, IEditorLocalizationService text, GameTextAuthoringService texts) +{ + private readonly SemaphoreSlim _gate = new(1, 1); + public IEditorLocalizationService Text => text; + public event Action? Changed { add => content.Changed += value; remove => content.Changed -= value; } + private LocalizationReferenceIndex Index => query.Get() ?? throw Error("character.obsolete"); + public bool Complete => query.Get()?.Complete == true; + public IReadOnlyList Rows() + { + var index = Index; + var culture = content.CaptureWorking().Settings.TargetLocale.Code; + var counts = index.References.Where(reference => reference.Semantic == FieldValueSemantic.CharacterReference) + .GroupBy(reference => reference.Key, StringComparer.Ordinal).ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + return index.Characters.Select(character => new CharacterRow(character.Id, character.DisplayNameKey, + index.FindDirect(culture, character.DisplayNameKey)?.Text, + counts.GetValueOrDefault(character.Id))).ToArray(); + } + private EditorOperationException Error(string code) => new(code, text["Error." + code]); + private static JsonArray ReadCatalog(LocalizationFileSnapshot snapshot) + { + LocalizationJson.Validate(snapshot.Path, snapshot.Data); + return JsonNode.Parse(LocalizationJson.Decode(snapshot.Data))!.AsArray(); + } + private static JsonObject Find(JsonArray array, string id) => array.Select(node => node!.AsObject()).Single(node => node["id"]!.GetValue() == id); + private async Task EnsureFilesAsync() + { + var locale = content.CaptureWorking().Settings.TargetLocale.Code; + var files = new[] { store.CaptureFile(LocalizationJson.CatalogPath), store.CaptureFile($"I18n/{locale}/characters.json") }; + var missing = files.Where(file => !file.Exists).Select(file => new LocalizationFilePatch(file, file.Data)).ToArray(); + if (missing.Length > 0) await store.Prepare(missing).ApplyAsync(true); + } + private void ValidateName(string name) { if (string.IsNullOrWhiteSpace(name)) throw Error("character.name"); } + private void RequireComplete() { if (!Complete) throw Error("character.incomplete"); } + + public async Task CreateAsync(string name, string? key = null) + { + await _gate.WaitAsync(); + try { return await CreateCoreAsync(name, key); } + finally { _gate.Release(); } + } + private async Task CreateCoreAsync(string name, string? key) + { + ValidateName(name); + if (key is not null && string.IsNullOrWhiteSpace(key)) throw Error("character.key"); + if (key is not null && Rows().Any(row => row.Id == key)) throw Error("character.duplicate"); + await EnsureFilesAsync(); + key ??= await store.ReserveCharacterKeyAsync(); + var file = store.CaptureFile(LocalizationJson.CatalogPath); + await content.CommitFileDraftsAsync(file.Id); + var array = ReadCatalog(file); + if (array.Any(node => node!["id"]!.GetValue() == key)) throw Error("character.duplicate"); + var nameKey = "character." + Guid.NewGuid().ToString("N") + ".name"; + array.Add(new JsonObject { ["id"] = key, ["displayNameKey"] = nameKey }); + var target = store.CaptureText(nameKey, newFile: $"I18n/{content.CaptureWorking().Settings.TargetLocale.Code}/characters.json"); + await content.CommitFileDraftsAsync(target.File.Id); + var patches = new[] { new LocalizationFilePatch(file, LocalizationJson.WriteLike(file.Data, array)), store.PrepareText(target, name) }; + await authoring.ApplyAsync(store.Prepare(patches), key, "History.Character"); + return Rows().Single(row => row.Id == key); + } + + public async Task UpdateAsync(CharacterRow expected, string key, string name) + { + await _gate.WaitAsync(); + try + { + ValidateName(name); + if (string.IsNullOrWhiteSpace(key)) throw Error("character.key"); + if (Rows().Any(row => row.Id == key && row.Id != expected.Id)) throw Error("character.duplicate"); + if (key != expected.Id) RequireComplete(); + var before = store.CaptureFile(LocalizationJson.CatalogPath); + var array = ReadCatalog(before); + var row = Rows().SingleOrDefault(row => row.Id == expected.Id); + if (row is null || row.DisplayNameKey != expected.DisplayNameKey || row.Name != expected.Name) throw Error("character.changed"); + if (key == row.Id && name == row.Name) return; + var target = store.CaptureText(row.DisplayNameKey); + await content.CommitFileDraftsAsync(target.File.Id); + Find(array, expected.Id)["id"] = key; + var refs = key != expected.Id ? ReferencePatches(expected.Id, key) : []; + var patches = await texts.PrepareTextAsync(target, name, [new(before, LocalizationJson.WriteLike(before.Data, array))], () => + { if (Rows().SingleOrDefault(row => row.Id == expected.Id)?.Name != expected.Name) throw Error("character.changed"); }); + await authoring.ApplyAsync(store.PrepareWithEntries( + patches, refs), key, "History.Character"); + } + finally { _gate.Release(); } + } + public async Task DeleteAsync(CharacterRow expected) + { + await _gate.WaitAsync(); + try + { + RequireComplete(); + var row = Rows().SingleOrDefault(row => row.Id == expected.Id); + if (row is null || row.DisplayNameKey != expected.DisplayNameKey || row.Name != expected.Name) throw Error("character.changed"); + if (row.Uses > 0) throw Error("character.inUse"); + var file = store.CaptureFile(LocalizationJson.CatalogPath); + var array = ReadCatalog(file); array.Remove(Find(array, row.Id)); + await authoring.ApplyAsync(store.Prepare(new LocalizationFilePatch(file, LocalizationJson.WriteLike(file.Data, array))), row.Id, "History.Character"); + } + finally { _gate.Release(); } + } + + public async Task CommitParametersAsync(string groupId, EditorEntryData expected, string type, + Dictionary updated, Func isCurrent, CancellationToken cancellationToken) + { + var before = expected.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + var candidate = new EditorEntryData { StableId = expected.StableId, Type = type, Parameters = updated }; + var oldRefs = References(groupId, expected).ToDictionary(reference => reference.JsonPointer, reference => reference.Key, StringComparer.Ordinal); + var changed = References(groupId, candidate).Where(reference => oldRefs.GetValueOrDefault(reference.JsonPointer) != reference.Key).ToArray(); + if (changed.Length == 0) return false; + await _gate.WaitAsync(cancellationToken); + try + { + void Current() + { + cancellationToken.ThrowIfCancellationRequested(); + if (!isCurrent() || expected.Type != type || !LocalizationEditOperation.Same(expected.Parameters, before)) throw Error("character.obsolete"); + } + Current(); + var root = JsonSerializer.SerializeToNode(updated)!.AsObject(); + // Resolve against the catalog after admission to the gate, never an old name-search snapshot. + var rows = Rows(); var needed = new Dictionary(StringComparer.Ordinal); + foreach (var reference in changed) + { + var exact = rows.SingleOrDefault(row => row.Id == reference.Key); + var names = rows.Where(row => row.Name == reference.Key).ToArray(); + if (exact is null && names.Length > 1) throw Error("character.ambiguous"); + if (exact is not null || names.Length == 1) Set(root, reference.JsonPointer, (exact ?? names[0]).Id); + else needed.TryAdd(reference.Key, ""); + } + var filePatches = new List(); + if (needed.Count > 0) + { + await EnsureFilesAsync(); + foreach (var name in needed.Keys.ToArray()) { needed[name] = await store.ReserveCharacterKeyAsync(); Current(); } + var file = store.CaptureFile(LocalizationJson.CatalogPath); var array = ReadCatalog(file); + var nameFile = store.CaptureFile($"I18n/{content.CaptureWorking().Settings.TargetLocale.Code}/characters.json"); + await content.CommitFileDraftsAsync(nameFile.Id); Current(); nameFile = store.CaptureFile(nameFile.Path); + var namesBytes = nameFile.Data; + foreach (var (name, id) in needed) + { + var nameKey = "character." + Guid.NewGuid().ToString("N") + ".name"; + array.Add(new JsonObject { ["id"] = id, ["displayNameKey"] = nameKey }); + namesBytes = LocalizationJson.SetText(namesBytes, nameKey, name); + } + filePatches.Add(new(file, LocalizationJson.WriteLike(file.Data, array))); filePatches.Add(new(nameFile, namesBytes)); + foreach (var reference in changed.Where(reference => needed.ContainsKey(reference.Key))) Set(root, reference.JsonPointer, needed[reference.Key]); + } + Current(); + var after = root.Deserialize>()!; + if (await texts.PrepareEntryAsync(groupId, expected, after, filePatches, Current) is { } localized) + { after = localized.Parameters; filePatches = localized.Files.ToList(); } + Current(); + ValidateParameters(type, after, before); + await authoring.ApplyAsync(store.PrepareWithEntries(filePatches, [new(groupId, expected.StableId, type, before, after)]), expected.StableId, "History.Character"); + return true; + } + finally { _gate.Release(); } + } + + private IReadOnlyList References(string group, EditorEntryData entry) => new LocalizationReferenceIndex( + new() { GroupEntries = new() { [group] = [entry] } }, catalog, []).References.Where(reference => reference.Semantic == FieldValueSemantic.CharacterReference).ToArray(); + private IReadOnlyList ReferencePatches(string from, string to) + { + var patches = new List(); + foreach (var group in Index.References.Where(reference => reference.Semantic == FieldValueSemantic.CharacterReference && reference.Key == from) + .GroupBy(reference => (reference.Owner, reference.EntryId))) + { + var entry = documents.Groups[group.Key.Owner].Entries.Single(entry => entry.StableId == group.Key.EntryId); + var node = JsonSerializer.SerializeToNode(entry.Parameters)!.AsObject(); + foreach (var reference in group) Set(node, reference.JsonPointer, to); + var after = node.Deserialize>()!; + ValidateParameters(entry.Type, after, entry.Parameters); + patches.Add(new(group.Key.Owner, entry.StableId, entry.Type, entry.Parameters, after)); + } + return patches; + } + private void ValidateParameters(string type, IReadOnlyDictionary values, IReadOnlyDictionary before) + { + var schema = catalog.Get(type).DynamicParameters; + schema?.ValidatePartialArguments(JsonSerializer.SerializeToElement(values.Where(pair => schema.ContainsKey(pair.Key) && + (!before.TryGetValue(pair.Key, out var previous) || !JsonElement.DeepEquals(previous, pair.Value))).ToDictionary(pair => pair.Key, pair => pair.Value))); + } + private static void Set(JsonNode root, string pointer, string value) + { + var parts = pointer.Split('/').Skip(1).Select(part => part.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal)).ToArray(); + var parent = root; + foreach (var part in parts.SkipLast(1)) parent = parent is JsonArray array ? array[int.Parse(part, System.Globalization.CultureInfo.InvariantCulture)]! : parent[part]!; + if (parent is JsonArray list) list[int.Parse(parts[^1], System.Globalization.CultureInfo.InvariantCulture)] = value; + else parent[parts[^1]] = value; + } +} diff --git a/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs b/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs index 2be060ce..a6cd48bf 100644 --- a/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs +++ b/src/Editor/GalNet.Editor/Services/EditorAssetManager.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using GalNet.Assets; using GalNet.Assets.Provider; +using GalNet.Assets.Variants; using GalNet.Core.Assets; using GalNet.Editor.Abstraction.Services; using GalNet.Editor.Services.Interfaces; @@ -30,7 +31,9 @@ public EditorAssetManager(IProjectService projects, IAssetCatalogService catalog private void OnCatalogChanged() => Rebuild(_projects.Current); private void Rebuild(Abstraction.Project.GalProject? project) { - var previous = Interlocked.Exchange(ref _inner, project is null ? new AssetManager() : new AssetManager([new LocalFileProvider(project.AssetsPath, _resourceTypes, optional: true)])); + var next = project is null ? new AssetManager() : new AssetManager([new LocalFileProvider(project.AssetsPath, _resourceTypes, optional: true)]); + next.RegisterImageVariantsDecoder(); + var previous = Interlocked.Exchange(ref _inner, next); previous.Dispose(); } public Task GetFileAsync(string assetId, CancellationToken ct = default) => _inner.GetFileAsync(assetId, ct); diff --git a/src/Editor/GalNet.Editor/Services/EditorGroupProjectionStore.cs b/src/Editor/GalNet.Editor/Services/EditorGroupProjectionStore.cs index 0048f500..4f89b8d5 100644 --- a/src/Editor/GalNet.Editor/Services/EditorGroupProjectionStore.cs +++ b/src/Editor/GalNet.Editor/Services/EditorGroupProjectionStore.cs @@ -9,15 +9,25 @@ namespace GalNet.Editor.Services; /// Project-owned projections. Closing a Group page releases its subscriptions, not its content. public sealed class EditorGroupProjectionStore : IDisposable { + public IEnumerable Loaded => _groups.Values; private readonly Dictionary _groups = new(StringComparer.Ordinal); private readonly IEditorDocumentService documents; private readonly IEntryCatalog catalog; - public EditorGroupProjectionStore(IEditorDocumentService documents, IEntryCatalog catalog) + private readonly EntryFieldAuthoringService authoring; + private readonly SceneTargetCatalog? scenes; + private readonly History.EditorHistories? histories; + private readonly EntryEditSession? edits; + public EditorGroupProjectionStore(IEditorDocumentService documents, IEntryCatalog catalog, EntryFieldAuthoringService authoring, SceneTargetCatalog? scenes = null, History.EditorHistories? histories = null, EntryEditSession? edits = null) { this.documents = documents; this.catalog = catalog; + this.authoring = authoring; + this.scenes = scenes; this.histories = histories; this.edits = edits; documents.DocumentChanged += Synchronize; + authoring.Changed += RefreshFields; + if (histories is not null) histories.Changed += Synchronize; } + private void RefreshFields(string groupId) { if (_groups.TryGetValue(groupId, out var group)) group.Synchronize(catalog); } private void Synchronize() { foreach (var group in _groups.Values) group.Synchronize(catalog); @@ -27,7 +37,7 @@ public EditorGroupProjection Get(string groupId, string? sourcePath = null) { if (_groups.TryGetValue(groupId, out var existing)) return existing; var source = documents.GetOrCreateGroup(groupId, "Graph/" + (sourcePath ?? $"groups/{groupId}.rawgalgroup")); - var projection = new EditorGroupProjection(source, catalog); + var projection = new EditorGroupProjection(source, catalog, authoring, scenes, edits); _groups.Add(groupId, projection); return projection; } @@ -35,6 +45,8 @@ public EditorGroupProjection Get(string groupId, string? sourcePath = null) public void Dispose() { documents.DocumentChanged -= Synchronize; + authoring.Changed -= RefreshFields; + if (histories is not null) histories.Changed -= Synchronize; foreach (var group in _groups.Values) group.Dispose(); _groups.Clear(); } @@ -46,28 +58,43 @@ public sealed class EditorGroupProjection : IDisposable public ObservableCollection Entries { get; } private readonly HashSet _allEntries = []; private bool _synchronizing; + private readonly EntryFieldAuthoringService _authoring; + private readonly SceneTargetCatalog? _scenes; + private readonly EntryEditSession? _edits; - internal EditorGroupProjection(EditorGroupDocument document, IEntryCatalog catalog) + internal EditorGroupProjection(EditorGroupDocument document, IEntryCatalog catalog, EntryFieldAuthoringService authoring, SceneTargetCatalog? scenes, EntryEditSession? edits = null) { Document = document; + _authoring = authoring; + _scenes = scenes; _edits = edits; Entries = new(document.Entries.Select(entry => new EntryEditorItemViewModel(entry, catalog))); _allEntries.UnionWith(Entries); + foreach (var entry in Entries) Bind(entry); Entries.CollectionChanged += OnEntriesChanged; } private void OnEntriesChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs args) { if (_synchronizing) return; + if (args.OldItems is not null) + foreach (EntryEditorItemViewModel entry in args.OldItems) + if (!Entries.Contains(entry)) entry.InvalidateParameterFields(); _allEntries.UnionWith(Entries); + foreach (var entry in Entries) Bind(entry); Document.Entries.Clear(); Document.Entries.AddRange(Entries.Select(entry => entry.Source)); } public void Synchronize(IEntryCatalog catalog) { - if (Document.Entries.SequenceEqual(Entries.Select(entry => entry.Source))) return; + if (Document.Entries.SequenceEqual(Entries.Select(entry => entry.Source))) + { + foreach (var entry in Entries) entry.RefreshAuthorReferences(catalog); + return; + } var previous = Entries.ToArray(); var current = Document.Entries.ToArray(); + foreach (var entry in previous.Where(entry => !current.Contains(entry.Source))) entry.InvalidateParameterFields(); _synchronizing = true; try { @@ -76,12 +103,15 @@ public void Synchronize(IEntryCatalog catalog) { var entry = previous.FirstOrDefault(entry => ReferenceEquals(entry.Source, source)) ?? new EntryEditorItemViewModel(source, catalog); Entries.Add(entry); + Bind(entry); _allEntries.Add(entry); } } finally { _synchronizing = false; } } + private void Bind(EntryEditorItemViewModel entry) { entry.EditSession = _edits; entry.BindAuthoring(Document.Id, _authoring, _scenes); } + public void Dispose() { Entries.CollectionChanged -= OnEntriesChanged; diff --git a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs index 1dd20642..409d59a3 100644 --- a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs +++ b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs @@ -1,6 +1,6 @@ using System; -using System.Globalization; using System.Collections.ObjectModel; +using System.Globalization; using Avalonia.Threading; using Serilog.Core; using Serilog.Events; @@ -28,7 +28,8 @@ public void Emit(LogEvent logEvent) Message = logEvent.RenderMessage(CultureInfo.InvariantCulture), Exception = logEvent.Exception?.ToString(), Source = GetSource(logEvent), - Channel = GetChannel(logEvent) + Channel = GetChannel(logEvent), + Target = GetTarget(logEvent) }; Dispatcher.UIThread.Post(() => @@ -47,6 +48,12 @@ private static string GetSource(LogEvent logEvent) return string.Empty; } + private static AuthorNavigationTarget? GetTarget(LogEvent logEvent) + { + string? Read(string key) => logEvent.Properties.TryGetValue(key, out var value) && value is ScalarValue { Value: string text } ? text : null; + var node = Read("AuthorNodeId"); var file = Read("AuthorFile"); + return node is null && file is null ? null : new(node, Read("AuthorEntryId"), Read("AuthorFieldId"), file); + } private static LogChannel GetChannel(LogEvent logEvent) { @@ -68,6 +75,7 @@ public sealed class LogLine public string? Exception { get; init; } public string Source { get; init; } = ""; public LogChannel Channel { get; init; } = LogChannel.Editor; + public AuthorNavigationTarget? Target { get; init; } public bool IsError => Level >= LogEventLevel.Error; public bool IsWarning => Level == LogEventLevel.Warning; diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index 7eb7688d..7517e7fb 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -275,6 +275,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) { if (_engine is not null) return; var content = await _context.Content.LoadAsync(cancellationToken); + var imageVariants = await GalNet.Assets.Variants.ImageVariantsCatalogLoader.LoadImageVariantsCatalogAsync(_context.Assets, cancellationToken); _context.Assets.RegisterDecoder("sprite", new SceneTextureAssetDecoder()); var spriteFiles = await _context.Assets.GetFilesAsync("sprite", cancellationToken); var textures = await Task.WhenAll(spriteFiles.Select(file => _context.Assets.AcquireAsync(file.Id, cancellationToken))); @@ -297,7 +298,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) content.Gallery, _pageView.ParticlePresenter, _pageView.NvlPresenter, - _audio).BuildView(); + _audio, imageVariants: imageVariants).BuildView(); try { view.RequireInstructions(content.Graph.Nodes.OfType().SelectMany(group => group.Entries).Select(entry => entry.Type)); } catch { view.Dispose(); throw; } var runtime = new GameRuntime(null, content.Graph.RootNodeId, new SettingsContainer(), _context.Variables); diff --git a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs index 25ab5f2e..be904cb2 100644 --- a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs +++ b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs @@ -19,6 +19,8 @@ public sealed class EditorViewFactory : IEditorViewFactory [typeof(EditorWorkspaceViewModel)] = typeof(NodeGraphPanelView), [typeof(InspectorHostViewModel)] = typeof(InspectorHostView), [typeof(GroupEditorPanelViewModel)] = typeof(GroupEditorPanelView), + [typeof(TimelinePanelViewModel)] = typeof(TimelinePanelView), + [typeof(FileDocumentPanelViewModel)] = typeof(FileDocumentPanelView), [typeof(NewProjectPanelViewModel)] = typeof(NewProjectPanelView), [typeof(GamePreviewPanelViewModel)] = typeof(GamePreviewPanelView), [typeof(LogPanelViewModel)] = typeof(LogPanelView) diff --git a/src/Editor/GalNet.Editor/Services/EntryEditSession.cs b/src/Editor/GalNet.Editor/Services/EntryEditSession.cs new file mode 100644 index 00000000..0f46113c --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/EntryEditSession.cs @@ -0,0 +1,144 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Parameters; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Models.Graph; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +/// Project-owned drafts. Closing a view never releases unsubmitted author input. +public sealed class EntryEditSession : IDisposable +{ + private readonly Dictionary _drafts = new(StringComparer.Ordinal); + private readonly IDisposable _registration; + private readonly IProjectContentSession _content; + private readonly GameTextAuthoringService _texts; + private readonly IEditorLocalizationService _localization; + public EntryEditSession(IProjectContentSession content, GameTextAuthoringService texts, IEditorLocalizationService localization) + { + _content = content; _texts = texts; _localization = localization; + _registration = content.RegisterDraft(() => PendingCount > 0, CommitAsync); + content.Changed += CheckSources; + } + public event Action? Changed; + public int PendingCount => _drafts.Values.Count(draft => draft.State.IsDirty || !draft.State.CanCommit); + public IEnumerable Pending => _drafts.Values.Where(draft => draft.State.IsDirty || !draft.State.CanCommit); + + public FieldDraftState Get(EntryEditorItemViewModel owner, FieldDescriptor field) => Get(owner, field, field, []); + private FieldDraftState Get(EntryEditorItemViewModel owner, FieldDescriptor root, FieldDescriptor field, string[] path) + { + var key = owner.AuthorGroupId + "/" + owner.StableId + "/" + root.Id + "/" + string.Join("/", path); + // The author object is a generation boundary: undo may restore the same ID with a new source. + key += "/" + owner.Type + "/" + System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(owner.Source); + if (_drafts.TryGetValue(key, out var found)) return found.State; + var type = owner.Type; + string Snapshot() + { + if (!owner.IsCurrent || owner.Source.Type != type) return ""; + var value = owner.Source.Parameters.GetValueOrDefault(root.Id); + return value.GetRawTextSafe() + (root.Semantic == FieldValueSemantic.LocalizedText && value.ValueKind == JsonValueKind.String ? "\n" + _texts.Read(value.GetString()!) : ""); + } + var state = new FieldDraftState(Snapshot); + var draft = new Draft(owner, root, field, path, type, state); + _drafts.Add(key, draft); + state.Child = (child, segment) => Get(owner, root, child, [.. path, segment]); + state.Commit = () => CommitAsync(draft); + state.Changed += OnChanged; + return state; + } + private void OnChanged() { _content.Refresh(); Changed?.Invoke(); } + private void CheckSources() + { + foreach (var draft in _drafts.Values) draft.State.CheckSource(); + } + private ValueIssue Error(string code) => new(code, _localization["Uiux.Draft." + code]); + private async Task CommitAsync(Draft draft) + { + var state = draft.State; + if (!state.CanCommit) return Error("Composing"); + if (!state.IsDirty) return null; + if (state.IsConflicted || !draft.Owner.IsCurrent || draft.Owner.Type != draft.Type) return Error("Conflict"); + try + { + FieldValue candidate; + GameTextDraft? textDraft = null; + try + { + if (draft.Field.Semantic == FieldValueSemantic.LocalizedText) + { + var key = draft.Owner.Source.Parameters.GetValueOrDefault(draft.Root.Id); + textDraft = _texts.Register(_texts.Capture(key.ValueKind == JsonValueKind.String ? key.GetString()! : "", draft.Owner.StableId), state.Text); + candidate = new(JsonSerializer.SerializeToElement(textDraft.Token)); + } + else candidate = !state.JsonText && state.Text.Length == 0 && draft.Field.Value.ValueType != typeof(string) + && (!draft.Field.IsRequired || draft.Field.DefaultValue is not null) ? default + : new(state.JsonText ? JsonSerializer.Deserialize(state.Text) : ValueCodec.ParseEditorText(draft.Field.Value, state.Text)); + if (candidate.Json is { } value && ValueValidation.Validate(draft.Field.Value, value) is { } invalid) return invalid; + if (!candidate.IsProvided && draft.Field.IsRequired && draft.Field.DefaultValue is null) return Error("Invalid"); + if (draft.Path.Length > 0) + { + var source = draft.Owner.Source.Parameters.GetValueOrDefault(draft.Root.Id); + var encoded = source.ValueKind == JsonValueKind.String; + var root = source.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null ? new JsonObject() + : JsonNode.Parse(encoded ? source.GetString()! : source.GetRawText())!; + JsonNode node = root; + foreach (var segment in draft.Path[..^1]) + { + var child = Child(node, segment); + if (child is null && node is JsonObject obj) { child = new JsonObject(); obj[Wire(obj, segment)] = child; } + node = child ?? throw new InvalidDataException("The draft's object or list target is no longer available."); + } + var last = draft.Path[^1]; + var replacement = candidate.Json is { } json ? JsonNode.Parse(json.GetRawText()) : null; + if (node is JsonArray array) array[int.Parse(last, System.Globalization.CultureInfo.InvariantCulture)] = replacement; + else if (candidate.IsProvided) node[Wire(node, last)] = replacement; + else ((JsonObject)node).Remove(Wire(node, last)); + candidate = new(encoded ? JsonSerializer.SerializeToElement(root.ToJsonString()) : JsonSerializer.SerializeToElement(root)); + } + var result = await draft.Owner.CommitParameterAsync(draft.Root, candidate, CancellationToken.None); + if (result is null) state.Clear(); + return result; + } + finally { if (textDraft is not null) _texts.Release(textDraft); } + } + catch (Exception exception) when (exception is JsonException or ArgumentException or InvalidOperationException or InvalidDataException) + { return new("field.draft", exception.Message); } + } + private static string Wire(JsonNode node, string name) + { + if (node is not JsonObject obj) return name; + var matches = obj.Select(pair => pair.Key).Where(key => key.Equals(name, StringComparison.OrdinalIgnoreCase)).ToArray(); + return matches.Length > 1 ? throw new InvalidDataException("The draft's property name is ambiguous.") : matches.SingleOrDefault() ?? name; + } + private static JsonNode? Child(JsonNode node, string name) => node is JsonArray array ? array[int.Parse(name, System.Globalization.CultureInfo.InvariantCulture)] : node[Wire(node, name)]; + public async Task CommitAsync() + { + var failures = new List(); + foreach (var draft in Pending.ToArray()) + if (await CommitAsync(draft) is { } issue) + { + draft.State.Update(draft.State.Text, draft.State.IsDirty, issue, draft.State.JsonText); + failures.Add($"{draft.Owner.AuthorGroupId} / {draft.Owner.Id} / {draft.Root.Id}: {issue.Message}"); + } + if (failures.Count > 0) throw new InvalidDataException(string.Join(Environment.NewLine, failures)); + } + public void Dispose() + { + _registration.Dispose(); + _content.Changed -= CheckSources; + foreach (var draft in _drafts.Values) draft.State.Changed -= OnChanged; + _drafts.Clear(); Changed = null; + } + public sealed record Draft(EntryEditorItemViewModel Owner, FieldDescriptor Root, FieldDescriptor Field, string[] Path, string Type, FieldDraftState State); + internal string Summary(EntryEditorItemViewModel owner) => string.Join(" · ", owner.Source.Parameters + .Where(pair => pair.Key is "content" or "speaker" or "asset" or "assetId" or "target" or "expression" or "handleId") + .Select(pair => pair.Key == "content" && pair.Value.ValueKind == JsonValueKind.String ? _texts.Read(pair.Value.GetString()!) ?? pair.Value.GetString() + : pair.Value.ValueKind == JsonValueKind.String ? pair.Value.GetString() : pair.Value.GetRawText()) + .Where(value => !string.IsNullOrWhiteSpace(value))).Replace('\n', ' '); +} + +internal static class DraftJson +{ + public static string GetRawTextSafe(this JsonElement json) => json.ValueKind == JsonValueKind.Undefined ? "" : json.GetRawText(); +} diff --git a/src/Editor/GalNet.Editor/Services/EntryFieldAuthoringService.cs b/src/Editor/GalNet.Editor/Services/EntryFieldAuthoringService.cs new file mode 100644 index 00000000..06227784 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/EntryFieldAuthoringService.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; + +namespace GalNet.Editor.Services; + +/// Entry field intents retain author values and stable IDs, never a page or control. +public sealed class EntryFieldAuthoringService(IEditorDocumentService documents, EditorHistories histories, CharacterAuthoringService? characters = null, GameTextAuthoringService? texts = null) +{ + public event Action? Changed; + internal bool IsCurrent(string groupId, EditorEntryData expected) => documents.IsLoaded + && documents.CurrentDocument.Nodes.Any(node => node.Id == groupId && node.Type == "Group") + && documents.Groups.TryGetValue(groupId, out var group) && group.Entries.Any(entry => ReferenceEquals(entry, expected)); + + public void SetCondition(string groupId, EditorEntryData expected, string value) => Update(groupId, expected, expected.Type, + new(expected.Type, value, Clone(expected.Parameters)), "Edit Condition"); + + public void SetParameters(string groupId, EditorEntryData expected, string type, Dictionary parameters) => + Update(groupId, expected, type, new(type, expected.Condition, Clone(parameters)), "Edit Parameters"); + + public async Task SetParametersAsync(string groupId, EditorEntryData expected, string type, Dictionary parameters, CancellationToken token) + { + if (!IsCurrent(groupId, expected) || expected.Type != type) throw Obsolete(); + if (characters is not null && await characters.CommitParametersAsync(groupId, expected, type, parameters, () => IsCurrent(groupId, expected), token)) return; + if (texts is not null && await texts.CommitEntryAsync(groupId, expected, type, parameters, () => IsCurrent(groupId, expected), token)) return; + token.ThrowIfCancellationRequested(); + SetParameters(groupId, expected, type, parameters); + } + + public void SetType(string groupId, EditorEntryData expected, string type, Dictionary parameters) => + Update(groupId, expected, expected.Type, new(type, expected.Condition, GalNet.Editor.Shared.Services.SceneAuthoringIdentities.NewEntry(type, Clone(parameters))), "Edit Type"); + + private void Update(string groupId, EditorEntryData expected, string expectedType, EntryValue after, string description) + { + var entry = Find(groupId, expected.StableId); + if (!ReferenceEquals(entry, expected) || entry.Type != expectedType) throw Obsolete(); + var before = Capture(entry); + if (!Equal(before, after)) histories.Content.Execute(new FieldEdit(this, groupId, entry.StableId, before, after, description)); + } + + private EditorEntryData Find(string groupId, string id) + { + if (!documents.IsLoaded || !documents.CurrentDocument.Nodes.Any(node => node.Id == groupId && node.Type == "Group") + || !documents.Groups.TryGetValue(groupId, out var group)) throw Obsolete(); + return group.Entries.SingleOrDefault(entry => entry.StableId == id) ?? throw Obsolete(); + } + + private sealed record EntryValue(string Type, string Condition, Dictionary Parameters); + private static Dictionary Clone(IReadOnlyDictionary values) => + values.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + private static EntryValue Capture(EditorEntryData entry) => new(entry.Type, entry.Condition, Clone(entry.Parameters)); + private static bool Equal(EntryValue left, EntryValue right) => left.Type == right.Type && left.Condition == right.Condition + && left.Parameters.Count == right.Parameters.Count && left.Parameters.All(pair => right.Parameters.TryGetValue(pair.Key, out var value) && JsonElement.DeepEquals(pair.Value, value)); + private static void Write(EditorEntryData entry, EntryValue value) + { entry.Type = value.Type; entry.Condition = value.Condition; entry.Parameters = Clone(value.Parameters); } + private static EditorOperationException Obsolete() => new("entry.obsolete", "The entry or its schema is no longer current."); + + private sealed record FieldEdit(EntryFieldAuthoringService Owner, string GroupId, string EntryId, EntryValue Before, EntryValue After, string Description) : IUndoableEdit + { + public EditorText DisplayText => new(Description switch + { + "Edit Type" => "History.EntryType", + "Edit Condition" => "History.EntryCondition", + _ => "History.EntryParameters" + }); + public long RetainedBytes => JsonSerializer.SerializeToUtf8Bytes(Before).LongLength + JsonSerializer.SerializeToUtf8Bytes(After).LongLength; + public void Undo() => Apply(After, Before); + public void Redo() => Apply(Before, After); + private void Apply(EntryValue expected, EntryValue value) + { + var entry = Owner.Find(GroupId, EntryId); + if (!Equal(Capture(entry), expected)) throw new EditorOperationException("entry.changed", "The entry changed outside this edit."); + try { Write(entry, value); Owner.Changed?.Invoke(GroupId); } + catch (Exception failure) + { + try { Write(entry, expected); Owner.Changed?.Invoke(GroupId); } + catch (Exception compensation) { throw new HistoryRepairRequiredException("Entry field compensation failed.", new AggregateException(failure, compensation)); } + throw; + } + } + } +} diff --git a/src/Editor/GalNet.Editor/Services/EntrySelectionContext.cs b/src/Editor/GalNet.Editor/Services/EntrySelectionContext.cs new file mode 100644 index 00000000..019b6761 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/EntrySelectionContext.cs @@ -0,0 +1,21 @@ +namespace GalNet.Editor.Services; + +public sealed record ActiveEntrySelection(System.Guid Owner, string GroupId, string? EntryId); + +/// Project-owned active item; focusing a tool does not change the author selection. +public sealed class EntrySelectionContext +{ + public ActiveEntrySelection? Current { get; private set; } + public event System.Action? Changed; + public void Select(System.Guid owner, string groupId, string? entryId) + { + var next = new ActiveEntrySelection(owner, groupId, entryId); + if (Current == next) return; + Current = next; Changed?.Invoke(); + } + public void Clear(System.Guid owner) + { + if (Current?.Owner != owner) return; + Current = null; Changed?.Invoke(); + } +} diff --git a/src/Editor/GalNet.Editor/Services/FileDialogService.cs b/src/Editor/GalNet.Editor/Services/FileDialogService.cs index 28a15e7d..23082b21 100644 --- a/src/Editor/GalNet.Editor/Services/FileDialogService.cs +++ b/src/Editor/GalNet.Editor/Services/FileDialogService.cs @@ -30,4 +30,11 @@ public async Task> OpenFilePickerAsync(string title, bool var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { Title = title, AllowMultiple = allowMultiple }); return files.Select(x => x.Path.LocalPath).ToList(); } + public async Task SaveFilePickerAsync(string title, string suggestedName) + { + var owner = GetOwner(); + if (owner?.StorageProvider is null) return null; + var file = await owner.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions { Title = title, SuggestedFileName = suggestedName, DefaultExtension = "json" }); + return file?.Path.LocalPath; + } } diff --git a/src/Editor/GalNet.Editor/Services/GameLocalizationPanelService.cs b/src/Editor/GalNet.Editor/Services/GameLocalizationPanelService.cs new file mode 100644 index 00000000..85e30b55 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/GameLocalizationPanelService.cs @@ -0,0 +1,180 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using GalNet.Core.I18n; +using GalNet.Core.Parameters; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GalNet.Editor.Services; + +public sealed record LocalizationCategory(string Id, string Name); +public sealed record LocalizationTableRow(string Key, string Context, IReadOnlyList References); +public sealed record LocalizationTableSnapshot(string Source, IReadOnlyList Languages, + IReadOnlyList Categories, IReadOnlyList Rows, LocalizationReferenceIndex Index, byte[] Metadata); +public sealed record LocalizationCellDraft(string Category, LocalizationTextTarget Target, string SourceLocale, string? SourceText); +public sealed record DevelopmentLanguagePreview(string Before, string Target, int Missing, string Evidence); + +/// Projects the existing author documents. Cell values and language order share project history. +public sealed class GameLocalizationPanelService(IProjectContentSession content, IProjectService projects, + LocalizationDocumentStore store, ProjectLocalizationQuery query, GameTextAuthoringService gameTexts, + LocalizationAuthoringService authoring, EditorHistories histories, IEditorLocalizationService text) +{ + public IEditorLocalizationService Text => text; + public IProjectContentSession Content => content; + public void Navigate(LocalizationTableRow row) + { + Active(); + if (row.References.FirstOrDefault() is { } reference) + projects.Current!.Services.GetRequiredService().NavigateLocalization(reference.Owner, reference.EntryId); + } + private string Source => content.CaptureWorking().Settings.TargetLocale.Code; + public IReadOnlyList Languages => new[] { Source }.Concat(content.CaptureWorking().Settings.AvailableLocales.Select(locale => locale.Code)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + private void Active() { _ = store.CaptureFile(LocalizationJson.MetadataPath); } + private static string Normalize(string value) + { + try + { + var culture = CultureInfo.GetCultureInfo(value.Trim().Replace('_', '-')).Name; + if (culture.Length > 0) return culture; + } + catch (CultureNotFoundException) { } + throw new EditorOperationException("i18n.language", "Invalid language."); + } + private IReadOnlyList References(LocalizationReferenceIndex index, string category) + { + var branches = category == "branches" ? content.CaptureWorking().Graph.Nodes.Where(node => node.Type == "Branch").Select(node => node.Id).ToHashSet(StringComparer.Ordinal) : []; + return index.References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText && + (category == "branches" ? branches.Contains(reference.Owner) : reference.Owner == category)).ToArray(); + } + public LocalizationTableSnapshot Snapshot(string category) + { + Active(); var document = content.CaptureWorking(); var index = query.Get()!; + var categories = new List { new("characters", text["I18n.Characters"]) }; + categories.AddRange(document.Graph.Nodes.Where(node => node.Type == "Group").Select(node => new LocalizationCategory(node.Id, node.Name))); + categories.Add(new("branches", text["I18n.Branches"])); + var contexts = new Dictionary<(string Owner, string? Entry), string>(); + if (category == "characters") + foreach (var (character, position) in index.Characters.Select((character, position) => (character, position))) + contexts.TryAdd(("characters", character.Id), text.Format("I18n.CharacterRow", position + 1)); + else if (category == "branches") + foreach (var node in document.Graph.Nodes.Where(node => node.Type == "Branch" && node.BranchType == "Choice")) + foreach (var (option, position) in (node.Options ?? []).Select((option, position) => (option, position))) + contexts.TryAdd((node.Id, option.Id), node.Name + " · " + text.Format("I18n.OptionRow", position + 1)); + else if (document.GroupEntries.TryGetValue(category, out var entries)) + foreach (var (entry, position) in entries.Select((entry, position) => (entry, position))) + contexts.TryAdd((category, entry.StableId), text.Format("I18n.EntryRow", position + 1, "")); + var rows = References(index, category).GroupBy(reference => reference.Key, StringComparer.Ordinal).Select(group => + { + var refs = group.ToArray(); var first = refs[0]; + var context = contexts.GetValueOrDefault((first.Owner, first.EntryId), ""); + if (category is not ("characters" or "branches")) context += first.JsonPointer; + if (refs.Length > 1) context += " · " + text.Format("GameText.Shared", refs.Length); + return new LocalizationTableRow(group.Key, context, refs); + }).ToArray(); + return new(Source, Languages, categories, rows, index, store.CaptureFile(LocalizationJson.MetadataPath).Data); + } + private void Current(LocalizationCellDraft draft) + { + Active(); var index = query.Get()!; + if (Source != draft.SourceLocale || !Languages.Contains(draft.Target.Culture, StringComparer.OrdinalIgnoreCase) || + !References(index, draft.Category).Any(reference => reference.Key == draft.Target.Key) || + index.FindDirect(Source, draft.Target.Key)?.Text != draft.SourceText) + throw new EditorOperationException("i18n.stale", text["I18n.Stale"]); + } + public LocalizationCellDraft Capture(string category, string key, string culture) + { + var draft = new LocalizationCellDraft(category, store.CaptureText(key, culture), Source, query.Get()!.FindDirect(Source, key)?.Text); + Current(draft); return draft; + } + public async Task SetAsync(LocalizationCellDraft draft, string value) + { + Current(draft); + var metadata = store.CaptureFile(LocalizationJson.MetadataPath); + foreach (var file in new[] { draft.Target.File, metadata }.Where(file => file.Exists).DistinctBy(file => file.Id)) + { await content.CommitFileDraftsAsync(file.Id); Current(draft); } + if (draft.Target.Culture == Source) + { + var patches = await gameTexts.PrepareTextAsync(draft.Target, value, [], () => Current(draft)); + Current(draft); await ApplyCellAsync(draft, patches.ToArray()); return; + } + var target = draft.Target; + // Empty saved baselines remain safe after undo/save/redo, just as normal source authoring does. + var missing = new[] { target.File, metadata }.Where(file => !file.Exists).DistinctBy(file => file.Path).ToArray(); + if (missing.Length > 0) + { await store.Prepare(missing.Select(file => new LocalizationFilePatch(file, file.Data)).ToArray()).ApplyAsync(true); Current(draft); } + if (!target.File.Exists) target = target with { File = store.CaptureFile(target.File.Path) }; + var translation = store.PrepareText(target, value); + var meta = store.CaptureFile(LocalizationJson.MetadataPath); var root = JsonNode.Parse(LocalizationJson.Decode(meta.Data))!; + root["textStates"] ??= new JsonObject(); + var states = root["textStates"]!; + states[target.Key] ??= new JsonObject { ["sourceLocale"] = Source, ["translations"] = new JsonObject() }; + var state = states[target.Key]!; + if (state["sourceLocale"]?.GetValue() != Source) state["translations"] = new JsonObject(); + state["sourceLocale"] = Source; + if (draft.SourceText is { } source) state["sourceFingerprint"] = LocalizationTextState.Fingerprint(source); + else state["sourceFingerprint"] ??= LocalizationTextState.Fingerprint(""); + state["translations"]![target.Culture] = draft.SourceText is { } original ? JsonValue.Create(LocalizationTextState.Fingerprint(original)) : null; + Current(draft); + await ApplyCellAsync(draft, [translation, new(meta, LocalizationJson.WriteLike(meta.Data, root))]); + } + private Task ApplyCellAsync(LocalizationCellDraft draft, LocalizationFilePatch[] patches) => histories.Content.ExecuteAsync( + new GuardedCellEdit(authoring.CreateEdit(store.Prepare(patches), draft.Target.Culture, "History.GameText"), () => Current(draft))); + private sealed record GuardedCellEdit(IAsyncUndoableEdit Edit, Action Check) : IAsyncUndoableEdit + { + public string Description => Edit.Description; + public EditorText DisplayText => Edit.DisplayText; + public long RetainedBytes => Edit.RetainedBytes; + public ValueTask RedoAsync() { Check(); return Edit.RedoAsync(); } + public ValueTask UndoAsync() => Edit.UndoAsync(); + } + public Task AddLanguageAsync(string value) + { + Active(); var code = Normalize(value); + if (Languages.Contains(code, StringComparer.OrdinalIgnoreCase)) throw new EditorOperationException("i18n.duplicate", text["I18n.Duplicate"]); + return SetLanguagesAsync(Source, Languages.Append(code).ToArray()); + } + public Task MoveLanguageAsync(string code, int direction) + { + Active(); var languages = Languages.ToArray(); var position = Array.IndexOf(languages, code); var next = position + Math.Sign(direction); + if (position <= 0 || next <= 0 || next >= languages.Length) return Task.CompletedTask; + (languages[position], languages[next]) = (languages[next], languages[position]); + return SetLanguagesAsync(Source, languages); + } + public DevelopmentLanguagePreview Preview(string value) + { + Active(); var target = Normalize(value); + if (!Languages.Contains(target, StringComparer.OrdinalIgnoreCase)) throw new EditorOperationException("i18n.language", text["I18n.AddFirst"]); + var index = query.Get()!; var keys = index.References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText).Select(reference => reference.Key).Distinct(StringComparer.Ordinal).ToArray(); + return new(Source, target, keys.Count(key => index.FindDirect(target, key) is null), + string.Join("|", keys.Order(StringComparer.Ordinal).Select(key => key.Length + ":" + key + ":" + (index.FindDirect(target, key)?.Text is { } value ? LocalizationTextState.Fingerprint(value) : "missing")))); + } + public Task ChangeDevelopmentLanguageAsync(DevelopmentLanguagePreview preview) + { + void CurrentPreview() { if (Preview(preview.Target) != preview) throw new EditorOperationException("i18n.stale", text["I18n.Stale"]); } + CurrentPreview(); + return SetLanguagesAsync(preview.Target, new[] { preview.Target }.Concat(Languages).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), CurrentPreview); + } + private Task SetLanguagesAsync(string source, IReadOnlyList languages, Action? validate = null) + { + Active(); var settings = projects.Current!.Settings; + return histories.Content.ExecuteAsync(new LanguageEdit(settings, settings.TargetLocale.Code, settings.AvailableLocales.Select(locale => locale.Code).ToArray(), source, languages.ToArray(), Active, validate)); + } + private sealed record LanguageEdit(GalNet.Core.Settings.ProjectSettings Settings, string BeforeSource, string[] Before, string AfterSource, string[] After, Action Active, Action? Validate) : IUndoableEdit + { + public string Description => "Change project languages"; + public EditorText DisplayText => new("History.Languages"); + public long RetainedBytes => (Before.Sum(value => value.Length) + After.Sum(value => value.Length)) * 2L; + public void Undo() => Apply(AfterSource, After, BeforeSource, Before); + public void Redo() { Validate?.Invoke(); Apply(BeforeSource, Before, AfterSource, After); } + private void Apply(string expectedSource, string[] expected, string source, string[] value) + { + Active(); + if (Settings.TargetLocale.Code != expectedSource || !Settings.AvailableLocales.Select(locale => locale.Code).SequenceEqual(expected)) + throw new EditorOperationException("i18n.stale", "Project languages changed."); + Settings.TargetLocale = new I18nLocale(source); Settings.AvailableLocales = value.Select(code => new I18nLocale(code)).ToList(); + } + } +} diff --git a/src/Editor/GalNet.Editor/Services/GameTextAuthoringService.cs b/src/Editor/GalNet.Editor/Services/GameTextAuthoringService.cs new file mode 100644 index 00000000..18f7dd25 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/GameTextAuthoringService.cs @@ -0,0 +1,264 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Entry; +using GalNet.Core.Parameters; +using GalNet.Core.Primitives; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Shared.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +public sealed record GameTextDraft(string Token, string Key, LocalizationTextTarget Target, string Text, + IReadOnlyDictionary? Translations = null, JsonElement? State = null, bool WriteSource = true); +public sealed record GameTextClipboard(Dictionary> Texts, Dictionary States); +public sealed record GameTextChange(Dictionary Parameters, IReadOnlyList Files); + +/// Semantic text intents are resolved only at the document commit boundary. +public sealed class GameTextAuthoringService(LocalizationDocumentStore store, ProjectLocalizationQuery query, + IProjectContentSession content, IEditorDocumentService documents, IEntryCatalog catalog, + LocalizationAuthoringService authoring, IEditorLocalizationService text) +{ + private readonly Dictionary _drafts = new(StringComparer.Ordinal); + public IEditorLocalizationService Text => text; + public event Action? Changed { add => content.Changed += value; remove => content.Changed -= value; } + public string Culture => content.CaptureWorking().Settings.TargetLocale.Code; + public string? Read(string key) => string.IsNullOrEmpty(key) ? null : query.Get()?.FindDirect(Culture, key)?.Text; + public int Uses(string key) => query.Get()?.References.Count(reference => reference.Semantic == FieldValueSemantic.LocalizedText && reference.Key == key) ?? 0; + public LocalizationTextTarget Capture(string key, string owner) => store.CaptureText(key.Length == 0 ? NewKey(owner) : key, newFile: $"I18n/{Culture}/authoring.json"); + private readonly HashSet _reservedKeys = new(StringComparer.Ordinal); + private string NewKey(string owner, LocalizationReferenceIndex? snapshot = null) + { + var index = snapshot ?? query.Get() ?? throw new ObjectDisposedException(nameof(GameTextAuthoringService)); + string key; + do { key = "text_" + owner + "_" + Guid.NewGuid().ToString("N"); } + while (_reservedKeys.Contains(key) || index.Texts.Any(value => value.Key == key) || index.References.Any(value => value.Key == key)); + _reservedKeys.Add(key); return key; + } + public GameTextDraft Register(LocalizationTextTarget target, string value) + { + var draft = new GameTextDraft("edit_" + Guid.NewGuid().ToString("N"), target.Key, target, value); + _drafts.Add(draft.Token, draft); return draft; + } + public void Release(GameTextDraft draft) => _drafts.Remove(draft.Token); + public Task> PrepareTextAsync(LocalizationTextTarget target, string value, IReadOnlyList existing, Action current) => + PrepareFilesAsync([new("source", target.Key, target, value)], existing, current); + public IReadOnlyList References(string group, EditorEntryData entry) => new LocalizationReferenceIndex( + new() { GroupEntries = new() { [group] = [entry] } }, catalog, []).References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText).ToArray(); + + public async Task PrepareEntryAsync(string group, EditorEntryData entry, Dictionary parameters, + IReadOnlyList existing, Action current) + { + var candidate = new EditorEntryData { StableId = entry.StableId, Type = entry.Type, Parameters = parameters }; + var references = References(group, candidate).Where(reference => _drafts.ContainsKey(reference.Key)).ToArray(); + if (references.Length == 0) return null; + var drafts = references.Select(reference => _drafts[reference.Key]).DistinctBy(draft => draft.Token).ToArray(); + var files = await PrepareFilesAsync(drafts, existing, current); + var root = JsonSerializer.SerializeToNode(parameters)!; + foreach (var reference in references) Set(root, reference.JsonPointer, _drafts[reference.Key].Key); + return new(root.Deserialize>()!, files); + } + public async Task CommitEntryAsync(string group, EditorEntryData entry, string type, Dictionary parameters, Func isCurrent, CancellationToken token) + { + var before = entry.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + void Current() + { + token.ThrowIfCancellationRequested(); + if (!isCurrent() || entry.Type != type || !LocalizationEditOperation.Same(entry.Parameters, before)) throw new EditorOperationException("entry.obsolete", "The text field changed before submission."); + } + Current(); var change = await PrepareEntryAsync(group, entry, parameters, [], Current); + if (change is null) return false; + Current(); catalog.Get(type).DynamicParameters?.ValidatePartialArguments(JsonSerializer.SerializeToElement(change.Parameters.Where(pair => catalog.Get(type).Parameters.ContainsKey(pair.Key) && + (!before.TryGetValue(pair.Key, out var previous) || !JsonElement.DeepEquals(previous, pair.Value))).ToDictionary(pair => pair.Key, pair => pair.Value))); + if (change.Files.Count == 0 && LocalizationEditOperation.Same(before, change.Parameters)) return true; + await authoring.ApplyAsync(store.PrepareWithEntries(change.Files, [new(group, entry.StableId, type, before, change.Parameters)]), entry.StableId, "History.GameText"); + return true; + } + + private async Task> PrepareFilesAsync(IEnumerable values, IReadOnlyList existing, Action current) + { + var admittedCulture = Culture; var admittedCurrent = current; + current = () => { admittedCurrent(); if (Culture != admittedCulture) throw new EditorOperationException("text.localeChanged", text["Error.text.localeChanged"]); }; + var drafts = values.ToArray(); current(); + if (drafts.GroupBy(draft => draft.Key, StringComparer.Ordinal).Any(group => group.Select(draft => draft.Text).Distinct(StringComparer.Ordinal).Skip(1).Any())) + throw new EditorOperationException("text.conflict", text["Error.text.conflict"]); + if (drafts.Any(draft => draft.Target.Culture != Culture)) throw new EditorOperationException("text.localeChanged", text["Error.text.localeChanged"]); + var metadata = store.CaptureFile(LocalizationJson.MetadataPath); + var translationTargets = drafts.SelectMany(draft => (draft.Translations ?? new Dictionary()).Keys.Where(culture => culture != Culture) + .Select(culture => (draft.Token, Culture: culture, Target: store.CaptureText(draft.Key, culture)))).ToArray(); + var allFiles = drafts.Select(draft => draft.Target.File).Concat(translationTargets.Select(target => target.Target.File)).Append(metadata).ToArray(); + var missing = allFiles.Where(file => !file.Exists).DistinctBy(file => file.Path, ProjectFilePaths.Comparer).ToArray(); + foreach (var file in allFiles.Where(file => file.Exists).DistinctBy(file => file.Id)) + { await content.CommitFileDraftsAsync(file.Id); current(); } + if (missing.Length > 0) { await store.Prepare(missing.Select(file => new LocalizationFilePatch(file, file.Data)).ToArray()).ApplyAsync(true); current(); } + var patches = existing.ToDictionary(patch => patch.Before.Path, ProjectFilePaths.Comparer); + void Add(LocalizationFilePatch patch) + { + if (patches.TryGetValue(patch.Before.Path, out var old)) + { + // All intents in one submission start from the same snapshot. + if (!old.Before.Data.AsSpan().SequenceEqual(patch.Before.Data)) throw ProjectFilePaths.Error("changed", patch.Before.Path); + patches[patch.Before.Path] = patch with { Before = old.Before }; + } + else patches.Add(patch.Before.Path, patch); + } + var meta = store.CaptureFile(LocalizationJson.MetadataPath); var metaBytes = meta.Data; + foreach (var draft in drafts) + { + var target = draft.Target; + if (!target.File.Exists) target = target with { File = store.CaptureFile(target.File.Path) }; + if (draft.WriteSource) + { + var patch = store.PrepareText(target, draft.Text); + if (patches.TryGetValue(patch.Before.Path, out var accumulated)) patch = patch with { After = LocalizationJson.SetText(accumulated.After, draft.Key, draft.Text) }; + Add(patch); + } + var translated = query.Get()!.Texts.Where(item => item.Key == draft.Key && item.Culture != Culture).Select(item => item.Culture).ToArray(); + if (draft.WriteSource) metaBytes = LocalizationTextState.Update(metaBytes, draft.Key, Culture, target.Text, draft.Text, translated.Concat(draft.Translations?.Keys ?? [])); + if (draft.State is { } state && state.TryGetProperty("sourceLocale", out var sourceLocale) && sourceLocale.GetString() == Culture) + { + var metaRoot = JsonNode.Parse(LocalizationJson.Decode(metaBytes))!; metaRoot["textStates"] ??= new JsonObject(); + metaRoot["textStates"]![draft.Key] = JsonNode.Parse(state.GetRawText()); metaBytes = LocalizationJson.WriteLike(metaBytes, metaRoot); + } + foreach (var (culture, translation) in draft.Translations ?? new Dictionary()) + { + if (culture == Culture) continue; + var translatedTarget = translationTargets.Single(item => item.Token == draft.Token && item.Culture == culture).Target; + if (!translatedTarget.File.Exists) translatedTarget = translatedTarget with { File = store.CaptureFile(translatedTarget.File.Path) }; + var translatedPatch = store.PrepareText(translatedTarget, translation); + if (patches.TryGetValue(translatedPatch.Before.Path, out var previous)) translatedPatch = translatedPatch with { After = LocalizationJson.SetText(previous.After, draft.Key, translation) }; + Add(translatedPatch); + } + } + if (!meta.Data.AsSpan().SequenceEqual(metaBytes)) Add(new(meta, metaBytes)); + return patches.Values.Where(patch => !patch.Before.Data.AsSpan().SequenceEqual(patch.After)).ToArray(); + } + + private IReadOnlyList CopyReferences(IReadOnlyList entries, IReadOnlyList nodes) => + new LocalizationReferenceIndex(new() { GroupEntries = new() { ["clipboard"] = entries.ToList() }, Graph = new() { Nodes = nodes.ToList() } }, catalog, []) + .References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText).ToArray(); + public GameTextClipboard CaptureCopies(IReadOnlyList entries, IReadOnlyList? nodes = null) + { + var index = query.Get()!; var keys = CopyReferences(entries, nodes ?? []).Select(reference => reference.Key).Distinct(StringComparer.Ordinal).ToArray(); + var texts = keys.ToDictionary(key => key, key => index.Texts.Where(item => item.Key == key).Select(item => item.Culture).Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(culture => culture, culture => index.FindDirect(culture, key)!.Text, StringComparer.OrdinalIgnoreCase), StringComparer.Ordinal); + var metadata = store.CaptureFile(LocalizationJson.MetadataPath); + using var json = JsonDocument.Parse(LocalizationJson.Decode(metadata.Data)); + var states = new Dictionary(StringComparer.Ordinal); + if (json.RootElement.TryGetProperty("textStates", out var saved)) + foreach (var key in keys) if (saved.TryGetProperty(key, out var state)) states[key] = state.Clone(); + return new(texts, states); + } + public static void ValidateClipboard(GameTextClipboard value) + { + if (value?.Texts is null || value.States is null || value.Texts.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null || pair.Value.Any(item => item.Value is null))) throw new JsonException("Invalid text clipboard."); + foreach (var language in value.Texts.Values.SelectMany(values => values.Keys)) + if (System.Globalization.CultureInfo.GetCultureInfo(language).Name != language) throw new JsonException("Use canonical clipboard cultures."); + var metadata = JsonSerializer.SerializeToUtf8Bytes(new { version = 1, characterHighWater = 0, textStates = value.States }); + LocalizationJson.Validate(LocalizationJson.MetadataPath, metadata); + } + public async Task PrepareCopiesAsync(GameTextClipboard clipboard, IReadOnlyList entries, + IReadOnlyList? nodes, Action current) + { + ValidateClipboard(clipboard); var options = nodes ?? []; var references = CopyReferences(entries, options); + var sourceIndex = query.Get()!; var admittedCulture = Culture; var admittedCurrent = current; + current = () => { admittedCurrent(); if (Culture != admittedCulture) throw new EditorOperationException("text.localeChanged", text["Error.text.localeChanged"]); }; + var map = references.Select(reference => reference.Key).Distinct(StringComparer.Ordinal).ToDictionary(key => key, _ => NewKey("copy", sourceIndex), StringComparer.Ordinal); + if (map.Count == 0) return null; + if (map.Keys.Any(key => !clipboard.Texts.ContainsKey(key))) throw new JsonException("Clipboard text snapshots are incomplete."); + var targets = map.SelectMany(pair => clipboard.Texts[pair.Key].Select(value => (Old: pair.Key, Key: pair.Value, Culture: value.Key, Text: value.Value, + Target: store.CaptureText(pair.Value, value.Key)))).ToArray(); + var metadata = store.CaptureFile(LocalizationJson.MetadataPath); + foreach (var file in targets.Select(target => target.Target.File).Append(metadata).Where(file => file.Exists).DistinctBy(file => file.Id)) + { await content.CommitFileDraftsAsync(file.Id); current(); } + var missing = targets.Select(target => target.Target.File).Append(metadata).Where(file => !file.Exists).DistinctBy(file => file.Path, ProjectFilePaths.Comparer).ToArray(); + if (missing.Length > 0) { await store.Prepare(missing.Select(file => new LocalizationFilePatch(file, file.Data)).ToArray()).ApplyAsync(true); current(); } + var patches = new Dictionary(ProjectFilePaths.Comparer); + foreach (var target in targets) + { + var expected = target.Target; + if (!expected.File.Exists) expected = expected with { File = store.CaptureFile(expected.File.Path) }; + var patch = store.PrepareText(expected, target.Text); + if (patches.TryGetValue(patch.Before.Path, out var before)) patch = patch with { Before = before.Before, After = LocalizationJson.SetText(before.After, target.Key, target.Text) }; + patches[patch.Before.Path] = patch; + } + metadata = store.CaptureFile(LocalizationJson.MetadataPath); var bytes = metadata.Data; + foreach (var (old, key) in map) + { + if (clipboard.Texts[old].TryGetValue(Culture, out var source)) + bytes = LocalizationTextState.Update(bytes, key, Culture, null, source, clipboard.Texts[old].Keys); + if (clipboard.States.TryGetValue(old, out var state) && state.TryGetProperty("sourceLocale", out var locale) && locale.GetString() == Culture) + { + var root = JsonNode.Parse(LocalizationJson.Decode(bytes))!; + root["textStates"] ??= new JsonObject(); root["textStates"]![key] = JsonNode.Parse(state.GetRawText()); bytes = LocalizationJson.WriteLike(bytes, root); + } + } + if (!bytes.AsSpan().SequenceEqual(metadata.Data)) patches[metadata.Path] = new(metadata, bytes); + current(); + foreach (var entry in entries) + { + var root = JsonSerializer.SerializeToNode(entry.Parameters)!; + foreach (var reference in References("clipboard", entry)) Set(root, reference.JsonPointer, map[reference.Key]); + entry.Parameters = root.Deserialize>()!; + } + foreach (var option in options.SelectMany(node => node.Options ?? [])) if (map.TryGetValue(option.Text, out var key)) option.Text = key; + return authoring.CreateEdit(store.Prepare(patches.Values.ToArray()), "copy", "History.GameText"); + } + + public FieldCopy StageCopy(ValueDescriptor descriptor, JsonElement value) + { + var fields = new DynamicParameterTable([new(new FieldDescriptor("value", descriptor))]); + var copyCatalog = new TargetProfileEntryCatalog([new DefaultEntryModule("editor", [new DefaultPrimitiveEntryBase("editor.copy", fields)])]); + var entry = new EditorEntryData { StableId = "copy", Type = "editor.copy", Parameters = new() { ["value"] = value.Clone() } }; + var references = new LocalizationReferenceIndex(new() { GroupEntries = new() { ["copy"] = [entry] } }, copyCatalog, []) + .References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText).ToArray(); + var root = JsonSerializer.SerializeToNode(entry.Parameters)!; var drafts = new List(); var index = query.Get()!; + foreach (var group in references.GroupBy(reference => reference.Key, StringComparer.Ordinal)) + { + var key = NewKey("item", index); + var translations = index.Texts.Where(item => item.Key == group.Key).Select(item => item.Culture).Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(culture => culture, culture => index.FindDirect(culture, group.Key)!.Text, StringComparer.OrdinalIgnoreCase); + using var metadata = JsonDocument.Parse(LocalizationJson.Decode(store.CaptureFile(LocalizationJson.MetadataPath).Data)); + JsonElement? state = metadata.RootElement.TryGetProperty("textStates", out var states) && states.TryGetProperty(group.Key, out var existing) ? existing.Clone() : null; + var target = store.CaptureText(key); var draft = Register(target, translations.GetValueOrDefault(Culture) ?? "") with { Translations = translations, State = state, WriteSource = translations.ContainsKey(Culture) }; + _drafts[draft.Token] = draft; drafts.Add(draft); + foreach (var reference in group) Set(root, reference.JsonPointer, draft.Token); + } + return new(root["value"]!.Deserialize(), () => { foreach (var draft in drafts) Release(draft); }); + } + + public IFieldBinding BranchBinding(string nodeId, EditorGraphBranchOptionDto option) => new OptionBinding(this, nodeId, option); + private bool CurrentOption(string node, EditorGraphBranchOptionDto option) => documents.IsLoaded && documents.CurrentDocument.Nodes.Any(item => item.Id == node && item.Options?.Any(value => ReferenceEquals(value, option)) == true); + private async Task CommitOptionAsync(string node, EditorGraphBranchOptionDto option, FieldValue value, CancellationToken token) + { + var before = option.Text; + void Current() { token.ThrowIfCancellationRequested(); if (!CurrentOption(node, option) || option.Text != before) throw new EditorOperationException("graph.branchChanged", "The option changed."); } + Current(); var raw = value.Json is { ValueKind: JsonValueKind.String } json ? json.GetString()! : ""; + var files = _drafts.TryGetValue(raw, out var draft) ? await PrepareFilesAsync([draft], [], Current) : []; + var after = draft?.Key ?? raw; Current(); + if (before == after && files.Count == 0) return null; + await authoring.ApplyAsync(store.PrepareWithOptions(files, [new(node, option.Id, before, after)]), option.Id, "History.GameText"); return null; + } + private sealed class OptionBinding(GameTextAuthoringService owner, string node, EditorGraphBranchOptionDto option) : IFieldBinding, IEntryFieldOrigin + { + public string? GroupId => node; + public string EntryId => option.Id; + public object HostContext => this; + public bool IsEnabled => owner.CurrentOption(node, option); + public FieldValue Read() => new(JsonSerializer.SerializeToElement(option.Text)); + public event EventHandler? Changed { add { _changed += value; if (!_subscribed) { owner.Changed += Refresh; _subscribed = true; } } remove { _changed -= value; if (_changed is null && _subscribed) { owner.Changed -= Refresh; _subscribed = false; } } } + private EventHandler? _changed; private bool _subscribed; + private void Refresh() => _changed?.Invoke(this, EventArgs.Empty); + public Task CommitAsync(FieldValue value, CancellationToken cancellationToken) => owner.CommitOptionAsync(node, option, value, cancellationToken); + } + public static void Set(JsonNode root, string pointer, string value) + { + var parts = pointer.Split('/').Skip(1).Select(part => part.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal)).ToArray(); + var parent = root; + foreach (var part in parts.SkipLast(1)) parent = parent is JsonArray array ? array[int.Parse(part, System.Globalization.CultureInfo.InvariantCulture)]! : parent[part]!; + if (parent is JsonArray list) list[int.Parse(parts[^1], System.Globalization.CultureInfo.InvariantCulture)] = value; else parent[parts[^1]] = value; + } +} diff --git a/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Branches.cs b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Branches.cs new file mode 100644 index 00000000..7c3bcbe7 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Branches.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; + +namespace GalNet.Editor.Services; + +public sealed partial class GraphAuthoringService +{ + public void AddBranchItem(string nodeId) + { + var node = Find(nodeId); + ChangeBranch(node, "History.AddBranchItem", state => + { + if (node.BranchType == "Choice") state.Options!.Add(new() { Id = Guid.NewGuid().ToString("N"), Text = "" }); + else state.Conditions!.Add(new() { Id = Guid.NewGuid().ToString("N"), Expression = "true" }); + }); + } + + public void RemoveBranchItem(string nodeId, string itemId) => ReorderBranch(nodeId, itemId, null); + public void MoveBranchItem(string nodeId, string itemId, int index) => ReorderBranch(nodeId, itemId, index); + + private void ReorderBranch(string nodeId, string itemId, int? index) + { + var node = Find(nodeId); + ChangeBranch(node, index is null ? "History.RemoveBranchItem" : "History.MoveBranchItem", state => + { + var order = state.Ids; + var oldIndex = Array.IndexOf(order, itemId); + if (oldIndex < 0 || index is < 0 || index >= order.Length) throw BranchChanged(); + var targets = state.Edges.Select((edge, index) => (edge, Index: state.EdgeIndices[index], Slot: order.ElementAtOrDefault(edge.FromOutlet))).ToArray(); + if (state.Options is { } options) { var item = options[oldIndex]; options.RemoveAt(oldIndex); if (index is { } next) options.Insert(next, item); } + else { var conditions = state.Conditions!; var item = conditions[oldIndex]; conditions.RemoveAt(oldIndex); if (index is { } next) conditions.Insert(next, item); } + var nextOrder = state.Ids; + state.Edges.Clear(); state.EdgeIndices.Clear(); + foreach (var (edge, edgeIndex, slot) in targets) + { + var outlet = Array.IndexOf(nextOrder, slot); + if (outlet < 0) continue; + edge.FromOutlet = outlet; + state.Edges.Add(edge); state.EdgeIndices.Add(edgeIndex - targets.Count(item => item.Index < edgeIndex && !nextOrder.Contains(item.Slot))); + } + }); + } + + public void SetBranchValue(string nodeId, string itemId, string field, string? value) + { + var node = Find(nodeId); + ChangeBranch(node, "History.EditBranchItem", state => + { + if (state.Options is { } options) + { + var item = options.Single(item => item.Id == itemId); + if (field == "Text") item.Text = value ?? ""; + else if (field == "Condition") item.Condition = value ?? ""; + else throw BranchChanged(); + } + else if (field == "Expression") state.Conditions!.Single(item => item.Id == itemId).Expression = value ?? ""; + else throw BranchChanged(); + }); + } + + private void ChangeBranch(EditorGraphNodeDto node, string key, Action update) + { + if (node.Type != "Branch" || node.BranchType is not ("Choice" or "Condition")) throw BranchChanged(); + var before = BranchState.Capture(node, documents.CurrentDocument); + var after = Copy(before); + update(after); + if (JsonSerializer.Serialize(before) == JsonSerializer.Serialize(after)) return; + histories.Content.Execute(new BranchEdit(documents, node.Id, node.Name, key, before, after)); + } + + private static EditorOperationException BranchChanged() => new("graph.branchChanged", "The branch or its items changed. Review the current values and retry."); + private static T Copy(T value) => JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(value))!; + + private sealed record BranchState(List? Options, List? Conditions, List Edges, List EdgeIndices) + { + [System.Text.Json.Serialization.JsonIgnore] + public string[] Ids => Options?.Select(item => item.Id).ToArray() ?? Conditions!.Select(item => item.Id).ToArray(); + public static BranchState Capture(EditorGraphNodeDto node, EditorGraphDocument graph) => Copy(new BranchState(node.Options, node.Conditions, graph.Edges.Where(edge => edge.FromNodeId == node.Id).ToList(), graph.Edges.Select((edge, index) => (edge, index)).Where(item => item.edge.FromNodeId == node.Id).Select(item => item.index).ToList())); + } + + private sealed record BranchEdit(IEditorDocumentService Source, string NodeId, string TargetName, string Key, BranchState Before, BranchState After) : IUndoableEdit + { + public string Description => "Edit branch " + NodeId; + public EditorText DisplayText => new(Key, TargetName); + public long RetainedBytes => JsonSerializer.SerializeToUtf8Bytes(new[] { Before, After }).LongLength; + public void Undo() => Apply(After, Before); + public void Redo() => Apply(Before, After); + private void Apply(BranchState expected, BranchState value) + { + var graph = Source.CurrentDocument; + var node = graph.Nodes.SingleOrDefault(node => node.Id == NodeId) ?? throw BranchChanged(); + if (JsonSerializer.Serialize(BranchState.Capture(node, graph)) != JsonSerializer.Serialize(expected)) throw BranchChanged(); + // Keep live DTO identity for text editors and locked inspectors; records retain only author data. + node.Options = value.Options?.Select(item => + { + var live = node.Options?.SingleOrDefault(option => option.Id == item.Id) ?? new(); + live.Id = item.Id; live.Text = item.Text; live.Condition = item.Condition; return live; + }).ToList(); + node.Conditions = value.Conditions?.Select(item => + { + var live = node.Conditions?.SingleOrDefault(condition => condition.Id == item.Id) ?? new(); + live.Id = item.Id; live.Expression = item.Expression; return live; + }).ToList(); + var old = graph.Edges.Where(edge => edge.FromNodeId == NodeId).ToDictionary(edge => edge.Id); + graph.Edges.RemoveAll(edge => edge.FromNodeId == NodeId); + foreach (var edge in value.Edges) + { + var live = old.GetValueOrDefault(edge.Id) ?? Copy(edge); + live.FromOutlet = edge.FromOutlet; + graph.Edges.Insert(Math.Min(value.EdgeIndices[value.Edges.IndexOf(edge)], graph.Edges.Count), live); + } + Changed(Source); + } + } +} diff --git a/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Clipboard.cs b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Clipboard.cs new file mode 100644 index 00000000..dbebc433 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.Clipboard.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +public sealed partial class GraphAuthoringService +{ + private sealed record GraphClipboard(int Version, List Nodes, List Edges, Dictionary> Groups, GameTextClipboard Texts); + + public string CopyNodes(IEnumerable nodeIds) + { + var ids = nodeIds.ToHashSet(StringComparer.Ordinal); + var graph = documents.CurrentDocument; + var nodes = graph.Nodes.Where(node => ids.Contains(node.Id) && node.Type != "Entry").ToList(); + if (nodes.Count == 0) return ""; + ids = nodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal); + var groups = documents.LoadedProject.GroupEntries.Where(pair => ids.Contains(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value); + return JsonSerializer.Serialize(new GraphClipboard(2, nodes, graph.Edges.Where(edge => ids.Contains(edge.FromNodeId) && ids.Contains(edge.ToNodeId)).ToList(), + groups, texts?.CaptureCopies(groups.Values.SelectMany(entries => entries).ToArray(), nodes) ?? new(new(), new()))); + } + + public async Task> PasteAsync(string text, double x, double y) + { + GraphClipboard clip; + try + { + if (text.Length > 16 * 1024 * 1024 || !double.IsFinite(x) || !double.IsFinite(y)) throw new JsonException(); + clip = JsonSerializer.Deserialize(text) ?? throw new JsonException(); + if (clip.Version != 2 || clip.Nodes is not { Count: > 0 and <= 1000 } || clip.Edges is null || clip.Groups is null + || clip.Nodes.Any(node => node is null || string.IsNullOrWhiteSpace(node.Id) || !double.IsFinite(node.X) || !double.IsFinite(node.Y) + || node.Type is not ("Group" or "Branch") || node.Type == "Branch" && node.BranchType is not ("Choice" or "Condition")) + || clip.Nodes.Select(node => node.Id).Distinct(StringComparer.Ordinal).Count() != clip.Nodes.Count) throw new JsonException(); + GameTextAuthoringService.ValidateClipboard(clip.Texts); + var nodes = clip.Nodes.ToDictionary(node => node.Id); + foreach (var node in clip.Nodes) + { + if (node.Type == "Group" && (!clip.Groups.TryGetValue(node.Id, out var entries) || entries is null || entries.Any(entry => entry is null || entry.Parameters is null))) throw new JsonException(); + var slots = node.BranchType == "Choice" ? node.Options?.Select(item => item.Id).ToArray() : node.BranchType == "Condition" ? node.Conditions?.Select(item => item.Id).ToArray() : []; + if (slots is null || slots.Any(string.IsNullOrWhiteSpace) || slots.Distinct(StringComparer.Ordinal).Count() != slots.Length) throw new JsonException(); + } + if (clip.Edges.Any(edge => edge is null || !nodes.ContainsKey(edge.FromNodeId) || !nodes.ContainsKey(edge.ToNodeId) || edge.FromNodeId == edge.ToNodeId + || edge.FromOutlet < 0 || edge.FromOutlet >= OutletCount(nodes[edge.FromNodeId])) + || clip.Edges.GroupBy(edge => (edge.FromNodeId, edge.FromOutlet)).Any(group => group.Count() > 1)) throw new JsonException(); + } + catch (Exception failure) when (failure is JsonException or ArgumentException or NullReferenceException) + { throw new EditorOperationException("graph.clipboard", "The clipboard does not contain a supported GalNet graph selection."); } + + var map = clip.Nodes.ToDictionary(node => node.Id, _ => Guid.NewGuid().ToString("N")); + var minX = clip.Nodes.Min(node => node.X); var minY = clip.Nodes.Min(node => node.Y); + var groups = new Dictionary>(); + // One identity map spans the complete selection, including references across Groups. + SceneAuthoringIdentities.RemapCopy(clip.Nodes.Where(node => node.Type == "Group").SelectMany(node => clip.Groups[node.Id]).ToArray()); + foreach (var node in clip.Nodes) + { + var originalId = node.Id; + node.Id = map[originalId]; + node.X = Math.Max(0, x + node.X - minX); node.Y = Math.Max(0, y + node.Y - minY); + foreach (var option in node.Options ?? []) option.Id = Guid.NewGuid().ToString("N"); + foreach (var condition in node.Conditions ?? []) condition.Id = Guid.NewGuid().ToString("N"); + if (node.Type != "Group") continue; + node.File = $"groups/{node.Id}.rawgalgroup"; + var entries = clip.Groups[originalId]; + for (var index = 0; index < entries.Count; index++) { entries[index].StableId = Guid.NewGuid().ToString("N"); entries[index].Id = index + 1; } + groups.Add(node.Id, entries); + } + foreach (var edge in clip.Edges) { edge.Id = Guid.NewGuid().ToString("N"); edge.FromNodeId = map[edge.FromNodeId]; edge.ToNodeId = map[edge.ToNodeId]; } + var bundle = new Bundle(clip.Nodes.Select((node, index) => (documents.CurrentDocument.Nodes.Count + index, node)).ToArray(), + clip.Edges.Select((edge, index) => (documents.CurrentDocument.Edges.Count + index, edge)).ToArray(), groups); + var ids = clip.Nodes.Select(node => node.Id).ToArray(); + var copiedTexts = texts is null ? null : await texts.PrepareCopiesAsync(clip.Texts, groups.Values.SelectMany(entries => entries).ToArray(), clip.Nodes, + () => { if (!documents.IsLoaded) throw new EditorOperationException("graph.targetRemoved", "The project is closed."); }); + var presence = new PresenceEdit(documents, content, ids, bundle, display: new EditorText("History.PasteNodes", ids.Length), emptyCopyBaseline: copiedTexts is not null); + if (copiedTexts is null) await histories.Content.ExecuteAsync(presence); + else await histories.Content.ExecuteAsync(new AsyncCompositeEdit("Paste nodes", [copiedTexts, presence], new EditorText("History.PasteNodes", ids.Length))); + return ids; + } + + private static int OutletCount(EditorGraphNodeDto node) => node.BranchType == "Choice" ? node.Options?.Count ?? 0 : node.BranchType == "Condition" ? node.Conditions?.Count ?? 0 : 1; +} diff --git a/src/Editor/GalNet.Editor/Services/GraphAuthoringService.cs b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.cs index 92a71c4b..74a34a38 100644 --- a/src/Editor/GalNet.Editor/Services/GraphAuthoringService.cs +++ b/src/Editor/GalNet.Editor/Services/GraphAuthoringService.cs @@ -7,9 +7,9 @@ namespace GalNet.Editor.Services; /// Project data edits. Records retain author data and IDs, never a workspace or control. -public sealed class GraphAuthoringService(IEditorDocumentService documents, EditorHistories histories, IProjectContentSession content) +public sealed partial class GraphAuthoringService(IEditorDocumentService documents, EditorHistories histories, IProjectContentSession content, GameTextAuthoringService? texts = null) { - public async Task AddNodeAsync(GraphNodeKind kind, double x, double y, string? fromId = null, int outlet = 0) + public async Task AddNodeAsync(GraphNodeKind kind, double x, double y, string? fromId = null, int outlet = 0, string? fromItemId = null) { if (kind == GraphNodeKind.Entry) throw new EditorOperationException("graph.rootExists", "The project already owns its Entry root."); if (!double.IsFinite(x) || !double.IsFinite(y)) throw new EditorOperationException("graph.coordinates", "Node coordinates must be finite."); @@ -23,13 +23,18 @@ public async Task AddNodeAsync(GraphNodeKind kind, double x, double y, s Type = kind == GraphNodeKind.LinearGroup ? "Group" : "Branch", File = kind == GraphNodeKind.LinearGroup ? $"groups/{id}.rawgalgroup" : null, BranchType = kind == GraphNodeKind.ChoiceBranch ? "Choice" : kind == GraphNodeKind.ConditionBranch ? "Condition" : null, - Options = kind == GraphNodeKind.ChoiceBranch ? [new() { Id = Guid.NewGuid().ToString("N"), Text = "Option 1" }] : null, + Options = kind == GraphNodeKind.ChoiceBranch ? [new() { Id = Guid.NewGuid().ToString("N"), Text = "" }] : null, Conditions = kind == GraphNodeKind.ConditionBranch ? [new() { Id = Guid.NewGuid().ToString("N"), Expression = "true" }] : null }; // Creation with a connection is one structural intent, including the replaced output edge. - if (fromId is not null) ValidateConnection(fromId, outlet, node); + if (fromId is not null) + { + ValidateConnection(fromId, outlet, node); + var from = Find(fromId); + fromItemId ??= from.BranchType == "Choice" ? from.Options![outlet].Id : from.BranchType == "Condition" ? from.Conditions![outlet].Id : null; + } await histories.Content.ExecuteAsync(new PresenceEdit(documents, content, [id], new Bundle([(documents.CurrentDocument.Nodes.Count, node)], [], - node.Type == "Group" ? new() { [id] = [] } : new()), fromId, outlet)); + node.Type == "Group" ? new() { [id] = [] } : new()), fromId, outlet, fromItemId: fromItemId)); return id; } @@ -79,12 +84,16 @@ public void DeleteEdge(string id) private void ValidateConnection(string fromId, int outlet, EditorGraphNodeDto to) { var from = Find(fromId); - var count = from.BranchType == "Choice" ? Math.Max(1, from.Options?.Count ?? 0) - : from.BranchType == "Condition" ? Math.Max(1, from.Conditions?.Count ?? 0) : 1; + var count = from.BranchType == "Choice" ? from.Options?.Count ?? 0 + : from.BranchType == "Condition" ? from.Conditions?.Count ?? 0 : 1; if (from.Id == to.Id || to.Type == "Entry" || outlet < 0 || outlet >= count) throw new EditorOperationException("graph.connection", "Invalid graph connection."); } + private static int ResolveOutlet(EditorGraphNodeDto? from, int outlet, string? itemId) => from is null ? -1 : itemId is null ? outlet + : from.BranchType == "Choice" ? from.Options?.FindIndex(item => item.Id == itemId) ?? -1 + : from.Conditions?.FindIndex(item => item.Id == itemId) ?? -1; + private static void Changed(IEditorDocumentService source) => source.SynchronizeGroups(); private sealed record NameEdit(IEditorDocumentService Source, string Id, string Before, string After) : IUndoableEdit { @@ -146,7 +155,7 @@ private sealed record Bundle((int Index, EditorGraphNodeDto Node)[] Nodes, (int } private sealed class PresenceEdit(IEditorDocumentService source, IProjectContentSession session, string[] ids, - Bundle? created = null, string? fromId = null, int outlet = 0) : IAsyncUndoableEdit + Bundle? created = null, string? fromId = null, int outlet = 0, EditorText? display = null, string? fromItemId = null, bool emptyCopyBaseline = false) : IAsyncUndoableEdit { private readonly bool _isCreation = created is not null; private readonly bool _writesFiles = created?.Nodes.Any(item => item.Node.Type == "Group") == true || source.CurrentDocument.Nodes.Any(node => ids.Contains(node.Id) && node.Type == "Group"); @@ -155,6 +164,7 @@ private sealed class PresenceEdit(IEditorDocumentService source, IProjectContent private (int Index, EditorGraphEdgeDto Edge)[] _replacedWorking = []; private (int Index, EditorGraphEdgeDto Edge)[] _replacedSaved = []; public string Description => !_isCreation ? "Delete nodes and Group sources" : "Create node and source"; + public EditorText DisplayText => display ?? EditorText.History(Description); public long RetainedBytes => (_working?.Bytes ?? 0) + (_saved?.Bytes ?? 0); public ValueTask RedoAsync() => SetAsync(_isCreation); public ValueTask UndoAsync() => SetAsync(!_isCreation); @@ -170,17 +180,25 @@ IProjectStructureChange Prepare(EditorProjectDocument working, EditorProjectDocu { var from = working.Graph.Nodes.SingleOrDefault(node => node.Id == fromId) ?? throw new EditorOperationException("graph.targetRemoved", "The starting node was removed before this edit."); - var count = from.BranchType == "Choice" ? Math.Max(1, from.Options?.Count ?? 0) - : from.BranchType == "Condition" ? Math.Max(1, from.Conditions?.Count ?? 0) : 1; + if (fromItemId is not null) outlet = from.BranchType == "Choice" ? from.Options!.FindIndex(item => item.Id == fromItemId) : from.Conditions!.FindIndex(item => item.Id == fromItemId); + var count = from.BranchType == "Choice" ? from.Options?.Count ?? 0 + : from.BranchType == "Condition" ? from.Conditions?.Count ?? 0 : 1; if (outlet < 0 || outlet >= count) throw new EditorOperationException("graph.targetRemoved", "The starting outlet changed before this edit."); } // Capture only after publication is available: an earlier save may advance Saved while we wait. var liveWorking = new EditorProjectDocument { Graph = source.CurrentDocument, GroupEntries = source.LoadedProject.GroupEntries }; nextWorking = present ? _working! : Bundle.Capture(liveWorking, ids); nextSaved = present ? _saved! : Bundle.Capture(saved, ids); + if (present && emptyCopyBaseline) + { + nextSaved = new(nextSaved.Nodes.Select(item => (item.Index, Copy(item.Node))).ToArray(), nextSaved.Edges.Select(item => (item.Index, Copy(item.Edge))).ToArray(), + nextSaved.Groups.ToDictionary(pair => pair.Key, pair => Copy(pair.Value))); + foreach (var group in nextSaved.Groups.Values) group.Clear(); + foreach (var option in nextSaved.Nodes.SelectMany(item => item.Node.Options ?? [])) option.Text = ""; + } replacedWorking = present && fromId is not null ? Outgoing(working) : _replacedWorking; replacedSaved = present && fromId is not null ? Outgoing(saved) : _replacedSaved; - return new PresenceChange(source.CurrentDocument, ids, nextWorking, nextSaved, present, fromId, outlet, replacedWorking, replacedSaved); + return new PresenceChange(source.CurrentDocument, ids, nextWorking, nextSaved, present, fromId, outlet, replacedWorking, replacedSaved, fromItemId); } if (_writesFiles) await session.PublishStructureAsync(Prepare); else @@ -195,11 +213,11 @@ IProjectStructureChange Prepare(EditorProjectDocument working, EditorProjectDocu else { _replacedWorking = replacedWorking; _replacedSaved = replacedSaved; } } private (int Index, EditorGraphEdgeDto Edge)[] Outgoing(EditorProjectDocument document) => - document.Graph.Edges.Select((edge, index) => (index, edge)).Where(item => item.edge.FromNodeId == fromId && item.edge.FromOutlet == outlet).ToArray(); + document.Graph.Edges.Select((edge, index) => (index, edge)).Where(item => item.edge.FromNodeId == fromId && item.edge.FromOutlet == ResolveOutlet(document.Graph.Nodes.SingleOrDefault(node => node.Id == fromId), outlet, fromItemId)).ToArray(); } private sealed record PresenceChange(EditorGraphDocument LiveGraph, string[] Ids, Bundle Working, Bundle Saved, bool Present, string? FromId, int Outlet, - (int Index, EditorGraphEdgeDto Edge)[] ReplacedWorking, (int Index, EditorGraphEdgeDto Edge)[] ReplacedSaved) : IProjectStructureChange + (int Index, EditorGraphEdgeDto Edge)[] ReplacedWorking, (int Index, EditorGraphEdgeDto Edge)[] ReplacedSaved, string? FromItemId) : IProjectStructureChange { public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) { @@ -220,10 +238,11 @@ public void Apply(EditorProjectDocument document, AuthorDocumentVersion version) foreach (var item in bundle.Nodes.OrderBy(item => item.Index)) document.Graph.Nodes.Insert(Math.Min(item.Index, document.Graph.Nodes.Count), live ? item.Node : Copy(item.Node)); foreach (var pair in bundle.Groups) document.GroupEntries.Add(pair.Key, live ? pair.Value : Copy(pair.Value)); foreach (var item in bundle.Edges.OrderBy(item => item.Index)) document.Graph.Edges.Insert(Math.Min(item.Index, document.Graph.Edges.Count), live ? item.Edge : Copy(item.Edge)); - if (FromId is not null && document.Graph.Nodes.Any(node => node.Id == FromId)) + var resolvedOutlet = ResolveOutlet(document.Graph.Nodes.SingleOrDefault(node => node.Id == FromId), Outlet, FromItemId); + if (FromId is not null && resolvedOutlet >= 0) { - document.Graph.Edges.RemoveAll(edge => edge.FromNodeId == FromId && edge.FromOutlet == Outlet); - document.Graph.Edges.Add(new() { Id = "edge-" + Ids[0], FromNodeId = FromId, FromOutlet = Outlet, ToNodeId = Ids[0] }); + document.Graph.Edges.RemoveAll(edge => edge.FromNodeId == FromId && edge.FromOutlet == resolvedOutlet); + document.Graph.Edges.Add(new() { Id = "edge-" + Ids[0], FromNodeId = FromId, FromOutlet = resolvedOutlet, ToNodeId = Ids[0] }); } } private static T Copy(T value) => JsonSerializer.Deserialize(JsonSerializer.SerializeToUtf8Bytes(value))!; diff --git a/src/Editor/GalNet.Editor/Services/GraphChangeTracker.cs b/src/Editor/GalNet.Editor/Services/GraphChangeTracker.cs deleted file mode 100644 index bfa1f733..00000000 --- a/src/Editor/GalNet.Editor/Services/GraphChangeTracker.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Collections.Specialized; -using System.ComponentModel; -using System.Linq; -using CommunityToolkit.Mvvm.ComponentModel; -using GalNet.Editor.Models.Graph; - -namespace GalNet.Editor.Services; - -/// Tracks persisted graph presentation changes without coupling them to the workspace view model. -public sealed class GraphChangeTracker : IDisposable -{ - private readonly Action _markDirty; - private readonly Func _isLoading; - private readonly Action? _itemChanged; - private readonly List _unsubscribe = []; - - public GraphChangeTracker( - Action markDirty, - Func isLoading, - Action? itemChanged = null) - { - _markDirty = markDirty; - _isLoading = isLoading; - _itemChanged = itemChanged; - } - - public void Track(GraphNode node) - { - TrackCollection(node, node.Entries, nameof(EntryEditorItemViewModel.Type), nameof(EntryEditorItemViewModel.Condition), nameof(EntryEditorItemViewModel.Parameters)); - TrackCollection(node, node.Options, nameof(BranchOptionEditorItemViewModel.Text), nameof(BranchOptionEditorItemViewModel.Condition)); - TrackCollection(node, node.Conditions, nameof(BranchConditionEditorItemViewModel.Expression)); - } - - public void Clear() - { - foreach (var unsubscribe in _unsubscribe) - unsubscribe(); - _unsubscribe.Clear(); - } - - private void TrackCollection(GraphNode node, ObservableCollection collection, params string[] persistedProperties) - where TItem : ObservableObject - { - PropertyChangedEventHandler itemHandler = (sender, args) => - { - if (!_isLoading() && args.PropertyName is { } propertyName && persistedProperties.Contains(propertyName, StringComparer.Ordinal)) - { - if (_itemChanged is not null && sender is not null) _itemChanged(node, sender, propertyName); - else _markDirty(); - } - }; - NotifyCollectionChangedEventHandler collectionHandler = (_, args) => - { - SubscribeItems(args.NewItems, itemHandler, true); - SubscribeItems(args.OldItems, itemHandler, false); - if (!_isLoading() && args.Action != NotifyCollectionChangedAction.Reset) - _markDirty(); - }; - collection.CollectionChanged += collectionHandler; - foreach (var item in collection) - item.PropertyChanged += itemHandler; - _unsubscribe.Add(() => - { - collection.CollectionChanged -= collectionHandler; - foreach (var item in collection) - item.PropertyChanged -= itemHandler; - }); - } - - private static void SubscribeItems(System.Collections.IList? items, PropertyChangedEventHandler handler, bool subscribe) - { - if (items is null) - return; - foreach (var item in items.OfType()) - { - if (subscribe) item.PropertyChanged += handler; - else item.PropertyChanged -= handler; - } - } - - public void Dispose() => Clear(); -} diff --git a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs index fb8fa1a2..a4bf9ea4 100644 --- a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs +++ b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs @@ -1,134 +1,11 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; -using GalNet.Core.Entry; -using GalNet.Core.Graph; using GalNet.Editor.ViewModels; namespace GalNet.Editor.Services; public sealed class GraphEditingService : IGraphEditingService { - private readonly IEntryCatalog _catalog; - public GraphEditingService(IEntryCatalog catalog) => _catalog = catalog; - - public IReadOnlyList InsertEntries(GraphNode groupNode, int index, int count) - { - if (groupNode.NodeKind != GraphNodeKind.LinearGroup || count < 1) - return []; - - var definition = _catalog.Definitions.Count > 0 ? _catalog.Definitions[0] : null; - if (definition is null) - return []; - - index = Math.Clamp(index, 0, groupNode.Entries.Count); - var inserted = new List(count); - for (var offset = 0; offset < count; offset++) - { - var entry = new EntryEditorItemViewModel(_catalog) - { - StableId = Guid.NewGuid().ToString("N"), - Type = definition.Type, - Parameters = new Dictionary( - _catalog.Create(definition.Type).Values, - StringComparer.Ordinal) - }; - groupNode.Entries.Insert(index + offset, entry); - inserted.Add(entry); - } - RenumberEntries(groupNode); - return inserted; - } - - public bool RemoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry) - { - var removed = groupNode.Entries.Remove(entry); - if (removed) - RenumberEntries(groupNode); - - return removed; - } - - public bool MoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry, int newIndex) - { - var oldIndex = groupNode.Entries.IndexOf(entry); - if (oldIndex < 0 || newIndex < 0 || newIndex >= groupNode.Entries.Count || oldIndex == newIndex) - return false; - - groupNode.Entries.Move(oldIndex, newIndex); - RenumberEntries(groupNode); - return true; - } - - public bool AddChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node) - { - if (node.NodeKind != GraphNodeKind.ChoiceBranch) - return false; - - node.Options.Add(new BranchOptionEditorItemViewModel - { - Id = Guid.NewGuid().ToString("N"), - Text = $"Option {node.Options.Count + 1}" - }); - node.RefreshConnectors(); - UpdateConnectorStates(nodes, edges); - return true; - } - - public bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption) - { - if (node.NodeKind != GraphNodeKind.ChoiceBranch || !node.Options.Remove(choiceOption)) - return false; - - node.RefreshConnectors(); - RemoveDanglingOutletEdges(node, edges); - UpdateConnectorStates(nodes, edges); - return true; - } - - public bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex) - { - if (node.NodeKind != GraphNodeKind.ChoiceBranch) - return false; - - return MoveBranchItemWithEdges(nodes, edges, node, node.Options, choiceOption, newIndex); - } - - public bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node) - { - if (node.NodeKind != GraphNodeKind.ConditionBranch) - return false; - - node.Conditions.Add(new BranchConditionEditorItemViewModel - { - Id = Guid.NewGuid().ToString("N"), - Expression = "true" - }); - node.RefreshConnectors(); - UpdateConnectorStates(nodes, edges); - return true; - } - - public bool RemoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition) - { - if (node.NodeKind != GraphNodeKind.ConditionBranch || !node.Conditions.Remove(condition)) - return false; - - node.RefreshConnectors(); - RemoveDanglingOutletEdges(node, edges); - UpdateConnectorStates(nodes, edges); - return true; - } - - public bool MoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition, int newIndex) - { - if (node.NodeKind != GraphNodeKind.ConditionBranch) - return false; - - return MoveBranchItemWithEdges(nodes, edges, node, node.Conditions, condition, newIndex); - } - public void UpdateConnectorStates(ObservableCollection nodes, ObservableCollection edges) { foreach (var connector in nodes.SelectMany(node => node.InputConnectors.Concat(node.OutputConnectors))) @@ -145,53 +22,4 @@ public void UpdateConnectorStates(ObservableCollection nodes, Observa input.IsConnected = true; } } - - private static void RenumberEntries(GraphNode groupNode) - { - for (var index = 0; index < groupNode.Entries.Count; index++) - groupNode.Entries[index].Id = index + 1; - } - - private static void RemoveDanglingOutletEdges(GraphNode node, ObservableCollection edges) - { - var maxOutlet = node.OutputConnectors.Count - 1; - var danglingEdges = edges - .Where(edge => ReferenceEquals(edge.From, node) && edge.Outlet > maxOutlet) - .ToList(); - - foreach (var edge in danglingEdges) - edges.Remove(edge); - } - - private bool MoveBranchItemWithEdges( - ObservableCollection nodes, - ObservableCollection edges, - GraphNode node, - ObservableCollection items, - TItem item, - int newIndex) - where TItem : class - { - var oldIndex = items.IndexOf(item); - if (oldIndex < 0 || newIndex < 0 || newIndex >= items.Count || oldIndex == newIndex) - return false; - - var oldOrder = items.ToList(); - var edgeByItem = oldOrder - .Select((entry, index) => new { entry, edge = edges.FirstOrDefault(current => ReferenceEquals(current.From, node) && current.Outlet == index) }) - .Where(pair => pair.edge is not null) - .ToDictionary(pair => pair.entry, pair => pair.edge!); - - items.Move(oldIndex, newIndex); - - for (var index = 0; index < items.Count; index++) - { - if (edgeByItem.TryGetValue(items[index], out var edge)) - edge.Outlet = index; - } - - node.RefreshConnectors(); - UpdateConnectorStates(nodes, edges); - return true; - } } diff --git a/src/Editor/GalNet.Editor/Services/GraphSelectionState.cs b/src/Editor/GalNet.Editor/Services/GraphSelectionState.cs index 287462a0..24875613 100644 --- a/src/Editor/GalNet.Editor/Services/GraphSelectionState.cs +++ b/src/Editor/GalNet.Editor/Services/GraphSelectionState.cs @@ -13,6 +13,7 @@ public sealed class GraphSelectionState public void SelectNode(GraphNode? node, bool additive = false) { + if (SelectedEdge is { } edge) { edge.IsSelected = false; SelectedEdge = null; } if (!additive) Clear(); diff --git a/src/Editor/GalNet.Editor/Services/GroupEntryAuthoringService.cs b/src/Editor/GalNet.Editor/Services/GroupEntryAuthoringService.cs new file mode 100644 index 00000000..0023d4c2 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/GroupEntryAuthoringService.cs @@ -0,0 +1,192 @@ +using System.Collections.ObjectModel; +using System.Text; +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// Project-owned batch entry intents. History never retains a Group page. +public sealed class GroupEntryAuthoringService(EditorGroupProjectionStore groups, IEditorDocumentService documents, IEntryCatalog catalog, EditorHistories histories, GameTextAuthoringService? texts = null) +{ + public IReadOnlyList Insert(string groupId, int index, int count, string? type) + { + var group = Group(groupId); + type ??= GalNet.Contracts.Builtins.BuiltinEntryIds.Text; + if (!catalog.TryGet(type, out _)) return []; + index = Math.Clamp(index, 0, group.Entries.Count); + var inserted = Enumerable.Range(0, Math.Clamp(count, 1, MaximumEntries)).Select(_ => new EntryEditorItemViewModel(new EditorEntryData + { + StableId = Guid.NewGuid().ToString("N"), + Type = type, + Parameters = SceneAuthoringIdentities.NewEntry(type, EditorEntryData.ParseParameters(catalog, type, catalog.Create(type).Values)) + }, catalog)).ToArray(); + histories.Content.Execute(new PresenceEdit(this, groupId, inserted.Select((entry, offset) => new PositionedEntry(index + offset, entry)).ToArray(), true, + new("History.AddEntries", inserted.Length))); + return inserted; + } + private sealed record EntryClipboard(string Kind, int Version, List Entries, GameTextClipboard Texts); + private const string ClipboardKind = "GalNet.Entries"; + private const int MaximumEntries = 1000; + private const int MaximumBytes = 16 * 1024 * 1024; + + private EditorGroupProjection Group(string id) + { + if (!documents.CurrentDocument.Nodes.Any(node => node.Id == id && node.Type == "Group")) + throw new EditorOperationException("entry.groupMissing", "The Group no longer exists."); + return groups.Get(id); + } + + public string Copy(string groupId, IEnumerable ids) + { + var selected = ids.ToHashSet(StringComparer.Ordinal); + var entries = Group(groupId).Entries.Where(entry => selected.Contains(entry.StableId)).Select(entry => entry.Source).ToList(); + if (entries.Count == 0) return ""; + if (entries.Count > MaximumEntries) throw InvalidClipboard(); + var text = JsonSerializer.Serialize(new EntryClipboard(ClipboardKind, 2, entries, texts?.CaptureCopies(entries) ?? new(new(), new()))); + if (Encoding.UTF8.GetByteCount(text) > MaximumBytes) throw InvalidClipboard(); + return text; + } + + public async Task> PasteAsync(string groupId, string text, string? anchorId) + { + var group = Group(groupId); + var index = anchorId is null ? group.Entries.Count : group.Entries.ToList().FindIndex(entry => entry.StableId == anchorId) + 1; + if (anchorId is not null && index == 0) throw new EditorOperationException("entry.anchorMissing", "The insertion target was removed."); + EntryClipboard clip; + try + { + if (text.Length > MaximumBytes || Encoding.UTF8.GetByteCount(text) > MaximumBytes) throw new JsonException(); + clip = JsonSerializer.Deserialize(text) ?? throw new JsonException(); + if (clip.Kind != ClipboardKind || clip.Version != 2 || clip.Entries is not { Count: > 0 and <= MaximumEntries } + || clip.Entries.Any(entry => entry is null || string.IsNullOrWhiteSpace(entry.Type) || entry.Condition is null || entry.Parameters is null)) throw new JsonException(); + GameTextAuthoringService.ValidateClipboard(clip.Texts); + // Check the entire payload before creating any live projection. Keep incomplete drafts; + // full semantic validation belongs to compile/check, not clipboard insertion. + foreach (var entry in clip.Entries) + { + entry.StableId = Guid.NewGuid().ToString("N"); + } + } + catch (Exception error) when (error is JsonException or ArgumentException) { throw InvalidClipboard(); } + SceneAuthoringIdentities.RemapCopy(clip.Entries); + var copy = texts is null ? null : await texts.PrepareCopiesAsync(clip.Texts, clip.Entries, null, () => + { + _ = Group(groupId); + if (anchorId is not null && !group.Entries.Any(entry => entry.StableId == anchorId)) throw new EditorOperationException("entry.anchorMissing", "The insertion target was removed."); + }); + index = anchorId is null ? group.Entries.Count : group.Entries.ToList().FindIndex(entry => entry.StableId == anchorId) + 1; + var inserted = clip.Entries.Select(entry => new EntryEditorItemViewModel(entry, catalog)).ToArray(); + var positions = inserted.Select((entry, offset) => new PositionedEntry(index + offset, entry)).ToArray(); + var presence = new PresenceEdit(this, groupId, positions, true, new("History.PasteEntries", inserted.Length)); + if (copy is null) histories.Content.Execute(presence); + else await histories.Content.ExecuteAsync(new AsyncCompositeEdit("Paste entries", [copy, new AsyncEditAdapter(presence)], new("History.PasteEntries", inserted.Length))); + return inserted; + } + + public void Delete(string groupId, IEnumerable ids) + { + var selected = ids.ToHashSet(StringComparer.Ordinal); + var entries = Group(groupId).Entries.Select((entry, index) => new PositionedEntry(index, entry)).Where(item => selected.Contains(item.Entry.StableId)).ToArray(); + if (entries.Length > 0) histories.Content.Execute(new PresenceEdit(this, groupId, entries, false, new("History.DeleteEntries", entries.Length))); + } + + public void Move(string groupId, IEnumerable ids, string draggedId, int newIndex) + { + var entries = Group(groupId).Entries; + var before = entries.Select(entry => entry.StableId).ToArray(); + var oldIndex = Array.IndexOf(before, draggedId); + if (oldIndex < 0 || newIndex < 0 || newIndex >= entries.Count || oldIndex == newIndex) return; + var selected = ids.ToHashSet(StringComparer.Ordinal); + selected.Add(draggedId); + var boundary = newIndex > oldIndex ? newIndex + 1 : newIndex; + var offset = before.Take(boundary).Count(id => !selected.Contains(id)); + var after = before.Where(id => !selected.Contains(id)).ToList(); + after.InsertRange(offset, before.Where(selected.Contains)); + if (!before.SequenceEqual(after)) histories.Content.Execute(new OrderEdit(this, groupId, before, after.ToArray())); + } + + private sealed record PositionedEntry(int Index, EntryEditorItemViewModel Entry); + private sealed record PresenceEdit(GroupEntryAuthoringService Owner, string GroupId, PositionedEntry[] Entries, bool Insert, EditorText DisplayText) : IUndoableEdit + { + public string Description => Insert ? "Paste entries" : "Delete entries"; + public long RetainedBytes => Entries.Sum(item => (long)JsonSerializer.SerializeToUtf8Bytes(item.Entry.Source).Length); + public void Redo() => Apply(Insert); + public void Undo() => Apply(!Insert); + private void Apply(bool present) + { + var entries = Owner.Group(GroupId).Entries; + ApplyAtomically(entries, () => + { + if (present) + { + if (Entries.Any(item => entries.Any(entry => entry.StableId == item.Entry.StableId))) throw new InvalidOperationException("Entry already exists."); + foreach (var item in Entries) entries.Insert(Math.Min(item.Index, entries.Count), item.Entry); + } + else + { + if (Entries.Any(item => !entries.Contains(item.Entry))) throw new InvalidOperationException("Entry no longer exists."); + foreach (var item in Entries.Reverse()) entries.Remove(item.Entry); + } + }); + } + } + + private sealed record OrderEdit(GroupEntryAuthoringService Owner, string GroupId, string[] Before, string[] After) : IUndoableEdit + { + public string Description => "Move entries"; + public EditorText DisplayText => new("History.MoveEntries"); + public long RetainedBytes => (Before.Length + After.Length) * 80L; + public void Redo() => Apply(After); + public void Undo() => Apply(Before); + private void Apply(string[] order) + { + var entries = Owner.Group(GroupId).Entries; + var map = entries.ToDictionary(entry => entry.StableId); + if (map.Count != order.Length || order.Any(id => !map.ContainsKey(id))) throw new InvalidOperationException("Entry order has changed."); + ApplyAtomically(entries, () => + { + for (var index = 0; index < order.Length; index++) + if (!ReferenceEquals(entries[index], map[order[index]])) entries.Move(entries.IndexOf(map[order[index]]), index); + }); + } + } + + private static void ApplyAtomically(ObservableCollection entries, Action apply) + { + var before = entries.ToArray(); + try { apply(); Renumber(entries); } + catch (Exception failure) + { + try + { + // ObservableCollection mutates before notifying. A failing observer can + // therefore leave even its current operation applied; reconcile the + // actual collection rather than counting completed calls. + var retained = before.ToHashSet(); + for (var index = entries.Count - 1; index >= 0; index--) + if (!retained.Contains(entries[index])) entries.RemoveAt(index); + for (var index = 0; index < before.Length; index++) + { + var current = entries.IndexOf(before[index]); + if (current < 0) entries.Insert(index, before[index]); + else if (current != index) entries.Move(current, index); + } + Renumber(entries); + } + catch (Exception compensation) { throw new HistoryRepairRequiredException("Entry edit compensation failed.", new AggregateException(failure, compensation)); } + throw; + } + } + + private static void Renumber(IEnumerable entries) + { + var index = 0; + foreach (var entry in entries) entry.Id = ++index; + } + private static EditorOperationException InvalidClipboard() => new("entry.clipboard", "The clipboard does not contain a supported GalNet entry selection (up to 1,000 entries / 16 MiB of text)."); +} diff --git a/src/Editor/GalNet.Editor/Services/Interfaces/IAssetCatalogService.cs b/src/Editor/GalNet.Editor/Services/Interfaces/IAssetCatalogService.cs index 9411ec00..abeba58b 100644 --- a/src/Editor/GalNet.Editor/Services/Interfaces/IAssetCatalogService.cs +++ b/src/Editor/GalNet.Editor/Services/Interfaces/IAssetCatalogService.cs @@ -13,12 +13,5 @@ public interface IAssetCatalogService : IDisposable bool DirectoryExists(string relativeDirectory); AssetEntry? GetEntry(string relativePath); Task RefreshAsync(CancellationToken cancellationToken = default); - Task ImportAsync(IEnumerable sourcePaths, string targetDirectory, CancellationToken cancellationToken = default); - Task ImportExternalAsync(IEnumerable sourcePaths, string targetDirectory, CancellationToken cancellationToken = default); Task GetThumbnailPathAsync(AssetEntry entry, CancellationToken cancellationToken = default); - Task MoveAsync(string relativePath, string targetDirectory, CancellationToken cancellationToken = default); - Task RenameAsync(string relativePath, string newName, CancellationToken cancellationToken = default); - Task DeleteAsync(string relativePath, CancellationToken cancellationToken = default); - Task UpdateMetaAsync(AssetEntry entry, string? filter, string? compress, CancellationToken cancellationToken = default); - Task CreateDirectoryAsync(string parentDirectory, string name, CancellationToken cancellationToken = default); } diff --git a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs index 079aa5d5..1fba4580 100644 --- a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs +++ b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using System.Collections.ObjectModel; using GalNet.Editor.ViewModels; @@ -6,14 +5,5 @@ namespace GalNet.Editor.Services.Interfaces; public interface IGraphEditingService { - IReadOnlyList InsertEntries(GraphNode groupNode, int index, int count); - bool RemoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry); - bool MoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry, int newIndex); - bool AddChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node); - bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption); - bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex); - bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node); - bool RemoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition); - bool MoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition, int newIndex); void UpdateConnectorStates(ObservableCollection nodes, ObservableCollection edges); } diff --git a/src/Editor/GalNet.Editor/Services/LocalizationAuthoringService.cs b/src/Editor/GalNet.Editor/Services/LocalizationAuthoringService.cs new file mode 100644 index 00000000..55411f72 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/LocalizationAuthoringService.cs @@ -0,0 +1,27 @@ +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// Project history and file-draft coordination for structured localization edits. +public sealed class LocalizationAuthoringService(LocalizationDocumentStore store, IProjectContentSession content, EditorHistories histories) +{ + public async Task SetTextAsync(LocalizationTextTarget target, string text) + { + await content.CommitFileDraftsAsync(target.File.Id); + var patch = store.PrepareText(target, text); + if (patch.Before.Exists && patch.Before.Data.AsSpan().SequenceEqual(patch.After)) return; + await ApplyAsync(store.Prepare(patch), patch.Before.Path); + } + public Task ApplyAsync(LocalizationEditOperation operation, string label, string historyKey = "History.TextFile") => histories.Content.ExecuteAsync(new Edit(operation, label, historyKey)); + public IAsyncUndoableEdit CreateEdit(LocalizationEditOperation operation, string label, string historyKey) => new Edit(operation, label, historyKey); + private sealed record Edit(LocalizationEditOperation Operation, string Label, string HistoryKey) : IAsyncUndoableEdit + { + public string Description => "Edit localization " + Label; + public EditorText DisplayText => new(HistoryKey, Label); + public long RetainedBytes => Operation.RetainedBytes; + public ValueTask UndoAsync() => new(Operation.ApplyAsync(false)); + public ValueTask RedoAsync() => new(Operation.ApplyAsync(true)); + } +} diff --git a/src/Editor/GalNet.Editor/Services/LocalizationExchangeService.cs b/src/Editor/GalNet.Editor/Services/LocalizationExchangeService.cs new file mode 100644 index 00000000..c9d57f60 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/LocalizationExchangeService.cs @@ -0,0 +1,128 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Parameters; +using GalNet.Editor.Abstraction.Project; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +public sealed record LocalizationImportCandidate(LocalizationExchangeRow Row, bool Known, bool SourceChanged, LocalizationTextTarget? Target, string? CurrentSource); +public sealed record LocalizationImportPreview(GalProject Project, string Culture, string SourceLocale, string Evidence, LocalizationImportCandidate[] Rows); + +public sealed class LocalizationExchangeService(IProjectService projects, IProjectContentSession content, ProjectLocalizationQuery query, + LocalizationDocumentStore store, LocalizationAuthoringService authoring, EditorHistories histories) +{ + private GalProject Active() + { _ = store.CaptureFile(LocalizationJson.MetadataPath); return projects.Current!; } + private string Source => content.CaptureWorking().Settings.TargetLocale.Code; + private string? Identity => JsonNode.Parse(LocalizationJson.Decode(store.CaptureFile(LocalizationJson.MetadataPath).Data))!["exchangeProjectId"]?.GetValue() is { } identity ? Guid.Parse(identity).ToString("N") : null; + private string Evidence() + { + Active(); var index = query.Get()!; var meta = JsonNode.Parse(LocalizationJson.Decode(store.CaptureFile(LocalizationJson.MetadataPath).Data))!.AsObject(); + meta.Remove("characterHighWater"); + return LocalizationTextState.Fingerprint(JsonSerializer.Serialize(new { Source, Languages = content.CaptureWorking().Settings.AvailableLocales.Select(locale => locale.Code), index.References, index.Texts, Metadata = meta })); + } + public async Task ExportAsync(string culture, string? category, bool runtime) + { + Active(); culture = LocalizationExchange.Culture(culture); + await content.CommitDraftsAsync(); Active(); + if (!content.CaptureWorking().Settings.AvailableLocales.Any(locale => locale.Code == culture)) throw new EditorOperationException("exchange.language", "Language is not supported."); + var identity = runtime ? "" : await store.EnsureExchangeIdentityAsync(); Active(); + var snapshot = content.CaptureWorking(); var index = query.Get()!; var sourceLocale = snapshot.Settings.TargetLocale.Code; + var branches = snapshot.Graph.Nodes.Where(node => node.Type == "Branch").Select(node => node.Id).ToHashSet(StringComparer.Ordinal); + var references = index.References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText && + (category is null || (category == "branches" ? branches.Contains(reference.Owner) : reference.Owner == category))); + var rows = references.GroupBy(reference => reference.Key, StringComparer.Ordinal).Select(group => + { + var source = index.FindDirect(sourceLocale, group.Key)?.Text; + return new LocalizationExchangeRow(group.Key, source, source is null ? null : LocalizationTextState.Fingerprint(source), + group.Select(reference => $"{reference.Owner}/{reference.EntryId}{reference.JsonPointer}").ToArray(), index.FindDirect(culture, group.Key)?.Text); + }).ToArray(); + return runtime ? JsonSerializer.Serialize(rows.Where(row => row.Translation is not null).ToDictionary(row => row.Key, row => row.Translation), new JsonSerializerOptions { WriteIndented = true }) + : LocalizationExchange.Write(new(1, identity, sourceLocale, culture, rows, index.Complete)); + } + public LocalizationImportPreview Preview(string json, string flatLanguage) + { + var project = Active(); var input = LocalizationExchange.Parse(json, flatLanguage); + if (input.ProjectId is not null && (input.ProjectId != Identity || input.SourceLocale != Source)) throw new EditorOperationException("exchange.project", "Template project or source language does not match."); + if (input.TargetLocale == Source || !content.CaptureWorking().Settings.AvailableLocales.Any(locale => locale.Code == input.TargetLocale)) + throw new EditorOperationException("exchange.language", "Import requires a supported translation language other than the development language."); + var index = query.Get()!; var sourceLocale = Source; + var known = index.References.Where(reference => reference.Semantic == FieldValueSemantic.LocalizedText).Select(reference => reference.Key).ToHashSet(StringComparer.Ordinal); + var targets = store.CaptureTexts(input.Rows.Where(row => known.Contains(row.Key)).Select(row => row.Key), input.TargetLocale); + var rows = input.Rows.Select(row => new LocalizationImportCandidate(row, known.Contains(row.Key), + row.SourceFingerprint is not null && !string.Equals(row.SourceFingerprint, index.FindDirect(sourceLocale, row.Key)?.Text is { } source ? LocalizationTextState.Fingerprint(source) : null, StringComparison.OrdinalIgnoreCase), + targets.GetValueOrDefault(row.Key), index.FindDirect(sourceLocale, row.Key)?.Text)).ToArray(); + return new(project, input.TargetLocale, sourceLocale, Evidence(), rows); + } + private void Check(LocalizationImportPreview preview) + => Check(preview.Project, preview.Culture, preview.SourceLocale, preview.Evidence); + private void Check(GalProject project, string culture, string sourceLocale, string evidence) + { + if (!ReferenceEquals(Active(), project) || sourceLocale != Source || culture == Source || !content.CaptureWorking().Settings.AvailableLocales.Any(locale => locale.Code == culture) || Evidence() != evidence) + throw new EditorOperationException("exchange.stale", "Import preview changed. Preview the file again."); + } + public async Task ApplyAsync(LocalizationImportPreview preview, IReadOnlyCollection selected, bool allowEmpty) + { + Check(preview); + var selection = selected.ToHashSet(StringComparer.Ordinal); + var rows = preview.Rows.Where(row => selection.Contains(row.Row.Key)).ToArray(); + if (selection.Count != selected.Count || rows.Length != selected.Count || rows.Any(row => !row.Known || row.Row.Translation is null || row.Row.Translation.Length == 0 && !allowEmpty)) + throw new InvalidDataException("Selection contains unknown, null or disabled empty translations."); + if (rows.Length == 0) return; + var metadata = store.CaptureFile(LocalizationJson.MetadataPath); + var files = rows.Select(row => row.Target!.File).Append(metadata).DistinctBy(file => file.Path).ToArray(); + foreach (var file in files.Where(file => file.Exists)) { await content.CommitFileDraftsAsync(file.Id); Active(); } + Check(preview); + var missing = files.Where(file => !file.Exists).ToArray(); + if (missing.Length > 0) { await store.Prepare(missing.Select(file => new LocalizationFilePatch(file, file.Data)).ToArray()).ApplyAsync(true); Check(preview); } + var baselineFiles = new Dictionary(StringComparer.Ordinal); + var targets = rows.Select(row => + { + var target = row.Target!; + if (target.File.Exists) return target; + if (!baselineFiles.TryGetValue(target.File.Path, out var file)) baselineFiles[target.File.Path] = file = store.CaptureFile(target.File.Path); + return target with { File = file }; + }).ToArray(); + var patches = store.PrepareTexts(targets, rows.ToDictionary(row => row.Row.Key, row => row.Row.Translation!, StringComparer.Ordinal)).ToDictionary(patch => patch.Before.Path, StringComparer.Ordinal); + var meta = store.CaptureFile(LocalizationJson.MetadataPath); var root = JsonNode.Parse(LocalizationJson.Decode(meta.Data))!; + root["textStates"] ??= new JsonObject(); var index = query.Get()!; + foreach (var candidate in rows) + { + var key = candidate.Row.Key; var source = index.FindDirect(preview.SourceLocale, key)?.Text; + var states = root["textStates"]!; states[key] ??= new JsonObject { ["translations"] = new JsonObject() }; + var state = states[key]!; + if (state["sourceLocale"]?.GetValue() != preview.SourceLocale) state["translations"] = new JsonObject(); + state["sourceLocale"] = preview.SourceLocale; state["sourceFingerprint"] = LocalizationTextState.Fingerprint(source ?? ""); + state["translations"]![preview.Culture] = source is not null && candidate.Row.SourceFingerprint is { } proof ? JsonValue.Create(proof) : null; + } + patches.Add(meta.Path, new(meta, LocalizationJson.WriteLike(meta.Data, root))); + Check(preview); + var project = preview.Project; var culture = preview.Culture; var sourceLocale = preview.SourceLocale; var evidence = preview.Evidence; + await histories.Content.ExecuteAsync(new ImportEdit(authoring.CreateEdit(store.Prepare(patches.Values.ToArray()), culture, "History.ImportLocalization"), () => Check(project, culture, sourceLocale, evidence))); + } + private sealed record ImportEdit(IAsyncUndoableEdit Edit, Action Check) : IAsyncUndoableEdit + { + public string Description => Edit.Description; + public EditorText DisplayText => Edit.DisplayText; + public long RetainedBytes => Edit.RetainedBytes; + public ValueTask RedoAsync() { Check(); return Edit.RedoAsync(); } + public ValueTask UndoAsync() => Edit.UndoAsync(); + } + public async Task ExportFileAsync(string path, string culture, string? category, bool runtime) + { + var project = Active(); var full = Path.GetFullPath(path); var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(project.RootPath)); + if (full.Equals(root, StringComparison.OrdinalIgnoreCase) || full.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("Export must be outside the project directory."); + for (var directory = new DirectoryInfo(Path.GetDirectoryName(full)!); directory is not null; directory = directory.Parent) + if (directory.Exists && directory.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidDataException("Export path traverses a directory link."); + var json = await ExportAsync(culture, category, runtime); + if (!ReferenceEquals(project, Active())) throw new EditorOperationException("exchange.stale", "Project closed."); + var temporary = full + ".export-" + Guid.NewGuid().ToString("N") + ".tmp"; + try { await File.WriteAllTextAsync(temporary, json, new UTF8Encoding(false)); Active(); File.Move(temporary, full, true); } + finally { if (File.Exists(temporary)) File.Delete(temporary); } + } +} diff --git a/src/Editor/GalNet.Editor/Services/ProjectFileAuthoringService.cs b/src/Editor/GalNet.Editor/Services/ProjectFileAuthoringService.cs new file mode 100644 index 00000000..14953b42 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/ProjectFileAuthoringService.cs @@ -0,0 +1,57 @@ +using GalNet.Core.Assets; +using GalNet.Core.Entry; +using GalNet.Editor.Abstraction.Commands; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Commands; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// One project history entry for each complete file intent. +public sealed class ProjectFileAuthoringService(IProjectService projects, IProjectContentSession content, EditorHistories histories, IEntryCatalog catalog, IResourceTypeCatalog types, Interfaces.IAssetCatalogService assets) : IProjectFileCommandExecutor +{ + public ProjectFileImpact Analyze(ProjectFileRequest request) + { + using var operation = new ProjectFileOperation(projects.Current!.RootPath, request, catalog, types); + return operation.Analyze(content.CaptureWorking(), content.CaptureSaved()); + } + public async Task ExecuteAsync(ProjectFileRequest request) + { + var project = projects.Current ?? throw new InvalidOperationException("No project is open."); + await content.CommitDraftsAsync(); + var operation = new ProjectFileOperation(project.RootPath, request, catalog, types); + try { await histories.Content.ExecuteAsync(new FileEdit(operation, content, assets)); } + catch { operation.Dispose(); throw; } + } + public async Task ExecuteAsync(string projectPath, IProjectFileCommand command, CancellationToken cancellationToken = default) + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (projects.Current is not { } project || !ProjectFilePaths.Comparer.Equals(Path.GetFullPath(projectPath), Path.GetFullPath(project.RootPath))) throw new InvalidOperationException("File commands require the active project."); + await ExecuteAsync(AssetFileCommandExecutor.Translate(command)); + return new(true, null, command.CommandId, [], []); + } + catch (Exception failure) + { + content.ReportError(failure); + return FileCommandResult.Failure(EditorDiagnostic.Error(failure is EditorOperationException coded ? coded.Code : "files.failed", failure.Message)) with { Error = failure }; + } + } + private sealed record FileEdit(ProjectFileOperation Operation, IProjectContentSession Content, Interfaces.IAssetCatalogService Assets) : IAsyncUndoableEdit, IDisposable + { + public string Description => "Project file " + Operation.Action; + public EditorText DisplayText => new("History.File" + Operation.Action, Operation.TargetLabel); + public long RetainedBytes => Operation.RetainedBytes; + public ValueTask RedoAsync() => ApplyAsync(true); + public ValueTask UndoAsync() => ApplyAsync(false); + private async ValueTask ApplyAsync(bool forward) + { + await Operation.ApplyAsync(forward, Content.PublishStructureAsync); + try { await Assets.RefreshAsync(); } + catch (Exception failure) { Content.ReportError(failure); Serilog.Log.Error(failure, "File publication succeeded but asset index refresh failed"); } + } + public void Dispose() => Operation.Dispose(); + } +} diff --git a/src/Editor/GalNet.Editor/Services/ProjectFilesIndex.cs b/src/Editor/GalNet.Editor/Services/ProjectFilesIndex.cs new file mode 100644 index 00000000..2ff05723 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/ProjectFilesIndex.cs @@ -0,0 +1,43 @@ +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Models; +using GalNet.Editor.Services.Interfaces; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// Read-only enumeration; every mutation goes through ProjectFileAuthoringService. +public sealed class ProjectFilesIndex(IProjectService projects, IAssetCatalogService assets, IEditorLocalizationService localization) +{ + private static readonly string[] Hidden = [".galnet", ".git", ".vs", ".idea", ".editor-tx", ".editor-trash", "bin", "obj", "Output", "Temp"]; + public string Root { get; } = projects.Current?.RootPath ?? throw new InvalidOperationException("No project is open."); + private static bool Visible(string full) => !Hidden.Contains(Path.GetFileName(full), StringComparer.OrdinalIgnoreCase) && (File.GetAttributes(full) & FileAttributes.ReparsePoint) == 0; + private bool HasDirectories(string path) => Directory.EnumerateDirectories(new ProjectRawReader(Root).Resolve(path)).Any(Visible); + public bool DirectoryExists(string path) { try { return Directory.Exists(new ProjectRawReader(Root).Resolve(path)); } catch (Exception) { return false; } } + public IEnumerable Read(string directory) + { + var reader = new ProjectRawReader(Root); + foreach (var full in Directory.EnumerateFileSystemEntries(reader.Resolve(directory)).OrderBy(path => !Directory.Exists(path)).ThenBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)) + { + if (!Visible(full)) continue; + var path = reader.Relative(full); + if (path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) && path.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) continue; + var reason = ""; + try { ProjectFilePaths.Editable(path); } catch (Exception error) { reason = EditorText.Error(error).Render(localization); } + var asset = path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase) ? assets.GetEntry(path[7..]) : null; + yield return new() { Path = path, FullPath = full, IsDirectory = Directory.Exists(full), WriteTimeUtc = File.GetLastWriteTimeUtc(full), Type = Directory.Exists(full) ? "folder" : asset?.Type ?? Path.GetExtension(path).TrimStart('.'), ProtectionReason = reason }; + } + } + public IEnumerable Directories(string path) => Read(path).Where(item => item.IsDirectory).Select(item => new ProjectDirectoryNode(item.Path, item.Name, Directories, HasDirectories(item.Path))); + // Type filtering stays separate from the search syntax for future scope/type controls. + public IEnumerable Search(string name, string? type = null) + { + var pending = new Stack(); pending.Push(""); + while (pending.TryPop(out var directory)) + foreach (var item in Read(directory)) + { + if (item.IsDirectory) pending.Push(item.Path); + if (item.Name.Contains(name, StringComparison.OrdinalIgnoreCase) && (type is null || item.Type.Equals(type, StringComparison.OrdinalIgnoreCase))) yield return item; + else item.Dispose(); + } + } +} diff --git a/src/Editor/GalNet.Editor/Services/ProjectSceneTargets.cs b/src/Editor/GalNet.Editor/Services/ProjectSceneTargets.cs new file mode 100644 index 00000000..501f0151 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/ProjectSceneTargets.cs @@ -0,0 +1,237 @@ +using System.Text; +using System.Text.Json; +using Avalonia.Threading; +using GalNet.Assets.Variants; +using GalNet.Contracts.Builtins; +using GalNet.Core.Assets; +using GalNet.Core.Scene; +using GalNet.Core.Variants; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Services.Interfaces; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// One immutable author snapshot plus asynchronously loaded resource declarations per project scope. +public sealed class ProjectSceneTargets : IDisposable +{ + private readonly IEditorDocumentService _documents; + private readonly IAssetManager? _assets; + private readonly IEffectCatalog? _effects; + private readonly IAssetCatalogService? _resources; + private readonly IProjectContentSession? _content; + private readonly IProjectService? _projects; + private readonly IResourceTypeCatalog? _types; + private string _workingFingerprint = ""; + private readonly object _gate = new(); + private CancellationTokenSource _resourceLifetime = new(); + private bool _disposed; + private string? _snapshot; + private EditorProjectDocument? _frozen; + private SceneTargetAnalysis? _analysis; + private IReadOnlyDictionary _programs = new Dictionary(StringComparer.Ordinal); + private IReadOnlyDictionary _variants = new Dictionary(StringComparer.OrdinalIgnoreCase); + private Task? _loading; + private long _resourceEpoch; + public event Action? Changed; + public long Revision { get; private set; } + public bool IsAvailable { get { lock (_gate) return !_disposed && _documents.IsLoaded; } } + internal (object? Author, long Resources) PreparationStamp { get { lock (_gate) return (Capture() ? _frozen : null, _resourceEpoch); } } + + public ProjectSceneTargets(IEditorDocumentService documents, IAssetManager? assets = null, + IEffectCatalog? effects = null, IAssetCatalogService? resources = null, IProjectContentSession? content = null, + IProjectService? projects = null, IResourceTypeCatalog? types = null) + { + _documents = documents; _assets = assets; _effects = effects; _resources = resources; + _content = content; _projects = projects; _types = types; + if (_content is not null) _content.Changed += OnContentChanged; + if (_resources is not null) _resources.Changed += OnResourcesChanged; + } + public ImageVariantsDefinition? VariantDefinition(string id) + { + lock (_gate) { if (!Capture()) return null; QueuePreparation(); return _variants.GetValueOrDefault(id.Trim()); } + } + private void OnContentChanged() + { + var fingerprint = string.Join("\n", _content!.FileDocuments.Values.Where(file => file.Exists && file.ManagedContent && file.Path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + .OrderBy(file => file.Id, StringComparer.Ordinal).Select(file => file.Id + ":" + file.Path + ":" + Convert.ToBase64String(file.Data))); + if (fingerprint == _workingFingerprint) return; + _workingFingerprint = fingerprint; InvalidateResources(); + } + + public SceneTargetResult Before(string groupId, string? entryId) => Query(groupId, entryId, null); + public SceneTargetResult AtTimeline(string groupId, string entryId, SceneTimelinePosition position) => Query(groupId, entryId, position); + private SceneTargetResult Query(string groupId, string? entryId, SceneTimelinePosition? position) + { + lock (_gate) + { + if (!Capture()) return new([], false, false); + QueuePreparation(); + _analysis ??= new(_frozen!.Graph, _frozen.GroupEntries, programs: _assets is null ? null : _programs, effects: _effects, variants: _variants); + return position is null ? _analysis.Before(groupId, entryId) : entryId is null ? new([], false, false) : _analysis.AtTimeline(groupId, entryId, position); + } + } + + // Only the caller's wait is cancelled; resource IO belongs to the project/refresh generation. + public async Task PrepareAsync(CancellationToken token = default) + { + while (true) + { + Task? loading; + lock (_gate) { if (!Capture()) return; loading = QueuePreparation(); } + if (loading is null) return; + await loading.WaitAsync(token); + } + } + + private bool Capture() + { + if (_disposed) return false; + if (!_documents.IsLoaded) + { + if (_snapshot is not null) Revision++; + _snapshot = null; _frozen = null; _analysis = null; return false; + } + var snapshot = JsonSerializer.Serialize(new EditorProjectDocument + { + Graph = _documents.CurrentDocument, + GroupEntries = _documents.Groups.ToDictionary(pair => pair.Key, pair => pair.Value.Entries) + }); + if (snapshot != _snapshot) + { + _frozen = JsonSerializer.Deserialize(snapshot)!; + _snapshot = snapshot; _analysis = null; Revision++; + } + return true; + } + + private Task? QueuePreparation() + { + if (_assets is null || _frozen is null || _disposed) return null; + if (_loading is not null) return _loading; + var ids = Programs(_frozen).Distinct(StringComparer.Ordinal).Take(256).Where(id => !_programs.ContainsKey(id)).ToArray(); + var variants = Variants(_frozen).Distinct(StringComparer.OrdinalIgnoreCase).Take(256).Where(id => !_variants.ContainsKey(id)).ToArray(); + if (ids.Length == 0 && variants.Length == 0) return null; + var epoch = _resourceEpoch; + var token = _resourceLifetime.Token; + var working = _content?.FileDocuments.Values.Where(file => file.Exists && file.ManagedContent && file.Path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + .ToDictionary(file => file.Path, file => file.Data.ToArray(), StringComparer.OrdinalIgnoreCase) ?? []; + var root = _projects?.Current?.RootPath; + return _loading = Task.Run(() => LoadAsync(ids, variants, epoch, token, working, root)); + } + + private async Task LoadAsync(string[] ids, string[] variants, long epoch, CancellationToken token, Dictionary working, string? root) + { + var loaded = new Dictionary(StringComparer.Ordinal); + var loadedVariants = new Dictionary(StringComparer.OrdinalIgnoreCase); + try + { + foreach (var id in ids) + { + token.ThrowIfCancellationRequested(); + loaded[id] = null; + try + { + var file = await _assets!.GetFileAsync(id, token); + if (file is null || file.TypeId != "effect-program" || file.Length > 1024 * 1024) continue; + var bytes = await file.ReadAllBytesAsync(token); + if (bytes.Length > 1024 * 1024) continue; + var source = new UTF8Encoding(false, true).GetString(bytes); + var parsed = ShaderEffectMetadataParser.Parse(new EffectProgramResource(id), source); + if (parsed.Success) loaded[id] = parsed.Descriptor; + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException or InvalidOperationException) + { /* Missing/unreadable declarations remain incomplete until the next resource refresh. */ } + } + foreach (var id in variants) + { + token.ThrowIfCancellationRequested(); + loadedVariants[id] = null; + try + { + var file = await _assets!.GetFileAsync(id, token); + if (file is null || file.TypeId != ImageVariantsCodec.ResourceType || file.Length > 1024 * 1024) continue; + if (root is not null && _types is not null && working.TryGetValue("Assets/" + file.Path, out var author)) + { + if (author.Length <= 1024 * 1024 && ProjectTextFiles.TryDecode(author, out _, out var text)) + loadedVariants[id] = ImageVariantsAuthoring.Scan(root, "Assets/" + file.Path, text, _types).Build.Definition; + continue; + } + var bytes = await file.ReadAllBytesAsync(token); + if (bytes.Length <= 1024 * 1024) loadedVariants[id] = ImageVariantsCodec.Decode(bytes); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception error) when (error is IOException or InvalidDataException or UnauthorizedAccessException or ArgumentException or InvalidOperationException or JsonException) + { /* Failed definitions stay incomplete until the next resource refresh. */ } + } + lock (_gate) + { + if (_disposed || epoch != _resourceEpoch || _frozen is null) return; + var referenced = Programs(_frozen!).Distinct(StringComparer.Ordinal).Take(256).ToHashSet(StringComparer.Ordinal); + var merged = _programs.Where(pair => referenced.Contains(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal); + foreach (var pair in loaded.Where(pair => referenced.Contains(pair.Key))) merged[pair.Key] = pair.Value; + var referencedVariants = Variants(_frozen).Distinct(StringComparer.OrdinalIgnoreCase).Take(256).ToHashSet(StringComparer.OrdinalIgnoreCase); + var mergedVariants = _variants.Where(pair => referencedVariants.Contains(pair.Key)).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + foreach (var pair in loadedVariants.Where(pair => referencedVariants.Contains(pair.Key))) mergedVariants[pair.Key] = pair.Value; + _programs = merged; _variants = mergedVariants; _analysis = null; Revision++; + } + NotifyChanged(); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { } + finally { lock (_gate) { if (epoch == _resourceEpoch) _loading = null; } } + } + + private static IEnumerable Variants(EditorProjectDocument project) => project.GroupEntries.Values.SelectMany(entries => entries) + .Where(entry => entry.Type == BuiltinEntryIds.ShowVariants).Select(entry => entry.Parameters.GetValueOrDefault("assetId")) + .Where(value => value.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(value.GetString())).Select(value => value.GetString()!.Trim()); + + private static IEnumerable Programs(EditorProjectDocument project) + { + foreach (var entry in project.GroupEntries.Values.SelectMany(entries => entries)) + { + if (entry.Type == "effect.apply" && entry.Parameters.TryGetValue("program", out var program) + && program.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(program.GetString())) yield return program.GetString()!; + if (entry.Type != "timeline.play" || !entry.Parameters.TryGetValue("timeline", out var timeline) || timeline.ValueKind != JsonValueKind.Object) continue; + var events = Member(timeline, "events"); + if (events.ValueKind != JsonValueKind.Array) continue; + foreach (var item in events.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || Member(item, "type") is not { ValueKind: JsonValueKind.String } type || type.GetString() != "effect.apply") continue; + var parameters = Member(item, "parameters"); + if (parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty("program", out program) + && program.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(program.GetString())) yield return program.GetString()!; + } + } + } + + private static JsonElement Member(JsonElement element, string name) => element.EnumerateObject() + .FirstOrDefault(property => string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)).Value; + private void OnResourcesChanged() => Dispatcher.UIThread.Post(InvalidateResources); + public void InvalidateResources() + { + lock (_gate) + { + if (_disposed) return; + _resourceEpoch++; _resourceLifetime.Cancel(); _resourceLifetime.Dispose(); _resourceLifetime = new(); + _programs = new Dictionary(StringComparer.Ordinal); _loading = null; _analysis = null; Revision++; + _variants = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (Capture()) QueuePreparation(); + } + NotifyChanged(); + } + private void NotifyChanged() => Dispatcher.UIThread.Post(() => { if (IsAvailable) Changed?.Invoke(); }); + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; _resourceEpoch++; _resourceLifetime.Cancel(); _resourceLifetime.Dispose(); + if (_resources is not null) _resources.Changed -= OnResourcesChanged; + if (_content is not null) _content.Changed -= OnContentChanged; + _snapshot = null; _frozen = null; _analysis = null; _programs = new Dictionary(); Revision++; Changed = null; + _variants = new Dictionary(); + } + } +} diff --git a/src/Editor/GalNet.Editor/Services/ProjectTextFiles.cs b/src/Editor/GalNet.Editor/Services/ProjectTextFiles.cs new file mode 100644 index 00000000..9e36bbb9 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/ProjectTextFiles.cs @@ -0,0 +1,187 @@ +using System.ComponentModel; +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GalNet.Core.Assets; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// Project-owned buffers outlive their Dock views; bytes, paths and saving belong to the content session. +public sealed class ProjectTextFiles(IProjectService projects, IProjectContentSession content, EditorHistories histories, IEditorLocalizationService localization, IResourceTypeCatalog? resourceTypes = null) : IDisposable +{ + private readonly Dictionary _buffers = []; + public ProjectTextBuffer Get(string id) => _buffers[id]; + public ProjectTextBuffer Open(string path) + { + var document = content.FileDocuments.Values.FirstOrDefault(file => file.Exists && ProjectFilePaths.Comparer.Equals(file.Path, path)); + if (document is not null && _buffers.TryGetValue(document.Id, out var buffer)) return buffer; + var root = projects.Current!.RootPath; + var full = new ProjectRawReader(root).Resolve(path); + byte[]? bytes = null; + Encoding? encoding = null; + string text = ""; + var editable = true; + try { ProjectFilePaths.Editable(path); } + catch (Exception) { editable = false; } + if (path.StartsWith("Graph/", StringComparison.OrdinalIgnoreCase)) editable = false; + if (new[] { ".bin", ".dll", ".exe", ".pdb", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp3", ".ogg", ".wav", ".mp4", ".ttf", ".otf", ".pak", ".galpak", ".galgroup" }.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase)) editable = false; + var original = document is { ManagedContent: true } ? document.Data + : File.Exists(full) && new FileInfo(full).Length <= 2 * 1024 * 1024 ? File.ReadAllBytes(full) : null; + if (original is { Length: <= 2 * 1024 * 1024 }) + { + if (TryDecode(original, out encoding, out text) && editable) bytes = original; + } + if (encoding is null) editable = false; + document ??= content.TrackFile(path, bytes); + buffer = new(document, content, histories, localization, encoding, text, root, editable, ValidateVariants); + _buffers.Add(document.Id, buffer); + return buffer; + } + private void ValidateVariants(string path, string text) + { + if (resourceTypes is null || !path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) return; + var isVariants = resourceTypes.TryGetByExtension(Path.GetExtension(path), out var type) && type.TypeId == "image-variants"; + var sidecar = new ProjectRawReader(projects.Current!.RootPath).Resolve(path + ".meta"); + if (!isVariants && File.Exists(sidecar)) isVariants = new AssetMetaCodec(resourceTypes).Deserialize(File.ReadAllText(sidecar), path[7..]).TypeId == "image-variants"; + if (!isVariants) return; + var build = ImageVariantsAuthoring.Scan(projects.Current!.RootPath, path, text, resourceTypes).Build; + if (build.Definition is null) throw new InvalidDataException(string.Join("\n", build.Diagnostics.Select(item => $"{item.Path} [{item.Code}]: {item.Message}"))); + } + internal static bool TryDecode(byte[] bytes, out Encoding? encoding, out string text) + { + encoding = null; text = ""; + var skip = 0; + if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe, 0, 0 })) { encoding = new UTF32Encoding(false, true, true); skip = 4; } + else if (bytes.AsSpan().StartsWith(new byte[] { 0, 0, 0xfe, 0xff })) { encoding = new UTF32Encoding(true, true, true); skip = 4; } + else if (bytes.AsSpan().StartsWith(new byte[] { 0xff, 0xfe })) { encoding = new UnicodeEncoding(false, true, true); skip = 2; } + else if (bytes.AsSpan().StartsWith(new byte[] { 0xfe, 0xff })) { encoding = new UnicodeEncoding(true, true, true); skip = 2; } + else if (bytes.AsSpan().StartsWith(new byte[] { 0xef, 0xbb, 0xbf })) { encoding = new UTF8Encoding(true, true); skip = 3; } + else encoding = new UTF8Encoding(false, true); + try + { + text = encoding.GetString(bytes, skip, bytes.Length - skip); + if (text.Any(c => char.IsControl(c) && c is not ('\r' or '\n' or '\t'))) return false; + return encoding.GetPreamble().Concat(encoding.GetBytes(text)).SequenceEqual(bytes); + } + catch (DecoderFallbackException) { text = ""; return false; } + } + public void Dispose() { foreach (var buffer in _buffers.Values) buffer.Dispose(); _buffers.Clear(); } +} + +public sealed partial class ProjectTextBuffer : ObservableObject, IDisposable +{ + private readonly EditorFileDocument _document; + private readonly IProjectContentSession _content; + private readonly EditorHistories _histories; + private readonly IEditorLocalizationService _localization; + private readonly Encoding? _encoding; + private readonly string _root; + private readonly bool _canEdit; + private readonly IDisposable _draft; + private readonly Action? _validate; + private byte[] _observed; + private Exception? _failure; + private bool _externalChange; + private bool _disposed; + public string Id => _document.Id; + public string Path => _document.Path; + public string Title => System.IO.Path.GetFileName(Path) + (IsDirty ? " *" : ""); + public bool IsReadOnly => _disposed || !_canEdit || !_document.ManagedContent || !_document.Exists || _content.RepairRequired; + public bool IsDirty => _document.Exists && _document.ManagedContent && (HasDraft || !_content.CaptureSaved().Files.TryGetValue(Id, out var saved) || !saved.Data.AsSpan().SequenceEqual(_document.Data)); + private bool HasDraft => !IsReadOnly && Text != Decode(_observed); + public string Information => !_document.Exists ? _localization["Files.Missing"] : IsReadOnly ? _localization["Files.ReadOnly"] : _encoding!.WebName; + public long Size { get { try { return new FileInfo(System.IO.Path.Combine(_root, Path)).Length; } catch (IOException) { return 0; } } } + public bool CanOpenExternal => _document.Exists; + [ObservableProperty] private string _text; + [ObservableProperty] private string _error = ""; + public ProjectTextBuffer(EditorFileDocument document, IProjectContentSession content, EditorHistories histories, IEditorLocalizationService localization, Encoding? encoding, string text, string root, bool canEdit = true, Action? validate = null) + { + _validate = validate; + _document = document; _content = content; _histories = histories; _localization = localization; _encoding = encoding; _root = root; + _canEdit = canEdit; + _text = document.ManagedContent && encoding is not null ? Decode(document.Data) : text; _observed = document.Data.ToArray(); + _draft = content.RegisterFileDraft(document.Id, () => HasDraft, CommitAsync, ValidateStructuredDraft); + content.Changed += OnChanged; localization.PropertyChanged += OnLocale; + } + private string Decode(byte[] bytes) => _encoding!.GetString(bytes, _encoding.GetPreamble().Length, bytes.Length - _encoding.GetPreamble().Length); + partial void OnTextChanged(string value) { if (!_disposed) _content.Refresh(); OnPropertyChanged(nameof(Title)); } + private void OnLocale(object? sender, PropertyChangedEventArgs args) + { + if (_failure is not null) Error = EditorText.Error(_failure).Render(_localization); + else if (_externalChange) Error = _localization["Files.ExternalChange"]; + OnChanged(); + } + private void OnChanged() + { + if (_document.ManagedContent && _encoding is not null && !_observed.AsSpan().SequenceEqual(_document.Data)) + { + if (HasDraft && Text != Decode(_document.Data)) + { + _failure = ProjectFilePaths.Error("changed", Path); + Error = EditorText.Error(_failure).Render(_localization); + } + else { _observed = _document.Data.ToArray(); Text = Decode(_document.Data); } + } + OnPropertyChanged(nameof(Path)); OnPropertyChanged(nameof(Title)); OnPropertyChanged(nameof(IsDirty)); OnPropertyChanged(nameof(IsReadOnly)); OnPropertyChanged(nameof(Information)); OnPropertyChanged(nameof(Size)); OnPropertyChanged(nameof(CanOpenExternal)); + } + public async Task CommitAsync() + { + if (!HasDraft) return; + try + { + _validate?.Invoke(Path, Text); + if (!_document.Data.AsSpan().SequenceEqual(_observed)) throw ProjectFilePaths.Error("changed", Path); + var next = _encoding!.GetPreamble().Concat(_encoding.GetBytes(Text)).ToArray(); + await _histories.Content.ExecuteAsync(new TextEdit(_document, _observed.ToArray(), next)); + _failure = null; Error = _externalChange ? _localization["Files.ExternalChange"] : ""; + } + catch (Exception error) { _failure = error; Error = EditorText.Error(error).Render(_localization); throw; } + } + [RelayCommand] private async Task ApplyAsync() { try { await CommitAsync(); } catch (Exception) { } } + [RelayCommand] private async Task SaveAsync() { try { await _content.SaveAsync(); _failure = null; Error = ""; } catch (Exception error) { _failure = error; Error = EditorText.Error(error).Render(_localization); } } + private void ValidateStructuredDraft() + { + _validate?.Invoke(Path, Text); + if (Path.StartsWith("I18n/", StringComparison.Ordinal) || Path == LocalizationJson.MetadataPath) + LocalizationJson.Validate(Path, _encoding!.GetPreamble().Concat(_encoding.GetBytes(Text)).ToArray()); + if (!_document.Data.AsSpan().SequenceEqual(_observed)) throw ProjectFilePaths.Error("changed", Path); + } + [RelayCommand] + private void Cancel() + { + if (!_document.ManagedContent || _encoding is null) return; + _observed = _document.Data.ToArray(); _failure = null; Text = Decode(_document.Data); + Error = _externalChange ? _localization["Files.ExternalChange"] : ""; + } + [RelayCommand] private void OpenExternal() { try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(System.IO.Path.Combine(_root, Path)) { UseShellExecute = true }); } catch (Exception error) { _failure = error; Error = EditorText.Error(error).Render(_localization); } } + public void CheckExternalChange() + { + if (!_document.ManagedContent || !_document.Exists) return; + var saved = _content.CaptureSaved().Files[Id]; + try + { + var full = new ProjectRawReader(_root).Resolve(Path); + _externalChange = !File.Exists(full) || !File.ReadAllBytes(full).AsSpan().SequenceEqual(saved.Data); + if (_failure is null) Error = _externalChange ? _localization["Files.ExternalChange"] : ""; + } + catch (IOException) { Error = _localization["Files.ExternalChange"]; } + } + private sealed record TextEdit(EditorFileDocument Document, byte[] Before, byte[] After) : IUndoableEdit + { + public string Description => "Edit file"; + public EditorText DisplayText => new("History.TextFile", Document.Path); + public long RetainedBytes => Before.LongLength + After.LongLength; + private void Apply(byte[] expected, byte[] next) + { + if (!Document.Exists || !Document.Data.AsSpan().SequenceEqual(expected)) throw ProjectFilePaths.Error("changed", Document.Path); + Document.Data = next.ToArray(); + } + public void Redo() => Apply(Before, After); + public void Undo() => Apply(After, Before); + } + public void Dispose() { if (_disposed) return; _disposed = true; _content.Changed -= OnChanged; _localization.PropertyChanged -= OnLocale; _draft.Dispose(); OnPropertyChanged(nameof(IsReadOnly)); } +} diff --git a/src/Editor/GalNet.Editor/Services/SceneTargetCatalog.cs b/src/Editor/GalNet.Editor/Services/SceneTargetCatalog.cs new file mode 100644 index 00000000..d95bb62c --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/SceneTargetCatalog.cs @@ -0,0 +1,104 @@ +using System.Text.Json; +using GalNet.Contracts.Builtins; +using GalNet.Core.Assets; +using GalNet.Core.Variants; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Shared.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +public sealed record SceneTargetChoice(SceneTarget Target, ResourcePickerItem Item); +public sealed record SceneTargetSnapshot(string GroupId, string EntryId, long Revision, bool Complete, IReadOnlyList Choices, Func? Filter = null, SceneTimelinePosition? Position = null, bool Playback = false); +public sealed record SceneFieldChoice(SceneTargetSnapshot Snapshot, SceneTargetChoice? Choice, ResourcePickerItem Item, string? EmptyLabelKey = null) +{ public override string ToString() => Item.Label; } +public sealed record VariantFieldSource(SceneTargetSnapshot Snapshot, ImageVariantsDefinition? Definition, string? Dimension); + +/// Editor adapter: stable locator choices resolve to opaque runtime handles only at commit. +public sealed class SceneTargetCatalog(ProjectSceneTargets query, IEditorDocumentService documents, IAssetManager assets, IEditorLocalizationService localization) +{ + private readonly Dictionary _paths = new(StringComparer.Ordinal); + public IEditorLocalizationService Text => localization; + public event Action? Changed { add => query.Changed += value; remove => query.Changed -= value; } + internal (object? Author, long Resources) PreparationStamp => query.PreparationStamp; + internal bool IsSamePreparation((object? Author, long Resources) stamp) + { var current = query.PreparationStamp; return query.IsAvailable && ReferenceEquals(current.Author, stamp.Author) && current.Resources == stamp.Resources; } + public VariantFieldSource? VariantsSource(IEntryFieldOrigin? origin) + { + if (origin?.GroupId is not { } groupId || !documents.Groups.TryGetValue(groupId, out var group) + || group.Entries.FirstOrDefault(entry => entry.StableId == origin.EntryId) is not { } entry) return null; + var snapshot = Query(groupId, entry.StableId); + string Read(string key) => entry.Parameters.TryGetValue(key, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() ?? "" : ""; + var id = entry.Type == BuiltinEntryIds.ShowVariants ? Read("assetId") : snapshot.Choices.FirstOrDefault(choice => choice.Target.Handle == Read("handleId"))?.Target.Variants is { Definite: true, AssetIds.Count: 1 } binding ? binding.AssetIds[0] : ""; + return new(snapshot, id.Length == 0 ? null : query.VariantDefinition(id), Read("dimension")); + } + public SceneTargetSnapshot Query(string groupId, string entryId, Func? filter = null, SceneTimelinePosition? position = null, bool playback = false) + { + var result = position is null ? query.Before(groupId, entryId) : query.AtTimeline(groupId, entryId, position); + var choices = (playback ? result.Playbacks : result.Targets).Where(target => target.Kind != SceneTargetKind.Unresolved && (filter?.Invoke(target) ?? true)).Select(target => + { + var group = documents.CurrentDocument.Nodes.FirstOrDefault(node => node.Id == target.Locator.GroupId); + var entry = documents.Groups.TryGetValue(target.Locator.GroupId, out var source) ? source.Entries.FirstOrDefault(item => item.StableId == target.Locator.EntryId) : null; + var origin = (group?.Name ?? "") + " / " + entry?.Id; + var resourceId = string.IsNullOrWhiteSpace(target.Resource) && target.Variants is { AssetIds.Count: 1 } variants ? variants.AssetIds[0] : target.Resource; + var resource = playback ? localization["Entry.Type." + target.Resource] : _paths.GetValueOrDefault(resourceId, resourceId); + var kind = localization[playback ? "Scene.Playback" : "Timeline.Kind." + target.Kind]; + var name = playback ? resource : string.IsNullOrWhiteSpace(resource) || Guid.TryParse(resource, out _) ? kind : Path.GetFileName(resource); + var detail = playback ? resource + " · " + target.Resource : resource; + if (target.Variants is { Definite: false }) detail += " · " + localization["Variants.AmbiguousSource"]; + if (target.CreatedAtFrame is { } created && !target.IsFuture) origin += " · " + localization.Format("Scene.CreatedAt", created); + var presence = target.IsFuture ? localization.Format("Scene.FutureAt", target.CreatedAtFrame) : localization[target.Presence == TargetPresence.Definite ? "Timeline.Definite" : "Timeline.Possible"]; + return new SceneTargetChoice(target, new(JsonSerializer.Serialize(target.Locator), name + " · " + origin, + kind + " · " + detail + " · " + origin + " · " + presence, + playback ? "▶" : target.Kind == SceneTargetKind.Particle ? "✦" : target.Kind == SceneTargetKind.Effect ? "◈" : "▣", Badge: presence)); + }).ToArray(); + return new(groupId, entryId, query.Revision, result.Complete, choices, filter, position, playback); + } + public async Task PrepareAsync(string groupId, string entryId, SceneTimelinePosition? position = null, CancellationToken token = default) + { + await query.PrepareAsync(token); + var resources = (position is null ? query.Before(groupId, entryId) : query.AtTimeline(groupId, entryId, position)).Targets + .SelectMany(target => new[] { target.Resource }.Concat(target.Variants?.AssetIds ?? [])).Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToArray(); + foreach (var resource in resources) + { + token.ThrowIfCancellationRequested(); + var file = await assets.GetFileAsync(resource, token); + if (file is not null) _paths[resource] = file.Path; + } + } + public bool IsCurrent(SceneTargetSnapshot snapshot) + { if (snapshot.Position is null) query.Before(snapshot.GroupId, snapshot.EntryId); else query.AtTimeline(snapshot.GroupId, snapshot.EntryId, snapshot.Position); return query.IsAvailable && query.Revision == snapshot.Revision; } + public SceneTarget? Resolve(SceneTargetSnapshot snapshot, string id) + { + if (!snapshot.Choices.Any(choice => choice.Item.Id == id) || !IsCurrent(snapshot)) return null; + return Query(snapshot.GroupId, snapshot.EntryId, snapshot.Filter, snapshot.Position, snapshot.Playback).Choices.FirstOrDefault(choice => choice.Item.Id == id)?.Target; + } + public IReadOnlyList? FieldOptions(string groupId, string entryId, string type, string field, SceneTimelinePosition? position = null) + { + var kinds = SceneFieldSemantics.TargetKinds(type, field); if (kinds is null) return null; + var snapshot = Query(groupId, entryId, target => kinds.Contains(target.Kind) && (type != BuiltinEntryIds.SelectVariants || target.Variants is not null), position, type == BuiltinEntryIds.StopAnimation); + var noneKey = type == BuiltinEntryIds.ApplyEffect ? "Scene.Global" : "Scene.None"; + var none = new ResourcePickerItem("none", localization[noneKey], "", "×"); + return new[] { new FieldEditorOption("none", new SceneFieldChoice(snapshot, null, none, noneKey), JsonSerializer.SerializeToElement("")) } + .Concat(snapshot.Choices.Select(choice => new FieldEditorOption(choice.Item.Id, new SceneFieldChoice(snapshot, choice, choice.Item), JsonSerializer.SerializeToElement(choice.Target.Handle)))).ToArray(); + } +} + +internal static class SceneFieldSemantics +{ + public static SceneTargetKind[]? TargetKinds(string type, string field) => (type, field) switch + { + (BuiltinEntryIds.StopAnimation, "playbackHandleId") => [SceneTargetKind.Playback], + (BuiltinEntryIds.HideLayer or BuiltinEntryIds.MoveLayer or BuiltinEntryIds.ReplaceLayer or BuiltinEntryIds.StopVideo or BuiltinEntryIds.SelectVariants, "handleId") => [SceneTargetKind.Layer], + (BuiltinEntryIds.Animate, "handleId") => [SceneTargetKind.Layer, SceneTargetKind.Particle, SceneTargetKind.Effect], + (BuiltinEntryIds.ApplyEffect, "targetHandleId") => [SceneTargetKind.Layer], + (BuiltinEntryIds.CrossFadeTransition or BuiltinEntryIds.BlindsTransition, "oldHandleId") => [SceneTargetKind.Layer], + (BuiltinEntryIds.SlideTransition or BuiltinEntryIds.BlackFadeTransition or BuiltinEntryIds.WhiteFadeTransition or BuiltinEntryIds.ColorFadeTransition, "fromLayerHandleId") => [SceneTargetKind.Layer], + (BuiltinEntryIds.StopEffect, "instanceId") => [SceneTargetKind.Effect], + (BuiltinEntryIds.StopParticleEmitter, "instanceId") => [SceneTargetKind.Particle], + _ => null + }; + public static bool IsOutput(string type, string field) => SceneAuthoringIdentities.IsOutput(type, field); + public static string? Label(string type, string field) => TargetKinds(type, field) is null ? null : type == BuiltinEntryIds.StopAnimation ? "Scene.Playback" : "Scene.Target"; +} diff --git a/src/Editor/GalNet.Editor/Services/SceneTargetSelection.cs b/src/Editor/GalNet.Editor/Services/SceneTargetSelection.cs new file mode 100644 index 00000000..035c40d4 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/SceneTargetSelection.cs @@ -0,0 +1,47 @@ +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Shared.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +/// One reusable selection lease. Consumers decide how the resolved handle is committed. +public sealed class SceneTargetSelection(SceneTargetCatalog catalog, SceneTargetSnapshot snapshot, string? noneLabelKey = null) : IDisposable +{ + private readonly CancellationTokenSource _lifetime = new(); + private bool _disposed; + public SceneTargetSnapshot Snapshot { get; private set; } = snapshot; + public IEditorLocalizationService Text => catalog.Text; + public string? SelectedId { get; private set; } + public SceneTarget? Target { get; private set; } + public bool IsCurrent => !_disposed && catalog.IsCurrent(Snapshot); + public IReadOnlyList Items => (noneLabelKey is null ? Array.Empty() : [new("none", Text[noneLabelKey], "", "×")]) + .Concat(Snapshot.Choices.Select(choice => choice.Item)).ToArray(); + public async Task PrepareAsync() + { + if (!IsCurrent) return; + var stamp = catalog.PreparationStamp; + await catalog.PrepareAsync(Snapshot.GroupId, Snapshot.EntryId, Snapshot.Position, _lifetime.Token); + if (!_disposed && catalog.IsSamePreparation(stamp)) + Snapshot = catalog.Query(Snapshot.GroupId, Snapshot.EntryId, Snapshot.Filter, Snapshot.Position, Snapshot.Playback); + } + public void RefreshLabels() + { + if (!IsCurrent) return; + Snapshot = catalog.Query(Snapshot.GroupId, Snapshot.EntryId, Snapshot.Filter, Snapshot.Position, Snapshot.Playback); + } + public bool Select(string id) + { + if (!IsCurrent) return false; + if (id == "none" && noneLabelKey is not null) { SelectedId = id; Target = null; return true; } + var resolved = catalog.Resolve(Snapshot, id); if (resolved is null) return false; + SelectedId = id; Target = resolved; return true; + } + public void Clear() { SelectedId = null; Target = null; } + public bool TryResolve(out string handle) + { + handle = ""; + if (SelectedId is null || !Select(SelectedId)) return false; + handle = Target?.Handle ?? ""; return true; + } + public void Dispose() { if (_disposed) return; _disposed = true; _lifetime.Cancel(); _lifetime.Dispose(); } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineCurveEditing.cs b/src/Editor/GalNet.Editor/Services/TimelineCurveEditing.cs new file mode 100644 index 00000000..439dd62b --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineCurveEditing.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using GalNet.Core.Scene; +using GalNet.Game.Controls.Timeline; + +namespace GalNet.Editor.Services; + +public sealed partial class TimelineEditSession +{ + private readonly Dictionary _curveViews = new(StringComparer.Ordinal); + private TimelineCurveViewport CurveView(TimelineTreeNode node, IReadOnlyList keys) + { + if (!_curveViews.TryGetValue(node.Key, out var viewport)) + { viewport = new(); viewport.Fit(keys); _curveViews.Add(node.Key, viewport); } + return viewport; + } + public void FitCurve(TimelineTreeNode node) + { + var index = VisibleNodes.IndexOf(node); + if (index < 0 || Canvas.Rows[index].Curve is not { } viewport) return; + Canvas.Cancel(); viewport.Fit(Canvas.Rows[index].Keys); Canvas.Changed(); + } + private JsonElement CurvePatch(TimelineCurveGesture gesture) + { + var patch = _moveDocument!.ChangeCurve(_moveKeys!, gesture); + var document = new TimelineDocument(patch); + foreach (var location in _moveKeys!) + { + var value = document.Tracks[location.Track].Keys.Single(key => key.Frame == location.Frame + gesture.FrameDelta).Value; + if (Rows.FirstOrDefault(row => row.TrackIndex == location.Track)?.Capability is { } capability + && document.Tracks[location.Track].BlendMode != AnimationBlendMode.Additive && !capability.Accepts(value)) + throw new InvalidDataException("Timeline.ValueRange"); + } + return patch; + } + private bool PreviewCurve(TimelineCurveGesture gesture) + { + if (!IsMoveCurrent) return false; + try + { + var document = new TimelineDocument(CurvePatch(gesture)); + Canvas.PreviewKeys = _keyIds.ToDictionary(pair => pair.Value, pair => + { + var frame = pair.Key.Frame + (_moveKeys!.Contains(pair.Key) ? gesture.FrameDelta : 0); + var key = document.Tracks[pair.Key.Track].Keys.Single(key => key.Frame == frame); + return CanvasKey(pair.Value, key); + }); + Message = ""; return true; + } + catch (Exception error) when (error is InvalidDataException or ArgumentException or OverflowException or InvalidOperationException) + { Canvas.PreviewKeys = new Dictionary(); EditError(error); return false; } + } + private bool CommitCurve(TimelineCurveGesture gesture) + { + if (!IsMoveCurrent) return false; + try + { + var locations = _moveKeys!; var patch = CurvePatch(gesture); + if (_baseline is { } original && JsonElement.DeepEquals(original, patch)) return true; + if (!Submit(patch)) return false; + Canvas.Select(locations.Select(location => _keyIds[new(location.Track, location.Frame + gesture.FrameDelta)])); return true; + } + catch (Exception error) when (error is InvalidDataException or ArgumentException or OverflowException or InvalidOperationException) + { EditError(error); return false; } + } + private static TimelineCanvasKey CanvasKey(Guid id, TimelineKeyframeDefinition key) => new(id, key.Frame, key.Value, key.InTangent, key.OutTangent, key.InterpolationToNext); + internal bool SetTangentMode(TimelineKeyEdit draft, bool? incoming, TimelineTangentMode mode, bool? linked = null) + { + if (!IsKeyEditCurrent(draft)) return false; + try + { + var patch = _document!.SetTangentMode(draft.Locations, incoming, mode, linked); + if (_baseline is { } original && JsonElement.DeepEquals(original, patch)) return true; + var locations = draft.Locations; + if (!Submit(patch)) return false; + Canvas.Select(locations.Select(location => _keyIds[location])); return true; + } + catch (Exception error) when (error is InvalidDataException or ArgumentException or OverflowException or InvalidOperationException) + { EditError(error); return false; } + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineDocument.Curves.cs b/src/Editor/GalNet.Editor/Services/TimelineDocument.Curves.cs new file mode 100644 index 00000000..37d16e89 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineDocument.Curves.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Scene; + +namespace GalNet.Editor.Services; + +public enum TimelineTangentPreset { Flat, Linear, Smooth, Aligned } + +internal sealed partial class TimelineDocument +{ + public JsonElement EditKeys(IEnumerable selected, float? value = null, float? incoming = null, + float? outgoing = null, AnimationInterpolation? interpolation = null, TimelineTangentPreset? preset = null) + { + var locations = Locations(selected); + if (locations.Length == 0 || new[] { value, incoming, outgoing }.Any(number => number is { } item && !float.IsFinite(item)) + || interpolation is { } mode && !Enum.IsDefined(mode) || preset is { } setting && !Enum.IsDefined(setting)) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); + foreach (var location in locations) + { + var definition = Tracks[location.Track]; var index = definition.Keys.FindIndex(key => key.Frame == location.Frame); + var key = (JsonObject)Array((JsonObject)Array(root, "tracks")[location.Track]!, "keys")[index]!; + void Set(string name, float number) { if (!float.IsFinite(number)) throw new InvalidDataException("Timeline.EditRejected"); key[Name(key, name)] = number; } + var settings = Settings(key); + if (incoming is not null || outgoing is not null || preset is not null) + { + var inputChanged = incoming is not null || preset is not null || settings.Linked && outgoing is not null; + var outputChanged = outgoing is not null || preset is not null || settings.Linked && incoming is not null; + if (key.ContainsKey(Name(key, "editorTangents"))) + WriteSettings(key, settings with { Incoming = inputChanged ? TimelineTangentMode.Free : settings.Incoming, Outgoing = outputChanged ? TimelineTangentMode.Free : settings.Outgoing }); + if (settings.Linked && preset is null) + { if (incoming is { } joinedIn) Set("outTangent", joinedIn); else if (outgoing is { } joinedOut) Set("inTangent", joinedOut); } + } + if (value is { } number) Set("value", number); + if (incoming is { } input) Set("inTangent", input); + if (outgoing is { } output) Set("outTangent", output); + if (interpolation is { } kind) key[Name(key, "interpolationToNext")] = kind.ToString(); + if (preset is { } action) + { + var current = definition.Keys[index]; var previous = index > 0 ? definition.Keys[index - 1] : current; + var next = index + 1 < definition.Keys.Count ? definition.Keys[index + 1] : current; + float Slope(TimelineKeyframeDefinition left, TimelineKeyframeDefinition right) => left.Frame == right.Frame ? 0 : (float)(((double)right.Value - left.Value) / (right.Frame - left.Frame)); + var leftSlope = index > 0 ? Slope(previous, current) : Slope(current, next); + var rightSlope = index + 1 < definition.Keys.Count ? Slope(current, next) : leftSlope; + var smooth = Slope(previous, next); + var aligned = (float)(((double)current.InTangent + current.OutTangent) / 2); + Set("inTangent", action switch { TimelineTangentPreset.Flat => 0, TimelineTangentPreset.Linear => leftSlope, TimelineTangentPreset.Smooth => smooth, _ => aligned }); + Set("outTangent", action switch { TimelineTangentPreset.Flat => 0, TimelineTangentPreset.Linear => rightSlope, TimelineTangentPreset.Smooth => smooth, _ => aligned }); + key[Name(key, "interpolationToNext")] = AnimationInterpolation.CubicHermite.ToString(); + } + } + return FinishCurvePatch(root); + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineDocument.Events.cs b/src/Editor/GalNet.Editor/Services/TimelineDocument.Events.cs new file mode 100644 index 00000000..7e296436 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineDocument.Events.cs @@ -0,0 +1,65 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace GalNet.Editor.Services; + +internal sealed record TimelineEventItem(int Index, int Frame, string Type, JsonElement Raw); + +internal sealed partial class TimelineDocument +{ + public IReadOnlyList Events { get; } + private IReadOnlyList ReadEvents() => System.Array.AsReadOnly(Array(_root, "events").Select((node, index) => + { + var item = node as JsonObject ?? throw new InvalidDataException("Timeline.Invalid"); + var type = item[Name(item, "type")] is JsonValue value && value.TryGetValue(out var text) ? text ?? "" : ""; + return new TimelineEventItem(index, Integer(item, "frame", 0), type, JsonSerializer.SerializeToElement(item)); + }).ToArray()); + public JsonElement AddEvent(string type, int frame, Dictionary parameters) + { + if (!GalNet.Contracts.Builtins.TimelineEventCapabilities.Supports(type) || frame < 0 || frame > Duration) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); var name = Name(root, "events"); + if (!root.ContainsKey(name)) root[name] = new JsonArray(); + Array(root, "events").Add(new JsonObject { ["frame"] = frame, ["type"] = type, ["editorId"] = Guid.NewGuid().ToString("N"), ["parameters"] = JsonSerializer.SerializeToNode(parameters) }); + return JsonSerializer.SerializeToElement(root); + } + public JsonElement MoveEvents(IEnumerable indices, int delta) + { + var selected = indices.Distinct().ToArray(); var events = Events; + ValidateEventMove(selected, delta); + var root = (JsonObject)_root.DeepClone(); var items = Array(root, "events"); var duration = Duration; + foreach (var index in selected) { var item = (JsonObject)items[index]!; var frame = checked(events[index].Frame + delta); item[Name(item, "frame")] = frame; duration = Math.Max(duration, frame); } + root[Name(root, "durationFrames")] = duration; + return JsonSerializer.SerializeToElement(root); + } + public void ValidateEventMove(IEnumerable indices, int delta) + { + var selected = indices.Distinct().ToArray(); + if (selected.Length == 0 || selected.Any(index => index < 0 || index >= Events.Count || (long)Events[index].Frame + delta < 0 || (long)Events[index].Frame + delta > int.MaxValue)) throw new InvalidDataException("Timeline.EditRejected"); + } + public JsonElement DeleteEvents(IEnumerable indices) + { + var selected = indices.Distinct().OrderDescending().ToArray(); + if (selected.Any(index => index < 0 || index >= Events.Count)) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); foreach (var index in selected) Array(root, "events").RemoveAt(index); + return JsonSerializer.SerializeToElement(root); + } + public JsonElement OrderEvent(int index, int direction) + { + var events = Events; if (index < 0 || index >= events.Count || direction is not (-1 or 1)) throw new InvalidDataException("Timeline.EditRejected"); + var peers = events.Where(item => item.Frame == events[index].Frame).Select(item => item.Index).ToArray(); + var position = System.Array.IndexOf(peers, index); var next = position + direction; + if (next < 0 || next >= peers.Length) return JsonSerializer.SerializeToElement(_root); + var root = (JsonObject)_root.DeepClone(); var items = Array(root, "events"); var other = peers[next]; + var left = items[index]!.DeepClone(); var right = items[other]!.DeepClone(); items[index] = right; items[other] = left; + return JsonSerializer.SerializeToElement(root); + } + public JsonElement PatchEvent(int index, IReadOnlyDictionary patch) + { + if (index < 0 || index >= Events.Count) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); var item = (JsonObject)Array(root, "events")[index]!; var name = Name(item, "parameters"); + if (!item.ContainsKey(name)) item[name] = new JsonObject(); + var parameters = item[name] as JsonObject ?? throw new InvalidDataException("Timeline.EditRejected"); + foreach (var (key, value) in patch) { if (value.Json is { } json) parameters[key] = JsonNode.Parse(json.GetRawText()); else parameters.Remove(key); } + return JsonSerializer.SerializeToElement(root); + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineDocument.Keys.cs b/src/Editor/GalNet.Editor/Services/TimelineDocument.Keys.cs new file mode 100644 index 00000000..0bfd1cae --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineDocument.Keys.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Scene; + +namespace GalNet.Editor.Services; + +internal readonly record struct TimelineKeyLocation(int Track, int Frame); + +internal sealed partial class TimelineDocument +{ + private TimelineKeyLocation[] Locations(IEnumerable selected) + { + var keys = selected.Distinct().ToArray(); + if (keys.Any(key => key.Track < 0 || key.Track >= Tracks.Count || !Tracks[key.Track].Keys.Any(item => item.Frame == key.Frame))) throw new InvalidDataException("Timeline.EditRejected"); + return keys; + } + public JsonElement MoveKeys(IEnumerable selected, int delta, bool replace) + { + var keys = Locations(selected); + ValidateMove(keys, delta, replace); + if (delta == 0) return JsonSerializer.SerializeToElement(_root); + var root = (JsonObject)_root.DeepClone(); + foreach (var group in keys.GroupBy(key => key.Track)) + { + var frames = group.Select(key => key.Frame).ToHashSet(); var destinations = frames.Select(frame => frame + delta).ToHashSet(); + var track = (JsonObject)Array(root, "tracks")[group.Key]!; var array = Array(track, "keys"); + var nodes = array.OfType().Where(key => !replace || frames.Contains(Integer(key, "frame", 0)) || !destinations.Contains(Integer(key, "frame", 0))).Select(key => (JsonObject)key.DeepClone()).ToArray(); + foreach (var key in nodes) { var frame = Integer(key, "frame", 0); if (frames.Contains(frame)) key[Name(key, "frame")] = frame + delta; } + array.Clear(); foreach (var key in nodes.OrderBy(key => Integer(key, "frame", 0))) array.Add(key); + } + root[Name(root, "durationFrames")] = Math.Max(Duration, keys.Select(key => key.Frame + delta).DefaultIfEmpty(0).Max()); + return FinishCurvePatch(root); + } + public void ValidateMove(IEnumerable selected, int delta, bool replace) + { + var keys = Locations(selected); + if (delta == 0) return; + if (keys.Any(key => key.Frame == 0)) throw new InvalidDataException("Timeline.KeyProtected"); + if (keys.Any(key => (long)key.Frame + delta < 0 || (long)key.Frame + delta > int.MaxValue)) throw new InvalidDataException("Timeline.KeyOutside"); + foreach (var group in keys.GroupBy(key => key.Track)) + { + var frames = group.Select(key => key.Frame).ToHashSet(); var destinations = frames.Select(frame => frame + delta).ToHashSet(); + var collision = Tracks[group.Key].Keys.Where(key => !frames.Contains(key.Frame) && destinations.Contains(key.Frame)).ToArray(); + if (collision.Any(key => key.Frame == 0)) throw new InvalidDataException("Timeline.KeyProtected"); + if (!replace && collision.Length != 0) throw new InvalidDataException("Timeline.KeyCollision"); + } + } + public JsonElement DeleteKeys(IEnumerable selected) + { + var keys = Locations(selected); + if (keys.Any(key => key.Frame == 0)) throw new InvalidDataException("Timeline.KeyProtected"); + var root = (JsonObject)_root.DeepClone(); + foreach (var group in keys.GroupBy(key => key.Track)) + { + var frames = group.Select(key => key.Frame).ToHashSet(); var array = Array((JsonObject)Array(root, "tracks")[group.Key]!, "keys"); + for (var index = array.Count - 1; index >= 0; index--) if (frames.Contains(Integer((JsonObject)array[index]!, "frame", 0))) array.RemoveAt(index); + } + return FinishCurvePatch(root); + } + public JsonElement AddKeys(IEnumerable tracks, int frame) + { + var result = JsonSerializer.SerializeToElement(_root); + foreach (var index in tracks.Distinct()) + { + if (index < 0 || index >= Tracks.Count) throw new InvalidDataException("Timeline.EditRejected"); + if (!Tracks[index].Keys.Any(key => key.Frame == frame)) result = new TimelineDocument(result).Upsert(index, frame, TimelineSampler.Evaluate(Tracks[index], frame)); + } + return result; + } + public JsonElement ChangeTiming(int duration, int fps) + { + if (duration < 0 || fps is < 1 or > 240) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); + if (fps != FrameRate) root[Name(root, "frameRate")] = fps; + duration = Math.Max(duration, MinimumDuration); + if (duration != Duration) root[Name(root, "durationFrames")] = duration; + return JsonSerializer.SerializeToElement(root); + } + public JsonElement ChangeBlendMode(int index, bool additive) + { + if (index < 0 || index >= Tracks.Count) throw new InvalidDataException("Timeline.EditRejected"); + if ((Tracks[index].BlendMode == AnimationBlendMode.Additive) == additive) return JsonSerializer.SerializeToElement(_root); + var root = (JsonObject)_root.DeepClone(); + var track = (JsonObject)Array(root, "tracks")[index]!; + track[Name(track, "blendMode")] = additive ? "Additive" : "Replace"; + return JsonSerializer.SerializeToElement(root); + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineDocument.TangentModes.cs b/src/Editor/GalNet.Editor/Services/TimelineDocument.TangentModes.cs new file mode 100644 index 00000000..b6a2be91 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineDocument.TangentModes.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using GalNet.Core.Scene; +using GalNet.Game.Controls.Timeline; + +namespace GalNet.Editor.Services; + +public enum TimelineTangentMode { Free, Flat, Linear, Auto } +public sealed record TimelineTangentSettings(TimelineTangentMode Incoming, TimelineTangentMode Outgoing, bool Linked); + +internal sealed partial class TimelineDocument +{ + private static TimelineTangentSettings Settings(JsonObject key) + { + if (!key.TryGetPropertyValue(Name(key, "editorTangents"), out var node)) return new(TimelineTangentMode.Free, TimelineTangentMode.Free, false); + if (node is not JsonObject modes) throw new InvalidDataException("Timeline.Invalid"); + TimelineTangentMode Read(string name) => !modes.TryGetPropertyValue(Name(modes, name), out var value) ? TimelineTangentMode.Free + : value is JsonValue item && item.TryGetValue(out var text) && Enum.TryParse(text, out var mode) && Enum.IsDefined(mode) + ? mode : throw new InvalidDataException("Timeline.Invalid"); + var linked = modes.TryGetPropertyValue(Name(modes, "linked"), out var flag) && (flag?.GetValue() ?? throw new InvalidDataException("Timeline.Invalid")); + return new(Read("incoming"), Read("outgoing"), linked); + } + public TimelineTangentSettings TangentSettings(TimelineKeyLocation location) + { + var index = Tracks[location.Track].Keys.FindIndex(key => key.Frame == location.Frame); + return Settings((JsonObject)Array((JsonObject)Array(_root, "tracks")[location.Track]!, "keys")[index]!); + } + private static void WriteSettings(JsonObject key, TimelineTangentSettings settings) + { + var name = Name(key, "editorTangents"); + if (!key.ContainsKey(name)) key[name] = new JsonObject(); + var modes = key[name] as JsonObject ?? throw new InvalidDataException("Timeline.Invalid"); + modes[Name(modes, "incoming")] = settings.Incoming.ToString(); + modes[Name(modes, "outgoing")] = settings.Outgoing.ToString(); + modes[Name(modes, "linked")] = settings.Linked; + } + private static void RecalculateTangents(JsonObject root) + { + foreach (var track in Array(root, "tracks").OfType()) + { + var keys = Array(track, "keys"); + var definitions = keys.Select(key => key!.Deserialize(Options)!).ToArray(); + for (var index = 0; index < keys.Count; index++) + { + var raw = (JsonObject)keys[index]!; var settings = Settings(raw); + var current = definitions[index]; var previous = definitions[Math.Max(0, index - 1)]; var next = definitions[Math.Min(keys.Count - 1, index + 1)]; + double Slope(TimelineKeyframeDefinition left, TimelineKeyframeDefinition right) => left.Frame == right.Frame ? 0 : ((double)right.Value - left.Value) / (right.Frame - left.Frame); + var left = index > 0 ? Slope(previous, current) : Slope(current, next); + var right = index + 1 < keys.Count ? Slope(current, next) : left; + var automatic = Slope(previous, next); + if (index > 0 && index + 1 < keys.Count) + automatic = Math.Sign(left) != Math.Sign(right) ? 0 : Math.CopySign(Math.Min(Math.Abs(automatic), 3 * Math.Min(Math.Abs(left), Math.Abs(right))), automatic); + void Set(string field, TimelineTangentMode mode, double linear) + { + if (mode == TimelineTangentMode.Free) return; + var value = (float)(mode == TimelineTangentMode.Flat ? 0 : mode == TimelineTangentMode.Linear ? linear : automatic); + if (!float.IsFinite(value)) throw new InvalidDataException("Timeline.EditRejected"); + raw[Name(raw, field)] = value; + } + Set("inTangent", settings.Incoming, left); Set("outTangent", settings.Outgoing, right); + } + } + } + private static JsonElement FinishCurvePatch(JsonObject root) + { + RecalculateTangents(root); + var result = JsonSerializer.SerializeToElement(root); _ = new TimelineDocument(result); return result; + } + public JsonElement SetTangentMode(IEnumerable selected, bool? incoming, TimelineTangentMode mode, bool? linked = null) + { + if (!Enum.IsDefined(mode)) throw new InvalidDataException("Timeline.EditRejected"); + var root = (JsonObject)_root.DeepClone(); + foreach (var location in Locations(selected)) + { + var keys = Array((JsonObject)Array(root, "tracks")[location.Track]!, "keys"); + var index = Tracks[location.Track].Keys.FindIndex(key => key.Frame == location.Frame); var key = (JsonObject)keys[index]!; + var settings = Settings(key); + if (linked is { } joining) + { + settings = settings with { Linked = joining }; + if (joining) + { + var definition = Tracks[location.Track].Keys[index]; + var tangent = (float)(((double)definition.InTangent + definition.OutTangent) / 2); + key[Name(key, "inTangent")] = tangent; key[Name(key, "outTangent")] = tangent; + settings = new(TimelineTangentMode.Free, TimelineTangentMode.Free, true); + } + } + else + { + settings = new(incoming != false ? mode : settings.Incoming, incoming != true ? mode : settings.Outgoing, incoming is null && mode is TimelineTangentMode.Auto or TimelineTangentMode.Flat); + if (incoming != false && index > 0) ((JsonObject)keys[index - 1]!)[Name((JsonObject)keys[index - 1]!, "interpolationToNext")] = "CubicHermite"; + if (incoming != true) key[Name(key, "interpolationToNext")] = "CubicHermite"; + } + WriteSettings(key, settings); + } + return FinishCurvePatch(root); + } + public JsonElement ChangeCurve(IEnumerable selected, TimelineCurveGesture gesture) + { + var locations = Locations(selected); + var moved = gesture.FrameDelta == 0 ? this : new TimelineDocument(MoveKeys(locations, gesture.FrameDelta, false)); + var root = (JsonObject)moved._root.DeepClone(); + foreach (var location in locations) + { + var keys = Array((JsonObject)Array(root, "tracks")[location.Track]!, "keys"); + var index = moved.Tracks[location.Track].Keys.FindIndex(key => key.Frame == location.Frame + gesture.FrameDelta); var raw = (JsonObject)keys[index]!; + var definition = moved.Tracks[location.Track].Keys[index]; + if (gesture.Tangent is { } tangent) + { + if (!float.IsFinite(tangent)) throw new InvalidDataException("Timeline.EditRejected"); + var settings = Settings(raw); + var incoming = gesture.Incoming || settings.Linked; var outgoing = !gesture.Incoming || settings.Linked; + if (incoming) { raw[Name(raw, "inTangent")] = tangent; if (index > 0) ((JsonObject)keys[index - 1]!)[Name((JsonObject)keys[index - 1]!, "interpolationToNext")] = "CubicHermite"; } + if (outgoing) { raw[Name(raw, "outTangent")] = tangent; raw[Name(raw, "interpolationToNext")] = "CubicHermite"; } + WriteSettings(raw, settings with { Incoming = incoming ? TimelineTangentMode.Free : settings.Incoming, Outgoing = outgoing ? TimelineTangentMode.Free : settings.Outgoing }); + } + else + { + var value = (float)((double)definition.Value + gesture.ValueDelta); + if (!float.IsFinite(value)) throw new InvalidDataException("Timeline.EditRejected"); + raw[Name(raw, "value")] = value; + } + } + return FinishCurvePatch(root); + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineDocument.cs b/src/Editor/GalNet.Editor/Services/TimelineDocument.cs new file mode 100644 index 00000000..4b6b6292 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineDocument.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using GalNet.Core.Scene; + +namespace GalNet.Editor.Services; + +/// Known-value projection of raw author JSON. Patches always retain the original objects. +internal sealed partial class TimelineDocument +{ + private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }; + private readonly JsonObject _root; + public int Duration { get; } + public int FrameRate { get; } + public IReadOnlyList Tracks { get; } + public bool HasEvents { get; } + public int MinimumDuration { get; } + public TimelineDocument(JsonElement value) + { + _root = JsonNode.Parse(value.GetRawText()) as JsonObject ?? throw new InvalidDataException("Timeline.Invalid"); + FrameRate = Integer(_root, "frameRate", 60); + Duration = Integer(_root, "durationFrames", 0); + HasEvents = Array(_root, "events").Count > 0; + if (FrameRate is < 1 or > 240 || Duration < 0) throw new InvalidDataException("Timeline.Invalid"); + var tracks = Array(_root, "tracks"); + Tracks = tracks.Select(node => node?.Deserialize(Options) ?? throw new InvalidDataException("Timeline.Invalid")).ToArray(); + var targets = new HashSet<(string, string)>(); + foreach (var track in Tracks) + { + if (string.IsNullOrWhiteSpace(track.HandleId) || string.IsNullOrWhiteSpace(track.Property) || !Enum.IsDefined(track.BlendMode) + || !targets.Add((track.HandleId, track.Property)) || track.Keys is not { Count: > 0 } || track.Keys[0] is null || track.Keys[0].Frame != 0) + throw new InvalidDataException("Timeline.Invalid"); + var previous = -1; + foreach (var key in track.Keys) + { + if (key is null || key.Frame <= previous || key.Frame > Duration || !Enum.IsDefined(key.InterpolationToNext) + || !float.IsFinite(key.Value) || !float.IsFinite(key.InTangent) || !float.IsFinite(key.OutTangent)) throw new InvalidDataException("Timeline.Invalid"); + previous = key.Frame; + } + } + foreach (var track in tracks.OfType()) foreach (var key in Array(track, "keys").OfType()) _ = Settings(key); + Events = ReadEvents(); + var eventFrames = Events.Select(item => item.Frame).ToArray(); + if (eventFrames.Any(frame => frame < 0 || frame > Duration)) throw new InvalidDataException("Timeline.Invalid"); + MinimumDuration = Math.Max(Tracks.SelectMany(track => track.Keys).Select(key => key.Frame).DefaultIfEmpty(0).Max(), eventFrames.DefaultIfEmpty(0).Max()); + } + private static string Name(JsonObject obj, string name) + { + var matches = obj.Select(pair => pair.Key).Where(key => key.Equals(name, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (matches.Length > 1) throw new InvalidDataException("Timeline.Invalid"); + return matches.SingleOrDefault() ?? name; + } + private static int Integer(JsonObject obj, string name, int fallback) => obj.TryGetPropertyValue(Name(obj, name), out var value) + ? value?.GetValue() ?? throw new InvalidDataException("Timeline.Invalid") : fallback; + private static JsonArray Array(JsonObject obj, string name) => !obj.TryGetPropertyValue(Name(obj, name), out var node) ? [] : node as JsonArray ?? throw new InvalidDataException("Timeline.Invalid"); + public JsonElement Add(string handle, string property, int frame, float value, float? baseline, bool additive) + { + if (Tracks.Any(track => track.HandleId == handle && track.Property == property) || string.IsNullOrWhiteSpace(handle) + || frame < 0 || frame > Duration || !float.IsFinite(value)) throw new InvalidDataException("Timeline.Invalid"); + var initial = frame == 0 ? value : additive ? 0 : baseline ?? value; + var root = (JsonObject)_root.DeepClone(); + var name = Name(root, "tracks"); + if (!root.ContainsKey(name)) root[name] = new JsonArray(); + var keys = new JsonArray(new JsonObject { ["frame"] = 0, ["value"] = initial, ["interpolationToNext"] = "Linear" }); + if (frame > 0) keys.Add(new JsonObject { ["frame"] = frame, ["value"] = value, ["interpolationToNext"] = "Linear" }); + Array(root, "tracks").Add(new JsonObject { ["handleId"] = handle, ["property"] = property, ["blendMode"] = additive ? "Additive" : "Replace", ["keys"] = keys }); + return JsonSerializer.SerializeToElement(root); + } + public JsonElement Remove(int index) + => RemoveTracks([index]); + public JsonElement RemoveTracks(IEnumerable indices) + { + var selected = indices.Distinct().OrderDescending().ToArray(); + if (selected.Any(index => index < 0 || index >= Tracks.Count)) throw new InvalidDataException("Timeline.Invalid"); + var root = (JsonObject)_root.DeepClone(); + foreach (var index in selected) Array(root, "tracks").RemoveAt(index); + return JsonSerializer.SerializeToElement(root); + } + public JsonElement Upsert(int index, int frame, float value) + { + if (index < 0 || index >= Tracks.Count || frame < 0 || frame > Duration || !float.IsFinite(value)) throw new InvalidDataException("Timeline.Invalid"); + var root = (JsonObject)_root.DeepClone(); + var track = (JsonObject)Array(root, "tracks")[index]!; + var keys = Array(track, "keys"); + var definition = Tracks[index]; + var found = definition.Keys.FindIndex(key => key.Frame == frame); + if (found >= 0) + { + var key = (JsonObject)keys[found]!; + key[Name(key, "value")] = value; + } + else + { + var position = definition.Keys.FindIndex(key => key.Frame > frame); + if (position < 0) position = keys.Count; + var previous = definition.Keys[position - 1]; + // Derivative is in value/frame, matching the runtime Hermite sampler. + var tangent = Derivative(definition, frame); + keys.Insert(position, new JsonObject + { + ["frame"] = frame, + ["value"] = value, + ["inTangent"] = tangent, + ["outTangent"] = tangent, + ["interpolationToNext"] = previous.InterpolationToNext.ToString() + }); + } + return FinishCurvePatch(root); + } + private static float Derivative(TimelineTrackDefinition track, int frame) + { + if (frame >= track.Keys[^1].Frame) return 0; + var endIndex = track.Keys.FindIndex(key => key.Frame > frame); + var start = track.Keys[endIndex - 1]; var end = track.Keys[endIndex]; + var span = end.Frame - start.Frame; var t = (float)(frame - start.Frame) / span; + return start.InterpolationToNext switch + { + AnimationInterpolation.Step => 0, + AnimationInterpolation.Linear => (end.Value - start.Value) / span, + _ => ((6 * t * t - 6 * t) * start.Value + (3 * t * t - 4 * t + 1) * start.OutTangent * span + + (-6 * t * t + 6 * t) * end.Value + (3 * t * t - 2 * t) * end.InTangent * span) / span + }; + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineEditSession.cs b/src/Editor/GalNet.Editor/Services/TimelineEditSession.cs new file mode 100644 index 00000000..02a222fe --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineEditSession.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Contracts.Builtins; +using GalNet.Core.Entry; +using GalNet.Core.Scene; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +/// Project-owned Timeline binding. Rows capture identity, frame and revision, never a page. +public sealed partial class TimelineEditSession : ObservableObject, IDisposable +{ + private readonly ProjectSceneTargets _sceneQuery; + private readonly SceneTargetCatalog _targets; + private SceneTargetResult _scene = new([], false, false); + private string? _sceneSignature; + private long _sceneRevision; + private readonly EntrySelectionContext _selection; + private readonly IEditorDocumentService _documents; + private readonly EntryFieldAuthoringService _authoring; + private readonly IEntryCatalog _catalog; + private readonly IEditorLocalizationService _localization; + private readonly EditorHistories _histories; + private string? _type; + private EditorEntryData? _entry; + private TimelineDocument? _document; + private ActiveEntrySelection? _target; + private bool _disposed; + private int _generation; + private int _frame; + private JsonElement? _baseline; + public ObservableCollection Rows { get; } = []; + [ObservableProperty] private string _statusKey = "Timeline.SelectEntry"; + [ObservableProperty] private string _message = ""; + [ObservableProperty] private string _source = ""; + [ObservableProperty] private bool _canCreate; + [ObservableProperty] private bool _canEdit; + [ObservableProperty] private int _duration; + [ObservableProperty] private int _frameRate = 60; + public bool HasNoRows => CanEdit && VisibleNodes.Count == 0; + public bool AnalysisIncomplete => CanEdit && !_scene.Complete; + public int Frame + { + get => _frame; + set + { + var next = Math.Clamp(value, 0, Duration); + if (SetProperty(ref _frame, next)) { Canvas.Cancel(); BuildRows(); } + } + } + public TimelineEditSession(EntrySelectionContext selection, IEditorDocumentService documents, EntryFieldAuthoringService authoring, + IEntryCatalog catalog, IEditorLocalizationService localization, EditorHistories histories, ProjectSceneTargets sceneQuery, SceneTargetCatalog targets) + { + _selection = selection; _documents = documents; _authoring = authoring; _catalog = catalog; _localization = localization; _histories = histories; + _sceneQuery = sceneQuery; + _targets = targets; + InitializeCanvas(); + selection.Changed += OnSelection; documents.DocumentChanged += Refresh; histories.Changed += Refresh; + _sceneQuery.Changed += Refresh; + localization.PropertyChanged += OnLocale; + InitializeEvents(); + Refresh(); + } + private void OnSelection() { ClearObjectDrafts(); _frame = 0; OnPropertyChanged(nameof(Frame)); Refresh(); } + private void OnLocale(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName != "Item[]") return; + foreach (var row in Rows) row.RefreshLabel(); + foreach (var node in Tree.SelectMany(node => node.Descendants())) node.RefreshLabel(); + foreach (var row in Events) row.RefreshLabel(); + if (Message.Length != 0) Message = _localization["Timeline.EditRejected"]; + } + private EditorEntryData? Find(ActiveEntrySelection? target) => !_documents.IsLoaded || target?.EntryId is null + || !_documents.CurrentDocument.Nodes.Any(node => node.Id == target.GroupId && node.Type == "Group") + || !_documents.Groups.TryGetValue(target.GroupId, out var group) ? null : group.Entries.SingleOrDefault(entry => entry.StableId == target.EntryId); + private void Refresh() + { + if (_disposed) return; + var next = Find(_selection.Current); + var root = next?.Parameters.TryGetValue("timeline", out var value) == true ? (JsonElement?)value.Clone() : null; + var scene = next is null ? new SceneTargetResult([], false, false) : _sceneQuery.Before(_selection.Current!.GroupId, next.StableId); + if (next?.Type == BuiltinEntryIds.PlayTimeline) scene = _sceneQuery.AtTimeline(_selection.Current!.GroupId, next.StableId, new(Frame, IncludeFuture: true)); + var sceneSignature = JsonSerializer.Serialize(scene); + var sameValue = _baseline is { } previous ? root is { } current && JsonElement.DeepEquals(previous, current) : root is null; + if (_target == _selection.Current && ReferenceEquals(_entry, next) && _type == next?.Type && sameValue && sceneSignature == _sceneSignature && _sceneRevision == _sceneQuery.Revision) + { RefreshSource(); foreach (var node in Tree.SelectMany(node => node.Descendants())) node.RefreshLabel(); return; } + EndObjectEdit(); _scene = scene; _sceneSignature = sceneSignature; _sceneRevision = _sceneQuery.Revision; + SaveExpansion(); DetachTree(); InvalidateKeyDocument(); _generation++; Rows.Clear(); Tree.Clear(); _document = null; _baseline = null; Message = ""; + InvalidateEvents(); + _target = _selection.Current; _entry = next; _type = next?.Type; _baseline = root; + CanCreate = false; CanEdit = false; Duration = 0; FrameRate = 60; + RefreshSource(); + StatusKey = _entry is null ? "Timeline.SelectEntry" : "Timeline.Unsupported"; + if (_entry?.Type != BuiltinEntryIds.PlayTimeline || !_catalog.TryGet(_entry.Type, out var schema) + || schema.DynamicParameters?.TryGetValue("timeline", out var field) != true || field.Value.ValueType != typeof(JsonElement)) + { NotifyState(); return; } + if (!_entry.Parameters.TryGetValue("timeline", out var json) || json.ValueKind == JsonValueKind.Null) + { CanCreate = true; StatusKey = "Timeline.Missing"; NotifyState(); return; } + _baseline = json.Clone(); + try + { + _document = new(json); Duration = _document.Duration; FrameRate = _document.FrameRate; CanEdit = true; StatusKey = "Timeline.Editing"; + _frame = Math.Clamp(_frame, 0, Duration); OnPropertyChanged(nameof(Frame)); BuildRows(); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) + { CanEdit = false; _document = null; Rows.Clear(); Tree.Clear(); StatusKey = "Timeline.Invalid"; NotifyState(); } + } + private void RefreshSource() => Source = _entry is null ? "" : + (_documents.CurrentDocument.Nodes.Single(node => node.Id == _target!.GroupId).Name) + " / " + _entry.Id; + private void BuildRows() + { + EndObjectEdit(); + var zero = new SceneTargetResult([], false, false); + if (_document is not null && CanEdit && _target?.EntryId is { } id) + { + _scene = _sceneQuery.AtTimeline(_target.GroupId, id, new(Frame, IncludeFuture: true)); + _sceneSignature = JsonSerializer.Serialize(_scene); + zero = Frame == 0 ? _scene : _sceneQuery.AtTimeline(_target.GroupId, id, new(0)); + } + PruneObjectDrafts(); + SaveExpansion(); DetachTree(); _generation++; Rows.Clear(); Tree.Clear(); + if (_document is not null && CanEdit) + { + var objects = _document.Tracks.Select(track => track.HandleId).Distinct(StringComparer.Ordinal).ToArray(); + for (var index = 0; index < _document.Tracks.Count; index++) + { + var track = _document.Tracks[index]; + var value = TimelineSampler.Evaluate(track, Frame); + var target = _scene.Targets.FirstOrDefault(item => item.Handle == track.HandleId); + Rows.Add(new(this, _generation, index, Frame, System.Array.IndexOf(objects, track.HandleId) + 1, track.Property, + track.BlendMode == AnimationBlendMode.Additive, track.Keys.Any(key => key.Frame == Frame), value, _localization, track.HandleId, target)); + } + foreach (var target in _scene.Targets) + foreach (var property in target.Properties.Where(property => !_document.Tracks.Any(track => track.HandleId == target.Handle && track.Property == property.Name))) + { + float? value = target.Presence == TargetPresence.Definite && target.Values.TryGetValue(property.Name, out var initial) && property.Accepts(initial) ? initial : null; + Rows.Add(new(this, _generation, -1, Frame, 0, property.Name, false, false, value, _localization, target.Handle, target) + { + InitialValue = zero.Targets.FirstOrDefault(item => item.Handle == target.Handle && item.Presence == TargetPresence.Definite) is { } origin + && origin.Values.TryGetValue(property.Name, out var baseline) && property.Accepts(baseline) ? baseline : null + }); + } + BuildTree(); + } + SyncCanvasDocument(); SyncEvents(); NotifyState(); + } + public void Create() + { + if (!CanCreate || _disposed || !ReferenceEquals(_entry, Find(_target))) return; + Submit(JsonSerializer.SerializeToElement(new + { + playbackHandleId = Guid.NewGuid().ToString("N"), + frameRate = 60, + durationFrames = 60, + tracks = System.Array.Empty(), + events = System.Array.Empty() + })); + } + internal bool Commit(TimelineValueRow row, int generation, int index, int frame, decimal? value) + { + if (_disposed || generation != _generation || frame != Frame || !Rows.Contains(row) || _document is null || value is null) return false; + try + { + if (row.Capability is { } capability && !row.IsAdditive && !capability.Accepts((float)value.Value)) { Message = _localization["Timeline.ValueRange"]; return false; } + return Submit(index >= 0 ? _document.Upsert(index, frame, (float)value.Value) : _document.Add(row.Handle, row.Property, frame, (float)value.Value, row.Baseline, row.IsAdditive)); + } + catch (Exception error) when (error is JsonException or InvalidOperationException or ArgumentException or InvalidDataException or OverflowException) + { Message = _localization["Timeline.EditRejected"]; return false; } + } + private bool Submit(JsonElement value) + { + if (_target?.EntryId is { } entryId) _sceneQuery.Before(_target.GroupId, entryId); + if (_sceneRevision != _sceneQuery.Revision) { Refresh(); return false; } + if (_entry is null || !ReferenceEquals(_entry, Find(_target)) || _entry.Type != BuiltinEntryIds.PlayTimeline || _selection.Current != _target) return false; + var current = _entry.Parameters.TryGetValue("timeline", out var json) ? (JsonElement?)json : null; + if (_baseline is { } expected ? current is not { } actual || !JsonElement.DeepEquals(expected, actual) + : current is { ValueKind: not JsonValueKind.Null }) { Refresh(); return false; } + var parameters = _entry.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value.Clone(), StringComparer.Ordinal); + parameters["timeline"] = value.Clone(); + try + { + _catalog.Get(_entry.Type).DynamicParameters!.ValidatePartialArguments(JsonSerializer.SerializeToElement(parameters)); + _authoring.SetParameters(_target!.GroupId, _entry, _entry.Type, parameters); return true; + } + catch (Exception error) { Message = EditorText.Error(error).Render(_localization); return false; } + } + public void CancelInput() { if (!_disposed) { Canvas.Cancel(); CancelTimingInput(false); CancelTimingInput(true); BuildRows(); } } + public void Dispose() + { + DisposeEvents(); + Canvas.PropertyChanged -= OnCanvasSelection; KeyEdit = null; + if (_disposed) return; EndObjectEdit(); _disposed = true; DetachTree(); InvalidateKeyDocument(); _generation++; Rows.Clear(); Tree.Clear(); + _selection.Changed -= OnSelection; _documents.DocumentChanged -= Refresh; _histories.Changed -= Refresh; + _sceneQuery.Changed -= Refresh; + _localization.PropertyChanged -= OnLocale; _entry = null; _document = null; + } +} + +public sealed partial class TimelineValueRow : ObservableObject +{ + private readonly TimelineEditSession _session; + private readonly int _generation, _index, _frame, _objectNumber; + private readonly IEditorLocalizationService _localization; + private readonly bool _offset, _hasKey; + private readonly decimal? _original; + internal string Handle { get; } + internal int TrackIndex => _index; + internal SceneTarget? Target { get; } + internal AnimatableProperty? Capability => Target?.Properties.FirstOrDefault(property => property.Name == Property); + internal float? InitialValue { get; init; } + internal float? Baseline => HasTrack ? _original is { } value ? (float)value : null : InitialValue; + public bool HasTrack => _index >= 0; + private bool _isAdditive; + public bool IsAdditive + { + get => _isAdditive; + set + { + if (_isAdditive == value || (HasTrack && !_session.ChangeBlendMode(this, _generation, _index, value))) return; + SetProperty(ref _isAdditive, value); OnPropertyChanged(nameof(BlendModeIndex)); + } + } + public int BlendModeIndex { get => IsAdditive ? 1 : 0; set { if (value is 0 or 1) IsAdditive = value == 1; } } + public string Property { get; } + public string Label { get { var key = "Timeline.Property." + Property; var text = _localization[key]; return text == "[" + key + "]" ? Property : text; } } + public string ObjectLabel => _localization.Format("Timeline.Object", _objectNumber); + public string Hint => _localization[!HasTrack ? _original is null ? "Timeline.InitialUnknown" : "Timeline.NotTracked" : _offset ? "Timeline.Offset" : _hasKey ? "Timeline.KeyHere" : "Timeline.Sampled"]; + public bool IsEditable { get; } + [ObservableProperty] private decimal? _draftValue; + internal TimelineValueRow(TimelineEditSession session, int generation, int index, int frame, int objectNumber, + string property, bool offset, bool hasKey, float? value, IEditorLocalizationService localization, string handle, SceneTarget? target) + { + _session = session; _generation = generation; _index = index; _frame = frame; _objectNumber = objectNumber; + Handle = handle; Target = target; _isAdditive = offset; + Property = property; _offset = offset; _hasKey = hasKey; _localization = localization; + try { _original = value is { } number ? (decimal)number : null; _draftValue = _original; IsEditable = value is null || float.IsFinite(value.Value); } + catch (OverflowException) { IsEditable = false; } + } + public bool Commit(bool confirm = false) => ((!HasTrack && confirm) || DraftValue != _original) && _session.Commit(this, _generation, _index, _frame, DraftValue); + public void Cancel() => DraftValue = _original; + internal void RefreshLabel() { OnPropertyChanged(nameof(ObjectLabel)); OnPropertyChanged(nameof(Hint)); OnPropertyChanged(nameof(Label)); } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineEventEdit.cs b/src/Editor/GalNet.Editor/Services/TimelineEventEdit.cs new file mode 100644 index 00000000..70c838ef --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineEventEdit.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using GalNet.Core.Parameters; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +/// A cancelable event form. Member commits only modify this draft; Apply writes one author edit. +public sealed class TimelineEventEdit : IDisposable +{ + private readonly TimelineEditSession _owner; + private readonly Dictionary _values; + private readonly Dictionary _fields; + private bool _disposed; + internal TimelineDocument Document { get; } + internal int Generation { get; } + internal int Frame { get; } + internal int Index { get; } + internal Dictionary Patch { get; } = new(StringComparer.Ordinal); + public bool IsCurrent => !_disposed && _owner.IsEventEditCurrent(this); + public DtoEditorItem Item { get; } + internal TimelineEventEdit(TimelineEditSession owner, TimelineDocument document, int generation, int frame, int index, string type, Type? dtoType, IReadOnlyList fields) + { + _owner = owner; Document = document; Generation = generation; Frame = frame; Index = index; + var raw = document.Events[index].Raw; + var parameters = raw.EnumerateObject().FirstOrDefault(pair => pair.Name.Equals("parameters", StringComparison.OrdinalIgnoreCase)).Value; + if (parameters.ValueKind is not (JsonValueKind.Undefined or JsonValueKind.Object)) throw new InvalidDataException("Timeline.EditRejected"); + _values = parameters.ValueKind == JsonValueKind.Object ? parameters.EnumerateObject().ToDictionary(pair => pair.Name, pair => pair.Value.Clone(), StringComparer.Ordinal) : new(StringComparer.Ordinal); + _fields = fields.ToDictionary(field => field.Id, field => new EventField(this, field, type), StringComparer.Ordinal); + Item = new("timeline-event-" + index, dtoType, fields, field => _fields[field.Id], CommitAsync); + } + public IFieldBinding Field(string name) => _fields[name]; + private Task CommitAsync(IReadOnlyDictionary patch, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + if (!IsCurrent) return Task.FromResult(new("timeline.obsolete", _owner.SceneText["Timeline.EditRejected"])); + foreach (var (name, value) in patch) + if (!_fields.TryGetValue(name, out var field) || !field.IsVisible) return Task.FromResult(new("timeline.field", _owner.SceneText["Timeline.EditRejected"], name)); + else if (value.Json is { } json && ValueValidation.Validate(field.Descriptor.Value, json) is { } issue) return Task.FromResult(issue); + foreach (var (name, value) in patch) { if (value.Json is { } json) _values[name] = json.Clone(); else _values.Remove(name); Patch[name] = value; } + foreach (var field in _fields.Values) field.Notify(); + return Task.FromResult(null); + } + public bool Apply() => IsCurrent && _owner.ApplyEvent(this); + public void Dispose() { if (_disposed) return; _disposed = true; foreach (var field in _fields.Values) field.Release(); } + private sealed class EventField(TimelineEventEdit owner, FieldDescriptor descriptor, string type) : IFieldBinding + { + public FieldDescriptor Descriptor => descriptor; + public bool IsEnabled => owner.IsCurrent; + public bool IsVisible => !SceneFieldSemantics.IsOutput(type, descriptor.Id); + public object? Label => SceneFieldSemantics.Label(type, descriptor.Id) ?? descriptor.LabelKey ?? "Entry.Parameter." + descriptor.Id; + public IReadOnlyList? Options => owner.IsCurrent ? owner._owner.EventFieldOptions(owner.Index, type, descriptor.Id) : null; + public FieldValue Read() => owner._values.TryGetValue(descriptor.Id, out var value) ? new(value) : default; + public event EventHandler? Changed; + public Task CommitAsync(FieldValue value, CancellationToken token) => owner.CommitAsync(new Dictionary { [descriptor.Id] = value }, token); + public void Notify() => Changed?.Invoke(this, EventArgs.Empty); + public void Release() { Changed?.Invoke(this, EventArgs.Empty); Changed = null; } + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineEventEditing.cs b/src/Editor/GalNet.Editor/Services/TimelineEventEditing.cs new file mode 100644 index 00000000..32db3da2 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineEventEditing.cs @@ -0,0 +1,162 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Contracts.Builtins; +using GalNet.Editor.Models.Graph; +using GalNet.Editor.Shared.Services; +using GalNet.Game.Controls.Timeline; + +namespace GalNet.Editor.Services; + +public sealed partial class TimelineEventRow(Guid id, int index, int frame, string type, Func label) : ObservableObject +{ + public Guid Id { get; } = id; + public int Index { get; } = index; + public int Frame { get; } = frame; + public string Type { get; } = type; + public bool CanEdit => TimelineEventCapabilities.Supports(Type); + public string Label => label(); + [ObservableProperty] private bool _isSelected; + internal void RefreshLabel() => OnPropertyChanged(nameof(Label)); +} + +public sealed partial class TimelineEditSession +{ + public TimelineCanvasModel EventCanvas { get; } = new(); + public ObservableCollection Events { get; } = []; + public IReadOnlyList EventTypes => TimelineEventCapabilities.Types.Where(type => _catalog.TryGet(type, out _)).Select(type => new EntryTypeOption(type, "Timeline", "Entry.Type." + type)).ToArray(); + private TimelineDocument? _eventDocument, _eventMoveDocument; + private ActiveEntrySelection? _eventMoveTarget; + private int[] _eventMoveIndices = []; + private readonly Dictionary _eventIds = []; + private bool _syncEventView; + public TimelineEventEdit? EventEdit { get; private set; } + private void InitializeEvents() + { + Canvas.PropertyChanged += OnEventViewChanged; + EventCanvas.PropertyChanged += OnEventCanvasChanged; + EventCanvas.SeekRequested += SeekCanvas; + EventCanvas.BeginMove = () => + { + var selected = SelectedEvents(); if (_disposed || !CanEdit || _document is null || selected.Length == 0) return false; + EndEventEdit(); _eventMoveDocument = _document; _eventMoveTarget = _target; _eventMoveIndices = selected; return true; + }; + EventCanvas.PreviewMove = delta => EventMove(delta, false); + EventCanvas.CommitMove = delta => EventMove(delta, true); + EventCanvas.CancelMove = () => { _eventMoveDocument = null; _eventMoveIndices = []; }; + EventCanvas.DeleteRequested = DeleteSelectedEvents; + } + private void OnEventViewChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName is null or nameof(TimelineCanvasModel.DisplayFrame) or nameof(TimelineCanvasModel.Frame)) EventCanvas.PreviewSeek(Canvas.DisplayFrame); + if (args.PropertyName == nameof(TimelineCanvasModel.Selection) && Canvas.Selection.Count > 0) { EventCanvas.Cancel(); EventCanvas.Select([]); } + SyncEventView(false, args.PropertyName); + } + private void OnEventCanvasChanged(object? sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(TimelineCanvasModel.Selection)) + { + if (EventCanvas.Selection.Count > 0) { Canvas.Cancel(); Canvas.Select([]); } + foreach (var row in Events) row.IsSelected = EventCanvas.Selection.Contains(row.Id); + } + SyncEventView(true, args.PropertyName); + } + private void SyncEventView(bool fromEvents, string? property) + { + if (_syncEventView || property is not (nameof(TimelineCanvasModel.StartFrame) or nameof(TimelineCanvasModel.PixelsPerFrame))) return; + _syncEventView = true; + try { var source = fromEvents ? EventCanvas : Canvas; var target = fromEvents ? Canvas : EventCanvas; target.StartFrame = source.StartFrame; target.PixelsPerFrame = source.PixelsPerFrame; } + finally { _syncEventView = false; } + } + private void SyncEvents() + { + EndEventEdit(); EventCanvas.Cancel(); + if (!ReferenceEquals(_eventDocument, _document)) { EventCanvas.Invalidate(); _eventIds.Clear(); _eventDocument = _document; } + Events.Clear(); + if (_document is not null && CanEdit) + foreach (var item in _document.Events.OrderBy(item => item.Frame).ThenBy(item => item.Index)) + { + if (!_eventIds.TryGetValue(item.Index, out var id)) _eventIds[item.Index] = id = Guid.NewGuid(); + Events.Add(new(id, item.Index, item.Frame, item.Type, () => + { + var key = "Entry.Type." + item.Type; var name = _localization[key]; + if (name == "[" + key + "]") name = item.Type; + return _localization.Format("Timeline.Event.Label", item.Frame, name); + }) + { IsSelected = EventCanvas.Selection.Contains(id) }); + } + EventCanvas.Duration = Duration; EventCanvas.Frame = Frame; EventCanvas.StartFrame = Canvas.StartFrame; EventCanvas.PixelsPerFrame = Canvas.PixelsPerFrame; + EventCanvas.Rows = Events.Select(item => new TimelineCanvasRow([new(item.Id, item.Frame)], false)).ToArray(); + } + private int[] SelectedEvents() => Events.Where(item => EventCanvas.Selection.Contains(item.Id)).Select(item => item.Index).ToArray(); + public void SelectEvent(TimelineEventRow row, bool toggle = false) + { if (Events.Contains(row)) EventCanvas.Select([row.Id], toggle: toggle); } + public Func CaptureEventInsertion() + { + var document = _document; var target = _target; var generation = _generation; var frame = Frame; + return type => !_disposed && document is not null && ReferenceEquals(document, _document) && target == _target && generation == _generation && frame == Frame && AddEvent(type); + } + public bool AddEvent(string type) + { + if (_disposed || !CanEdit || _document is null || !TimelineEventCapabilities.Supports(type) || !_catalog.TryGet(type, out var schema)) return false; + var parameters = schema.DynamicParameters!.Values.Where(parameter => parameter.DefaultValue is not null).ToDictionary(parameter => parameter.Name, parameter => parameter.DefaultValue!.Value.Clone()); + parameters = SceneAuthoringIdentities.NewEntry(type, parameters); + var index = _document.Events.Count; + if (!Submit(_document.AddEvent(type, Frame, parameters))) return false; + if (_eventIds.TryGetValue(index, out var id)) EventCanvas.Select([id]); return true; + } + private bool EventMove(int delta, bool commit) + { + if (_disposed || _eventMoveDocument is null || !ReferenceEquals(_eventMoveDocument, _document) || _eventMoveTarget != _selection.Current || _eventMoveTarget != _target) return false; + try + { + var indices = _eventMoveIndices; + if (!commit) { _eventMoveDocument.ValidateEventMove(indices, delta); return true; } + if (delta == 0) return true; + var value = _eventMoveDocument.MoveEvents(indices, delta); + if (!Submit(value)) return false; + EventCanvas.Select(indices.Where(_eventIds.ContainsKey).Select(index => _eventIds[index])); return true; + } + catch (Exception error) when (error is InvalidDataException or OverflowException or ArgumentException) { EditError(error); return false; } + } + public void DeleteSelectedEvents() + { + if (_disposed || _document is null || EventCanvas.HasPendingMove) return; + var selected = SelectedEvents(); if (selected.Length > 0) Submit(_document.DeleteEvents(selected)); + } + public void OrderEvent(TimelineEventRow row, int direction) + { + if (_disposed || _document is null || !Events.Contains(row) || direction is not (-1 or 1)) return; + var peers = _document.Events.Where(item => item.Frame == row.Frame).Select(item => item.Index).ToArray(); + var next = Array.IndexOf(peers, row.Index) + direction; + if (next < 0 || next >= peers.Length) return; + var index = peers[next]; + if (Submit(_document.OrderEvent(row.Index, direction)) && _eventIds.TryGetValue(index, out var id)) EventCanvas.Select([id]); + } + public TimelineEventEdit? BeginEventEdit(TimelineEventRow row) + { + EndEventEdit(); + if (_disposed || !CanEdit || _document is null || !Events.Contains(row) || !TimelineEventCapabilities.Supports(row.Type) || !_catalog.TryGet(row.Type, out var schema) || schema.DynamicParameters is null) return null; + EventCanvas.Cancel(); Canvas.Cancel(); SelectEvent(row); + try { EventEdit = new(this, _document, _generation, Frame, row.Index, row.Type, schema.FieldDataType, schema.DynamicParameters.Fields); } + catch (Exception error) when (error is JsonException or InvalidDataException or InvalidOperationException or ArgumentException) { EditError(error); return null; } + OnPropertyChanged(nameof(EventEdit)); return EventEdit; + } + internal bool IsEventEditCurrent(TimelineEventEdit draft) => !_disposed && CanEdit && ReferenceEquals(EventEdit, draft) && ReferenceEquals(_document, draft.Document) && draft.Generation == _generation && draft.Frame == Frame && _target == _selection.Current; + internal IReadOnlyList? EventFieldOptions(int index, string type, string field) => _target?.EntryId is { } id && _document is not null + ? _targets.FieldOptions(_target.GroupId, id, type, field, new(_document.Events[index].Frame, index)) : null; + internal bool ApplyEvent(TimelineEventEdit draft) => IsEventEditCurrent(draft) && Submit(draft.Document.PatchEvent(draft.Index, draft.Patch)); + public void EndEventEdit() { var old = EventEdit; EventEdit = null; old?.Dispose(); OnPropertyChanged(nameof(EventEdit)); } + private void InvalidateEvents() + { + EndEventEdit(); EventCanvas.Cancel(); EventCanvas.Invalidate(); Events.Clear(); _eventIds.Clear(); _eventDocument = null; + EventCanvas.Rows = []; + } + private void DisposeEvents() + { + InvalidateEvents(); + Canvas.PropertyChanged -= OnEventViewChanged; EventCanvas.PropertyChanged -= OnEventCanvasChanged; EventCanvas.SeekRequested -= SeekCanvas; + EventCanvas.BeginMove = null; EventCanvas.PreviewMove = null; EventCanvas.CommitMove = null; EventCanvas.CancelMove = null; EventCanvas.DeleteRequested = null; + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineKeyDetails.cs b/src/Editor/GalNet.Editor/Services/TimelineKeyDetails.cs new file mode 100644 index 00000000..8cde3855 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineKeyDetails.cs @@ -0,0 +1,96 @@ +using System.ComponentModel; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Core.Scene; + +namespace GalNet.Editor.Services; + +/// Selection-scoped draft; never holds an author array index without its document generation. +public sealed partial class TimelineKeyEdit : ObservableObject +{ + private readonly TimelineEditSession _owner; + internal TimelineDocument Document { get; } + internal int Generation { get; } + internal TimelineKeyLocation[] Locations { get; } + private readonly TimelineKeyframeDefinition[] _keys; + private bool _perSecond; + private decimal Factor => _perSecond ? Document.FrameRate : 1; + public int Count => Locations.Length; + public bool IsCurrent => _owner.IsKeyEditCurrent(this); + public string UnitKey => _perSecond ? "Timeline.Key.PerSecond" : "Timeline.Key.PerFrame"; + [ObservableProperty] private decimal? _value; + [ObservableProperty] private decimal? _incoming; + [ObservableProperty] private decimal? _outgoing; + [ObservableProperty] private int _interpolationIndex; + internal TimelineKeyEdit(TimelineEditSession owner, TimelineDocument document, int generation, TimelineKeyLocation[] locations, bool perSecond) + { + _owner = owner; Document = document; Generation = generation; Locations = locations; _perSecond = perSecond; + _keys = locations.Select(location => document.Tracks[location.Track].Keys.Single(key => key.Frame == location.Frame)).ToArray(); + Value = Mixed(_keys.Select(key => key.Value)); Incoming = Scale(Mixed(_keys.Select(key => key.InTangent)), Factor); + Outgoing = Scale(Mixed(_keys.Select(key => key.OutTangent)), Factor); + InterpolationIndex = _keys.Select(key => key.InterpolationToNext).Distinct().Count() == 1 ? (int)_keys[0].InterpolationToNext : -1; + } + private static decimal? Mixed(IEnumerable values) + { var numbers = values.Distinct().Take(2).ToArray(); try { return numbers.Length == 1 ? (decimal)numbers[0] : null; } catch (OverflowException) { return null; } } + private static decimal? Scale(decimal? value, decimal factor) { try { return value * factor; } catch (OverflowException) { return null; } } + internal void ConvertUnits(bool perSecond) + { + if (_perSecond == perSecond) return; + var oldFactor = Factor; _perSecond = perSecond; + Incoming = Scale(Incoming, Factor / oldFactor); Outgoing = Scale(Outgoing, Factor / oldFactor); OnPropertyChanged(nameof(UnitKey)); + } + public void Cancel() + { + Value = Mixed(_keys.Select(key => key.Value)); Incoming = Scale(Mixed(_keys.Select(key => key.InTangent)), Factor); + Outgoing = Scale(Mixed(_keys.Select(key => key.OutTangent)), Factor); + InterpolationIndex = _keys.Select(key => key.InterpolationToNext).Distinct().Count() == 1 ? (int)_keys[0].InterpolationToNext : -1; + } + public bool CommitValue() => IsCurrent && Value is { } value && (Value == Mixed(_keys.Select(key => key.Value)) || _owner.EditSelectedKeys(this, value: (float)value)); + public bool CommitIncoming() => IsCurrent && Incoming is { } value && (Incoming == Scale(Mixed(_keys.Select(key => key.InTangent)), Factor) || _owner.EditSelectedKeys(this, incoming: (float)(value / Factor))); + public bool CommitOutgoing() => IsCurrent && Outgoing is { } value && (Outgoing == Scale(Mixed(_keys.Select(key => key.OutTangent)), Factor) || _owner.EditSelectedKeys(this, outgoing: (float)(value / Factor))); + public bool CommitInterpolation() + { + if (!IsCurrent || InterpolationIndex is < 0 or > 2) return false; + if (_keys.All(key => (int)key.InterpolationToNext == InterpolationIndex)) return true; + if (_owner.EditSelectedKeys(this, interpolation: (AnimationInterpolation)InterpolationIndex)) return true; + Cancel(); return false; + } + public TimelineTangentSettings[] TangentModes => Locations.Select(Document.TangentSettings).ToArray(); + public bool SetMode(TimelineTangentMode mode, bool? incoming = null) => _owner.SetTangentMode(this, incoming, mode); + public bool SetLinked(bool linked) => _owner.SetTangentMode(this, null, TimelineTangentMode.Free, linked); + public bool ApplyPreset(TimelineTangentPreset preset) => _owner.EditSelectedKeys(this, preset: preset); +} + +public sealed partial class TimelineEditSession +{ + [ObservableProperty] private TimelineKeyEdit? _keyEdit; + [ObservableProperty] private bool _tangentsPerSecond; + public bool HasKeySelection => KeyEdit is not null; + partial void OnKeyEditChanged(TimelineKeyEdit? value) => OnPropertyChanged(nameof(HasKeySelection)); + partial void OnTangentsPerSecondChanged(bool value) => KeyEdit?.ConvertUnits(value); + private void OnCanvasSelection(object? sender, PropertyChangedEventArgs args) { if (args.PropertyName == nameof(Canvas.Selection)) { if (Canvas.HasPendingMove || Canvas.HasPendingCurve) Canvas.Cancel(); RefreshKeyEdit(); } } + private void RefreshKeyEdit() + { + var selected = Canvas.Selection.Where(_keyLocations.ContainsKey).Select(id => _keyLocations[id]).OrderBy(key => key.Track).ThenBy(key => key.Frame).ToArray(); + KeyEdit = !_disposed && _document is not null && selected.Length > 0 ? new(this, _document, _generation, selected, TangentsPerSecond) : null; + } + internal bool IsKeyEditCurrent(TimelineKeyEdit draft) => !_disposed && ReferenceEquals(KeyEdit, draft) && ReferenceEquals(_document, draft.Document) && draft.Generation == _generation; + internal bool EditSelectedKeys(TimelineKeyEdit draft, float? value = null, float? incoming = null, float? outgoing = null, + AnimationInterpolation? interpolation = null, TimelineTangentPreset? preset = null) + { + if (!IsKeyEditCurrent(draft)) return false; + try + { + if (value is { } number && draft.Locations.Any(location => Rows.FirstOrDefault(row => row.TrackIndex == location.Track)?.Capability is { } capability + && _document!.Tracks[location.Track].BlendMode != AnimationBlendMode.Additive && !capability.Accepts(number))) + { Message = _localization["Timeline.ValueRange"]; return false; } + var patch = _document!.EditKeys(draft.Locations, value, incoming, outgoing, interpolation, preset); + if (_baseline is { } original && JsonElement.DeepEquals(original, patch)) return true; + var locations = draft.Locations; + if (!Submit(patch)) return false; + Canvas.Select(locations.Select(location => _keyIds[location])); return true; + } + catch (Exception error) when (error is InvalidDataException or ArgumentException or OverflowException or InvalidOperationException) + { Message = _localization["Timeline.EditRejected"]; return false; } + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineKeyEditing.cs b/src/Editor/GalNet.Editor/Services/TimelineKeyEditing.cs new file mode 100644 index 00000000..6dabeceb --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineKeyEditing.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Game.Controls.Timeline; + +namespace GalNet.Editor.Services; + +public sealed partial class TimelineEditSession +{ + public TimelineCanvasModel Canvas { get; } = new(); + public ObservableCollection VisibleNodes { get; } = []; + private readonly Dictionary _keyLocations = []; + private readonly Dictionary _keyIds = []; + private TimelineDocument? _keyDocument, _moveDocument; + private ActiveEntrySelection? _moveTarget; + private TimelineKeyLocation[]? _moveKeys; + private string? _activeHandle, _activeProperty; + [ObservableProperty] private decimal? _timingDurationDraft = 60; + [ObservableProperty] private decimal? _timingFrameRateDraft = 60; + public int MinimumDuration => _document?.MinimumDuration ?? 0; + private void InitializeCanvas() + { + Canvas.PropertyChanged += OnCanvasSelection; + Canvas.SeekRequested += SeekCanvas; + Canvas.BeginMove = BeginKeyMove; Canvas.PreviewMove = PreviewKeyMove; Canvas.CommitMove = delta => CommitKeyMove(delta, false); + Canvas.BeginCurve = BeginKeyMove; Canvas.PreviewCurve = PreviewCurve; Canvas.CommitCurve = CommitCurve; + Canvas.CancelMove = () => { _moveDocument = null; _moveKeys = null; }; + Canvas.DeleteRequested = DeleteSelectedKeys; Canvas.AddRequested = AddCurrentKey; + } + private void SeekCanvas(int frame) => Frame = frame; + private void InvalidateKeyDocument() + { + Canvas.Cancel(); Canvas.Invalidate(); _moveDocument = null; _moveKeys = null; _keyDocument = null; + _keyIds.Clear(); _keyLocations.Clear(); VisibleNodes.Clear(); Canvas.Rows = []; + } + private void SyncCanvasDocument() + { + if (!ReferenceEquals(_keyDocument, _document)) + { + InvalidateKeyDocument(); _keyDocument = _document; + if (_document is not null) + for (var track = 0; track < _document.Tracks.Count; track++) foreach (var key in _document.Tracks[track].Keys) + { var location = new TimelineKeyLocation(track, key.Frame); var id = Guid.NewGuid(); _keyIds.Add(location, id); _keyLocations.Add(id, location); } + TimingDurationDraft = Duration; TimingFrameRateDraft = FrameRate; + } + Canvas.Duration = Duration; Canvas.Frame = Frame; OnPropertyChanged(nameof(MinimumDuration)); + RebuildVisibleNodes(); + RefreshKeyEdit(); + } + private void DetachTree() + { foreach (var node in Tree.SelectMany(node => node.Descendants())) node.PropertyChanged -= OnTreeExpansion; } + private void OnTreeExpansion(object? sender, PropertyChangedEventArgs args) + { if (args.PropertyName == nameof(TimelineTreeNode.IsExpanded)) { Canvas.Cancel(); RebuildVisibleNodes(); } } + private void RebuildVisibleNodes() + { + VisibleNodes.Clear(); + bool Include(TimelineTreeNode node) => node.Value is { } row ? row.HasTrack || _pendingProperties.Contains((row.Handle, row.Property)) + : node.ObjectHandle is { } handle && _selectedObjects.Contains(handle) || node.Children.Any(Include); + void Add(TimelineTreeNode node, int depth) + { + if (!Include(node)) return; + node.Depth = depth; VisibleNodes.Add(node); + if (node.IsExpanded) foreach (var child in node.Children) Add(child, depth + 1); + } + foreach (var root in Tree) Add(root, 0); + IReadOnlyList Keys(TimelineTreeNode node) => node.Descendants().Where(child => child.Value?.HasTrack == true) + .SelectMany(child => _keyIds.Where(pair => pair.Key.Track == child.Value!.TrackIndex).Select(pair => CanvasKey(pair.Value, _document!.Tracks[pair.Key.Track].Keys.Single(key => key.Frame == pair.Key.Frame)))).ToArray(); + Canvas.Rows = VisibleNodes.Select(node => { var keys = Keys(node); return new TimelineCanvasRow(keys, !node.IsLeaf, node.HasCurve ? CurveView(node, keys) : null); }).ToArray(); + OnPropertyChanged(nameof(HasNoRows)); + } + public void SetActiveTrack(TimelineValueRow row) + { if (!Rows.Contains(row)) return; _activeHandle = row.Handle; _activeProperty = row.Property; Canvas.Select([]); } + private int[] SelectedTracks() => Canvas.Selection.Where(_keyLocations.ContainsKey).Select(id => _keyLocations[id].Track).Distinct().ToArray() is { Length: > 0 } selected ? selected + : Rows.FirstOrDefault(row => row.Handle == _activeHandle && row.Property == _activeProperty && row.HasTrack) is { } active ? [active.TrackIndex] : []; + private bool BeginKeyMove() + { + var keys = Canvas.Selection.Where(_keyLocations.ContainsKey).Select(id => _keyLocations[id]).ToArray(); + if (!CanEdit || _document is null || keys.Length == 0) return false; + _moveKeys = keys; _moveDocument = _document; _moveTarget = _target; Message = ""; return true; + } + private bool IsMoveCurrent => !_disposed && _moveDocument is not null && ReferenceEquals(_moveDocument, _document) && _moveTarget == _target && _moveTarget == _selection.Current && _moveKeys is { Length: > 0 }; + private bool PreviewKeyMove(int delta) + { + if (!IsMoveCurrent) return false; + try { _moveDocument!.ValidateMove(_moveKeys!, delta, false); Message = ""; return true; } + catch (Exception error) when (error is InvalidDataException or OverflowException) { EditError(error); return false; } + } + private bool CommitKeyMove(int delta, bool replace) + { + if (!IsMoveCurrent) return false; + try + { + var locations = _moveKeys!; var next = _moveDocument!.MoveKeys(locations, delta, replace); + if (!Submit(next)) return false; + Canvas.Select(locations.Select(key => _keyIds.TryGetValue(new(key.Track, key.Frame + delta), out var id) ? id : Guid.Empty).Where(id => id != Guid.Empty)); + return true; + } + catch (Exception error) when (error is InvalidDataException or OverflowException or ArgumentException) { EditError(error); return false; } + } + public void ReplaceKeyCollisions() { if (Canvas.HasPendingMove && CommitKeyMove(Canvas.PreviewDelta, true)) Canvas.ResetPreview(); } + private void EditError(Exception error) => Message = _localization[error.Message.StartsWith("Timeline.", StringComparison.Ordinal) ? error.Message : "Timeline.EditRejected"]; + public void DeleteSelectedKeys() + { + if (_document is null || Canvas.HasPendingMove) return; + var selected = Canvas.Selection.Where(_keyLocations.ContainsKey).Select(id => _keyLocations[id]).ToArray(); if (selected.Length == 0) return; + try { Submit(_document.DeleteKeys(selected)); } + catch (Exception error) when (error is InvalidDataException or ArgumentException) { EditError(error); } + } + public void AddCurrentKey() + { + if (_document is null || Canvas.HasPendingMove) return; + var tracks = SelectedTracks(); if (tracks.Length == 0) { Message = _localization["Timeline.SelectTrack"]; return; } + try + { + var frame = Frame; + if (Submit(_document.AddKeys(tracks, frame))) Canvas.Select(tracks.Select(track => _keyIds[new(track, frame)])); + } + catch (Exception error) when (error is InvalidDataException or ArgumentException) { EditError(error); } + } + public void NavigateKey(int direction) + { + if (_document is null) return; + var tracks = SelectedTracks(); if (tracks.Length == 0) tracks = Enumerable.Range(0, _document.Tracks.Count).ToArray(); + var frames = tracks.SelectMany(track => _document.Tracks[track].Keys.Select(key => key.Frame)); + var next = direction < 0 ? frames.Where(frame => frame < Frame).DefaultIfEmpty(0).Max() : frames.Where(frame => frame > Frame).DefaultIfEmpty(Duration).Min(); Frame = next; + } + public void CancelTimingInput(bool frameRate) + { if (frameRate) TimingFrameRateDraft = FrameRate; else TimingDurationDraft = Duration; } + public void CommitTimingInput(bool frameRate) + { + if (_document is null) return; + var value = frameRate ? TimingFrameRateDraft : TimingDurationDraft; + if (value is not { } number || number % 1 != 0 || number < (frameRate ? 1 : 0) || number > (frameRate ? 240 : int.MaxValue)) + { Message = _localization["Timeline.EditRejected"]; return; } + var duration = frameRate ? Duration : Math.Max((int)number, MinimumDuration); + var fps = frameRate ? (int)number : FrameRate; + Canvas.Cancel(); + try { if (Submit(_document.ChangeTiming(duration, fps))) CancelTimingInput(frameRate); } + catch (Exception error) when (error is InvalidDataException or OverflowException or InvalidOperationException or ArgumentException) { EditError(error); } + } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelineObjectEditing.cs b/src/Editor/GalNet.Editor/Services/TimelineObjectEditing.cs new file mode 100644 index 00000000..ccba272f --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelineObjectEditing.cs @@ -0,0 +1,78 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.Shared.Services; +using GalNet.Game.Controls.Forms; + +namespace GalNet.Editor.Services; + +/// Object selection only; properties are added from the object row. +public sealed partial class TimelineObjectEdit : ObservableObject, IDisposable +{ + private readonly TimelineEditSession _owner; + private readonly SceneTargetCatalog _catalog; + private bool _disposed; + internal TimelineDocument Document { get; } + internal int Generation { get; } + internal int Frame { get; } + public SceneTargetSelection Selection { get; } + public SceneTargetSnapshot Snapshot => Selection.Snapshot; + public IReadOnlyList Choices => Snapshot.Choices.Select(choice => choice.Item).ToArray(); + public SceneTarget? Target => Selection.Target; + [ObservableProperty] private string _message = ""; + public bool IsCurrent => !_disposed && _owner.IsObjectEditCurrent(this) && Selection.IsCurrent; + internal TimelineObjectEdit(TimelineEditSession owner, SceneTargetCatalog catalog, TimelineDocument document, int generation, int frame, string group, string entry) + { + _owner = owner; _catalog = catalog; Document = document; Generation = generation; Frame = frame; + Selection = new(catalog, catalog.Query(group, entry, Eligible, new(frame, IncludeFuture: true))); + } + private static bool Eligible(SceneTarget target) => target.Properties.Count > 0; + public Task PrepareAsync() => Selection.PrepareAsync(); + public void Select(string id) + { if (!IsCurrent || !Selection.Select(id)) Message = _catalog.Text["Scene.Stale"]; } + public bool Commit() + { + if (!IsCurrent || !Selection.TryResolve(out var handle) || Target is null) { Message = _catalog.Text["Scene.Stale"]; return false; } + return _owner.SelectObject(this, handle); + } + public void Dispose() { if (_disposed) return; _disposed = true; Selection.Dispose(); } +} + +public sealed partial class TimelineEditSession +{ + private readonly HashSet _selectedObjects = new(StringComparer.Ordinal); + private readonly HashSet<(string Handle, string Property)> _pendingProperties = []; + public IEditorLocalizationService SceneText => _localization; + public TimelineObjectEdit? ObjectEdit { get; private set; } + public TimelineObjectEdit? BeginObjectEdit() + { + if (_disposed || !CanEdit || _document is null || _target?.EntryId is null) return null; + // Keep the clicked object row attached while its selector is open. + Canvas.Cancel(); CancelTimingInput(false); CancelTimingInput(true); + foreach (var row in Rows) row.Cancel(); + EndObjectEdit(); + ObjectEdit = new(this, _targets, _document, _generation, Frame, _target.GroupId, _target.EntryId); OnPropertyChanged(nameof(ObjectEdit)); return ObjectEdit; + } + internal bool IsObjectEditCurrent(TimelineObjectEdit draft) => !_disposed && ReferenceEquals(ObjectEdit, draft) && draft.Generation == _generation && ReferenceEquals(draft.Document, _document) && draft.Frame == Frame && _selection.Current == _target; + internal bool SelectObject(TimelineObjectEdit draft, string handle) + { + if (!IsObjectEditCurrent(draft)) return false; + _selectedObjects.Add(handle); BuildRows(); return true; + } + public IReadOnlyList AvailableProperties(string handle) => Rows.Where(row => row.Handle == handle && !row.HasTrack && row.Capability is not null).ToArray(); + public bool AddProperty(TimelineValueRow row) + { + if (_disposed || !CanEdit || !Rows.Contains(row) || row.HasTrack || row.Capability is null) return false; + if (Tree.FirstOrDefault(node => node.ObjectHandle == row.Handle) is { } root) root.IsExpanded = true; + if (row.DraftValue is not null) return row.Commit(true); + _selectedObjects.Add(row.Handle); _pendingProperties.Add((row.Handle, row.Property)); + RebuildVisibleNodes(); Message = _localization["Timeline.InitialUnknown"]; return true; + } + private void PruneObjectDrafts() + { + _selectedObjects.RemoveWhere(handle => !_scene.Targets.Any(target => target.Handle == handle)); + _pendingProperties.RemoveWhere(item => !_scene.Targets.Any(target => target.Handle == item.Handle) + || _document?.Tracks.Any(track => track.HandleId == item.Handle && track.Property == item.Property) == true); + } + private void ClearObjectDrafts() { _curveViews.Clear(); _selectedObjects.Clear(); _pendingProperties.Clear(); } + public void EndObjectEdit() { var previous = ObjectEdit; ObjectEdit = null; previous?.Dispose(); OnPropertyChanged(nameof(ObjectEdit)); } +} diff --git a/src/Editor/GalNet.Editor/Services/TimelinePropertyTree.cs b/src/Editor/GalNet.Editor/Services/TimelinePropertyTree.cs new file mode 100644 index 00000000..c4b509d7 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/TimelinePropertyTree.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using GalNet.Editor.Shared.Services; + +namespace GalNet.Editor.Services; + +public sealed partial class TimelineTreeNode(string key, Func label, TimelineValueRow? value = null) : ObservableObject +{ + internal string Key { get; } = key; + public string Label => label(); + public TimelineValueRow? Value { get; } = value; + public bool IsLeaf => Value is not null; + public bool IsBranch => !IsLeaf; + public bool CanExpand => IsBranch || Value?.HasTrack == true; + public bool HasCurve => IsLeaf && Value!.HasTrack && IsExpanded; + public double Height => 38 + (HasCurve ? GalNet.Game.Controls.Timeline.TimelineCurveViewport.Height : 0); + public string? ObjectHandle { get; init; } + public bool IsObject => ObjectHandle is not null; + public bool CanAddProperties => ObjectHandle is not null && Descendants().Any(node => node.Value is { HasTrack: false, Capability: not null }); + public int Depth { get; internal set; } + public double Indent => Depth * 16; + public ObservableCollection Children { get; } = []; + [ObservableProperty] private bool _isExpanded = value is null; + public string ExpandGlyph => IsExpanded ? "▾" : "▸"; + partial void OnIsExpandedChanged(bool value) { OnPropertyChanged(nameof(ExpandGlyph)); OnPropertyChanged(nameof(HasCurve)); OnPropertyChanged(nameof(Height)); } + internal IEnumerable Descendants() => new[] { this }.Concat(Children.SelectMany(node => node.Descendants())); + internal void RefreshLabel() => OnPropertyChanged(nameof(Label)); +} + +public sealed partial class TimelineEditSession +{ + private readonly Dictionary _expansion = new(StringComparer.Ordinal); + public ObservableCollection Tree { get; } = []; + private void NotifyState() { OnPropertyChanged(nameof(HasNoRows)); OnPropertyChanged(nameof(AnalysisIncomplete)); } + private void SaveExpansion() + { + foreach (var node in Tree.SelectMany(node => node.Descendants())) _expansion[node.Key] = node.IsExpanded; + if (_expansion.Count > 2000) _expansion.Clear(); + } + private string ObjectName(TimelineValueRow row, int number) + { + if (row.Target is not { } target || target.Kind == SceneTargetKind.Unresolved) return _localization.Format("Timeline.Unresolved", number); + var group = _documents.CurrentDocument.Nodes.FirstOrDefault(node => node.Id == target.Locator.GroupId); + var entry = _documents.Groups.TryGetValue(target.Locator.GroupId, out var document) ? document.Entries.FirstOrDefault(item => item.StableId == target.Locator.EntryId) : null; + var source = (group?.Name ?? "") + " / " + entry?.Id; + if (target.CreatedAtFrame is { } created && !target.IsFuture) source += " · " + _localization.Format("Scene.CreatedAt", created); + return _localization["Timeline.Kind." + target.Kind] + " · " + source + " · " + (target.IsFuture ? _localization.Format("Scene.FutureAt", target.CreatedAtFrame) : _localization[target.Presence == TargetPresence.Definite ? "Timeline.Definite" : "Timeline.Possible"]); + } + private void BuildTree() + { + var number = 0; + foreach (var rows in Rows.GroupBy(row => row.Handle)) + { + var ordinal = ++number; var first = rows.First(); + var root = new TimelineTreeNode(rows.Key, () => ObjectName(first, ordinal)) { ObjectHandle = rows.Key }; Tree.Add(root); + foreach (var row in rows) + { + var segments = row.Property.Split('.'); var parent = root; var path = root.Key; + for (var index = 0; index < segments.Length - 1; index++) + { + var segment = segments[index]; path += "/" + segment; + var node = parent.Children.FirstOrDefault(child => child.Key == path); + if (node is null) { node = new(path, () => PropertyName(segment)); parent.Children.Add(node); } + parent = node; + } + parent.Children.Add(new(path + "/" + segments[^1], () => PropertyName(row.Property), row)); + } + } + foreach (var node in Tree.SelectMany(node => node.Descendants())) + { if (_expansion.TryGetValue(node.Key, out var expanded)) node.IsExpanded = expanded; node.PropertyChanged += OnTreeExpansion; } + } + private string PropertyName(string property) + { + var key = "Timeline.Property." + property; var text = _localization[key]; return text == "[" + key + "]" ? property : text; + } + public bool RemoveOverride(TimelineTreeNode node) + { + if (_disposed || !CanEdit || _document is null || !Tree.SelectMany(root => root.Descendants()).Contains(node)) return false; + Canvas.Cancel(); + var rows = node.Descendants().Where(child => child.Value is not null).Select(child => child.Value!).ToArray(); + var indices = rows.Where(row => row.HasTrack).Select(row => row.TrackIndex).ToArray(); + if (indices.Length > 0 && !Submit(_document.RemoveTracks(indices))) return false; + if (node.ObjectHandle is { } handle) _selectedObjects.Remove(handle); + foreach (var row in rows) _pendingProperties.Remove((row.Handle, row.Property)); + RebuildVisibleNodes(); return true; + } + internal bool ChangeBlendMode(TimelineValueRow row, int generation, int index, bool additive) + { + if (_disposed || generation != _generation || !Rows.Contains(row) || _document is null || index < 0) return false; + if (!additive && row.Capability is { } capability && _document.Tracks[index].Keys.Any(key => !capability.Accepts(key.Value))) + { Message = _localization["Timeline.ValueRange"]; return false; } + return Submit(_document.ChangeBlendMode(index, additive)); + } +} diff --git a/src/Editor/GalNet.Editor/Services/VariableAuthoringService.cs b/src/Editor/GalNet.Editor/Services/VariableAuthoringService.cs new file mode 100644 index 00000000..155ece21 --- /dev/null +++ b/src/Editor/GalNet.Editor/Services/VariableAuthoringService.cs @@ -0,0 +1,217 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using GalNet.Core.Entry; +using GalNet.Core.Gallery; +using GalNet.Core.Variable; +using GalNet.Editor.Abstraction.Documents; +using GalNet.Editor.Abstraction.Services; +using GalNet.Editor.History; + +namespace GalNet.Editor.Services; + +public sealed record VariableReference(string Location, bool Certain, AuthorNavigationTarget? Target = null); + +/// Edits declarations and their known references as one project history intent. +public sealed class VariableAuthoringService(IEditorDocumentService documents, IEntryCatalog catalog, IVariableDefinitionService definitions, EditorHistories histories) +{ + public IReadOnlyList Get(VariableScope scope) => List(scope); + private List List(VariableScope scope) => scope switch + { + VariableScope.Player => documents.CurrentDocument.PlayerVariables, + VariableScope.Save => documents.CurrentDocument.SaveVariables, + _ => throw new ArgumentOutOfRangeException(nameof(scope)) + }; + private IEnumerable All => Get(VariableScope.Player).Concat(Get(VariableScope.Save)); + private ProjectVariableDefinition Find(VariableScope scope, string uid) => List(scope).SingleOrDefault(item => item.DefaultValue.Uid == uid) + ?? throw new EditorOperationException("variable.missing", "The variable no longer exists."); + public ProjectVariableDefinition Add(VariableScope scope) + { + var number = 1; + string name; + do { name = $"var_{scope.ToString().ToLowerInvariant()}_{number++}"; } while (All.Any(item => item.Name == name)); + var value = new ProjectVariableDefinition { Name = name, DefaultValue = new() { Name = name } }; + histories.Content.Execute(new VariableEdit(this, scope, List(scope).Count, null, value, [], new("History.Variable.Add", name))); + return value; + } + public IReadOnlyList Inspect(VariableScope scope, string uid) => References(Find(scope, uid).Name).Select(item => item.Reference).ToArray(); + + public IReadOnlyList Update(VariableScope scope, string uid, string name, VariableType type, string defaultText, string description, string? expectedSnapshot = null) + { + var before = Find(scope, uid); + if (expectedSnapshot is not null && JsonSerializer.Serialize(before) != expectedSnapshot) throw new EditorOperationException("variable.changed", "The variable or its references changed outside this edit."); + if (!Regex.IsMatch(name, "^[A-Za-z_][A-Za-z0-9_]*$", RegexOptions.CultureInvariant) || GalleryUnlockVariable.IsReservedName(name) + || new[] { "true", "false", "and", "or", "not", "in", "mod", "xor" }.Contains(name, StringComparer.OrdinalIgnoreCase)) + throw new EditorOperationException("variable.name", "Use an ASCII letter or underscore followed by letters, digits or underscores; Gallery names are reserved."); + if (All.Any(item => item.DefaultValue.Uid != uid && item.Name == name)) throw new EditorOperationException("variable.duplicate", "The variable name is already used in this project."); + var after = before.Clone(); after.Name = name; after.DefaultValue.Name = name; after.Description = description; + try + { + after.DefaultValue.Value = type switch + { + VariableType.Bool => VariableValue.From(bool.Parse(defaultText)), + VariableType.Int => VariableValue.From(int.Parse(defaultText, NumberStyles.Integer, CultureInfo.InvariantCulture)), + VariableType.Float => ParseFloat(defaultText), + VariableType.String => VariableValue.From(defaultText), + _ => throw new FormatException() + }; + } + catch (Exception failure) when (failure is FormatException or OverflowException) { throw new EditorOperationException("variable.default", "The default value is not valid for the selected type."); } + var references = before.Name == name ? [] : References(before.Name); + var patches = references.Where(item => item.Reference.Certain).Select(item => new Patch(item.Slot, item.Value, item.Direct ? RenameSymbol(item.Value, before.Name, name) : Rewrite(item.Value, before.Name, name).Text)).ToArray(); + if (JsonSerializer.Serialize(before) != JsonSerializer.Serialize(after)) + histories.Content.Execute(new VariableEdit(this, scope, List(scope).IndexOf(before), before.Clone(), after, patches, new("History.Variable.Update", name))); + return references.Where(item => !item.Reference.Certain).Select(item => item.Reference).ToArray(); + } + public void Delete(VariableScope scope, string uid) + { + var before = Find(scope, uid); + if (References(before.Name).Any(item => item.Reference.Certain)) throw new EditorOperationException("variable.inUse", "The variable has references. Remove them before deleting it."); + histories.Content.Execute(new VariableEdit(this, scope, List(scope).IndexOf(before), before.Clone(), null, [], new("History.Variable.Delete", before.Name))); + } + public static string DefaultText(ProjectVariableDefinition value) => value.Type switch + { + VariableType.Bool => value.DefaultValue.AsBool() ? "true" : "false", + VariableType.Int => value.DefaultValue.AsInt().ToString(CultureInfo.InvariantCulture), + VariableType.Float => value.DefaultValue.AsFloat().ToString("R", CultureInfo.InvariantCulture), + _ => value.DefaultValue.AsString() + }; + private static VariableValue ParseFloat(string text) + { + var value = float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); + return float.IsFinite(value) ? VariableValue.From(value) : throw new FormatException(); + } + + private sealed record Slot(string Node, string? Entry, string? Item, string Field); + private sealed record Found(Slot Slot, string Value, bool Direct, VariableReference Reference); + private sealed record Patch(Slot Slot, string Before, string After); + private List References(string name) + { + var found = new List(); + void Add(Slot slot, string text, bool expression, bool direct, string location) + { + var known = direct ? MatchesSymbol(text, name) : expression && text.Contains(name, StringComparison.Ordinal) && ValidExpression(text) && Rewrite(text, name, name).Found; + if (known || text.Contains(name, StringComparison.Ordinal)) found.Add(new(slot, text, direct, new(location, known, new(slot.Node, slot.Entry, + slot.Field.StartsWith("parameter:", StringComparison.Ordinal) ? slot.Field[10..] : slot.Field)))); + } + foreach (var node in documents.CurrentDocument.Nodes) + { + foreach (var option in node.Options ?? []) Add(new(node.Id, null, option.Id, "option"), option.Condition, true, false, $"Graph/{node.Id}/options/{option.Id}/condition"); + foreach (var option in node.Options ?? []) Add(new(node.Id, null, option.Id, "optionText"), option.Text, false, false, $"Graph/{node.Id}/options/{option.Id}/text"); + foreach (var condition in node.Conditions ?? []) Add(new(node.Id, null, condition.Id, "branch"), condition.Expression, true, false, $"Graph/{node.Id}/conditions/{condition.Id}"); + if (!documents.LoadedProject.GroupEntries.TryGetValue(node.Id, out var entries)) continue; + foreach (var entry in entries) + { + var path = $"Graph/{node.File}/entries/{entry.StableId}"; + Add(new(node.Id, entry.StableId, null, "condition"), entry.Condition, true, false, path + "/condition"); + catalog.TryGet(entry.Type, out var schema); + foreach (var pair in entry.Parameters) + { + var kind = schema?.Parameters.GetValueOrDefault(pair.Key); + var text = pair.Value.ValueKind == JsonValueKind.String ? pair.Value.GetString()! : pair.Value.GetRawText(); + Add(new(node.Id, entry.StableId, null, "parameter:" + pair.Key), text, + pair.Value.ValueKind == JsonValueKind.String && kind == EntryParameterType.Expression, + pair.Value.ValueKind == JsonValueKind.String && kind == EntryParameterType.VariableName, path + "/parameters/" + pair.Key); + } + } + } + return found; + } + // Rewrite symbols only outside quoted strings. Bracket parameters and bare + // identifiers are supported; function names and opaque parameter text are not rewritten. + private static (string Text, bool Found) Rewrite(string text, string name, string replacement) + { + var result = new StringBuilder(); var found = false; + for (var index = 0; index < text.Length;) + { + var start = index; var ch = text[index++]; + if (ch is '\'' or '"' or '#') + { + var closed = false; + while (index < text.Length) + { + if (text[index] == '\\') { index += Math.Min(2, text.Length - index); continue; } + if (text[index++] == ch) { closed = true; break; } + } + if (!closed) return (text, false); + result.Append(text.AsSpan(start, index - start)); continue; + } + if (ch == '[') + { + var end = text.IndexOf(']', index); if (end < 0) return (text, false); + var symbol = text[index..end]; var match = MatchesSymbol(symbol, name); + result.Append('[').Append(match ? RenameSymbol(symbol, name, replacement) : symbol).Append(']'); found |= match; index = end + 1; continue; + } + if (char.IsAsciiLetter(ch) || ch == '_') + { + while (index < text.Length && (char.IsAsciiLetterOrDigit(text[index]) || text[index] == '_')) index++; + var next = index; while (next < text.Length && char.IsWhiteSpace(text[next])) next++; + var match = text[start..index] == name && (start == 0 || !(char.IsLetterOrDigit(text[start - 1]) || text[start - 1] is '.' or '_')) + && (next == text.Length || text[next] is not ('(' or '.')); + result.Append(match ? replacement : text[start..index]); found |= match; continue; + } + result.Append(ch); + } + return (result.ToString(), found); + } + private static bool MatchesSymbol(string symbol, string name) => symbol == name || symbol == "player." + name || symbol == "save." + name; + private static string RenameSymbol(string symbol, string name, string replacement) => symbol[..^name.Length] + replacement; + private static bool ValidExpression(string text) + { + try { return !new NCalc.Expression(text.Replace('"', '\'')).HasErrors(); } + catch (Exception) { return false; } + } + private string Read(Slot slot) + { + var node = documents.CurrentDocument.Nodes.Single(item => item.Id == slot.Node); + if (slot.Field == "option") return node.Options!.Single(item => item.Id == slot.Item).Condition; + if (slot.Field == "branch") return node.Conditions!.Single(item => item.Id == slot.Item).Expression; + var entry = documents.LoadedProject.GroupEntries[slot.Node].Single(item => item.StableId == slot.Entry); + return slot.Field == "condition" ? entry.Condition : entry.Parameters[slot.Field[10..]].GetString()!; + } + private void Write(Slot slot, string value) + { + var node = documents.CurrentDocument.Nodes.Single(item => item.Id == slot.Node); + if (slot.Field == "option") { node.Options!.Single(item => item.Id == slot.Item).Condition = value; return; } + if (slot.Field == "branch") { node.Conditions!.Single(item => item.Id == slot.Item).Expression = value; return; } + var entry = documents.LoadedProject.GroupEntries[slot.Node].Single(item => item.StableId == slot.Entry); + if (slot.Field == "condition") entry.Condition = value; + else entry.Parameters[slot.Field[10..]] = JsonSerializer.SerializeToElement(value); + } + private sealed record VariableEdit(VariableAuthoringService Owner, VariableScope Scope, int Index, ProjectVariableDefinition? Before, ProjectVariableDefinition? After, Patch[] Patches, EditorText DisplayText) : IUndoableEdit + { + public string Description => "Edit variable"; + public long RetainedBytes => (JsonSerializer.Serialize(Before).Length + JsonSerializer.Serialize(After).Length) * 2L + Patches.Sum(patch => (patch.Before.Length + patch.After.Length) * 2L); + public void Redo() => Apply(Before, After, true); + public void Undo() => Apply(After, Before, false); + private void Apply(ProjectVariableDefinition? expected, ProjectVariableDefinition? value, bool forward) + { + var list = Owner.List(Scope); var uid = (Before ?? After)!.DefaultValue.Uid; + var current = list.SingleOrDefault(item => item.DefaultValue.Uid == uid); + if (JsonSerializer.Serialize(current) != JsonSerializer.Serialize(expected) || Patches.Any(patch => Owner.Read(patch.Slot) != (forward ? patch.Before : patch.After))) + throw new EditorOperationException("variable.changed", "The variable or its references changed outside this edit."); + var oldIndex = current is null ? Index : list.IndexOf(current); + try + { + if (current is not null) list.Remove(current); + if (value is not null) list.Insert(Math.Min(oldIndex, list.Count), value.Clone()); + foreach (var patch in Patches) Owner.Write(patch.Slot, forward ? patch.After : patch.Before); + Owner.Changed(); + } + catch (Exception failure) + { + try + { + list.RemoveAll(item => item.DefaultValue.Uid == uid); + if (current is not null) list.Insert(Math.Min(oldIndex, list.Count), current); + foreach (var patch in Patches) Owner.Write(patch.Slot, forward ? patch.Before : patch.After); + Owner.Changed(); + } + catch (Exception compensation) { throw new HistoryRepairRequiredException("Variable edit compensation failed.", new AggregateException(failure, compensation)); } + throw; + } + } + } + private void Changed() { documents.SynchronizeGroups(); definitions.ResetFromDocument(); } +} diff --git a/src/Editor/GalNet.Editor/Themes/AuroraTheme.axaml b/src/Editor/GalNet.Editor/Themes/AuroraTheme.axaml index 7dc9d21a..40b69339 100644 --- a/src/Editor/GalNet.Editor/Themes/AuroraTheme.axaml +++ b/src/Editor/GalNet.Editor/Themes/AuroraTheme.axaml @@ -32,7 +32,7 @@ - + diff --git a/src/Editor/GalNet.Editor/Themes/EditorButton.cs b/src/Editor/GalNet.Editor/Themes/EditorButton.cs new file mode 100644 index 00000000..50459b05 --- /dev/null +++ b/src/Editor/GalNet.Editor/Themes/EditorButton.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; + +namespace GalNet.Editor.Themes; + +/// Separates icon surfaces from the neutral native-button palette. +public sealed class EditorButton : AvaloniaObject +{ + public static readonly AttachedProperty IsIconProperty = AvaloniaProperty.RegisterAttached("IsIcon"); + static EditorButton() => IsIconProperty.Changed.AddClassHandler