【Godot】3D塔防游戏-野蛮爆破手BarbarianBlaster

项目素材及源码

https://gitee.com/bobokaka/barbarian-blaster

游戏玩法设计

image.png

地板和环境三要素

image.png

照相机参数:


image.png

保存为BaseLevel.tscn场景文件。

制作网格库

创建res://MeshLibraries/SpaceLibrary.tscn 3D界面场景。
创建子节点MeshInstance3D。

image.png

设置材质,颜色为#cdcdcd。创建碰撞体。

image.png

image.png

鼠标选中FreeSpace节点。
右上角点击 场景。


image.png

image.png

image.png

如上,保存。

同上再创建一个炮台网格,颜色改为#d63a29。
两个物体重叠没关系。


image.png

同样的步骤,导出为网格图,然后覆盖保存到同一个文件。

打开SpaceLibrary.tres。我们能看到数组里面有2个网格材料了。


image.png

打开BaseLevel.tscn场景。

增加网格组件GridMap。然后再Mesh Library中,加载SpaceLibrary.tres,可以看到2个网格材料再右侧新增了一个列表展示,而且可以选中并拖动到主体界面中去。


image.png

设计线路

如果发现网格方向不对,可以试试这里,比如光标清除旋转

image.png

image.png

默认情况下,网格和格子对不上。解决方案就是调整右侧Size属性(4,4,4),因为我们网格块是4米x4米的大小。


image.png

倾斜发现,网格是悬空的。因为中心属性Y轴是打开的。


image.png

关掉Y轴为中心。


image.png

shift+鼠标右键,可以选取一块区域,再选中网格材料后,按ctrl+f会一次填充。或者按Del键删除。(这里层错了 应该是0层)


image.png
image.png

如下,红色的是敌人行动路线。


image.png

其他地方用白色填满,然后删除红色。


image.png

调整格子到(0,-4,0)。(这里层错了 应该是0层)


image.png

为敌人创建行走的路线

路径3D组件 Path3D。


image.png

image.png
image.png

创建CSG多边形节点CSGPolygon3D。


image.png

加载图形和配置参数。
最终效果如下


image.png
image.png
image.png

参数分别为:


image.png
image.png

PathFollow3D组件,Path3D的点采样器,必须为PAth3D的子节点,能够控制从开始走到结束的行动节奏。我们这个游戏得关闭循环。


image.png

敌人

image.png

创建圆柱体代表敌人。

image.png

敌人单独生成新的场景。保存到res://Enemy/enemy.tscn
image.png

image.png

根节点增加脚本Enemy.cs

using Godot;
using System;

public partial class Enemy : PathFollow3D
{
    [Export] public double speed = 2.5;
    private Node3D playerBase;
    private PlayerBase playerBaseScript;

    public override void _Ready()
    {
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用ScriptA中的方法
            playerBaseScript?.takeDamage();
        }

      
    }
}

增加玩家基地PlayerBase。增加一个Node3D节点下,挂在CSGCylinder3D子节点,然后独立成场景PlayerBase.tscn。

image.png

image.png

PlayerBase节点位置(-10,0,-12)。
进入PlayerBase场景
image.png

圆柱体颜色#e20005

根节点增加PlayerBase.cs脚本。

using Godot;
using System;

public partial class PlayerBase : Node3D
{

    public void takeDamage()
    {
        GD.Print("takeDamage");
    }
}

运行,发现敌人只要碰撞到基地(路程进度100%)才会触发碰撞。

基地掉血

增加文本。


image.png

image.png

修改脚本PlayerBase.cs

using Godot;
using System;

public partial class PlayerBase : Node3D
{
    [Export] public int MaxHealth=5; 
    private Label3D _label3D;

    public override void _Ready()
    {
        _label3D = GetNode<Label3D>("Label3D");
        _label3D.Text =MaxHealth.ToString();
    }

    public void takeDamage()
    {
        GD.Print("takeDamage");
        MaxHealth--;
        _label3D.Text =MaxHealth.ToString();
    }
}

修改脚本Enemy.cs

using Godot;
using System;

public partial class Enemy : PathFollow3D
{
    [Export] public double speed = 2.5;
    private Node3D playerBase;
    private PlayerBase playerBaseScript;

    public override void _Ready()
    {
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用Script中的方法
            playerBaseScript?.takeDamage();
            // 停止运行_Process函数
            SetProcess(false);
            // Free();
        }
    }
}

一波敌人

修改脚本PlayerBase.cs

using Godot;
using System;

public partial class PlayerBase : Node3D
{
    [Export] public int MaxHealth = 5;

    private int _currentHealth;
    private Label3D _label3D;

    public int CurrentHealth
    {
        get => _currentHealth;
        set
        {
            _currentHealth = value;
            GD.Print("生命值改变!");
            _label3D.Text = value.ToString();
            //  重新加载游戏
            if (value < 1)
            {
                GetTree().ReloadCurrentScene();
            }
        }
    }


    public override void _Ready()
    {
        _label3D=GetNode<Label3D>("Label3D");
        CurrentHealth= MaxHealth;
    }

    public void takeDamage()
    {
        GD.Print("takeDamage");
        CurrentHealth -= 1;
    }
}

然后复制5个敌人,修改初始位置。


image.png

游戏掉血开发完成。

优化一下显示文字。

