Add woodland atmosphere, interaction cues and scene restoration

This commit is contained in:
cthangover committed 2026-10-27 01:07:31 +01:00
1 parent 88ab26dcec
commit e153dc9b15
12 files changed
+256 -9

No files matched your search

+5
View File
@@ -1,2 +1,7 @@
mod_rpg_name = RPG World mod_rpg_name = RPG World
mod_rpg_desc = Top-down tile world in the spirit of RPG Maker: three tile layers and a collision layer, grid walking with WASD, events on the action key E/F, random encounters and battles that return to the map. mod_rpg_desc = Top-down tile world in the spirit of RPG Maker: three tile layers and a collision layer, grid walking with WASD, events on the action key E/F, random encounters and battles that return to the map.
rpg/ui/location = EXPLORING
rpg/ui/interact = E / F · Interact
rpg/ui/walk = WASD / arrows · Walk
rpg/ui/hint = Shift · Run | The journal keeps your next steps.
+5
View File
@@ -1,2 +1,7 @@
mod_rpg_name = RPG-мир mod_rpg_name = RPG-мир
mod_rpg_desc = Мир из тайлов с видом сверху в духе RPG Maker: три слоя тайлов и слой коллизий, ходьба по клеткам на WASD, события по клавише действия E/F, случайные встречи и бои с возвратом на карту. mod_rpg_desc = Мир из тайлов с видом сверху в духе RPG Maker: три слоя тайлов и слой коллизий, ходьба по клеткам на WASD, события по клавише действия E/F, случайные встречи и бои с возвратом на карту.
rpg/ui/location = В ПУТИ
rpg/ui/interact = E / F · Взаимодействовать
rpg/ui/walk = WASD / стрелки · Идти
rpg/ui/hint = Shift · Бежать | Следующие шаги — в журнале.
+61
View File
@@ -21,3 +21,64 @@ grow_horizontal = 2
grow_vertical = 2 grow_vertical = 2
mouse_filter = 2 mouse_filter = 2
color = Color(0.06, 0.07, 0.09, 1) color = Color(0.06, 0.07, 0.09, 1)
[node name="Hud" type="Control" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
mouse_filter = 2
[node name="Location" type="VBoxContainer" parent="Hud"]
layout_mode = 0
offset_left = 32.0
offset_top = 84.0
offset_right = 530.0
offset_bottom = 145.0
mouse_filter = 2
[node name="Eyebrow" type="Label" parent="Hud/Location"]
layout_mode = 2
theme_override_colors/font_color = Color(0.82, 0.72, 0.47, 1)
theme_override_colors/font_shadow_color = Color(0.03, 0.06, 0.07, 0.95)
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 12
text = "rpg/ui/location"
mouse_filter = 2
[node name="Name" type="Label" parent="Hud/Location"]
layout_mode = 2
theme_override_colors/font_color = Color(0.94, 0.94, 0.84, 1)
theme_override_colors/font_shadow_color = Color(0.03, 0.06, 0.07, 0.95)
theme_override_constants/shadow_offset_x = 1
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 24
mouse_filter = 2
[node name="Controls" type="VBoxContainer" parent="Hud"]
layout_mode = 1
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = -76.0
offset_right = 570.0
offset_bottom = -22.0
mouse_filter = 2
[node name="Prompt" type="Label" parent="Hud/Controls"]
layout_mode = 2
theme_override_colors/font_color = Color(0.96, 0.88, 0.65, 1)
theme_override_colors/font_shadow_color = Color(0.02, 0.04, 0.05, 1)
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 18
mouse_filter = 2
[node name="Hint" type="Label" parent="Hud/Controls"]
layout_mode = 2
theme_override_colors/font_color = Color(0.75, 0.83, 0.78, 1)
theme_override_colors/font_shadow_color = Color(0.02, 0.04, 0.05, 1)
theme_override_constants/shadow_offset_y = 2
theme_override_font_sizes/font_size = 13
text = "rpg/ui/hint"
mouse_filter = 2
+1 -1
View File
@@ -9,7 +9,7 @@ shader_type canvas_item;
uniform vec4 day_color : source_color = vec4(1.0, 1.0, 1.0, 1.0); uniform vec4 day_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform vec4 morning_color : source_color = vec4(0.86, 0.72, 0.84, 1.0); uniform vec4 morning_color : source_color = vec4(0.86, 0.72, 0.84, 1.0);
uniform vec4 evening_color : source_color = vec4(0.84, 0.72, 0.92, 1.0); uniform vec4 evening_color : source_color = vec4(0.84, 0.72, 0.92, 1.0);
uniform vec4 night_color : source_color = vec4(0.2, 0.26, 0.42, 1.0); uniform vec4 night_color : source_color = vec4(0.52, 0.62, 0.76, 1.0);
uniform vec4 time_weights = vec4(0.0, 1.0, 0.0, 0.0); uniform vec4 time_weights = vec4(0.0, 1.0, 0.0, 0.0);
// A map that sets its own ambient (indoors) uses it instead of the time of day. // A map that sets its own ambient (indoors) uses it instead of the time of day.
+3
View File
@@ -61,5 +61,8 @@ namespace Cthangover.Mods.Rpg
/// lights the map (<see cref="RpgLighting"/>). /// lights the map (<see cref="RpgLighting"/>).
/// </summary> /// </summary>
public string Ambient { get; set; } 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
View File
@@ -30,6 +30,10 @@ namespace Cthangover.Mods.Rpg
private Vector2 to; private Vector2 to;
private float progress = 1f; private float progress = 1f;
private float animationTime; 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> /// <summary>Raised when a step reaches its cell.</summary>
public event Action<RpgActor> StepFinished; public event Action<RpgActor> StepFinished;
@@ -70,6 +74,8 @@ namespace Cthangover.Mods.Rpg
{ {
if (sprite == null) if (sprite == null)
return; return;
hasShadow = false;
QueueRedraw();
sprite.QueueFree(); sprite.QueueFree();
sprite = null; sprite = null;
} }
@@ -79,6 +85,8 @@ namespace Cthangover.Mods.Rpg
{ {
if (texture == null) if (texture == null)
return; return;
hasShadow = true;
QueueRedraw();
sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false, UseParentMaterial = true }; sprite = new Sprite2D { Texture = texture, Hframes = 3, Vframes = 4, Centered = false, UseParentMaterial = true };
var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f); var frame = new Vector2(texture.GetWidth() / 3f, texture.GetHeight() / 4f);
sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y); sprite.Position = new Vector2((tileSize - frame.X) / 2f, tileSize - frame.Y);
@@ -155,14 +163,24 @@ namespace Cthangover.Mods.Rpg
if (!IsMoving) if (!IsMoving)
return; return;
progress = Mathf.Min(1f, progress + (float)delta * TilesPerSecond); progress = Mathf.Min(1f, progress + (float)delta * Math.Clamp(Speed, 1f, 12f));
animationTime += (float)delta * TilesPerSecond * 2f; animationTime += (float)delta * Math.Clamp(Speed, 1f, 12f) * 2f;
Position = from.Lerp(to, progress); Position = from.Lerp(to, progress);
UpdateFrame(); UpdateFrame();
if (!IsMoving) if (!IsMoving)
StepFinished?.Invoke(this); 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 Vector2 CellPosition(Vector2I cell) => new(cell.X * tileSize, cell.Y * tileSize);
private void UpdateFrame() private void UpdateFrame()
+74
View File
@@ -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));
}
}
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ namespace Cthangover.Mods.Rpg
if (dialogBox == null || dialogBox.IsRunning) if (dialogBox == null || dialogBox.IsRunning)
return false; return false;
var dialog = ScenarioParser.Parse(text.ToString()); var dialog = new ScenarioConverter().Convert(text.ToString());
if (dialog?.Queue == null || dialog.Queue.Count == 0) if (dialog?.Queue == null || dialog.Queue.Count == 0)
return false; return false;
+44
View File
@@ -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);
}
}
}
}
+19
View File
@@ -23,6 +23,9 @@ namespace Cthangover.Mods.Rpg
public partial class RpgWorldHost : Control public partial class RpgWorldHost : Control
{ {
private Control backdrop; private Control backdrop;
private Control hud;
private Label mapName;
private Label prompt;
/// <summary>The host in the running shell, or null.</summary> /// <summary>The host in the running shell, or null.</summary>
public static RpgWorldHost Current { get; private set; } public static RpgWorldHost Current { get; private set; }
@@ -43,9 +46,22 @@ namespace Cthangover.Mods.Rpg
public override void _Ready() public override void _Ready()
{ {
backdrop = GetNodeOrNull<Control>("Backdrop"); backdrop = GetNodeOrNull<Control>("Backdrop");
hud = GetNodeOrNull<Control>("Hud");
mapName = GetNodeOrNull<Label>("Hud/Location/Name");
prompt = GetNodeOrNull<Label>("Hud/Controls/Prompt");
Visible = false; 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> /// <summary>Unsubscribes; the view goes with the node.</summary>
public override void _ExitTree() public override void _ExitTree()
{ {
@@ -97,7 +113,10 @@ namespace Cthangover.Mods.Rpg
View = new RpgWorldView(); View = new RpgWorldView();
AddChild(View); AddChild(View);
MoveChild(View, 1);
View.Build(map); View.Build(map);
if (mapName != null)
mapName.Text = Tr(map.Name ?? map.ID);
var state = RpgState.Instance.Data; var state = RpgState.Instance.Data;
if (state.Map == map.ID && View.Contains(state.X, state.Y)) if (state.Map == map.ID && View.Contains(state.X, state.Y))
+20 -5
View File
@@ -75,6 +75,9 @@ namespace Cthangover.Mods.Rpg
foreach (var mapEvent in RpgMapEvents.Of(map.ID)) foreach (var mapEvent in RpgMapEvents.Of(map.ID))
AddEventActor(mapEvent); AddEventActor(mapEvent);
RefreshEvents(); 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 = new RpgActor(TileSize) { Name = "Player", Material = lighting.Material };
Player.UseCharset(textures.Charset(RpgPlayerLook.Charset())); Player.UseCharset(textures.Charset(RpgPlayerLook.Charset()));
@@ -155,8 +158,7 @@ namespace Cthangover.Mods.Rpg
if (Player == null || Player.IsMoving) if (Player == null || Player.IsMoving)
return false; return false;
var target = EventAt(Player.Cell + RpgDirections.Delta(Player.Facing), RpgEventTrigger.Action) var target = InteractionTarget();
?? EventAt(Player.Cell, RpgEventTrigger.Action);
if (target == null) if (target == null)
return false; return false;
@@ -165,6 +167,15 @@ namespace Cthangover.Mods.Rpg
return RpgEventRunner.Run(target.Current, Map.ID); 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> /// <summary>Walking: one decision per frame while the player stands still.</summary>
public override void _Process(double delta) public override void _Process(double delta)
{ {
@@ -178,12 +189,15 @@ namespace Cthangover.Mods.Rpg
{ {
var next = Player.Cell + RpgDirections.Delta(held.Value); var next = Player.Cell + RpgDirections.Delta(held.Value);
if (CanEnter(next)) if (CanEnter(next))
{
Player.Speed = Input.IsPhysicalKeyPressed(Key.Shift) ? 7f : RpgActor.TilesPerSecond;
Player.StepTo(held.Value); Player.StepTo(held.Value);
}
else else
Player.Face(held.Value); Player.Face(held.Value);
} }
} }
UpdateCamera(); UpdateCamera(delta);
lighting.Update(this, eventActors.Values); 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) if (Player == null || GetParent() is not Control host)
return; return;
@@ -313,7 +327,8 @@ namespace Cthangover.Mods.Rpg
var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom; var mapSize = new Vector2(grid.Width, grid.Height) * TileSize * zoom;
var screen = host.Size; var screen = host.Size;
var focus = (Player.Position + new Vector2(TileSize, TileSize) / 2f) * zoom; 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) private static float Axis(float screen, float map, float focus)
+3
View File
@@ -57,12 +57,15 @@ namespace Cthangover.Mods.Rpg.Tests
{ {
var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1, var json = @"{""Id"":""m"",""Tileset"":""village"",""Width"":2,""Height"":1,
""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""}, ""Spawn"":{""X"":1,""Y"":0,""Dir"":""left""},
""Atmosphere"":""woodland"",""Ambient"":""#c5d6b0"",
""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}"; ""Encounters"":{""Steps"":20,""Groups"":[""slime""]}}";
var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); var map = JsonSerializer.Deserialize<RpgMap>(json, Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(map), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions); var again = JsonSerializer.Deserialize<RpgMap>(JsonSerializer.Serialize(map), Cthangover.Core.Utils.JsonOptionsProvider.ReadOptions);
ModAssert.Equal("m", again.ID, "id"); ModAssert.Equal("m", again.ID, "id");
ModAssert.Equal("woodland", again.Atmosphere, "atmosphere survives saves and editor snapshots");
ModAssert.Equal("#c5d6b0", again.Ambient, "fixed ambient retained");
ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case"); ModAssert.Equal("Left", again.Spawn.Dir.ToString(), "spawn facing, read in any case");
ModAssert.Equal(20, again.Encounters.Steps, "encounter steps"); ModAssert.Equal(20, again.Encounters.Steps, "encounter steps");
ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group"); ModAssert.Equal("slime", again.Encounters.Groups[0], "encounter group");