import { director, Director, game, Label, Node, Tween, UIOpacity, Vec3 } from 'cc'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GBundle } from '../../game/ConfigRes'; import { DeviceProfile } from './DeviceProfile'; import type { ProjectBattleHandle } from '../runtime/BattleRuntime'; import { getBattleTimeScale } from '../skill/BattlePresentationClock'; import { BATTLE_MONSTER_CCCLASS, type IBattleMonsterView } from './IBattleMonsterView'; /** 弹出放大峰值倍率 */ const POP_PEAK = 1.35; /** 弹出前半:缩放到峰值(秒) */ const POP_OUT_SEC = 0.08; /** 弹出后半:回落到 1(秒) */ const POP_SETTLE_SEC = 0.1; /** 上浮像素:少抬一点,避免飞太高 */ const FLOAT_UP_PX = 12; /** 弹出后停留再上飘(秒) */ const HOLD_SEC = 1; /** 上飘渐隐时长(秒):快速消散 */ const FLOAT_SEC = 0.3; /** 相对血条再抬高 */ const ANCHOR_LIFT_PX = 20; /** battleText 预制体默认字号(回池还原) */ const DEFAULT_FONT_SIZE = 20; /** 血上限提升飘字 */ export const STATUS_TEXT_HP_CAP = '血上限'; /** 还魂丹原地复活飘字 */ export const STATUS_TEXT_REVIVE = '复活'; /** 血上限提升时显示的箭头子节点 */ const HP_CAP_ARROW = '上箭头'; /** 飘字阶段:弹出 → 停留 → 上飘 */ enum StatusTextPhase { /** 缩小冲到峰值 */ PopOut = 0, /** 峰值回落到 1 */ PopSettle = 1, /** 钉在锚点停留 */ Hold = 2, /** 上浮渐隐 */ Float = 3, } /** 一条状态飘字(跟随实体或定点世界坐标) */ interface StatusTextJob { /** 飘字节点 */ node: Node; /** 跟随的怪物;定点模式为 null */ entityId: EntityId | null; /** 定点世界坐标(仅 entityId 为空时有效) */ fixedWorld: Vec3 | null; /** 透明度 */ fade: UIOpacity; /** 当前阶段 */ phase: StatusTextPhase; /** 当前阶段剩余秒 */ phaseLeft: number; /** 上飘剩余秒(Float 阶段) */ floatLeft: number; /** 文案(同实体去重 / 血上限优先) */ text: string; } /** * 状态飘字:先弹出再停留上浮淡出;怪物模式跟随位移,定点模式钉在世界坐标。 */ export class MonsterStatusText { private _layer: Node | null = null; private readonly _jobs: StatusTextJob[] = []; private readonly _world = new Vec3(); private _listening = false; /** 绑定飘字层(与伤害数字同层) */ bind(layer: Node): void { this.unbind(); this._layer = layer; } /** 回收未结束的飘字并解绑 */ unbind(): void { this._recycleAll(); this._layer = null; } /** * 在怪物头顶飘出状态字。 * @param entityId 怪物实体 * @param text 展示文案 */ play(entityId: EntityId, text: string): void { if (!this._acceptEntityText(entityId, text)) return; if (this._writeEntityAnchor(entityId, this._world)) { this._spawn(text, entityId, null, this._world); return; } const battle = gg.game.CurentBattle as ProjectBattleHandle | null | undefined; const pos = battle?.getMonsterPresenter?.()?.getEntityWorldPos(entityId); if (!pos) return; this._world.set(pos.x, pos.y + ANCHOR_LIFT_PX, pos.z); this._spawn(text, null, new Vec3(this._world.x, this._world.y, this._world.z), this._world); } /** * 在指定世界坐标飘出状态字(不跟随实体,如神将技能名)。 * @param worldPos 世界坐标 * @param text 展示文案 * @param fontSize 可选字号;缺省用预制体默认 */ playAtWorld(worldPos: Readonly, text: string, fontSize?: number): void { this._spawn(text, null, new Vec3(worldPos.x, worldPos.y, worldPos.z), worldPos, fontSize); } /** * 按当前头顶锚点定点飘字(不跟随)。缩放会把跟随锚点打到脚底时用。 * @param entityId 怪物 * @param text 展示文案 */ playPinned(entityId: EntityId, text: string): void { if (!this._writeEntityAnchor(entityId, this._world)) { this.play(entityId, text); return; } this._spawn(text, null, new Vec3(this._world.x, this._world.y, this._world.z), this._world); } /** * 取预制体并入队一条飘字。 * @param text 文案 * @param entityId 跟随实体;定点为 null * @param fixedWorld 定点坐标副本;跟随为 null * @param spawnPos 初始世界坐标 * @param fontSize 可选字号 */ private _spawn(text: string, entityId: EntityId | null, fixedWorld: Vec3 | null, spawnPos: Readonly, fontSize?: number): void { const label = String(text ?? '').trim(); if (!label) return; if (this._jobs.length >= DeviceProfile.statusTextBurstMax) return; const layer = this._layer; if (!layer?.isValid) return; let node: Node | null = null; try { node = gg.res.getNode('prefab/', 'battleText', GBundle.BattleGame); } catch { console.log('[MonsterStatusText] battleText 取节点失败'); return; } if (!node?.isValid) { console.log('[MonsterStatusText] battleText 预制体缺失'); return; } const lab = node.getComponent(Label); if (!lab) { console.log('[MonsterStatusText] battleText 无 Label'); gg.res.putNode(node); return; } lab.string = label; lab.fontSize = fontSize != null && fontSize > 0 ? fontSize : DEFAULT_FONT_SIZE; this._setHpCapArrow(node, label === STATUS_TEXT_HP_CAP); let fade = node.getComponent(UIOpacity); if (!fade) fade = node.addComponent(UIOpacity); fade.opacity = 255; node.setParent(layer); node.active = true; node.setScale(0.5, 0.5, 1); node.setWorldPosition(spawnPos); node.setSiblingIndex(layer.children.length - 1); Tween.stopAllByTarget(node); Tween.stopAllByTarget(fade); this._jobs.push({ node, entityId, fixedWorld, fade, phase: StatusTextPhase.PopOut, phaseLeft: POP_OUT_SEC, floatLeft: FLOAT_SEC, text: label, }); this._startTick(); } /** 每帧更新锚点并推进弹出 / 停留 / 上飘 */ private readonly _onTick = (): void => { const dt = (game.deltaTime || 1 / 60) * getBattleTimeScale(); for (let i = this._jobs.length - 1; i >= 0; i--) { if (!this._stepJob(this._jobs[i], dt)) this._recycleAt(i); } }; /** * 推进一条飘字。 * @param job 飘字 * @param dt 战斗逻辑秒 * @returns 是否仍存活 */ private _stepJob(job: StatusTextJob, dt: number): boolean { const node = job.node; const fade = job.fade; if (!node?.isValid || !fade?.isValid) return false; if (!this._writeJobAnchor(job, this._world)) return false; fade.opacity = 255; node.setWorldPosition(this._world); if (job.phase === StatusTextPhase.PopOut) { job.phaseLeft -= dt; const t = 1 - Math.max(0, job.phaseLeft) / POP_OUT_SEC; const s = 0.5 + (POP_PEAK - 0.5) * Math.sin(t * Math.PI * 0.5); node.setScale(s, s, 1); if (job.phaseLeft > 0) return true; job.phase = StatusTextPhase.PopSettle; job.phaseLeft = POP_SETTLE_SEC; return true; } if (job.phase === StatusTextPhase.PopSettle) { job.phaseLeft -= dt; const t = 1 - Math.max(0, job.phaseLeft) / POP_SETTLE_SEC; const s = POP_PEAK + (1 - POP_PEAK) * t; node.setScale(s, s, 1); if (job.phaseLeft > 0) return true; node.setScale(1, 1, 1); job.phase = StatusTextPhase.Hold; job.phaseLeft = HOLD_SEC; return true; } if (job.phase === StatusTextPhase.Hold) { node.setScale(1, 1, 1); job.phaseLeft -= dt; if (job.phaseLeft > 0) return true; job.phase = StatusTextPhase.Float; job.floatLeft = FLOAT_SEC; return true; } job.floatLeft -= dt; const t = 1 - Math.max(0, job.floatLeft) / FLOAT_SEC; const lift = FLOAT_UP_PX * Math.sin(t * Math.PI * 0.5); fade.opacity = 255 * Math.cos(t * Math.PI * 0.5); this._world.y += lift; node.setScale(1, 1, 1); node.setWorldPosition(this._world); return job.floatLeft > 0; } /** * 写入本帧锚点。 * @param job 飘字任务 * @param out 输出 */ private _writeJobAnchor(job: StatusTextJob, out: Vec3): boolean { if (job.fixedWorld) { out.set(job.fixedWorld.x, job.fixedWorld.y, job.fixedWorld.z); return true; } if (job.entityId == null) return false; return this._writeEntityAnchor(job.entityId, out); } /** 头顶锚点:血条优先 */ private _writeEntityAnchor(entityId: EntityId, out: Vec3): boolean { const battle = gg.game.CurentBattle as ProjectBattleHandle | null | undefined; const node = battle?.getMonsterPresenter?.()?.getEntityNodeMap().get(entityId); if (!node?.isValid) return false; const view = node.getComponent(BATTLE_MONSTER_CCCLASS) as IBattleMonsterView | null; const anchor = view?.bloodPos?.isValid ? view.bloodPos : node; const p = anchor.worldPosition; out.set(p.x, p.y + ANCHOR_LIFT_PX, p.z); return true; } private _startTick(): void { if (this._listening) return; this._listening = true; director.on(Director.EVENT_AFTER_UPDATE, this._onTick, this); } private _stopTick(): void { if (!this._listening) return; this._listening = false; director.off(Director.EVENT_AFTER_UPDATE, this._onTick, this); } /** * 回收指定下标的飘字。 * @param index 队列下标 */ private _recycleAt(index: number): void { const job = this._jobs[index]; this._jobs.splice(index, 1); this._put(job.node); if (this._jobs.length === 0) this._stopTick(); } private _recycleAll(): void { this._stopTick(); for (let i = 0; i < this._jobs.length; i++) { this._put(this._jobs[i].node); } this._jobs.length = 0; } /** * 停动画并回池。 * @param node 飘字节点 */ private _put(node: Node): void { if (!node?.isValid) return; Tween.stopAllByTarget(node); const fade = node.getComponent(UIOpacity); if (fade) { Tween.stopAllByTarget(fade); fade.opacity = 255; } const lab = node.getComponent(Label); if (lab) lab.fontSize = DEFAULT_FONT_SIZE; this._setHpCapArrow(node, false); node.setScale(1, 1, 1); gg.res.putNode(node); } /** * 同实体只留一条;血上限不被「加血」顶掉。 * @param entityId 怪物 * @param text 新文案 */ private _acceptEntityText(entityId: EntityId, text: string): boolean { for (let i = this._jobs.length - 1; i >= 0; i--) { if (this._jobs[i].entityId !== entityId) continue; if (this._jobs[i].text === STATUS_TEXT_HP_CAP && text !== STATUS_TEXT_HP_CAP) { return false; } this._recycleAt(i); } return true; } /** * 仅血上限提升显示「上箭头」。 * @param node battleText 根节点 * @param show 是否显示 */ private _setHpCapArrow(node: Node, show: boolean): void { const arrow = node.getChildByName(HP_CAP_ARROW); if (!arrow) { if (show) console.warn('[MonsterStatusText] 缺少上箭头节点'); return; } arrow.active = show; } } let _inst: MonsterStatusText | null = null; /** 全局状态飘字(单局 bind 飘字层) */ export function getMonsterStatusText(): MonsterStatusText { if (!_inst) _inst = new MonsterStatusText(); return _inst; }