ECS射击游戏—双管老太婆

请帮我点个赞和小心心,你的支持是我最大的动力(╹▽╹)通过网盘分享的文件:GodGUN.unitypackage链接: https://pan.baidu.com/s/1S9JtU0Bz8GrgIjV_aF6NRg?pwd=77hd 提取码: 77hd

TL;DR(先吃肉)

  • Authoring + Baker 把场景配置转成 ECS 数据。
  • EntityCommandBuffer 做所有结构性更改(创建/销毁/加减组件)。
  • 子弹对象池:初始化时创建 N 颗子弹并加上 Disabled;发射时 Remove<Disabled>;命中后 Add<Disabled> 回收。
  • 发射一定使用 LocalToWorld(而不是 LocalTransform)读取枪口的世界姿态,否则会从“旧位置”开火。
  • 命中检测(简化版)先用距离判断,后续可替换为 Unity Physics 碰撞事件。
  • UI 用常规 UGUI/UIToolkit;ECS 用一个 Score 单例组件,UI 每帧读取它即可。

效果与系统关系图

flowchart LR A[Player 实体<br/>PlayerTag+GunPoints] --> B[PlayerMoveSystem] A --> C[PlayerShootSystem<br/>从对象池取子弹] C --> D[BulletMoveSystem<br/>位置更新/越界回收] D --> E[BulletHitSystem<br/>命中判定/加分] E --> F[Score 单例 <br/>Value: int] F --> G[ScoreUI Mono <br/>Text显示] H[SpawnSystem<br/>生成小块 Asteroid] --> E

工程结构建议

Assets/Scripts/
  Components/
    MoveSpeed.cs
    Heading.cs
    Lifetime.cs
    Bullet.cs
    Asteroid.cs
    Score.cs
    GunPoints.cs
  Authoring/
    SpawnerAuthoring.cs
    SpawnerBaker.cs
    PlayerAuthoring.cs
    PlayerBaker.cs
    BulletPoolAuthoring.cs
    BulletPoolBaker.cs
    ScoreAuthoring.cs
    ScoreBaker.cs
  Systems/
    SpawnSystem.cs
    PlayerMoveSystem.cs
    PlayerShootSystem.cs
    BulletPoolSystem.cs
    BulletMoveSystem.cs
    BulletHitSystem.cs
  UI/
    ScoreUI.cs   // MonoBehaviour

数据建模(组件)

// Components/Bullet.cs
using Unity.Entities;
using Unity.Mathematics;

public struct BulletTag : IComponentData {}
public struct ActiveBullet : IComponentData {}          // 在飞行中
public struct BulletVelocity : IComponentData { public float3 Value; }

// Components/Asteroid.cs
using Unity.Entities;
public struct AsteroidTag : IComponentData {}
public struct Lifetime : IComponentData { public float Seconds; } // 可选:小块也能寿命消失

// Components/Score.cs
using Unity.Entities;
public struct Score : IComponentData { public int Value; }

// Components/GunPoints.cs
using Unity.Entities;
public struct PlayerTag : IComponentData {}
public struct GunPoints : IComponentData
{
    public Entity Left;   // 左枪口实体
    public Entity Right;  // 右枪口实体
}

Authoring 与 Baker

小块生成器(Spawner)

// Authoring/SpawnerAuthoring.cs
using UnityEngine;

public class SpawnerAuthoring : MonoBehaviour
{
    public GameObject AsteroidPrefab;
    public int Count = 500;
    public Vector3 AreaSize = new(200, 0, 200);
}
// Authoring/SpawnerBaker.cs
using Unity.Entities;
using UnityEngine;

public struct SpawnSettings : IComponentData
{
    public Entity Prefab;
    public int Count;
    public Unity.Mathematics.float3 AreaSize;
}

public class SpawnerBaker : Baker<SpawnerAuthoring>
{
    public override void Bake(SpawnerAuthoring a)
    {
        var e = GetEntity(TransformUsageFlags.None);
        AddComponent(e, new SpawnSettings
        {
            Prefab = GetEntity(a.AsteroidPrefab, TransformUsageFlags.Dynamic),
            Count = a.Count,
            AreaSize = a.AreaSize
        });
    }
}

玩家与双枪口

