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:
cthangover committed 2026-10-23 19:09:37 +02:00
1 parent 281b722667
commit c4d6adf8fb
13 files changed
+396 -20

No files matched your search

+4 -3
View File
@@ -16,7 +16,10 @@ namespace Cthangover.Mods.Rpg
/// <para/>
/// Files declare <c>"Kind": "rpg_map"</c>. Layers are parsed by
/// <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/&lt;id&gt;/</c> of any
/// mod, merged by <see cref="RpgMapEventResolver"/>, so a mod adds to another
/// mod's map without replacing it.
/// </summary>
[EntityKind("rpg_map")]
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>
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>
public RpgEncounterTable Encounters { get; set; }
+21 -3
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
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
/// what it does are independent:
/// <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
/// 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>
@@ -15,6 +16,9 @@ namespace Cthangover.Mods.Rpg
/// A <see cref="Condition"/> in the scenario condition language hides the
/// event while false; it is re-checked whenever game state changes, so a
/// defeated boss (<c>flag boss_dead 1</c>) disappears at once.
/// <para/>
/// Events are stored apart from their map, in <c>map_events/&lt;map&gt;/*.json</c>
/// of any mod (<see cref="RpgMapEventResolver"/>).
/// </summary>
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>
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>
public RpgDirection Dir { get; set; } = RpgDirection.Down;
/// <summary>When the event fires.</summary>
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; }
/// <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>
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>
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0);
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0 || !string.IsNullOrEmpty(Icon));
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One file of map events, <c>map_events/&lt;map id&gt;/&lt;any name&gt;.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();
}
}
+53
View File
@@ -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;
}
}
}
+93
View File
@@ -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/&lt;map id&gt;/**/*.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/&lt;id&gt;.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);
}
}
}
}
+19
View File
@@ -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();
}
}
+19
View File
@@ -79,6 +79,25 @@ namespace Cthangover.Mods.Rpg
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>
public void Place(Vector2I cell, RpgDirection facing)
{
+8 -1
View File
@@ -8,7 +8,8 @@ namespace Cthangover.Mods.Rpg
/// 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),
/// 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.
/// </summary>
public class RpgTextures
@@ -19,6 +20,9 @@ namespace Cthangover.Mods.Rpg
/// <summary>File group of charset images.</summary>
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();
/// <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>
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)
{
if (string.IsNullOrWhiteSpace(path))
+3 -1
View File
@@ -66,7 +66,7 @@ namespace Cthangover.Mods.Rpg
AddChild(actorLayer);
AddChild(BuildLayer(RpgLayer.Front, tileSet));
foreach (var mapEvent in map.Events ?? new List<RpgMapEvent>())
foreach (var mapEvent in RpgMapEvents.Of(map.ID))
AddEventActor(mapEvent);
RefreshEvents();
@@ -227,6 +227,8 @@ namespace Cthangover.Mods.Rpg
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;
+1 -1
View File
@@ -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. 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": [
"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"
}
]
}
+89 -8
View File
@@ -6,6 +6,7 @@ using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Games;
using Cthangover.Core.Scenes;
using Cthangover.Core.Settings;
using Cthangover.Core.Items;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.View;
using Godot;
@@ -17,6 +18,13 @@ namespace Cthangover.Mods.Rpg.Tests
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
/// it reads a keyboard (physical keys, held state, unhandled action key).
/// <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
/// advancing) or checks and moves on. A failed check is recorded and the
/// 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 WinFlag = "rpg_test_win";
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 List<string> failures = new();
private int passed;
private int step;
private int waitTicks;
private int mushroomsBefore;
private double elapsed;
/// <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 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()
{
if (step < 0)
@@ -142,17 +160,63 @@ namespace Cthangover.Mods.Rpg.Tests
case 9:
if (AdvanceDialog())
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);
Press(Key.W, true);
Next();
return;
case 10:
case 16:
if (!View.Player.IsMoving && View.Map.ID == Village)
return;
Press(Key.W, false);
Next();
return;
case 11:
case 17:
if (View?.Map?.ID != House)
return;
if (AdvanceDialog())
@@ -163,14 +227,14 @@ namespace Cthangover.Mods.Rpg.Tests
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
Next();
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");
ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
Godot.Engine.TimeScale = 4;
Next();
return;
case 13:
case 19:
if (GetTree().CurrentScene is not BattleSceneContext battle)
return;
// 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();
Next();
return;
case 14:
case 20:
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
return;
Shot("victory");
button.EmitSignal(BaseButton.SignalName.Pressed);
Next();
return;
case 15:
case 21:
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
return;
Godot.Engine.TimeScale = 1;
@@ -202,19 +266,27 @@ namespace Cthangover.Mods.Rpg.Tests
GameSession.OpenMainMenu(GetTree());
Next();
return;
case 16:
case 22:
if (RpgWorldHost.Current != null || ++waitTicks < 5)
return;
Check(GameSession.LoadAndEnter(this, SaveSlot), "the save loads");
Next();
return;
case 17:
case 23:
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning || ++waitTicks < 5)
return;
Shot("loaded");
var dialog = RpgInput.FindDialogBox();
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(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);
Finish();
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>
private bool AdvanceDialog()
{
+52 -3
View File
@@ -57,7 +57,6 @@ namespace Cthangover.Mods.Rpg.Tests
{
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
""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""]}}";
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("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("x", again.Events[0].Teleport.Map, "teleport map");
ModAssert.Equal(4, again.Events[0].Teleport.Y, "teleport y");
ModAssert.False(again.Events[0].IsSolid, "an invisible event does not block");
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
ModAssert.Equal(2, again.Zoom, "zoom default");
ModAssert.Equal("rpg_demo/forest_mushroom", again.Events[1].Icon, "icon");
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");
}
}
}