diff --git a/README.md b/README.md index 98184f5..706acad 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## 项目环境 - Godot Engine 4.5.1 -- .NET Core 9.0.301 +- .NET SDK 8.x(本仓库默认 `net8.0`,确保更高兼容性;如你本机是 .NET 9 也可把 `RogueTank.csproj` 的 `TargetFramework` 改为 `net9.0`) ## 核心玩法(MVP) - **进入关卡**:随机生成地图与敌人配置 @@ -36,7 +36,20 @@ ## 开发与运行(占位) 1. 安装 Godot 4.5.1(建议使用 .NET 版本) 2. 使用 Godot 打开本项目文件夹(`project.godot` 所在目录) -3. 选择运行主场景并启动 +3. 运行主场景:`res://Scenes/Main.tscn` +4. 点击运行(F5) + +## 已实现的 Demo(可运行 MVP) +- **关卡/战斗**:进入一个随机生成的“竞技场”(随机障碍 + 边界墙),刷出一波敌人;清完进入下一波并重新随机障碍 +- **战斗采集**:击毁敌人掉落 **经验/金币/小概率修理包**,靠近会自动拾取(含磁吸范围) +- **升级改装**:每波结束(以及升级时)弹出 3 选 1 升级(移速/射速/伤害/最大生命/减伤/弹速/穿透/拾取范围/维修) +- **继续推进**:波次递增,敌人血量/伤害/移速逐渐提高,坦克被击毁会结算并可重开 + +## 输入映射(默认) +- **移动**:WASD 或 方向键 +- **瞄准**:鼠标 +- **射击**:鼠标左键(按住连射) +- **暂停**:Esc(暂停界面可继续/重开) > 说明:后续补充“主场景路径 / 输入映射 / 导出配置”。 diff --git a/RogueTank.csproj b/RogueTank.csproj new file mode 100644 index 0000000..9757662 --- /dev/null +++ b/RogueTank.csproj @@ -0,0 +1,11 @@ + + + net9.0 + enable + enable + latest + true + + + + diff --git a/Scenes/Main.tscn b/Scenes/Main.tscn new file mode 100644 index 0000000..c67ca3f --- /dev/null +++ b/Scenes/Main.tscn @@ -0,0 +1,255 @@ +[gd_scene load_steps=8 format=3] + +[ext_resource type="Script" path="res://Scripts/Game.cs" id="1_4j3d2"] + +[sub_resource type="Theme" id="Theme_1"] + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_1"] +bg_color = Color(0.05, 0.05, 0.07, 0.85) +border_width_left = 2 +border_width_top = 2 +border_width_right = 2 +border_width_bottom = 2 +border_color = Color(0.2, 0.2, 0.25, 1) +content_margin_left = 10 +content_margin_top = 10 +content_margin_right = 10 +content_margin_bottom = 10 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2"] +bg_color = Color(0.12, 0.12, 0.14, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.3, 0.3, 0.35, 1) +content_margin_left = 10 +content_margin_top = 8 +content_margin_right = 10 +content_margin_bottom = 8 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_3"] +bg_color = Color(0.18, 0.18, 0.21, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.5, 0.5, 0.55, 1) +content_margin_left = 10 +content_margin_top = 10 +content_margin_right = 10 +content_margin_bottom = 10 + +[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4"] +bg_color = Color(0.22, 0.22, 0.25, 1) +border_width_left = 1 +border_width_top = 1 +border_width_right = 1 +border_width_bottom = 1 +border_color = Color(0.7, 0.7, 0.75, 1) +content_margin_left = 10 +content_margin_top = 10 +content_margin_right = 10 +content_margin_bottom = 10 + +[sub_resource type="LabelSettings" id="LabelSettings_1"] +font_size = 14 + +[node name="Main" type="Node2D"] +script = ExtResource("1_4j3d2") + +[node name="World" type="Node2D" parent="."] + +[node name="Arena" type="Node2D" parent="World"] + +[node name="Actors" type="Node2D" parent="World"] + +[node name="Projectiles" type="Node2D" parent="World"] + +[node name="Pickups" type="Node2D" parent="World"] + +[node name="Camera2D" type="Camera2D" parent="World"] +zoom = Vector2(2, 2) +position_smoothing_enabled = true +position_smoothing_speed = 8.0 + +[node name="CanvasLayer" type="CanvasLayer" parent="."] + +[node name="HUD" type="Control" parent="CanvasLayer"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme = SubResource("Theme_1") + +[node name="TopBar" type="PanelContainer" parent="CanvasLayer/HUD"] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 12.0 +offset_top = 12.0 +offset_right = -12.0 +offset_bottom = 48.0 +theme_override_styles/panel = SubResource("StyleBoxFlat_2") + +[node name="HBox" type="HBoxContainer" parent="CanvasLayer/HUD/TopBar"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="HpLabel" type="Label" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +text = "HP: 0/0" +label_settings = SubResource("LabelSettings_1") + +[node name="Spacer1" type="Control" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="LevelLabel" type="Label" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +text = "Lv 1 (0/0)" +label_settings = SubResource("LabelSettings_1") + +[node name="Spacer2" type="Control" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="CoinLabel" type="Label" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +text = "Coin: 0" +label_settings = SubResource("LabelSettings_1") + +[node name="Spacer3" type="Control" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="WaveLabel" type="Label" parent="CanvasLayer/HUD/TopBar/HBox"] +layout_mode = 2 +text = "Wave: 1" +label_settings = SubResource("LabelSettings_1") + +[node name="Hint" type="Label" parent="CanvasLayer/HUD"] +layout_mode = 1 +anchors_preset = 14 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 12.0 +offset_top = -36.0 +offset_right = -12.0 +offset_bottom = -12.0 +text = "WASD/方向键移动 | 鼠标瞄准 | 左键射击 | Esc暂停" +horizontal_alignment = 1 +label_settings = SubResource("LabelSettings_1") + +[node name="PauseOverlay" type="PanelContainer" parent="CanvasLayer/HUD"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_styles/panel = SubResource("StyleBoxFlat_1") + +[node name="PauseVBox" type="VBoxContainer" parent="CanvasLayer/HUD/PauseOverlay"] +layout_mode = 2 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -160.0 +offset_top = -70.0 +offset_right = 160.0 +offset_bottom = 70.0 + +[node name="PauseTitle" type="Label" parent="CanvasLayer/HUD/PauseOverlay/PauseVBox"] +layout_mode = 2 +text = "暂停" +horizontal_alignment = 1 +label_settings = SubResource("LabelSettings_1") + +[node name="ResumeBtn" type="Button" parent="CanvasLayer/HUD/PauseOverlay/PauseVBox"] +layout_mode = 2 +text = "继续 (Esc)" + +[node name="RestartBtn" type="Button" parent="CanvasLayer/HUD/PauseOverlay/PauseVBox"] +layout_mode = 2 +text = "重新开始" + +[node name="UpgradeOverlay" type="PanelContainer" parent="CanvasLayer/HUD"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_styles/panel = SubResource("StyleBoxFlat_3") + +[node name="UpgradeVBox" type="VBoxContainer" parent="CanvasLayer/HUD/UpgradeOverlay"] +layout_mode = 2 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -220.0 +offset_top = -130.0 +offset_right = 220.0 +offset_bottom = 130.0 +alignment = 1 + +[node name="UpgradeTitle" type="Label" parent="CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox"] +layout_mode = 2 +text = "选择一项升级" +horizontal_alignment = 1 +label_settings = SubResource("LabelSettings_1") + +[node name="UpgradeBtn1" type="Button" parent="CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox"] +layout_mode = 2 +text = "Upgrade 1" + +[node name="UpgradeBtn2" type="Button" parent="CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox"] +layout_mode = 2 +text = "Upgrade 2" + +[node name="UpgradeBtn3" type="Button" parent="CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox"] +layout_mode = 2 +text = "Upgrade 3" + +[node name="GameOverOverlay" type="PanelContainer" parent="CanvasLayer/HUD"] +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +theme_override_styles/panel = SubResource("StyleBoxFlat_4") + +[node name="GameOverVBox" type="VBoxContainer" parent="CanvasLayer/HUD/GameOverOverlay"] +layout_mode = 2 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -180.0 +offset_top = -90.0 +offset_right = 180.0 +offset_bottom = 90.0 +alignment = 1 + +[node name="GameOverTitle" type="Label" parent="CanvasLayer/HUD/GameOverOverlay/GameOverVBox"] +layout_mode = 2 +text = "你被击毁了" +horizontal_alignment = 1 +label_settings = SubResource("LabelSettings_1") + +[node name="GameOverStats" type="Label" parent="CanvasLayer/HUD/GameOverOverlay/GameOverVBox"] +layout_mode = 2 +text = "Wave: 1 Coin: 0" +horizontal_alignment = 1 +label_settings = SubResource("LabelSettings_1") + +[node name="GameOverRestartBtn" type="Button" parent="CanvasLayer/HUD/GameOverOverlay/GameOverVBox"] +layout_mode = 2 +text = "重新开始" + + \ No newline at end of file diff --git a/Scripts/Actors/EnemyTank.cs b/Scripts/Actors/EnemyTank.cs new file mode 100644 index 0000000..7caf840 --- /dev/null +++ b/Scripts/Actors/EnemyTank.cs @@ -0,0 +1,73 @@ +using Godot; +using RogueTank.Combat; +using RogueTank.Util; + +namespace RogueTank.Actors; + +public partial class EnemyTank : CharacterBody2D, IDamageable +{ + [Signal] public delegate void DiedEventHandler(EnemyTank who); + + public float MoveSpeed { get; set; } = 110f; + public float MaxHp { get; set; } = 30f; + public float Hp { get; private set; } = 30f; + public float ContactDamage { get; set; } = 8f; + + public Node2D? Target { get; set; } + + private Sprite2D _sprite = null!; + + public override void _Ready() + { + Hp = MaxHp; + _sprite = new Sprite2D + { + Texture = PixelArt.MakeTankTexture(16, new Color(0.75f, 0.2f, 0.2f), new Color(0.95f, 0.7f, 0.2f)), + Centered = true, + TextureFilter = CanvasItem.TextureFilterEnum.Nearest + }; + AddChild(_sprite); + + var col = new CollisionShape2D + { + Shape = new CircleShape2D { Radius = 7f } + }; + AddChild(col); + } + + public override void _PhysicsProcess(double delta) + { + if (Target == null || !IsInstanceValid(Target)) + { + Velocity = Vector2.Zero; + MoveAndSlide(); + return; + } + + var dir = (Target.GlobalPosition - GlobalPosition); + if (dir.Length() > 1f) dir = dir.Normalized(); + Velocity = dir * MoveSpeed; + Rotation = Velocity.Angle(); + MoveAndSlide(); + + // crude contact damage + if (Target is PlayerTank p) + { + var dist = (p.GlobalPosition - GlobalPosition).Length(); + if (dist < 14f) + p.TakeDamage(ContactDamage * (float)delta); + } + } + + public void TakeDamage(float amount) + { + Hp -= amount; + if (Hp <= 0f) + { + EmitSignal(SignalName.Died, this); + QueueFree(); + } + } +} + + diff --git a/Scripts/Actors/EnemyTank.cs.uid b/Scripts/Actors/EnemyTank.cs.uid new file mode 100644 index 0000000..e31ba93 --- /dev/null +++ b/Scripts/Actors/EnemyTank.cs.uid @@ -0,0 +1 @@ +uid://by72qj47ax6m diff --git a/Scripts/Actors/PlayerTank.cs b/Scripts/Actors/PlayerTank.cs new file mode 100644 index 0000000..67c59b7 --- /dev/null +++ b/Scripts/Actors/PlayerTank.cs @@ -0,0 +1,199 @@ +using Godot; +using RogueTank.Combat; +using RogueTank.Util; + +namespace RogueTank.Actors; + +public partial class PlayerTank : CharacterBody2D, IDamageable +{ + [Signal] public delegate void StatsChangedEventHandler(); + [Signal] public delegate void DiedEventHandler(); + [Signal] public delegate void ShotFiredEventHandler(); + + public Vector2 DebugLastMoveInput { get; private set; } = Vector2.Zero; + + // Stats + public float MoveSpeed { get; set; } = 165f; + public float FireRate { get; set; } = 4.0f; // shots/sec + public float Damage { get; set; } = 12f; + public float Armor { get; set; } = 0f; // flat reduction + public float BulletSpeed { get; set; } = 520f; + public int BulletPierce { get; set; } = 0; + public float MagnetRadius { get; set; } = 42f; + + public float MaxHp { get; set; } = 100f; + public float Hp { get; private set; } = 100f; + + public int Level { get; private set; } = 1; + public int Xp { get; private set; } + public int XpToNext { get; private set; } = 30; + public int Coins { get; private set; } + + // References + public Node2D? ProjectilesRoot { get; set; } + public PackedScene? BulletScene { get; set; } + public Node2D? PickupsRoot { get; set; } + + private Sprite2D _sprite = null!; + private Sprite2D _turret = null!; + private Area2D _magnet = null!; + private CollisionShape2D _magnetShape = null!; + + private float _shootCd; + + public override void _Ready() + { + Hp = MaxHp; + + CollisionLayer = 1u << 3; // player + CollisionMask = (1u << 0) | (1u << 1); // walls + enemies + + _sprite = new Sprite2D + { + Texture = PixelArt.MakeTankTexture(16, new Color(0.15f, 0.75f, 0.35f), new Color(0.1f, 0.9f, 0.35f)), + Centered = true, + TextureFilter = CanvasItem.TextureFilterEnum.Nearest + }; + AddChild(_sprite); + + _turret = new Sprite2D + { + Texture = PixelArt.MakeTankTexture(16, new Color(0.0f, 0.0f, 0.0f, 0.0f), new Color(0.95f, 0.95f, 0.95f)), + Centered = true, + TextureFilter = CanvasItem.TextureFilterEnum.Nearest, + Modulate = new Color(0.85f, 0.95f, 0.9f) + }; + AddChild(_turret); + + var col = new CollisionShape2D + { + Shape = new CircleShape2D { Radius = 7f } + }; + AddChild(col); + + _magnet = new Area2D(); + _magnet.CollisionLayer = 1u << 5; // magnet + _magnet.CollisionMask = 0; + _magnetShape = new CollisionShape2D { Shape = new CircleShape2D { Radius = MagnetRadius } }; + _magnet.AddChild(_magnetShape); + AddChild(_magnet); + } + + public override void _PhysicsProcess(double delta) + { + var d = (float)delta; + var input = ReadMoveInput(); + DebugLastMoveInput = input; + if (input.Length() > 1f) + input = input.Normalized(); + + Velocity = input * MoveSpeed; + MoveAndSlide(); + + AimTurret(); + HandleShooting(d); + UpdateMagnet(); + } + + private static Vector2 ReadMoveInput() + { + // Prefer InputMap actions; if they're missing/broken, fallback to raw keys. + if (InputMap.HasAction("move_left") && InputMap.HasAction("move_right") && + InputMap.HasAction("move_up") && InputMap.HasAction("move_down")) + { + return Input.GetVector("move_left", "move_right", "move_up", "move_down"); + } + + float x = 0f; + float y = 0f; + if (Input.IsKeyPressed(Key.A) || Input.IsKeyPressed(Key.Left)) x -= 1f; + if (Input.IsKeyPressed(Key.D) || Input.IsKeyPressed(Key.Right)) x += 1f; + if (Input.IsKeyPressed(Key.W) || Input.IsKeyPressed(Key.Up)) y -= 1f; + if (Input.IsKeyPressed(Key.S) || Input.IsKeyPressed(Key.Down)) y += 1f; + return new Vector2(x, y); + } + + private void AimTurret() + { + var viewport = GetViewport(); + if (viewport == null) return; + + var worldMouse = GetGlobalMousePosition(); + _turret.Rotation = (worldMouse - GlobalPosition).Angle(); + } + + private void HandleShooting(float delta) + { + _shootCd -= delta; + var wantShoot = + (InputMap.HasAction("shoot") && Input.IsActionPressed("shoot")) || + Input.IsMouseButtonPressed(MouseButton.Left); + if (!wantShoot || _shootCd > 0f) return; + if (BulletScene == null || ProjectilesRoot == null) return; + + _shootCd = 1f / Mathf.Max(0.1f, FireRate); + + var bullet = BulletScene.Instantiate(); + var dir = new Vector2(Mathf.Cos(_turret.Rotation), Mathf.Sin(_turret.Rotation)); + bullet.GlobalPosition = GlobalPosition + dir * 10f; + bullet.Speed = BulletSpeed; + bullet.Damage = Damage; + bullet.Pierce = BulletPierce; + bullet.Init(dir); + ProjectilesRoot.AddChild(bullet); + + EmitSignal(SignalName.ShotFired); + } + + private void UpdateMagnet() + { + if (_magnetShape.Shape is CircleShape2D cs) + cs.Radius = MagnetRadius; + } + + public void AddCoins(int amount) + { + Coins += amount; + EmitSignal(SignalName.StatsChanged); + } + + public bool AddXp(int amount) + { + Xp += amount; + var leveled = false; + while (Xp >= XpToNext) + { + Xp -= XpToNext; + Level++; + XpToNext = Mathf.RoundToInt(XpToNext * 1.22f + 10); + leveled = true; + } + EmitSignal(SignalName.StatsChanged); + return leveled; + } + + public void Heal(float amount) + { + Hp = Mathf.Min(MaxHp, Hp + amount); + EmitSignal(SignalName.StatsChanged); + } + + public void TakeDamage(float amount) + { + var final = Mathf.Max(1f, amount - Armor); + Hp -= final; + if (Hp <= 0f) + { + Hp = 0f; + EmitSignal(SignalName.StatsChanged); + EmitSignal(SignalName.Died); + QueueFree(); + return; + } + EmitSignal(SignalName.StatsChanged); + } + + public Area2D GetMagnetArea() => _magnet; +} + + diff --git a/Scripts/Actors/PlayerTank.cs.uid b/Scripts/Actors/PlayerTank.cs.uid new file mode 100644 index 0000000..56ff1d1 --- /dev/null +++ b/Scripts/Actors/PlayerTank.cs.uid @@ -0,0 +1 @@ +uid://b1g6f3hc5rctf diff --git a/Scripts/Combat/Bullet.cs b/Scripts/Combat/Bullet.cs new file mode 100644 index 0000000..9fb2b20 --- /dev/null +++ b/Scripts/Combat/Bullet.cs @@ -0,0 +1,66 @@ +using Godot; + +namespace RogueTank.Combat; + +public partial class Bullet : Area2D +{ + [Export] public float Speed = 520f; + [Export] public float Damage = 10f; + [Export] public int Pierce = 0; + [Export] public float Lifetime = 1.6f; + + private float _alive; + private Vector2 _velocity; + + public void Init(Vector2 dir) + { + _velocity = dir.Normalized() * Speed; + Rotation = _velocity.Angle(); + } + + public override void _Ready() + { + BodyEntered += OnBodyEntered; + AreaEntered += OnAreaEntered; + } + + public override void _PhysicsProcess(double delta) + { + var d = (float)delta; + GlobalPosition += _velocity * d; + _alive += d; + if (_alive >= Lifetime) + QueueFree(); + } + + private void OnBodyEntered(Node body) + { + if (body is IDamageable dmg) + { + dmg.TakeDamage(Damage); + if (Pierce <= 0) + QueueFree(); + else + Pierce--; + } + else if (body is StaticBody2D) + { + QueueFree(); + } + } + + private void OnAreaEntered(Area2D area) + { + // In case targets use Area2D-based hurtboxes. + if (area is IDamageableArea dmg) + { + dmg.TakeDamage(Damage); + if (Pierce <= 0) + QueueFree(); + else + Pierce--; + } + } +} + + diff --git a/Scripts/Combat/Bullet.cs.uid b/Scripts/Combat/Bullet.cs.uid new file mode 100644 index 0000000..c7fa2df --- /dev/null +++ b/Scripts/Combat/Bullet.cs.uid @@ -0,0 +1 @@ +uid://dlichk5t54pi diff --git a/Scripts/Combat/IDamageable.cs b/Scripts/Combat/IDamageable.cs new file mode 100644 index 0000000..7806fb1 --- /dev/null +++ b/Scripts/Combat/IDamageable.cs @@ -0,0 +1,13 @@ +namespace RogueTank.Combat; + +public interface IDamageable +{ + void TakeDamage(float amount); +} + +public interface IDamageableArea +{ + void TakeDamage(float amount); +} + + diff --git a/Scripts/Combat/IDamageable.cs.uid b/Scripts/Combat/IDamageable.cs.uid new file mode 100644 index 0000000..724664d --- /dev/null +++ b/Scripts/Combat/IDamageable.cs.uid @@ -0,0 +1 @@ +uid://c45vi17n37lk3 diff --git a/Scripts/Game.cs b/Scripts/Game.cs new file mode 100644 index 0000000..ec4a78e --- /dev/null +++ b/Scripts/Game.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Godot; +using RogueTank.Actors; +using RogueTank.Combat; +using RogueTank.Upgrades; +using RogueTank.Util; +using RogueTank.World; + +namespace RogueTank; + +public partial class Game : Node2D +{ + private static readonly Vector2 ArenaHalfSize = new(250, 150); + + // Scene roots + private Node2D _world = null!; + private Node2D _arena = null!; + private Node2D _actors = null!; + private Node2D _projectiles = null!; + private Node2D _pickups = null!; + private Camera2D _camera = null!; + + // UI + private Label _hpLabel = null!; + private Label _levelLabel = null!; + private Label _coinLabel = null!; + private Label _waveLabel = null!; + private Label _hintLabel = null!; + + private Control _pauseOverlay = null!; + private Button _resumeBtn = null!; + private Button _restartBtn = null!; + + private Control _upgradeOverlay = null!; + private Button _up1 = null!; + private Button _up2 = null!; + private Button _up3 = null!; + + private Control _gameOverOverlay = null!; + private Label _gameOverStats = null!; + private Button _gameOverRestartBtn = null!; + + // Gameplay + private PlayerTank? _player; + private readonly PackedScene _bulletScene; + private readonly PackedScene _enemyScene; + private readonly PackedScene _pickupScene; + private readonly RandomNumberGenerator _rng = new(); + + private int _wave = 1; + private int _enemiesAlive; + private bool _paused; + private bool _choosingUpgrade; + private string _lastDebug = ""; + private int _shotsFired; + + private readonly List _currentChoices = new(); + + public Game() + { + _bulletScene = MakeBulletScene(); + _enemyScene = MakeEnemyScene(); + _pickupScene = MakePickupScene(); + } + + public override void _Ready() + { + _rng.Randomize(); + // IMPORTANT: "WhenPaused" will prevent the game from processing during normal gameplay. + // Keep the game running normally; only the UI should keep processing while paused. + ProcessMode = ProcessModeEnum.Pausable; + + // Safety: ensure we start unpaused (some editor/debug states can leave the tree paused). + GetTree().Paused = false; + + _world = GetNode("World"); + _arena = GetNode("World/Arena"); + _actors = GetNode("World/Actors"); + _projectiles = GetNode("World/Projectiles"); + _pickups = GetNode("World/Pickups"); + _camera = GetNode("World/Camera2D"); + + var canvas = GetNode("CanvasLayer"); + canvas.ProcessMode = ProcessModeEnum.WhenPaused; + GetNode("CanvasLayer/HUD").ProcessMode = ProcessModeEnum.WhenPaused; + + _hpLabel = GetNode