Появление противников и раунды
Один контроллер связывает таймер, игрока, префаб противника и интерфейс; старые экземпляры удаляются при перезапуске.
Подготовьте уровень
В иерархии Arena нужны экземпляры Player и Hud, пустой Entity3D с именем Mobs и Timer с именем SpawnTimer. У таймера Wait Time = 1.2, Process Callback = Physics, One Shot и Autostart выключены. Корень уровня и Mobs имеют нулевой transform с масштабом 1.
Прикрепите Scripts/SquashGame.cs к корню. Перед сборкой добавьте SquashHud.cs и остальные классы.
Полный SquashGame.cs
using Reckfell;
public partial class SquashGame : Entity3D
{
public bool Playing { get; private set; }
public int Score { get; private set; }
private SquashPlayer _player = null!;
private Entity3D _mobs = null!;
private Timer _spawnTimer = null!;
private SquashHud _hud = null!;
private PackedEntity? _template;
private readonly RandomNumberGenerator _random = new();
public override void _EnterTree()
{
_player = GetEntity<SquashPlayer>("Player");
_mobs = GetEntity<Entity3D>("Mobs");
_spawnTimer = GetEntity<Timer>("SpawnTimer");
_hud = GetEntity<SquashHud>("Hud");
_player.Hit += EndRun;
_spawnTimer.Timeout += OnSpawnTimeout;
_hud.StartRequested += StartRun;
}
public override void _ExitTree()
{
if (ReckfellObject.IsInstanceValid(_player)) _player.Hit -= EndRun;
if (ReckfellObject.IsInstanceValid(_spawnTimer)) _spawnTimer.Timeout -= OnSpawnTimeout;
if (ReckfellObject.IsInstanceValid(_hud)) _hud.StartRequested -= StartRun;
if (ReckfellObject.IsInstanceValid(_mobs))
foreach (SquashMob mob in _mobs.GetChildren()) mob.Squashed -= OnSquashed;
}
public override void _Ready()
{
_player.Active = false;
_random.Randomize();
_hud.SetScore(0);
_template = ResourceLoader.Load("asset://Prefabs/Mob.rprefab") as PackedEntity;
if (_template == null || !_template.CanInstantiate()) _hud.ShowSetupError();
else _hud.ShowReady();
}
public void StartRun()
{
if (Playing || _template == null) return;
foreach (Entity mob in _mobs.GetChildren()) mob.QueueFree();
Playing = true;
Score = 0;
_player.StartAt(new Vector3(0, 0.9f, 0));
_hud.SetScore(0);
_hud.ShowPlaying();
_spawnTimer.Start();
}
public SquashMob? SpawnMob()
{
if (!Playing || _template == null || _mobs.GetChildCount() >= 32) return null;
Entity instance = _template.Instantiate();
if (instance is not SquashMob mob)
{
instance?.Free();
ReckfellApi.PushError("Mob.rprefab must have a SquashMob root.");
EndRun();
return null;
}
float side = _random.RandfRange(-6, 6);
mob.Position = _random.RandiRange(0, 3) switch
{
0 => new Vector3(-6, 0.4f, side),
1 => new Vector3(6, 0.4f, side),
2 => new Vector3(side, 0.4f, -6),
_ => new Vector3(side, 0.4f, 6)
};
mob.MoveDirection = _player.Position - mob.Position;
mob.Squashed += OnSquashed;
_mobs.AddChild(mob);
return mob;
}
public void EndRun()
{
if (!Playing) return;
Playing = false;
_player.Active = false;
_player.Velocity = Vector3.Zero;
_spawnTimer.Stop();
foreach (Entity mob in _mobs.GetChildren()) mob.SetPhysicsProcess(false);
_hud.ShowGameOver();
}
private void OnSpawnTimeout() => SpawnMob();
private void OnSquashed()
{
if (Playing) _hud.SetScore(++Score);
}
}Где появляется экземпляр
Ресурс Prefabs/Mob.rprefab загружается один раз. При появлении создаётся SquashMob; неподходящий корень освобождается, ошибка выводится, раунд прекращается. Позиция выбирается на одной из четырёх сторон квадрата X/Z = ±6, Y = 0.4.
Начальное направление берётся к текущей позиции игрока. Положение и направление назначены до AddChild(), поэтому жизненный цикл начинается с готовыми исходными данными. Здесь используются локальные координаты контейнера; при его преобразовании изменится и положение противников в мире.
Ограничение количества
При 32 дочерних объектах Mobs новая попытка возвращает null. Объект, ожидающий QueueFree(), ещё входит в число детей до удаления. У каждого противника дополнительно ограничено время жизни. Это локальная защита прототипа от накопления объектов, не система потоковой загрузки мира.
Начало и завершение
StartRun() сбрасывает счёт, удаляет старые экземпляры, возвращает игрока в (0, 0.9, 0) и запускает таймер. Повторный вызов во время раунда ничего не меняет. EndRun() выключает управление, таймер и физическую обработку оставшихся противников, сохраняя финальный счёт на экране.
Каждому новому противнику подключается один обработчик Squashed. Поля заполняются в _EnterTree(), подписки снимаются в _ExitTree(). Не подключайте те же события ещё раз через редактор. Очки и интерфейс.