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.log('[WeaponDrop] 武器表缺失', record.weaponId); return; } const bgIndex = WeaponDropView._qualityBgIndex(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.log('[WeaponDrop] icon 缺失', record.weaponId); } const icon = node.getChildByName(ICON_NODE); if (!icon?.isValid) { console.log('[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.log('[WeaponDrop] 武器表缺失', record.weaponId); return; } const bgIndex = WeaponDropView._qualityBgIndex(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.log('[WeaponDrop] icon 缺失', record.weaponId); } const icon = node.getChildByName(SETTLE_ICON_NODE); if (!icon?.isValid) { console.log('[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 0~4 → 武器底 0~4(白/绿/蓝/紫/橙) */ private static _qualityBgIndex(quality: number, weaponId: number): number { const q = Number(quality) | 0; if (q < 0 || q > 4) { console.log('[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, 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): 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); } } }