feat(monster&spawn): 新增飞行怪物支持,重构怪物移动与刷怪系统

抽离MonMoveComp拆分怪物移动逻辑,让MoveComp仅负责英雄移动
新增Fly和FlyBoss怪物类型,配置三层飞行轨道支持空中怪物
重写波次刷怪逻辑,移除固定5槽限制,按轨道自由排布怪物
将怪物生成上限与恢复阈值从5/3调整为50/30
优化渲染排序逻辑,为飞行怪添加持续浮动动画
移除跨波怪物属性继承,波次切换时自动清理残留怪物
This commit is contained in:
walkpan
2026-05-12 12:23:37 +08:00
parent 92db480baf
commit e7075004fe
7 changed files with 390 additions and 264 deletions

View File

@@ -80,9 +80,9 @@ export class MissionComp extends CCComp {
// ======================== 配置参数 ========================
/** 怪物数量上限(超过后暂停刷怪) */
private maxMonsterCount: number = 5;
private maxMonsterCount: number = 50;
/** 怪物数量恢复阈值(降至此值以下恢复刷怪) */
private resumeMonsterCount: number = 3;
private resumeMonsterCount: number = 30;
/** 新一波金币奖励基础值(现已固定,不再随波次增长) */
private prepareBaseCoinReward: number = 25;
/** 每一波金币增长值固定收益设为0 */

View File

@@ -3,18 +3,15 @@
* @description 怪物Monster波次刷新管理组件逻辑层
*
* 职责:
* 1. 管理每一波怪物的 **生成计划**:根据 WaveSlotConfig 分配怪物到固定槽位
* 2. 管理 5 个固定刷怪槽位的占用状态,支持 Boss 占 2 格
* 3. 处理特殊插队刷怪请求MonQueue优先于常规刷新
* 4. 自动推进波次:当前波所有怪物被清除后自动进入下一波。
* 1. 管理每一波怪物的 **生成计划**:根据 WaveSlotConfig 生成怪物
* 2. 处理特殊插队刷怪请求MonQueue优先于常规刷新
* 3. 自动推进波次:当前波所有怪物被清除后自动进入下一波
*
* 关键设计:
* - 全场固定 5 个槽位(索引 0-4每个槽位占固定 X 坐标
* - Boss 默认占 3 个连续槽位,只要有连续三格空闲即可
* - slotOccupiedEids 记录每个槽位占用的怪物 ECS 实体 ID。
* - 突破 5 槽限制,怪物按刷怪顺序依次从 X=280 开始向右(每隔 50排布
* - 3 条刷怪线:地面、+120、+240
* - resetSlotSpawnData(wave) 在每波开始时读取配置,分配并立即生成所有怪物。
* - refreshSlotOccupancy() 定期检查槽位占用的实体是否仍存活,清除已死亡的占用
* - tryAdvanceWave() 在所有怪物死亡后自动推进波次。
* - 去除跨波 HP 继承,上一波残留怪在波次结束/开始时销毁
*
* 怪物属性计算公式:
* ap = floor((base_ap + stage × grow_ap) × SpawnPowerBias)
@@ -25,7 +22,7 @@
* - RogueConfig —— 怪物类型、成长值、波次配置
* - Monsterhero/Mon.ts—— 怪物 ECS 实体类
* - HeroInfoheroSet—— 怪物基础属性配置(与英雄共用配置)
* - HeroAttrsComp / MoveComp —— 怪物属性和移动组件
* - HeroAttrsComp / MonMoveComp —— 怪物属性和移动组件
* - BoxSet.GAME_LINE —— 地面基准 Y 坐标
*/
import { _decorator, v3, Vec3 } from "cc";
@@ -37,33 +34,31 @@ import { Monster } from "../hero/Mon";
import { HeroInfo, HType } from "../common/config/heroSet";
import { smc } from "../common/SingletonModuleComp";
import { GameEvent } from "../common/config/GameEvent";
import {BoxSet } from "../common/config/GameSet";
import {BoxSet, FacSet } from "../common/config/GameSet";
import { MonList, MonType, SpawnPowerBias, StageBossGrow, StageGrow, UpType, WaveSlotConfig, DefaultWaveSlot, IWaveSlot } from "./RogueConfig";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { MoveComp } from "../hero/MoveComp";
import { MonMoveComp } from "../hero/MonMoveComp";
const { ccclass, property } = _decorator;
/**
* MissionMonCompComp —— 怪物波次刷新管理器
*
* 每波开始时根据 WaveSlotConfig 配置分配怪物到固定槽位
* 战斗中监控槽位状态,所有怪物消灭后自动推进到下一波。
* 每波开始时根据 WaveSlotConfig 配置生成怪物
* 战斗中监控数量,所有怪物消灭后自动推进到下一波。
*/
@ccclass('MissionMonCompComp')
@ecs.register('MissionMonComp', false)
export class MissionMonCompComp extends CCComp {
// ======================== 常量 ========================
/** Boss 的渲染优先级偏移(确保 Boss 始终渲染在最前) */
private static readonly BOSS_RENDER_PRIORITY = 1000000;
/** 第一个槽位的 X 坐标起点 */
private static readonly MON_SLOT_START_X = 50;
/** 槽位间的 X 间距 */
private static readonly MON_SLOT_X_INTERVAL = 65;
/** 怪物出生点起点 X */
private static readonly MON_SPAWN_START_X = 280;
/** 怪物出生的 X 间距 */
private static readonly MON_SPAWN_GAP_X = 50;
/** 怪物出生掉落高度 */
private static readonly MON_DROP_HEIGHT = 280;
/** 最大槽位数 */
private static readonly MAX_SLOTS = 5;
/** 飞行层高度偏移(地面, 空中1, 空中2 */
private static readonly FLY_LANE_Y_OFFSETS = [0, 120, 240];
// ======================== 编辑器属性 ========================
@@ -81,12 +76,14 @@ export class MissionMonCompComp extends CCComp {
uuid: number,
/** 怪物等级 */
level: number,
/** 飞行层 */
flyLane: number,
}> = [];
// ======================== 运行时状态 ========================
/** 槽位占用状态:记录每个槽位当前占用的怪物 ECS 实体 IDnull 表示空闲 */
private slotOccupiedEids: Array<number | null> = Array(5).fill(null);
/** 记录每条线当前排到的索引 */
private laneIndices: number[] = [0, 0, 0];
/** 全局生成顺序计数器(用于渲染层级排序) */
private globalSpawnOrder: number = 0;
/** 插队刷怪处理计时器 */
@@ -110,17 +107,13 @@ export class MissionMonCompComp extends CCComp {
/**
* 帧更新:
* 1. 检查游戏是否运行中。
* 2. 刷新槽位占用状态(清除已死亡怪物的占用)
* 3. 尝试推进波次(所有怪物清除后自动进入下一波)。
* 4. 处理插队刷怪队列。
* 2. 处理插队刷怪队列
*/
protected update(dt: number): void {
if(!smc.mission.play) return
if(smc.mission.pause) return
if(smc.mission.stop_mon_action) return;
if(!smc.mission.in_fight) return;
this.refreshSlotOccupancy();
if(!smc.mission.in_fight) return;
if(smc.mission.stop_spawn_mon) return;
this.updateSpecialQueue(dt);
}
@@ -130,7 +123,7 @@ export class MissionMonCompComp extends CCComp {
/**
* 接收特殊刷怪事件并入队。
* @param event 事件名
* @param args { uuid: number, level: number }
* @param args { uuid: number, level: number, flyLane?: number }
*/
private onSpawnSpecialMonster(event: string, args: any) {
if (!args) return;
@@ -138,6 +131,7 @@ export class MissionMonCompComp extends CCComp {
this.MonQueue.push({
uuid: args.uuid,
level: args.level,
flyLane: args.flyLane || 0
});
// 加速队列消费
this.queueTimer = 1.0;
@@ -158,11 +152,12 @@ export class MissionMonCompComp extends CCComp {
this.waveTargetCount = 0
this.waveSpawnedCount = 0
this.MonQueue = []
this.laneIndices = [0, 0, 0];
let hasBoss = false;
const config = WaveSlotConfig[this.currentWave] || DefaultWaveSlot;
for (const slot of config) {
if (slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss) {
if (slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss || slot.type === MonType.FlyBoss) {
hasBoss = true;
}
}
@@ -172,7 +167,6 @@ export class MissionMonCompComp extends CCComp {
bossWave: hasBoss,
});
// 不再直接调用 startNextWave(),等待进入 PrepareEnd 阶段再刷怪
mLogger.log(this.debugMode, 'MissionMonComp', "[MissionMonComp] Starting Wave System");
}
@@ -180,54 +174,26 @@ export class MissionMonCompComp extends CCComp {
/**
* 处理插队刷怪队列(每 0.15 秒尝试消费一个):
* 1. 判断怪物是否为 Boss决定占用 1 格还是 2 格)
* 2. 在空闲槽位中查找合适位置
* 3. 找到后从队列中移除并生成怪物。
* 1. 根据飞行层排号
* 2. 找到后从队列中移除并生成怪物
*/
private updateSpecialQueue(dt: number) {
if (this.MonQueue.length <= 0) return;
this.queueTimer += dt;
if (this.queueTimer < 0.15) return;
const item = this.MonQueue[0];
const isBoss = MonList[MonType.MeleeBoss].includes(item.uuid) || MonList[MonType.LongBoss].includes(item.uuid);
const isLongBoss = MonList[MonType.LongBoss].includes(item.uuid);
const slotsPerMon = isBoss ? 3 : 1;
const item = this.MonQueue.shift()!;
this.queueTimer = 0;
// 查找空闲槽位
let slotIndex = -1;
if (slotsPerMon === 3) {
// 构造可用索引列表
let allowedIndices = [];
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS - 2; i++) {
allowedIndices.push(i);
}
// 远程 Boss 插队时优先尝试从后往前找
if (isLongBoss) {
allowedIndices.reverse();
}
for (const idx of allowedIndices) {
if (!this.slotOccupiedEids[idx] && !this.slotOccupiedEids[idx + 1] && !this.slotOccupiedEids[idx + 2]) {
slotIndex = idx;
break;
}
}
} else {
// 普通怪找第一个空闲格
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS; i++) {
if (!this.slotOccupiedEids[i]) {
slotIndex = i;
break;
}
}
}
const isBoss = MonList[MonType.MeleeBoss].includes(item.uuid) ||
MonList[MonType.LongBoss].includes(item.uuid) ||
(MonList[MonType.FlyBoss] && MonList[MonType.FlyBoss].includes(item.uuid));
if (slotIndex !== -1) {
this.MonQueue.shift();
this.queueTimer = 0;
const upType = this.getRandomUpType();
this.addMonsterBySlot(slotIndex, item.uuid, isBoss, upType, Math.max(1, Number(item.level ?? 1)), slotsPerMon);
}
const upType = this.getRandomUpType();
const lane = item.flyLane >= 0 && item.flyLane <= 2 ? item.flyLane : 0;
this.addMonsterAt(lane, this.laneIndices[lane], item.uuid, isBoss, upType, Math.max(1, Number(item.level ?? 1)));
this.laneIndices[lane]++;
}
// ======================== 波次管理 ========================
@@ -246,7 +212,7 @@ export class MissionMonCompComp extends CCComp {
let hasBoss = false;
const config = WaveSlotConfig[this.currentWave] || DefaultWaveSlot;
for (const slot of config) {
if (slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss) {
if (slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss || slot.type === MonType.FlyBoss) {
hasBoss = true;
}
}
@@ -311,30 +277,45 @@ export class MissionMonCompComp extends CCComp {
// ======================== 槽位管理 ========================
/**
* 重置槽位并生成本波所有怪物:
* 1. 读取波次配置WaveSlotConfig 或 DefaultWaveSlot
* 2. 将 Boss 和普通怪分类
* 3. Boss 优先分配到 0, 2, 4 号位(占 2 格)
* 4. 普通怪填充剩余空闲格
* 5. 立即实例化所有怪物。
* 重新分配并生成本波所有怪物:
* 1. 清理上一波残留怪物
* 2. 读取波次配置
* 3. 依据配置和 flyLane 属性,为每只怪物分配自增索引
* 4. 立即实例化所有怪物
*
* @param wave 当前波数
*/
private resetSlotSpawnData(wave: number = 1) {
// 1. 清理上一波残留怪物
ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => {
const attrs = e.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) {
e.destroy();
}
});
// 2. 读取波次配置
const config: IWaveSlot[] = WaveSlotConfig[wave] || DefaultWaveSlot;
const oldOccupiedEids = [...this.slotOccupiedEids];
this.slotOccupiedEids = Array(MissionMonCompComp.MAX_SLOTS).fill(null);
// 3. 重置排号索引
this.laneIndices = [0, 0, 0];
let allMons: any[] = [];
// 解析配置
for (const slot of config) {
const isBoss = slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss;
const slotsPerMon = slot.slotsPerMon || (isBoss ? 3 : 1);
const isBoss = slot.type === MonType.MeleeBoss || slot.type === MonType.LongBoss || slot.type === MonType.FlyBoss;
// 判断分配到的飞行层
let lane: number = slot.flyLane !== undefined ? slot.flyLane : 0;
if (slot.type === MonType.Fly || slot.type === MonType.FlyBoss) {
lane = slot.flyLane !== undefined ? slot.flyLane : 1; // 飞行怪默认在第一层
}
lane = Math.max(0, Math.min(2, lane)); // 约束在 0,1,2
for (let i = 0; i < slot.count; i++) {
const uuid = this.getRandomUuidByType(slot.type);
const upType = this.getRandomUpType();
const req = { uuid, isBoss, upType, monLv: wave, slotsPerMon };
const req = { uuid, isBoss, upType, monLv: wave, lane };
allMons.push(req);
}
}
@@ -342,166 +323,48 @@ export class MissionMonCompComp extends CCComp {
this.waveTargetCount = allMons.length;
this.waveSpawnedCount = 0;
let assignedSlots = new Array(MissionMonCompComp.MAX_SLOTS).fill(null);
// 统一按顺序分配(根据所需格数找连续空位)
for (const mon of allMons) {
let placed = false;
if (mon.slotsPerMon === 3) {
// 需要 3 格,占满连续的 3 格
for (let idx = 0; idx < MissionMonCompComp.MAX_SLOTS - 2; idx++) {
if (!assignedSlots[idx] && !assignedSlots[idx + 1] && !assignedSlots[idx + 2]) {
assignedSlots[idx] = mon;
assignedSlots[idx + 1] = "occupied"; // 占位标记
assignedSlots[idx + 2] = "occupied"; // 占位标记
placed = true;
break;
}
}
} else {
// 只需要 1 格
for (let idx = 0; idx < MissionMonCompComp.MAX_SLOTS; idx++) {
if (!assignedSlots[idx]) {
assignedSlots[idx] = mon;
placed = true;
break;
}
}
}
if (!placed) {
mLogger.log(this.debugMode, 'MissionMonComp', "[MissionMonComp] No slot for monster! uuid:", mon.uuid);
}
}
let absorbedEids = new Set<number>();
// 立即生成本波所有怪物
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS; i++) {
const req = assignedSlots[i];
if (req && req !== "occupied") {
let inheritedHp = 0;
let inheritedAp = 0;
for (let j = 0; j < req.slotsPerMon; j++) {
let oldEid = oldOccupiedEids[i + j];
if (oldEid && !absorbedEids.has(oldEid)) {
absorbedEids.add(oldEid);
const entity = ecs.getEntityByEid(oldEid);
if (entity) {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.hp > 0 && !attrs.is_dead) {
inheritedHp += attrs.hp;
inheritedAp += Math.floor(attrs.ap / 2);
}
entity.destroy();
}
}
oldOccupiedEids[i + j] = null;
}
this.addMonsterBySlot(i, req.uuid, req.isBoss, req.upType, req.monLv, req.slotsPerMon, inheritedHp, inheritedAp);
}
}
// 清理被覆盖但没有被新怪占用槽位的旧怪(或者保留它们)
// 按照需求,保留未被覆盖的旧怪物
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS; i++) {
if (oldOccupiedEids[i]) {
const entity = ecs.getEntityByEid(oldOccupiedEids[i]!);
if (entity) {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.hp > 0 && !attrs.is_dead) {
this.slotOccupiedEids[i] = oldOccupiedEids[i];
} else {
entity.destroy();
}
}
}
}
}
/** 检查是否有任何槽位仍被活着的怪物占用 */
private hasActiveSlotMonster() {
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS; i++) {
if (this.slotOccupiedEids[i]) return true;
}
return false;
}
/**
* 刷新所有槽位的占用状态:
* 检查每个占用的 ECS 实体是否仍存在且 HP > 0
* 已失效的清除占用标记。
*/
private refreshSlotOccupancy() {
for (let i = 0; i < MissionMonCompComp.MAX_SLOTS; i++) {
const eid = this.slotOccupiedEids[i];
if (!eid) continue;
const entity = ecs.getEntityByEid(eid);
if (!entity) {
this.slotOccupiedEids[i] = null;
continue;
}
const attrs = entity.get(HeroAttrsComp);
if (!attrs || attrs.hp <= 0) {
this.slotOccupiedEids[i] = null;
}
// 4. 立即生成本波所有怪物
for (const req of allMons) {
this.addMonsterAt(req.lane, this.laneIndices[req.lane], req.uuid, req.isBoss, req.upType, req.monLv);
this.laneIndices[req.lane]++;
}
}
// ======================== 怪物生成 ========================
/**
* 在指定槽位生成一个怪物:
* 1. 计算出生坐标(多格时居中)。
* 2. 创建 Monster ECS 实体。
* 3. 标记槽位占用。
* 4. 设置渲染排序Boss 优先级更高)。
* 5. 根据阶段和成长类型计算最终 AP / HP。
* 在指定层级、指定索引处生成一个怪物:
*
* @param slotIndex 槽位索引0-5
* @param laneIndex 飞行层索引 (0, 1, 2)
* @param monIndex 该层级的第几个怪 (0, 1, 2...)
* @param uuid 怪物 UUID
* @param isBoss 是否为 Boss
* @param upType 属性成长类型
* @param monLv 怪物等级
* @param slotsPerMon 占用格数
*/
private addMonsterBySlot(
slotIndex: number,
private addMonsterAt(
laneIndex: number,
monIndex: number,
uuid: number = 1001,
isBoss: boolean = false,
upType: UpType = UpType.AP1_HP1,
monLv: number = 1,
slotsPerMon: number = 1,
inheritedHp: number = 0,
inheritedAp: number = 0,
monLv: number = 1
) {
let mon = ecs.getEntity<Monster>(Monster);
let scale = -1;
// 多格占用时居中出生点
const centerXOffset = (slotsPerMon - 1) * MissionMonCompComp.MON_SLOT_X_INTERVAL / 2;
const spawnX = MissionMonCompComp.MON_SLOT_START_X + slotIndex * MissionMonCompComp.MON_SLOT_X_INTERVAL + centerXOffset;
const landingY = BoxSet.GAME_LINE + (isBoss ? 6 : 0);
// 计算坐标
const spawnX = MissionMonCompComp.MON_SPAWN_START_X + monIndex * MissionMonCompComp.MON_SPAWN_GAP_X;
const landingY = BoxSet.GAME_LINE + MissionMonCompComp.FLY_LANE_Y_OFFSETS[laneIndex] + (isBoss ? 6 : 0);
const spawnPos: Vec3 = v3(spawnX, landingY + MissionMonCompComp.MON_DROP_HEIGHT, 0);
this.globalSpawnOrder = (this.globalSpawnOrder + 1) % 999;
mon.load(spawnPos, scale, uuid, isBoss, landingY, monLv);
mon.load(spawnPos, scale, uuid, isBoss, landingY, monLv, laneIndex);
// 标记槽位占用
for (let j = 0; j < slotsPerMon; j++) {
if (slotIndex + j < MissionMonCompComp.MAX_SLOTS) {
this.slotOccupiedEids[slotIndex + j] = mon.eid;
}
}
// 设置渲染排序
const move = mon.get(MoveComp);
const move = mon.get(MonMoveComp);
if (move) {
move.spawnOrder = isBoss
? MissionMonCompComp.BOSS_RENDER_PRIORITY + this.globalSpawnOrder
: this.globalSpawnOrder;
move.spawnOrder = this.globalSpawnOrder;
}
// 计算最终属性
@@ -511,8 +374,8 @@ export class MissionMonCompComp extends CCComp {
const stage = this.getCurrentStage();
const grow = this.resolveGrowPair(upType, isBoss);
const bias = Math.max(0.1, this.getSpawnPowerBias());
model.ap = Math.max(1, Math.floor((base.ap + stage * grow[0]) * bias)) + inheritedAp;
model.hp_max = Math.max(1, Math.floor((base.hp + stage * grow[1]) * bias)) + inheritedHp;
model.ap = Math.max(1, Math.floor((base.ap + stage * grow[0]) * bias));
model.hp_max = Math.max(1, Math.floor((base.hp + stage * grow[1]) * bias));
model.hp = model.hp_max;
}

View File

@@ -68,6 +68,10 @@ export const MonType = {
MeleeBoss: 3,
/** 远程 Boss */
LongBoss: 4,
/** 飞行普通怪 */
Fly: 5,
/** 飞行 Boss */
FlyBoss: 6,
}
// ======================== 怪物 UUID 池 ========================
@@ -79,6 +83,8 @@ export const MonList = {
[MonType.Support]: [6005], // 辅助怪池
[MonType.MeleeBoss]:[6006,6105], // 近战 Boss 池
[MonType.LongBoss]:[6104], // 远程 Boss 池
[MonType.Fly]: [6004, 6005], // 飞行怪池 (占位)
[MonType.FlyBoss]: [6104], // 飞行 Boss 池 (占位)
}
// ======================== 全局刷怪强度系数 ========================
@@ -98,23 +104,24 @@ export interface IWaveSlot {
type: number;
/** 该类型的怪物数量 */
count: number;
/** (可选)每个怪物占用几个槽位,默认 1大型 Boss 设为 2 */
/** (可选)每个怪物占用几个槽位,默认 1大型 Boss 设为 2 (新方案中主要用作标识Boss) */
slotsPerMon?: number;
/** 可选飞行层0=地面, 1=第一层空中(+120), 2=第二层空中(+240)。默认地面为 0Fly/FlyBoss 类型默认 1 */
flyLane?: 0 | 1 | 2;
}
// =========================================================================================
// 【每波怪物占位与刷怪配置说明】
//
// 字段说明:
// - type: 怪物类型 (参考 MonType如近战 0远程 1Boss 3 等)。
// - type: 怪物类型 (参考 MonType如近战 0远程 1Boss 3,飞行 5 等)。
// - count: 该类型的怪在场上同时存在几个。
// - slotsPerMon: (可选) 单个怪物体积占用几个占位坑,默认为 1
// 大型 Boss 默认设为 3它会跨占位降落
// - slotsPerMon: (可选) Boss 占位,旧方案占用多格,现作为标识
// - flyLane: (可选) 飞行层0=地面1=空中1层2=空中2层
//
// 【规则约束】:
// - 全场固定 5 个槽位(索引 0-4
// - Boss 默认占用 3 个位置,只要有连续 3 格即可
// - 每波怪物总槽位占用不能超过 5。
// - 突破 5 槽限制,怪物按刷怪顺序依次从 X=280 开始向右(每隔 50排布
// - Boss 和普通怪占据一个相同的 X 锚点,但视觉体积更大
// =========================================================================================
/** 各波次的怪物占位配置key = 波次编号) */