Map events apart from the map: map_events/<map>/*.json of any mod merged by id (RpgMapEventResolver, RpgMapEventMerge); item Icon look for events; rpg_walk checks a pickup across leaving the map and loading a save
This commit is contained in:
1 parent
281b722667
commit
c4d6adf8fb
13 files changed
+396
-20
No files matched your search
+4
-3
@@ -16,7 +16,10 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <para/>
|
/// <para/>
|
||||||
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
|
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
|
||||||
/// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the
|
/// <see cref="RpgMapGrid"/>; a dependent mod replaces a whole map by id, the
|
||||||
/// usual entity override.
|
/// usual entity override. The map file holds only the ground: what stands on it
|
||||||
|
/// (NPCs, doors, chests, pickups) are events in <c>map_events/<id>/</c> of any
|
||||||
|
/// mod, merged by <see cref="RpgMapEventResolver"/>, so a mod adds to another
|
||||||
|
/// mod's map without replacing it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[EntityKind("rpg_map")]
|
[EntityKind("rpg_map")]
|
||||||
public class RpgMap : IIdentifiable
|
public class RpgMap : IIdentifiable
|
||||||
@@ -49,8 +52,6 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Where the player appears when entering the map without a teleport target.</summary>
|
/// <summary>Where the player appears when entering the map without a teleport target.</summary>
|
||||||
public RpgTeleport Spawn { get; set; } = new();
|
public RpgTeleport Spawn { get; set; } = new();
|
||||||
|
|
||||||
/// <summary>Events placed on the map.</summary>
|
|
||||||
public List<RpgMapEvent> Events { get; set; } = new();
|
|
||||||
|
|
||||||
/// <summary>Random battles; null for a safe map.</summary>
|
/// <summary>Random battles; null for a safe map.</summary>
|
||||||
public RpgEncounterTable Encounters { get; set; }
|
public RpgEncounterTable Encounters { get; set; }
|
||||||
|
|||||||
+21
-3
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace Cthangover.Mods.Rpg
|
namespace Cthangover.Mods.Rpg
|
||||||
{
|
{
|
||||||
@@ -7,7 +8,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and
|
/// RPG Maker events: an NPC, a sign, a chest, a door. What it looks like and
|
||||||
/// what it does are independent:
|
/// what it does are independent:
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or nothing (an invisible trigger);</item>
|
/// <item>look — a charset <see cref="Sprite"/>, or one atlas <see cref="Tile"/>, or an item <see cref="Icon"/>, or nothing (an invisible trigger);</item>
|
||||||
/// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built
|
/// <item>effect — a <see cref="Teleport"/>, handled natively, or a dialog built
|
||||||
/// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/>
|
/// from a <see cref="Scenario"/> file followed by inline <see cref="Commands"/>
|
||||||
/// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item>
|
/// (the full scenario DSL, so <c>action rpg.battle …</c>, <c>flag …</c>, quests).</item>
|
||||||
@@ -15,6 +16,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// A <see cref="Condition"/> in the scenario condition language hides the
|
/// A <see cref="Condition"/> in the scenario condition language hides the
|
||||||
/// event while false; it is re-checked whenever game state changes, so a
|
/// event while false; it is re-checked whenever game state changes, so a
|
||||||
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
|
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
|
||||||
|
/// <para/>
|
||||||
|
/// Events are stored apart from their map, in <c>map_events/<map>/*.json</c>
|
||||||
|
/// of any mod (<see cref="RpgMapEventResolver"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RpgMapEvent
|
public class RpgMapEvent
|
||||||
{
|
{
|
||||||
@@ -33,13 +37,19 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary>
|
/// <summary>Atlas index of the map's tileset drawn as the event when it has no sprite; -1 for none.</summary>
|
||||||
public int Tile { get; set; } = -1;
|
public int Tile { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picture in the <c>items</c> group (what an item's <c>Sprite</c> names), drawn fitted into the
|
||||||
|
/// cell when the event has neither a sprite nor a tile: an item lying on the ground.
|
||||||
|
/// </summary>
|
||||||
|
public string Icon { get; set; }
|
||||||
|
|
||||||
/// <summary>Initial facing of a sprite.</summary>
|
/// <summary>Initial facing of a sprite.</summary>
|
||||||
public RpgDirection Dir { get; set; } = RpgDirection.Down;
|
public RpgDirection Dir { get; set; } = RpgDirection.Down;
|
||||||
|
|
||||||
/// <summary>When the event fires.</summary>
|
/// <summary>When the event fires.</summary>
|
||||||
public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action;
|
public RpgEventTrigger Trigger { get; set; } = RpgEventTrigger.Action;
|
||||||
|
|
||||||
/// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite or a tile).</summary>
|
/// <summary>The event occupies its cell. Null means "solid when it is visible" (a sprite, a tile or an icon).</summary>
|
||||||
public bool? Solid { get; set; }
|
public bool? Solid { get; set; }
|
||||||
|
|
||||||
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
|
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
|
||||||
@@ -54,7 +64,15 @@ 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>Mod the event was read from (set by the event resolver, not stored).</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string ModId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mod-relative path of the file the event was read from (set by the event resolver, not stored).</summary>
|
||||||
|
[JsonIgnore]
|
||||||
|
public string File { get; set; }
|
||||||
|
|
||||||
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
|
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
|
||||||
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0);
|
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0 || !string.IsNullOrEmpty(Icon));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// One file of map events, <c>map_events/<map id>/<any name>.json</c>:
|
||||||
|
/// <c>{"Events": [ … ]}</c>. The folder names the map, so a file holds the events
|
||||||
|
/// of one map, and a mod may split them over as many files as it likes.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventFile
|
||||||
|
{
|
||||||
|
/// <summary>The events of the file, in drawing order.</summary>
|
||||||
|
public List<RpgMapEvent> Events { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the event list of every map from event files read in mod load order.
|
||||||
|
/// A new id is appended; an id already present (ignoring case) is replaced in
|
||||||
|
/// place, so the later file wins and keeps the original's drawing order. Mods
|
||||||
|
/// load after their dependencies, so the engine's replacement rule holds:
|
||||||
|
/// ship the same event id and declare <c>depends</c> on the owner to replace
|
||||||
|
/// it, ship a new id to add to any mod's map.
|
||||||
|
/// <para/>
|
||||||
|
/// Pure data, so the merge rules are tested without a mod set.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventMerge
|
||||||
|
{
|
||||||
|
/// <summary>Events per map id (case-insensitive).</summary>
|
||||||
|
public Dictionary<string, List<RpgMapEvent>> Maps { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the events of one file of <paramref name="modId"/> for map <paramref name="map"/>;
|
||||||
|
/// each event gets <see cref="RpgMapEvent.ModId"/> and <see cref="RpgMapEvent.File"/>.
|
||||||
|
/// Returns the ids that replaced an earlier event.
|
||||||
|
/// </summary>
|
||||||
|
public List<string> Add(string map, string modId, string file, IEnumerable<RpgMapEvent> events)
|
||||||
|
{
|
||||||
|
var replaced = new List<string>();
|
||||||
|
if (!Maps.TryGetValue(map, out var list))
|
||||||
|
{
|
||||||
|
list = new List<RpgMapEvent>();
|
||||||
|
Maps[map] = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var mapEvent in events ?? Array.Empty<RpgMapEvent>())
|
||||||
|
{
|
||||||
|
if (mapEvent == null || string.IsNullOrWhiteSpace(mapEvent.Id))
|
||||||
|
continue;
|
||||||
|
mapEvent.ModId = modId;
|
||||||
|
mapEvent.File = file;
|
||||||
|
var index = list.FindIndex(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (index < 0)
|
||||||
|
{
|
||||||
|
list.Add(mapEvent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
list[index] = mapEvent;
|
||||||
|
replaced.Add(mapEvent.Id);
|
||||||
|
}
|
||||||
|
return replaced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Collects map events from <c>map_events/<map id>/**/*.json</c> of every
|
||||||
|
/// loaded mod in load order (files of one mod sorted by path), then from the
|
||||||
|
/// studio overlays, and merges them per map with <see cref="RpgMapEventMerge"/>.
|
||||||
|
/// <para/>
|
||||||
|
/// Events live apart from the map file (<c>maps/<id>.json</c> holds only
|
||||||
|
/// tiles, collision, spawn and encounters) so that any mod can put its own NPC,
|
||||||
|
/// chest or quest trigger on another mod's map without replacing the map, and
|
||||||
|
/// so that drawing and placing do not fight over one big file. Events of a map
|
||||||
|
/// no mod defines are kept and simply never shown.
|
||||||
|
/// </summary>
|
||||||
|
public class RpgMapEventResolver : ModAssetResolver
|
||||||
|
{
|
||||||
|
/// <summary>Group folder of event files.</summary>
|
||||||
|
public const string Directory = "map_events";
|
||||||
|
|
||||||
|
private Dictionary<string, List<RpgMapEvent>> cache;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override string Id => "rpg_map_events";
|
||||||
|
|
||||||
|
/// <summary>Events per map id, merged; cached until the next mod reload or <see cref="ClearCache"/>.</summary>
|
||||||
|
public Dictionary<string, List<RpgMapEvent>> Collect()
|
||||||
|
{
|
||||||
|
Context.Initialize();
|
||||||
|
if (cache != null)
|
||||||
|
return cache;
|
||||||
|
|
||||||
|
var merge = new RpgMapEventMerge();
|
||||||
|
foreach (var modId in Context.GetOrderedModIds())
|
||||||
|
CollectMod(Context.Mods[modId].FileProvider, modId, merge);
|
||||||
|
foreach (var overlay in ModOverlays.All)
|
||||||
|
CollectMod(overlay.Value, overlay.Key, merge);
|
||||||
|
|
||||||
|
cache = merge.Maps;
|
||||||
|
GameLogger.Log("RPG", $"map events: {cache.Values.Sum(l => l.Count)} event(s) on {cache.Count} map(s)");
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void ClearCache() => cache = null;
|
||||||
|
|
||||||
|
private void CollectMod(IModFileProvider provider, string modId, RpgMapEventMerge merge)
|
||||||
|
{
|
||||||
|
foreach (var mapDir in provider.ListFiles(Directory).Where(e => e.EndsWith("/")).OrderBy(e => e, System.StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
var map = mapDir.TrimEnd('/');
|
||||||
|
map = map.Substring(map.LastIndexOf('/') + 1);
|
||||||
|
var files = new List<string>();
|
||||||
|
ListJson(provider, mapDir.TrimEnd('/'), files);
|
||||||
|
foreach (var file in files.OrderBy(f => f, System.StringComparer.Ordinal))
|
||||||
|
ReadFile(provider, modId, map, file, merge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ListJson(IModFileProvider provider, string dir, List<string> files)
|
||||||
|
{
|
||||||
|
foreach (var entry in provider.ListFiles(dir))
|
||||||
|
{
|
||||||
|
if (entry.EndsWith("/"))
|
||||||
|
ListJson(provider, entry.TrimEnd('/'), files);
|
||||||
|
else if (entry.EndsWith(".json", System.StringComparison.OrdinalIgnoreCase))
|
||||||
|
files.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ReadFile(IModFileProvider provider, string modId, string map, string file, RpgMapEventMerge merge)
|
||||||
|
{
|
||||||
|
var text = Context.ReadResolvedText(modId, file, provider);
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var data = JsonSerializer.Deserialize<RpgMapEventFile>(text, JsonOptionsProvider.ReadOptions);
|
||||||
|
foreach (var id in merge.Add(map, modId, file, data?.Events))
|
||||||
|
GameLogger.Log("RPG", $"map '{map}': event '{id}' replaced by mod '{modId}' ({file})");
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
GameLogger.Log("RPG", $"map events '{modId}/{file}': broken JSON: {ex.Message}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using Cthangover.Core.Mods;
|
||||||
|
|
||||||
|
namespace Cthangover.Mods.Rpg
|
||||||
|
{
|
||||||
|
/// <summary>Access to the merged map events of <see cref="RpgMapEventResolver"/>.</summary>
|
||||||
|
public static class RpgMapEvents
|
||||||
|
{
|
||||||
|
/// <summary>The events of a map (an empty list when it has none); the list is shared, do not change it.</summary>
|
||||||
|
public static IReadOnlyList<RpgMapEvent> Of(string map)
|
||||||
|
{
|
||||||
|
var maps = ModManager.Instance.GetResolver<RpgMapEventResolver>()?.Collect();
|
||||||
|
return map != null && maps != null && maps.TryGetValue(map, out var list) ? list : new List<RpgMapEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the collected events, so the next <see cref="Of"/> reads the files again (after an editor save).</summary>
|
||||||
|
public static void Invalidate() => ModManager.Instance.GetResolver<RpgMapEventResolver>()?.ClearCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,25 @@ namespace Cthangover.Mods.Rpg
|
|||||||
AddChild(sprite);
|
AddChild(sprite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shows an item picture fitted into three quarters of the cell and centred on it,
|
||||||
|
/// so an icon of any size lies on the ground like a tile-sized object.
|
||||||
|
/// </summary>
|
||||||
|
public void UseIcon(Texture2D texture)
|
||||||
|
{
|
||||||
|
if (texture == null)
|
||||||
|
return;
|
||||||
|
var fit = tileSize * 0.75f / Mathf.Max(1, Mathf.Max(texture.GetWidth(), texture.GetHeight()));
|
||||||
|
sprite = new Sprite2D
|
||||||
|
{
|
||||||
|
Texture = texture,
|
||||||
|
Position = new Vector2(tileSize, tileSize) / 2f,
|
||||||
|
Scale = new Vector2(fit, fit),
|
||||||
|
TextureFilter = TextureFilterEnum.Linear,
|
||||||
|
};
|
||||||
|
AddChild(sprite);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
|
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
|
||||||
public void Place(Vector2I cell, RpgDirection facing)
|
public void Place(Vector2I cell, RpgDirection facing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file
|
/// Image lookup of the RPG world: tileset atlases in the <c>tilesets</c> file
|
||||||
/// group, charsets in <c>charsets</c>. Both are file groups (last mod wins),
|
/// group, charsets in <c>charsets</c>. Both are file groups (last mod wins),
|
||||||
/// not texture groups, so their names never collide with backgrounds or
|
/// not texture groups, so their names never collide with backgrounds or
|
||||||
/// icons. Decoded textures are kept for the life of one world, which builds
|
/// icons; item pictures for <see cref="RpgMapEvent.Icon"/> come from the
|
||||||
|
/// <c>items</c> group the inventory uses. Decoded textures are kept for the life of one world, which builds
|
||||||
/// and owns an instance; the editor keeps its own.
|
/// and owns an instance; the editor keeps its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RpgTextures
|
public class RpgTextures
|
||||||
@@ -19,6 +20,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>File group of charset images.</summary>
|
/// <summary>File group of charset images.</summary>
|
||||||
public const string CharsetGroup = "charsets";
|
public const string CharsetGroup = "charsets";
|
||||||
|
|
||||||
|
/// <summary>File group of item pictures (an item's <c>Sprite</c>).</summary>
|
||||||
|
public const string IconGroup = "items";
|
||||||
|
|
||||||
private readonly Dictionary<string, Texture2D> cache = new();
|
private readonly Dictionary<string, Texture2D> cache = new();
|
||||||
|
|
||||||
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
|
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
|
||||||
@@ -27,6 +31,9 @@ namespace Cthangover.Mods.Rpg
|
|||||||
/// <summary>Charset image, or null.</summary>
|
/// <summary>Charset image, or null.</summary>
|
||||||
public Texture2D Charset(string path) => Load(CharsetGroup, path);
|
public Texture2D Charset(string path) => Load(CharsetGroup, path);
|
||||||
|
|
||||||
|
/// <summary>Item picture, or null.</summary>
|
||||||
|
public Texture2D Icon(string path) => Load(IconGroup, path);
|
||||||
|
|
||||||
private Texture2D Load(string group, string path)
|
private Texture2D Load(string group, string path)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ namespace Cthangover.Mods.Rpg
|
|||||||
AddChild(actorLayer);
|
AddChild(actorLayer);
|
||||||
AddChild(BuildLayer(RpgLayer.Front, tileSet));
|
AddChild(BuildLayer(RpgLayer.Front, tileSet));
|
||||||
|
|
||||||
foreach (var mapEvent in map.Events ?? new List<RpgMapEvent>())
|
foreach (var mapEvent in RpgMapEvents.Of(map.ID))
|
||||||
AddEventActor(mapEvent);
|
AddEventActor(mapEvent);
|
||||||
RefreshEvents();
|
RefreshEvents();
|
||||||
|
|
||||||
@@ -227,6 +227,8 @@ namespace Cthangover.Mods.Rpg
|
|||||||
actor.UseCharset(textures.Charset(mapEvent.Sprite));
|
actor.UseCharset(textures.Charset(mapEvent.Sprite));
|
||||||
else if (mapEvent.Tile >= 0 && Tileset != null)
|
else if (mapEvent.Tile >= 0 && Tileset != null)
|
||||||
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile));
|
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);
|
actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir);
|
||||||
actorLayer.AddChild(actor);
|
actorLayer.AddChild(actor);
|
||||||
eventActors[mapEvent.Id] = actor;
|
eventActors[mapEvent.Id] = actor;
|
||||||
|
|||||||
@@ -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. 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_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).",
|
||||||
"sources": [
|
"sources": [
|
||||||
"src/*.cs"
|
"src/*.cs"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"Events": [
|
||||||
|
{
|
||||||
|
"Id": "rpg_walk_mushroom",
|
||||||
|
"X": 18,
|
||||||
|
"Y": 14,
|
||||||
|
"Icon": "rpg_demo/forest_mushroom",
|
||||||
|
"Trigger": "Touch",
|
||||||
|
"Solid": false,
|
||||||
|
"Commands": [
|
||||||
|
"ptext \"Found: a mushroom\"",
|
||||||
|
"action item.add id=rpg_demo/mushroom count=1",
|
||||||
|
"flag rpg_village_rpg_walk_mushroom_taken 1",
|
||||||
|
"end"
|
||||||
|
],
|
||||||
|
"Condition": "rpg_village_rpg_walk_mushroom_taken != 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using Cthangover.Core.Factories.Impls;
|
|||||||
using Cthangover.Core.Games;
|
using Cthangover.Core.Games;
|
||||||
using Cthangover.Core.Scenes;
|
using Cthangover.Core.Scenes;
|
||||||
using Cthangover.Core.Settings;
|
using Cthangover.Core.Settings;
|
||||||
|
using Cthangover.Core.Items;
|
||||||
using Cthangover.Core.Utils;
|
using Cthangover.Core.Utils;
|
||||||
using Cthangover.Mods.Interface.View;
|
using Cthangover.Mods.Interface.View;
|
||||||
using Godot;
|
using Godot;
|
||||||
@@ -17,6 +18,13 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
|
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
|
||||||
/// it reads a keyboard (physical keys, held state, unhandled action key).
|
/// it reads a keyboard (physical keys, held state, unhandled action key).
|
||||||
/// <para/>
|
/// <para/>
|
||||||
|
/// A pickup is checked across map rebuilds: the suite's own
|
||||||
|
/// <c>map_events/rpg_village/rpg_walk_test.json</c> lays a mushroom on the demo's
|
||||||
|
/// village (an event added by another mod, shaped like the map editor's
|
||||||
|
/// pickups); it is picked up, the player leaves the map and comes back, then
|
||||||
|
/// saves, loads and comes back again — it must never reappear nor give a
|
||||||
|
/// second item.
|
||||||
|
/// <para/>
|
||||||
/// One step per timer tick; a step either waits (returns without
|
/// One step per timer tick; a step either waits (returns without
|
||||||
/// advancing) or checks and moves on. A failed check is recorded and the
|
/// advancing) or checks and moves on. A failed check is recorded and the
|
||||||
/// run goes on; an exception ends it. Result line:
|
/// run goes on; an exception ends it. Result line:
|
||||||
@@ -31,12 +39,18 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
private const string House = "rpg_elder_house";
|
private const string House = "rpg_elder_house";
|
||||||
private const string WinFlag = "rpg_test_win";
|
private const string WinFlag = "rpg_test_win";
|
||||||
private const string SaveSlot = "slot_9901";
|
private const string SaveSlot = "slot_9901";
|
||||||
|
private const string PickupId = "rpg_walk_mushroom";
|
||||||
|
private const string PickupFlag = "rpg_village_rpg_walk_mushroom_taken";
|
||||||
|
private const string Mushroom = "rpg_demo/mushroom";
|
||||||
|
|
||||||
|
private static readonly Vector2I PickupCell = new(18, 14);
|
||||||
|
|
||||||
private readonly string shotsDir;
|
private readonly string shotsDir;
|
||||||
private readonly List<string> failures = new();
|
private readonly List<string> failures = new();
|
||||||
private int passed;
|
private int passed;
|
||||||
private int step;
|
private int step;
|
||||||
private int waitTicks;
|
private int waitTicks;
|
||||||
|
private int mushroomsBefore;
|
||||||
private double elapsed;
|
private double elapsed;
|
||||||
|
|
||||||
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||||
@@ -51,6 +65,10 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
|
|
||||||
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
||||||
|
|
||||||
|
private static IInventory Inventory => GameData.Instance.Runtime.Inventory;
|
||||||
|
|
||||||
|
private static string Flag(string name) => GameData.Instance.Runtime.Flags.TryGetValue(name, out var value) ? value : null;
|
||||||
|
|
||||||
private void Tick()
|
private void Tick()
|
||||||
{
|
{
|
||||||
if (step < 0)
|
if (step < 0)
|
||||||
@@ -142,17 +160,63 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
case 9:
|
case 9:
|
||||||
if (AdvanceDialog())
|
if (AdvanceDialog())
|
||||||
return;
|
return;
|
||||||
|
// A pickup this suite adds to the demo's village (map_events of another mod).
|
||||||
|
mushroomsBefore = Inventory.CheckCount(Mushroom);
|
||||||
|
Check(View.FindEvent(PickupId)?.Visible == true, "a test mod's map_events file adds a pickup to the demo's village");
|
||||||
|
View.PlacePlayer(PickupCell.X - 1, PickupCell.Y, RpgDirection.Right);
|
||||||
|
Press(Key.D, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 10:
|
||||||
|
if (View.Player.Cell.X < PickupCell.X)
|
||||||
|
return;
|
||||||
|
Press(Key.D, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 11:
|
||||||
|
// Stepping on the pickup starts its dialog; the flag is set when it ends.
|
||||||
|
if (AdvanceDialog() || Flag(PickupFlag) != "1")
|
||||||
|
return;
|
||||||
|
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"stepping on the pickup adds the item, {Inventory.CheckCount(Mushroom)} now");
|
||||||
|
Check(View.FindEvent(PickupId)?.Visible == false, "the picked item leaves the map");
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = House, X = 6, Y = 8, Dir = RpgDirection.Up });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 12:
|
||||||
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 13:
|
||||||
|
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
CheckPickupGone("after leaving the map and coming back");
|
||||||
|
Press(Key.D, true);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 14:
|
||||||
|
if (View.Player.Cell.X < PickupCell.X)
|
||||||
|
return;
|
||||||
|
Press(Key.D, false);
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 15:
|
||||||
|
if (View.Player.IsMoving || ++waitTicks < 5)
|
||||||
|
return;
|
||||||
|
Check(RpgInput.FindDialogBox()?.IsRunning != true && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1,
|
||||||
|
"stepping on the empty cell again gives nothing");
|
||||||
View.PlacePlayer(6, 7, RpgDirection.Up);
|
View.PlacePlayer(6, 7, RpgDirection.Up);
|
||||||
Press(Key.W, true);
|
Press(Key.W, true);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 10:
|
case 16:
|
||||||
if (!View.Player.IsMoving && View.Map.ID == Village)
|
if (!View.Player.IsMoving && View.Map.ID == Village)
|
||||||
return;
|
return;
|
||||||
Press(Key.W, false);
|
Press(Key.W, false);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 11:
|
case 17:
|
||||||
if (View?.Map?.ID != House)
|
if (View?.Map?.ID != House)
|
||||||
return;
|
return;
|
||||||
if (AdvanceDialog())
|
if (AdvanceDialog())
|
||||||
@@ -163,14 +227,14 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
|
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 12:
|
case 18:
|
||||||
Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once");
|
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");
|
ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
|
||||||
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
|
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
|
||||||
Godot.Engine.TimeScale = 4;
|
Godot.Engine.TimeScale = 4;
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 13:
|
case 19:
|
||||||
if (GetTree().CurrentScene is not BattleSceneContext battle)
|
if (GetTree().CurrentScene is not BattleSceneContext battle)
|
||||||
return;
|
return;
|
||||||
// The core's auto turn only passes, so the hero would lose: once the
|
// The core's auto turn only passes, so the hero would lose: once the
|
||||||
@@ -184,14 +248,14 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
battle.ShowWinground();
|
battle.ShowWinground();
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 14:
|
case 20:
|
||||||
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
|
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
|
||||||
return;
|
return;
|
||||||
Shot("victory");
|
Shot("victory");
|
||||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 15:
|
case 21:
|
||||||
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
|
||||||
return;
|
return;
|
||||||
Godot.Engine.TimeScale = 1;
|
Godot.Engine.TimeScale = 1;
|
||||||
@@ -202,19 +266,27 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
GameSession.OpenMainMenu(GetTree());
|
GameSession.OpenMainMenu(GetTree());
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 16:
|
case 22:
|
||||||
if (RpgWorldHost.Current != null || ++waitTicks < 5)
|
if (RpgWorldHost.Current != null || ++waitTicks < 5)
|
||||||
return;
|
return;
|
||||||
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
|
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
|
||||||
Next();
|
Next();
|
||||||
return;
|
return;
|
||||||
case 17:
|
case 23:
|
||||||
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
|
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
|
||||||
return;
|
return;
|
||||||
Shot("loaded");
|
Shot("loaded");
|
||||||
var dialog = RpgInput.FindDialogBox();
|
var dialog = RpgInput.FindDialogBox();
|
||||||
Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})");
|
Check(dialog != null && !dialog.IsRunning && !dialog.IsVisibleInTree(), $"no dialog box after loading (running={dialog?.IsRunning}, visible={dialog?.IsVisibleInTree()})");
|
||||||
Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}");
|
Check(View.Player.Cell == new Vector2I(6, 8), $"the loaded game puts the player on the saved cell, got {View.Player.Cell}");
|
||||||
|
Check(Flag(PickupFlag) == "1" && Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, "the save keeps the pickup's flag and the picked item");
|
||||||
|
RpgWorldHost.Teleport(new RpgTeleport { Map = Village, X = PickupCell.X - 1, Y = PickupCell.Y, Dir = RpgDirection.Right });
|
||||||
|
Next();
|
||||||
|
return;
|
||||||
|
case 24:
|
||||||
|
if (View?.Map?.ID != Village || GodotSceneService.IsTransitioning || AdvanceDialog())
|
||||||
|
return;
|
||||||
|
CheckPickupGone("after loading the save");
|
||||||
SaveService.DeleteSave(SaveSlot);
|
SaveService.DeleteSave(SaveSlot);
|
||||||
Finish();
|
Finish();
|
||||||
return;
|
return;
|
||||||
@@ -237,6 +309,15 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The map was built again (a teleport or a load): the picked item must stay gone and give nothing.</summary>
|
||||||
|
private void CheckPickupGone(string when)
|
||||||
|
{
|
||||||
|
var pickup = View.FindEvent(PickupId);
|
||||||
|
Check(pickup != null && !pickup.Visible, $"the picked item does not come back {when}");
|
||||||
|
Check(View.EventAt(PickupCell, RpgEventTrigger.Touch) == null, $"its cell has no pickup to step on {when}");
|
||||||
|
Check(Inventory.CheckCount(Mushroom) == mushroomsBefore + 1, $"the inventory still holds exactly the one picked item {when}");
|
||||||
|
}
|
||||||
|
|
||||||
/// <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>
|
||||||
private bool AdvanceDialog()
|
private bool AdvanceDialog()
|
||||||
{
|
{
|
||||||
|
|||||||
+52
-3
@@ -57,7 +57,6 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
{
|
{
|
||||||
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
|
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
|
||||||
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
|
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
|
||||||
""Events"":[{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}}],
|
|
||||||
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
|
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
|
||||||
|
|
||||||
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
@@ -65,12 +64,62 @@ namespace Cthangover.Mods.Rpg.Tests
|
|||||||
|
|
||||||
ModAssert.Equal("m", again.ID, "id");
|
ModAssert.Equal("m", again.ID, "id");
|
||||||
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
|
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
|
||||||
|
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
|
||||||
|
ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group");
|
||||||
|
ModAssert.Equal(2, again.Zoom, "zoom default");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("state/event_file_round_trip")]
|
||||||
|
static void EventFileRoundTrip(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var json = @"{""Events"":[
|
||||||
|
{""Id"":""door"",""X"":0,""Y"":0,""Trigger"":""touch"",""Teleport"":{""Map"":""x"",""X"":3,""Y"":4,""Dir"":""Up""}},
|
||||||
|
{""Id"":""herb"",""X"":2,""Y"":1,""Icon"":""rpg_demo/forest_mushroom"",""Commands"":[""end""],""Condition"":""herb != 1""}]}";
|
||||||
|
|
||||||
|
var file = JsonSerializer.Deserialize<RpgMapEventFile>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
file.Events[0].ModId = "some_mod";
|
||||||
|
var text = JsonSerializer.Serialize(file);
|
||||||
|
var again = JsonSerializer.Deserialize<RpgMapEventFile>(text, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
|
||||||
|
|
||||||
|
ModAssert.Equal(2, again.Events.Count, "both events");
|
||||||
ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
|
ModAssert.Equal("Touch", again.Events[0].Trigger.ToString(), "trigger");
|
||||||
ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
|
ModAssert.Equal("x", again.Events[0].Teleport.Map, "teleport map");
|
||||||
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
|
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
|
||||||
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
|
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
|
||||||
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
|
ModAssert.Equal("rpg_demo/forest_mushroom", again.Events[1].Icon, "icon");
|
||||||
ModAssert.Equal(2, again.Zoom, "zoom default");
|
ModAssert.True(again.Events[1].IsSolid, "an icon is drawn, so it blocks unless told otherwise");
|
||||||
|
ModAssert.Equal("herb != 1", again.Events[1].Condition, "condition");
|
||||||
|
ModAssert.Equal("end", again.Events[1].Commands[0], "commands");
|
||||||
|
ModAssert.False(text.Contains("some_mod"), "where an event came from is not written");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("state/event_merge_adds_and_replaces")]
|
||||||
|
static void EventMergeAddsAndReplaces(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var merge = new RpgMapEventMerge();
|
||||||
|
merge.Add("village", "base_game", "map_events/village/events.json", new[]
|
||||||
|
{
|
||||||
|
new RpgMapEvent { Id = "elder", X = 1, Y = 1 },
|
||||||
|
new RpgMapEvent { Id = "sign", X = 2, Y = 1 },
|
||||||
|
new RpgMapEvent { Id = " " }
|
||||||
|
});
|
||||||
|
var replaced = merge.Add("VILLAGE", "quest_mod", "map_events/village/quest.json", new[]
|
||||||
|
{
|
||||||
|
new RpgMapEvent { Id = "Elder", X = 5, Y = 5 },
|
||||||
|
new RpgMapEvent { Id = "quest_giver", X = 3, Y = 3 }
|
||||||
|
});
|
||||||
|
merge.Add("forest", "quest_mod", "map_events/forest/a.json", null);
|
||||||
|
|
||||||
|
var village = merge.Maps["village"];
|
||||||
|
ModAssert.Equal(3, village.Count, "an event without an id is skipped, a new id is added");
|
||||||
|
ModAssert.Equal(1, replaced.Count, "one replacement reported");
|
||||||
|
ModAssert.Equal("Elder", replaced[0], "the replacing id");
|
||||||
|
ModAssert.Equal(5, village[0].X, "the later mod's event replaces in place (any case)");
|
||||||
|
ModAssert.Equal("quest_mod", village[0].ModId, "the replacement remembers its mod");
|
||||||
|
ModAssert.Equal("sign", village[1].Id, "the others keep their order");
|
||||||
|
ModAssert.Equal("base_game", village[1].ModId, "and their mod");
|
||||||
|
ModAssert.Equal("map_events/village/quest.json", village[2].File, "an added event remembers its file");
|
||||||
|
ModAssert.Equal(0, merge.Maps["forest"].Count, "a file without events adds nothing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user