......
  private Color colorRed = new Color(Colors.Red);
    private Color colorWhite = new Color(Colors.White);

    public int CurrentHealth
    {
        get => _currentHealth;
        set
        {
            _currentHealth = value;
            GD.Print("生命值改变!");
            _label3D.Text = value + "/" + MaxHealth;

            Color color = colorRed.Lerp(colorWhite, (float)value / MaxHealth);
            _label3D.Modulate = color;
            //  重新加载游戏
            if (value < 1)
            {
                GetTree().ReloadCurrentScene();
            }
        }
    }
......

鼠标输入

将相机独立成一个场景ray_picker_camera.tscn。

image.png
image.png

根节点增加脚本RayPickerCamera.cs。


image.png
using Godot;
using System;

public partial class RayPickerCamera : Camera3D
{
    private RayCast3D _rayCast3D;

    public override void _Ready()
    {
        _rayCast3D = GetNode<RayCast3D>("RayCast3D");
    }

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        GD.Print(mousePosition.ToString());
    }
}

运行后,鼠标移动,可以看到坐标变化打印。

修改脚本。

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition);
    }

打开调试的 显示碰撞区域。


image.png

运行:


image.png

修改脚本
    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        // 因为指向射线只有单位向量(1米),这里乘以100,使向量穿过任何地面
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition) * 100.0f;
        // ForceRaycastUpdate:立即更新光线的碰撞信息,而不等待下一个_physics_process调用。
        // 例如,当射线或它的父射线改变了状态时,使用此方法。注意:Enabled并不需要为true才能正常工作。
        _rayCast3D.ForceRaycastUpdate();
        // GetCollider:打印得到的第一个对撞对象。如果没有对象与射线相交,则返回null(即iscollision()返回false)。
        // GetCollisionPoint:返回在全局坐标系中光线与最近物体相交的碰撞点。
        GD.PrintT(_rayCast3D.GetCollider(),_rayCast3D.GetCollisionPoint()); 
    }
image.png

放置网格

修改RayPickerCamera.cs脚本

using Godot;
using System;

public partial class RayPickerCamera : Camera3D
{
    private RayCast3D _rayCast3D;
    [Export] public GridMap gridMap;

    public override void _Ready()
    {
        _rayCast3D = GetNode<RayCast3D>("RayCast3D");
    }

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        // 因为指向射线只有单位向量(1米),这里乘以100,使向量穿过任何地面
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition) * 100.0f;
        // ForceRaycastUpdate:立即更新光线的碰撞信息,而不等待下一个_physics_process调用。
        // 例如,当射线或它的父射线改变了状态时,使用此方法。
        _rayCast3D.ForceRaycastUpdate();
        // GetCollider:打印得到的第一个对撞对象。如果没有对象与射线相交,则返回null(即iscollision()返回false)。
        // GetCollisionPoint:返回在全局坐标系中光线与最近物体相交的碰撞点。
        // GD.PrintT(_rayCast3D.GetCollider(),_rayCast3D.GetCollisionPoint()); 
        // 如果撞上了什么东西
        if (_rayCast3D.IsColliding())
        {
            GodotObject collider = _rayCast3D.GetCollider();
            if (collider is GridMap)
            {
                Vector3 collisionPoint = _rayCast3D.GetCollisionPoint();
                Vector3I cell = gridMap.LocalToMap(collisionPoint);
                // GD.Print(cell);
                gridMap.SetCellItem(cell, 1);
            }
        }
    }
}

这样,鼠标悬浮的网格都会编程红色。


image.png

增加点击变红色和鼠标样式交互。

using Godot;
using System;

public partial class RayPickerCamera : Camera3D
{
    private RayCast3D _rayCast3D;
    [Export] public GridMap gridMap;

    public override void _Ready()
    {
        _rayCast3D = GetNode<RayCast3D>("RayCast3D");
    }

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        // 因为指向射线只有单位向量(1米),这里乘以100,使向量穿过任何地面
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition) * 100.0f;
        // ForceRaycastUpdate:立即更新光线的碰撞信息,而不等待下一个_physics_process调用。
        // 例如,当射线或它的父射线改变了状态时,使用此方法。
        _rayCast3D.ForceRaycastUpdate();
        // GetCollider:打印得到的第一个对撞对象。如果没有对象与射线相交,则返回null(即iscollision()返回false)。
        // GetCollisionPoint:返回在全局坐标系中光线与最近物体相交的碰撞点。
        // GD.PrintT(_rayCast3D.GetCollider(),_rayCast3D.GetCollisionPoint()); 
        // 如果撞上了什么东西
        if (_rayCast3D.IsColliding())
        {
            Input.SetDefaultCursorShape(Input.CursorShape.PointingHand);
            GodotObject collider = _rayCast3D.GetCollider();
            if (collider is GridMap)
            {
                // 鼠标点击
                if (Input.IsActionPressed("click"))
                {
                    Vector3 collisionPoint = _rayCast3D.GetCollisionPoint();
                    Vector3I cell = gridMap.LocalToMap(collisionPoint);
                    // GD.Print(cell);
                    // 如果没有炮台,再出现炮台
                    if (gridMap.GetCellItem(cell) == 0)
                    {
                        gridMap.SetCellItem(cell, 1);
                    }
                }
            }
        }
        else
        {
            Input.SetDefaultCursorShape();
        }
    }
}

