feat: add core game mechanics and assets for RogueTank

main
Weifubin 4 days ago
parent e7922a316a
commit 1ce7797df9

@ -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暂停界面可继续/重开)
> 说明:后续补充“主场景路径 / 输入映射 / 导出配置”。

@ -0,0 +1,11 @@
<Project Sdk="Godot.NET.Sdk/4.5.1">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
</Project>

@ -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 = "重新开始"

@ -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();
}
}
}

@ -0,0 +1 @@
uid://by72qj47ax6m

@ -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<Bullet>();
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;
}

@ -0,0 +1 @@
uid://b1g6f3hc5rctf

@ -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--;
}
}
}

@ -0,0 +1 @@
uid://dlichk5t54pi

@ -0,0 +1,13 @@
namespace RogueTank.Combat;
public interface IDamageable
{
void TakeDamage(float amount);
}
public interface IDamageableArea
{
void TakeDamage(float amount);
}

@ -0,0 +1 @@
uid://c45vi17n37lk3

@ -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<UpgradeOption> _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<Node2D>("World");
_arena = GetNode<Node2D>("World/Arena");
_actors = GetNode<Node2D>("World/Actors");
_projectiles = GetNode<Node2D>("World/Projectiles");
_pickups = GetNode<Node2D>("World/Pickups");
_camera = GetNode<Camera2D>("World/Camera2D");
var canvas = GetNode<CanvasLayer>("CanvasLayer");
canvas.ProcessMode = ProcessModeEnum.WhenPaused;
GetNode<Control>("CanvasLayer/HUD").ProcessMode = ProcessModeEnum.WhenPaused;
_hpLabel = GetNode<Label>("CanvasLayer/HUD/TopBar/HBox/HpLabel");
_levelLabel = GetNode<Label>("CanvasLayer/HUD/TopBar/HBox/LevelLabel");
_coinLabel = GetNode<Label>("CanvasLayer/HUD/TopBar/HBox/CoinLabel");
_waveLabel = GetNode<Label>("CanvasLayer/HUD/TopBar/HBox/WaveLabel");
_hintLabel = GetNode<Label>("CanvasLayer/HUD/Hint");
_pauseOverlay = GetNode<Control>("CanvasLayer/HUD/PauseOverlay");
_resumeBtn = GetNode<Button>("CanvasLayer/HUD/PauseOverlay/PauseVBox/ResumeBtn");
_restartBtn = GetNode<Button>("CanvasLayer/HUD/PauseOverlay/PauseVBox/RestartBtn");
_upgradeOverlay = GetNode<Control>("CanvasLayer/HUD/UpgradeOverlay");
_up1 = GetNode<Button>("CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox/UpgradeBtn1");
_up2 = GetNode<Button>("CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox/UpgradeBtn2");
_up3 = GetNode<Button>("CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox/UpgradeBtn3");
_gameOverOverlay = GetNode<Control>("CanvasLayer/HUD/GameOverOverlay");
_gameOverStats = GetNode<Label>("CanvasLayer/HUD/GameOverOverlay/GameOverVBox/GameOverStats");
_gameOverRestartBtn = GetNode<Button>("CanvasLayer/HUD/GameOverOverlay/GameOverVBox/GameOverRestartBtn");
_resumeBtn.Pressed += TogglePause;
_restartBtn.Pressed += Restart;
_gameOverRestartBtn.Pressed += Restart;
_up1.Pressed += () => ChooseUpgrade(0);
_up2.Pressed += () => ChooseUpgrade(1);
_up3.Pressed += () => ChooseUpgrade(2);
BuildArena();
SpawnPlayer();
StartWave(1);
UpdateHud();
GD.Print("RogueTank: C# Game._Ready OK");
_hintLabel.Text = "C# 已启动 | 如果键鼠无响应,请先点击游戏窗口获取焦点 | WASD移动 鼠标瞄准 左键射击 Esc暂停";
}
public override void _UnhandledInput(InputEvent @event)
{
if (@event.IsActionPressed("pause"))
{
TogglePause();
GetViewport().SetInputAsHandled();
}
}
public override void _Process(double delta)
{
if (_player == null || !IsInstanceValid(_player))
return;
_camera.GlobalPosition = _player.GlobalPosition;
// Lightweight input/paused debug (helps diagnose "can't move/shoot").
var v = _player.Velocity;
var inp = _player.DebugLastMoveInput;
var pos = _player.GlobalPosition;
var paused = GetTree().Paused;
var shoot = (InputMap.HasAction("shoot") && Input.IsActionPressed("shoot")) || Input.IsMouseButtonPressed(MouseButton.Left);
var dbg = $"Paused:{paused} In:{inp.X:0},{inp.Y:0} Vel:{v.X:0},{v.Y:0} Pos:{pos.X:0},{pos.Y:0} Shoot:{shoot} Shots:{_shotsFired} Bullets:{_projectiles.GetChildCount()}";
if (dbg != _lastDebug)
{
_lastDebug = dbg;
_hintLabel.Text = $"C# 已启动 | {dbg} | (先点窗口获取焦点) WASD移动 鼠标瞄准 左键射击 Esc暂停";
}
}
private void TogglePause()
{
if (_choosingUpgrade) return;
_paused = !_paused;
GetTree().Paused = _paused;
_pauseOverlay.Visible = _paused;
}
private void Restart()
{
GetTree().Paused = false;
GetTree().ReloadCurrentScene();
}
private void GameOver()
{
GetTree().Paused = true;
_pauseOverlay.Visible = false;
_upgradeOverlay.Visible = false;
_gameOverOverlay.Visible = true;
var coin = _player?.Coins ?? 0;
_gameOverStats.Text = $"Wave: {_wave} Coin: {coin}";
// Auto-restart after a short delay (timer runs while paused).
var t = GetTree().CreateTimer(1.2, processAlways: true);
t.Timeout += () =>
{
// If player already restarted, don't reload again.
if (!IsInstanceValid(_player))
Restart();
};
}
private void BuildArena()
{
_arena.QueueFreeChildren();
// Boundary walls as static bodies (simple rectangles).
MakeWall(new Vector2(0, -ArenaHalfSize.Y - 10), new Vector2(ArenaHalfSize.X * 2 + 60, 20));
MakeWall(new Vector2(0, ArenaHalfSize.Y + 10), new Vector2(ArenaHalfSize.X * 2 + 60, 20));
MakeWall(new Vector2(-ArenaHalfSize.X - 10, 0), new Vector2(20, ArenaHalfSize.Y * 2 + 60));
MakeWall(new Vector2(ArenaHalfSize.X + 10, 0), new Vector2(20, ArenaHalfSize.Y * 2 + 60));
// Random obstacles
for (int i = 0; i < 8; i++)
{
var pos = new Vector2(_rng.RandfRange(-ArenaHalfSize.X + 40, ArenaHalfSize.X - 40),
_rng.RandfRange(-ArenaHalfSize.Y + 30, ArenaHalfSize.Y - 30));
// Keep a clear spawn zone around the origin so the player isn't stuck inside walls.
if (pos.Length() < 80f)
{
i--;
continue;
}
var size = new Vector2(_rng.RandfRange(20, 60), _rng.RandfRange(14, 46));
MakeWall(pos, size, new Color(0.2f, 0.2f, 0.25f));
}
}
private void MakeWall(Vector2 pos, Vector2 size, Color? color = null)
{
var wall = new StaticBody2D { GlobalPosition = pos };
var col = new CollisionShape2D { Shape = new RectangleShape2D { Size = size } };
wall.AddChild(col);
var sprite = new Sprite2D
{
Texture = PixelArt.MakeSolidTexture(8, 8, color ?? new Color(0.16f, 0.16f, 0.18f)),
TextureFilter = CanvasItem.TextureFilterEnum.Nearest,
Centered = true,
Scale = size / 8f,
};
wall.AddChild(sprite);
_arena.AddChild(wall);
}
private void SpawnPlayer()
{
_player = new PlayerTank { GlobalPosition = Vector2.Zero };
_player.ProjectilesRoot = _projectiles;
_player.BulletScene = _bulletScene;
_player.PickupsRoot = _pickups;
_player.StatsChanged += UpdateHud;
_player.Died += GameOver;
_player.ShotFired += () => _shotsFired++;
_actors.AddChild(_player);
}
private void StartWave(int wave)
{
_wave = wave;
_waveLabel.Text = $"Wave: {_wave}";
var count = 6 + (_wave - 1) * 3;
_enemiesAlive = 0;
for (int i = 0; i < count; i++)
SpawnEnemy();
}
private void SpawnEnemy()
{
if (_player == null || !IsInstanceValid(_player))
return;
var e = _enemyScene.Instantiate<EnemyTank>();
var angle = _rng.RandfRange(0, Mathf.Tau);
var radius = _rng.RandfRange(120, 220);
e.GlobalPosition = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
e.Target = _player;
var hp = 26f + _wave * 6f;
e.MaxHp = hp;
e.MoveSpeed = 90f + _wave * 3f;
e.ContactDamage = 10f + _wave * 1.2f;
e.Died += OnEnemyDied;
_actors.AddChild(e);
_enemiesAlive++;
}
private void OnEnemyDied(EnemyTank who)
{
_enemiesAlive = Mathf.Max(0, _enemiesAlive - 1);
// Drops
Drop(Pickup.PickupType.Xp, _rng.RandiRange(4, 7), who.GlobalPosition);
if (_rng.Randf() < 0.55f)
Drop(Pickup.PickupType.Coin, _rng.RandiRange(1, 3), who.GlobalPosition + new Vector2(6, 0));
if (_rng.Randf() < 0.10f)
Drop(Pickup.PickupType.Heal, _rng.RandiRange(6, 12), who.GlobalPosition + new Vector2(-6, 0));
if (_player != null && IsInstanceValid(_player))
{
var leveled = _player.AddXp(_rng.RandiRange(1, 2)); // small bonus on kill
if (leveled && !_choosingUpgrade)
ShowUpgradeChoices("升级:等级提升");
}
if (_enemiesAlive <= 0 && !_choosingUpgrade)
ShowUpgradeChoices("通关奖励:选择一项升级");
}
private void Drop(Pickup.PickupType type, int amount, Vector2 at)
{
var p = _pickupScene.Instantiate<Pickup>();
p.Type = type;
p.Amount = amount;
p.GlobalPosition = at + new Vector2(_rng.RandfRange(-6, 6), _rng.RandfRange(-6, 6));
_pickups.AddChild(p);
}
private void ShowUpgradeChoices(string title)
{
if (_player == null || !IsInstanceValid(_player))
return;
_choosingUpgrade = true;
GetTree().Paused = true;
_pauseOverlay.Visible = false;
_upgradeOverlay.Visible = true;
var titleLabel = GetNode<Label>("CanvasLayer/HUD/UpgradeOverlay/UpgradeVBox/UpgradeTitle");
titleLabel.Text = title;
_currentChoices.Clear();
var choices = RollUpgrades(_player).Take(3).ToList();
_currentChoices.AddRange(choices);
ApplyToButton(_up1, choices.ElementAtOrDefault(0));
ApplyToButton(_up2, choices.ElementAtOrDefault(1));
ApplyToButton(_up3, choices.ElementAtOrDefault(2));
}
private void ApplyToButton(Button btn, UpgradeOption? opt)
{
if (opt == null)
{
btn.Disabled = true;
btn.Text = "---";
return;
}
btn.Disabled = false;
btn.Text = $"{opt.Title}\n{opt.Desc}";
}
private void ChooseUpgrade(int index)
{
if (index < 0 || index >= _currentChoices.Count) return;
var opt = _currentChoices[index];
opt.Apply();
_upgradeOverlay.Visible = false;
_choosingUpgrade = false;
GetTree().Paused = false;
UpdateHud();
if (_enemiesAlive <= 0)
{
// Next wave: rebuild arena a bit to simulate "随机关卡"
BuildArena();
StartWave(_wave + 1);
}
}
private IEnumerable<UpgradeOption> RollUpgrades(PlayerTank p)
{
// We allow repeats; keeps MVP simple.
var list = new List<UpgradeOption>
{
new()
{
Id = UpgradeId.MoveSpeed,
Title = "底盘升级:移速 +10%",
Desc = $"当前移速:{p.MoveSpeed:0}",
Apply = () => p.MoveSpeed *= 1.10f
},
new()
{
Id = UpgradeId.FireRate,
Title = "火炮升级:射速 +15%",
Desc = $"当前射速:{p.FireRate:0.0}/s",
Apply = () => p.FireRate *= 1.15f
},
new()
{
Id = UpgradeId.Damage,
Title = "火炮升级:伤害 +20%",
Desc = $"当前伤害:{p.Damage:0}",
Apply = () => p.Damage *= 1.20f
},
new()
{
Id = UpgradeId.MaxHp,
Title = "装甲升级:最大生命 +25",
Desc = $"当前生命:{p.Hp:0}/{p.MaxHp:0}",
Apply = () =>
{
p.MaxHp += 25f;
p.Heal(25f);
}
},
new()
{
Id = UpgradeId.Armor,
Title = "装甲升级:减伤 +1",
Desc = $"当前减伤:{p.Armor:0}",
Apply = () => p.Armor += 1f
},
new()
{
Id = UpgradeId.BulletSpeed,
Title = "弹种升级:弹速 +15%",
Desc = $"当前弹速:{p.BulletSpeed:0}",
Apply = () => p.BulletSpeed *= 1.15f
},
new()
{
Id = UpgradeId.BulletPierce,
Title = "弹种升级:穿透 +1",
Desc = $"当前穿透:{p.BulletPierce}",
Apply = () => p.BulletPierce += 1
},
new()
{
Id = UpgradeId.HealSmall,
Title = "维修:恢复 30 生命",
Desc = $"当前生命:{p.Hp:0}/{p.MaxHp:0}",
Apply = () => p.Heal(30f)
},
new()
{
Id = UpgradeId.Magnet,
Title = "辅助模块:拾取范围 +25%",
Desc = $"当前范围:{p.MagnetRadius:0}",
Apply = () => p.MagnetRadius *= 1.25f
}
};
// Random shuffle
return list.OrderBy(_ => _rng.Randf());
}
private void UpdateHud()
{
if (_player == null || !IsInstanceValid(_player))
return;
_hpLabel.Text = $"HP: {_player.Hp:0}/{_player.MaxHp:0}";
_levelLabel.Text = $"Lv {_player.Level} ({_player.Xp}/{_player.XpToNext})";
_coinLabel.Text = $"Coin: {_player.Coins}";
_waveLabel.Text = $"Wave: {_wave}";
}
// --- Runtime-built PackedScenes (keeps repo asset-light) ---
private static PackedScene MakeBulletScene()
{
var root = new Bullet();
root.Name = "Bullet";
var sprite = new Sprite2D
{
Texture = PixelArt.MakeBulletTexture(6, 2, new Color(1f, 0.95f, 0.65f)),
Centered = true,
TextureFilter = CanvasItem.TextureFilterEnum.Nearest
};
root.AddChild(sprite);
var col = new CollisionShape2D { Shape = new RectangleShape2D { Size = new Vector2(6, 2) } };
root.AddChild(col);
root.CollisionLayer = 1u << 2; // bullet
root.CollisionMask = (1u << 0) | (1u << 1); // walls + enemies
var ps = new PackedScene();
ps.Pack(root);
root.QueueFree();
return ps;
}
private static PackedScene MakeEnemyScene()
{
var root = new EnemyTank { Name = "Enemy" };
root.CollisionLayer = 1u << 1; // enemies
root.CollisionMask = (1u << 0) | (1u << 1) | (1u << 3); // walls + enemies + player
var ps = new PackedScene();
ps.Pack(root);
root.QueueFree();
return ps;
}
private static PackedScene MakePickupScene()
{
var root = new Pickup { Name = "Pickup" };
root.CollisionLayer = 1u << 4; // pickup
root.CollisionMask = (1u << 3) | (1u << 5); // player + magnet
var ps = new PackedScene();
ps.Pack(root);
root.QueueFree();
return ps;
}
}
internal static class NodeExtensions
{
public static void QueueFreeChildren(this Node node)
{
foreach (var c in node.GetChildren())
(c as Node)?.QueueFree();
}
}

