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.
370 lines
13 KiB
370 lines
13 KiB
|
1 week ago
|
import { Color, Graphics, Node, Sorting2D, 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 FRAME_PATH = 'prefab/障碍物/';
|
||
|
|
/** 引导虚线框预制体名 */
|
||
|
|
const FRAME_NAME = '引导虚线框';
|
||
|
|
/** 虚线框 Spine 动画名 */
|
||
|
|
const FRAME_ANIM = 'animation';
|
||
|
|
/** 连线层节点名 */
|
||
|
|
const LINE_LAYER = 'ui_areaUnlockGuideLine';
|
||
|
|
/** 黑白交替每段长度(对齐效果图) */
|
||
|
|
const DASH_LEN = 12;
|
||
|
|
/** 斑马段半宽(实心矩形,避免细 stroke 在深色底上看不见) */
|
||
|
|
const DASH_HALF_W = 2.5;
|
||
|
|
/** 描边相对半宽加宽,保证黑/白段在任意底上都可见 */
|
||
|
|
const DASH_OUTLINE_EXTRA = 1.5;
|
||
|
|
/** 背包端取字块顶边中点的向上偏移 */
|
||
|
|
const BAG_TOP_PAD = 2;
|
||
|
|
/** 气泡端取底边中点的向下偏移 */
|
||
|
|
const TIP_BOTTOM_PAD = 2;
|
||
|
|
/** 虚线框须压在字形之上(textGrid skin Sorting2D order=3,canUpEff=4) */
|
||
|
|
const FRAME_SORT_LAYER = 3;
|
||
|
|
/** 虚线框 Sorting2D order */
|
||
|
|
const FRAME_SORT_ORDER = 6;
|
||
|
|
/** 交替色:黑 */
|
||
|
|
const DASH_BLACK = new Color(15, 15, 15, 255);
|
||
|
|
/** 交替色:白 */
|
||
|
|
const DASH_WHITE = new Color(255, 255, 255, 255);
|
||
|
|
|
||
|
|
/** 世界坐标复用 */
|
||
|
|
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;
|
||
|
|
/** 槽位是否可拖(开启且非待开启) */
|
||
|
|
canDragSlot: (slotIndex: number) => boolean;
|
||
|
|
/** 为 true 时只清不显(拖动中 / 入场中) */
|
||
|
|
skip?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 背包可解锁当前地图区域时:随机标一个背包字 + 连到格子锁气泡。
|
||
|
|
*/
|
||
|
|
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;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 绑定连线层父节点(须同时覆盖地图与背包,如 BattleGame 根)。
|
||
|
|
* @param parent 父节点
|
||
|
|
*/
|
||
|
|
bind(parent: Node | null): void {
|
||
|
|
this.unbind();
|
||
|
|
if (!parent?.isValid) return;
|
||
|
|
this._parent = parent;
|
||
|
|
this._ensureLineLayer();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 释放节点与池资源 */
|
||
|
|
unbind(): void {
|
||
|
|
this.clear();
|
||
|
|
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 {
|
||
|
|
this._slotIndex = null;
|
||
|
|
this._releaseFrame();
|
||
|
|
if (this._graphics?.isValid) this._graphics.clear();
|
||
|
|
if (this._lineLayer?.isValid) this._lineLayer.active = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 按背包与当前解锁条件刷新引导。
|
||
|
|
* 原槽仍可解锁时保持不变,避免频繁乱跳。
|
||
|
|
*/
|
||
|
|
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);
|
||
|
|
if (!grid?.node?.isValid) {
|
||
|
|
this.clear();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!this._attachFrame(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;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 把虚线框挂到背包字块上并循环播放。
|
||
|
|
* @param host 字块节点
|
||
|
|
*/
|
||
|
|
private _attachFrame(host: Node): boolean {
|
||
|
|
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 = host.layer;
|
||
|
|
if (frame.parent !== host) {
|
||
|
|
frame.parent = host;
|
||
|
|
frame.setPosition(0, 0, 0);
|
||
|
|
frame.setScale(1, 1, 1);
|
||
|
|
}
|
||
|
|
frame.setSiblingIndex(host.children.length - 1);
|
||
|
|
this._applyFrameSorting(frame);
|
||
|
|
frame.active = true;
|
||
|
|
const sk = frame.getComponent(sp.Skeleton) ?? frame.getComponentInChildren(sp.Skeleton);
|
||
|
|
if (sk?.isValid) {
|
||
|
|
sk.node.layer = host.layer;
|
||
|
|
BattleSpinePlayback.play(sk, FRAME_ANIM, { loop: true, optional: true });
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 虚线框加 Sorting2D,避免被 textGrid 字形盖住。
|
||
|
|
* @param frame 引导虚线框
|
||
|
|
*/
|
||
|
|
private _applyFrameSorting(frame: Node): void {
|
||
|
|
let rootSort = frame.getComponent(Sorting2D);
|
||
|
|
if (!rootSort) rootSort = frame.addComponent(Sorting2D);
|
||
|
|
rootSort.sortingLayer = FRAME_SORT_LAYER;
|
||
|
|
rootSort.sortingOrder = FRAME_SORT_ORDER;
|
||
|
|
const skels = frame.getComponentsInChildren(sp.Skeleton);
|
||
|
|
for (let i = 0; i < skels.length; i++) {
|
||
|
|
const n = skels[i]?.node;
|
||
|
|
if (!n?.isValid) continue;
|
||
|
|
let sort = n.getComponent(Sorting2D);
|
||
|
|
if (!sort) sort = n.addComponent(Sorting2D);
|
||
|
|
sort.sortingLayer = FRAME_SORT_LAYER;
|
||
|
|
sort.sortingOrder = FRAME_SORT_ORDER;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 归还虚线框 */
|
||
|
|
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._lineLayer = layer;
|
||
|
|
this._graphics = layer.getComponent(Graphics);
|
||
|
|
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;
|
||
|
|
|
||
|
|
const bagUt = bagNode.getComponent(UITransform);
|
||
|
|
const tipUt = tip.getComponent(UITransform);
|
||
|
|
const bagHalfH = bagUt ? bagUt.contentSize.height * 0.5 * Math.abs(bagNode.worldScale.y) : 30;
|
||
|
|
const tipHalfH = tipUt ? tipUt.contentSize.height * 0.5 * Math.abs(tip.worldScale.y) : 20;
|
||
|
|
|
||
|
|
_bagWorld.set(bagNode.worldPosition.x, bagNode.worldPosition.y + bagHalfH + BAG_TOP_PAD, bagNode.worldPosition.z);
|
||
|
|
_tipWorld.set(tip.worldPosition.x, tip.worldPosition.y - tipHalfH - TIP_BOTTOM_PAD, tip.worldPosition.z);
|
||
|
|
layerUt.convertToNodeSpaceAR(_bagWorld, _fromLocal);
|
||
|
|
layerUt.convertToNodeSpaceAR(_tipWorld, _toLocal);
|
||
|
|
|
||
|
|
g.clear();
|
||
|
|
this._fillZebraDash(g, _fromLocal.x, _fromLocal.y, _toLocal.x, _toLocal.y);
|
||
|
|
layer.active = true;
|
||
|
|
layer.setSiblingIndex(layer.parent!.children.length - 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 黑白段交替实心绳:每段先画反色描边再画本色,深色/浅色底上都清晰。
|
||
|
|
* @param g Graphics
|
||
|
|
* @param x1 起点 x
|
||
|
|
* @param y1 起点 y
|
||
|
|
* @param x2 终点 x
|
||
|
|
* @param y2 终点 y
|
||
|
|
*/
|
||
|
|
private _fillZebraDash(g: Graphics, x1: number, y1: number, x2: number, y2: 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;
|
||
|
|
let d = 0;
|
||
|
|
let useBlack = true;
|
||
|
|
while (d < len) {
|
||
|
|
const d1 = Math.min(d + DASH_LEN, len);
|
||
|
|
const fill = useBlack ? DASH_BLACK : DASH_WHITE;
|
||
|
|
const outline = useBlack ? DASH_WHITE : DASH_BLACK;
|
||
|
|
this._fillDashSeg(g, x1, y1, ux, uy, d, d1, DASH_HALF_W + DASH_OUTLINE_EXTRA, outline);
|
||
|
|
this._fillDashSeg(g, x1, y1, ux, uy, d, d1, DASH_HALF_W, fill);
|
||
|
|
d = d1;
|
||
|
|
useBlack = !useBlack;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 沿路径画一段实心矩形(比 stroke 短段更稳、更粗)。
|
||
|
|
* @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();
|
||
|
|
}
|
||
|
|
}
|