From afc49a16e03ce14c0e80df28c93678eab176f998 Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 24 Oct 2026 10:01:10 +0200 Subject: [PATCH] Event pages and NPC move routes on RPG maps; rpg action schemas; walk test passes the RPG Plus prologue Co-Authored-By: Claude Opus 5.5 --- locale/schema_en.properties | 10 +++ locale/schema_es.properties | 10 +++ locale/schema_ja.properties | 10 +++ locale/schema_ko.properties | 10 +++ locale/schema_ru.properties | 10 +++ locale/schema_vi.properties | 10 +++ locale/schema_zh.properties | 10 +++ schemas/rpg.json | 61 +++++++++++++++ src/Data/RpgEventPage.cs | 47 ++++++++++++ src/Data/RpgEventPages.cs | 72 ++++++++++++++++++ src/Data/RpgMapEvent.cs | 10 +++ src/Data/RpgMoveRoute.cs | 25 +++++++ src/Data/RpgRouteStep.cs | 58 +++++++++++++++ src/World/RpgActor.cs | 23 +++++- src/World/RpgWorldView.cs | 84 +++++++++++++++++---- test_suites/walk/manifest.json | 2 +- test_suites/walk/src/RpgWalkTestDriver.cs | 22 ++++++ tests/RpgEventPagesTests.cs | 91 +++++++++++++++++++++++ 18 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 locale/schema_en.properties create mode 100644 locale/schema_es.properties create mode 100644 locale/schema_ja.properties create mode 100644 locale/schema_ko.properties create mode 100644 locale/schema_ru.properties create mode 100644 locale/schema_vi.properties create mode 100644 locale/schema_zh.properties create mode 100644 schemas/rpg.json create mode 100644 src/Data/RpgEventPage.cs create mode 100644 src/Data/RpgEventPages.cs create mode 100644 src/Data/RpgMoveRoute.cs create mode 100644 src/Data/RpgRouteStep.cs create mode 100644 tests/RpgEventPagesTests.cs diff --git a/locale/schema_en.properties b/locale/schema_en.properties new file mode 100644 index 0000000..c05577d --- /dev/null +++ b/locale/schema_en.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = Battle from the map +rpg/schema/builtin/rpg_battle_desc = Enemies, comma separated; the win flag is set on victory +rpg/schema/field/enemies = Enemies +rpg/schema/field/win_flag = Win flag +rpg/schema/builtin/rpg_teleport = Teleport on the map +rpg/schema/builtin/rpg_teleport_desc = A cell and a facing; the map is optional +rpg/schema/field/map = Map +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = Facing diff --git a/locale/schema_es.properties b/locale/schema_es.properties new file mode 100644 index 0000000..17b8d6c --- /dev/null +++ b/locale/schema_es.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = Combate desde el mapa +rpg/schema/builtin/rpg_battle_desc = Enemigos separados por comas; la marca de victoria se pone al ganar +rpg/schema/field/enemies = Enemigos +rpg/schema/field/win_flag = Marca de victoria +rpg/schema/builtin/rpg_teleport = Teletransportar en el mapa +rpg/schema/builtin/rpg_teleport_desc = Una casilla y una dirección; el mapa es opcional +rpg/schema/field/map = Mapa +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = Dirección diff --git a/locale/schema_ja.properties b/locale/schema_ja.properties new file mode 100644 index 0000000..9b193b4 --- /dev/null +++ b/locale/schema_ja.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = マップからの戦闘 +rpg/schema/builtin/rpg_battle_desc = 敵はカンマ区切り。勝つと勝利フラグが立つ +rpg/schema/field/enemies = 敵 +rpg/schema/field/win_flag = 勝利フラグ +rpg/schema/builtin/rpg_teleport = マップ上で移動 +rpg/schema/builtin/rpg_teleport_desc = マスと向き。マップは任意 +rpg/schema/field/map = マップ +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = 向き diff --git a/locale/schema_ko.properties b/locale/schema_ko.properties new file mode 100644 index 0000000..8830eb9 --- /dev/null +++ b/locale/schema_ko.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = 맵에서 전투 +rpg/schema/builtin/rpg_battle_desc = 적은 쉼표로 구분. 이기면 승리 플래그 설정 +rpg/schema/field/enemies = 적 +rpg/schema/field/win_flag = 승리 플래그 +rpg/schema/builtin/rpg_teleport = 맵에서 이동 +rpg/schema/builtin/rpg_teleport_desc = 칸과 방향. 맵은 선택 +rpg/schema/field/map = 맵 +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = 방향 diff --git a/locale/schema_ru.properties b/locale/schema_ru.properties new file mode 100644 index 0000000..d5f374c --- /dev/null +++ b/locale/schema_ru.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = Бой с карты +rpg/schema/builtin/rpg_battle_desc = Враги через запятую; флаг победы ставится при выигрыше +rpg/schema/field/enemies = Враги +rpg/schema/field/win_flag = Флаг победы +rpg/schema/builtin/rpg_teleport = Переместить на карте +rpg/schema/builtin/rpg_teleport_desc = Клетка и направление; карта — по желанию +rpg/schema/field/map = Карта +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = Направление diff --git a/locale/schema_vi.properties b/locale/schema_vi.properties new file mode 100644 index 0000000..7a70580 --- /dev/null +++ b/locale/schema_vi.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = Trận đấu từ bản đồ +rpg/schema/builtin/rpg_battle_desc = Kẻ địch cách nhau bằng dấu phẩy; thắng thì đặt cờ chiến thắng +rpg/schema/field/enemies = Kẻ địch +rpg/schema/field/win_flag = Cờ chiến thắng +rpg/schema/builtin/rpg_teleport = Dịch chuyển trên bản đồ +rpg/schema/builtin/rpg_teleport_desc = Ô và hướng; bản đồ tùy chọn +rpg/schema/field/map = Bản đồ +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = Hướng diff --git a/locale/schema_zh.properties b/locale/schema_zh.properties new file mode 100644 index 0000000..d73dff3 --- /dev/null +++ b/locale/schema_zh.properties @@ -0,0 +1,10 @@ +rpg/schema/builtin/rpg_battle = 地图上的战斗 +rpg/schema/builtin/rpg_battle_desc = 敌人以逗号分隔;获胜时设置胜利标记 +rpg/schema/field/enemies = 敌人 +rpg/schema/field/win_flag = 胜利标记 +rpg/schema/builtin/rpg_teleport = 地图传送 +rpg/schema/builtin/rpg_teleport_desc = 格子和朝向;地图可选 +rpg/schema/field/map = 地图 +rpg/schema/field/x = X +rpg/schema/field/y = Y +rpg/schema/field/dir = 朝向 diff --git a/schemas/rpg.json b/schemas/rpg.json new file mode 100644 index 0000000..37a8ecb --- /dev/null +++ b/schemas/rpg.json @@ -0,0 +1,61 @@ +{ + "Actions": [ + { + "Id": "rpg.battle", + "Name": "rpg/schema/builtin/rpg_battle", + "Description": "rpg/schema/builtin/rpg_battle_desc", + "Category": "battle", + "Params": [ + { + "Key": "enemies", + "Type": "ref_list", + "Kind": "character", + "Required": true, + "Name": "rpg/schema/field/enemies" + }, + { + "Key": "win_flag", + "Type": "flag", + "Name": "rpg/schema/field/win_flag" + } + ] + }, + { + "Id": "rpg.teleport", + "Name": "rpg/schema/builtin/rpg_teleport", + "Description": "rpg/schema/builtin/rpg_teleport_desc", + "Category": "world", + "Params": [ + { + "Key": "map", + "Type": "ref", + "Kind": "rpg_map", + "Name": "rpg/schema/field/map" + }, + { + "Key": "x", + "Type": "int", + "Required": true, + "Name": "rpg/schema/field/x" + }, + { + "Key": "y", + "Type": "int", + "Required": true, + "Name": "rpg/schema/field/y" + }, + { + "Key": "dir", + "Type": "choice", + "Options": [ + "Up", + "Down", + "Left", + "Right" + ], + "Name": "rpg/schema/field/dir" + } + ] + } + ] +} diff --git a/src/Data/RpgEventPage.cs b/src/Data/RpgEventPage.cs new file mode 100644 index 0000000..ad22250 --- /dev/null +++ b/src/Data/RpgEventPage.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// One page of a map event, as in RPG Maker: the event's look and effect while the + /// page's holds. Every field left out (null) is the event's + /// own; an empty / clears it. The last page whose + /// condition holds is the active one; the event's own fields are page 0 and count only + /// while the event's own condition holds (). + /// + public class RpgEventPage + { + /// Scenario condition of the page; empty = always. + public string Condition { get; set; } + + /// Charset of the page ("" = none). + public string Sprite { get; set; } + + /// Atlas tile of the page (-1 = none). + public int? Tile { get; set; } + + /// Item icon of the page ("" = none). + public string Icon { get; set; } + + /// Facing the event turns to when the page becomes active. + public RpgDirection? Dir { get; set; } + + /// How the page starts. + public RpgEventTrigger? Trigger { get; set; } + + /// Whether the page blocks walking. + public bool? Solid { get; set; } + + /// Scenario file of the page. + public string Scenario { get; set; } + + /// Inline scenario lines of the page. + public List Commands { get; set; } + + /// Teleport of the page. + public RpgTeleport Teleport { get; set; } + + /// Autonomous movement of the page. + public RpgMoveRoute Route { get; set; } + } +} diff --git a/src/Data/RpgEventPages.cs b/src/Data/RpgEventPages.cs new file mode 100644 index 0000000..b9c4da3 --- /dev/null +++ b/src/Data/RpgEventPages.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Picks the active page of an event and builds the event as that page shows it. Page + /// index 0 is the event's own fields (active while the event's own condition holds), + /// 1… are ; the last page whose condition holds wins, -1 + /// means none holds and the event is hidden. Pure: the caller passes the condition check. + /// + public static class RpgEventPages + { + /// The index of the active page, or -1. + public static int ActiveIndex(RpgMapEvent mapEvent, Func holds) + { + if (mapEvent == null) + return -1; + var pages = mapEvent.Pages; + if (pages != null) + { + for (var i = pages.Count - 1; i >= 0; i--) + { + if (pages[i] != null && Holds(pages[i].Condition, holds)) + return i + 1; + } + } + return Holds(mapEvent.Condition, holds) ? 0 : -1; + } + + /// + /// The event as page shows it: the event itself for 0, else a + /// copy with the page's fields over the event's. Null for an index out of range. + /// + public static RpgMapEvent Effective(RpgMapEvent mapEvent, int index) + { + if (mapEvent == null || index < 0) + return null; + if (index == 0) + return mapEvent; + if (mapEvent.Pages == null || index > mapEvent.Pages.Count || mapEvent.Pages[index - 1] == null) + return null; + + var page = mapEvent.Pages[index - 1]; + var hasLook = page.Sprite != null || page.Tile.HasValue || page.Icon != null; + return new RpgMapEvent + { + Id = mapEvent.Id, + X = mapEvent.X, + Y = mapEvent.Y, + // A page that names any look replaces the whole look, so a chest page with an + // Icon does not keep the closed chest's Tile underneath it. + Sprite = hasLook ? page.Sprite : mapEvent.Sprite, + Tile = hasLook ? page.Tile ?? -1 : mapEvent.Tile, + Icon = hasLook ? page.Icon : mapEvent.Icon, + Dir = page.Dir ?? mapEvent.Dir, + Trigger = page.Trigger ?? mapEvent.Trigger, + Solid = page.Solid ?? (hasLook ? null : mapEvent.Solid), + Scenario = page.Scenario ?? mapEvent.Scenario, + Commands = page.Commands ?? mapEvent.Commands, + Teleport = page.Teleport ?? mapEvent.Teleport, + Route = page.Route ?? mapEvent.Route, + Condition = page.Condition, + ModId = mapEvent.ModId, + File = mapEvent.File, + }; + } + + private static bool Holds(string condition, Func holds) + => string.IsNullOrWhiteSpace(condition) || (holds != null && holds(condition)); + } +} diff --git a/src/Data/RpgMapEvent.cs b/src/Data/RpgMapEvent.cs index 2be16e5..4e820a2 100644 --- a/src/Data/RpgMapEvent.cs +++ b/src/Data/RpgMapEvent.cs @@ -64,6 +64,16 @@ namespace Cthangover.Mods.Rpg /// Scenario condition; the event exists only while it is true. public string Condition { get; set; } + /// + /// Further pages (RPG Maker's event pages): the last one whose condition holds replaces + /// the event's look and effect; with pages, a false own only + /// turns off page 0 (). + /// + public List Pages { get; set; } + + /// Autonomous movement (); null = the event stands still. + public RpgMoveRoute Route { get; set; } + /// Mod the event was read from (set by the event resolver, not stored). [JsonIgnore] public string ModId { get; set; } diff --git a/src/Data/RpgMoveRoute.cs b/src/Data/RpgMoveRoute.cs new file mode 100644 index 0000000..2b94afb --- /dev/null +++ b/src/Data/RpgMoveRoute.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; + +namespace Cthangover.Mods.Rpg +{ + /// + /// Autonomous movement of an event (RPG Maker's "move route" / "autonomous movement"): + /// "Route": { "Steps": ["left", "left", "wait", "right", "right"], "Every": 0.8 }. + /// Steps: up, down, left, right, random (a random + /// direction), toward / away (relative to the player), wait, and + /// face_up … face_right (turn without moving). A step into a blocked cell is + /// skipped (the event turns that way). The route pauses while a dialog runs or the player + /// cannot walk; positions are not saved, so an event starts from its cell on every visit. + /// + public class RpgMoveRoute + { + /// The steps, repeated from the start when is set. + public List Steps { get; set; } = new(); + + /// Seconds between steps. + public double Every { get; set; } = 1.0; + + /// Whether the route starts over after the last step. + public bool Repeat { get; set; } = true; + } +} diff --git a/src/Data/RpgRouteStep.cs b/src/Data/RpgRouteStep.cs new file mode 100644 index 0000000..6750513 --- /dev/null +++ b/src/Data/RpgRouteStep.cs @@ -0,0 +1,58 @@ +using System; +using Godot; + +namespace Cthangover.Mods.Rpg +{ + /// + /// One step of an resolved for the current positions: a + /// direction (null for wait or an unknown word) and whether the event walks or only + /// turns. Pure, so routes are tested without a map. + /// + public readonly struct RpgRouteStep + { + /// Creates a step. + public RpgRouteStep(RpgDirection? direction, bool moves) + { + Direction = direction; + Moves = moves; + } + + /// Where to walk or turn; null = stand. + public RpgDirection? Direction { get; } + + /// True for a walk, false for a turn or a wait. + public bool Moves { get; } + + /// Resolves (any case) for an event at . + public static RpgRouteStep Resolve(string word, Vector2I self, Vector2I player, Random random) + { + var step = (word ?? string.Empty).Trim().ToLowerInvariant(); + switch (step) + { + case "up": return new RpgRouteStep(RpgDirection.Up, true); + case "down": return new RpgRouteStep(RpgDirection.Down, true); + case "left": return new RpgRouteStep(RpgDirection.Left, true); + case "right": return new RpgRouteStep(RpgDirection.Right, true); + case "face_up": return new RpgRouteStep(RpgDirection.Up, false); + case "face_down": return new RpgRouteStep(RpgDirection.Down, false); + case "face_left": return new RpgRouteStep(RpgDirection.Left, false); + case "face_right": return new RpgRouteStep(RpgDirection.Right, false); + case "random": return new RpgRouteStep((RpgDirection)(random ?? Random.Shared).Next(4), true); + case "toward": return new RpgRouteStep(Toward(self, player), true); + case "away": return new RpgRouteStep(Toward(self, player) is { } d ? RpgDirections.Opposite(d) : null, true); + default: return new RpgRouteStep(null, false); + } + } + + /// The direction that shortens the longer axis of the distance to ; null when already there. + public static RpgDirection? Toward(Vector2I self, Vector2I target) + { + var delta = target - self; + if (delta == Vector2I.Zero) + return null; + if (Math.Abs(delta.X) >= Math.Abs(delta.Y)) + return delta.X > 0 ? RpgDirection.Right : RpgDirection.Left; + return delta.Y > 0 ? RpgDirection.Down : RpgDirection.Up; + } + } +} diff --git a/src/World/RpgActor.cs b/src/World/RpgActor.cs index 794ea52..7e78ed5 100644 --- a/src/World/RpgActor.cs +++ b/src/World/RpgActor.cs @@ -42,9 +42,21 @@ namespace Cthangover.Mods.Rpg /// True while a step is in progress. public bool IsMoving => progress < 1f; - /// The map event this actor shows; null for the player. + /// The map event this actor shows (its definition, all pages); null for the player. public RpgMapEvent Event { get; } + /// The event as its active page shows it (); null while no page is active. + public RpgMapEvent Current { get; set; } + + /// Index of the active page (); -2 before the first refresh. + public int PageIndex { get; set; } = -2; + + /// Next step of the active page's route. + public int RouteIndex { get; set; } + + /// Seconds until the next route step. + public double RouteTimer { get; set; } + /// Creates an actor for a cell grid of pixels. public RpgActor(int tileSize, RpgMapEvent mapEvent = null) { @@ -52,6 +64,15 @@ namespace Cthangover.Mods.Rpg Event = mapEvent; } + /// Removes the shown charset, tile or icon (a page change shows another one). + public void ClearLook() + { + if (sprite == null) + return; + sprite.QueueFree(); + sprite = null; + } + /// Shows a charset; a null texture leaves the actor invisible (an invisible trigger). public void UseCharset(Texture2D texture) { diff --git a/src/World/RpgWorldView.cs b/src/World/RpgWorldView.cs index 3f9f160..d3486ce 100644 --- a/src/World/RpgWorldView.cs +++ b/src/World/RpgWorldView.cs @@ -92,7 +92,22 @@ namespace Cthangover.Mods.Rpg public void RefreshEvents() { foreach (var actor in eventActors.Values) - actor.Visible = string.IsNullOrWhiteSpace(actor.Event.Condition) || ScenarioCondition.Evaluate(actor.Event.Condition); + { + var index = RpgEventPages.ActiveIndex(actor.Event, ScenarioCondition.Evaluate); + actor.Visible = index >= 0; + if (index < 0 || index == actor.PageIndex) + continue; + var first = actor.PageIndex == -2; + actor.PageIndex = index; + actor.Current = RpgEventPages.Effective(actor.Event, index); + actor.RouteIndex = 0; + actor.RouteTimer = actor.Current.Route?.Every ?? 0; + ApplyLook(actor); + if (first) + actor.Place(new Vector2I(actor.Event.X, actor.Event.Y), actor.Current.Dir); + else if (index > 0 && actor.Event.Pages[index - 1].Dir.HasValue) + actor.Face(actor.Current.Dir); + } } /// True when the cell lies on the map (a saved position may be stale after a map edit). @@ -105,7 +120,7 @@ namespace Cthangover.Mods.Rpg return false; foreach (var actor in eventActors.Values) { - if (actor.Visible && actor.Event.IsSolid && actor.Cell == cell) + if (actor.Visible && actor.Current?.IsSolid == true && actor.Cell == cell) return false; } return true; @@ -119,7 +134,7 @@ namespace Cthangover.Mods.Rpg { foreach (var actor in eventActors.Values) { - if (actor.Visible && actor.Cell == cell && actor.Event.Trigger == trigger) + if (actor.Visible && actor.Cell == cell && actor.Current?.Trigger == trigger) return actor; } return null; @@ -141,13 +156,16 @@ namespace Cthangover.Mods.Rpg if (target.Cell != Player.Cell) target.Face(RpgDirections.Opposite(Player.Facing)); - return RpgEventRunner.Run(target.Event, Map.ID); + return RpgEventRunner.Run(target.Current, 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 blocked = RpgInput.IsWalkBlocked(this); + if (!blocked) + WalkRoutes(delta); + if (Player != null && !Player.IsMoving && !blocked) { var held = RpgInput.HeldDirection(); if (held.HasValue) @@ -188,7 +206,7 @@ namespace Cthangover.Mods.Rpg var touch = EventAt(player.Cell, RpgEventTrigger.Touch); if (touch != null) { - RpgEventRunner.Run(touch.Event, Map.ID); + RpgEventRunner.Run(touch.Current, Map.ID); return; } @@ -222,18 +240,58 @@ namespace Cthangover.Mods.Rpg return; } + // The look and the cell come with the first page (RefreshEvents). 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)); - else if (!string.IsNullOrEmpty(mapEvent.Icon)) - actor.UseIcon(textures.Icon(mapEvent.Icon)); - actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir); actorLayer.AddChild(actor); eventActors[mapEvent.Id] = actor; } + /// Shows the active page's charset, tile or icon (nothing for an invisible trigger). + private void ApplyLook(RpgActor actor) + { + var page = actor.Current; + actor.ClearLook(); + if (!string.IsNullOrEmpty(page.Sprite)) + actor.UseCharset(textures.Charset(page.Sprite)); + else if (page.Tile >= 0 && Tileset != null) + actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, page.Tile)); + else if (!string.IsNullOrEmpty(page.Icon)) + actor.UseIcon(textures.Icon(page.Icon)); + } + + /// + /// Advances the routes of the shown events (): one step per + /// Every seconds; a walk into a blocked cell or onto the player only turns. + /// + private void WalkRoutes(double delta) + { + foreach (var actor in eventActors.Values) + { + var route = actor.Current?.Route; + if (!actor.Visible || route?.Steps == null || route.Steps.Count == 0 || actor.IsMoving) + continue; + actor.RouteTimer -= delta; + if (actor.RouteTimer > 0) + continue; + actor.RouteTimer = Math.Max(0.1, route.Every); + if (actor.RouteIndex >= route.Steps.Count) + { + if (!route.Repeat) + continue; + actor.RouteIndex = 0; + } + + var step = RpgRouteStep.Resolve(route.Steps[actor.RouteIndex++], actor.Cell, Player?.Cell ?? actor.Cell, random); + if (!step.Direction.HasValue) + continue; + var next = actor.Cell + RpgDirections.Delta(step.Direction.Value); + if (step.Moves && CanEnter(next) && next != Player?.Cell) + actor.StepTo(step.Direction.Value); + else + actor.Face(step.Direction.Value); + } + } + private void UpdateCamera() { if (Player == null || GetParent() is not Control host) diff --git a/test_suites/walk/manifest.json b/test_suites/walk/manifest.json index 6185262..af3ade9 100644 --- a/test_suites/walk/manifest.json +++ b/test_suites/walk/manifest.json @@ -1,6 +1,6 @@ { "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. A pickup the suite adds to the village through its own map_events file is picked up and must stay gone after leaving the map and coming back, and after a save is loaded. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots= saves screenshots (windowed runs only).", + "description": "Integration test of the rpg mod on the rpg_plus game (its rpg_demo_game world): 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. A pickup the suite adds to the village through its own map_events file is picked up and must stay gone after leaving the map and coming back, and after a save is loaded. Activated by --itest=rpg_walk with --game=rpg_plus --new (the prologue is clicked through first); --shots= saves screenshots (windowed runs only).", "sources": [ "src/*.cs" ], diff --git a/test_suites/walk/src/RpgWalkTestDriver.cs b/test_suites/walk/src/RpgWalkTestDriver.cs index d1f0c67..f112fd9 100644 --- a/test_suites/walk/src/RpgWalkTestDriver.cs +++ b/test_suites/walk/src/RpgWalkTestDriver.cs @@ -97,6 +97,8 @@ namespace Cthangover.Mods.Rpg.Tests switch (step) { case 0: + if (Boot.IsReady && SkipPrologue()) + return; if (Boot.IsReady && View?.Map?.ID == Village) Next(); return; @@ -319,6 +321,26 @@ namespace Cthangover.Mods.Rpg.Tests } /// Advances a running dialog with the action key; true while it is still running. + /// + /// The game opens with a novel-style prologue (no map yet): its lines are clicked + /// through and every choice takes the first answer. True while it runs. + /// + private static bool SkipPrologue() + { + // Asking for the dialog box before the first scene is up caches the wrong node. + var scene = ((SceneTree)Engine.GetMainLoop()).Root.GetNodeOrNull("SceneManager")?.CurrentSceneName; + if (string.IsNullOrEmpty(scene) || RpgWorldHost.Current?.View?.Map != null) + return false; + var box = RpgInput.FindDialogBox(); + if (box == null || !box.IsRunning) + return false; + if (box.IsWaitingAnswer) + box.TrySelectVariant(0); + else + box.NextAction(); + return true; + } + private bool AdvanceDialog() { var box = RpgInput.FindDialogBox(); diff --git a/tests/RpgEventPagesTests.cs b/tests/RpgEventPagesTests.cs new file mode 100644 index 0000000..3c691ec --- /dev/null +++ b/tests/RpgEventPagesTests.cs @@ -0,0 +1,91 @@ +namespace Cthangover.Mods.Rpg.Tests +{ + using System; + using System.Collections.Generic; + using System.Text.Json; + using Cthangover.Core.Mods.Testing; + using Cthangover.Core.Utils; + using Godot; + + /// Event pages pick the last page whose condition holds; routes resolve their steps. + public class RpgEventPagesTests + { + private static RpgMapEvent Chest() => JsonSerializer.Deserialize(@"{ + ""Id"": ""chest"", ""X"": 3, ""Y"": 2, ""Tile"": 40, ""Commands"": [""flag chest_open 1""], + ""Pages"": [ + { ""Condition"": ""chest_open == 1"", ""Icon"": ""rpg_demo/coin"", ""Commands"": [""text \""Empty.\""""] }, + { ""Condition"": ""guard_back == 1"", ""Sprite"": ""village/guard"", ""Dir"": ""left"", ""Solid"": true } + ] + }", JsonOptionsProvider.ReadOptions); + + private static Func Flags(params string[] holding) => c => Array.IndexOf(holding, c) >= 0; + + [ModTest("pages/last_holding_page_wins")] + static void LastHoldingPageWins(ModTestContext ctx) + { + var chest = Chest(); + ModAssert.Equal(0, RpgEventPages.ActiveIndex(chest, Flags()), "no page holds: the event itself"); + ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "the opened page"); + ModAssert.Equal(2, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1", "guard_back == 1")), "the later page wins"); + } + + [ModTest("pages/own_condition_only_turns_off_page_zero")] + static void OwnConditionOnlyTurnsOffPageZero(ModTestContext ctx) + { + var chest = Chest(); + chest.Condition = "chest_here == 1"; + ModAssert.Equal(-1, RpgEventPages.ActiveIndex(chest, Flags()), "nothing holds: hidden"); + ModAssert.Equal(1, RpgEventPages.ActiveIndex(chest, Flags("chest_open == 1")), "a page shows it without the own condition"); + ModAssert.Equal(0, RpgEventPages.ActiveIndex(new RpgMapEvent { Id = "plain" }, Flags()), "an event without pages and condition"); + } + + [ModTest("pages/effective_merges_page_over_event")] + static void EffectiveMergesPageOverEvent(ModTestContext ctx) + { + var chest = Chest(); + ModAssert.True(ReferenceEquals(chest, RpgEventPages.Effective(chest, 0)), "page 0 is the event itself"); + + var open = RpgEventPages.Effective(chest, 1); + ModAssert.Equal("chest", open.Id, "id kept"); + ModAssert.Equal(3, open.X, "cell kept"); + ModAssert.Equal("rpg_demo/coin", open.Icon, "the page's icon"); + ModAssert.Equal(-1, open.Tile, "a page look replaces the whole look"); + ModAssert.Equal("text \"Empty.\"", open.Commands[0], "the page's commands"); + ModAssert.True(open.Trigger == RpgEventTrigger.Action, "trigger inherited"); + + var guard = RpgEventPages.Effective(chest, 2); + ModAssert.Equal("village/guard", guard.Sprite, "charset of page 2"); + ModAssert.True(guard.Dir == RpgDirection.Left, "facing of page 2"); + ModAssert.True(guard.IsSolid, "solid"); + ModAssert.Equal("flag chest_open 1", guard.Commands[0], "commands inherited from the event"); + ModAssert.Null(RpgEventPages.Effective(chest, 3), "out of range"); + } + + [ModTest("routes/steps_resolve")] + static void StepsResolve(ModTestContext ctx) + { + var self = new Vector2I(5, 5); + var player = new Vector2I(2, 6); + var left = RpgRouteStep.Resolve("Left", self, player, null); + ModAssert.True(left.Moves && left.Direction == RpgDirection.Left, "a walk, any case"); + var face = RpgRouteStep.Resolve("face_up", self, player, null); + ModAssert.True(!face.Moves && face.Direction == RpgDirection.Up, "a turn"); + ModAssert.Null(RpgRouteStep.Resolve("wait", self, player, null).Direction, "wait stands"); + ModAssert.Null(RpgRouteStep.Resolve("dance", self, player, null).Direction, "an unknown word stands"); + ModAssert.True(RpgRouteStep.Resolve("toward", self, player, null).Direction == RpgDirection.Left, "toward along the longer axis"); + ModAssert.True(RpgRouteStep.Resolve("away", self, player, null).Direction == RpgDirection.Right, "away is the opposite"); + ModAssert.Null(RpgRouteStep.Toward(self, self), "already there"); + var random = RpgRouteStep.Resolve("random", self, player, new Random(7)); + ModAssert.True(random.Moves && random.Direction.HasValue, "random walks somewhere"); + } + + [ModTest("routes/json_defaults")] + static void JsonDefaults(ModTestContext ctx) + { + var mapEvent = JsonSerializer.Deserialize(@"{ ""Id"": ""cat"", ""Route"": { ""Steps"": [""random""] } }", JsonOptionsProvider.ReadOptions); + ModAssert.CloseTo(1.0, mapEvent.Route.Every, what: "a step a second by default"); + ModAssert.True(mapEvent.Route.Repeat, "repeats by default"); + ModAssert.Null(mapEvent.Pages, "no pages"); + } + } +}