From e7075004fe198d12099e8853a52562546225960b Mon Sep 17 00:00:00 2001 From: walkpan Date: Tue, 12 May 2026 12:23:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(monster&spawn):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=A3=9E=E8=A1=8C=E6=80=AA=E7=89=A9=E6=94=AF=E6=8C=81=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=80=AA=E7=89=A9=E7=A7=BB=E5=8A=A8=E4=B8=8E?= =?UTF-8?q?=E5=88=B7=E6=80=AA=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽离MonMoveComp拆分怪物移动逻辑,让MoveComp仅负责英雄移动 新增Fly和FlyBoss怪物类型,配置三层飞行轨道支持空中怪物 重写波次刷怪逻辑,移除固定5槽限制,按轨道自由排布怪物 将怪物生成上限与恢复阈值从5/3调整为50/30 优化渲染排序逻辑,为飞行怪添加持续浮动动画 移除跨波怪物属性继承,波次切换时自动清理残留怪物 --- assets/script/game/hero/Mon.ts | 21 +- assets/script/game/hero/MonMoveComp.ts | 229 +++++++++++++++ assets/script/game/hero/MonMoveComp.ts.meta | 9 + assets/script/game/hero/MoveComp.ts | 63 ++-- assets/script/game/map/MissionComp.ts | 4 +- assets/script/game/map/MissionMonComp.ts | 307 ++++++-------------- assets/script/game/map/RogueConfig.ts | 21 +- 7 files changed, 390 insertions(+), 264 deletions(-) create mode 100644 assets/script/game/hero/MonMoveComp.ts create mode 100644 assets/script/game/hero/MonMoveComp.ts.meta diff --git a/assets/script/game/hero/Mon.ts b/assets/script/game/hero/Mon.ts index d9aa2876..0451cd62 100644 --- a/assets/script/game/hero/Mon.ts +++ b/assets/script/game/hero/Mon.ts @@ -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( - 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) { diff --git a/assets/script/game/hero/MonMoveComp.ts b/assets/script/game/hero/MonMoveComp.ts new file mode 100644 index 00000000..4f5dcf20 --- /dev/null +++ b/assets/script/game/hero/MonMoveComp.ts @@ -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; + } +} \ No newline at end of file diff --git a/assets/script/game/hero/MonMoveComp.ts.meta b/assets/script/game/hero/MonMoveComp.ts.meta new file mode 100644 index 00000000..bc9a1549 --- /dev/null +++ b/assets/script/game/hero/MonMoveComp.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "a3113d69-645e-4a4b-bf68-a434fcbd6d8d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/script/game/hero/MoveComp.ts b/assets/script/game/hero/MoveComp.ts index 22a455a3..b5a9615c 100644 --- a/assets/script/game/hero/MoveComp.ts +++ b/assets/script/game/hero/MoveComp.ts @@ -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; }); diff --git a/assets/script/game/map/MissionComp.ts b/assets/script/game/map/MissionComp.ts index 855cc23a..a9f911a7 100644 --- a/assets/script/game/map/MissionComp.ts +++ b/assets/script/game/map/MissionComp.ts @@ -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) */ diff --git a/assets/script/game/map/MissionMonComp.ts b/assets/script/game/map/MissionMonComp.ts index 20169d1f..0b40e969 100644 --- a/assets/script/game/map/MissionMonComp.ts +++ b/assets/script/game/map/MissionMonComp.ts @@ -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 —— 怪物类型、成长值、波次配置 * - Monster(hero/Mon.ts)—— 怪物 ECS 实体类 * - HeroInfo(heroSet)—— 怪物基础属性配置(与英雄共用配置) - * - 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 实体 ID,null 表示空闲 */ - private slotOccupiedEids: Array = 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(); - - // 立即生成本波所有怪物 - 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); 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; } diff --git a/assets/script/game/map/RogueConfig.ts b/assets/script/game/map/RogueConfig.ts index 2bd1ead2..834b85dc 100644 --- a/assets/script/game/map/RogueConfig.ts +++ b/assets/script/game/map/RogueConfig.ts @@ -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)。默认地面为 0,Fly/FlyBoss 类型默认 1 */ + flyLane?: 0 | 1 | 2; } // ========================================================================================= // 【每波怪物占位与刷怪配置说明】 // // 字段说明: -// - type: 怪物类型 (参考 MonType,如近战 0,远程 1,Boss 3 等)。 +// - type: 怪物类型 (参考 MonType,如近战 0,远程 1,Boss 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 = 波次编号) */