RPG world mod: tile maps, grid walking, map events, battles

Maps of square tiles in three layers plus a collision layer, mounted over
the 2D shell when their scene is entered. WASD walking, E/F action events,
touch teleports, story-flag conditions, random encounters, rpg.battle and
rpg.teleport actions, position saving. Integration suite rpg_walk.
This commit is contained in:
cthangover committed 2026-10-21 13:22:18 +02:00
commit 5ca225a2d7
43 files changed
+2066

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Cell codes of the map's system collision layer. It is a per-cell override
/// of the tiles' own <see cref="RpgTileInfo.Solid"/> flag: most cells stay
/// <see cref="Auto"/>, and the author paints exceptions (an invisible wall at
/// the cliff edge, a walkable gap in a hedge).
/// </summary>
public static class RpgCollision
{
/// <summary>Passability comes from the tiles.</summary>
public const char Auto = '.';
/// <summary>Always blocked.</summary>
public const char Block = '#';
/// <summary>Always walkable, whatever the tiles say.</summary>
public const char Open = 'o';
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Facing of an actor on the grid. The order is the row order of an RPG Maker
/// style charset (down, left, right, up), so the value is also the frame row
/// of <see cref="RpgActor"/>. JSON spells it as a word ("down", "Left").
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RpgDirection
{
Down = 0,
Left = 1,
Right = 2,
Up = 3,
}
}
+26
View File
@@ -0,0 +1,26 @@
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>Grid arithmetic of <see cref="RpgDirection"/>: the cell step it makes and the facing that looks back.</summary>
public static class RpgDirections
{
/// <summary>Cell offset of one step; y grows downwards like the map rows.</summary>
public static Vector2I Delta(RpgDirection direction) => direction switch
{
RpgDirection.Left => new Vector2I(-1, 0),
RpgDirection.Right => new Vector2I(1, 0),
RpgDirection.Up => new Vector2I(0, -1),
_ => new Vector2I(0, 1),
};
/// <summary>The facing that looks at the one given: an NPC turns to the player with it.</summary>
public static RpgDirection Opposite(RpgDirection direction) => direction switch
{
RpgDirection.Left => RpgDirection.Right,
RpgDirection.Right => RpgDirection.Left,
RpgDirection.Up => RpgDirection.Down,
_ => RpgDirection.Up,
};
}
}
+27
View File
@@ -0,0 +1,27 @@
using System;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// The random-battle roll made after each step on a map with an
/// <see cref="RpgEncounterTable"/>. Takes the random source from outside so a
/// test can make it deterministic.
/// </summary>
public static class RpgEncounterRoller
{
/// <summary>
/// Returns the enemy group of the battle this step starts, or null for no
/// battle (no table, encounters off, no groups, or the roll missed).
/// </summary>
public static string Roll(RpgEncounterTable table, Random random)
{
if (table == null || table.Steps <= 0 || table.Groups == null || table.Groups.Count == 0 || random == null)
return null;
if (random.Next(table.Steps) != 0)
return null;
var group = table.Groups[random.Next(table.Groups.Count)];
return string.IsNullOrWhiteSpace(group) ? null : group;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Random battles of a map: after every step the player takes on it, a battle
/// starts with probability <c>1 / Steps</c> against one of the
/// <see cref="Groups"/>. Groups are enemy lists in the <c>battle.init</c>
/// format (<c>"slime,slime"</c>), so they name character templates.
/// </summary>
public class RpgEncounterTable
{
/// <summary>Average number of steps between battles; 0 or less turns encounters off.</summary>
public int Steps { get; set; }
/// <summary>Enemy groups, one of them picked uniformly.</summary>
public List<string> Groups { get; set; } = new();
}
}
+15
View File
@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Cthangover.Mods.Rpg
{
/// <summary>How the player sets off an <see cref="RpgMapEvent"/>.</summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum RpgEventTrigger
{
/// <summary>The action key (E or F) while facing the event, or standing on it.</summary>
Action = 0,
/// <summary>Stepping onto the event's cell (doors, map exits, traps).</summary>
Touch = 1,
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>Drawn layers of a map, bottom to top; actors are drawn between <see cref="Main"/> and <see cref="Front"/>.</summary>
public enum RpgLayer
{
Back = 0,
Main = 1,
Front = 2,
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One tile map of the RPG world. The map id is also the name of the logical
/// scene that shows it: entering scene <c>X</c> (any <c>switch_scene X</c>, a
/// teleport, the return from a battle, loading a save) mounts map <c>X</c>
/// over the shell. Because the scene stays an ordinary scene, everything the
/// novel engine attaches to scenes works on a map unchanged — entry
/// scenarios, ambience and music from <c>scenes/X.json</c>, the default
/// background (which also becomes the backdrop of battles started here),
/// saving rules.
/// <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.
/// </summary>
[EntityKind("rpg_map")]
public class RpgMap : IIdentifiable
{
/// <summary>Map id = logical scene name.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Locale key of the map's display name.</summary>
public string Name { get; set; }
/// <summary>Id of the <see cref="RpgTileset"/> the indices refer to.</summary>
public string Tileset { get; set; }
/// <summary>Columns.</summary>
public int Width { get; set; }
/// <summary>Rows.</summary>
public int Height { get; set; }
/// <summary>Screen pixels per tile pixel; pixel art is scaled by a whole number.</summary>
public int Zoom { get; set; } = 2;
/// <summary>The three drawn layers.</summary>
public RpgMapLayers Layers { get; set; } = new();
/// <summary>Rows of <see cref="RpgCollision"/> codes, one character per cell; may be empty.</summary>
public List<string> Collision { get; set; } = new();
/// <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; }
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Something placed on a map cell that reacts to the player, in the spirit of
/// 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>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>
/// </list>
/// 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.
/// </summary>
public class RpgMapEvent
{
/// <summary>Id unique within the map.</summary>
public string Id { get; set; }
/// <summary>Column.</summary>
public int X { get; set; }
/// <summary>Row.</summary>
public int Y { get; set; }
/// <summary>Charset image in the <c>charsets</c> group (3 frames × 4 facings).</summary>
public string Sprite { get; set; }
/// <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>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>
public bool? Solid { get; set; }
/// <summary>Scenario path inside the <c>scenarios</c> group (extension optional), played as a dialog.</summary>
public string Scenario { get; set; }
/// <summary>Inline DSL lines appended after the scenario.</summary>
public List<string> Commands { get; set; }
/// <summary>Moves the player instead of starting a dialog.</summary>
public RpgTeleport Teleport { get; set; }
/// <summary>Scenario condition; the event exists only while it is true.</summary>
public string Condition { get; set; }
/// <summary>Effective solidity: explicit value, else whether the event is drawn.</summary>
public bool IsSolid => Solid ?? (!string.IsNullOrEmpty(Sprite) || Tile >= 0);
}
}
+27
View File
@@ -0,0 +1,27 @@
using System;
using Cthangover.Core.Factories;
using Cthangover.Core.Mods.Resolvers;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Maps collected by the shared entity resolver (<c>"Kind": "rpg_map"</c>).
/// <see cref="Publish"/> lets the map editor put a just-saved map into the
/// resolver's cache, so the running world shows the edit without a mod
/// reload; the next reload reads the same content back from the file.
/// </summary>
public class RpgMapFactory : FileFactory<RpgMap>
{
private static readonly Lazy<RpgMapFactory> instance = new(() => new RpgMapFactory());
/// <summary>Shared instance.</summary>
public static RpgMapFactory Instance => instance.Value;
/// <summary>Replaces the cached definition of <paramref name="map"/>'s id.</summary>
public void Publish(RpgMap map)
{
if (map != null && !string.IsNullOrEmpty(map.ID))
Entities.Collect<RpgMap>()[map.ID] = map;
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Parsed cells of an <see cref="RpgMap"/>: the three tile layers as atlas
/// indices (-1 = empty) and the collision codes. The map file keeps rows as
/// text (<see cref="RpgMapLayers"/>); this is the form the world and the
/// editor work with, and <see cref="WriteTo"/> turns it back into rows.
/// <para/>
/// Parsing is forgiving, because maps are edited by hand: short rows and
/// missing rows are empty cells, extra cells are cut off, an unreadable token
/// is empty. The grid always has exactly the map's declared size.
/// </summary>
public class RpgMapGrid
{
private const string EmptyToken = ".";
private int[][] layers;
private char[] collision;
/// <summary>Columns.</summary>
public int Width { get; private set; }
/// <summary>Rows.</summary>
public int Height { get; private set; }
/// <summary>Creates an empty grid: no tiles, collision from tiles everywhere.</summary>
public RpgMapGrid(int width, int height)
{
Width = Math.Max(1, width);
Height = Math.Max(1, height);
layers = new int[3][];
for (var i = 0; i < layers.Length; i++)
layers[i] = Enumerable.Repeat(-1, Width * Height).ToArray();
collision = Enumerable.Repeat(RpgCollision.Auto, Width * Height).ToArray();
}
/// <summary>Reads the layers and the collision rows of <paramref name="map"/>.</summary>
public static RpgMapGrid Parse(RpgMap map)
{
var grid = new RpgMapGrid(map?.Width ?? 1, map?.Height ?? 1);
if (map == null)
return grid;
grid.ReadLayer(RpgLayer.Back, map.Layers?.Back);
grid.ReadLayer(RpgLayer.Main, map.Layers?.Main);
grid.ReadLayer(RpgLayer.Front, map.Layers?.Front);
var rows = map.Collision ?? new List<string>();
for (var y = 0; y < grid.Height && y < rows.Count; y++)
{
var row = rows[y] ?? string.Empty;
for (var x = 0; x < grid.Width && x < row.Length; x++)
grid.SetCollision(x, y, row[x]);
}
return grid;
}
/// <summary>True when the cell lies on the map.</summary>
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
/// <summary>Atlas index on a layer, -1 for empty or outside the map.</summary>
public int GetTile(RpgLayer layer, int x, int y) => InBounds(x, y) ? layers[(int)layer][y * Width + x] : -1;
/// <summary>Puts an atlas index (-1 clears); outside the map it does nothing.</summary>
public void SetTile(RpgLayer layer, int x, int y, int index)
{
if (InBounds(x, y))
layers[(int)layer][y * Width + x] = index < 0 ? -1 : index;
}
/// <summary>Collision code of a cell; outside the map it is <see cref="RpgCollision.Block"/>.</summary>
public char GetCollision(int x, int y) => InBounds(x, y) ? collision[y * Width + x] : RpgCollision.Block;
/// <summary>Sets a collision code; an unknown code becomes <see cref="RpgCollision.Auto"/>.</summary>
public void SetCollision(int x, int y, char code)
{
if (!InBounds(x, y))
return;
collision[y * Width + x] = code == RpgCollision.Block || code == RpgCollision.Open ? code : RpgCollision.Auto;
}
/// <summary>
/// Changes the size keeping the top-left content; new cells are empty.
/// Used by the editor; the map's events are not moved.
/// </summary>
public void Resize(int width, int height)
{
var resized = new RpgMapGrid(width, height);
for (var y = 0; y < Math.Min(Height, resized.Height); y++)
{
for (var x = 0; x < Math.Min(Width, resized.Width); x++)
{
foreach (RpgLayer layer in Enum.GetValues(typeof(RpgLayer)))
resized.SetTile(layer, x, y, GetTile(layer, x, y));
resized.SetCollision(x, y, GetCollision(x, y));
}
}
Width = resized.Width;
Height = resized.Height;
layers = resized.layers;
collision = resized.collision;
}
/// <summary>Copy of the grid, so editor undo steps never share cells.</summary>
public RpgMapGrid Clone()
{
var copy = new RpgMapGrid(Width, Height);
for (var i = 0; i < layers.Length; i++)
Array.Copy(layers[i], copy.layers[i], layers[i].Length);
Array.Copy(collision, copy.collision, collision.Length);
return copy;
}
/// <summary>
/// Writes size, layer rows and collision rows into <paramref name="map"/>.
/// Indices are right-aligned to one width per layer, so columns line up in
/// the file. An all-auto collision layer is written as no rows.
/// </summary>
public void WriteTo(RpgMap map)
{
map.Width = Width;
map.Height = Height;
map.Layers ??= new RpgMapLayers();
map.Layers.Back = WriteLayer(RpgLayer.Back);
map.Layers.Main = WriteLayer(RpgLayer.Main);
map.Layers.Front = WriteLayer(RpgLayer.Front);
var rows = new List<string>();
if (collision.Any(c => c != RpgCollision.Auto))
{
for (var y = 0; y < Height; y++)
rows.Add(new string(collision, y * Width, Width));
}
map.Collision = rows;
}
private void ReadLayer(RpgLayer layer, List<string> rows)
{
if (rows == null)
return;
for (var y = 0; y < Height && y < rows.Count; y++)
{
var tokens = (rows[y] ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries);
for (var x = 0; x < Width && x < tokens.Length; x++)
SetTile(layer, x, y, int.TryParse(tokens[x], out var index) ? index : -1);
}
}
private List<string> WriteLayer(RpgLayer layer)
{
var cells = layers[(int)layer];
var width = cells.Select(Token).Max(t => t.Length);
var rows = new List<string>();
var line = new StringBuilder();
for (var y = 0; y < Height; y++)
{
line.Clear();
for (var x = 0; x < Width; x++)
{
if (x > 0)
line.Append(' ');
line.Append(Token(cells[y * Width + x]).PadLeft(width));
}
rows.Add(line.ToString());
}
return rows;
}
private static string Token(int index) => index < 0 ? EmptyToken : index.ToString();
}
}
+26
View File
@@ -0,0 +1,26 @@
using System.Collections.Generic;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// The three drawn layers of a map, each a list of rows. A row is a string of
/// space-separated atlas indices, <c>.</c> for an empty cell, so a map file
/// stays readable and diffable by hand (see <see cref="RpgMapGrid"/>).
/// <list type="bullet">
/// <item><see cref="Back"/> — ground: grass, floors, water.</item>
/// <item><see cref="Main"/> — objects at the actors' level: walls, trunks, fences.</item>
/// <item><see cref="Front"/> — drawn over the actors: tree crowns, roofs' eaves, arches.</item>
/// </list>
/// </summary>
public class RpgMapLayers
{
/// <summary>Ground layer rows.</summary>
public List<string> Back { get; set; } = new();
/// <summary>Object layer rows.</summary>
public List<string> Main { get; set; } = new();
/// <summary>Overhead layer rows.</summary>
public List<string> Front { get; set; } = new();
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Whether the terrain of a cell can be walked on: the collision layer
/// decides when painted, otherwise a solid tile on the back or the main layer
/// blocks. Front-layer tiles never block — they are what the player walks
/// under. Events are not terrain: the world adds their cells on top.
/// </summary>
public static class RpgPassability
{
/// <summary>True when the cell is inside the map and its terrain lets the player in.</summary>
public static bool IsWalkable(RpgMapGrid grid, RpgTileset tileset, int x, int y)
{
if (grid == null || !grid.InBounds(x, y))
return false;
var code = grid.GetCollision(x, y);
if (code == RpgCollision.Block)
return false;
if (code == RpgCollision.Open)
return true;
if (tileset == null)
return true;
return !tileset.IsSolid(grid.GetTile(RpgLayer.Back, x, y))
&& !tileset.IsSolid(grid.GetTile(RpgLayer.Main, x, y));
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// A cell on some map and the facing the player arrives with. Used for the
/// map's spawn point and for teleport events (doors, map edges, stairs).
/// </summary>
public class RpgTeleport
{
/// <summary>Target map id; empty means the map the event is on.</summary>
public string Map { get; set; }
/// <summary>Target column.</summary>
public int X { get; set; }
/// <summary>Target row.</summary>
public int Y { get; set; }
/// <summary>Facing after arrival.</summary>
public RpgDirection Dir { get; set; } = RpgDirection.Down;
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Properties of one atlas cell of an <see cref="RpgTileset"/>. Only cells that
/// differ from the default (walkable, unnamed) need an entry.
/// </summary>
public class RpgTileInfo
{
/// <summary>Row-major atlas index: <c>row * Columns + column</c>.</summary>
public int Index { get; set; }
/// <summary>
/// The tile blocks walking when it lies on the back or the main layer
/// (front-layer tiles never block: the player walks under them). The map's
/// collision layer can override it per cell.
/// </summary>
public bool Solid { get; set; }
/// <summary>
/// Optional author-facing tag shown by the editor palette ("grass",
/// "tree_crown"). Not a locale key: players never see it.
/// </summary>
public string Tag { get; set; }
}
}
+54
View File
@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Cthangover.Core.Factories;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// A square-tile atlas: one image cut into <see cref="TileSize"/> cells,
/// <see cref="Columns"/> per row, addressed by a row-major index. Maps store
/// only these indices, so a pack can redraw its tiles without touching maps.
/// <para/>
/// Files declare <c>"Kind": "rpg_tileset"</c> and live anywhere in a mod; the
/// image is resolved in the <c>tilesets</c> file group (last mod wins), so a
/// dependent mod can reskin a tileset by shipping an image of the same path.
/// </summary>
[EntityKind("rpg_tileset")]
public class RpgTileset : IIdentifiable
{
private HashSet<int> solid;
/// <summary>Tileset id that maps reference.</summary>
[JsonPropertyName("Id")]
public string ID { get; set; }
/// <summary>Image path inside the <c>tilesets</c> group, extension optional.</summary>
public string Image { get; set; }
/// <summary>Edge of one square tile in image pixels.</summary>
public int TileSize { get; set; } = 32;
/// <summary>Atlas cells per row.</summary>
public int Columns { get; set; } = 8;
/// <summary>Per-cell properties; cells without an entry are walkable.</summary>
public List<RpgTileInfo> Tiles { get; set; } = new();
/// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary>
public bool IsSolid(int index)
{
if (index < 0)
return false;
if (solid == null)
{
solid = new HashSet<int>();
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
{
if (tile.Solid)
solid.Add(tile.Index);
}
}
return solid.Contains(index);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
using System;
using Cthangover.Core.Factories;
namespace Cthangover.Mods.Rpg
{
/// <summary>Tilesets collected by the shared entity resolver (<c>"Kind": "rpg_tileset"</c>).</summary>
public class RpgTilesetFactory : FileFactory<RpgTileset>
{
private static readonly Lazy<RpgTilesetFactory> instance = new(() => new RpgTilesetFactory());
/// <summary>Shared instance.</summary>
public static RpgTilesetFactory Instance => instance.Value;
}
}