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

+28
View File
@@ -0,0 +1,28 @@
using Cthangover.Core.Actions;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// <c>action rpg.battle enemies=slime,slime [win_flag=slime_dead]</c> — a
/// battle that returns to the map the player stands on. Unlike
/// <c>battle.init</c> + <c>switch_scene Battle</c> it needs no return scene
/// and can mark the victory with a story flag. End the scenario after it.
/// </summary>
public class RpgBattleAction : IScenarioAction
{
/// <summary>DSL name.</summary>
public string Name => "rpg.battle";
/// <summary>Reads <c>enemies</c> and <c>win_flag</c> and starts the battle.</summary>
public void Run(IActionContext ctx)
{
var enemies = ctx.GetParam("enemies");
if (string.IsNullOrWhiteSpace(enemies))
{
ctx.LogWarning("RPG", "rpg.battle: missing 'enemies'");
return;
}
RpgBattleService.Start(enemies, ctx.GetParam("win_flag"));
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System;
using Cthangover.Core.Actions;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// <c>action rpg.teleport [map=forest] x=3 y=7 [dir=up]</c> — moves the player
/// to a cell, on this map or another one (then the scene switches). The same
/// effect a teleport event has, for scenarios: a cutscene that ends with the
/// hero waking up at home, a ferryman who takes you across.
/// </summary>
public class RpgTeleportAction : IScenarioAction
{
/// <summary>DSL name.</summary>
public string Name => "rpg.teleport";
/// <summary>Parses the target and hands it to the world.</summary>
public void Run(IActionContext ctx)
{
if (!int.TryParse(ctx.GetParam("x"), out var x) || !int.TryParse(ctx.GetParam("y"), out var y))
{
ctx.LogWarning("RPG", "rpg.teleport: 'x' and 'y' must be numbers");
return;
}
var dir = Enum.TryParse<RpgDirection>(ctx.GetParam("dir"), true, out var parsed) ? parsed : RpgDirection.Down;
RpgWorldHost.Teleport(new RpgTeleport { Map = ctx.GetParam("map"), X = x, Y = y, Dir = dir });
}
}
}
+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;
}
}
+65
View File
@@ -0,0 +1,65 @@
using Cthangover.Core.Battle;
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Starts a battle of the active battle core from the map and brings the
/// player back to the same map cell afterwards. It is a thin layer over the
/// platform (<see cref="BattleService.Init"/> + the <c>Battle</c> route):
/// <list type="bullet">
/// <item>the return scene is the current map's scene, and the position is
/// already in <see cref="RpgState"/>, so the world restores it;</item>
/// <item>the backdrop is the map scene's default background (captured by
/// the platform from the scene), so a map chooses its battle background in
/// <c>scenes/&lt;map&gt;.json</c>;</item>
/// <item>an optional win flag becomes the story flag "1" on victory — how a
/// map monster disappears after it is beaten (its event's condition).</item>
/// </list>
/// </summary>
public static class RpgBattleService
{
private const string BattleRoute = "Battle";
private const string VictoryResult = "victory";
/// <summary>
/// Requests the encounter and switches to the battle route on the next
/// frame (deferred like <c>switch_scene</c>, so a running dialog finishes
/// its current command first). Returns false when there is no scene to come back to.
/// </summary>
public static bool Start(string enemies, string winFlag)
{
var sceneManager = FindSceneManager();
var returnScene = sceneManager?.CurrentSceneName;
if (string.IsNullOrEmpty(returnScene) || string.IsNullOrWhiteSpace(enemies))
{
GameLogger.Log("RPG", $"battle not started: scene='{returnScene}', enemies='{enemies}'", LogLevel.Error);
return false;
}
RpgState.Instance.PendingWinFlag = string.IsNullOrWhiteSpace(winFlag) ? null : winFlag.Trim();
BattleService.Init(returnScene, enemies.Replace(" ", string.Empty));
GameLogger.Log("RPG", $"battle '{enemies}' from '{returnScene}' (win flag: {RpgState.Instance.PendingWinFlag ?? "none"})");
Callable.From(() => sceneManager.SwitchScene(BattleRoute)).CallDeferred();
return true;
}
/// <summary>Applies the pending win flag of the battle that just ended.</summary>
public static void OnBattleEnded(BattleEndedEvent evt)
{
var flag = RpgState.Instance.PendingWinFlag;
RpgState.Instance.PendingWinFlag = null;
if (flag != null && evt?.Result == VictoryResult)
ScenarioCondition.SetFlag(flag, "1");
}
private static SceneManager FindSceneManager()
{
var tree = Engine.GetMainLoop() as SceneTree;
return tree?.Root?.GetNodeOrNull<SceneManager>("SceneManager");
}
}
}
+33
View File
@@ -0,0 +1,33 @@
using Cthangover.Core.Bootstrap;
using Cthangover.Core.Events;
using Cthangover.Core.Mods;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Wires the rpg mod into the session: the new-game reset of the position
/// and the battle-end hook for win flags. The world itself is not created
/// here — <see cref="RpgWorldHost"/> sits in the shell scene (scene patch)
/// and mounts a map whenever a map scene is entered. Save data goes through
/// <see cref="RpgSaveable"/>, found by the kernel automatically.
/// </summary>
public class RpgModInitializer : IModInitializer
{
private static bool subscribed;
/// <summary>Nothing per mod.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Registers the reset (deduplicated by id) and subscribes once.</summary>
public void OnModResourcesReady()
{
AppServices.Current.RegisterResettable(new RpgStateReset());
if (subscribed)
return;
subscribed = true;
ModEventBus.Subscribe<BattleEndedEvent>(RpgBattleService.OnBattleEnded);
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using Cthangover.Core.Mods;
using Cthangover.Core.Settings;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Saves the player's map position in the rpg mod's slot. The scene itself is
/// saved by the core (the map is the current scene); loading enters that
/// scene and the world reads the position back from <see cref="RpgState"/>.
/// </summary>
public class RpgSaveable : IModSaveable
{
private const string BlobKey = "Position";
/// <summary>Writes the position.</summary>
public void OnSave(ModSlot slot)
{
slot.Data[BlobKey] = new DataBlob(1, RpgState.Instance.Data);
}
/// <summary>Restores the position; a slot without it leaves the player at the spawn.</summary>
public bool OnLoad(ModSlot slot, out string errorMessage)
{
errorMessage = null;
RpgState.Instance.Reset();
if (slot?.Data != null && slot.Data.TryGetValue(BlobKey, out var blob))
RpgState.Instance.Data = blob.ReadJson<RpgStateData>() ?? new RpgStateData();
return true;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Session state of the RPG world, outliving the map node: the shell (and
/// with it the world) is rebuilt after every battle, so the position must
/// live here.
/// <para/>
/// The position doubles as the arrival point: a teleport writes the target
/// map and cell, then switches the scene; a map that is entered while
/// <see cref="Data"/> names it starts the player there, any other entry uses
/// the map's spawn. The same rule restores the player after a battle and
/// after loading a save.
/// <para/>
/// <see cref="PendingWinFlag"/> is transient: the flag a running battle sets on
/// victory. It is not saved, because battles cannot be saved mid-way.
/// </summary>
public class RpgState
{
/// <summary>The session's state.</summary>
public static RpgState Instance { get; } = new();
/// <summary>Saved position.</summary>
public RpgStateData Data { get; set; } = new();
/// <summary>Story flag set to "1" when the running battle is won; null when none.</summary>
public string PendingWinFlag { get; set; }
/// <summary>Stores a position (and the map it is on).</summary>
public void Place(string map, int x, int y, RpgDirection dir)
{
Data = new RpgStateData { Map = map, X = x, Y = y, Dir = dir };
}
/// <summary>Back to "nowhere": the next map entry uses the spawn.</summary>
public void Reset()
{
Data = new RpgStateData();
PendingWinFlag = null;
}
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Saved part of the RPG world state: where the player stands. Written into
/// the mod's save slot as JSON, so every member is a plain public property.
/// </summary>
public class RpgStateData
{
/// <summary>Map the position belongs to; empty before the first map.</summary>
public string Map { get; set; }
/// <summary>Column.</summary>
public int X { get; set; }
/// <summary>Row.</summary>
public int Y { get; set; }
/// <summary>Facing.</summary>
public RpgDirection Dir { get; set; } = RpgDirection.Down;
}
}
+14
View File
@@ -0,0 +1,14 @@
using Cthangover.Core.Bootstrap;
namespace Cthangover.Mods.Rpg
{
/// <summary>New game: the player starts at the spawn of the entry map.</summary>
public class RpgStateReset : IResettable
{
/// <summary>Deduplication key in <see cref="AppServices"/>.</summary>
public string Id => "rpg";
/// <summary>Forgets the position.</summary>
public void ResetForNewGame() => RpgState.Instance.Reset();
}
}
+133
View File
@@ -0,0 +1,133 @@
using System;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Something standing on a map cell: the player, an NPC, a chest. Drawn
/// either from a charset — 3 frames by 4 facings, rows in
/// <see cref="RpgDirection"/> order, the middle frame is the standing pose — or
/// from one atlas tile. The frame is bottom-aligned and centred on the cell,
/// so taller charsets overlap the cell above like in RPG Maker.
/// <para/>
/// Movement is grid based: <see cref="StepTo"/> starts a step to the next
/// cell, the node slides there at <see cref="TilesPerSecond"/> and then raises
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the
/// start of a step, so two actors never walk into the same cell. The actor
/// layer sorts children by Y, which keeps whoever is lower in front.
/// </summary>
public partial class RpgActor : Node2D
{
/// <summary>Walking speed.</summary>
public const float TilesPerSecond = 4.5f;
private static readonly int[] WalkCycle = { 0, 1, 2, 1 };
private readonly int tileSize;
private Sprite2D sprite;
private Vector2 from;
private Vector2 to;
private float progress = 1f;
private float animationTime;
/// <summary>Raised when a step reaches its cell.</summary>
public event Action<RpgActor> StepFinished;
/// <summary>Cell the actor stands on (or walks into).</summary>
public Vector2I Cell { get; private set; }
/// <summary>Facing.</summary>
public RpgDirection Facing { get; private set; }
/// <summary>True while a step is in progress.</summary>
public bool IsMoving => progress < 1f;
/// <summary>The map event this actor shows; null for the player.</summary>
public RpgMapEvent Event { get; }
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
{
this.tileSize = tileSize;
Event = mapEvent;
}
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
public void UseCharset(Texture2D texture)
{
if (texture == null)
return;
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false };
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
AddChild(sprite);
UpdateFrame();
}
/// <summary>Shows one atlas cell of the map's tileset.</summary>
public void UseTile(Texture2D atlas, Vector2I atlasCoords)
{
if (atlas == null)
return;
sprite = new Sprite2D
{
Texture = atlas,
Centered = false,
RegionEnabled = true,
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
};
AddChild(sprite);
}
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
public void Place(Vector2I cell, RpgDirection facing)
{
Cell = cell;
Facing = facing;
progress = 1f;
Position = CellPosition(cell);
UpdateFrame();
}
/// <summary>Turns without moving.</summary>
public void Face(RpgDirection facing)
{
Facing = facing;
UpdateFrame();
}
/// <summary>Starts a step to the neighbour cell; the caller has checked it is free.</summary>
public void StepTo(RpgDirection direction)
{
Facing = direction;
from = CellPosition(Cell);
Cell += RpgDirections.Delta(direction);
to = CellPosition(Cell);
progress = 0f;
}
/// <summary>Advances the step and the walk animation.</summary>
public override void _Process(double delta)
{
if (!IsMoving)
return;
progress = Mathf.Min(1f, progress + (float)delta * TilesPerSecond);
animationTime += (float)delta * TilesPerSecond * 2f;
Position = from.Lerp(to, progress);
UpdateFrame();
if (!IsMoving)
StepFinished?.Invoke(this);
}
private Vector2 CellPosition(Vector2I cell) => new(cell.X * tileSize, cell.Y * tileSize);
private void UpdateFrame()
{
if (sprite == null || sprite.Hframes != 3)
return;
var column = IsMoving ? WalkCycle[(int)animationTime % WalkCycle.Length] : 1;
sprite.Frame = (int)Facing * 3 + column;
}
}
}
+61
View File
@@ -0,0 +1,61 @@
using System.Text;
using Cthangover.Core.Utils;
using Cthangover.Mods.Dialog.Scenarios;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Fires a map event. A teleport moves the player natively (no dialog, so a
/// door costs nothing). Anything else becomes one dialog in the shell's
/// dialog box: the event's scenario body followed by its inline commands —
/// the same composition the interactive objects use for click dialogs, so
/// every DSL command, action and condition is available to map events.
/// </summary>
public static class RpgEventRunner
{
/// <summary>Runs the event; returns false when it has nothing to run or the dialog box is busy.</summary>
public static bool Run(RpgMapEvent mapEvent, string currentMap)
{
if (mapEvent == null)
return false;
if (mapEvent.Teleport != null)
{
var target = mapEvent.Teleport;
RpgWorldHost.Teleport(new RpgTeleport
{
Map = string.IsNullOrEmpty(target.Map) ? currentMap : target.Map,
X = target.X,
Y = target.Y,
Dir = target.Dir,
});
return true;
}
var text = new StringBuilder();
if (!string.IsNullOrWhiteSpace(mapEvent.Scenario))
{
var body = RpgScenarioSource.ReadBody(mapEvent.Scenario);
if (body == null)
GameLogger.Log("RPG", $"event '{mapEvent.Id}' on '{currentMap}': scenario '{mapEvent.Scenario}' not found", LogLevel.Error);
else
text.AppendLine(body);
}
foreach (var line in mapEvent.Commands ?? new())
text.AppendLine(line);
if (text.Length == 0)
return false;
var dialogBox = RpgInput.FindDialogBox();
if (dialogBox == null || dialogBox.IsRunning)
return false;
var dialog = ScenarioParser.Parse(text.ToString());
if (dialog?.Queue == null || dialog.Queue.Count == 0)
return false;
GameLogger.Log("RPG", $"event '{mapEvent.Id}' on '{currentMap}'");
return dialogBox.SetDialogQueueAndRun(dialog, null, 0, null);
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using Cthangover.Core.Scenes;
using Cthangover.Core.UI;
using Cthangover.Mods.Dialog;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Keyboard of the RPG world. Keys are read by their <b>physical</b> position
/// (<see cref="Input.IsPhysicalKeyPressed"/>), so WASD and E/F stay where
/// they are on a Russian or any other layout; the arrows work too.
/// <para/>
/// The world must not walk while something else owns the keyboard or the
/// player's attention: a dialog, a toolbar panel (inventory, journal), a
/// focused editor window or text field, a paused tree, a scene transition.
/// <see cref="IsWalkBlocked"/> collects those checks in one place.
/// </summary>
public static class RpgInput
{
/// <summary>The held direction, or null. Vertical keys win over horizontal ones when both are held.</summary>
public static RpgDirection? HeldDirection()
{
if (Input.IsPhysicalKeyPressed(Key.W) || Input.IsPhysicalKeyPressed(Key.Up))
return RpgDirection.Up;
if (Input.IsPhysicalKeyPressed(Key.S) || Input.IsPhysicalKeyPressed(Key.Down))
return RpgDirection.Down;
if (Input.IsPhysicalKeyPressed(Key.A) || Input.IsPhysicalKeyPressed(Key.Left))
return RpgDirection.Left;
if (Input.IsPhysicalKeyPressed(Key.D) || Input.IsPhysicalKeyPressed(Key.Right))
return RpgDirection.Right;
return null;
}
/// <summary>True for a fresh press of the action key (E or F).</summary>
public static bool IsActionPress(InputEvent @event)
{
return @event is InputEventKey key && key.Pressed && !key.Echo
&& (key.PhysicalKeycode == Key.E || key.PhysicalKeycode == Key.F);
}
/// <summary>The shell's dialog box, or null outside the shell.</summary>
public static DialogBox FindDialogBox() => SceneContextNode.FindNode<DialogBox>("DialogBox");
/// <summary>True when the player must not walk or interact right now.</summary>
public static bool IsWalkBlocked(Node node)
{
if (node == null || !node.IsInsideTree() || node.GetTree().Paused || GodotSceneService.IsTransitioning)
return true;
if (FindDialogBox()?.IsRunning == true)
return true;
var viewport = node.GetViewport();
var focus = viewport.GuiGetFocusOwner();
if (focus is LineEdit || focus is TextEdit)
return true;
foreach (var window in viewport.GetEmbeddedSubwindows())
{
if (window.Visible && window.HasFocus())
return true;
}
return IsPanelOpen();
}
private static bool IsPanelOpen()
{
var host = UiHost.Instance;
if (host == null)
return false;
foreach (var entry in UiRegistry.PanelEntries)
{
if (host.TryGetInstance(entry.Id, out var panel) && panel.IsVisibleInTree())
return true;
}
return false;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
using Cthangover.Core.Games;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// The player's map sprite is a choice of the game, not of a map: it comes
/// from the collection setting <c>rpg.player_charset</c> (a path in the
/// <c>charsets</c> group), so two games can share the maps of a pack with
/// different heroes.
/// </summary>
public static class RpgPlayerLook
{
/// <summary>Collection setting key.</summary>
public const string SettingKey = "rpg.player_charset";
private const string Fallback = "hero";
/// <summary>The configured charset path, or <c>hero</c>.</summary>
public static string Charset()
{
var value = ActiveGame.GetSetting(SettingKey);
return string.IsNullOrWhiteSpace(value) ? Fallback : value;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Scenarios;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Reads the scenario a map event points to. Event scenarios are not scene
/// scenarios: they have no header (or one that is ignored) and play only when
/// the event fires. The path is relative to the <c>scenarios</c> file group
/// with an optional extension, and the usual file override applies (a
/// dependent mod's file of the same path wins), so the map does not need to
/// know which mod ships the text.
/// </summary>
public static class RpgScenarioSource
{
private const string Group = "scenarios";
private const string Extension = ".scenario";
/// <summary>Scenario body without its header, or null when no mod has the file.</summary>
public static string ReadBody(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
var wanted = path.Replace('\\', '/').Trim('/');
if (wanted.StartsWith(Group + "/", StringComparison.OrdinalIgnoreCase))
wanted = wanted.Substring(Group.Length + 1);
if (!wanted.EndsWith(Extension, StringComparison.OrdinalIgnoreCase))
wanted += Extension;
foreach (var pair in Files.Collect(Group))
{
if (!string.Equals(pair.Key, wanted, StringComparison.OrdinalIgnoreCase))
continue;
var text = ModManager.Instance.ReadFileText(pair.Value.ModId, pair.Value.FullPath);
return text == null ? null : ScenarioMetadata.ParseMetadata(text).remainingText;
}
return null;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// 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
/// and owns an instance; the editor keeps its own.
/// </summary>
public class RpgTextures
{
/// <summary>File group of tileset images.</summary>
public const string TilesetGroup = "tilesets";
/// <summary>File group of charset images.</summary>
public const string CharsetGroup = "charsets";
private readonly Dictionary<string, Texture2D> cache = new();
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
public Texture2D Tileset(string path) => Load(TilesetGroup, path);
/// <summary>Charset image, or null.</summary>
public Texture2D Charset(string path) => Load(CharsetGroup, path);
private Texture2D Load(string group, string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
var key = group + "/" + path;
if (!cache.TryGetValue(key, out var texture))
{
texture = TextureUtils.LoadFromModGroup(group, path);
cache[key] = texture;
}
return texture;
}
}
}
+51
View File
@@ -0,0 +1,51 @@
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Turns an <see cref="RpgTileset"/> and its atlas image into a Godot
/// <see cref="TileSet"/> with a single atlas source (id 0), so maps are drawn
/// by <see cref="TileMapLayer"/> — batched, culled, pixel-exact. Atlas index
/// <c>i</c> is atlas coordinate <c>(i % Columns, i / Columns)</c>; the world
/// and the editor both convert through <see cref="AtlasCoords"/>.
/// </summary>
public static class RpgTileSetBuilder
{
/// <summary>The only atlas source id of a built tile set.</summary>
public const int SourceId = 0;
/// <summary>Builds the tile set; every whole cell of the image becomes a tile.</summary>
public static TileSet Build(RpgTileset tileset, Texture2D texture)
{
var size = new Vector2I(tileset.TileSize, tileset.TileSize);
var tileSet = new TileSet { TileSize = size };
var source = new TileSetAtlasSource { Texture = texture, TextureRegionSize = size };
var columns = texture.GetWidth() / tileset.TileSize;
var rows = texture.GetHeight() / tileset.TileSize;
for (var y = 0; y < rows; y++)
{
for (var x = 0; x < columns; x++)
source.CreateTile(new Vector2I(x, y));
}
tileSet.AddSource(source, SourceId);
return tileSet;
}
/// <summary>Atlas coordinate of a row-major index.</summary>
public static Vector2I AtlasCoords(RpgTileset tileset, int index)
{
var columns = System.Math.Max(1, tileset.Columns);
return new Vector2I(index % columns, index / columns);
}
/// <summary>Number of tiles the image holds.</summary>
public static int TileCount(RpgTileset tileset, Texture2D texture)
{
if (texture == null || tileset.TileSize <= 0)
return 0;
return (texture.GetWidth() / tileset.TileSize) * (texture.GetHeight() / tileset.TileSize);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
using Cthangover.Core.Events;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// The place of the RPG world in the 2D shell. The rpg mod patches
/// <c>base_scene.tscn</c> with this full-screen node right above the view box,
/// below the dialog canvas and the toolbar, so dialogs, panels and
/// notifications stay on top of the map.
/// <para/>
/// It follows the scene lifecycle: entering a scene whose name is a map id
/// mounts that map (<see cref="RpgWorldView"/>) and shows the dark backdrop;
/// any other scene leaves the host empty and invisible, so a game can mix
/// novel scenes and map scenes freely. The shell is rebuilt after battles,
/// so the host is too; the position survives in <see cref="RpgState"/>.
/// <para/>
/// <see cref="Current"/> is the host of the live shell, used by teleports
/// and the tests.
/// </summary>
public partial class RpgWorldHost : Control
{
private Control backdrop;
/// <summary>The host in the running shell, or null.</summary>
public static RpgWorldHost Current { get; private set; }
/// <summary>The mounted map, or null.</summary>
public RpgWorldView View { get; private set; }
/// <summary>Subscribes to the scene lifecycle and condition changes.</summary>
public override void _EnterTree()
{
Current = this;
ModEventBus.Subscribe<SceneEnteredEvent>(OnSceneEntered);
ModEventBus.Subscribe<SceneExitedEvent>(OnSceneExited);
ModEventBus.Subscribe<ConditionStateChangedEvent>(OnConditionsChanged);
}
/// <summary>Finds the backdrop and starts hidden until a map scene is entered.</summary>
public override void _Ready()
{
backdrop = GetNodeOrNull<Control>("Backdrop");
Visible = false;
}
/// <summary>Unsubscribes; the view goes with the node.</summary>
public override void _ExitTree()
{
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited);
ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionsChanged);
if (Current == this)
Current = null;
}
/// <summary>
/// Moves the player to a cell. On the mounted map it happens at once;
/// otherwise the target is stored as the arrival point and the scene
/// switches (deferred, like <c>switch_scene</c>).
/// </summary>
public static void Teleport(RpgTeleport target)
{
var host = Current;
var mapId = string.IsNullOrEmpty(target.Map) ? host?.View?.Map?.ID : target.Map;
if (string.IsNullOrEmpty(mapId))
{
GameLogger.Log("RPG", "teleport without a map while no map is mounted", LogLevel.Error);
return;
}
if (host?.View != null && host.View.Map.ID == mapId)
{
host.View.PlacePlayer(target.X, target.Y, target.Dir);
return;
}
RpgState.Instance.Place(mapId, target.X, target.Y, target.Dir);
var sceneManager = (Engine.GetMainLoop() as SceneTree)?.Root?.GetNodeOrNull<SceneManager>("SceneManager");
if (sceneManager == null)
{
GameLogger.Log("RPG", $"teleport to '{mapId}': SceneManager not found", LogLevel.Error);
return;
}
Callable.From(() => sceneManager.SwitchScene(mapId)).CallDeferred();
}
/// <summary>Mounts the map of the entered scene, if there is one.</summary>
public void Mount(string sceneName)
{
Unmount();
var map = RpgMapFactory.Instance.Get(sceneName);
if (map == null)
return;
View = new RpgWorldView();
AddChild(View);
View.Build(map);
var state = RpgState.Instance.Data;
if (state.Map == map.ID && View.Contains(state.X, state.Y))
View.PlacePlayer(state.X, state.Y, state.Dir);
else
View.PlacePlayer(map.Spawn?.X ?? 0, map.Spawn?.Y ?? 0, map.Spawn?.Dir ?? RpgDirection.Down);
if (backdrop != null)
backdrop.Visible = true;
Visible = true;
GameLogger.Log("RPG", $"map '{map.ID}' mounted, player at {View.Player.Cell}");
}
/// <summary>Frees the mounted map and hides the host.</summary>
public void Unmount()
{
if (View != null)
{
RemoveChild(View);
View.QueueFree();
View = null;
}
Visible = false;
}
private void OnSceneEntered(SceneEnteredEvent evt) => Mount(evt.SceneName);
private void OnSceneExited(SceneExitedEvent evt) => Unmount();
private void OnConditionsChanged(ConditionStateChangedEvent evt) => View?.RefreshEvents();
}
}
+254
View File
@@ -0,0 +1,254 @@
using System;
using System.Collections.Generic;
using Cthangover.Core.Scenes;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// One mounted map: the drawn layers, the actors and the player, plus the
/// rules of walking around. Built by <see cref="RpgWorldHost"/> when a map
/// scene is entered and freed when the scene is left.
/// <para/>
/// Draw order (children, bottom to top): back tiles, main tiles, the actor
/// layer (Y-sorted: events and the player), front tiles. The whole node is
/// scaled by the map's zoom with nearest filtering and follows the player
/// like a camera, clamped to the map edges; a map smaller than the screen is
/// centred.
/// <para/>
/// Each frame without a blocker (<see cref="RpgInput.IsWalkBlocked"/>) a held
/// direction turns the player and, if the cell is free, starts a step. When a
/// step ends the position is stored in <see cref="RpgState"/>, then a touch
/// event on the cell fires, else the encounter table rolls. The action key
/// fires the action event in front of the player (or under them); during a
/// dialog it advances the dialog instead.
/// </summary>
public partial class RpgWorldView : Node2D
{
private readonly Dictionary<string, RpgActor> eventActors = new();
private readonly RpgTextures textures = new();
private readonly Random random = new();
private RpgMapGrid grid;
private Node2D actorLayer;
private Texture2D atlas;
/// <summary>The mounted map.</summary>
public RpgMap Map { get; private set; }
/// <summary>The map's tileset (null when it could not be resolved: the map is drawn empty).</summary>
public RpgTileset Tileset { get; private set; }
/// <summary>The player's actor.</summary>
public RpgActor Player { get; private set; }
/// <summary>Pixel edge of a tile before zoom.</summary>
public int TileSize => Tileset?.TileSize ?? 32;
/// <summary>Builds the layers and actors of <paramref name="map"/>; the player is placed by <see cref="PlacePlayer"/>.</summary>
public void Build(RpgMap map)
{
Map = map;
Name = "World";
TextureFilter = TextureFilterEnum.Nearest;
Scale = Vector2.One * Math.Max(1, map.Zoom);
grid = RpgMapGrid.Parse(map);
Tileset = RpgTilesetFactory.Instance.Get(map.Tileset);
if (Tileset == null)
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error);
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null;
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null;
AddChild(BuildLayer(RpgLayer.Back, tileSet));
AddChild(BuildLayer(RpgLayer.Main, tileSet));
actorLayer = new Node2D { Name = "Actors", YSortEnabled = true };
AddChild(actorLayer);
AddChild(BuildLayer(RpgLayer.Front, tileSet));
foreach (var mapEvent in map.Events ?? new List<RpgMapEvent>())
AddEventActor(mapEvent);
RefreshEvents();
Player = new RpgActor(TileSize) { Name = "Player" };
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
Player.StepFinished += OnPlayerStepFinished;
actorLayer.AddChild(Player);
}
/// <summary>Puts the player on a cell and stores the position.</summary>
public void PlacePlayer(int x, int y, RpgDirection facing)
{
Player.Place(new Vector2I(x, y), facing);
RpgState.Instance.Place(Map.ID, x, y, facing);
UpdateCamera();
}
/// <summary>
/// Re-evaluates every event's condition: hidden events stop drawing,
/// blocking and reacting. Called on build and whenever game state that a
/// condition may read changes.
/// </summary>
public void RefreshEvents()
{
foreach (var actor in eventActors.Values)
actor.Visible = string.IsNullOrWhiteSpace(actor.Event.Condition) || ScenarioCondition.Evaluate(actor.Event.Condition);
}
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
public bool Contains(int x, int y) => grid.InBounds(x, y);
/// <summary>True when the player could step into the cell now.</summary>
public bool CanEnter(Vector2I cell)
{
if (!RpgPassability.IsWalkable(grid, Tileset, cell.X, cell.Y))
return false;
foreach (var actor in eventActors.Values)
{
if (actor.Visible && actor.Event.IsSolid && actor.Cell == cell)
return false;
}
return true;
}
/// <summary>The actor of the event with this id (visible or not), or null.</summary>
public RpgActor FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null;
/// <summary>The visible event on a cell with the given trigger, or null.</summary>
public RpgActor EventAt(Vector2I cell, RpgEventTrigger trigger)
{
foreach (var actor in eventActors.Values)
{
if (actor.Visible && actor.Cell == cell && actor.Event.Trigger == trigger)
return actor;
}
return null;
}
/// <summary>
/// Fires the action event the player faces, or the one under them
/// (a sign on the floor). An NPC turns to the player first.
/// </summary>
public bool Interact()
{
if (Player == null || Player.IsMoving)
return false;
var target = EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
?? EventAt(Player.Cell, RpgEventTrigger.Action);
if (target == null)
return false;
if (target.Cell != Player.Cell)
target.Face(RpgDirections.Opposite(Player.Facing));
return RpgEventRunner.Run(target.Event, Map.ID);
}
/// <summary>Walking: one decision per frame while the player stands still.</summary>
public override void _Process(double delta)
{
if (Player != null && !Player.IsMoving && !RpgInput.IsWalkBlocked(this))
{
var held = RpgInput.HeldDirection();
if (held.HasValue)
{
var next = Player.Cell + RpgDirections.Delta(held.Value);
if (CanEnter(next))
Player.StepTo(held.Value);
else
Player.Face(held.Value);
}
}
UpdateCamera();
}
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary>
public override void _UnhandledInput(InputEvent @event)
{
if (!RpgInput.IsActionPress(@event))
return;
var dialogBox = RpgInput.FindDialogBox();
if (dialogBox?.IsRunning == true)
{
if (!dialogBox.IsWaitingAnswer)
dialogBox.NextAction();
GetViewport().SetInputAsHandled();
return;
}
if (!RpgInput.IsWalkBlocked(this) && Interact())
GetViewport().SetInputAsHandled();
}
private void OnPlayerStepFinished(RpgActor player)
{
RpgState.Instance.Place(Map.ID, player.Cell.X, player.Cell.Y, player.Facing);
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
if (touch != null)
{
RpgEventRunner.Run(touch.Event, Map.ID);
return;
}
var enemies = RpgEncounterRoller.Roll(Map.Encounters, random);
if (enemies != null)
RpgBattleService.Start(enemies, null);
}
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
{
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet };
if (tileSet == null)
return node;
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
var index = grid.GetTile(layer, x, y);
if (index >= 0)
node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index));
}
}
return node;
}
private void AddEventActor(RpgMapEvent mapEvent)
{
if (string.IsNullOrEmpty(mapEvent.Id) || eventActors.ContainsKey(mapEvent.Id))
{
GameLogger.Log("RPG", $"map '{Map.ID}': event without a unique id at {mapEvent.X},{mapEvent.Y} skipped", LogLevel.Error);
return;
}
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id };
if (!string.IsNullOrEmpty(mapEvent.Sprite))
actor.UseCharset(textures.Charset(mapEvent.Sprite));
else if (mapEvent.Tile >= 0 && Tileset != null)
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, mapEvent.Tile));
actor.Place(new Vector2I(mapEvent.X, mapEvent.Y), mapEvent.Dir);
actorLayer.AddChild(actor);
eventActors[mapEvent.Id] = actor;
}
private void UpdateCamera()
{
if (Player == null || GetParent() is not Control host)
return;
var zoom = Scale.X;
var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom;
var screen = host.Size;
var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom;
Position = new Vector2(Axis(screen.X, mapSize.X, focus.X), Axis(screen.Y, mapSize.Y, focus.Y));
}
private static float Axis(float screen, float map, float focus)
{
if (map <= screen)
return Mathf.Round((screen - map) / 2f);
return Mathf.Round(Mathf.Clamp(screen / 2f - focus, screen - map, 0f));
}
}
}