From 266fbbd164ab42b4ab15e4b4f15a2b6ad4ece72e Mon Sep 17 00:00:00 2001 From: sakuya Date: Sat, 10 Oct 2026 12:53:46 +0800 Subject: [PATCH 01/10] fix: reduce low-risk build warnings --- .../F-20261010-01-warning-cleanup/design.md | 3 +++ .../F-20261010-01-warning-cleanup/feature.md | 16 +++++++++++++ .../phase-plan.md | 11 +++++++++ .../F-20261010-01-warning-cleanup/summary.md | 23 +++++++++++++++++++ .../Project/GalProject.cs | 3 +-- .../Commands/AssetFileCommandExecutor.cs | 3 ++- .../Commands/EditorProjectCreator.cs | 4 +++- .../Services/EditorPlayerVariableStore.cs | 3 ++- .../Services/EditorSettingsService.cs | 3 ++- src/Shared/GalNet.Assets/CompressionHelper.cs | 2 +- .../GalNet.Core/Variable/VariableValue.cs | 6 ++--- .../View/CompositeGameView.cs | 6 +---- .../GalNet.Runtime/Engine/GameEngine.cs | 6 +---- .../Entry/TargetProfileEntryCatalogTests.cs | 2 +- .../Scene/ShaderEffectMetadataTests.cs | 3 ++- .../Text/TypewriterTextParserTests.cs | 17 ++++++++------ .../Variable/VariableTests.cs | 4 +++- .../EditorDocumentRepositoryTests.cs | 15 ++++++++---- .../Audio/AudioRuntimeTests.cs | 6 ++++- .../Runtime/GameRuntimeSnapshotTests.cs | 4 +++- 20 files changed, 103 insertions(+), 37 deletions(-) create mode 100644 features/F-20261010-01-warning-cleanup/design.md create mode 100644 features/F-20261010-01-warning-cleanup/feature.md create mode 100644 features/F-20261010-01-warning-cleanup/phase-plan.md create mode 100644 features/F-20261010-01-warning-cleanup/summary.md diff --git a/features/F-20261010-01-warning-cleanup/design.md b/features/F-20261010-01-warning-cleanup/design.md new file mode 100644 index 0000000..5e014c6 --- /dev/null +++ b/features/F-20261010-01-warning-cleanup/design.md @@ -0,0 +1,3 @@ +# Design + +Use behavior-preserving substitutions only: cache immutable JSON serialization options and test expectations, use invariant numeric formatting for variable strings, select the memory-based stream write overload, and use the standard disposed-object helper. Do not narrow public return types or rename domain APIs solely to satisfy analyzers. diff --git a/features/F-20261010-01-warning-cleanup/feature.md b/features/F-20261010-01-warning-cleanup/feature.md new file mode 100644 index 0000000..dfe8a68 --- /dev/null +++ b/features/F-20261010-01-warning-cleanup/feature.md @@ -0,0 +1,16 @@ +# F-20261010-01 Warning cleanup + +## Goal + +Reduce mechanically fixable build warnings without suppressing diagnostics or changing public contracts. + +## Scope + +- Remove allocation and formatting analyzer findings in directly affected production and test code. +- Keep domain naming, public API shape, and larger ownership/lifetime findings out of scope. + +## Acceptance criteria + +- Updated production projects compile successfully. +- Core, Runtime, Assets, and Editor.Shared targeted tests pass. +- No warning suppression is introduced. diff --git a/features/F-20261010-01-warning-cleanup/phase-plan.md b/features/F-20261010-01-warning-cleanup/phase-plan.md new file mode 100644 index 0000000..4b413bb --- /dev/null +++ b/features/F-20261010-01-warning-cleanup/phase-plan.md @@ -0,0 +1,11 @@ +# Phase plan + +## Phase 1 — Low-risk analyzer cleanup + +- Status: complete +- Exit criteria: affected code and tests compile; targeted tests pass. + +## Phase 2 — Verify + +- Status: complete +- Evidence: `dotnet test` passed for Core (57), Runtime (35), and Editor.Shared (16); Assets (32) passed before the final test-only cleanup. diff --git a/features/F-20261010-01-warning-cleanup/summary.md b/features/F-20261010-01-warning-cleanup/summary.md new file mode 100644 index 0000000..9fd97a2 --- /dev/null +++ b/features/F-20261010-01-warning-cleanup/summary.md @@ -0,0 +1,23 @@ +# Summary + +Implemented behavior-preserving warning cleanup across Core, Assets, Runtime, Presentation abstractions, Editor, and their tests. + +## Implemented + +- Cached indented `JsonSerializerOptions` instances instead of allocating them for each write. +- Used invariant integer formatting for `VariableValue.Int` string conversion. +- Used the memory-based `Stream.WriteAsync` overload. +- Replaced handwritten disposed checks with `ObjectDisposedException.ThrowIf`. +- Reused static test expectations and corrected a test host's intentionally inert interface event implementation. + +## Validation + +- Core tests: 57 passed. +- Runtime tests: 35 passed. +- Editor.Shared tests: 16 passed. +- Assets tests: 32 passed. +- `git diff --check` passed. + +## Known limitations + +Avalonia-dependent test projects cannot be built in this restricted environment because Avalonia BuildServices is denied access to its user-local licensing ticket directory. Remaining analyzer warnings involving public naming, API shapes, ownership/lifetime, and broad type changes were intentionally retained for separate review. diff --git a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs index e12cb4f..c4fb7d4 100644 --- a/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs +++ b/src/Editor/GalNet.Editor.Abstraction/Project/GalProject.cs @@ -85,8 +85,7 @@ public GalProject(string id, string name, string rootPath, ProjectSettings setti public IDisposable RegisterClosingCallback(Func callback) { ArgumentNullException.ThrowIfNull(callback); - if (_disposed) - throw new ObjectDisposedException(nameof(GalProject)); + ObjectDisposedException.ThrowIf(_disposed, this); _closingCallbacks.Add(callback); return new CallbackRegistration(_closingCallbacks, callback); diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs index a11bc4d..d1d4ba9 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; using GalNet.Core.Entry; using GalNet.Editor.Abstraction.Commands; @@ -70,7 +71,7 @@ private static async Task ImportAsync( for (var index = 0; index < sources.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); - var staged = Path.Combine(staging, index.ToString("D4") + Path.GetExtension(sources[index])); + var staged = Path.Combine(staging, index.ToString("D4", CultureInfo.InvariantCulture) + Path.GetExtension(sources[index])); File.Copy(sources[index], staged, false); File.Move(staged, destinations[index]); moved.Add(destinations[index]); diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs index f57fa54..dff234f 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorProjectCreator.cs @@ -8,6 +8,8 @@ namespace GalNet.Editor.Shared.Commands; public static class EditorProjectCreator { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + public static async Task CreateAsync( string projectPath, IEntryCatalog catalog, @@ -62,7 +64,7 @@ public static async Task CreateAsync( await new DirectProjectPersistence(projectPath, repository).SaveAsync(document, cancellationToken); await File.WriteAllTextAsync( Path.Combine(projectPath, ".galnet", "editor-state.json"), - JsonSerializer.Serialize(new GalNet.Editor.Abstraction.Project.EditorProjectState(), new JsonSerializerOptions { WriteIndented = true }), + JsonSerializer.Serialize(new GalNet.Editor.Abstraction.Project.EditorProjectState(), IndentedJsonOptions), cancellationToken); } } diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs index 0f50c92..cdfac92 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs @@ -13,6 +13,7 @@ namespace GalNet.Editor.Shared.Services; public sealed class EditorPlayerVariableStore : IEditorPlayerVariableStore { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; private readonly IProjectService _projectService; private readonly IVariableDefinitionService _variableDefinitions; private readonly Dictionary _variables = new(StringComparer.Ordinal); @@ -158,7 +159,7 @@ private void Save() try { Directory.CreateDirectory(Path.GetDirectoryName(GetStoragePath(project))!); - var json = JsonSerializer.Serialize(_variables, new JsonSerializerOptions { WriteIndented = true }); + var json = JsonSerializer.Serialize(_variables, IndentedJsonOptions); File.WriteAllText(GetStoragePath(project), json); } catch (Exception ex) diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs index 09c28eb..105de26 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorSettingsService.cs @@ -12,6 +12,7 @@ namespace GalNet.Editor.Shared.Services; /// public sealed class EditorSettingsService : IEditorSettingsService { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; private readonly string _settingsPath; private EditorSettings? _settings; @@ -60,7 +61,7 @@ private void Save(EditorSettings settings) if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); - var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); + var json = JsonSerializer.Serialize(settings, IndentedJsonOptions); File.WriteAllText(_settingsPath, json); } catch (Exception ex) diff --git a/src/Shared/GalNet.Assets/CompressionHelper.cs b/src/Shared/GalNet.Assets/CompressionHelper.cs index ad7330d..53e66e4 100644 --- a/src/Shared/GalNet.Assets/CompressionHelper.cs +++ b/src/Shared/GalNet.Assets/CompressionHelper.cs @@ -33,7 +33,7 @@ public static async Task CompressAsync(byte[] data, Core.Assets.Compress using var output = new MemoryStream(); using (var compressor = CreateCompressor(output, mode)) { - await compressor.WriteAsync(data, 0, data.Length, ct); + await compressor.WriteAsync(data, ct); } return output.ToArray(); } diff --git a/src/Shared/GalNet.Core/Variable/VariableValue.cs b/src/Shared/GalNet.Core/Variable/VariableValue.cs index ec42e2d..47ee1af 100644 --- a/src/Shared/GalNet.Core/Variable/VariableValue.cs +++ b/src/Shared/GalNet.Core/Variable/VariableValue.cs @@ -32,9 +32,9 @@ public sealed class Int : VariableValue public override bool AsBool() => Value != 0; public override int AsInt() => Value; public override float AsFloat() => Value; - public override string AsString() => Value.ToString(); - public override string Serialize() => Value.ToString(); - public override string ToString() => Value.ToString(); + public override string AsString() => Value.ToString(CultureInfo.InvariantCulture); + public override string Serialize() => Value.ToString(CultureInfo.InvariantCulture); + public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); } public sealed class Float : VariableValue diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs b/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs index e153ee9..522e153 100644 --- a/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs +++ b/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs @@ -68,11 +68,7 @@ public void Dispose() throw new AggregateException(exceptions); } - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(nameof(CompositeGameView)); - } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); private static (IReadOnlyList Modules, IReadOnlyDictionary Entries) FreezeModules(IEnumerable modules) diff --git a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs index 31493ad..1940d96 100644 --- a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs +++ b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs @@ -485,11 +485,7 @@ private void ThrowFlowError() throw new InvalidOperationException("The game flow failed.", exception); } - private void ThrowIfDisposed() - { - if (_disposed) - throw new ObjectDisposedException(nameof(GameEngine)); - } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); private sealed record ActivePrimitive( long GroupExecutionId, diff --git a/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs b/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs index 20dd40f..7b2a455 100644 --- a/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs +++ b/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs @@ -83,7 +83,7 @@ public void CompilerRejectsMissingRequiredPrimitiveParameter() Throws.TypeOf().With.Message.Contains("requires parameter 'count'")); } - private static PrimitiveEntryBase Primitive(string type, DynamicParameterTable parameters) => + private static DefaultPrimitiveEntryBase Primitive(string type, DynamicParameterTable parameters) => new DefaultPrimitiveEntryBase(type, parameters, static _ => new ImmediatePrimitiveInstance()); private sealed class TestEntryModule(string id, IEnumerable entries) diff --git a/test/GalNet.Core.Tests/Scene/ShaderEffectMetadataTests.cs b/test/GalNet.Core.Tests/Scene/ShaderEffectMetadataTests.cs index cf45c6a..d267ce0 100644 --- a/test/GalNet.Core.Tests/Scene/ShaderEffectMetadataTests.cs +++ b/test/GalNet.Core.Tests/Scene/ShaderEffectMetadataTests.cs @@ -6,6 +6,7 @@ namespace GeneralTest.Scene; public sealed class ShaderEffectMetadataTests { private static readonly EffectProgramResource Program = new("effects/dissolve"); + private static readonly string[] ExpectedResourceDependencies = ["effects/dissolve", "textures/cloud-noise"]; [Test] public void Parser_reads_platform_neutral_parameter_schema() @@ -150,7 +151,7 @@ @texture mask { Assert.That(attachment.RuntimeHandleId, Is.EqualTo("runtime:effect:1")); Assert.That(attachment.Stage, Is.EqualTo(EffectStage.Layer)); - Assert.That(attachment.EnumerateResourceDependencies(descriptor).Select(resource => resource.Value), Is.EqualTo(new[] { "effects/dissolve", "textures/cloud-noise" })); + Assert.That(attachment.EnumerateResourceDependencies(descriptor).Select(resource => resource.Value), Is.EqualTo(ExpectedResourceDependencies)); }); } } diff --git a/test/GalNet.Core.Tests/Text/TypewriterTextParserTests.cs b/test/GalNet.Core.Tests/Text/TypewriterTextParserTests.cs index e23f3d9..99e825f 100644 --- a/test/GalNet.Core.Tests/Text/TypewriterTextParserTests.cs +++ b/test/GalNet.Core.Tests/Text/TypewriterTextParserTests.cs @@ -4,6 +4,14 @@ namespace GeneralTest.Runtime; public sealed class TypewriterTextParserTests { + private static readonly RichTypewriterTokenKind[] ExpectedRichTokenKinds = + [ + RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, + RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Delay, + RichTypewriterTokenKind.Text, RichTypewriterTokenKind.LineBreak, RichTypewriterTokenKind.Text + ]; + private static readonly string[] ExpectedPlainText = ["A", "B\\skip\\skipper"]; + [Test] public void Parse_Recognizes_Delay_Instant_And_Newline() { @@ -25,12 +33,7 @@ public void Rich_parse_preserves_styles_and_typewriter_directives() Assert.Multiple(() => { - Assert.That(tokens.Select(token => token.Kind), Is.EqualTo(new[] - { - RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, - RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Text, RichTypewriterTokenKind.Delay, - RichTypewriterTokenKind.Text, RichTypewriterTokenKind.LineBreak, RichTypewriterTokenKind.Text - })); + Assert.That(tokens.Select(token => token.Kind), Is.EqualTo(ExpectedRichTokenKinds)); Assert.That(tokens[0], Is.EqualTo(new RichTypewriterToken(RichTypewriterTokenKind.Text, "Bold", IsBold: true))); Assert.That(tokens[2], Is.EqualTo(new RichTypewriterToken(RichTypewriterTokenKind.Text, "italic", IsItalic: true))); Assert.That(tokens[5].DelayMilliseconds, Is.EqualTo(120)); @@ -50,7 +53,7 @@ public void Skip_boundaries_and_backslash_escaping_are_shared_by_both_parsers() { Assert.That(plain.Count(token => token.Kind == TypewriterTokenKind.SkipBoundary), Is.EqualTo(1)); Assert.That(plain.Where(token => token.Kind == TypewriterTokenKind.Text).Select(token => token.Text), - Is.EqualTo(new[] { "A", "B\\skip\\skipper" })); + Is.EqualTo(ExpectedPlainText)); Assert.That(rich.Count(token => token.Kind == RichTypewriterTokenKind.SkipBoundary), Is.EqualTo(1)); Assert.That(string.Concat(rich.Where(token => token.Kind == RichTypewriterTokenKind.Text).Select(token => token.Text)), Is.EqualTo("AB\\skip\\skipper")); diff --git a/test/GalNet.Core.Tests/Variable/VariableTests.cs b/test/GalNet.Core.Tests/Variable/VariableTests.cs index 00b5d5b..1bf0147 100644 --- a/test/GalNet.Core.Tests/Variable/VariableTests.cs +++ b/test/GalNet.Core.Tests/Variable/VariableTests.cs @@ -61,12 +61,14 @@ public void Variable_Default_Type_Should_Be_String() public class VariableRouteTests { + private static readonly string[] PlayerAffectionAlice = ["player", "affection", "alice"]; + [Test] public void Route_Should_Parse_Segments() { var route = new VariableRoute("player.affection.alice"); Assert.That(route.Path, Is.EqualTo("player.affection.alice")); - Assert.That(route.Segments, Is.EquivalentTo(new[] { "player", "affection", "alice" })); + Assert.That(route.Segments, Is.EquivalentTo(PlayerAffectionAlice)); } [Test] diff --git a/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs b/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs index edd0356..d6a6bd3 100644 --- a/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs +++ b/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs @@ -15,6 +15,11 @@ namespace GeneralTest.Editor; [TestFixture] public class EditorDocumentRepositoryTests { + private static readonly JsonSerializerOptions IndentedJsonOptions = new() { WriteIndented = true }; + private static readonly string[] ExpectedPlayerFlag = ["player_flag"]; + private static readonly string[] ExpectedSaveCount = ["save_count"]; + private static readonly string[] ExpectedPlayerName = ["player_name"]; + private static readonly string[] ExpectedSaveSlot = ["save_slot"]; private string _tempDir = null!; [SetUp] @@ -50,7 +55,7 @@ public void Load_MigratesVariablesFromProjectSettings_WhenGraphDocumentDoesNotCo File.WriteAllText( Path.Combine(_tempDir, "Graph", "graph.json"), - JsonSerializer.Serialize(document, new JsonSerializerOptions { WriteIndented = true })); + JsonSerializer.Serialize(document, IndentedJsonOptions)); File.WriteAllText( Path.Combine(_tempDir, "Graph", "groups", $"{groupId}.rawgalgroup"), """{ "version": 2, "kind": "Raw", "entries": [ { "id": "entry-1", "type": "dialogue.text", "parameters": { "content": "hello", "obsolete": "discard" } } ] }"""); @@ -70,8 +75,8 @@ public void Load_MigratesVariablesFromProjectSettings_WhenGraphDocumentDoesNotCo var repository = new EditorDocumentRepository(BuiltinEntryModules.CreateRecommendedTargetProfile()); var loaded = repository.Load(_tempDir, "Demo", settings); - Assert.That(loaded.Document.PlayerVariables.Select(v => v.Name), Is.EqualTo(new[] { "player_flag" })); - Assert.That(loaded.Document.SaveVariables.Select(v => v.Name), Is.EqualTo(new[] { "save_count" })); + Assert.That(loaded.Document.PlayerVariables.Select(v => v.Name), Is.EqualTo(ExpectedPlayerFlag)); + Assert.That(loaded.Document.SaveVariables.Select(v => v.Name), Is.EqualTo(ExpectedSaveCount)); Assert.That(loaded.GroupEntries[groupId], Has.Count.EqualTo(1)); Assert.That(loaded.GroupEntries[groupId][0].Type, Is.EqualTo("dialogue.text")); Assert.That(loaded.GroupEntries[groupId][0].Parameters, Does.ContainKey("content").WithValue("hello")); @@ -117,8 +122,8 @@ public void Save_WritesVariablesAndGroupEntriesIntoProjectFiles() var savedGroup = File.ReadAllText(Path.Combine(_tempDir, "Graph", "groups", "group_1.rawgalgroup")); Assert.That(savedDocument, Is.Not.Null); - Assert.That(savedDocument!.PlayerVariables.Select(v => v.Name), Is.EqualTo(new[] { "player_name" })); - Assert.That(savedDocument.SaveVariables.Select(v => v.Name), Is.EqualTo(new[] { "save_slot" })); + Assert.That(savedDocument!.PlayerVariables.Select(v => v.Name), Is.EqualTo(ExpectedPlayerName)); + Assert.That(savedDocument.SaveVariables.Select(v => v.Name), Is.EqualTo(ExpectedSaveSlot)); var reloaded = repository.Load(_tempDir, "Demo", new ProjectSettings()); var savedGroupDocument = JsonDocument.Parse(savedGroup); Assert.That(savedGroupDocument.RootElement.GetProperty("kind").GetString(), Is.EqualTo("Raw")); diff --git a/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs b/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs index cd289b9..b11e8a4 100644 --- a/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs +++ b/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs @@ -66,7 +66,11 @@ private sealed class RecordingHost : IAudioPlaybackHost new("seffect.gain", new HashSet { SEffectScope.Track }, [new("gain", Required: true, Minimum: 0, Maximum: 1)]) ]); public List Requests { get; } = []; - public event Action? EventReceived; + public event Action? EventReceived + { + add { } + remove { } + } public Task ApplyAsync(AudioBackendRequest request, CancellationToken cancellationToken = default) { lock (Requests) Requests.Add(request); diff --git a/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs b/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs index 5890faf..8b490fb 100644 --- a/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs +++ b/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs @@ -5,6 +5,8 @@ namespace GeneralTest.Runtime; public class GameRuntimeSnapshotTests { + private static readonly string[] ExpectedEffectInstanceIds = ["effect"]; + [Test] public void CreateSnapshotDoesNotShareMutableSceneState() { @@ -16,7 +18,7 @@ public void CreateSnapshotDoesNotShareMutableSceneState() runtime.SceneState.Layers[0].EffectInstanceIds.Add("another"); Assert.That(snapshot.SceneState.Layers.Single().AssetId, Is.EqualTo("before")); - Assert.That(snapshot.SceneState.Layers.Single().EffectInstanceIds, Is.EqualTo(new[] { "effect" })); + Assert.That(snapshot.SceneState.Layers.Single().EffectInstanceIds, Is.EqualTo(ExpectedEffectInstanceIds)); } [Test] From b950c9f5536dad5e93ebf0551ddeadb26609a894 Mon Sep 17 00:00:00 2001 From: sakuya Date: Sat, 10 Oct 2026 12:54:17 +0800 Subject: [PATCH 02/10] opt --- ...0001-runtime-storage-contract-ownership.md | 59 ------------------- .../GalNet.Sample.Avalonia/App.axaml.cs | 13 ++-- 2 files changed, 8 insertions(+), 64 deletions(-) delete mode 100644 docs/adr/0001-runtime-storage-contract-ownership.md diff --git a/docs/adr/0001-runtime-storage-contract-ownership.md b/docs/adr/0001-runtime-storage-contract-ownership.md deleted file mode 100644 index e8d1038..0000000 --- a/docs/adr/0001-runtime-storage-contract-ownership.md +++ /dev/null @@ -1,59 +0,0 @@ -# ADR-0001:运行时与存储契约归属 - -- 状态:Accepted -- 日期:2026-09-29 -- 决策者:项目维护者 -- 关联 feature:`F-20260929-05-contract-ownership` - -## 上下文 - -`GalNet.Storage.Abstractions` 同时定义资源访问、内容加载、存档、变量和 Gallery 组合类型。它只引用 Core,但公开类型位于 `GalNet.Core.Assets`、`GalNet.Core.Services` 和 `GalNet.Core.Gallery` 命名空间;Runtime、Assets、Storage.FileSystem、Rendering 与 Editor 均直接引用它。 - -该结构造成三个问题:程序集名称不能说明职责;Runtime 的真实依赖与架构文档不一致;新增契约容易继续堆入一个横向的 Abstractions 项目。 - -## 候选方案 - -### A. 保留 Storage.Abstractions - -迁移最少,但资源、运行时端口和 Gallery 继续共处,职责与命名错位不会消失。 - -### B. 全部并入 Core - -依赖图最简单,但会让 Core 持有存档、宿主和运行时编排协议,持续扩大通用协议仓库。 - -### C. 契约由策略消费者拥有,并删除 Storage.Abstractions - -资源契约归 Core.Assets;内容、存档和变量端口归 Runtime;具体文件系统、PAK 和资源管理实现留在外层项目。Gallery 的领域接口与 DTO 留在 Core,依赖运行时变量端口的组合实现进入 Runtime。 - -## 决定 - -采用方案 C。用户于 2026-09-29 明确接受该方案。 - -- `IAssetProvider`、`IArchive`、`IGameFile`、`IAssetManager`、`IAssetDecoder`、`AssetHandle` 移入 `GalNet.Core`,保持 `GalNet.Core.Assets` 命名空间。 -- `IGameContentProvider` 与 `GameContent` 移入 `GalNet.Runtime.Content`。 -- `ISaveService`、`SaveRequest`、`SaveSlotInfo`、`IPlayerVariableStore` 移入 `GalNet.Runtime.Persistence`。 -- `IVariableService` 移入 `GalNet.Runtime.Variables`。 -- `GalleryDataSource` 移入 `GalNet.Runtime.Gallery`;`IGalleryDataSource`、Gallery catalog 与 DTO 保持在 Core。 -- 不创建 `GalNet.Runtime.Abstractions`;Runtime 自己拥有其策略端口。 -- 完成仓库内消费者迁移后删除 `GalNet.Storage.Abstractions`。 - -## 后果 - -正面影响: - -- 程序集、命名空间和策略消费者表达一致的职责。 -- Runtime 恢复为只依赖 Core 与 Presentation.Abstractions。 -- Assets 与 Storage.FileSystem 成为实现层,不再拥有 Runtime 使用的端口。 -- 新持久化能力有明确入口,不再默认加入通用 Storage 抽象程序集。 - -代价与风险: - -- 后续迁移会修改公开命名空间,并需要一次更新所有仓库内消费者。 -- Core.Assets 会包含资源访问协议,但不包含目录、PAK、缓存实现或平台解码资源。 -- 未证明外部消费者兼容需求,因此本次决定不要求 type forwarding;若迁移前发现真实外部消费者,应另行决定过渡版本。 - -## 边界 - -- 本 ADR 不决定音频接口;`IAudioService` 由音频 feature 管理。 -- 本 ADR 不迁移 Presentation/Builtins 命名空间,也不决定 Editor 插件 API。 -- 本 ADR 描述目标结构;迁移完成前,`GalNet.Storage.Abstractions` 仍是当前实现的一部分。 diff --git a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs index a958805..6de3559 100644 --- a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs +++ b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs @@ -33,15 +33,18 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { var services = new ServiceCollection(); - services.AddAvaloniaGameViewPages(); - services.AddScoped(); - services.AddScoped(sp => sp.GetRequiredService()); - services.AddSampleDebug(); - services.AddTransient(); + services.AddAvaloniaGameViewPages() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddSampleDebug() + .AddTransient(); + _rootServices = services.BuildServiceProvider(); _gameScope = _rootServices.CreateScope(); var scopedServices = _gameScope.ServiceProvider; + scopedServices.GetRequiredService().ResetTo(NavigationTransition.None); + var mainWindow = scopedServices.GetRequiredService(); desktop.MainWindow = mainWindow; mainWindow.Opened += async (_, _) => await scopedServices.GetRequiredService().InitializeAsync(LaunchOptions); From d35d1cc4dfd68b5566562c1286b3fe41663de1d7 Mon Sep 17 00:00:00 2001 From: sakuya Date: Sat, 10 Oct 2026 14:54:33 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/agent-knowledge.md | 7 +- GalNet.slnx | 1 - docs/development.md | 140 ++--- docs/engine.md | 2 +- docs/spec/architecture.md | 6 +- docs/spec/entry-types.md | 16 +- docs/spec/runtime.md | 36 +- src/Editor/GalNet.Editor.Headless/Program.cs | 2 +- .../Services/GamePackageExporter.cs | 2 +- .../Services/ProjectContentBuilder.cs | 2 +- .../EditorServiceCollectionExtensions.cs | 2 +- .../Services/EditorPreviewHost.cs | 5 +- src/Editor/GalNet.Editor/packages.lock.json | 8 - .../InstalledGameContentProvider.cs | 2 +- .../ProjectGameContentProvider.cs | 2 +- .../GalNet.Avalonia.GameView.csproj | 1 - .../AvaloniaAnimationPresenter.cs | 3 +- .../Services/AvaloniaAudioPlaybackHost.cs | 163 ++++- .../packages.lock.json | 7 - .../GalNet.Presentation.Defaults.csproj | 12 - .../Media/LibVlcAudioController.cs | 57 -- .../NullGameView.cs | 19 - .../packages.lock.json | 22 - .../GalNet.Sample.Avalonia/App.axaml.cs | 30 +- .../GalNet.Sample.Avalonia.csproj | 1 - .../Services/SampleGameRunner.cs | 17 +- .../Services/SampleGameSessionService.cs | 50 +- .../GalNet.Sample.Avalonia/packages.lock.json | 8 - .../HeadlessConsolePresentation.cs | 2 +- src/Samples/GalNet.Sample.Headless/Program.cs | 5 +- .../Compilation/GalgroupCompiler.cs | 2 + src/Shared/GalNet.Core/Entry/EntryModule.cs | 22 +- src/Shared/GalNet.Core/Entry/EntrySchema.cs | 5 +- .../Entry/PrimitiveArgumentHelper.cs | 4 +- .../Entry/TargetProfileEntryCatalog.cs | 4 - .../Primitives/DynamicParameterTable.cs | 5 +- .../Primitives/PrimitiveCreateContext.cs | 6 +- .../GalNet.Core/Runtime/GameSnapshot.cs | 2 +- .../GalNet.Core/Runtime/IGameRuntime.cs | 4 - src/Shared/GalNet.Core/Scene/Layer.cs | 6 - src/Shared/GalNet.Core/Scene/SceneState.cs | 6 - .../GalNet.Core/Services/IAudioService.cs | 20 - .../View/CompositeGameView.cs | 100 ++- .../View/IAnimationPresenter.cs | 4 +- .../View/IGameView.cs | 18 +- .../AnimationPlanPrimitiveInstance.cs | 127 ++-- .../AnimationPrimitiveInstance.cs | 9 +- .../BuiltinEntryModules.cs | 445 ------------- .../BuiltinRuntimeActions.cs | 592 ------------------ .../EffectPrimitiveInstance.cs | 7 +- .../Entry/NvlEntries.cs | 3 + .../NvlAppendPrimitiveInstance.cs | 4 +- .../ParticlePrimitiveInstance.cs | 7 +- .../GalNet.Runtime/Audio/AudioRuntime.cs | 74 ++- .../Content/IGameContentProvider.cs | 2 + .../GalNet.Runtime/Engine/GameEngine.cs | 55 +- .../GalNet.Runtime/Runtime/GameRuntime.cs | 135 +--- .../ProjectDependencyTests.cs | 5 +- .../Entry/TargetProfileEntryCatalogTests.cs | 2 +- test/GalNet.Core.Tests/Scene/SceneTests.cs | 10 +- .../EditorDocumentRepositoryTests.cs | 9 +- .../TargetProfileEditorCommandTests.cs | 5 +- .../EditorWorkspaceViewModelTests.cs | 2 +- .../EntryTypeShortcutTests.cs | 4 +- .../GraphEditingServiceTests.cs | 6 +- test/GalNet.Editor.Tests/packages.lock.json | 8 - .../GalgroupLoaderTests.cs | 15 +- .../GameEnginePrimitiveDispatchTests.cs | 99 ++- .../GameInstallationTests.cs | 13 +- .../PersistentSceneRestorerTests.cs | 2 +- .../packages.lock.json | 9 - .../CompositeGameViewTests.cs | 175 ++---- .../packages.lock.json | 8 - .../Entry/EntryModelTests.cs | 2 +- .../Entry/EntryModuleCatalogTests.cs | 150 ++--- .../Runtime/BuiltinPrimitiveInstanceTests.cs | 62 +- .../Runtime/NvlPrimitiveInstanceTests.cs | 4 +- .../Runtime/VideoPrimitiveInstanceTests.cs | 12 +- .../Audio/AudioRuntimeTests.cs | 66 ++ .../Runtime/GameRuntimeSnapshotTests.cs | 29 +- .../SaveLoad/SaveManagerTests.cs | 1 + 81 files changed, 921 insertions(+), 2075 deletions(-) delete mode 100644 src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj delete mode 100644 src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs delete mode 100644 src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs delete mode 100644 src/Presentation/GalNet.Presentation.Defaults/packages.lock.json delete mode 100644 src/Shared/GalNet.Core/Services/IAudioService.cs delete mode 100644 src/Shared/GalNet.Primitives.Builtins/BuiltinEntryModules.cs delete mode 100644 src/Shared/GalNet.Primitives.Builtins/BuiltinRuntimeActions.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 3a54091..9d2ed40 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -13,8 +13,11 @@ - 解决方案入口是 `GalNet.slnx`。 - `docs/spec/` 描述已经实现的稳定事实;`docs/design/` 描述设计和分阶段计划。 - Runtime/Core 应保持与 Avalonia、WPF、Skia、具体文件系统和其他平台实现解耦。 +- Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为主内置入口。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/adr/0002-entry-schema-execution-registration.md 和 docs/development.md。 +- 当前 GameSnapshot 版本为 3,不兼容旧版;读档取消旧运行 scope 并替换场景对象。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。 +- Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前 20 个生产项目。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。 - 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。 -- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由 Engine 根据状态重放展示。 +- 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由宿主通过 PersistentSceneRestorer 根据状态重放展示。 - 视觉扩展应通过展示抽象和渲染端口接入;不要把平台类型或渲染资源泄漏进 Core/Runtime。 - 当前已有大型阶段计划:[runtime-presentation-decoupling-phase-plan.md](../docs/design/runtime-presentation-decoupling-phase-plan.md) 和 [render-effects-pipeline-plan.md](../docs/design/render-effects-pipeline-plan.md)。新增 feature 应链接它们,不要复制其中的长期设计。 @@ -49,6 +52,8 @@ ## 经验索引 - [受限环境中的 .NET/Avalonia 构建](lessons/L-20260922-01-dotnet-build-sandbox.md):禁用 Roslyn shared compilation,并关闭 Avalonia build telemetry。 +- [读档必须隔离旧运行实例](lessons/L-20261010-02-restore-run-scope.md):取消与场景对象隔离同时使用;句柄复用的清理需校验实例 identity;快照不能共享 mutable 对象。 +- [删除项目后检查依赖锁图](lessons/L-20261010-03-lock-graph-after-project-removal.md):检查 lock 内容;缓存可能保留旧项目,需要对受影响 roots 强制重算再 locked restore。 - [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 ADR-0001 和 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。 - [受限环境中的平台 SDK 枚举](lessons/L-20260925-01-platform-sdk-sandbox.md):全解决方案构建可能只因 Android/Browser/iOS 的 SDK 探测读取用户目录失败;应区分平台环境限制和业务项目编译结果。 - [受限环境中的 Avalonia licensing 枚举](lessons/L-20260926-01-avalonia-license-sandbox.md):Avalonia BuildServices 即使关闭 telemetry 仍可能读取用户级 licensing tickets;应与业务编译失败区分。 diff --git a/GalNet.slnx b/GalNet.slnx index 522901d..a4e2f5b 100644 --- a/GalNet.slnx +++ b/GalNet.slnx @@ -24,7 +24,6 @@ - diff --git a/docs/development.md b/docs/development.md index 7ae2371..067da2a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,83 +47,37 @@ var reputationParameters = new DynamicParameterTable( `DynamicParameterDescriptor` 可声明 required、JSON 默认值与 JSON constraints。若主要目的是提供编辑器控件提示,也可用 `EntrySchema.DynamicParameters(...)` 由 `EntryParameterType` 构建 schema。 -### 2. 选择原语的生命周期 +### 2. 分别定义契约和执行模块 -同步且立即结束的逻辑使用 `ImmediatePrimitiveInstance`: +schema 不引用游戏服务或平台对象。执行模块持有共享数据和服务,贡献完整指令 ID 与实例工厂: ```csharp -new DefaultPrimitiveEntryBase( - "mygame.reputation.add", - reputationParameters, - context => new ImmediatePrimitiveInstance(() => - { - var faction = context.Arguments.GetProperty("faction").GetString()!; - var amount = context.Arguments.GetProperty("amount").GetInt32(); - reputationService.Add(faction, amount); - }, batchId: context.BatchId)); -``` - -有等待、动画或用户跳过语义时,继承 `PrimitiveInstance`: +using GalNet.Presentation.Abstractions.Execution; -```csharp -public sealed class WaitForSignalInstance : PrimitiveInstance +public sealed class MyGameSchemas : EntryModuleBase { - private readonly IMySignalService _signals; - private readonly string _signal; - private CancellationTokenSource? _cancellation; - - public WaitForSignalInstance(IMySignalService signals, string signal, - string? batchId, CancellationToken scopeCancellation) : base(batchId) - { - _signals = signals; - _signal = signal; - ScopeCancellation = scopeCancellation; - } - - private CancellationToken ScopeCancellation { get; } - public override bool IsBlocking => true; - public override bool IsSkippable => true; - - protected override async void OnDispatch() - { - _cancellation = CancellationTokenSource.CreateLinkedTokenSource(ScopeCancellation); - try { await _signals.WaitAsync(_signal, _cancellation.Token); } - catch (OperationCanceledException) { } - finally { TryComplete(); } - } - - protected override void OnSkip() => _cancellation?.Cancel(); + public MyGameSchemas() + : base("mygame", + [ + new DefaultPrimitiveEntryBase( + "mygame.reputation.add", + new DynamicParameterTable( + [ + new DynamicParameterDescriptor("faction", typeof(string), isRequired: true), + new DynamicParameterDescriptor("amount", typeof(int), + defaultValue: JsonSerializer.SerializeToElement(1)) + ])) + ]) + { } } -``` - -原语实现必须遵守以下规则: - -- 让基类调用 `Dispatch()`;只在 `OnDispatch()` 启动一次行为。 -- 成功、取消或错误路径最终都只调用一次 `TryComplete()`。 -- 创建实例时原样传入 `context.BatchId`;不要生成不同的 batch ID。 -- `OnSkip()` 必须幂等,且只处理本实例自己的资源。 -- non-blocking 原语在返回前提交可存档状态;平台 Task、控件和播放游标不属于存档。 -### 3. 将定义组成模块 - -```csharp -public sealed class MyGameModule : EntryModuleBase +public sealed class MyGameModule : PrimitiveModule { - private static readonly DynamicParameterTable ReputationParameters = new( - [ - new DynamicParameterDescriptor("faction", typeof(string), isRequired: true), - new DynamicParameterDescriptor( - "amount", typeof(int), - defaultValue: JsonSerializer.SerializeToElement(1)) - ]); - public MyGameModule(IReputationService reputationService) - : base("mygame", + : base( [ - new DefaultPrimitiveEntryBase( - "mygame.reputation.add", - ReputationParameters, - context => new ImmediatePrimitiveInstance(() => + new PrimitiveImplementation("mygame.reputation.add", context => + new ImmediatePrimitiveInstance(() => { var faction = context.Arguments.GetProperty("faction").GetString()!; var amount = context.Arguments.GetProperty("amount").GetInt32(); @@ -134,28 +88,48 @@ public sealed class MyGameModule : EntryModuleBase } ``` -模块构造时会冻结 primitive 与 composite 两张表,并拒绝模块内或跨模块的重复 type ID。模块有资源时覆写 `Dispose()`;`CompositeGameView` 会按相反顺序释放已挂载模块。 +不必逐条在组合根注册。一个领域模块可以贡献多个指令,并在实例间共享轨道、缓存或服务;也可用 `RegisterInstruction` 单独贡献指令。同前缀可以由多个模块贡献不同指令,只有完整 type ID 重复才冲突。schema 模块无资源生命周期;执行模块可覆写 `Dispose` / `DisposeAsync`。 -### 4. 同时接入作者期与运行期 +异步或可跳过行为继承 `PrimitiveInstance`,在 `OnDispatch` 启动受 scope cancellation 控制的任务;成功、取消和失败都应完成实例,失败需明确诊断。不要用未经观察异常的 `async void`。创建实例时原样传入 `context.BatchId`,`OnSkip` 必须幂等且只处理自己的资源。non-blocking 原语应先提交可存档状态,再启动展示。 -必须让编辑器/编译器的 target profile 与运行时 view 使用**等价的模块集合和 schema**。实际宿主可以各自创建模块实例(例如编辑器没有 renderer,游戏有 renderer),但 type ID、参数和 composite 展开契约必须一致。 +### 3. 在游戏组合根独立注册 ```csharp -// 编辑器或导出器:决定允许作者使用哪些 entry。 -var authoringModules = new IEntryModule[] -{ - new MyGameModule(editorReputationService) -}; -var profile = new TargetProfileEntryCatalog(authoringModules); +using GalNet.Presentation.Abstractions.View; -// 游戏会话:提供同一批 primitive 的实际工厂和平台依赖。 -using IGameView view = new CompositeGameView( -[ - new MyGameModule(runtimeReputationService) -]); +// 编译器只需契约,不构造执行服务。 +using var registration = new GameRegistration() + .RegisterSchema(new MyGameSchemas()) + .RegisterImplementation(new MyGameModule(reputationService)); +var profile = registration.BuildCatalog(); + +// BuildView 冻结注册并将执行模块所有权转移给 view。 +await using var view = registration.BuildView(); ``` -只把模块挂到 `CompositeGameView` 会使 Runtime 能执行、编辑器却不能作者化/编译;只挂到 `TargetProfileEntryCatalog` 则相反。不要依赖全局内置目录作为回退。 +编译期 target profile 和运行期 view 使用等价的 schema;执行模块只在运行期创建。宿主先停止/释放 Engine,再异步释放 view,最后释放它借用的 presenter 和平台资源。注册失败时释放 registration,避免遗留已创建的执行模块。 + +### 4. 自由选择内置能力 + +```csharp +// 主入口:所有内置 schema 与实现,平台能力通过端口提供。 +await using var full = new GameRegistration() + .AddBuiltins(dialoguePresenter: dialogue, audioHost: audioHost) + .BuildView(); + +// 内置 schema + 部分内置逻辑 + 自己的实现。 +await using var custom = new GameRegistration() + .AddBuiltinSchemas() + .AddBuiltinVariables() + .RegisterImplementation(new MyPlatformModule()) + .BuildView(); +``` + +`AddBuiltinSchemas` 不创建播放器或运行期状态;`AddBuiltinImplementations` 注册全部内置实现;`AddBuiltinAudio`、`AddBuiltinAnimations`、`AddBuiltinVariables` 等按领域选择实现。也可以完全不使用内置契约。 + +只注册 schema 的 view 可以构建,但不能执行尚未提供实现的指令。Engine 创建时检查 Graph 引用的所有 primitive;缺失实现直接报错,不静默跳过。没有 presenter 的内置模块不等于具备平台播放能力:部分模块可只提交状态,对话等需要交互的能力仍要求对应 presenter。 + +音频用组合而非强制继承:`BuiltinAudioModule`/`AudioRuntime` 管理轨道与 voice,`IAudioPlaybackHost` 管理平台播放。Avalonia 可提供 LibVLC host,Unity 可提供原生音频 host;若轨道语义也需替换,只注册内置 schema 和自己的音频执行模块。 ## 创建 composite(可选) @@ -200,7 +174,7 @@ public sealed class AlertCompositeEntry : CompositeEntry - 自定义 type ID 在目标 profile 中存在,且无重复。 - Raw Group 可编译为只含 primitive 的 `.galgroup`。 -- Runtime 的 `CompositeGameView` 挂载等价模块集合。 +- Runtime 的 view 使用等价 schema,且 Graph 所需指令都有实现。 - 参数默认值、必填项和 JSON 类型有测试。 - blocking、skip、取消和完成事件分别有测试。 - 任何影响长期画面的状态先写入可恢复的状态模型,再调用平台呈现。 diff --git a/docs/engine.md b/docs/engine.md index fcb8a1e..df1a900 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -66,7 +66,7 @@ Project/ ## 默认能力与宿主 -`GalNet.Primitives.Builtins` 是可选的推荐模块集合,提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与相应的已实现能力。具体媒体后端能力由所选宿主提供;宿主可以只挂载自定义模块,或在内置模块之外追加能力。 +`GalNet.Primitives.Builtins` 提供对话、NVL、图层、动画、效果、粒子、音频、视频、等待、变量和 Gallery 等 schema 与执行模块。`GameRegistration.AddBuiltins` 是主内置注册入口;也可只注册 `AddBuiltinSchemas`,再选择部分内置实现或全部自定义实现。媒体后端通过平台端口组合,不要求继承播放器基类;多个领域模块可共享指令前缀,但完整 ID 必须唯一。示例见[二次开发手册](development.md)。 官方 Avalonia 宿主 `GalNet.Avalonia.GameView` 提供 `GameShell`、导航、页面和默认 presenter。它不读取项目目录,也不决定媒体或存档实现;这些由游戏宿主在组合根提供。页面集成见[Avalonia 游戏页面](spec/control.md)。 diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index 790e309..f07feae 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -22,7 +22,7 @@ Editor / Samples ───────── 组合根与平台宿主 ```text src/ Shared/ Core、Runtime、Assets、Presentation.Abstractions、Primitives.Builtins - Presentation/ Avalonia.Controls、Avalonia.Rendering、Avalonia.GameView、Presentation.Defaults + Presentation/ Avalonia.Controls、Avalonia.Rendering、Avalonia.GameView Infrastructure/ Storage.FileSystem Editor/ Editor.Abstraction、Editor.Shared、Editor、Editor.Headless Samples/ Sample.Avalonia、Sample.Headless @@ -31,7 +31,7 @@ src/ 物理分组用于表达查找和所有权,不改变程序集名称、根命名空间或依赖方向。新增生产项目应放入与其职责一致的分组,并同步 `GalNet.slnx`;测试项目统一位于 `test/.Tests`。 -程序集拥有自己的根命名空间:展示端口位于 `GalNet.Presentation.Abstractions.*`,推荐内置 entry、primitive 和 module 位于 `GalNet.Primitives.Builtins`。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。 +程序集拥有自己的根命名空间:展示与执行注册端口位于 `GalNet.Presentation.Abstractions.*`,内置 entry、primitive 和 module 位于 `GalNet.Primitives.Builtins`。Entry schema 不依赖执行工厂,执行模块按领域共享状态。Builtins 可依赖 Runtime 的平台无关逻辑(如音频轨道),Runtime 不反向依赖 Builtins。扩展程序集不得在 `GalNet.Core.*` 下声明类型;Core 只保留领域基础类型和扩展协议。注册拆分决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。 资源层只向上暴露 `IAssetProvider`、`IArchive`、`IGameFile` 和 `IAssetManager`。provider 定位源文件;manager 负责 GUID 查询、decoder 分派、缓存和 `AssetHandle` 生命周期。Core、Runtime 与 Editor.Shared 不引用 Avalonia。 @@ -53,7 +53,7 @@ src/ `GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `SceneState`,再通知呈现端;读取快照后由 Engine 根据状态重放。 -Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePageView` 只创建并暴露这些 presenter,`CompositeGameView` 仍只负责把内置 entry module 组合为 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 管理 engine 和页面展示生命周期,`PersistentSceneRestorer` 在 Runtime 快照恢复后重放 renderer-owned 场景对象。runner 必须先于资源 scope 释放。 +Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePageView` 只创建并暴露这些 presenter,`CompositeGameView` 组合独立的 schema catalog 与执行模块,提供 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 管理 engine、执行 view 和页面展示生命周期,`PersistentSceneRestorer` 在 Runtime 快照恢复后重放 renderer-owned 场景对象。runner 必须先于资源 scope 异步释放。 Gallery catalog 是平台无关的静态内容。宿主把其自动生成的 `gallery__unlocked` Player bool 注册到变量服务;`GalleryDataSource` 合并 catalog 与当前 Player 值。Gallery 解锁不存于独立 UI 状态或存档槽。 diff --git a/docs/spec/entry-types.md b/docs/spec/entry-types.md index edbd8b7..1d32956 100644 --- a/docs/spec/entry-types.md +++ b/docs/spec/entry-types.md @@ -6,7 +6,7 @@ - `.rawgalgroup` 的 `parameters` 与编译后 `.galgroup` 的 `arguments` 都是 JSON 对象;数字、布尔值、对象和数组不被压平为字符串。 - 某个 entry 是否可编辑、可编译,完全由当前 target profile 中挂载模块导出的 entry schema 决定。未挂载的类型被拒绝,不会回退到全局内置目录。 - 每个 entry 定义的 `DynamicParameterTable` 是只读 schema:每个参数以 `typeof(T)` 指定进程内运行时类型,并包含 required、JSON 默认值和 JSON constraints。CLR 类型名绝不写入内容、metadata、存档或 pak;内容只保存 JSON 值。 -- Runtime 只接收通用 `PrimitiveEntry`,并通过 `IGameView.Dispatch` 动态路由。Core/Runtime 不内置或要求任何推荐 primitive 实现。 +- Runtime 只接收通用 `PrimitiveEntry`,并通过 `IGameView.Dispatch` 动态路由。Core/Runtime 不要求任何内置 primitive 实现。 `EntryParameterType` 是当前编辑器的 UI hint。它由参数 constraints 生成,不是执行协议。 @@ -14,10 +14,10 @@ 每个 `IEntryModule` 持有两张冻结表: -- `PrimitiveEntries`:Runtime 可执行 primitive schema 与 instance 工厂。 +- `PrimitiveEntries`:primitive 参数契约,不含执行工厂或平台依赖。 - `CompositeEntries`:只服务编辑器和编译器的 authoring 展开。 -同一模块内 primitive/composite 不得重名;多个挂载模块之间 primitive 名称也不得重复。组合根可以把同一组模块用于 `TargetProfileEntryCatalog` 和 `CompositeGameView`,使编辑器 schema 与 Runtime 工厂来自同一事实来源。 +同一模块内 primitive/composite 不得重名;跨模块仅完整 type ID 不得重复,模块名称和指令前缀可重复。`TargetProfileEntryCatalog` 只依赖 schema;运行时通过 `GameRegistration` 独立注册 `IPrimitiveModule` 执行模块,再构建 `CompositeGameView`。一个执行模块可以共享服务与状态,并贡献多个指令;每个实现必须有 primitive schema。 ## Primitive Entry @@ -35,7 +35,7 @@ Composite 仅存在于 `.rawgalgroup`,并在编译时展开为通用 `PrimitiveEntry` 序列;`.galgroup` 只包含 primitive envelope。Composite 自己决定展开结果的 batch 划分,框架不规定“一个 Composite 等于一个 Batch”。 -推荐 Builtins 在 animation module 的 composite 表中提供: +内置 schema 模块的 composite 表提供: ```text transition.crossFade @@ -48,9 +48,9 @@ transition.fadeColor 这些 entry 不是 Runtime primitive,也不绕过 target profile:展开后的每一个 primitive 都必须在所选 profile 中存在。 -## 推荐 primitive ID +## 内置 primitive ID -`GalNet.Primitives.Builtins` 提供可选推荐 authoring profile 与运行时模块。当前推荐 ID 为: +`GalNet.Primitives.Builtins` 提供独立可选的 schema 与运行时领域模块。主入口是 `GameRegistration.AddBuiltins`,也可单独调用 `AddBuiltinSchemas`,并组合部分内置或自定义执行模块。当前内置 ID 为: 内置 entry DTO、primitive instance 和 module 都使用 `GalNet.Primitives.Builtins` 命名空间;`GalNet.Core.Entry` 只包含通用 entry 基类、schema 和 catalog 基础设施。内容格式只保存下列稳定 `TypeId`,不保存 CLR 类型名。 @@ -71,9 +71,9 @@ flow.wait, variable.set, gallery.unlock `nvl.show` 和 `nvl.hide` 只改变 NVL 覆盖层的可见性;`nvl.clear` 移除已累积的历史行;`nvl.append` 只接收长文本 `content`,以与 `dialogue.text` 相同的富文本和打字机语义追加一段,并在文字完成后等待一次玩家推进。它不会自动显示 NVL,剧情应显式调用 `nvl.show`。 -这些名称和参数只是推荐语义。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。 +这些名称和参数属于可选内置契约。开发者可以只注册自定义模块,或在自己的 target profile 中定义完全不同的前缀、参数与语义;编辑器不会凭名称假定 `dialogue`、`nvl`、`layer` 或任何其他模块存在。 -当前已实现 Runtime 行为的推荐能力见 [runtime.md](runtime.md)。 +当前已实现 Runtime 行为的内置能力见 [runtime.md](runtime.md)。 ## 视频图层 entry diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index df77c13..641acb1 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -62,6 +62,8 @@ Choice 节点由 Engine 求值并过滤可见选项,再通过 `IChoicePresente `GameRuntime` 是游戏事实的唯一来源: +内容 provider 将项目与安装目录的 `settings.json` 解析为 `GameContent.Settings`;两个 Sample 都把它注入 Runtime 的 `SettingsContainer`,自定义音频轨道不会退回默认轨道。缺少文件使用默认设置,非法 JSON 明确拒绝。 + | 状态 | 说明 | | --- | --- | | `CurrentNodeId` / `EntryIndex` | 当前流程位置 | @@ -71,7 +73,7 @@ Choice 节点由 Engine 求值并过滤可见选项,再通过 `IChoicePresente | `SceneState` / `SceneInstances` | 可存档场景快照与按句柄管理的活跃实例 | | 变量存储 | Player / Save 作用域变量与表达式求值 | -`GameSnapshot` 包含 `NodeId`、`EntryIndex`、变量字典与 `SceneState`。`CreateSaveData()` 返回 Engine 保存的最后稳定快照,而不是即时抓取可能仍在异步变化的状态。 +`GameSnapshot` 当前版本为 3,旧版本直接拒绝,不提供迁移兼容。它包含 `NodeId`、`EntryIndex`、变量字典与 `SceneState`。`CreateSaveData()` 返回 Engine 保存的最后稳定快照,而不是即时抓取可能仍在异步变化的状态。所有对外快照均深复制;调用者修改保存数据或事件参数不会影响 Engine。读档取消旧 scope,并替换 SceneState/SceneInstances;旧实例保留旧场景,不能更改新运行的变量与流程,恢复时重置结束标记。 Save scope 随存档槽保存和恢复;Player scope 由宿主变量服务独立持久化,普通读档不得回滚。内置系统可以通过 `SystemVariableDefinition` 注册生成变量。Gallery 为每个 item 注册默认 `false` 的 Player bool `gallery__unlocked`;`gallery.unlock` 校验 item ID 后把该变量设为 `true`。 @@ -115,31 +117,27 @@ public void Skip(); ## Entry Module 与 GameView -宿主把一个或多个 `IEntryModule` 挂到 `CompositeGameView`。每个模块持有两张冻结表: - -- `PrimitiveEntries`:Runtime 可执行 primitive schema 与 instance 工厂。 -- `CompositeEntries`:只服务编辑器和编译器的 authoring 展开。 +契约和执行独立注册: -`CompositeGameView.Dispatch()` 负责: +- `IEntryModule` 导出 primitive 参数 schema 和编译期 composite,不创建执行服务。 +- `IPrimitiveModule` 导出完整指令 ID 到 `PrimitiveImplementation` 的表,持有游戏会话共享状态。 +- `GameRegistration` 组合两者;`BuildCatalog` 提供编译契约,`BuildView` 冻结注册并转移执行模块所有权。 +- 同前缀可多次注册不同指令;重复完整 ID、实现无对应 schema 都会报错。 +- `AddBuiltins` 是主内置入口;`AddBuiltinSchemas` 和各领域 `AddBuiltin*` 可独立选择。注册不等于提供平台能力,具体 presenter/host 由宿主传入。 -1. 按 `PrimitiveEntry.Type` 查找 `PrimitiveEntryBase`。 -2. 使用该 entry 的 `DynamicParameterTable` 规范化参数和默认值。 -3. 构造 `PrimitiveCreateContext`,其中包含 definition、规范化后的 entry、runtime、scope cancellation、arguments 与 BatchId。 -4. 调用工厂创建 instance。 -5. 验证 instance BatchId 与 entry BatchId 一致。 -6. 调用一次 `Dispatch()` 并把 instance 返回给 Engine。 +`CompositeGameView.Dispatch()` 查找完整 ID,按 schema 规范化参数并执行语义验证,构造 `PrimitiveCreateContext`,调用工厂、验证 BatchId,最后分发实例。缺失实现抛错,不返回 null;Engine 初始化时检查 Graph 所需的全部 primitive 实现。普通分发失败也不会被忽略或推进该条目的游标。 -未知 primitive 返回 `null`,由 Engine 记录诊断并继续执行。 +Engine 不拥有 view。宿主先释放 Engine,再 `await view.DisposeAsync()`,最后释放借用的 presenter 和后端;view 逆序释放全部执行模块并汇总错误。未转移的模块由 registration 释放。 -## 当前推荐模块 +## 当前内置模块 -`GalNet.Primitives.Builtins` 提供可选推荐模块。当前已实现运行时行为的能力包括: +`GalNet.Primitives.Builtins` 提供可选内置模块。当前已实现运行时行为的能力包括: - `dialogue.text`:对话与打字机阶段,支持 `\skip` 分段跳过;`dialogue.show` / `dialogue.hide` 控制显示状态。 - `nvl.show` / `nvl.hide` / `nvl.clear`:分别显示、隐藏和清空 NVL 历史;`nvl.append` 以相同的富文本和打字机语义追加一行,完成打字后等待玩家推进。NVL 不会因 append 自动显示。 - `layer.*`:show/showColor/hide/move/replace,先更新 `SceneState` 再通知 `ILayerPresenter`。 - `animation.animate` / `animation.play` / `animation.stop`:创建 animation primitive instance;一次性动画先提交最终逻辑状态,循环动画提交第 0 帧状态并保存完整重放定义,再启动呈现动画。plan 是单个 instance,内部事件只作为 animation 模块私有 layer/effect 事件处理,不重新进入通用 Entry 分发。 -- `effect.apply` / `effect.stop`:维护 `SceneState.ActiveEffects`、目标 Layer 的 effect 索引和 `IEffectPresenter` 调用。 +- `effect.apply` / `effect.stop`:维护 `SceneState.ActiveEffects` 和 `IEffectPresenter` 调用,不另存冗余的 effect ID 索引。 - `audio.play` / `audio.stop` / `audio.pause` / `audio.resume` / `audio.enqueue` / `audio.next` / `audio.clear`:`AudioRuntime` 持有轨道队列与 voice 生命周期,`IAudioPlaybackHost` 只执行平台播放请求。`dialogue.textVoice` 与 `nvl.appendVoice` 会先发出非阻塞的 `audio.play(replaceIfDifferent)`,默认使用 `vocal` 轨道,再按正常文本语义阻塞。 - `audio.seffect.apply` / `audio.seffect.stop`:以 `trackId` 和 `instanceId` 附着或移除宿主 catalog 已注册的轨道 SEffect;当前 Avalonia host 支持 `seffect.gain`、基于 equalizer 频段近似的 `seffect.lowPass` 与 `seffect.highPass`。未知 ID、非法参数或不支持 scope 只记录诊断,不会静默附着。 - `particle.play` / `particle.stop`:用 `rate` 创建和停止持续 emitter,维护 `SceneState.ActiveParticleEmitters` 与 Runtime emitter instance。存档只保存 emitter 定义、排序与动画值;宿主读档后通过 `BuiltinPresentationReplay` 以相同 seed 从头重建,不保存单颗粒子。`initial` 的 lifetime、velocity、size、rotation、angular velocity、color 以及 `flipbook` 的播放速度与起始帧都按粒子确定性采样;`motion` 提供 gravity、noise、drag、radial、orbit、attractor,`lifetime` 曲线线性插值 size、opacity、velocity、rotation、color。当前粒子年龄、位置和 flipbook 帧不单独存档。 @@ -149,4 +147,8 @@ public void Skip(); - `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 行为由内置模块实现。bus/master 处理、item effect、参数动画、存档恢复和玩家音量设置仍由后续 feature 补齐。Gallery 的平台无关 catalog 与解锁数据层由 Core/Storage 提供;默认 Avalonia 前端通过宿主提供的 `IGameGallerySession` 浏览 CG、视频和音频,不把页面或媒体状态写入 Runtime。 + +动画 plan 的事件与轨道共用 presenter 的播放时间源。`PlayAnimationPlanAsync` 必须在采样前 await `beforeFrame(elapsedFrames)`,正常完成前提供末帧;Loop/PingPong 的 elapsedFrames 单调增加,不在循环时归零。跳过不补发未来 particle burst。 + +音频后端请求按 voice 顺序提交,Stop 即刻取消尚未完成的 Start;异步销毁等待排队请求结束。Avalonia host 先登记待加载音频,停止/销毁取消加载,等待加载结束后释放 LibVLC 与临时目录。音频轨道状态不进入当前存档;读档会停止旧 scope 音频。 diff --git a/src/Editor/GalNet.Editor.Headless/Program.cs b/src/Editor/GalNet.Editor.Headless/Program.cs index 85b3e55..67a14e3 100644 --- a/src/Editor/GalNet.Editor.Headless/Program.cs +++ b/src/Editor/GalNet.Editor.Headless/Program.cs @@ -27,7 +27,7 @@ public static async Task Main(string[] args) if (args.Length == 0 || args[0] is "help" or "--help" or "-h") { PrintHelp(); return 0; } var catalog = new EditorCommandCatalog(); var fileCatalog = new AssetFileCommandCatalog(); - var entryCatalog = BuiltinEntryModules.CreateRecommendedTargetProfile(); + var entryCatalog = BuiltinSchemas.CreateCatalog(); if (args[0].Equals("commands", StringComparison.OrdinalIgnoreCase)) { WriteJson(catalog.GetAll().Concat(fileCatalog.GetAll()).Select(CommandSummary)); diff --git a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs index 335f6d2..5bd76c6 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs @@ -34,7 +34,7 @@ public static async Task ExportAsync( ArgumentNullException.ThrowIfNull(resourceTypes); ArgumentNullException.ThrowIfNull(galleryTypes); stagingRoot = Path.Combine(Path.GetTempPath(), $"galnet-export-{Guid.NewGuid():N}"); - await new ProjectContentBuilder(BuiltinEntryModules.CreateRecommendedTargetProfile()) + await new ProjectContentBuilder(BuiltinSchemas.CreateCatalog()) .BuildAsync(projectRoot, stagingRoot, cancellationToken) .ConfigureAwait(false); var assets = await LoadAssetsAsync(Path.Combine(stagingRoot, "Assets"), resourceTypes, cancellationToken); diff --git a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs index d9cda49..850d215 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/ProjectContentBuilder.cs @@ -18,7 +18,7 @@ public sealed class ProjectContentBuilder(IEntryCatalog? entryCatalog = null) Converters = { new JsonStringEnumConverter() } }; - private readonly IEntryCatalog _entryCatalog = entryCatalog ?? BuiltinEntryModules.CreateRecommendedTargetProfile(); + private readonly IEntryCatalog _entryCatalog = entryCatalog ?? BuiltinSchemas.CreateCatalog(); public async Task BuildAsync( string projectRoot, diff --git a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs index c066357..0f102f7 100644 --- a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs +++ b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs @@ -27,7 +27,7 @@ public static class EditorServiceCollectionExtensions { public static IServiceCollection AddEditorServices(this IServiceCollection services) { - var targetProfile = BuiltinEntryModules.CreateRecommendedTargetProfile(); + var targetProfile = BuiltinSchemas.CreateCatalog(); services.AddSingleton(targetProfile); services.AddEditorCoreServices(); services.AddEditorCommands(targetProfile); diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index 58b9739..8c20b3b 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -11,7 +11,6 @@ using GalNet.Core.Settings; using GalNet.Presentation.Abstractions.Runtime; using GalNet.Presentation.Abstractions.View; -using GalNet.Presentation.Defaults; using GalNet.Primitives.Builtins; using GalNet.Rendering.Scene; using GalNet.Runtime.Content; @@ -282,7 +281,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) _pageView = new AvaloniaGamePageView(_gameplay, _page, _layers); _effects = new AvaloniaEffectRuntime(_gameplay); _audio = new AvaloniaAudioPlaybackHost(_context.Assets); - var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + var view = new GameRegistration().AddBuiltins( _pageView.DialoguePresenter, _pageView.LayerPresenter, _pageView.AnimationPresenter, @@ -290,7 +289,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) content.Gallery, _pageView.ParticlePresenter, _pageView.NvlPresenter, - _audio)); + _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); _pageView.AdvanceRequested += OnAdvanceRequested; diff --git a/src/Editor/GalNet.Editor/packages.lock.json b/src/Editor/GalNet.Editor/packages.lock.json index d1b82a1..488f660 100644 --- a/src/Editor/GalNet.Editor/packages.lock.json +++ b/src/Editor/GalNet.Editor/packages.lock.json @@ -468,7 +468,6 @@ "GalNet.Avalonia.Rendering": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "Irihi.Ursa": "[2.1.0, )", "LibVLCSharp": "[3.10.0, )", "LibVLCSharp.Avalonia": "[3.10.0, )", @@ -511,13 +510,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "galnet.primitives.builtins": { "type": "Project", "dependencies": { diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs b/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs index c71c334..6a5c125 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/InstalledGameContentProvider.cs @@ -22,6 +22,6 @@ public Task LoadAsync(CancellationToken cancellationToken = default cancellationToken.ThrowIfCancellationRequested(); var graph = GameGraphContentLoader.Load(_directory, Path.Combine("Graph", "graph.json"), Path.Combine("Graph", "groups"), cancellationToken); var gallery = GalleryFileLoader.LoadGenerated(Path.Combine(_directory, "gallery.json"), _galleryTypes); - return new GameContent { Graph = graph, Gallery = gallery }; + return new GameContent { Graph = graph, Gallery = gallery, Settings = GameProjectSettingsLoader.Load(_directory) }; }, cancellationToken); } diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs b/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs index 0b4bbf1..ed86214 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/ProjectGameContentProvider.cs @@ -28,6 +28,6 @@ public Task LoadAsync(CancellationToken cancellationToken = default using var archive = assets.OpenArchive("assets"); var gallery = new GalleryCatalogCompiler(_resourceTypes, _galleryTypes) .Compile(archive.AssetIds.Select(id => archive.GetAsset(id)!.Metadata)); - return new GameContent { Graph = graph, AssetRoot = assetsDirectory, Gallery = gallery }; + return new GameContent { Graph = graph, AssetRoot = assetsDirectory, Gallery = gallery, Settings = GameProjectSettingsLoader.Load(_directory) }; }, cancellationToken); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj index 1413b69..a31c2a8 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj +++ b/src/Presentation/GalNet.Avalonia.GameView/GalNet.Avalonia.GameView.csproj @@ -17,7 +17,6 @@ - diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs index 6f1f497..7194897 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaAnimationPresenter.cs @@ -116,7 +116,7 @@ await _dispatcher.InvokeAsync(() => public async Task PlayAnimationPlanAsync( AnimationPlanDefinition plan, - CancellationToken cancellationToken) + CancellationToken cancellationToken, Func? beforeFrame = null) { cancellationToken.ThrowIfCancellationRequested(); var active = new ActivePlan(plan, Interlocked.Increment(ref _sequence)); @@ -145,6 +145,7 @@ public async Task PlayAnimationPlanAsync( } var elapsedFrames = started.Elapsed.TotalSeconds * plan.FrameRate; + if (beforeFrame is not null) await beforeFrame(elapsedFrames); var frame = GetPlanFrame(plan, elapsedFrames); await ApplyPlanFrameAsync(tracks, frame); if (plan.LoopMode == AnimationLoopMode.Once && elapsedFrames >= plan.DurationFrames) diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs index d0747cb..d239ecf 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Threading.Channels; using GalNet.Core.Assets; using GalNet.Core.Audio; using GalNet.Presentation.Abstractions.Audio; @@ -9,13 +10,21 @@ namespace GalNet.Avalonia.GameView.Services; /// LibVLC-backed audio host. Logical queue and track ownership remains in AudioRuntime. -public sealed class AvaloniaAudioPlaybackHost : IAudioPlaybackHost +public sealed class AvaloniaAudioPlaybackHost : IAudioPlaybackHost, IAsyncDisposable { private readonly object _gate = new(); private readonly IAssetManager _assets; private readonly string _directory = Path.Combine(Path.GetTempPath(), "GalNet", "Audio", Guid.NewGuid().ToString("N")); private readonly Dictionary _voices = new(StringComparer.Ordinal); private readonly Dictionary _paths = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _pending = new(StringComparer.Ordinal); + private readonly List _loads = []; + private readonly CancellationTokenSource _shutdown = new(); + private readonly SemaphoreSlim _materialization = new(1, 1); + private readonly Channel<(Voice Voice, AudioBackendEventKind Kind, string? Message)> _terminalEvents = + Channel.CreateUnbounded<(Voice, AudioBackendEventKind, string?)>(new UnboundedChannelOptions { SingleReader = true }); + private readonly Task _terminalWorker; + private Task? _disposeTask; private readonly AvaloniaAudioSEffectRegistry _effects; private readonly LibVLC? _libVlc; private bool _disposed; @@ -38,6 +47,7 @@ public AvaloniaAudioPlaybackHost(IAssetManager assets, IEnumerable _ = DisposeAsync().AsTask(); + + public ValueTask DisposeAsync() { lock (_gate) { - if (_disposed) return; + if (_disposeTask is not null) return new ValueTask(_disposeTask); _disposed = true; + _shutdown.Cancel(); foreach (var voice in _voices.Values) voice.Dispose(); _voices.Clear(); + _terminalEvents.Writer.TryComplete(); + _disposeTask = FinishDisposalAsync(_loads.ToArray()); + return new ValueTask(_disposeTask); + } + } + + private async Task FinishDisposalAsync(Task[] loads) + { + // Ensure cancellation callbacks have left the disposal lock before cleanup. + await Task.Yield(); + try { await Task.WhenAll(loads.Append(_terminalWorker)).ConfigureAwait(false); } + finally + { _paths.Clear(); _libVlc?.Dispose(); + _materialization.Dispose(); + _shutdown.Dispose(); + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } + + private Task StartTrackedAsync(AudioBackendRequest request, CancellationToken cancellationToken) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_pending.TryGetValue(request.VoiceId, out var previous)) previous.Cancellation.Cancel(); + var pending = new PendingLoad(request.Generation, + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdown.Token)); + _pending[request.VoiceId] = pending; + var task = ObserveStartAsync(request, pending); + _loads.RemoveAll(load => load.IsCompleted); + _loads.Add(task); + return task; + } + } + + private async Task ObserveStartAsync(AudioBackendRequest request, PendingLoad pending) + { + try { await StartAsync(request, pending.Cancellation.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (pending.Cancellation.IsCancellationRequested) { } + finally + { + lock (_gate) + if (_pending.TryGetValue(request.VoiceId, out var current) && ReferenceEquals(current, pending)) + _pending.Remove(request.VoiceId); + pending.Cancellation.Dispose(); } - if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); } private async Task StartAsync(AudioBackendRequest request, CancellationToken cancellationToken) @@ -99,6 +156,7 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can string path; try { path = await MaterializeAsync(request.AssetId, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception exception) { Raise(new(AudioBackendEventKind.Failed, request.TrackId, request.VoiceId, request.Generation, exception.Message)); @@ -108,6 +166,7 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can Voice voice; lock (_gate) { + cancellationToken.ThrowIfCancellationRequested(); if (_disposed) return; RemoveVoice(request.VoiceId); var player = new MediaPlayer(_libVlc); @@ -127,33 +186,44 @@ private async Task StartAsync(AudioBackendRequest request, CancellationToken can private async Task MaterializeAsync(string assetId, CancellationToken cancellationToken) { - lock (_gate) - if (_paths.TryGetValue(assetId, out var existing)) return existing; - var file = await _assets.GetFileAsync(assetId, cancellationToken).ConfigureAwait(false) - ?? throw new FileNotFoundException($"Audio asset '{assetId}' was not found."); - using var handle = await _assets.AcquireAsync(assetId, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidDataException($"Audio asset '{assetId}' could not be decoded as bytes."); - var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(assetId))).ToLowerInvariant(); - var path = Path.Combine(_directory, name + Path.GetExtension(file.Path)); - await File.WriteAllBytesAsync(path, handle.Value, cancellationToken).ConfigureAwait(false); - lock (_gate) + await _materialization.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - if (_disposed) throw new ObjectDisposedException(nameof(AvaloniaAudioPlaybackHost)); - _paths.TryAdd(assetId, path); - return _paths[assetId]; + lock (_gate) + if (_paths.TryGetValue(assetId, out var existing)) return existing; + var file = await _assets.GetFileAsync(assetId, cancellationToken).ConfigureAwait(false) + ?? throw new FileNotFoundException($"Audio asset '{assetId}' was not found."); + using var handle = await _assets.AcquireAsync(assetId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidDataException($"Audio asset '{assetId}' could not be decoded as bytes."); + var name = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(assetId))).ToLowerInvariant(); + var path = Path.Combine(_directory, name + Path.GetExtension(file.Path)); + await File.WriteAllBytesAsync(path, handle.Value, cancellationToken).ConfigureAwait(false); + lock (_gate) + { + if (_disposed) throw new ObjectDisposedException(nameof(AvaloniaAudioPlaybackHost)); + _paths.TryAdd(assetId, path); + return _paths[assetId]; + } } + finally { _materialization.Release(); } } - private void Stop(AudioBackendRequest request, float fadeSeconds) => WithVoice(request, voice => + private void Stop(AudioBackendRequest request, float fadeSeconds) { - if (fadeSeconds <= 0) + lock (_gate) + if (_pending.TryGetValue(request.VoiceId, out var pending) && pending.Generation == request.Generation) + pending.Cancellation.Cancel(); + WithVoice(request, voice => { - voice.Player.Stop(); - RemoveVoice(voice.Request.VoiceId); - return; - } - _ = FadeAsync(voice, voice.FadeGain, 0, fadeSeconds, stopWhenDone: true); - }); + if (fadeSeconds <= 0) + { + voice.Player.Stop(); + RemoveVoice(voice.Request.VoiceId); + return; + } + _ = FadeAsync(voice, voice.FadeGain, 0, fadeSeconds, stopWhenDone: true); + }); + } private void ApplySEffect(AudioBackendRequest request) => WithVoice(request, voice => { @@ -170,11 +240,17 @@ private void StopSEffect(AudioBackendRequest request) => WithVoice(request, voic private async Task FadeAsync(Voice voice, float from, float to, float seconds, bool stopWhenDone) { const int steps = 20; + CancellationToken cancellation; + lock (_gate) + { + if (_disposed || !_voices.TryGetValue(voice.Request.VoiceId, out var current) || !ReferenceEquals(current, voice)) return; + cancellation = voice.BeginFade(); + } try { for (var index = 1; index <= steps; index++) { - await Task.Delay(TimeSpan.FromSeconds(seconds / steps), voice.FadeCancellation.Token).ConfigureAwait(false); + await Task.Delay(TimeSpan.FromSeconds(seconds / steps), cancellation).ConfigureAwait(false); lock (_gate) { if (_disposed || !_voices.TryGetValue(voice.Request.VoiceId, out var current) || !ReferenceEquals(current, voice)) return; @@ -205,14 +281,29 @@ private void WithVoice(AudioBackendRequest request, Action action) private void Ended(Voice voice) { - lock (_gate) RemoveVoice(voice.Request.VoiceId); - Raise(new(AudioBackendEventKind.Ended, voice.Request.TrackId, voice.Request.VoiceId, voice.Request.Generation)); + _terminalEvents.Writer.TryWrite((voice, AudioBackendEventKind.Ended, null)); } private void Failed(Voice voice, string message) { - lock (_gate) RemoveVoice(voice.Request.VoiceId); - Raise(new(AudioBackendEventKind.Failed, voice.Request.TrackId, voice.Request.VoiceId, voice.Request.Generation, message)); + _terminalEvents.Writer.TryWrite((voice, AudioBackendEventKind.Failed, message)); + } + + private async Task DrainTerminalEventsAsync() + { + // LibVLC callbacks must not Stop/Dispose a player or wait for our gate: + // player disposal can itself wait for the native callback to return. + await foreach (var item in _terminalEvents.Reader.ReadAllAsync().ConfigureAwait(false)) + { + lock (_gate) + { + if (_disposed || !_voices.TryGetValue(item.Voice.Request.VoiceId, out var current) || + !ReferenceEquals(current, item.Voice)) continue; + RemoveVoice(item.Voice.Request.VoiceId); + } + Raise(new(item.Kind, item.Voice.Request.TrackId, item.Voice.Request.VoiceId, + item.Voice.Request.Generation, item.Message)); + } } private void RemoveVoice(string voiceId) @@ -249,7 +340,14 @@ private sealed class Voice(AudioBackendRequest request, MediaPlayer player) : ID public float FadeGain { get; set; } = 1; public float EffectGain { get; set; } = 1; public Dictionary Effects { get; } = new(StringComparer.Ordinal); - public CancellationTokenSource FadeCancellation { get; } = new(); + private CancellationTokenSource FadeCancellation { get; set; } = new(); + public CancellationToken BeginFade() + { + FadeCancellation.Cancel(); + FadeCancellation.Dispose(); + FadeCancellation = new CancellationTokenSource(); + return FadeCancellation.Token; + } public void Dispose() { FadeCancellation.Cancel(); @@ -259,4 +357,5 @@ public void Dispose() } private sealed record ActiveSEffect(string Id, System.Text.Json.JsonElement Parameters); + private sealed record PendingLoad(long Generation, CancellationTokenSource Cancellation); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json b/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json index 4b4e537..c24ec6c 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json +++ b/src/Presentation/GalNet.Avalonia.GameView/packages.lock.json @@ -168,13 +168,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "Avalonia.Skia": { "type": "CentralTransitive", "requested": "[12.0.5, )", diff --git a/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj b/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj deleted file mode 100644 index 9796020..0000000 --- a/src/Presentation/GalNet.Presentation.Defaults/GalNet.Presentation.Defaults.csproj +++ /dev/null @@ -1,12 +0,0 @@ - - - net10.0 - enable - enable - - - - - - - diff --git a/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs b/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs deleted file mode 100644 index 055cdfd..0000000 --- a/src/Presentation/GalNet.Presentation.Defaults/Media/LibVlcAudioController.cs +++ /dev/null @@ -1,57 +0,0 @@ -using LibVLCSharp.Shared; -using LibVlcMedia = LibVLCSharp.Shared.Media; - -namespace GalNet.Presentation.Defaults.Media; - -/// Framework-neutral LibVLC audio controller for UI hosts that choose this backend. -public sealed class LibVlcAudioController : IDisposable -{ - private readonly LibVLC? _libVlc; - private readonly Dictionary _players = new(StringComparer.OrdinalIgnoreCase); - - public LibVlcAudioController(LibVLC? libVlc) => _libVlc = libVlc; - - public void Play(string channel, string assetId, float volume) - { - if (_libVlc is null) return; - var player = GetPlayer(channel); - player.Stop(); - using var media = new LibVlcMedia(_libVlc, assetId); - player.Play(media); - player.Volume = (int)(Math.Clamp(volume, 0, 1) * 100); - } - - public void Stop(string channel) => TryGetPlayer(channel)?.Stop(); - public void Pause(string channel) => TryGetPlayer(channel)?.Pause(); - public void Resume(string channel) => TryGetPlayer(channel)?.Play(); - - public void Enqueue(string channel, string assetId) - { - if (_libVlc is null) return; - GetPlayer(channel).Play(new LibVlcMedia(_libVlc, assetId)); - } - - public void StopAll() - { - foreach (var player in _players.Values) player.Stop(); - } - - public void Dispose() - { - foreach (var player in _players.Values) player.Dispose(); - _players.Clear(); - } - - private MediaPlayer GetPlayer(string channel) - { - if (_libVlc is null) throw new InvalidOperationException("LibVLC is unavailable."); - if (!_players.TryGetValue(channel, out var player)) - { - player = new MediaPlayer(_libVlc); - _players[channel] = player; - } - return player; - } - - private MediaPlayer? TryGetPlayer(string channel) => _players.GetValueOrDefault(channel); -} diff --git a/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs b/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs deleted file mode 100644 index c2baa6a..0000000 --- a/src/Presentation/GalNet.Presentation.Defaults/NullGameView.cs +++ /dev/null @@ -1,19 +0,0 @@ -using GalNet.Core.Entry; -using GalNet.Core.Primitives; -using GalNet.Core.Runtime; -using GalNet.Presentation.Abstractions.View; - -namespace GalNet.Presentation.Defaults; - -/// A headless view with no mounted primitive capabilities. -public class NullGameView : IGameView -{ - public IReadOnlyCollection Primitives => []; - public bool TryGetEntry(string primitiveType, out PrimitiveEntryBase? entry) - { - entry = null; - return false; - } - public PrimitiveInstance? Dispatch(PrimitiveEntry entry, IGameRuntime runtime, CancellationToken cancellationToken) => null; - public virtual void Dispose() { } -} diff --git a/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json b/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json deleted file mode 100644 index 0504907..0000000 --- a/src/Presentation/GalNet.Presentation.Defaults/packages.lock.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "LibVLCSharp": { - "type": "Direct", - "requested": "[3.10.0, )", - "resolved": "3.10.0", - "contentHash": "VHuOfq8Xtb/lNz0asvCm5qDjgTmfB3m3lC40Dz9iH9oL3DM0fSXHjZaLxdtYVr5nsldJoh5BTeG8MtODprhC9Q==" - }, - "galnet.core": { - "type": "Project" - }, - "galnet.presentation.abstractions": { - "type": "Project", - "dependencies": { - "GalNet.Core": "[1.0.0, )" - } - } - } - } -} \ No newline at end of file diff --git a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs index 6de3559..73bb50f 100644 --- a/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs +++ b/src/Samples/GalNet.Sample.Avalonia/App.axaml.cs @@ -10,6 +10,7 @@ using GalNet.Avalonia.GameView.Page; using GalNet.Avalonia.GameView.Services; using GalNet.Avalonia.GameView.ViewModels; +using GalNet.Runtime.Logging; using GalNet.Sample.Avalonia.Debug; using GalNet.Sample.Avalonia.Services; using GalNet.Sample.Avalonia.Views; @@ -21,7 +22,7 @@ public partial class App : Application { internal static GameLaunchOptions LaunchOptions { get; set; } = new(null, null); private ServiceProvider? _rootServices; - private IServiceScope? _gameScope; + private AsyncServiceScope? _gameScope; public override void Initialize() { @@ -40,8 +41,8 @@ public override void OnFrameworkInitializationCompleted() .AddTransient(); _rootServices = services.BuildServiceProvider(); - _gameScope = _rootServices.CreateScope(); - var scopedServices = _gameScope.ServiceProvider; + _gameScope = _rootServices.CreateAsyncScope(); + var scopedServices = _gameScope.Value.ServiceProvider; scopedServices.GetRequiredService().ResetTo(NavigationTransition.None); @@ -60,21 +61,28 @@ public override void OnFrameworkInitializationCompleted() closingInProgress = true; try { - await scopedServices.GetRequiredService().StopAsync(); + try + { + await scopedServices.GetRequiredService().StopAsync(); + } + finally + { + try { if (_gameScope is { } scope) await scope.DisposeAsync(); } + finally + { + _gameScope = null; + if (_rootServices is not null) await _rootServices.DisposeAsync(); + _rootServices = null; + } + } } + catch (Exception error) { GameLog.Logger.Error(error, "Game host shutdown failed"); } finally { allowClose = true; mainWindow.Close(); } }; - mainWindow.Closed += (_, _) => - { - _gameScope?.Dispose(); - _rootServices?.Dispose(); - _gameScope = null; - _rootServices = null; - }; } base.OnFrameworkInitializationCompleted(); diff --git a/src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.csproj b/src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.csproj index 56ee705..d145c81 100644 --- a/src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.csproj +++ b/src/Samples/GalNet.Sample.Avalonia/GalNet.Sample.Avalonia.csproj @@ -40,7 +40,6 @@ - diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs index bc971bb..7b91091 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs @@ -20,6 +20,7 @@ internal sealed class SampleGameRunner : IAsyncDisposable { private readonly GamePageViewModel _gameplay; private readonly GameEngine _engine; + private readonly CompositeGameView _gameView; private readonly AvaloniaGamePageView _pageView; private readonly SampleLayerFactory _layers; private readonly SampleMediaViews _media; @@ -38,6 +39,7 @@ internal sealed class SampleGameRunner : IAsyncDisposable private SampleGameRunner( GamePageViewModel gameplay, GameEngine engine, + CompositeGameView gameView, AvaloniaGamePageView pageView, SampleLayerFactory layers, SampleMediaViews media, @@ -51,6 +53,7 @@ private SampleGameRunner( { _gameplay = gameplay; _engine = engine; + _gameView = gameView; _pageView = pageView; _layers = layers; _media = media; @@ -106,7 +109,7 @@ public static async Task CreateAsync( effects = new AvaloniaEffectRuntime(gameplay, programs: programs); audio = new AvaloniaAudioPlaybackHost(resources.Assets); video = new AvaloniaVideoPresenter(resources.Assets, gameplay); - gameView = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + gameView = new GameRegistration().AddBuiltins( pageView.DialoguePresenter, pageView.LayerPresenter, pageView.AnimationPresenter, @@ -115,8 +118,9 @@ public static async Task CreateAsync( pageView.ParticlePresenter, pageView.NvlPresenter, audio, - video)); + video).BuildView(); var settings = new SettingsContainer(); + settings.Set(resources.Content.Settings); settings.Set(saveSession.Settings); var runtime = new GameRuntime( null, @@ -132,6 +136,7 @@ public static async Task CreateAsync( return new SampleGameRunner( gameplay, engine, + gameView, pageView, layers, media, @@ -146,13 +151,12 @@ public static async Task CreateAsync( catch { engine?.Dispose(); - if (engine is null) - gameView?.Dispose(); + if (gameView is not null) await gameView.DisposeAsync(); pageView?.Dispose(); layers?.Dispose(); media?.Dispose(); effects?.Dispose(); - audio?.Dispose(); + if (audio is not null) await audio.DisposeAsync(); if (video is not null) await video.DisposeAsync(); if (effects is null) programs?.Dispose(); @@ -201,11 +205,12 @@ public async ValueTask DisposeAsync() await _updateStateAsync(false, null); _pageView.AdvanceRequested -= OnAdvanceRequested; _engine.Dispose(); + await _gameView.DisposeAsync(); _pageView.Dispose(); _layers.Dispose(); _media.Dispose(); _effects.Dispose(); - _audio.Dispose(); + await _audio.DisposeAsync(); await _video.DisposeAsync(); _prepared = false; GameLog.Logger.Debug("Resetting scene presentation before creating the next game engine"); diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs index a954218..eda9bf7 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs @@ -1,7 +1,7 @@ using System.Collections.ObjectModel; -using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using GalNet.Avalonia.GameView.Page; +using GalNet.Avalonia.GameView.Presentation; using GalNet.Avalonia.GameView.Services; using GalNet.Avalonia.GameView.ViewModels; using GalNet.Core.Assets; @@ -14,7 +14,7 @@ namespace GalNet.Sample.Avalonia.Services; /// Sample host implementation; all game/file-system work stays outside page VMs. -internal sealed partial class SampleGameSessionService : ObservableObject, IGameSessionService, IPreparedGameSessionService, IGameGallerySession, IDisposable +internal sealed partial class SampleGameSessionService : ObservableObject, IGameSessionService, IPreparedGameSessionService, IGameGallerySession, IAsyncDisposable { private readonly GamePageViewModel _gameplay; private readonly GamePage _page; @@ -249,15 +249,15 @@ public async Task PrepareLoadAsync(int slotIndex, CancellationToken cancellation _gameplay.StatusMessage = $"Loaded slot {slotIndex}."; } - public void Dispose() + public async ValueTask DisposeAsync() { if (_disposed) return; - try { StopAsync().GetAwaiter().GetResult(); } + try { await StopAsync(); } finally { _disposed = true; _gameplay.InteractionObserved -= OnInteractionObserved; - DisposeRunnerAsync().GetAwaiter().GetResult(); + await DisposeRunnerAsync(); _saveSession?.Dispose(); _saveSession = null; GalleryResources = null; @@ -307,26 +307,7 @@ private async Task DisposeRunnerAsync() await runner.DisposeAsync(); } - private Task ResetScenePresentationAsync() - { - if (Dispatcher.UIThread.CheckAccess()) - { - _gameplay.ResetScenePresentation(); - return Task.CompletedTask; - } - - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Dispatcher.UIThread.Post(() => - { - try - { - _gameplay.ResetScenePresentation(); - completion.TrySetResult(); - } - catch (Exception exception) { completion.TrySetException(exception); } - }); - return completion.Task; - } + private Task ResetScenePresentationAsync() => OnUiAsync(_gameplay.ResetScenePresentation); private async Task EnsureRunnerAsync(CancellationToken cancellationToken) { @@ -381,20 +362,9 @@ private Task UpdateRunnerStateAsync(bool isPlaying, string? statusMessage) private static void OnInteractionObserved(string interaction) => GameLog.Logger.Information("Player interaction: {Interaction}", interaction); - private static Task OnUiAsync(Action action) + private static Task OnUiAsync(Action action) => AvaloniaUiDispatcher.Instance.InvokeAsync(() => { - if (Dispatcher.UIThread.CheckAccess()) - { - action(); - return Task.CompletedTask; - } - - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - Dispatcher.UIThread.Post(() => - { - try { action(); completion.SetResult(); } - catch (Exception exception) { completion.SetException(exception); } - }); - return completion.Task; - } + action(); + return Task.CompletedTask; + }); } diff --git a/src/Samples/GalNet.Sample.Avalonia/packages.lock.json b/src/Samples/GalNet.Sample.Avalonia/packages.lock.json index 7c8552b..503a087 100644 --- a/src/Samples/GalNet.Sample.Avalonia/packages.lock.json +++ b/src/Samples/GalNet.Sample.Avalonia/packages.lock.json @@ -324,7 +324,6 @@ "GalNet.Avalonia.Rendering": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "Irihi.Ursa": "[2.1.0, )", "LibVLCSharp": "[3.10.0, )", "LibVLCSharp.Avalonia": "[3.10.0, )", @@ -349,13 +348,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "galnet.primitives.builtins": { "type": "Project", "dependencies": { diff --git a/src/Samples/GalNet.Sample.Headless/HeadlessConsolePresentation.cs b/src/Samples/GalNet.Sample.Headless/HeadlessConsolePresentation.cs index a43f166..3794f7f 100644 --- a/src/Samples/GalNet.Sample.Headless/HeadlessConsolePresentation.cs +++ b/src/Samples/GalNet.Sample.Headless/HeadlessConsolePresentation.cs @@ -28,7 +28,7 @@ public Task AnimateAsync(AnimationRequest request, Cancellatio Console.WriteLine($"[Animation] {request.PlaybackHandleId}: {request.HandleId}.{request.Property} -> {request.To}"); return Task.FromResult(AnimationOutcome.Completed); } - public Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken) + public Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken, Func? beforeFrame = null) { Console.WriteLine($"[Animation] plan {plan.PlaybackHandleId}: {plan.Tracks.Count} track(s), {plan.DurationFrames} frame(s)"); return Task.FromResult(new AnimationPlanPlayResult { Outcome = AnimationOutcome.Completed }); diff --git a/src/Samples/GalNet.Sample.Headless/Program.cs b/src/Samples/GalNet.Sample.Headless/Program.cs index 8f795a1..1cc4596 100644 --- a/src/Samples/GalNet.Sample.Headless/Program.cs +++ b/src/Samples/GalNet.Sample.Headless/Program.cs @@ -23,6 +23,7 @@ var galleryTypes = BuiltinGalleryTypes.CreateCatalog(resourceTypes); var content = await installation.CreateContentProvider(resourceTypes, galleryTypes).LoadAsync(); var settings = new SettingsContainer(); + settings.Set(content.Settings); settings.Set(new GameSettings()); var saves = new FileSaveService(options.ProfileDirectory); @@ -31,8 +32,8 @@ var progress = new FileGameProgressService(options.ProfileDirectory); var presentation = new ConsolePresentation(settings.Get()); - using IGameView view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( - presentation, presentation, presentation, presentation, content.Gallery, nvlPresenter: presentation)); + await using var view = new GameRegistration().AddBuiltins( + presentation, presentation, presentation, presentation, content.Gallery, nvlPresenter: presentation).BuildView(); var runtime = new GameRuntime(null, content.Graph.RootNodeId, settings, variables); using var engine = new GameEngine(content.Graph, runtime, view, progress, presentation); diff --git a/src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs b/src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs index 1cadb6e..c082119 100644 --- a/src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs +++ b/src/Shared/GalNet.Core/Compilation/GalgroupCompiler.cs @@ -51,6 +51,8 @@ public static CompiledGroupResult Compile(GroupDocument source, IEntryCatalog ca primitive.Id = compiled.Entries.Count + 1; primitive.Condition = CombineConditions(sourceEntry.Condition, primitive.Condition); var generatedId = $"{sourceEntry.Id}#{emittedIndex + 1}"; + var schema = catalog.Get(primitive.Type); + primitive.SetArguments(PrimitiveArgumentHelper.Normalize(schema.DynamicParameters!, primitive.Arguments)); compiled.Entries.Add(SerializeEntry(generatedId, primitive)); emittedIds.Add(generatedId); } diff --git a/src/Shared/GalNet.Core/Entry/EntryModule.cs b/src/Shared/GalNet.Core/Entry/EntryModule.cs index 18387dd..fd7d670 100644 --- a/src/Shared/GalNet.Core/Entry/EntryModule.cs +++ b/src/Shared/GalNet.Core/Entry/EntryModule.cs @@ -62,7 +62,7 @@ private static string ToPersistedValue(JsonElement value) => value.ValueKind == : value.GetRawText(); } -/// A Runtime-ready primitive definition with a per-invocation instance factory. +/// A primitive parameter contract. Execution is registered independently. public abstract class PrimitiveEntryBase : EntryBase { protected PrimitiveEntryBase( @@ -72,31 +72,20 @@ protected PrimitiveEntryBase( : base(name, parameters, category) { } - public abstract PrimitiveInstance CreateInstance(PrimitiveCreateContext context); - internal override EntryDefinition CreateDefinition() => BuildDefinition( () => new AuthoringPrimitiveEntry(Name), EntryKind.Primitive); } -/// Default primitive definition backed directly by a factory delegate. +/// A data-only primitive definition. public sealed class DefaultPrimitiveEntryBase : PrimitiveEntryBase { - private readonly Func _factory; - public DefaultPrimitiveEntryBase( string name, DynamicParameterTable parameters, - Func factory, string? category = null) : base(name, parameters, category) - { - ArgumentNullException.ThrowIfNull(factory); - _factory = factory; - } - - public override PrimitiveInstance CreateInstance(PrimitiveCreateContext context) => - _factory(context) ?? throw new InvalidOperationException($"Primitive '{Name}' returned no runtime instance."); + { } } /// An editor/compiler-only entry that expands into one or more primitives. @@ -133,13 +122,12 @@ public override CompositeEntry CreateEntry() => } /// A module owns independent frozen primitive and composite entry tables. -public interface IEntryModule : IDisposable +public interface IEntryModule { string Id { get; } IReadOnlyDictionary PrimitiveEntries { get; } IReadOnlyDictionary CompositeEntries { get; } - void IDisposable.Dispose() { } } /// Core helper that validates and freezes a module's two entry tables. @@ -164,8 +152,6 @@ protected EntryModuleBase( public IReadOnlyDictionary PrimitiveEntries { get; } public IReadOnlyDictionary CompositeEntries { get; } - public virtual void Dispose() { } - private static IReadOnlyDictionary Freeze(IEnumerable entries, string argumentName) where TEntry : EntryBase { diff --git a/src/Shared/GalNet.Core/Entry/EntrySchema.cs b/src/Shared/GalNet.Core/Entry/EntrySchema.cs index 628a7e1..51085cd 100644 --- a/src/Shared/GalNet.Core/Entry/EntrySchema.cs +++ b/src/Shared/GalNet.Core/Entry/EntrySchema.cs @@ -18,7 +18,8 @@ public static IReadOnlyDictionary> Options(params public static DynamicParameterTable DynamicParameters( IReadOnlyDictionary parameters, IReadOnlyDictionary? defaults = null, - IReadOnlyDictionary>? options = null) + IReadOnlyDictionary>? options = null, + Action? validateArguments = null) { ArgumentNullException.ThrowIfNull(parameters); return new DynamicParameterTable(parameters.Select(pair => @@ -31,7 +32,7 @@ public static DynamicParameterTable DynamicParameters( ? DynamicParameterValue.FromEditorValue(value, GetValueType(pair.Value)) : null, constraints: constraints); - })); + }), validateArguments); } public static EntryParameterType GetEditorType(DynamicParameterDescriptor parameter) diff --git a/src/Shared/GalNet.Core/Entry/PrimitiveArgumentHelper.cs b/src/Shared/GalNet.Core/Entry/PrimitiveArgumentHelper.cs index 729c2c0..cf38a3b 100644 --- a/src/Shared/GalNet.Core/Entry/PrimitiveArgumentHelper.cs +++ b/src/Shared/GalNet.Core/Entry/PrimitiveArgumentHelper.cs @@ -31,7 +31,9 @@ public static JsonElement Normalize(DynamicParameterTable parameters, JsonElemen throw new InvalidDataException($"Primitive requires parameter '{parameter.Name}'."); } - return JsonSerializer.SerializeToElement(normalized); + var result = JsonSerializer.SerializeToElement(normalized); + parameters.ValidateArguments(result); + return result; } public static (JsonElement Arguments, string? BatchId) NormalizeCompiledArguments( diff --git a/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs b/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs index 6102ef4..ae75197 100644 --- a/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs +++ b/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs @@ -18,13 +18,9 @@ public TargetProfileEntryCatalog(IEnumerable modules) ArgumentNullException.ThrowIfNull(modules); var definitions = new Dictionary(StringComparer.Ordinal); - var moduleIds = new HashSet(StringComparer.Ordinal); foreach (var module in modules) { ArgumentNullException.ThrowIfNull(module); - if (!moduleIds.Add(module.Id)) - throw new InvalidOperationException($"Entry module catalog '{module.Id}' is already present in this target profile."); - foreach (var entry in module.PrimitiveEntries.Values) { var definition = entry.CreateDefinition(); diff --git a/src/Shared/GalNet.Core/Primitives/DynamicParameterTable.cs b/src/Shared/GalNet.Core/Primitives/DynamicParameterTable.cs index 10c1caa..f16e5be 100644 --- a/src/Shared/GalNet.Core/Primitives/DynamicParameterTable.cs +++ b/src/Shared/GalNet.Core/Primitives/DynamicParameterTable.cs @@ -48,10 +48,12 @@ public bool TryGetConstraint(string name, out JsonElement value) public sealed class DynamicParameterTable : IReadOnlyDictionary { private readonly IReadOnlyDictionary _parameters; + private readonly Action? _validateArguments; - public DynamicParameterTable(IEnumerable parameters) + public DynamicParameterTable(IEnumerable parameters, Action? validateArguments = null) { ArgumentNullException.ThrowIfNull(parameters); + _validateArguments = validateArguments; var copy = new Dictionary(StringComparer.Ordinal); foreach (var parameter in parameters) { @@ -68,6 +70,7 @@ public DynamicParameterTable(IEnumerable parameters) public int Count => _parameters.Count; public DynamicParameterDescriptor this[string key] => _parameters[key]; public bool ContainsKey(string key) => _parameters.ContainsKey(key); + public void ValidateArguments(JsonElement arguments) => _validateArguments?.Invoke(arguments); public bool TryGetValue(string key, out DynamicParameterDescriptor value) => _parameters.TryGetValue(key, out value!); public IEnumerator> GetEnumerator() => _parameters.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); diff --git a/src/Shared/GalNet.Core/Primitives/PrimitiveCreateContext.cs b/src/Shared/GalNet.Core/Primitives/PrimitiveCreateContext.cs index 11516a4..9f6e67a 100644 --- a/src/Shared/GalNet.Core/Primitives/PrimitiveCreateContext.cs +++ b/src/Shared/GalNet.Core/Primitives/PrimitiveCreateContext.cs @@ -8,7 +8,7 @@ namespace GalNet.Core.Primitives; public sealed class PrimitiveCreateContext { public PrimitiveCreateContext( - PrimitiveEntryBase definition, + EntryDefinition definition, PrimitiveEntry entry, IGameRuntime runtime, CancellationToken scopeCancellation) @@ -22,11 +22,11 @@ public PrimitiveCreateContext( ScopeCancellation = scopeCancellation; } - public PrimitiveEntryBase Definition { get; } + public EntryDefinition Definition { get; } public PrimitiveEntry Entry { get; } public IGameRuntime Runtime { get; } public CancellationToken ScopeCancellation { get; } - public DynamicParameterTable Parameters => Definition.Parameters; + public DynamicParameterTable Parameters => Definition.DynamicParameters!; public JsonElement Arguments => Entry.Arguments; public string? BatchId => Entry.BatchId; } diff --git a/src/Shared/GalNet.Core/Runtime/GameSnapshot.cs b/src/Shared/GalNet.Core/Runtime/GameSnapshot.cs index 5ade241..a5f7728 100644 --- a/src/Shared/GalNet.Core/Runtime/GameSnapshot.cs +++ b/src/Shared/GalNet.Core/Runtime/GameSnapshot.cs @@ -5,7 +5,7 @@ namespace GalNet.Core.Runtime; /// Persisted runtime state needed to resume a game and rebuild its visible scene. public sealed class GameSnapshot { - public const int CurrentFormatVersion = 2; + public const int CurrentFormatVersion = 3; /// Persisted save format version. Older formats are intentionally rejected. public int Version { get; init; } = CurrentFormatVersion; diff --git a/src/Shared/GalNet.Core/Runtime/IGameRuntime.cs b/src/Shared/GalNet.Core/Runtime/IGameRuntime.cs index 05a4fbb..ed2f9d3 100644 --- a/src/Shared/GalNet.Core/Runtime/IGameRuntime.cs +++ b/src/Shared/GalNet.Core/Runtime/IGameRuntime.cs @@ -43,10 +43,6 @@ public interface IGameRuntime /// 标记游戏结束。 void EndGame(); - // ── 调用栈(call/return)── - void PushCallStack(string nodeId); - (string nodeId, int entryIndex)? PopCallStack(); - // ── 变量操作 ── void SetVariable(string name, object value); GalNet.Core.Variable.Variable? GetVariable(string name); diff --git a/src/Shared/GalNet.Core/Scene/Layer.cs b/src/Shared/GalNet.Core/Scene/Layer.cs index 045111c..6af90ff 100644 --- a/src/Shared/GalNet.Core/Scene/Layer.cs +++ b/src/Shared/GalNet.Core/Scene/Layer.cs @@ -47,12 +47,6 @@ public sealed class Layer : AnimatableSceneInstance public float Opacity { get; set; } = 1; - /// - /// Effect instance handles attached to this layer. The effect itself remains the - /// canonical owner of its target; this list is the layer-local rendering index. - /// - public List EffectInstanceIds { get; set; } = []; - public override IReadOnlyList AnimatableProperties => AnimationProperties; /// Returns the image region selected for the current render frame. diff --git a/src/Shared/GalNet.Core/Scene/SceneState.cs b/src/Shared/GalNet.Core/Scene/SceneState.cs index baee3b4..be01fa8 100644 --- a/src/Shared/GalNet.Core/Scene/SceneState.cs +++ b/src/Shared/GalNet.Core/Scene/SceneState.cs @@ -8,12 +8,6 @@ public sealed class SceneState /// 当前活跃的 Layer 列表 public List Layers { get; init; } = []; - /// 当前活跃的控件实例 ID 列表 - public List ActiveControlIds { get; init; } = []; - - /// 当前活跃的特效实例 ID 列表 - public List ActiveEffectIds { get; init; } = []; - /// Replayable state for effects that remain active across a stable save. public List ActiveEffects { get; init; } = []; diff --git a/src/Shared/GalNet.Core/Services/IAudioService.cs b/src/Shared/GalNet.Core/Services/IAudioService.cs deleted file mode 100644 index 6539de9..0000000 --- a/src/Shared/GalNet.Core/Services/IAudioService.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace GalNet.Core.Services; - -/// -/// Audio playback service. -/// -/// Channel convention: -/// 0 = BGM 1 = BGM2 2 = Voice 3..N = SFX (count configurable) -/// -public interface IAudioService -{ - void Play(int channel, string assetId, float volume = 1f, string mode = "once", int times = 1); - void Stop(int channel); - void Pause(int channel); - void Resume(int channel); - void Enqueue(int channel, string assetId, int times = 1); - - float GetVolume(int channel); - void SetVolume(int channel, float volume); - void SetMasterVolume(float volume); -} diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs b/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs index 522e153..86a80fa 100644 --- a/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs +++ b/src/Shared/GalNet.Presentation.Abstractions/View/CompositeGameView.cs @@ -1,91 +1,81 @@ using GalNet.Core.Entry; using GalNet.Core.Primitives; using GalNet.Core.Runtime; +using GalNet.Presentation.Abstractions.Execution; namespace GalNet.Presentation.Abstractions.View; -/// Default game-scope mount for primitive entry modules. +/// Dispatches validated instructions to independently registered execution modules. public sealed class CompositeGameView : IGameView { - private readonly IReadOnlyList _modules; - private readonly IReadOnlyDictionary _entries; + private readonly IEntryCatalog _catalog; + private readonly IReadOnlyList _modules; + private readonly Dictionary _instructions = new(StringComparer.Ordinal); private bool _disposed; - public CompositeGameView(IEnumerable modules) + public CompositeGameView(IEntryCatalog catalog, IEnumerable modules) { + ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(modules); - (_modules, _entries) = FreezeModules(modules); - Primitives = Array.AsReadOnly(_entries.Values.ToArray()); + _catalog = catalog; + _modules = modules.ToArray(); + foreach (var module in _modules) + foreach (var instruction in module.Instructions.Values) + { + if (!catalog.TryGet(instruction.Type, out var schema) || schema.Kind != EntryKind.Primitive) + throw new InvalidOperationException($"Instruction '{instruction.Type}' has no primitive schema."); + if (!_instructions.TryAdd(instruction.Type, instruction)) + throw new InvalidOperationException($"Instruction '{instruction.Type}' is already registered."); + } + Primitives = Array.AsReadOnly(catalog.Definitions.Where(definition => definition.Kind == EntryKind.Primitive).ToArray()); } - public IReadOnlyCollection Primitives { get; } + public IReadOnlyCollection Primitives { get; } + public bool TryGetEntry(string primitiveType, out EntryDefinition? entry) => _catalog.TryGet(primitiveType, out entry!); + public bool CanExecute(string primitiveType) => !_disposed && _instructions.ContainsKey(primitiveType); - public bool TryGetEntry(string primitiveType, out PrimitiveEntryBase? entry) => - _entries.TryGetValue(primitiveType, out entry); - - public PrimitiveInstance? Dispatch( - PrimitiveEntry entry, - IGameRuntime runtime, - CancellationToken cancellationToken) + public PrimitiveInstance Dispatch(PrimitiveEntry entry, IGameRuntime runtime, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(entry); ArgumentNullException.ThrowIfNull(runtime); - ThrowIfDisposed(); - if (!_entries.TryGetValue(entry.Type, out var definition)) - return null; - - var normalizedEntry = new PrimitiveEntry( - entry.Type, - PrimitiveArgumentHelper.Normalize(definition.Parameters, entry.Arguments), - entry.BatchId) + ObjectDisposedException.ThrowIf(_disposed, this); + if (!entry.IsGeneric) + throw new InvalidOperationException($"Instruction '{entry.Type}' must be compiled before dispatch."); + if (!_instructions.TryGetValue(entry.Type, out var implementation)) + throw new InvalidOperationException($"Instruction '{entry.Type}' has no registered implementation."); + var definition = _catalog.Get(entry.Type); + var normalized = new PrimitiveEntry(entry.Type, + PrimitiveArgumentHelper.Normalize(definition.DynamicParameters!, entry.Arguments), entry.BatchId) { Id = entry.Id, Condition = entry.Condition }; - var context = new PrimitiveCreateContext(definition, normalizedEntry, runtime, cancellationToken); - var instance = definition.CreateInstance(context); - if (!string.Equals(instance.BatchId, normalizedEntry.BatchId, StringComparison.Ordinal)) - throw new InvalidOperationException( - $"Primitive '{entry.Type}' created an instance with a different BatchId."); - + var instance = implementation.CreateInstance(new PrimitiveCreateContext(definition, normalized, runtime, cancellationToken)); + if (!string.Equals(instance.BatchId, normalized.BatchId, StringComparison.Ordinal)) + throw new InvalidOperationException($"Instruction '{entry.Type}' created an instance with a different BatchId."); instance.Dispatch(); return instance; } public void Dispose() { - if (_disposed) - return; + if (_disposed) return; _disposed = true; - - List? exceptions = null; + List? errors = null; foreach (var module in _modules.Reverse()) - { try { module.Dispose(); } - catch (Exception exception) { (exceptions ??= []).Add(exception); } - } - if (exceptions is { Count: > 0 }) - throw new AggregateException(exceptions); + catch (Exception error) { (errors ??= []).Add(error); } + if (errors is not null) throw new AggregateException(errors); } - private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_disposed, this); - - private static (IReadOnlyList Modules, IReadOnlyDictionary Entries) - FreezeModules(IEnumerable modules) + public async ValueTask DisposeAsync() { - var moduleList = new List(); - var moduleIds = new HashSet(StringComparer.Ordinal); - var entries = new Dictionary(StringComparer.Ordinal); - foreach (var module in modules) - { - ArgumentNullException.ThrowIfNull(module); - if (!moduleIds.Add(module.Id)) - throw new InvalidOperationException($"Entry module '{module.Id}' is already mounted."); - moduleList.Add(module); - foreach (var entry in module.PrimitiveEntries.Values) - if (!entries.TryAdd(entry.Name, entry)) - throw new InvalidOperationException($"Primitive entry '{entry.Name}' is already mounted."); - } - return (moduleList.AsReadOnly(), new System.Collections.ObjectModel.ReadOnlyDictionary(entries)); + if (_disposed) return; + _disposed = true; + List? errors = null; + foreach (var module in _modules.Reverse()) + try { await module.DisposeAsync().ConfigureAwait(false); } + catch (Exception error) { (errors ??= []).Add(error); } + if (errors is not null) throw new AggregateException(errors); } } diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/IAnimationPresenter.cs b/src/Shared/GalNet.Presentation.Abstractions/View/IAnimationPresenter.cs index d4c2ab0..491fd33 100644 --- a/src/Shared/GalNet.Presentation.Abstractions/View/IAnimationPresenter.cs +++ b/src/Shared/GalNet.Presentation.Abstractions/View/IAnimationPresenter.cs @@ -6,6 +6,8 @@ namespace GalNet.Presentation.Abstractions.View; public interface IAnimationPresenter { Task AnimateAsync(AnimationRequest request, CancellationToken cancellationToken); - Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken); + /// Await beforeFrame with monotonically increasing elapsed frames before sampling each frame. + /// The callback owns event dispatch; the presenter owns timing and must publish the terminal frame before normal completion. + Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken, Func? beforeFrame = null); bool CompleteAnimationImmediately(string playbackHandleId); } diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/IGameView.cs b/src/Shared/GalNet.Presentation.Abstractions/View/IGameView.cs index ef7acb5..830ef70 100644 --- a/src/Shared/GalNet.Presentation.Abstractions/View/IGameView.cs +++ b/src/Shared/GalNet.Presentation.Abstractions/View/IGameView.cs @@ -5,16 +5,24 @@ namespace GalNet.Presentation.Abstractions.View; /// Resolves and dispatches one compiled primitive call. -public interface IGameView : IDisposable +public interface IGameView : IDisposable, IAsyncDisposable { - IReadOnlyCollection Primitives { get; } + IReadOnlyCollection Primitives { get; } - bool TryGetEntry(string primitiveType, out PrimitiveEntryBase? entry); + bool TryGetEntry(string primitiveType, out EntryDefinition? entry); - PrimitiveInstance? Dispatch( + bool CanExecute(string primitiveType); + + PrimitiveInstance Dispatch( PrimitiveEntry entry, IGameRuntime runtime, CancellationToken cancellationToken); - void IDisposable.Dispose() { } + void IDisposable.Dispose() => GC.SuppressFinalize(this); + ValueTask IAsyncDisposable.DisposeAsync() + { + Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } } diff --git a/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs index 0286b38..c6a713c 100644 --- a/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs @@ -3,6 +3,7 @@ using GalNet.Core.Runtime; using GalNet.Core.Scene; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; namespace GalNet.Primitives.Builtins; @@ -19,7 +20,11 @@ public sealed class AnimationPlanPrimitiveInstance : PrimitiveInstance private readonly IndexedPlanEvent[] _events; private readonly bool[] _notifiedEvents; private readonly CancellationTokenSource _presentationCancellation = new(); + private readonly SemaphoreSlim _notificationGate = new(1, 1); + private bool _skipRequested; private bool _skippable; + private AnimationPlaybackInstance? _playback; + private long _eventCycle; public AnimationPlanPrimitiveInstance( IGameRuntime runtime, @@ -61,8 +66,10 @@ public override bool IsSkippable protected override void OnDispatch() { BuiltinRuntimeActions.ApplyPlanStableState(_runtime, _plan); + _runtime.SceneInstances.TryGet(_plan.PlaybackHandleId, out _playback!); if (_animationPresenter is null) { + _presentationCancellation.Dispose(); TryComplete(); return; } @@ -77,13 +84,13 @@ protected override void OnSkip() if (!_skippable || IsCompleted) return; _skippable = false; + _skipRequested = true; + _presentationCancellation.Cancel(); } - _presentationCancellation.Cancel(); _animationPresenter?.CompleteAnimationImmediately(_plan.PlaybackHandleId); BuiltinRuntimeActions.ApplyPlanTerminalState(_runtime, _plan); - _ = NotifyRemainingEventsAsync(CancellationToken.None); - TryComplete(); + _ = CompleteSkipAsync(); } private async Task ObservePresentationAsync() @@ -94,24 +101,23 @@ private async Task ObservePresentationAsync() _scopeCancellation, _presentationCancellation.Token); await NotifyEventsThroughFrameAsync(0, linked.Token).ConfigureAwait(false); - var animation = _animationPresenter!.PlayAnimationPlanAsync(_plan, linked.Token); - var events = NotifyFutureEventsAsync(linked.Token); - await animation.ConfigureAwait(false); - if (_plan.LoopMode != AnimationLoopMode.Once) - _presentationCancellation.Cancel(); - await events.ConfigureAwait(false); + await _animationPresenter!.PlayAnimationPlanAsync( + _plan, linked.Token, elapsedFrames => NotifyPlaybackEventsAsync(elapsedFrames, linked.Token)).ConfigureAwait(false); } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested || _presentationCancellation.IsCancellationRequested) { } - catch - { } + catch (Exception error) + { GameLog.Logger.Warning(error, "Animation plan {PlaybackId} presentation failed", _plan.PlaybackHandleId); } finally { - BuiltinRuntimeActions.StopAnimationState(_runtime, _plan.PlaybackHandleId); + if (_playback is not null && _runtime.SceneInstances.TryGet(_plan.PlaybackHandleId, out var current) && ReferenceEquals(current, _playback)) + BuiltinRuntimeActions.StopAnimationState(_runtime, _plan.PlaybackHandleId); lock (_gate) + { _skippable = false; - TryComplete(); - _presentationCancellation.Dispose(); + _presentationCancellation.Dispose(); + } + if (!_skipRequested) TryComplete(); } } @@ -121,40 +127,29 @@ private async Task NotifyEventsThroughFrameAsync(int frame, CancellationToken ca await NotifyEventAsync(item, cancellationToken).ConfigureAwait(false); } - private async Task NotifyFutureEventsAsync(CancellationToken cancellationToken) + private async Task CompleteSkipAsync() { - while (true) - { - var started = DateTimeOffset.UtcNow; - foreach (var item in _events.Where(item => item.Event.Frame > 0)) - { - var delay = TimeSpan.FromSeconds(item.Event.Frame / (double)Math.Max(1, _plan.FrameRate)) - - (DateTimeOffset.UtcNow - started); - if (delay > TimeSpan.Zero) - await Task.Delay(delay, cancellationToken).ConfigureAwait(false); - await NotifyEventAsync(item, cancellationToken).ConfigureAwait(false); - } - - if (_plan.LoopMode == AnimationLoopMode.Once) - return; - if (_plan.DurationFrames <= 0) - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); - return; - } - - var cycleFrames = _plan.LoopMode == AnimationLoopMode.PingPong - ? _plan.DurationFrames * 2d - : _plan.DurationFrames; - var remainder = TimeSpan.FromSeconds(cycleFrames / Math.Max(1, _plan.FrameRate)) - - (DateTimeOffset.UtcNow - started); - if (remainder > TimeSpan.Zero) - await Task.Delay(remainder, cancellationToken).ConfigureAwait(false); + try { await NotifyRemainingEventsAsync(_scopeCancellation).ConfigureAwait(false); } + catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } + finally { TryComplete(); } + } - lock (_gate) - Array.Clear(_notifiedEvents); - await NotifyEventsThroughFrameAsync(0, cancellationToken).ConfigureAwait(false); + // The presenter supplies its own elapsed frame count before sampling tracks. + // Events and values therefore share one monotonic playback clock, including stalls. + private async Task NotifyPlaybackEventsAsync(double elapsedFrames, CancellationToken cancellationToken) + { + var cycleFrames = _plan.LoopMode == AnimationLoopMode.PingPong + ? _plan.DurationFrames * 2d : _plan.DurationFrames; + var cycle = _plan.LoopMode == AnimationLoopMode.Once || cycleFrames <= 0 + ? 0 : (long)(elapsedFrames / cycleFrames); + for (; _eventCycle < cycle; _eventCycle++) + { + await NotifyEventsThroughFrameAsync(_plan.DurationFrames, cancellationToken).ConfigureAwait(false); + lock (_gate) Array.Clear(_notifiedEvents); } + var frame = _plan.LoopMode == AnimationLoopMode.Once || cycleFrames <= 0 + ? elapsedFrames : elapsedFrames % cycleFrames; + await NotifyEventsThroughFrameAsync((int)Math.Min(_plan.DurationFrames, frame), cancellationToken).ConfigureAwait(false); } private async Task NotifyRemainingEventsAsync(CancellationToken cancellationToken) @@ -169,28 +164,32 @@ private async Task NotifyRemainingEventsAsync(CancellationToken cancellationToke private async Task NotifyEventAsync(IndexedPlanEvent item, CancellationToken cancellationToken) { - lock (_gate) - { - if (_notifiedEvents[item.Index]) - return; - _notifiedEvents[item.Index] = true; - } - + await _notificationGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await BuiltinRuntimeActions.NotifyPlanEventAsync( - item.Event, - _layerPresenter, - _effectPresenter, - _particlePresenter, - cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + if (_notifiedEvents[item.Index]) return; + _notifiedEvents[item.Index] = true; + } + try + { + await BuiltinRuntimeActions.NotifyPlanEventAsync( + item.Event, _layerPresenter, _effectPresenter, _particlePresenter, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + lock (_gate) _notifiedEvents[item.Index] = false; + throw; + } + catch (Exception error) + { + GameLog.Logger.Warning(error, "Animation plan event {EntryType} presentation failed", item.Event.Type); + } } - catch - { } + finally { _notificationGate.Release(); } } private sealed record IndexedPlanEvent(int Index, AnimationPlanEventDefinition Event); diff --git a/src/Shared/GalNet.Primitives.Builtins/AnimationPrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/AnimationPrimitiveInstance.cs index 6b1f552..909245e 100644 --- a/src/Shared/GalNet.Primitives.Builtins/AnimationPrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/AnimationPrimitiveInstance.cs @@ -2,6 +2,7 @@ using GalNet.Core.Runtime; using GalNet.Core.Scene; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; namespace GalNet.Primitives.Builtins; @@ -13,6 +14,7 @@ public sealed class AnimationPrimitiveInstance : PrimitiveInstance private readonly AnimationRequest _request; private readonly CancellationToken _scopeCancellation; private bool _skippable; + private AnimationPlaybackInstance? _playback; public AnimationPrimitiveInstance( IGameRuntime runtime, @@ -42,6 +44,7 @@ public override bool IsSkippable protected override void OnDispatch() { BuiltinRuntimeActions.ApplyAnimationStableState(_runtime, _request); + _runtime.SceneInstances.TryGet(_request.PlaybackHandleId, out _playback!); if (_presenter is null) { TryComplete(); @@ -73,11 +76,11 @@ private async Task ObservePresentationAsync() } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch - { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { - BuiltinRuntimeActions.StopAnimationState(_runtime, _request.PlaybackHandleId); + if (_playback is not null && _runtime.SceneInstances.TryGet(_request.PlaybackHandleId, out var current) && ReferenceEquals(current, _playback)) + BuiltinRuntimeActions.StopAnimationState(_runtime, _request.PlaybackHandleId); lock (_gate) _skippable = false; TryComplete(); diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinEntryModules.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinEntryModules.cs deleted file mode 100644 index 8aabbc0..0000000 --- a/src/Shared/GalNet.Primitives.Builtins/BuiltinEntryModules.cs +++ /dev/null @@ -1,445 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using GalNet.Core.Audio; -using GalNet.Core.Entry; -using GalNet.Core.Gallery; -using GalNet.Core.Primitives; -using GalNet.Core.Scene; -using GalNet.Core.Settings; -using GalNet.Presentation.Abstractions.Audio; -using GalNet.Presentation.Abstractions.View; -using GalNet.Runtime.Audio; - -namespace GalNet.Primitives.Builtins; - -/// Recommended schemas plus the small runtime modules implemented by this feature. -public static class BuiltinEntryModules -{ - public static IReadOnlyList CreateRecommended( - IDialoguePresenter? dialoguePresenter = null, - ILayerPresenter? layerPresenter = null, - IAnimationPresenter? animationPresenter = null, - IEffectPresenter? effectPresenter = null, - GalleryCatalog? gallery = null, - IParticlePresenter? particlePresenter = null, - INvlPresenter? nvlPresenter = null, - IAudioPlaybackHost? audioHost = null, - IVideoPresenter? videoPresenter = null) => Array.AsReadOnly( - [ - new BuiltinDialogueModule(dialoguePresenter), - new BuiltinNvlModule(nvlPresenter), - new BuiltinLayerModule(layerPresenter), - new BuiltinAnimationModule(animationPresenter, layerPresenter, effectPresenter, particlePresenter), - new BuiltinAudioModule(audioHost), - new BuiltinVideoModule(videoPresenter), - new BuiltinEffectModule(effectPresenter), - new BuiltinParticleModule(particlePresenter), - new BuiltinFlowModule(), - new BuiltinVariableModule(), - new BuiltinGalleryModule(gallery) - ]); - - public static TargetProfileEntryCatalog CreateRecommendedTargetProfile() => new(CreateRecommended()); -} - -public sealed class BuiltinDialogueModule : EntryModuleBase -{ - public BuiltinDialogueModule(IDialoguePresenter? presenter = null) : base("dialogue", - [ - Text(presenter), - Visibility(presenter, true), - Visibility(presenter, false) - ]) - { } - - private static PrimitiveEntryBase Text(IDialoguePresenter? presenter) => new DefaultPrimitiveEntryBase( - TextEntry.TypeId, - EntrySchema.DynamicParameters(TextEntry.ParameterTypes, TextEntry.DefaultValues), - context => new DialoguePrimitiveInstance( - presenter ?? throw new InvalidOperationException("The dialogue module has no presenter."), - Arguments.String(context, "speaker"), - Arguments.String(context, "content"), - context.BatchId, - context.ScopeCancellation)); - - private static PrimitiveEntryBase Visibility(IDialoguePresenter? presenter, bool visible) - where TEntry : PrimitiveEntry, new() => new DefaultPrimitiveEntryBase( - new TEntry().Type, - DynamicParameterTable.Empty, - context => new ImmediatePrimitiveInstance(() => - { - var target = presenter ?? throw new InvalidOperationException("The dialogue module has no presenter."); - if (visible) target.ShowDialogue(); else target.HideDialogue(); - }, batchId: context.BatchId)); -} - -public sealed class BuiltinNvlModule : EntryModuleBase -{ - public BuiltinNvlModule(INvlPresenter? presenter = null) : base("nvl", - [ - Visibility(presenter, static target => target.ShowNvl()), - Visibility(presenter, static target => target.HideNvl()), - Visibility(presenter, static target => target.ClearNvl()), - new DefaultPrimitiveEntryBase( - AppendNvlEntry.TypeId, - EntrySchema.DynamicParameters(AppendNvlEntry.ParameterTypes), - context => new NvlAppendPrimitiveInstance( - presenter ?? throw new InvalidOperationException("The NVL module has no presenter."), - Arguments.String(context, "content"), - context.BatchId, - context.ScopeCancellation)) - ]) - { } - - private static PrimitiveEntryBase Visibility(INvlPresenter? presenter, Action operation) - where TEntry : PrimitiveEntry, new() => new DefaultPrimitiveEntryBase( - new TEntry().Type, - DynamicParameterTable.Empty, - context => new ImmediatePrimitiveInstance(() => operation( - presenter ?? throw new InvalidOperationException("The NVL module has no presenter.")), batchId: context.BatchId)); -} - -public sealed class BuiltinLayerModule : EntryModuleBase -{ - public BuiltinLayerModule(ILayerPresenter? presenter = null) : base("layer", - [ - Primitive(ShowLayerEntry.ParameterTypes, ShowLayerEntry.DefaultValues, ShowLayerEntry.ParameterOptions, context => Show(context, presenter, null)), - Primitive(ShowColorLayerEntry.ParameterTypes, ShowColorLayerEntry.DefaultValues, factory: context => Show(context, presenter, Arguments.String(context, "color"))), - Primitive(HideLayerEntry.ParameterTypes, factory: context => Immediate(context, () => - BuiltinRuntimeActions.HideLayer(context.Runtime, presenter, Arguments.String(context, "handleId")))), - Primitive(MoveLayerEntry.ParameterTypes, MoveLayerEntry.DefaultValues, factory: context => Immediate(context, () => - BuiltinRuntimeActions.MoveLayer( - context.Runtime, - presenter, - Arguments.String(context, "handleId"), - Arguments.Json(context, "transform") ?? new LayerTransform(), - Arguments.Float(context, "z"), - Arguments.Float(context, "duration")))), - Primitive(ReplaceLayerEntry.ParameterTypes, factory: context => Immediate(context, () => - BuiltinRuntimeActions.ReplaceLayer( - context.Runtime, - presenter, - Arguments.String(context, "handleId"), - Arguments.String(context, "assetId")))) - ]) - { } - - private static PrimitiveInstance Show(PrimitiveCreateContext context, ILayerPresenter? presenter, string? color) => - Immediate(context, () => - { - var request = new LayerRenderRequest( - Arguments.String(context, "handleId"), - Arguments.String(context, "assetId"), - Arguments.Json(context, "transform") ?? new LayerTransform(), - Arguments.Float(context, "z"), - Arguments.Enum(context, "displayMode", LayerDisplayMode.Native), - Arguments.Float(context, "opacity", 1), - color, - Arguments.Json(context, "flipbook")?.Clone()); - BuiltinRuntimeActions.ShowLayer(context.Runtime, presenter, request); - }); - - private static PrimitiveEntryBase Primitive( - IReadOnlyDictionary parameters, - IReadOnlyDictionary? defaults = null, - IReadOnlyDictionary>? options = null, - Func? factory = null) - where TEntry : PrimitiveEntry, new() => - BuiltinEntrySchemas.Primitive(parameters, defaults, options, factory); - - private static ImmediatePrimitiveInstance Immediate(PrimitiveCreateContext context, Action action) => - new(action, batchId: context.BatchId); -} - -public sealed class BuiltinAnimationModule : EntryModuleBase -{ - public BuiltinAnimationModule( - IAnimationPresenter? animationPresenter = null, - ILayerPresenter? layerPresenter = null, - IEffectPresenter? effectPresenter = null, - IParticlePresenter? particlePresenter = null) : base("animation", - [ - BuiltinEntrySchemas.Primitive( - AnimateEntry.ParameterTypes, - AnimateEntry.DefaultValues, - AnimateEntry.ParameterOptions, - context => new AnimationPrimitiveInstance( - context.Runtime, - animationPresenter, - BuiltinRuntimeActions.CreateAnimationRequest(context), - context.BatchId, - context.ScopeCancellation)), - BuiltinEntrySchemas.Primitive( - PlayAnimationPlanEntry.ParameterTypes, - factory: context => new AnimationPlanPrimitiveInstance( - context.Runtime, - animationPresenter, - layerPresenter, - effectPresenter, - particlePresenter, - BuiltinRuntimeActions.CreateAnimationPlan(context), - context.BatchId, - context.ScopeCancellation)), - BuiltinEntrySchemas.Primitive( - StopAnimationEntry.ParameterTypes, - StopAnimationEntry.DefaultValues, - StopAnimationEntry.ParameterOptions, - context => new ImmediatePrimitiveInstance(() => - { - var playbackHandleId = Arguments.String(context, "playbackHandleId"); - var mode = Arguments.Enum(context, "mode", AnimationStopMode.AfterIteration); - if (context.Runtime.SceneInstances.TryGet(playbackHandleId, out var playback)) - playback.RequestStop(mode); - if (mode == AnimationStopMode.CompleteImmediately) - { - animationPresenter?.CompleteAnimationImmediately(playbackHandleId); - BuiltinRuntimeActions.StopAnimationState(context.Runtime, playbackHandleId); - } - }, batchId: context.BatchId)) - ], BuiltinTransitionEntries.Definitions) - { } -} - -public sealed class BuiltinAudioModule : EntryModuleBase -{ - private readonly AudioRuntime _runtime; - - public BuiltinAudioModule(IAudioPlaybackHost? host = null) : this(new AudioRuntime(host)) { } - - private BuiltinAudioModule(AudioRuntime runtime) : base("audio", CreatePrimitives(runtime), CreateComposites()) => _runtime = runtime; - - public override void Dispose() - { - _runtime.Dispose(); - base.Dispose(); - } - - private static IReadOnlyList Tracks(PrimitiveCreateContext context) => context.Runtime.Settings.Get().AudioTracks; - - private static IReadOnlyList CreatePrimitives(AudioRuntime runtime) => - [ - BuiltinEntrySchemas.Primitive(PlayAudioEntry.ParameterTypes, PlayAudioEntry.DefaultValues, PlayAudioEntry.ParameterOptions, context => Audio(context, () => runtime.Play(Tracks(context), new AudioPlayRequest(Arguments.String(context, "trackId"), Arguments.String(context, "asset"), Arguments.Enum(context, "placement", AudioPlacement.Enqueue), Arguments.Float(context, "gain", 1), Arguments.Float(context, "fadeIn"))))), - BuiltinEntrySchemas.Primitive(StopAudioEntry.ParameterTypes, StopAudioEntry.DefaultValues, StopAudioEntry.ParameterOptions, context => Audio(context, () => runtime.Stop(Tracks(context), new AudioStopRequest(Arguments.String(context, "trackId"), Arguments.Bool(context, "clearQueue", true), Arguments.Float(context, "fadeOut"))))), - BuiltinEntrySchemas.Primitive(PauseAudioEntry.ParameterTypes, PauseAudioEntry.DefaultValues, factory: context => Audio(context, () => runtime.Pause(Tracks(context), Arguments.String(context, "trackId")))), - BuiltinEntrySchemas.Primitive(ResumeAudioEntry.ParameterTypes, ResumeAudioEntry.DefaultValues, factory: context => Audio(context, () => runtime.Resume(Tracks(context), Arguments.String(context, "trackId")))), - BuiltinEntrySchemas.Primitive(NextAudioEntry.ParameterTypes, NextAudioEntry.DefaultValues, factory: context => Audio(context, () => runtime.Next(Tracks(context), Arguments.String(context, "trackId")))), - BuiltinEntrySchemas.Primitive(ClearAudioEntry.ParameterTypes, ClearAudioEntry.DefaultValues, factory: context => Audio(context, () => runtime.Clear(Tracks(context), Arguments.String(context, "trackId")))), - BuiltinEntrySchemas.Primitive(EnqueueAudioEntry.ParameterTypes, EnqueueAudioEntry.DefaultValues, EnqueueAudioEntry.ParameterOptions, context => Audio(context, () => runtime.Play(Tracks(context), new AudioPlayRequest(Arguments.String(context, "trackId"), Arguments.String(context, "asset"), AudioPlacement.Enqueue, Arguments.Float(context, "gain", 1), Arguments.Float(context, "fadeIn"))))), - BuiltinEntrySchemas.Primitive(ApplyAudioSEffectEntry.ParameterTypes, ApplyAudioSEffectEntry.DefaultValues, factory: context => Audio(context, () => runtime.ApplySEffect(Tracks(context), new AudioSEffectRequest(Arguments.String(context, "trackId"), Arguments.String(context, "instanceId"), Arguments.String(context, "id"), Arguments.Json(context, "parameters"))))), - BuiltinEntrySchemas.Primitive(StopAudioSEffectEntry.ParameterTypes, StopAudioSEffectEntry.DefaultValues, factory: context => Audio(context, () => runtime.StopSEffect(Tracks(context), Arguments.String(context, "trackId"), Arguments.String(context, "instanceId")))) - ]; - - private static IReadOnlyList CreateComposites() => - [ - new DefaultCompositeEntryBase(TextVoiceEntry.TypeId, EntrySchema.DynamicParameters(TextVoiceEntry.ParameterTypes, TextVoiceEntry.DefaultValues), static () => new TextVoiceEntry()), - new DefaultCompositeEntryBase(AppendVoiceNvlEntry.TypeId, EntrySchema.DynamicParameters(AppendVoiceNvlEntry.ParameterTypes, AppendVoiceNvlEntry.DefaultValues), static () => new AppendVoiceNvlEntry()) - ]; - - private static ImmediatePrimitiveInstance Audio(PrimitiveCreateContext context, Action action) => new(action, batchId: context.BatchId); -} - -public sealed class BuiltinVideoModule : EntryModuleBase -{ - public BuiltinVideoModule(IVideoPresenter? presenter = null) : base("video", - [ - BuiltinEntrySchemas.Primitive(PlayVideoEntry.ParameterTypes, PlayVideoEntry.DefaultValues, - PlayVideoEntry.ParameterOptions, context => CreatePlay(context, presenter)), - BuiltinEntrySchemas.Primitive(StopVideoEntry.ParameterTypes, StopVideoEntry.DefaultValues, - factory: context => new ImmediatePrimitiveInstance(() => - { - var handleId = Arguments.String(context, "handleId"); - if (context.Runtime.SceneInstances.TryGet(handleId, out var layer) && layer.IsVideo) - context.Runtime.SceneInstances.Remove(handleId, out _); - presenter?.StopVideo(handleId); - }, batchId: context.BatchId)) - ]) - { } - - private static PrimitiveInstance CreatePlay(PrimitiveCreateContext context, IVideoPresenter? presenter) - { - var points = Arguments.Json(context, "skipPoints") ?? []; - if (points.Any(point => !double.IsFinite(point) || point < 0) || - points.Zip(points.Skip(1), (left, right) => right > left).Any(valid => !valid)) - throw new InvalidDataException("video.play skipPoints must be finite, non-negative and strictly increasing seconds."); - var handleId = Arguments.String(context, "handleId"); - var asset = Arguments.String(context, "asset"); - if (string.IsNullOrWhiteSpace(handleId) || string.IsNullOrWhiteSpace(asset)) - throw new InvalidDataException("video.play requires a handleId and video asset."); - var layer = new LayerRenderRequest(handleId, asset, - Arguments.Json(context, "transform") ?? new LayerTransform(), - Arguments.Float(context, "z"), Arguments.Enum(context, "displayMode", LayerDisplayMode.Uniform), - Arguments.Float(context, "opacity", 1)); - return new VideoPrimitiveInstance(context.Runtime, presenter, - new VideoRenderRequest(Guid.NewGuid().ToString("N"), layer, points), - Arguments.Bool(context, "blocking"), context.BatchId, context.ScopeCancellation); - } -} - -public sealed class BuiltinEffectModule : EntryModuleBase -{ - public BuiltinEffectModule(IEffectPresenter? presenter = null) : base("effect", - [ - BuiltinEntrySchemas.Primitive( - ApplyEffectEntry.ParameterTypes, - ApplyEffectEntry.DefaultValues, - factory: context => new ApplyEffectPrimitiveInstance( - context.Runtime, - presenter, - BuiltinRuntimeActions.CreateEffectRequest(context), - context.BatchId, - context.ScopeCancellation)), - BuiltinEntrySchemas.Primitive( - StopEffectEntry.ParameterTypes, - factory: context => new StopEffectPrimitiveInstance( - context.Runtime, - presenter, - Arguments.String(context, "instanceId"), - context.BatchId, - context.ScopeCancellation)) - ]) - { } -} - -public sealed class BuiltinParticleModule : EntryModuleBase -{ - public BuiltinParticleModule(IParticlePresenter? presenter = null) : base("particle", - [ - BuiltinEntrySchemas.Primitive(PlayParticleEmitterEntry.ParameterTypes, PlayParticleEmitterEntry.DefaultValues, - factory: context => new PlayParticlePrimitiveInstance(context.Runtime, presenter, BuiltinRuntimeActions.CreateParticleRequest(context), context.BatchId, context.ScopeCancellation)), - BuiltinEntrySchemas.Primitive(BurstParticlesEntry.ParameterTypes, BurstParticlesEntry.DefaultValues, - factory: context => new BurstParticlePrimitiveInstance(presenter, BuiltinRuntimeActions.CreateParticleBurstRequest(context), context.BatchId, context.ScopeCancellation)), - BuiltinEntrySchemas.Primitive(StopParticleEmitterEntry.ParameterTypes, - factory: context => new StopParticlePrimitiveInstance(context.Runtime, presenter, Arguments.String(context, "instanceId"), context.BatchId, context.ScopeCancellation)) - ]) - { } -} - -public sealed class BuiltinFlowModule : EntryModuleBase -{ - public BuiltinFlowModule() : base("flow", - [ - BuiltinEntrySchemas.Primitive( - WaitEntry.ParameterTypes, - WaitEntry.DefaultValues, - factory: context => new WaitPrimitiveInstance( - TimeSpan.FromSeconds(Arguments.Float(context, "duration", 1)), - context.BatchId, - context.ScopeCancellation)) - ]) - { } -} - -public sealed class BuiltinVariableModule : EntryModuleBase -{ - public BuiltinVariableModule() : base("variable", - [ - BuiltinEntrySchemas.Primitive( - SetVariableEntry.ParameterTypes, - factory: context => new ImmediatePrimitiveInstance(() => - { - var target = Arguments.String(context, "target"); - if (!string.IsNullOrWhiteSpace(target)) - context.Runtime.SetVariable(target, context.Runtime.EvaluateExpression(Arguments.String(context, "expression")) ?? ""); - }, batchId: context.BatchId)) - ]) - { } -} - -public sealed class BuiltinGalleryModule : EntryModuleBase -{ - public BuiltinGalleryModule(GalleryCatalog? gallery = null) : base("gallery", - [ - BuiltinEntrySchemas.Primitive( - UnlockGalleryEntry.ParameterTypes, - factory: context => new ImmediatePrimitiveInstance(() => - { - var itemId = Arguments.Int(context, "id"); - if (itemId <= 0) throw new InvalidDataException("gallery.unlock requires a positive integer 'id'."); - var catalog = gallery ?? throw new InvalidOperationException( - "The gallery module requires a GalleryCatalog."); - if (!catalog.TryGetItem(itemId, out var item)) - throw new InvalidDataException($"Gallery item '{itemId}' is not declared in the generated Gallery catalog."); - context.Runtime.SetVariable(GalleryUnlockVariable.GetRuntimeName(item.Id), true); - }, batchId: context.BatchId)) - ]) - { } -} - -internal static class BuiltinEntrySchemas -{ - public static PrimitiveEntryBase Primitive( - IReadOnlyDictionary parameters, - IReadOnlyDictionary? defaults = null, - IReadOnlyDictionary>? options = null, - Func? factory = null) - where TEntry : PrimitiveEntry, new() - { - var typeId = new TEntry().Type; - return new DefaultPrimitiveEntryBase( - typeId, - EntrySchema.DynamicParameters(parameters, defaults, options), - factory ?? (context => new ImmediatePrimitiveInstance(batchId: context.BatchId))); - } -} - -internal static class Arguments -{ - internal static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - Converters = { new JsonStringEnumConverter() } - }; - - public static string String(PrimitiveCreateContext context, string name, string fallback = "") => - context.Arguments.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() ?? fallback - : fallback; - - public static float Float(PrimitiveCreateContext context, string name, float fallback = 0) => - context.Arguments.TryGetProperty(name, out var value) && value.TryGetSingle(out var result) ? result : fallback; - - public static bool TryFloat(PrimitiveCreateContext context, string name, out float result) - { - if (context.Arguments.TryGetProperty(name, out var value) && value.TryGetSingle(out result)) - return true; - result = default; - return false; - } - - public static int Int(PrimitiveCreateContext context, string name, int fallback = 0) => - context.Arguments.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : fallback; - - public static bool Bool(PrimitiveCreateContext context, string name, bool fallback = false) - { - if (!context.Arguments.TryGetProperty(name, out var value)) - return fallback; - return value.ValueKind switch - { - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.String => bool.TryParse(value.GetString(), out var result) ? result : fallback, - _ => fallback - }; - } - - public static TEnum Enum(PrimitiveCreateContext context, string name, TEnum fallback) - where TEnum : struct, Enum => - System.Enum.TryParse(String(context, name), true, out var result) ? result : fallback; - - public static string RawJson(PrimitiveCreateContext context, string name, string fallback = "{}") => - context.Arguments.TryGetProperty(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) - ? value.GetRawText() - : fallback; - - public static T? Json(PrimitiveCreateContext context, string name) - { - if (!context.Arguments.TryGetProperty(name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - return default; - if (value.ValueKind == JsonValueKind.String) - { - var json = value.GetString(); - return string.IsNullOrWhiteSpace(json) ? default : JsonSerializer.Deserialize(json, JsonOptions); - } - return value.Deserialize(JsonOptions); - } -} diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinRuntimeActions.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinRuntimeActions.cs deleted file mode 100644 index 642a1cd..0000000 --- a/src/Shared/GalNet.Primitives.Builtins/BuiltinRuntimeActions.cs +++ /dev/null @@ -1,592 +0,0 @@ -using System.Text.Json; -using GalNet.Core.Entry; -using GalNet.Core.Primitives; -using GalNet.Core.Runtime; -using GalNet.Core.Scene; -using GalNet.Presentation.Abstractions.View; - -namespace GalNet.Primitives.Builtins; - -internal static class BuiltinRuntimeActions -{ - public static AnimationRequest CreateAnimationRequest(PrimitiveCreateContext context) - { - var curve = Arguments.Enum(context, "curve", BuiltinAnimationCurve.Linear); - return new AnimationRequest - { - PlaybackHandleId = Arguments.String(context, "playbackHandleId"), - HandleId = Arguments.String(context, "handleId"), - Property = Arguments.String(context, "property"), - From = Arguments.TryFloat(context, "from", out var from) ? from : null, - To = Arguments.Float(context, "to"), - DurationSeconds = Math.Max(0, Arguments.Float(context, "duration", 0.25f)), - CurveKind = curve, - Curve = AnimationCurves.Create(curve), - Blocking = Arguments.Bool(context, "blocking"), - Skippable = Arguments.Bool(context, "skippable"), - LoopMode = Arguments.Enum(context, "loopMode", AnimationLoopMode.Once), - BlendMode = Arguments.Enum(context, "blendMode", AnimationBlendMode.Replace) - }; - } - - public static AnimationRequest CreateAnimationRequest(ActiveAnimationState state) - { - var curve = ParseEnum(Get(state.Parameters, "curve"), BuiltinAnimationCurve.Linear); - return new AnimationRequest - { - PlaybackHandleId = state.PlaybackHandleId, - HandleId = Get(state.Parameters, "handleId"), - Property = Get(state.Parameters, "property"), - From = TryParseFloat(Get(state.Parameters, "from"), out var from) ? from : null, - To = ParseFloat(Get(state.Parameters, "to")), - DurationSeconds = Math.Max(0, ParseDouble(Get(state.Parameters, "duration"), 0.25)), - CurveKind = curve, - Curve = AnimationCurves.Create(curve), - Blocking = ParseBool(Get(state.Parameters, "blocking")), - Skippable = ParseBool(Get(state.Parameters, "skippable")), - LoopMode = state.LoopMode, - BlendMode = ParseEnum(Get(state.Parameters, "blendMode"), AnimationBlendMode.Replace) - }; - } - - public static AnimationPlanDefinition CreateAnimationPlan(PrimitiveCreateContext context) - { - var plan = Arguments.Json(context, "plan") ?? new AnimationPlanDefinition(); - if (plan.FrameRate <= 0) - plan.FrameRate = 60; - if (plan.DurationFrames < 0) - plan.DurationFrames = 0; - return plan; - } - - public static AnimationPlanDefinition CreateAnimationPlan(ActiveAnimationState state) - { - if (!state.Parameters.TryGetValue("plan", out var json) || string.IsNullOrWhiteSpace(json)) - throw new InvalidDataException($"Persistent animation plan '{state.PlaybackHandleId}' has no replay definition."); - return JsonSerializer.Deserialize(json, Arguments.JsonOptions) - ?? throw new InvalidDataException($"Persistent animation plan '{state.PlaybackHandleId}' is invalid."); - } - - public static EffectRequest CreateEffectRequest(PrimitiveCreateContext context) => new( - Arguments.String(context, "id"), - Arguments.String(context, "instanceId"), - Arguments.String(context, "targetHandleId"), - Arguments.Int(context, "order"), - Arguments.RawJson(context, "parameters", "{}"), - Arguments.String(context, "program")); - - public static ParticleEmitterRequest CreateParticleRequest(PrimitiveCreateContext context) - { - var instanceId = Arguments.String(context, "instanceId"); - if (string.IsNullOrWhiteSpace(instanceId)) throw new InvalidDataException("particle.play requires 'instanceId'."); - var definition = ReadParticleDefinition(Arguments.RawJson(context, "parameters", "{}"), PlayParticleEmitterEntry.TypeId); - return new ParticleEmitterRequest(instanceId, definition, Arguments.Float(context, "z", 100)); - } - - public static ParticleBurstRequest CreateParticleBurstRequest(PrimitiveCreateContext context) - { - var count = Arguments.Int(context, "count", 1); - if (count <= 0) throw new InvalidDataException("particle.burst requires a positive 'count'."); - var definition = ReadParticleDefinition(Arguments.RawJson(context, "parameters", "{}"), BurstParticlesEntry.TypeId) with { EmissionRate = 0 }; - return new ParticleBurstRequest(definition, count, Arguments.Float(context, "z", 100)); - } - - public static void ApplyParticleState(IGameRuntime runtime, ParticleEmitterRequest request) - { - RemoveParticleState(runtime, request.InstanceId); - var emitter = runtime.SceneInstances.GetOrAddTransient(request.InstanceId, id => new ParticleEmitterInstance(id, request.Definition, request.Z)); - emitter.RestoreAnimationValues(request.AnimationValues); - runtime.SceneState.ActiveParticleEmitters.Add(new ActiveParticleEmitterState - { - InstanceId = request.InstanceId, - Definition = request.Definition, - Z = request.Z, - AnimationValues = emitter.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - }); - } - - public static void RemoveParticleState(IGameRuntime runtime, string instanceId) - { - if (string.IsNullOrWhiteSpace(instanceId)) return; - runtime.SceneInstances.Remove(instanceId, out _); - runtime.SceneState.ActiveParticleEmitters.RemoveAll(emitter => string.Equals(emitter.InstanceId, instanceId, StringComparison.Ordinal)); - } - - public static void ApplyEffectState(IGameRuntime runtime, EffectRequest request) - { - if (string.IsNullOrWhiteSpace(request.InstanceId)) - return; - - RemoveEffectState(runtime, request.InstanceId); - var effect = runtime.SceneInstances.GetOrAddTransient(request.InstanceId, _ => new EffectInstance - { - Id = request.InstanceId, - EffectId = request.Id, - ProgramResource = request.ProgramResource, - TargetHandleId = request.TargetHandleId, - Order = request.Order, - Parameters = request.Parameters - }); - effect.RestoreAnimationValues(request.AnimationValues); - - runtime.SceneState.ActiveEffects.Add(new ActiveEffectState - { - Id = request.Id, - ProgramResource = request.ProgramResource, - InstanceId = request.InstanceId, - TargetHandleId = request.TargetHandleId, - Order = request.Order, - Parameters = request.Parameters, - AnimationValues = effect.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - }); - if (!runtime.SceneState.ActiveEffectIds.Contains(request.InstanceId, StringComparer.Ordinal)) - runtime.SceneState.ActiveEffectIds.Add(request.InstanceId); - if (!string.IsNullOrWhiteSpace(request.TargetHandleId) && - runtime.SceneInstances.TryGet(request.TargetHandleId, out var layer) && - !layer.EffectInstanceIds.Contains(request.InstanceId, StringComparer.Ordinal)) - layer.EffectInstanceIds.Add(request.InstanceId); - } - - public static void RemoveEffectState(IGameRuntime runtime, string instanceId) - { - if (string.IsNullOrWhiteSpace(instanceId)) - return; - - runtime.SceneInstances.Remove(instanceId, out _); - runtime.SceneState.ActiveEffectIds.RemoveAll(id => string.Equals(id, instanceId, StringComparison.Ordinal)); - runtime.SceneState.ActiveEffects.RemoveAll(effect => string.Equals(effect.InstanceId, instanceId, StringComparison.Ordinal)); - foreach (var layer in runtime.SceneState.Layers) - layer.EffectInstanceIds.RemoveAll(id => string.Equals(id, instanceId, StringComparison.Ordinal)); - } - - public static void ApplyAnimationStableState(IGameRuntime runtime, AnimationRequest request) - { - if (request.LoopMode == AnimationLoopMode.Once) - { - ApplyAnimationTerminalValue(runtime, request); - return; - } - - // Replace loops own a stable frame-zero value. Additive loops retain their underlying - // base value; the presenter reapplies the authored frame-zero offset on every replay. - if (request.BlendMode == AnimationBlendMode.Replace && - TryResolveAnimationStartValue(runtime, request, out var initialValue)) - { - request.From ??= initialValue; - ApplyAnimationValue(runtime, request.HandleId, request.Property, initialValue, AnimationBlendMode.Replace); - } - - TrackLoopingAnimation( - runtime, - AnimateEntry.TypeId, - request.PlaybackHandleId, - request.LoopMode, - ToReplayParameters(request)); - } - - public static void ApplyAnimationTerminalValue(IGameRuntime runtime, AnimationRequest request) => - ApplyAnimationValue(runtime, request.HandleId, request.Property, request.To, request.BlendMode); - - public static void ApplyPlanStableState(IGameRuntime runtime, AnimationPlanDefinition plan) - { - if (plan.LoopMode != AnimationLoopMode.Once) - { - foreach (var item in plan.Events.Where(item => item.Frame <= 0).OrderBy(item => item.Frame)) - ApplyPlanEventToRuntime(runtime, item); - - foreach (var track in plan.Tracks.Where(track => track.Keys.Count > 0 && track.BlendMode == AnimationBlendMode.Replace)) - ApplyAnimationValue( - runtime, - track.HandleId, - track.Property, - AnimationTrackSampler.Evaluate(track, 0), - AnimationBlendMode.Replace); - - TrackLoopingAnimation( - runtime, - PlayAnimationPlanEntry.TypeId, - plan.PlaybackHandleId, - plan.LoopMode, - new Dictionary(StringComparer.Ordinal) - { - ["plan"] = JsonSerializer.Serialize(plan, Arguments.JsonOptions) - }); - return; - } - - ApplyPlanTerminalState(runtime, plan); - } - - public static void ApplyPlanTerminalState(IGameRuntime runtime, AnimationPlanDefinition plan) - { - foreach (var item in plan.Events.OrderBy(item => item.Frame)) - ApplyPlanEventToRuntime(runtime, item); - - foreach (var track in plan.Tracks) - if (track.Keys.Count > 0) - ApplyAnimationValue( - runtime, - track.HandleId, - track.Property, - AnimationTrackSampler.Evaluate(track, plan.DurationFrames), - track.BlendMode); - } - - public static void StopAnimationState(IGameRuntime runtime, string playbackHandleId) - { - if (string.IsNullOrWhiteSpace(playbackHandleId)) - return; - - runtime.SceneInstances.Remove(playbackHandleId, out _); - runtime.SceneState.ActiveAnimations.RemoveAll(animation => - string.Equals(animation.PlaybackHandleId, playbackHandleId, StringComparison.Ordinal)); - } - - public static void ApplyPlanEventToRuntime(IGameRuntime runtime, AnimationPlanEventDefinition planEvent) - { - switch (planEvent.Type) - { - case ShowLayerEntry.TypeId: - ShowLayer(runtime, null, ToLayerRenderRequest(planEvent.Parameters, color: null), notify: false); - break; - case ShowColorLayerEntry.TypeId: - ShowLayer(runtime, null, ToLayerRenderRequest(planEvent.Parameters, Get(planEvent.Parameters, "color")), notify: false); - break; - case HideLayerEntry.TypeId: - HideLayer(runtime, null, Get(planEvent.Parameters, "handleId"), notify: false); - break; - case MoveLayerEntry.TypeId: - MoveLayer( - runtime, - null, - Get(planEvent.Parameters, "handleId"), - Json(planEvent.Parameters, "transform") ?? new LayerTransform(), - Float(planEvent.Parameters, "z"), - Float(planEvent.Parameters, "duration"), - notify: false); - break; - case ReplaceLayerEntry.TypeId: - ReplaceLayer(runtime, null, Get(planEvent.Parameters, "handleId"), Get(planEvent.Parameters, "assetId"), notify: false); - break; - case ApplyEffectEntry.TypeId: - ApplyEffectState(runtime, ToEffectRequest(planEvent.Parameters)); - break; - case StopEffectEntry.TypeId: - RemoveEffectState(runtime, Get(planEvent.Parameters, "instanceId")); - break; - case PlayParticleEmitterEntry.TypeId: - ApplyParticleState(runtime, ToParticleEmitterRequest(planEvent.Parameters)); - break; - case StopParticleEmitterEntry.TypeId: - RemoveParticleState(runtime, Get(planEvent.Parameters, "instanceId")); - break; - } - } - - public static Task NotifyPlanEventAsync( - AnimationPlanEventDefinition planEvent, - ILayerPresenter? layerPresenter, - IEffectPresenter? effectPresenter, - IParticlePresenter? particlePresenter, - CancellationToken cancellationToken) - { - switch (planEvent.Type) - { - case ShowLayerEntry.TypeId: - layerPresenter?.ShowLayer(ToLayerRenderRequest(planEvent.Parameters, color: null)); - break; - case ShowColorLayerEntry.TypeId: - layerPresenter?.ShowLayer(ToLayerRenderRequest(planEvent.Parameters, Get(planEvent.Parameters, "color"))); - break; - case HideLayerEntry.TypeId: - layerPresenter?.HideLayer(Get(planEvent.Parameters, "handleId")); - break; - case MoveLayerEntry.TypeId: - layerPresenter?.MoveLayer( - Get(planEvent.Parameters, "handleId"), - Json(planEvent.Parameters, "transform") ?? new LayerTransform(), - Float(planEvent.Parameters, "z"), - Float(planEvent.Parameters, "duration")); - break; - case ReplaceLayerEntry.TypeId: - layerPresenter?.ReplaceLayer(Get(planEvent.Parameters, "handleId"), Get(planEvent.Parameters, "assetId")); - break; - case ApplyEffectEntry.TypeId when effectPresenter is not null: - return effectPresenter.StartEffectAsync(ToEffectRequest(planEvent.Parameters), cancellationToken); - case StopEffectEntry.TypeId when effectPresenter is not null: - return effectPresenter.StopEffectAsync(Get(planEvent.Parameters, "instanceId"), cancellationToken); - case PlayParticleEmitterEntry.TypeId when particlePresenter is not null: - return particlePresenter.StartParticleEmitterAsync(ToParticleEmitterRequest(planEvent.Parameters), cancellationToken); - case StopParticleEmitterEntry.TypeId when particlePresenter is not null: - return particlePresenter.StopParticleEmitterAsync(Get(planEvent.Parameters, "instanceId"), cancellationToken); - case BurstParticlesEntry.TypeId when particlePresenter is not null: - return particlePresenter.BurstParticlesAsync(ToParticleBurstRequest(planEvent.Parameters), cancellationToken); - } - - return Task.CompletedTask; - } - - public static void ShowLayer(IGameRuntime runtime, ILayerPresenter? presenter, LayerRenderRequest request, bool notify = true) - { - var layer = runtime.SceneInstances.GetOrAdd(request.HandleId, id => new Layer { Id = id }); - layer.AssetId = request.AssetId; - layer.IsVideo = false; - layer.VideoPlaybackId = null; - layer.Color = request.Color; - layer.Flipbook = request.Flipbook?.Clone(); - layer.Transform = request.Transform.Clone(); - layer.Z = request.Z; - layer.Opacity = request.Opacity; - layer.DisplayMode = request.DisplayMode; - layer.Visible = true; - if (notify) - presenter?.ShowLayer(request); - } - - public static void HideLayer(IGameRuntime runtime, ILayerPresenter? presenter, string handleId, bool notify = true) - { - runtime.SceneInstances.Remove(handleId, out _); - if (notify) - presenter?.HideLayer(handleId); - } - - public static void MoveLayer( - IGameRuntime runtime, - ILayerPresenter? presenter, - string handleId, - LayerTransform transform, - float z, - float durationSeconds, - bool notify = true) - { - if (runtime.SceneInstances.TryGet(handleId, out var layer)) - { - layer.Transform = transform.Clone(); - layer.Z = z; - } - if (notify) - presenter?.MoveLayer(handleId, transform, z, durationSeconds); - } - - public static void ReplaceLayer(IGameRuntime runtime, ILayerPresenter? presenter, string handleId, string assetId, bool notify = true) - { - if (runtime.SceneInstances.TryGet(handleId, out var layer)) - { - layer.AssetId = assetId; - layer.IsVideo = false; - layer.VideoPlaybackId = null; - layer.Color = null; - } - if (notify) - presenter?.ReplaceLayer(handleId, assetId); - } - - private static void ApplyAnimationValue( - IGameRuntime runtime, - string handleId, - string property, - float authoredValue, - AnimationBlendMode blendMode) - { - if (!runtime.SceneInstances.TryGet(handleId, out var target)) - return; - - var finalValue = authoredValue; - if (blendMode == AnimationBlendMode.Additive && - target.TryGetAnimationValue(property, out var current)) - finalValue = current + authoredValue; - - if (target.TrySetAnimationValue(property, finalValue, out _)) - SyncAnimationState(runtime, target); - } - - private static void SyncAnimationState(IGameRuntime runtime, AnimatableSceneInstance target) - { - switch (target) - { - case EffectInstance effect: - SyncEffectAnimationState(runtime, effect); - break; - case ParticleEmitterInstance emitter: - SyncParticleAnimationState(runtime, emitter); - break; - } - } - - private static void SyncEffectAnimationState(IGameRuntime runtime, EffectInstance effect) - { - var state = runtime.SceneState.ActiveEffects.FirstOrDefault(item => - string.Equals(item.InstanceId, effect.Id, StringComparison.Ordinal)); - if (state is null) - return; - - state.AnimationValues.Clear(); - foreach (var (property, value) in effect.AnimationValues) - state.AnimationValues[property] = value; - } - - private static void SyncParticleAnimationState(IGameRuntime runtime, ParticleEmitterInstance emitter) - { - var state = runtime.SceneState.ActiveParticleEmitters.FirstOrDefault(item => - string.Equals(item.InstanceId, emitter.Id, StringComparison.Ordinal)); - if (state is null) - return; - - state.AnimationValues.Clear(); - foreach (var (property, value) in emitter.AnimationValues) - state.AnimationValues[property] = value; - } - - private static void TrackLoopingAnimation( - IGameRuntime runtime, - string entryType, - string playbackHandleId, - AnimationLoopMode loopMode, - Dictionary parameters) - { - if (string.IsNullOrWhiteSpace(playbackHandleId)) - return; - - runtime.SceneInstances.GetOrAddTransient(playbackHandleId, id => new AnimationPlaybackInstance - { - Id = id, - LoopMode = loopMode - }); - runtime.SceneState.ActiveAnimations.RemoveAll(animation => - string.Equals(animation.PlaybackHandleId, playbackHandleId, StringComparison.Ordinal)); - runtime.SceneState.ActiveAnimations.Add(new ActiveAnimationState - { - EntryType = entryType, - PlaybackHandleId = playbackHandleId, - LoopMode = loopMode, - Parameters = parameters - }); - } - - private static Dictionary ToReplayParameters(AnimationRequest request) - { - var result = new Dictionary(StringComparer.Ordinal) - { - ["handleId"] = request.HandleId, - ["property"] = request.Property, - ["to"] = Invariant(request.To), - ["duration"] = Invariant(request.DurationSeconds), - ["curve"] = request.CurveKind.ToString(), - ["blocking"] = request.Blocking.ToString(), - ["skippable"] = request.Skippable.ToString(), - ["blendMode"] = request.BlendMode.ToString() - }; - if (request.From.HasValue) - result["from"] = Invariant(request.From.Value); - return result; - } - - private static bool TryResolveAnimationStartValue(IGameRuntime runtime, AnimationRequest request, out float value) - { - if (request.From.HasValue) - { - value = request.From.Value; - return true; - } - - if (runtime.SceneInstances.TryGet(request.HandleId, out var target) && - target.TryGetAnimationValue(request.Property, out value)) - return true; - - value = default; - return false; - } - - private static string Get(IReadOnlyDictionary parameters, string name, string fallback = "") => - parameters.TryGetValue(name, out var value) ? value : fallback; - - private static bool TryParseFloat(string value, out float result) => - float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); - - private static float ParseFloat(string value, float fallback = 0) => - TryParseFloat(value, out var result) ? result : fallback; - - private static double ParseDouble(string value, double fallback = 0) => - double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result) - ? result - : fallback; - - private static bool ParseBool(string value, bool fallback = false) => - bool.TryParse(value, out var result) ? result : fallback; - - private static TEnum ParseEnum(string value, TEnum fallback) - where TEnum : struct, Enum => - System.Enum.TryParse(value, true, out var result) ? result : fallback; - - private static string Invariant(float value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); - private static string Invariant(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); - - private static LayerRenderRequest ToLayerRenderRequest( - IReadOnlyDictionary parameters, - string? color) => new( - Get(parameters, "handleId"), - Get(parameters, "assetId"), - Json(parameters, "transform") ?? new LayerTransform(), - Float(parameters, "z"), - Enum(parameters, "displayMode", LayerDisplayMode.Native), - Float(parameters, "opacity", 1), - color, - Json(parameters, "flipbook")?.Clone()); - - private static EffectRequest ToEffectRequest(IReadOnlyDictionary parameters) => new( - Get(parameters, "id"), - Get(parameters, "instanceId"), - Get(parameters, "targetHandleId"), - Int(parameters, "order"), - RawJson(parameters, "parameters", "{}"), - Get(parameters, "program")); - - private static ParticleBurstRequest ToParticleBurstRequest(IReadOnlyDictionary parameters) - { - var count = Int(parameters, "count", 1); - if (count <= 0) throw new InvalidDataException("particle.burst requires a positive 'count'."); - var definition = ReadParticleDefinition(RawJson(parameters, "parameters", "{}"), BurstParticlesEntry.TypeId) with { EmissionRate = 0 }; - return new ParticleBurstRequest(definition, count, Float(parameters, "z", 100)); - } - - private static ParticleEmitterRequest ToParticleEmitterRequest(IReadOnlyDictionary parameters) - { - var instanceId = Get(parameters, "instanceId"); - if (string.IsNullOrWhiteSpace(instanceId)) throw new InvalidDataException("particle.play requires 'instanceId'."); - var definition = ReadParticleDefinition(RawJson(parameters, "parameters", "{}"), PlayParticleEmitterEntry.TypeId); - return new ParticleEmitterRequest(instanceId, definition, Float(parameters, "z", 100)); - } - - private static ParticleEmitterDefinition ReadParticleDefinition(string json, string entryType) - { - var definition = ParticleEmitterDefinition.FromJson(json); - if (string.IsNullOrWhiteSpace(definition.ParticleTexture)) - throw new InvalidDataException($"{entryType} requires 'parameters.particleTexture'."); - return definition; - } - - private static string Get(IReadOnlyDictionary parameters, string name, string fallback = "") => - parameters.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() ?? fallback - : fallback; - - private static float Float(IReadOnlyDictionary parameters, string name, float fallback = 0) => - parameters.TryGetValue(name, out var value) && value.TryGetSingle(out var result) ? result : fallback; - - private static int Int(IReadOnlyDictionary parameters, string name, int fallback = 0) => - parameters.TryGetValue(name, out var value) && value.TryGetInt32(out var result) ? result : fallback; - - private static TEnum Enum(IReadOnlyDictionary parameters, string name, TEnum fallback) - where TEnum : struct, Enum => - System.Enum.TryParse(Get(parameters, name), true, out var result) ? result : fallback; - - private static string RawJson(IReadOnlyDictionary parameters, string name, string fallback = "{}") => - parameters.TryGetValue(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) - ? value.GetRawText() - : fallback; - - private static T? Json(IReadOnlyDictionary parameters, string name) => - parameters.TryGetValue(name, out var value) - ? value.Deserialize(Arguments.JsonOptions) - : default; -} diff --git a/src/Shared/GalNet.Primitives.Builtins/EffectPrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/EffectPrimitiveInstance.cs index d08e8f8..052dede 100644 --- a/src/Shared/GalNet.Primitives.Builtins/EffectPrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/EffectPrimitiveInstance.cs @@ -1,6 +1,7 @@ using GalNet.Core.Primitives; using GalNet.Core.Runtime; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; namespace GalNet.Primitives.Builtins; @@ -47,8 +48,7 @@ private async Task ObservePresentationAsync() } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch - { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { TryComplete(); @@ -99,8 +99,7 @@ private async Task ObservePresentationAsync() } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch - { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { TryComplete(); diff --git a/src/Shared/GalNet.Primitives.Builtins/Entry/NvlEntries.cs b/src/Shared/GalNet.Primitives.Builtins/Entry/NvlEntries.cs index 7912029..2664cc7 100644 --- a/src/Shared/GalNet.Primitives.Builtins/Entry/NvlEntries.cs +++ b/src/Shared/GalNet.Primitives.Builtins/Entry/NvlEntries.cs @@ -4,18 +4,21 @@ namespace GalNet.Primitives.Builtins; public sealed class ShowNvlEntry : PrimitiveEntry { + public static IReadOnlyDictionary ParameterTypes { get; } = EntrySchema.Parameters(); public const string TypeId = "nvl.show"; public override string Type => TypeId; } public sealed class HideNvlEntry : PrimitiveEntry { + public static IReadOnlyDictionary ParameterTypes { get; } = EntrySchema.Parameters(); public const string TypeId = "nvl.hide"; public override string Type => TypeId; } public sealed class ClearNvlEntry : PrimitiveEntry { + public static IReadOnlyDictionary ParameterTypes { get; } = EntrySchema.Parameters(); public const string TypeId = "nvl.clear"; public override string Type => TypeId; } diff --git a/src/Shared/GalNet.Primitives.Builtins/NvlAppendPrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/NvlAppendPrimitiveInstance.cs index eba398b..eec834c 100644 --- a/src/Shared/GalNet.Primitives.Builtins/NvlAppendPrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/NvlAppendPrimitiveInstance.cs @@ -1,5 +1,6 @@ using GalNet.Core.Primitives; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; namespace GalNet.Primitives.Builtins; @@ -82,8 +83,9 @@ private async Task ObservePresentationAsync() { CompleteAfterPresentationFailure(); } - catch + catch (Exception error) { + GameLog.Logger.Warning(error, "NVL presentation failed"); CompleteAfterPresentationFailure(); } } diff --git a/src/Shared/GalNet.Primitives.Builtins/ParticlePrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/ParticlePrimitiveInstance.cs index e98ccc0..dc35e2b 100644 --- a/src/Shared/GalNet.Primitives.Builtins/ParticlePrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/ParticlePrimitiveInstance.cs @@ -2,6 +2,7 @@ using GalNet.Core.Runtime; using GalNet.Core.Scene; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; namespace GalNet.Primitives.Builtins; @@ -26,7 +27,7 @@ private async Task ObserveAsync() { try { await _presenter!.StartParticleEmitterAsync(_request, _scopeCancellation).ConfigureAwait(false); } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { TryComplete(); } } } @@ -51,7 +52,7 @@ private async Task ObserveAsync() { try { await _presenter!.StopParticleEmitterAsync(_instanceId, _scopeCancellation).ConfigureAwait(false); } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { TryComplete(); } } } @@ -75,7 +76,7 @@ private async Task ObserveAsync() { try { await _presenter!.BurstParticlesAsync(_request, _scopeCancellation).ConfigureAwait(false); } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - catch { } + catch (Exception error) { GameLog.Logger.Warning(error, "Primitive presentation failed"); } finally { TryComplete(); } } } diff --git a/src/Shared/GalNet.Runtime/Audio/AudioRuntime.cs b/src/Shared/GalNet.Runtime/Audio/AudioRuntime.cs index 2128e27..876faa8 100644 --- a/src/Shared/GalNet.Runtime/Audio/AudioRuntime.cs +++ b/src/Shared/GalNet.Runtime/Audio/AudioRuntime.cs @@ -4,11 +4,13 @@ namespace GalNet.Runtime.Audio; /// Game-scope logical audio state. It serializes queues and protects against late backend events. -public sealed class AudioRuntime : IDisposable +public sealed class AudioRuntime : IDisposable, IAsyncDisposable { private readonly object _gate = new(); private readonly IAudioPlaybackHost? _host; private readonly Dictionary _tracks = new(StringComparer.Ordinal); + private readonly Dictionary _backendQueues = new(StringComparer.Ordinal); + private readonly List _pendingRequests = []; private long _nextGeneration; private bool _disposed; @@ -20,6 +22,16 @@ public AudioRuntime(IAudioPlaybackHost? host = null) public event Action? Diagnostic; + public void Reset() + { + lock (_gate) + { + if (_disposed) return; + foreach (var track in _tracks.Values) StopActive(track, 0); + _tracks.Clear(); + } + } + public void Play(IReadOnlyList definitions, AudioPlayRequest request) { lock (_gate) @@ -149,6 +161,15 @@ public void Dispose() } } + /// Stops voices and waits for all backend commands before the host is released. + public async ValueTask DisposeAsync() + { + Dispose(); + Task[] requests; + lock (_gate) requests = _pendingRequests.ToArray(); + await Task.WhenAll(requests).ConfigureAwait(false); + } + private void ApplyActive(IReadOnlyList definitions, string trackId, AudioBackendAction action) { lock (_gate) @@ -255,6 +276,11 @@ private void OnBackendEvent(AudioBackendEvent backendEvent) if (backendEvent.Kind is AudioBackendEventKind.Ended or AudioBackendEventKind.Failed) { track.Active.Remove(voice); + if (_backendQueues.Remove(voice.Id, out var queue)) + { + queue.StartCancellation.Cancel(); + _ = ReleaseQueueAsync(queue); + } if (backendEvent.Kind == AudioBackendEventKind.Failed) Report(backendEvent.Message ?? $"Audio backend failed for '{voice.AssetId}'."); Pump(track); } @@ -278,13 +304,53 @@ private void Send(AudioBackendRequest request) Report($"Audio backend does not support fades on '{request.TrackId}'; applying immediately."); request = request with { FadeSeconds = 0 }; } - _ = SendAsync(request); + if (!_backendQueues.TryGetValue(request.VoiceId, out var queue)) + { + queue = new BackendQueue(); + _backendQueues.Add(request.VoiceId, queue); + } + // Stop invalidates an in-flight asset load immediately; subsequent commands + // for this voice still run in order after the producer has observed cancellation. + if (request.Action == AudioBackendAction.Stop) queue.StartCancellation.Cancel(); + queue.Tail = SendAsync(queue.Tail, queue, request); + _pendingRequests.RemoveAll(task => task.IsCompleted); + _pendingRequests.Add(queue.Tail); } - private async Task SendAsync(AudioBackendRequest request) + private async Task SendAsync(Task previous, BackendQueue queue, AudioBackendRequest request) { - try { await _host!.ApplyAsync(request).ConfigureAwait(false); } + try + { + await previous.ConfigureAwait(false); + var cancellation = request.Action == AudioBackendAction.Start ? queue.StartCancellation.Token : CancellationToken.None; + cancellation.ThrowIfCancellationRequested(); + await _host!.ApplyAsync(request, cancellation).ConfigureAwait(false); + } + catch (OperationCanceledException) when (queue.StartCancellation.IsCancellationRequested) { } catch (Exception exception) { Report($"Audio backend request failed: {exception.Message}"); } + finally + { + if (request.Action == AudioBackendAction.Stop) + { + lock (_gate) _backendQueues.Remove(request.VoiceId); + queue.StartCancellation.Dispose(); + } + } + } + + private sealed class BackendQueue + { + public Task Tail { get; set; } = Task.CompletedTask; + public CancellationTokenSource StartCancellation { get; } = new(); + } + + private static async Task ReleaseQueueAsync(BackendQueue queue) + { + // Backend events may arrive synchronously inside ApplyAsync, before Send + // has assigned its returned task. Wait for that assignment before cleanup. + await Task.Yield(); + await queue.Tail.ConfigureAwait(false); + queue.StartCancellation.Dispose(); } private void Report(string message) => Diagnostic?.Invoke(message); diff --git a/src/Shared/GalNet.Runtime/Content/IGameContentProvider.cs b/src/Shared/GalNet.Runtime/Content/IGameContentProvider.cs index b398391..a552088 100644 --- a/src/Shared/GalNet.Runtime/Content/IGameContentProvider.cs +++ b/src/Shared/GalNet.Runtime/Content/IGameContentProvider.cs @@ -1,4 +1,5 @@ using GalNet.Core.Gallery; +using GalNet.Core.Settings; using GameGraph = GalNet.Core.Graph.Graph; namespace GalNet.Runtime.Content; @@ -14,4 +15,5 @@ public sealed class GameContent public required GameGraph Graph { get; init; } public string? AssetRoot { get; init; } public GalleryCatalog Gallery { get; init; } = GalleryCatalog.Empty; + public ProjectSettings Settings { get; init; } = new(); } diff --git a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs index 1940d96..529882e 100644 --- a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs +++ b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs @@ -5,7 +5,6 @@ using GalNet.Core.Services; using GalNet.Core.Settings; using GalNet.Presentation.Abstractions.View; -using GalNet.Runtime.Logging; using GalNet.Runtime.Progress; using GalNet.Runtime.Runtime; @@ -20,7 +19,8 @@ public sealed class GameEngine : IDisposable private readonly IChoicePresenter? _choicePresenter; private readonly IGameProgressService? _progress; private readonly SemaphoreSlim _dispatchGate = new(1, 1); - private readonly CancellationTokenSource _scopeCancellation = new(); + private CancellationTokenSource _scopeCancellation = new(); + private IGameRuntime _dispatchRuntime; private readonly List _active = []; private GameSnapshot _lastStableSnapshot; private PendingChoice? _pendingChoice; @@ -55,6 +55,14 @@ public GameEngine( _graph = graph ?? throw new ArgumentNullException(nameof(graph)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _view = view ?? throw new ArgumentNullException(nameof(view)); + _dispatchRuntime = new RunScopedRuntime(_runtime, _scopeCancellation.Token); + var entries = graph.Nodes.OfType().SelectMany(group => group.Entries).ToArray(); + if (entries.Any(entry => entry is not PrimitiveEntry { IsGeneric: true })) + throw new InvalidOperationException("The game graph must contain compiled generic primitives, not authoring entries."); + var missing = entries.OfType() + .Where(entry => !_view.CanExecute(entry.Type)).Select(entry => entry.Type).Distinct().ToArray(); + if (missing.Length > 0) + throw new InvalidOperationException($"Game instructions have no implementation: {string.Join(", ", missing)}."); _progress = progress; _choicePresenter = choicePresenter; _lastStableSnapshot = _runtime.CreateSnapshot(); @@ -62,7 +70,7 @@ public GameEngine( public event Action? CheckpointCreated; public IGameRuntime Runtime => _runtime; - public GameSnapshot LastStableSnapshot => _lastStableSnapshot; + public GameSnapshot LastStableSnapshot => SnapshotCopy.Clone(_lastStableSnapshot); public string CurrentNodeId => _runtime.CurrentNodeId; public int EntryIndex => _runtime.EntryIndex; public bool IsRunning { get; private set; } @@ -104,19 +112,27 @@ public async Task AdvanceAsync(CancellationToken cancellationToken = defau } } - public GameSnapshot CreateSaveData() => _lastStableSnapshot; + public GameSnapshot CreateSaveData() => SnapshotCopy.Clone(_lastStableSnapshot); public void RestoreFrom(GameSnapshot data) { ArgumentNullException.ThrowIfNull(data); ThrowIfDisposed(); + if (data.Version != GameSnapshot.CurrentFormatVersion) + throw new InvalidDataException($"Unsupported save version '{data.Version}'."); + data = SnapshotCopy.Clone(data); _dispatchGate.Wait(); try { CancelPendingChoice(); ClearActive(); + var previousScope = _scopeCancellation; + previousScope.Cancel(); + previousScope.Dispose(); _runtime.RestoreFrom(data); - _lastStableSnapshot = data; + _scopeCancellation = new CancellationTokenSource(); + _dispatchRuntime = new RunScopedRuntime(_runtime, _scopeCancellation.Token); + _lastStableSnapshot = _runtime.CreateSnapshot(); _executingGroupNodeId = null; _flowError = null; IsRunning = true; @@ -194,7 +210,7 @@ private void ProcessGroup(Group group) if (_runtime.EvaluateCondition(entry.Condition) && entry is PrimitiveEntry primitive) instance = Dispatch(group, primitive); else if (entry is not PrimitiveEntry) - GameLog.Logger.Warning("Runtime ignored composite entry '{EntryType}'.", entry.Type); + throw new InvalidOperationException($"Runtime cannot execute authoring entry '{entry.Type}'."); _runtime.SetEntryIndex(_runtime.EntryIndex + 1); if (instance is not null) @@ -213,33 +229,16 @@ private void ProcessGroup(Group group) UpdateLastStableSnapshot(); } - private PrimitiveInstance? Dispatch(Group group, PrimitiveEntry entry) + private PrimitiveInstance Dispatch(Group group, PrimitiveEntry entry) { if (!entry.IsGeneric) - { - GameLog.Logger.Warning("Runtime ignored non-generic primitive '{EntryType}'.", entry.Type); - return null; - } + throw new InvalidOperationException($"Runtime cannot execute authoring entry '{entry.Type}'."); - PrimitiveInstance? instance; - try - { - instance = _view.Dispatch(entry, _runtime, _scopeCancellation.Token); - } - catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - GameLog.Logger.Error(exception, "Primitive '{PrimitiveType}' failed to dispatch and execution continued.", entry.Type); - return null; - } + var instance = _view.Dispatch(entry, _dispatchRuntime, _scopeCancellation.Token); if (instance is null) { - GameLog.Logger.Warning("Primitive '{PrimitiveType}' is not registered.", entry.Type); - return null; + throw new InvalidOperationException($"Instruction '{entry.Type}' returned no instance."); } if (entry.Type == "dialogue.text") @@ -466,7 +465,7 @@ private void UpdateLastStableSnapshot() if (HasBlockingInstance() || _pendingChoice is not null) return; _lastStableSnapshot = _runtime.CreateSnapshot(); - CheckpointCreated?.Invoke(_lastStableSnapshot); + CheckpointCreated?.Invoke(SnapshotCopy.Clone(_lastStableSnapshot)); } private void CancelPendingChoice() diff --git a/src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs b/src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs index 4b45029..d6764b0 100644 --- a/src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs +++ b/src/Shared/GalNet.Runtime/Runtime/GameRuntime.cs @@ -9,7 +9,7 @@ namespace GalNet.Runtime.Runtime; /// -/// 游戏运行时状态 —— 统一管理游戏的位置、变量、场景状态、调用栈。 +/// 游戏运行时状态 —— 统一管理游戏的位置、变量和场景状态。 /// Primitive module 通过 PrimitiveCreateContext.Runtime 访问此实例。 /// public sealed class GameRuntime : IGameRuntime @@ -28,13 +28,12 @@ public sealed class GameRuntime : IGameRuntime public SettingsContainer Settings { get; } // ── 场景状态 ── - public SceneState SceneState { get; } = new(); - public ISceneInstanceManager SceneInstances { get; } + public SceneState SceneState { get; private set; } = new(); + public ISceneInstanceManager SceneInstances { get; private set; } // ── 内部状态 ── private readonly VariableStore _variables; private readonly ExpressionEvaluator _evaluator; - private readonly Stack<(string NodeId, int EntryIndex)> _callStack = new(); private readonly IVariableService? _variableService; public GameRuntime(ITextResolver? textResolver, string rootNodeId = "", @@ -101,17 +100,6 @@ public IReadOnlyDictionary GetVariables(VariableScope scope public object? EvaluateExpression(string expression) => _evaluator.Evaluate(expression); - // ── 调用栈 ── - - public void PushCallStack(string nodeId) => _callStack.Push((nodeId, 0)); - - public (string nodeId, int entryIndex)? PopCallStack() - { - if (_callStack.Count == 0) return null; - var saved = _callStack.Pop(); - return saved; - } - // ── 存档快照 ── public GameSnapshot CreateSnapshot() @@ -121,8 +109,8 @@ public GameSnapshot CreateSnapshot() Version = GameSnapshot.CurrentFormatVersion, NodeId = CurrentNodeId, EntryIndex = EntryIndex, - Variables = _variables.SaveSnapshot.ToDictionary(pair => pair.Key, pair => CloneVariable(pair.Value)), - SceneState = CloneSceneState(SceneState) + Variables = _variables.SaveSnapshot.ToDictionary(pair => pair.Key, pair => SnapshotCopy.CloneVariable(pair.Value)), + SceneState = SnapshotCopy.CloneSceneState(SceneState) }; } @@ -135,60 +123,9 @@ public void RestoreFrom(GameSnapshot snapshot) EntryIndex = snapshot.EntryIndex; _variables.RestoreSaveFrom(snapshot.Variables); - SceneState.Layers.Clear(); - SceneState.Layers.AddRange(snapshot.SceneState.Layers.Select(layer => new Layer - { - Id = layer.Id, - AssetId = layer.AssetId, - IsVideo = layer.IsVideo, - Flipbook = layer.Flipbook?.Clone(), - Color = layer.Color, - Transform = layer.Transform?.Clone() ?? new LayerTransform(), - Z = layer.Z, - DisplayMode = layer.DisplayMode, - Visible = layer.Visible, - Opacity = layer.Opacity, - EffectInstanceIds = [.. layer.EffectInstanceIds] - })); - SceneState.ActiveControlIds.Clear(); - SceneState.ActiveControlIds.AddRange(snapshot.SceneState.ActiveControlIds); - SceneState.ActiveEffectIds.Clear(); - SceneState.ActiveEffectIds.AddRange(snapshot.SceneState.ActiveEffectIds); - SceneState.ActiveEffects.Clear(); - SceneState.ActiveEffects.AddRange(snapshot.SceneState.ActiveEffects.Select(effect => new ActiveEffectState - { - Id = effect.Id, - ProgramResource = effect.ProgramResource, - InstanceId = effect.InstanceId, - TargetHandleId = effect.TargetHandleId, - Order = effect.Order, - Parameters = effect.Parameters, - AnimationValues = effect.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - })); - SceneState.ActiveParticleEmitters.Clear(); - SceneState.ActiveParticleEmitters.AddRange(snapshot.SceneState.ActiveParticleEmitters.Select(emitter => new ActiveParticleEmitterState - { - InstanceId = emitter.InstanceId, - Definition = emitter.Definition, - Z = emitter.Z, - AnimationValues = emitter.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - })); - SceneState.ActiveAnimations.Clear(); - SceneState.ActiveAnimations.AddRange(snapshot.SceneState.ActiveAnimations.Select(animation => new ActiveAnimationState - { - EntryType = animation.EntryType, - PlaybackHandleId = animation.PlaybackHandleId, - LoopMode = animation.LoopMode, - Parameters = animation.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - })); - // Effect is the canonical owner of its target. Rebuild the Layer-side index so - // older saves and malformed duplicate lists cannot leave a dangling association. - foreach (var layer in SceneState.Layers) layer.EffectInstanceIds.Clear(); - foreach (var effect in SceneState.ActiveEffects.Where(effect => !string.IsNullOrWhiteSpace(effect.TargetHandleId))) - { - var target = SceneState.Layers.FirstOrDefault(layer => layer.Id == effect.TargetHandleId); - if (target is not null) target.EffectInstanceIds.Add(effect.InstanceId); - } + IsGameEnded = false; + SceneState = SnapshotCopy.CloneSceneState(snapshot.SceneState); + SceneInstances = new SceneInstanceManager(SceneState); SceneInstances.Rebuild(SceneState.Layers.Cast() .Concat(SceneState.ActiveEffects.Select(CreateEffectInstance)) .Concat(SceneState.ActiveParticleEmitters.Select(CreateParticleEmitterInstance)) @@ -223,60 +160,4 @@ private static ParticleEmitterInstance CreateParticleEmitterInstance(ActiveParti LoopMode = animation.LoopMode }; - private static GalVariable CloneVariable(GalVariable variable) - { - var copy = new GalVariable { Uid = variable.Uid, Name = variable.Name }; - switch (variable.Type) - { - case VariableType.Bool: copy.SetValue(variable.AsBool()); break; - case VariableType.Int: copy.SetValue(variable.AsInt()); break; - case VariableType.Float: copy.SetValue(variable.AsFloat()); break; - default: copy.SetValue(variable.AsString()); break; - } - return copy; - } - - private static SceneState CloneSceneState(SceneState source) => new() - { - Layers = source.Layers.Select(layer => new Layer - { - Id = layer.Id, - AssetId = layer.AssetId, - IsVideo = layer.IsVideo, - Flipbook = layer.Flipbook?.Clone(), - Color = layer.Color, - Transform = layer.Transform.Clone(), - Z = layer.Z, - DisplayMode = layer.DisplayMode, - Visible = layer.Visible, - Opacity = layer.Opacity, - EffectInstanceIds = [.. layer.EffectInstanceIds] - }).ToList(), - ActiveControlIds = [.. source.ActiveControlIds], - ActiveEffectIds = [.. source.ActiveEffectIds], - ActiveEffects = source.ActiveEffects.Select(effect => new ActiveEffectState - { - Id = effect.Id, - ProgramResource = effect.ProgramResource, - InstanceId = effect.InstanceId, - TargetHandleId = effect.TargetHandleId, - Order = effect.Order, - Parameters = effect.Parameters, - AnimationValues = effect.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - }).ToList(), - ActiveParticleEmitters = source.ActiveParticleEmitters.Select(emitter => new ActiveParticleEmitterState - { - InstanceId = emitter.InstanceId, - Definition = emitter.Definition, - Z = emitter.Z, - AnimationValues = emitter.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - }).ToList(), - ActiveAnimations = source.ActiveAnimations.Select(animation => new ActiveAnimationState - { - EntryType = animation.EntryType, - PlaybackHandleId = animation.PlaybackHandleId, - LoopMode = animation.LoopMode, - Parameters = animation.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) - }).ToList() - }; } diff --git a/test/GalNet.Architecture.Tests/ProjectDependencyTests.cs b/test/GalNet.Architecture.Tests/ProjectDependencyTests.cs index ce593ea..5ee9656 100644 --- a/test/GalNet.Architecture.Tests/ProjectDependencyTests.cs +++ b/test/GalNet.Architecture.Tests/ProjectDependencyTests.cs @@ -12,7 +12,7 @@ public class ProjectDependencyTests ["GalNet.Core"] = [], ["GalNet.Presentation.Abstractions"] = ["GalNet.Core"], ["GalNet.Runtime"] = ["GalNet.Core", "GalNet.Presentation.Abstractions"], - ["GalNet.Primitives.Builtins"] = ["GalNet.Core", "GalNet.Presentation.Abstractions"], + ["GalNet.Primitives.Builtins"] = ["GalNet.Core", "GalNet.Presentation.Abstractions", "GalNet.Runtime"], ["GalNet.Assets"] = ["GalNet.Core"], ["GalNet.Storage.FileSystem"] = ["GalNet.Core", "GalNet.Runtime", "GalNet.Assets"], ["GalNet.Editor.Abstraction"] = ["GalNet.Core"], @@ -25,8 +25,7 @@ public class ProjectDependencyTests "GalNet.Assets" ], ["GalNet.Avalonia.Controls"] = ["GalNet.Core"], - ["GalNet.Avalonia.Rendering"] = ["GalNet.Core"], - ["GalNet.Presentation.Defaults"] = ["GalNet.Presentation.Abstractions"] + ["GalNet.Avalonia.Rendering"] = ["GalNet.Core"] }; private static readonly IReadOnlyDictionary AllowedTestDependencies = diff --git a/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs b/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs index 7b2a455..9fca2b9 100644 --- a/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs +++ b/test/GalNet.Core.Tests/Entry/TargetProfileEntryCatalogTests.cs @@ -84,7 +84,7 @@ public void CompilerRejectsMissingRequiredPrimitiveParameter() } private static DefaultPrimitiveEntryBase Primitive(string type, DynamicParameterTable parameters) => - new DefaultPrimitiveEntryBase(type, parameters, static _ => new ImmediatePrimitiveInstance()); + new DefaultPrimitiveEntryBase(type, parameters); private sealed class TestEntryModule(string id, IEnumerable entries) : EntryModuleBase(id, entries); diff --git a/test/GalNet.Core.Tests/Scene/SceneTests.cs b/test/GalNet.Core.Tests/Scene/SceneTests.cs index 88a1651..cacb45b 100644 --- a/test/GalNet.Core.Tests/Scene/SceneTests.cs +++ b/test/GalNet.Core.Tests/Scene/SceneTests.cs @@ -64,8 +64,8 @@ public void SceneState_Should_Have_Empty_Collections_By_Default() { var scene = new SceneState(); Assert.That(scene.Layers, Is.Empty); - Assert.That(scene.ActiveControlIds, Is.Empty); - Assert.That(scene.ActiveEffectIds, Is.Empty); + Assert.That(scene.ActiveEffects, Is.Empty); + Assert.That(scene.ActiveAnimations, Is.Empty); } [Test] @@ -78,12 +78,10 @@ public void SceneState_Should_Store_Layers_And_Controls() new Layer { Id = "bg", AssetId = "bg_classroom", Z = 0 }, new Layer { Id = "alice", AssetId = "alice_smile", Z = 10 } }, - ActiveControlIds = { "default_dialogue" }, - ActiveEffectIds = { "shake" } + ActiveEffects = { new ActiveEffectState { InstanceId = "shake" } } }; Assert.That(scene.Layers, Has.Count.EqualTo(2)); - Assert.That(scene.ActiveControlIds[0], Is.EqualTo("default_dialogue")); - Assert.That(scene.ActiveEffectIds[0], Is.EqualTo("shake")); + Assert.That(scene.ActiveEffects[0].InstanceId, Is.EqualTo("shake")); } } diff --git a/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs b/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs index d6a6bd3..2271562 100644 --- a/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs +++ b/test/GalNet.Editor.Shared.Tests/EditorDocumentRepositoryTests.cs @@ -72,7 +72,7 @@ public void Load_MigratesVariablesFromProjectSettings_WhenGraphDocumentDoesNotCo ] }; - var repository = new EditorDocumentRepository(BuiltinEntryModules.CreateRecommendedTargetProfile()); + var repository = new EditorDocumentRepository(BuiltinSchemas.CreateCatalog()); var loaded = repository.Load(_tempDir, "Demo", settings); Assert.That(loaded.Document.PlayerVariables.Select(v => v.Name), Is.EqualTo(ExpectedPlayerFlag)); @@ -86,7 +86,7 @@ public void Load_MigratesVariablesFromProjectSettings_WhenGraphDocumentDoesNotCo [Test] public void Save_WritesVariablesAndGroupEntriesIntoProjectFiles() { - var repository = new EditorDocumentRepository(BuiltinEntryModules.CreateRecommendedTargetProfile()); + var repository = new EditorDocumentRepository(BuiltinSchemas.CreateCatalog()); var document = new EditorGraphDocument { Name = "Demo", @@ -140,10 +140,7 @@ public void RepositoryUsesOnlyTheInjectedTargetProfile() var catalog = new TargetProfileEntryCatalog( [new DefaultEntryModule("custom", [ - new DefaultPrimitiveEntryBase( - "custom.pulse", - new DynamicParameterTable([new DynamicParameterDescriptor("count", typeof(int), isRequired: true)]), - static _ => new ImmediatePrimitiveInstance()) + new DefaultPrimitiveEntryBase("custom.pulse", new DynamicParameterTable([new DynamicParameterDescriptor("count", typeof(int), isRequired: true)])) ])]); var graph = new EditorGraphDocument { diff --git a/test/GalNet.Editor.Shared.Tests/TargetProfileEditorCommandTests.cs b/test/GalNet.Editor.Shared.Tests/TargetProfileEditorCommandTests.cs index 2f28f24..14117f0 100644 --- a/test/GalNet.Editor.Shared.Tests/TargetProfileEditorCommandTests.cs +++ b/test/GalNet.Editor.Shared.Tests/TargetProfileEditorCommandTests.cs @@ -14,10 +14,7 @@ public void EntryCommandsOnlyAcceptTypesFromTheirInjectedProfile() var catalog = new TargetProfileEntryCatalog( [new DefaultEntryModule("custom", [ - new DefaultPrimitiveEntryBase( - "custom.pulse", - new DynamicParameterTable([new DynamicParameterDescriptor("count", typeof(int), isRequired: true)]), - static _ => new ImmediatePrimitiveInstance()) + new DefaultPrimitiveEntryBase("custom.pulse", new DynamicParameterTable([new DynamicParameterDescriptor("count", typeof(int), isRequired: true)])) ])]); var handler = new BuiltInEditorCommandHandler(catalog); var document = new EditorProjectDocument diff --git a/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs b/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs index f1a67bb..5205ad1 100644 --- a/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs +++ b/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs @@ -191,7 +191,7 @@ public WorkspaceFixture(bool withProject = false) new NullServiceProvider(), new EditorExtensionRegistry(), localization); - var catalog = BuiltinEntryModules.CreateRecommendedTargetProfile(); + var catalog = BuiltinSchemas.CreateCatalog(); var variableDefinitions = new VariableDefinitionService(DocumentService); var repository = new FakeDocumentRepository(loaded); var mapper = new GraphDocumentMapper(); diff --git a/test/GalNet.Editor.Tests/EntryTypeShortcutTests.cs b/test/GalNet.Editor.Tests/EntryTypeShortcutTests.cs index f90cfec..dc98251 100644 --- a/test/GalNet.Editor.Tests/EntryTypeShortcutTests.cs +++ b/test/GalNet.Editor.Tests/EntryTypeShortcutTests.cs @@ -12,7 +12,7 @@ public class EntryTypeShortcutTests [Test] public void Defaults_Should_Only_Assign_The_Four_Documented_Gestures() { - var commands = BuiltinEntryModules.CreateRecommendedTargetProfile().Definitions.Select(definition => new EntryTypeShortcutCommand(definition)).ToList(); + var commands = BuiltinSchemas.CreateCatalog().Definitions.Select(definition => new EntryTypeShortcutCommand(definition)).ToList(); var assigned = commands.Where(command => command.DefaultGesture is not null).ToDictionary(command => command.EntryType); Assert.That(assigned, Has.Count.EqualTo(4)); @@ -27,7 +27,7 @@ public void Defaults_Should_Only_Assign_The_Four_Documented_Gestures() public void Service_Should_Persist_Disable_Reset_And_Detect_Context_Conflicts() { var settings = new TestSettingsService(); - var commands = BuiltinEntryModules.CreateRecommendedTargetProfile().Definitions.Select(definition => new EntryTypeShortcutCommand(definition)).ToList(); + var commands = BuiltinSchemas.CreateCatalog().Definitions.Select(definition => new EntryTypeShortcutCommand(definition)).ToList(); var service = new EditorShortcutService(commands, settings); var video = commands.Single(command => command.EntryType == PlayVideoEntry.TypeId); diff --git a/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs b/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs index d92443f..b1e509b 100644 --- a/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs +++ b/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs @@ -11,7 +11,7 @@ public class GraphEditingServiceTests [Test] public void InsertEntries_Should_Insert_A_Batch_At_Requested_Position_And_Renumber() { - var service = new GraphEditingService(BuiltinEntryModules.CreateRecommendedTargetProfile()); + var service = new GraphEditingService(BuiltinSchemas.CreateCatalog()); var node = new GraphNode(new Group { Name = "Group" }, GraphNodeKind.LinearGroup); var inserted = service.InsertEntries(node, 0, 3); @@ -24,7 +24,7 @@ public void InsertEntries_Should_Insert_A_Batch_At_Requested_Position_And_Renumb [Test] public void InsertEntries_Should_Clamp_Index_And_Reject_Invalid_Count() { - var service = new GraphEditingService(BuiltinEntryModules.CreateRecommendedTargetProfile()); + var service = new GraphEditingService(BuiltinSchemas.CreateCatalog()); var node = new GraphNode(new Group { Name = "Group" }, GraphNodeKind.LinearGroup); var appended = service.InsertEntries(node, int.MaxValue, 2); @@ -48,7 +48,7 @@ public void Variable_Target_Field_Should_Expose_Definitions_And_Update_The_Entry } }; - var catalog = BuiltinEntryModules.CreateRecommendedTargetProfile(); + var catalog = BuiltinSchemas.CreateCatalog(); entry.ConfigureParameterFields(catalog, [], ["first", "second"]); var target = entry.ParameterFields.OfType().Single(); diff --git a/test/GalNet.Editor.Tests/packages.lock.json b/test/GalNet.Editor.Tests/packages.lock.json index 89935b3..42cfa03 100644 --- a/test/GalNet.Editor.Tests/packages.lock.json +++ b/test/GalNet.Editor.Tests/packages.lock.json @@ -352,7 +352,6 @@ "GalNet.Avalonia.Rendering": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "Irihi.Ursa": "[2.1.0, )", "LibVLCSharp": "[3.10.0, )", "LibVLCSharp.Avalonia": "[3.10.0, )", @@ -435,13 +434,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "galnet.primitives.builtins": { "type": "Project", "dependencies": { diff --git a/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs b/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs index 05b0936..61c8623 100644 --- a/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs +++ b/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs @@ -57,10 +57,7 @@ public void CompileAndLoadPromoteBatchIdOutOfPrimitiveArguments() new DynamicParameterDescriptor("batchId", typeof(string)) ]); var catalog = new TargetProfileEntryCatalog( - [new TestEntryModule("test", [new DefaultPrimitiveEntryBase( - "test.run", - parameters, - context => new ImmediatePrimitiveInstance(batchId: context.BatchId))])]); + [new TestEntryModule("test", [new DefaultPrimitiveEntryBase("test.run", parameters)])]); var source = new GroupDocument { Entries = @@ -154,7 +151,7 @@ public void Compile_ExpandsCrossFadeIntoPrimitiveAnimationPlan() ] }; - var result = GalgroupCompiler.Compile(raw, BuiltinEntryModules.CreateRecommendedTargetProfile()); + var result = GalgroupCompiler.Compile(raw, BuiltinSchemas.CreateCatalog()); Assert.That(result.Document.Kind, Is.EqualTo(GroupDocumentKind.Compiled)); Assert.That(result.Document.Entries, Has.Count.EqualTo(1)); @@ -196,7 +193,7 @@ public void Compile_ExpandsSlideIntoIncomingReplaceAndOutgoingAdditiveTracks() ] }; - var plan = GalgroupCompiler.Compile(raw, BuiltinEntryModules.CreateRecommendedTargetProfile()).Document.Entries.Single().Arguments.GetProperty("plan"); + var plan = GalgroupCompiler.Compile(raw, BuiltinSchemas.CreateCatalog()).Document.Entries.Single().Arguments.GetProperty("plan"); var tracks = plan.GetProperty("tracks"); Assert.That(plan.GetProperty("blocking").GetBoolean(), Is.True); @@ -236,7 +233,7 @@ public void Compile_ExpandsBlindsIntoAnAnimatableMaskEffect() ] }; - var plan = GalgroupCompiler.Compile(raw, BuiltinEntryModules.CreateRecommendedTargetProfile()).Document.Entries.Single().Arguments.GetProperty("plan"); + var plan = GalgroupCompiler.Compile(raw, BuiltinSchemas.CreateCatalog()).Document.Entries.Single().Arguments.GetProperty("plan"); var track = plan.GetProperty("tracks")[0]; var events = plan.GetProperty("events"); @@ -283,7 +280,7 @@ public void Compile_ExpandsCustomColorFadeIntoTransientOverlayPlan() ] }; - var result = GalgroupCompiler.Compile(raw, BuiltinEntryModules.CreateRecommendedTargetProfile()); + var result = GalgroupCompiler.Compile(raw, BuiltinSchemas.CreateCatalog()); var plan = result.Document.Entries.Single().Arguments.GetProperty("plan"); var events = plan.GetProperty("events"); @@ -324,7 +321,7 @@ public void Compile_ExpandsPresetColorFadesIntoExpectedOverlay(string transition ] }; - var plan = GalgroupCompiler.Compile(raw, BuiltinEntryModules.CreateRecommendedTargetProfile()).Document.Entries.Single().Arguments.GetProperty("plan"); + var plan = GalgroupCompiler.Compile(raw, BuiltinSchemas.CreateCatalog()).Document.Entries.Single().Arguments.GetProperty("plan"); Assert.That(plan.GetProperty("events")[0].GetProperty("parameters").GetProperty("color").GetString(), Is.EqualTo(expectedColor)); } } diff --git a/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs b/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs index 088660c..cd2aabc 100644 --- a/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs +++ b/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs @@ -3,6 +3,7 @@ using GalNet.Core.Graph; using GalNet.Core.Primitives; using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.Execution; using GalNet.Presentation.Abstractions.View; using GalNet.Primitives.Builtins; using GalNet.Runtime.Engine; @@ -12,14 +13,65 @@ namespace GeneralTest.Runtime; public class GameEnginePrimitiveDispatchTests { + [Test] + public void UncompiledAuthoringEntriesAreRejectedInsteadOfSilentlySkipped() + { + using var view = new GameRegistration().AddBuiltins().BuildView(); + Assert.Throws(() => new GameEngine(GraphWithGroup(new WaitEntry()), view)); + } + [Test] + public async Task RestoreCancelsOldScopeAndIsolatesSceneAndSnapshots() + { + PrimitiveCreateContext? oldContext = null; + using var view = View(Entry("test.wait", context => + { + oldContext = context; + return new ControlledInstance(true, false, null); + })); + using var engine = new GameEngine(GraphWithGroup(Primitive("test.wait")), view); + var snapshot = engine.CreateSaveData(); + snapshot.SceneState.Layers.Add(new Layer { Id = "restored", AssetId = "saved" }); + await engine.AdvanceAsync(); + + engine.RestoreFrom(snapshot); + oldContext!.Runtime.SceneState.Layers.Clear(); + snapshot.SceneState.Layers.Clear(); + var returned = engine.LastStableSnapshot; + returned.SceneState.Layers.Clear(); + + Assert.Multiple(() => + { + Assert.That(oldContext.ScopeCancellation.IsCancellationRequested, Is.True); + Assert.Throws(() => oldContext.Runtime.EndGame()); + Assert.That(engine.Runtime.SceneState.Layers.Single().AssetId, Is.EqualTo("saved")); + Assert.That(engine.CreateSaveData().SceneState.Layers.Single().AssetId, Is.EqualTo("saved")); + }); + } + + [Test] + public async Task InvalidSaveVersionDoesNotCancelTheCurrentRun() + { + PrimitiveCreateContext? context = null; + using var view = View(Entry("test.wait", value => + { + context = value; + return new ControlledInstance(true, false, null); + })); + using var engine = new GameEngine(GraphWithGroup(Primitive("test.wait")), view); + await engine.AdvanceAsync(); + + Assert.Throws(() => engine.RestoreFrom(new GalNet.Core.Runtime.GameSnapshot { Version = -1 })); + Assert.That(context!.ScopeCancellation.IsCancellationRequested, Is.False); + } [Test] public async Task Video_segment_advances_hold_engine_and_previous_snapshot_until_natural_completion() { var video = new SegmentedVideoPresenter(); var markers = 0; - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(videoPresenter: video).Concat( - [new DefaultEntryModule("test", [Entry("test.mark", context => - new ImmediatePrimitiveInstance(() => markers++, batchId: context.BatchId))])])); + using var view = new GameRegistration().AddBuiltins(videoPresenter: video) + .RegisterSchema(new DefaultEntryModule("test", [new DefaultPrimitiveEntryBase("test.mark", DynamicParameterTable.Empty)])) + .RegisterInstruction("test.mark", context => new ImmediatePrimitiveInstance(() => markers++, batchId: context.BatchId)) + .BuildView(); using var engine = new GameEngine(GraphWithGroup( Primitive("video.play", new { asset = "clip", skipPoints = new[] { 5, 12 } }), Primitive("test.mark")), view); @@ -226,7 +278,7 @@ public async Task BlockingInstanceKeepsOldSnapshotUntilItCompletes() public async Task RecommendedLayerDialogueAndChoiceRunThroughTheNewRuntimeBoundary() { var presenter = new StoryPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(presenter, presenter)); + using var view = new GameRegistration().AddBuiltins(presenter, presenter).BuildView(); var graph = new GraphModel { RootNodeId = "scene", @@ -246,8 +298,7 @@ public async Task RecommendedLayerDialogueAndChoiceRunThroughTheNewRuntimeBounda opacity = 1, displayMode = "Native" }), - Primitive(TextEntry.TypeId, new { speaker = "Alice", content = "Continue?" }), - Primitive("unsupported.demo") + Primitive(TextEntry.TypeId, new { speaker = "Alice", content = "Continue?" }) } }, new Branch @@ -296,20 +347,20 @@ public async Task RecommendedLayerDialogueAndChoiceRunThroughTheNewRuntimeBounda } [Test] - public async Task UnknownPrimitiveAndDispatchFailureDoNotStopLaterEntries() + public void MissingInstructionFailsBeforeStoryExecution() { - var completed = 0; - using var view = View( - Entry("test.fail", _ => throw new InvalidOperationException("expected")), - Entry("test.mark", context => new ImmediatePrimitiveInstance(() => completed++, batchId: context.BatchId))); - using var engine = new GameEngine(GraphWithGroup( - Primitive("missing.run"), - Primitive("test.fail"), - Primitive("test.mark")), view); - - await engine.AdvanceAsync(); + using var view = View(Entry("test.mark", context => new ImmediatePrimitiveInstance(batchId: context.BatchId))); + Assert.That(() => new GameEngine(GraphWithGroup(Primitive("missing.run")), view), + Throws.InvalidOperationException.With.Message.Contains("missing.run")); + } - Assert.That(completed, Is.EqualTo(1)); + [Test] + public void DispatchFailureIsReportedWithoutSilentlyAdvancing() + { + using var view = View(Entry("test.fail", _ => throw new InvalidOperationException("expected"))); + using var engine = new GameEngine(GraphWithGroup(Primitive("test.fail")), view); + Assert.That(async () => await engine.AdvanceAsync(), Throws.InvalidOperationException.With.Message.Contains("expected")); + Assert.That(engine.EntryIndex, Is.Zero); } private static GraphModel GraphWithGroup(params PrimitiveEntry[] entries) @@ -325,13 +376,16 @@ private static PrimitiveEntry Primitive(string typeId, string? batchId = null) = private static PrimitiveEntry Primitive(string typeId, object arguments, string? batchId = null) => new(typeId, JsonSerializer.SerializeToElement(arguments), batchId); - private static DefaultPrimitiveEntryBase Entry( + private static PrimitiveImplementation Entry( string name, Func factory) => - new(name, DynamicParameterTable.Empty, factory); + new(name, factory); - private static CompositeGameView View(params PrimitiveEntryBase[] entries) => - new([new TestModule("test", entries)]); + private static CompositeGameView View(params PrimitiveImplementation[] entries) => + new GameRegistration() + .RegisterSchema(new DefaultEntryModule("test", entries.Select(entry => new DefaultPrimitiveEntryBase(entry.Type, DynamicParameterTable.Empty)))) + .RegisterImplementation(new PrimitiveModule(entries)) + .BuildView(); private static async Task WaitUntilAsync(Func condition) { @@ -340,7 +394,6 @@ private static async Task WaitUntilAsync(Func condition) await Task.Delay(10, timeout.Token); } - private sealed class TestModule(string id, IEnumerable entries) : EntryModuleBase(id, entries); private sealed class SegmentedVideoPresenter : IVideoPresenter { diff --git a/test/GalNet.IntegrationTests/GameInstallationTests.cs b/test/GalNet.IntegrationTests/GameInstallationTests.cs index d73bb9b..7ce900c 100644 --- a/test/GalNet.IntegrationTests/GameInstallationTests.cs +++ b/test/GalNet.IntegrationTests/GameInstallationTests.cs @@ -28,11 +28,15 @@ public async Task Exporter_writes_direct_content_and_installed_resources() await File.WriteAllBytesAsync(Path.Combine(project, "Assets", "hero.png"), "hero"u8.ToArray()); await File.WriteAllTextAsync(Path.Combine(project, "Assets", "hero.png.meta"), """{"id":"hero","type":"sprite","path":"hero.png","filter":"bilinear","compress":"none"}"""); await File.WriteAllTextAsync(Path.Combine(project, "Graph", "graph.json"), """{"name":"export-test","rootNodeId":"","nodes":[],"edges":[]}"""); - await File.WriteAllTextAsync(Path.Combine(project, "settings.json"), "{}"); + await File.WriteAllTextAsync(Path.Combine(project, "settings.json"), """ + { "audioTracks": [ { "id": "ambient", "bus": "bgm", "defaultGain": 0.3, "maxVoices": 2, "overflowPolicy": "StopOldest" } ] } + """); var development = await GameInstallation.OpenAsync(project); Assert.That(development.IsPackaged, Is.False); - Assert.That((await development.CreateContentProvider(resourceTypes, galleryTypes).LoadAsync()).Graph.Name, Is.EqualTo("export-test")); + var developmentContent = await development.CreateContentProvider(resourceTypes, galleryTypes).LoadAsync(); + Assert.That(developmentContent.Graph.Name, Is.EqualTo("export-test")); + Assert.That(developmentContent.Settings.AudioTracks.Single().Id, Is.EqualTo("ambient")); using (var sourceAssets = new AssetManager(development.CreateAssetProviders(resourceTypes))) Assert.That((await sourceAssets.GetFileAsync("hero"))?.Path, Is.EqualTo("hero.png")); @@ -43,7 +47,10 @@ public async Task Exporter_writes_direct_content_and_installed_resources() Assert.That(File.Exists(Path.Combine(installation.RootDirectory, "gallery.json")), Is.True); Assert.That(File.Exists(Path.Combine(installation.RootDirectory, "Graph", "graph.json")), Is.True); Assert.That(File.Exists(Path.Combine(installation.RootDirectory, "Assets", "Paks", "000-base.pak")), Is.True); - Assert.That((await installation.CreateContentProvider(resourceTypes, galleryTypes).LoadAsync()).Graph.Name, Is.EqualTo("export-test")); + var installedContent = await installation.CreateContentProvider(resourceTypes, galleryTypes).LoadAsync(); + Assert.That(installedContent.Graph.Name, Is.EqualTo("export-test")); + Assert.That(installedContent.Settings.AudioTracks.Single().DefaultGain, Is.EqualTo(.3f)); + Assert.That(installedContent.Settings.AudioTracks.Single().OverflowPolicy, Is.EqualTo(GalNet.Core.Audio.AudioOverflowPolicy.StopOldest)); using var assets = new AssetManager(installation.CreateAssetProviders(resourceTypes)); Assert.That((await assets.GetFileAsync("hero"))?.Path, Is.EqualTo("hero.png")); diff --git a/test/GalNet.IntegrationTests/PersistentSceneRestorerTests.cs b/test/GalNet.IntegrationTests/PersistentSceneRestorerTests.cs index a1de578..58a6b09 100644 --- a/test/GalNet.IntegrationTests/PersistentSceneRestorerTests.cs +++ b/test/GalNet.IntegrationTests/PersistentSceneRestorerTests.cs @@ -81,7 +81,7 @@ public Task AnimateAsync(AnimationRequest request, Cancellatio public Task PlayAnimationPlanAsync( AnimationPlanDefinition plan, - CancellationToken cancellationToken) => + CancellationToken cancellationToken, Func? beforeFrame = null) => Task.FromResult(new AnimationPlanPlayResult { Outcome = AnimationOutcome.Completed }); public bool CompleteAnimationImmediately(string playbackHandleId) => false; diff --git a/test/GalNet.IntegrationTests/packages.lock.json b/test/GalNet.IntegrationTests/packages.lock.json index 1252dc0..1cb7639 100644 --- a/test/GalNet.IntegrationTests/packages.lock.json +++ b/test/GalNet.IntegrationTests/packages.lock.json @@ -303,7 +303,6 @@ "GalNet.Avalonia.Rendering": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "Irihi.Ursa": "[2.1.0, )", "LibVLCSharp": "[3.10.0, )", "LibVLCSharp.Avalonia": "[3.10.0, )", @@ -346,13 +345,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "galnet.primitives.builtins": { "type": "Project", "dependencies": { @@ -384,7 +376,6 @@ "GalNet.Avalonia.GameView": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "GalNet.Primitives.Builtins": "[1.0.0, )", "GalNet.Runtime": "[1.0.0, )", "GalNet.Storage.FileSystem": "[1.0.0, )", diff --git a/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs b/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs index 43e5603..f7654b0 100644 --- a/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs +++ b/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using GalNet.Core.Entry; using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; using GalNet.Presentation.Abstractions.View; using GalNet.Runtime.Runtime; @@ -8,19 +9,27 @@ namespace GeneralTest.Runtime; public class CompositeGameViewTests { - private static readonly string[] ExpectedDisposalOrder = ["third", "second", "first"]; - private static readonly string[] ExpectedDisposalErrors = ["third failed", "second failed"]; - [Test] - public void GameViewExposesOnlySinglePrimitiveDispatchResponsibilities() + public async Task AsyncDisposalThroughGameViewInterfaceUsesModuleAsyncCleanup() { - Assert.That(typeof(IGameView).GetInterfaces(), Is.EquivalentTo(new[] { typeof(IDisposable) })); - Assert.That(typeof(IGameView).GetProperty("ActiveInstances"), Is.Null); - Assert.That(typeof(IGameView).GetMethod("Advance"), Is.Null); + var module = new AsyncDisposableModule(); + await using (IGameView view = new GameRegistration().RegisterImplementation(module).BuildView()) + Assert.That(module.CleanedUp, Is.False); + Assert.That(module.CleanedUp, Is.True); } + private sealed class AsyncDisposableModule() : PrimitiveModule([]) + { + public bool CleanedUp { get; private set; } + public override async ValueTask DisposeAsync() + { + await Task.Yield(); + CleanedUp = true; + await base.DisposeAsync(); + } + } [Test] - public void DispatchNormalizesArgumentsAndBuildsTheCompleteFactoryContext() + public void SchemaAndImplementationCanBeRegisteredIndependently() { PrimitiveCreateContext? received = null; var parameters = new DynamicParameterTable( @@ -28,141 +37,91 @@ public void DispatchNormalizesArgumentsAndBuildsTheCompleteFactoryContext() new DynamicParameterDescriptor("visible", typeof(bool), isRequired: true), new DynamicParameterDescriptor("opacity", typeof(float), defaultValue: JsonSerializer.SerializeToElement(1f)) ]); - var definition = new DefaultPrimitiveEntryBase("layer.show", parameters, context => + using var registration = new GameRegistration(); + registration.RegisterSchema(new DefaultEntryModule("layer", [new DefaultPrimitiveEntryBase("layer.show", parameters)])); + Assert.That(registration.BuildCatalog().Get("layer.show").DynamicParameters, Is.SameAs(parameters)); + registration.RegisterInstruction("layer.show", context => { received = context; return new ImmediatePrimitiveInstance(batchId: context.BatchId); }); - using var view = new CompositeGameView([new TestModule("layer", [definition])]); - var runtime = new GameRuntime(null); - var entry = Entry("layer.show", new { visible = true }, "opening"); - - var instance = view.Dispatch(entry, runtime, CancellationToken.None); - + using var view = registration.BuildView(); + var instance = view.Dispatch(Entry("layer.show", new { visible = true }, "opening"), new GameRuntime(null), CancellationToken.None); Assert.Multiple(() => { - Assert.That(instance, Is.Not.Null); - Assert.That(instance!.IsCompleted, Is.True); + Assert.That(instance.IsCompleted, Is.True); Assert.That(instance.BatchId, Is.EqualTo("opening")); - Assert.That(received!.Definition, Is.SameAs(definition)); - Assert.That(received.Entry, Is.Not.SameAs(entry)); - Assert.That(received.Parameters, Is.SameAs(parameters)); - Assert.That(received.Runtime, Is.SameAs(runtime)); - Assert.That(received.Arguments.GetProperty("visible").GetBoolean(), Is.True); + Assert.That(received!.Parameters, Is.SameAs(parameters)); Assert.That(received.Arguments.GetProperty("opacity").GetSingle(), Is.EqualTo(1f)); - Assert.That(received.BatchId, Is.EqualTo("opening")); }); } [Test] - public void UnknownPrimitiveSafelySkips() + public void ModulesWithTheSamePrefixAndNameCanContributeDifferentInstructions() { - using var view = new CompositeGameView([]); - Assert.That(view.Dispatch(Entry("missing.run", new { }), new GameRuntime(null), CancellationToken.None), Is.Null); + using var registration = new GameRegistration(); + registration.RegisterSchema(Schema("custom", "custom.first")); + registration.RegisterSchema(Schema("custom", "custom.second")); + registration.RegisterInstruction("custom.first", context => new ImmediatePrimitiveInstance(batchId: context.BatchId)); + registration.RegisterInstruction("custom.second", context => new ImmediatePrimitiveInstance(batchId: context.BatchId)); + using var view = registration.BuildView(); + Assert.That(view.CanExecute("custom.first") && view.CanExecute("custom.second"), Is.True); } [Test] - public void ScopeRejectsDuplicateModulesAndPrimitiveNames() + public void DuplicateFullInstructionIdsAndMissingSchemasAreRejected() { - var first = new TestModule("one", [Definition("custom.run")]); - var duplicateModule = new TestModule("one", [Definition("custom.other")]); - var duplicateEntry = new TestModule("two", [Definition("custom.run")]); - - Assert.That(() => new CompositeGameView([first, duplicateModule]), Throws.InvalidOperationException); - Assert.That(() => new CompositeGameView([first, duplicateEntry]), Throws.InvalidOperationException); + using var duplicate = new GameRegistration(); + duplicate.RegisterSchema(Schema("test", "test.run")); + duplicate.RegisterInstruction("test.run", _ => new ImmediatePrimitiveInstance()); + duplicate.RegisterInstruction("test.run", _ => new ImmediatePrimitiveInstance()); + Assert.That(duplicate.BuildView, Throws.InvalidOperationException.With.Message.Contains("already registered")); + + using var missing = new GameRegistration(); + missing.RegisterInstruction("test.run", _ => new ImmediatePrimitiveInstance()); + Assert.That(missing.BuildView, Throws.InvalidOperationException.With.Message.Contains("no primitive schema")); } [Test] - public void DispatchRejectsAnInstanceWhoseBatchIdDiffersFromTheEntry() + public void MissingImplementationAndWrongBatchAreExplicitErrors() { - using var view = new CompositeGameView( - [new TestModule("test", [new DefaultPrimitiveEntryBase( - "test.run", - DynamicParameterTable.Empty, - _ => new ImmediatePrimitiveInstance(batchId: "wrong"))])]); - - Assert.That( - () => view.Dispatch(Entry("test.run", new { }, "expected"), new GameRuntime(null), CancellationToken.None), + using var registration = new GameRegistration(); + registration.RegisterSchema(Schema("test", "test.run", "test.missing")); + registration.RegisterInstruction("test.run", _ => new ImmediatePrimitiveInstance(batchId: "wrong")); + using var view = registration.BuildView(); + Assert.That(() => view.Dispatch(Entry("test.missing", new { }), new GameRuntime(null), CancellationToken.None), + Throws.InvalidOperationException.With.Message.Contains("no registered implementation")); + Assert.That(() => view.Dispatch(Entry("test.run", new { }, "expected"), new GameRuntime(null), CancellationToken.None), Throws.InvalidOperationException.With.Message.Contains("BatchId")); } [Test] - public void DisposeReleasesModulesInReverseOrderAndAggregatesFailures() + public void DisposalIsReverseOrderedIdempotentAndIncludesEveryModuleDespiteErrors() { - var disposalOrder = new List(); - var first = new DisposableModule("first", disposalOrder); - var second = new DisposableModule("second", disposalOrder, new InvalidOperationException("second failed")); - var third = new DisposableModule("third", disposalOrder, new IOException("third failed")); - var view = new CompositeGameView([first, second, third]); - - var exception = Assert.Throws(view.Dispose); - - Assert.Multiple(() => - { - Assert.That(disposalOrder, Is.EqualTo(ExpectedDisposalOrder)); - Assert.That(exception!.InnerExceptions, Has.Count.EqualTo(2)); - Assert.That(exception.InnerExceptions.Select(error => error.Message), - Is.EquivalentTo(ExpectedDisposalErrors)); - Assert.That(() => view.Dispatch(Entry("missing.run", new { }), new GameRuntime(null), CancellationToken.None), - Throws.TypeOf()); - }); - + var order = new List(); + using var registration = new GameRegistration(); + registration.RegisterImplementation(new DisposableModule(1, order)); + registration.RegisterImplementation(new DisposableModule(2, order, fail: true)); + var view = registration.BuildView(); + Assert.That(() => registration.RegisterSchema(Schema("test", "test.run")), Throws.TypeOf()); + Assert.That(view.Dispose, Throws.TypeOf()); + Assert.That(order, Is.EqualTo(new[] { 2, 1 })); Assert.DoesNotThrow(view.Dispose); } - [Test] - public void PrimitiveInstanceDispatchesOnceAndCompletionIsMonotonic() - { - var instance = new ControlledInstance(false, false, null); - var completed = 0; - instance.Completed += _ => completed++; - - instance.Dispatch(); - instance.Complete(); - instance.Complete(); - - Assert.That(instance.IsCompleted, Is.True); - Assert.That(completed, Is.EqualTo(1)); - Assert.That(() => instance.Dispatch(), Throws.InvalidOperationException); - } - - private static PrimitiveEntry Entry(string type, object arguments, string? batchId = null) => - new(type, JsonSerializer.SerializeToElement(arguments), batchId); - - private static PrimitiveEntryBase Definition(string name) => new DefaultPrimitiveEntryBase( - name, - DynamicParameterTable.Empty, - context => new ImmediatePrimitiveInstance(batchId: context.BatchId)); + private static IEntryModule Schema(string module, params string[] types) => + new DefaultEntryModule(module, types.Select(type => new DefaultPrimitiveEntryBase(type, DynamicParameterTable.Empty))); - private sealed class TestModule(string id, IEnumerable entries) : EntryModuleBase(id, entries); + private static PrimitiveEntry Entry(string type, object arguments, string? batch = null) => + new(type, JsonSerializer.SerializeToElement(arguments), batch); - private sealed class DisposableModule : EntryModuleBase + private sealed class DisposableModule(int id, List order, bool fail = false) : PrimitiveModule([]) { - private readonly string _id; - private readonly ICollection _disposalOrder; - private readonly Exception? _disposalError; - - public DisposableModule(string id, ICollection disposalOrder, Exception? disposalError = null) - : base(id, []) - { - _id = id; - _disposalOrder = disposalOrder; - _disposalError = disposalError; - } - public override void Dispose() { + order.Add(id); + if (fail) throw new IOException("expected"); base.Dispose(); - _disposalOrder.Add(_id); - if (_disposalError is not null) throw _disposalError; } } - - private sealed class ControlledInstance(bool blocking, bool skippable, string? batchId) : PrimitiveInstance(batchId) - { - public override bool IsBlocking { get; } = blocking; - public override bool IsSkippable { get; } = skippable; - protected override void OnDispatch() { } - public void Complete() => TryComplete(); - } } diff --git a/test/GalNet.Presentation.Tests/packages.lock.json b/test/GalNet.Presentation.Tests/packages.lock.json index 54040ec..fde8e4d 100644 --- a/test/GalNet.Presentation.Tests/packages.lock.json +++ b/test/GalNet.Presentation.Tests/packages.lock.json @@ -233,7 +233,6 @@ "GalNet.Avalonia.Rendering": "[1.0.0, )", "GalNet.Core": "[1.0.0, )", "GalNet.Presentation.Abstractions": "[1.0.0, )", - "GalNet.Presentation.Defaults": "[1.0.0, )", "Irihi.Ursa": "[2.1.0, )", "LibVLCSharp": "[3.10.0, )", "LibVLCSharp.Avalonia": "[3.10.0, )", @@ -258,13 +257,6 @@ "GalNet.Core": "[1.0.0, )" } }, - "galnet.presentation.defaults": { - "type": "Project", - "dependencies": { - "GalNet.Presentation.Abstractions": "[1.0.0, )", - "LibVLCSharp": "[3.10.0, )" - } - }, "galnet.runtime": { "type": "Project", "dependencies": { diff --git a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs index 6f0a13a..185e322 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs @@ -5,7 +5,7 @@ namespace GeneralTest.Entry; public class EntryModelTests { - private static TargetProfileEntryCatalog Catalog => BuiltinEntryModules.CreateRecommendedTargetProfile(); + private static TargetProfileEntryCatalog Catalog => BuiltinSchemas.CreateCatalog(); [Test] public void Registry_Should_Create_All_BuiltIn_Entries() diff --git a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs index dadc3b2..f73e5e9 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs @@ -1,125 +1,95 @@ +using System.Text.Json; using GalNet.Core.Entry; using GalNet.Core.Primitives; using GalNet.Presentation.Abstractions.View; using GalNet.Primitives.Builtins; +using GalNet.Runtime.Runtime; namespace GeneralTest.Entry; public class EntryModuleTests { [Test] - public void ModuleFreezesBothIndependentTables() + public void BuiltinContractsValidateCustomImplementationsBeforeFactoryInvocation() { - var primitives = new List { Primitive("custom.first") }; - var composites = new List { Composite("authoring.first") }; - - var module = new TestEntryModule("authoring", primitives, composites); - primitives.Add(Primitive("custom.second")); - composites.Add(Composite("authoring.second")); - - Assert.That(module.PrimitiveEntries.Keys, Is.EqualTo(["custom.first"])); - Assert.That(module.CompositeEntries.Keys, Is.EqualTo(["authoring.first"])); - Assert.That(() => ((IDictionary)module.PrimitiveEntries).Clear(), Throws.TypeOf()); - Assert.That(() => ((IDictionary)module.CompositeEntries).Clear(), Throws.TypeOf()); + var called = false; + using var view = new GameRegistration().AddBuiltinSchemas() + .RegisterInstruction("video.play", context => + { + called = true; + return new ImmediatePrimitiveInstance(() => { }, batchId: context.BatchId); + }).BuildView(); + Assert.Throws(() => view.Dispatch(new PrimitiveEntry("video.play", + JsonSerializer.SerializeToElement(new { asset = "clip", skipPoints = new[] { 4, 2 } })), + new GameRuntime(null), CancellationToken.None)); + Assert.That(called, Is.False); } [Test] - public void ModuleRejectsDuplicateOrCrossKindDefinitions() + public void AnimationContractRejectsUnknownEmbeddedEvents() { - Assert.That( - () => new TestEntryModule("test", [Primitive("custom.duplicate"), Primitive("custom.duplicate")]), - Throws.ArgumentException.With.Message.Contains("more than once")); - Assert.That( - () => new TestEntryModule("test", [Primitive("custom.same")], [Composite("custom.same")]), - Throws.ArgumentException.With.Message.Contains("both primitive and composite")); + var schema = BuiltinSchemas.CreateCatalog().Get("animation.play"); + Assert.Throws(() => PrimitiveArgumentHelper.Normalize(schema.DynamicParameters!, + JsonSerializer.SerializeToElement(new + { + plan = new { durationFrames = 1, events = new[] { new { frame = 0, type = "custom.unsupported", parameters = new { } } } } + }))); } - [Test] - public void DefaultPrimitiveDefinitionCallsItsFactoryForEveryInvocation() + public void SchemaOnlyContainsAllBuiltinContractsAndNoRuntimeFactories() { - var created = 0; - var entry = new DefaultPrimitiveEntryBase( - "custom.pulse", - DynamicParameterTable.Empty, - _ => { created++; return new ImmediatePrimitiveInstance(); }); - var compiled = new PrimitiveEntry( - "custom.pulse", - System.Text.Json.JsonSerializer.SerializeToElement(new { }), - "batch"); - var context = new PrimitiveCreateContext( - entry, - compiled, - new GalNet.Runtime.Runtime.GameRuntime(null), - CancellationToken.None); - - Assert.That(entry.CreateInstance(context), Is.Not.Null); - Assert.That(entry.CreateInstance(context), Is.Not.Null); - Assert.That(created, Is.EqualTo(2)); + using var registration = new GameRegistration().AddBuiltinSchemas(); + var catalog = registration.BuildCatalog(); + Assert.That(catalog.TryGet("audio.play", out _), Is.True); + Assert.That(catalog.TryGet("video.play", out _), Is.True); + Assert.That(catalog.Get("transition.crossFade").Kind, Is.EqualTo(EntryKind.Composite)); + Assert.That(typeof(PrimitiveEntryBase).GetMethod("CreateInstance"), Is.Null); + Assert.That(typeof(IEntryModule).GetInterfaces(), Does.Not.Contain(typeof(IDisposable))); + using var view = registration.BuildView(); + Assert.That(view.CanExecute("variable.set"), Is.False); } [Test] - public void ProfileMergesModuleTablesAndRejectsCrossModuleTypeCollisions() + public void PartialBuiltinImplementationDoesNotRegisterOtherDomains() { - var profile = new TargetProfileEntryCatalog( - [ - new TestEntryModule("runtime", [Primitive("custom.pulse")]), - new TestEntryModule("authoring", compositeEntries: [Composite("authoring.expand")]) - ]); - - Assert.That(profile.Definitions.Select(item => item.Type), Is.EquivalentTo(["custom.pulse", "authoring.expand"])); - Assert.That( - () => new TargetProfileEntryCatalog( - [ - new TestEntryModule("one", [Primitive("custom.pulse")]), - new TestEntryModule("two", [Primitive("custom.pulse")]) - ]), - Throws.InvalidOperationException.With.Message.Contains("already present")); + using var registration = new GameRegistration().AddBuiltinSchemas().AddBuiltinVariables(); + using var view = registration.BuildView(); + var runtime = new GameRuntime(null); + view.Dispatch(new PrimitiveEntry("variable.set", JsonSerializer.SerializeToElement(new { target = "score", expression = "3" })), + runtime, CancellationToken.None); + Assert.That(runtime.GetVariable("score")!.AsInt(), Is.EqualTo(3)); + Assert.That(view.CanExecute("audio.play"), Is.False); } [Test] - public void RecommendedAnimationModuleContainsPrimitiveAndCompositeTables() + public void CustomImplementationCanUseBuiltinSchemaWithoutBuiltinExecution() { - var modules = BuiltinEntryModules.CreateRecommended(); - var animation = modules.Single(module => module.Id == "animation"); - - Assert.That(animation.PrimitiveEntries.Keys, Is.EquivalentTo(["animation.animate", "animation.play", "animation.stop"])); - Assert.That(animation.CompositeEntries, Is.Not.Empty); - Assert.That(animation.CompositeEntries.Keys, Does.Contain("transition.crossFade")); - Assert.That(() => ((IList)modules).Clear(), Throws.TypeOf()); + var calls = 0; + using var registration = new GameRegistration().AddBuiltinSchemas() + .RegisterInstruction("audio.play", context => new ImmediatePrimitiveInstance(() => calls++, batchId: context.BatchId)); + using var view = registration.BuildView(); + view.Dispatch(new PrimitiveEntry("audio.play", JsonSerializer.SerializeToElement(new { asset = "clip" })), new GameRuntime(null), CancellationToken.None); + Assert.That(calls, Is.EqualTo(1)); + Assert.That(view.CanExecute("variable.set"), Is.False); } [Test] - public void MountedViewAndTargetProfileUseTheSamePrimitiveDefinition() + public void MainBuiltinRegistrationAndSeparateRegistrationExposeTheSameInstructions() { - using var module = new TestEntryModule("runtime", [Primitive("runtime.pulse")], [Composite("authoring.expand")]); - using var view = new CompositeGameView([module]); - var profile = new TargetProfileEntryCatalog([module]); - - Assert.That(view.TryGetEntry("runtime.pulse", out var runtimeEntry), Is.True); - Assert.That(runtimeEntry, Is.SameAs(module.PrimitiveEntries["runtime.pulse"])); - Assert.That(profile.Get("runtime.pulse").DynamicParameters, Is.SameAs(runtimeEntry!.Parameters)); - Assert.That(profile.Get("authoring.expand").Kind, Is.EqualTo(EntryKind.Composite)); + using var main = new GameRegistration().AddBuiltins().BuildView(); + using var separate = new GameRegistration().AddBuiltinSchemas().AddBuiltinImplementations().BuildView(); + Assert.That(main.Primitives.Select(entry => entry.Type), Is.EquivalentTo(separate.Primitives.Select(entry => entry.Type))); + Assert.That(main.Primitives.All(entry => main.CanExecute(entry.Type)), Is.True); } - private static PrimitiveEntryBase Primitive(string type) => new DefaultPrimitiveEntryBase( - type, - DynamicParameterTable.Empty, - static _ => new ImmediatePrimitiveInstance()); - - private static CompositeEntryBase Composite(string type) => new DefaultCompositeEntryBase( - type, - DynamicParameterTable.Empty, - () => new TestCompositeEntry(type)); - - private sealed class TestCompositeEntry(string type) : CompositeEntry + [Test] + public void SchemaModuleFreezesItsTablesAndRejectsDuplicateFullIds() { - public override string Type => type; - public override IReadOnlyList Compile(EntryCompileContext context) => []; + var entries = new List { new DefaultPrimitiveEntryBase("custom.first", DynamicParameterTable.Empty) }; + var module = new DefaultEntryModule("custom", entries); + entries.Clear(); + Assert.That(module.PrimitiveEntries, Has.Count.EqualTo(1)); + Assert.That(() => ((IDictionary)module.PrimitiveEntries).Clear(), Throws.TypeOf()); + Assert.That(() => new TargetProfileEntryCatalog([module, module]), Throws.InvalidOperationException); } - - private sealed class TestEntryModule( - string id, - IEnumerable? primitiveEntries = null, - IEnumerable? compositeEntries = null) - : EntryModuleBase(id, primitiveEntries, compositeEntries); } diff --git a/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs b/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs index 7507427..5b5e7f4 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs @@ -23,7 +23,7 @@ public class BuiltinPrimitiveInstanceTests public void AnimationAnimateCommitsFinalStateAndCanBeSkippedThroughPresenter() { var presenter = new RecordingAnimationPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(animationPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(animationPresenter: presenter).BuildView(); var runtime = new GameRuntime(null); var layer = runtime.SceneInstances.GetOrAdd("hero", id => new Layer { Id = id, Opacity = 0 }); @@ -64,7 +64,7 @@ public void AnimationAnimateCommitsFinalStateAndCanBeSkippedThroughPresenter() public async Task EffectApplyAndStopUpdateRuntimeStateAndPresenter() { var presenter = new RecordingEffectPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(effectPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(effectPresenter: presenter).BuildView(); var runtime = new GameRuntime(null); var layer = runtime.SceneInstances.GetOrAdd("hero", id => new Layer { Id = id }); @@ -82,9 +82,7 @@ public async Task EffectApplyAndStopUpdateRuntimeStateAndPresenter() Assert.Multiple(() => { - Assert.That(runtime.SceneState.ActiveEffectIds, Is.EqualTo(new[] { "blur-1" })); Assert.That(runtime.SceneState.ActiveEffects.Single().Parameters, Does.Contain("radius")); - Assert.That(layer.EffectInstanceIds, Is.EqualTo(new[] { "blur-1" })); Assert.That(presenter.Started.Single().InstanceId, Is.EqualTo("blur-1")); }); @@ -94,9 +92,7 @@ public async Task EffectApplyAndStopUpdateRuntimeStateAndPresenter() Assert.Multiple(() => { - Assert.That(runtime.SceneState.ActiveEffectIds, Is.Empty); Assert.That(runtime.SceneState.ActiveEffects, Is.Empty); - Assert.That(layer.EffectInstanceIds, Is.Empty); Assert.That(presenter.Stopped, Is.EqualTo(new[] { "blur-1" })); }); } @@ -105,7 +101,7 @@ public async Task EffectApplyAndStopUpdateRuntimeStateAndPresenter() public async Task ParticlePlayAndStopUpdateRuntimeStateAndPresenter() { var presenter = new RecordingParticlePresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(particlePresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(particlePresenter: presenter).BuildView(); var runtime = new GameRuntime(null); var play = view.Dispatch(Primitive(PlayParticleEmitterEntry.TypeId, new @@ -183,7 +179,7 @@ public async Task ParticlePlayAndStopUpdateRuntimeStateAndPresenter() public async Task ParticleBurstIsFireAndForgetAndDoesNotCreateRuntimeState() { var presenter = new RecordingParticlePresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(particlePresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(particlePresenter: presenter).BuildView(); var runtime = new GameRuntime(null); var burst = view.Dispatch(Primitive(BurstParticlesEntry.TypeId, new @@ -218,9 +214,9 @@ public async Task AnimationPlanCanScheduleParticleLifetimeAndMultipleBursts() { var particles = new RecordingParticlePresenter(); var animations = new RecordingAnimationPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + using var view = new GameRegistration().AddBuiltins( animationPresenter: animations, - particlePresenter: particles)); + particlePresenter: particles).BuildView(); var runtime = new GameRuntime(null); var burstParameters = new { @@ -268,10 +264,10 @@ public async Task SkippingAnimationPlanDoesNotBackfillFutureParticleBurst() { var runtime = new GameRuntime(null); var particles = new RecordingParticlePresenter(); - var animations = new RecordingAnimationPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + var animations = new LoopingAnimationPresenter(); + using var view = new GameRegistration().AddBuiltins( animationPresenter: animations, - particlePresenter: particles)); + particlePresenter: particles).BuildView(); var plan = new AnimationPlanDefinition { PlaybackHandleId = "skipped-burst", @@ -305,9 +301,9 @@ public async Task LoopingAnimationPlanRetriggersParticleBurstEachCycle() var runtime = new GameRuntime(null); var particles = new RecordingParticlePresenter(); var animations = new LoopingAnimationPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + using var view = new GameRegistration().AddBuiltins( animationPresenter: animations, - particlePresenter: particles)); + particlePresenter: particles).BuildView(); using var scope = new CancellationTokenSource(); var plan = new AnimationPlanDefinition { @@ -329,7 +325,12 @@ public async Task LoopingAnimationPlanRetriggersParticleBurstEachCycle() }; var instance = view.Dispatch(Primitive(PlayAnimationPlanEntry.TypeId, new { plan }), runtime, scope.Token)!; - await WaitUntilAsync(() => particles.Bursts.Count >= 2); + await Task.Delay(50); + Assert.That(particles.Bursts, Is.Empty, "Events must not run ahead of a stalled presentation clock."); + await animations.AdvanceFrameAsync(1); + Assert.That(particles.Bursts.Count, Is.EqualTo(1)); + await animations.AdvanceFrameAsync(3); + Assert.That(particles.Bursts.Count, Is.EqualTo(2)); scope.Cancel(); await WaitUntilAsync(() => instance.IsCompleted); @@ -468,9 +469,9 @@ public async Task LoopingParticleAnimationKeepsFrameZeroValueAndCompleteReplayDe { var particles = new RecordingParticlePresenter(); var animations = new RecordingAnimationPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + using var view = new GameRegistration().AddBuiltins( animationPresenter: animations, - particlePresenter: particles)); + particlePresenter: particles).BuildView(); var runtime = new GameRuntime(null); var play = view.Dispatch(Primitive(PlayParticleEmitterEntry.TypeId, new @@ -513,7 +514,7 @@ public async Task LoopingParticleAnimationKeepsFrameZeroValueAndCompleteReplayDe [Test] public void LoopingAnimationPlanCommitsOnlyFrameZeroStateAndSavesCompletePlan() { - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended()); + using var view = new GameRegistration().AddBuiltins().BuildView(); var runtime = new GameRuntime(null); runtime.SceneInstances.GetOrAdd("old", id => new Layer { Id = id, AssetId = "old.png", Opacity = 1 }); var plan = new AnimationPlanDefinition @@ -553,9 +554,9 @@ public async Task AnimationPlanCommitsLayerEventsAndFinalTrackValues() { var animation = new RecordingAnimationPresenter(); var layers = new RecordingLayerPresenter(); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended( + using var view = new GameRegistration().AddBuiltins( layerPresenter: layers, - animationPresenter: animation)); + animationPresenter: animation).BuildView(); var runtime = new GameRuntime(null); var oldLayer = runtime.SceneInstances.GetOrAdd("old", id => new Layer { Id = id, AssetId = "old.png", Opacity = 1 }); var plan = new AnimationPlanDefinition @@ -611,7 +612,7 @@ public async Task AnimationPlanCommitsLayerEventsAndFinalTrackValues() [Test] public async Task FlowWaitBlocksUntilSkippedOrElapsed() { - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended()); + using var view = new GameRegistration().AddBuiltins().BuildView(); var runtime = new GameRuntime(null); var instance = view.Dispatch(Primitive(WaitEntry.TypeId, new { duration = 10 }, "wait"), runtime, CancellationToken.None); @@ -637,7 +638,7 @@ public void GalleryUnlockWritesGeneratedPlayerVariableWithoutAPlatformPresenter( Types = [new GalleryTypeRegistration { TypeId = "cg", ResourceTypeId = "sprite" }], Items = [new GalleryItem { Id = 3, TypeId = "cg", ResourceId = "asset-cg-3" }] }); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(gallery: gallery)); + using var view = new GameRegistration().AddBuiltins(gallery: gallery).BuildView(); var runtime = new GameRuntime(null); var instance = view.Dispatch( Primitive(UnlockGalleryEntry.TypeId, new { id = 3 }), @@ -656,7 +657,7 @@ public void GalleryUnlockWritesGeneratedPlayerVariableWithoutAPlatformPresenter( [Test] public void GalleryUnlockRejectsMissingCatalogInsteadOfSilentlySucceeding() { - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended()); + using var view = new GameRegistration().AddBuiltins().BuildView(); Assert.That( () => view.Dispatch( @@ -669,7 +670,7 @@ public void GalleryUnlockRejectsMissingCatalogInsteadOfSilentlySucceeding() [Test] public void GalleryUnlockRejectsUnknownItemId() { - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(gallery: GalleryCatalog.Empty)); + using var view = new GameRegistration().AddBuiltins(gallery: GalleryCatalog.Empty).BuildView(); Assert.That( () => view.Dispatch( @@ -719,10 +720,12 @@ public Task AnimateAsync(AnimationRequest request, Cancellatio return _animation.Task.WaitAsync(cancellationToken); } - public Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken) + public async Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken, Func? beforeFrame = null) { Plans.Add(plan); - return Task.FromResult(new AnimationPlanPlayResult { Outcome = AnimationOutcome.Completed }); + if (beforeFrame is not null && plan.LoopMode == AnimationLoopMode.Once) + await beforeFrame(plan.DurationFrames); + return new AnimationPlanPlayResult { Outcome = AnimationOutcome.Completed }; } public bool CompleteAnimationImmediately(string playbackHandleId) @@ -735,11 +738,14 @@ public bool CompleteAnimationImmediately(string playbackHandleId) private sealed class LoopingAnimationPresenter : IAnimationPresenter { + private Func? _beforeFrame; + public Task AdvanceFrameAsync(double frame) => _beforeFrame!(frame); public Task AnimateAsync(AnimationRequest request, CancellationToken cancellationToken) => Task.FromResult(AnimationOutcome.Completed); - public async Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken) + public async Task PlayAnimationPlanAsync(AnimationPlanDefinition plan, CancellationToken cancellationToken, Func? beforeFrame = null) { + _beforeFrame = beforeFrame; await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return new AnimationPlanPlayResult { Outcome = AnimationOutcome.Completed }; } diff --git a/test/GalNet.Primitives.Builtins.Tests/Runtime/NvlPrimitiveInstanceTests.cs b/test/GalNet.Primitives.Builtins.Tests/Runtime/NvlPrimitiveInstanceTests.cs index b68fd04..29d7128 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Runtime/NvlPrimitiveInstanceTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Runtime/NvlPrimitiveInstanceTests.cs @@ -36,9 +36,9 @@ public void VisibilityAndClearEntriesCallOnlyTheirRequestedOperation() foreach (var entry in new[] { ShowNvlEntry.TypeId, HideNvlEntry.TypeId, ClearNvlEntry.TypeId }) { - var primitive = module.PrimitiveEntries[entry]; + var primitive = module.Instructions[entry]; primitive.CreateInstance(new GalNet.Core.Primitives.PrimitiveCreateContext( - primitive, + BuiltinSchemas.CreateCatalog().Get(entry), new GalNet.Core.Entry.PrimitiveEntry(entry, System.Text.Json.JsonSerializer.SerializeToElement(new { })), new GalNet.Runtime.Runtime.GameRuntime(null), CancellationToken.None)).Dispatch(); diff --git a/test/GalNet.Primitives.Builtins.Tests/Runtime/VideoPrimitiveInstanceTests.cs b/test/GalNet.Primitives.Builtins.Tests/Runtime/VideoPrimitiveInstanceTests.cs index 35150d7..f9997a9 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Runtime/VideoPrimitiveInstanceTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Runtime/VideoPrimitiveInstanceTests.cs @@ -14,7 +14,7 @@ public async Task Blocking_video_advances_without_completing_until_playback_ends { var presenter = new ControlledVideoPresenter(); var runtime = new GameRuntime(null); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(videoPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(videoPresenter: presenter).BuildView(); var instance = view.Dispatch(Play(), runtime, CancellationToken.None)!; instance.Skip(); Assert.Multiple(() => @@ -34,7 +34,7 @@ public async Task Old_completion_does_not_remove_replacement_and_stop_targets_on { var presenter = new ControlledVideoPresenter(); var runtime = new GameRuntime(null); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(videoPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(videoPresenter: presenter).BuildView(); var first = view.Dispatch(Play(false), runtime, CancellationToken.None)!; var second = view.Dispatch(Play(false), runtime, CancellationToken.None)!; var other = view.Dispatch(Play(false, "other"), runtime, CancellationToken.None)!; @@ -58,7 +58,7 @@ public async Task Cancellation_and_failure_complete_and_remove_video_state() using var cancellation = new CancellationTokenSource(); var presenter = new ControlledVideoPresenter(); var runtime = new GameRuntime(null); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(videoPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(videoPresenter: presenter).BuildView(); var instance = view.Dispatch(Play(), runtime, cancellation.Token)!; if (fail) presenter.Ends[0].SetException(new InvalidDataException("decode failure")); else cancellation.Cancel(); @@ -72,7 +72,7 @@ public async Task Stable_video_snapshot_replays_definition_from_zero_without_pla { var presenter = new ControlledVideoPresenter(); var runtime = new GameRuntime(null); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended(videoPresenter: presenter)); + using var view = new GameRegistration().AddBuiltins(videoPresenter: presenter).BuildView(); var first = view.Dispatch(Play(false), runtime, CancellationToken.None)!; var snapshot = runtime.CreateSnapshot(); Assert.That(snapshot.SceneState.Layers.Single().VideoPlaybackId, Is.Null); @@ -95,7 +95,7 @@ public async Task Stable_video_snapshot_replays_definition_from_zero_without_pla [TestCase("[2,2]")] public void Invalid_skip_points_are_rejected(string json) { - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended()); + using var view = new GameRegistration().AddBuiltins().BuildView(); Assert.Throws(() => view.Dispatch(new PrimitiveEntry("video.play", JsonSerializer.SerializeToElement(new { asset = "clip", skipPoints = JsonSerializer.Deserialize(json) })), new GameRuntime(null), CancellationToken.None)); @@ -105,7 +105,7 @@ public void Invalid_skip_points_are_rejected(string json) public void Headless_video_completes_without_leaving_a_layer() { var runtime = new GameRuntime(null); - using var view = new CompositeGameView(BuiltinEntryModules.CreateRecommended()); + using var view = new GameRegistration().AddBuiltins().BuildView(); Assert.That(view.Dispatch(Play(), runtime, CancellationToken.None)!.IsCompleted, Is.True); Assert.That(runtime.SceneState.Layers, Is.Empty); } diff --git a/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs b/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs index b11e8a4..f1a27fd 100644 --- a/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs +++ b/test/GalNet.Runtime.Tests/Audio/AudioRuntimeTests.cs @@ -58,6 +58,72 @@ public void Unsupported_seffect_is_diagnosed_without_attaching_to_the_track() Assert.That(diagnostics, Does.Contain("Unsupported SEffect 'seffect.lowPass'.")); } + [Test] + public async Task Stop_cancels_pending_start_and_async_disposal_drains_backend_commands() + { + var host = new DelayedStartHost(); + var runtime = new AudioRuntime(host); + runtime.Play(Tracks, new("bgm", "pending", AudioPlacement.Replace)); + await host.StartEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + runtime.Stop(Tracks, new("bgm", true)); + await runtime.DisposeAsync(); + + Assert.Multiple(() => + { + Assert.That(host.StartCancelled, Is.True); + Assert.That(host.StartCompleted, Is.False); + Assert.That(host.Actions, Is.EqualTo(new[] { AudioBackendAction.Start, AudioBackendAction.Stop })); + }); + } + + [Test] + public async Task Commands_for_one_voice_wait_for_asset_start_in_submission_order() + { + var host = new DelayedStartHost(); + await using var runtime = new AudioRuntime(host); + runtime.Play(Tracks, new("bgm", "pending", AudioPlacement.Replace)); + await host.StartEntered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + runtime.Pause(Tracks, "bgm"); + runtime.Resume(Tracks, "bgm"); + Assert.That(host.Actions, Is.EqualTo(new[] { AudioBackendAction.Start })); + + host.AllowStart.TrySetResult(); + await host.Resumed.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(host.Actions, Is.EqualTo(new[] { AudioBackendAction.Start, AudioBackendAction.Pause, AudioBackendAction.Resume })); + } + + private sealed class DelayedStartHost : IAudioPlaybackHost + { + public AudioBackendCapabilities Capabilities { get; } = new(true, true, true); + public ISEffectCatalog SEffects { get; } = new SEffectCatalog([]); + public TaskCompletionSource StartEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource AllowStart { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Resumed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public List Actions { get; } = []; + public bool StartCompleted { get; private set; } + public bool StartCancelled { get; private set; } + public event Action? EventReceived { add { } remove { } } + + public async Task ApplyAsync(AudioBackendRequest request, CancellationToken cancellationToken = default) + { + Actions.Add(request.Action); + if (request.Action == AudioBackendAction.Start) + { + StartEntered.TrySetResult(); + try + { + await AllowStart.Task.WaitAsync(cancellationToken); + StartCompleted = true; + } + catch (OperationCanceledException) { StartCancelled = true; throw; } + } + if (request.Action == AudioBackendAction.Resume) Resumed.TrySetResult(); + } + public void Dispose() { } + } + private sealed class RecordingHost : IAudioPlaybackHost { public AudioBackendCapabilities Capabilities { get; } = new(true, true, true); diff --git a/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs b/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs index 8b490fb..cbdfe8e 100644 --- a/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs +++ b/test/GalNet.Runtime.Tests/Runtime/GameRuntimeSnapshotTests.cs @@ -5,20 +5,41 @@ namespace GeneralTest.Runtime; public class GameRuntimeSnapshotTests { - private static readonly string[] ExpectedEffectInstanceIds = ["effect"]; + [Test] + public void RestoreDetachesOldSceneAndResetsEndedState() + { + var runtime = new GameRuntime(null, "start"); + runtime.SceneState.Layers.Add(new Layer { Id = "layer", AssetId = "saved" }); + var snapshot = runtime.CreateSnapshot(); + var oldScene = runtime.SceneState; + var oldInstances = runtime.SceneInstances; + runtime.EndGame(); + + runtime.RestoreFrom(snapshot); + oldScene.Layers.Clear(); + snapshot.SceneState.Layers[0].AssetId = "caller-mutated"; + + Assert.Multiple(() => + { + Assert.That(runtime.IsGameEnded, Is.False); + Assert.That(runtime.SceneState, Is.Not.SameAs(oldScene)); + Assert.That(runtime.SceneInstances, Is.Not.SameAs(oldInstances)); + Assert.That(runtime.SceneState.Layers.Single().AssetId, Is.EqualTo("saved")); + }); + } [Test] public void CreateSnapshotDoesNotShareMutableSceneState() { var runtime = new GameRuntime(null, "start"); - runtime.SceneState.Layers.Add(new Layer { Id = "layer", AssetId = "before", EffectInstanceIds = ["effect"] }); + runtime.SceneState.Layers.Add(new Layer { Id = "layer", AssetId = "before" }); var snapshot = runtime.CreateSnapshot(); runtime.SceneState.Layers[0].AssetId = "after"; - runtime.SceneState.Layers[0].EffectInstanceIds.Add("another"); + runtime.SceneState.Layers[0].Transform.X = 42; Assert.That(snapshot.SceneState.Layers.Single().AssetId, Is.EqualTo("before")); - Assert.That(snapshot.SceneState.Layers.Single().EffectInstanceIds, Is.EqualTo(ExpectedEffectInstanceIds)); + Assert.That(snapshot.SceneState.Layers.Single().Transform.X, Is.Zero); } [Test] diff --git a/test/GalNet.Runtime.Tests/SaveLoad/SaveManagerTests.cs b/test/GalNet.Runtime.Tests/SaveLoad/SaveManagerTests.cs index 271ba85..ac9c3c2 100644 --- a/test/GalNet.Runtime.Tests/SaveLoad/SaveManagerTests.cs +++ b/test/GalNet.Runtime.Tests/SaveLoad/SaveManagerTests.cs @@ -21,6 +21,7 @@ public void Serialize_WritesTheCurrentSnapshotVersion() public void Deserialize_RejectsPreviousSnapshotVersions() { Assert.That(SaveManager.Deserialize("""{ "version": 1, "nodeId": "intro" }"""), Is.Null); + Assert.That(SaveManager.Deserialize("""{ "version": 2, "nodeId": "intro" }"""), Is.Null); Assert.That(SaveManager.Deserialize("""{ "nodeId": "intro" }"""), Is.Null); } } From e650ee511a1e2511f0c82110b954e255110a2fd5 Mon Sep 17 00:00:00 2001 From: sakuya Date: Sat, 10 Oct 2026 15:50:15 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E8=A7=A3=E5=86=B3warning=E5=92=8C?= =?UTF-8?q?=E5=A2=9E=E5=BC=BAengine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/agent-knowledge.md | 2 + .../L-20261010-02-restore-run-scope.md | 22 ++ ...010-03-lock-graph-after-project-removal.md | 14 + ...L-20261010-04-engine-stop-continuations.md | 16 + .github/workflows/ci.yml | 34 +- ...002-entry-schema-execution-registration.md | 25 ++ docs/design/entry-registration-refactor.md | 60 ++++ docs/design/game-runner-refactor.md | 31 ++ docs/development.md | 2 + docs/engine.md | 2 + docs/glossary.md | 2 +- docs/spec/architecture.md | 4 +- docs/spec/control.md | 8 +- docs/spec/runtime.md | 10 + .../F-20261010-02-runtime-i18n/feature.md | 90 ++++++ src/Editor/GalNet.Editor.Headless/Program.cs | 8 +- .../Commands/AssetFileCommandCatalog.cs | 2 +- .../Commands/AssetFileCommandExecutor.cs | 2 +- .../BuiltInEditorCommandHandler.Entry.cs | 2 +- .../Commands/EditorCommandCatalog.cs | 4 +- .../Commands/EditorDocumentValidator.cs | 4 +- .../Services/EditorPlayerVariableStore.cs | 2 +- .../Services/GamePackageExporter.cs | 2 +- src/Editor/GalNet.Editor/App.axaml.cs | 2 +- .../EditorServiceCollectionExtensions.cs | 3 +- .../Controls/EntryTypePicker.axaml.cs | 2 +- .../Dock/DockLayoutSerializer.cs | 2 +- .../GalNet.Editor/Dock/EditorDockFactory.cs | 2 +- .../AssetInspectorControlViewModel.cs | 3 +- .../Models/VariableDefinitionCollection.cs | 4 +- src/Editor/GalNet.Editor/Program.cs | 3 + .../Services/EditorLocalizationService.cs | 2 +- .../GalNet.Editor/Services/EditorLogSink.cs | 3 +- .../Services/EditorPreviewHost.cs | 9 +- .../Services/EditorViewFactory.cs | 2 +- .../Services/EditorWorkspacePersistence.cs | 13 +- .../Services/FileDialogService.cs | 4 +- .../Services/GraphDocumentMapper.cs | 10 +- .../Services/GraphEditingService.cs | 10 +- .../Interfaces/IGraphEditingService.cs | 4 +- .../Services/ProjectSaveScheduler.cs | 3 +- .../ViewModels/AssetPanelViewModel.cs | 2 +- .../ViewModels/EditorPageViewModel.cs | 11 +- .../ViewModels/EditorWorkspaceViewModel.cs | 5 +- .../ViewModels/GamePreviewPanelViewModel.cs | 7 +- .../ProjectSettingsPanelViewModel.cs | 3 +- .../ViewModels/StartupPageViewModel.cs | 3 +- .../VariableListEditorViewModels.cs | 4 +- .../Views/AssetPanelView.axaml.cs | 14 +- .../Views/GamePreviewPanelView.axaml.cs | 4 +- .../FilePlayerVariableStore.cs | 8 +- .../FileSaveService.cs | 10 +- .../FileVariableService.cs | 8 +- .../GameProjectSettingsLoader.cs | 28 ++ .../TypewriterTextBlock.cs | 12 +- .../Navigation/GalleryPageNavigation.cs | 2 +- .../Navigation/PageNavigation.cs | 2 +- .../Page/GameShell.axaml.cs | 1 + .../Presentation/AvaloniaDialoguePresenter.cs | 4 - ...ePageView.cs => AvaloniaGamePresenters.cs} | 10 +- .../Services/AvaloniaAudioPlaybackHost.cs | 2 +- .../Services/IGameSessionService.cs | 3 +- .../ViewModels/CgGalleryImagePageViewModel.cs | 3 +- .../ViewModels/GalleryEntryViewModel.cs | 3 +- .../GalleryVisualPageViewModelBase.cs | 1 + .../VideoGalleryPlayerPageViewModel.cs | 3 +- .../GameLaunchOptions.cs | 2 +- .../Presentation/SampleMediaViews.cs | 29 -- src/Samples/GalNet.Sample.Avalonia/Program.cs | 3 + .../Services/PersistentSceneRestorer.cs | 4 +- .../Services/SampleGamePresentation.cs | 89 ++++++ .../Services/SampleGameRunner.cs | 298 ++++++------------ .../Services/SampleGameSessionService.cs | 2 +- .../Services/SampleSaveSession.cs | 2 + .../Views/MainWindow.axaml.cs | 2 +- src/Shared/GalNet.Core/Entry/Entry.cs | 2 +- src/Shared/GalNet.Core/Entry/EntryModule.cs | 2 +- .../Entry/TargetProfileEntryCatalog.cs | 2 +- .../GalNet.Core/Gallery/GalleryTypeCatalog.cs | 2 +- .../GalNet.Core/Runtime/SnapshotCopy.cs | 87 +++++ .../Execution/PrimitiveImplementation.cs | 21 ++ .../Execution/PrimitiveModule.cs | 31 ++ .../View/GameRegistration.cs | 67 ++++ .../View/NullGameView.cs | 20 ++ .../AnimationPlanPrimitiveInstance.cs | 39 ++- .../GalNet.Primitives.Builtins/Arguments.cs | 67 ++++ .../BuiltinArgumentValidation.cs | 97 ++++++ .../BuiltinPresentationReplay.cs | 4 +- .../BuiltinRegistration.cs | 65 ++++ .../BuiltinSchemas.cs | 63 ++++ .../Entry/BuiltinTransitionEntries.cs | 2 +- .../Modules/BuiltinAnimationModule.cs | 45 +++ .../Modules/BuiltinAudioModule.cs | 102 ++++++ .../Modules/BuiltinDialogueModule.cs | 31 ++ .../Modules/BuiltinEffectModule.cs | 24 ++ .../Modules/BuiltinFlowModule.cs | 15 + .../Modules/BuiltinGalleryModule.cs | 23 ++ .../Modules/BuiltinInstructionFactory.cs | 11 + .../Modules/BuiltinLayerModule.cs | 50 +++ .../Modules/BuiltinNvlModule.cs | 26 ++ .../Modules/BuiltinParticleModule.cs | 15 + .../Modules/BuiltinVariableModule.cs | 18 ++ .../Modules/BuiltinVideoModule.cs | 36 +++ .../BuiltinRuntimeActions.AnimationState.cs | 92 ++++++ .../BuiltinRuntimeActions.AnimationValues.cs | 125 ++++++++ ...iltinRuntimeActions.EffectParticleState.cs | 67 ++++ .../BuiltinRuntimeActions.Layers.cs | 63 ++++ .../BuiltinRuntimeActions.Parameters.cs | 101 ++++++ .../BuiltinRuntimeActions.PlanEvents.cs | 93 ++++++ .../BuiltinRuntimeActions.Requests.cs | 85 +++++ .../GalNet.Runtime/Engine/GameEngine.cs | 181 +++++++++-- .../GalNet.Runtime/Engine/GameEngineState.cs | 13 + .../Runtime/RunScopedRuntime.cs | 43 +++ .../DockLayoutSerializerTests.cs | 2 +- .../EditorWorkspaceViewModelTests.cs | 7 +- .../GraphDocumentMapperTests.cs | 7 +- .../GraphEditingServiceTests.cs | 13 +- .../UndoRedoHistoryTests.cs | 4 +- .../VariableDefinitionCollectionTests.cs | 8 +- .../GalgroupLoaderTests.cs | 6 +- .../GameEnginePrimitiveDispatchTests.cs | 10 +- .../AvaloniaAudioLifetimeTests.cs | 52 +++ .../AvaloniaPresenterTests.cs | 6 +- .../CompositeGameViewTests.cs | 6 +- .../Entry/EntryModelTests.cs | 13 +- .../Entry/EntryModuleCatalogTests.cs | 4 +- .../Runtime/BuiltinPrimitiveInstanceTests.cs | 21 +- .../Engine/GameEngineLifecycleTests.cs | 257 +++++++++++++++ 128 files changed, 2833 insertions(+), 426 deletions(-) create mode 100644 .agents/lessons/L-20261010-02-restore-run-scope.md create mode 100644 .agents/lessons/L-20261010-03-lock-graph-after-project-removal.md create mode 100644 .agents/lessons/L-20261010-04-engine-stop-continuations.md create mode 100644 docs/adr/0002-entry-schema-execution-registration.md create mode 100644 docs/design/entry-registration-refactor.md create mode 100644 docs/design/game-runner-refactor.md create mode 100644 features/F-20261010-02-runtime-i18n/feature.md create mode 100644 src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs rename src/Presentation/GalNet.Avalonia.GameView/Presentation/{AvaloniaGamePageView.cs => AvaloniaGamePresenters.cs} (89%) delete mode 100644 src/Samples/GalNet.Sample.Avalonia/Presentation/SampleMediaViews.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Services/SampleGamePresentation.cs create mode 100644 src/Shared/GalNet.Core/Runtime/SnapshotCopy.cs create mode 100644 src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveImplementation.cs create mode 100644 src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveModule.cs create mode 100644 src/Shared/GalNet.Presentation.Abstractions/View/GameRegistration.cs create mode 100644 src/Shared/GalNet.Presentation.Abstractions/View/NullGameView.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Arguments.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/BuiltinArgumentValidation.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/BuiltinRegistration.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/BuiltinSchemas.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAnimationModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAudioModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinDialogueModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinEffectModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinFlowModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinGalleryModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinInstructionFactory.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinLayerModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinNvlModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinParticleModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVariableModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVideoModule.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationState.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationValues.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.EffectParticleState.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Layers.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Parameters.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.PlanEvents.cs create mode 100644 src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Requests.cs create mode 100644 src/Shared/GalNet.Runtime/Engine/GameEngineState.cs create mode 100644 src/Shared/GalNet.Runtime/Runtime/RunScopedRuntime.cs create mode 100644 test/GalNet.Presentation.Tests/AvaloniaAudioLifetimeTests.cs create mode 100644 test/GalNet.Runtime.Tests/Engine/GameEngineLifecycleTests.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 9d2ed40..0836f8f 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -15,6 +15,7 @@ - Runtime/Core 应保持与 Avalonia、WPF、Skia、具体文件系统和其他平台实现解耦。 - Entry 契约与执行已分离:IEntryModule 只含 schema/composite,IPrimitiveModule 按领域共享状态并贡献完整指令 ID;GameRegistration 独立注册,AddBuiltins 为主内置入口。可选 schema、部分内置实现或全自定义;仅完整 ID 排他。同前缀和 schema 模块名可重复。音频用 AudioRuntime + IAudioPlaybackHost 组合,不强制继承。见 docs/adr/0002-entry-schema-execution-registration.md 和 docs/development.md。 - 当前 GameSnapshot 版本为 3,不兼容旧版;读档取消旧运行 scope 并替换场景对象。Engine 不拥有执行 view,宿主先释放 Engine,再异步释放 view 与后端。 +- Engine 提供终态 StopAsync 和 StateChanged,覆盖玩家推进、Choice 与阻塞实例自然完成;不新增通用 Runner。AvaloniaGamePresenters 只组合端口,SampleGamePresentation 管理平台资源。见 docs/design/game-runner-refactor.md 和 docs/spec/runtime.md。 - Presentation.Defaults 已删除,空 NullGameView 位于展示抽象的 View 命名空间;当前 20 个生产项目。GameContent.Settings 包含项目设置,项目/安装加载器与两个 Sample 已接通自定义音频轨道配置。 - 旧的 `GalNet.Control` 与 `GalNet.Control.Abstraction` 已完成迁移并删除;当前共享 Avalonia 游戏页面唯一入口是 `GalNet.Avalonia.GameView`,页面架构见 `docs/spec/control.md`。 - 会影响场景的运行时条目应先更新 `SceneState`,再通知展示层;读取存档后由宿主通过 PersistentSceneRestorer 根据状态重放展示。 @@ -54,6 +55,7 @@ - [受限环境中的 .NET/Avalonia 构建](lessons/L-20260922-01-dotnet-build-sandbox.md):禁用 Roslyn shared compilation,并关闭 Avalonia build telemetry。 - [读档必须隔离旧运行实例](lessons/L-20261010-02-restore-run-scope.md):取消与场景对象隔离同时使用;句柄复用的清理需校验实例 identity;快照不能共享 mutable 对象。 - [删除项目后检查依赖锁图](lessons/L-20261010-03-lock-graph-after-project-removal.md):检查 lock 内容;缓存可能保留旧项目,需要对受影响 roots 强制重算再 locked restore。 +- [Engine 停止覆盖所有续跑](lessons/L-20261010-04-engine-stop-continuations.md):首次推进返回不等于运行结束;停止须取消 scope、等待内部 continuation,统一发布终态。 - [平台无关的进度契约归属](lessons/L-20260922-02-core-progress-contract.md):已被 ADR-0001 和 F-20260929-07 取代;当前进度端口由实际消费者 Runtime 拥有。 - [受限环境中的平台 SDK 枚举](lessons/L-20260925-01-platform-sdk-sandbox.md):全解决方案构建可能只因 Android/Browser/iOS 的 SDK 探测读取用户目录失败;应区分平台环境限制和业务项目编译结果。 - [受限环境中的 Avalonia licensing 枚举](lessons/L-20260926-01-avalonia-license-sandbox.md):Avalonia BuildServices 即使关闭 telemetry 仍可能读取用户级 licensing tickets;应与业务编译失败区分。 diff --git a/.agents/lessons/L-20261010-02-restore-run-scope.md b/.agents/lessons/L-20261010-02-restore-run-scope.md new file mode 100644 index 0000000..722dccd --- /dev/null +++ b/.agents/lessons/L-20261010-02-restore-run-scope.md @@ -0,0 +1,22 @@ +# 读档必须隔离旧运行实例 + +状态:confirmed +来源:Entry 契约/执行注册重构(docs/design/entry-registration-refactor.md) + +## 现象与根因 + +仅清空 Engine 活动列表并在原 SceneState 上恢复内容,不能阻止仍持有 Runtime 的旧异步实例在 finally 中清理新状态。取消令牌只是一种协作信号,不保证旧任务已退出。 + +## 发现与证据 + +审查发现 Engine 原先读档不更新 scope cancellation。新增 GameEnginePrimitiveDispatchTests.RestoreCancelsOldScopeAndIsolatesSceneAndSnapshots 验证旧 scope 被取消、旧实例的场景引用不能清理新场景、控制流操作抛出取消异常、调用者快照修改不污染 Engine。 + +## 正确做法 + +先验证存档版本并复制输入,再取消旧 scope;恢复时创建新的 SceneState/SceneInstanceManager。实例收到 RunScopedRuntime,持有该代场景对象,变量和控制流操作检查该代令牌。外部快照与 checkpoint 参数深复制。句柄复用时,异步清理还须检查实例 identity,不能仅按字符串句柄移除。 + +## 适用范围与禁止做法 + +适用于读档、重载、重启、同句柄替换。禁止把 ClearActive 当作取消;禁止让旧 finally 按句柄无条件清理新状态;禁止取消现有运行后才检查非法存档版本。 + +关联:docs/spec/runtime.md;GameRuntimeSnapshotTests;AnimationPrimitiveInstance;AnimationPlanPrimitiveInstance。 diff --git a/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md b/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md new file mode 100644 index 0000000..dc98f8a --- /dev/null +++ b/.agents/lessons/L-20261010-03-lock-graph-after-project-removal.md @@ -0,0 +1,14 @@ +# 删除项目后检查依赖锁图 + +状态:confirmed +来源:Entry 注册重构,删除 Presentation.Defaults + +## 现象与根因 + +删除 csproj 和 ProjectReference 后,普通 restore 与后续 locked restore 返回成功,但外层 packages.lock.json 仍保存已删除项目及其旧依赖。还原缓存没有重新生成锁图;成功返回不能替代检查锁文件内容。 + +## 证据与正确做法 + +本轮用 rg 检查仍存在的 galnet.presentation.defaults 锁图条目。对 Integration、Editor.Tests、Presentation.Tests 的具体 csproj 执行 dotnet restore --force-evaluate 后,所有受影响生产与测试 lock 文件移除了旧项目;再执行 --locked-mode 验证。不要手写锁文件或仅依据退出码认定依赖图已同步。 + +适用范围:项目删除、依赖移除或跨项目引用变化。边界:此记录不建议无条件删除 bin/obj;强制重算只针对已确认受影响的 restore roots。 diff --git a/.agents/lessons/L-20261010-04-engine-stop-continuations.md b/.agents/lessons/L-20261010-04-engine-stop-continuations.md new file mode 100644 index 0000000..f9b3c4d --- /dev/null +++ b/.agents/lessons/L-20261010-04-engine-stop-continuations.md @@ -0,0 +1,16 @@ +# Engine 停止必须覆盖自动续跑,而不只跟踪首次推进 + +状态:confirmed +来源:docs/design/game-runner-refactor.md;GameEngineLifecycleTests + +## 现象与根因 + +旧 Sample 用 GameRunCoordinator 只追踪首次 AdvanceAsync,后续玩家点击、选择结果和阻塞实例完成的续跑不在同一停止边界。只取消首次推进任务不能取消 Engine scope,也无法保证旧 continuation 不再访问宿主资源。 + +## 正确做法 + +Engine 在终态停止时先拒绝新请求、取消执行 scope,再清理队列并等待内部 continuation 退出。选择等待必须通过 scope token 中断,不能无条件等待不协作取消的 presenter Task。终态通知必须覆盖所有续跑入口并在执行锁外发布,宿主按 Engine、执行 view、平台后端顺序释放。 + +## 验证与禁止做法 + +GameEngineLifecycleTests 验证自然阻塞完成与 Choice 的完成/失败通知、不协作 Choice 的停止、执行中与排队推进的停止、正常结束后不重放和 view 非拥有关系。禁止把“首次 AdvanceAsync 已返回”当成游戏已停止;禁止让宿主分别猜测每种异步入口的终态;禁止在状态事件中同步等待 Engine 异步任务。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1bb612..8f9a64e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,14 +23,36 @@ jobs: - uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.401 - - name: Restore workloads - run: dotnet workload restore GalNet.slnx --version 10.0.401.1 - - name: Restore locked dependencies - run: dotnet restore GalNet.slnx --locked-mode + - name: Restore locked dependencies (excluding launcher) + shell: pwsh + run: | + $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | + Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | + Sort-Object FullName + foreach ($project in $projects) { + dotnet restore $project.FullName --locked-mode + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } - name: Verify whitespace - run: dotnet format whitespace GalNet.slnx --no-restore --verify-no-changes --verbosity quiet + shell: pwsh + run: | + $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | + Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | + Sort-Object FullName + foreach ($project in $projects) { + dotnet format whitespace $project.FullName --no-restore --verify-no-changes --verbosity quiet + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } - name: Verify imports - run: dotnet format style GalNet.slnx --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet + shell: pwsh + run: | + $projects = Get-ChildItem src, test -Filter *.csproj -Recurse | + Where-Object { $_.FullName -notmatch '[\\/]src[\\/]Launcher[\\/]' } | + Sort-Object FullName + foreach ($project in $projects) { + dotnet format style $project.FullName --no-restore --verify-no-changes --diagnostics IDE0005 --verbosity quiet + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } desktop-build-and-test: runs-on: windows-latest diff --git a/docs/adr/0002-entry-schema-execution-registration.md b/docs/adr/0002-entry-schema-execution-registration.md new file mode 100644 index 0000000..dd5a6bd --- /dev/null +++ b/docs/adr/0002-entry-schema-execution-registration.md @@ -0,0 +1,25 @@ +# ADR-0002:Entry 契约与执行注册分离 + +状态:Accepted(2026-10-10) + +## 上下文 + +旧 EntryModule 同时保存参数 schema、composite 展开和 primitive 工厂。编译器必须创建运行期模块,平台依赖与契约耦合;按模块/前缀排他注册不利于局部替换。此次迁移不保留旧 API 或存档兼容,不改造 Editor/Launcher 功能。 + +## 决定 + +`IEntryModule` 只声明 schema 和编译期展开;`IPrimitiveModule` 贡献完整指令 ID 与实例工厂并拥有共享状态。`GameRegistration` 独立组合两者。前缀和 schema 模块名不排他,仅完整 ID 必须唯一。主内置入口 `AddBuiltins` 组合 schema 和所有执行模块;开发者可只选择 schema、部分内置逻辑或全部自定义实现。 + +轨道规则留在平台无关 `AudioRuntime`,播放由 `IAudioPlaybackHost` 提供。Avalonia/LibVLC 和 Unity/原生音频均通过组合接口接入;不强制继承内置基类。执行模块是共享状态的生命周期单元,指令是路由单元。 + +## 候选与取舍 + +- 逐条注册可用于小扩展,但不强迫所有开发者采用;领域模块便于共享轨道和服务。 +- 继续将工厂放入 schema 会维持编译器/平台耦合,因此不采用。 +- 强制平台继承轨道基类会绑定后端结构;组合接口更容易适配已有播放系统。 + +## 后果 + +宿主须明确管理 Engine、view、presenter 的释放顺序;schema-only view 可构建,但 GameEngine 拒绝缺失执行实现的 Graph。新执行模块必须提供对应 schema,重复 ID 明确失败。旧模块工厂 API 与无消费者音频协议删除;快照移除冗余索引并升级版本 3。验证由注册、运行时、内置和跨层集成测试覆盖,使用示例见 [development](../development.md)。 + +旧 Presentation.Defaults 的唯一剩余空 view 移入 Presentation.Abstractions,独立 Defaults 项目和无用引用删除。 diff --git a/docs/design/entry-registration-refactor.md b/docs/design/entry-registration-refactor.md new file mode 100644 index 0000000..3cd0697 --- /dev/null +++ b/docs/design/entry-registration-refactor.md @@ -0,0 +1,60 @@ +# Entry 契约与执行注册迁移 + +用户授权:不保留旧 API/存档兼容;排除 Editor/Launcher 功能改造,允许迁移其公共 API 调用点以保持构建。 + +## 目标与决定 + +Entry module 只拥有参数契约和编译期 composite。执行 module 只拥有完整指令 ID 与实例工厂,并可持有共享服务/状态。模块名和指令前缀不是排他注册键,完整 ID 才是。宿主通过 GameRegistration 独立注册契约与实现,在创建游戏时冻结并转移执行模块所有权。 + +内置入口 AddBuiltins 组合 AddBuiltinSchemas 与 AddBuiltinImplementations;每个领域有独立实现注册入口。音频轨道逻辑可复用 AudioRuntime,平台通过 IAudioPlaybackHost 组合实现;无需继承播放器基类。平台后端不进入 schema。 + +## 实施顺序 + +1. 契约/工厂拆分、注册入口、领域拆文件、消费者迁移与注册测试。 +2. 删除旧协议与重复状态,统一快照复制及读档取消,补充生命周期回归测试。 +3. 修正音频 pending-load/命令顺序、动画统一播放时间源、诊断和宿主生命周期。 +4. 顺序执行边界测试、集成测试、格式检查并同步当前文档。 + +## 验收 + +纯 schema 注册不创建执行服务;schema-only、自定义实现、部分内置实现、全内置均可组合;相同前缀不同指令可注册,完整 ID 重复报错;缺失实现明确失败;旧异步任务不能污染读档后状态;快照与调用者隔离;停止待加载音频后不能晚开始;动画轨道与事件使用同一播放进度。 + +## 风险 + +公共 API 破坏性迁移需要同步测试及所有消费者。取消并不等待所有外部任务,旧任务必须使用运行代际/状态对象隔离。后端资源销毁必须先取消待加载、等待工作结束后删除临时目录。 + +## 实现结果(2026-10-10) + +目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。 + +删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。 + +音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。 + +## 验证与边界 + +- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。 +- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。 +- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。 +- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。 +- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。 + +使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。 + +## 实现结果(2026-10-10) + +目标已落地。领域执行模块与 runtime actions 分文件维护,schema 的语义验证用于编译和运行期,包括 composite 展开的指令;重复的执行层视频参数检查移除。缺失实现、未编译作者条目、参数错误和 BatchId 不一致均明确拒绝。 + +删除无消费者 IAudioService、LibVlcAudioController、调用栈、冗余场景 ID 索引,并把空 GameView 移入展示抽象,删除 Presentation.Defaults 项目。旧文件可通过 Git 恢复。GameSnapshot 升级为 3;输入/输出快照复制,读档重置结束标记并隔离旧 scope/场景。 + +音频逐 voice 有序提交,Stop 取消 pending Start,平台 host 跟踪加载并异步排空;native 回调只排队信号,播放器清理不在回调线程。新 fade 取消旧 fade。Sample 明确释放 engine、view、后端与 DI scope。动画事件由 presenter 的同一帧进度驱动,旧完成按 identity 清理。项目/安装内容现在读取 settings.json,两种 Sample 将自定义轨道配置注入 Runtime。 + +## 验证与边界 + +- 10 个测试项目顺序执行:Architecture 24、Core 57、Runtime 38、Builtins 52、Assets 32、Storage.FileSystem 12、Editor.Shared 16、Editor 28、Presentation 76、Integration 38,共 373 项通过。测试构建关闭分析器并禁用 shared compilation。 +- 使用 Sample 携带的 Windows x64 LibVLC,显式运行 AvaloniaAudioLifetimeTests 的停止/销毁两个 native 测试,均通过。 +- Sample.Headless 和 Editor.Headless 单独构建成功;Editor 仅公共 API/namespace 消费点迁移,Launcher 未修改。 +- 受影响依赖锁强制重算后 locked restore 成功。solution whitespace/imports 门禁检查通过,git diff --check 无错误。 +- 普通 Shared 构建开启分析器后无错误,但仍有命名、性能与生命周期等非阻断告警;Avalonia 仍有既存 AVLN3001 告警。本轮未声称全仓零告警,也未进行完整 GUI 交互验收或实现 Unity 后端。 + +使用方式见 [二次开发手册](../development.md),当前契约见 [runtime](../spec/runtime.md),决定见 [ADR-0002](../adr/0002-entry-schema-execution-registration.md)。 diff --git a/docs/design/game-runner-refactor.md b/docs/design/game-runner-refactor.md new file mode 100644 index 0000000..e55fb50 --- /dev/null +++ b/docs/design/game-runner-refactor.md @@ -0,0 +1,31 @@ +# 游戏运行编排与 Avalonia 呈现收敛 + +## 范围 + +保留页面、presenter 和运行会话的职责边界。不改变 Editor/Launcher 功能;Editor 仅迁移共享类型名称和事件订阅 API。 + +## 设计 + +- 不新增通用 Runner。Engine 统一报告运行、完成、失败和停止,覆盖玩家推进、选择结果和阻塞实例自然完成三个入口。 +- Engine 提供 `StopAsync` 和异步释放,停止先拒绝新请求并取消 scope,再清理活动实例、等待内部 continuation 退出。停止是 Engine 的终态,重开创建新实例。 +- 状态事件在执行锁外按发生顺序发布;订阅者异常诊断但不改变剧情状态。事件只报告事实,不承担 UI、日志文案或存储业务。 +- 准备、页面切换、首次推进留在宿主;内置场景重放继续复用 Builtins 入口。 +- `AvaloniaGamePageView` 改名为 `AvaloniaGamePresenters`。玩家推进直接从页面 ViewModel 接到宿主,不经过 presenter 集合转发。 +- Sample 的平台对象装配和统一清理集中到一个 `SampleGamePresentation`;执行模块先于借用的 presenter/后端释放。清理一个对象失败不能阻止其他对象清理。 +- 删除未使用的 `SampleMediaViews`,Sample Runner 只保留平台绑定、日志和 UI 更新。 + +## 验证阶段 + +1. **verified**:Engine 停止、状态通知与生命周期测试,新增 11 个用例。 +2. **verified**:Avalonia 命名/API 迁移、Sample 装配与事件绑定、旧代码清理。 +3. **verified**:全仓 10 个测试项目共 384 项通过;另外 2 项原生音频取消测试通过。Sample.Headless 和 Editor.Headless 构建通过,solution whitespace / IDE0005 门禁及 git diff --check 通过。 + +当前状态:已实现并验证。 + +## 结果和限制 + +- 平台实现只提供呈现端口,无需继承 Runner 基类、管理 Engine continuation 或重复判断剧情终态。 +- SampleGameRunner 保留页面准备/切换、日志和 UI 反馈;平台对象由 SampleGamePresentation 统一装配与释放。删除旧媒体占位对象和推进事件的两层转发。 +- 放弃最初的通用 GameRunner 草案,职责直接归 Engine;不兼容旧 AvaloniaGamePageView 类型名。 +- Editor 仅迁移共享 API,原预览编排未重构;Launcher 未改动。 +- 常规测试不包含显式视频原生测试,本次未进行完整 GUI 操作验收。Avalonia 构建有既有 AVLN3001 警告;沙箱许可目录读取限制通过授权验证处理,没有修改产品代码绕过。 diff --git a/docs/development.md b/docs/development.md index 067da2a..fce5fc7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,6 +109,8 @@ await using var view = registration.BuildView(); 编译期 target profile 和运行期 view 使用等价的 schema;执行模块只在运行期创建。宿主先停止/释放 Engine,再异步释放 view,最后释放它借用的 presenter 和平台资源。注册失败时释放 registration,避免遗留已创建的执行模块。 +平台实现只需要提供 presenter/后端,不需要实现 Runner、跟踪 Engine continuation 或自行推断剧情结束。宿主调用 `AdvanceAsync()`,通过 `StateChanged` 接收统一运行状态,关闭时调用 `StopAsync()` 或异步释放 Engine。选择或阻塞实例自然完成后的推进与终态通知由 Engine 负责。页面切换前的快照恢复及 Builtins 展示重放仍由宿主编排。 + ### 4. 自由选择内置能力 ```csharp diff --git a/docs/engine.md b/docs/engine.md index df1a900..5b6992e 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -16,6 +16,8 @@ Graph + 已编译 .galgroup `IGameRuntime` 是运行事实的唯一来源,保存当前节点、条目位置、变量和 `SceneState`。呈现端口负责显示,不反过来拥有剧情或可存档状态。 +Engine 的 `StateChanged` 统一报告开始运行、完成、失败和停止,覆盖选择结果和阻塞实例自然完成;宿主无需在多个推进入口重复判断结束。关闭会话使用 `await engine.StopAsync()` 或异步释放,随后释放执行 view 与平台后端。停止是 Engine 终态,重开创建新实例。详细契约见[运行时参考](spec/runtime.md#状态通知和停止)。 + ## 内容项目与发布 开发项目的最小布局如下: diff --git a/docs/glossary.md b/docs/glossary.md index 8eae415..5a85a55 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -49,7 +49,7 @@ | 游戏导航服务 | `IGameNavigationService` | 管理游戏 Scope 内的当前页面、回退历史和导航转场。 | | 页面注册表 | `IPageViewRegistry` / `IPageViewFactory` | 在组合期建立并解析不可变的 ViewModel→View 映射。 | | 游戏根页面 | `GameShell` | 承载页面切换、页面内容和游戏截图入口的 Avalonia 根控件。 | -| 游戏页面呈现 | `AvaloniaGamePageView` | 将 presenter 端口接到 Avalonia 游戏页面和场景渲染。 | +| 游戏页面呈现 | `AvaloniaGamePresenters` | 将 presenter 端口接到 Avalonia 游戏页面和场景渲染,不负责 Engine 运行编排。 | | Gallery 页面注册表 | `IGalleryPageRegistry` | 将精确 Gallery `typeId` 映射到页面 ViewModel 和 View 的组合期注册表;不按资源类型推断或 fallback。 | `WidgetTemplate`、`WidgetInstance`、`ScreenTemplate`、`ScreenInstance`、`UiProject` 与调色板模板体系是历史设计术语,不属于当前游戏页面宿主。 diff --git a/docs/spec/architecture.md b/docs/spec/architecture.md index f07feae..cf72c8f 100644 --- a/docs/spec/architecture.md +++ b/docs/spec/architecture.md @@ -51,9 +51,9 @@ src/ ## 游戏运行 -`GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `SceneState`,再通知呈现端;读取快照后由 Engine 根据状态重放。 +`GameEngine` 持有 `IGameRuntime`,解释图、条目、Choice、skip batch 和快照;它不认识具体 UI。Engine 统一报告运行状态,负责停止、取消执行 scope 和等待内部 continuation 退出,不拥有执行 view 或平台后端。`IGameView` 解析 entry schema、创建 primitive instance 并调用 presenter 端口。影响场景的 primitive 先写入 `SceneState`,再通知呈现端;读取快照后由宿主调用 Builtins 的场景重放入口。 -Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePageView` 只创建并暴露这些 presenter,`CompositeGameView` 组合独立的 schema catalog 与执行模块,提供 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 管理 engine、执行 view 和页面展示生命周期,`PersistentSceneRestorer` 在 Runtime 快照恢复后重放 renderer-owned 场景对象。runner 必须先于资源 scope 异步释放。 +Avalonia 展示按领域拆为 dialogue/choice、layer、animation 和 particle presenter;`AvaloniaGamePresenters` 只创建并暴露这些 presenter,`CompositeGameView` 组合独立的 schema catalog 与执行模块,提供 Runtime 使用的 `IGameView`。官方 Avalonia Sample 的页面会话只串行化公开命令和顶层 UI 状态:`SampleGameResourceScope` 管理安装内容与资源,`SampleSaveSession` 管理玩家持久化与 Gallery 数据,`SampleGameRunner` 绑定 Engine 与页面、准备读档及首次推进,`SampleGamePresentation` 管理执行 view 和平台展示资源,`PersistentSceneRestorer` 复用 Builtins 重放 renderer-owned 场景对象。没有通用 Runner 抽象或必须继承的宿主基类。runner 必须先于资源 scope 异步释放。 Gallery catalog 是平台无关的静态内容。宿主把其自动生成的 `gallery__unlocked` Player bool 注册到变量服务;`GalleryDataSource` 合并 catalog 与当前 Player 值。Gallery 解锁不存于独立 UI 状态或存档槽。 diff --git a/docs/spec/control.md b/docs/spec/control.md index e53ac17..a686d28 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -11,7 +11,7 @@ └─ GameShell ├─ IGameNavigationService ├─ 标题、加载、存读档、设置、鉴赏、关于页面 - └─ GamePage + AvaloniaGamePageView + └─ GamePage + AvaloniaGamePresenters ├─ Dialogue / Layer / Animation / Particle presenter └─ CompositeGameView ── Runtime.GameEngine ``` @@ -42,11 +42,11 @@ Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映 ## 运行期呈现边界 -Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePageView` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。 +Runtime 只依赖 `GalNet.Presentation.Abstractions` 中的 `IGameView` 与细分接口(文本、NVL、交互、图层、转场、音频、视频、效果)。`AvaloniaGamePresenters` 是页面侧 presenter 的组合器,分别暴露 dialogue/NVL/choice、layer、animation 和 particle 端口;它不是页面或执行 view。宿主通过 `CompositeGameView` 把这些端口组合成 Runtime 使用的 `IGameView`。`NullGameView` 供测试和无界面宿主使用。 -各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePageView` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。 +各 presenter 共享 `IAvaloniaUiDispatcher`,但分别拥有自己的等待、动画和粒子生命周期。`AvaloniaGamePresenters` 通过宿主提供的 `IGamePageLayerFactory` 解析图层图像,因而共享页面不会自行推断资源路径。玩家推进从 `GamePageViewModel.AdvanceRequested` 直接连接宿主,不经过 dialogue presenter 或 presenter 集合转发。动画逐帧更新由呈现实现完成;Runtime 只维护可存档的场景状态和动画结束后的稳定属性。 -官方 Sample 使用 `SampleGameResourceScope`、`SampleSaveSession`、`SampleGameRunner` 和 `PersistentSceneRestorer` 分离资源、玩家持久化、运行编排与读档展示重放。读档顺序固定为恢复 Runtime snapshot、重放持久展示、页面切换后启动 prepared run;reload 和清空玩家状态必须先停止并释放 runner,再释放资源或重建持久化状态。 +官方 Sample 使用 `SampleGameResourceScope`、`SampleSaveSession`、`SampleGameRunner` 和 `PersistentSceneRestorer` 分离资源、玩家持久化、页面运行编排与读档展示重放。`SampleGamePresentation` 集中创建和释放执行 view 与平台后端,创建失败与正常销毁使用同一条清理路径;单个对象释放失败仍继续释放其他对象。Runner 只绑定页面操作、处理 Engine 状态通知和宿主反馈,不重复推断剧情终态。读档顺序固定为恢复 Runtime snapshot、重放持久展示、页面切换后首次调用 `AdvanceAsync`;reload 和清空玩家状态必须先停止并释放 runner,再释放资源或重建持久化状态。 ## 宿主接入约束 diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 641acb1..96bd24c 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -22,6 +22,8 @@ Runtime 只执行 primitive entry。Composite entry 只能存在于 `.rawgalgrou public Task AdvanceAsync(CancellationToken cancellationToken = default); public GameSnapshot CreateSaveData(); public void RestoreFrom(GameSnapshot data); +public Task StopAsync(); +public ValueTask DisposeAsync(); ``` 一次 `AdvanceAsync()` 的顺序是: @@ -34,6 +36,14 @@ public void RestoreFrom(GameSnapshot data); Blocking instance 自然完成时,Engine 只执行无 skip 权限的内部 continue;它不是第二个玩家入口,也不能跳过下一个新遇到的 batch。 +### 状态通知和停止 + +`State` 和 `StateChanged(GameEngineStateChange)` 统一报告 `Ready`、`Running`、`Completed`、`Faulted`、`Stopped`;失败通知附带原始异常。自然阻塞完成、Choice 结果/异常和玩家推进都经过同一状态机制,宿主不需要分别推断终态。通知在执行锁外按发生顺序发布,重复状态不重复通知;订阅者异常只诊断,不改变剧情状态。事件处理器不应同步等待 Engine 的异步操作;UI 线程切换、日志文案和存档槽刷新由宿主处理。 + +`StopAsync()` 是该 Engine 的终态操作:立即拒绝新推进,取消执行 scope,清理活动实例和 pending Choice,并等待内部 continuation 退出后报告 `Stopped`。Choice 后端即使没有协作取消,Engine 的等待也能退出;平台资源本身仍由宿主释放。停止后推进返回 `false`,不能恢复该 Engine;重开使用新 Engine。正常完成后再次推进不会重放已经结束的内容。未停止时 `RestoreFrom()` 取消旧 scope 并重新进入 `Ready`。 + +Engine 同时支持同步和异步释放;异步宿主优先使用 `await using`。释放 Engine 不释放借用的 `IGameView`:先停止/释放 Engine,再异步释放执行 view,最后释放 presenters、媒体后端及资源。不新增通用 Runner 或要求平台实现继承宿主基类。 + ## Batch 与活动实例 `PrimitiveEntry.BatchId` 是编译后的可选局部分组字段。GameView 创建实例时把它原样传入 `PrimitiveInstance.BatchId`,Engine 实际按 `(GroupExecutionId, BatchId)` 匹配 skip 批次: diff --git a/features/F-20261010-02-runtime-i18n/feature.md b/features/F-20261010-02-runtime-i18n/feature.md new file mode 100644 index 0000000..bc30fae --- /dev/null +++ b/features/F-20261010-02-runtime-i18n/feature.md @@ -0,0 +1,90 @@ +# F-20261010-02 Runtime i18n with DynamicLocalization + +**Status:** discovery + +## Original goal + +Use the DynamicLocalization package to implement end-to-end i18n. The feature should cover UI localization and language switching; game text and character localization; embedded i18n resources and runtime loading of third-party external language resources; and a first-launch language-selection page. + +The proposed runtime loading model is: + +- Load embedded i18n resources from designated folders. +- Create an empty, documented external-language directory beside an installed, extracted game package. +- Export translation templates for third-party translators to fill in. +- Recursively discover third-party JSON localization files in that external directory at runtime. +- Keep interfaces in Core and register/pass implementations through the DI composition root, because DynamicLocalization is DI-based. + +The proposed content model is: + +- UI text uses the package's Avalonia extensions or view-model bindings and built-in JSON files. +- Character records store a stable i18n key for their display name. +- Dialogue and other repeated narrative content stored in `.galgroup` use i18n keys. +- A galgroup owns its associated localization JSON files. +- Locale-specific binary resources such as voice and layers may use a shared basename with a locale suffix, for example `x.png`, `x_zh-CN.png`, and `x_en.png`, with locale fallback when a variant is absent. + +## Problem + +The current repository has only the two boundary contracts documented in `docs/spec/i18n.md`: Editor UI localization via DynamicLocalization and Runtime text resolution via `ITextResolver`. It deliberately leaves game translation file shape, player override discovery, fallback behavior, and asset locale selection to individual hosts. There is no defined player-facing language setting, first-launch selection flow, external translator-pack contract, or runtime implementation that binds those choices together. + +## Scope + +- Define and implement (in later feature phases) a player UI localization and settings-switching experience based on DynamicLocalization and Avalonia. +- Preserve a platform-neutral Core/Runtime text-localization abstraction, with DynamicLocalization-specific adaptation and registration outside Core/Runtime. +- Define the authoring, generated-template, embedded-resource, installed-package, and external third-party language-pack boundaries. +- Localize game text, including dialogue, branch choices, and character display names, through stable keys rather than user-visible source text. +- Define locale resolution and deterministic fallback for localized non-text resources (for example sprites, voice, and layers). +- Provide first-run language selection and persist the selected language for subsequent launches. +- Preserve the existing project `I18n/` special-content boundary and `.galpak` installation model. + +## Non-goals + +- Implementing the design, changing production code, adding package references, or selecting a package version during discovery. +- Replacing the existing Editor localization service without an explicit compatibility/migration decision. +- Localizing arbitrary third-party plug-in UI unless its extension boundary is defined as part of the later design. +- Making raw filesystem paths the formal identity of game assets; stable asset IDs remain authoritative. +- Defining translation workflow tooling beyond template export/import and validation required for this feature. + +## Initial acceptance criteria + +- A player can select a language on first entry before normal UI content is shown; the selection persists and can later be changed in settings. +- Built-in UI translations load from embedded resources and update UI bindings after a language change. +- Game text resolves through a Runtime/Core-owned contract, independently of DynamicLocalization's concrete JSON/API types. +- Installed content can supply built-in game translations, and valid third-party JSON translations placed below the designated external directory are recursively discovered at runtime. +- Template export produces a documented, stable translator input and runtime behavior is deterministic when keys, cultures, or files conflict. +- Character names, dialogue, and branch options can resolve stable keys with a specified fallback chain. +- Locale-dependent assets resolve a requested locale variant and fall back predictably to a neutral/base asset without bypassing `IAssetManager` or breaking GUID-based references. +- Invalid external localization content is surfaced diagnostically and cannot crash normal startup or silently replace a valid translation in an unspecified order. +- Automated tests cover culture selection/persistence, source precedence, text fallback, external discovery, and localized-asset fallback. + +## Constraints and existing facts + +- `docs/spec/i18n.md` currently specifies that Runtime does not reference DynamicLocalization or a JSON format. It receives `ITextResolver` from its host; `PassthroughTextResolver` preserves existing direct-text content when no resolver is provided. +- The documented `I18nKey` helpers already derive stable entry and branch-option keys from stable graph IDs. Any expanded convention must not depend on mutable display names. +- `ProjectGameContentProvider` and `InstalledGameContentProvider` already treat `I18n/` as special content. It is not an `IAssetProvider` input. Installed resource PAKs are limited to `Assets/Paks/**/*.pak`. +- Assets are formally identified by stable IDs, not paths. Locale variation must integrate with the documented `AssetLocaleResolver`/asset-manager boundary rather than make filenames the gameplay-facing identity. +- Core/Runtime must remain decoupled from Avalonia, DynamicLocalization, and concrete filesystem implementations. +- The package API and license must be verified from the requested GitHub link before a design commits to its registration, resource schema, or Avalonia binding APIs. + +## Assumptions to validate + +- “UI” includes player/launcher UI as well as the existing Editor UI, but the player flow is the primary new scope. +- Built-in game translations are distributed inside the game package under `I18n/`; third-party translations reside outside the extracted package so game updates do not overwrite them. +- Third-party language sources are data-only JSON packs, not code or arbitrary resource archives. +- A locale-specific file naming convention may be an authoring/import convenience, while runtime resolves the corresponding asset through stable identity plus a locale-variant mapping. + +## Open questions + +1. Please provide the DynamicLocalization GitHub URL (and intended package/version, if already chosen). Its actual APIs, Avalonia integration, license, and supported JSON source model determine the adapter boundary. +2. Which product surfaces are in this feature: player/launcher UI only, or should the existing Editor UI localization service migrate in the same feature as well? +3. For third-party packs, should external files be allowed only to add a new locale, or may they override built-in translations for an existing locale? If overrides are allowed, what precedence should apply among nested files and packs? +4. What must be localized by third parties: text only, or text plus voices/images/layers? If binary assets are included, should they be package-local external files, or should the first release support only the built-in locale-suffixed assets? +5. What is the required fallback chain? A likely candidate is exact locale (`zh-Hans-CN`) → parent locale (`zh-Hans`) → configured default locale → neutral/base asset/text → visible key or source text, but this needs product confirmation. +6. When does “first entry” occur: before a game is selected, once per app installation, or once per installed game/version? Should an OS culture suggestion be preselected? + +## Related documents + +- `docs/spec/i18n.md` +- `docs/spec/assets.md` +- `docs/spec/architecture.md` +- `docs/spec/file-formats.md` +- `features/F-20260929-01-compiled-content-pipeline/design.md` diff --git a/src/Editor/GalNet.Editor.Headless/Program.cs b/src/Editor/GalNet.Editor.Headless/Program.cs index 67a14e3..1fc3616 100644 --- a/src/Editor/GalNet.Editor.Headless/Program.cs +++ b/src/Editor/GalNet.Editor.Headless/Program.cs @@ -57,7 +57,7 @@ public static async Task Main(string[] args) case "entries": return WriteEntries(document, args); case "variables": WriteJson(new { player = document.Graph.PlayerVariables, save = document.Graph.SaveVariables }); return 0; case "validate": - var validation = new EditorDocumentValidator().Validate(document); + var validation = EditorDocumentValidator.Validate(document); WriteJson(new { success = validation.IsValid, diagnostics = validation.Diagnostics }); return validation.IsValid ? 0 : 4; case "execute": @@ -72,7 +72,7 @@ public static async Task Main(string[] args) } private static async Task ExecuteAsync(string projectPath, EditorProjectDocument original, - IEditorCommandCatalog catalog, IProjectFileCommandCatalog fileCatalog, IEntryCatalog entryCatalog, string json) + EditorCommandCatalog catalog, AssetFileCommandCatalog fileCatalog, IEntryCatalog entryCatalog, string json) { using var parsed = JsonDocument.Parse(json); var root = parsed.RootElement; @@ -102,7 +102,7 @@ private static async Task ExecuteAsync(string projectPath, EditorProjectDoc changed.AddRange(execution.ChangedResources); if (execution.Description is not null) descriptions.Add(execution.Description.Description); } - var validation = new EditorDocumentValidator().Validate(working); + var validation = EditorDocumentValidator.Validate(working); if (!validation.IsValid) { WriteJson(new { success = false, diagnostics = validation.Diagnostics }); return 5; } var dryRun = root.TryGetProperty("dryRun", out var dryRunElement) && dryRunElement.GetBoolean(); if (!dryRun) await new DirectProjectPersistence(projectPath, new EditorDocumentRepository(entryCatalog)).SaveAsync(working); @@ -173,7 +173,7 @@ private static int WriteEntries(EditorProjectDocument document, string[] args) private static object CreateSummary(string projectPath, EditorProjectDocument document) { - var validation = new EditorDocumentValidator().Validate(document); + var validation = EditorDocumentValidator.Validate(document); return new { project = new { name = document.Graph.Name, path = projectPath }, diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs index 85cc850..64633ab 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandCatalog.cs @@ -34,7 +34,7 @@ public IProjectFileCommand Deserialize(string commandId, JsonElement payload, Js ?? throw new JsonException($"Asset command '{commandId}' deserialized to null."); } - private static IProjectCommandDefinition Define(string id, string description) where T : IProjectFileCommand + private static Definition Define(string id, string description) where T : IProjectFileCommand { var parameters = typeof(T).GetConstructors().OrderByDescending(item => item.GetParameters().Length).First().GetParameters() .Select(parameter => new EditorCommandParameter( diff --git a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs index d1d4ba9..dec6bf6 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/AssetFileCommandExecutor.cs @@ -149,7 +149,7 @@ private FileCommandResult Delete(string projectPath, string root, DeleteAssetCom return Success(transactionId, $"Moved asset '{command.RelativePath}' to the project recovery area.", [Resource(root, source)]); } - private IReadOnlyList FindReferences(string projectPath, string assetPath, string relativePath) + private List FindReferences(string projectPath, string assetPath, string relativePath) { var values = new HashSet(StringComparer.OrdinalIgnoreCase) { diff --git a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs index 719c874..22e732a 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/BuiltInEditorCommandHandler.Entry.cs @@ -338,7 +338,7 @@ private static void RemapMovedOutlet(EditorProjectDocument document, string node } } - private static void Renumber(IReadOnlyList entries) + private static void Renumber(List entries) { for (var index = 0; index < entries.Count; index++) entries[index].Id = index + 1; diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs index da2437e..8012837 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorCommandCatalog.cs @@ -15,7 +15,7 @@ public sealed class EditorCommandCatalog : IEditorCommandCatalog }; private readonly IReadOnlyList _definitions; - private readonly IReadOnlyDictionary _byId; + private readonly Dictionary _byId; public EditorCommandCatalog() { @@ -75,7 +75,7 @@ private static IReadOnlyList CreateDefinitions() => Define("project.settings.patch", "Updates selected project settings after validating each supported field.") ]; - private static IProjectCommandDefinition Define(string id, string description) + private static ProjectCommandDefinition Define(string id, string description) where TCommand : IProjectEditCommand => new ProjectCommandDefinition( id, diff --git a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs index 328a3fa..2d9ebda 100644 --- a/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs +++ b/src/Editor/GalNet.Editor.Shared/Commands/EditorDocumentValidator.cs @@ -5,7 +5,7 @@ namespace GalNet.Editor.Shared.Commands; public sealed class EditorDocumentValidator { - public ValidationResult Validate(EditorProjectDocument document) + public static ValidationResult Validate(EditorProjectDocument document) { var diagnostics = new List(); AddDuplicateDiagnostics(document.Graph.Nodes.Select(node => node.Id), "graph.node.duplicateId", "node", diagnostics); @@ -57,7 +57,7 @@ private static void AddDuplicateDiagnostics( IEnumerable values, string code, string kind, - ICollection diagnostics) + List diagnostics) { foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)).GroupBy(value => value, StringComparer.Ordinal).Where(group => group.Count() > 1)) diagnostics.Add(EditorDiagnostic.Error(code, $"Duplicate {kind} identifier '{value.Key}'.")); diff --git a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs index cdfac92..d68e082 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/EditorPlayerVariableStore.cs @@ -180,7 +180,7 @@ private void OnDefinitionsChanged(VariableScope scope) Changed?.Invoke(); } - private IReadOnlyList<(string Name, Variable DefaultValue)> GetDefinitions() + private List<(string Name, Variable DefaultValue)> GetDefinitions() { var definitions = new Dictionary(StringComparer.Ordinal); foreach (var definition in _systemVariables.Values) diff --git a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs index 5bd76c6..0001ebe 100644 --- a/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs +++ b/src/Editor/GalNet.Editor.Shared/Services/GamePackageExporter.cs @@ -78,7 +78,7 @@ private static async Task> LoadAssetsAsync(string asset return archive.AssetIds.OrderBy(id => id, StringComparer.Ordinal).Select(id => archive.GetAsset(id)!).ToArray(); } - private static IReadOnlyList<(string Path, byte[] Data)> BuildContentFiles(string projectRoot, GalleryCatalog gallery, CancellationToken ct) + private static (string Path, byte[] Data)[] BuildContentFiles(string projectRoot, GalleryCatalog gallery, CancellationToken ct) { return Directory.EnumerateFiles(projectRoot, "*", SearchOption.AllDirectories) .Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/')) diff --git a/src/Editor/GalNet.Editor/App.axaml.cs b/src/Editor/GalNet.Editor/App.axaml.cs index 56a57b9..dcca1cd 100644 --- a/src/Editor/GalNet.Editor/App.axaml.cs +++ b/src/Editor/GalNet.Editor/App.axaml.cs @@ -20,7 +20,7 @@ public partial class App : Application /// DI container for application-level services. public static IServiceProvider? ServiceProvider { get; private set; } - public ISettingsService GetSettingsService() => + public static ISettingsService GetSettingsService() => ServiceProvider!.GetRequiredService(); public override void Initialize() diff --git a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs index 0f102f7..27cd58e 100644 --- a/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs +++ b/src/Editor/GalNet.Editor/Composition/EditorServiceCollectionExtensions.cs @@ -60,7 +60,6 @@ private static IServiceCollection AddEditorCoreServices(this IServiceCollection services.AddSingleton(); services.AddScoped(); services.AddScoped(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -86,7 +85,7 @@ private static IServiceCollection AddEditorCoreServices(this IServiceCollection return services; } - private static IServiceCollection AddEditorCommands(this IServiceCollection services, IEntryCatalog targetProfile) + private static IServiceCollection AddEditorCommands(this IServiceCollection services, TargetProfileEntryCatalog targetProfile) { services.AddSingleton(); services.AddSingleton(); diff --git a/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs b/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs index e9f2674..be37ad3 100644 --- a/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs +++ b/src/Editor/GalNet.Editor/Controls/EntryTypePicker.axaml.cs @@ -107,7 +107,7 @@ private void RebuildCategories() RaiseSelectedProperties(); } - private IReadOnlyList GetOptions() => Options?.Cast().ToArray() ?? []; + private EntryTypeOption[] GetOptions() => Options?.Cast().ToArray() ?? []; private EntryTypeShortcutCommand? FindTypeCommand(string type) => ShortcutService?.Commands.OfType().FirstOrDefault(command => command.EntryType == type); diff --git a/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs b/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs index 817dd18..0b22218 100644 --- a/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs +++ b/src/Editor/GalNet.Editor/Dock/DockLayoutSerializer.cs @@ -52,7 +52,7 @@ public string Serialize(IRootDock layout) return JsonSerializer.Serialize(new LayoutData { Version = FormatVersion, Root = root, Windows = windows }, JsonOptions); } - public IRootDock? Deserialize(string serialized) + public static IRootDock? Deserialize(string serialized) { if (string.IsNullOrWhiteSpace(serialized)) return null; diff --git a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs index 0e2cc9b..bbacf07 100644 --- a/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs +++ b/src/Editor/GalNet.Editor/Dock/EditorDockFactory.cs @@ -314,7 +314,7 @@ private Document CreateDocument(IDockPanelContribution panel, object? parameter return document; } - private IEnumerable CreateDefaultPanels(DockPanelPlacement placement, params Document[] builtIns) + private List CreateDefaultPanels(DockPanelPlacement placement, params Document[] builtIns) { var documents = builtIns.Cast().ToList(); foreach (var panel in _extensions.DockPanelContributions.Where(panel => panel.IsDefaultPanel && panel.Placement == placement && builtIns.All(document => document.Id != panel.PanelId))) diff --git a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs index de57354..e8e6e72 100644 --- a/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs +++ b/src/Editor/GalNet.Editor/Inspector/ViewModels/AssetInspectorControlViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; @@ -187,7 +188,7 @@ private async Task LoadImagePreviewAsync() catch (Exception ex) { ImagePreviewError = ex.Message; } } - private static string FormatTime(double milliseconds) => TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)).ToString(@"m\:ss"); + private static string FormatTime(double milliseconds) => TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)).ToString(@"m\:ss", CultureInfo.CurrentCulture); public void Dispose() { Workspace.PropertyChanged -= OnWorkspacePropertyChanged; diff --git a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs b/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs index 10e5dfe..a683650 100644 --- a/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs +++ b/src/Editor/GalNet.Editor/Models/VariableDefinitionCollection.cs @@ -4,10 +4,10 @@ namespace GalNet.Editor.Models; /// Owns ordered editor variable definitions and their collection invariants. -public sealed class VariableDefinitionCollection +public sealed class VariableDefinitions { private readonly List _definitions; - public VariableDefinitionCollection(List definitions) => _definitions = definitions; + public VariableDefinitions(List definitions) => _definitions = definitions; public int Count => _definitions.Count; public IEnumerable Items => _definitions; public ProjectVariableDefinition Add(string name) diff --git a/src/Editor/GalNet.Editor/Program.cs b/src/Editor/GalNet.Editor/Program.cs index 9e65ff7..0f16d0e 100644 --- a/src/Editor/GalNet.Editor/Program.cs +++ b/src/Editor/GalNet.Editor/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using Avalonia; using Dock.Model.Core; using Dock.Settings; @@ -18,8 +19,10 @@ public static void Main(string[] args) Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console( + formatProvider: CultureInfo.InvariantCulture, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Debug( + formatProvider: CultureInfo.InvariantCulture, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.Sink(EditorLogSink.Instance) .CreateLogger(); diff --git a/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs b/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs index 028b360..38a43f9 100644 --- a/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs +++ b/src/Editor/GalNet.Editor/Services/EditorLocalizationService.cs @@ -47,7 +47,7 @@ public EditorLocalizationService() public string Format(string key, params object[] args) { var template = this[key]; - return string.Format(CultureInfo.CurrentUICulture, template, args); + return string.Format(CultureInfo.CurrentCulture, template, args); } public void ApplyLocale(string localeCode) diff --git a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs index 96c53f8..1dd2064 100644 --- a/src/Editor/GalNet.Editor/Services/EditorLogSink.cs +++ b/src/Editor/GalNet.Editor/Services/EditorLogSink.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Collections.ObjectModel; using Avalonia.Threading; using Serilog.Core; @@ -24,7 +25,7 @@ public void Emit(LogEvent logEvent) { Timestamp = logEvent.Timestamp, Level = logEvent.Level, - Message = logEvent.RenderMessage(), + Message = logEvent.RenderMessage(CultureInfo.InvariantCulture), Exception = logEvent.Exception?.ToString(), Source = GetSource(logEvent), Channel = GetChannel(logEvent) diff --git a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs index 8c20b3b..fa66cbb 100644 --- a/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs +++ b/src/Editor/GalNet.Editor/Services/EditorPreviewHost.cs @@ -94,7 +94,7 @@ internal sealed partial class EditorPreviewSessionService : ObservableObject, IG private readonly ObservableCollection _slots = []; private readonly ReadOnlyObservableCollection _readOnlySlots; private readonly NullGameView _fallback = new(); - private AvaloniaGamePageView? _pageView; + private AvaloniaGamePresenters? _pageView; private EditorPreviewLayerFactory? _layers; private AvaloniaEffectRuntime? _effects; private AvaloniaAudioPlaybackHost? _audio; @@ -187,6 +187,7 @@ public void Dispose() { _disposed = true; DisposeEngine(); + (_context.Saves as IDisposable)?.Dispose(); } } @@ -249,7 +250,7 @@ private void DisposeEngine() { (GalleryResources as IDisposable)?.Dispose(); GalleryResources = null; - if (_pageView is not null) _pageView.AdvanceRequested -= OnAdvanceRequested; + _gameplay.AdvanceRequested -= OnAdvanceRequested; _engine?.Dispose(); _pageView?.Dispose(); _layers?.Dispose(); @@ -278,7 +279,7 @@ private async Task EnsureEngineAsync(CancellationToken cancellationToken) OnPropertyChanged(nameof(GalleryDataSource)); OnPropertyChanged(nameof(GalleryResources)); _layers = new EditorPreviewLayerFactory(_preloadedTextures); - _pageView = new AvaloniaGamePageView(_gameplay, _page, _layers); + _pageView = new AvaloniaGamePresenters(_gameplay, _page, _layers); _effects = new AvaloniaEffectRuntime(_gameplay); _audio = new AvaloniaAudioPlaybackHost(_context.Assets); var view = new GameRegistration().AddBuiltins( @@ -292,7 +293,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); - _pageView.AdvanceRequested += OnAdvanceRequested; + _gameplay.AdvanceRequested += OnAdvanceRequested; _context.RuntimeCreated(runtime); await RefreshSlotsAsync(cancellationToken); } diff --git a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs index d495e28..25ab5f2 100644 --- a/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs +++ b/src/Editor/GalNet.Editor/Services/EditorViewFactory.cs @@ -12,7 +12,7 @@ namespace GalNet.Editor.Services; public sealed class EditorViewFactory : IEditorViewFactory { private readonly IServiceProvider _serviceProvider; - private static readonly IReadOnlyDictionary ViewModelToViewMap = new Dictionary + private static readonly Dictionary ViewModelToViewMap = new() { [typeof(ProjectSettingsPanelViewModel)] = typeof(ProjectSettingsPanelView), [typeof(EditorSettingsPanelViewModel)] = typeof(EditorSettingsPanelView), diff --git a/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs b/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs index 1730b57..3e72bb7 100644 --- a/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs +++ b/src/Editor/GalNet.Editor/Services/EditorWorkspacePersistence.cs @@ -12,20 +12,17 @@ public sealed class EditorWorkspacePersistence private readonly IEditorDocumentRepository _repository; private readonly IEditorDocumentService _documents; private readonly IEditorSaveCoordinator _saveCoordinator; - private readonly GraphDocumentMapper _mapper; public EditorWorkspacePersistence( IProjectService projects, IEditorDocumentRepository repository, IEditorDocumentService documents, - IEditorSaveCoordinator saveCoordinator, - GraphDocumentMapper mapper) + IEditorSaveCoordinator saveCoordinator) { _projects = projects; _repository = repository; _documents = documents; _saveCoordinator = saveCoordinator; - _mapper = mapper; } public GraphDocumentLoadResult? LoadCurrentProject() @@ -44,7 +41,7 @@ public EditorWorkspacePersistence( } _documents.Load(loaded); - return _mapper.Load(loaded); + return GraphDocumentMapper.Load(loaded); } public async Task SaveCurrentProjectAsync( @@ -58,7 +55,7 @@ public async Task SaveCurrentProjectAsync( _saveCoordinator.SaveProjectDocument( project.RootPath, document, - _mapper.CreateGroupEntriesSnapshot(nodes)); + GraphDocumentMapper.CreateGroupEntriesSnapshot(nodes)); await _projects.SaveAsync(); return true; } @@ -75,14 +72,14 @@ public string BuildPreviewData( return _saveCoordinator.BuildPreviewData( previewPath, document, - _mapper.CreateGroupEntriesSnapshot(nodes)); + GraphDocumentMapper.CreateGroupEntriesSnapshot(nodes)); } private EditorGraphDocument CreateDocument( string projectName, IEnumerable nodes, IEnumerable edges) => - _mapper.CreateDocument( + GraphDocumentMapper.CreateDocument( projectName, _documents.CurrentDocument.Version, nodes, diff --git a/src/Editor/GalNet.Editor/Services/FileDialogService.cs b/src/Editor/GalNet.Editor/Services/FileDialogService.cs index f4a3279..28a15e7 100644 --- a/src/Editor/GalNet.Editor/Services/FileDialogService.cs +++ b/src/Editor/GalNet.Editor/Services/FileDialogService.cs @@ -11,7 +11,7 @@ namespace GalNet.Editor.Services; public sealed class FileDialogService : IFileDialogService { - private static TopLevel? GetOwner() => Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop + private static Window? GetOwner() => Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop ? desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow : null; @@ -20,7 +20,7 @@ public sealed class FileDialogService : IFileDialogService var owner = GetOwner(); if (owner?.StorageProvider is null) return null; var folders = await owner.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = title, AllowMultiple = false }); - return folders.FirstOrDefault()?.Path.LocalPath; + return folders.Count == 0 ? null : folders[0].Path.LocalPath; } public async Task> OpenFilePickerAsync(string title, bool allowMultiple = true) diff --git a/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs b/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs index fb2da25..ffb0bc7 100644 --- a/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs +++ b/src/Editor/GalNet.Editor/Services/GraphDocumentMapper.cs @@ -12,7 +12,7 @@ namespace GalNet.Editor.Services; /// Maps persisted graph data to the editor presentation model and back. public sealed class GraphDocumentMapper { - public GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded) + public static GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded) { var nodes = loaded.Document.Nodes .Select(dto => CreateNode(dto, loaded.GroupEntries)) @@ -34,7 +34,7 @@ public GraphDocumentLoadResult Load(LoadedEditorProjectDocument loaded) return new GraphDocumentLoadResult(nodes, edges); } - public EditorGraphDocument CreateDocument( + public static EditorGraphDocument CreateDocument( string projectName, int version, IEnumerable nodes, @@ -52,7 +52,7 @@ public EditorGraphDocument CreateDocument( SaveVariables = saveVariables.Select(variable => variable.Clone()).ToList() }; - public IReadOnlyDictionary> CreateGroupEntriesSnapshot(IEnumerable nodes) => + public static IReadOnlyDictionary> CreateGroupEntriesSnapshot(IEnumerable nodes) => nodes.Where(node => node.NodeKind == GraphNodeKind.LinearGroup) .ToDictionary( node => node.Id, @@ -90,7 +90,7 @@ private static EditorGraphNodeDto ToDto(GraphNode node) return dto; } - private static GraphNode CreateNode(EditorGraphNodeDto dto, IReadOnlyDictionary> groupEntries) + private static GraphNode CreateNode(EditorGraphNodeDto dto, Dictionary> groupEntries) { var kind = dto.Type.Equals("Entry", StringComparison.OrdinalIgnoreCase) ? GraphNodeKind.Entry : dto.Type.Equals("Group", StringComparison.OrdinalIgnoreCase) ? GraphNodeKind.LinearGroup @@ -102,7 +102,7 @@ private static GraphNode CreateNode(EditorGraphNodeDto dto, IReadOnlyDictionary< GraphNodeKind.LinearGroup => new Group { Id = dto.Id, Name = dto.Name }, GraphNodeKind.ChoiceBranch => new Branch { Id = dto.Id, Name = dto.Name, BranchType = BranchType.Choice }, GraphNodeKind.ConditionBranch => new Branch { Id = dto.Id, Name = dto.Name, BranchType = BranchType.Condition }, - _ => throw new ArgumentOutOfRangeException() + _ => throw new ArgumentOutOfRangeException(nameof(dto), dto, null) }; var graphNode = new GraphNode(node, kind) { X = dto.X, Y = dto.Y }; if (kind == GraphNodeKind.LinearGroup) diff --git a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs index fa68029..674cf83 100644 --- a/src/Editor/GalNet.Editor/Services/GraphEditingService.cs +++ b/src/Editor/GalNet.Editor/Services/GraphEditingService.cs @@ -92,7 +92,7 @@ public IReadOnlyList InsertEntries(GraphNode groupNode if (groupNode.NodeKind != GraphNodeKind.LinearGroup || count < 1) return []; - var definition = _catalog.Definitions.FirstOrDefault(); + var definition = _catalog.Definitions.Count > 0 ? _catalog.Definitions[0] : null; if (definition is null) return []; @@ -150,9 +150,9 @@ public bool AddChoiceOption(ObservableCollection nodes, ObservableCol return true; } - public bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option) + public bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption) { - if (node.NodeKind != GraphNodeKind.ChoiceBranch || !node.Options.Remove(option)) + if (node.NodeKind != GraphNodeKind.ChoiceBranch || !node.Options.Remove(choiceOption)) return false; node.RefreshConnectors(); @@ -161,12 +161,12 @@ public bool RemoveChoiceOption(ObservableCollection nodes, Observable return true; } - public bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option, int newIndex) + public bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex) { if (node.NodeKind != GraphNodeKind.ChoiceBranch) return false; - return MoveBranchItemWithEdges(nodes, edges, node, node.Options, option, newIndex); + return MoveBranchItemWithEdges(nodes, edges, node, node.Options, choiceOption, newIndex); } public bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node) diff --git a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs index 4de6bcb..751341a 100644 --- a/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs +++ b/src/Editor/GalNet.Editor/Services/Interfaces/IGraphEditingService.cs @@ -14,8 +14,8 @@ public interface IGraphEditingService bool RemoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry); bool MoveEntry(GraphNode groupNode, EntryEditorItemViewModel entry, int newIndex); bool AddChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node); - bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option); - bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel option, int newIndex); + bool RemoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption); + bool MoveChoiceOption(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchOptionEditorItemViewModel choiceOption, int newIndex); bool AddCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node); bool RemoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition); bool MoveCondition(ObservableCollection nodes, ObservableCollection edges, GraphNode node, BranchConditionEditorItemViewModel condition, int newIndex); diff --git a/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs b/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs index 9cf54fe..4240adb 100644 --- a/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs +++ b/src/Editor/GalNet.Editor/Services/ProjectSaveScheduler.cs @@ -95,7 +95,6 @@ public void Dispose() private void ThrowIfDisposed() { - if (_disposed) - throw new ObjectDisposedException(nameof(ProjectSaveScheduler)); + ObjectDisposedException.ThrowIf(_disposed, this); } } diff --git a/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs index 8263dab..96fb067 100644 --- a/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/AssetPanelViewModel.cs @@ -186,7 +186,7 @@ private async Task DeleteSelectedAsync() try { await ExecuteFileCommandAsync(new DeleteAssetCommand(SelectedEntry.RelativePath)); SelectedEntry = null; LoadEntries(); } catch (Exception ex) { ErrorText = ex.Message; } } - public bool CanMoveTo(AssetEntry target, string sourceRelativePath) + public static bool CanMoveTo(AssetEntry target, string sourceRelativePath) { if (!target.IsDirectory || string.IsNullOrWhiteSpace(sourceRelativePath)) return false; return CanMoveToDirectory(target.RelativePath, sourceRelativePath); diff --git a/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs index 55f663a..37ea589 100644 --- a/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/EditorPageViewModel.cs @@ -220,7 +220,7 @@ private void DeleteLayout(string? name) if (string.IsNullOrWhiteSpace(serialized)) return null; try { - var layout = _dockLayoutSerializer.Deserialize(serialized); + var layout = DockLayoutSerializer.Deserialize(serialized); if (layout is null) return null; @@ -394,9 +394,10 @@ public void Dispose() _dockFactory.LayoutChanged -= OnDockLayoutChanged; _projectService.CurrentChanged -= OnCurrentProjectChanged; L.PropertyChanged -= _localizationChangedHandler; + GC.SuppressFinalize(this); } - private IList BuildViewMenuItems() => new AvaloniaList( + private AvaloniaList BuildViewMenuItems() => new( _dockFactory.ViewMenuPanels.Select(panel => new MenuData { HeaderKey = panel.TitleKey, @@ -406,7 +407,7 @@ public void Dispose() IsChecked = panel.IsGlobal && _dockFactory.HasGlobalPanel(panel.PanelId) })); - private IList BuildLoadLayoutItems() + private AvaloniaList BuildLoadLayoutItems() { var items = new AvaloniaList { new() { Header = "Default", Command = LoadLayoutCommand, CommandParameter = "Default" } }; foreach (var name in _editorSettingsService.GetSettings().DockLayouts.Keys.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)) @@ -414,7 +415,7 @@ private IList BuildLoadLayoutItems() return items; } - private IList BuildDeleteLayoutItems() => new AvaloniaList( + private AvaloniaList BuildDeleteLayoutItems() => new( _editorSettingsService.GetSettings().DockLayouts.Keys .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) .Select(name => new MenuData { Header = name, Command = new RelayCommand(DeleteLayout), CommandParameter = name })); @@ -430,7 +431,7 @@ private void RefreshViewMenuChecks() } } - private async Task RequestLayoutNameAsync() + private static async Task RequestLayoutNameAsync() { var owner = GetMainWindow(); if (owner is null) return null; diff --git a/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs index 8a82d92..a66bc6d 100644 --- a/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/EditorWorkspaceViewModel.cs @@ -125,6 +125,7 @@ public void Dispose() _variableDefinitionService.DefinitionsChanged -= _definitionsChangedHandler; _histories.Changed -= OnHistoryChanged; _graphChangeTracker.Dispose(); + GC.SuppressFinalize(this); } public void SelectNode(GraphNode? node, bool additive = false) @@ -388,7 +389,7 @@ public void SaveGraphDocument() if (edits.Count > 0) PushGraphEdit(new CompositeEdit("Move nodes", edits)); } - public void PersistGraphDocument() + public static void PersistGraphDocument() { // The live ViewModels are the canonical GUI state. SaveCoreAsync maps them to files. } @@ -758,7 +759,7 @@ private void ReplaceEdges(IReadOnlyList edges) private void RefreshGraphNode(GraphNode node) { node.RefreshConnectors(); UpdateConnectorStates(); } - private IUndoableEdit CreateBranchMoveEdit(string description, GraphNode node, ObservableCollection items, + private DelegateEdit CreateBranchMoveEdit(string description, GraphNode node, ObservableCollection items, int before, int after, IReadOnlyDictionary oldOutlets, IReadOnlyDictionary newOutlets) { void Apply(int from, int to, IReadOnlyDictionary outlets) diff --git a/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs index 8cf91bd..b1b18f3 100644 --- a/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/GamePreviewPanelViewModel.cs @@ -324,13 +324,18 @@ private async Task StopPreviewAsync() public async ValueTask DisposeAsync() { await DisposePreviewForProjectCloseAsync(); + GC.SuppressFinalize(this); } /// /// Dock.Model disposes document contexts synchronously. Start the same idempotent /// cleanup path without blocking the UI thread that owns the document close. /// - public void Dispose() => _ = DisposeAsync(); + public void Dispose() + { + _ = DisposeAsync().AsTask(); + GC.SuppressFinalize(this); + } private async Task DisposePreviewForProjectCloseAsync() { diff --git a/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs index e05bfb1..8434004 100644 --- a/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/ProjectSettingsPanelViewModel.cs @@ -13,7 +13,7 @@ namespace GalNet.Editor.ViewModels; public partial class ProjectSettingsPanelViewModel : ObservableObject, IDisposable, IUndoRedoTarget { - public IReadOnlyList ResolutionPresets => NewProjectPanelViewModel.ResolutionPresets; + public IReadOnlyList ResolutionPresets { get; } = NewProjectPanelViewModel.ResolutionPresets; public IReadOnlyList AudioBuses { get; } = [GalNet.Core.Audio.AudioBuses.Bgm, GalNet.Core.Audio.AudioBuses.Sfx, GalNet.Core.Audio.AudioBuses.Vocal]; public ObservableCollection AudioTracks { get; } = []; private readonly IProjectService _projectService; @@ -70,6 +70,7 @@ public void Dispose() { _projectService.CurrentChanged -= OnProjectChanged; _histories.Settings.Changed -= UpdateDirtyText; + GC.SuppressFinalize(this); } private void LoadFromProject() diff --git a/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs b/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs index 1d5a09c..8fa4d63 100644 --- a/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs +++ b/src/Editor/GalNet.Editor/ViewModels/StartupPageViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Collections.ObjectModel; using System.IO; using System.Threading.Tasks; @@ -57,7 +58,7 @@ private void RefreshRecentProjects() ? Path.GetFileName(Path.TrimEndingDirectorySeparator(info.Path)) : info.Name, Path = info.Path, - LastOpened = info.LastOpened.ToString("yyyy-MM-dd HH:mm") + LastOpened = info.LastOpened.ToString("yyyy-MM-dd HH:mm", CultureInfo.CurrentCulture) }); } } diff --git a/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs b/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs index 83dc7c2..1eaa4ee 100644 --- a/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs +++ b/src/Editor/GalNet.Editor/ViewModels/VariableListEditorViewModels.cs @@ -15,7 +15,7 @@ namespace GalNet.Editor.ViewModels; public sealed partial class VariableListEditorViewModel : ObservableObject { - private readonly VariableDefinitionCollection _definitions; + private readonly VariableDefinitions _definitions; private readonly VariableScope _scope; private readonly Action _persistDefinitions; private readonly Func _getCurrentValue; @@ -46,7 +46,7 @@ public VariableListEditorViewModel( Action persistDefinitions, Action? onNameConflict = null) { - _definitions = new VariableDefinitionCollection(source); + _definitions = new VariableDefinitions(source); _scope = scope; _persistDefinitions = persistDefinitions; _getCurrentValue = getCurrentValue; diff --git a/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs index 2008e36..54eff4b 100644 --- a/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs +++ b/src/Editor/GalNet.Editor/Views/AssetPanelView.axaml.cs @@ -53,7 +53,17 @@ private void OnDragOver(object? sender, DragEventArgs e) return; } var files = e.DataTransfer.TryGetFiles(); - e.DragEffects = files?.Any() == true ? DragDropEffects.Copy : DragDropEffects.None; + var fileEnumerator = files?.GetEnumerator(); + try + { + e.DragEffects = fileEnumerator?.MoveNext() == true + ? DragDropEffects.Copy + : DragDropEffects.None; + } + finally + { + (fileEnumerator as IDisposable)?.Dispose(); + } if (DataContext is AssetPanelViewModel vm) vm.SetExternalDropActive(e.DragEffects == DragDropEffects.Copy); e.Handled = true; } @@ -93,7 +103,7 @@ private void OnEntryDragOver(object? sender, DragEventArgs e) { if (sender is not Avalonia.Controls.Control { DataContext: AssetEntry target } || DataContext is not AssetPanelViewModel vm || !e.DataTransfer.Formats.Contains(InternalAssetFormat)) return; var sourcePath = e.DataTransfer.TryGetValue(InternalAssetFormat); - var allowed = sourcePath is not null && vm.CanMoveTo(target, sourcePath); + var allowed = sourcePath is not null && AssetPanelViewModel.CanMoveTo(target, sourcePath); e.DragEffects = allowed ? DragDropEffects.Move : DragDropEffects.None; vm.SetDropTarget(allowed ? target : null); e.Handled = true; diff --git a/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs b/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs index d395edb..45a0c98 100644 --- a/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs +++ b/src/Editor/GalNet.Editor/Views/GamePreviewPanelView.axaml.cs @@ -17,10 +17,10 @@ public GamePreviewPanelView() DetachedFromVisualTree += OnDetachedFromVisualTree; } - private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + private async void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) { if (_vm is not null) - _ = _vm.DisposeAsync(); + await _vm.DisposeAsync(); } protected override void OnDataContextChanged(EventArgs e) diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs index fba45e2..4611f15 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/FilePlayerVariableStore.cs @@ -6,7 +6,7 @@ namespace GalNet.Storage.FileSystem; /// File-backed store for player variables that survive individual save slots. -public sealed class FilePlayerVariableStore : IPlayerVariableStore +public sealed class FilePlayerVariableStore : IPlayerVariableStore, IDisposable { private readonly string _path; private readonly SemaphoreSlim _gate = new(1, 1); @@ -45,6 +45,12 @@ public async Task SaveAsync(IReadOnlyDictionary variables, finally { _gate.Release(); } } + public void Dispose() + { + _gate.Dispose(); + GC.SuppressFinalize(this); + } + private static GalVariable Clone(string name, GalVariable source) { var clone = new GalVariable { Name = name }; diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs index 15f9601..d848b8d 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/FileSaveService.cs @@ -5,7 +5,7 @@ namespace GalNet.Storage.FileSystem; /// File-backed slot and quick-save store. -public sealed class FileSaveService : ISaveService +public sealed class FileSaveService : ISaveService, IDisposable { private const int FormatVersion = GameSnapshot.CurrentFormatVersion; private readonly string _root; @@ -106,7 +106,7 @@ private async Task GetInfoAsync(int slot, bool quick, Cancellation catch { return new SaveSlotInfo { SlotIndex = slot, IsQuickSave = quick, IsCorrupt = true }; } } - private async Task ReadAsync(string path, CancellationToken ct) + private static async Task ReadAsync(string path, CancellationToken ct) { if (!File.Exists(path)) return null; try @@ -127,6 +127,12 @@ private void DeleteFiles(int slot, bool quick) if (File.Exists(path)) File.Delete(path); } + public void Dispose() + { + _gate.Dispose(); + GC.SuppressFinalize(this); + } + private sealed class StoredSave { public int Version { get; set; } diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs b/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs index f660726..83d550f 100644 --- a/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs +++ b/src/Infrastructure/GalNet.Storage.FileSystem/FileVariableService.cs @@ -9,7 +9,7 @@ namespace GalNet.Storage.FileSystem; /// Platform-neutral runtime variable bridge backed by an . /// Variables prefixed with player. persist globally; all other variables remain in the active save. /// -public sealed class FileVariableService : IVariableService +public sealed class FileVariableService : IVariableService, IDisposable { private readonly IPlayerVariableStore _playerStore; private readonly Dictionary _playerVariables; @@ -78,6 +78,12 @@ public async Task ResetPlayerVariablesAsync(CancellationToken ct = default) VariableChanged?.Invoke(VariableScope.Player, pair.Key, pair.Value); } + public void Dispose() + { + (_playerStore as IDisposable)?.Dispose(); + GC.SuppressFinalize(this); + } + private static GalVariable Clone(string name, GalVariable source) { var clone = new GalVariable { Name = name }; diff --git a/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs b/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs new file mode 100644 index 0000000..327468a --- /dev/null +++ b/src/Infrastructure/GalNet.Storage.FileSystem/GameProjectSettingsLoader.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GalNet.Core.Settings; + +namespace GalNet.Storage.FileSystem; + +internal static class GameProjectSettingsLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + public static ProjectSettings Load(string directory) + { + var path = Path.Combine(directory, "settings.json"); + if (!File.Exists(path)) return new ProjectSettings(); + try + { + return JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException("Project settings must be a JSON object."); + } + catch (JsonException error) + { + throw new InvalidDataException($"Invalid project settings in '{path}'.", error); + } + } +} diff --git a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs index 674b3d3..ef48bbd 100644 --- a/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs +++ b/src/Presentation/GalNet.Avalonia.Controls/TypewriterTextBlock.cs @@ -81,7 +81,7 @@ public Task StartAsync(CancellationToken cancellationToken = default) IsCompleted = false; Text = null; (Inlines ?? throw new InvalidOperationException("TextBlock did not provide an inline collection.")).Clear(); - _ = RenderAsync(SourceText, _runCancellation.Token, _completion, _skipSignal); + _ = RenderAsync(SourceText, _completion, _skipSignal, _runCancellation.Token); return _completion.Task; } @@ -99,9 +99,9 @@ public void Skip() private async Task RenderAsync( string? source, - CancellationToken cancellationToken, TaskCompletionSource completion, - TaskCompletionSource skipSignal) + TaskCompletionSource skipSignal, + CancellationToken cancellationToken) { try { @@ -125,14 +125,14 @@ private async Task RenderAsync( cancellationToken.ThrowIfCancellationRequested(); run.Text += character; if (CharactersPerSecond > 0) - await DelayOrSkipAsync(TimeSpan.FromSeconds(1d / CharactersPerSecond), cancellationToken, skipSignal); + await DelayOrSkipAsync(TimeSpan.FromSeconds(1d / CharactersPerSecond), skipSignal, cancellationToken); } break; case RichTypewriterTokenKind.LineBreak: inlines.Add(new LineBreak()); break; case RichTypewriterTokenKind.Delay: - await DelayOrSkipAsync(TimeSpan.FromMilliseconds(token.DelayMilliseconds), cancellationToken, skipSignal); + await DelayOrSkipAsync(TimeSpan.FromMilliseconds(token.DelayMilliseconds), skipSignal, cancellationToken); break; case RichTypewriterTokenKind.Instant: lock (_skipGate) @@ -170,7 +170,7 @@ private async Task RenderAsync( } } - private async Task DelayOrSkipAsync(TimeSpan duration, CancellationToken cancellationToken, TaskCompletionSource skipSignal) + private async Task DelayOrSkipAsync(TimeSpan duration, TaskCompletionSource skipSignal, CancellationToken cancellationToken) { Task signal; lock (_skipGate) diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs index 1d57001..980a820 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/GalleryPageNavigation.cs @@ -73,7 +73,7 @@ private static string NormalizeTypeId(string? value) public sealed class GalleryPageRegistry(IReadOnlyDictionary pages) : IGalleryPageRegistry { - private readonly IReadOnlyDictionary _pages = new Dictionary(pages, StringComparer.Ordinal); + private readonly Dictionary _pages = new(pages, StringComparer.Ordinal); public Type GetViewModelType(string galleryTypeId) => TryGetViewModelType(galleryTypeId, out var viewModelType) diff --git a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs index d52d9d9..01858a5 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Navigation/PageNavigation.cs @@ -259,7 +259,7 @@ public IPageViewRegistry Build() /// Immutable runtime page mapping. public sealed class PageViewRegistry : IPageViewRegistry { - private readonly IReadOnlyDictionary _views; + private readonly Dictionary _views; internal PageViewRegistry(IReadOnlyDictionary views) => _views = new Dictionary(views); diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs index 0379725..9a97ba8 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GameShell.axaml.cs @@ -60,6 +60,7 @@ public void Dispose() _gameplay.ScreenshotRequested -= OnScreenshotRequested; _gameplay.ReturnToTitleRequested -= OnReturnToTitleRequested; } + GC.SuppressFinalize(this); } private void OnSessionPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs eventArgs) diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs index 47e4781..8328d22 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaDialoguePresenter.cs @@ -16,12 +16,10 @@ public AvaloniaDialoguePresenter(GamePageViewModel state, GamePage page, IAvalon _state = state; _page = page; _dispatcher = dispatcher; - _state.AdvanceRequested += OnAdvanceRequested; _state.NvlClearRequested += OnNvlClearRequested; } public Task InitialPresentationReady => _initialPresentationReady.Task; - public event Action? AdvanceRequested; public void CompleteInitialPresentation() => _initialPresentationReady.TrySetResult(); public void FailInitialPresentation(Exception exception) => _initialPresentationReady.TrySetException(exception); @@ -71,10 +69,8 @@ Task IChoicePresenter.ChooseAsync(IReadOnlyList options, Cancellati public void Dispose() { - _state.AdvanceRequested -= OnAdvanceRequested; _state.NvlClearRequested -= OnNvlClearRequested; } - private void OnAdvanceRequested() => AdvanceRequested?.Invoke(); private void OnNvlClearRequested() => _dispatcher.Dispatch(_page.Nvl.Clear); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs similarity index 89% rename from src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs rename to src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs index 68d02bf..1ca6fe0 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePageView.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Presentation/AvaloniaGamePresenters.cs @@ -11,13 +11,13 @@ public interface IGamePageLayerFactory : ISceneTextureResolver } /// Composes the presentation ports used by one shared game page. -public sealed class AvaloniaGamePageView : IDisposable +public sealed class AvaloniaGamePresenters : IDisposable { private readonly AvaloniaDialoguePresenter _dialogue; private readonly AvaloniaAnimationPresenter _animation; private readonly AvaloniaParticlePresenter _particles; - public AvaloniaGamePageView( + public AvaloniaGamePresenters( GamePageViewModel state, GamePage page, IGamePageLayerFactory layers, @@ -43,12 +43,6 @@ public AvaloniaGamePageView( public IParticlePresenter ParticlePresenter { get; } public Task InitialPresentationReady => _dialogue.InitialPresentationReady; - public event Action? AdvanceRequested - { - add => _dialogue.AdvanceRequested += value; - remove => _dialogue.AdvanceRequested -= value; - } - public void CompleteInitialPresentation() => _dialogue.CompleteInitialPresentation(); public void FailInitialPresentation(Exception exception) => _dialogue.FailInitialPresentation(exception); diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs index d239ecf..39101c6 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Services/AvaloniaAudioPlaybackHost.cs @@ -200,7 +200,7 @@ private async Task MaterializeAsync(string assetId, CancellationToken ca await File.WriteAllBytesAsync(path, handle.Value, cancellationToken).ConfigureAwait(false); lock (_gate) { - if (_disposed) throw new ObjectDisposedException(nameof(AvaloniaAudioPlaybackHost)); + ObjectDisposedException.ThrowIf(_disposed, this); _paths.TryAdd(assetId, path); return _paths[assetId]; } diff --git a/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs b/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs index 25a3126..a519b81 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Services/IGameSessionService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Collections.ObjectModel; using System.ComponentModel; @@ -39,5 +40,5 @@ public interface IPreparedGameSessionService /// Presentation-safe save slot data. The host owns loading, writing and preview bytes. public sealed record GameSaveSlot(int SlotIndex, DateTime Timestamp, string Description, bool IsEmpty, bool IsCorrupt) { - public string DisplayTimestamp => Timestamp == default ? string.Empty : Timestamp.ToString("g"); + public string DisplayTimestamp => Timestamp == default ? string.Empty : Timestamp.ToString("g", CultureInfo.CurrentCulture); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs index 85a1357..9598a96 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/CgGalleryImagePageViewModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -21,7 +22,7 @@ public Task ActivateAsync(GalleryItemData item, CancellationToken cancellationTo Image?.Dispose(); Image = null; ErrorMessage = null; - Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString() : item.Item.Title; + Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString(CultureInfo.InvariantCulture) : item.Item.Title; if (!item.IsUnlocked) { ErrorMessage = "This Gallery item is locked."; return Task.CompletedTask; } var path = media.ResolvePath(item.Item.ResourceId); try { Image = path is null ? null : new Bitmap(path); } diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs index 1017e39..db8f2d5 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryEntryViewModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using GalNet.Core.Gallery; @@ -8,7 +9,7 @@ public sealed partial class GalleryEntryViewModel(GalleryItemData data) : Observ { public GalleryItemData Data { get; } = data; public int Id => Data.Item.Id; - public string Title => string.IsNullOrWhiteSpace(Data.Item.Title) ? Data.Item.Id.ToString() : Data.Item.Title; + public string Title => string.IsNullOrWhiteSpace(Data.Item.Title) ? Data.Item.Id.ToString(CultureInfo.InvariantCulture) : Data.Item.Title; public bool IsUnlocked => Data.IsUnlocked; public bool IsLocked => !IsUnlocked; diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs index a1483f7..c0aea65 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/GalleryVisualPageViewModelBase.cs @@ -64,6 +64,7 @@ public void Dispose() _thumbnailCancellation = null; foreach (var item in Items) item.Dispose(); Items.Clear(); + GC.SuppressFinalize(this); } protected IGameNavigationService Navigation => navigation; diff --git a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs index 49e5801..669c4b9 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/ViewModels/VideoGalleryPlayerPageViewModel.cs @@ -1,3 +1,4 @@ +using System.Globalization; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using GalNet.Avalonia.GameView.Navigation; @@ -18,7 +19,7 @@ public sealed partial class VideoGalleryPlayerPageViewModel( public Task ActivateAsync(GalleryItemData item, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString() : item.Item.Title; + Title = string.IsNullOrWhiteSpace(item.Item.Title) ? item.Item.Id.ToString(CultureInfo.InvariantCulture) : item.Item.Title; ErrorMessage = null; if (!item.IsUnlocked) ErrorMessage = "This Gallery item is locked."; else { media.PlayVideo(item); ErrorMessage = media.LastError; } diff --git a/src/Samples/GalNet.Sample.Avalonia/GameLaunchOptions.cs b/src/Samples/GalNet.Sample.Avalonia/GameLaunchOptions.cs index 9174cd5..8cbb64c 100644 --- a/src/Samples/GalNet.Sample.Avalonia/GameLaunchOptions.cs +++ b/src/Samples/GalNet.Sample.Avalonia/GameLaunchOptions.cs @@ -23,7 +23,7 @@ public static GameLaunchOptions Parse(string[] args) continue; } - if (!args[index].StartsWith("-", StringComparison.Ordinal) && gameDirectory is null) + if (!args[index].StartsWith('-') && gameDirectory is null) gameDirectory = Path.GetFullPath(args[index]); } diff --git a/src/Samples/GalNet.Sample.Avalonia/Presentation/SampleMediaViews.cs b/src/Samples/GalNet.Sample.Avalonia/Presentation/SampleMediaViews.cs deleted file mode 100644 index 11f41c9..0000000 --- a/src/Samples/GalNet.Sample.Avalonia/Presentation/SampleMediaViews.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Avalonia.Threading; -using GalNet.Avalonia.GameView.ViewModels; - -namespace GalNet.Sample.Avalonia.Presentation; - -/// Sample-specific audio/video service. Replace this in a real client to select another backend. -internal sealed class SampleMediaViews(GamePageViewModel page) : IDisposable -{ - public void PlayAudio(string channel, string assetId, float volume, string mode, int times) - { - SetStatus($"Audio unavailable in this sample build ({channel}: {assetId})."); - } - - public void StopAudio(string channel) => SetStatus($"Audio unavailable in this sample build ({channel})."); - public void PauseAudio(string channel) => SetStatus($"Audio unavailable in this sample build ({channel})."); - public void ResumeAudio(string channel) => SetStatus($"Audio unavailable in this sample build ({channel})."); - public void EnqueueAudio(string channel, string assetId, int times) => - SetStatus($"Audio queue unavailable in this sample build ({channel}: {assetId})."); - public void ConfigureAudioQueue(string channel, string onEnd, string onEmpty) { } - public void PlayVideo(string assetId) => SetStatus($"Video requested: {assetId}"); - public void StopVideo() => SetStatus("Video stopped."); - public void Dispose() { } - - private void SetStatus(string message) - { - if (Dispatcher.UIThread.CheckAccess()) page.StatusMessage = message; - else Dispatcher.UIThread.Post(() => page.StatusMessage = message); - } -} diff --git a/src/Samples/GalNet.Sample.Avalonia/Program.cs b/src/Samples/GalNet.Sample.Avalonia/Program.cs index 542c68e..1aa1c49 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Program.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Program.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Avalonia; using GalNet.Runtime.Logging; using GalNet.Sample.Avalonia.Debug; @@ -16,9 +17,11 @@ public static void Main(string[] args) .MinimumLevel.Debug() .Enrich.WithProperty("LogChannel", "Game") .WriteTo.Console( + formatProvider: CultureInfo.InvariantCulture, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3} {LogChannel}] {Message:lj}{NewLine}{Exception}") .WriteTo.File( Path.Combine(launchOptions.LogDirectory, "game-.log"), + formatProvider: CultureInfo.InvariantCulture, rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14, shared: true, diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/PersistentSceneRestorer.cs b/src/Samples/GalNet.Sample.Avalonia/Services/PersistentSceneRestorer.cs index bfda0d6..460b853 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/PersistentSceneRestorer.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/PersistentSceneRestorer.cs @@ -18,6 +18,6 @@ public Task RestoreAsync(IGameRuntime runtime, CancellationToken cancellationTok particles, animations, layers, - cancellationToken, - videos); + videos, + cancellationToken); } diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGamePresentation.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGamePresentation.cs new file mode 100644 index 0000000..c3bd637 --- /dev/null +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGamePresentation.cs @@ -0,0 +1,89 @@ +using GalNet.Assets; +using GalNet.Avalonia.GameView.Page; +using GalNet.Avalonia.GameView.Presentation; +using GalNet.Avalonia.GameView.Services; +using GalNet.Avalonia.GameView.ViewModels; +using GalNet.Presentation.Abstractions.View; +using GalNet.Primitives.Builtins; +using GalNet.Rendering.Scene; +using GalNet.Runtime.Logging; +using GalNet.Sample.Avalonia.Presentation; + +namespace GalNet.Sample.Avalonia.Services; + +/// Sample composition and ownership of platform resources; no story execution logic. +internal sealed class SampleGamePresentation : IAsyncDisposable +{ + private SampleLayerFactory? _layers; + private SkiaShaderEffectProgramResolver? _programs; + private Task? _disposeTask; + + private SampleGamePresentation() { } + + public AvaloniaGamePresenters Presenters { get; private set; } = null!; + public AvaloniaEffectRuntime Effects { get; private set; } = null!; + public AvaloniaAudioPlaybackHost Audio { get; private set; } = null!; + public AvaloniaVideoPresenter Video { get; private set; } = null!; + public CompositeGameView View { get; private set; } = null!; + + public static async Task CreateAsync( + GamePageViewModel gameplay, GamePage page, SampleGameResourceScope resources, + CancellationToken cancellationToken) + { + var presentation = new SampleGamePresentation(); + try + { + presentation._layers = new SampleLayerFactory(resources.PreloadedTextures); + presentation.Presenters = new AvaloniaGamePresenters(gameplay, page, presentation._layers); + presentation._programs = new SkiaShaderEffectProgramResolver( + new AssetManagerShaderEffectProgramSource(resources.Assets)); + var preload = await presentation._programs.PreloadAsync(resources.EffectPrograms, cancellationToken); + GameLog.Logger.Information( + "Preloaded {UsableEffectProgramCount}/{EffectProgramCount} effect programs in {ElapsedMilliseconds} ms", + preload.UsableProgramCount, preload.RequestedProgramCount, preload.Cache.TotalLoadTime.TotalMilliseconds); + presentation.Effects = new AvaloniaEffectRuntime(gameplay, presentation._programs); + presentation.Audio = new AvaloniaAudioPlaybackHost(resources.Assets); + presentation.Video = new AvaloniaVideoPresenter(resources.Assets, gameplay); + var presenters = presentation.Presenters; + using var registration = new GameRegistration(); + registration.AddBuiltins( + presenters.DialoguePresenter, presenters.LayerPresenter, presenters.AnimationPresenter, + presentation.Effects, resources.Content.Gallery, presenters.ParticlePresenter, + presenters.NvlPresenter, presentation.Audio, presentation.Video); + presentation.View = registration.BuildView(); + return presentation; + } + catch (Exception error) + { + try { await presentation.DisposeAsync(); } + catch (Exception cleanupError) { throw new AggregateException(error, cleanupError); } + throw; + } + } + + public ValueTask DisposeAsync() => new(_disposeTask ??= DisposeCoreAsync()); + + private async Task DisposeCoreAsync() + { + List? errors = null; + // Implementations borrow platform ports, so release the view first. + await ReleaseAsync(() => View?.DisposeAsync() ?? ValueTask.CompletedTask); + await ReleaseAsync(() => Video?.DisposeAsync() ?? ValueTask.CompletedTask); + await ReleaseAsync(() => Audio?.DisposeAsync() ?? ValueTask.CompletedTask); + await ReleaseAsync(() => + { + if (Effects is not null) Effects.Dispose(); // owns the program resolver + else _programs?.Dispose(); + return ValueTask.CompletedTask; + }); + await ReleaseAsync(() => { Presenters?.Dispose(); return ValueTask.CompletedTask; }); + await ReleaseAsync(() => { _layers?.Dispose(); return ValueTask.CompletedTask; }); + if (errors is not null) throw new AggregateException(errors); + + async Task ReleaseAsync(Func release) + { + try { await release(); } + catch (Exception error) { (errors ??= []).Add(error); } + } + } +} diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs index 7b91091..f14f7b4 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameRunner.cs @@ -1,165 +1,74 @@ -using GalNet.Assets; using GalNet.Avalonia.GameView.Page; -using GalNet.Avalonia.GameView.Presentation; -using GalNet.Avalonia.GameView.Services; using GalNet.Avalonia.GameView.ViewModels; using GalNet.Core.Runtime; using GalNet.Core.Settings; -using GalNet.Presentation.Abstractions.Runtime; -using GalNet.Presentation.Abstractions.View; -using GalNet.Primitives.Builtins; -using GalNet.Rendering.Scene; using GalNet.Runtime.Engine; using GalNet.Runtime.Logging; using GalNet.Runtime.Runtime; -using GalNet.Sample.Avalonia.Presentation; namespace GalNet.Sample.Avalonia.Services; +/// Sample page binding and run ownership; the engine owns execution and terminal states. internal sealed class SampleGameRunner : IAsyncDisposable { private readonly GamePageViewModel _gameplay; private readonly GameEngine _engine; - private readonly CompositeGameView _gameView; - private readonly AvaloniaGamePageView _pageView; - private readonly SampleLayerFactory _layers; - private readonly SampleMediaViews _media; - private readonly AvaloniaEffectRuntime _effects; - private readonly AvaloniaAudioPlaybackHost _audio; - private readonly AvaloniaVideoPresenter _video; + private readonly SampleGamePresentation _presentation; private readonly PersistentSceneRestorer _restorer; private readonly Func _updateStateAsync; private readonly Func _runCompletedAsync; private readonly Func _resetPresentationAsync; private readonly Func _onUiAsync; - private readonly GameRunCoordinator _run = new(); + private Task _stateUpdate = Task.CompletedTask; private bool _prepared; private bool _disposed; private SampleGameRunner( - GamePageViewModel gameplay, - GameEngine engine, - CompositeGameView gameView, - AvaloniaGamePageView pageView, - SampleLayerFactory layers, - SampleMediaViews media, - AvaloniaEffectRuntime effects, - AvaloniaAudioPlaybackHost audio, - AvaloniaVideoPresenter video, - Func updateStateAsync, - Func runCompletedAsync, - Func resetPresentationAsync, - Func onUiAsync) + GamePageViewModel gameplay, GameEngine engine, SampleGamePresentation presentation, + Func updateStateAsync, Func runCompletedAsync, + Func resetPresentationAsync, Func onUiAsync) { _gameplay = gameplay; _engine = engine; - _gameView = gameView; - _pageView = pageView; - _layers = layers; - _media = media; - _effects = effects; - _audio = audio; - _video = video; - _restorer = new PersistentSceneRestorer( - effects, - pageView.ParticlePresenter, - pageView.AnimationPresenter, - pageView.LayerPresenter, - video); + _presentation = presentation; + var presenters = presentation.Presenters; + _restorer = new PersistentSceneRestorer(presentation.Effects, presenters.ParticlePresenter, + presenters.AnimationPresenter, presenters.LayerPresenter, presentation.Video); _updateStateAsync = updateStateAsync; _runCompletedAsync = runCompletedAsync; _resetPresentationAsync = resetPresentationAsync; _onUiAsync = onUiAsync; - _pageView.AdvanceRequested += OnAdvanceRequested; + _gameplay.AdvanceRequested += OnAdvanceRequested; + _engine.StateChanged += OnEngineStateChanged; } public static async Task CreateAsync( - GamePageViewModel gameplay, - GamePage page, - SampleGameResourceScope resources, - SampleSaveSession saveSession, - Func updateStateAsync, - Func runCompletedAsync, - Func resetPresentationAsync, - Func onUiAsync, - CancellationToken cancellationToken) + GamePageViewModel gameplay, GamePage page, SampleGameResourceScope resources, + SampleSaveSession saveSession, Func updateStateAsync, + Func runCompletedAsync, Func resetPresentationAsync, + Func onUiAsync, CancellationToken cancellationToken) { - SampleLayerFactory? layers = null; - AvaloniaGamePageView? pageView = null; - SampleMediaViews? media = null; - SkiaShaderEffectProgramResolver? programs = null; - AvaloniaEffectRuntime? effects = null; - AvaloniaAudioPlaybackHost? audio = null; - AvaloniaVideoPresenter? video = null; - CompositeGameView? gameView = null; + var presentation = await SampleGamePresentation.CreateAsync(gameplay, page, resources, cancellationToken); GameEngine? engine = null; try { - layers = new SampleLayerFactory(resources.PreloadedTextures); - pageView = new AvaloniaGamePageView(gameplay, page, layers); - media = new SampleMediaViews(gameplay); - programs = new SkiaShaderEffectProgramResolver( - new AssetManagerShaderEffectProgramSource(resources.Assets)); - var preload = await programs.PreloadAsync(resources.EffectPrograms, cancellationToken); - GameLog.Logger.Information( - "Preloaded {UsableEffectProgramCount}/{EffectProgramCount} effect programs in {ElapsedMilliseconds} ms", - preload.UsableProgramCount, - preload.RequestedProgramCount, - preload.Cache.TotalLoadTime.TotalMilliseconds); - effects = new AvaloniaEffectRuntime(gameplay, programs: programs); - audio = new AvaloniaAudioPlaybackHost(resources.Assets); - video = new AvaloniaVideoPresenter(resources.Assets, gameplay); - gameView = new GameRegistration().AddBuiltins( - pageView.DialoguePresenter, - pageView.LayerPresenter, - pageView.AnimationPresenter, - effects, - resources.Content.Gallery, - pageView.ParticlePresenter, - pageView.NvlPresenter, - audio, - video).BuildView(); var settings = new SettingsContainer(); settings.Set(resources.Content.Settings); settings.Set(saveSession.Settings); - var runtime = new GameRuntime( - null, - resources.Content.Graph.RootNodeId, - settings, - saveSession.Variables); - engine = new GameEngine( - resources.Content.Graph, - runtime, - gameView, - saveSession.Progress, - pageView.ChoicePresenter); - return new SampleGameRunner( - gameplay, - engine, - gameView, - pageView, - layers, - media, - effects, - audio, - video, - updateStateAsync, - runCompletedAsync, - resetPresentationAsync, - onUiAsync); + var runtime = new GameRuntime(null, resources.Content.Graph.RootNodeId, settings, saveSession.Variables); + engine = new GameEngine(resources.Content.Graph, runtime, presentation.View, + saveSession.Progress, presentation.Presenters.ChoicePresenter); + return new SampleGameRunner(gameplay, engine, presentation, + updateStateAsync, runCompletedAsync, resetPresentationAsync, onUiAsync); } - catch + catch (Exception error) { - engine?.Dispose(); - if (gameView is not null) await gameView.DisposeAsync(); - pageView?.Dispose(); - layers?.Dispose(); - media?.Dispose(); - effects?.Dispose(); - if (audio is not null) await audio.DisposeAsync(); - if (video is not null) await video.DisposeAsync(); - if (effects is null) - programs?.Dispose(); + List errors = [error]; + try { if (engine is not null) await engine.DisposeAsync(); } + catch (Exception cleanupError) { errors.Add(cleanupError); } + try { await presentation.DisposeAsync(); } + catch (Exception cleanupError) { errors.Add(cleanupError); } + if (errors.Count > 1) throw new AggregateException(errors); throw; } } @@ -174,131 +83,100 @@ public async Task PrepareAsync(GameSnapshot? snapshot, CancellationToken cancell _engine.RestoreFrom(snapshot); await _restorer.RestoreAsync(_engine.Runtime, cancellationToken); } - _prepared = true; - GameLog.Logger.Debug("Game runtime prepared; waiting for page transition before starting the engine flow"); } - public void StartPrepared() + public async Task StartPreparedAsync(CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed, this); - if (!_prepared) - throw new InvalidOperationException("No prepared game is available to start."); - + if (!_prepared) throw new InvalidOperationException("No prepared game is available to start."); _prepared = false; - _run.Start(cancellationToken => RunEngineAsync(cancellationToken)); _ = CompleteOpeningPresentationAsync(); - GameLog.Logger.Debug("Game engine run task created"); + try { await _engine.AdvanceAsync(cancellationToken); } + finally { _presentation.Presenters.CompleteInitialPresentation(); } } public async Task StopAsync() { - GameLog.Logger.Debug("Cancellation requested for the active engine flow"); - await _run.StopAsync(); + await _engine.StopAsync(); + _presentation.Presenters.CompleteInitialPresentation(); + await _stateUpdate; } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; - await StopAsync(); - await _updateStateAsync(false, null); - _pageView.AdvanceRequested -= OnAdvanceRequested; - _engine.Dispose(); - await _gameView.DisposeAsync(); - _pageView.Dispose(); - _layers.Dispose(); - _media.Dispose(); - _effects.Dispose(); - await _audio.DisposeAsync(); - await _video.DisposeAsync(); - _prepared = false; - GameLog.Logger.Debug("Resetting scene presentation before creating the next game engine"); - await _resetPresentationAsync(); - GameLog.Logger.Debug("Scene presentation reset completed"); + _gameplay.AdvanceRequested -= OnAdvanceRequested; + _engine.StateChanged -= OnEngineStateChanged; + _presentation.Presenters.CompleteInitialPresentation(); + List? errors = null; + await ReleaseAsync(() => _engine.DisposeAsync().AsTask()); + await ReleaseAsync(() => _stateUpdate); + await ReleaseAsync(() => _presentation.DisposeAsync().AsTask()); + await ReleaseAsync(() => _updateStateAsync(false, null)); + await ReleaseAsync(_resetPresentationAsync); + if (errors is not null) throw new AggregateException(errors); + + async Task ReleaseAsync(Func release) + { + try { await release(); } + catch (Exception error) { (errors ??= []).Add(error); } + } } private async Task CompleteOpeningPresentationAsync() { try { - await _pageView.InitialPresentationReady; - if (!_disposed) - await _onUiAsync(_gameplay.CompleteOpeningPresentation); - } - catch (Exception exception) - { - GameLog.Logger.Debug(exception, "Opening presentation did not reach an interactive boundary"); + await _presentation.Presenters.InitialPresentationReady; + await _onUiAsync(() => + { + if (!_disposed && _engine.State != GameEngineState.Stopped) + _gameplay.CompleteOpeningPresentation(); + }); } + catch (Exception error) { GameLog.Logger.Debug(error, "Opening presentation did not become interactive"); } } - private async Task RunEngineAsync(CancellationToken cancellationToken) + private void OnAdvanceRequested() => _ = AdvanceAsync(); + + private async Task AdvanceAsync() { - var finished = false; - try - { - await _updateStateAsync(true, "Playing"); - GameLog.Logger.Information("Game engine flow started"); - finished = !await _engine.AdvanceAsync(cancellationToken); - if (finished) - { - await _updateStateAsync(true, "Game flow completed."); - GameLog.Logger.Information("Game engine flow completed"); - } - else - { - GameLog.Logger.Debug("Game engine flow reached an interactive boundary"); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - finished = true; - _pageView.CompleteInitialPresentation(); - await _updateStateAsync(true, "Game flow was cancelled."); - GameLog.Logger.Information("Game engine flow cancelled"); - } - catch (Exception exception) - { - finished = true; - _pageView.FailInitialPresentation(exception); - await _updateStateAsync(true, $"Game flow failed: {exception.Message}"); - GameLog.Logger.Error(exception, "Game engine flow failed"); - } - finally - { - _pageView.CompleteInitialPresentation(); - if (finished) - await CompleteRunAsync(); - } + try { await _engine.AdvanceAsync(); } + catch (Exception error) { GameLog.Logger.Error(error, "Player advance failed"); } } - private void OnAdvanceRequested() => _ = AdvanceEngineAsync(); + private void OnEngineStateChanged(GameEngineStateChange change) => + _stateUpdate = ApplyStateAsync(_stateUpdate, change); - private async Task AdvanceEngineAsync() + private async Task ApplyStateAsync(Task previous, GameEngineStateChange change) { + await previous; + if (_disposed) return; try { - if (!await _engine.AdvanceAsync()) + var message = change.State switch + { + GameEngineState.Running => "Playing", + GameEngineState.Completed => "Game flow completed.", + GameEngineState.Faulted => $"Game flow failed: {change.Error?.Message}", + GameEngineState.Stopped => "Game flow was cancelled.", + _ => null + }; + GameLog.Logger.Information("Game engine state changed to {State}", change.State); + if (change.Error is { } error) { - await _updateStateAsync(true, "Game flow completed."); - GameLog.Logger.Information("Game engine flow completed"); - await CompleteRunAsync(); + _presentation.Presenters.FailInitialPresentation(error); + GameLog.Logger.Error(error, "Game engine flow failed"); + } + await _updateStateAsync(change.State == GameEngineState.Running, message); + if (change.State is GameEngineState.Completed or GameEngineState.Faulted) + { + _presentation.Presenters.CompleteInitialPresentation(); + await _runCompletedAsync(); } } - catch (Exception exception) - { - _pageView.FailInitialPresentation(exception); - await _updateStateAsync(true, $"Game flow failed: {exception.Message}"); - GameLog.Logger.Error(exception, "Player advance failed"); - await CompleteRunAsync(); - } - } - - private async Task CompleteRunAsync() - { - if (_disposed) return; - _pageView.CompleteInitialPresentation(); - await _updateStateAsync(false, null); - await _runCompletedAsync(); + catch (Exception error) { GameLog.Logger.Error(error, "Game state UI update failed"); } } } diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs index eda9bf7..5d5bccc 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleGameSessionService.cs @@ -275,7 +275,7 @@ public async Task BeginPreparedGameAsync(CancellationToken cancellationToken = d if (_runner is null) throw new InvalidOperationException("No prepared game is available to start."); - _runner.StartPrepared(); + await _runner.StartPreparedAsync(cancellationToken); } finally { _lifecycle.Release(); } } diff --git a/src/Samples/GalNet.Sample.Avalonia/Services/SampleSaveSession.cs b/src/Samples/GalNet.Sample.Avalonia/Services/SampleSaveSession.cs index 2f36f49..6a567a1 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Services/SampleSaveSession.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Services/SampleSaveSession.cs @@ -88,5 +88,7 @@ public void Dispose() if (_disposed) return; _disposed = true; (GalleryResources as IDisposable)?.Dispose(); + Variables.Dispose(); + _saves.Dispose(); } } diff --git a/src/Samples/GalNet.Sample.Avalonia/Views/MainWindow.axaml.cs b/src/Samples/GalNet.Sample.Avalonia/Views/MainWindow.axaml.cs index 190996f..1e747b0 100644 --- a/src/Samples/GalNet.Sample.Avalonia/Views/MainWindow.axaml.cs +++ b/src/Samples/GalNet.Sample.Avalonia/Views/MainWindow.axaml.cs @@ -48,7 +48,7 @@ private void OnKeyDown(object? sender, KeyEventArgs e) private async Task ConfirmAsync(SampleDebugPrompt prompt) => await MessageBox.ShowAsync(this, prompt.Message, prompt.Title, button: MessageBoxButton.OKCancel) == MessageBoxResult.OK; - private Task NotifyAsync(SampleDebugNotification notification) => + private Task NotifyAsync(SampleDebugNotification notification) => MessageBox.ShowAsync(this, notification.Message, notification.Title, button: MessageBoxButton.OK); private Task ShowDebugLogAsync() diff --git a/src/Shared/GalNet.Core/Entry/Entry.cs b/src/Shared/GalNet.Core/Entry/Entry.cs index 46df0df..f670d7f 100644 --- a/src/Shared/GalNet.Core/Entry/Entry.cs +++ b/src/Shared/GalNet.Core/Entry/Entry.cs @@ -170,7 +170,7 @@ public EntryDefinition( public EntryKind Kind { get; } public DynamicParameterTable? DynamicParameters { get; } - private static IReadOnlyDictionary Freeze(IReadOnlyDictionary values) + private static ReadOnlyDictionary Freeze(IReadOnlyDictionary values) where TKey : notnull => new ReadOnlyDictionary(values.ToDictionary(pair => pair.Key, pair => pair.Value)); } diff --git a/src/Shared/GalNet.Core/Entry/EntryModule.cs b/src/Shared/GalNet.Core/Entry/EntryModule.cs index fd7d670..94d5990 100644 --- a/src/Shared/GalNet.Core/Entry/EntryModule.cs +++ b/src/Shared/GalNet.Core/Entry/EntryModule.cs @@ -152,7 +152,7 @@ protected EntryModuleBase( public IReadOnlyDictionary PrimitiveEntries { get; } public IReadOnlyDictionary CompositeEntries { get; } - private static IReadOnlyDictionary Freeze(IEnumerable entries, string argumentName) + private static ReadOnlyDictionary Freeze(IEnumerable entries, string argumentName) where TEntry : EntryBase { var result = new Dictionary(StringComparer.Ordinal); diff --git a/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs b/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs index ae75197..0623890 100644 --- a/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs +++ b/src/Shared/GalNet.Core/Entry/TargetProfileEntryCatalog.cs @@ -6,7 +6,7 @@ namespace GalNet.Core.Entry; /// public sealed class TargetProfileEntryCatalog : IEntryCatalog { - private readonly IReadOnlyDictionary _definitions; + private readonly Dictionary _definitions; /// /// Builds one target profile from optional authoring-module contributions. diff --git a/src/Shared/GalNet.Core/Gallery/GalleryTypeCatalog.cs b/src/Shared/GalNet.Core/Gallery/GalleryTypeCatalog.cs index 7e63211..6cfd867 100644 --- a/src/Shared/GalNet.Core/Gallery/GalleryTypeCatalog.cs +++ b/src/Shared/GalNet.Core/Gallery/GalleryTypeCatalog.cs @@ -22,7 +22,7 @@ public IGalleryTypeCatalog Build(IResourceTypeCatalog resourceTypes) } public sealed class GalleryTypeCatalog(IReadOnlyList types) : IGalleryTypeCatalog { - private readonly IReadOnlyDictionary _byId = types.ToDictionary(item => item.TypeId, StringComparer.Ordinal); + private readonly Dictionary _byId = types.ToDictionary(item => item.TypeId, StringComparer.Ordinal); public IReadOnlyList Types { get; } = new ReadOnlyCollection(types.ToList()); public GalleryTypeRegistration Get(string typeId) => TryGet(typeId, out var result) ? result : throw new KeyNotFoundException($"Gallery type '{typeId}' is not registered."); public bool TryGet(string typeId, out GalleryTypeRegistration registration) => _byId.TryGetValue(GalleryCatalog.NormalizeTypeId(typeId), out registration!); diff --git a/src/Shared/GalNet.Core/Runtime/SnapshotCopy.cs b/src/Shared/GalNet.Core/Runtime/SnapshotCopy.cs new file mode 100644 index 0000000..a68e130 --- /dev/null +++ b/src/Shared/GalNet.Core/Runtime/SnapshotCopy.cs @@ -0,0 +1,87 @@ +using GalNet.Core.Scene; +using GalNet.Core.Variable; +using GalVariable = GalNet.Core.Variable.Variable; + +namespace GalNet.Core.Runtime; + +/// Copies persistent state without sharing mutable objects or playback identities. +public static class SnapshotCopy +{ + public static GameSnapshot Clone(GameSnapshot snapshot) => new() + { + Version = snapshot.Version, + NodeId = snapshot.NodeId, + EntryIndex = snapshot.EntryIndex, + Variables = snapshot.Variables.ToDictionary(pair => pair.Key, pair => CloneVariable(pair.Value), StringComparer.Ordinal), + SceneState = CloneSceneState(snapshot.SceneState) + }; + + public static GalVariable CloneVariable(GalVariable variable) + { + var copy = new GalVariable { Uid = variable.Uid, Name = variable.Name }; + switch (variable.Type) + { + case VariableType.Bool: copy.SetValue(variable.AsBool()); break; + case VariableType.Int: copy.SetValue(variable.AsInt()); break; + case VariableType.Float: copy.SetValue(variable.AsFloat()); break; + default: copy.SetValue(variable.AsString()); break; + } + return copy; + } + + public static SceneState CloneSceneState(SceneState source) => new() + { + Layers = source.Layers.Select(layer => new Layer + { + Id = layer.Id, + AssetId = layer.AssetId, + IsVideo = layer.IsVideo, + Flipbook = layer.Flipbook?.Clone(), + Color = layer.Color, + Transform = layer.Transform.Clone(), + Z = layer.Z, + DisplayMode = layer.DisplayMode, + Visible = layer.Visible, + Opacity = layer.Opacity + }).ToList(), + ActiveEffects = source.ActiveEffects.Select(effect => new ActiveEffectState + { + Id = effect.Id, + ProgramResource = effect.ProgramResource, + InstanceId = effect.InstanceId, + TargetHandleId = effect.TargetHandleId, + Order = effect.Order, + Parameters = effect.Parameters, + AnimationValues = effect.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + }).ToList(), + ActiveParticleEmitters = source.ActiveParticleEmitters.Select(emitter => new ActiveParticleEmitterState + { + InstanceId = emitter.InstanceId, + Definition = CloneParticleDefinition(emitter.Definition), + Z = emitter.Z, + AnimationValues = emitter.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + }).ToList(), + ActiveAnimations = source.ActiveAnimations.Select(animation => new ActiveAnimationState + { + EntryType = animation.EntryType, + PlaybackHandleId = animation.PlaybackHandleId, + LoopMode = animation.LoopMode, + Parameters = animation.Parameters.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + }).ToList() + }; + + private static ParticleEmitterDefinition CloneParticleDefinition(ParticleEmitterDefinition definition) => definition with + { + // All other modules and curve keys are immutable records. Lifetime's + // collection interfaces can wrap mutable author-owned arrays or lists. + Lifetime = definition.Lifetime is { } lifetime ? lifetime with + { + Size = lifetime.Size?.ToArray(), + Opacity = lifetime.Opacity?.ToArray(), + Velocity = lifetime.Velocity?.ToArray(), + RotationDegrees = lifetime.RotationDegrees?.ToArray(), + Color = lifetime.Color?.ToArray() + } : null + }; + +} diff --git a/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveImplementation.cs b/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveImplementation.cs new file mode 100644 index 0000000..12b5a96 --- /dev/null +++ b/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveImplementation.cs @@ -0,0 +1,21 @@ +using GalNet.Core.Primitives; + +namespace GalNet.Presentation.Abstractions.Execution; + +/// One instruction factory, independent of its parameter schema. +public sealed class PrimitiveImplementation +{ + private readonly Func _factory; + + public PrimitiveImplementation(string type, Func factory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(type); + ArgumentNullException.ThrowIfNull(factory); + Type = type; + _factory = factory; + } + + public string Type { get; } + public PrimitiveInstance CreateInstance(PrimitiveCreateContext context) => + _factory(context) ?? throw new InvalidOperationException($"Instruction '{Type}' returned no instance."); +} diff --git a/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveModule.cs b/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveModule.cs new file mode 100644 index 0000000..dee59f1 --- /dev/null +++ b/src/Shared/GalNet.Presentation.Abstractions/Execution/PrimitiveModule.cs @@ -0,0 +1,31 @@ +using System.Collections.ObjectModel; + +namespace GalNet.Presentation.Abstractions.Execution; + +/// A game-scoped set of instruction implementations and their shared state. +public interface IPrimitiveModule : IDisposable, IAsyncDisposable +{ + IReadOnlyDictionary Instructions { get; } +} + +public class PrimitiveModule : IPrimitiveModule +{ + public PrimitiveModule(IEnumerable instructions) + { + ArgumentNullException.ThrowIfNull(instructions); + var entries = new Dictionary(StringComparer.Ordinal); + foreach (var instruction in instructions) + if (!entries.TryAdd(instruction.Type, instruction)) + throw new ArgumentException($"Instruction '{instruction.Type}' is registered more than once.", nameof(instructions)); + Instructions = new ReadOnlyDictionary(entries); + } + + public IReadOnlyDictionary Instructions { get; } + public virtual void Dispose() => GC.SuppressFinalize(this); + public virtual ValueTask DisposeAsync() + { + Dispose(); + GC.SuppressFinalize(this); + return ValueTask.CompletedTask; + } +} diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/GameRegistration.cs b/src/Shared/GalNet.Presentation.Abstractions/View/GameRegistration.cs new file mode 100644 index 0000000..87cbe6a --- /dev/null +++ b/src/Shared/GalNet.Presentation.Abstractions/View/GameRegistration.cs @@ -0,0 +1,67 @@ +using GalNet.Core.Entry; +using GalNet.Presentation.Abstractions.Execution; + +namespace GalNet.Presentation.Abstractions.View; + +/// Explicit composition of data contracts and game-scoped execution modules. +public sealed class GameRegistration : IDisposable, IAsyncDisposable +{ + private readonly List _schemas = []; + private readonly List _implementations = []; + private bool _frozen; + + public GameRegistration RegisterSchema(IEntryModule schema) + { + ObjectDisposedException.ThrowIf(_frozen, this); + ArgumentNullException.ThrowIfNull(schema); + _schemas.Add(schema); + return this; + } + + public GameRegistration RegisterImplementation(IPrimitiveModule implementation) + { + ObjectDisposedException.ThrowIf(_frozen, this); + ArgumentNullException.ThrowIfNull(implementation); + _implementations.Add(implementation); + return this; + } + + public GameRegistration RegisterInstruction(string type, Func factory) => + RegisterImplementation(new PrimitiveModule([new PrimitiveImplementation(type, factory)])); + + public TargetProfileEntryCatalog BuildCatalog() => new(_schemas); + + /// Freezes registration and transfers module ownership to the view. + public CompositeGameView BuildView() + { + ObjectDisposedException.ThrowIf(_frozen, this); + var view = new CompositeGameView(BuildCatalog(), _implementations); + _frozen = true; + _implementations.Clear(); + return view; + } + + public void Dispose() + { + if (_frozen) return; + _frozen = true; + List? errors = null; + foreach (var module in _implementations.AsEnumerable().Reverse().Distinct()) + try { module.Dispose(); } + catch (Exception error) { (errors ??= []).Add(error); } + _implementations.Clear(); + if (errors is not null) throw new AggregateException(errors); + } + + public async ValueTask DisposeAsync() + { + if (_frozen) return; + _frozen = true; + List? errors = null; + foreach (var module in _implementations.AsEnumerable().Reverse().Distinct()) + try { await module.DisposeAsync().ConfigureAwait(false); } + catch (Exception error) { (errors ??= []).Add(error); } + _implementations.Clear(); + if (errors is not null) throw new AggregateException(errors); + } +} diff --git a/src/Shared/GalNet.Presentation.Abstractions/View/NullGameView.cs b/src/Shared/GalNet.Presentation.Abstractions/View/NullGameView.cs new file mode 100644 index 0000000..1ce9c63 --- /dev/null +++ b/src/Shared/GalNet.Presentation.Abstractions/View/NullGameView.cs @@ -0,0 +1,20 @@ +using GalNet.Core.Entry; +using GalNet.Core.Primitives; +using GalNet.Core.Runtime; + +namespace GalNet.Presentation.Abstractions.View; + +/// An empty view with no registered contracts or executable instructions. +public sealed class NullGameView : IGameView +{ + public IReadOnlyCollection Primitives => []; + public bool CanExecute(string primitiveType) => false; + public bool TryGetEntry(string primitiveType, out EntryDefinition? entry) + { + entry = null; + return false; + } + public PrimitiveInstance Dispatch(PrimitiveEntry entry, IGameRuntime runtime, CancellationToken cancellationToken) => + throw new InvalidOperationException($"Instruction '{entry.Type}' has no registered implementation."); + public void Dispose() => GC.SuppressFinalize(this); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs b/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs index c6a713c..e315940 100644 --- a/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs +++ b/src/Shared/GalNet.Primitives.Builtins/AnimationPlanPrimitiveInstance.cs @@ -7,7 +7,7 @@ namespace GalNet.Primitives.Builtins; -public sealed class AnimationPlanPrimitiveInstance : PrimitiveInstance +public sealed class AnimationPlanPrimitiveInstance : PrimitiveInstance, IDisposable { private readonly object _gate = new(); private readonly IGameRuntime _runtime; @@ -21,6 +21,7 @@ public sealed class AnimationPlanPrimitiveInstance : PrimitiveInstance private readonly bool[] _notifiedEvents; private readonly CancellationTokenSource _presentationCancellation = new(); private readonly SemaphoreSlim _notificationGate = new(1, 1); + private int _resourcesDisposed; private bool _skipRequested; private bool _skippable; private AnimationPlaybackInstance? _playback; @@ -69,7 +70,7 @@ protected override void OnDispatch() _runtime.SceneInstances.TryGet(_plan.PlaybackHandleId, out _playback!); if (_animationPresenter is null) { - _presentationCancellation.Dispose(); + Dispose(); TryComplete(); return; } @@ -112,12 +113,17 @@ private async Task ObservePresentationAsync() { if (_playback is not null && _runtime.SceneInstances.TryGet(_plan.PlaybackHandleId, out var current) && ReferenceEquals(current, _playback)) BuiltinRuntimeActions.StopAnimationState(_runtime, _plan.PlaybackHandleId); + bool skipRequested; lock (_gate) { _skippable = false; - _presentationCancellation.Dispose(); + skipRequested = _skipRequested; + } + if (!skipRequested) + { + Dispose(); + TryComplete(); } - if (!_skipRequested) TryComplete(); } } @@ -131,7 +137,11 @@ private async Task CompleteSkipAsync() { try { await NotifyRemainingEventsAsync(_scopeCancellation).ConfigureAwait(false); } catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) { } - finally { TryComplete(); } + finally + { + Dispose(); + TryComplete(); + } } // The presenter supplies its own elapsed frame count before sampling tracks. @@ -192,5 +202,24 @@ await BuiltinRuntimeActions.NotifyPlanEventAsync( finally { _notificationGate.Release(); } } + public void Dispose() + { + lock (_gate) + { + _skippable = false; + _skipRequested = true; + } + + lock (_gate) + { + if (_resourcesDisposed != 0) return; + _resourcesDisposed = 1; + _presentationCancellation.Cancel(); + _presentationCancellation.Dispose(); + _notificationGate.Dispose(); + } + GC.SuppressFinalize(this); + } + private sealed record IndexedPlanEvent(int Index, AnimationPlanEventDefinition Event); } diff --git a/src/Shared/GalNet.Primitives.Builtins/Arguments.cs b/src/Shared/GalNet.Primitives.Builtins/Arguments.cs new file mode 100644 index 0000000..ec11bfe --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Arguments.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GalNet.Core.Primitives; + +namespace GalNet.Primitives.Builtins; + +internal static class Arguments +{ + internal static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static string String(PrimitiveCreateContext context, string name, string fallback = "") => + context.Arguments.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? fallback + : fallback; + + public static float Float(PrimitiveCreateContext context, string name, float fallback = 0) => + context.Arguments.TryGetProperty(name, out var value) && value.TryGetSingle(out var result) ? result : fallback; + + public static bool TryFloat(PrimitiveCreateContext context, string name, out float result) + { + if (context.Arguments.TryGetProperty(name, out var value) && value.TryGetSingle(out result)) + return true; + result = default; + return false; + } + + public static int Int(PrimitiveCreateContext context, string name, int fallback = 0) => + context.Arguments.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : fallback; + + public static bool Bool(PrimitiveCreateContext context, string name, bool fallback = false) + { + if (!context.Arguments.TryGetProperty(name, out var value)) + return fallback; + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String => bool.TryParse(value.GetString(), out var result) ? result : fallback, + _ => fallback + }; + } + + public static TEnum Enum(PrimitiveCreateContext context, string name, TEnum fallback) + where TEnum : struct, Enum => + System.Enum.TryParse(String(context, name), true, out var result) ? result : fallback; + + public static string RawJson(PrimitiveCreateContext context, string name, string fallback = "{}") => + context.Arguments.TryGetProperty(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) + ? value.GetRawText() + : fallback; + + public static T? Json(PrimitiveCreateContext context, string name) + { + if (!context.Arguments.TryGetProperty(name, out var value) || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + return default; + if (value.ValueKind == JsonValueKind.String) + { + var json = value.GetString(); + return string.IsNullOrWhiteSpace(json) ? default : JsonSerializer.Deserialize(json, JsonOptions); + } + return value.Deserialize(JsonOptions); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinArgumentValidation.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinArgumentValidation.cs new file mode 100644 index 0000000..f8e7289 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/BuiltinArgumentValidation.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using GalNet.Core.Entry; +using GalNet.Core.Scene; + +namespace GalNet.Primitives.Builtins; + +/// Portable semantic checks shared by compilation and every implementation of built-in contracts. +internal static class BuiltinArgumentValidation +{ + private static readonly Lazy EventCatalog = new(BuiltinSchemas.CreateCatalog); + private static readonly HashSet PlanEventTypes = new(StringComparer.Ordinal) + { + ShowLayerEntry.TypeId, ShowColorLayerEntry.TypeId, HideLayerEntry.TypeId, + MoveLayerEntry.TypeId, ReplaceLayerEntry.TypeId, ApplyEffectEntry.TypeId, + StopEffectEntry.TypeId, PlayParticleEmitterEntry.TypeId, + StopParticleEmitterEntry.TypeId, BurstParticlesEntry.TypeId + }; + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + public static void Validate(string type, JsonElement arguments) + { + switch (type) + { + case PlayVideoEntry.TypeId: + RequireText(type, arguments, "asset", "handleId"); + var points = arguments.GetProperty("skipPoints").Deserialize(JsonOptions) + ?? throw new InvalidDataException("video.play skipPoints must be an array."); + if (points.Any(point => !double.IsFinite(point) || point < 0) || + points.Zip(points.Skip(1), (left, right) => right > left).Any(valid => !valid)) + throw new InvalidDataException("video.play skipPoints must be finite, non-negative and strictly increasing seconds."); + break; + case PlayAnimationPlanEntry.TypeId: + if (!arguments.TryGetProperty("plan", out var value) || value.ValueKind != JsonValueKind.Object) + throw new InvalidDataException("animation.play requires a plan object."); + ValidatePlan(value.Deserialize(JsonOptions)!); + break; + case PlayParticleEmitterEntry.TypeId: + case BurstParticlesEntry.TypeId: + if (type == PlayParticleEmitterEntry.TypeId) RequireText(type, arguments, "instanceId"); + if (type == BurstParticlesEntry.TypeId && arguments.GetProperty("count").GetInt32() <= 0) + throw new InvalidDataException("particle.burst requires a positive 'count'."); + var definition = ParticleEmitterDefinition.FromJson(arguments.GetProperty("parameters").GetRawText()); + if (string.IsNullOrWhiteSpace(definition.ParticleTexture)) + throw new InvalidDataException($"{type} requires 'parameters.particleTexture'."); + break; + } + foreach (var name in new[] { "duration", "fadeIn", "fadeOut" }) + if (arguments.TryGetProperty(name, out var duration) && + (!duration.TryGetDouble(out var seconds) || !double.IsFinite(seconds) || seconds < 0)) + throw new InvalidDataException($"{type}.{name} must be finite and non-negative."); + } + + private static void RequireText(string type, JsonElement arguments, params string[] names) + { + foreach (var name in names) + if (!arguments.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(value.GetString())) + throw new InvalidDataException($"{type} requires '{name}'."); + } + + private static void ValidatePlan(AnimationPlanDefinition plan) + { + if (plan.FrameRate is < 1 or > 240 || plan.DurationFrames < 0) + throw new InvalidDataException("Animation plan frameRate must be 1..240 and durationFrames must be non-negative."); + if (plan.Tracks is null || plan.Events is null || !Enum.IsDefined(plan.LoopMode)) + throw new InvalidDataException("Animation plan requires track/event arrays and a valid loop mode."); + var targets = new HashSet<(string HandleId, string Property)>(); + foreach (var track in plan.Tracks) + { + if (track is null || track.Keys is null || !Enum.IsDefined(track.BlendMode) || + string.IsNullOrWhiteSpace(track.HandleId) || string.IsNullOrWhiteSpace(track.Property) || + !targets.Add((track.HandleId, track.Property)) || track.Keys.Count == 0 || track.Keys[0] is null || track.Keys[0].Frame != 0) + throw new InvalidDataException("Animation tracks require unique targets and a key at frame zero."); + var previous = -1; + foreach (var key in track.Keys) + { + if (key is null || !Enum.IsDefined(key.InterpolationToNext) || key.Frame <= previous || key.Frame > plan.DurationFrames || + !float.IsFinite(key.Value) || !float.IsFinite(key.InTangent) || !float.IsFinite(key.OutTangent)) + throw new InvalidDataException("Animation keys must be finite, strictly ordered and inside the plan duration."); + previous = key.Frame; + } + } + foreach (var item in plan.Events) + { + if (item is null || item.Parameters is null || item.Frame < 0 || item.Frame > plan.DurationFrames) + throw new InvalidDataException("Animation events must be inside the plan duration."); + if (!PlanEventTypes.Contains(item.Type)) + throw new InvalidDataException($"Unsupported animation plan event '{item.Type}'."); + PrimitiveArgumentHelper.Normalize(EventCatalog.Value.Get(item.Type).DynamicParameters!, + JsonSerializer.SerializeToElement(item.Parameters)); + } + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinPresentationReplay.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinPresentationReplay.cs index d2c4b3f..66125cd 100644 --- a/src/Shared/GalNet.Primitives.Builtins/BuiltinPresentationReplay.cs +++ b/src/Shared/GalNet.Primitives.Builtins/BuiltinPresentationReplay.cs @@ -18,8 +18,8 @@ public static async Task ReplayPersistentSceneObjectsAsync( IParticlePresenter? particlePresenter, IAnimationPresenter? animationPresenter, ILayerPresenter? layerPresenter, - CancellationToken cancellationToken = default, - IVideoPresenter? videoPresenter = null) + IVideoPresenter? videoPresenter = null, + CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(runtime); var sceneState = runtime.SceneState; diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinRegistration.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinRegistration.cs new file mode 100644 index 0000000..34359b1 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/BuiltinRegistration.cs @@ -0,0 +1,65 @@ +using GalNet.Core.Gallery; +using GalNet.Presentation.Abstractions.Audio; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +/// Independent schema and implementation registration for one game scope. +public static class BuiltinRegistration +{ + public static GameRegistration AddBuiltinSchemas(this GameRegistration registration) => + registration.RegisterSchema(BuiltinSchemas.CreateModule()); + + public static GameRegistration AddBuiltins( + this GameRegistration registration, + IDialoguePresenter? dialoguePresenter = null, + ILayerPresenter? layerPresenter = null, + IAnimationPresenter? animationPresenter = null, + IEffectPresenter? effectPresenter = null, + GalleryCatalog? gallery = null, + IParticlePresenter? particlePresenter = null, + INvlPresenter? nvlPresenter = null, + IAudioPlaybackHost? audioHost = null, + IVideoPresenter? videoPresenter = null) => registration.AddBuiltinSchemas().AddBuiltinImplementations( + dialoguePresenter, layerPresenter, animationPresenter, effectPresenter, gallery, + particlePresenter, nvlPresenter, audioHost, videoPresenter); + + /// Registers logical implementations. Optional presenters select state-only/headless operation. + public static GameRegistration AddBuiltinImplementations( + this GameRegistration registration, + IDialoguePresenter? dialoguePresenter = null, + ILayerPresenter? layerPresenter = null, + IAnimationPresenter? animationPresenter = null, + IEffectPresenter? effectPresenter = null, + GalleryCatalog? gallery = null, + IParticlePresenter? particlePresenter = null, + INvlPresenter? nvlPresenter = null, + IAudioPlaybackHost? audioHost = null, + IVideoPresenter? videoPresenter = null) => registration + .AddBuiltinDialogue(dialoguePresenter) + .AddBuiltinNvl(nvlPresenter) + .AddBuiltinLayers(layerPresenter) + .AddBuiltinAnimations(animationPresenter, layerPresenter, effectPresenter, particlePresenter) + .AddBuiltinAudio(audioHost) + .AddBuiltinVideo(videoPresenter) + .AddBuiltinEffects(effectPresenter) + .AddBuiltinParticles(particlePresenter) + .AddBuiltinFlow() + .AddBuiltinVariables() + .AddBuiltinGallery(gallery); + + public static GameRegistration AddBuiltinVariables(this GameRegistration registration) => registration.RegisterImplementation(new BuiltinVariableModule()); + public static GameRegistration AddBuiltinFlow(this GameRegistration registration) => registration.RegisterImplementation(new BuiltinFlowModule()); + public static GameRegistration AddBuiltinAudio(this GameRegistration registration, IAudioPlaybackHost? host = null) => registration.RegisterImplementation(new BuiltinAudioModule(host)); + public static GameRegistration AddBuiltinGallery(this GameRegistration registration, GalleryCatalog? gallery = null) => registration.RegisterImplementation(new BuiltinGalleryModule(gallery)); + public static GameRegistration AddBuiltinDialogue(this GameRegistration registration, IDialoguePresenter? presenter = null) => registration.RegisterImplementation(new BuiltinDialogueModule(presenter)); + public static GameRegistration AddBuiltinNvl(this GameRegistration registration, INvlPresenter? presenter = null) => registration.RegisterImplementation(new BuiltinNvlModule(presenter)); + public static GameRegistration AddBuiltinLayers(this GameRegistration registration, ILayerPresenter? presenter = null) => registration.RegisterImplementation(new BuiltinLayerModule(presenter)); + public static GameRegistration AddBuiltinEffects(this GameRegistration registration, IEffectPresenter? presenter = null) => registration.RegisterImplementation(new BuiltinEffectModule(presenter)); + public static GameRegistration AddBuiltinParticles(this GameRegistration registration, IParticlePresenter? presenter = null) => registration.RegisterImplementation(new BuiltinParticleModule(presenter)); + public static GameRegistration AddBuiltinVideo(this GameRegistration registration, IVideoPresenter? presenter = null) => registration.RegisterImplementation(new BuiltinVideoModule(presenter)); + public static GameRegistration AddBuiltinAnimations(this GameRegistration registration, + IAnimationPresenter? animations = null, ILayerPresenter? layers = null, + IEffectPresenter? effects = null, IParticlePresenter? particles = null) => + registration.RegisterImplementation(new BuiltinAnimationModule(animations, layers, effects, particles)); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/BuiltinSchemas.cs b/src/Shared/GalNet.Primitives.Builtins/BuiltinSchemas.cs new file mode 100644 index 0000000..c207099 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/BuiltinSchemas.cs @@ -0,0 +1,63 @@ +using GalNet.Core.Entry; + +namespace GalNet.Primitives.Builtins; + +/// Built-in contracts and compile-time expansions; no runtime service is created. +public static class BuiltinSchemas +{ + public static IEntryModule CreateModule() => new DefaultEntryModule("builtins", + [ + Schema(TextEntry.ParameterTypes, TextEntry.DefaultValues), + Schema(ShowDialogueEntry.ParameterTypes, null), + Schema(HideDialogueEntry.ParameterTypes, null), + Schema(ApplyEffectEntry.ParameterTypes, ApplyEffectEntry.DefaultValues), + Schema(StopEffectEntry.ParameterTypes, null), + Schema(WaitEntry.ParameterTypes, WaitEntry.DefaultValues), + Schema(SetVariableEntry.ParameterTypes, null), + Schema(UnlockGalleryEntry.ParameterTypes, null), + Schema(ShowLayerEntry.ParameterTypes, ShowLayerEntry.DefaultValues, ShowLayerEntry.ParameterOptions), + Schema(ShowColorLayerEntry.ParameterTypes, ShowColorLayerEntry.DefaultValues), + Schema(HideLayerEntry.ParameterTypes, null), + Schema(MoveLayerEntry.ParameterTypes, MoveLayerEntry.DefaultValues), + Schema(ReplaceLayerEntry.ParameterTypes, null), + Schema(AnimateEntry.ParameterTypes, AnimateEntry.DefaultValues, AnimateEntry.ParameterOptions), + Schema(PlayAnimationPlanEntry.ParameterTypes, null), + Schema(StopAnimationEntry.ParameterTypes, StopAnimationEntry.DefaultValues, StopAnimationEntry.ParameterOptions), + Schema(PlayAudioEntry.ParameterTypes, PlayAudioEntry.DefaultValues, PlayAudioEntry.ParameterOptions), + Schema(StopAudioEntry.ParameterTypes, StopAudioEntry.DefaultValues, StopAudioEntry.ParameterOptions), + Schema(PauseAudioEntry.ParameterTypes, PauseAudioEntry.DefaultValues), + Schema(ResumeAudioEntry.ParameterTypes, ResumeAudioEntry.DefaultValues), + Schema(NextAudioEntry.ParameterTypes, NextAudioEntry.DefaultValues), + Schema(ClearAudioEntry.ParameterTypes, ClearAudioEntry.DefaultValues), + Schema(EnqueueAudioEntry.ParameterTypes, EnqueueAudioEntry.DefaultValues, EnqueueAudioEntry.ParameterOptions), + Schema(ApplyAudioSEffectEntry.ParameterTypes, ApplyAudioSEffectEntry.DefaultValues), + Schema(StopAudioSEffectEntry.ParameterTypes, StopAudioSEffectEntry.DefaultValues), + Schema(PlayVideoEntry.ParameterTypes, PlayVideoEntry.DefaultValues, PlayVideoEntry.ParameterOptions), + Schema(StopVideoEntry.ParameterTypes, StopVideoEntry.DefaultValues), + Schema(ShowNvlEntry.ParameterTypes, null), + Schema(HideNvlEntry.ParameterTypes, null), + Schema(ClearNvlEntry.ParameterTypes, null), + Schema(AppendNvlEntry.ParameterTypes, null), + Schema(PlayParticleEmitterEntry.ParameterTypes, PlayParticleEmitterEntry.DefaultValues), + Schema(StopParticleEmitterEntry.ParameterTypes, null), + Schema(BurstParticlesEntry.ParameterTypes, BurstParticlesEntry.DefaultValues) + ], + BuiltinTransitionEntries.Definitions.Concat( + [ + new DefaultCompositeEntryBase(TextVoiceEntry.TypeId, EntrySchema.DynamicParameters(TextVoiceEntry.ParameterTypes, TextVoiceEntry.DefaultValues), static () => new TextVoiceEntry()), + new DefaultCompositeEntryBase(AppendVoiceNvlEntry.TypeId, EntrySchema.DynamicParameters(AppendVoiceNvlEntry.ParameterTypes, AppendVoiceNvlEntry.DefaultValues), static () => new AppendVoiceNvlEntry()) + ])); + + public static TargetProfileEntryCatalog CreateCatalog() => new([CreateModule()]); + + private static DefaultPrimitiveEntryBase Schema( + IReadOnlyDictionary parameters, + IReadOnlyDictionary? defaults = null, + IReadOnlyDictionary>? options = null) + where TEntry : PrimitiveEntry, new() + { + var type = new TEntry().Type; + return new DefaultPrimitiveEntryBase(type, EntrySchema.DynamicParameters(parameters, defaults, options, + arguments => BuiltinArgumentValidation.Validate(type, arguments))); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Entry/BuiltinTransitionEntries.cs b/src/Shared/GalNet.Primitives.Builtins/Entry/BuiltinTransitionEntries.cs index 897c278..f31f0c2 100644 --- a/src/Shared/GalNet.Primitives.Builtins/Entry/BuiltinTransitionEntries.cs +++ b/src/Shared/GalNet.Primitives.Builtins/Entry/BuiltinTransitionEntries.cs @@ -18,7 +18,7 @@ internal static class BuiltinTransitionEntries Define(ColorFadeTransitionEntry.TypeId, () => new ColorFadeTransitionEntry(), ColorFadeTransitionEntry.ParameterTypes, ColorFadeTransitionEntry.DefaultValues, ColorFadeTransitionEntry.ParameterOptions) ]; - private static CompositeEntryBase Define( + private static DefaultCompositeEntryBase Define( string type, Func factory, IReadOnlyDictionary parameters, diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAnimationModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAnimationModule.cs new file mode 100644 index 0000000..f30cb35 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAnimationModule.cs @@ -0,0 +1,45 @@ +using GalNet.Core.Primitives; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinAnimationModule : PrimitiveModule +{ + public BuiltinAnimationModule( + IAnimationPresenter? animationPresenter = null, + ILayerPresenter? layerPresenter = null, + IEffectPresenter? effectPresenter = null, + IParticlePresenter? particlePresenter = null) : base( + [ + BuiltinInstructionFactory.Create(context => new AnimationPrimitiveInstance( + context.Runtime, + animationPresenter, + BuiltinRuntimeActions.CreateAnimationRequest(context), + context.BatchId, + context.ScopeCancellation)), + BuiltinInstructionFactory.Create(context => new AnimationPlanPrimitiveInstance( + context.Runtime, + animationPresenter, + layerPresenter, + effectPresenter, + particlePresenter, + BuiltinRuntimeActions.CreateAnimationPlan(context), + context.BatchId, + context.ScopeCancellation)), + BuiltinInstructionFactory.Create(context => new ImmediatePrimitiveInstance(() => + { + var playbackHandleId = Arguments.String(context, "playbackHandleId"); + var mode = Arguments.Enum(context, "mode", AnimationStopMode.AfterIteration); + if (context.Runtime.SceneInstances.TryGet(playbackHandleId, out var playback)) + playback.RequestStop(mode); + if (mode == AnimationStopMode.CompleteImmediately) + { + animationPresenter?.CompleteAnimationImmediately(playbackHandleId); + BuiltinRuntimeActions.StopAnimationState(context.Runtime, playbackHandleId); + } + }, batchId: context.BatchId)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAudioModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAudioModule.cs new file mode 100644 index 0000000..01587a7 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinAudioModule.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using GalNet.Core.Audio; +using GalNet.Core.Primitives; +using GalNet.Core.Settings; +using GalNet.Presentation.Abstractions.Audio; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Runtime.Audio; +using GalNet.Runtime.Logging; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinAudioModule : PrimitiveModule +{ + private readonly AudioRuntime _runtime; + private readonly AudioScope _scope; + + public BuiltinAudioModule(IAudioPlaybackHost? host = null) : this(new AudioRuntime(host)) { } + + private BuiltinAudioModule(AudioRuntime runtime) : this(runtime, new AudioScope(runtime)) { } + + private BuiltinAudioModule(AudioRuntime runtime, AudioScope scope) : base(CreatePrimitives(runtime, scope)) + { + _runtime = runtime; + _scope = scope; + _runtime.Diagnostic += OnDiagnostic; + } + + public override void Dispose() + { + _scope.Dispose(); + _runtime.Dispose(); + _runtime.Diagnostic -= OnDiagnostic; + base.Dispose(); + } + + public override async ValueTask DisposeAsync() + { + _scope.Dispose(); + try { await _runtime.DisposeAsync().ConfigureAwait(false); } + finally + { + _runtime.Diagnostic -= OnDiagnostic; + await base.DisposeAsync().ConfigureAwait(false); + } + } + + private static List Tracks(PrimitiveCreateContext context) => context.Runtime.Settings.Get().AudioTracks; + + private static IReadOnlyList CreatePrimitives(AudioRuntime runtime, AudioScope scope) => + [ + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Play(Tracks(context), PlayRequest(context, Arguments.Enum(context, "placement", AudioPlacement.Enqueue))))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Stop(Tracks(context), new AudioStopRequest( + Arguments.String(context, "trackId"), Arguments.Bool(context, "clearQueue", true), + Arguments.Float(context, "fadeOut"))))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Pause(Tracks(context), Arguments.String(context, "trackId")))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Resume(Tracks(context), Arguments.String(context, "trackId")))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Next(Tracks(context), Arguments.String(context, "trackId")))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Clear(Tracks(context), Arguments.String(context, "trackId")))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.Play(Tracks(context), PlayRequest(context, AudioPlacement.Enqueue)))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.ApplySEffect(Tracks(context), new AudioSEffectRequest( + Arguments.String(context, "trackId"), Arguments.String(context, "instanceId"), + Arguments.String(context, "id"), Arguments.Json(context, "parameters"))))), + BuiltinInstructionFactory.Create(context => Audio(context, scope, () => + runtime.StopSEffect(Tracks(context), Arguments.String(context, "trackId"), Arguments.String(context, "instanceId")))) + ]; + + private static AudioPlayRequest PlayRequest(PrimitiveCreateContext context, AudioPlacement placement) => new( + Arguments.String(context, "trackId"), Arguments.String(context, "asset"), placement, + Arguments.Float(context, "gain", 1), Arguments.Float(context, "fadeIn")); + + private static void OnDiagnostic(string message) => GameLog.Logger.Warning("Audio: {Diagnostic}", message); + + private sealed class AudioScope(AudioRuntime runtime) : IDisposable + { + private CancellationToken _token; + private CancellationTokenRegistration _registration; + public void Bind(CancellationToken token) + { + if (_token == token) return; + _registration.Dispose(); + _token = token; + _registration = token.Register(runtime.Reset); + } + public void Dispose() => _registration.Dispose(); + } + + private static ImmediatePrimitiveInstance Audio(PrimitiveCreateContext context, AudioScope scope, Action action) => + new(() => + { + scope.Bind(context.ScopeCancellation); + context.ScopeCancellation.ThrowIfCancellationRequested(); + action(); + }, batchId: context.BatchId); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinDialogueModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinDialogueModule.cs new file mode 100644 index 0000000..8bbfc5d --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinDialogueModule.cs @@ -0,0 +1,31 @@ +using GalNet.Core.Entry; +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinDialogueModule : PrimitiveModule +{ + public BuiltinDialogueModule(IDialoguePresenter? presenter = null) : base( + [ + Text(presenter), + Visibility(presenter, true), + Visibility(presenter, false) + ]) + { } + + private static PrimitiveImplementation Text(IDialoguePresenter? presenter) => new PrimitiveImplementation(TextEntry.TypeId, context => new DialoguePrimitiveInstance( + presenter ?? throw new InvalidOperationException("The dialogue module has no presenter."), + Arguments.String(context, "speaker"), + Arguments.String(context, "content"), + context.BatchId, + context.ScopeCancellation)); + + private static PrimitiveImplementation Visibility(IDialoguePresenter? presenter, bool visible) + where TEntry : PrimitiveEntry, new() => new PrimitiveImplementation(new TEntry().Type, context => new ImmediatePrimitiveInstance(() => + { + var target = presenter ?? throw new InvalidOperationException("The dialogue module has no presenter."); + if (visible) target.ShowDialogue(); else target.HideDialogue(); + }, batchId: context.BatchId)); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinEffectModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinEffectModule.cs new file mode 100644 index 0000000..b36d7dc --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinEffectModule.cs @@ -0,0 +1,24 @@ +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinEffectModule : PrimitiveModule +{ + public BuiltinEffectModule(IEffectPresenter? presenter = null) : base( + [ + BuiltinInstructionFactory.Create(context => new ApplyEffectPrimitiveInstance( + context.Runtime, + presenter, + BuiltinRuntimeActions.CreateEffectRequest(context), + context.BatchId, + context.ScopeCancellation)), + BuiltinInstructionFactory.Create(context => new StopEffectPrimitiveInstance( + context.Runtime, + presenter, + Arguments.String(context, "instanceId"), + context.BatchId, + context.ScopeCancellation)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinFlowModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinFlowModule.cs new file mode 100644 index 0000000..ea04ea8 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinFlowModule.cs @@ -0,0 +1,15 @@ +using GalNet.Presentation.Abstractions.Execution; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinFlowModule : PrimitiveModule +{ + public BuiltinFlowModule() : base( + [ + BuiltinInstructionFactory.Create(context => new WaitPrimitiveInstance( + TimeSpan.FromSeconds(Arguments.Float(context, "duration", 1)), + context.BatchId, + context.ScopeCancellation)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinGalleryModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinGalleryModule.cs new file mode 100644 index 0000000..49cf994 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinGalleryModule.cs @@ -0,0 +1,23 @@ +using GalNet.Core.Gallery; +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinGalleryModule : PrimitiveModule +{ + public BuiltinGalleryModule(GalleryCatalog? gallery = null) : base( + [ + BuiltinInstructionFactory.Create(context => new ImmediatePrimitiveInstance(() => + { + var itemId = Arguments.Int(context, "id"); + if (itemId <= 0) throw new InvalidDataException("gallery.unlock requires a positive integer 'id'."); + var catalog = gallery ?? throw new InvalidOperationException( + "The gallery module requires a GalleryCatalog."); + if (!catalog.TryGetItem(itemId, out var item)) + throw new InvalidDataException($"Gallery item '{itemId}' is not declared in the generated Gallery catalog."); + context.Runtime.SetVariable(GalleryUnlockVariable.GetRuntimeName(item.Id), true); + }, batchId: context.BatchId)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinInstructionFactory.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinInstructionFactory.cs new file mode 100644 index 0000000..ee39e8b --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinInstructionFactory.cs @@ -0,0 +1,11 @@ +using GalNet.Core.Entry; +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; + +namespace GalNet.Primitives.Builtins; + +internal static class BuiltinInstructionFactory +{ + public static PrimitiveImplementation Create(Func factory) + where TEntry : PrimitiveEntry, new() => new(new TEntry().Type, factory); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinLayerModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinLayerModule.cs new file mode 100644 index 0000000..35228fe --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinLayerModule.cs @@ -0,0 +1,50 @@ +using GalNet.Core.Primitives; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinLayerModule : PrimitiveModule +{ + public BuiltinLayerModule(ILayerPresenter? presenter = null) : base( + [ + BuiltinInstructionFactory.Create(context => Show(context, presenter, null)), + BuiltinInstructionFactory.Create(context => Show(context, presenter, Arguments.String(context, "color"))), + BuiltinInstructionFactory.Create(context => Immediate(context, () => + BuiltinRuntimeActions.HideLayer(context.Runtime, presenter, Arguments.String(context, "handleId")))), + BuiltinInstructionFactory.Create(context => Immediate(context, () => + BuiltinRuntimeActions.MoveLayer( + context.Runtime, + presenter, + Arguments.String(context, "handleId"), + Arguments.Json(context, "transform") ?? new LayerTransform(), + Arguments.Float(context, "z"), + Arguments.Float(context, "duration")))), + BuiltinInstructionFactory.Create(context => Immediate(context, () => + BuiltinRuntimeActions.ReplaceLayer( + context.Runtime, + presenter, + Arguments.String(context, "handleId"), + Arguments.String(context, "assetId")))) + ]) + { } + + private static ImmediatePrimitiveInstance Show(PrimitiveCreateContext context, ILayerPresenter? presenter, string? color) => + Immediate(context, () => + { + var request = new LayerRenderRequest( + Arguments.String(context, "handleId"), + Arguments.String(context, "assetId"), + Arguments.Json(context, "transform") ?? new LayerTransform(), + Arguments.Float(context, "z"), + Arguments.Enum(context, "displayMode", LayerDisplayMode.Native), + Arguments.Float(context, "opacity", 1), + color, + Arguments.Json(context, "flipbook")?.Clone()); + BuiltinRuntimeActions.ShowLayer(context.Runtime, presenter, request); + }); + + private static ImmediatePrimitiveInstance Immediate(PrimitiveCreateContext context, Action action) => + new(action, batchId: context.BatchId); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinNvlModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinNvlModule.cs new file mode 100644 index 0000000..4e6aaf8 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinNvlModule.cs @@ -0,0 +1,26 @@ +using GalNet.Core.Entry; +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinNvlModule : PrimitiveModule +{ + public BuiltinNvlModule(INvlPresenter? presenter = null) : base( + [ + Visibility(presenter, static target => target.ShowNvl()), + Visibility(presenter, static target => target.HideNvl()), + Visibility(presenter, static target => target.ClearNvl()), + new PrimitiveImplementation(AppendNvlEntry.TypeId, context => new NvlAppendPrimitiveInstance( + presenter ?? throw new InvalidOperationException("The NVL module has no presenter."), + Arguments.String(context, "content"), + context.BatchId, + context.ScopeCancellation)) + ]) + { } + + private static PrimitiveImplementation Visibility(INvlPresenter? presenter, Action operation) + where TEntry : PrimitiveEntry, new() => new PrimitiveImplementation(new TEntry().Type, context => new ImmediatePrimitiveInstance(() => operation( + presenter ?? throw new InvalidOperationException("The NVL module has no presenter.")), batchId: context.BatchId)); +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinParticleModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinParticleModule.cs new file mode 100644 index 0000000..8f64385 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinParticleModule.cs @@ -0,0 +1,15 @@ +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinParticleModule : PrimitiveModule +{ + public BuiltinParticleModule(IParticlePresenter? presenter = null) : base( + [ + BuiltinInstructionFactory.Create(context => new PlayParticlePrimitiveInstance(context.Runtime, presenter, BuiltinRuntimeActions.CreateParticleRequest(context), context.BatchId, context.ScopeCancellation)), + BuiltinInstructionFactory.Create(context => new BurstParticlePrimitiveInstance(presenter, BuiltinRuntimeActions.CreateParticleBurstRequest(context), context.BatchId, context.ScopeCancellation)), + BuiltinInstructionFactory.Create(context => new StopParticlePrimitiveInstance(context.Runtime, presenter, Arguments.String(context, "instanceId"), context.BatchId, context.ScopeCancellation)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVariableModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVariableModule.cs new file mode 100644 index 0000000..ad705f4 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVariableModule.cs @@ -0,0 +1,18 @@ +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinVariableModule : PrimitiveModule +{ + public BuiltinVariableModule() : base( + [ + BuiltinInstructionFactory.Create(context => new ImmediatePrimitiveInstance(() => + { + var target = Arguments.String(context, "target"); + if (!string.IsNullOrWhiteSpace(target)) + context.Runtime.SetVariable(target, context.Runtime.EvaluateExpression(Arguments.String(context, "expression")) ?? ""); + }, batchId: context.BatchId)) + ]) + { } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVideoModule.cs b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVideoModule.cs new file mode 100644 index 0000000..2c7da55 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/Modules/BuiltinVideoModule.cs @@ -0,0 +1,36 @@ +using GalNet.Core.Primitives; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +public sealed class BuiltinVideoModule : PrimitiveModule +{ + public BuiltinVideoModule(IVideoPresenter? presenter = null) : base( + [ + BuiltinInstructionFactory.Create(context => CreatePlay(context, presenter)), + BuiltinInstructionFactory.Create(context => new ImmediatePrimitiveInstance(() => + { + var handleId = Arguments.String(context, "handleId"); + if (context.Runtime.SceneInstances.TryGet(handleId, out var layer) && layer.IsVideo) + context.Runtime.SceneInstances.Remove(handleId, out _); + presenter?.StopVideo(handleId); + }, batchId: context.BatchId)) + ]) + { } + + private static VideoPrimitiveInstance CreatePlay(PrimitiveCreateContext context, IVideoPresenter? presenter) + { + var points = Arguments.Json(context, "skipPoints") ?? []; + var handleId = Arguments.String(context, "handleId"); + var asset = Arguments.String(context, "asset"); + var layer = new LayerRenderRequest(handleId, asset, + Arguments.Json(context, "transform") ?? new LayerTransform(), + Arguments.Float(context, "z"), Arguments.Enum(context, "displayMode", LayerDisplayMode.Uniform), + Arguments.Float(context, "opacity", 1)); + return new VideoPrimitiveInstance(context.Runtime, presenter, + new VideoRenderRequest(Guid.NewGuid().ToString("N"), layer, points), + Arguments.Bool(context, "blocking"), context.BatchId, context.ScopeCancellation); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationState.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationState.cs new file mode 100644 index 0000000..6b4c98b --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationState.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + public static void ApplyAnimationStableState(IGameRuntime runtime, AnimationRequest request) + { + if (request.LoopMode == AnimationLoopMode.Once) + { + ApplyAnimationTerminalValue(runtime, request); + return; + } + + // Replace loops own a stable frame-zero value. Additive loops retain their underlying + // base value; the presenter reapplies the authored frame-zero offset on every replay. + if (request.BlendMode == AnimationBlendMode.Replace && + TryResolveAnimationStartValue(runtime, request, out var initialValue)) + { + request.From ??= initialValue; + ApplyAnimationValue(runtime, request.HandleId, request.Property, initialValue, AnimationBlendMode.Replace); + } + + TrackLoopingAnimation( + runtime, + AnimateEntry.TypeId, + request.PlaybackHandleId, + request.LoopMode, + ToReplayParameters(request)); + } + + public static void ApplyAnimationTerminalValue(IGameRuntime runtime, AnimationRequest request) => + ApplyAnimationValue(runtime, request.HandleId, request.Property, request.To, request.BlendMode); + + public static void ApplyPlanStableState(IGameRuntime runtime, AnimationPlanDefinition plan) + { + if (plan.LoopMode != AnimationLoopMode.Once) + { + foreach (var item in plan.Events.Where(item => item.Frame <= 0).OrderBy(item => item.Frame)) + ApplyPlanEventToRuntime(runtime, item); + + foreach (var track in plan.Tracks.Where(track => track.Keys.Count > 0 && track.BlendMode == AnimationBlendMode.Replace)) + ApplyAnimationValue( + runtime, + track.HandleId, + track.Property, + AnimationTrackSampler.Evaluate(track, 0), + AnimationBlendMode.Replace); + + TrackLoopingAnimation( + runtime, + PlayAnimationPlanEntry.TypeId, + plan.PlaybackHandleId, + plan.LoopMode, + new Dictionary(StringComparer.Ordinal) + { + ["plan"] = JsonSerializer.Serialize(plan, Arguments.JsonOptions) + }); + return; + } + + ApplyPlanTerminalState(runtime, plan); + } + + public static void ApplyPlanTerminalState(IGameRuntime runtime, AnimationPlanDefinition plan) + { + foreach (var item in plan.Events.OrderBy(item => item.Frame)) + ApplyPlanEventToRuntime(runtime, item); + + foreach (var track in plan.Tracks) + if (track.Keys.Count > 0) + ApplyAnimationValue( + runtime, + track.HandleId, + track.Property, + AnimationTrackSampler.Evaluate(track, plan.DurationFrames), + track.BlendMode); + } + + public static void StopAnimationState(IGameRuntime runtime, string playbackHandleId) + { + if (string.IsNullOrWhiteSpace(playbackHandleId)) + return; + + runtime.SceneInstances.Remove(playbackHandleId, out _); + runtime.SceneState.ActiveAnimations.RemoveAll(animation => + string.Equals(animation.PlaybackHandleId, playbackHandleId, StringComparison.Ordinal)); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationValues.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationValues.cs new file mode 100644 index 0000000..43f8241 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.AnimationValues.cs @@ -0,0 +1,125 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + private static void ApplyAnimationValue( + IGameRuntime runtime, + string handleId, + string property, + float authoredValue, + AnimationBlendMode blendMode) + { + if (!runtime.SceneInstances.TryGet(handleId, out var target)) + return; + + var finalValue = authoredValue; + if (blendMode == AnimationBlendMode.Additive && + target.TryGetAnimationValue(property, out var current)) + finalValue = current + authoredValue; + + if (target.TrySetAnimationValue(property, finalValue, out _)) + SyncAnimationState(runtime, target); + } + + private static void SyncAnimationState(IGameRuntime runtime, AnimatableSceneInstance target) + { + switch (target) + { + case EffectInstance effect: + SyncEffectAnimationState(runtime, effect); + break; + case ParticleEmitterInstance emitter: + SyncParticleAnimationState(runtime, emitter); + break; + } + } + + private static void SyncEffectAnimationState(IGameRuntime runtime, EffectInstance effect) + { + var state = runtime.SceneState.ActiveEffects.FirstOrDefault(item => + string.Equals(item.InstanceId, effect.Id, StringComparison.Ordinal)); + if (state is null) + return; + + state.AnimationValues.Clear(); + foreach (var (property, value) in effect.AnimationValues) + state.AnimationValues[property] = value; + } + + private static void SyncParticleAnimationState(IGameRuntime runtime, ParticleEmitterInstance emitter) + { + var state = runtime.SceneState.ActiveParticleEmitters.FirstOrDefault(item => + string.Equals(item.InstanceId, emitter.Id, StringComparison.Ordinal)); + if (state is null) + return; + + state.AnimationValues.Clear(); + foreach (var (property, value) in emitter.AnimationValues) + state.AnimationValues[property] = value; + } + + private static void TrackLoopingAnimation( + IGameRuntime runtime, + string entryType, + string playbackHandleId, + AnimationLoopMode loopMode, + Dictionary parameters) + { + if (string.IsNullOrWhiteSpace(playbackHandleId)) + return; + + runtime.SceneInstances.Remove(playbackHandleId, out _); + runtime.SceneInstances.GetOrAddTransient(playbackHandleId, id => new AnimationPlaybackInstance + { + Id = id, + LoopMode = loopMode + }); + runtime.SceneState.ActiveAnimations.RemoveAll(animation => + string.Equals(animation.PlaybackHandleId, playbackHandleId, StringComparison.Ordinal)); + runtime.SceneState.ActiveAnimations.Add(new ActiveAnimationState + { + EntryType = entryType, + PlaybackHandleId = playbackHandleId, + LoopMode = loopMode, + Parameters = parameters + }); + } + + private static Dictionary ToReplayParameters(AnimationRequest request) + { + var result = new Dictionary(StringComparer.Ordinal) + { + ["handleId"] = request.HandleId, + ["property"] = request.Property, + ["to"] = Invariant(request.To), + ["duration"] = Invariant(request.DurationSeconds), + ["curve"] = request.CurveKind.ToString(), + ["blocking"] = request.Blocking.ToString(), + ["skippable"] = request.Skippable.ToString(), + ["blendMode"] = request.BlendMode.ToString() + }; + if (request.From.HasValue) + result["from"] = Invariant(request.From.Value); + return result; + } + + private static bool TryResolveAnimationStartValue(IGameRuntime runtime, AnimationRequest request, out float value) + { + if (request.From.HasValue) + { + value = request.From.Value; + return true; + } + + if (runtime.SceneInstances.TryGet(request.HandleId, out var target) && + target.TryGetAnimationValue(request.Property, out value)) + return true; + + value = default; + return false; + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.EffectParticleState.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.EffectParticleState.cs new file mode 100644 index 0000000..1b7830a --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.EffectParticleState.cs @@ -0,0 +1,67 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + public static void ApplyParticleState(IGameRuntime runtime, ParticleEmitterRequest request) + { + RemoveParticleState(runtime, request.InstanceId); + var emitter = runtime.SceneInstances.GetOrAddTransient(request.InstanceId, id => new ParticleEmitterInstance(id, request.Definition, request.Z)); + emitter.RestoreAnimationValues(request.AnimationValues); + runtime.SceneState.ActiveParticleEmitters.Add(new ActiveParticleEmitterState + { + InstanceId = request.InstanceId, + Definition = request.Definition, + Z = request.Z, + AnimationValues = emitter.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + }); + } + + public static void RemoveParticleState(IGameRuntime runtime, string instanceId) + { + if (string.IsNullOrWhiteSpace(instanceId)) return; + runtime.SceneInstances.Remove(instanceId, out _); + runtime.SceneState.ActiveParticleEmitters.RemoveAll(emitter => string.Equals(emitter.InstanceId, instanceId, StringComparison.Ordinal)); + } + + public static void ApplyEffectState(IGameRuntime runtime, EffectRequest request) + { + if (string.IsNullOrWhiteSpace(request.InstanceId)) + return; + + RemoveEffectState(runtime, request.InstanceId); + var effect = runtime.SceneInstances.GetOrAddTransient(request.InstanceId, _ => new EffectInstance + { + Id = request.InstanceId, + EffectId = request.Id, + ProgramResource = request.ProgramResource, + TargetHandleId = request.TargetHandleId, + Order = request.Order, + Parameters = request.Parameters + }); + effect.RestoreAnimationValues(request.AnimationValues); + + runtime.SceneState.ActiveEffects.Add(new ActiveEffectState + { + Id = request.Id, + ProgramResource = request.ProgramResource, + InstanceId = request.InstanceId, + TargetHandleId = request.TargetHandleId, + Order = request.Order, + Parameters = request.Parameters, + AnimationValues = effect.AnimationValues.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal) + }); + } + + public static void RemoveEffectState(IGameRuntime runtime, string instanceId) + { + if (string.IsNullOrWhiteSpace(instanceId)) + return; + + runtime.SceneInstances.Remove(instanceId, out _); + runtime.SceneState.ActiveEffects.RemoveAll(effect => string.Equals(effect.InstanceId, instanceId, StringComparison.Ordinal)); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Layers.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Layers.cs new file mode 100644 index 0000000..8f70750 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Layers.cs @@ -0,0 +1,63 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + public static void ShowLayer(IGameRuntime runtime, ILayerPresenter? presenter, LayerRenderRequest request, bool notify = true) + { + var layer = runtime.SceneInstances.GetOrAdd(request.HandleId, id => new Layer { Id = id }); + layer.AssetId = request.AssetId; + layer.IsVideo = false; + layer.VideoPlaybackId = null; + layer.Color = request.Color; + layer.Flipbook = request.Flipbook?.Clone(); + layer.Transform = request.Transform.Clone(); + layer.Z = request.Z; + layer.Opacity = request.Opacity; + layer.DisplayMode = request.DisplayMode; + layer.Visible = true; + if (notify) + presenter?.ShowLayer(request); + } + + public static void HideLayer(IGameRuntime runtime, ILayerPresenter? presenter, string handleId, bool notify = true) + { + runtime.SceneInstances.Remove(handleId, out _); + if (notify) + presenter?.HideLayer(handleId); + } + + public static void MoveLayer( + IGameRuntime runtime, + ILayerPresenter? presenter, + string handleId, + LayerTransform transform, + float z, + float durationSeconds, + bool notify = true) + { + if (runtime.SceneInstances.TryGet(handleId, out var layer)) + { + layer.Transform = transform.Clone(); + layer.Z = z; + } + if (notify) + presenter?.MoveLayer(handleId, transform, z, durationSeconds); + } + + public static void ReplaceLayer(IGameRuntime runtime, ILayerPresenter? presenter, string handleId, string assetId, bool notify = true) + { + if (runtime.SceneInstances.TryGet(handleId, out var layer)) + { + layer.AssetId = assetId; + layer.IsVideo = false; + layer.VideoPlaybackId = null; + layer.Color = null; + } + if (notify) + presenter?.ReplaceLayer(handleId, assetId); + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Parameters.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Parameters.cs new file mode 100644 index 0000000..a6d7cc6 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Parameters.cs @@ -0,0 +1,101 @@ +using System.Text.Json; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + private static string Get(Dictionary parameters, string name, string fallback = "") => + parameters.TryGetValue(name, out var value) ? value : fallback; + + private static bool TryParseFloat(string value, out float result) => + float.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out result); + + private static float ParseFloat(string value, float fallback = 0) => + TryParseFloat(value, out var result) ? result : fallback; + + private static double ParseDouble(string value, double fallback = 0) => + double.TryParse(value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var result) + ? result + : fallback; + + private static bool ParseBool(string value, bool fallback = false) => + bool.TryParse(value, out var result) ? result : fallback; + + private static TEnum ParseEnum(string value, TEnum fallback) + where TEnum : struct, Enum => + System.Enum.TryParse(value, true, out var result) ? result : fallback; + + private static string Invariant(float value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); + private static string Invariant(double value) => value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private static LayerRenderRequest ToLayerRenderRequest( + IReadOnlyDictionary parameters, + string? color) => new( + Get(parameters, "handleId"), + Get(parameters, "assetId"), + Json(parameters, "transform") ?? new LayerTransform(), + Float(parameters, "z"), + Enum(parameters, "displayMode", LayerDisplayMode.Native), + Float(parameters, "opacity", 1), + color, + Json(parameters, "flipbook")?.Clone()); + + private static EffectRequest ToEffectRequest(IReadOnlyDictionary parameters) => new( + Get(parameters, "id"), + Get(parameters, "instanceId"), + Get(parameters, "targetHandleId"), + Int(parameters, "order"), + RawJson(parameters, "parameters", "{}"), + Get(parameters, "program")); + + private static ParticleBurstRequest ToParticleBurstRequest(IReadOnlyDictionary parameters) + { + var count = Int(parameters, "count", 1); + if (count <= 0) throw new InvalidDataException("particle.burst requires a positive 'count'."); + var definition = ReadParticleDefinition(RawJson(parameters, "parameters", "{}"), BurstParticlesEntry.TypeId) with { EmissionRate = 0 }; + return new ParticleBurstRequest(definition, count, Float(parameters, "z", 100)); + } + + private static ParticleEmitterRequest ToParticleEmitterRequest(IReadOnlyDictionary parameters) + { + var instanceId = Get(parameters, "instanceId"); + if (string.IsNullOrWhiteSpace(instanceId)) throw new InvalidDataException("particle.play requires 'instanceId'."); + var definition = ReadParticleDefinition(RawJson(parameters, "parameters", "{}"), PlayParticleEmitterEntry.TypeId); + return new ParticleEmitterRequest(instanceId, definition, Float(parameters, "z", 100)); + } + + private static ParticleEmitterDefinition ReadParticleDefinition(string json, string entryType) + { + var definition = ParticleEmitterDefinition.FromJson(json); + if (string.IsNullOrWhiteSpace(definition.ParticleTexture)) + throw new InvalidDataException($"{entryType} requires 'parameters.particleTexture'."); + return definition; + } + + private static string Get(IReadOnlyDictionary parameters, string name, string fallback = "") => + parameters.TryGetValue(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? fallback + : fallback; + + private static float Float(IReadOnlyDictionary parameters, string name, float fallback = 0) => + parameters.TryGetValue(name, out var value) && value.TryGetSingle(out var result) ? result : fallback; + + private static int Int(IReadOnlyDictionary parameters, string name, int fallback = 0) => + parameters.TryGetValue(name, out var value) && value.TryGetInt32(out var result) ? result : fallback; + + private static TEnum Enum(IReadOnlyDictionary parameters, string name, TEnum fallback) + where TEnum : struct, Enum => + System.Enum.TryParse(Get(parameters, name), true, out var result) ? result : fallback; + + private static string RawJson(IReadOnlyDictionary parameters, string name, string fallback = "{}") => + parameters.TryGetValue(name, out var value) && value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined) + ? value.GetRawText() + : fallback; + + private static T? Json(IReadOnlyDictionary parameters, string name) => + parameters.TryGetValue(name, out var value) + ? value.Deserialize(Arguments.JsonOptions) + : default; +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.PlanEvents.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.PlanEvents.cs new file mode 100644 index 0000000..d8ef161 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.PlanEvents.cs @@ -0,0 +1,93 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + public static void ApplyPlanEventToRuntime(IGameRuntime runtime, AnimationPlanEventDefinition planEvent) + { + switch (planEvent.Type) + { + case ShowLayerEntry.TypeId: + ShowLayer(runtime, null, ToLayerRenderRequest(planEvent.Parameters, color: null), notify: false); + break; + case ShowColorLayerEntry.TypeId: + ShowLayer(runtime, null, ToLayerRenderRequest(planEvent.Parameters, Get(planEvent.Parameters, "color")), notify: false); + break; + case HideLayerEntry.TypeId: + HideLayer(runtime, null, Get(planEvent.Parameters, "handleId"), notify: false); + break; + case MoveLayerEntry.TypeId: + MoveLayer( + runtime, + null, + Get(planEvent.Parameters, "handleId"), + Json(planEvent.Parameters, "transform") ?? new LayerTransform(), + Float(planEvent.Parameters, "z"), + Float(planEvent.Parameters, "duration"), + notify: false); + break; + case ReplaceLayerEntry.TypeId: + ReplaceLayer(runtime, null, Get(planEvent.Parameters, "handleId"), Get(planEvent.Parameters, "assetId"), notify: false); + break; + case ApplyEffectEntry.TypeId: + ApplyEffectState(runtime, ToEffectRequest(planEvent.Parameters)); + break; + case StopEffectEntry.TypeId: + RemoveEffectState(runtime, Get(planEvent.Parameters, "instanceId")); + break; + case PlayParticleEmitterEntry.TypeId: + ApplyParticleState(runtime, ToParticleEmitterRequest(planEvent.Parameters)); + break; + case StopParticleEmitterEntry.TypeId: + RemoveParticleState(runtime, Get(planEvent.Parameters, "instanceId")); + break; + } + } + + public static Task NotifyPlanEventAsync( + AnimationPlanEventDefinition planEvent, + ILayerPresenter? layerPresenter, + IEffectPresenter? effectPresenter, + IParticlePresenter? particlePresenter, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + switch (planEvent.Type) + { + case ShowLayerEntry.TypeId: + layerPresenter?.ShowLayer(ToLayerRenderRequest(planEvent.Parameters, color: null)); + break; + case ShowColorLayerEntry.TypeId: + layerPresenter?.ShowLayer(ToLayerRenderRequest(planEvent.Parameters, Get(planEvent.Parameters, "color"))); + break; + case HideLayerEntry.TypeId: + layerPresenter?.HideLayer(Get(planEvent.Parameters, "handleId")); + break; + case MoveLayerEntry.TypeId: + layerPresenter?.MoveLayer( + Get(planEvent.Parameters, "handleId"), + Json(planEvent.Parameters, "transform") ?? new LayerTransform(), + Float(planEvent.Parameters, "z"), + Float(planEvent.Parameters, "duration")); + break; + case ReplaceLayerEntry.TypeId: + layerPresenter?.ReplaceLayer(Get(planEvent.Parameters, "handleId"), Get(planEvent.Parameters, "assetId")); + break; + case ApplyEffectEntry.TypeId when effectPresenter is not null: + return effectPresenter.StartEffectAsync(ToEffectRequest(planEvent.Parameters), cancellationToken); + case StopEffectEntry.TypeId when effectPresenter is not null: + return effectPresenter.StopEffectAsync(Get(planEvent.Parameters, "instanceId"), cancellationToken); + case PlayParticleEmitterEntry.TypeId when particlePresenter is not null: + return particlePresenter.StartParticleEmitterAsync(ToParticleEmitterRequest(planEvent.Parameters), cancellationToken); + case StopParticleEmitterEntry.TypeId when particlePresenter is not null: + return particlePresenter.StopParticleEmitterAsync(Get(planEvent.Parameters, "instanceId"), cancellationToken); + case BurstParticlesEntry.TypeId when particlePresenter is not null: + return particlePresenter.BurstParticlesAsync(ToParticleBurstRequest(planEvent.Parameters), cancellationToken); + } + + return Task.CompletedTask; + } +} diff --git a/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Requests.cs b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Requests.cs new file mode 100644 index 0000000..de00b75 --- /dev/null +++ b/src/Shared/GalNet.Primitives.Builtins/RuntimeActions/BuiltinRuntimeActions.Requests.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using GalNet.Core.Primitives; +using GalNet.Core.Scene; +using GalNet.Presentation.Abstractions.View; + +namespace GalNet.Primitives.Builtins; + +internal static partial class BuiltinRuntimeActions +{ + public static AnimationRequest CreateAnimationRequest(PrimitiveCreateContext context) + { + var curve = Arguments.Enum(context, "curve", BuiltinAnimationCurve.Linear); + return new AnimationRequest + { + PlaybackHandleId = Arguments.String(context, "playbackHandleId"), + HandleId = Arguments.String(context, "handleId"), + Property = Arguments.String(context, "property"), + From = Arguments.TryFloat(context, "from", out var from) ? from : null, + To = Arguments.Float(context, "to"), + DurationSeconds = Math.Max(0, Arguments.Float(context, "duration", 0.25f)), + CurveKind = curve, + Curve = AnimationCurves.Create(curve), + Blocking = Arguments.Bool(context, "blocking"), + Skippable = Arguments.Bool(context, "skippable"), + LoopMode = Arguments.Enum(context, "loopMode", AnimationLoopMode.Once), + BlendMode = Arguments.Enum(context, "blendMode", AnimationBlendMode.Replace) + }; + } + + public static AnimationRequest CreateAnimationRequest(ActiveAnimationState state) + { + var curve = ParseEnum(Get(state.Parameters, "curve"), BuiltinAnimationCurve.Linear); + return new AnimationRequest + { + PlaybackHandleId = state.PlaybackHandleId, + HandleId = Get(state.Parameters, "handleId"), + Property = Get(state.Parameters, "property"), + From = TryParseFloat(Get(state.Parameters, "from"), out var from) ? from : null, + To = ParseFloat(Get(state.Parameters, "to")), + DurationSeconds = Math.Max(0, ParseDouble(Get(state.Parameters, "duration"), 0.25)), + CurveKind = curve, + Curve = AnimationCurves.Create(curve), + Blocking = ParseBool(Get(state.Parameters, "blocking")), + Skippable = ParseBool(Get(state.Parameters, "skippable")), + LoopMode = state.LoopMode, + BlendMode = ParseEnum(Get(state.Parameters, "blendMode"), AnimationBlendMode.Replace) + }; + } + + public static AnimationPlanDefinition CreateAnimationPlan(PrimitiveCreateContext context) + { + return Arguments.Json(context, "plan") + ?? throw new InvalidDataException("animation.play requires a plan object."); + } + + public static AnimationPlanDefinition CreateAnimationPlan(ActiveAnimationState state) + { + if (!state.Parameters.TryGetValue("plan", out var json) || string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException($"Persistent animation plan '{state.PlaybackHandleId}' has no replay definition."); + return JsonSerializer.Deserialize(json, Arguments.JsonOptions) + ?? throw new InvalidDataException($"Persistent animation plan '{state.PlaybackHandleId}' is invalid."); + } + + public static EffectRequest CreateEffectRequest(PrimitiveCreateContext context) => new( + Arguments.String(context, "id"), + Arguments.String(context, "instanceId"), + Arguments.String(context, "targetHandleId"), + Arguments.Int(context, "order"), + Arguments.RawJson(context, "parameters", "{}"), + Arguments.String(context, "program")); + + public static ParticleEmitterRequest CreateParticleRequest(PrimitiveCreateContext context) + { + var instanceId = Arguments.String(context, "instanceId"); + var definition = ReadParticleDefinition(Arguments.RawJson(context, "parameters", "{}"), PlayParticleEmitterEntry.TypeId); + return new ParticleEmitterRequest(instanceId, definition, Arguments.Float(context, "z", 100)); + } + + public static ParticleBurstRequest CreateParticleBurstRequest(PrimitiveCreateContext context) + { + var count = Arguments.Int(context, "count", 1); + var definition = ReadParticleDefinition(Arguments.RawJson(context, "parameters", "{}"), BurstParticlesEntry.TypeId) with { EmissionRate = 0 }; + return new ParticleBurstRequest(definition, count, Arguments.Float(context, "z", 100)); + } +} diff --git a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs index 529882e..12b8258 100644 --- a/src/Shared/GalNet.Runtime/Engine/GameEngine.cs +++ b/src/Shared/GalNet.Runtime/Engine/GameEngine.cs @@ -5,13 +5,14 @@ using GalNet.Core.Services; using GalNet.Core.Settings; using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Logging; using GalNet.Runtime.Progress; using GalNet.Runtime.Runtime; namespace GalNet.Runtime.Engine; /// Owns story flow and all active primitive instances for one game run. -public sealed class GameEngine : IDisposable +public sealed class GameEngine : IDisposable, IAsyncDisposable { private readonly Graph _graph; private readonly IGameRuntime _runtime; @@ -28,7 +29,18 @@ public sealed class GameEngine : IDisposable private string? _executingGroupNodeId; private long _groupExecutionId; private long _nextSequence; - private bool _disposed; + private readonly object _lifecycleSync = new(); + private readonly List _backgroundTasks = []; + private readonly Queue _stateChanges = new(); + private readonly object _notificationSync = new(); + private GameEngineState _reportedState = GameEngineState.Ready; + private Task? _stopTask; + private Task? _disposeTask; + private int _stopRequested; + private bool _started; + private bool _stopped; + private bool _publishingState; + private volatile bool _disposed; public GameEngine( Graph graph, @@ -69,11 +81,18 @@ public GameEngine( } public event Action? CheckpointCreated; + /// Published outside the execution lock. Handlers must not synchronously wait for engine tasks. + public event Action? StateChanged; public IGameRuntime Runtime => _runtime; public GameSnapshot LastStableSnapshot => SnapshotCopy.Clone(_lastStableSnapshot); public string CurrentNodeId => _runtime.CurrentNodeId; public int EntryIndex => _runtime.EntryIndex; public bool IsRunning { get; private set; } + public GameEngineState State => _stopped ? GameEngineState.Stopped + : Volatile.Read(ref _stopRequested) != 0 ? _reportedState + : _flowError is not null ? GameEngineState.Faulted + : !_started ? GameEngineState.Ready + : IsRunning ? GameEngineState.Running : GameEngineState.Completed; /// Applies one player advance and runs until the next blocking boundary. public async Task AdvanceAsync(CancellationToken cancellationToken = default) @@ -82,8 +101,13 @@ public async Task AdvanceAsync(CancellationToken cancellationToken = defau await _dispatchGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + if (Volatile.Read(ref _stopRequested) != 0) return false; + ThrowIfDisposed(); ThrowFlowError(); + if (_started && !IsRunning) return false; + _started = true; IsRunning = !_runtime.IsGameEnded; + CaptureStateChange(); RemoveCompleted(); UpdateLastStableSnapshot(); @@ -106,9 +130,21 @@ public async Task AdvanceAsync(CancellationToken cancellationToken = defau ThrowFlowError(); return IsRunning; } + catch (OperationCanceledException) when (_scopeCancellation.IsCancellationRequested) + { + return false; + } + catch (Exception exception) + { + _flowError ??= exception; + IsRunning = false; + throw; + } finally { + CaptureStateChange(); _dispatchGate.Release(); + PublishStateChanges(); } } @@ -124,6 +160,9 @@ public void RestoreFrom(GameSnapshot data) _dispatchGate.Wait(); try { + ThrowIfDisposed(); + if (Volatile.Read(ref _stopRequested) != 0) + throw new InvalidOperationException("A stopped engine cannot be restored. Create a new engine."); CancelPendingChoice(); ClearActive(); var previousScope = _scopeCancellation; @@ -136,31 +175,80 @@ public void RestoreFrom(GameSnapshot data) _executingGroupNodeId = null; _flowError = null; IsRunning = true; + _started = false; } finally { + CaptureStateChange(); _dispatchGate.Release(); + PublishStateChanges(); } } - public void Dispose() + /// Terminal stop: cancel execution and drain internal continuations without owning the view. + public Task StopAsync() { - if (_disposed) - return; - _disposed = true; - _scopeCancellation.Cancel(); - _dispatchGate.Wait(); + lock (_lifecycleSync) + { + Interlocked.Exchange(ref _stopRequested, 1); + return _stopTask ??= Task.Run(StopCoreAsync); + } + } + + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + public ValueTask DisposeAsync() + { + lock (_lifecycleSync) + { + _disposed = true; + return new ValueTask(_disposeTask ??= DisposeCoreAsync()); + } + } + + private async Task DisposeCoreAsync() + { + try { await StopAsync().ConfigureAwait(false); } + finally { _scopeCancellation.Dispose(); } + } + + private async Task StopCoreAsync() + { + Exception? cancellationError = null; + var scope = _scopeCancellation; + try { scope.Cancel(); } + catch (ObjectDisposedException) { } + catch (Exception exception) { cancellationError = exception; } + await _dispatchGate.WaitAsync().ConfigureAwait(false); try { - CancelPendingChoice(); + // Restore may have replaced the scope while stop was waiting for the gate. + try + { + if (!ReferenceEquals(scope, _scopeCancellation)) _scopeCancellation.Cancel(); + CancelPendingChoice(); + } + catch (Exception exception) { cancellationError = exception; } ClearActive(); IsRunning = false; } finally { _dispatchGate.Release(); - _scopeCancellation.Dispose(); } + Task[] background; + lock (_lifecycleSync) background = _backgroundTasks.ToArray(); + await Task.WhenAll(background).ConfigureAwait(false); + await _dispatchGate.WaitAsync().ConfigureAwait(false); + try + { + _stopped = true; + CaptureStateChange(); + } + finally { _dispatchGate.Release(); } + PublishStateChanges(); + if (cancellationError is not null) + throw new AggregateException("Game scope cancellation failed.", cancellationError); } private void ContinueUntilBlocked() @@ -205,6 +293,7 @@ private void ProcessGroup(Group group) BeginGroupExecution(group); while (_runtime.EntryIndex < group.Entries.Count && !HasBlockingInstance()) { + _scopeCancellation.Token.ThrowIfCancellationRequested(); var entry = group.Entries[_runtime.EntryIndex]; PrimitiveInstance? instance = null; if (_runtime.EvaluateCondition(entry.Condition) && entry is PrimitiveEntry primitive) @@ -301,7 +390,7 @@ private void ProcessChoice(Branch branch) cancellation.Dispose(); throw; } - _ = CompleteChoiceAsync(pending, selection); + StartBackground(() => CompleteChoiceAsync(pending, selection)); } private async Task CompleteChoiceAsync(PendingChoice pending, Task selection) @@ -309,7 +398,7 @@ private async Task CompleteChoiceAsync(PendingChoice pending, Task selectio int selected; try { - selected = await selection.ConfigureAwait(false); + selected = await selection.WaitAsync(pending.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (pending.Cancellation.IsCancellationRequested) { @@ -324,7 +413,7 @@ private async Task CompleteChoiceAsync(PendingChoice pending, Task selectio await _dispatchGate.WaitAsync().ConfigureAwait(false); try { - if (!ReferenceEquals(_pendingChoice, pending) || _disposed) + if (!ReferenceEquals(_pendingChoice, pending) || _disposed || Volatile.Read(ref _stopRequested) != 0) return; _pendingChoice = null; pending.Cancellation.Dispose(); @@ -347,7 +436,9 @@ private async Task CompleteChoiceAsync(PendingChoice pending, Task selectio } finally { + CaptureStateChange(); _dispatchGate.Release(); + PublishStateChanges(); } } @@ -356,7 +447,7 @@ private async Task RecordFlowErrorAsync(PendingChoice pending, Exception excepti await _dispatchGate.WaitAsync().ConfigureAwait(false); try { - if (!ReferenceEquals(_pendingChoice, pending) || _disposed) + if (!ReferenceEquals(_pendingChoice, pending) || _disposed || Volatile.Read(ref _stopRequested) != 0) return; _pendingChoice = null; pending.Cancellation.Dispose(); @@ -365,19 +456,21 @@ private async Task RecordFlowErrorAsync(PendingChoice pending, Exception excepti } finally { + CaptureStateChange(); _dispatchGate.Release(); + PublishStateChanges(); } } private void OnBlockingCompleted(PrimitiveInstance instance) => - _ = ContinueAfterBlockingCompletionAsync(instance); + StartBackground(() => ContinueAfterBlockingCompletionAsync(instance)); private async Task ContinueAfterBlockingCompletionAsync(PrimitiveInstance instance) { await _dispatchGate.WaitAsync().ConfigureAwait(false); try { - if (_disposed || !_active.Any(item => ReferenceEquals(item.Instance, instance))) + if (_disposed || Volatile.Read(ref _stopRequested) != 0 || !_active.Any(item => ReferenceEquals(item.Instance, instance))) return; RemoveCompleted(); UpdateLastStableSnapshot(); @@ -393,7 +486,54 @@ private async Task ContinueAfterBlockingCompletionAsync(PrimitiveInstance instan } finally { + CaptureStateChange(); _dispatchGate.Release(); + PublishStateChanges(); + } + } + + private void StartBackground(Func work) + { + lock (_lifecycleSync) + { + if (_disposed || Volatile.Read(ref _stopRequested) != 0) return; + _backgroundTasks.RemoveAll(task => task.IsCompleted); + _backgroundTasks.Add(Task.Run(work)); + } + } + + // Called under the execution lock; delivery is deferred until after it is released. + private void CaptureStateChange() + { + if (Volatile.Read(ref _stopRequested) != 0 && !_stopped) return; + var state = State; + if (state == _reportedState) return; + _reportedState = state; + lock (_notificationSync) + _stateChanges.Enqueue(new GameEngineStateChange(state, state == GameEngineState.Faulted ? _flowError : null)); + } + + private void PublishStateChanges() + { + lock (_notificationSync) + { + if (_publishingState) return; + _publishingState = true; + } + while (true) + { + GameEngineStateChange change; + lock (_notificationSync) + { + if (!_stateChanges.TryDequeue(out change!)) + { + _publishingState = false; + return; + } + } + foreach (var handler in StateChanged?.GetInvocationList() ?? []) + try { ((Action)handler)(change); } + catch (Exception exception) { GameLog.Logger.Error(exception, "Game state observer failed"); } } } @@ -474,8 +614,8 @@ private void CancelPendingChoice() _pendingChoice = null; if (pending is null) return; - pending.Cancellation.Cancel(); - pending.Cancellation.Dispose(); + try { pending.Cancellation.Cancel(); } + finally { pending.Cancellation.Dispose(); } } private void ThrowFlowError() @@ -496,5 +636,8 @@ private sealed record VisibleChoice(int Outlet, string Text, string Condition); private sealed record PendingChoice( string BranchId, IReadOnlyList Options, - CancellationTokenSource Cancellation); + CancellationTokenSource Cancellation) + { + public CancellationToken Token { get; } = Cancellation.Token; + } } diff --git a/src/Shared/GalNet.Runtime/Engine/GameEngineState.cs b/src/Shared/GalNet.Runtime/Engine/GameEngineState.cs new file mode 100644 index 0000000..a9fd896 --- /dev/null +++ b/src/Shared/GalNet.Runtime/Engine/GameEngineState.cs @@ -0,0 +1,13 @@ +namespace GalNet.Runtime.Engine; + +public enum GameEngineState +{ + Ready, + Running, + Completed, + Faulted, + Stopped +} + +/// An ordered engine state notification; UI and persistence decisions belong to the host. +public sealed record GameEngineStateChange(GameEngineState State, Exception? Error = null); diff --git a/src/Shared/GalNet.Runtime/Runtime/RunScopedRuntime.cs b/src/Shared/GalNet.Runtime/Runtime/RunScopedRuntime.cs new file mode 100644 index 0000000..7e4c8f8 --- /dev/null +++ b/src/Shared/GalNet.Runtime/Runtime/RunScopedRuntime.cs @@ -0,0 +1,43 @@ +using GalNet.Core.Runtime; +using GalNet.Core.Scene; +using GalNet.Core.Services; +using GalNet.Core.Settings; +using GalNet.Core.Variable; +using GalVariable = GalNet.Core.Variable.Variable; + +namespace GalNet.Runtime.Runtime; + +/// Old callbacks retain their old scene and cannot change a restored run's control flow or variables. +internal sealed class RunScopedRuntime : IGameRuntime +{ + private readonly IGameRuntime _runtime; + private readonly CancellationToken _cancellation; + + public RunScopedRuntime(IGameRuntime runtime, CancellationToken cancellation) + { + _runtime = runtime; + _cancellation = cancellation; + SceneState = runtime.SceneState; + SceneInstances = runtime.SceneInstances; + } + + public string CurrentNodeId => _runtime.CurrentNodeId; + public int EntryIndex => _runtime.EntryIndex; + public bool IsGameEnded => _runtime.IsGameEnded; + public ITextResolver TextResolver => _runtime.TextResolver; + public SettingsContainer Settings => _runtime.Settings; + public SceneState SceneState { get; } + public ISceneInstanceManager SceneInstances { get; } + public void JumpTo(string nodeId, int entryIndex = 0) { Check(); _runtime.JumpTo(nodeId, entryIndex); } + public void SetEntryIndex(int entryIndex) { Check(); _runtime.SetEntryIndex(entryIndex); } + public void EndGame() { Check(); _runtime.EndGame(); } + public void SetVariable(string name, object value) { Check(); _runtime.SetVariable(name, value); } + public GalVariable? GetVariable(string name) { Check(); return _runtime.GetVariable(name); } + public bool TryGetVariable(string name, out GalVariable variable) { Check(); return _runtime.TryGetVariable(name, out variable!); } + public IReadOnlyDictionary GetVariables(VariableScope scope) { Check(); return _runtime.GetVariables(scope); } + public bool EvaluateCondition(string expression) { Check(); return _runtime.EvaluateCondition(expression); } + public object? EvaluateExpression(string expression) { Check(); return _runtime.EvaluateExpression(expression); } + public GameSnapshot CreateSnapshot() { Check(); return _runtime.CreateSnapshot(); } + public void RestoreFrom(GameSnapshot snapshot) => throw new InvalidOperationException("Only the engine can restore a run."); + private void Check() => _cancellation.ThrowIfCancellationRequested(); +} diff --git a/test/GalNet.Editor.Tests/DockLayoutSerializerTests.cs b/test/GalNet.Editor.Tests/DockLayoutSerializerTests.cs index 26901f2..b92dfb7 100644 --- a/test/GalNet.Editor.Tests/DockLayoutSerializerTests.cs +++ b/test/GalNet.Editor.Tests/DockLayoutSerializerTests.cs @@ -13,6 +13,6 @@ public void Serialize_AllowsNonFiniteDockValues() var serialized = new DockLayoutSerializer().Serialize(layout); Assert.That(serialized, Does.Contain("\"NaN\"")); - Assert.That(new DockLayoutSerializer().Deserialize(serialized), Is.Not.Null); + Assert.That(DockLayoutSerializer.Deserialize(serialized), Is.Not.Null); } } diff --git a/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs b/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs index 5205ad1..75d83c5 100644 --- a/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs +++ b/test/GalNet.Editor.Tests/EditorWorkspaceViewModelTests.cs @@ -145,8 +145,7 @@ public async Task EditorWorkspacePersistence_LoadsAndSavesWithoutAWorkspace() projects, new FakeDocumentRepository(WorkspaceFixture.CreateLoadedDocument()), documents, - saveCoordinator, - new GraphDocumentMapper()); + saveCoordinator); var graph = persistence.LoadCurrentProject(); var saved = await persistence.SaveCurrentProjectAsync(graph!.Nodes, graph.Edges); @@ -194,13 +193,11 @@ public WorkspaceFixture(bool withProject = false) var catalog = BuiltinSchemas.CreateCatalog(); var variableDefinitions = new VariableDefinitionService(DocumentService); var repository = new FakeDocumentRepository(loaded); - var mapper = new GraphDocumentMapper(); var persistence = new EditorWorkspacePersistence( ProjectService, repository, DocumentService, - SaveCoordinator, - mapper); + SaveCoordinator); Workspace = new EditorWorkspaceViewModel( ProjectService, diff --git a/test/GalNet.Editor.Tests/GraphDocumentMapperTests.cs b/test/GalNet.Editor.Tests/GraphDocumentMapperTests.cs index 698990b..871a253 100644 --- a/test/GalNet.Editor.Tests/GraphDocumentMapperTests.cs +++ b/test/GalNet.Editor.Tests/GraphDocumentMapperTests.cs @@ -18,7 +18,7 @@ public void Load_AddsMissingEntryAndPreservesTheReachableRoot() } }; - var graph = new GraphDocumentMapper().Load(loaded); + var graph = GraphDocumentMapper.Load(loaded); Assert.That(graph.Nodes, Has.Count.EqualTo(2)); Assert.That(graph.Nodes[0].IsEntryNode, Is.True); @@ -30,7 +30,6 @@ public void Load_AddsMissingEntryAndPreservesTheReachableRoot() [Test] public void CreateDocument_RoundTripsBranchAndGroupData() { - var mapper = new GraphDocumentMapper(); var loaded = new LoadedEditorProjectDocument { Document = new EditorGraphDocument @@ -49,8 +48,8 @@ public void CreateDocument_RoundTripsBranchAndGroupData() } }; - var graph = mapper.Load(loaded); - var document = mapper.CreateDocument("Demo", 2, graph.Nodes, graph.Edges, [], []); + var graph = GraphDocumentMapper.Load(loaded); + var document = GraphDocumentMapper.CreateDocument("Demo", 2, graph.Nodes, graph.Edges, [], []); Assert.That(document.RootNodeId, Is.EqualTo("entry")); Assert.That(document.Nodes.Single(node => node.Id == "choice").Options![0].Text, Is.EqualTo("Continue")); diff --git a/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs b/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs index b1e509b..54d85d2 100644 --- a/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs +++ b/test/GalNet.Editor.Tests/GraphEditingServiceTests.cs @@ -8,6 +8,11 @@ namespace GeneralTest.Editor; public class GraphEditingServiceTests { + private static readonly int[] ThreeEntryIds = [1, 2, 3]; + private static readonly int[] TwoEntryIds = [1, 2]; + private static readonly string[] FirstSuggestions = ["first", "second"]; + private static readonly string[] SecondSuggestions = ["second", "third"]; + [Test] public void InsertEntries_Should_Insert_A_Batch_At_Requested_Position_And_Renumber() { @@ -17,7 +22,7 @@ public void InsertEntries_Should_Insert_A_Batch_At_Requested_Position_And_Renumb Assert.That(inserted, Has.Count.EqualTo(3)); Assert.That(node.Entries, Is.EqualTo(inserted)); - Assert.That(node.Entries.Select(entry => entry.Id), Is.EqualTo(new[] { 1, 2, 3 })); + Assert.That(node.Entries.Select(entry => entry.Id), Is.EqualTo(ThreeEntryIds)); Assert.That(inserted.All(entry => entry.Type == TextEntry.TypeId), Is.True); } @@ -32,7 +37,7 @@ public void InsertEntries_Should_Clamp_Index_And_Reject_Invalid_Count() Assert.That(node.Entries.TakeLast(2), Is.EqualTo(appended)); Assert.That(rejected, Is.Empty); - Assert.That(node.Entries.Select(entry => entry.Id), Is.EqualTo(new[] { 1, 2 })); + Assert.That(node.Entries.Select(entry => entry.Id), Is.EqualTo(TwoEntryIds)); } [Test] @@ -52,7 +57,7 @@ public void Variable_Target_Field_Should_Expose_Definitions_And_Update_The_Entry entry.ConfigureParameterFields(catalog, [], ["first", "second"]); var target = entry.ParameterFields.OfType().Single(); - Assert.That(target.Suggestions, Is.EqualTo(new[] { "first", "second" })); + Assert.That(target.Suggestions, Is.EqualTo(FirstSuggestions)); Assert.That(target.StringValue, Is.EqualTo("first")); target.StringValue = "second"; @@ -60,6 +65,6 @@ public void Variable_Target_Field_Should_Expose_Definitions_And_Update_The_Entry entry.ConfigureParameterFields(catalog, [], ["second", "third"]); Assert.That(entry.ParameterFields.Single(field => field.Id == "target").Suggestions, - Is.EqualTo(new[] { "second", "third" })); + Is.EqualTo(SecondSuggestions)); } } diff --git a/test/GalNet.Editor.Tests/UndoRedoHistoryTests.cs b/test/GalNet.Editor.Tests/UndoRedoHistoryTests.cs index 5d90fc0..7c5a3e0 100644 --- a/test/GalNet.Editor.Tests/UndoRedoHistoryTests.cs +++ b/test/GalNet.Editor.Tests/UndoRedoHistoryTests.cs @@ -6,6 +6,8 @@ namespace GeneralTest.Editor; [TestFixture] public sealed class UndoRedoHistoryTests { + private static readonly string[] PairItems = ["A", "B"]; + [Test] public void ExecuteUndoRedo_PreservesObjectIdentity() { @@ -64,7 +66,7 @@ public void CompositeEditUndoesInReverseOrder() history.Undo(); Assert.That(items, Is.Empty); history.Redo(); - Assert.That(items, Is.EqualTo(new[] { "A", "B" })); + Assert.That(items, Is.EqualTo(PairItems)); } [Test] diff --git a/test/GalNet.Editor.Tests/VariableDefinitionCollectionTests.cs b/test/GalNet.Editor.Tests/VariableDefinitionCollectionTests.cs index b32ac48..1ba63a6 100644 --- a/test/GalNet.Editor.Tests/VariableDefinitionCollectionTests.cs +++ b/test/GalNet.Editor.Tests/VariableDefinitionCollectionTests.cs @@ -5,22 +5,24 @@ namespace GeneralTest.Editor; public class VariableDefinitionCollectionTests { + private static readonly string[] ReorderedNames = ["second", "first"]; + [Test] public void Move_Reorders_TheBackingDefinitions() { var first = new ProjectVariableDefinition { Name = "first" }; var second = new ProjectVariableDefinition { Name = "second" }; var definitions = new List { first, second }; - var collection = new VariableDefinitionCollection(definitions); + var collection = new VariableDefinitions(definitions); Assert.That(collection.Move(second, 0), Is.True); - Assert.That(definitions.Select(x => x.Name), Is.EqualTo(new[] { "second", "first" })); + Assert.That(definitions.Select(x => x.Name), Is.EqualTo(ReorderedNames)); } [Test] public void Add_Creates_A_NameConsistent_DefaultValue() { - var collection = new VariableDefinitionCollection([]); + var collection = new VariableDefinitions([]); var definition = collection.Add("score"); diff --git a/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs b/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs index 61c8623..6ec8c02 100644 --- a/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs +++ b/test/GalNet.IntegrationTests/GalgroupLoaderTests.cs @@ -11,6 +11,8 @@ namespace GeneralTest.Runtime; public class GalgroupLoaderTests { + private static readonly string[] CrossFadeSourceMap = ["cross-fade#1"]; + [Test] public void LoadIntoGroup_PreservesGenericPrimitiveArguments() { @@ -118,7 +120,7 @@ public void Load_RejectsRawDocuments() """), Throws.TypeOf().With.Message.Contains("compiled")); } - public void Load_RejectsPreviousFormatVersion() + public static void Load_RejectsPreviousFormatVersion() { var group = new Group { Id = "test" }; @@ -157,7 +159,7 @@ public void Compile_ExpandsCrossFadeIntoPrimitiveAnimationPlan() Assert.That(result.Document.Entries, Has.Count.EqualTo(1)); Assert.That(result.Document.Entries[0].Id, Is.EqualTo("cross-fade#1")); Assert.That(result.Document.Entries[0].TypeId, Is.EqualTo(PlayAnimationPlanEntry.TypeId)); - Assert.That(result.SourceMap["cross-fade"], Is.EqualTo(new[] { "cross-fade#1" })); + Assert.That(result.SourceMap["cross-fade"], Is.EqualTo(CrossFadeSourceMap)); var plan = result.Document.Entries[0].Arguments.GetProperty("plan"); Assert.That(plan.GetProperty("blocking").GetBoolean(), Is.True); diff --git a/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs b/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs index cd2aabc..df3b05c 100644 --- a/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs +++ b/test/GalNet.IntegrationTests/GameEnginePrimitiveDispatchTests.cs @@ -13,6 +13,10 @@ namespace GeneralTest.Runtime; public class GameEnginePrimitiveDispatchTests { + private static readonly int[] VideoSkipPoints = [5, 12]; + private static readonly string[] MarkDispatch = ["mark"]; + private static readonly string[] VisibleOptions = ["visible"]; + [Test] public void UncompiledAuthoringEntriesAreRejectedInsteadOfSilentlySkipped() { @@ -73,7 +77,7 @@ public async Task Video_segment_advances_hold_engine_and_previous_snapshot_until .RegisterInstruction("test.mark", context => new ImmediatePrimitiveInstance(() => markers++, batchId: context.BatchId)) .BuildView(); using var engine = new GameEngine(GraphWithGroup( - Primitive("video.play", new { asset = "clip", skipPoints = new[] { 5, 12 } }), + Primitive("video.play", new { asset = "clip", skipPoints = VideoSkipPoints }), Primitive("test.mark")), view); await engine.AdvanceAsync(); Assert.That(engine.CreateSaveData().EntryIndex, Is.Zero); @@ -107,7 +111,7 @@ public async Task AdvanceRunsSynchronousContentUntilTheFirstUnfinishedBlocker() Assert.Multiple(() => { - Assert.That(dispatched, Is.EqualTo(new[] { "mark" })); + Assert.That(dispatched, Is.EqualTo(MarkDispatch)); Assert.That(engine.EntryIndex, Is.EqualTo(2)); Assert.That(blocker.SkipCount, Is.Zero); }); @@ -338,7 +342,7 @@ public async Task RecommendedLayerDialogueAndChoiceRunThroughTheNewRuntimeBounda }); await engine.AdvanceAsync(); - Assert.That(presenter.Options, Is.EqualTo(new[] { "visible" })); + Assert.That(presenter.Options, Is.EqualTo(VisibleOptions)); presenter.Select(0); await WaitUntilAsync(() => runtime.SceneState.Layers.Single().AssetId == "hero-b.png"); diff --git a/test/GalNet.Presentation.Tests/AvaloniaAudioLifetimeTests.cs b/test/GalNet.Presentation.Tests/AvaloniaAudioLifetimeTests.cs new file mode 100644 index 0000000..adec04e --- /dev/null +++ b/test/GalNet.Presentation.Tests/AvaloniaAudioLifetimeTests.cs @@ -0,0 +1,52 @@ +using GalNet.Avalonia.GameView.Services; +using GalNet.Core.Assets; +using GalNet.Core.Audio; +using GalNet.Presentation.Abstractions.Audio; + +namespace GeneralTest.Presentation; + +public sealed class AvaloniaAudioLifetimeTests +{ + [TestCase(false), TestCase(true), Explicit("Requires GALNET_AUDIO_LIBVLC pointing to native LibVLC."), NonParallelizable] + public async Task Stop_or_dispose_cancels_loading_before_a_voice_can_start(bool dispose) + { + LibVLCSharp.Shared.Core.Initialize(Environment.GetEnvironmentVariable("GALNET_AUDIO_LIBVLC")!); + using var assets = new DelayedAssets(); + await using var host = new AvaloniaAudioPlaybackHost(assets); + Assert.That(host.InitializationError, Is.Null); + var events = new List(); + host.EventReceived += events.Add; + var request = new AudioBackendRequest(AudioBackendAction.Start, "bgm", "voice", 1, "pending"); + var starting = host.ApplyAsync(request); + await assets.Entered.Task.WaitAsync(TimeSpan.FromSeconds(3)); + + if (dispose) await host.DisposeAsync(); + else await host.ApplyAsync(request with { Action = AudioBackendAction.Stop }); + await starting.WaitAsync(TimeSpan.FromSeconds(3)); + + Assert.Multiple(() => + { + Assert.That(assets.Cancelled, Is.True); + Assert.That(events, Is.Empty, "A cancelled pending load must not start or report playback failure."); + }); + } + + private sealed class DelayedAssets : IAssetManager + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool Cancelled { get; private set; } + public async Task GetFileAsync(string assetId, CancellationToken ct = default) + { + Entered.TrySetResult(); + try { await Task.Delay(Timeout.InfiniteTimeSpan, ct); } + catch (OperationCanceledException) { Cancelled = true; throw; } + return null; + } + public Task> GetFilesAsync(string? typeId = null, CancellationToken ct = default) => throw new NotSupportedException(); + public Task?> AcquireAsync(string assetId, CancellationToken ct = default) where T : class => throw new NotSupportedException(); + public Task?> AcquireByPathAsync(string path, CancellationToken ct = default) where T : class => throw new NotSupportedException(); + public void RegisterDecoder(string resourceTypeId, IAssetDecoder decoder) where T : class => throw new NotSupportedException(); + public void RegisterProvider(IAssetProvider provider) => throw new NotSupportedException(); + public void Dispose() { } + } +} diff --git a/test/GalNet.Presentation.Tests/AvaloniaPresenterTests.cs b/test/GalNet.Presentation.Tests/AvaloniaPresenterTests.cs index eb658c1..b682191 100644 --- a/test/GalNet.Presentation.Tests/AvaloniaPresenterTests.cs +++ b/test/GalNet.Presentation.Tests/AvaloniaPresenterTests.cs @@ -10,9 +10,9 @@ namespace GeneralTest.Presentation; public sealed class AvaloniaPresenterTests { [Test] - public void PageViewComposesRatherThanImplementsPresenterPorts() + public void PresenterCollectionComposesRatherThanImplementsPresenterPorts() { - var pageViewType = typeof(AvaloniaGamePageView); + var pageViewType = typeof(AvaloniaGamePresenters); var presenterTypes = new[] { typeof(IDialoguePresenter), @@ -24,6 +24,8 @@ public void PageViewComposesRatherThanImplementsPresenterPorts() Assert.Multiple(() => { + Assert.That(pageViewType.GetEvent("AdvanceRequested"), Is.Null); + Assert.That(typeof(AvaloniaDialoguePresenter).GetEvent("AdvanceRequested"), Is.Null); Assert.That(pageViewType.GetInterfaces(), Does.Not.Contain(typeof(IDialoguePresenter))); Assert.That(pageViewType.GetInterfaces(), Does.Not.Contain(typeof(IChoicePresenter))); Assert.That(pageViewType.GetInterfaces(), Does.Not.Contain(typeof(ILayerPresenter))); diff --git a/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs b/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs index f7654b0..1fb48bb 100644 --- a/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs +++ b/test/GalNet.Presentation.Tests/CompositeGameViewTests.cs @@ -9,6 +9,8 @@ namespace GeneralTest.Runtime; public class CompositeGameViewTests { + private static readonly int[] DisposalOrder = [2, 1]; + [Test] public async Task AsyncDisposalThroughGameViewInterfaceUsesModuleAsyncCleanup() { @@ -105,11 +107,11 @@ public void DisposalIsReverseOrderedIdempotentAndIncludesEveryModuleDespiteError var view = registration.BuildView(); Assert.That(() => registration.RegisterSchema(Schema("test", "test.run")), Throws.TypeOf()); Assert.That(view.Dispose, Throws.TypeOf()); - Assert.That(order, Is.EqualTo(new[] { 2, 1 })); + Assert.That(order, Is.EqualTo(DisposalOrder)); Assert.DoesNotThrow(view.Dispose); } - private static IEntryModule Schema(string module, params string[] types) => + private static DefaultEntryModule Schema(string module, params string[] types) => new DefaultEntryModule(module, types.Select(type => new DefaultPrimitiveEntryBase(type, DynamicParameterTable.Empty))); private static PrimitiveEntry Entry(string type, object arguments, string? batch = null) => diff --git a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs index 185e322..7d115bd 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModelTests.cs @@ -5,6 +5,11 @@ namespace GeneralTest.Entry; public class EntryModelTests { + private static readonly string[] TextParameters = ["speaker", "content"]; + private static readonly string[] VariableParameters = ["target", "expression"]; + private static readonly string[] SlideDirections = ["Left", "Right", "Up", "Down"]; + private static readonly string[] BlindOrientations = ["Vertical", "Horizontal"]; + private static TargetProfileEntryCatalog Catalog => BuiltinSchemas.CreateCatalog(); [Test] @@ -60,7 +65,7 @@ public void Concrete_Action_Types_Should_Not_Declare_Action_Parameter() => public void TextEntry_Should_Not_Expose_Widget_Parameter() { var definition = Catalog.Get(TextEntry.TypeId); - Assert.That(definition.Parameters.Keys, Is.EquivalentTo(new[] { "speaker", "content" })); + Assert.That(definition.Parameters.Keys, Is.EquivalentTo(TextParameters)); Assert.That(Catalog.Create(TextEntry.TypeId).Values, Does.Not.ContainKey("widget")); } @@ -87,7 +92,7 @@ public void Nvl_Entries_Expose_Explicit_Visibility_Clear_And_Append_Schemas() public void SetVariable_Should_Only_Expose_Target_And_Expression() { var definition = Catalog.Get(SetVariableEntry.TypeId); - Assert.That(definition.Parameters.Keys, Is.EquivalentTo(new[] { "target", "expression" })); + Assert.That(definition.Parameters.Keys, Is.EquivalentTo(VariableParameters)); Assert.That(definition.Parameters["target"], Is.EqualTo(EntryParameterType.VariableName)); Assert.That(definition.Parameters["expression"], Is.EqualTo(EntryParameterType.Expression)); } @@ -119,7 +124,7 @@ public void Slide_Transition_Is_A_Composite_Entry_With_Direction_And_Distance() Assert.That(definition.Kind, Is.EqualTo(EntryKind.Composite)); Assert.That(definition.Parameters["direction"], Is.EqualTo(EntryParameterType.Select)); Assert.That(definition.Parameters["distance"], Is.EqualTo(EntryParameterType.Float)); - Assert.That(definition.Options["direction"], Is.EquivalentTo(new[] { "Left", "Right", "Up", "Down" })); + Assert.That(definition.Options["direction"], Is.EquivalentTo(SlideDirections)); } [Test] @@ -130,7 +135,7 @@ public void Blinds_Transition_Is_A_Composite_Entry_With_Mask_Parameters() Assert.That(definition.Parameters["maskProgram"], Is.EqualTo(EntryParameterType.EffectProgramAsset)); Assert.That(definition.Parameters["bladeCount"], Is.EqualTo(EntryParameterType.Integer)); Assert.That(definition.Parameters["orientation"], Is.EqualTo(EntryParameterType.Select)); - Assert.That(definition.Options["orientation"], Is.EquivalentTo(new[] { "Vertical", "Horizontal" })); + Assert.That(definition.Options["orientation"], Is.EquivalentTo(BlindOrientations)); } [Test] diff --git a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs index f73e5e9..4c991f0 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Entry/EntryModuleCatalogTests.cs @@ -9,6 +9,8 @@ namespace GeneralTest.Entry; public class EntryModuleTests { + private static readonly int[] InvalidSkipPoints = [4, 2]; + [Test] public void BuiltinContractsValidateCustomImplementationsBeforeFactoryInvocation() { @@ -20,7 +22,7 @@ public void BuiltinContractsValidateCustomImplementationsBeforeFactoryInvocation return new ImmediatePrimitiveInstance(() => { }, batchId: context.BatchId); }).BuildView(); Assert.Throws(() => view.Dispatch(new PrimitiveEntry("video.play", - JsonSerializer.SerializeToElement(new { asset = "clip", skipPoints = new[] { 4, 2 } })), + JsonSerializer.SerializeToElement(new { asset = "clip", skipPoints = InvalidSkipPoints })), new GameRuntime(null), CancellationToken.None)); Assert.That(called, Is.False); } diff --git a/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs b/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs index 5b5e7f4..332b06e 100644 --- a/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs +++ b/test/GalNet.Primitives.Builtins.Tests/Runtime/BuiltinPrimitiveInstanceTests.cs @@ -13,6 +13,13 @@ namespace GeneralTest.Runtime; public class BuiltinPrimitiveInstanceTests { + private static readonly string[] FadeCompleted = ["fade"]; + private static readonly string[] BlurStopped = ["blur-1"]; + private static readonly string[] SnowStopped = ["snow"]; + private static readonly int[] TimedBurstCounts = [8, 12]; + private static readonly string[] TimedSnowStopped = ["timed-snow"]; + private static readonly string[] NewLayerIds = ["new"]; + private static readonly JsonSerializerOptions AnimationJsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -55,7 +62,7 @@ public void AnimationAnimateCommitsFinalStateAndCanBeSkippedThroughPresenter() Assert.Multiple(() => { - Assert.That(presenter.CompletedImmediately, Is.EqualTo(new[] { "fade" })); + Assert.That(presenter.CompletedImmediately, Is.EqualTo(FadeCompleted)); Assert.That(instance.IsCompleted, Is.True); }); } @@ -93,7 +100,7 @@ public async Task EffectApplyAndStopUpdateRuntimeStateAndPresenter() Assert.Multiple(() => { Assert.That(runtime.SceneState.ActiveEffects, Is.Empty); - Assert.That(presenter.Stopped, Is.EqualTo(new[] { "blur-1" })); + Assert.That(presenter.Stopped, Is.EqualTo(BlurStopped)); }); } @@ -171,7 +178,7 @@ public async Task ParticlePlayAndStopUpdateRuntimeStateAndPresenter() { Assert.That(runtime.SceneState.ActiveParticleEmitters, Is.Empty); Assert.That(runtime.SceneInstances.TryGet("snow", out _), Is.False); - Assert.That(presenter.Stopped, Is.EqualTo(new[] { "snow" })); + Assert.That(presenter.Stopped, Is.EqualTo(SnowStopped)); }); } @@ -253,8 +260,8 @@ public async Task AnimationPlanCanScheduleParticleLifetimeAndMultipleBursts() Assert.Multiple(() => { Assert.That(particles.Started.Single().InstanceId, Is.EqualTo("timed-snow")); - Assert.That(particles.Bursts.Select(request => request.Count), Is.EqualTo(new[] { 8, 12 })); - Assert.That(particles.Stopped, Is.EqualTo(new[] { "timed-snow" })); + Assert.That(particles.Bursts.Select(request => request.Count), Is.EqualTo(TimedBurstCounts)); + Assert.That(particles.Stopped, Is.EqualTo(TimedSnowStopped)); Assert.That(runtime.SceneState.ActiveParticleEmitters, Is.Empty); }); } @@ -432,7 +439,7 @@ await BuiltinPresentationReplay.ReplayPersistentSceneObjectsAsync( particlePresenter, animationPresenter, layerPresenter, - CancellationToken.None); + cancellationToken: CancellationToken.None); var effectRequest = effectPresenter.Started.Single(); var particleRequest = particlePresenter.Started.Single(); @@ -602,7 +609,7 @@ public async Task AnimationPlanCommitsLayerEventsAndFinalTrackValues() Assert.That(runtime.SceneInstances.TryGet("old", out _), Is.False); Assert.That(runtime.SceneInstances.TryGet("new", out var newLayer), Is.True); Assert.That(newLayer.Opacity, Is.EqualTo(1)); - Assert.That(runtime.SceneState.Layers.Select(layer => layer.Id), Is.EqualTo(new[] { "new" })); + Assert.That(runtime.SceneState.Layers.Select(layer => layer.Id), Is.EqualTo(NewLayerIds)); Assert.That(instance, Is.TypeOf()); Assert.That(animation.Plans.Single().PlaybackHandleId, Is.EqualTo("crossfade")); Assert.That(oldLayer.Opacity, Is.EqualTo(1)); diff --git a/test/GalNet.Runtime.Tests/Engine/GameEngineLifecycleTests.cs b/test/GalNet.Runtime.Tests/Engine/GameEngineLifecycleTests.cs new file mode 100644 index 0000000..a45babe --- /dev/null +++ b/test/GalNet.Runtime.Tests/Engine/GameEngineLifecycleTests.cs @@ -0,0 +1,257 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using GalNet.Core.Entry; +using GalNet.Core.Graph; +using GalNet.Core.Primitives; +using GalNet.Presentation.Abstractions.Execution; +using GalNet.Presentation.Abstractions.View; +using GalNet.Runtime.Engine; + +namespace GeneralTest.Runtime; + +public sealed class GameEngineLifecycleTests +{ + [Test] + public async Task CompletionIsReportedOnceAndFurtherAdvanceDoesNotReplayContent() + { + var calls = 0; + using var view = View(_ => new ImmediatePrimitiveInstance(() => calls++)); + await using var engine = new GameEngine(GroupGraph(), view); + var states = new List(); + engine.StateChanged += change => states.Add(change.State); + + Assert.That(engine.State, Is.EqualTo(GameEngineState.Ready)); + await engine.AdvanceAsync(); + await engine.AdvanceAsync(); + + Assert.Multiple(() => + { + Assert.That(states, Is.EqualTo(new[] { GameEngineState.Running, GameEngineState.Completed })); + Assert.That(calls, Is.EqualTo(1)); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task NaturalBlockerCompletionReportsTheTerminalStateWithoutAnotherClick(bool fail) + { + var blocker = new Blocker(); + var calls = 0; + using var view = View(_ => ++calls == 1 ? blocker : fail + ? throw new InvalidOperationException("continuation failed") + : new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(GroupGraph(2), view); + var terminal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + engine.StateChanged += change => + { + if (change.State is GameEngineState.Completed or GameEngineState.Faulted) terminal.TrySetResult(change); + }; + await engine.AdvanceAsync(); + + blocker.Finish(); + var result = await terminal.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(result.State, Is.EqualTo(fail ? GameEngineState.Faulted : GameEngineState.Completed)); + if (fail) Assert.That(result.Error!.Message, Is.EqualTo("continuation failed")); + } + + [TestCase(0, GameEngineState.Completed)] + [TestCase(7, GameEngineState.Faulted)] + public async Task ChoiceResultReportsTheTerminalState(int selected, GameEngineState expected) + { + var choice = new PendingChoicePresenter(); + using var view = View(_ => new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(ChoiceGraph(), view, choicePresenter: choice); + var terminal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + engine.StateChanged += change => + { + if (change.State is GameEngineState.Completed or GameEngineState.Faulted) terminal.TrySetResult(change); + }; + await engine.AdvanceAsync(); + + choice.Result.SetResult(selected); + + var result = await terminal.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(result.State, Is.EqualTo(expected)); + Assert.That(result.Error is not null, Is.EqualTo(expected == GameEngineState.Faulted)); + } + + [Test] + public async Task ChoiceFailureIsReportedImmediately() + { + var choice = new PendingChoicePresenter(); + using var view = View(_ => new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(ChoiceGraph(), view, choicePresenter: choice); + var fault = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + engine.StateChanged += change => { if (change.Error is { } error) fault.TrySetResult(error); }; + await engine.AdvanceAsync(); + + choice.Result.SetException(new InvalidOperationException("choice failed")); + + Assert.That((await fault.Task.WaitAsync(TimeSpan.FromSeconds(2))).Message, Is.EqualTo("choice failed")); + } + + [Test] + public async Task StopCancelsAndDrainsEvenAnUncooperativeChoicePresenter() + { + var choice = new PendingChoicePresenter(); + using var view = View(_ => new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(ChoiceGraph(), view, choicePresenter: choice); + var states = new ConcurrentQueue(); + engine.StateChanged += change => states.Enqueue(change.State); + await engine.AdvanceAsync(); + + await engine.StopAsync().WaitAsync(TimeSpan.FromSeconds(2)); + choice.Result.SetResult(0); + await engine.StopAsync(); + + Assert.Multiple(() => + { + Assert.That(choice.Token.IsCancellationRequested, Is.True); + Assert.That(engine.IsRunning, Is.False); + Assert.That(states, Is.EqualTo(new[] { GameEngineState.Running, GameEngineState.Stopped })); + Assert.That(engine.CurrentNodeId, Is.EqualTo("choice")); + }); + Assert.That(await engine.AdvanceAsync(), Is.False); + Assert.Throws(() => engine.RestoreFrom(engine.CreateSaveData())); + } + + [Test] + public async Task StopWaitsForInFlightAdvanceAndPreventsQueuedAndFollowingInstructions() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + var calls = 0; + CancellationToken scope = default; + using var view = View(context => new ImmediatePrimitiveInstance(() => + { + scope = context.ScopeCancellation; + Interlocked.Increment(ref calls); + entered.SetResult(); + if (!release.Wait(TimeSpan.FromSeconds(3))) throw new TimeoutException(); + })); + await using var engine = new GameEngine(GroupGraph(2), view); + var advance = Task.Run(() => engine.AdvanceAsync()); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var queued = engine.AdvanceAsync(); + var stop = engine.StopAsync(); + try + { + Assert.That(stop.IsCompleted, Is.False); + Assert.That(SpinWait.SpinUntil(() => scope.IsCancellationRequested, TimeSpan.FromSeconds(2)), Is.True); + } + finally { release.Set(); } + + await Task.WhenAll(advance, queued, stop).WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(calls, Is.EqualTo(1)); + Assert.That(engine.State, Is.EqualTo(GameEngineState.Stopped)); + } + + [Test] + public async Task RestoreDiscardsOldChoiceAndAllowsAFreshRun() + { + var choice = new PendingChoicePresenter(); + using var view = View(_ => new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(ChoiceGraph(), view, choicePresenter: choice); + var snapshot = engine.CreateSaveData(); + await engine.AdvanceAsync(); + var oldResult = choice.Result; + var oldToken = choice.Token; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + engine.StateChanged += change => + { + if (change.State is GameEngineState.Completed or GameEngineState.Faulted) completion.TrySetResult(change); + }; + + engine.RestoreFrom(snapshot); + oldResult.SetException(new InvalidOperationException("obsolete")); + await engine.AdvanceAsync(); + choice.Result.SetResult(0); + var terminal = await completion.Task.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(oldToken.IsCancellationRequested, Is.True); + Assert.That(engine.CurrentNodeId, Is.EqualTo("choice")); + Assert.That(terminal.State, Is.EqualTo(GameEngineState.Completed)); + Assert.That(terminal.Error, Is.Null); + } + + [Test] + public async Task ObserversRunOutsideTheExecutionLockAndCannotFaultTheStory() + { + using var view = View(_ => new ImmediatePrimitiveInstance()); + await using var engine = new GameEngine(GroupGraph(), view); + var snapshot = engine.CreateSaveData(); + engine.StateChanged += _ => throw new InvalidOperationException("observer failed"); + engine.StateChanged += change => + { + if (change.State == GameEngineState.Completed) engine.RestoreFrom(snapshot); + }; + + await engine.AdvanceAsync().WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.That(engine.State, Is.EqualTo(GameEngineState.Ready)); + } + + [Test] + public async Task StopAndDisposeDoNotOwnTheExecutionView() + { + var module = new OwnedModule(); + await using var view = new GameRegistration() + .RegisterSchema(new DefaultEntryModule("test", [new DefaultPrimitiveEntryBase("test.run", DynamicParameterTable.Empty)])) + .RegisterImplementation(module).BuildView(); + var engine = new GameEngine(GroupGraph(), view); + + await engine.StopAsync(); + await engine.DisposeAsync(); + await engine.DisposeAsync(); + + Assert.That(module.Disposals, Is.Zero); + await view.DisposeAsync(); + Assert.That(module.Disposals, Is.EqualTo(1)); + } + + private static CompositeGameView View(Func factory) => + new GameRegistration() + .RegisterSchema(new DefaultEntryModule("test", [new DefaultPrimitiveEntryBase("test.run", DynamicParameterTable.Empty)])) + .RegisterInstruction("test.run", factory).BuildView(); + + private static Graph GroupGraph(int count = 1) => new() + { + RootNodeId = "main", + Nodes = { new Group { Id = "main", Entries = Enumerable.Range(0, count) + .Select(_ => new PrimitiveEntry("test.run", JsonSerializer.SerializeToElement(new { }))).Cast().ToList() } } + }; + + private static Graph ChoiceGraph() => new() + { + RootNodeId = "choice", + Nodes = { new Branch { Id = "choice", BranchType = BranchType.Choice, Options = { new BranchOption { Text = "Continue" } } } } + }; + + private sealed class Blocker : PrimitiveInstance + { + public override bool IsBlocking => true; + public override bool IsSkippable => false; + protected override void OnDispatch() { } + public void Finish() => TryComplete(); + } + + private sealed class PendingChoicePresenter : IChoicePresenter + { + public TaskCompletionSource Result { get; private set; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public CancellationToken Token { get; private set; } + public Task ChooseAsync(IReadOnlyList options, CancellationToken cancellationToken) + { + Token = cancellationToken; + Result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return Result.Task; + } + } + + private sealed class OwnedModule() : PrimitiveModule([new PrimitiveImplementation("test.run", _ => new ImmediatePrimitiveInstance())]) + { + public int Disposals { get; private set; } + public override void Dispose() { Disposals++; base.Dispose(); } + } +} From e2f055e3210a49c260c6e84db95e2ee18313e5d0 Mon Sep 17 00:00:00 2001 From: sakuya Date: Sat, 10 Oct 2026 21:24:11 +0800 Subject: [PATCH 05/10] feat(i18n): add dynamic player UI localization and persisted settings --- .agents/agent-knowledge.md | 1 + ...05-dynamiclocalization-avalonia-version.md | 30 ++++ ...20261010-06-i18n-key-authoring-boundary.md | 13 ++ Directory.Packages.props | 3 +- docs/spec/control.md | 8 + docs/spec/i18n.md | 13 +- features/F-20261010-02-runtime-i18n/design.md | 153 ++++++++++++++++ .../F-20261010-02-runtime-i18n/feature.md | 78 ++++++--- .../F-20261010-02-runtime-i18n/phase-plan.md | 164 ++++++++++++++++++ .../F-20261010-02-runtime-i18n/summary.md | 29 ++++ scripts/run-avalonia-sample.ps1 | 7 +- src/Editor/GalNet.Editor/packages.lock.json | 18 +- ...oniaGameViewServiceCollectionExtensions.cs | 1 + .../MarkupExtensions/PlayerTextExtension.cs | 13 ++ .../Page/AboutPage.axaml | 7 +- .../Page/AudioGalleryPage.axaml | 5 +- .../Page/CgGalleryImagePage.axaml | 3 +- .../Page/CgGalleryPage.axaml | 5 +- .../Page/GalleryPage.axaml | 7 +- .../Page/GamePage.axaml | 13 +- .../Page/MissingGalleryPage.axaml | 3 +- .../Page/SaveSlotsPage.axaml | 7 +- .../Page/ScreenshotDialog.axaml | 15 +- .../Page/SettingsPage.axaml | 36 +++- .../Page/TitlePage.axaml | 13 +- .../Page/VideoGalleryPage.axaml | 5 +- .../Page/VideoGalleryPlayerPage.axaml | 3 +- .../Services/IPlayerSettingsService.cs | 61 +++++++ .../ViewModels/SettingsPageViewModel.cs | 6 +- src/Samples/GalNet.Sample.Avalonia/App.axaml | 6 + .../GalNet.Sample.Avalonia/App.axaml.cs | 23 +++ .../GalNet.Sample.Avalonia.csproj | 2 + .../Localization/en-US.json | 42 +++++ .../Localization/zh-CN.json | 42 +++++ .../MarkupExtensions/LocalizeExtension.cs | 36 ++++ .../Services/SampleGameSessionService.cs | 7 +- .../Services/SampleLocalizationService.cs | 44 +++++ .../Services/SamplePlayerSettingsService.cs | 87 ++++++++++ .../Views/DebugLogWindow.axaml | 7 +- .../Views/MainWindow.axaml | 19 +- .../GalNet.Sample.Avalonia/packages.lock.json | 20 +++ .../GalNet.Core/I18n/CharacterCatalog.cs | 48 +++++ src/Shared/GalNet.Core/I18n/I18nKey.cs | 20 ++- .../BuiltinRegistration.cs | 13 +- .../Modules/BuiltinDialogueModule.cs | 17 +- .../Modules/BuiltinNvlModule.cs | 2 +- .../I18n/CharacterCatalogTests.cs | 31 ++++ test/GalNet.Core.Tests/I18n/I18nTests.cs | 4 +- test/GalNet.Editor.Tests/packages.lock.json | 20 ++- .../GameEnginePrimitiveDispatchTests.cs | 20 ++- .../GameTextLocalizationTests.cs | 109 ++++++++++++ .../SampleLocalizationTests.cs | 105 +++++++++++ .../packages.lock.json | 21 +++ 53 files changed, 1338 insertions(+), 127 deletions(-) create mode 100644 .agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md create mode 100644 .agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md create mode 100644 features/F-20261010-02-runtime-i18n/design.md create mode 100644 features/F-20261010-02-runtime-i18n/phase-plan.md create mode 100644 features/F-20261010-02-runtime-i18n/summary.md create mode 100644 src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs create mode 100644 src/Presentation/GalNet.Avalonia.GameView/Services/IPlayerSettingsService.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Localization/en-US.json create mode 100644 src/Samples/GalNet.Sample.Avalonia/Localization/zh-CN.json create mode 100644 src/Samples/GalNet.Sample.Avalonia/MarkupExtensions/LocalizeExtension.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Services/SampleLocalizationService.cs create mode 100644 src/Samples/GalNet.Sample.Avalonia/Services/SamplePlayerSettingsService.cs create mode 100644 src/Shared/GalNet.Core/I18n/CharacterCatalog.cs create mode 100644 test/GalNet.Core.Tests/I18n/CharacterCatalogTests.cs create mode 100644 test/GalNet.IntegrationTests/GameTextLocalizationTests.cs create mode 100644 test/GalNet.IntegrationTests/SampleLocalizationTests.cs diff --git a/.agents/agent-knowledge.md b/.agents/agent-knowledge.md index 0836f8f..94f258a 100644 --- a/.agents/agent-knowledge.md +++ b/.agents/agent-knowledge.md @@ -76,3 +76,4 @@ - [项目移动后必须清理旧构建中间产物](lessons/L-20260929-08-project-move-stale-restore-path.md):移动 .NET 项目后先删除随项目移动的 `bin/obj`,再从新路径 locked restore,避免旧绝对路径缓存重建旧目录和 lock 文件。 - [动态视频图层的加载与帧生命周期](lessons/L-20261010-01-video-layer-lifetime.md):待加载源先登记,hide/replace 取消加载;帧租约与独立播放 identity 防止晚发布和旧完成误清理。 +- [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` 适配层。 diff --git a/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md b/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md new file mode 100644 index 0000000..0b01abe --- /dev/null +++ b/.agents/lessons/L-20261010-05-dynamiclocalization-avalonia-version.md @@ -0,0 +1,30 @@ +# DynamicLocalization Avalonia package must match the host Avalonia major version + +状态:confirmed + +来源 Feature:F-20261010-02-runtime-i18n + +## 现象 + +`DynamicLocalization.Avalonia` 2.1.0 的 NuGet manifest 为 net6.0 至 net9.0 target groups 声明了 `Avalonia (>= 11.0.0)`,其 assembly 在载入时要求 `Avalonia.Markup.Xaml, Version=11.0.0.0`。GalNet 的 Avalonia Sample 已使用 Avalonia 12.0.5,因此不能直接采用该版本的 XAML markup extension。2.1.2 虽将依赖范围更新为 `Avalonia [11.0.0, 13.0.0)`,但运行时仍调用 Avalonia 12 已移除的 `Binding(string, BindingMode)` 构造函数。 + +## 根因 + +该包的 2.1.0 release 尚未针对 Avalonia 12 编译;Core package 是平台无关的,不具有此 Avalonia assembly dependency。2.1.2 的包元数据虽然允许 Avalonia 12,但实际 `LocalizeExtension` 仍为旧 `Binding` API 编译,因此 restore 和 build 都不能证明可启动。 + +## 正确做法 + +保留 Avalonia 12,不为使用 markup extension 降级全仓 Avalonia。Sample 只保留 `DynamicLocalization.Core` 的 `CultureService`,自有 extension 按官方 demo 绑定 Core 的 `LocalizedString.Value`,将平台 binding 换成 Avalonia 12 `ReflectionBinding`。`LocalizedString` 监听 culture/provider change,不能只在自己的 ApplyCulture wrapper 中发通知,否则外部直接改 culture 时不会更新。任何未来重新引入第三方 extension 的方案都必须做真实启动验证。 + +## 适用范围 + +任何将 `DynamicLocalization.Avalonia` 引入当前 Avalonia 12 GalNet 应用的 feature。 + +## 不要做什么 + +不要仅因 package 可 restore 就假定其 XAML extension 可与当前 Avalonia major 运行,也不要为单个本地化功能降级共享 Avalonia 依赖。 + +## 证据 + +- 用户于 2026-10-10 报告的 Sample 启动堆栈:`LocalizeExtension.ProvideValue` 调用缺失的 `Avalonia.Data.Binding..ctor(string, BindingMode)`。 +- `GalNet.Sample.Avalonia` 在移除 `DynamicLocalization.Avalonia`、保留 Core 并改用自有适配层后成功构建;输出目录不再有 `DynamicLocalization.Avalonia.dll`。 diff --git a/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md b/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md new file mode 100644 index 0000000..ff10f02 --- /dev/null +++ b/.agents/lessons/L-20261010-06-i18n-key-authoring-boundary.md @@ -0,0 +1,13 @@ +# 已存储的 i18n key 与执行位置 + +状态:confirmed(用户确认,2026-10-10) + +来源:F-20261010-02-runtime-i18n。 + +现象:实现讨论把 raw ID 当成需要透传 Runtime 的本地化身份,随后又提出在编译时将作者文本改写成 key;两者均偏离用户确认的契约。 + +根因:混淆了 Editor 生成 key 的输入与 Runtime 消费的完整 key。每个 group 的 Entry.Id 在编译前后都表示位置,组合展开会改变编号。content 在 raw 阶段已是 key。 + +正确做法:未来 Editor 自动生成 key 时使用 raw 阶段的 ID;key 保存后原样编译、展开、执行。不要按运行时序号重新生成,不增加仅为解析 key 使用的 SourceEntryId 属性,不擅自把 raw content 解释为源文本。 + +适用:GalNet 内置文本条目与未来翻译编辑工具。自定义参数的本地化语义仍由 implement 决定。 diff --git a/Directory.Packages.props b/Directory.Packages.props index aa52da8..fdee1e9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,8 +22,7 @@ - - + diff --git a/docs/spec/control.md b/docs/spec/control.md index a686d28..40741e2 100644 --- a/docs/spec/control.md +++ b/docs/spec/control.md @@ -38,6 +38,14 @@ Gallery 使用独立的 `IGalleryPageRegistry`,以精确 Gallery `typeId` 映射到页面 ViewModel 和 View。`Add` 注册新类型,`Replace` 显式覆盖既有类型;多个 type ID 可以映射到同一页面。该注册同时写入普通 ViewModel→View registry 和 DI,构建后不可修改。 +### 设置与界面文本 + +`SettingsPageViewModel` 通过通用的 `IPlayerSettingsService.Items` 获取设置项,不绑定语言专用服务。页面支持 `ChoicePlayerSetting` 和 `RangePlayerSetting`;默认服务提供文本速度,宿主可在 DI 中替换服务并提供其他设置。设置应用、恢复和持久化由宿主负责,共享页面不读取配置文件。 + +共享页面的静态文案使用 `PlayerTextExtension`,未接入宿主本地化时显示默认英文。官方 Sample 提供 `LocalizedString` 并绑定其 `Value`,沿用 DynamicLocalization 的 Culture/Provider 变更通知机制;宿主设置标签也可以使用同样的动态对象。该绑定不会自动翻译任意业务字符串或剧情内容。 + +Sample 在游戏会话初始化成功后,把设置接入现有 Player 变量存储,恢复语言与文本速度。语言变更(包括直接调用 CultureService)立即刷新绑定并排队持久化;清空玩家状态和销毁会话前等待写入完成。 + `GalleryNavigationService` 不按资源类型推断页面:没有有内容类型时入口不可用,只有一个时直接打开该页,多个时先进入类型选择页;未注册页面会显示诊断页。CG、视频和音频是独立页面/媒体状态,不使用 renderer enum 或单一多媒体 ViewModel。 ## 运行期呈现边界 diff --git a/docs/spec/i18n.md b/docs/spec/i18n.md index edf810e..2234439 100644 --- a/docs/spec/i18n.md +++ b/docs/spec/i18n.md @@ -5,20 +5,25 @@ 编辑器 UI 与游戏文本使用不同的边界。 - 编辑器 UI 使用 `IEditorLocalizationService`。它通过 DynamicLocalization 从 `GalNet.Editor/Localization` 的嵌入 JSON 资源加载 `zh-CN`、`en-US`、`ja-JP`、`ko-KR`,切换语言时通知 `CurrentCulture` 与索引器绑定。 -- Runtime 不依赖 DynamicLocalization 或某种 JSON 结构。宿主向 `GameEngine` 提供 `ITextResolver`,`EntryContext.GetText()` 用它解析文本;未提供时使用 `PassthroughTextResolver`,即把输入原样返回。 +- Avalonia Sample UI 使用 `DynamicLocalization.Core` 2.1.2 与 Sample 自有的 Avalonia 12 绑定适配层,从 Sample 程序集 `Localization/` 的嵌入 JSON 加载 `en-US` 与 `zh-CN`;`{loc:Localize ...}` 绑定读取同一 culture service。该 Sample UI 来源与游戏包 `I18n/` 内容仍是不同边界。 +- Runtime 不依赖 DynamicLocalization 或某种 JSON 结构。宿主向 `GameEngine` 提供 `ITextResolver`;内置 dialogue、NVL 和分支选项在展示前调用它。未提供时使用 `PassthroughTextResolver`,即把 key 原样返回。 -因此剧情文本字段(例如 `.galgroup` 内的 `speaker`、`content`,以及图中的分支选项)既可以直接保存文本,也可以保存宿主能识别的键。Runtime 不会自动生成、扫描或覆写游戏翻译文件。 +内置 `content` 与分支选项文本从 raw 开始就是 key;编译和复合展开原样传递。`speaker` 是人物 ID,空值表示无说话人。宿主把 `CharacterCatalog` 传给 `AddBuiltins`、`AddBuiltinImplementations` 或 `AddBuiltinDialogue`;catalog 记录唯一 `Id` 和非空 `DisplayNameKey`,内置对话先查人物再解析姓名 key,未知人物 ID 抛出内容错误。自定义参数如何本地化由 implement 决定。 ## 键与稳定性 +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 后恢复偏好;正常关闭等待已排队的保存,清空玩家数据同时删除这些偏好。首次语言选择页仍未实现。 + `I18nKey` 是点分隔键的轻量封装。推荐使用稳定的 Group/Branch ID,而不是可变的显示名称: ```csharp -I18nKey.ForEntry(groupId, entryId, "content"); +I18nKey.ForEntry(groupId, rawEntryId, "content"); I18nKey.ForBranchOption(branchId, optionIndex, "text"); ``` -这会生成如 `group-1.entry_3.content` 和 `branch-1.option_0.text` 的键。键的命名、翻译文件位置、回退策略及玩家覆盖方式均由具体宿主决定;它们不属于当前 Core/Runtime 文件格式的固定规范。 +条目 helper 接收 raw ID(字符串或整数),生成如 `group.group-1.entry.3.content` 的键;分支 helper 生成 `branch-1.option_0.text`。未来 Editor 在生成 key 时使用 raw 阶段的 ID;保存后的 key 不随位置重排或组合展开改变。编译前后的整数 `Entry.Id` 表示所在阶段的顺序位置,无需为本地化新增 source ID 属性。翻译文件加载与回退由宿主提供。 ## 项目设置 diff --git a/features/F-20261010-02-runtime-i18n/design.md b/features/F-20261010-02-runtime-i18n/design.md new file mode 100644 index 0000000..319f5ce --- /dev/null +++ b/features/F-20261010-02-runtime-i18n/design.md @@ -0,0 +1,153 @@ +# F-20261010-02 设计:游戏文本国际化与 DynamicLocalization + +**状态:Proposed** + +## 结论 + +首期不按 galgroup 动态装卸翻译,也不为每个 group 增加 i18n 文件 ID 列表或独立 i18n 包元数据。游戏启动时一次性、递归读取包内 `I18n/` 的 JSON,并按确定性顺序逐个交给 DynamicLocalization 动态加载;外部目录的 JSON 在其后加载,以库的覆盖语义覆盖同 culture、同 key 的内置值。 + +这让 `ITextResolver` 始终能按照当前 culture 解出任意已加载 group 的 key,避免进入 group 时异步加载、离开 group 后丢失已显示文本、以及保存/读档时 catalog 状态不一致的问题。文本 JSON 很小,按 group 拆文件只服务于作者组织和 diff,不影响运行时载入策略。 + +DynamicLocalization 仅在 Sample 的宿主层使用:它拥有 culture 切换和 provider 注册,Sample 自有 Avalonia 12 `ReflectionBinding` 适配层负责 UI 绑定。Core/Runtime 继续只依赖 `ITextResolver`。`DynamicLocalization.Avalonia` 2.1.2 虽声明兼容 Avalonia 11–12,却在运行时调用了 Avalonia 12 已移除的 Binding 构造函数,因此不使用该 XAML extension。宿主把同一个文化服务同时用于 UI 与 Runtime adapter,因此外部 JSON 可以覆盖 UI 与游戏文本;键命名空间避免意外碰撞。 + +## 当前事实 + +- Runtime 已有 `ITextResolver.Resolve(string)`;未提供实现时会回显输入 key。 +- `GameContent` 目前只有 Graph、Assets、Gallery 与 ProjectSettings;两个内容 provider 尚未读取 `I18n/`。 +- 导出器与内容构建器已递归复制 `I18n/`,并把它作为非资源的特殊内容随 `.galpak` 分发。 +- `ProjectSettings.Speakers` 是 Editor 自动补全用的显示名字符串列表,不是人物表;当前 `dialogue.text` 的 `speaker` 参数也是字符串。 +- Sample 的玩家变量独立于存档槽,保存到 profile 目录的 `player-variables.json`;有游戏目录时默认 profile 为 `/.galnet`。 + +## 目标与边界 + +本设计覆盖: + +- Avalonia Sample UI 的嵌入 JSON、本机语言选择与运行时切换。 +- 游戏包中人物表、人物名称、galgroup 对话和分支选项的键化文本。 +- 包内内置 JSON 与安装目录外部 JSON 的递归合并、回退和覆盖。 +- 后续宿主注册自定义文本源的扩展点。 + +本设计不覆盖:Editor i18n 迁移、翻译模板导出、Editor 翻译编辑器、图片/语音/图层等二进制本地化、网络下载与热更新协议。 + +## 统一键空间与文件布局 + +所有本地化文件保留 DynamicLocalization 的 JSON 格式和 `.json` 扩展名;不采用 `x_zh-CN.png` 一类资源后缀约定。建议键空间为: + +```text +ui.. Sample UI +character..name 人物显示名 +group..entry.. 对话/NVL 等条目字段 +branch..option..text 选项文本 +``` + +项目内置文件按 culture 集中而按内容分片: + +```text +I18n/ + catalog/ + characters.json + zh-CN/ + characters.json + groups/ + intro.json + chapter-01.json + en-US/ + characters.json + groups/ + intro.json +``` + +`I18n/catalog/characters.json` 是不随语言重复的 character catalog;它只保存角色 ID 与名称 key。`I18n//characters.json` 才保存各 culture 的名称文本。每个文本 JSON 是普通平铺或嵌套 key/value JSON;其 culture 由目录名提供,文件名不承载 locale。`groups/.json` 是作者组织约定,不是 Runtime 必需的引用关系。导出器已经递归复制 `I18n/`,不需要将这些文件写入资源 PAK。 + +Sample UI 使用程序集嵌入的标准 DynamicLocalization JSON,例如 `Localization/zh-CN.json`;它和游戏的 `I18n/` 不共享物理位置,但在运行时合并到同一键空间。约定 `ui.` 只由 Sample UI 使用,游戏文本绝不占用该前缀。 + +外部覆盖目录建议由安装器在解压后创建为: + +```text +/I18n/External//**/*.json +``` + +它与包内文件同样按 culture、递归读取。安装/更新必须保留该目录;若未来安装器以重建安装目录的方式更新,则应在重建前保留并恢复 `I18n/External`,而不是把第三方翻译与包内文件混放。 + +## 人物表 + +新增游戏内容的 character catalog,而不是把 `ProjectSettings.Speakers` 扩展成隐式人物表。最小人物记录仅包含: + +```text +CharacterId 稳定、项目内唯一,供剧情引用 +DisplayNameKey 必填,例如 character.alice.name +``` + +`dialogue.text`、`dialogue.textVoice` 等文本条目的 `speaker` 参数存储 `CharacterId`。Runtime 或 built-in dialogue 模块在调用 presenter 前解析人物 ID 到 DisplayNameKey,再经 `ITextResolver` 解析成显示名。缺失人物 ID 应为内容验证错误,而不是把 ID 当名称静默显示。 + +旧内容的 speaker 显示名需要由作者显式迁移成人物 ID,并建立姓名 key;不自动把显示字符串当人物记录。内置对话实现拒绝未知非空人物 ID。人物 catalog 在内容层加载并由宿主传入模块,文件读取留在 Phase 3;Editor 自动补全的接线在内容加载完成后处理。 + +## 条目 key 的稳定性 + +每个 group 的 entry 在编译前后都有表示顺序位置的 `Entry.Id`;组合条目展开会增加 entry 数量并重新编号。`content` 在 raw 中已经是完整 i18n key,编译器与 Runtime 原样传递,禁止根据执行位置重新生成。 + +未来 Editor 自动生成 key 时,使用 raw 阶段的 ID 作为 `group..entry..` 的一段;`sourceEntryId` 仅指生成时的输入,不新增持久化属性。key 一旦保存就独立于顺序位置,后续重排和复合展开不改变它。现有序列化的 raw string `id` 与 `#<展开序号>` 编译 envelope ID 继续保留原协议,不承担运行时文本解析职责。 + +## 哪些字段是 key + +不能把任意 `string` 参数自动视为 i18n key:许多 entry 的字符串是资源 ID、变量名、表达式、轨道 ID 或扩展自己的协议值。内置 gameplay schema 必须显式声明可本地化字段:人物显示名经 character catalog 解析;`dialogue.text` / `dialogue.textVoice` 的 `content`、NVL 内容和 branch option 文本均为 key。`speaker` 仅是 CharacterId,不是文本 key。 + +自定义 entry 不受这套内置字段约定约束:其参数 schema 只负责描述参数形态,是否把某个参数视为 i18n key、如何解析和何时解析,完全由该 entry 的 implement 决定。框架不对自定义字符串做推断或转换。这样“游戏文本一律是 key”的规则只约束内置 gameplay 文本字段,不会破坏资源和插件协议。 + +## 加载、覆盖与回退 + +启动某游戏时,宿主的游戏 i18n 加载协调器(名称为设计建议)按如下顺序把每个 JSON 单独交给 DynamicLocalization: + +```text +embedded Sample UI JSON + -> package I18n//**/*.json + -> external I18n/External//**/*.json + -> DynamicLocalization culture service + -> UI binding / ITextResolver adapter +``` + +合并顺序固定: + +1. embedded Sample UI; +2. 包内游戏 JSON; +3. 外部 JSON。 + +同一层内部按相对路径的 ordinal 顺序加载,后加载者覆盖先加载者;后一层覆盖前一层。这使外部 JSON 自然可以覆盖 `ui.*` 和游戏 key。加载错误的 JSON 不加入文化服务,宿主可以记录诊断但继续以其余来源启动。 + +文本解析顺序固定:请求 culture → 父 culture → `ProjectSettings.TargetLocale` → 原 key。默认语言必须包含所有运行时必需的游戏 key;内容构建/验证应检查该不变量。Runtime 不承担 culture 查找或文件 I/O,adapter 负责这一逻辑。 + +DynamicLocalization 的逐文件动态加载与重复 key 的覆盖语义必须在引入选定 NuGet 版本时以 characterization test 确认。首期不在 GalNet 侧预合并 JSON,也不依赖未定义的 provider 注册或文件系统枚举顺序;加载协调器显式排序并逐个调用该库的加载 API。若该库版本不能表达后加载覆盖语义,才退回一个内部聚合 adapter。 + +## 为什么不在载入 galgroup 时动态载入 + +候选方案是每个 group meta 保存 JSON 文件 ID,进入 group 时注册相应 provider,并在离开后卸载。首期不选它,因为: + +- `ITextResolver` 只接收 key,不带 group 上下文;选择、历史 NVL、回放和异步展示可能在 group 切换后仍需旧文本。 +- provider 注册/卸载造成 culture 切换与读档重放的时序状态,需要额外可存档或可重建的加载集。 +- group 文本通常远小于图像、音频和视频;一次性载入更可预测,且与当前递归复制整个 `I18n/` 的发布模型一致。 + +因此 group 的 i18n 文件不需要在 `.galgroup` meta 中保存 ID。未来若有测得的文本规模问题,可新增按 group 的 lazy catalog cache;它应由完整的文档/运行状态管理,而不是把文件路径泄漏给 Runtime。 + +## 包、更新与元数据 + +现有 `.galpak` 导出已对 `I18n/**` 逐文件打包,安装过程已有包 manifest 的文件完整性职责。因此首期不创建“这是 i18n 包”的额外元数据文件,也不创建独立 i18n PAK。 + +未来热更新或补丁若确实需要独立下载、版本协商或卸载翻译层,可在那个 feature 中引入版本化 localization manifest。其触发条件应是实际的补丁/网络分发需求,而不是为静态本地 JSON 提前增加协议。 + +## 首次语言选择 + +Sample 读取本机 profile 的玩家变量:首次启动计数为零时,以嵌入 UI 的 bootstrap culture 显示语言选择页。用户确认后在同一操作中持久化所选 locale,再增加启动计数。取消或异常不增加计数,因此下次仍显示选择页。后续启动读取所选 locale 并在创建 GameEngine 前设置 catalog/current culture;重置玩家变量会重新触发该流程。 + +## 验收影响 + +- Character catalog 验证唯一 ID、非空 display-name key,并由内置对话拒绝未知的 speaker ID。 +- 同一 culture 下的多个内置与外部 JSON 的覆盖结果可复现,且不依赖枚举顺序。 +- UI markup 与 Runtime dialogue/choice 使用同一选择 culture;外部文件可以覆盖两者。 +- 默认语言缺失必需游戏 key 时,内容验证失败;非默认语言缺失时按回退链解析。 +- 首次选择、确认、取消、重启与重置玩家变量均可自动化验证。 + +## 风险与后续决策 + +- 需在实现前 pin DynamicLocalization 版本并验证其 Avalonia markup、动态 provider 和 culture-change 行为。 +- Character catalog 的具体持久化文件(例如 `I18n/characters.json` 的 metadata 与 display key 分离,或独立 `characters.json`)需要以 Editor/项目设置序列化的现有约束选择;它不应随着 culture 重复。 +- 外部目录在包更新时的保留需要由安装器实现测试保证。 diff --git a/features/F-20261010-02-runtime-i18n/feature.md b/features/F-20261010-02-runtime-i18n/feature.md index bc30fae..817a9af 100644 --- a/features/F-20261010-02-runtime-i18n/feature.md +++ b/features/F-20261010-02-runtime-i18n/feature.md @@ -1,6 +1,10 @@ # F-20261010-02 Runtime i18n with DynamicLocalization -**Status:** discovery +**Status:** implementation in progress + +Phase 1 和 Phase 2 已有构建/测试证据。Phase 2 完成人物 catalog 与内置文本解析;Phase 3 的文件加载和 Sample 接线、Phase 4 外部覆盖、Phase 5 首次选择仍未实现。 + +**Selected package:** [DynamicLocalization.Core](https://github.com/StarryXYJ/DynamicLocalization) 2.1.2, MIT-licensed. The Sample uses its JSON provider, culture service, and `LocalizedString`; an Avalonia 12 binding adapter replaces the incompatible platform extension. ## Original goal @@ -10,7 +14,6 @@ The proposed runtime loading model is: - Load embedded i18n resources from designated folders. - Create an empty, documented external-language directory beside an installed, extracted game package. -- Export translation templates for third-party translators to fill in. - Recursively discover third-party JSON localization files in that external directory at runtime. - Keep interfaces in Core and register/pass implementations through the DI composition root, because DynamicLocalization is DI-based. @@ -20,7 +23,6 @@ The proposed content model is: - Character records store a stable i18n key for their display name. - Dialogue and other repeated narrative content stored in `.galgroup` use i18n keys. - A galgroup owns its associated localization JSON files. -- Locale-specific binary resources such as voice and layers may use a shared basename with a locale suffix, for example `x.png`, `x_zh-CN.png`, and `x_en.png`, with locale fallback when a variant is absent. ## Problem @@ -30,10 +32,10 @@ The current repository has only the two boundary contracts documented in `docs/s - Define and implement (in later feature phases) a player UI localization and settings-switching experience based on DynamicLocalization and Avalonia. - Preserve a platform-neutral Core/Runtime text-localization abstraction, with DynamicLocalization-specific adaptation and registration outside Core/Runtime. -- Define the authoring, generated-template, embedded-resource, installed-package, and external third-party language-pack boundaries. +- Define embedded-resource, installed-package, and external third-party language-pack boundaries. - Localize game text, including dialogue, branch choices, and character display names, through stable keys rather than user-visible source text. -- Define locale resolution and deterministic fallback for localized non-text resources (for example sprites, voice, and layers). -- Provide first-run language selection and persist the selected language for subsequent launches. +- Deliver this in phases, beginning with the Avalonia Sample as the reference implementation; broader player/launcher adoption follows only after Sample validation. +- Provide first-run language selection through machine-local player-variable storage that records launch count, supports existing player-data reset behavior, and persists the selected language for subsequent launches. This preference is outside game-save scope and is not synchronized across games or machines. - Preserve the existing project `I18n/` special-content boundary and `.galpak` installation model. ## Non-goals @@ -42,7 +44,8 @@ The current repository has only the two boundary contracts documented in `docs/s - Replacing the existing Editor localization service without an explicit compatibility/migration decision. - Localizing arbitrary third-party plug-in UI unless its extension boundary is defined as part of the later design. - Making raw filesystem paths the formal identity of game assets; stable asset IDs remain authoritative. -- Defining translation workflow tooling beyond template export/import and validation required for this feature. +- Generating translator templates or adding Editor translation authoring/import tooling; those follow after the runtime loading and override model is proven. +- Localizing non-text assets (images, voice, layers, and other binary resources), including locale-suffixed filename conventions. ## Initial acceptance criteria @@ -50,36 +53,67 @@ The current repository has only the two boundary contracts documented in `docs/s - Built-in UI translations load from embedded resources and update UI bindings after a language change. - Game text resolves through a Runtime/Core-owned contract, independently of DynamicLocalization's concrete JSON/API types. - Installed content can supply built-in game translations, and valid third-party JSON translations placed below the designated external directory are recursively discovered at runtime. -- Template export produces a documented, stable translator input and runtime behavior is deterministic when keys, cultures, or files conflict. -- Character names, dialogue, and branch options can resolve stable keys with a specified fallback chain. -- Locale-dependent assets resolve a requested locale variant and fall back predictably to a neutral/base asset without bypassing `IAssetManager` or breaking GUID-based references. -- Invalid external localization content is surfaced diagnostically and cannot crash normal startup or silently replace a valid translation in an unspecified order. -- Automated tests cover culture selection/persistence, source precedence, text fallback, external discovery, and localized-asset fallback. +- Runtime behavior is deterministic when built-in and external sources conflict; external JSON can override both Sample UI and game-text keys. +- A character catalog combines stable character IDs with character-name i18n keys; dialogue `speaker` parameters store character IDs rather than display names. +- Character names, dialogue, and branch options require stable keys rather than direct text. Resolution falls back to the configured default language; only a key missing even from that default language is displayed verbatim. +- Invalid external localization content cannot crash normal startup; valid external translations use a deterministic, documented override order. +- Automated tests cover culture selection/persistence, source precedence, text fallback, external discovery, and character-ID speaker resolution. ## Constraints and existing facts - `docs/spec/i18n.md` currently specifies that Runtime does not reference DynamicLocalization or a JSON format. It receives `ITextResolver` from its host; `PassthroughTextResolver` preserves existing direct-text content when no resolver is provided. -- The documented `I18nKey` helpers already derive stable entry and branch-option keys from stable graph IDs. Any expanded convention must not depend on mutable display names. +- There is no character table today. `ProjectSettings.Speakers` is only a mutable `List` used for Editor dialogue-speaker autocomplete; `.galgroup` dialogue entries pass `speaker` as a plain string. This feature needs a later design decision to replace or supplement that list with a stable character catalog whose records own a character ID and display-name i18n key. +- `content` is already a localization key in raw content and is passed unchanged through compilation and Runtime. Future Editor key generation uses the raw-stage entry ID once; entry positions may change during composite expansion. No extra SourceEntryId field is required in compiled or Runtime content. - `ProjectGameContentProvider` and `InstalledGameContentProvider` already treat `I18n/` as special content. It is not an `IAssetProvider` input. Installed resource PAKs are limited to `Assets/Paks/**/*.pak`. -- Assets are formally identified by stable IDs, not paths. Locale variation must integrate with the documented `AssetLocaleResolver`/asset-manager boundary rather than make filenames the gameplay-facing identity. +- `FilePlayerVariableStore` already persists player variables separately from save slots as `player-variables.json` in the selected profile directory. The Avalonia Sample defaults that profile to `/.galnet` when a game directory is supplied, which matches a local, per-game, non-save scope; its existing clear/reset path resets these variables. +- Assets remain formally identified by stable IDs, not paths. This feature does not add localized asset variants or filename conventions. - Core/Runtime must remain decoupled from Avalonia, DynamicLocalization, and concrete filesystem implementations. - The package API and license must be verified from the requested GitHub link before a design commits to its registration, resource schema, or Avalonia binding APIs. ## Assumptions to validate -- “UI” includes player/launcher UI as well as the existing Editor UI, but the player flow is the primary new scope. +- “UI” means the Avalonia Sample/player UI; the existing Editor UI localization service is outside this feature. - Built-in game translations are distributed inside the game package under `I18n/`; third-party translations reside outside the extracted package so game updates do not overwrite them. - Third-party language sources are data-only JSON packs, not code or arbitrary resource archives. -- A locale-specific file naming convention may be an authoring/import convenience, while runtime resolves the corresponding asset through stable identity plus a locale-variant mapping. +- External language sources may add new languages and override built-in text for an existing language. They apply to both UI and game text, and do not supply localized binary assets. +- The selected locale and onboarding counter are local to the machine's player-variable storage. They are intentionally excluded from a game save and have no cross-game or cross-machine synchronization requirement. +- Game-localizable fields are keys, not direct display strings. The configured project default language must provide the authoritative base translation for every required game-text key. + +## Confirmed product decisions + +- Scope is game i18n and the Avalonia Sample; migration of the existing Editor UI localization service is out of scope. +- Delivery is phased, with the Avalonia Sample implemented and validated first. +- External third-party resources are recursively discovered JSON files in the designated directory and may enhance or override a language's built-in text. +- First-launch detection is based on a persisted player variable holding a launch count. Each game startup increments the count. Resetting player data resets the count to zero, making the onboarding flow testable. +- Text fallback is exact locale → parent locale → configured default locale → visible i18n key. Direct text is not a supported game-text fallback or authoring form. +- Localized binary resources and filename suffix conventions are out of scope; only text JSON resources are localized in this feature. +- The character catalog and character i18n are one game-content feature: dialogue `speaker` stores a stable character ID, and the catalog resolves that ID to a display-name key. + +## Candidate implementation order (not yet a formal phase plan) + +1. **Avalonia Sample UI:** register DynamicLocalization in the Sample composition root, load its built-in embedded UI resources through the Avalonia-facing integration, bind UI text, and support runtime language switching. +2. **Built-in game content loading and character catalog:** define the platform-neutral game i18n source/resolver boundary; add a stable character catalog whose records contain display-name i18n keys; change dialogue `speaker` to store character IDs; load the bundled character and galgroup translation resources, then pass the resolver service to Runtime. +3. **Game text adoption:** introduce stable character/display-name and galgroup text keys, text fallback, and Runtime resolution without exposing DynamicLocalization types to Core/Runtime. +4. **External JSON overlays:** recursively load the designated third-party text directory, apply deterministic override order to the shared UI/game key space, and allow host applications/developers to register additional sources through the service/composition boundary. Template export and Editor tooling are deferred. +5. **First-run language selection:** add the Sample's machine-local player variables, picker flow, reset behavior, and persistence. +## Design + +The proposed architecture, file layout, provider/adapter boundary, eager game-text catalog, external overlay rules, character catalog, and reasons for rejecting per-galgroup dynamic loading are in [design.md](design.md). + +The dependency-ordered, independently verifiable implementation plan is in [phase-plan.md](phase-plan.md). + +**Implementation progress:** Phase 1 uses `DynamicLocalization.Core` 2.1.2 and the official demo's `LocalizedString.Value` binding with an Avalonia 12 `ReflectionBinding` adapter. Embedded JSON, later JSON overrides, and live binding updates have test evidence. Phase 2's character/text contracts are implemented. Settings from Phase 5 were brought forward: a general host settings service renders language and text speed, persists both in player variables, and restores them on session initialization. Phase 3 game loading/resolver, Phase 4 overlays, and Phase 5 first-run picker/counter remain unfinished. `DynamicLocalization.Avalonia` 2.1.2 failed at startup against Avalonia 12 and is not used. + +## Required design decisions created by the confirmed scope + +- Define the machine-local player-variable name and the transaction/order of: read count → show language picker when zero → persist locale → increment count. The flow must avoid marking onboarding complete when the user exits/cancels before a locale is committed. +- Define whether the language picker reappears after a data reset before the first playable game session, and whether an OS locale is only a preselected suggestion. +- Define a deterministic external-pack ordering and collision rule: external packs override built-in sources; among external files, lexical normalized-relative-path order is applied last-wins. Collision logging is optional diagnostics, not required behavior. +- Decide whether DynamicLocalization's `ICultureService` is used directly only by UI and wrapped by a host-side `ITextResolver` adapter for Runtime game text. Core/Runtime must not reference DynamicLocalization package types. ## Open questions -1. Please provide the DynamicLocalization GitHub URL (and intended package/version, if already chosen). Its actual APIs, Avalonia integration, license, and supported JSON source model determine the adapter boundary. -2. Which product surfaces are in this feature: player/launcher UI only, or should the existing Editor UI localization service migrate in the same feature as well? -3. For third-party packs, should external files be allowed only to add a new locale, or may they override built-in translations for an existing locale? If overrides are allowed, what precedence should apply among nested files and packs? -4. What must be localized by third parties: text only, or text plus voices/images/layers? If binary assets are included, should they be package-local external files, or should the first release support only the built-in locale-suffixed assets? -5. What is the required fallback chain? A likely candidate is exact locale (`zh-Hans-CN`) → parent locale (`zh-Hans`) → configured default locale → neutral/base asset/text → visible key or source text, but this needs product confirmation. -6. When does “first entry” occur: before a game is selected, once per app installation, or once per installed game/version? Should an OS culture suggestion be preselected? +1. Should operating-system language merely preselect the first-run picker choice, with explicit user confirmation required? This is optional; it does not block the implemented settings flow. ## Related documents diff --git a/features/F-20261010-02-runtime-i18n/phase-plan.md b/features/F-20261010-02-runtime-i18n/phase-plan.md new file mode 100644 index 0000000..ccd04b4 --- /dev/null +++ b/features/F-20261010-02-runtime-i18n/phase-plan.md @@ -0,0 +1,164 @@ +# F-20261010-02 实施 Phase Plan + +**设计依据:** [design.md](design.md) + +**状态:** implementation in progress + +## Phase 1 — Avalonia Sample UI 本地化基础 + +**状态:** verified(2026-10-10) + +**目标:** 在 Avalonia Sample 宿主中引入固定版本的 DynamicLocalization.Core,加载程序集内嵌 UI JSON,使用 Sample 自有 Avalonia 12 绑定适配层支持运行时 culture 切换,但不改变游戏内容格式。 + +**前置条件:** 确认目标 NuGet 版本与 Avalonia 目标框架兼容。 + +**涉及模块:** `GalNet.Sample.Avalonia`、Sample composition root、必要的 Presentation 测试项目。 + +**任务:** + +1. 固定 `DynamicLocalization.Core` 2.1.2;以小型 characterization test 验证嵌入 JSON、culture change、逐文件动态加载与重复 key 的后加载覆盖语义。 +2. 在 Sample composition root 注册唯一的 culture service,嵌入 `ui.*` JSON,并通过 Sample 的 Avalonia 12 绑定适配层暴露本地化文本。 +3. 保证 UI culture 改变会刷新现有页面绑定,且未加载游戏时仍能显示 bootstrap UI。 + +**测试与验证:** + +- 嵌入 `zh-CN`、`en-US` 等 UI 资源可被解析。 +- culture 切换后 UI binding 刷新。 +- 两个按确定顺序加载的 JSON 含同 key 时,后加载值生效。 + +**风险:** 库的多 provider 优先级可能不同于预期;本 Phase 的 characterization test 是后续所有覆盖规则的依据。 + +**退出条件:** Sample UI 独立完成嵌入资源加载和切换;库的加载/覆盖行为有自动化证据。 + +**验证证据:** + +- `dotnet build src\Samples\GalNet.Sample.Avalonia\GalNet.Sample.Avalonia.csproj --no-restore -m:1 -p:UseSharedCompilation=false -p:AvaloniaTelemetryOptOut=true` 成功;移除 `DynamicLocalization.Avalonia` 2.1.2 后输出不含其 DLL,且仅有既有 Avalonia runtime-loader 可达性警告。 +- `dotnet test test\GalNet.IntegrationTests\GalNet.IntegrationTests.csproj --no-restore -m:1 -p:UseSharedCompilation=false -p:AvaloniaTelemetryOptOut=true --filter FullyQualifiedName~SampleLocalizationTests`:1/1 通过,验证内嵌中英文 JSON 与后加载覆盖。 + +## Phase 2 — 稳定游戏文本契约与人物表 + +**状态:** verified(2026-10-10) + +**目标:** 建立人物 catalog 与内置文本字段的 key 契约;raw 中已有 key 在编译、复合展开和 Runtime 之间保持一致。 + +**前置条件:** Phase 1 已确认库行为;不依赖外部语言文件。 + +**涉及模块:** `GalNet.Core`(人物与 key 契约)、`GalNet.Runtime`(compiled group loader)、`GalNet.Primitives.Builtins`(dialogue/NVL/choice)、Editor 共享编译/验证层及相关测试。 + +**任务:** + +1. 定义 character catalog DTO/验证:唯一 CharacterId、必填 DisplayNameKey;以模块参数接收 catalog。文件加载与 Editor 自动补全接线留在 Phase 3。 +2. 编译前后继续用 `Entry.Id` 表示顺序位置,不增加 SourceEntryId 持久化字段。key 由未来 Editor 根据 raw 阶段的 ID 生成一次并保存为 content;本期 compiler 原样传递。 +3. 将内置 `speaker` 统一解释为 CharacterId;内置 content/NVL/choice 文本统一解释为 key。未知人物 ID 是内容错误;默认语言必需 key 校验归 Phase 3。 +4. 保持自定义 entry 不受通用字符串推断影响;其实现自行决定参数的本地化语义。 + +**测试与验证:** + +- 在 group 插入或重排条目后,既有 raw entry ID 对应的 key 不变。 +- voice-text composite 展开后,文本 primitive 保留原 key。 +- 有效/重复人物 ID、未知 speaker CharacterId、key 化 choice/dialogue 的验证测试。 + +**风险:** 现有项目使用 speaker 显示名。迁移或拒绝策略必须在实现时以明确诊断处理,不能静默改变显示名称。 + +**退出条件:** 内置游戏文本和人物显示名经 resolver 解析,编译和展开不改写 content key;无需 Runtime source identity。 + +**验证证据:** + +- Core i18n 测试 12/12 通过:人物 ID/姓名 key 必填、重复 ID 拒绝与生成 helper。 +- Builtins 全组 52/52 通过。 +- Integration 定向测试 28/28 通过:编译展开与插入后 key 不变、姓名/对话/NVL/choice 解析、未知人物拒绝及原有 loader/engine 行为。 +- 未新增 compiled 或 Runtime 的 SourceEntryId 字段,未改写 raw content,现有文件格式保持不变。 +- 人物 catalog 文件加载、Sample 的 catalog/resolver 接线和项目示例迁移待 Phase 3;现有含非空 speaker 的宿主需提供 catalog。 + +## Phase 3 — 包内游戏 i18n 加载与 Runtime resolver + +**状态:** planned + +**目标:** 从项目/安装内容的 `I18n/` 递归加载人物 catalog 与 per-culture JSON,并把同一 culture service 适配为 Runtime `ITextResolver`。 + +**前置条件:** Phase 2 契约和测试通过。 + +**涉及模块:** `GalNet.Runtime.Content`、`GalNet.Storage.FileSystem` 内容 providers、Sample game/session composition、`GalNet.Core.Services.ITextResolver` adapter、集成测试。 + +**任务:** + +1. 实现 `I18n/catalog/characters.json` 与 `I18n//**/*.json` 的安全递归枚举、路径规范化和 JSON 验证。 +2. 在项目和安装内容 provider 中加载 i18n 描述,向 Sample composition 暴露足以建立 resolver 的内容;保持 `I18n/` 不进入 `IAssetProvider`。 +3. 按明确顺序逐文件调用 DynamicLocalization 动态加载,不在 GalNet 中重写 JSON 合并算法。 +4. 实现 `ITextResolver` adapter:请求 culture → 父 culture → `ProjectSettings.TargetLocale` → key;Core/Runtime 不引用第三方包类型。 +5. 对默认语言做完整性验证,至少覆盖人物、group entry 与 branch key。 + +**测试与验证:** + +- Project 与 installed content 对相同 i18n tree 得到等价解析结果。 +- dialogue、NVL、choice 与人物名按当前/父/默认 culture 回退。 +- 默认语言缺 key 被内容验证拒绝;非默认语言缺 key 正确回退。 +- `I18n/` 文件不被资源 asset provider 枚举。 + +**风险:** 载入时机必须先于 GameEngine 建立;包内 UI/game key 的命名空间冲突应由 `ui.` 保留前缀防止。 + +**退出条件:** 仅靠包内内容,Sample 能以所选 culture 运行 key 化剧情和人物名,并保持 Runtime 无第三方包依赖。 + +## Phase 4 — 外部 JSON 覆盖源 + +**状态:** planned + +**目标:** 让已安装游戏递归加载 `I18n/External//**/*.json`,以确定性方式覆盖包内游戏和 Sample UI 文本。 + +**前置条件:** Phase 3 完成且逐文件加载覆盖语义已由 Phase 1 锁定。 + +**涉及模块:** 安装/内容加载边界、Sample composition、文件系统集成测试;必要时安装器。 + +**任务:** + +1. 创建并保留 External 目录;更新/重新安装流程不得删除第三方语言文件。 +2. 路径按 ordinal 相对路径排序后逐个加载;外部层位于内置层之后。 +3. 将无效 JSON 限制为单个文件诊断,不损害已加载语言和正常启动。 +4. 提供宿主注册额外 source 的组合根扩展点,而不将文件系统或 DynamicLocalization 类型泄漏进 Core/Runtime。 + +**测试与验证:** + +- 外部 JSON 覆盖一个 `ui.*` key 和一个 group/character key。 +- 嵌套外部文件的相对路径确定最后覆盖值。 +- 更新/install 保留 external 文件;损坏外部文件不会阻止其余翻译加载。 + +**风险:** 当前安装器的更新策略需要被实际验证;不要在没有证据时假定目录保留。 + +**退出条件:** 翻译者手动放入 JSON 即可覆盖界面和剧情文本,且行为跨启动可复现。 + +## Phase 5 — 首次语言选择与持久化 + +**状态:** partially implemented(2026-10-10) + +用户调整顺序,先完成设置页:共享页使用通用 `IPlayerSettingsService` 与选择/范围项模板,Sample 注册语言和文字速度。语言绑定参照官方 Avalonia JSON demo 使用 `LocalizedString.Value`;直接更改 culture 会刷新当前绑定。两项偏好写入 session 的 player variables 并在下次 session 初始化时恢复;首次启动选择、启动计数与游戏文本 resolver 同步仍待后续 Phase。 + +验证:Sample 构建成功;`SampleLocalizationTests` 3/3 通过,包括已建立的 Avalonia TextBlock binding 在直接 SetCulture 后更新,以及偏好保存/恢复且保留其他玩家变量。 + +**目标:** 在 Sample 以本机、按游戏、非存档的玩家变量保存语言与启动次数,并实现可重置的首次语言选择页。 + +**前置条件:** Phase 1 culture service 和 Phase 3 game resolver 均可在 Engine 创建前配置。 + +**涉及模块:** `GalNet.Sample.Avalonia` session/startup、`FilePlayerVariableStore` 使用点、Sample settings UI、自动化测试。 + +**任务:** + +1. 定义受保留命名空间保护的 player variables:所选 locale 与启动计数。 +2. 启动时先读 player variables;计数为零时用 bootstrap UI 显示选择页。 +3. 确认选择时原子地保存 locale 后增加计数;取消/异常不增加计数。 +4. 后续启动在 Engine 创建前应用已保存 locale;设置页可重新选择语言;既有“清空游戏数据”重置这两个变量。 + +**测试与验证:** + +- 首次、确认、取消、重启和 reset 流程。 +- 选择 language 后 UI 与游戏文本同步更新;保存不进入 save slot。 +- profile 内不同游戏目录互不污染。 + +**风险:** language picker 在游戏内容未准备好时仍需有可用 bootstrap UI;不要让失败的内容加载永久写入 onboarding 完成状态。 + +**退出条件:** Sample 首次启动可选语言、后续可持久化并从设置切换,重置后可重复验收。 + +## 跨 Phase 文档与完成条件 + +- 每个 Phase 落地后更新 `docs/spec/i18n.md` 与受影响的内容格式文档,只记录已验证事实。 +- 任何发现的非显然问题先写入 `.agents/lessons/`;结束时按 closeout 流程整理。 +- 完成前应顺序运行受影响项目的测试,避免共享 `obj` 并行构建争用;验证具体命令由实施时的项目依赖与环境确定。 diff --git a/features/F-20261010-02-runtime-i18n/summary.md b/features/F-20261010-02-runtime-i18n/summary.md new file mode 100644 index 0000000..8aa6838 --- /dev/null +++ b/features/F-20261010-02-runtime-i18n/summary.md @@ -0,0 +1,29 @@ +# Runtime i18n 阶段总结 + +日期:2026-10-10。状态:阶段性提交,Feature 仍在实现中。 + +## 已实现 + +- Sample 使用 DynamicLocalization.Core 2.1.2 的嵌入 JSON、CultureService 与 LocalizedString.Value。自有 Avalonia 12 ReflectionBinding 适配层替代不兼容的平台扩展;直接修改 CultureService 也更新已有绑定。 +- 共享页面提供通用 IPlayerSettingsService 和选择/数值设置项,Sample 提供语言与文本速度。设置标签、常用静态页面文本及调试窗口使用动态本地化绑定。 +- 设置复用会话的 Player 变量存储,不覆盖其他玩家变量;会话初始化后恢复,修改时排队写入,清空玩家状态及销毁会话前等待写入。 +- Core 提供人物 ID/name-key catalog;内置 dialogue/NVL 通过角色 ID 和 ITextResolver 解析文本。raw content 中已有的 key 原样通过编译和运行期,不按执行位置重写。 +- Sample 启动脚本将默认 profile 放在项目 .galnet 中,避免把玩家数据放入可替换的构建输出。 + +## 验证 + +本次提交前执行代码空白格式化、IDE0005 无用 using 清理及 git diff --check。 +测试通过:Core 61、Builtins 52、Presentation 76、Integration 43、Architecture 24,共 256 项,0 失败。Presentation 输出另有真实媒体场景跳过提示,不能据此声称本机媒体验收完成。集成测试包含真实 Avalonia TextBlock 绑定的直接 culture 更新、设置持久化/恢复及其他 Player 变量保留、嵌入资源与 JSON 同 key 覆盖。 + +## 未完成与边界 + +- Phase 3 的游戏人物文件加载、游戏翻译加载和 Sample resolver 接线未完成。因此旧 Sample 剧情的 Guide 尚未提供角色记录,点击启动游戏的 Unknown character ID 异常仍是已知问题,不是本阶段完成的验收项。 +- Phase 4 的外部翻译目录发现、验证及确定性覆盖未完成。provider 单元级 JSON 覆盖测试不等于完整外部包加载已实现。 +- Phase 5 首次语言选择与启动计数未完成。设置存储仅在游戏会话成功初始化后接入,不能声称无游戏/初始化失败时的设置已持久化。 +- 任意 ViewModel 业务字符串及已显示剧情不会因 culture 自动翻译;只有接入 LocalizedString 的动态绑定具备当前刷新行为。 +- 未完成完整 GUI 游戏流程验收;已有分析器和 AVLN3001 构建警告仍保留。 +- 原 Output 目录替换失败的访问拒绝未确认最终根因;独立 Output-new 可重复构建不代表原目录问题已修复。生成输出和官方参考仓库不进入提交。 + +## 文档与经验 + +实现事实同步至 docs/spec/i18n.md 与 docs/spec/control.md。经验记录见 L-20261010-05(平台扩展二进制兼容与官方动态绑定机制)及 L-20261010-06(已存储 key 的作者边界)。后续继续按 phase-plan.md 推进,不将阶段性提交标记为整个 Feature 完成。 diff --git a/scripts/run-avalonia-sample.ps1 b/scripts/run-avalonia-sample.ps1 index 5dc09ec..ce61002 100644 --- a/scripts/run-avalonia-sample.ps1 +++ b/scripts/run-avalonia-sample.ps1 @@ -12,6 +12,7 @@ $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path -Parent $PSScriptRoot $Project = if ($Project) { [System.IO.Path]::GetFullPath($Project) } else { Join-Path $repositoryRoot 'GameTestCase' } $gameDirectory = if ($BuildOutput) { [System.IO.Path]::GetFullPath($BuildOutput) } else { Join-Path $Project 'Output' } +$profileDirectory = if ($Profile) { [System.IO.Path]::GetFullPath($Profile) } else { Join-Path $Project '.galnet' } $sampleProject = Join-Path $repositoryRoot 'src\Samples\GalNet.Sample.Avalonia\GalNet.Sample.Avalonia.csproj' $editorProject = Join-Path $repositoryRoot 'src\Editor\GalNet.Editor.Headless\GalNet.Editor.Headless.csproj' @@ -20,11 +21,7 @@ if (-not $SkipBuild) & dotnet run --project $editorProject -- build $Project --output $gameDirectory if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } -$sampleArguments = @($gameDirectory) -if ($Profile) -{ - $sampleArguments += '--profile', [System.IO.Path]::GetFullPath($Profile) -} +$sampleArguments = @($gameDirectory, '--profile', $profileDirectory) Write-Host "Launching Avalonia sample: $gameDirectory" -ForegroundColor Cyan & dotnet run --project $sampleProject -- @sampleArguments diff --git a/src/Editor/GalNet.Editor/packages.lock.json b/src/Editor/GalNet.Editor/packages.lock.json index 488f660..cd20dfc 100644 --- a/src/Editor/GalNet.Editor/packages.lock.json +++ b/src/Editor/GalNet.Editor/packages.lock.json @@ -139,11 +139,12 @@ }, "DynamicLocalization.Core": { "type": "Direct", - "requested": "[2.1.0, )", - "resolved": "2.1.0", - "contentHash": "xZYqTkryy36XVPUSuOGU68KEcS2Nk4AR+5dDCJdAGBgdX6MtCZ+XEOJH1CGF8QDMrU3WDk921uVsfIlqqc683g==", + "requested": "[2.1.2, )", + "resolved": "2.1.2", + "contentHash": "UdDvJeBnNoif/bi73CBDi7GiOhGUB9EkKl3v0JTNdVcPhCqWY+Jy9qr4IqNS9bQ5Xwn6uTYimYLDqGNxEEDjqw==", "dependencies": { - "YamlDotNet": "18.1.0" + "System.Text.Json": "[6.0.10, 10.0.0)", + "YamlDotNet": "[11.0.1, 19.0.0)" } }, "Irihi.Ursa": { @@ -436,6 +437,11 @@ "resolved": "3.119.4", "contentHash": "XOpbx/4CReO2wYsq2s6rbvdauc6dntG4Zv499sHGTJ87bwZaFXszFkwql3+FIZMc8kUPeaj3Mx2ezIJmo8a1Kg==" }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "6.0.10", + "contentHash": "NSB0kDipxn2ychp88NXWfFRFlmi1bst/xynOutbnpEfRCT9JZkZ7KOmF/I/hNKo2dILiMGnqblm+j1sggdLB9g==" + }, "Tmds.DBus.Protocol": { "type": "Transitive", "resolved": "0.92.0", @@ -443,8 +449,8 @@ }, "YamlDotNet": { "type": "Transitive", - "resolved": "18.1.0", - "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + "resolved": "11.0.1", + "contentHash": "UvICfSN4gJfAmOsuY/go0hb5SFfr3ZYCXb7jg66yT7kh+wSJEt7tUiwuoCNMpt8ScEXLDwXttpFoWKWeirYU1A==" }, "galnet.assets": { "type": "Project", diff --git a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs index 0d7af88..bd9449a 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs +++ b/src/Presentation/GalNet.Avalonia.GameView/Composition/AvaloniaGameViewServiceCollectionExtensions.cs @@ -39,6 +39,7 @@ public static IServiceCollection AddAvaloniaGameViewPages( services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs new file mode 100644 index 0000000..9b3192b --- /dev/null +++ b/src/Presentation/GalNet.Avalonia.GameView/MarkupExtensions/PlayerTextExtension.cs @@ -0,0 +1,13 @@ +using Avalonia.Data; + +namespace GalNet.Avalonia.GameView.MarkupExtensions; + +/// Host supplies observable text values; shared pages retain English fallbacks. +public sealed class PlayerTextExtension(string key, string fallback) +{ + public static Func? Resolve { get; set; } + + public object ProvideValue(IServiceProvider serviceProvider) => Resolve is { } resolve + ? new ReflectionBinding("Value") { Source = resolve(key), Mode = BindingMode.OneWay } + : fallback; +} diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml index f75c1a6..2f0eb08 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/AboutPage.axaml @@ -1,5 +1,6 @@ - - + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml index 3f037ab..e110fae 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/GalleryPage.axaml @@ -1,5 +1,6 @@ - - + + - + diff --git a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml index 73596f8..e4612a1 100644 --- a/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml +++ b/src/Presentation/GalNet.Avalonia.GameView/Page/VideoGalleryPlayerPage.axaml @@ -1,8 +1,9 @@ -