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 每帧读取它即可。
效果与系统关系图
工程结构建议
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()。
运行与测试
-
准备 Prefab
Asteroid.prefab(带 MeshRenderer)Bullet.prefab(小球/方块即可,带 MeshRenderer)
-
场景放置
- 一个空物体:
SpawnerAuthoring,填好AsteroidPrefab/Count/Area - 一个
PlayerAuthoring,拖LeftGun/RightGun(建议为两个空物体) - 一个
BulletPoolAuthoring,填BulletPrefab&PoolSize - 一个
ScoreAuthoring(空物体挂脚本即可) - 一个 Canvas + Text,挂
ScoreUI
- 一个空物体:
-
SubScene(推荐):将以上 Authoring 放到 SubScene 中以获得稳定烘焙。
-
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
作者:世纪末的魔术师
出处:https://www.cnblogs.com/Firepad-magic/
Unity最受欢迎插件推荐:点击查看
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

浙公网安备 33010602011771号