Draw broken objects as fragments, plane tiles for fences and walls, tileset publishing; mod documentation
This commit is contained in:
1 parent
e153dc9b15
commit
7b28c1bb0a
74 files changed
+2782
-886
No files matched your search
+10
-16
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
@@ -16,7 +17,7 @@ namespace Cthangover.Mods.Rpg
|
||||
/// 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
|
||||
/// without a border. The world adapter and map editor draw
|
||||
/// through <see cref="Quarters"/> and <see cref="Draw"/>, so both show the same cells.
|
||||
/// </summary>
|
||||
public static class RpgAutotiles
|
||||
@@ -27,15 +28,7 @@ namespace Cthangover.Mods.Rpg
|
||||
/// </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),
|
||||
};
|
||||
}
|
||||
=> Cthangover.Mods.TileRender.TileAutotile.Quarters(up, down, left, right, upLeft, upRight, downLeft, downRight);
|
||||
|
||||
/// <summary>
|
||||
/// The quarters of the cell (<paramref name="x"/>, <paramref name="y"/>) of
|
||||
@@ -71,12 +64,13 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <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);
|
||||
}
|
||||
/// <summary>
|
||||
/// Selects one A2 block from a sheet of adjacent 2×3 blocks by map position.
|
||||
/// A single-block image keeps its original behaviour. Larger sheets let a
|
||||
/// material span several cells without introducing seams or extra terrain ids.
|
||||
/// </summary>
|
||||
public static Rect2 VariantRect(Vector2I imageSize, int tileSize, Vector2I cell)
|
||||
=> Cthangover.Mods.TileRender.TileAutotile.VariantRect(imageSize, tileSize, cell);
|
||||
|
||||
private static bool Alike(RpgMapGrid grid, RpgLayer layer, int x, int y, int index)
|
||||
=> !grid.InBounds(x, y) || grid.GetTile(layer, x, y) == index;
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>
|
||||
/// Facing of an actor on the grid. The order is the row order of an RPG Maker
|
||||
/// style charset (down, left, right, up), so the value is also the frame row
|
||||
/// of <see cref="RpgActor"/>. JSON spells it as a word ("down", "Left").
|
||||
/// of <see cref="RpgActorState"/>. JSON spells it as a word ("down", "Left").
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum RpgDirection
|
||||
|
||||
+17
-1
@@ -6,9 +6,18 @@ namespace Cthangover.Mods.Rpg
|
||||
/// </summary>
|
||||
public class RpgTileInfo
|
||||
{
|
||||
/// <summary>Row-major atlas index: <c>row * Columns + column</c>.</summary>
|
||||
/// <summary>Logical map id; AtlasIndex or Surface can give it independent physical storage.</summary>
|
||||
public int Index { get; set; }
|
||||
|
||||
/// <summary>Physical cell in the object atlas; null preserves legacy row-major addressing.</summary>
|
||||
public int? AtlasIndex { get; set; }
|
||||
|
||||
/// <summary>Square material patch in the separate surface atlas; null denotes an object.</summary>
|
||||
public int? Surface { get; set; }
|
||||
|
||||
/// <summary>Blend this material with neighbouring surfaces; interiors can keep hard edges.</summary>
|
||||
public bool Diffuse { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// The tile blocks walking when it lies on the back or the main layer
|
||||
/// (front-layer tiles never block: the player walks under them). The map's
|
||||
@@ -30,5 +39,12 @@ namespace Cthangover.Mods.Rpg
|
||||
/// tilesets group; set, the entry is an autotile and <see cref="Index"/> should lie outside the atlas.
|
||||
/// </summary>
|
||||
public string Autotile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Thin upright object (fence, wall, railing) shown in the isometric view as a vertical plane along a map
|
||||
/// axis instead of a camera-facing sprite: "x", "y", or "auto" (along neighbouring plane tiles of the same
|
||||
/// layer, see <see cref="RpgTilePlanes"/>). Null keeps the sprite; the square view always shows the sprite.
|
||||
/// </summary>
|
||||
public string Plane { get; set; }
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -5,9 +5,10 @@ using Cthangover.Core.Factories;
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
/// <summary>
|
||||
/// A square-tile atlas: one image cut into <see cref="TileSize"/> cells,
|
||||
/// <see cref="Columns"/> per row, addressed by a row-major index. Maps store
|
||||
/// only these indices, so a pack can redraw its tiles without touching maps.
|
||||
/// A square-tile object atlas, optionally accompanied by a strip of surface materials.
|
||||
/// Maps store logical tile ids; AtlasIndex decouples those ids from compact object
|
||||
/// storage, while Surface selects a patch for runtime material diffusion. Legacy
|
||||
/// packs without these fields retain row-major image addressing.
|
||||
/// <para/>
|
||||
/// Files declare <c>"Kind": "rpg_tileset"</c> and live anywhere in a mod; the
|
||||
/// image is resolved in the <c>tilesets</c> file group (last mod wins), so a
|
||||
@@ -25,6 +26,15 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>Image path inside the <c>tilesets</c> group, extension optional.</summary>
|
||||
public string Image { get; set; }
|
||||
|
||||
/// <summary>Separate atlas of reusable surface patches, arranged in one horizontal strip.</summary>
|
||||
public string SurfaceImage { get; set; }
|
||||
|
||||
/// <summary>Width and height of one surface patch in pixels.</summary>
|
||||
public int SurfaceSize { get; set; } = 128;
|
||||
|
||||
/// <summary>Properties of a logical map tile, independent of physical atlas packing.</summary>
|
||||
public RpgTileInfo TileOf(int index) => Tiles?.Find(tile => tile.Index == index);
|
||||
|
||||
/// <summary>Edge of one square tile in image pixels.</summary>
|
||||
public int TileSize { get; set; } = 32;
|
||||
|
||||
@@ -34,6 +44,9 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>Per-cell properties; cells without an entry are walkable.</summary>
|
||||
public List<RpgTileInfo> Tiles { get; set; } = new();
|
||||
|
||||
/// <summary>Exact image groups for projection-independent whole objects; legacy sets may omit them.</summary>
|
||||
public List<RpgVisualGroup> VisualGroups { get; set; } = new();
|
||||
|
||||
/// <summary>The autotile entry with this index (<see cref="RpgTileInfo.Autotile"/> set), or null.</summary>
|
||||
public RpgTileInfo AutotileOf(int index)
|
||||
{
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
using System;
|
||||
using Cthangover.Core.Factories;
|
||||
using Cthangover.Core.Mods.Resolvers;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
/// <summary>Tilesets collected by the shared entity resolver (<c>"Kind": "rpg_tileset"</c>).</summary>
|
||||
/// <summary>
|
||||
/// Tilesets collected by the shared entity resolver (<c>"Kind": "rpg_tileset"</c>).
|
||||
/// <see cref="Publish"/> lets the tileset editor put a just-saved tileset into the resolver's cache,
|
||||
/// like <see cref="RpgMapFactory.Publish"/> does for maps.
|
||||
/// </summary>
|
||||
public class RpgTilesetFactory : FileFactory<RpgTileset>
|
||||
{
|
||||
private static readonly Lazy<RpgTilesetFactory> instance = new(() => new RpgTilesetFactory());
|
||||
|
||||
/// <summary>Shared instance.</summary>
|
||||
public static RpgTilesetFactory Instance => instance.Value;
|
||||
|
||||
/// <summary>Replaces the cached definition of <paramref name="tileset"/>'s id.</summary>
|
||||
public void Publish(RpgTileset tileset)
|
||||
{
|
||||
if (tileset != null && !string.IsNullOrEmpty(tileset.ID))
|
||||
Entities.Collect<RpgTileset>()[tileset.ID] = tileset;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
/// <summary>
|
||||
/// An exact legacy tile pattern describing one whole image. Matching never changes
|
||||
/// collision or event coordinates; partial patterns remain ordinary cells.
|
||||
/// </summary>
|
||||
public class RpgVisualGroup
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int[] Pattern { get; set; }
|
||||
public float AnchorX { get; set; }
|
||||
public float AnchorY { get; set; }
|
||||
/// <summary>Optional same-atlas architectural planes, instead of an upright whole image.</summary>
|
||||
public List<RpgVisualPlane> IsoPlanes { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
/// <summary>Rejects ambiguous source geometry before it reaches a renderer; validation never changes map cells.</summary>
|
||||
public static class RpgVisualGroupValidator
|
||||
{
|
||||
public static string Error(RpgVisualGroup group)
|
||||
{
|
||||
if (group == null || string.IsNullOrWhiteSpace(group.Id)) return "visual group requires an ID";
|
||||
if (group.Width <= 0 || group.Height <= 0 || group.Pattern?.Length != (long)group.Width * group.Height)
|
||||
return "visual group pattern dimensions differ";
|
||||
if (!float.IsFinite(group.AnchorX) || !float.IsFinite(group.AnchorY)) return "non-finite group anchor";
|
||||
foreach (var id in group.Pattern) if (id < 0) return "visual group contains an empty logical ID";
|
||||
if (group.IsoPlanes == null) return null;
|
||||
foreach (var plane in group.IsoPlanes)
|
||||
{
|
||||
if (plane?.Crop?.Length != 4 || plane.Points?.Length != 4) return "plane requires a crop and four corners";
|
||||
var crop = plane.Crop;
|
||||
if (crop[0] < 0 || crop[1] < 0 || crop[2] <= 0 || crop[3] <= 0 ||
|
||||
(long)crop[0] + crop[2] > group.Width || (long)crop[1] + crop[3] > group.Height) return "plane crop exceeds its source group";
|
||||
foreach (var point in plane.Points)
|
||||
{
|
||||
if (point?.Length != 3) return "plane corners require XYZ coordinates";
|
||||
foreach (var value in point) if (!float.IsFinite(value)) return "non-finite plane corner";
|
||||
}
|
||||
for (var axis = 0; axis < 3; axis++)
|
||||
if (Math.Abs(plane.Points[0][axis] + plane.Points[2][axis] - plane.Points[1][axis] - plane.Points[3][axis]) > 0.001f)
|
||||
return "plane corners must form an affine quad";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
/// <summary>Crop in group cells, and four local XYZ cell coordinates (TL/TR/BR/BL).</summary>
|
||||
public class RpgVisualPlane
|
||||
{
|
||||
public int[] Crop { get; set; }
|
||||
public float[][] Points { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
/// <summary>
|
||||
/// Something standing on a map cell: the player, an NPC, a chest. Drawn
|
||||
/// either from a charset — 3 frames by 4 facings, rows in
|
||||
/// <see cref="RpgDirection"/> order, the middle frame is the standing pose — or
|
||||
/// from one atlas tile. The frame is bottom-aligned and centred on the cell,
|
||||
/// so taller charsets overlap the cell above like in RPG Maker.
|
||||
/// <para/>
|
||||
/// Movement is grid based: <see cref="StepTo"/> starts a step to the next
|
||||
/// cell, the node slides there at <see cref="TilesPerSecond"/> and then raises
|
||||
/// <see cref="StepFinished"/>. The actor's <see cref="Cell"/> changes at the
|
||||
/// start of a step, so two actors never walk into the same cell. The actor
|
||||
/// layer sorts children by Y, which keeps whoever is lower in front.
|
||||
/// The sprite uses the actor's material (the map's light, set by the view).
|
||||
/// </summary>
|
||||
public partial class RpgActor : Node2D
|
||||
{
|
||||
/// <summary>Walking speed.</summary>
|
||||
public const float TilesPerSecond = 4.5f;
|
||||
|
||||
private static readonly int[] WalkCycle = { 0, 1, 2, 1 };
|
||||
|
||||
private readonly int tileSize;
|
||||
private Sprite2D sprite;
|
||||
private Vector2 from;
|
||||
private Vector2 to;
|
||||
private float progress = 1f;
|
||||
private float animationTime;
|
||||
private bool hasShadow;
|
||||
|
||||
/// <summary>Current speed in tiles/s; the player can dash without changing NPC routes.</summary>
|
||||
public float Speed { get; set; } = TilesPerSecond;
|
||||
|
||||
/// <summary>Raised when a step reaches its cell.</summary>
|
||||
public event Action<RpgActor> StepFinished;
|
||||
|
||||
/// <summary>Cell the actor stands on (or walks into).</summary>
|
||||
public Vector2I Cell { get; private set; }
|
||||
|
||||
/// <summary>Facing.</summary>
|
||||
public RpgDirection Facing { get; private set; }
|
||||
|
||||
/// <summary>True while a step is in progress.</summary>
|
||||
public bool IsMoving => progress < 1f;
|
||||
|
||||
/// <summary>The map event this actor shows (its definition, all pages); null for the player.</summary>
|
||||
public RpgMapEvent Event { get; }
|
||||
|
||||
/// <summary>The event as its active page shows it (<see cref="RpgEventPages.Effective"/>); null while no page is active.</summary>
|
||||
public RpgMapEvent Current { get; set; }
|
||||
|
||||
/// <summary>Index of the active page (<see cref="RpgEventPages.ActiveIndex"/>); -2 before the first refresh.</summary>
|
||||
public int PageIndex { get; set; } = -2;
|
||||
|
||||
/// <summary>Next step of the active page's route.</summary>
|
||||
public int RouteIndex { get; set; }
|
||||
|
||||
/// <summary>Seconds until the next route step.</summary>
|
||||
public double RouteTimer { get; set; }
|
||||
|
||||
/// <summary>Creates an actor for a cell grid of <paramref name="tileSize"/> pixels.</summary>
|
||||
public RpgActor(int tileSize, RpgMapEvent mapEvent = null)
|
||||
{
|
||||
this.tileSize = tileSize;
|
||||
Event = mapEvent;
|
||||
}
|
||||
|
||||
/// <summary>Removes the shown charset, tile or icon (a page change shows another one).</summary>
|
||||
public void ClearLook()
|
||||
{
|
||||
if (sprite == null)
|
||||
return;
|
||||
hasShadow = false;
|
||||
QueueRedraw();
|
||||
sprite.QueueFree();
|
||||
sprite = null;
|
||||
}
|
||||
|
||||
/// <summary>Shows a charset; a null texture leaves the actor invisible (an invisible trigger).</summary>
|
||||
public void UseCharset(Texture2D texture)
|
||||
{
|
||||
if (texture == null)
|
||||
return;
|
||||
hasShadow = true;
|
||||
QueueRedraw();
|
||||
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);
|
||||
UpdateFrame();
|
||||
}
|
||||
|
||||
/// <summary>Shows one atlas cell of the map's tileset.</summary>
|
||||
public void UseTile(Texture2D atlas, Vector2I atlasCoords)
|
||||
{
|
||||
if (atlas == null)
|
||||
return;
|
||||
sprite = new Sprite2D
|
||||
{
|
||||
Texture = atlas,
|
||||
Centered = false,
|
||||
UseParentMaterial = true,
|
||||
RegionEnabled = true,
|
||||
RegionRect = new Rect2(atlasCoords * tileSize, new Vector2(tileSize, tileSize)),
|
||||
};
|
||||
AddChild(sprite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an item picture fitted into three quarters of the cell and centred on it,
|
||||
/// so an icon of any size lies on the ground like a tile-sized object.
|
||||
/// </summary>
|
||||
public void UseIcon(Texture2D texture)
|
||||
{
|
||||
if (texture == null)
|
||||
return;
|
||||
var fit = tileSize * 0.75f / Mathf.Max(1, Mathf.Max(texture.GetWidth(), texture.GetHeight()));
|
||||
sprite = new Sprite2D
|
||||
{
|
||||
Texture = texture,
|
||||
Position = new Vector2(tileSize, tileSize) / 2f,
|
||||
Scale = new Vector2(fit, fit),
|
||||
TextureFilter = TextureFilterEnum.Linear,
|
||||
UseParentMaterial = true,
|
||||
};
|
||||
AddChild(sprite);
|
||||
}
|
||||
|
||||
/// <summary>Puts the actor on a cell at once, stopping any step.</summary>
|
||||
public void Place(Vector2I cell, RpgDirection facing)
|
||||
{
|
||||
Cell = cell;
|
||||
Facing = facing;
|
||||
progress = 1f;
|
||||
Position = CellPosition(cell);
|
||||
UpdateFrame();
|
||||
}
|
||||
|
||||
/// <summary>Turns without moving.</summary>
|
||||
public void Face(RpgDirection facing)
|
||||
{
|
||||
Facing = facing;
|
||||
UpdateFrame();
|
||||
}
|
||||
|
||||
/// <summary>Starts a step to the neighbour cell; the caller has checked it is free.</summary>
|
||||
public void StepTo(RpgDirection direction)
|
||||
{
|
||||
Facing = direction;
|
||||
from = CellPosition(Cell);
|
||||
Cell += RpgDirections.Delta(direction);
|
||||
to = CellPosition(Cell);
|
||||
progress = 0f;
|
||||
}
|
||||
|
||||
/// <summary>Advances the step and the walk animation.</summary>
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (!IsMoving)
|
||||
return;
|
||||
|
||||
progress = Mathf.Min(1f, progress + (float)delta * Math.Clamp(Speed, 1f, 12f));
|
||||
animationTime += (float)delta * Math.Clamp(Speed, 1f, 12f) * 2f;
|
||||
Position = from.Lerp(to, progress);
|
||||
UpdateFrame();
|
||||
if (!IsMoving)
|
||||
StepFinished?.Invoke(this);
|
||||
}
|
||||
|
||||
/// <summary>A soft contact shadow anchors charsets to the ground without changing their art.</summary>
|
||||
public override void _Draw()
|
||||
{
|
||||
if (!hasShadow)
|
||||
return;
|
||||
DrawSetTransform(new Vector2(tileSize / 2f, tileSize - 3), 0, new Vector2(1, 0.32f));
|
||||
DrawCircle(Vector2.Zero, tileSize * 0.28f, new Color(0.04f, 0.09f, 0.1f, 0.28f));
|
||||
DrawSetTransform(Vector2.Zero);
|
||||
}
|
||||
|
||||
private Vector2 CellPosition(Vector2I cell) => new(cell.X * tileSize, cell.Y * tileSize);
|
||||
|
||||
private void UpdateFrame()
|
||||
{
|
||||
if (sprite == null || sprite.Hframes != 3)
|
||||
return;
|
||||
var column = IsMoving ? WalkCycle[(int)animationTime % WalkCycle.Length] : 1;
|
||||
sprite.Frame = (int)Facing * 3 + column;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// An actor in cell coordinates, independent of nodes and textures. The destination
|
||||
/// is reserved at step start; renderers interpolate Position without owning the clock.
|
||||
/// This transient state is rebuilt on map entry, while RpgState owns saved position.
|
||||
/// </summary>
|
||||
public class RpgActorState
|
||||
{
|
||||
/// <summary>Normal walking speed in cells per second.</summary>
|
||||
public const float TilesPerSecond = 4.5f;
|
||||
|
||||
private Vector2 from;
|
||||
private float progress = 1f;
|
||||
|
||||
/// <summary>Definition and all its pages, or null for the player.</summary>
|
||||
public RpgMapEvent Event { get; }
|
||||
|
||||
/// <summary>Effective event page, resolved by the world session.</summary>
|
||||
public RpgMapEvent Current { get; internal set; }
|
||||
|
||||
/// <summary>Active page index; -2 means no page has yet been resolved.</summary>
|
||||
public int PageIndex { get; internal set; } = -2;
|
||||
|
||||
/// <summary>Whether conditions currently enable this event in the world.</summary>
|
||||
public bool Visible { get; internal set; } = true;
|
||||
|
||||
/// <summary>Route bookkeeping belongs to the simulation, never to a sprite.</summary>
|
||||
public int RouteIndex { get; internal set; }
|
||||
|
||||
/// <summary>Seconds until another route instruction may start.</summary>
|
||||
public double RouteTimer { get; internal set; }
|
||||
|
||||
/// <summary>Reserved cell, updated immediately when a step starts.</summary>
|
||||
public Vector2I Cell { get; private set; }
|
||||
|
||||
/// <summary>Direction in map axes, independent of camera orientation.</summary>
|
||||
public RpgDirection Facing { get; private set; }
|
||||
|
||||
/// <summary>Duration uses cells per second, not projected pixel distance.</summary>
|
||||
public float Speed { get; set; } = TilesPerSecond;
|
||||
|
||||
/// <summary>True until the current step reaches its reserved destination.</summary>
|
||||
public bool IsMoving => progress < 1f;
|
||||
|
||||
/// <summary>Continuous top-left cell position for any projection.</summary>
|
||||
public Vector2 Position => from.Lerp(new Vector2(Cell.X, Cell.Y), progress);
|
||||
|
||||
/// <summary>Continuous walk phase shared by every presentation.</summary>
|
||||
public float AnimationTime { get; private set; }
|
||||
|
||||
/// <summary>Creates transient state; no Godot object is allocated.</summary>
|
||||
public RpgActorState(RpgMapEvent mapEvent = null)
|
||||
{
|
||||
Event = mapEvent;
|
||||
}
|
||||
|
||||
/// <summary>Teleportation stops an unfinished step without reporting its completion.</summary>
|
||||
public void Place(Vector2I cell, RpgDirection facing)
|
||||
{
|
||||
Cell = cell;
|
||||
from = new Vector2(cell.X, cell.Y);
|
||||
Facing = facing;
|
||||
progress = 1f;
|
||||
}
|
||||
|
||||
/// <summary>A blocked movement command can still turn the actor.</summary>
|
||||
public void Face(RpgDirection facing) => Facing = facing;
|
||||
|
||||
/// <summary>Reserves a neighbour; the session checks passability before calling.</summary>
|
||||
public void StepTo(RpgDirection direction)
|
||||
{
|
||||
if (IsMoving)
|
||||
return;
|
||||
Facing = direction;
|
||||
from = new Vector2(Cell.X, Cell.Y);
|
||||
Cell += RpgDirections.Delta(direction);
|
||||
progress = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances an existing step and returns true exactly once at arrival.
|
||||
/// Renderers must never call this: the session is the sole time source.
|
||||
/// </summary>
|
||||
public bool Advance(double delta)
|
||||
{
|
||||
if (delta < 0 || !double.IsFinite(delta))
|
||||
throw new ArgumentOutOfRangeException(nameof(delta));
|
||||
if (!IsMoving || delta <= 0)
|
||||
return false;
|
||||
var speed = Math.Clamp(Speed, 1f, 12f);
|
||||
progress = Math.Min(1f, progress + (float)delta * speed);
|
||||
AnimationTime += (float)delta * speed * 2f;
|
||||
return !IsMoving;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in woodland motion in map coordinates: drifting leaves, fireflies and small
|
||||
/// water ripples. Positions are deterministic; nothing enters the save or collision
|
||||
/// model. The small fixed draw budget is independent of map dimensions.
|
||||
/// </summary>
|
||||
public partial class RpgAtmosphere : Node2D
|
||||
{
|
||||
private readonly Vector2 extent;
|
||||
private readonly List<Vector2> water = new();
|
||||
private readonly int tileSize;
|
||||
private double elapsed;
|
||||
private double redraw;
|
||||
|
||||
/// <summary>Collects water cells once; an empty Atmosphere leaves this node unmounted.</summary>
|
||||
public RpgAtmosphere(RpgMap map, RpgMapGrid grid, int tileSize, RpgTileset tileset)
|
||||
{
|
||||
Name = "Atmosphere";
|
||||
this.tileSize = tileSize;
|
||||
extent = new Vector2(grid.Width, grid.Height) * tileSize;
|
||||
var waterTiles = new HashSet<int>();
|
||||
foreach (var tile in tileset?.Tiles ?? new List<RpgTileInfo>())
|
||||
if (string.Equals(tile.Tag, "water", StringComparison.OrdinalIgnoreCase))
|
||||
waterTiles.Add(tile.Index);
|
||||
for (var y = 0; y < grid.Height; y++)
|
||||
for (var x = 0; x < grid.Width; x++)
|
||||
if (waterTiles.Contains(grid.GetTile(RpgLayer.Back, x, y)))
|
||||
water.Add(new Vector2(x, y) * tileSize);
|
||||
}
|
||||
|
||||
/// <summary>Updates at 20 Hz, retaining crisp pixel positions and avoiding a particle system per tile.</summary>
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
elapsed += delta;
|
||||
redraw += delta;
|
||||
if (redraw < 0.05)
|
||||
return;
|
||||
redraw = 0;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Draws small translucent accents; the ground and actor sprites retain their own palette.</summary>
|
||||
public override void _Draw()
|
||||
{
|
||||
var t = (float)elapsed;
|
||||
for (var i = 0; i < 36; i++)
|
||||
{
|
||||
var x = Mathf.PosMod(i * 137.3f + t * (2 + i % 3), extent.X);
|
||||
var y = Mathf.PosMod(i * 83.7f + t * 1.4f + Mathf.Sin(t * 0.4f + i) * 7, extent.Y);
|
||||
var p = new Vector2(Mathf.Round(x), Mathf.Round(y));
|
||||
if (i % 3 == 0)
|
||||
{
|
||||
var alpha = 0.15f + 0.45f * Mathf.Pow(Mathf.Max(0, Mathf.Sin(t * 1.2f + i)), 2);
|
||||
DrawCircle(p, 3, new Color(0.72f, 0.91f, 0.5f, alpha * 0.12f));
|
||||
DrawRect(new Rect2(p, Vector2.One), new Color(0.95f, 1f, 0.65f, alpha));
|
||||
}
|
||||
else
|
||||
DrawLine(p, p + new Vector2(2, i % 2 == 0 ? 1 : -1), new Color(0.77f, 0.68f, 0.34f, 0.32f));
|
||||
}
|
||||
for (var i = 0; i < water.Count; i += 2)
|
||||
{
|
||||
var phase = t * 0.6f + i * 1.7f;
|
||||
var p = water[i] + new Vector2(8 + Mathf.Sin(phase) * 4, 10 + i % 13);
|
||||
var alpha = 0.08f + 0.12f * Mathf.Max(0, Mathf.Sin(phase));
|
||||
DrawLine(p.Round(), (p + new Vector2(tileSize * 0.25f, 0)).Round(), new Color(0.63f, 0.87f, 0.84f, alpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Where each tile of a visual group sits inside its object. A cell left over after another object overwrote
|
||||
/// part of a group (so the pattern no longer matches) is placed relative to the anchor of the object it came
|
||||
/// from: the isometric view draws the remaining fragment exactly as that part of the whole sprite, instead of
|
||||
/// standing every cell up at its own map cell. A tile used by several groups or twice in one keeps its first place.
|
||||
/// </summary>
|
||||
public static class RpgGroupFragments
|
||||
{
|
||||
public static Dictionary<int, (RpgVisualGroup Group, Vector2I Cell)> Of(RpgTileset tileset)
|
||||
{
|
||||
var result = new Dictionary<int, (RpgVisualGroup, Vector2I)>();
|
||||
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
|
||||
{
|
||||
if (group.IsoPlanes?.Count > 0 || RpgVisualGroupValidator.Error(group) != null) continue;
|
||||
for (var i = 0; i < group.Pattern.Length; i++)
|
||||
result.TryAdd(group.Pattern[i], (group, new Vector2I(i % group.Width, i / group.Width)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using Godot;
|
||||
|
||||
/// <summary>Highlights exactly the event the action key will run. Uses the view's target selection, so hints cannot disagree with input.</summary>
|
||||
public partial class RpgInteractionCue : Node2D
|
||||
{
|
||||
private readonly RpgWorldView view;
|
||||
private float elapsed;
|
||||
|
||||
/// <summary>Attaches to one map; no subscriptions or state survive unmount.</summary>
|
||||
public RpgInteractionCue(RpgWorldView view)
|
||||
{
|
||||
this.view = view;
|
||||
Name = "InteractionCue";
|
||||
}
|
||||
|
||||
/// <summary>Redraws the small corner marker while an action event is in reach.</summary>
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
elapsed += (float)delta;
|
||||
QueueRedraw();
|
||||
}
|
||||
|
||||
/// <summary>Draws four corners around the target cell, hidden whenever another UI owns input.</summary>
|
||||
public override void _Draw()
|
||||
{
|
||||
if (RpgInput.IsWalkBlocked(this))
|
||||
return;
|
||||
var target = view.InteractionTarget();
|
||||
if (target == null)
|
||||
return;
|
||||
var rect = new Rect2(target.Position + new Vector2(2, 2), new Vector2(view.TileSize - 4, view.TileSize - 4));
|
||||
var color = new Color(1f, 0.83f, 0.48f, 0.65f + 0.2f * Mathf.Sin(elapsed * 3));
|
||||
foreach (var corner in new[] { rect.Position, new Vector2(rect.End.X, rect.Position.Y), rect.End, new Vector2(rect.Position.X, rect.End.Y) })
|
||||
{
|
||||
var dx = corner.X == rect.Position.X ? 5 : -5;
|
||||
var dy = corner.Y == rect.Position.Y ? 5 : -5;
|
||||
DrawLine(corner, corner + new Vector2(dx, 0), color);
|
||||
DrawLine(corner, corner + new Vector2(0, dy), color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-84
@@ -1,61 +1,26 @@
|
||||
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>
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Settings;
|
||||
using Cthangover.Mods.Time;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
|
||||
/// <summary>Collects logical lighting; projection and shader application belong to the renderer.</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>
|
||||
private readonly RpgMap map;
|
||||
private readonly List<(Vector2I Cell, RpgLight Light)> tileLights;
|
||||
private readonly List<TileLight> candidates = new();
|
||||
private Vector2 focus;
|
||||
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));
|
||||
this.map = map;
|
||||
tileLights = tileset == null ? new() : TileLights(grid, tileset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -92,46 +57,26 @@ namespace Cthangover.Mods.Rpg
|
||||
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)
|
||||
public void Fill(TileFrame frame, RpgWorldSession session)
|
||||
{
|
||||
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;
|
||||
frame.TimeWeights = Weights(hour, controller?.IsDarkMode == true, controller?.IsUseLight != false);
|
||||
frame.Ambient = !string.IsNullOrEmpty(map.Ambient) && Color.HtmlIsValid(map.Ambient) ? Color.FromHtml(map.Ambient) : null;
|
||||
frame.Lights.Clear();
|
||||
focus = session.Player.Position + Vector2.One / 2;
|
||||
frame.Lights.Add(new TileLight { Position = focus, Radius = PlayerRadius, Color = new Color(1f, 0.9f, 0.75f, 0.75f) });
|
||||
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);
|
||||
foreach (var (cell, light) in tileLights) Add(new Vector2(cell.X, cell.Y) + Vector2.One / 2, light);
|
||||
foreach (var actor in session.Events)
|
||||
if (actor.Visible && actor.Current?.Light != null) Add(actor.Position + Vector2.One / 2, actor.Current.Light);
|
||||
candidates.Sort(CompareDistance);
|
||||
for (var i = 0; i < candidates.Count && frame.Lights.Count < MaxLights; i++) frame.Lights.Add(candidates[i]);
|
||||
}
|
||||
private void Add(Vector2 position, RpgLight light)
|
||||
=> candidates.Add(new TileLight { Position = position, Radius = light.Radius,
|
||||
Color = Color.HtmlIsValid(light.Color ?? "") ? Color.FromHtml(light.Color) : Colors.White });
|
||||
private int CompareDistance(TileLight a, TileLight b)
|
||||
=> a.Position.DistanceSquaredTo(focus).CompareTo(b.Position.DistanceSquaredTo(focus));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the existing map format into renderer-neutral image primitives. No
|
||||
/// projection, collision decision or scenario execution belongs in this adapter.
|
||||
/// </summary>
|
||||
public class RpgRenderSceneBuilder : IDisposable
|
||||
{
|
||||
private readonly RpgTextures textures;
|
||||
private readonly Dictionary<RpgLayer, TileSurfaceCache> surfaceCaches = new();
|
||||
private readonly HashSet<string> reported = new();
|
||||
private long revision;
|
||||
public int LastBuiltSurfaceChunks { get; private set; }
|
||||
public long SurfaceTextureBytes { get; private set; }
|
||||
public RpgRenderSceneBuilder(RpgTextures textures) { this.textures = textures; }
|
||||
|
||||
public TileRenderScene Build(RpgMap map, RpgMapGrid grid, RpgTileset tileset)
|
||||
{
|
||||
var scene = new TileRenderScene { Revision = ++revision, Size = new Vector2I(grid.Width, grid.Height), TileSize = tileset?.TileSize ?? 32,
|
||||
Zoom = Math.Max(1, map.Zoom), Atmosphere = map.Atmosphere };
|
||||
LastBuiltSurfaceChunks = 0;
|
||||
SurfaceTextureBytes = 0;
|
||||
if (tileset == null) return scene;
|
||||
var atlas = textures.Tileset(tileset.Image);
|
||||
var grouped = new HashSet<(int Layer, int X, int Y)>();
|
||||
AddGroups(scene, grid, tileset, atlas, grouped);
|
||||
var fragments = RpgGroupFragments.Of(tileset);
|
||||
foreach (var layer in new[] { RpgLayer.Back, RpgLayer.Main, RpgLayer.Front })
|
||||
{
|
||||
if (!string.IsNullOrEmpty(tileset.SurfaceImage) && textures.Tileset(tileset.SurfaceImage) is { } source)
|
||||
{
|
||||
if (!surfaceCaches.TryGetValue(layer, out var cache)) surfaceCaches[layer] = cache = new TileSurfaceCache();
|
||||
cache.Update(RpgSurfaceRenderer.Field(grid, layer, tileset), source);
|
||||
LastBuiltSurfaceChunks += cache.LastBuiltChunks;
|
||||
SurfaceTextureBytes += cache.TextureBytes;
|
||||
foreach (var chunk in cache.Chunks)
|
||||
scene.Visuals.Add(new TileVisual { Id = "surface/" + layer, Texture = chunk.Texture, Layer = (int)layer,
|
||||
Bounds = new Rect2(chunk.Cells.Position, chunk.Cells.Size),
|
||||
Source = new Rect2(Vector2.Zero, chunk.Texture.GetSize()) });
|
||||
}
|
||||
for (var y = 0; y < grid.Height; y++)
|
||||
for (var x = 0; x < grid.Width; x++)
|
||||
{
|
||||
var id = grid.GetTile(layer, x, y);
|
||||
if (id < 0) continue;
|
||||
var info = tileset.TileOf(id);
|
||||
if (info?.Tag == "water" && layer == RpgLayer.Back) scene.Water.Add(new Vector2(x, y));
|
||||
if (info?.Surface != null) continue;
|
||||
var only = grouped.Contains(((int)layer, x, y)) ? "topdown" : null;
|
||||
if (tileset.AutotileOf(id) is { } autotile)
|
||||
{
|
||||
var block = textures.Autotile(autotile.Autotile, tileset.TileSize, x, y);
|
||||
var quarters = RpgAutotiles.QuartersAt(grid, layer, x, y, id);
|
||||
for (var q = 0; q < 4; q++)
|
||||
scene.Visuals.Add(new TileVisual { Texture = block, Layer = (int)layer,
|
||||
Bounds = new Rect2(x + q % 2 * 0.5f, y + q / 2 * 0.5f, 0.5f, 0.5f),
|
||||
Source = RpgAutotiles.SourceRect(quarters[q], tileset.TileSize) });
|
||||
}
|
||||
else if (atlas != null)
|
||||
{
|
||||
var planes = only == null && layer != RpgLayer.Back && info?.Plane != null;
|
||||
var fragment = only == null && fragments.TryGetValue(id, out var part) ? part : default;
|
||||
scene.Visuals.Add(new TileVisual { Texture = atlas, Source = TileRegion(tileset, id),
|
||||
Bounds = new Rect2(x, y, 1, 1), Anchor = new Vector2(x + 0.5f, y + 1),
|
||||
Layer = (int)layer, Only = planes || fragment.Group != null ? "topdown" : only, Upright = layer != RpgLayer.Back });
|
||||
if (fragment.Group != null)
|
||||
scene.Visuals.Add(new TileVisual { Id = fragment.Group.Id, Texture = atlas, Source = TileRegion(tileset, id),
|
||||
Bounds = new Rect2(x, y, 1, 1), Layer = (int)layer, Upright = true, Only = "isometric",
|
||||
Anchor = new Vector2(x - fragment.Cell.X + fragment.Group.AnchorX, y - fragment.Cell.Y + fragment.Group.AnchorY) });
|
||||
if (planes)
|
||||
foreach (var quad in RpgTilePlanes.Quads(grid, tileset, layer, x, y, info.Plane))
|
||||
scene.Visuals.Add(new TileVisual { Texture = atlas, Source = TileRegion(tileset, id),
|
||||
Bounds = new Rect2(x, y, 1, 1), Anchor = new Vector2(x + 0.5f, y + 0.5f), Layer = (int)layer,
|
||||
Only = "isometric", WorldQuad = quad, Upright = true });
|
||||
}
|
||||
}
|
||||
}
|
||||
ReportPartial(scene, map.ID, grid, tileset, grouped);
|
||||
return scene;
|
||||
}
|
||||
|
||||
private void ReportPartial(TileRenderScene scene, string mapId, RpgMapGrid grid, RpgTileset tileset,
|
||||
HashSet<(int Layer, int X, int Y)> used)
|
||||
{
|
||||
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
|
||||
{
|
||||
var ids = new HashSet<int>(group.Pattern);
|
||||
var partial = false;
|
||||
for (var layer = 0; layer < 3 && !partial; layer++)
|
||||
for (var y = 0; y < grid.Height && !partial; y++)
|
||||
for (var x = 0; x < grid.Width && !partial; x++)
|
||||
partial = !used.Contains((layer, x, y)) && ids.Contains(grid.GetTile((RpgLayer)layer, x, y));
|
||||
if (!partial) continue;
|
||||
var message = $"map '{mapId}': incomplete visual group '{group.Id}', drawing the remaining cells in place of the whole object";
|
||||
scene.Warnings.Add(message);
|
||||
if (reported.Add(mapId + "/" + tileset.ID + "/" + group.Id)) GameLogger.Log("RPG", message, LogLevel.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddGroups(TileRenderScene scene, RpgMapGrid grid, RpgTileset tileset, Texture2D atlas,
|
||||
HashSet<(int Layer, int X, int Y)> grouped)
|
||||
{
|
||||
if (atlas == null) return;
|
||||
foreach (var group in tileset.VisualGroups ?? new List<RpgVisualGroup>())
|
||||
{
|
||||
var error = RpgVisualGroupValidator.Error(group);
|
||||
if (error != null) throw new ArgumentException(error + ": " + group?.Id);
|
||||
if (group.IsoPlanes?.Count > 0 && !PackedRectangle(group, tileset))
|
||||
throw new ArgumentException("Architectural source must remain a packed rectangle: " + group.Id);
|
||||
for (var y = 0; y <= grid.Height - group.Height; y++)
|
||||
for (var x = 0; x <= grid.Width - group.Width; x++)
|
||||
{
|
||||
var matched = Match(grid, group, x, y, grouped);
|
||||
if (matched == null) continue;
|
||||
foreach (var cell in matched) grouped.Add(cell);
|
||||
if (group.IsoPlanes?.Count > 0)
|
||||
{
|
||||
AddPlanes(scene, group, x, y, tileset, atlas);
|
||||
continue;
|
||||
}
|
||||
if (PackedRectangle(group, tileset))
|
||||
{
|
||||
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas,
|
||||
Source = new Rect2(TileRegion(tileset, group.Pattern[0]).Position, new Vector2(group.Width, group.Height) * tileset.TileSize),
|
||||
Bounds = new Rect2(x, y, group.Width, group.Height), Anchor = new Vector2(x + group.AnchorX, y + group.AnchorY),
|
||||
Layer = 1, Upright = true, Only = "isometric" });
|
||||
continue;
|
||||
}
|
||||
for (var dy = 0; dy < group.Height; dy++)
|
||||
for (var dx = 0; dx < group.Width; dx++)
|
||||
{
|
||||
var id = group.Pattern[dy * group.Width + dx];
|
||||
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas, Source = TileRegion(tileset, id),
|
||||
Bounds = new Rect2(x + dx, y + dy, 1, 1), Anchor = new Vector2(x + group.AnchorX, y + group.AnchorY),
|
||||
Layer = 1, Upright = true, Only = "isometric" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PackedRectangle(RpgVisualGroup group, RpgTileset tileset)
|
||||
{
|
||||
var first = RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[0]);
|
||||
for (var y = 0; y < group.Height; y++) for (var x = 0; x < group.Width; x++)
|
||||
if (RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[y * group.Width + x]) != first + new Vector2I(x, y)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Exact source-cell consumption; no inference from tags, opacity or collision.</summary>
|
||||
private static List<(int Layer, int X, int Y)> Match(RpgMapGrid grid, RpgVisualGroup group, int x, int y,
|
||||
HashSet<(int Layer, int X, int Y)> used)
|
||||
{
|
||||
var first = group.Pattern[0];
|
||||
if (grid.GetTile(RpgLayer.Back, x, y) != first && grid.GetTile(RpgLayer.Main, x, y) != first && grid.GetTile(RpgLayer.Front, x, y) != first) return null;
|
||||
var result = new List<(int, int, int)>();
|
||||
for (var dy = 0; dy < group.Height; dy++)
|
||||
for (var dx = 0; dx < group.Width; dx++)
|
||||
{
|
||||
var found = -1;
|
||||
for (var layer = 0; layer < 3; layer++)
|
||||
if (!used.Contains((layer, x + dx, y + dy)) && grid.GetTile((RpgLayer)layer, x + dx, y + dy) == group.Pattern[dy * group.Width + dx])
|
||||
{ found = layer; break; }
|
||||
if (found < 0) return null;
|
||||
result.Add((found, x + dx, y + dy));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddPlanes(TileRenderScene scene, RpgVisualGroup group, int x, int y, RpgTileset tileset, Texture2D atlas)
|
||||
{
|
||||
var start = TileRegion(tileset, group.Pattern[0]).Position;
|
||||
foreach (var plane in group.IsoPlanes)
|
||||
{
|
||||
if (plane.Crop?.Length != 4 || plane.Points?.Length != 4) throw new ArgumentException("Invalid architectural plane: " + group.Id);
|
||||
var points = new Vector3[4];
|
||||
for (var i = 0; i < 4; i++)
|
||||
points[i] = new Vector3(x + plane.Points[i][0], y + plane.Points[i][1], plane.Points[i][2]);
|
||||
scene.Visuals.Add(new TileVisual { Id = group.Id, Texture = atlas, Only = "isometric", Layer = 0,
|
||||
Bounds = new Rect2(x, y, group.Width, group.Height), WorldQuad = points,
|
||||
Source = new Rect2(start + new Vector2(plane.Crop[0], plane.Crop[1]) * tileset.TileSize,
|
||||
new Vector2(plane.Crop[2], plane.Crop[3]) * tileset.TileSize) });
|
||||
}
|
||||
}
|
||||
|
||||
public static Rect2 TileRegion(RpgTileset tileset, int id)
|
||||
=> new(RpgTileSetBuilder.AtlasCoords(tileset, id) * tileset.TileSize, Vector2.One * tileset.TileSize);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var cache in surfaceCaches.Values) cache.Dispose();
|
||||
surfaceCaches.Clear();
|
||||
}
|
||||
|
||||
public TileVisual ActorLook(RpgActorState actor, RpgTileset tileset, bool player)
|
||||
{
|
||||
var page = actor.Current;
|
||||
var charset = player ? RpgPlayerLook.Charset() : page?.Sprite;
|
||||
if (!string.IsNullOrEmpty(charset))
|
||||
{
|
||||
var texture = textures.Charset(charset);
|
||||
if (texture == null) return null;
|
||||
var size = texture.GetSize() / new Vector2(3, 4) / (tileset?.TileSize ?? 32);
|
||||
return new TileVisual { Texture = texture, Bounds = new Rect2(new Vector2(0.5f - size.X / 2, 1 - size.Y), size),
|
||||
Anchor = new Vector2(0.5f, 1), Source = new Rect2(Vector2.Zero, texture.GetSize()) };
|
||||
}
|
||||
if (page?.Tile >= 0 && tileset != null)
|
||||
return new TileVisual { Texture = textures.Tileset(tileset.Image), Source = TileRegion(tileset, page.Tile),
|
||||
Bounds = new Rect2(0, 0, 1, 1), Anchor = new Vector2(0.5f, 1) };
|
||||
if (!string.IsNullOrEmpty(page?.Icon))
|
||||
{
|
||||
var texture = textures.Icon(page.Icon);
|
||||
if (texture == null) return null;
|
||||
var size = texture.GetSize() * (0.75f / Math.Max(texture.GetWidth(), texture.GetHeight()));
|
||||
return new TileVisual { Texture = texture, Source = new Rect2(Vector2.Zero, texture.GetSize()),
|
||||
Bounds = new Rect2((Vector2.One - size) / 2, size), Anchor = new Vector2(0.5f, 1) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Editor appearance without running conditions, routes or gameplay events.</summary>
|
||||
public TileVisual EventLook(RpgMapEvent mapEvent, RpgTileset tileset)
|
||||
=> ActorLook(new RpgActorState(mapEvent) { Current = mapEvent }, tileset, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
using Cthangover.Mods.TileRender;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
/// <summary>
|
||||
/// Diffuses neighbouring materials in map space at load time (and after editor changes).
|
||||
/// The content atlas contains only source patches, never edge masks or phase variants.
|
||||
/// A smooth separable kernel includes diagonal neighbours; world-anchored irregularity
|
||||
/// and material sampling continue across cell boundaries. Object cells remain transparent.
|
||||
/// The resulting layer uses the world's ordinary lighting material.
|
||||
/// </summary>
|
||||
public static class RpgSurfaceRenderer
|
||||
{
|
||||
/// <summary>Builds a transient layer texture; maps without surfaces allocate no image.</summary>
|
||||
public static Texture2D Build(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, RpgTextures textures)
|
||||
{
|
||||
if (!GameEnvironment.IsRuntime || string.IsNullOrEmpty(tileset.SurfaceImage))
|
||||
return null;
|
||||
var found = false;
|
||||
for (var y = 0; y < grid.Height && !found; y++)
|
||||
for (var x = 0; x < grid.Width && !found; x++)
|
||||
found = tileset.TileOf(grid.GetTile(layer, x, y))?.Surface != null;
|
||||
if (!found || textures.Tileset(tileset.SurfaceImage) is not { } atlas)
|
||||
return null;
|
||||
using var source = atlas.GetImage();
|
||||
source.Convert(Image.Format.Rgba8);
|
||||
var pixels = Render(grid, layer, tileset, source.GetData(), source.GetWidth());
|
||||
using var image = Image.CreateFromData(grid.Width * tileset.TileSize, grid.Height * tileset.TileSize,
|
||||
false, Image.Format.Rgba8, pixels);
|
||||
return ImageTexture.CreateFromImage(image);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders RGBA8 pixels from a horizontal strip of square patches. This pure CPU
|
||||
/// entry point is shared by the editor, the world and tests; it calls no Godot APIs.
|
||||
/// </summary>
|
||||
public static byte[] Render(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset, byte[] atlas, int atlasWidth)
|
||||
=> TileSurfaceRasterizer.Render(Field(grid, layer, tileset), atlas, atlasWidth,
|
||||
new Rect2I(0, 0, grid.Width, grid.Height));
|
||||
|
||||
/// <summary>Material preparation independent of pixel rasterization and map projection.</summary>
|
||||
public static TileSurfaceField Field(RpgMapGrid grid, RpgLayer layer, RpgTileset tileset)
|
||||
{
|
||||
var field = new TileSurfaceField { Width = grid.Width, Height = grid.Height,
|
||||
TileSize = tileset.TileSize, PatchSize = tileset.SurfaceSize,
|
||||
Materials = new int[grid.Width * grid.Height], Diffuse = new bool[grid.Width * grid.Height] };
|
||||
for (var y = 0; y < grid.Height; y++)
|
||||
for (var x = 0; x < grid.Width; x++)
|
||||
{
|
||||
var info = tileset.TileOf(grid.GetTile(layer, x, y));
|
||||
var i = y * grid.Width + x;
|
||||
field.Materials[i] = info?.Surface ?? -1;
|
||||
field.Diffuse[i] = info?.Diffuse ?? false;
|
||||
}
|
||||
return field;
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-14
@@ -12,7 +12,7 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <c>items</c> group the inventory uses. Decoded textures are kept for the life of one world, which builds
|
||||
/// and owns an instance; the editor keeps its own.
|
||||
/// </summary>
|
||||
public class RpgTextures
|
||||
public class RpgTextures : System.IDisposable
|
||||
{
|
||||
/// <summary>File group of tileset images.</summary>
|
||||
public const string TilesetGroup = "tilesets";
|
||||
@@ -23,28 +23,35 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>File group of item pictures (an item's <c>Sprite</c>).</summary>
|
||||
public const string IconGroup = "items";
|
||||
|
||||
private readonly Dictionary<string, Texture2D> cache = new();
|
||||
private readonly Cthangover.Mods.TileRender.TileAssetCache cache = new();
|
||||
|
||||
/// <summary>Tileset atlas image, or null (logged by the loader).</summary>
|
||||
public Texture2D Tileset(string path) => Load(TilesetGroup, path);
|
||||
|
||||
/// <summary>
|
||||
/// An A2 block for this map cell. Multi-block sheets repeat by world position;
|
||||
/// regions are cached so every water cell sharing a phase reuses one texture.
|
||||
/// The editor and the world use this same lookup.
|
||||
/// </summary>
|
||||
public Texture2D Autotile(string path, int tileSize, int x, int y)
|
||||
{
|
||||
var sheet = Tileset(path);
|
||||
if (sheet == null)
|
||||
return null;
|
||||
var region = RpgAutotiles.VariantRect(new Vector2I(sheet.GetWidth(), sheet.GetHeight()), tileSize, new Vector2I(x, y));
|
||||
if (region.Position == Vector2.Zero && region.Size == sheet.GetSize())
|
||||
return sheet;
|
||||
var key = $"autotile/{path}/{tileSize}/{region.Position.X}/{region.Position.Y}";
|
||||
return cache.Region(key, sheet, region);
|
||||
}
|
||||
|
||||
/// <summary>Charset image, or null.</summary>
|
||||
public Texture2D Charset(string path) => Load(CharsetGroup, path);
|
||||
|
||||
/// <summary>Item picture, or null.</summary>
|
||||
public Texture2D Icon(string path) => Load(IconGroup, path);
|
||||
|
||||
private Texture2D Load(string group, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
var key = group + "/" + path;
|
||||
if (!cache.TryGetValue(key, out var texture))
|
||||
{
|
||||
texture = TextureUtils.LoadFromModGroup(group, path);
|
||||
cache[key] = texture;
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
private Texture2D Load(string group, string path) => cache.Resolve(group, path);
|
||||
public void Dispose() => cache.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System.Collections.Generic;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// World quads of a <see cref="RpgTileInfo.Plane"/> tile: a one-cell-high vertical plane through the cell centre,
|
||||
/// so the isometric view shears the same atlas sprite onto a map axis like the ground, keeping its pixels.
|
||||
/// "auto" follows neighbouring plane tiles of the same layer like an autotile: left/right neighbours give the
|
||||
/// x plane, up/down neighbours the y plane, both give a corner of two planes, none a lone x plane.
|
||||
/// Corners are TL/TR/BR/BL with the image's left edge on the screen's left in both orientations.
|
||||
/// </summary>
|
||||
public static class RpgTilePlanes
|
||||
{
|
||||
public static List<Vector3[]> Quads(RpgMapGrid grid, RpgTileset tileset, RpgLayer layer, int x, int y, string plane)
|
||||
{
|
||||
var alongX = plane == "x";
|
||||
var alongY = plane == "y";
|
||||
if (plane == "auto")
|
||||
{
|
||||
alongX = IsPlane(grid, tileset, layer, x - 1, y) || IsPlane(grid, tileset, layer, x + 1, y);
|
||||
alongY = IsPlane(grid, tileset, layer, x, y - 1) || IsPlane(grid, tileset, layer, x, y + 1);
|
||||
alongX |= !alongY;
|
||||
}
|
||||
var result = new List<Vector3[]>();
|
||||
if (alongX)
|
||||
result.Add(new[] { new Vector3(x, y + 0.5f, 1), new Vector3(x + 1, y + 0.5f, 1),
|
||||
new Vector3(x + 1, y + 0.5f, 0), new Vector3(x, y + 0.5f, 0) });
|
||||
if (alongY)
|
||||
result.Add(new[] { new Vector3(x + 0.5f, y + 1, 1), new Vector3(x + 0.5f, y, 1),
|
||||
new Vector3(x + 0.5f, y, 0), new Vector3(x + 0.5f, y + 1, 0) });
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsPlane(RpgMapGrid grid, RpgTileset tileset, RpgLayer layer, int x, int y)
|
||||
=> grid.GetTile(layer, x, y) is var id and >= 0 && tileset.TileOf(id)?.Plane != null;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,19 @@
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using Godot;
|
||||
/// <summary>
|
||||
/// Turns an <see cref="RpgTileset"/> and its atlas image into a Godot
|
||||
/// <see cref="TileSet"/> with a single atlas source (id 0), so maps are drawn
|
||||
/// by <see cref="TileMapLayer"/> — batched, culled, pixel-exact. Atlas index
|
||||
/// <c>i</c> is atlas coordinate <c>(i % Columns, i / Columns)</c>; the world
|
||||
/// and the editor both convert through <see cref="AtlasCoords"/>.
|
||||
/// Resolves logical tile IDs to source atlas coordinates for the palette and
|
||||
/// scene adapter. Rendering and projection belong to the tile renderer mods.
|
||||
/// </summary>
|
||||
public static class RpgTileSetBuilder
|
||||
{
|
||||
/// <summary>The only atlas source id of a built tile set.</summary>
|
||||
/// <summary>Legacy single-atlas source id for tools consuming atlas metadata.</summary>
|
||||
public const int SourceId = 0;
|
||||
|
||||
/// <summary>Builds the tile set; every whole cell of the image becomes a tile.</summary>
|
||||
public static TileSet Build(RpgTileset tileset, Texture2D texture)
|
||||
{
|
||||
var size = new Vector2I(tileset.TileSize, tileset.TileSize);
|
||||
var tileSet = new TileSet { TileSize = size };
|
||||
var source = new TileSetAtlasSource { Texture = texture, TextureRegionSize = size };
|
||||
|
||||
var columns = texture.GetWidth() / tileset.TileSize;
|
||||
var rows = texture.GetHeight() / tileset.TileSize;
|
||||
for (var y = 0; y < rows; y++)
|
||||
{
|
||||
for (var x = 0; x < columns; x++)
|
||||
source.CreateTile(new Vector2I(x, y));
|
||||
}
|
||||
|
||||
tileSet.AddSource(source, SourceId);
|
||||
return tileSet;
|
||||
}
|
||||
|
||||
/// <summary>Atlas coordinate of a row-major index.</summary>
|
||||
public static Vector2I AtlasCoords(RpgTileset tileset, int index)
|
||||
{
|
||||
index = tileset.TileOf(index)?.AtlasIndex ?? index;
|
||||
var columns = System.Math.Max(1, tileset.Columns);
|
||||
return new Vector2I(index % columns, index / columns);
|
||||
}
|
||||
|
||||
+77
-10
@@ -1,10 +1,10 @@
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// The place of the RPG world in the 2D shell. The rpg mod patches
|
||||
/// <c>base_scene.tscn</c> with this full-screen node right above the view box,
|
||||
@@ -33,6 +33,9 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>The mounted map, or null.</summary>
|
||||
public RpgWorldView View { get; private set; }
|
||||
|
||||
/// <summary>Logical world owned by the shell, independently of its current presentation.</summary>
|
||||
public RpgWorldSession Session { get; private set; }
|
||||
|
||||
/// <summary>Subscribes to the scene lifecycle and condition changes.</summary>
|
||||
public override void _EnterTree()
|
||||
{
|
||||
@@ -55,11 +58,32 @@ namespace Cthangover.Mods.Rpg
|
||||
/// <summary>Updates the action hint from the same target selection as the world.</summary>
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (View == null || hud == null)
|
||||
if (View == null)
|
||||
return;
|
||||
hud.Visible = !RpgInput.IsWalkBlocked(this);
|
||||
var blocked = View.Presentation == null || RpgInput.IsWalkBlocked(this);
|
||||
Session.Tick(delta, blocked, RpgInput.HeldDirection(), Input.IsPhysicalKeyPressed(Key.Shift));
|
||||
View.Present(delta);
|
||||
if (hud != null)
|
||||
hud.Visible = !RpgInput.IsWalkBlocked(this);
|
||||
if (prompt != null)
|
||||
prompt.Text = Tr(View.InteractionTarget() != null ? "rpg/ui/interact" : "rpg/ui/walk");
|
||||
prompt.Text = Tr(View.Presentation == null ? "rpg/ui/no_renderer" : View.InteractionTarget() != null ? "rpg/ui/interact" : "rpg/ui/walk");
|
||||
}
|
||||
|
||||
/// <summary>Shell input owns gameplay; a future SubViewport receives no duplicate walking/actions.</summary>
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (View == null || !RpgInput.IsActionPress(@event))
|
||||
return;
|
||||
var dialogBox = RpgInput.FindDialogBox();
|
||||
if (dialogBox?.IsRunning == true)
|
||||
{
|
||||
if (!dialogBox.IsWaitingAnswer)
|
||||
dialogBox.NextAction();
|
||||
GetViewport().SetInputAsHandled();
|
||||
return;
|
||||
}
|
||||
if (!RpgInput.IsWalkBlocked(this) && View.Interact())
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes; the view goes with the node.</summary>
|
||||
@@ -68,6 +92,7 @@ namespace Cthangover.Mods.Rpg
|
||||
ModEventBus.Unsubscribe<SceneEnteredEvent>(OnSceneEntered);
|
||||
ModEventBus.Unsubscribe<SceneExitedEvent>(OnSceneExited);
|
||||
ModEventBus.Unsubscribe<ConditionStateChangedEvent>(OnConditionsChanged);
|
||||
DetachSession();
|
||||
if (Current == this)
|
||||
Current = null;
|
||||
}
|
||||
@@ -114,7 +139,12 @@ namespace Cthangover.Mods.Rpg
|
||||
View = new RpgWorldView();
|
||||
AddChild(View);
|
||||
MoveChild(View, 1);
|
||||
View.Build(map);
|
||||
Session = new RpgWorldSession(map, RpgTilesetFactory.Instance.Get(map.Tileset),
|
||||
RpgMapEvents.Of(map.ID), ScenarioCondition.Evaluate);
|
||||
Session.PlayerStepFinished += OnPlayerStepFinished;
|
||||
Session.TouchTriggered += OnTouchTriggered;
|
||||
Session.EncounterTriggered += OnEncounterTriggered;
|
||||
View.Build(Session);
|
||||
if (mapName != null)
|
||||
mapName.Text = Tr(map.Name ?? map.ID);
|
||||
|
||||
@@ -135,6 +165,7 @@ namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
if (View != null)
|
||||
{
|
||||
DetachSession();
|
||||
RemoveChild(View);
|
||||
View.QueueFree();
|
||||
View = null;
|
||||
@@ -142,10 +173,46 @@ namespace Cthangover.Mods.Rpg
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
/// <summary>Arrival placement updates the save independently of any presentation.</summary>
|
||||
public void PlacePlayer(int x, int y, RpgDirection facing)
|
||||
{
|
||||
Session.Player.Place(new Vector2I(x, y), facing);
|
||||
RpgState.Instance.Place(Session.Map.ID, x, y, facing);
|
||||
View.Present(0);
|
||||
}
|
||||
|
||||
/// <summary>Executes exactly the action selected by the logical session.</summary>
|
||||
public bool Interact()
|
||||
{
|
||||
var target = Session.PrepareInteraction();
|
||||
View.Present(0);
|
||||
return target != null && RpgEventRunner.Run(target, Session.Map.ID);
|
||||
}
|
||||
|
||||
private void OnSceneEntered(SceneEnteredEvent evt) => Mount(evt.SceneName);
|
||||
|
||||
private void OnSceneExited(SceneExitedEvent evt) => Unmount();
|
||||
|
||||
private void OnConditionsChanged(ConditionStateChangedEvent evt) => View?.RefreshEvents();
|
||||
private void OnConditionsChanged(ConditionStateChangedEvent evt)
|
||||
{
|
||||
View?.RefreshEvents();
|
||||
}
|
||||
|
||||
private void OnPlayerStepFinished(RpgActorState player)
|
||||
=> RpgState.Instance.Place(Session.Map.ID, player.Cell.X, player.Cell.Y, player.Facing);
|
||||
|
||||
private void OnTouchTriggered(RpgMapEvent mapEvent) => RpgEventRunner.Run(mapEvent, Session.Map.ID);
|
||||
|
||||
private void OnEncounterTriggered(string enemies) => RpgBattleService.Start(enemies, null);
|
||||
|
||||
private void DetachSession()
|
||||
{
|
||||
if (Session == null)
|
||||
return;
|
||||
Session.PlayerStepFinished -= OnPlayerStepFinished;
|
||||
Session.TouchTriggered -= OnTouchTriggered;
|
||||
Session.EncounterTriggered -= OnEncounterTriggered;
|
||||
Session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// One map's simulation, with no scene-tree or presentation dependency. The host
|
||||
/// supplies input and time and handles emitted gameplay effects. All renderers
|
||||
/// observe these same reserved cells, event pages and interpolated actor positions.
|
||||
/// </summary>
|
||||
public class RpgWorldSession
|
||||
{
|
||||
private readonly Dictionary<string, RpgActorState> events = new();
|
||||
private readonly Func<string, bool> evaluate;
|
||||
private readonly Random random;
|
||||
|
||||
/// <summary>Original map definition, including the encounter table.</summary>
|
||||
public RpgMap Map { get; }
|
||||
|
||||
/// <summary>Parsed terrain shared by passability and presentation preparation.</summary>
|
||||
public RpgMapGrid Grid { get; }
|
||||
|
||||
/// <summary>Tile rules; null retains the existing walkable-terrain fallback.</summary>
|
||||
public RpgTileset Tileset { get; }
|
||||
|
||||
/// <summary>The player remains alive when a presentation is replaced.</summary>
|
||||
public RpgActorState Player { get; } = new();
|
||||
|
||||
/// <summary>Event states in resolved file order, including disabled events.</summary>
|
||||
public IEnumerable<RpgActorState> Events => events.Values;
|
||||
|
||||
/// <summary>Raised before a touch event or encounter, so the host can save arrival.</summary>
|
||||
public event Action<RpgActorState> PlayerStepFinished;
|
||||
|
||||
/// <summary>The host executes a touch event in the existing scenario/dialog system.</summary>
|
||||
public event Action<RpgMapEvent> TouchTriggered;
|
||||
|
||||
/// <summary>The host starts a battle on the existing turn-based platform.</summary>
|
||||
public event Action<string> EncounterTriggered;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a logical world from already resolved data. Supplying a Random makes
|
||||
/// route and encounter decisions reproducible without creating a viewport.
|
||||
/// </summary>
|
||||
public RpgWorldSession(RpgMap map, RpgTileset tileset, IEnumerable<RpgMapEvent> definitions,
|
||||
Func<string, bool> evaluate, Random random = null)
|
||||
{
|
||||
Map = map ?? throw new ArgumentNullException(nameof(map));
|
||||
Tileset = tileset;
|
||||
Grid = RpgMapGrid.Parse(map);
|
||||
this.evaluate = evaluate ?? throw new ArgumentNullException(nameof(evaluate));
|
||||
this.random = random ?? new Random();
|
||||
foreach (var definition in definitions ?? Array.Empty<RpgMapEvent>())
|
||||
{
|
||||
if (string.IsNullOrEmpty(definition.Id) || events.ContainsKey(definition.Id))
|
||||
{
|
||||
GameLogger.Log("RPG", $"map '{map.ID}': event without a unique id at {definition.X},{definition.Y} skipped", LogLevel.Error);
|
||||
continue;
|
||||
}
|
||||
events[definition.Id] = new RpgActorState(definition);
|
||||
}
|
||||
RefreshEvents();
|
||||
}
|
||||
|
||||
/// <summary>Re-resolves pages while retaining moved cells and restarting changed routes.</summary>
|
||||
public void RefreshEvents()
|
||||
{
|
||||
foreach (var actor in events.Values)
|
||||
{
|
||||
var index = RpgEventPages.ActiveIndex(actor.Event, evaluate);
|
||||
actor.Visible = index >= 0;
|
||||
if (index < 0 || index == actor.PageIndex)
|
||||
continue;
|
||||
var first = actor.PageIndex == -2;
|
||||
actor.PageIndex = index;
|
||||
actor.Current = RpgEventPages.Effective(actor.Event, index);
|
||||
actor.RouteIndex = 0;
|
||||
actor.RouteTimer = actor.Current.Route?.Every ?? 0;
|
||||
if (first)
|
||||
actor.Place(new Vector2I(actor.Event.X, actor.Event.Y), actor.Current.Dir);
|
||||
else if (index > 0 && actor.Event.Pages[index - 1].Dir.HasValue)
|
||||
actor.Face(actor.Current.Dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uses terrain overrides and the destinations reserved by visible solid events.</summary>
|
||||
public bool CanEnter(Vector2I cell)
|
||||
{
|
||||
if (!RpgPassability.IsWalkable(Grid, Tileset, cell.X, cell.Y))
|
||||
return false;
|
||||
foreach (var actor in events.Values)
|
||||
if (actor.Visible && actor.Current?.IsSolid == true && actor.Cell == cell)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Looks up an event independently of the presence of a sprite.</summary>
|
||||
public RpgActorState FindEvent(string id) => id != null && events.TryGetValue(id, out var actor) ? actor : null;
|
||||
|
||||
/// <summary>Returns the first enabled event of the requested trigger at a logical cell.</summary>
|
||||
public RpgActorState EventAt(Vector2I cell, RpgEventTrigger trigger)
|
||||
{
|
||||
foreach (var actor in events.Values)
|
||||
if (actor.Visible && actor.Cell == cell && actor.Current?.Trigger == trigger)
|
||||
return actor;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>The same logical target is used for action input and the visual cue.</summary>
|
||||
public RpgActorState InteractionTarget()
|
||||
{
|
||||
if (Player.IsMoving)
|
||||
return null;
|
||||
return EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
|
||||
?? EventAt(Player.Cell, RpgEventTrigger.Action);
|
||||
}
|
||||
|
||||
/// <summary>Turns the target toward the player and returns its effective action for the host.</summary>
|
||||
public RpgMapEvent PrepareInteraction()
|
||||
{
|
||||
var target = InteractionTarget();
|
||||
if (target == null)
|
||||
return null;
|
||||
if (target.Cell != Player.Cell)
|
||||
target.Face(RpgDirections.Opposite(Player.Facing));
|
||||
return target.Current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts at most one route/player step per frame, then advances all existing steps.
|
||||
/// UI blockers prevent new commands, while already started steps still arrive as before.
|
||||
/// Touch events take precedence over random encounters at arrival.
|
||||
/// </summary>
|
||||
public void Tick(double delta, bool blocked, RpgDirection? heldDirection = null, bool dash = false)
|
||||
{
|
||||
if (delta < 0 || !double.IsFinite(delta))
|
||||
throw new ArgumentOutOfRangeException(nameof(delta));
|
||||
if (!blocked)
|
||||
WalkRoutes(delta);
|
||||
if (!blocked && !Player.IsMoving && heldDirection.HasValue)
|
||||
{
|
||||
var next = Player.Cell + RpgDirections.Delta(heldDirection.Value);
|
||||
if (CanEnter(next))
|
||||
{
|
||||
Player.Speed = dash ? 7f : RpgActorState.TilesPerSecond;
|
||||
Player.StepTo(heldDirection.Value);
|
||||
}
|
||||
else
|
||||
Player.Face(heldDirection.Value);
|
||||
}
|
||||
foreach (var actor in events.Values)
|
||||
actor.Advance(delta);
|
||||
if (!Player.Advance(delta))
|
||||
return;
|
||||
PlayerStepFinished?.Invoke(Player);
|
||||
var touch = EventAt(Player.Cell, RpgEventTrigger.Touch);
|
||||
if (touch != null)
|
||||
{
|
||||
TouchTriggered?.Invoke(touch.Current);
|
||||
return;
|
||||
}
|
||||
var enemies = RpgEncounterRoller.Roll(Map.Encounters, random);
|
||||
if (enemies != null)
|
||||
EncounterTriggered?.Invoke(enemies);
|
||||
}
|
||||
|
||||
private void WalkRoutes(double delta)
|
||||
{
|
||||
foreach (var actor in events.Values)
|
||||
{
|
||||
var route = actor.Current?.Route;
|
||||
if (!actor.Visible || route?.Steps == null || route.Steps.Count == 0 || actor.IsMoving)
|
||||
continue;
|
||||
actor.RouteTimer -= delta;
|
||||
if (actor.RouteTimer > 0)
|
||||
continue;
|
||||
actor.RouteTimer = Math.Max(0.1, route.Every);
|
||||
if (actor.RouteIndex >= route.Steps.Count)
|
||||
{
|
||||
if (!route.Repeat)
|
||||
continue;
|
||||
actor.RouteIndex = 0;
|
||||
}
|
||||
var step = RpgRouteStep.Resolve(route.Steps[actor.RouteIndex++], actor.Cell, Player.Cell, random);
|
||||
if (!step.Direction.HasValue)
|
||||
continue;
|
||||
var next = actor.Cell + RpgDirections.Delta(step.Direction.Value);
|
||||
if (step.Moves && CanEnter(next) && next != Player.Cell)
|
||||
actor.StepTo(step.Direction.Value);
|
||||
else
|
||||
actor.Face(step.Direction.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
-310
@@ -1,341 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Scenes;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Cthangover.Core.Games;
|
||||
using Cthangover.Core.Events;
|
||||
using Cthangover.Core.Utils;
|
||||
using Cthangover.Mods.TileRender;
|
||||
using Godot;
|
||||
|
||||
/// <summary>
|
||||
/// One mounted map: the drawn layers, the actors and the player, plus the
|
||||
/// rules of walking around. Built by <see cref="RpgWorldHost"/> when a map
|
||||
/// scene is entered and freed when the scene is left.
|
||||
/// <para/>
|
||||
/// Draw order (children, bottom to top): back tiles, main tiles, the actor
|
||||
/// layer (Y-sorted: events and the player), front tiles; 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
|
||||
/// like a camera, clamped to the map edges; a map smaller than the screen is
|
||||
/// centred.
|
||||
/// <para/>
|
||||
/// Each frame without a blocker (<see cref="RpgInput.IsWalkBlocked"/>) a held
|
||||
/// direction turns the player and, if the cell is free, starts a step. When a
|
||||
/// step ends the position is stored in <see cref="RpgState"/>, then a touch
|
||||
/// event on the cell fires, else the encounter table rolls. The action key
|
||||
/// fires the action event in front of the player (or under them); during a
|
||||
/// dialog it advances the dialog instead.
|
||||
/// <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.
|
||||
/// Compatibility facade and RPG-to-renderer adapter. The host owns simulation;
|
||||
/// implementation mods own all visible geometry. A renderer can be replaced while
|
||||
/// a step is in progress without rebuilding this session or rerolling encounters.
|
||||
/// </summary>
|
||||
public partial class RpgWorldView : Node2D
|
||||
{
|
||||
private readonly Dictionary<string, RpgActor> eventActors = new();
|
||||
private readonly Dictionary<string, RpgActorState> eventActors = new();
|
||||
private readonly Dictionary<string, RpgMapEvent> shownPages = new();
|
||||
private readonly Dictionary<string, TileActorPose> poses = new();
|
||||
private readonly RpgTextures textures = new();
|
||||
private readonly Random random = new();
|
||||
private RpgMapGrid grid;
|
||||
private Node2D actorLayer;
|
||||
private Texture2D atlas;
|
||||
private readonly TileFrame frame = new();
|
||||
private TileRenderScene scene;
|
||||
private RpgRenderSceneBuilder builder;
|
||||
private RpgLighting lighting;
|
||||
|
||||
/// <summary>The mounted map.</summary>
|
||||
public RpgMap Map { get; private set; }
|
||||
|
||||
/// <summary>The map's tileset (null when it could not be resolved: the map is drawn empty).</summary>
|
||||
public RpgTileset Tileset { get; private set; }
|
||||
|
||||
/// <summary>The player's actor.</summary>
|
||||
public RpgActor Player { get; private set; }
|
||||
|
||||
/// <summary>Pixel edge of a tile before zoom.</summary>
|
||||
public RpgWorldSession Session { get; private set; }
|
||||
public RpgMap Map => Session.Map;
|
||||
public RpgTileset Tileset => Session.Tileset;
|
||||
public RpgActorState Player => Session.Player;
|
||||
public int TileSize => Tileset?.TileSize ?? 32;
|
||||
public ITileWorldPresentation Presentation { get; private set; }
|
||||
public string RendererId { get; private set; }
|
||||
|
||||
/// <summary>Builds the layers and actors of <paramref name="map"/>; the player is placed by <see cref="PlacePlayer"/>.</summary>
|
||||
public void Build(RpgMap map)
|
||||
public override void _EnterTree()
|
||||
{
|
||||
Map = map;
|
||||
Name = "World";
|
||||
TextureFilter = TextureFilterEnum.Nearest;
|
||||
Scale = Vector2.One * Math.Max(1, map.Zoom);
|
||||
grid = RpgMapGrid.Parse(map);
|
||||
|
||||
Tileset = RpgTilesetFactory.Instance.Get(map.Tileset);
|
||||
if (Tileset == null)
|
||||
GameLogger.Log("RPG", $"map '{map.ID}': tileset '{map.Tileset}' not found", LogLevel.Error);
|
||||
atlas = Tileset != null ? textures.Tileset(Tileset.Image) : null;
|
||||
var tileSet = Tileset != null && atlas != null ? RpgTileSetBuilder.Build(Tileset, atlas) : null;
|
||||
lighting = new RpgLighting(map, grid, Tileset);
|
||||
|
||||
AddChild(BuildLayer(RpgLayer.Back, tileSet));
|
||||
AddChild(BuildLayer(RpgLayer.Main, tileSet));
|
||||
actorLayer = new Node2D { Name = "Actors", YSortEnabled = true };
|
||||
AddChild(actorLayer);
|
||||
AddChild(BuildLayer(RpgLayer.Front, tileSet));
|
||||
|
||||
foreach (var mapEvent in RpgMapEvents.Of(map.ID))
|
||||
AddEventActor(mapEvent);
|
||||
RefreshEvents();
|
||||
if (string.Equals(map.Atmosphere, "woodland", StringComparison.OrdinalIgnoreCase))
|
||||
AddChild(new RpgAtmosphere(map, grid, TileSize, Tileset));
|
||||
AddChild(new RpgInteractionCue(this));
|
||||
|
||||
Player = new RpgActor(TileSize) { Name = "Player", Material = lighting.Material };
|
||||
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
|
||||
Player.StepFinished += OnPlayerStepFinished;
|
||||
actorLayer.AddChild(Player);
|
||||
TileRendererRegistry.Instance.Removing += OnRendererRemoving;
|
||||
ModEventBus.Subscribe<ModSetReloadedEvent>(OnModsReloaded);
|
||||
}
|
||||
public override void _ExitTree()
|
||||
{
|
||||
TileRendererRegistry.Instance.Removing -= OnRendererRemoving;
|
||||
ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnModsReloaded);
|
||||
Presentation?.Dispose();
|
||||
Presentation = null;
|
||||
scene?.Release();
|
||||
scene = null;
|
||||
builder?.Dispose();
|
||||
textures.Dispose();
|
||||
}
|
||||
|
||||
public void Build(RpgWorldSession session)
|
||||
{
|
||||
Session = session;
|
||||
Name = "World";
|
||||
builder = new RpgRenderSceneBuilder(textures);
|
||||
scene = builder.Build(Map, session.Grid, Tileset);
|
||||
lighting = new RpgLighting(Map, session.Grid, Tileset);
|
||||
foreach (var state in session.Events) eventActors[state.Event.Id] = state;
|
||||
UpdateFrame();
|
||||
var id = ActiveGame.GetSetting("tile.renderer");
|
||||
foreach (var arg in OS.GetCmdlineUserArgs())
|
||||
if (arg.StartsWith("--tile-renderer=")) id = arg.Substring(16);
|
||||
SetRenderer(id);
|
||||
}
|
||||
|
||||
/// <summary>Transactional replacement: a failed candidate leaves the current presentation alive.</summary>
|
||||
public bool SetRenderer(string id)
|
||||
{
|
||||
ITileWorldPresentation candidate = null;
|
||||
try
|
||||
{
|
||||
var factory = TileRendererRegistry.Instance.Resolve(id);
|
||||
candidate = factory.Create();
|
||||
AddChild(candidate.Root);
|
||||
candidate.Root.Visible = false;
|
||||
candidate.Mount(scene);
|
||||
candidate.Resize(GetParent() is Control host ? host.Size : GetViewportRect().Size);
|
||||
candidate.Present(frame, 0);
|
||||
Presentation?.Dispose();
|
||||
Presentation = candidate;
|
||||
RendererId = factory.Id;
|
||||
Presentation.Root.Visible = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
candidate?.Dispose();
|
||||
GameLogger.Log("RPG", ex.Message, LogLevel.Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRendererRemoving(string id)
|
||||
{
|
||||
if (RendererId != id) return;
|
||||
Presentation?.Dispose();
|
||||
Presentation = null;
|
||||
}
|
||||
|
||||
private void OnModsReloaded(ModSetReloadedEvent evt)
|
||||
{
|
||||
if (evt.Applied && Presentation == null && scene != null) SetRenderer(RendererId);
|
||||
}
|
||||
|
||||
/// <summary>Puts the player on a cell and stores the position.</summary>
|
||||
public void PlacePlayer(int x, int y, RpgDirection facing)
|
||||
{
|
||||
Player.Place(new Vector2I(x, y), facing);
|
||||
RpgState.Instance.Place(Map.ID, x, y, facing);
|
||||
UpdateCamera();
|
||||
if (GetParent() is RpgWorldHost host) host.PlacePlayer(x, y, facing);
|
||||
else { Session.Player.Place(new Vector2I(x, y), facing); Present(0); }
|
||||
}
|
||||
public void RefreshEvents() { Session.RefreshEvents(); Present(0); }
|
||||
public bool Contains(int x, int y) => Session.Grid.InBounds(x, y);
|
||||
public bool CanEnter(Vector2I cell) => Session.CanEnter(cell);
|
||||
public RpgActorState FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null;
|
||||
public RpgActorState EventAt(Vector2I cell, RpgEventTrigger trigger) => FindEvent(Session.EventAt(cell, trigger)?.Event.Id);
|
||||
public RpgActorState InteractionTarget() => FindEvent(Session.InteractionTarget()?.Event.Id);
|
||||
public bool Interact() => GetParent() is RpgWorldHost host && host.Interact();
|
||||
|
||||
public void Present(double delta)
|
||||
{
|
||||
UpdateFrame();
|
||||
if (Presentation == null) return;
|
||||
Presentation.Resize(GetParent() is Control host ? host.Size : GetViewportRect().Size);
|
||||
Presentation.Present(frame, delta);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-evaluates every event's condition: hidden events stop drawing,
|
||||
/// blocking and reacting. Called on build and whenever game state that a
|
||||
/// condition may read changes.
|
||||
/// </summary>
|
||||
public void RefreshEvents()
|
||||
private void UpdateFrame()
|
||||
{
|
||||
frame.Focus = Session.Player.Position + Vector2.One / 2;
|
||||
frame.InteractionCell = RpgInput.IsWalkBlocked(GetParent()) ? null : Session.InteractionTarget()?.Cell;
|
||||
FillActor("player", Session.Player, true);
|
||||
foreach (var actor in eventActors.Values)
|
||||
{
|
||||
var index = RpgEventPages.ActiveIndex(actor.Event, ScenarioCondition.Evaluate);
|
||||
actor.Visible = index >= 0;
|
||||
if (index < 0 || index == actor.PageIndex)
|
||||
continue;
|
||||
var first = actor.PageIndex == -2;
|
||||
actor.PageIndex = index;
|
||||
actor.Current = RpgEventPages.Effective(actor.Event, index);
|
||||
actor.RouteIndex = 0;
|
||||
actor.RouteTimer = actor.Current.Route?.Every ?? 0;
|
||||
ApplyLook(actor);
|
||||
if (first)
|
||||
actor.Place(new Vector2I(actor.Event.X, actor.Event.Y), actor.Current.Dir);
|
||||
else if (index > 0 && actor.Event.Pages[index - 1].Dir.HasValue)
|
||||
actor.Face(actor.Current.Dir);
|
||||
FillActor("event/" + actor.Event.Id, actor, false);
|
||||
}
|
||||
lighting.Fill(frame, Session);
|
||||
}
|
||||
|
||||
/// <summary>True when the cell lies on the map (a saved position may be stale after a map edit).</summary>
|
||||
public bool Contains(int x, int y) => grid.InBounds(x, y);
|
||||
|
||||
/// <summary>True when the player could step into the cell now.</summary>
|
||||
public bool CanEnter(Vector2I cell)
|
||||
private void FillActor(string id, RpgActorState state, bool player)
|
||||
{
|
||||
if (!RpgPassability.IsWalkable(grid, Tileset, cell.X, cell.Y))
|
||||
return false;
|
||||
foreach (var actor in eventActors.Values)
|
||||
if (!poses.TryGetValue(id, out var pose))
|
||||
{
|
||||
if (actor.Visible && actor.Current?.IsSolid == true && actor.Cell == cell)
|
||||
return false;
|
||||
pose = new TileActorPose { Id = id };
|
||||
poses[id] = pose;
|
||||
frame.Actors.Add(pose);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The actor of the event with this id (visible or not), or null.</summary>
|
||||
public RpgActor FindEvent(string id) => id != null && eventActors.TryGetValue(id, out var actor) ? actor : null;
|
||||
|
||||
/// <summary>The visible event on a cell with the given trigger, or null.</summary>
|
||||
public RpgActor EventAt(Vector2I cell, RpgEventTrigger trigger)
|
||||
{
|
||||
foreach (var actor in eventActors.Values)
|
||||
if (pose.Look == null || !shownPages.TryGetValue(id, out var page) || page != state.Current)
|
||||
{
|
||||
if (actor.Visible && actor.Cell == cell && actor.Current?.Trigger == trigger)
|
||||
return actor;
|
||||
pose.Look = builder.ActorLook(state, Tileset, player);
|
||||
shownPages[id] = state.Current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires the action event the player faces, or the one under them
|
||||
/// (a sign on the floor). An NPC turns to the player first.
|
||||
/// </summary>
|
||||
public bool Interact()
|
||||
{
|
||||
if (Player == null || Player.IsMoving)
|
||||
return false;
|
||||
|
||||
var target = InteractionTarget();
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
if (target.Cell != Player.Cell)
|
||||
target.Face(RpgDirections.Opposite(Player.Facing));
|
||||
return RpgEventRunner.Run(target.Current, Map.ID);
|
||||
}
|
||||
|
||||
/// <summary>The action event in reach, shared by input and its visual cue; hidden while moving.</summary>
|
||||
public RpgActor InteractionTarget()
|
||||
{
|
||||
if (Player == null || Player.IsMoving)
|
||||
return null;
|
||||
return EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
|
||||
?? EventAt(Player.Cell, RpgEventTrigger.Action);
|
||||
}
|
||||
|
||||
/// <summary>Walking: one decision per frame while the player stands still.</summary>
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
var blocked = RpgInput.IsWalkBlocked(this);
|
||||
if (!blocked)
|
||||
WalkRoutes(delta);
|
||||
if (Player != null && !Player.IsMoving && !blocked)
|
||||
{
|
||||
var held = RpgInput.HeldDirection();
|
||||
if (held.HasValue)
|
||||
{
|
||||
var next = Player.Cell + RpgDirections.Delta(held.Value);
|
||||
if (CanEnter(next))
|
||||
{
|
||||
Player.Speed = Input.IsPhysicalKeyPressed(Key.Shift) ? 7f : RpgActor.TilesPerSecond;
|
||||
Player.StepTo(held.Value);
|
||||
}
|
||||
else
|
||||
Player.Face(held.Value);
|
||||
}
|
||||
}
|
||||
UpdateCamera(delta);
|
||||
lighting.Update(this, eventActors.Values);
|
||||
}
|
||||
|
||||
/// <summary>The action key: advances a running dialog, otherwise interacts.</summary>
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (!RpgInput.IsActionPress(@event))
|
||||
return;
|
||||
|
||||
var dialogBox = RpgInput.FindDialogBox();
|
||||
if (dialogBox?.IsRunning == true)
|
||||
{
|
||||
if (!dialogBox.IsWaitingAnswer)
|
||||
dialogBox.NextAction();
|
||||
GetViewport().SetInputAsHandled();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!RpgInput.IsWalkBlocked(this) && Interact())
|
||||
GetViewport().SetInputAsHandled();
|
||||
}
|
||||
|
||||
private void OnPlayerStepFinished(RpgActor player)
|
||||
{
|
||||
RpgState.Instance.Place(Map.ID, player.Cell.X, player.Cell.Y, player.Facing);
|
||||
|
||||
var touch = EventAt(player.Cell, RpgEventTrigger.Touch);
|
||||
if (touch != null)
|
||||
{
|
||||
RpgEventRunner.Run(touch.Current, Map.ID);
|
||||
return;
|
||||
}
|
||||
|
||||
var enemies = RpgEncounterRoller.Roll(Map.Encounters, random);
|
||||
if (enemies != null)
|
||||
RpgBattleService.Start(enemies, null);
|
||||
}
|
||||
|
||||
private TileMapLayer BuildLayer(RpgLayer layer, TileSet tileSet)
|
||||
{
|
||||
var node = new TileMapLayer { Name = layer.ToString(), TileSet = tileSet, Material = lighting.Material };
|
||||
if (tileSet == null)
|
||||
return node;
|
||||
for (var y = 0; y < grid.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < grid.Width; x++)
|
||||
{
|
||||
var index = grid.GetTile(layer, x, y);
|
||||
if (index >= 0 && 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;
|
||||
}
|
||||
|
||||
private void AddEventActor(RpgMapEvent mapEvent)
|
||||
{
|
||||
if (string.IsNullOrEmpty(mapEvent.Id) || eventActors.ContainsKey(mapEvent.Id))
|
||||
{
|
||||
GameLogger.Log("RPG", $"map '{Map.ID}': event without a unique id at {mapEvent.X},{mapEvent.Y} skipped", LogLevel.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// The look and the cell come with the first page (RefreshEvents).
|
||||
var actor = new RpgActor(TileSize, mapEvent) { Name = "Event_" + mapEvent.Id, Material = lighting.Material };
|
||||
actorLayer.AddChild(actor);
|
||||
eventActors[mapEvent.Id] = actor;
|
||||
}
|
||||
|
||||
/// <summary>Shows the active page's charset, tile or icon (nothing for an invisible trigger).</summary>
|
||||
private void ApplyLook(RpgActor actor)
|
||||
{
|
||||
var page = actor.Current;
|
||||
actor.ClearLook();
|
||||
if (!string.IsNullOrEmpty(page.Sprite))
|
||||
actor.UseCharset(textures.Charset(page.Sprite));
|
||||
else if (page.Tile >= 0 && Tileset != null)
|
||||
actor.UseTile(atlas, RpgTileSetBuilder.AtlasCoords(Tileset, page.Tile));
|
||||
else if (!string.IsNullOrEmpty(page.Icon))
|
||||
actor.UseIcon(textures.Icon(page.Icon));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the routes of the shown events (<see cref="RpgMoveRoute"/>): one step per
|
||||
/// <c>Every</c> seconds; a walk into a blocked cell or onto the player only turns.
|
||||
/// </summary>
|
||||
private void WalkRoutes(double delta)
|
||||
{
|
||||
foreach (var actor in eventActors.Values)
|
||||
{
|
||||
var route = actor.Current?.Route;
|
||||
if (!actor.Visible || route?.Steps == null || route.Steps.Count == 0 || actor.IsMoving)
|
||||
continue;
|
||||
actor.RouteTimer -= delta;
|
||||
if (actor.RouteTimer > 0)
|
||||
continue;
|
||||
actor.RouteTimer = Math.Max(0.1, route.Every);
|
||||
if (actor.RouteIndex >= route.Steps.Count)
|
||||
{
|
||||
if (!route.Repeat)
|
||||
continue;
|
||||
actor.RouteIndex = 0;
|
||||
}
|
||||
|
||||
var step = RpgRouteStep.Resolve(route.Steps[actor.RouteIndex++], actor.Cell, Player?.Cell ?? actor.Cell, random);
|
||||
if (!step.Direction.HasValue)
|
||||
continue;
|
||||
var next = actor.Cell + RpgDirections.Delta(step.Direction.Value);
|
||||
if (step.Moves && CanEnter(next) && next != Player?.Cell)
|
||||
actor.StepTo(step.Direction.Value);
|
||||
else
|
||||
actor.Face(step.Direction.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCamera(double delta = 0)
|
||||
{
|
||||
if (Player == null || GetParent() is not Control host)
|
||||
return;
|
||||
|
||||
var zoom = Scale.X;
|
||||
var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom;
|
||||
var screen = host.Size;
|
||||
var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom;
|
||||
var target = new Vector2(Axis(screen.X, mapSize.X, focus.X), Axis(screen.Y, mapSize.Y, focus.Y));
|
||||
Position = delta <= 0 ? target : Position.Lerp(target, 1f - Mathf.Exp(-(float)delta * 14f)).Round();
|
||||
}
|
||||
|
||||
private static float Axis(float screen, float map, float focus)
|
||||
{
|
||||
if (map <= screen)
|
||||
return Mathf.Round((screen - map) / 2f);
|
||||
return Mathf.Round(Mathf.Clamp(screen / 2f - focus, screen - map, 0f));
|
||||
pose.Position = state.Position;
|
||||
pose.Visible = state.Visible;
|
||||
pose.Charset = player || !string.IsNullOrEmpty(state.Current?.Sprite);
|
||||
pose.Facing = (int)state.Facing;
|
||||
var cycle = (int)state.AnimationTime % 4;
|
||||
pose.Column = !state.IsMoving ? 1 : cycle == 3 ? 1 : cycle;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user