制作炮塔

创建res://Turret/turret.tscn场景

image.png

image.png

再增加2个节点。


image.png

she

创建Node3D节点TurretManager。


image.png

创建脚本TurretManager.cs。

using Godot;
using System;

public partial class TurretManager : Node3D
{
    [Export]
    public PackedScene turretScene;
    public override void _Ready()
    {
        Node newTurret = turretScene.Instantiate();
        AddChild(newTurret);
    }

    public override void _Process(double delta)
    {
    }
}
image.png

修改res://RayPickerCamera/RayPickerCamera.cs

using Godot;
using System;

public partial class RayPickerCamera : Camera3D
{
    private RayCast3D _rayCast3D;
    [Export] public GridMap gridMap;
    [Export] public Node3D curretManage;

    private TurretManager turretManagerScript;
    public override void _Ready()
    {
        _rayCast3D = GetNode<RayCast3D>("RayCast3D");
        if (curretManage != null)
        {
            turretManagerScript = curretManage as TurretManager;
        }
    }

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        // 因为指向射线只有单位向量(1米),这里乘以100,使向量穿过任何地面
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition) * 100.0f;
        // ForceRaycastUpdate:立即更新光线的碰撞信息,而不等待下一个_physics_process调用。
        // 例如,当射线或它的父射线改变了状态时,使用此方法。
        _rayCast3D.ForceRaycastUpdate();
        // GetCollider:打印得到的第一个对撞对象。如果没有对象与射线相交,则返回null(即iscollision()返回false)。
        // GetCollisionPoint:返回在全局坐标系中光线与最近物体相交的碰撞点。
        // GD.PrintT(_rayCast3D.GetCollider(),_rayCast3D.GetCollisionPoint()); 
        // 如果撞上了什么东西
        if (_rayCast3D.IsColliding())
        {
            Input.SetDefaultCursorShape(Input.CursorShape.PointingHand);
            GodotObject collider = _rayCast3D.GetCollider();
            if (collider is GridMap)
            {
                // 鼠标点击
                if (Input.IsActionPressed("click"))
                {
                    Vector3 collisionPoint = _rayCast3D.GetCollisionPoint();
                    Vector3I cell = gridMap.LocalToMap(collisionPoint);
                    // GD.Print(cell);
                    // 如果没有炮台,再出现炮台
                    if (gridMap.GetCellItem(cell) == 0)
                    {
                        gridMap.SetCellItem(cell, 1);
                        //得到网格的坐本地坐标空间中的位置。(全局坐标)
                        Vector3 tilePosition = gridMap.MapToLocal(cell);
                        // 传递给炮塔创建
                        turretManagerScript.buildTurret(tilePosition);
                    }
                }
            }
        }
        else
        {
            Input.SetDefaultCursorShape();
        }
    }
}

修改TurretManager.cs

using Godot;
using System;

public partial class TurretManager : Node3D
{
    //  可实例化的场景
    [Export] public PackedScene turretScene;

    public override void _Ready()
    {
    }

    public override void _Process(double delta)
    {
    }

    public void buildTurret(Vector3 turretPosition)
    {
        // 必须强转成Node3D才有GlobalPosition属性
        Node3D newTurret = (Node3D)turretScene.Instantiate();
        AddChild(newTurret);
        // 设置全球坐标
        newTurret.GlobalPosition = turretPosition;
    }
}

运行,可以发现炮塔能放到网格中了。


image.png

炮弹

新建炮弹场景res://Turret/projectile.tscn。设置颜色 #aa2820

image.png

根节点新增脚本res://Turret/Projectile.cs

using Godot;
using System;

public partial class Projectile : Area3D
{
    [Export] private float speed = 30.0f;

    //默认z轴方向的向量
    Vector3 direction = Vector3.Forward;

    public override void _PhysicsProcess(double delta)
    {
        Position += direction * speed * (float)delta;
    }
}

将炮弹场景加入炮塔场景中


image.png

运行,可以看到子弹发射出去,但是从地图中间。


image.png

现在需要炮塔发射炮弹,入炮塔场景删除炮弹场景,炮塔场景新增脚本
新增计时器组件。


image.png

image.png
image.png

res://Turret/Turret.cs

using Godot;
using System;

public partial class Turret : Node3D
{
    [Export] PackedScene projectilePrefab;
    private MeshInstance3D turretTop;

    public override void _Ready()
    {
        turretTop = GetNode<MeshInstance3D>("TurretBase/TurretTop");
    }

    public void onTimerTimeout()
    {
        Node3D shot = (Node3D)projectilePrefab.Instantiate();
        AddChild(shot);
        shot.GlobalPosition = turretTop.GlobalPosition;
    }
}
image.png

炮弹销毁

炮弹场景增加计时器。

image.png

绑定响应事件。
res://Turret/Projectile.cs

using Godot;
using System;

public partial class Projectile : Area3D
{
    [Export] private float speed = 30.0f;

    //默认z轴方向的向量
    Vector3 direction = Vector3.Forward;

    public override void _PhysicsProcess(double delta)
    {
        Position += direction * speed * (float)delta;
    }
    
    public void onTimerTimeout()
    {
      QueueFree();
    }
}

炮塔和炮弹打向敌人

修改TurretManager.cs

using Godot;
using System;

