LOADING

加载过慢请开启缓存 浏览器默认开启

车万空寂花系列三:时间轴关卡、资源预热与加载流程

前言

弹幕本身做出来以后,还要把敌人、波次和 Boss 排成一关。车万空寂花现在用 StageData 保存任务数据,再由 StageRunner 按时间触发。进入关卡前还会加载资源、建立子弹贴图索引并预热对象池。

这一篇主要记录这两个部分是怎么接起来的,以及目前 Resources 方案还留下了哪些问题。

源码入口:qian488/Danmaku

这个系列

  1. 从可玩成品倒推整体架构
  2. 集中更新、对象池与碰撞判定
  3. 本文:时间轴关卡、资源预热与加载流程

关卡时间轴

最初写关卡时,很容易得到这样的代码:

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 前,可以先把玩法脚本的资源请求收敛到统一接口,随后替换接口后的加载实现。

加载流程怎样检查

建议分别记录冷启动和热启动:

  1. 清理缓存后第一次进入主菜单;
  2. 第一次进入某一关;
  3. 返回菜单后第二次进入同一关;
  4. 进入包含不同 Boss 和弹幕样式的另一关;
  5. 长时间游玩后返回菜单观察内存是否回落。

验证指标包括:

  • Loading 总耗时与最长单帧;
  • 开战后首次开火、首次受击、首次播放音效是否仍有尖刺;
  • 池命中率和运行时扩容次数;
  • 切场景后的对象数与内存;
  • 缺失资源能否输出包含资源键和加载阶段的错误信息。

写在最后

  • 时间轴数据让关卡节奏可以通过任务配置调整。
  • Boss 阶段暂停任务推进,同时保留玩家、Boss 和弹幕更新。
  • 预加载资源、建立索引和预热对象池解决的是三种不同首次成本。
  • Loading 阶段统一执行资源加载、索引建立和对象池预热。
  • 场景退出时先解除项目引用,再执行资源回收。

车万空寂花的三篇先整理到这里。现在能说明的是项目已经有完整游戏流程、集中弹幕管理、时间轴和预加载;性能基准、关卡编辑预览和更细的资源生命周期还需要继续补。以后实际改到这些地方,再回来更新。