feat(ui): 新增段位系统并重构结算弹窗

新增玩家得分段位配置表与相关工具函数,重构战斗结算弹窗:
1.  新增ScoreRankSet.ts实现段位判定、进度计算逻辑
2.  为结算界面添加段位徽章、进度条和晋升动效
3.  实现总分滚动动画与破纪录涨幅显示
4.  调整UI5图集的边框参数适配新UI资源
This commit is contained in:
panFD
2026-08-03 22:43:38 +08:00
parent 3fe6325830
commit ae24d0378a
8 changed files with 3112 additions and 9676 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -3474,10 +3474,10 @@
"height": 75,
"rawWidth": 75,
"rawHeight": 75,
"borderTop": 37.5,
"borderBottom": 37.5,
"borderLeft": 37.5,
"borderRight": 37.5,
"borderTop": 37,
"borderBottom": 37,
"borderLeft": 37,
"borderRight": 37,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
@@ -3520,10 +3520,10 @@
"height": 75,
"rawWidth": 75,
"rawHeight": 75,
"borderTop": 37.5,
"borderBottom": 37.5,
"borderLeft": 37.5,
"borderRight": 37.5,
"borderTop": 37,
"borderBottom": 37,
"borderLeft": 37,
"borderRight": 37,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
@@ -3612,10 +3612,10 @@
"height": 65,
"rawWidth": 65,
"rawHeight": 65,
"borderTop": 32.5,
"borderBottom": 32.5,
"borderLeft": 32.5,
"borderRight": 32.5,
"borderTop": 32,
"borderBottom": 32,
"borderLeft": 32,
"borderRight": 32,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -0,0 +1,80 @@
/**
* 玩家得分段位配置
* 依据玩家历史最高分smc.data.score划分段位黄铜 -> 传说
*/
/** 段位标识 */
export enum ScoreTier {
BRONZE = "bronze", // 黄铜
SILVER = "silver", // 白银
GOLD = "gold", // 黄金
PLATINUM = "platinum", // 铂金
DIAMOND = "diamond", // 钻石
MASTER = "master", // 大师
LEGEND = "legend", // 传说
}
/** 段位定义(按 minScore 升序排列) */
export interface ScoreRankInfo {
/** 段位标识 */
tier: ScoreTier;
/** 段位中文名 */
name: string;
/** 进入该段位所需的最低分(含) */
minScore: number;
/** 段位图标帧名gui/ui6 图集内的 SpriteFrame 名UI 层经 smc.uiconsAtlas 取帧) */
icon: string;
}
/**
* 段位阈值表score >= minScore 即达到对应段位
* 顺序必须保持 minScore 升序,供二分/逆序查找使用
*/
export const ScoreRankList: ScoreRankInfo[] = [
{ tier: ScoreTier.BRONZE, name: "黄铜", minScore: 0, icon: "League_1_Bronze" },
{ tier: ScoreTier.SILVER, name: "白银", minScore: 2000, icon: "League_2_Silver" },
{ tier: ScoreTier.GOLD, name: "黄金", minScore: 5000, icon: "League_3_Gold" },
{ tier: ScoreTier.PLATINUM, name: "铂金", minScore: 10000, icon: "League_4_Platinum" },
{ tier: ScoreTier.DIAMOND, name: "钻石", minScore: 20000, icon: "League_5_Diamond" },
{ tier: ScoreTier.MASTER, name: "大师", minScore: 40000, icon: "League_6_Master" },
{ tier: ScoreTier.LEGEND, name: "传说", minScore: 80000, icon: "League_7_Legendary" },
];
/**
* 根据分数获取当前段位
* @param score 玩家分数(通常传历史最高分 smc.data.score
* @returns 当前段位信息score 为负时保底返回黄铜
*/
export function getRankByScore(score: number): ScoreRankInfo {
// 逆序遍历,命中第一个 minScore <= score 的段位即为当前段位
for (let i = ScoreRankList.length - 1; i >= 0; i--) {
if (score >= ScoreRankList[i].minScore) {
return ScoreRankList[i];
}
}
return ScoreRankList[0];
}
/**
* 根据分数获取下一段位目标
* @param score 玩家分数
* @returns 下一段位信息;已是传说(最高段位)时返回 null
*/
export function getNextRank(score: number): ScoreRankInfo | null {
const current = getRankByScore(score);
const idx = ScoreRankList.indexOf(current);
return idx < ScoreRankList.length - 1 ? ScoreRankList[idx + 1] : null;
}
/**
* 计算当前分数在当前段位内的进度
* @param score 玩家分数
* @returns 0~1 的进度值;已是最高段位时恒为 1
*/
export function getRankProgress(score: number): number {
const current = getRankByScore(score);
const next = getNextRank(score);
if (!next) return 1;
const range = next.minScore - current.minScore;
return range > 0 ? Math.min((score - current.minScore) / range, 1) : 1;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "577b2b8c-c387-4c64-bd45-9ea58980397a",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -2,57 +2,60 @@
* @file VictoryComp.ts
* @description 战斗结算弹窗组件UI 视图层)
*
* 通过 oops.gui.open(UIID.Victory, args) 打开。
* 展示段位徽章、总分滚动动画、段位进度与亮点成就,并提供重开 / 退出操作。
*/
import { _decorator, instantiate, Label, Prefab, Node, Sprite, Animation, AnimationClip, resources, UITransform, Widget, ProgressBar, Tween, NodeEventType } from "cc";
import { _decorator, instantiate, Label, Prefab, Node, Sprite, ProgressBar, Tween, tween, v3, UIOpacity } 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 { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { FacSet } from "../common/config/GameSet";
import { HeroInfo } from "../common/config/heroSet";
import { CKind, CardType, CardConfig } from "../common/config/CardSet";
import { CardComp } from "./CardComp";
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";
const { ccclass, property } = _decorator;
/** 总分滚动动画时长(秒) */
const SCORE_TWEEN_DURATION = 1.6;
/** 段位晋升动效时长(秒) */
const RANK_UP_DURATION = 0.5;
/**
* VictoryComp —— 战斗结算弹窗视图组件
*
* 通过 oops.gui.open(UIID.Victory, args) 打开。
* 展示战斗结果,计算总分,并提供重开 / 退出操作。
* 布局(自上而下):
* 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 {
@property(Node)
mvp_node = null!
// ======================== 结算 UI 绑定 ========================
@property({ type: Label, tooltip: "总分文本" })
@property({ type: Sprite, tooltip: "段位徽章图标rank_icon 节点上的 Sprite" })
rank_icon: Sprite = null!;
@property({ type: Label, tooltip: "总分文本total/score_label" })
total_score_label: Label = null!;
@property({ type: Node, tooltip: "战绩分节点 (需包含 score_label 和 progress_bar 子节点)" })
combat_node: Node = null!;
@property({ type: ProgressBar, tooltip: "段位进度条rank_progress" })
rank_progress: ProgressBar = null!;
@property({ type: Node, tooltip: "输出分节点 (需包含 score_label 和 progress_bar 子节点)" })
output_node: Node = null!;
@property({ type: Label, tooltip: "下一段位提示文本next_rank_label" })
next_rank_label: Label = null!;
@property({ type: Node, tooltip: "防御分节点 (需包含 score_label 和 progress_bar 子节点)" })
defense_node: Node = null!;
@property({ type: Node, tooltip: "构建分节点 (需包含 score_label 和 progress_bar 子节点)" })
build_node: Node = null!;
@property({ type: Node, tooltip: "效率分节点 (需包含 score_label 和 progress_bar 子节点)" })
efficiency_node: Node = null!;
@property({ type: Label, tooltip: "涨幅标签gain_label仅破纪录时显示" })
gain_label: Label = null!;
@property({ type: Node, tooltip: "亮点成就标签的容器" })
highlights_container: Node = null!;
@@ -75,6 +78,19 @@ export class VictoryComp extends CCComp {
diamond: 0
}
// ======================== 动画状态 ========================
/** 动画期间实时显示的分数tween 驱动) */
private _displayScore: number = 0;
/** 滚动前的历史最高分(动画起始值) */
private _prevBestScore: number = 0;
/** 本局总分(动画结束值) */
private _finalScore: number = 0;
/** 滚动起始段位(用于检测跨段) */
private _startRank: ScoreRankInfo = null!;
/** 动画期间是否已触发过段位晋升动效 */
private _rankUpPlayed: boolean = false;
// ======================== 复活相关 ========================
/** 是否可以复活(由 MissionComp 传入,取决于剩余复活次数) */
@@ -103,105 +119,17 @@ export class VictoryComp extends CCComp {
// 根据是否可复活决定按钮显示
this.node.getChildByName("btns").getChildByName("next").active = !args.can_revive
// 计算总分
// 计算总分并写入历史纪录
this.calculateTotalScore();
// 渲染分数UI和亮点标签
this.renderScores();
// 播放结算动画
this.playSettleAnimation();
// 显示MVP英雄
const mvp = this.getMVPHero();
this.renderMVPHero(mvp);
// 渲染亮点标签
this.renderHighlights();
}
// ======================== MVP 英雄 ========================
/**
* 获取战斗中最厉害的英雄(根据等级和攻击力)
*/
private getMVPHero(): HeroAttrsComp | null {
let mvp: HeroAttrsComp | null = null;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (!model || model.fac !== FacSet.HERO) return;
if (!mvp) {
mvp = model;
} else {
if (model.lv > mvp.lv) {
mvp = model;
} else if (model.lv === mvp.lv && model.ap > mvp.ap) {
mvp = model;
}
}
});
return mvp;
}
/**
* 渲染 MVP 英雄,使用绑定的 CardComp
*/
private renderMVPHero(mvp: HeroAttrsComp | null) {
if (!this.mvp_node) return;
if (!mvp) {
this.mvp_node.active = false;
return;
}
this.mvp_node.active = true;
const uuid = mvp.hero_uuid;
const hero = HeroInfo[uuid];
if (!hero) return;
// 延迟到下一帧执行,因为如果 mvp_node 刚被激活Cocos 会在当前帧稍后/下一帧触发 CardComp 的 onLoad
// 而 CardComp 的 onLoad 中调用了 applyEmptyUI() 会把卡面清空,导致我们的设置被覆盖。
this.scheduleOnce(() => {
if (!this.isValid || !this.mvp_node) return;
// 获取 CardComp 组件
let cardComp = this.mvp_node.getComponent(CardComp);
if (!cardComp) {
cardComp = this.mvp_node.addComponent(CardComp);
}
// 构造虚拟的 CardConfig 数据供渲染
const cardConfig: CardConfig = {
uuid: uuid,
type: CardType.Hero,
cost: 0,
weight: 0,
kind: CKind.Hero,
card_lv: mvp.card_lv || 1,
hero_lv: mvp.lv || 1,
base_card_lv: mvp.base_card_lv || mvp.card_lv || 1,
};
const originalPos = this.mvp_node.position.clone();
// 禁用交互事件,防止结算界面的卡牌被拖拽或点击
this.mvp_node.off(NodeEventType.TOUCH_START);
this.mvp_node.off(NodeEventType.TOUCH_MOVE);
this.mvp_node.off(NodeEventType.TOUCH_END);
this.mvp_node.off(NodeEventType.TOUCH_CANCEL);
if (cardComp.Lock) cardComp.Lock.off(NodeEventType.TOUCH_END);
if (cardComp.unLock) cardComp.unLock.off(NodeEventType.TOUCH_END);
// 应用数据并刷新UI
cardComp.applyDrawCard(cardConfig);
// 隐藏不必要的信息(比如费用)
if (cardComp.cost_node) {
cardComp.cost_node.active = false;
}
// 覆盖 CardComp 内部动画的 scale停止其上的 tween并直接放大
// 结算界面的卡牌需要放大显示170 * 1.35 ≈ 230
Tween.stopAllByTarget(this.mvp_node);
this.mvp_node.setPosition(originalPos);
this.mvp_node.setScale(1.35, 1.35, 1);
}, 0);
}
// ======================== 分数计算(逻辑层,保持不变) ========================
/**
* 获取满足条件的最高等级的亮点成就
@@ -257,6 +185,7 @@ export class VictoryComp extends CCComp {
/**
* 计算单局总分并更新到 smc.vmdata.scores.score。
* 同时刷新 smc.data.score 历史最高分。
*/
private calculateTotalScore() {
const s = smc.vmdata.scores;
@@ -298,6 +227,10 @@ export class VictoryComp extends CCComp {
// 取整并存储当前局分数
s.score = Math.floor(s.score_combat + s.score_output + s.score_defense + s.score_build + s.score_efficiency + highlightBonus);
// 记录滚动前的历史最高分(动画起始值)
this._prevBestScore = smc.data.score;
this._finalScore = s.score;
// 判定是否打破历史最高分记录
let isNewRecord = false;
if (s.score > smc.data.score) {
@@ -320,53 +253,184 @@ export class VictoryComp extends CCComp {
efficiency: s.score_efficiency,
highlightBonus: highlightBonus,
achievedHighlights: achieved,
isNewRecord: isNewRecord
isNewRecord: isNewRecord,
prevBest: this._prevBestScore
});
}
// ======================== 结算动画 ========================
/**
* 渲染得分条与亮点标签
* 依赖各维度对应的UI节点需要在Cocos Creator中拖入绑定
* 播放结算动画:入场 → 总分滚动 → 定格涨幅
*/
private renderScores() {
const s = smc.vmdata.scores;
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 = `${s.score}`;
this.total_score_label.string = `${score}`;
}
}
// 判定是否是新记录,如果是则激活 new 节点
/**
* 渲染段位徽章、进度条与下一段位提示
* @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 isNewRecord = (s as any).isNewRecord === true;
const newNode = this.total_score_label.node.getChildByName("new");
if (newNode) {
newNode.active = isNewRecord;
}
const gain = this._finalScore - this._prevBestScore;
if (!isNewRecord || gain <= 0) {
this.gain_label.node.active = false;
return;
}
// 通用渲染单个维度的函数
const renderDim = (node: Node, score: number, maxScore: number) => {
if (!node) return;
const lab = node.getChildByName("score_label")?.getComponent(Label);
if (lab) lab.string = `${score}`;
this.gain_label.string = `+${gain} 新纪录!`;
this.gain_label.node.active = true;
const bar = node.getChildByName("progress_bar")?.getComponent(ProgressBar);
if (bar) {
// 根据该维度得分占“预期满分”的比例设置进度条fillRange
bar.progress = Math.min(1, Math.max(0, score / maxScore));
// 浮现动效:从 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;
// TODO: 进度条的最大值可按设计期望自行调整,目前为占位预估值
// renderDim(this.combat_node, s.score_combat, 3000);
// renderDim(this.output_node, s.score_output, 3000);
// renderDim(this.defense_node, s.score_defense, 1500);
// renderDim(this.build_node, s.score_build, 1000);
// renderDim(this.efficiency_node, s.score_efficiency, 200);
// 渲染成就亮点标签
this.renderHighlights();
tween(node)
.to(0.35, { scale: v3(1, 1, 1) }, { easing: 'backOut' })
.start();
tween(op)
.to(0.35, { opacity: 255 })
.start();
}
// ======================== 亮点成就 ========================
/**
* 根据当前局数据匹配并生成对应的亮点标签(成就)
*/
@@ -449,6 +513,9 @@ export class VictoryComp extends CCComp {
}
protected onDestroy(): void {
// 停止所有动画,防止节点销毁后 tween 回调访问无效对象
if (this.rank_icon) Tween.stopAllByTarget(this.rank_icon.node);
if (this.gain_label) Tween.stopAllByTarget(this.gain_label.node);
super.onDestroy();
mLogger.log(this.debugMode, 'VictoryComp', "释放胜利界面");
}