@ -0,0 +1 @@
uid://bxocsvm0xxgxq

@ -0,0 +1,16 @@
namespace RogueTank.Upgrades;
public enum UpgradeId
{
MoveSpeed,
FireRate,
Damage,
MaxHp,
Armor,
BulletSpeed,
BulletPierce,
HealSmall,
Magnet
}

@ -0,0 +1 @@
uid://drsekfavhk8w6

@ -0,0 +1,13 @@
using System;
namespace RogueTank.Upgrades;
public sealed class UpgradeOption
{
public required UpgradeId Id { get; init; }
public required string Title { get; init; }
public required string Desc { get; init; }
public required Action Apply { get; init; }
}

@ -0,0 +1,74 @@
using Godot;
namespace RogueTank.Util;
public static class PixelArt
{
public static Texture2D MakeSolidTexture(int w, int h, Color color)
{
var img = Image.Create(w, h, false, Image.Format.Rgba8);
img.Fill(color);
return ImageTexture.CreateFromImage(img);
}
public static Texture2D MakeTankTexture(int size, Color body, Color turret)
{
var img = Image.Create(size, size, false, Image.Format.Rgba8);
img.Fill(Colors.Transparent);
// Body
var margin = Mathf.Max(1, size / 8);
var bodyRect = new Rect2I(margin, margin * 2, size - margin * 2, size - margin * 3);
FillRect(img, bodyRect, body);
// Tracks
FillRect(img, new Rect2I(margin / 2, margin * 2, margin, size - margin * 3), body.Darkened(0.25f));
FillRect(img, new Rect2I(size - margin - margin / 2, margin * 2, margin, size - margin * 3), body.Darkened(0.25f));
// Turret
var t = Mathf.Max(2, size / 4);
FillRect(img, new Rect2I(size / 2 - t / 2, size / 2 - t / 2, t, t), turret);
// Barrel
FillRect(img, new Rect2I(size / 2 - 1, margin, 2, size / 2 - margin), turret.Lightened(0.1f));
return ImageTexture.CreateFromImage(img);
}
public static Texture2D MakeBulletTexture(int w, int h, Color color)
{
var img = Image.Create(w, h, false, Image.Format.Rgba8);
img.Fill(Colors.Transparent);
FillRect(img, new Rect2I(0, 0, w, h), color);
return ImageTexture.CreateFromImage(img);
}
public static Texture2D MakePickupTexture(int size, Color color)
{
var img = Image.Create(size, size, false, Image.Format.Rgba8);
img.Fill(Colors.Transparent);
// simple diamond
var c = size / 2;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
var dx = Mathf.Abs(x - c);
var dy = Mathf.Abs(y - c);
if (dx + dy <= c)
img.SetPixel(x, y, color);
}
}
return ImageTexture.CreateFromImage(img);
}
private static void FillRect(Image img, Rect2I rect, Color c)
{
for (int y = rect.Position.Y; y < rect.Position.Y + rect.Size.Y; y++)
{
for (int x = rect.Position.X; x < rect.Position.X + rect.Size.X; x++)
img.SetPixel(x, y, c);
}
}
}