public partial class TurretManager : Node3D
{
    //  可实例化的场景
    [Export] public PackedScene turretScene;
    // 导出敌人的路径变量
    [Export] public Path3D enemyPath;

    public override void _Ready()
    {
    }

    public override void _Process(double delta)
    {
    }

    public void buildTurret(Vector3 turretPosition)
    {
        // 必须强转成Node3D才有GlobalPosition属性
        Node3D newTurret = (Node3D)turretScene.Instantiate();
        Turret turretCs = newTurret as Turret;
        AddChild(newTurret);
        // 设置全球坐标
        newTurret.GlobalPosition = turretPosition;

        turretCs.enemyPath = enemyPath;
    }
}

修改Turret.cs

using Godot;
using System;
using System.Linq;

public partial class Turret : Node3D
{
    [Export] PackedScene projectilePrefab;
    private MeshInstance3D turretTop;

    public Path3D enemyPath;

    public override void _Ready()
    {
        turretTop = GetNode<MeshInstance3D>("TurretBase/TurretTop");
    }

    public override void _PhysicsProcess(double delta)
    {
        // 炮塔总是对着最后一个敌人
        Node3D enemy =
            (Node3D)enemyPath.GetChildren().Last();
        {
            LookAt(enemy.GlobalPosition, Vector3.Up, true);
        }
    }

    public void onTimerTimeout()
    {
        Area3D shot = (Area3D)projectilePrefab.Instantiate();
        AddChild(shot);
        Projectile projectileCs = shot as Projectile;
        // 该节点的全局位置
        shot.GlobalPosition = turretTop.GlobalPosition;
        // 射击方向
        // projectileCs.direction = Basis.Z;
        projectileCs.direction = GlobalTransform.Basis.Z;
    }}
image.png

打出伤害

res://Enemy/enemy.tscn增加碰撞体和分组

image.png

修改res://Enemy/Enemy.cs增加血量:

using Godot;
using System;

public partial class Enemy : PathFollow3D
{
    [Export] public double speed = 2.5;
    [Export] public int MaxHealth = 50;
    private Node3D playerBase;
    private PlayerBase playerBaseScript;

    private int _currentHealth;
    public int CurrentHealth
    {
        get => _currentHealth;
        set
        {
            _currentHealth = value;
            //  销毁自身
            if (value < 1)
            {
                QueueFree();
            }
        }
    }
    public override void _Ready()
    {
        CurrentHealth = MaxHealth;
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用Script中的方法
            playerBaseScript?.takeDamage();
            // 停止运行_Process函数
            SetProcess(false);
            // Free();
        }
    }
}

修改炮弹增加退出:


image.png
using Godot;
using System;

public partial class Projectile : Area3D
{
    [Export] private float speed = 30.0f;

    //默认z轴方向的向量
    public Vector3 direction = Vector3.Forward;

    public override void _PhysicsProcess(double delta)
    {
        Position += direction * speed * (float)delta;
    }

    public void onTimerTimeout()
    {
        QueueFree();
    }

    public void _on_area_entered(Area3D area)
    {
        if (area.IsInGroup("enemy_area"))
        {
            Enemy enemyCs = area.GetParent() as Enemy;
            enemyCs.CurrentHealth -= 25;

            QueueFree();
        }
    }
}

优化一下Tureet.cs

using Godot;
using System;
using System.Linq;

public partial class Turret : Node3D
{
    [Export] PackedScene projectilePrefab;
    private MeshInstance3D turretTop;

    public Path3D enemyPath;
    private PathFollow3D enemyTarget;

    public override void _Ready()
    {
        turretTop = GetNode<MeshInstance3D>("TurretBase/TurretTop");
    }

    public override void _PhysicsProcess(double delta)
    {
        // 炮塔总是对着最后一个敌人
        // Node3D enemy =
        //     (Node3D)enemyPath.GetChildren().Last();
        enemyTarget = findBaseTarget();
        if (enemyTarget != null)
        {
            LookAt(enemyTarget.GlobalPosition, Vector3.Up, true);
        }
    }

    public void onTimerTimeout()
    {
        // 没有目标,停止射击
        if (enemyTarget != null)
        {
            Area3D shot = (Area3D)projectilePrefab.Instantiate();
            AddChild(shot);
            Projectile projectileCs = shot as Projectile;
            // 该节点的全局位置
            shot.GlobalPosition = turretTop.GlobalPosition;
            // 射击方向
            // projectileCs.direction = Basis.Z;
            projectileCs.direction = GlobalTransform.Basis.Z;
        }
    }


    // 永远打最前面一个敌人
    public PathFollow3D findBaseTarget()
    {
        PathFollow3D bestTarget = null;
        float baseProgress = 0.0f;
        foreach (Node enemy in enemyPath.GetChildren())
        {
            if (enemy is PathFollow3D)
            {
                PathFollow3D enemyPf = enemy as PathFollow3D;
                if (enemyPf.Progress > baseProgress)
                {
                    bestTarget = enemyPf;
                    baseProgress = enemyPf.Progress;
                }
            }
        }
        return bestTarget;
    }
}

约束炮塔打击范围

using Godot;
using System;
using System.Linq;

public partial class Turret : Node3D
{
    // 炮弹实体
    [Export] PackedScene projectilePrefab;

    // 打击范围
    [Export] float turretRange = 10.0f;
    private MeshInstance3D turretTop;

