commit 5ca225a2d7ee6f011fe81a8545fae16c35add144 Author: iisaev Date: Wed Oct 21 13:22:18 2026 +0200 RPG world mod: tile maps, grid walking, map events, battles Maps of square tiles in three layers plus a collision layer, mounted over the 2D shell when their scene is entered. WASD walking, E/F action events, touch teleports, story-flag conditions, random encounters, rpg.battle and rpg.teleport actions, position saving. Integration suite rpg_walk. diff --git a/locale/en.properties b/locale/en.properties new file mode 100644 index 0000000..846e941 --- /dev/null +++ b/locale/en.properties @@ -0,0 +1,2 @@ +mod_rpg_name = RPG World +mod_rpg_desc = Top-down tile world in the spirit of RPG Maker: three tile layers and a collision layer, grid walking with WASD, events on the action key E/F, random encounters and battles that return to the map. diff --git a/locale/ru.properties b/locale/ru.properties new file mode 100644 index 0000000..e25cc2c --- /dev/null +++ b/locale/ru.properties @@ -0,0 +1,2 @@ +mod_rpg_name = RPG-мир +mod_rpg_desc = Мир из тайлов с видом сверху в духе RPG Maker: три слоя тайлов и слой коллизий, ходьба по клеткам на WASD, события по клавише действия E/F, случайные встречи и бои с возвратом на карту. diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..be820cb --- /dev/null +++ b/manifest.json @@ -0,0 +1,18 @@ +{ + "id": "rpg", + "name": "RPG World", + "version": "1.0.0", + "author": "ct", + "description": "Top-down tile world in the spirit of RPG Maker on top of the novel engine: maps of square tiles in three layers (back, main, front) with a collision layer, walking on the grid with WASD or the arrows, events that talk, teleport or start battles with the action key E/F or on touch, random encounters. A map is a logical scene, so entry scenarios, ambience, saving and the dialog box work on maps unchanged; battles run on the turn-based platform with the game's battle core and return to the same cell. Scenario actions: rpg.battle, rpg.teleport.", + "category": "gameplay", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": ["shell2d_base>=1", "ui_base>=1", "dialog_base>=1", "turn_battle_base>=2"], + "test_suites": [ + {"id": "walk", "activate": ["--itest=rpg_walk"]} + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..f3f9a56 Binary files /dev/null and b/mod.jpg differ diff --git a/patches/scenes/ui/base_scene.tscn b/patches/scenes/ui/base_scene.tscn new file mode 100644 index 0000000..87854b5 --- /dev/null +++ b/patches/scenes/ui/base_scene.tscn @@ -0,0 +1,8 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="PackedScene" path="res://mods/shell2d_base/scenes/ui/base_scene.tscn" id="1_target"] +[ext_resource type="PackedScene" path="res://scenes/ui/rpg_world_host.tscn" id="2_world"] + +[node name="BaseScene" instance=ExtResource("1_target")] + +[node name="RpgWorldHost" parent="Scene" index="2" instance=ExtResource("2_world")] diff --git a/scenes/ui/rpg_world_host.tscn b/scenes/ui/rpg_world_host.tscn new file mode 100644 index 0000000..421e3d0 --- /dev/null +++ b/scenes/ui/rpg_world_host.tscn @@ -0,0 +1,23 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://mods/rpg/src/World/RpgWorldHost.cs" id="1_host"] + +[node name="RpgWorldHost" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +script = ExtResource("1_host") + +[node name="Backdrop" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +mouse_filter = 2 +color = Color(0.06, 0.07, 0.09, 1) diff --git a/src/Actions/RpgBattleAction.cs b/src/Actions/RpgBattleAction.cs new file mode 100644 index 0000000..f055fa2 --- /dev/null +++ b/src/Actions/RpgBattleAction.cs @@ -0,0 +1,28 @@ +using Cthangover.Core.Actions; + +namespace Cthangover.Mods.Rpg +{ + /// + /// action rpg.battle enemies=slime,slime [win_flag=slime_dead] — a + /// battle that returns to the map the player stands on. Unlike + /// battle.init + switch_scene Battle it needs no return scene + /// and can mark the victory with a story flag. End the scenario after it. + /// + public class RpgBattleAction : IScenarioAction + { + /// DSL name. + public string Name => "rpg.battle"; + + /// Reads enemies and win_flag and starts the battle. + public void Run(IActionContext ctx) + { + var enemies = ctx.GetParam("enemies"); + if (string.IsNullOrWhiteSpace(enemies)) + { + ctx.LogWarning("RPG", "rpg.battle: missing 'enemies'"); + return; + } + RpgBattleService.Start(enemies, ctx.GetParam("win_flag")); + } + } +} diff --git a/src/Actions/RpgTeleportAction.cs b/src/Actions/RpgTeleportAction.cs new file mode 100644 index 0000000..b00af7f --- /dev/null +++ b/src/Actions/RpgTeleportAction.cs @@ -0,0 +1,30 @@ +using System; +using Cthangover.Core.Actions; + +namespace Cthangover.Mods.Rpg +{ + /// + /// action rpg.teleport [map=forest] x=3 y=7 [dir=up] — moves the player + /// to a cell, on this map or another one (then the scene switches). The same + /// effect a teleport event has, for scenarios: a cutscene that ends with the + /// hero waking up at home, a ferryman who takes you across. + /// + public class RpgTeleportAction : IScenarioAction + { + /// DSL name. + public string Name => "rpg.teleport"; + + /// Parses the target and hands it to the world. + public void Run(IActionContext ctx) + { + if (!int.TryParse(ctx.GetParam("x"), out var x) || !int.TryParse(ctx.GetParam("y"), out var y)) + { + ctx.LogWarning("RPG", "rpg.teleport: 'x' and 'y' must be numbers"); + return; + } + + var dir = Enum.TryParse(ctx.GetParam("dir"), true, out var parsed) ? parsed : RpgDirection.Down; + RpgWorldHost.Teleport(new RpgTeleport { Map = ctx.GetParam("map"), X = x, Y = y, Dir = dir }); + } + } +} diff --git a/src/Data/RpgCollision.cs b/src/Data/RpgCollision.cs new file mode 100644 index 0000000..c523c4d --- /dev/null +++ b/src/Data/RpgCollision.cs @@ -0,0 +1,20 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// Cell codes of the map's system collision layer. It is a per-cell override + /// of the tiles' own flag: most cells stay + /// , and the author paints exceptions (an invisible wall at + /// the cliff edge, a walkable gap in a hedge). + /// + public static class RpgCollision + { + /// Passability comes from the tiles. + public const char Auto = '.'; + + /// Always blocked. + public const char Block = '#'; + + /// Always walkable, whatever the tiles say. + public const char Open = 'o'; + } +} diff --git a/src/Data/RpgDirection.cs b/src/Data/RpgDirection.cs new file mode 100644 index 0000000..dfc59de --- /dev/null +++ b/src/Data/RpgDirection.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Facing of an actor on the grid. The order is the row order of an RPG Maker + /// style charset (down, left, right, up), so the value is also the frame row + /// of . JSON spells it as a word ("down", "Left"). + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum RpgDirection + { + Down = 0, + Left = 1, + Right = 2, + Up = 3, + } +} diff --git a/src/Data/RpgDirections.cs b/src/Data/RpgDirections.cs new file mode 100644 index 0000000..99ed591 --- /dev/null +++ b/src/Data/RpgDirections.cs @@ -0,0 +1,26 @@ +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// Grid arithmetic of : the cell step it makes and the facing that looks back. + public static class RpgDirections + { + /// Cell offset of one step; y grows downwards like the map rows. + public static Vector2I Delta(RpgDirection direction) => direction switch + { + RpgDirection.Left => new Vector2I(-1, 0), + RpgDirection.Right => new Vector2I(1, 0), + RpgDirection.Up => new Vector2I(0, -1), + _ => new Vector2I(0, 1), + }; + + /// The facing that looks at the one given: an NPC turns to the player with it. + public static RpgDirection Opposite(RpgDirection direction) => direction switch + { + RpgDirection.Left => RpgDirection.Right, + RpgDirection.Right => RpgDirection.Left, + RpgDirection.Up => RpgDirection.Down, + _ => RpgDirection.Up, + }; + } +} diff --git a/src/Data/RpgEncounterRoller.cs b/src/Data/RpgEncounterRoller.cs new file mode 100644 index 0000000..b8d7dd1 --- /dev/null +++ b/src/Data/RpgEncounterRoller.cs @@ -0,0 +1,27 @@ +using System; + +namespace Cthangover.Mods.Rpg +{ + /// + /// The random-battle roll made after each step on a map with an + /// . Takes the random source from outside so a + /// test can make it deterministic. + /// + public static class RpgEncounterRoller + { + /// + /// Returns the enemy group of the battle this step starts, or null for no + /// battle (no table, encounters off, no groups, or the roll missed). + /// + public static string Roll(RpgEncounterTable table, Random random) + { + if (table == null || table.Steps <= 0 || table.Groups == null || table.Groups.Count == 0 || random == null) + return null; + if (random.Next(table.Steps) != 0) + return null; + + var group = table.Groups[random.Next(table.Groups.Count)]; + return string.IsNullOrWhiteSpace(group) ? null : group; + } + } +} diff --git a/src/Data/RpgEncounterTable.cs b/src/Data/RpgEncounterTable.cs new file mode 100644 index 0000000..35d38ee --- /dev/null +++ b/src/Data/RpgEncounterTable.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Random battles of a map: after every step the player takes on it, a battle + /// starts with probability 1 / Steps against one of the + /// . Groups are enemy lists in the battle.init + /// format ("slime,slime"), so they name character templates. + /// + public class RpgEncounterTable + { + /// Average number of steps between battles; 0 or less turns encounters off. + public int Steps { get; set; } + + /// Enemy groups, one of them picked uniformly. + public List Groups { get; set; } = new(); + } +} diff --git a/src/Data/RpgEventTrigger.cs b/src/Data/RpgEventTrigger.cs new file mode 100644 index 0000000..77e5d46 --- /dev/null +++ b/src/Data/RpgEventTrigger.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace Cthangover.Mods.Rpg +{ + /// How the player sets off an . + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum RpgEventTrigger + { + /// The action key (E or F) while facing the event, or standing on it. + Action = 0, + + /// Stepping onto the event's cell (doors, map exits, traps). + Touch = 1, + } +} diff --git a/src/Data/RpgLayer.cs b/src/Data/RpgLayer.cs new file mode 100644 index 0000000..120ba02 --- /dev/null +++ b/src/Data/RpgLayer.cs @@ -0,0 +1,10 @@ +namespace Cthangover.Mods.Rpg +{ + /// Drawn layers of a map, bottom to top; actors are drawn between and . + public enum RpgLayer + { + Back = 0, + Main = 1, + Front = 2, + } +} diff --git a/src/Data/RpgMap.cs b/src/Data/RpgMap.cs new file mode 100644 index 0000000..4552d9f --- /dev/null +++ b/src/Data/RpgMap.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Rpg +{ + /// + /// One tile map of the RPG world. The map id is also the name of the logical + /// scene that shows it: entering scene X (any switch_scene X, a + /// teleport, the return from a battle, loading a save) mounts map X + /// over the shell. Because the scene stays an ordinary scene, everything the + /// novel engine attaches to scenes works on a map unchanged — entry + /// scenarios, ambience and music from scenes/X.json, the default + /// background (which also becomes the backdrop of battles started here), + /// saving rules. + /// + /// Files declare "Kind": "rpg_map". Layers are parsed by + /// ; a dependent mod replaces a whole map by id, the + /// usual entity override. + /// + [EntityKind("rpg_map")] + public class RpgMap : IIdentifiable + { + /// Map id = logical scene name. + [JsonPropertyName("Id")] + public string ID { get; set; } + + /// Locale key of the map's display name. + public string Name { get; set; } + + /// Id of the the indices refer to. + public string Tileset { get; set; } + + /// Columns. + public int Width { get; set; } + + /// Rows. + public int Height { get; set; } + + /// Screen pixels per tile pixel; pixel art is scaled by a whole number. + public int Zoom { get; set; } = 2; + + /// The three drawn layers. + public RpgMapLayers Layers { get; set; } = new(); + + /// Rows of codes, one character per cell; may be empty. + public List Collision { get; set; } = new(); + + /// Where the player appears when entering the map without a teleport target. + public RpgTeleport Spawn { get; set; } = new(); + + /// Events placed on the map. + public List Events { get; set; } = new(); + + /// Random battles; null for a safe map. + public RpgEncounterTable Encounters { get; set; } + } +} diff --git a/src/Data/RpgMapEvent.cs b/src/Data/RpgMapEvent.cs new file mode 100644 index 0000000..07e1a7d --- /dev/null +++ b/src/Data/RpgMapEvent.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Something placed on a map cell that reacts to the player, in the spirit of + /// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and + /// what it does are independent: + /// + /// look — a charset , or one atlas , or nothing (an invisible trigger); + /// effect — a , handled natively, or a dialog built + /// from a file followed by inline + /// (the full scenario DSL, so action rpg.battle …, flag …, quests). + /// + /// A in the scenario condition language hides the + /// event while false; it is re-checked whenever game state changes, so a + /// defeated boss (flag boss_dead 1) disappears at once. + /// + public class RpgMapEvent + { + /// Id unique within the map. + public string Id { get; set; } + + /// Column. + public int X { get; set; } + + /// Row. + public int Y { get; set; } + + /// Charset image in the charsets group (3 frames × 4 facings). + public string Sprite { get; set; } + + /// Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none. + public int Tile { get; set; } = -1; + + /// Initial facing of a sprite. + public RpgDirection Dir { get; set; } = RpgDirection.Down; + + /// When the event fires. + public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action; + + /// The event occupies its cell. Null means "solid when it is visible" (a sprite or a tile). + public bool? Solid { get; set; } + + /// Scenario path inside the scenarios group (extension optional), played as a dialog. + public string Scenario { get; set; } + + /// Inline DSL lines appended after the scenario. + public List Commands { get; set; } + + /// Moves the player instead of starting a dialog. + public RpgTeleport Teleport { get; set; } + + /// Scenario condition; the event exists only while it is true. + public string Condition { get; set; } + + /// Effective solidity: explicit value, else whether the event is drawn. + public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0); + } +} diff --git a/src/Data/RpgMapFactory.cs b/src/Data/RpgMapFactory.cs new file mode 100644 index 0000000..df553c0 --- /dev/null +++ b/src/Data/RpgMapFactory.cs @@ -0,0 +1,27 @@ +using System; +using Cthangover.Core.Factories; +using Cthangover.Core.Mods.Resolvers; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Maps collected by the shared entity resolver ("Kind": "rpg_map"). + /// lets the map editor put a just-saved map into the + /// resolver's cache, so the running world shows the edit without a mod + /// reload; the next reload reads the same content back from the file. + /// + public class RpgMapFactory : FileFactory + { + private static readonly Lazy instance = new(() => new RpgMapFactory()); + + /// Shared instance. + public static RpgMapFactory Instance => instance.Value; + + /// Replaces the cached definition of 's id. + public void Publish(RpgMap map) + { + if (map != null && !string.IsNullOrEmpty(map.ID)) + Entities.Collect()[map.ID] = map; + } + } +} diff --git a/src/Data/RpgMapGrid.cs b/src/Data/RpgMapGrid.cs new file mode 100644 index 0000000..f3d8761 --- /dev/null +++ b/src/Data/RpgMapGrid.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Parsed cells of an : the three tile layers as atlas + /// indices (-1 = empty) and the collision codes. The map file keeps rows as + /// text (); this is the form the world and the + /// editor work with, and turns it back into rows. + /// + /// Parsing is forgiving, because maps are edited by hand: short rows and + /// missing rows are empty cells, extra cells are cut off, an unreadable token + /// is empty. The grid always has exactly the map's declared size. + /// + public class RpgMapGrid + { + private const string EmptyToken = "."; + + private int[][] layers; + private char[] collision; + + /// Columns. + public int Width { get; private set; } + + /// Rows. + public int Height { get; private set; } + + /// Creates an empty grid: no tiles, collision from tiles everywhere. + public RpgMapGrid(int width, int height) + { + Width = Math.Max(1, width); + Height = Math.Max(1, height); + layers = new int[3][]; + for (var i = 0; i < layers.Length; i++) + layers[i] = Enumerable.Repeat(-1, Width * Height).ToArray(); + collision = Enumerable.Repeat(RpgCollision.Auto, Width * Height).ToArray(); + } + + /// Reads the layers and the collision rows of . + public static RpgMapGrid Parse(RpgMap map) + { + var grid = new RpgMapGrid(map?.Width ?? 1, map?.Height ?? 1); + if (map == null) + return grid; + + grid.ReadLayer(RpgLayer.Back, map.Layers?.Back); + grid.ReadLayer(RpgLayer.Main, map.Layers?.Main); + grid.ReadLayer(RpgLayer.Front, map.Layers?.Front); + + var rows = map.Collision ?? new List(); + for (var y = 0; y < grid.Height && y < rows.Count; y++) + { + var row = rows[y] ?? string.Empty; + for (var x = 0; x < grid.Width && x < row.Length; x++) + grid.SetCollision(x, y, row[x]); + } + return grid; + } + + /// True when the cell lies on the map. + public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height; + + /// Atlas index on a layer, -1 for empty or outside the map. + public int GetTile(RpgLayer layer, int x, int y) => InBounds(x, y) ? layers[(int)layer][y * Width + x] : -1; + + /// Puts an atlas index (-1 clears); outside the map it does nothing. + public void SetTile(RpgLayer layer, int x, int y, int index) + { + if (InBounds(x, y)) + layers[(int)layer][y * Width + x] = index < 0 ? -1 : index; + } + + /// Collision code of a cell; outside the map it is . + public char GetCollision(int x, int y) => InBounds(x, y) ? collision[y * Width + x] : RpgCollision.Block; + + /// Sets a collision code; an unknown code becomes . + public void SetCollision(int x, int y, char code) + { + if (!InBounds(x, y)) + return; + collision[y * Width + x] = code == RpgCollision.Block || code == RpgCollision.Open ? code : RpgCollision.Auto; + } + + /// + /// Changes the size keeping the top-left content; new cells are empty. + /// Used by the editor; the map's events are not moved. + /// + public void Resize(int width, int height) + { + var resized = new RpgMapGrid(width, height); + for (var y = 0; y < Math.Min(Height, resized.Height); y++) + { + for (var x = 0; x < Math.Min(Width, resized.Width); x++) + { + foreach (RpgLayer layer in Enum.GetValues(typeof(RpgLayer))) + resized.SetTile(layer, x, y, GetTile(layer, x, y)); + resized.SetCollision(x, y, GetCollision(x, y)); + } + } + Width = resized.Width; + Height = resized.Height; + layers = resized.layers; + collision = resized.collision; + } + + /// Copy of the grid, so editor undo steps never share cells. + public RpgMapGrid Clone() + { + var copy = new RpgMapGrid(Width, Height); + for (var i = 0; i < layers.Length; i++) + Array.Copy(layers[i], copy.layers[i], layers[i].Length); + Array.Copy(collision, copy.collision, collision.Length); + return copy; + } + + /// + /// Writes size, layer rows and collision rows into . + /// Indices are right-aligned to one width per layer, so columns line up in + /// the file. An all-auto collision layer is written as no rows. + /// + public void WriteTo(RpgMap map) + { + map.Width = Width; + map.Height = Height; + map.Layers ??= new RpgMapLayers(); + map.Layers.Back = WriteLayer(RpgLayer.Back); + map.Layers.Main = WriteLayer(RpgLayer.Main); + map.Layers.Front = WriteLayer(RpgLayer.Front); + + var rows = new List(); + if (collision.Any(c => c != RpgCollision.Auto)) + { + for (var y = 0; y < Height; y++) + rows.Add(new string(collision, y * Width, Width)); + } + map.Collision = rows; + } + + private void ReadLayer(RpgLayer layer, List rows) + { + if (rows == null) + return; + for (var y = 0; y < Height && y < rows.Count; y++) + { + var tokens = (rows[y] ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries); + for (var x = 0; x < Width && x < tokens.Length; x++) + SetTile(layer, x, y, int.TryParse(tokens[x], out var index) ? index : -1); + } + } + + private List WriteLayer(RpgLayer layer) + { + var cells = layers[(int)layer]; + var width = cells.Select(Token).Max(t => t.Length); + var rows = new List(); + var line = new StringBuilder(); + for (var y = 0; y < Height; y++) + { + line.Clear(); + for (var x = 0; x < Width; x++) + { + if (x > 0) + line.Append(' '); + line.Append(Token(cells[y * Width + x]).PadLeft(width)); + } + rows.Add(line.ToString()); + } + return rows; + } + + private static string Token(int index) => index < 0 ? EmptyToken : index.ToString(); + } +} diff --git a/src/Data/RpgMapLayers.cs b/src/Data/RpgMapLayers.cs new file mode 100644 index 0000000..b12607d --- /dev/null +++ b/src/Data/RpgMapLayers.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// The three drawn layers of a map, each a list of rows. A row is a string of + /// space-separated atlas indices, . for an empty cell, so a map file + /// stays readable and diffable by hand (see ). + /// + /// — ground: grass, floors, water. + /// — objects at the actors' level: walls, trunks, fences. + /// — drawn over the actors: tree crowns, roofs' eaves, arches. + /// + /// + public class RpgMapLayers + { + /// Ground layer rows. + public List Back { get; set; } = new(); + + /// Object layer rows. + public List Main { get; set; } = new(); + + /// Overhead layer rows. + public List Front { get; set; } = new(); + } +} diff --git a/src/Data/RpgPassability.cs b/src/Data/RpgPassability.cs new file mode 100644 index 0000000..b75e820 --- /dev/null +++ b/src/Data/RpgPassability.cs @@ -0,0 +1,29 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// Whether the terrain of a cell can be walked on: the collision layer + /// decides when painted, otherwise a solid tile on the back or the main layer + /// blocks. Front-layer tiles never block — they are what the player walks + /// under. Events are not terrain: the world adds their cells on top. + /// + public static class RpgPassability + { + /// True when the cell is inside the map and its terrain lets the player in. + public static bool IsWalkable(RpgMapGrid grid, RpgTileset tileset, int x, int y) + { + if (grid == null || !grid.InBounds(x, y)) + return false; + + var code = grid.GetCollision(x, y); + if (code == RpgCollision.Block) + return false; + if (code == RpgCollision.Open) + return true; + + if (tileset == null) + return true; + return !tileset.IsSolid(grid.GetTile(RpgLayer.Back, x, y)) + && !tileset.IsSolid(grid.GetTile(RpgLayer.Main, x, y)); + } + } +} diff --git a/src/Data/RpgTeleport.cs b/src/Data/RpgTeleport.cs new file mode 100644 index 0000000..7f3021c --- /dev/null +++ b/src/Data/RpgTeleport.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// A cell on some map and the facing the player arrives with. Used for the + /// map's spawn point and for teleport events (doors, map edges, stairs). + /// + public class RpgTeleport + { + /// Target map id; empty means the map the event is on. + public string Map { get; set; } + + /// Target column. + public int X { get; set; } + + /// Target row. + public int Y { get; set; } + + /// Facing after arrival. + public RpgDirection Dir { get; set; } = RpgDirection.Down; + } +} diff --git a/src/Data/RpgTileInfo.cs b/src/Data/RpgTileInfo.cs new file mode 100644 index 0000000..6bb2f95 --- /dev/null +++ b/src/Data/RpgTileInfo.cs @@ -0,0 +1,25 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// Properties of one atlas cell of an . Only cells that + /// differ from the default (walkable, unnamed) need an entry. + /// + public class RpgTileInfo + { + /// Row-major atlas index: row * Columns + column. + public int Index { get; set; } + + /// + /// The tile blocks walking when it lies on the back or the main layer + /// (front-layer tiles never block: the player walks under them). The map's + /// collision layer can override it per cell. + /// + public bool Solid { get; set; } + + /// + /// Optional author-facing tag shown by the editor palette ("grass", + /// "tree_crown"). Not a locale key: players never see it. + /// + public string Tag { get; set; } + } +} diff --git a/src/Data/RpgTileset.cs b/src/Data/RpgTileset.cs new file mode 100644 index 0000000..c981e36 --- /dev/null +++ b/src/Data/RpgTileset.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Rpg +{ + /// + /// A square-tile atlas: one image cut into cells, + /// per row, addressed by a row-major index. Maps store + /// only these indices, so a pack can redraw its tiles without touching maps. + /// + /// Files declare "Kind": "rpg_tileset" and live anywhere in a mod; the + /// image is resolved in the tilesets file group (last mod wins), so a + /// dependent mod can reskin a tileset by shipping an image of the same path. + /// + [EntityKind("rpg_tileset")] + public class RpgTileset : IIdentifiable + { + private HashSet solid; + + /// Tileset id that maps reference. + [JsonPropertyName("Id")] + public string ID { get; set; } + + /// Image path inside the tilesets group, extension optional. + public string Image { get; set; } + + /// Edge of one square tile in image pixels. + public int TileSize { get; set; } = 32; + + /// Atlas cells per row. + public int Columns { get; set; } = 8; + + /// Per-cell properties; cells without an entry are walkable. + public List Tiles { get; set; } = new(); + + /// True when the atlas cell blocks walking. Built once from . + public bool IsSolid(int index) + { + if (index < 0) + return false; + if (solid == null) + { + solid = new HashSet(); + foreach (var tile in Tiles ?? new List()) + { + if (tile.Solid) + solid.Add(tile.Index); + } + } + return solid.Contains(index); + } + } +} diff --git a/src/Data/RpgTilesetFactory.cs b/src/Data/RpgTilesetFactory.cs new file mode 100644 index 0000000..08a262b --- /dev/null +++ b/src/Data/RpgTilesetFactory.cs @@ -0,0 +1,14 @@ +using System; +using Cthangover.Core.Factories; + +namespace Cthangover.Mods.Rpg +{ + /// Tilesets collected by the shared entity resolver ("Kind": "rpg_tileset"). + public class RpgTilesetFactory : FileFactory + { + private static readonly Lazy instance = new(() => new RpgTilesetFactory()); + + /// Shared instance. + public static RpgTilesetFactory Instance => instance.Value; + } +} diff --git a/src/RpgBattleService.cs b/src/RpgBattleService.cs new file mode 100644 index 0000000..214634c --- /dev/null +++ b/src/RpgBattleService.cs @@ -0,0 +1,65 @@ +using Cthangover.Core.Battle; +using Cthangover.Core.Events; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Starts a battle of the active battle core from the map and brings the + /// player back to the same map cell afterwards. It is a thin layer over the + /// platform ( + the Battle route): + /// + /// the return scene is the current map's scene, and the position is + /// already in , so the world restores it; + /// the backdrop is the map scene's default background (captured by + /// the platform from the scene), so a map chooses its battle background in + /// scenes/<map>.json; + /// an optional win flag becomes the story flag "1" on victory — how a + /// map monster disappears after it is beaten (its event's condition). + /// + /// + public static class RpgBattleService + { + private const string BattleRoute = "Battle"; + private const string VictoryResult = "victory"; + + /// + /// Requests the encounter and switches to the battle route on the next + /// frame (deferred like switch_scene, so a running dialog finishes + /// its current command first). Returns false when there is no scene to come back to. + /// + public static bool Start(string enemies, string winFlag) + { + var sceneManager = FindSceneManager(); + var returnScene = sceneManager?.CurrentSceneName; + if (string.IsNullOrEmpty(returnScene) || string.IsNullOrWhiteSpace(enemies)) + { + GameLogger.Log("RPG", $"battle not started: scene='{returnScene}', enemies='{enemies}'", LogLevel.Error); + return false; + } + + RpgState.Instance.PendingWinFlag = string.IsNullOrWhiteSpace(winFlag) ? null : winFlag.Trim(); + BattleService.Init(returnScene, enemies.Replace(" ", string.Empty)); + GameLogger.Log("RPG", $"battle '{enemies}' from '{returnScene}' (win flag: {RpgState.Instance.PendingWinFlag ?? "none"})"); + Callable.From(() => sceneManager.SwitchScene(BattleRoute)).CallDeferred(); + return true; + } + + /// Applies the pending win flag of the battle that just ended. + public static void OnBattleEnded(BattleEndedEvent evt) + { + var flag = RpgState.Instance.PendingWinFlag; + RpgState.Instance.PendingWinFlag = null; + if (flag != null && evt?.Result == VictoryResult) + ScenarioCondition.SetFlag(flag, "1"); + } + + private static SceneManager FindSceneManager() + { + var tree = Engine.GetMainLoop() as SceneTree; + return tree?.Root?.GetNodeOrNull("SceneManager"); + } + } +} diff --git a/src/RpgModInitializer.cs b/src/RpgModInitializer.cs new file mode 100644 index 0000000..1e3bf04 --- /dev/null +++ b/src/RpgModInitializer.cs @@ -0,0 +1,33 @@ +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Events; +using Cthangover.Core.Mods; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Wires the rpg mod into the session: the new-game reset of the position + /// and the battle-end hook for win flags. The world itself is not created + /// here — sits in the shell scene (scene patch) + /// and mounts a map whenever a map scene is entered. Save data goes through + /// , found by the kernel automatically. + /// + public class RpgModInitializer : IModInitializer + { + private static bool subscribed; + + /// Nothing per mod. + public void OnModLoaded(string modId) + { + } + + /// Registers the reset (deduplicated by id) and subscribes once. + public void OnModResourcesReady() + { + AppServices.Current.RegisterResettable(new RpgStateReset()); + if (subscribed) + return; + subscribed = true; + ModEventBus.Subscribe(RpgBattleService.OnBattleEnded); + } + } +} diff --git a/src/State/RpgSaveable.cs b/src/State/RpgSaveable.cs new file mode 100644 index 0000000..13d7240 --- /dev/null +++ b/src/State/RpgSaveable.cs @@ -0,0 +1,31 @@ +using Cthangover.Core.Mods; +using Cthangover.Core.Settings; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Saves the player's map position in the rpg mod's slot. The scene itself is + /// saved by the core (the map is the current scene); loading enters that + /// scene and the world reads the position back from . + /// + public class RpgSaveable : IModSaveable + { + private const string BlobKey = "Position"; + + /// Writes the position. + public void OnSave(ModSlot slot) + { + slot.Data[BlobKey] = new DataBlob(1, RpgState.Instance.Data); + } + + /// Restores the position; a slot without it leaves the player at the spawn. + public bool OnLoad(ModSlot slot, out string errorMessage) + { + errorMessage = null; + RpgState.Instance.Reset(); + if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob)) + RpgState.Instance.Data = blob.ReadJson() ?? new RpgStateData(); + return true; + } + } +} diff --git a/src/State/RpgState.cs b/src/State/RpgState.cs new file mode 100644 index 0000000..2a0e18b --- /dev/null +++ b/src/State/RpgState.cs @@ -0,0 +1,41 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// Session state of the RPG world, outliving the map node: the shell (and + /// with it the world) is rebuilt after every battle, so the position must + /// live here. + /// + /// The position doubles as the arrival point: a teleport writes the target + /// map and cell, then switches the scene; a map that is entered while + /// names it starts the player there, any other entry uses + /// the map's spawn. The same rule restores the player after a battle and + /// after loading a save. + /// + /// is transient: the flag a running battle sets on + /// victory. It is not saved, because battles cannot be saved mid-way. + /// + public class RpgState + { + /// The session's state. + public static RpgState Instance { get; } = new(); + + /// Saved position. + public RpgStateData Data { get; set; } = new(); + + /// Story flag set to "1" when the running battle is won; null when none. + public string PendingWinFlag { get; set; } + + /// Stores a position (and the map it is on). + public void Place(string map, int x, int y, RpgDirection dir) + { + Data = new RpgStateData { Map = map, X = x, Y = y, Dir = dir }; + } + + /// Back to "nowhere": the next map entry uses the spawn. + public void Reset() + { + Data = new RpgStateData(); + PendingWinFlag = null; + } + } +} diff --git a/src/State/RpgStateData.cs b/src/State/RpgStateData.cs new file mode 100644 index 0000000..ee4a800 --- /dev/null +++ b/src/State/RpgStateData.cs @@ -0,0 +1,21 @@ +namespace Cthangover.Mods.Rpg +{ + /// + /// Saved part of the RPG world state: where the player stands. Written into + /// the mod's save slot as JSON, so every member is a plain public property. + /// + public class RpgStateData + { + /// Map the position belongs to; empty before the first map. + public string Map { get; set; } + + /// Column. + public int X { get; set; } + + /// Row. + public int Y { get; set; } + + /// Facing. + public RpgDirection Dir { get; set; } = RpgDirection.Down; + } +} diff --git a/src/State/RpgStateReset.cs b/src/State/RpgStateReset.cs new file mode 100644 index 0000000..0198a20 --- /dev/null +++ b/src/State/RpgStateReset.cs @@ -0,0 +1,14 @@ +using Cthangover.Core.Bootstrap; + +namespace Cthangover.Mods.Rpg +{ + /// New game: the player starts at the spawn of the entry map. + public class RpgStateReset : IResettable + { + /// Deduplication key in . + public string Id => "rpg"; + + /// Forgets the position. + public void ResetForNewGame() => RpgState.Instance.Reset(); + } +} diff --git a/src/World/RpgActor.cs b/src/World/RpgActor.cs new file mode 100644 index 0000000..26ae03f --- /dev/null +++ b/src/World/RpgActor.cs @@ -0,0 +1,133 @@ +using System; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Something standing on a map cell: the player, an NPC, a chest. Drawn + /// either from a charset — 3 frames by 4 facings, rows in + /// order, the middle frame is the standing pose — or + /// from one atlas tile. The frame is bottom-aligned and centred on the cell, + /// so taller charsets overlap the cell above like in RPG Maker. + /// + /// Movement is grid based: starts a step to the next + /// cell, the node slides there at and then raises + /// . The actor's changes at the + /// start of a step, so two actors never walk into the same cell. The actor + /// layer sorts children by Y, which keeps whoever is lower in front. + /// + public partial class RpgActor : Node2D + { + /// Walking speed. + public const float TilesPerSecond = 4.5f; + + private static readonly int[] WalkCycle = { 0, 1, 2, 1 }; + + private readonly int tileSize; + private Sprite2D sprite; + private Vector2 from; + private Vector2 to; + private float progress = 1f; + private float animationTime; + + /// Raised when a step reaches its cell. + public event Action StepFinished; + + /// Cell the actor stands on (or walks into). + public Vector2I Cell { get; private set; } + + /// Facing. + public RpgDirection Facing { get; private set; } + + /// True while a step is in progress. + public bool IsMoving => progress < 1f; + + /// The map event this actor shows; null for the player. + public RpgMapEvent Event { get; } + + /// Creates an actor for a cell grid of pixels. + public RpgActor(int tileSize, RpgMapEvent mapEvent = null) + { + this.tileSize = tileSize; + Event = mapEvent; + } + + /// Shows a charset; a null texture leaves the actor invisible (an invisible trigger). + public void UseCharset(Texture2D texture) + { + if (texture == null) + return; + sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false }; + var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f); + sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y); + AddChild(sprite); + UpdateFrame(); + } + + /// Shows one atlas cell of the map's tileset. + public void UseTile(Texture2D atlas, Vector2I atlasCoords) + { + if (atlas == null) + return; + sprite = new Sprite2D + { + Texture = atlas, + Centered = false, + RegionEnabled = true, + RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)), + }; + AddChild(sprite); + } + + /// Puts the actor on a cell at once, stopping any step. + public void Place(Vector2I cell, RpgDirection facing) + { + Cell = cell; + Facing = facing; + progress = 1f; + Position = CellPosition(cell); + UpdateFrame(); + } + + /// Turns without moving. + public void Face(RpgDirection facing) + { + Facing = facing; + UpdateFrame(); + } + + /// Starts a step to the neighbour cell; the caller has checked it is free. + public void StepTo(RpgDirection direction) + { + Facing = direction; + from = CellPosition(Cell); + Cell += RpgDirections.Delta(direction); + to = CellPosition(Cell); + progress = 0f; + } + + /// Advances the step and the walk animation. + public override void _Process(double delta) + { + if (!IsMoving) + return; + + progress = Mathf.Min(1f, progress + (float)delta * TilesPerSecond); + animationTime += (float)delta * TilesPerSecond * 2f; + Position = from.Lerp(to, progress); + UpdateFrame(); + if (!IsMoving) + StepFinished?.Invoke(this); + } + + private Vector2 CellPosition(Vector2I cell) => new(cell.X * tileSize, cell.Y * tileSize); + + private void UpdateFrame() + { + if (sprite == null || sprite.Hframes != 3) + return; + var column = IsMoving ? WalkCycle[(int)animationTime % WalkCycle.Length] : 1; + sprite.Frame = (int)Facing * 3 + column; + } + } +} diff --git a/src/World/RpgEventRunner.cs b/src/World/RpgEventRunner.cs new file mode 100644 index 0000000..e678dc4 --- /dev/null +++ b/src/World/RpgEventRunner.cs @@ -0,0 +1,61 @@ +using System.Text; +using Cthangover.Core.Utils; +using Cthangover.Mods.Dialog.Scenarios; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Fires a map event. A teleport moves the player natively (no dialog, so a + /// door costs nothing). Anything else becomes one dialog in the shell's + /// dialog box: the event's scenario body followed by its inline commands — + /// the same composition the interactive objects use for click dialogs, so + /// every DSL command, action and condition is available to map events. + /// + public static class RpgEventRunner + { + /// Runs the event; returns false when it has nothing to run or the dialog box is busy. + public static bool Run(RpgMapEvent mapEvent, string currentMap) + { + if (mapEvent == null) + return false; + + if (mapEvent.Teleport != null) + { + var target = mapEvent.Teleport; + RpgWorldHost.Teleport(new RpgTeleport + { + Map = string.IsNullOrEmpty(target.Map) ? currentMap : target.Map, + X = target.X, + Y = target.Y, + Dir = target.Dir, + }); + return true; + } + + var text = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(mapEvent.Scenario)) + { + var body = RpgScenarioSource.ReadBody(mapEvent.Scenario); + if (body == null) + GameLogger.Log("RPG", $"event '{mapEvent.Id}' on '{currentMap}': scenario '{mapEvent.Scenario}' not found", LogLevel.Error); + else + text.AppendLine(body); + } + foreach (var line in mapEvent.Commands ?? new()) + text.AppendLine(line); + if (text.Length == 0) + return false; + + var dialogBox = RpgInput.FindDialogBox(); + if (dialogBox == null || dialogBox.IsRunning) + return false; + + var dialog = ScenarioParser.Parse(text.ToString()); + if (dialog?.Queue == null || dialog.Queue.Count == 0) + return false; + + GameLogger.Log("RPG", $"event '{mapEvent.Id}' on '{currentMap}'"); + return dialogBox.SetDialogQueueAndRun(dialog, null, 0, null); + } + } +} diff --git a/src/World/RpgInput.cs b/src/World/RpgInput.cs new file mode 100644 index 0000000..c32ee36 --- /dev/null +++ b/src/World/RpgInput.cs @@ -0,0 +1,77 @@ +using Cthangover.Core.Scenes; +using Cthangover.Core.UI; +using Cthangover.Mods.Dialog; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Keyboard of the RPG world. Keys are read by their physical position + /// (), so WASD and E/F stay where + /// they are on a Russian or any other layout; the arrows work too. + /// + /// The world must not walk while something else owns the keyboard or the + /// player's attention: a dialog, a toolbar panel (inventory, journal), a + /// focused editor window or text field, a paused tree, a scene transition. + /// collects those checks in one place. + /// + public static class RpgInput + { + /// The held direction, or null. Vertical keys win over horizontal ones when both are held. + public static RpgDirection? HeldDirection() + { + if (Input.IsPhysicalKeyPressed(Key.W) || Input.IsPhysicalKeyPressed(Key.Up)) + return RpgDirection.Up; + if (Input.IsPhysicalKeyPressed(Key.S) || Input.IsPhysicalKeyPressed(Key.Down)) + return RpgDirection.Down; + if (Input.IsPhysicalKeyPressed(Key.A) || Input.IsPhysicalKeyPressed(Key.Left)) + return RpgDirection.Left; + if (Input.IsPhysicalKeyPressed(Key.D) || Input.IsPhysicalKeyPressed(Key.Right)) + return RpgDirection.Right; + return null; + } + + /// True for a fresh press of the action key (E or F). + public static bool IsActionPress(InputEvent @event) + { + return @event is InputEventKey key && key.Pressed && !key.Echo + && (key.PhysicalKeycode == Key.E || key.PhysicalKeycode == Key.F); + } + + /// The shell's dialog box, or null outside the shell. + public static DialogBox FindDialogBox() => SceneContextNode.FindNode("DialogBox"); + + /// True when the player must not walk or interact right now. + public static bool IsWalkBlocked(Node node) + { + if (node == null || !node.IsInsideTree() || node.GetTree().Paused || GodotSceneService.IsTransitioning) + return true; + if (FindDialogBox()?.IsRunning == true) + return true; + + var viewport = node.GetViewport(); + var focus = viewport.GuiGetFocusOwner(); + if (focus is LineEdit || focus is TextEdit) + return true; + foreach (var window in viewport.GetEmbeddedSubwindows()) + { + if (window.Visible && window.HasFocus()) + return true; + } + return IsPanelOpen(); + } + + private static bool IsPanelOpen() + { + var host = UiHost.Instance; + if (host == null) + return false; + foreach (var entry in UiRegistry.PanelEntries) + { + if (host.TryGetInstance(entry.Id, out var panel) && panel.IsVisibleInTree()) + return true; + } + return false; + } + } +} diff --git a/src/World/RpgPlayerLook.cs b/src/World/RpgPlayerLook.cs new file mode 100644 index 0000000..2be1cbc --- /dev/null +++ b/src/World/RpgPlayerLook.cs @@ -0,0 +1,25 @@ +using Cthangover.Core.Games; + +namespace Cthangover.Mods.Rpg +{ + /// + /// The player's map sprite is a choice of the game, not of a map: it comes + /// from the collection setting rpg.player_charset (a path in the + /// charsets group), so two games can share the maps of a pack with + /// different heroes. + /// + public static class RpgPlayerLook + { + /// Collection setting key. + public const string SettingKey = "rpg.player_charset"; + + private const string Fallback = "hero"; + + /// The configured charset path, or hero. + public static string Charset() + { + var value = ActiveGame.GetSetting(SettingKey); + return string.IsNullOrWhiteSpace(value) ? Fallback : value; + } + } +} diff --git a/src/World/RpgScenarioSource.cs b/src/World/RpgScenarioSource.cs new file mode 100644 index 0000000..282ed14 --- /dev/null +++ b/src/World/RpgScenarioSource.cs @@ -0,0 +1,43 @@ +using System; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Scenarios; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Reads the scenario a map event points to. Event scenarios are not scene + /// scenarios: they have no header (or one that is ignored) and play only when + /// the event fires. The path is relative to the scenarios file group + /// with an optional extension, and the usual file override applies (a + /// dependent mod's file of the same path wins), so the map does not need to + /// know which mod ships the text. + /// + public static class RpgScenarioSource + { + private const string Group = "scenarios"; + private const string Extension = ".scenario"; + + /// Scenario body without its header, or null when no mod has the file. + public static string ReadBody(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + var wanted = path.Replace('\\', '/').Trim('/'); + if (wanted.StartsWith(Group + "/", StringComparison.OrdinalIgnoreCase)) + wanted = wanted.Substring(Group.Length + 1); + if (!wanted.EndsWith(Extension, StringComparison.OrdinalIgnoreCase)) + wanted += Extension; + + foreach (var pair in Files.Collect(Group)) + { + if (!string.Equals(pair.Key, wanted, StringComparison.OrdinalIgnoreCase)) + continue; + var text = ModManager.Instance.ReadFileText(pair.Value.ModId, pair.Value.FullPath); + return text == null ? null : ScenarioMetadata.ParseMetadata(text).remainingText; + } + return null; + } + } +} diff --git a/src/World/RpgTextures.cs b/src/World/RpgTextures.cs new file mode 100644 index 0000000..3f9e3f1 --- /dev/null +++ b/src/World/RpgTextures.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Image lookup of the RPG world: tileset atlases in the tilesets file + /// group, charsets in charsets. Both are file groups (last mod wins), + /// not texture groups, so their names never collide with backgrounds or + /// icons. Decoded textures are kept for the life of one world, which builds + /// and owns an instance; the editor keeps its own. + /// + public class RpgTextures + { + /// File group of tileset images. + public const string TilesetGroup = "tilesets"; + + /// File group of charset images. + public const string CharsetGroup = "charsets"; + + private readonly Dictionary cache = new(); + + /// Tileset atlas image, or null (logged by the loader). + public Texture2D Tileset(string path) => Load(TilesetGroup, path); + + /// Charset image, or null. + public Texture2D Charset(string path) => Load(CharsetGroup, path); + + private Texture2D Load(string group, string path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + var key = group + "/" + path; + if (!cache.TryGetValue(key, out var texture)) + { + texture = TextureUtils.LoadFromModGroup(group, path); + cache[key] = texture; + } + return texture; + } + } +} diff --git a/src/World/RpgTileSetBuilder.cs b/src/World/RpgTileSetBuilder.cs new file mode 100644 index 0000000..642ed13 --- /dev/null +++ b/src/World/RpgTileSetBuilder.cs @@ -0,0 +1,51 @@ +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Turns an and its atlas image into a Godot + /// with a single atlas source (id 0), so maps are drawn + /// by — batched, culled, pixel-exact. Atlas index + /// i is atlas coordinate (i % Columns, i / Columns); the world + /// and the editor both convert through . + /// + public static class RpgTileSetBuilder + { + /// The only atlas source id of a built tile set. + public const int SourceId = 0; + + /// Builds the tile set; every whole cell of the image becomes a tile. + public static TileSet Build(RpgTileset tileset, Texture2D texture) + { + var size = new Vector2I(tileset.TileSize, tileset.TileSize); + var tileSet = new TileSet { TileSize = size }; + var source = new TileSetAtlasSource { Texture = texture, TextureRegionSize = size }; + + var columns = texture.GetWidth() / tileset.TileSize; + var rows = texture.GetHeight() / tileset.TileSize; + for (var y = 0; y < rows; y++) + { + for (var x = 0; x < columns; x++) + source.CreateTile(new Vector2I(x, y)); + } + + tileSet.AddSource(source, SourceId); + return tileSet; + } + + /// Atlas coordinate of a row-major index. + public static Vector2I AtlasCoords(RpgTileset tileset, int index) + { + var columns = System.Math.Max(1, tileset.Columns); + return new Vector2I(index % columns, index / columns); + } + + /// Number of tiles the image holds. + public static int TileCount(RpgTileset tileset, Texture2D texture) + { + if (texture == null || tileset.TileSize <= 0) + return 0; + return (texture.GetWidth() / tileset.TileSize) * (texture.GetHeight() / tileset.TileSize); + } + } +} diff --git a/src/World/RpgWorldHost.cs b/src/World/RpgWorldHost.cs new file mode 100644 index 0000000..473b9bb --- /dev/null +++ b/src/World/RpgWorldHost.cs @@ -0,0 +1,132 @@ +using Cthangover.Core.Events; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// The place of the RPG world in the 2D shell. The rpg mod patches + /// base_scene.tscn with this full-screen node right above the view box, + /// below the dialog canvas and the toolbar, so dialogs, panels and + /// notifications stay on top of the map. + /// + /// It follows the scene lifecycle: entering a scene whose name is a map id + /// mounts that map () and shows the dark backdrop; + /// any other scene leaves the host empty and invisible, so a game can mix + /// novel scenes and map scenes freely. The shell is rebuilt after battles, + /// so the host is too; the position survives in . + /// + /// is the host of the live shell, used by teleports + /// and the tests. + /// + public partial class RpgWorldHost : Control + { + private Control backdrop; + + /// The host in the running shell, or null. + public static RpgWorldHost Current { get; private set; } + + /// The mounted map, or null. + public RpgWorldView View { get; private set; } + + /// Subscribes to the scene lifecycle and condition changes. + public override void _EnterTree() + { + Current = this; + ModEventBus.Subscribe(OnSceneEntered); + ModEventBus.Subscribe(OnSceneExited); + ModEventBus.Subscribe(OnConditionsChanged); + } + + /// Finds the backdrop and starts hidden until a map scene is entered. + public override void _Ready() + { + backdrop = GetNodeOrNull("Backdrop"); + Visible = false; + } + + /// Unsubscribes; the view goes with the node. + public override void _ExitTree() + { + ModEventBus.Unsubscribe(OnSceneEntered); + ModEventBus.Unsubscribe(OnSceneExited); + ModEventBus.Unsubscribe(OnConditionsChanged); + if (Current == this) + Current = null; + } + + /// + /// Moves the player to a cell. On the mounted map it happens at once; + /// otherwise the target is stored as the arrival point and the scene + /// switches (deferred, like switch_scene). + /// + public static void Teleport(RpgTeleport target) + { + var host = Current; + var mapId = string.IsNullOrEmpty(target.Map) ? host?.View?.Map?.ID : target.Map; + if (string.IsNullOrEmpty(mapId)) + { + GameLogger.Log("RPG", "teleport without a map while no map is mounted", LogLevel.Error); + return; + } + + if (host?.View != null && host.View.Map.ID == mapId) + { + host.View.PlacePlayer(target.X, target.Y, target.Dir); + return; + } + + RpgState.Instance.Place(mapId, target.X, target.Y, target.Dir); + var sceneManager = (Engine.GetMainLoop() as SceneTree)?.Root?.GetNodeOrNull("SceneManager"); + if (sceneManager == null) + { + GameLogger.Log("RPG", $"teleport to '{mapId}': SceneManager not found", LogLevel.Error); + return; + } + Callable.From(() => sceneManager.SwitchScene(mapId)).CallDeferred(); + } + + /// Mounts the map of the entered scene, if there is one. + public void Mount(string sceneName) + { + Unmount(); + var map = RpgMapFactory.Instance.Get(sceneName); + if (map == null) + return; + + View = new RpgWorldView(); + AddChild(View); + View.Build(map); + + var state = RpgState.Instance.Data; + if (state.Map == map.ID && View.Contains(state.X, state.Y)) + View.PlacePlayer(state.X, state.Y, state.Dir); + else + View.PlacePlayer(map.Spawn?.X ?? 0, map.Spawn?.Y ?? 0, map.Spawn?.Dir ?? RpgDirection.Down); + + if (backdrop != null) + backdrop.Visible = true; + Visible = true; + GameLogger.Log("RPG", $"map '{map.ID}' mounted, player at {View.Player.Cell}"); + } + + /// Frees the mounted map and hides the host. + public void Unmount() + { + if (View != null) + { + RemoveChild(View); + View.QueueFree(); + View = null; + } + Visible = false; + } + + private void OnSceneEntered(SceneEnteredEvent evt) => Mount(evt.SceneName); + + private void OnSceneExited(SceneExitedEvent evt) => Unmount(); + + private void OnConditionsChanged(ConditionStateChangedEvent evt) => View?.RefreshEvents(); + } +} diff --git a/src/World/RpgWorldView.cs b/src/World/RpgWorldView.cs new file mode 100644 index 0000000..f09e518 --- /dev/null +++ b/src/World/RpgWorldView.cs @@ -0,0 +1,254 @@ +using System; +using System.Collections.Generic; +using Cthangover.Core.Scenes; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// One mounted map: the drawn layers, the actors and the player, plus the + /// rules of walking around. Built by when a map + /// scene is entered and freed when the scene is left. + /// + /// Draw order (children, bottom to top): back tiles, main tiles, the actor + /// layer (Y-sorted: events and the player), front tiles. The whole node is + /// scaled by the map's zoom with nearest filtering and follows the player + /// like a camera, clamped to the map edges; a map smaller than the screen is + /// centred. + /// + /// Each frame without a blocker () a held + /// direction turns the player and, if the cell is free, starts a step. When a + /// step ends the position is stored in , then a touch + /// event on the cell fires, else the encounter table rolls. The action key + /// fires the action event in front of the player (or under them); during a + /// dialog it advances the dialog instead. + /// + public partial class RpgWorldView : Node2D + { + private readonly Dictionary eventActors = new(); + private readonly RpgTextures textures = new(); + private readonly Random random = new(); + private RpgMapGrid grid; + private Node2D actorLayer; + private Texture2D atlas; + + /// The mounted map. + public RpgMap Map { get; private set; } + + /// The map's tileset (null when it could not be resolved: the map is drawn empty). + public RpgTileset Tileset { get; private set; } + + /// The player's actor. + public RpgActor Player { get; private set; } + + /// Pixel edge of a tile before zoom. + public int TileSize => Tileset?.TileSize ?? 32; + + /// Builds the layers and actors of ; the player is placed by . + public void Build(RpgMap map) + { + Map = map; + Name = "World"; + TextureFilter = TextureFilterEnum.Nearest; + Scale = Vector2.One * Math.Max(1, map.Zoom); + grid = RpgMapGrid.Parse(map); + + Tileset = RpgTilesetFactory.Instance.Get(map.Tileset); + if (Tileset == null) + GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error); + atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null; + var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null; + + AddChild(BuildLayer(RpgLayer.Back, tileSet)); + AddChild(BuildLayer(RpgLayer.Main, tileSet)); + actorLayer = new Node2D { Name = "Actors", YSortEnabled = true }; + AddChild(actorLayer); + AddChild(BuildLayer(RpgLayer.Front, tileSet)); + + foreach (var mapEvent in map.Events ?? new List()) + AddEventActor(mapEvent); + RefreshEvents(); + + Player = new RpgActor(TileSize) { Name = "Player" }; + Player.UseCharset(textures.Charset(RpgPlayerLook.Charset())); + Player.StepFinished += OnPlayerStepFinished; + actorLayer.AddChild(Player); + } + + /// Puts the player on a cell and stores the position. + public void PlacePlayer(int x, int y, RpgDirection facing) + { + Player.Place(new Vector2I(x, y), facing); + RpgState.Instance.Place(Map.ID, x, y, facing); + UpdateCamera(); + } + + /// + /// Re-evaluates every event's condition: hidden events stop drawing, + /// blocking and reacting. Called on build and whenever game state that a + /// condition may read changes. + /// + public void RefreshEvents() + { + foreach (var actor in eventActors.Values) + actor.Visible = string.IsNullOrWhiteSpace(actor.Event.Condition) || ScenarioCondition.Evaluate(actor.Event.Condition); + } + + /// True when the cell lies on the map (a saved position may be stale after a map edit). + public bool Contains(int x, int y) => grid.InBounds(x, y); + + /// True when the player could step into the cell now. + public bool CanEnter(Vector2I cell) + { + if (!RpgPassability.IsWalkable(grid, Tileset, cell.X, cell.Y)) + return false; + foreach (var actor in eventActors.Values) + { + if (actor.Visible && actor.Event.IsSolid && actor.Cell == cell) + return false; + } + return true; + } + + /// The actor of the event with this id (visible or not), or null. + public RpgActor FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null; + + /// The visible event on a cell with the given trigger, or null. + public RpgActor EventAt(Vector2I cell, RpgEventTrigger trigger) + { + foreach (var actor in eventActors.Values) + { + if (actor.Visible && actor.Cell == cell && actor.Event.Trigger == trigger) + return actor; + } + return null; + } + + /// + /// Fires the action event the player faces, or the one under them + /// (a sign on the floor). An NPC turns to the player first. + /// + public bool Interact() + { + if (Player == null || Player.IsMoving) + return false; + + var target = EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action) + ?? EventAt(Player.Cell, RpgEventTrigger.Action); + if (target == null) + return false; + + if (target.Cell != Player.Cell) + target.Face(RpgDirections.Opposite(Player.Facing)); + return RpgEventRunner.Run(target.Event, Map.ID); + } + + /// Walking: one decision per frame while the player stands still. + public override void _Process(double delta) + { + if (Player != null && !Player.IsMoving && !RpgInput.IsWalkBlocked(this)) + { + var held = RpgInput.HeldDirection(); + if (held.HasValue) + { + var next = Player.Cell + RpgDirections.Delta(held.Value); + if (CanEnter(next)) + Player.StepTo(held.Value); + else + Player.Face(held.Value); + } + } + UpdateCamera(); + } + + /// The action key: advances a running dialog, otherwise interacts. + public override void _UnhandledInput(InputEvent @event) + { + if (!RpgInput.IsActionPress(@event)) + return; + + var dialogBox = RpgInput.FindDialogBox(); + if (dialogBox?.IsRunning == true) + { + if (!dialogBox.IsWaitingAnswer) + dialogBox.NextAction(); + GetViewport().SetInputAsHandled(); + return; + } + + if (!RpgInput.IsWalkBlocked(this) && Interact()) + GetViewport().SetInputAsHandled(); + } + + private void OnPlayerStepFinished(RpgActor player) + { + RpgState.Instance.Place(Map.ID, player.Cell.X, player.Cell.Y, player.Facing); + + var touch = EventAt(player.Cell, RpgEventTrigger.Touch); + if (touch != null) + { + RpgEventRunner.Run(touch.Event, Map.ID); + return; + } + + var enemies = RpgEncounterRoller.Roll(Map.Encounters, random); + if (enemies != null) + RpgBattleService.Start(enemies, null); + } + + private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet) + { + var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet }; + if (tileSet == null) + return node; + for (var y = 0; y < grid.Height; y++) + { + for (var x = 0; x < grid.Width; x++) + { + var index = grid.GetTile(layer, x, y); + if (index >= 0) + node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index)); + } + } + return node; + } + + private void AddEventActor(RpgMapEvent mapEvent) + { + if (string.IsNullOrEmpty(mapEvent.Id) || eventActors.ContainsKey(mapEvent.Id)) + { + GameLogger.Log("RPG", $"map '{Map.ID}': event without a unique id at {mapEvent.X},{mapEvent.Y} skipped", LogLevel.Error); + return; + } + + var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id }; + if (!string.IsNullOrEmpty(mapEvent.Sprite)) + actor.UseCharset(textures.Charset(mapEvent.Sprite)); + else if (mapEvent.Tile >= 0 && Tileset != null) + actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile)); + actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir); + actorLayer.AddChild(actor); + eventActors[mapEvent.Id] = actor; + } + + private void UpdateCamera() + { + if (Player == null || GetParent() is not Control host) + return; + + var zoom = Scale.X; + var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom; + var screen = host.Size; + var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom; + Position = new Vector2(Axis(screen.X, mapSize.X, focus.X), Axis(screen.Y, mapSize.Y, focus.Y)); + } + + private static float Axis(float screen, float map, float focus) + { + if (map <= screen) + return Mathf.Round((screen - map) / 2f); + return Mathf.Round(Mathf.Clamp(screen / 2f - focus, screen - map, 0f)); + } + } +} diff --git a/test_suites/walk/manifest.json b/test_suites/walk/manifest.json new file mode 100644 index 0000000..c9a5925 --- /dev/null +++ b/test_suites/walk/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "RPG Walk Test", + "description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots= saves screenshots (windowed runs only).", + "sources": [ + "src/*.cs" + ], + "depends": [ + "rpg_demo_game>=1" + ] +} diff --git a/test_suites/walk/src/RpgWalkTestDriver.cs b/test_suites/walk/src/RpgWalkTestDriver.cs new file mode 100644 index 0000000..bfdfd49 --- /dev/null +++ b/test_suites/walk/src/RpgWalkTestDriver.cs @@ -0,0 +1,258 @@ +using System.Collections.Generic; +using Cthangover.Core.Battle; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Scenes; +using Cthangover.Core.Settings; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg.Tests +{ + /// + /// Plays the rpg_demo village like a player: key events go through + /// , so the world reads them exactly as + /// it reads a keyboard (physical keys, held state, unhandled action key). + /// + /// One step per timer tick; a step either waits (returns without + /// advancing) or checks and moves on. A failed check is recorded and the + /// run goes on; an exception ends it. Result line: + /// [TEST_RESULT] name=rpg_walk passed=N failed=M, exit code 0/1, and a + /// 90 s watchdog. + /// + public partial class RpgWalkTestDriver : Node + { + private const float TickSeconds = 0.1f; + private const float TimeoutSeconds = 90f; + private const string Village = "rpg_village"; + private const string House = "rpg_elder_house"; + private const string WinFlag = "rpg_test_win"; + + private readonly string shotsDir; + private readonly List failures = new(); + private int passed; + private int step; + private int waitTicks; + private double elapsed; + + /// Creates the driver; null disables screenshots. + public RpgWalkTestDriver(string shotsDir) + { + this.shotsDir = shotsDir; + Name = "RpgWalkTestDriver"; + var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += Tick; + } + + private static RpgWorldView View => RpgWorldHost.Current?.View; + + private void Tick() + { + if (step < 0) + return; + elapsed += TickSeconds; + if (elapsed > TimeoutSeconds) + { + Fail($"timeout at step {step}"); + Finish(); + return; + } + + try + { + RunStep(); + } + catch (System.Exception ex) + { + Fail($"step {step}: {ex.GetType().Name}: {ex.Message}"); + Finish(); + } + } + + private void RunStep() + { + switch (step) + { + case 0: + if (Boot.IsReady && View?.Map?.ID == Village) + Next(); + return; + case 1: + // The intro dialog: the action key advances it. + if (AdvanceDialog()) + return; + Check(GameData.Instance.Runtime.CharacterData.Characters.ContainsKey("rpg_hero"), "party.add put the hero into the party"); + Check(View.Player.Cell == new Vector2I(15, 14), $"player at the spawn (15,14), got {View.Player.Cell}"); + Shot("village"); + Press(Key.W, true); + Next(); + return; + case 2: + if (View.Player.Cell.Y > 12) + return; + Press(Key.W, false); + Next(); + return; + case 3: + if (View.Player.IsMoving) + return; + Check(View.Player.Cell.X == 15 && View.Player.Cell.Y <= 12, $"W walked up the path, at {View.Player.Cell}"); + Check(RpgState.Instance.Data.Map == Village && RpgState.Instance.Data.Y == View.Player.Cell.Y, "the position is stored after the step"); + View.PlacePlayer(14, 11, RpgDirection.Up); + Press(Key.S, true); + Next(); + return; + case 4: + if (++waitTicks < 4) + return; + Press(Key.S, false); + Check(View.Player.Cell == new Vector2I(14, 11), $"the well blocks the way, at {View.Player.Cell}"); + Check(View.Player.Facing == RpgDirection.Down, "a blocked key still turns the player"); + Next(); + return; + case 5: + // The S release is read on the next frame; placing the player + // before it would let the still-held key walk them away. + View.PlacePlayer(16, 12, RpgDirection.Up); + Tap(Key.E); + Next(); + return; + case 6: + Check(RpgInput.FindDialogBox()?.IsRunning == true, "E on the sign opens a dialog"); + Next(); + return; + case 7: + if (AdvanceDialog()) + return; + View.PlacePlayer(10, 10, RpgDirection.Left); + Tap(Key.F); + Next(); + return; + case 8: + Check(RpgInput.FindDialogBox()?.IsRunning == true, "F talks to the villager"); + Check(View.FindEvent("villager")?.Facing == RpgDirection.Right, "the villager turns to the player"); + Next(); + return; + case 9: + if (AdvanceDialog()) + return; + View.PlacePlayer(6, 7, RpgDirection.Up); + Press(Key.W, true); + Next(); + return; + case 10: + if (!View.Player.IsMoving && View.Map.ID == Village) + return; + Press(Key.W, false); + Next(); + return; + case 11: + if (View?.Map?.ID != House) + return; + if (AdvanceDialog()) + return; + Check(View.Player.Cell == new Vector2I(6, 8), $"the door teleports into the house at (6,8), got {View.Player.Cell}"); + Check(View.FindEvent("elder_quest")?.Visible == true && View.FindEvent("elder_thanks")?.Visible == false, "before the wolf the elder gives the quest"); + Shot("house"); + ScenarioCondition.SetFlag("rpg_wolf_dead", "1"); + Next(); + return; + case 12: + Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once"); + ScenarioCondition.SetFlag("rpg_wolf_dead", "0"); + Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map"); + Engine.TimeScale = 4; + Next(); + return; + case 13: + if (GetTree().CurrentScene is not BattleSceneContext battle) + return; + // The core's auto turn only passes, so the hero would lose: once the + // core waits for the player, take the platform's victory path. + if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput) + return; + Shot("battle"); + battle.ShowWinground(); + Next(); + return; + case 14: + if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree()) + return; + Shot("victory"); + button.EmitSignal(BaseButton.SignalName.Pressed); + Next(); + return; + case 15: + if (View?.Map?.ID != House || GodotSceneService.IsTransitioning) + return; + Engine.TimeScale = 1; + Check(View.Player.Cell == new Vector2I(6, 8), $"the battle returns to the same cell, got {View.Player.Cell}"); + Check(GameData.Instance.Runtime.Flags.TryGetValue(WinFlag, out var won) && won == "1", "the win flag is set after the victory"); + Finish(); + return; + } + } + + /// Advances a running dialog with the action key; true while it is still running. + private bool AdvanceDialog() + { + var box = RpgInput.FindDialogBox(); + if (box == null || !box.IsRunning) + return false; + Tap(Key.E); + return true; + } + + private static void Press(Key key, bool pressed) + { + Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = pressed }); + } + + private static void Tap(Key key) + { + Press(key, true); + Press(key, false); + } + + private void Next() + { + step++; + waitTicks = 0; + } + + private void Check(bool condition, string what) + { + if (condition) + { + passed++; + GameLogger.Log("TEST", $"[PASS] {what}"); + } + else + { + Fail(what); + } + } + + private void Fail(string what) + { + failures.Add(what); + GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error); + } + + private void Shot(string name) + { + if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless") + return; + DirAccess.MakeDirRecursiveAbsolute(shotsDir); + GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/rpg_" + name + ".png"); + } + + private void Finish() + { + step = -1; + Engine.TimeScale = 1; + GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_walk passed={passed} failed={failures.Count}"); + GetTree().Quit(failures.Count == 0 ? 0 : 1); + } + } +} diff --git a/test_suites/walk/src/RpgWalkTestInitializer.cs b/test_suites/walk/src/RpgWalkTestInitializer.cs new file mode 100644 index 0000000..b752a4b --- /dev/null +++ b/test_suites/walk/src/RpgWalkTestInitializer.cs @@ -0,0 +1,38 @@ +using System.Linq; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Godot; + +namespace Cthangover.Mods.Rpg.Tests +{ + /// + /// Entry point of the rpg_walk integration test: adds the driver to the tree + /// root when the exact flag --itest=rpg_walk is given (the driver + /// quits the process, so it must never run in another test). Optional + /// --shots=<dir> makes the driver save screenshots. + /// + public class RpgWalkTestInitializer : IModInitializer + { + private static bool driverQueued; + + /// Nothing per mod. + public void OnModLoaded(string modId) + { + } + + /// Queues the driver once. + public void OnModResourcesReady() + { + var args = OS.GetCmdlineUserArgs(); + if (driverQueued || !args.Any(a => a == "--itest=rpg_walk")) + return; + + driverQueued = true; + var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal)) + .Select(a => a.Substring("--shots=".Length)) + .FirstOrDefault(); + GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}"); + (Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgWalkTestDriver(shots)); + } + } +}