From 1ce7797df96a0891dfc223637798917de89d2183 Mon Sep 17 00:00:00 2001
From: Weifubin <382063817@qq.com>
Date: Tue, 16 Dec 2025 01:43:18 +0800
Subject: [PATCH] feat: add core game mechanics and assets for RogueTank
---
README.md | 17 +-
RogueTank.csproj | 11 +
Scenes/Main.tscn | 255 +++++++++++++
Scripts/Actors/EnemyTank.cs | 73 ++++
Scripts/Actors/EnemyTank.cs.uid | 1 +
Scripts/Actors/PlayerTank.cs | 199 ++++++++++
Scripts/Actors/PlayerTank.cs.uid | 1 +
Scripts/Combat/Bullet.cs | 66 ++++
Scripts/Combat/Bullet.cs.uid | 1 +
Scripts/Combat/IDamageable.cs | 13 +
Scripts/Combat/IDamageable.cs.uid | 1 +
Scripts/Game.cs | 512 ++++++++++++++++++++++++++
Scripts/Game.cs.uid | 1 +
Scripts/Upgrades/UpgradeId.cs | 16 +
Scripts/Upgrades/UpgradeId.cs.uid | 1 +
Scripts/Upgrades/UpgradeOption.cs | 13 +
Scripts/Upgrades/UpgradeOption.cs.uid | 1 +
Scripts/Util/PixelArt.cs | 74 ++++
Scripts/Util/PixelArt.cs.uid | 1 +
Scripts/World/Pickup.cs | 87 +++++
Scripts/World/Pickup.cs.uid | 1 +
control.tscn | 9 +
project.godot | 106 +++++-
23 files changed, 1457 insertions(+), 3 deletions(-)
create mode 100644 RogueTank.csproj
create mode 100644 Scenes/Main.tscn
create mode 100644 Scripts/Actors/EnemyTank.cs
create mode 100644 Scripts/Actors/EnemyTank.cs.uid
create mode 100644 Scripts/Actors/PlayerTank.cs
create mode 100644 Scripts/Actors/PlayerTank.cs.uid
create mode 100644 Scripts/Combat/Bullet.cs
create mode 100644 Scripts/Combat/Bullet.cs.uid
create mode 100644 Scripts/Combat/IDamageable.cs
create mode 100644 Scripts/Combat/IDamageable.cs.uid
create mode 100644 Scripts/Game.cs
create mode 100644 Scripts/Game.cs.uid
create mode 100644 Scripts/Upgrades/UpgradeId.cs
create mode 100644 Scripts/Upgrades/UpgradeId.cs.uid
create mode 100644 Scripts/Upgrades/UpgradeOption.cs
create mode 100644 Scripts/Upgrades/UpgradeOption.cs.uid
create mode 100644 Scripts/Util/PixelArt.cs
create mode 100644 Scripts/Util/PixelArt.cs.uid
create mode 100644 Scripts/World/Pickup.cs
create mode 100644 Scripts/World/Pickup.cs.uid
create mode 100644 control.tscn
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