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");
+ }
+ }
+}