910 lines
35 KiB
TypeScript
910 lines
35 KiB
TypeScript
/**
|
||
* @file VictoryComp.ts
|
||
* @description 战斗结算弹窗组件(UI 视图层)
|
||
*
|
||
* 通过 oops.gui.open(UIID.Victory, args) 打开。
|
||
* 展示段位徽章、总分滚动动画、段位进度与亮点成就,并提供重开 / 退出操作。
|
||
*/
|
||
import { _decorator, instantiate, Label, Prefab, Node, Sprite, ProgressBar, Tween, tween, v3, UIOpacity, Button } from "cc";
|
||
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
|
||
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
|
||
import { oops } from "../../../../extensions/oops-plugin-framework/assets/core/Oops";
|
||
import { smc } from "../common/SingletonModuleComp";
|
||
import { GameEvent } from "../common/config/GameEvent";
|
||
import { HighlightSet, HighlightType, HighlightLevel } from "../common/config/HighlightSet";
|
||
import { getRankByScore, getNextRank, getRankProgress, ScoreRankInfo } from "../common/config/ScoreRankSet";
|
||
import { LangPrefix, lang, langf } from "../common/LangUtil";
|
||
import { mLogger } from "../common/Logger";
|
||
import { UIID } from "../common/config/GameUIConfig";
|
||
import { VICTORY_REWARD_SET } from "../common/config/GameSet";
|
||
import { HeroInfo } from "../common/config/heroSet";
|
||
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
|
||
import { ChipComp } from "./ChipComp";
|
||
import { RewardItemComp } from "./RewardItemComp";
|
||
|
||
const { ccclass, property } = _decorator;
|
||
|
||
/** 总分滚动动画时长(秒) */
|
||
const SCORE_TWEEN_DURATION = 1.6;
|
||
/** 段位晋升动效时长(秒) */
|
||
const RANK_UP_DURATION = 0.5;
|
||
/** 奖励条目逐个入场的间隔(秒) */
|
||
const REWARD_ENTER_INTERVAL = 0.12;
|
||
|
||
/** 单个英雄碎片奖励 */
|
||
interface FragmentReward {
|
||
/** 英雄模板 uuid */
|
||
uuid: number;
|
||
/** 碎片数量 */
|
||
count: number;
|
||
/** 是否为本局出场英雄(否则为随机英雄) */
|
||
isPlayed: boolean;
|
||
}
|
||
|
||
/** 结算奖励结果 */
|
||
interface RewardResult {
|
||
/** 发放的金币数量 */
|
||
gold: number;
|
||
/** 发放的天赋经验数量 */
|
||
talentExp: number;
|
||
/** 发放的银币数量 */
|
||
silver: number;
|
||
/** 发放的钻石数量 */
|
||
diamond: number;
|
||
/** 英雄碎片奖励列表 */
|
||
fragments: FragmentReward[];
|
||
}
|
||
|
||
/**
|
||
* VictoryComp —— 战斗结算弹窗视图组件
|
||
*
|
||
* 布局(自上而下):
|
||
* rank_icon 段位徽章
|
||
* total 总分(score_label 滚动数字)
|
||
* rank_progress 段位内进度条
|
||
* next_rank_label 下一段位提示
|
||
* gain_label +N 涨幅标签(仅破纪录时显示)
|
||
* highlights 亮点成就标签容器
|
||
* btns 操作按钮
|
||
*/
|
||
@ccclass('VictoryComp')
|
||
@ecs.register('Victory', false)
|
||
export class VictoryComp extends CCComp {
|
||
|
||
// ======================== 结算 UI 绑定 ========================
|
||
|
||
@property({ type: Sprite, tooltip: "段位徽章图标(rank_icon 节点上的 Sprite)" })
|
||
rank_icon: Sprite = null!;
|
||
|
||
@property({ type: Node, tooltip: "撒花装饰节点(fireworks,循环缩放弹跳)" })
|
||
fireworks: Node = null!;
|
||
|
||
@property({ type: Node, tooltip: "光晕装饰节点(halo,持续旋转)" })
|
||
halo: Node = null!;
|
||
|
||
@property({ type: Label, tooltip: "总分文本(total/score_label)" })
|
||
total_score_label: Label = null!;
|
||
|
||
@property({ type: ProgressBar, tooltip: "段位进度条(rank_progress)" })
|
||
rank_progress: ProgressBar = null!;
|
||
|
||
@property({ type: Label, tooltip: "下一段位提示文本(next_rank_label)" })
|
||
next_rank_label: Label = null!;
|
||
|
||
@property({ type: Label, tooltip: "涨幅标签(gain_label,仅破纪录时显示)" })
|
||
gain_label: Label = null!;
|
||
|
||
@property({ type: Node, tooltip: "亮点成就标签的容器" })
|
||
highlights_container: Node = null!;
|
||
|
||
@property({ type: Prefab, tooltip: "亮点成就标签预制体" })
|
||
highlight_prefab: Prefab = null!;
|
||
|
||
// ======================== 结算奖励绑定 ========================
|
||
|
||
@property({ type: Node, tooltip: "结算奖励条目容器(rewards_container,横向 Layout)" })
|
||
rewards_container: Node = null!;
|
||
|
||
@property({ type: Prefab, tooltip: "英雄碎片条目预制体(clips.prefab,挂 ChipComp,num 子节点显示数量)" })
|
||
clip_prefab: Prefab = null!;
|
||
|
||
@property({ type: Prefab, tooltip: "金币奖励条目预制体(reward_item_gold.prefab,挂 RewardItemComp)" })
|
||
reward_gold_prefab: Prefab = null!;
|
||
|
||
@property({ type: Prefab, tooltip: "天赋经验奖励条目预制体(reward_item_exp.prefab,挂 RewardItemComp)" })
|
||
reward_exp_prefab: Prefab = null!;
|
||
|
||
@property({ type: Prefab, tooltip: "银币奖励条目预制体(reward_item_silver.prefab,挂 RewardItemComp)" })
|
||
reward_silver_prefab: Prefab = null!;
|
||
|
||
@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: Node, tooltip: "确认按钮(末关通关/失败时显示,点击确认退出结算)" })
|
||
confirm_btn: Node = null!;
|
||
|
||
/** 通关标题(末关通关时显示"本关通关/本章通关") */
|
||
@property({ type: Label, tooltip: "通关标题 Label(末关通关时显示,默认隐藏)" })
|
||
settle_title: Label = null!;
|
||
|
||
/** 调试日志开关 */
|
||
debugMode: boolean = false;
|
||
/** 奖励等级(预留) */
|
||
reward_lv: number = 1
|
||
/** 奖励数量(预留) */
|
||
reward_num: number = 2
|
||
/** 掉落奖励列表 */
|
||
rewards: any[] = []
|
||
/** 累计游戏数据(经验 / 金币 / 钻石) */
|
||
game_data: any = {
|
||
exp: 0,
|
||
gold: 0,
|
||
diamond: 0
|
||
}
|
||
|
||
// ======================== 结算奖励(运行时) ========================
|
||
|
||
/** 本次结算实际发放的奖励结果(供渲染) */
|
||
private _rewardResult: RewardResult = null!;
|
||
|
||
// ======================== 动画状态 ========================
|
||
|
||
/** 动画期间实时显示的分数(tween 驱动) */
|
||
private _displayScore: number = 0;
|
||
/** 滚动前的生涯累计分(动画起始值) */
|
||
private _prevBestScore: number = 0;
|
||
/** 滚动后的生涯累计分(动画结束值) */
|
||
private _finalScore: number = 0;
|
||
/** 滚动起始段位(用于检测跨段) */
|
||
private _startRank: ScoreRankInfo = null!;
|
||
/** 动画期间是否已触发过段位晋升动效 */
|
||
private _rankUpPlayed: boolean = false;
|
||
|
||
// ======================== 复活相关 ========================
|
||
|
||
/** 是否可以复活(由 MissionComp 传入,取决于剩余复活次数) */
|
||
private canRevive: boolean = false;
|
||
|
||
/** 结算类型:section=小节胜利, victory=末关通关, defeat=失败 */
|
||
private settleType: string = "defeat";
|
||
/** 是否胜利场(驱动主按钮文案与行为) */
|
||
private isVictory: boolean = false;
|
||
|
||
/** 失败奖励判定数据(MissionComp 传入:本节坚持时间 + 击杀统计) */
|
||
private defeatStats: { clear_time: number; kills: number; total: number } | null = null;
|
||
|
||
/** 本节怪物等级(= 大关号,驱动胜利奖励随关卡进度增长) */
|
||
private monLv: number = 1;
|
||
|
||
/** 加载时隐藏 loading 遮罩并绑定按钮事件 */
|
||
protected onLoad(): void {
|
||
this.node.getChildByName("loading").active = false
|
||
|
||
// 代码绑定按钮点击事件,替代编辑器 Click Events 配置
|
||
// next 按钮按状态分发:小节胜利→下一节,失败→再来一次
|
||
this.bindButtonClick(this.next_section_btn, this.on_next_btn_click);
|
||
this.bindButtonClick(this.confirm_btn, this.victory_end);
|
||
}
|
||
|
||
/**
|
||
* 为按钮节点绑定点击事件(无则自动添加 Button 组件)
|
||
* @param node 按钮节点
|
||
* @param handler 点击回调
|
||
*/
|
||
private bindButtonClick(node: Node, handler: () => void) {
|
||
if (!node || !node.isValid) return;
|
||
let btn = node.getComponent(Button);
|
||
if (!btn) {
|
||
btn = node.addComponent(Button);
|
||
}
|
||
node.on(Button.EventType.CLICK, handler, this);
|
||
}
|
||
|
||
/**
|
||
* 弹窗打开时的回调:接收战斗结果参数。
|
||
*
|
||
* @param args.victory 是否胜利(当前仅用于标识)
|
||
* @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
|
||
mLogger.log(this.debugMode, 'VictoryComp', "[VictoryComp] onAdded", args)
|
||
if (args.game_data) {
|
||
this.game_data = args.game_data
|
||
}
|
||
|
||
// 结算类型与胜负状态:缺省按失败处理
|
||
this.settleType = args.settle_type ?? "defeat";
|
||
this.isVictory = !!args.victory;
|
||
this.defeatStats = args.defeat_stats ?? null;
|
||
this.monLv = Math.max(1, Number(args.mon_lv) || 1);
|
||
|
||
// 根据是否可复活决定按钮显示
|
||
this.node.getChildByName("btns").getChildByName("next").active = !args.can_revive
|
||
|
||
// 按钮显隐与文案:下一节 / 再来一次 / 确认
|
||
this.updateButtonStates();
|
||
// 通关标题:仅末关通关时显示
|
||
this.updateSettleTitle(args.settle_title ? String(args.settle_title) : "");
|
||
|
||
// 计算总分并写入历史纪录
|
||
this.calculateTotalScore(!!args.victory, !!args.all_alive);
|
||
|
||
// 计算并发放结算奖励(英雄碎片 + 金币 + 天赋经验)
|
||
this.grantRewards(!!args.victory);
|
||
|
||
// 每次战斗结束都标记数据脏并触发云端同步(防抖 3 秒后自动上传)
|
||
smc.updateCloudData();
|
||
|
||
// 播放结算动画
|
||
this.playSettleAnimation();
|
||
|
||
// 渲染亮点标签
|
||
this.renderHighlights();
|
||
|
||
// 渲染结算奖励条目
|
||
this.renderRewards();
|
||
}
|
||
|
||
// ======================== 分数计算(逻辑层,保持不变) ========================
|
||
|
||
/**
|
||
* 获取满足条件的最高等级的亮点成就
|
||
* @param type 亮点类型
|
||
* @param value 玩家实际达成的值
|
||
* @returns 达成的最高等级配置,未达成返回 null
|
||
*/
|
||
private getHighestHighlightLevel(type: HighlightType, value: number): HighlightLevel | null {
|
||
const config = HighlightSet[type];
|
||
if (!config || !config.levels) return null;
|
||
|
||
let highest: HighlightLevel | null = null;
|
||
for (const levelConfig of config.levels) {
|
||
if (value >= levelConfig.threshold) {
|
||
highest = levelConfig;
|
||
}
|
||
}
|
||
return highest;
|
||
}
|
||
|
||
/**
|
||
* 计算并获取所有达成的最高亮点配置数组,用于加分和UI展示
|
||
*/
|
||
private getAchievedHighlights(s: any): { type: HighlightType, config: HighlightLevel, value: number }[] {
|
||
const achieved: { type: HighlightType, config: HighlightLevel, value: number }[] = [];
|
||
|
||
// 计算辅助比例
|
||
const refreshRatio = s.refresh_count > 0 ? (s.refresh_hit_count / s.refresh_count) : 0;
|
||
const goldRatio = s.gold_earned > 0 ? (s.gold_spent / s.gold_earned) : 0;
|
||
|
||
// 判定表:每个维度对应的值
|
||
const checkList: { type: HighlightType, value: number }[] = [
|
||
{ type: HighlightType.CritMaster, value: s.crt_count },
|
||
{ type: HighlightType.DeathExpert, value: s.dead_trigger_count },
|
||
{ type: HighlightType.IronWall, value: s.shield_block_count },
|
||
// 风怒机制已停用: WindStorm 成就判定已移除
|
||
// { type: HighlightType.WindStorm, value: s.wf_count },
|
||
{ type: HighlightType.OneHitKill, value: Math.floor(s.highest_dmg) },
|
||
{ type: HighlightType.HealingLight, value: Math.floor(s.heal_total) },
|
||
{ type: HighlightType.PerfectClear, value: (s.passed_wave_20 && s.wave_all_alive_count >= 20) ? 1 : 0 },
|
||
{ type: HighlightType.LuckyKing, value: refreshRatio },
|
||
{ type: HighlightType.ThriftyPlayer, value: goldRatio }
|
||
];
|
||
|
||
for (const item of checkList) {
|
||
const levelConfig = this.getHighestHighlightLevel(item.type, item.value);
|
||
if (levelConfig) {
|
||
achieved.push({ type: item.type, config: levelConfig, value: item.value });
|
||
}
|
||
}
|
||
|
||
return achieved;
|
||
}
|
||
|
||
/**
|
||
* 计算本局得分并累加到生涯总分(smc.data.score 为累计分,驱动段位)。
|
||
*
|
||
* 积分规则:
|
||
* - 单局:胜利 +10(全队存活再 +5)/ 失败 -10;暴击/闪避/击晕/冰冻每次 +1、单项封顶 5
|
||
* - 累计:生涯分 += max(0, 本局分),负分局不回退(不掉段),累计下限钳 0
|
||
*
|
||
* @param isVictory 是否胜利
|
||
* @param allAlive 是否全队存活
|
||
*/
|
||
private calculateTotalScore(isVictory: boolean, allAlive: boolean) {
|
||
const s = smc.vmdata.scores;
|
||
|
||
// 0. 胜负分:胜利 +10(全队存活再 +5),失败 -10
|
||
let base = 0;
|
||
if (isVictory) {
|
||
base = 10;
|
||
if (allAlive) {
|
||
base += 5;
|
||
}
|
||
} else {
|
||
base = -10;
|
||
}
|
||
|
||
// 1. 战斗行为分:暴击/闪避/击晕/冰冻每次 +1,单项封顶 5
|
||
const critPts = Math.min(5, s.crt_count);
|
||
const dodgePts = Math.min(5, s.dod_count);
|
||
const stunPts = Math.min(5, s.stun_count);
|
||
const freezePts = Math.min(5, s.freeze_count);
|
||
|
||
// 本局得分:失败局可为负(结算页如实展示)
|
||
s.score = Math.floor(base + critPts + dodgePts + stunPts + freezePts);
|
||
|
||
// 亮点成就判定:仅作结算页亮点标签展示,不计入总分
|
||
s.achieved_highlights = this.getAchievedHighlights(s);
|
||
|
||
// 累计生涯分:正贡献累加、负分不回退(声望式不掉段),下限钳 0
|
||
this._prevBestScore = smc.data.score;
|
||
const gain = Math.max(0, s.score);
|
||
smc.data.score = Math.max(0, this._prevBestScore + gain);
|
||
this._finalScore = smc.data.score;
|
||
|
||
// 持久化生涯分(云端/本地同步)
|
||
if (typeof smc.updateCloudData === "function") {
|
||
smc.updateCloudData();
|
||
}
|
||
|
||
// 借用 scores 对象传递标记:本局有正贡献(供涨幅标签展示)
|
||
(s as any).isNewRecord = gain > 0;
|
||
|
||
mLogger.log(this.debugMode, 'VictoryComp', `[VictoryComp] 本局得分: ${s.score}`, {
|
||
base,
|
||
critPts, dodgePts, stunPts, freezePts,
|
||
gain,
|
||
careerTotal: this._finalScore,
|
||
prevTotal: this._prevBestScore
|
||
});
|
||
}
|
||
|
||
// ======================== 结算动画 ========================
|
||
|
||
/**
|
||
* 播放结算动画:入场 → 总分滚动 → 定格涨幅
|
||
*/
|
||
private playSettleAnimation() {
|
||
// 初始化动画状态
|
||
this._displayScore = this._prevBestScore;
|
||
this._startRank = getRankByScore(this._prevBestScore);
|
||
this._rankUpPlayed = false;
|
||
|
||
// 初始渲染:旧纪录分数与段位
|
||
this.renderScore(this._prevBestScore);
|
||
this.renderRank(this._prevBestScore);
|
||
|
||
// 隐藏涨幅标签
|
||
if (this.gain_label) {
|
||
this.gain_label.node.active = false;
|
||
}
|
||
|
||
// 若本局分数未超过旧纪录,直接定格,无需滚动
|
||
if (this._finalScore <= this._prevBestScore) {
|
||
this.showGainLabel();
|
||
return;
|
||
}
|
||
|
||
// 滚动动画:从旧纪录涨到本局分数
|
||
const self = this;
|
||
const proxy = { value: this._prevBestScore };
|
||
tween(proxy)
|
||
.to(SCORE_TWEEN_DURATION, { value: this._finalScore }, {
|
||
easing: 'quadOut',
|
||
onUpdate(target) {
|
||
self.onScoreTweenUpdate(Math.floor(target!.value));
|
||
}
|
||
})
|
||
.call(() => {
|
||
self.onScoreTweenEnd();
|
||
})
|
||
.start();
|
||
}
|
||
|
||
/**
|
||
* 总分滚动过程中的每一帧更新
|
||
* @param score 当前插值分数
|
||
*/
|
||
private onScoreTweenUpdate(score: number) {
|
||
this._displayScore = score;
|
||
this.renderScore(score);
|
||
this.renderRank(score);
|
||
|
||
// 跨段检测:当前分数已超过起始段位的下一段门槛,触发晋升动效
|
||
const currentRank = getRankByScore(score);
|
||
if (!this._rankUpPlayed && currentRank.tier !== this._startRank.tier) {
|
||
this._rankUpPlayed = true;
|
||
this.playRankUpEffect();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 总分滚动结束
|
||
*/
|
||
private onScoreTweenEnd() {
|
||
this._displayScore = this._finalScore;
|
||
this.renderScore(this._finalScore);
|
||
this.renderRank(this._finalScore);
|
||
this.showGainLabel();
|
||
}
|
||
|
||
/**
|
||
* 渲染总分数字
|
||
*/
|
||
private renderScore(score: number) {
|
||
if (this.total_score_label) {
|
||
this.total_score_label.string = `${score}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染段位徽章、进度条与下一段位提示
|
||
* @param score 当前用于判定段位的分数
|
||
*/
|
||
private renderRank(score: number) {
|
||
const rank = getRankByScore(score);
|
||
const next = getNextRank(score);
|
||
|
||
// 段位徽章:从全局图集取帧
|
||
if (this.rank_icon && smc.uiconsAtlas) {
|
||
const frame = smc.uiconsAtlas.getSpriteFrame(rank.icon);
|
||
if (frame) {
|
||
this.rank_icon.spriteFrame = frame;
|
||
}
|
||
}
|
||
|
||
// 段位进度条
|
||
if (this.rank_progress) {
|
||
this.rank_progress.progress = getRankProgress(score);
|
||
}
|
||
|
||
// 下一段位提示
|
||
if (this.next_rank_label) {
|
||
this.next_rank_label.string = next
|
||
? `下一段位:${next.name}(${next.minScore} 分)`
|
||
: `已达最高段位`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 段位晋升动效:徽章放大回弹 + 闪白
|
||
*/
|
||
private playRankUpEffect() {
|
||
if (!this.rank_icon) return;
|
||
const node = this.rank_icon.node;
|
||
|
||
// 停止徽章上残留的 tween,避免叠加
|
||
Tween.stopAllByTarget(node);
|
||
node.setScale(1, 1, 1);
|
||
|
||
tween(node)
|
||
.to(RANK_UP_DURATION * 0.4, { scale: v3(1.4, 1.4, 1) }, { easing: 'quadOut' })
|
||
.to(RANK_UP_DURATION * 0.6, { scale: v3(1, 1, 1) }, { easing: 'backOut' })
|
||
.start();
|
||
|
||
// 若有 UIOpacity 组件则做一次闪白
|
||
const op = node.getComponent(UIOpacity);
|
||
if (op) {
|
||
Tween.stopAllByTarget(op);
|
||
tween(op)
|
||
.to(RANK_UP_DURATION * 0.3, { opacity: 120 })
|
||
.to(RANK_UP_DURATION * 0.7, { opacity: 255 })
|
||
.start();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 滚动定格后显示 +N 涨幅标签(本局有正贡献时;跨段位时附加升段文案)
|
||
*/
|
||
private showGainLabel() {
|
||
if (!this.gain_label) return;
|
||
|
||
const s = smc.vmdata.scores;
|
||
const hasGain = (s as any).isNewRecord === true;
|
||
const gain = this._finalScore - this._prevBestScore;
|
||
|
||
if (!hasGain || gain <= 0) {
|
||
this.gain_label.node.active = false;
|
||
return;
|
||
}
|
||
|
||
// 跨段位判定:滚动前后段位不一致即为升段(累计分只涨不跌,不存在降段)
|
||
const rankUp = getRankByScore(this._finalScore).tier !== getRankByScore(this._prevBestScore).tier;
|
||
this.gain_label.string = rankUp ? `+${gain} 升段!` : `+${gain}`;
|
||
this.gain_label.node.active = true;
|
||
|
||
// 浮现动效:从 0 缩放淡入
|
||
const node = this.gain_label.node;
|
||
Tween.stopAllByTarget(node);
|
||
node.setScale(0, 0, 1);
|
||
|
||
let op = node.getComponent(UIOpacity);
|
||
if (!op) {
|
||
op = node.addComponent(UIOpacity);
|
||
}
|
||
op.opacity = 0;
|
||
|
||
tween(node)
|
||
.to(0.35, { scale: v3(1, 1, 1) }, { easing: 'backOut' })
|
||
.start();
|
||
tween(op)
|
||
.to(0.35, { opacity: 255 })
|
||
.start();
|
||
}
|
||
|
||
// ======================== 结算奖励 ========================
|
||
|
||
/**
|
||
* 收集本局出场英雄的 uuid 列表(去重)。
|
||
* 来源 = 存活英雄(heroGrid 中实体的 hero_uuid)+ 死亡英雄快照(dead_heroes)。
|
||
*/
|
||
private collectPlayedHeroUuids(): number[] {
|
||
const uuidSet = new Set<number>();
|
||
|
||
// 存活英雄:heroGrid 存 eid,反查实体属性
|
||
for (const eid of smc.mission.heroGrid) {
|
||
if (eid < 0) continue;
|
||
const entity = ecs.getEntityByEid(eid);
|
||
const attrs = entity?.get(HeroAttrsComp);
|
||
if (attrs && attrs.fac === 0) {
|
||
uuidSet.add(attrs.hero_uuid);
|
||
}
|
||
}
|
||
|
||
// 死亡英雄快照
|
||
const deadList = smc.mission.dead_heroes || [];
|
||
for (const rec of deadList) {
|
||
if (rec && rec.uuid > 0) uuidSet.add(rec.uuid);
|
||
}
|
||
|
||
return [...uuidSet];
|
||
}
|
||
|
||
/**
|
||
* 从全英雄池中随机抽取 1 个英雄 uuid(优先未出场,避免与出场碎片重复)。
|
||
*/
|
||
private pickRandomHeroUuid(exclude: Set<number>): number {
|
||
const all = Object.keys(HeroInfo).map(k => Number(k)).filter(u => u > 0);
|
||
const candidates = all.filter(u => !exclude.has(u));
|
||
const pool = candidates.length > 0 ? candidates : all;
|
||
if (pool.length === 0) return 0;
|
||
return pool[Math.floor(Math.random() * pool.length)];
|
||
}
|
||
|
||
/**
|
||
* 计算并发放结算奖励,结果存入 _rewardResult。
|
||
* 胜利:随关卡进度增长(base + monLv × per_lv,整体 ×1.2)。
|
||
* Why: 无回合概念后 wave_win_count 每节被 resetScores 清零恒为 1,
|
||
* 旧的 per_wave 增长失效,改挂怪物等级(= 大关号)随关卡推进线性增长。
|
||
* 失败:受"坚持时间 + 击杀比例"限制——
|
||
* - 坚持 < 15s 或本节一只怪未杀 → 无任何奖励;
|
||
* - 达标 → 坚持奖励(固定)+ 击杀比例奖励(基数 × 本节击杀比例),
|
||
* 银币/钻石/碎片同样受此门槛限制(不达标不发,达标正常发放)。
|
||
* @param isVictory 是否胜利
|
||
*/
|
||
private grantRewards(isVictory: boolean) {
|
||
const cfg = VICTORY_REWARD_SET;
|
||
|
||
// 失败奖励资格判定:坚持时间与击杀数不达标则整场无奖励
|
||
let defeatQualified = false;
|
||
let killRatio = 0;
|
||
if (!isVictory) {
|
||
const stats = this.defeatStats;
|
||
const clearTime = stats?.clear_time ?? 0;
|
||
const kills = stats?.kills ?? 0;
|
||
const total = stats?.total ?? 0;
|
||
defeatQualified = clearTime >= cfg.defeat.time_threshold && kills > 0;
|
||
killRatio = total > 0 ? Math.min(1, kills / total) : 0;
|
||
}
|
||
|
||
// 1. 金币 / 天赋经验
|
||
let gold: number;
|
||
let talentExp: number;
|
||
if (isVictory) {
|
||
const mult = cfg.victory_multiplier;
|
||
gold = Math.floor((cfg.gold.base + this.monLv * cfg.gold.per_lv) * mult);
|
||
talentExp = Math.floor((cfg.talent_exp.base + this.monLv * cfg.talent_exp.per_lv) * mult);
|
||
} else if (defeatQualified) {
|
||
// 失败达标 = 坚持奖励(固定)+ 击杀比例奖励
|
||
gold = Math.floor(cfg.defeat.survive.gold + cfg.defeat.kill.gold * killRatio);
|
||
talentExp = Math.floor(cfg.defeat.survive.talent_exp + cfg.defeat.kill.talent_exp * killRatio);
|
||
} else {
|
||
gold = 0;
|
||
talentExp = 0;
|
||
}
|
||
|
||
// 1.5 银币固定发放;钻石按输赢在 [min, max] 内随机(失败不达标时同样清零)
|
||
const silver = !isVictory && !defeatQualified ? 0 : cfg.silver;
|
||
let diamond = 0;
|
||
if (isVictory || defeatQualified) {
|
||
const dRange = isVictory ? cfg.diamond.victory : cfg.diamond.defeat;
|
||
diamond = dRange.min + Math.floor(Math.random() * (dRange.max - dRange.min + 1));
|
||
}
|
||
|
||
// 2. 英雄碎片:出场英雄各保底 + 1 个随机英雄(失败不达标时不发)
|
||
const fragments: FragmentReward[] = [];
|
||
if (isVictory || defeatQualified) {
|
||
const playedUuids = this.collectPlayedHeroUuids();
|
||
const playedSet = new Set(playedUuids);
|
||
for (const uuid of playedUuids) {
|
||
fragments.push({ uuid, count: cfg.played_fragment, isPlayed: true });
|
||
}
|
||
const randomUuid = this.pickRandomHeroUuid(playedSet);
|
||
if (randomUuid > 0) {
|
||
fragments.push({ uuid: randomUuid, count: cfg.random_fragment, isPlayed: false });
|
||
}
|
||
}
|
||
|
||
// 3. 落地发放
|
||
if (gold > 0) {
|
||
smc.updateGold(gold); // 统一入口:改 data 持久层 + 镜像 vmdata + 标脏同步
|
||
}
|
||
if (silver > 0) {
|
||
smc.updateSilver(silver);
|
||
}
|
||
if (diamond > 0) {
|
||
smc.updateDiamond(diamond);
|
||
}
|
||
if (talentExp > 0) {
|
||
smc.addTalentExp(talentExp);
|
||
}
|
||
for (const f of fragments) {
|
||
smc.addHeroFragments(f.uuid, f.count);
|
||
}
|
||
|
||
this._rewardResult = { gold, talentExp, silver, diamond, fragments };
|
||
|
||
mLogger.log(this.debugMode, 'VictoryComp', '[VictoryComp] 结算奖励发放', {
|
||
isVictory, monLv: this.monLv, gold, talentExp, silver, diamond, fragments,
|
||
defeatStats: this.defeatStats, defeatQualified, killRatio,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 渲染结算奖励条目(金币 / 天赋经验 / 英雄碎片),逐个弹跳入场。
|
||
*/
|
||
private renderRewards() {
|
||
if (!this.rewards_container || !this._rewardResult) return;
|
||
this.rewards_container.removeAllChildren();
|
||
|
||
const items: Node[] = [];
|
||
const r = this._rewardResult;
|
||
|
||
// 金币条目
|
||
if (this.reward_gold_prefab && r.gold > 0) {
|
||
const node = instantiate(this.reward_gold_prefab);
|
||
node.getComponent(RewardItemComp)?.setReward(r.gold);
|
||
items.push(node);
|
||
}
|
||
|
||
// 天赋经验条目
|
||
if (this.reward_exp_prefab && r.talentExp > 0) {
|
||
const node = instantiate(this.reward_exp_prefab);
|
||
node.getComponent(RewardItemComp)?.setReward(r.talentExp);
|
||
items.push(node);
|
||
}
|
||
|
||
// 银币条目
|
||
if (this.reward_silver_prefab && r.silver > 0) {
|
||
const node = instantiate(this.reward_silver_prefab);
|
||
node.getComponent(RewardItemComp)?.setReward(r.silver);
|
||
items.push(node);
|
||
}
|
||
|
||
// 钻石条目
|
||
if (this.reward_diamond_prefab && r.diamond > 0) {
|
||
const node = instantiate(this.reward_diamond_prefab);
|
||
node.getComponent(RewardItemComp)?.setReward(r.diamond);
|
||
items.push(node);
|
||
}
|
||
|
||
// 英雄碎片条目(复用 clips.prefab + ChipComp,num 子节点显示数量)
|
||
if (this.clip_prefab) {
|
||
for (const f of r.fragments) {
|
||
const node = instantiate(this.clip_prefab);
|
||
node.getComponent(ChipComp)?.setHeroByUuid(f.uuid);
|
||
const numLabel = node.getChildByName("num")?.getComponent(Label);
|
||
if (numLabel) {
|
||
numLabel.string = `${f.count}`;
|
||
numLabel.node.active = true;
|
||
}
|
||
items.push(node);
|
||
}
|
||
}
|
||
|
||
// 逐个入场(初始隐藏,间隔弹出)
|
||
items.forEach((node, i) => {
|
||
node.active = false;
|
||
this.rewards_container.addChild(node);
|
||
this.scheduleOnce(() => {
|
||
node.active = true;
|
||
// 碎片条目没有 RewardItemComp,手动播一次弹跳
|
||
if (!node.getComponent(RewardItemComp)) {
|
||
Tween.stopAllByTarget(node);
|
||
node.setScale(0, 0, 1);
|
||
tween(node).to(0.35, { scale: v3(1, 1, 1) }, { easing: 'backOut' }).start();
|
||
}
|
||
}, REWARD_ENTER_INTERVAL * (i + 1));
|
||
});
|
||
}
|
||
|
||
// ======================== 亮点成就 ========================
|
||
|
||
/**
|
||
* 根据当前局数据匹配并生成对应的亮点标签(成就)
|
||
*/
|
||
private renderHighlights() {
|
||
if (!this.highlights_container || !this.highlight_prefab) return;
|
||
|
||
// 先清空原有的标签
|
||
this.highlights_container.removeAllChildren();
|
||
|
||
const s = smc.vmdata.scores;
|
||
// 获取所有已达成的亮点(包含对应等级的信息)
|
||
const achievedList = s.achieved_highlights || [];
|
||
|
||
// 最多显示前3个亮点(如有优先级需求,可在截取前对 achievedList 进行排序)
|
||
const displayTags = achievedList.slice(0, 3);
|
||
|
||
displayTags.forEach(item => {
|
||
const tagNode = instantiate(this.highlight_prefab);
|
||
const lab = tagNode.getComponent(Label) || tagNode.getChildByName("label")?.getComponent(Label);
|
||
|
||
if (lab) {
|
||
const typeConfig = HighlightSet[item.type];
|
||
const levelConfig = item.config;
|
||
const uuid = levelConfig.uuid;
|
||
|
||
const titleStr = lang(LangPrefix.hl_title, uuid);
|
||
const descStr = langf(LangPrefix.hl_desc, uuid, levelConfig.threshold);
|
||
|
||
lab.string = `${typeConfig.icon} ${titleStr} (${descStr})`;
|
||
}
|
||
this.highlights_container.addChild(tagNode);
|
||
});
|
||
}
|
||
|
||
// ======================== 操作入口 ========================
|
||
|
||
/**
|
||
* 统一更新按钮显隐与文案:
|
||
* section(小节胜利)→ btn="退出",next="下一节"
|
||
* final(末关通关) → btn="返回",next 隐藏
|
||
* defeat(失败) → btn="退出",next="再来一次"
|
||
*/
|
||
private updateButtonStates() {
|
||
const btns = this.node.getChildByName("btns");
|
||
if (!btns) return;
|
||
|
||
const btnNode = btns.getChildByName("btn");
|
||
const nextNode = btns.getChildByName("next");
|
||
|
||
// btn 确认/退出按钮:所有结算类型都显示
|
||
if (btnNode) {
|
||
btnNode.active = true;
|
||
const label = btnNode.getChildByName("Label")?.getComponent(Label);
|
||
if (label) {
|
||
label.string = this.settleType === "final" ? "返回" : "退出";
|
||
}
|
||
}
|
||
|
||
// next 按钮:小节胜利→"下一节",失败→"再来一次",末关通关隐藏
|
||
if (nextNode) {
|
||
nextNode.active = this.settleType !== "final";
|
||
const label = nextNode.getChildByName("Label")?.getComponent(Label);
|
||
if (label) {
|
||
label.string = this.settleType === "section" ? "下一节" : "再来一次";
|
||
}
|
||
}
|
||
|
||
// 属性绑定的确认按钮同步显隐
|
||
if (this.confirm_btn && this.confirm_btn.isValid) {
|
||
this.confirm_btn.active = true;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通关标题显隐与文案:仅末关通关时显示("本关通关"/"本章通关")。
|
||
* 该 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* next 按钮点击分发:
|
||
* 小节胜利 → 派发 NextSection 推进下一节
|
||
* 失败场 → 派发 RestartMission 重开本关
|
||
*/
|
||
on_next_btn_click() {
|
||
if (this.settleType === "section") {
|
||
oops.message.dispatchEvent(GameEvent.NextSection);
|
||
} else {
|
||
oops.message.dispatchEvent(GameEvent.RestartMission);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确认按钮点击(代码绑定,btns/btn):
|
||
* - 小节胜利(section)→ 推进存档到下一节并保存 → 派发 MissionEnd → 关闭结算弹窗
|
||
* - 末关通关(final) → 清理数据 → 派发 MissionEnd → 关闭结算弹窗
|
||
* - 失败(defeat) → 清理数据 → 派发 MissionEnd → 关闭结算弹窗
|
||
*
|
||
* Why: 小节胜利点"退出"既要保存进度(避免下次重玩卡在原节),
|
||
* 又不能像"下一节"按钮那样直接开战(玩家已选退出,不再继续本局战斗)。
|
||
*/
|
||
victory_end() {
|
||
if (this.settleType === "section") {
|
||
// 小节胜利:手动推进 section 到下一节并保存存档,避免点退出后进度丢失。
|
||
// 注:MissionComp 的 NextSection 会进入战斗流程,这里不走该事件以避免自动开战。
|
||
const cur = (smc.data.stage_progress ?? { chapter: 1, stage: 1, section: 1 }) as { chapter: number; stage: number; section: number };
|
||
// 当前节已胜利,写入下一节作为待挑战存档;若已是末节(section=10),则不做 section 推进
|
||
if (cur.section < 10) {
|
||
smc.updateStageProgress({
|
||
chapter: cur.chapter,
|
||
stage: cur.stage,
|
||
section: cur.section + 1,
|
||
});
|
||
}
|
||
}
|
||
this.clear_data()
|
||
oops.message.dispatchEvent(GameEvent.MissionEnd)
|
||
oops.gui.removeByNode(this.node)
|
||
}
|
||
|
||
/** 清理运行时数据:解除暂停标志 */
|
||
clear_data() {
|
||
smc.mission.pause = false
|
||
}
|
||
|
||
/** 看广告双倍奖励(预留) */
|
||
watch_ad() {
|
||
return true
|
||
}
|
||
|
||
/** 双倍奖励发放(预留) */
|
||
double_reward() {
|
||
|
||
}
|
||
|
||
/** 再来一次(已迁移至 MissionComp.restartCurrentMission,保留空实现兼容旧引用) */
|
||
restart() {
|
||
oops.message.dispatchEvent(GameEvent.RestartMission);
|
||
}
|
||
|
||
/** 物品展示回调(预留) */
|
||
item_show(e: any, val: any) {
|
||
mLogger.log(this.debugMode, 'VictoryComp', "item_show", val)
|
||
}
|
||
|
||
protected onDestroy(): void {
|
||
// 停止所有动画与延迟回调,防止节点销毁后 tween/scheduleOnce 访问无效对象
|
||
if (this.rank_icon) Tween.stopAllByTarget(this.rank_icon.node);
|
||
if (this.gain_label) Tween.stopAllByTarget(this.gain_label.node);
|
||
this.unscheduleAllCallbacks();
|
||
super.onDestroy();
|
||
mLogger.log(this.debugMode, 'VictoryComp', "释放胜利界面");
|
||
}
|
||
|
||
/** ECS 组件移除时销毁节点 */
|
||
reset() {
|
||
this.node.destroy()
|
||
}
|
||
}
|