// Authoring/PlayerAuthoring.cs
using UnityEngine;
public class PlayerAuthoring : MonoBehaviour
{
    public float MoveSpeed = 10f;
    public Transform LeftGun;
    public Transform RightGun;
}
// Authoring/PlayerBaker.cs
using Unity.Entities;
using Unity.Transforms;

public class PlayerBaker : Baker<PlayerAuthoring>
{
    public override void Bake(PlayerAuthoring a)
    {
        var player = GetEntity(TransformUsageFlags.Dynamic);
        AddComponent<PlayerTag>(player);
        AddComponent(player, new MoveSpeed { Value = a.MoveSpeed });
        AddComponent(player, new GunPoints
        {
            Left  = GetEntity(a.LeftGun,  TransformUsageFlags.Dynamic),
            Right = GetEntity(a.RightGun, TransformUsageFlags.Dynamic)
        });
    }
}

public struct MoveSpeed : IComponentData { public float Value; }

子弹池

// Authoring/BulletPoolAuthoring.cs
using UnityEngine;
public class BulletPoolAuthoring : MonoBehaviour
{
    public GameObject BulletPrefab;
    public int PoolSize = 200;
}
// Authoring/BulletPoolBaker.cs
using Unity.Entities;
using UnityEngine;

public struct BulletPool : IComponentData
{
    public Entity Prefab;
    public int Size;
}

public class BulletPoolBaker : Baker<BulletPoolAuthoring>
{
    public override void Bake(BulletPoolAuthoring a)
    {
        var pool = GetEntity(TransformUsageFlags.None);
        AddComponent(pool, new BulletPool
        {
            Prefab = GetEntity(a.BulletPrefab, TransformUsageFlags.Dynamic),
            Size = a.PoolSize
        });
    }
}

分数单例

// Authoring/ScoreAuthoring.cs
using UnityEngine;
public class ScoreAuthoring : MonoBehaviour {}
// Authoring/ScoreBaker.cs
using Unity.Entities;

public class ScoreBaker : Baker<ScoreAuthoring>
{
    public override void Bake(ScoreAuthoring a)
    {
        var e = GetEntity(TransformUsageFlags.None);
        AddComponent(e, new Score { Value = 0 });
    }
}

Systems(核心逻辑)

1)生成小块

// Systems/SpawnSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct SpawnSystem : ISystem
{
    [BurstCompile] public void OnCreate(ref SystemState s) => s.RequireForUpdate<SpawnSettings>();

    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        var settings = SystemAPI.GetSingleton<SpawnSettings>();
        var ecb = new EntityCommandBuffer(Allocator.Temp);
        var rnd = new Unity.Mathematics.Random(0xABCDEFu);

        for (int i = 0; i < settings.Count; i++)
        {
            var e = ecb.Instantiate(settings.Prefab);
            float3 pos = new float3(
                rnd.NextFloat(-0.5f, 0.5f) * settings.AreaSize.x,
                0,
                rnd.NextFloat(-0.5f, 0.5f) * settings.AreaSize.z
            );
            ecb.AddComponent<AsteroidTag>(e);
            ecb.SetComponent(e, LocalTransform.FromPositionRotationScale(pos, quaternion.identity, 1f));
        }

        ecb.Playback(s.EntityManager);
        ecb.Dispose();
        s.Enabled = false; // 只生成一次
    }
}

2)玩家移动(WASD)

// Systems/PlayerMoveSystem.cs
using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

[BurstCompile]
public partial struct PlayerMoveSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        float dt = SystemAPI.Time.DeltaTime;
        float3 input = float3.zero;
        if (Input.GetKey(KeyCode.W)) input.z += 1;
        if (Input.GetKey(KeyCode.S)) input.z -= 1;
        if (Input.GetKey(KeyCode.A)) input.x -= 1;
        if (Input.GetKey(KeyCode.D)) input.x += 1;

        foreach (var (lt, ms) in SystemAPI.Query<RefRW<LocalTransform>, MoveSpeed>().WithAll<PlayerTag>())
        {
            if (math.lengthsq(input) > 1e-6f) input = math.normalize(input);
            var t = lt.ValueRW;
            t.Position += input * ms.Value * dt;
            lt.ValueRW = t;
        }
    }
}

3)子弹池初始化(默认隐藏)

// Systems/BulletPoolSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Transforms;

