diff --git a/.github/workflows/CSharpBible.yml b/.github/workflows/CSharpBible.yml
index a601fb628..80e82e2a4 100644
--- a/.github/workflows/CSharpBible.yml
+++ b/.github/workflows/CSharpBible.yml
@@ -1,115 +1,51 @@
-# This workflow uses actions that are not certified by GitHub.
-# They are provided by a third-party and are governed by
-# separate terms of service, privacy policy, and support
-# documentation.
-
-# This workflow will build, test, sign and package a WPF or Windows Forms desktop application
-# built on .NET Core.
-# To learn how to migrate your existing application to .NET Core,
-# refer to https://docs.microsoft.com/en-us/dotnet/desktop-wpf/migration/convert-project-from-net-framework
-#
-# To configure this workflow:
-#
-# 1. Configure environment variables
-# GitHub sets default environment variables for every workflow run.
-# Replace the variables relative to your project in the "env" section below.
-#
-# 2. Signing
-# Generate a signing certificate in the Windows Application
-# Packaging Project or add an existing signing certificate to the project.
-# Next, use PowerShell to encode the .pfx file using Base64 encoding
-# by running the following Powershell script to generate the output string:
-#
-# $pfx_cert = Get-Content '.\SigningCertificate.pfx' -Encoding Byte
-# [System.Convert]::ToBase64String($pfx_cert) | Out-File 'SigningCertificate_Encoded.txt'
-#
-# Open the output file, SigningCertificate_Encoded.txt, and copy the
-# string inside. Then, add the string to the repo as a GitHub secret
-# and name it "Base64_Encoded_Pfx."
-# For more information on how to configure your signing certificate for
-# this workflow, refer to https://github.com/microsoft/github-actions-for-desktop-apps#signing
-#
-# Finally, add the signing certificate password to the repo as a secret and name it "Pfx_Key".
-# See "Build the Windows Application Packaging project" below to see how the secret is used.
-#
-# For more information on GitHub Actions, refer to https://github.com/features/actions
-# For a complete CI/CD sample to get started with GitHub Action workflows for Desktop Applications,
-# refer to https://github.com/microsoft/github-actions-for-desktop-apps
-
-name: .NET Core Desktop
+name: CSharpBible CI3
on:
push:
- branches: [ "master" ]
+ paths:
+ - "CSharpBible/**"
pull_request:
- branches: [ "master" ]
+ paths:
+ - "CSharpBible/**"
jobs:
-
- build:
-
- strategy:
- matrix:
- configuration: [Debug, Release]
-
- runs-on: windows-latest # For a list of available runner types, refer to
- # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idruns-on
-
- env:
- Solution_Name: CSharpBible.sln # Replace with your solution name, i.e. MyWpfApp.sln.
- Test_Project_Path: Tests\Test.csproj # Replace with the path to your test project, i.e. MyWpfApp.Tests\MyWpfApp.Tests.csproj.
- Wap_Project_Directory: CSharpBible # Replace with the Wap project directory relative to the solution, i.e. MyWpfApp.Package.
- Wap_Project_Path: CSharpBible # Replace with the path to your Wap project, i.e. MyWpf.App.Package\MyWpfApp.Package.wapproj.
-
+ build-test:
+ runs-on: windows-latest
+ defaults:
+ run:
+ working-directory: CSharpBible
steps:
- - name: Checkout
- uses: actions/checkout@v3
- with:
- fetch-depth: 0
-
- # Install the .NET Core workload
- - name: Install .NET Core
- uses: actions/setup-dotnet@v3
- with:
- dotnet-version: 6.0.x
-
- # Add MSBuild to the PATH: https://github.com/microsoft/setup-msbuild
- - name: Setup MSBuild.exe
- uses: microsoft/setup-msbuild@v1.0.2
-
- # Execute all unit tests in the solution
- - name: Execute unit tests
- run: dotnet test
-
- # Restore the application to populate the obj folder with RuntimeIdentifiers
- - name: Restore the application
- run: msbuild $env:Solution_Name /t:Restore /p:Configuration=$env:Configuration
- env:
- Configuration: ${{ matrix.configuration }}
-
- # Decode the base 64 encoded pfx and save the Signing_Certificate
- - name: Decode the pfx
- run: |
- $pfx_cert_byte = [System.Convert]::FromBase64String("${{ secrets.Base64_Encoded_Pfx }}")
- $certificatePath = Join-Path -Path $env:Wap_Project_Directory -ChildPath GitHubActionsWorkflow.pfx
- [IO.File]::WriteAllBytes("$certificatePath", $pfx_cert_byte)
-
- # Create the app package by building and packaging the Windows Application Packaging project
- - name: Create the app package
- run: msbuild $env:Wap_Project_Path /p:Configuration=$env:Configuration /p:UapAppxPackageBuildMode=$env:Appx_Package_Build_Mode /p:AppxBundle=$env:Appx_Bundle /p:PackageCertificateKeyFile=GitHubActionsWorkflow.pfx /p:PackageCertificatePassword=${{ secrets.Pfx_Key }}
- env:
- Appx_Bundle: Always
- Appx_Bundle_Platforms: x86|x64
- Appx_Package_Build_Mode: StoreUpload
- Configuration: ${{ matrix.configuration }}
-
- # Remove the pfx
- - name: Remove the pfx
- run: Remove-Item -path $env:Wap_Project_Directory\GitHubActionsWorkflow.pfx
-
- # Upload the MSIX package: https://github.com/marketplace/actions/upload-a-build-artifact
- - name: Upload build artifacts
- uses: actions/upload-artifact@v3
- with:
- name: MSIX Package
- path: ${{ env.Wap_Project_Directory }}\AppPackages
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Remove private projects
+ run: |
+ dotnet sln CSharpBible.sln remove "Mobile/MauiApp1/MauiApp1.csproj"
+ dotnet sln CSharpBible.sln remove "Simulation/AGVFktTests/AGVFktTests.csproj"
+ dotnet sln CSharpBible.sln remove "Simulation/AGVFkt/AGVFkt.csproj"
+ dotnet sln CSharpBible.sln remove "DB/ADO_Test/ADO_Test.csproj"
+ dotnet sln CSharpBible.sln remove "Libraries/CSFreeVision_/CSFreeVision.csproj"
+ dotnet sln CSharpBible.sln remove "Mobile/DXMauiApp1/DXMauiApp1.csproj"
+ dotnet sln CSharpBible.sln remove "Web/MyComponent/Client/MyComponent.Client.csproj"
+ dotnet sln CSharpBible.sln remove "Web/MyComponent/Server/MyComponent.Server.csproj"
+ dotnet sln CSharpBible.sln remove "Web/MyComponent/Shared/MyComponent.csproj"
+ dotnet sln CSharpBible.sln remove "WinUI/App1/App1/App1.csproj"
+ dotnet sln CSharpBible.sln remove "App2/App2.csproj"
+ dotnet sln CSharpBible.sln remove "../PackageDwnLd.csproj"
+ dotnet sln CSharpBible.sln remove "Web/BlazorApp1/BlazorApp1.csproj"
+ dotnet sln CSharpBible.sln remove "Web/WebApp1/BlazorApp2/BlazorApp2.csproj"
+ dotnet sln CSharpBible.sln remove "Web/WebApp1/WebApplication1/WebApplication1/WebApplication1.csproj"
+
+ - name: Restore
+ run: dotnet restore CSharpBible.sln -p:TargetFramework=net8.0-windows
+
+ - name: Build
+ run: dotnet build CSharpBible.sln --configuration Release --no-restore -p:TargetFramework=net8.0-windows
+
+ - name: Test
+ run: dotnet test CSharpBible.sln --configuration Release --no-build --verbosity normal -p:TargetFramework=net8.0-windows
diff --git a/.github/workflows/Teststatements.yml b/.github/workflows/Teststatements.yml
new file mode 100644
index 000000000..ec5a8018d
--- /dev/null
+++ b/.github/workflows/Teststatements.yml
@@ -0,0 +1,35 @@
+name: TestStatemens CI0
+
+on:
+ push:
+ paths:
+ - "TestStatements/**"
+ pull_request:
+ paths:
+ - "TestStatements/**"
+
+jobs:
+ build-test:
+ runs-on: windows-latest
+ defaults:
+ run:
+ working-directory: TestStatements
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: "8.0.x"
+
+ - name: Remove private projects
+ run: |
+
+ - name: Restore
+ run: dotnet restore TestStatements.sln -p:TargetFramework=net8.0-windows
+
+ - name: Build
+ run: dotnet build TestStatements.sln --configuration Release --no-restore -p:TargetFramework=net8.0-windows
+ - name: Test
+ run: dotnet test TestStatements.sln --configuration Release --no-build --verbosity normal -p:TargetFramework=net8.0-windows
diff --git a/.gitignore b/.gitignore
index 3e5f6349f..0b4b38c24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,37 @@
.vs
+.gitignore
bin
obj
+*.db
+*.cache
+*.bin
+*.dll
*.bak
*.user
-/obj.net
-/CSharpBible/Data/obj.net
-/CSharpBible/obj.net
*.log
+*.suo
+*.snk
+**/vs
+**/obj
+**/obj.net
+**/bin
+**/Help
+/CSharpBible/App2
+/CSharpBible/Mobile
+/CSharpBible/Web
+/CSharpBible/packages
+/CSharpBible/Simulation
+/CSharpBible/DB/ADO_Test
+/CSharpBible/Libraries/CSFreeVision_
+/CSharpBible/Libraries/MathLibraryTests/TestResults
+/CSharpBible/Graphics/PermutationTests/TestResults
+/CSharpBible/MVVM_Tutorial
+/CSharpBible/WinUI/App1
+/TestStatements/Help
+/CSharpBible/Games/SharpHack.Wpf/tiles.png
+/CSharpBible/Games/SharpHack.Wpf/tiles2.png
+/CSharpBible/Resources/t10k-images.idx3-ubyte
+/CSharpBible/Resources/t10k-labels.idx1-ubyte
+/CSharpBible/Resources/train-images.idx3-ubyte
+/CSharpBible/Resources/train-labels.idx1-ubyte
+/CSharpBible/Games/Sokoban_Base/Version.svn
diff --git a/Avalonia_Apps/AA14_ScreenX/AA14_ScreenX/AA14_ScreenX.csproj b/Avalonia_Apps/AA14_ScreenX/AA14_ScreenX/AA14_ScreenX.csproj
index d246e0113..87fc8753a 100644
--- a/Avalonia_Apps/AA14_ScreenX/AA14_ScreenX/AA14_ScreenX.csproj
+++ b/Avalonia_Apps/AA14_ScreenX/AA14_ScreenX/AA14_ScreenX.csproj
@@ -23,11 +23,4 @@
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA15_Labyrinth/AA15_Labyrinth/AA15_Labyrinth.csproj b/Avalonia_Apps/AA15_Labyrinth/AA15_Labyrinth/AA15_Labyrinth.csproj
index 868598e9c..a3e52e864 100644
--- a/Avalonia_Apps/AA15_Labyrinth/AA15_Labyrinth/AA15_Labyrinth.csproj
+++ b/Avalonia_Apps/AA15_Labyrinth/AA15_Labyrinth/AA15_Labyrinth.csproj
@@ -24,10 +24,4 @@
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj b/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj
index 5d0860a91..47341c74d 100644
--- a/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj
+++ b/Avalonia_Apps/AA15_Labyrinth/AA15_LabyrinthTests/AA15_LabyrinthTests.csproj
@@ -24,9 +24,4 @@
-
-
-
-
-
diff --git a/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/AA15a_Treppen.csproj b/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/AA15a_Treppen.csproj
index bc98e7507..c3558789b 100644
--- a/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/AA15a_Treppen.csproj
+++ b/Avalonia_Apps/AA15_Labyrinth/AA15a_Treppen/AA15a_Treppen.csproj
@@ -37,12 +37,4 @@
-
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props
index 921a6da2f..d9e976c52 100644
--- a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props
+++ b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol1/Directory.Packages.props
@@ -3,13 +3,13 @@
true
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol2/AA16_UserControl2.csproj b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol2/AA16_UserControl2.csproj
index ed77aa851..aeb7c33fb 100644
--- a/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol2/AA16_UserControl2.csproj
+++ b/Avalonia_Apps/AA16_UserControl/AA16_Usercontrol2/AA16_UserControl2.csproj
@@ -25,11 +25,4 @@
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA18_MultiConverter/AA18_MultiConverters/AA18_MultiConverters.csproj b/Avalonia_Apps/AA18_MultiConverter/AA18_MultiConverters/AA18_MultiConverters.csproj
index 1c16896a3..0d373bb9a 100644
--- a/Avalonia_Apps/AA18_MultiConverter/AA18_MultiConverters/AA18_MultiConverters.csproj
+++ b/Avalonia_Apps/AA18_MultiConverter/AA18_MultiConverters/AA18_MultiConverters.csproj
@@ -25,11 +25,4 @@
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/AA19_FilterLists.csproj b/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/AA19_FilterLists.csproj
index daf30f25c..7a4043932 100644
--- a/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/AA19_FilterLists.csproj
+++ b/Avalonia_Apps/AA19_FilterLists/AA19_FilterLists/AA19_FilterLists.csproj
@@ -61,11 +61,4 @@
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/AA20a_CommonDialogs.csproj b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/AA20a_CommonDialogs.csproj
new file mode 100644
index 000000000..b1b9f92ee
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/AA20a_CommonDialogs.csproj
@@ -0,0 +1,43 @@
+
+
+
+
+ WinExe
+ net8.0
+ enable
+ enable
+ true
+ AA20a_CommonDialogs
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml
new file mode 100644
index 000000000..ab42778ea
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml.cs b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml.cs
new file mode 100644
index 000000000..ed5c5ef0d
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/App.axaml.cs
@@ -0,0 +1,50 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Avalonia.Controls;
+using Avln_CommonDialogs.Avalonia;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace AA20a_CommonDialogs;
+
+public sealed partial class App : Application
+{
+ public IServiceProvider Services { get; private set; } = default!;
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ Services = ConfigureServices();
+
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = Services.GetRequiredService();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private IServiceProvider ConfigureServices()
+ {
+ var services = new ServiceCollection();
+
+ var topLevelAccessor = new TopLevelAccessor();
+ services.AddSingleton(topLevelAccessor);
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddAvaloniaCommonDialogs(() => topLevelAccessor.Current);
+
+ return services.BuildServiceProvider();
+ }
+}
+
+public sealed class TopLevelAccessor
+{
+ public TopLevel? Current { get; set; }
+}
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml
new file mode 100644
index 000000000..7d34efab3
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml.cs b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml.cs
new file mode 100644
index 000000000..b6980e600
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindow.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia.Controls;
+
+namespace AA20a_CommonDialogs;
+
+public sealed partial class MainWindow : Window
+{
+ public MainWindow(MainWindowViewModel vm, TopLevelAccessor tla)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ tla.Current = this;
+ }
+}
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindowViewModel.cs b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindowViewModel.cs
new file mode 100644
index 000000000..762af9cf8
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/MainWindowViewModel.cs
@@ -0,0 +1,69 @@
+using System.Text;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace AA20a_CommonDialogs;
+
+public partial class MainWindowViewModel : ObservableObject
+{
+ private readonly IOpenFileDialog _open;
+ private readonly ISaveFileDialog _save;
+ private readonly IColorDialog _color;
+ private readonly IFontDialog _font;
+
+ public MainWindowViewModel(
+ IOpenFileDialog open,
+ ISaveFileDialog save,
+ IColorDialog color,
+ IFontDialog font)
+ {
+ _open = open;
+ _save = save;
+ _color = color;
+ _font = font;
+
+ _open.Title = "Open";
+ _open.AllowMultiple = true;
+ (_open as dynamic).MutableFilters?.Add(new FileTypeFilter("Text", new[] { ".txt", ".md" }));
+
+ _save.Title = "Save";
+ _save.DefaultExtension = "txt";
+ (_save as dynamic).MutableFilters?.Add(new FileTypeFilter("Text", new[] { ".txt" }));
+ }
+
+ [ObservableProperty]
+ private string result = string.Empty;
+
+ [RelayCommand]
+ private async Task OpenFileAsync()
+ {
+ var files = await _open.ShowAsync();
+ var sb = new StringBuilder();
+ sb.AppendLine("OpenFile:");
+ foreach (var f in files)
+ sb.AppendLine(f);
+ Result = sb.ToString();
+ }
+
+ [RelayCommand]
+ private async Task SaveFileAsync()
+ {
+ var file = await _save.ShowAsync();
+ Result = $"SaveFile: {file}";
+ }
+
+ [RelayCommand]
+ private async Task ColorAsync()
+ {
+ var ok = await _color.ShowAsync();
+ Result = $"ColorDialog: {ok}, Color={_color.Color}";
+ }
+
+ [RelayCommand]
+ private async Task FontAsync()
+ {
+ var ok = await _font.ShowAsync();
+ Result = $"FontDialog: {ok}, Font={_font.Font}";
+ }
+}
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/Program.cs b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/Program.cs
new file mode 100644
index 000000000..d34e6c303
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/Program.cs
@@ -0,0 +1,17 @@
+using Avalonia;
+using System;
+
+namespace AA20a_CommonDialogs;
+
+internal static class Program
+{
+ [STAThread]
+ public static void Main(string[] args)
+ => BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+}
diff --git a/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/ViewLocator.cs b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/ViewLocator.cs
new file mode 100644
index 000000000..56c2e930c
--- /dev/null
+++ b/Avalonia_Apps/AA20_SysDialogs/AA20a_CommonDialogs/ViewLocator.cs
@@ -0,0 +1,5 @@
+namespace AA20a_CommonDialogs;
+
+internal static class ViewLocator
+{
+}
diff --git a/Avalonia_Apps/AA21_Buttons/Directory.Packages.props b/Avalonia_Apps/AA21_Buttons/Directory.Packages.props
index 6ffd9eac8..b4e283877 100644
--- a/Avalonia_Apps/AA21_Buttons/Directory.Packages.props
+++ b/Avalonia_Apps/AA21_Buttons/Directory.Packages.props
@@ -6,15 +6,15 @@
-
+
-
-
+
+
-
+
@@ -22,8 +22,8 @@
-
-
+
+
diff --git a/Avalonia_Apps/AA22_AvlnCap/AA22_AvlnCap/AA22_AvlnCap.csproj b/Avalonia_Apps/AA22_AvlnCap/AA22_AvlnCap/AA22_AvlnCap.csproj
index 75b089aa5..c78a700b7 100644
--- a/Avalonia_Apps/AA22_AvlnCap/AA22_AvlnCap/AA22_AvlnCap.csproj
+++ b/Avalonia_Apps/AA22_AvlnCap/AA22_AvlnCap/AA22_AvlnCap.csproj
@@ -52,12 +52,5 @@
Settings.Designer.cs
-
-
-
-
-
-
-
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/AA25_RichTextEdit.csproj b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/AA25_RichTextEdit.csproj
index be0084342..25fe431f1 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/AA25_RichTextEdit.csproj
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/AA25_RichTextEdit.csproj
@@ -21,6 +21,7 @@
+
@@ -38,7 +39,9 @@
-
+
+
+
@@ -71,4 +74,7 @@
Settings.Designer.cs
+
+
+
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/App.axaml.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/App.axaml.cs
index 81e1e2ed9..cdd99c9cc 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/App.axaml.cs
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/App.axaml.cs
@@ -1,9 +1,11 @@
+using AA25_RichTextEdit.ViewModels;
using Avalonia;
+using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
+using Avln_CommonDialogs.Avalonia;
using Microsoft.Extensions.DependencyInjection;
using System;
-using AA25_RichTextEdit.ViewModels;
namespace AA25_RichTextEdit;
@@ -28,8 +30,16 @@ public override void OnFrameworkInitializationCompleted()
private static void ConfigureServices(IServiceCollection services)
{
+ var topLevelAccessor = new TopLevelAccessor();
+ services.AddSingleton(topLevelAccessor);
services.AddSingleton();
services.AddTransient();
services.AddTransient();
+ services.AddAvaloniaCommonDialogs(() => topLevelAccessor.Current);
}
}
+
+public sealed class TopLevelAccessor
+{
+ public TopLevel? Current { get; set; }
+}
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.Designer.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.Designer.cs
index b8b28bd63..ef5dcbd6d 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.Designer.cs
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.Designer.cs
@@ -8,111 +8,112 @@
//
//------------------------------------------------------------------------------
-namespace AA25_RichTextEdit.Properties;
-using System;
-
-
-///
-/// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw.
-///
-// Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert
-// -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert.
-// Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen
-// mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu.
-[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
-[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
-[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
-public class Resources {
-
- private static global::System.Resources.ResourceManager resourceMan;
+namespace AA25_RichTextEdit.Properties {
+ using System;
- private static global::System.Globalization.CultureInfo resourceCulture;
-
- [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
- internal Resources() {
- }
///
- /// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird.
+ /// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw.
///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- public static global::System.Resources.ResourceManager ResourceManager {
- get {
- if (object.ReferenceEquals(resourceMan, null)) {
- global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("AA25_RichTextEdit.Properties.Resources", typeof(Resources).Assembly);
- resourceMan = temp;
- }
- return resourceMan;
+ // Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert
+ // -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert.
+ // Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen
+ // mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
}
- }
-
- ///
- /// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle
- /// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden.
- ///
- [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
- public static global::System.Globalization.CultureInfo Culture {
- get {
- return resourceCulture;
+
+ ///
+ /// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("AA25_RichTextEdit.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
}
- set {
- resourceCulture = value;
+
+ ///
+ /// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle
+ /// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
}
- }
-
- ///
- /// Sucht eine lokalisierte Zeichenfolge, die MVVM-RichTextEdit using Community-Toolkit ähnelt.
- ///
- public static string Description {
- get {
- return ResourceManager.GetString("Description", resourceCulture);
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die MVVM-RichTextEdit using Community-Toolkit ähnelt.
+ ///
+ public static string Description {
+ get {
+ return ResourceManager.GetString("Description", resourceCulture);
+ }
}
- }
-
- ///
- /// Sucht eine lokalisierte Zeichenfolge, die <Page
- /// xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
- /// xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- /// xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- /// xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
- /// xmlns:local="clr-namespace:AA25_RichTextEdit.Views"
- /// xmlns:p="clr-namespace:AA25_RichTextEdit.Properties"
- /// xmlns:vc="clr-namespace:AA25_RichTextEdit.ValueConverter"
- /// xmlns:mvvm="clr-namespace: [Rest der Zeichenfolge wurde abgeschnitten]"; ähnelt.
- ///
- public static string RichTextEditView {
- get {
- return ResourceManager.GetString("RichTextEditView", resourceCulture);
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die <UserControl x:Class="AA25_RichTextEdit.Views.RichTextEditView"
+ /// xmlns="https://github.com/avaloniaui"
+ /// xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ /// xmlns:avrichtextbox="using:AvRichTextBox"
+ /// xmlns:vmi="clr-namespace:AA25_RichTextEdit.ViewModels.Interfaces"
+ /// x:DataType="vmi:IRichTextEditViewModel">
+ /// <DockPanel Background="Silver">
+ /// <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="4" FlowDirection="LeftToRight [Rest der Zeichenfolge wurde abgeschnitten]"; ähnelt.
+ ///
+ public static string RichTextEditView {
+ get {
+ return ResourceManager.GetString("RichTextEditView", resourceCulture);
+ }
}
- }
-
- ///
- /// Sucht eine lokalisierte Zeichenfolge, die // ***********************************************************************
- ///// Assembly : AA25_RichTextEdit
- ///// Author : Mir
- ///// Created : 08-11-2022
- /////
- ///// Last Modified By : Mir
- ///// Last Modified On : 08-24-2022
- ///// ***********************************************************************
- ///// <copyright file="MainWindowViewModel.cs" company="JC-Soft">
- ///// Copyright © JC-Soft 2022
- ///// </copyright>
- ///// <summary></summary>
- ///// ************************************************** [Rest der Zeichenfolge wurde abgeschnitten]"; ähnelt.
- ///
- public static string RichTextEditViewModel {
- get {
- return ResourceManager.GetString("RichTextEditViewModel", resourceCulture);
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die // ***********************************************************************
+ ///// Assembly : AA25_RichTextEdit
+ ///// Author : Mir
+ ///// ***********************************************************************
+ ///using CommunityToolkit.Mvvm.ComponentModel;
+ ///using CommunityToolkit.Mvvm.Input;
+ ///using BaseLib.Helper;
+ ///using AA25_RichTextEdit.Models;
+ ///using System;
+ ///using System.ComponentModel;
+ ///using System.IO;
+ ///using System.Reflection;
+ ///using AA25_RichTextEdit.ViewModels.Interfaces;
+ ///using Avalonia.Vie [Rest der Zeichenfolge wurde abgeschnitten]"; ähnelt.
+ ///
+ public static string RichTextEditViewModel {
+ get {
+ return ResourceManager.GetString("RichTextEditViewModel", resourceCulture);
+ }
}
- }
-
- ///
- /// Sucht eine lokalisierte Zeichenfolge, die CT-RichTextEdit-View ähnelt.
- ///
- public static string Title {
- get {
- return ResourceManager.GetString("Title", resourceCulture);
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die CT-RichTextEdit-View ähnelt.
+ ///
+ public static string Title {
+ get {
+ return ResourceManager.GetString("Title", resourceCulture);
+ }
}
}
}
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.resx b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.resx
index d388d764d..76edafd87 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.resx
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Properties/Resources.resx
@@ -122,7 +122,7 @@
- ..\Views\RichTextEditView.xaml;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8
+ ..\Views\RichTextEditView.axaml;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8
..\ViewModels\RichTextEditViewModel.cs;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;utf-8
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/ViewModels/Helper/RichTextboxHelper.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/ViewModels/Helper/RichTextboxHelper.cs
index a94f94034..91e282c7b 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/ViewModels/Helper/RichTextboxHelper.cs
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/ViewModels/Helper/RichTextboxHelper.cs
@@ -4,7 +4,7 @@
using System.Text;
using System.Threading;
using System.Windows;
-using System.Windows.Markup;
+using Avalonia.Markup;
namespace AA25_RichTextEdit.ViewModels.Helper;
diff --git a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs
index fb78c11c1..e70c8e5ba 100644
--- a/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs
+++ b/Avalonia_Apps/AA25_RichTextEdit/AA25_RichTextEdit/Views/RichTextEditView.axaml.cs
@@ -1,7 +1,7 @@
using Avalonia.Controls;
using System;
-using CommonDialogs.Interfaces;
using AA25_RichTextEdit.ViewModels;
+using Avln_CommonDialogs.Base.Interfaces;
namespace AA25_RichTextEdit.Views;
@@ -35,10 +35,11 @@ private void RichTextEditView_AttachedToVisualTree(object? sender, Avalonia.Visu
private bool? DoPrintDialog(IPrintDialog par, Action? onPrint)
{
- bool? result = par.ShowDialog();
+ bool? result = par.ShowAsync().GetAwaiter().GetResult();
+
if (result ?? false) onPrint?.Invoke(par, null); // Avalonia placeholder
return result;
}
- private void DoClose() => (this.GetVisualRoot() as Window)?.Close();
+ private void DoClose() => (this)?.Close();
}
diff --git a/Avalonia_Apps/Avalonia_Apps.sln b/Avalonia_Apps/Avalonia_Apps.sln
index f87d8f366..4b33baac8 100644
--- a/Avalonia_Apps/Avalonia_Apps.sln
+++ b/Avalonia_Apps/Avalonia_Apps.sln
@@ -237,6 +237,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avln_RichTextEdit", "AA25_R
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Document.Flow", "AA25_RichTextEdit\Document.Flow\Document.Flow.csproj", "{167E435C-6598-F86F-1743-AF5C3B274E13}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AA20a_CommonDialogs", "AA20_SysDialogs\AA20a_CommonDialogs\AA20a_CommonDialogs.csproj", "{9F873885-C33A-C49F-95B8-D0D1A5BE6968}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avln_CommonDialogs.Base", "Libraries\Avln_CommonDialogs.Base\Avln_CommonDialogs.Base.csproj", "{C0C89947-CE7A-36DC-EADF-2C2255AC1D78}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avln_CommonDialogs.Avalonia", "Libraries\Avln_CommonDialogs.Avalonia\Avln_CommonDialogs.Avalonia.csproj", "{D94D599F-6B68-AB23-FF20-0FBD684A1FE7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -447,6 +453,18 @@ Global
{167E435C-6598-F86F-1743-AF5C3B274E13}.Debug|Any CPU.Build.0 = Debug|Any CPU
{167E435C-6598-F86F-1743-AF5C3B274E13}.Release|Any CPU.ActiveCfg = Release|Any CPU
{167E435C-6598-F86F-1743-AF5C3B274E13}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9F873885-C33A-C49F-95B8-D0D1A5BE6968}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9F873885-C33A-C49F-95B8-D0D1A5BE6968}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9F873885-C33A-C49F-95B8-D0D1A5BE6968}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9F873885-C33A-C49F-95B8-D0D1A5BE6968}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C0C89947-CE7A-36DC-EADF-2C2255AC1D78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C0C89947-CE7A-36DC-EADF-2C2255AC1D78}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C0C89947-CE7A-36DC-EADF-2C2255AC1D78}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C0C89947-CE7A-36DC-EADF-2C2255AC1D78}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D94D599F-6B68-AB23-FF20-0FBD684A1FE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D94D599F-6B68-AB23-FF20-0FBD684A1FE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D94D599F-6B68-AB23-FF20-0FBD684A1FE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D94D599F-6B68-AB23-FF20-0FBD684A1FE7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -502,6 +520,9 @@ Global
{9976096C-CC4E-5DDA-B34D-AA25D402703F} = {3DB34546-9932-4FDF-A61C-E42E9C1FFDC9}
{EFE04CA3-4C28-4D56-9AC3-B6C422C24626} = {3DB34546-9932-4FDF-A61C-E42E9C1FFDC9}
{167E435C-6598-F86F-1743-AF5C3B274E13} = {3DB34546-9932-4FDF-A61C-E42E9C1FFDC9}
+ {9F873885-C33A-C49F-95B8-D0D1A5BE6968} = {BE3C4175-29CB-4B10-A72B-45259D58A33C}
+ {C0C89947-CE7A-36DC-EADF-2C2255AC1D78} = {FBC48B38-56FA-4530-850A-8A68B3A959CF}
+ {D94D599F-6B68-AB23-FF20-0FBD684A1FE7} = {FBC48B38-56FA-4530-850A-8A68B3A959CF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {CCEB9146-C458-415E-BB4B-E073B0B24876}
diff --git a/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj b/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj
index 3f4006b73..21257cf41 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_AnimationTiming/Avln_AnimationTiming.csproj
@@ -38,13 +38,13 @@
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj
index e8623ad95..cae31cbef 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_AnimationTimingTests/Avln_AnimationTimingTests.csproj
@@ -9,9 +9,9 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj b/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj
index 3b20b6f46..b3b35d7a1 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Brushes/Avln_Brushes.csproj
@@ -23,12 +23,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj b/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj
index 10b9ea2f9..bd84dad7f 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Complex_Layout/Avln_Complex_Layout.csproj
@@ -18,14 +18,14 @@
-
-
-
-
+
+
+
+
-
-
-
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj
index fd12911bc..4ff71aaa1 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Complex_LayoutTests/Avln_Complex_LayoutTests.csproj
@@ -4,10 +4,10 @@
false
-
-
+
+
-
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj b/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj
index 812caff42..da3ae96b1 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_CustomAnimation/Avln_CustomAnimation.csproj
@@ -12,12 +12,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj b/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj
index 64e26ba81..2fdd96f36 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Geometry/Avln_Geometry.csproj
@@ -25,12 +25,12 @@
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj b/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj
index ca12d3155..e6662ed76 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Hello_World/Avln_Hello_World.csproj
@@ -18,13 +18,13 @@
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj
index 98f11b678..fabea28f5 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Hello_WorldTests/Avln_Hello_WorldTests.csproj
@@ -9,13 +9,13 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj b/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj
index 9f7603ab8..605326e7d 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_ImageView/Avln_ImageView.csproj
@@ -13,13 +13,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj b/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj
index 903f33c19..4c455362c 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_IntegrationTestApp/Avln_IntegrationTestApp.csproj
@@ -22,13 +22,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj b/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj
index 4ab714f8b..e2a0ee05b 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_MoveWindow/Avln_MoveWindow.csproj
@@ -18,13 +18,13 @@
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj b/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj
index 0be2e2957..ccc38fe44 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_MoveWindowTests/Avln_MoveWindowTests.csproj
@@ -9,13 +9,13 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj b/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj
index 131e0d148..2ca3d63f7 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_RenderDemo/Avln_RenderDemo.csproj
@@ -13,13 +13,13 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj b/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj
index 2264d5e57..b8cb57a02 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_Sample_Template/Avln_Sample_Template.csproj
@@ -18,13 +18,13 @@
-
-
-
-
+
+
+
+
-
-
+
+
diff --git a/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj b/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj
index 2b7096ffe..341fbf652 100644
--- a/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj
+++ b/Avalonia_Apps/AvlnSamples/Avln_TextTestApp/Avln_TextTestApp.csproj
@@ -10,10 +10,10 @@
-
-
-
-
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj
new file mode 100644
index 000000000..33f8a20eb
--- /dev/null
+++ b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.Pack.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0;net9.0
+ enable
+ true
+ ControlSamples
+ 0.1.0-local
+ Samples
+ Reusable sample controls for Avalonia demos.
+ $(SolutionDir).packages
+ false
+ ControlSamples
+
+
+
+
+ %(Filename)
+
+
+ Designer
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.csproj b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.csproj
new file mode 100644
index 000000000..240b4adce
--- /dev/null
+++ b/Avalonia_Apps/AvlnSamples/SampleControls/ControlSamples.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0;net9.0
+ enable
+ true
+ ControlSamples
+ 0.1.0-local
+ Samples
+ Reusable sample controls for Avalonia demos.
+ $(SolutionDir).packages
+ false
+
+
+
+
+ %(Filename)
+
+
+ Designer
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.cs b/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.cs
new file mode 100644
index 000000000..57f8a138a
--- /dev/null
+++ b/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.cs
@@ -0,0 +1,84 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Media;
+
+namespace ControlSamples
+{
+ public class HamburgerMenu : TabControl
+ {
+ private SplitView? _splitView;
+
+ public static readonly StyledProperty PaneBackgroundProperty =
+ SplitView.PaneBackgroundProperty.AddOwner();
+
+ public IBrush? PaneBackground
+ {
+ get => GetValue(PaneBackgroundProperty);
+ set => SetValue(PaneBackgroundProperty, value);
+ }
+
+ public static readonly StyledProperty ContentBackgroundProperty =
+ AvaloniaProperty.Register(nameof(ContentBackground));
+
+ public IBrush? ContentBackground
+ {
+ get => GetValue(ContentBackgroundProperty);
+ set => SetValue(ContentBackgroundProperty, value);
+ }
+
+ public static readonly StyledProperty ExpandedModeThresholdWidthProperty =
+ AvaloniaProperty.Register(nameof(ExpandedModeThresholdWidth), 1008);
+
+ public int ExpandedModeThresholdWidth
+ {
+ get => GetValue(ExpandedModeThresholdWidthProperty);
+ set => SetValue(ExpandedModeThresholdWidthProperty, value);
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ _splitView = e.NameScope.Find("PART_NavigationPane");
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == BoundsProperty && _splitView is not null)
+ {
+ var (oldBounds, newBounds) = change.GetOldAndNewValue();
+ EnsureSplitViewMode(oldBounds, newBounds);
+ }
+
+ if (change.Property == SelectedItemProperty)
+ {
+ if (_splitView is not null && _splitView.DisplayMode == SplitViewDisplayMode.Overlay)
+ {
+ _splitView.SetCurrentValue(SplitView.IsPaneOpenProperty, false);
+ }
+ }
+ }
+
+ private void EnsureSplitViewMode(Rect oldBounds, Rect newBounds)
+ {
+ if (_splitView is not null)
+ {
+ var threshold = ExpandedModeThresholdWidth;
+
+ if (newBounds.Width >= threshold)
+ {
+ _splitView.DisplayMode = SplitViewDisplayMode.Inline;
+ _splitView.IsPaneOpen = true;
+ }
+ else if (newBounds.Width < threshold)
+ {
+ _splitView.DisplayMode = SplitViewDisplayMode.Overlay;
+ _splitView.IsPaneOpen = false;
+ }
+ }
+ }
+ }
+}
diff --git a/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.xaml b/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.xaml
new file mode 100644
index 000000000..4ab09e958
--- /dev/null
+++ b/Avalonia_Apps/AvlnSamples/SampleControls/HamburgerMenu/HamburgerMenu.xaml
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #99FFFFFF
+ #FF1F1F1F
+ #FF000000
+ #FF171717
+
+
+ #99000000
+ #FFE6E6E6
+ #FFFFFFFF
+ #FFF2F2F2
+
+
+
+ 40
+ 220
+ 36
+ 36
+ 32
+ 12,0,0,0
+ 52,0,0,0
+ 212,0,0,0
+ 1 1 1 1 #2000, 0 0 1 1 #2fff
+ 0 0 1 1 #2000
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/BenchmarkSuite1/BenchmarkSuite1.csproj b/Avalonia_Apps/BenchmarkSuite1/BenchmarkSuite1.csproj
index ec0e7cb39..1556596f7 100644
--- a/Avalonia_Apps/BenchmarkSuite1/BenchmarkSuite1.csproj
+++ b/Avalonia_Apps/BenchmarkSuite1/BenchmarkSuite1.csproj
@@ -8,8 +8,8 @@
-
-
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj b/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj
index 228b64b39..cfa2854a4 100644
--- a/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj
+++ b/Avalonia_Apps/Libraries/Avln_BaseLib/Avln_BaseLib.csproj
@@ -26,10 +26,10 @@
-
-
-
-
+
+
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj b/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj
index e35c3cdfd..b24aa4ce8 100644
--- a/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj
+++ b/Avalonia_Apps/Libraries/Avln_BaseLibTests/Avln_BaseLibTests.csproj
@@ -15,15 +15,15 @@
-
-
-
-
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Avln_CommonDialogs.Avalonia.csproj b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Avln_CommonDialogs.Avalonia.csproj
new file mode 100644
index 000000000..8ea65432d
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Avln_CommonDialogs.Avalonia.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaColorDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaColorDialog.cs
new file mode 100644
index 000000000..f1235e99b
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaColorDialog.cs
@@ -0,0 +1,80 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace Avln_CommonDialogs.Avalonia.Dialogs;
+
+public sealed class AvaloniaColorDialog : IColorDialog
+{
+ private readonly Func _topLevelProvider;
+
+ public AvaloniaColorDialog(Func topLevelProvider)
+ {
+ _topLevelProvider = topLevelProvider;
+ }
+
+ public bool AllowAlpha { get; set; } = true;
+
+ // expects/returns Avalonia.Media.Color (kept as object for base agnostic interface)
+ public object? Color { get; set; }
+
+ public ValueTask ShowAsync(object owner)
+ => ShowInternalAsync(TopLevelOwner.From(owner));
+
+ public ValueTask ShowAsync()
+ => ShowInternalAsync(_topLevelProvider());
+
+ private async ValueTask ShowInternalAsync(TopLevel? owner)
+ {
+ var ownerWindow = owner as Window;
+
+ var picker = new global::Avalonia.Controls.ColorPicker
+ {
+ IsAlphaEnabled = AllowAlpha,
+ Color = Color is Color c ? c : Colors.Black
+ };
+
+ var ok = new Button { Content = "OK", IsDefault = true };
+ var cancel = new Button { Content = "Cancel", IsCancel = true };
+
+ var buttons = new StackPanel
+ {
+ Orientation = global::Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Right,
+ Spacing = 8,
+ Children = { ok, cancel }
+ };
+
+ var root = new DockPanel
+ {
+ LastChildFill = true,
+ Children = { buttons, picker }
+ };
+
+ DockPanel.SetDock(buttons, Dock.Bottom);
+
+ var dlg = new Window
+ {
+ Title = "Color",
+ Width = 520,
+ Height = 420,
+ Content = root,
+ WindowStartupLocation = ownerWindow is null
+ ? WindowStartupLocation.CenterScreen
+ : WindowStartupLocation.CenterOwner
+ };
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ ok.Click += (_, _) => { Color = picker.Color; tcs.TrySetResult(true); dlg.Close(); };
+ cancel.Click += (_, _) => { tcs.TrySetResult(false); dlg.Close(); };
+ dlg.Closed += (_, _) => tcs.TrySetResult(null);
+
+ if (ownerWindow is not null)
+ await dlg.ShowDialog(ownerWindow).ConfigureAwait(false);
+ else
+ dlg.Show();
+
+ return await tcs.Task.ConfigureAwait(false);
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaFontDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaFontDialog.cs
new file mode 100644
index 000000000..d4780964d
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaFontDialog.cs
@@ -0,0 +1,84 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace Avln_CommonDialogs.Avalonia.Dialogs;
+
+public sealed class AvaloniaFontDialog : IFontDialog
+{
+ private readonly Func _topLevelProvider;
+
+ public AvaloniaFontDialog(Func topLevelProvider)
+ {
+ _topLevelProvider = topLevelProvider;
+ }
+
+ // expects/returns Avalonia.Media.FontFamily (kept as object for base agnostic interface)
+ public object? Font { get; set; }
+
+ public ValueTask ShowAsync(object owner)
+ => ShowInternalAsync(TopLevelOwner.From(owner));
+
+ public ValueTask ShowAsync()
+ => ShowInternalAsync(_topLevelProvider());
+
+ private async ValueTask ShowInternalAsync(TopLevel? owner)
+ {
+ var ownerWindow = owner as Window;
+
+ var families = FontManager.Current.SystemFonts
+ .OrderBy(f => f.Name)
+ .ToArray();
+
+ var list = new ListBox
+ {
+ ItemsSource = families
+ };
+
+ if (Font is FontFamily current)
+ list.SelectedItem = families.FirstOrDefault(f => f.Name == current.Name);
+
+ var ok = new Button { Content = "OK", IsDefault = true };
+ var cancel = new Button { Content = "Cancel", IsCancel = true };
+
+ var buttons = new StackPanel
+ {
+ Orientation = global::Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = global::Avalonia.Layout.HorizontalAlignment.Right,
+ Spacing = 8,
+ Children = { ok, cancel }
+ };
+
+ var root = new DockPanel
+ {
+ LastChildFill = true,
+ Children = { buttons, list }
+ };
+
+ DockPanel.SetDock(buttons, Dock.Bottom);
+
+ var dlg = new Window
+ {
+ Title = "Font",
+ Width = 700,
+ Height = 520,
+ Content = root,
+ WindowStartupLocation = ownerWindow is null
+ ? WindowStartupLocation.CenterScreen
+ : WindowStartupLocation.CenterOwner
+ };
+
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ ok.Click += (_, _) => { Font = list.SelectedItem as FontFamily; tcs.TrySetResult(true); dlg.Close(); };
+ cancel.Click += (_, _) => { tcs.TrySetResult(false); dlg.Close(); };
+ dlg.Closed += (_, _) => tcs.TrySetResult(null);
+
+ if (ownerWindow is not null)
+ await dlg.ShowDialog(ownerWindow).ConfigureAwait(false);
+ else
+ dlg.Show();
+
+ return await tcs.Task.ConfigureAwait(false);
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaOpenFileDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaOpenFileDialog.cs
new file mode 100644
index 000000000..3743f40ac
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaOpenFileDialog.cs
@@ -0,0 +1,93 @@
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace Avln_CommonDialogs.Avalonia.Dialogs;
+
+public sealed class AvaloniaOpenFileDialog : IOpenFileDialog
+{
+ private readonly Func _topLevelProvider;
+
+ public AvaloniaOpenFileDialog(Func topLevelProvider)
+ {
+ _topLevelProvider = topLevelProvider;
+ }
+
+ public string? Title { get; set; }
+ public string? InitialDirectory { get; set; }
+ public bool RestoreDirectory { get; set; }
+ public bool AddExtension { get; set; }
+ public bool CheckFileExists { get; set; }
+ public string? DefaultExtension { get; set; }
+
+ public bool AllowMultiple { get; set; }
+
+ public IList MutableFilters { get; } = new List();
+ public IReadOnlyList Filters => MutableFilters as IReadOnlyList;
+
+ public ValueTask> ShowAsync(object owner)
+ => ShowInternalAsync(TopLevelOwner.From(owner));
+
+ public ValueTask> ShowAsync()
+ => ShowInternalAsync(_topLevelProvider());
+
+ private async ValueTask> ShowInternalAsync(TopLevel? topLevel)
+ {
+ if (topLevel?.StorageProvider is not { } sp)
+ return Array.Empty();
+
+ var options = new FilePickerOpenOptions
+ {
+ Title = Title,
+ AllowMultiple = AllowMultiple,
+ FileTypeFilter = ConvertFilters(MutableFilters)
+ };
+
+ if (!string.IsNullOrWhiteSpace(InitialDirectory))
+ {
+ try
+ {
+ options.SuggestedStartLocation = await sp.TryGetFolderFromPathAsync(InitialDirectory).ConfigureAwait(false);
+ }
+ catch
+ {
+ // ignore invalid paths
+ }
+ }
+
+ var files = await sp.OpenFilePickerAsync(options).ConfigureAwait(false);
+ return files.Select(f => f.Path.LocalPath).ToArray();
+ }
+
+ private static IReadOnlyList? ConvertFilters(IEnumerable filters)
+ {
+ var list = new List();
+
+ foreach (var f in filters)
+ {
+ var patterns = f.Patterns
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(NormalizePattern)
+ .ToArray();
+
+ list.Add(new FilePickerFileType(f.Name)
+ {
+ Patterns = patterns.Length == 0 ? null : patterns
+ });
+ }
+
+ return list.Count == 0 ? null : list;
+ }
+
+ private static string NormalizePattern(string pattern)
+ {
+ pattern = pattern.Trim();
+ if (pattern.StartsWith("*.", StringComparison.Ordinal) || pattern.StartsWith("*", StringComparison.Ordinal))
+ return pattern;
+
+ if (pattern.StartsWith(".", StringComparison.Ordinal))
+ return "*" + pattern;
+
+ return "*." + pattern;
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaSaveFileDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaSaveFileDialog.cs
new file mode 100644
index 000000000..088465862
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Dialogs/AvaloniaSaveFileDialog.cs
@@ -0,0 +1,95 @@
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace Avln_CommonDialogs.Avalonia.Dialogs;
+
+public sealed class AvaloniaSaveFileDialog : ISaveFileDialog
+{
+ private readonly Func _topLevelProvider;
+
+ public AvaloniaSaveFileDialog(Func topLevelProvider)
+ {
+ _topLevelProvider = topLevelProvider;
+ }
+
+ public string? Title { get; set; }
+ public string? InitialDirectory { get; set; }
+ public bool RestoreDirectory { get; set; }
+ public bool AddExtension { get; set; }
+ public bool CheckFileExists { get; set; }
+ public string? DefaultExtension { get; set; }
+
+ public string? InitialFileName { get; set; }
+ public bool OverwritePrompt { get; set; }
+
+ public IList MutableFilters { get; } = new List();
+ public IReadOnlyList Filters => MutableFilters as IReadOnlyList;
+
+ public ValueTask ShowAsync(object owner)
+ => ShowInternalAsync(TopLevelOwner.From(owner));
+
+ public ValueTask ShowAsync()
+ => ShowInternalAsync(_topLevelProvider());
+
+ private async ValueTask ShowInternalAsync(TopLevel? topLevel)
+ {
+ if (topLevel?.StorageProvider is not { } sp)
+ return null;
+
+ var options = new FilePickerSaveOptions
+ {
+ Title = Title,
+ SuggestedFileName = InitialFileName,
+ DefaultExtension = DefaultExtension,
+ FileTypeChoices = ConvertFilters(MutableFilters)
+ };
+
+ if (!string.IsNullOrWhiteSpace(InitialDirectory))
+ {
+ try
+ {
+ options.SuggestedStartLocation = await sp.TryGetFolderFromPathAsync(InitialDirectory).ConfigureAwait(false);
+ }
+ catch
+ {
+ // ignore invalid paths
+ }
+ }
+
+ var file = await sp.SaveFilePickerAsync(options).ConfigureAwait(false);
+ return file?.Path.LocalPath;
+ }
+
+ private static IReadOnlyList? ConvertFilters(IEnumerable filters)
+ {
+ var list = new List();
+
+ foreach (var f in filters)
+ {
+ var patterns = f.Patterns
+ .Where(p => !string.IsNullOrWhiteSpace(p))
+ .Select(NormalizePattern)
+ .ToArray();
+
+ list.Add(new FilePickerFileType(f.Name)
+ {
+ Patterns = patterns.Length == 0 ? null : patterns
+ });
+ }
+
+ return list.Count == 0 ? null : list;
+ }
+
+ private static string NormalizePattern(string pattern)
+ {
+ pattern = pattern.Trim();
+ if (pattern.StartsWith("*.", StringComparison.Ordinal) || pattern.StartsWith("*", StringComparison.Ordinal))
+ return pattern;
+
+ if (pattern.StartsWith(".", StringComparison.Ordinal))
+ return "*" + pattern;
+
+ return "*." + pattern;
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Directory.Packages.props b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Directory.Packages.props
new file mode 100644
index 000000000..ae5ae426a
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Directory.Packages.props
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Printing/NotSupportedPrintDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Printing/NotSupportedPrintDialog.cs
new file mode 100644
index 000000000..a44fc4617
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/Printing/NotSupportedPrintDialog.cs
@@ -0,0 +1,23 @@
+using Avln_CommonDialogs.Base.Interfaces;
+
+namespace Avln_CommonDialogs.Avalonia.Printing;
+
+public sealed class NotSupportedPrintDialog : IPrintDialog
+{
+ public string? Title { get; set; }
+
+ public uint MinPage { get; set; } = 1;
+ public uint MaxPage { get; set; } = 1;
+
+ public bool AllowCurrentPage { get; set; }
+ public bool AllowSelectedPages { get; set; }
+ public bool AllowPageRange { get; set; } = true;
+
+ public PrintPageRange? PageRange { get; set; }
+
+ public ValueTask ShowAsync(object owner)
+ => new((IPrintSession?)null);
+
+ public ValueTask ShowAsync()
+ => new((IPrintSession?)null);
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/ServiceCollectionExtensions.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/ServiceCollectionExtensions.cs
new file mode 100644
index 000000000..71e8e4826
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/ServiceCollectionExtensions.cs
@@ -0,0 +1,32 @@
+using Avln_CommonDialogs.Avalonia.Dialogs;
+using Avln_CommonDialogs.Avalonia.Printing;
+using Avln_CommonDialogs.Base.Interfaces;
+using Avalonia.Controls;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Avln_CommonDialogs.Avalonia;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers Avalonia-based dialog implementations.
+ ///
+ /// The is used for parameterless ShowAsync() calls.
+ /// If you always call the owner overloads, you can pass () => null.
+ ///
+ public static IServiceCollection AddAvaloniaCommonDialogs(
+ this IServiceCollection services,
+ Func topLevelProvider)
+ {
+ services.AddTransient(_ => new AvaloniaOpenFileDialog(topLevelProvider));
+ services.AddTransient(_ => new AvaloniaSaveFileDialog(topLevelProvider));
+ services.AddTransient(_ => new AvaloniaColorDialog(topLevelProvider));
+ services.AddTransient(_ => new AvaloniaFontDialog(topLevelProvider));
+
+ // Printing is not provided cross-platform by Avalonia out of the box.
+ // Register a placeholder so consumers can still resolve IPrintDialog.
+ services.AddTransient();
+
+ return services;
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/TopLevelOwner.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/TopLevelOwner.cs
new file mode 100644
index 000000000..f9793988d
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Avalonia/TopLevelOwner.cs
@@ -0,0 +1,20 @@
+using Avalonia.Controls;
+
+namespace Avln_CommonDialogs.Avalonia;
+
+public static class TopLevelOwner
+{
+ public static TopLevel? From(object? owner)
+ {
+ if (owner is null)
+ return null;
+
+ if (owner is TopLevel tl)
+ return tl;
+
+ if (owner is Control c)
+ return TopLevel.GetTopLevel(c);
+
+ return null;
+ }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Avln_CommonDialogs.Base.csproj b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Avln_CommonDialogs.Base.csproj
new file mode 100644
index 000000000..fa71b7ae6
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Avln_CommonDialogs.Base.csproj
@@ -0,0 +1,9 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Directory.Packages.props b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Directory.Packages.props
new file mode 100644
index 000000000..ae5ae426a
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Directory.Packages.props
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/FileTypeFilter.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/FileTypeFilter.cs
new file mode 100644
index 000000000..c66aef74b
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/FileTypeFilter.cs
@@ -0,0 +1,3 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public sealed record FileTypeFilter(string Name, IReadOnlyList Patterns);
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IColorDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IColorDialog.cs
new file mode 100644
index 000000000..d4d263f8b
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IColorDialog.cs
@@ -0,0 +1,10 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IColorDialog
+{
+ bool AllowAlpha { get; set; }
+ object? Color { get; set; }
+
+ ValueTask ShowAsync(object owner);
+ ValueTask ShowAsync();
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFileDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFileDialog.cs
new file mode 100644
index 000000000..ae2ff084b
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFileDialog.cs
@@ -0,0 +1,13 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IFileDialog
+{
+ string? Title { get; set; }
+ string? InitialDirectory { get; set; }
+ bool RestoreDirectory { get; set; }
+ bool AddExtension { get; set; }
+ bool CheckFileExists { get; set; }
+ string? DefaultExtension { get; set; }
+
+ IReadOnlyList Filters { get; }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFontDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFontDialog.cs
new file mode 100644
index 000000000..6f2cef2ba
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IFontDialog.cs
@@ -0,0 +1,9 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IFontDialog
+{
+ object? Font { get; set; }
+
+ ValueTask ShowAsync(object owner);
+ ValueTask ShowAsync();
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IOpenFileDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IOpenFileDialog.cs
new file mode 100644
index 000000000..a393feae7
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IOpenFileDialog.cs
@@ -0,0 +1,11 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IOpenFileDialog : IFileDialog
+{
+ bool AllowMultiple { get; set; }
+
+ IList MutableFilters { get; }
+
+ ValueTask> ShowAsync(object owner);
+ ValueTask> ShowAsync();
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintDialog.cs
new file mode 100644
index 000000000..86c56fe52
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintDialog.cs
@@ -0,0 +1,18 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IPrintDialog
+{
+ string? Title { get; set; }
+
+ uint MinPage { get; set; }
+ uint MaxPage { get; set; }
+
+ bool AllowCurrentPage { get; set; }
+ bool AllowSelectedPages { get; set; }
+ bool AllowPageRange { get; set; }
+
+ PrintPageRange? PageRange { get; set; }
+
+ ValueTask ShowAsync(object owner);
+ ValueTask ShowAsync();
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintPageCanvas.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintPageCanvas.cs
new file mode 100644
index 000000000..af15f94da
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintPageCanvas.cs
@@ -0,0 +1,13 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IPrintPageCanvas
+{
+ double Width { get; }
+ double Height { get; }
+
+ ///
+ /// Underlying platform-specific drawing target.
+ /// Implementations may expose an Avalonia DrawingContext, SKCanvas, etc.
+ ///
+ object Target { get; }
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintSession.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintSession.cs
new file mode 100644
index 000000000..0f8d4c5b3
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/IPrintSession.cs
@@ -0,0 +1,17 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface IPrintSession : IAsyncDisposable
+{
+ PrintDocumentInfo DocumentInfo { get; }
+
+ ///
+ /// Begins a new page and returns a canvas-like target to draw onto.
+ /// Caller owns the lifetime of the returned object only until .
+ ///
+ ValueTask BeginPageAsync(CancellationToken cancellationToken = default);
+
+ ValueTask EndPageAsync(CancellationToken cancellationToken = default);
+
+ ValueTask CommitAsync(CancellationToken cancellationToken = default);
+ ValueTask CancelAsync(CancellationToken cancellationToken = default);
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/ISaveFileDialog.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/ISaveFileDialog.cs
new file mode 100644
index 000000000..c4442c51a
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/ISaveFileDialog.cs
@@ -0,0 +1,12 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public interface ISaveFileDialog : IFileDialog
+{
+ string? InitialFileName { get; set; }
+ bool OverwritePrompt { get; set; }
+
+ IList MutableFilters { get; }
+
+ ValueTask ShowAsync(object owner);
+ ValueTask ShowAsync();
+}
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintDocumentInfo.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintDocumentInfo.cs
new file mode 100644
index 000000000..3efc01b4f
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintDocumentInfo.cs
@@ -0,0 +1,7 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public sealed record PrintDocumentInfo(
+ double PrintableAreaWidth,
+ double PrintableAreaHeight,
+ double DpiX,
+ double DpiY);
diff --git a/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintPageRange.cs b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintPageRange.cs
new file mode 100644
index 000000000..6977aa366
--- /dev/null
+++ b/Avalonia_Apps/Libraries/Avln_CommonDialogs.Base/Interfaces/PrintPageRange.cs
@@ -0,0 +1,3 @@
+namespace Avln_CommonDialogs.Base.Interfaces;
+
+public readonly record struct PrintPageRange(uint From, uint To);
diff --git a/Avalonia_Apps/Libraries/BaseLib/BaseLib.csproj b/Avalonia_Apps/Libraries/BaseLib/BaseLib.csproj
index db46080b4..c6fd681ff 100644
--- a/Avalonia_Apps/Libraries/BaseLib/BaseLib.csproj
+++ b/Avalonia_Apps/Libraries/BaseLib/BaseLib.csproj
@@ -3,6 +3,7 @@
Library
net6.0;net7.0;net8.0;net481;net48;net472;net462
+ AnyCPU;x64
@@ -29,7 +30,7 @@
-
-
+
+
\ No newline at end of file
diff --git a/Avalonia_Apps/Libraries/BaseLib/Helper/ListHelper.cs b/Avalonia_Apps/Libraries/BaseLib/Helper/ListHelper.cs
index 6a274e268..432f0175c 100644
--- a/Avalonia_Apps/Libraries/BaseLib/Helper/ListHelper.cs
+++ b/Avalonia_Apps/Libraries/BaseLib/Helper/ListHelper.cs
@@ -9,26 +9,72 @@
//
// Copyright © JC-Soft 2023
//
-//
+//
+// Provides extension methods for working with generic lists and arrays,
+// including item manipulation, swapping, and range generation utilities.
+//
// ***********************************************************************
using System;
using System.Collections.Generic;
///
-/// The Helper namespace.
+/// The Helper namespace contains utility classes and extension methods
+/// that provide common functionality across the BaseLib library.
///
namespace BaseLib.Helper;
///
-/// Class ListHelper.
+/// Provides static extension methods for manipulating generic lists and generating arrays.
+/// This class contains helper methods for common list operations such as moving items,
+/// swapping elements, and creating ranges of values.
///
+///
+/// All methods in this class are implemented as extension methods, allowing them to be
+/// called directly on instances or value types.
+///
+///
+///
+/// // Moving an item in a list
+/// var list = new List<string> { "A", "B", "C", "D" };
+/// list.MoveItem(0, 3); // Result: { "B", "C", "A", "D" }
+///
+/// // Swapping items
+/// list.Swap(0, 2); // Swaps items at index 0 and 2
+///
+/// // Creating a range
+/// int[] range = 1.To(5); // Result: { 1, 2, 3, 4, 5 }
+///
+///
public static class ListHelper
{
- /// Moves the item from source to target.
- /// The generic type of the list
- /// The list to change.
- /// The index of the source.
- /// The index of the destination.
+ ///
+ /// Moves an item from a source index to a destination index within the list.
+ ///
+ /// The type of elements in the list.
+ /// The list in which to move the item.
+ /// The zero-based index of the item to move.
+ /// The zero-based destination index where the item should be placed.
+ ///
+ ///
+ /// If the source and destination indices are equal, no operation is performed.
+ ///
+ ///
+ /// The method handles the index shift that occurs when removing an item before
+ /// the destination index. If an exception occurs during insertion, the item
+ /// is restored to its original position before re-throwing the exception.
+ ///
+ ///
+ ///
+ /// Thrown when or is outside
+ /// the valid range of indices for the list.
+ ///
+ ///
+ ///
+ /// var list = new List<int> { 1, 2, 3, 4, 5 };
+ /// list.MoveItem(0, 4); // Moves element at index 0 to index 4
+ /// // Result: { 2, 3, 4, 1, 5 }
+ ///
+ ///
public static void MoveItem(this IList list, int iSrc, int iDst)
{
if (iSrc == iDst) return;
@@ -46,18 +92,59 @@ public static void MoveItem(this IList list, int iSrc, int iDst)
}
///
- /// Exchanges the items.
+ /// Exchanges (swaps) the positions of two items in the list.
///
- /// The generic type of the list
- /// The list to change.
- /// The index of the first item.
- /// The index of the second item.
+ /// The type of elements in the list.
+ /// The list containing the items to swap.
+ /// The zero-based index of the first item to swap.
+ /// The zero-based index of the second item to swap.
+ ///
+ /// If both indices are equal, no operation is performed.
+ /// Uses tuple deconstruction for efficient in-place swapping.
+ ///
+ ///
+ /// Thrown when or is outside
+ /// the valid range of indices for the list.
+ ///
+ ///
+ ///
+ /// var list = new List<string> { "A", "B", "C" };
+ /// list.Swap(0, 2); // Swaps "A" and "C"
+ /// // Result: { "C", "B", "A" }
+ ///
+ ///
public static void Swap(this IList list, int iItm1, int iItm2)
{
if (iItm1 == iItm2) return;
- (list[iItm1], list[iItm2]) = (list[iItm2],list[iItm1]);
+ (list[iItm1], list[iItm2]) = (list[iItm2], list[iItm1]);
}
+ ///
+ /// Creates an array containing a range of values from to inclusive.
+ ///
+ ///
+ /// A value type that implements and can be converted to and from .
+ ///
+ ///
+ /// The instance used as an extension method anchor. This parameter is not used.
+ ///
+ /// The starting value of the range (inclusive).
+ /// The ending value of the range (inclusive).
+ ///
+ /// An array of type containing all values from
+ /// to inclusive. Returns an empty array if
+ /// is greater than .
+ ///
+ ///
+ /// This method uses internally, so it works best
+ /// with numeric types that can be represented as integers.
+ ///
+ ///
+ ///
+ /// int[] range = typeof(int).Range(1, 5); // Result: { 1, 2, 3, 4, 5 }
+ /// int[] empty = typeof(int).Range(5, 1); // Result: empty array
+ ///
+ ///
public static T[] Range(this Type _, T v1, T v2) where T : struct, IComparable
{
if (v1.CompareTo(v2) > 0)
@@ -67,6 +154,37 @@ public static T[] Range(this Type _, T v1, T v2) where T : struct, IComparabl
result[i] = (T)Convert.ChangeType(Convert.ToInt32(v1) + i, typeof(T));
return result;
}
+
+ ///
+ /// Creates an array containing a range of values from the current value to the specified end value inclusive.
+ ///
+ ///
+ /// A value type that implements and can be converted to and from .
+ ///
+ /// The starting value of the range (inclusive).
+ /// The ending value of the range (inclusive).
+ ///
+ /// An array of type containing all values from
+ /// to inclusive. Returns an empty array if
+ /// is greater than .
+ ///
+ ///
+ ///
+ /// This extension method provides a fluent syntax for creating ranges directly from a value.
+ ///
+ ///
+ /// This method uses internally, so it works best
+ /// with numeric types that can be represented as integers (e.g., ,
+ /// , ).
+ ///
+ ///
+ ///
+ ///
+ /// int[] range = 1.To(5); // Result: { 1, 2, 3, 4, 5 }
+ /// byte[] bytes = ((byte)0).To((byte)3); // Result: { 0, 1, 2, 3 }
+ /// int[] empty = 10.To(5); // Result: empty array
+ ///
+ ///
public static T[] To(this T v1, T v2) where T : struct, IComparable
{
if (v1.CompareTo(v2) > 0)
@@ -76,5 +194,4 @@ public static T[] To(this T v1, T v2) where T : struct, IComparable
result[i] = (T)Convert.ChangeType(Convert.ToInt32(v1) + i, typeof(T));
return result;
}
-
}
diff --git a/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs b/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs
index 04832da4d..686e531de 100644
--- a/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs
+++ b/Avalonia_Apps/Libraries/BaseLib/Helper/ObjectHelper.cs
@@ -1,4 +1,16 @@
-
+// ***********************************************************************
+// Assembly : BaseLib
+// Author : Mir
+// Created : 03-27-2023
+//
+// Last Modified By : Mir
+// Last Modified On : 03-27-2023
+// ***********************************************************************
+//
+// Copyright © JC-Soft 2025
+//
+//
+// ***********************************************************************
using BaseLib.Interfaces;
using System;
using System.Collections.Generic;
@@ -9,8 +21,54 @@
namespace BaseLib.Helper;
+///
+/// Provides extension methods for safe type conversion of instances to various primitive and common types.
+///
+///
+///
+/// This static class contains a collection of extension methods that facilitate the conversion of objects
+/// to specific types such as , , , ,
+/// , , and .
+///
+///
+/// Each conversion method handles various input types including:
+///
+/// - Direct type matches (returns the value as-is)
+/// - String parsing with appropriate format providers
+/// - interface implementations for recursive value extraction
+/// - and values (returns default)
+/// - implementations for standard .NET type conversions
+///
+///
+///
public static class ObjectHelper
{
+ ///
+ /// Converts an object to a 32-bit signed integer.
+ ///
+ /// The object to convert. Can be .
+ /// The default value to return when conversion fails. Defaults to 0.
+ ///
+ /// The converted value, or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already an , returns it directly.
+ /// - If is a , performs an unchecked cast to .
+ /// - If is a , attempts to parse it as an integer.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns .
+ /// - If implements , uses with .
+ ///
+ ///
+ ///
+ ///
+ /// int result1 = "42".AsInt(); // Returns 42
+ /// int result2 = ((object)null).AsInt(); // Returns 0
+ /// int result3 = "invalid".AsInt(-1); // Returns -1
+ ///
+ ///
public static int AsInt(this object? obj, int def = default) => obj switch
{
int i => i,
@@ -23,6 +81,31 @@ public static class ObjectHelper
_ => def
};
+ ///
+ /// Converts an object to a 64-bit signed integer.
+ ///
+ /// The object to convert. Can be .
+ /// The default value to return when conversion fails. Defaults to 0.
+ ///
+ /// The converted value, or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already a , returns it directly.
+ /// - If is a , performs an unchecked cast to .
+ /// - If is a , attempts to parse it as a long integer.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns .
+ /// - If implements , uses with .
+ ///
+ ///
+ ///
+ ///
+ /// long result1 = "9223372036854775807".AsLong(); // Returns long.MaxValue
+ /// long result2 = ((object)null).AsLong(); // Returns 0
+ ///
+ ///
public static long AsLong(this object? obj, int def = default) => obj switch
{
long i => i,
@@ -35,7 +118,34 @@ public static class ObjectHelper
_ => def
};
-
+ ///
+ /// Converts an object to an enumeration value of the specified type.
+ ///
+ /// The enumeration type to convert to. Must be a value type and an .
+ /// The object to convert. Can be .
+ ///
+ /// The converted enumeration value of type , or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already of type , returns it directly.
+ /// - If is an within the valid enum range, converts it to the enum value.
+ /// - If is a , attempts to parse it as an enum name.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns .
+ /// - If is a , performs an unchecked cast and converts to enum.
+ /// - If implements , converts to first, then to enum.
+ ///
+ ///
+ ///
+ ///
+ /// enum Color { Red, Green, Blue }
+ /// Color result1 = "Green".AsEnum<Color>(); // Returns Color.Green
+ /// Color result2 = 1.AsEnum<Color>(); // Returns Color.Green
+ /// Color result3 = "Invalid".AsEnum<Color>(); // Returns Color.Red (default)
+ ///
+ ///
public static T AsEnum(this object? obj) where T : struct, Enum => obj switch
{
T t => t,
@@ -51,6 +161,37 @@ string s when Enum.TryParse(s, out var t) => t,
_ => default
};
+ ///
+ /// Converts an object to a value.
+ ///
+ /// The object to convert. Can be .
+ ///
+ /// The converted value, or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already a , returns it directly.
+ /// - If is an in the format YYYYMMDD (e.g., 20231225), parses it as a date.
+ /// - If is an that is 0 or greater than 10000000, returns .
+ /// - If implements , recursively converts its .
+ /// - If is a without dots, attempts to parse using .
+ /// - If is a with dots, attempts to parse using .
+ /// - If is a representing a numeric date (YYYYMMDD), parses accordingly.
+ /// - If is a , treats it as ticks for .
+ /// - If is a , converts from OLE Automation date format.
+ /// - If is a , creates a date with year = char value + 1900.
+ /// - If is , returns .
+ /// - If implements , converts from OLE Automation date format.
+ ///
+ ///
+ ///
+ ///
+ /// DateTime result1 = "2023-12-25".AsDate(); // Returns December 25, 2023
+ /// DateTime result2 = 20231225.AsDate(); // Returns December 25, 2023
+ /// DateTime result3 = ((object)null).AsDate(); // Returns DateTime.MinValue
+ ///
+ ///
public static DateTime AsDate(this object? obj) => obj switch
{
DateTime dt => dt,
@@ -69,6 +210,35 @@ string s when DateTime.TryParse(s, CultureInfo.CurrentUICulture, DateTimeStyles.
_ => default,
};
+ ///
+ /// Converts an object to a double-precision floating-point number.
+ ///
+ /// The object to convert. Can be .
+ ///
+ /// The culture-specific formatting information to use for parsing strings.
+ /// If , is used.
+ ///
+ ///
+ /// The converted value, or 0.0 if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already a , returns it directly.
+ /// - If is a , attempts to parse it using and the specified culture.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns (0.0).
+ /// - If is a , returns its numeric value.
+ /// - If implements , uses .
+ ///
+ ///
+ ///
+ ///
+ /// double result1 = "3.14".AsDouble(); // Returns 3.14
+ /// double result2 = "3,14".AsDouble(CultureInfo.GetCultureInfo("de-DE")); // Returns 3.14
+ /// double result3 = ((object)null).AsDouble(); // Returns 0.0
+ ///
+ ///
public static double AsDouble(this object? obj, CultureInfo? culture = null) => obj switch
{
double d => d,
@@ -82,6 +252,33 @@ string s when double.TryParse(s, NumberStyles.Float, culture ?? CultureInfo.Inva
_ => default
};
+ ///
+ /// Converts an object to a Boolean value.
+ ///
+ /// The object to convert. Can be .
+ ///
+ /// The converted value, or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already a , returns it directly.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns .
+ /// - If is a , attempts to parse it using .
+ /// - If is a equal to "1", returns ; otherwise .
+ /// - If is a , returns for '1', 'T', or 't'.
+ /// - If implements , returns if not equal to 0.
+ ///
+ ///
+ ///
+ ///
+ /// bool result1 = "true".AsBool(); // Returns true
+ /// bool result2 = "1".AsBool(); // Returns true
+ /// bool result3 = 'T'.AsBool(); // Returns true
+ /// bool result4 = ((object)null).AsBool(); // Returns false
+ ///
+ ///
public static bool AsBool(this object? obj) => obj switch
{
bool x => x,
@@ -95,6 +292,32 @@ string s when bool.TryParse(s, out var b) => b,
_ => default
};
+ ///
+ /// Converts an object to a value.
+ ///
+ /// The object to convert. Can be .
+ ///
+ /// The converted value, or if conversion is not possible.
+ ///
+ ///
+ /// The conversion is performed using the following priority:
+ ///
+ /// - If is already a , returns it directly.
+ /// - If implements , recursively converts its .
+ /// - If is or , returns .
+ /// - If is a , attempts to parse it as a GUID.
+ /// - If is a representing an integer, creates a GUID with that integer as the first component.
+ /// - If is a , creates a GUID with that value as the first component.
+ /// - If implements , converts to and creates a GUID with that as the first component.
+ ///
+ ///
+ ///
+ ///
+ /// Guid result1 = "550e8400-e29b-41d4-a716-446655440000".AsGUID(); // Returns the parsed GUID
+ /// Guid result2 = 42.AsGUID(); // Returns GUID with first component = 42
+ /// Guid result3 = ((object)null).AsGUID(); // Returns Guid.Empty
+ ///
+ ///
public static Guid AsGUID(this object? obj) => obj switch
{
Guid g => g,
@@ -109,22 +332,137 @@ string s when Guid.TryParse(s, out var g) => g,
_ => default
};
+ ///
+ /// Executes an action on an object and returns a specified value.
+ ///
+ /// The type of the return value.
+ /// The type of the object on which the action is performed.
+ /// The object on which to perform the action.
+ /// The action to perform on .
+ /// The value to return after the action is executed.
+ /// The value after executing the action.
+ ///
+ /// This method is useful for performing side effects on an object while returning
+ /// a value in a fluent or expression-based context.
+ ///
+ ///
+ ///
+ /// var list = new List<int>();
+ /// int count = list.SetRet(l => l.Add(42), 1); // Adds 42 to list, returns 1
+ ///
+ ///
public static T SetRet(this T2 obj, Action action, T v)
{
action(obj);
return v;
}
}
+
+///
+/// Provides extension methods for dictionary operations with 1-based indexing.
+///
+///
+/// This static class contains helper methods for
+/// that use 1-based indexing internally while accepting 0-based index parameters.
+/// This is useful for compatibility with legacy VB6-style control arrays.
+///
public static class ObjectHelper2
{
+ ///
+ /// Sets a value in the dictionary at a 1-based internal index corresponding to the given 0-based index.
+ ///
+ /// The type of the values in the dictionary.
+ /// The dictionary to modify.
+ /// The value to set.
+ /// The 0-based index. Internally stored at + 1.
+ ///
+ /// This method stores the value at key ( + 1) to maintain
+ /// compatibility with 1-based indexing systems.
+ ///
+ ///
+ ///
+ /// var dic = new Dictionary<int, string>();
+ /// dic.SetIndex("Hello", 0); // Stores "Hello" at key 1
+ ///
+ ///
public static void SetIndex(this Dictionary dic, T value, int index) => dic[index + 1] = value;
+
+ ///
+ /// Gets the 0-based index of a value in the dictionary.
+ ///
+ /// The type of the values in the dictionary.
+ /// The dictionary to search.
+ /// The value to find.
+ ///
+ /// The 0-based index of the value (internal key - 1), or -1 if the value is not found.
+ ///
+ ///
+ /// This method searches for the value using
+ /// and returns the corresponding 0-based index.
+ ///
+ ///
+ ///
+ /// var dic = new Dictionary<int, string> { { 1, "Hello" }, { 2, "World" } };
+ /// int index = dic.GetIndex("World"); // Returns 1 (key 2 - 1)
+ ///
+ ///
public static int GetIndex(this Dictionary dic, T value) => dic.Where((itm) => itm.Value?.Equals(value) ?? false)
.FirstOrDefault().Key - 1;
}
+
+///
+/// Represents a generic control array that uses 0-based external indexing with 1-based internal storage.
+///
+/// The type of elements in the control array.
+///
+///
+/// This class extends to provide functionality
+/// similar to Visual Basic 6 control arrays. The external indexer uses 0-based indexing,
+/// but values are stored internally with 1-based keys.
+///
+///
+/// The class implements to support designer initialization
+/// scenarios, though the implementation is empty.
+///
+///
+///
+///
+/// var controls = new ControlArray<Button>();
+/// controls[1] = new Button(); // Stored at key 2
+/// var button = controls[0]; // Retrieves from key 1
+///
+///
public class ControlArray : Dictionary, ISupportInitialize
{
+ ///
+ /// Signals the object that initialization is starting.
+ ///
+ ///
+ /// This method is part of the interface
+ /// and is intentionally left empty as no special initialization logic is required.
+ ///
public void BeginInit() { }
+
+ ///
+ /// Signals the object that initialization is complete.
+ ///
+ ///
+ /// This method is part of the interface
+ /// and is intentionally left empty as no special finalization logic is required.
+ ///
public void EndInit() { }
+ ///
+ /// Gets the element at the specified 0-based index.
+ ///
+ /// The 0-based index of the element to retrieve.
+ /// The element stored at the internal key ( + 1).
+ ///
+ /// Thrown when no element exists at the specified index.
+ ///
+ ///
+ /// This indexer provides 0-based access while internally using 1-based keys,
+ /// maintaining compatibility with VB6-style control arrays.
+ ///
public new T this[int i] => base[i+1];
}
diff --git a/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs b/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs
index d2a7396a4..ccc56783a 100644
--- a/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs
+++ b/Avalonia_Apps/Libraries/BaseLib/Helper/StringUtils.cs
@@ -9,34 +9,109 @@
//
// Copyright JC-Soft 2023
//
-//
+//
+// Provides a collection of utility extension methods for string manipulation,
+// including quoting/unquoting, splitting, formatting, validation, and substring operations.
+//
// ***********************************************************************
-using System.Collections.Generic;
+using BaseLib.Interfaces;
using System;
+using System.Collections.Generic;
using System.Linq;
-using BaseLib.Interfaces;
+using System.Runtime.InteropServices;
namespace BaseLib.Helper;
-/// A static class with useful string-routines.
+///
+/// A static class providing a comprehensive set of utility extension methods for string manipulation.
+///
+///
+///
+/// This class contains various helper methods for common string operations such as:
+///
+///
+/// - Quoting and unquoting strings (escaping/unescaping special characters)
+/// - String formatting with parameters
+/// - Splitting strings by separators with support for quoted sections
+/// - Tab padding and normalization
+/// - Substring extraction (Left, Right)
+/// - Identifier validation
+/// - Pattern matching (StartswithAny, EndswithAny, ContainsAny)
+///
+///
+/// All methods are implemented as extension methods on or related types,
+/// allowing for fluent method chaining syntax.
+///
+///
+///
+///
+/// // Quoting a multi-line string
+/// string quoted = "Hello\nWorld".Quote(); // Returns "Hello\\nWorld"
+///
+/// // Getting the first part of a string
+/// string first = "Hello World".SFirst(); // Returns "Hello"
+///
+/// // Checking if a string is a valid identifier
+/// bool valid = "MyVariable".IsValidIdentifyer(); // Returns true
+///
+///
public static class StringUtils
{
- public const string AlphaUpper="ABCDEFGHIJKLMNOPQRSTUVWXYZ";
- public const string AlphaLower="abcdefghijklmnopqrstuvwxyz";
- public const string Alpha=AlphaUpper+AlphaLower;
- public const string Numeric="0123456789";
- public const string AlphaNumeric=Alpha+Numeric;
+ ///
+ /// Contains all uppercase letters of the English alphabet (A-Z).
+ ///
+ /// The string "ABCDEFGHIJKLMNOPQRSTUVWXYZ".
+ public const string AlphaUpper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+ ///
+ /// Contains all lowercase letters of the English alphabet (a-z).
+ ///
+ /// The string "abcdefghijklmnopqrstuvwxyz".
+ public const string AlphaLower = "abcdefghijklmnopqrstuvwxyz";
+
+ ///
+ /// Contains all letters of the English alphabet, both uppercase and lowercase.
+ ///
+ /// The concatenation of and .
+ public const string Alpha = AlphaUpper + AlphaLower;
+
+ ///
+ /// Contains all numeric digits (0-9).
+ ///
+ /// The string "0123456789".
+ public const string Numeric = "0123456789";
///
- ///
- /// Makes the specified string quotable.
-> by escaping special Characters (Linefeed, NewLine, Tab ...)
- ///
- ///
- ///
+ /// Contains all alphanumeric characters (letters and digits).
///
- /// the string .
- /// Quoted/escaped string
- /// Does the opposite of
In other words: Puts a given text into one line of text.
+ /// The concatenation of and .
+ public const string AlphaNumeric = Alpha + Numeric;
+
+ ///
+ /// Escapes special characters in a string to make it suitable for single-line representation.
+ ///
+ /// The input string to quote. Can be null.
+ ///
+ /// A string with special characters escaped:
+ ///
+ /// - Backslash (\) becomes \\
+ /// - Tab (\t) becomes \t
+ /// - Carriage return (\r) becomes \r
+ /// - Line feed (\n) becomes \n
+ ///
+ /// Returns an empty string if the input is null or if an exception occurs.
+ ///
+ ///
+ /// This method is the inverse of .
+ /// It converts a multi-line text into a single-line representation by escaping control characters.
+ ///
+ ///
+ ///
+ /// string input = "Line1\nLine2\tTabbed";
+ /// string quoted = input.Quote(); // Returns "Line1\\nLine2\\tTabbed"
+ ///
+ ///
+ ///
public static string Quote(this string? aStr)
{
try
@@ -49,11 +124,38 @@ public static string Quote(this string? aStr)
}
catch { return ""; }
}
- /// Un-quotes the given string.
by un-escaping special characters (Linefeed, Newline, Tab ...)
- /// a string.
- /// the unquoted/un-escaped string
- /// Does the opposite of
In other words: Takes a given line of text and extracts the (original) text.
- public static string UnQuote(this string? aStr)
+
+ ///
+ /// Unescapes special character sequences in a string, restoring the original control characters.
+ ///
+ /// The input string to unquote. Can be null.
+ ///
+ /// A string with escape sequences converted back to their original characters:
+ ///
+ /// - \\ becomes backslash (\)
+ /// - \t becomes tab character
+ /// - \r becomes carriage return
+ /// - \n becomes line feed
+ ///
+ /// Returns an empty string if the input is null.
+ ///
+ ///
+ ///
+ /// This method is the inverse of .
+ /// It converts a single-line escaped representation back to its original multi-line form.
+ ///
+ ///
+ /// The method uses a temporary placeholder character (U+0001) to handle nested backslashes correctly.
+ ///
+ ///
+ ///
+ ///
+ /// string escaped = "Line1\\nLine2\\tTabbed";
+ /// string unquoted = escaped.UnQuote(); // Returns "Line1\nLine2\tTabbed"
+ ///
+ ///
+ ///
+ public static string UnQuote(this string? aStr)
=> (aStr ?? "")
.Replace("\\\\", "\\\u0001")
.Replace("\\t", "\t")
@@ -62,22 +164,50 @@ public static string UnQuote(this string? aStr)
.Replace("\\\u0001", "\\");
///
- /// Formats the specified string with par.
+ /// Formats a string using the specified parameters, similar to .
///
- /// a string.
- /// The par.
- /// System.String.
- ///
+ /// The format string containing placeholders like {0}, {1}, etc.
+ /// An array of objects to format into the string.
+ /// The formatted string with placeholders replaced by the corresponding parameter values.
+ ///
+ /// This is a convenience extension method that wraps ,
+ /// allowing for a more fluent syntax when formatting strings.
+ ///
+ ///
+ ///
+ /// string template = "Hello, {0}! You have {1} messages.";
+ /// string result = template.Format("John", 5); // Returns "Hello, John! You have 5 messages."
+ ///
+ ///
+ ///
+ /// Thrown when the format string is invalid or when there are more placeholders than parameters.
+ ///
public static string Format(this string aStr, params object[] par)
=> string.Format(aStr, par);
///
- /// Gets the first part of the string separated by the separator.
+ /// Extracts the first part of a string before the specified separator.
///
- /// The s.
- /// The sep.
- /// System.String.
- ///
+ /// The input string to split.
+ /// The separator string to search for. Defaults to a single space.
+ ///
+ /// The portion of the string before the first occurrence of the separator.
+ /// If the separator is not found, returns the entire original string.
+ ///
+ ///
+ /// This method is useful for parsing delimited strings when only the first element is needed.
+ /// For the remaining part after the separator, use .
+ ///
+ ///
+ ///
+ /// string input = "key=value";
+ /// string key = input.SFirst("="); // Returns "key"
+ ///
+ /// string words = "Hello World Today";
+ /// string firstWord = words.SFirst(); // Returns "Hello"
+ ///
+ ///
+ ///
public static string SFirst(this string s, string sep = " ")
{
if (!s.Contains(sep)) return s;
@@ -85,24 +215,61 @@ public static string SFirst(this string s, string sep = " ")
}
///
- /// Gets the other part of the string separated by the separator.
+ /// Extracts the remaining part of a string after the first occurrence of the specified separator.
///
- /// The s.
- /// The sep.
- /// System.String.
- ///
+ /// The input string to split.
+ /// The separator string to search for. Defaults to a single space.
+ ///
+ /// The portion of the string after the first occurrence of the separator.
+ /// If the separator is not found, returns an empty string.
+ ///
+ ///
+ /// This method complements for parsing delimited strings.
+ /// Together, they can be used to iterate through delimited elements.
+ ///
+ ///
+ ///
+ /// string input = "key=value=extra";
+ /// string rest = input.SRest("="); // Returns "value=extra"
+ ///
+ /// string words = "Hello World Today";
+ /// string remaining = words.SRest(); // Returns "World Today"
+ ///
+ ///
+ ///
public static string SRest(this string s, string sep = " ")
{
if (!s.Contains(sep)) return "";
return s.Substring(s.IndexOf(sep) + 1);
}
-
- /// Pads the tab of the string with spaces.
- /// The string.
- /// The offset from the start of the line.
- /// System.String.
- ///
+ ///
+ /// Replaces tab characters in a string with the appropriate number of spaces to align to tab stops.
+ ///
+ /// The input string containing tab characters.
+ ///
+ /// The offset from the start of the line to use for tab stop calculation.
+ /// Defaults to 0.
+ ///
+ ///
+ /// A string with all tab characters replaced by spaces, aligned to 8-character tab stops.
+ ///
+ ///
+ ///
+ /// Tab stops are calculated at every 8th character position, taking into account the current
+ /// position in the line plus the specified offset.
+ ///
+ ///
+ /// This is useful for converting tab-formatted text to fixed-width spacing for display
+ /// in environments that don't support tab characters.
+ ///
+ ///
+ ///
+ ///
+ /// string input = "Name\tAge\tCity";
+ /// string padded = input.PadTab(); // Returns "Name Age City" (aligned to 8-char tabs)
+ ///
+ ///
public static string PadTab(this string s, int offs = 0)
{
var _s = s.Split('\t');
@@ -119,19 +286,61 @@ public static string PadTab(this string s, int offs = 0)
static int TabLen(int l, int o) => l + o + (8 - (l + o) % 8) - o;
}
- /// Converts to "Normal" case. (first letter to upper- rest to lowercase)
- /// The string.
- /// System.String.
- /// e.G: pEtEr will be converted to Peter.
+ ///
+ /// Converts a string to "normal" case, where the first letter is uppercase and all subsequent letters are lowercase.
+ ///
+ /// The input string to convert.
+ ///
+ /// The string with the first character in uppercase and all remaining characters in lowercase.
+ /// Returns the original string unchanged if it is null or empty.
+ ///
+ ///
+ /// This method is useful for normalizing names or titles that may have inconsistent casing.
+ /// Only the first character is capitalized; all other characters are converted to lowercase.
+ ///
+ ///
+ ///
+ /// string name = "pEtEr";
+ /// string normalized = name.ToNormal(); // Returns "Peter"
+ ///
+ /// string allCaps = "HELLO";
+ /// string result = allCaps.ToNormal(); // Returns "Hello"
+ ///
+ ///
public static string ToNormal(this string s)
=> string.IsNullOrEmpty(s) ? s : s.Substring(0, 1).ToUpper() + s.Remove(0, 1).ToLower();
- /// Does a quoted split of the given string.
- /// The data.
- /// The separator.
- /// The quotation mark.
- /// List<System.String>.
- ///
+ ///
+ /// Splits a string by a separator while respecting quoted sections that may contain the separator.
+ ///
+ /// The input string to split.
+ /// The separator string to split by. Defaults to comma (",").
+ /// The quotation mark string that defines quoted sections. Defaults to double quote (").
+ ///
+ /// A of strings representing the split elements.
+ /// Quoted sections are preserved as single elements even if they contain the separator.
+ /// Leading and trailing whitespace is trimmed from each element.
+ ///
+ ///
+ ///
+ /// This method is particularly useful for parsing CSV data where fields may contain commas
+ /// enclosed in quotes. The quote marks themselves are removed from the resulting elements.
+ ///
+ ///
+ /// If a quoted section is not properly closed, the remaining content is added as a final element.
+ ///
+ ///
+ ///
+ ///
+ /// string csv = "John,\"Doe, Jr.\",25";
+ /// List<string> fields = csv.QuotedSplit();
+ /// // Returns: ["John", "Doe, Jr.", "25"]
+ ///
+ /// string data = "name='John Smith';age='30'";
+ /// List<string> parts = data.QuotedSplit(";", "'");
+ /// // Returns: ["name=John Smith", "age=30"]
+ ///
+ ///
public static List QuotedSplit(this string Data, string Separator = ",", string QuoteMark = "\"")
{
var arPreSplit = Data.Split(new string[] { Separator }, StringSplitOptions.None);
@@ -170,14 +379,59 @@ public static List QuotedSplit(this string Data, string Separator = ",",
return result;
}
- public static bool EndswithAny(this string s,params string[] strings)
+ ///
+ /// Determines whether the string ends with any of the specified suffixes.
+ ///
+ /// The string to check.
+ /// An array of suffix strings to test against.
+ ///
+ /// true if the string ends with any of the specified suffixes as a complete word boundary;
+ /// otherwise, false.
+ ///
+ ///
+ ///
+ /// This method checks for word-boundary endings by prepending a space to both the input string
+ /// and each suffix before comparison. This ensures that "test" does not match "contest" when
+ /// checking for "test" as an ending.
+ ///
+ ///
+ ///
+ ///
+ /// bool result1 = "Hello World".EndswithAny("World", "Test"); // Returns true
+ /// bool result2 = "contest".EndswithAny("test"); // Returns false (word boundary check)
+ ///
+ ///
+ ///
+ ///
+ public static bool EndswithAny(this string s, params string[] strings)
{
foreach (var item in strings)
- if ((" "+s).EndsWith(" "+item))
+ if ((" " + s).EndsWith(" " + item))
return true;
return false;
}
+ ///
+ /// Determines whether the string contains any of the specified substrings.
+ ///
+ /// The string to search in.
+ /// An array of substrings to search for.
+ ///
+ /// true if the string contains any of the specified substrings;
+ /// otherwise, false.
+ ///
+ ///
+ /// The search is case-sensitive. The method returns true as soon as
+ /// the first matching substring is found.
+ ///
+ ///
+ ///
+ /// bool hasKeyword = "The quick brown fox".ContainsAny("cat", "dog", "fox"); // Returns true
+ /// bool noMatch = "Hello World".ContainsAny("xyz", "123"); // Returns false
+ ///
+ ///
+ ///
+ ///
public static bool ContainsAny(this string s, params string[] strings)
{
foreach (var item in strings)
@@ -185,69 +439,193 @@ public static bool ContainsAny(this string s, params string[] strings)
return true;
return false;
}
+
+ ///
+ /// Determines whether the string starts with any of the specified prefixes.
+ ///
+ /// The string to check.
+ /// An array of prefix strings to test against.
+ ///
+ /// true if the string starts with any of the specified prefixes as a complete word boundary;
+ /// otherwise, false.
+ ///
+ ///
+ ///
+ /// This method checks for word-boundary beginnings by appending a space to both the input string
+ /// and each prefix before comparison. This ensures that "test" does not match "testing" when
+ /// checking for "test" as a beginning.
+ ///
+ ///
+ ///
+ ///
+ /// bool result1 = "Hello World".StartswithAny("Hello", "Hi"); // Returns true
+ /// bool result2 = "testing".StartswithAny("test"); // Returns false (word boundary check)
+ ///
+ ///
+ ///
+ ///
public static bool StartswithAny(this string s, params string[] strings)
{
foreach (var item in strings)
- if ((s+" ").StartsWith(item+" "))
+ if ((s + " ").StartsWith(item + " "))
return true;
return false;
}
-
+
///
- /// Prft, ob der angegebene String ein gltiger Bezeichner (Identifier) ist.
- /// Ein gltiger Bezeichner beginnt mit einem Grobuchstaben (A-Z) und enthlt nur Buchstaben, Ziffern oder Unterstriche.
- /// Leere oder nur aus Leerzeichen bestehende Strings sind ungltig.
+ /// Determines whether the specified string is a valid identifier according to specific naming rules.
///
- /// Der zu prfende String.
- /// True, wenn der String ein gltiger Bezeichner ist, sonst false.
+ /// The string to validate. Can be null.
+ ///
+ /// true if the string is a valid identifier; otherwise, false.
+ ///
+ ///
+ ///
+ /// A valid identifier must meet the following criteria:
+ ///
+ ///
+ /// - Cannot be null, empty, or contain only whitespace
+ /// - Must begin with an uppercase letter (A-Z)
+ /// - Can only contain letters (A-Z, a-z), digits (0-9), or underscores (_)
+ ///
+ ///
+ /// Note: The validation is case-insensitive for subsequent characters, but the first character
+ /// must be an alphabetic character (converted to uppercase for checking).
+ ///
+ ///
+ ///
+ ///
+ /// bool valid1 = "MyVariable".IsValidIdentifyer(); // Returns true
+ /// bool valid2 = "my_variable_123".IsValidIdentifyer(); // Returns true
+ /// bool invalid1 = "123Variable".IsValidIdentifyer(); // Returns false (starts with digit)
+ /// bool invalid2 = "my-variable".IsValidIdentifyer(); // Returns false (contains hyphen)
+ /// bool invalid3 = "".IsValidIdentifyer(); // Returns false (empty)
+ ///
+ ///
public static bool IsValidIdentifyer(this string? s)
{
if (string.IsNullOrWhiteSpace(s)) return false;
var _s = s!.ToUpper();
if (!AlphaUpper.Contains(_s[0])) return false;
foreach (var c in _s)
- if (!(AlphaNumeric+"_").Contains(c)) return false;
+ if (!(AlphaNumeric + "_").Contains(c)) return false;
return true;
}
///
- /// Gibt die ersten iCnt Zeichen des Strings zurck.
- /// Bei positivem iCnt werden die ersten iCnt Zeichen geliefert.
- /// Bei negativem iCnt werden alle bis auf die letzten |iCnt| Zeichen geliefert.
- /// Ist iCnt grer als die Lnge des Strings, wird der gesamte String zurckgegeben.
+ /// Returns the leftmost characters of a string up to the specified count.
///
- /// Der Eingabestring.
- /// Anzahl der gewnschten Zeichen (positiv: von links, negativ: bis auf die letzten |iCnt| Zeichen).
- /// Der entsprechend gekrzte String.
+ /// The input string.
+ ///
+ /// The number of characters to return.
+ ///
+ /// - Positive value: Returns the first characters from the left
+ /// - Negative value: Returns all characters except the last || characters
+ ///
+ ///
+ ///
+ /// A substring containing the specified number of leftmost characters.
+ /// If exceeds the string length, the entire string is returned.
+ /// If the result would be negative length, an empty string is returned.
+ ///
+ ///
+ /// This method provides Python-like string slicing behavior for the beginning of strings.
+ ///
+ ///
+ ///
+ /// string text = "Hello World";
+ ///
+ /// // Positive count - get first N characters
+ /// string first5 = text.Left(5); // Returns "Hello"
+ /// string first20 = text.Left(20); // Returns "Hello World" (entire string)
+ ///
+ /// // Negative count - exclude last N characters
+ /// string allButLast3 = text.Left(-3); // Returns "Hello Wo" (excludes "rld")
+ ///
+ ///
+ ///
public static string Left(this string data, int iCnt)
=> iCnt >= 0
? data.Substring(0, Math.Min(data.Length, iCnt))
: data.Substring(0, Math.Max(0, data.Length + iCnt));
///
- /// Gibt die letzten iCnt Zeichen des Strings zurck.
- /// Bei positivem iCnt werden die letzten iCnt Zeichen geliefert.
- /// Bei negativem iCnt werden die ersten |iCnt| Zeichen entfernt und der Rest geliefert.
- /// Ist iCnt grer als die Lnge des Strings, wird der gesamte String zurckgegeben.
+ /// Returns the rightmost characters of a string up to the specified count.
///
- /// Der Eingabestring.
- /// Anzahl der gewnschten Zeichen (positiv: von rechts, negativ: ab dem |iCnt|-ten Zeichen von links).
- /// Der entsprechend gekrzte String.
+ /// The input string.
+ ///
+ /// The number of characters to return.
+ ///
+ /// - Positive value: Returns the last characters from the right
+ /// - Negative value: Returns all characters starting from the ||th position
+ ///
+ ///
+ ///
+ /// A substring containing the specified number of rightmost characters.
+ /// If exceeds the string length, the entire string is returned.
+ ///
+ ///
+ /// This method provides Python-like string slicing behavior for the end of strings.
+ ///
+ ///
+ ///
+ /// string text = "Hello World";
+ ///
+ /// // Positive count - get last N characters
+ /// string last5 = text.Right(5); // Returns "World"
+ /// string last20 = text.Right(20); // Returns "Hello World" (entire string)
+ ///
+ /// // Negative count - skip first N characters
+ /// string skipFirst3 = text.Right(-3); // Returns "lo World" (skips "Hel")
+ ///
+ ///
+ ///
public static string Right(this string data, int iCnt)
=> iCnt >= 0
? data.Substring(Math.Max(0, data.Length - iCnt))
: data.Substring(Math.Min(data.Length, -iCnt));
///
- /// Gibt eine String-Reprsentation des Objekts zurck.
- /// Falls das Objekt ein String ist, wird es direkt zurckgegeben.
- /// Falls das Objekt das Interface IHasValue implementiert, wird dessen Value als String zurckgegeben.
- /// Bei null wird ein leerer String geliefert, ansonsten wird ToString() verwendet.
+ /// Converts an object to its string representation with special handling for certain types.
///
- /// The data.
- /// The format.(optional)
- /// System.String.
- public static string AsString(this object? data,string? format =null)
+ /// The object to convert. Can be null.
+ ///
+ /// An optional format string. Currently unused but reserved for future implementation.
+ ///
+ ///
+ /// A string representation of the object according to the following rules:
+ ///
+ /// - If is a , it is returned directly
+ /// - If implements , the Value property's string representation is returned
+ /// - If is null, an empty string is returned
+ /// - Otherwise, is called, with null results converted to empty string
+ ///
+ ///
+ ///
+ ///
+ /// This method provides a null-safe way to convert objects to strings, with special handling
+ /// for value wrapper types that implement .
+ ///
+ ///
+ /// The parameter is included for API consistency but is not
+ /// currently used in the implementation.
+ ///
+ ///
+ ///
+ ///
+ /// // String passthrough
+ /// string s = "Hello".AsString(); // Returns "Hello"
+ ///
+ /// // Null handling
+ /// object? nullObj = null;
+ /// string empty = nullObj.AsString(); // Returns ""
+ ///
+ /// // Object conversion
+ /// int number = 42;
+ /// string numStr = number.AsString(); // Returns "42"
+ ///
+ ///
+ public static string AsString(this object? data, string? format = null)
=> data switch
{
string s => s,
@@ -255,16 +633,49 @@ public static string AsString(this object? data,string? format =null)
null => "",
object o => o.ToString() ?? "",
};
-
+
///
- /// Copies the elements of the given string array into the specified list of strings, starting at the given offset.
- /// If the target list is null, a new list with sufficient capacity is created.
- /// Only elements that fit within the bounds of the target list are copied.
+ /// Copies elements from a string array into a target list of strings at a specified offset.
///
/// The source array of strings to copy from.
- /// The target list to copy into. If null, a new list is created.
- /// The zero-based index in the target list at which copying begins. Can be negative.
- /// The target list with the copied elements.
+ ///
+ /// The target list to copy into. If null, a new array is created with
+ /// sufficient capacity to hold all source elements considering the offset.
+ ///
+ ///
+ /// The zero-based index in the target list at which copying begins.
+ /// Can be negative, which will skip the first || elements of the source array.
+ ///
+ ///
+ /// The target list with the copied elements. If was null,
+ /// returns the newly created array.
+ ///
+ ///
+ ///
+ /// Only elements that fit within the bounds of the target list are copied. Elements that would
+ /// fall outside the target list boundaries (either negative indices or beyond the list count)
+ /// are silently ignored.
+ ///
+ ///
+ /// When is null, a new string array is created with size
+ /// Math.Max(0, asData.Length + offs).
+ ///
+ ///
+ ///
+ ///
+ /// // Copy into a new array
+ /// string[] source = { "a", "b", "c" };
+ /// IList<string> result = source.IntoString(); // Creates ["a", "b", "c"]
+ ///
+ /// // Copy with offset into existing array
+ /// string[] target = new string[5];
+ /// source.IntoString(target, 1); // target becomes [null, "a", "b", "c", null]
+ ///
+ /// // Copy with negative offset (skips first element of source)
+ /// string[] target2 = new string[2];
+ /// source.IntoString(target2, -1); // target2 becomes ["b", "c"]
+ ///
+ ///
public static IList IntoString(this string[] asData, IList? asKont = null, int offs = 0)
{
asKont ??= new string[Math.Max(0, asData.Length + offs)];
@@ -274,4 +685,171 @@ public static IList IntoString(this string[] asData, IList? asKo
return asKont;
}
+ ///
+ /// Finds the index of the next placeholder opening bracket ('<') in the specified line that is followed by an alphabetical character.
+ ///
+ /// The source text that may contain placeholder tokens.
+ /// The starting position in the line from which the search begins.
+ ///
+ /// The zero-based index of the next placeholder start if one exists; otherwise, the length of the line plus one.
+ ///
+ public static int GetNextPlaceHolder(this string line, int offset=0)
+ {
+ int result = line.Length + 1;
+ int i = offset;
+ while (i < line.Length)
+ {
+ var p = line.IndexOf('<', i);
+ if (p > -1 && p < line.Length - 2)
+ {
+ if (Alpha.Contains(line[p + 1]))
+ {
+ result = p;
+ i = line.Length;
+ }
+ else
+ i = p + 1;
+ }
+ else
+ i = line.Length;
+ }
+ return result;
+ }
+
+
+ ///
+ /// Attempts to match a probe string against a mask containing named placeholders while collecting the values that fill those placeholders.
+ ///
+ /// The input text that should conform to the placeholder mask.
+ /// The pattern that may contain literal text and placeholder tokens of the form "<Name>".
+ /// A list that receives the resolved placeholder/value pairs when the match succeeds.
+ ///
+ /// An optional callback that can validate whether a candidate value is acceptable for a given placeholder token; returning false rejects the candidate.
+ ///
+ ///
+ /// true if the probe string can be fully matched to the mask with consistent placeholder assignments; otherwise, false.
+ ///
+ ///
+ ///
+ /// The algorithm walks through the mask, matching literal segments case-insensitively and treating placeholders as flexible sections that can absorb
+ /// varying amounts of text. When placeholders are bounded by literal anchors, the method tests each possible split until a consistent assignment is found.
+ ///
+ ///
+ /// Any assignments that fail deeper in the recursion are rolled back to ensure only contains results from successful matches.
+ ///
+ ///
+ public static bool TryPlaceHolderMatching(string Probe, string Mask, List> WilldCardFill, Func? checkPlaceholderCharset = null)
+ {
+ if (WilldCardFill == null)
+ throw new ArgumentNullException(nameof(WilldCardFill));
+
+ Probe ??= string.Empty;
+ Mask ??= string.Empty;
+
+ return Match(Mask, Probe);
+
+
+ bool Match(string currentMask, string currentProbe)
+ {
+ var placeholderIndex = FindPlaceholderIndex(currentMask);
+ if (placeholderIndex >= currentMask.Length)
+ return currentMask.Equals(currentProbe, StringComparison.OrdinalIgnoreCase);
+
+ var literalPrefix = currentMask.Substring(0, placeholderIndex);
+ if (!currentProbe.StartsWith(literalPrefix, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var placeholderEnd = currentMask.IndexOf('>', placeholderIndex);
+ if (placeholderEnd < 0)
+ return false;
+
+ var placeholderToken = currentMask.Substring(placeholderIndex, placeholderEnd - placeholderIndex + 1);
+ var suffix = currentMask.Substring(placeholderEnd + 1);
+ var probeRemainder = currentProbe.Substring(literalPrefix.Length);
+
+ if (suffix.Length == 0)
+ return TryAssignCandidate(probeRemainder, placeholderToken, suffix, string.Empty);
+
+ return TryMatchWithAnchors(placeholderToken, suffix, probeRemainder);
+ }
+
+ bool TryMatchWithAnchors(string placeholderToken, string suffix, string probeRemainder)
+ {
+ var nextPlaceholder = FindPlaceholderIndex(suffix);
+ var literalAnchor = nextPlaceholder >= suffix.Length ? suffix : suffix.Substring(0, nextPlaceholder);
+
+ if (!string.IsNullOrEmpty(literalAnchor))
+ {
+ var searchIndex = 0;
+ while (true)
+ {
+ var anchorPos = probeRemainder.IndexOf(literalAnchor, searchIndex, StringComparison.OrdinalIgnoreCase);
+ if (anchorPos < 0)
+ break;
+
+ if (TryAssignCandidate(probeRemainder.Substring(0, anchorPos), placeholderToken, suffix, probeRemainder.Substring(anchorPos)))
+ return true;
+
+ searchIndex = anchorPos + 1;
+ }
+
+ return false;
+ }
+
+ for (var split = 0; split <= probeRemainder.Length; split++)
+ {
+ if (TryAssignCandidate(probeRemainder.Substring(0, split), placeholderToken, suffix, probeRemainder.Substring(split)))
+ return true;
+ }
+
+ return false;
+ }
+
+ bool TryAssignCandidate(string value, string placeholderToken, string suffix, string remainingProbe)
+ {
+ var trimmed = value.Trim();
+ if (checkPlaceholderCharset?.Invoke(placeholderToken, trimmed) == false)
+ return false;
+
+ WilldCardFill.Add(new KeyValuePair(placeholderToken, trimmed));
+ if (Match(suffix, remainingProbe))
+ return true;
+
+ WilldCardFill.RemoveAt(WilldCardFill.Count - 1);
+ return false;
+ }
+
+ int FindPlaceholderIndex(string pattern)
+ {
+ if (string.IsNullOrEmpty(pattern))
+ return pattern.Length;
+
+ var next = pattern.GetNextPlaceHolder();
+ return next >= pattern.Length ? pattern.Length : next;
+ }
+ }
+
+ public static string MTSpaceTrim(this string MT)
+ {
+ IList result = [];
+ char _last = ' ';
+ for (var I = 0; I < MT.Length; I++)
+ {
+ if (MT[I] == ' ')
+ {
+ if (I == 0 || I == MT.Length - 1 || _last == ' ')
+ continue;
+
+ if ((AlphaNumeric.Contains(_last) == AlphaNumeric.Contains(MT[I + 1]) && MT[I + 1] != ' ')
+ || (_last is '>' or '<')
+ || (MT[I + 1] is '>' or '<' or ':'))
+ result.Add(_last = MT[I]);
+ }
+ else
+ result.Add(_last = MT[I]);
+ }
+ return string.Join("", result);
+ }
+
+
}
diff --git a/Avalonia_Apps/Libraries/BaseLib/Interfaces/IConsole.cs b/Avalonia_Apps/Libraries/BaseLib/Interfaces/IConsole.cs
index 30d2bbb15..51a405c64 100644
--- a/Avalonia_Apps/Libraries/BaseLib/Interfaces/IConsole.cs
+++ b/Avalonia_Apps/Libraries/BaseLib/Interfaces/IConsole.cs
@@ -15,24 +15,164 @@
namespace BaseLib.Interfaces;
+///
+/// Defines an abstraction layer for console operations, enabling dependency injection
+/// and testability for console-based applications.
+///
+///
+/// This interface provides a comprehensive set of console operations including:
+///
+/// - Color management for foreground and background
+/// - Window sizing and positioning
+/// - Input/output operations
+/// - Cursor positioning
+/// - Audio feedback capabilities
+///
+/// Implementations of this interface can wrap for production use
+/// or provide mock implementations for unit testing purposes.
+///
public interface IConsole
{
+ ///
+ /// Gets or sets the foreground color of the console output.
+ ///
+ ///
+ /// A value representing the text color.
+ ///
ConsoleColor ForegroundColor { get; set; }
+
+ ///
+ /// Gets or sets the background color of the console output.
+ ///
+ ///
+ /// A value representing the background color behind the text.
+ ///
ConsoleColor BackgroundColor { get; set; }
+
+ ///
+ /// Gets a value indicating whether the console output stream has been redirected.
+ ///
+ ///
+ /// true if the output is redirected to a file or another stream; otherwise, false.
+ ///
bool IsOutputRedirected { get; }
+
+ ///
+ /// Gets a value indicating whether a key press is available in the input stream.
+ ///
+ ///
+ /// true if a key press is available to be read; otherwise, false.
+ ///
bool KeyAvailable { get; }
+
+ ///
+ /// Gets the largest possible number of console window rows based on the current font and display resolution.
+ ///
+ ///
+ /// The maximum number of rows that can be displayed in the console window.
+ ///
int LargestWindowHeight { get; }
+
+ ///
+ /// Gets or sets the title to display in the console window's title bar.
+ ///
+ ///
+ /// A string representing the console window title.
+ ///
string Title { get; set; }
+
+ ///
+ /// Gets or sets the height of the console window area in rows.
+ ///
+ ///
+ /// The number of rows visible in the console window.
+ ///
int WindowHeight { get; set; }
+
+ ///
+ /// Gets or sets the width of the console window area in columns.
+ ///
+ ///
+ /// The number of columns visible in the console window.
+ ///
int WindowWidth { get; set; }
+ ///
+ /// Gets or sets a value indicating whether the cursor is visible.
+ ///
+ bool CursorVisible { get; set; }
+
+ ///
+ /// Gets the width of the console buffer area.
+ ///
+ int BufferWidth { get; }
+
+ ///
+ /// Gets the height of the console buffer area.
+ ///
+ int BufferHeight { get; }
+
+ ///
+ /// Plays a beep sound through the console speaker.
+ ///
+ /// The frequency of the beep sound in hertz (Hz). Valid range is typically 37 to 32767 Hz.
+ /// The duration of the beep sound in milliseconds.
void Beep(int freq, int len);
+
+ ///
+ /// Clears the console buffer and corresponding console window of all displayed information.
+ ///
void Clear();
+
+ ///
+ /// Gets the current position of the cursor within the console buffer.
+ ///
+ ///
+ /// A tuple containing the Left (column) and Top (row) coordinates of the cursor position,
+ /// where (0, 0) represents the top-left corner of the console buffer.
+ ///
(int Left, int Top) GetCursorPosition();
+
+ ///
+ /// Obtains the next character or function key pressed by the user.
+ ///
+ ///
+ /// A object describing the key pressed, including the character
+ /// and any modifier keys (Alt, Shift, Control); or null if no key is available.
+ ///
ConsoleKeyInfo? ReadKey();
+
+ ///
+ /// Reads the next line of characters from the standard input stream.
+ ///
+ ///
+ /// The next line of characters from the input stream, or an empty string if no input is available.
+ ///
string ReadLine();
+ void ResetColor();
+
+ ///
+ /// Sets the position of the cursor within the console buffer.
+ ///
+ /// The column position of the cursor, where 0 is the leftmost column.
+ /// The row position of the cursor, where 0 is the topmost row.
void SetCursorPosition(int left, int top);
+
+ ///
+ /// Writes the specified Unicode character to the standard output stream.
+ ///
+ /// The character to write to the console.
void Write(char ch);
+
+ ///
+ /// Writes the specified string value to the standard output stream.
+ ///
+ /// The string to write. Can be null, in which case nothing is written.
void Write(string? st);
+
+ ///
+ /// Writes the specified string value, followed by the current line terminator, to the standard output stream.
+ ///
+ /// The string to write. Can be null or empty. Defaults to an empty string.
void WriteLine(string? st = "");
}
\ No newline at end of file
diff --git a/Avalonia_Apps/Libraries/BaseLib/Models/ConsoleProxy.cs b/Avalonia_Apps/Libraries/BaseLib/Models/ConsoleProxy.cs
index acae40bc0..0b2ee1214 100644
--- a/Avalonia_Apps/Libraries/BaseLib/Models/ConsoleProxy.cs
+++ b/Avalonia_Apps/Libraries/BaseLib/Models/ConsoleProxy.cs
@@ -6,51 +6,62 @@ namespace BaseLib.Models;
public class ConsoleProxy : IConsole
{
- MethodInfo _writeLineS = typeof(Console).GetMethod(nameof(Console.WriteLine), [typeof(string)])!;
- MethodInfo _beep = typeof(Console).GetMethod(nameof(Console.Beep), [typeof(int), typeof(int)])!;
+ protected MethodInfo _writeLineS = typeof(Console).GetMethod(nameof(Console.WriteLine), [typeof(string)])!;
+ protected MethodInfo _beep = typeof(Console).GetMethod(nameof(Console.Beep), [typeof(int), typeof(int)])!;
#if NET5_0_OR_GREATER
- MethodInfo _GetCursorPosition = typeof(Console).GetMethod(nameof(Console.GetCursorPosition), [])!;
+ protected MethodInfo _GetCursorPosition = typeof(Console).GetMethod(nameof(Console.GetCursorPosition), [])!;
#else
- MethodInfo _CursorLeft = typeof(Console).GetMethod(nameof(Console.CursorLeft), [])!;
- MethodInfo _CursorTop = typeof(Console).GetMethod(nameof(Console.CursorTop), [])!;
+ protected MethodInfo _CursorLeft = typeof(Console).GetMethod(nameof(Console.CursorLeft), [])!;
+ protected MethodInfo _CursorTop = typeof(Console).GetMethod(nameof(Console.CursorTop), [])!;
#endif
- MethodInfo _ReadKey = typeof(Console).GetMethod(nameof(Console.ReadKey), [])!;
- MethodInfo _Readline = typeof(Console).GetMethod(nameof(Console.ReadLine), [])!;
- MethodInfo _setCursorPosition = typeof(Console).GetMethod(nameof(Console.SetCursorPosition), [typeof(int), typeof(int)])!;
- MethodInfo _writeCh = typeof(Console).GetMethod(nameof(Console.Write), [typeof(char)])!;
- MethodInfo _writeS = typeof(Console).GetMethod(nameof(Console.Write), [typeof(string)])!;
- MethodInfo _clear = typeof(Console).GetMethod(nameof(Console.Clear), [])!;
-
- PropertyInfo _ForegroundColor = typeof(Console).GetProperty(nameof(Console.ForegroundColor))!;
- PropertyInfo _BackgroundColor = typeof(Console).GetProperty(nameof(Console.BackgroundColor))!;
- PropertyInfo _IsOutputRedirected = typeof(Console).GetProperty(nameof(Console.IsOutputRedirected))!;
- PropertyInfo _KeyAvailable = typeof(Console).GetProperty(nameof(Console.KeyAvailable))!;
- PropertyInfo _LargestWindowHeight = typeof(Console).GetProperty(nameof(Console.LargestWindowHeight))!;
- PropertyInfo _Title = typeof(Console).GetProperty(nameof(Console.Title))!;
- PropertyInfo _WindowHeight = typeof(Console).GetProperty(nameof(Console.WindowHeight))!;
- PropertyInfo _WindowWidth = typeof(Console).GetProperty(nameof(Console.WindowWidth))!;
-
- public ConsoleColor ForegroundColor { get => _ForegroundColor?.GetValue(null) as ConsoleColor? ?? ConsoleColor.White; set => _ForegroundColor?.SetValue(null,value); }
- public ConsoleColor BackgroundColor { get => _BackgroundColor?.GetValue(null) as ConsoleColor? ?? ConsoleColor.Black; set => _BackgroundColor?.SetValue(null, value); }
- public bool IsOutputRedirected => _IsOutputRedirected?.GetValue(null) as bool? ?? false;
- public bool KeyAvailable => _KeyAvailable?.GetValue(null) as bool? ?? false;
- public int LargestWindowHeight => _LargestWindowHeight?.GetValue(null) as int? ?? 0;
- public string Title { get => _Title?.GetValue(null) as string ?? string.Empty; set => _Title?.SetValue(null, value); }
- public int WindowHeight { get => _WindowHeight?.GetValue(null) as int? ?? 0; set => _WindowHeight?.SetValue(null, value); }
- public int WindowWidth { get => _WindowWidth?.GetValue(null) as int? ?? 0; set => _WindowWidth?.SetValue(null, value); }
-
-
- public void Beep(int freq, int len) => _beep?.Invoke(null, [freq, len]);
- public void Clear() => _clear?.Invoke(null, []);
+ protected MethodInfo _ReadKey = typeof(Console).GetMethod(nameof(Console.ReadKey), [])!;
+ protected MethodInfo _Readline = typeof(Console).GetMethod(nameof(Console.ReadLine), [])!;
+ protected MethodInfo _setCursorPosition = typeof(Console).GetMethod(nameof(Console.SetCursorPosition), [typeof(int), typeof(int)])!;
+ protected MethodInfo _writeCh = typeof(Console).GetMethod(nameof(Console.Write), [typeof(char)])!;
+ protected MethodInfo _writeS = typeof(Console).GetMethod(nameof(Console.Write), [typeof(string)])!;
+ protected MethodInfo _clear = typeof(Console).GetMethod(nameof(Console.Clear), [])!;
+ protected MethodInfo _resetColor = typeof(Console).GetMethod(nameof(Console.ResetColor), [])!;
+
+ protected PropertyInfo _ForegroundColor = typeof(Console).GetProperty(nameof(Console.ForegroundColor))!;
+ protected PropertyInfo _BackgroundColor = typeof(Console).GetProperty(nameof(Console.BackgroundColor))!;
+ protected PropertyInfo _IsOutputRedirected = typeof(Console).GetProperty(nameof(Console.IsOutputRedirected))!;
+ protected PropertyInfo _KeyAvailable = typeof(Console).GetProperty(nameof(Console.KeyAvailable))!;
+ protected PropertyInfo _LargestWindowHeight = typeof(Console).GetProperty(nameof(Console.LargestWindowHeight))!;
+ protected PropertyInfo _Title = typeof(Console).GetProperty(nameof(Console.Title))!;
+ protected PropertyInfo _WindowHeight = typeof(Console).GetProperty(nameof(Console.WindowHeight))!;
+ protected PropertyInfo _WindowWidth = typeof(Console).GetProperty(nameof(Console.WindowWidth))!;
+ protected PropertyInfo _CursorVisible = typeof(Console).GetProperty(nameof(Console.CursorVisible))!;
+ protected PropertyInfo _BufferWidth = typeof(Console).GetProperty(nameof(Console.BufferWidth))!;
+ protected PropertyInfo _BufferHeight = typeof(Console).GetProperty(nameof(Console.BufferHeight))!;
+
+ protected object? instance = default;
+ public ConsoleColor ForegroundColor { get => _ForegroundColor?.GetValue(instance) as ConsoleColor? ?? ConsoleColor.White; set => _ForegroundColor?.SetValue(instance,value); }
+ public ConsoleColor BackgroundColor { get => _BackgroundColor?.GetValue(instance) as ConsoleColor? ?? ConsoleColor.Black; set => _BackgroundColor?.SetValue(instance, value); }
+ public bool IsOutputRedirected => _IsOutputRedirected?.GetValue(instance) as bool? ?? false;
+ public bool KeyAvailable => _KeyAvailable?.GetValue(instance) as bool? ?? false;
+ public int LargestWindowHeight => _LargestWindowHeight?.GetValue(instance) as int? ?? 0;
+ public string Title { get => _Title?.GetValue(instance) as string ?? string.Empty; set => _Title?.SetValue(instance, value); }
+ public int WindowHeight { get => _WindowHeight?.GetValue(instance) as int? ?? 0; set => _WindowHeight?.SetValue(instance, value); }
+ public int WindowWidth { get => _WindowWidth?.GetValue(instance) as int? ?? 0; set => _WindowWidth?.SetValue(instance, value); }
+ public bool CursorVisible { get => _CursorVisible?.GetValue(instance) as bool? ?? false; set => _CursorVisible?.SetValue(instance, value); }
+ public int BufferWidth => _BufferWidth?.GetValue(instance) as int? ?? 0;
+ public int BufferHeight => _BufferHeight?.GetValue(instance) as int? ?? 0;
+
+
+ public void Beep(int freq, int len) => _beep?.Invoke(instance, [freq, len]);
+ public void Clear() => _clear?.Invoke(instance, []);
#if NET5_0_OR_GREATER
- public (int Left, int Top) GetCursorPosition() => _GetCursorPosition?.Invoke(null, []) as (int, int)? ?? (0, 0);
+ public (int Left, int Top) GetCursorPosition() => _GetCursorPosition?.Invoke(instance, []) as (int, int)? ?? (0, 0);
#else
- public (int Left, int Top) GetCursorPosition() => (_CursorLeft?.Invoke(null, []) as int? ?? 0, _CursorTop?.Invoke(null, []) as int? ?? 0);
+ public (int Left, int Top) GetCursorPosition() => (_CursorLeft?.Invoke(instance, []) as int? ?? 0, _CursorTop?.Invoke(instance, []) as int? ?? 0);
#endif
- public ConsoleKeyInfo? ReadKey() => _ReadKey?.Invoke(null, []) as ConsoleKeyInfo?;
- public string ReadLine() => _Readline?.Invoke(null, [])?.ToString() ?? string.Empty;
- public void SetCursorPosition(int left, int top) => _setCursorPosition?.Invoke(null, [left, top]);
- public void Write(char ch) => _writeCh?.Invoke(null, [ch]);
- public void Write(string? st) => _writeS?.Invoke(null, [st]);
- public void WriteLine(string? v) => _writeLineS?.Invoke(null, [v]);
+ public ConsoleKeyInfo? ReadKey() => _ReadKey?.Invoke(instance, []) as ConsoleKeyInfo?;
+ public string ReadLine() => _Readline?.Invoke(instance, [])?.ToString() ?? string.Empty;
+
+ public void ResetColor() => _resetColor?.Invoke(instance, []);
+
+ public void SetCursorPosition(int left, int top) => _setCursorPosition?.Invoke(instance, [left, top]);
+ public void Write(char ch) => _writeCh?.Invoke(instance, [ch]);
+ public void Write(string? st) => _writeS?.Invoke(instance, [st]);
+ public void WriteLine(string? v) => _writeLineS?.Invoke(instance, [v]);
}
diff --git a/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj b/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj
index 8a1e73ac9..54f402aab 100644
--- a/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj
+++ b/Avalonia_Apps/Libraries/BaseLibTests/BaseLibTests.csproj
@@ -4,6 +4,7 @@
net8.0;net481;net48;net472;net462
false
true
+ AnyCPU;x64
@@ -14,8 +15,8 @@
$(TargetFrameworks);net10.0
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Avalonia_Apps/Libraries/BaseLibTests/Helper/ListHelperTests.cs b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ListHelperTests.cs
new file mode 100644
index 000000000..7f49e6103
--- /dev/null
+++ b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ListHelperTests.cs
@@ -0,0 +1,67 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Linq;
+using static BaseLib.Helper.TestHelper;
+
+namespace BaseLib.Helper.Tests;
+
+[TestClass()]
+public class ListHelperTests
+{
+ [TestMethod()]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 2, 2, new object[] { 0, 1, 2, 3, 4 })]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 4, 0, new object[] { 4, 0, 1, 2, 3 })]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 1, 3, new object[] { 0, 2, 1, 3, 4 })]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 2, 5, new object[] { 0, 1, 3, 4, 2 })]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 1, 6, new object[] { 0, 1, 2, 3, 4 })]
+ public void MoveItemTest(object[] aoSrc, int iSrc, int iDst, object[] asExp)
+ {
+ var aoResult = aoSrc.ToList();
+ if (iDst > asExp.Length)
+ Assert.ThrowsExactly(() => aoResult.MoveItem(iSrc, iDst));
+ else
+ aoResult.MoveItem(iSrc, iDst);
+ AssertAreEqual(asExp, aoResult.ToArray());
+ }
+
+ [TestMethod()]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 2, 2, new object[] { 0, 1, 2, 3, 4 })]
+ [DataRow(new object[] { 0, 1, 2, 3, 4 }, 4, 0, new object[] { 4, 1, 2, 3, 0 })]
+ public void SwapTest(object[] aoSrc, int iSrc, int iDst, object[] asExp)
+ {
+ aoSrc.Swap(iSrc, iDst);
+ AssertAreEqual(asExp, aoSrc);
+ }
+
+ [TestMethod()]
+ [DataRow(0, -1, new int[] { })]
+ [DataRow(2, 2, new[] { 2 })]
+ [DataRow(0, 4, new[] { 0, 1, 2, 3, 4 })]
+ public void RangeTest(int iSrc, int iDst, int[] aiExp)
+ {
+ AssertAreEqual(aiExp, typeof(int).Range(iSrc, iDst));
+ }
+
+ [TestMethod()]
+ [DataRow('2', '6', new char[] { '2', '3', '4', '5', '6' })]
+ public void RangeTest2(char iSrc, char iDst, char[] aoExp)
+ {
+ AssertAreEqual(aoExp, typeof(object).Range(iSrc, iDst));
+ }
+
+ [TestMethod()]
+ [DataRow((byte)0, (byte)4, new byte[] { 0, 1, 2, 3, 4 })]
+ public void RangeTest3(byte iSrc, byte iDst, byte[] aoExp)
+ {
+ AssertAreEqual(aoExp, typeof(object).Range(iSrc, iDst));
+ }
+
+ [TestMethod()]
+ [DataRow(0, -1, new int[] { })]
+ [DataRow(2, 2, new[] { 2 })]
+ [DataRow(0, 4, new[] { 0, 1, 2, 3, 4 })]
+ public void ToTest(int iSrc, int iDst, int[] aoExp)
+ {
+ AssertAreEqual(aoExp, iSrc.To(iDst));
+ }
+}
\ No newline at end of file
diff --git a/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelper2Tests.cs b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelper2Tests.cs
new file mode 100644
index 000000000..333e3b3f7
--- /dev/null
+++ b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelper2Tests.cs
@@ -0,0 +1,45 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Collections.Generic;
+
+namespace BaseLib.Helper.Tests;
+
+[TestClass()]
+public class ObjectHelper2Tests
+{
+#pragma warning disable CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Fügen Sie ggf. den „erforderlichen“ Modifizierer hinzu, oder deklarieren Sie den Modifizierer als NULL-Werte zulassend.
+ private ControlArray _testClass;
+#pragma warning restore CS8618 // Ein Non-Nullable-Feld muss beim Beenden des Konstruktors einen Wert ungleich NULL enthalten. Fügen Sie ggf. den „erforderlichen“ Modifizierer hinzu, oder deklarieren Sie den Modifizierer als NULL-Werte zulassend.
+
+ [TestInitialize]
+ public void Init()
+ {
+ _testClass = new ControlArray();
+ }
+
+ [TestMethod()]
+ public void SetIndexTest()
+ {
+ // Act
+ _testClass.SetIndex(8, 4);
+ _testClass.SetIndex(2, 1);
+ // Assert
+ Assert.HasCount(2, _testClass);
+ Assert.AreEqual(8, _testClass[4]);
+ Assert.AreEqual(2, _testClass[1]);
+ }
+
+ [TestMethod()]
+ public void GetIndexTest()
+ {
+ // Arrange
+ var d = (Dictionary)_testClass;
+ d[5] = 8;
+ d[1] = null!;
+ d[2] = 2;
+
+ // Assert
+ Assert.AreEqual(4, _testClass.GetIndex(8));
+ Assert.AreEqual(1, _testClass.GetIndex(2));
+ Assert.AreEqual(-1, _testClass.GetIndex(1));
+ }
+}
\ No newline at end of file
diff --git a/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelperTests.cs b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelperTests.cs
new file mode 100644
index 000000000..1357942ca
--- /dev/null
+++ b/Avalonia_Apps/Libraries/BaseLibTests/Helper/ObjectHelperTests.cs
@@ -0,0 +1,278 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using BaseLib.Interfaces;
+using NSubstitute;
+
+namespace BaseLib.Helper.Tests;
+
+[TestClass()]
+public class ObjectHelperTests
+{
+ [TestMethod()]
+ [DataRow(null, -1)]
+ [DataRow(1, 1)]
+ [DataRow(int.MaxValue, int.MaxValue)]
+ [DataRow(int.MinValue, int.MinValue)]
+ [DataRow(uint.MaxValue-1, -2)]
+ [DataRow(2u, 2)]
+ [DataRow("3", 3)]
+ [DataRow("4.5", -1)]
+ [DataRow(5.1f, 5)]
+ [DataRow(6L, 6)]
+ [DataRow("DBNull", -1)]
+ [DataRow("IHasValue", 0)]
+ [DataRow("_", -1)]
+ public void AsIntTest(object? obj,int iExp)
+ {
+ if (obj is string s)
+ obj = obj switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "_" => new object(),
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsInt(obj,-1);
+
+ // Assert
+ Assert.AreEqual(iExp, result);
+
+ }
+
+ [TestMethod()]
+ [DataRow(null, -1)]
+ [DataRow(1, 1)]
+ [DataRow(long.MaxValue, long.MaxValue)]
+ [DataRow(long.MinValue, long.MinValue)]
+ [DataRow(ulong.MaxValue - 1, -2)]
+ [DataRow(2u, 2)]
+ [DataRow("3", 3)]
+ [DataRow("4.5", -1)]
+ [DataRow(5.1f, 5)]
+ [DataRow(6L, 6)]
+ [DataRow("DBNull", -1)]
+ [DataRow("IHasValue", 0)]
+ [DataRow("_", -1)]
+ public void AsLongTest(object? obj, long lExp)
+ {
+ if (obj is string s)
+ obj = obj switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "_" => new object(),
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsLong(obj, -1);
+
+ // Assert
+ Assert.AreEqual(lExp, result);
+ }
+
+ public enum TestEnum
+ {
+ Default = 0,
+ Value1 = 1,
+ Value2 = 2
+ }
+
+ [TestMethod()]
+ [DataRow(null, TestEnum.Default)]
+ [DataRow("Value1", TestEnum.Value1)]
+ [DataRow("Value2", TestEnum.Value2)]
+ [DataRow("InvalidValue", TestEnum.Default)]
+ [DataRow("TestEnum.Value1", TestEnum.Value1)]
+ [DataRow("IHasValue", TestEnum.Default)]
+ [DataRow("DBNull", TestEnum.Default)]
+ [DataRow(1, TestEnum.Value1)]
+ [DataRow(2, TestEnum.Value2)]
+ [DataRow(2u, TestEnum.Value2)]
+ [DataRow(0, TestEnum.Default)]
+ [DataRow(99, (TestEnum)99)]
+ [DataRow(-1, (TestEnum)(int)-1)]
+ [DataRow("_", TestEnum.Default)]
+ public void AsEnumTest(object? obj, TestEnum expected)
+ {
+ if (obj is string s)
+ obj = obj switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "TestEnum.Value1" => TestEnum.Value1,
+
+ "_" => new object(),
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsEnum(obj);
+
+ // Assert
+ Assert.AreEqual(expected, result);
+ }
+
+ [TestMethod()]
+ [DataRow(null, "0001-01-01T00:00:00")]
+ [DataRow(20231040, "0001-01-01T00:00:00")]
+ [DataRow(20230010, "0001-01-01T00:00:00")]
+ [DataRow(0, "0001-01-01T00:00:00")]
+ [DataRow(20231010, "2023-10-10T00:00:00")]
+ [DataRow(1000u, "1902-09-26T00:00:00")]
+ [DataRow(1000, "1902-09-26T00:00:00")]
+ [DataRow('c', "1999-01-01T00:00:00")]
+ [DataRow("10-10-2000", "2000-10-10T00:00:00")]
+ [DataRow("2023-10-10", "2023-10-10T00:00:00")]
+ [DataRow("10/10/2023", "2023-10-10T00:00:00")]
+ [DataRow("10-10-2023", "2023-10-10T00:00:00")]
+ [DataRow("2023-10-10T10:10:10", "2023-10-10T10:10:10")]
+ [DataRow("InvalidDate", "0001-01-01T00:00:00")]
+ [DataRow(638000000000000000L, "2022-09-28T22:13:20")]
+ [DataRow("DBNull", "0001-01-01T00:00:00")]
+ [DataRow("IHasValue", "0001-01-01T00:00:00")]
+ [DataRow("_", "0001-01-01T00:00:00")]
+ public void AsDateTest(object? obj, string expected)
+ {
+ if (obj is string s)
+ obj = s switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "_" => new object(),
+ _ when s.Contains("2000") && DateTime.TryParse(s, out var dt) => dt,
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsDate(obj);
+
+ // Assert
+ Assert.AreEqual(DateTime.Parse(expected), result);
+ }
+
+ [TestMethod()]
+ [DataRow(null, 0)]
+ [DataRow(1, 1)]
+ [DataRow(long.MaxValue, long.MaxValue)]
+ [DataRow(long.MinValue, long.MinValue)]
+ [DataRow(ulong.MaxValue , ulong.MaxValue)]
+ [DataRow(2u, 2)]
+ [DataRow("3", 3)]
+ [DataRow("Dog", 0)]
+ [DataRow("4.5", 4.5)]
+ [DataRow(5.1f, 5.1f)]
+ [DataRow(double.MinValue, double.MinValue)]
+ [DataRow(double.MaxValue, double.MaxValue)]
+ [DataRow(double.NaN, double.NaN)]
+ [DataRow(6L, 6)]
+ [DataRow('7', 48+7)]
+ [DataRow("DBNull", 0)]
+ [DataRow("IHasValue", 0)]
+ [DataRow("_", 0)]
+ public void AsDoubleTest(object? obj, double dExp)
+ {
+ if (obj is string s)
+ obj = s switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "_" => new object(),
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsDouble(obj);
+
+ // Assert
+ Assert.AreEqual(dExp, result);
+ }
+
+ [TestMethod()]
+ [DataRow(null, false)]
+ [DataRow(true, true)]
+ [DataRow(false, false)]
+ [DataRow(1, true)]
+ [DataRow(0, false)]
+ [DataRow("true", true)]
+ [DataRow("false", false)]
+ [DataRow("True", true)]
+ [DataRow("False", false)]
+ [DataRow("yes", false)]
+ [DataRow("no", false)]
+ [DataRow("1", true)]
+ [DataRow("0", false)]
+ [DataRow('1', true)]
+ [DataRow('T', true)]
+ [DataRow('t', true)]
+ [DataRow('0', false)]
+ [DataRow("DBNull", false)]
+ [DataRow("IHasValue", false)]
+ [DataRow("_", false)]
+ public void AsBoolTest(object? obj, bool bExp)
+ {
+ if (obj is string s)
+ obj = obj switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "_" => new object(),
+ _ => obj
+ };
+ // Act
+ var result = ObjectHelper.AsBool(obj);
+
+ // Assert
+ Assert.AreEqual(bExp, result);
+ }
+
+ [TestMethod()]
+ [DataRow(null, "00000000-0000-0000-0000-000000000000")]
+ [DataRow("d3c4a1b2-3f4e-5d6c-7a8b-9c0d1e2f3a4b", "d3c4a1b2-3f4e-5d6c-7a8b-9c0d1e2f3a4b")]
+ [DataRow("invalid-guid", "00000000-0000-0000-0000-000000000000")]
+ [DataRow("00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000000")]
+ [DataRow("123456789", "075bcd15-0000-0000-0000-000000000000")]
+ [DataRow(123456789L, "075bcd15-0000-0000-0000-000000000000")]
+ [DataRow(123456789u, "075bcd15-0000-0000-0000-000000000000")]
+ [DataRow("IHasValue", "00000000-0000-0000-0000-000000000000")]
+ [DataRow("DBNull", "00000000-0000-0000-0000-000000000000")]
+ [DataRow("EmptyGUID", "00000000-0000-0000-0000-000000000000")]
+ [DataRow("_", "00000000-0000-0000-0000-000000000000")]
+ public void AsGUIDTest(object? obj, string expected)
+ {
+ if (obj is string s)
+ obj = obj switch
+ {
+ "DBNull" => DBNull.Value,
+ "IHasValue" => Substitute.For(),
+ "EmptyGUID" => Guid.Empty,
+ "_" => new object(),
+ _ => obj
+ };
+
+ // Act
+ var result = ObjectHelper.AsGUID(obj);
+
+ // Assert
+ Assert.AreEqual(Guid.Parse(expected), result);
+ }
+
+ [TestMethod()]
+ [DataRow(null, false)]
+ [DataRow(1, true)]
+ [DataRow("test", true)]
+ [DataRow("", false)]
+ [DataRow(0, false)]
+ public void SetRetTest(object? input, bool expected)
+ {
+ // Arrange
+ bool actionCalled = false;
+ object? _o = null;
+ Action
-
-
+
+
diff --git a/CSharpBible/ConsoleApps/ConsoleMouseApp/ConsoleMouseApp_net.csproj b/CSharpBible/ConsoleApps/ConsoleMouseApp/ConsoleMouseApp_net.csproj
index 68ba48046..5a9d7aaa5 100644
--- a/CSharpBible/ConsoleApps/ConsoleMouseApp/ConsoleMouseApp_net.csproj
+++ b/CSharpBible/ConsoleApps/ConsoleMouseApp/ConsoleMouseApp_net.csproj
@@ -27,8 +27,8 @@
-
-
+
+
diff --git a/CSharpBible/ConsoleApps/TestConsole/TestConsole.cs b/CSharpBible/ConsoleApps/TestConsole/TestConsole.cs
index 8b42c4e71..ec6e4a7b3 100644
--- a/CSharpBible/ConsoleApps/TestConsole/TestConsole.cs
+++ b/CSharpBible/ConsoleApps/TestConsole/TestConsole.cs
@@ -17,73 +17,73 @@ public class TstConsole : IConsole
/// Gets or sets the color of the foreground.
///
/// The color of the foreground.
- public ConsoleColor ForegroundColor { get => form?.ForegroundColor ?? ConsoleColor.Gray; set => form.ForegroundColor = value; }
+ public ConsoleColor ForegroundColor { get => form.ForegroundColor; set => form.ForegroundColor = value; }
///
/// Gets or sets the color of the background.
///
/// The color of the background.
- public ConsoleColor BackgroundColor { get => form?.BackgroundColor ?? ConsoleColor.Black; set => form.BackgroundColor = value; }
+ public ConsoleColor BackgroundColor { get => form.BackgroundColor; set => form.BackgroundColor = value; }
///
/// Gets or sets the height of the window.
///
/// The height of the window.
- public int WindowHeight { get => form?.WindowHeight ?? 0; set => form.WindowHeight = value; }
+ public int WindowHeight { get => form.WindowHeight; set => form.WindowHeight = value; }
///
/// Gets or sets the width of the window.
///
/// The width of the window.
- public int WindowWidth { get => form?.WindowWidth ?? 0; set => form.WindowWidth = value; }
+ public int WindowWidth { get => form.WindowWidth; set => form.WindowWidth = value; }
///
/// Gets a value indicating whether [key available].
///
/// true if [key available]; otherwise, false.
- public bool KeyAvailable => form?.KeyAvailable ?? false;
+ public bool KeyAvailable => form.KeyAvailable;
///
/// Gets the height of the largest window.
///
/// The height of the largest window.
///
- public int LargestWindowHeight => form?.WindowHeight ?? 50;
+ public int LargestWindowHeight => form.WindowHeight;
- public string Title { get => form?.Text??""; set => form.Text = value; }
+ public string Title { get => form.Text ?? ""; set => form.Text = value; }
///
/// Clears this instance.
///
- public void Clear() => form?.Clear();
+ public void Clear() => form.Clear();
///
/// Reads the key.
///
/// System.Nullable<ConsoleKeyInfo>.
- public ConsoleKeyInfo? ReadKey() => form?.ReadKey();
+ public ConsoleKeyInfo? ReadKey() => form.ReadKey();
///
/// Sets the cursor position.
///
/// The left.
/// The top.
- public void SetCursorPosition(int left, int top) => form?.SetCursorPosition(left, top);
+ public void SetCursorPosition(int left, int top) => form.SetCursorPosition(left, top);
///
/// Writes the specified ch.
///
/// The ch.
- public void Write(char ch) => form?.Write(ch);
+ public void Write(char ch) => form.Write(ch);
///
/// Writes the specified st.
///
/// The st.
- public void Write(string? st) => form?.Write(st);
+ public void Write(string? st) => form.Write(st);
///
/// Writes the line.
///
/// The st.
- public void WriteLine(string? st = "") => Write((st ?? "") + "\r\n");
+ public void WriteLine(string? st = "") => Write((st ?? "") + "\r\n");
private readonly IConsoleHandler form;
///
@@ -92,16 +92,16 @@ public class TstConsole : IConsole
public TstConsole()
{
form = new TestConsoleForm();
- (form as TestConsoleForm).Show();
+ ((TestConsoleForm)form).Show();
}
///
/// Gets the cursor position.
///
/// System.ValueTuple<System.Int32, System.Int32>.
- public (int Left, int Top) GetCursorPosition()
+ public (int Left, int Top) GetCursorPosition()
{
- return ((int Left, int Top)?)(form?.GetCursorPosition()) ?? (0, 0);
+ return form.GetCursorPosition();
}
///
@@ -110,14 +110,92 @@ public TstConsole()
/// The freq.
/// The length.
///
- public void Beep(int freq, int len)
+ public void Beep(int freq, int len)
{
throw new NotImplementedException();
}
- public string ReadLine()
+ private readonly System.Collections.Concurrent.ConcurrentQueue _scriptedLines = new();
+
+ ///
+ /// Enqueue a full line (without newline) that will be returned by the next ReadLine() call.
+ /// Useful for deterministic unit tests.
+ ///
+ public void EnqueueLine(string line) => _scriptedLines.Enqueue(line);
+
+ ///
+ /// Optional. If set, ReadLine will wait up to this duration for keys to become available.
+ /// When elapsed, it throws to avoid freezing test environments.
+ ///
+ public TimeSpan? ReadLineTimeout { get; set; }
+
+ public string ReadLine()
{
- throw new NotImplementedException();
+ if (_scriptedLines.TryDequeue(out var scripted))
+ {
+ // Optional echo behavior to keep output similar to a real console
+ Write(scripted);
+ WriteLine();
+ return scripted;
+ }
+
+ var timeout = ReadLineTimeout;
+ var start = DateTime.UtcNow;
+
+ var sb = new System.Text.StringBuilder();
+
+ while (true)
+ {
+ if (timeout.HasValue && DateTime.UtcNow - start > timeout.Value)
+ {
+ throw new TimeoutException("ReadLine timed out waiting for input.");
+ }
+
+ // Avoid hot spinning in test runners
+ if (!KeyAvailable)
+ {
+ System.Threading.Thread.Sleep(10);
+ continue;
+ }
+
+ var keyInfo = ReadKey();
+ if (keyInfo == null)
+ {
+ continue;
+ }
+
+ switch (keyInfo.Value.Key)
+ {
+ case ConsoleKey.Enter:
+ WriteLine();
+ return sb.ToString();
+
+ case ConsoleKey.Backspace:
+ if (sb.Length > 0)
+ {
+ sb.Length -= 1;
+ Write("\b \b");
+ }
+ break;
+
+ default:
+ var ch = keyInfo.Value.KeyChar;
+ if (ch == '\0' || char.IsControl(ch))
+ {
+ break;
+ }
+
+ sb.Append(ch);
+ Write(ch);
+ break;
+ }
+ }
+ }
+
+ public void ResetColor()
+ {
+ ForegroundColor = ConsoleColor.Gray;
+ BackgroundColor = ConsoleColor.Black;
}
///
@@ -126,7 +204,20 @@ public string ReadLine()
/// The content.
public string Content => form.Content;
- public bool IsOutputRedirected
+ public bool IsOutputRedirected
=> false;
+
+ public bool CursorVisible
+ {
+ get => true;
+ set
+ {
+ // Test console UI does not currently model caret visibility.
+ }
+ }
+
+ public int BufferWidth => WindowWidth;
+
+ public int BufferHeight => WindowHeight;
}
}
diff --git a/CSharpBible/ConsoleApps/TestConsole/TestConsole_net.csproj b/CSharpBible/ConsoleApps/TestConsole/TestConsole_net.csproj
index d3d7ab0fe..c7da959ff 100644
--- a/CSharpBible/ConsoleApps/TestConsole/TestConsole_net.csproj
+++ b/CSharpBible/ConsoleApps/TestConsole/TestConsole_net.csproj
@@ -1,11 +1,17 @@
- net6.0-windows;net7.0-windows;net8.0-windows;net9.0-windows
+ net6.0-windows;net7.0-windows;net8.0-windows
true
+
+ $(TargetFrameworks);net9.0-windows
+
+
+ $(TargetFrameworks);net10.0-windows
+
enable
disable
diff --git a/CSharpBible/ConsoleApps/TestConsoleDemo/TestConsoleDemo.csproj b/CSharpBible/ConsoleApps/TestConsoleDemo/TestConsoleDemo.csproj
index 65f5390a9..617333cfa 100644
--- a/CSharpBible/ConsoleApps/TestConsoleDemo/TestConsoleDemo.csproj
+++ b/CSharpBible/ConsoleApps/TestConsoleDemo/TestConsoleDemo.csproj
@@ -25,8 +25,8 @@
-
-
+
+
\ No newline at end of file
diff --git a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.cs b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.cs
index cea8ad438..0005b7e9c 100644
--- a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.cs
+++ b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.cs
@@ -144,7 +144,7 @@ public void ReadKeyTest()
///
/// Defines the test method SetCursorPositionTest.
///
- [DataTestMethod()]
+ [TestMethod()]
[DataRow("0,0",0,0)]
[DataRow("0,1", 0, 1)]
[DataRow("1,0", 1, 0)]
diff --git a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj
index 967446997..8007f278e 100644
--- a/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj
+++ b/CSharpBible/ConsoleApps/TestConsoleTests/TestConsoleTests.csproj
@@ -19,8 +19,8 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/CSharpBible/ConsoleApps/WaveFuncCol/WaveFunCollapse.csproj b/CSharpBible/ConsoleApps/WaveFuncCol/WaveFunCollapse.csproj
index eff66c56d..41f070acf 100644
--- a/CSharpBible/ConsoleApps/WaveFuncCol/WaveFunCollapse.csproj
+++ b/CSharpBible/ConsoleApps/WaveFuncCol/WaveFunCollapse.csproj
@@ -24,7 +24,7 @@
-
+
diff --git a/CSharpBible/ConsoleApps/WaveFuncColFrm/WaveFuncColFrm.csproj b/CSharpBible/ConsoleApps/WaveFuncColFrm/WaveFuncColFrm.csproj
index da2bebee4..00b57a9b9 100644
--- a/CSharpBible/ConsoleApps/WaveFuncColFrm/WaveFuncColFrm.csproj
+++ b/CSharpBible/ConsoleApps/WaveFuncColFrm/WaveFuncColFrm.csproj
@@ -24,8 +24,8 @@
-
-
+
+
\ No newline at end of file
diff --git a/CSharpBible/DB/DBTest1/DBTest1.csproj b/CSharpBible/DB/DBTest1/DBTest1.csproj
index 14d3645c7..7370e68b8 100644
--- a/CSharpBible/DB/DBTest1/DBTest1.csproj
+++ b/CSharpBible/DB/DBTest1/DBTest1.csproj
@@ -12,7 +12,7 @@
-->
-
+
diff --git a/CSharpBible/DB/FoxCon/FoxCon.csproj b/CSharpBible/DB/FoxCon/FoxCon.csproj
index 542a047a7..87686e905 100644
--- a/CSharpBible/DB/FoxCon/FoxCon.csproj
+++ b/CSharpBible/DB/FoxCon/FoxCon.csproj
@@ -19,8 +19,8 @@
-
-
+
+
diff --git a/CSharpBible/DB/MdbBrowser/MdbBrowser.csproj b/CSharpBible/DB/MdbBrowser/MdbBrowser.csproj
index 1f76463c8..dd76927f9 100644
--- a/CSharpBible/DB/MdbBrowser/MdbBrowser.csproj
+++ b/CSharpBible/DB/MdbBrowser/MdbBrowser.csproj
@@ -2,14 +2,20 @@
WinExe
- net481;net472;net462;net6.0-windows;net7.0-windows;net8.0-windows;net9.0-windows
+ net481;net472;net462;net8.0-windows
enable
true
AnyCPU;x86
-
+
+ $(TargetFrameworks);net9.0-windows
+
+
+ $(TargetFrameworks);net10.0-windows
+
+
@@ -20,9 +26,9 @@
-
-
-
+
+
+
diff --git a/CSharpBible/DB/MdbBrowserTests/MdbBrowserTests.csproj b/CSharpBible/DB/MdbBrowserTests/MdbBrowserTests.csproj
index 94a916268..969471750 100644
--- a/CSharpBible/DB/MdbBrowserTests/MdbBrowserTests.csproj
+++ b/CSharpBible/DB/MdbBrowserTests/MdbBrowserTests.csproj
@@ -20,8 +20,8 @@
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/CSharpBible/DB/OLEDBTest/OleDbTest.csproj b/CSharpBible/DB/OLEDBTest/OleDbTest.csproj
index 6c4f4780e..2f7977719 100644
--- a/CSharpBible/DB/OLEDBTest/OleDbTest.csproj
+++ b/CSharpBible/DB/OLEDBTest/OleDbTest.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/CSharpBible/DB/OLEDBTest2/OleDbTest2.csproj b/CSharpBible/DB/OLEDBTest2/OleDbTest2.csproj
index 784a3d5b3..b3b3497fb 100644
--- a/CSharpBible/DB/OLEDBTest2/OleDbTest2.csproj
+++ b/CSharpBible/DB/OLEDBTest2/OleDbTest2.csproj
@@ -13,7 +13,7 @@
-
+
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj
index ddd4f0052..d26d35186 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core.Tests/DataAnalysis.Core.Tests.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisAggregateProfile.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisAggregateProfile.cs
index 31ff305ec..17ac7408b 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisAggregateProfile.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisAggregateProfile.cs
@@ -1,3 +1,4 @@
+using ClosedXML.Excel;
using System.Collections.Generic;
namespace DataAnalysis.Core.Models;
@@ -17,36 +18,80 @@ public sealed class AnalysisAggregateProfile
{
Queries = new List
{
+ // SW-ProgID
new AnalysisQuery { Title = "Severity", Dimensions = new [] { DimensionKind.Severity } },
+ new AnalysisQuery { Title = "Source x ProgID", Dimensions = new [] { DimensionKind.Source, DimensionKind.ProgID }, Grouped = true },
new AnalysisQuery { Title = "Source", Filter = new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" }, Dimensions = new [] { DimensionKind.Source }, TopN =300 },
- new AnalysisQuery { Title = "Hour x Source", Dimensions = new [] { DimensionKind.Hour, DimensionKind.Source },Filter = new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" }, },
+ new AnalysisQuery { Title = "Hour x Source", Dimensions = new [] { DimensionKind.Hour, DimensionKind.Source },
+ Filter = new GroupFilterDefinition { Mode="And", Type = "group", Filters = [
+ new ValueFilterDefinition { Field = "PS", Type = "value", Operator = FilterOperator.Eq, Value = "1" },
+ new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" },
+ ] }
+ },
new AnalysisQuery { Title = "Top-Events", Dimensions = new [] { DimensionKind.MessageNormalized }, TopN =100 },
- new AnalysisQuery { Title = "Top-Events2", Dimensions = new [] { DimensionKind.MessageNormalized }, TopN =30, Filter = new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" }, },
+ new AnalysisQuery { Title = "Top-Errors", Dimensions = new [] { DimensionKind.MessageNormalized }, TopN =30,
+ Filter = new GroupFilterDefinition { Mode="And", Type = "group", Filters = [
+ new ValueFilterDefinition { Field = "PS", Type = "value", Operator = FilterOperator.Eq, Value = "1" },
+ new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" },
+ ] }
+ },
new AnalysisQuery { Title = "Severity x Source", Dimensions = new [] { DimensionKind.Source, DimensionKind.Severity }, Columns = Enum.GetValues().Select(s => s.ToString()).ToArray() },
new AnalysisQuery { Title = "Events x Source (Top50)", Dimensions = new [] { DimensionKind.Source, DimensionKind.MessageNormalized }, TopN =300},
- new AnalysisQuery { Title = "Error Cluster", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 2.0, DbMinPts = 3, Filter = new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" }, },
- new AnalysisQuery { Title = "Max-G Cluster (1.0)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 1.0, DbMinPts = 3, Filter = new GroupFilterDefinition { Mode="And", Type = "group",
+ new AnalysisQuery { Title = "Events x ProgID", Dimensions = new [] { DimensionKind.ProgID, DimensionKind.MessageNormalized }, Grouped = true ,
+ Filter = new ValueFilterDefinition { Field = "PS", Type = "value", Operator = FilterOperator.Eq, Value = "1" } },
+ new AnalysisQuery { Title = "Error Cluster", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 2.0, DbMinPts = 3,
+ Filter = new GroupFilterDefinition { Mode="And", Type = "group", Filters = [
+ new ValueFilterDefinition { Field = "PS", Type = "value", Operator = FilterOperator.Eq, Value = "1" },
+ new ValueFilterDefinition { Field = "Severity", Type = "Enum", Operator = FilterOperator.Le, Value = "Error" },
+ ] }
+ },
+ new AnalysisQuery { Title = "Max-G Cluster (1.0)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 1.0, DbMinPts = 3,
+ Filter = new GroupFilterDefinition { Mode="And", Type = "group",
Filters=[ new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.Eq, Value = "Max. G-Force" },
new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" }
] }, },
- new AnalysisQuery { Title = "Max-G Cluster (0.5)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 50, IsDBScan = true, DbEps = 0.5, DbMinPts = 3, Filter =
+ new AnalysisQuery { Title = "Max-G Cluster (0.5)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 50, IsDBScan = true, DbEps = 0.5, DbMinPts = 3, Filter =
new GroupFilterDefinition { Mode="And", Type = "group",
- Filters=[
+ Filters=[
new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.Eq, Value = "Max. G-Force" },
new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" }
] }, },
+ new AnalysisQuery { Title = "Max-G Cluster (0.5) > 1g", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 50, IsDBScan = true, DbEps = 0.5, DbMinPts = 3, Filter =
+ new GroupFilterDefinition { Mode="And", Type = "group",
+ Filters=[
+ new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.Eq, Value = "Max. G-Force" },
+ new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" },
+ new GroupFilterDefinition { Mode="Or", Type = "group",
+ Filters=[
+ new ValueFilterDefinition { Field = "U", Type = "value", Operator = FilterOperator.Ge, Value = "1" },
+ new ValueFilterDefinition { Field = "U", Type = "value", Operator = FilterOperator.Le, Value = "-1" }] }
+ ] }, },
+
new AnalysisQuery { Title = "SSCU Cluster (0.5)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 0.5, DbMinPts = 3, Filter =
new GroupFilterDefinition { Mode="And", Type = "group", Filters=[
new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.StartsWith, Value = "SSCU" },
new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" },
- ]
+ ]
}, },
new AnalysisQuery { Title = "SSCU Cluster (0.25)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 50, IsDBScan = true, DbEps = 0.25, DbMinPts = 3, Filter =
- new GroupFilterDefinition { Mode="And", Type = "group", Filters=[
+ new GroupFilterDefinition { Mode="And", Type = "group", Filters=[
new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.StartsWith, Value = "SSCU" },
new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" },
] },
+ },
+ new AnalysisQuery { Title = "Scanner Front Err (0.25)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 30, IsDBScan = true, DbEps = 0.25, DbMinPts = 3, Filter =
+ new GroupFilterDefinition { Mode="And", Type = "group", Filters=[
+ new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.StartsWith, Value = "Front Scanner" },
+ new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" },
+ ]
+ }, },
+ new AnalysisQuery { Title = "Scanner Rear Err (0.25)", Dimensions = new [] { DimensionKind.X, DimensionKind.Y }, TopN = 50, IsDBScan = true, DbEps = 0.25, DbMinPts = 3, Filter =
+ new GroupFilterDefinition { Mode="And", Type = "group", Filters=[
+ new ValueFilterDefinition { Field = "Message", Type = "String", Operator = FilterOperator.StartsWith, Value = "Rear Scanner" },
+ new ValueFilterDefinition { Field = "X", Type = "value", Operator = FilterOperator.Gt, Value = "10" },
+ ] },
}
+
}
};
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisQuery.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisQuery.cs
index 9d62fbf87..8ec422351 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisQuery.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/AnalysisQuery.cs
@@ -4,28 +4,31 @@ namespace DataAnalysis.Core.Models;
public sealed class AnalysisQuery
{
- // Anzeigename fr den Export
- public required string Title { get; init; }
+ // Anzeigename fr den Export
+ public required string Title { get; init; }
- // Liste der Dimensionen:1D => Serie,2D => Kreuztabelle
- public required IReadOnlyList Dimensions { get; init; }
+ // Liste der Dimensionen:1D => Serie,2D => Kreuztabelle
+ public required IReadOnlyList Dimensions { get; init; }
- // Optional: feste Spalten fr2D (z. B. Reihenfolge der Severity-Spalten)
- public IReadOnlyList? Columns { get; init; }
+ // Optional: feste Spalten fr2D (z. B. Reihenfolge der Severity-Spalten)
+ public IReadOnlyList? Columns { get; init; }
- // Optional: TopN-Begrenzung fr1D-Serien
- public int? TopN { get; init; }
+ // Optional: TopN-Begrenzung fr1D-Serien
+ public int? TopN { get; init; }
- // Optional: TopN pro Zeile fr2D-Kreuztabellen
- public int? RowTopN { get; init; }
+ // Optional: TopN pro Zeile fr2D-Kreuztabellen
+ public int? RowTopN { get; init; }
- // Optional: DBSCAN-Clustering der Punkte (bei2D X/Y)
- public bool IsDBScan { get; init; } = false;
- // Optional: Radius (Epsilon) fr DBSCAN (gleiche Einheit wie X/Y)
- public double? DbEps { get; init; }
- // Optional: Mindestanzahl Punkte pro Cluster
- public int? DbMinPts { get; init; }
+ // Optional: DBSCAN-Clustering der Punkte (bei2D X/Y)
+ public bool IsDBScan { get; init; } = false;
+ // Optional: Radius (Epsilon) fr DBSCAN (gleiche Einheit wie X/Y)
+ public double? DbEps { get; init; }
+ // Optional: Mindestanzahl Punkte pro Cluster
+ public int? DbMinPts { get; init; }
- // Optional: Auswertungsspezifischer Filter (DTO, serialisierbar)
- public FilterDefinition? Filter { get; init; }
+ // Optional: Auswertungsspezifischer Filter (DTO, serialisierbar)
+ public FilterDefinition? Filter { get; init; }
+
+ // Optional: Gruppierte Ausgabe (fr2D-Tabellen)
+ public bool Grouped { get; init; }
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/DimensionKind.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/DimensionKind.cs
index 30f2a0758..3df695e29 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/DimensionKind.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/DimensionKind.cs
@@ -2,10 +2,12 @@ namespace DataAnalysis.Core.Models;
public enum DimensionKind
{
- Severity,
- Source,
- Hour, // Hour bucket of timestamp
- MessageNormalized,
- X,
- Y
+ Severity,
+ Source,
+ Hour, // Hour bucket of timestamp
+ MessageNormalized,
+ ProgID, // Software version extracted from message
+ X,
+ Y,
+ MsgID,
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterCompiler.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterCompiler.cs
index cec3effcb..90588ce33 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterCompiler.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterCompiler.cs
@@ -117,28 +117,28 @@ public static class FilterCompiler
break;
}
- return e =>
+ return e =>
{
if (!TryGetFieldText(e, v.Field, out var text, out var typed))
- return false;
+ return false ;
switch (v.DataType)
{
case FilterDataType.Number:
if (!TryAsDouble(text, typed, culture, out var d))
return false;
- return CompareNumbers(d, v.Operator, parsedFrom as double?, parsedTo as double?);
+ return v.Negate ^ CompareNumbers(d, v.Operator, parsedFrom as double?, parsedTo as double?);
case FilterDataType.DateTime:
if (!TryAsDateTimeOffset(text, typed, culture, formats, out var dt))
return false;
- return CompareDateTimes(dt, v.Operator, parsedFrom as DateTimeOffset?, parsedTo as DateTimeOffset?);
+ return v.Negate ^ CompareDateTimes(dt, v.Operator, parsedFrom as DateTimeOffset?, parsedTo as DateTimeOffset?);
case FilterDataType.Enum:
if (!TryAsEnum(text, typed, parsedFrom, out var eobj))
return false;
- return CompareEnums(eobj, v.Operator, parsedFrom, parsedTo);
+ return v.Negate ^ CompareEnums(eobj!, v.Operator, parsedFrom, parsedTo);
case FilterDataType.String:
default:
- return CompareStrings(text ?? string.Empty, v.Operator, v.Value, v.ValueTo, cmp);
+ return v.Negate ^ CompareStrings(text ?? string.Empty, v.Operator, v.Value, v.ValueTo, cmp);
}
};
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterDefinitions.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterDefinitions.cs
index 8379558dd..c4ebe5208 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterDefinitions.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/Filters/FilterDefinitions.cs
@@ -10,6 +10,9 @@ namespace DataAnalysis.Core.Models;
public abstract class FilterDefinition
{
[JsonPropertyName("type")] public string Type { get; init; }
+
+ public bool Negate { get; init; } = false;
+
}
public sealed class ValueFilterDefinition : FilterDefinition
@@ -34,5 +37,4 @@ public sealed class GroupFilterDefinition : FilterDefinition
// "and" oder "or"
public required string Mode { get; init; }
public required IReadOnlyList Filters { get; init; }
- public bool Negate { get; init; } = false;
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/QueryBuilder.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/QueryBuilder.cs
index 488353515..5d304497f 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/QueryBuilder.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.Core/Models/QueryBuilder.cs
@@ -56,7 +56,7 @@ public void Observe(SyslogEntry e)
var col = GetKey(e, _q.Dimensions[1]);
if (row is null || col is null) return;
if (!_matrix.TryGetValue(row, out var d)) { d = new Dictionary(StringComparer.OrdinalIgnoreCase); _matrix[row] = d; }
- d[col] = d.TryGetValue(col, out var c) ? c +1 :1;
+ d[col] = d.TryGetValue(col, out var c) && !_q.Grouped ? c +1 :1;
}
}
@@ -152,6 +152,8 @@ public AggregationResult Build()
DimensionKind.Source => string.IsNullOrWhiteSpace(e.Source) ? "(unbekannt)" : e.Source.Trim(),
DimensionKind.Hour => e.Timestamp is DateTimeOffset ts ? new DateTimeOffset(ts.Year, ts.Month, ts.Day, ts.Hour,0,0, ts.Offset).LocalDateTime.ToString("yyyy-MM-dd HH:00") : null,
DimensionKind.MessageNormalized => AnalysisModel.NormalizeMessage(e.Message),
+ DimensionKind.ProgID => TryGetAttributeAsString(e, "eventprogid", out var s) ? s : "",
+ DimensionKind.MsgID => TryGetAttributeAsString(e, "eventmsgid", out var s) ? s : null,
DimensionKind.X => TryGetAttributeAsDouble(e, "X", out var x) ? x.ToString(CultureInfo.InvariantCulture) : null,
DimensionKind.Y => TryGetAttributeAsDouble(e, "Y", out var y) ? y.ToString(CultureInfo.InvariantCulture) : null,
_ => null
@@ -176,5 +178,17 @@ private static bool TryGetAttributeAsDouble(SyslogEntry e, string key, out doubl
}
return false;
}
+
+ private static bool TryGetAttributeAsString(SyslogEntry e, string key, out string value)
+ {
+ value = "";
+ if (e.Attributes.TryGetValue(key, out var s))
+ {
+ value = s;
+ return true;
+ }
+ return false;
+ }
+
}
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/DataAnalysis.WPF.csproj b/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/DataAnalysis.WPF.csproj
index 923762b6f..7a6953369 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/DataAnalysis.WPF.csproj
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/DataAnalysis.WPF.csproj
@@ -17,9 +17,9 @@
-
-
-
+
+
+
diff --git a/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/ViewModels/MatrixAggregationViewModel.cs b/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/ViewModels/MatrixAggregationViewModel.cs
index d5caeeaed..77a45ad0c 100644
--- a/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/ViewModels/MatrixAggregationViewModel.cs
+++ b/CSharpBible/Data/DataAnalysis/DataAnalysis.WPF/ViewModels/MatrixAggregationViewModel.cs
@@ -15,7 +15,7 @@ public MatrixAggregationViewModel(AggregationResult agg)
var dt = new DataTable();
dt.Columns.Add("Key", typeof(string));
var columns = agg.Columns ?? Array.Empty();
- foreach (var col in columns) dt.Columns.Add(col.Replace(".", "˳"), typeof(int));
+ foreach (var col in columns) dt.Columns.Add(col.Replace(".", "˳").Replace("/", "|"), typeof(int));
if (agg.Matrix is not null)
{
var rows = agg.Matrix.Keys.ToList();
diff --git a/CSharpBible/Data/DataAnalysis/DataConvert.Console/DataConvert.Console.csproj b/CSharpBible/Data/DataAnalysis/DataConvert.Console/DataConvert.Console.csproj
index 6af1c927c..978d08732 100644
--- a/CSharpBible/Data/DataAnalysis/DataConvert.Console/DataConvert.Console.csproj
+++ b/CSharpBible/Data/DataAnalysis/DataConvert.Console/DataConvert.Console.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/CSharpBible/Data/DocumentUtils/DocOdfTest/DocOdfTest.csproj b/CSharpBible/Data/DocumentUtils/DocOdfTest/DocOdfTest.csproj
new file mode 100644
index 000000000..a1c1c6c17
--- /dev/null
+++ b/CSharpBible/Data/DocumentUtils/DocOdfTest/DocOdfTest.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Data/DocumentUtils/DocOdfTest/Program.cs b/CSharpBible/Data/DocumentUtils/DocOdfTest/Program.cs
new file mode 100644
index 000000000..e86784e6d
--- /dev/null
+++ b/CSharpBible/Data/DocumentUtils/DocOdfTest/Program.cs
@@ -0,0 +1,29 @@
+using Document.Base.Models;
+using Document.Odf;
+
+namespace DocOdfTest;
+
+internal class Program
+{
+ static void Main(string[] args)
+ {
+ // Neues Dokument erstellen
+ var doc = new OdfTextDocument();
+
+ // Überschrift hinzufügen
+ var h1 = doc.AddHeadline(1);
+ h1.AppendText("Mein Dokument");
+
+ // Absatz hinzufügen
+ var p = doc.AddParagraph("");
+ p.AppendText("Dies ist ein ");
+ p.AddSpan("fetter", EFontStyle.Bold);
+ p.AppendText(" Text.");
+
+ // Speichern
+ doc.SaveTo("test.odt");
+
+ // Laden
+ doc.LoadFrom("existing.odt");
+ }
+}
diff --git a/CSharpBible/Data/DocumentUtils/Document.Base/Document.Base.csproj b/CSharpBible/Data/DocumentUtils/Document.Base/Document.Base.csproj
index f817901a6..1a195279a 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Base/Document.Base.csproj
+++ b/CSharpBible/Data/DocumentUtils/Document.Base/Document.Base.csproj
@@ -1,10 +1,16 @@
- net8.0;net9.0;net10.0
+ net8.0
enable
enable
preview
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
diff --git a/CSharpBible/Data/DocumentUtils/Document.Docx/Document.Docx.csproj b/CSharpBible/Data/DocumentUtils/Document.Docx/Document.Docx.csproj
index 62a854977..644f2746a 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Docx/Document.Docx.csproj
+++ b/CSharpBible/Data/DocumentUtils/Document.Docx/Document.Docx.csproj
@@ -1,12 +1,18 @@
- net8.0;net9.0;net10.0
+ net8.0
enable
preview
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
-
+
diff --git a/CSharpBible/Data/DocumentUtils/Document.Html/Document.Html.csproj b/CSharpBible/Data/DocumentUtils/Document.Html/Document.Html.csproj
index 0b6317dc2..22bd4cb73 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Html/Document.Html.csproj
+++ b/CSharpBible/Data/DocumentUtils/Document.Html/Document.Html.csproj
@@ -1,10 +1,16 @@
- net9.0
+ net8.0
enable
enable
preview
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/Document.Odf.csproj b/CSharpBible/Data/DocumentUtils/Document.Odf/Document.Odf.csproj
index 0e3ffb3c4..c53c7ce5b 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Odf/Document.Odf.csproj
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/Document.Odf.csproj
@@ -1,10 +1,16 @@
- net9.0
+ net8.0
enable
enable
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfNamespaces.cs b/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfNamespaces.cs
index 12dc45f97..1de21292f 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfNamespaces.cs
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfNamespaces.cs
@@ -9,4 +9,9 @@ public static class OdfNamespaces
public static readonly XNamespace Style = "urn:oasis:names:tc:opendocument:xmlns:style:1.0";
public static readonly XNamespace Table = "urn:oasis:names:tc:opendocument:xmlns:table:1.0";
public static readonly XNamespace XLink = "http://www.w3.org/1999/xlink";
+ public static readonly XNamespace Fo = "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0";
+ public static readonly XNamespace Svg = "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0";
+ public static readonly XNamespace Dc = "http://purl.org/dc/elements/1.1/";
+ public static readonly XNamespace Meta = "urn:oasis:names:tc:opendocument:xmlns:meta:1.0";
+ public static readonly XNamespace Manifest = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
}
\ No newline at end of file
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfTextDocument.cs b/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfTextDocument.cs
index f58906551..8cc20aa78 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfTextDocument.cs
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/Models/OdfTextDocument.cs
@@ -1,60 +1,177 @@
using Document.Base.Models.Interfaces;
+using Document.Odf.Parsing;
+using Document.Odf.Writing;
namespace Document.Odf.Models;
public class OdfTextDocument : IUserDocument
{
- public IDocElement Root => throw new NotImplementedException();
+ private readonly OdfSection _root = new();
+ private bool _isModified;
- public bool IsModified => throw new NotImplementedException();
+ public IDocElement Root => _root;
- public static IUserDocument CreateUserDocument()
- {
- throw new NotImplementedException();
- }
+ public bool IsModified => _isModified;
+
+ public static IUserDocument CreateUserDocument() => new OdfTextDocument();
public IDocHeadline AddHeadline(int nLevel, string? Id = null)
{
- throw new NotImplementedException();
+ var id = Id ?? Guid.NewGuid().ToString("N");
+ var headline = new OdfHeadline(nLevel, id);
+ _root.AddChild(headline);
+ _isModified = true;
+ return headline;
}
public IDocParagraph AddParagraph(string cStylename)
{
- throw new NotImplementedException();
+ var paragraph = new OdfParagraph(cStylename);
+ _root.AddChild(paragraph);
+ _isModified = true;
+ return paragraph;
}
public IDocTOC AddTOC(string cName, int nLevel)
{
- throw new NotImplementedException();
+ var toc = new OdfTOC(cName, nLevel);
+ _root.AddChild(toc);
+ _isModified = true;
+ return toc;
}
- public IEnumerable Enumerate()
- {
- throw new NotImplementedException();
- }
+ public IEnumerable Enumerate() => _root.Enumerate();
public bool LoadFrom(string cInputPath)
{
- throw new NotImplementedException();
+ try
+ {
+ using var fs = File.OpenRead(cInputPath);
+ return LoadFrom(fs);
+ }
+ catch
+ {
+ return false;
+ }
}
public bool LoadFrom(Stream sInputStream, object? options = null)
{
- throw new NotImplementedException();
+ try
+ {
+ // Stream in temporäre Datei kopieren, da ZipArchive seekable Stream benötigt
+ using var ms = new MemoryStream();
+ sInputStream.CopyTo(ms);
+ ms.Position = 0;
+
+ using var zip = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Read);
+ var contentEntry = zip.GetEntry("content.xml");
+ if (contentEntry == null)
+ return false;
+
+ using var contentStream = contentEntry.Open();
+ var contentXml = System.Xml.Linq.XDocument.Load(contentStream,
+ System.Xml.Linq.LoadOptions.PreserveWhitespace | System.Xml.Linq.LoadOptions.SetLineInfo);
+
+ // Root-Nodes löschen und neu befüllen
+ _root.Nodes.Clear();
+ OdfBlockParser.Populate(this, contentXml);
+ _isModified = false;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
}
- public void SaveTo(string cOutputPath)
+ public bool SaveTo(string cOutputPath)
{
- throw new NotImplementedException();
+ try
+ {
+ using var fs = new FileStream(cOutputPath, FileMode.Create, FileAccess.Write);
+ return SaveTo(fs);
+ }
+ catch
+ {
+ return false;
+ }
}
public bool SaveTo(Stream sOutputStream, object? options = null)
{
- throw new NotImplementedException();
+ try
+ {
+ using var zip = new System.IO.Compression.ZipArchive(sOutputStream,
+ System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true);
+
+ // mimetype (muss erste Entry sein, unkomprimiert)
+ var mimetypeEntry = zip.CreateEntry("mimetype", System.IO.Compression.CompressionLevel.NoCompression);
+ using (var s = mimetypeEntry.Open())
+ using (var w = new StreamWriter(s, new System.Text.UTF8Encoding(false)))
+ {
+ w.Write("application/vnd.oasis.opendocument.text");
+ }
+
+ // content.xml
+ var contentXml = OdfXmlWriter.CreateContentXml(_root);
+ var contentEntry = zip.CreateEntry("content.xml", System.IO.Compression.CompressionLevel.Optimal);
+ using (var s = contentEntry.Open())
+ {
+ contentXml.Save(s);
+ }
+
+ // styles.xml
+ var stylesXml = OdfXmlWriter.CreateStylesXml();
+ var stylesEntry = zip.CreateEntry("styles.xml", System.IO.Compression.CompressionLevel.Optimal);
+ using (var s = stylesEntry.Open())
+ {
+ stylesXml.Save(s);
+ }
+
+ // meta.xml
+ var metaXml = OdfXmlWriter.CreateMetaXml();
+ var metaEntry = zip.CreateEntry("meta.xml", System.IO.Compression.CompressionLevel.Optimal);
+ using (var s = metaEntry.Open())
+ {
+ metaXml.Save(s);
+ }
+
+ // META-INF/manifest.xml
+ WriteManifest(zip);
+
+ _isModified = false;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
}
- bool IUserDocument.SaveTo(string cOutputPath)
+ private static void WriteManifest(System.IO.Compression.ZipArchive zip)
{
- throw new NotImplementedException();
+ var manifestEntry = zip.CreateEntry("META-INF/manifest.xml", System.IO.Compression.CompressionLevel.Optimal);
+ using var s = manifestEntry.Open();
+
+ var manifest = new System.Xml.Linq.XDocument(
+ new System.Xml.Linq.XDeclaration("1.0", "UTF-8", null),
+ new System.Xml.Linq.XElement(OdfNamespaces.Manifest + "manifest",
+ new System.Xml.Linq.XAttribute(System.Xml.Linq.XNamespace.Xmlns + "manifest", OdfNamespaces.Manifest),
+ new System.Xml.Linq.XElement(OdfNamespaces.Manifest + "file-entry",
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "full-path", "/"),
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "media-type", "application/vnd.oasis.opendocument.text")),
+ new System.Xml.Linq.XElement(OdfNamespaces.Manifest + "file-entry",
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "full-path", "content.xml"),
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml")),
+ new System.Xml.Linq.XElement(OdfNamespaces.Manifest + "file-entry",
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "full-path", "styles.xml"),
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml")),
+ new System.Xml.Linq.XElement(OdfNamespaces.Manifest + "file-entry",
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "full-path", "meta.xml"),
+ new System.Xml.Linq.XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml"))
+ )
+ );
+ manifest.Save(s);
}
}
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/OdfDocument.cs b/CSharpBible/Data/DocumentUtils/Document.Odf/OdfDocument.cs
index aca63dce7..435a88edf 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Odf/OdfDocument.cs
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/OdfDocument.cs
@@ -1,31 +1,59 @@
using System.IO.Compression;
+using System.Text;
using System.Xml.Linq;
+using Document.Odf.Models;
namespace Document.Odf;
public sealed class OdfDocument : IAsyncDisposable, IDisposable
{
- private readonly ZipArchive _zip; private bool _disposed;
- private OdfDocument(ZipArchive zip)
+ private readonly ZipArchive _zip;
+ private readonly bool _isReadOnly;
+ private bool _disposed;
+
+ private OdfDocument(ZipArchive zip, bool isReadOnly)
{
_zip = zip;
+ _isReadOnly = isReadOnly;
}
public static OdfDocument Open(string path)
{
var fs = File.OpenRead(path);
var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false);
- return new OdfDocument(zip);
+ return new OdfDocument(zip, isReadOnly: true);
+ }
+
+ public static OdfDocument OpenForUpdate(string path)
+ {
+ var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None);
+ var zip = new ZipArchive(fs, ZipArchiveMode.Update, leaveOpen: false);
+ return new OdfDocument(zip, isReadOnly: false);
+ }
+
+ public static OdfDocument Create(string path)
+ {
+ var fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
+ var zip = new ZipArchive(fs, ZipArchiveMode.Create, leaveOpen: false);
+ var doc = new OdfDocument(zip, isReadOnly: false);
+ doc.WriteMinimalStructure();
+ return doc;
+ }
+
+ public static OdfDocument Create(Stream stream, bool leaveOpen = false)
+ {
+ var zip = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen);
+ var doc = new OdfDocument(zip, isReadOnly: false);
+ doc.WriteMinimalStructure();
+ return doc;
}
public static async Task OpenAsync(string path, CancellationToken ct = default)
{
- // ZipArchive hat kein echtes Async-Open; Datei async öffnen für IO.
var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true);
- // trotzdem synchrones Zip-Parsing
var zip = new ZipArchive(fs, ZipArchiveMode.Read, leaveOpen: false);
await Task.CompletedTask;
- return new OdfDocument(zip);
+ return new OdfDocument(zip, isReadOnly: true);
}
public XDocument ReadContentXml()
@@ -43,12 +71,108 @@ public XDocument ReadContentXml()
return XDocument.Load(s, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
}
+ public XDocument? ReadStylesXmlOrNull()
+ {
+ var entry = _zip.GetEntry("styles.xml");
+ if (entry is null) return null;
+ using var s = entry.Open();
+ return XDocument.Load(s, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo);
+ }
+
public Stream? OpenBinary(string pathInPackage)
{
var entry = _zip.GetEntry(pathInPackage);
return entry?.Open();
}
+ public void WriteContentXml(XDocument contentXml)
+ {
+ if (_isReadOnly) throw new InvalidOperationException("Dokument ist schreibgeschützt");
+
+ var entry = _zip.GetEntry("content.xml");
+ entry?.Delete();
+
+ entry = _zip.CreateEntry("content.xml", CompressionLevel.Optimal);
+ using var s = entry.Open();
+ contentXml.Save(s);
+ }
+
+ public void WriteMetaXml(XDocument metaXml)
+ {
+ if (_isReadOnly) throw new InvalidOperationException("Dokument ist schreibgeschützt");
+
+ var entry = _zip.GetEntry("meta.xml");
+ entry?.Delete();
+
+ entry = _zip.CreateEntry("meta.xml", CompressionLevel.Optimal);
+ using var s = entry.Open();
+ metaXml.Save(s);
+ }
+
+ public void WriteStylesXml(XDocument stylesXml)
+ {
+ if (_isReadOnly) throw new InvalidOperationException("Dokument ist schreibgeschützt");
+
+ var entry = _zip.GetEntry("styles.xml");
+ entry?.Delete();
+
+ entry = _zip.CreateEntry("styles.xml", CompressionLevel.Optimal);
+ using var s = entry.Open();
+ stylesXml.Save(s);
+ }
+
+ public void WriteBinary(string pathInPackage, Stream data)
+ {
+ if (_isReadOnly) throw new InvalidOperationException("Dokument ist schreibgeschützt");
+
+ var entry = _zip.GetEntry(pathInPackage);
+ entry?.Delete();
+
+ entry = _zip.CreateEntry(pathInPackage, CompressionLevel.Optimal);
+ using var s = entry.Open();
+ data.CopyTo(s);
+ }
+
+ private void WriteMinimalStructure()
+ {
+ // mimetype (uncompressed, must be first)
+ var mimetypeEntry = _zip.CreateEntry("mimetype", CompressionLevel.NoCompression);
+ using (var s = mimetypeEntry.Open())
+ using (var w = new StreamWriter(s, new UTF8Encoding(false)))
+ {
+ w.Write("application/vnd.oasis.opendocument.text");
+ }
+
+ // META-INF/manifest.xml
+ WriteManifest();
+ }
+
+ private void WriteManifest()
+ {
+ var manifestEntry = _zip.CreateEntry("META-INF/manifest.xml", CompressionLevel.Optimal);
+ using var s = manifestEntry.Open();
+
+ var manifest = new XDocument(
+ new XDeclaration("1.0", "UTF-8", null),
+ new XElement(OdfNamespaces.Manifest + "manifest",
+ new XAttribute(XNamespace.Xmlns + "manifest", OdfNamespaces.Manifest),
+ new XElement(OdfNamespaces.Manifest + "file-entry",
+ new XAttribute(OdfNamespaces.Manifest + "full-path", "/"),
+ new XAttribute(OdfNamespaces.Manifest + "media-type", "application/vnd.oasis.opendocument.text")),
+ new XElement(OdfNamespaces.Manifest + "file-entry",
+ new XAttribute(OdfNamespaces.Manifest + "full-path", "content.xml"),
+ new XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml")),
+ new XElement(OdfNamespaces.Manifest + "file-entry",
+ new XAttribute(OdfNamespaces.Manifest + "full-path", "styles.xml"),
+ new XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml")),
+ new XElement(OdfNamespaces.Manifest + "file-entry",
+ new XAttribute(OdfNamespaces.Manifest + "full-path", "meta.xml"),
+ new XAttribute(OdfNamespaces.Manifest + "media-type", "text/xml"))
+ )
+ );
+ manifest.Save(s);
+ }
+
public void Dispose()
{
if (_disposed) return;
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/OdfTextDocument.cs b/CSharpBible/Data/DocumentUtils/Document.Odf/OdfTextDocument.cs
index 1bac6c6ac..e2f1f6fd1 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Odf/OdfTextDocument.cs
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/OdfTextDocument.cs
@@ -47,10 +47,53 @@ public IEnumerable Enumerate()
=> Root.Enumerate();
public bool SaveTo(string cOutputPath)
- => throw new NotSupportedException();
+ {
+ try
+ {
+ using var fs = new FileStream(cOutputPath, FileMode.Create, FileAccess.Write);
+ return SaveTo(fs);
+ }
+ catch
+ {
+ return false;
+ }
+ }
public bool SaveTo(Stream sOutputStream, object? options = null)
- => throw new NotSupportedException();
+ {
+ try
+ {
+ using var zip = new System.IO.Compression.ZipArchive(sOutputStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true);
+
+ // mimetype (uncompressed, first entry)
+ var mimeEntry = zip.CreateEntry("mimetype", System.IO.Compression.CompressionLevel.NoCompression);
+ using (var writer = new StreamWriter(mimeEntry.Open()))
+ writer.Write("application/vnd.oasis.opendocument.text");
+
+ // content.xml
+ var contentEntry = zip.CreateEntry("content.xml");
+ using (var stream = contentEntry.Open())
+ {
+ var xdoc = CreateContentXDocument();
+ xdoc.Save(stream);
+ }
+
+ // META-INF/manifest.xml
+ var manifestEntry = zip.CreateEntry("META-INF/manifest.xml");
+ using (var stream = manifestEntry.Open())
+ {
+ var manifest = CreateManifest();
+ manifest.Save(stream);
+ }
+
+ _isModified = false;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
public bool LoadFrom(string cInputPath)
{
@@ -88,4 +131,76 @@ public bool LoadFrom(Stream sInputStream, object? options = null)
private Models.OdfSection EnsureRoot()
=> Root as Models.OdfSection ?? throw new InvalidOperationException("Root ist nicht OdfSection");
+
+ private static System.Xml.Linq.XDocument CreateManifest()
+ {
+ System.Xml.Linq.XNamespace manifestNs = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0";
+ return new System.Xml.Linq.XDocument(
+ new System.Xml.Linq.XDeclaration("1.0", "UTF-8", null),
+ new System.Xml.Linq.XElement(manifestNs + "manifest",
+ new System.Xml.Linq.XAttribute(System.Xml.Linq.XNamespace.Xmlns + "manifest", manifestNs),
+ new System.Xml.Linq.XElement(manifestNs + "file-entry",
+ new System.Xml.Linq.XAttribute(manifestNs + "full-path", "/"),
+ new System.Xml.Linq.XAttribute(manifestNs + "media-type", "application/vnd.oasis.opendocument.text")),
+ new System.Xml.Linq.XElement(manifestNs + "file-entry",
+ new System.Xml.Linq.XAttribute(manifestNs + "full-path", "content.xml"),
+ new System.Xml.Linq.XAttribute(manifestNs + "media-type", "text/xml"))
+ )
+ );
+ }
+
+ private System.Xml.Linq.XDocument CreateContentXDocument()
+ {
+ // TODO: Anpassung an tatsächliches ODF-Modell der Anwendung
+ var officeNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:opendocument:xmlns:office:1.0");
+ var textNs = System.Xml.Linq.XNamespace.Get("urn:oasis:names:tc:opendocument:xmlns:text:1.0");
+
+ var rootSection = EnsureRoot();
+
+ var bodyElement = new System.Xml.Linq.XElement(officeNs + "body");
+ var textElement = new System.Xml.Linq.XElement(officeNs + "text");
+ bodyElement.Add(textElement);
+
+ foreach (var element in rootSection.Enumerate())
+ {
+ if (element is IDocHeadline headline)
+ {
+ var h = new System.Xml.Linq.XElement(textNs + "h",
+ new System.Xml.Linq.XAttribute(textNs + "outline-level", headline.Level));
+
+ if (!string.IsNullOrEmpty(headline.Id))
+ h.Add(new System.Xml.Linq.XAttribute("xml:id", headline.Id));
+
+ // headline.Text gibt es nicht, stattdessen TextContent verwenden
+ if (!string.IsNullOrEmpty(headline.TextContent))
+ h.Add(new System.Xml.Linq.XText(headline.TextContent));
+
+ textElement.Add(h);
+ }
+ else if (element is IDocParagraph paragraph)
+ {
+ var p = new System.Xml.Linq.XElement(textNs + "p");
+
+ // StyleName gibt es nicht im Interface, daher entfernt
+ // if (!string.IsNullOrEmpty(paragraph.StyleName))
+ // p.Add(new System.Xml.Linq.XAttribute(textNs + "style-name", paragraph.StyleName));
+
+ if (!string.IsNullOrEmpty(paragraph.TextContent))
+ p.Add(new System.Xml.Linq.XText(paragraph.TextContent));
+
+ textElement.Add(p);
+ }
+ // Weitere Elementtypen hier bei Bedarf abbilden
+ }
+
+ var doc = new System.Xml.Linq.XDocument(
+ new System.Xml.Linq.XDeclaration("1.0", "UTF-8", null),
+ new System.Xml.Linq.XElement(officeNs + "document-content",
+ new System.Xml.Linq.XAttribute(System.Xml.Linq.XNamespace.Xmlns + "office", officeNs),
+ new System.Xml.Linq.XAttribute(System.Xml.Linq.XNamespace.Xmlns + "text", textNs),
+ bodyElement)
+ );
+
+ return doc;
+ }
}
diff --git a/CSharpBible/Data/DocumentUtils/Document.Odf/Writing/OdfXmlWriter.cs b/CSharpBible/Data/DocumentUtils/Document.Odf/Writing/OdfXmlWriter.cs
new file mode 100644
index 000000000..1affcaf8e
--- /dev/null
+++ b/CSharpBible/Data/DocumentUtils/Document.Odf/Writing/OdfXmlWriter.cs
@@ -0,0 +1,345 @@
+using System.Xml.Linq;
+using Document.Base.Models.Interfaces;
+using Document.Odf.Models;
+
+namespace Document.Odf.Writing;
+
+///
+/// Generiert ODF-XML-Dokumente aus dem Dokumentmodell.
+///
+public static class OdfXmlWriter
+{
+ ///
+ /// Erstellt ein vollstndiges content.xml fr ein ODF-Textdokument.
+ ///
+ public static XDocument CreateContentXml(OdfSection root)
+ {
+ var officeText = new XElement(OdfNamespaces.Office + "text");
+
+ foreach (var node in root.Nodes)
+ {
+ var el = ConvertNode(node);
+ if (el != null)
+ officeText.Add(el);
+ }
+
+ var doc = new XDocument(
+ new XDeclaration("1.0", "UTF-8", null),
+ new XElement(OdfNamespaces.Office + "document-content",
+ new XAttribute(XNamespace.Xmlns + "office", OdfNamespaces.Office),
+ new XAttribute(XNamespace.Xmlns + "text", OdfNamespaces.Text),
+ new XAttribute(XNamespace.Xmlns + "style", OdfNamespaces.Style),
+ new XAttribute(XNamespace.Xmlns + "fo", OdfNamespaces.Fo),
+ new XAttribute(XNamespace.Xmlns + "xlink", OdfNamespaces.XLink),
+ new XAttribute(XNamespace.Xmlns + "draw", OdfNamespaces.Draw),
+ new XAttribute(XNamespace.Xmlns + "table", OdfNamespaces.Table),
+ new XAttribute(XNamespace.Xmlns + "svg", OdfNamespaces.Svg),
+ new XAttribute(OdfNamespaces.Office + "version", "1.3"),
+ CreateAutomaticStyles(root),
+ new XElement(OdfNamespaces.Office + "body", officeText)
+ )
+ );
+
+ return doc;
+ }
+
+ ///
+ /// Erstellt ein minimales styles.xml.
+ ///
+ public static XDocument CreateStylesXml()
+ {
+ return new XDocument(
+ new XDeclaration("1.0", "UTF-8", null),
+ new XElement(OdfNamespaces.Office + "document-styles",
+ new XAttribute(XNamespace.Xmlns + "office", OdfNamespaces.Office),
+ new XAttribute(XNamespace.Xmlns + "style", OdfNamespaces.Style),
+ new XAttribute(XNamespace.Xmlns + "fo", OdfNamespaces.Fo),
+ new XAttribute(XNamespace.Xmlns + "text", OdfNamespaces.Text),
+ new XAttribute(OdfNamespaces.Office + "version", "1.3"),
+ new XElement(OdfNamespaces.Office + "styles",
+ CreateDefaultParagraphStyle(),
+ CreateHeadingStyles()
+ )
+ )
+ );
+ }
+
+ ///
+ /// Erstellt ein minimales meta.xml.
+ ///
+ public static XDocument CreateMetaXml(string? title = null, string? creator = null)
+ {
+ var meta = new XElement(OdfNamespaces.Office + "meta");
+
+ if (!string.IsNullOrEmpty(title))
+ meta.Add(new XElement(OdfNamespaces.Dc + "title", title));
+
+ if (!string.IsNullOrEmpty(creator))
+ meta.Add(new XElement(OdfNamespaces.Dc + "creator", creator));
+
+ meta.Add(new XElement(OdfNamespaces.Meta + "creation-date", DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")));
+ meta.Add(new XElement(OdfNamespaces.Meta + "generator", "Document.Odf"));
+
+ return new XDocument(
+ new XDeclaration("1.0", "UTF-8", null),
+ new XElement(OdfNamespaces.Office + "document-meta",
+ new XAttribute(XNamespace.Xmlns + "office", OdfNamespaces.Office),
+ new XAttribute(XNamespace.Xmlns + "dc", OdfNamespaces.Dc),
+ new XAttribute(XNamespace.Xmlns + "meta", OdfNamespaces.Meta),
+ new XAttribute(OdfNamespaces.Office + "version", "1.3"),
+ meta
+ )
+ );
+ }
+
+ private static XElement CreateAutomaticStyles(OdfSection root)
+ {
+ var autoStyles = new XElement(OdfNamespaces.Office + "automatic-styles");
+ var usedStyles = new HashSet();
+
+ CollectUsedStyles(root, usedStyles);
+
+ foreach (var styleName in usedStyles)
+ {
+ var style = CreateAutomaticStyle(styleName);
+ if (style != null)
+ autoStyles.Add(style);
+ }
+
+ return autoStyles;
+ }
+
+ private static void CollectUsedStyles(IDOMElement element, HashSet styles)
+ {
+ if (element is OdfSpan span && span.FontStyle != null)
+ {
+ var styleName = GetAutoStyleName(span.FontStyle);
+ if (!string.IsNullOrEmpty(styleName))
+ styles.Add(styleName);
+ }
+
+ foreach (var child in element.Nodes)
+ {
+ if (child is IDOMElement childEl)
+ CollectUsedStyles(childEl, styles);
+ }
+ }
+
+ private static XElement? CreateAutomaticStyle(string styleName)
+ {
+ var textProps = new XElement(OdfNamespaces.Style + "text-properties");
+ bool hasProps = false;
+
+ if (styleName.Contains("Bold"))
+ {
+ textProps.Add(new XAttribute(OdfNamespaces.Fo + "font-weight", "bold"));
+ hasProps = true;
+ }
+ if (styleName.Contains("Italic"))
+ {
+ textProps.Add(new XAttribute(OdfNamespaces.Fo + "font-style", "italic"));
+ hasProps = true;
+ }
+ if (styleName.Contains("Underline"))
+ {
+ textProps.Add(new XAttribute(OdfNamespaces.Style + "text-underline-style", "solid"));
+ textProps.Add(new XAttribute(OdfNamespaces.Style + "text-underline-width", "auto"));
+ hasProps = true;
+ }
+ if (styleName.Contains("Strikeout"))
+ {
+ textProps.Add(new XAttribute(OdfNamespaces.Style + "text-line-through-style", "solid"));
+ hasProps = true;
+ }
+
+ if (!hasProps) return null;
+
+ return new XElement(OdfNamespaces.Style + "style",
+ new XAttribute(OdfNamespaces.Style + "name", styleName),
+ new XAttribute(OdfNamespaces.Style + "family", "text"),
+ textProps
+ );
+ }
+
+ private static string? GetAutoStyleName(IDocFontStyle fontStyle)
+ {
+ var parts = new List();
+ if (fontStyle.Bold) parts.Add("Bold");
+ if (fontStyle.Italic) parts.Add("Italic");
+ if (fontStyle.Underline) parts.Add("Underline");
+ if (fontStyle.Strikeout) parts.Add("Strikeout");
+
+ return parts.Count > 0 ? "T_" + string.Join("_", parts) : null;
+ }
+
+ private static XElement CreateDefaultParagraphStyle()
+ {
+ return new XElement(OdfNamespaces.Style + "default-style",
+ new XAttribute(OdfNamespaces.Style + "family", "paragraph"),
+ new XElement(OdfNamespaces.Style + "paragraph-properties",
+ new XAttribute(OdfNamespaces.Fo + "margin-top", "0cm"),
+ new XAttribute(OdfNamespaces.Fo + "margin-bottom", "0.212cm")
+ ),
+ new XElement(OdfNamespaces.Style + "text-properties",
+ new XAttribute(OdfNamespaces.Style + "font-name", "Liberation Serif"),
+ new XAttribute(OdfNamespaces.Fo + "font-size", "12pt")
+ )
+ );
+ }
+
+ private static IEnumerable CreateHeadingStyles()
+ {
+ for (int level = 1; level <= 6; level++)
+ {
+ var fontSize = level switch
+ {
+ 1 => "24pt",
+ 2 => "18pt",
+ 3 => "14pt",
+ 4 => "12pt",
+ 5 => "10pt",
+ _ => "10pt"
+ };
+
+ yield return new XElement(OdfNamespaces.Style + "style",
+ new XAttribute(OdfNamespaces.Style + "name", $"Heading_{level}"),
+ new XAttribute(OdfNamespaces.Style + "family", "paragraph"),
+ new XAttribute(OdfNamespaces.Style + "parent-style-name", "Heading"),
+ new XAttribute(OdfNamespaces.Style + "default-outline-level", level.ToString()),
+ new XElement(OdfNamespaces.Style + "text-properties",
+ new XAttribute(OdfNamespaces.Fo + "font-size", fontSize),
+ new XAttribute(OdfNamespaces.Fo + "font-weight", "bold")
+ )
+ );
+ }
+ }
+
+ private static XElement? ConvertNode(IDOMElement node)
+ {
+ return node switch
+ {
+ OdfParagraph p => ConvertParagraph(p),
+ OdfHeadline h => ConvertHeadline(h),
+ OdfTOC toc => ConvertTOC(toc),
+ _ => null
+ };
+ }
+
+ private static XElement ConvertParagraph(OdfParagraph p)
+ {
+ var el = new XElement(OdfNamespaces.Text + "p");
+
+ if (!string.IsNullOrEmpty(p.StyleName))
+ el.Add(new XAttribute(OdfNamespaces.Text + "style-name", p.StyleName));
+
+ AddInlineContent(el, p);
+ return el;
+ }
+
+ private static XElement ConvertHeadline(OdfHeadline h)
+ {
+ var el = new XElement(OdfNamespaces.Text + "h",
+ new XAttribute(OdfNamespaces.Text + "outline-level", h.Level.ToString()),
+ new XAttribute(OdfNamespaces.Text + "style-name", $"Heading_{h.Level}")
+ );
+
+ if (!string.IsNullOrEmpty(h.Id))
+ el.Add(new XAttribute(OdfNamespaces.Text + "name", h.Id));
+
+ AddInlineContent(el, h);
+ return el;
+ }
+
+ private static XElement ConvertTOC(OdfTOC toc)
+ {
+ var tocSource = new XElement(OdfNamespaces.Text + "table-of-content-source",
+ new XAttribute(OdfNamespaces.Text + "outline-level", toc.Level.ToString())
+ );
+
+ var tocBody = new XElement(OdfNamespaces.Text + "index-body");
+
+ foreach (var child in toc.Nodes)
+ {
+ if (child is OdfParagraph p)
+ {
+ tocBody.Add(ConvertParagraph(p));
+ }
+ }
+
+ return new XElement(OdfNamespaces.Text + "table-of-content",
+ new XAttribute(OdfNamespaces.Text + "name", toc.Name),
+ tocSource,
+ tocBody
+ );
+ }
+
+ private static void AddInlineContent(XElement parent, OdfContentBase content)
+ {
+ if (!string.IsNullOrEmpty(content.TextContent))
+ parent.Add(new XText(content.TextContent));
+
+ foreach (var child in content.Nodes)
+ {
+ var el = ConvertInlineNode(child);
+ if (el != null)
+ parent.Add(el);
+ }
+ }
+
+ private static object? ConvertInlineNode(IDOMElement node)
+ {
+ return node switch
+ {
+ OdfSpan span => ConvertSpan(span),
+ OdfLineBreak => new XElement(OdfNamespaces.Text + "line-break"),
+ OdfNbSpace => new XElement(OdfNamespaces.Text + "s"),
+ OdfTab => new XElement(OdfNamespaces.Text + "tab"),
+ _ => null
+ };
+ }
+
+ private static XElement ConvertSpan(OdfSpan span)
+ {
+ XElement el;
+
+ if (span.IsLink && !string.IsNullOrEmpty(span.Href))
+ {
+ el = new XElement(OdfNamespaces.Text + "a",
+ new XAttribute(OdfNamespaces.XLink + "href", span.Href),
+ new XAttribute(OdfNamespaces.XLink + "type", "simple")
+ );
+ }
+ else
+ {
+ el = new XElement(OdfNamespaces.Text + "span");
+
+ var styleName = GetAutoStyleName(span.FontStyle);
+ if (!string.IsNullOrEmpty(styleName))
+ el.Add(new XAttribute(OdfNamespaces.Text + "style-name", styleName));
+ }
+
+ if (!string.IsNullOrEmpty(span.Id))
+ {
+ el.AddFirst(new XElement(OdfNamespaces.Text + "bookmark-start",
+ new XAttribute(OdfNamespaces.Text + "name", span.Id)));
+ }
+
+ if (!string.IsNullOrEmpty(span.TextContent))
+ el.Add(new XText(span.TextContent));
+
+ foreach (var child in span.Nodes)
+ {
+ var childEl = ConvertInlineNode(child);
+ if (childEl != null)
+ el.Add(childEl);
+ }
+
+ if (!string.IsNullOrEmpty(span.Id))
+ {
+ el.Add(new XElement(OdfNamespaces.Text + "bookmark-end",
+ new XAttribute(OdfNamespaces.Text + "name", span.Id)));
+ }
+
+ return el;
+ }
+}
diff --git a/CSharpBible/Data/DocumentUtils/Document.Pdf/Document.Pdf.csproj b/CSharpBible/Data/DocumentUtils/Document.Pdf/Document.Pdf.csproj
index 914c04e38..7521c7a97 100644
--- a/CSharpBible/Data/DocumentUtils/Document.Pdf/Document.Pdf.csproj
+++ b/CSharpBible/Data/DocumentUtils/Document.Pdf/Document.Pdf.csproj
@@ -5,7 +5,7 @@
enable
-
+
diff --git a/CSharpBible/Data/PictureDB/PictureDB.Base/PictureDB.Base.csproj b/CSharpBible/Data/PictureDB/PictureDB.Base/PictureDB.Base.csproj
index 0ab59ca54..3b425b7b5 100644
--- a/CSharpBible/Data/PictureDB/PictureDB.Base/PictureDB.Base.csproj
+++ b/CSharpBible/Data/PictureDB/PictureDB.Base/PictureDB.Base.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/CSharpBible/Data/PictureDB/PictureDB.UI/PictureDB.UI.csproj b/CSharpBible/Data/PictureDB/PictureDB.UI/PictureDB.UI.csproj
index 3b7ee9f5a..2a4206e4b 100644
--- a/CSharpBible/Data/PictureDB/PictureDB.UI/PictureDB.UI.csproj
+++ b/CSharpBible/Data/PictureDB/PictureDB.UI/PictureDB.UI.csproj
@@ -8,10 +8,10 @@
-
-
-
-
+
+
+
+
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/DI/BootStrap.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/DI/BootStrap.cs
index c8c26ae4f..5d08c4708 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/DI/BootStrap.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/DI/BootStrap.cs
@@ -1,5 +1,6 @@
// RepoMigrator.App.Wpf/DI/Bootstrap.cs
using Microsoft.Extensions.DependencyInjection;
+using RepoMigrator.App.Wpf.Services;
using RepoMigrator.App.Wpf.ViewModels;
using RepoMigrator.Core;
using RepoMigrator.Core.Abstractions;
@@ -31,6 +32,7 @@ public static ServiceProvider Create()
});
// ViewModels
+ services.AddSingleton();
services.AddSingleton();
return services.BuildServiceProvider();
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml
index 91eab1cea..88892e5ee 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml
@@ -2,6 +2,7 @@
@@ -35,8 +36,9 @@
-
+
+
@@ -52,6 +54,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml.cs
index e9d71d141..0e1d90001 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/MainWindow.xaml.cs
@@ -1,5 +1,6 @@
// RepoMigrator.App.Wpf/MainWindow.xaml.cs
using System.Windows;
+using System.Windows.Controls;
using RepoMigrator.App.Wpf.ViewModels;
namespace RepoMigrator.App.Wpf;
@@ -8,9 +9,27 @@ public partial class MainWindow : Window
{
public MainWindow() => InitializeComponent();
+ private void WindowLoaded(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is not MainViewModel vm)
+ return;
+
+ if (FindName("SourcePasswordBox") is PasswordBox sourcePasswordBox)
+ sourcePasswordBox.Password = vm.SourcePassword ?? string.Empty;
+
+ if (FindName("TargetPasswordBox") is PasswordBox targetPasswordBox)
+ targetPasswordBox.Password = vm.TargetPassword ?? string.Empty;
+ }
+
private void SourcePasswordChanged(object sender, System.Windows.RoutedEventArgs e)
{
if (DataContext is MainViewModel vm && sender is System.Windows.Controls.PasswordBox pb)
vm.SourcePassword = pb.Password;
}
+
+ private void TargetPasswordChanged(object sender, System.Windows.RoutedEventArgs e)
+ {
+ if (DataContext is MainViewModel vm && sender is System.Windows.Controls.PasswordBox pb)
+ vm.TargetPassword = pb.Password;
+ }
}
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/RepoMigrator.App.Wpf.csproj b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/RepoMigrator.App.Wpf.csproj
index 9b6632180..2d8f5cb0c 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/RepoMigrator.App.Wpf.csproj
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/RepoMigrator.App.Wpf.csproj
@@ -13,7 +13,7 @@
-
-
+
+
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs
index 744d9bae3..93f606664 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs
@@ -1,7 +1,10 @@
// RepoMigrator.App.Wpf/ViewModels/MainViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using RepoMigrator.App.Wpf.Services;
+using RepoMigrator.App.Wpf.Settings;
using RepoMigrator.Core;
+using RepoMigrator.Core.Abstractions;
using System;
using System.Collections.ObjectModel;
using System.Security.AccessControl;
@@ -15,6 +18,11 @@ namespace RepoMigrator.App.Wpf.ViewModels;
public partial class MainViewModel : ObservableObject, IMigrationProgress
{
private readonly IMigrationService _migration;
+ private readonly IProviderFactory _providerFactory;
+ private readonly AppInputStateStore _inputStateStore;
+ private bool _isLoadingInputState;
+ private string? _targetUser;
+ private string? _targetPassword;
[ObservableProperty] public partial RepoType SourceType { get; set; } = RepoType.Git;
[ObservableProperty] public partial string SourceUrl { get; set; } = "";
@@ -24,6 +32,26 @@ public partial class MainViewModel : ObservableObject, IMigrationProgress
[ObservableProperty] public partial RepoType TargetType { get; set; } = RepoType.Git;
[ObservableProperty] public partial string TargetUrl { get; set; } = "";
+ [ObservableProperty] public partial string? TargetBranch { get; set; }
+ public string? TargetUser
+ {
+ get => _targetUser;
+ set
+ {
+ if (SetProperty(ref _targetUser, value))
+ SaveInputState();
+ }
+ }
+
+ public string? TargetPassword
+ {
+ get => _targetPassword;
+ set
+ {
+ if (SetProperty(ref _targetPassword, value))
+ SaveInputState();
+ }
+ }
[ObservableProperty] public partial string? FromId { get; set; }
[ObservableProperty] public partial string? ToId { get; set; }
@@ -42,33 +70,36 @@ public partial class MainViewModel : ObservableObject, IMigrationProgress
private CancellationTokenSource? _cts;
private int _total;
- public MainViewModel(IMigrationService migration) => _migration = migration;
+ public MainViewModel(IMigrationService migration, IProviderFactory providerFactory, AppInputStateStore inputStateStore)
+ {
+ _migration = migration;
+ _providerFactory = providerFactory;
+ _inputStateStore = inputStateStore;
+
+ _isLoadingInputState = true;
+ var state = _inputStateStore.Load();
+ SourceType = state.SourceType;
+ SourceUrl = state.SourceUrl;
+ SourceBranch = state.SourceBranch;
+ SourceUser = state.SourceUser;
+ SourcePassword = state.SourcePassword;
+ TargetType = state.TargetType;
+ TargetUrl = state.TargetUrl;
+ TargetBranch = state.TargetBranch;
+ TargetUser = state.TargetUser;
+ TargetPassword = state.TargetPassword;
+ FromId = state.FromId;
+ ToId = state.ToId;
+ MaxCount = state.MaxCount;
+ OldestFirst = state.OldestFirst;
+ _isLoadingInputState = false;
+ }
[RelayCommand]
private async Task Start()
{
- IsRunning = true;
- OnPropertyChanged(nameof(IsIdle));
- _cts = new CancellationTokenSource();
- try
+ await RunOperationAsync(clearLog: true, async ct =>
{
- Log = "";
- ProgressValue = 0;
-
- var src = new RepositoryEndpoint
- {
- Type = SourceType,
- UrlOrPath = SourceUrl,
- BranchOrTrunk = SourceBranch,
- Credentials = new RepoCredentials { Username = SourceUser, Password = SourcePassword }
- };
-
- var dst = new RepositoryEndpoint
- {
- Type = TargetType,
- UrlOrPath = TargetUrl
- };
-
var q = new ChangeSetQuery
{
FromExclusiveId = FromId,
@@ -77,25 +108,18 @@ private async Task Start()
OldestFirst = OldestFirst
};
- await _migration.MigrateAsync(src, dst, q, this, _cts.Token);
- }
- catch (OperationCanceledException)
- {
- Append("Abgebrochen.");
- }
- catch (Exception ex)
- {
- Append("Fehler: " + ex.Message);
- }
- finally
- {
- IsRunning = false;
- OnPropertyChanged(nameof(IsIdle));
- _cts?.Dispose();
- _cts = null;
- }
+ await _migration.MigrateAsync(CreateSourceEndpoint(), CreateTargetEndpoint(), q, this, ct);
+ });
}
+ [RelayCommand]
+ private async Task TestSource()
+ => await ProbeEndpointAsync("Quelle", CreateSourceEndpoint(), RepositoryAccessMode.Read);
+
+ [RelayCommand]
+ private async Task TestTarget()
+ => await ProbeEndpointAsync("Ziel", CreateTargetEndpoint(), RepositoryAccessMode.Write);
+
[RelayCommand]
private void Cancel() => _cts?.Cancel();
@@ -119,4 +143,113 @@ private void Append(string line)
ProgressValue = idx;
}
}
+
+ private RepositoryEndpoint CreateSourceEndpoint()
+ => new()
+ {
+ Type = SourceType,
+ UrlOrPath = SourceUrl,
+ BranchOrTrunk = SourceBranch,
+ Credentials = new RepoCredentials { Username = SourceUser, Password = SourcePassword }
+ };
+
+ private RepositoryEndpoint CreateTargetEndpoint()
+ => new()
+ {
+ Type = TargetType,
+ UrlOrPath = TargetUrl,
+ BranchOrTrunk = TargetBranch,
+ Credentials = new RepoCredentials { Username = TargetUser, Password = TargetPassword }
+ };
+
+ private async Task ProbeEndpointAsync(string label, RepositoryEndpoint endpoint, RepositoryAccessMode accessMode)
+ {
+ await RunOperationAsync(clearLog: false, async ct =>
+ {
+ await using var provider = _providerFactory.Create(endpoint.Type);
+ Append($"Teste {label} ({provider.Name}) …");
+
+ var result = await provider.ProbeAsync(endpoint, accessMode, ct);
+ Append(result.Success
+ ? $"{label} erfolgreich getestet: {result.Summary}"
+ : $"{label}-Test fehlgeschlagen: {result.Summary}");
+
+ foreach (var detail in result.Details)
+ Append($" {detail}");
+ });
+ }
+
+ private async Task RunOperationAsync(bool clearLog, Func action)
+ {
+ if (IsRunning)
+ return;
+
+ IsRunning = true;
+ OnPropertyChanged(nameof(IsIdle));
+ _cts = new CancellationTokenSource();
+ try
+ {
+ if (clearLog)
+ {
+ Log = "";
+ _total = 0;
+ ProgressValue = 0;
+ ProgressMax = 100;
+ }
+
+ await action(_cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ Append("Abgebrochen.");
+ }
+ catch (Exception ex)
+ {
+ Append("Fehler: " + ex.Message);
+ }
+ finally
+ {
+ IsRunning = false;
+ OnPropertyChanged(nameof(IsIdle));
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ partial void OnSourceTypeChanged(RepoType value) => SaveInputState();
+ partial void OnSourceUrlChanged(string value) => SaveInputState();
+ partial void OnSourceBranchChanged(string? value) => SaveInputState();
+ partial void OnSourceUserChanged(string? value) => SaveInputState();
+ partial void OnSourcePasswordChanged(string? value) => SaveInputState();
+ partial void OnTargetTypeChanged(RepoType value) => SaveInputState();
+ partial void OnTargetUrlChanged(string value) => SaveInputState();
+ partial void OnTargetBranchChanged(string? value) => SaveInputState();
+ partial void OnFromIdChanged(string? value) => SaveInputState();
+ partial void OnToIdChanged(string? value) => SaveInputState();
+ partial void OnMaxCountChanged(int? value) => SaveInputState();
+ partial void OnOldestFirstChanged(bool value) => SaveInputState();
+
+ private void SaveInputState()
+ {
+ if (_isLoadingInputState)
+ return;
+
+ _inputStateStore.Save(new AppInputState
+ {
+ SourceType = SourceType,
+ SourceUrl = SourceUrl,
+ SourceBranch = SourceBranch,
+ SourceUser = SourceUser,
+ SourcePassword = SourcePassword,
+ TargetType = TargetType,
+ TargetUrl = TargetUrl,
+ TargetBranch = TargetBranch,
+ TargetUser = TargetUser,
+ TargetPassword = TargetPassword,
+ FromId = FromId,
+ ToId = ToId,
+ MaxCount = MaxCount,
+ OldestFirst = OldestFirst
+ });
+ }
}
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Abstractions/IVersionControlProvider.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Abstractions/IVersionControlProvider.cs
index 36aeff09b..658c4e029 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Abstractions/IVersionControlProvider.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Abstractions/IVersionControlProvider.cs
@@ -9,11 +9,12 @@ namespace RepoMigrator.Core.Abstractions;
public interface IVersionControlProvider : IAsyncDisposable
{
Task OpenAsync(RepositoryEndpoint endpoint, CancellationToken ct);
+ Task ProbeAsync(RepositoryEndpoint endpoint, RepositoryAccessMode accessMode, CancellationToken ct);
Task> GetChangeSetsAsync(ChangeSetQuery query, CancellationToken ct);
Task MaterializeSnapshotAsync(string workDir, string changeSetId, CancellationToken ct);
- Task InitializeTargetAsync(string targetPath, bool emptyInit, CancellationToken ct);
+ Task InitializeTargetAsync(RepositoryEndpoint endpoint, bool emptyInit, CancellationToken ct);
Task CommitSnapshotAsync(string workDir, CommitMetadata metadata, CancellationToken ct);
string Name { get; }
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Models.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Models.cs
index b7c1b695c..c262501d5 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Models.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Models.cs
@@ -3,6 +3,8 @@ namespace RepoMigrator.Core;
public enum RepoType { Git, Svn, Custom }
+public enum RepositoryAccessMode { Read, Write }
+
public sealed class RepositoryEndpoint
{
public RepoType Type { get; init; }
@@ -42,4 +44,11 @@ public sealed class CommitMetadata
public string AuthorName { get; init; } = "";
public string? AuthorEmail { get; init; }
public DateTimeOffset Timestamp { get; init; }
+}
+
+public sealed class RepositoryProbeResult
+{
+ public bool Success { get; init; }
+ public string Summary { get; init; } = "";
+ public IReadOnlyList Details { get; init; } = Array.Empty();
}
\ No newline at end of file
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/RepoMigrator.Core.csproj b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/RepoMigrator.Core.csproj
index 8f7f30621..2503a65e5 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/RepoMigrator.Core.csproj
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/RepoMigrator.Core.csproj
@@ -6,6 +6,6 @@
enable
-
+
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs
index c348ce394..832a25cb5 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Core/Services/MigrationService.cs
@@ -52,8 +52,7 @@ public async Task MigrateAsync(
}
progress.Report($"Initialisiere Ziel ({dst.Name}) …");
- await dst.OpenAsync(target, ct);
- await dst.InitializeTargetAsync(target.UrlOrPath, emptyInit: true, ct);
+ await dst.InitializeTargetAsync(target, emptyInit: true, ct);
var total = changes.Count;
for (int i = 0; i < total; i++)
@@ -72,7 +71,7 @@ public async Task MigrateAsync(
// 2) Snapshot in Ziel committen
var meta = new CommitMetadata
{
- Message = c.Message,
+ Message = string.IsNullOrWhiteSpace(c.Message) ? $"Imported revision {c.Id}" : c.Message,
AuthorName = c.AuthorName,
AuthorEmail = c.AuthorEmail,
Timestamp = c.Timestamp
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.Git/GitProvider.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.Git/GitProvider.cs
index ea0ed0631..b04549a4a 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.Git/GitProvider.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.Git/GitProvider.cs
@@ -4,9 +4,10 @@
using RepoMigrator.Core.Abstractions;
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Reflection.Metadata;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -14,6 +15,7 @@ namespace RepoMigrator.Providers.Git;
public sealed class GitProvider : IVersionControlProvider
{
+ private RepositoryEndpoint? _endpoint;
private string? _localPath; // Lokales Arbeitsrepo für Quelle und/oder Ziel
private Repository? _repo;
public string Name => "Git";
@@ -22,6 +24,8 @@ public sealed class GitProvider : IVersionControlProvider
public async Task OpenAsync(RepositoryEndpoint endpoint, CancellationToken ct)
{
+ _endpoint = endpoint;
+
// Für lokale Pfade: direkt öffnen; für Remote: temporär klonen
_localPath = PrepareLocalPath(endpoint);
if (!Repository.IsValid(_localPath))
@@ -32,16 +36,16 @@ public async Task OpenAsync(RepositoryEndpoint endpoint, CancellationToken ct)
if (LooksLikeRemote(endpoint.UrlOrPath))
{
cloneOpts = new CloneOptions();
- if (endpoint.Credentials?.Username is not null)
+ if (HasHttpCredentials(endpoint))
{
- cloneOpts.FetchOptions.CredentialsProvider = (_url, _user, _cred) =>
- new UsernamePasswordCredentials
- {
- Username = endpoint.Credentials!.Username!,
- Password = endpoint.Credentials!.Password ?? ""
- };
+ var password = endpoint.Credentials?.Password ?? "";
+ cloneOpts.FetchOptions.CredentialsProvider = (_url, _user, _types) => new UsernamePasswordCredentials
+ {
+ Username = GetEffectiveHttpUsername(endpoint),
+ Password = password
+ };
}
- Repository.Clone(endpoint.UrlOrPath, _localPath!, cloneOpts);
+ Repository.Clone(GetAccessUrl(endpoint), _localPath!, cloneOpts);
}
else
{
@@ -62,6 +66,76 @@ public async Task OpenAsync(RepositoryEndpoint endpoint, CancellationToken ct)
await Task.CompletedTask;
}
+ public async Task ProbeAsync(RepositoryEndpoint endpoint, RepositoryAccessMode accessMode, CancellationToken ct)
+ {
+ try
+ {
+ if (!LooksLikeRemote(endpoint.UrlOrPath))
+ {
+ var localPath = Path.GetFullPath(endpoint.UrlOrPath);
+ if (!Repository.IsValid(localPath))
+ {
+ return new RepositoryProbeResult
+ {
+ Success = false,
+ Summary = "Lokales Git-Repository wurde nicht gefunden.",
+ Details = [$"Pfad: {localPath}"]
+ };
+ }
+
+ using var repo = new Repository(localPath);
+ return new RepositoryProbeResult
+ {
+ Success = true,
+ Summary = "Lokales Git-Repository ist erreichbar.",
+ Details =
+ [
+ $"Pfad: {localPath}",
+ $"Branch: {repo.Head.FriendlyName}",
+ $"Letzter Commit: {repo.Head.Tip?.Sha[..Math.Min(8, repo.Head.Tip.Sha.Length)] ?? "keiner"}"
+ ]
+ };
+ }
+
+ var output = await RunGitAsync(
+ $"ls-remote --symref \"{GetAccessUrl(endpoint)}\" HEAD",
+ workingDir: null,
+ operationName: "Git-Remote-Test",
+ ct);
+
+ var refs = output
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Take(3)
+ .ToArray();
+
+ var details = new List { $"Remote: {endpoint.UrlOrPath}" };
+ if (!string.IsNullOrWhiteSpace(endpoint.BranchOrTrunk))
+ details.Add($"Ziel-Branch: {endpoint.BranchOrTrunk}");
+ if (refs.Length > 0)
+ details.AddRange(refs.Select(r => $"Ref: {r}"));
+ else
+ details.Add("Keine Referenzen gefunden. Das Remote ist möglicherweise leer.");
+
+ if (accessMode == RepositoryAccessMode.Write)
+ details.Add("Hinweis: Der Test bestätigt Erreichbarkeit und Authentifizierung. Schreibrechte werden endgültig erst beim Push geprüft.");
+
+ return new RepositoryProbeResult
+ {
+ Success = true,
+ Summary = "Git-Remote ist erreichbar.",
+ Details = details
+ };
+ }
+ catch (Exception ex)
+ {
+ return new RepositoryProbeResult
+ {
+ Success = false,
+ Summary = ex.Message
+ };
+ }
+ }
+
public Task> GetChangeSetsAsync(ChangeSetQuery query, CancellationToken ct)
{
var commits = _repo!.Commits.QueryBy(new CommitFilter
@@ -113,13 +187,55 @@ public Task MaterializeSnapshotAsync(string workDir, string changeSetId, Cancell
return Task.CompletedTask;
}
- public Task InitializeTargetAsync(string targetPath, bool emptyInit, CancellationToken ct)
+ public async Task InitializeTargetAsync(RepositoryEndpoint endpoint, bool emptyInit, CancellationToken ct)
{
- if (!Repository.IsValid(targetPath))
+ _endpoint = endpoint;
+ _localPath = LooksLikeRemote(endpoint.UrlOrPath)
+ ? Path.Combine(Path.GetTempPath(), "RepoMigrator", "git-target", Guid.NewGuid().ToString("N"))
+ : Path.GetFullPath(endpoint.UrlOrPath);
+
+ Directory.CreateDirectory(_localPath);
+
+ if (!Repository.IsValid(_localPath))
+ Repository.Init(_localPath);
+
+ _repo?.Dispose();
+ _repo = new Repository(_localPath);
+
+ if (_endpoint is not null
+ && LooksLikeRemote(_endpoint.UrlOrPath)
+ && _repo.Network.Remotes["origin"] is null)
{
- Repository.Init(targetPath);
+ _repo.Network.Remotes.Add("origin", _endpoint.UrlOrPath);
+
+ var accessUrl = GetAccessUrl(_endpoint);
+ var targetBranch = NormalizeBranchName(_endpoint.BranchOrTrunk);
+
+ await RunGitAsync($"fetch \"{accessUrl}\" \"+refs/heads/*:refs/remotes/origin/*\"", _localPath, "Git-Fetch", ct);
+
+ var defaultRemoteBranch = await GetRemoteHeadBranchAsync(_endpoint, ct);
+ if (!string.IsNullOrWhiteSpace(targetBranch) && await RemoteBranchExistsAsync(_endpoint, targetBranch, ct))
+ {
+ await RunGitAsync($"checkout -B \"{targetBranch}\" \"origin/{targetBranch}\"", _localPath, "Git-Checkout", ct);
+ }
+ else if (!string.IsNullOrWhiteSpace(targetBranch) && !string.IsNullOrWhiteSpace(defaultRemoteBranch))
+ {
+ await RunGitAsync($"checkout -B \"{targetBranch}\" \"origin/{defaultRemoteBranch}\"", _localPath, "Git-Checkout", ct);
+ }
+ else if (!string.IsNullOrWhiteSpace(targetBranch))
+ {
+ await RunGitAsync($"checkout -B \"{targetBranch}\"", _localPath, "Git-Checkout", ct);
+ }
+ else if (!string.IsNullOrWhiteSpace(defaultRemoteBranch))
+ {
+ await RunGitAsync($"checkout -B \"{defaultRemoteBranch}\" \"origin/{defaultRemoteBranch}\"", _localPath, "Git-Checkout", ct);
+ }
+
+ _repo.Dispose();
+ _repo = new Repository(_localPath);
}
- return Task.CompletedTask;
+
+ return;
}
public Task CommitSnapshotAsync(string workDir, CommitMetadata metadata, CancellationToken ct)
@@ -132,7 +248,23 @@ public Task CommitSnapshotAsync(string workDir, CommitMetadata metadata, Cancell
Commands.Stage(_repo!, "*");
var author = new Signature(metadata.AuthorName, metadata.AuthorEmail ?? "unknown@example.com", metadata.Timestamp);
var committer = author;
- _repo!.Commit(metadata.Message, author, committer);
+ try
+ {
+ _repo!.Commit(metadata.Message, author, committer);
+ }
+ catch (EmptyCommitException)
+ {
+ return Task.CompletedTask;
+ }
+
+ if (_endpoint is not null && LooksLikeRemote(_endpoint.UrlOrPath))
+ {
+ var branchName = string.IsNullOrWhiteSpace(NormalizeBranchName(_endpoint.BranchOrTrunk))
+ ? _repo!.Head.FriendlyName
+ : NormalizeBranchName(_endpoint.BranchOrTrunk)!;
+ var args = $"push --set-upstream \"{GetAccessUrl(_endpoint)}\" \"HEAD:refs/heads/{branchName}\"";
+ return RunGitAsync(args, _localPath, "Git-Push", ct);
+ }
return Task.CompletedTask;
}
@@ -152,6 +284,114 @@ private static string PrepareLocalPath(RepositoryEndpoint ep)
=> LooksLikeRemote(ep.UrlOrPath) ? Path.Combine(Path.GetTempPath(), "RepoMigrator", "git", Guid.NewGuid().ToString("N"))
: Path.GetFullPath(ep.UrlOrPath);
+ private static bool IsHttpRemote(string urlOrPath)
+ => urlOrPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ || urlOrPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
+
+ private static string GetAccessUrl(RepositoryEndpoint endpoint)
+ {
+ if (!IsHttpRemote(endpoint.UrlOrPath) || !HasHttpCredentials(endpoint))
+ return endpoint.UrlOrPath;
+
+ var uri = new Uri(endpoint.UrlOrPath, UriKind.Absolute);
+ var builder = new UriBuilder(uri)
+ {
+ UserName = GetEffectiveHttpUsername(endpoint),
+ Password = endpoint.Credentials?.Password ?? string.Empty
+ };
+
+ return builder.Uri.AbsoluteUri;
+ }
+
+ private static async Task GetRemoteHeadBranchAsync(RepositoryEndpoint endpoint, CancellationToken ct)
+ {
+ var output = await RunGitAsync(
+ $"ls-remote --symref \"{GetAccessUrl(endpoint)}\" HEAD",
+ workingDir: null,
+ operationName: "Git-Remote-HEAD",
+ ct);
+
+ foreach (var line in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
+ {
+ const string prefix = "ref: refs/heads/";
+ if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ {
+ var headIndex = line.IndexOf("\tHEAD", StringComparison.OrdinalIgnoreCase);
+ if (headIndex > prefix.Length)
+ return line[prefix.Length..headIndex];
+ }
+ }
+
+ return null;
+ }
+
+ private static async Task RemoteBranchExistsAsync(RepositoryEndpoint endpoint, string branchName, CancellationToken ct)
+ {
+ var output = await RunGitAsync(
+ $"ls-remote --heads \"{GetAccessUrl(endpoint)}\" \"refs/heads/{branchName}\"",
+ workingDir: null,
+ operationName: "Git-Remote-Branch-Test",
+ ct);
+
+ return output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Length > 0;
+ }
+
+ private static bool HasHttpCredentials(RepositoryEndpoint endpoint)
+ => !string.IsNullOrWhiteSpace(endpoint.Credentials?.Username)
+ || !string.IsNullOrWhiteSpace(endpoint.Credentials?.Password);
+
+ private static string? NormalizeBranchName(string? branchName)
+ => string.IsNullOrWhiteSpace(branchName) ? null : branchName.Trim();
+
+ private static string GetEffectiveHttpUsername(RepositoryEndpoint endpoint)
+ => string.IsNullOrWhiteSpace(endpoint.Credentials?.Username)
+ ? "git"
+ : endpoint.Credentials.Username!;
+
+ private static async Task RunGitAsync(string arguments, string? workingDir, string operationName, CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = "git",
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8
+ };
+
+ psi.Environment["GIT_TERMINAL_PROMPT"] = "0";
+ psi.Environment["GCM_INTERACTIVE"] = "Never";
+
+ if (!string.IsNullOrWhiteSpace(workingDir))
+ psi.WorkingDirectory = workingDir;
+
+ using var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+
+ proc.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
+
+ if (!proc.Start())
+ throw new InvalidOperationException($"{operationName} konnte nicht gestartet werden.");
+
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+
+ await proc.WaitForExitAsync(ct);
+
+ if (proc.ExitCode != 0)
+ {
+ var message = stderr.Length > 0 ? stderr.ToString().Trim() : stdout.ToString().Trim();
+ throw new InvalidOperationException(string.IsNullOrWhiteSpace(message) ? $"{operationName} ist fehlgeschlagen." : message);
+ }
+
+ return stdout.ToString();
+ }
+
private static void CleanDirectory(string dir)
{
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.SvnCli/SvnCliProvider.cs b/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.SvnCli/SvnCliProvider.cs
index a4637c9bb..c4b2212dd 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.SvnCli/SvnCliProvider.cs
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Providers.SvnCli/SvnCliProvider.cs
@@ -29,6 +29,48 @@ public Task OpenAsync(RepositoryEndpoint endpoint, CancellationToken ct)
return Task.CompletedTask;
}
+ public async Task ProbeAsync(RepositoryEndpoint endpoint, RepositoryAccessMode accessMode, CancellationToken ct)
+ {
+ try
+ {
+ _endpoint = endpoint;
+ _wcPath = IsRemote(endpoint.UrlOrPath) ? null : Path.GetFullPath(endpoint.UrlOrPath);
+
+ var target = IsRemote(endpoint.UrlOrPath) ? endpoint.UrlOrPath : _wcPath!;
+ var infoXml = await RunSvnAsync($"info --xml \"{target}\"", workingDir: null, ct);
+ var doc = XDocument.Parse(infoXml);
+ var entry = doc.Root?.Element("entry");
+ var url = entry?.Element("url")?.Value;
+ var root = entry?.Element("repository")?.Element("root")?.Value;
+ var revision = entry?.Attribute("revision")?.Value;
+
+ var details = new List();
+ if (!string.IsNullOrWhiteSpace(url))
+ details.Add($"URL: {url}");
+ if (!string.IsNullOrWhiteSpace(root))
+ details.Add($"Repository Root: {root}");
+ if (!string.IsNullOrWhiteSpace(revision))
+ details.Add($"Revision: {revision}");
+ if (accessMode == RepositoryAccessMode.Write)
+ details.Add("Hinweis: Der Test bestätigt Erreichbarkeit. Schreibrechte werden endgültig erst beim Commit geprüft.");
+
+ return new RepositoryProbeResult
+ {
+ Success = true,
+ Summary = "SVN-Repository ist erreichbar.",
+ Details = details
+ };
+ }
+ catch (Exception ex)
+ {
+ return new RepositoryProbeResult
+ {
+ Success = false,
+ Summary = ex.Message
+ };
+ }
+ }
+
public async Task> GetChangeSetsAsync(ChangeSetQuery query, CancellationToken ct)
{
var repoUrl = await GetRepositoryUrlAsync(ct);
@@ -85,20 +127,22 @@ public async Task MaterializeSnapshotAsync(string workDir, string changeSetId, C
await RunSvnAsync($"export -r {changeSetId} \"{repoUrl}\" \"{workDir}\" --force", workingDir: null, ct);
}
- public async Task InitializeTargetAsync(string targetPath, bool emptyInit, CancellationToken ct)
+ public async Task InitializeTargetAsync(RepositoryEndpoint endpoint, bool emptyInit, CancellationToken ct)
{
- if (_endpoint is null)
- throw new InvalidOperationException("Endpoint nicht gesetzt. OpenAsync zuerst aufrufen.");
+ _endpoint = endpoint;
- Directory.CreateDirectory(targetPath);
- _wcPath = Path.GetFullPath(targetPath);
+ _wcPath = IsRemote(endpoint.UrlOrPath)
+ ? Path.Combine(Path.GetTempPath(), "RepoMigrator", "svn-target", Guid.NewGuid().ToString("N"))
+ : Path.GetFullPath(endpoint.UrlOrPath);
- if (IsRemote(_endpoint.UrlOrPath))
+ Directory.CreateDirectory(_wcPath);
+
+ if (IsRemote(endpoint.UrlOrPath))
{
// Falls targetPath noch keine WC ist: checkout
if (!await IsWorkingCopyAsync(_wcPath, ct))
{
- await RunSvnAsync($"checkout \"{_endpoint.UrlOrPath}\" \"{_wcPath}\"", workingDir: null, ct);
+ await RunSvnAsync($"checkout \"{endpoint.UrlOrPath}\" \"{_wcPath}\"", workingDir: null, ct);
}
}
else
diff --git a/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj b/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj
index b537b0d1b..0b0b18caf 100644
--- a/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj
+++ b/CSharpBible/Data/RepoMigrator/RepoMigrator.Tests/RepoMigrator.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net9.0
@@ -7,8 +7,8 @@
enable
-
-
+
+
diff --git a/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj b/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj
index bda95742d..f8dff9530 100644
--- a/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj
+++ b/CSharpBible/Data/TraceCsv2realCsv/TraceCsv2realCsv.csproj
@@ -7,7 +7,7 @@
-
+
diff --git a/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj b/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj
index 8fe426c44..5e7c8b5d6 100644
--- a/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj
+++ b/CSharpBible/Data/TraceCsv2realCsvTests/TraceCsv2realCsvTests.csproj
@@ -7,9 +7,9 @@
-
-
-
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj b/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj
index edaba1c69..b3a111ddf 100644
--- a/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj
+++ b/CSharpBible/DependencyInjection/CustomerRepositoryTests/CustomerRepositoryTests.csproj
@@ -11,12 +11,12 @@
-
+
-
-
-
-
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -24,7 +24,7 @@
-
+
diff --git a/CSharpBible/Games/Arkanoid.Base/Arkanoid.Base.csproj b/CSharpBible/Games/Arkanoid.Base/Arkanoid.Base.csproj
new file mode 100644
index 000000000..1705ad3c5
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Arkanoid.Base.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0
+ enable
+ enable
+ true
+ Arkanoid.Base
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/Arkanoid.Base/GameEngine.cs b/CSharpBible/Games/Arkanoid.Base/GameEngine.cs
new file mode 100644
index 000000000..ac6cb6a0c
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/GameEngine.cs
@@ -0,0 +1,176 @@
+using Arkanoid.Base.Models;
+using System;
+using System.Linq;
+
+namespace Arkanoid.Base;
+
+///
+/// Provides the core game loop mechanics for the Arkanoid-style game,
+/// including state updates, collision handling, and level setup.
+///
+///
+/// This engine operates on a simple fixed-field coordinate system and updates
+/// the in-place. It is intentionally lightweight and
+/// deterministic for predictable gameplay behavior.
+///
+public class GameEngine
+{
+ ///
+ /// Gets the current mutable game state, including ball, paddle, bricks, score, and lives.
+ ///
+ public GameState State { get; } = new();
+
+ ///
+ /// Initializes a new instance of the class
+ /// and creates the default level layout.
+ ///
+ public GameEngine()
+ {
+ CreateDefaultLevel();
+ }
+
+ ///
+ /// Moves the paddle horizontally according to player input and elapsed time.
+ ///
+ ///
+ /// The horizontal input direction, typically in the range [-1, 1],
+ /// where negative values move left and positive values move right.
+ ///
+ /// The elapsed time since the last update, in seconds.
+ ///
+ /// The paddle position is clamped so it cannot leave the horizontal bounds of the field.
+ ///
+ public void MovePaddle(float direction, float deltaTime)
+ {
+ var dx = State.Paddle.Speed * direction * deltaTime;
+ var newX = Math.Clamp(State.Paddle.Position.X + dx, 0, State.FieldWidth - State.Paddle.Width);
+ State.Paddle.Position = new Vector2(newX, State.Paddle.Position.Y);
+ }
+
+ ///
+ /// Advances the simulation by the specified time step.
+ ///
+ /// The elapsed time since the last update, in seconds.
+ ///
+ /// If the game is over, no further updates are performed.
+ /// This method updates the ball position and resolves collisions.
+ ///
+ public void Update(float deltaTime)
+ {
+ if (State.IsGameOver) return;
+
+ State.Ball.Position = new Vector2(
+ State.Ball.Position.X + State.Ball.Velocity.X * deltaTime,
+ State.Ball.Position.Y + State.Ball.Velocity.Y * deltaTime);
+
+ HandleCollisions();
+ }
+
+ ///
+ /// Detects and resolves collisions between the ball and walls, paddle, and bricks.
+ ///
+ ///
+ /// - Wall collisions reflect velocity components as needed.
+ /// - Falling below the field reduces lives and resets the ball and paddle.
+ /// - Paddle collisions reflect the ball upward when moving downward.
+ /// - Brick collisions reflect vertical velocity and reduce hit points for breakable bricks.
+ /// - If all bricks are destroyed, the game ends.
+ ///
+ private void HandleCollisions()
+ {
+ var b = State.Ball;
+
+ // walls
+ if (b.Position.X - b.Radius < 0 || b.Position.X + b.Radius > State.FieldWidth)
+ State.Ball.Velocity = new Vector2(-b.Velocity.X, b.Velocity.Y);
+ if (b.Position.Y - b.Radius < 0)
+ State.Ball.Velocity = new Vector2(b.Velocity.X, -b.Velocity.Y);
+ if (b.Position.Y - b.Radius > State.FieldHeight)
+ {
+ State.Lives--;
+ if (State.Lives <= 0) State.IsGameOver = true;
+ ResetBallAndPaddle();
+ return;
+ }
+
+ // paddle (simple AABB)
+ var p = State.Paddle;
+ if (b.Position.Y + b.Radius >= p.Position.Y &&
+ b.Position.Y - b.Radius <= p.Position.Y + 1 &&
+ b.Position.X >= p.Position.X &&
+ b.Position.X <= p.Position.X + p.Width &&
+ b.Velocity.Y > 0)
+ {
+ State.Ball.Velocity = new Vector2(b.Velocity.X, -Math.Abs(b.Velocity.Y));
+ }
+
+ // bricks
+ foreach (var brick in State.Bricks.ToList())
+ {
+ if (brick.IsDestroyed) continue;
+
+ if (b.Position.X + b.Radius < brick.Position.X ||
+ b.Position.X - b.Radius > brick.Position.X + brick.Width ||
+ b.Position.Y + b.Radius < brick.Position.Y ||
+ b.Position.Y - b.Radius > brick.Position.Y + brick.Height)
+ continue;
+
+ State.Ball.Velocity = new Vector2(b.Velocity.X, -b.Velocity.Y);
+
+ if (brick.Type != BrickType.Unbreakable)
+ {
+ brick.HitPoints--;
+ if (brick.IsDestroyed)
+ {
+ State.Score += 10;
+ }
+ }
+ }
+
+ if (State.Bricks.All(br => br.IsDestroyed))
+ State.IsGameOver = true;
+ }
+
+ ///
+ /// Resets the paddle and ball to their default starting positions and velocity.
+ ///
+ ///
+ /// This is invoked after losing a life or when initializing a level.
+ ///
+ private void ResetBallAndPaddle()
+ {
+ State.Paddle.Position = new Vector2(State.FieldWidth / 2 - State.Paddle.Width / 2, State.FieldHeight - 2);
+ State.Ball.Position = new Vector2(State.FieldWidth / 2, State.FieldHeight / 2);
+ State.Ball.Velocity = new Vector2(15, -20);
+ }
+
+ ///
+ /// Creates the default level layout consisting of a rectangular grid of bricks.
+ ///
+ ///
+ /// The layout uses a fixed number of rows and columns and distributes bricks evenly
+ /// across the field width. All bricks are initialized as breakable with one hit point.
+ ///
+ private void CreateDefaultLevel()
+ {
+ State.Bricks.Clear();
+ var rows = 5;
+ var cols = 10;
+ var brickWidth = State.FieldWidth / cols;
+ for (var y = 0; y < rows; y++)
+ {
+ for (var x = 0; x < cols; x++)
+ {
+ State.Bricks.Add(new Brick
+ {
+ Position = new Vector2(x * brickWidth, 2 + y),
+ Width = brickWidth - 0.5f,
+ Height = 1,
+ Type = BrickType.Normal,
+ HitPoints = 1
+ });
+ }
+ }
+ ResetBallAndPaddle();
+ }
+}
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/Ball.cs b/CSharpBible/Games/Arkanoid.Base/Models/Ball.cs
new file mode 100644
index 000000000..fef28322d
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/Ball.cs
@@ -0,0 +1,34 @@
+namespace Arkanoid.Base.Models;
+///
+/// The ball object in the Arkanoid game.
+///
+///
+public class Ball : IGameObject
+{
+ ///
+ /// Gets or sets the current world-space position of the ball in game units.
+ ///
+ ///
+ /// The position represents the center of the ball and is typically updated each frame
+ /// based on and elapsed time.
+ ///
+ public Vector2 Position { get; set; } = new(10, 10);
+
+ ///
+ /// Gets or sets the linear velocity of the ball in game units per second.
+ ///
+ ///
+ /// Positive X moves the ball to the right, and positive Y moves it upward, assuming a
+ /// conventional Cartesian coordinate system.
+ ///
+ public Vector2 Velocity { get; set; } = new(10, -10); // units per second
+
+ ///
+ /// Gets or sets the radius of the ball in game units.
+ ///
+ ///
+ /// The radius is used for collision detection and rendering. The ball's diameter is
+ /// 2 * Radius.
+ ///
+ public float Radius { get; set; } = 0.5f;
+}
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/Brick.cs b/CSharpBible/Games/Arkanoid.Base/Models/Brick.cs
new file mode 100644
index 000000000..4dcd6b7a1
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/Brick.cs
@@ -0,0 +1,51 @@
+namespace Arkanoid.Base.Models;
+
+///
+/// Represents a single brick in the game arena, including its position, size, type,
+/// durability, and destruction state.
+///
+///
+/// A brick can be breakable or unbreakable depending on its . The
+/// value defines how many hits are required to destroy it.
+/// The property reflects whether the brick should be
+/// considered removed from play.
+///
+public class Brick : IGameObject
+{
+ ///
+ /// Gets or sets the top-left position of the brick in world coordinates.
+ ///
+ public Vector2 Position { get; set; } = new(0, 0);
+
+ ///
+ /// Gets or sets the width of the brick in world units.
+ ///
+ public float Width { get; set; } = 3f;
+
+ ///
+ /// Gets or sets the height of the brick in world units.
+ ///
+ public float Height { get; set; } = 1f;
+
+ ///
+ /// Gets or sets the brick's type, which determines its behavior (e.g., breakable or unbreakable).
+ ///
+ public BrickType Type { get; set; }
+
+ ///
+ /// Gets or sets the current hit points of the brick.
+ ///
+ ///
+ /// When this value reaches zero or below, the brick is considered destroyed
+ /// unless it is of type .
+ ///
+ public int HitPoints { get; set; } = 1;
+
+ ///
+ /// Gets a value indicating whether the brick is destroyed and should be removed from play.
+ ///
+ ///
+ /// Unbreakable bricks are never destroyed even if is zero or negative.
+ ///
+ public bool IsDestroyed => HitPoints <= 0 && Type != BrickType.Unbreakable;
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/BrickType.cs b/CSharpBible/Games/Arkanoid.Base/Models/BrickType.cs
new file mode 100644
index 000000000..f7f21c797
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/BrickType.cs
@@ -0,0 +1,23 @@
+namespace Arkanoid.Base.Models;
+
+///
+/// Defines the available brick categories used by the game logic, rendering, and collision handling
+/// to determine durability and behavior when hit by a ball.
+///
+public enum BrickType
+{
+ ///
+ /// A standard brick that is destroyed after a single hit.
+ ///
+ Normal,
+
+ ///
+ /// A reinforced brick that requires multiple hits to break.
+ ///
+ Strong,
+
+ ///
+ /// An indestructible brick that never breaks when hit.
+ ///
+ Unbreakable
+}
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/GameState.cs b/CSharpBible/Games/Arkanoid.Base/Models/GameState.cs
new file mode 100644
index 000000000..0ee604c9f
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/GameState.cs
@@ -0,0 +1,83 @@
+namespace Arkanoid.Base.Models;
+
+///
+/// Represents the complete mutable state of an Arkanoid game session, including
+/// player control, ball physics, level geometry, scoring, and terminal flags.
+///
+///
+/// This type is intended to be the single source of truth for the game loop,
+/// allowing rendering and simulation to read and update a unified model.
+///
+public class GameState
+{
+ ///
+ /// Gets the player's paddle, which is responsible for reflecting the ball.
+ ///
+ ///
+ /// The paddle position and size are typically updated by player input and
+ /// used during collision checks with the ball.
+ ///
+ public Paddle Paddle { get; } = new();
+
+ ///
+ /// Gets the active ball instance used for movement, collisions, and scoring.
+ ///
+ ///
+ /// The ball's position and velocity are advanced each tick of the game loop
+ /// and are evaluated against the field bounds, paddle, and bricks.
+ ///
+ public Ball Ball { get; } = new();
+
+ ///
+ /// Gets the collection of bricks currently present in the playfield.
+ ///
+ ///
+ /// Bricks are typically removed when hit by the ball and may contribute
+ /// to the score or trigger power-ups in extended implementations.
+ ///
+ public List Bricks { get; } = new();
+
+ ///
+ /// Gets or sets the player's current score.
+ ///
+ ///
+ /// This value is generally increased when the ball destroys bricks or
+ /// completes objectives, and may be used for high score tracking.
+ ///
+ public int Score { get; set; }
+
+ ///
+ /// Gets or sets the number of remaining lives.
+ ///
+ ///
+ /// A life is typically lost when the ball exits the bottom boundary of the field.
+ /// When this value reaches zero, the game is considered over.
+ ///
+ public int Lives { get; set; } = 3;
+
+ ///
+ /// Gets or sets the width of the playable field in game units.
+ ///
+ ///
+ /// Used to clamp paddle movement and to reflect the ball from the left and right walls.
+ ///
+ public float FieldWidth { get; set; } = 80;
+
+ ///
+ /// Gets or sets the height of the playable field in game units.
+ ///
+ ///
+ /// Used to determine the top boundary for ball reflection and the bottom
+ /// boundary for life loss.
+ ///
+ public float FieldHeight { get; set; } = 40;
+
+ ///
+ /// Gets or sets a value indicating whether the game has ended.
+ ///
+ ///
+ /// This flag is typically set when reaches zero or when
+ /// all bricks have been cleared, depending on game rules.
+ ///
+ public bool IsGameOver { get; set; }
+}
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/IGameObject.cs b/CSharpBible/Games/Arkanoid.Base/Models/IGameObject.cs
new file mode 100644
index 000000000..e9e51160e
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/IGameObject.cs
@@ -0,0 +1,14 @@
+namespace Arkanoid.Base.Models;
+///
+/// Interface IGameObject
+///
+public interface IGameObject
+{
+ ///
+ /// Gets or sets the position.
+ ///
+ ///
+ /// The position.
+ ///
+ Vector2 Position { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/Paddle.cs b/CSharpBible/Games/Arkanoid.Base/Models/Paddle.cs
new file mode 100644
index 000000000..60ee3568d
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/Paddle.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+
+namespace Arkanoid.Base.Models;
+
+///
+/// Represents the player-controlled paddle used to deflect the ball in the game.
+///
+///
+/// The paddle is modeled as a horizontal segment positioned in world coordinates.
+/// Movement is typically constrained to the horizontal axis by input handling elsewhere.
+///
+public class Paddle : IGameObject
+{
+ ///
+ /// Gets or sets the world-space position of the paddle's reference point.
+ ///
+ ///
+ /// The exact interpretation of the reference point (e.g., center or left edge)
+ /// is defined by the rendering and collision systems that consume this model.
+ ///
+ public Vector2 Position { get; set; } = new(10, 20);
+
+ ///
+ /// Gets or sets the paddle width expressed in world units.
+ ///
+ ///
+ /// This value is used for collision checks and rendering to determine the paddle's span.
+ ///
+ public float Width { get; set; } = 6f;
+
+ ///
+ /// Gets or sets the movement speed of the paddle in world units per second.
+ ///
+ ///
+ /// Higher values allow faster horizontal movement when input is applied.
+ ///
+ public float Speed { get; set; } = 25f; // units per second
+}
diff --git a/CSharpBible/Games/Arkanoid.Base/Models/Vector2.cs b/CSharpBible/Games/Arkanoid.Base/Models/Vector2.cs
new file mode 100644
index 000000000..278f9d64a
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Base/Models/Vector2.cs
@@ -0,0 +1,14 @@
+namespace Arkanoid.Base.Models;
+
+///
+/// Represents a two-dimensional vector with single-precision floating-point components,
+/// commonly used to describe positions, directions, or sizes in 2D space.
+///
+///
+/// The horizontal component of the vector, where positive values typically indicate movement or offset to the right.
+///
+///
+/// The vertical component of the vector, where positive values typically indicate movement or offset downward in screen space,
+/// depending on the coordinate system used by the game.
+///
+public record Vector2(float X, float Y);
\ No newline at end of file
diff --git a/CSharpBible/Games/Arkanoid.Cons/Arkanoid.Cons.csproj b/CSharpBible/Games/Arkanoid.Cons/Arkanoid.Cons.csproj
new file mode 100644
index 000000000..aeb6601b0
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Cons/Arkanoid.Cons.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ true
+ Arkanoid.Cons
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/Arkanoid.Cons/Program.cs b/CSharpBible/Games/Arkanoid.Cons/Program.cs
new file mode 100644
index 000000000..5138efed3
--- /dev/null
+++ b/CSharpBible/Games/Arkanoid.Cons/Program.cs
@@ -0,0 +1,154 @@
+using Arkanoid.Base;
+using BaseLib.Interfaces;
+using BaseLib.Models;
+using ConsoleDisplay.View;
+using ConsoleLib;
+using ConsoleLib.Interfaces;
+using System;
+using System.Threading;
+
+namespace Arkanoid.Cons
+{
+ internal class Program
+ {
+ static void Main(string[] args)
+ {
+ IConsole console = new ConsoleProxy();
+ console.CursorVisible = false;
+ var engine = new GameEngine();
+
+ var displayWidth = (int)engine.State.FieldWidth;
+ var displayHeight = (int)engine.State.FieldHeight;
+ var display = new Display(0, 0, displayWidth, displayHeight);
+
+ // ExtendedConsole initialisieren, um echte Key-/Maus-Events zu bekommen
+ var extConsole = new ExtendedConsole();
+
+ bool leftDown = false;
+ bool rightDown = false;
+ bool useMouse = true;
+
+ float paddleVelocity = 0f;
+ const float paddleAcceleration = 60f;
+ const float paddleFriction = 40f;
+
+ // Key-Events (KeyDown/KeyUp)
+ extConsole.KeyEvent += (s, e) =>
+ {
+ // Virtuelle Keycodes der Pfeiltasten
+ const ushort VK_LEFT = 0x25;
+ const ushort VK_RIGHT = 0x27;
+
+ if (e.usKeyCode == VK_LEFT)
+ {
+ leftDown = e.bKeyDown;
+ if (e.bKeyDown) rightDown = false;
+ e.Handled = true;
+ }
+ else if (e.usKeyCode == VK_RIGHT)
+ {
+ rightDown = e.bKeyDown;
+ if (e.bKeyDown) leftDown = false;
+ e.Handled = true;
+ }
+ else if (e.usKeyCode == (ushort)ConsoleKey.Escape && e.bKeyDown)
+ {
+ // ESC zum Beenden
+ extConsole.Stop();
+ engine.State.IsGameOver = true;
+ e.Handled = true;
+ }
+ };
+
+ // Maussteuerung: Paddle per Maus-X-Position bewegen
+ extConsole.MouseEvent += (s, me) =>
+ {
+ if (!useMouse) return;
+
+ if (me.MouseMoved || me.ButtonEvent)
+ {
+ var mouseX = me.MousePos.X;
+ // Paddle-Mittelpunkt an Maus-X ausrichten
+ var newX = mouseX - engine.State.Paddle.Width / 2f;
+ newX = Math.Clamp(newX, 0, engine.State.FieldWidth - engine.State.Paddle.Width);
+ engine.State.Paddle.Position = new Arkanoid.Base.Models.Vector2(newX, engine.State.Paddle.Position.Y);
+ me.Handled = true;
+ }
+ };
+
+ var lastTime = DateTime.UtcNow;
+
+ while (!engine.State.IsGameOver)
+ {
+ var now = DateTime.UtcNow;
+ var delta = (float)(now - lastTime).TotalSeconds;
+ if (delta <= 0f) delta = 0.001f;
+ lastTime = now;
+
+ // Tastatursteuerung (wenn Maus nicht benutzt wird)
+ if (!useMouse)
+ {
+ float moveDir = 0f;
+ if (leftDown && !rightDown) moveDir = -1f;
+ else if (rightDown && !leftDown) moveDir = 1f;
+ else
+ {
+ // Reibung, wenn keine Richtung gehalten wird
+ if (paddleVelocity > 0)
+ {
+ paddleVelocity -= paddleFriction * delta;
+ if (paddleVelocity < 0) paddleVelocity = 0;
+ }
+ else if (paddleVelocity < 0)
+ {
+ paddleVelocity += paddleFriction * delta;
+ if (paddleVelocity > 0) paddleVelocity = 0;
+ }
+ }
+
+ if (moveDir != 0f)
+ {
+ paddleVelocity += moveDir * paddleAcceleration * delta;
+ }
+
+ if (paddleVelocity != 0f)
+ {
+ var direction = Math.Sign(paddleVelocity);
+ engine.MovePaddle(direction, delta);
+ }
+ }
+
+ engine.Update(delta);
+
+ // render
+ display.Clear();
+
+ // paddle
+ for (int x = 0; x < (int)engine.State.Paddle.Width; x++)
+ {
+ display.PutPixel((int)(engine.State.Paddle.Position.X + x), (int)engine.State.Paddle.Position.Y, ConsoleColor.Green);
+ }
+
+ // ball
+ display.PutPixel((int)engine.State.Ball.Position.X, (int)engine.State.Ball.Position.Y, ConsoleColor.Yellow);
+
+ // bricks
+ foreach (var brick in engine.State.Bricks)
+ {
+ if (brick.IsDestroyed) continue;
+ for (int bx = 0; bx < (int)brick.Width; bx++)
+ {
+ display.PutPixel((int)(brick.Position.X + bx), (int)brick.Position.Y, ConsoleColor.Red);
+ }
+ }
+
+ display.Update();
+
+ Thread.Sleep(10);
+ }
+
+ console.ResetColor();
+ console.CursorVisible = true;
+ }
+ }
+}
diff --git a/CSharpBible/Games/Asteroids/Asteroids_net.csproj b/CSharpBible/Games/Asteroids/Asteroids_net.csproj
index b549cb479..373fabcf2 100644
--- a/CSharpBible/Games/Asteroids/Asteroids_net.csproj
+++ b/CSharpBible/Games/Asteroids/Asteroids_net.csproj
@@ -14,7 +14,7 @@
-
+
@@ -24,4 +24,7 @@
+
+
+
diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/Abstractions/ColorTests.cs b/CSharpBible/Games/AsteroidsModernEngine.Tests/Abstractions/ColorTests.cs
index 54f409eac..a8c7196ad 100644
--- a/CSharpBible/Games/AsteroidsModernEngine.Tests/Abstractions/ColorTests.cs
+++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/Abstractions/ColorTests.cs
@@ -1,4 +1,5 @@
using AsteroidsModern.Engine.Abstractions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;
using System.Collections.Generic;
using System.Linq;
diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj b/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj
index 69a877585..4cd9f96aa 100644
--- a/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj
+++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/AsteroidsModernEngine.Tests.csproj
@@ -1,16 +1,28 @@
-
+
+
- net8.0
+ net6.0;net7.0;net8.0
false
true
- enable
- enable
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
-
-
-
-
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -18,4 +30,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/Fakes.cs b/CSharpBible/Games/AsteroidsModernEngine.Tests/Fakes.cs
index 19b3211c7..637ce2867 100644
--- a/CSharpBible/Games/AsteroidsModernEngine.Tests/Fakes.cs
+++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/Fakes.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Numerics;
using AsteroidsModern.Engine.Abstractions;
diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/GameWorldTests.cs b/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/GameWorldTests.cs
index 1522c17bc..6e00b31e4 100644
--- a/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/GameWorldTests.cs
+++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/GameWorldTests.cs
@@ -4,6 +4,8 @@
using AsteroidsModern.Engine.Model;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NSubstitute;
+using System.Collections.Generic;
+using System;
namespace AsteroidsModernEngine.Tests.Services;
@@ -40,8 +42,8 @@ public void Reset_ShouldPlaceShipCenterAndSpawnAsteroids()
{
var gw = CreateWorld(new(100, 80));
Assert.AreEqual(new Vector2(50, 40), gw.Ship.Position);
- Assert.IsTrue(gw.Asteroids.Count > 0);
- Assert.AreEqual(0, gw.Bullets.Count);
+ Assert.IsNotEmpty(gw.Asteroids);
+ Assert.IsEmpty(gw.Bullets);
}
[TestMethod]
@@ -54,7 +56,7 @@ public void Update_Thrust_IncreasesVelocityAndCallsSound()
var velBefore = gw.Ship.Velocity;
gw.Update(_input, _time, _sound);
- Assert.IsTrue(gw.Ship.Velocity.Length() > velBefore.Length());
+ Assert.IsGreaterThan(velBefore.Length(), gw.Ship.Velocity.Length());
_sound.Received().PlayThrust();
}
@@ -85,18 +87,18 @@ public void Fire_SpawnsBullet_WithCooldown()
// t=0, first shot
_time.TotalTime.Returns(0.0);
gw.Update(_input, _time, _sound);
- Assert.AreEqual(1, gw.Bullets.Count);
+ Assert.HasCount(1, gw.Bullets);
_sound.Received().PlayShoot();
// t=0.1, still cooldown, no extra bullet
_time.TotalTime.Returns(0.1);
gw.Update(_input, _time, _sound);
- Assert.AreEqual(1, gw.Bullets.Count);
+ Assert.HasCount(1, gw.Bullets);
// t=0.2, second shot
_time.TotalTime.Returns(0.2);
gw.Update(_input, _time, _sound);
- Assert.AreEqual(2, gw.Bullets.Count);
+ Assert.HasCount(2, gw.Bullets);
}
[TestMethod]
@@ -108,7 +110,7 @@ public void Bullets_Expire_ByLife()
_time.DeltaTime.Returns(0.016);
_time.TotalTime.Returns(0.0);
gw.Update(_input, _time, _sound);
- Assert.AreEqual(1, gw.Bullets.Count);
+ Assert.HasCount(1, gw.Bullets);
// Advance life beyond bullet life
for (int i = 0; i < 100; i++)
@@ -116,7 +118,7 @@ public void Bullets_Expire_ByLife()
_time.TotalTime.Returns(i * 0.05);
gw.Update(_input, _time, _sound);
}
- Assert.AreEqual(0, gw.Bullets.Count);
+ Assert.IsEmpty(gw.Bullets);
}
[TestMethod]
@@ -174,8 +176,8 @@ public void Collision_BulletWithAsteroid_SplitsOrRemoves()
gw.Update(_input, _time, _sound);
_sound.Received().PlayBang();
- Assert.IsTrue(gw.Asteroids.Count >= 1); // split into 2 if large, or removed if small
- Assert.AreEqual(0, gw.Bullets.Count);
+ Assert.IsGreaterThanOrEqualTo(1, gw.Asteroids.Count); // split into 2 if large, or removed if small
+ Assert.IsEmpty(gw.Bullets);
}
[TestMethod]
diff --git a/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/TitleScreenTests.cs b/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/TitleScreenTests.cs
index 84187639c..de0af92a8 100644
--- a/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/TitleScreenTests.cs
+++ b/CSharpBible/Games/AsteroidsModernEngine.Tests/Services/TitleScreenTests.cs
@@ -126,7 +126,7 @@ private static string GetScreenName(TitleScreen ts)
// Tests
- [DataTestMethod]
+ [TestMethod]
[DataRow(true)]
public void StartGame_FireOnTitle_RaisesEvent(bool _)
{
@@ -139,7 +139,7 @@ public void StartGame_FireOnTitle_RaisesEvent(bool _)
Assert.AreEqual(1, startEvents, "StartGameRequested sollte genau einmal ausgelst werden.");
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(true)]
public void StartGame_ThrustOnTitle_RaisesEvent(bool _)
{
@@ -152,7 +152,7 @@ public void StartGame_ThrustOnTitle_RaisesEvent(bool _)
Assert.AreEqual(1, startEvents, "StartGameRequested sollte genau einmal ausgelst werden.");
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(1, "Options")]
[DataRow(2, "Highscores")]
[DataRow(3, "Credits")]
@@ -168,7 +168,7 @@ public void Title_NavigateRight_Fire_OpensExpectedScreen(int stepsRight, string
Assert.AreEqual(expectedScreen, GetScreenName(ts));
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(true)]
public void Title_NavigateLeft_Wraps_ToCredits(bool _)
{
@@ -180,7 +180,7 @@ public void Title_NavigateLeft_Wraps_ToCredits(bool _)
Assert.AreEqual("Credits", GetScreenName(ts));
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(true, 4)]
[DataRow(false, 2)]
public void Options_ToggleSound_And_ChangeLives_OneStep(bool increase, int expectedLives)
@@ -210,7 +210,7 @@ public void Options_ToggleSound_And_ChangeLives_OneStep(bool increase, int expec
Assert.AreEqual(expectedLives, ts.StartLives);
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(20, 9)]
[DataRow(-20, 1)]
public void Options_StartLives_Clamped(int steps, int expectedLives)
@@ -238,7 +238,7 @@ public void Options_StartLives_Clamped(int steps, int expectedLives)
Assert.AreEqual(expectedLives, ts.StartLives);
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(50, 50)]
public void SetLastScore_UpdatesHighscore_And_ReturnsToTitle(int score, int expectedHigh)
{
@@ -254,7 +254,7 @@ public void SetLastScore_UpdatesHighscore_And_ReturnsToTitle(int score, int expe
Assert.AreEqual(expectedHigh, ts.HighScore);
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(true)]
public void SetLastScore_DoesNotLowerHighscore(bool _)
{
@@ -268,7 +268,7 @@ public void SetLastScore_DoesNotLowerHighscore(bool _)
Assert.AreEqual("Title", GetScreenName(ts));
}
- [DataTestMethod]
+ [TestMethod]
[DataRow(true)]
public void Options_EdgeDetection_NoRepeatOnHold(bool _)
{
diff --git a/CSharpBible/Games/AsteroidsModernEngine/AsteroidsModernEngine.csproj b/CSharpBible/Games/AsteroidsModernEngine/AsteroidsModernEngine.csproj
index 6acc27ca0..9794b1f17 100644
--- a/CSharpBible/Games/AsteroidsModernEngine/AsteroidsModernEngine.csproj
+++ b/CSharpBible/Games/AsteroidsModernEngine/AsteroidsModernEngine.csproj
@@ -1,22 +1,30 @@
-
+
+
- enable
- enable
- preview
+ net6.0;net7.0;net8.0
AsteroidsModernEngine
AsteroidsModern.Engine
-
-
- <_SdkVersion>$(NETCoreSdkVersion)
-
- net6.0
- $(TargetFrameworks);net7.0
- $(TargetFrameworks);net8.0
- $(TargetFrameworks);net9.0
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/AsteroidsModernEngine/Services/Interfaces/IGameWorld.cs b/CSharpBible/Games/AsteroidsModernEngine/Services/Interfaces/IGameWorld.cs
index 6ab677030..932cc5019 100644
--- a/CSharpBible/Games/AsteroidsModernEngine/Services/Interfaces/IGameWorld.cs
+++ b/CSharpBible/Games/AsteroidsModernEngine/Services/Interfaces/IGameWorld.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Numerics;
using AsteroidsModern.Engine.Abstractions;
using AsteroidsModern.Engine.Model.Interfaces;
diff --git a/CSharpBible/Games/AsteroidsModernEngineTests/Services/TitleScreenTests.cs b/CSharpBible/Games/AsteroidsModernEngineTests/Services/TitleScreenTests.cs
deleted file mode 100644
index 3638183a7..000000000
--- a/CSharpBible/Games/AsteroidsModernEngineTests/Services/TitleScreenTests.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-
-namespace AsteroidsModernEngineTests.Services
-{
- internal class TitleScreenTests
- {
- }
-}
diff --git a/CSharpBible/Games/AsteroidsModernUI/AsteroidsModernUI.csproj b/CSharpBible/Games/AsteroidsModernUI/AsteroidsModernUI.csproj
index ce4dcdcc0..0e04f0b85 100644
--- a/CSharpBible/Games/AsteroidsModernUI/AsteroidsModernUI.csproj
+++ b/CSharpBible/Games/AsteroidsModernUI/AsteroidsModernUI.csproj
@@ -10,13 +10,26 @@
AsteroidsModern.UI
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/Asteroids_Base/Asteroids_Base.csproj b/CSharpBible/Games/Asteroids_Base/Asteroids_Base.csproj
index a373f81b1..0e590c436 100644
--- a/CSharpBible/Games/Asteroids_Base/Asteroids_Base.csproj
+++ b/CSharpBible/Games/Asteroids_Base/Asteroids_Base.csproj
@@ -1,7 +1,7 @@
- net6.0-windows;net7.0-windows;net8.0-windows;net9.0-windows
+ net6.0-windows;net7.0-windows;net8.0-windows
true
diff --git a/CSharpBible/Games/BoxFlight/BoxFlight.csproj b/CSharpBible/Games/BoxFlight/BoxFlight.csproj
index bf9bb1374..a9f1db40f 100644
--- a/CSharpBible/Games/BoxFlight/BoxFlight.csproj
+++ b/CSharpBible/Games/BoxFlight/BoxFlight.csproj
@@ -8,7 +8,11 @@
preview
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/BoxFlight/ViewModels/BoxFlightViewModel.cs b/CSharpBible/Games/BoxFlight/ViewModels/BoxFlightViewModel.cs
index c729f0bac..68b81a521 100644
--- a/CSharpBible/Games/BoxFlight/ViewModels/BoxFlightViewModel.cs
+++ b/CSharpBible/Games/BoxFlight/ViewModels/BoxFlightViewModel.cs
@@ -36,7 +36,7 @@ public BoxFlightViewModel(IGameWorld world)
{
_world = world;
- int width = 640, height = 360;
+ int width = 1280, height = 720;
Frame = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
MiniMap = new WriteableBitmap(400, 400, 96, 96, PixelFormats.Bgra32, null);
_render = new RenderEntry[width];
@@ -128,7 +128,7 @@ private void DrawRenderEntries(int bd2)
for (int x = 0; x < _render.Length; x++)
{
var re = _render[x];
- if (re.Height > re.Shad / 2)
+ if (re.Shad !=0 && re.Height > re.Shad / 2)
DrawVLine(buffer, stride, x, bd2 + re.Shad, bd2 + re.Shad / 2, Colors.Black);
int c1 = Math.Clamp(re.Light, 0, 255);
diff --git a/CSharpBible/Games/BoxFlight/World/GameWorld.cs b/CSharpBible/Games/BoxFlight/World/GameWorld.cs
index d93f45ce2..9b352a558 100644
--- a/CSharpBible/Games/BoxFlight/World/GameWorld.cs
+++ b/CSharpBible/Games/BoxFlight/World/GameWorld.cs
@@ -71,17 +71,15 @@ public void Step(int time, bool stereo, int scrollOffset, int focusX, int maxHei
var lCv2 = new Vector(lCv1.Y, -lCv1.X);
_cMove = _cPoint - cMoveOld;
+
_cPoint.X -= Math.Truncate(_cPoint.X / 400.0) * 400.0;
_cPoint.Y -= Math.Truncate(_cPoint.Y / 400.0) * 400.0;
- var sPnt = _cPoint;
- if (stereo) { _cPoint.X += _cView.Y * 0.4; _cPoint.Y += -_cView.X * 0.4; }
- else { _cPoint.X += -_cView.Y * 0.4; _cPoint.Y += _cView.X * 0.4; }
- sPnt.X += _cView.X; sPnt.Y += _cView.Y;
foreach (var o in Objects)
{
- o.Visible = false; o.Hit = false;
+ o.Visible = false;
+ o.Hit = false;
if (o.Size <= 0) continue;
var vp = new Vector(o.Pos.X, o.Pos.Y) + (Vector)_cPoint + new Vector(400, 400);
vp -= new Vector(Math.Floor(vp.X / 400.0) * 400.0, Math.Floor(vp.Y / 400.0) * 400.0);
@@ -95,20 +93,38 @@ public void Step(int time, bool stereo, int scrollOffset, int focusX, int maxHei
o.Visible = vis;
}
+
int half = render.Length / 2;
int hrc2 = half / 2;
int obj = -1;
- for (int j = 0; j < render.Length; j++)
+ Parallel.For (0,render.Length, (j)=>
{
+ var sPnt = _cPoint;
double s, c;
- if (j <= half) { s = Math.Sin((hrc2 - j) * 0.6 / hrc2); c = Math.Cos((hrc2 - j) * 0.6 / hrc2); }
- else { s = Math.Sin((-j + hrc2 * 3) * 0.6 / hrc2); c = Math.Cos((-j + hrc2 * 3) * 0.6 / hrc2); }
+ if (stereo)
+ if (j <= half)
+ {
+ sPnt.X -= _cView.Y * 0.4;
+ sPnt.Y += _cView.X * 0.4;
+ s = Math.Sin((hrc2 - j) * 0.6 / hrc2);
+ c = Math.Cos((hrc2 - j) * 0.6 / hrc2);
+ }
+ else
+ {
+ sPnt.X += _cView.Y * 0.4;
+ sPnt.Y -= _cView.X * 0.4;
+ s = Math.Sin((-j + hrc2 * 3) * 0.6 / hrc2);
+ c = Math.Cos((-j + hrc2 * 3) * 0.6 / hrc2);
+ }
+ else
+ {
+ s = Math.Sin((half - j) * 0.6 / half);
+ c = Math.Cos((half - j) * 0.6 / half);
+ }
var renDir = new Vector(_cView.X * c + _cView.Y * s, -_cView.X * s + _cView.Y * c);
render[j] = Trace(maxHeight, sPnt, obj, renDir, j == focusX);
- }
+ });
- if (stereo) { _cPoint.X += -_cView.Y * 0.8; _cPoint.Y += _cView.X * 0.8; }
- else { _cPoint.X += _cView.Y * 0.8; _cPoint.Y += -_cView.X * 0.8; }
cPoint2 = _cPoint2; cPoint3 = _cPoint3;
}
@@ -118,10 +134,10 @@ private RenderEntry Trace(int maxHeight, Point sPnt, int obj, Vector renDir, boo
float rDistQ, r;
double rDist; double rr = 0; int hObj = obj;
var entry = new RenderEntry { Oo1 = -1, Oo2 = -1, Light = 0, Height = 0, Light2 = 0, Height2 = 0, Shad = 0 };
- if (obj >= 0 && Objects[obj].HitTest(_cPoint, renDir, out rDistQ, out r) && rDistQ > 2 && rDistQ < 300) { rDist = rDistQ; rr = r; }
+ if (obj >= 0 && Objects[obj].HitTest(sPnt, renDir, out rDistQ, out r) && rDistQ > 2 && rDistQ < 300) { rDist = rDistQ; rr = r; }
else { obj = -1; rDist = 300.0; }
- ObjHit(_cPoint, renDir, -1, ref obj, ref rDist, ref rr);
+ ObjHit(sPnt, renDir, -1, ref obj, ref rDist, ref rr);
if (obj >= 0)
{
var o = Objects[obj];
@@ -155,7 +171,8 @@ private RenderEntry Trace(int maxHeight, Point sPnt, int obj, Vector renDir, boo
int lsobj = hObj; double shDist = 300.0; double shRr = 0.0;
ObjHit(sPnt, renDir, -1, ref lsobj, ref shDist, ref shRr);
- if (lsobj >= 0) entry.Shad = (int)(maxHeight / (shDist + 1.0)); else entry.Shad = 0;
+ // if (lsobj >= 0) entry.Shad = (int)(maxHeight / (shDist + 1.0)); else
+ entry.Shad = 0;
return entry;
}
diff --git a/CSharpBible/Games/CONTRIBUTING.md b/CSharpBible/Games/CONTRIBUTING.md
new file mode 100644
index 000000000..eb11d2db4
--- /dev/null
+++ b/CSharpBible/Games/CONTRIBUTING.md
@@ -0,0 +1,33 @@
+# Contributing Guidelines
+
+## Coding Standards & Patterns
+
+### Architecture
+- **MVVM (Model-View-ViewModel):** We strictly follow the MVVM pattern for UI applications.
+- **Dependency Injection (DI):** Use DI for loose coupling and testability.
+- **File Structure:** Adhere to the "One Class per File" rule.
+- **Strict Separation of Concerns:** Each class should have a single responsibility.
+- **Strict Separation of Code and generated Stuff:** The code you write should be separate from any generated code.
+- **I18n/L10n:** All user-facing strings must be localized.
+- Files should be encoded in UTF-8 with BOM.
+- Prefer short methods (max about. 20 lines). If a method exceeds this, consider refactoring.
+- Try to achieve high code-coverage with unit tests.
+- Detailed XML documentation is required for all public members.
+ - Use ``, ``, and `` tags where appropriate.
+ - Also document Methods, longer than 5 lines, with remarks about their behavior.
+
+
+### Testing & TDD
+- **Test-Driven Development (TDD):** Tests must be created *before* the implementation.
+- **Frameworks:**
+ - **Testing Framework:** MSTestv4
+ - **Mocking:** NSubstitute
+ - **MVVM Support:** CommunityToolkit.Mvvm
+
+### Build Configuration
+- **Warnings as Errors:** `TreatWarningsAsErrors` is enabled. Ensure your code compiles without warnings.
+
+## Workflow
+1. Create a failing test (Red).
+2. Implement the minimal code to pass the test (Green).
+3. Refactor.
diff --git a/CSharpBible/Games/CreateCards/CreateCards.csproj b/CSharpBible/Games/CreateCards/CreateCards.csproj
index 90887b382..b7fa7a8c0 100644
--- a/CSharpBible/Games/CreateCards/CreateCards.csproj
+++ b/CSharpBible/Games/CreateCards/CreateCards.csproj
@@ -2,12 +2,21 @@
Exe
- net6.0;net7.0;net8.0;net9.0
+ net6.0;net7.0;net8.0
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
-
+
diff --git a/CSharpBible/Games/CreateCards/Model/Card.cs b/CSharpBible/Games/CreateCards/Model/Card.cs
index 47f853d43..d70a7a245 100644
--- a/CSharpBible/Games/CreateCards/Model/Card.cs
+++ b/CSharpBible/Games/CreateCards/Model/Card.cs
@@ -1,4 +1,5 @@
-using System.Collections.Generic;
+using System;
+using System.Collections.Generic;
using System.Drawing.Drawing2D;
using System.Drawing;
@@ -11,6 +12,23 @@ public enum CardValues
public class Card
{
+ // Helper methods for color manipulation (replacing ControlPaint)
+ private static Color DarkenColor(Color color, float factor)
+ {
+ int r = (int)(color.R * (1 - factor));
+ int g = (int)(color.G * (1 - factor));
+ int b = (int)(color.B * (1 - factor));
+ return Color.FromArgb(color.A, System.Math.Max(0, r), System.Math.Max(0, g), System.Math.Max(0, b));
+ }
+
+ private static Color LightenColor(Color color, float factor)
+ {
+ int r = (int)(color.R + (255 - color.R) * factor);
+ int g = (int)(color.G + (255 - color.G) * factor);
+ int b = (int)(color.B + (255 - color.B) * factor);
+ return Color.FromArgb(color.A, System.Math.Min(255, r), System.Math.Min(255, g), System.Math.Min(255, b));
+ }
+
public static Dictionary DrawDef = new() {
{ CardValues.Ace ,new("A",0.8){
PntSuits=new[]{new PointF(0.5f,0.5f) } } },
@@ -40,18 +58,14 @@ public class Card
new PointF(0.3f, 0.375f), new PointF(0.7f, 0.375f), new PointF(0.3f, 0.625f), new PointF(0.7f, 0.625f),
new PointF(0.5f, 0.3125f),new PointF(0.5f, 0.6825f) } } },
{ CardValues.Jack ,new("J"){
- PntSuits=new[]{ new PointF(0.7f, 0.25f), new PointF(0.7f, 0.35f), new PointF(0.7f, 0.45f), new PointF(0.7f, 0.55f),new PointF(0.7f, 0.65f),
- new PointF(0.6f, 0.75f),new PointF(0.5f, 0.75f),new PointF(0.4f, 0.75f), new PointF(0.3f, 0.65f) } } },
+ CustomDraw = (gfx, rect, color, suit) => DrawJack(gfx, rect, color, suit)
+ }},
{ CardValues.Queen ,new("Q"){
- PntSuits=new[]{ new PointF(0.3f, 0.35f), new PointF(0.3f, 0.45f), new PointF(0.3f, 0.55f),new PointF(0.3f, 0.65f),
- new PointF(0.7f, 0.35f), new PointF(0.7f, 0.45f), new PointF(0.7f, 0.55f), new PointF(0.7f, 0.75f),
- new PointF(0.4f, 0.3f), new PointF(0.5f, 0.25f),new PointF(0.6f, 0.3f),
- new PointF(0.4f, 0.7f), new PointF(0.5f, 0.75f),new PointF(0.6f, 0.65f),
- new PointF(0.5f, 0.55f) } } },
+ CustomDraw = (gfx, rect, color, suit) => DrawQueen(gfx, rect, color, suit)
+ }},
{ CardValues.King ,new("K"){
- PntSuits=new[]{ new PointF(0.3f, 0.25f), new PointF(0.3f, 0.35f), new PointF(0.3f, 0.45f), new PointF(0.3f, 0.55f),new PointF(0.3f, 0.75f),
- new PointF(0.3f, 0.65f), new PointF(0.4f, 0.55f), new PointF(0.5f, 0.45f), new PointF(0.6f, 0.35f), new PointF(0.7f, 0.25f),
- new PointF(0.566667f, 0.55f), new PointF(0.63333f, 0.65f), new PointF(0.7f, 0.75f) } } },
+ CustomDraw = (gfx, rect, color, suit) => DrawKing(gfx, rect, color, suit)
+ }},
};
public string Suit { get; set; }
public CardValues Value { get; set; }
@@ -86,13 +100,25 @@ public void DrawCard(int width, int height, Graphics g)
if (DrawDef.TryGetValue(Value, out var drawDef))
{
- foreach (var pnt in drawDef.PntVals)
- DrawText(rect, drawDef.PrintValue, drawDef.ValSize, pnt, g);
- foreach (var pnt in drawDef.PntSVals)
- DrawText(rect, Suit, drawDef.ValSize, pnt, g);
- foreach (var pnt in drawDef.PntSuits)
- DrawText(rect, Suit, drawDef.SuitSize, pnt, g);
+ if (drawDef.CustomDraw != null)
+ {
+ // Face card or custom layout
+ drawDef.CustomDraw(g, rect, Color, Suit);
+ foreach (var pnt in drawDef.PntVals)
+ DrawText(rect, drawDef.PrintValue, drawDef.ValSize, pnt, g);
+ foreach (var pnt in drawDef.PntSVals)
+ DrawText(rect, Suit, drawDef.ValSize, pnt, g);
+ }
+ else
+ {
+ foreach (var pnt in drawDef.PntVals)
+ DrawText(rect, drawDef.PrintValue, drawDef.ValSize, pnt, g);
+ foreach (var pnt in drawDef.PntSVals)
+ DrawText(rect, Suit, drawDef.ValSize, pnt, g);
+ foreach (var pnt in drawDef.PntSuits)
+ DrawText(rect, Suit, drawDef.SuitSize, pnt, g);
+ }
}
void DrawText(Rectangle rect, string sP, double fSize, PointF fpOffs, Graphics g)
@@ -108,6 +134,1325 @@ void DrawText(Rectangle rect, string sP, double fSize, PointF fpOffs, Graphics g
}
}
+ private static void DrawJack(Graphics g, Rectangle rect, Color color, string suit)
+ {
+ // French-style Jack (Valet) - elaborate mirrored double-headed design
+ float penW = System.Math.Max(1f, rect.Width / 120f);
+ using var outlinePen = new Pen(Color.FromArgb(50, 30, 15), penW);
+ using var thinPen = new Pen(Color.FromArgb(50, 30, 15), penW * 0.5f);
+ using var shadowPen = new Pen(Color.FromArgb(80, 0, 0, 0), penW * 0.4f);
+
+ // Colors - rich palette
+ Color cream = Color.FromArgb(255, 252, 245);
+ Color gold = Color.FromArgb(212, 175, 55);
+ Color darkGold = Color.FromArgb(170, 135, 30);
+ Color lightGold = Color.FromArgb(255, 223, 120);
+ Color skinTone = Color.FromArgb(255, 220, 185);
+ Color skinShadow = Color.FromArgb(235, 190, 150);
+ Color hairColor = Color.FromArgb(120, 75, 35);
+ Color hairHighlight = Color.FromArgb(160, 110, 60);
+
+ // Draw decorative frame with gradient effect
+ RectangleF frameRect = new RectangleF(rect.X + rect.Width * 0.10f, rect.Y + rect.Height * 0.06f,
+ rect.Width * 0.80f, rect.Height * 0.88f);
+
+ // Ornate frame background with subtle pattern
+ using var frameBrush = new LinearGradientBrush(frameRect, cream, Color.FromArgb(250, 245, 235), 45f);
+ g.FillRectangle(frameBrush, frameRect);
+
+ // Draw diagonal pattern in frame
+ using var patternPen = new Pen(Color.FromArgb(15, color), penW * 0.3f);
+ for (float i = frameRect.X; i < frameRect.Right + frameRect.Height; i += rect.Width * 0.04f)
+ {
+ g.DrawLine(patternPen, i, frameRect.Y, i - frameRect.Height * 0.3f, frameRect.Y + frameRect.Height * 0.3f);
+ }
+
+ // Inner decorative border - double line with gold
+ float borderInset = rect.Width * 0.02f;
+ RectangleF innerFrame = RectangleF.Inflate(frameRect, -borderInset * 2, -borderInset * 2);
+ using var borderPen = new Pen(color, penW * 1.2f);
+ using var goldBorderPen = new Pen(gold, penW * 0.6f);
+ g.DrawRectangle(borderPen, innerFrame.X, innerFrame.Y, innerFrame.Width, innerFrame.Height);
+ g.DrawRectangle(goldBorderPen, innerFrame.X - penW, innerFrame.Y - penW, innerFrame.Width + penW * 2, innerFrame.Height + penW * 2);
+
+ // Corner ornaments
+ DrawCornerOrnament(g, innerFrame.X, innerFrame.Y, rect.Width * 0.08f, gold, outlinePen, false, false);
+ DrawCornerOrnament(g, innerFrame.Right, innerFrame.Y, rect.Width * 0.08f, gold, outlinePen, true, false);
+ DrawCornerOrnament(g, innerFrame.X, innerFrame.Bottom, rect.Width * 0.08f, gold, outlinePen, false, true);
+ DrawCornerOrnament(g, innerFrame.Right, innerFrame.Bottom, rect.Width * 0.08f, gold, outlinePen, true, true);
+
+ // Draw upper half figure
+ DrawJackHalf(g, innerFrame, color, suit, false, outlinePen, thinPen, shadowPen,
+ skinTone, skinShadow, hairColor, hairHighlight, gold, darkGold, lightGold);
+
+ // Draw lower half figure (mirrored)
+ DrawJackHalf(g, innerFrame, color, suit, true, outlinePen, thinPen, shadowPen,
+ skinTone, skinShadow, hairColor, hairHighlight, gold, darkGold, lightGold);
+
+ // Center medallion with suit symbol
+ float centerY = innerFrame.Y + innerFrame.Height / 2;
+ float medalSize = innerFrame.Width * 0.22f;
+ RectangleF medalRect = new RectangleF(innerFrame.X + innerFrame.Width / 2 - medalSize / 2,
+ centerY - medalSize / 2, medalSize, medalSize);
+
+ // Medallion with gradient
+ using var medalGradient = new LinearGradientBrush(medalRect, Color.White, Color.FromArgb(240, 235, 220), 135f);
+ g.FillEllipse(medalGradient, medalRect);
+ using var medalBorderPen = new Pen(gold, penW * 1.5f);
+ g.DrawEllipse(medalBorderPen, medalRect);
+ g.DrawEllipse(outlinePen, RectangleF.Inflate(medalRect, penW, penW));
+
+ // Suit symbol in center
+ using var suitFont = new Font("Segoe UI Symbol", medalSize * 0.5f, FontStyle.Bold);
+ using var suitBrush = new SolidBrush(color);
+ StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ g.DrawString(suit, suitFont, suitBrush, medalRect, sf);
+ }
+
+ private static void DrawCornerOrnament(Graphics g, float x, float y, float size, Color gold, Pen outlinePen, bool flipH, bool flipV)
+ {
+ var state = g.Save();
+ g.TranslateTransform(x, y);
+ if (flipH) g.ScaleTransform(-1, 1);
+ if (flipV) g.ScaleTransform(1, -1);
+
+ using var goldBrush = new SolidBrush(gold);
+ // Fleur-de-lis style corner
+ PointF[] ornament = {
+ new PointF(0, 0),
+ new PointF(size * 0.3f, 0),
+ new PointF(size * 0.15f, size * 0.15f),
+ new PointF(size * 0.4f, size * 0.1f),
+ new PointF(size * 0.2f, size * 0.3f),
+ new PointF(size * 0.1f, size * 0.4f),
+ new PointF(size * 0.15f, size * 0.15f),
+ new PointF(0, size * 0.3f)
+ };
+ g.FillPolygon(goldBrush, ornament);
+ g.DrawPolygon(outlinePen, ornament);
+
+ g.Restore(state);
+ }
+
+ private static void DrawJackHalf(Graphics g, RectangleF rect, Color color, string suit, bool mirrored,
+ Pen outlinePen, Pen thinPen, Pen shadowPen, Color skinTone, Color skinShadow,
+ Color hairColor, Color hairHighlight, Color gold, Color darkGold, Color lightGold)
+ {
+ var state = g.Save();
+
+ if (mirrored)
+ {
+ g.TranslateTransform(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
+ g.RotateTransform(180);
+ g.TranslateTransform(-(rect.X + rect.Width / 2), -(rect.Y + rect.Height / 2));
+ }
+
+ float halfH = rect.Height * 0.47f;
+ RectangleF half = new RectangleF(rect.X, rect.Y, rect.Width, halfH);
+
+ // Shadow layer for depth
+ using var shadowBrush = new SolidBrush(Color.FromArgb(30, 0, 0, 0));
+
+ // Tunic with gradient and folds
+ RectangleF tunicRect = new RectangleF(half.X + half.Width * 0.18f, half.Y + half.Height * 0.4f,
+ half.Width * 0.64f, half.Height * 0.6f);
+ using var tunicGradient = new LinearGradientBrush(tunicRect,
+ Color.FromArgb(255, color), Color.FromArgb(200, DarkenColor(color, 0.2f)), 0f);
+
+ PointF[] tunic = {
+ new PointF(half.X + half.Width * 0.22f, half.Bottom),
+ new PointF(half.X + half.Width * 0.18f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.28f, half.Y + half.Height * 0.46f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.42f),
+ new PointF(half.X + half.Width * 0.72f, half.Y + half.Height * 0.46f),
+ new PointF(half.X + half.Width * 0.82f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.78f, half.Bottom)
+ };
+ g.FillPolygon(tunicGradient, tunic);
+ g.DrawPolygon(outlinePen, tunic);
+
+ // Tunic folds/creases for texture
+ using var foldPen = new Pen(Color.FromArgb(60, 0, 0, 0), thinPen.Width);
+ using var highlightPen = new Pen(Color.FromArgb(80, 255, 255, 255), thinPen.Width);
+ g.DrawLine(foldPen, half.X + half.Width * 0.35f, half.Y + half.Height * 0.55f,
+ half.X + half.Width * 0.32f, half.Bottom);
+ g.DrawLine(highlightPen, half.X + half.Width * 0.37f, half.Y + half.Height * 0.55f,
+ half.X + half.Width * 0.35f, half.Bottom);
+ g.DrawLine(foldPen, half.X + half.Width * 0.65f, half.Y + half.Height * 0.55f,
+ half.X + half.Width * 0.68f, half.Bottom);
+
+ // Decorative trim on tunic
+ using var trimPen = new Pen(gold, outlinePen.Width * 1.2f);
+ g.DrawLine(trimPen, half.X + half.Width * 0.5f, half.Y + half.Height * 0.45f,
+ half.X + half.Width * 0.5f, half.Bottom);
+
+ // Elaborate collar with lace detail
+ using var collarBrush = new SolidBrush(Color.White);
+ PointF[] collar = {
+ new PointF(half.X + half.Width * 0.35f, half.Y + half.Height * 0.46f),
+ new PointF(half.X + half.Width * 0.42f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.55f),
+ new PointF(half.X + half.Width * 0.58f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.65f, half.Y + half.Height * 0.46f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.42f)
+ };
+ g.FillPolygon(collarBrush, collar);
+ g.DrawPolygon(thinPen, collar);
+
+ // Lace pattern on collar
+ using var lacePen = new Pen(Color.FromArgb(100, 150, 150, 150), thinPen.Width * 0.5f);
+ for (float lx = half.X + half.Width * 0.4f; lx < half.X + half.Width * 0.6f; lx += half.Width * 0.03f)
+ {
+ g.DrawLine(lacePen, lx, half.Y + half.Height * 0.48f, lx + half.Width * 0.015f, half.Y + half.Height * 0.52f);
+ }
+
+ // Head with shading
+ float headW = half.Width * 0.24f;
+ float headH = half.Height * 0.34f;
+ RectangleF headRect = new RectangleF(half.X + half.Width * 0.5f - headW / 2,
+ half.Y + half.Height * 0.10f, headW, headH);
+
+ // Hair with texture
+ using var hairGradient = new LinearGradientBrush(
+ new RectangleF(headRect.X - headW * 0.15f, headRect.Y - headH * 0.1f, headW * 1.3f, headH * 0.6f),
+ hairHighlight, hairColor, 45f);
+ RectangleF hairRect = new RectangleF(headRect.X - headW * 0.12f, headRect.Y - headH * 0.08f,
+ headW * 1.24f, headH * 0.55f);
+ g.FillEllipse(hairGradient, hairRect);
+
+ // Hair strands
+ using var strandPen = new Pen(Color.FromArgb(80, 60, 40, 20), thinPen.Width * 0.6f);
+ for (int i = 0; i < 5; i++)
+ {
+ float sx = headRect.X + headW * (0.1f + i * 0.2f);
+ g.DrawBezier(strandPen,
+ new PointF(sx, headRect.Y),
+ new PointF(sx - headW * 0.05f, headRect.Y + headH * 0.1f),
+ new PointF(sx + headW * 0.05f, headRect.Y + headH * 0.2f),
+ new PointF(sx, headRect.Y + headH * 0.3f));
+ }
+
+ // Face with gradient for 3D effect
+ using var faceGradient = new LinearGradientBrush(headRect, skinTone, skinShadow, 120f);
+ g.FillEllipse(faceGradient, headRect);
+ g.DrawEllipse(outlinePen, headRect);
+
+ // Cheek blush
+ using var blushBrush = new SolidBrush(Color.FromArgb(40, 255, 150, 150));
+ g.FillEllipse(blushBrush, headRect.X + headW * 0.6f, headRect.Y + headH * 0.45f, headW * 0.25f, headH * 0.15f);
+
+ // Elaborate beret/cap with feather
+ RectangleF capRect = new RectangleF(headRect.X - headW * 0.2f, headRect.Y - headH * 0.35f, headW * 1.6f, headH * 0.6f);
+ using var capGradient = new LinearGradientBrush(capRect,
+ LightenColor(color, 0.2f), color, 90f);
+ PointF[] cap = {
+ new PointF(headRect.X - headW * 0.18f, headRect.Y + headH * 0.18f),
+ new PointF(headRect.X + headW * 0.1f, headRect.Y - headH * 0.15f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y - headH * 0.28f),
+ new PointF(headRect.Right + headW * 0.35f, headRect.Y - headH * 0.12f),
+ new PointF(headRect.Right + headW * 0.2f, headRect.Y + headH * 0.22f)
+ };
+ g.FillPolygon(capGradient, cap);
+ g.DrawPolygon(outlinePen, cap);
+
+ // Cap band
+ using var bandBrush = new SolidBrush(gold);
+ g.FillRectangle(bandBrush, headRect.X - headW * 0.1f, headRect.Y + headH * 0.08f, headW * 1.2f, headH * 0.08f);
+
+ // Jewel on cap
+ using var jewelBrush = new SolidBrush(Color.FromArgb(220, 20, 60));
+ float jewelSize = headW * 0.12f;
+ g.FillEllipse(jewelBrush, headRect.X + headW * 0.4f, headRect.Y + headH * 0.02f, jewelSize, jewelSize);
+ g.DrawEllipse(thinPen, headRect.X + headW * 0.4f, headRect.Y + headH * 0.02f, jewelSize, jewelSize);
+
+ // Elaborate feather with multiple strands
+ using var featherGradient = new LinearGradientBrush(
+ new RectangleF(headRect.Right, headRect.Y - headH * 0.7f, headW * 0.8f, headH * 0.8f),
+ lightGold, gold, 45f);
+ using var featherPen = new Pen(featherGradient, outlinePen.Width * 2f);
+
+ // Main feather curve
+ g.DrawBezier(featherPen,
+ new PointF(headRect.Right + headW * 0.25f, headRect.Y - headH * 0.05f),
+ new PointF(headRect.Right + headW * 0.6f, headRect.Y - headH * 0.35f),
+ new PointF(headRect.Right + headW * 0.5f, headRect.Y - headH * 0.55f),
+ new PointF(headRect.Right + headW * 0.15f, headRect.Y - headH * 0.45f));
+
+ // Feather barbs
+ using var barbPen = new Pen(gold, thinPen.Width * 0.8f);
+ for (int i = 0; i < 6; i++)
+ {
+ float t = i / 6f;
+ float bx = headRect.Right + headW * (0.25f + t * 0.25f);
+ float by = headRect.Y - headH * (0.05f + t * 0.35f);
+ g.DrawLine(barbPen, bx, by, bx + headW * 0.15f, by - headH * 0.08f);
+ g.DrawLine(barbPen, bx, by, bx - headW * 0.1f, by - headH * 0.12f);
+ }
+
+ // Eyes - two symmetric eyes (front view like Queen and King)
+ float eyeW = headW * 0.12f;
+ float eyeH = headH * 0.07f;
+ float eyeY = headRect.Y + headH * 0.36f;
+
+ // Left eye
+ float leftEyeX = headRect.X + headW * 0.22f;
+ DrawJackEye(g, leftEyeX, eyeY, eyeW, eyeH, thinPen);
+
+ // Right eye
+ float rightEyeX = headRect.X + headW * 0.66f;
+ DrawJackEye(g, rightEyeX, eyeY, eyeW, eyeH, thinPen);
+
+ // Eyebrows
+ using var browPen = new Pen(hairColor, thinPen.Width * 1.2f);
+ g.DrawArc(browPen, leftEyeX - eyeW * 0.1f, eyeY - eyeH * 1.0f, eyeW * 1.2f, eyeH, 200, 70);
+ g.DrawArc(browPen, rightEyeX - eyeW * 0.1f, eyeY - eyeH * 1.0f, eyeW * 1.2f, eyeH, 250, 70);
+
+ // Nose (centered)
+ g.DrawBezier(thinPen,
+ new PointF(headRect.X + headW * 0.48f, headRect.Y + headH * 0.4f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.48f),
+ new PointF(headRect.X + headW * 0.52f, headRect.Y + headH * 0.54f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.56f));
+
+ // Mouth (centered)
+ using var lipPen = new Pen(Color.FromArgb(180, 100, 90), thinPen.Width);
+ g.DrawArc(lipPen, headRect.X + headW * 0.4f, headRect.Y + headH * 0.62f, headW * 0.2f, headH * 0.1f, 0, 180);
+
+ // Halberd with detailed blade
+ float shaftX = half.X + half.Width * 0.10f;
+
+ // Shaft with wood grain
+ RectangleF shaftRect = new RectangleF(shaftX, half.Y + half.Height * 0.08f, half.Width * 0.028f, half.Height * 0.88f);
+ using var shaftGradient = new LinearGradientBrush(shaftRect,
+ Color.FromArgb(160, 110, 60), Color.FromArgb(100, 65, 30), 0f);
+ g.FillRectangle(shaftGradient, shaftRect);
+ g.DrawRectangle(outlinePen, shaftRect.X, shaftRect.Y, shaftRect.Width, shaftRect.Height);
+
+ // Wood grain lines
+ using var grainPen = new Pen(Color.FromArgb(50, 60, 40, 20), thinPen.Width * 0.3f);
+ for (float gy = shaftRect.Y; gy < shaftRect.Bottom; gy += half.Height * 0.05f)
+ {
+ g.DrawLine(grainPen, shaftRect.X + shaftRect.Width * 0.2f, gy,
+ shaftRect.X + shaftRect.Width * 0.8f, gy + half.Height * 0.02f);
+ }
+
+ // Elaborate blade with engraving
+ using var bladeGradient = new LinearGradientBrush(
+ new RectangleF(shaftX, half.Y, half.Width * 0.15f, half.Height * 0.3f),
+ Color.FromArgb(220, 220, 230), Color.FromArgb(160, 165, 175), 90f);
+ PointF[] blade = {
+ new PointF(shaftX + half.Width * 0.028f, half.Y + half.Height * 0.12f),
+ new PointF(shaftX + half.Width * 0.08f, half.Y + half.Height * 0.04f),
+ new PointF(shaftX + half.Width * 0.14f, half.Y + half.Height * 0.06f),
+ new PointF(shaftX + half.Width * 0.12f, half.Y + half.Height * 0.18f),
+ new PointF(shaftX + half.Width * 0.08f, half.Y + half.Height * 0.28f),
+ new PointF(shaftX + half.Width * 0.028f, half.Y + half.Height * 0.24f)
+ };
+ g.FillPolygon(bladeGradient, blade);
+ g.DrawPolygon(outlinePen, blade);
+
+ // Blade edge highlight
+ using var edgePen = new Pen(Color.FromArgb(150, 255, 255, 255), thinPen.Width * 0.5f);
+ g.DrawLine(edgePen, blade[1], blade[2]);
+
+ // Axe spike
+ PointF[] spike = {
+ new PointF(shaftX + half.Width * 0.014f, half.Y + half.Height * 0.08f),
+ new PointF(shaftX + half.Width * 0.014f, half.Y),
+ new PointF(shaftX + half.Width * 0.028f, half.Y + half.Height * 0.08f)
+ };
+ g.FillPolygon(bladeGradient, spike);
+ g.DrawPolygon(outlinePen, spike);
+
+ g.Restore(state);
+ }
+
+ private static void DrawQueen(Graphics g, Rectangle rect, Color color, string suit)
+ {
+ // French-style Queen (Dame) - elaborate mirrored double-headed design
+ float penW = System.Math.Max(1f, rect.Width / 120f);
+ using var outlinePen = new Pen(Color.FromArgb(50, 30, 15), penW);
+ using var thinPen = new Pen(Color.FromArgb(50, 30, 15), penW * 0.5f);
+
+ // Colors - elegant palette
+ Color cream = Color.FromArgb(255, 252, 245);
+ Color gold = Color.FromArgb(212, 175, 55);
+ Color lightGold = Color.FromArgb(255, 223, 120);
+ Color skinTone = Color.FromArgb(255, 225, 200);
+ Color skinShadow = Color.FromArgb(240, 200, 175);
+ Color hairColor = Color.FromArgb(180, 140, 60);
+ Color hairHighlight = Color.FromArgb(220, 190, 100);
+
+ // Draw decorative frame with gradient
+ RectangleF frameRect = new RectangleF(rect.X + rect.Width * 0.10f, rect.Y + rect.Height * 0.06f,
+ rect.Width * 0.80f, rect.Height * 0.88f);
+ using var frameBrush = new LinearGradientBrush(frameRect, cream, Color.FromArgb(250, 245, 235), 45f);
+ g.FillRectangle(frameBrush, frameRect);
+
+ // Elegant floral pattern in background
+ using var patternPen = new Pen(Color.FromArgb(12, color), penW * 0.4f);
+ for (float px = frameRect.X; px < frameRect.Right; px += rect.Width * 0.06f)
+ {
+ for (float py = frameRect.Y; py < frameRect.Bottom; py += rect.Height * 0.05f)
+ {
+ g.DrawEllipse(patternPen, px, py, rect.Width * 0.02f, rect.Width * 0.02f);
+ }
+ }
+
+ // Inner decorative border - double line
+ float borderInset = rect.Width * 0.02f;
+ RectangleF innerFrame = RectangleF.Inflate(frameRect, -borderInset * 2, -borderInset * 2);
+ using var borderPen = new Pen(color, penW * 1.2f);
+ using var goldBorderPen = new Pen(gold, penW * 0.6f);
+ g.DrawRectangle(borderPen, innerFrame.X, innerFrame.Y, innerFrame.Width, innerFrame.Height);
+ g.DrawRectangle(goldBorderPen, innerFrame.X - penW, innerFrame.Y - penW, innerFrame.Width + penW * 2, innerFrame.Height + penW * 2);
+
+ // Floral corner ornaments
+ DrawFloralCorner(g, innerFrame.X, innerFrame.Y, rect.Width * 0.1f, gold, color, outlinePen, false, false);
+ DrawFloralCorner(g, innerFrame.Right, innerFrame.Y, rect.Width * 0.1f, gold, color, outlinePen, true, false);
+ DrawFloralCorner(g, innerFrame.X, innerFrame.Bottom, rect.Width * 0.1f, gold, color, outlinePen, false, true);
+ DrawFloralCorner(g, innerFrame.Right, innerFrame.Bottom, rect.Width * 0.1f, gold, color, outlinePen, true, true);
+
+ // Draw upper half figure
+ DrawQueenHalf(g, innerFrame, color, suit, false, outlinePen, thinPen, skinTone, skinShadow, hairColor, hairHighlight, gold, lightGold);
+
+ // Draw lower half figure (mirrored)
+ DrawQueenHalf(g, innerFrame, color, suit, true, outlinePen, thinPen, skinTone, skinShadow, hairColor, hairHighlight, gold, lightGold);
+
+ // Center medallion with suit symbol
+ float centerY = innerFrame.Y + innerFrame.Height / 2;
+ float medalSize = innerFrame.Width * 0.24f;
+ RectangleF medalRect = new RectangleF(innerFrame.X + innerFrame.Width / 2 - medalSize / 2,
+ centerY - medalSize / 2, medalSize, medalSize);
+
+ // Ornate medallion
+ using var medalGradient = new LinearGradientBrush(medalRect, Color.White, Color.FromArgb(245, 240, 230), 135f);
+ g.FillEllipse(medalGradient, medalRect);
+
+ // Decorative ring around medallion
+ using var ringPen = new Pen(gold, penW * 2f);
+ g.DrawEllipse(ringPen, medalRect);
+ using var innerRingPen = new Pen(color, penW * 0.8f);
+ g.DrawEllipse(innerRingPen, RectangleF.Inflate(medalRect, -penW * 2, -penW * 2));
+
+ // Suit symbol
+ using var suitFont = new Font("Segoe UI Symbol", medalSize * 0.45f, FontStyle.Bold);
+ using var suitBrush = new SolidBrush(color);
+ StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ g.DrawString(suit, suitFont, suitBrush, medalRect, sf);
+ }
+
+ private static void DrawFloralCorner(Graphics g, float x, float y, float size, Color gold, Color accent, Pen outlinePen, bool flipH, bool flipV)
+ {
+ var state = g.Save();
+ g.TranslateTransform(x, y);
+ if (flipH) g.ScaleTransform(-1, 1);
+ if (flipV) g.ScaleTransform(1, -1);
+
+ using var goldBrush = new SolidBrush(gold);
+ using var accentBrush = new SolidBrush(accent);
+
+ // Main curved vine
+ using var vinePen = new Pen(gold, outlinePen.Width * 1.2f);
+ g.DrawBezier(vinePen,
+ new PointF(0, size * 0.5f),
+ new PointF(size * 0.2f, size * 0.3f),
+ new PointF(size * 0.3f, size * 0.2f),
+ new PointF(size * 0.5f, 0));
+
+ // Small leaves
+ PointF[] leaf1 = {
+ new PointF(size * 0.15f, size * 0.35f),
+ new PointF(size * 0.05f, size * 0.15f),
+ new PointF(size * 0.25f, size * 0.25f)
+ };
+ g.FillPolygon(goldBrush, leaf1);
+
+ PointF[] leaf2 = {
+ new PointF(size * 0.35f, size * 0.15f),
+ new PointF(size * 0.15f, size * 0.05f),
+ new PointF(size * 0.25f, size * 0.25f)
+ };
+ g.FillPolygon(goldBrush, leaf2);
+
+ // Small flower
+ float flowerX = size * 0.08f;
+ float flowerY = size * 0.08f;
+ float petalR = size * 0.06f;
+ g.FillEllipse(accentBrush, flowerX - petalR, flowerY - petalR * 1.5f, petalR * 2, petalR * 2);
+ g.FillEllipse(accentBrush, flowerX - petalR * 1.5f, flowerY - petalR, petalR * 2, petalR * 2);
+ g.FillEllipse(goldBrush, flowerX - petalR * 0.5f, flowerY - petalR * 0.5f, petalR, petalR);
+
+ g.Restore(state);
+ }
+
+ private static void DrawQueenHalf(Graphics g, RectangleF rect, Color color, string suit, bool mirrored,
+ Pen outlinePen, Pen thinPen, Color skinTone, Color skinShadow, Color hairColor, Color hairHighlight, Color gold, Color lightGold)
+ {
+ var state = g.Save();
+
+ if (mirrored)
+ {
+ g.TranslateTransform(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
+ g.RotateTransform(180);
+ g.TranslateTransform(-(rect.X + rect.Width / 2), -(rect.Y + rect.Height / 2));
+ }
+
+ float halfH = rect.Height * 0.47f;
+ RectangleF half = new RectangleF(rect.X, rect.Y, rect.Width, halfH);
+
+ // Elaborate gown with gradient and layers
+ RectangleF gownRect = new RectangleF(half.X + half.Width * 0.12f, half.Y + half.Height * 0.42f,
+ half.Width * 0.76f, half.Height * 0.58f);
+ using var gownGradient = new LinearGradientBrush(gownRect,
+ Color.FromArgb(255, color), Color.FromArgb(200, DarkenColor(color, 0.15f)), 90f);
+
+ // Full gown shape
+ PointF[] gown = {
+ new PointF(half.X + half.Width * 0.12f, half.Bottom),
+ new PointF(half.X + half.Width * 0.15f, half.Y + half.Height * 0.58f),
+ new PointF(half.X + half.Width * 0.22f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.32f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.42f),
+ new PointF(half.X + half.Width * 0.68f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.78f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.85f, half.Y + half.Height * 0.58f),
+ new PointF(half.X + half.Width * 0.88f, half.Bottom)
+ };
+ g.FillPolygon(gownGradient, gown);
+ g.DrawPolygon(outlinePen, gown);
+
+ // Gown folds for depth
+ using var foldPen = new Pen(Color.FromArgb(50, 0, 0, 0), thinPen.Width);
+ using var highlightPen = new Pen(Color.FromArgb(60, 255, 255, 255), thinPen.Width);
+ g.DrawBezier(foldPen,
+ new PointF(half.X + half.Width * 0.3f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.28f, half.Y + half.Height * 0.7f),
+ new PointF(half.X + half.Width * 0.25f, half.Y + half.Height * 0.85f),
+ new PointF(half.X + half.Width * 0.22f, half.Bottom));
+ g.DrawBezier(highlightPen,
+ new PointF(half.X + half.Width * 0.32f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.3f, half.Y + half.Height * 0.7f),
+ new PointF(half.X + half.Width * 0.28f, half.Y + half.Height * 0.85f),
+ new PointF(half.X + half.Width * 0.26f, half.Bottom));
+ g.DrawBezier(foldPen,
+ new PointF(half.X + half.Width * 0.7f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.72f, half.Y + half.Height * 0.7f),
+ new PointF(half.X + half.Width * 0.75f, half.Y + half.Height * 0.85f),
+ new PointF(half.X + half.Width * 0.78f, half.Bottom));
+
+ // Decorative embroidery on gown
+ using var embPen = new Pen(gold, thinPen.Width * 1.2f);
+ // Vertical ornament
+ g.DrawLine(embPen, half.X + half.Width * 0.5f, half.Y + half.Height * 0.5f,
+ half.X + half.Width * 0.5f, half.Bottom);
+ // Small diamonds along the line
+ for (float ey = half.Y + half.Height * 0.55f; ey < half.Bottom - half.Height * 0.1f; ey += half.Height * 0.1f)
+ {
+ PointF[] diamond = {
+ new PointF(half.X + half.Width * 0.5f, ey - half.Height * 0.02f),
+ new PointF(half.X + half.Width * 0.52f, ey),
+ new PointF(half.X + half.Width * 0.5f, ey + half.Height * 0.02f),
+ new PointF(half.X + half.Width * 0.48f, ey)
+ };
+ using var diamondBrush = new SolidBrush(gold);
+ g.FillPolygon(diamondBrush, diamond);
+ }
+
+ // Elaborate neckline with lace
+ using var laceBrush = new SolidBrush(Color.White);
+ PointF[] neckLace = {
+ new PointF(half.X + half.Width * 0.32f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.38f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.44f, half.Y + half.Height * 0.53f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.55f),
+ new PointF(half.X + half.Width * 0.56f, half.Y + half.Height * 0.53f),
+ new PointF(half.X + half.Width * 0.62f, half.Y + half.Height * 0.5f),
+ new PointF(half.X + half.Width * 0.68f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.42f)
+ };
+ g.FillPolygon(laceBrush, neckLace);
+ g.DrawPolygon(thinPen, neckLace);
+
+ // Lace scalloped edge
+ using var scallopPen = new Pen(Color.FromArgb(120, 120, 120), thinPen.Width * 0.6f);
+ for (float sx = half.X + half.Width * 0.36f; sx < half.X + half.Width * 0.64f; sx += half.Width * 0.04f)
+ {
+ g.DrawArc(scallopPen, sx, half.Y + half.Height * 0.51f, half.Width * 0.04f, half.Height * 0.03f, 0, 180);
+ }
+
+ // Necklace with pendant
+ using var necklacePen = new Pen(gold, thinPen.Width * 1.5f);
+ g.DrawArc(necklacePen, half.X + half.Width * 0.38f, half.Y + half.Height * 0.42f,
+ half.Width * 0.24f, half.Height * 0.12f, 0, 180);
+
+ // Pendant
+ using var pendantBrush = new SolidBrush(color);
+ using var goldBrush = new SolidBrush(gold);
+ float pendX = half.X + half.Width * 0.5f;
+ float pendY = half.Y + half.Height * 0.52f;
+ g.FillEllipse(goldBrush, pendX - half.Width * 0.025f, pendY, half.Width * 0.05f, half.Width * 0.05f);
+ g.FillEllipse(pendantBrush, pendX - half.Width * 0.018f, pendY + half.Width * 0.007f,
+ half.Width * 0.036f, half.Width * 0.036f);
+
+ // Elaborate hair with curls
+ float headW = half.Width * 0.22f;
+ float headH = half.Height * 0.3f;
+ RectangleF headRect = new RectangleF(half.X + half.Width * 0.5f - headW / 2,
+ half.Y + half.Height * 0.12f, headW, headH);
+
+ // Hair base with gradient
+ using var hairGradient = new LinearGradientBrush(
+ new RectangleF(headRect.X - headW * 0.3f, headRect.Y - headH * 0.15f, headW * 1.6f, headH * 1.2f),
+ hairHighlight, hairColor, 60f);
+
+ // Main hair volume
+ RectangleF hairOuter = new RectangleF(headRect.X - headW * 0.28f, headRect.Y - headH * 0.12f,
+ headW * 1.56f, headH * 1.15f);
+ g.FillEllipse(hairGradient, hairOuter);
+
+ // Hair curls on sides
+ using var curlPen = new Pen(Color.FromArgb(100, 60, 25), thinPen.Width * 0.8f);
+ // Left curls
+ g.DrawBezier(curlPen,
+ new PointF(headRect.X - headW * 0.15f, headRect.Y + headH * 0.3f),
+ new PointF(headRect.X - headW * 0.3f, headRect.Y + headH * 0.5f),
+ new PointF(headRect.X - headW * 0.25f, headRect.Y + headH * 0.7f),
+ new PointF(headRect.X - headW * 0.1f, headRect.Y + headH * 0.6f));
+ g.DrawBezier(curlPen,
+ new PointF(headRect.X - headW * 0.1f, headRect.Y + headH * 0.5f),
+ new PointF(headRect.X - headW * 0.22f, headRect.Y + headH * 0.65f),
+ new PointF(headRect.X - headW * 0.18f, headRect.Y + headH * 0.8f),
+ new PointF(headRect.X - headW * 0.05f, headRect.Y + headH * 0.75f));
+ // Right curls
+ g.DrawBezier(curlPen,
+ new PointF(headRect.Right + headW * 0.15f, headRect.Y + headH * 0.3f),
+ new PointF(headRect.Right + headW * 0.3f, headRect.Y + headH * 0.5f),
+ new PointF(headRect.Right + headW * 0.25f, headRect.Y + headH * 0.7f),
+ new PointF(headRect.Right + headW * 0.1f, headRect.Y + headH * 0.6f));
+
+ // Face with 3D shading
+ using var faceGradient = new LinearGradientBrush(headRect, skinTone, skinShadow, 130f);
+ g.FillEllipse(faceGradient, headRect);
+ g.DrawEllipse(outlinePen, headRect);
+
+ // Rosy cheeks
+ using var blushBrush = new SolidBrush(Color.FromArgb(35, 255, 130, 130));
+ g.FillEllipse(blushBrush, headRect.X + headW * 0.08f, headRect.Y + headH * 0.45f, headW * 0.22f, headH * 0.15f);
+ g.FillEllipse(blushBrush, headRect.X + headW * 0.7f, headRect.Y + headH * 0.45f, headW * 0.22f, headH * 0.15f);
+
+ // Elegant tiara crown
+ float crownH = headH * 0.45f;
+ float crownW = headW * 1.4f;
+ float crownX = headRect.X - headW * 0.2f;
+ float crownY = headRect.Y - crownH * 0.55f;
+
+ // Crown with gradient
+ using var crownGradient = new LinearGradientBrush(
+ new RectangleF(crownX, crownY, crownW, crownH), lightGold, gold, 90f);
+
+ PointF[] crown = {
+ new PointF(crownX, crownY + crownH),
+ new PointF(crownX + crownW * 0.08f, crownY + crownH * 0.35f),
+ new PointF(crownX + crownW * 0.18f, crownY + crownH * 0.75f),
+ new PointF(crownX + crownW * 0.28f, crownY + crownH * 0.15f),
+ new PointF(crownX + crownW * 0.38f, crownY + crownH * 0.6f),
+ new PointF(crownX + crownW * 0.5f, crownY - crownH * 0.1f),
+ new PointF(crownX + crownW * 0.62f, crownY + crownH * 0.6f),
+ new PointF(crownX + crownW * 0.72f, crownY + crownH * 0.15f),
+ new PointF(crownX + crownW * 0.82f, crownY + crownH * 0.75f),
+ new PointF(crownX + crownW * 0.92f, crownY + crownH * 0.35f),
+ new PointF(crownX + crownW, crownY + crownH)
+ };
+ g.FillPolygon(crownGradient, crown);
+ g.DrawPolygon(outlinePen, crown);
+
+ // Crown jewels
+ float jewelSize = crownW * 0.065f;
+ using var rubyBrush = new SolidBrush(Color.FromArgb(220, 20, 60));
+ using var sapphireBrush = new SolidBrush(Color.FromArgb(30, 80, 180));
+ using var pearlBrush = new SolidBrush(Color.FromArgb(255, 250, 245));
+
+ // Center jewel (ruby)
+ g.FillEllipse(rubyBrush, crownX + crownW * 0.5f - jewelSize * 0.7f, crownY - crownH * 0.1f - jewelSize * 0.3f,
+ jewelSize * 1.4f, jewelSize * 1.4f);
+ g.DrawEllipse(thinPen, crownX + crownW * 0.5f - jewelSize * 0.7f, crownY - crownH * 0.1f - jewelSize * 0.3f,
+ jewelSize * 1.4f, jewelSize * 1.4f);
+
+ // Side jewels
+ g.FillEllipse(sapphireBrush, crownX + crownW * 0.28f - jewelSize/2, crownY + crownH * 0.1f, jewelSize, jewelSize);
+ g.FillEllipse(sapphireBrush, crownX + crownW * 0.72f - jewelSize/2, crownY + crownH * 0.1f, jewelSize, jewelSize);
+
+ // Pearls
+ g.FillEllipse(pearlBrush, crownX + crownW * 0.08f - jewelSize * 0.4f, crownY + crownH * 0.3f, jewelSize * 0.8f, jewelSize * 0.8f);
+ g.FillEllipse(pearlBrush, crownX + crownW * 0.92f - jewelSize * 0.4f, crownY + crownH * 0.3f, jewelSize * 0.8f, jewelSize * 0.8f);
+
+ // Detailed eyes
+ float eyeW = headW * 0.12f;
+ float eyeH = headH * 0.07f;
+
+ // Left eye
+ DrawDetailedEye(g, headRect.X + headW * 0.22f, headRect.Y + headH * 0.38f, eyeW, eyeH,
+ Color.FromArgb(100, 80, 60), thinPen);
+ // Right eye
+ DrawDetailedEye(g, headRect.X + headW * 0.66f, headRect.Y + headH * 0.38f, eyeW, eyeH,
+ Color.FromArgb(100, 80, 60), thinPen);
+
+ // Eyebrows
+ using var browPen = new Pen(Color.FromArgb(120, 90, 50), thinPen.Width);
+ g.DrawArc(browPen, headRect.X + headW * 0.18f, headRect.Y + headH * 0.28f, eyeW * 1.3f, eyeH, 200, 70);
+ g.DrawArc(browPen, headRect.X + headW * 0.62f, headRect.Y + headH * 0.28f, eyeW * 1.3f, eyeH, 250, 70);
+
+ // Elegant nose
+ g.DrawBezier(thinPen,
+ new PointF(headRect.X + headW * 0.48f, headRect.Y + headH * 0.4f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.5f),
+ new PointF(headRect.X + headW * 0.52f, headRect.Y + headH * 0.55f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.58f));
+
+ // Lips with detail
+ using var lipBrush = new SolidBrush(Color.FromArgb(200, 90, 90));
+ using var lipHighlight = new SolidBrush(Color.FromArgb(100, 255, 200, 200));
+
+ // Upper lip
+ PointF[] upperLip = {
+ new PointF(headRect.X + headW * 0.38f, headRect.Y + headH * 0.68f),
+ new PointF(headRect.X + headW * 0.45f, headRect.Y + headH * 0.65f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.67f),
+ new PointF(headRect.X + headW * 0.55f, headRect.Y + headH * 0.65f),
+ new PointF(headRect.X + headW * 0.62f, headRect.Y + headH * 0.68f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.72f)
+ };
+ g.FillPolygon(lipBrush, upperLip);
+
+ // Lower lip
+ g.FillEllipse(lipBrush, headRect.X + headW * 0.4f, headRect.Y + headH * 0.69f, headW * 0.2f, headH * 0.08f);
+ g.FillEllipse(lipHighlight, headRect.X + headW * 0.44f, headRect.Y + headH * 0.71f, headW * 0.08f, headH * 0.03f);
+
+ // Elegant flower scepter
+ float flowerX = half.X + half.Width * 0.85f;
+ float flowerY = half.Y + half.Height * 0.25f;
+ DrawElaborateFlower(g, flowerX, flowerY, half.Width * 0.1f, color, gold, outlinePen, thinPen);
+
+ // Flower stem
+ using var stemPen = new Pen(Color.FromArgb(80, 120, 60), outlinePen.Width * 1.2f);
+ g.DrawBezier(stemPen,
+ new PointF(flowerX, flowerY + half.Width * 0.08f),
+ new PointF(flowerX - half.Width * 0.02f, flowerY + half.Height * 0.3f),
+ new PointF(flowerX + half.Width * 0.02f, flowerY + half.Height * 0.5f),
+ new PointF(flowerX - half.Width * 0.01f, half.Bottom));
+
+ // Small leaves on stem
+ using var leafBrush = new SolidBrush(Color.FromArgb(100, 140, 80));
+ PointF[] leaf1 = {
+ new PointF(flowerX, flowerY + half.Height * 0.35f),
+ new PointF(flowerX + half.Width * 0.06f, flowerY + half.Height * 0.32f),
+ new PointF(flowerX + half.Width * 0.02f, flowerY + half.Height * 0.4f)
+ };
+ g.FillPolygon(leafBrush, leaf1);
+
+ g.Restore(state);
+ }
+
+ private static void DrawDetailedEye(Graphics g, float x, float y, float w, float h, Color irisColor, Pen outlinePen)
+ {
+ // Eye white
+ using var whiteBrush = new SolidBrush(Color.FromArgb(252, 252, 252));
+ g.FillEllipse(whiteBrush, x, y, w, h);
+
+ // Iris
+ using var irisBrush = new SolidBrush(irisColor);
+ float irisW = w * 0.5f;
+ float irisH = h * 0.85f;
+ g.FillEllipse(irisBrush, x + w * 0.25f, y + h * 0.08f, irisW, irisH);
+
+ // Pupil
+ using var pupilBrush = new SolidBrush(Color.Black);
+ g.FillEllipse(pupilBrush, x + w * 0.35f, y + h * 0.2f, irisW * 0.6f, irisH * 0.6f);
+
+ // Highlight
+ using var highlightBrush = new SolidBrush(Color.White);
+ g.FillEllipse(highlightBrush, x + w * 0.32f, y + h * 0.15f, w * 0.12f, h * 0.25f);
+
+ // Eye outline
+ g.DrawEllipse(outlinePen, x, y, w, h);
+
+ // Eyelashes
+ using var lashPen = new Pen(Color.FromArgb(60, 40, 30), outlinePen.Width * 0.4f);
+ for (int i = 0; i < 4; i++)
+ {
+ float lx = x + w * (0.15f + i * 0.22f);
+ g.DrawLine(lashPen, lx, y, lx - w * 0.02f, y - h * 0.15f);
+ }
+ }
+
+ private static void DrawElaborateFlower(Graphics g, float x, float y, float size, Color petalColor, Color centerColor, Pen outlinePen, Pen thinPen)
+ {
+ // Outer petals
+ RectangleF petalRect = new RectangleF(x - size, y - size, size * 2, size * 2);
+ using var petalGradient = new LinearGradientBrush(petalRect,
+ LightenColor(petalColor, 0.3f), petalColor, 45f);
+
+ for (int i = 0; i < 6; i++)
+ {
+ float angle = i * 60 * (float)System.Math.PI / 180;
+ float px = x + (float)System.Math.Cos(angle) * size * 0.7f;
+ float py = y + (float)System.Math.Sin(angle) * size * 0.7f;
+
+ g.FillEllipse(petalGradient, px - size * 0.45f, py - size * 0.45f, size * 0.9f, size * 0.9f);
+ g.DrawEllipse(thinPen, px - size * 0.45f, py - size * 0.45f, size * 0.9f, size * 0.9f);
+ }
+
+ // Inner petals
+ for (int i = 0; i < 6; i++)
+ {
+ float angle = (i * 60 + 30) * (float)System.Math.PI / 180;
+ float px = x + (float)System.Math.Cos(angle) * size * 0.4f;
+ float py = y + (float)System.Math.Sin(angle) * size * 0.4f;
+
+ using var innerBrush = new SolidBrush(LightenColor(petalColor, 0.2f));
+ g.FillEllipse(innerBrush, px - size * 0.3f, py - size * 0.3f, size * 0.6f, size * 0.6f);
+ }
+
+ // Center
+ using var centerBrush = new SolidBrush(centerColor);
+ g.FillEllipse(centerBrush, x - size * 0.25f, y - size * 0.25f, size * 0.5f, size * 0.5f);
+ g.DrawEllipse(thinPen, x - size * 0.25f, y - size * 0.25f, size * 0.5f, size * 0.5f);
+
+ // Center dots
+ using var dotBrush = new SolidBrush(Color.FromArgb(180, 140, 40));
+ for (int i = 0; i < 5; i++)
+ {
+ float angle = i * 72 * (float)System.Math.PI / 180;
+ float dx = x + (float)System.Math.Cos(angle) * size * 0.12f;
+ float dy = y + (float)System.Math.Sin(angle) * size * 0.12f;
+ g.FillEllipse(dotBrush, dx - size * 0.04f, dy - size * 0.04f, size * 0.08f, size * 0.08f);
+ }
+ }
+
+ private static void DrawJackEye(Graphics g, float x, float y, float w, float h, Pen outlinePen)
+ {
+ // Eye white
+ using var whiteBrush = new SolidBrush(Color.FromArgb(252, 252, 252));
+ g.FillEllipse(whiteBrush, x, y, w, h);
+
+ // Iris
+ using var irisBrush = new SolidBrush(Color.FromArgb(80, 60, 40));
+ float irisW = w * 0.5f;
+ float irisH = h * 0.85f;
+ g.FillEllipse(irisBrush, x + w * 0.25f, y + h * 0.08f, irisW, irisH);
+
+ // Pupil
+ using var pupilBrush = new SolidBrush(Color.Black);
+ g.FillEllipse(pupilBrush, x + w * 0.35f, y + h * 0.2f, irisW * 0.6f, irisH * 0.6f);
+
+ // Highlight
+ using var highlightBrush = new SolidBrush(Color.White);
+ g.FillEllipse(highlightBrush, x + w * 0.32f, y + h * 0.15f, w * 0.12f, h * 0.25f);
+
+ // Eye outline
+ g.DrawEllipse(outlinePen, x, y, w, h);
+ }
+
+ private static void DrawKing(Graphics g, Rectangle rect, Color color, string suit)
+ {
+ // French-style King (Roi) - elaborate mirrored double-headed design
+ float penW = System.Math.Max(1f, rect.Width / 120f);
+ using var outlinePen = new Pen(Color.FromArgb(50, 30, 15), penW);
+ using var thinPen = new Pen(Color.FromArgb(50, 30, 15), penW * 0.5f);
+
+ // Colors - royal palette
+ Color cream = Color.FromArgb(255, 252, 245);
+ Color gold = Color.FromArgb(212, 175, 55);
+ Color darkGold = Color.FromArgb(170, 135, 30);
+ Color lightGold = Color.FromArgb(255, 223, 120);
+ Color skinTone = Color.FromArgb(255, 218, 185);
+ Color skinShadow = Color.FromArgb(235, 190, 160);
+ Color beardColor = Color.FromArgb(110, 75, 40);
+ Color beardHighlight = Color.FromArgb(150, 110, 65);
+
+ // Draw decorative frame with gradient
+ RectangleF frameRect = new RectangleF(rect.X + rect.Width * 0.10f, rect.Y + rect.Height * 0.06f,
+ rect.Width * 0.80f, rect.Height * 0.88f);
+ using var frameBrush = new LinearGradientBrush(frameRect, cream, Color.FromArgb(250, 245, 235), 45f);
+ g.FillRectangle(frameBrush, frameRect);
+
+ // Royal cross-hatch pattern in background
+ using var patternPen = new Pen(Color.FromArgb(10, color), penW * 0.3f);
+ for (float i = frameRect.X; i < frameRect.Right + frameRect.Height; i += rect.Width * 0.035f)
+ {
+ g.DrawLine(patternPen, i, frameRect.Y, i - frameRect.Height * 0.4f, frameRect.Bottom);
+ g.DrawLine(patternPen, i - frameRect.Height * 0.4f, frameRect.Y, i, frameRect.Bottom);
+ }
+
+ // Inner decorative border - triple line royal style
+ float borderInset = rect.Width * 0.02f;
+ RectangleF innerFrame = RectangleF.Inflate(frameRect, -borderInset * 2, -borderInset * 2);
+ using var borderPen = new Pen(color, penW * 1.2f);
+ using var goldBorderPen = new Pen(gold, penW * 0.6f);
+ g.DrawRectangle(borderPen, innerFrame.X, innerFrame.Y, innerFrame.Width, innerFrame.Height);
+ g.DrawRectangle(goldBorderPen, innerFrame.X - penW, innerFrame.Y - penW, innerFrame.Width + penW * 2, innerFrame.Height + penW * 2);
+ g.DrawRectangle(goldBorderPen, innerFrame.X + penW * 2, innerFrame.Y + penW * 2, innerFrame.Width - penW * 4, innerFrame.Height - penW * 4);
+
+ // Royal crown corner ornaments
+ DrawRoyalCorner(g, innerFrame.X, innerFrame.Y, rect.Width * 0.1f, gold, color, outlinePen, false, false);
+ DrawRoyalCorner(g, innerFrame.Right, innerFrame.Y, rect.Width * 0.1f, gold, color, outlinePen, true, false);
+ DrawRoyalCorner(g, innerFrame.X, innerFrame.Bottom, rect.Width * 0.1f, gold, color, outlinePen, false, true);
+ DrawRoyalCorner(g, innerFrame.Right, innerFrame.Bottom, rect.Width * 0.1f, gold, color, outlinePen, true, true);
+
+ // Draw upper half figure
+ DrawKingHalf(g, innerFrame, color, suit, false, outlinePen, thinPen, skinTone, skinShadow, beardColor, beardHighlight, gold, darkGold, lightGold);
+
+ // Draw lower half figure (mirrored)
+ DrawKingHalf(g, innerFrame, color, suit, true, outlinePen, thinPen, skinTone, skinShadow, beardColor, beardHighlight, gold, darkGold, lightGold);
+
+ // Center medallion with suit symbol and royal seal
+ float centerY = innerFrame.Y + innerFrame.Height / 2;
+ float medalSize = innerFrame.Width * 0.26f;
+ RectangleF medalRect = new RectangleF(innerFrame.X + innerFrame.Width / 2 - medalSize / 2,
+ centerY - medalSize / 2, medalSize, medalSize);
+
+ // Royal seal medallion with multiple rings
+ using var sealGradient = new LinearGradientBrush(medalRect, Color.White, Color.FromArgb(245, 240, 225), 135f);
+ g.FillEllipse(sealGradient, medalRect);
+
+ // Decorative rings
+ using var outerRingPen = new Pen(gold, penW * 2.5f);
+ g.DrawEllipse(outerRingPen, medalRect);
+ using var innerRingPen = new Pen(color, penW * 1.2f);
+ g.DrawEllipse(innerRingPen, RectangleF.Inflate(medalRect, -penW * 3, -penW * 3));
+ using var centerRingPen = new Pen(gold, penW * 0.8f);
+ g.DrawEllipse(centerRingPen, RectangleF.Inflate(medalRect, -penW * 5, -penW * 5));
+
+ // Suit symbol
+ using var suitFont = new Font("Segoe UI Symbol", medalSize * 0.4f, FontStyle.Bold);
+ using var suitBrush = new SolidBrush(color);
+ StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ g.DrawString(suit, suitFont, suitBrush, medalRect, sf);
+ }
+
+ private static void DrawRoyalCorner(Graphics g, float x, float y, float size, Color gold, Color accent, Pen outlinePen, bool flipH, bool flipV)
+ {
+ var state = g.Save();
+ g.TranslateTransform(x, y);
+ if (flipH) g.ScaleTransform(-1, 1);
+ if (flipV) g.ScaleTransform(1, -1);
+
+ using var goldBrush = new SolidBrush(gold);
+ using var accentBrush = new SolidBrush(accent);
+
+ // Crown-like corner ornament
+ PointF[] crownOrn = {
+ new PointF(0, 0),
+ new PointF(size * 0.4f, 0),
+ new PointF(size * 0.3f, size * 0.15f),
+ new PointF(size * 0.5f, size * 0.08f),
+ new PointF(size * 0.35f, size * 0.25f),
+ new PointF(size * 0.25f, size * 0.35f),
+ new PointF(size * 0.08f, size * 0.5f),
+ new PointF(size * 0.15f, size * 0.3f),
+ new PointF(0, size * 0.4f)
+ };
+ g.FillPolygon(goldBrush, crownOrn);
+ g.DrawPolygon(outlinePen, crownOrn);
+
+ // Small jewel
+ float jewelX = size * 0.12f;
+ float jewelY = size * 0.12f;
+ float jewelSize = size * 0.1f;
+ g.FillEllipse(accentBrush, jewelX, jewelY, jewelSize, jewelSize);
+
+ g.Restore(state);
+ }
+
+ private static void DrawKingHalf(Graphics g, RectangleF rect, Color color, string suit, bool mirrored,
+ Pen outlinePen, Pen thinPen, Color skinTone, Color skinShadow, Color beardColor, Color beardHighlight,
+ Color gold, Color darkGold, Color lightGold)
+ {
+ var state = g.Save();
+
+ if (mirrored)
+ {
+ g.TranslateTransform(rect.X + rect.Width / 2, rect.Y + rect.Height / 2);
+ g.RotateTransform(180);
+ g.TranslateTransform(-(rect.X + rect.Width / 2), -(rect.Y + rect.Height / 2));
+ }
+
+ float halfH = rect.Height * 0.47f;
+ RectangleF half = new RectangleF(rect.X, rect.Y, rect.Width, halfH);
+
+ // Royal robe with rich gradient and ermine trim
+ RectangleF robeRect = new RectangleF(half.X + half.Width * 0.1f, half.Y + half.Height * 0.38f,
+ half.Width * 0.8f, half.Height * 0.62f);
+ using var robeGradient = new LinearGradientBrush(robeRect,
+ Color.FromArgb(255, color), Color.FromArgb(200, DarkenColor(color, 0.2f)), 90f);
+
+ PointF[] robe = {
+ new PointF(half.X + half.Width * 0.1f, half.Bottom),
+ new PointF(half.X + half.Width * 0.15f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.22f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.38f),
+ new PointF(half.X + half.Width * 0.78f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.85f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.9f, half.Bottom)
+ };
+ g.FillPolygon(robeGradient, robe);
+ g.DrawPolygon(outlinePen, robe);
+
+ // Robe folds
+ using var foldPen = new Pen(Color.FromArgb(50, 0, 0, 0), thinPen.Width);
+ using var highlightPen = new Pen(Color.FromArgb(60, 255, 255, 255), thinPen.Width);
+ g.DrawBezier(foldPen,
+ new PointF(half.X + half.Width * 0.35f, half.Y + half.Height * 0.48f),
+ new PointF(half.X + half.Width * 0.32f, half.Y + half.Height * 0.65f),
+ new PointF(half.X + half.Width * 0.28f, half.Y + half.Height * 0.8f),
+ new PointF(half.X + half.Width * 0.22f, half.Bottom));
+ g.DrawBezier(highlightPen,
+ new PointF(half.X + half.Width * 0.38f, half.Y + half.Height * 0.48f),
+ new PointF(half.X + half.Width * 0.36f, half.Y + half.Height * 0.65f),
+ new PointF(half.X + half.Width * 0.33f, half.Y + half.Height * 0.8f),
+ new PointF(half.X + half.Width * 0.28f, half.Bottom));
+ g.DrawBezier(foldPen,
+ new PointF(half.X + half.Width * 0.65f, half.Y + half.Height * 0.48f),
+ new PointF(half.X + half.Width * 0.68f, half.Y + half.Height * 0.65f),
+ new PointF(half.X + half.Width * 0.72f, half.Y + half.Height * 0.8f),
+ new PointF(half.X + half.Width * 0.78f, half.Bottom));
+
+ // Elaborate ermine collar with spots
+ using var ermineBrush = new SolidBrush(Color.FromArgb(255, 252, 248));
+ PointF[] collar = {
+ new PointF(half.X + half.Width * 0.25f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.32f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.42f, half.Y + half.Height * 0.56f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.58f),
+ new PointF(half.X + half.Width * 0.58f, half.Y + half.Height * 0.56f),
+ new PointF(half.X + half.Width * 0.68f, half.Y + half.Height * 0.52f),
+ new PointF(half.X + half.Width * 0.75f, half.Y + half.Height * 0.45f),
+ new PointF(half.X + half.Width * 0.5f, half.Y + half.Height * 0.38f)
+ };
+ g.FillPolygon(ermineBrush, collar);
+ g.DrawPolygon(thinPen, collar);
+
+ // Ermine spots (black tail tips)
+ using var spotBrush = new SolidBrush(Color.FromArgb(35, 25, 15));
+ float spotH = half.Width * 0.012f;
+ float spotW = spotH * 0.6f;
+ float[] spotXs = { 0.35f, 0.42f, 0.5f, 0.58f, 0.65f };
+ float[] spotYs = { 0.48f, 0.52f, 0.54f, 0.52f, 0.48f };
+ for (int i = 0; i < spotXs.Length; i++)
+ {
+ g.FillEllipse(spotBrush, half.X + half.Width * spotXs[i], half.Y + half.Height * spotYs[i], spotW, spotH * 2.5f);
+ }
+ // Second row
+ float[] spotXs2 = { 0.38f, 0.46f, 0.54f, 0.62f };
+ for (int i = 0; i < spotXs2.Length; i++)
+ {
+ g.FillEllipse(spotBrush, half.X + half.Width * spotXs2[i], half.Y + half.Height * 0.44f, spotW, spotH * 2.5f);
+ }
+
+ // Royal chain with medallion
+ using var chainPen = new Pen(gold, outlinePen.Width * 2f);
+ g.DrawArc(chainPen, half.X + half.Width * 0.35f, half.Y + half.Height * 0.48f,
+ half.Width * 0.3f, half.Height * 0.16f, 0, 180);
+
+ // Chain links detail
+ using var linkPen = new Pen(darkGold, thinPen.Width);
+ for (float lx = half.X + half.Width * 0.38f; lx < half.X + half.Width * 0.62f; lx += half.Width * 0.04f)
+ {
+ float ly = half.Y + half.Height * 0.52f + (float)System.Math.Sin((lx - half.X) * 0.1f) * half.Height * 0.04f;
+ g.DrawEllipse(linkPen, lx, ly, half.Width * 0.025f, half.Height * 0.02f);
+ }
+
+ // Royal medallion on chain
+ using var medalGradient = new LinearGradientBrush(
+ new RectangleF(half.X + half.Width * 0.44f, half.Y + half.Height * 0.58f, half.Width * 0.12f, half.Width * 0.12f),
+ lightGold, gold, 135f);
+ float medalX = half.X + half.Width * 0.5f;
+ float medalY = half.Y + half.Height * 0.62f;
+ float medalSize = half.Width * 0.1f;
+ g.FillEllipse(medalGradient, medalX - medalSize / 2, medalY, medalSize, medalSize);
+ g.DrawEllipse(outlinePen, medalX - medalSize / 2, medalY, medalSize, medalSize);
+
+ // Cross on medallion
+ using var crossPen = new Pen(color, thinPen.Width * 1.5f);
+ g.DrawLine(crossPen, medalX, medalY + medalSize * 0.2f, medalX, medalY + medalSize * 0.8f);
+ g.DrawLine(crossPen, medalX - medalSize * 0.3f, medalY + medalSize * 0.5f, medalX + medalSize * 0.3f, medalY + medalSize * 0.5f);
+
+ // Head with detailed shading
+ float headW = half.Width * 0.24f;
+ float headH = half.Height * 0.32f;
+ RectangleF headRect = new RectangleF(half.X + half.Width * 0.5f - headW / 2,
+ half.Y + half.Height * 0.08f, headW, headH);
+
+ // Face with gradient
+ using var faceGradient = new LinearGradientBrush(headRect, skinTone, skinShadow, 130f);
+ g.FillEllipse(faceGradient, headRect);
+ g.DrawEllipse(outlinePen, headRect);
+
+ // Elaborate beard with gradient and texture
+ using var beardGradient = new LinearGradientBrush(
+ new RectangleF(headRect.X + headW * 0.1f, headRect.Y + headH * 0.45f, headW * 0.8f, headH * 0.7f),
+ beardHighlight, beardColor, 90f);
+
+ // Main beard shape
+ PointF[] beard = {
+ new PointF(headRect.X + headW * 0.15f, headRect.Y + headH * 0.5f),
+ new PointF(headRect.X + headW * 0.1f, headRect.Y + headH * 0.7f),
+ new PointF(headRect.X + headW * 0.2f, headRect.Y + headH * 0.9f),
+ new PointF(headRect.X + headW * 0.35f, headRect.Y + headH * 1.05f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 1.1f),
+ new PointF(headRect.X + headW * 0.65f, headRect.Y + headH * 1.05f),
+ new PointF(headRect.X + headW * 0.8f, headRect.Y + headH * 0.9f),
+ new PointF(headRect.X + headW * 0.9f, headRect.Y + headH * 0.7f),
+ new PointF(headRect.X + headW * 0.85f, headRect.Y + headH * 0.5f)
+ };
+ g.FillPolygon(beardGradient, beard);
+ g.DrawPolygon(thinPen, beard);
+
+ // Beard texture (wavy lines)
+ using var beardTexturePen = new Pen(Color.FromArgb(60, 80, 55, 30), thinPen.Width * 0.5f);
+ for (int i = 0; i < 5; i++)
+ {
+ float bx = headRect.X + headW * (0.25f + i * 0.12f);
+ g.DrawBezier(beardTexturePen,
+ new PointF(bx, headRect.Y + headH * 0.55f),
+ new PointF(bx - headW * 0.03f, headRect.Y + headH * 0.7f),
+ new PointF(bx + headW * 0.03f, headRect.Y + headH * 0.85f),
+ new PointF(bx, headRect.Y + headH * 1.0f));
+ }
+
+ // Mustache with detail
+ using var mustacheBrush = new SolidBrush(beardColor);
+ PointF[] leftMustache = {
+ new PointF(headRect.X + headW * 0.48f, headRect.Y + headH * 0.52f),
+ new PointF(headRect.X + headW * 0.3f, headRect.Y + headH * 0.48f),
+ new PointF(headRect.X + headW * 0.15f, headRect.Y + headH * 0.55f),
+ new PointF(headRect.X + headW * 0.2f, headRect.Y + headH * 0.6f),
+ new PointF(headRect.X + headW * 0.35f, headRect.Y + headH * 0.58f),
+ new PointF(headRect.X + headW * 0.48f, headRect.Y + headH * 0.6f)
+ };
+ g.FillPolygon(mustacheBrush, leftMustache);
+
+ PointF[] rightMustache = {
+ new PointF(headRect.X + headW * 0.52f, headRect.Y + headH * 0.52f),
+ new PointF(headRect.X + headW * 0.7f, headRect.Y + headH * 0.48f),
+ new PointF(headRect.X + headW * 0.85f, headRect.Y + headH * 0.55f),
+ new PointF(headRect.X + headW * 0.8f, headRect.Y + headH * 0.6f),
+ new PointF(headRect.X + headW * 0.65f, headRect.Y + headH * 0.58f),
+ new PointF(headRect.X + headW * 0.52f, headRect.Y + headH * 0.6f)
+ };
+ g.FillPolygon(mustacheBrush, rightMustache);
+
+ // Detailed eyes
+ float eyeW = headW * 0.13f;
+ float eyeH = headH * 0.07f;
+ DrawKingEye(g, headRect.X + headW * 0.22f, headRect.Y + headH * 0.32f, eyeW, eyeH, thinPen);
+ DrawKingEye(g, headRect.X + headW * 0.65f, headRect.Y + headH * 0.32f, eyeW, eyeH, thinPen);
+
+ // Stern eyebrows
+ using var browPen = new Pen(beardColor, thinPen.Width * 1.5f);
+ g.DrawLine(browPen, headRect.X + headW * 0.18f, headRect.Y + headH * 0.28f,
+ headRect.X + headW * 0.38f, headRect.Y + headH * 0.25f);
+ g.DrawLine(browPen, headRect.X + headW * 0.62f, headRect.Y + headH * 0.25f,
+ headRect.X + headW * 0.82f, headRect.Y + headH * 0.28f);
+
+ // Nose
+ g.DrawBezier(thinPen,
+ new PointF(headRect.X + headW * 0.48f, headRect.Y + headH * 0.35f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.42f),
+ new PointF(headRect.X + headW * 0.52f, headRect.Y + headH * 0.48f),
+ new PointF(headRect.X + headW * 0.5f, headRect.Y + headH * 0.5f));
+
+ // Elaborate royal crown with cross
+ float crownH = headH * 0.6f;
+ float crownW = headW * 1.5f;
+ float crownX = headRect.X - headW * 0.25f;
+ float crownY = headRect.Y - crownH * 0.45f;
+
+ // Crown base band
+ using var crownBaseGradient = new LinearGradientBrush(
+ new RectangleF(crownX, crownY + crownH * 0.55f, crownW, crownH * 0.45f),
+ lightGold, gold, 90f);
+ RectangleF crownBase = new RectangleF(crownX, crownY + crownH * 0.55f, crownW, crownH * 0.45f);
+ g.FillRectangle(crownBaseGradient, crownBase);
+ g.DrawRectangle(outlinePen, crownBase.X, crownBase.Y, crownBase.Width, crownBase.Height);
+
+ // Velvet interior (visible above base)
+ using var velvetBrush = new SolidBrush(color);
+ RectangleF velvetRect = new RectangleF(crownX + crownW * 0.08f, crownY + crownH * 0.4f,
+ crownW * 0.84f, crownH * 0.2f);
+ g.FillRectangle(velvetBrush, velvetRect);
+
+ // Crown arches with gradient
+ using var archGradient = new LinearGradientBrush(
+ new RectangleF(crownX, crownY, crownW, crownH * 0.6f), lightGold, gold, 90f);
+
+ PointF[] crownArch = {
+ new PointF(crownX + crownW * 0.08f, crownY + crownH * 0.55f),
+ new PointF(crownX + crownW * 0.15f, crownY + crownH * 0.2f),
+ new PointF(crownX + crownW * 0.3f, crownY + crownH * 0.35f),
+ new PointF(crownX + crownW * 0.5f, crownY - crownH * 0.05f),
+ new PointF(crownX + crownW * 0.7f, crownY + crownH * 0.35f),
+ new PointF(crownX + crownW * 0.85f, crownY + crownH * 0.2f),
+ new PointF(crownX + crownW * 0.92f, crownY + crownH * 0.55f)
+ };
+ g.FillPolygon(archGradient, crownArch);
+ g.DrawPolygon(outlinePen, crownArch);
+
+ // Fleur-de-lis points on crown
+ DrawFleurDeLis(g, crownX + crownW * 0.15f, crownY + crownH * 0.15f, crownW * 0.08f, gold, outlinePen);
+ DrawFleurDeLis(g, crownX + crownW * 0.5f, crownY - crownH * 0.1f, crownW * 0.1f, gold, outlinePen);
+ DrawFleurDeLis(g, crownX + crownW * 0.85f, crownY + crownH * 0.15f, crownW * 0.08f, gold, outlinePen);
+
+ // Cross on top (monde)
+ float crossX = crownX + crownW * 0.5f;
+ float crossY = crownY - crownH * 0.15f;
+ float crossSize = crownW * 0.08f;
+
+ // Orb
+ using var orbGradient = new LinearGradientBrush(
+ new RectangleF(crossX - crossSize * 0.7f, crossY - crossSize * 0.3f, crossSize * 1.4f, crossSize * 1.4f),
+ lightGold, gold, 135f);
+ g.FillEllipse(orbGradient, crossX - crossSize * 0.5f, crossY, crossSize, crossSize);
+ g.DrawEllipse(outlinePen, crossX - crossSize * 0.5f, crossY, crossSize, crossSize);
+
+ // Cross on orb
+ using var crossBrush = new SolidBrush(gold);
+ g.FillRectangle(crossBrush, crossX - crossSize * 0.08f, crossY - crossSize * 0.5f, crossSize * 0.16f, crossSize * 0.6f);
+ g.FillRectangle(crossBrush, crossX - crossSize * 0.25f, crossY - crossSize * 0.3f, crossSize * 0.5f, crossSize * 0.12f);
+
+ // Crown jewels
+ float jewelSize = crownW * 0.055f;
+ using var rubyBrush = new SolidBrush(Color.FromArgb(180, 20, 40));
+ using var sapphireBrush = new SolidBrush(Color.FromArgb(30, 60, 150));
+ using var emeraldBrush = new SolidBrush(Color.FromArgb(30, 120, 60));
+
+ // Band jewels
+ g.FillEllipse(rubyBrush, crownX + crownW * 0.2f, crownY + crownH * 0.65f, jewelSize, jewelSize);
+ g.FillEllipse(sapphireBrush, crownX + crownW * 0.4f, crownY + crownH * 0.65f, jewelSize, jewelSize);
+ g.FillEllipse(emeraldBrush, crownX + crownW * 0.5f - jewelSize / 2, crownY + crownH * 0.62f, jewelSize * 1.2f, jewelSize * 1.2f);
+ g.FillEllipse(sapphireBrush, crownX + crownW * 0.6f, crownY + crownH * 0.65f, jewelSize, jewelSize);
+ g.FillEllipse(rubyBrush, crownX + crownW * 0.8f - jewelSize, crownY + crownH * 0.65f, jewelSize, jewelSize);
+
+ // Jewel highlights
+ using var jewelHighlight = new SolidBrush(Color.FromArgb(100, 255, 255, 255));
+ g.FillEllipse(jewelHighlight, crownX + crownW * 0.5f - jewelSize * 0.3f, crownY + crownH * 0.64f, jewelSize * 0.3f, jewelSize * 0.3f);
+
+ // Royal sword (behind shoulder)
+ DrawRoyalSword(g, half.X + half.Width * 0.06f, half.Y + half.Height * 0.02f, half.Width * 0.12f, half.Height * 0.95f,
+ gold, darkGold, outlinePen, thinPen);
+
+ g.Restore(state);
+ }
+
+ private static void DrawKingEye(Graphics g, float x, float y, float w, float h, Pen thinPen)
+ {
+ using var whiteBrush = new SolidBrush(Color.FromArgb(250, 250, 250));
+ g.FillEllipse(whiteBrush, x, y, w, h);
+
+ using var irisBrush = new SolidBrush(Color.FromArgb(70, 55, 45));
+ g.FillEllipse(irisBrush, x + w * 0.28f, y + h * 0.1f, w * 0.45f, h * 0.8f);
+
+ using var pupilBrush = new SolidBrush(Color.Black);
+ g.FillEllipse(pupilBrush, x + w * 0.38f, y + h * 0.22f, w * 0.25f, h * 0.56f);
+
+ using var highlightBrush = new SolidBrush(Color.White);
+ g.FillEllipse(highlightBrush, x + w * 0.35f, y + h * 0.18f, w * 0.12f, h * 0.25f);
+
+ g.DrawEllipse(thinPen, x, y, w, h);
+ }
+
+ private static void DrawFleurDeLis(Graphics g, float x, float y, float size, Color gold, Pen outlinePen)
+ {
+ using var goldBrush = new SolidBrush(gold);
+
+ // Center petal
+ PointF[] center = {
+ new PointF(x, y + size),
+ new PointF(x - size * 0.15f, y + size * 0.3f),
+ new PointF(x, y - size * 0.2f),
+ new PointF(x + size * 0.15f, y + size * 0.3f)
+ };
+ g.FillPolygon(goldBrush, center);
+
+ // Left petal
+ PointF[] left = {
+ new PointF(x - size * 0.1f, y + size * 0.5f),
+ new PointF(x - size * 0.4f, y),
+ new PointF(x - size * 0.35f, y + size * 0.6f)
+ };
+ g.FillPolygon(goldBrush, left);
+
+ // Right petal
+ PointF[] right = {
+ new PointF(x + size * 0.1f, y + size * 0.5f),
+ new PointF(x + size * 0.4f, y),
+ new PointF(x + size * 0.35f, y + size * 0.6f)
+ };
+ g.FillPolygon(goldBrush, right);
+ }
+
+ private static void DrawRoyalSword(Graphics g, float x, float y, float w, float h, Color gold, Color darkGold, Pen outlinePen, Pen thinPen)
+ {
+ // Blade with gradient
+ using var bladeGradient = new LinearGradientBrush(
+ new RectangleF(x, y, w, h * 0.7f),
+ Color.FromArgb(230, 235, 240), Color.FromArgb(180, 185, 195), 0f);
+
+ PointF[] blade = {
+ new PointF(x + w * 0.35f, y),
+ new PointF(x + w * 0.65f, y),
+ new PointF(x + w * 0.6f, y + h * 0.65f),
+ new PointF(x + w * 0.4f, y + h * 0.65f)
+ };
+ g.FillPolygon(bladeGradient, blade);
+ g.DrawPolygon(outlinePen, blade);
+
+ // Blade center line (fuller)
+ using var fullerPen = new Pen(Color.FromArgb(60, 100, 100, 100), thinPen.Width);
+ g.DrawLine(fullerPen, x + w * 0.5f, y + h * 0.05f, x + w * 0.5f, y + h * 0.55f);
+
+ // Elaborate crossguard
+ using var guardGradient = new LinearGradientBrush(
+ new RectangleF(x - w * 0.3f, y + h * 0.63f, w * 1.6f, h * 0.06f),
+ Color.FromArgb(255, 223, 120), gold, 90f);
+ RectangleF guard = new RectangleF(x - w * 0.2f, y + h * 0.64f, w * 1.4f, h * 0.04f);
+ g.FillRectangle(guardGradient, guard);
+ g.DrawRectangle(outlinePen, guard.X, guard.Y, guard.Width, guard.Height);
+
+ // Guard ends (quillons)
+ using var goldBrush = new SolidBrush(gold);
+ g.FillEllipse(goldBrush, x - w * 0.3f, y + h * 0.635f, w * 0.15f, h * 0.05f);
+ g.FillEllipse(goldBrush, x + w * 1.15f, y + h * 0.635f, w * 0.15f, h * 0.05f);
+
+ // Handle (grip) with wire wrapping
+ using var gripGradient = new LinearGradientBrush(
+ new RectangleF(x + w * 0.35f, y + h * 0.68f, w * 0.3f, h * 0.2f),
+ darkGold, Color.FromArgb(120, 85, 30), 0f);
+ RectangleF grip = new RectangleF(x + w * 0.38f, y + h * 0.68f, w * 0.24f, h * 0.18f);
+ g.FillRectangle(gripGradient, grip);
+ g.DrawRectangle(outlinePen, grip.X, grip.Y, grip.Width, grip.Height);
+
+ // Wire wrapping
+ using var wirePen = new Pen(gold, thinPen.Width * 0.7f);
+ for (float wy = grip.Y + h * 0.02f; wy < grip.Bottom - h * 0.01f; wy += h * 0.025f)
+ {
+ g.DrawLine(wirePen, grip.X, wy, grip.Right, wy + h * 0.01f);
+ }
+
+ // Pommel
+ using var pommelGradient = new LinearGradientBrush(
+ new RectangleF(x + w * 0.3f, y + h * 0.85f, w * 0.4f, w * 0.4f),
+ Color.FromArgb(255, 223, 120), gold, 135f);
+ g.FillEllipse(pommelGradient, x + w * 0.32f, y + h * 0.86f, w * 0.36f, w * 0.36f);
+ g.DrawEllipse(outlinePen, x + w * 0.32f, y + h * 0.86f, w * 0.36f, w * 0.36f);
+
+ // Jewel in pommel
+ using var jewelBrush = new SolidBrush(Color.FromArgb(150, 20, 40));
+ float jewelSize = w * 0.12f;
+ g.FillEllipse(jewelBrush, x + w * 0.5f - jewelSize / 2, y + h * 0.88f, jewelSize, jewelSize);
+ }
+
private GraphicsPath RoundedRectangle(Rectangle r, int d)
{
GraphicsPath path = new GraphicsPath();
diff --git a/CSharpBible/Games/CreateCards/Model/CardDrawDef.cs b/CSharpBible/Games/CreateCards/Model/CardDrawDef.cs
index d1ed02f54..e40f45257 100644
--- a/CSharpBible/Games/CreateCards/Model/CardDrawDef.cs
+++ b/CSharpBible/Games/CreateCards/Model/CardDrawDef.cs
@@ -1,4 +1,5 @@
-using System.Drawing;
+using System;
+using System.Drawing;
namespace CreateCards.Model
{
@@ -12,6 +13,10 @@ public class CardDrawDef
public PointF[] PntVals = new[] { new PointF(hOffs, vOffs), new PointF(hOffs, 1f-vOffs), new PointF(1f-hOffs, vOffs), new PointF(1f-hOffs, 1f- vOffs) };
public PointF[] PntSVals = new[] { new PointF(hOffs, vOffs*2), new PointF(hOffs, 1f - vOffs*2), new PointF(1f-hOffs, vOffs*2), new PointF(1f-hOffs, 1f - vOffs*2) };
public PointF[] PntSuits = { };
+
+ // Optional custom drawing callback for face cards or other special layouts
+ public Action? CustomDraw;
+
public CardDrawDef(string _pv,double _ss=0.2d) {
PrintValue = _pv; SuitSize = _ss;
}
diff --git a/CSharpBible/Games/DetectiveGame.Console/DetectiveGame.Console.csproj b/CSharpBible/Games/DetectiveGame.Console/DetectiveGame.Console.csproj
index 874cdeb66..645c746ed 100644
--- a/CSharpBible/Games/DetectiveGame.Console/DetectiveGame.Console.csproj
+++ b/CSharpBible/Games/DetectiveGame.Console/DetectiveGame.Console.csproj
@@ -7,6 +7,11 @@
enable
latest
+
+
+
+
+
@@ -15,7 +20,11 @@
-
-
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/DetectiveGame.Console/ViewModels/GameViewModel.cs b/CSharpBible/Games/DetectiveGame.Console/ViewModels/GameViewModel.cs
index 68246ebdb..0d211d610 100644
--- a/CSharpBible/Games/DetectiveGame.Console/ViewModels/GameViewModel.cs
+++ b/CSharpBible/Games/DetectiveGame.Console/ViewModels/GameViewModel.cs
@@ -26,7 +26,7 @@ public partial class GameViewModel : ObservableObject, IGameViewModel
[ObservableProperty]
private string _currentTitle = "Detektivspiel";
- public bool CanInteract => _state != null && !_state.Finished;
+ public bool CanInteract => State != null && !State.Finished;
public Action DisplayHelp { get ; set; }
@@ -42,7 +42,8 @@ private void Start()
Players.Clear();
History.Clear();
State = ((IGameSetup)_service).CreateNew(new[] { "Alice", "Bob", "Carol" });
- foreach (var p in _state.Players)
+ if (State != null)
+ foreach (var p in State.Players)
Players.Add(p.Name);
History.Add("Spiel gestartet");
UpdateTitle();
@@ -51,22 +52,22 @@ private void Start()
[RelayCommand(CanExecute = nameof(CanInteract))]
private void Suggest()
{
- if (_state == null) return;
- var cur = State.Players[_state.CurrentPlayerIndex];
- var person = GameData.Persons.First();
- var weapon = GameData.Weapons.First();
- var room = GameData.Rooms.First();
- var sug = _service.MakeSuggestion(_state, cur.Id, person, weapon, room);
- History.Add($"V: {cur.Name}: {person.Name}/{weapon.Name}/{room.Name} -> {(sug.RefutingPlayerId is int id ? $"von {_state.Players.First(p=>p.Id==id).Name}" : "Keiner")}");
+ if (State == null) return;
+ var cur = State.Players[State.CurrentPlayerIndex];
+ var person = GameData.Persons[0];
+ var weapon = GameData.Weapons[0];
+ var room = GameData.Rooms[0];
+ var sug = _service.MakeSuggestion(State, cur.Id, person, weapon, room);
+ History.Add($"V: {cur.Name}: {person.Name}/{weapon.Name}/{room.Name} -> {(sug.RefutingPlayerId is int id ? $"von {State.Players.First(p=>p.Id==id).Name}" : "Keiner")}");
UpdateTitle();
}
[RelayCommand(CanExecute = nameof(CanInteract))]
private void Accuse()
{
- if (_state == null) return;
- var cur = State.Players[_state.CurrentPlayerIndex];
- var ok = _service.MakeAccusation(_state, cur.Id, GameData.Persons.First(), GameData.Weapons.First(), GameData.Rooms.First());
+ if (State == null) return;
+ var cur = State.Players[State.CurrentPlayerIndex];
+ var ok = _service.MakeAccusation(State, cur.Id, GameData.Persons.First(), GameData.Weapons.First(), GameData.Rooms.First());
History.Add(ok ? $"{cur.Name} gewinnt" : $"{cur.Name} falsch (inaktiv)");
UpdateTitle();
}
@@ -90,9 +91,9 @@ private void UpdateTitle()
if (State == null)
CurrentTitle = "Detektivspiel";
else if (State.Finished)
- CurrentTitle = $"Ende Sieger {_state.WinnerPlayerId}";
+ CurrentTitle = $"Ende Sieger {State.WinnerPlayerId}";
else
- CurrentTitle = $"Zug: {_state.Players[_state.CurrentPlayerIndex].Name}";
+ CurrentTitle = $"Zug: {State.Players[State.CurrentPlayerIndex].Name}";
}
}
diff --git a/CSharpBible/Games/DetectiveGame.Console/Views/SuggestionDialog.cs b/CSharpBible/Games/DetectiveGame.Console/Views/SuggestionDialog.cs
index da84771cc..89d796b9f 100644
--- a/CSharpBible/Games/DetectiveGame.Console/Views/SuggestionDialog.cs
+++ b/CSharpBible/Games/DetectiveGame.Console/Views/SuggestionDialog.cs
@@ -104,6 +104,6 @@ public void Hide()
public void Show()
{
Visible = true;
- (Parent as IGroupControl).BringToFront(this);
+ (Parent as IGroupControl)?.BringToFront(this);
}
}
diff --git a/CSharpBible/Games/DetectiveGame.Engine/DetectiveGame.Engine.csproj b/CSharpBible/Games/DetectiveGame.Engine/DetectiveGame.Engine.csproj
index 061486838..01e73ea11 100644
--- a/CSharpBible/Games/DetectiveGame.Engine/DetectiveGame.Engine.csproj
+++ b/CSharpBible/Games/DetectiveGame.Engine/DetectiveGame.Engine.csproj
@@ -6,4 +6,9 @@
latest
true
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj b/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj
index 16fd768dc..17c22cb15 100644
--- a/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj
+++ b/CSharpBible/Games/DetectiveGame.Tests/DetectiveGame.Tests.csproj
@@ -5,10 +5,15 @@
enable
-
-
-
-
+
+
+
+
+
+
+
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -16,4 +21,9 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/DetectiveGame.Tests/SetupTests.cs b/CSharpBible/Games/DetectiveGame.Tests/SetupTests.cs
index a277a041c..2ed21f277 100644
--- a/CSharpBible/Games/DetectiveGame.Tests/SetupTests.cs
+++ b/CSharpBible/Games/DetectiveGame.Tests/SetupTests.cs
@@ -12,7 +12,7 @@ public void Distribution_Is_Fair()
var svc = new GameService();
var state = svc.CreateNew(new[] { "A", "B", "C", "D" });
var counts = state.Players.Select(p => p.Hand.Count).ToList();
- Assert.IsTrue(counts.Max() - counts.Min() <= 1);
+ Assert.IsLessThanOrEqualTo(1, counts.Max() - counts.Min());
// total cards should be 27 - 3 = 24
Assert.AreEqual(24, counts.Sum());
}
@@ -24,9 +24,9 @@ public void Solution_Cards_Not_In_Hands()
var state = svc.CreateNew(new[] { "A", "B", "C" });
foreach (var p in state.Players)
{
- Assert.IsFalse(p.Hand.Contains(state.Solution.Person));
- Assert.IsFalse(p.Hand.Contains(state.Solution.Weapon));
- Assert.IsFalse(p.Hand.Contains(state.Solution.Room));
+ Assert.DoesNotContain(state.Solution.Person, p.Hand);
+ Assert.DoesNotContain(state.Solution.Weapon, p.Hand);
+ Assert.DoesNotContain(state.Solution.Room, p.Hand);
}
}
}
\ No newline at end of file
diff --git a/CSharpBible/Games/DetectiveGame.Wpf/DetectiveGame.Wpf.csproj b/CSharpBible/Games/DetectiveGame.Wpf/DetectiveGame.Wpf.csproj
index fbee800a3..6139114ef 100644
--- a/CSharpBible/Games/DetectiveGame.Wpf/DetectiveGame.Wpf.csproj
+++ b/CSharpBible/Games/DetectiveGame.Wpf/DetectiveGame.Wpf.csproj
@@ -8,9 +8,12 @@
latest
-
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/Directory.Packages.props b/CSharpBible/Games/Directory.Packages.props
new file mode 100644
index 000000000..07fa7bc83
--- /dev/null
+++ b/CSharpBible/Games/Directory.Packages.props
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/Galaxia_Base/Models/CorActions/DoubleJump.cs b/CSharpBible/Games/Galaxia_Base/Models/CorActions/DoubleJump.cs
index c095cbafe..7f154a09c 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/CorActions/DoubleJump.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/CorActions/DoubleJump.cs
@@ -16,7 +16,7 @@ public class DoubleJump : JumpAction, ICorAction
///
/// Das Ziel-Sternensystem fr den Hyperjump.
///
- public IStarsystem TargetStarsystem { get; }
+ public IStarsystem? TargetStarsystem { get; }
///
/// Erstellt eine neue DoubleJump-Aktion.
@@ -24,7 +24,7 @@ public class DoubleJump : JumpAction, ICorAction
/// Die ausfhrende Corporation.
/// Die springende Flotte.
/// Das Ziel-Sternensystem.
- public DoubleJump(ICorporation corporation, IFleet fleet1, IFleet fleet2, IStarsystem targetStarsystem):base(corporation,fleet1)
+ public DoubleJump(ICorporation corporation, IFleet fleet1, IFleet fleet2, IStarsystem? targetStarsystem):base(corporation,fleet1)
{
Fleet2 = fleet2;
TargetStarsystem = targetStarsystem;
@@ -37,7 +37,7 @@ public DoubleJump(ICorporation corporation, IFleet fleet1, IFleet fleet2, IStars
public override bool Execute()
{
// berprfe, ob die Flotte zur Corporation gehrt und sich im Hyperspace befindet
- if (Fleet.Owner != Corporation || Fleet.Container is not IHyperSlot hs )
+ if (Fleet?.Owner != Corporation || Fleet.Container is not IHyperSlot hs )
return false;
// berprfe, ob die Flotte zur Corporation gehrt und sich im Hyperspace befindet
@@ -58,7 +58,7 @@ public override bool Execute()
// Die Flotte verlsst den HyperSlot und wird dem Zielsystem zugewiesen
// Dies kann je nach Implementierung variieren
TargetStarsystem.Sector.SetFleet(Fleet);
- TargetStarsystem.Sector.Fleet.Join(Fleet2);
+ TargetStarsystem.Sector.Fleet?.Join(Fleet2);
TargetStarsystem.SetFleet(Fleet);
return true;
}
diff --git a/CSharpBible/Games/Galaxia_Base/Models/CorActions/EmbarkFleet.cs b/CSharpBible/Games/Galaxia_Base/Models/CorActions/EmbarkFleet.cs
index 11ce31eff..8a3321cbe 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/CorActions/EmbarkFleet.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/CorActions/EmbarkFleet.cs
@@ -25,7 +25,7 @@ public class EmbarkFleet : ICorAction
///
/// Das Sternensystem, aus dem die Flotte startet.
///
- public IStarsystem SourceStarsystem { get; }
+ public IStarsystem? SourceStarsystem { get; }
///
/// Erstellt eine neue EmbarkFleet-Aktion.
@@ -33,7 +33,7 @@ public class EmbarkFleet : ICorAction
/// Die ausfhrende Corporation.
/// Die einzuschiffende Flotte.
/// Das Start-Sternensystem.
- public EmbarkFleet(ICorporation corporation, IFleet fleet, IStarsystem sourceStarsystem,float fleetSize =0f)
+ public EmbarkFleet(ICorporation corporation, IFleet? fleet, IStarsystem? sourceStarsystem,float fleetSize =0f)
{
Corporation = corporation;
Fleet = fleet;
@@ -52,13 +52,13 @@ public bool Execute()
return false;
// Versuche, die Flotte ins Hyperspace-System einzuschiffen
- if ((FleetSize > 1f) && (Fleet.Size > FleetSize))
+ if ((FleetSize > 1f) && (Fleet!.Size > FleetSize))
{
// Nicht genug Platz in den HyperSlots
- return Corporation.hyperspace.Embark(Fleet.Split(FleetSize));
+ return Corporation.hyperspace.Embark(Fleet.Split(FleetSize)!);
}
else
- return Corporation.hyperspace.Embark(Fleet);
+ return Corporation.hyperspace.Embark(Fleet!);
}
}
}
\ No newline at end of file
diff --git a/CSharpBible/Games/Galaxia_Base/Models/CorActions/JumpAction.cs b/CSharpBible/Games/Galaxia_Base/Models/CorActions/JumpAction.cs
index fd1c80e13..5a07e16ba 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/CorActions/JumpAction.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/CorActions/JumpAction.cs
@@ -1,11 +1,10 @@
using Galaxia.Models.Interfaces;
-namespace Galaxia.Models.CorActions
+namespace Galaxia.Models.CorActions;
+
+public abstract class JumpAction(ICorporation corporation, IFleet? fleet) : ICorAction
{
- public abstract class JumpAction(ICorporation corporation, IFleet? fleet) : ICorAction
- {
- public ICorporation Corporation => corporation;
- public IFleet? Fleet => fleet;
- public abstract bool Execute();
- }
+ public ICorporation Corporation => corporation;
+ public IFleet? Fleet => fleet;
+ public abstract bool Execute();
}
\ No newline at end of file
diff --git a/CSharpBible/Games/Galaxia_Base/Models/CorActions/SingleJump.cs b/CSharpBible/Games/Galaxia_Base/Models/CorActions/SingleJump.cs
index 14e33a12b..221703672 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/CorActions/SingleJump.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/CorActions/SingleJump.cs
@@ -8,20 +8,11 @@ namespace Galaxia.Models.CorActions
///
public class SingleJump : JumpAction, ICorAction
{
- ///
- /// Die Corporation, die die Aktion ausfhrt.
- ///
- public ICorporation Corporation { get; }
-
- ///
- /// Die Flotte, die den Hyperjump ausfhren soll.
- ///
- public IFleet Fleet { get; }
///
/// Das Ziel-Sternensystem fr den Hyperjump.
///
- public IStarsystem TargetStarsystem { get; }
+ public IStarsystem? TargetStarsystem { get; }
///
/// Erstellt eine neue SingleJump-Aktion.
@@ -29,7 +20,7 @@ public class SingleJump : JumpAction, ICorAction
/// Die ausfhrende Corporation.
/// Die springende Flotte.
/// Das Ziel-Sternensystem.
- public SingleJump(ICorporation corporation, IFleet fleet, IStarsystem targetStarsystem):base(corporation,fleet)
+ public SingleJump(ICorporation corporation, IFleet? fleet, IStarsystem? targetStarsystem):base(corporation,fleet)
{
TargetStarsystem = targetStarsystem;
}
@@ -41,7 +32,7 @@ public SingleJump(ICorporation corporation, IFleet fleet, IStarsystem targetStar
public override bool Execute()
{
// berprfe, ob die Flotte zur Corporation gehrt und sich im Hyperspace befindet
- if (Fleet.Owner != Corporation || Fleet.Container is not IHyperSlot hs )
+ if (Fleet?.Owner != Corporation || Fleet.Container is not IHyperSlot hs )
return false;
// berprfe, ob das Zielsystem erreichbar ist (optional, je nach Spielmechanik)
diff --git a/CSharpBible/Games/Galaxia_Base/Models/Corporation.cs b/CSharpBible/Games/Galaxia_Base/Models/Corporation.cs
index 9e1b59929..a924286a8 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/Corporation.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/Corporation.cs
@@ -77,7 +77,7 @@ public IList GetPossibleActions()
// Test if a double jump is possible (2 ships in hyperspace and combined reachable sectors)
if (!Actions.Any(a => a is JumpAction) && hyperspace.CombReachableSectors().Count() > 0)
{
- possibleActions.Add(new DoubleJump(this, hyperspace.HyperSlots[0].Fleet, hyperspace.HyperSlots[1].Fleet, null));
+ possibleActions.Add(new DoubleJump(this, hyperspace.HyperSlots[0].Fleet!, hyperspace.HyperSlots[1].Fleet!, null));
}
// test if a Hyperslot is free or gets free this turn -> Embark is possible
diff --git a/CSharpBible/Games/Galaxia_Base/Models/Interfaces/ICorAction.cs b/CSharpBible/Games/Galaxia_Base/Models/Interfaces/ICorAction.cs
index d93c5e527..ae19db31d 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/Interfaces/ICorAction.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/Interfaces/ICorAction.cs
@@ -7,7 +7,7 @@ public interface ICorAction
///
/// The corporation.
ICorporation Corporation { get; }
- IFleet Fleet { get; }
+ IFleet? Fleet { get; }
///
/// Executes this instance.
diff --git a/CSharpBible/Games/Galaxia_Base/Models/Interfaces/Point3d.cs b/CSharpBible/Games/Galaxia_Base/Models/Interfaces/Point3d.cs
index e24f3e442..da9f03e68 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/Interfaces/Point3d.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/Interfaces/Point3d.cs
@@ -2,9 +2,11 @@
namespace Galaxia.Models.Interfaces;
+#pragma warning disable CS0659 // Typ überschreibt Object.Equals(object o), überschreibt jedoch nicht Object.GetHashCode()
public struct Point3d
+#pragma warning restore CS0659 // Typ überschreibt Object.Equals(object o), überschreibt jedoch nicht Object.GetHashCode()
{
- internal static readonly Point3d Zero;
+ internal static readonly Point3d Zero=new();
public int X { get; }
public int Y { get; }
diff --git a/CSharpBible/Games/Galaxia_Base/Models/Starsystem.cs b/CSharpBible/Games/Galaxia_Base/Models/Starsystem.cs
index d47c69bc5..ad5634c7c 100644
--- a/CSharpBible/Games/Galaxia_Base/Models/Starsystem.cs
+++ b/CSharpBible/Games/Galaxia_Base/Models/Starsystem.cs
@@ -41,7 +41,7 @@ public bool SetFleet(IFleet? fleet)
if (Fleet != null)
{
if (!Fleet.Owner.Stars.Contains(this) && Fleet.Owner.Home != this)
- fleet.Owner.Stars.Remove(this);
+ Fleet.Owner.Stars.Remove(this);
}
Fleet = fleet;
diff --git a/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj b/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj
index 73b2e0cba..a994bb297 100644
--- a/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj
+++ b/CSharpBible/Games/Galaxia_BaseTests/Galaxia_BaseTests.csproj
@@ -9,9 +9,9 @@
-
-
-
+
+
+
@@ -22,4 +22,9 @@
+
+
+
+
+
diff --git a/CSharpBible/Games/Galaxia_BaseTests/Models/SpaceTests.cs b/CSharpBible/Games/Galaxia_BaseTests/Models/SpaceTests.cs
index 21ddb7529..96a06a301 100644
--- a/CSharpBible/Games/Galaxia_BaseTests/Models/SpaceTests.cs
+++ b/CSharpBible/Games/Galaxia_BaseTests/Models/SpaceTests.cs
@@ -20,17 +20,17 @@ public void InitializeTest()
rnd.Next(Arg.Any()).Returns(x => _rnd++ % x.ArgAt(0));
space.Initialize();
Assert.IsNotNull(space.Sectors);
- Assert.AreEqual(24, space.Sectors.Count);
+ Assert.HasCount(24, space.Sectors);
foreach (var sector in space.Sectors.Values)
{
Assert.IsNotNull(sector);
Assert.IsNotNull(sector.Starsystems);
- Assert.AreEqual(2, sector.Starsystems.Count);
+ Assert.HasCount(2, sector.Starsystems);
foreach (var star in sector.Starsystems)
{
Assert.IsNotNull(star);
Assert.IsFalse(string.IsNullOrEmpty(star.Name));
- Assert.IsTrue(NamingHelper.existingNames.Contains(star.Name));
+ Assert.Contains(star.Name, NamingHelper.existingNames);
Debug.WriteLine($"Star System: {star.Name} at {star.Position.X},{star.Position.Y},{star.Position.Z} pop:{star.Population} res:{star.Resources}");
}
}
diff --git a/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj b/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj
index 4817a8c49..f021ee2da 100644
--- a/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj
+++ b/CSharpBible/Games/Galaxia_UI.Tests/Galaxia_UI.Tests.csproj
@@ -4,8 +4,16 @@
false
enable
+
+
+
+
+
-
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/Galaxia_UI.Tests/ViewModels/MainViewModelTests.cs b/CSharpBible/Games/Galaxia_UI.Tests/ViewModels/MainViewModelTests.cs
index 968e7742d..4b1b7e464 100644
--- a/CSharpBible/Games/Galaxia_UI.Tests/ViewModels/MainViewModelTests.cs
+++ b/CSharpBible/Games/Galaxia_UI.Tests/ViewModels/MainViewModelTests.cs
@@ -30,7 +30,7 @@ public void Initialize_Populates_Sectors()
vm.InitializeCommand.Execute(null);
- Assert.IsTrue(vm.Sectors.Count > 0, "Sektoren sollten nach Initialisierung vorhanden sein.");
+ Assert.IsNotEmpty(vm.Sectors, "Sektoren sollten nach Initialisierung vorhanden sein.");
}
[TestMethod]
diff --git a/CSharpBible/Games/Galaxia_UI/Galaxia_UI.csproj b/CSharpBible/Games/Galaxia_UI/Galaxia_UI.csproj
index 8153d6a73..ee0a2795c 100644
--- a/CSharpBible/Games/Galaxia_UI/Galaxia_UI.csproj
+++ b/CSharpBible/Games/Galaxia_UI/Galaxia_UI.csproj
@@ -14,10 +14,15 @@
-
-
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/Game_Base/Game_Base.csproj b/CSharpBible/Games/Game_Base/Game_Base.csproj
index 81b845c47..f5968e276 100644
--- a/CSharpBible/Games/Game_Base/Game_Base.csproj
+++ b/CSharpBible/Games/Game_Base/Game_Base.csproj
@@ -2,16 +2,28 @@
Library
- net481;net48;net472;net462;net6.0;net7.0;net8.0;net9.0
+ net481;net48;net472;net462;net6.0;net7.0;net8.0
+ AnyCPU;x64
-
-
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/Game_Base/Model/Playfield2D.cs b/CSharpBible/Games/Game_Base/Model/Playfield2D.cs
index bcba6d9d2..7135267e2 100644
--- a/CSharpBible/Games/Game_Base/Model/Playfield2D.cs
+++ b/CSharpBible/Games/Game_Base/Model/Playfield2D.cs
@@ -109,7 +109,7 @@ public T? this[Point P]
po.Parent = (IHasChildren
-
+
+
+
+
diff --git a/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleLnx.csproj b/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleLnx.csproj
index a1746f9c2..b86e174c3 100644
--- a/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleLnx.csproj
+++ b/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleLnx.csproj
@@ -11,11 +11,13 @@
-
-
+
+
+
+
diff --git a/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleWin.csproj b/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleWin.csproj
index 558e581bc..3ce8e3c75 100644
--- a/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleWin.csproj
+++ b/CSharpBible/Games/HexaBan_Console/HexaBan_ConsoleWin.csproj
@@ -11,11 +11,13 @@
-
-
+
+
+
+
diff --git a/CSharpBible/Games/HexaBan_Console/Properties/launchSettings.json b/CSharpBible/Games/HexaBan_Console/Properties/launchSettings.json
new file mode 100644
index 000000000..30d002368
--- /dev/null
+++ b/CSharpBible/Games/HexaBan_Console/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "profiles": {
+ "HexaBan_Console": {
+ "commandName": "Project"
+ },
+ "Container (Dockerfile)": {
+ "commandName": "Docker"
+ },
+ "WSL": {
+ "commandName": "WSL2",
+ "distributionName": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/MVVM_TiledDisplay_net/MVVM_TiledDisplay_net.csproj b/CSharpBible/Games/MVVM_TiledDisplay_net/MVVM_TiledDisplay_net.csproj
index 14ad9931c..695194ea0 100644
--- a/CSharpBible/Games/MVVM_TiledDisplay_net/MVVM_TiledDisplay_net.csproj
+++ b/CSharpBible/Games/MVVM_TiledDisplay_net/MVVM_TiledDisplay_net.csproj
@@ -18,10 +18,14 @@
-
+
+
+
+
+
diff --git a/CSharpBible/Games/MVVM_TiledDisplay_net/View/Converter/TileDataArrToTileDisplay.cs b/CSharpBible/Games/MVVM_TiledDisplay_net/View/Converter/TileDataArrToTileDisplay.cs
index c4150f582..6154af87a 100644
--- a/CSharpBible/Games/MVVM_TiledDisplay_net/View/Converter/TileDataArrToTileDisplay.cs
+++ b/CSharpBible/Games/MVVM_TiledDisplay_net/View/Converter/TileDataArrToTileDisplay.cs
@@ -30,7 +30,7 @@ public class TileDataArrToTileDisplay : IValueConverter
/// Gets or sets the background.
///
/// The background.
- public Style ItemStyle { get; set; } = default;
+ public Style? ItemStyle { get; set; } = default;
public Brush background { get; set; } = Brushes.DarkBlue;
///
/// Gets or sets the size of the tile.
diff --git a/CSharpBible/Games/MVVM_TiledDisplay_net/ViewModels/MainViewViewModel.cs b/CSharpBible/Games/MVVM_TiledDisplay_net/ViewModels/MainViewViewModel.cs
index fdcacc6a8..b5ce765a2 100644
--- a/CSharpBible/Games/MVVM_TiledDisplay_net/ViewModels/MainViewViewModel.cs
+++ b/CSharpBible/Games/MVVM_TiledDisplay_net/ViewModels/MainViewViewModel.cs
@@ -11,8 +11,8 @@ namespace MVVM_TiledDisplay.ViewModel
public class MainViewViewModel : BaseViewModel
{
#region Properties
- public DelegateCommand cmdButton { get; set; }
- public event EventHandler SetFrame = default;
+ public DelegateCommand? cmdButton { get; set; }
+ public event EventHandler? SetFrame = default;
#endregion
#region Methods
diff --git a/CSharpBible/Games/MidiSwing.MVVM/MidiSwing.MVVM.csproj b/CSharpBible/Games/MidiSwing.MVVM/MidiSwing.MVVM.csproj
index 54d668405..d19a10fc5 100644
--- a/CSharpBible/Games/MidiSwing.MVVM/MidiSwing.MVVM.csproj
+++ b/CSharpBible/Games/MidiSwing.MVVM/MidiSwing.MVVM.csproj
@@ -9,8 +9,20 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/MidiSwing.MVVM/Models/MusicGenerator.cs b/CSharpBible/Games/MidiSwing.MVVM/Models/MusicGenerator.cs
index 68c8892a3..f43483133 100644
--- a/CSharpBible/Games/MidiSwing.MVVM/Models/MusicGenerator.cs
+++ b/CSharpBible/Games/MidiSwing.MVVM/Models/MusicGenerator.cs
@@ -16,12 +16,8 @@ public class MusicGenerator
private MidiOut _midi;
private readonly object _midiSync = new();
- // Platzhalter für MIDI-Ausgabegerät aus der gewählten Bibliothek (z.B. NAudio)
- // private NAudio.Midi.MidiOut midiOut;
-
public MusicGenerator()
{
- // Initialisierung des MIDI-Ports (z.B. Port 0)
_channel = Math.Clamp(2, 1, 16);
try
{
@@ -36,111 +32,190 @@ public MusicGenerator()
}
}
- /*
- PSEUDOCODE (Plan):
- - Ziel: Die bestehende Swing-Melodie um eine Begleitung (Akkord-Comping) und Schlagzeug (Hi-Hat, Kick, Snare) erweitern.
- - Kanäle:
- - Melodie: vorhandener Kanal _channel (z.B. 2), Instrument: Tenor Sax (GM 67).
- - Begleitung: eigener Kanal (z.B. 4), Instrument: Jazz-Gitarre (GM 27).
- - Drums: GM-Drum-Kanal 10 (Percussion; Patch egal).
- - Timing:
- - BPM definieren (z.B. 120).
- - beatMs = 60000 / BPM.
- - Swing-Achtel: long = 2/3 beat, short = 1/3 beat.
- - Melodie:
- - Vorhandene Notenliste weiterverwenden.
- - Nacheinander NoteOn -> Delay(duration) -> NoteOff -> kurze Pause.
- - Begleitung (Comping):
- - Einfache 4-taktige Akkordfolge passend zur Melodie (Cmaj7 | Dm7 | G7 | Cmaj7).
- - Pro Takt zwei kurze Stabs auf 2 und 4 (je ~140 ms), restliche Zeit Pause.
- - Akkordtöne im mittleren Register, Blockakkord anspielen, nach Länge NoteOff für alle.
- - Drums:
- - Hi-Hat (42) auf geswingten Achteln: On-Beat (stärker), Off-Beat (leiser).
- - Kick (36) auf 1 und 3, Snare (38) auf 2 und 4.
- - Jeder Schlag kurzer Impuls (NoteOff nach ~20–30 ms).
- - Nebenläufigkeit:
- - Melodie, Begleitung und Drums als drei Tasks parallel starten, am Ende await Task.WhenAll.
- - Thread-Safety: alle MIDI-Sends über Lock synchronisieren.
- - Fehlerbehandlung:
- - try/catch um Gesamtablauf; TaskCanceledException ignorieren.
- - Bei fehlendem MIDI (_midi == null) früh beenden.
- */
+ // Basic lengths in eighth-note units to keep rhythm handling simple
+ private enum NoteLength
+ {
+ Eighth = 1,
+ Quarter = 2,
+ Half = 4,
+ Whole = 8
+ }
- public async Task PlaySwingMelody()
+ private sealed class MelodyNote
+ {
+ public int Pitch { get; set; }
+ public NoteLength Length { get; set; }
+ public bool Accent { get; set; }
+ }
+
+ private List GenerateBaseMelody(int root = 60)
{
- // Plan (Pseudocode):
- // - Ein einziger Timing-Task steuert alle Events deterministisch.
- // - Vorbereitungen:
- // - Kanäle/Velocity/BPM/Swing berechnen.
- // - Patches setzen.
- // - Timeline aufbauen:
- // - Drums: Für 4 Takte pro Beat On-Beat (Hi-Hat, Kick/Snare), danach Swing-Off-Beat.
- // - Begleitung: Pro Takt Stabs auf 2 und 4 (140 ms) mit Akkordtönen.
- // - Melodie: Sequenz der Noten nacheinander mit gegebenen Dauer und kleiner Pause.
- // - Ein einziger Ablauf:
- // - Ereignisse werden nacheinander mit Task.Delay ausgeführt, Reihenfolge durch Zeitstempel.
- // - NoteOn/NoteOff/DrumHits mit Lock senden.
- // - Fehlerbehandlung: try/catch um Gesamtablauf, bei fehlendem MIDI früh beenden.
+ // Simple minor-blues flavored motif around root
+ var scale = new[] { 0, 2, 4, 5, 7,9, 10, 12 };
+ int Degree(int i) => root + scale[i];
+ var motif = new List
+ {
+ new() { Pitch = Degree(0), Length = NoteLength.Quarter, Accent = true },
+ new() { Pitch = Degree(1), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(2), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(3), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(4), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(5), Length = NoteLength.Quarter, Accent = false },
+ new() { Pitch = Degree(6), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(7), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(5), Length = NoteLength.Quarter, Accent = false },
+ new() { Pitch = Degree(7), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(2), Length = NoteLength.Eighth, Accent = false },
+ new() { Pitch = Degree(0), Length = NoteLength.Quarter, Accent = false }
+ };
+
+ var phrase = new List();
+
+ // Bar 1: original motif
+ phrase.AddRange(motif);
+
+ // Bar 2: up a second
+ phrase.AddRange(motif.Select(n => new MelodyNote
+ {
+ Pitch = n.Pitch + 2,
+ Length = n.Length,
+ Accent = n.Accent
+ }));
+
+ // Bar 3: down a minor third
+ phrase.AddRange(motif.Select(n => new MelodyNote
+ {
+ Pitch = n.Pitch - 3,
+ Length = n.Length,
+ Accent = n.Accent
+ }));
+
+ // Bar 4: original but with a held note at the end
+ phrase.AddRange(motif.Take(motif.Count - 1));
+ phrase.Add(new MelodyNote
+ {
+ Pitch = Degree(0),
+ Length = NoteLength.Half,
+ Accent = true
+ });
+
+ return phrase;
+ }
+
+ private List CreateVariation(List baseMelody, int semitoneShift, bool addSyncopation)
+ {
+ var rnd = new Random();
+ var result = new List();
+
+ foreach (var note in baseMelody)
+ {
+ var pitch = note.Pitch + semitoneShift;
+
+ // Occasional neighbor note for variation
+ if (rnd.NextDouble() < 0.2)
+ {
+ int neighbor = pitch + (rnd.Next(2) == 0 ? -1 : 1);
+ result.Add(new MelodyNote
+ {
+ Pitch = neighbor,
+ Length = NoteLength.Eighth,
+ Accent = false
+ });
+
+ result.Add(new MelodyNote
+ {
+ Pitch = pitch,
+ Length = note.Length == NoteLength.Quarter ? NoteLength.Eighth : note.Length,
+ Accent = note.Accent
+ });
+
+ continue;
+ }
+
+ var len = note.Length;
+ if (addSyncopation && len == NoteLength.Quarter && rnd.NextDouble() < 0.4)
+ {
+ // Split quarter into two eighths
+ result.Add(new MelodyNote
+ {
+ Pitch = pitch,
+ Length = NoteLength.Eighth,
+ Accent = note.Accent
+ });
+ result.Add(new MelodyNote
+ {
+ Pitch = pitch,
+ Length = NoteLength.Eighth,
+ Accent = false
+ });
+ }
+ else
+ {
+ result.Add(new MelodyNote
+ {
+ Pitch = pitch,
+ Length = len,
+ Accent = note.Accent
+ });
+ }
+ }
+
+ return result;
+ }
+
+ private List GetMainChords()
+ {
+ // Rough C minor → F7 → G7 → Cmin pattern
+ return new List
+ {
+ new[] { 48, 51, 55, 58 }, // Cmin7
+ new[] { 41, 45, 48, 52 }, // F7
+ new[] { 43, 47, 50, 53 }, // G7
+ new[] { 48, 51, 55, 58 } // Cmin7
+ };
+ }
+
+ public async Task PlaySwingMelody()
+ {
if (_midi == null)
{
Console.WriteLine("Kein MIDI-Ausgabegerät verfügbar.");
return;
}
- const int drumsChannel = 10; // GM-Drums
- const int compChannel = 4; // Begleitung
- const int melodyVelocity = 100;
- const int compVelocity = 80;
- const int hiHatVelocityOn = 70;
- const int hiHatVelocityOff = 55;
- const int kickVelocity = 90;
- const int snareVelocity = 95;
+ const int drumsChannel = 10;
+ const int bassChannel = 3;
+ const int compChannel = 4;
+ const int leadChannel = 2;
+
+ const int leadPatch = 67; // Tenor Sax
+ const int compPatch = 27; // Jazz Guitar
+ const int bassPatch = 33; // Acoustic Bass
int bpm = 120;
int beatMs = 60000 / bpm;
- int swingLong = (int)Math.Round(beatMs * 2.0 / 3.0);
- int swingShort = beatMs - swingLong;
try
{
- // Instrumente setzen
- SetPatch(67, _channel); // Tenor Sax
- SetPatch(27, compChannel); // Jazz-Gitarre (GM 27)
-
- // Beispiel-Melodie mit Swing-Rhythmus (Triolen-basiert)
- var melody = new List<(int note, int duration)>
- {
- (60, 2), (62, 1), (64, 2), (62, 1), // C D E D
- (60, 6), // C
- (65, 2), (64, 1), (62, 2), (60, 1), // F E D C
- (59, 6), // H (B)
- (60, 2), (62, 1), (64, 2), (62, 1), // C D E D
- (60, 6), // C
- (65, 2), (64, 1), (62, 2), (60, 1), // F E D C
- (59, 6), // H (B)
- };
-
- // 4-taktige Akkordfolge (Cmaj7 | Dm7 | G7 | Cmaj7)
- var chords = new List
- {
- new[] { 48, 52, 55, 59 }, // Cmaj7: C E G B (C3 E3 G3 B3)
- new[] { 50, 53, 57, 60 }, // Dm7: D F A C
- new[] { 43, 47, 50, 53 }, // G7: G B D F (tiefer für Klarheit)
- new[] { 48, 52, 55, 59 } // Cmaj7
- };
+ SetPatch(leadPatch, leadChannel);
+ SetPatch(compPatch, compChannel);
+ SetPatch(bassPatch, bassChannel);
- // Timeline-Event-Struktur
var timeline = new List<(int atMs, Action action)>();
- // Hilfsfunktionen zum Hinzufügen
- void AddNoteOn(int atMs, int note, int vel, int ch) => timeline.Add((atMs, () => NoteOn(note, vel, ch)));
- void AddNoteOff(int atMs, int note, int ch) => timeline.Add((atMs, () => NoteOff(note, ch)));
- void AddDrumHit(int atMs, int midiNote, int vel, int ch, int lenMs)
+ void AddNoteOn(int atMs, int note, int vel, int ch) =>
+ timeline.Add((atMs, () => NoteOn(note, vel, ch)));
+
+ void AddNoteOff(int atMs, int note, int ch) =>
+ timeline.Add((atMs, () => NoteOff(note, ch)));
+
+ void AddDrumHit(int atMs, int midiNote, int vel, int lenMs)
{
- timeline.Add((atMs, () => NoteOn(midiNote, vel, ch)));
- timeline.Add((atMs + lenMs, () => NoteOff(midiNote, ch)));
+ timeline.Add((atMs, () => NoteOn(midiNote, vel, drumsChannel)));
+ timeline.Add((atMs + lenMs, () => NoteOff(midiNote, drumsChannel)));
}
+
void AddChordStab(int atMs, IEnumerable notes, int vel, int ch, int lenMs)
{
foreach (var n in notes)
@@ -150,63 +225,161 @@ void AddChordStab(int atMs, IEnumerable notes, int vel, int ch, int lenMs)
}
}
- // Gesamtdauer: 4 Takte à 4 Schläge
- int bars = 4;
- int barLenMs = beatMs * 4;
- int totalLenMs = bars * barLenMs;
+ int TimeBeatsToMs(double beats) => (int)Math.Round(beats * beatMs);
+
+ // Build musical material
+ var baseMelody = GenerateBaseMelody(60);
+ var variation1 = CreateVariation(baseMelody, 2, addSyncopation: true);
+ var variation2 = CreateVariation(baseMelody, -3, addSyncopation: true);
+ var chords = GetMainChords();
+
+ int sectionStartMs = 0;
- // Drums: On-Beat + Swing Off-Beat
- for (int bar = 0; bar < bars; bar++)
+ // Intro: 4 bars, bass + light drums, short walk-in
+ int introBars = 4;
+ for (int bar = 0; bar < introBars; bar++)
{
- int barStart = bar * barLenMs;
+ int barStart = sectionStartMs + bar * beatMs * 4;
+
+ int[] bassPattern = { 36, 43, 36, 43 }; // C2, G2
for (int beat = 0; beat < 4; beat++)
{
- int beatStart = barStart + beat * beatMs;
-
- // On-Beat: Hi-Hat
- AddDrumHit(beatStart, 42, hiHatVelocityOn, drumsChannel, 25);
+ int t = barStart + beat * beatMs;
+ int note = bassPattern[beat % bassPattern.Length];
+ AddNoteOn(t, note, 85, bassChannel);
+ AddNoteOff(t + TimeBeatsToMs(0.9), note, bassChannel);
- // Kick auf 1 und 3, Snare auf 2 und 4
- if (beat == 0 || beat == 2)
+ if (beat == 1 || beat == 3)
{
- AddDrumHit(beatStart, 36, kickVelocity, drumsChannel, 25);
+ AddDrumHit(t, 42, 70, 30); // light hi-hat on 2 and 4
}
+ }
+ }
+
+ sectionStartMs += introBars * beatMs * 4;
+
+ // Helper to add one groove bar (drums + comp + bass)
+ void AddGrooveBar(int barIndex, int startMs)
+ {
+ int chordIndex = barIndex % chords.Count;
+ var chord = chords[chordIndex];
+
+ for (int beat = 0; beat < 4; beat++)
+ {
+ int beatStart = startMs + beat * beatMs;
+
+ // Hi-hat on each beat
+ AddDrumHit(beatStart, 42, 75, 25);
+
+ // Kick/snare
+ if (beat == 0 || beat == 2)
+ AddDrumHit(beatStart, 36, 90, 30); // kick
else
- {
- AddDrumHit(beatStart, 38, snareVelocity, drumsChannel, 25);
- }
+ AddDrumHit(beatStart, 38, 95, 30); // snare
+
+ // Off-beat hi-hat (swing)
+ int off = beatStart + (int)Math.Round(beatMs * 2.0 / 3.0);
+ AddDrumHit(off, 42, 60, 25);
+ }
- // Off-Beat (Swing)
- int offBeat = beatStart + swingLong;
- AddDrumHit(offBeat, 42, hiHatVelocityOff, drumsChannel, 25);
+ // Comp stabs on 2 and 4
+ int beat2 = startMs + beatMs;
+ int beat4 = startMs + 3 * beatMs;
+ AddChordStab(beat2, chord, 75, compChannel, 140);
+ AddChordStab(beat4, chord, 80, compChannel, 160);
+
+ // Simple bass walking
+ int[] bassLine = { chord[0], chord[2], chord[2] + 2, chord[3] };
+ for (int beat = 0; beat < 4; beat++)
+ {
+ int t = startMs + beat * beatMs;
+ int n = bassLine[beat % bassLine.Length] - 12;
+ AddNoteOn(t, n, 80, bassChannel);
+ AddNoteOff(t + TimeBeatsToMs(0.9), n, bassChannel);
}
}
- // Begleitung: Stabs auf 2 und 4
- const int stabLen = 125;
- for (int bar = 0; bar < chords.Count; bar++)
+ int barLenMs = beatMs * 4;
+
+ // Main A section: 8 bars of groove
+ int aSectionBars = 8;
+ for (int bar = 0; bar < aSectionBars; bar++)
{
- int barStart = bar * barLenMs;
- var chord = chords[bar];
+ int barStart = sectionStartMs + bar * barLenMs;
+ AddGrooveBar(bar, barStart);
+ }
- int beat2 = barStart + beatMs; // Schlag 2
- int beat4 = barStart + beatMs * 3; // Schlag 4
+ // Helper to place a melody line on the lead channel
+ void PlaceMelody(List melody, int startMs, int channel, int baseVelocity)
+ {
+ double beatPos = 0;
+ foreach (var n in melody)
+ {
+ int start = startMs + TimeBeatsToMs(beatPos);
+ double lenBeats = n.Length switch
+ {
+ NoteLength.Eighth => 0.5,
+ NoteLength.Quarter => 1.0,
+ NoteLength.Half => 2.0,
+ NoteLength.Whole => 4.0,
+ _ => 1.0
+ };
+
+ int vel = baseVelocity + (n.Accent ? 15 : 0);
+ AddNoteOn(start, n.Pitch, vel, channel);
+ AddNoteOff(start + TimeBeatsToMs(lenBeats * 0.9), n.Pitch, channel);
+
+ beatPos += lenBeats;
+ }
+ }
+
+ int melodyStart = sectionStartMs;
+ PlaceMelody(baseMelody, melodyStart, leadChannel, 100);
+
+ int baseMelodyLenBeats = baseMelody.Sum(m => m.Length switch
+ {
+ NoteLength.Eighth => 1,
+ NoteLength.Quarter => 2,
+ NoteLength.Half => 4,
+ NoteLength.Whole => 8,
+ _ => 2
+ });
+ int baseMelodyLenMs = TimeBeatsToMs(baseMelodyLenBeats / 2.0); // convert eighth units back to beats
+
+ PlaceMelody(baseMelody, melodyStart + baseMelodyLenMs, leadChannel, 105);
+
+ sectionStartMs += aSectionBars * barLenMs;
- AddChordStab(beat2, chord, compVelocity, compChannel, stabLen);
- AddChordStab(beat4, chord, compVelocity, compChannel, stabLen);
+ // A' section: variation1 over same groove
+ int a2Bars = 8;
+ for (int bar = 0; bar < a2Bars; bar++)
+ {
+ int barStart = sectionStartMs + bar * barLenMs;
+ AddGrooveBar(bar, barStart);
}
- // Melodie: nacheinander ohne weitere Parallelität
- // Start der Melodie am Beginn
- int t = 0;
- foreach (var (note, duration) in melody)
+ PlaceMelody(variation1, sectionStartMs, leadChannel, 105);
+ sectionStartMs += a2Bars * barLenMs;
+
+ // B section: variation2, denser comping
+ int bBars = 8;
+ for (int bar = 0; bar < bBars; bar++)
{
- AddNoteOn(t , note, melodyVelocity, _channel);
- AddNoteOff(t + duration* beatMs / 3-25, note, _channel);
- t += duration* beatMs / 3; // kleine Pause zwischen den Noten
+ int barStart = sectionStartMs + bar * barLenMs;
+ AddGrooveBar(bar, barStart);
+
+ int chordIndex = bar % chords.Count;
+ var chord = chords[chordIndex];
+ int off2 = barStart + beatMs + beatMs / 2;
+ int off4 = barStart + 3 * beatMs + beatMs / 2;
+ AddChordStab(off2, chord, 85, compChannel, 100);
+ AddChordStab(off4, chord, 90, compChannel, 120);
}
- // Sortierte Ausführung nach Zeitstempel
+ PlaceMelody(variation2, sectionStartMs, leadChannel, 110);
+ sectionStartMs += bBars * barLenMs;
+
+ // Play timeline in order
timeline.Sort((a, b) => a.atMs.CompareTo(b.atMs));
int currentTime = 0;
@@ -222,26 +395,15 @@ void AddChordStab(int atMs, IEnumerable notes, int vel, int ch, int lenMs)
action();
}
- // Sicherstellen, dass Restzeit abgewartet wird, falls Melodie kürzer ist als Gesamt-Arrangement
- if (currentTime < totalLenMs)
- {
- await Task.Delay(totalLenMs - currentTime);
- }
+ await Task.Delay(1000);
}
catch (TaskCanceledException)
{
- // Ignorieren, falls später Abbruch hinzugefügt wird
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Abspielen: {ex.Message}");
}
- finally
- {
- // Gerät schließen (falls gewünscht)
- // midiOut.Close();
- // midiOut.Dispose();
- }
}
private void NoteOn(int noteNumber, int velocity = 100, int channel = -1)
@@ -284,26 +446,4 @@ private void SetPatch(int program, int channel)
_midi.Send(MidiMessage.ChangePatch(program, channel).RawData);
}
}
-
- private async Task PlayChordStabAsync(IEnumerable notes, int velocity, int lengthMs, int channel)
- {
- foreach (var n in notes)
- {
- NoteOn(n, velocity, channel);
- }
-
- await Task.Delay(lengthMs);
-
- foreach (var n in notes)
- {
- NoteOff(n, channel);
- }
- }
-
- private async Task DrumHitAsync(int midiNote, int velocity, int channel)
- {
- NoteOn(midiNote, velocity, channel);
- await Task.Delay(25);
- NoteOff(midiNote, channel);
- }
}
diff --git a/CSharpBible/Games/Packages.props b/CSharpBible/Games/Packages.props
index 1881a483e..356d8f340 100644
--- a/CSharpBible/Games/Packages.props
+++ b/CSharpBible/Games/Packages.props
@@ -6,40 +6,39 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
+
+
\ No newline at end of file
diff --git a/CSharpBible/Games/ReadMe.md b/CSharpBible/Games/ReadMe.md
index 461f9c8ae..01d72832e 100644
--- a/CSharpBible/Games/ReadMe.md
+++ b/CSharpBible/Games/ReadMe.md
@@ -1,10 +1,11 @@
# Games
a collection of "small" games
-mostly the engines with a easy interface.
+mostly the engines with an easy interface.
## Sokoban
Push all the Stones to the destiantion places
+
## Tetris
Stack all blocks together, full lines will be removed.
@@ -16,3 +17,20 @@ Bring Werner(player) to the Flaschbier(destination) and avoid enemies, and falli
## Snake
Eat all the apples, but don't eat yourself
+
+## Arkanoid
+Hit all bricks, but don't loose the Ball.
+
+
+## Asteroids
+Destroy all asteroids, but don't get hit by one.
+
+
+## Treppen (Stairs)
+Build a labyrinth from stairs.
+
+
+## SharpHack
+A rogue-like dungeon crawler inspired by NetHack.
+
+
diff --git a/CSharpBible/Games/RemoteTerminal.Tests/Remote/RemoteAnsiConsoleTests.cs b/CSharpBible/Games/RemoteTerminal.Tests/Remote/RemoteAnsiConsoleTests.cs
new file mode 100644
index 000000000..12b01862f
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal.Tests/Remote/RemoteAnsiConsoleTests.cs
@@ -0,0 +1,19 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using RemoteTerminal.Remote;
+using System.IO;
+
+namespace RemoteTerminal.Tests.Remote;
+
+[TestClass]
+public sealed class RemoteAnsiConsoleTests
+{
+ [TestMethod]
+ public void Ctor_WritesHandshake()
+ {
+ using var stream = new MemoryStream();
+ _ = new RemoteAnsiConsole(stream);
+
+ var bytes = stream.ToArray();
+ Assert.IsNotEmpty(bytes);
+ }
+}
diff --git a/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj b/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj
new file mode 100644
index 000000000..73433b0d9
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal.Tests/RemoteTerminal.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Library
+ net8.0;net481;net48;net472;net462
+ AnyCPU;x64
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/RemoteTerminal/Ansi/AnsiWriter.cs b/CSharpBible/Games/RemoteTerminal/Ansi/AnsiWriter.cs
new file mode 100644
index 000000000..491d11939
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal/Ansi/AnsiWriter.cs
@@ -0,0 +1,70 @@
+using System.IO;
+using System.Text;
+
+namespace RemoteTerminal.Ansi;
+
+///
+/// Writes ANSI escape sequences to a stream.
+///
+public sealed class AnsiWriter
+{
+ private readonly StreamWriter _writer;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stream to write to.
+ public AnsiWriter(Stream stream)
+ {
+ _writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true)
+ {
+ NewLine = "\n",
+ AutoFlush = false,
+ };
+ }
+
+ ///
+ /// Writes a string to the underlying stream.
+ ///
+ /// The value to write.
+ public void Write(string value) => _writer.Write(value);
+
+ ///
+ /// Flushes the underlying writer.
+ ///
+ public void Flush() => _writer.Flush();
+
+ ///
+ /// Clears the screen.
+ ///
+ public void Clear() => Write("\x1b[2J\x1b[H");
+
+ ///
+ /// Shows the cursor.
+ ///
+ public void ShowCursor() => Write("\x1b[?25h");
+
+ ///
+ /// Hides the cursor.
+ ///
+ public void HideCursor() => Write("\x1b[?25l");
+
+ ///
+ /// Moves the cursor.
+ ///
+ /// The 1-based row.
+ /// The 1-based column.
+ public void MoveCursor(int row, int col) => Write($"\x1b[{row};{col}H");
+
+ ///
+ /// Sets 256-color foreground.
+ ///
+ /// The 0-255 color index.
+ public void SetFg256(int color) => Write($"\x1b[38;5;{color}m");
+
+ ///
+ /// Sets 256-color background.
+ ///
+ /// The 0-255 color index.
+ public void SetBg256(int color) => Write($"\x1b[48;5;{color}m");
+}
diff --git a/CSharpBible/Games/RemoteTerminal/Net/AnsiKeyReader.cs b/CSharpBible/Games/RemoteTerminal/Net/AnsiKeyReader.cs
new file mode 100644
index 000000000..cef882b53
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal/Net/AnsiKeyReader.cs
@@ -0,0 +1,163 @@
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace RemoteTerminal.Net;
+
+///
+/// Reads keys from an ANSI terminal stream.
+///
+public sealed class AnsiKeyReader
+{
+ private readonly Stream _stream;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying stream.
+ public AnsiKeyReader(Stream stream)
+ {
+ _stream = stream;
+ }
+
+ ///
+ /// Reads a key asynchronously.
+ ///
+ /// Cancellation token.
+ /// The read key info.
+ public async Task ReadAsync(CancellationToken cancellationToken)
+ {
+ var b = new byte[1];
+
+ while (true)
+ {
+ int read = await _stream.ReadAsync(b, 0, 1, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return new AnsiKeyInfo(AnsiKey.None, '\0');
+ }
+
+ char ch = (char)b[0];
+
+ if (ch != '\x1b')
+ {
+ if (ch == '\n' || ch == '\r')
+ {
+ return new AnsiKeyInfo(AnsiKey.Enter, '\n');
+ }
+
+ if (ch == '\b' || ch == '\x7f')
+ {
+ return new AnsiKeyInfo(AnsiKey.Backspace, '\b');
+ }
+
+ return new AnsiKeyInfo(AnsiKey.Char, ch);
+ }
+
+ // ESC sequence. Try parse CSI arrows. Typical: ESC [ A/B/C/D
+ int nextRead = await _stream.ReadAsync(b, 0, 1, cancellationToken).ConfigureAwait(false);
+ if (nextRead == 0)
+ {
+ return new AnsiKeyInfo(AnsiKey.Escape, '\x1b');
+ }
+
+ char ch2 = (char)b[0];
+ if (ch2 != '[')
+ {
+ return new AnsiKeyInfo(AnsiKey.Escape, '\x1b');
+ }
+
+ int thirdRead = await _stream.ReadAsync(b, 0, 1, cancellationToken).ConfigureAwait(false);
+ if (thirdRead == 0)
+ {
+ return new AnsiKeyInfo(AnsiKey.Escape, '\x1b');
+ }
+
+ char ch3 = (char)b[0];
+
+ // Arrow keys.
+ switch (ch3)
+ {
+ case 'A': return new AnsiKeyInfo(AnsiKey.Up, '\0');
+ case 'B': return new AnsiKeyInfo(AnsiKey.Down, '\0');
+ case 'C': return new AnsiKeyInfo(AnsiKey.Right, '\0');
+ case 'D': return new AnsiKeyInfo(AnsiKey.Left, '\0');
+ }
+
+ // Ignore non-input ANSI sequences that some terminals send (notably on window resize).
+ // Examples: CSI 8 ; rows ; cols t (xterm resize)
+ // CSI ? ... h/l (mode changes)
+ if (ch3 == '8' || ch3 == '?' || char.IsDigit(ch3))
+ {
+ await ConsumeCsiToFinalByteAsync(cancellationToken).ConfigureAwait(false);
+ continue;
+ }
+
+ return new AnsiKeyInfo(AnsiKey.Escape, '\x1b');
+ }
+ }
+
+ private async Task ConsumeCsiToFinalByteAsync(CancellationToken cancellationToken)
+ {
+ // CSI parameters/intermediates end with final byte in range 0x40..0x7E
+ var b = new byte[1];
+ while (true)
+ {
+ int read = await _stream.ReadAsync(b, 0, 1, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return;
+ }
+
+ byte ch = b[0];
+ if (ch >= 0x40 && ch <= 0x7E)
+ {
+ return;
+ }
+ }
+ }
+}
+
+///
+/// Supported ANSI keys.
+///
+public enum AnsiKey
+{
+ None = 0,
+ Up,
+ Down,
+ Left,
+ Right,
+ Enter,
+ Escape,
+ Backspace,
+ Char,
+}
+
+///
+/// Represents one read key.
+///
+public readonly struct AnsiKeyInfo
+{
+ ///
+ /// Initializes a new instance of the struct.
+ ///
+ /// The key kind.
+ /// The read character (if any).
+ public AnsiKeyInfo(AnsiKey key, char ch)
+ {
+ Key = key;
+ Char = ch;
+ }
+
+ ///
+ /// Gets the key kind.
+ ///
+ public AnsiKey Key { get; }
+
+ ///
+ /// Gets the character.
+ ///
+ public char Char { get; }
+}
diff --git a/CSharpBible/Games/RemoteTerminal/Net/TelnetNegotiation.cs b/CSharpBible/Games/RemoteTerminal/Net/TelnetNegotiation.cs
new file mode 100644
index 000000000..7713003be
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal/Net/TelnetNegotiation.cs
@@ -0,0 +1,55 @@
+using System.IO;
+
+namespace RemoteTerminal.Net;
+
+internal static class TelnetNegotiation
+{
+ // Telnet command bytes
+ private const byte IAC = 255;
+ private const byte DONT = 254;
+ private const byte DO = 253;
+ private const byte WONT = 252;
+ private const byte WILL = 251;
+
+ // Telnet options
+ private const byte ECHO = 1;
+ private const byte SUPPRESS_GO_AHEAD = 3;
+ private const byte STATUS = 5;
+ private const byte TERMINAL_TYPE = 24;
+ private const byte WINDOW_SIZE = 31;
+ private const byte TERMINAL_SPEED = 32;
+ private const byte LINEMODE = 34;
+
+ public static void SendBasicServerNegotiation(Stream stream)
+ {
+ // Goal:
+ // - suppress go-ahead (character mode)
+ // - disable line mode
+ // - disable local echo (client should not echo)
+ // Note: Telnet negotiation is best-effort; clients may ignore.
+
+ WriteCmd(stream, WILL, SUPPRESS_GO_AHEAD);
+ WriteCmd(stream, DO, SUPPRESS_GO_AHEAD);
+
+ WriteCmd(stream, WONT, ECHO);
+ WriteCmd(stream, DONT, ECHO);
+
+ WriteCmd(stream, WONT, LINEMODE);
+ WriteCmd(stream, DONT, LINEMODE);
+
+ // Optional asks (ignored if unsupported)
+ WriteCmd(stream, DO, TERMINAL_TYPE);
+ WriteCmd(stream, DO, WINDOW_SIZE);
+ WriteCmd(stream, DO, TERMINAL_SPEED);
+ WriteCmd(stream, DO, STATUS);
+
+ stream.Flush();
+ }
+
+ private static void WriteCmd(Stream stream, byte cmd, byte option)
+ {
+ stream.WriteByte(IAC);
+ stream.WriteByte(cmd);
+ stream.WriteByte(option);
+ }
+}
diff --git a/CSharpBible/Games/RemoteTerminal/Remote/RemoteAnsiConsole.cs b/CSharpBible/Games/RemoteTerminal/Remote/RemoteAnsiConsole.cs
new file mode 100644
index 000000000..14cda0330
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal/Remote/RemoteAnsiConsole.cs
@@ -0,0 +1,307 @@
+using BaseLib.Interfaces;
+using RemoteTerminal.Ansi;
+using RemoteTerminal.Net;
+using System;
+using System.IO;
+using System.Net.Sockets;
+using System.Threading;
+
+namespace RemoteTerminal.Remote;
+
+///
+/// Implements on top of a remote ANSI-compatible terminal.
+///
+public sealed class RemoteAnsiConsole : IConsole
+{
+ private readonly Stream _stream;
+ private readonly AnsiWriter _ansi;
+ private readonly AnsiKeyReader _keys;
+
+ private readonly Action? _log;
+
+ private ConsoleColor _fg = ConsoleColor.Gray;
+ private ConsoleColor _bg = ConsoleColor.Black;
+ private bool _cursorVisible;
+
+ private readonly int _bufferWidth;
+ private readonly int _bufferHeight;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The underlying bidirectional stream.
+ /// The assumed buffer width in columns.
+ /// The assumed buffer height in rows.
+ /// Optional log callback.
+ public RemoteAnsiConsole(Stream stream, int bufferWidth = 120, int bufferHeight = 40, Action? log = null)
+ {
+ _stream = stream;
+ _ansi = new AnsiWriter(stream);
+ _keys = new AnsiKeyReader(stream);
+ _bufferWidth = bufferWidth;
+ _bufferHeight = bufferHeight;
+ _log = log;
+
+ try
+ {
+ // Best-effort: if user connects via PuTTY TELNET, this helps to disable line mode / echo.
+ TelnetNegotiation.SendBasicServerNegotiation(stream);
+ }
+ catch (Exception ex)
+ {
+ _log?.Invoke($"Telnet negotiation failed: {ex.Message}");
+ }
+
+ _ansi.Write("\x1b[0m\x1b[2J\x1b[H\x1b[?25l\x1b[?7l");
+ _ansi.Flush();
+ _log?.Invoke("RemoteAnsiConsole initialized (ANSI reset/clear + hide cursor)");
+ }
+
+ ///
+ public ConsoleColor ForegroundColor
+ {
+ get => _fg;
+ set
+ {
+ _fg = value;
+ _ansi.SetFg256(MapColor256(value));
+ }
+ }
+
+ ///
+ public ConsoleColor BackgroundColor
+ {
+ get => _bg;
+ set
+ {
+ _bg = value;
+ _ansi.SetBg256(MapColor256(value));
+ }
+ }
+
+ ///
+ public bool IsOutputRedirected => false;
+
+ ///
+ public bool KeyAvailable
+ {
+ get
+ {
+ try
+ {
+ // Cooperative scheduling: Visuals uses short Thread.Sleep loops and then polls KeyAvailable.
+ // Yielding here avoids starving background I/O / socket processing on some runtimes.
+ Thread.Yield();
+ _ansi.Flush();
+ return _stream is NetworkStream ns && ns.DataAvailable;
+ }
+ catch
+ {
+ return true;
+ }
+ }
+ }
+
+ ///
+ public int LargestWindowHeight => _bufferHeight;
+
+ ///
+ public string Title
+ {
+ get => string.Empty;
+ set => _ansi.Write($"\x1b]0;{value}\x07");
+ }
+
+ ///
+ public int WindowHeight
+ {
+ get => _bufferHeight;
+ set { }
+ }
+
+ ///
+ public int WindowWidth
+ {
+ get => _bufferWidth;
+ set { }
+ }
+
+ ///
+ public bool CursorVisible
+ {
+ get => _cursorVisible;
+ set
+ {
+ _cursorVisible = value;
+ if (value)
+ {
+ _ansi.ShowCursor();
+ }
+ else
+ {
+ _ansi.HideCursor();
+ }
+ }
+ }
+
+ ///
+ public int BufferWidth => _bufferWidth;
+
+ ///
+ public int BufferHeight => _bufferHeight;
+
+ ///
+ public void Beep(int freq, int len) => _ansi.Write("\x07");
+
+ ///
+ public void Clear() => _ansi.Clear();
+
+ ///
+ public (int Left, int Top) GetCursorPosition() => (0, 0);
+
+ ///
+ public ConsoleKeyInfo? ReadKey()
+ {
+ try
+ {
+ // Cooperative scheduling: allow I/O/other work to run before potentially blocking.
+ _ansi.Flush();
+ Thread.Yield();
+ var info = _keys.ReadAsync(CancellationToken.None).GetAwaiter().GetResult();
+ return info.Key switch
+ {
+ AnsiKey.Up => new ConsoleKeyInfo('\0', ConsoleKey.UpArrow, false, false, false),
+ AnsiKey.Down => new ConsoleKeyInfo('\0', ConsoleKey.DownArrow, false, false, false),
+ AnsiKey.Left => new ConsoleKeyInfo('\0', ConsoleKey.LeftArrow, false, false, false),
+ AnsiKey.Right => new ConsoleKeyInfo('\0', ConsoleKey.RightArrow, false, false, false),
+ AnsiKey.Enter => new ConsoleKeyInfo('\n', ConsoleKey.Enter, false, false, false),
+ AnsiKey.Escape => new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false),
+ AnsiKey.Backspace => new ConsoleKeyInfo('\b', ConsoleKey.Backspace, false, false, false),
+ AnsiKey.Char => new ConsoleKeyInfo(info.Char, MapCharKey(info.Char), false, false, false),
+ _ => null,
+ };
+ }
+ catch (Exception ex)
+ {
+ _log?.Invoke($"ReadKey exception: {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ ///
+ public string ReadLine()
+ {
+ var sb = new System.Text.StringBuilder();
+
+ while (true)
+ {
+ var k = ReadKey();
+ if (k is null)
+ {
+ continue;
+ }
+
+ switch (k.Value.Key)
+ {
+ case ConsoleKey.Enter:
+ WriteLine();
+ return sb.ToString();
+
+ case ConsoleKey.Backspace:
+ if (sb.Length > 0)
+ {
+ sb.Length -= 1;
+ Write("\b \b");
+ }
+ break;
+
+ default:
+ var ch = k.Value.KeyChar;
+ if (ch == '\0' || char.IsControl(ch))
+ {
+ break;
+ }
+
+ sb.Append(ch);
+ Write(ch);
+ break;
+ }
+ }
+ }
+
+ ///
+ public void SetCursorPosition(int left, int top) => _ansi.MoveCursor(top + 1, left + 1);
+
+ ///
+ public void Write(char ch) => _ansi.Write(ch.ToString());
+
+ ///
+ public void Write(string? st)
+ {
+ if (st is null)
+ {
+ return;
+ }
+
+ _ansi.Write(st);
+ }
+
+ ///
+ public void WriteLine(string? st = "")
+ {
+ if (!string.IsNullOrEmpty(st))
+ {
+ Write(st);
+ }
+
+ _ansi.Write("\n");
+ }
+
+ private static int MapColor256(ConsoleColor color)
+ => color switch
+ {
+ ConsoleColor.Black => 16,
+ ConsoleColor.DarkBlue => 18,
+ ConsoleColor.DarkGreen => 22,
+ ConsoleColor.DarkCyan => 30,
+ ConsoleColor.DarkRed => 88,
+ ConsoleColor.DarkMagenta => 90,
+ ConsoleColor.DarkYellow => 94,
+ ConsoleColor.Gray => 245,
+ ConsoleColor.DarkGray => 240,
+ ConsoleColor.Blue => 21,
+ ConsoleColor.Green => 82,
+ ConsoleColor.Cyan => 51,
+ ConsoleColor.Red => 196,
+ ConsoleColor.Magenta => 201,
+ ConsoleColor.Yellow => 226,
+ ConsoleColor.White => 15,
+ _ => 15
+ };
+
+ private static ConsoleKey MapCharKey(char ch)
+ {
+ if (ch >= 'a' && ch <= 'z')
+ {
+ return (ConsoleKey)(ConsoleKey.A + (ch - 'a'));
+ }
+
+ if (ch >= 'A' && ch <= 'Z')
+ {
+ return (ConsoleKey)(ConsoleKey.A + (ch - 'A'));
+ }
+
+ if (ch >= '0' && ch <= '9')
+ {
+ return (ConsoleKey)(ConsoleKey.D0 + (ch - '0'));
+ }
+
+ return ConsoleKey.NoName;
+ }
+
+ public void ResetColor()
+ {
+ ForegroundColor = ConsoleColor.Gray;
+ BackgroundColor = ConsoleColor.Black;
+ }
+}
diff --git a/CSharpBible/Games/RemoteTerminal/RemoteTerminal.csproj b/CSharpBible/Games/RemoteTerminal/RemoteTerminal.csproj
new file mode 100644
index 000000000..471330685
--- /dev/null
+++ b/CSharpBible/Games/RemoteTerminal/RemoteTerminal.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Library
+ net6.0;net7.0;net8.0;net481;net48;net472;net462
+ AnyCPU;x64
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/Resources/Arkanoid.png b/CSharpBible/Games/Resources/Arkanoid.png
new file mode 100644
index 000000000..1e1fc8e95
Binary files /dev/null and b/CSharpBible/Games/Resources/Arkanoid.png differ
diff --git a/CSharpBible/Games/Resources/Asteroids.png b/CSharpBible/Games/Resources/Asteroids.png
new file mode 100644
index 000000000..abd3bdc26
Binary files /dev/null and b/CSharpBible/Games/Resources/Asteroids.png differ
diff --git a/CSharpBible/Games/Resources/SharpHack.Cons.png b/CSharpBible/Games/Resources/SharpHack.Cons.png
new file mode 100644
index 000000000..1148125d1
Binary files /dev/null and b/CSharpBible/Games/Resources/SharpHack.Cons.png differ
diff --git a/CSharpBible/Games/Resources/SharpHack.WPF.png b/CSharpBible/Games/Resources/SharpHack.WPF.png
new file mode 100644
index 000000000..b42c19b2f
Binary files /dev/null and b/CSharpBible/Games/Resources/SharpHack.WPF.png differ
diff --git a/CSharpBible/Games/Resources/Sokoban2.PNG b/CSharpBible/Games/Resources/Sokoban2.PNG
new file mode 100644
index 000000000..ad971834e
Binary files /dev/null and b/CSharpBible/Games/Resources/Sokoban2.PNG differ
diff --git a/CSharpBible/Games/Resources/Treppen.png b/CSharpBible/Games/Resources/Treppen.png
new file mode 100644
index 000000000..29b8b0cdd
Binary files /dev/null and b/CSharpBible/Games/Resources/Treppen.png differ
diff --git a/CSharpBible/Games/SharpHack.AI/SharpHack.AI.csproj b/CSharpBible/Games/SharpHack.AI/SharpHack.AI.csproj
new file mode 100644
index 000000000..388c0b5aa
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.AI/SharpHack.AI.csproj
@@ -0,0 +1,29 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+ Sokoban
+ AnyCPU;x64
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.AI/SimpleEnemyAI.cs b/CSharpBible/Games/SharpHack.AI/SimpleEnemyAI.cs
new file mode 100644
index 000000000..6ba2c5e4d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.AI/SimpleEnemyAI.cs
@@ -0,0 +1,45 @@
+using System;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.AI;
+
+public class SimpleEnemyAI : IEnemyAI
+{
+ public Point GetNextMove(ICreature enemy, ICreature target, IMap map)
+ {
+ int dx = target.Position.X - enemy.Position.X;
+ int dy = target.Position.Y - enemy.Position.Y;
+
+ int stepX = dx == 0 ? 0 : dx / Math.Abs(dx);
+ int stepY = dy == 0 ? 0 : dy / Math.Abs(dy);
+
+ // Try moving diagonally first if needed
+ if (stepX != 0 && stepY != 0)
+ {
+ var diagPos = new Point(enemy.Position.X + stepX, enemy.Position.Y + stepY);
+ if (IsWalkable(map, diagPos)) return diagPos;
+ }
+
+ // Try moving X
+ if (stepX != 0)
+ {
+ var xPos = new Point(enemy.Position.X + stepX, enemy.Position.Y);
+ if (IsWalkable(map, xPos)) return xPos;
+ }
+
+ // Try moving Y
+ if (stepY != 0)
+ {
+ var yPos = new Point(enemy.Position.X, enemy.Position.Y + stepY);
+ if (IsWalkable(map, yPos)) return yPos;
+ }
+
+ return enemy.Position;
+ }
+
+ private bool IsWalkable(IMap map, Point p)
+ {
+ return map.IsValid(p) && map[p].IsWalkable && map[p].Creature == null;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.AITests/SharpHack.AITests.csproj b/CSharpBible/Games/SharpHack.AITests/SharpHack.AITests.csproj
new file mode 100644
index 000000000..81b090d9d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.AITests/SharpHack.AITests.csproj
@@ -0,0 +1,36 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.AITests/SimpleEnemyAITests.cs b/CSharpBible/Games/SharpHack.AITests/SimpleEnemyAITests.cs
new file mode 100644
index 000000000..09b60a865
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.AITests/SimpleEnemyAITests.cs
@@ -0,0 +1,52 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.AI;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+
+namespace SharpHack.AITests;
+
+[TestClass]
+public class SimpleEnemyAITests
+{
+ [TestMethod]
+ public void GetNextMove_MovesTowardsTarget_XAxis()
+ {
+ var ai = new SimpleEnemyAI();
+ var enemy = new Creature { Position = new Point(1, 1) };
+ var target = new Creature { Position = new Point(3, 1) };
+ var map = new Map(10, 10);
+ map[2, 1].Type = TileType.Floor; // Path is clear
+
+ var nextPos = ai.GetNextMove(enemy, target, map);
+
+ Assert.AreEqual(new Point(2, 1), nextPos);
+ }
+
+ [TestMethod]
+ public void GetNextMove_MovesTowardsTarget_YAxis()
+ {
+ var ai = new SimpleEnemyAI();
+ var enemy = new Creature { Position = new Point(1, 1) };
+ var target = new Creature { Position = new Point(1, 3) };
+ var map = new Map(10, 10);
+ map[1, 2].Type = TileType.Floor; // Path is clear
+
+ var nextPos = ai.GetNextMove(enemy, target, map);
+
+ Assert.AreEqual(new Point(1, 2), nextPos);
+ }
+
+ [TestMethod]
+ public void GetNextMove_StaysPut_IfBlocked()
+ {
+ var ai = new SimpleEnemyAI();
+ var enemy = new Creature { Position = new Point(1, 1) };
+ var target = new Creature { Position = new Point(3, 1) };
+ var map = new Map(10, 10);
+ map[2, 1].Type = TileType.Wall; // Path is blocked
+
+ var nextPos = ai.GetNextMove(enemy, target, map);
+
+ Assert.AreEqual(new Point(1, 1), nextPos);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Data/TileType.cs b/CSharpBible/Games/SharpHack.Base/Data/TileType.cs
new file mode 100644
index 000000000..3281e4a93
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Data/TileType.cs
@@ -0,0 +1,13 @@
+namespace SharpHack.Base.Data;
+
+public enum TileType
+{
+ Empty,
+ Floor,
+ Room,
+ Wall,
+ DoorClosed,
+ DoorOpen,
+ StairsUp,
+ StairsDown
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IArmor.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IArmor.cs
new file mode 100644
index 000000000..439c13b56
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IArmor.cs
@@ -0,0 +1,6 @@
+namespace SharpHack.Base.Interfaces;
+
+public interface IArmor : IItem
+{
+ int DefenseBonus { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/ICombatSystem.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/ICombatSystem.cs
new file mode 100644
index 000000000..f11cdd1da
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/ICombatSystem.cs
@@ -0,0 +1,9 @@
+using System;
+using SharpHack.Base.Model;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface ICombatSystem
+{
+ void Attack(ICreature attacker, ICreature defender, Action onMessage);
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IContainerItem.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IContainerItem.cs
new file mode 100644
index 000000000..dfb7e7c0b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IContainerItem.cs
@@ -0,0 +1,9 @@
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface IContainerItem
+{
+ IList Items { get; }
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/ICreature.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/ICreature.cs
new file mode 100644
index 000000000..f7483912d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/ICreature.cs
@@ -0,0 +1,20 @@
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Interfaces
+{
+ public interface ICreature: IGameObject
+ {
+ int Attack { get; }
+ int BaseAttack { get; set; }
+ int BaseDefense { get; set; }
+ IArmor? Body { get; set; }
+ int Defense { get; }
+ int HP { get; set; }
+ List Inventory { get; }
+ IWeapon? MainHand { get; set; }
+ int MaxHP { get; set; }
+ int Speed { get; set; }
+ Point OldPosition { get; }
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IEnemyAI.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IEnemyAI.cs
new file mode 100644
index 000000000..c6abb8bd2
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IEnemyAI.cs
@@ -0,0 +1,8 @@
+using SharpHack.Base.Model;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface IEnemyAI
+{
+ Point GetNextMove(ICreature enemy, ICreature target, IMap map);
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IGameObject.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IGameObject.cs
new file mode 100644
index 000000000..859fe76e4
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IGameObject.cs
@@ -0,0 +1,14 @@
+using SharpHack.Base.Model;
+using System;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface IGameObject
+{
+ ConsoleColor Color { get; set; }
+ string Description { get; set; }
+ Guid Id { get; }
+ string Name { get; set; }
+ Point Position { get; set; }
+ char Symbol { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IGamePersist.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IGamePersist.cs
new file mode 100644
index 000000000..9241642ac
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IGamePersist.cs
@@ -0,0 +1,10 @@
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface IGamePersist
+{
+ bool LoadLevel(int level, out IMap map, out IList enemies);
+ void SaveLevel(int level, IMap map, ICreature player, IList enemies);
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IItem.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IItem.cs
new file mode 100644
index 000000000..90ddb52b6
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IItem.cs
@@ -0,0 +1,8 @@
+namespace SharpHack.Base.Interfaces;
+
+public interface IItem : IGameObject
+{
+ bool IsStackable { get; set; }
+ int Quantity { get; set; }
+ double Weight { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IMap.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IMap.cs
new file mode 100644
index 000000000..74d0c4790
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IMap.cs
@@ -0,0 +1,18 @@
+using SharpHack.Base.Model;
+using System.Collections;
+
+namespace SharpHack.Base.Interfaces
+{
+ public interface IMap : IEnumerable
+ {
+ Tile this[Point p] { get; }
+ Tile this[int x, int y] { get; }
+
+ int Height { get; }
+ int Width { get; }
+
+ (int X, int Y) GetOldPos(int x, int y);
+ bool IsValid(int x, int y);
+ bool IsValid(Point p);
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IMapGenerator.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IMapGenerator.cs
new file mode 100644
index 000000000..502e152a8
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IMapGenerator.cs
@@ -0,0 +1,8 @@
+using SharpHack.Base.Model;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface IMapGenerator
+{
+ IMap Generate(int width, int height, Point? startPos=null);
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/ITile.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/ITile.cs
new file mode 100644
index 000000000..8439a162a
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/ITile.cs
@@ -0,0 +1,18 @@
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Interfaces;
+
+public interface ITile
+{
+ ICreature? Creature { get; set; }
+ bool IsExplored { get; set; }
+ bool IsTransparent { get; }
+ bool IsVisible { get; set; }
+ bool IsWalkable { get; }
+ IList Items { get; }
+ Point OldPosition { get; }
+ Point Position { get; set; }
+ TileType Type { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Interfaces/IWeapon.cs b/CSharpBible/Games/SharpHack.Base/Interfaces/IWeapon.cs
new file mode 100644
index 000000000..aa1fd3ec1
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Interfaces/IWeapon.cs
@@ -0,0 +1,6 @@
+namespace SharpHack.Base.Interfaces;
+
+public interface IWeapon : IItem
+{
+ int AttackBonus { get; set; }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Base/Model/Creature.cs b/CSharpBible/Games/SharpHack.Base/Model/Creature.cs
new file mode 100644
index 000000000..98929a958
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/Creature.cs
@@ -0,0 +1,24 @@
+using SharpHack.Base.Interfaces;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Model;
+
+public class Creature : GameObject, ICreature
+{
+ public int HP { get; set; }
+ public int MaxHP { get; set; }
+
+ public int BaseAttack { get; set; }
+ public int BaseDefense { get; set; }
+
+ public int Attack => BaseAttack + (MainHand?.AttackBonus ?? 0);
+ public int Defense => BaseDefense + (Body?.DefenseBonus ?? 0);
+
+ public int Speed { get; set; }
+
+ public List Inventory { get; } = new();
+ public IWeapon? MainHand { get; set; }
+ public IArmor? Body { get; set; }
+
+ public Point OldPosition => _oldPosition;
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Model/Direction.cs b/CSharpBible/Games/SharpHack.Base/Model/Direction.cs
new file mode 100644
index 000000000..53ea4639d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/Direction.cs
@@ -0,0 +1,16 @@
+namespace SharpHack.Base.Model;
+
+public enum Direction
+{
+ None,
+ North,
+ East,
+ South,
+ West,
+ NorthEast,
+ SouthEast,
+ SouthWest,
+ NorthWest,
+ Up,
+ Down
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Model/GameObject.cs b/CSharpBible/Games/SharpHack.Base/Model/GameObject.cs
new file mode 100644
index 000000000..b7e9a971b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/GameObject.cs
@@ -0,0 +1,17 @@
+using SharpHack.Base.Interfaces;
+using System;
+
+namespace SharpHack.Base.Model;
+
+public abstract class GameObject : IGameObject
+{
+ public Guid Id { get; } = Guid.NewGuid();
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+
+ protected Point _oldPosition;
+
+ public Point Position { get=>field; set { _oldPosition = field;field = value; } }
+ public char Symbol { get; set; } = '?';
+ public ConsoleColor Color { get; set; } = ConsoleColor.White;
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Model/Map.cs b/CSharpBible/Games/SharpHack.Base/Model/Map.cs
new file mode 100644
index 000000000..d5a158325
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/Map.cs
@@ -0,0 +1,52 @@
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using System.Collections;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Model;
+
+public class Map : IMap
+{
+ public int Width { get; }
+ public int Height { get; }
+ private readonly Tile[,] _tiles;
+
+ public Map(int width, int height)
+ {
+ Width = width;
+ Height = height;
+ _tiles = new Tile[width, height];
+ for (int x = 0; x < width; x++)
+ {
+ for (int y = 0; y < height; y++)
+ {
+ _tiles[x, y] = new Tile { Position = new Point(x, y), Type = TileType.Empty };
+ }
+ }
+ }
+
+ public Tile this[int x, int y]
+ {
+ get
+ {
+ if (IsValid(x, y))
+ return _tiles[x, y];
+ return new Tile { Position = new Point(x, y), Type = TileType.Empty };
+ }
+ }
+
+ public Tile this[Point p] => this[p.X, p.Y];
+
+ public bool IsValid(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height;
+ public bool IsValid(Point p) => IsValid(p.X, p.Y);
+
+ public (int X, int Y) GetOldPos(int x, int y)
+ {
+ return _tiles[x, y].OldPosition;
+ }
+
+ public IEnumerator GetEnumerator()
+ {
+ return _tiles.GetEnumerator();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Model/Point.cs b/CSharpBible/Games/SharpHack.Base/Model/Point.cs
new file mode 100644
index 000000000..116a5284d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/Point.cs
@@ -0,0 +1,9 @@
+namespace SharpHack.Base.Model;
+
+public record struct Point(int X, int Y)
+{
+ public static Point Zero => new(0, 0);
+ public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
+ public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
+ public static implicit operator (int, int)(Point p) => (p.X, p.Y);
+}
diff --git a/CSharpBible/Games/SharpHack.Base/Model/Tile.cs b/CSharpBible/Games/SharpHack.Base/Model/Tile.cs
new file mode 100644
index 000000000..a9ce17046
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/Model/Tile.cs
@@ -0,0 +1,21 @@
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using System.Collections.Generic;
+
+namespace SharpHack.Base.Model;
+
+public class Tile : ITile
+{
+ public Point Position { get; set; }
+ public TileType Type { get; set; }
+ public IList Items { get; } = [];
+ public ICreature? Creature { get; set; }
+
+ public bool IsVisible { get; set; }
+ public bool IsExplored { get; set; }
+
+ public bool IsWalkable => Type is not TileType.Wall and not TileType.Empty and not TileType.DoorClosed;
+ public bool IsTransparent => Type is not TileType.Wall and not TileType.DoorClosed;
+
+ public Point OldPosition => Creature?.OldPosition ?? Position;
+}
diff --git a/CSharpBible/Games/SharpHack.Base/SharpHack.Base.csproj b/CSharpBible/Games/SharpHack.Base/SharpHack.Base.csproj
new file mode 100644
index 000000000..05017359d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Base/SharpHack.Base.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.BaseCreatures/SharpHack.BaseCreatures.csproj b/CSharpBible/Games/SharpHack.BaseCreatures/SharpHack.BaseCreatures.csproj
new file mode 100644
index 000000000..761cc8606
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseCreatures/SharpHack.BaseCreatures.csproj
@@ -0,0 +1,25 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.BaseItems/Model/Armor.cs b/CSharpBible/Games/SharpHack.BaseItems/Model/Armor.cs
new file mode 100644
index 000000000..f59caa8fa
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItems/Model/Armor.cs
@@ -0,0 +1,9 @@
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.BaseItems.Model;
+
+public class Armor : Item, IArmor
+{
+ public int DefenseBonus { get; set; }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseItems/Model/ContainerItem.cs b/CSharpBible/Games/SharpHack.BaseItems/Model/ContainerItem.cs
new file mode 100644
index 000000000..544a80a10
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItems/Model/ContainerItem.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using SharpHack.Base.Interfaces;
+
+namespace SharpHack.Base.Model;
+
+public abstract class ContainerItem : Item, IContainerItem
+{
+ private readonly IList _items = [];
+
+ public IList Items => _items;
+}
diff --git a/CSharpBible/Games/SharpHack.BaseItems/Model/Item.cs b/CSharpBible/Games/SharpHack.BaseItems/Model/Item.cs
new file mode 100644
index 000000000..6b408b31c
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItems/Model/Item.cs
@@ -0,0 +1,10 @@
+using SharpHack.Base.Interfaces;
+
+namespace SharpHack.Base.Model;
+
+public class Item : GameObject, IItem
+{
+ public double Weight { get; set; }
+ public bool IsStackable { get; set; }
+ public int Quantity { get; set; } = 1;
+}
diff --git a/CSharpBible/Games/SharpHack.BaseItems/Model/Weapon.cs b/CSharpBible/Games/SharpHack.BaseItems/Model/Weapon.cs
new file mode 100644
index 000000000..2f42449f9
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItems/Model/Weapon.cs
@@ -0,0 +1,8 @@
+using SharpHack.Base.Interfaces;
+
+namespace SharpHack.Base.Model;
+
+public class Weapon : Item, IWeapon
+{
+ public int AttackBonus { get; set; }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseItems/SharpHack.BaseItems.csproj b/CSharpBible/Games/SharpHack.BaseItems/SharpHack.BaseItems.csproj
new file mode 100644
index 000000000..a7baa2e57
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItems/SharpHack.BaseItems.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.BaseItemsTests/Model/ArmorTests.cs b/CSharpBible/Games/SharpHack.BaseItemsTests/Model/ArmorTests.cs
new file mode 100644
index 000000000..7a65f35d1
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItemsTests/Model/ArmorTests.cs
@@ -0,0 +1,23 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Model;
+using SharpHack.BaseItems.Model;
+
+namespace SharpHack.BaseTests.Model;
+
+[TestClass]
+public class ArmorTests
+{
+ [TestMethod]
+ public void DefenseBonus_Default_IsZero()
+ {
+ var sut = new Armor();
+ Assert.AreEqual(0, sut.DefenseBonus);
+ }
+
+ [TestMethod]
+ public void DefenseBonus_CanBeSet_AndReadBack()
+ {
+ var sut = new Armor { DefenseBonus = 7 };
+ Assert.AreEqual(7, sut.DefenseBonus);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseItemsTests/SharpHack.BaseItemsTests.csproj b/CSharpBible/Games/SharpHack.BaseItemsTests/SharpHack.BaseItemsTests.csproj
new file mode 100644
index 000000000..d561581a1
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseItemsTests/SharpHack.BaseItemsTests.csproj
@@ -0,0 +1,43 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Libraries/MVVM_BaseLib/View/.info b/CSharpBible/Games/SharpHack.BaseTests/Model/ArmorTests.cs
similarity index 100%
rename from CSharpBible/Libraries/MVVM_BaseLib/View/.info
rename to CSharpBible/Games/SharpHack.BaseTests/Model/ArmorTests.cs
diff --git a/CSharpBible/Games/SharpHack.BaseTests/Model/CreatureTests.cs b/CSharpBible/Games/SharpHack.BaseTests/Model/CreatureTests.cs
new file mode 100644
index 000000000..114da73db
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseTests/Model/CreatureTests.cs
@@ -0,0 +1,34 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using NSubstitute;
+
+namespace SharpHack.BaseTests.Model;
+
+[TestClass]
+public class CreatureTests
+{
+ [TestMethod]
+ public void Attack_IncludesWeaponBonus()
+ {
+ var creature = new Creature { BaseAttack = 10 };
+ var weapon = Substitute.For();
+ weapon.AttackBonus.Returns(5);
+
+ creature.MainHand = weapon;
+
+ Assert.AreEqual(15, creature.Attack);
+ }
+
+ [TestMethod]
+ public void Defense_IncludesArmorBonus()
+ {
+ var creature = new Creature { BaseDefense = 2 };
+ var armor = Substitute.For();
+ armor.DefenseBonus.Returns(3);
+
+ creature.Body = armor;
+
+ Assert.AreEqual(5, creature.Defense);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseTests/Model/MapTests.cs b/CSharpBible/Games/SharpHack.BaseTests/Model/MapTests.cs
new file mode 100644
index 000000000..49c8c389e
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseTests/Model/MapTests.cs
@@ -0,0 +1,61 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+
+namespace SharpHack.BaseTests.Model;
+
+[TestClass]
+public class MapTests
+{
+ [TestMethod]
+ public void Constructor_InitializesDimensions()
+ {
+ var map = new Map(10, 20);
+ Assert.AreEqual(10, map.Width);
+ Assert.AreEqual(20, map.Height);
+ }
+
+ [TestMethod]
+ public void Constructor_InitializesEmptyTiles()
+ {
+ var map = new Map(5, 5);
+ for (int x = 0; x < 5; x++)
+ {
+ for (int y = 0; y < 5; y++)
+ {
+ Assert.AreEqual(TileType.Empty, map[x, y].Type);
+ Assert.AreEqual(new Point(x, y), map[x, y].Position);
+ }
+ }
+ }
+
+ [TestMethod]
+ public void IsValid_ReturnsTrueForValidCoordinates()
+ {
+ var map = new Map(10, 10);
+ Assert.IsTrue(map.IsValid(0, 0));
+ Assert.IsTrue(map.IsValid(9, 9));
+ Assert.IsTrue(map.IsValid(new Point(5, 5)));
+ }
+
+ [TestMethod]
+ public void IsValid_ReturnsFalseForInvalidCoordinates()
+ {
+ var map = new Map(10, 10);
+ Assert.IsFalse(map.IsValid(-1, 0));
+ Assert.IsFalse(map.IsValid(0, -1));
+ Assert.IsFalse(map.IsValid(10, 0));
+ Assert.IsFalse(map.IsValid(0, 10));
+ }
+
+ [TestMethod]
+ public void Indexer_ReturnsEmptyTileForOutOfBounds()
+ {
+ var map = new Map(10, 10);
+ var tile = map[100, 100];
+ Assert.IsNotNull(tile);
+ Assert.AreEqual(TileType.Empty, tile.Type);
+ // Note: The current implementation returns a new tile with the requested coordinates
+ Assert.AreEqual(100, tile.Position.X);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseTests/Model/PointTests.cs b/CSharpBible/Games/SharpHack.BaseTests/Model/PointTests.cs
new file mode 100644
index 000000000..500ee2c05
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseTests/Model/PointTests.cs
@@ -0,0 +1,47 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Model;
+
+namespace SharpHack.BaseTests.Model;
+
+[TestClass]
+public class PointTests
+{
+ [TestMethod]
+ public void Zero_ReturnsZeroPoint()
+ {
+ var p = Point.Zero;
+ Assert.AreEqual(0, p.X);
+ Assert.AreEqual(0, p.Y);
+ }
+
+ [TestMethod]
+ public void OperatorPlus_AddsCoordinates()
+ {
+ var p1 = new Point(1, 2);
+ var p2 = new Point(3, 4);
+ var result = p1 + p2;
+ Assert.AreEqual(4, result.X);
+ Assert.AreEqual(6, result.Y);
+ }
+
+ [TestMethod]
+ public void OperatorMinus_SubtractsCoordinates()
+ {
+ var p1 = new Point(5, 5);
+ var p2 = new Point(2, 3);
+ var result = p1 - p2;
+ Assert.AreEqual(3, result.X);
+ Assert.AreEqual(2, result.Y);
+ }
+
+ [TestMethod]
+ public void Equality_WorksCorrectly()
+ {
+ var p1 = new Point(1, 2);
+ var p2 = new Point(1, 2);
+ var p3 = new Point(3, 4);
+
+ Assert.AreEqual(p1, p2);
+ Assert.AreNotEqual(p1, p3);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.BaseTests/SharpHack.BaseTests.csproj b/CSharpBible/Games/SharpHack.BaseTests/SharpHack.BaseTests.csproj
new file mode 100644
index 000000000..5576323fe
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.BaseTests/SharpHack.BaseTests.csproj
@@ -0,0 +1,44 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Combat/SharpHack.Combat.csproj b/CSharpBible/Games/SharpHack.Combat/SharpHack.Combat.csproj
new file mode 100644
index 000000000..1a62f0f72
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Combat/SharpHack.Combat.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Combat/SimpleCombatSystem.cs b/CSharpBible/Games/SharpHack.Combat/SimpleCombatSystem.cs
new file mode 100644
index 000000000..de8bcf3ab
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Combat/SimpleCombatSystem.cs
@@ -0,0 +1,21 @@
+using System;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.Combat;
+
+public class SimpleCombatSystem : ICombatSystem
+{
+ public void Attack(ICreature attacker, ICreature defender, Action onMessage)
+ {
+ int damage = Math.Max(0, attacker.Attack - defender.Defense);
+ defender.HP -= damage;
+
+ onMessage?.Invoke($"{attacker.Name} attacks {defender.Name} for {damage} damage.");
+
+ if (defender.HP <= 0)
+ {
+ onMessage?.Invoke($"{defender.Name} dies!");
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.CombatTests/SharpHack.CombatTests.csproj b/CSharpBible/Games/SharpHack.CombatTests/SharpHack.CombatTests.csproj
new file mode 100644
index 000000000..960851cb0
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.CombatTests/SharpHack.CombatTests.csproj
@@ -0,0 +1,43 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.CombatTests/SimpleCombatSystemTests.cs b/CSharpBible/Games/SharpHack.CombatTests/SimpleCombatSystemTests.cs
new file mode 100644
index 000000000..ab31bf15d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.CombatTests/SimpleCombatSystemTests.cs
@@ -0,0 +1,39 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Model;
+using SharpHack.Combat;
+using System.Collections.Generic;
+
+namespace SharpHack.CombatTests;
+
+[TestClass]
+public class SimpleCombatSystemTests
+{
+ [TestMethod]
+ public void Attack_DealsCorrectDamage()
+ {
+ var system = new SimpleCombatSystem();
+ var attacker = new Creature { Name = "Attacker", BaseAttack = 10 }; // Changed Attack to BaseAttack
+ var defender = new Creature { Name = "Defender", BaseDefense = 2, HP = 20 }; // Changed Defense to BaseDefense
+ var messages = new List();
+
+ system.Attack(attacker, defender, msg => messages.Add(msg));
+
+ Assert.AreEqual(12, defender.HP); // 20 - (10 - 2) = 12
+ Assert.IsNotEmpty(messages);
+ Assert.Contains("8 damage", messages[0]);
+ }
+
+ [TestMethod]
+ public void Attack_ReportsDeath()
+ {
+ var system = new SimpleCombatSystem();
+ var attacker = new Creature { Name = "Attacker", BaseAttack = 10 }; // Changed Attack to BaseAttack
+ var defender = new Creature { Name = "Defender", BaseDefense = 0, HP = 5 }; // Changed Defense to BaseDefense
+ var messages = new List();
+
+ system.Attack(attacker, defender, msg => messages.Add(msg));
+
+ Assert.IsLessThanOrEqualTo(0, defender.HP);
+ Assert.IsTrue(messages.Exists(m => m.Contains("dies")));
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/Application/ConsoleGameApp.cs b/CSharpBible/Games/SharpHack.Console/Application/ConsoleGameApp.cs
new file mode 100644
index 000000000..a4fc0b9ce
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Application/ConsoleGameApp.cs
@@ -0,0 +1,114 @@
+using System;
+using BaseLib.Interfaces;
+using BaseLib.Models;
+using ConsoleDisplay.View;
+using SharpHack.Base.Data;
+using Direction = SharpHack.Base.Model.Direction;
+using SharpHack.ViewModel;
+
+namespace SharpHack.Console;
+
+///
+/// Coordinates the console game's life cycle.
+///
+public sealed class ConsoleGameApp
+{
+ private readonly IConsole _console;
+ private readonly GameViewModel _viewModel;
+ private readonly TileDisplay _tileDisplay;
+ private readonly Display _miniMap;
+ private readonly GameRenderer _renderer;
+ private readonly NavigationPrompt _navigationPrompt;
+
+ private bool _autoDoorOpen = true;
+
+ public ConsoleGameApp() : this(new ConsoleProxy())
+ {
+ }
+
+ public ConsoleGameApp(IConsole console)
+ {
+ _console = console ?? throw new ArgumentNullException(nameof(console));
+ var setup = new GameSetup();
+ var context = setup.Create(_console);
+ _viewModel = context.ViewModel;
+ _tileDisplay = context.TileDisplay;
+ _miniMap = context.MiniMap;
+
+ _renderer = new GameRenderer(_console, _viewModel, _tileDisplay, _miniMap, () => _autoDoorOpen);
+ _navigationPrompt = new NavigationPrompt(_console, _viewModel, _renderer);
+
+ _tileDisplay.FncGetTile = _renderer.GetTileAt;
+ _viewModel.AutoDoorOpen = _autoDoorOpen;
+ }
+
+ ///
+ /// Runs the main game loop.
+ ///
+ public void Run()
+ {
+ _console.CursorVisible = false;
+ _console.Title = "SharpHack";
+
+ var running = true;
+ while (running)
+ {
+ _renderer.Render();
+ var key = _console.ReadKey()?.Key ?? ConsoleKey.NoName;
+ running = HandleInput(key);
+ }
+ }
+
+ private bool HandleInput(ConsoleKey key)
+ {
+ switch (key)
+ {
+ case ConsoleKey.UpArrow: _viewModel.Move(Direction.North); break;
+ case ConsoleKey.DownArrow: _viewModel.Move(Direction.South); break;
+ case ConsoleKey.LeftArrow: _viewModel.Move(Direction.West); break;
+ case ConsoleKey.RightArrow: _viewModel.Move(Direction.East); break;
+ case ConsoleKey.NumPad7: _viewModel.Move(Direction.NorthWest); break;
+ case ConsoleKey.NumPad9: _viewModel.Move(Direction.NorthEast); break;
+ case ConsoleKey.NumPad1: _viewModel.Move(Direction.SouthWest); break;
+ case ConsoleKey.NumPad3: _viewModel.Move(Direction.SouthEast); break;
+ case ConsoleKey.NumPad5:
+ case ConsoleKey.OemPeriod:
+ _viewModel.Wait();
+ break;
+
+ case ConsoleKey.O:
+ _viewModel.OpenDoorNearby();
+ break;
+ case ConsoleKey.C:
+ _viewModel.CloseDoorNearby();
+ break;
+ case ConsoleKey.T:
+ _viewModel.ToggleDoorNearby();
+ break;
+
+ case ConsoleKey.A:
+ ToggleAutoDoor();
+ break;
+
+ case ConsoleKey.Enter:
+ _viewModel.ExecutePrimaryAction();
+ break;
+
+ case ConsoleKey.Escape:
+ return false;
+
+ case ConsoleKey.G:
+ _navigationPrompt.GoRelative();
+ break;
+ }
+
+ return true;
+ }
+
+ private void ToggleAutoDoor()
+ {
+ _autoDoorOpen = !_autoDoorOpen;
+ _viewModel.AutoDoorOpen = _autoDoorOpen;
+ _viewModel.Messages.Add($"AutoDoorOpen: {(_autoDoorOpen ? "ON" : "OFF")}");
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/Application/GameSetup.cs b/CSharpBible/Games/SharpHack.Console/Application/GameSetup.cs
new file mode 100644
index 000000000..1fe9d4346
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Application/GameSetup.cs
@@ -0,0 +1,63 @@
+using System;
+using BaseLib.Interfaces;
+using BaseLib.Models;
+using ConsoleDisplay.View;
+using SharpHack.AI;
+using SharpHack.Base.Data;
+using SharpHack.Combat;
+using SharpHack.Engine;
+using SharpHack.LevelGen.BSP;
+using SharpHack.Persist;
+using SharpHack.ViewModel;
+using DrawingPoint = System.Drawing.Point;
+using DrawingSize = System.Drawing.Size;
+
+namespace SharpHack.Console;
+
+///
+/// Creates the objects required to run the console game.
+///
+internal sealed class GameSetup
+{
+ ///
+ /// Builds the view model, tile display and mini map instances.
+ ///
+ public GameContext Create(IConsole console)
+ {
+ if (console == null)
+ {
+ throw new ArgumentNullException(nameof(console));
+ }
+
+ var random = new CRandom();
+ var mapGenerator = new BSPMapGenerator(random);
+ var combatSystem = new SimpleCombatSystem();
+ var enemyAI = new SimpleEnemyAI();
+ var gamePersist = new InMemoryGamePersist();
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ var viewModel = new GameViewModel(session);
+
+ var tileDef = new TileDefRes(".\\Resources\\Tiles4x2.tdj");
+ var viewWidth = 70 / tileDef.TileSize.Width;
+ var viewHeight = 20 / tileDef.TileSize.Height;
+ viewModel.SetViewSize(viewWidth, viewHeight);
+
+ var tileDisplay = new TileDisplay(console, tileDef, DrawingPoint.Empty,
+ new DrawingSize(viewWidth, viewHeight), tileDef.TileSize);
+ TileDisplay.defaultTile = DisplayTile.Empty;
+
+ var miniMap = new Display(console, 60, 21, 20, 12);
+
+ return new GameContext(viewModel, tileDisplay, tileDef, miniMap);
+ }
+}
+
+///
+/// Bundles the objects produced by .
+///
+internal sealed record GameContext(
+ GameViewModel ViewModel,
+ TileDisplay TileDisplay,
+ ITileDef TileDefinition,
+ Display MiniMap);
diff --git a/CSharpBible/Games/SharpHack.Console/Input/NavigationPrompt.cs b/CSharpBible/Games/SharpHack.Console/Input/NavigationPrompt.cs
new file mode 100644
index 000000000..87226360f
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Input/NavigationPrompt.cs
@@ -0,0 +1,89 @@
+using System;
+using BaseLib.Interfaces;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+using SharpHack.ViewModel;
+
+namespace SharpHack.Console;
+
+///
+/// Handles navigation related prompts and commands.
+///
+internal sealed class NavigationPrompt
+{
+ private readonly IConsole _console;
+ private readonly GameViewModel _viewModel;
+ private readonly GameRenderer _renderer;
+
+ public NavigationPrompt(IConsole console, GameViewModel viewModel, GameRenderer renderer)
+ {
+ _console = console ?? throw new ArgumentNullException(nameof(console));
+ _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
+ _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
+ }
+
+ public void GoRelative()
+ {
+ var playerPos = _viewModel.Player.Position;
+ var (dx, dy) = ReadIntPairAtHud($"GoTo (dx,dy) from [{playerPos.X},{playerPos.Y}]: ");
+ var target = new Point(playerPos.X + dx, playerPos.Y + dy);
+
+ if (!_viewModel.Map.IsValid(target))
+ {
+ _viewModel.Messages.Add("Target is outside of the map.");
+ return;
+ }
+
+ _viewModel.GoToWorldAsync(target).GetAwaiter().GetResult();
+ }
+
+ private (int dx, int dy) ReadIntPairAtHud(string prompt)
+ {
+ var previousVisibility = _console.CursorVisible;
+ var previousFore = _console.ForegroundColor;
+ var previousBack = _console.BackgroundColor;
+
+ try
+ {
+ _console.CursorVisible = true;
+ _console.ForegroundColor = ConsoleColor.White;
+ _console.BackgroundColor = ConsoleColor.Black;
+
+ while (true)
+ {
+ _renderer.Render();
+
+ _console.SetCursorPosition(0, ConsoleHudLayout.HudY + 1);
+ _console.Write(new string(' ', 79));
+ _console.SetCursorPosition(0, ConsoleHudLayout.HudY + 1);
+ _console.Write(prompt);
+
+ var inputX = Math.Min(_console.BufferWidth - 1, prompt.Length);
+ _console.SetCursorPosition(inputX, ConsoleHudLayout.HudY + 1);
+
+ var input = _console.ReadLine();
+ if (string.IsNullOrWhiteSpace(input))
+ {
+ continue;
+ }
+
+ var parts = input.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2)
+ {
+ continue;
+ }
+
+ if (int.TryParse(parts[0], out var dx) && int.TryParse(parts[1], out var dy))
+ {
+ return (dx, dy);
+ }
+ }
+ }
+ finally
+ {
+ _console.ForegroundColor = previousFore;
+ _console.BackgroundColor = previousBack;
+ _console.CursorVisible = previousVisibility;
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/Program.cs b/CSharpBible/Games/SharpHack.Console/Program.cs
new file mode 100644
index 000000000..8bf7afc19
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Program.cs
@@ -0,0 +1,10 @@
+namespace SharpHack.Console;
+
+public static class Program
+{
+ public static void Main(string[] args)
+ {
+ var app = new ConsoleGameApp();
+ app.Run();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/Rendering/GameRenderer.cs b/CSharpBible/Games/SharpHack.Console/Rendering/GameRenderer.cs
new file mode 100644
index 000000000..39e8e0c76
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Rendering/GameRenderer.cs
@@ -0,0 +1,144 @@
+using System;
+using BaseLib.Interfaces;
+using ConsoleDisplay.View;
+using SharpHack.ViewModel;
+using DrawingPoint = System.Drawing.Point;
+
+namespace SharpHack.Console;
+
+///
+/// Handles all console rendering.
+///
+internal sealed class GameRenderer
+{
+ private readonly IConsole _console;
+ private readonly GameViewModel _viewModel;
+ private readonly TileDisplay _tileDisplay;
+ private readonly Display _miniMap;
+ private readonly Func _autoDoorOpenAccessor;
+ private string? _lastPrimaryHint;
+
+ public GameRenderer(
+ IConsole console,
+ GameViewModel viewModel,
+ TileDisplay tileDisplay,
+ Display miniMap,
+ Func autoDoorOpenAccessor)
+ {
+ _console = console ?? throw new ArgumentNullException(nameof(console));
+ _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
+ _tileDisplay = tileDisplay ?? throw new ArgumentNullException(nameof(tileDisplay));
+ _miniMap = miniMap ?? throw new ArgumentNullException(nameof(miniMap));
+ _autoDoorOpenAccessor = autoDoorOpenAccessor ?? throw new ArgumentNullException(nameof(autoDoorOpenAccessor));
+ }
+
+ ///
+ /// Updates the tile display and HUD.
+ ///
+ public void Render()
+ {
+ _tileDisplay.Update(false);
+ UpdatePrimaryActionHint();
+ DrawHud();
+ DrawMiniMap();
+ }
+
+ ///
+ /// Provides tiles for the tile display.
+ ///
+ public DisplayTile GetTileAt(DrawingPoint point)
+ {
+ if (point.X < 0 || point.Y < 0 || point.X >= _viewModel.ViewWidth || point.Y >= _viewModel.ViewHeight)
+ {
+ return DisplayTile.Empty;
+ }
+
+ var index = point.Y * _viewModel.ViewWidth + point.X;
+ return _viewModel.DisplayTiles[index];
+ }
+
+ private void DrawHud()
+ {
+ _console.ForegroundColor = ConsoleColor.White;
+ _console.SetCursorPosition(0, ConsoleHudLayout.HudY);
+ _console.Write($"HP: {_viewModel.HP}/{_viewModel.MaxHP} Lvl: {_viewModel.Level} AutoDoorOpen: {(_autoDoorOpenAccessor() ? "ON" : "OFF")} ");
+ _console.Write(new string(' ', 50));
+
+ _console.SetCursorPosition(0, ConsoleHudLayout.HudY + 1);
+ if (_viewModel.Messages.Count > 0)
+ {
+ var lastMessage = _viewModel.Messages[^1];
+ _console.Write(lastMessage.PadRight(59));
+ }
+ else
+ {
+ _console.Write(new string(' ', 59));
+ }
+ }
+
+ private void UpdatePrimaryActionHint()
+ {
+ var hint = _viewModel.PrimaryActionHint;
+ if (hint == _lastPrimaryHint || hint == null)
+ {
+ return;
+ }
+
+ _lastPrimaryHint = hint;
+ _viewModel.Messages.Add(hint);
+ }
+
+ private void DrawMiniMap()
+ {
+ var ratioX = ((_viewModel.Map.Width - 1) / _miniMap.dSize.Width) + 1;
+ var ratioY = ((_viewModel.Map.Height - 1) / _miniMap.dSize.Height) + 1;
+ var mm = _viewModel.MiniMap;
+
+ for (var x = 0; x < _miniMap.dSize.Width; x++)
+ {
+ for (var y = 0; y < _miniMap.dSize.Height; y++)
+ {
+ var xMonster = false;
+ var xWay = false;
+ var xItem = false;
+ var xExplored = false;
+ var xPlayer = false;
+ for (var xx = 0; xx < ratioX; xx++)
+ {
+ for (var yy = 0; yy < ratioY; yy++)
+ {
+ var mapX = x * ratioX + xx;
+ var mapY = y * ratioY + yy;
+ if (mapX >= _viewModel.Map.Width || mapY >= _viewModel.Map.Height)
+ {
+ continue;
+ }
+
+ var tile = mm[mapY * _viewModel.Map.Width + mapX];
+ xPlayer |= (tile & 0x80) != 0;
+ xMonster |= (tile & 0x40) != 0;
+ xWay |= (tile & 0x4) != 0;
+ xItem |= (tile & 0x2) != 0;
+ xExplored |= (tile & 0x1) != 0;
+ }
+ }
+
+ var color = ConsoleColor.Black;
+ if (xPlayer)
+ color = ConsoleColor.Yellow;
+ else if (xMonster)
+ color = ConsoleColor.Red;
+ else if (xWay)
+ color = ConsoleColor.Gray;
+ else if (xItem)
+ color = ConsoleColor.Green;
+ else if (xExplored)
+ color = ConsoleColor.DarkGray;
+
+ _miniMap.PutPixel(x, y, color);
+ }
+ }
+
+ _miniMap.Update();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x1.tdj b/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x1.tdj
new file mode 100644
index 000000000..523248511
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x1.tdj
@@ -0,0 +1,360 @@
+{
+ "Item1": null,
+ "Item2": {
+ "IsEmpty": false,
+ "Width": 1,
+ "Height": 1
+ },
+ "Item3": [
+ {
+ "Item1": 60,
+ "Item2": {
+ "lines": [
+ "g"
+ ],
+ "colors": [
+ {
+ "fgr": 10,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 335,
+ "Item2": {
+ "lines": [
+ "@"
+ ],
+ "colors": [
+ {
+ "fgr": 14,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 432,
+ "Item2": {
+ "lines": [
+ "/"
+ ],
+ "colors": [
+ {
+ "fgr": 11,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 509,
+ "Item2": {
+ "lines": [
+ "["
+ ],
+ "colors": [
+ {
+ "fgr": 11,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 829,
+ "Item2": {
+ "lines": [
+ " "
+ ],
+ "colors": [
+ {
+ "fgr": 0,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 830,
+ "Item2": {
+ "lines": [
+ "|"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 831,
+ "Item2": {
+ "lines": [
+ "="
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 832,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 833,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 834,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 835,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 836,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 837,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 838,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 839,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 840,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 841,
+ "Item2": {
+ "lines": [
+ "\u002B"
+ ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 842,
+ "Item2": {
+ "lines": [
+ "/"
+ ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 843,
+ "Item2": {
+ "lines": [
+ "/"
+ ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 844,
+ "Item2": {
+ "lines": [
+ "|"
+ ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 845,
+ "Item2": {
+ "lines": [
+ "="
+ ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 848,
+ "Item2": {
+ "lines": [
+ "."
+ ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 849,
+ "Item2": {
+ "lines": [
+ "."
+ ],
+ "colors": [
+ {
+ "fgr": 8,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 851,
+ "Item2": {
+ "lines": [
+ "\u003C"
+ ],
+ "colors": [
+ {
+ "fgr": 15,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 852,
+ "Item2": {
+ "lines": [
+ "\u003E"
+ ],
+ "colors": [
+ {
+ "fgr": 15,
+ "bgr": 0
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x2.tdj b/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x2.tdj
new file mode 100644
index 000000000..9f0450f08
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Resources/Tiles1x2.tdj
@@ -0,0 +1,394 @@
+{
+ "Item1": "SharpHack.ViewModel.DisplayTile, VTileEditTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null",
+ "Item2": {
+ "IsEmpty": false,
+ "Width": 2,
+ "Height": 1
+ },
+ "Item3": [
+ {
+ "Item1": 60,
+ "Item2": {
+ "lines": [ "g " ],
+ "colors": [
+ {
+ "fgr": 10,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 335,
+ "Item2": {
+ "lines": [ "@ " ],
+ "colors": [
+ {
+ "fgr": 14,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 432,
+ "Item2": {
+ "lines": [ "/ " ],
+ "colors": [
+ {
+ "fgr": 11,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 509,
+ "Item2": {
+ "lines": [ "\u2320{" ],
+ "colors": [
+ {
+ "fgr": 4,
+ "bgr": 0
+ },
+ {
+ "fgr": 4,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 829,
+ "Item2": {
+ "lines": [ " " ],
+ "colors": [
+ {
+ "fgr": 0,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 830,
+ "Item2": {
+ "lines": [ "\u2502\u2502" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 831,
+ "Item2": {
+ "lines": [ "\u2550\u2550" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 832,
+ "Item2": {
+ "lines": [ "\u2552\u2564" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 833,
+ "Item2": {
+ "lines": [ "\u2564\u2555" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 834,
+ "Item2": {
+ "lines": [ "\u2558\u2567" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 835,
+ "Item2": {
+ "lines": [ "\u2567\u255B" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 836,
+ "Item2": {
+ "lines": [ "\u2561\u255E" ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 837,
+ "Item2": {
+ "lines": [ "\u002B " ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 838,
+ "Item2": {
+ "lines": [ "\u002B " ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 839,
+ "Item2": {
+ "lines": [ "\u002B " ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 840,
+ "Item2": {
+ "lines": [ "\u002B " ],
+ "colors": [
+ {
+ "fgr": 7,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 842,
+ "Item2": {
+ "lines": [ "##" ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ },
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 843,
+ "Item2": {
+ "lines": [ "##" ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ },
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 844,
+ "Item2": {
+ "lines": [ "\u2551\u00AC" ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ },
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 845,
+ "Item2": {
+ "lines": [ "//" ],
+ "colors": [
+ {
+ "fgr": 6,
+ "bgr": 0
+ },
+ {
+ "fgr": 6,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 848,
+ "Item2": {
+ "lines": [ ". " ],
+ "colors": [
+ {
+ "fgr": 8,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 849,
+ "Item2": {
+ "lines": [ ". " ],
+ "colors": [
+ {
+ "fgr": 8,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 851,
+ "Item2": {
+ "lines": [ "\u003C\u00AB" ],
+ "colors": [
+ {
+ "fgr": 15,
+ "bgr": 0
+ },
+ {
+ "fgr": 7,
+ "bgr": 0
+ }
+ ]
+ }
+ },
+ {
+ "Item1": 852,
+ "Item2": {
+ "lines": [ "\u003E]" ],
+ "colors": [
+ {
+ "fgr": 15,
+ "bgr": 0
+ },
+ {
+ "fgr": 15,
+ "bgr": 0
+ }
+ ]
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Console/Resources/Tiles4x2.tdj b/CSharpBible/Games/SharpHack.Console/Resources/Tiles4x2.tdj
new file mode 100644
index 000000000..b33bb050b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Resources/Tiles4x2.tdj
@@ -0,0 +1 @@
+{"Item1":"SharpHack.ViewModel.DisplayTile, VTileEditTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null","Item2":{"IsEmpty":false,"Width":4,"Height":2},"Item3":[{"Item1":60,"Item2":{"lines":["(\u00F2\u00F3)","{\u2321\u2321}"],"colors":[{"fgr":10,"bgr":0},{"fgr":12,"bgr":0},{"fgr":12,"bgr":0},{"fgr":10,"bgr":0},{"fgr":10,"bgr":0},{"fgr":10,"bgr":0},{"fgr":10,"bgr":0},{"fgr":10,"bgr":0}]}},{"Item1":335,"Item2":{"lines":["\u25C4\u0398\u0022)","/\u2320\u2321\\"],"colors":[{"fgr":14,"bgr":0},{"fgr":14,"bgr":0},{"fgr":4,"bgr":14},{"fgr":14,"bgr":0},{"fgr":14,"bgr":0},{"fgr":14,"bgr":1},{"fgr":14,"bgr":1},{"fgr":14,"bgr":0}]}},{"Item1":432,"Item2":{"lines":[" / "," / "],"colors":[{"fgr":11,"bgr":0},{"fgr":11,"bgr":0},{"fgr":14,"bgr":0},{"fgr":11,"bgr":0},{"fgr":11,"bgr":0},{"fgr":14,"bgr":0},{"fgr":11,"bgr":0},{"fgr":11,"bgr":0}]}},{"Item1":509,"Item2":{"lines":["(__)","[\u2592\u2592]"],"colors":[{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0},{"fgr":4,"bgr":0}]}},{"Item1":829,"Item2":{"lines":[" "," "],"colors":[{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0},{"fgr":0,"bgr":0}]}},{"Item1":830,"Item2":{"lines":["\u2502 \u2502","\u2502 \u2502"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":831,"Item2":{"lines":["\u2500\u2500\u2500\u2500","\u2500\u2500\u2500\u2500"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":832,"Item2":{"lines":["\u250C\u2500\u2500\u2500","\u2502 \u250C"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":833,"Item2":{"lines":["\u2500\u2500\u2500\u2510","\u2510 \u2502"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":834,"Item2":{"lines":["\u2502 \u2514","\u2514\u2500\u2500\u2500"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":835,"Item2":{"lines":["\u2518 \u2502","\u2500\u2500\u2500\u2518"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":836,"Item2":{"lines":["\u2518 \u2514","\u2510 \u250C"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":837,"Item2":{"lines":["\u002B \u002B","\u2500\u2500\u2500\u2500"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":838,"Item2":{"lines":["\u2500\u2500\u2500\u2500","\u002B \u002B"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":839,"Item2":{"lines":["\u002B \u2502","\u002B \u2502"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":840,"Item2":{"lines":["\u2502 \u002B","\u2502 \u002B"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":842,"Item2":{"lines":["\u002B\u002B\u002B\u002B","\u002B\u002B\u002B\u002B"],"colors":[{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0}]}},{"Item1":843,"Item2":{"lines":["////","////"],"colors":[{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0},{"fgr":6,"bgr":0}]}},{"Item1":848,"Item2":{"lines":[" . .",". . "],"colors":[{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0}]}},{"Item1":849,"Item2":{"lines":[" . ",". "],"colors":[{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0},{"fgr":8,"bgr":0}]}},{"Item1":851,"Item2":{"lines":[" //["," \\\\["],"colors":[{"fgr":15,"bgr":0},{"fgr":8,"bgr":0},{"fgr":7,"bgr":0},{"fgr":15,"bgr":0},{"fgr":15,"bgr":0},{"fgr":8,"bgr":0},{"fgr":7,"bgr":0},{"fgr":15,"bgr":0}]}},{"Item1":852,"Item2":{"lines":["]\u003E\u003E\u003E","]\u003E\u003E\u003E"],"colors":[{"fgr":15,"bgr":8},{"fgr":15,"bgr":0},{"fgr":7,"bgr":0},{"fgr":8,"bgr":0},{"fgr":15,"bgr":8},{"fgr":15,"bgr":0},{"fgr":7,"bgr":0},{"fgr":8,"bgr":0}]}},{"Item1":844,"Item2":{"lines":["\u2514\u2591\u2591\u2518","_\u2591\u2591_"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":4},{"fgr":7,"bgr":4},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":4},{"fgr":7,"bgr":4},{"fgr":7,"bgr":0}]}},{"Item1":845,"Item2":{"lines":["\u2500\u2591\u2591 ","\u2500\u2591\u2591 "],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":4},{"fgr":7,"bgr":4},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":4},{"fgr":7,"bgr":4},{"fgr":7,"bgr":0}]}}]}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Console/Resources/default.tdj b/CSharpBible/Games/SharpHack.Console/Resources/default.tdj
new file mode 100644
index 000000000..8474b1a95
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/Resources/default.tdj
@@ -0,0 +1 @@
+{"Item1":null,"Item2":{"IsEmpty":false,"Width":8,"Height":8},"Item3":[{"Item1":0,"Item2":{"lines":["#.#.#.#.",".#.#.#.#","#.#.#.#.",".#.#.#.#","#.#.#.#.",".#.#.#.#","#.#.#.#.",".#.#.#.#"],"colors":[{"fgr":0,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}},{"Item1":1,"Item2":{"lines":["\u002B\u002B\u002B\u002B\u002B\u002B\u002B\u002B","\u002B \u002B","\u002B \u002B","\u002B \u002B","\u002B \u002B","\u002B \u002B","\u002B \u002B","\u002B\u002B\u002B\u002B\u002B\u002B\u002B\u002B"],"colors":[{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0},{"fgr":7,"bgr":0}]}}]}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Console/SharpHack.Console.csproj b/CSharpBible/Games/SharpHack.Console/SharpHack.Console.csproj
new file mode 100644
index 000000000..94c87b3d0
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/SharpHack.Console.csproj
@@ -0,0 +1,33 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Console/SharpHackTileDef.cs b/CSharpBible/Games/SharpHack.Console/SharpHackTileDef.cs
new file mode 100644
index 000000000..a14a96d32
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/SharpHackTileDef.cs
@@ -0,0 +1,61 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using ConsoleDisplay.View;
+using SharpHack.ViewModel;
+
+namespace SharpHack.Console;
+
+public sealed class SharpHackTileDef : TileDefBase
+{
+ private static readonly (string[] lines, (ConsoleColor fgr, ConsoleColor bgr)[] colors) _empty = MakeDef(" ", ConsoleColor.Black, ConsoleColor.Black);
+
+ private static readonly Dictionary _defs = new()
+ {
+ [(int)DisplayTile.Archaeologist] = MakeDef("@", ConsoleColor.Yellow),
+ [(int)DisplayTile.Goblin] = MakeDef("g", ConsoleColor.Green),
+ [(int)DisplayTile.Wall_EW] = MakeDef("=", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_NS] = MakeDef("|", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_ES] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_WS] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_EN] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_NW] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_ENWS] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_ENW] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_EWS] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_NWS] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Wall_ENS] = MakeDef("+", ConsoleColor.Gray),
+ [(int)DisplayTile.Floor_Lit] = MakeDef(".", ConsoleColor.DarkGray),
+ [(int)DisplayTile.Floor_Dark] = MakeDef(".", ConsoleColor.DarkGray),
+ [(int)DisplayTile.Door_Closed_NS] = MakeDef("+", ConsoleColor.DarkYellow),
+ [(int)DisplayTile.Door_Open_NS] = MakeDef("/", ConsoleColor.DarkYellow),
+ [(int)DisplayTile.Stairs_Up] = MakeDef("<", ConsoleColor.White),
+ [(int)DisplayTile.Stairs_Down] = MakeDef(">", ConsoleColor.White),
+ [(int)DisplayTile.Sword] = MakeDef("/", ConsoleColor.Cyan),
+ [(int)DisplayTile.Armor] = MakeDef("[", ConsoleColor.Cyan)
+ };
+
+ public SharpHackTileDef()
+ {
+ TileSize = new Size(4, 2);
+ }
+
+ public override (string[] lines, (ConsoleColor fgr, ConsoleColor bgr)[] colors) GetTileDef(Enum? tile)
+ {
+ if (tile == null)
+ return _empty;
+
+ var key = Tile2Int(tile);
+ return _defs.TryGetValue(key, out var def) ? def : _empty;
+ }
+
+ private static (string[] lines, (ConsoleColor fgr, ConsoleColor bgr)[] colors) MakeDef(string symbol, ConsoleColor foreground, ConsoleColor? background = null)
+ {
+ var bg = background ?? ConsoleColor.Black;
+ var lines = new[] { new string(symbol[0], 4), new string(symbol[0], 4) };
+ var colors = new (ConsoleColor fgr, ConsoleColor bgr)[lines.Length * lines[0].Length];
+ for (int i = 0; i < colors.Length; i++)
+ colors[i] = (foreground, bg);
+ return (lines, colors);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Console/UI/ConsoleHudLayout.cs b/CSharpBible/Games/SharpHack.Console/UI/ConsoleHudLayout.cs
new file mode 100644
index 000000000..145f048df
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Console/UI/ConsoleHudLayout.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace SharpHack.Console;
+
+///
+/// Defines layout constants for the console HUD.
+///
+internal static class ConsoleHudLayout
+{
+ ///
+ /// Gets the top line of the HUD area.
+ ///
+ public const int HudY = 20;
+}
diff --git a/CSharpBible/Games/SharpHack.Engine/FieldOfView.cs b/CSharpBible/Games/SharpHack.Engine/FieldOfView.cs
new file mode 100644
index 000000000..ae3a6f290
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Engine/FieldOfView.cs
@@ -0,0 +1,167 @@
+using System;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.Engine;
+
+///
+/// Provides recursive shadow-casting visibility calculations for a given map instance.
+///
+public class FieldOfView
+{
+ private IMap _map;
+
+ ///
+ /// Initializes a new instance of the class bound to the supplied map.
+ ///
+ /// The map that will be queried and updated with visibility information.
+ public FieldOfView(IMap map)
+ {
+ Map = map;
+ }
+
+ ///
+ /// Gets or sets the map currently associated with this field-of-view calculator.
+ ///
+ public IMap Map { get => _map; set => _map = value; } // Make setter public to update reference on level change
+
+ ///
+ /// Computes visibility information for all tiles within the provided range around the origin.
+ ///
+ /// The tile used as the center of vision.
+ /// The maximum distance, measured as a circular radius, that can be seen.
+ ///
+ /// The method clears visibility flags for the entire map, marks the origin as visible, then traverses all octants
+ /// via recursive shadow casting to determine which tiles become visible and explored.
+ ///
+ public void Compute(Point origin, int range)
+ {
+ // Reset visibility
+ for (int x = 0; x < _map.Width; x++)
+ {
+ for (int y = 0; y < _map.Height; y++)
+ {
+ _map[x, y].IsVisible = false;
+ }
+ }
+
+ _map[origin].IsVisible = true;
+ _map[origin].IsExplored = true;
+
+ for (int octant = 0; octant < 8; octant++)
+ {
+ ComputeOctant(octant, origin, range, 1, new Slope(1, 1), new Slope(0, 1));
+ }
+ }
+
+ ///
+ /// Encapsulates the upper and lower slope boundaries that bound the recursive shadow-casting beam.
+ ///
+ private struct Slope
+ {
+ ///
+ /// Represents the numerator (Y) and denominator (X) parts of a slope used to track current scan boundaries.
+ ///
+ public int Y, X;
+
+ ///
+ /// Initializes a new instance with the provided numerator and denominator components.
+ ///
+ /// The numerator portion of the slope.
+ /// The denominator portion of the slope.
+ public Slope(int y, int x) { Y = y; X = x; }
+ }
+
+ ///
+ /// Recursively scans a single octant to determine which tiles remain visible while respecting opaque blockers.
+ ///
+ /// The octant index (0-7) currently being processed.
+ /// The point about which visibility is measured.
+ /// The maximum radius allowed for visibility checks.
+ /// The current distance from the origin along the primary axis.
+ /// The slope describing the upper boundary of the scan beam.
+ /// The slope describing the lower boundary of the scan beam.
+ ///
+ /// The algorithm walks tiles row by row, updating visibility when within range, and splits the scan beam when
+ /// encountering opaque transitions so that shadows are correctly propagated deeper into the octant.
+ ///
+ private void ComputeOctant(int octant, Point origin, int range, int x, Slope top, Slope bottom)
+ {
+ for (; x <= range; x++)
+ {
+ int topY = ProjectTopY(x, top);
+ int bottomY = ProjectBottomY(x, bottom);
+
+ if (topY < bottomY)
+ {
+ continue;
+ }
+
+ int wasOpaque = -1; // 0: false, 1: true, -1: not initialized
+
+ for (int y = topY; y >= bottomY; y--)
+ {
+ int tx = origin.X, ty = origin.Y;
+ switch (octant)
+ {
+ case 0: tx += x; ty -= y; break;
+ case 1: tx += y; ty -= x; break;
+ case 2: tx -= y; ty -= x; break;
+ case 3: tx -= x; ty -= y; break;
+ case 4: tx -= x; ty += y; break;
+ case 5: tx -= y; ty += x; break;
+ case 6: tx += y; ty += x; break;
+ case 7: tx += x; ty += y; break;
+ }
+
+ bool inBounds = _map.IsValid(tx, ty);
+ if (inBounds)
+ {
+ if (x * x + y * y <= range * range) // Circular radius
+ {
+ _map[tx, ty].IsVisible = true;
+ _map[tx, ty].IsExplored = true;
+ }
+ }
+
+ bool isOpaque = !inBounds || !_map[tx, ty].IsTransparent;
+
+ if (x < range)
+ {
+ if (wasOpaque != -1) // If we have a previous state
+ {
+ if (isOpaque)
+ {
+ if (wasOpaque == 0) // Transition from transparent to opaque
+ {
+ Slope newBottom = new Slope(y * 2 + 1, x * 2 - 1);
+ if (!inBounds || y * 2 + 1 < x * 2) // Blocked
+ ComputeOctant(octant, origin, range, x + 1, top, newBottom);
+ }
+ }
+ else // Transparent
+ {
+ if (wasOpaque == 1) // Transition from opaque to transparent
+ {
+ top = new Slope(y * 2 + 1, x * 2 + 1);
+ }
+ }
+ }
+ wasOpaque = isOpaque ? 1 : 0;
+ }
+ }
+
+ if (wasOpaque != -1 && wasOpaque == 1) break; // If the last cell was opaque, stop processing this row
+ }
+ }
+
+ private static int ProjectTopY(int x, Slope slope)
+ {
+ return ((x * 2 - 1) * slope.Y + slope.X) / (slope.X * 2);
+ }
+
+ private static int ProjectBottomY(int x, Slope slope)
+ {
+ return ((x * 2 + 1) * slope.Y - slope.X) / (slope.X * 2);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Engine/GameSession.cs b/CSharpBible/Games/SharpHack.Engine/GameSession.cs
new file mode 100644
index 000000000..f21083acb
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Engine/GameSession.cs
@@ -0,0 +1,739 @@
+using System.Linq;
+using SharpHack.Base.Model;
+using BaseLib.Models.Interfaces;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Data;
+using SharpHack.Engine.Pathfinding;
+using SharpHack.BaseItems.Model;
+using System.Collections.Generic;
+using System;
+
+namespace SharpHack.Engine;
+
+public class GameSession
+{
+ public IMap Map { get; private set; }
+ public ICreature Player { get; private set; }
+ public IList Enemies { get; private set; } = [];
+ public bool IsRunning { get; private set; } = true;
+ public int Level { get; private set; } = 1; // Add Level property
+
+#if _DEBUG
+ private const int Width = 30;
+ private const int Height = 20;
+#else
+ private const int Width = 80;
+ private const int Height = 50;
+#endif
+ private readonly IMapGenerator _mapGenerator;
+ private readonly IRandom _random;
+ private readonly ICombatSystem _combatSystem;
+ private readonly IEnemyAI _enemyAI; // Add field
+ private readonly FieldOfView _fov;
+ private readonly IGamePersist _persistence;
+
+ public byte[] MiniMap
+ {
+ get
+ {
+ // Simple minimap representation: 1 byte per tile
+ var miniMap = new byte[Map.Width * Map.Height];
+ for (int x = 0; x < Map.Width; x++)
+ {
+ for (int y = 0; y < Map.Height; y++)
+ {
+ var tile = Map[x, y];
+ byte value = !tile.IsExplored ? (byte)0 : (byte)(tile.Type switch
+ {
+ TileType.Wall => 8,
+ TileType.Floor => 1,
+ TileType.Room => 1,
+ TileType.StairsUp => 4,
+ TileType.StairsDown => 4,
+ TileType.DoorClosed => 4,
+ TileType.DoorOpen => 4,
+ _ => 0
+ });
+ if (tile.IsVisible && tile.Creature != null)
+ {
+ value |= 0x40; // Enemy
+ }
+ if (tile.IsExplored && tile.Items.Count > 0)
+ {
+ value |= 0x2; // Items
+ }
+ if (Player.Position.X == x && Player.Position.Y == y)
+ {
+ value |= 0x80; // Player
+ }
+ miniMap[y * Map.Width + x] = value;
+ }
+ }
+ return miniMap;
+ }
+ }
+
+ public event Action? OnMessage;
+
+ public GameSession(IMapGenerator mapGenerator, IGamePersist gamePersist, IRandom random, ICombatSystem combatSystem, IEnemyAI enemyAI) // Update constructor
+ {
+ _mapGenerator = mapGenerator;
+ _random = random;
+ _combatSystem = combatSystem;
+ _enemyAI = enemyAI; // Assign field
+ _persistence = gamePersist;
+ Initialize();
+ _fov = new FieldOfView(Map);
+ UpdateFov();
+ }
+
+ private static IEnumerable GetAdjacent8(Point p)
+ {
+ yield return new Point(p.X - 1, p.Y - 1);
+ yield return new Point(p.X, p.Y - 1);
+ yield return new Point(p.X + 1, p.Y - 1);
+ yield return new Point(p.X - 1, p.Y);
+ yield return new Point(p.X + 1, p.Y);
+ yield return new Point(p.X - 1, p.Y + 1);
+ yield return new Point(p.X, p.Y + 1);
+ yield return new Point(p.X + 1, p.Y + 1);
+ }
+
+ private void EnsureEntryAreaWalkable(Point entry)
+ {
+ if (Map == null)
+ {
+ return;
+ }
+
+ if (!Map.IsValid(entry))
+ {
+ return;
+ }
+
+ var entryTile = Map[entry];
+ if (entryTile == null)
+ {
+ return;
+ }
+
+ if (entryTile.Type == TileType.Wall)
+ {
+ entryTile.Type = TileType.Floor;
+ }
+
+ bool hasExit = GetAdjacent8(entry).Any(p =>
+ {
+ if (!Map.IsValid(p))
+ {
+ return false;
+ }
+
+ var t = Map[p];
+ return t != null && t.IsWalkable;
+ });
+
+ if (hasExit)
+ {
+ return;
+ }
+
+ foreach (var p in GetAdjacent8(entry))
+ {
+ if (!Map.IsValid(p))
+ {
+ continue;
+ }
+
+ var t = Map[p];
+ if (t == null)
+ {
+ continue;
+ }
+
+ if (t.Type == TileType.Wall)
+ {
+ t.Type = TileType.Floor;
+ break;
+ }
+ }
+ }
+
+ private void Initialize(Point? startPosition = null)
+ {
+ Map = _mapGenerator.Generate(Width, Height, startPosition);
+
+ if (Player == null)
+ {
+ Player = new Creature
+ {
+ Name = "Hero",
+ Symbol = '@',
+ Color = System.ConsoleColor.Yellow,
+ HP = 100,
+ MaxHP = 100,
+ BaseAttack = 10,
+ BaseDefense = 2,
+ Position = new Point(1, 1)
+ };
+ }
+
+ if (startPosition.HasValue)
+ {
+ Player.Position = startPosition.Value;
+ EnsureEntryAreaWalkable(Player.Position);
+
+ var st = Map[Player.Position];
+ if (st != null)
+ {
+ st.Type = TileType.StairsUp;
+ }
+ }
+ else
+ {
+ // Ensure player is on a valid tile (initial spawn)
+ if (!Map.IsValid(Player.Position) || !Map[Player.Position].IsWalkable)
+ {
+ // Simple fallback search for a walkable tile
+ for (int x = 0; x < Map.Width; x++)
+ for (int y = 0; y < Map.Height; y++)
+ if (Map[x, y].IsWalkable)
+ {
+ Player.Position = new Point(x, y);
+ goto Found;
+ }
+ Found:;
+ }
+ }
+
+ SpawnEnemies();
+ SpawnItems();
+ SpawnStairs(); // Add call
+ }
+
+ private void SpawnItems()
+ {
+ int itemCount = 5;
+ for (int i = 0; i < itemCount; i++)
+ {
+ int x, y;
+ int attempts = 0;
+ do
+ {
+ x = _random.Next(Map.Width);
+ y = _random.Next(Map.Height);
+ attempts++;
+ } while ((!Map[x, y].IsWalkable || Map[x, y].Items.Count > 0) && attempts < 100);
+
+ if (Map[x, y].IsWalkable && Map[x, y].Items.Count == 0)
+ {
+ var itemType = _random.Next(2);
+ Item item;
+ if (itemType == 0)
+ {
+ item = new Weapon { Name = "Sword", Symbol = '/', Color = System.ConsoleColor.Cyan, AttackBonus = 5 };
+ }
+ else
+ {
+ item = new Armor { Name = "Leather Armor", Symbol = '[', Color = System.ConsoleColor.Cyan, DefenseBonus = 2 };
+ }
+ item.Position = new Point(x, y);
+ Map[x, y].Items.Add(item);
+ }
+ }
+ }
+
+ private void SpawnEnemies()
+ {
+ // Simple spawn logic for now
+ // In a real game, this would be more sophisticated (e.g. based on level depth)
+ int enemyCount = 5;
+
+ for (int i = 0; i < enemyCount; i++)
+ {
+ int x, y;
+ int attempts = 0;
+ do
+ {
+ x = _random.Next(Map.Width); // Use _random
+ y = _random.Next(Map.Height); // Use _random
+ attempts++;
+ } while ((!Map[x, y].IsWalkable || (x == Player.Position.X && y == Player.Position.Y)) && attempts < 100);
+
+ if (Map[x, y].IsWalkable && (x != Player.Position.X || y != Player.Position.Y))
+ {
+ var enemy = new Creature
+ {
+ Name = "Goblin",
+ Symbol = 'g',
+ Color = System.ConsoleColor.Green,
+ HP = 20,
+ MaxHP = 20,
+ BaseAttack = 5, // Changed from Attack
+ BaseDefense = 1, // Changed from Defense
+ Position = new Point(x, y)
+ };
+ Enemies.Add(enemy);
+ Map[x, y].Creature = enemy;
+ }
+ }
+ }
+
+ private void SpawnStairs()
+ {
+ int x, y;
+ int attempts = 0;
+ do
+ {
+ x = _random.Next(Map.Width);
+ y = _random.Next(Map.Height);
+ attempts++;
+ } while ((!Map[x, y].IsWalkable || Map[x, y].Items.Count > 0 || (x == Player.Position.X && y == Player.Position.Y)) && attempts < 100);
+
+ if (Map[x, y].IsWalkable)
+ {
+ Map[x, y].Type = TileType.StairsDown;
+ }
+ }
+
+ private void NextLevel(Point entryPosition)
+ {
+ _persistence.SaveLevel(Level, Map, Player, Enemies);
+ Level++;
+ if (_persistence.LoadLevel(Level, out var _Map, out var _Enemies))
+ {
+ Map = _Map;
+ Enemies = _Enemies;
+ }
+ else
+ {
+ Enemies.Clear();
+ Initialize(entryPosition);
+ }
+ _fov.Map = Map;
+ UpdateFov();
+ Log($"You descend to level {Level}.");
+ }
+ private void PrevLevel(Point entryPosition)
+ {
+ _persistence.SaveLevel(Level, Map, Player, Enemies);
+ Level--;
+ if (_persistence.LoadLevel(Level, out var _Map, out var _Enemies))
+ {
+ Map = _Map;
+ Enemies = _Enemies;
+ }
+ else
+ {
+ Enemies.Clear();
+ Initialize(entryPosition);
+ }
+ _fov.Map = Map;
+ UpdateFov();
+ Log($"You ascend to level {Level}.");
+ }
+
+ private void UpdateFov()
+ {
+ _fov.Compute(Player.Position, 10); // 10 is the view radius
+ }
+
+ private void Log(string message)
+ {
+ OnMessage?.Invoke(message);
+ }
+
+ public void Update()
+ {
+ // Game logic update (turn processing)
+ foreach (var enemy in Enemies.ToList()) // ToList to allow modification of collection if needed (though we don't remove here)
+ {
+ var nextPos = _enemyAI.GetNextMove(enemy, Player, Map);
+
+ if (nextPos.X == Player.Position.X && nextPos.Y == Player.Position.Y)
+ {
+ Attack(enemy, Player);
+ }
+ else if (nextPos != enemy.Position)
+ {
+ // Move enemy
+ Map[enemy.Position].Creature = null;
+ enemy.Position = nextPos;
+ Map[enemy.Position].Creature = enemy;
+ }
+ }
+ }
+
+ public void MovePlayer(Direction direction, bool autoPickup = true, bool autoEquip = true, bool autoDoorOpen = false)
+ {
+ var newPos = Player.Position;
+ switch (direction)
+ {
+ case Direction.North: newPos.Y--; break;
+ case Direction.South: newPos.Y++; break;
+ case Direction.West: newPos.X--; break;
+ case Direction.East: newPos.X++; break;
+ case Direction.NorthWest: newPos.X--; newPos.Y--; break;
+ case Direction.NorthEast: newPos.X++; newPos.Y--; break;
+ case Direction.SouthWest: newPos.X--; newPos.Y++; break;
+ case Direction.SouthEast: newPos.X++; newPos.Y++; break;
+ }
+
+ if (!Map.IsValid(newPos))
+ {
+ return;
+ }
+
+ // Auto-open door by bumping into it
+ if (autoDoorOpen && Map[newPos].Type == TileType.DoorClosed)
+ {
+ if (TryOpenDoorBetween(Player.Position, newPos))
+ {
+ UpdateFov();
+ return;
+ }
+ }
+
+ if (Map[newPos].IsWalkable)
+ {
+ if (Map[newPos].Creature == null)
+ {
+ Player.Position = newPos;
+
+ if (Map[newPos].Items.Count > 0)
+ {
+ foreach (var item in Map[newPos].Items.ToList())
+ {
+ Log($"You see a {item.Name}.");
+ if (autoPickup)
+ {
+ PickUpItem(Player, item, autoEquip);
+ }
+ }
+ }
+
+ if (Map[newPos].Type == TileType.StairsDown)
+ {
+ NextLevel(newPos);
+ return;
+ }
+
+ if (Map[newPos].Type == TileType.StairsUp)
+ {
+ PrevLevel(newPos);
+ return;
+ }
+
+ UpdateFov();
+ Update();
+ }
+ else
+ {
+ Attack(Player, Map[newPos].Creature!);
+ Update();
+ }
+ }
+ }
+
+ public bool OpenDoorAt(Point position)
+ {
+ if (!Map.IsValid(position))
+ {
+ return false;
+ }
+
+ if (Math.Abs(position.X - Player.Position.X) > 1 || Math.Abs(position.Y - Player.Position.Y) > 1)
+ {
+ return false;
+ }
+
+ var tile = Map[position];
+ if (tile == null || !tile.IsExplored)
+ {
+ return false;
+ }
+
+ if (tile.Type != TileType.DoorClosed)
+ {
+ return false;
+ }
+
+ tile.Type = TileType.DoorOpen;
+ UpdateFov();
+ Log("You open the door.");
+ return true;
+ }
+
+ public bool CloseDoorAt(Point position)
+ {
+ if (!Map.IsValid(position))
+ {
+ return false;
+ }
+
+ if (Math.Abs(position.X - Player.Position.X) > 1 || Math.Abs(position.Y - Player.Position.Y) > 1)
+ {
+ return false;
+ }
+
+ var tile = Map[position];
+ if (tile == null || !tile.IsExplored)
+ {
+ return false;
+ }
+
+ if (tile.Type != TileType.DoorOpen)
+ {
+ return false;
+ }
+
+ if (tile.Creature != null)
+ {
+ return false;
+ }
+
+ tile.Type = TileType.DoorClosed;
+ UpdateFov();
+ Log("You close the door.");
+ return true;
+ }
+
+ public bool TryOpenDoorBetween(Point from, Point to)
+ {
+ if (!Map.IsValid(to))
+ {
+ return false;
+ }
+
+ var tile = Map[to];
+ if (tile == null)
+ {
+ return false;
+ }
+
+ if (tile.Type != TileType.DoorClosed)
+ {
+ return false;
+ }
+
+ // Only allow if adjacent to the actor position
+ if (Math.Abs(to.X - from.X) > 1 || Math.Abs(to.Y - from.Y) > 1)
+ {
+ return false;
+ }
+
+ tile.Type = TileType.DoorOpen;
+ Log("You open the door.");
+ return true;
+ }
+
+ public void PickUpItem(ICreature creature, IItem item, bool autoEquip = true)
+ {
+ if (Map[item.Position].Items.Contains(item))
+ {
+ Map[item.Position].Items.Remove(item);
+ creature.Inventory.Add(item);
+ Log($"{creature.Name} picks up {item.Name}.");
+
+ if (!autoEquip)
+ {
+ return;
+ }
+
+ if (item is Weapon w && creature.MainHand == null)
+ {
+ creature.MainHand = w;
+ Log($"{creature.Name} equips {w.Name}.");
+ }
+ else if (item is Armor a && creature.Body == null)
+ {
+ creature.Body = a;
+ Log($"{creature.Name} equips {a.Name}.");
+ }
+ }
+ }
+
+ private void Attack(ICreature attacker, ICreature defender)
+ {
+ _combatSystem.Attack(attacker, defender, Log); // Delegate to combat system
+
+ if (defender.HP <= 0)
+ {
+ // Death handling remains here or could be part of a result object from combat system
+ // For now, we check HP after attack
+ Enemies.Remove(defender);
+ Map[defender.Position].Creature = null;
+ }
+ }
+
+ public enum PrimaryActionKind
+ {
+ None,
+ OpenDoor,
+ CloseDoor,
+ Pickup
+ }
+
+ public readonly record struct PrimaryAction(PrimaryActionKind Kind, Point Target, string Message);
+
+ public PrimaryAction GetPrimaryAction()
+ {
+ var pp = Player.Position;
+
+ // 1) Closed door adjacent -> open
+ foreach (var p in GetAdjacent8(pp))
+ {
+ if (!Map.IsValid(p))
+ continue;
+ var t = Map[p];
+ if (t == null || !t.IsExplored)
+ continue;
+
+ if (t.Type == TileType.DoorClosed)
+ {
+ return new PrimaryAction(PrimaryActionKind.OpenDoor, p, "There is a closed door nearby: open? (Enter)");
+ }
+ }
+
+ // 2) Item(s) adjacent -> pickup (only if exactly 1 and not container, matching view-model behavior)
+ foreach (var p in GetAdjacent8(pp))
+ {
+ if (!Map.IsValid(p))
+ continue;
+ var t = Map[p];
+ if (t == null || !t.IsExplored)
+ continue;
+
+ if (t.Items.Count == 1 && t.Items[0] is not SharpHack.Base.Interfaces.IContainerItem)
+ {
+ return new PrimaryAction(PrimaryActionKind.Pickup, p, $"There is a {t.Items[0].Name} nearby: pick up? (Enter)");
+ }
+ }
+
+ // 3) Open door adjacent -> close (optional)
+ foreach (var p in GetAdjacent8(pp))
+ {
+ if (!Map.IsValid(p))
+ continue;
+ var t = Map[p];
+ if (t == null || !t.IsExplored)
+ continue;
+
+ if (t.Type == TileType.DoorOpen)
+ {
+ return new PrimaryAction(PrimaryActionKind.CloseDoor, p, "There is an open door nearby: close? (Enter)" );
+ }
+ }
+
+ return new PrimaryAction(PrimaryActionKind.None, pp, string.Empty);
+ }
+
+ public bool ExecutePrimaryAction()
+ {
+ var action = GetPrimaryAction();
+ switch (action.Kind)
+ {
+ case PrimaryActionKind.OpenDoor:
+ return OpenDoorAt(action.Target);
+ case PrimaryActionKind.CloseDoor:
+ return CloseDoorAt(action.Target);
+ case PrimaryActionKind.Pickup:
+ {
+ var t = Map[action.Target];
+ if (t == null || t.Items.Count != 1)
+ {
+ Log("Sorry, nothing to pick up.");
+ return false;
+ }
+
+ var item = t.Items[0];
+ PickUpItem(Player, item, autoEquip: true);
+ return true;
+ }
+ default:
+ Log("Sorry, there is nothing to do here.");
+ return false;
+ }
+ }
+
+ public bool ToggleDoorAt(Point position)
+ {
+ if (!Map.IsValid(position))
+ {
+ return false;
+ }
+
+ // Only allow if adjacent (8-neighborhood)
+ if (Math.Abs(position.X - Player.Position.X) > 1 || Math.Abs(position.Y - Player.Position.Y) > 1)
+ {
+ return false;
+ }
+
+ var tile = Map[position];
+ if (tile == null)
+ {
+ return false;
+ }
+
+ if (!tile.IsExplored)
+ {
+ return false;
+ }
+
+ if (tile.Type == TileType.DoorClosed)
+ {
+ tile.Type = TileType.DoorOpen;
+ UpdateFov();
+ Log("You open the door.");
+ return true;
+ }
+
+ if (tile.Type == TileType.DoorOpen)
+ {
+ // Don't close on creatures; basic safety
+ if (tile.Creature != null)
+ {
+ return false;
+ }
+
+ tile.Type = TileType.DoorClosed;
+ UpdateFov();
+ Log("You close the door.");
+ return true;
+ }
+
+ return false;
+ }
+
+ public void RevealAll(bool visible = true)
+ {
+ if (Map == null)
+ {
+ return;
+ }
+
+ for (int x = 0; x < Map.Width; x++)
+ {
+ for (int y = 0; y < Map.Height; y++)
+ {
+ var t = Map[x, y];
+ if (t == null)
+ {
+ continue;
+ }
+
+ var p = new Point(x, y);
+ if (p.GetNeighbors8().Any(p => Map.IsValid(p) && Map[p].IsWalkable))
+ {
+ t.IsExplored = true;
+ t.IsVisible = visible;
+ }
+ }
+ }
+
+ if (visible)
+ {
+ UpdateFov();
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Engine/Pathfinding/AStarPathfinder.cs b/CSharpBible/Games/SharpHack.Engine/Pathfinding/AStarPathfinder.cs
new file mode 100644
index 000000000..5f27b1810
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Engine/Pathfinding/AStarPathfinder.cs
@@ -0,0 +1,91 @@
+using System;
+using System.Collections.Generic;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.Engine.Pathfinding;
+
+public static class AStarPathfinder
+{
+ public static List? FindPath(this IMap map, Point start, Point goal, Func? canEnter = null, bool goDiagonal = true)
+ {
+ canEnter ??= (p => map[p].IsWalkable);
+ if (!map.IsValid(start) || !map.IsValid(goal) || !canEnter(goal))
+ {
+ return null;
+ }
+#if NET5_0_OR_GREATER
+ var open = new PriorityQueue();
+#else
+ var open = new SimplePriorityQueue();
+#endif
+ var cameFrom = new Dictionary();
+ var gScore = new Dictionary
+ {
+ [start] = 0
+ };
+
+ open.Enqueue(start, Heuristic(start, goal));
+
+ while (open.Count > 0)
+ {
+ var current = open.Dequeue();
+
+ if (current == goal)
+ {
+ return ReconstructPath(cameFrom, current);
+ }
+
+ foreach (var n in GetNeighbors8(current,goDiagonal))
+ {
+ if (!map.IsValid(n) || !canEnter(n))
+ {
+ continue;
+ }
+
+ var tentative = gScore[current] + (map[n].IsWalkable?1:2);
+
+ if (!gScore.TryGetValue(n, out var existing) || tentative < existing)
+ {
+ cameFrom[n] = current;
+ gScore[n] = tentative;
+ var f = tentative + Heuristic(n, goal)*2;
+ open.Enqueue(n, f);
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private static int Heuristic(Point a, Point b)
+ {
+ // Chebyshev distance for 8-direction movement.
+ return Math.Max(Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y));
+ }
+
+ public static IEnumerable GetNeighbors8(this Point p,bool goDiagonal = true)
+ {
+ yield return new Point(p.X + 1, p.Y);
+ if (goDiagonal) yield return new Point(p.X + 1, p.Y + 1);
+ yield return new Point(p.X, p.Y + 1);
+ if (goDiagonal) yield return new Point(p.X - 1, p.Y + 1);
+ yield return new Point(p.X - 1, p.Y);
+ if (goDiagonal) yield return new Point(p.X - 1, p.Y - 1);
+ yield return new Point(p.X, p.Y - 1);
+ if (goDiagonal) yield return new Point(p.X + 1, p.Y - 1);
+ }
+
+ private static List ReconstructPath(Dictionary cameFrom, Point current)
+ {
+ var path = new List { current };
+ while (cameFrom.TryGetValue(current, out var prev))
+ {
+ current = prev;
+ path.Add(current);
+ }
+
+ path.Reverse();
+ return path;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Engine/Pathfinding/SimplePriorityQueue.cs b/CSharpBible/Games/SharpHack.Engine/Pathfinding/SimplePriorityQueue.cs
new file mode 100644
index 000000000..ea4a8c53b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Engine/Pathfinding/SimplePriorityQueue.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace SharpHack.Engine.Pathfinding;
+
+public class SimplePriorityQueue where TPriority : IComparable
+{
+ private readonly List<(TElement Element, TPriority Priority)> _nodes = new();
+
+ public int Count => _nodes.Count;
+
+ public void Enqueue(TElement element, TPriority priority)
+ {
+ _nodes.Add((element, priority));
+ var i = _nodes.Count - 1;
+ while (i > 0)
+ {
+ var parent = (i - 1) / 2;
+ if (_nodes[parent].Priority.CompareTo(_nodes[i].Priority) <= 0) break;
+ (_nodes[parent], _nodes[i]) = (_nodes[i], _nodes[parent]);
+ i = parent;
+ }
+ }
+
+ public TElement Dequeue()
+ {
+ if (_nodes.Count == 0) throw new InvalidOperationException("Queue ist leer.");
+
+ var result = _nodes[0].Element;
+ _nodes[0] = _nodes[_nodes.Count - 1];
+ _nodes.RemoveAt(_nodes.Count - 1);
+
+ var i = 0;
+ while (true)
+ {
+ var left = i * 2 + 1;
+ var right = i * 2 + 2;
+ var smallest = i;
+
+ if (left < _nodes.Count && _nodes[left].Priority.CompareTo(_nodes[smallest].Priority) < 0)
+ smallest = left;
+ if (right < _nodes.Count && _nodes[right].Priority.CompareTo(_nodes[smallest].Priority) < 0)
+ smallest = right;
+
+ if (smallest == i) break;
+ (_nodes[i], _nodes[smallest]) = (_nodes[smallest], _nodes[i]);
+ i = smallest;
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Engine/SharpHack.Engine.csproj b/CSharpBible/Games/SharpHack.Engine/SharpHack.Engine.csproj
new file mode 100644
index 000000000..02a2f09f4
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Engine/SharpHack.Engine.csproj
@@ -0,0 +1,37 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.EngineTests/AssemblyInfo.cs b/CSharpBible/Games/SharpHack.EngineTests/AssemblyInfo.cs
new file mode 100644
index 000000000..8a74109a5
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.EngineTests/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel, Workers = 0)]
diff --git a/CSharpBible/Games/SharpHack.EngineTests/FieldOfViewTests.cs b/CSharpBible/Games/SharpHack.EngineTests/FieldOfViewTests.cs
new file mode 100644
index 000000000..645944b04
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.EngineTests/FieldOfViewTests.cs
@@ -0,0 +1,56 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+using SharpHack.Engine;
+
+namespace SharpHack.EngineTests;
+
+[TestClass]
+public class FieldOfViewTests
+{
+ [TestMethod]
+ public void Compute_Always_MarksOriginVisibleAndExplored()
+ {
+ var map = new Map(10, 10);
+ map[5, 5].Type = TileType.Floor;
+
+ var fov = new FieldOfView(map);
+
+ fov.Compute(new Point(5, 5), range: 3);
+
+ Assert.IsTrue(map[5, 5].IsVisible);
+ Assert.IsTrue(map[5, 5].IsExplored);
+ }
+
+ [TestMethod]
+ public void Compute_SetsTilesOutsideRange_NotVisible()
+ {
+ var map = new Map(10, 10);
+ map[5, 5].Type = TileType.Floor;
+ map[9, 9].Type = TileType.Floor;
+
+ var fov = new FieldOfView(map);
+
+ fov.Compute(new Point(5, 5), range: 3);
+
+ Assert.IsFalse(map[9, 9].IsVisible);
+ }
+
+ [TestMethod]
+ public void Compute_DoesNotRevealTilesBehindWallInCardinalDirection()
+ {
+ var map = new Map(10, 10);
+
+ map[5, 5].Type = TileType.Floor; // origin
+ map[6, 5].Type = TileType.Wall; // blocker
+ map[7, 5].Type = TileType.Floor; // behind wall
+
+ var fov = new FieldOfView(map);
+
+ fov.Compute(new Point(5, 5), range: 5);
+
+ Assert.IsTrue(map[6, 5].IsVisible, "The blocking wall itself should be visible.");
+ Assert.IsFalse(map[7, 5].IsVisible, "Tile directly behind wall should not be visible.");
+ Assert.IsFalse(map[7, 5].IsExplored, "Tile directly behind wall should not be explored.");
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.EngineTests/GameSessionPrevLevelTests.cs b/CSharpBible/Games/SharpHack.EngineTests/GameSessionPrevLevelTests.cs
new file mode 100644
index 000000000..f9eb5f516
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.EngineTests/GameSessionPrevLevelTests.cs
@@ -0,0 +1,75 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using SharpHack.Engine;
+using BaseLib.Models.Interfaces;
+
+namespace SharpHack.EngineTests;
+
+[TestClass]
+public class GameSessionPrevLevelTests
+{
+ [TestMethod]
+ public void MovePlayer_IntoStairsUp_DecrementsLevel_AndPlacesStairsUpAtEntryPosition()
+ {
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+
+ var map1 = new Map(10, 10);
+ map1[1, 1].Type = TileType.StairsUp;
+ map1[0, 1].Type = TileType.Floor;
+
+ var map0 = new Map(10, 10);
+ map0[1, 1].Type = TileType.Wall;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map1, map0);
+ mapGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(map1, map0);
+ random.Next(Arg.Any()).Returns(0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(0, 1);
+
+ session.MovePlayer(Direction.East);
+
+ Assert.AreEqual(0, session.Level);
+ Assert.AreEqual(new Point(1, 1), session.Player.Position);
+ Assert.AreEqual(TileType.StairsUp, session.Map[1, 1].Type);
+ Assert.IsTrue(session.Map[1, 1].IsWalkable);
+ }
+
+ [TestMethod]
+ public void MovePlayer_IntoStairsUp_LogsAscendMessage()
+ {
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+
+ var map1 = new Map(10, 10);
+ map1[1, 1].Type = TileType.StairsUp;
+ map1[0, 1].Type = TileType.Floor;
+
+ var map0 = new Map(10, 10);
+ map0[1, 1].Type = TileType.Floor;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map1, map0);
+ mapGenerator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(map1, map0);
+ random.Next(Arg.Any()).Returns(0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(0, 1);
+
+ string? lastMessage = null;
+ session.OnMessage += m => lastMessage = m;
+
+ session.MovePlayer(Direction.East);
+
+ Assert.AreEqual("You ascend to level 0.", lastMessage);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.EngineTests/GameSessionTests.cs b/CSharpBible/Games/SharpHack.EngineTests/GameSessionTests.cs
new file mode 100644
index 000000000..abbd41718
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.EngineTests/GameSessionTests.cs
@@ -0,0 +1,231 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+using SharpHack.Engine;
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+using BaseLib.Models.Interfaces;
+using SharpHack.Base.Interfaces;
+using System.Linq;
+using SharpHack.Base.Data;
+using NSubstitute.Core.Arguments;
+
+namespace SharpHack.EngineTests;
+
+[TestClass]
+public class GameSessionTests
+{
+ [TestMethod]
+ public void Initialize_CreatesEnemies()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+ map[1, 1].Type = TileType.Floor;
+ map[5, 5].Type = TileType.Floor;
+ mapGenerator.Generate(Arg.Any(), Arg.Any(),Arg.Any()).Returns(map);
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(5);
+
+ // Act
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+
+ // Assert
+ Assert.IsNotNull(session.Enemies);
+ Assert.IsNotEmpty(session.Enemies, "Enemies should be spawned.");
+ }
+
+ [TestMethod]
+ public void MovePlayer_BlockedByEnemy_DoesNotMove()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+
+ map[1, 1].Type = TileType.Floor;
+ map[2, 1].Type = TileType.Floor;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(2, 1, 0, 0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(1, 1);
+
+ var enemy = session.Enemies.FirstOrDefault(e => e.Position.X == 2 && e.Position.Y == 1);
+ Assert.IsNotNull(enemy);
+
+ // Act
+ session.MovePlayer(Direction.East);
+
+ // Assert
+ Assert.AreEqual(new Point(1, 1), session.Player.Position, "Player should not move into enemy.");
+ combatSystem.Received().Attack(session.Player, enemy, Arg.Any>());
+ }
+
+ [TestMethod]
+ public void MovePlayer_AttacksEnemy_DealsDamage()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+
+ map[1, 1].Type = TileType.Floor;
+ map[2, 1].Type = TileType.Floor;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(2, 1, 0, 0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(1, 1);
+
+ var enemy = session.Enemies.FirstOrDefault(e => e.Position.X == 2 && e.Position.Y == 1);
+ Assert.IsNotNull(enemy);
+
+ // Act
+ session.MovePlayer(Direction.East);
+
+ // Assert
+ combatSystem.Received().Attack(session.Player, enemy, Arg.Any>());
+ }
+
+ [TestMethod]
+ public void MovePlayer_KillsEnemy_RemovesFromMap()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+
+ map[1, 1].Type = TileType.Floor;
+ map[2, 1].Type = TileType.Floor;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(2, 1, 0, 0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(1, 1);
+
+ var enemy = session.Enemies.FirstOrDefault(e => e.Position.X == 2 && e.Position.Y == 1);
+ Assert.IsNotNull(enemy);
+
+ combatSystem.When(x => x.Attack(session.Player, enemy, Arg.Any>() ))
+ .Do(_ => enemy.HP = 0);
+
+ // Act
+ session.MovePlayer(Direction.East);
+
+ // Assert
+ Assert.DoesNotContain(enemy, session.Enemies, "Enemy should be removed from list.");
+ Assert.IsNull(map[2, 1].Creature, "Enemy should be removed from map tile.");
+ }
+
+ [TestMethod]
+ public void Update_MovesEnemies()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+
+ map[1, 1].Type = TileType.Floor;
+ map[5, 5].Type = TileType.Floor;
+ map[5, 4].Type = TileType.Floor;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(5);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ var enemy = session.Enemies.First();
+
+ enemyAI.GetNextMove(enemy, session.Player, map).Returns(new Point(5, 4));
+
+ // Act
+ session.Update();
+
+ // Assert
+ Assert.AreEqual(new Point(5, 4), enemy.Position);
+ Assert.IsNull(map[5, 5].Creature);
+ Assert.AreEqual(enemy, map[5, 4].Creature);
+ }
+
+ [TestMethod]
+ public void PickUpItem_AddsToInventory_AndEquips()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var enemyAI = Substitute.For();
+ var gamePersist = Substitute.For();
+ var map = new Map(10, 10);
+
+ map[1, 1].Type = TileType.Floor;
+ var sword = new Weapon { Name = "Sword", AttackBonus = 5, Position = new Point(1, 1) };
+ map[1, 1].Items.Add(sword);
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map);
+ random.Next(Arg.Any()).Returns(0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ session.Player.Position = new Point(1, 1);
+
+ // Act
+ session.PickUpItem(session.Player, sword);
+
+ // Assert
+ Assert.Contains(sword, session.Player.Inventory);
+ Assert.AreEqual(sword, session.Player.MainHand);
+ Assert.DoesNotContain(sword, map[1, 1].Items);
+ }
+
+ [TestMethod]
+ public void NextLevel_PlacesStairsUp_AtEntryPosition()
+ {
+ // Arrange
+ var mapGenerator = Substitute.For();
+ var random = Substitute.For();
+ var combatSystem = Substitute.For();
+ var gamePersist = Substitute.For();
+ var enemyAI = Substitute.For();
+
+ var map1 = new Map(10, 10);
+ map1[1, 1].Type = TileType.StairsDown;
+
+ var map2 = new Map(10, 10);
+ map2[1, 1].Type = TileType.Wall;
+
+ mapGenerator.Generate(Arg.Any(), Arg.Any()).Returns(map1, map2);
+ mapGenerator.Generate(Arg.Any(), Arg.Any(),Arg.Any()).Returns(map1, map2);
+ random.Next(Arg.Any()).Returns(0);
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+
+ session.Player.Position = new Point(0, 1);
+ map1[0, 1].Type = TileType.Floor;
+
+ // Act
+ session.MovePlayer(Direction.East);
+
+ // Assert
+ Assert.AreEqual(2, session.Level);
+ Assert.AreEqual(new Point(1, 1), session.Player.Position);
+ Assert.AreEqual(TileType.StairsUp, session.Map[1, 1].Type);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.EngineTests/SharpHack.EngineTests.csproj b/CSharpBible/Games/SharpHack.EngineTests/SharpHack.EngineTests.csproj
new file mode 100644
index 000000000..008e3f5c5
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.EngineTests/SharpHack.EngineTests.csproj
@@ -0,0 +1,45 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPMapGenerator.cs b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPMapGenerator.cs
new file mode 100644
index 000000000..32d5608ab
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPMapGenerator.cs
@@ -0,0 +1,485 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using SharpHack.Base.Model;
+using BaseLib.Models.Interfaces;
+using Point = SharpHack.Base.Model.Point;
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Engine.Pathfinding;
+using System.Linq; // Resolve ambiguity with System.Drawing.Point
+
+namespace SharpHack.LevelGen.BSP;
+
+public class BSPMapGenerator : IMapGenerator
+{
+ private readonly IRandom _random;
+ private BSPNode root;
+ private const int MinNodeSize = 10;
+ private const int MinRoomSize = 6;
+
+ private enum DoorCandidateKind
+ {
+ RoomConnector,
+ CorridorJunction
+ }
+
+ private readonly record struct DoorCandidate(Point Position, DoorCandidateKind Kind);
+
+ public BSPMapGenerator(IRandom random)
+ {
+ _random = random;
+ }
+
+ public IMap Generate(int width, int height, Point? point=null)
+ {
+ var map = new Map(width, height);
+
+ // Initialize with walls
+ for (int x = 0; x < width; x++)
+ for (int y = 0; y < height; y++)
+ map[x, y].Type = TileType.Wall;
+
+ var maxRooms = (width * height*2) / (MinNodeSize * MinNodeSize*3);
+ var maxSplits = _random.Next(maxRooms-4, maxRooms);
+ root = new BSPNode(new Rectangle(0, 0, width, height));
+ Split(root, maxSplits);
+ if (point.HasValue)
+ CreateStart(root, map, point.Value);
+ CreateRooms(root, map);
+
+ var doorCandidates = new List();
+ ConnectRooms(root, map, doorCandidates);
+ ApplyDoorCandidates(map, doorCandidates);
+
+ return map;
+ }
+
+ private void CreateStart(BSPNode root, Map map, Point point)
+ {
+ // Find a leaf node to place the starting room at given point
+ BSPNode node = FindLeafNode(root, point);
+ int w = _random.Next(MinRoomSize, Math.Max(MinRoomSize, node.Bounds.Width - 2));
+ int h = _random.Next(MinRoomSize, Math.Max(MinRoomSize, node.Bounds.Height - 2));
+ // make sure the random room contains the point
+#if NET5_0_OR_GREATER
+ int x = Math.Clamp(point.X - w / 2, node.Bounds.X, node.Bounds.Right - w);
+ int y = Math.Clamp(point.Y - h / 2, node.Bounds.Y, node.Bounds.Bottom - h);
+#else
+ int x = Math.Max(node.Bounds.X, Math.Min(point.X - w / 2, node.Bounds.Right - w));
+ int y = Math.Max(node.Bounds.Y, Math.Min(point.Y - h / 2, node.Bounds.Bottom - h));
+#endif
+ node.Room = new Rectangle(x, y, w, h);
+ for (int rx = x; rx < x + w; rx++)
+ {
+ for (int ry = y; ry < y + h; ry++)
+ {
+ if (map.IsValid(rx, ry))
+ map[rx, ry].Type = TileType.Room;
+ }
+ }
+ }
+
+ private static BSPNode FindLeafNode(BSPNode root, Point point)
+ {
+ BSPNode? node = root;
+ while (!node.IsLeaf)
+ {
+ if (node.Left != null && node.Left.Bounds.Contains(point.X, point.Y))
+ node = node.Left;
+ else if (node.Right != null && node.Right.Bounds.Contains(point.X, point.Y))
+ node = node.Right;
+ else
+ break; // Point is not in either child, break out
+ }
+
+ return node;
+ }
+
+ private void Split(BSPNode node, int maxspl)
+ {
+ if (maxspl==0 || node.Bounds.Width < MinNodeSize * 2 && node.Bounds.Height < MinNodeSize * 2)
+ return;
+
+ bool splitH = _random.Next(2) == 0;
+ if (node.Bounds.Width > node.Bounds.Height && node.Bounds.Width / (double)node.Bounds.Height >= 1.25) splitH = false;
+ else if (node.Bounds.Height > node.Bounds.Width && node.Bounds.Height / (double)node.Bounds.Width >= 1.25) splitH = true;
+
+ int max = (splitH ? node.Bounds.Height : node.Bounds.Width) - MinNodeSize;
+ if (max <= MinNodeSize) return;
+
+ int split = _random.Next(MinNodeSize, max);
+
+ if (splitH)
+ {
+ node.Left = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y, node.Bounds.Width, split));
+ node.Right = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y + split, node.Bounds.Width, node.Bounds.Height - split));
+ }
+ else
+ {
+ node.Left = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y, split, node.Bounds.Height));
+ node.Right = new BSPNode(new Rectangle(node.Bounds.X + split, node.Bounds.Y, node.Bounds.Width - split, node.Bounds.Height));
+ }
+
+ node.Left.Parent = node;
+ node.Right.Parent = node;
+
+ if (_random.Next(2) == 0)
+ {
+ Split(node.Left, (max + 1) / 2);
+ Split(node.Right, max / 2);
+ }
+ else
+ {
+ Split(node.Right, (max + 1) / 2);
+ Split(node.Left, max / 2);
+ }
+ }
+ private void CreateRooms(BSPNode node, Map map)
+ {
+ if (node.IsLeaf && node.Room==null)
+ {
+ int w = _random.Next(MinRoomSize, Math.Max(MinRoomSize, node.Bounds.Width - 2));
+ int h = _random.Next(MinRoomSize, Math.Max(MinRoomSize, node.Bounds.Height - 2));
+ int x = _random.Next(node.Bounds.X + 1, node.Bounds.Right - w - 1);
+ int y = _random.Next(node.Bounds.Y + 1, node.Bounds.Bottom - h - 1);
+
+ node.Room = new Rectangle(x, y, w, h);
+
+ for (int rx = x; rx < x + w; rx++)
+ {
+ for (int ry = y; ry < y + h; ry++)
+ {
+ if (map.IsValid(rx, ry))
+ map[rx, ry].Type = TileType.Room;
+ }
+ }
+ }
+ else
+ {
+ if (node.Left != null) CreateRooms(node.Left, map);
+ if (node.Right != null) CreateRooms(node.Right, map);
+ }
+ }
+
+ private void ConnectRooms(BSPNode node, Map map, List doorCandidates)
+ {
+ if (node.IsLeaf) return;
+
+ ConnectRooms(node.Left!, map, doorCandidates);
+ ConnectRooms(node.Right!, map, doorCandidates);
+
+ var leftRoom = GetRandomRoom(node.Left!)!.Value;
+ var rightRoom = GetRandomRoom(node.Right!)!.Value;
+
+ // Pick wall breakthrough points (on room wall) for both rooms.
+ var (leftDoor, leftOutside) = PickRoomDoorAndOutside(leftRoom, rightRoom, map);
+ var (rightDoor, rightOutside) = PickRoomDoorAndOutside(rightRoom, leftRoom, map);
+
+ // Carve from each room wall one step outward (keeps most wall intact).
+ if (map.IsValid(leftDoor.X, leftDoor.Y) && (map[leftDoor.X, leftDoor.Y].Type == TileType.Wall || map[leftDoor.X, leftDoor.Y].Type == TileType.Empty))
+ map[leftDoor.X, leftDoor.Y].Type = TileType.Floor;
+
+ if (map.IsValid(rightDoor.X, rightDoor.Y) && (map[rightDoor.X, rightDoor.Y].Type == TileType.Wall || map[rightDoor.X, rightDoor.Y].Type == TileType.Empty))
+ map[rightDoor.X, rightDoor.Y].Type = TileType.Floor;
+
+ // Carve from each room wall one step outward (keeps most wall intact).
+ if (map.IsValid(leftOutside.X, leftOutside.Y) && (map[leftOutside.X, leftOutside.Y].Type == TileType.Wall || map[leftOutside.X, leftOutside.Y].Type == TileType.Empty))
+ map[leftOutside.X, leftOutside.Y].Type = TileType.Floor;
+
+ if (map.IsValid(rightOutside.X, rightOutside.Y) && (map[rightOutside.X, rightOutside.Y].Type == TileType.Wall || map[rightOutside.X, rightOutside.Y].Type == TileType.Empty))
+ map[rightOutside.X, rightOutside.Y].Type = TileType.Floor;
+
+ // Door candidates are exactly on the room wall breakthrough tiles.
+ doorCandidates.Add(new DoorCandidate(leftDoor, DoorCandidateKind.RoomConnector));
+ doorCandidates.Add(new DoorCandidate(rightDoor, DoorCandidateKind.RoomConnector));
+
+ CreateCorridor(root, map, leftOutside, rightOutside, doorCandidates);
+ }
+
+ private static (Point door, Point outside) PickRoomDoorAndOutside(Rectangle room, Rectangle otherRoom, Map map)
+ {
+ // Decide which wall to use based on relative position of the other room.
+ int roomCenterX = room.X + room.Width / 2;
+ int roomCenterY = room.Y + room.Height / 2;
+ int otherCenterX = otherRoom.X + otherRoom.Width / 2;
+ int otherCenterY = otherRoom.Y + otherRoom.Height / 2;
+
+ int dx = otherCenterX - roomCenterX;
+ int dy = otherCenterY - roomCenterY;
+
+ // Choose dominant axis.
+ bool horizontal = Math.Abs(dx) >= Math.Abs(dy);
+
+ if (horizontal)
+ {
+ bool toRight = dx >= 0;
+ int doorX = toRight ? room.Right : room.Left-1;
+#if NET5_0_OR_GREATER
+ int doorY = Math.Clamp(otherCenterY, room.Top + 1, room.Bottom - 1);
+#else
+ int doorY = Math.Max(room.Top + 1, Math.Min(otherCenterY, room.Bottom - 1));
+#endif
+ var door = new Point(doorX, doorY);
+ var outside = new Point(doorX + (toRight ? 1 : -1), doorY);
+ return (door, outside);
+ }
+ else
+ {
+ bool toDown = dy >= 0;
+ int doorY = toDown ? room.Bottom : room.Top-1;
+#if NET5_0_OR_GREATER
+ int doorX = Math.Clamp(otherCenterX, room.Left + 1, room.Right - 1);
+#else
+ int doorX = Math.Max(room.Left + 1, Math.Min(otherCenterX, room.Right - 1));
+#endif
+ var door = new Point(doorX, doorY);
+ var outside = new Point(doorX, doorY + (toDown ? 1 : -1));
+ return (door, outside);
+ }
+ }
+
+ private Rectangle? GetRandomRoom(BSPNode node)
+ {
+ if (node.IsLeaf) return node.Room;
+ // If not leaf, pick a random room from one of its children to connect to
+ return _random.Next(2) == 0 ? GetRandomRoom(node.Left!) : GetRandomRoom(node.Right!);
+ }
+
+ private void CreateCorridor(BSPNode node, IMap map, Point start, Point end, List doorCandidates)
+ {
+ int x = start.X;
+ int y = start.Y;
+
+ // 1. Try to find a path with no intersections
+ List? path = default;
+ if(map[start].Type == TileType.Wall && map[end].Type == TileType.Wall)
+ path = map.FindPath(start, end, (p) => p.GetNeighbors8().All(p=> map[p]?.Type is TileType.Wall or TileType.Empty ) , false);
+
+ // 2. Try to find a path allowing floors (may intersect corridors)
+ if (path == null)
+ path = map.FindPath(start, end, (p) => {
+ var room = FindLeafNode(node,p)?.Room ?? Rectangle.Empty;
+ if (room != Rectangle.Empty) room.Inflate(1,1);
+ return room == Rectangle.Empty || !room.Contains(p.X, p.Y);
+ }, false );
+
+ if (path == null || path.Count == 0)
+ {
+ // Fallback to simple straight-line carving if no path found
+ x = start.X;
+ y = start.Y;
+ }
+ else
+ {
+ // Carve along the found path
+ foreach (var p in path)
+ {
+ if (map.IsValid(p.X, p.Y))
+ {
+ var cur = map[p.X, p.Y];
+ if (cur.Type == TileType.Wall || cur.Type == TileType.Empty)
+ {
+ cur.Type = TileType.Floor;
+ }
+ }
+ }
+ return;
+ }
+
+ // Ensure endpoints are floors
+ if (map.IsValid(x, y) && (map[x, y].Type == TileType.Wall || map[x, y].Type == TileType.Empty))
+ map[x, y].Type = TileType.Floor;
+
+ if (map.IsValid(end.X, end.Y) && (map[end.X, end.Y].Type == TileType.Wall || map[end.X, end.Y].Type == TileType.Empty))
+ map[end.X, end.Y].Type = TileType.Floor;
+
+ bool started = false;
+ Point? lastWall = null;
+ Size One = new Size(1, 1);
+ while (x != end.X || y != end.Y)
+ {
+ if (map.IsValid(x, y))
+ {
+ var cur = map[x, y];
+
+ // Junction candidate: we hit an existing corridor/room floor while carving (ignore the initial start tile).
+ if (started && cur.Type == TileType.Floor && lastWall != null)
+ {
+ doorCandidates.Add(new DoorCandidate(lastWall.Value, DoorCandidateKind.CorridorJunction));
+ lastWall = null;
+ }
+ else if(cur.Type == TileType.Wall)
+ lastWall = new Point(x,y);
+
+ if (cur.Type == TileType.Wall || cur.Type == TileType.Empty)
+ {
+ cur.Type = TileType.Floor;
+ }
+ }
+
+ started = true;
+
+ Point cand = new Point(x+ (x < end.X ? 1 : -1), y);
+ if (Math.Abs(x-end.X)Math.Abs(d.Item2)?
+ (Math.Clamp(d.Item1, -1, 1), 0):
+ (0, Math.Clamp(d.Item2, -1, 1));
+#else
+ d = Math.Abs(d.Item1)>Math.Abs(d.Item2)?
+ (d.Item1<0?-1:d.Item1>0?1:0, 0):
+ (0, d.Item2<0?-1:d.Item2>0?1:0);
+#endif
+ room.Offset(d.Item1, d.Item2);
+ if (d.Item1 * (end.X - x) + d.Item2 * (end.Y - y) < 3)
+ {
+ (x, y) = (cand.X, cand.Y);
+ }
+ else if(room.Contains(x, y))
+ {
+ var rm = (room.X + room.Width / 2 - x, room.Y + room.Height / 2 - y);
+ // we are about to carve into a room wall, compute, to get around it.
+#if NET5_0_OR_GREATER
+ (x, y) = (x + Math.Clamp((rm.Item1 * 2 + 1) * -Math.Abs(d.Item2), -1, 1), y + Math.Clamp((rm.Item2 * 2 + 1) * -Math.Abs(d.Item1), -1, 1));
+#else
+ (x, y) = (x + ((rm.Item1 * 2 + 1) * -Math.Abs(d.Item2) < 0 ? -1 : ((rm.Item1 * 2 + 1) * -Math.Abs(d.Item2) > 0 ? 1 : 0)),
+ y + ((rm.Item2 * 2 + 1) * -Math.Abs(d.Item1) < 0 ? -1 : ((rm.Item2 * 2 + 1) * -Math.Abs(d.Item1) > 0 ? 1 : 0)));
+#endif
+ }
+ else
+ (x, y) = (x - d.Item1, y - d.Item2);
+
+
+ }
+
+ }
+ if (!blocked)
+ if (_random.Next(2) == 0)
+ {
+ if (x != end.X)
+ x += x < end.X ? 1 : -1;
+ else if (y != end.Y)
+ y += y < end.Y ? 1 : -1;
+ }
+ else
+ {
+ if (y != end.Y)
+ y += y < end.Y ? 1 : -1;
+ else if (x != end.X)
+ x += x < end.X ? 1 : -1;
+ }
+ }
+ }
+
+ private static Point? FindDoorCandidateNearEnd(Map map, Point end)
+ {
+ // Look for a floor neighbor adjacent to end; prefer one that borders at least one wall (doorway-like).
+ var candidates = new List();
+ foreach (var (dx, dy) in new (int dx, int dy)[] { (1,0), (-1,0), (0,1), (0,-1) })
+ {
+ int x = end.X + dx;
+ int y = end.Y + dy;
+ if (!map.IsValid(x, y)) continue;
+ if (map[x, y].Type != TileType.Floor) continue;
+ candidates.Add(new Point(x, y));
+ }
+
+ if (candidates.Count == 0) return null;
+
+ Point best = candidates[0];
+ int bestScore = -1;
+ foreach (var p in candidates)
+ {
+ int score = 0;
+ if (map.IsValid(p.X + 1, p.Y) && map[p.X + 1, p.Y].Type == TileType.Wall) score++;
+ if (map.IsValid(p.X - 1, p.Y) && map[p.X - 1, p.Y].Type == TileType.Wall) score++;
+ if (map.IsValid(p.X, p.Y + 1) && map[p.X, p.Y + 1].Type == TileType.Wall) score++;
+ if (map.IsValid(p.X, p.Y - 1) && map[p.X, p.Y - 1].Type == TileType.Wall) score++;
+ if (score > bestScore)
+ {
+ bestScore = score;
+ best = p;
+ }
+ }
+
+ return best;
+ }
+
+ private void ApplyDoorCandidates(Map map, List doorCandidates)
+ {
+ const int roomDoorChancePercent = 99;
+ const int junctionDoorChancePercent = 30;
+
+ // Deduplicate positions; prioritize room connectors over junctions.
+ var merged = new Dictionary();
+ foreach (var dc in doorCandidates)
+ {
+ if (merged.TryGetValue(dc.Position, out var existing))
+ {
+ if (existing == DoorCandidateKind.CorridorJunction && dc.Kind == DoorCandidateKind.RoomConnector)
+ {
+ merged[dc.Position] = dc.Kind;
+ }
+ }
+ else
+ {
+ merged.Add(dc.Position, dc.Kind);
+ }
+ }
+
+ foreach (var kv in merged)
+ {
+ var pos = kv.Key;
+ var kind = kv.Value;
+
+ if (!map.IsValid(pos.X, pos.Y))
+ {
+ continue;
+ }
+
+ var t = map[pos.X, pos.Y];
+ if (t.Type != TileType.Floor)
+ {
+ continue;
+ }
+
+ // Avoid placing doors inside room interiors: if too many adjacent floors, skip.
+ int neighbors8 = 0;
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ if (dx == 0 && dy == 0) continue;
+ if (map.IsValid(pos.X + dx, pos.Y + dy) && map[pos.X + dx, pos.Y + dy].Type == TileType.Floor)
+ neighbors8++;
+ }
+ }
+ if (neighbors8 >= 6 && kind != DoorCandidateKind.RoomConnector)
+ {
+ continue;
+ }
+
+ int chance = kind == DoorCandidateKind.RoomConnector ? roomDoorChancePercent : junctionDoorChancePercent;
+ if (_random.Next(100) < chance)
+ {
+ t.Type = TileType.DoorClosed;
+ }
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPNode.cs b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPNode.cs
new file mode 100644
index 000000000..a8d8e3f79
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPNode.cs
@@ -0,0 +1,19 @@
+using System.Drawing;
+
+namespace SharpHack.LevelGen.BSP;
+
+public class BSPNode
+{
+ public Rectangle Bounds { get; set; }
+ public Rectangle? Room { get; set; } // The actual room inside the bounds
+ public BSPNode? Left { get; set; }
+ public BSPNode? Right { get; set; }
+ public BSPNode? Parent { get; set; }
+
+ public bool IsLeaf => Left == null && Right == null;
+
+ public BSPNode(Rectangle bounds)
+ {
+ Bounds = bounds;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPRoomMazeMapGenerator.cs b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPRoomMazeMapGenerator.cs
new file mode 100644
index 000000000..c1bb8fa98
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGen/BSP/BSPRoomMazeMapGenerator.cs
@@ -0,0 +1,355 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using BaseLib.Models.Interfaces;
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using SharpHack.Engine.Pathfinding;
+using Point = SharpHack.Base.Model.Point;
+
+namespace SharpHack.LevelGen.BSP;
+
+///
+ /// BSP based generator that splits the whole map into partitions (like ),
+ /// but only creates fixed 3x3 rooms with an even/even center coordinate (and a guaranteed room around the start position).
+/// The remaining area becomes a corridor maze (perfect maze), and rooms are connected to the maze via doors.
+///
+public sealed class BSPRoomMazeMapGenerator : IMapGenerator
+{
+ private readonly IRandom _random;
+ private BSPNode _root = null!;
+
+ private const int MinNodeSize = 8;
+ private const int RoomSize = 3;
+
+ private readonly record struct DoorCandidate(Point Position);
+
+ public BSPRoomMazeMapGenerator(IRandom random)
+ {
+ _random = random;
+ }
+
+ public IMap Generate(int width, int height, Point? startPos = null)
+ {
+ var map = new Map(width, height);
+
+ // Init walls
+ for (int x = 0; x < width; x++)
+ for (int y = 0; y < height; y++)
+ map[x, y].Type = TileType.Wall;
+
+ // Split
+ var maxRooms = (width * height * 2) / (MinNodeSize * MinNodeSize * 3);
+ var maxSplits = Math.Max(1, _random.Next(Math.Max(1, maxRooms - 4), Math.Max(2, maxRooms)));
+ _root = new BSPNode(new Rectangle(0, 0, width, height));
+ Split(_root, maxSplits);
+
+ // Create rooms
+ var roomCenters = new List();
+ if (startPos.HasValue)
+ {
+ CreateStartRoom(map, startPos.Value, roomCenters);
+ }
+ CreateFixedRooms(_root, map, roomCenters);
+
+ // Create maze in remaining walls (odd grid)
+ CarveMaze(map);
+
+ // Connect each room to the maze with a door
+ var doorCandidates = new List();
+ ConnectRoomsToMaze(map, roomCenters, doorCandidates);
+ ApplyDoors(map, doorCandidates);
+
+ return map;
+ }
+
+ private void Split(BSPNode node, int maxspl)
+ {
+ if (maxspl == 0 || (node.Bounds.Width < MinNodeSize * 2 && node.Bounds.Height < MinNodeSize * 2))
+ return;
+
+ bool splitH = _random.Next(2) == 0;
+ if (node.Bounds.Width > node.Bounds.Height && node.Bounds.Width / (double)node.Bounds.Height >= 1.25) splitH = false;
+ else if (node.Bounds.Height > node.Bounds.Width && node.Bounds.Height / (double)node.Bounds.Width >= 1.25) splitH = true;
+
+ int max = (splitH ? node.Bounds.Height : node.Bounds.Width) - MinNodeSize;
+ if (max <= MinNodeSize) return;
+
+ int split = _random.Next(MinNodeSize, max);
+
+ if (splitH)
+ {
+ node.Left = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y, node.Bounds.Width, split));
+ node.Right = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y + split, node.Bounds.Width, node.Bounds.Height - split));
+ }
+ else
+ {
+ node.Left = new BSPNode(new Rectangle(node.Bounds.X, node.Bounds.Y, split, node.Bounds.Height));
+ node.Right = new BSPNode(new Rectangle(node.Bounds.X + split, node.Bounds.Y, node.Bounds.Width - split, node.Bounds.Height));
+ }
+
+ node.Left.Parent = node;
+ node.Right.Parent = node;
+
+ if (_random.Next(2) == 0)
+ {
+ Split(node.Left, (max + 1) / 2);
+ Split(node.Right, max / 2);
+ }
+ else
+ {
+ Split(node.Right, (max + 1) / 2);
+ Split(node.Left, max / 2);
+ }
+ }
+
+ private void CreateStartRoom(Map map, Point start, List roomCenters)
+ {
+ // Force a 3x3 room centered on the (clamped) even coordinate near start.
+ var center = new Point(ClampEven(start.X, 1, map.Width - 2), ClampEven(start.Y, 1, map.Height - 2));
+ CarveRoom3x3(map, center);
+ roomCenters.Add(center);
+ }
+
+ private void CreateFixedRooms(BSPNode node, Map map, List roomCenters)
+ {
+ if (node.IsLeaf)
+ {
+ // Choose a 3x3 room center on odd coordinates inside node bounds with a 1-tile margin.
+ // room footprint: center +/-1.
+ int minX = node.Bounds.Left + 2;
+ int maxX = node.Bounds.Right - 3;
+ int minY = node.Bounds.Top + 2;
+ int maxY = node.Bounds.Bottom - 3;
+
+ if (minX > maxX || minY > maxY) return;
+
+ int cx = ClampEven(_random.Next(minX, maxX + 1), minX, maxX);
+ int cy = ClampEven(_random.Next(minY, maxY + 1), minY, maxY);
+
+ var center = new Point(cx, cy);
+
+ // Avoid duplicates / overlaps with existing rooms (simple check on center distance)
+ foreach (var c in roomCenters)
+ {
+ if (Math.Abs(c.X - center.X) <= 2 && Math.Abs(c.Y - center.Y) <= 2)
+ return;
+ }
+
+ CarveRoom3x3(map, center);
+ roomCenters.Add(center);
+ node.Room = new Rectangle(center.X - 1, center.Y - 1, RoomSize, RoomSize);
+ return;
+ }
+
+ if (node.Left != null) CreateFixedRooms(node.Left, map, roomCenters);
+ if (node.Right != null) CreateFixedRooms(node.Right, map, roomCenters);
+ }
+
+ private static void CarveRoom3x3(Map map, Point center)
+ {
+ for (int x = center.X - 1; x <= center.X + 1; x++)
+ {
+ for (int y = center.Y - 1; y <= center.Y + 1; y++)
+ {
+ if (map.IsValid(x, y))
+ map[x, y].Type = TileType.Room;
+ }
+ }
+ }
+
+ private void CarveMaze(Map map)
+ {
+ // Standard DFS perfect maze on odd grid.
+ // We carve floors only where there are walls (do not overwrite rooms).
+
+ if (map.Width < 3 || map.Height < 3) return;
+
+ var visited = new bool[map.Width, map.Height];
+
+ bool IsBlockedByRoomBuffer(int x, int y)
+ {
+ // Keep a wall ring around rooms: do not carve inside room tiles nor directly adjacent to them.
+ // (adjacent means 8-neighborhood)
+ for (int dx = -1; dx <= 1; dx++)
+ {
+ for (int dy = -1; dy <= 1; dy++)
+ {
+ int nx = x + dx;
+ int ny = y + dy;
+ if (!map.IsValid(nx, ny)) continue;
+ if (map[nx, ny].Type == TileType.Room) return true;
+ }
+ }
+ return false;
+ }
+
+ Point start = new Point(1, 1);
+ // find a start cell that is not blocked by a room buffer
+ for (int x = 1; x < map.Width - 1; x += 2)
+ {
+ bool found = false;
+ for (int y = 1; y < map.Height - 1; y += 2)
+ {
+ if (!IsBlockedByRoomBuffer(x, y))
+ {
+ start = new Point(x, y);
+ found = true;
+ break;
+ }
+ }
+ if (found) break;
+ }
+
+ var stack = new Stack();
+ stack.Push(start);
+ MarkCell(start);
+
+ while (stack.Count > 0)
+ {
+ var cur = stack.Peek();
+
+ var neighbors = GetUnvisitedNeighbors(cur);
+ if (neighbors.Count == 0)
+ {
+ stack.Pop();
+ continue;
+ }
+
+ var next = neighbors[_random.Next(neighbors.Count)];
+ CarveBetween(cur, next);
+ MarkCell(next);
+ stack.Push(next);
+ }
+
+ void MarkCell(Point p)
+ {
+ visited[p.X, p.Y] = true;
+ if (map[p.X, p.Y].Type == TileType.Wall || map[p.X, p.Y].Type == TileType.Empty)
+ map[p.X, p.Y].Type = TileType.Floor;
+ }
+
+ void CarveBetween(Point a, Point b)
+ {
+ int mx = (a.X + b.X) / 2;
+ int my = (a.Y + b.Y) / 2;
+
+ if (map.IsValid(mx, my) && !IsBlockedByRoomBuffer(mx, my) && (map[mx, my].Type == TileType.Wall || map[mx, my].Type == TileType.Empty))
+ map[mx, my].Type = TileType.Floor;
+
+ if (map.IsValid(b.X, b.Y) && !IsBlockedByRoomBuffer(b.X, b.Y) && (map[b.X, b.Y].Type == TileType.Wall || map[b.X, b.Y].Type == TileType.Empty))
+ map[b.X, b.Y].Type = TileType.Floor;
+ }
+
+ List GetUnvisitedNeighbors(Point p)
+ {
+ var list = new List(4);
+ foreach (var (dx, dy) in new (int dx, int dy)[] { (2, 0), (-2, 0), (0, 2), (0, -2) })
+ {
+ int nx = p.X + dx;
+ int ny = p.Y + dy;
+ if (nx <= 0 || ny <= 0 || nx >= map.Width - 1 || ny >= map.Height - 1) continue;
+ if (visited[nx, ny]) continue;
+ if (IsBlockedByRoomBuffer(nx, ny)) continue;
+ list.Add(new Point(nx, ny));
+ }
+ return list;
+ }
+ }
+
+ private void ConnectRoomsToMaze(Map map, List roomCenters, List doorCandidates)
+ {
+ foreach (var center in roomCenters)
+ {
+ var door = FindRoomToMazeDoor(map, center);
+ if (door.HasValue)
+ {
+ doorCandidates.Add(new DoorCandidate(door.Value));
+ }
+ }
+ }
+
+ private static Point? FindRoomToMazeDoor(Map map, Point roomCenter)
+ {
+ // Keep a 1-tile wall ring around the room. The first wall ring is at distance 2 from center.
+ // We place the door into that wall ring (distance 2) and make sure the corridor starts at distance 3.
+ // Prefer cardinal directions.
+ foreach (var (dx, dy) in new (int dx, int dy)[] { (0, -1), (1, 0), (0, 1), (-1, 0) })
+ {
+ // For 3x3 room, the outer wall candidate sits 2 steps away from center.
+ int wallX = roomCenter.X + dx * 2;
+ int wallY = roomCenter.Y + dy * 2;
+ int corridorX = roomCenter.X + dx * 3;
+ int corridorY = roomCenter.Y + dy * 3;
+
+ if (!map.IsValid(wallX, wallY) || !map.IsValid(corridorX, corridorY)) continue;
+
+ if (map[wallX, wallY].Type == TileType.Wall && map[corridorX, corridorY].Type == TileType.Floor)
+ return new Point(wallX, wallY);
+ }
+
+ // Fallback: search small radius around room for a wall bordering floor
+ for (int x = roomCenter.X - 3; x <= roomCenter.X + 3; x++)
+ {
+ for (int y = roomCenter.Y - 3; y <= roomCenter.Y + 3; y++)
+ {
+ if (!map.IsValid(x, y)) continue;
+ if (map[x, y].Type != TileType.Wall) continue;
+
+ if (IsAdjacentToType(map, x, y, TileType.Room) && IsAdjacentToType(map, x, y, TileType.Floor))
+ return new Point(x, y);
+ }
+ }
+
+ return null;
+ }
+
+ private static bool IsAdjacentToType(Map map, int x, int y, TileType t)
+ {
+ foreach (var (dx, dy) in new (int dx, int dy)[] { (1,0), (-1,0), (0,1), (0,-1) })
+ {
+ int nx = x + dx;
+ int ny = y + dy;
+ if (!map.IsValid(nx, ny)) continue;
+ if (map[nx, ny].Type == t) return true;
+ }
+ return false;
+ }
+
+ private void ApplyDoors(Map map, List doorCandidates)
+ {
+ // Deduplicate
+ var used = new HashSet();
+ foreach (var dc in doorCandidates)
+ {
+ if (!used.Add(dc.Position)) continue;
+ if (!map.IsValid(dc.Position.X, dc.Position.Y)) continue;
+
+ var tile = map[dc.Position.X, dc.Position.Y];
+ if (tile.Type != TileType.Wall) continue;
+
+ // Only doors that connect room <-> floor
+ if (IsAdjacentToType(map, dc.Position.X, dc.Position.Y, TileType.Room) &&
+ IsAdjacentToType(map, dc.Position.X, dc.Position.Y, TileType.Floor))
+ {
+ tile.Type = TileType.DoorClosed;
+ }
+ }
+ }
+
+ private static int ClampEven(int value, int min, int max)
+ {
+#if NET5_0_OR_GREATER
+ int v = Math.Clamp(value, min, max);
+#else
+ int v = Math.Max(min, Math.Min(max, value));
+#endif
+ if ((v & 1) == 1)
+ {
+ if (v + 1 <= max) v++;
+ else if (v - 1 >= min) v--;
+ }
+ return v;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGen/SharpHack.LevelGen.csproj b/CSharpBible/Games/SharpHack.LevelGen/SharpHack.LevelGen.csproj
new file mode 100644
index 000000000..9e11dfb86
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGen/SharpHack.LevelGen.csproj
@@ -0,0 +1,33 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.LevelGen/SimpleMapGenerator.cs b/CSharpBible/Games/SharpHack.LevelGen/SimpleMapGenerator.cs
new file mode 100644
index 000000000..af2ce2df8
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGen/SimpleMapGenerator.cs
@@ -0,0 +1,33 @@
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+
+namespace SharpHack.LevelGen;
+
+public class SimpleMapGenerator : IMapGenerator
+{
+ public IMap Generate(int width, int height,Point? point = null)
+ {
+ var map = new Map(width, height);
+
+ // Fill with walls
+ for (int x = 0; x < width; x++)
+ {
+ for (int y = 0; y < height; y++)
+ {
+ map[x, y].Type = TileType.Wall;
+ }
+ }
+
+ // Create a simple room
+ for (int x = 1; x < width - 1; x++)
+ {
+ for (int y = 1; y < height - 1; y++)
+ {
+ map[x, y].Type = TileType.Floor;
+ }
+ }
+
+ return map;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGenTests/BSPMapGeneratorTests.cs b/CSharpBible/Games/SharpHack.LevelGenTests/BSPMapGeneratorTests.cs
new file mode 100644
index 000000000..e73ec8899
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGenTests/BSPMapGeneratorTests.cs
@@ -0,0 +1,84 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+using SharpHack.LevelGen.BSP;
+using BaseLib.Models.Interfaces;
+using SharpHack.Base.Data;
+using BaseLib.Helper;
+
+namespace SharpHack.LevelGenTests;
+
+[TestClass]
+public class BSPMapGeneratorTests
+{
+ [TestMethod]
+ public void Generate_ReturnsMapWithCorrectDimensions()
+ {
+ var random = Substitute.For();
+ var generator = new BSPMapGenerator(random);
+ int width = 50;
+ int height = 50;
+
+ var map = generator.Generate(width, height);
+
+ Assert.AreEqual(width, map.Width);
+ Assert.AreEqual(height, map.Height);
+ }
+
+ [TestMethod]
+ public void Generate_CreatesWalkableTiles()
+ {
+ var random = Substitute.For();
+ // Mock random to ensure some splits happen if needed,
+ // but BSP logic is complex to mock deterministically without knowing internal calls.
+ // For now, we rely on the fact that it should produce *some* floor tiles.
+ random.Next(Arg.Any()).Returns(10);
+ random.Next(Arg.Any(), Arg.Any()).Returns(10);
+
+ var generator = new BSPMapGenerator(random);
+ var map = generator.Generate(40, 40);
+
+ bool hasFloor = false;
+ bool hasRoom = false;
+ for (int x = 0; x < map.Width; x++)
+ {
+ for (int y = 0; y < map.Height; y++)
+ {
+ if (map[x, y].Type == TileType.Floor)
+ {
+ hasFloor = true;
+ }
+ if (map[x, y].Type == TileType.Room)
+ {
+ hasRoom = true;
+ }
+ if (hasFloor && hasRoom)
+ {
+ break;
+ }
+ }
+ }
+
+ Assert.IsTrue(hasFloor, "Map should contain floor tiles.");
+ Assert.IsTrue(hasRoom, "Map should contain room tiles.");
+ }
+
+ [TestMethod]
+ [DataRow(20, 20)]
+ [DataRow(19, 20)]
+ [DataRow(20, 19)]
+ public void Generate_CreatesRoomWhereplayer(int x, int y)
+ {
+ var random = Substitute.For();
+ // Mock random to ensure some splits happen if needed,
+ // but BSP logic is complex to mock deterministically without knowing internal calls.
+ // For now, we rely on the fact that it should produce *some* floor tiles.
+ random.Next(Arg.Any()).Returns(o => o.Args()[0].AsInt()/2);
+ random.Next(Arg.Any(), Arg.Any()).Returns(o =>( o.Args()[0].AsInt()+ o.Args()[1].AsInt() ) / 2);
+
+ var generator = new BSPMapGenerator(random);
+ var map = generator.Generate(40, 40 , new Base.Model.Point(x,y));
+
+
+ Assert.AreEqual(TileType.Room, map[x, y].Type, "startpoint should be room tiles.");
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGenTests/BSPRoomMazeMapGeneratorTests.cs b/CSharpBible/Games/SharpHack.LevelGenTests/BSPRoomMazeMapGeneratorTests.cs
new file mode 100644
index 000000000..0ed45afd6
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGenTests/BSPRoomMazeMapGeneratorTests.cs
@@ -0,0 +1,84 @@
+using BaseLib.Models.Interfaces;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+using SharpHack.Base.Data;
+using SharpHack.LevelGen.BSP;
+
+namespace SharpHack.LevelGenTests;
+
+[TestClass]
+public class BSPRoomMazeMapGeneratorTests
+{
+ [TestMethod]
+ public void Generate_ReturnsMapWithCorrectDimensions()
+ {
+ var random = Substitute.For();
+ random.Next(Arg.Any()).Returns(1);
+ random.Next(Arg.Any(), Arg.Any()).Returns(ci => (ci.ArgAt(0) + ci.ArgAt(1)) / 2);
+
+ var gen = new BSPRoomMazeMapGenerator(random);
+ var map = gen.Generate(41, 37);
+
+ Assert.AreEqual(41, map.Width);
+ Assert.AreEqual(37, map.Height);
+ }
+
+ [TestMethod]
+ public void Generate_Creates_3x3_StartRoom_Around_StartPos_With_Even_Center()
+ {
+ var random = Substitute.For();
+ random.Next(Arg.Any()).Returns(1);
+ random.Next(Arg.Any(), Arg.Any()).Returns(ci => (ci.ArgAt(0) + ci.ArgAt(1)) / 2);
+
+ var gen = new BSPRoomMazeMapGenerator(random);
+ var start = new SharpHack.Base.Model.Point(21, 21); // odd -> generator should clamp to even
+ var map = gen.Generate(50, 50, start);
+
+ // center should be even/even
+ int cx = 22;
+ int cy = 22;
+
+ for (int x = cx - 1; x <= cx + 1; x++)
+ {
+ for (int y = cy - 1; y <= cy + 1; y++)
+ {
+ Assert.AreEqual(TileType.Room, map[x, y].Type, "Start room must be a 3x3 Room block.");
+ }
+ }
+
+ // The wall ring around the room must remain walls (distance 2 from center in cardinal directions)
+ Assert.AreEqual(TileType.Wall, map[cx + 2, cy].Type);
+ Assert.AreEqual(TileType.Wall, map[cx - 2, cy].Type);
+ Assert.AreEqual(TileType.Wall, map[cx, cy + 2].Type);
+ Assert.AreEqual(TileType.Wall, map[cx, cy - 2].Type);
+ }
+
+ [TestMethod]
+ public void Generate_Creates_Maze_Floors_And_Doors()
+ {
+ var random = Substitute.For();
+ random.Next(Arg.Any()).Returns(1);
+ random.Next(Arg.Any(), Arg.Any()).Returns(ci => (ci.ArgAt(0) + ci.ArgAt(1)) / 2);
+
+ var gen = new BSPRoomMazeMapGenerator(random);
+ var map = gen.Generate(60, 40, new SharpHack.Base.Model.Point(5, 5));
+
+ bool hasFloor = false;
+ bool hasRoom = false;
+ bool hasDoor = false;
+
+ for (int x = 0; x < map.Width; x++)
+ {
+ for (int y = 0; y < map.Height; y++)
+ {
+ hasFloor |= map[x, y].Type == TileType.Floor;
+ hasRoom |= map[x, y].Type == TileType.Room;
+ hasDoor |= map[x, y].Type == TileType.DoorClosed;
+ }
+ }
+
+ Assert.IsTrue(hasRoom, "Map should contain room tiles.");
+ Assert.IsTrue(hasFloor, "Map should contain floor (maze) tiles.");
+ Assert.IsTrue(hasDoor, "Map should contain doors connecting rooms to the maze.");
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.LevelGenTests/SharpHack.LevelGenTests.csproj b/CSharpBible/Games/SharpHack.LevelGenTests/SharpHack.LevelGenTests.csproj
new file mode 100644
index 000000000..2495cf219
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.LevelGenTests/SharpHack.LevelGenTests.csproj
@@ -0,0 +1,44 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Magic/SharpHack.Magic.csproj b/CSharpBible/Games/SharpHack.Magic/SharpHack.Magic.csproj
new file mode 100644
index 000000000..3878bc2bc
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Magic/SharpHack.Magic.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.MapExport/Program.cs b/CSharpBible/Games/SharpHack.MapExport/Program.cs
new file mode 100644
index 000000000..2b9fa44f9
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.MapExport/Program.cs
@@ -0,0 +1,149 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using SharpHack.AI;
+using SharpHack.Base.Interfaces;
+using SharpHack.Combat;
+using SharpHack.Engine;
+using SharpHack.LevelGen.BSP;
+using SharpHack.Persist;
+using SharpHack.ViewModel;
+using SharpHack.Wpf.Services;
+using BaseLib.Models; // CRandom
+using BaseLib.Models.Interfaces; // IRandom
+
+namespace SharpHack.MapExport;
+
+public static class Program
+{
+ [STAThread]
+ public static int Main(string[] args)
+ {
+ try
+ {
+ var options = ExportOptions.Parse(args);
+
+ var services = new ServiceCollection();
+
+ // Core services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Map generator
+ services.AddSingleton();
+
+ // WPF tile service
+ services.AddSingleton();
+
+ //GameSession
+ services.AddSingleton();
+
+ var sp = services.BuildServiceProvider();
+
+ var tileService = sp.GetRequiredService();
+ tileService.LoadTileset(options.TileSheetPath, options.TileSize);
+
+ var session = sp.GetRequiredService();
+
+ session.RevealAll(visible: true);
+
+ var vm = new GameViewModel(session, viewWidth: session.Map.Width, viewHeight: session.Map.Height);
+ vm.SetViewSize(session.Map.Width, session.Map.Height);
+
+ var exporter = new WpfMapExporter(tileService);
+ exporter.Export(vm.DisplayTiles, session.Map.Width, session.Map.Height, options.TileSize, options.OutputPath);
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex);
+ return 1;
+ }
+ }
+
+ private sealed record ExportOptions(string TileSheetPath, int TileSize, string OutputPath)
+ {
+ public static ExportOptions Parse(string[] args)
+ {
+ // Defaults: try to read SharpHack.Wpf\tiles.tileset.json if present.
+ string? tryJson = TryFindTilesetJson();
+ string defaultTileSheet = "tiles2.png"; // matches TileService.EnsureLoaded default
+ int defaultTileSize = 96; // matches TileService.EnsureLoaded default
+
+ if (tryJson != null)
+ {
+ TryReadTilesetJson(tryJson, ref defaultTileSheet, ref defaultTileSize);
+ }
+
+ string tileSheetPath = defaultTileSheet;
+ int tileSize = defaultTileSize;
+ string output = Path.Combine(Environment.CurrentDirectory, "map.png");
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--tiles":
+ case "-t":
+ tileSheetPath = args[++i];
+ break;
+ case "--tileSize":
+ case "-s":
+ tileSize = int.Parse(args[++i]);
+ break;
+ case "--out":
+ case "-o":
+ output = args[++i];
+ break;
+ }
+ }
+
+ // Allow relative tiles path next to the executable
+ if (!Path.IsPathRooted(tileSheetPath))
+ {
+ var local = Path.Combine(AppContext.BaseDirectory, tileSheetPath);
+ if (File.Exists(local))
+ {
+ tileSheetPath = local;
+ }
+ }
+
+ return new ExportOptions(tileSheetPath, tileSize, output);
+ }
+
+ private static string? TryFindTilesetJson()
+ {
+ // Look next to executable
+ var local = Path.Combine(AppContext.BaseDirectory, "tiles2.tileset.json");
+ if (File.Exists(local)) return local;
+
+ // Look in repo layout (best-effort)
+ var repo = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
+ var inRepo = Path.Combine(repo, "SharpHack.Wpf", "tiles2.tileset.json");
+ if (File.Exists(inRepo)) return inRepo;
+
+ return null;
+ }
+
+ private static void TryReadTilesetJson(string tilesetJsonPath, ref string tileSheetPath, ref int tileSize)
+ {
+ try
+ {
+ using var fs = File.OpenRead(tilesetJsonPath);
+ using var doc = JsonDocument.Parse(fs);
+ if (doc.RootElement.TryGetProperty("TileSheetPath", out var p))
+ tileSheetPath = p.GetString() ?? tileSheetPath;
+ if (doc.RootElement.TryGetProperty("Grid", out var g) && g.TryGetProperty("TileWidth", out var tw))
+ tileSize = tw.GetInt32();
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.MapExport/Properties/launchSettings.json b/CSharpBible/Games/SharpHack.MapExport/Properties/launchSettings.json
new file mode 100644
index 000000000..645870887
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.MapExport/Properties/launchSettings.json
@@ -0,0 +1,11 @@
+{
+ "profiles": {
+ "SharpHack.MapExport": {
+ "commandName": "Project"
+ },
+ "Profil \"1\"": {
+ "commandName": "Project",
+ "commandLineArgs": "-t \"D:\\Mir\\Pictures\\Tileset2r.png\" -s 64"
+ }
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.MapExport/README.md b/CSharpBible/Games/SharpHack.MapExport/README.md
new file mode 100644
index 000000000..0b837e408
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.MapExport/README.md
@@ -0,0 +1,20 @@
+# SharpHack.MapExport
+
+Exports a freshly generated SharpHack map to a full-resolution PNG using the same `DisplayTile` mapping logic as the WPF client.
+
+## Usage
+
+```powershell
+# From repo root
+
+# Uses defaults from `SharpHack.Wpf/tiles.tileset.json` if found,
+# otherwise falls back to `TileService` defaults.
+dotnet run --project .\SharpHack.MapExport\SharpHack.MapExport.csproj -- -o .\map.png
+
+# Explicit tileset + tileSize
+dotnet run --project .\SharpHack.MapExport\SharpHack.MapExport.csproj -- -t .\SharpHack.Wpf\tiles.png -s 32 -o .\map.png
+```
+
+Notes:
+- Windows-only (`net10.0-windows`).
+- The exporter marks the whole map as explored/visible to get a full map render.
diff --git a/CSharpBible/Games/SharpHack.MapExport/SharpHack.MapExport.csproj b/CSharpBible/Games/SharpHack.MapExport/SharpHack.MapExport.csproj
new file mode 100644
index 000000000..18a69a976
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.MapExport/SharpHack.MapExport.csproj
@@ -0,0 +1,34 @@
+
+
+ Exe
+ net10.0-windows
+ enable
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.MapExport/WpfMapExporter.cs b/CSharpBible/Games/SharpHack.MapExport/WpfMapExporter.cs
new file mode 100644
index 000000000..967dce68b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.MapExport/WpfMapExporter.cs
@@ -0,0 +1,59 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using SharpHack.ViewModel;
+using SharpHack.Wpf.Services;
+
+namespace SharpHack.MapExport;
+
+public sealed class WpfMapExporter
+{
+ private readonly ITileService _tileService;
+
+ public WpfMapExporter(ITileService tileService)
+ {
+ _tileService = tileService;
+ }
+
+ public void Export(IReadOnlyList tiles, int width, int height, int tileSize, string outputPath)
+ {
+ if (width <= 0 || height <= 0)
+ throw new ArgumentOutOfRangeException(nameof(width));
+
+ if (tiles.Count != width * height)
+ throw new ArgumentException("tiles size mismatch", nameof(tiles));
+
+ int pixelWidth = width * tileSize;
+ int pixelHeight = height * tileSize;
+
+ var visual = new DrawingVisual();
+ using (var dc = visual.RenderOpen())
+ {
+ dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, pixelWidth, pixelHeight));
+
+ for (int y = 0; y < height; y++)
+ {
+ for (int x = 0; x < width; x++)
+ {
+ var tile = tiles[y * width + x];
+ var img = _tileService.GetTile(tile);
+ dc.DrawImage(img, new Rect(x * tileSize, y * tileSize, tileSize, tileSize));
+ }
+ }
+ }
+
+ var rtb = new RenderTargetBitmap(pixelWidth, pixelHeight, 96, 96, PixelFormats.Pbgra32);
+ rtb.Render(visual);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(outputPath))!);
+
+ var encoder = new PngBitmapEncoder();
+ encoder.Frames.Add(BitmapFrame.Create(rtb));
+
+ using var fs = File.Create(outputPath);
+ encoder.Save(fs);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Persist/GamePersist.cs b/CSharpBible/Games/SharpHack.Persist/GamePersist.cs
new file mode 100644
index 000000000..ddbf2a6ca
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Persist/GamePersist.cs
@@ -0,0 +1,20 @@
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace SharpHack.Persist;
+
+public class GamePersist : IGamePersist
+{
+ public bool LoadLevel(int level, out IMap map, out IList enemies)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void SaveLevel(int level, IMap map, ICreature player, IList enemies)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Persist/InMemoryGamePersist.cs b/CSharpBible/Games/SharpHack.Persist/InMemoryGamePersist.cs
new file mode 100644
index 000000000..7b1dfb5f7
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Persist/InMemoryGamePersist.cs
@@ -0,0 +1,36 @@
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using System.Collections.Generic;
+
+namespace SharpHack.Persist;
+
+public class InMemoryGamePersist : IGamePersist
+{
+ private Dictionary)> _memory = new();
+
+ public bool LoadLevel(int level, out IMap? map, out IList? enemies)
+ {
+ var result= _memory.TryGetValue(level, out var m);
+ if (result)
+ {
+ map = m.Item1;
+ enemies = [];
+ foreach (var tile in map)
+ {
+ if (tile is Tile t && t.Creature is not null)
+ enemies.Add(t.Creature);
+ }
+ }
+ else
+ {
+ map = null;
+ enemies = null;
+ }
+ return result;
+ }
+
+ public void SaveLevel(int level, IMap map, ICreature player, IList enemies)
+ {
+ _memory[level] = (map, player, enemies);
+ }
+}
\ No newline at end of file
diff --git a/CSharpBible/Games/SharpHack.Persist/SharpHack.Persist.csproj b/CSharpBible/Games/SharpHack.Persist/SharpHack.Persist.csproj
new file mode 100644
index 000000000..638b1784a
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Persist/SharpHack.Persist.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Library
+ net462;net472;net48;net481;net6.0;net7.0;net8.0
+
+
+
+
+
+ $(TargetFrameworks);net9.0
+
+
+ $(TargetFrameworks);net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Server/Dockerfile b/CSharpBible/Games/SharpHack.Server/Dockerfile
new file mode 100644
index 000000000..ceb1a5bb9
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Server/Dockerfile
@@ -0,0 +1,12 @@
+# Build
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY . .
+RUN dotnet publish SharpHack.Server/SharpHack.Server.csproj -c Release -o /app/publish
+
+# Runtime
+FROM mcr.microsoft.com/dotnet/runtime:10.0
+WORKDIR /app
+COPY --from=build /app/publish .
+EXPOSE 2323
+ENTRYPOINT ["dotnet", "SharpHack.Server.dll", "2323"]
diff --git a/CSharpBible/Games/SharpHack.Server/Game/SessionRunner.cs b/CSharpBible/Games/SharpHack.Server/Game/SessionRunner.cs
new file mode 100644
index 000000000..a168afa74
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Server/Game/SessionRunner.cs
@@ -0,0 +1,216 @@
+using BaseLib.Models;
+using SharpHack.AI;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+using SharpHack.Combat;
+using SharpHack.Engine;
+using SharpHack.LevelGen.BSP;
+using SharpHack.Persist;
+using SharpHack.Server.Ansi;
+using SharpHack.Server.Net;
+using SharpHack.ViewModel;
+
+namespace SharpHack.Server.Game;
+
+internal sealed class SessionRunner
+{
+ private const int ViewWidth = 80;
+ private const int ViewHeight = 22;
+
+ private readonly AnsiWriter _ansi;
+ private readonly AnsiKeyReader _keys;
+
+ private readonly GameViewModel _vm;
+ private bool _autoDoorOpen = true;
+
+ public SessionRunner(Stream stream)
+ {
+ _ansi = new AnsiWriter(stream);
+ _keys = new AnsiKeyReader(stream);
+
+ var random = new CRandom();
+ var mapGenerator = new BSPMapGenerator(random);
+ var combatSystem = new SimpleCombatSystem();
+ var enemyAI = new SimpleEnemyAI();
+ var gamePersist = new InMemoryGamePersist();
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ _vm = new GameViewModel(session);
+ _vm.SetViewSize(ViewWidth, ViewHeight);
+ _vm.AutoDoorOpen = _autoDoorOpen;
+ }
+
+ public async Task RunAsync(CancellationToken ct)
+ {
+ _ansi.Clear();
+ _ansi.HideCursor();
+
+ try
+ {
+ var running = true;
+ while (running && !ct.IsCancellationRequested)
+ {
+ Render();
+ running = await HandleInputAsync(ct).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _ansi.Reset();
+ _ansi.ShowCursor();
+ }
+ }
+
+ private void Render()
+ {
+ _ansi.MoveCursor(1, 1);
+
+ int w = _vm.ViewWidth;
+ int h = _vm.ViewHeight;
+ var tiles = _vm.DisplayTiles;
+
+ for (int y = 0; y < h; y++)
+ {
+ for (int x = 0; x < w; x++)
+ {
+ var t = tiles[y * w + x];
+ var (ch, fg) = TileGlyphs.Map(t);
+ _ansi.SetFg256(fg);
+ _ansi.Write(ch.ToString());
+ }
+ _ansi.Reset();
+ _ansi.Write("\n");
+ }
+
+ _ansi.Reset();
+ _ansi.SetFg256(15);
+ _ansi.Write($"HP: {_vm.HP}/{_vm.MaxHP} Lvl: {_vm.Level} AutoDoorOpen: {(_autoDoorOpen ? "ON" : "OFF")} \n");
+
+ var msg = _vm.Messages.Count > 0 ? _vm.Messages[^1] : string.Empty;
+ var hint = _vm.PrimaryActionHint;
+ if (!string.IsNullOrWhiteSpace(hint) && hint != msg)
+ {
+ msg = hint;
+ }
+ _ansi.Write(msg);
+ _ansi.Write("\n");
+
+ _ansi.Reset();
+ _ansi.Write("Keys: Arrows/Numpad, Enter=Interact, A=AutoDoor, O/C/T, G=Go dx,dy, Q=Quit\n");
+ }
+
+ private async Task HandleInputAsync(CancellationToken ct)
+ {
+ var key = await _keys.ReadAsync(ct).ConfigureAwait(false);
+
+ if (key.Key == AnsiKey.Char)
+ {
+ switch (char.ToLowerInvariant(key.Char))
+ {
+ case 'q':
+ return false;
+ case 'o':
+ _vm.OpenDoorNearby();
+ return true;
+ case 'c':
+ _vm.CloseDoorNearby();
+ return true;
+ case 't':
+ _vm.ToggleDoorNearby();
+ return true;
+ case 'a':
+ _autoDoorOpen = !_autoDoorOpen;
+ _vm.AutoDoorOpen = _autoDoorOpen;
+ _vm.Messages.Add($"AutoDoorOpen: {(_autoDoorOpen ? "ON" : "OFF")}");
+ return true;
+ case 'g':
+ await GoPromptAsync(ct).ConfigureAwait(false);
+ return true;
+
+ // roguelike movement fallback
+ case 'h': _vm.Move(Direction.West); return true;
+ case 'l': _vm.Move(Direction.East); return true;
+ case 'k': _vm.Move(Direction.North); return true;
+ case 'j': _vm.Move(Direction.South); return true;
+
+ case '.':
+ _vm.Wait();
+ return true;
+ }
+ }
+
+ switch (key.Key)
+ {
+ case AnsiKey.Up: _vm.Move(Direction.North); return true;
+ case AnsiKey.Down: _vm.Move(Direction.South); return true;
+ case AnsiKey.Left: _vm.Move(Direction.West); return true;
+ case AnsiKey.Right: _vm.Move(Direction.East); return true;
+ case AnsiKey.Enter: _vm.ExecutePrimaryAction(); return true;
+ case AnsiKey.Escape: return false;
+ default: return true;
+ }
+ }
+
+ private async Task GoPromptAsync(CancellationToken ct)
+ {
+ var pp = _vm.Player.Position;
+ _ansi.Reset();
+ _ansi.SetFg256(15);
+ _ansi.Write($"\nGoTo (dx,dy) from [{pp.X},{pp.Y}]: ");
+ _ansi.ShowCursor();
+
+ try
+ {
+ var line = await ReadLineAsync(ct).ConfigureAwait(false);
+ var parts = line.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 2 && int.TryParse(parts[0], out var dx) && int.TryParse(parts[1], out var dy))
+ {
+ var target = new Point(pp.X + dx, pp.Y + dy);
+ if (!_vm.Map.IsValid(target))
+ {
+ _vm.Messages.Add("Target is outside of the map.");
+ return;
+ }
+
+ await _vm.GoToWorldAsync(target).ConfigureAwait(false);
+ }
+ }
+ finally
+ {
+ _ansi.HideCursor();
+ }
+ }
+
+ private async Task ReadLineAsync(CancellationToken ct)
+ {
+ var sb = new System.Text.StringBuilder();
+
+ while (!ct.IsCancellationRequested)
+ {
+ var k = await _keys.ReadAsync(ct).ConfigureAwait(false);
+ if (k.Key == AnsiKey.Enter)
+ {
+ _ansi.Write("\n");
+ return sb.ToString();
+ }
+
+ if (k.Key == AnsiKey.Backspace)
+ {
+ if (sb.Length > 0)
+ {
+ sb.Length -= 1;
+ _ansi.Write("\b \b");
+ }
+ continue;
+ }
+
+ if (k.Key == AnsiKey.Char)
+ {
+ sb.Append(k.Char);
+ _ansi.Write(k.Char.ToString());
+ }
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Server/Game/TileGlyphs.cs b/CSharpBible/Games/SharpHack.Server/Game/TileGlyphs.cs
new file mode 100644
index 000000000..163e4d373
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Server/Game/TileGlyphs.cs
@@ -0,0 +1,27 @@
+using SharpHack.ViewModel;
+
+namespace SharpHack.Server.Game;
+
+internal static class TileGlyphs
+{
+ public static (char ch, int fg) Map(DisplayTile tile)
+ => tile switch
+ {
+ DisplayTile.Archaeologist => ('@', 226),
+ DisplayTile.Goblin => ('g', 82),
+ DisplayTile.Sword => ('/', 51),
+ DisplayTile.Armor => ('[', 51),
+
+ DisplayTile.Floor_Lit or DisplayTile.Floor_Dark => ('.', 245),
+
+ DisplayTile.Wall_NS or DisplayTile.Wall_EW or DisplayTile.Wall_EN or DisplayTile.Wall_NW or DisplayTile.Wall_ES or DisplayTile.Wall_WS or DisplayTile.Wall_ENWS or DisplayTile.Wall_ENW or DisplayTile.Wall_EWS or DisplayTile.Wall_NWS or DisplayTile.Wall_ENS => ('#', 250),
+
+ DisplayTile.Door_Closed_EW or DisplayTile.Door_Closed_NS => ('+', 220),
+ DisplayTile.Door_Open_EW or DisplayTile.Door_Open_NS => ('/', 220),
+
+ DisplayTile.Stairs_Up => ('<', 214),
+ DisplayTile.Stairs_Down => ('>', 214),
+
+ _ => (' ', 15)
+ };
+}
diff --git a/CSharpBible/Games/SharpHack.Server/Program.cs b/CSharpBible/Games/SharpHack.Server/Program.cs
new file mode 100644
index 000000000..4365d7f00
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Server/Program.cs
@@ -0,0 +1,190 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Net.Security;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using RemoteTerminal.Remote;
+
+namespace SharpHack.Server;
+
+public static class Program
+{
+ public static async Task Main(string[] args)
+ {
+ int port = 2323;
+ bool useTls = false;
+ string? certPath = null;
+ string? certPassword = null;
+ bool requireClientCert = false;
+
+ ParseArgs(args, ref port, ref useTls, ref certPath, ref certPassword, ref requireClientCert);
+
+ var listener = new TcpListener(IPAddress.Any, port);
+ listener.Start();
+
+ System.Console.WriteLine($"SharpHack server listening on 0.0.0.0:{port}");
+ if (useTls)
+ {
+ System.Console.WriteLine("TLS enabled: connect with an SSL/TLS capable client, then use TELNET/RAW over the secure channel.");
+ System.Console.WriteLine($"Certificate: {(string.IsNullOrWhiteSpace(certPath) ? "" : certPath)}");
+ }
+ else
+ {
+ System.Console.WriteLine("PuTTY recommended: Connection type TELNET (preferred) or RAW");
+ }
+ System.Console.WriteLine("PuTTY Terminal settings: Local echo OFF, Local line editing OFF");
+
+ while (true)
+ {
+ var client = await listener.AcceptTcpClientAsync().ConfigureAwait(false);
+ _ = Task.Run(() => HandleClient(client, useTls, certPath, certPassword, requireClientCert));
+ }
+ }
+
+ private static void HandleClient(TcpClient client, bool useTls, string? certPath, string? certPassword, bool requireClientCert)
+ {
+ using (client)
+ {
+ var remote = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
+ System.Console.WriteLine($"Client connected: {remote}");
+
+ client.ReceiveTimeout = 60_000;
+ client.SendTimeout = 60_000;
+ client.NoDelay = true;
+
+ using var rawStream = client.GetStream();
+
+ Stream stream = rawStream;
+ if (useTls)
+ {
+ try
+ {
+ var cert = LoadCertificate(certPath, certPassword);
+ var ssl = new SslStream(
+ rawStream,
+ leaveInnerStreamOpen: false,
+ userCertificateValidationCallback: requireClientCert ? null : static (_, __, ___, ____) => true);
+
+ ssl.AuthenticateAsServer(
+ cert,
+ clientCertificateRequired: requireClientCert,
+ enabledSslProtocols: SslProtocols.Tls12 | SslProtocols.Tls13,
+ checkCertificateRevocation: false);
+
+ stream = ssl;
+ }
+ catch (Exception ex)
+ {
+ System.Console.WriteLine($"[{remote}] TLS handshake failed: {ex.GetType().Name}: {ex.Message}");
+ return;
+ }
+ }
+
+
+ void Log(string msg) => System.Console.WriteLine($"[{remote}] {msg}");
+
+ var console = new RemoteAnsiConsole(stream, log: Log);
+
+ console.ForegroundColor = ConsoleColor.Yellow;
+ console.BackgroundColor = ConsoleColor.Black;
+ console.SetCursorPosition(0, 0);
+ console.Write("SharpHack remote console connected. (If no keys: use PuTTY TELNET)\n");
+ console.ForegroundColor = ConsoleColor.Gray;
+
+ var app = new SharpHack.Console.ConsoleGameApp(console);
+
+ try
+ {
+ app.Run();
+ }
+ catch (Exception ex)
+ {
+ System.Console.Error.WriteLine(ex);
+ }
+ finally
+ {
+ System.Console.WriteLine($"Client disconnected: {remote}");
+ }
+ }
+ }
+
+ private static void ParseArgs(string[] args, ref int port, ref bool useTls, ref string? certPath, ref string? certPassword, ref bool requireClientCert)
+ {
+ for (int i = 0; i < args.Length; i++)
+ {
+ var a = args[i];
+ if (a.Equals("--port", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length && int.TryParse(args[i + 1], out var p))
+ {
+ port = p;
+ i++;
+ continue;
+ }
+
+ if (a.Equals("--tls", StringComparison.OrdinalIgnoreCase) || a.Equals("--ssl", StringComparison.OrdinalIgnoreCase))
+ {
+ useTls = true;
+ continue;
+ }
+
+ if (a.Equals("--cert", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ certPath = args[i + 1];
+ i++;
+ continue;
+ }
+
+ if (a.Equals("--certpass", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
+ {
+ certPassword = args[i + 1];
+ i++;
+ continue;
+ }
+
+ if (a.Equals("--require-client-cert", StringComparison.OrdinalIgnoreCase))
+ {
+ requireClientCert = true;
+ continue;
+ }
+
+ // Backward compatible: single numeric arg = port
+ if (args.Length == 1 && int.TryParse(a, out var parsed))
+ {
+ port = parsed;
+ }
+ }
+
+ if (!useTls)
+ {
+ var envTls = Environment.GetEnvironmentVariable("SHARPHACK_TLS") ?? Environment.GetEnvironmentVariable("SHARPHACK_SSL");
+ if (!string.IsNullOrWhiteSpace(envTls) && (envTls.Equals("1") || envTls.Equals("true", StringComparison.OrdinalIgnoreCase) || envTls.Equals("yes", StringComparison.OrdinalIgnoreCase)))
+ {
+ useTls = true;
+ }
+ }
+
+ certPath ??= Environment.GetEnvironmentVariable("SHARPHACK_TLS_CERT_PATH");
+ certPassword ??= Environment.GetEnvironmentVariable("SHARPHACK_TLS_CERT_PASSWORD");
+
+ if (useTls && string.IsNullOrWhiteSpace(certPath))
+ {
+ throw new InvalidOperationException("TLS is enabled but no certificate was provided. Use --cert or set SHARPHACK_TLS_CERT_PATH.");
+ }
+ }
+
+ private static X509Certificate2 LoadCertificate(string? certPath, string? certPassword)
+ {
+ if (string.IsNullOrWhiteSpace(certPath))
+ {
+ throw new InvalidOperationException("Certificate path is missing.");
+ }
+
+ if (!File.Exists(certPath))
+ {
+ throw new FileNotFoundException("Certificate file not found.", certPath);
+ }
+
+ return string.IsNullOrEmpty(certPassword)
+ ? new X509Certificate2(certPath)
+ : new X509Certificate2(certPath, certPassword);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.Server/SharpHack.Server.csproj b/CSharpBible/Games/SharpHack.Server/SharpHack.Server.csproj
new file mode 100644
index 000000000..5dfc74145
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Server/SharpHack.Server.csproj
@@ -0,0 +1,32 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.Terminal/Ansi/AnsiWriter.cs b/CSharpBible/Games/SharpHack.Terminal/Ansi/AnsiWriter.cs
new file mode 100644
index 000000000..e69de29bb
diff --git a/CSharpBible/Games/SharpHack.Terminal/Program.cs b/CSharpBible/Games/SharpHack.Terminal/Program.cs
new file mode 100644
index 000000000..a76c22af3
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Terminal/Program.cs
@@ -0,0 +1,201 @@
+using BaseLib.Interfaces;
+using BaseLib.Models;
+using SharpHack.AI;
+using SharpHack.Base.Data;
+using SharpHack.Base.Model;
+using SharpHack.Combat;
+using SharpHack.Engine;
+using SharpHack.LevelGen.BSP;
+using SharpHack.Persist;
+using SharpHack.ViewModel;
+
+namespace SharpHack.Terminal;
+
+public static class Program
+{
+ private const int HudY = 22;
+
+ public static void Main(string[] args)
+ {
+ IConsole console = new ConsoleProxy();
+
+ var random = new CRandom();
+ var mapGenerator = new BSPMapGenerator(random);
+ var combatSystem = new SimpleCombatSystem();
+ var enemyAI = new SimpleEnemyAI();
+ var gamePersist = new InMemoryGamePersist();
+
+ var session = new GameSession(mapGenerator, gamePersist, random, combatSystem, enemyAI);
+ var vm = new GameViewModel(session);
+
+ // Terminal-friendly: fixed view; ASCII output.
+ vm.SetViewSize(80, 22);
+
+ bool autoDoorOpen = true;
+ vm.AutoDoorOpen = autoDoorOpen;
+
+ console.CursorVisible = false;
+ console.Title = "SharpHack (Terminal)";
+
+ var running = true;
+ while (running)
+ {
+ Render(console, vm, autoDoorOpen);
+
+ var key = console.ReadKey()?.Key ?? ConsoleKey.NoName;
+ running = HandleInput(console, vm, ref autoDoorOpen, key);
+ }
+ }
+
+ private static bool HandleInput(IConsole console, GameViewModel vm, ref bool autoDoorOpen, ConsoleKey key)
+ {
+ switch (key)
+ {
+ case ConsoleKey.UpArrow: vm.Move(Direction.North); break;
+ case ConsoleKey.DownArrow: vm.Move(Direction.South); break;
+ case ConsoleKey.LeftArrow: vm.Move(Direction.West); break;
+ case ConsoleKey.RightArrow: vm.Move(Direction.East); break;
+ case ConsoleKey.NumPad7: vm.Move(Direction.NorthWest); break;
+ case ConsoleKey.NumPad9: vm.Move(Direction.NorthEast); break;
+ case ConsoleKey.NumPad1: vm.Move(Direction.SouthWest); break;
+ case ConsoleKey.NumPad3: vm.Move(Direction.SouthEast); break;
+ case ConsoleKey.NumPad5:
+ case ConsoleKey.OemPeriod:
+ vm.Wait();
+ break;
+
+ case ConsoleKey.O: vm.OpenDoorNearby(); break;
+ case ConsoleKey.C: vm.CloseDoorNearby(); break;
+ case ConsoleKey.T: vm.ToggleDoorNearby(); break;
+
+ case ConsoleKey.A:
+ autoDoorOpen = !autoDoorOpen;
+ vm.AutoDoorOpen = autoDoorOpen;
+ vm.Messages.Add($"AutoDoorOpen: {(autoDoorOpen ? "ON" : "OFF")}");
+ break;
+
+ case ConsoleKey.Enter:
+ vm.ExecutePrimaryAction();
+ break;
+
+ case ConsoleKey.G:
+ GoRelative(console, vm);
+ break;
+
+ case ConsoleKey.Escape:
+ case ConsoleKey.Q:
+ return false;
+ }
+
+ return true;
+ }
+
+ private static void GoRelative(IConsole console, GameViewModel vm)
+ {
+ var pp = vm.Player.Position;
+ var (dx, dy) = ReadIntPairAtHud(console, $"GoTo (dx,dy) from [{pp.X},{pp.Y}]: ");
+ var target = new Point(pp.X + dx, pp.Y + dy);
+
+ if (!vm.Map.IsValid(target))
+ {
+ vm.Messages.Add("Target is outside of the map.");
+ return;
+ }
+
+ vm.GoToWorldAsync(target).GetAwaiter().GetResult();
+ }
+
+ private static (int dx, int dy) ReadIntPairAtHud(IConsole console, string prompt)
+ {
+ var prevCursorVisible = console.CursorVisible;
+ var prevFore = console.ForegroundColor;
+ var prevBack = console.BackgroundColor;
+
+ try
+ {
+ console.CursorVisible = true;
+ console.ForegroundColor = ConsoleColor.White;
+ console.BackgroundColor = ConsoleColor.Black;
+
+ while (true)
+ {
+ console.SetCursorPosition(0, HudY);
+ console.Write(new string(' ', Math.Max(1, console.BufferWidth - 1)));
+ console.SetCursorPosition(0, HudY);
+ console.Write(prompt);
+
+ var inputX = Math.Min(console.BufferWidth - 1, prompt.Length);
+ console.SetCursorPosition(inputX, HudY);
+
+ var s = console.ReadLine();
+ var parts = s.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length == 2 && int.TryParse(parts[0], out var dx) && int.TryParse(parts[1], out var dy))
+ {
+ return (dx, dy);
+ }
+ }
+ }
+ finally
+ {
+ console.ForegroundColor = prevFore;
+ console.BackgroundColor = prevBack;
+ console.CursorVisible = prevCursorVisible;
+ }
+ }
+
+ private static void Render(IConsole console, GameViewModel vm, bool autoDoorOpen)
+ {
+ // Clear + draw full frame (simple and robust for SSH terminals)
+ console.SetCursorPosition(0, 0);
+
+ int w = vm.ViewWidth;
+ int h = vm.ViewHeight;
+
+ var tiles = vm.DisplayTiles;
+
+ for (int y = 0; y < h; y++)
+ {
+ for (int x = 0; x < w; x++)
+ {
+ var t = tiles[y * w + x];
+ console.Write(ToChar(t));
+ }
+ console.WriteLine();
+ }
+
+ console.ForegroundColor = ConsoleColor.White;
+ console.SetCursorPosition(0, h);
+ console.Write($"HP: {vm.HP}/{vm.MaxHP} Lvl: {vm.Level} AutoDoorOpen: {(autoDoorOpen ? "ON" : "OFF")}");
+ console.Write(new string(' ', 20));
+
+ console.SetCursorPosition(0, h + 1);
+ var msg = vm.Messages.Count > 0 ? vm.Messages[^1] : string.Empty;
+ var hint = vm.PrimaryActionHint;
+ if (!string.IsNullOrWhiteSpace(hint) && hint != msg)
+ {
+ msg = hint;
+ }
+ console.Write(msg);
+ console.Write(new string(' ', 20));
+ }
+
+ private static char ToChar(DisplayTile t) => t switch
+ {
+ DisplayTile.Archaeologist => '@',
+ DisplayTile.Goblin => 'g',
+ DisplayTile.Sword => '/',
+ DisplayTile.Armor => '[',
+
+ DisplayTile.Floor_Lit or DisplayTile.Floor_Dark => '.',
+
+ DisplayTile.Wall_NS or DisplayTile.Wall_EW or DisplayTile.Wall_EN or DisplayTile.Wall_NW or DisplayTile.Wall_ES or DisplayTile.Wall_WS or DisplayTile.Wall_ENWS or DisplayTile.Wall_ENW or DisplayTile.Wall_EWS or DisplayTile.Wall_NWS or DisplayTile.Wall_ENS => '#',
+
+ DisplayTile.Door_Closed_EW or DisplayTile.Door_Closed_NS => '+',
+ DisplayTile.Door_Open_EW or DisplayTile.Door_Open_NS => '/',
+
+ DisplayTile.Stairs_Up => '<',
+ DisplayTile.Stairs_Down => '>',
+
+ _ => ' '
+ };
+}
diff --git a/CSharpBible/Games/SharpHack.Terminal/Remote/RemoteAnsiConsole.cs b/CSharpBible/Games/SharpHack.Terminal/Remote/RemoteAnsiConsole.cs
new file mode 100644
index 000000000..e69de29bb
diff --git a/CSharpBible/Games/SharpHack.Terminal/SharpHack.Terminal.csproj b/CSharpBible/Games/SharpHack.Terminal/SharpHack.Terminal.csproj
new file mode 100644
index 000000000..220eb6968
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.Terminal/SharpHack.Terminal.csproj
@@ -0,0 +1,27 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml b/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml
new file mode 100644
index 000000000..a9f70bb6a
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml.cs b/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml.cs
new file mode 100644
index 000000000..4b20616d4
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/App.xaml.cs
@@ -0,0 +1,65 @@
+using System.Windows;
+using Microsoft.Extensions.DependencyInjection;
+using SharpHack.ViewModel;
+using SharpHack.Engine;
+using SharpHack.LevelGen.BSP;
+using SharpHack.Combat;
+using SharpHack.AI;
+using BaseLib.Models;
+using BaseLib.Models.Interfaces;
+using SharpHack.Base.Interfaces;
+using SharpHack.Persist;
+
+namespace SharpHack.View.FirstPerson;
+
+///
+/// Interaction logic for App.xaml
+///
+public partial class App : Application
+{
+ private ServiceProvider _serviceProvider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public App()
+ {
+ ServiceCollection services = new ServiceCollection();
+ ConfigureServices(services);
+ _serviceProvider = services.BuildServiceProvider();
+ }
+
+ ///
+ /// Configures the services for the application.
+ ///
+ /// The service collection to add services to.
+ private void ConfigureServices(ServiceCollection services)
+ {
+ // Core Services
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Game Session
+ services.AddSingleton();
+
+ // ViewModel
+ services.AddSingleton();
+
+ // View
+ services.AddSingleton();
+ }
+
+ ///
+ /// Raises the event.
+ ///
+ /// A that contains the event data.
+ protected override void OnStartup(StartupEventArgs e)
+ {
+ var mainWindow = _serviceProvider.GetRequiredService();
+ mainWindow.Show();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml b/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml
new file mode 100644
index 000000000..fd9e4f2c0
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml.cs b/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml.cs
new file mode 100644
index 000000000..0555a69e2
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/MainWindow.xaml.cs
@@ -0,0 +1,20 @@
+using System.Windows;
+using SharpHack.ViewModel;
+
+namespace SharpHack.View.FirstPerson;
+
+///
+/// Interaction logic for MainWindow.xaml
+///
+public partial class MainWindow : Window
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The view model.
+ public MainWindow(FirstPersonGameViewModel viewModel)
+ {
+ InitializeComponent();
+ DataContext = viewModel;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.Designer.cs b/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.Designer.cs
new file mode 100644
index 000000000..f1917bf4c
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.Designer.cs
@@ -0,0 +1,90 @@
+//------------------------------------------------------------------------------
+//
+// Dieser Code wurde von einem Tool generiert.
+// Laufzeitversion:4.0.30319.42000
+//
+// Änderungen an dieser Datei können falsches Verhalten verursachen und gehen verloren, wenn
+// der Code erneut generiert wird.
+//
+//------------------------------------------------------------------------------
+
+namespace SharpHack.View.FirstPerson.Properties {
+ using System;
+
+
+ ///
+ /// Eine stark typisierte Ressourcenklasse zum Suchen von lokalisierten Zeichenfolgen usw.
+ ///
+ // Diese Klasse wurde von der StronglyTypedResourceBuilder automatisch generiert
+ // -Klasse über ein Tool wie ResGen oder Visual Studio automatisch generiert.
+ // Um einen Member hinzuzufügen oder zu entfernen, bearbeiten Sie die .ResX-Datei und führen dann ResGen
+ // mit der /str-Option erneut aus, oder Sie erstellen Ihr VS-Projekt neu.
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ public class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Gibt die zwischengespeicherte ResourceManager-Instanz zurück, die von dieser Klasse verwendet wird.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SharpHack.View.FirstPerson.Properties.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Überschreibt die CurrentUICulture-Eigenschaft des aktuellen Threads für alle
+ /// Ressourcenzuordnungen, die diese stark typisierte Ressourcenklasse verwenden.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ public static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die SharpHack First Person ähnelt.
+ ///
+ public static string AppTitle {
+ get {
+ return ResourceManager.GetString("AppTitle", resourceCulture);
+ }
+ }
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die HP: ähnelt.
+ ///
+ public static string HPLabel {
+ get {
+ return ResourceManager.GetString("HPLabel", resourceCulture);
+ }
+ }
+
+ ///
+ /// Sucht eine lokalisierte Zeichenfolge, die Level: ähnelt.
+ ///
+ public static string LevelLabel {
+ get {
+ return ResourceManager.GetString("LevelLabel", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.resx b/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.resx
new file mode 100644
index 000000000..1e55a5757
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/Properties/Resources.resx
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ SharpHack First Person
+
+
+ HP:
+
+
+ Level:
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/SharpHack.View.FirstPerson.csproj b/CSharpBible/Games/SharpHack.View.FirstPerson/SharpHack.View.FirstPerson.csproj
new file mode 100644
index 000000000..e52bcb88f
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/SharpHack.View.FirstPerson.csproj
@@ -0,0 +1,44 @@
+
+
+ WinExe
+ net10.0-windows
+ enable
+ true
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+ PublicResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml b/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml
new file mode 100644
index 000000000..61e9e51ba
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml.cs b/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml.cs
new file mode 100644
index 000000000..a919d9881
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.View.FirstPerson/Views/FirstPersonView.xaml.cs
@@ -0,0 +1,17 @@
+using System.Windows.Controls;
+
+namespace SharpHack.View.FirstPerson.Views;
+
+///
+/// Interaction logic for FirstPersonView.xaml
+///
+public partial class FirstPersonView : UserControl
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FirstPersonView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.ViewModel.Tests/FirstPersonGameViewModelTests.cs b/CSharpBible/Games/SharpHack.ViewModel.Tests/FirstPersonGameViewModelTests.cs
new file mode 100644
index 000000000..6686ae3b4
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel.Tests/FirstPersonGameViewModelTests.cs
@@ -0,0 +1,81 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using NSubstitute;
+using SharpHack.Base.Model;
+using SharpHack.Base.Interfaces;
+using SharpHack.Engine;
+using SharpHack.ViewModel;
+using BaseLib.Models.Interfaces;
+
+namespace SharpHack.ViewModel.Tests;
+
+[TestClass]
+public class FirstPersonGameViewModelTests
+{
+ private GameSession _session;
+ private IRandom _random;
+ private IMapGenerator _generator;
+ private ICombatSystem _combat;
+ private IEnemyAI _ai;
+ private IGamePersist _persist;
+
+ private FirstPersonGameViewModel _vm;
+
+ [TestInitialize]
+ public void Setup()
+ {
+ _random = Substitute.For();
+ _generator = Substitute.For();
+ _combat = Substitute.For();
+ _ai = Substitute.For();
+ _persist = Substitute.For();
+
+ var map = Substitute.For();
+ map.Width.Returns(10);
+ map.Height.Returns(10);
+ _generator.Generate(Arg.Any(), Arg.Any(), Arg.Any()).Returns(map);
+
+ _session = new GameSession(_generator, _persist ,_random, _combat, _ai);
+ _vm = new FirstPersonGameViewModel(_session);
+ }
+
+ [TestMethod]
+ public void RotateLeft_ChangesDirectionCorrecty()
+ {
+ // Act
+ _vm.FacingDirection = Direction.North;
+ _vm.RotateLeft();
+
+ // Assert
+ Assert.AreEqual(Direction.West, _vm.FacingDirection);
+ }
+
+ [TestMethod]
+ public void RotateRight_ChangesDirectionCorrecty()
+ {
+ // Act
+ _vm.FacingDirection = Direction.North;
+ _vm.RotateRight();
+
+ // Assert
+ Assert.AreEqual(Direction.East, _vm.FacingDirection);
+ }
+
+ [TestMethod]
+ public void GetRelativeTile_NorthFacing_ReturnsCorrectTile()
+ {
+ // Arrange
+ _vm.FacingDirection = Direction.North;
+ var playerPos = new Point(5, 5);
+ _vm.Player.Position.Returns(playerPos);
+
+ var targetTile = Substitute.For();
+ _vm.Map.IsValid(5, 4).Returns(true);
+ _vm.Map[5, 4].Returns(targetTile);
+
+ // Act
+ var result = _vm.GetRelativeTile(1, 0); // 1 block forward
+
+ // Assert
+ Assert.AreSame(targetTile, result);
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.ViewModel.Tests/SharpHack.ViewModel.Tests.csproj b/CSharpBible/Games/SharpHack.ViewModel.Tests/SharpHack.ViewModel.Tests.csproj
new file mode 100644
index 000000000..14c48956d
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel.Tests/SharpHack.ViewModel.Tests.csproj
@@ -0,0 +1,31 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CSharpBible/Games/SharpHack.ViewModel/DisplayTile.cs b/CSharpBible/Games/SharpHack.ViewModel/DisplayTile.cs
new file mode 100644
index 000000000..5b13445cc
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel/DisplayTile.cs
@@ -0,0 +1,112 @@
+using System;
+
+namespace SharpHack.ViewModel;
+
+///
+/// Provides NetHack tile indices that the view layers can translate to glyphs or sprites.
+///
+public enum DisplayTile
+{
+ ///
+ /// Represents an empty cell / void tile.
+ ///
+ Empty = 829,
+
+ ///
+ /// Classic NetHack goblin (index 60).
+ ///
+ Goblin = 60,
+
+ ///
+ /// Archeologist player tile (index 335).
+ ///
+ Archaeologist = 335,
+
+ ///
+ /// Basic sword pickup (index 432).
+ ///
+ Sword = 432,
+
+ ///
+ /// Basic armor pickup (index 509).
+ ///
+ Armor = 509,
+
+ ///
+ /// Specifies the wall north-south orientation type.
+ ///
+ Wall_NS = 830,
+
+ ///
+ /// Horizontal wall (index 831).
+ ///
+ Wall_EW = 831,
+
+ ///
+ /// Specifies the wall east-south orientation type.
+ ///
+ Wall_EN = 832,
+
+ ///
+ /// Specifies the wall west-south orientation type.
+ ///
+ Wall_NW = 833,
+
+ ///
+ /// Specifies the wall north-east orientation type.
+ ///
+ Wall_ES = 834,
+
+ ///
+ /// Represents a wall segment oriented in the northwest direction.
+ ///
+ Wall_WS = 835,
+
+ ///
+ /// Specifies a wall facing east-northwest-south orientation in the architectural model.
+ ///
+ Wall_ENWS = 836,
+
+ Wall_ENW = 837,
+
+ Wall_EWS = 838,
+
+ Wall_NWS = 839,
+
+ Wall_ENS = 840,
+
+ ///
+ /// Open door (index 843).
+ ///
+ Door_Open_EW = 843,
+ ///
+ /// Open door (index 843).
+ ///
+ Door_Open_NS = 842,
+
+ ///
+ /// Closed door (index 842).
+ ///
+ Door_Closed_EW= 845,
+
+ ///
+ /// Closed door (index 842).
+ ///
+ Door_Closed_NS = 844,
+
+ ///
+ /// Lit floor (index 848).
+ ///
+ Floor_Lit = 848,
+
+ Floor_Dark = 849,
+ ///
+ /// Stairs leading up (index 851).
+ ///
+ Stairs_Up = 851,
+
+ ///
+ /// Stairs leading down (index 852).
+ ///
+ Stairs_Down = 852,
+}
diff --git a/CSharpBible/Games/SharpHack.ViewModel/FirstPersonGameViewModel.cs b/CSharpBible/Games/SharpHack.ViewModel/FirstPersonGameViewModel.cs
new file mode 100644
index 000000000..3dd325444
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel/FirstPersonGameViewModel.cs
@@ -0,0 +1,107 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using SharpHack.Engine;
+
+namespace SharpHack.ViewModel;
+
+///
+/// ViewModel for the first-person view of the dungeon.
+///
+public partial class FirstPersonGameViewModel : GameViewModel
+{
+ [ObservableProperty]
+ private Direction _facingDirection = Direction.North;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The game session.
+ public FirstPersonGameViewModel(GameSession session) : base(session)
+ {
+ }
+
+ ///
+ /// Rotates the player left.
+ ///
+ [RelayCommand]
+ public void RotateLeft()
+ {
+ FacingDirection = FacingDirection switch
+ {
+ Direction.North => Direction.West,
+ Direction.West => Direction.South,
+ Direction.South => Direction.East,
+ Direction.East => Direction.North,
+ _ => FacingDirection
+ };
+ }
+
+ ///
+ /// Rotates the player right.
+ ///
+ [RelayCommand]
+ public void RotateRight()
+ {
+ FacingDirection = FacingDirection switch
+ {
+ Direction.North => Direction.East,
+ Direction.East => Direction.South,
+ Direction.South => Direction.West,
+ Direction.West => Direction.North,
+ _ => FacingDirection
+ };
+ }
+
+ ///
+ /// Moves the player forward in the current facing direction.
+ ///
+ [RelayCommand]
+ public void MoveForward()
+ {
+ Move(FacingDirection);
+ }
+
+ ///
+ /// Gets the tile at the relative position from the player.
+ ///
+ /// Distance forward.
+ /// Distance to the right (negative for left).
+ /// The tile at the relative position.
+ public ITile? GetRelativeTile(int forward, int right)
+ {
+ var playerPos = Player.Position;
+ int dx = 0, dy = 0;
+
+ switch (FacingDirection)
+ {
+ case Direction.North:
+ dx = right;
+ dy = -forward;
+ break;
+ case Direction.South:
+ dx = -right;
+ dy = forward;
+ break;
+ case Direction.East:
+ dx = forward;
+ dy = right;
+ break;
+ case Direction.West:
+ dx = -forward;
+ dy = -right;
+ break;
+ }
+
+ int targetX = playerPos.X + dx;
+ int targetY = playerPos.Y + dy;
+
+ if (Map.IsValid(targetX, targetY))
+ {
+ return Map[targetX, targetY];
+ }
+
+ return null;
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.ViewModel/GameViewModel.cs b/CSharpBible/Games/SharpHack.ViewModel/GameViewModel.cs
new file mode 100644
index 000000000..99062f52b
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel/GameViewModel.cs
@@ -0,0 +1,592 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using SharpHack.Engine;
+using SharpHack.Engine.Pathfinding;
+
+namespace SharpHack.ViewModel;
+
+public partial class GameViewModel : ObservableObject
+{
+ private readonly GameSession _session;
+ private DisplayTile[] _displayTiles;
+
+ private int _viewOffsetX;
+ private int _viewOffsetY;
+
+ private CancellationTokenSource? _goCts;
+
+ [ObservableProperty]
+ private string _playerName;
+
+ [ObservableProperty]
+ private int _hP;
+
+ [ObservableProperty]
+ private int _maxHP;
+
+ [ObservableProperty]
+ private int _level;
+
+ [ObservableProperty]
+ private bool _autoPickup = true;
+
+ [ObservableProperty]
+ private bool _autoEquip = true;
+
+ [ObservableProperty]
+ private bool _autoDoorOpen = true;
+
+ public ObservableCollection Messages { get; } = new();
+ public ObservableCollection Inventory { get; } = new();
+
+ public IMap Map => _session.Map;
+
+ public byte[] MiniMap => _session.MiniMap;
+
+ public ICreature Player => _session.Player;
+ public IList Enemies => _session.Enemies;
+
+ public int ViewWidth { get; private set; }
+ public int ViewHeight { get; private set; }
+
+ private byte[]? _lastMiniMap;
+
+ public int ViewOffsetX
+ {
+ get => _viewOffsetX;
+ private set => SetProperty(ref _viewOffsetX, value);
+ }
+
+ public int ViewOffsetY
+ {
+ get => _viewOffsetY;
+ private set => SetProperty(ref _viewOffsetY, value);
+ }
+
+ public void SetViewSize(int width, int height)
+ {
+ if (ViewWidth == width && ViewHeight == height)
+ {
+ return;
+ }
+
+ ViewWidth = width;
+ ViewHeight = height;
+ _displayTiles = new DisplayTile[ViewWidth * ViewHeight];
+ UpdateDisplayBuffer();
+
+ OnPropertyChanged(nameof(ViewWidth));
+ OnPropertyChanged(nameof(ViewHeight));
+ OnPropertyChanged(nameof(DisplayTiles));
+ }
+
+ public IReadOnlyList DisplayTiles => _displayTiles;
+
+ public GameViewModel(GameSession session, int viewWidth = 40, int viewHeight = 25)
+ {
+ _session = session;
+ _session.OnMessage += OnGameMessage;
+
+ ViewWidth = viewWidth;
+ ViewHeight = viewHeight;
+ _displayTiles = new DisplayTile[ViewWidth * ViewHeight];
+
+ PlayerName = _session.Player.Name;
+ UpdateStats();
+ UpdateInventory();
+ UpdateDisplayBuffer();
+ }
+
+ private void OnGameMessage(string message)
+ {
+ Messages.Add(message);
+ if (Messages.Count > 5)
+ {
+ Messages.RemoveAt(0);
+ }
+ }
+
+ private void UpdateStats()
+ {
+ HP = _session.Player.HP;
+ MaxHP = _session.Player.MaxHP;
+ Level = _session.Level;
+ }
+
+ private void UpdateInventory()
+ {
+ Inventory.Clear();
+ foreach (var item in _session.Player.Inventory)
+ {
+ Inventory.Add(item);
+ }
+ }
+
+ private void UpdateDisplayBuffer()
+ {
+ var map = Map;
+ var player = Player;
+ var playerPos = player.Position;
+
+ int maxOffsetX = Math.Max(0, map.Width - ViewWidth);
+ int maxOffsetY = Math.Max(0, map.Height - ViewHeight);
+ int offsetX = Math.Clamp(playerPos.X - ViewWidth / 2, 0, maxOffsetX);
+ int offsetY = Math.Clamp(playerPos.Y - ViewHeight / 2, 0, maxOffsetY);
+
+ ViewOffsetX = offsetX;
+ ViewOffsetY = offsetY;
+
+ int index = 0;
+ for (int y = 0; y < ViewHeight; y++)
+ {
+ int mapY = y + offsetY;
+ bool rowInBounds = mapY >= 0 && mapY < map.Height;
+
+ for (int x = 0; x < ViewWidth; x++)
+ {
+ int mapX = x + offsetX;
+ DisplayTile displayTile;
+
+ if (!rowInBounds || mapX < 0 || mapX >= map.Width)
+ {
+ displayTile = DisplayTile.Empty;
+ }
+ else
+ {
+ var tile = map[mapX, mapY];
+ var surWall = 0;
+ if (tile.Type == TileType.Wall || tile.Type == TileType.DoorClosed || tile.Type == TileType.DoorOpen)
+ {
+ foreach (var d in new List<(byte, int, int)>() { (1, 1, 0), (2, 0, -1), (4, -1, 0), (8, 0, 1) })
+ if (map.IsValid(mapX + d.Item2, mapY + d.Item3) && map[mapX + d.Item2, mapY + d.Item3].IsExplored && map[mapX + d.Item2, mapY + d.Item3].Type is TileType.Wall or TileType.DoorClosed or TileType.DoorOpen)
+ surWall |= d.Item1;
+ }
+ bool isPlayer = mapX == playerPos.X && mapY == playerPos.Y;
+ displayTile = CreateDisplayTile(tile, surWall, player, isPlayer);
+ }
+
+ _displayTiles[index] = displayTile;
+ index++;
+ }
+ }
+
+ OnPropertyChanged(nameof(PrimaryActionHint));
+ }
+
+ private bool IsEnemyNearPlayer()
+ {
+ var pp = Player.Position;
+ return Enemies.Any(e =>
+ {
+ int dx = Math.Abs(e.Position.X - pp.X);
+ int dy = Math.Abs(e.Position.Y - pp.Y);
+ return dx <= 1 && dy <= 1;
+ });
+ }
+
+ private static Direction? TryGetDirection(Point from, Point to)
+ {
+ int dx = to.X - from.X;
+ int dy = to.Y - from.Y;
+
+ dx = Math.Clamp(dx, -1, 1);
+ dy = Math.Clamp(dy, -1, 1);
+
+ return (dx, dy) switch
+ {
+ (0, -1) => Direction.North,
+ (0, 1) => Direction.South,
+ (-1, 0) => Direction.West,
+ (1, 0) => Direction.East,
+ (-1, -1) => Direction.NorthWest,
+ (1, -1) => Direction.NorthEast,
+ (-1, 1) => Direction.SouthWest,
+ (1, 1) => Direction.SouthEast,
+ _ => null
+ };
+ }
+
+ private static DisplayTile CreateDisplayTile(ITile tile,int surWall, ICreature player, bool isPlayer)
+ {
+ if (!tile.IsExplored)
+ {
+ return DisplayTile.Empty;
+ }
+
+ if (!tile.IsVisible && tile.Type == TileType.Room)
+ {
+ return tile.IsExplored ? DisplayTile.Floor_Lit : DisplayTile.Floor_Dark;
+ }
+
+ if (isPlayer)
+ {
+ return DisplayTile.Archaeologist;
+ }
+
+ if (tile.Creature is Creature creature)
+ {
+ return creature.Name switch
+ {
+ "Goblin" => DisplayTile.Goblin,
+ _ => DisplayTile.Goblin
+ };
+ }
+
+ if (tile.Items.Count > 0)
+ {
+ var item = tile.Items[0];
+ return item.Symbol switch
+ {
+ '[' => DisplayTile.Armor,
+ _ => DisplayTile.Sword
+ };
+ }
+
+ return tile.Type switch
+ {
+ TileType.Wall => surWall switch
+ {
+ 0 => DisplayTile.Wall_NS,
+ 1 => DisplayTile.Wall_EW,
+ 3 => DisplayTile.Wall_ES,
+ 4 => DisplayTile.Wall_EW,
+ 5 => DisplayTile.Wall_EW,
+ 6 => DisplayTile.Wall_WS,
+ 7 => DisplayTile.Wall_ENW,
+ 8 => DisplayTile.Wall_NS,
+ 9=> DisplayTile.Wall_EN,
+ 10 => DisplayTile.Wall_NS,
+ 11 => DisplayTile.Wall_ENS,
+ 12 => DisplayTile.Wall_NW,
+ 13 => DisplayTile.Wall_EWS,
+ 14 => DisplayTile.Wall_NWS,
+ _ => DisplayTile.Wall_ENWS
+ },
+ TileType.Floor => DisplayTile.Floor_Dark,
+ TileType.Room => DisplayTile.Floor_Lit,
+ TileType.DoorClosed => surWall switch
+ {
+ 2 => DisplayTile.Door_Closed_NS,
+ 8 => DisplayTile.Door_Closed_NS,
+ 10 => DisplayTile.Door_Closed_NS,
+ _ => DisplayTile.Door_Closed_EW
+ },
+
+ TileType.DoorOpen => surWall switch
+ {
+ 2 => DisplayTile.Door_Open_NS,
+ 8 => DisplayTile.Door_Open_NS,
+ 10 => DisplayTile.Door_Open_NS,
+ _ => DisplayTile.Door_Open_EW
+ },
+ TileType.StairsDown => DisplayTile.Stairs_Down,
+ TileType.StairsUp => DisplayTile.Stairs_Up,
+ _ => DisplayTile.Empty
+ };
+ }
+
+ private void NotifyMiniMapIfChanged()
+ {
+ var current = _session.MiniMap;
+ if (!ReferenceEquals(_lastMiniMap, current))
+ {
+ _lastMiniMap = current;
+ OnPropertyChanged(nameof(MiniMap));
+ }
+ }
+
+ public void RefreshMiniMap()
+ {
+ _lastMiniMap = null;
+ OnPropertyChanged(nameof(MiniMap));
+ }
+
+ public enum TileHoverAction
+ {
+ None,
+ GoTo,
+ Pickup,
+ ToggleDoor
+ }
+
+ public TileHoverAction GetHoverActionForView(Point targetView)
+ {
+ var world = new Point(targetView.X + ViewOffsetX, targetView.Y + ViewOffsetY);
+ if (!Map.IsValid(world))
+ {
+ return TileHoverAction.None;
+ }
+
+ var tile = Map[world];
+ if (!tile.IsExplored)
+ {
+ return TileHoverAction.None;
+ }
+
+ var pp = Player.Position;
+ bool adjacent = Math.Abs(world.X - pp.X) <= 1 && Math.Abs(world.Y - pp.Y) <= 1;
+
+ if (adjacent && (tile.Type == TileType.DoorClosed || tile.Type == TileType.DoorOpen))
+ {
+ return TileHoverAction.ToggleDoor;
+ }
+
+ if (adjacent && tile.Items.Count == 1)
+ {
+ var item = tile.Items[0];
+ if (item is not SharpHack.Base.Interfaces.IContainerItem)
+ {
+ return TileHoverAction.Pickup;
+ }
+ }
+
+ if (tile.IsWalkable)
+ {
+ return TileHoverAction.GoTo;
+ }
+
+ return TileHoverAction.None;
+ }
+
+ [RelayCommand]
+ public void ToggleDoorAtView(Point targetView)
+ {
+ var action = GetHoverActionForView(targetView);
+ if (action != TileHoverAction.ToggleDoor)
+ {
+ return;
+ }
+
+ var world = new Point(targetView.X + ViewOffsetX, targetView.Y + ViewOffsetY);
+ if (_session.ToggleDoorAt(world))
+ {
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+ }
+
+ public bool CanClickGoToView(Point targetView) => GetHoverActionForView(targetView) == TileHoverAction.GoTo;
+
+ public bool CanClickPickupAtView(Point targetView) => GetHoverActionForView(targetView) == TileHoverAction.Pickup;
+
+ [RelayCommand]
+ public void PickupAtView(Point targetView)
+ {
+ if (!CanClickPickupAtView(targetView))
+ {
+ return;
+ }
+
+ var world = new Point(targetView.X + ViewOffsetX, targetView.Y + ViewOffsetY);
+ var tile = Map[world];
+ if (tile.Items.Count != 1)
+ {
+ return;
+ }
+
+ _session.PickUpItem(Player, tile.Items[0], autoEquip: AutoEquip);
+ UpdateInventory();
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+
+ [RelayCommand]
+ public void Move(Direction direction)
+ {
+ _session.MovePlayer(direction, autoPickup: AutoPickup, autoEquip: AutoEquip, autoDoorOpen: AutoDoorOpen);
+ UpdateStats();
+ UpdateInventory();
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+
+ private Point? FindAdjacentDoor(bool closed)
+ {
+ var pp = Player.Position;
+ foreach (var p in new[]
+ {
+ new Point(pp.X, pp.Y - 1),
+ new Point(pp.X + 1, pp.Y),
+ new Point(pp.X, pp.Y + 1),
+ new Point(pp.X - 1, pp.Y),
+ new Point(pp.X - 1, pp.Y - 1),
+ new Point(pp.X + 1, pp.Y - 1),
+ new Point(pp.X - 1, pp.Y + 1),
+ new Point(pp.X + 1, pp.Y + 1)
+ })
+ {
+ if (!Map.IsValid(p))
+ continue;
+ var t = Map[p];
+ if (!t.IsExplored)
+ continue;
+ if (closed && t.Type == TileType.DoorClosed)
+ return p;
+ if (!closed && t.Type == TileType.DoorOpen)
+ return p;
+ }
+
+ return null;
+ }
+
+ [RelayCommand]
+ public void OpenDoorNearby()
+ {
+ var p = FindAdjacentDoor(closed: true);
+ if (p == null)
+ return;
+
+ if (_session.OpenDoorAt(p.Value))
+ {
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+ }
+
+ [RelayCommand]
+ public void CloseDoorNearby()
+ {
+ var p = FindAdjacentDoor(closed: false);
+ if (p == null)
+ return;
+
+ if (_session.CloseDoorAt(p.Value))
+ {
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+ }
+
+ [RelayCommand]
+ public void ToggleDoorNearby()
+ {
+ var closed = FindAdjacentDoor(closed: true);
+ if (closed != null)
+ {
+ OpenDoorNearby();
+ return;
+ }
+
+ var open = FindAdjacentDoor(closed: false);
+ if (open != null)
+ {
+ CloseDoorNearby();
+ }
+ }
+
+ [RelayCommand]
+ public void Wait()
+ {
+ _session.Update();
+ UpdateStats();
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ }
+
+ [RelayCommand]
+ public async Task GoToViewAsync(Point targetView)
+ {
+ if (!CanClickGoToView(targetView))
+ {
+ return;
+ }
+
+ var targetWorld = new Point(targetView.X + ViewOffsetX, targetView.Y + ViewOffsetY);
+ await GoToWorldAsync(targetWorld);
+ }
+
+ [RelayCommand]
+ public async Task GoToWorldAsync(Point targetWorld)
+ {
+ if (!Map.IsValid(targetWorld))
+ {
+ return;
+ }
+
+ _goCts?.Cancel();
+ _goCts = new CancellationTokenSource();
+ var token = _goCts.Token;
+
+ // Stepwise re-pathing:
+ // Only route through explored tiles for the current map state.
+ // If new tiles become explored while moving, the next iteration can see them.
+ while (!token.IsCancellationRequested)
+ {
+ if (Player.Position == targetWorld)
+ {
+ break;
+ }
+
+ if (IsEnemyNearPlayer())
+ {
+ break;
+ }
+
+ if (!Map.IsValid(targetWorld) || !Map[targetWorld].IsExplored || !Map[targetWorld].IsWalkable)
+ {
+ break;
+ }
+
+ var start = Player.Position;
+ var path = AStarPathfinder.FindPath(
+ Map,
+ start,
+ targetWorld,
+ canEnter: p => Map[p].IsExplored && Map[p].IsWalkable);
+
+ if (path is null || path.Count < 2)
+ {
+ break;
+ }
+
+ var next = path[1];
+ var dir = TryGetDirection(start, next);
+ if (dir is null)
+ {
+ break;
+ }
+
+ Move(dir.Value);
+
+ await Task.Delay(25, token);
+ }
+ }
+
+ public string? PrimaryActionHint
+ {
+ get
+ {
+ var a = _session.GetPrimaryAction();
+ return string.IsNullOrWhiteSpace(a.Message) ? null : a.Message;
+ }
+ }
+
+ [RelayCommand]
+ public void ExecutePrimaryAction()
+ {
+ if (_session.ExecutePrimaryAction())
+ {
+ UpdateStats();
+ UpdateInventory();
+ UpdateDisplayBuffer();
+ OnPropertyChanged(nameof(DisplayTiles));
+ NotifyMiniMapIfChanged();
+ OnPropertyChanged(nameof(PrimaryActionHint));
+ }
+ }
+}
diff --git a/CSharpBible/Games/SharpHack.ViewModel/LayeredGameViewModel.cs b/CSharpBible/Games/SharpHack.ViewModel/LayeredGameViewModel.cs
new file mode 100644
index 000000000..29e84d037
--- /dev/null
+++ b/CSharpBible/Games/SharpHack.ViewModel/LayeredGameViewModel.cs
@@ -0,0 +1,540 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using SharpHack.Base.Data;
+using SharpHack.Base.Interfaces;
+using SharpHack.Base.Model;
+using SharpHack.Engine;
+using SharpHack.Engine.Pathfinding;
+
+namespace SharpHack.ViewModel;
+
+public enum LayeredEntityKind
+{
+ Player,
+ Creature,
+ Item
+}
+
+public sealed record LayeredEntity(LayeredEntityKind Kind, DisplayTile Tile, string? name);
+
+public sealed record LayeredCell(DisplayTile Structure, IReadOnlyList