diff --git a/manifest.json b/manifest.json
index e27943c..1624c6f 100644
--- a/manifest.json
+++ b/manifest.json
@@ -11,7 +11,7 @@
"test_sources": [
"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": [
{"id": "walk", "activate": ["--itest=rpg_walk"]}
]
diff --git a/shaders/rpg_lit.gdshader b/shaders/rpg_lit.gdshader
new file mode 100644
index 0000000..138a4a9
--- /dev/null
+++ b/shaders/rpg_lit.gdshader
@@ -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);
+}
diff --git a/src/Data/RpgAutotiles.cs b/src/Data/RpgAutotiles.cs
new file mode 100644
index 0000000..d2ebb87
--- /dev/null
+++ b/src/Data/RpgAutotiles.cs
@@ -0,0 +1,84 @@
+using Godot;
+
+namespace Cthangover.Mods.Rpg
+{
+ ///
+ /// 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
+ /// (); its index is any number outside the atlas.
+ ///
+ /// 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 () and the map editor draw
+ /// through and , so both show the same cells.
+ ///
+ public static class RpgAutotiles
+ {
+ ///
+ /// 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.
+ ///
+ 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),
+ };
+ }
+
+ ///
+ /// The quarters of the cell (, ) of
+ /// , whose tile is the autotile .
+ ///
+ 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));
+ }
+
+ /// Pixel rectangle of a quarter in the block picture of a tileset with .
+ public static Rect2 SourceRect(Vector2I quarter, int tileSize)
+ {
+ var half = tileSize / 2f;
+ return new Rect2(quarter.X * half, quarter.Y * half, half, half);
+ }
+
+ /// Draws a cell of an autotile into on .
+ 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);
+ }
+ }
+
+ /// Source rectangle of the block's preview tile (its top-left tile), for palettes.
+ 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;
+ }
+}
diff --git a/src/Data/RpgEventPage.cs b/src/Data/RpgEventPage.cs
index ad22250..601b821 100644
--- a/src/Data/RpgEventPage.cs
+++ b/src/Data/RpgEventPage.cs
@@ -43,5 +43,8 @@ namespace Cthangover.Mods.Rpg
/// Autonomous movement of the page.
public RpgMoveRoute Route { get; set; }
+
+ /// Light of the page (a lamp lit by a page).
+ public RpgLight Light { get; set; }
}
}
diff --git a/src/Data/RpgEventPages.cs b/src/Data/RpgEventPages.cs
index b9c4da3..6ce7995 100644
--- a/src/Data/RpgEventPages.cs
+++ b/src/Data/RpgEventPages.cs
@@ -60,6 +60,7 @@ namespace Cthangover.Mods.Rpg
Commands = page.Commands ?? mapEvent.Commands,
Teleport = page.Teleport ?? mapEvent.Teleport,
Route = page.Route ?? mapEvent.Route,
+ Light = page.Light ?? mapEvent.Light,
Condition = page.Condition,
ModId = mapEvent.ModId,
File = mapEvent.File,
diff --git a/src/Data/RpgLight.cs b/src/Data/RpgLight.cs
new file mode 100644
index 0000000..120a1eb
--- /dev/null
+++ b/src/Data/RpgLight.cs
@@ -0,0 +1,16 @@
+namespace Cthangover.Mods.Rpg
+{
+ ///
+ /// A light an event gives off (a lamp, a fire, a lit window), drawn by
+ /// . It brightens the cells around the event towards their
+ /// own colours, so it shows at dusk and at night and is lost in daylight.
+ ///
+ public class RpgLight
+ {
+ /// Reach in cells from the event's centre; the light fades to nothing there.
+ public float Radius { get; set; } = 3f;
+
+ /// Colour (#rrggbb, optionally #rrggbbaa: alpha is the strength).
+ public string Color { get; set; } = "#ffd8a0";
+ }
+}
diff --git a/src/Data/RpgMap.cs b/src/Data/RpgMap.cs
index dcf7075..51bd695 100644
--- a/src/Data/RpgMap.cs
+++ b/src/Data/RpgMap.cs
@@ -55,5 +55,11 @@ namespace Cthangover.Mods.Rpg
/// Random battles; null for a safe map.
public RpgEncounterTable Encounters { get; set; }
+
+ ///
+ /// Fixed light colour of the map (#rrggbb), e.g. a lit room; null = the time of day
+ /// lights the map ().
+ ///
+ public string Ambient { get; set; }
}
}
diff --git a/src/Data/RpgMapEvent.cs b/src/Data/RpgMapEvent.cs
index 4e820a2..8612205 100644
--- a/src/Data/RpgMapEvent.cs
+++ b/src/Data/RpgMapEvent.cs
@@ -74,6 +74,9 @@ namespace Cthangover.Mods.Rpg
/// Autonomous movement (); null = the event stands still.
public RpgMoveRoute Route { get; set; }
+ /// Light the event gives off (); null = none.
+ public RpgLight Light { get; set; }
+
/// Mod the event was read from (set by the event resolver, not stored).
[JsonIgnore]
public string ModId { get; set; }
diff --git a/src/Data/RpgTileInfo.cs b/src/Data/RpgTileInfo.cs
index 6bb2f95..ade1666 100644
--- a/src/Data/RpgTileInfo.cs
+++ b/src/Data/RpgTileInfo.cs
@@ -21,5 +21,14 @@ namespace Cthangover.Mods.Rpg
/// "tree_crown"). Not a locale key: players never see it.
///
public string Tag { get; set; }
+
+ /// Light the tile gives off wherever it lies on a map (a lamp, a lit window); null = none.
+ public RpgLight Light { get; set; }
+
+ ///
+ /// Picture of an autotile block (RPG Maker A2 layout, see ) in the
+ /// tilesets group; set, the entry is an autotile and should lie outside the atlas.
+ ///
+ public string Autotile { get; set; }
}
}
diff --git a/src/Data/RpgTileset.cs b/src/Data/RpgTileset.cs
index c981e36..d00c5ca 100644
--- a/src/Data/RpgTileset.cs
+++ b/src/Data/RpgTileset.cs
@@ -34,6 +34,30 @@ namespace Cthangover.Mods.Rpg
/// Per-cell properties; cells without an entry are walkable.
public List Tiles { get; set; } = new();
+ /// The autotile entry with this index ( set), or null.
+ public RpgTileInfo AutotileOf(int index)
+ {
+ if (index < 0)
+ return null;
+ foreach (var tile in Tiles ?? new List())
+ {
+ if (tile.Index == index && !string.IsNullOrEmpty(tile.Autotile))
+ return tile;
+ }
+ return null;
+ }
+
+ /// Light of the atlas cell (), or null.
+ public RpgLight LightOf(int index)
+ {
+ foreach (var tile in Tiles ?? new List())
+ {
+ if (tile.Index == index && tile.Light != null)
+ return tile.Light;
+ }
+ return null;
+ }
+
/// True when the atlas cell blocks walking. Built once from .
public bool IsSolid(int index)
{
diff --git a/src/World/RpgActor.cs b/src/World/RpgActor.cs
index 7e78ed5..b0f5a6f 100644
--- a/src/World/RpgActor.cs
+++ b/src/World/RpgActor.cs
@@ -15,6 +15,7 @@ namespace Cthangover.Mods.Rpg
/// . The actor's 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.
+ /// The sprite uses the actor's material (the map's light, set by the view).
///
public partial class RpgActor : Node2D
{
@@ -78,7 +79,7 @@ namespace Cthangover.Mods.Rpg
{
if (texture == null)
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);
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
AddChild(sprite);
@@ -94,6 +95,7 @@ namespace Cthangover.Mods.Rpg
{
Texture = atlas,
Centered = false,
+ UseParentMaterial = true,
RegionEnabled = true,
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
};
@@ -115,6 +117,7 @@ namespace Cthangover.Mods.Rpg
Position = new Vector2(tileSize, tileSize) / 2f,
Scale = new Vector2(fit, fit),
TextureFilter = TextureFilterEnum.Linear,
+ UseParentMaterial = true,
};
AddChild(sprite);
}
diff --git a/src/World/RpgAutotileLayer.cs b/src/World/RpgAutotileLayer.cs
new file mode 100644
index 0000000..1c482fb
--- /dev/null
+++ b/src/World/RpgAutotileLayer.cs
@@ -0,0 +1,50 @@
+using System.Collections.Generic;
+using Godot;
+
+namespace Cthangover.Mods.Rpg
+{
+ ///
+ /// The autotile cells of one map layer, drawn as a child of that layer's
+ /// — 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 (), so the cells and their
+ /// quarters are worked out once when the map is built and drawn by _Draw, which
+ /// Godot caches until the next redraw.
+ ///
+ public partial class RpgAutotileLayer : Node2D
+ {
+ private readonly List<(Rect2 Cell, Texture2D Block, Vector2I[] Quarters)> cells = new();
+ private readonly int tileSize;
+
+ /// Collects the autotile cells of ; cells whose picture is missing stay empty.
+ 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)));
+ }
+ }
+ }
+
+ /// Number of drawn autotile cells.
+ public int Count => cells.Count;
+
+ /// Draws every collected cell.
+ public override void _Draw()
+ {
+ foreach (var (cell, block, quarters) in cells)
+ RpgAutotiles.Draw(this, block, cell, quarters, tileSize, Colors.White);
+ }
+ }
+}
diff --git a/src/World/RpgLighting.cs b/src/World/RpgLighting.cs
new file mode 100644
index 0000000..91d760d
--- /dev/null
+++ b/src/World/RpgLighting.cs
@@ -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
+{
+ ///
+ /// Light of a mounted map. One rpg_lit 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.
+ ///
+ /// The ambient follows the game clock with the phases and cross-fades of the novel
+ /// backgrounds (; dark mode forces night,
+ /// the light switch off keeps day), unless the map fixes its own (,
+ /// a lit room). The lights are the player's own glow, the tiles whose tileset entry has a
+ /// (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 . They are pushed every frame in canvas
+ /// pixels, because the view moves with the player and actors walk.
+ ///
+ public sealed class RpgLighting
+ {
+ /// Lights the shader takes (its arrays' size).
+ public const int MaxLights = 16;
+
+ /// Reach of the player's glow in cells.
+ 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();
+
+ /// The shared material; null when the shader is missing (the map is drawn unlit).
+ public ShaderMaterial Material { get; }
+
+ /// Creates the material for , applies its fixed ambient and collects the lit tiles.
+ 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));
+ }
+
+ ///
+ /// 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.
+ ///
+ public static Vector4 Weights(float hour, bool darkMode, bool useLight)
+ {
+ if (darkMode)
+ return Night;
+ if (!useLight)
+ return Day;
+ return UiLightController.CalculateTimeWeights(hour);
+ }
+
+ /// Cells of whose tile (on any layer) gives off light, with that light.
+ 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;
+ }
+
+ /// Pushes the ambient of the current hour and the lights of .
+ public void Update(RpgWorldView view, IEnumerable 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);
+ }
+ }
+}
diff --git a/src/World/RpgWorldView.cs b/src/World/RpgWorldView.cs
index d3486ce..865e911 100644
--- a/src/World/RpgWorldView.cs
+++ b/src/World/RpgWorldView.cs
@@ -12,7 +12,8 @@ namespace Cthangover.Mods.Rpg
/// scene is entered and freed when the scene is left.
///
/// 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 (). 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.
@@ -23,6 +24,9 @@ namespace Cthangover.Mods.Rpg
/// 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.
+ ///
+ /// Layers and actors share the material of (time of day, the
+ /// player's glow, lit events), updated after the camera each frame.
///
public partial class RpgWorldView : Node2D
{
@@ -32,6 +36,7 @@ namespace Cthangover.Mods.Rpg
private RpgMapGrid grid;
private Node2D actorLayer;
private Texture2D atlas;
+ private RpgLighting lighting;
/// The mounted map.
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);
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : 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.Main, tileSet));
@@ -70,7 +76,7 @@ namespace Cthangover.Mods.Rpg
AddEventActor(mapEvent);
RefreshEvents();
- Player = new RpgActor(TileSize) { Name = "Player" };
+ Player = new RpgActor(TileSize) { Name = "Player", Material = lighting.Material };
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
Player.StepFinished += OnPlayerStepFinished;
actorLayer.AddChild(Player);
@@ -178,6 +184,7 @@ namespace Cthangover.Mods.Rpg
}
}
UpdateCamera();
+ lighting.Update(this, eventActors.Values);
}
/// The action key: advances a running dialog, otherwise interacts.
@@ -217,7 +224,7 @@ namespace Cthangover.Mods.Rpg
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)
return node;
for (var y = 0; y < grid.Height; y++)
@@ -225,10 +232,15 @@ namespace Cthangover.Mods.Rpg
for (var x = 0; x < grid.Width; x++)
{
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));
}
}
+ var autotiles = new RpgAutotileLayer(grid, layer, Tileset, textures);
+ if (autotiles.Count > 0)
+ node.AddChild(autotiles);
+ else
+ autotiles.Free();
return node;
}
@@ -241,7 +253,7 @@ namespace Cthangover.Mods.Rpg
}
// 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);
eventActors[mapEvent.Id] = actor;
}
diff --git a/test_suites/walk/src/RpgWalkTestDriver.cs b/test_suites/walk/src/RpgWalkTestDriver.cs
index f112fd9..aef4cc1 100644
--- a/test_suites/walk/src/RpgWalkTestDriver.cs
+++ b/test_suites/walk/src/RpgWalkTestDriver.cs
@@ -34,6 +34,9 @@ namespace Cthangover.Mods.Rpg.Tests
public partial class RpgWalkTestDriver : Node
{
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 string Village = "rpg_village";
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.
if (battle.ActiveBattleCore is not IBattleAutoPlayable auto || !auto.IsWaitingForPlayerInput)
return;
- // The shot waits for the fade-in too, otherwise it catches the blank transition.
- if (GodotSceneService.IsTransitioning)
+ // The shot waits for the fade-in and the intro animation, otherwise it catches the zoomed backdrop.
+ if (GodotSceneService.IsTransitioning || ++waitTicks < BattleIntroTicks)
return;
Shot("battle");
battle.ShowWinground();
diff --git a/tests/RpgAutotilesTests.cs b/tests/RpgAutotilesTests.cs
new file mode 100644
index 0000000..2011128
--- /dev/null
+++ b/tests/RpgAutotilesTests.cs
@@ -0,0 +1,64 @@
+namespace Cthangover.Mods.Rpg.Tests
+{
+ using Cthangover.Core.Mods.Testing;
+ using Godot;
+
+ /// Autotile cells are assembled from quarters of the A2 block picked by their neighbours.
+ 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");
+ }
+ }
+}
diff --git a/tests/RpgEventPagesTests.cs b/tests/RpgEventPagesTests.cs
index 3c691ec..c182b79 100644
--- a/tests/RpgEventPagesTests.cs
+++ b/tests/RpgEventPagesTests.cs
@@ -7,7 +7,7 @@ namespace Cthangover.Mods.Rpg.Tests
using Cthangover.Core.Utils;
using Godot;
- /// Event pages pick the last page whose condition holds; routes resolve their steps.
+ /// Event pages pick the last page whose condition holds; routes resolve their steps; a page light replaces the event's.
public class RpgEventPagesTests
{
private static RpgMapEvent Chest() => JsonSerializer.Deserialize(@"{
@@ -87,5 +87,27 @@ namespace Cthangover.Mods.Rpg.Tests
ModAssert.True(mapEvent.Route.Repeat, "repeats by default");
ModAssert.Null(mapEvent.Pages, "no pages");
}
+
+ [ModTest("lights/page_light_over_event_light_round_trip")]
+ static void PageLightOverEventLight(ModTestContext ctx)
+ {
+ var lamp = JsonSerializer.Deserialize(@"{
+ ""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(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(@"{ ""Id"": ""dark"" }", JsonOptionsProvider.ReadOptions).Light, "no light by default");
+ }
}
}
diff --git a/tests/RpgLightingTests.cs b/tests/RpgLightingTests.cs
new file mode 100644
index 0000000..b976efc
--- /dev/null
+++ b/tests/RpgLightingTests.cs
@@ -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;
+
+ /// The map's light: ambient weights from the clock, lit tiles, the fixed ambient of a map.
+ 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
+ {
+ 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(@"{ ""ID"": ""room"", ""Ambient"": ""#e8dcc8"" }", JsonOptionsProvider.ReadOptions);
+ ModAssert.Equal("#e8dcc8", room.Ambient, "a lit room");
+ var again = JsonSerializer.Deserialize(JsonSerializer.Serialize(room), JsonOptionsProvider.ReadOptions);
+ ModAssert.Equal("#e8dcc8", again.Ambient, "ambient survives the round trip");
+ ModAssert.Null(JsonSerializer.Deserialize(@"{ ""ID"": ""field"" }", JsonOptionsProvider.ReadOptions).Ambient, "outdoors by default");
+ }
+ }
+}
diff --git a/tests/RpgRulesTests.cs b/tests/RpgRulesTests.cs
index 03dc132..1553948 100644
--- a/tests/RpgRulesTests.cs
+++ b/tests/RpgRulesTests.cs
@@ -53,6 +53,26 @@ namespace Cthangover.Mods.Rpg.Tests
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(@"{
+ ""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(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")]
static void EncounterOffWithoutTable(ModTestContext ctx)
{