    public Path3D enemyPath;
    private PathFollow3D enemyTarget;

    public override void _Ready()
    {
        turretTop = GetNode<MeshInstance3D>("TurretBase/TurretTop");
    }

    public override void _PhysicsProcess(double delta)
    {
        // 炮塔总是对着最后一个敌人
        // Node3D enemy =
        //     (Node3D)enemyPath.GetChildren().Last();
        enemyTarget = findBaseTarget();
        if (enemyTarget != null)
        {
            LookAt(enemyTarget.GlobalPosition, Vector3.Up, true);
        }
    }

    public void onTimerTimeout()
    {
        // 没有目标,停止射击
        if (enemyTarget != null)
        {
            Area3D shot = (Area3D)projectilePrefab.Instantiate();
            AddChild(shot);
            Projectile projectileCs = shot as Projectile;
            // 该节点的全局位置
            shot.GlobalPosition = turretTop.GlobalPosition;
            // 射击方向
            // projectileCs.direction = Basis.Z;
            projectileCs.direction = GlobalTransform.Basis.Z;
        }
    }


    // 永远打最前面一个敌人
    public PathFollow3D findBaseTarget()
    {
        PathFollow3D bestTarget = null;
        float baseProgress = 0.0f;
        foreach (Node enemy in enemyPath.GetChildren())
        {
            if (enemy is PathFollow3D)
            {
                PathFollow3D enemyPf = enemy as PathFollow3D;
                // 返回2个向量之间的距离
                float distanceTo = GlobalPosition.DistanceTo(enemyPf.GlobalPosition);
                if (enemyPf.Progress > baseProgress && distanceTo <= turretRange)
                {
                    bestTarget = enemyPf;
                    baseProgress = enemyPf.Progress;
                }
            }
        }

        return bestTarget;
    }
}

动画播放

image.png

新增组件DamageHight


image.png

image.png

动画播放新增动画。


image.png

调整参数,增加visible属性动画控制。


image.png

将默认状态重置为不可见。
image.png

修改脚本,增加动画播放。

using Godot;
using System;

public partial class Enemy : PathFollow3D
{
    [Export] public double speed = 2.5;
    [Export] public int MaxHealth = 50;
    private Node3D playerBase;
    private PlayerBase playerBaseScript;
    private AnimationPlayer animationPlayer;

    private int _currentHealth;

    public int CurrentHealth
    {
        get => _currentHealth;
        set
        {
            if (value < CurrentHealth)
            {
                animationPlayer.Play("TakeDamage");
            }

            _currentHealth = value;
            //  销毁自身
            if (value < 1)
            {
                QueueFree();
            }
        }
    }

    public override void _Ready()
    {
        animationPlayer=GetNode<AnimationPlayer>("AnimationPlayer");
        CurrentHealth = MaxHealth;
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用Script中的方法
            playerBaseScript?.takeDamage();
            // 停止运行_Process函数
            SetProcess(false);
            // Free();
        }
    }
}

同理,炮台也增加动画,控制TurretTop的Z轴,模拟炮台后坐力动画。


image.png

image.png

image.png

修改脚本:

using Godot;
using System;
using System.Linq;

public partial class Turret : Node3D
{
    // 炮弹实体
    [Export] PackedScene projectilePrefab;

    // 打击范围
    [Export] float turretRange = 10.0f;
    private MeshInstance3D turretTop;

    public Path3D enemyPath;
    private PathFollow3D enemyTarget;

    private AnimationPlayer animationPlayer;
    
    public override void _Ready()
    {
        animationPlayer=GetNode<AnimationPlayer>("AnimationPlayer");
        turretTop = GetNode<MeshInstance3D>("TurretBase/TurretTop");
    }

    public override void _PhysicsProcess(double delta)
    {
        // 炮塔总是对着最后一个敌人
        // Node3D enemy =
        //     (Node3D)enemyPath.GetChildren().Last();
        enemyTarget = findBaseTarget();
        if (enemyTarget != null)
        {
            LookAt(enemyTarget.GlobalPosition, Vector3.Up, true);
        }
    }

    public void onTimerTimeout()
    {
        // 没有目标,停止射击
        if (enemyTarget != null)
        {
            Area3D shot = (Area3D)projectilePrefab.Instantiate();
            AddChild(shot);
            Projectile projectileCs = shot as Projectile;
            // 该节点的全局位置
            shot.GlobalPosition = turretTop.GlobalPosition;
            // 射击方向
            // projectileCs.direction = Basis.Z;
            projectileCs.direction = GlobalTransform.Basis.Z;
            animationPlayer.Play("fire");
        }
    }


    // 永远打最前面一个敌人
    public PathFollow3D findBaseTarget()
    {
        PathFollow3D bestTarget = null;
        float baseProgress = 0.0f;
        foreach (Node enemy in enemyPath.GetChildren())
        {
            if (enemy is PathFollow3D)
            {
                PathFollow3D enemyPf = enemy as PathFollow3D;
                // 返回2个向量之间的距离
                float distanceTo = GlobalPosition.DistanceTo(enemyPf.GlobalPosition);
                if (enemyPf.Progress > baseProgress && distanceTo <= turretRange)
                {
                    bestTarget = enemyPf;
                    baseProgress = enemyPf.Progress;
                }
            }
        }

        return bestTarget;
    }
}

