前言
弹幕本身做出来以后,还要把敌人、波次和 Boss 排成一关。车万空寂花现在用 StageData 保存任务数据,再由 StageRunner 按时间触发。进入关卡前还会加载资源、建立子弹贴图索引并预热对象池。
这一篇主要记录这两个部分是怎么接起来的,以及目前 Resources 方案还留下了哪些问题。
源码入口:qian488/Danmaku。
这个系列
- 从可玩成品倒推整体架构
- 集中更新、对象池与碰撞判定
- 本文:时间轴关卡、资源预热与加载流程
关卡时间轴
最初写关卡时,很容易得到这样的代码:
yield return new WaitForSeconds(2f);
SpawnWaveA();
yield return new WaitForSeconds(5f);
SpawnWaveB();
yield return new WaitForSeconds(8f);
SpawnBoss();
它能运行,但关卡节奏、执行逻辑和资源引用全部混在代码里。修改一个出怪时间需要改脚本,插入波次可能影响后续等待,跳转调试也不方便。
项目改用 StageData 描述“时间点发生什么”,由 StageRunner 推进:
[Serializable]
public class StageTask
{
public float triggerTime;
public StageTaskType type;
public string payloadId;
}
运行时只维护已过时间和下一个任务索引:
推进 elapsedTime
↓
检查 nextTask.triggerTime
↓ 到时
执行 SpawnWave / FirePattern / SpawnBoss
↓
nextTaskIndex++
只按顺序检查未执行任务,避免每帧扫描整张表。
时间任务如何调用弹幕系统
时间轴记录事件发生时机,执行工作分配给对应系统:
StageRunner:什么时候触发;- Wave/Spawner:生成哪些敌人;
- Shooter/Pattern:以什么角度和速度发射;
BulletManager:管理生成后的弹幕;- Boss:管理自身阶段、生命与技能。
这种拆分使同一套圆形、扇形、自机狙或螺旋 pattern 可以被不同关卡复用。调整关卡时只需重新组合任务数据和 pattern 参数。
Boss 出现后暂停普通任务
普通波次按时间前进,但 Boss 战通常需要“击败后才继续”。如果时间轴照常推进,Boss 尚未结束时后续敌人和结算事件就可能提前出现。
项目在进入 Boss 阶段后暂停普通时间轴任务,但保留战斗更新:
Stage timeline:暂停
玩家、Boss、弹幕:继续更新
Boss 被击败
Stage timeline:恢复或进入结算
实现中只停止关卡任务计时,玩家、Boss 和现有弹幕继续更新。全局 Time.timeScale 会冻结整场游戏,因此不适合这个阶段切换需求。
无尽模式在时间轴结束后重置任务索引,并逐轮调整速度或强度。循环规则由关卡模式统一管理,最后一个普通任务不负责创建下一轮。
第一次进入关卡要准备什么
即使每帧没有分配,第一次使用某项资源仍可能触发:
Resources.LoadAsync或同步加载;- Sprite、AudioClip 等资源反序列化;
- 音频首次解码;
- prefab 第一次 Instantiate;
- 对象池首次扩容;
- 子弹样式目录和变体索引建立。
这些工作集中发生时会形成单帧尖刺。项目把可提前完成的部分放入 Loading 阶段,并显示准备进度。
主菜单和关卡使用两份清单
项目将主菜单和关内资源分开:
Home
├── 主菜单 UI
├── 角色与章节信息
└── 通用音频
Danmaku Stage
├── 本关敌人与 Boss
├── 本关弹幕样式
├── 战斗 UI 与特效
└── 核心对象池预热
主菜单入口 DanmakuLauncher 先加载首页必需资源;玩家确定章节后,再加载本关清单。这样不会为了进入一个轻量菜单就把所有关卡常驻内存,也不会等进战斗后才发现关键 prefab 未就绪。
异步资源大小不同,API 调用数量无法直接代表真实进度。项目按加载阶段映射进度;资源、索引和对象池预热全部完成后,进度才到 100% 并进入战斗。
提前建立子弹贴图索引
子弹有多个样式目录和颜色变体。如果运行时每生成一颗子弹都拼路径、探测资源并查找 Sprite,会把资源组织方式泄露到热路径。
DanmakuBulletResourceIndex 在加载阶段建立类似映射:
(styleId, variantId) → Sprite
运行时只按 ID 查询缓存。索引还可以记录目录是否已经预热,避免切回相同内容时重复全量扫描。
如果没有显式清单而必须探测变体,也应该通过协程分帧执行,避免一次同步 IO 集中在单帧。
加载 prefab 以后再预热池
预加载得到的是资源;预热对象池创建的是运行时实例:
预加载 prefab → 内存里已有模板
预热 pool → 已创建可立即复用的 GameObject
因此加载流程先准备 prefab 及其依赖,再创建核心池对象。贴图、音效和表现资源也要进入相应清单。
核心高频对象根据预估峰值分帧创建。分帧可以限制 Loading 阶段的单帧耗时;总创建量不会因此减少。
长期运行后还需要裁剪空闲池:关卡高潮时需要的大量对象,不一定要在回到菜单后全部常驻。合理的策略是保留常用容量,对明显超出常态的空闲实例设置上限。
离开关卡时清理引用
释放前首先要清理自己的强引用:
- 活跃弹幕和敌人回池;
- 本关资源缓存移除;
- UI、音频和特效停止使用;
- 事件订阅解除;
- 跨场景静态引用清空。
只有清理这些强引用后,Resources.UnloadUnusedAssets 才能回收符合条件的资源。因此资源释放需要和场景退出流程一起执行。
当前 Resources 方案的主要限制是打包粒度、依赖分析和远程更新能力。未来迁移 Addressables 前,可以先把玩法脚本的资源请求收敛到统一接口,随后替换接口后的加载实现。
加载流程怎样检查
建议分别记录冷启动和热启动:
- 清理缓存后第一次进入主菜单;
- 第一次进入某一关;
- 返回菜单后第二次进入同一关;
- 进入包含不同 Boss 和弹幕样式的另一关;
- 长时间游玩后返回菜单观察内存是否回落。
验证指标包括:
- Loading 总耗时与最长单帧;
- 开战后首次开火、首次受击、首次播放音效是否仍有尖刺;
- 池命中率和运行时扩容次数;
- 切场景后的对象数与内存;
- 缺失资源能否输出包含资源键和加载阶段的错误信息。
写在最后
- 时间轴数据让关卡节奏可以通过任务配置调整。
- Boss 阶段暂停任务推进,同时保留玩家、Boss 和弹幕更新。
- 预加载资源、建立索引和预热对象池解决的是三种不同首次成本。
- Loading 阶段统一执行资源加载、索引建立和对象池预热。
- 场景退出时先解除项目引用,再执行资源回收。
车万空寂花的三篇先整理到这里。现在能说明的是项目已经有完整游戏流程、集中弹幕管理、时间轴和预加载;性能基准、关卡编辑预览和更细的资源生命周期还需要继续补。以后实际改到这些地方,再回来更新。