From a50f75acea33d6ab3992b02f64c1a21a7f02ac09 Mon Sep 17 00:00:00 2001 From: sakuya Date: Sun, 11 Oct 2026 17:12:07 +0800 Subject: [PATCH 1/7] feat: add startup game layout themes and independent palettes --- .agents/agent-knowledge.md | 4 + .../L-20261011-01-headless-session-cleanup.md | 20 + Directory.Packages.props | 1 + GalNet.slnx | 1 + docs/adr/0003-startup-game-layout-themes.md | 23 ++ docs/design/game-ui-themes.md | 195 +++++++++ docs/development.md | 14 + docs/spec/control.md | 18 + .../F-20261011-01-game-ui-themes/design.md | 15 + .../F-20261011-01-game-ui-themes/feature.md | 30 ++ .../phase-plan.md | 19 + .../F-20261011-01-game-ui-themes/review.md | 9 + .../F-20261011-01-game-ui-themes/summary.md | 41 ++ .../Styles/Generic.axaml | 219 ++++++----- .../Styles/Resources.axaml | 34 ++ ...oniaGameViewServiceCollectionExtensions.cs | 21 +- .../Composition/IGameLayoutTheme.cs | 14 + .../Navigation/PageNavigation.cs | 2 + .../Page/AboutPage.axaml | 8 +- .../Page/AudioGalleryPage.axaml | 6 +- .../Page/CgGalleryImagePage.axaml | 2 +- .../Page/CgGalleryPage.axaml | 6 +- .../Page/GalleryPage.axaml | 4 +- .../Page/GamePage.axaml | 50 --- .../Page/GamePage.axaml.cs | 49 --- .../GalNet.Avalonia.GameView/Page/GamePage.cs | 107 +++++ .../Page/GameShell.axaml | 55 +-- .../Page/GameShell.axaml.cs | 9 +- .../Page/GameToolbar.axaml | 19 + .../Page/GameToolbar.axaml.cs | 8 + .../Page/LoadingPage.axaml | 4 +- .../Page/MissingGalleryPage.axaml | 2 +- .../Page/SaveSlotsPage.axaml | 8 +- .../Page/ScreenshotDialog.axaml | 12 +- .../Page/ScreenshotDialog.axaml.cs | 27 +- .../Page/SettingsPage.axaml | 8 +- .../Page/TitlePage.axaml | 6 +- .../Page/VideoGalleryPage.axaml | 6 +- .../Page/VideoGalleryPlayerPage.axaml | 2 +- .../Presentation/AvaloniaDialoguePresenter.cs | 8 +- .../Services/AvaloniaGameScreenshotService.cs | 11 +- .../Styles/Generic.axaml | 98 +++++ .../Base/TitleMenu.axaml | 15 + .../Base/TitleMenu.axaml.cs | 8 + .../GalNet.Avalonia.Themes/ClassicTheme.cs | 11 + .../Colors/Colors.axaml | 22 ++ .../GalNet.Avalonia.Themes.csproj | 10 + .../GalNet.Avalonia.Themes/GameLayoutTheme.cs | 83 ++++ .../GalNet.Avalonia.Themes/GamePalette.cs | 37 ++ .../Layouts/Classic/ClassicTitlePage.axaml | 23 ++ .../Layouts/Classic/ClassicTitlePage.axaml.cs | 8 + .../Layouts/Classic/Resources.axaml | 30 ++ .../Layouts/Minimal/MinimalTitlePage.axaml | 21 + .../Layouts/Minimal/MinimalTitlePage.axaml.cs | 8 + .../Layouts/Minimal/Resources.axaml | 48 +++ .../Layouts/Paper/PaperTitlePage.axaml | 35 ++ .../Layouts/Paper/PaperTitlePage.axaml.cs | 8 + .../Layouts/Paper/Resources.axaml | 69 ++++ .../GalNet.Avalonia.Themes/MinimalTheme.cs | 11 + .../Palettes/Mono.axaml | 45 +++ .../Palettes/Ocean.axaml | 45 +++ .../Palettes/Rose.axaml | 45 +++ .../GalNet.Avalonia.Themes/PaperTheme.cs | 11 + .../GalNet.Avalonia.Themes/README.md | 97 +++++ .../GalNet.Avalonia.Themes/packages.lock.json | 213 ++++++++++ src/Samples/GalNet.Sample.Avalonia/App.axaml | 2 - .../GalNet.Sample.Avalonia/App.axaml.cs | 5 +- .../GalNet.Sample.Avalonia.csproj | 4 + .../Views/LanguageSelectionView.axaml | 8 +- .../Views/MainWindow.axaml | 4 +- .../Views/MainWindow.axaml.cs | 1 + .../GalNet.Sample.Avalonia/packages.lock.json | 8 + .../ProjectDependencyTests.cs | 2 + .../packages.lock.json | 9 + .../GalNet.Presentation.Tests.csproj | 2 + .../GameThemeTests.cs | 370 ++++++++++++++++++ .../packages.lock.json | 48 +++ 77 files changed, 2246 insertions(+), 315 deletions(-) create mode 100644 .agents/lessons/L-20261011-01-headless-session-cleanup.md create mode 100644 docs/adr/0003-startup-game-layout-themes.md create mode 100644 docs/design/game-ui-themes.md create mode 100644 features/F-20261011-01-game-ui-themes/design.md create mode 100644 features/F-20261011-01-game-ui-themes/feature.md create mode 100644 features/F-20261011-01-game-ui-themes/phase-plan.md create mode 100644 features/F-20261011-01-game-ui-themes/review.md create mode 100644 features/F-20261011-01-game-ui-themes/summary.md create mode 100644 src/Presentation/GalNet.Avalonia.Controls/Styles/Resources.axaml create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Composition/IGameLayoutTheme.cs delete mode 100644 src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml delete mode 100644 src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.cs create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Page/GameToolbar.axaml create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Page/GameToolbar.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Styles/Generic.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Base/TitleMenu.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Base/TitleMenu.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/ClassicTheme.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Colors/Colors.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/GalNet.Avalonia.Themes.csproj create mode 100644 src/Presentation/GalNet.Avalonia.Themes/GameLayoutTheme.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/GamePalette.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicTitlePage.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/ClassicTitlePage.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Classic/Resources.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalTitlePage.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/MinimalTitlePage.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Minimal/Resources.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperTitlePage.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/PaperTitlePage.axaml.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Layouts/Paper/Resources.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/MinimalTheme.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Palettes/Mono.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Palettes/Ocean.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/Palettes/Rose.axaml create mode 100644 src/Presentation/GalNet.Avalonia.Themes/PaperTheme.cs create mode 100644 src/Presentation/GalNet.Avalonia.Themes/README.md create mode 100644 src/Presentation/GalNet.Avalonia.Themes/packages.lock.json create mode 100644 test/GalNet.Presentation.Tests/GameThemeTests.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 903e06ee..0b036f41 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -34,6 +34,8 @@ ## 当前工作 +- [F-20261011-01-game-ui-themes](../features/F-20261011-01-game-ui-themes/summary.md) 已完成启动期主题组合:可选 Themes 提供三布局/三配色,页面顺序默认→主题→宿主;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 +83,5 @@ - [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。 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/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/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..8f2ce0bb --- /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;内置布局覆盖标题,其余页面共享资源化的基础 View。GamePage 成为稳定 TemplatedControl,以 Scene/Dialogue/Nvl/Choice 四个内容属性和 PART_* ContentPresenter 安排布局。 +- 注册顺序为默认→主题→宿主 Gallery 回调→宿主普通回调,最终 View 自动补充 Scoped 注册。游戏页自定义类型须继承 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..549a342d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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()); +``` + +主题注册先于宿主覆盖;仅替换一个页面不需要另写主题。GameShell 自动加载对应玩家样式,其他玩家根用同一主题的 CreateStyles 加载。普通页保持 View + 共享 ViewModel;GamePage 通过 ControlTheme 安排稳定场景/对白/选项内容。当前配置在运行期固定。 + +三套布局、三套配色和模板扩展示例见 [Themes README](../src/Presentation/GalNet.Avalonia.Themes/README.md),契约见 [游戏页面规范](spec/control.md)。Sample 的 App.PlayerTheme 是实际启动配置入口。 + ## 创建一个自定义原语 ### 1. 选定稳定 type ID 与参数 diff --git a/docs/spec/control.md b/docs/spec/control.md index 40741e2c..b8aead1a 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -34,6 +34,24 @@ 宿主可以在 `AddAvaloniaGameViewPages()` 的注册回调中覆盖默认 View,或者追加自定义页面。注册表构建后不可修改。 +### 布局主题与独立配色 + +`IGameLayoutTheme` 位于 GameView.Composition,提供 `CreateStyles()` 和 `RegisterViews(IPageViewRegistryBuilder)`。`AddAvaloniaGameViewPages(theme: theme)` 按默认页面、主题覆盖、宿主 Gallery 回调、宿主普通 View 回调顺序注册;最终 View 自动补充 Scoped DI 注册。共享 ViewModel 与业务服务继续由 GameView/宿主注册。 + +可选 `GalNet.Avalonia.Themes` 集合提供 Classic、Minimal、Paper 布局和 Ocean、Rose、Mono 配色,布局与配色任意组合。配置由开发者在启动代码决定,运行期固定。GameShell 获取 DI 中的主题,并把资源与样式加载到游戏根;无主题时加载 GameView 的中性 Generic。独立语言选择与截图弹窗接入同一主题资源。官方布局使用玩家根内的 Fluent 模板和所选配色,宿主 Semi、编辑器、调试 UI 与系统文件选择器保持各自作用域。 + +资源分为 `GalNet.Color.*` 基础色、`GalNet.Palette.*Color` 语义色、`GalNet.Brush.*` 语义画刷和 `GalNet.Theme.*` 布局尺寸。Controls 提供中性画刷回退;Palette 提供完整配色;内置布局不重定义公共语义色,宿主可通过玩家根 Resources 覆盖。GamePalette 检查 21 个必需画刷;官方 Fluent 桥接需对应 Color 资源或可解析的纯色画刷。 + +普通页保持 UserControl + 共享 ViewModel,主题按需注册新 View,其余页面复用语义化的默认页面。替换单页无需实现完整主题。配置和扩展示例见 [Themes README](../../src/Presentation/GalNet.Avalonia.Themes/README.md)。 + +### 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 的推进逻辑。 + ### Gallery 页面 Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映射到页面 ViewModel 和 View。`Add` 注册新类型,`Replace` 显式覆盖既有类型;多个 type ID 可以映射到同一页面。该注册同时写入普通 ViewModel→View registry 和 DI,构建后不可修改。 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/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..137b237b --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.Controls/Styles/Resources.axaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + 4 + 24 + 200 + 20 + 18 + 20,14 + 0,5 + 14 + 150 + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs index bd9449a1..6e416f38 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using GalNet.Avalonia.GameView.Services; using GalNet.Avalonia.GameView.ViewModels; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace GalNet.Avalonia.GameView.Composition; @@ -12,9 +13,11 @@ public static class AvaloniaGameViewServiceCollectionExtensions public static IServiceCollection AddAvaloniaGameViewPages( this IServiceCollection services, Action? configureViews = null, - Action? configureGalleryPages = null) + Action? configureGalleryPages = null, + IGameLayoutTheme? theme = null) { var views = new PageViewRegistryBuilder(); + if (theme is not null) services.AddSingleton(theme); views.Register(); views.Register(); views.Register(); @@ -25,15 +28,22 @@ public static IServiceCollection AddAvaloniaGameViewPages( views.Register(); views.Register(); views.Register(); - configureViews?.Invoke(views); - var galleryPages = new GalleryPageRegistryBuilder(views, services); galleryPages.Add("cg"); galleryPages.Add("video"); galleryPages.Add("audio"); + theme?.RegisterViews(views); configureGalleryPages?.Invoke(galleryPages); + configureViews?.Invoke(views); + + foreach (var viewType in views.ViewTypes) + services.TryAddScoped(viewType); - services.AddSingleton(views.Build()); + var viewRegistry = views.Build(); + 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."); + services.AddSingleton(viewRegistry); services.AddSingleton(galleryPages.Build()); services.AddScoped(); services.AddScoped(provider => @@ -59,7 +69,8 @@ public static IServiceCollection AddAvaloniaGameViewPages( services.AddScoped(); services.AddScoped(); - services.AddScoped(); + if (gamePageType == typeof(GamePage)) services.AddScoped(); + else services.AddScoped(provider => (GamePage)provider.GetRequiredService(gamePageType)); services.AddScoped(); services.AddScoped(); services.AddScoped(); 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..3c7f0c04 --- /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(); + + /// Overrides default page views before the host applies its own overrides. + void RegisterViews(IPageViewRegistryBuilder views); +} diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs index 01858a59..ca3b9a98 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs @@ -241,6 +241,8 @@ 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 TView : Control diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml index 2f0eb084..93ec3e62 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml @@ -5,12 +5,12 @@ xmlns:vm="using:GalNet.Avalonia.GameView.ViewModels" x:Class="GalNet.Avalonia.GameView.Page.AboutPage" x:DataType="vm:AboutPageViewModel"> - - + + - + + + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml index e110fae5..8a66497a 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml @@ -6,10 +6,10 @@ xmlns:gallery="using:GalNet.Core.Gallery" x:Class="GalNet.Avalonia.GameView.Page.GalleryPage" x:DataType="vm:GalleryPageViewModel"> - + - + + + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml index e4612a1a..720a8fcb 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml @@ -5,5 +5,5 @@ xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia" x:Class="GalNet.Avalonia.GameView.Page.VideoGalleryPlayerPage" x:DataType="vm:VideoGalleryPlayerPageViewModel"> - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml index 8a66497a..51746fcc 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml @@ -1,37 +1,36 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml index 3593d7f5..c1734a61 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml @@ -1,8 +1,17 @@ - - + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml index 720a8fcb..82f2a459 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml @@ -1,9 +1,20 @@ - - + + + + + + + + \ 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 @@ + + + + + + + + - - - - - - - - \ No newline at end of file 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 51746fcc..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file 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.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.cs index 0bf42b83..71454777 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GamePage.cs @@ -60,7 +60,7 @@ public void EnsureLayout() { ApplyTemplate(); if (Template is null) - throw new InvalidOperationException("GamePage requires a layout theme. Include GalNet.Avalonia.GameView/Styles/Generic.axaml or a game layout theme."); + throw new InvalidOperationException("GamePage requires a layout theme. Apply the selected game layout theme to the player root."); } protected override void OnApplyTemplate(TemplateAppliedEventArgs e) diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml index 630587f7..5eaff0c8 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml @@ -1,14 +1,11 @@ - + Classes="galnet-player"> + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs index ffa3993a..be994717 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs @@ -2,7 +2,6 @@ using Avalonia.Animation; using Avalonia.Controls; using Avalonia.Controls.Presenters; -using Avalonia.Markup.Xaml.Styling; using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; @@ -38,7 +37,7 @@ public GameShell( IGameSessionService session, IGameNavigationService navigation, GameNavigationTransitionCoordinator transitions, - IGameLayoutTheme? theme = null) + IGameLayoutTheme theme) { _viewModel = viewModel; _views = views; @@ -47,10 +46,7 @@ public GameShell( _navigation = navigation; _transitions = transitions; InitializeComponent(); - Styles.Add(theme?.CreateStyles() ?? new StyleInclude(new Uri("avares://GalNet.Avalonia.GameView/")) - { - Source = new Uri("avares://GalNet.Avalonia.GameView/Styles/Generic.axaml") - }); + Styles.Add(theme.CreateStyles()); DataContext = viewModel; session.PropertyChanged += OnSessionPropertyChanged; _transitions.Attach(ShowPageAsync); diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml deleted file mode 100644 index eab597ff..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml.cs deleted file mode 100644 index 3a70f774..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/LoadingPage.axaml.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Avalonia.Controls; - -namespace GalNet.Avalonia.GameView.Page; - -public partial class LoadingPage : UserControl -{ - public LoadingPage() => InitializeComponent(); -} diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml deleted file mode 100644 index c1734a61..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/MissingGalleryPage.axaml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file 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 82f2a459..00000000 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - -