diff --git a/assets/resources/gui/element/victory.prefab b/assets/resources/gui/element/victory.prefab index 5a6607f5..a5585922 100644 --- a/assets/resources/gui/element/victory.prefab +++ b/assets/resources/gui/element/victory.prefab @@ -1174,7 +1174,7 @@ }, "_contentSize": { "__type__": "cc.Size", - "width": 164, + "width": 124, "height": 54.4 }, "_anchorPoint": { @@ -1210,7 +1210,7 @@ "b": 255, "a": 255 }, - "_string": "再来一局", + "_string": "下一关", "_horizontalAlign": 1, "_verticalAlign": 1, "_actualFontSize": 40, diff --git a/assets/script/game/common/SingletonModuleComp.ts b/assets/script/game/common/SingletonModuleComp.ts index fa2d9082..e30721c3 100644 --- a/assets/script/game/common/SingletonModuleComp.ts +++ b/assets/script/game/common/SingletonModuleComp.ts @@ -46,6 +46,12 @@ export interface GameDate { talent_lv?: number, /** 天赋当前等级已累计的经验值 */ talent_exp?: number, + /** 关卡进度存档:当前章节-关-节 */ + stage_progress?: { + chapter: number, + stage: number, + section: number, + }, } export interface CloudData { openid: string; @@ -145,6 +151,12 @@ export class SingletonModuleComp extends ecs.Comp { talent_lv: 0, /** 当前等级已累计经验 */ talent_exp: 0, + /** 关卡进度存档:默认从 1-1-1 开始 */ + stage_progress: { + chapter: 1, + stage: 1, + section: 1, + }, } vmdata: any = { diff --git a/assets/script/game/common/config/GameEvent.ts b/assets/script/game/common/config/GameEvent.ts index 8f3e775c..0d92ae1b 100644 --- a/assets/script/game/common/config/GameEvent.ts +++ b/assets/script/game/common/config/GameEvent.ts @@ -100,5 +100,15 @@ export enum GameEvent { HERO_GROWTH_UPDATE = "HERO_GROWTH_UPDATE", /** 天赋数据变更(等级/点数更新,payload 为空,UI 重新读取 smc.data.talents / talent_points) */ TALENT_UPDATE = "TALENT_UPDATE", + /** 小节开始(payload: { chapter, stage, section },MissionMonComp 据此生成怪物) */ + SectionStart = "SectionStart", + /** 小节结束(payload: { chapter, stage, section },玩家点击"下一节"后推进) */ + SectionEnd = "SectionEnd", + /** 大关结束(payload: { chapter, stage },10 节打完) */ + StageEnd = "StageEnd", + /** 章节结束(payload: { chapter },10 关打完) */ + ChapterEnd = "ChapterEnd", + /** 结算弹窗点击"下一节"(payload 为空,MissionComp 据此推进到下一小节) */ + NextSection = "NextSection", } diff --git a/assets/script/game/common/config/StageSet.ts b/assets/script/game/common/config/StageSet.ts new file mode 100644 index 00000000..b8a99e5d --- /dev/null +++ b/assets/script/game/common/config/StageSet.ts @@ -0,0 +1,255 @@ +/** + * @file StageSet.ts + * @description 关卡系统配置(章节-关-节三级结构) + * + * 设计要点: + * - 1 章 = 10 关,每关 = 10 小节,共 100 个小节 + * - 第 5 关为精英关,第 10 关为 Boss 关,其余为普通关 + * - 怪物等级 = 关卡号(1-1 的怪为 Lv1,1-5 的怪为 Lv5) + * - 怪物属性 = 基础 × 开局系数 × (1 + 成长斜率 × (lv-1)) × 类型倍率 + * + * 数值基准: + * - 开局系数 0.8(1 级小怪略弱于英雄) + * - 成长斜率 +5%/级(10 级 ≈ 1.45 倍) + * - 精英倍率 × 1.5,Boss 倍率 × 3.0(精英 × 2) + */ + +import { MonType } from "./heroSet"; + +// ======================== 常量 ======================== + +/** 怪物开局系数:1 级小怪略弱于英雄(0.8 倍) */ +export const MON_START_COEF = 0.8; +/** 怪物成长斜率:每级 +5% 基础属性 */ +export const MON_GROW_SLOPE = 0.05; +/** 精英属性倍率:同级普通怪的 1.5 倍 */ +export const ELITE_MUL = 1.5; +/** Boss 属性倍率:同级普通怪的 3.0 倍(精英 × 2) */ +export const BOSS_MUL = 3.0; +/** 普通关小怪数量 */ +export const NORMAL_MON_COUNT = 9; +/** 普通关开局立即刷出数量 */ +export const NORMAL_FIRST_SPAWN = 3; +/** 普通关后续刷怪间隔(秒) */ +export const NORMAL_SPAWN_INTERVAL = 5; +/** 精英关精英数量 */ +export const ELITE_COUNT = 3; +/** Boss 关精英数量 */ +export const BOSS_ELITE_COUNT = 2; +/** Boss 关 Boss 延迟刷出时间(秒) */ +export const BOSS_DELAY = 1; + +// ======================== 类型定义 ======================== + +/** 小节类型 */ +export enum SectionType { + Normal = 0, // 普通关:9 小怪 + Elite = 1, // 精英关:3 精英 + Boss = 2, // Boss 关:1 Boss + 2 精英 +} + +/** 小节配置 */ +export interface SectionConfig { + /** 小节类型 */ + type: SectionType; + /** 怪物等级(驱动线性成长) */ + monLv: number; + /** 精英数量(精英关/Boss 关用) */ + eliteCount?: number; + /** Boss UUID(Boss 关用,可选,缺省随机) */ + bossUuid?: number; + /** 怪物类型池(普通关用,缺省随机) */ + monTypes?: MonType[]; +} + +/** 大关配置(1-1 ~ 1-10) */ +export interface StageConfig { + /** 章节号 */ + chapter: number; + /** 关卡号 1~10 */ + stage: number; + /** 10 个小节 */ + sections: SectionConfig[]; +} + +/** 章节配置 */ +export interface ChapterConfig { + /** 章节号 */ + chapter: number; + /** 章节名称 */ + name: string; + /** 10 个大关 */ + stages: StageConfig[]; +} + +// ======================== 工具函数 ======================== + +/** + * 计算怪物最终属性 + * @param baseHp 基础 HP + * @param baseAp 基础 AP + * @param lv 怪物等级 + * @param isElite 是否精英 + * @param isBoss 是否 Boss + * @returns 最终 HP/AP + */ +export function calcMonsterAttrs( + baseHp: number, + baseAp: number, + lv: number, + isElite: boolean = false, + isBoss: boolean = false +): { hp: number; ap: number } { + const growCoef = 1 + MON_GROW_SLOPE * (lv - 1); + let typeMul = 1; + if (isBoss) { + typeMul = BOSS_MUL; + } else if (isElite) { + typeMul = ELITE_MUL; + } + const hp = Math.max(1, Math.round(baseHp * MON_START_COEF * growCoef * typeMul)); + const ap = Math.max(1, Math.round(baseAp * MON_START_COEF * growCoef * typeMul)); + return { hp, ap }; +} + +/** + * 获取指定关卡的怪物等级 + * @param stage 关卡号(1~10) + * @returns 怪物等级 + */ +export function getMonsterLv(stage: number): number { + return Math.max(1, Math.min(10, stage)); +} + +/** + * 判断是否为精英关 + * @param stage 关卡号(1~10) + */ +export function isEliteStage(stage: number): boolean { + return stage === 5; +} + +/** + * 判断是否为 Boss 关 + * @param stage 关卡号(1~10) + */ +export function isBossStage(stage: number): boolean { + return stage === 10; +} + +/** + * 获取小节类型 + * @param stage 关卡号(1~10) + */ +export function getSectionType(stage: number): SectionType { + if (isBossStage(stage)) return SectionType.Boss; + if (isEliteStage(stage)) return SectionType.Elite; + return SectionType.Normal; +} + +// ======================== 第 1 章配置 ======================== + +/** 生成单个大关的 10 个小节配置 */ +function makeStageSections(stage: number): SectionConfig[] { + const type = getSectionType(stage); + const monLv = getMonsterLv(stage); + const sections: SectionConfig[] = []; + + for (let i = 1; i <= 10; i++) { + const section: SectionConfig = { type, monLv }; + + if (type === SectionType.Elite) { + section.eliteCount = ELITE_COUNT; + } else if (type === SectionType.Boss) { + section.eliteCount = BOSS_ELITE_COUNT; + // bossUuid 缺省随机,由生成引擎从 Boss 池抽取 + } else { + // 普通关:近战为主,穿插远程/重型 + section.monTypes = [MonType.Melee, MonType.Melee, MonType.Melee, MonType.Heavy, MonType.Long]; + } + + sections.push(section); + } + return sections; +} + +/** 第 1 章配置 */ +export const Chapter1: ChapterConfig = { + chapter: 1, + name: "兽人入侵", + stages: [], +}; + +// 初始化第 1 章 10 个大关 +for (let stage = 1; stage <= 10; stage++) { + Chapter1.stages.push({ + chapter: 1, + stage, + sections: makeStageSections(stage), + }); +} + +/** 所有章节配置表(当前仅第 1 章,后续扩展) */ +export const ChapterSet: Record = { + 1: Chapter1, +}; + +// ======================== 查询接口 ======================== + +/** + * 获取小节配置 + * @param chapter 章节号 + * @param stage 关卡号 + * @param section 小节号(1~10) + * @returns 小节配置,不存在时返回 null + */ +export function getSectionConfig(chapter: number, stage: number, section: number): SectionConfig | null { + const ch = ChapterSet[chapter]; + if (!ch) return null; + const st = ch.stages.find(s => s.stage === stage); + if (!st) return null; + if (section < 1 || section > st.sections.length) return null; + return st.sections[section - 1]; +} + +/** + * 获取大关配置 + * @param chapter 章节号 + * @param stage 关卡号 + * @returns 大关配置,不存在时返回 null + */ +export function getStageConfig(chapter: number, stage: number): StageConfig | null { + const ch = ChapterSet[chapter]; + if (!ch) return null; + return ch.stages.find(s => s.stage === stage) ?? null; +} + +/** + * 获取章节配置 + * @param chapter 章节号 + * @returns 章节配置,不存在时返回 null + */ +export function getChapterConfig(chapter: number): ChapterConfig | null { + return ChapterSet[chapter] ?? null; +} + +/** + * 获取当前进度显示文本 + * @param chapter 章节号 + * @param stage 关卡号 + * @param section 小节号 + * @returns 格式化文本,如 "1-5-3" + */ +export function getProgressText(chapter: number, stage: number, section: number): string { + return `${chapter}-${stage}-${section}`; +} + +/** + * 获取大关显示文本 + * @param chapter 章节号 + * @param stage 关卡号 + * @returns 格式化文本,如 "1-5" + */ +export function getStageText(chapter: number, stage: number): string { + return `${chapter}-${stage}`; +} diff --git a/assets/script/game/common/config/StageSet.ts.meta b/assets/script/game/common/config/StageSet.ts.meta new file mode 100644 index 00000000..e75d5d23 --- /dev/null +++ b/assets/script/game/common/config/StageSet.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "f2599ec4-e0c3-4d35-8ca3-5054e674d927", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/script/game/map/MissionComp.ts b/assets/script/game/map/MissionComp.ts index 983068ff..fea89458 100644 --- a/assets/script/game/map/MissionComp.ts +++ b/assets/script/game/map/MissionComp.ts @@ -3,28 +3,25 @@ * @description 任务(关卡)核心控制组件(UI + 逻辑层) * * 职责: - * 1. 管理单局游戏的 **完整生命周期**:初始化 → 准备阶段 → 战斗阶段 → 结算。 + * 1. 管理单局游戏的 **完整生命周期**:初始化 → 小节循环(战斗→结算弹窗)→ 章节通关。 * 2. 在战斗阶段每帧更新战斗计时器、同步怪物数量、检测英雄全灭。 - * 3. 管理怪物数量阈值(暂停 / 恢复刷怪的上下限)。 - * 4. 处理新一回合事件(NewWave),进入准备阶段并发放金币奖励。 + * 3. 驱动小节推进:SectionStart → 战斗 → VictoryComp 结算 → 下一节/下一关。 + * 4. 处理关卡进度持久化(chapter / stage / section)。 * 5. 提供战斗结束后的结算弹窗入口(VictoryComp)。 * 6. (可选)内建性能监控面板,显示内存、帧率、实体数量等开发信息。 * * 关键设计: - * - mission_start() 初始化所有游戏数据 → 进入准备阶段 → 显示 loading。 - * - 准备阶段(enterPreparePhase):停止刷怪,显示开始按钮。 - * - 战斗阶段(to_fight):开始刷怪,隐藏按钮,由 update 驱动。 - * - 怪物数量管理采用 max/resume 双阈值: - * * 超过 max → 暂停刷怪(stop_spawn_mon=true) - * * 降至 resume 以下 → 恢复刷怪 + * - mission_start() 初始化所有游戏数据 → 恢复存档进度 → 进入第一小节。 + * - 每节即一局:怪物全灭后弹 VictoryComp 结算。 + * - 第 1~9 节胜利点"下一节"继续;第 10 节胜利显示"本关通关",确认退出并存档进下一关。 + * - 每节完全独立:进入下一节时重派 MissionStart 触发三选一,英雄重新召唤。 * - cleanComponents() 在任务开始/结束时销毁所有英雄和技能 ECS 实体。 * - clearBattlePools() 回收对象池(Monster / Skill / Tooltip)。 * * 依赖: * - smc.mission —— 全局任务运行状态(play / pause / in_fight / stop_spawn_mon 等) - * - smc.vmdata.mission_data —— 局内数据(金币 / 回合数 / 怪物数量等) - * - FightSet —— 战斗常量配置 - * - CardInitCoins —— 初始金币数 + * - smc.vmdata.mission_data —— 局内数据(金币 / 回合数 / 怪物数量 / 关卡进度等) + * - StageSet —— 关卡配置与数值公式 * - UIID.Victory —— 结算弹窗 */ import { _decorator, Vec3, Animation, instantiate, Prefab, Node, NodeEventType, ProgressBar, Label, CCInteger, tween, v3, Tween, Widget, UIOpacity } from "cc"; @@ -49,6 +46,9 @@ import { Timer } from "db://oops-framework/core/common/timer/Timer"; import { FieldSkillType } from "../common/config/SkillSet"; import { FieldSkillHelper } from "../hero/FieldSkillHelper"; import { spawningEngine, WAVE_DURATION, BATTLE_TIMEOUT } from "./RogueConfig"; +import { + getProgressText, +} from "../common/config/StageSet"; const { ccclass, property } = _decorator; /** 任务(关卡)生命周期阶段 */ @@ -60,16 +60,19 @@ export enum MissionPhase { BattleStart = 4, // 战斗开始阶段 (2s) Battle = 5, // 战斗阶段 (刷怪、战斗中) BattleEnd = 6, // 战斗结束阶段 (2s) - Settle = 7 // 结算阶段 + Settle = 7, // 结算阶段(VictoryComp 弹窗,等待玩家点击下一节/下一关/退出) } +/** 结算类型(驱动 VictoryComp 按钮分流与文案) */ +type SettleType = "defeat" | "section" | "final"; + //@todo 需要关注 当boss死亡的时候的动画播放完成后,需要触发事件,通知 MissionComp 进行奖励处理 /** * MissionComp —— 任务(关卡)核心控制器 * - * 驱动单局游戏的完整流程:准备 → 战斗 → 结算。 - * 管理战斗计时、怪物数量控制、英雄全灭检测和金币奖励发放。 + * 驱动单局游戏的完整流程:准备 → 战斗 → 小节结算 → 大关结算 → 章节结算。 + * 管理战斗计时、怪物数量控制、英雄全灭检测和关卡进度推进。 */ @ccclass('MissionComp') @ecs.register('MissionComp', false) @@ -102,7 +105,7 @@ export class MissionComp extends CCComp { [MissionPhase.BattleStart]: "战斗开始", [MissionPhase.Battle]: "战斗中", [MissionPhase.BattleEnd]: "战斗结束", - [MissionPhase.Settle]: "结算阶段" + [MissionPhase.Settle]: "结算阶段", }; // ======================== 运行时状态 ======================== @@ -171,11 +174,24 @@ export class MissionComp extends CCComp { private isWaveCountdown: boolean = false; /** 本局是否胜利(open_Victory 时记录,供结算弹窗使用) */ private _victory: boolean = false; + /** 本次结算类型(小节胜利/末关通关/失败,供结算弹窗按钮分流) */ + private _settleType: SettleType = "defeat"; + /** 末关通关时结算页展示的标题("本关通关"/"本章通关"),其余结算为空 */ + private _settleTitle: string = ""; /** 结算弹窗延迟(秒):等待最后一个英雄/怪物死亡动画播完再切 BattleEnd */ private static readonly SETTLE_DELAY = 1.5; /** 结算是否已预约调度(防止 0.2s 同步周期内重复 scheduleOnce) */ private _settleScheduled: boolean = false; + // ======================== 关卡进度状态 ======================== + + /** 当前章节号 */ + public currentChapter: number = 1; + /** 当前关卡号(1~10) */ + public currentStage: number = 1; + /** 当前小节号(1~10) */ + public currentSection: number = 1; + // ======================== ECS 查询匹配器(预缓存) ======================== /** 匹配拥有 HeroViewComp 的实体(英雄/怪物视图) */ @@ -196,6 +212,8 @@ export class MissionComp extends CCComp { this.on(GameEvent.MissionEnd, this.mission_end, this) this.on(GameEvent.NewWave, this.onNewWave, this) this.on(GameEvent.DO_AD_BACK, this.do_ad, this) + // 结算弹窗"下一节"按钮推进关卡 + this.on(GameEvent.NextSection, this.onNextSection, this) this.start_btn?.on(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this) // 开局免费三选一选完后自动进入战斗 oops.message.on("OpeningPicksDone", this.onOpeningPicksDone, this); @@ -317,7 +335,7 @@ export class MissionComp extends CCComp { if (phaseNode) { const label = phaseNode.getComponent(Label); if (label) { - label.string = `下一回合 ${remain}s`; + label.string = `下一节 ${remain}s`; } } } @@ -360,15 +378,22 @@ export class MissionComp extends CCComp { * 1. 取消上一局延迟回调。 * 2. 清理残留实体。 * 3. 初始化全部局内数据。 - * 4. 分发 FightReady 事件。 + * 4. 恢复关卡进度存档。 * 5. 进入准备阶段并显示 loading。 */ async mission_start() { this.unscheduleAllCallbacks(); this.cleanComponents(); this.data_init() + this.loadStageProgress(); oops.message.dispatchEvent(GameEvent.FightReady) - this.changePhase(MissionPhase.Prepare) + this.changePhase(MissionPhase.Prepare); + // 派发首个 SectionStart,MissionMonComp 据此生成开局怪物 + oops.message.dispatchEvent(GameEvent.SectionStart, { + chapter: this.currentChapter, + stage: this.currentStage, + section: this.currentSection, + }); let loading = this.node.getChildByName("loading") if (loading) { loading.active = true @@ -468,8 +493,7 @@ export class MissionComp extends CCComp { if (phaseNode) { const label = phaseNode.getComponent(Label); if (label) { - const wave = Math.max(1, this.currentWave || (smc.vmdata && smc.vmdata.mission_data ? smc.vmdata.mission_data.level : 1) || 1); - label.string = `第 ${wave} 回合`; + label.string = `第 ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)} 节`; } // 阶段切换动感表现:只在进入战斗阶段跳动一下,让流程充满心流体验 @@ -642,7 +666,10 @@ export class MissionComp extends CCComp { all_alive: this.checkAllHeroAlive(), rewards: this.rewards, game_data: this.game_data, - can_revive: this.revive_times > 0 + can_revive: this.revive_times > 0, + // 结算类型:小节胜利显示"下一节",末关通关显示"确认退出" + 通关标题 + settle_type: this._settleType, + settle_title: this._settleTitle, }); } else { // 如果 play 已经是 false,说明是通过 fight_end 进来的 @@ -732,6 +759,12 @@ export class MissionComp extends CCComp { this.to_fight(); } + /** 结算弹窗"下一节"事件:关闭弹窗并推进到下一小节 */ + private onNextSection() { + oops.gui.remove(UIID.Victory); + this.advanceToNextSection(); + } + /** 开局免费三选一选完,自动进入战斗(等效于点击开始按钮) */ private onOpeningPicksDone() { if (this.currentPhase !== MissionPhase.Prepare) return; @@ -743,7 +776,8 @@ export class MissionComp extends CCComp { /** * 打开结算弹窗: * - 暂停游戏 - * - 打开 VictoryComp 弹窗 + * - 按当前进度判定结算类型(小节胜利 / 末关通关 / 失败) + * - 切入 BattleEnd,2s 后由 autoNextPhase 自动弹出 VictoryComp * * @param e 事件对象(未使用) * @param is_hero_dead 是否因英雄全灭触发 @@ -752,8 +786,35 @@ export class MissionComp extends CCComp { open_Victory(e: any, is_hero_dead: boolean = false, victory: boolean = false) { // 战斗失败或胜利,标记暂停状态以切断回合流转逻辑 smc.mission.pause = true; - // 记录胜利标记,供 BattleEnd 阶段结算弹窗使用 + // 记录胜利标记,供结算弹窗使用 this._victory = victory; + this._settleTitle = ""; + + if (!victory) { + // 失败:进度不变,结算窗走复活/退出流程 + this._settleType = "defeat"; + } else if (this.currentSection < 10) { + // 第 1~9 节胜利:还有下一节,结算窗显示"下一节" + this._settleType = "section"; + } else { + // 第 10 节胜利:本关最后一节,无"下一节"按钮,结算页显示通关标题、确认即退出 + this._settleType = "final"; + this._settleTitle = this.currentStage >= 10 ? "本章通关" : "本关通关"; + // 存档直接推进到下一关第 1 小节(仅写存档,内存进度保留用于本局结算显示); + // 第 10 关通关即本章全通,存档停留等待后续章节扩展 + if (this.currentStage < 10) { + smc.data.stage_progress = { + chapter: this.currentChapter, + stage: this.currentStage + 1, + section: 1, + }; + if ((smc as any).updateCloudData) { + (smc as any).updateCloudData(); + } + mLogger.log(this.debugMode, 'MissionComp', + `[StageProgress] 本关通关,存档推进: ${this.currentChapter}-${this.currentStage + 1}-1`); + } + } // 直接切入 BattleEnd,2s 后由 autoNextPhase 自动流转弹窗结算 this.changePhase(MissionPhase.BattleEnd); } @@ -835,6 +896,83 @@ export class MissionComp extends CCComp { smc.vmdata.scores.gold_earned += smc.vmdata.mission_data.coin; } + // ======================== 关卡进度管理 ======================== + + /** + * 加载关卡进度存档 + * 从 smc.data.stage_progress 恢复,缺省从 1-1-1 开始 + */ + private loadStageProgress() { + const progress = smc.data.stage_progress; + if (progress && progress.chapter >= 1 && progress.stage >= 1 && progress.section >= 1) { + this.currentChapter = progress.chapter; + this.currentStage = progress.stage; + this.currentSection = progress.section; + } else { + this.currentChapter = 1; + this.currentStage = 1; + this.currentSection = 1; + } + mLogger.log(this.debugMode, 'MissionComp', + `[StageProgress] 加载进度: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`); + } + + /** + * 保存关卡进度存档 + * 写入 smc.data.stage_progress,并标记云端同步 + */ + private saveStageProgress() { + smc.data.stage_progress = { + chapter: this.currentChapter, + stage: this.currentStage, + section: this.currentSection, + }; + // 标记数据脏,触发云端/本地同步 + if ((smc as any).updateCloudData) { + (smc as any).updateCloudData(); + } + mLogger.log(this.debugMode, 'MissionComp', + `[StageProgress] 保存进度: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`); + } + + /** + * 推进到下一小节 + * 结算弹窗保证:第 10 节胜利时不显示"下一节",不会触发本方法 + */ + private advanceToNextSection() { + this.currentSection++; + this.startNextSectionBattle(); + } + + /** + * 开始下一小节战斗: + * 1. 清理上一节战场(英雄/技能实体 + 对象池)。 + * 2. 重置局内数据(金币/计时/标志位)。 + * 3. 重派 MissionStart 触发开局免费三选一(MissionCardComp 监听)。 + * 4. 进入准备阶段并派发 SectionStart 生成新小节怪物。 + * 5. 保存进度存档(精确到待挑战小节)。 + */ + private startNextSectionBattle() { + this.cleanComponents(); + this.clearBattlePools(); + this.data_init(); + this.changePhase(MissionPhase.Prepare); + + // 重新三选一:MissionCardComp 监听 MissionStart 重置开局免费抽卡 + oops.message.dispatchEvent(GameEvent.MissionStart, {}); + oops.message.dispatchEvent(GameEvent.FightReady); + // 触发 SectionStart 事件,MissionMonComp 据此生成新小节怪物 + oops.message.dispatchEvent(GameEvent.SectionStart, { + chapter: this.currentChapter, + stage: this.currentStage, + section: this.currentSection, + }); + + this.saveStageProgress(); + mLogger.log(this.debugMode, 'MissionComp', + `[StageProgress] 进入小节: ${getProgressText(this.currentChapter, this.currentStage, this.currentSection)}`); + } + // ======================== 回合管理 ======================== /** @@ -902,7 +1040,7 @@ export class MissionComp extends CCComp { }); this.handleHeroWipe(heroCount); - // 怪物全灭检测:一场定胜负,全灭即整局通关,直接进入结算 + // 怪物全灭检测:本小节怪物全灭 + 待刷队列空 → 弹结算窗(小节胜利,按钮按进度显示下一节/下一关) const pendingCount = smc.vmdata.mission_data.pending_mon_num || 0; if (monsterCount === 0 && pendingCount === 0 && smc.mission.play && !smc.mission.pause && this.currentPhase === MissionPhase.Battle) { // oops.message 全局事件总线:清屏庆祝(横幅/奖励由 BattleBannerComp 消费),需在结算前派发(clearTime 归零前取值) @@ -915,7 +1053,7 @@ export class MissionComp extends CCComp { allAlive, fastClear, }); - // 最后一个怪物死亡:延迟结算,等待死亡动画播放完再弹窗 + // 小节胜利:延迟弹结算窗,等待死亡动画播放完 this.scheduleSettle(false, true); return; } @@ -961,6 +1099,7 @@ export class MissionComp extends CCComp { // 延迟期间局内状态可能已变化(如复活、任务结束),兜底校验 if (!smc.mission.play || smc.mission.pause) return; if (this.currentPhase !== MissionPhase.Battle) return; + // 胜利/失败统一走结算弹窗:open_Victory 内部按进度判定小节/大关/章节结算类型 this.open_Victory(null, is_hero_dead, victory); }, MissionComp.SETTLE_DELAY); } diff --git a/assets/script/game/map/MissionMonComp.ts b/assets/script/game/map/MissionMonComp.ts index 41dc21dc..032d2234 100644 --- a/assets/script/game/map/MissionMonComp.ts +++ b/assets/script/game/map/MissionMonComp.ts @@ -3,8 +3,11 @@ * @description 怪物(Monster)登场管理组件(逻辑层) * * 职责: - * 1. 预生成战斗怪物:FightReady 时通过 RogueSpawningEngine.generateBossCombo 随机一组 Boss 组合,并立即登场展示(供玩家侦查后再选英雄)。 - * 2. 战斗开始:PhasePrepareEnd 时恢复怪物行动(in_fight 前怪物仅原地待命,不会推进/索敌)。 + * 1. 按小节配置刷怪:监听 SectionStart 事件,从 StageSpawningEngine 获取怪物列表。 + * 2. 普通关:前 3 只立即刷,后续每 5s 刷 1 只(共 9 只)。 + * 3. 精英关:3 只精英同时刷出。 + * 4. Boss 关:2 精英先刷,Boss 延迟 1s 刷出。 + * 5. 维护小节刷怪计数,供 MissionComp 检测小节结束。 * * 关键设计: * - 怪物按类型落排(近战/Boss→前排、远程/刺客→中排、辅助/召唤→后排),与英雄三排镜像对应。 @@ -24,6 +27,7 @@ import { MonType } from "../common/config/heroSet"; import { spawningEngine, GeneratedMonster, TestModeConfig } from "./RogueConfig"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { MoveComp } from "../hero/MoveComp"; +import { NORMAL_FIRST_SPAWN, NORMAL_SPAWN_INTERVAL } from "../common/config/StageSet"; const { ccclass, property } = _decorator; @@ -77,68 +81,116 @@ export class MissionMonCompComp extends CCComp { /** 全局生成顺序计数器(用于渲染层级排序) */ private globalSpawnOrder: number = 0; - /** 战斗场次(本局恒为 1,保留供 HUD/事件协议) */ - private currentWave: number = 1; - /** 本场战斗的怪物总数 */ - private waveTargetCount: number = 0; - /** 已登场的怪物数量 */ - private waveSpawnedCount: number = 0; - /** 待登场队列(FightReady 时整组进入,PhasePrepareEnd 时一次全量刷出) */ - private spawnQueue: GeneratedMonster[] = []; + /** 当前小节目标怪物总数 */ + private sectionTargetCount: number = 0; + /** 当前小节已刷出怪物数量 */ + private sectionSpawnedCount: number = 0; + /** 待刷出队列(batch=1 的怪物) */ + private pendingQueue: GeneratedMonster[] = []; + /** 刷怪定时器已累积时间 */ + private spawnTimer: number = 0; + /** 是否正在刷怪中 */ + private isSpawning: boolean = false; // ======================== 生命周期 ======================== onLoad() { - this.on(GameEvent.FightReady, this.fight_ready, this); + this.on(GameEvent.SectionStart, this.onSectionStart, this); } protected update(dt: number): void { // 待登场统计(战斗结束检测与 HUD 进度消费) - smc.vmdata.mission_data.pending_mon_num = this.spawnQueue.length; + smc.vmdata.mission_data.pending_mon_num = this.pendingQueue.length; + + // 定时刷怪:仅战斗阶段且非暂停时推进 + if (!this.isSpawning || !smc.mission.in_fight || smc.mission.pause) return; + if (this.pendingQueue.length === 0) { + this.isSpawning = false; + return; + } + + this.spawnTimer += dt; + if (this.spawnTimer >= NORMAL_SPAWN_INTERVAL) { + this.spawnTimer = 0; + const mon = this.pendingQueue.shift(); + if (mon) { + this.spawnMonster(mon); + } + } } start() { } /** - * 战斗准备:重置运行时状态并预生成本场 Boss 组合。 + * 小节开始事件回调:生成怪物列表并按节奏刷出 + * @param event 事件名 + * @param data { chapter, stage, section } */ - fight_ready() { - smc.vmdata.mission_data.mon_num = 0; - smc.mission.stop_spawn_mon = false; + private onSectionStart(event: string, data: any) { + const chapter = Number(data?.chapter ?? 1); + const stage = Number(data?.stage ?? 1); + const section = Number(data?.section ?? 1); + + // 重置小节状态 this.globalSpawnOrder = 0; - this.currentWave = 1; - this.waveTargetCount = 0; - this.waveSpawnedCount = 0; - this.spawnQueue = []; - - // 随机一组 Boss 组合(最终 Boss 战强度) - const monsters = spawningEngine.generateBossCombo(); - this.spawnQueue = monsters; - this.waveTargetCount = monsters.length; - smc.vmdata.mission_data.pending_mon_num = this.waveTargetCount; - - // 怪物先行登场:玩家侦查 Boss 组合后再做英雄三选一(in_fight 前怪物原地待命,不推进不索敌) - // 按编队优先级排序后依次登场:近战/Boss(0) → 远程/刺客(1) → 辅助/召唤(2) - const squad = [...this.spawnQueue].sort((a, b) => { - const pa = MissionMonCompComp.MON_TYPE_PRIORITY[a.type] ?? 1; - const pb = MissionMonCompComp.MON_TYPE_PRIORITY[b.type] ?? 1; - return pa - pb; - }); - this.spawnQueue = []; + this.sectionSpawnedCount = 0; + this.pendingQueue = []; + this.spawnTimer = 0; + this.isSpawning = false; + smc.vmdata.mission_data.mon_num = 0; smc.vmdata.mission_data.pending_mon_num = 0; - for (let i = 0; i < squad.length; i++) { - this.addMonsterAtGrid(i, squad[i], this.currentWave); - this.waveSpawnedCount++; - } + + // 从生成引擎获取本小节怪物列表 + const monsters = spawningEngine.generateSectionMonsters(chapter, stage, section); + this.sectionTargetCount = monsters.length; if (TestModeConfig.enable) { mLogger.log(this.debugMode, 'MissionMonComp', "[MissionMonComp] 测试模式已开启"); } - mLogger.log(this.debugMode, 'MissionMonComp', `[MissionMonComp] Boss 组合生成完成,怪物总数: ${this.waveTargetCount}`); + mLogger.log(this.debugMode, 'MissionMonComp', + `[MissionMonComp] 小节 ${chapter}-${stage}-${section} 生成完成,怪物总数: ${this.sectionTargetCount}`); + // 按批次拆分:batch=0 立即刷,batch=1 进入待刷队列 + const immediate: GeneratedMonster[] = []; + const delayed: GeneratedMonster[] = []; + + for (const mon of monsters) { + if (mon.batch === 0) { + immediate.push(mon); + } else { + delayed.push(mon); + } + } + + // 按编队优先级排序立即刷出的怪物 + immediate.sort((a, b) => { + const pa = MissionMonCompComp.MON_TYPE_PRIORITY[a.type] ?? 1; + const pb = MissionMonCompComp.MON_TYPE_PRIORITY[b.type] ?? 1; + return pa - pb; + }); + + // 立即刷出 batch=0 的怪物(处理 delay 字段) + for (const mon of immediate) { + if (mon.delay && mon.delay > 0) { + // Boss 延迟刷出 + this.scheduleOnce(() => { + this.spawnMonster(mon); + }, mon.delay); + } else { + this.spawnMonster(mon); + } + } + + // 剩余怪物进入待刷队列,启动定时刷怪 + this.pendingQueue = delayed; + if (this.pendingQueue.length > 0) { + this.isSpawning = true; + } + + // 广播小节开始事件(供 HUD 显示进度) oops.message.dispatchEvent(GameEvent.NewWave, { - wave: this.currentWave, - total: this.waveTargetCount, + wave: section, + total: this.sectionTargetCount, bossWave: monsters.some(m => m.isBoss), }); } @@ -146,29 +198,11 @@ export class MissionMonCompComp extends CCComp { // ======================== 怪物生成 ======================== /** - * 解析怪物出生 X:按编队优先级名次映射三排锚点(与英雄编队排队逻辑一致)。 - * 名次映射:rank0→前排(MON_ROW_FRONT_X)、rank1→中排(MON_ROW_MID_X)、rank≥2→后排(MON_ROW_BACK_X)。 - * 效果:高优先级类型永远占据更靠前的排;当更高优先级类型插入时,低优先级类型自动向后让位。 - * - * @param index 当前怪物在排序后列表中的索引(名次) - * @returns 出生 X 坐标 + * 刷出单个怪物(统一入口) + * @param monData 怪物生成数据 */ - private resolveSpawnX(index: number): number { - if (index === 0) return FightSet.MON_ROW_FRONT_X; - if (index === 1) return FightSet.MON_ROW_MID_X; - return FightSet.MON_ROW_BACK_X; - } - - /** - * 在指定位置索引处生成一个怪物 - * 怪物按编队优先级名次落排(rank0→前排、rank1→中排、rank≥2→后排), - * 与英雄编队排队逻辑一致:高优先级类型永远占更靠前的排,低优先级自动让位。 - */ - private addMonsterAtGrid( - posIndex: number, - monData: GeneratedMonster, - monLv: number = 1 - ) { + private spawnMonster(monData: GeneratedMonster) { + const posIndex = monData.spawnIndex; let mon = ecs.getEntity(Monster); let scale = -1; @@ -183,7 +217,7 @@ export class MissionMonCompComp extends CCComp { (mon as any)._testSkills = monData.skills; } - mon.load(spawnPos, scale, monData.uuid, monData.isBoss, landingY, monLv, posIndex); + mon.load(spawnPos, scale, monData.uuid, monData.isBoss, landingY, monData.lv, posIndex); // oops.message 全局事件总线:Boss 登场(震屏/音效由表现层消费) if (monData.isBoss) { @@ -202,6 +236,22 @@ export class MissionMonCompComp extends CCComp { model.hp_max = monData.hp; model.hp = model.hp_max; } + + this.sectionSpawnedCount++; + } + + /** + * 解析怪物出生 X:按编队优先级名次映射三排锚点(与英雄编队排队逻辑一致)。 + * 名次映射:rank0→前排(MON_ROW_FRONT_X)、rank1→中排(MON_ROW_MID_X)、rank≥2→后排(MON_ROW_BACK_X)。 + * 效果:高优先级类型永远占据更靠前的排;当更高优先级类型插入时,低优先级类型自动向后让位。 + * + * @param index 当前怪物在排序后列表中的索引(名次) + * @returns 出生 X 坐标 + */ + private resolveSpawnX(index: number): number { + if (index === 0) return FightSet.MON_ROW_FRONT_X; + if (index === 1) return FightSet.MON_ROW_MID_X; + return FightSet.MON_ROW_BACK_X; } /** ECS 组件移除时触发 */ diff --git a/assets/script/game/map/RogueConfig.ts b/assets/script/game/map/RogueConfig.ts index 0d3db0b7..7a3bf094 100644 --- a/assets/script/game/map/RogueConfig.ts +++ b/assets/script/game/map/RogueConfig.ts @@ -1,55 +1,38 @@ /** * @file RogueConfig.ts - * @description Boss 组合刷怪系统(单场 Boss 战定胜负) + * @description 关卡刷怪系统(章节-关-节三级推进) * * 游戏机制: - * - 整局只有一场战斗:开局英雄三次三选一后自动开战。 - * - 怪物从 BossComboLibrary 按权重随机抽一组(固定 3 只怪:0~2 Boss + 剩余小怪), - * 战斗开始瞬间一次全量登场(X∈[100,320] 均匀铺开),双方立即互相攻击。 - * - 怪物全灭 → 胜利结算;英雄全灭 → 失败结算;无回合递进、无分批刷怪。 + * - 玩家线性推进 1 章 10 关 × 每关 10 小节,进度精确到小节并持久化。 + * - 普通关(1~4, 6~9):9 只小怪,前 3 只立即刷,后续每 5s 刷 1 只。 + * - 精英关(5):3 只精英同时刷出。 + * - Boss 关(10):2 精英先刷,Boss 延迟 1s 刷出。 + * - 怪物属性 = 基础 × 0.8 × (1 + 0.05×(lv-1)) × 类型倍率(精英1.5 / Boss3.0)。 * * 设计层次: - * 1. BossComboLibrary - Boss 组合库(小怪构成 + Boss/小怪技能池 + 抽取权重) + * 1. StageSet - 关卡配置(章节/关/节 + 数值公式) * 2. MonSkillPool/BossSkillPool - 怪物技能池(atking / atked / dead 等全触发类型) - * 3. RogueSpawningEngine - 生成引擎(基础属性 × 可调系数) + * 3. StageSpawningEngine - 生成引擎(按小节配置生成怪物列表) * - * 核心公式(基础属性 × 可调系数): - * 小怪 hp = HeroInfo 基础hp × MON_HP_MUL,小怪 ap = HeroInfo 基础ap × MON_AP_MUL - * Boss 基础面板固定为初始英雄 2 倍强度(BOSS_BASE_HP/BOSS_BASE_AP),与小怪共用同一套系数, - * 场上 Boss 恰好为小怪 2 倍强度(如需额外压场可调 BOSS_HP_BONUS_MUL)。 - * 小怪基础面板与 1 级英雄持平(近战镜像 hp300/ap22/cd1.8),默认系数校准为 - * 整场战斗(3 英雄 vs 3 只怪)双方对砍 ≈ WAVE_DURATION(30s)。 - * 手感微调只改 MON_HP_MUL / MON_AP_MUL / BOSS_HP_BONUS_MUL 三个系数即可。 + * 核心公式: + * 最终属性 = HeroInfo 基础属性 × MON_START_COEF × (1 + MON_GROW_SLOPE×(lv-1)) × 类型倍率 + * 1 级小怪 ≈ 英雄 × 0.8,10 级小怪 ≈ 英雄 × 1.16,保持平缓成长。 */ import { HeroInfo, MonType, MonTypeName, TriggerGrouped } from "../common/config/heroSet"; import { SkillOverrides } from "../common/config/SkillSet"; import { FacSet } from "../common/config/GameSet"; +import { + SectionType, SectionConfig, getSectionConfig, calcMonsterAttrs, + NORMAL_MON_COUNT, NORMAL_FIRST_SPAWN, ELITE_COUNT, BOSS_ELITE_COUNT, BOSS_DELAY, + MON_START_COEF, MON_GROW_SLOPE, ELITE_MUL, BOSS_MUL, +} from "../common/config/StageSet"; // ======================== 1. 战斗常量 ======================== /** 单场战斗时长基准(秒):供清屏速度评价(BattleBannerComp 清屏奖励)等使用 */ export const WAVE_DURATION = 30; -/** 怪物登场固定构成:单场战斗固定 3 只怪,Boss 数量由组合配置决定(0~2 个), - * 剩余位置由小怪填充。例如:3 小怪、1 Boss + 2 小怪、2 Boss + 1 小怪(最大难度) */ -export const BOSS_COUNT = 2; -export const MINION_COUNT = 3; - -/** 怪物血量可调系数:最终 hp = HeroInfo 基础hp × MON_HP_MUL。 - * 2.6 = 校准后怪物总血量 / 3 英雄 30s 输出(≈30 DPS),双方对砍 ≈ 30s 分胜负 */ -export const MON_HP_MUL = 2.6; -/** 怪物攻击可调系数:最终 ap = HeroInfo 基础ap × MON_AP_MUL。 - * 0.9 使 4 小怪+1 Boss 的总 DPS ≈ 20,30s 恰好磨光 3 英雄总血量(≈600) */ -export const MON_AP_MUL = 0.9; - -/** Boss 基础面板 = 初始英雄基准(hp300/ap22)× 2 倍强度, - * 不读取 Boss HeroInfo 的差异化高基础值,避免数值膨胀 */ -export const BOSS_BASE_HP = 600; -export const BOSS_BASE_AP = 44; - -/** Boss 血量额外加成(在 MON_HP_MUL 之上乘算,默认 1.0 表示不再额外膨胀,保留作压场微调旋钮) */ -export const BOSS_HP_BONUS_MUL = 1.0; /** 战斗超时(秒):超过后强制结算整局(残留怪销毁并扣留存分) */ export const BATTLE_TIMEOUT = 90; @@ -97,89 +80,7 @@ export function getMonsterGoldDrop(monType: number, isBoss: boolean): number { return Math.max(0, Math.floor(isBoss ? cfg.boss : cfg.base)); } -// ======================== 3. Boss 组合库 ======================== - -/** Boss 组合中小怪的槽位定义(type + 数量,Boss 另由 BOSS_COUNT 提供) */ -export interface BossComboMinionSlot { - type: MonType; - count: number; -} - -/** Boss 组合配置:一场战斗随机抽一组登场(固定 3 只怪,Boss 数量由 bossCount 决定) */ -export interface BossComboConfig { - id: string; - name: string; - /** 本场 Boss 数量(0~2),剩余位置由小怪填充 */ - bossCount: number; - /** 小怪构成(合计数量建议等于 MINION_COUNT - bossCount,超出截断、不足自动补近战) */ - minions: BossComboMinionSlot[]; - /** Boss 技能池 id(引用 BossSkillPool,可选) */ - boss_skill_pool?: string[]; - /** 小怪技能池 id(引用 MonSkillPool,可选) */ - skill_pool?: string[]; - /** 在组合库中的抽取权重 */ - weight: number; -} - -/** Boss 组合库(硬编码):每场战斗按权重随机抽一组,固定 3 只怪。 - * 组合类型: - * - 3 小怪(0 Boss):均衡难度 - * - 1 Boss + 2 小怪:标准难度 - * - 2 Boss + 1 小怪:最大难度 */ -export const BossComboLibrary: Record = { - /** 3 小怪:均衡组合 */ - rage_charge: { - id: "rage_charge", name: "狂暴冲锋组", weight: 10, bossCount: 0, - minions: [{ type: MonType.Melee, count: 2 }, { type: MonType.Heavy, count: 1 }], - }, - iron_wall: { - id: "iron_wall", name: "铁壁推进组", weight: 8, bossCount: 0, - minions: [{ type: MonType.Heavy, count: 2 }, { type: MonType.Support, count: 1 }], - skill_pool: ["tough"], - }, - doom_ritual: { - id: "doom_ritual", name: "毁灭仪式组", weight: 6, bossCount: 0, - minions: [{ type: MonType.Summoner, count: 1 }, { type: MonType.Long, count: 1 }, { type: MonType.Support, count: 1 }], - skill_pool: ["warcry"], - }, - /** 1 Boss + 2 小怪:标准难度 */ - assassin_raid: { - id: "assassin_raid", name: "刺客突袭组", weight: 7, bossCount: 1, - minions: [{ type: MonType.Assassin, count: 1 }, { type: MonType.Melee, count: 1 }], - boss_skill_pool: ["boss_rage", "boss_doom"], - skill_pool: ["berserk"], - }, - blood_legion: { - id: "blood_legion", name: "吸血军团组", weight: 5, bossCount: 1, - minions: [{ type: MonType.Melee, count: 1 }, { type: MonType.Long, count: 1 }], - boss_skill_pool: ["boss_iron", "boss_doom"], - skill_pool: ["leech"], - }, - /** 2 Boss + 1 小怪:最大难度 */ - twin_boss: { - id: "twin_boss", name: "双王降临", weight: 3, bossCount: 2, - minions: [{ type: MonType.Support, count: 1 }], - boss_skill_pool: ["boss_rage", "boss_iron", "boss_doom"], - }, -}; - -/** - * 按权重随机抽取一组 Boss 组合 - * @returns 组合配置,库为空时返回 null - */ -export function pickBossCombo(): BossComboConfig | null { - const combos = Object.values(BossComboLibrary).filter(c => c && c.weight > 0); - if (combos.length === 0) return null; - const total = combos.reduce((s, c) => s + c.weight, 0); - let r = Math.random() * total; - for (const c of combos) { - r -= c.weight; - if (r <= 0) return c; - } - return combos[combos.length - 1]; -} - -// ======================== 4. 怪物技能池 ======================== +// ======================== 3. 怪物技能池 ======================== /** * 怪物技能套装(一套完整的触发技能配置) @@ -279,50 +180,17 @@ export const BossSkillPool: Record = { }, }; -// ======================== 5. 配置校验 ======================== +// ======================== 4. 配置校验 ======================== /** - * 校验 BossComboLibrary / MonSkillPool 引用一致性 + * 校验 MonSkillPool / BossSkillPool 引用一致性 * 建议在游戏启动时调用一次,便于发现配置错误 * @returns 错误信息数组,空数组表示校验通过 */ export function validateRogueConfig(): string[] { const errors: string[] = []; - // 1. 校验组合引用与数量(固定 3 只怪:bossCount + minionTotal 必须等于 MINION_COUNT) - for (const id in BossComboLibrary) { - const combo = BossComboLibrary[id]; - let minionTotal = 0; - for (const slot of combo.minions) { - minionTotal += slot.count; - if (!MonList[slot.type] || MonList[slot.type].length === 0) { - errors.push(`组合 ${id} 引用的怪物类型 ${slot.type}(${MonTypeName[slot.type] || "?"}) 在 MonList 中无可用 uuid`); - } - } - const totalMon = combo.bossCount + minionTotal; - if (totalMon !== MINION_COUNT) { - errors.push(`组合 ${id} 总怪数=${totalMon}(bossCount=${combo.bossCount} + minions=${minionTotal})与 MINION_COUNT=${MINION_COUNT} 不一致`); - } - if (combo.bossCount < 0 || combo.bossCount > BOSS_COUNT) { - errors.push(`组合 ${id} bossCount=${combo.bossCount} 超出允许范围 [0, ${BOSS_COUNT}]`); - } - if (combo.skill_pool) { - for (const skillId of combo.skill_pool) { - if (!MonSkillPool[skillId]) { - errors.push(`组合 ${id} 引用了不存在的技能: ${skillId}`); - } - } - } - if (combo.boss_skill_pool) { - for (const skillId of combo.boss_skill_pool) { - if (!BossSkillPool[skillId]) { - errors.push(`组合 ${id} 引用了不存在的 Boss 技能: ${skillId}`); - } - } - } - } - - // 2. 校验 Boss 类型在 MonList 中有怪 + // 校验 Boss 类型在 MonList 中有怪 if ((!MonList[MonType.MeleeBoss] || MonList[MonType.MeleeBoss].length === 0) && (!MonList[MonType.LongBoss] || MonList[MonType.LongBoss].length === 0)) { errors.push("MonList 中无任何 Boss 类型(MeleeBoss/LongBoss)可用 uuid"); @@ -331,7 +199,7 @@ export function validateRogueConfig(): string[] { return errors; } -// ======================== 6. 生成结果接口 ======================== +// ======================== 5. 生成结果接口 ======================== /** 生成结果(传递给 MissionMonComp 用于实例化怪物) */ export interface GeneratedMonster { @@ -340,11 +208,17 @@ export interface GeneratedMonster { hp: number; ap: number; isBoss: boolean; + /** 是否为精英(精英关/Boss 关的精英) */ + isElite?: boolean; /** 是否为 Boss 护卫队成员(供 UI/统计识别) */ isBossGuard?: boolean; + /** 怪物等级(驱动技能等级增量,Mon.load 第 6 参) */ + lv: number; spawnIndex: number; - /** 本怪所属批次(保留字段,单场全量登场恒为 0) */ + /** 本怪所属批次(0=首批立即刷,1=后续定时刷) */ batch: number; + /** 延迟刷出时间(秒),0 表示立即 */ + delay?: number; /** * 怪物技能套装(覆盖全部触发时机) @@ -361,7 +235,7 @@ export interface GeneratedMonster { }; } -// ======================== 7. 测试模式配置 ======================== +// ======================== 6. 测试模式配置 ======================== export const TestModeConfig = { enable: false, // 默认关闭测试模式 @@ -378,74 +252,138 @@ export const TestModeConfig = { fstart: undefined as TriggerGrouped | undefined, }; -// ======================== 8. 生成引擎 ======================== +// ======================== 7. 生成引擎 ======================== /** - * Boss 组合生成引擎 - * 随机抽一组 Boss 组合,怪物最终属性 = HeroInfo 基础属性 × 可调系数 + * 关卡刷怪生成引擎 + * 按章节-关-节配置生成怪物列表,属性走 StageSet 数值公式 */ -export class RogueSpawningEngine { +export class StageSpawningEngine { /** - * 生成一场战斗的怪物列表:随机一组 Boss 组合(固定 3 只怪,Boss 数量由 combo.bossCount 决定)。 - * Boss 压队尾(登场时由 MissionMonComp 统一布位)。 - * @returns 怪物列表(小怪在前、Boss 在尾) + * 生成指定小节的怪物列表 + * @param chapter 章节号 + * @param stage 关卡号 + * @param section 小节号(1~10) + * @returns 怪物列表(按刷出顺序排列) */ - generateBossCombo(): GeneratedMonster[] { + generateSectionMonsters(chapter: number, stage: number, section: number): GeneratedMonster[] { // 测试模式拦截:完全绕过生成引擎,直接返回硬编码测试怪 if (TestModeConfig.enable) { return this.generateTestWave(); } - const combo = pickBossCombo(); - const monsters: GeneratedMonster[] = []; - const bossCount = combo ? Math.max(0, Math.min(combo.bossCount, BOSS_COUNT)) : 0; - - if (combo) { - for (const slot of combo.minions) { - for (let i = 0; i < slot.count; i++) { - monsters.push(this.makeMonster(slot.type, monsters.length)); - } - } - } - // 组合库为空或数量不足兜底:补近战小怪至 MINION_COUNT - bossCount - const minionTarget = MINION_COUNT - bossCount; - while (monsters.length < minionTarget) { - monsters.push(this.makeMonster(MonType.Melee, monsters.length)); + const cfg = getSectionConfig(chapter, stage, section); + if (!cfg) { + // 配置缺失兜底:生成 3 只 Lv1 近战小怪 + return this.makeFallbackMonsters(); } - // Boss 压队尾 - for (let i = 0; i < bossCount; i++) { - monsters.push(this.makeBoss()); + switch (cfg.type) { + case SectionType.Elite: + return this.generateEliteSection(cfg); + case SectionType.Boss: + return this.generateBossSection(cfg); + case SectionType.Normal: + default: + return this.generateNormalSection(cfg); } - - // 属性缩放:基础属性 × 可调系数(Boss 基础面板已是英雄 2 倍,BOSS_HP_BONUS_MUL 默认 1.0), - // 校准目标:Boss 场上强度恰为小怪 2 倍,双方对砍 ≈ 30s - for (const m of monsters) { - m.hp = Math.max(1, Math.round(m.hp * MON_HP_MUL * (m.isBoss ? BOSS_HP_BONUS_MUL : 1))); - m.ap = Math.max(1, Math.round(m.ap * MON_AP_MUL)); - } - - // 挂载组合技能(Boss 专属池 / 小怪普通池) - for (const m of monsters) { - if (m.isBoss && combo?.boss_skill_pool) { - m.skills = this.pickSkillSet(combo.boss_skill_pool, BossSkillPool); - } else if (!m.isBoss && combo?.skill_pool) { - m.skills = this.pickSkillSet(combo.skill_pool, MonSkillPool); - } - m.batch = 0; - } - - for (let i = 0; i < monsters.length; i++) { - monsters[i].spawnIndex = i; - } - return monsters; } /** 重置引擎状态(每局开始时调用,预留) */ reset(): void { } + /** + * 普通关:9 只小怪 + * 前 3 只 batch=0(立即刷),后 6 只 batch=1(定时刷) + */ + private generateNormalSection(cfg: SectionConfig): GeneratedMonster[] { + const monsters: GeneratedMonster[] = []; + const types = cfg.monTypes ?? [MonType.Melee]; + + for (let i = 0; i < NORMAL_MON_COUNT; i++) { + const type = types[i % types.length]; + const mon = this.makeMonster(type, cfg.monLv, i); + mon.batch = i < NORMAL_FIRST_SPAWN ? 0 : 1; + monsters.push(mon); + } + return monsters; + } + + /** + * 精英关:3 只精英同时刷出 + */ + private generateEliteSection(cfg: SectionConfig): GeneratedMonster[] { + const monsters: GeneratedMonster[] = []; + const count = cfg.eliteCount ?? ELITE_COUNT; + // 精英池:近战/重型/远程/刺客 + const eliteTypes = [MonType.Melee, MonType.Heavy, MonType.Long, MonType.Assassin]; + + for (let i = 0; i < count; i++) { + const type = eliteTypes[i % eliteTypes.length]; + const mon = this.makeMonster(type, cfg.monLv, i); + mon.isElite = true; + mon.batch = 0; + // 精英属性 = 普通 × ELITE_MUL + mon.hp = Math.max(1, Math.round(mon.hp * ELITE_MUL)); + mon.ap = Math.max(1, Math.round(mon.ap * ELITE_MUL)); + monsters.push(mon); + } + return monsters; + } + + /** + * Boss 关:2 精英 + 1 Boss + * 精英先刷(batch=0),Boss 延迟刷出(batch=0, delay=BOSS_DELAY) + */ + private generateBossSection(cfg: SectionConfig): GeneratedMonster[] { + const monsters: GeneratedMonster[] = []; + const eliteCount = cfg.eliteCount ?? BOSS_ELITE_COUNT; + const eliteTypes = [MonType.Melee, MonType.Heavy]; + + // 先刷精英 + for (let i = 0; i < eliteCount; i++) { + const type = eliteTypes[i % eliteTypes.length]; + const mon = this.makeMonster(type, cfg.monLv, i); + mon.isElite = true; + mon.isBossGuard = true; + mon.batch = 0; + mon.hp = Math.max(1, Math.round(mon.hp * ELITE_MUL)); + mon.ap = Math.max(1, Math.round(mon.ap * ELITE_MUL)); + monsters.push(mon); + } + + // Boss 延迟刷出 + const boss = this.makeBoss(cfg.monLv, monsters.length); + boss.batch = 0; + boss.delay = BOSS_DELAY; + // Boss 属性 = 普通 × BOSS_MUL + boss.hp = Math.max(1, Math.round(boss.hp * BOSS_MUL)); + boss.ap = Math.max(1, Math.round(boss.ap * BOSS_MUL)); + monsters.push(boss); + + return monsters; + } + + /** 配置缺失兜底:3 只 Lv1 近战小怪 */ + private makeFallbackMonsters(): GeneratedMonster[] { + const monsters: GeneratedMonster[] = []; + for (let i = 0; i < 3; i++) { + monsters.push({ + uuid: 6001, + type: MonType.Melee, + hp: Math.round(560 * MON_START_COEF), + ap: Math.round(22 * MON_START_COEF), + isBoss: false, + lv: 1, + spawnIndex: i, + batch: 0, + }); + } + return monsters; + } + /** 测试模式:完全绕过引擎逻辑 */ private generateTestWave(): GeneratedMonster[] { const count = Math.max(1, TestModeConfig.spawnCount || 1); @@ -458,6 +396,7 @@ export class RogueSpawningEngine { hp: TestModeConfig.baseHp, ap: TestModeConfig.baseAp, isBoss: false, + lv: 1, spawnIndex: i, batch: 0, skills: { @@ -472,8 +411,8 @@ export class RogueSpawningEngine { return monsters; } - /** 生成 Boss(基础面板固定为初始英雄 2 倍强度,属性随后由 generateBossCombo 统一系数缩放) */ - private makeBoss(): GeneratedMonster { + /** 生成 Boss(基础面板按 StageSet 公式计算) */ + private makeBoss(monLv: number, spawnIndex: number): GeneratedMonster { const isMeleeBoss = Math.random() < 0.5; const type = isMeleeBoss ? MonType.MeleeBoss : MonType.LongBoss; @@ -484,19 +423,26 @@ export class RogueSpawningEngine { } const uuid = uuids[Math.floor(Math.random() * uuids.length)]; + // Boss 基础面板取 HeroInfo 基础值,属性由调用方乘 BOSS_MUL + const baseInfo = HeroInfo[uuid]; + const baseHp = baseInfo ? baseInfo.hp : 600; + const baseAp = baseInfo ? baseInfo.ap : 30; + const attrs = calcMonsterAttrs(baseHp, baseAp, monLv, false, false); + return { uuid, type, - hp: BOSS_BASE_HP, - ap: BOSS_BASE_AP, + hp: attrs.hp, + ap: attrs.ap, isBoss: true, - spawnIndex: 0, // 占位值,由 generateBossCombo 统一分配 + lv: monLv, + spawnIndex, batch: 0, }; } - /** 生成普通怪物 */ - private makeMonster(type: MonType, spawnIndex: number): GeneratedMonster { + /** 生成普通怪物(属性按 StageSet 公式计算) */ + private makeMonster(type: MonType, monLv: number, spawnIndex: number): GeneratedMonster { let uuids = MonList[type]; if (!uuids || uuids.length === 0) { // 兜底 Melee @@ -507,13 +453,15 @@ export class RogueSpawningEngine { const baseInfo = HeroInfo[uuid]; const baseHp = baseInfo ? baseInfo.hp : 100; const baseAp = baseInfo ? baseInfo.ap : 10; + const attrs = calcMonsterAttrs(baseHp, baseAp, monLv, false, false); return { uuid, type, - hp: baseHp, - ap: baseAp, + hp: attrs.hp, + ap: attrs.ap, isBoss: false, + lv: monLv, spawnIndex, batch: 0, }; @@ -550,6 +498,18 @@ export class RogueSpawningEngine { } } -// ======================== 9. 全局单例 ======================== +// ======================== 8. 全局单例 ======================== -export const spawningEngine = new RogueSpawningEngine(); +export const spawningEngine = new StageSpawningEngine(); + +// 保留旧接口兼容(逐步迁移期使用) +/** @deprecated 请使用 StageSpawningEngine.generateSectionMonsters */ +export class RogueSpawningEngine extends StageSpawningEngine { + /** @deprecated 请使用 generateSectionMonsters */ + generateBossCombo(): GeneratedMonster[] { + return this.generateSectionMonsters(1, 1, 1); + } +} + +/** @deprecated 请使用 spawningEngine */ +export const rogueSpawningEngine = new RogueSpawningEngine(); diff --git a/assets/script/game/map/VictoryComp.ts b/assets/script/game/map/VictoryComp.ts index 7a9b1289..f2af57d5 100644 --- a/assets/script/game/map/VictoryComp.ts +++ b/assets/script/game/map/VictoryComp.ts @@ -120,6 +120,16 @@ export class VictoryComp extends CCComp { @property({ type: Prefab, tooltip: "钻石奖励条目预制体(reward_item_diamond.prefab,挂 RewardItemComp)" }) reward_diamond_prefab: Prefab = null!; + // ======================== 关卡推进绑定 ======================== + + /** 下一节按钮(小节胜利时显示,末关通关/失败时隐藏) */ + @property({ type: Node, tooltip: "下一节按钮(小节胜利时显示)" }) + next_section_btn: Node = null!; + + /** 通关标题(末关通关时显示"本关通关/本章通关") */ + @property({ type: Label, tooltip: "通关标题 Label(末关通关时显示,默认隐藏)" }) + settle_title: Label = null!; + /** 调试日志开关 */ debugMode: boolean = false; /** 奖励等级(预留) */ @@ -158,6 +168,13 @@ export class VictoryComp extends CCComp { /** 是否可以复活(由 MissionComp 传入,取决于剩余复活次数) */ private canRevive: boolean = false; + /** + * 结算类型(由 MissionComp 传入,驱动主按钮行为): + * section - 小节胜利(主按钮"下一节",派发 NextSection) + * 其余 - 末关通关 / 失败(主按钮确认退出,走原结算退出流程) + */ + private settleType: string = "defeat"; + /** 加载时隐藏 loading 遮罩 */ protected onLoad(): void { this.node.getChildByName("loading").active = false @@ -170,6 +187,8 @@ export class VictoryComp extends CCComp { * @param args.rewards 掉落奖励列表 * @param args.game_data 累计数据 { exp, gold, diamond } * @param args.can_revive 是否可复活 + * @param args.settle_type 结算类型:section=小节胜利(下一节按钮),其余=确认退出 + * @param args.settle_title 末关通关标题(如"本关通关"),非空时在结算页顶部展示 */ onAdded(args: any) { this.node.getChildByName("loading").active = false @@ -178,9 +197,17 @@ export class VictoryComp extends CCComp { this.game_data = args.game_data } + // 结算类型:缺省按失败处理 + this.settleType = args.settle_type ?? "defeat"; + // 根据是否可复活决定按钮显示 this.node.getChildByName("btns").getChildByName("next").active = !args.can_revive + // 下一节按钮:仅小节胜利时显示 + this.updateNextButtonState(); + // 通关标题:仅末关通关时显示 + this.updateSettleTitle(args.settle_title ? String(args.settle_title) : ""); + // 计算总分并写入历史纪录 this.calculateTotalScore(!!args.victory, !!args.all_alive); @@ -715,7 +742,41 @@ export class VictoryComp extends CCComp { // ======================== 操作入口 ======================== - /** 退出战斗:清理数据 → 触发任务结束 → 关闭弹窗 */ + /** + * 下一节按钮显隐:仅小节胜利(settleType === "section")时显示。 + * 该按钮为 @property 绑定节点,编辑器中已设计好样式与位置。 + */ + private updateNextButtonState() { + if (this.next_section_btn && this.next_section_btn.isValid) { + this.next_section_btn.active = this.settleType === "section"; + } + } + + /** + * 通关标题显隐与文案:仅末关通关时显示("本关通关"/"本章通关")。 + * 该 Label 为 @property 绑定节点,编辑器中默认隐藏。 + * @param title 文案,空串时隐藏 + */ + private updateSettleTitle(title: string) { + if (this.settle_title && this.settle_title.isValid) { + this.settle_title.string = title; + this.settle_title.node.active = !!title; + } + } + + /** + * 下一节按钮点击(编辑器 Click Events 绑定): + * 派发 NextSection,由 MissionComp 关窗并推进下一小节。 + */ + on_next_section() { + oops.message.dispatchEvent(GameEvent.NextSection); + } + + /** + * 主按钮点击(编辑器 Click Events 绑定,btns/next): + * 确认结算退出(原有退出流程)。 + * 注:下一节按钮为独立节点,不经过本方法。 + */ victory_end() { this.clear_data() oops.message.dispatchEvent(GameEvent.MissionEnd)