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();
}
}