Draw broken objects as fragments, plane tiles for fences and walls, tileset publishing; mod documentation
This commit is contained in:
1 parent
e153dc9b15
commit
7b28c1bb0a
74 files changed
+2782
-886
No files matched your search
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "Tile renderer contracts and visual preview",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"rpg_demo_game>=1",
|
||||
"rpg_editor>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
/// <summary>Opt-in measurements with the same map, materials, camera rectangle and edits for both projections.</summary>
|
||||
public static class TileRenderBenchmark
|
||||
{
|
||||
public static async Task Run(Node owner, string output)
|
||||
{
|
||||
var results = new List<Dictionary<string, object>>();
|
||||
using var textures = new RpgTextures();
|
||||
using var builder = new RpgRenderSceneBuilder(textures);
|
||||
var tileset = RpgTilesetFactory.Instance.Get("village");
|
||||
var viewport = owner.GetViewport().GetViewportRid();
|
||||
using var process = Process.GetCurrentProcess();
|
||||
RenderingServer.ViewportSetMeasureRenderTime(viewport, true);
|
||||
foreach (var size in new[] { 128, 256 })
|
||||
{
|
||||
var grid = new RpgMapGrid(size, size);
|
||||
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) grid.SetTile(RpgLayer.Back, x, y, 0);
|
||||
var trees = 0;
|
||||
for (var y = 8; y < size - 3; y += 8)
|
||||
for (var x = 8; x < size - 3; x += 8)
|
||||
{
|
||||
for (var ty = 0; ty < 3; ty++) for (var tx = 0; tx < 3; tx++)
|
||||
grid.SetTile(RpgLayer.Main, x + tx, y + ty, 64 + ty * 3 + tx);
|
||||
trees++;
|
||||
}
|
||||
var map = new RpgMap { ID = "benchmark", Width = size, Height = size, Tileset = "village", Zoom = 1 };
|
||||
var clock = Stopwatch.StartNew();
|
||||
var scene = builder.Build(map, grid, tileset);
|
||||
var row = new Dictionary<string, object> { ["cells_per_axis"] = size, ["cold_ms"] = clock.Elapsed.TotalMilliseconds,
|
||||
["surface_texture_bytes"] = builder.SurfaceTextureBytes, ["cold_chunks"] = builder.LastBuiltSurfaceChunks,
|
||||
["trees"] = trees, ["lights"] = 16 };
|
||||
foreach (var id in new[] { "topdown", "isometric" })
|
||||
{
|
||||
var view = TileRendererRegistry.Instance.Resolve(id).Create();
|
||||
owner.AddChild(view.Root);
|
||||
try
|
||||
{
|
||||
clock.Restart();
|
||||
view.Mount(scene);
|
||||
view.Resize(new Vector2(1920, 1024));
|
||||
var frame = new TileFrame { Focus = new Vector2(size, size) / 2, Ambient = Colors.White };
|
||||
for (var i = 0; i < 16; i++) frame.Lights.Add(new TileLight {
|
||||
Position = frame.Focus + new Vector2(i % 4 * 3 - 6, i / 4 * 3 - 6), Color = Colors.Orange, Radius = 4 });
|
||||
view.Present(frame, 0);
|
||||
row[id + "_mount_ms"] = clock.Elapsed.TotalMilliseconds;
|
||||
for (var i = 0; i < 8; i++) await owner.ToSignal(owner.GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
double presentMs = 0, cpuMs = 0, gpuMs = 0;
|
||||
var wall = Stopwatch.StartNew();
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
clock.Restart();
|
||||
view.Present(frame, 1.0 / 60);
|
||||
presentMs += clock.Elapsed.TotalMilliseconds;
|
||||
await owner.ToSignal(owner.GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
cpuMs += RenderingServer.ViewportGetMeasuredRenderTimeCpu(viewport);
|
||||
gpuMs += RenderingServer.ViewportGetMeasuredRenderTimeGpu(viewport);
|
||||
}
|
||||
row[id + "_present_cpu_ms"] = presentMs / 16;
|
||||
row[id + "_frame_wall_ms"] = wall.Elapsed.TotalMilliseconds / 16;
|
||||
row[id + "_draw_calls"] = Performance.GetMonitor(Performance.Monitor.RenderTotalDrawCallsInFrame);
|
||||
row[id + "_total_video_bytes"] = Performance.GetMonitor(Performance.Monitor.RenderVideoMemUsed);
|
||||
row[id + "_render_cpu_ms"] = cpuMs / 16;
|
||||
row[id + "_render_gpu_ms"] = gpuMs / 16;
|
||||
process.Refresh();
|
||||
row[id + "_process_working_set_bytes"] = process.WorkingSet64;
|
||||
}
|
||||
finally { view.Dispose(); }
|
||||
}
|
||||
scene.Release();
|
||||
clock.Restart();
|
||||
scene = builder.Build(map, grid, tileset);
|
||||
row["warm_ms"] = clock.Elapsed.TotalMilliseconds;
|
||||
ModAssert.Equal(0, builder.LastBuiltSurfaceChunks, "unchanged map reuses every surface texture");
|
||||
scene.Release();
|
||||
grid.SetTile(RpgLayer.Back, 31, size / 2, 4);
|
||||
clock.Restart();
|
||||
scene = builder.Build(map, grid, tileset);
|
||||
row["edit_ms"] = clock.Elapsed.TotalMilliseconds;
|
||||
row["edit_chunks"] = builder.LastBuiltSurfaceChunks;
|
||||
ModAssert.Equal(4, builder.LastBuiltSurfaceChunks, "a corner edit rebuilds only four halo chunks");
|
||||
scene.Release();
|
||||
results.Add(row);
|
||||
GameLogger.Log("TEST", "TILE_BENCHMARK " + JsonSerializer.Serialize(row));
|
||||
}
|
||||
RenderingServer.ViewportSetMeasureRenderTime(viewport, false);
|
||||
if (!string.IsNullOrEmpty(output))
|
||||
{
|
||||
Directory.CreateDirectory(output);
|
||||
File.WriteAllText(Path.Combine(output, "benchmark.json"), JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Exercises version checks, sparse redraws and removal through the actual presentation API.</summary>
|
||||
public static class TileRenderDeltaContract
|
||||
{
|
||||
public static int Run(Node owner)
|
||||
{
|
||||
var passed = 0;
|
||||
foreach (var id in new[] { "topdown", "isometric" })
|
||||
{
|
||||
var view = TileRendererRegistry.Instance.Resolve(id).Create();
|
||||
owner.AddChild(view.Root);
|
||||
try
|
||||
{
|
||||
var first = Snapshot(1, Colors.White);
|
||||
view.Mount(first);
|
||||
var unchanged = Snapshot(2, Colors.White);
|
||||
view.Apply(new TileSceneChanges { BaseRevision = 1, Scene = unchanged });
|
||||
ModAssert.Equal(0, ((TileCanvasPresentation)view).LastChangedDrawChunks, "unchanged draw chunks retained " + id);
|
||||
var changed = Snapshot(3, Colors.Red);
|
||||
view.Apply(new TileSceneChanges { BaseRevision = 2, Scene = changed });
|
||||
ModAssert.Equal(1, ((TileCanvasPresentation)view).LastChangedDrawChunks, "one edited draw chunk " + id);
|
||||
var rejected = false;
|
||||
try { view.Apply(new TileSceneChanges { BaseRevision = 1, Scene = Snapshot(4, Colors.White) }); }
|
||||
catch (InvalidOperationException) { rejected = true; }
|
||||
ModAssert.True(rejected, "stale revision rejected " + id);
|
||||
var frame = new TileFrame();
|
||||
frame.Actors.Add(new TileActorPose { Id = "removed", Visible = true });
|
||||
view.Present(frame, 0);
|
||||
ModAssert.Equal(1, view.Root.GetNode<Node2D>("Actors").GetChildCount(), "actor mounted " + id);
|
||||
frame.Actors.Clear();
|
||||
view.Present(frame, 0);
|
||||
ModAssert.Equal(0, view.Root.GetNode<Node2D>("Actors").GetChildCount(), "removed actor released " + id);
|
||||
passed += 5;
|
||||
}
|
||||
finally { view.Dispose(); }
|
||||
}
|
||||
return passed;
|
||||
}
|
||||
|
||||
private static TileRenderScene Snapshot(long revision, Color firstTint)
|
||||
{
|
||||
var scene = new TileRenderScene { Size = new Vector2I(64, 64), Revision = revision, Zoom = 1 };
|
||||
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(0, 0, 1, 1), Tint = firstTint });
|
||||
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(33, 0, 1, 1) });
|
||||
scene.Visuals.Add(new TileVisual { Bounds = new Rect2(0, 33, 1, 1) });
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Mods.HotReload;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Tools.RpgEditor;
|
||||
using Cthangover.Core.Mods.Testing;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.Interface.View;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
/// <summary>Compares both presentations of identical prepared resources and tests projection/picking invariants.</summary>
|
||||
public partial class TileRenderTestDriver : Node
|
||||
{
|
||||
private readonly string[] maps = { "rpg_village", "rpg_elder_house", "rpg_forest" };
|
||||
private readonly List<ITileWorldPresentation> views = new();
|
||||
private readonly RpgTextures textures = new();
|
||||
private readonly TileFrame frame = new();
|
||||
private CanvasLayer overlay;
|
||||
private RpgRenderSceneBuilder builder;
|
||||
private TileRenderScene scene;
|
||||
private int ticks;
|
||||
private int step;
|
||||
private int passed;
|
||||
private double elapsed;
|
||||
private string shots;
|
||||
private Task contracts;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
foreach (var arg in OS.GetCmdlineUserArgs()) if (arg.StartsWith("--shots=")) shots = arg.Substring(8);
|
||||
overlay = new CanvasLayer { Layer = 90 };
|
||||
AddChild(overlay);
|
||||
overlay.AddChild(new ColorRect { Size = new Vector2(1920, 1024), Color = new Color(0.04f, 0.05f, 0.06f) });
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
elapsed += delta;
|
||||
if (elapsed > 300) { Finish("timeout"); return; }
|
||||
if (++ticks < 30) return;
|
||||
try
|
||||
{
|
||||
if (contracts != null)
|
||||
{
|
||||
if (contracts.IsCompleted) { contracts.GetAwaiter().GetResult(); Finish(null); }
|
||||
return;
|
||||
}
|
||||
if (views.Count == 0)
|
||||
Build(maps[step]);
|
||||
foreach (var view in views) view.Present(frame, 0);
|
||||
if (ticks < 45) return;
|
||||
if (shots != null && DisplayServer.GetName() != "headless")
|
||||
{
|
||||
DirAccess.MakeDirRecursiveAbsolute(shots);
|
||||
ViewportCapture.SavePng(GetViewport(), shots.TrimEnd('/') + "/compare_" + maps[step] + ".png");
|
||||
}
|
||||
Release();
|
||||
ticks = 0;
|
||||
if (++step == maps.Length) contracts = RunContracts();
|
||||
}
|
||||
catch (Exception ex) { Finish(ex.ToString()); }
|
||||
}
|
||||
|
||||
private async Task RunContracts()
|
||||
{
|
||||
passed += TileRenderDeltaContract.Run(this);
|
||||
TestEditor();
|
||||
GetTree().Root.GetNode<SceneManager>("SceneManager").SwitchScene("rpg_village");
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
var host = RpgWorldHost.Current;
|
||||
ModAssert.NotNull(host, "shell world host");
|
||||
host.Mount("rpg_village");
|
||||
host.PlacePlayer(15, 14, RpgDirection.Up);
|
||||
var session = host.Session;
|
||||
session.Tick(0.05, false, RpgDirection.Up);
|
||||
host.View.Present(0);
|
||||
var pose = session.Player.Position;
|
||||
var cell = session.Player.Cell;
|
||||
ModAssert.True(session.Player.IsMoving, "switch test starts during a step");
|
||||
ModAssert.True(host.View.SetRenderer("isometric"), "iso view can replace square view");
|
||||
ModAssert.True(session.Player.Position.IsEqualApprox(pose) && session.Player.Cell == cell, "switch preserves step progress and reservation");
|
||||
ModAssert.True(host.View.SetRenderer("topdown"), "switch back");
|
||||
ModAssert.True(ReferenceEquals(session, host.Session) && session.Player.Position.IsEqualApprox(pose), "one session survives both projections");
|
||||
ModAssert.True(host.View.SetRenderer("isometric"), "reload test has a live iso presentation");
|
||||
var oldFactory = new WeakReference(TileRendererRegistry.Instance.Resolve("isometric"));
|
||||
var oldProjection = ((TileCanvasPresentation)host.View.Presentation).ProjectionLifetime();
|
||||
passed += 6;
|
||||
session.Tick(1, true);
|
||||
var savedCell = session.Player.Cell;
|
||||
var savedFacing = session.Player.Facing;
|
||||
var verified = await ModHotReloadService.ReloadNowAsync(new[] { "tile_isometric_base" });
|
||||
ModAssert.True(verified?.Applied == true, "forced implementation reload applies through the normal session-parking flow");
|
||||
GameLogger.Log("TEST", $"renderer lifetime: old factory={oldFactory.IsAlive}, old projection={oldProjection.IsAlive}");
|
||||
ModAssert.Equal(0, verified.Leaked.Count, "renderer load context is collectible after disposal");
|
||||
// LoadAndEnter schedules the shell; the public reload task ends
|
||||
// before the deferred scene entry mounts its world.
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
ModAssert.NotNull(RpgWorldHost.Current?.View?.Presentation, "kept host restores its presentation after reload");
|
||||
ModAssert.True(savedCell == RpgWorldHost.Current.Session.Player.Cell, "reload restores saved logical cell");
|
||||
ModAssert.True(savedFacing == RpgWorldHost.Current.Session.Player.Facing, "reload restores saved world facing");
|
||||
passed += 5;
|
||||
foreach (var arg in OS.GetCmdlineUserArgs())
|
||||
if (arg == "--tile-benchmark") await TileRenderBenchmark.Run(this, shots);
|
||||
}
|
||||
|
||||
private void TestEditor()
|
||||
{
|
||||
var document = RpgMapDocument.Create("render_contract", "village", 8, 8, 0);
|
||||
var tileset = RpgTilesetFactory.Instance.Get("village");
|
||||
var canvas = new RpgMapEditorCanvas { Document = document, Tileset = tileset, Atlas = textures.Tileset(tileset.Image),
|
||||
ActiveLayer = RpgEditLayer.Main, SelectedTile = 3 };
|
||||
overlay.AddChild(canvas);
|
||||
canvas.EditStarting += document.BeginChange;
|
||||
try
|
||||
{
|
||||
foreach (var id in new[] { "topdown", "isometric" })
|
||||
{
|
||||
canvas.SetRenderer(id);
|
||||
var point = canvas.ProjectCell(new Vector2(2.5f, 3.5f));
|
||||
ModAssert.True(canvas.Preview.TryPickGround(point, out var cell) && cell == new Vector2I(2, 3), "editor picking " + id);
|
||||
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = point });
|
||||
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = point });
|
||||
ModAssert.Equal(3, document.Grid.GetTile(RpgLayer.Main, 2, 3), "projected GUI click paints exact cell " + id);
|
||||
ModAssert.True(document.Undo(), "projected stroke has an undo step " + id);
|
||||
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Main, 2, 3), "undo restores prior data " + id);
|
||||
canvas.Refresh();
|
||||
passed += 4;
|
||||
}
|
||||
}
|
||||
finally { canvas.Free(); }
|
||||
}
|
||||
|
||||
private void Build(string mapId)
|
||||
{
|
||||
var map = RpgMapFactory.Instance.Get(mapId);
|
||||
var tileset = RpgTilesetFactory.Instance.Get(map.Tileset);
|
||||
builder ??= new RpgRenderSceneBuilder(textures);
|
||||
scene = builder.Build(map, RpgMapGrid.Parse(map), tileset);
|
||||
scene.Zoom = mapId == "rpg_elder_house" ? 1.1f : 0.55f;
|
||||
frame.Focus = new Vector2(map.Width, map.Height) / 2;
|
||||
frame.Ambient = Colors.White;
|
||||
var index = 0;
|
||||
foreach (var id in new[] { "topdown", "isometric" })
|
||||
{
|
||||
var factory = TileRendererRegistry.Instance.Resolve(id);
|
||||
var projection = factory.Projection(scene.TileSize);
|
||||
var point = new Vector2(3.25f, 2.75f);
|
||||
ModAssert.True(projection.Unproject(TileDrawing.Project(projection, point)).IsEqualApprox(point), "round trip " + id);
|
||||
var view = factory.Create();
|
||||
var host = new Control { Position = new Vector2(index++ * 960, 0), Size = new Vector2(960, 1024), ClipContents = true };
|
||||
overlay.AddChild(host);
|
||||
host.AddChild(view.Root);
|
||||
view.Mount(scene);
|
||||
view.Resize(new Vector2(960, 1024));
|
||||
view.Present(frame, 0);
|
||||
var pixel = view.ProjectToView(new Vector3(3.5f, 2.5f, 0));
|
||||
ModAssert.True(view.TryPickGround(pixel, out var cell) && cell == new Vector2I(3, 2), "picking " + id);
|
||||
ModAssert.False(view.TryPickGround(view.ProjectToView(new Vector3(-1, -1, 0)), out _), "outside " + id);
|
||||
views.Add(view);
|
||||
passed += 3;
|
||||
}
|
||||
GameLogger.Log("TEST", $"renderer preview {mapId}: {scene.Visuals.Count} visual parts, {scene.OwnedTextures.Count} generated textures");
|
||||
}
|
||||
|
||||
private void Release()
|
||||
{
|
||||
foreach (var view in views)
|
||||
{
|
||||
var parent = view.Root.GetParent();
|
||||
view.Dispose();
|
||||
parent.Free();
|
||||
}
|
||||
views.Clear();
|
||||
scene?.Release();
|
||||
scene = null;
|
||||
}
|
||||
|
||||
private void Finish(string error)
|
||||
{
|
||||
Release();
|
||||
if (error != null) GameLogger.Log("TEST", error, LogLevel.Error);
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=tile_render passed={passed} failed={(error == null ? 0 : 1)}");
|
||||
builder?.Dispose();
|
||||
textures.Dispose();
|
||||
GetTree().Quit(error == null ? 0 : 1);
|
||||
SetProcess(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
using Cthangover.Core.Mods;
|
||||
using Godot;
|
||||
/// <summary>Opt-in renderer contract/preview driver, never active during normal gameplay.</summary>
|
||||
public class TileRenderTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool queued;
|
||||
public void OnModLoaded(string id) { }
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
if (queued) return;
|
||||
foreach (var arg in OS.GetCmdlineUserArgs())
|
||||
if (arg == "--itest=tile_render")
|
||||
{ queued = true; ModRoot.AddDeferred(new TileRenderTestDriver()); return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user