[BurstCompile]
public partial struct BulletPoolSystem : ISystem
{
    [BurstCompile] public void OnCreate(ref SystemState s) => s.RequireForUpdate<BulletPool>();

    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        var pool = SystemAPI.GetSingleton<BulletPool>();
        var ecb = new EntityCommandBuffer(Allocator.Temp);

        for (int i = 0; i < pool.Size; i++)
        {
            var e = ecb.Instantiate(pool.Prefab);
            ecb.AddComponent<BulletTag>(e);
            ecb.AddComponent<Disabled>(e); // 关键:初始禁用,不显示不参与逻辑
        }

        ecb.Playback(s.EntityManager);
        ecb.Dispose();
        s.Enabled = false;
    }
}

4)玩家射击(用 LocalToWorld!双管交替)

// Systems/PlayerShootSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;

[BurstCompile]
public partial struct PlayerShootSystem : ISystem
{
    private bool useLeftGun;

    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        if (!Input.GetMouseButtonDown(0)) return;
        if (!SystemAPI.TryGetSingleton(out GunPoints guns)) return;

        // 找闲置子弹(池中 Disabled 且没有 ActiveBullet)
        var q = SystemAPI.QueryBuilder()
                         .WithAll<BulletTag, Disabled>()
                         .WithNone<ActiveBullet>()
                         .Build();

        var list = q.ToEntityArray(Allocator.Temp);
        if (list.Length == 0) { list.Dispose(); return; }
        var bullet = list[0];
        list.Dispose();

        var gun = useLeftGun ? guns.Left : guns.Right;
        var gunL2W = SystemAPI.GetComponent<LocalToWorld>(gun);

        var ecb = new EntityCommandBuffer(Allocator.Temp);
        if (SystemAPI.HasComponent<Disabled>(bullet))
            ecb.RemoveComponent<Disabled>(bullet);

        ecb.SetComponent(bullet, LocalTransform.FromPositionRotationScale(
            gunL2W.Position, gunL2W.Rotation, 0.2f));

        ecb.AddComponent(bullet, new BulletVelocity { Value = gunL2W.Forward() * 50f });
        ecb.AddComponent<ActiveBullet>(bullet);

        ecb.Playback(s.EntityManager);
        ecb.Dispose();

        useLeftGun = !useLeftGun;
    }
}

要点:读取枪口姿态用 LocalToWorld,否则会用到上帧/本地坐标,出现“子弹从旧位置发射”的错觉。

5)子弹飞行与越界回收

// Systems/BulletMoveSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct BulletMoveSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        float dt = SystemAPI.Time.DeltaTime;
        var ecb = new EntityCommandBuffer(Allocator.Temp);

        foreach (var (lt, v, e) in SystemAPI.Query<RefRW<LocalTransform>, BulletVelocity>()
                                            .WithAll<ActiveBullet>()
                                            .WithEntityAccess())
        {
            lt.ValueRW.Position += v.Value * dt;

            // 超出范围 → 回收
            if (math.lengthsq(lt.ValueRO.Position) > 10000f)
            {
                ecb.RemoveComponent<ActiveBullet>(e);
                ecb.AddComponent<Disabled>(e); // 隐藏
            }
        }

        ecb.Playback(s.EntityManager);
        ecb.Dispose();
    }
}

6)命中检测(简版:距离判断)

// Systems/BulletHitSystem.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

[BurstCompile]
public partial struct BulletHitSystem : ISystem
{
    [BurstCompile]
    public void OnUpdate(ref SystemState s)
    {
        var ecb = new EntityCommandBuffer(Allocator.Temp);

        foreach (var (bL2W, b) in SystemAPI.Query<RefRO<LocalToWorld>>()
                                           .WithAll<BulletTag, ActiveBullet>()
                                           .WithEntityAccess())
        {
            float3 bp = bL2W.ValueRO.Position;

            foreach (var (aL2W, a) in SystemAPI.Query<RefRO<LocalToWorld>>()
                                               .WithAll<AsteroidTag>()
                                               .WithEntityAccess())
            {
                float3 ap = aL2W.ValueRO.Position;
                if (math.distance(bp, ap) < 1.0f)
                {
                    ecb.DestroyEntity(a);                    // 击中销毁小块
                    ecb.RemoveComponent<ActiveBullet>(b);    // 回收子弹
                    ecb.AddComponent<Disabled>(b);           // 隐藏子弹

                    if (SystemAPI.TryGetSingletonEntity<Score>(out var scoreE))
                    {
                        var cur = SystemAPI.GetComponent<Score>(scoreE).Value;
                        ecb.SetComponent(scoreE, new Score { Value = cur + 1 });
                    }
                    break; // 一发子弹只命中一个目标
                }
            }
        }

        ecb.Playback(s.EntityManager);
        ecb.Dispose();
    }
}