运行:


image.png

自动创建敌人

修改res://BaseLevel.tscn场景。

image.png

EnemyPath节点增加脚本res://EnemyPath.cs

using Godot;
using System;

public partial class EnemyPath : Path3D
{
    [Export] public PackedScene EnemyScene;
    [Export] public int EnemyCount = 20;
    private int currEnemyCount;

    public override void _Ready()
    {
        currEnemyCount = EnemyCount;
    }

    public override void _Process(double delta)
    {
    }


    public void spawnEnemy()
    {
        Node3D newEnemy = (Node3D)EnemyScene.Instantiate();
        AddChild(newEnemy);
    }

    public void _on_timer_timeout()
    {
        if (currEnemyCount > 0)
        {
            currEnemyCount--;
            spawnEnemy();
        }
    }
}
image.png
image.png

增加组件MarginContainer。组件下增加一个文本。


image.png

image.png

创建脚本Bank.cs

using Godot;
using System;

public partial class Bank : MarginContainer
{
    [Export] public int startingGold = 150;

    private Label label;
    private int _currGold;

    public int CurrGold
    {
        get => _currGold;
        set
        {
            _currGold = Math.Max(value, 0);
            label.Text = $"金币:{_currGold}";
        }
    }

    public override void _Ready()
    {
        label = GetNode<Label>("Label");
        CurrGold = startingGold;
    }

    public override void _Process(double delta)
    {
    }
}

修改res://RayPickerCamera/RayPickerCamera.cs,让敌人产生受金币限制。

using Godot;
using System;

public partial class RayPickerCamera : Camera3D
{
    private RayCast3D _rayCast3D;
    [Export] public GridMap gridMap;
    [Export] public Node3D curretManage;
    private Bank bankCs;
    private PlayerBase playerBaseCs;
    private TurretManager turretManagerScript;

    public override void _Ready()
    {
        bankCs = GetTree().GetFirstNodeInGroup("bank") as Bank;
        playerBaseCs = GetTree().GetFirstNodeInGroup("PlayerBase") as PlayerBase;
        _rayCast3D = GetNode<RayCast3D>("RayCast3D");
        if (curretManage != null)
        {
            turretManagerScript = curretManage as TurretManager;
        }
    }

    public override void _Process(double delta)
    {
        // 获取射线和鼠标交叉的坐标点
        Vector2 mousePosition = GetViewport().GetMousePosition();
        // GD.Print(mousePosition.ToString());
        // ProjectLocalRayNormal 从屏幕点位置返回沿相机方向的法向量。
        // 再赋值给射线的目标位置
        // 因为指向射线只有单位向量(1米),这里乘以100,使向量穿过任何地面
        _rayCast3D.TargetPosition = ProjectLocalRayNormal(mousePosition) * 100.0f;
        // ForceRaycastUpdate:立即更新光线的碰撞信息,而不等待下一个_physics_process调用。
        // 例如,当射线或它的父射线改变了状态时,使用此方法。
        _rayCast3D.ForceRaycastUpdate();
        // GetCollider:打印得到的第一个对撞对象。如果没有对象与射线相交,则返回null(即iscollision()返回false)。
        // GetCollisionPoint:返回在全局坐标系中光线与最近物体相交的碰撞点。
        // GD.PrintT(_rayCast3D.GetCollider(),_rayCast3D.GetCollisionPoint()); 
        // 如果撞上了什么东西
        if (_rayCast3D.IsColliding())
        {
            // 有钱才允许建炮台
            if (bankCs.CurrGold >= playerBaseCs.TurretCost)
            {
                // 鼠标变小手
                Input.SetDefaultCursorShape(Input.CursorShape.PointingHand);

                GodotObject collider = _rayCast3D.GetCollider();
                if (collider is GridMap)
                {
                    // 鼠标点击
                    if (Input.IsActionPressed("click"))
                    {
                        Vector3 collisionPoint = _rayCast3D.GetCollisionPoint();
                        Vector3I cell = gridMap.LocalToMap(collisionPoint);
                        // GD.Print(cell);
                        // 如果没有炮台,再出现炮台
                        if (gridMap.GetCellItem(cell) == 0)
                        {
                            gridMap.SetCellItem(cell, 1);
                            //得到网格的坐本地坐标空间中的位置。(全局坐标)
                            Vector3 tilePosition = gridMap.MapToLocal(cell);
                            // 传递给炮塔创建
                            turretManagerScript.buildTurret(tilePosition);
                            
                            // 建好炮台就扣钱
                            bankCs.CurrGold -= playerBaseCs.TurretCost;
                        }
                    }
                }
            }
            else
            {
                // 鼠标样式小手变默认
                Input.SetDefaultCursorShape();
            }
        }
        else
        {
            Input.SetDefaultCursorShape();
        }
    }
}

修改res://Enemy/Enemy.cs,让敌人死亡提供金币。

using Godot;
using System;

public partial class Enemy : PathFollow3D
{
    [Export] public double speed = 2.5;
    [Export] public int MaxHealth = 50;
    [Export] public int DeadGold = 15;
    private Node3D playerBase;
    private PlayerBase playerBaseScript;
    private AnimationPlayer animationPlayer;
    private Bank bankCs;

    private int _currentHealth;

