diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 903e06ee..606ce2d4 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -34,6 +34,12 @@ ## 当前工作 +- [F-20261011-03-theme-owned-views](../features/F-20261011-03-theme-owned-views/summary.md) 已移除 GameView 普通页面和默认玩家样式。主题参数必需,最终映射校验完整性;Page 仅保留 GameShell/GamePage/ScreenshotDialog 行为。Themes 提供布局、通用样式与工具栏;截图独立构造/释放,普通页面 Scoped。Editor 显式 Classic 并采用 ThemesAssembly 别名。 + +- [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-20261009-02-video-entries](../features/F-20261009-02-video-entries/feature.md) 已实现剧情视频图层、阻塞分段 seek、停止/替换清理和非阻塞视频定义恢复。平台视频必须经 AssetManager 获取,解码帧通过租约进入场景管线;播放游标不入存档。验证见 feature summary。 - [F-20261010-02-runtime-i18n](../features/F-20261010-02-runtime-i18n/summary.md) Phase 1–5 已实现。Sample 用 Core 2.1.2 + ReflectionBinding;每 JSON 独立 provider,culture-first 查询内嵌/包内/External/额外源;首次选择在 Engine 前原子保存 locale/count。设置切换不重译当前剧情。安装器保留 External 并拒绝覆盖不同包,导出不包含第三方文件。当前契约见 `docs/spec/i18n.md`,人工 GUI 全程与原生媒体 opt-in 验证限制见 summary。 @@ -81,3 +87,7 @@ - [DynamicLocalization Avalonia 主版本兼容性](lessons/L-20261010-05-dynamiclocalization-avalonia-version.md):2.1.0 与 2.1.2 的 Avalonia extension 都不适用于当前 Avalonia 12 Sample;后者虽能编译仍在启动时调用已移除的 Binding 构造函数。Sample 使用 Core + 自有 `ReflectionBinding` 适配层。 - [JSON provider 的整 culture 替换](lessons/L-20261010-07-json-provider-replaces-culture.md):2.1.2 的 LoadJsonString 不增量合并,也不做父 culture 回退;分文件需独立 provider 与宿主有序查询。 - [Render 内的粒子耗尽清理](lessons/L-20261010-08-render-pass-particle-drain.md):confirmed;UI Dispatch 在 UI 线程同步执行,不能作为渲染回调清理的延迟队列;使用保证异步的 `Post`,并以 emitter identity 防止旧回调删除替换实例。 + +- [Headless session cleanup](lessons/L-20261011-01-headless-session-cleanup.md):async Dispatch 测试使用 await using,避免同步 Dispose 等待同一个 session worker;动态 Brush 预期值要在资源宿主中解析或比较语义 Color。 + +- [Editor Avalonia aliases](lessons/L-20261011-02-editor-avalonia-alias.md):Editor 引用 GalNet.Avalonia.* 时采用程序集别名,避免既有 Avalonia 限定名称被相对命名空间解析遮蔽。 diff --git a/.agents/lessons/L-20261011-01-headless-session-cleanup.md b/.agents/lessons/L-20261011-01-headless-session-cleanup.md new file mode 100644 index 00000000..d970848d --- /dev/null +++ b/.agents/lessons/L-20261011-01-headless-session-cleanup.md @@ -0,0 +1,20 @@ +# Headless session cleanup in asynchronous Avalonia tests + +Status: confirmed +Source: F-20261011-01-game-ui-themes + +## Symptom and evidence + +The UI action reached an assertion, then the test appeared to hang until the blame timeout killed testhost. The same case passed after asynchronous session disposal and fixing the palette comparison. Avalonia.Headless 12.0.5 decompiled implementation confirms Dispatch uses a TaskCompletionSource without RunContinuationsAsynchronously, while synchronous Dispose waits for the session dispatch task. + +## Cause and rule + +An awaited Dispatch may resume the test continuation inline on the session worker. Synchronous Dispose can then wait on that same worker task. Use await using for HeadlessUnitTestSession in asynchronous tests; never synchronously dispose the session after awaited Dispatch. A hang at a failed assertion can hide the original assertion error. + +## Related palette observation + +A newly loaded resource dictionary that is not attached to a resource host has unresolved DynamicResource brush colors. Compare semantic Color resources or attach the expected resources before comparing brush colors; do not treat detached transparent brush values as the expected palette. + +## Scope + +Avalonia.Headless session tests with asynchronous Dispatch. Tests: GameThemeTests. See feature summary for final verification. diff --git a/.agents/lessons/L-20261011-02-editor-avalonia-alias.md b/.agents/lessons/L-20261011-02-editor-avalonia-alias.md new file mode 100644 index 00000000..56b8f91d --- /dev/null +++ b/.agents/lessons/L-20261011-02-editor-avalonia-alias.md @@ -0,0 +1,9 @@ +# Editor 的 Avalonia 程序集别名 + +状态:confirmed。来源:F-20261011-03-theme-owned-views。 + +现象:Editor 直接全局引用 GalNet.Avalonia.Themes 后,既有 Avalonia.Controls/Input 等限定名称被解析为 GalNet.Avalonia 下的成员,出现 CS0234 和接口返回类型错误。 + +根因与证据:Editor 位于 GalNet.Editor 命名空间;新增 GalNet.Avalonia.Themes 暴露同级 GalNet.Avalonia,使 Avalonia 的相对名称解析受到影响。既有 GameView 引用已采用 GameViewAssembly 别名。Themes 引用改为 Aliases="ThemesAssembly",仅 EditorPreviewHost 用 extern alias 后,原来 13 个编译错误消失,独立输出目录构建成功。 + +做法:Editor 增加 GalNet.Avalonia.* 引用时沿用程序集别名,局部通过别名访问 API;不要为此批量重写无关 Editor 源码中的 Avalonia 名称。改动后构建 Editor,不能只验证新增程序集。 diff --git a/Directory.Packages.props b/Directory.Packages.props index fdee1e9d..4b5898ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/GalNet.slnx b/GalNet.slnx index a4e2f5be..74e28d2c 100644 --- a/GalNet.slnx +++ b/GalNet.slnx @@ -24,6 +24,7 @@ + diff --git a/GameTestCase/README.md b/GameTestCase/README.md index b3794945..fc451dff 100644 --- a/GameTestCase/README.md +++ b/GameTestCase/README.md @@ -33,9 +33,17 @@ so the picker reappears. Use `--profile ` for an isolated test profil - Configurable `bgm`, `sfx`, and `vocal` tracks: the intro uses `心.mp3` to demonstrate in-progress BGM fade-in, `seffect.gain` apply/stop, `seffect.lowPass` / `seffect.highPass` apply/stop, and in-progress fade-out. The `.rawgalgroup` files are the sole authoring source. Build them with `GalNet.Editor.Headless`; -the generated `.galgroup` files use the version 2 compiled format (`typeId` plus `arguments`). Every +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. +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 +local profile, move `progress.json` aside as a backup before launching again, or +use a new `--profile` directory. This resets read history only; retain player +variables and saves separately. Rebuild content after changing the compiler or +authoring source; do not use `-SkipBuild` with stale output. + The fixture follows the editable project layout: `Graph/graph.json`, `Graph/groups/*.rawgalgroup`, and `Assets/` with each resource's `.meta`. Gallery data is derived from the metadata rather than stored as an editable root `gallery.json`. The classroom background is also a `cg` Gallery item (ID `100`), diff --git a/docs/adr/0003-startup-game-layout-themes.md b/docs/adr/0003-startup-game-layout-themes.md new file mode 100644 index 00000000..5217594f --- /dev/null +++ b/docs/adr/0003-startup-game-layout-themes.md @@ -0,0 +1,23 @@ +# 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/design/game-ui-themes.md b/docs/design/game-ui-themes.md new file mode 100644 index 00000000..9e6aee67 --- /dev/null +++ b/docs/design/game-ui-themes.md @@ -0,0 +1,195 @@ +# 游戏 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/development.md b/docs/development.md index fce5fc72..768fd879 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,7 +9,7 @@ | 自定义剧情指令 | 自己的 Entry Module | 往 Core 写某个项目专属 primitive | | 自定义即时或异步玩法逻辑 | 自己的 `PrimitiveInstance` | 让 Engine 知道项目的指令名 | | 高级作者指令 | 自己的 composite,编译为 primitive | 让 Runtime 直接执行 composite | -| 新页面、主题、窗口或媒体后端 | `MyGame.View` / 客户端宿主 | 修改 `GalNet.Avalonia.GameView` 默认页面 | +| 新页面、主题、窗口或媒体后端 | `MyGame.View` / 客户端宿主 | 把项目布局写入 `GalNet.Avalonia.GameView` | | 新资源类型、decoder、Gallery 页面 | 外层注册和平台程序集 | 让 Core 引用 Avalonia/Skia | | 项目存档位置、账户、平台服务 | 客户端宿主基础设施 | 修改 Runtime 的状态机 | @@ -25,6 +25,20 @@ MyGame/ `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 的 App.PlayerTheme 是实际启动配置入口。 + ## 创建一个自定义原语 ### 1. 选定稳定 type ID 与参数 @@ -164,7 +178,7 @@ public sealed class AlertCompositeEntry : CompositeEntry ## 扩展页面、资源和宿主 -- 在 `MyGame.View` 使用 `AddAvaloniaGameViewPages()` 的注册回调替换默认 View,或追加页面;运行期 registry 构建后不可修改。 +- 在 `MyGame.View` 使用 `AddAvaloniaGameViewPages(theme)` 的注册回调替换主题 View,或追加页面;运行期 registry 构建后不可修改。 - 新 Gallery 类型通过 `IGalleryPageRegistry` 按精确 `typeId` 注册,不按资源类型推断 fallback。 - 新资源类型要在组合期注册同一份 `IResourceTypeCatalog`;decoder 按 `(typeId, CLR 类型)` 注册到资产管理器。开发内容、导出器、安装内容和预览必须使用同一份冻结 catalog。 - 平台媒体若需要文件路径,先通过 `IAssetManager` acquire 内容,再实体化到受控会话临时目录;不要扫描项目目录或 PAK。 diff --git a/docs/spec/control.md b/docs/spec/control.md index 40741e2c..210ff599 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -2,7 +2,7 @@ ## 当前范围 -`GalNet.Avalonia.GameView` 是官方共享的 Avalonia 游戏页面宿主。它提供可替换的 `GameShell`、标题、加载、游戏、存读档、设置、鉴赏、关于和截图页面,并通过组合期注册表完成 ViewModel 到 View 的映射。 +`GalNet.Avalonia.GameView` 是官方共享的 Avalonia 游戏页面宿主。它提供 GameShell、GamePage、截图对话框行为基类及共享 ViewModel/服务;普通页面布局由主题提供,通过组合期注册表完成 ViewModel 到 View 的映射。 页面宿主不读取游戏文件,也不持有项目目录。宿主负责提供游戏内容、资源、存档、玩家变量、进度和媒体实现;编辑器预览与官方 Avalonia Sample 在各自的组合根中装配这些服务。 @@ -20,19 +20,45 @@ ## 页面与组合 -默认页面由 `AddAvaloniaGameViewPages()` 注册: +共享 ViewModel 由 `AddAvaloniaGameViewPages(theme)` 注册,普通 View 由所选主题提供: | 页面 | 作用 | | --- | --- | -| `TitlePage` / `TitlePageViewModel` | 开始、继续和进入其他页面 | -| `LoadingPage` / `LoadingPageViewModel` | 游戏会话加载状态 | +| `TitlePageViewModel` | 开始、继续和进入其他页面 | +| `LoadingPageViewModel` | 游戏会话加载状态 | | `GamePage` / `GamePageViewModel` | 游戏场景、对话、选择和交互 | -| `SaveSlotsPage` / `SaveSlotsPageViewModel` | 存档槽和读写操作 | -| `SettingsPage` / `SettingsPageViewModel` | 运行期设置 | -| `GalleryPage` / `GalleryPageViewModel` | 已解锁内容 | -| `AboutPage` / `AboutPageViewModel` | 宿主提供的关于内容 | +| `SaveSlotsPageViewModel` | 存档槽和读写操作 | +| `SettingsPageViewModel` | 运行期设置 | +| `GalleryPageViewModel` | 已解锁内容 | +| `AboutPageViewModel` | 宿主提供的关于内容 | -宿主可以在 `AddAvaloniaGameViewPages()` 的注册回调中覆盖默认 View,或者追加自定义页面。注册表构建后不可修改。 +宿主可以在 `AddAvaloniaGameViewPages(theme)` 的注册回调中覆盖主题 View,或者追加自定义页面。注册表构建后不可修改。 + +### 布局主题与独立配色 + +`IGameLayoutTheme` 位于 GameView.Composition,提供 `CreateStyles()` 和 `RegisterViews(IPageViewRegistryBuilder)`。`AddAvaloniaGameViewPages(theme: theme)` 主题参数必需,按共享 GamePage 宿主、主题页面、宿主 Gallery 回调、宿主 View 回调顺序注册,随后检查全部必需映射及 GamePage/ScreenshotDialog 基类契约;缺失时列出诊断并拒绝初始化。普通 View 自动补充 Scoped DI 注册,截图对话框每次新建并释放。共享 ViewModel 与业务服务继续由 GameView/宿主注册。 + +可选 `GalNet.Avalonia.Themes` 集合提供 Classic、Minimal、Paper 布局和 Ocean、Rose、Mono 配色,布局与配色任意组合。配置由开发者在启动代码决定,运行期固定。GameShell 获取 DI 中的主题,并把资源与样式加载到游戏根;Sample 和 Editor 预览均显式选择 Classic;GameView 不再提供默认页面或玩家样式。独立语言选择与截图弹窗接入同一主题资源。官方布局使用玩家根内的 Fluent 模板和所选配色,宿主 Semi、编辑器、调试 UI 与系统文件选择器保持各自作用域。 + +资源分为 `GalNet.Color.*` 基础色、`GalNet.Palette.*Color` 语义色、`GalNet.Brush.*` 语义画刷和 `GalNet.Theme.*` 布局尺寸。Controls 提供中性画刷回退;Palette 提供完整配色;内置布局不重定义公共语义色,宿主可通过玩家根 Resources 覆盖。GamePalette 检查 21 个必需画刷;官方 Fluent 桥接需对应 Color 资源或可解析的纯色画刷。 + +普通页保持 UserControl + 共享 ViewModel。三套内置布局分别提供全部普通页面的 XAML View,包含标题、设置、存档、关于、加载、Gallery 入口/列表/详情和诊断页;GameView 不保留普通页面回退;最终映射必须包含全部内置页面与 ScreenshotDialogViewModel。替换单页无需实现完整主题。配置和扩展示例见 [Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md)。 + +页面容器不绘制外框。同一主题内保持一致的返回位置:Classic 顶部页头右侧、Minimal 侧栏底部、Paper 居中标题左侧。图片按比例缩放至独立内容区,不覆盖返回操作。`GalNet.Theme.PageMaxWidth`、`PageHeaderMinHeight`、`BackMinWidth`、`BackMinHeight` 可调整整体宽度与返回点击区域;这些尺寸属于玩家设计画布,实际显示受宿主缩放影响。 + +对白 `DialogueFontSize` 与 `SpeakerFontSize` 独立配置,内置主题人物名比正文大 4 个设计单位(Classic/Paper 为 26/22,Minimal 为 24/20);中性回退为 22/18。 + +### GamePage 模板契约 + +`GamePage` 是稳定的 TemplatedControl,拥有 `Scene`、`SceneHost`、`Dialogue`、`Nvl`、`Choice` 内容及输入行为。其 ControlTheme 必须包含 `PART_Scene`、`PART_Dialogue`、`PART_Nvl`、`PART_Choice` 四个 ContentPresenter,并用 TemplateBinding 呈现对应内容。不得创建第二套核心 presenter 或重复挂载内容;缺失或错误承载点在模板应用时明确报错,阻塞呈现前调用 EnsureLayout。 + +GamePageViewModel 的自定义 View 必须继承 GamePage;DI 中的 GamePage 别名与页面工厂解析同一 Scoped 实例。只变更布局推荐覆盖 ControlTheme。模板重建及运行期主题切换不在当前契约内,NVL/打字机内部部件仍保存呈现状态。 + +页面识别按钮、SelectingItemsControl、TextBox 和 RangeBase 的交互;自定义交互区域设置 `GamePage.IsPlayerInteraction=true`,避免点击操作同时推进剧情。非交互区域继续调用共享 ViewModel 的推进逻辑。 + +### 截图对话框契约 + +主题把 ScreenshotDialogViewModel 映射到 ScreenshotDialog 派生 View。XAML 加载后调用受保护的 InitializeDialog,布局必须提供 Preview(Image)、DirectoryBox/TextBox、FileNameBox/TextBox、IncludeUiBox(CheckBox)、BrowseButton/CancelButton/SaveButton(Button)、ErrorBlock(TextBlock)。缺失控件明确报错。基类管理选择目录、预览更新、保存、取消和 Bitmap 释放;服务通过 DI 创建独立实例,并加载当前主题样式。宿主可在 configureViews 中覆盖截图 View,构造参数可注入宿主服务。 ### Gallery 页面 @@ -40,22 +66,28 @@ Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映 ### 设置与界面文本 -`SettingsPageViewModel` 通过通用的 `IPlayerSettingsService.Items` 获取设置项,不绑定语言专用服务。页面支持 `ChoicePlayerSetting` 和 `RangePlayerSetting`;默认服务提供文本速度,宿主可在 DI 中替换服务并提供其他设置。设置应用、恢复和持久化由宿主负责,共享页面不读取配置文件。 +`SettingsPageViewModel` 通过 `IPlayerSettingsService.Categories` 获取只读分类及类型化设置项,分类使用局部 TabControl,不创建导航子页面。支持整数/浮点(Slider 或 NumericUpDown)、Bool、String、Choice、Action 与自定义编辑器。Step 仅改变操作步长,不参与校验;动态可见性、可用性和原因 key 驱动界面更新。应用/保存由 SettingsCoordinator 串行协调,保存失败可重试而不重复应用。共享页面不读取配置文件。 + +内置主题的 `Common.SettingsCategories.EditorTemplates` 接收按顺序匹配的 `IDataTemplate` 列表,先匹配宿主/主题覆盖,再回退内置编辑器;只替换匹配类型,保留统一设置行的标签、说明、可用性与错误。自定义设置通过 `CustomSettingEditor` 创建独立控件租约并在 detach 时释放。 共享页面的静态文案使用 `PlayerTextExtension`,未接入宿主本地化时显示默认英文。官方 Sample 提供 `LocalizedString` 并绑定其 `Value`,沿用 DynamicLocalization 的 Culture/Provider 变更通知机制;宿主设置标签也可以使用同样的动态对象。该绑定不会自动翻译任意业务字符串或剧情内容。 -Sample 在游戏会话初始化成功后,把设置接入现有 Player 变量存储,恢复语言与文本速度。语言变更(包括直接调用 CultureService)立即刷新绑定并排队持久化;清空玩家状态和销毁会话前等待写入完成。 +Sample 在会话初始化后将设置接入 Player 变量存储,恢复语言、文本速度、快速已读限制、快速/自动间隔、音量/静音及显示偏好。等待时间使用整数毫秒:快速默认 50、范围 0–5000、步长 10;自动默认 2000、范围 0–30000、步长 100;旧浮点秒值视为类型无效,回退默认值,不迁移。画面能力由宿主提供,桌面 Sample 支持窗口/无边框全屏与预设窗口尺寸,非独占全屏;全屏时禁用窗口尺寸。语言变更立即刷新绑定并排队持久化;清空玩家状态和销毁会话前等待写入完成。 `GalleryNavigationService` 不按资源类型推断页面:没有有内容类型时入口不可用,只有一个时直接打开该页,多个时先进入类型选择页;未注册页面会显示诊断页。CG、视频和音频是独立页面/媒体状态,不使用 renderer enum 或单一多媒体 ViewModel。 ## 运行期呈现边界 +游戏工具栏的快速/自动模式互斥且允许均关闭,再次点击已选模式可取消。两模式未选中时保持透明,仅选中时显示主题色背景;其他工具栏按钮平时透明,悬浮使用淡色背景,按下加深。左键取消旧模式后,模式按钮按点击前状态决定开启/关闭,并从权威播放状态刷新选中绑定;工具栏操作不同时推进剧情。 + Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePresenters` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;它不是页面或执行 view。宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。 各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePresenters` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。玩家推进从 `GamePageViewModel.AdvanceRequested` 直接连接宿主,不经过 dialogue presenter 或 presenter 集合转发。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。 官方 Sample 使用 `SampleGameResourceScope`、`SampleSaveSession`、`SampleGameRunner` 和 `PersistentSceneRestorer` 分离资源、玩家持久化、页面运行编排与读档展示重放。`SampleGamePresentation` 集中创建和释放执行 view 与平台后端,创建失败与正常销毁使用同一条清理路径;单个对象释放失败仍继续释放其他对象。Runner 只绑定页面操作、处理 Engine 状态通知和宿主反馈,不重复推断剧情终态。读档顺序固定为恢复 Runtime snapshot、重放持久展示、页面切换后首次调用 `AdvanceAsync`;reload 和清空玩家状态必须先停止并释放 runner,再释放资源或重建持久化状态。 +Sample 将 `SampleGamePresentation` 作为 Engine 的 `ICatchUpPresentation` 接入:开始时切入音频投影、缓存文本状态,并暂停/排空恢复出来的展示续跑;结束时清理旧 visual 资源,通过该代 scoped runtime/token 重建最终持久场景,再提交对白/NVL 历史及音频。`ICatchUpTextRecorder` 是可选的无中间渲染文本投影端口,已读文本的追帧调用不会被视为一次正常成功阅读。可见性及 NVL clear 同样延迟到最终同步。追帧不扩展存档格式,不保存 NVL 展示历史或媒体游标。 + ## 宿主接入约束 - 每次独立运行必须使用独立的游戏会话与页面 Scope;切换项目或重启预览时释放旧 Scope。 diff --git a/docs/spec/i18n.md b/docs/spec/i18n.md index cd7864c6..759bab01 100644 --- a/docs/spec/i18n.md +++ b/docs/spec/i18n.md @@ -14,7 +14,7 @@ Sample UI 按 DynamicLocalization 官方 Avalonia JSON demo 绑定 `LocalizedString.Value`;这些对象自行监听 `CultureChanged` 和 `ProvidersChanged`,因此直接更改 culture 也会刷新已有绑定。Sample 使用 Avalonia 12 的 `ReflectionBinding`,并在退出时释放缓存的 `LocalizedString` 订阅。共享页面通过宿主提供的 observable text 适配获取文案。 -共享 Settings 页依赖通用 `IPlayerSettingsService.Items`,按选择项与数值范围项的模板显示宿主设置,不依赖语言专用服务。Sample 注册语言和文字速度两项,变更写入当前 session 使用的 `FileVariableService`:`player.galnet.settings.locale` 与 `player.galnet.settings.textSpeed` 位于 profile 的 `player-variables.json`,不进入游戏存档。成功初始化 session 后恢复偏好;正常关闭等待已排队的保存,清空玩家数据同时删除这些偏好。 +共享 Settings 页依赖通用 `IPlayerSettingsService.Categories`,按类型化设置项显示宿主提供的分类,不依赖语言专用服务。Sample 的语言、文字速度、播放间隔/已读限制、音量/静音和显示偏好写入 session 的 `FileVariableService`,使用 `player.galnet.settings.*` 命名空间,位于 profile 的 `player-variables.json`,不进入游戏存档。快速/自动间隔均保存为整数毫秒,旧浮点秒偏好回退默认值而不迁移。成功初始化 session 后恢复偏好;正常关闭等待已排队的保存并重试待保存项,失败向调用者传播;清空玩家数据同时删除这些偏好。名称、说明、单位、禁用原因和错误 key 使用共享动态本地化适配,原因同时提供 tooltip 和行内说明。 `I18nKey` 是点分隔键的轻量封装。推荐使用稳定的 Group/Branch ID,而不是可变的显示名称: diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 96bd24c0..e370a1ec 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -34,7 +34,7 @@ public ValueTask DisposeAsync(); 4. 如果调用开始时已有未完成 blocking instance,选择 sequence 最早者所属 batch,只 skip 该 batch 中当前已分发且可跳过的实例。 5. 如果当前 blocking 已解除,继续按顺序消费剧情,直到遇到新的 blocking instance、Choice 等待或剧情结束。 -Blocking instance 自然完成时,Engine 只执行无 skip 权限的内部 continue;它不是第二个玩家入口,也不能跳过下一个新遇到的 batch。 +手动模式下 Blocking instance 自然完成时,Engine 只执行无 skip 权限的内部 continue;它不是第二个玩家入口,也不能跳过下一个新遇到的 batch。自动/快速模式在有效推进边界等待全部 `IsAutoBlocking` 解除,再计时推进;不会通过模拟点击执行。 ### 状态通知和停止 @@ -111,6 +111,7 @@ public abstract bool IsBlocking { get; } public abstract bool IsSkippable { get; } public string? BatchId { get; } public bool IsCompleted { get; } +public virtual Task PresentationCompletion { get; } public void Dispatch(); public void Skip(); ``` @@ -125,6 +126,12 @@ public void Skip(); 实例可以内部持有 Task、取消源、动画游标或文本游标;这些都是运行期状态,不进入内容或存档格式。 +`PresentationCompletion` 默认已完成;异步 presenter 原语覆写为包含 finally 清理的任务。逻辑 `IsCompleted` 不代表后端和旧回调已经清理完。Engine 进入追帧时先切换已有实例的模式并等待展示任务,再继续执行和最终重建;非 Engine 所有的恢复实例由宿主同样跟踪、压缩并等待。`BuiltinPresentationReplay` 的 `onContinuation` 在 Dispatch 前交付恢复出来的动画/视频实例,避免部分重建失败时丢失跟踪。 + +`SynchronizePresentationAsync` 与追帧展示协调在 Engine 执行门内调用;回调及其等待的清理任务不得重新进入或等待 Engine 推进/模式操作,否则会形成循环等待。同步使用该代隔离的 runtime 和 run token;调用者 token 只控制进入同步门,不作为持久播放的生命周期 token。 + +玩家间隔配置为 `GameSettings.AutoAdvanceIntervalMilliseconds` 与 `QuickAdvanceIntervalMilliseconds`(int,默认 2000/50),Runtime 以毫秒计时并拒绝负值。Normal/Quick/CatchUp 分发与 `IsAutoBlocking`、状态事件、`IAdvanceWaitInstance` 分别表达呈现策略、自动等待和全文后推进等待;自动模式不会把对白等待下一次推进误当作仍在打字。完整阅读成功使用 group ID + 稳定编译条目 ID 记录,progress v2 与 compiled v3 拒绝旧格式,不迁移。 + ## Entry Module 与 GameView 契约和执行独立注册: @@ -157,7 +164,7 @@ Engine 不拥有 view。宿主先释放 Engine,再 `await view.DisposeAsync()` - `variable.set`:求值后写入 Runtime 变量。 - `gallery.unlock`:按稳定 item ID 解锁 Gallery,写入对应的系统 Player bool。 -视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由内置模块实现。bus/master 处理、item effect、参数动画、存档恢复和玩家音量设置仍由后续 feature 补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 +视频图层与音频的轨道播放、淡化、队列和当前轨道 SEffect 行为由内置模块实现。玩家音量/静音按 master × bus × 内容 gain × 轨道默认 gain 合成并应用到当前及后续 voice;item effect、音频参数动画和音频存档恢复仍未实现,读档后不会自动续播旧音频。向前追帧的 BGM 最终投影属于会话内状态,不等于音频存档恢复。Gallery 的平台无关 catalog 与解锁数据层由 Core/Runtime 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 动画 plan 的事件与轨道共用 presenter 的播放时间源。`PlayAnimationPlanAsync` 必须在采样前 await `beforeFrame(elapsedFrames)`,正常完成前提供末帧;Loop/PingPong 的 elapsedFrames 单调增加,不在循环时归零。跳过不补发未来 particle burst。 diff --git a/features/F-20261011-01-game-ui-themes/design.md b/features/F-20261011-01-game-ui-themes/design.md new file mode 100644 index 00000000..601dd65f --- /dev/null +++ b/features/F-20261011-01-game-ui-themes/design.md @@ -0,0 +1,15 @@ +# 实施设计 + +依据 [长期设计](../../docs/design/game-ui-themes.md),采用启动期固定配置。 + +IGameLayoutTheme 位于 GameView 的组合命名空间,提供 Avalonia 样式入口和页面注册方法。AddAvaloniaGameViewPages 保留旧调用兼容性,增加主题参数;主题先注册普通页与 Gallery 覆盖,宿主回调最后覆盖。最终 View 类型自动补充 Scoped DI 注册,避免开发者覆盖映射却遗漏 View 注册。 + +Themes 独立项目包含色表、完整 Palette、共享样式、Classic/Minimal/Paper 标题页面与 GamePage ControlTheme。通过同一个布局主题对象提供资源与页面注册,Sample 在 App 初始化选择主题,在 DI 注册前使用同一对象。资源可应用于游戏根以支持 Editor 隔离。 + +GamePage 迁移为 TemplatedControl,拥有稳定 Scene、Dialogue、Nvl、Choice 内容;模板用 ContentPresenter 安排布局,工具栏是主题模板里的共享命令绑定。必需承载点在 OnApplyTemplate 验证。GameShell 的导航、截图及会话生命周期保留。 + +所有默认玩家页移除固定语义色,引用统一资源,布局参数按需提取,基础资源保证未引用 Themes 的嵌入宿主仍能使用默认页面。每套布局通过模板与主题专用资源调整控件和页面;普通页面可复用基础页面,不机械复制业务逻辑。 + +不支持运行中换完整模板。输入排除由交互标记与常见交互控件识别保障,避免自定义工具栏操作触发剧情推进。 + +验证采用 Presentation 层注册与 headless Avalonia 模板/页面测试、Sample/Desktop 构建、Architecture 门禁及格式检查。新 UI 资源须渲染验证;若环境无法显示原生 UI,在 summary 说明验证范围。 diff --git a/features/F-20261011-01-game-ui-themes/feature.md b/features/F-20261011-01-game-ui-themes/feature.md new file mode 100644 index 00000000..7b8b1fc6 --- /dev/null +++ b/features/F-20261011-01-game-ui-themes/feature.md @@ -0,0 +1,30 @@ +# 游戏布局主题与配色 + +状态:complete。用户于 2026-10-11 明确授权开始实现;实现、回归和开发者文档已完成,验证边界见 summary.md。 + +## 已确认需求 + +- 官方内置布局主题集中在独立 GalNet.Avalonia.Themes 项目,提供普通页面 View 和控件主题。 +- 配色与布局独立组合,配色引用基础色表并暴露统一语义资源。 +- 组合顺序:默认页面 → 所选主题页面 → 开发者页面覆盖;最终注册表不可变。 +- GamePage 为稳定可模板化宿主;主题负责布局,共享层保留游戏行为。 +- 开发者在启动代码/XAML 配置,第一版无动态主题切换、玩家变量绑定或运行期选择器。 +- 覆盖共享玩家页面和 Sample 启动、首次语言选择、截图弹窗;调试 UI、编辑器与系统文件选择器不在范围内。 + +## 验收 + +三套布局不同的内置示例及独立配色可构建和展示;页面覆盖可单独注册;GamePage 的首条对白、NVL、选择、输入和截图能力不被模板化破坏;资源与页面覆盖有确定优先级;开发者文档包含配置与局部覆盖示例。 + +## 约束 + +Core/Runtime 不依赖 Themes。保护原有 Sample csproj 的 DependentUpon 修改。采用普通分阶段流程,因为影响公共模板契约与多个页面;不再压缩为快速通道。 + +## 文档 + +- [design.md](design.md) +- [phase-plan.md](phase-plan.md) +- [review.md](review.md) +- [summary.md](summary.md) +- [长期设计](../../docs/design/game-ui-themes.md) + +默认示例选择 Classic + Ocean,另提供 Minimal、Paper 布局与 Rose、Mono 配色;名称属于实施默认值,不增加玩家选择流程。 diff --git a/features/F-20261011-01-game-ui-themes/phase-plan.md b/features/F-20261011-01-game-ui-themes/phase-plan.md new file mode 100644 index 00000000..e8989940 --- /dev/null +++ b/features/F-20261011-01-game-ui-themes/phase-plan.md @@ -0,0 +1,19 @@ +# 分阶段计划 + +## Phase 1:组合契约与稳定 GamePage — complete + +提供主题入口、默认→主题→宿主的注册顺序及自动 View 注册;模板化 GamePage 并保留行为。退出条件:Presentation 测试验证覆盖顺序、模板部件、核心控件 identity 与首条对白。 + +证据:GameThemeTests 验证主题/宿主优先级、Scoped View 复用、派生 GamePage 与 Runtime 别名 identity、损坏模板诊断,以及九种组合的真实 presenter/点击/选择/NVL 链路。 + +## Phase 2:内置布局、配色与全页面覆盖 — complete + +新增 Themes 项目、三套布局与三套独立配色,接通 Sample。退出条件:全部组合能加载,所有共享页及启动/截图 UI 引用配色,Sample/Desktop 可构建,渲染检查布局。 + +证据:Classic/Minimal/Paper × Ocean/Rose/Mono 的标题、游戏、共享页面和截图界面 Skia headless 渲染通过;Sample 与 Editor 构建通过,Editor 使用独立 OutDir 避开正在运行的程序锁。 + +## Phase 3:回归与开发者文档 — complete + +运行相关测试、架构与格式检查,修复本 feature 引入问题,同步 spec/README 和总结。退出条件:记录验证证据、已知限制、原有用户修改仍保留。 + +证据见 summary.md。已同步 control spec、开发者手册、Themes README 与 ADR,保留原有 Sample csproj DependentUpon 修改;运行期换主题、原生媒体及系统选择器未扩充范围。 diff --git a/features/F-20261011-01-game-ui-themes/review.md b/features/F-20261011-01-game-ui-themes/review.md new file mode 100644 index 00000000..de2a7550 --- /dev/null +++ b/features/F-20261011-01-game-ui-themes/review.md @@ -0,0 +1,9 @@ +# 实施者自查 + +结论:pass。此记录为实施者自查,不是独立 agent 审核。 + +检查主题/配色依赖方向、默认/主题/宿主注册优先级、View Scope 与 GamePage alias identity、模板必需部件、输入路由、截图资源清理、Sample 外围页面覆盖和 Editor 默认回退。相关验证及环境限制见 summary.md。 + +自查修正:派生 GamePage 的 DI 别名、默认场景承载点 Stretch、设置面板宽度与 Fluent Palette 桥接、存档卡行高、Paper/Minimal 的外边距背景、选项焦点/禁用状态、强调文字对比度、截图事件订阅与 Bitmap 释放。 + +未发现阻止本期启动固定主题交付的问题。后续运行期切换需单独处理模板内部状态与媒体生命周期;外部宿主的尺寸/DPI 与原生媒体仍按各自环境验证。 diff --git a/features/F-20261011-01-game-ui-themes/summary.md b/features/F-20261011-01-game-ui-themes/summary.md new file mode 100644 index 00000000..3289ab6c --- /dev/null +++ b/features/F-20261011-01-game-ui-themes/summary.md @@ -0,0 +1,41 @@ +# 实现总结 + +2026-10-11。 + +## 结果 + +新增可选 GalNet.Avalonia.Themes 项目,提供 Classic、Minimal、Paper 三种布局与 Ocean、Rose、Mono 三种独立配色。Sample 默认 Classic + Ocean,开发者在 App.PlayerTheme 选择启动配置,GameShell 自动加载相同主题。标题、加载、设置、存读档、鉴赏、关于、对白/NVL/选择/工具栏、截图弹窗和启动/语言选择界面接入语义资源。 + +页面映射按默认→主题→宿主 Gallery→宿主普通 View 覆盖组成不可变注册表,最终 View 类型自动注册到 Scoped DI;替换单页无需新主题。GamePage 从 UserControl 迁移为稳定 TemplatedControl,保留 scene/presenter 状态与输入接线,用四个必需内容承载点开放布局。派生 GamePage 与 Runtime 使用同一实例,错误类型和模板明确诊断。 + +Controls 的基础模板使用 ControlTheme 与中性回退资源。官方布局在玩家根内加载 Fluent 并桥接 Palette,隔离 Sample Semi/Editor;截图弹窗显式加载样式并释放预览图片/订阅。布局不覆盖公共配色键,宿主根 Resources 可局部覆盖。 + +## 验证 + +- Presentation:92 通过;包含 15 个新主题测试用例,九种组合均通过真实对白 presenter、等待推进、工具栏输入隔离、选项、NVL 重挂状态和场景 PNG 验证。 +- Integration:62 通过,覆盖 Sample 会话、存读档和本地化回归。 +- Architecture:25 通过,Themes 仅依赖 Controls/GameView;Shared 无反向依赖。 +- Sample 构建、Editor 独立输出目录构建通过。现有 AVLN3001 构造函数加载提示和其他模块 CA1716/CA1861 警告仍存在,无新增编译错误。 +- 变更 C# 文件的 dotnet format whitespace 与 IDE0005 检查通过,git diff --check 通过。 +- 九种组合所有共享页在 1280×720 headless Skia 加载并渲染,检查标题/游戏/设置/存档/选项/NVL/截图代表画面;修正白色外边距、设置 Slider 压缩与配色、存档卡过度拉伸问题。 +- 渲染图片位于忽略目录 artifacts/game-ui-themes;设置 GALNET_THEME_CAPTURE_DIR 可重新导出。 + +## 偏差与边界 + +本次影响公开模板契约和多页面,按普通分阶段流程执行,没有使用快速流程压缩。普通页面共享默认 View 和尺寸资源,只为三套标题提供专用 View;不复制每个页面。 + +仅代码启动配置,不提供直接主题 XAML 元素、运行期换主题/配色、玩家变量绑定或主题 ID 继承管理。Paper 标题固定棕色矢量纹理属于原创装饰美术,文本和交互跟随 Palette。 + +真实 LibVLC 媒体 opt-in 测试未启用,未验证原生播放、系统对话框、全部 DPI/字体环境。headless 视频页测试加载空播放状态,不声称验证播放后端。Sample 按 1920×1080 画布缩放,外部宿主需验证自己的尺寸;Paper 适合宽屏布局。调试确认/通知和窗口装饰由宿主负责。 + +默认 Editor 输出被已有进程锁定,独立 OutDir 构建通过;没有停止用户进程。原有 Sample csproj LanguageSelectionView 的 DependentUpon 修改保留。未创建 commit/PR。 + +## 文档 + +- [Themes 配置与扩展](../../src/Presentation/GalNet.Avalonia.Themes/README.md) +- [当前游戏页面规范](../../docs/spec/control.md) +- [开发者手册](../../docs/development.md) +- [长期设计与落地差异](../../docs/design/game-ui-themes.md) +- [ADR 0003](../../docs/adr/0003-startup-game-layout-themes.md) + +lesson:Headless session 的 async Dispatch 应 await using 清理,避免同步 Dispose 自等待;已记录在 .agents/lessons/L-20261011-01-headless-session-cleanup.md。 diff --git a/features/F-20261011-02-player-page-layout/design.md b/features/F-20261011-02-player-page-layout/design.md new file mode 100644 index 00000000..8d1ff4b5 --- /dev/null +++ b/features/F-20261011-02-player-page-layout/design.md @@ -0,0 +1,11 @@ +# 设计 + +## 2026-10-11 用户修订 + +原先七页共享 View 的方案只提供资源变化,现改为三套布局各自拥有全部普通页面 XAML View(仅 InitializeComponent 构造),业务/媒体生命周期全部留在共享 ViewModel。页面 XAML 有意作为可复制修改的参考,不复制 code-behind 业务。Classic 顶部页头、Minimal 侧栏与底部返回、Paper 居中标题与左侧返回;同一主题内各页返回位置一致。页面与媒体容器取消描边;内容卡和按钮仍有自己的状态视觉。对白 Classic/Paper 正文22、名字26,Minimal正文20、名字24,中性回退正文18、名字22。以下保留上一轮设计记录。 + +保持普通 View + 共享 ViewModel,使用玩家根 Styles 内的 page-frame、back、settings-card、media-stage 视觉类,不引入新的公共控件或页面基类。 + +七个页面共用铺满可用高度且限制最大宽度的面板:同一主题外边距/内边距、页头、分隔线和可滚动/受限内容区。返回固定右上角,点击区域至少 120×48;长标题截断,不压缩返回按钮。设置项在页头下的内容卡中滚动,Gallery 列表继续按媒体种类呈现。图片使用 Uniform 保持比例,位于页头下的独立边框中;视频原生控件也被安排在独立内容区。 + +共享语义配色和主题圆角/字体继续生效。新增 PageMaxWidth、BackMinWidth、BackMinHeight 资源供外层覆盖。图片不会扩展页面到标题栏以外,不增加自动放大或全屏入口。 diff --git a/features/F-20261011-02-player-page-layout/feature.md b/features/F-20261011-02-player-page-layout/feature.md new file mode 100644 index 00000000..b827c27a --- /dev/null +++ b/features/F-20261011-02-player-page-layout/feature.md @@ -0,0 +1,11 @@ +# 设置与 Gallery 页面统一布局 + +状态:complete。2026-10-11 用户继续要求去除页面外框、调整对白字号,并让各布局主题提供自己的页面;本轮补充范围已实现并验证。 + +三主题分别提供全部普通页面 View,复用共享 ViewModel;保留按主题一致的导航位置,Classic 顶部、Minimal 侧栏、Paper 阅读式居中布局。页面外框取消,人物名字号比正文大 4 个设计单位;正文比上一版小 2。 + +本轮范围:设置、Gallery 类型入口、CG/视频/音频列表、图片/视频详情。统一页面内容边框、标题、留白和返回位置,避免进入图片后内容扩大且返回难点。其他页面后续逐个推进。 + +验收:九种主题/配色组合加载与渲染通过;大图片不覆盖页头,800×500 与 1280×720 下返回按钮仍可见并实际可点击;ViewModel、导航、媒体生命周期不变。 + +先前主题实现已独立提交 a50f75a,本轮不混入该提交。 diff --git a/features/F-20261011-02-player-page-layout/phase-plan.md b/features/F-20261011-02-player-page-layout/phase-plan.md new file mode 100644 index 00000000..64744a4c --- /dev/null +++ b/features/F-20261011-02-player-page-layout/phase-plan.md @@ -0,0 +1,13 @@ +# 阶段 + +## 页面框架与交互验证 — verified + +统一七个页面和视觉类,渲染设置/有缩略图的 Gallery/大图详情;以实际鼠标返回验证导航,检查大小窗口及所有主题组合。退出条件为相关展示层回归、Sample 构建和格式检查通过,并记录预览与验证边界。 + +证据:Presentation 98 通过,新增六个大图返回检查,九种组合的面板与返回几何一致;Sample 构建通过,变更测试文件格式/import 和 diff 检查通过。渲染目录 artifacts/player-page-layout。 + +## 用户修订:主题独立页面及字号 — verified + +补齐三套主题的全部普通页面 View;移除页面外框,调整对白名字/正文层级;验证各主题内导航一致且大图不会遮挡返回。退出条件:最终展示与集成回归、主题项目格式和 Sample 构建通过;记录渲染对比。渲染目录 artifacts/theme-owned-pages。 + +证据:最终 Presentation 98、Integration 62 通过(并构建 Sample);Themes whitespace/IDE0005 和变更测试文件 whitespace 检查通过。九种组合验证 View 属于各自主题命名空间、页面无外框和名字字号,大图返回覆盖两种窗口尺寸。修正 Paper 宽度资源的重复定义后全套回归通过。 diff --git a/features/F-20261011-02-player-page-layout/review.md b/features/F-20261011-02-player-page-layout/review.md new file mode 100644 index 00000000..0b931cb1 --- /dev/null +++ b/features/F-20261011-02-player-page-layout/review.md @@ -0,0 +1,7 @@ +# 实施者自查 + +结论:pass。本记录不代表独立 agent 审核。 + +三主题分别注册全部普通页面,代码隐藏层只负责初始化,共享业务/媒体生命周期未复制。GamePage 保留原内容实例与模板契约;所有页面外框取消,控件状态视觉保留。回归检查字体层级、主题页面归属、资源隔离、导航位置及大图返回,最终 98 个展示层和 62 个集成测试通过。 + +可编辑页面 XAML 的绑定存在三套视觉副本,这是本次明确选择的主题参考设计;业务变更应修改共享 ViewModel。人工完整 Sample、实际原生媒体和 DPI/font 全环境验证仍待各宿主环境执行。 diff --git a/features/F-20261011-02-player-page-layout/summary.md b/features/F-20261011-02-player-page-layout/summary.md new file mode 100644 index 00000000..06e1e1ee --- /dev/null +++ b/features/F-20261011-02-player-page-layout/summary.md @@ -0,0 +1,27 @@ +# 实现总结 + +## 用户修订后的最终实现 + +三套主题分别拥有 12 个普通页面 View(标题及设置、存档、关于、加载、Gallery 入口/列表/详情/诊断),全部由主题注册,不再只共用默认页面换资源。Classic 使用顶部页头与右侧返回,Minimal 使用侧栏与底部返回,Paper 使用居中标题与左侧返回;同一主题内各页的面板尺寸与返回区域一致。各主题目录的 XAML 可独立编辑,仅共享 ViewModel/命令,没有复制媒体生命周期。 + +页面外框和媒体容器描边已取消,Classic 标题装饰外框、Paper 标题及场景外框也去除;按钮、存档卡等控件仍保留状态视觉。对白 Classic/Paper 正文22、人物名26,Minimal正文20、人物名24,中性回退正文18、人物名22。正文比上一版小2,人物名比正文大4。 + +新页面包含全部普通 View,设置页面各自采用内容卡、无框侧栏表单或阅读式表单;图片与导航区域分离,不要求所有主题都使用顶部页头。 + +验证覆盖九种组合的全部普通页面归属、无外框、几何一致、名字/正文字号、首次对白/NVL/选择以及六个大小窗口的大图鼠标返回。原生媒体和人工完整 Sample 操作仍未纳入 headless 验证。 + +最终 Presentation 98、Integration 62 通过,Sample 随集成构建成功;Themes 项目 whitespace/IDE0005、变更测试文件 whitespace 检查通过。修正 Paper 重复资源项后全套回归通过。新预览目录 artifacts/theme-owned-pages,包含三主题的页面与对白对比。 + +## 上一轮实施记录 + +本轮统一设置及 Gallery 类型入口、CG/视频/音频列表、图片/视频详情七个页面。普通 View 和媒体/导航生命周期保持原样,使用共享视觉类与主题资源形成一致面板、页头、分隔线和返回位置。 + +设置从居中小面板调整为与 Gallery 一致的页面区域,设置项在内容卡内滚动。返回按钮扩大到至少 120×48 设计画布单位,长标题截断;实际像素尺寸随宿主画布缩放。图片保留比例,限定在独立媒体区中,不占页头;视频亦使用同一布局。缩略图卡片撑满统一格子,锁定状态与有图状态尺寸一致。 + +PageMaxWidth、PageHeaderMinHeight、BackMinWidth、BackMinHeight 可由宿主覆盖;延续各布局现有间距、字体、圆角和独立配色。未新增全屏放大、键盘快捷键、公共页面控件或运行期主题 API。 + +验证:Presentation 98 通过,其中六个新增检查覆盖三布局 × 两窗口尺寸(800×500/1280×720),使用 3840×2160 图片和长标题验证媒体/返回位置及实际点击返回导航;九种布局配色组合检查七页一致几何并渲染。Sample 构建通过,格式与 IDE0005/diff 检查通过。渲染自查修正页头高度造成的 1 像素偏差及锁定卡片高度不足。 + +预览位于忽略目录 artifacts/player-page-layout,通过 GALNET_THEME_CAPTURE_DIR 可重新生成。本次为 headless Skia 渲染和鼠标路由检查,没有人工操作完整 Sample 或验证原生 LibVLC 播放;既有 opt-in 原生媒体测试未启用。 + +规范更新在 docs/spec/control.md,主题扩展说明在 Themes README。先前主题实现已提交 a50f75a,本轮页面改动留在工作区供查看。后续用户修订已补齐关于、存读档、加载与诊断等主题页面,见本文最终实现。 diff --git a/features/F-20261011-03-theme-owned-views/design.md b/features/F-20261011-03-theme-owned-views/design.md new file mode 100644 index 00000000..0b0a009b --- /dev/null +++ b/features/F-20261011-03-theme-owned-views/design.md @@ -0,0 +1,11 @@ +# 设计 + +当前 GameView 普通页面与主题页面重复,缺失主题映射会悄悄回退。改为必需的启动主题,注册顺序为共享 GamePage 宿主 → 主题 → Gallery 扩展 → 宿主 View 覆盖 → 完整性校验。GameView 不反向依赖 Themes。 + +Gallery 类型与 View 映射分离:内置 cg/video/audio 只注册对应 VM,主题提供 View。扩展的 Add/Replace 仍可同时指定自定义 View。 + +通用玩家样式、工具栏移动到 Themes/Common,布局主题保留各自 GamePage 模板。GameShell 只提供导航容器,主题决定其背景。 + +ScreenshotDialog 成为无布局的共享行为基类,主题派生 View 提供约定的具名控件并在加载 XAML 后初始化行为。截图 VM 也参与 View 注册,宿主可按相同机制覆盖它;服务每次创建并释放对话框,避免复用已释放的预览。截图捕获、文件选择、保存和 Bitmap 生命周期仍归 GameView。 + +公共 API 不再允许省略主题;外部主题需提供完整页面集与截图 View。注册器验证 GamePage/ScreenshotDialog 基类契约并列出缺失映射。Sample 保持 Classic,Editor 显式 Classic。删除旧布局不会删除共享 VM、呈现器、命令和生命周期。 diff --git a/features/F-20261011-03-theme-owned-views/feature.md b/features/F-20261011-03-theme-owned-views/feature.md new file mode 100644 index 00000000..7ee06b49 --- /dev/null +++ b/features/F-20261011-03-theme-owned-views/feature.md @@ -0,0 +1,11 @@ +# F-20261011-03 Theme-owned views + +状态:完成。用户授权先提交 F02,再清理 GameView 默认页面。 + +目标:GameView 保留状态、导航和共享行为;布局主题提供全部普通页面、工具栏与截图对话框布局。Sample、Editor 显式选择主题;开发者仍可覆盖单个 View。 + +验收:没有默认普通页面回退;注册时检查所有必需映射;GamePage 仍是运行时同一 Scoped 实例;三主题截图行为与页面可用;宿主覆盖优先;Presentation、Integration、Architecture 测试通过,Sample 与 Editor 构建通过。 + +不包含运行时主题切换、配色设计变更或核心运行时改动。 + +设计见 design.md,阶段见 phase-plan.md。 diff --git a/features/F-20261011-03-theme-owned-views/phase-plan.md b/features/F-20261011-03-theme-owned-views/phase-plan.md new file mode 100644 index 00000000..6c5175c9 --- /dev/null +++ b/features/F-20261011-03-theme-owned-views/phase-plan.md @@ -0,0 +1,4 @@ +# 阶段 + +1. verified:迁移布局与注册契约,移除默认普通页面,接通 Sample/Editor。主题、Sample、Editor 构建成功;缺失映射和覆盖优先测试通过。 +2. verified:补齐三主题截图、真实 GameShell 接入、完整注册与共享行为验证。Presentation 106、Integration 62、Architecture 26 项通过;正式文档已同步。证据与验证限制见 summary.md。 diff --git a/features/F-20261011-03-theme-owned-views/review.md b/features/F-20261011-03-theme-owned-views/review.md new file mode 100644 index 00000000..45f022c5 --- /dev/null +++ b/features/F-20261011-03-theme-owned-views/review.md @@ -0,0 +1,9 @@ +# 实施者自查 + +结论:pass;本记录是实施者自查,并非独立 agent 审核。 + +检查实际 diff、完整注册顺序、Gallery 映射分离、主题/宿主覆盖、Scoped GamePage 别名、截图关闭与异步预览生命周期、Editor 依赖和开发者文档。未发现阻止交付的问题。 + +证据:Presentation 106、Integration 62、Architecture 26 项通过;Sample、Themes、Editor 构建成功。真实 GameShell 在三个主题下通过 DI 创建并导航到主题设置页;截图预览、错误后重试、取消、成功关闭时释放均通过实际点击验证。 + +兼容性影响:省略主题的旧调用、引用已删除默认页面或 GameView/Styles/Generic.axaml 的宿主需要迁移。必需普通页面与截图映射由主题/宿主提供,缺失不再回退。系统文件选择器和原生媒体 opt-in 限制在 summary 中明确。 diff --git a/features/F-20261011-03-theme-owned-views/summary.md b/features/F-20261011-03-theme-owned-views/summary.md new file mode 100644 index 00000000..839f35f7 --- /dev/null +++ b/features/F-20261011-03-theme-owned-views/summary.md @@ -0,0 +1,23 @@ +# 实现总结 + +F02 已按用户要求提交为 `69f3535`,之后实施本次清理。本 feature 的改动保留在工作树。 + +GameView 删除十二套默认普通页面及默认玩家样式;Page 目录仅保留 GameShell 导航宿主、GamePage 稳定行为控件、ScreenshotDialog 无布局行为基类。Themes/Common 持有通用玩家样式和工具栏,三套布局各自提供普通页面、GamePage 模板和截图对话框。 + +AddAvaloniaGameViewPages 的第一个参数改为必需 IGameLayoutTheme。共享 GamePage → 主题 → Gallery 扩展 → 宿主 View 覆盖 → 完整性校验;缺失必需页面或错误 GamePage/ScreenshotDialog 类型在注册阶段拒绝。Gallery 内置类型映射不再依赖默认 View。普通 View 仍是 Scoped;截图对话框每次由 DI 创建并释放,宿主也能覆盖截图 View。 + +Sample 已显式配置 Classic/Ocean,保持原调用方式;Editor 预览新增显式 Classic 引用,使用 ThemesAssembly 别名避免命名空间冲突。Editor 与其测试锁文件新增主题依赖,Editor locked restore 成功。GameView 不依赖 Themes,Architecture 门禁补充此边界。 + +ScreenshotDialog 约定具名控件,加载 XAML 后 InitializeDialog;共享基类保留目录选择、保存、错误展示和预览 Bitmap 释放。异步预览只发布当前请求,关闭后到达的结果被忽略;保存完成触发关闭并释放视图时不再读取失效 VM。 + +验证: + +- Themes 构建成功;Editor 构建成功,输出至 artifacts/theme-owned-views-editor,避开用户运行中的 DLL。 +- Presentation 全部 106 项通过,包括九种布局/配色渲染、六种大图窗口返回区域、三主题真实 GameShell DI/导航、三主题截图保存错误/取消/关闭释放,以及缺失映射和模板诊断。 +- Integration 全部 62 项通过,同时构建 Sample;Architecture 全部 26 项通过。 +- TRX 位于对应测试项目 TestResults/theme-owned-views.trx。渲染截图位于忽略的 artifacts/theme-owned-views。 +- 改动文件 whitespace 与 IDE0005 检查通过,git diff --check 通过。既有 Avalonia DI 构造警告与分析器警告保留。 + +限制:未运行原生 LibVLC opt-in 用例,未人工验证系统文件选择器或所有 DPI/字体环境;没有运行期主题切换。普通页面删除与必需主题参数属于源码 API 变更,外部宿主应显式选择内置主题或提供完整自定义主题。 + +正式文档:[control](../../docs/spec/control.md)、[开发手册](../../docs/development.md)、[主题设计](../../docs/design/game-ui-themes.md)、[Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md)。过程经验独立保存于 .agents/lessons/L-20261011-02-editor-avalonia-alias.md。 diff --git a/features/F-20261011-04-playback-settings/agent-lessons.md b/features/F-20261011-04-playback-settings/agent-lessons.md new file mode 100644 index 00000000..cd0c6330 --- /dev/null +++ b/features/F-20261011-04-playback-settings/agent-lessons.md @@ -0,0 +1,47 @@ +# 暂存 agent lessons + +## 工具栏模板仍可能被 Fluent 状态样式覆盖 + +状态:confirmed,2026-10-12。自定义 Button 模板若沿用 PART_ContentPresenter 名称,Fluent 的 pointerover/pressed 模板子选择器仍会直接覆写该 presenter.Background,导致 Button.Background 正确而实际呈现错误。游戏工具栏使用自己的 ToolbarContentPresenter 名称和 TemplateBinding,避开默认模板子选择器,保留命令、内容及焦点行为。九组布局×配色测试先复现 presenter 与按钮画刷不一致,调整后通过。验证自定义按钮时需检查实际模板背景,不能仅断言外层 Background。来源:F-20261011-04-playback-settings。 + +## 玩家点击不等于消费推进边界 + +状态:confirmed,2026-10-12。来源:F-20261011-04-playback-settings;PlaybackModeTests、GameEnginePrimitiveDispatchTests、BuiltinAutoPlaybackTests。 + +现象:视频分段 seek 后开启快速,视频完成并移除,但 Engine 不启动下一句计时,左键才恢复;手动文本 reveal 和点击不可跳过 blocker 后开启 Auto/Quick 同样受影响。根因为 AdvanceAsync 在调用 Skip 之前清空 _advanceBoundary,而 Skip 不保证逻辑完成。修正为只有确实释放 blocker、进入后续剧情时才消费边界,不通过重新枚举已移除实例来推断是否曾达到过边界。 + +同类路径:进入追帧时提前清空边界,现有自定义 blocker 回退正常执行且没有完成;取消追帧再切 Auto/Quick 后无法续跑。压缩和清理之后按剩余 blocker 恢复边界。恢复/停止/Choice 等真正的生命周期边界仍明确清空,不能全局取消清理。 + +验证:手动路径四例、视频六例(有手动点击的四例先失败)、取消追帧两例先复现;修复后加上真实 ADV/NVL 四例,共新增 16 项测试通过。禁止用“只从首次 Dispatch 开启模式”的测试代替“中途手动操作后再开启模式”的验证。`.agents` 当前只读,本条暂存 feature,待可写时整理索引。 + +## 即时文字速度不能代替全文快速完成 + +状态:confirmed,2026-10-12。TypewriterTextBlock 的 CharactersPerSecond=0 仅省略逐字符延迟,显式 `\\d{...}` 仍会等待;最终追帧重建也会受影响。Avalonia 即时 ADV/NVL 路径在 Start/Append 后显式 RevealAll,跨越 delay 与 skip 分段。三布局×三配色用 60 秒 delay 验证最终同步在 2 秒内完成,Presentation 125 项通过。适用快速全文和最终展示重建;禁止仅设速度 0 就承诺忽略所有等待。来源:F-20261011-04-playback-settings。 + +`.agents` 在当前会话是只读目录,以下已确认经验暂存在 feature 内,待可写时整理到 agent lessons/索引。 + +## 样例编译通过不等于作者文档校验通过 + +状态:confirmed,2026-10-12。 + +发现:GameTestCase 能编译且双路线运行测试通过,但 Headless validate 报跨 group 的 `line` 重复。EditorDocumentValidator 和 AddEntry 把 GroupEntries 展平后要求全局唯一;编译器逐 group 校验,已读使用 (groupId, entryId),命令查找也以 group 定位。正确作用域为 group 内唯一,不应改名样例绕过错误约束。 + +修正:校验和 AddEntry 按目标 group 检查;新增跨 group 同名合法、同 group 重复拒绝及样例作者文档校验测试。Shared 测试 18 项、样例集成测试 6 项通过,CLI validate 无诊断,4 个 group 重编译成功。 + +另:本地启动日志确认旧 progress 无版本字段导致 version 0 被拒绝。按用户要求不迁移;本轮显式修复本地样例时将旧文件移到 progress.legacy-20261012.json.bak,保留设置和存档。运行时不增加自动备份/清空兼容逻辑。 + +适用:样例验收应包含作者文档校验、编译及运行,不能只用 compiler smoke 断言所有工具约束一致。来源:F-20261011-04-playback-settings 样例修复。 + +## 持久展示重建必须使用 Engine run scope + +状态:confirmed,2026-10-12。 + +发现:Sample Prepare 原先传 Engine.Runtime 和启动/读档调用者 token 给 PersistentSceneRestorer。恢复出来的循环动画/视频并非 Engine dispatch 实例,不能假定 Engine 清活动集合会停止它们;原始 Runtime 也没有该代场景隔离。 + +修正:Engine.SynchronizePresentationAsync 在同一执行门下传 scoped runtime 和 run token。调用者 token 控制进入操作,持久播放保留 run token,不能通过返回后立即 Dispose 的临时 linked CTS 承诺后续停止通知。 + +证据:PresentationSynchronizationUsesAnIsolatedRunScopeThatRestoreAndStopCancel 验证旧场景引用隔离、读档/停止取消以及旧回调变量写入拒绝;Runtime 70 项和 Integration 67 项通过。 + +适用:读档后的动画/视频重建、追帧最终呈现重建。禁止把原始 Runtime 和临时 UI 操作 token 当作持久呈现 scope;仍需跟踪非 Engine 所有的恢复播放及晚清理,scope 修正不等于追帧协调已经完成。 + +来源:F-20261011-04-playback-settings;关联 `.agents/lessons/L-20261010-02-restore-run-scope.md`、`docs/spec/runtime.md`。 diff --git a/features/F-20261011-04-playback-settings/design.md b/features/F-20261011-04-playback-settings/design.md new file mode 100644 index 00000000..6d6a8d7c --- /dev/null +++ b/features/F-20261011-04-playback-settings/design.md @@ -0,0 +1,249 @@ +# 播放控制与分类设置设计 + +状态:Design draft。2026-10-11。关联 [feature.md](feature.md)。本文描述目标设计,不代表已实现事实;接口名称可在实现时按项目惯例微调,语义不能省略。 + +## 目标与已确认方向 + +原语自己处理正常、快速、追帧调用,基类提供回退;Engine 管理活动实例、自动阻塞集合、推进边界及间隔。自动模式不需要文本与语音配对,也不模拟鼠标操作。 + +设置服务提供只读分类列表及类型化设置项。整数与浮点各自通过编辑方式选择 Slider / NumericUpDown;Step 仅为操作步长。动态 Choice 失效回退默认值;可见性、可用性及原因 key 可由委托计算,并有明确刷新机制。各主题提供模板,自定义项由工厂构造编辑控件。不引入 AutoSettingUI。 + +## 当前事实及主要改动 + +- PrimitiveInstance 有 Dispatch、Skip、IsBlocking、IsSkippable、IsCompleted 和 Completed。Dispatch 只能调用一次;当前 Skip 是玩家手动跳过当前 batch 的请求,不一定完成实例。 +- 对白/NVL 在文本显示完成后仍等待推进;音频原语在提交命令后立即完成。不能直接通过所有实例的 Completed 判断自动计时起点。 +- CompositeGameView 当前创建并立即 dispatch 实例。Engine Track 在返回后发生,必须兼容实例在 dispatch 内同步结束。 +- Engine 自然阻塞完成时自动续跑,自动模式计时需要介入该续跑路径,不能只包装公开 AdvanceAsync。 +- GalgroupCompiler 已输出稳定字符串 ID,但 GalgroupLoader 转成顺序整数并丢弃字符串。需要保留稳定身份;EntryIndex 与整数 Id 的位置语义不变。 +- Avalonia.GameView 不引用 Runtime。不得为播放控制或设置模型引入反向引用;宿主负责连接 Engine 与共享 UI。 +- 完整 GameSnapshot v3 继续使用。旧 replay-save feature 已暂缓,本设计只新增向前追帧执行。 + +## 分层与职责 + +| 层 | 新职责 | +| --- | --- | +| Core | primitive 模式/自动阻塞契约、稳定条目身份、纯玩家播放/音量配置值 | +| Presentation.Abstractions | 分发模式输入及原语能力查询,媒体完成/立即完成端口 | +| Runtime | 活动实例、自动阻塞集合、推进策略、计时与取消、稳定进度身份、音频播放 ticket | +| Builtins | 正常/快速/追帧语义,文本自动阻塞状态,语音有限播放实例及最终场景语义 | +| Storage.FileSystem | 已读格式与玩家偏好持久化适配,不拥有播放策略 | +| Avalonia.GameView | 分类设置、共享页面状态、输入与确认行为、主题无关编辑承载 | +| Avalonia.Themes | 设置模板、局部标签页、ToggleButton、确认与禁用原因布局 | +| Samples / Editor | 设置应用与保存、窗口能力、Engine/UI 桥接、媒体后端和资源生命周期 | + +Engine 保持运行事实与队列的唯一拥有者,不新增重复解释剧情状态的通用 Runner。内部 PlaybackPolicy/调度协作者可以拆出纯逻辑,但使用 Engine 的同一个执行门和生命周期。 + +## 原语模式与自动阻塞协议 + +概念接口: + +```csharp +enum PrimitiveInvocationMode { Normal, Quick, CatchUp } + +// 保留已有 Dispatch 单次约束、Skip 与 Completed。 +bool IsAutoBlocking { get; } +event Action? AutoBlockingChanged; +void Dispatch(PrimitiveInvocationMode mode); +void ApplyPlaybackMode(PrimitiveInvocationMode mode); // 已启动实例切换策略 + +protected abstract void OnDispatch(); +protected virtual void OnQuickDispatch() => OnDispatch(); +protected virtual void OnCatchUpDispatch() => OnDispatch(); +protected void SetAutoBlocking(bool value); +``` + +模式入口在首次 Dispatch 时只选一个 hook,绝不先正常执行再重复执行快速/追帧 hook。基类快速与追帧默认调用正常实现;该回退仍遵守原语完成和等待语义,不把它当成无副作用或已完成操作。即时变量/解锁等原语通常直接复用正常实现。 + +已有 Skip/OnSkip 保持手动分段语义。快速/追帧 hook 不复用 OnSkip 名称,避免把视频分段 seek、文本 `\\skip` 边界与完整快速完成混淆。ApplyPlaybackMode 对已启动的实例调用幂等模式变更 hook,不重新 Dispatch;默认不改变当前执行。切回 Normal 不恢复已截断声音或已显示文字,只恢复之后的调用策略。 + +IsAutoBlocking 为动态事实,允许 false→true 或 true→false;事件仅在实际改变时触发。普通有限 blocking 实例默认在未完成时阻塞自动,Completed 后为 false。长期 non-blocking 默认不阻塞自动。文本/NVL 单独在呈现结束时置 false,但仍保持 IsCompleted=false 等待推进。 + +TryComplete 原子地结束实例、保证自动阻塞解除,然后发布状态/完成通知;取消或呈现失败不标记“成功已读”。SetAutoBlocking 对已结束实例不能重新设置 true。状态可跨线程改变,事件不直接重入 Engine;Engine 通过调度门重新读取权威状态。 + +快速/追帧需要立即终结的异步操作提供可观察的完成通知或 Task,不能仅发送完成请求就认为成功。停止/读档先取消 scope,后台清理使用播放 identity 检查,不能删除新实例。 + +对白的推进等待通过独立的 IAdvanceWaitInstance 概念能力暴露 IsWaitingForAdvance 和 CompleteAdvance,而不是令 Engine 根据具体对白类或 typeId 分支。进入/退出推进等待同样通知 Engine 重评估边界。CompleteAdvance 只接受已经呈现完成的实例;手动点击仍经原有 Skip 执行分段显示。已完成实例、非等待阶段与旧 run 请求均幂等拒绝,不以 SkipText 代替自动推进。 + +原语注册/描述端口提供阅读边界、长期播放和追帧安全能力查询,供 Engine 在 dispatch 前判定。状态性的 IsAutoBlocking 仍由实例报告,不能只使用静态 descriptor 判断播放结束。编译期阅读 metadata 由 schema/composite 贡献,执行实现对其支持在组合期校验;Runtime 不引用具体 Builtins 类型。 + +### 内置行为 + +| 类别 | Normal / Auto | Quick | CatchUp | +| --- | --- | --- | --- | +| 变量、条件、Gallery 解锁 | 执行逻辑 | 同正常 | 同正常;每次真实经过只执行一次 | +| ADV/NVL 文本 | 正常打字;打字结束解除自动阻塞,保留推进等待 | 全文立即显示并解除自动阻塞,保留快速间隔等待 | 已读文本不逐句呈现,更新必要文本逻辑并完成;未读目标正常呈现 | +| flow.wait | 等自然结束 | 可跳过时立即结束 | 省略等待 | +| Once 动画/plan | 完成后解除自动阻塞 | 可跳过时提交末态并结束 | 提交正确末态,不播放中间帧,不补发瞬时 burst | +| Loop/PingPong 动画、持续粒子 | 不默认参与自动等待 | 保持长期状态 | 重建定义与规定起始状态,停止时统一呈现 | +| 有限语音 | 播放 ticket 结束解除自动阻塞 | 停止对应播放并解除等待 | 省略中间语音播放 | +| 普通音效 / BGM | 默认不阻塞自动,可显式声明有限播放参与 | 按原语声明处理 | 瞬时音效省略,长期音频保留最终逻辑状态 | +| 视频 | 阻塞视频等待结束;非阻塞视频默认不参与自动等待 | 可跳过视频直接完成,不循环调用分段 seek | 阻塞视频按终态处理;非阻塞持久图层在停止点从头重建 | +| 自定义原语 | 默认正常运行 | 默认正常运行,保留等待 | 默认正常运行,保留等待;可声明安全追帧或停止边界 | + +有 skippable=false 的播放不得强制终结。追帧遇到没有声明安全压缩语义的异步/外部原语时,回退正常执行并等待或停在显式边界,不能假装已经跳过。扩展文档明确这一点;不恢复旧 replay-save 要求所有扩展都支持静默 Replay 的规则。 + +场景 terminal 写入只能执行一次:当前非阻塞动画已经在 Dispatch 提交末态,后续快速/追帧不得重复叠加 Additive 数值。plan 的持久事件与末态使用统一的逻辑状态计算,瞬时 presenter 事件按模式过滤。 + +## 自动阻塞集合、推进边界与时序 + +Engine 为当前 run 跟踪所有活动 primitive;以实例 identity 维护 HashSet 自动阻塞集合,数量由集合取得。不是只维护裸整数,亦不是从 SceneInstances 枚举:后者保存图层等持久对象,不等价于执行实例。 + +注册顺序为订阅事件、读取 IsAutoBlocking/IsCompleted、幂等协调集合。由于当前 view 可能已经 dispatch 并同步完成,初始协调必须读取实际状态。随后事件排入 Engine 的执行门,再读取状态;不能按旧事件无条件 +1/-1。所有订阅移除与集合清理使用同一执行门。 + +阻塞集合为空只是必要条件,计时还必须满足: + +1. 当前模式为 Auto,运行会话和推进边界仍有效; +2. 正常批次已经分发到 blocking/对白推进边界,当前不存在尚未分发的同轮前置指令; +3. 没有待选 Choice、追帧/确认操作、终态或页面暂停; +4. 当前有限 flow blocker 均已结束,剩余对白仅等待推进; +5. 当前所有显式自动阻塞实例均已解除。 + +自然阻塞完成后,Engine 先判断策略再执行 continuation。Normal 延用现有续跑;Auto 在已达到的边界进入间隔等待,不直接跑到下一句;Quick 使用快速完成及间隔规则。纯即时语句不制造计时间隔;无文本的有限阻塞边界也可在自然完成后使用自动间隔。 + +文本与语音无一对一关联。尚未结束且声明自动阻塞的语音即使来自上一批,也会阻止本次自动推进,直到结束/停止;若作者不希望这样,应令该播放不参与自动阻塞。长期 BGM 不参与。 + +Engine 内部边界标识包含 RunGeneration、BoundaryRevision;计时任务另有 ModeRevision 和 linked cancellation。到期后重新取得执行门并校验全部条件,才释放当前对白的推进等待并继续。Completed、AutoBlockingChanged、玩家操作或设置变更不能产生两个有效计时任务。 + +计时期间新增自动阻塞:取消计时,等待再次清空后从完整间隔重新开始。间隔设置改变时同样重新计时。关闭模式立即使旧 ModeRevision 失效;等于零的间隔仍异步调度/让出,不产生同步递归。 + +进入 Choice、页面离开游戏、确认框、读档、重载、终态与 scope 销毁均切回 Manual 并撤销计时,回到游戏不自行恢复。模式只保存本会话状态。 + +## 音频完成与玩家音量 + +AudioRuntime.Play 增加可观察的逻辑播放 ticket;ticket 在 enqueue 前建立,覆盖等待队列、加载、播放、结束、失败、替换、清队和取消,不只代表 voice 已开始。ReplaceIfDifferent 命中已有 voice 时,ticket 关联实际仍在播放的同一代对象;暂停保持未完成,恢复后继续。 + +声明 autoBlocking 的 audio.play 产生 non-blocking 有限播放实例:普通剧情仍可立即分发文本,但实例保持活动直到 ticket 结束。语音 composite 只需为音频设置 autoBlocking=true,不绑定具体文本。普通音频默认 false;允许作者显式选择,循环/无限播放不能声明自动阻塞。 + +后端不存在、Start 请求抛错、资源失败、被溢出规则丢弃等路径也必须完成 ticket 并诊断,否则自动模式可能永久等待。Ended/Failed 用 voice identity + generation 过滤;播放实例与后台命令的生命周期独立排空。 + +玩家音量使用 Master、Bgm、Sfx、Vocal 及对应静音值。有效增益由内容 gain × track DefaultGain × bus 玩家音量 × master 玩家音量组成,静音后为零,并在最终边界限制到后端合法范围。设置改变刷新当前 voice 及后续 voice,不能改写作者内容 gain。SEffect 自己的 gain 保持独立。 + +追帧维护会话内音频逻辑投影,按原有 placement/stop/queue 规则处理命令但不启动中间后端播放;忽略瞬时 SFX/语音,停止点只恢复最终应持续播放的轨道。该投影不进入本期存档格式,不扩大为精确 seek、shuffle 或音频持久化 feature。 + +## 已读身份与向前追帧 + +PrimitiveEntry 增加稳定字符串身份,loader 保留已编译 envelope.Id,CompositeGameView 规范化时原样复制。整数 Id/EntryIndex 继续表示位置,不能作为进度身份。 + +进度键使用 graph Group node ID + 稳定文本 ID;与语言无关。ADV/NVL 阅读原语统一声明文本边界,Engine 不通过硬编码唯一 dialogue.text 判断。当前句开始前捕获 WasRead;全文成功呈现后 MarkRead,失败/取消不标记。改动文本而保留同 ID 仍视为同条目;作者需要重新视为未读时更换稳定 ID。 + +语音 composite 在 audio 前增加可供 Engine 查询的阅读边界 metadata,引用随后稳定文本 ID。这只用于进入前的未读检测/停止,不关联音文完成。编译器/loader 验证边界连续性、身份和 condition 一致性;普通文本自身也有边界。不通过字符串解析 '#n' 猜源条目,不复用 BatchId。 + +如果某个 composite 内有不同条件,必须拆成可验证的阅读边界,不能把未执行文本作为停止目标。用户快速仅已读设置在边界进入前生效;开启当前句快速仍使用缓存 WasRead。 + +progress.json 使用 v2 稳定键集合;旧顺序数字键格式直接拒绝,不备份、清空或迁移。需要新玩家配置或显式清空进度后使用;拒绝读取不修改文件。该决定遵循用户新增“不考虑向后兼容”的要求。 + +追帧在确认后从当前真实 Runtime 向前执行,不搜索目标后直接跳游标,不载入旧路径变量快照。停止检查在目标阅读边界分发前、Choice 建立前进行;到达未读目标后恢复 Normal 并呈现该句。若当前句 WasRead=false,不跨过当前句。 + +追帧有单一操作 token,按有限指令预算分段让出,并在每段检查取消。预算用于 UI 响应,不把循环当错误或硬跳出口。左键取消追帧时保留已经执行的变量和逻辑状态,在当前合法边界同步展示;不会回滚已发生的外部动作。 + +Builtins 的追帧语义直接更新 Runtime 状态并暂缓展示。宿主提供窄的 Begin/EndCatchUp presentation 协调端口,停止/取消时用同一 scope 的最终场景重放及文本/NVL 可见状态同步;Engine 只等待端口,不依赖 Builtins/具体 renderer。恢复失败明确报告并停止继续执行,不能展示旧画面后假装成功。 + +进入追帧时已有播放必须停止或转入声明的压缩状态,隔离晚清理,不能只把 future Dispatch 改为 CatchUp。取消追帧不取消整个 Engine run scope;模式 token 与 run token 分离。 + +2026-10-12 接线细化:PrimitiveInstance.PresentationCompletion 将逻辑完成与异步展示 finally 清理区分;Engine 进入追帧先等待旧展示任务,BuiltinPresentationReplay 在 dispatch 前向宿主交付 continuation,Sample 跟踪并排空恢复实例。SampleGamePresentation 本身实现 ICatchUpPresentation,复用现有 restorer 和音频模块,不新增执行协调框架。SceneState 不包含文本/NVL 历史,故通过可选 ICatchUpTextRecorder 在追帧中记录已解析文本;AvaloniaDialoguePresenter 缓存最后对白、NVL 历史和可见状态,最终一次重建。该投影仅在当前展示会话有效,不扩展 snapshot v3,也不承诺恢复存档中的 NVL 历史。 + +## 输入与主题 + +GamePage 左键 tunnel 行为先撤销旧模式/追帧请求;只过滤左键,其他鼠标键不误推进。保留 IsPlayerInteraction 对推进的屏蔽,但它不屏蔽模式取消。 + +剧情区域点击取消旧模式并执行一次手动 Advance;工具栏仅执行按钮命令。模式按钮需保存 PointerPressed 前的选中状态,按该状态决定 Toggle,避免 tunnel 先清空状态导致无法关闭已选按钮;命令路径和键盘激活同样一致。点击另一模式直接切换,只有一个 enum 状态源。 + +带主题资源的确认 overlay 位于 GameShell 玩家根,独立于页面导航历史。确认期间禁止背景推进,防止重复弹框;关闭会话取消等待。新增确认 ViewModel/主题模板按组合注册校验,不借用宿主调试窗口的 Ursa MessageBox 样式。 + +Phase 4 实施细化:确认只含现有播放 VM 的两条命令和一个可见状态,因此复用 GamePageViewModel,不增加独立导航页或确认 VM;GameShell 持有 SkipConfirmation 行为控件,主题提供 ControlTemplate。根 PageHost 在确认期间禁用,Escape 取消。该选择减少无独立生命周期的样板类型,不改变确认和主题所有权。 + +## 分类设置模型 + +模型留在 Avalonia.GameView 的设置边界,普通项不持有 Control。现有 CommunityToolkit.ObservableObject 与本地化适配继续使用,不新增生产项目或第三方 UI 依赖。 + +```csharp +IPlayerSettingsService.Categories : IReadOnlyList +SettingCategory { Id, NameKey, IReadOnlyList Items } +PlayerSetting { Id, NameKey, DescriptionKey, ValueType, Availability, ErrorKey } +PlayerSetting { Value, DefaultValue, TrySetValueAsync(...), RefreshValue() } +NumericEditorKind { NumericUpDown, Slider } +SettingAvailability { IsVisible, IsEnabled, ReasonKey } +``` + +分类和设置项结构在组合期复制/冻结,Id 在服务内唯一。值、可用性和 Choice 候选集可以动态变化。基类 ValueType 从 T 推导,类型擦除仅用于异构列表,不做任意 object/string 强制转换。 + +内置 IntSetting、DoubleSetting、StringSetting、BoolSetting、ChoiceSetting、ActionSetting。数值项包含 Minimum、Maximum、Step、EditorKind、UnitKey、Format;构造时验证范围和 Step 合法,写值只按类型、有限值、范围及业务 validator 验证,不按 Step 的倍数限制。Slider 不启用步长吸附;NumericUpDown 允许输入范围内任意合法值。 + +Choice 有稳定选项 ID、类型化 Value 和名称 key(动态语言可提供显示名称适配);更新候选集时验证唯一性,当前值失效选择有效 DefaultValue。默认值由静态值或默认值工厂给出;如果默认值也无效,候选提供者须先更新默认值,否则禁用设置并报告配置错误,不随意选首项。空列表同样禁用,待恢复候选后重新协调。有效回退经过正常应用与持久化,不只刷新控件。 + +### 可见性与可用性 + +每项可选 Func EvaluateAvailability,默认可见可用;结果缓存并通知 PropertyChanged。一次求值返回三项状态,避免分开委托观察到不一致状态。求值无副作用,不在 getter 内保存或改配置。 + +服务在任意设置成功生效后统一刷新各项 availability(项数较小,无需新增依赖表达式框架)。宿主能力/外部偏好变化通过可释放订阅触发 RefreshAvailability,并调度到 UI 线程。只刷新变化的结果,不递归写 Value;释放服务时解除订阅。 + +禁用原因 key 由共享本地化适配响应语言切换。设置行可接收悬浮/键盘焦点提示,编辑控件禁用但原因容器不整体禁用;在触摸端支持点按说明或行内说明。隐藏项不占布局。不可用状态由 TrySetValueAsync 再检查,不能仅依赖控件 IsEnabled。 + +### 应用、保存、默认值与强制值 + +每项绑定类型化读取/应用协作者,服务负责持久化。顺序为校验→实际应用→读取生效值→通知 UI/依赖→保存;UI Value 的绑定入口调度该流程并公开 IsApplying/ErrorKey。应用失败恢复实际值,保存失败保留已生效值并报告/保留待重试状态,不假装写盘成功。 + +同项/相互依赖的应用通过服务串行门协调。窗口或媒体异步应用不在 UI 上同步等待;新编辑可排队,退回/释放等待已提交工作或明确取消。恢复偏好不逐项重复保存,候选修正与默认回退完成后按需保存一次。正常退出 flush,清空玩家数据恢复默认。 + +有效值优先级为开发者强制值→合法已保存玩家值→默认值。UI 删除/隐藏和策略固定独立。默认服务可删项但仍保留其运行默认配置,不因缺少控件停止设置消费。 + +Sample 延用 player-variables.json 和现有 locale/textSpeed 键,其他内置值使用 player.galnet.settings.* 稳定命名空间。共享页面不引用 FileVariableService。设置值的校验、类型与序列化在宿主适配边界完成,运行时播放/音量值在启动前应用。 + +### 主题模板与自定义控件 + +各主题根据具体设置项和 NumericEditorKind 提供 DataTemplate。开放泛型 Choice 用非泛型编辑 adapter 暴露选项/选中项,值转换在类型化设置内部校验,主题不反射枚举 T。 + +共享 SettingsRow 统一呈现名称、说明、单位、可用性、错误及编辑内容;三主题可替换模板和布局。数值编辑器处理 NumericUpDown 的 decimal↔int/double 安全转换,越界/溢出给出错误,不通过字符串文化隐式转换。 + +CustomSetting 的 Avalonia adapter 持有 Func 工厂,传入类型化项、主题/本地化/服务上下文;每次创建新控件,工厂自行构造绑定但经统一值应用入口写入。承载方提供编辑器租约/可释放 hook,切换分类或页面销毁解除事件订阅,不把临时 detach 当作永久释放。普通配置值与运行协议均不保存该 factory。 + +未知项类型提供可见诊断;自定义模板注册/内置模板完整性在组合期或主题验收中检查。 + +## 设置分类与平台能力 + +SettingsPageViewModel 暴露 Categories/SelectedCategory;无 navigation 子页面。主题使用 TabControl 或统一局部 selector,保留当前分类;被移除或全隐藏分类回退首个可见分类。 + +| 分类 | 默认项 | +| --- | --- | +| 游戏 | 语言、文本速度、仅已读快速、快速间隔、自动间隔 | +| 画面 | 显示模式、窗口尺寸/支持的显示分辨率 | +| 音频 | Master/Bgm/Sfx/Vocal 音量和静音 | +| 控制 | 本期不注册空分类,后续快捷键 feature 追加 | + +默认文本速度 30 字符/秒(0 即时),快速间隔 50 毫秒,自动间隔 2000 毫秒,快速仅已读 true;范围分别为 0–120、0–5000 毫秒、0–30000 毫秒。2026-10-12 用户确认等待时间采用整数毫秒:设置使用 IntSetting/NumericUpDown,运行配置及持久偏好均为 int,字段名显式包含 Milliseconds;调整步长分别为 10/100 毫秒,仍不参与校验。旧浮点秒偏好不迁移,按类型无效回退默认值。音量 0–1,初始 1 避免与作者轨道默认 gain 重复衰减。开发者可覆盖范围/default/模板。 + +窗口设置通过宿主提供的 IPlayerDisplayService 概念端口协商 capabilities、候选项、实际状态及 ApplyAsync。Sample desktop 支持窗口/无边框全屏,独占全屏仅后端明确支持时提供,不能把 Avalonia FullScreen 名称当作独占保证。无窗口平台/Editor 嵌入预览隐藏不适用项。窗口尺寸不是项目设计画布分辨率;全屏模式下窗口尺寸禁用并解释原因。应用失败恢复生效值;危险显示模式的确认/自动恢复仅在支持实际显示模式切换的宿主实现。 + +已有 Ursa 依赖不意味着内置玩家主题采用其样式;Choice 默认使用当前主题下的 ComboBox/selector,未来可覆盖为 enum selector。无新增 Ursa/AutoSettingUI 依赖要求。 + +## 候选方案与取舍 + +- 定时模拟点击:无法表达自动就绪、全文快速与追帧区别,排除。 +- 等所有实例 Completed:对白等待推进形成循环,循环媒体无终态,音频命令已完成但声音仍播放,排除。 +- 为每句文本绑定语音:额外维护关联并限制自定义原语,采用所有声明自动阻塞实例的集合取代。 +- 裸阻塞计数:容易重复扣减和遗留,采用 identity 集合与状态重读。 +- 强制所有原语实现三套逻辑:重复即时逻辑,采用基类正常回退与特殊原语覆写。 +- 每种数值 UI 一个值类型:重复验证,采用 int/double + EditorKind。 +- AutoSettingUI/属性反射:用户明确不采用,沿用可扩展设置项与主题模板。 + +## 兼容、迁移与风险 + +原语 hook 的正常调用默认行为是扩展语义复用,不是历史接口适配。IGameView 只保留模式感知的 Dispatch 契约;对白/NVL presenter 必须实现真正的全文显示,删除以一次 Skip 假装全文显示的旧接口回退。实现者需更新到当前接口,不承诺旧扩展兼容。 + +阅读 metadata 改变编译内容契约,需要版本化并更新编译、加载、导出、安装与 samples。raw 无结构变化可保留版本,compiled 独立升级版本并拒绝旧产物、要求重编译;不要只改共享版本常量导致无理由拒绝旧 raw。GameSnapshot 结构仍 v3;旧内容保存位置无法证明与重编译内容一致时不得承诺跨内容兼容,本期不扩大为存档迁移。 + +主要风险为自然完成抢先续跑、计时事件重入、同步完成漏订阅、声音加载失败不释放等待、动画末态重复叠加、追帧结束晚回调污染,以及异步设置更新/关闭的竞争。run/mode/boundary identity、集合协调、单次逻辑提交、可取消等待和明确错误反馈是共同约束。 + +本设计不新增跨项目依赖或替换存档架构,无需单独 ADR;未来若实施中引入新跨层依赖或长期存档协议,应另行提出 Proposed ADR。 + +## 验证与验收影响 + +- Runtime:同步完成/事件乱序去重、多个自动 blocker、阻塞重新出现重置计时、零间隔、Choice/终态停止、自然 continuation 策略、取消/读档代际隔离。 +- Builtins:Normal/Quick/CatchUp 只执行一次,文本完整呈现与手动分段区别,NVL、失败不已读,动画/plan Additive 末态不重复提交。 +- Audio:排队/替换/失败/无后端/静音 ticket 终止、late generation、语音与文本无关联仍正确等待、当前 voice 音量即时生效。 +- Core/Loader/Storage:稳定 ID 保留与位置分离、语音 composite 未读前停止、条件边界、旧 progress 拒绝且不修改与新格式。 +- Settings:Step 非倍数合法、数值转换、Choice/default/空候选、强制策略、委托刷新与释放、应用失败/保存失败/关闭 flush。 +- Presentation:三主题模板、禁用原因可访问、分类切换、自定义 editor 生命周期、Toggle/左键事件顺序、确认框屏蔽背景。 +- Integration:Sample/Editor 构造、跨语言已读、音文播放自动计时、追帧变量/解锁/场景结果、平台能力与持久偏好。 + +本阶段只保存设计并检查文档一致性,不运行与业务代码改动无关的构建测试。下一阶段编写可验证的 Phase Plan 后再进入实现。 diff --git a/features/F-20261011-04-playback-settings/feature.md b/features/F-20261011-04-playback-settings/feature.md new file mode 100644 index 00000000..4227c3ef --- /dev/null +++ b/features/F-20261011-04-playback-settings/feature.md @@ -0,0 +1,139 @@ +--- +id: F-20261011-04-playback-settings +title: 玩家快速与自动播放、追帧跳过和分类设置 +type: feature +status: implementing +created: 2026-10-11 +updated: 2026-10-12 +--- + +# 玩家快速与自动播放、追帧跳过和分类设置 + +## 原始目标与已确认需求 + +2026-10-12 工具栏交互补充:自动/快速仅选中时显示背景,再次点击取消;其他工具栏按钮平时透明,悬浮淡色背景、按下背景加深。三布局与三配色统一使用主题色。 + +2026-10-12 范围收敛:用户明确 Editor 不用管。后续仅实现 Sample 的宿主接入及所需共享能力,不继续扩展 Editor 设置或把 Editor 接入作为验收前置;保留已完成改动,不擅自回退工作树。 + +游戏页面提供互斥的快速模式、自动模式 ToggleButton,以及带确认提示的跳过按钮。快速模式完整显示文本,按可配置间隔推进,可跳过的阻塞快速完成;自动模式正常播放,当前阅读单元需要等待的呈现全部结束后才开始自动间隔。跳过沿当前剧情路径追帧到下一段未读文本或下一个选项。 + +设置采用分类列表及类型化配置项,分类内部切换使用 TabControl 或等价的局部选择,不加入页面导航历史。默认分类为游戏、画面、音频、控制,开发者可以增删分类和设置项,各主题统一提供设置 UI。控制/快捷键功能以后另做,本期不显示无功能的空分类。 + +用户已确认: + +- 2026-10-11 补充:不考虑向后兼容;删除旧接口/旧格式适配,不迁移旧已读数据。其他项目兼容代码经确认仅用于历史格式后可清理;正常扩展与平台能力不属于兼容层。 + +- 勾选“快速模式仅限已读文本”时只允许已读文本快速;不勾选时已读、未读均可快速。 +- int 与 int range 合并为整数设置项,double 与 double range 合并为浮点设置项,通过编辑方式成员区分 Slider / NumericUpDown。 +- Step 只是操作调整步长,不参与合法性校验,不要求值是 Step 的倍数。 +- Choice 候选项变化导致当前值失效时回退默认值。 +- 可见性、可用性与原因说明可动态变化;原因保存本地化 key。 +- 不使用 AutoSettingUI。 +- 原语分别处理 Normal / Quick / CatchUp 调用;快速和追帧可由基类回退正常调用,不重复执行逻辑。 +- 自动等待采用动态 IsAutoBlocking 与状态变化事件;Engine 按实例 identity 维护阻塞集合,清空后在有效推进边界开始间隔。无需文本与语音配对。 +- 接受类型化值访问、主题模板、自定义编辑控件、验证/应用/保存职责、开发者默认值与强制值区分等前期讨论结论。 + +## 当前问题与事实 + +- Engine 在 dispatch `dialogue.text` 时标记已读,尚无运行时 `IsRead` 消费者;需要避免当前句的本次写入改变快速资格,并覆盖 NVL 文本。 +- 编译文档已经具有源条目派生的字符串 ID,但 Runtime Entry.Id 为顺序整数;当前已读记录使用该整数。需核对内容加载边界并保留稳定身份,防止插入条目后错配进度。 +- 对白与 NVL 打字结束后仍 blocking,等待推进;只有 blocking/completed/skippable 状态无法直接表达自动计时起点。 +- 当前音频原语立即完成,语音播放结束没有参与自动推进等待;需补齐关联播放的结束/失败/停止通知语义。 +- `GameSettings` 有自动间隔、快速间隔、SkipMode 预留字段,但没有播放策略消费者。 +- 当前设置服务提供扁平的 Choice / Range 设置列表;Sample 仅恢复和持久化语言、文本速度。 +- 三个内置主题分别拥有普通页面和游戏工具栏布局,普通设置模板由主题提供。 +- F-20260929-15 的追帧存档方案已暂缓,没有统一原语追帧实现。本 feature 使用向前跳过的追帧语义,不恢复该存档方案。 + +## 范围 + +### 播放模式与输入 + +- 单一播放模式状态保证手动、快速、自动互斥;两个 ToggleButton 允许均未选中。 +- 快速模式完整显示当前文本,跨过文本内部手动 skip 分段;对可跳过阻塞使用其明确定义的完成语义,不可跳过阻塞按声明等待或停止。 +- 快速间隔以阅读单元为单位,不在每条即时 primitive 后添加间隔。 +- 自动模式不主动截断正常呈现。当前阅读单元的文本、关联语音和其他声明参与等待的有限播放都结束后,才开始自动间隔。 +- 循环 BGM、长期动画、持续粒子等不因全局仍在运行而阻止自动推进;选项不被自动选择。 +- 所有原语具备明确的正常、快速、追帧及自动等待语义;可由共享能力/默认规则覆盖,不要求每个原语重复配置全套模式参数。 +- 在游戏交互范围内左键操作取消旧快速/自动状态,并取消其待执行计时。点击模式按钮后仍能开启该按钮选择的新模式。 +- 模式、计时和旧运行续跑在读档、退出、会话替换、运行结束等生命周期边界正确取消。 + +### 已读与追帧跳过 + +- 已读跨会话持久化,不随存档槽回滚;普通对白、NVL 和语音文本编译结果均正确关联文本身份。 +- 当前句快速资格基于进入前的已读事实;遇到未读文本且限制开启时停止快速并正常呈现。 +- 跳过按钮首先确认,再沿真实剧情路径追帧,停在未读文本、选项或剧情结束;当前已是未读文本时不越过该句。 +- 追帧执行必要的变量、条件分支、解锁及场景逻辑;压缩中间等待和呈现,到达目标后同步最终展示。 +- 向前追帧与存档历史恢复区分:不能照搬旧 replay-save 的“变量已恢复,跳过写入”规则。 +- 跳过使用固定的未读/选项停止边界,不受“快速模式允许未读”偏好解除限制。 +- 自定义外部动作和不可跨越边界具有明确语义;不能无声明地丢弃动作或强行完成。 +- 长路径与循环执行可取消且不会冻结 UI;确认提示及进度/错误展示使用主题与本地化资源。 + +### 分类设置契约 + +- 设置服务提供只读的设置分类列表;分类包含稳定 ID、名称 key 和设置项列表,组合完成后结构固定。 +- 设置项具有稳定 ID、名称/说明 key、类型化值访问、默认值、验证与变更通知;类型信息从实际值类型推导。 +- 内置整数、浮点、字符串、布尔、Choice 和操作项,并提供自定义编辑项。 +- 整数/浮点设置包含最小值、最大值、Step、编辑方式及必要的单位/格式;Step 不参与合法性校验。整数保持整数值,浮点拒绝非有限值。 +- Choice 支持 enum 与动态候选项。当前值失效回退有效默认值;动态默认值失效的处理在设计阶段明确,禁止回写无效值。 +- 开发者能增删/重排分类和设置项,并设置默认值、范围、固定策略和可修改权限。 +- 隐藏或删除设置项只影响修改入口;开发者强制值应覆盖此前保存的玩家偏好。 +- 设置项支持动态可见性、可用性和本地化原因 key;状态变化通知 UI,避免仅保存委托而没有刷新机制。 +- 可采用状态计算委托并统一返回可见/可用/原因;具体依赖与通知 API 在设计文档确定。依赖源变化后自动刷新,解除订阅与会话销毁对应。 +- 禁用原因通过可用的悬浮提示展示,触摸/键盘场景也可读取;隐藏项不占布局,不产生悬浮提示。 +- 修改值经验证、应用、通知和持久化;应用失败不把尚未生效的值当作成功配置,提供本地化错误反馈。 +- 玩家偏好独立于剧情存档;当前快速/自动开启状态属于运行会话,不持久化为偏好。 + +### 设置 UI 与宿主能力 + +- 每个主题根据设置类型和数值编辑方式提供 UI 模板;可复用设置行及编辑控件,并支持主题覆盖。 +- 统一使用现有 GalNet 主题画刷、尺寸和本地化能力;分类切换是设置页局部状态。 +- 普通设置模型不保存 Avalonia 控件;自定义控件工厂位于 Avalonia 展示边界,每次创建独立编辑控件并处理绑定及资源生命周期。 +- 主题统一承载自定义项的名称、说明及验证错误;工厂支持主题/服务上下文,不能绕过配置验证与保存。 +- 未知设置类型或缺失模板应提供明确诊断,不静默漏项。 +- 游戏分类提供语言、文本速度、仅已读快速、快速间隔、自动间隔。 +- 画面分类由宿主能力提供窗口、无边框全屏、支持时的独占全屏,以及适用的窗口尺寸/分辨率;不支持的功能按能力隐藏或禁用。项目设计分辨率与窗口/显示分辨率区分。 +- 音频分类提供实际生效的主音量、BGM、音效、语音和静音设置,正确组合玩家音量与内容/轨道增益。 +- 设置依赖变化应刷新可用性,例如全屏模式下窗口尺寸的可修改状态。 + +## 非目标 + +- 2026-10-11 用户已授权继续直到实现;阶段与验证见 [phase-plan.md](phase-plan.md)。 +- 不恢复或改造 replay-save 存档格式;正式完整快照模型继续保留。 +- 不实现快捷键编辑、冲突检测和控制映射;仅为未来控制分类留扩展能力。 +- 不引入 AutoSettingUI,不要求使用 Ursa;可借鉴 enum selector 的交互逻辑,具体依赖需设计时评估。 +- 不实现运行期主题切换,不把 Avalonia、窗口或原生媒体类型引入 Core/Runtime。 +- 不对所有平台承诺相同的全屏/分辨率能力,也不以失效控件冒充已实现功能。 + +## 验收标准 + +1. 快速与自动互斥,左键取消和模式按钮启动均有效,无迟到计时推进、重复推进和跨旧会话推进。 +2. 快速文本完整显示、间隔有效,已读限制开启时第一次遇到的文本不会因 dispatch 标记变为可跳过。 +3. 文本与语音同时播放时,在两者及当前单元其他等待完成后才开始自动间隔;媒体失败/停止不造成永久等待,长期播放不阻止推进。 +4. 跳过必须经过确认,沿实际条件路线推进,变量和解锁结果正确;在未读文本或选项处停止并呈现正确场景,可取消长路径执行。 +5. 已读包含 ADV/NVL,跨会话和读档保留;条目位置变化不导致把另一句错误识别为已读。 +6. 三个主题提供分类设置、所有内置编辑类型和自定义项承载;开发者可追加分类、删除配置项或覆盖模板。 +7. Step 只改变调整步长,合法范围内不符合步长倍数的值仍能接受。Choice 失效后回退有效默认值并同步运行值与 UI。 +8. 可见性/可用性依赖改变时自动刷新;禁用原因可本地化并由鼠标、键盘或触摸用户访问,隐藏项不占布局。 +9. 玩家偏好恢复、应用、保存和错误反馈一致;强制值不会被旧偏好绕过。画面/音频配置在支持的宿主实际生效。 +10. Runtime、Builtins、设置、主题及宿主集成的关键行为得到比例适当的验证;Sample 与 Editor 预览保持可用,正式文档在实现验证后同步。 + +## 约束、假设与设计阶段待定 + +- 需求校验没有必须先向用户追问的冲突;详细设计见 [design.md](design.md),状态进入 design,下一阶段为 Phase Plan。 +- 已确认无需音文配对;阅读边界 metadata 只用于未读前停止,BatchId 继续表示手动 skip 分组。 +- 设计选择:剧情区域点击取消旧模式并执行一次手动推进,工具栏仅执行按钮操作;无效默认 Choice 明确禁用/诊断,等待候选/default 提供者修正;旧数字已读格式明确拒绝,不迁移、不修改旧文件。 +- 其余时序、原语默认回退、媒体 ticket、追帧状态同步与设置生命周期边界在 design.md 中说明,属于设计选择而非已实现事实。 +- 当前工作树中已有用户对 `src/Samples/GalNet.Sample.Avalonia/App.axaml.cs` 的修改,本 feature 建立时未触碰。 + +## 相关链接 + +- [Runtime 规范](../../docs/spec/runtime.md) +- [Avalonia 游戏页面](../../docs/spec/control.md) +- [运行期本地化与玩家偏好](../../docs/spec/i18n.md) +- [架构规范](../../docs/spec/architecture.md) +- [Feature 工作流](../../docs/design/feature-workflow.md) +- [暂缓的追帧存档需求](../F-20260929-15-replay-save-format/feature.md) +- [主题拥有页面](../F-20261011-03-theme-owned-views/feature.md) +- [现有设置项接口](../../src/Presentation/GalNet.Avalonia.GameView/Services/IPlayerSettingsService.cs) +- [Engine](../../src/Shared/GalNet.Runtime/Engine/GameEngine.cs) +- [编译与稳定条目映射](../../src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs) diff --git a/features/F-20261011-04-playback-settings/phase-plan.md b/features/F-20261011-04-playback-settings/phase-plan.md new file mode 100644 index 00000000..9d440e60 --- /dev/null +++ b/features/F-20261011-04-playback-settings/phase-plan.md @@ -0,0 +1,73 @@ +# 实现阶段 + +授权:2026-10-11 用户要求继续直到实现。业务代码可修改;既有 App.axaml.cs 用户改动保留。 + +## Phase 1 — 原语契约、稳定身份与已读(verified) + +前置:feature/design。Core 的 Normal/Quick/CatchUp、自动阻塞状态与推进等待;Builtins 文本/NVL 的单次模式行为;编译/加载保留稳定 ID 与阅读边界;已读版本化。验证 Core、Builtins、Runtime/Storage 的相关测试。退出:手动语义不回退,快速全文与已读身份可测试。 + +证据(2026-10-11):Core 70、Builtins 54、Storage 14、Runtime 59、Integration 63 项通过。compiled 独立 v3,raw 仍 v2;progress v2 拒绝旧版本不修改旧文件。用户追加不保留兼容后,删除 presenter 的单步 Skip 回退、IGameView 旧 Dispatch 分支、Variable 旧类型容错及注释 Layer 桥。Integration 需要沙箱外读取 Avalonia licensing tickets;已获工具批准并实际跑通。 + +## Phase 2 — Engine 策略、语音与追帧(verified) + +前置:Phase 1。Engine 阻塞集合、计时、代际隔离、模式与追帧;音频播放 ticket、快速停止及音量;动画/视频追帧语义。验证自然完成、重复事件、音文等待、Choice、取消/读档及追帧真实变量路径。退出:完整策略可在无 UI 测试验证。 + +证据(2026-10-11):Runtime 69、Builtins 59、Integration 63 项通过。包括自动阻塞与计时取消、旧读档实例隔离、真实变量/条件路径、Choice 停止、循环预算取消、追帧最终同步先于目标派发、同步失败后的停止清理、持久视频定义与叠加动画只提交一次;音频 ticket 的结束/失败/替换、BGM 追帧最终投影及实际增益已有无 UI 测试。取消追帧后模式切换/手动推进会等待旧展示收尾,未启动的追帧不修改活动实例。具体 Sample/Editor 协调接线、UI 抑制与真实媒体人工验收按 Phase 4 实施,不能将端口测试视为页面已接通。 + +## Phase 3 — 类型化分类设置(verified) + +前置:配置消费契约。设置分类/项、整数浮点编辑方式、Choice、availability、应用/保存、自定义编辑器。验证 Step、回退、状态刷新、失败与生命周期。退出:宿主可扩展配置,无平台类型泄漏。 + +证据(2026-10-11):设置模型及主题定向 Presentation 测试 39 项(模型 9 项、主题 30 项)、Integration 63 项通过。IPlayerSettingsService 已替换为只读 Categories,旧扁平 PlayerSetting/ChoicePlayerSetting/RangePlayerSetting 删除,Sample 语言/文本速度应用及保存恢复使用新队列。覆盖 Step 不校验倍数、动态 Choice 默认回退(隐藏/禁用时也修正运行值)、无效默认禁用、依赖刷新、应用失败、保存失败后同值重试且不重复应用、强制值与 Action。自定义编辑器 detach/替换释放租约并重建独立 Control。因删除旧契约需要保持编译,三主题最小分类 TabControl/模板接线提前完成,共用 Themes 内模板,完整原因/错误本地化与实际功能接线仍属 Phase 4。 + +补充验证:完整 Presentation 回归 116 项通过;原生媒体 opt-in 用例未运行,不声称验证真实解码后端。Editor 独立输出构建成功(0 error、9 warning);默认输出构建因两个正在运行的 Editor 进程锁定 DLL 而在复制阶段失败,未结束用户进程。验证输出在 obj/verification/playback-settings-editor(忽略目录),不会更新运行中的 Editor。git diff --check 通过(仅既有/当前 LF 到 CRLF 提示)。 + +验证发现:绑定 null 的可选 Format 会覆盖 NumericUpDown 自身默认并在实际 attach 时抛出 NullReferenceException;模板使用 TargetNullValue=G 修正。编译成功不能代替三主题实际 headless 渲染,9 组 layout/palette 回归已通过。 + +## Phase 4 — 三主题与宿主接入(in-progress) + +前置:Phase 2/3。工具栏互斥 Toggle、左键取消、主题确认;设置分类模板;Sample/Editor 接入、显示能力、玩家偏好/音量。验证主题与交互测试、Sample/Editor 构建及集成。退出:实际页面可使用所有已支持设置,平台不支持功能有明确可用性。 + +阶段内进展(2026-10-11):三主题工具栏已加入 Quick/Auto 互斥 Toggle 与跳过按钮;GamePage 左键 tunnel 先取消旧请求,保存按钮点击前选中状态,右键不推进。GameShell 玩家根加入主题化确认层,确认期间禁用背景,取消/Escape 不执行追帧。Sample/Editor 绑定 Engine 的纯展示控制端口;离页、截图及释放撤销旧模式。确认复用 GamePageViewModel 的命令和可见状态,不再增加单独确认 ViewModel/普通页面注册;它仍由主题提供 ControlTemplate,且不参与导航历史。 + +Sample 游戏分类包含语言、文本速度、仅已读快速、快速/自动间隔,音频分类包含 Master/BGM/SFX/Voice 音量与静音。设置和 Engine 使用同一 GameSettings 实例,宿主保留由执行 view 拥有的 BuiltinAudioModule 引用来刷新当前播放增益;不是仅更新 UI。偏好使用 player.galnet.settings 命名空间,恢复时校验类型和范围,无效值用默认值、不强制迁移或修改原记录。动态说明、禁用原因、错误及单位采用与 PlayerText 相同的可观察本地化适配,原因同时有 tooltip 与行内文本;空 key 不占文本布局。播放请求异常通过 StatusMessage 和宿主已有 InteractionObserved 暴露详情,不静默吞掉错误。 + +当前验证:Presentation 122 项通过(包含三主题实际点击、根确认层背景屏蔽/Escape、分类隐藏选择与 Flush 重试);Integration 65 项通过(新增偏好恢复、范围内非 Step 倍数、错误类型默认回退、动态原因 key/语言更新)。原生媒体 opt-in 未运行,不能将这些测试视为真实媒体人工验收。修复了新增 Common.Cancel 与既有资源重复导致 JSON provider 加载失败的问题,已重新构建资源并通过完整 Integration。git diff --check 通过,只有行尾提示。 + +补充:全隐藏/空分类不显示标签,当前分类失效时回退首个可见分类,未失效时保持选择;页面 detach 解除设置项事件订阅。退出 Flush 不仅等待队列,还重试 pending save,失败向调用者传播而不重复应用运行值。Editor 独立输出构建成功(0 error、10 warning),未覆盖运行中的程序。 + +仍未完成:Sample/Editor 的具体 Begin/EndCatchUp 展示抑制及最终场景重建;窗口/显示能力设置;Editor 预览的完整设置消费者;主题单项模板覆盖;真实媒体及完整场景人工验收。上述事项完成前保持 in-progress,跳过端口的 Runtime 测试不能替代真实宿主接线。下一步需首先处理已恢复的循环动画/视频不在 Engine 活动集合中的暂停与晚清理隔离,再接追帧协调,不能直接 Dispose presenter 后重放而删除 Runtime 持久定义。 + +2026-10-12:用户移除 Editor 宿主实现范围,后续验收只针对 Sample 与共享能力。Sample 增加画面分类及 IPlayerDisplayService 纯能力端口;MainWindow 绑定桌面实现,支持 Window / BorderlessFullscreen(Avalonia FullScreen 不宣称独占),预设窗口尺寸独立于设计分辨率。全屏期间尺寸禁用并提供本地化说明;无 Window 时整组隐藏。显示偏好由设置队列保存,启动先恢复窗口尺寸再恢复显示模式,不重复回写偏好。集成测试 67 项通过,包含能力筛选、全屏禁用、显示恢复和后端拒绝后的实际值保持;真实桌面切换尚未人工验证。 + +恢复语言候选时原 Attach 使用 fire-and-forget,新增测试后的回归复现了旧候选偶发可见的问题;改为 AttachAsync 并等待 UpdateOptionsAsync,调用者等待恢复完成。原有语言快照回归重新通过。 + +追帧生命周期修正:Sample Prepare 不再用 Engine.Runtime 和调用者 token 重放循环对象,而经 SynchronizePresentationAsync 使用引擎该代 scoped runtime/token,重建受同一执行门保护。Runtime 70 项通过,新增测试验证重建回调持有的旧场景隔离、读档/停止取消 token,旧回调不能写变量。调用者 token 控制进入执行门,持久播放保留 Engine run token,不能将临时 linked CTS 在重建返回后 Dispose 而切断停止通知。 + +上述旧剩余项已于 2026-10-12 推进:Replay 通过 onContinuation 在 Dispatch 前交付动画/视频实例;PrimitiveInstance.PresentationCompletion 包含展示清理,Engine 先模式切换再排空已有展示任务,restorer 对恢复实例同样跟踪、压缩、排空,取消等待不丢失跟踪。SampleGamePresentation 实现并实际传入 ICatchUpPresentation,Begin 接音频最终投影、文本缓存和恢复实例暂停,End 清 visual、不 Dispose 后端,再以 scoped runtime/token 重建场景、对白/NVL 和音频。文本投影通过 ICatchUpTextRecorder 接入,不扩展 snapshot。Normal 和最终重建的可见状态/NVL clear 分开处理;立即呈现显式 RevealAll,不能只靠文字速度 0 忽略文本内部 delay。 + +2026-10-12 等待时间按用户要求改为 int 毫秒,Runtime 字段显式命名 Milliseconds,快速默认 50(0–5000,步长 10),自动默认 2000(0–30000,步长 100);持久偏好保存 Int。Step 不做校验,75/3250 能正常保存恢复;负值、越界与旧 float 值回退默认值,不迁移。 + +主题覆盖已提供 Common.SettingsCategories.EditorTemplates,按匹配顺序优先覆盖某种类型,其余回退内置编辑器,仍保留统一设置行。三布局覆盖/撤销覆盖验证通过。最新验证:Runtime 71、Builtins 60、Integration 73、Presentation 125 项通过。三布局×三配色验证追帧中无中间 NVL clear/append、最终历史/可见性正确,含 60 秒 delay 的文本最终重建在 2 秒内完成。恢复动画、plan、视频的延迟 finally 清理与取消后继续重建有 4 项测试;Sample 已构建。 + +原生 opt-in 已实际执行:VideoNativeSmokeTests 4 项(真实 niko.mp4 解码、seek/结束、加载时隐藏/替换),AvaloniaAudioLifetimeTests 2 项(加载时停止/销毁)通过,使用 Sample 已有 win-x64 LibVLC。这些不是完整桌面 Sample 的操作链人工验收;物理窗口全屏切换、实际音量听感及完整追帧媒体组合仍未人工验证。因此 Phase 4 暂保持 in-progress,不能将单项后端测试替代完整宿主验收。 + +## Phase 5 — 审核、正式文档与收尾(in-progress) + +2026-10-12 用户报告视频分段点击后开启快速,视频消失但剧情停住。回归先复现:手动文本 reveal / 点击不可跳过 blocker 的 Auto、Quick 四例均失败;实际视频点击 0/1/2 次再切换模式六例中四例失败。根因为 AdvanceAsync 在 blocker 尚未结束时提前清空推进边界。修复只在真正消费边界、继续分发后续剧情前清空。排查还复现了追帧遇到已有不可跳过 blocker、取消后开启 Auto/Quick 的两例失败;追帧压缩并清理活动实例后,按剩余 blocker 重建边界,保留扩展回退正常执行的语义。 + +新增共 16 项回归,包括上述 Runtime 六例、视频六例、真实 ADV/NVL 手动 reveal 后 Auto/Quick 四例;后者使用 120 毫秒间隔验证没有立即越过文本。完整 Runtime 77、Integration 87、Builtins 60,共 224 项通过;Sample 随 Integration 构建。未修改用户 App.axaml.cs,未进行桌面视频操作链人工验收。Phase 4/5 状态不变。 + +2026-10-12 代码整理:对白/NVL 的正常、即时和最终重建复用 UI 线程渲染路径,重建不重复记录历史或更改缓存可见性;Sample Int/Double/Bool 设置统一应用→通知→刷新→存储顺序,保留各自类型校验与保存类型;Engine 集中当前未读文本的快速资格判断。不新增服务、接口或兼容层。Presentation 131、Runtime 71、Integration 77 项通过,Sample 随 Integration 构建成功;未扩大人工验收结论。用户 App.axaml.cs 仍保留未提交。 + +2026-10-12:主实现已提交 `43dd62f`,排除用户原有 App.axaml.cs。审核重点覆盖生命周期、读资格、计时、设置及依赖边界,结论 pass-with-follow-up,见 review.md;summary.md 记录交付与限制。Architecture 26 项通过;格式修正后的七组回归共 441 项通过(Core 70、Runtime 71、Builtins 60、Storage 14、Editor.Shared 18、Presentation 131、Integration 77)。新增 4 项真实内置音文原语联动测试,验证两种完成顺序/音频失败均在全部等待解除后开始间隔。剩余桌面组合验收未完成,Phase 4/5 不提前标 verified。 + +2026-10-12 Phase 4 工具栏补充:自动/快速仅选中显示主题色半透明背景;普通工具栏按钮透明→8% 悬浮→18% 按下,选中模式 24%。局部模板避免 Fluent 默认 PART_ContentPresenter 状态画刷覆盖。模式应用后重新发布 OneWay 选中绑定,保证按钮原生 toggle 与左键取消后的权威模式一致。九组布局×配色交互测试通过,覆盖两模式再次点击取消、互斥、真实模板背景与确认。完整 Presentation 回归 131 项通过(本轮未运行 6 项原生 opt-in),Sample 构建成功(0 error、2 warning),git diff --check 通过。 + +2026-10-12 样例优先修复:GameTestCase 的三个 group 均可使用 `line`,问题是 Shared 校验与 AddEntry 错将条目 ID 要求为全局唯一。修复为 group 内唯一并增加两项回归,以及样例作者文档校验。Shared 18、完整 Integration 68 项通过;CLI validate 无诊断,GameTestCase/Output 的 4 个 group 已重编译为 compiled v3。样例 README 更新格式与进度说明。本地旧 progress version 0 按本次修复显式移至 progress.legacy-20261012.json.bak,保留设置及存档,Runtime 无自动迁移/清空兼容代码。本次只修正 Shared 作者内容契约,不扩展 Editor UI;Phase 4 剩余项不变。 + +样例补充运行验证:实际 Headless 加载 GameTestCase/Output,经对白、动画、NVL、Choice 和 downtown 路线到达 Runtime smoke test complete,进程 exit 0;独立 artifacts/verification/sample-fix-20261012 档案生成 progress v2。Avalonia Sample 构建通过,但本轮未人工验证桌面界面及原生媒体。 + +前置:实现阶段通过。检查 diff/架构/相关集成与格式,记录实现偏差和未能人工验证的限制,同步 runtime/control/i18n 文档和 feature summary。退出:证据完整,未完成项不隐藏。 + +测试避免多 MSBuild 进程同时构建共享 obj。阶段状态仅在验证有证据后变更。 diff --git a/features/F-20261011-04-playback-settings/review.md b/features/F-20261011-04-playback-settings/review.md new file mode 100644 index 00000000..82593d6f --- /dev/null +++ b/features/F-20261011-04-playback-settings/review.md @@ -0,0 +1,32 @@ +# 播放与设置 feature 审核 + +日期:2026-10-12。结论:**pass-with-follow-up**(自动化与代码审核通过,桌面完整验收待补)。 + +审核基线为 `43dd62f`(相对其父提交),包含本轮格式修正和 `BuiltinAutoPlaybackTests`。未纳入用户原有 `App.axaml.cs` 改动,不扩展 Editor UI。审核依据为 feature/design/phase-plan、实际 diff、实现与测试;不是另一个独立 agent 的审核。 + +## 重点检查 + +- Core/Runtime 不反向依赖 Avalonia;播放状态以 Engine enum 为唯一来源,自动等待使用实例 identity 集合,不模拟点击。 +- 阅读资格在本次标记已读之前捕获,稳定身份使用 group + compiled entry ID;同名条目允许跨 group,旧格式拒绝且不自动迁移。 +- 自动计时等待有效边界及所有动态自动阻塞,设置变更/取消/读档/停止使旧计时失效;Choice 不自动选择。 +- 追帧实际执行条件与变量,目标语音前先停止并同步最终展示;已启动和存档恢复的展示任务分别排空,最终恢复使用隔离 run scope。 +- 类型化设置的范围验证、Step 非校验、Choice 默认回退、availability、保存失败重试及自定义控件 detach 生命周期有对应测试。 +- 三布局×三配色测试检查真实模板背景和重复点击取消,而非只检查 ViewModel 值。 + +本轮没有确认新的 Blocker/P1 业务缺陷。格式门禁发现的换行及导入顺序问题已定向修正;没有对整个工作树做格式化。 + +## 验证 + +本轮顺序执行 Core 70、Runtime 71、Builtins 60、Storage 14、Editor.Shared 18、Presentation 131、Integration 77,共 441 项通过;Architecture 26 项通过。Integration 新增 4 项测试使用真实内置 Dialogue/Audio 原语和 AudioRuntime、可控展示端,覆盖两种完成顺序及音频失败,确认间隔不提前开始。 + +Sample 在 Integration 构建中成功编译;仍有非阻断 analyzer/XAML constructor warning,不将其表述为零 warning。原生视频 4 项、音频 lifetime 2 项在先前 Phase 4 已实际通过,本轮常规回归不包含原生 opt-in,不重复声称原生后端再次验收。 + +格式验证限定本 feature 文件:whitespace、IDE0005;`git diff --check`。具体最终输出见 phase-plan。 + +## 后续验收(P2,非已确认代码缺陷) + +2026-10-12 用户视频反馈后的补充:原审核没有覆盖“手动分段/reveal 后切换播放模式”。本轮确认并修复了手动点击提前清空有效推进边界,以及取消追帧后已有不可跳过 blocker 的边界遗失,两者均先用失败回归复现。新增 16 项测试并通过 Runtime 77、Integration 87、Builtins 60 的完整回归;还检查了 ADV/NVL、wait、动画/plan 模式 hook 和恢复/停止/Choice 的边界清理路径。此证据更新原审核,不等价于已完成桌面组合验收。 + +完整桌面 Sample 链路仍缺人工证据:自动文本+可听语音;快速/追帧最终媒体组合;追帧取消后手动推进;读档/退出时迟到回调;物理窗口/无边框全屏切换及音量听感。分层自动化和孤立原生测试降低风险,但不替代这些组合验收。 + +保留 Phase 4/5 为 in-progress 和 feature implementing,不以生成总结作为完成依据。当前工具不能原生控制桌面窗口,因此没有伪造截图或操作结果。Editor 预览完整设置与追帧协调、快捷键编辑、独占全屏、音频存档恢复仍不属于本次交付范围。 diff --git a/features/F-20261011-04-playback-settings/summary.md b/features/F-20261011-04-playback-settings/summary.md new file mode 100644 index 00000000..1f9fc0e7 --- /dev/null +++ b/features/F-20261011-04-playback-settings/summary.md @@ -0,0 +1,34 @@ +# 播放控制与分类设置实现总结 + +状态:主要功能已实现并提交;自动化审核通过,桌面组合验收未完成。feature 仍为 implementing。主实现提交:`43dd62f`。 + +## 实现结果 + +- Engine 提供互斥 Quick/Auto、动态自动阻塞和整数毫秒间隔。Quick 全文呈现并快速完成可跳过阻塞;Auto 等所有声明参与的有限呈现结束后计时,不需音文配对。 +- 带主题确认的 Skip 沿真实路线追帧,在未读阅读边界或 Choice 停止;取消保留已执行逻辑,不回滚变量。 +- ADV/NVL 成功呈现后记录已读,group + 稳定编译 ID 消除位置变化与跨 group 同名冲突。compiled v3 / progress v2 拒绝旧格式,raw v2 / snapshot v3 不变。 +- 分类设置采用只读结构和可观察类型化项:Int/Double(Slider/NumericUpDown)、Bool、String、Choice、Action、自定义编辑控件租约;动态 availability 和原因 key、默认回退、应用/保存队列及保存重试。 +- Sample 接通游戏、画面、音频设置;快速默认 50ms、自动 2000ms。窗口/无边框全屏及预设尺寸由纯能力端口提供,不宣称独占全屏。音量/静音实际作用于当前与后续播放。 +- 三布局×三配色工具栏与设置模板统一;自动/快速只在选中时显示背景、可再次点击取消,其他工具按钮悬浮淡色、按下加深;可按类型覆盖设置模板。 + +## 关键边界与计划调整 + +`PrimitiveInstance.PresentationCompletion` 区分逻辑完成与异步展示清理;Sample 跟踪恢复实例,并在 Engine scoped runtime/token 下重建。文本/NVL 使用会话内投影,不把历史塞入 snapshot,也不把 BGM 追帧投影扩大为音频存档恢复。 + +复用 `SampleGamePresentation` 作为追帧端口,确认状态复用现有游戏 VM,不新增通用 Runner 或独立确认导航页。用户移除 Editor 后续实现范围;保留已有的最小播放绑定及 Shared 作者文档 ID 修正,不扩展 Editor 设置。未采用 AutoSettingUI 或新 UI 依赖。 + +本地旧样例 progress version 0 已显式移至 `progress.legacy-20261012.json.bak`,没有加入运行时自动迁移/清空。用户设置和存档保留。用户原有 `App.axaml.cs` 配色修改未提交。 + +## 验证与限制 + +最新回归:Core 70、Runtime 71、Builtins 60、Storage 14、Editor.Shared 18、Presentation 131、Integration 77,共 441 项通过;Architecture 26 项通过。新增 4 项真实内置文本/音频联动测试覆盖完成顺序与失败。Sample 构建通过。 + +先前验证:GameTestCase 作者 validate、4 group 重编译和 Headless 路线到终态通过;真实 LibVLC Video 4 项、Audio lifetime 2 项通过。未将 isolated 后端测试当作完整桌面验收。编译仍有非阻断 warning;格式检查仅针对 feature 文件,避免改动用户文件。 + +[审核记录](review.md) 为 pass-with-follow-up。剩余桌面人工验收包括媒体组合、追帧取消/读档/退出、物理全屏切换和音量听感;Phase 4/5 未据此标为 verified。快捷键、音频存档恢复和独占全屏不在范围。 + +## 文档与经验 + +已同步 [Runtime](../../docs/spec/runtime.md)、[游戏页面](../../docs/spec/control.md)、[i18n](../../docs/spec/i18n.md) 和 [Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md)。正式文档保留当前能力与边界,未加入未实现方案。 + +经验见 [agent-lessons.md](agent-lessons.md):run scope、作者校验作用域、显式文本 delay、Fluent 模板状态覆盖。`.agents` 只读,尚未晋升或更新长期索引,临时记录保留原始证据。 diff --git a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs index 22e732a8..008470f0 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs @@ -12,8 +12,8 @@ private static CommandExecution AddEntry(EditorProjectDocument document, AddEntr return failure!; if (string.IsNullOrWhiteSpace(command.EntryId)) return Error("group.entry.idRequired", "Entry ID is required."); - if (document.GroupEntries.Values.SelectMany(value => value).Any(entry => entry.StableId == command.EntryId)) - return Error("group.entry.duplicateId", $"Entry '{command.EntryId}' already exists."); + if (entries!.Any(entry => entry.StableId == command.EntryId)) + return Error("group.entry.duplicateId", $"Entry '{command.EntryId}' already exists in group '{command.GroupId}'."); var groupEntries = entries!; var index = command.Index ?? groupEntries.Count; if (index < 0 || index > groupEntries.Count) diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs index 2d9ebda7..61546d8f 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs @@ -32,10 +32,13 @@ public static ValidationResult Validate(EditorProjectDocument document) diagnostics.Add(EditorDiagnostic.Error("graph.edge.invalidOutlet", $"Edge '{edge.Id}' references invalid outlet {edge.FromOutlet} on '{from.Id}'.", $"graph/edges/{edge.Id}")); } - var entryIds = document.GroupEntries.Values.SelectMany(entries => entries).Select(entry => entry.StableId).ToList(); - AddDuplicateDiagnostics(entryIds, "group.entry.duplicateId", "entry", diagnostics); - if (entryIds.Any(string.IsNullOrWhiteSpace)) - diagnostics.Add(EditorDiagnostic.Error("group.entry.idRequired", "Every group entry must have a stable ID.")); + foreach (var (groupId, entries) in document.GroupEntries) + { + var entryIds = entries.Select(entry => entry.StableId).ToList(); + AddDuplicateDiagnostics(entryIds, "group.entry.duplicateId", $"entry in group '{groupId}'", diagnostics); + if (entryIds.Any(string.IsNullOrWhiteSpace)) + diagnostics.Add(EditorDiagnostic.Error("group.entry.idRequired", "Every group entry must have a stable ID.", $"groups/{groupId}")); + } var optionIds = document.Graph.Nodes.SelectMany(node => node.Options ?? []).Select(option => option.Id).ToList(); AddDuplicateDiagnostics(optionIds, "branch.option.duplicateId", "choice option", diagnostics); diff --git a/src/Editor/GalNet.Editor/GalNet.Editor.csproj b/src/Editor/GalNet.Editor/GalNet.Editor.csproj index 605862fb..976ef48d 100644 --- a/src/Editor/GalNet.Editor/GalNet.Editor.csproj +++ b/src/Editor/GalNet.Editor/GalNet.Editor.csproj @@ -50,6 +50,7 @@ + diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index fa66cbb0..1f881ba0 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -1,4 +1,5 @@ extern alias GameViewAssembly; +extern alias ThemesAssembly; using System.Collections.ObjectModel; using Avalonia; using Avalonia.Controls; @@ -46,7 +47,7 @@ public EditorPreviewHost(EditorPreviewContext context) { var services = new ServiceCollection(); services.AddSingleton(context); - services.AddAvaloniaGameViewPages(); + services.AddAvaloniaGameViewPages(new ThemesAssembly::GalNet.Avalonia.Themes.ClassicTheme()); services.AddScoped(); services.AddScoped(provider => provider.GetRequiredService()); _services = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); @@ -251,6 +252,7 @@ private void DisposeEngine() (GalleryResources as IDisposable)?.Dispose(); GalleryResources = null; _gameplay.AdvanceRequested -= OnAdvanceRequested; + _gameplay.BindPlayback(null); _engine?.Dispose(); _pageView?.Dispose(); _layers?.Dispose(); @@ -293,6 +295,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) _audio).BuildView(); var runtime = new GameRuntime(null, content.Graph.RootNodeId, new SettingsContainer(), _context.Variables); _engine = new GameEngine(content.Graph, runtime, view, _context.Progress, _pageView.ChoicePresenter); + _gameplay.BindPlayback(_engine); _gameplay.AdvanceRequested += OnAdvanceRequested; _context.RuntimeCreated(runtime); await RefreshSlotsAsync(cancellationToken); diff --git a/src/Editor/GalNet.Editor/packages.lock.json b/src/Editor/GalNet.Editor/packages.lock.json index cd20dfc7..f3d18191 100644 --- a/src/Editor/GalNet.Editor/packages.lock.json +++ b/src/Editor/GalNet.Editor/packages.lock.json @@ -489,6 +489,14 @@ "GalNet.Core": "[1.0.0, )" } }, + "galnet.avalonia.themes": { + "type": "Project", + "dependencies": { + "Avalonia.Themes.Fluent": "[12.0.5, )", + "GalNet.Avalonia.Controls": "[1.0.0, )", + "GalNet.Avalonia.GameView": "[1.0.0, )" + } + }, "galnet.core": { "type": "Project" }, diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FileGameProgressService.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FileGameProgressService.cs index 66c74141..a2f53142 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/FileGameProgressService.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/FileGameProgressService.cs @@ -13,19 +13,32 @@ public sealed class FileGameProgressService : IGameProgressService public FileGameProgressService(string profileDirectory) { _path = Path.Combine(profileDirectory, "progress.json"); - try { _data = File.Exists(_path) ? JsonSerializer.Deserialize(File.ReadAllText(_path)) ?? new() : new(); } - catch { _data = new(); } + _data = File.Exists(_path) + ? JsonSerializer.Deserialize(File.ReadAllText(_path)) + ?? throw new InvalidDataException("Read progress document is empty.") + : new() { Version = 2 }; + if (_data.Version != 2) + throw new InvalidDataException($"Unsupported read progress version '{_data.Version}'."); } public bool IsRead(string groupId, string entryId) { lock (_sync) return _data.ReadEntries.Contains(Key(groupId, entryId)); } - public void MarkRead(string groupId, string entryId) { lock (_sync) { if (_data.ReadEntries.Add(Key(groupId, entryId))) Save(); } } + public void MarkRead(string groupId, string entryId) + { + lock (_sync) + { + var key = Key(groupId, entryId); + if (!_data.ReadEntries.Add(key)) return; + try { Save(); } + catch { _data.ReadEntries.Remove(key); throw; } + } + } /// Clears all persisted read progress for a fresh player profile. public void Clear() { lock (_sync) { - _data = new ProgressData(); + _data = new ProgressData { Version = 2 }; if (File.Exists(_path)) File.Delete(_path); } @@ -34,13 +47,16 @@ public void Clear() private void Save() { Directory.CreateDirectory(Path.GetDirectoryName(_path)!); - File.WriteAllText(_path, JsonSerializer.Serialize(_data)); + var temporaryPath = _path + ".tmp"; + File.WriteAllText(temporaryPath, JsonSerializer.Serialize(_data)); + File.Move(temporaryPath, _path, overwrite: true); } - private static string Key(string groupId, string entryId) => $"{groupId}/{entryId}"; + private static string Key(string groupId, string entryId) => JsonSerializer.Serialize(new[] { groupId, entryId }); private sealed class ProgressData { + public int Version { get; set; } public HashSet ReadEntries { get; set; } = []; } } diff --git a/src/Presentation/GalNet.Avalonia.Controls/DialoguePresenter.cs b/src/Presentation/GalNet.Avalonia.Controls/DialoguePresenter.cs index d648f18a..9ebe3173 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/DialoguePresenter.cs +++ b/src/Presentation/GalNet.Avalonia.Controls/DialoguePresenter.cs @@ -48,6 +48,7 @@ public Task StartAsync(CancellationToken cancellationToken = default) } public void Skip() => _typewriter?.Skip(); + public void RevealAll() => _typewriter?.RevealAll(); protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { diff --git a/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs b/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs index 0a471b75..569f5fe4 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs +++ b/src/Presentation/GalNet.Avalonia.Controls/NvlPresenter.cs @@ -30,6 +30,7 @@ public Task AppendAsync(string text, double charactersPerSecond, CancellationTok /// Skips the currently appended line through its next typewriter skip boundary. public void SkipText() => _latestLine?.Skip(); + public void RevealAllText() => _latestLine?.RevealAll(); /// Removes all accumulated lines. public void Clear() @@ -78,6 +79,7 @@ public Task StartAsync(CancellationToken cancellationToken = default) } public void Skip() => _typewriter?.Skip(); + public void RevealAll() => _typewriter?.RevealAll(); protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { diff --git a/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml b/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml index 41743bc6..f972ad06 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml +++ b/src/Presentation/GalNet.Avalonia.Controls/Styles/Generic.axaml @@ -1,112 +1,115 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - diff --git a/src/Presentation/GalNet.Avalonia.Controls/Styles/Resources.axaml b/src/Presentation/GalNet.Avalonia.Controls/Styles/Resources.axaml new file mode 100644 index 00000000..e1960c3d --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Controls/Styles/Resources.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + 4 + 24 + 200 + 18 + 22 + 20,14 + 0,5 + 14 + 150 + diff --git a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs index ef48bbd7..0c2fbd7e 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs +++ b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs @@ -95,6 +95,15 @@ public void Skip() } } + public void RevealAll() + { + lock (_skipGate) + { + _instantMode = true; + _skipSignal?.TrySetResult(); + } + } + private void Restart() => _ = StartAsync(); private async Task RenderAsync( diff --git a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs index bd9449a1..8430f69f 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs @@ -3,37 +3,63 @@ using GalNet.Avalonia.GameView.Services; using GalNet.Avalonia.GameView.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace GalNet.Avalonia.GameView.Composition; -/// Registers default page VMs and their stateful views in one game scope. +/// Registers shared behavior and theme-owned views in one game scope. public static class AvaloniaGameViewServiceCollectionExtensions { public static IServiceCollection AddAvaloniaGameViewPages( this IServiceCollection services, + IGameLayoutTheme theme, Action? configureViews = null, Action? configureGalleryPages = null) { var views = new PageViewRegistryBuilder(); - views.Register(); + ArgumentNullException.ThrowIfNull(theme); + services.AddSingleton(theme); views.Register(); - views.Register(); - views.Register(); - views.Register(); - views.Register(); - views.Register(); - views.Register(); - views.Register(); - views.Register(); - configureViews?.Invoke(views); - var galleryPages = new GalleryPageRegistryBuilder(views, services); - galleryPages.Add("cg"); - galleryPages.Add("video"); - galleryPages.Add("audio"); + + galleryPages.Add("cg"); + galleryPages.Add("video"); + galleryPages.Add("audio"); + + theme.RegisterViews(views); configureGalleryPages?.Invoke(galleryPages); + configureViews?.Invoke(views); - services.AddSingleton(views.Build()); + var viewRegistry = views.Build(); + var requiredViewModels = new[] + { + typeof(TitlePageViewModel), typeof(GamePageViewModel), typeof(SettingsPageViewModel), + typeof(SaveSlotsPageViewModel), typeof(GalleryPageViewModel), typeof(CgGalleryPageViewModel), + typeof(CgGalleryImagePageViewModel), typeof(AudioGalleryPageViewModel), typeof(VideoGalleryPageViewModel), + typeof(VideoGalleryPlayerPageViewModel), typeof(MissingGalleryPageViewModel), typeof(AboutPageViewModel), + typeof(LoadingPageViewModel), typeof(ScreenshotDialogViewModel) + }; + var missing = new List(); + foreach (var viewModelType in requiredViewModels) + { + try { viewRegistry.GetViewType(viewModelType); } + catch (InvalidOperationException) { missing.Add(viewModelType.Name); } + } + + if (missing.Count > 0) + throw new InvalidOperationException($"The layout theme and host must register all required views. Missing: {string.Join(", ", missing)}."); + if (!typeof(ScreenshotDialog).IsAssignableFrom(viewRegistry.GetViewType(typeof(ScreenshotDialogViewModel)))) + throw new InvalidOperationException("ScreenshotDialogViewModel requires a ScreenshotDialog-derived view."); + var gamePageType = viewRegistry.GetViewType(typeof(GamePageViewModel)); + if (!typeof(GamePage).IsAssignableFrom(gamePageType)) + throw new InvalidOperationException("GamePageViewModel requires a GamePage-derived view. Customize its ControlTheme to replace the layout."); + + foreach (var viewType in views.ViewTypes) + { + if (typeof(ScreenshotDialog).IsAssignableFrom(viewType)) continue; + services.TryAddScoped(viewType); + } + services.AddSingleton(viewRegistry); services.AddSingleton(galleryPages.Build()); services.AddScoped(); services.AddScoped(provider => @@ -56,18 +82,10 @@ public static IServiceCollection AddAvaloniaGameViewPages( 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(); + + if (gamePageType == typeof(GamePage)) services.AddScoped(); + else services.AddScoped(provider => (GamePage)provider.GetRequiredService(gamePageType)); return services; } } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Composition/IGameLayoutTheme.cs b/src/Presentation/GalNet.Avalonia.GameView/Composition/IGameLayoutTheme.cs new file mode 100644 index 00000000..0c7a67c0 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.GameView/Composition/IGameLayoutTheme.cs @@ -0,0 +1,14 @@ +using Avalonia.Styling; +using GalNet.Avalonia.GameView.Navigation; + +namespace GalNet.Avalonia.GameView.Composition; + +/// A startup-time layout choice. Use the same instance for styles and page registration. +public interface IGameLayoutTheme +{ + /// Creates a fresh set of player styles, including the configured palette. + IStyle CreateStyles(); + + /// Registers all player page and screenshot dialog views before host overrides. + void RegisterViews(IPageViewRegistryBuilder views); +} diff --git a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj index a31c2a88..c1ab382c 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj +++ b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj @@ -21,11 +21,5 @@ - - GalleryPage.axaml - - - LoadingPage.axaml - diff --git a/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextBlock.cs b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextBlock.cs new file mode 100644 index 00000000..7f2872c0 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextBlock.cs @@ -0,0 +1,43 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace GalNet.Avalonia.GameView.MarkupExtensions; + +/// Resolves a changing localization key through the same host adapter as PlayerText. +public sealed class PlayerTextBlock : TextBlock +{ + public static readonly StyledProperty KeyProperty = + AvaloniaProperty.Register(nameof(Key)); + private IDisposable? _binding; + public string? Key { get => GetValue(KeyProperty); set => SetValue(KeyProperty, value); } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == KeyProperty) UpdateText(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + UpdateText(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _binding?.Dispose(); + _binding = null; + base.OnDetachedFromVisualTree(e); + } + + private void UpdateText() + { + _binding?.Dispose(); + _binding = null; + IsVisible = !string.IsNullOrWhiteSpace(Key); + Text = Key; + if (IsVisible && PlayerTextExtension.Resolve is { } resolve) + _binding = this.Bind(TextProperty, new ReflectionBinding("Value") { Source = resolve(Key!), Mode = BindingMode.OneWay }); + } +} diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs index 980a820f..ab592111 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs @@ -34,6 +34,14 @@ public sealed class GalleryPageRegistryBuilder(IPageViewRegistryBuilder pageView private readonly Dictionary _pages = new(StringComparer.Ordinal); private bool _built; + internal void Add(string galleryTypeId) + where TViewModel : PageViewModelBase, IGalleryPageViewModel + { + if (_built) throw new InvalidOperationException("Gallery page mappings are immutable after composition."); + _pages.Add(NormalizeTypeId(galleryTypeId), typeof(TViewModel)); + services.AddScoped(); + } + public void Add(string galleryTypeId) where TViewModel : PageViewModelBase, IGalleryPageViewModel where TView : Control => Register(galleryTypeId, replace: false); diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs index 01858a59..a4ac0bb4 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs @@ -226,11 +226,11 @@ public interface IPageViewRegistry Type GetViewType(Type viewModelType); } -/// Composition-time registry builder. Later registrations intentionally replace defaults. +/// Composition-time page and dialog registry. Later registrations replace theme mappings. public interface IPageViewRegistryBuilder { void Register() - where TViewModel : PageViewModelBase + where TViewModel : class where TView : Control; IPageViewRegistry Build(); @@ -241,8 +241,10 @@ public sealed class PageViewRegistryBuilder : IPageViewRegistryBuilder private readonly Dictionary _views = []; private bool _built; + internal IEnumerable ViewTypes => _views.Values.Distinct(); + public void Register() - where TViewModel : PageViewModelBase + where TViewModel : class where TView : Control { if (_built) throw new InvalidOperationException("Page view mappings are immutable after composition."); diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml deleted file mode 100644 index 2f0eb084..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml.cs deleted file mode 100644 index 5f078b58..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/CgGalleryPage.axaml.cs +++ /dev/null @@ -1,4 +0,0 @@ -using Avalonia.Controls; -namespace GalNet.Avalonia.GameView.Page; - -public partial class CgGalleryPage : UserControl { public CgGalleryPage() => InitializeComponent(); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml deleted file mode 100644 index e110fae5..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml.cs deleted file mode 100644 index 0d6b3567..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml.cs +++ /dev/null @@ -1,5 +0,0 @@ -using Avalonia.Controls; - -namespace GalNet.Avalonia.GameView.Page; - -public partial class GalleryPage : UserControl { public GalleryPage() => InitializeComponent(); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml deleted file mode 100644 index 24b5498d..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPage.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPage.axaml.cs deleted file mode 100644 index 8d0f2d97..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPage.axaml.cs +++ /dev/null @@ -1,4 +0,0 @@ -using Avalonia.Controls; -namespace GalNet.Avalonia.GameView.Page; - -public partial class VideoGalleryPage : UserControl { public VideoGalleryPage() => InitializeComponent(); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml deleted file mode 100644 index e4612a1a..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml +++ /dev/null @@ -1,9 +0,0 @@ - - + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicGalleryPage.axaml new file mode 100644 index 00000000..93d8c272 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicGalleryPage.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicLoadingPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicLoadingPage.axaml new file mode 100644 index 00000000..313a2b06 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicLoadingPage.axaml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicMissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicMissingGalleryPage.axaml new file mode 100644 index 00000000..76a77171 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicMissingGalleryPage.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicVideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicVideoGalleryPlayerPage.axaml new file mode 100644 index 00000000..bd58713d --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicVideoGalleryPlayerPage.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalGalleryPage.axaml new file mode 100644 index 00000000..f8e2093b --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalGalleryPage.axaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalLoadingPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalLoadingPage.axaml new file mode 100644 index 00000000..a5fd1e2e --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalLoadingPage.axaml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalMissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalMissingGalleryPage.axaml new file mode 100644 index 00000000..e96ffd4d --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalMissingGalleryPage.axaml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalVideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalVideoGalleryPlayerPage.axaml new file mode 100644 index 00000000..07e8383c --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalVideoGalleryPlayerPage.axaml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperGalleryPage.axaml new file mode 100644 index 00000000..bc1939c2 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperGalleryPage.axaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperLoadingPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperLoadingPage.axaml new file mode 100644 index 00000000..2eec9559 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperLoadingPage.axaml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperMissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperMissingGalleryPage.axaml new file mode 100644 index 00000000..691c0b0d --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperMissingGalleryPage.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperVideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperVideoGalleryPlayerPage.axaml new file mode 100644 index 00000000..215461c1 --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperVideoGalleryPlayerPage.axaml @@ -0,0 +1,26 @@ + + + + + + + +