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/ui/BattleGame/BattleWeaponDropPresenter.ts

625 lines
22 KiB

import {
_decorator, Button, Canvas, Component, instantiate, Label, Node, Rect, Sprite, Tween, tween, UIOpacity, UITransform, Vec3, view, Widget,
} from 'cc';
import { SpriteLoad } from 'db://assets/mx/components/SpriteLoad';
import { GBundle } from '../../game/ConfigRes';
import { BATTLE_HERO_WEAPON_PATH } from '../../battle/res/BattleHeroResCollector';
import type { ProjectBattleHandle } from '../../battle/runtime/BattleRuntime';
import type { EntityId } from 'db://assets/mx/module/ecs';
import type { WeaponDropRecord } from '../../battle/combat/WeaponDropService';
import { BattleAudio } from '../../battle/audio/BattleAudio';
const { ccclass } = _decorator;
/** 战场掉落图标子节点名 */
const ICON_NODE = '图标';
/** 结算模板图标子节点名 */
const SETTLE_ICON_NODE = 'name';
/** 碎片数量底节点名 */
const NUM_BG_NODE = 'numBg';
/** 碎片数量文本节点名 */
const NUM_LABEL_NODE = 'lb_weaponNum';
/** 暂停列表敌方角标节点名(预制体已挂贴图,仅控制显隐) */
const ENEMY_MARK_NODE = '敌方';
/** 图标显示尺寸(与预制体一致) */
const ICON_W = 68;
const ICON_H = 35;
/** 掉落根节点默认尺寸(与「武器」预制体 UITransform 一致) */
const DROP_W = 88;
const DROP_H = 48;
/** 重力(像素/秒²) */
const GRAVITY = -3200;
/** 落地弹性 */
const BOUNCE = 0.42;
/** 落地水平摩擦 */
const GROUND_FRICTION = 0.55;
/** 视为静止的速度 */
const SETTLE_SPEED = 90;
/** 屏幕内边距(像素),避免贴边被裁 */
const SCREEN_PAD = 8;
/** 飞向暂停按钮时长 */
const FLY_SEC = 0.55;
/** 到达后渐隐时长 */
const FADE_SEC = 0.18;
/** 掉落阶段 */
enum DropPhase {
/** 空闲 / 暂停列表展示 */
Idle = 0,
/** 物理掉落中 */
Drop = 1,
/** 等待玩家点击拾取 */
WaitClick = 2,
/** 飞向暂停按钮 */
Fly = 3,
}
/**
* 「武器」预制体掉落视图:物理落地、点击拾取、飞入暂停按钮。
*/
@ccclass('WeaponDropView')
export class WeaponDropView extends Component {
private _phase = DropPhase.Idle;
private _battle: ProjectBattleHandle | null = null;
private _pauseBtn: Node | null = null;
private _needClick = false;
private _vx = 0;
private _vy = 0;
private _groundY = 0;
private _minX = 0;
private _maxX = 0;
private _minY = 0;
private _maxY = 0;
private _hasBounds = false;
private readonly _pos = new Vec3();
private readonly _onTouchEnd = this._handleClick.bind(this);
private readonly _onFlyDone = this._finishFly.bind(this);
private readonly _onFadeDone = this._recycle.bind(this);
/**
* 克隆结算页 ui_weaponTemp 模板。
* @param template BattleSuccess / BattleFail 下 ui_weaponTemp
*/
static takeSettlePrefab(template: Node | null | undefined): Node | null {
if (!template?.isValid) {
console.log('[WeaponDrop] ui_weaponTemp 模板无效');
return null;
}
const node = instantiate(template);
node.active = true;
return node;
}
/** 从节点池取「武器」预制体(战场掉落 / 暂停列表) */
static takePrefab(): Node | null {
let node: Node | null = null;
try {
node = gg.res.getNode(`${BATTLE_HERO_WEAPON_PATH}/`, '武器', GBundle.BattleHero);
} catch (e) {
console.log('[WeaponDrop] 预制体加载异常', String(e));
return null;
}
if (!node?.isValid) {
console.log('[WeaponDrop] 武器预制体缺失');
return null;
}
return node;
}
/**
* 按宝石信息刷新底框与图标(掉落 / 暂停列表共用)。
* @param node 「武器」预制体根节点(仍复用该预制)
* @param record 掉落记录
*/
static applyVisual(node: Node, record: WeaponDropRecord): void {
WeaponDropView.clearFragmentCount(node);
const weapon = gg.equip.getEquip(record.weaponId);
if (!weapon) {
console.warn('[WeaponDrop] 宝石表缺失', record.weaponId);
return;
}
const bgIndex = WeaponDropView._gemFrameIndex(record.quality, record.weaponId);
WeaponDropView._setSprite(node, `weapon/宝石框大${bgIndex}`);
const bgSprite = node.getComponent(Sprite);
if (bgSprite) bgSprite.type = Sprite.Type.SLICED;
const iconName = String(weapon.icon ?? '').trim();
if (!iconName) {
console.warn('[WeaponDrop] icon 缺失', record.weaponId);
}
const icon = node.getChildByName(ICON_NODE);
if (!icon?.isValid) {
console.warn('[WeaponDrop] 缺少图标节点', record.weaponId);
return;
}
WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true);
}
/**
* 结算列表:刷新 ui_weaponTemp 克隆体底框与 name 图标。
* @param node ui_weaponTemp 克隆根节点
* @param record 掉落记录
*/
static applySettleVisual(node: Node, record: WeaponDropRecord): void {
WeaponDropView.clearFragmentCount(node);
const weapon = gg.equip.getEquip(record.weaponId);
if (!weapon) {
console.warn('[WeaponDrop] 宝石表缺失', record.weaponId);
return;
}
const bgIndex = WeaponDropView._gemFrameIndex(record.quality, record.weaponId);
WeaponDropView._setSprite(node, `weapon/宝石框大${bgIndex}`);
const bgSprite = node.getComponent(Sprite);
if (bgSprite) bgSprite.type = Sprite.Type.SLICED;
const iconName = String(weapon.icon ?? '').trim();
if (!iconName) {
console.warn('[WeaponDrop] icon 缺失', record.weaponId);
}
const icon = node.getChildByName(SETTLE_ICON_NODE);
if (!icon?.isValid) {
console.warn('[WeaponDrop] 缺少 name 节点', record.weaponId);
return;
}
WeaponDropView._setSprite(icon, iconName ? `weapon/${iconName}` : '', true);
}
/** 销毁结算 ui_weaponTemp 克隆体(勿回战场武器池) */
static recycleSettleNode(node: Node): void {
if (!node?.isValid) return;
node.getComponent(WeaponDropView)?.resetMotion();
node.destroy();
}
/**
* 暂停列表:敌方奖励显示「敌方」角标,我方奖励隐藏。
* @param node 武器预制体根节点
* @param isSelf 是否己方击杀掉落
*/
static applySideMark(node: Node, isSelf: boolean): void {
if (!node?.isValid) return;
const mark = node?.find(ENEMY_MARK_NODE);
if (!mark?.isValid) {
console.log('[WeaponDrop] 缺少敌方节点');
return;
}
mark.active = !isSelf;
}
/** 隐藏敌方角标(战场掉落 / 回池) */
static clearSideMark(node: Node | null | undefined): void {
if (!node?.isValid) return;
const mark = node?.find(ENEMY_MARK_NODE);
if (mark?.isValid) mark.active = false;
}
/**
* 结算列表:显示武器碎片数量。
* @param node 武器预制体根节点
* @param count 碎片数量
*/
static applyFragmentCount(node: Node, count: number): void {
const numBg = node.getChildByName(NUM_BG_NODE);
if (!numBg?.isValid) {
console.log('[WeaponDrop] 缺少 numBg');
return;
}
const num = Math.max(0, Math.floor(count));
if (num <= 0) {
numBg.active = false;
return;
}
numBg.active = true;
const lb = numBg.getChildByName(NUM_LABEL_NODE)?.getComponent(Label);
if (!lb) {
console.log('[WeaponDrop] 缺少 lb_weaponNum');
return;
}
lb.string = `${num}`;
}
/** 隐藏碎片数量(战场掉落 / 回池) */
static clearFragmentCount(node: Node): void {
const numBg = node.getChildByName(NUM_BG_NODE);
if (!numBg?.isValid) return;
numBg.active = false;
const lb = numBg.getChildByName(NUM_LABEL_NODE)?.getComponent(Label);
if (lb) lb.string = '';
}
/**
* Weapon.quality → 宝石框大资源下标(现有资源 1~5)。
* @param quality 品质
* @param weaponId 宝石 id(仅日志)
*/
private static _gemFrameIndex(quality: number, weaponId: number): number {
const q = Number(quality) | 0;
if (q < 1 || q > 5) {
console.warn('[WeaponDrop] 宝石品质无对应宝石框大', weaponId, q);
}
return q;
}
/**
* 加载 icons/weapon 图。
* @param node 目标节点
* @param path bundle 内路径
* @param fitIcon 是否把图标压到预制体尺寸
*/
private static _setSprite(node: Node, path: string, fitIcon = false): void {
if (!node?.isValid) return;
if (!path) {
node.active = false;
return;
}
node.active = true;
node.getOrAddComponent(SpriteLoad).setSprite(path, GBundle.Items);
}
/**
* 世界掉落:物理落地后,己方等待点击、敌方自动飞入。
* @param battle 当前战斗
* @param pauseBtn 暂停按钮
* @param startWorld 死亡世界坐标
* @param needClick 是否需要点击拾取
*/
playWorldDrop(battle: ProjectBattleHandle, pauseBtn: Node | null, startWorld: Readonly<Vec3>, needClick: boolean): void {
this.resetMotion();
WeaponDropView.clearSideMark(this.node);
this._battle = battle;
this._pauseBtn = pauseBtn;
this._needClick = needClick;
const node = this.node;
this._captureScreenBounds();
this._pos.set(startWorld.x, startWorld.y, startWorld.z);
this._clampInScreen(this._pos, false);
node.setWorldPosition(this._pos);
node.setScale(1, 1, 1);
node.angle = 0;
this._setOpacity(255);
this._vx = (Math.random() - 0.5) * 280;
this._vy = 120 + Math.random() * 80;
this._groundY = this._pos.y - (18 + Math.random() * 28);
if (this._hasBounds) {
this._groundY = Math.min(this._maxY, Math.max(this._minY, this._groundY));
}
this._phase = DropPhase.Drop;
this._setClickable(false);
}
/** 取战斗面板/画布世界包围盒,按掉落物半尺寸得到可停留范围 */
private _captureScreenBounds(): void {
this._hasBounds = false;
let panelBox: Rect | null = null;
let canvasBox: Rect | null = null;
let n: Node | null = this.node.parent;
while (n?.isValid) {
const ut = n.getComponent(UITransform);
const widget = n.getComponent(Widget);
if (ut && widget && (widget.alignFlags & 45) === 45) {
const worldBox = ut.getBoundingBoxToWorld();
if (worldBox.width > 1 && worldBox.height > 1) {
panelBox = worldBox;
}
}
if (n.getComponent(Canvas) && ut) {
const worldBox = ut.getBoundingBoxToWorld();
if (worldBox.width > 1 && worldBox.height > 1) {
canvasBox = worldBox;
}
break;
}
n = n.parent;
}
let box = panelBox ?? canvasBox;
if (!box) {
box = this._fallbackScreenBox();
}
if (!box) {
console.log('[WeaponDrop] 无法取得画布范围,掉落可能出屏');
return;
}
const selfUt = this.node.getComponent(UITransform);
const hw = Math.max(1, (selfUt?.width ?? DROP_W) * 0.5) + SCREEN_PAD;
const hh = Math.max(1, (selfUt?.height ?? DROP_H) * 0.5) + SCREEN_PAD;
this._minX = box.x + hw;
this._maxX = box.x + box.width - hw;
this._minY = box.y + hh;
this._maxY = box.y + box.height - hh;
if (this._minX > this._maxX) {
const midX = box.x + box.width * 0.5;
this._minX = midX;
this._maxX = midX;
}
if (this._minY > this._maxY) {
const midY = box.y + box.height * 0.5;
this._minY = midY;
this._maxY = midY;
}
this._hasBounds = true;
}
/** Canvas / Widget 均不可用时,用可见区域与父节点 UITransform 估算世界包围盒 */
private _fallbackScreenBox(): Rect | null {
let anchor: Node | null = this.node.parent;
while (anchor?.isValid && !anchor.getComponent(UITransform)) {
anchor = anchor.parent;
}
const ut = anchor?.getComponent(UITransform);
if (!ut) return null;
const visible = view.getVisibleSize();
const halfW = visible.width * 0.5;
const halfH = visible.height * 0.5;
const center = ut.convertToWorldSpaceAR(new Vec3(0, 0, 0));
return new Rect(center.x - halfW, center.y - halfH, visible.width, visible.height);
}
/**
* 把掉落中心钳在屏幕内;碰到左右边时反向水平速度。
* @param pos 世界坐标
* @param bounce 是否反弹速度
*/
private _clampInScreen(pos: Vec3, bounce: boolean): void {
if (!this._hasBounds) return;
if (pos.x < this._minX) {
pos.x = this._minX;
if (bounce) this._vx = Math.abs(this._vx);
} else if (pos.x > this._maxX) {
pos.x = this._maxX;
if (bounce) this._vx = -Math.abs(this._vx);
}
if (pos.y < this._minY) {
pos.y = this._minY;
if (bounce && this._vy < 0) this._vy = 0;
} else if (pos.y > this._maxY) {
pos.y = this._maxY;
if (bounce && this._vy > 0) this._vy = 0;
}
}
/** 暂停列表:仅展示,关闭交互 */
playDisplay(): void {
Tween.stopAllByTarget(this.node);
const op = this.node.getComponent(UIOpacity);
if (op) Tween.stopAllByTarget(op);
this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this._setClickable(false);
this._phase = DropPhase.Idle;
this._battle = null;
this._pauseBtn = null;
this._needClick = false;
this._vx = 0;
this._vy = 0;
this.node.angle = 0;
this.node.setScale(1, 1, 1);
this._setOpacity(255);
}
/** 停动画、解点击、复位运动状态(回池前调用) */
resetMotion(): void {
this._stopMotion();
WeaponDropView.clearSideMark(this.node);
this._phase = DropPhase.Idle;
this._battle = null;
this._pauseBtn = null;
this._needClick = false;
this._vx = 0;
this._vy = 0;
this._hasBounds = false;
}
/** 停 tween / 点击;销毁过程中子节点可能已空,禁止 find */
private _stopMotion(): void {
const node = this.node;
if (!node?.isValid) return;
Tween.stopAllByTarget(node);
const op = node.getComponent(UIOpacity);
if (op) Tween.stopAllByTarget(op);
node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this._setClickable(false);
}
protected update(dt: number): void {
if (this._phase !== DropPhase.Drop) return;
if (this._battle?.IsGamePause) return;
const node = this.node;
if (!node?.isValid) return;
this._vy += GRAVITY * dt;
node.getWorldPosition(this._pos);
this._pos.x += this._vx * dt;
this._pos.y += this._vy * dt;
this._clampInScreen(this._pos, true);
if (this._pos.y <= this._groundY && this._vy < 0) {
this._pos.y = this._groundY;
this._vy *= -BOUNCE;
this._vx *= GROUND_FRICTION;
this._clampInScreen(this._pos, true);
if (Math.abs(this._vy) < SETTLE_SPEED && Math.abs(this._vx) < SETTLE_SPEED) {
this._pos.y = this._groundY;
this._clampInScreen(this._pos, false);
node.setWorldPosition(this._pos);
node.angle = 0;
this._onLanded();
return;
}
}
node.setWorldPosition(this._pos);
node.angle = 0;
}
protected onDestroy(): void {
this._stopMotion();
this._phase = DropPhase.Idle;
this._battle = null;
this._pauseBtn = null;
this._needClick = false;
}
/** 落地:敌方自动飞入,己方等待点击 */
private _onLanded(): void {
this._vx = 0;
this._vy = 0;
if (this._needClick) {
this._phase = DropPhase.WaitClick;
this._setClickable(true);
this.node.on(Node.EventType.TOUCH_END, this._onTouchEnd, this);
return;
}
this._startFly();
}
/** 玩家点击拾取 */
private _handleClick(): void {
if (this._phase !== DropPhase.WaitClick) return;
this.node.off(Node.EventType.TOUCH_END, this._onTouchEnd, this);
this._setClickable(false);
this._startFly();
}
/** 飞向暂停按钮后渐隐回池 */
private _startFly(): void {
const node = this.node;
const target = this._pauseBtn;
if (!node?.isValid) return;
this._phase = DropPhase.Fly;
if (!target?.isValid) {
console.log('[WeaponDrop] 暂停按钮无效,直接回收');
this._recycle();
return;
}
Tween.stopAllByTarget(node);
tween(node)
.to(FLY_SEC, { worldPosition: target.worldPosition.clone(), scale: new Vec3(0.45, 0.45, 1) }, { easing: 'sineIn' })
.call(this._onFlyDone)
.start();
}
/** 到达暂停按钮后渐隐 */
private _finishFly(): void {
if (!this.node?.isValid) return;
const op = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
Tween.stopAllByTarget(op);
op.opacity = 255;
tween(op).to(FADE_SEC, { opacity: 0 }).call(this._onFadeDone).start();
}
/** 回池 */
private _recycle(): void {
if (!this.node?.isValid) return;
this.resetMotion();
this._setOpacity(255);
this.node.setScale(1, 1, 1);
this.node.angle = 0;
gg.res.putNode(this.node);
}
/**
* 设置透明度。
* @param value 0~255
*/
private _setOpacity(value: number): void {
const op = this.node.getComponent(UIOpacity) ?? this.node.addComponent(UIOpacity);
op.opacity = value;
}
/**
* 开关点击拾取。
* @param on 是否可点
*/
private _setClickable(on: boolean): void {
const node = this.node;
if (!node?.isValid) return;
let btn = node.getComponent(Button);
if (on && !btn) btn = node.addComponent(Button);
if (!btn) return;
btn.transition = Button.Transition.NONE;
btn.interactable = on;
btn.enabled = on;
}
}
/**
* 击杀武器碎片掉落表现。
*/
export class BattleWeaponDropPresenter {
private _battle: ProjectBattleHandle | null = null;
private _flyLayer: Node | null = null;
private _pauseBtn: Node | null = null;
private _resolveDeathWorldPos: ((victim: EntityId) => Vec3 | null) | null = null;
/**
* 绑定战斗、飞行层、暂停按钮与死亡坐标。
* @param battle 当前战斗
* @param flyLayer 飞行层
* @param pauseBtn 暂停按钮
* @param resolveDeathWorldPos 死亡世界坐标
*/
bind(battle: ProjectBattleHandle | null, flyLayer: Node | null, pauseBtn: Node | null, resolveDeathWorldPos: (victim: EntityId) => Vec3 | null): void {
this.unbind();
this._battle = battle;
this._flyLayer = flyLayer;
this._pauseBtn = pauseBtn;
this._resolveDeathWorldPos = resolveDeathWorldPos;
}
/** 注销并回收场上掉落节点 */
unbind(): void {
this._recycleAlive();
this._battle = null;
this._flyLayer = null;
this._pauseBtn = null;
this._resolveDeathWorldPos = null;
}
/**
* 击杀回调:取出待播掉落并生成世界节点。
* @param victim 死亡实体
*/
onEntityKilled(victim: EntityId): void {
const battle = this._battle;
if (!battle) return;
const rec = battle.weaponDrop.shiftPendingVisual();
if (!rec) return;
const from = this._resolveDeathWorldPos?.(victim);
if (!from) {
console.log('[WeaponDrop] 无死亡坐标', victim, rec.weaponId);
return;
}
this._spawnWorld(battle, rec, from);
}
/**
* 在死亡点生成世界掉落。
* @param battle 当前战斗
* @param record 掉落记录
* @param startWorld 死亡世界坐标
*/
private _spawnWorld(battle: ProjectBattleHandle, record: WeaponDropRecord, startWorld: Readonly<Vec3>): void {
const layer = this._flyLayer;
if (!layer?.isValid) {
console.log('[WeaponDrop] flyLayer 未绑定');
return;
}
const node = WeaponDropView.takePrefab();
if (!node) return;
if (!layer.active) layer.active = true;
layer.addChild(node);
WeaponDropView.applyVisual(node, record);
const view = node.getComponent(WeaponDropView) ?? node.addComponent(WeaponDropView);
view.playWorldDrop(battle, this._pauseBtn, startWorld, record.isSelf);
BattleAudio.playFragmentDrop();
}
/** 回收飞行层上尚未回池的掉落节点 */
private _recycleAlive(): void {
const layer = this._flyLayer;
if (!layer?.isValid) return;
const children = layer.children.slice();
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (!node?.isValid) continue;
const view = node.getComponent(WeaponDropView);
if (!view) continue;
view.resetMotion();
gg.res.putNode(node);
}
}
}