Уровень, таймеры и состояние раунда
Свяжите игрока, противников, таймеры и интерфейс одним контроллером без повторных подписок.
Иерархия уровня
Создайте File → Create Scene…, выберите корень Entity2D и сохраните Levels/Arena.rscene. Его положение, поворот и масштаб оставьте исходными. Разместите префабы игрока и интерфейса, затем добавьте контейнер и таймеры:
Arena (Entity2D, DodgeGame.cs)
├── Player (instance: Prefabs/Player.rprefab)
├── Enemies (Entity2D)
├── SpawnTimer (Timer)
├── ScoreTimer (Timer)
└── Hud (instance: Prefabs/Hud.rprefab)Имена и пути должны совпадать с кодом. Экземпляру игрока задайте Position = (320, 300). У обоих таймеров Process Callback = Physics, One Shot и Autostart выключены. SpawnTimer.WaitTime = 0.75, ScoreTimer.WaitTime = 1.0.
Добавьте все четыре C#-файла, включая DodgeHud.cs, до общей сборки. Прикрепите DodgeGame.cs к Arena.
Контроллер
Playing отличает активный раунд от начального экрана и поражения. StartRun() сбрасывает счёт и игрока, очищает старые экземпляры и запускает два таймера. EndRun() останавливает их, управление и движение оставшихся противников. Повторный вызов начала активного раунда ничего не делает.
Полный DodgeGame.cs
using Reckfell;
public partial class DodgeGame : Entity2D
{
public bool Playing { get; private set; }
public int Score { get; private set; }
private DodgePlayer _player = null!;
private Entity2D _enemies = null!;
private Timer _spawnTimer = null!;
private Timer _scoreTimer = null!;
private DodgeHud _hud = null!;
private PackedEntity? _enemyTemplate;
private readonly RandomNumberGenerator _random = new();
public override void _EnterTree()
{
_player = GetEntity<DodgePlayer>("Player");
_enemies = GetEntity<Entity2D>("Enemies");
_spawnTimer = GetEntity<Timer>("SpawnTimer");
_scoreTimer = GetEntity<Timer>("ScoreTimer");
_hud = GetEntity<DodgeHud>("Hud");
_player.Hit += EndRun;
_spawnTimer.Timeout += OnSpawnTimeout;
_scoreTimer.Timeout += OnScoreTimeout;
_hud.StartRequested += StartRun;
}
public override void _ExitTree()
{
if (ReckfellObject.IsInstanceValid(_player)) _player.Hit -= EndRun;
if (ReckfellObject.IsInstanceValid(_spawnTimer)) _spawnTimer.Timeout -= OnSpawnTimeout;
if (ReckfellObject.IsInstanceValid(_scoreTimer)) _scoreTimer.Timeout -= OnScoreTimeout;
if (ReckfellObject.IsInstanceValid(_hud)) _hud.StartRequested -= StartRun;
}
public override void _Ready()
{
_player.Active = false;
_random.Randomize();
_hud.SetScore(0);
_enemyTemplate = ResourceLoader.Load("asset://Prefabs/Enemy.rprefab") as PackedEntity;
if (_enemyTemplate == null || !_enemyTemplate.CanInstantiate())
{
_hud.ShowSetupError();
return;
}
_hud.ShowReady();
}
public void StartRun()
{
if (Playing || _enemyTemplate == null)
return;
foreach (Entity enemy in _enemies.GetChildren())
enemy.QueueFree();
Score = 0;
Playing = true;
_player.StartAt(new Vector2(320, 300));
_hud.SetScore(Score);
_hud.ShowPlaying();
_spawnTimer.Start();
_scoreTimer.Start();
}
public DodgeEnemy? SpawnEnemy()
{
if (!Playing || _enemyTemplate == null)
return null;
Entity instance = _enemyTemplate.Instantiate();
if (instance is not DodgeEnemy enemy)
{
instance?.Free();
ReckfellApi.PushError("Enemy.rprefab must have a DodgeEnemy root.");
EndRun();
return null;
}
enemy.Position = new Vector2(_random.RandfRange(24, 616), -20);
_enemies.AddChild(enemy);
return enemy;
}
public void EndRun()
{
if (!Playing)
return;
Playing = false;
_player.Active = false;
_player.Velocity = Vector2.Zero;
_spawnTimer.Stop();
_scoreTimer.Stop();
foreach (Entity enemy in _enemies.GetChildren())
enemy.SetPhysicsProcess(false);
_hud.ShowGameOver();
}
private void OnSpawnTimeout() => SpawnEnemy();
private void OnScoreTimeout()
{
if (Playing)
_hud.SetScore(++Score);
}
}null! у полей подавляет предупреждение C# до заполнения ссылок в _EnterTree(). Это не проверка существования объекта: ошибка в иерархии всё равно требует исправления. Родитель может найти сохранённых детей при своём входе; их готовность завершается до его _Ready().
Создание противника
Enemy.rprefab загружается один раз. Таймер вызывает SpawnEnemy(): создаётся и проверяется корень DodgeEnemy, до присоединения назначается позиция, затем AddChild() запускает жизненный цикл. Локальная X выбирается от 24 до 616, Y = −20. В этом примере корень уровня и контейнер не преобразованы, поэтому эти координаты совпадают с координатами поля.
При неверном корне экземпляр освобождается, ошибка выводится в Output, раунд останавливается. Пропущенный файл также требует исправления, а не тихой подмены другим объектом.
Счёт и перезапуск
Таймер счёта добавляет одно очко за сигнал Timeout, только пока Playing = true. Это игровые секунды: изменение масштаба времени или остановка приложения влияет на таймер. Это не часы реального времени и не сохранение рекорда.
Кнопка интерфейса отправляет StartRequested; источник каждого события подключается ровно в одном месте. После проигрыша следующий запуск удаляет старые круги через QueueFree(). У них есть защита от отложенного попадания. Законченный интерфейс, проверка цикла раунда.