Add woodland atmosphere, interaction cues and scene restoration
This commit is contained in:
1 parent
88ab26dcec
commit
e153dc9b15
12 files changed
+256
-9
No files matched your search
@@ -61,5 +61,8 @@ namespace Cthangover.Mods.Rpg
|
||||
/// lights the map (<see cref="RpgLighting"/>).
|
||||
/// </summary>
|
||||
public string Ambient { get; set; }
|
||||
|
||||
/// <summary>Optional woodland leaves, fireflies and water ripples; empty keeps interiors still.</summary>
|
||||
public string Atmosphere { get; set; }
|
||||
}
|
||||
}
|
||||
+20
-2
@@ -30,6 +30,10 @@ namespace Cthangover.Mods.Rpg
|
||||
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;
|
||||
@@ -70,6 +74,8 @@ namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
if (sprite == null)
|
||||
return;
|
||||
hasShadow = false;
|
||||
QueueRedraw();
|
||||
sprite.QueueFree();
|
||||
sprite = null;
|
||||
}
|
||||
@@ -79,6 +85,8 @@ namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
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);
|
||||
@@ -155,14 +163,24 @@ namespace Cthangover.Mods.Rpg
|
||||
if (!IsMoving)
|
||||
return;
|
||||
|
||||
progress = Mathf.Min(1f, progress + (float)delta * TilesPerSecond);
|
||||
animationTime += (float)delta * TilesPerSecond * 2f;
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ namespace Cthangover.Mods.Rpg
|
||||
if (dialogBox == null || dialogBox.IsRunning)
|
||||
return false;
|
||||
|
||||
var dialog = ScenarioParser.Parse(text.ToString());
|
||||
var dialog = new ScenarioConverter().Convert(text.ToString());
|
||||
if (dialog?.Queue == null || dialog.Queue.Count == 0)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ namespace Cthangover.Mods.Rpg
|
||||
public partial class RpgWorldHost : Control
|
||||
{
|
||||
private Control backdrop;
|
||||
private Control hud;
|
||||
private Label mapName;
|
||||
private Label prompt;
|
||||
|
||||
/// <summary>The host in the running shell, or null.</summary>
|
||||
public static RpgWorldHost Current { get; private set; }
|
||||
@@ -43,9 +46,22 @@ namespace Cthangover.Mods.Rpg
|
||||
public override void _Ready()
|
||||
{
|
||||
backdrop = GetNodeOrNull<Control>("Backdrop");
|
||||
hud = GetNodeOrNull<Control>("Hud");
|
||||
mapName = GetNodeOrNull<Label>("Hud/Location/Name");
|
||||
prompt = GetNodeOrNull<Label>("Hud/Controls/Prompt");
|
||||
Visible = false;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
return;
|
||||
hud.Visible = !RpgInput.IsWalkBlocked(this);
|
||||
if (prompt != null)
|
||||
prompt.Text = Tr(View.InteractionTarget() != null ? "rpg/ui/interact" : "rpg/ui/walk");
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes; the view goes with the node.</summary>
|
||||
public override void _ExitTree()
|
||||
{
|
||||
@@ -97,7 +113,10 @@ namespace Cthangover.Mods.Rpg
|
||||
|
||||
View = new RpgWorldView();
|
||||
AddChild(View);
|
||||
MoveChild(View, 1);
|
||||
View.Build(map);
|
||||
if (mapName != null)
|
||||
mapName.Text = Tr(map.Name ?? map.ID);
|
||||
|
||||
var state = RpgState.Instance.Data;
|
||||
if (state.Map == map.ID && View.Contains(state.X, state.Y))
|
||||
|
||||
@@ -75,6 +75,9 @@ namespace Cthangover.Mods.Rpg
|
||||
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()));
|
||||
@@ -155,8 +158,7 @@ namespace Cthangover.Mods.Rpg
|
||||
if (Player == null || Player.IsMoving)
|
||||
return false;
|
||||
|
||||
var target = EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action)
|
||||
?? EventAt(Player.Cell, RpgEventTrigger.Action);
|
||||
var target = InteractionTarget();
|
||||
if (target == null)
|
||||
return false;
|
||||
|
||||
@@ -165,6 +167,15 @@ namespace Cthangover.Mods.Rpg
|
||||
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)
|
||||
{
|
||||
@@ -178,12 +189,15 @@ namespace Cthangover.Mods.Rpg
|
||||
{
|
||||
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();
|
||||
UpdateCamera(delta);
|
||||
lighting.Update(this, eventActors.Values);
|
||||
}
|
||||
|
||||
@@ -304,7 +318,7 @@ namespace Cthangover.Mods.Rpg
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCamera()
|
||||
private void UpdateCamera(double delta = 0)
|
||||
{
|
||||
if (Player == null || GetParent() is not Control host)
|
||||
return;
|
||||
@@ -313,7 +327,8 @@ namespace Cthangover.Mods.Rpg
|
||||
var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom;
|
||||
var screen = host.Size;
|
||||
var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom;
|
||||
Position = new Vector2(Axis(screen.X, mapSize.X, focus.X), Axis(screen.Y, mapSize.Y, focus.Y));
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user