feat: add core game mechanics and assets for RogueTank
parent
e7922a316a
commit
1ce7797df9
@ -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 @@
|
|||||||
|
uid://dh26dcnl1d7a6
|
||||||
@ -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
|
||||||
Loading…
Reference in New Issue