feat: 完成肉鸽回合制游戏核心玩法迭代

新增连杀系统、金币飞行特效、波次HUD、屏幕震动等表现功能,重构怪物刷出逻辑与难度动态调节,调整回合回血比例,优化游戏节奏与体验。

主要变更:
1.  调整回合回血比例从0.5到0.4,优化前期节奏
2.  新增连杀计数与奖励系统,支持5/10/20连杀触发对应表现
3.  实现怪物死亡掉落金币的抛物线飞行特效
4.  增加波次进度HUD,显示剩余怪物与全局回合进度
5.  新增屏幕震动工具与战斗横幅统一展示系统
6.  重构怪物刷出逻辑,支持按回合类型调整刷怪间隔,加入清场加速机制
7.  优化动态难度调节算法,增加滞回与指数平滑,避免难度突变
8.  新增Boss预警、登场事件与回合清屏事件,完善事件总线
9.  调整怪物数量阈值与回合倒计时档位,适配新的节奏设计
This commit is contained in:
pan
2026-08-11 19:00:23 +08:00
parent d57f4c14e0
commit 409113e269
12 changed files with 904 additions and 80 deletions

View File

@@ -0,0 +1,45 @@
/**
* @file ScreenShake.ts
* @description 屏幕震动静态工具(表现层)
*
* 项目内无现成相机震动能力MapViewScene.camera 可能为 null 且从未使用),
* 改为抖动地图根节点 smc.map.MapView.node地图+实体一起震UI 层不受影响)。
*
* 用法:
* ScreenShake.shake(10, 0.4); // 强度 10 像素,持续 0.4 秒
*/
import { tween, Tween, v3, Vec3 } from "cc";
import { smc } from "./SingletonModuleComp";
export class ScreenShake {
/** 地图根节点的基准位置(首次震动时快照,用于收敛回原位,防多次震动漂移) */
private static basePos: Vec3 | null = null;
/**
* 触发一次屏幕震动
* @param strength 像素振幅(建议 4~10
* @param duration 总时长(秒,建议 0.2~0.4
*/
static shake(strength: number = 8, duration: number = 0.3) {
const target = smc.map?.MapView?.node;
if (!target || !target.isValid) return;
if (!this.basePos) this.basePos = target.position.clone();
// 打断进行中的震动(新震动覆盖旧震动,不做强度叠加,避免失控)
Tween.stopAllByTarget(target);
const steps = Math.max(1, Math.floor(duration / 0.04));
const t = tween(target);
for (let i = 0; i < steps; i++) {
t.to(0.04, {
position: v3(
this.basePos.x + (Math.random() * 2 - 1) * strength,
this.basePos.y + (Math.random() * 2 - 1) * strength,
this.basePos.z),
});
}
// 收敛回原位
t.to(0.05, { position: this.basePos }).start();
}
}

View File

