1. 新增RichOutline工具类,封装cc.RichText的outline标签包裹逻辑 2. 为所有道具/技能/英雄描述富文本添加黑色2px描边,提升深色背景下的可读性 3. 优化技能描述渲染逻辑: - 调整数值高亮色为更鲜亮的绿色 - 使用罗马数字作为技能等级徽章 - 移除升级预览弹窗的重复描述,改为当前/升级后双版本对比 - 简化升级确认弹窗的格式排版 4. 调整道具/技能预制体的文字颜色为白色,适配深色面板
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
/**
|
||
* @file SCardComp.ts
|
||
* @description 技能商店单个技能卡项组件(UI 视图层 + 购买逻辑)
|
||
*
|
||
* 职责:
|
||
* 1. 渲染单个技能卡的显示信息(名称、等级、描述词条、图标)。
|
||
* 2. 处理购买点击 → 扣费 → 派发 UseSkillCard 事件触发技能生效。
|
||
*
|
||
* 技能使用机制:
|
||
* 购买后派发 GameEvent.UseSkillCard,
|
||
* 由 MissSkillsComp 接收并创建 SBox 实体 / SkillBoxComp 执行技能逻辑。
|
||
*/
|
||
import { mLogger } from "../common/Logger";
|
||
import { _decorator, Node, Sprite, Label, RichText, NodeEventType, Vec3, UIOpacity, tween, Tween } 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 { CardConfig } from "../common/config/CardSet";
|
||
import { SkillCardList } from "../common/config/SCardSet";
|
||
import { SkillSet } from "../common/config/SkillSet";
|
||
import { buildCardDescRich } from "../common/config/HeroSkillDesc";
|
||
import { outlineRich } from "../common/RichOutline";
|
||
import { oops } from "db://oops-framework/core/Oops";
|
||
import { getCardIconId, setSpriteFrame } from "../common/CardIconHelper";
|
||
import { GameEvent } from "../common/config/GameEvent";
|
||
import { smc } from "../common/SingletonModuleComp";
|
||
|
||
const { ccclass, property } = _decorator;
|
||
|
||
/**
|
||
* SCardComp —— 技能商店单个技能卡项
|
||
*
|
||
* 由 MissionCardComp.dispatchCardsToSkillSlots() 实例化。
|
||
* 以图标 + 名称 + 描述词条 + 购买按钮的形式呈现。
|
||
*/
|
||
@ccclass('SCardComp')
|
||
@ecs.register('SCardComp', false)
|
||
export class SCardComp extends CCComp {
|
||
private debugMode: boolean = false;
|
||
|
||
// ======================== 编辑器绑定节点 ========================
|
||
|
||
/** 技能图标节点 */
|
||
@property({ type: Node })
|
||
private icon_node: Node = null;
|
||
|
||
/** 背景节点(含 green/blue/purple/red/yellow 颜色子节点,按 card_lv 激活) */
|
||
@property({ type: Node })
|
||
private bg_node: Node = null;
|
||
|
||
/** 技能名称 */
|
||
@property(Label)
|
||
private name_label: Label = null;
|
||
|
||
/** 描述富文本(多行,数值绿色高亮、技能名蓝色,由 buildCardDescRich 生成) */
|
||
@property(RichText)
|
||
private info_rich: RichText = null;
|
||
|
||
/** 等级标签 */
|
||
@property(Label)
|
||
private level_label: Label = null;
|
||
|
||
/** 购买按钮节点 */
|
||
@property({ type: Node })
|
||
private buy_node: Node = null;
|
||
|
||
/** 消费金币标签(未绑定时回退查找 buy_node 下的 num/Label 子节点) */
|
||
@property(Label)
|
||
private cost_label: Label = null;
|
||
|
||
// ======================== 运行时状态 ========================
|
||
|
||
/** 当前技能的卡牌配置 */
|
||
private cardData: CardConfig | null = null;
|
||
/** 购买/逝去动画播放中标记(仅用于跳过新卡的普通逝去动画,不影响再次购买) */
|
||
private isPurchased: boolean = false;
|
||
/** 透明度组件(用于淡入淡出动画) */
|
||
private opacityComp: UIOpacity | null = null;
|
||
/** 入场动画时长(秒) */
|
||
private readonly enterDuration: number = 0.25;
|
||
/** 购买/逝去动画时长(秒) */
|
||
private readonly exitDuration: number = 0.2;
|
||
|
||
// ======================== 生命周期 ========================
|
||
|
||
onLoad() {
|
||
this.buy_node?.on(NodeEventType.TOUCH_START, this.onBuyTouchStart, this);
|
||
this.buy_node?.on(NodeEventType.TOUCH_END, this.onBuyClick, this);
|
||
this.buy_node?.on(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this);
|
||
this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity);
|
||
}
|
||
|
||
onDestroy() {
|
||
super.onDestroy();
|
||
if (this.buy_node && this.buy_node.isValid) {
|
||
this.buy_node.off(NodeEventType.TOUCH_START, this.onBuyTouchStart, this);
|
||
this.buy_node.off(NodeEventType.TOUCH_END, this.onBuyClick, this);
|
||
this.buy_node.off(NodeEventType.TOUCH_CANCEL, this.onBuyTouchCancel, this);
|
||
}
|
||
}
|
||
|
||
// ======================== 数据初始化 ========================
|
||
|
||
/**
|
||
* 初始化技能卡数据并渲染 UI
|
||
*
|
||
* 若槽位已有旧卡且显示中,先播普通逝去动画(缩小淡出),结束后切新卡;
|
||
* 刚购买的槽位跳过逝去动画直接应用新卡(购买动画已播放大淡出)。
|
||
*
|
||
* @param data 技能卡配置,传 null 时清空显示
|
||
*/
|
||
applyCardData(data: CardConfig | null): void {
|
||
// 刚购买(已播放大逝去动画)→ 不播普通逝去动画,直接应用新卡
|
||
if (this.isPurchased) {
|
||
this.cardData = data;
|
||
this.isPurchased = false;
|
||
if (data) {
|
||
this.node.active = true;
|
||
this.updateUI();
|
||
} else {
|
||
this.applyEmptyUI();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 旧卡存在且显示中 → 播普通逝去动画,结束后切新卡
|
||
if (this.cardData && this.node.active) {
|
||
this.playRefreshExitAnim(() => {
|
||
this.cardData = data;
|
||
this.isPurchased = false;
|
||
if (data) {
|
||
this.node.active = true;
|
||
this.updateUI();
|
||
} else {
|
||
this.applyEmptyUI();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 首次赋值或空槽 → 直接渲染
|
||
this.cardData = data;
|
||
this.isPurchased = false;
|
||
if (data) {
|
||
this.node.active = true;
|
||
this.updateUI();
|
||
} else {
|
||
this.applyEmptyUI();
|
||
}
|
||
}
|
||
|
||
/** 清空 UI 显示 */
|
||
private applyEmptyUI() {
|
||
// 清理动画状态
|
||
Tween.stopAllByTarget(this.node);
|
||
if (this.opacityComp) {
|
||
Tween.stopAllByTarget(this.opacityComp);
|
||
this.opacityComp.opacity = 255;
|
||
}
|
||
this.node.setScale(1, 1, 1);
|
||
|
||
if (this.name_label) this.name_label.string = "";
|
||
if (this.level_label) this.level_label.string = "";
|
||
if (this.info_rich) {
|
||
this.info_rich.string = "";
|
||
this.info_rich.node.active = false;
|
||
}
|
||
if (this.icon_node) {
|
||
const sprite = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite);
|
||
if (sprite) sprite.spriteFrame = null;
|
||
}
|
||
if (this.buy_node) {
|
||
this.buy_node.active = false;
|
||
}
|
||
this.node.active = false;
|
||
}
|
||
|
||
// ======================== UI 渲染 ========================
|
||
|
||
updateUI() {
|
||
if (!this.cardData) return;
|
||
|
||
const skillCard = SkillCardList.find(c => c.uuid === this.cardData!.uuid);
|
||
const skill = this.cardData.skill ? SkillSet[this.cardData.skill] : null;
|
||
|
||
// 名称
|
||
if (this.name_label) {
|
||
this.name_label.string = skillCard?.name || skill?.name || this.cardData.name || "";
|
||
}
|
||
|
||
// 等级标签(★ 表示卡牌等级)
|
||
if (this.level_label) {
|
||
const lv = this.cardData.card_lv ?? 1;
|
||
this.level_label.string = lv >= 2 ? "★".repeat(lv - 1) : "";
|
||
}
|
||
|
||
// 背景:按 card_lv 激活对应颜色子节点(1→green 2→blue 3→purple 4→red 5→yellow)
|
||
this.updateBgNode();
|
||
|
||
// 描述富文本(数值随 overrides 档位自动同步高亮)
|
||
// outlineRich 为整段富文本包裹描边,提升深色面板可读性
|
||
if (this.info_rich) {
|
||
const desc = buildCardDescRich(this.cardData);
|
||
this.info_rich.string = outlineRich(desc);
|
||
this.info_rich.node.active = !!desc;
|
||
}
|
||
|
||
// 图标(共享工具解析:cardData.icon → FieldSkillSet → SkillSet)
|
||
if (this.icon_node) {
|
||
const iconId = getCardIconId(this.cardData);
|
||
if (iconId) {
|
||
setSpriteFrame(this.icon_node, iconId);
|
||
}
|
||
}
|
||
|
||
// 购买按钮费用显示
|
||
if (this.buy_node) {
|
||
this.buy_node.active = true;
|
||
const costLabel = this.cost_label
|
||
|| this.buy_node.getChildByName("num")?.getComponent(Label)
|
||
|| this.buy_node.getComponentInChildren(Label);
|
||
if (costLabel) {
|
||
costLabel.string = `${this.cardData.cost ?? 0}`;
|
||
}
|
||
}
|
||
|
||
// 播放入场动画(由小变大 + 淡入)
|
||
this.playEnterAnim();
|
||
}
|
||
|
||
/**
|
||
* 根据 card_lv 切换 bg_node 下的颜色子节点显示。
|
||
* 等级与颜色对应关系:
|
||
* card_lv 1 → green
|
||
* card_lv 2 → blue
|
||
* card_lv 3 → purple
|
||
* card_lv 4 → red
|
||
* card_lv 5 → yellow
|
||
*/
|
||
private updateBgNode() {
|
||
if (!this.bg_node || !this.cardData) return;
|
||
const lvToColor: Record<number, string> = {
|
||
1: "green",
|
||
2: "blue",
|
||
3: "purple",
|
||
4: "red",
|
||
5: "yellow",
|
||
};
|
||
const targetColor = lvToColor[this.cardData.card_lv ?? 1];
|
||
this.bg_node.children.forEach(child => {
|
||
child.active = (child.name === targetColor);
|
||
});
|
||
}
|
||
|
||
// ======================== 动画 ========================
|
||
|
||
/** 播放入场动画:卡牌由小变大(scale 0→1)+ 淡入 */
|
||
private playEnterAnim() {
|
||
Tween.stopAllByTarget(this.node);
|
||
if (this.opacityComp) {
|
||
Tween.stopAllByTarget(this.opacityComp);
|
||
}
|
||
this.node.setScale(0, 0, 1);
|
||
if (this.opacityComp) {
|
||
this.opacityComp.opacity = 0;
|
||
}
|
||
tween(this.node)
|
||
.to(this.enterDuration, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' })
|
||
.start();
|
||
if (this.opacityComp) {
|
||
tween(this.opacityComp)
|
||
.to(this.enterDuration, { opacity: 255 })
|
||
.start();
|
||
}
|
||
}
|
||
|
||
/** 播放购买动画:卡牌放大(scale 1→1.1)+ 淡出 */
|
||
private playPurchaseAnim() {
|
||
Tween.stopAllByTarget(this.node);
|
||
if (this.opacityComp) {
|
||
Tween.stopAllByTarget(this.opacityComp);
|
||
}
|
||
tween(this.node)
|
||
.to(this.exitDuration, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadIn' })
|
||
.start();
|
||
if (this.opacityComp) {
|
||
tween(this.opacityComp)
|
||
.to(this.exitDuration, { opacity: 0 })
|
||
.start();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 播放普通刷新"逝去"动画:旧卡缩小(scale 1→0)+ 淡出,结束后回调切换新卡。
|
||
* @param onComplete 动画结束回调,用于应用新卡数据
|
||
*/
|
||
private playRefreshExitAnim(onComplete: () => void) {
|
||
Tween.stopAllByTarget(this.node);
|
||
if (this.opacityComp) {
|
||
Tween.stopAllByTarget(this.opacityComp);
|
||
}
|
||
tween(this.node)
|
||
.to(this.exitDuration, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' })
|
||
.start();
|
||
if (this.opacityComp) {
|
||
tween(this.opacityComp)
|
||
.to(this.exitDuration, { opacity: 0 })
|
||
.call(onComplete)
|
||
.start();
|
||
} else {
|
||
this.scheduleOnce(onComplete, this.exitDuration);
|
||
}
|
||
}
|
||
|
||
// ======================== 按钮动画 ========================
|
||
|
||
/** 按钮正常缩放 */
|
||
private readonly btnNormalScale: number = 1;
|
||
/** 按钮按下缩放 */
|
||
private readonly btnPressScale: number = 0.92;
|
||
/** 按钮点击回弹峰值缩放 */
|
||
private readonly btnClickScale: number = 1.08;
|
||
|
||
/** 购买按钮按下:缩小反馈 */
|
||
private onBuyTouchStart() {
|
||
this.playBtnScaleTo(this.btnPressScale, 0.06);
|
||
}
|
||
|
||
/** 购买按钮取消:恢复缩放 */
|
||
private onBuyTouchCancel() {
|
||
this.playBtnScaleTo(this.btnNormalScale, 0.08);
|
||
}
|
||
|
||
/** 按钮缩放动画(通用) */
|
||
private playBtnScaleTo(scale: number, duration: number) {
|
||
if (!this.buy_node || !this.buy_node.isValid) return;
|
||
Tween.stopAllByTarget(this.buy_node);
|
||
tween(this.buy_node)
|
||
.to(duration, { scale: new Vec3(scale, scale, 1) })
|
||
.start();
|
||
}
|
||
|
||
/** 按钮点击回弹动画:先弹到峰值再回到正常 */
|
||
private playBtnClickAnim() {
|
||
if (!this.buy_node || !this.buy_node.isValid) return;
|
||
Tween.stopAllByTarget(this.buy_node);
|
||
tween(this.buy_node)
|
||
.to(0.05, { scale: new Vec3(this.btnClickScale, this.btnClickScale, 1) })
|
||
.to(0.08, { scale: new Vec3(this.btnNormalScale, this.btnNormalScale, 1) })
|
||
.start();
|
||
}
|
||
|
||
// ======================== 购买逻辑 ========================
|
||
|
||
/**
|
||
* 购买点击处理:
|
||
* 1. 派发 UseSkillCard 事件,触发技能生效流程。
|
||
* 2. 播放购买动画,购买后可继续购买(购买按钮保持可见)。
|
||
*
|
||
* 注:购买不花费金币(免费获取),原扣费逻辑已移除。
|
||
*/
|
||
private onBuyClick() {
|
||
if (!this.cardData) return;
|
||
|
||
// 播放按钮点击回弹动画
|
||
this.playBtnClickAnim();
|
||
|
||
oops.audio.playEffect("music/button");
|
||
|
||
smc.vmdata.scores.refresh_hit_count++;
|
||
|
||
// 派发 UseSkillCard,技能生效流程由 MissSkillsComp 接收处理
|
||
oops.message.dispatchEvent(GameEvent.UseSkillCard, this.cardData);
|
||
|
||
// 播放购买动画(卡牌放大 + 淡出)
|
||
this.isPurchased = true;
|
||
this.playPurchaseAnim();
|
||
|
||
// 派发 CardUsed 事件,通知 MissionCardComp 刷新技能卡池
|
||
oops.message.dispatchEvent(GameEvent.CardUsed, this);
|
||
|
||
mLogger.log(this.debugMode, "SCardComp", "skill card purchased", {
|
||
uuid: this.cardData.uuid
|
||
});
|
||
}
|
||
|
||
/** 标记购买动画播放中(供外部在刷新前保持动画状态,不再用于限购) */
|
||
setPurchased(): void {
|
||
this.isPurchased = true;
|
||
}
|
||
|
||
/**
|
||
* 接收 MissionCardComp 分发的卡牌并刷新显示。
|
||
* @param data 卡牌配置,null 时清空显示
|
||
* @returns 是否成功接收(true = 接收并刷新 UI)
|
||
*/
|
||
applyDrawCard(data: CardConfig | null): boolean {
|
||
this.applyCardData(data);
|
||
return !!data;
|
||
}
|
||
|
||
/**
|
||
* 系统清槽:任务开始/结束时强制重置。
|
||
* 清空数据与 UI 并隐藏节点。
|
||
*/
|
||
clearBySystem() {
|
||
this.cardData = null;
|
||
this.isPurchased = false;
|
||
this.applyEmptyUI();
|
||
this.node.active = false;
|
||
}
|
||
|
||
/** ECS 组件移除时销毁节点 */
|
||
reset() {
|
||
if (this.node && this.node.isValid) {
|
||
this.node.destroy();
|
||
}
|
||
}
|
||
}
|