import { _decorator, Color, Component, Graphics, Node, UITransform, Vec3, isValid, sp } from 'cc'; import { isElementGlyphType } from 'db://assets/mx/module/merge'; import { GBundle } from '../../game/ConfigRes'; import { AI_VIEW_LOCAL_CAMP } from '../ai/runtime/AiCampArea'; import type { BattleBagSession } from '../bag/BattleBagSession'; import { WeaponComposeConfigProvider } from '../bag/WeaponComposeConfigProvider'; import type { MapManager } from '../map/legacy/MapManager'; import { BattleSpinePlayback } from '../presentation/BattleSpinePlayback'; import type { TextGrid } from './TextGrid'; const { ccclass } = _decorator; /** 引导虚线框预制体路径 */ const FRAME_PATH = 'prefab/障碍物/'; /** 引导虚线框预制体名 */ const FRAME_NAME = '引导虚线框'; /** 虚线框 Spine 动画名 */ const FRAME_ANIM = 'animation'; /** 连线层节点名 */ const LINE_LAYER = 'ui_areaUnlockGuideLine'; /** 黑白交替每段长度(对齐效果图) */ const DASH_LEN = 12; /** 斑马段半宽 */ const DASH_HALF_W = 2.5; /** 描边相对半宽加宽 */ const DASH_OUTLINE_EXTRA = 1.5; /** 流动速度:像素/秒(背包→地图) */ const FLOW_SPEED = 48; /** 交替色:黑 */ const DASH_BLACK = new Color(15, 15, 15, 255); /** 交替色:白 */ const DASH_WHITE = new Color(255, 255, 255, 255); /** * 连线层每帧推进斑马相位。 */ @ccclass('AreaUnlockGuideFlowTick') class AreaUnlockGuideFlowTick extends Component { /** 宿主 */ host: AreaUnlockGuidePresenter | null = null; /** * @param dt 帧间隔 */ update(dt: number): void { this.host?.onFlowTick(dt); } } /** 世界坐标复用 */ const _bagWorld = new Vec3(); /** 气泡世界坐标复用 */ const _tipWorld = new Vec3(); /** 本地起点复用 */ const _fromLocal = new Vec3(); /** 本地终点复用 */ const _toLocal = new Vec3(); /** 解锁引导刷新入参 */ export interface AreaUnlockGuideRefresh { /** 本机背包会话 */ bagSession: BattleBagSession; /** 地图 */ map: MapManager; /** 阵营 CampAreaID;默认本机下方 */ campAreaId?: number; /** 取背包槽字块 */ slotGridAt: (slotIndex: number) => TextGrid | null; /** 取背包槽位节点(虚线框挂这里,禁止挂 TextGrid,避免合成叠字) */ slotNodeAt: (slotIndex: number) => Node | null; /** 槽位是否可拖(开启且非待开启) */ canDragSlot: (slotIndex: number) => boolean; /** 为 true 时只清不显(拖动中 / 入场中 / 落子飞行中) */ skip?: boolean; } /** * 背包可解锁当前地图区域时:随机标一个背包字 + 连到格子锁气泡。 * 虚线框挂在槽位节点上,不进入 TextGrid 子树,避免合成/回收把框和字皮搅在一起。 */ export class AreaUnlockGuidePresenter { /** 连线层父节点 */ private _parent: Node | null = null; /** Graphics 连线层 */ private _lineLayer: Node | null = null; private _graphics: Graphics | null = null; /** 背包虚线框 */ private _frame: Node | null = null; /** 当前标识的背包槽;null 表示未展示 */ private _slotIndex: number | null = null; /** 连线本地起点 x */ private _fromX = 0; /** 连线本地起点 y */ private _fromY = 0; /** 连线本地终点 x */ private _toX = 0; /** 连线本地终点 y */ private _toY = 0; /** 斑马流动相位(像素) */ private _flowPhase = 0; /** 是否正在播流动 */ private _flowing = false; /** * 绑定连线层父节点(须同时覆盖地图与背包,如 BattleGame 根)。 * @param parent 父节点 */ bind(parent: Node | null): void { this.unbind(); if (!parent?.isValid) return; this._parent = parent; this._ensureLineLayer(); } /** 释放节点与池资源 */ unbind(): void { this.clear(); const tick = this._lineLayer?.getComponent(AreaUnlockGuideFlowTick); if (tick) tick.host = null; if (this._lineLayer?.isValid) { this._lineLayer.destroy(); } this._lineLayer = null; this._graphics = null; this._parent = null; } /** 当前标识的背包槽;无引导时 null */ get slotIndex(): number | null { return this._slotIndex; } /** 立刻隐藏虚线框与连线 */ clear(): void { const prevHost = this._frame?.parent; this._slotIndex = null; this._stopFlow(); this._releaseFrame(); if (prevHost?.isValid) this._purgeOrphanFrames(prevHost); if (this._graphics?.isValid) this._graphics.clear(); if (this._lineLayer?.isValid) this._lineLayer.active = false; } /** * 连线层帧 tick:推进相位并重绘。 * @param dt 帧间隔 */ onFlowTick(dt: number): void { if (!this._flowing) return; if (!this._lineLayer?.isValid || !this._lineLayer.active) return; if (!this._graphics?.isValid) return; const step = Math.max(0, Number(dt) || 0) * FLOW_SPEED; if (step <= 0) return; // 相位减小:斑马段沿起点(背包)→终点(地图)滑动 this._flowPhase -= step; const period = DASH_LEN * 2; if (this._flowPhase < 0) this._flowPhase = (this._flowPhase % period + period) % period; this._redrawLine(); } /** * 按背包与当前解锁条件刷新引导。 * 原槽仍可解锁时保持不变,避免频繁乱跳。 */ refresh(opts: AreaUnlockGuideRefresh): void { if (opts.skip || !this._parent?.isValid) { this.clear(); return; } const camp = Math.floor(Number(opts.campAreaId) || AI_VIEW_LOCAL_CAMP); const tip = opts.map.areaUnlock.tipOf(camp); if (!tip?.isValid) { this.clear(); return; } const current = opts.map.areaUnlock.currentId(camp); if (current <= 0) { this.clear(); return; } const unlockGrids = opts.map.areaUnlock.gridsOf(current, camp); if (unlockGrids.length <= 0) { this.clear(); return; } const probeIndex = unlockGrids[0].Index; const candidates = this._collectCandidates(opts, probeIndex); if (candidates.length <= 0) { this.clear(); return; } let slot = this._slotIndex; if (slot == null || !this._listHas(candidates, slot)) { slot = candidates[Math.floor(Math.random() * candidates.length)]; } const grid = opts.slotGridAt(slot); const slotNode = opts.slotNodeAt(slot); if (!grid?.node?.isValid || !slotNode?.isValid) { this.clear(); return; } if (!this._attachFrame(slotNode, grid.node)) { this.clear(); return; } this._slotIndex = slot; this._drawLine(grid.node, tip); } /** * 收集可解锁当前区域的背包槽。 * @param opts 刷新入参 * @param probeIndex 探测用地图格 */ private _collectCandidates(opts: AreaUnlockGuideRefresh, probeIndex: number): number[] { const out: number[] = []; WeaponComposeConfigProvider.instance.load(); const provider = WeaponComposeConfigProvider.instance; const slots = opts.bagSession.getBagSlots(); for (let i = 0; i < slots.length; i++) { const slot = slots[i]; if (!slot) continue; if (!opts.canDragSlot(i)) continue; const t = Number(provider.getWeapon2Type(slot.weapon2Id)); if (!isElementGlyphType(t)) continue; if (!opts.slotGridAt(i)?.node?.isValid) continue; if (!opts.bagSession.canUnlockArea(probeIndex, slot.weapon2Id, slot.level)) continue; out.push(i); } return out; } /** * @param list 候选槽 * @param slot 槽下标 */ private _listHas(list: readonly number[], slot: number): boolean { for (let i = 0; i < list.length; i++) { if (list[i] === slot) return true; } return false; } /** * 虚线框挂到背包槽位(不是 TextGrid),与字块同中心,不参与合成节点树。 * @param slotNode 背包槽 * @param glyphNode 字块(仅用于对齐位置) */ private _attachFrame(slotNode: Node, glyphNode: Node): boolean { this._purgeOrphanFrames(slotNode); this._purgeOrphanFrames(glyphNode); if (!this._frame?.isValid) { if (typeof gg === 'undefined' || !gg.res?.getNode) { console.warn('[AreaUnlockGuide] gg.res 未就绪,无法加载引导虚线框'); return false; } const node = gg.res.getNode(FRAME_PATH, FRAME_NAME, GBundle.BattleMap); if (!node?.isValid) { console.warn('[AreaUnlockGuide] 缺少引导虚线框预制体', FRAME_NAME); return false; } this._frame = node; } const frame = this._frame; if (!frame.isValid) return false; frame.layer = slotNode.layer; if (frame.parent !== slotNode) { frame.parent = slotNode; } // 与槽内字块同局部坐标,避免挂 TextGrid 导致合成叠字 frame.setPosition(glyphNode.position.x, glyphNode.position.y, glyphNode.position.z); frame.setScale(1, 1, 1); frame.setSiblingIndex(slotNode.children.length - 1); this._disableNodeHit(frame); frame.active = true; const sk = frame.getComponent(sp.Skeleton) ?? frame.getComponentInChildren(sp.Skeleton); if (sk?.isValid) { sk.node.layer = slotNode.layer; BattleSpinePlayback.play(sk, FRAME_ANIM, { loop: true, optional: true }); } return true; } /** * 关闭节点树命中,防止 Spine/UITransform 截获拖拽。 * @param root 虚线框或连线层 */ private _disableNodeHit(root: Node): void { const uts = root.getComponentsInChildren(UITransform); for (let i = 0; i < uts.length; i++) { const ut = uts[i]; if (!ut) continue; (ut as UITransform & { hitTest: () => boolean }).hitTest = () => false; } } /** * 清掉残留虚线框(含误挂在 TextGrid 上的旧实现残留)。 * @param host 槽位或字块 */ private _purgeOrphanFrames(host: Node): void { const children = host.children; for (let i = children.length - 1; i >= 0; i--) { const child = children[i]; if (!child?.isValid || child.name !== FRAME_NAME) continue; if (child === this._frame) continue; if (child['resKey']) gg.res.putNode(child); else child.destroy(); } } /** 归还虚线框 */ private _releaseFrame(): void { const frame = this._frame; this._frame = null; if (!frame || !isValid(frame)) return; const sk = frame.getComponent(sp.Skeleton) ?? frame.getComponentInChildren(sp.Skeleton); if (sk?.isValid) { BattleSpinePlayback.clearPendingComplete(sk); BattleSpinePlayback.resetTracks(sk); } if (frame['resKey']) gg.res.putNode(frame); else frame.destroy(); } /** 确保 Graphics 连线层 */ private _ensureLineLayer(): void { const parent = this._parent; if (!parent?.isValid) return; let layer = parent.getChildByName(LINE_LAYER); if (!layer) { layer = new Node(LINE_LAYER); layer.layer = parent.layer; layer.parent = parent; layer.setPosition(0, 0, 0); const ut = layer.addComponent(UITransform); const parentUt = parent.getComponent(UITransform); if (parentUt) ut.setContentSize(parentUt.contentSize); else ut.setContentSize(2000, 2000); ut.setAnchorPoint(0.5, 0.5); layer.addComponent(Graphics); this._disableNodeHit(layer); } this._lineLayer = layer; this._graphics = layer.getComponent(Graphics); let tick = layer.getComponent(AreaUnlockGuideFlowTick); if (!tick) tick = layer.addComponent(AreaUnlockGuideFlowTick); tick.host = this; this._disableNodeHit(layer); layer.active = false; } /** * 背包字中心 → 地图待解锁格中心(气泡已挂在格心)。 * @param bagNode 背包字块 * @param tip 格子锁气泡 */ private _drawLine(bagNode: Node, tip: Node): void { const layer = this._lineLayer; const g = this._graphics; if (!layer?.isValid || !g?.isValid) return; const layerUt = layer.getComponent(UITransform); if (!layerUt) return; _bagWorld.set(bagNode.worldPosition.x, bagNode.worldPosition.y, bagNode.worldPosition.z); _tipWorld.set(tip.worldPosition.x, tip.worldPosition.y, tip.worldPosition.z); layerUt.convertToNodeSpaceAR(_bagWorld, _fromLocal); layerUt.convertToNodeSpaceAR(_tipWorld, _toLocal); const nx = _fromLocal.x; const ny = _fromLocal.y; const mx = _toLocal.x; const my = _toLocal.y; const moved = Math.abs(nx - this._fromX) > 1 || Math.abs(ny - this._fromY) > 1 || Math.abs(mx - this._toX) > 1 || Math.abs(my - this._toY) > 1; this._fromX = nx; this._fromY = ny; this._toX = mx; this._toY = my; if (!this._flowing || moved) this._flowPhase = 0; this._redrawLine(); layer.active = true; layer.setSiblingIndex(layer.parent!.children.length - 1); this._flowing = true; } /** 按当前相位重绘斑马线 */ private _redrawLine(): void { const g = this._graphics; if (!g?.isValid) return; g.clear(); this._fillZebraDash(g, this._fromX, this._fromY, this._toX, this._toY, this._flowPhase); } /** 停止流动(清相位,保留层节点) */ private _stopFlow(): void { this._flowing = false; this._flowPhase = 0; } /** * 黑白段交替实心绳:相位沿背包→地图方向滑动。 * @param g Graphics * @param x1 起点 x * @param y1 起点 y * @param x2 终点 x * @param y2 终点 y * @param phase 流动相位(像素) */ private _fillZebraDash(g: Graphics, x1: number, y1: number, x2: number, y2: number, phase: number): void { const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx * dx + dy * dy); if (len < 1) return; const ux = dx / len; const uy = dy / len; const period = DASH_LEN * 2; const offset = ((phase % period) + period) % period; let d = -offset; let useBlack = true; while (d < len) { const d0 = Math.max(d, 0); const d1 = Math.min(d + DASH_LEN, len); if (d1 > d0) { const fill = useBlack ? DASH_BLACK : DASH_WHITE; const outline = useBlack ? DASH_WHITE : DASH_BLACK; this._fillDashSeg(g, x1, y1, ux, uy, d0, d1, DASH_HALF_W + DASH_OUTLINE_EXTRA, outline); this._fillDashSeg(g, x1, y1, ux, uy, d0, d1, DASH_HALF_W, fill); } d += DASH_LEN; useBlack = !useBlack; } } /** * 沿路径画一段实心矩形。 * @param g Graphics * @param x1 路径起点 x * @param y1 路径起点 y * @param ux 单位方向 x * @param uy 单位方向 y * @param d0 段起点路程 * @param d1 段终点路程 * @param halfW 半宽 * @param color 填充色 */ private _fillDashSeg(g: Graphics, x1: number, y1: number, ux: number, uy: number, d0: number, d1: number, halfW: number, color: Color): void { const ax = x1 + ux * d0; const ay = y1 + uy * d0; const bx = x1 + ux * d1; const by = y1 + uy * d1; const px = -uy * halfW; const py = ux * halfW; g.fillColor = color; g.moveTo(ax + px, ay + py); g.lineTo(bx + px, by + py); g.lineTo(bx - px, by - py); g.lineTo(ax - px, ay - py); g.close(); g.fill(); } }