@@ -85,6 +85,8 @@ export class SingletonModuleComp extends ecs.Comp {
game_pause: false,
mission_data: {
mon_num: 0,//怪物数量
pending_mon_num: 0,//待刷出怪物数量MissionMonComp 每帧写入,供回合结束检测与 HUD 进度)
wave_early_skip: 0,//本回合清场加速累计节省的秒数MissionComp 用于还原 DDA 判定口径)
hero_num: 0,//英雄数量
hero_max_num: FightSet.HERO_MAX_NUM,//英雄可召唤上限
hero_extend_max_num: FightSet.HERO_MAX_NUM + 1,//英雄可拓展上限

View File

@@ -88,5 +88,14 @@ export enum GameEvent {
UseEquipCard = "UseEquipCard", // 装备卡购买使用事件
RemoveEquipBox = "RemoveEquipBox", // 装备盒销毁事件
HeroBoxEmptyClick = "HeroBoxEmptyClick", // 英雄面板空槽位点击事件(展示英雄卡池)
BossWarning = "BossWarning", // Boss 回合战斗开始预警payload: { wave, eta }eta 为 Boss 预计进场秒数)
/** 回合清屏(场上+待刷怪全灭payload: { wave, clearTime, allAlive, fastClear } */
WaveClear = "WaveClear",
/** Boss 实体刷出瞬间payload: { pos },供登场震屏/音效定位) */
BossSpawn = "BossSpawn",
/** 金币飞行表现payload: { worldPos, gold, isBoss },账务已即时结算,纯视觉事件) */
CoinFly = "CoinFly",
/** 连杀达阈值payload: { count, tier }tier 0/1/2 对应 5/10/20 连杀) */
ComboReach = "ComboReach",
}

View File

@@ -46,7 +46,7 @@ export enum FightSet {
CSKILL_START_X = -340,
CSKILL_START_Y = 30,
SHIELD_MAX = 5,
WAVE_HEAL_RATE = 0.5, // 回合结束时所有英雄恢复最大生命值的比例
WAVE_HEAL_RATE = 0.4, // 回合结束时所有英雄恢复最大生命值的比例(与 DDA 放水兜底分层:回血保节奏,放水防崩盘)
PUNCTURE_DOWN = 50,
REFRESH_COST = 2,
BASE_COST = 5,

View File

@@ -468,6 +468,11 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
if (TAttrsComp.fac === FacSet.MON) {
// 怪物死亡处理
this.scheduleDrop(entity);
// oops.message 全局事件总线:怪物死亡(连杀计数等表现层消费)
oops.message.dispatchEvent(GameEvent.MonDead, {
isBoss: !!TAttrsComp.is_boss,
worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null,
});
} else if (TAttrsComp.fac === FacSet.HERO) {
// 英雄死亡处理
this.scheduleHeroDeath(entity);
@@ -495,6 +500,12 @@ export class HeroAtkSystem extends ecs.ComblockSystem implements ecs.ISystemUpda
if (gold <= 0) return;
MissionEconomy.addCoin(gold);
// oops.message 全局事件总线:金币飞行表现(账务已即时结算,飞行纯视觉)
oops.message.dispatchEvent(GameEvent.CoinFly, {
worldPos: entity.get(HeroViewComp)?.node?.worldPosition?.clone() ?? null,
gold,
isBoss: !!TAttrsComp.is_boss,
});
mLogger.log(this.debugMode, 'HeroAtkSystem',
` ${TAttrsComp.hero_name} 死亡掉落金币 ${gold}monType=${monType}, isBoss=${TAttrsComp.is_boss}`);
}

View File

@@ -0,0 +1,143 @@
/**
* @file BattleBannerComp.ts
* @description 战斗横幅统一通道组件(表现层)
*
* 职责:
* 1. 监听清屏WaveClear/ Boss 预警BossWarning/ 连杀ComboReach事件播分级横幅。
* 2. 横幅动画右侧飞入backOut→ 中央停留 → 左侧飞出backIn与 MissionComp.playTooltipAnim 同风格。
* 3. 清屏奖励结算fastClear 金币 / allAlive 刷新石),走 MissionEconomy 静态接口。
* 4. Boss 登场BossSpawn触发震屏 + 音效。
*
* 使用 oops-framework 模块oops.message事件解耦、oops.audio音效
* 编辑器绑定bannerNodeLabel+背景节点,初始 active=false
*/
import { _decorator, Node, Label, tween, Tween, v3, Color } from "cc";
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { GameEvent } from "../common/config/GameEvent";
import { MissionEconomy } from "./MissionEconomy";
import { ScreenShake } from "../common/ScreenShake";
import { WAVE_DURATION } from "./RogueConfig";
const { ccclass, property } = _decorator;
/** 清屏奖励:迅速清场(< 75% 时长) */
const FAST_CLEAR_COIN = 2;
/** 清屏奖励:极速清场(< 50% 时长) */
const ULTRA_CLEAR_COIN = 5;
/** 连杀分级金币爆发tier 0/1/2 对应 5/10/20 连杀) */
const COMBO_COIN = [2, 5, 10];
@ccclass('BattleBannerComp')
export class BattleBannerComp extends CCComp {
/** 横幅节点Label+背景,初始隐藏),编辑器拖拽绑定 */
@property({ type: Node, tooltip: "横幅节点Label+背景,初始 active=false" })
bannerNode: Node | null = null;
/** 上次横幅播放时间戳去抖0.3s 内的新横幅直接打断旧的,不叠加 tween */
private lastBannerTime: number = 0;
onLoad() {
// oops.message 全局事件总线:清屏 / Boss 预警 / Boss 登场 / 连杀
oops.message.on(GameEvent.WaveClear, this.onWaveClear, this);
oops.message.on(GameEvent.BossWarning, this.onBossWarning, this);
oops.message.on(GameEvent.BossSpawn, this.onBossSpawn, this);
oops.message.on(GameEvent.ComboReach, this.onComboReach, this);
}
onDestroy() {
oops.message.off(GameEvent.WaveClear, this.onWaveClear, this);
oops.message.off(GameEvent.BossWarning, this.onBossWarning, this);
oops.message.off(GameEvent.BossSpawn, this.onBossSpawn, this);
oops.message.off(GameEvent.ComboReach, this.onComboReach, this);
}
// ======================== 事件处理 ========================
/** 清屏庆祝:分级文案 + 奖励结算 */
private onWaveClear(event: string, data: { wave: number; clearTime: number; allAlive: boolean; fastClear: number }) {
if (data.allAlive) {
this.playBanner("Perfect!", new Color(255, 80, 80), 1.2);
MissionEconomy.addRefreshStone(1);
oops.audio.playEffect("music/flash");
} else if (data.fastClear === 2) {
this.playBanner("极速清场!", new Color(255, 170, 0), 1.15);
MissionEconomy.addCoin(ULTRA_CLEAR_COIN);
oops.audio.playEffect("music/flash");
} else if (data.fastClear === 1) {
this.playBanner("迅速清场!", new Color(255, 220, 60), 1.05);
MissionEconomy.addCoin(FAST_CLEAR_COIN);
} else {
this.playBanner("Clear!", new Color(255, 255, 255), 1.0);
}
}
/** Boss 回合预警:红字横幅 */
private onBossWarning(event: string, data: { wave: number; eta: number }) {
this.playBanner("强敌来袭!", new Color(255, 60, 60), 1.3);
oops.audio.playEffect("music/flash");
}
/** Boss 登场:震屏 + 闷响 */
private onBossSpawn() {
ScreenShake.shake(10, 0.4);
oops.audio.playEffect("music/dun");
}
/** 连杀达阈值:分级文案 + 震屏 + 金币爆发 */
private onComboReach(event: string, data: { count: number; tier: number }) {
const tier = Math.min(data.tier, 2);
const colors = [new Color(255, 230, 80), new Color(255, 150, 30), new Color(255, 60, 60)];
const scales = [1.0, 1.2, 1.4];
this.playBanner(`${data.count} 连杀!`, colors[tier], scales[tier]);
if (tier === 1) ScreenShake.shake(4, 0.2);
if (tier === 2) ScreenShake.shake(8, 0.3);
MissionEconomy.addCoin(COMBO_COIN[tier]);
// 占位音效分级(后续可替换为升调 combo 音效)
oops.audio.playEffect(tier === 2 ? "music/Fire" : tier === 1 ? "music/Critical" : "music/Hit");
}
// ======================== 横幅播放 ========================
/**
* 播放横幅:右进 → 中央停留 → 左出
* @param text 文案
* @param color 文字颜色
* @param scale 整体缩放(分级表现力)
*/
private playBanner(text: string, color: Color, scale: number = 1.0) {
if (!this.bannerNode || !this.bannerNode.isValid) return;
// 去抖0.3s 内的新横幅直接打断旧的
const now = Date.now();
if (now - this.lastBannerTime < 300) {
Tween.stopAllByTarget(this.bannerNode);
}
this.lastBannerTime = now;
this.bannerNode.active = true;
this.bannerNode.setScale(v3(scale, scale, 1));
const label = this.bannerNode.getComponentInChildren(Label);
if (label) {
label.string = text;
label.color = color;
label.updateRenderData(true);
}
Tween.stopAllByTarget(this.bannerNode);
const startPos = v3(1200, 0, 0);
const centerPos = v3(0, 0, 0);
const endPos = v3(-1200, 0, 0);
this.bannerNode.setPosition(startPos);
tween(this.bannerNode)
.to(0.4, { position: centerPos }, { easing: "backOut" })
.to(0.8, { position: v3(-40, 0, 0) }, { easing: "sineInOut" })
.to(0.35, { position: endPos }, { easing: "backIn" })
.call(() => {
this.bannerNode!.active = false;
})
.start();
}
}

View File

@@ -0,0 +1,101 @@
/**
* @file CoinFlyComp.ts
* @description 金币飞行表现组件(表现层,纯视觉)
*
* 职责:
* 1. 监听 CoinFly 事件(怪物死亡掉金币,账务已由 MissionEconomy 即时结算)。
* 2. 从怪物位置生成金币 icon抛物线散开后加速飞入钱包 icon。
* 3. 独立 NodePool 管理金币节点(上限 30不复用伤害飘字池。
*
* 使用 oops-framework 模块oops.message事件解耦
* 编辑器绑定flyLayer全屏容器最高 sibling、coinIconSrc钱包 coin/icon 节点,取其 spriteFrame
*/
import { _decorator, Node, Sprite, UITransform, tween, v3, Vec3, NodePool } from "cc";
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { GameEvent } from "../common/config/GameEvent";
const { ccclass, property } = _decorator;
/** 普通怪金币枚数 */
const COIN_COUNT_NORMAL = 3;
/** Boss 金币枚数 */
const COIN_COUNT_BOSS = 8;
/** 对象池上限(防节点泄漏) */
const POOL_MAX = 30;
@ccclass('CoinFlyComp')
export class CoinFlyComp extends CCComp {
@property({ type: Node, tooltip: "金币飞行容器(全屏节点,置于最高 sibling 盖在所有 UI 之上)" })
flyLayer: Node | null = null;
@property({ type: Node, tooltip: "钱包金币 icon 节点(运行期取其 spriteFrame 克隆)" })
coinIconSrc: Node | null = null;
/** 金币节点对象池 */
private pool: NodePool = new NodePool();
onLoad() {
// oops.message 全局事件总线:金币飞行
oops.message.on(GameEvent.CoinFly, this.onCoinFly, this);
}
onDestroy() {
oops.message.off(GameEvent.CoinFly, this.onCoinFly, this);
this.pool.clear();
}
private onCoinFly(event: string, data: { worldPos: Vec3 | null; gold: number; isBoss: boolean }) {
if (!this.flyLayer || !this.coinIconSrc || !data.worldPos) return;
const uiTransform = this.flyLayer.getComponent(UITransform);
if (!uiTransform) return;
const startPos = uiTransform.convertToNodeSpaceAR(data.worldPos);
const endPos = uiTransform.convertToNodeSpaceAR(this.coinIconSrc.worldPosition);
const count = data.isBoss ? COIN_COUNT_BOSS : COIN_COUNT_NORMAL;
for (let i = 0; i < count; i++) {
this.spawnCoin(startPos, endPos, i * 0.03);
}
}
/** 生成一枚金币:上抛散开 → 加速飞向钱包 → 回收 */
private spawnCoin(startPos: Vec3, endPos: Vec3, delay: number) {
const coin = this.pool.size() > 0 ? this.pool.get()! : this.createCoinNode();
coin.parent = this.flyLayer;
coin.setPosition(startPos);
coin.setScale(v3(1, 1, 1));
const scatter = v3(
startPos.x + (Math.random() * 120 - 60),
startPos.y + 60 + Math.random() * 40,
0);
tween(coin)
.delay(delay)
.to(0.25, { position: scatter }, { easing: "quadOut" })
.to(0.4, { position: endPos }, { easing: "quadIn" })
.call(() => this.recycleCoin(coin))
.start();
}
private createCoinNode(): Node {
const node = new Node("coin_fly");
node.addComponent(UITransform).setContentSize(32, 32);
const sp = node.addComponent(Sprite);
const srcSp = this.coinIconSrc?.getComponent(Sprite);
if (srcSp?.spriteFrame) {
sp.spriteFrame = srcSp.spriteFrame;
}
return node;
}
private recycleCoin(coin: Node) {
if (this.pool.size() >= POOL_MAX) {
coin.destroy();
return;
}
this.pool.put(coin);
}
}

View File

@@ -0,0 +1,72 @@
/**
* @file ComboComp.ts
* @description 连杀 Combo 计数组件(逻辑层,事件驱动)
*
* 职责:
* 1. 监听 MonDead 事件2s 滑动窗口内累计连杀数。
* 2. 达阈值5/10/20时派发 ComboReach 事件,由 BattleBannerComp 消费分级表现。
* 3. 窗口超时或整局结束MissionEnd清零。
*
* 使用 oops-framework 模块oops.message事件解耦
* 本组件无 UI 绑定,挂 mission.prefab 任意节点即可。
*/
import { _decorator } from "cc";
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { GameEvent } from "../common/config/GameEvent";
import { smc } from "../common/SingletonModuleComp";
const { ccclass } = _decorator;
/** 连杀窗口(秒):窗口内每次击杀续期 */
const COMBO_WINDOW = 2;
/** 连杀阈值档位x5 / x10 / x20 */
const COMBO_TIERS = [5, 10, 20];
@ccclass('ComboComp')
export class ComboComp extends CCComp {
/** 当前连杀数 */
private comboCount: number = 0;
/** 滑动窗口剩余时间(秒) */
private windowTimer: number = 0;
onLoad() {
// oops.message 全局事件总线:怪物死亡 / 整局结束
oops.message.on(GameEvent.MonDead, this.onMonDead, this);
oops.message.on(GameEvent.MissionEnd, this.resetCombo, this);
}
onDestroy() {
oops.message.off(GameEvent.MonDead, this.onMonDead, this);
oops.message.off(GameEvent.MissionEnd, this.resetCombo, this);
}
private onMonDead() {
this.comboCount++;
this.windowTimer = COMBO_WINDOW;
const tier = COMBO_TIERS.indexOf(this.comboCount);
if (tier >= 0) {
// oops.message 全局事件总线:连杀达阈值(横幅/震屏/金币爆发由 BattleBannerComp 消费)
oops.message.dispatchEvent(GameEvent.ComboReach, {
count: this.comboCount,
tier,
});
}
}
private resetCombo() {
this.comboCount = 0;
this.windowTimer = 0;
}
protected update(dt: number) {
if (!smc.mission.play || smc.mission.pause) return;
if (this.windowTimer > 0) {
this.windowTimer -= dt;
if (this.windowTimer <= 0) {
this.comboCount = 0;
}
}
}
}

View File

@@ -48,7 +48,7 @@ import { Tooltip } from "../skill/Tooltip";
import { Timer } from "db://oops-framework/core/common/timer/Timer";
import { FieldSkillType } from "../common/config/SkillSet";
import { FieldSkillHelper } from "../hero/FieldSkillHelper";
import { spawningEngine, MAX_WAVE, DynamicTuner } from "./RogueConfig";
import { spawningEngine, MAX_WAVE, DynamicTuner, WAVE_TIMEOUT, WAVE_TIMEOUT_BOSS, BATCH_INTERVAL, BATCH_COUNT, WAVE_DURATION } from "./RogueConfig";
const { ccclass, property } = _decorator;
/** 任务(关卡)生命周期阶段 */
@@ -82,10 +82,10 @@ export class MissionComp extends CCComp {
// ======================== 配置参数 ========================
/** 怪物数量上限(超过后暂停刷怪) */
private maxMonsterCount: number = 80;
/** 怪物数量恢复阈值(降至此值以下恢复刷怪) */
private resumeMonsterCount: number = 45;
/** 怪物数量上限(超过后暂停刷怪):略高于单回合上限 MAX_MONSTERS(54),只有跨回合堆积才触发泄压 */
private maxMonsterCount: number = 60;
/** 怪物数量恢复阈值(降至此值以下恢复刷怪,需留空间接纳后续批次,单批最大约 18 只 */
private resumeMonsterCount: number = 40;
// ======================== 编辑器绑定节点 ========================
@@ -128,8 +128,12 @@ export class MissionComp extends CCComp {
PhaseTime: Timer = new Timer(1)
/** 回合间倒计时(秒) */
private waveCountdown: number = 0;
/** 回合间倒计时总时长(秒 */
private readonly WAVE_COUNTDOWN_DURATION: number = 5;
/** 倒计时档位:快清场(压缩垃圾时间,奖励强 build */
private static readonly COUNTDOWN_FAST = 2.5;
/** 倒计时档位:普通 */
private static readonly COUNTDOWN_NORMAL = 4.0;
/** 倒计时档位:有英雄死亡(保留满运营窗口:调整阵型/复活/买卡) */
private static readonly COUNTDOWN_FULL = 5.0;
/** 上一次显示的时间字符串(避免重复设置) */
private lastTimeStr: string = "";
/** 上一次显示的秒数(避免重复计算) */
@@ -160,6 +164,12 @@ export class MissionComp extends CCComp {
private currentWave: number = 0;
/** 是否为Boss回合 */
private isBossWave: boolean = false;
/** 超时回合已在 onBattleTimeout 预扣留存分BattleEnd 需跳过重复累加 */
private skipRemainScoreOnBattleEnd: boolean = false;
/** 上回合英雄死亡数快照BattleEnd 写入,供下一回合倒计时档位判定) */
private lastWaveDeathCount: number = 0;
/** 上回合计时口径清场时间快照(含清场加速还原,供下一回合倒计时档位判定) */
private lastWaveClearTime: number = 0;
/** 当前任务阶段 */
public currentPhase: MissionPhase = MissionPhase.None;
/** 是否处于回合间倒计时状态 */
@@ -248,6 +258,12 @@ export class MissionComp extends CCComp {
smc.vmdata.mission_data.fight_time += dt
this.clearTime += dt
this.update_time();
// 回合超时兜底:僵局(坦克 vs 高血 Boss 互磨不死时强制收束回合Boss 回合阈值放宽
const timeout = this.isBossWave ? WAVE_TIMEOUT_BOSS : WAVE_TIMEOUT;
if (this.clearTime >= timeout) {
this.onBattleTimeout();
}
}
}
@@ -275,13 +291,20 @@ export class MissionComp extends CCComp {
// ======================== 回合倒计时 ========================
/** 进入回合间倒计时:重置倒计时并显示提示 */
/** 进入回合间倒计时:按上回合战况分档(快清场压缩垃圾时间,有死亡保留运营窗口) */
private startWaveCountdown() {
this.waveCountdown = this.WAVE_COUNTDOWN_DURATION;
this.waveCountdown = this.computeCountdown();
this.isWaveCountdown = true;
this.updateCountdownUI(true);
}
/** 计算下一回合倒计时档位:读 BattleEnd 写入的快照onNewWave 会清零 clearTime不能现读 */
private computeCountdown(): number {
if (this.lastWaveDeathCount > 0) return MissionComp.COUNTDOWN_FULL;
if (this.lastWaveClearTime > 0 && this.lastWaveClearTime < WAVE_DURATION * 0.65) return MissionComp.COUNTDOWN_FAST;
return MissionComp.COUNTDOWN_NORMAL;
}
/** 更新倒计时 UI显示剩余秒数 */
private updateCountdownUI(force: boolean = false) {
if (!this.isWaveCountdown) return;
@@ -491,12 +514,21 @@ export class MissionComp extends CCComp {
case MissionPhase.PrepareEnd:
// 不隐藏开始按钮
// 开闸刷怪MissionMonComp 在本阶段启动分批释放,需解除暂停标志让第一批按时出场
smc.mission.stop_spawn_mon = false;
oops.message.dispatchEvent("PhasePrepareEnd");
break;
case MissionPhase.BattleStart:
// 触发战斗开始技能fstart
this.triggerHeroBattleSkills(true);
// Boss 回合预警战斗正式开始、Boss 压轴进场(最后一批),提前给 UI 表现窗口
if (this.isBossWave) {
oops.message.dispatchEvent(GameEvent.BossWarning, {
wave: this.currentWave,
eta: BATCH_INTERVAL * (BATCH_COUNT - 1),
});
}
break;
case MissionPhase.Battle:
@@ -520,7 +552,11 @@ export class MissionComp extends CCComp {
// 【评分系统 - 战绩分】每回合胜利加分
smc.vmdata.scores.wave_win_count++;
// 【评分系统 - 战绩分】记录每回合结束时场上留存的敌人数量(扣分项)
smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num;
// 超时回合已在 onBattleTimeout 预扣,跳过防重
if (!this.skipRemainScoreOnBattleEnd) {
smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num;
}
this.skipRemainScoreOnBattleEnd = false;
let allAlive = true;
let hasHero = false;
@@ -537,8 +573,15 @@ export class MissionComp extends CCComp {
});
// 【动态难度调节】根据本回合战况自动放水 / 加压
DynamicTuner.adjust(this.clearTime, heroDeathCount);
mLogger.log(this.debugMode, 'MissionComp', `[DynamicTuner] wave=${this.currentWave} clearTime=${this.clearTime.toFixed(1)}s deaths=${heroDeathCount} factor=${DynamicTuner.factor.toFixed(2)}`);
// 清场加速节省的时间还原进口径,避免"打得好"被节奏加速+强度加压双重惩罚
const effectiveClearTime = this.clearTime + smc.vmdata.mission_data.wave_early_skip;
const tuned = DynamicTuner.adjust(effectiveClearTime, heroDeathCount);
if (tuned) {
mLogger.log(this.debugMode, 'MissionComp', `[DynamicTuner] wave=${this.currentWave} effClear=${effectiveClearTime.toFixed(1)}s deaths=${heroDeathCount} factor=${DynamicTuner.factor.toFixed(3)}`);
}
// 快照供下一回合倒计时档位判定onNewWave 会清零 clearTime必须提前快照
this.lastWaveDeathCount = heroDeathCount;
this.lastWaveClearTime = effectiveClearTime;
// 【评分系统 - 战绩分】记录全员存活的胜利回合数(额外加分)
if (hasHero && allAlive) {
smc.vmdata.scores.wave_all_alive_count++;
@@ -553,7 +596,7 @@ export class MissionComp extends CCComp {
// 触发战斗结束技能fend
this.triggerHeroBattleSkills(false);
// 战斗结束阶段,给予所有英雄恢复70%血量的技能效果
// 战斗结束阶段,按 FightSet.WAVE_HEAL_RATE 恢复所有英雄血量
this.healAllHeroes();
// 【新增】派发每回合战斗结束事件,供卡牌技能监听(区别于整局结束的 MissionEnd
@@ -674,7 +717,7 @@ export class MissionComp extends CCComp {
}
/**
* 战斗结束阶段治疗所有英雄(包括墓地英雄),恢复70%最大生命值
* 战斗结束阶段治疗所有英雄(包括墓地英雄),按 FightSet.WAVE_HEAL_RATE 恢复最大生命值比例
*/
private healAllHeroes() {
const healRateBoost = FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.WaveHeal);
@@ -788,6 +831,9 @@ export class MissionComp extends CCComp {
this.currentPhase = MissionPhase.None;
this.currentWave = 1;
this.isBossWave = false;
this.skipRemainScoreOnBattleEnd = false;
this.lastWaveDeathCount = 0;
this.lastWaveClearTime = 0;
this.rewards = []
this.revive_times = 1;
this.lastTimeStr = "";
@@ -901,6 +947,16 @@ export class MissionComp extends CCComp {
// 20 回合通关
this.open_Victory(null, false);
} else {
// oops.message 全局事件总线:清屏庆祝(横幅/奖励由 BattleBannerComp 消费需在推进回合前派发clearTime 归零前取值)
const allAlive = this.checkAllHeroAlive();
const fastClear = this.clearTime < WAVE_DURATION * 0.5 ? 2
: this.clearTime < WAVE_DURATION * 0.75 ? 1 : 0;
oops.message.dispatchEvent(GameEvent.WaveClear, {
wave: this.currentWave,
clearTime: this.clearTime,
allAlive,
fastClear,
});
oops.message.dispatchEvent("TimeUpAdvanceWave");
}
return;
@@ -919,6 +975,20 @@ export class MissionComp extends CCComp {
if (monsterCount >= max) smc.mission.stop_spawn_mon = true;
}
/** 检测场上英雄是否全员存活(清屏 Perfect 判定用,独立于评分统计逻辑) */
private checkAllHeroAlive(): boolean {
let hasHero = false;
let allAlive = true;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.HERO) {
hasHero = true;
if (attrs.is_dead) allAlive = false;
}
});
return hasHero && allAlive;
}
/**
* 英雄全灭检测:若场上无存活英雄且处于战斗中,触发结算弹窗。
* @param heroCount 当前存活英雄数量
@@ -930,6 +1000,36 @@ export class MissionComp extends CCComp {
this.open_Victory(null, true);
}
/**
* 回合超时强制结束(僵局兜底):
* 1. 预扣留存分(残留怪销毁后 mon_num 会被下一次同步清零BattleEnd 的累加会漏扣,故在此显式预扣并置防重标志)。
* 2. 销毁全部残留怪(不触发 MonDead、不掉金币——超时是对"清不掉"的惩罚)。
* 3. 复用正常回合推进流DynamicTuner 因 clearTime 远超阈值会自动放水,无需特殊处理。
*/
private onBattleTimeout() {
// 防重入:同一回合只触发一次
if (this.currentPhase !== MissionPhase.Battle) return;
smc.vmdata.scores.wave_remain_monsters += smc.vmdata.mission_data.mon_num;
this.skipRemainScoreOnBattleEnd = true;
ecs.query(this.heroAttrsMatcher).forEach(entity => {
const attrs = entity.get(HeroAttrsComp);
if (attrs && attrs.fac === FacSet.MON && !attrs.is_dead) {
entity.destroy();
}
});
smc.vmdata.mission_data.mon_num = 0;
mLogger.log(this.debugMode, 'MissionComp', `[WaveTimeout] wave=${this.currentWave} 超时强制结束`);
if (this.currentWave >= MAX_WAVE) {
this.open_Victory(null, false);
} else {
oops.message.dispatchEvent("TimeUpAdvanceWave");
}
}
// ======================== 清理 ========================
/** 清理所有英雄和技能 ECS 实体 */

View File

@@ -22,7 +22,7 @@ import { Monster } from "../hero/Mon";
import { smc } from "../common/SingletonModuleComp";
import { GameEvent } from "../common/config/GameEvent";
import { BoxSet, FacSet } from "../common/config/GameSet";
import { spawningEngine, GeneratedMonster, TestModeConfig, MAX_MONSTERS, BATCH_COUNT, BATCH_INTERVAL } from "./RogueConfig";
import { spawningEngine, GeneratedMonster, TestModeConfig, MAX_MONSTERS, BATCH_COUNT, BATCH_INTERVAL, getWaveType, SPAWN_INTERVAL_BY_TYPE } from "./RogueConfig";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { MonMoveComp } from "../hero/MonMoveComp";
@@ -37,7 +37,7 @@ export class MissionMonCompComp extends CCComp {
private static readonly MON_DROP_HEIGHT = 0;
/** 怪物统一从右侧 X=400 出生点逐个刷出,随后向左推进 */
private static readonly MON_SPAWN_X = 400;
/** 逐个刷怪间隔(秒):保证怪物排成纵队,避免堆叠 */
/** 逐个刷怪默认间隔(秒):保证怪物排成纵队,避免堆叠;运行时按回合类型查 SPAWN_INTERVAL_BY_TYPE 覆盖 */
private static readonly MON_SPAWN_INTERVAL = 0.3;
/**
@@ -88,6 +88,20 @@ export class MissionMonCompComp extends CCComp {
private spawnQueue: GeneratedMonster[] = [];
/** 逐个刷怪累计计时(秒) */
private spawnTimer: number = 0;
/** 当前回合的逐个刷怪间隔(秒),按回合类型查 SPAWN_INTERVAL_BY_TYPE */
private spawnInterval: number = MissionMonCompComp.MON_SPAWN_INTERVAL;
/** 当前批已刷出的怪物数(清场加速存活比例分母) */
private batchReleasedCount: number = 0;
/** 当前批是否已通过清场加速提前推进过(每批只触发一次) */
private batchFastForwarded: boolean = false;
/** 清场加速检测节流计时器(秒) */
private aliveCheckTimer: number = 0;
/** 本回合因清场加速累计节省的秒数(供 MissionComp 还原 DDA 判定口径,避免双重惩罚) */
public waveEarlySkipTotal: number = 0;
/** 清场加速触发阈值当前批存活比例低于此值时提前推进0.25 = 清掉 75% */
private static readonly BATCH_EARLY_RATIO = 0.25;
/** 清场加速提前量(秒):等效 batchTimer 快进,保底批间隔不被瞬间叠爆 */
private static readonly BATCH_EARLY_SKIP = 2.0;
// ======================== 生命周期 ========================
@@ -105,6 +119,10 @@ export class MissionMonCompComp extends CCComp {
}
smc.vmdata.mission_data.pending_mon_num = pendingCount;
// 场上怪物超阈值时暂停释放:冻结批次推进与逐个刷出计时(不推进计时器,恢复后从断点平滑续接)
// 注意 pending 统计必须在 return 之前执行保证回合结束检测pending==0语义不受暂停影响
if (smc.mission.stop_spawn_mon) return;
// 分批释放:按 BATCH_INTERVAL 节奏推进批次
if (this.isReleasing) {
this.batchTimer += dt;
@@ -112,12 +130,19 @@ export class MissionMonCompComp extends CCComp {
this.batchTimer = 0;
this.advanceBatch();
}
// 清场加速0.2s 节流检测当前批存活比例,清得快则快进批次计时(学 PvZ 血量阈值提前刷新)
this.aliveCheckTimer += dt;
if (this.aliveCheckTimer >= 0.2) {
this.aliveCheckTimer = 0;
this.checkBatchEarlyAdvance();
}
}
// 逐个刷怪:按 MON_SPAWN_INTERVAL 节奏从队列释放
// 逐个刷怪:按 spawnInterval 节奏从队列释放
if (this.spawnQueue.length > 0) {
this.spawnTimer += dt;
if (this.spawnTimer >= MissionMonCompComp.MON_SPAWN_INTERVAL) {
if (this.spawnTimer >= this.spawnInterval) {
this.spawnTimer = 0;
const monData = this.spawnQueue.shift()!;
const targetPosIndex = this.waveSpawnedCount % MissionMonCompComp.MON_POSITIONS.length;
@@ -127,7 +152,7 @@ export class MissionMonCompComp extends CCComp {
}
}
start() {}
start() { }
private setupWaveData(monsters: GeneratedMonster[]) {
// 按批次分组
@@ -167,6 +192,10 @@ export class MissionMonCompComp extends CCComp {
this.isReleasing = false;
this.spawnQueue = [];
this.spawnTimer = 0;
this.batchReleasedCount = 0;
this.batchFastForwarded = false;
this.aliveCheckTimer = 0;
this.waveEarlySkipTotal = 0;
// 预生成第一回合数据以获取数量和 Boss 信息
const monsters = spawningEngine.generateWave(this.currentWave);
@@ -195,6 +224,9 @@ export class MissionMonCompComp extends CCComp {
private onPhasePrepareEnd() {
this.resetSlotSpawnData();
// 按回合类型确定本回合刷怪间隔(放松回合快速倾泻,压力回合稍慢聚焦)
this.spawnInterval = SPAWN_INTERVAL_BY_TYPE[getWaveType(this.currentWave)] ?? MissionMonCompComp.MON_SPAWN_INTERVAL;
// 准备结束阶段:启动分批释放,
// 第一批立即转入 spawnQueue后续批次由 update 按 BATCH_INTERVAL 推进。
this.startBatchRelease();
@@ -238,10 +270,39 @@ export class MissionMonCompComp extends CCComp {
for (const m of batch) {
this.spawnQueue.push(m);
}
this.batchReleasedCount = batch.length;
this.batchFastForwarded = false;
batch.length = 0;
// 让首个怪物在下一帧立即刷出,避免额外延迟
this.spawnTimer = MissionMonCompComp.MON_SPAWN_INTERVAL;
this.spawnTimer = this.spawnInterval;
}
/**
* 清场加速检测:当前批已放完且存活比例 ≤ BATCH_EARLY_RATIO 时,快进批次计时器。
* 只奖励"清得快"的 build压缩批间垃圾时间弱 build 清不完则不触发、不叠加压力。
*/
private checkBatchEarlyAdvance() {
if (this.batchFastForwarded) return;
if (this.currentBatch >= BATCH_COUNT - 1) return;
if (this.spawnQueue.length > 0) return; // 本批还没放完,不判断
if (this.batchReleasedCount <= 0) return;
let alive = 0;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach(e => {
const a = e.get(HeroAttrsComp);
if (a && a.fac === FacSet.MON && !a.is_dead) alive++;
});
// alive 含前几批残留,比例 clamp 防低估
const aliveRatio = Math.min(1, alive / this.batchReleasedCount);
if (aliveRatio <= MissionMonCompComp.BATCH_EARLY_RATIO) {
this.batchFastForwarded = true;
this.batchTimer += MissionMonCompComp.BATCH_EARLY_SKIP;
this.waveEarlySkipTotal += MissionMonCompComp.BATCH_EARLY_SKIP;
smc.vmdata.mission_data.wave_early_skip = this.waveEarlySkipTotal;
mLogger.log(this.debugMode, 'MissionMonComp',
`[EarlyAdvance] batch=${this.currentBatch} alive=${alive}/${this.batchReleasedCount},快进 ${MissionMonCompComp.BATCH_EARLY_SKIP}s`);
}
}
// ======================== 槽位管理 ========================
@@ -264,6 +325,11 @@ export class MissionMonCompComp extends CCComp {
this.isReleasing = false;
this.currentBatch = 0;
this.batchTimer = 0;
this.batchReleasedCount = 0;
this.batchFastForwarded = false;
this.aliveCheckTimer = 0;
this.waveEarlySkipTotal = 0;
smc.vmdata.mission_data.wave_early_skip = 0;
}
// ======================== 怪物生成 ========================
@@ -292,6 +358,11 @@ export class MissionMonCompComp extends CCComp {
mon.load(spawnPos, scale, monData.uuid, monData.isBoss, landingY, monLv, posIndex);
// oops.message 全局事件总线Boss 登场(震屏/音效由表现层消费)
if (monData.isBoss) {
oops.message.dispatchEvent(GameEvent.BossSpawn, { pos: spawnPos.clone() });
}
const move = mon.get(MonMoveComp);
if (move) {
move.spawnOrder = this.globalSpawnOrder;
@@ -307,5 +378,5 @@ export class MissionMonCompComp extends CCComp {
}
/** ECS 组件移除时触发 */
reset() {}
reset() { }
}

View File

@@ -9,18 +9,19 @@
* 4. MonSkillSet - 怪物技能池atking / atked / dead 等全触发类型)
* 5. RogueSpawningEngine - 生成引擎(按英雄强度反推怪物强度)
*
* 核心公式:
* 核心公式(最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul
* heroPower = Σ calcHeroPower(HeroInfo[uuid], lv) (场上存活英雄)
* targetPower = heroPower × 回合类型系数 × wave.power_adjust × DynamicTuner.factor
* scale = targetPower ÷ Σ 怪物基础强度
* 每只怪: hp ×= scale, ap ×= scale
* hpScale = targetPower × wave.hp_mul ÷ Σ 怪物基础强度
* apScale = targetPower × wave.ap_mul ÷ Σ 怪物基础强度
* 每只怪: hp ×= hpScale, ap ×= apScale
*
* 回合节奏:
* - 最大 20 回合,第 20 回合通关
* - 每回合 30 秒,固定分 3 批,每 10 秒释放一批
* - 普通回合 18~36 只,放松回合 × 1.5 = 27~54 只
* - wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少
* - wave % 5 === 1 → 放松回合(数量 × 1.5,强度低,爽快清屏)
* - wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,爽快清屏,大战前夜收割补给
*/
import { HeroInfo, MonType, MonTypeName, calcHeroPower, TriggerGrouped, LvReviveEntry, heroInfo } from "../common/config/heroSet";
@@ -35,7 +36,7 @@ import { HeroAttrsComp } from "../hero/HeroAttrsComp";
export enum WaveType {
Normal = 0, // 普通回合
Pressure = 1, // 压力回合wave % 5 === 0必带 Boss
Relax = 2, // 放松回合wave % 5 === 1,量大强度低)
Relax = 2, // 放松回合wave % 5 === 4,量大强度低,大战前夜的收割补给
}
/** 回合类型名称 */
@@ -70,6 +71,28 @@ export const BATCH_INTERVAL = WAVE_DURATION / BATCH_COUNT;
/** 每回合怪物硬上限(放松回合 36 × 1.5 = 54 */
export const MAX_MONSTERS = 54;
/** Boss 护卫队数量(与 Boss 同批压轴进场,占用回合总名额) */
export const BOSS_GUARD_COUNT = 3;
/** 批次怪物数量占比(铺垫 → 加压 → 高潮,第三批另有收尾小队加压) */
export const BATCH_RATIO: number[] = [0.25, 0.35, 0.40];
/** 收尾高潮批额外补入的最强小队数量(非放松回合生效) */
export const FINALE_SQUAD_COUNT = 2;
/** 按回合类型的逐个刷怪间隔(秒):放松回合快速倾泻造潮水感,压力回合稍慢便于聚焦 */
export const SPAWN_INTERVAL_BY_TYPE: Record<WaveType, number> = {
[WaveType.Normal]: 0.18,
[WaveType.Pressure]: 0.25,
[WaveType.Relax]: 0.12,
};
/** 回合战斗超时超过后强制结束回合残留怪销毁并扣留存分DynamicTuner 因 clearTime 过大自动放水) */
export const WAVE_TIMEOUT = 75;
/** Boss 回合超时Boss 压轴进场(第 20 秒),击杀耗时更长,放宽兜底阈值 */
export const WAVE_TIMEOUT_BOSS = 90;
/**
* 获取指定回合的回合类型
* @param wave 回合1 起)
@@ -77,7 +100,7 @@ export const MAX_MONSTERS = 54;
*/
export function getWaveType(wave: number): WaveType {
if (wave % 5 === 0) return WaveType.Pressure;
if (wave % 5 === 1) return WaveType.Relax;
if (wave % 5 === 4) return WaveType.Relax; // 大战前夜的收割补给:清杂攒金币备战 Boss
return WaveType.Normal;
}
@@ -88,42 +111,66 @@ export function getWaveType(wave: number): WaveType {
* 与硬编码系数并存,用于根据战况实时微调难度。
*
* 用法示例MissionComp 每回合结束时调用):
* DynamicTuner.adjust(clearTime, heroDeathCount);
* DynamicTuner.adjust(effectiveClearTime, heroDeathCount);
*
* 调节规则(内部硬编码
* - 清场时间 < 20s 且无英雄死亡 → factor += 0.05(加压)
* - 清场时间 > 28s 或有英雄死亡 → factor -= 0.05(放水)
* - factor 范围钳制 [0.5, 2.0]
* 设计原则(真隐形 DDA
* - 连续映射desired = 1 + (0.8 - clearTime/WAVE_DURATION) × K清场越快要价越高非离散跳变
* - 滞回:连续同方向判定满 HYSTERESIS 回合才生效,偶发超神/崩盘不立即拉阀门
* - 指数靠拢:每回合向 desired 移动 50%,避免突变被玩家察觉
* - 总幅度钳制 [0.7, 1.3]±30%),防止橡皮筋效应
* - 英雄死亡直接锚定 desired=0.8(温和放水),不与慢清场放水叠加
*/
export const DynamicTuner = {
/** 当前难度系数(默认 1.0>1 加压,<1 放水) */
factor: 1.0,
/** 系数下限(最多放水到 50% */
MIN_FACTOR: 0.5,
/** 系数上限(最多加压到 200% */
MAX_FACTOR: 2.0,
/** 单次调节步长 */
STEP: 0.05,
/** 系数下限(最多放水到 70% */
MIN_FACTOR: 0.7,
/** 系数上限(最多加压到 130% */
MAX_FACTOR: 1.3,
/** 连续映射增益clearTime 每偏离基准 100% 时长factor 偏移 K */
K: 0.5,
/** 滞回:连续同方向判定满 N 回合才生效 */
HYSTERESIS: 2,
/** 连续方向计数(>0 加压倾向,<0 放水倾向) */
streak: 0,
/**
* 根据上一回合战况自动调节难度
* @param clearTime 清场耗时(秒)
* @param clearTime 清场耗时(秒,已含清场加速提前量的还原口径
* @param heroDeathCount 英雄死亡数
* @returns 本回合是否实际调整了 factor
*/
adjust(clearTime: number, heroDeathCount: number): void {
if (heroDeathCount > 0 || clearTime > WAVE_DURATION * 0.95) {
// 有英雄死亡或清场过慢 → 放水
this.factor = Math.max(this.MIN_FACTOR, this.factor - this.STEP);
} else if (clearTime < WAVE_DURATION * 0.65 && heroDeathCount === 0) {
// 清场过快且无死亡 → 加压
this.factor = Math.min(this.MAX_FACTOR, this.factor + this.STEP);
adjust(clearTime: number, heroDeathCount: number): boolean {
// 1) 连续映射期望系数:基准 0.8×时长不动,更快加压、更慢放水;死亡锚定 0.8
let desired: number;
if (heroDeathCount > 0) {
desired = 1 + (0.8 - 1.2) * this.K; // = 0.8
} else {
desired = 1 + (0.8 - clearTime / WAVE_DURATION) * this.K;
}
desired = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, desired));
// 2) 滞回:连续同方向满 HYSTERESIS 回合才向 desired 靠拢
const dir = Math.sign(desired - 1);
if (dir === 0) {
this.streak = 0;
return false;
}
this.streak = (Math.sign(this.streak) === dir) ? this.streak + dir : dir;
if (Math.abs(this.streak) < this.HYSTERESIS) return false;
// 3) 指数靠拢:每回合向 desired 移动 50%,避免跳变
const old = this.factor;
this.factor = old + (desired - old) * 0.5;
this.factor = Math.min(this.MAX_FACTOR, Math.max(this.MIN_FACTOR, this.factor));
return this.factor !== old;
},
/** 重置调节器(每局开始时调用) */
reset(): void {
this.factor = 1.0;
this.streak = 0;
},
};
@@ -195,6 +242,8 @@ export const SquadLibrary: Record<string, SquadConfig> = {
long_line: { id: "long_line", name: "远程线列组", weight: 7, slots: [{ type: MonType.Long, count: 2 }, { type: MonType.Support, count: 1 }] },
heavy_shield: { id: "heavy_shield", name: "重盾堡垒组", weight: 5, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, count: 2 }] },
summoner_cult: { id: "summoner_cult", name: "召唤教派组", weight: 4, slots: [{ type: MonType.Summoner, count: 1 }, { type: MonType.Long, count: 2 }] },
/** Boss 护卫队weight=0 不进随机池,仅供引擎在 Boss 回合直接引用,与 Boss 同批压轴进场) */
boss_guard: { id: "boss_guard", name: "Boss 护卫队", weight: 0, slots: [{ type: MonType.Heavy, count: 1 }, { type: MonType.Melee, count: 2 }] },
};
// ======================== 5. 怪物技能池 ========================
@@ -309,9 +358,9 @@ export interface WaveConfig {
base_count: number;
/** 可选小队 id 池,引擎按权重抽取拼装到 base_count */
squad_pool: string[];
/** HP 强化倍率(硬编码,逐回合递进 */
/** HP 成长乘区(并入强度缩放分子,终值 = heroPower × 系数 × hp_mul ÷ Σ基础强度 */
hp_mul: number;
/** AP 强化倍率(硬编码,逐回合递进 */
/** AP 成长乘区(同 hp_mul独立控制怪物肉度与输出的成长比例 */
ap_mul: number;
/** 强度微调(放水 / 加压,默认 1.0 */
power_adjust?: number;
@@ -328,7 +377,7 @@ export interface WaveConfig {
*
* 心流循环5 回合一循环):
* wave % 5 === 0 → 压力回合(必带 Boss强度高、数量少
* wave % 5 === 1 → 放松回合(数量 × 1.5,强度低)
* wave % 5 === 4 → 放松回合(数量 × 1.5,强度低,大战前夜收割补给
* 其余 → 普通回合(标准强度)
*
* 强度递进hp_mul / ap_mul 每 5 回合一档,压力回合额外提升。
@@ -338,34 +387,35 @@ export const WaveConfigs: Record<number, WaveConfig> = {
1: { base_count: 18, 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 },
3: { base_count: 24, squad_pool: ["melee_grunt", "mixed_balanced", "long_line"], hp_mul: 1.05, ap_mul: 1.00 },
// 放松回合量大好清Boss 前收割补给
4: { base_count: 27, squad_pool: ["mixed_balanced", "long_line", "heavy_shield"], hp_mul: 1.10, ap_mul: 1.05 },
// 压力回合:第一 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"] },
// ===== 第二循环:引入技能怪 =====
// 放松回合:量大好清
6: { base_count: 30, 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"] },
8: { base_count: 33, squad_pool: ["assassin_squad", "mixed_balanced", "summoner_cult"], hp_mul: 1.20, ap_mul: 1.15, skill_pool: ["berserk"] },
// 放松回合量大好清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"] },
// 压力回合:第二 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"] },
// ===== 第三循环:组合多样化 =====
// 放松回合
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"] },
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"] },
13: { base_count: 36, squad_pool: ["assassin_squad", "summoner_cult", "mixed_balanced"], hp_mul: 1.35, ap_mul: 1.30, skill_pool: ["berserk", "leech"] },
// 放松回合量大好清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"] },
// 压力回合:第三 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"] },
// ===== 第四循环:终极阶段 =====
// 放松回合
// ===== 第四循环:终极阶段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"] },
17: { base_count: 36, squad_pool: ["melee_grunt", "summoner_cult", "mixed_balanced"], hp_mul: 1.50, ap_mul: 1.45, skill_pool: ["berserk", "legacy"] },
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"] },
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"] },
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 },
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 },
// 放松回合:量大好清,最终 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 },
// 压力回合:最终 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"] },
};
@@ -405,6 +455,9 @@ export function validateRogueConfig(): string[] {
if (cfg.base_count < 1 || cfg.base_count > MAX_MONSTERS) {
errors.push(`Wave ${wave} base_count=${cfg.base_count} 越界 (1~${MAX_MONSTERS})`);
}
if (cfg.power_adjust !== undefined && (cfg.power_adjust < 0.8 || cfg.power_adjust > 1.3)) {
errors.push(`Wave ${wave} power_adjust=${cfg.power_adjust} 越界 (0.8~1.3)`);
}
}
// 2. 校验 SquadLibrary 中所有 type 在 MonList 中有怪
@@ -429,6 +482,8 @@ export interface GeneratedMonster {
hp: number;
ap: number;
isBoss: boolean;
/** 是否为 Boss 护卫队成员(与 Boss 同批压轴进场,供 UI/统计识别) */
isBossGuard?: boolean;
spawnIndex: number;
/** 本怪所属批次0~2由 MissionMonComp 按 BATCH_INTERVAL 释放) */
batch: number;
@@ -499,42 +554,59 @@ export class RogueSpawningEngine {
const powerAdjust = cfg.power_adjust ?? 1.0;
const targetPower = heroPower * typeRatio * powerAdjust * DynamicTuner.factor;
// 2. 确定怪物总数(放松回合 × 1.5
// 2. 确定怪物总数(放松回合 × 1.5;普通/压力回合预留收尾高潮批名额,防 slice 截掉
let totalCount = cfg.base_count;
if (waveType === WaveType.Relax) {
totalCount = Math.round(totalCount * RELAX_COUNT_MUL);
} else {
totalCount += this.estimateFinaleCount(cfg.squad_pool);
}
totalCount = Math.min(totalCount, MAX_MONSTERS);
// 3. Boss 位(压力回合必带 Boss,占 1 个名额)
const monsters: GeneratedMonster[] = [];
// 3. Boss 位(压力回合必带 Boss):先记录延后挂载,使其压轴进场而非第 0 秒开场
let boss: GeneratedMonster | null = null;
let remaining = totalCount;
if (cfg.boss_wave) {
monsters.push(this.makeBoss(wave, cfg));
remaining -= 1;
boss = this.makeBoss(wave, cfg);
remaining -= 1 + BOSS_GUARD_COUNT; // Boss 1 只 + 护卫队名额
}
// 4. 小队拼装:按权重从 squad_pool 抽小队,累计到 remaining
const squadMonsters = this.assembleSquads(cfg.squad_pool, remaining, wave);
monsters.push(...squadMonsters);
const monsters: GeneratedMonster[] = this.assembleSquads(cfg.squad_pool, remaining, wave);
// 5. 应用硬编码 HP/AP 倍率
for (const m of monsters) {
m.hp = Math.max(1, Math.round(m.hp * cfg.hp_mul));
m.ap = Math.max(1, Math.round(m.ap * cfg.ap_mul));
// 4.5 收尾高潮批:非放松回合额外补入最强小队,与 Boss 一样压轴(先补入再统一缩放,保证强度自洽)
const finaleSquad = waveType !== WaveType.Relax ? this.pickStrongestSquad(cfg.squad_pool) : null;
if (finaleSquad) {
for (let s = 0; s < FINALE_SQUAD_COUNT; s++) {
for (const slot of finaleSquad.slots) {
for (let c = 0; c < slot.count; c++) {
const m = this.makeMonster(slot.type, wave, 0);
m.batch = BATCH_COUNT - 1; // 标记收尾批,第 8 步不再覆盖
monsters.push(m);
}
}
}
}
// 6. 按英雄强度反推缩放系数
// 4.6 Boss 与护卫队压队尾,使其在批次分配后落入最后一批(回合内高潮点)
if (boss) {
monsters.push(...this.makeBossGuards(wave));
monsters.push(boss);
}
// 5. 按英雄强度反推缩放系数hp_mul/ap_mul 并入目标强度乘区,而非预先乘到怪物上,
// 保证配置表语义单一:最终强度 = heroPower × typeRatio × power_adjust × DDA × hp/ap_mul
const totalBasePower = monsters.reduce((sum, m) => {
const info = HeroInfo[m.uuid];
return sum + (info ? calcHeroPower(info, 1) : m.hp + m.ap);
}, 0);
if (totalBasePower > 0 && targetPower > 0) {
const scale = targetPower / totalBasePower;
const hpScale = (targetPower * cfg.hp_mul) / totalBasePower;
const apScale = (targetPower * cfg.ap_mul) / totalBasePower;
for (const m of monsters) {
m.hp = Math.max(1, Math.round(m.hp * scale));
m.ap = Math.max(1, Math.round(m.ap * scale));
m.hp = Math.max(1, Math.round(m.hp * hpScale));
m.ap = Math.max(1, Math.round(m.ap * apScale));
}
}
@@ -547,9 +619,15 @@ export class RogueSpawningEngine {
}
}
// 8. 分配批次0~2均匀分布
// 8. 分配批次:按 BATCH_RATIO 递增加权(铺垫 → 加压 → 高潮),收尾小队/Boss/护卫保持最后一批
this.assignBatches(monsters);
if (boss) {
const lastBatch = BATCH_COUNT - 1;
for (const m of monsters) {
if (m.isBoss || m.isBossGuard) m.batch = lastBatch;
}
}
for (let i = 0; i < monsters.length; i++) {
monsters[i].batch = i % BATCH_COUNT;
monsters[i].spawnIndex = i;
}
@@ -656,8 +734,8 @@ export class RogueSpawningEngine {
return result;
}
/** 生成 Boss首位 */
private makeBoss(wave: number, cfg: WaveConfig): GeneratedMonster {
/** 生成 Boss压轴位batch/spawnIndex 为占位值,由 generateWave 统一分配并强制最后一批 */
private makeBoss(wave: number, _cfg: WaveConfig): GeneratedMonster {
const isMeleeBoss = Math.random() < 0.5;
const type = isMeleeBoss ? MonType.MeleeBoss : MonType.LongBoss;
@@ -681,13 +759,82 @@ export class RogueSpawningEngine {
hp: Math.round(baseHp * bossBonusHpMul),
ap: baseAp,
isBoss: true,
spawnIndex: 0,
batch: 0, // Boss 固定第一批
spawnIndex: 0, // 占位值,由 generateWave 第 8 步统一分配
batch: 0, // 占位值generateWave 会强制 Boss 进入最后一批
};
}
/** 生成普通怪物 */
private makeMonster(type: MonType, wave: number, spawnIndex: number): GeneratedMonster {
/** 生成 Boss 护卫队(复用 boss_guard 小队模板),与 Boss 同批压轴进场 */
private makeBossGuards(wave: number): GeneratedMonster[] {
const squad = SquadLibrary["boss_guard"];
const guards: GeneratedMonster[] = [];
for (const slot of squad.slots) {
for (let i = 0; i < slot.count; i++) {
const g = this.makeMonster(slot.type, wave, 0);
g.isBossGuard = true;
guards.push(g);
}
}
return guards;
}
/**
* 批次分配:按 BATCH_RATIO 递增加权切分(铺垫 → 加压 → 高潮)。
* 已预标记 batch 的怪(收尾小队 / Boss / 护卫)不参与切分,保持最后一批。
*/
private assignBatches(monsters: GeneratedMonster[]): void {
const normal = monsters.filter(m => m.batch !== BATCH_COUNT - 1 && !m.isBoss && !m.isBossGuard);
const n = normal.length;
if (n === 0) return;
let cursor = 0;
for (let b = 0; b < BATCH_COUNT - 1; b++) {
const quota = Math.round(n * BATCH_RATIO[b]);
for (let k = 0; k < quota && cursor < n; k++, cursor++) {
normal[cursor].batch = b;
}
}
// 剩余全部进入高潮批
for (; cursor < n; cursor++) {
normal[cursor].batch = BATCH_COUNT - 1;
}
}
/** 预估收尾高潮批额外补入的怪物数量(用于 totalCount 预留名额) */
private estimateFinaleCount(pool: string[]): number {
const squad = this.pickStrongestSquad(pool);
if (!squad) return 0;
let per = 0;
for (const slot of squad.slots) per += slot.count;
return per * FINALE_SQUAD_COUNT;
}
/**
* 识别小队池中最强小队:按槽位 MonType 基础强度calcHeroPower 1 级样本)× 数量加权求和。
* 注意不能用 squad.weight——它是"出现频率"语义而非强度。
*/
private pickStrongestSquad(pool: string[]): SquadConfig | null {
let best: SquadConfig | null = null;
let bestScore = -1;
for (const id of pool) {
const sq = SquadLibrary[id];
if (!sq) continue;
let score = 0;
for (const slot of sq.slots) {
const uuids = MonList[slot.type];
const sample = uuids && uuids.length ? HeroInfo[uuids[0]] : null;
score += (sample ? calcHeroPower(sample, 1) : 100) * slot.count;
}
if (score > bestScore) {
bestScore = score;
best = sq;
}
}
return best;
}
/** 生成普通怪物wave 保留参数位,供后续按回合差异化基础属性扩展) */
private makeMonster(type: MonType, _wave: number, spawnIndex: number): GeneratedMonster {
let uuids = MonList[type];
if (!uuids || uuids.length === 0) {
// 兜底 Melee

View File

@@ -0,0 +1,123 @@
/**
* @file WaveHudComp.ts
* @description 波次进度 HUD 组件(表现层)
*
* 职责:
* 1. 本回合清剿进度剩余怪数mon_num + pending_mon_num+ 进度条。
* 2. 全局 20 回合进度格静态生成Boss 回合wave%5==0置旗帜样式当前回合脉动、已过回合置灰。
*
* 数据流NewWave 事件缓存 total/waveupdate 0.2s 降频轮询 vmdata与 syncMonsterSpawnState 同节奏)。
* 使用 oops-framework 模块oops.message事件解耦
*
* 编辑器绑定waveLab / remainLab / progress / flagsRoot水平 Layout 容器20 格代码生成)。
*/
import { _decorator, Node, Label, ProgressBar, Sprite, Color, tween, Tween, v3, UITransform } from "cc";
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { GameEvent } from "../common/config/GameEvent";
import { smc } from "../common/SingletonModuleComp";
import { getWaveType, WaveType, MAX_WAVE } from "./RogueConfig";
const { ccclass, property } = _decorator;
@ccclass('WaveHudComp')
export class WaveHudComp extends CCComp {
@property({ type: Label, tooltip: "回合文本(第 x/20 回合)" })
waveLab: Label | null = null;
@property({ type: Label, tooltip: "剩余怪数文本" })
remainLab: Label | null = null;
@property({ type: ProgressBar, tooltip: "本回合清剿进度条" })
progress: ProgressBar | null = null;
@property({ type: Node, tooltip: "全局回合旗帜容器(水平 Layout20 格代码生成)" })
flagsRoot: Node | null = null;
/** 本回合怪物总数NewWave 事件载荷缓存) */
private waveTotal: number = 0;
/** HUD 刷新节流计时器 */
private hudTimer: number = 0;
/** 20 格旗帜节点缓存Boss 位为旗帜样式) */
private flagNodes: Node[] = [];
onLoad() {
// oops.message 全局事件总线:新回合
oops.message.on(GameEvent.NewWave, this.onNewWave, this);
this.buildFlags();
}
onDestroy() {
oops.message.off(GameEvent.NewWave, this.onNewWave, this);
}
/** 静态生成 20 格回合进度Boss 位按 getWaveType 静态预知,无需运行数据) */
private buildFlags() {
if (!this.flagsRoot) return;
for (let i = 1; i <= MAX_WAVE; i++) {
const cell = new Node(`flag_${i}`);
cell.addComponent(UITransform).setContentSize(14, 14);
const sp = cell.addComponent(Sprite);
// Boss 位用警示色方块,普通位用圆点色(无美术资源时以颜色区分,后续可换 spriteFrame
const isBoss = getWaveType(i) === WaveType.Pressure;
sp.color = isBoss ? new Color(220, 50, 50) : new Color(120, 120, 120);
sp.sizeMode = Sprite.SizeMode.CUSTOM;
cell.parent = this.flagsRoot;
this.flagNodes.push(cell);
}
}
private onNewWave(event: string, data: { wave: number; total: number; bossWave: boolean }) {
this.waveTotal = data.total;
if (this.waveLab) this.waveLab.string = `${data.wave}/${MAX_WAVE} 回合`;
this.refreshFlags(data.wave);
}
/** 刷新旗帜状态:已过回合置暗,当前回合 Boss 旗脉动 */
private refreshFlags(currentWave: number) {
for (let i = 0; i < this.flagNodes.length; i++) {
const cell = this.flagNodes[i];
const wave = i + 1;
const sp = cell.getComponent(Sprite)!;
const isBoss = getWaveType(wave) === WaveType.Pressure;
if (wave < currentWave) {
sp.color = new Color(70, 70, 70); // 已过回合置暗
cell.setScale(v3(1, 1, 1));
Tween.stopAllByTarget(cell);
} else if (wave === currentWave) {
sp.color = isBoss ? new Color(255, 80, 80) : new Color(255, 220, 100);
if (isBoss) this.playFlagPulse(cell);
} else {
sp.color = isBoss ? new Color(220, 50, 50) : new Color(120, 120, 120);
cell.setScale(v3(1, 1, 1));
Tween.stopAllByTarget(cell);
}
}
}
/** 当前回合 Boss 旗缩放脉动 */
private playFlagPulse(cell: Node) {
Tween.stopAllByTarget(cell);
tween(cell)
.to(0.5, { scale: v3(1.4, 1.4, 1) })
.to(0.5, { scale: v3(1, 1, 1) })
.union()
.repeatForever()
.start();
}
protected update(dt: number) {
this.hudTimer += dt;
if (this.hudTimer < 0.2) return;
this.hudTimer = 0;
const md = smc.vmdata.mission_data;
const remain = (md.mon_num || 0) + (md.pending_mon_num || 0);
if (this.remainLab) this.remainLab.string = `剩余 ${remain}`;
if (this.progress) {
this.progress.progress = this.waveTotal > 0
? Math.min(1, Math.max(0, (this.waveTotal - remain) / this.waveTotal))
: 0;
}
}
}