From 5ca225a2d7ee6f011fe81a8545fae16c35add144 Mon Sep 17 00:00:00 2001 From: iisaev Date: Wed, 21 Oct 2026 13:22:18 +0200 Subject: [PATCH] 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. --- locale/en.properties | 2 + locale/ru.properties | 2 + manifest.json | 18 ++ mod.jpg | Bin 0 -> 68315 bytes patches/scenes/ui/base_scene.tscn | 8 + scenes/ui/rpg_world_host.tscn | 23 ++ src/Actions/RpgBattleAction.cs | 28 ++ src/Actions/RpgTeleportAction.cs | 30 ++ src/Data/RpgCollision.cs | 20 ++ src/Data/RpgDirection.cs | 18 ++ src/Data/RpgDirections.cs | 26 ++ src/Data/RpgEncounterRoller.cs | 27 ++ src/Data/RpgEncounterTable.cs | 19 ++ src/Data/RpgEventTrigger.cs | 15 + src/Data/RpgLayer.cs | 10 + src/Data/RpgMap.cs | 58 ++++ src/Data/RpgMapEvent.cs | 60 ++++ src/Data/RpgMapFactory.cs | 27 ++ src/Data/RpgMapGrid.cs | 176 ++++++++++++ src/Data/RpgMapLayers.cs | 26 ++ src/Data/RpgPassability.cs | 29 ++ src/Data/RpgTeleport.cs | 21 ++ src/Data/RpgTileInfo.cs | 25 ++ src/Data/RpgTileset.cs | 54 ++++ src/Data/RpgTilesetFactory.cs | 14 + src/RpgBattleService.cs | 65 +++++ src/RpgModInitializer.cs | 33 +++ src/State/RpgSaveable.cs | 31 +++ src/State/RpgState.cs | 41 +++ src/State/RpgStateData.cs | 21 ++ src/State/RpgStateReset.cs | 14 + src/World/RpgActor.cs | 133 +++++++++ src/World/RpgEventRunner.cs | 61 +++++ src/World/RpgInput.cs | 77 ++++++ src/World/RpgPlayerLook.cs | 25 ++ src/World/RpgScenarioSource.cs | 43 +++ src/World/RpgTextures.cs | 43 +++ src/World/RpgTileSetBuilder.cs | 51 ++++ src/World/RpgWorldHost.cs | 132 +++++++++ src/World/RpgWorldView.cs | 254 +++++++++++++++++ test_suites/walk/manifest.json | 10 + test_suites/walk/src/RpgWalkTestDriver.cs | 258 ++++++++++++++++++ .../walk/src/RpgWalkTestInitializer.cs | 38 +++ 43 files changed, 2066 insertions(+) create mode 100644 locale/en.properties create mode 100644 locale/ru.properties create mode 100644 manifest.json create mode 100644 mod.jpg create mode 100644 patches/scenes/ui/base_scene.tscn create mode 100644 scenes/ui/rpg_world_host.tscn create mode 100644 src/Actions/RpgBattleAction.cs create mode 100644 src/Actions/RpgTeleportAction.cs create mode 100644 src/Data/RpgCollision.cs create mode 100644 src/Data/RpgDirection.cs create mode 100644 src/Data/RpgDirections.cs create mode 100644 src/Data/RpgEncounterRoller.cs create mode 100644 src/Data/RpgEncounterTable.cs create mode 100644 src/Data/RpgEventTrigger.cs create mode 100644 src/Data/RpgLayer.cs create mode 100644 src/Data/RpgMap.cs create mode 100644 src/Data/RpgMapEvent.cs create mode 100644 src/Data/RpgMapFactory.cs create mode 100644 src/Data/RpgMapGrid.cs create mode 100644 src/Data/RpgMapLayers.cs create mode 100644 src/Data/RpgPassability.cs create mode 100644 src/Data/RpgTeleport.cs create mode 100644 src/Data/RpgTileInfo.cs create mode 100644 src/Data/RpgTileset.cs create mode 100644 src/Data/RpgTilesetFactory.cs create mode 100644 src/RpgBattleService.cs create mode 100644 src/RpgModInitializer.cs create mode 100644 src/State/RpgSaveable.cs create mode 100644 src/State/RpgState.cs create mode 100644 src/State/RpgStateData.cs create mode 100644 src/State/RpgStateReset.cs create mode 100644 src/World/RpgActor.cs create mode 100644 src/World/RpgEventRunner.cs create mode 100644 src/World/RpgInput.cs create mode 100644 src/World/RpgPlayerLook.cs create mode 100644 src/World/RpgScenarioSource.cs create mode 100644 src/World/RpgTextures.cs create mode 100644 src/World/RpgTileSetBuilder.cs create mode 100644 src/World/RpgWorldHost.cs create mode 100644 src/World/RpgWorldView.cs create mode 100644 test_suites/walk/manifest.json create mode 100644 test_suites/walk/src/RpgWalkTestDriver.cs create mode 100644 test_suites/walk/src/RpgWalkTestInitializer.cs 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 0000000000000000000000000000000000000000..f3f9a56a2d74a4a044f47ca39e0691929e91b998 GIT binary patch literal 68315 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 z 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 E*(A{5I{*Lx literal 0 HcmV?d00001 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)); + } + } +}