    public int CurrentHealth
    {
        get => _currentHealth;
        set
        {
            if (value < CurrentHealth)
            {
                animationPlayer.Play("TakeDamage");
            }

            _currentHealth = value;
            //  销毁自身
            if (value < 1)
            {
                bankCs.CurrGold += DeadGold;
                QueueFree();
            }
        }
    }

    public override void _Ready()
    {
        bankCs = GetTree().GetFirstNodeInGroup("bank") as Bank;
        animationPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
        CurrentHealth = MaxHealth;
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用Script中的方法
            playerBaseScript?.takeDamage();
            // 停止运行_Process函数
            SetProcess(false);
            // Free();
        }
    }
}

修改res://PlayerBase/PlayerBase.cs,初始化炮台制造的耗费。

public partial class PlayerBase : Node3D
{
    ......
    [Export] public int TurretCost = 100;
......
}
image.png

敌人难度曲线增加

增加Node节点DifficultyManager,当做困难管理器。
该节点下新增节点Timer。


image.png

DifficultyManager挂载脚本DifficultyManager/DifficultyManagerCs.cs。

using Godot;
using System;

public partial class DifficultyManagerCs : Node
{
    /// <summary>
    /// 游戏时间长度
    /// </summary>
    [Export] public float ExGameLength = 10.0f;

    // 制作敌人时间曲线
    [Export] public Curve ExSpawnTimeCurve;
    private Timer _timer;

    public override void _Ready()
    {
        _timer = GetNode<Timer>("Timer");
        _timer.Start(ExGameLength);
    }

    public override void _Process(double delta)
    {
        GD.Print(getSpawnTime());
    }

    public float gameProgressRatio()
    {
        return 1.0f - ((float)_timer.TimeLeft / ExGameLength);
    }

    public float getSpawnTime()
    {
        return ExSpawnTimeCurve.Sample(gameProgressRatio());
    }
}
image.png

image.png

记得重新加载一下节点(删掉,然后重新拉入),否则代码不会生效:


image.png

修改EnemyPath节点,


image.png

EnemyPath.cs,将困难管理器当参数传入路径节点的脚本中,

using Godot;
using System;
using System.Diagnostics;
using Godot.Collections;

/// <summary>
/// 敌人路径
/// </summary>
public partial class EnemyPath : Path3D
{
    [Export] public PackedScene ExEnemyScene;

    /// <summary>
    /// 计时器曲线
    /// </summary>
    [Export] public Node ExDifficultyManager;

    private DifficultyManagerCs difficultyManagerCs;
    private Timer exTimer;

    public override void _Ready()
    {
        exTimer = (Timer)GetNode("Timer");
        
        difficultyManagerCs= ExDifficultyManager as DifficultyManagerCs;
        GD.Print(difficultyManagerCs);
    }

    public void spawnEnemy()
    {
        Node3D newEnemy = (Node3D)ExEnemyScene.Instantiate();
        AddChild(newEnemy);
        if (difficultyManagerCs != null)
        {
            exTimer.WaitTime = difficultyManagerCs.getSpawnTime();
            GD.Print(exTimer.WaitTime);
        }
    }

    public void _on_timer_timeout()
    {
        spawnEnemy();
    }
}

通过绑定困难曲线联动时间工具节点。

image.png

ExGameLength调成30f。


image.png

敌人生命值曲线增加

困难管理器增加导入属性。

    // 制作敌人生命值变化曲线
    [Export] public Curve ExEnemyHealthCurve;
image.png

记得参数是存入场景中,再重新加载场景。

定义一个信号结束生产敌人

修改DifficultyManager/DifficultyManager.cs

using Godot;
using System;
using Timer = Godot.Timer;

public partial class DifficultyManager : Node
{
    [Signal]
    public delegate void StopSpawningEnemiesEventHandler();

    /// <summary>
    /// 游戏时间长度
    /// </summary>
    [Export] public float ExGameLength = 10.0f;

    // 制作敌人出现时间曲线
    [Export] public Curve ExSpawnTimeCurve;

    // 制作敌人生命值变化曲线
    [Export] public Curve ExEnemyHealthCurve;
    private Timer _timer;

    public override void _Ready()
    {
        _timer = GetNode<Timer>("Timer");
        _timer.Start(ExGameLength);
    }

    public override void _Process(double delta)
    {
        // GD.Print(GetSpawnTime());
    }

    public float GameProgressRatio()
    {
        return 1.0f - ((float)_timer.TimeLeft / ExGameLength);
    }

    /// <summary>
    /// 获取敌人出现时间
    /// </summary>
    /// <returns></returns>
    public float GetSpawnTime()
    {
        // 返回沿曲线存在于X位置偏移处的点的Y值
        return ExSpawnTimeCurve.Sample(GameProgressRatio());
    }

    /// <summary>
    /// 获取敌人生命值
    /// </summary>
    /// <returns></returns>
    public int GetEnemyHealth()
    {
        // 返回沿曲线存在于X位置偏移处的点的Y值
        return (int)Math.Round(ExEnemyHealthCurve.Sample(GameProgressRatio()));
    }

    public void OnTimerTimeout()
    {
        GD.Print("触发OnTimerTimeout事件!");
        // 发送自定义信号
        EmitSignal(SignalName.StopSpawningEnemies);
    }
}

增加信号并绑定:


image.png
image.png

修改EnemyPath.cs

