Движение 2D сверху: ввод, столкновения и анимация
Готовый персонаж с WASD, нормальной скоростью по диагонали и остановкой у стены.
Что получится
Персонаж перемещается по WASD со скоростью 160 единиц canvas в секунду, скользит вдоль стены и переключает idle/walk по фактическому движению. Четыре кадра создаются кодом: внешние изображения не нужны. Это вид сверху, без прыжка и гравитации; для платформера нужен другой режим движения.
Запустить в пустом уровне
- Создайте C#-проект с Reckfell.NET.Sdk 1.0.0 и net10.0.
- Сохраните четыре файла ниже в проект, выполните dotnet build.
- Создайте уровень с корнем Entity2D, прикрепите TopDownExample.cs, сохраните как top_down.rscene и запустите этот уровень.
- Персонаж появляется в (96,96), стена — в (200,96). Нажмите D: персонаж движется вправо и останавливается перед стеной. D+W позволяет двигаться вдоль неё вверх.
Скрипт сам создаёт персонажа, графику, CollisionShape2D и стену. Не добавляйте ещё один набор форм вручную. Используйте масштаб корня и физических тел (1,1); меняйте размер самих Shape, если нужны другие габариты.
ActorInput.cs
using Reckfell;
public static class ActorInput
{
public static void Ensure()
{
Add("actor_left", Key.A);
Add("actor_right", Key.D);
Add("actor_up", Key.W);
Add("actor_down", Key.S);
}
private static void Add(string action, Key key)
{
if (InputMap.HasAction(action)) return;
InputMap.AddAction(action);
InputMap.ActionAddEvent(action, new InputEventKey { PhysicalKeycode = key });
}
}ActorFrames.cs
using Reckfell;
public static class ActorFrames
{
public static SpriteFrames Create()
{
using var image = Image.CreateEmpty(128, 32, false, Image.Format.Rgba8);
image.Fill(Colors.Transparent);
for (int i = 0; i < 4; i++)
{
int x = i * 32;
int stride = i == 1 ? 3 : i == 3 ? -3 : 0;
image.FillRect(new Rect2I(x + 9, 4, 14, 16), Colors.White);
image.FillRect(new Rect2I(x + 19, 8, 3, 3), Colors.Black);
image.FillRect(new Rect2I(x + 9 + stride, 20, 5, 8), new Color(0.25f, 0.45f, 1));
image.FillRect(new Rect2I(x + 18 - stride, 20, 5, 8), new Color(0.25f, 0.45f, 1));
}
var atlas = ImageTexture.CreateFromImage(image);
var frames = new SpriteFrames();
frames.RemoveAnimation("default");
frames.AddAnimation("idle");
frames.SetAnimationLoopMode("idle", SpriteFrames.LoopMode.Linear);
frames.SetAnimationSpeed("idle", 1);
frames.AddAnimation("walk");
frames.SetAnimationLoopMode("walk", SpriteFrames.LoopMode.Linear);
frames.SetAnimationSpeed("walk", 8);
for (int i = 0; i < 4; i++)
{
var region = new AtlasTexture { Atlas = atlas, Region = new Rect2(i * 32, 0, 32, 32) };
frames.AddFrame("walk", region, i == 2 ? 2 : 1);
if (i == 0) frames.AddFrame("idle", region);
}
return frames;
}
}TopDownActor.cs
using Reckfell;
public partial class TopDownActor : CharacterBody2D
{
[Export] public float Speed { get; set; } = 160;
public AnimatedSprite2D Sprite { get; private set; } = null!;
public override void _Ready()
{
ActorInput.Ensure();
MotionMode = MotionModeEnum.Floating;
AddChild(new CollisionShape2D { Shape = new RectangleShape2D { Size = new Vector2(18, 26) } });
Sprite = new AnimatedSprite2D
{
SpriteFrames = ActorFrames.Create(),
TextureFilter = TextureFilterEnum.Nearest
};
AddChild(Sprite);
Sprite.Play("idle");
}
public override void _PhysicsProcess(double delta)
{
Vector2 direction = Input.GetVector("actor_left", "actor_right", "actor_up", "actor_down");
float speed = float.IsFinite(Speed) && Speed > 0 ? Speed : 0;
Velocity = direction * speed;
MoveAndSlide();
if (direction.X != 0) Sprite.FlipH = direction.X < 0;
StringName animation = GetRealVelocity().LengthSquared() > 1 ? "walk" : "idle";
if (Sprite.Animation != animation) Sprite.Play(animation);
}
}TopDownExample.cs
using Reckfell;
public partial class TopDownExample : Entity2D
{
public TopDownActor Actor { get; private set; } = null!;
public override void _Ready()
{
Actor = new TopDownActor { Position = new Vector2(96, 96) };
AddChild(Actor);
var wall = new StaticBody2D { Position = new Vector2(200, 96) };
AddChild(wall);
wall.AddChild(new CollisionShape2D { Shape = new RectangleShape2D { Size = new Vector2(20, 160) } });
wall.AddChild(new Polygon2D
{
Polygon = new[] { new Vector2(-10, -80), new Vector2(10, -80), new Vector2(10, 80), new Vector2(-10, 80) },
Color = new Color(0.2f, 0.22f, 0.25f)
});
var hud = new CanvasLayer { Layer = 1 };
AddChild(hud);
hud.AddChild(new Label { Position = new Vector2(12, 190), Text = "W A S D" });
}
}Почему движение устроено так
Input.GetVector ограничивает длину диагонального ввода единицей и обрабатывает круговую deadzone. Дополнительно нормализовать полученный вектор не нужно: это уничтожило бы величину аналогового ввода. ActorInput создаёт отсутствующие действия с физическими клавишами в позициях WASD и сохраняет уже существующие назначения. Изменения InputMap в этом примере действуют во время запуска; для постоянных настроек проекта сохраните назначения в Input Map.
Velocity — скорость, не смещение за кадр. MoveAndSlide сам использует шаг физики; не умножайте Velocity на delta перед вызовом. _PhysicsProcess пересчитывает желаемую скорость на каждом шаге, поскольку столкновение может изменить Velocity. Floating трактует контакты как стены; IsOnFloor здесь не проверяет наличие пола под персонажем.
Прямое изменение Position обходит этот путь движения и не заменяет MoveAndSlide для столкновений. Получить видимую картинку недостаточно: нужна активная CollisionShape2D с Shape и совместимые collision layer/mask у обоих тел. В чистом проекте пример использует их стандартные значения.
Связать анимацию с результатом
GetRealVelocity показывает фактическую скорость после перемещения. При величине больше 1 выбирается walk, иначе idle. Поэтому при упоре в стену персонаж не продолжает шагать на месте. Порог — решение примера; для очень медленного персонажа настройте его под свою игру.
FlipH меняет только спрайт. Физическое тело и форма не зеркалятся. Последнее горизонтальное направление сохраняется при остановке и вертикальном движении. Это две стороны одного спрайта, а не готовый набор анимаций для четырёх или восьми направлений.
Исправить типичные ошибки
| Симптом | Проверка |
|---|---|
| По диагонали быстрее | Не складывайте ненормированные оси вместо GetVector |
| Почти не движется | Уберите лишний delta из Velocity; задайте конечный положительный Speed |
| Проходит сквозь стену | Проверьте Shape, Disabled и collision layer/mask; не перемещайте тело через Position |
| Нет реакции на WASD | Дайте окну игры фокус; проверьте существующие actor_* назначения, которые пример не перезаписывает |
| Слишком большая/маленькая картинка | AtlasTexture.Region задаётся в пикселях атласа; не масштабируйте физический родитель ради картинки |
| Ходьба не останавливается | Выбирайте состояние после MoveAndSlide по GetRealVelocity, если нужна связь с результатом |