You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
AdLike2048/assets/script/battle/view/MonsterStatusText.ts

366 lines
12 KiB

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<Vec3>, 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<Vec3>, 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;
}