diff --git a/docs-website/docs/admin-panel/map-editor.md b/docs-website/docs/admin-panel/map-editor.md index 18f7abfa34..4092821481 100644 --- a/docs-website/docs/admin-panel/map-editor.md +++ b/docs-website/docs/admin-panel/map-editor.md @@ -1,7 +1,7 @@ --- title: Map editor sidebar_position: 10 -description: Edit monster spawn areas and gates of a map graphically. +description: Edit monster spawn areas, gates and the terrain of a map graphically. --- # Map editor @@ -67,3 +67,55 @@ This is how you Import replaces the objects of the currently selected map, so export first if you want to be able to go back. + +## Terrain editor + +**Navigation:** *Configuration → Terrain editor* — route `/terrain-editor`. + +The terrain defines for every tile of a map whether it can be walked on, is a +safezone, a wall, a hole or water. The terrain editor lets you paint these +attributes directly on the map. + +Select the map, and in the second drop-down the terrain to edit: the **default +terrain**, or one of the **terrain variants** which replace it in certain states +of the map, e.g. the peace, occupied and war terrains of the Crywolf fortress. + +![The terrain editor showing the war terrain of the Crywolf fortress](/img/admin-panel/terrain-editor.png) + +The tools: + +| Tool | Edits | +|---|---| +| **Brush** | The tiles under the cursor while the left mouse button is pressed; the brush size sets the width | +| **Rectangle** | All tiles of the dragged rectangle | +| **Fill** | All connected tiles which have the same attributes as the clicked one | + +The **mode** decides what happens to the selected **attributes**: *add* them to +the tiles, *remove* them, or *replace* all attributes of the tiles with them. +Replace without any selected attribute makes the tiles plain walkable ground. + +![A zoomed terrain with the tile grid, while drawing a rectangle](/img/admin-panel/terrain-editor-zoomed.png) + +Use the mouse wheel or the zoom buttons to zoom, and drag with the right mouse +button to pan. Below the terrain you see the coordinates and the attributes of +the tile under the cursor. **Undo** and **Redo** work per terrain until you +switch to another one; **Save** writes the changes of all terrains to the +database. + +### Terrain files + +* **Import terrain file** reads a terrain file into the selected terrain. It + accepts both the `*.att` file of the original server and the encrypted + `EncTerrain*.att` file of the game client. You can undo an import. +* **Server terrain file** downloads the terrain in the `*.att` format of the + original server. +* **Client terrain file** downloads the encrypted terrain file of the game + client. The client loads it from `Data\World\EncTerrain.att`, where + `` is the map number plus one. + +:::warning[The client needs the terrain, too] +The game servers use a changed terrain after they reloaded their configuration. +The game client, however, has its own copy of the terrain: if walls, safezones +or water changed, distribute the client terrain file to your players as well. +Otherwise characters get stuck or walk through walls on their screen. +::: diff --git a/docs-website/docs/admin-panel/overview.md b/docs-website/docs/admin-panel/overview.md index dd1186143d..24fffdaf9f 100644 --- a/docs-website/docs/admin-panel/overview.md +++ b/docs-website/docs/admin-panel/overview.md @@ -55,7 +55,7 @@ with `-adminpanel:disabled`. | [Game configuration](game-configuration.md) | Everything about the game world: system settings, items, monsters, skills, maps, drops, … | | [Plugins](plugins.md) | Activate, deactivate and configure plugins | | [Chat commands](chat-commands.md) | Which in-game commands exist and who may use them | -| [Map editor](map-editor.md) | Edit monster spawn areas and gates graphically | +| [Map editor](map-editor.md) | Edit monster spawn areas, gates and the [terrain](map-editor.md#terrain-editor) graphically | | [Live map](live-map.md) | Watch what happens on a map in real time | | [Logs and monitoring](logs-and-monitoring.md) | Log files, Grafana with logs, metrics and traces | | [Users](users.md) | The users which may log into the admin panel | diff --git a/docs-website/static/img/admin-panel/terrain-editor-zoomed.png b/docs-website/static/img/admin-panel/terrain-editor-zoomed.png new file mode 100644 index 0000000000..91569a72d9 Binary files /dev/null and b/docs-website/static/img/admin-panel/terrain-editor-zoomed.png differ diff --git a/docs-website/static/img/admin-panel/terrain-editor.png b/docs-website/static/img/admin-panel/terrain-editor.png new file mode 100644 index 0000000000..3e1bf0b241 Binary files /dev/null and b/docs-website/static/img/admin-panel/terrain-editor.png differ diff --git a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor index a2097ccc5b..e00204b484 100644 --- a/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor +++ b/src/Web/AdminPanel/Components/Layout/ConfigNavMenu.razor @@ -39,6 +39,9 @@ @Resources.MapEditor + + @Resources.TerrainEditor + @Resources.FullConfiguration diff --git a/src/Web/AdminPanel/Pages/EditMap.cs b/src/Web/AdminPanel/Pages/EditMap.cs index 9700e1617f..1a7cb19a62 100644 --- a/src/Web/AdminPanel/Pages/EditMap.cs +++ b/src/Web/AdminPanel/Pages/EditMap.cs @@ -4,285 +4,18 @@ namespace MUnique.OpenMU.Web.AdminPanel.Pages; -using System.Reflection; -using System.Threading; using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.Routing; -using Microsoft.Extensions.Logging; -using Microsoft.JSInterop; using MUnique.OpenMU.DataModel.Configuration; -using MUnique.OpenMU.Persistence; using MUnique.OpenMU.Web.AdminPanel.Properties; -using MUnique.OpenMU.Web.Shared; -using MUnique.OpenMU.Web.Shared.Components; using MUnique.OpenMU.Web.Shared.Components.MapEditor; -using MUnique.OpenMU.Web.Shared.Components.Modal; -using MUnique.OpenMU.Web.Shared.Components.Toast; -using MUnique.OpenMU.Web.Shared.Services; /// /// A page, which shows an for all . /// [Route("/map-editor")] [Route("/map-editor/{SelectedMapId:guid}")] -public sealed class EditMap : ComponentBase, IDisposable +public sealed class EditMap : MapEditorPageBase { - private List? _maps; - private CancellationTokenSource? _disposeCts; - private IContext? _context; - private IDisposable? _navigationLockDisposable; - - /// - /// Gets or sets the selected map identifier. - /// - [Parameter] - public Guid SelectedMapId { get; set; } - - /// - /// Gets or sets the modal service. - /// - [Inject] - private IModalService ModalService { get; set; } = null!; - - /// - /// Gets or sets the loading overlay service. - /// - [Inject] - private LoadingOverlayService LoadingOverlay { get; set; } = null!; - - /// - /// Gets or sets the toast service. - /// - [Inject] - private IToastService ToastService { get; set; } = null!; - - /// - /// Gets or sets the game configuration source. - /// - [Inject] - private IDataSource GameConfigurationSource { get; set; } = null!; - - /// - /// Gets or sets the logger. - /// - [Inject] - private ILogger Logger { get; set; } = null!; - - /// - /// Gets or sets the navigation manager. - /// - [Inject] - private NavigationManager NavigationManager { get; set; } = null!; - - /// - /// Gets or sets the JavaScript runtime. - /// - [Inject] - private IJSRuntime JavaScript { get; set; } = null!; - - /// - public void Dispose() - { - this._disposeCts?.Cancel(); - this._disposeCts?.Dispose(); - this._disposeCts = null; - - this._navigationLockDisposable?.Dispose(); - this._navigationLockDisposable = null; - } - - /// - protected override void BuildRenderTree(RenderTreeBuilder builder) - { - if (this._maps is null) - { - return; - } - - builder.OpenComponent(0); - builder.AddAttribute(1, nameof(Breadcrumb.Caption), Resources.MapEditor); - builder.CloseComponent(); - - builder.OpenComponent>(10); - builder.AddAttribute(11, nameof(CascadingValue.Value), this._context); - builder.AddAttribute(12, nameof(CascadingValue.IsFixed), false); - builder.AddAttribute(13, nameof(CascadingValue.ChildContent), (RenderFragment)this.BuildMapEditorFragment); - builder.CloseComponent(); - } - - /// - protected override async Task OnParametersSetAsync() - { - await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); - this._disposeCts?.Dispose(); - this._disposeCts = new CancellationTokenSource(); - - this._context = await this.GameConfigurationSource - .GetContextAsync(this._disposeCts.Token) - .ConfigureAwait(false); - - await base.OnParametersSetAsync().ConfigureAwait(false); - } - - /// - protected override async Task OnAfterRenderAsync(bool firstRender) - { - await base.OnAfterRenderAsync(firstRender).ConfigureAwait(false); - if (this._maps is null) - { - this._disposeCts ??= new CancellationTokenSource(); - var cts = this._disposeCts.Token; - _ = Task.Run(() => this.LoadDataAsync(cts), cts); - } - } - /// - protected override Task OnInitializedAsync() - { - this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync); - return base.OnInitializedAsync(); - } - - private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context) - { - if (!await this.AllowChangeAsync().ConfigureAwait(false)) - { - context.PreventNavigation(); - } - } - - private async Task OnSelectedMapChangingAsync(MapChangingArgs eventArgs) - { - eventArgs.Cancel = !await this.AllowChangeAsync().ConfigureAwait(true); - if (!eventArgs.Cancel) - { - this.SelectedMapId = eventArgs.NextMap; - } - } - - private async ValueTask AllowChangeAsync() - { - var cancellationToken = this._disposeCts?.Token ?? default; - var persistenceContext = await this.GameConfigurationSource - .GetContextAsync(cancellationToken) - .ConfigureAwait(true); - - if (persistenceContext?.HasChanges is not true) - { - return true; - } - - var isConfirmed = await this.JavaScript - .InvokeAsync("window.confirm", cancellationToken, Resources.UnsavedChangesQuestion) - .ConfigureAwait(true); - - if (!isConfirmed) - { - return false; - } - - await this.GameConfigurationSource.DiscardChangesAsync().ConfigureAwait(true); - - IDisposable? loadingOverlay = null; - var showOverlayTask = this.InvokeAsync(() => loadingOverlay = this.LoadingOverlay.ShowLoadingIndicator()); - - try - { - this._context = await this.GameConfigurationSource - .GetContextAsync(cancellationToken) - .ConfigureAwait(true); - - var gameConfig = await this.GameConfigurationSource - .GetOwnerAsync(Guid.Empty, cancellationToken) - .ConfigureAwait(false); - - this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList(); - } - finally - { - await showOverlayTask.ConfigureAwait(false); - loadingOverlay?.Dispose(); - } - - await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); - - return true; - } - - private async Task LoadDataAsync(CancellationToken cancellationToken) - { - IDisposable? modal = null; - var showModalTask = this.InvokeAsync(() => modal = this.LoadingOverlay.ShowLoadingIndicator()); - - try - { - if (!cancellationToken.IsCancellationRequested) - { - var gameConfig = await this.GameConfigurationSource - .GetOwnerAsync(Guid.Empty, cancellationToken) - .ConfigureAwait(false); - - try - { - this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList(); - } - catch (Exception ex) - { - this.Logger.LogError( - ex, - "Could not load game maps: {Message}{NewLine}{StackTrace}", - ex.Message, - Environment.NewLine, - ex.StackTrace); - - await this.ModalService - .ShowMessageAsync(Resources.Error, Resources.CouldNotLoadMapDataCheckTheLogs) - .ConfigureAwait(false); - } - - await showModalTask.ConfigureAwait(false); - modal?.Dispose(); - await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); - } - } - catch (TargetInvocationException ex) when (ex.InnerException is ObjectDisposedException) - { - // See ObjectDisposedException. - } - catch (ObjectDisposedException) - { - // Happens when the user navigated away. The persistence layer does not - // yet have a cancellation token based async API so we swallow this. - } - } - - private async Task SaveChangesAsync() - { - try - { - var context = await this.GameConfigurationSource.GetContextAsync().ConfigureAwait(true); - var success = await context.SaveChangesAsync().ConfigureAwait(true); - var text = success ? Resources.SavedChanges : Resources.NoChangesToSave; - this.ToastService.ShowSuccess(text); - } - catch (Exception ex) - { - this.Logger.LogError(ex, "Error during saving"); - this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorCheckLogs, ex.Message)); - } - } - - private void BuildMapEditorFragment(RenderTreeBuilder builder) - { - builder.OpenComponent(15); - builder.AddAttribute(16, nameof(MapEditor.Maps), this._maps); - builder.AddAttribute(17, nameof(MapEditor.SelectedMapId), this.SelectedMapId); - builder.AddAttribute(18, nameof(MapEditor.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync)); - builder.AddAttribute( - 19, - nameof(MapEditor.SelectedMapChanging), - EventCallback.Factory.Create(this, this.OnSelectedMapChangingAsync)); - builder.CloseComponent(); - } -} \ No newline at end of file + protected override string Caption => Resources.MapEditor; +} diff --git a/src/Web/AdminPanel/Pages/EditTerrain.cs b/src/Web/AdminPanel/Pages/EditTerrain.cs new file mode 100644 index 0000000000..1482d0ed59 --- /dev/null +++ b/src/Web/AdminPanel/Pages/EditTerrain.cs @@ -0,0 +1,21 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using Microsoft.AspNetCore.Components; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +/// +/// A page, which shows a for the terrains of all . +/// +[Route("/terrain-editor")] +[Route("/terrain-editor/{SelectedMapId:guid}")] +public sealed class EditTerrain : MapEditorPageBase +{ + /// + protected override string Caption => Resources.TerrainEditor; +} diff --git a/src/Web/AdminPanel/Pages/MapEditorPageBase.cs b/src/Web/AdminPanel/Pages/MapEditorPageBase.cs new file mode 100644 index 0000000000..9617ad24f3 --- /dev/null +++ b/src/Web/AdminPanel/Pages/MapEditorPageBase.cs @@ -0,0 +1,295 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.AdminPanel.Pages; + +using System.Reflection; +using System.Threading; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.AdminPanel.Properties; +using MUnique.OpenMU.Web.Shared; +using MUnique.OpenMU.Web.Shared.Components; +using MUnique.OpenMU.Web.Shared.Components.MapEditor; +using MUnique.OpenMU.Web.Shared.Components.Modal; +using MUnique.OpenMU.Web.Shared.Components.Toast; +using MUnique.OpenMU.Web.Shared.Services; + +/// +/// The base class of the pages, which show an editor for all . +/// It loads the maps, saves the changes and asks before discarding unsaved changes. +/// +/// The type of the editor component, which has the same parameters as the . +public abstract class MapEditorPageBase : ComponentBase, IDisposable + where TEditor : IComponent +{ + private List? _maps; + private CancellationTokenSource? _disposeCts; + private IContext? _context; + private IDisposable? _navigationLockDisposable; + + /// + /// Gets or sets the selected map identifier. + /// + [Parameter] + public Guid SelectedMapId { get; set; } + + /// + /// Gets the caption of the page. + /// + protected abstract string Caption { get; } + + /// + /// Gets or sets the modal service. + /// + [Inject] + private IModalService ModalService { get; set; } = null!; + + /// + /// Gets or sets the loading overlay service. + /// + [Inject] + private LoadingOverlayService LoadingOverlay { get; set; } = null!; + + /// + /// Gets or sets the toast service. + /// + [Inject] + private IToastService ToastService { get; set; } = null!; + + /// + /// Gets or sets the game configuration source. + /// + [Inject] + private IDataSource GameConfigurationSource { get; set; } = null!; + + /// + /// Gets or sets the logger. + /// + [Inject] + private ILogger> Logger { get; set; } = null!; + + /// + /// Gets or sets the navigation manager. + /// + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + /// + /// Gets or sets the JavaScript runtime. + /// + [Inject] + private IJSRuntime JavaScript { get; set; } = null!; + + /// + public void Dispose() + { + this._disposeCts?.Cancel(); + this._disposeCts?.Dispose(); + this._disposeCts = null; + + this._navigationLockDisposable?.Dispose(); + this._navigationLockDisposable = null; + GC.SuppressFinalize(this); + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (this._maps is null) + { + return; + } + + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(Breadcrumb.Caption), this.Caption); + builder.CloseComponent(); + + builder.OpenComponent>(10); + builder.AddAttribute(11, nameof(CascadingValue.Value), this._context); + builder.AddAttribute(12, nameof(CascadingValue.IsFixed), false); + builder.AddAttribute(13, nameof(CascadingValue.ChildContent), (RenderFragment)this.BuildEditorFragment); + builder.CloseComponent(); + } + + /// + protected override async Task OnParametersSetAsync() + { + await (this._disposeCts?.CancelAsync() ?? Task.CompletedTask).ConfigureAwait(false); + this._disposeCts?.Dispose(); + this._disposeCts = new CancellationTokenSource(); + + this._context = await this.GameConfigurationSource + .GetContextAsync(this._disposeCts.Token) + .ConfigureAwait(false); + + await base.OnParametersSetAsync().ConfigureAwait(false); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender).ConfigureAwait(false); + if (this._maps is null) + { + this._disposeCts ??= new CancellationTokenSource(); + var cts = this._disposeCts.Token; + _ = Task.Run(() => this.LoadDataAsync(cts), cts); + } + } + + /// + protected override Task OnInitializedAsync() + { + this._navigationLockDisposable = this.NavigationManager.RegisterLocationChangingHandler(this.OnBeforeInternalNavigationAsync); + return base.OnInitializedAsync(); + } + + private async ValueTask OnBeforeInternalNavigationAsync(LocationChangingContext context) + { + if (!await this.AllowChangeAsync().ConfigureAwait(false)) + { + context.PreventNavigation(); + } + } + + private async Task OnSelectedMapChangingAsync(MapChangingArgs eventArgs) + { + eventArgs.Cancel = !await this.AllowChangeAsync().ConfigureAwait(true); + if (!eventArgs.Cancel) + { + this.SelectedMapId = eventArgs.NextMap; + } + } + + private async ValueTask AllowChangeAsync() + { + var cancellationToken = this._disposeCts?.Token ?? default; + var persistenceContext = await this.GameConfigurationSource + .GetContextAsync(cancellationToken) + .ConfigureAwait(true); + + if (persistenceContext?.HasChanges is not true) + { + return true; + } + + var isConfirmed = await this.JavaScript + .InvokeAsync("window.confirm", cancellationToken, Resources.UnsavedChangesQuestion) + .ConfigureAwait(true); + + if (!isConfirmed) + { + return false; + } + + await this.GameConfigurationSource.DiscardChangesAsync().ConfigureAwait(true); + + IDisposable? loadingOverlay = null; + var showOverlayTask = this.InvokeAsync(() => loadingOverlay = this.LoadingOverlay.ShowLoadingIndicator()); + + try + { + this._context = await this.GameConfigurationSource + .GetContextAsync(cancellationToken) + .ConfigureAwait(true); + + var gameConfig = await this.GameConfigurationSource + .GetOwnerAsync(Guid.Empty, cancellationToken) + .ConfigureAwait(false); + + this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList(); + } + finally + { + await showOverlayTask.ConfigureAwait(false); + loadingOverlay?.Dispose(); + } + + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + + return true; + } + + private async Task LoadDataAsync(CancellationToken cancellationToken) + { + IDisposable? modal = null; + var showModalTask = this.InvokeAsync(() => modal = this.LoadingOverlay.ShowLoadingIndicator()); + + try + { + if (!cancellationToken.IsCancellationRequested) + { + var gameConfig = await this.GameConfigurationSource + .GetOwnerAsync(Guid.Empty, cancellationToken) + .ConfigureAwait(false); + + try + { + this._maps = gameConfig.Maps.OrderBy(c => c.Number).ToList(); + } + catch (Exception ex) + { + this.Logger.LogError( + ex, + "Could not load game maps: {Message}{NewLine}{StackTrace}", + ex.Message, + Environment.NewLine, + ex.StackTrace); + + await this.ModalService + .ShowMessageAsync(Resources.Error, Resources.CouldNotLoadMapDataCheckTheLogs) + .ConfigureAwait(false); + } + + await showModalTask.ConfigureAwait(false); + modal?.Dispose(); + await this.InvokeAsync(this.StateHasChanged).ConfigureAwait(false); + } + } + catch (TargetInvocationException ex) when (ex.InnerException is ObjectDisposedException) + { + // See ObjectDisposedException. + } + catch (ObjectDisposedException) + { + // Happens when the user navigated away. The persistence layer does not + // yet have a cancellation token based async API so we swallow this. + } + } + + private async Task SaveChangesAsync() + { + try + { + var context = await this.GameConfigurationSource.GetContextAsync().ConfigureAwait(true); + var success = await context.SaveChangesAsync().ConfigureAwait(true); + var text = success ? Resources.SavedChanges : Resources.NoChangesToSave; + this.ToastService.ShowSuccess(text); + } + catch (Exception ex) + { + this.Logger.LogError(ex, "Error during saving"); + this.ToastService.ShowError(string.Format(Resources.UnexpectedErrorCheckLogs, ex.Message)); + } + } + + private void BuildEditorFragment(RenderTreeBuilder builder) + { + builder.OpenComponent(15); + builder.AddAttribute(16, nameof(MapEditor.Maps), this._maps); + builder.AddAttribute(17, nameof(MapEditor.SelectedMapId), this.SelectedMapId); + builder.AddAttribute(18, nameof(MapEditor.OnValidSubmit), EventCallback.Factory.Create(this, this.SaveChangesAsync)); + builder.AddAttribute( + 19, + nameof(MapEditor.SelectedMapChanging), + EventCallback.Factory.Create(this, this.OnSelectedMapChangingAsync)); + builder.CloseComponent(); + } +} \ No newline at end of file diff --git a/src/Web/AdminPanel/Properties/Resources.Designer.cs b/src/Web/AdminPanel/Properties/Resources.Designer.cs index 54b17c5303..3cadc21cab 100644 --- a/src/Web/AdminPanel/Properties/Resources.Designer.cs +++ b/src/Web/AdminPanel/Properties/Resources.Designer.cs @@ -5557,5 +5557,14 @@ public static string CaptionsCheckingLinkable { return ResourceManager.GetString("CaptionsCheckingLinkable", resourceCulture); } } + + /// + /// Looks up a localized string similar to Terrain Editor. + /// + public static string TerrainEditor { + get { + return ResourceManager.GetString("TerrainEditor", resourceCulture); + } + } } } diff --git a/src/Web/AdminPanel/Properties/Resources.de.resx b/src/Web/AdminPanel/Properties/Resources.de.resx index 1a4747c13f..5c0e12bce7 100644 --- a/src/Web/AdminPanel/Properties/Resources.de.resx +++ b/src/Web/AdminPanel/Properties/Resources.de.resx @@ -1878,6 +1878,9 @@ Produkt {0} hat eine Menge kleiner als 1. + + Terrain-Editor + Logging-Konfiguration diff --git a/src/Web/AdminPanel/Properties/Resources.resx b/src/Web/AdminPanel/Properties/Resources.resx index 19928b6063..11e14e2409 100644 --- a/src/Web/AdminPanel/Properties/Resources.resx +++ b/src/Web/AdminPanel/Properties/Resources.resx @@ -1969,6 +1969,9 @@ Product {0} has a quantity below 1. + + Terrain Editor + Logging Configuration diff --git a/src/Web/AdminPanel/Properties/Resources.zh-CN.resx b/src/Web/AdminPanel/Properties/Resources.zh-CN.resx index d02828a29f..3f6074559f 100644 --- a/src/Web/AdminPanel/Properties/Resources.zh-CN.resx +++ b/src/Web/AdminPanel/Properties/Resources.zh-CN.resx @@ -1786,4 +1786,7 @@ 地图 + + 地形编辑器 + diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainEditMode.cs b/src/Web/Shared/Components/TerrainEditor/TerrainEditMode.cs new file mode 100644 index 0000000000..b41d5aab92 --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainEditMode.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +/// +/// Defines how the selected attributes are applied to the edited tiles of the . +/// +public enum TerrainEditMode +{ + /// + /// The selected attributes are added to the existing attributes of a tile. + /// + Add, + + /// + /// The selected attributes are removed from the existing attributes of a tile. + /// + Remove, + + /// + /// The attributes of a tile are replaced by the selected attributes. + /// + Replace, +} diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor new file mode 100644 index 0000000000..f7a6189337 --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor @@ -0,0 +1,153 @@ +@using MUnique.OpenMU.DataModel +@using MUnique.OpenMU.DataModel.Configuration +@using MUnique.OpenMU.Persistence +@using MUnique.OpenMU.Web.Shared.Properties + +@implements IAsyncDisposable + +@if (this._selectedMap is null) +{ + return; +} + +

