Map lighting by the time of day, lit tiles and events; A2 autotiles

This commit is contained in:
cthangover committed 2026-10-24 12:56:08 +02:00
1 parent 0027c8ded0
commit 88ab26dcec
19 files changed
+573 -10

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
"test_sources": [ "test_sources": [
"tests/*.cs" "tests/*.cs"
], ],
"depends": ["shell2d_base>=1", "ui_base>=1", "dialog_base>=1", "turn_battle_base>=2"], "depends": ["shell2d_base>=1", "ui_base>=1", "time_base>=1", "dialog_base>=1", "turn_battle_base>=2"],
"test_suites": [ "test_suites": [
{"id": "walk", "activate": ["--itest=rpg_walk"]} {"id": "walk", "activate": ["--itest=rpg_walk"]}
] ]
+47
View File
@@ -0,0 +1,47 @@
shader_type canvas_item;
// Light of an RPG map (RpgLighting): the tiles and actors are multiplied by the ambient
// colour of the time of day (time_weights blends the four phases like timed_sprite), and
// point lights (the player, lit events) add to it. Lights only add up to white, so by day
// they are invisible and at night they open circles in the dark. Positions and radii are
// in canvas pixels, the space of world_pos below.
uniform vec4 day_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform vec4 morning_color : source_color = vec4(0.86, 0.72, 0.84, 1.0);
uniform vec4 evening_color : source_color = vec4(0.84, 0.72, 0.92, 1.0);
uniform vec4 night_color : source_color = vec4(0.2, 0.26, 0.42, 1.0);
uniform vec4 time_weights = vec4(0.0, 1.0, 0.0, 0.0);
// A map that sets its own ambient (indoors) uses it instead of the time of day.
uniform bool use_ambient = false;
uniform vec4 ambient_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform vec2 light_positions[16];
uniform vec4 light_colors[16];
uniform float light_radii[16];
uniform int light_count = 0;
varying vec2 world_pos;
void vertex() {
world_pos = (MODEL_MATRIX * vec4(VERTEX, 0.0, 1.0)).xy;
}
void fragment() {
vec4 color = texture(TEXTURE, UV) * COLOR;
vec3 ambient = use_ambient
? ambient_color.rgb
: (morning_color * time_weights.x + day_color * time_weights.y
+ evening_color * time_weights.z + night_color * time_weights.w).rgb;
vec3 light = vec3(0.0);
for (int i = 0; i < 16; i++) {
if (i >= light_count)
break;
float k = clamp(1.0 - distance(world_pos, light_positions[i]) / max(light_radii[i], 1.0), 0.0, 1.0);
light += light_colors[i].rgb * light_colors[i].a * k * k;
}
COLOR = vec4(color.rgb * min(ambient + light, vec3(1.0)), color.a);
}
+84
View File
@@ -0,0 +1,84 @@
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Autotiles in the RPG Maker A2 layout: one picture of 2×3 tiles describes a terrain
/// (water, a path, a cliff top), and each map cell is assembled from four quarter tiles
/// chosen by its eight neighbours — so an author paints "water" and the shores draw
/// themselves. A tileset entry becomes an autotile by naming that picture
/// (<see cref="RpgTileInfo.Autotile"/>); its index is any number outside the atlas.
/// <para/>
/// The block in quarter units (4 columns × 6 rows): the top-left tile is the palette
/// preview; the top-right tile holds the four inner corners; the 2×2 tiles below are a
/// framed patch whose outer ring gives outer corners and edges and whose middle gives the
/// fill. For a quarter, the two neighbours along its sides and the one across its corner
/// decide: both sides alike and the corner too — fill; both sides alike but not the
/// corner — inner corner; only one side alike — the edge along the other; none — the
/// outer corner. Cells past the map edge count as alike, so a terrain runs off the map
/// without a border. The world (<see cref="RpgAutotileLayer"/>) and the map editor draw
/// through <see cref="Quarters"/> and <see cref="Draw"/>, so both show the same cells.
/// </summary>
public static class RpgAutotiles
{
/// <summary>
/// Source quarters (in quarter units of the block) for the top-left, top-right,
/// bottom-left and bottom-right quarter of a cell, from which neighbours are alike.
/// </summary>
public static Vector2I[] Quarters(bool up, bool down, bool left, bool right,
bool upLeft, bool upRight, bool downLeft, bool downRight)
{
return new[]
{
Quarter(up, left, upLeft, new Vector2I(2, 0), left ? 2 : 0, up ? 4 : 2),
Quarter(up, right, upRight, new Vector2I(3, 0), right ? 1 : 3, up ? 4 : 2),
Quarter(down, left, downLeft, new Vector2I(2, 1), left ? 2 : 0, down ? 3 : 5),
Quarter(down, right, downRight, new Vector2I(3, 1), right ? 1 : 3, down ? 3 : 5),
};
}
/// <summary>
/// The quarters of the cell (<paramref name="x"/>, <paramref name="y"/>) of
/// <paramref name="layer"/>, whose tile is the autotile <paramref name="index"/>.
/// </summary>
public static Vector2I[] QuartersAt(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
{
return Quarters(
Alike(grid, layer, x, y - 1, index), Alike(grid, layer, x, y + 1, index),
Alike(grid, layer, x - 1, y, index), Alike(grid, layer, x + 1, y, index),
Alike(grid, layer, x - 1, y - 1, index), Alike(grid, layer, x + 1, y - 1, index),
Alike(grid, layer, x - 1, y + 1, index), Alike(grid, layer, x + 1, y + 1, index));
}
/// <summary>Pixel rectangle of a quarter in the block picture of a tileset with <paramref name="tileSize"/>.</summary>
public static Rect2 SourceRect(Vector2I quarter, int tileSize)
{
var half = tileSize / 2f;
return new Rect2(quarter.X * half, quarter.Y * half, half, half);
}
/// <summary>Draws a cell of an autotile into <paramref name="cell"/> on <paramref name="target"/>.</summary>
public static void Draw(CanvasItem target, Texture2D block, Rect2 cell, Vector2I[] quarters, int tileSize, Color modulate)
{
var half = cell.Size / 2f;
for (var i = 0; i < 4; i++)
{
var offset = new Vector2(i % 2 * half.X, i / 2 * half.Y);
target.DrawTextureRectRegion(block, new Rect2(cell.Position + offset, half), SourceRect(quarters[i], tileSize), modulate);
}
}
/// <summary>Source rectangle of the block's preview tile (its top-left tile), for palettes.</summary>
public static Rect2 PreviewRect(int tileSize) => new(0, 0, tileSize, tileSize);
private static Vector2I Quarter(bool sideA, bool sideB, bool corner, Vector2I inner, int column, int row)
{
if (sideA && sideB && !corner)
return inner;
return new Vector2I(column, row);
}
private static bool Alike(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
=> !grid.InBounds(x, y) || grid.GetTile(layer, x, y) == index;
}
}
+3
View File
@@ -43,5 +43,8 @@ namespace Cthangover.Mods.Rpg
/// <summary>Autonomous movement of the page.</summary> /// <summary>Autonomous movement of the page.</summary>
public RpgMoveRoute Route { get; set; } public RpgMoveRoute Route { get; set; }
/// <summary>Light of the page (a lamp lit by a page).</summary>
public RpgLight Light { get; set; }
} }
} }
+1
View File
@@ -60,6 +60,7 @@ namespace Cthangover.Mods.Rpg
Commands = page.Commands ?? mapEvent.Commands, Commands = page.Commands ?? mapEvent.Commands,
Teleport = page.Teleport ?? mapEvent.Teleport, Teleport = page.Teleport ?? mapEvent.Teleport,
Route = page.Route ?? mapEvent.Route, Route = page.Route ?? mapEvent.Route,
Light = page.Light ?? mapEvent.Light,
Condition = page.Condition, Condition = page.Condition,
ModId = mapEvent.ModId, ModId = mapEvent.ModId,
File = mapEvent.File, File = mapEvent.File,
+16
View File
@@ -0,0 +1,16 @@
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// A light an event gives off (a lamp, a fire, a lit window), drawn by
/// <see cref="RpgLighting"/>. It brightens the cells around the event towards their
/// own colours, so it shows at dusk and at night and is lost in daylight.
/// </summary>
public class RpgLight
{
/// <summary>Reach in cells from the event's centre; the light fades to nothing there.</summary>
public float Radius { get; set; } = 3f;
/// <summary>Colour (<c>#rrggbb</c>, optionally <c>#rrggbbaa</c>: alpha is the strength).</summary>
public string Color { get; set; } = "#ffd8a0";
}
}
+6
View File
@@ -55,5 +55,11 @@ namespace Cthangover.Mods.Rpg
/// <summary>Random battles; null for a safe map.</summary> /// <summary>Random battles; null for a safe map.</summary>
public RpgEncounterTable Encounters { get; set; } public RpgEncounterTable Encounters { get; set; }
/// <summary>
/// Fixed light colour of the map (<c>#rrggbb</c>), e.g. a lit room; null = the time of day
/// lights the map (<see cref="RpgLighting"/>).
/// </summary>
public string Ambient { get; set; }
} }
} }
+3
View File
@@ -74,6 +74,9 @@ namespace Cthangover.Mods.Rpg
/// <summary>Autonomous movement (<see cref="RpgMoveRoute"/>); null = the event stands still.</summary> /// <summary>Autonomous movement (<see cref="RpgMoveRoute"/>); null = the event stands still.</summary>
public RpgMoveRoute Route { get; set; } public RpgMoveRoute Route { get; set; }
/// <summary>Light the event gives off (<see cref="RpgLight"/>); null = none.</summary>
public RpgLight Light { get; set; }
/// <summary>Mod the event was read from (set by the event resolver, not stored).</summary> /// <summary>Mod the event was read from (set by the event resolver, not stored).</summary>
[JsonIgnore] [JsonIgnore]
public string ModId { get; set; } public string ModId { get; set; }
+9
View File
@@ -21,5 +21,14 @@ namespace Cthangover.Mods.Rpg
/// "tree_crown"). Not a locale key: players never see it. /// "tree_crown"). Not a locale key: players never see it.
/// </summary> /// </summary>
public string Tag { get; set; } public string Tag { get; set; }
/// <summary>Light the tile gives off wherever it lies on a map (a lamp, a lit window); null = none.</summary>
public RpgLight Light { get; set; }
/// <summary>
/// Picture of an autotile block (RPG Maker A2 layout, see <see cref="RpgAutotiles"/>) in the
/// tilesets group; set, the entry is an autotile and <see cref="Index"/> should lie outside the atlas.
/// </summary>
public string Autotile { get; set; }
} }
} }
+24
View File
@@ -34,6 +34,30 @@ namespace Cthangover.Mods.Rpg
/// <summary>Per-cell properties; cells without an entry are walkable.</summary> /// <summary>Per-cell properties; cells without an entry are walkable.</summary>
public List<RpgTileInfo> Tiles { get; set; } = new(); public List<RpgTileInfo> Tiles { get; set; } = new();
/// <summary>The autotile entry with this index (<see cref="RpgTileInfo.Autotile"/> set), or null.</summary>
public RpgTileInfo AutotileOf(int index)
{
if (index < 0)
return null;
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
{
if (tile.Index == index && !string.IsNullOrEmpty(tile.Autotile))
return tile;
}
return null;
}
/// <summary>Light of the atlas cell (<see cref="RpgTileInfo.Light"/>), or null.</summary>
public RpgLight LightOf(int index)
{
foreach (var tile in Tiles ?? new List<RpgTileInfo>())
{
if (tile.Index == index && tile.Light != null)
return tile.Light;
}
return null;
}
/// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary> /// <summary>True when the atlas cell blocks walking. Built once from <see cref="Tiles"/>.</summary>
public bool IsSolid(int index) public bool IsSolid(int index)
{ {
+4 -1
View File
@@ -15,6 +15,7 @@ namespace Cthangover.Mods.Rpg
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the /// <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 /// 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. /// layer sorts children by Y, which keeps whoever is lower in front.
/// The sprite uses the actor's material (the map's light, set by the view).
/// </summary> /// </summary>
public partial class RpgActor : Node2D public partial class RpgActor : Node2D
{ {
@@ -78,7 +79,7 @@ namespace Cthangover.Mods.Rpg
{ {
if (texture == null) if (texture == null)
return; return;
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false }; sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false, UseParentMaterial = true };
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f); var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y); sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
AddChild(sprite); AddChild(sprite);
@@ -94,6 +95,7 @@ namespace Cthangover.Mods.Rpg
{ {
Texture = atlas, Texture = atlas,
Centered = false, Centered = false,
UseParentMaterial = true,
RegionEnabled = true, RegionEnabled = true,
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)), RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
}; };
@@ -115,6 +117,7 @@ namespace Cthangover.Mods.Rpg
Position = new Vector2(tileSize, tileSize) / 2f, Position = new Vector2(tileSize, tileSize) / 2f,
Scale = new Vector2(fit, fit), Scale = new Vector2(fit, fit),
TextureFilter = TextureFilterEnum.Linear, TextureFilter = TextureFilterEnum.Linear,
UseParentMaterial = true,
}; };
AddChild(sprite); AddChild(sprite);
} }
+50
View File
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// The autotile cells of one map layer, drawn as a child of that layer's
/// <see cref="TileMapLayer"/> — right above its plain tiles, below the next layer — with
/// the layer's material (the map's light). A tile map cannot hold them: each cell is four
/// quarters picked from its neighbours (<see cref="RpgAutotiles"/>), so the cells and their
/// quarters are worked out once when the map is built and drawn by <c>_Draw</c>, which
/// Godot caches until the next redraw.
/// </summary>
public partial class RpgAutotileLayer : Node2D
{
private readonly List<(Rect2 Cell, Texture2D Block, Vector2I[] Quarters)> cells = new();
private readonly int tileSize;
/// <summary>Collects the autotile cells of <paramref name="layer"/>; cells whose picture is missing stay empty.</summary>
public RpgAutotileLayer(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, RpgTextures textures)
{
Name = layer + "Autotiles";
UseParentMaterial = true;
tileSize = tileset.TileSize;
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
var index = grid.GetTile(layer, x, y);
var info = tileset.AutotileOf(index);
var block = info == null ? null : textures.Tileset(info.Autotile);
if (block == null)
continue;
var rect = new Rect2(x * tileSize, y * tileSize, tileSize, tileSize);
cells.Add((rect, block, RpgAutotiles.QuartersAt(grid, layer, x, y, index)));
}
}
}
/// <summary>Number of drawn autotile cells.</summary>
public int Count => cells.Count;
/// <summary>Draws every collected cell.</summary>
public override void _Draw()
{
foreach (var (cell, block, quarters) in cells)
RpgAutotiles.Draw(this, block, cell, quarters, tileSize, Colors.White);
}
}
}
+137
View File
@@ -0,0 +1,137 @@
using System.Collections.Generic;
using System.Linq;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Settings;
using Cthangover.Mods.Time;
using Godot;
namespace Cthangover.Mods.Rpg
{
/// <summary>
/// Light of a mounted map. One <c>rpg_lit</c> material is shared by the tile layers and
/// every actor (their sprites use the parent's material), so the whole map is lit by the
/// same ambient and the same lights.
/// <para/>
/// The ambient follows the game clock with the phases and cross-fades of the novel
/// backgrounds (<see cref="UiLightController.CalculateTimeWeights"/>; dark mode forces night,
/// the light switch off keeps day), unless the map fixes its own (<see cref="RpgMap.Ambient"/>,
/// a lit room). The lights are the player's own glow, the tiles whose tileset entry has a
/// <see cref="RpgLight"/> (lamps and windows light every map they are drawn on, collected
/// once per map) and the shown events with a light on their active page. The nearest to the
/// player win, at most <see cref="MaxLights"/>. They are pushed every frame in canvas
/// pixels, because the view moves with the player and actors walk.
/// </summary>
public sealed class RpgLighting
{
/// <summary>Lights the shader takes (its arrays' size).</summary>
public const int MaxLights = 16;
/// <summary>Reach of the player's glow in cells.</summary>
public const float PlayerRadius = 3.5f;
private static readonly Color PlayerColor = new(1f, 0.9f, 0.75f, 0.75f);
private static readonly Vector4 Day = new(0, 1, 0, 0);
private static readonly Vector4 Night = new(0, 0, 0, 1);
private readonly Vector2[] positions = new Vector2[MaxLights];
private readonly Color[] colors = new Color[MaxLights];
private readonly float[] radii = new float[MaxLights];
private readonly List<(Vector2I Cell, RpgLight Light)> tileLights = new();
private readonly List<(Vector2 Centre, RpgLight Light)> candidates = new();
/// <summary>The shared material; null when the shader is missing (the map is drawn unlit).</summary>
public ShaderMaterial Material { get; }
/// <summary>Creates the material for <paramref name="map"/>, applies its fixed ambient and collects the lit tiles.</summary>
public RpgLighting(RpgMap map, RpgMapGrid grid, RpgTileset tileset)
{
if (tileset != null)
tileLights.AddRange(TileLights(grid, tileset));
var shader = Shaders.Resolve("rpg_lit");
if (shader == null)
return;
Material = new ShaderMaterial { Shader = shader };
var ambient = !string.IsNullOrEmpty(map.Ambient) && Color.HtmlIsValid(map.Ambient);
Material.SetShaderParameter("use_ambient", ambient);
if (ambient)
Material.SetShaderParameter("ambient_color", Color.FromHtml(map.Ambient));
}
/// <summary>
/// The ambient weights for an hour 0–24: night in dark mode, day while lighting is
/// switched off, otherwise the phase blend of the time shaders.
/// </summary>
public static Vector4 Weights(float hour, bool darkMode, bool useLight)
{
if (darkMode)
return Night;
if (!useLight)
return Day;
return UiLightController.CalculateTimeWeights(hour);
}
/// <summary>Cells of <paramref name="grid"/> whose tile (on any layer) gives off light, with that light.</summary>
public static List<(Vector2I Cell, RpgLight Light)> TileLights(RpgMapGrid grid, RpgTileset tileset)
{
var result = new List<(Vector2I, RpgLight)>();
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
foreach (var layer in new[] { RpgLayer.Back, RpgLayer.Main, RpgLayer.Front })
{
var light = tileset.LightOf(grid.GetTile(layer, x, y));
if (light == null)
continue;
result.Add((new Vector2I(x, y), light));
break;
}
}
}
return result;
}
/// <summary>Pushes the ambient of the current hour and the lights of <paramref name="view"/>.</summary>
public void Update(RpgWorldView view, IEnumerable<RpgActor> events)
{
if (Material == null || view.Player == null)
return;
var controller = UiLightController.Instance;
var hour = GameData.Instance?.Runtime?.Time?.Normalized ?? 12f;
Material.SetShaderParameter("time_weights", Weights(hour, controller?.IsDarkMode == true, controller?.IsUseLight != false));
var half = new Vector2(view.TileSize, view.TileSize) / 2f;
candidates.Clear();
foreach (var (cell, light) in tileLights)
candidates.Add((new Vector2(cell.X, cell.Y) * view.TileSize + half, light));
foreach (var actor in events)
{
if (actor.Visible && actor.Current?.Light != null)
candidates.Add((actor.Position + half, actor.Current.Light));
}
var cellPixels = view.TileSize * view.GlobalScale.X;
var player = view.Player.Position + half;
positions[0] = view.ToGlobal(player);
colors[0] = PlayerColor;
radii[0] = PlayerRadius * cellPixels;
var count = 1;
foreach (var (centre, light) in candidates.OrderBy(c => c.Centre.DistanceSquaredTo(player)))
{
if (count >= MaxLights)
break;
positions[count] = view.ToGlobal(centre);
colors[count] = Color.HtmlIsValid(light.Color ?? "") ? Color.FromHtml(light.Color) : Colors.White;
radii[count] = light.Radius * cellPixels;
count++;
}
Material.SetShaderParameter("light_count", count);
Material.SetShaderParameter("light_positions", positions);
Material.SetShaderParameter("light_colors", colors);
Material.SetShaderParameter("light_radii", radii);
}
}
}
+17 -5
View File
@@ -12,7 +12,8 @@ namespace Cthangover.Mods.Rpg
/// scene is entered and freed when the scene is left. /// scene is entered and freed when the scene is left.
/// <para/> /// <para/>
/// Draw order (children, bottom to top): back tiles, main tiles, the actor /// 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 /// layer (Y-sorted: events and the player), front tiles; each tile layer draws its
/// autotile cells over its plain tiles (<see cref="RpgAutotileLayer"/>). The whole node is
/// scaled by the map's zoom with nearest filtering and follows the player /// 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 /// like a camera, clamped to the map edges; a map smaller than the screen is
/// centred. /// centred.
@@ -23,6 +24,9 @@ namespace Cthangover.Mods.Rpg
/// event on the cell fires, else the encounter table rolls. The action key /// 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 /// fires the action event in front of the player (or under them); during a
/// dialog it advances the dialog instead. /// dialog it advances the dialog instead.
/// <para/>
/// Layers and actors share the material of <see cref="RpgLighting"/> (time of day, the
/// player's glow, lit events), updated after the camera each frame.
/// </summary> /// </summary>
public partial class RpgWorldView : Node2D public partial class RpgWorldView : Node2D
{ {
@@ -32,6 +36,7 @@ namespace Cthangover.Mods.Rpg
private RpgMapGrid grid; private RpgMapGrid grid;
private Node2D actorLayer; private Node2D actorLayer;
private Texture2D atlas; private Texture2D atlas;
private RpgLighting lighting;
/// <summary>The mounted map.</summary> /// <summary>The mounted map.</summary>
public RpgMap Map { get; private set; } public RpgMap Map { get; private set; }
@@ -59,6 +64,7 @@ namespace Cthangover.Mods.Rpg
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error); GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error);
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null; atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null;
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null; var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null;
lighting = new RpgLighting(map, grid, Tileset);
AddChild(BuildLayer(RpgLayer.Back, tileSet)); AddChild(BuildLayer(RpgLayer.Back, tileSet));
AddChild(BuildLayer(RpgLayer.Main, tileSet)); AddChild(BuildLayer(RpgLayer.Main, tileSet));
@@ -70,7 +76,7 @@ namespace Cthangover.Mods.Rpg
AddEventActor(mapEvent); AddEventActor(mapEvent);
RefreshEvents(); RefreshEvents();
Player = new RpgActor(TileSize) { Name = "Player" }; Player = new RpgActor(TileSize) { Name = "Player", Material = lighting.Material };
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset())); Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
Player.StepFinished += OnPlayerStepFinished; Player.StepFinished += OnPlayerStepFinished;
actorLayer.AddChild(Player); actorLayer.AddChild(Player);
@@ -178,6 +184,7 @@ namespace Cthangover.Mods.Rpg
} }
} }
UpdateCamera(); UpdateCamera();
lighting.Update(this, eventActors.Values);
} }
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary> /// <summary>The action key: advances a running dialog, otherwise interacts.</summary>
@@ -217,7 +224,7 @@ namespace Cthangover.Mods.Rpg
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet) private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
{ {
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet }; var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet, Material = lighting.Material };
if (tileSet == null) if (tileSet == null)
return node; return node;
for (var y = 0; y < grid.Height; y++) for (var y = 0; y < grid.Height; y++)
@@ -225,10 +232,15 @@ namespace Cthangover.Mods.Rpg
for (var x = 0; x < grid.Width; x++) for (var x = 0; x < grid.Width; x++)
{ {
var index = grid.GetTile(layer, x, y); var index = grid.GetTile(layer, x, y);
if (index >= 0) if (index >= 0 && Tileset.AutotileOf(index) == null)
node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index)); node.SetCell(new Vector2I(x, y), RpgTileSetBuilder.SourceId, RpgTileSetBuilder.AtlasCoords(Tileset, index));
} }
} }
var autotiles = new RpgAutotileLayer(grid, layer, Tileset, textures);
if (autotiles.Count > 0)
node.AddChild(autotiles);
else
autotiles.Free();
return node; return node;
} }
@@ -241,7 +253,7 @@ namespace Cthangover.Mods.Rpg
} }
// The look and the cell come with the first page (RefreshEvents). // The look and the cell come with the first page (RefreshEvents).
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id }; var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id, Material = lighting.Material };
actorLayer.AddChild(actor); actorLayer.AddChild(actor);
eventActors[mapEvent.Id] = actor; eventActors[mapEvent.Id] = actor;
} }
+5 -2
View File
@@ -34,6 +34,9 @@ namespace Cthangover.Mods.Rpg.Tests
public partial class RpgWalkTestDriver : Node public partial class RpgWalkTestDriver : Node
{ {
private const float TickSeconds = 0.1f; private const float TickSeconds = 0.1f;
// The battle opens with an intro animation (the backdrop zooms in): shots wait it out.
private const int BattleIntroTicks = 20;
private const float TimeoutSeconds = 90f; private const float TimeoutSeconds = 90f;
private const string Village = "rpg_village"; private const string Village = "rpg_village";
private const string House = "rpg_elder_house"; private const string House = "rpg_elder_house";
@@ -243,8 +246,8 @@ namespace Cthangover.Mods.Rpg.Tests
// core waits for the player, take the platform's victory path. // core waits for the player, take the platform's victory path.
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput) if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
return; return;
// The shot waits for the fade-in too, otherwise it catches the blank transition. // The shot waits for the fade-in and the intro animation, otherwise it catches the zoomed backdrop.
if (GodotSceneService.IsTransitioning) if (GodotSceneService.IsTransitioning || ++waitTicks < BattleIntroTicks)
return; return;
Shot("battle"); Shot("battle");
battle.ShowWinground(); battle.ShowWinground();
+64
View File
@@ -0,0 +1,64 @@
namespace Cthangover.Mods.Rpg.Tests
{
using Cthangover.Core.Mods.Testing;
using Godot;
/// <summary>Autotile cells are assembled from quarters of the A2 block picked by their neighbours.</summary>
public class RpgAutotilesTests
{
private const int Water = 40;
private static bool Is(Vector2I[] quarters, params int[] xy)
{
for (var i = 0; i < 4; i++)
{
if (quarters[i] != new Vector2I(xy[i * 2], xy[i * 2 + 1]))
return false;
}
return true;
}
[ModTest("autotiles/quarters_by_neighbours")]
static void QuartersByNeighbours(ModTestContext ctx)
{
ModAssert.True(Is(RpgAutotiles.Quarters(false, false, false, false, false, false, false, false), 0, 2, 3, 2, 0, 5, 3, 5),
"alone: the four outer corners");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, true, true, true, true), 2, 4, 1, 4, 2, 3, 1, 3),
"surrounded: the fill of the patch");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, true, true, false, false, false, false), 2, 0, 3, 0, 2, 1, 3, 1),
"sides alike, corners not: the inner corners");
ModAssert.True(Is(RpgAutotiles.Quarters(false, true, true, true, false, false, true, true), 2, 2, 1, 2, 2, 3, 1, 3),
"the top row of a pond: top edge above, fill below");
ModAssert.True(Is(RpgAutotiles.Quarters(true, true, false, true, false, true, false, true), 0, 4, 1, 4, 0, 3, 1, 3),
"the left column: left edge");
var corner = RpgAutotiles.Quarters(true, true, true, true, false, true, true, true);
ModAssert.True(corner[0] == new Vector2I(2, 0) && corner[3] == new Vector2I(1, 3), "only the missing corner turns inner");
}
[ModTest("autotiles/cells_read_their_layer_and_the_map_edge_counts_alike")]
static void QuartersAt(ModTestContext ctx)
{
var grid = new RpgMapGrid(3, 3);
grid.SetTile(RpgLayer.Back, 0, 0, Water);
grid.SetTile(RpgLayer.Back, 1, 0, Water);
grid.SetTile(RpgLayer.Main, 0, 1, Water);
var corner = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 0, 0, Water);
ModAssert.True(corner[0] == new Vector2I(2, 4), "off the map counts as water: the top-left quarter is fill");
ModAssert.True(corner[2] == new Vector2I(2, 5), "below lies grass on this layer: bottom edge (the main layer is not read)");
ModAssert.True(corner[1] == new Vector2I(1, 4), "to the right is water");
var end = RpgAutotiles.QuartersAt(grid, RpgLayer.Back, 1, 0, Water);
ModAssert.True(end[1] == new Vector2I(3, 4), "the right end: right edge");
ModAssert.True(end[3] == new Vector2I(3, 5), "and its outer bottom-right corner");
}
[ModTest("autotiles/source_and_preview_rects")]
static void Rects(ModTestContext ctx)
{
ModAssert.True(RpgAutotiles.SourceRect(new Vector2I(3, 5), 32) == new Rect2(48, 80, 16, 16), "quarters are half tiles");
ModAssert.True(RpgAutotiles.SourceRect(Vector2I.Zero, 48) == new Rect2(0, 0, 24, 24), "of any tile size");
ModAssert.True(RpgAutotiles.PreviewRect(32) == new Rect2(0, 0, 32, 32), "the preview is the top-left tile");
}
}
}
+23 -1
View File
@@ -7,7 +7,7 @@ namespace Cthangover.Mods.Rpg.Tests
using Cthangover.Core.Utils; using Cthangover.Core.Utils;
using Godot; using Godot;
/// <summary>Event pages pick the last page whose condition holds; routes resolve their steps.</summary> /// <summary>Event pages pick the last page whose condition holds; routes resolve their steps; a page light replaces the event's.</summary>
public class RpgEventPagesTests public class RpgEventPagesTests
{ {
private static RpgMapEvent Chest() => JsonSerializer.Deserialize<RpgMapEvent>(@"{ private static RpgMapEvent Chest() => JsonSerializer.Deserialize<RpgMapEvent>(@"{
@@ -87,5 +87,27 @@ namespace Cthangover.Mods.Rpg.Tests
ModAssert.True(mapEvent.Route.Repeat, "repeats by default"); ModAssert.True(mapEvent.Route.Repeat, "repeats by default");
ModAssert.Null(mapEvent.Pages, "no pages"); ModAssert.Null(mapEvent.Pages, "no pages");
} }
[ModTest("lights/page_light_over_event_light_round_trip")]
static void PageLightOverEventLight(ModTestContext ctx)
{
var lamp = JsonSerializer.Deserialize<RpgMapEvent>(@"{
""Id"": ""lamp"", ""Tile"": 12, ""Light"": { ""Radius"": 2.5, ""Color"": ""#ff8040"" },
""Pages"": [ { ""Condition"": ""lamp_out == 1"", ""Tile"": 13, ""Light"": { ""Radius"": 1 } }, { ""Condition"": ""door == 1"" } ]
}", JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(2.5, lamp.Light.Radius, what: "the event's light");
ModAssert.Equal("#ff8040", lamp.Light.Color, "the event's colour");
var dim = RpgEventPages.Effective(lamp, 1);
ModAssert.CloseTo(1, dim.Light.Radius, what: "the page's light replaces it");
ModAssert.Equal("#ffd8a0", dim.Light.Color, "a page light without a colour is warm by default");
ModAssert.CloseTo(2.5, RpgEventPages.Effective(lamp, 2).Light.Radius, what: "a page without a light keeps the event's");
var again = JsonSerializer.Deserialize<RpgMapEvent>(JsonSerializer.Serialize(lamp), JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(2.5, again.Light.Radius, what: "radius survives the round trip");
ModAssert.Equal("#ff8040", again.Light.Color, "colour survives the round trip");
ModAssert.CloseTo(1, again.Pages[0].Light.Radius, what: "the page light survives the round trip");
ModAssert.Null(JsonSerializer.Deserialize<RpgMapEvent>(@"{ ""Id"": ""dark"" }", JsonOptionsProvider.ReadOptions).Light, "no light by default");
}
} }
} }
+59
View File
@@ -0,0 +1,59 @@
namespace Cthangover.Mods.Rpg.Tests
{
using System.Collections.Generic;
using System.Text.Json;
using Cthangover.Core.Mods.Testing;
using Cthangover.Core.Utils;
using Godot;
/// <summary>The map's light: ambient weights from the clock, lit tiles, the fixed ambient of a map.</summary>
public class RpgLightingTests
{
[ModTest("lights/weights_follow_clock_dark_mode_and_switch")]
static void WeightsFollowClock(ModTestContext ctx)
{
ModAssert.CloseTo(1, RpgLighting.Weights(13f, false, true).Y, what: "noon is day");
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, true).W, what: "midnight is night");
ModAssert.CloseTo(1, RpgLighting.Weights(13f, true, true).W, what: "dark mode is night at noon");
ModAssert.CloseTo(1, RpgLighting.Weights(0.5f, false, false).Y, what: "lighting switched off keeps day at midnight");
var dawn = RpgLighting.Weights(5f, false, true);
ModAssert.CloseTo(1, dawn.X + dawn.W, what: "dawn blends night into morning");
ModAssert.True(dawn.X > 0 && dawn.W > 0, "both phases at dawn");
}
[ModTest("lights/tile_lights_on_any_layer_once_per_cell")]
static void TileLights(ModTestContext ctx)
{
var tileset = new RpgTileset
{
ID = "lit",
Tiles = new List<RpgTileInfo>
{
new() { Index = 3, Light = new RpgLight { Radius = 4 } },
new() { Index = 7, Light = new RpgLight { Radius = 1.5f, Color = "#ffd27a" } },
new() { Index = 9, Solid = true },
},
};
var grid = new RpgMapGrid(4, 3);
grid.SetTile(RpgLayer.Front, 1, 0, 3);
grid.SetTile(RpgLayer.Main, 2, 2, 7);
grid.SetTile(RpgLayer.Back, 2, 2, 3);
grid.SetTile(RpgLayer.Main, 0, 1, 9);
var lights = RpgLighting.TileLights(grid, tileset);
ModAssert.Equal(2, lights.Count, "one light per lit cell, unlit tiles give none");
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(1, 0) && Mathf.IsEqualApprox(l.Light.Radius, 4f)), "a front-layer lamp");
ModAssert.True(lights.Exists(l => l.Cell == new Vector2I(2, 2)), "a cell lit on two layers counts once");
}
[ModTest("lights/map_ambient_json")]
static void MapAmbientJson(ModTestContext ctx)
{
var room = JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""room"", ""Ambient"": ""#e8dcc8"" }", JsonOptionsProvider.ReadOptions);
ModAssert.Equal("#e8dcc8", room.Ambient, "a lit room");
var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(room), JsonOptionsProvider.ReadOptions);
ModAssert.Equal("#e8dcc8", again.Ambient, "ambient survives the round trip");
ModAssert.Null(JsonSerializer.Deserialize<RpgMap>(@"{ ""ID"": ""field"" }", JsonOptionsProvider.ReadOptions).Ambient, "outdoors by default");
}
}
}
+20
View File
@@ -53,6 +53,26 @@ namespace Cthangover.Mods.Rpg.Tests
ModAssert.False(tileset.IsSolid(-1), "empty cell"); ModAssert.False(tileset.IsSolid(-1), "empty cell");
} }
[ModTest("rules/tileset_light_and_autotile_lookup_round_trip")]
static void TilesetLightAndAutotileLookup(ModTestContext ctx)
{
var tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(@"{
""Id"": ""lit"", ""Tiles"": [
{ ""Index"": 29, ""Tag"": ""lamp_top"", ""Light"": { ""Radius"": 4, ""Color"": ""#ffcf80"" } },
{ ""Index"": 40, ""Solid"": true, ""Tag"": ""water"", ""Autotile"": ""village/water_auto"" }
] }", Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
tileset = System.Text.Json.JsonSerializer.Deserialize<RpgTileset>(System.Text.Json.JsonSerializer.Serialize(tileset), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
ModAssert.CloseTo(4, tileset.LightOf(29).Radius, what: "the lamp's light survives the round trip");
ModAssert.Equal("#ffcf80", tileset.LightOf(29).Color, "with its colour");
ModAssert.Null(tileset.LightOf(40), "the water gives no light");
ModAssert.Null(tileset.LightOf(-1), "an empty cell gives no light");
ModAssert.Equal("village/water_auto", tileset.AutotileOf(40)?.Autotile, "the autotile entry and its block");
ModAssert.Null(tileset.AutotileOf(29), "a plain tile is no autotile");
ModAssert.Null(tileset.AutotileOf(-1), "nor an empty cell");
ModAssert.True(tileset.IsSolid(40), "an autotile is solid like any tile");
}
[ModTest("rules/encounter_off_without_table")] [ModTest("rules/encounter_off_without_table")]
static void EncounterOffWithoutTable(ModTestContext ctx) static void EncounterOffWithoutTable(ModTestContext ctx)
{ {