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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1b733ea01
commit
afc49a16e0
18 files changed
+550
-15
No files matched your search
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 = 向き
|
||||||
@@ -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 = 방향
|
||||||
@@ -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 = Направление
|
||||||
@@ -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
|
||||||
@@ -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 = 朝向
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One page of a map event, as in RPG Maker: the event's look and effect while the
|
||||||
|
/// page's <see cref="Condition"/> holds. Every field left out (null) is the event's
|
||||||
|
/// own; an empty <see cref="Sprite"/>/<see cref="Icon"/> 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 (<see cref="RpgEventPages"/>).
|
||||||
|
/// </summary>
|
||||||
|
public class RpgEventPage
|
||||||
|
{
|
||||||
|
/// <summary>Scenario condition of the page; empty = always.</summary>
|
||||||
|
public string Condition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Charset of the page ("" = none).</summary>
|
||||||
|
public string Sprite { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Atlas tile of the page (-1 = none).</summary>
|
||||||
|
public int? Tile { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Item icon of the page ("" = none).</summary>
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Facing the event turns to when the page becomes active.</summary>
|
||||||
|
public RpgDirection? Dir { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How the page starts.</summary>
|
||||||
|
public RpgEventTrigger? Trigger { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the page blocks walking.</summary>
|
||||||
|
public bool? Solid { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Scenario file of the page.</summary>
|
||||||
|
public string Scenario { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Inline scenario lines of the page.</summary>
|
||||||
|
public List<string> Commands { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Teleport of the page.</summary>
|
||||||
|
public RpgTeleport Teleport { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Autonomous movement of the page.</summary>
|
||||||
|
public RpgMoveRoute Route { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="RpgMapEvent.Pages"/>; the last page whose condition holds wins, -1
|
||||||
|
/// means none holds and the event is hidden. Pure: the caller passes the condition check.
|
||||||
|
/// </summary>
|
||||||
|
public static class RpgEventPages
|
||||||
|
{
|
||||||
|
/// <summary>The index of the active page, or -1.</summary>
|
||||||
|
public static int ActiveIndex(RpgMapEvent mapEvent, Func<string, bool> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The event as page <paramref name="index"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, bool> holds)
|
||||||
|
=> string.IsNullOrWhiteSpace(condition) || (holds != null && holds(condition));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,16 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Scenario condition; the event exists only while it is true.</summary>
|
/// <summary>Scenario condition; the event exists only while it is true.</summary>
|
||||||
public string Condition { get; set; }
|
public string Condition { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="Condition"/> only
|
||||||
|
/// turns off page 0 (<see cref="RpgEventPages"/>).
|
||||||
|
/// </summary>
|
||||||
|
public List<RpgEventPage> Pages { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Autonomous movement (<see cref="RpgMoveRoute"/>); null = the event stands still.</summary>
|
||||||
|
public RpgMoveRoute Route { get; set; }
|
||||||
|
|
||||||
/// <summary>Mod the event was read from (set by the event resolver, not stored).</summary>
|
/// <summary>Mod the event was read from (set by the event resolver, not stored).</summary>
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public string ModId { get; set; }
|
public string ModId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Autonomous movement of an event (RPG Maker's "move route" / "autonomous movement"):
|
||||||
|
/// <c>"Route": { "Steps": ["left", "left", "wait", "right", "right"], "Every": 0.8 }</c>.
|
||||||
|
/// Steps: <c>up</c>, <c>down</c>, <c>left</c>, <c>right</c>, <c>random</c> (a random
|
||||||
|
/// direction), <c>toward</c> / <c>away</c> (relative to the player), <c>wait</c>, and
|
||||||
|
/// <c>face_up</c> … <c>face_right</c> (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.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMoveRoute
|
||||||
|
{
|
||||||
|
/// <summary>The steps, repeated from the start when <see cref="Repeat"/> is set.</summary>
|
||||||
|
public List<string> Steps { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>Seconds between steps.</summary>
|
||||||
|
public double Every { get; set; } = 1.0;
|
||||||
|
|
||||||
|
/// <summary>Whether the route starts over after the last step.</summary>
|
||||||
|
public bool Repeat { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using Godot;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One step of an <see cref="RpgMoveRoute"/> resolved for the current positions: a
|
||||||
|
/// direction (null for <c>wait</c> or an unknown word) and whether the event walks or only
|
||||||
|
/// turns. Pure, so routes are tested without a map.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct RpgRouteStep
|
||||||
|
{
|
||||||
|
/// <summary>Creates a step.</summary>
|
||||||
|
public RpgRouteStep(RpgDirection? direction, bool moves)
|
||||||
|
{
|
||||||
|
Direction = direction;
|
||||||
|
Moves = moves;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Where to walk or turn; null = stand.</summary>
|
||||||
|
public RpgDirection? Direction { get; }
|
||||||
|
|
||||||
|
/// <summary>True for a walk, false for a turn or a wait.</summary>
|
||||||
|
public bool Moves { get; }
|
||||||
|
|
||||||
|
/// <summary>Resolves <paramref name="word"/> (any case) for an event at <paramref name="self"/>.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The direction that shortens the longer axis of the distance to <paramref name="target"/>; null when already there.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -42,9 +42,21 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>True while a step is in progress.</summary>
|
/// <summary>True while a step is in progress.</summary>
|
||||||
public bool IsMoving => progress < 1f;
|
public bool IsMoving => progress < 1f;
|
||||||
|
|
||||||
/// <summary>The map event this actor shows; null for the player.</summary>
|
/// <summary>The map event this actor shows (its definition, all pages); null for the player.</summary>
|
||||||
public RpgMapEvent Event { get; }
|
public RpgMapEvent Event { get; }
|
||||||
|
|
||||||
|
/// <summary>The event as its active page shows it (<see cref="RpgEventPages.Effective"/>); null while no page is active.</summary>
|
||||||
|
public RpgMapEvent Current { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Index of the active page (<see cref="RpgEventPages.ActiveIndex"/>); -2 before the first refresh.</summary>
|
||||||
|
public int PageIndex { get; set; } = -2;
|
||||||
|
|
||||||
|
/// <summary>Next step of the active page's route.</summary>
|
||||||
|
public int RouteIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Seconds until the next route step.</summary>
|
||||||
|
public double RouteTimer { get; set; }
|
||||||
|
|
||||||
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
|
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
|
||||||
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
|
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
|
||||||
{
|
{
|
||||||
@@ -52,6 +64,15 @@ namespace Cthangover.Mods.Rpg
|
|||||||
Event = mapEvent;
|
Event = mapEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes the shown charset, tile or icon (a page change shows another one).</summary>
|
||||||
|
public void ClearLook()
|
||||||
|
{
|
||||||
|
if (sprite == null)
|
||||||
|
return;
|
||||||
|
sprite.QueueFree();
|
||||||
|
sprite = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
|
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
|
||||||
public void UseCharset(Texture2D texture)
|
public void UseCharset(Texture2D texture)
|
||||||
{
|
{
|
||||||
|
|||||||
+71
-13
@@ -92,7 +92,22 @@ namespace Cthangover.Mods.Rpg
|
|||||||
public void RefreshEvents()
|
public void RefreshEvents()
|
||||||
{
|
{
|
||||||
foreach (var actor in eventActors.Values)
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
|
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
|
||||||
@@ -105,7 +120,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
return false;
|
return false;
|
||||||
foreach (var actor in eventActors.Values)
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -119,7 +134,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
{
|
{
|
||||||
foreach (var actor in eventActors.Values)
|
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 actor;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -141,13 +156,16 @@ namespace Cthangover.Mods.Rpg
|
|||||||
|
|
||||||
if (target.Cell != Player.Cell)
|
if (target.Cell != Player.Cell)
|
||||||
target.Face(RpgDirections.Opposite(Player.Facing));
|
target.Face(RpgDirections.Opposite(Player.Facing));
|
||||||
return RpgEventRunner.Run(target.Event, Map.ID);
|
return RpgEventRunner.Run(target.Current, Map.ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Walking: one decision per frame while the player stands still.</summary>
|
/// <summary>Walking: one decision per frame while the player stands still.</summary>
|
||||||
public override void _Process(double delta)
|
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();
|
var held = RpgInput.HeldDirection();
|
||||||
if (held.HasValue)
|
if (held.HasValue)
|
||||||
@@ -188,7 +206,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
|
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
|
||||||
if (touch != null)
|
if (touch != null)
|
||||||
{
|
{
|
||||||
RpgEventRunner.Run(touch.Event, Map.ID);
|
RpgEventRunner.Run(touch.Current, Map.ID);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,18 +240,58 @@ namespace Cthangover.Mods.Rpg
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The look and the cell come with the first page (RefreshEvents).
|
||||||
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id };
|
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);
|
actorLayer.AddChild(actor);
|
||||||
eventActors[mapEvent.Id] = actor;
|
eventActors[mapEvent.Id] = actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Shows the active page's charset, tile or icon (nothing for an invisible trigger).</summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances the routes of the shown events (<see cref="RpgMoveRoute"/>): one step per
|
||||||
|
/// <c>Every</c> seconds; a walk into a blocked cell or onto the player only turns.
|
||||||
|
/// </summary>
|
||||||
|
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()
|
private void UpdateCamera()
|
||||||
{
|
{
|
||||||
if (Player == null || GetParent() is not Control host)
|
if (Player == null || GetParent() is not Control host)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "RPG Walk Test",
|
"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=<dir> 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=<dir> saves screenshots (windowed runs only).",
|
||||||
"sources": [
|
"sources": [
|
||||||
"src/*.cs"
|
"src/*.cs"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
switch (step)
|
switch (step)
|
||||||
{
|
{
|
||||||
case 0:
|
case 0:
|
||||||
|
if (Boot.IsReady && SkipPrologue())
|
||||||
|
return;
|
||||||
if (Boot.IsReady && View?.Map?.ID == Village)
|
if (Boot.IsReady && View?.Map?.ID == Village)
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
@@ -319,6 +321,26 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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>("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()
|
private bool AdvanceDialog()
|
||||||
{
|
{
|
||||||
var box = RpgInput.FindDialogBox();
|
var box = RpgInput.FindDialogBox();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Event pages pick the last page whose condition holds; routes resolve their steps.</summary>
|
||||||
|
public class RpgEventPagesTests
|
||||||
|
{
|
||||||
|
private static RpgMapEvent Chest() => JsonSerializer.Deserialize<RpgMapEvent>(@"{
|
||||||
|
""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<string, bool> 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<RpgMapEvent>(@"{ ""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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user