@ -0,0 +1 @@
uid://dc4s7prcs8mhy

@ -0,0 +1,87 @@
using Godot;
using RogueTank.Actors;
using RogueTank.Util;
namespace RogueTank.World;
public partial class Pickup : Area2D
{
public enum PickupType
{
Coin,
Xp,
Heal
}
[Export] public PickupType Type { get; set; } = PickupType.Xp;
[Export] public int Amount { get; set; } = 1;
private Sprite2D _sprite = null!;
private Node2D? _magnetTarget;
private float _magnetSpeed = 0f;
public override void _Ready()
{
BodyEntered += OnBodyEntered;
AreaEntered += OnAreaEntered;
_sprite = new Sprite2D
{
Centered = true,
TextureFilter = CanvasItem.TextureFilterEnum.Nearest
};
_sprite.Texture = Type switch
{
PickupType.Coin => PixelArt.MakePickupTexture(10, new Color(1f, 0.85f, 0.2f)),
PickupType.Xp => PixelArt.MakePickupTexture(10, new Color(0.2f, 0.85f, 1f)),
PickupType.Heal => PixelArt.MakePickupTexture(10, new Color(0.35f, 1f, 0.45f)),
_ => PixelArt.MakePickupTexture(10, Colors.White)
};
AddChild(_sprite);
var col = new CollisionShape2D { Shape = new CircleShape2D { Radius = 6f } };
AddChild(col);
}
public override void _PhysicsProcess(double delta)
{
if (_magnetTarget == null || !IsInstanceValid(_magnetTarget))
return;
_magnetSpeed = Mathf.Min(520f, _magnetSpeed + 1400f * (float)delta);
var dir = (_magnetTarget.GlobalPosition - GlobalPosition).Normalized();
GlobalPosition += dir * _magnetSpeed * (float)delta;
}
private void OnAreaEntered(Area2D area)
{
// Magnet area: start homing when enter.
if (area.GetParent() is PlayerTank p && area == p.GetMagnetArea())
{
_magnetTarget = p;
return;
}
}
private void OnBodyEntered(Node body)
{
if (body is not PlayerTank p) return;
switch (Type)
{
case PickupType.Coin:
p.AddCoins(Amount);
break;
case PickupType.Xp:
p.AddXp(Amount);
break;
case PickupType.Heal:
p.Heal(Amount);
break;
}
QueueFree();
}
}