using Godot;
using System;
using System.Diagnostics;
using Godot.Collections;

/// <summary>
/// 敌人路径
/// </summary>
public partial class EnemyPath : Path3D
{
    [Export] public PackedScene ExEnemyScene;

    /// <summary>
    /// 计时器曲线
    /// </summary>
    [Export] public Node ExDifficultyManager;

    private DifficultyManager DifficultyManager;
    private Timer _timer;

    public override void _Ready()
    {
        _timer = (Timer)GetNode("Timer");

        DifficultyManager = ExDifficultyManager as DifficultyManager;
        // GD.Print(DifficultyManager);
    }

    /// <summary>
    /// 敌人产卵方法
    /// </summary>
    public void spawnEnemy()
    {
        Enemy newEnemy = ExEnemyScene.Instantiate() as Enemy;
        if (newEnemy == null)
        {
            return;
        }
        // 设置新的最大生命值
        newEnemy.MaxHealth = DifficultyManager.GetEnemyHealth();
        AddChild(newEnemy);
        if (DifficultyManager != null)
        {
            _timer.WaitTime = DifficultyManager.GetSpawnTime();
            // GD.Print(_timer.WaitTime);
            GD.Print(newEnemy.CurrentHealth);
        }
    }

    public void _on_timer_timeout()
    {
        spawnEnemy();
    }

    /// <summary>
    /// 游戏结束,停止敌人产卵
    /// </summary>
    public void OnDifficultyManagerStopSpawning()
    {
        GD.Print("进入OnDifficultyManagerStopSpawning");
        _timer.Stop();
    }
}

完善敌人销毁,修改Enemy/Enemy.cs,增加QueueFree函数使用,还可以调节速度。

......
    public override void _Ready()
    {
        bankCs = GetTree().GetFirstNodeInGroup("bank") as Bank;
        animationPlayer = GetNode<AnimationPlayer>("AnimationPlayer");
        CurrentHealth = MaxHealth;
        playerBase = (Node3D)GetTree().GetFirstNodeInGroup("PlayerBase");
        // speed = 13;
    }

    public override void _Process(double delta)
    {
        this.Progress += (float)(delta * speed);
        if (playerBase != null)
        {
            playerBaseScript = playerBase as PlayerBase;
        }

        if (ProgressRatio >= 1.0)
        {
            // 调用Script中的方法
            playerBaseScript?.takeDamage();
            // 停止运行_Process函数
            SetProcess(false);
            // Free();
            QueueFree();
        }
    }
......

程序结束时,页面重新加载,报错:


image.png

启动CSG多边形父子依赖就好。


image.png

游戏通关

所有敌人被消灭则意味着通关。
增加游戏通关结算页面ExVictoryLayer。


image.png
image.png

将颜色设置半透明。


image.png

修改脚本EnemyPath.cs。

using Godot;
using System;
using System.Diagnostics;
using Godot.Collections;

/// <summary>
/// 敌人路径
/// </summary>
public partial class EnemyPath : Path3D
{
    [Export] public PackedScene ExEnemyScene;

    /// <summary>
    /// 计时器曲线
    /// </summary>
    [Export] public Node ExDifficultyManager;
    
    
    [Export] public CanvasLayer ExVictoryLayer;

    private DifficultyManager _difficultyManager;
    
    
    private Timer _timer;

    public override void _Ready()
    {
        _timer = (Timer)GetNode("Timer");

        _difficultyManager = ExDifficultyManager as DifficultyManager;
        // GD.Print(_difficultyManager);
    }

    /// <summary>
    /// 敌人产卵方法
    /// </summary>
    public void spawnEnemy()
    {
        Enemy newEnemy = ExEnemyScene.Instantiate() as Enemy;
        if (newEnemy == null)
        {
            return;
        }

        // 设置新的最大生命值
        newEnemy.MaxHealth = _difficultyManager.GetEnemyHealth();
        AddChild(newEnemy);
        if (_difficultyManager != null)
        {
            _timer.WaitTime = _difficultyManager.GetSpawnTime();
            // GD.Print(_timer.WaitTime);
            GD.Print(newEnemy.CurrentHealth);
            // 将树退出的信号连接到enemyDefeated函数
            newEnemy.TreeExited += enemyDefeated;
        }
    }

    public void _on_timer_timeout()
    {
        spawnEnemy();
    }

    /// <summary>
    /// 游戏结束,停止敌人产卵
    /// </summary>
    public void OnDifficultyManagerStopSpawning()
    {
        GD.Print("进入OnDifficultyManagerStopSpawning");
        _timer.Stop();
    }


    /// <summary>
    /// 敌人都被击败
    /// </summary>
    public void enemyDefeated()
    {
        if (_timer.IsStopped())
        {
            for (int i = 0; i < GetChildren().Count; i++)
            {
                Node node = GetChildren()[i];
                // 如果找到子节点为路径节点,则停止后续代码执行
                if (node is PathFollow3D)
                {
                    return;
                }
            }

            GD.Print("你赢了!");
            ExVictoryLayer.Visible = true;
        }
    }
}

将默认visiable设置为停用,导入组件。


image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,137评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,824评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,465评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,131评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,140评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,895评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,535评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,435评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,952评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,081评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,210评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,896评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,552评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,089评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,198评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,531评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,209评论 2 357

推荐阅读更多精彩内容