@this._selectedMap.Name.ToString()

+ +
+
+ + +
+
+ + +
+
+ + + +
+
+
+
+
+ +
+
+
+
+
+
+ + +
+ +
+
+ @Resources.TerrainTool +
+ @foreach (var tool in Enum.GetValues()) + { + + } +
+ @if (this._tool == TerrainTool.Brush) + { + + + } +
+ +
+ @Resources.TerrainEditMode + @foreach (var mode in Enum.GetValues()) + { +
+ + +
+ } +
+ +
+ @Resources.TerrainAttributes + @foreach (var attributeType in EditableAttributes) + { +
+ + +
+ } +
+ +
+ @Resources.TerrainLegend +
    +
  • @Resources.TerrainWalkable
  • + @foreach (var entry in Palette) + { +
  • @GetLegendCaption(entry.Mask)
  • + } +
+
+ +
@Resources.TerrainEditorRestartHint
+
+
diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.cs b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.cs new file mode 100644 index 0000000000..d060c5727e --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.cs @@ -0,0 +1,549 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +using System.IO; +using BlazorInputFile; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Logging; +using Microsoft.JSInterop; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Persistence; +using MUnique.OpenMU.Web.Shared.Components.MapEditor; +using MUnique.OpenMU.Web.Shared.Components.Toast; +using MUnique.OpenMU.Web.Shared.Properties; + +/// +/// A graphical editor for the terrains of a , which are its +/// and the . +/// +/// +/// The tiles are edited in the browser. After each edit, the whole terrain is transferred back +/// and assigned to the edited terrain, so that the persistence context tracks the change. +/// +public partial class TerrainEditor : IAsyncDisposable +{ + private const string ImportInputId = "terrain-import-file-input"; + + private const int MaximumBrushSize = 25; + + /// + /// The default zoom, which shows the whole terrain with 3 pixels per tile. + /// + private const int DefaultZoom = 3; + + /// + /// The minimum zoom, which shows one pixel per tile. + /// + private const int MinimumZoom = 1; + + /// + /// The maximum zoom, which shows 16 pixels per tile. + /// + private const int MaximumZoom = 16; + + /// + /// The maximum size of an imported file, which is the size of a client terrain file with two bytes per tile. + /// + private const int MaximumImportFileSize = 4 + (TerrainFileConverter.TileCount * sizeof(ushort)); + + private const string WalkableColor = "#50c850"; + + /// + /// The attributes which can be edited. is + /// just set at runtime by the game server, so it doesn't make sense to edit it. + /// + private static readonly TerrainAttributeType[] EditableAttributes = + [ + TerrainAttributeType.Safezone, + TerrainAttributeType.Blocked, + TerrainAttributeType.NoGround, + TerrainAttributeType.Water, + ]; + + /// + /// The colors of the tiles, by priority. The first entry whose mask matches the attributes of a tile defines its color. + /// Tiles without attributes are drawn with the . + /// + private static readonly (int Mask, string Color)[] Palette = + [ + ((int)TerrainAttributeType.Blocked, "#7a3b3b"), + ((int)TerrainAttributeType.NoGround, "#000000"), + ((int)TerrainAttributeType.Water, "#2f6fd6"), + (OtherAttributesMask, "#b08a3c"), + ((int)TerrainAttributeType.Safezone, "#9a9a9a"), + ]; + + private static readonly string JsModulePath = + $"./_content/{typeof(TerrainEditor).Assembly.GetName().Name}/Components/TerrainEditor/{nameof(TerrainEditor)}.razor.js"; + + private ElementReference _mapSelectRef; + private ElementReference _hostRef; + private ElementReference _canvasRef; + private ElementReference _selectionRef; + private ElementReference _statusRef; + + private IJSObjectReference? _jsModule; + private IJSObjectReference? _editor; + private DotNetObjectReference? _dotNetRef; + + private GameMapDefinition? _selectedMap; + private GameMapTerrainVariant? _selectedVariant; + + /// + /// The map or variant of which the terrain is currently loaded in the browser. + /// + private object? _loadedTerrainOwner; + + private TerrainTool _tool = TerrainTool.Brush; + private TerrainEditMode _mode = TerrainEditMode.Add; + private TerrainAttributeType _attributes = TerrainAttributeType.Blocked; + private int _brushSize = 1; + private int _zoom = DefaultZoom; + private bool _showGrid; + private bool _canUndo; + private bool _canRedo; + + /// + /// Gets or sets the callback invoked before the selected map changes, + /// allowing the caller to cancel the change. + /// + [Parameter] + public EventCallback SelectedMapChanging { get; set; } + + /// + /// Gets or sets the maps which can be selected. + /// + [Parameter] + [EditorRequired] + public required List Maps { get; set; } + + /// + /// Gets or sets the ID of the initially selected map. + /// + [Parameter] + public Guid SelectedMapId { get; set; } + + /// + /// Gets or sets the callback which saves the changes. + /// + [Parameter] + public EventCallback OnValidSubmit { get; set; } + + private static int OtherAttributesMask => byte.MaxValue & ~EditableAttributes.Aggregate(0, (mask, attribute) => mask | (int)attribute); + + [Inject] + private IJSRuntime JsRuntime { get; set; } = null!; + + [Inject] + private IToastService ToastService { get; set; } = null!; + + [Inject] + private ILogger Logger { get; set; } = null!; + + /// + /// Gets the map or variant which owns the selected terrain. + /// + private object? SelectedTerrainOwner => (object?)this._selectedVariant ?? this._selectedMap; + + /// + public async ValueTask DisposeAsync() + { + try + { + if (this._editor is not null) + { + await this._editor.InvokeVoidAsync("dispose").ConfigureAwait(false); + await this._editor.DisposeAsync().ConfigureAwait(false); + } + + if (this._jsModule is not null) + { + await this._jsModule.DisposeAsync().ConfigureAwait(false); + } + } + catch (JSDisconnectedException) + { + // The circuit is already gone, so there is nothing to clean up in the browser. + } + + this._dotNetRef?.Dispose(); + GC.SuppressFinalize(this); + } + + /// + /// Called by the browser after the terrain was edited. + /// Transfers the edited terrain and assigns it to the edited map or variant. + /// + /// If set to true, an edit can be undone. + /// If set to true, an undone edit can be redone. + [JSInvokable] + public async Task OnTerrainChangedAsync(bool canUndo, bool canRedo) + { + this._canUndo = canUndo; + this._canRedo = canRedo; + if (this._editor is not { } editor || this._loadedTerrainOwner is not { } owner) + { + return; + } + + try + { + var streamReference = await editor.InvokeAsync("getData").ConfigureAwait(true); + await using (streamReference.ConfigureAwait(true)) + { + var tileAttributes = new byte[TerrainFileConverter.TileCount]; + var stream = await streamReference.OpenReadStreamAsync(TerrainFileConverter.TileCount).ConfigureAwait(true); + await using (stream.ConfigureAwait(true)) + { + await stream.ReadExactlyAsync(tileAttributes).ConfigureAwait(true); + } + + SetTerrainData(owner, TerrainFileConverter.ToServerFile(tileAttributes)); + } + } + catch (Exception ex) when (ex is not JSDisconnectedException) + { + this.Logger.LogError(ex, "Could not transfer the edited terrain."); + this.ToastService.ShowError(string.Format(Resources.TerrainTransferFailed, ex.Message)); + } + + this.StateHasChanged(); + } + + /// + /// Called by the browser when the zoom was changed by the mouse wheel. + /// + /// The zoom. + [JSInvokable] + public void OnZoomChanged(int zoom) + { + this._zoom = zoom; + this.StateHasChanged(); + } + + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + if (this.Maps is not { Count: > 0 }) + { + throw new InvalidOperationException("The Maps parameter is required and must not be empty."); + } + + // The maps are loaded again when changes were discarded, so we select the new instances. + var selectedMapId = this._selectedMap?.GetId() ?? this.SelectedMapId; + var selectedVariantId = this._selectedVariant?.GetId(); + this._selectedMap = this.Maps.FirstOrDefault(m => m.GetId() == selectedMapId) ?? this.Maps.First(); + this._selectedVariant = this._selectedMap.TerrainVariants.FirstOrDefault(v => v.GetId() == selectedVariantId); + } + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender).ConfigureAwait(true); + if (firstRender) + { + this._jsModule = await this.JsRuntime.InvokeAsync("import", JsModulePath).ConfigureAwait(true); + this._dotNetRef = DotNetObjectReference.Create(this); + var options = new + { + Palette = Palette.Select(p => new { p.Mask, p.Color }).ToArray(), + WalkableColor, + FlagNames = Enum.GetValues().Select(a => new { Mask = (int)a, Name = a.GetEnumCaption() }).ToArray(), + Zoom = this._zoom, + MinimumZoom, + MaximumZoom, + }; + + this._editor = await this._jsModule.InvokeAsync( + "createEditor", + this._hostRef, + this._canvasRef, + this._selectionRef, + this._statusRef, + this._dotNetRef, + options).ConfigureAwait(true); + await this.UpdateToolAsync().ConfigureAwait(true); + } + + if (this._editor is not null && !ReferenceEquals(this._loadedTerrainOwner, this.SelectedTerrainOwner)) + { + await this.LoadSelectedTerrainAsync().ConfigureAwait(true); + } + } + + private static byte[]? GetTerrainData(object owner) + { + return owner switch + { + GameMapTerrainVariant variant => variant.TerrainData, + GameMapDefinition map => map.TerrainData, + _ => null, + }; + } + + private static void SetTerrainData(object owner, byte[] terrainData) + { + switch (owner) + { + case GameMapTerrainVariant variant: + variant.TerrainData = terrainData; + break; + case GameMapDefinition map: + map.TerrainData = terrainData; + break; + default: + throw new ArgumentException($"Unknown terrain owner {owner}.", nameof(owner)); + } + } + + private static string GetToolCaption(TerrainTool tool) => tool switch + { + TerrainTool.Rectangle => Resources.TerrainToolRectangle, + TerrainTool.Fill => Resources.TerrainToolFill, + _ => Resources.TerrainToolBrush, + }; + + private static string GetToolIcon(TerrainTool tool) => tool switch + { + TerrainTool.Rectangle => "oi-crop", + TerrainTool.Fill => "oi-droplet", + _ => "oi-brush", + }; + + private static string GetModeCaption(TerrainEditMode mode) => mode switch + { + TerrainEditMode.Remove => Resources.TerrainEditModeRemove, + TerrainEditMode.Replace => Resources.TerrainEditModeReplace, + _ => Resources.TerrainEditModeAdd, + }; + + private static string GetColor(TerrainAttributeType attribute) + { + return Palette.First(p => p.Mask == (int)attribute).Color; + } + + private static string GetLegendCaption(int mask) + { + return mask == OtherAttributesMask + ? Resources.TerrainOtherAttributes + : ((TerrainAttributeType)mask).GetEnumCaption(); + } + + private async Task LoadSelectedTerrainAsync() + { + if (this._editor is not { } editor || this.SelectedTerrainOwner is not { } owner) + { + return; + } + + this._loadedTerrainOwner = owner; + this._canUndo = false; + this._canRedo = false; + var tileAttributes = TerrainFileConverter.GetTileAttributes(GetTerrainData(owner)); + await editor.InvokeVoidAsync("setData", Convert.ToBase64String(tileAttributes), false).ConfigureAwait(true); + this.StateHasChanged(); + } + + private async Task OnMapSelectedAsync(ChangeEventArgs args) + { + if (args.Value is not string idString || !Guid.TryParse(idString, out var mapId) || this._selectedMap is null) + { + return; + } + + var changingArgs = new MapChangingArgs(mapId); + if (this.SelectedMapChanging.HasDelegate) + { + await this.SelectedMapChanging.InvokeAsync(changingArgs).ConfigureAwait(true); + } + + if (changingArgs.Cancel) + { + if (this._jsModule is not null) + { + await this._jsModule.InvokeVoidAsync("setSelectValue", this._mapSelectRef, this._selectedMap.GetId().ToString()).ConfigureAwait(true); + } + + return; + } + + // The maps may have been reloaded by the callback, so we look it up afterward. + this._selectedMap = this.Maps.First(m => m.GetId() == mapId); + this._selectedVariant = null; + } + + private void OnTerrainSelected(ChangeEventArgs args) + { + if (args.Value is not string idString || !Guid.TryParse(idString, out var variantId)) + { + return; + } + + this._selectedVariant = this._selectedMap?.TerrainVariants.FirstOrDefault(v => v.GetId() == variantId); + } + + private async Task SetToolAsync(TerrainTool tool) + { + this._tool = tool; + await this.UpdateToolAsync().ConfigureAwait(true); + } + + private async Task SetModeAsync(TerrainEditMode mode) + { + this._mode = mode; + await this.UpdateToolAsync().ConfigureAwait(true); + } + + private async Task ToggleAttributeAsync(TerrainAttributeType attribute) + { + this._attributes ^= attribute; + await this.UpdateToolAsync().ConfigureAwait(true); + } + + private async Task OnBrushSizeChangedAsync(ChangeEventArgs args) + { + if (int.TryParse(args.Value?.ToString(), out var size)) + { + this._brushSize = Math.Clamp(size, 1, MaximumBrushSize); + await this.UpdateToolAsync().ConfigureAwait(true); + } + } + + private async Task UpdateToolAsync() + { + if (this._editor is { } editor) + { + await editor.InvokeVoidAsync( + "setTool", + this._tool.ToString().ToLowerInvariant(), + this._brushSize, + this._mode.ToString().ToLowerInvariant(), + (int)this._attributes).ConfigureAwait(true); + } + } + + private async Task ZoomByAsync(int steps) + { + if (this._editor is { } editor) + { + this._zoom = await editor.InvokeAsync("zoomBy", steps).ConfigureAwait(true); + } + } + + private async Task ResetZoomAsync() + { + if (this._editor is { } editor) + { + this._zoom = await editor.InvokeAsync("zoomTo", DefaultZoom).ConfigureAwait(true); + } + } + + private async Task ToggleGridAsync() + { + this._showGrid = !this._showGrid; + if (this._editor is { } editor) + { + await editor.InvokeVoidAsync("setGrid", this._showGrid).ConfigureAwait(true); + } + } + + private async Task UndoAsync() + { + if (this._editor is { } editor) + { + await editor.InvokeVoidAsync("undo").ConfigureAwait(true); + } + } + + private async Task RedoAsync() + { + if (this._editor is { } editor) + { + await editor.InvokeVoidAsync("redo").ConfigureAwait(true); + } + } + + private async Task SaveAsync() + { + await this.OnValidSubmit.InvokeAsync().ConfigureAwait(true); + } + + private async Task TriggerFileImportAsync() + { + if (this._jsModule is { } jsModule) + { + await jsModule.InvokeVoidAsync("triggerFileInput", ImportInputId).ConfigureAwait(true); + } + } + + private async Task ImportFileSelectedAsync(IFileListEntry[] files) + { + if (files.FirstOrDefault() is not { } file || this._editor is not { } editor) + { + return; + } + + if (file.Size > MaximumImportFileSize) + { + this.ToastService.ShowError(Resources.InvalidTerrainFile); + return; + } + + using var memoryStream = new MemoryStream(); + await file.Data.CopyToAsync(memoryStream).ConfigureAwait(true); + if (!TerrainFileConverter.TryReadFile(memoryStream.ToArray(), out var tileAttributes)) + { + this.ToastService.ShowError(Resources.InvalidTerrainFile); + return; + } + + // The editor takes it as an edit which can be undone, and transfers it back to us. + await editor.InvokeVoidAsync("setData", Convert.ToBase64String(tileAttributes), true).ConfigureAwait(true); + this.ToastService.ShowSuccess(Resources.TerrainImported); + } + + private async Task DownloadServerFileAsync() + { + if (this._selectedMap is not { } map || this.SelectedTerrainOwner is not { } owner) + { + return; + } + + var tileAttributes = TerrainFileConverter.GetTileAttributes(GetTerrainData(owner)); + await this.DownloadAsync($"Terrain{this.GetFileNameSuffix(map)}.att", TerrainFileConverter.ToServerFile(tileAttributes)).ConfigureAwait(true); + } + + private async Task DownloadClientFileAsync() + { + if (this._selectedMap is not { } map || this.SelectedTerrainOwner is not { } owner) + { + return; + } + + var tileAttributes = TerrainFileConverter.GetTileAttributes(GetTerrainData(owner)); + await this.DownloadAsync($"EncTerrain{this.GetFileNameSuffix(map)}.att", TerrainFileConverter.ToClientFile(tileAttributes, map.Number)).ConfigureAwait(true); + } + + /// + /// Gets the suffix of the file names, which is the number of the map as it's used by the file names of the client and the original server. + /// The number of a variant is appended, because there is no common naming scheme for them. + /// + private string GetFileNameSuffix(GameMapDefinition map) + { + var suffix = (map.Number + 1).ToString(System.Globalization.CultureInfo.InvariantCulture); + return this._selectedVariant is { } variant ? $"{suffix}_{variant.Number}" : suffix; + } + + private async Task DownloadAsync(string fileName, byte[] content) + { + if (this._jsModule is { } jsModule) + { + await jsModule.InvokeVoidAsync("downloadFile", fileName, Convert.ToBase64String(content)).ConfigureAwait(true); + } + } +} diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.js b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.js new file mode 100644 index 0000000000..8d18e16598 --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainEditor.razor.js @@ -0,0 +1,533 @@ +/** + * The terrain editor keeps the attributes of the 256 x 256 tiles of a terrain in the browser, + * so that painting is immediate. After each finished edit, the Blazor component is notified + * and fetches the whole terrain through a stream. + * + * Like in the map editor, the game X coordinate is drawn vertically (rows) and + * the game Y coordinate horizontally (columns). + */ + +const SIZE = 256; +const MAX_HISTORY = 100; + +class TerrainEditor { + /** + * @param {HTMLElement} host - The scrollable host element. + * @param {HTMLCanvasElement} canvas - The canvas which displays the terrain. + * @param {HTMLElement} selection - The element which previews a rectangle selection. + * @param {HTMLElement} status - The element which shows the hovered tile. + * @param {object} dotNetRef - The reference to the Blazor component. + * @param {object} options - The palette, flag names and the zoom limits. + */ + constructor(host, canvas, selection, status, dotNetRef, options) { + this.host = host; + this.canvas = canvas; + this.selection = selection; + this.status = status; + this.dotNetRef = dotNetRef; + this.palette = options.palette.map((p) => ({ mask: p.mask, rgb: TerrainEditor.parseColor(p.color) })); + this.walkableRgb = TerrainEditor.parseColor(options.walkableColor); + this.flagNames = options.flagNames; + this.zoom = options.zoom; + this.minimumZoom = options.minimumZoom; + this.maximumZoom = options.maximumZoom; + + this.data = new Uint8Array(SIZE * SIZE); + this.undoStack = []; + this.redoStack = []; + + this.tool = "brush"; + this.brushSize = 1; + this.mode = "add"; + this.flags = 0; + + this.strokeSnapshot = null; + this.lastTile = null; + this.startTile = null; + this.pan = null; + + canvas.width = SIZE; + canvas.height = SIZE; + this.context = canvas.getContext("2d"); + this.image = this.context.createImageData(SIZE, SIZE); + + this.onMouseDown = this.onMouseDown.bind(this); + this.onMouseMove = this.onMouseMove.bind(this); + this.onMouseUp = this.onMouseUp.bind(this); + this.onMouseLeave = this.onMouseLeave.bind(this); + this.onWheel = this.onWheel.bind(this); + this.onContextMenu = (e) => e.preventDefault(); + + host.addEventListener("mousedown", this.onMouseDown); + host.addEventListener("mouseleave", this.onMouseLeave); + host.addEventListener("wheel", this.onWheel, { passive: false }); + host.addEventListener("contextmenu", this.onContextMenu); + document.addEventListener("mousemove", this.onMouseMove); + document.addEventListener("mouseup", this.onMouseUp); + + this.applyZoom(); + } + + static parseColor(hex) { + const value = parseInt(hex.substring(1), 16); + return [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]; + } + + /** + * Replaces the displayed terrain. + * @param {string} base64 - The attributes of all tiles, base64 encoded. + * @param {boolean} asEdit - True, if the replacement is an undoable edit (e.g. an import); + * false, if another terrain was loaded. + */ + setData(base64, asEdit) { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + if (bytes.length !== SIZE * SIZE) { + return; + } + + if (asEdit) { + this.pushUndo(this.data.slice()); + this.data = bytes; + this.render(); + this.notifyChanged(); + } else { + this.data = bytes; + this.undoStack = []; + this.redoStack = []; + this.render(); + } + } + + /** + * Gets the attributes of all tiles. They are requested as stream by the Blazor component, + * because they exceed the maximum message size of the circuit. + */ + getData() { + return this.data; + } + + setTool(tool, brushSize, mode, flags) { + this.tool = tool; + this.brushSize = Math.max(1, brushSize | 0); + this.mode = mode; + this.flags = flags & 0xFF; + } + + setGrid(showGrid) { + this.host.classList.toggle("show-grid", showGrid); + } + + undo() { + if (this.undoStack.length === 0) { + return; + } + + this.redoStack.push(this.data); + this.data = this.undoStack.pop(); + this.render(); + this.notifyChanged(); + } + + redo() { + if (this.redoStack.length === 0) { + return; + } + + this.undoStack.push(this.data); + this.data = this.redoStack.pop(); + this.render(); + this.notifyChanged(); + } + + /** + * Changes the zoom by the given number of steps, keeping the center of the view. + * @returns {number} The new zoom. + */ + zoomBy(steps) { + return this.zoomTo(this.zoom + steps); + } + + /** + * Changes the zoom to the given value, keeping the center of the view. + * @returns {number} The new zoom. + */ + zoomTo(zoom) { + const rect = this.host.getBoundingClientRect(); + this.zoomAround(zoom, rect.left + this.host.clientWidth / 2, rect.top + this.host.clientHeight / 2); + return this.zoom; + } + + dispose() { + this.host.removeEventListener("mousedown", this.onMouseDown); + this.host.removeEventListener("mouseleave", this.onMouseLeave); + this.host.removeEventListener("wheel", this.onWheel); + this.host.removeEventListener("contextmenu", this.onContextMenu); + document.removeEventListener("mousemove", this.onMouseMove); + document.removeEventListener("mouseup", this.onMouseUp); + this.dotNetRef = null; + } + + zoomAround(newZoom, clientX, clientY) { + newZoom = Math.max(this.minimumZoom, Math.min(this.maximumZoom, newZoom)); + if (newZoom === this.zoom) { + return; + } + + const rect = this.host.getBoundingClientRect(); + const offsetX = clientX - rect.left; + const offsetY = clientY - rect.top; + const contentX = (this.host.scrollLeft + offsetX) / this.zoom; + const contentY = (this.host.scrollTop + offsetY) / this.zoom; + this.zoom = newZoom; + this.applyZoom(); + this.host.scrollLeft = contentX * newZoom - offsetX; + this.host.scrollTop = contentY * newZoom - offsetY; + this.dotNetRef?.invokeMethodAsync("OnZoomChanged", this.zoom); + } + + applyZoom() { + const size = `${SIZE * this.zoom}px`; + this.canvas.style.width = size; + this.canvas.style.height = size; + this.host.style.setProperty("--tile-size", `${this.zoom}px`); + this.updateSelectionPreview(null); + } + + render() { + const pixels = this.image.data; + for (let row = 0; row < SIZE; row++) { + for (let column = 0; column < SIZE; column++) { + // row = game X, column = game Y; the data is stored by y, then x. + const rgb = this.getColor(this.data[(column * SIZE) + row]); + const offset = ((row * SIZE) + column) * 4; + pixels[offset] = rgb[0]; + pixels[offset + 1] = rgb[1]; + pixels[offset + 2] = rgb[2]; + pixels[offset + 3] = 255; + } + } + + this.context.putImageData(this.image, 0, 0); + } + + getColor(value) { + for (const entry of this.palette) { + if ((value & entry.mask) !== 0) { + return entry.rgb; + } + } + + return this.walkableRgb; + } + + getTile(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = Math.floor((e.clientY - rect.top) / this.zoom); + const y = Math.floor((e.clientX - rect.left) / this.zoom); + if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) { + return null; + } + + return { x, y }; + } + + applyToValue(value) { + switch (this.mode) { + case "remove": + return value & ~this.flags; + case "replace": + return this.flags; + default: + return value | this.flags; + } + } + + applyToTile(x, y) { + if (x < 0 || y < 0 || x >= SIZE || y >= SIZE) { + return; + } + + const index = (y * SIZE) + x; + this.data[index] = this.applyToValue(this.data[index]); + } + + applyBrush(tile) { + const offset = Math.floor((this.brushSize - 1) / 2); + for (let x = tile.x - offset; x < tile.x - offset + this.brushSize; x++) { + for (let y = tile.y - offset; y < tile.y - offset + this.brushSize; y++) { + this.applyToTile(x, y); + } + } + } + + /** Applies the brush along the line between two tiles, so that fast mouse movements leave no gaps. */ + applyBrushLine(from, to) { + let x = from.x; + let y = from.y; + const dx = Math.abs(to.x - x); + const dy = -Math.abs(to.y - y); + const stepX = x < to.x ? 1 : -1; + const stepY = y < to.y ? 1 : -1; + let error = dx + dy; + this.applyBrush({ x, y }); + while (x !== to.x || y !== to.y) { + const doubledError = 2 * error; + if (doubledError >= dy) { + error += dy; + x += stepX; + } + + if (doubledError <= dx) { + error += dx; + y += stepY; + } + + this.applyBrush({ x, y }); + } + } + + applyRectangle(from, to) { + for (let x = Math.min(from.x, to.x); x <= Math.max(from.x, to.x); x++) { + for (let y = Math.min(from.y, to.y); y <= Math.max(from.y, to.y); y++) { + this.applyToTile(x, y); + } + } + } + + /** Applies the edit to all connected tiles which have the same value as the clicked one. */ + applyFill(tile) { + const target = this.data[(tile.y * SIZE) + tile.x]; + if (this.applyToValue(target) === target) { + return; + } + + const visited = new Uint8Array(SIZE * SIZE); + const stack = [(tile.y * SIZE) + tile.x]; + while (stack.length > 0) { + const index = stack.pop(); + if (visited[index] || this.data[index] !== target) { + continue; + } + + visited[index] = 1; + this.data[index] = this.applyToValue(target); + const x = index % SIZE; + const y = Math.floor(index / SIZE); + if (x > 0) { + stack.push(index - 1); + } + + if (x < SIZE - 1) { + stack.push(index + 1); + } + + if (y > 0) { + stack.push(index - SIZE); + } + + if (y < SIZE - 1) { + stack.push(index + SIZE); + } + } + } + + onMouseDown(e) { + if (e.button === 2 || e.button === 1) { + this.pan = { x: e.clientX, y: e.clientY, left: this.host.scrollLeft, top: this.host.scrollTop }; + this.host.classList.add("panning"); + e.preventDefault(); + return; + } + + const tile = e.button === 0 ? this.getTile(e) : null; + if (!tile || (this.flags === 0 && this.mode !== "replace")) { + return; + } + + e.preventDefault(); + this.strokeSnapshot = this.data.slice(); + switch (this.tool) { + case "fill": + this.applyFill(tile); + this.finishStroke(); + break; + case "rectangle": + this.startTile = tile; + this.updateSelectionPreview(tile); + break; + default: + this.lastTile = tile; + this.applyBrush(tile); + this.render(); + break; + } + } + + onMouseMove(e) { + if (this.pan) { + this.host.scrollLeft = this.pan.left - (e.clientX - this.pan.x); + this.host.scrollTop = this.pan.top - (e.clientY - this.pan.y); + return; + } + + const tile = this.getTile(e); + this.updateStatus(tile); + if (!tile || !this.strokeSnapshot) { + return; + } + + if (this.startTile) { + this.updateSelectionPreview(tile); + } else if (this.lastTile && (tile.x !== this.lastTile.x || tile.y !== this.lastTile.y)) { + this.applyBrushLine(this.lastTile, tile); + this.lastTile = tile; + this.render(); + } + } + + onMouseUp(e) { + if (this.pan) { + this.pan = null; + this.host.classList.remove("panning"); + return; + } + + if (!this.strokeSnapshot) { + return; + } + + if (this.startTile) { + const tile = this.getTile(e) ?? this.clampTile(e); + this.applyRectangle(this.startTile, tile); + } + + this.finishStroke(); + } + + onMouseLeave() { + this.updateStatus(null); + } + + onWheel(e) { + e.preventDefault(); + this.zoomAround(this.zoom + (e.deltaY < 0 ? 1 : -1), e.clientX, e.clientY); + } + + clampTile(e) { + const rect = this.canvas.getBoundingClientRect(); + const clamp = (v) => Math.max(0, Math.min(SIZE - 1, v)); + return { + x: clamp(Math.floor((e.clientY - rect.top) / this.zoom)), + y: clamp(Math.floor((e.clientX - rect.left) / this.zoom)), + }; + } + + finishStroke() { + const snapshot = this.strokeSnapshot; + this.strokeSnapshot = null; + this.lastTile = null; + this.startTile = null; + this.updateSelectionPreview(null); + this.render(); + + if (snapshot.every((value, index) => value === this.data[index])) { + return; + } + + this.pushUndo(snapshot); + this.notifyChanged(); + } + + pushUndo(snapshot) { + this.undoStack.push(snapshot); + if (this.undoStack.length > MAX_HISTORY) { + this.undoStack.shift(); + } + + this.redoStack = []; + } + + notifyChanged() { + this.dotNetRef?.invokeMethodAsync("OnTerrainChangedAsync", this.undoStack.length > 0, this.redoStack.length > 0); + } + + updateSelectionPreview(tile) { + if (!this.startTile || !tile) { + this.selection.style.display = "none"; + return; + } + + const top = Math.min(this.startTile.x, tile.x); + const left = Math.min(this.startTile.y, tile.y); + this.selection.style.display = "block"; + this.selection.style.top = `${top * this.zoom}px`; + this.selection.style.left = `${left * this.zoom}px`; + this.selection.style.height = `${(Math.abs(this.startTile.x - tile.x) + 1) * this.zoom}px`; + this.selection.style.width = `${(Math.abs(this.startTile.y - tile.y) + 1) * this.zoom}px`; + } + + updateStatus(tile) { + if (!tile) { + this.status.textContent = ""; + return; + } + + const value = this.data[(tile.y * SIZE) + tile.x]; + const names = this.flagNames.filter((f) => (value & f.mask) !== 0).map((f) => f.name); + let text = `X: ${tile.x}, Y: ${tile.y} | ${value}`; + if (names.length > 0) { + text += ` (${names.join(", ")})`; + } + + if (this.startTile) { + text += ` | ${Math.abs(this.startTile.x - tile.x) + 1} x ${Math.abs(this.startTile.y - tile.y) + 1}`; + } + + this.status.textContent = text; + } +} + +/** + * Creates a terrain editor. + * @returns {TerrainEditor} The editor, which is used by the Blazor component through an object reference. + */ +export function createEditor(host, canvas, selection, status, dotNetRef, options) { + return new TerrainEditor(host, canvas, selection, status, dotNetRef, options); +} + +/** + * Sets the value of a select element without triggering a change event. + * @param {HTMLSelectElement} selectElement - The select element to update. + * @param {string} value - The value to select. + */ +export function setSelectValue(selectElement, value) { + if (selectElement) { + selectElement.value = value; + } +} + +/** + * Triggers a click on the file input used for importing a terrain file. + * @param {string} id - The id of the input element. + */ +export function triggerFileInput(id) { + document.getElementById(id)?.click(); +} + +/** + * Downloads a base64-encoded byte array as a file via a temporary anchor element. + * @param {string} fileName - The suggested download file name. + * @param {string} base64 - The base64-encoded file content. + */ +export function downloadFile(fileName, base64) { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const blob = new Blob([bytes], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainFileConverter.cs b/src/Web/Shared/Components/TerrainEditor/TerrainFileConverter.cs new file mode 100644 index 0000000000..a141b49a41 --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainFileConverter.cs @@ -0,0 +1,178 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +using System.Diagnostics.CodeAnalysis; +using MUnique.OpenMU.DataModel.Configuration; + +/// +/// Converts terrain attribute data between the format which is stored in +/// (the *.att file of the original server) +/// and the encrypted EncTerrain*.att file of the game client. +/// +/// +/// Both formats hold one attribute byte per tile of the 256 x 256 tiles of a map, row by row (y major). +/// The server file starts with a header of 3 bytes (version, width - 1, height - 1). +/// The client file starts with a header of 4 bytes (version, map number, width - 1, height - 1), +/// is XOR-ed with a 3 byte key and then encrypted with the map file encryption of the client. +/// Newer clients may also use two bytes per tile, of which only the low byte is relevant. +/// +public static class TerrainFileConverter +{ + /// + /// The number of tiles of a terrain. + /// + public const int TileCount = 256 * 256; + + private const int ServerHeaderSize = 3; + + private const int ClientHeaderSize = 4; + + private const byte MaximumCoordinate = 0xFF; + + private const byte InitialMapKey = 0x5E; + + private const byte MapKeyIncrement = 0x3D; + + private static readonly byte[] MapXorKey = [0xD1, 0x73, 0x52, 0xF6, 0xD2, 0x9A, 0xCB, 0x27, 0x3E, 0xAF, 0x59, 0x31, 0x37, 0xB3, 0xE7, 0xA2]; + + private static readonly byte[] BuxKey = [0xFC, 0xCF, 0xAB]; + + /// + /// Gets the attributes of all tiles from the stored terrain data. + /// + /// The terrain data, as stored in . + /// The attributes of all tiles. Missing data results in walkable tiles. + public static byte[] GetTileAttributes(byte[]? terrainData) + { + var result = new byte[TileCount]; + if (terrainData is { Length: > ServerHeaderSize }) + { + var source = terrainData.AsSpan(ServerHeaderSize); + source[..Math.Min(source.Length, TileCount)].CopyTo(result); + } + + return result; + } + + /// + /// Creates the terrain data of the server format, as stored in . + /// + /// The attributes of all tiles. + /// The terrain data. + public static byte[] ToServerFile(ReadOnlySpan tileAttributes) + { + ValidateTileAttributes(tileAttributes); + var result = new byte[ServerHeaderSize + TileCount]; + result[1] = MaximumCoordinate; + result[2] = MaximumCoordinate; + tileAttributes.CopyTo(result.AsSpan(ServerHeaderSize)); + return result; + } + + /// + /// Creates the encrypted terrain file which is loaded by the game client (EncTerrain*.att). + /// + /// The attributes of all tiles. + /// The number of the map. + /// The content of the client terrain file. + public static byte[] ToClientFile(ReadOnlySpan tileAttributes, short mapNumber) + { + ValidateTileAttributes(tileAttributes); + var result = new byte[ClientHeaderSize + TileCount]; + result[1] = unchecked((byte)mapNumber); + result[2] = MaximumCoordinate; + result[3] = MaximumCoordinate; + tileAttributes.CopyTo(result.AsSpan(ClientHeaderSize)); + ApplyBuxKey(result); + EncryptMapFile(result); + return result; + } + + /// + /// Reads the tile attributes from a terrain file, which can be either of the server or of the client format. + /// + /// The content of the file. + /// The attributes of all tiles, if the file could be read. + /// true, if the file has a known format; otherwise, false. + public static bool TryReadFile(ReadOnlySpan fileContent, [NotNullWhen(true)] out byte[]? tileAttributes) + { + tileAttributes = null; + if (fileContent.Length == ServerHeaderSize + TileCount) + { + tileAttributes = fileContent[ServerHeaderSize..].ToArray(); + return true; + } + + var isClientFile = fileContent.Length == ClientHeaderSize + TileCount; + var isExtendedClientFile = fileContent.Length == ClientHeaderSize + (TileCount * sizeof(ushort)); + if (!isClientFile && !isExtendedClientFile) + { + return false; + } + + var decrypted = fileContent.ToArray(); + DecryptMapFile(decrypted); + ApplyBuxKey(decrypted); + if (decrypted[0] != 0 || decrypted[2] != MaximumCoordinate || decrypted[3] != MaximumCoordinate) + { + return false; + } + + var data = decrypted.AsSpan(ClientHeaderSize); + if (isClientFile) + { + tileAttributes = data.ToArray(); + } + else + { + // Two bytes per tile (little endian), of which the client just uses the low byte. + tileAttributes = new byte[TileCount]; + for (int i = 0; i < TileCount; i++) + { + tileAttributes[i] = data[i * sizeof(ushort)]; + } + } + + return true; + } + + private static void ValidateTileAttributes(ReadOnlySpan tileAttributes) + { + if (tileAttributes.Length != TileCount) + { + throw new ArgumentException($"The terrain must have exactly {TileCount} tiles, but has {tileAttributes.Length}.", nameof(tileAttributes)); + } + } + + private static void ApplyBuxKey(Span data) + { + for (int i = 0; i < data.Length; i++) + { + data[i] ^= BuxKey[i % BuxKey.Length]; + } + } + + private static void EncryptMapFile(Span data) + { + byte mapKey = InitialMapKey; + for (int i = 0; i < data.Length; i++) + { + data[i] = (byte)((data[i] + mapKey) ^ MapXorKey[i % MapXorKey.Length]); + mapKey = (byte)(data[i] + MapKeyIncrement); + } + } + + private static void DecryptMapFile(Span data) + { + byte mapKey = InitialMapKey; + for (int i = 0; i < data.Length; i++) + { + var encrypted = data[i]; + data[i] = (byte)((encrypted ^ MapXorKey[i % MapXorKey.Length]) - mapKey); + mapKey = (byte)(encrypted + MapKeyIncrement); + } + } +} diff --git a/src/Web/Shared/Components/TerrainEditor/TerrainTool.cs b/src/Web/Shared/Components/TerrainEditor/TerrainTool.cs new file mode 100644 index 0000000000..8307ba4eb8 --- /dev/null +++ b/src/Web/Shared/Components/TerrainEditor/TerrainTool.cs @@ -0,0 +1,26 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +/// +/// The tool of the , which defines which tiles are edited by a click. +/// +public enum TerrainTool +{ + /// + /// Edits the square of tiles around the mouse cursor while the mouse button is pressed. + /// + Brush, + + /// + /// Edits all tiles of a dragged rectangle. + /// + Rectangle, + + /// + /// Edits all connected tiles which have the same attributes as the clicked tile. + /// + Fill, +} diff --git a/src/Web/Shared/Exports.cs b/src/Web/Shared/Exports.cs index 037bb39c7f..e067c57f16 100644 --- a/src/Web/Shared/Exports.cs +++ b/src/Web/Shared/Exports.cs @@ -53,6 +53,7 @@ private static IEnumerable SharedStylesheets yield return $"{prefix}/css/common.css"; yield return $"{prefix}/css/log.css"; yield return $"{prefix}/css/map-editor.css"; + yield return $"{prefix}/css/terrain-editor.css"; } } diff --git a/src/Web/Shared/Properties/Resources.Designer.cs b/src/Web/Shared/Properties/Resources.Designer.cs index 2b7a58d732..74134d8189 100644 --- a/src/Web/Shared/Properties/Resources.Designer.cs +++ b/src/Web/Shared/Properties/Resources.Designer.cs @@ -1052,5 +1052,98 @@ public static string FileLoaded { /// Gets the localized GameServerSelection_UnknownServer text. public static string GameServerSelection_UnknownServer => ResourceManager.GetString("GameServerSelection_UnknownServer", resourceCulture)!; + /// Gets the localized TerrainEditorTerrain text. + public static string TerrainEditorTerrain => ResourceManager.GetString("TerrainEditorTerrain", resourceCulture)!; + + /// Gets the localized DefaultTerrain text. + public static string DefaultTerrain => ResourceManager.GetString("DefaultTerrain", resourceCulture)!; + + /// Gets the localized TerrainTool text. + public static string TerrainTool => ResourceManager.GetString("TerrainTool", resourceCulture)!; + + /// Gets the localized TerrainToolBrush text. + public static string TerrainToolBrush => ResourceManager.GetString("TerrainToolBrush", resourceCulture)!; + + /// Gets the localized TerrainToolRectangle text. + public static string TerrainToolRectangle => ResourceManager.GetString("TerrainToolRectangle", resourceCulture)!; + + /// Gets the localized TerrainToolFill text. + public static string TerrainToolFill => ResourceManager.GetString("TerrainToolFill", resourceCulture)!; + + /// Gets the localized TerrainBrushSize text. + public static string TerrainBrushSize => ResourceManager.GetString("TerrainBrushSize", resourceCulture)!; + + /// Gets the localized TerrainEditMode text. + public static string TerrainEditMode => ResourceManager.GetString("TerrainEditMode", resourceCulture)!; + + /// Gets the localized TerrainEditModeAdd text. + public static string TerrainEditModeAdd => ResourceManager.GetString("TerrainEditModeAdd", resourceCulture)!; + + /// Gets the localized TerrainEditModeRemove text. + public static string TerrainEditModeRemove => ResourceManager.GetString("TerrainEditModeRemove", resourceCulture)!; + + /// Gets the localized TerrainEditModeReplace text. + public static string TerrainEditModeReplace => ResourceManager.GetString("TerrainEditModeReplace", resourceCulture)!; + + /// Gets the localized TerrainAttributes text. + public static string TerrainAttributes => ResourceManager.GetString("TerrainAttributes", resourceCulture)!; + + /// Gets the localized TerrainWalkable text. + public static string TerrainWalkable => ResourceManager.GetString("TerrainWalkable", resourceCulture)!; + + /// Gets the localized TerrainOtherAttributes text. + public static string TerrainOtherAttributes => ResourceManager.GetString("TerrainOtherAttributes", resourceCulture)!; + + /// Gets the localized TerrainLegend text. + public static string TerrainLegend => ResourceManager.GetString("TerrainLegend", resourceCulture)!; + + /// Gets the localized Redo text. + public static string Redo => ResourceManager.GetString("Redo", resourceCulture)!; + + /// Gets the localized ImportTerrain text. + public static string ImportTerrain => ResourceManager.GetString("ImportTerrain", resourceCulture)!; + + /// Gets the localized ImportTerrainHint text. + public static string ImportTerrainHint => ResourceManager.GetString("ImportTerrainHint", resourceCulture)!; + + /// Gets the localized DownloadServerTerrain text. + public static string DownloadServerTerrain => ResourceManager.GetString("DownloadServerTerrain", resourceCulture)!; + + /// Gets the localized DownloadServerTerrainHint text. + public static string DownloadServerTerrainHint => ResourceManager.GetString("DownloadServerTerrainHint", resourceCulture)!; + + /// Gets the localized DownloadClientTerrain text. + public static string DownloadClientTerrain => ResourceManager.GetString("DownloadClientTerrain", resourceCulture)!; + + /// Gets the localized DownloadClientTerrainHint text. + public static string DownloadClientTerrainHint => ResourceManager.GetString("DownloadClientTerrainHint", resourceCulture)!; + + /// Gets the localized InvalidTerrainFile text. + public static string InvalidTerrainFile => ResourceManager.GetString("InvalidTerrainFile", resourceCulture)!; + + /// Gets the localized TerrainImported text. + public static string TerrainImported => ResourceManager.GetString("TerrainImported", resourceCulture)!; + + /// Gets the localized TerrainEditorShortcutPaint text. + public static string TerrainEditorShortcutPaint => ResourceManager.GetString("TerrainEditorShortcutPaint", resourceCulture)!; + + /// Gets the localized TerrainEditorShortcutPan text. + public static string TerrainEditorShortcutPan => ResourceManager.GetString("TerrainEditorShortcutPan", resourceCulture)!; + + /// Gets the localized TerrainEditorShortcutZoom text. + public static string TerrainEditorShortcutZoom => ResourceManager.GetString("TerrainEditorShortcutZoom", resourceCulture)!; + + /// Gets the localized TerrainEditorRestartHint text. + public static string TerrainEditorRestartHint => ResourceManager.GetString("TerrainEditorRestartHint", resourceCulture)!; + + /// Gets the localized TerrainTransferFailed text. + public static string TerrainTransferFailed => ResourceManager.GetString("TerrainTransferFailed", resourceCulture)!; + + /// Gets the localized ZoomIn text. + public static string ZoomIn => ResourceManager.GetString("ZoomIn", resourceCulture)!; + + /// Gets the localized ZoomOut text. + public static string ZoomOut => ResourceManager.GetString("ZoomOut", resourceCulture)!; + } } diff --git a/src/Web/Shared/Properties/Resources.de.resx b/src/Web/Shared/Properties/Resources.de.resx index ea91985366..aa0e2b7a6d 100644 --- a/src/Web/Shared/Properties/Resources.de.resx +++ b/src/Web/Shared/Properties/Resources.de.resx @@ -523,4 +523,97 @@ Stufe: {2} - {3} Der Charakter wurde erstellt, aber die "Charakter erstellt"-Plugins sind fehlgeschlagen, daher fehlen ihm eventuell Startgegenstände oder -fertigkeiten: {0} + + Terrain + + + Standard-Terrain + + + Werkzeug + + + Pinsel + + + Rechteck + + + Füllen + + + Pinselgröße + + + Modus + + + Attribute hinzufügen + + + Attribute entfernen + + + Alle Attribute ersetzen + + + Attribute + + + Begehbar + + + Andere Attribute + + + Legende + + + Wiederholen + + + Terrain-Datei importieren + + + Akzeptiert die *.att-Datei des Servers und die EncTerrain*.att-Datei des Clients. + + + Server-Terrain-Datei + + + Lädt das Terrain im *.att-Format des Original-Servers herunter. + + + Client-Terrain-Datei + + + Lädt die verschlüsselte Terrain-Datei des Spiel-Clients herunter. Lege sie im Client als Data\World{0}\EncTerrain{0}.att ab. + + + Die Datei ist weder eine Terrain-Datei des Servers noch des Clients. + + + Die Terrain-Datei wurde importiert. Speichere, um sie zu übernehmen. + + + Linksklick, um die Felder mit dem gewählten Werkzeug zu bearbeiten + + + Rechtsklick und ziehen, um das Terrain zu verschieben + + + Mausrad zum Zoomen + + + Geänderte Terrains werden nach einem Neustart der Spielserver übernommen. Spieler benötigen zusätzlich die Client-Terrain-Datei, um die Änderung zu sehen. + + + Das bearbeitete Terrain konnte nicht übertragen werden: {0} + + + Vergrößern + + + Verkleinern + diff --git a/src/Web/Shared/Properties/Resources.resx b/src/Web/Shared/Properties/Resources.resx index a9b4e6ef2b..efb91f6a37 100644 --- a/src/Web/Shared/Properties/Resources.resx +++ b/src/Web/Shared/Properties/Resources.resx @@ -569,4 +569,97 @@ Level: {2} - {3} Could not load the game servers. Close the dialog and try again. + + Terrain + + + Default terrain + + + Tool + + + Brush + + + Rectangle + + + Fill + + + Brush size + + + Mode + + + Add attributes + + + Remove attributes + + + Replace all attributes + + + Attributes + + + Walkable + + + Other attributes + + + Legend + + + Redo + + + Import terrain file + + + Accepts the *.att file of the server and the EncTerrain*.att file of the client. + + + Server terrain file + + + Downloads the terrain in the *.att format of the original server. + + + Client terrain file + + + Downloads the encrypted terrain file of the game client. Place it as Data\World{0}\EncTerrain{0}.att in the client. + + + The file is neither a terrain file of the server nor of the client. + + + The terrain file was imported. Save to apply it. + + + Left click to edit the tiles with the selected tool + + + Right click and drag to pan the terrain + + + Mouse wheel to zoom + + + Changed terrains are applied to the game after restarting the game servers. Players need the client terrain file, too, to see the change. + + + The edited terrain could not be transferred: {0} + + + Zoom in + + + Zoom out + \ No newline at end of file diff --git a/src/Web/Shared/Properties/Resources.zh-CN.resx b/src/Web/Shared/Properties/Resources.zh-CN.resx index e6ed561961..bedef40f9b 100644 --- a/src/Web/Shared/Properties/Resources.zh-CN.resx +++ b/src/Web/Shared/Properties/Resources.zh-CN.resx @@ -545,4 +545,97 @@ 角色已创建,但“角色已创建”插件执行失败,因此可能缺少初始物品或技能:{0} + + 地形 + + + 默认地形 + + + 工具 + + + 画笔 + + + 矩形 + + + 填充 + + + 画笔大小 + + + 模式 + + + 添加属性 + + + 移除属性 + + + 替换所有属性 + + + 属性 + + + 可行走 + + + 其他属性 + + + 图例 + + + 重做 + + + 导入地形文件 + + + 支持服务器的 *.att 文件和客户端的 EncTerrain*.att 文件。 + + + 服务器地形文件 + + + 以原始服务器的 *.att 格式下载地形。 + + + 客户端地形文件 + + + 下载游戏客户端的加密地形文件。请将其放置到客户端的 Data\World{0}\EncTerrain{0}.att。 + + + 该文件既不是服务器的地形文件,也不是客户端的地形文件。 + + + 地形文件已导入。保存后生效。 + + + 左键单击以使用所选工具编辑图块 + + + 右键单击并拖动以平移地形 + + + 使用鼠标滚轮缩放 + + + 修改后的地形在重启游戏服务器后生效。玩家也需要客户端地形文件才能看到变化。 + + + 无法传输已编辑的地形:{0} + + + 放大 + + + 缩小 + diff --git a/src/Web/Shared/wwwroot/css/terrain-editor.css b/src/Web/Shared/wwwroot/css/terrain-editor.css new file mode 100644 index 0000000000..8a4f336f22 --- /dev/null +++ b/src/Web/Shared/wwwroot/css/terrain-editor.css @@ -0,0 +1,87 @@ +/* Terrain editor: scrollable pixelated canvas with optional tile grid, rectangle preview and tool panel. */ +.terrain-editor-row { + display: flex; + flex-wrap: wrap; + gap: 15px; +} + +.terrain-column { + flex-shrink: 0; +} + +.terrain-tool-column { + flex: 1; + min-width: 280px; + max-width: 440px; +} + +.terrain-host-container { + position: relative; + display: inline-block; +} + +.terrain-host { + position: relative; + width: 768px; + max-width: calc(100vw - 32px); + height: 768px; + max-height: 80vh; + overflow: auto; + user-select: none; + cursor: crosshair; + background-color: #1a1a2e; + border: 2px solid #333; + border-radius: 4px; +} + +.terrain-host.panning { + cursor: grabbing; +} + +.terrain-content { + position: relative; + display: inline-block; +} + +.terrain-content canvas { + display: block; + image-rendering: pixelated; + image-rendering: crisp-edges; +} + +.terrain-grid { + display: none; + position: absolute; + inset: 0; + pointer-events: none; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.15) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.15) 1px, transparent 1px); + background-size: var(--tile-size) var(--tile-size); +} + +.terrain-host.show-grid .terrain-grid { + display: block; +} + +.terrain-selection { + display: none; + position: absolute; + pointer-events: none; + border: 1px dashed #fff; + background-color: rgba(255, 255, 255, 0.2); +} + +.terrain-status { + min-height: 1.5em; + font-family: monospace; + font-size: 0.85em; +} + +.terrain-swatch { + display: inline-block; + width: 0.9em; + height: 0.9em; + border: 1px solid #888; + vertical-align: middle; +} diff --git a/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainEditorTests.cs b/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainEditorTests.cs new file mode 100644 index 0000000000..2d922ffc82 --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainEditorTests.cs @@ -0,0 +1,115 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.TerrainEditor; + +using Bunit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.Web.Shared.Components.Toast; +using MUnique.OpenMU.Web.Shared.Components.TerrainEditor; +using BasicModel = MUnique.OpenMU.Persistence.BasicModel; +using TerrainEditorComponent = MUnique.OpenMU.Web.Shared.Components.TerrainEditor.TerrainEditor; + +/// +/// Tests for the . +/// +[TestFixture] +public class TerrainEditorTests +{ + private const string ModulePath = "./_content/MUnique.OpenMU.Web.Shared/Components/TerrainEditor/TerrainEditor.razor.js"; + + private BunitContext _context = null!; + + private BunitJSModuleInterop _module = null!; + + /// + /// Setups the test objects. + /// + [SetUp] + public void Setup() + { + this._context = new BunitContext(); + this._context.JSInterop.Mode = JSRuntimeMode.Loose; + this._module = this._context.JSInterop.SetupModule(ModulePath); + this._module.Mode = JSRuntimeMode.Loose; + this._context.Services.AddSingleton(Mock.Of()); + this._context.Services.AddSingleton(Mock.Of>()); + } + + /// + /// Tears down the test objects. + /// + [TearDown] + public void Teardown() + { + this._context.Dispose(); + } + + /// + /// Tests that the default terrain and the variants of the selected map can be selected. + /// + [Test] + public void TerrainsOfSelectedMapAreSelectable() + { + var map = CreateMap(32); + var variant = new BasicModel.GameMapTerrainVariant { Number = 2, Description = "War" }; + map.TerrainVariants.Add(variant); + + var component = this.Render(map); + + var options = component.FindAll("#terrainSelect option"); + Assert.That(options.Select(o => o.TextContent), Is.EqualTo(new[] { Shared.Properties.Resources.DefaultTerrain, variant.ToString() })); + } + + /// + /// Tests that the editor is created with the terrain of the selected map. + /// + [Test] + public void TerrainOfSelectedMapIsLoaded() + { + var map = CreateMap(0); + var tiles = new byte[TerrainFileConverter.TileCount]; + tiles[42] = (byte)TerrainAttributeType.Blocked; + map.TerrainData = TerrainFileConverter.ToServerFile(tiles); + + this.Render(map); + + this._module.VerifyInvoke("createEditor"); + var setData = this._context.JSInterop.Invocations.Single(i => i.Identifier == "setData"); + Assert.That(setData.Arguments[0], Is.EqualTo(Convert.ToBase64String(tiles))); + Assert.That(setData.Arguments[1], Is.False); + } + + /// + /// Tests that the client terrain file is downloaded with the file name which the client expects. + /// + [Test] + public void ClientFileIsDownloaded() + { + var map = CreateMap(32); + var component = this.Render(map); + + component.FindAll("button").Single(b => b.TextContent.Contains(Shared.Properties.Resources.DownloadClientTerrain, StringComparison.Ordinal)).Click(); + + var download = this._module.VerifyInvoke("downloadFile"); + Assert.That(download.Arguments[0], Is.EqualTo("EncTerrain33.att")); + var content = Convert.FromBase64String((string)download.Arguments[1]!); + Assert.That(TerrainFileConverter.TryReadFile(content, out var tiles), Is.True); + Assert.That(tiles, Is.All.EqualTo(0)); + } + + private static BasicModel.GameMapDefinition CreateMap(short number) + { + return new BasicModel.GameMapDefinition { Number = number, Name = $"Map {number}" }; + } + + private IRenderedComponent Render(GameMapDefinition map) + { + return this._context.Render(parameters => parameters + .Add(p => p.Maps, [map])); + } +} diff --git a/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainFileConverterTests.cs b/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainFileConverterTests.cs new file mode 100644 index 0000000000..a50c05729a --- /dev/null +++ b/tests/MUnique.OpenMU.Web.Tests/TerrainEditor/TerrainFileConverterTests.cs @@ -0,0 +1,118 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Web.Tests.TerrainEditor; + +using MUnique.OpenMU.Web.Shared.Components.TerrainEditor; + +/// +/// Tests for the . +/// +[TestFixture] +public class TerrainFileConverterTests +{ + /// + /// Tests that the server file has the header of the original server, followed by the tiles. + /// + [Test] + public void ServerFileHasHeaderAndTiles() + { + var tiles = CreateTiles(); + + var file = TerrainFileConverter.ToServerFile(tiles); + + Assert.That(file, Has.Length.EqualTo(3 + TerrainFileConverter.TileCount)); + Assert.That(file[..3], Is.EqualTo(new byte[] { 0, 0xFF, 0xFF })); + Assert.That(file[3..], Is.EqualTo(tiles)); + } + + /// + /// Tests that the tiles of a server file can be read again. + /// + [Test] + public void ServerFileCanBeRead() + { + var tiles = CreateTiles(); + + var success = TerrainFileConverter.TryReadFile(TerrainFileConverter.ToServerFile(tiles), out var result); + + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo(tiles)); + } + + /// + /// Tests that the tiles of a client file can be read again. + /// + [Test] + public void ClientFileCanBeRead() + { + var tiles = CreateTiles(); + + var file = TerrainFileConverter.ToClientFile(tiles, 33); + var success = TerrainFileConverter.TryReadFile(file, out var result); + + Assert.That(file, Has.Length.EqualTo(4 + TerrainFileConverter.TileCount)); + Assert.That(file[4..], Is.Not.EqualTo(tiles), "The client file should be encrypted."); + Assert.That(success, Is.True); + Assert.That(result, Is.EqualTo(tiles)); + } + + /// + /// Tests that the start of an encrypted client file matches the known result of the client's encryption + /// for the header of the first map (version 0, map 0, width and height 255) and a walkable first tile. + /// + [Test] + public void ClientFileIsEncryptedLikeTheClient() + { + var file = TerrainFileConverter.ToClientFile(new byte[TerrainFileConverter.TileCount], 0); + + Assert.That(file[..5], Is.EqualTo(new byte[] { 0x8B, 0xE4, 0x27, 0x91, 0x4F })); + } + + /// + /// Tests that files of an unknown size are rejected. + /// + [Test] + public void FileWithUnknownSizeIsRejected() + { + var success = TerrainFileConverter.TryReadFile(new byte[1000], out var result); + + Assert.That(success, Is.False); + Assert.That(result, Is.Null); + } + + /// + /// Tests that a file with the size of a client file, but which doesn't decrypt to a valid header, is rejected. + /// + [Test] + public void ClientFileWithInvalidHeaderIsRejected() + { + var success = TerrainFileConverter.TryReadFile(new byte[4 + TerrainFileConverter.TileCount], out _); + + Assert.That(success, Is.False); + } + + /// + /// Tests that missing terrain data results in walkable tiles. + /// + [Test] + public void MissingTerrainDataResultsInWalkableTiles() + { + var tiles = TerrainFileConverter.GetTileAttributes(null); + + Assert.That(tiles, Has.Length.EqualTo(TerrainFileConverter.TileCount)); + Assert.That(tiles, Is.All.EqualTo(0)); + } + + private static byte[] CreateTiles() + { + var tiles = new byte[TerrainFileConverter.TileCount]; + for (int i = 0; i < tiles.Length; i++) + { + tiles[i] = (byte)(i * 7); + } + + return tiles; + } +}