@ -0,0 +1 @@
uid://d4g8a666f5ojc

@ -0,0 +1,9 @@
[gd_scene format=3 uid="uid://u0gpp4vmnutw"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2

@ -11,13 +11,117 @@ config_version=5
[application]
config/name="RogueTank"
config/features=PackedStringArray("4.5", "GL Compatibility")
run/main_scene="res://Scenes/Main.tscn"
config/features=PackedStringArray("4.5", "C#", "GL Compatibility")
config/icon="res://icon.svg"
[display]
window/size/viewport_width=640
window/size/viewport_height=360
window/stretch/mode="canvas_items"
window/size/window_width=1280
window/size/window_height=720
[dotnet]
project/assembly_name="RogueTank"
[input]
move_up={
"deadzone": 0.5,
"events": [null, {
"deadzone": 0.5,
"device": -1,
"keycode": 87,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}, {
"deadzone": 0.5,
"device": -1,
"keycode": 4194320,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}]
}
move_down={
"deadzone": 0.5,
"events": [null, {
"deadzone": 0.5,
"device": -1,
"keycode": 83,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}, {
"deadzone": 0.5,
"device": -1,
"keycode": 4194322,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}]
}
move_left={
"deadzone": 0.5,
"events": [null, {
"deadzone": 0.5,
"device": -1,
"keycode": 65,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}, {
"deadzone": 0.5,
"device": -1,
"keycode": 4194319,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}]
}
move_right={
"deadzone": 0.5,
"events": [null, {
"deadzone": 0.5,
"device": -1,
"keycode": 68,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}, {
"deadzone": 0.5,
"device": -1,
"keycode": 4194321,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}]
}
shoot={
"deadzone": 0.5,
"events": [null, {
"button_index": 1,
"device": -1,
"double_click": false,
"factor": 1.0
}]
}
pause={
"deadzone": 0.5,
"events": [null, {
"deadzone": 0.5,
"device": -1,
"keycode": 4194305,
"physical_keycode": 0,
"unicode": 0,
"window_id": 0
}]
}
[rendering]
renderer/rendering_method="gl_compatibility"

Loading…
Cancel
Save