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:
45
assets/script/game/common/ScreenShake.ts
Normal file
45
assets/script/game/common/ScreenShake.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,//英雄可拓展上限
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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})`);
|
||||
}
|
||||
|
||||
143
assets/script/game/map/BattleBannerComp.ts
Normal file
143
assets/script/game/map/BattleBannerComp.ts
Normal 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(音效)。
|
||||
* 编辑器绑定:bannerNode(Label+背景节点,初始 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();
|
||||
}
|
||||
}
|
||||
101
assets/script/game/map/CoinFlyComp.ts
Normal file
101
assets/script/game/map/CoinFlyComp.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
72
assets/script/game/map/ComboComp.ts
Normal file
72
assets/script/game/map/ComboComp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 实体 */
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
123
assets/script/game/map/WaveHudComp.ts
Normal file
123
assets/script/game/map/WaveHudComp.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @file WaveHudComp.ts
|
||||
* @description 波次进度 HUD 组件(表现层)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 本回合清剿进度:剩余怪数(mon_num + pending_mon_num)+ 进度条。
|
||||
* 2. 全局 20 回合进度格:静态生成,Boss 回合(wave%5==0)置旗帜样式,当前回合脉动、已过回合置灰。
|
||||
*
|
||||
* 数据流:NewWave 事件缓存 total/wave;update 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: "全局回合旗帜容器(水平 Layout,20 格代码生成)" })
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user