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

@@ -7,6 +7,7 @@ import { HeroInfo } from "../common/config/heroSet";
import { HeroAttrsComp } from "./HeroAttrsComp";
import { HeroViewComp } from "./HeroViewComp";
import { MoveComp } from "./MoveComp";
import { MonMoveComp } from "./MonMoveComp";
import { GameEvent } from "../common/config/GameEvent";
/** 怪物实体:负责怪物对象池复用、属性初始化、入场动画与回收 */
@ecs.register(`Monster`)
@@ -16,7 +17,7 @@ export class Monster extends ecs.Entity {
/** 怪物表现组件引用 */
HeroView!: HeroViewComp;
/** 怪物移动组件引用 */
MonMove!: MoveComp;
MonMove!: MonMoveComp;
/** 调试开关,控制生命周期日志输出 */
private debugMode: boolean = false;
@@ -95,7 +96,7 @@ export class Monster extends ecs.Entity {
/** 注册实体必需组件:移动 + 属性 */
protected init() {
this.addComponents<ecs.Comp>(
MoveComp,
MonMoveComp,
HeroAttrsComp,
);
}
@@ -122,7 +123,7 @@ export class Monster extends ecs.Entity {
* 2) 初始化表现、属性、技能与阵营
* 3) 播放下落入场并在落地后启用碰撞与移动
*/
load(pos: Vec3 = Vec3.ZERO,scale:number = 1,uuid:number=1001, is_boss:boolean=false, dropToY:number = pos.y,mon_lv:number=1) {
load(pos: Vec3 = Vec3.ZERO,scale:number = 1,uuid:number=1001, is_boss:boolean=false, dropToY:number = pos.y,mon_lv:number=1, flyLaneIndex:number = 0) {
// 怪物默认朝左,表现缩放固定为负向
scale=-1
// 当前怪物尺寸固定,保留变量便于后续扩展
@@ -200,7 +201,7 @@ export class Monster extends ecs.Entity {
// 广播怪物加载事件,供刷怪与战斗系统联动
oops.message.dispatchEvent("monster_load",this)
// 初始化移动参数:方向、目标 X、站位基准 Y
const move = this.get(MoveComp);
const move = this.get(MonMoveComp);
move.reset();
move.direction = -1;
move.targetX = pos.x;
@@ -219,7 +220,19 @@ export class Monster extends ecs.Entity {
if (!node || !node.isValid) return;
// 落地后锁定最终位置,切换到落地完成状态
node.setPosition(pos.x, dropToY, 0);
// 如果是飞行怪,可以保持空中状态,这里依然调用 down
view.playEnd("down");
// 飞行怪加一个轻微的上下浮动动效
if (flyLaneIndex > 0) {
tween(node)
.by(1.5, { position: v3(0, 15, 0) }, { easing: "sineOut" })
.by(1.5, { position: v3(0, -15, 0) }, { easing: "sineIn" })
.union()
.repeatForever()
.start();
}
move.moving = true;
// 落地后启用怪物碰撞分组
if (collider) {

View File

@@ -0,0 +1,229 @@
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { HeroViewComp } from "./HeroViewComp";
import { HeroAttrsComp } from "./HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { FacSet } from "../common/config/GameSet";
import { HeroDisVal, HType } from "../common/config/heroSet";
import { Node } from "cc";
@ecs.register('MonMoveComp')
export class MonMoveComp extends ecs.Comp {
/** 朝向1=向右,-1=向左 */
direction: number = -1;
/** 当前移动目标 X */
targetX: number = 0;
/** 是否允许移动(出生落地前会短暂关闭) */
moving: boolean = true;
/** 站位基准 Y */
baseY: number = 0;
/** 出生序,用于同条件渲染排序稳定 */
spawnOrder: number = 0;
reset() {
this.direction = -1;
this.targetX = 0;
this.moving = true;
this.baseY = 0;
this.spawnOrder = 0;
}
}
@ecs.register('MonMoveSystem')
export class MonMoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
/** 近战判定射程 */
private readonly meleeAttackRange = 250;
/** 远程判定射程 */
private readonly longAttackRange = 600;
/** 渲染层级重排节流,避免每帧排序 */
private readonly renderSortInterval = 0.05;
private lastRenderSortAt = 0;
private monMoveMatcher: ecs.IMatcher | null = null;
private heroViewMatcher: ecs.IMatcher | null = null;
private readonly renderEntries: { node: Node; bossPriority: number; frontScore: number; spawnOrder: number; eid: number }[] = [];
private renderEntryCount = 0;
private getMonMoveMatcher(): ecs.IMatcher {
if (!this.monMoveMatcher) {
this.monMoveMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp, MonMoveComp);
}
return this.monMoveMatcher;
}
private getHeroViewMatcher(): ecs.IMatcher {
if (!this.heroViewMatcher) {
this.heroViewMatcher = ecs.allOf(HeroAttrsComp, HeroViewComp);
}
return this.heroViewMatcher;
}
filter(): ecs.IMatcher {
return ecs.allOf(MonMoveComp, HeroViewComp, HeroAttrsComp);
}
update(e: ecs.Entity) {
/** 战斗未开始/暂停时不驱动移动 */
if (!smc.mission.play || smc.mission.pause) return;
const model = e.get(HeroAttrsComp);
const move = e.get(MonMoveComp);
const view = e.get(HeroViewComp);
if (!model || !move || !view || !view.node) return;
if (model.fac !== FacSet.MON) return;
if (!move.moving) return;
/** 关卡阶段性冻结怪物行为 */
if (smc.mission.stop_mon_action) {
this.clearCombatTarget(model);
view.status_change("idle");
return;
}
if (model.is_stop || model.is_dead || model.is_reviving || model.isFrost()) {
this.clearCombatTarget(model);
if (!model.is_reviving) view.status_change("idle");
return;
}
/** 所有移动都锁定在 baseY避免出现“漂移” */
if (view.node.position.y !== move.baseY) {
view.node.setPosition(view.node.position.x, move.baseY, 0);
}
// 渲染层级统交由 MoveSystem 统一处理,避免两个 System 争抢 setSiblingIndex
// 仅在战斗中才处理索敌和移动
if (!smc.mission.in_fight) return;
const nearestEnemy = this.findNearestEnemy(e);
if (nearestEnemy) {
/** 有敌人:进入战斗位移逻辑 */
this.processCombatLogic(e, move, view, model, nearestEnemy);
this.syncCombatTarget(model, view, nearestEnemy);
} else {
/** 无敌人:继续向左推进 */
this.clearCombatTarget(model);
model.is_atking = false;
this.moveEntity(view, -1, model.speed / 3);
}
}
private clearCombatTarget(model: HeroAttrsComp): void {
model.combat_target_eid = -1;
model.enemy_in_cast_range = false;
}
private syncCombatTarget(model: HeroAttrsComp, selfView: HeroViewComp, enemyView: HeroViewComp): void {
if (!enemyView || !enemyView.node || !enemyView.ent) {
this.clearCombatTarget(model);
return;
}
const enemyAttrs = enemyView.ent.get(HeroAttrsComp);
if (!enemyAttrs || enemyAttrs.is_dead || enemyAttrs.is_reviving || enemyAttrs.fac === model.fac) {
this.clearCombatTarget(model);
return;
}
model.combat_target_eid = enemyView.ent.eid;
model.enemy_in_cast_range = this.isEnemyInAttackRange(model, selfView.node.position.x, enemyView.node.position.x);
}
private isEnemyInAttackRange(model: HeroAttrsComp, selfX: number, enemyX: number): boolean {
const dist = Math.abs(selfX - enemyX);
const rangeType = model.type as HType.Melee | HType.Mid | HType.Long;
if (rangeType === HType.Melee) return dist <= this.meleeAttackRange;
return dist <= this.longAttackRange;
}
private processCombatLogic(e: ecs.Entity, move: MonMoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
const selfX = view.node.position.x;
const enemyX = enemy.node.position.x;
const dist = Math.abs(selfX - enemyX);
const inRange = this.isEnemyInAttackRange(model, selfX, enemyX);
// 接触判定距离,只有接触英雄才停止移动
// 飞行怪忽略接触碰撞,不被阻挡
const isFly = move.baseY > 50;
const touchDistance = 50;
const isTouching = !isFly && (dist <= touchDistance);
// 攻击判定
if (inRange) {
model.is_atking = true;
} else {
model.is_atking = false;
}
// 移动判定:只有接触了英雄才停止移动,否则继续向英雄方向移动
if (isTouching) {
view.status_change("idle");
} else {
const dir = enemyX > selfX ? 1 : -1;
this.moveEntity(view, dir, model.speed / 3);
}
}
private moveEntity(view: HeroViewComp, direction: number, speed: number) {
const model = view.ent.get(HeroAttrsComp);
const move = view.ent.get(MonMoveComp);
if (!model || !move) return;
// 简化的边界限制(怪物主要往左走,英雄防线在左侧,-999999 代表左侧尽头)
const moveMinX = -999999;
const moveMaxX = 999999;
const currentX = view.node.position.x;
const delta = speed * this.dt * direction;
let newX = currentX + delta;
newX = Math.max(moveMinX, Math.min(moveMaxX, newX));
if (Math.abs(newX - currentX) < 0.01) {
view.status_change("idle");
return;
}
view.node.setPosition(newX, move.baseY, 0);
move.direction = direction;
// 确保怪物的朝向表现,向左走 scale=-1向右走 scale=1
if (direction < 0) {
view.scale = -1;
} else if (direction > 0) {
view.scale = 1;
}
view.status_change("move");
}
private resolveCombatRange(model: HeroAttrsComp, defaultMin: number, defaultMax: number): [number, number] {
const minRange = model.getCachedMinSkillDistance();
const maxRange = model.getCachedMaxSkillDistance();
if (maxRange <= 0) return [defaultMin, defaultMax];
const safeMin = Math.max(0, Math.min(minRange, maxRange - 20));
return [safeMin, maxRange];
}
private findNearestEnemy(entity: ecs.Entity): HeroViewComp | null {
const currentView = entity.get(HeroViewComp);
if (!currentView?.node) return null;
const currentPos = currentView.node.position;
const myFac = entity.get(HeroAttrsComp).fac;
let nearest: HeroViewComp | null = null;
let minDis = Infinity;
/** 一次遍历筛出最近敌人(仅比较 x 轴距离,忽略 Y飞行和地面都能互相攻击 */
ecs.query(this.getHeroViewMatcher()).forEach(e => {
const m = e.get(HeroAttrsComp);
if (m.fac !== myFac && !m.is_dead) {
const v = e.get(HeroViewComp);
if (v?.node) {
const d = Math.abs(currentPos.x - v.node.position.x);
if (d < minDis) {
minDis = d;
nearest = v;
}
}
}
});
return nearest;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a3113d69-645e-4a4b-bf68-a434fcbd6d8d",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -5,6 +5,7 @@ import { smc } from "../common/SingletonModuleComp";
import { FacSet } from "../common/config/GameSet";
import { HeroDisVal, HType } from "../common/config/heroSet";
import { BoxCollider2D, Node } from "cc";
import { MonMoveComp } from "./MonMoveComp";
@ecs.register('MoveComp')
export class MoveComp extends ecs.Comp {
@@ -47,8 +48,10 @@ interface MoveFacConfig {
@ecs.register('MoveSystem')
export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate {
/** 近战判定射程(来自 heroSet */
private readonly meleeAttackRange = HeroDisVal[HType.Melee];
/** 近战判定射程 */
private readonly meleeAttackRange = 250;
/** 远程判定射程 */
private readonly longAttackRange = 600;
private readonly heroFrontAnchorX = -100;
private readonly monFrontAnchorX = 0;
/** 常规同阵营横向最小间距(英雄) */
@@ -106,21 +109,13 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
/** 战斗未开始/暂停时不驱动移动 */
if (!smc.mission.play || smc.mission.pause) return;
/** 在战斗阶段,英雄和怪物不移动 */
if (smc.mission.in_fight) return;
const model = e.get(HeroAttrsComp);
const move = e.get(MoveComp);
const view = e.get(HeroViewComp);
if (!model || !move || !view || !view.node) return;
if (model.fac !== FacSet.HERO && model.fac !== FacSet.MON) return;
if (model.fac !== FacSet.HERO) return; // 只处理英雄移动
if (!move.moving) return;
/** 关卡阶段性冻结怪物行为 */
if (model.fac === FacSet.MON && smc.mission.stop_mon_action) {
this.clearCombatTarget(model);
view.status_change("idle");
return;
}
if (model.is_stop || model.is_dead || model.is_reviving || model.isFrost()) {
this.clearCombatTarget(model);
if (!model.is_reviving) view.status_change("idle");
@@ -131,7 +126,10 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
if (view.node.position.y !== move.baseY) {
view.node.setPosition(view.node.position.x, move.baseY, 0);
}
this.updateRenderOrder(view);
// 渲染层级重排放在独立的系统或这里统筹,这里我们让它处理所有英雄和怪物的排序
this.updateRenderOrder();
const nearestEnemy = this.findNearestEnemy(e);
if (nearestEnemy) {
/** 有敌人:进入战斗位移逻辑 */
@@ -169,12 +167,7 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
const dist = Math.abs(selfX - enemyX);
const rangeType = model.type as HType.Melee | HType.Mid | HType.Long;
if (rangeType === HType.Melee) return dist <= this.meleeAttackRange;
if (rangeType === HType.Long) {
const [, maxRange] = this.resolveCombatRange(model, 360, 720);
return dist <= maxRange;
}
const [, maxRange] = this.resolveCombatRange(model, 120, 720);
return dist <= maxRange;
return dist <= this.longAttackRange;
}
private processCombatLogic(e: ecs.Entity, move: MoveComp, view: HeroViewComp, model: HeroAttrsComp, enemy: HeroViewComp) {
@@ -366,30 +359,42 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
return nearest;
}
private updateRenderOrder(view: HeroViewComp) {
private updateRenderOrder() {
const scene = smc.map?.MapView?.scene;
const actorRoot = scene?.entityLayer?.node?.getChildByName("HERO");
if (!actorRoot) return;
if (view.node.parent !== actorRoot) {
view.node.parent = actorRoot;
}
const now = Date.now() / 1000;
if (now - this.lastRenderSortAt < this.renderSortInterval) return;
this.lastRenderSortAt = now;
this.renderEntryCount = 0;
ecs.query(this.getHeroMoveMatcher()).forEach(e => {
ecs.query(this.getHeroViewMatcher()).forEach(e => {
const attrs = e.get(HeroAttrsComp);
const actorView = e.get(HeroViewComp);
const actorMove = e.get(MoveComp);
if (!attrs || !actorView?.node || !actorMove || attrs.is_dead) return;
if (!attrs || !actorView?.node || attrs.is_dead) return;
if (attrs.fac !== FacSet.HERO && attrs.fac !== FacSet.MON) return;
if (actorView.node.parent !== actorRoot) {
actorView.node.parent = actorRoot;
}
const frontScore = attrs.fac === FacSet.HERO ? actorView.node.position.x : -actorView.node.position.x;
// 获取 spawnOrder由于可能挂载 MoveComp 或 MonMoveComp我们需要动态获取
let spawnOrder = 0;
const heroMove = e.get(MoveComp);
if (heroMove) {
spawnOrder = heroMove.spawnOrder;
} else {
const monMove = e.get(MonMoveComp);
if (monMove) {
spawnOrder = monMove.spawnOrder;
}
}
const isFly = actorView.node.position.y > 50;
// 飞行怪在最前,其次是地面的 X 坐标
const frontScore = isFly ? 999999 - actorView.node.position.x : (attrs.fac === FacSet.HERO ? actorView.node.position.x : -actorView.node.position.x);
const entryIndex = this.renderEntryCount;
let entry = this.renderEntries[entryIndex];
if (!entry) {
@@ -399,7 +404,7 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
entry.node = actorView.node;
entry.bossPriority = attrs.is_boss ? 1 : 0;
entry.frontScore = frontScore;
entry.spawnOrder = actorMove.spawnOrder;
entry.spawnOrder = spawnOrder;
entry.eid = e.eid;
this.renderEntryCount += 1;
});

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;
// 查找空闲槽位
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;
}
}
}
if (slotIndex !== -1) {
this.MonQueue.shift();
const item = this.MonQueue.shift()!;
this.queueTimer = 0;
const isBoss = MonList[MonType.MeleeBoss].includes(item.uuid) ||
MonList[MonType.LongBoss].includes(item.uuid) ||
(MonList[MonType.FlyBoss] && MonList[MonType.FlyBoss].includes(item.uuid));
const upType = this.getRandomUpType();
this.addMonsterBySlot(slotIndex, item.uuid, isBoss, upType, Math.max(1, Number(item.level ?? 1)), slotsPerMon);
}
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);
// 标记槽位占用
for (let j = 0; j < slotsPerMon; j++) {
if (slotIndex + j < MissionMonCompComp.MAX_SLOTS) {
this.slotOccupiedEids[slotIndex + j] = mon.eid;
}
}
mon.load(spawnPos, scale, uuid, isBoss, landingY, monLv, laneIndex);
// 设置渲染排序
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 = 波次编号) */