Draw broken objects as fragments, plane tiles for fences and walls, tileset publishing; mod documentation

This commit is contained in:
cthangover committed 2026-10-29 09:26:10 +01:00
1 parent e153dc9b15
commit 7b28c1bb0a
74 files changed
+2782 -886

No files matched your search

+10
View File
@@ -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; }
}
}
}