Персонаж доходит до тёмной шахты, плавно исчезает (alpha 1->0), переносится в шахту на случайном другом этаже и плавно появляется (0->1). Без вертикального прохода. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
441 lines
14 KiB
C#
441 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using Godot;
|
|
|
|
// Демо-карта «убежища» в разрезе (à la Fallout Shelter): комнаты, лестницы, объекты
|
|
// и несколько персонажей, гуляющих по этажам.
|
|
// ЛКМ-перетаскивание или WASD/стрелки — панорама
|
|
// колесо мыши — зум, R — сброс вида
|
|
public partial class RoomMap : Node2D
|
|
{
|
|
private const float Cell = 256f;
|
|
private const int Cols = 6;
|
|
private const int Rows = 5;
|
|
private const float CharScale = 0.34f;
|
|
private static readonly Vector2 FootAnchor = new Vector2(640f, 604f); // ноги в кадре 1280x720
|
|
|
|
private Node2D _world;
|
|
private Label _label;
|
|
private bool _dragging;
|
|
private float _viewZoom = 1f;
|
|
private Vector2 _homePos;
|
|
|
|
private static float FloorY(int row) => row * Cell + Cell * 0.9f;
|
|
private float _shaftX; // X тёмной шахты (комнаты друг над другом = скрытая лестница)
|
|
|
|
private readonly List<Pawn> _pawns = new();
|
|
private System.Random _rng;
|
|
|
|
private Pawn _dragPawn, _selected;
|
|
private Vector2 _dragOffset;
|
|
private PanelContainer _infoPanel;
|
|
private Label _infoLabel;
|
|
|
|
private enum PState { Idle, Walk, GoShaft, Teleport, Held }
|
|
|
|
private class Pawn
|
|
{
|
|
public Node2D Node;
|
|
public AnimationPlayer Ap;
|
|
public string Name;
|
|
public int Row, TargetRow;
|
|
public float X, TargetX, Speed, MinX, MaxX, IdleTimer, FadeT;
|
|
public bool Swapped;
|
|
public PState State;
|
|
public string WalkAnim, IdleAnim;
|
|
}
|
|
|
|
public override void _Ready()
|
|
{
|
|
var bg = new ColorRect
|
|
{
|
|
Color = new Color(0.06f, 0.05f, 0.04f), // земля вокруг убежища
|
|
AnchorRight = 1, AnchorBottom = 1,
|
|
MouseFilter = Control.MouseFilterEnum.Ignore
|
|
};
|
|
AddChild(bg);
|
|
|
|
_world = new Node2D();
|
|
AddChild(_world);
|
|
|
|
BuildVault();
|
|
|
|
_label = new Label { Position = new Vector2(16, 12) };
|
|
_label.AddThemeFontSizeOverride("font_size", 18);
|
|
_label.AddThemeConstantOverride("outline_size", 4);
|
|
_label.AddThemeColorOverride("font_outline_color", Colors.Black);
|
|
AddChild(_label);
|
|
|
|
BuildInfoPanel();
|
|
ResetView();
|
|
}
|
|
|
|
private void BuildInfoPanel()
|
|
{
|
|
_infoPanel = new PanelContainer { Position = new Vector2(16, 64), Visible = false };
|
|
var margin = new MarginContainer();
|
|
foreach (var s in new[] { "margin_left", "margin_right", "margin_top", "margin_bottom" })
|
|
margin.AddThemeConstantOverride(s, 10);
|
|
_infoPanel.AddChild(margin);
|
|
_infoLabel = new Label();
|
|
margin.AddChild(_infoLabel);
|
|
AddChild(_infoPanel);
|
|
}
|
|
|
|
private void ShowInfo(Pawn p)
|
|
{
|
|
_selected = p;
|
|
_infoLabel.Text = $"Персонаж: {p.Name}\nЭтаж: {p.Row + 1}/{Rows}\nтащи мышью → перенести в комнату";
|
|
_infoPanel.Visible = true;
|
|
}
|
|
|
|
private void BuildVault()
|
|
{
|
|
// тонкая земляная «крышка» над верхним рядом (вместо неба)
|
|
AddRect(new Rect2(-Cell * 0.5f, -22, Cols * Cell + Cell, 22), new Color(0.11f, 0.09f, 0.07f));
|
|
|
|
// пол каждой ячейки — пустая комната (шахматкой две вариации)
|
|
for (int r = 0; r < Rows; r++)
|
|
for (int c = 0; c < Cols; c++)
|
|
{
|
|
string tex = (r + c) % 2 == 0
|
|
? "res://assets/room_sliced/Room_normal.png"
|
|
: "res://assets/room_sliced/Room_normal2.png";
|
|
AddCell(tex, c, r, 1, 1);
|
|
}
|
|
|
|
// несколько «обставленных» комнат-акцентов поверх пустых
|
|
AddCell("res://assets/room_sliced/PlayerRoom.png", 1, 0, 3, 1); // широкая на 3 клетки
|
|
AddCell("res://assets/room_sliced/BreedingRoom.png", 0, 2, 1, 1);
|
|
AddCell("res://assets/room_sliced/LactationRoom.png", 4, 3, 1, 1);
|
|
AddCell("res://assets/room_sliced/Light.png", 2, 2, 1, 1);
|
|
|
|
// рамки поверх всех ячеек — читаемость сетки
|
|
for (int r = 0; r < Rows; r++)
|
|
for (int c = 0; c < Cols; c++)
|
|
AddCell("res://assets/room_sliced/Frame.png", c, r, 1, 1, new Color(1, 1, 1, 0.5f));
|
|
|
|
BuildDarkRooms();
|
|
BuildObjects();
|
|
BuildPawns();
|
|
}
|
|
|
|
// ---------- тёмная шахта: колонка тёмных комнат друг над другом (скрытая лестница) ----------
|
|
private void BuildDarkRooms()
|
|
{
|
|
const int col = 5; // одна колонка на всю высоту
|
|
_shaftX = col * Cell + Cell * 0.5f;
|
|
for (int r = 0; r < Rows; r++)
|
|
{
|
|
// затемнение интерьера ячейки
|
|
AddRect(new Rect2(col * Cell + 8, r * Cell + 8, Cell - 16, Cell - 16), new Color(0.02f, 0.02f, 0.05f, 0.94f));
|
|
// слабое свечение по центру — вход в шахту
|
|
AddTex("res://assets/room_sliced/Light.png",
|
|
new Rect2(col * Cell + Cell * 0.3f, r * Cell + Cell * 0.3f, Cell * 0.4f, Cell * 0.4f),
|
|
_world, new Color(0.45f, 0.38f, 0.9f, 0.4f));
|
|
}
|
|
}
|
|
|
|
private void AddRect(Rect2 r, Color c)
|
|
{
|
|
var p = new Polygon2D
|
|
{
|
|
Color = c,
|
|
Polygon = new[]
|
|
{
|
|
r.Position, new Vector2(r.End.X, r.Position.Y), r.End, new Vector2(r.Position.X, r.End.Y)
|
|
}
|
|
};
|
|
_world.AddChild(p);
|
|
}
|
|
|
|
// ---------- объекты в комнатах ----------
|
|
private void BuildObjects()
|
|
{
|
|
(int c, int r)[] lanterns = { (0, 0), (5, 0), (3, 1), (5, 2), (1, 3), (0, 4), (5, 4) };
|
|
foreach (var (c, r) in lanterns)
|
|
AddTex("res://assets/room_sliced/Lantern.png",
|
|
new Rect2(c * Cell + Cell * 0.72f, r * Cell + Cell * 0.06f, Cell * 0.16f, Cell * 0.22f), _world, Colors.White);
|
|
|
|
(int c, int r)[] lights = { (1, 1), (3, 3), (4, 0) };
|
|
foreach (var (c, r) in lights)
|
|
AddTex("res://assets/room_sliced/Light.png",
|
|
new Rect2(c * Cell + Cell * 0.28f, r * Cell + Cell * 0.02f, Cell * 0.44f, Cell * 0.44f), _world, new Color(1, 1, 1, 0.75f));
|
|
}
|
|
|
|
// ---------- персонажи, гуляющие по этажам ----------
|
|
private void BuildPawns()
|
|
{
|
|
_rng = new System.Random(20260803);
|
|
(string path, int row)[] picks =
|
|
{
|
|
("res://characters/elf1/elf1_combat_native.tscn", 0),
|
|
("res://characters/knight/knight_combat_native.tscn", 1),
|
|
("res://characters/orc/orc_combat_native.tscn", 2),
|
|
("res://characters/sister/sister_combat_native.tscn", 3),
|
|
("res://characters/satyress/satyress_combat_native.tscn", 4),
|
|
};
|
|
float minX = Cell * 0.4f, maxX = Cols * Cell - Cell * 0.4f;
|
|
|
|
foreach (var (path, row) in picks)
|
|
{
|
|
if (!ResourceLoader.Exists(path)) continue;
|
|
var scene = ResourceLoader.Load<PackedScene>(path).Instantiate<Node2D>();
|
|
scene.Position = -FootAnchor * CharScale;
|
|
scene.Scale = new Vector2(CharScale, CharScale);
|
|
|
|
var node = new Node2D();
|
|
node.AddChild(scene);
|
|
_world.AddChild(node);
|
|
|
|
float x = (float)(_rng.NextDouble() * (maxX - minX) + minX);
|
|
node.Position = new Vector2(x, FloorY(row));
|
|
|
|
var ap = scene.GetNodeOrNull<AnimationPlayer>("AnimationPlayer");
|
|
string[] anims = ap != null ? ap.GetAnimationList() : System.Array.Empty<string>();
|
|
var p = new Pawn
|
|
{
|
|
Node = node, Ap = ap, Name = path.Split('/')[3], Row = row,
|
|
X = x, MinX = minX, MaxX = maxX,
|
|
Speed = 70f + (float)_rng.NextDouble() * 55f,
|
|
WalkAnim = Pick(anims, "walk_0"), IdleAnim = Pick(anims, "idle_0")
|
|
};
|
|
Decide(p);
|
|
_pawns.Add(p);
|
|
}
|
|
}
|
|
|
|
private static string Pick(string[] anims, string want)
|
|
{
|
|
foreach (var a in anims) if (a == want) return a;
|
|
return anims.Length > 0 ? anims[0] : null;
|
|
}
|
|
|
|
private void PlayWalk(Pawn p) { if (p.Ap != null && p.WalkAnim != null) p.Ap.Play(p.WalkAnim); }
|
|
private void PlayIdle(Pawn p) { if (p.Ap != null && p.IdleAnim != null) p.Ap.Play(p.IdleAnim); }
|
|
|
|
// решить: гулять по этажу или уйти в тёмную шахту и перейти на другой этаж
|
|
private void Decide(Pawn p)
|
|
{
|
|
if (Rows > 1 && _rng.NextDouble() < 0.45)
|
|
{
|
|
p.TargetX = _shaftX;
|
|
p.State = PState.GoShaft;
|
|
PlayWalk(p);
|
|
}
|
|
else
|
|
{
|
|
p.TargetX = (float)(_rng.NextDouble() * (p.MaxX - p.MinX) + p.MinX);
|
|
p.State = PState.Walk;
|
|
PlayWalk(p);
|
|
}
|
|
}
|
|
|
|
private void UpdatePawns(float dt)
|
|
{
|
|
foreach (var p in _pawns)
|
|
{
|
|
switch (p.State)
|
|
{
|
|
case PState.Held:
|
|
break;
|
|
|
|
case PState.Walk:
|
|
case PState.GoShaft:
|
|
{
|
|
float d = Mathf.Sign(p.TargetX - p.X);
|
|
p.X += d * p.Speed * dt;
|
|
p.Node.Scale = new Vector2(d >= 0 ? 1f : -1f, 1f);
|
|
p.Node.Position = new Vector2(p.X, FloorY(p.Row));
|
|
if (Mathf.Abs(p.TargetX - p.X) < 4f)
|
|
{
|
|
if (p.State == PState.GoShaft)
|
|
{
|
|
p.X = _shaftX;
|
|
do { p.TargetRow = _rng.Next(Rows); } while (p.TargetRow == p.Row);
|
|
p.State = PState.Teleport;
|
|
p.FadeT = 0f; p.Swapped = false;
|
|
PlayIdle(p);
|
|
}
|
|
else { p.State = PState.Idle; p.IdleTimer = 0.8f + (float)_rng.NextDouble() * 2.2f; PlayIdle(p); }
|
|
}
|
|
break;
|
|
}
|
|
|
|
case PState.Teleport: // исчезнуть в шахте на одном этаже и появиться на другом
|
|
{
|
|
const float dur = 0.5f;
|
|
p.FadeT += dt / dur;
|
|
if (!p.Swapped && p.FadeT >= 0.5f)
|
|
{
|
|
p.Swapped = true;
|
|
p.Row = p.TargetRow;
|
|
p.X = _shaftX;
|
|
p.Node.Position = new Vector2(_shaftX, FloorY(p.Row));
|
|
if (_selected == p) ShowInfo(p);
|
|
}
|
|
float a = p.FadeT < 0.5f ? 1f - p.FadeT * 2f : (p.FadeT - 0.5f) * 2f;
|
|
p.Node.Modulate = new Color(1f, 1f, 1f, Mathf.Clamp(a, 0f, 1f));
|
|
if (p.FadeT >= 1f)
|
|
{
|
|
p.Node.Modulate = Colors.White;
|
|
p.State = PState.Idle;
|
|
p.IdleTimer = 0.4f + (float)_rng.NextDouble() * 1.5f;
|
|
PlayIdle(p);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case PState.Idle:
|
|
p.IdleTimer -= dt;
|
|
if (p.IdleTimer <= 0f) Decide(p);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private Pawn PawnAt(Vector2 worldPos)
|
|
{
|
|
float w = Cell * 0.5f, h = Cell * 0.85f;
|
|
for (int i = _pawns.Count - 1; i >= 0; i--)
|
|
{
|
|
var p = _pawns[i];
|
|
var r = new Rect2(p.Node.Position.X - w / 2f, p.Node.Position.Y - h, w, h);
|
|
if (r.HasPoint(worldPos)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private void DropPawn(Pawn p)
|
|
{
|
|
int col = Mathf.Clamp((int)(p.Node.Position.X / Cell), 0, Cols - 1);
|
|
int row = Mathf.Clamp((int)(p.Node.Position.Y / Cell), 0, Rows - 1);
|
|
p.Row = row;
|
|
p.X = Mathf.Clamp(col * Cell + Cell * 0.5f, p.MinX, p.MaxX);
|
|
p.Node.Position = new Vector2(p.X, FloorY(row));
|
|
p.Node.Modulate = Colors.White;
|
|
p.State = PState.Idle; p.IdleTimer = 0.5f; PlayIdle(p);
|
|
if (_selected == p) ShowInfo(p);
|
|
}
|
|
|
|
private void AddCell(string path, int col, int row, int cw, int ch, Color? modulate = null)
|
|
{
|
|
AddTex(path, new Rect2(col * Cell, row * Cell, cw * Cell, ch * Cell), _world, modulate ?? Colors.White);
|
|
}
|
|
|
|
private static void AddTex(string path, Rect2 dest, Node2D parent, Color modulate)
|
|
{
|
|
var tex = ResourceLoader.Exists(path) ? ResourceLoader.Load<Texture2D>(path) : null;
|
|
if (tex == null)
|
|
return;
|
|
var s = new Sprite2D
|
|
{
|
|
Texture = tex,
|
|
Centered = false,
|
|
Position = dest.Position,
|
|
Scale = new Vector2(dest.Size.X / tex.GetWidth(), dest.Size.Y / tex.GetHeight()),
|
|
Modulate = modulate
|
|
};
|
|
parent.AddChild(s);
|
|
}
|
|
|
|
private void ResetView()
|
|
{
|
|
Vector2 vp = GetViewportRect().Size;
|
|
Vector2 gridSize = new Vector2(Cols * Cell, Rows * Cell);
|
|
float fit = Mathf.Min(vp.X * 0.9f / gridSize.X, vp.Y * 0.8f / gridSize.Y);
|
|
_viewZoom = fit;
|
|
_world.Scale = new Vector2(fit, fit);
|
|
_homePos = (vp - gridSize * fit) * 0.5f;
|
|
_world.Position = _homePos;
|
|
UpdateLabel();
|
|
}
|
|
|
|
private void ZoomAt(Vector2 screenPoint, float factor)
|
|
{
|
|
float newZoom = Mathf.Clamp(_viewZoom * factor, 0.1f, 3f);
|
|
factor = newZoom / _viewZoom;
|
|
_viewZoom = newZoom;
|
|
// удерживаем точку под курсором на месте
|
|
_world.Position = screenPoint - (screenPoint - _world.Position) * factor;
|
|
_world.Scale = new Vector2(_viewZoom, _viewZoom);
|
|
UpdateLabel();
|
|
}
|
|
|
|
private void UpdateLabel()
|
|
{
|
|
_label.Text =
|
|
$"Карта убежища — {Cols}×{Rows} комнат, тёмная шахта (скрытая лестница), {_pawns.Count} персонажа\n" +
|
|
$"ЛКМ по персонажу — выбрать/тащить · ЛКМ по фону/WASD — панорама · колесо — зум · R — сброс · Esc — меню";
|
|
}
|
|
|
|
public override void _Process(double delta)
|
|
{
|
|
if (!Visible)
|
|
return;
|
|
Vector2 dir = Vector2.Zero;
|
|
if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) dir.X += 1;
|
|
if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) dir.X -= 1;
|
|
if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) dir.Y += 1;
|
|
if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) dir.Y -= 1;
|
|
if (dir != Vector2.Zero)
|
|
_world.Position += dir * (float)delta * 600f;
|
|
|
|
UpdatePawns((float)delta);
|
|
}
|
|
|
|
public override void _UnhandledInput(InputEvent @event)
|
|
{
|
|
if (@event is InputEventKey esc && esc.Pressed && esc.Keycode == Key.Escape)
|
|
{
|
|
GetTree().ChangeSceneToFile("res://scenes/MainMenu.tscn");
|
|
return;
|
|
}
|
|
if (@event is InputEventMouseButton mb)
|
|
{
|
|
if (mb.ButtonIndex == MouseButton.Left)
|
|
{
|
|
if (mb.Pressed)
|
|
{
|
|
var hit = PawnAt(ScreenToWorld(mb.Position));
|
|
if (hit != null)
|
|
{
|
|
_dragPawn = hit;
|
|
hit.State = PState.Held;
|
|
_dragOffset = hit.Node.Position - ScreenToWorld(mb.Position);
|
|
PlayIdle(hit);
|
|
ShowInfo(hit);
|
|
}
|
|
else
|
|
{
|
|
_dragging = true;
|
|
_infoPanel.Visible = false;
|
|
_selected = null;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (_dragPawn != null) { DropPawn(_dragPawn); _dragPawn = null; }
|
|
_dragging = false;
|
|
}
|
|
}
|
|
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelUp)
|
|
ZoomAt(mb.Position, 1.1f);
|
|
else if (mb.Pressed && mb.ButtonIndex == MouseButton.WheelDown)
|
|
ZoomAt(mb.Position, 1f / 1.1f);
|
|
}
|
|
else if (@event is InputEventMouseMotion mm)
|
|
{
|
|
if (_dragPawn != null)
|
|
_dragPawn.Node.Position = ScreenToWorld(mm.Position) + _dragOffset;
|
|
else if (_dragging)
|
|
_world.Position += mm.Relative;
|
|
}
|
|
else if (@event is InputEventKey k && k.Pressed && !k.Echo && k.Keycode == Key.R)
|
|
{
|
|
ResetView();
|
|
}
|
|
}
|
|
|
|
private Vector2 ScreenToWorld(Vector2 s) => (s - _world.Position) / _world.Scale;
|
|
}
|