From 88ab26dceccef1720840bb6d6bc0f1fc1964528f Mon Sep 17 00:00:00 2001 From: iisaev Date: Sat, 24 Oct 2026 12:56:08 +0200 Subject: [PATCH] Map lighting by the time of day, lit tiles and events; A2 autotiles --- manifest.json | 2 +- shaders/rpg_lit.gdshader | 47 ++++++++ src/Data/RpgAutotiles.cs | 84 +++++++++++++ src/Data/RpgEventPage.cs | 3 + src/Data/RpgEventPages.cs | 1 + src/Data/RpgLight.cs | 16 +++ src/Data/RpgMap.cs | 6 + src/Data/RpgMapEvent.cs | 3 + src/Data/RpgTileInfo.cs | 9 ++ src/Data/RpgTileset.cs | 24 ++++ src/World/RpgActor.cs | 5 +- src/World/RpgAutotileLayer.cs | 50 ++++++++ src/World/RpgLighting.cs | 137 ++++++++++++++++++++++ src/World/RpgWorldView.cs | 22 +++- test_suites/walk/src/RpgWalkTestDriver.cs | 7 +- tests/RpgAutotilesTests.cs | 64 ++++++++++ tests/RpgEventPagesTests.cs | 24 +++- tests/RpgLightingTests.cs | 59 ++++++++++ tests/RpgRulesTests.cs | 20 ++++ 19 files changed, 573 insertions(+), 10 deletions(-) create mode 100644 shaders/rpg_lit.gdshader create mode 100644 src/Data/RpgAutotiles.cs create mode 100644 src/Data/RpgLight.cs create mode 100644 src/World/RpgAutotileLayer.cs create mode 100644 src/World/RpgLighting.cs create mode 100644 tests/RpgAutotilesTests.cs create mode 100644 tests/RpgLightingTests.cs 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) {