Files
pixelheros/assets/script/game/hero/HeroTopBarComp.ts
panFD 3b744fc3f2 fix(heroTopBar): 优化护盾条显示逻辑并添加数值标签
1. 修复原护盾条进度固定满格的问题,改为按护盾与血条上限的比例展示
2. 新增动态创建的护盾数值文本标签,显示格式化后的护盾数值
3. 优化护盾显隐逻辑和进度计算规则,视觉上表现为护盾优先消耗
2026-09-13 13:18:34 +08:00

288 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { _decorator, ProgressBar, Sprite, UIOpacity, UITransform, Vec3, v3, tween, Tween, Color, clamp, Node, Label, LabelOutline } 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 { HeroAttrsComp } from "./HeroAttrsComp";
import { BuffComp } from "./BuffComp";
import { BuffList, BuffCategory, DEBUFF_ICON_MAP } from "../common/config/BuffSet";
import { FacSet, NumberFormatter } from "../common/config/GameSet";
const { ccclass, property } = _decorator;
/**
* 英雄头顶状态栏组件
*
* 挂载在英雄节点下的 "top" 子节点上,独立管理:
* - 血条进度(ProgressBar)
* - 护盾数值条(ProgressBar,top/shield 节点,显示 shield / SHIELD_MAX)
* - 透明度激活 / 闲置
* - 受击抖动动画
*
* 数据流:HeroViewComp 将 HeroAttrsComp 引用传入,本组件通过 model 读取属性。
* 注意:英雄根节点下的 "shielded" 是护盾特效动画节点,由 HeroViewComp 管理,不在本组件职责内。
*/
@ccclass('HeroTopBarComp')
@ecs.register('HeroTopBar', false)
export class HeroTopBarComp extends CCComp {
// ==================== @property 绑定(编辑器拖拽) ====================
@property({ type: ProgressBar, tooltip: "血条进度条" })
private hpProgressBar: ProgressBar = null!;
@property({ type: ProgressBar, tooltip: "护盾数值条(top 下的 shield 节点)" })
private shieldProgressBar: ProgressBar = null!;
@property({ type: Sprite, tooltip: "血条填充 Sprite(用于设置阵营颜色)" })
private hpBarSprite: Sprite = null!;
@property({ type: ProgressBar, tooltip: "能量条(top 下的 cd 节点,反映 skills[1] 技能 CD)" })
private cdProgressBar: ProgressBar = null!;
// ==================== 内部引用 ====================
private topOpacity: UIOpacity = null!;
private basePos: Vec3 = v3();
/** debuff 容器节点(top 下的 debuff 子节点,按名字管理各 debuff 图标) */
private debuffNode: Node = null!;
/** 当前点亮的 debuff 图标节点名集合(用于 diff,避免每帧重复 set active) */
private activeDebuffNames: Set<string> = new Set();
/** 护盾数值文本(护盾条节点下动态创建,显示当前护盾数值) */
private shieldLabel: Label = null!;
// ==================== 配置 ====================
private hp_height: number = 90;
private boss_hp_height: number = 160;
// ==================== 透明度 ====================
/** 闲置透明度:取消默认半透明,顶栏常驻全不透明 */
private readonly barIdleOpacity: number = 255;
private readonly barActiveOpacity: number = 255;
private readonly idleOpacityDelay: number = 0.25;
/** 外部赋值:英雄数据模型 */
private _model: HeroAttrsComp = null!;
public get model(): HeroAttrsComp { return this._model; }
public set model(value: HeroAttrsComp) { this._model = value; }
private readonly restoreBarIdleOpacity = () => {
this.setTopBarOpacity(false);
};
/**
* 初始化 / 对象池复用时重置
* @param model 英雄属性组件
* @param direction 显示方向(1 或 -1)
*/
public init(model: HeroAttrsComp, direction: number = 1): void {
this._model = model;
// UIOpacity 无 @property 绑定,运行时获取或添加
this.topOpacity = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity);
// 方向取 abs,避免对象池复用时 scale 累乘导致血条反转
this.node.setScale(direction * Math.abs(this.node.scale.x), 1 * this.node.scale.y);
// 位置
const height = model.is_boss ? this.boss_hp_height : this.hp_height;
this.node.setPosition(0, height, 0);
this.basePos = this.node.position.clone();
// 血条颜色(英雄阵营为绿色)
if (model.fac === FacSet.HERO && this.hpBarSprite) {
this.hpBarSprite.color = new Color("#2ECC71");
}
// 确保血条等 UI 始终在最上层
this.node.setSiblingIndex(999);
// 重置显示状态
this.node.getChildByName("hp")!.active = true;
this.node.getChildByName("lv")!.active = false;
this.node.active = true;
// 能量条:仅当英雄拥有第二技能(skills[1])时显示
if (this.cdProgressBar) {
this.cdProgressBar.node.active = this._model.getSkillIds().length > 1;
}
this.cdShow();
// debuff 图标容器(无该节点的旧 prefab 容错为 null)
this.debuffNode = this.node.getChildByName("debuff")!;
this.activeDebuffNames.clear();
if (this.debuffNode) {
for (const child of this.debuffNode.children) {
child.active = false;
}
}
this.setTopBarOpacity(false);
this.hpShow();
this.shieldShow(model.shield);
}
/** 更新血量进度条 */
public hpShow(): void {
if (!this._model || !this.hpProgressBar) return;
// 计时 hp_max buff 的增删不走 dirty_hp,显示前先做上限-血量同步(等量回血/到期钳回)
this._model.syncHpWithFinalMax();
const hp = this._model.hp;
// 分母用 getFinalHpMax:计时 hp_max buff 的上限加成需实时反映到血条
const hpMax = this._model.getFinalHpMax();
let targetProgress = hpMax > 0 ? hp / hpMax : 0;
targetProgress = clamp(targetProgress, 0, 1);
const prevProgress = this.hpProgressBar.progress;
if (targetProgress < prevProgress) {
this.activateTopBar();
this.playHpBarShake();
}
this.hpProgressBar.progress = targetProgress;
if (targetProgress > prevProgress) {
this.setTopBarOpacity(false);
}
}
/**
* 更新护盾数值条。
*
* Why 进度按 shield / max(hpMax, shield) 计算:护盾无固定上限,
* 写死满条会导致受击时进度条不动;以血条上限为分母可在护盾被削减时实时反映下降,
* 同时护盾条作为血条之上的覆盖层,视觉上表现为护盾先行消耗。
* @param shield 当前护盾值(可为 0,此时隐藏护盾条)
*/
public shieldShow(shield: number = 0): void {
if (!this.shieldProgressBar) return;
const active = shield > 0;
this.shieldProgressBar.node.active = active;
if (!active) return;
// 进度分母取护盾与血条上限的较大值,保证比例稳定在 [0,1] 且随扣盾实时下降
const hpMax = this._model ? this._model.getFinalHpMax() : 0;
const divisor = Math.max(hpMax, shield, 1);
this.shieldProgressBar.progress = clamp(shield / divisor, 0, 1);
// 数值文本:懒加载创建,随护盾条节点一起显隐
if (!this.shieldLabel) {
const labelNode = new Node("shield_num");
const ui = labelNode.addComponent(UITransform);
labelNode.setParent(this.shieldProgressBar.node);
this.shieldLabel = labelNode.addComponent(Label);
this.shieldLabel.fontSize = 10;
this.shieldLabel.lineHeight = 10;
this.shieldLabel.color = new Color("#FFFFFF");
const outline = labelNode.addComponent(LabelOutline);
outline.color = new Color("#000000");
outline.width = 1;
}
this.shieldLabel.string = NumberFormatter.formatNumber(Math.floor(shield));
}
/**
* 更新能量条进度(= skills[1] 的 CD 充能比例,充满即技能就绪)。
* 由 SCastSystem 每帧 updateCD 后经 HeroViewComp.cd_show 驱动。
*/
public cdShow(): void {
if (!this._model || !this.cdProgressBar) return;
const skillIds = this._model.getSkillIds();
// 无第二技能时隐藏能量条
if (skillIds.length <= 1) {
this.cdProgressBar.node.active = false;
return;
}
this.cdProgressBar.node.active = true;
this.cdProgressBar.progress = clamp(this._model.getSkillCdProgress(skillIds[1]), 0, 1);
}
/**
* 刷新 debuff 图标显隐:按 BuffComp 中当前生效的 debuff 种类点亮对应图标。
* 由 HeroViewComp 在 dirty_buffs 脏标记时调用;与上次状态做 diff,避免重复 set active。
* @param buffComp 目标的 buff 组件(无 BuffComp 时全部隐藏)
*/
public debuffShow(buffComp: BuffComp | null): void {
if (!this.debuffNode) return;
// 收集当前生效的、有图标映射的 debuff 节点名
const wantActive = new Set<string>();
if (buffComp) {
for (const [buffId, layers] of buffComp.buffs) {
if (layers.length === 0) continue;
const cfg = BuffList[buffId];
// 只显示伤害类 debuff(tick 负值)与控制类 debuff;普通 Buff 不占用 debuff 栏
if (!cfg || cfg.category === BuffCategory.Buff) continue;
const nodeName = DEBUFF_ICON_MAP[buffId];
if (nodeName) wantActive.add(nodeName);
}
}
// diff:先隐藏不再需要的,再点亮新增的
for (const name of this.activeDebuffNames) {
if (!wantActive.has(name)) {
const child = this.debuffNode.getChildByName(name);
if (child) child.active = false;
}
}
for (const name of wantActive) {
if (!this.activeDebuffNames.has(name)) {
const child = this.debuffNode.getChildByName(name);
if (child) child.active = true;
}
}
this.activeDebuffNames = wantActive;
}
/** 激活顶栏(高亮 + 延迟恢复闲置透明度) */
public activateTopBar(): void {
this.setTopBarOpacity(true);
this.unschedule(this.restoreBarIdleOpacity);
this.scheduleOnce(this.restoreBarIdleOpacity, this.idleOpacityDelay);
}
/** 设置顶栏透明度 */
public setTopBarOpacity(isActive: boolean): void {
if (!this.node || !this.node.isValid) return;
if (this.topOpacity) {
this.topOpacity.opacity = isActive ? this.barActiveOpacity : this.barIdleOpacity;
}
this.node.active = true;
}
/** 显示顶栏 */
public show(): void {
if (this.node && this.node.isValid) this.node.active = true;
}
/** 隐藏顶栏 */
public hide(): void {
if (this.node && this.node.isValid) this.node.active = false;
}
/** 血条受击抖动动画 */
private playHpBarShake(): void {
if (!this.node || !this.node.isValid) return;
Tween.stopAllByTarget(this.node);
this.node.setPosition(this.basePos);
tween(this.node)
.by(0.04, { position: v3(-3, 0, 0) })
.by(0.04, { position: v3(6, 0, 0) })
.by(0.04, { position: v3(-5, 0, 0) })
.by(0.04, { position: v3(2, 0, 0) })
.call(() => {
this.node.setPosition(this.basePos);
})
.start();
}
/** 对象池回收 / 组件重置时调用 */
public reset(): void {
this.unscheduleAllCallbacks();
this.activeDebuffNames.clear();
if (this.debuffNode && this.debuffNode.isValid) {
for (const child of this.debuffNode.children) {
child.active = false;
}
}
if (this.node && this.node.isValid) {
Tween.stopAllByTarget(this.node);
this.node.setPosition(this.basePos);
}
}
}