Files
pixelheros/assets/script/game/hero/HeroTopBarComp.ts
panFD 4f749c9a81 refactor(hero): 重构护盾数值逻辑,使用快照最大值作为进度分母
1. 新增shield_max字段存储护盾获得时的快照值,作为护盾进度条固定分母
2. 修改add_shield方法为覆盖式设置护盾,新增注释说明设计原因
3. 移除HeroTopBarComp中冗余的NumberFormatter导入和动态创建的护盾数值标签
4. 重构护盾进度条更新逻辑,使用快照值计算进度,修复原进度条异常问题
2026-09-13 13:40:32 +08:00

269 lines
10 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, Vec3, v3, tween, Tween, Color, clamp, Node } 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 } 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 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 快照而非实时值:护盾为覆盖语义且可能远超血条上限,
* 若分母随扣盾变小,进度条会永远满格不动;以护盾获得时的值为分母,
* 扣盾时进度实时下降,覆盖新护盾时回到满格。
* @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;
const divisor = this._model ? this._model.shield_max : 0;
this.shieldProgressBar.progress = divisor > 0 ? clamp(shield / divisor, 0, 1) : 1;
}
/**
* 更新能量条进度(= 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);
}
}
}