2 Commits

Author SHA1 Message Date
pan
a905db8e48 feat: 大幅优化战斗体验与编队系统
1. 扩展英雄/怪物网格站位到10个槽位
2. 重构默认攻击距离计算逻辑,按职业动态适配
3. 重做英雄站位与移动系统,新增同阵营间距约束
4. 调整rogue模式怪物数量与强度配置,提升战斗爽感
5. 重构刷怪逻辑为匀速曲线+欠账补刷,优化刷怪节奏
2026-08-12 17:56:59 +08:00
pan
3060ee7df7 refactor(hero): 重构英雄死亡与复活机制
1. 统一英雄真实死亡流程:死亡时快照数据到全局列表,动画结束后统一销毁实体
2. 移除回合自动复活逻辑,改为由复活系统主动读取快照重新召唤
3. 修复近战追敌可能脱离战场的问题,增加推进距离上限
4. 重构死亡计数与全员存活检测逻辑,基于死亡快照列表而非实体状态
2026-08-12 16:15:33 +08:00
10 changed files with 288 additions and 213 deletions

View File

@@ -38,6 +38,23 @@ export interface CloudData {
openid: string; openid: string;
data: GameDate; data: GameDate;
} }
/**
* 死亡英雄快照:英雄真实死亡时记录,供后续复活机制(重新召唤)消费。
* 仅记录静态/成长数据不含战斗内临时状态buff、CD 等)。
*/
export interface DeadHeroRecord {
/** 英雄模板 uuid决定重新召唤哪个英雄 */
uuid: number;
/** 死亡时的英雄等级(复活后继承) */
lv: number;
/** 卡牌等级(驱动 bg_node 颜色) */
card_lv: number;
/** 死亡时占用的站位索引(复活时优先归位,-1 表示未分配) */
posIndex: number;
/** 死亡时的回合数(用于统计/按回合清理) */
wave: number;
}
/** 游戏模块 */ /** 游戏模块 */
@ecs.register('SingletonModule') @ecs.register('SingletonModule')
export class SingletonModuleComp extends ecs.Comp { export class SingletonModuleComp extends ecs.Comp {
@@ -58,8 +75,10 @@ export class SingletonModuleComp extends ecs.Comp {
in_select: false, in_select: false,
in_fight: false, in_fight: false,
stop_mon_action: false, stop_mon_action: false,
heroGrid: [-1, -1, -1, -1, -1, -1], heroGrid: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
monGrid: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], monGrid: [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1],
/** 本局真实死亡的英雄快照列表,供后续复活机制消费;整局结束(MissionEnd)时清空 */
dead_heroes: [] as DeadHeroRecord[],
}; };
data: any = { data: any = {
openid: '', openid: '',

View File

@@ -7,7 +7,7 @@ import { HeroViewComp } from "./HeroViewComp";
import { BoxSet, FacSet, FightSet, IndexSet } from "../common/config/GameSet"; import { BoxSet, FacSet, FightSet, IndexSet } from "../common/config/GameSet";
import { HeroInfo, HeroPos, resolveFormationTargetX, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv, calcGrowBonus } from "../common/config/heroSet"; import { HeroInfo, HeroPos, resolveFormationTargetX, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv, calcGrowBonus } from "../common/config/heroSet";
import { GameEvent } from "../common/config/GameEvent"; import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet"; import { SkillTriggerType, HType } from "../common/config/heroSet";
import { SkillTriggerHelper } from "./SkillTriggerHelper"; import { SkillTriggerHelper } from "./SkillTriggerHelper";
import { Attrs } from "../common/config/HeroAttrs"; import { Attrs } from "../common/config/HeroAttrs";
import { MoveComp } from "./MoveComp"; import { MoveComp } from "./MoveComp";
@@ -122,7 +122,8 @@ export class Hero extends ecs.Entity {
model.grow_type = hero.grow_type; model.grow_type = hero.grow_type;
model.role = hero.role; model.role = hero.role;
model.fac = FacSet.HERO; model.fac = FacSet.HERO;
model.dis = hero.dis ?? 720; // 攻击距离配置优先未配置按攻击定位给默认值近战180 / 远程600
model.dis = hero.dis ?? (hero.type === HType.Melee ? 180 : 600);
model.posIndex = posIndex; model.posIndex = posIndex;
if (posIndex >= 0) { if (posIndex >= 0) {
smc.mission.heroGrid[posIndex] = this.eid; smc.mission.heroGrid[posIndex] = this.eid;

View File

@@ -220,10 +220,11 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
// 检查死亡 // 检查死亡
if (TAttrsComp.hp <= 0) { if (TAttrsComp.hp <= 0) {
// 先触发死亡技能(如亡语),不管后续是否复活都应触发 // 先触发死亡技能(如亡语),不管后续是否续命都应触发
this.triggerDeadSkills(target); this.triggerDeadSkills(target);
// 复活机制:如果玩家属性内的复活属性值>=1 则执行复活,原地50%血量复活 // 濒死回血revive 系):不是真正复活,而是血量归零瞬间消耗 1 次续命次数,
// 按比例回血继续战斗。次数每回合重置(见 MissionComp.healAllHeroes
let canRevive = false; let canRevive = false;
let maxReviveCount = 0; let maxReviveCount = 0;
let reviveHpPercent = 50; // 默认恢复50% let reviveHpPercent = 50; // 默认恢复50%
@@ -341,13 +342,13 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
* *
* 死亡处理流程: * 死亡处理流程:
* 1. 标记死亡状态is_dead = true * 1. 标记死亡状态is_dead = true
* 2. 触发死亡事件onDeath * 2. 英雄阵营:快照死亡状态到 smc.mission.dead_heroes供后续复活机制重新召唤
* 3. 记录调试信息(如启用调试模式 * 3. 触发死亡事件onDeath
* *
* @param entity 死亡的实体 * @param entity 死亡的实体
* *
* @important 死亡状态一旦设置,该实体将不再处理新的伤害事件 * @important 死亡状态一旦设置,该实体将不再处理新的伤害事件
* 这确保了死亡逻辑的单一性和一致性 * 英雄为真实死亡:视图动画结束后由 HeroViewComp.realDead 销毁实体
*/ */
private doDead(entity: ecs.Entity): void { private doDead(entity: ecs.Entity): void {
const TAttrsComp = entity.get(HeroAttrsComp); const TAttrsComp = entity.get(HeroAttrsComp);
@@ -355,6 +356,11 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
TAttrsComp.is_dead = true; TAttrsComp.is_dead = true;
// 英雄真实死亡:记录快照,供后续复活机制(重新召唤)消费
if (TAttrsComp.fac === FacSet.HERO) {
this.recordDeadHero(TAttrsComp);
}
// 触发死亡事件 // 触发死亡事件
this.onDeath(entity); this.onDeath(entity);
@@ -363,6 +369,24 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
} }
} }
/**
* 快照死亡英雄的静态/成长数据到 smc.mission.dead_heroes。
* 仅记录重新召唤所需字段不含战斗内临时状态buff、技能 CD 等)。
*
* @param attrs 死亡英雄的属性组件
*/
private recordDeadHero(attrs: HeroAttrsComp): void {
smc.mission.dead_heroes.push({
uuid: attrs.hero_uuid,
lv: attrs.lv,
card_lv: attrs.card_lv,
posIndex: attrs.posIndex,
wave: Math.max(1, smc.vmdata?.mission_data?.level ?? 1),
});
mLogger.log(this.debugMode, 'HeroAtkSystem',
` 死亡英雄已记录: ${attrs.hero_name}(uuid=${attrs.hero_uuid}) lv=${attrs.lv} pos=${attrs.posIndex} wave=${smc.vmdata?.mission_data?.level}, 当前共 ${smc.mission.dead_heroes.length}`);
}
/** /**

View File

@@ -516,17 +516,12 @@ export class HeroViewComp extends CCComp {
let isHero = this.model.fac === FacSet.HERO; let isHero = this.model.fac === FacSet.HERO;
let dirX = isHero ? -800 : 800; let dirX = isHero ? -800 : 800;
// 死亡往后飞出屏幕动画 // 死亡往后飞出屏幕动画,结束后统一销毁实体
// 英雄为真实死亡:快照已在 doDead 阶段写入 smc.mission.dead_heroes此处直接销毁
tween(this.node) tween(this.node)
.by(0.5, { position: v3(dirX, 700, 0) }, { easing: "quadOut" }) .by(0.5, { position: v3(dirX, 700, 0) }, { easing: "quadOut" })
.call(() => { .call(() => {
if (isHero) {
// 将英雄移到玩家看不到的墓地,留待下回合上场
this.node.setPosition(v3(-2000, -2000, 0));
} else {
// 动画结束后销毁怪物实体
this.ent.destroy(); this.ent.destroy();
}
}) })
.start(); .start();
} }

View File

@@ -9,7 +9,7 @@ import { HeroViewComp } from "./HeroViewComp";
import { MoveComp } from "./MoveComp"; import { MoveComp } from "./MoveComp";
import { MonMoveComp } from "./MonMoveComp"; import { MonMoveComp } from "./MonMoveComp";
import { GameEvent } from "../common/config/GameEvent"; import { GameEvent } from "../common/config/GameEvent";
import { SkillTriggerType } from "../common/config/heroSet"; import { SkillTriggerType, HType } from "../common/config/heroSet";
import { SkillTriggerHelper } from "./SkillTriggerHelper"; import { SkillTriggerHelper } from "./SkillTriggerHelper";
/** 怪物实体:负责怪物对象池复用、属性初始化、入场动画与回收 */ /** 怪物实体:负责怪物对象池复用、属性初始化、入场动画与回收 */
@ecs.register(`Monster`) @ecs.register(`Monster`)
@@ -175,7 +175,8 @@ export class Monster extends ecs.Entity {
model.speed = hero.speed ?? 800; model.speed = hero.speed ?? 800;
model.type = hero.type; model.type = hero.type;
model.fac = FacSet.MON; model.fac = FacSet.MON;
model.dis = hero.dis ?? 720; // 攻击距离配置优先未配置按攻击定位给默认值近战180 / 远程600
model.dis = hero.dis ?? (hero.type === HType.Melee ? 180 : 600);
model.posIndex = laneIndex; model.posIndex = laneIndex;
if (laneIndex >= 0) { if (laneIndex >= 0) {
smc.mission.monGrid[laneIndex] = this.eid; smc.mission.monGrid[laneIndex] = this.eid;

View File

@@ -223,7 +223,10 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
// 未贴近:向怪物方向推进(攻击范围内也继续移动),于接触距离处停下 // 未贴近:向怪物方向推进(攻击范围内也继续移动),于接触距离处停下
const dir = enemyX > selfX ? 1 : -1; const dir = enemyX > selfX ? 1 : -1;
move.direction = dir; move.direction = dir;
const stopX = enemyX - dir * MoveSystem.MELEE_ENGAGE_X; // 终止点取接触距离与推进上限的较小者,防止越过 150 继续追击
let stopX = Math.min(enemyX - dir * MoveSystem.MELEE_ENGAGE_X, MoveSystem.HERO_ADVANCE_LIMIT_X);
// 同阵营最小间距约束:防止近战推进时穿越前方队友
stopX = this.clampAllySpacing(e, view, move, model, stopX);
const speed = model.speed / 3; const speed = model.speed / 3;
this.moveEntity(view, dir, speed, stopX); this.moveEntity(view, dir, speed, stopX);
model.is_atking = false; model.is_atking = false;
@@ -290,6 +293,10 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
private static readonly FORMATION_STEP_X = 60; private static readonly FORMATION_STEP_X = 60;
/** 近战贴近怪物的固定接触距离:小于攻击距离 dis使英雄进攻击范围后仍继续贴近到此间距才停 */ /** 近战贴近怪物的固定接触距离:小于攻击距离 dis使英雄进攻击范围后仍继续贴近到此间距才停 */
private static readonly MELEE_ENGAGE_X = 60; private static readonly MELEE_ENGAGE_X = 60;
/** 英雄向敌人推进的最远终止点 X防止近战追敌时无限右移脱离战场 */
private static readonly HERO_ADVANCE_LIMIT_X = 100;
/** 同阵营英雄间最小间距:小于该值时低优先级一方需让位 */
private static readonly ALLY_MIN_GAP_X = 30;
/** /**
* 计算某个槽位的目标 X。 * 计算某个槽位的目标 X。
@@ -325,7 +332,57 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
const dir = targetX > currentX ? 1 : -1; const dir = targetX > currentX ? 1 : -1;
move.direction = dir; move.direction = dir;
const speed = model.speed / 3; const speed = model.speed / 3;
this.moveEntity(view, dir, speed, targetX); // 同阵营最小间距约束:防止编队挤压时穿越前方队友
const clampedTargetX = this.clampAllySpacing(view.ent, view, move, model, targetX);
this.moveEntity(view, dir, speed, clampedTargetX);
}
/**
* 同阵营横向最小间距约束。
* Why: 编队/追击时英雄可能挤到一起,低优先级一方需要保持在高优先级身后 ALLY_MIN_GAP_X 处,
* 避免视觉重叠与站位混乱。
* 优先级编队优先级高者在前同级时召唤早spawnOrder 小)者在前。
* @returns 裁剪后的目标 X保证不越过身前队友
*/
private clampAllySpacing(self: ecs.Entity, view: HeroViewComp, selfMove: MoveComp, model: HeroAttrsComp, targetX: number): number {
const selfX = view.node.position.x;
const selfY = view.node.position.y;
let clamped = targetX;
ecs.query(this.getHeroMoveMatcher()).forEach(e => {
if (e === self) return;
const attrs = e.get(HeroAttrsComp);
const v = e.get(HeroViewComp);
const mv = e.get(MoveComp);
if (!attrs || !v?.node || !mv) return;
if (attrs.is_dead || attrs.is_reviving) return;
if (attrs.fac !== model.fac) return;
// 只约束同排Y 差在阈值内)的队友
if (Math.abs(v.node.position.y - selfY) > this.minSpacingY) return;
const otherX = v.node.position.x;
// 判断对方是否在自己前方(以目标方向为前)
const isAhead = clamped > selfX ? otherX > selfX : otherX < selfX;
if (!isAhead) return;
// 判定谁该在前:编队优先级高者在前;同级时 spawnOrder 小者在前
const myPriority = this.getFormationPriority(model);
const otherPriority = this.getFormationPriority(attrs);
let selfIsFront = false;
if (myPriority !== otherPriority) {
selfIsFront = myPriority > otherPriority;
} else {
selfIsFront = selfMove.spawnOrder <= mv.spawnOrder;
}
if (!selfIsFront) {
// 自己靠后:不能越过 otherX - ALLY_MIN_GAP_X朝右或 otherX + ALLY_MIN_GAP_X朝左
const limitX = clamped > selfX ? otherX - MoveSystem.ALLY_MIN_GAP_X : otherX + MoveSystem.ALLY_MIN_GAP_X;
clamped = clamped > selfX ? Math.min(clamped, limitX) : Math.max(clamped, limitX);
}
});
return clamped;
} }
private moveEntity(view: HeroViewComp, direction: number, speed: number, stopAtX?: number) { private moveEntity(view: HeroViewComp, direction: number, speed: number, stopAtX?: number) {

View File

@@ -560,17 +560,15 @@ export class MissionComp extends CCComp {
let allAlive = true; let allAlive = true;
let hasHero = false; let hasHero = false;
let heroDeathCount = 0;
ecs.query(this.heroAttrsMatcher).forEach(entity => { ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp); const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.HERO) { if (attrs && attrs.fac === FacSet.HERO) {
hasHero = true; hasHero = true;
if (attrs.is_dead) {
allAlive = false;
heroDeathCount++;
}
} }
}); });
// 英雄为真实死亡(实体已销毁),本回合死亡数取死亡记录长度
const heroDeathCount = smc.mission.dead_heroes.length;
if (heroDeathCount > 0) allAlive = false;
// 【动态难度调节】根据本回合战况自动放水 / 加压 // 【动态难度调节】根据本回合战况自动放水 / 加压
// 清场加速节省的时间还原进口径,避免"打得好"被节奏加速+强度加压双重惩罚 // 清场加速节省的时间还原进口径,避免"打得好"被节奏加速+强度加压双重惩罚
@@ -978,15 +976,14 @@ export class MissionComp extends CCComp {
/** 检测场上英雄是否全员存活(清屏 Perfect 判定用,独立于评分统计逻辑) */ /** 检测场上英雄是否全员存活(清屏 Perfect 判定用,独立于评分统计逻辑) */
private checkAllHeroAlive(): boolean { private checkAllHeroAlive(): boolean {
let hasHero = false; let hasHero = false;
let allAlive = true;
ecs.query(this.heroAttrsMatcher).forEach(entity => { ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp); const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.HERO) { if (attrs && attrs.fac === FacSet.HERO) {
hasHero = true; hasHero = true;
if (attrs.is_dead) allAlive = false;
} }
}); });
return hasHero && allAlive; // 英雄为真实死亡(实体已销毁),有死亡记录即非全员存活
return hasHero && smc.mission.dead_heroes.length === 0;
} }
/** /**

View File

@@ -15,6 +15,11 @@
* 旧英雄移动合并销毁,新英雄以 **两者最高等级 + 1** 落地(封顶 HERO_MAX_LV * 旧英雄移动合并销毁,新英雄以 **两者最高等级 + 1** 落地(封顶 HERO_MAX_LV
* 等级已达上限时拒绝召唤。英雄也可通过升级卡SpecialUpgrade升级。 * 等级已达上限时拒绝召唤。英雄也可通过升级卡SpecialUpgrade升级。
* *
* 死亡机制:
* 英雄为真实死亡HeroAtkSystem.doDead → 快照至 smc.mission.dead_heroes → 实体销毁),
* 本组件不做回合自动复活;复活由后续复活机制读取 dead_heroes 重新召唤。
* revive 系技能为"濒死回血"(血量归零时消耗次数回血续命),不触发真死。
*
* 依赖: * 依赖:
* - Herohero/Hero.ts—— 英雄 ECS 实体类 * - Herohero/Hero.ts—— 英雄 ECS 实体类
* - HeroAttrsComp —— 英雄属性组件 * - HeroAttrsComp —— 英雄属性组件
@@ -48,14 +53,18 @@ const { ccclass } = _decorator;
export class MissionHeroComp extends CCComp { export class MissionHeroComp extends CCComp {
// ======================== 常量 ======================== // ======================== 常量 ========================
/** 硬编码的6个英雄占位点 */ /** 硬编码的英雄占位点(数量对齐 FightSet.HERO_MAX_NUM落点 X 统一,英雄落地后由 MoveComp 自行移位到阵型目标) */
public static readonly HERO_POSITIONS: Vec3[] = [ public static readonly HERO_POSITIONS: Vec3[] = [
v3(-220, BoxSet.GAME_LINE, 0), // index 0 (node_index 1): Top Front 第二 v3(-300, BoxSet.GAME_LINE, 0), // index 0
v3(-140, BoxSet.GAME_LINE, 0), // index 1 (node_index 2): Mid Front 第一 v3(-300, BoxSet.GAME_LINE, 0), // index 1
v3(-300, BoxSet.GAME_LINE, 0), // index 2 (node_index 3): Bot Front 第三 v3(-300, BoxSet.GAME_LINE, 0), // index 2
// v3(-280, BoxSet.GAME_LINE + 100, 0), // index 3 (node_index 4): Top Back v3(-300, BoxSet.GAME_LINE, 0), // index 3
// v3(-280, BoxSet.GAME_LINE, 0), // index 4 (node_index 5): Mid Back v3(-300, BoxSet.GAME_LINE, 0), // index 4
// v3(-280, BoxSet.GAME_LINE - 100, 0), // index 5 (node_index 6): Bot Back v3(-300, BoxSet.GAME_LINE, 0), // index 5
v3(-300, BoxSet.GAME_LINE, 0), // index 6
v3(-300, BoxSet.GAME_LINE, 0), // index 7
v3(-300, BoxSet.GAME_LINE, 0), // index 8
v3(-300, BoxSet.GAME_LINE, 0), // index 9
]; ];
/** 英雄出生时的掉落高度(从空中落到地面的像素差) */ /** 英雄出生时的掉落高度(从空中落到地面的像素差) */
@@ -102,34 +111,28 @@ export class MissionHeroComp extends CCComp {
// ======================== 事件处理 ======================== // ======================== 事件处理 ========================
/** 关卡结束时清理全部英雄 ECS 实体 */ /** 关卡结束时清理全部存活英雄 ECS 实体,并清空本局死亡英雄记录 */
clear_heros() { clear_heros() {
const heroes = this.getAllHeroes(); const heroes = this.getAllHeroes();
for (let i = 0; i < heroes.length; i++) { for (let i = 0; i < heroes.length; i++) {
heroes[i].destroy(); heroes[i].destroy();
} }
// 整局结束,死亡记录失效(复活机制仅限局内)
smc.mission.dead_heroes = [];
} }
/** 战斗准备阶段:重置出战英雄计数,恢复满血重新登场 */ /**
* 战斗准备阶段:刷新存活英雄计数与血条。
*
* 说明:英雄为真实死亡(实体已销毁,快照存于 smc.mission.dead_heroes
* 本阶段不再做回合自动复活;复活由后续复活机制(重新召唤)显式触发。
*/
fight_ready() { fight_ready() {
const heroes = this.getAllHeroes(); const heroes = this.getAllHeroes();
smc.vmdata.mission_data.hero_num = heroes.length; smc.vmdata.mission_data.hero_num = heroes.length;
for (let i = 0; i < heroes.length; i++) { for (let i = 0; i < heroes.length; i++) {
const hero = heroes[i]; const model = heroes[i].get(HeroAttrsComp);
const model = hero.get(HeroAttrsComp); if (model) {
const view = hero.get(HeroViewComp);
if (model && view) {
if (model.is_dead) {
view.alive();
const posIndex = this.pickPositionIndexForHero([hero.eid]);
const landingPos = MissionHeroComp.HERO_POSITIONS[posIndex];
// 计算出生点(空中)
const spawnPos: Vec3 = v3(landingPos.x, landingPos.y + MissionHeroComp.HERO_DROP_HEIGHT, 0);
view.node.setPosition(spawnPos);
model.posIndex = posIndex;
if (posIndex >= 0) smc.mission.heroGrid[posIndex] = hero.eid;
hero.playDropAnim(spawnPos, landingPos.y);
}
model.dirty_hp = true; model.dirty_hp = true;
} }
} }
@@ -212,16 +215,16 @@ export class MissionHeroComp extends CCComp {
if (m && m.posIndex >= 0) occupied.add(m.posIndex); if (m && m.posIndex >= 0) occupied.add(m.posIndex);
} }
// 优先中前(1) -> 上前(0) -> 下前(2) -> 中后(4) -> 上后(3) -> 下后(5) // 按索引顺序填充空位0..9),全部落点相同,英雄落地后自行移位
const slotPriority = [1, 0, 2, 4, 3, 5]; const slotPriority = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for (const idx of slotPriority) { for (const idx of slotPriority) {
if (!occupied.has(idx)) { if (!occupied.has(idx) && MissionHeroComp.HERO_POSITIONS[idx]) {
return idx; return idx;
} }
} }
// 溢出:默认中前 // 溢出兜底:复用 index 0坐标有效避免越界崩溃
return 1; return 0;
} }
/** /**
@@ -253,7 +256,8 @@ export class MissionHeroComp extends CCComp {
let hero = ecs.getEntity<Hero>(Hero); let hero = ecs.getEntity<Hero>(Hero);
let scale = 1 let scale = 1
const finalPosIndex = posIndex >= 0 ? posIndex : this.pickPositionIndexForHero(); const finalPosIndex = posIndex >= 0 ? posIndex : this.pickPositionIndexForHero();
const landingPos = MissionHeroComp.HERO_POSITIONS[finalPosIndex]; // 兜底:索引越界或点位缺失时复用 index 0保证 landingPos 一定有效
const landingPos = MissionHeroComp.HERO_POSITIONS[finalPosIndex] ?? MissionHeroComp.HERO_POSITIONS[0];
let spawnPos: Vec3 = v3(landingPos.x, landingPos.y + MissionHeroComp.HERO_DROP_HEIGHT, 0); let spawnPos: Vec3 = v3(landingPos.x, landingPos.y + MissionHeroComp.HERO_DROP_HEIGHT, 0);
hero.load(spawnPos, scale, uuid, landingPos.y, hero_lv, card_lv, finalPosIndex); hero.load(spawnPos, scale, uuid, landingPos.y, hero_lv, card_lv, finalPosIndex);

View File

@@ -5,10 +5,12 @@
* 职责: * 职责:
* 1. 管理每一回合怪物的生成计划:根据 RogueConfig 生成怪物。 * 1. 管理每一回合怪物的生成计划:根据 RogueConfig 生成怪物。
* 2. 自动推进回合在准备阶段结束时PhasePrepareEnd启动分批释放。 * 2. 自动推进回合在准备阶段结束时PhasePrepareEnd启动分批释放。
* 3. 分批刷怪:回合固定 3 批,每 10 秒释放一批,批内按 MON_SPAWN_INTERVAL 逐个刷出 * 3. 持续刷怪:回合开始整波进入队列30 秒内按速度曲线匀速刷出(铺垫 → 加压 → 高潮)
* *
* 关键设计: * 关键设计:
* - 所有怪物统一从右侧 X=400 出生点逐个刷出MON_SPAWN_INTERVAL 节奏控制)。 * - 所有怪物统一从右侧 X=400 出生点刷出,速度曲线由 m.batch 驱动(前慢后快,压轴批自动高潮)。
* - 每帧欠账式补刷(期望数 已刷数),自动补偿帧余量,保证 30 秒预算不流失。
* - 场上怪物超阈值stop_spawn_mon时暂停推进恢复后瞬时补齐欠账。
* - 实际阵型与推进由 MonMoveComp 在战斗中向左移动自然形成,不再使用固定网格点。 * - 实际阵型与推进由 MonMoveComp 在战斗中向左移动自然形成,不再使用固定网格点。
* - 槽位索引3行×4列仅用于 monGrid 寻路与 SCastSystem 索敌定位。 * - 槽位索引3行×4列仅用于 monGrid 寻路与 SCastSystem 索敌定位。
* - 上一回合残留怪在回合结束/开始时统一清理。 * - 上一回合残留怪在回合结束/开始时统一清理。
@@ -22,7 +24,7 @@ import { Monster } from "../hero/Mon";
import { smc } from "../common/SingletonModuleComp"; import { smc } from "../common/SingletonModuleComp";
import { GameEvent } from "../common/config/GameEvent"; import { GameEvent } from "../common/config/GameEvent";
import { BoxSet, FacSet } from "../common/config/GameSet"; import { BoxSet, FacSet } from "../common/config/GameSet";
import { spawningEngine, GeneratedMonster, TestModeConfig, MAX_MONSTERS, BATCH_COUNT, BATCH_INTERVAL, getWaveType, SPAWN_INTERVAL_BY_TYPE } from "./RogueConfig"; import { spawningEngine, GeneratedMonster, TestModeConfig, WAVE_DURATION } from "./RogueConfig";
import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { MonMoveComp } from "../hero/MonMoveComp"; import { MonMoveComp } from "../hero/MonMoveComp";
@@ -37,8 +39,6 @@ export class MissionMonCompComp extends CCComp {
private static readonly MON_DROP_HEIGHT = 0; private static readonly MON_DROP_HEIGHT = 0;
/** 怪物统一从右侧 X=400 出生点逐个刷出,随后向左推进 */ /** 怪物统一从右侧 X=400 出生点逐个刷出,随后向左推进 */
private static readonly MON_SPAWN_X = 400; private static readonly MON_SPAWN_X = 400;
/** 逐个刷怪默认间隔(秒):保证怪物排成纵队,避免堆叠;运行时按回合类型查 SPAWN_INTERVAL_BY_TYPE 覆盖 */
private static readonly MON_SPAWN_INTERVAL = 0.3;
/** /**
* 怪物占位点:所有槽位统一以右侧出生点 (X=400) 作为坐标, * 怪物占位点:所有槽位统一以右侧出生点 (X=400) 作为坐标,
@@ -76,20 +76,14 @@ export class MissionMonCompComp extends CCComp {
private waveTargetCount: number = 0; private waveTargetCount: number = 0;
/** 当前回合已生成的怪物数量 */ /** 当前回合已生成的怪物数量 */
private waveSpawnedCount: number = 0; private waveSpawnedCount: number = 0;
/** 等待生成的怪物队列按批次分组batch 0~2 */ /** 待刷队列(回合开始时整波进入,按速度曲线匀速刷出 */
private pendingBatches: GeneratedMonster[][] = [[], [], []];
/** 当前正在释放的批次索引 */
private currentBatch: number = 0;
/** 批次释放计时器(秒) */
private batchTimer: number = 0;
/** 是否正在分批释放中 */
private isReleasing: boolean = false;
/** 逐个刷怪队列:从当前批次转入,按节奏在 update 中释放 */
private spawnQueue: GeneratedMonster[] = []; private spawnQueue: GeneratedMonster[] = [];
/** 逐个刷怪累计计时(秒 */ /** 本回合已推进的战斗时间(秒,暂停刷怪时冻结 */
private spawnTimer: number = 0; private waveTime: number = 0;
/** 当前回合的逐个刷怪间隔(秒),按回合类型查 SPAWN_INTERVAL_BY_TYPE */ /** 各批次在队列中的起始下标(用于速度曲线按批变速) */
private spawnInterval: number = MissionMonCompComp.MON_SPAWN_INTERVAL; private batchStarts: number[] = [0, 0, 0];
/** 是否正在刷怪释放中 */
private isReleasing: boolean = false;
/** 当前批已刷出的怪物数(清场加速存活比例分母) */ /** 当前批已刷出的怪物数(清场加速存活比例分母) */
private batchReleasedCount: number = 0; private batchReleasedCount: number = 0;
/** 当前批是否已通过清场加速提前推进过(每批只触发一次) */ /** 当前批是否已通过清场加速提前推进过(每批只触发一次) */
@@ -100,8 +94,11 @@ export class MissionMonCompComp extends CCComp {
public waveEarlySkipTotal: number = 0; public waveEarlySkipTotal: number = 0;
/** 清场加速触发阈值当前批存活比例低于此值时提前推进0.25 = 清掉 75% */ /** 清场加速触发阈值当前批存活比例低于此值时提前推进0.25 = 清掉 75% */
private static readonly BATCH_EARLY_RATIO = 0.25; private static readonly BATCH_EARLY_RATIO = 0.25;
/** 清场加速提前量(秒):等效 batchTimer 快进,保底批间隔不被瞬间叠爆 */ /** 清场加速提前量(秒):等效推进时间快进,保底不被瞬间叠爆 */
private static readonly BATCH_EARLY_SKIP = 2.0; private static readonly BATCH_EARLY_SKIP = 2.0;
/** 批次时间边界batch0 → [0, T1)batch1 → [T1, T2)batch2 → [T2, WAVE_DURATION] */
private static readonly BATCH_T1 = WAVE_DURATION * 0.25;
private static readonly BATCH_T2 = WAVE_DURATION * 0.60;
// ======================== 生命周期 ======================== // ======================== 生命周期 ========================
@@ -112,55 +109,51 @@ export class MissionMonCompComp extends CCComp {
} }
protected update(dt: number): void { protected update(dt: number): void {
// 统计待刷出的怪物总数(未释放的批次 + 正在释放的队列 // 待刷出统计(回合结束检测与 HUD 进度消费,必须在 return 前执行保证语义不受暂停影响
let pendingCount = this.spawnQueue.length; smc.vmdata.mission_data.pending_mon_num = this.spawnQueue.length;
for (let i = this.currentBatch; i < BATCH_COUNT; i++) {
pendingCount += this.pendingBatches[i].length;
}
smc.vmdata.mission_data.pending_mon_num = pendingCount;
// 场上怪物超阈值时暂停释放:冻结批次推进与逐个刷出计时(不推进计时器,恢复后从断点平滑续接) // 场上怪物超阈值时暂停推进:冻结 waveTime恢复后欠账式瞬时补齐30 秒预算不流失
// 注意 pending 统计必须在 return 之前执行保证回合结束检测pending==0语义不受暂停影响
if (smc.mission.stop_spawn_mon) return; if (smc.mission.stop_spawn_mon) return;
if (!this.isReleasing) return;
// 分批释放:按 BATCH_INTERVAL 节奏推进批次 this.waveTime += dt;
if (this.isReleasing) {
this.batchTimer += dt;
if (this.batchTimer >= BATCH_INTERVAL && this.currentBatch < BATCH_COUNT - 1) {
this.batchTimer = 0;
this.advanceBatch();
}
// 清场加速0.2s 节流检测当前存活比例,清得快则快进批次计时(学 PvZ 血量阈值提前刷新 // 清场加速0.2s 节流检测当前存活比例,清得快则快进推进时间(压缩垃圾时间
this.aliveCheckTimer += dt; this.aliveCheckTimer += dt;
if (this.aliveCheckTimer >= 0.2) { if (this.aliveCheckTimer >= 0.2) {
this.aliveCheckTimer = 0; this.aliveCheckTimer = 0;
this.checkBatchEarlyAdvance(); this.checkEarlyAdvance();
}
} }
// 逐个刷怪:按 spawnInterval 节奏从队列释放 // 欠账式补刷:期望累计刷出数 已刷数,自动补偿帧余量与暂停期间欠账
if (this.spawnQueue.length > 0) { let expected = this.expectedSpawned(this.waveTime);
this.spawnTimer += dt; while (this.waveSpawnedCount < expected && this.spawnQueue.length > 0) {
if (this.spawnTimer >= this.spawnInterval) {
this.spawnTimer = 0;
const monData = this.spawnQueue.shift()!; const monData = this.spawnQueue.shift()!;
const targetPosIndex = this.waveSpawnedCount % MissionMonCompComp.MON_POSITIONS.length; const targetPosIndex = this.waveSpawnedCount % MissionMonCompComp.MON_POSITIONS.length;
this.addMonsterAtGrid(targetPosIndex, monData, this.currentWave); this.addMonsterAtGrid(targetPosIndex, monData, this.currentWave);
this.waveSpawnedCount++; this.waveSpawnedCount++;
} }
if (this.spawnQueue.length === 0) {
this.isReleasing = false;
} }
} }
start() { } start() { }
private setupWaveData(monsters: GeneratedMonster[]) { private setupWaveData(monsters: GeneratedMonster[]) {
// 按批次分组 // 按批次排序后整波入队记录各批起始下标供速度曲线按批变速batch 越大越靠后压轴)
this.pendingBatches = [[], [], []]; const sorted = monsters.slice().sort((a, b) => a.batch - b.batch);
for (const m of monsters) { this.spawnQueue = sorted;
const batch = Math.min(m.batch, BATCH_COUNT - 1); const starts = [sorted.length, sorted.length, sorted.length];
this.pendingBatches[batch].push(m); for (let i = 0; i < sorted.length; i++) {
const b = Math.min(sorted[i].batch, 2);
if (i < starts[b]) starts[b] = i;
} }
// 空批起始下标前推到下一批起点(防 expectedSpawned 区间错乱)
for (let b = 1; b >= 0; b--) {
if (starts[b] === sorted.length) starts[b] = starts[b + 1];
}
this.batchStarts = starts;
this.waveTargetCount = monsters.length; this.waveTargetCount = monsters.length;
smc.vmdata.mission_data.pending_mon_num = this.waveTargetCount; smc.vmdata.mission_data.pending_mon_num = this.waveTargetCount;
@@ -186,12 +179,10 @@ export class MissionMonCompComp extends CCComp {
this.currentWave = 1; this.currentWave = 1;
this.waveTargetCount = 0; this.waveTargetCount = 0;
this.waveSpawnedCount = 0; this.waveSpawnedCount = 0;
this.pendingBatches = [[], [], []];
this.currentBatch = 0;
this.batchTimer = 0;
this.isReleasing = false;
this.spawnQueue = []; this.spawnQueue = [];
this.spawnTimer = 0; this.batchStarts = [0, 0, 0];
this.waveTime = 0;
this.isReleasing = false;
this.batchReleasedCount = 0; this.batchReleasedCount = 0;
this.batchFastForwarded = false; this.batchFastForwarded = false;
this.aliveCheckTimer = 0; this.aliveCheckTimer = 0;
@@ -222,71 +213,53 @@ export class MissionMonCompComp extends CCComp {
} }
private onPhasePrepareEnd() { private onPhasePrepareEnd() {
this.resetSlotSpawnData(); // 准备结束:清残留怪与计时,启动持续刷怪(队列已在 setupWaveData 整波入队,不能清)
this.resetBattleTimer();
// 按回合类型确定本回合刷怪间隔(放松回合快速倾泻,压力回合稍慢聚焦)
this.spawnInterval = SPAWN_INTERVAL_BY_TYPE[getWaveType(this.currentWave)] ?? MissionMonCompComp.MON_SPAWN_INTERVAL;
// 准备结束阶段:启动分批释放,
// 第一批立即转入 spawnQueue后续批次由 update 按 BATCH_INTERVAL 推进。
this.startBatchRelease();
}
// ======================== 分批释放 ========================
/** 启动分批释放:立即释放第一批,启动批次计时器 */
private startBatchRelease() {
this.currentBatch = 0;
this.batchTimer = 0;
this.isReleasing = true; this.isReleasing = true;
this.releaseCurrentBatch(); this.waveTime = 0;
} }
/** 推进到下一批次 */ // ======================== 持续刷怪 ========================
private advanceBatch() {
if (this.currentBatch >= BATCH_COUNT - 1) {
this.isReleasing = false;
return;
}
this.currentBatch++;
this.releaseCurrentBatch();
}
/** 将当前批次的怪物转入 spawnQueue等待逐个刷出 */ /**
private releaseCurrentBatch() { * 速度曲线:按 waveTime 所在时间段返回"到当前时刻应累计刷出的怪物数"。
const batch = this.pendingBatches[this.currentBatch]; * 三段时间对应三批(铺垫 → 加压 → 高潮批内匀速m.batch 只影响排序,不再切断节奏。
if (batch.length === 0) { */
// 当前批次为空,尝试推进到下一批 private expectedSpawned(t: number): number {
if (this.currentBatch < BATCH_COUNT - 1) { const total = this.waveTargetCount;
this.currentBatch++; if (total <= 0) return 0;
this.releaseCurrentBatch(); const [s0, s1] = [this.batchStarts[0], this.batchStarts[1]];
const s2 = this.batchStarts[2];
const T1 = MissionMonCompComp.BATCH_T1;
const T2 = MissionMonCompComp.BATCH_T2;
if (t < T1) {
const segTotal = s1 - s0;
const segTime = T1;
return s0 + Math.min(segTotal, Math.floor(t / segTime * segTotal));
} else if (t < T2) {
const segTotal = s2 - s1;
const segTime = T2 - T1;
return s1 + Math.min(segTotal, Math.floor((t - T1) / segTime * segTotal));
} else { } else {
this.isReleasing = false; const segTotal = total - s2;
const segTime = WAVE_DURATION - T2;
return s2 + Math.min(segTotal, Math.floor((t - T2) / segTime * segTotal));
} }
return;
}
// 将批次怪物转入逐个刷怪队列
for (const m of batch) {
this.spawnQueue.push(m);
}
this.batchReleasedCount = batch.length;
this.batchFastForwarded = false;
batch.length = 0;
// 让首个怪物在下一帧立即刷出,避免额外延迟
this.spawnTimer = this.spawnInterval;
} }
/** /**
* 清场加速检测:当前批已放完且存活比例 ≤ BATCH_EARLY_RATIO 时,快进批次计时器 * 清场加速检测:当前时间段已刷完且存活比例 ≤ BATCH_EARLY_RATIO 时,快进推进时间
* 只奖励"清得快"的 build压缩批间垃圾时间),弱 build 清不完则不触发、不叠加压力。 * 只奖励"清得快"的 build压缩垃圾时间弱 build 清不完则不触发、不叠加压力。
*/ */
private checkBatchEarlyAdvance() { private checkEarlyAdvance() {
if (this.batchFastForwarded) return; if (this.batchFastForwarded) return;
if (this.currentBatch >= BATCH_COUNT - 1) return; if (this.spawnQueue.length === 0) return;
if (this.spawnQueue.length > 0) return; // 本批还没放完,不判断 // 当前时间段的 quota 是否已刷完:用 expectedSpawned 反推
if (this.batchReleasedCount <= 0) return; const expected = this.expectedSpawned(this.waveTime);
if (this.waveSpawnedCount < expected) return; // 本时间段还没放完,不判断
if (this.batchReleasedCount <= 0) {
this.batchReleasedCount = Math.max(1, expected);
}
let alive = 0; let alive = 0;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => { ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => {
@@ -297,20 +270,22 @@ export class MissionMonCompComp extends CCComp {
const aliveRatio = Math.min(1, alive / this.batchReleasedCount); const aliveRatio = Math.min(1, alive / this.batchReleasedCount);
if (aliveRatio <= MissionMonCompComp.BATCH_EARLY_RATIO) { if (aliveRatio <= MissionMonCompComp.BATCH_EARLY_RATIO) {
this.batchFastForwarded = true; this.batchFastForwarded = true;
this.batchTimer += MissionMonCompComp.BATCH_EARLY_SKIP; this.waveTime += MissionMonCompComp.BATCH_EARLY_SKIP;
this.waveEarlySkipTotal += MissionMonCompComp.BATCH_EARLY_SKIP; this.waveEarlySkipTotal += MissionMonCompComp.BATCH_EARLY_SKIP;
smc.vmdata.mission_data.wave_early_skip = this.waveEarlySkipTotal; smc.vmdata.mission_data.wave_early_skip = this.waveEarlySkipTotal;
mLogger.log(this.debugMode, 'MissionMonComp', mLogger.log(this.debugMode, 'MissionMonComp',
`[EarlyAdvance] batch=${this.currentBatch} alive=${alive}/${this.batchReleasedCount},快进 ${MissionMonCompComp.BATCH_EARLY_SKIP}s`); `[EarlyAdvance] waveTime=${this.waveTime.toFixed(1)} alive=${alive}/${this.batchReleasedCount},快进 ${MissionMonCompComp.BATCH_EARLY_SKIP}s`);
} }
} }
// ======================== 槽位管理 ======================== // ======================== 槽位管理 ========================
/** /**
* 清理上一回合残留怪物,并重置生成计数与逐个刷怪队列 * 清理上一回合残留怪物,并重置战斗计时与清场加速状态。
* 注意spawnQueue / batchStarts / waveTargetCount 由 setupWaveData 在 Prepare 阶段写入,
* 本函数不能触碰,否则 PhasePrepareEnd 会清空未刷队列导致回合直接判空。
*/ */
private resetSlotSpawnData() { private resetBattleTimer() {
ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => { ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => {
const attrs = e.get(HeroAttrsComp); const attrs = e.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) { if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) {
@@ -319,12 +294,8 @@ export class MissionMonCompComp extends CCComp {
}); });
this.waveSpawnedCount = 0; this.waveSpawnedCount = 0;
// 同步丢弃上一回合未释放的队列,避免与新一回合混合 this.waveTime = 0;
this.spawnQueue = [];
this.spawnTimer = 0;
this.isReleasing = false; this.isReleasing = false;
this.currentBatch = 0;
this.batchTimer = 0;
this.batchReleasedCount = 0; this.batchReleasedCount = 0;
this.batchFastForwarded = false; this.batchFastForwarded = false;
this.aliveCheckTimer = 0; this.aliveCheckTimer = 0;

View File

@@ -23,7 +23,7 @@
* 回合节奏: * 回合节奏:
* - 最大 20 回合,第 20 回合通关 * - 最大 20 回合,第 20 回合通关
* - 每回合 30 秒,固定分 3 批,每 10 秒释放一批 * - 每回合 30 秒,固定分 3 批,每 10 秒释放一批
* - 普通回合 18~36 只,放松回合 × 1.5 = 27~54 只 * - 普通回合 36~72 只,放松回合 × 1.5 = 54~108 只(全局 MON_COUNT_MUL = 2
* - wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少 * - wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少
* - wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,爽快清屏,大战前夜收割补给) * - wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,爽快清屏,大战前夜收割补给)
*/ */
@@ -60,6 +60,12 @@ export const WAVE_TYPE_POWER_RATIO: Record<WaveType, number> = {
/** 放松回合数量倍率(相对普通回合) */ /** 放松回合数量倍率(相对普通回合) */
export const RELAX_COUNT_MUL = 1.5; export const RELAX_COUNT_MUL = 1.5;
/** 全局怪物数量倍率(在 base_count 基础上整体翻倍,量大管饱) */
export const MON_COUNT_MUL = 2;
/** 全局怪物强度系数2/3 = 强度削弱三分之一,同时作用于 hp 缩放与 ap 挂钩公式) */
export const MON_POWER_MUL = 2 / 3;
/** 最大回合数(第 20 回合通关) */ /** 最大回合数(第 20 回合通关) */
export const MAX_WAVE = 20; export const MAX_WAVE = 20;
@@ -72,8 +78,8 @@ export const BATCH_COUNT = 3;
/** 每批间隔30 秒 / 3 批 = 10 秒 */ /** 每批间隔30 秒 / 3 批 = 10 秒 */
export const BATCH_INTERVAL = WAVE_DURATION / BATCH_COUNT; export const BATCH_INTERVAL = WAVE_DURATION / BATCH_COUNT;
/** 每回合怪物硬上限(放松回合 36 × 1.5 = 54 */ /** 每回合怪物硬上限(放松回合 72 × 1.5 = 108 */
export const MAX_MONSTERS = 54; export const MAX_MONSTERS = 108;
/** Boss 护卫队数量(与 Boss 同批压轴进场,占用回合总名额) */ /** Boss 护卫队数量(与 Boss 同批压轴进场,占用回合总名额) */
export const BOSS_GUARD_COUNT = 3; export const BOSS_GUARD_COUNT = 3;
@@ -371,7 +377,7 @@ export const BossSkillPool: Record<string, MonSkillSet> = {
/** 单回合完整配置 */ /** 单回合完整配置 */
export interface WaveConfig { export interface WaveConfig {
/** 基础怪物总数(普通回合 36 为上限,放松回合自动 × 1.5 = 54 */ /** 基础怪物总数(普通回合 72 为上限,放松回合自动 × 1.5 = 108均已含全局 MON_COUNT_MUL = 2 */
base_count: number; base_count: number;
/** 可选小队 id 池,引擎按权重抽取拼装到 base_count */ /** 可选小队 id 池,引擎按权重抽取拼装到 base_count */
squad_pool: string[]; squad_pool: string[];
@@ -401,40 +407,40 @@ export interface WaveConfig {
*/ */
export const WaveConfigs: Record<number, WaveConfig> = { export const WaveConfigs: Record<number, WaveConfig> = {
// ===== 第一循环:教学期 ===== // ===== 第一循环:教学期 =====
1: { base_count: 18, squad_pool: ["melee_grunt"], hp_mul: 1.00, ap_mul: 1.00 }, 1: { base_count: 36, squad_pool: ["melee_grunt"], hp_mul: 1.00, ap_mul: 1.00 },
2: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced"], hp_mul: 1.00, ap_mul: 1.00 }, 2: { base_count: 42, squad_pool: ["melee_grunt", "mixed_balanced"], hp_mul: 1.00, ap_mul: 1.00 },
3: { base_count: 24, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.05, ap_mul: 1.00 }, 3: { base_count: 48, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.05, ap_mul: 1.00 },
// 放松回合量大好清Boss 前收割补给 // 放松回合量大好清Boss 前收割补给
4: { base_count: 27, squad_pool: ["mixed_balanced", "long_line", "heavy_shield"], hp_mul: 1.10, ap_mul: 1.05 }, 4: { base_count: 54, squad_pool: ["mixed_balanced", "long_line", "heavy_shield"], hp_mul: 1.10, ap_mul: 1.05 },
// 压力回合:第一 Boss // 压力回合:第一 Boss
5: { base_count: 21, squad_pool: ["melee_grunt", "mixed_balanced", "heavy_shield"], hp_mul: 1.15, ap_mul: 1.10, boss_wave: true, boss_skill_pool: ["boss_rage"] }, 5: { base_count: 42, squad_pool: ["melee_grunt", "mixed_balanced", "heavy_shield"], hp_mul: 1.15, ap_mul: 1.10, boss_wave: true, boss_skill_pool: ["boss_rage"] },
// ===== 第二循环:引入技能怪 ===== // ===== 第二循环:引入技能怪 =====
6: { base_count: 30, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.10, ap_mul: 1.05 }, 6: { base_count: 60, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.10, ap_mul: 1.05 },
7: { base_count: 30, squad_pool: ["melee_grunt", "heavy_shield", "long_line"], hp_mul: 1.15, ap_mul: 1.10, skill_pool: ["tough"] }, 7: { base_count: 60, squad_pool: ["melee_grunt", "heavy_shield", "long_line"], hp_mul: 1.15, ap_mul: 1.10, skill_pool: ["tough"] },
8: { base_count: 33, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.20, ap_mul: 1.15, skill_pool: ["berserk"] }, 8: { base_count: 66, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.20, ap_mul: 1.15, skill_pool: ["berserk"] },
// 放松回合量大好清Boss 前收割补给 // 放松回合量大好清Boss 前收割补给
9: { base_count: 33, squad_pool: ["heavy_shield", "long_line", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["berserk", "tough"] }, 9: { base_count: 66, squad_pool: ["heavy_shield", "long_line", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["berserk", "tough"] },
// 压力回合:第二 Boss // 压力回合:第二 Boss
10: { base_count: 24, squad_pool: ["melee_grunt", "assassin_squad", "mixed_balanced"], hp_mul: 1.30, ap_mul: 1.25, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron"] }, 10: { base_count: 48, squad_pool: ["melee_grunt", "assassin_squad", "mixed_balanced"], hp_mul: 1.30, ap_mul: 1.25, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron"] },
// ===== 第三循环:组合多样化 ===== // ===== 第三循环:组合多样化 =====
11: { base_count: 33, squad_pool: ["assassin_squad", "long_line", "summoner_cult", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["leech"] }, 11: { base_count: 66, squad_pool: ["assassin_squad", "long_line", "summoner_cult", "mixed_balanced"], hp_mul: 1.25, ap_mul: 1.20, skill_pool: ["leech"] },
12: { base_count: 33, squad_pool: ["heavy_shield", "melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.30, ap_mul: 1.25, skill_pool: ["tough", "warcry"] }, 12: { base_count: 66, squad_pool: ["heavy_shield", "melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.30, ap_mul: 1.25, skill_pool: ["tough", "warcry"] },
13: { base_count: 36, squad_pool: ["assassin_squad", "summoner_cult", "mixed_balanced"], hp_mul: 1.35, ap_mul: 1.30, skill_pool: ["berserk", "leech"] }, 13: { base_count: 72, squad_pool: ["assassin_squad", "summoner_cult", "mixed_balanced"], hp_mul: 1.35, ap_mul: 1.30, skill_pool: ["berserk", "leech"] },
// 放松回合量大好清Boss 前收割补给 // 放松回合量大好清Boss 前收割补给
14: { base_count: 36, squad_pool: ["heavy_shield", "long_line", "melee_grunt"], hp_mul: 1.40, ap_mul: 1.35, skill_pool: ["berserk", "tough", "legacy"] }, 14: { base_count: 72, squad_pool: ["heavy_shield", "long_line", "melee_grunt"], hp_mul: 1.40, ap_mul: 1.35, skill_pool: ["berserk", "tough", "legacy"] },
// 压力回合:第三 Boss中期高潮 // 压力回合:第三 Boss中期高潮
15: { base_count: 27, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.50, ap_mul: 1.40, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron", "boss_doom"] }, 15: { base_count: 54, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.50, ap_mul: 1.40, boss_wave: true, boss_skill_pool: ["boss_rage", "boss_iron", "boss_doom"] },
// ===== 第四循环终极阶段17~19 power_adjust 逐步爬坡,为最终 Boss 蓄势) ===== // ===== 第四循环终极阶段17~19 power_adjust 逐步爬坡,为最终 Boss 蓄势) =====
16: { base_count: 36, squad_pool: ["assassin_squad", "heavy_shield", "long_line"], hp_mul: 1.45, ap_mul: 1.40, skill_pool: ["leech", "warcry"] }, 16: { base_count: 72, squad_pool: ["assassin_squad", "heavy_shield", "long_line"], hp_mul: 1.45, ap_mul: 1.40, skill_pool: ["leech", "warcry"] },
17: { base_count: 36, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"], power_adjust: 1.05 }, 17: { base_count: 72, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"], power_adjust: 1.05 },
18: { base_count: 36, squad_pool: ["assassin_squad", "long_line", "heavy_shield"], hp_mul: 1.55, ap_mul: 1.50, skill_pool: ["berserk", "tough", "leech"], power_adjust: 1.10 }, 18: { base_count: 72, squad_pool: ["assassin_squad", "long_line", "heavy_shield"], hp_mul: 1.55, ap_mul: 1.50, skill_pool: ["berserk", "tough", "leech"], power_adjust: 1.10 },
// 放松回合:量大好清,最终 Boss 前收割补给 // 放松回合:量大好清,最终 Boss 前收割补给
19: { base_count: 36, squad_pool: ["mixed_balanced", "summoner_cult", "melee_grunt"], hp_mul: 1.60, ap_mul: 1.55, skill_pool: ["berserk", "tough", "legacy", "warcry"], power_adjust: 1.20 }, 19: { base_count: 72, squad_pool: ["mixed_balanced", "summoner_cult", "melee_grunt"], hp_mul: 1.60, ap_mul: 1.55, skill_pool: ["berserk", "tough", "legacy", "warcry"], power_adjust: 1.20 },
// 压力回合:最终 Boss // 压力回合:最终 Boss
20: { base_count: 30, squad_pool: ["assassin_squad", "heavy_shield", "summoner_cult", "mixed_balanced"], hp_mul: 1.80, ap_mul: 1.70, boss_wave: true, boss_skill_pool: ["boss_doom", "boss_rage"] }, 20: { base_count: 60, squad_pool: ["assassin_squad", "heavy_shield", "summoner_cult", "mixed_balanced"], hp_mul: 1.80, ap_mul: 1.70, boss_wave: true, boss_skill_pool: ["boss_doom", "boss_rage"] },
}; };
// ======================== 7. 配置校验 ======================== // ======================== 7. 配置校验 ========================
@@ -566,10 +572,10 @@ export class RogueSpawningEngine {
const waveType = getWaveType(wave); const waveType = getWaveType(wave);
const typeRatio = WAVE_TYPE_POWER_RATIO[waveType]; const typeRatio = WAVE_TYPE_POWER_RATIO[waveType];
// 1. 计算目标强度 // 1. 计算目标强度MON_POWER_MUL = 2/3数量翻倍后单怪强度整体削弱三分之一避免总压力膨胀
const heroPower = this.getCurrentHeroPower(); const heroPower = this.getCurrentHeroPower();
const powerAdjust = cfg.power_adjust ?? 1.0; const powerAdjust = cfg.power_adjust ?? 1.0;
const targetPower = heroPower * typeRatio * powerAdjust * DynamicTuner.factor; const targetPower = heroPower * typeRatio * powerAdjust * DynamicTuner.factor * MON_POWER_MUL;
// 2. 确定怪物总数(放松回合 × 1.5;普通/压力回合预留收尾高潮批名额,防 slice 截掉) // 2. 确定怪物总数(放松回合 × 1.5;普通/压力回合预留收尾高潮批名额,防 slice 截掉)
let totalCount = cfg.base_count; let totalCount = cfg.base_count;
@@ -626,8 +632,8 @@ export class RogueSpawningEngine {
const apGrowth = getApWaveGrowth(wave); const apGrowth = getApWaveGrowth(wave);
for (const m of monsters) { for (const m of monsters) {
m.hp = Math.max(1, Math.round(m.hp * hpScale)); m.hp = Math.max(1, Math.round(m.hp * hpScale));
// ap = 玩家平均攻击 × 回合递进 × 基础系数 × 类型系数Boss/护卫/精英在上游已上浮 base_ap // ap = 玩家平均攻击 × 回合递进 × 基础系数 × 类型系数 × 全局强度系数(削弱三分之一
m.ap = Math.max(1, Math.round(heroAvgAp * apGrowth * AP_RELATIVE * this.getApTypeFactor(m))); m.ap = Math.max(1, Math.round(heroAvgAp * apGrowth * AP_RELATIVE * MON_POWER_MUL * this.getApTypeFactor(m)));
} }
} }