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:
commit
5ca225a2d7
43 files changed
+2066
No files matched your search
@@ -0,0 +1,2 @@
|
||||
mod_rpg_name = RPG World
|
||||
mod_rpg_desc = Top-down tile world in the spirit of RPG Maker: three tile layers and a collision layer, grid walking with WASD, events on the action key E/F, random encounters and battles that return to the map.
|
||||
@@ -0,0 +1,2 @@
|
||||
mod_rpg_name = RPG-мир
|
||||
mod_rpg_desc = Мир из тайлов с видом сверху в духе RPG Maker: три слоя тайлов и слой коллизий, ходьба по клеткам на WASD, события по клавише действия E/F, случайные встречи и бои с возвратом на карту.
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "rpg",
|
||||
"name": "RPG World",
|
||||
"version": "1.0.0",
|
||||
"author": "ct",
|
||||
"description": "Top-down tile world in the spirit of RPG Maker on top of the novel engine: maps of square tiles in three layers (back, main, front) with a collision layer, walking on the grid with WASD or the arrows, events that talk, teleport or start battles with the action key E/F or on touch, random encounters. A map is a logical scene, so entry scenarios, ambience, saving and the dialog box work on maps unchanged; battles run on the turn-based platform with the game's battle core and return to the same cell. Scenario actions: rpg.battle, rpg.teleport.",
|
||||
"category": "gameplay",
|
||||
"sources": [
|
||||
"src/**/*.cs"
|
||||
],
|
||||
"test_sources": [
|
||||
"tests/*.cs"
|
||||
],
|
||||
"depends": ["shell2d_base>=1", "ui_base>=1", "dialog_base>=1", "turn_battle_base>=2"],
|
||||
"test_suites": [
|
||||
{"id": "walk", "activate": ["--itest=rpg_walk"]}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[gd_scene load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="PackedScene" path="res://mods/shell2d_base/scenes/ui/base_scene.tscn" id="1_target"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/ui/rpg_world_host.tscn" id="2_world"]
|
||||
|
||||
[node name="BaseScene" instance=ExtResource("1_target")]
|
||||
|
||||
[node name="RpgWorldHost" parent="Scene" index="2" instance=ExtResource("2_world")]
|
||||
@@ -0,0 +1,23 @@
|
||||
[gd_scene format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://mods/rpg/src/World/RpgWorldHost.cs" id="1_host"]
|
||||
|
||||
[node name="RpgWorldHost" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
script = ExtResource("1_host")
|
||||
|
||||
[node name="Backdrop" type="ColorRect" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
mouse_filter = 2
|
||||
color = Color(0.06, 0.07, 0.09, 1)
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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/<map>.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "RPG Walk Test",
|
||||
"description": "Integration test of the rpg mod on the rpg_demo game: real WASD/E key events walk the hero, collide, read a sign, turn an NPC, go through a door teleport, flip events by a story flag, fight a battle that returns to the same cell with the win flag set. Activated by --itest=rpg_walk with --game=rpg_demo --new; --shots=<dir> saves screenshots (windowed runs only).",
|
||||
"sources": [
|
||||
"src/*.cs"
|
||||
],
|
||||
"depends": [
|
||||
"rpg_demo_game>=1"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Battle;
|
||||
using Cthangover.Core.Bootstrap;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Plays the rpg_demo village like a player: key events go through
|
||||
/// <see cref="Input.ParseInputEvent"/>, so the world reads them exactly as
|
||||
/// it reads a keyboard (physical keys, held state, unhandled action key).
|
||||
/// <para/>
|
||||
/// One step per timer tick; a step either waits (returns without
|
||||
/// advancing) or checks and moves on. A failed check is recorded and the
|
||||
/// run goes on; an exception ends it. Result line:
|
||||
/// <c>[TEST_RESULT] name=rpg_walk passed=N failed=M</c>, exit code 0/1, and a
|
||||
/// 90 s watchdog.
|
||||
/// </summary>
|
||||
public partial class RpgWalkTestDriver : Node
|
||||
{
|
||||
private const float TickSeconds = 0.1f;
|
||||
private const float TimeoutSeconds = 90f;
|
||||
private const string Village = "rpg_village";
|
||||
private const string House = "rpg_elder_house";
|
||||
private const string WinFlag = "rpg_test_win";
|
||||
|
||||
private readonly string shotsDir;
|
||||
private readonly List<string> failures = new();
|
||||
private int passed;
|
||||
private int step;
|
||||
private int waitTicks;
|
||||
private double elapsed;
|
||||
|
||||
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
|
||||
public RpgWalkTestDriver(string shotsDir)
|
||||
{
|
||||
this.shotsDir = shotsDir;
|
||||
Name = "RpgWalkTestDriver";
|
||||
var timer = new Timer { WaitTime = TickSeconds, Autostart = true };
|
||||
AddChild(timer);
|
||||
timer.Timeout += Tick;
|
||||
}
|
||||
|
||||
private static RpgWorldView View => RpgWorldHost.Current?.View;
|
||||
|
||||
private void Tick()
|
||||
{
|
||||
if (step < 0)
|
||||
return;
|
||||
elapsed += TickSeconds;
|
||||
if (elapsed > TimeoutSeconds)
|
||||
{
|
||||
Fail($"timeout at step {step}");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunStep();
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Fail($"step {step}: {ex.GetType().Name}: {ex.Message}");
|
||||
Finish();
|
||||
}
|
||||
}
|
||||
|
||||
private void RunStep()
|
||||
{
|
||||
switch (step)
|
||||
{
|
||||
case 0:
|
||||
if (Boot.IsReady && View?.Map?.ID == Village)
|
||||
Next();
|
||||
return;
|
||||
case 1:
|
||||
// The intro dialog: the action key advances it.
|
||||
if (AdvanceDialog())
|
||||
return;
|
||||
Check(GameData.Instance.Runtime.CharacterData.Characters.ContainsKey("rpg_hero"), "party.add put the hero into the party");
|
||||
Check(View.Player.Cell == new Vector2I(15, 14), $"player at the spawn (15,14), got {View.Player.Cell}");
|
||||
Shot("village");
|
||||
Press(Key.W, true);
|
||||
Next();
|
||||
return;
|
||||
case 2:
|
||||
if (View.Player.Cell.Y > 12)
|
||||
return;
|
||||
Press(Key.W, false);
|
||||
Next();
|
||||
return;
|
||||
case 3:
|
||||
if (View.Player.IsMoving)
|
||||
return;
|
||||
Check(View.Player.Cell.X == 15 && View.Player.Cell.Y <= 12, $"W walked up the path, at {View.Player.Cell}");
|
||||
Check(RpgState.Instance.Data.Map == Village && RpgState.Instance.Data.Y == View.Player.Cell.Y, "the position is stored after the step");
|
||||
View.PlacePlayer(14, 11, RpgDirection.Up);
|
||||
Press(Key.S, true);
|
||||
Next();
|
||||
return;
|
||||
case 4:
|
||||
if (++waitTicks < 4)
|
||||
return;
|
||||
Press(Key.S, false);
|
||||
Check(View.Player.Cell == new Vector2I(14, 11), $"the well blocks the way, at {View.Player.Cell}");
|
||||
Check(View.Player.Facing == RpgDirection.Down, "a blocked key still turns the player");
|
||||
Next();
|
||||
return;
|
||||
case 5:
|
||||
// The S release is read on the next frame; placing the player
|
||||
// before it would let the still-held key walk them away.
|
||||
View.PlacePlayer(16, 12, RpgDirection.Up);
|
||||
Tap(Key.E);
|
||||
Next();
|
||||
return;
|
||||
case 6:
|
||||
Check(RpgInput.FindDialogBox()?.IsRunning == true, "E on the sign opens a dialog");
|
||||
Next();
|
||||
return;
|
||||
case 7:
|
||||
if (AdvanceDialog())
|
||||
return;
|
||||
View.PlacePlayer(10, 10, RpgDirection.Left);
|
||||
Tap(Key.F);
|
||||
Next();
|
||||
return;
|
||||
case 8:
|
||||
Check(RpgInput.FindDialogBox()?.IsRunning == true, "F talks to the villager");
|
||||
Check(View.FindEvent("villager")?.Facing == RpgDirection.Right, "the villager turns to the player");
|
||||
Next();
|
||||
return;
|
||||
case 9:
|
||||
if (AdvanceDialog())
|
||||
return;
|
||||
View.PlacePlayer(6, 7, RpgDirection.Up);
|
||||
Press(Key.W, true);
|
||||
Next();
|
||||
return;
|
||||
case 10:
|
||||
if (!View.Player.IsMoving && View.Map.ID == Village)
|
||||
return;
|
||||
Press(Key.W, false);
|
||||
Next();
|
||||
return;
|
||||
case 11:
|
||||
if (View?.Map?.ID != House)
|
||||
return;
|
||||
if (AdvanceDialog())
|
||||
return;
|
||||
Check(View.Player.Cell == new Vector2I(6, 8), $"the door teleports into the house at (6,8), got {View.Player.Cell}");
|
||||
Check(View.FindEvent("elder_quest")?.Visible == true && View.FindEvent("elder_thanks")?.Visible == false, "before the wolf the elder gives the quest");
|
||||
Shot("house");
|
||||
ScenarioCondition.SetFlag("rpg_wolf_dead", "1");
|
||||
Next();
|
||||
return;
|
||||
case 12:
|
||||
Check(View.FindEvent("elder_quest")?.Visible == false && View.FindEvent("elder_thanks")?.Visible == true, "a story flag swaps the elder's events at once");
|
||||
ScenarioCondition.SetFlag("rpg_wolf_dead", "0");
|
||||
Check(RpgBattleService.Start("rpg_slime", WinFlag), "a battle starts from the map");
|
||||
Engine.TimeScale = 4;
|
||||
Next();
|
||||
return;
|
||||
case 13:
|
||||
if (GetTree().CurrentScene is not BattleSceneContext battle)
|
||||
return;
|
||||
// The core's auto turn only passes, so the hero would lose: once the
|
||||
// core waits for the player, take the platform's victory path.
|
||||
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
|
||||
return;
|
||||
Shot("battle");
|
||||
battle.ShowWinground();
|
||||
Next();
|
||||
return;
|
||||
case 14:
|
||||
if (GetTree().Root.FindChild("ContinueButton", true, false) is not Button button || !button.IsVisibleInTree())
|
||||
return;
|
||||
Shot("victory");
|
||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
Next();
|
||||
return;
|
||||
case 15:
|
||||
if (View?.Map?.ID != House || GodotSceneService.IsTransitioning)
|
||||
return;
|
||||
Engine.TimeScale = 1;
|
||||
Check(View.Player.Cell == new Vector2I(6, 8), $"the battle returns to the same cell, got {View.Player.Cell}");
|
||||
Check(GameData.Instance.Runtime.Flags.TryGetValue(WinFlag, out var won) && won == "1", "the win flag is set after the victory");
|
||||
Finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Advances a running dialog with the action key; true while it is still running.</summary>
|
||||
private bool AdvanceDialog()
|
||||
{
|
||||
var box = RpgInput.FindDialogBox();
|
||||
if (box == null || !box.IsRunning)
|
||||
return false;
|
||||
Tap(Key.E);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Press(Key key, bool pressed)
|
||||
{
|
||||
Input.ParseInputEvent(new InputEventKey { PhysicalKeycode = key, Keycode = key, Pressed = pressed });
|
||||
}
|
||||
|
||||
private static void Tap(Key key)
|
||||
{
|
||||
Press(key, true);
|
||||
Press(key, false);
|
||||
}
|
||||
|
||||
private void Next()
|
||||
{
|
||||
step++;
|
||||
waitTicks = 0;
|
||||
}
|
||||
|
||||
private void Check(bool condition, string what)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"[PASS] {what}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(what);
|
||||
}
|
||||
}
|
||||
|
||||
private void Fail(string what)
|
||||
{
|
||||
failures.Add(what);
|
||||
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
|
||||
}
|
||||
|
||||
private void Shot(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
|
||||
return;
|
||||
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
|
||||
GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/rpg_" + name + ".png");
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
step = -1;
|
||||
Engine.TimeScale = 1;
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_walk passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Entry point of the rpg_walk integration test: adds the driver to the tree
|
||||
/// root when the exact flag <c>--itest=rpg_walk</c> is given (the driver
|
||||
/// quits the process, so it must never run in another test). Optional
|
||||
/// <c>--shots=<dir></c> makes the driver save screenshots.
|
||||
/// </summary>
|
||||
public class RpgWalkTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
/// <summary>Nothing per mod.</summary>
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Queues the driver once.</summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
if (driverQueued || !args.Any(a => a == "--itest=rpg_walk"))
|
||||
return;
|
||||
|
||||
driverQueued = true;
|
||||
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--shots=".Length))
|
||||
.FirstOrDefault();
|
||||
GameLogger.Log("TEST", $"RpgWalkTestInitializer: activating rpg_walk, shots={shots ?? "off"}");
|
||||
(Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgWalkTestDriver(shots));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user