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.
367 lines
12 KiB
367 lines
12 KiB
|
3 weeks ago
|
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;
|
||
|
|
}
|