You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
513 lines
17 KiB
C#
513 lines
17 KiB
C#
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();
|
|
}
|
|
}
|
|
|
|
|