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/>
|
||||
/// 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/<id>/</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
@@ -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/<map>/*.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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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,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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user