后续你可以把这一段替换为 Unity Physics 的碰撞事件,性能和精度更好。


UI(UGUI 版)

// UI/ScoreUI.cs (挂在 Canvas/Text 上)
using UnityEngine;
using UnityEngine.UI;
using Unity.Entities;

public class ScoreUI : MonoBehaviour
{
    public Text scoreText;

    private EntityManager em;
    private EntityQuery scoreQuery;

    void Start()
    {
        em = World.DefaultGameObjectInjectionWorld.EntityManager;
        scoreQuery = em.CreateEntityQuery(ComponentType.ReadOnly<Score>());
    }

    void Update()
    {
        if (scoreQuery.IsEmpty) return; // 避免“0 个单例”异常
        var scoreEntity = scoreQuery.GetSingletonEntity();
        var value = em.GetComponentData<Score>(scoreEntity).Value;
        scoreText.text = $"Score: {value}";
    }
}

这样写能避免你之前的异常:
InvalidOperationException: GetSingleton() requires exactly one entity...
因为我们先检查 IsEmpty 再去 GetSingletonEntity()


运行与测试

  1. 准备 Prefab

    • Asteroid.prefab(带 MeshRenderer)
    • Bullet.prefab(小球/方块即可,带 MeshRenderer)
  2. 场景放置

    • 一个空物体:SpawnerAuthoring,填好 AsteroidPrefab/Count/Area
    • 一个 PlayerAuthoring,拖 LeftGun/RightGun(建议为两个空物体)
    • 一个 BulletPoolAuthoring,填 BulletPrefab & PoolSize
    • 一个 ScoreAuthoring(空物体挂脚本即可)
    • 一个 Canvas + Text,挂 ScoreUI
  3. SubScene(推荐):将以上 Authoring 放到 SubScene 中以获得稳定烘焙。

  4. Play:WASD 移动,鼠标左键开火,命中加分。


常见坑 & 速修

  • 开火位置错乱:读取枪口姿态请用 LocalToWorld,不是 LocalTransform
  • 一开始场景出现子弹:池初始化时忘了加 Disabled
  • 命中不触发:判断位置时也要用 LocalToWorld(世界坐标)。
  • 报“没有单例 Score”:确保场景里有 ScoreAuthoring 或在初始化系统里创建。
  • 结构性更改报错:记住一切创建/销毁/加减组件用 ECB
  • 发射没子弹:池被用光了;要么增大池大小,要么在回收逻辑里及时 Disabled

性能小贴士(真香)

  • 子弹/小块数量大时,把命中检测换为 Unity Physics(ICollisionEventsJob)。
  • 跨帧复用 EntityQuery(缓存),减少构建开销。
  • 能用 RefRO<> 就不用 RefRW<>,帮助调度并行。
  • 将“高频变动”的组件合到同 Archetype,减少 Chunk 跳跃。
  • 尽量避免每帧 ToEntityArray 大量分配;可搭配 Allocator.TempJob 并控制频率,或用更合理的数据结构(环形索引池)。

可选:Unity Physics 碰撞(思路提示)

  • 安装 Unity Physics 包。
  • Asteroid 生成时 AddComponent<PhysicsCollider>;子弹加 PhysicsVelocity 与 Collider。
  • ICollisionEventsJob:在 Schedule() 后遍历碰撞事件,命中即回收/加分。
  • 这样就不需要 O(N²) 距离判断了。

🔥 推荐 Unity 插件
🌧️RainEffect-后处理雨水窗户
🎮 EasyRuntimeGizmo – 运行时可视化 Gizmo 工具
🧭 EasySceneMarker – 快速场景跳转标记系统
📈 Unity 最畅销插件榜单
Unity TopDownded

posted @ 2025-08-21 10:24  世纪末の魔术师  阅读(34)  评论(0)    收藏  举报